mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
feat(ski-routing): plan ski tours on the piste graphs of TREK Places
A new Nest domain plans the legs of a ski and winter tour on the runs, lifts, cross-country trails, ski touring and winter paths the Places host builds from OpenStreetMap, one SGR1 graph per ski area. TREK fetches the index once, downloads an area by its id when a planner first plans in it, checks it against the index's SHA-256 and keeps it in data/ski; no clicked point ever leaves the server. A leg whose area is slower than two seconds answers loading, and an older copy stands in while the host is down. The reader decodes the writer's golden file byte for byte, including the two edge flags it added (area_has_run, type_unknown). The search is A* in a worker thread over a profile per discipline (alpine, snowboard, cross-country by technique, touring, snowshoe, winter hike) with the times and penalties of the cost model, up to five snap candidates per point, lift stations as entry points, legs that go on from each other, pieces of a split network stitched by their boundary nodes, other ways by the penalty method, and a failed leg explained by relaxing one rule at a time (difficulty, lift class, technique, riding down, uphill) without ever handing out the relaxed line. POST /api/trips/:tripId/ski/route, GET .../ski/area-near and GET .../ski/network/:areaId sit behind Tours and the trip, with plan_ski_route as their MCP twin. A daily job looks for a new build and sweeps areas unused for 90 days. The admin switch ski_routing_enabled is on wherever TREK Places is.
This commit is contained in:
@@ -26,5 +26,8 @@
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
# Ski area graphs (SGR1) as the Places host writes them, byte for byte
|
||||
*.sgr binary
|
||||
*.gz binary
|
||||
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
|
||||
server/tests/fixtures/gpx/bom-crlf.gpx -text
|
||||
|
||||
@@ -101,6 +101,19 @@ export class AddonsService {
|
||||
return this.writeFlag('tours_cycle_map_enabled', enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin's switch for the piste router under Tours, as stored; fail-open, so it works
|
||||
* wherever TREK Places is enabled until an admin switches it off.
|
||||
*/
|
||||
async getToursSkiRouting() {
|
||||
const value = await readAppSetting(this.appSettings, 'ski_routing_enabled');
|
||||
return { enabled: value !== 'false' };
|
||||
}
|
||||
|
||||
async updateToursSkiRouting(enabled: boolean) {
|
||||
return this.writeFlag('ski_routing_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;
|
||||
|
||||
@@ -342,6 +342,25 @@ export class AdminController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('tours-ski-routing')
|
||||
@ResponseContract(adminFeatureToggleResponseSchema)
|
||||
getToursSkiRouting() {
|
||||
return this.addons.getToursSkiRouting();
|
||||
}
|
||||
|
||||
@Put('tours-ski-routing')
|
||||
@ResponseContract(adminFeatureToggleResponseSchema)
|
||||
async updateToursSkiRouting(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = await this.addons.updateToursSkiRouting(body.enabled);
|
||||
await this.audit.writeAudit({
|
||||
userId: user.id,
|
||||
action: 'admin.tours_ski_routing',
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: result.enabled },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('places-photos')
|
||||
getPlacesPhotos() {
|
||||
return this.addons.getPlacesPhotos();
|
||||
|
||||
@@ -86,6 +86,7 @@ import { SchoolHolidaysModule } from './school-holidays/school-holidays.module';
|
||||
import { SessionsModule } from './sessions/sessions.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { SkiRoutingModule } from './ski-routing/ski-routing.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
import { SystemNoticesModule } from './system-notices/system-notices.module';
|
||||
import { TagsModule } from './tags/tags.module';
|
||||
@@ -160,6 +161,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
|
||||
FerratasModule,
|
||||
PaddleObstaclesModule,
|
||||
PaddleRoutingModule,
|
||||
SkiRoutingModule,
|
||||
TripsModule,
|
||||
CollabModule,
|
||||
FilesModule,
|
||||
|
||||
@@ -116,6 +116,9 @@ export const APP_SETTINGS = {
|
||||
// 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' },
|
||||
// The piste router for ski tours, on wherever TREK Places is: it only downloads the ski areas a
|
||||
// planner plans in, and no clicked point leaves the server. The switch is the operator's way out.
|
||||
ski_routing_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' },
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readEnv } from '../../app-config';
|
||||
import {
|
||||
trekPlacesWinterArea,
|
||||
trekPlacesWinterIndex,
|
||||
trekPlacesWinterMeta,
|
||||
type TrekPlacesWinterBody,
|
||||
type TrekPlacesWinterIndex,
|
||||
} from './trek-places.client';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { WinterMeta } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The winter layer of the TREK Places host (`/v1/winter/*`) as a provider:
|
||||
* which build it serves, its index of ski areas, and one area's graph. Every
|
||||
* request goes through the same client as the place search, so the operator's
|
||||
* opt-out, the size caps, the timeouts and the circuit breaker hold for it
|
||||
* too. No request carries a coordinate. It holds no state of its own, so
|
||||
* the piste router's module provides it without importing the maps module.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TrekPlacesWinterService {
|
||||
/** Whether the operator lets anything go to TREK Places at all. */
|
||||
enabled(): boolean {
|
||||
return readEnv().maps.trekPlacesEnabled;
|
||||
}
|
||||
|
||||
meta(): Promise<WinterMeta> {
|
||||
return trekPlacesWinterMeta();
|
||||
}
|
||||
|
||||
index(etag: string | null): Promise<TrekPlacesWinterIndex> {
|
||||
return trekPlacesWinterIndex(etag);
|
||||
}
|
||||
|
||||
area(id: string, etag: string | null): Promise<TrekPlacesWinterBody> {
|
||||
return trekPlacesWinterArea(id, etag);
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,16 @@
|
||||
*/
|
||||
import { readEnv, getAppUrl } from '../../app-config';
|
||||
import { toApiLang } from './maps.helpers';
|
||||
import { normalizePlaceWebsite } from '@trek/shared';
|
||||
import {
|
||||
normalizePlaceWebsite,
|
||||
winterIndexRowSchema,
|
||||
winterMetaSchema,
|
||||
type WinterIndex,
|
||||
type WinterIndexRow,
|
||||
type WinterMeta,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
|
||||
/** The public instance. An operator may point at their own copy instead. */
|
||||
@@ -200,28 +208,68 @@ export function trekPlacesBaseUrl(): string {
|
||||
return (configured || DEFAULT_TREK_PLACES_URL).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* TREK_PLACES_ENABLED=false is the operator's word that nothing leaves for
|
||||
* the index. Every caller checks it before asking, and this is the one place
|
||||
* every request passes, so a caller that did not is still stopped here. Ahead
|
||||
* of the breaker: a refusal says nothing about reachability.
|
||||
*/
|
||||
function assertEnabled(): void {
|
||||
if (!readEnv().maps.trekPlacesEnabled) {
|
||||
throw new Error(SWITCHED_OFF_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a breaker holds requests back right now, for a caller with a batch
|
||||
* to stop early. The winter downloads are layer reads, so they ask the layers'.
|
||||
*/
|
||||
export function trekPlacesHeldBack(breaker: Breaker = layersBreaker): boolean {
|
||||
return Date.now() < breaker.openUntil;
|
||||
}
|
||||
|
||||
/** Refuses a request while its breaker holds them back. */
|
||||
function assertBreakerClosed(breaker: Breaker): void {
|
||||
if (Date.now() < breaker.openUntil) {
|
||||
throw new Error('TREK Places API unreachable, not retrying yet');
|
||||
}
|
||||
}
|
||||
|
||||
/** A failed request, counted against its breaker when it says something about reachability. */
|
||||
function noteFailure(err: unknown, breaker: Breaker): void {
|
||||
if (countsAgainstReachability(err)) {
|
||||
breaker.failures += 1;
|
||||
if (breaker.failures >= BREAKER_FAILURES_BEFORE_OPEN) {
|
||||
breaker.openUntil = Date.now() + BREAKER_COOLDOWN_MS;
|
||||
// Back to the threshold rather than to zero: when the cooldown ends,
|
||||
// one more failure re-opens the breaker instead of buying another four
|
||||
// full timeouts.
|
||||
breaker.failures = BREAKER_FAILURES_BEFORE_OPEN - 1;
|
||||
}
|
||||
} else {
|
||||
// The service answered. A 404 is an answer.
|
||||
breaker.failures = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function statusError(status: number): Error & { status: number } {
|
||||
const err = new Error(`TREK Places API ${status}`) as Error & { status: number };
|
||||
err.status = status;
|
||||
return err;
|
||||
}
|
||||
|
||||
async function getJson<T>(
|
||||
path: string,
|
||||
params: Record<string, string | number | undefined>,
|
||||
opts: { maxBytes?: number; timeoutMs?: number; breaker?: Breaker } = {},
|
||||
): Promise<T> {
|
||||
const breaker = opts.breaker ?? searchBreaker;
|
||||
// TREK_PLACES_ENABLED=false is the operator's word that nothing leaves for
|
||||
// the index. Every caller checks it before asking, and this is the one place
|
||||
// every request passes, so a caller that did not is still stopped here. Ahead
|
||||
// of the breaker: a refusal says nothing about reachability.
|
||||
if (!readEnv().maps.trekPlacesEnabled) {
|
||||
throw new Error(SWITCHED_OFF_MESSAGE);
|
||||
}
|
||||
|
||||
assertEnabled();
|
||||
const url = new URL(trekPlacesBaseUrl() + path);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
|
||||
}
|
||||
|
||||
if (Date.now() < breaker.openUntil) {
|
||||
throw new Error('TREK Places API unreachable, not retrying yet');
|
||||
}
|
||||
assertBreakerClosed(breaker);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? TIMEOUT_MS);
|
||||
@@ -233,11 +281,7 @@ async function getJson<T>(
|
||||
'X-TREK-Instance': instanceToken(),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = new Error(`TREK Places API ${res.status}`) as Error & { status: number };
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
if (!res.ok) throw statusError(res.status);
|
||||
// Read with a cap rather than res.json(): a hostile or broken upstream
|
||||
// should not be able to hand us an unbounded body to buffer.
|
||||
const text = await readCapped(res, opts.maxBytes ?? MAX_BYTES);
|
||||
@@ -246,19 +290,7 @@ async function getJson<T>(
|
||||
breaker.failures = 0;
|
||||
return parsed;
|
||||
} catch (err: unknown) {
|
||||
if (countsAgainstReachability(err)) {
|
||||
breaker.failures += 1;
|
||||
if (breaker.failures >= BREAKER_FAILURES_BEFORE_OPEN) {
|
||||
breaker.openUntil = Date.now() + BREAKER_COOLDOWN_MS;
|
||||
// Back to the threshold rather than to zero: when the cooldown ends,
|
||||
// one more failure re-opens the breaker instead of buying another four
|
||||
// full timeouts.
|
||||
breaker.failures = BREAKER_FAILURES_BEFORE_OPEN - 1;
|
||||
}
|
||||
} else {
|
||||
// The service answered. A 404 is an answer.
|
||||
breaker.failures = 0;
|
||||
}
|
||||
noteFailure(err, breaker);
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
@@ -286,8 +318,17 @@ export function serviceLang(lang: string | undefined): string | undefined {
|
||||
}
|
||||
|
||||
async function readCapped(res: Response, max: number): Promise<string> {
|
||||
if (!res.body) return res.text();
|
||||
return (await readCappedBytes(res, max)).toString('utf8');
|
||||
}
|
||||
|
||||
async function readCappedBytes(res: Response, max: number): Promise<Buffer> {
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) return res.text();
|
||||
if (!reader) {
|
||||
const whole = Buffer.from(await res.arrayBuffer());
|
||||
if (whole.length > max) throw new Error('TREK Places API response too large');
|
||||
return whole;
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
for (;;) {
|
||||
@@ -300,7 +341,7 @@ async function readCapped(res: Response, max: number): Promise<string> {
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -834,3 +875,109 @@ export async function trekPlacesRoutes(
|
||||
truncated: answer.data.truncated === true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Winter layer ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* What the winter layer answers a conditional request: nothing new since the
|
||||
* tag the caller holds, or the body as sent with its tag. None of its three
|
||||
* endpoints takes a coordinate, so the service learns only which area a
|
||||
* planner loaded, never where anyone clicked.
|
||||
*/
|
||||
export type TrekPlacesWinterBody = { status: 'not_modified' } | { status: 'ok'; bytes: Buffer; etag: string | null };
|
||||
|
||||
const WINTER_META = { timeoutMs: 5_000, maxBytes: 64 * 1024 };
|
||||
const WINTER_INDEX = { timeoutMs: 15_000, maxBytes: 8 * 1024 * 1024 };
|
||||
const WINTER_AREA = { timeoutMs: 30_000, maxBytes: 32 * 1024 * 1024 };
|
||||
const WINTER_AREA_ID = /^[a-z]:[0-9]+(-[0-9a-z]+)?$/;
|
||||
|
||||
/** An ETag without its quotes and weak mark. */
|
||||
function bareTag(value: string | null): string | null {
|
||||
let tag = value?.trim() ?? '';
|
||||
if (tag.startsWith('W/')) tag = tag.slice(2);
|
||||
if (tag.length >= 2 && tag.startsWith('"') && tag.endsWith('"')) tag = tag.slice(1, -1);
|
||||
return tag || null;
|
||||
}
|
||||
|
||||
/** The head of the index; its rows are read one by one. */
|
||||
const winterIndexHeadSchema = z.object({
|
||||
build: z.string().min(1).max(40),
|
||||
format: z.number().int(),
|
||||
areas: z.array(z.unknown()),
|
||||
});
|
||||
|
||||
async function getWinter(
|
||||
path: string,
|
||||
accept: string,
|
||||
etag: string | null,
|
||||
limits: { timeoutMs: number; maxBytes: number },
|
||||
): Promise<TrekPlacesWinterBody> {
|
||||
assertEnabled();
|
||||
const url = new URL(trekPlacesBaseUrl() + path);
|
||||
assertBreakerClosed(layersBreaker);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), limits.timeoutMs);
|
||||
try {
|
||||
const headers: Record<string, string> = { Accept: accept, 'X-TREK-Instance': instanceToken() };
|
||||
if (etag) headers['If-None-Match'] = `"${etag}"`;
|
||||
const res = await fetch(url, { signal: controller.signal, headers });
|
||||
if (res.status === 304 && etag) {
|
||||
layersBreaker.failures = 0;
|
||||
return { status: 'not_modified' };
|
||||
}
|
||||
if (!res.ok) throw statusError(res.status);
|
||||
const bytes = await readCappedBytes(res, limits.maxBytes);
|
||||
layersBreaker.failures = 0;
|
||||
return { status: 'ok', bytes, etag: bareTag(res.headers.get('etag')) };
|
||||
} catch (err: unknown) {
|
||||
noteFailure(err, layersBreaker);
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Which build the winter layer serves, and the SHA-256 that tells a new index from the one kept. */
|
||||
export async function trekPlacesWinterMeta(): Promise<WinterMeta> {
|
||||
const answer = await getWinter('/v1/winter/meta', 'application/json', null, WINTER_META);
|
||||
if (answer.status !== 'ok') throw new Error('TREK Places winter meta came back empty');
|
||||
return winterMetaSchema.parse(JSON.parse(answer.bytes.toString('utf8')));
|
||||
}
|
||||
|
||||
export type TrekPlacesWinterIndex =
|
||||
| { status: 'not_modified' }
|
||||
| { status: 'ok'; index: WinterIndex; sha256: string; dropped: number };
|
||||
|
||||
/**
|
||||
* Every ski area with its box, size and checksum, unless the index the caller
|
||||
* holds (`etag`, its SHA-256) is still the current one. The checksum returned
|
||||
* is that of the JSON as read, for the caller to hold against the meta's
|
||||
* `indexSha`. A row that does not read as an area is dropped on its own and
|
||||
* counted; the head of the index must read, or nothing does.
|
||||
*/
|
||||
export async function trekPlacesWinterIndex(etag: string | null): Promise<TrekPlacesWinterIndex> {
|
||||
const answer = await getWinter('/v1/winter/index', 'application/json', etag, WINTER_INDEX);
|
||||
if (answer.status !== 'ok') return answer;
|
||||
const head = winterIndexHeadSchema.parse(JSON.parse(answer.bytes.toString('utf8')));
|
||||
const areas: WinterIndexRow[] = [];
|
||||
for (const row of head.areas) {
|
||||
const parsed = winterIndexRowSchema.safeParse(row);
|
||||
if (parsed.success) areas.push(parsed.data);
|
||||
}
|
||||
return {
|
||||
status: 'ok',
|
||||
index: { build: head.build, format: head.format, areas },
|
||||
sha256: createHash('sha256').update(answer.bytes).digest('hex'),
|
||||
dropped: head.areas.length - areas.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One area's graph, gzip-packed SGR1 exactly as the service stores it, unless
|
||||
* the copy the caller holds (`etag`, its SHA-256) is still current. The caller
|
||||
* checks the bytes against the index's checksum before keeping them.
|
||||
*/
|
||||
export async function trekPlacesWinterArea(id: string, etag: string | null): Promise<TrekPlacesWinterBody> {
|
||||
if (!WINTER_AREA_ID.test(id) || id.length > 64) throw new Error(`Not a ski area id: ${id}`);
|
||||
return getWinter(`/v1/winter/area/${encodeURIComponent(id)}`, 'application/vnd.trek.sgraph', etag, WINTER_AREA);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { EdgeCosts } from './profile';
|
||||
import type { FoundPath, SearchOutcome } from './router';
|
||||
import { buildLeg, legSeconds } from './segments';
|
||||
import type { SkiNet } from './stitch';
|
||||
import type { SkiRouteAlternative } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Other ways for a leg (spec 7.5), by the penalty method: every edge a way
|
||||
* already uses costs 1.4 times as much in the next search. A way found that
|
||||
* way is offered only when it shares less than 70 % of its length with the
|
||||
* ways before it and takes at most half as long again as the best one.
|
||||
*/
|
||||
|
||||
const PENALTY = 1.4;
|
||||
const MAX_SHARED = 0.7;
|
||||
const MAX_SLOWER = 1.5;
|
||||
|
||||
/** How much of a way's length runs on edges an earlier way used. */
|
||||
function sharedShare(net: SkiNet, path: FoundPath, used: ReadonlySet<number>): number {
|
||||
let total = 0;
|
||||
let shared = 0;
|
||||
for (const step of path.steps) {
|
||||
const length = (net.edgeLen[step.edge] as number) * Math.abs(step.to - step.from);
|
||||
total += length;
|
||||
if (used.has(step.edge)) shared += length;
|
||||
}
|
||||
return total > 0 ? shared / total : 1;
|
||||
}
|
||||
|
||||
export function findAlternatives(
|
||||
net: SkiNet,
|
||||
costs: EdgeCosts,
|
||||
best: FoundPath,
|
||||
count: number,
|
||||
search: (penalty: Float64Array) => SearchOutcome,
|
||||
): SkiRouteAlternative[] {
|
||||
if (count <= 0) return [];
|
||||
const bestSeconds = legSeconds(buildLeg(net, costs, best.steps, [best.start.lat, best.start.lng, best.start.ele]));
|
||||
const penalty = new Float64Array(net.edgeCount).fill(1);
|
||||
const used = new Set<number>();
|
||||
const punish = (path: FoundPath) => {
|
||||
for (const edge of new Set(path.steps.map((step) => step.edge))) {
|
||||
penalty[edge] = (penalty[edge] as number) * PENALTY;
|
||||
used.add(edge);
|
||||
}
|
||||
};
|
||||
punish(best);
|
||||
const found: SkiRouteAlternative[] = [];
|
||||
// A rejected way is penalised too, so the next try looks elsewhere; two spare tries at most.
|
||||
for (let tries = 0; tries < count + 2 && found.length < count; tries++) {
|
||||
const outcome = search(penalty);
|
||||
if (outcome.status !== 'ok') break;
|
||||
const path = outcome.path;
|
||||
const leg = buildLeg(net, costs, path.steps, [path.start.lat, path.start.lng, path.start.ele]);
|
||||
const seconds = legSeconds(leg);
|
||||
if (sharedShare(net, path, used) < MAX_SHARED && seconds <= bestSeconds * MAX_SLOWER) {
|
||||
found.push({ geometry: leg.geometry, segments: leg.segments, seconds_total: seconds });
|
||||
}
|
||||
punish(path);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { DIFF_UNKNOWN, DIFFICULTY_NAMES, Kind } from '../ski-graph.contract';
|
||||
import { difficultyAllowed, liftClassOf, Mode, type EdgeCosts, type SkiProfile } from './profile';
|
||||
import type { FoundPath, Step } from './router';
|
||||
import type { SkiNet } from './stitch';
|
||||
import type { SkiFailCode, SkiFailHint, SkiRouteFailedLeg, SkiRunDifficulty } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Why a leg found no way (spec 7.6): the rules are relaxed one at a time and
|
||||
* the leg searched again; the first relaxation that finds a way names the
|
||||
* reason and the one change that would plan the leg. The way a relaxed search
|
||||
* found is only read for its reason, never returned as the leg's line.
|
||||
*/
|
||||
|
||||
/** One try at a leg with a profile: a way, no place to start or end, no way, or a search out of budget. */
|
||||
export type LegAttempt =
|
||||
| { status: 'ok'; path: FoundPath; settled: number; costs: EdgeCosts }
|
||||
| { status: 'no_snap'; which: 'from' | 'to' }
|
||||
| { status: 'none'; nearest: { lat: number; lng: number } | null }
|
||||
| { status: 'timeout' };
|
||||
|
||||
type Attempt = (profile: SkiProfile) => LegAttempt;
|
||||
|
||||
type Found = Extract<LegAttempt, { status: 'ok' }>;
|
||||
|
||||
interface Relaxation {
|
||||
code: SkiFailCode;
|
||||
applies: (profile: SkiProfile) => boolean;
|
||||
relax: (profile: SkiProfile) => SkiProfile;
|
||||
/** The hint and the run or lift the reason is about, read off the relaxed way. */
|
||||
read: (net: SkiNet, base: SkiProfile, found: Found) => { hint: SkiFailHint; name: string | null };
|
||||
}
|
||||
|
||||
const DOWNHILL_DISCIPLINES: ReadonlySet<string> = new Set(['alpine', 'snowboard', 'touring']);
|
||||
const DESCENT_MODES: ReadonlySet<number> = new Set([Mode.DOWNHILL, Mode.SKITOUR_DOWN]);
|
||||
const PISTE_KINDS: ReadonlySet<number> = new Set([Kind.RUN, Kind.AREA, Kind.CONNECTION]);
|
||||
const LIFT_EDGE_KINDS: ReadonlySet<number> = new Set([Kind.LIFT, Kind.RAIL, Kind.BOARD]);
|
||||
|
||||
const modeOf = (costs: EdgeCosts, step: Step): number =>
|
||||
(step.dir === 0 ? costs.modeFwd[step.edge] : costs.modeBack[step.edge]) as number;
|
||||
|
||||
const nameOf = (net: SkiNet, edge: number): string | null => net.edgeName[edge] ?? net.edgeRef[edge] ?? null;
|
||||
|
||||
/** The hardest run of a way above what a profile allows, if it has one. */
|
||||
export function hardestAbove(
|
||||
net: SkiNet,
|
||||
base: SkiProfile,
|
||||
path: FoundPath,
|
||||
costs: EdgeCosts,
|
||||
): { level: SkiRunDifficulty; name: string | null } | null {
|
||||
let hardest = -1;
|
||||
let edge = -1;
|
||||
for (const step of path.steps) {
|
||||
const diff = net.edgeDiff[step.edge] as number;
|
||||
if (!DESCENT_MODES.has(modeOf(costs, step)) || !PISTE_KINDS.has(net.edgeKind[step.edge] as number)) continue;
|
||||
if (diff === DIFF_UNKNOWN || difficultyAllowed(base.options, diff) || diff <= hardest) continue;
|
||||
hardest = diff;
|
||||
edge = step.edge;
|
||||
}
|
||||
const level = DIFFICULTY_NAMES[hardest];
|
||||
return level ? { level, name: nameOf(net, edge) } : null;
|
||||
}
|
||||
|
||||
const RELAXATIONS: readonly Relaxation[] = [
|
||||
{
|
||||
code: 'blocked_by_difficulty',
|
||||
applies: (p) =>
|
||||
DOWNHILL_DISCIPLINES.has(p.discipline) && (p.options.max_difficulty !== 'expert' || !p.options.allow_freeride),
|
||||
relax: (p) => ({ ...p, options: { ...p.options, max_difficulty: 'expert', allow_freeride: true } }),
|
||||
read: (net, base, found) => {
|
||||
const hardest = hardestAbove(net, base, found.path, found.costs);
|
||||
return { hint: hardest ? { raise_difficulty: hardest.level } : {}, name: hardest?.name ?? null };
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'blocked_by_lift_avoid',
|
||||
applies: (p) => p.useLifts && p.options.avoid_lifts.length > 0,
|
||||
relax: (p) => ({ ...p, options: { ...p.options, avoid_lifts: [] } }),
|
||||
read: (net, base, found) => {
|
||||
for (const step of found.path.steps) {
|
||||
if (!LIFT_EDGE_KINDS.has(net.edgeKind[step.edge] as number)) continue;
|
||||
const cls = liftClassOf(net.edgeLift[step.edge] as number);
|
||||
if (cls && base.options.avoid_lifts.includes(cls)) {
|
||||
return { hint: { allow_lift: cls }, name: nameOf(net, step.edge) };
|
||||
}
|
||||
}
|
||||
return { hint: {}, name: null };
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'blocked_by_technique',
|
||||
applies: (p) => p.discipline === 'cross_country' && p.technique !== 'unknown',
|
||||
relax: (p) => ({ ...p, technique: 'unknown' }),
|
||||
read: () => ({ hint: { technique: 'unknown' }, name: null }),
|
||||
},
|
||||
{
|
||||
code: 'needs_ride_down',
|
||||
applies: (p) => p.useLifts && !p.options.allow_lift_down,
|
||||
relax: (p) => ({ ...p, options: { ...p.options, allow_lift_down: true } }),
|
||||
read: (net, _base, found) => {
|
||||
const ride = found.path.steps.find((step) => modeOf(found.costs, step) === Mode.LIFT_DOWN);
|
||||
return { hint: { allow_lift_down: true }, name: ride ? nameOf(net, ride.edge) : null };
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'uphill_only',
|
||||
applies: (p) => p.discipline === 'alpine' || p.discipline === 'snowboard',
|
||||
relax: (p) => ({ ...p, walkUphill: true }),
|
||||
read: () => ({ hint: { discipline: 'touring' }, name: null }),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The reason a leg failed, from relaxing the rules one at a time, or null when
|
||||
* no single relaxation finds a way (the caller then says where it stopped).
|
||||
*/
|
||||
export function explainFailure(net: SkiNet, base: SkiProfile, attempt: Attempt): SkiRouteFailedLeg | null {
|
||||
for (const relaxation of RELAXATIONS) {
|
||||
if (!relaxation.applies(base)) continue;
|
||||
const found = attempt(relaxation.relax(base));
|
||||
if (found.status !== 'ok') continue;
|
||||
const { hint, name } = relaxation.read(net, base, found);
|
||||
return { status: 'failed', code: relaxation.code, at: null, hint, detail: { name } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { LocalFrame } from '../../../lib/geo/geo';
|
||||
import { encodePolyline } from '../../../utils/polyline';
|
||||
import { Kind, LIFT_NAMES, Use } from '../ski-graph.contract';
|
||||
import { difficultyName } from './segments';
|
||||
import type { SkiNet } from './stitch';
|
||||
import type { SkiNetworkFeature, SkiSegmentKind } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* An area's runs, lifts and trails as lines for the planner's map (spec 10.4):
|
||||
* each drawn edge simplified (Douglas-Peucker, 5 m) and encoded as a
|
||||
* Google polyline with what the map labels it by. Links, crossings, station
|
||||
* edges and the open-slope edges between an area's portals are not drawn;
|
||||
* the raster overlay shows the slopes themselves.
|
||||
*/
|
||||
|
||||
/** Drawn edge kinds and the kind each is drawn as. */
|
||||
const DRAWN: ReadonlyMap<number, SkiSegmentKind> = new Map<number, SkiSegmentKind>([
|
||||
[Kind.RUN, 'run'],
|
||||
[Kind.LIFT, 'lift'],
|
||||
[Kind.RAIL, 'rail'],
|
||||
[Kind.NORDIC, 'nordic'],
|
||||
[Kind.SKITOUR, 'skitour'],
|
||||
[Kind.HIKE, 'hike'],
|
||||
[Kind.SLED_WALKUP, 'hike'],
|
||||
[Kind.CONNECTION, 'connection'],
|
||||
]);
|
||||
|
||||
/** Above this the lines are simplified harder. */
|
||||
const NETWORK_MAX_BYTES = 3 * 1024 * 1024;
|
||||
|
||||
/** The points of a line that stay within `tolerance` metres of it, its ends always kept. */
|
||||
export function simplifyLine(points: ReadonlyArray<[number, number]>, tolerance: number): Array<[number, number]> {
|
||||
if (points.length <= 2) return [...points];
|
||||
const first = points[0] as [number, number];
|
||||
const frame = new LocalFrame(first[0], first[1]);
|
||||
const xy = points.map(([lat, lng]) => [frame.x(lng), frame.y(lat)] as const);
|
||||
const keep = new Uint8Array(points.length);
|
||||
keep[0] = 1;
|
||||
keep[points.length - 1] = 1;
|
||||
const stack: Array<[number, number]> = [[0, points.length - 1]];
|
||||
while (stack.length > 0) {
|
||||
const [from, to] = stack.pop() as [number, number];
|
||||
const [ax, ay] = xy[from] as readonly [number, number];
|
||||
const [bx, by] = xy[to] as readonly [number, number];
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const length2 = dx * dx + dy * dy;
|
||||
let worst = -1;
|
||||
let worstDistance = tolerance;
|
||||
for (let i = from + 1; i < to; i++) {
|
||||
const [px, py] = xy[i] as readonly [number, number];
|
||||
const t = length2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / length2));
|
||||
const distance = Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
||||
if (distance > worstDistance) {
|
||||
worstDistance = distance;
|
||||
worst = i;
|
||||
}
|
||||
}
|
||||
if (worst < 0) continue;
|
||||
keep[worst] = 1;
|
||||
stack.push([from, worst], [worst, to]);
|
||||
}
|
||||
return points.filter((_, i) => keep[i] === 1);
|
||||
}
|
||||
|
||||
function featureOf(net: SkiNet, e: number, kind: SkiSegmentKind, tolerance: number): SkiNetworkFeature {
|
||||
const line: Array<[number, number]> = [];
|
||||
for (let i = net.geomStart[e] as number; i < (net.geomStart[e + 1] as number); i++) {
|
||||
line.push([net.geomLat[i] as number, net.geomLng[i] as number]);
|
||||
}
|
||||
const fwd = net.edgeFwd[e] as number;
|
||||
const back = net.edgeBack[e] as number;
|
||||
const lift = net.edgeLift[e] as number;
|
||||
const riddenOneWay = (fwd & Use.LIFT_UP) !== 0 && (back & (Use.LIFT_UP | Use.LIFT_DOWN)) === 0;
|
||||
const skiedOneWay = (fwd & Use.DOWNHILL) !== 0 && (back & Use.DOWNHILL) === 0;
|
||||
return {
|
||||
kind,
|
||||
difficulty: kind === 'run' || kind === 'connection' ? difficultyName(net.edgeDiff[e] as number) : null,
|
||||
name: net.edgeName[e] ?? net.edgeRef[e] ?? null,
|
||||
lift_type: lift > 0 ? (LIFT_NAMES[lift] ?? null) : null,
|
||||
oneway: kind === 'lift' || kind === 'rail' ? riddenOneWay : skiedOneWay,
|
||||
line: encodePolyline(simplifyLine(line, tolerance), 5),
|
||||
};
|
||||
}
|
||||
|
||||
/** The drawn lines of a network, simplified at 5 m, or at 15 m when that comes to more than 3 MB. */
|
||||
export function networkFeatures(net: SkiNet): { features: SkiNetworkFeature[]; simplified_m: number } {
|
||||
let result: { features: SkiNetworkFeature[]; simplified_m: number } = { features: [], simplified_m: 5 };
|
||||
for (const tolerance of [5, 15]) {
|
||||
const features: SkiNetworkFeature[] = [];
|
||||
let bytes = 0;
|
||||
for (let e = 0; e < net.edgeCount; e++) {
|
||||
const kind = DRAWN.get(net.edgeKind[e] as number);
|
||||
if (!kind) continue;
|
||||
const feature = featureOf(net, e, kind, tolerance);
|
||||
bytes += feature.line.length + (feature.name?.length ?? 0) + 80;
|
||||
features.push(feature);
|
||||
}
|
||||
result = { features, simplified_m: tolerance };
|
||||
if (bytes <= NETWORK_MAX_BYTES) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { findAlternatives } from './alternatives';
|
||||
import { explainFailure, hardestAbove, type LegAttempt } from './explain';
|
||||
import { compileProfile, type EdgeCosts, type SkiProfile } from './profile';
|
||||
import { LEG_TIME_MS, MAX_SETTLED, searchPath, type SearchBudget } from './router';
|
||||
import { buildLeg, flagNotes, legSeconds } from './segments';
|
||||
import { nearestUsable, SNAP_RADIUS_M, snapPoint, type Snap } from './snap';
|
||||
import type { SkiNet } from './stitch';
|
||||
import type { SkiRouteLeg, SkiRoutePoint, SkiWhy } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The legs of one request on one network: each leg snapped, searched and,
|
||||
* planned, built into its line and stretches with its notes and other ways;
|
||||
* failed, explained. A leg that goes on from the one before starts exactly
|
||||
* where that one ended, so a tour does not jump between two parallel runs at
|
||||
* a point the skier set.
|
||||
*/
|
||||
|
||||
export interface LegInput {
|
||||
from: SkiRoutePoint;
|
||||
to: SkiRoutePoint;
|
||||
/** The leg starts at the point the previous leg of this job ended at. */
|
||||
continues: boolean;
|
||||
}
|
||||
|
||||
interface PlanInput {
|
||||
legs: readonly LegInput[];
|
||||
profile: SkiProfile;
|
||||
alternatives: number;
|
||||
}
|
||||
|
||||
/** A relaxed search while explaining gets a quarter of the nodes and well under a second. */
|
||||
const RELAXED_SETTLED = MAX_SETTLED / 4;
|
||||
const RELAXED_MS = 600;
|
||||
/** Only a leg found cheaply is searched again for the harder runs it kept clear of. */
|
||||
const WHY_SETTLED = 50_000;
|
||||
/** How much quicker the harder way must be before the note says so. */
|
||||
const WHY_FASTER = 0.8;
|
||||
/** How far a point off the network is measured, at most. */
|
||||
const MEASURE_OFF_M = 2000;
|
||||
const COMPILED_PROFILES = 8;
|
||||
|
||||
const round1 = (value: number): number => Math.round(value * 10) / 10;
|
||||
const startOf = (snap: Snap): [number, number, number] => [snap.lat, snap.lng, snap.ele];
|
||||
|
||||
export class SkiRouter {
|
||||
private readonly compiled = new Map<string, EdgeCosts>();
|
||||
|
||||
constructor(readonly net: SkiNet) {}
|
||||
|
||||
/** The network's costs for a profile, kept for the last few profiles asked. */
|
||||
costsFor(profile: SkiProfile): EdgeCosts {
|
||||
const key = JSON.stringify(profile);
|
||||
let costs = this.compiled.get(key);
|
||||
if (costs) {
|
||||
this.compiled.delete(key);
|
||||
} else {
|
||||
costs = compileProfile(profile, this.net);
|
||||
}
|
||||
this.compiled.set(key, costs);
|
||||
while (this.compiled.size > COMPILED_PROFILES) this.compiled.delete(this.compiled.keys().next().value as string);
|
||||
return costs;
|
||||
}
|
||||
|
||||
plan(input: PlanInput, areaIds: string[]): SkiRouteLeg[] {
|
||||
let previous: Snap | null = null;
|
||||
return input.legs.map((leg) => {
|
||||
const { answer, end } = this.planLeg(leg, input, leg.continues ? previous : null, areaIds);
|
||||
previous = end;
|
||||
return answer;
|
||||
});
|
||||
}
|
||||
|
||||
/** One try at a leg with a profile, starting at `start` when the leg goes on from the one before. */
|
||||
private attempt(profile: SkiProfile, leg: LegInput, start: Snap | null, budget: SearchBudget): LegAttempt {
|
||||
const costs = this.costsFor(profile);
|
||||
const radius = SNAP_RADIUS_M[profile.discipline];
|
||||
const starts = start ? [start] : snapPoint(this.net, costs, leg.from.lat, leg.from.lng, radius, 'from');
|
||||
if (starts.length === 0) return { status: 'no_snap', which: 'from' };
|
||||
const ends = snapPoint(this.net, costs, leg.to.lat, leg.to.lng, radius, 'to');
|
||||
if (ends.length === 0) return { status: 'no_snap', which: 'to' };
|
||||
const outcome = searchPath(this.net, costs, starts, ends, leg.to, budget);
|
||||
if (outcome.status === 'ok') return { status: 'ok', path: outcome.path, settled: outcome.settled, costs };
|
||||
return outcome.status === 'none' ? { status: 'none', nearest: outcome.nearest } : { status: 'timeout' };
|
||||
}
|
||||
|
||||
private relaxedBudget(): SearchBudget {
|
||||
return { maxSettled: RELAXED_SETTLED, deadline: Date.now() + RELAXED_MS };
|
||||
}
|
||||
|
||||
private planLeg(
|
||||
leg: LegInput,
|
||||
input: PlanInput,
|
||||
start: Snap | null,
|
||||
areaIds: string[],
|
||||
): { answer: SkiRouteLeg; end: Snap | null } {
|
||||
const base = input.profile;
|
||||
const found = this.attempt(base, leg, start, { maxSettled: MAX_SETTLED, deadline: Date.now() + LEG_TIME_MS });
|
||||
if (found.status === 'timeout') {
|
||||
return { answer: { status: 'failed', code: 'timeout', at: null, hint: null, detail: null }, end: null };
|
||||
}
|
||||
if (found.status !== 'ok') return { answer: this.failed(base, leg, start, found), end: null };
|
||||
const { path, costs } = found;
|
||||
const built = buildLeg(this.net, costs, path.steps, startOf(path.start));
|
||||
const avoided = this.avoidedDifficulty(base, leg, start, found, legSeconds(built));
|
||||
const why: SkiWhy[] = [...avoided, ...flagNotes(built.flagCounts)];
|
||||
const alternatives = findAlternatives(this.net, costs, path, input.alternatives, (penalty) =>
|
||||
searchPath(this.net, costs, [path.start], [path.end], leg.to, this.relaxedBudget(), penalty),
|
||||
);
|
||||
const { flagCounts: _flags, ...line } = built;
|
||||
return {
|
||||
answer: {
|
||||
status: 'ok',
|
||||
...line,
|
||||
snapped_m: { from: round1(path.start.distance), to: round1(path.end.distance) },
|
||||
area_ids: areaIds,
|
||||
why,
|
||||
alternatives,
|
||||
},
|
||||
end: path.end,
|
||||
};
|
||||
}
|
||||
|
||||
/** Why a leg found no way, or where it stopped. */
|
||||
private failed(base: SkiProfile, leg: LegInput, start: Snap | null, found: LegAttempt): SkiRouteLeg {
|
||||
const explained = explainFailure(this.net, base, (profile) =>
|
||||
this.attempt(profile, leg, start, this.relaxedBudget()),
|
||||
);
|
||||
if (explained) return explained;
|
||||
if (found.status === 'no_snap') {
|
||||
const point = found.which === 'from' ? leg.from : leg.to;
|
||||
const near = nearestUsable(this.net, this.costsFor(base), point.lat, point.lng, MEASURE_OFF_M);
|
||||
const name = near ? (this.net.edgeName[near.edge] ?? this.net.edgeRef[near.edge] ?? null) : null;
|
||||
const detail = near ? { name, distance_m: Math.round(near.distance) } : null;
|
||||
return { status: 'failed', code: 'not_on_piste', at: point, hint: null, detail };
|
||||
}
|
||||
const nearest = found.status === 'none' ? found.nearest : null;
|
||||
return { status: 'failed', code: 'disconnected', at: nearest, hint: null, detail: null };
|
||||
}
|
||||
|
||||
/** A note when a way over harder runs would have been clearly quicker; nothing for a leg costly to search. */
|
||||
private avoidedDifficulty(
|
||||
base: SkiProfile,
|
||||
leg: LegInput,
|
||||
start: Snap | null,
|
||||
found: Extract<LegAttempt, { status: 'ok' }>,
|
||||
seconds: number,
|
||||
): SkiWhy[] {
|
||||
const limited = base.options.max_difficulty !== 'expert' || !base.options.allow_freeride;
|
||||
const downhill = base.discipline === 'alpine' || base.discipline === 'snowboard' || base.discipline === 'touring';
|
||||
if (!limited || !downhill || found.settled > WHY_SETTLED) return [];
|
||||
const options = { ...base.options, max_difficulty: 'expert' as const, allow_freeride: true };
|
||||
const relaxed: SkiProfile = { ...base, options };
|
||||
const other = this.attempt(relaxed, leg, start, this.relaxedBudget());
|
||||
if (other.status !== 'ok') return [];
|
||||
const hardest = hardestAbove(this.net, base, other.path, other.costs);
|
||||
const faster = legSeconds(buildLeg(this.net, other.costs, other.path.steps, startOf(other.path.start)));
|
||||
if (!hardest || faster > seconds * WHY_FASTER) return [];
|
||||
const saved = Math.round(seconds - faster);
|
||||
return [{ kind: 'avoided_difficulty', level: hardest.level, name: hardest.name, saved_seconds: saved }];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { DIFF_EXPERT, DIFF_UNKNOWN, EdgeFlag, Groom, Kind, Lift, NodeFlag, Use } from '../ski-graph.contract';
|
||||
import type { SkiNet } from './stitch';
|
||||
import type { SkiDiscipline, SkiLiftClass, SkiRouteOptions, SkiTechnique } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Who travels the network, and what each edge costs them (spec 8). Two numbers
|
||||
* per edge and direction: the time it takes (what the traveller is shown) and
|
||||
* the cost the search weighs, which is the time with penalties and the
|
||||
* skier's preferences on top. An edge a profile may not use costs Infinity.
|
||||
*/
|
||||
export interface SkiProfile {
|
||||
discipline: SkiDiscipline;
|
||||
/** Cross-country only; `unknown` takes either technique. Null for every other discipline. */
|
||||
technique: SkiTechnique | null;
|
||||
options: SkiRouteOptions;
|
||||
/** Whether lifts may be ridden at all, the discipline's default resolved. */
|
||||
useLifts: boolean;
|
||||
/** Only while explaining a failure: a downhill skier may walk up a run, to tell "no way up" from "no way". */
|
||||
walkUphill?: boolean;
|
||||
}
|
||||
|
||||
/** How an edge is travelled in one direction, or 0 when it is not. */
|
||||
export const Mode = {
|
||||
NONE: 0,
|
||||
DOWNHILL: 1,
|
||||
LIFT: 2,
|
||||
LIFT_DOWN: 3,
|
||||
BOARD: 4,
|
||||
BOARD_DOWN: 5,
|
||||
ALIGHT: 6,
|
||||
ALIGHT_DOWN: 7,
|
||||
NORDIC: 8,
|
||||
SKITOUR_UP: 9,
|
||||
SKITOUR_DOWN: 10,
|
||||
SNOWSHOE: 11,
|
||||
HIKE: 12,
|
||||
WALK: 13,
|
||||
UPHILL_PISTE: 14,
|
||||
} as const;
|
||||
|
||||
/** The time and cost of every edge in both directions for one profile, and how each is travelled. */
|
||||
export interface EdgeCosts {
|
||||
timeFwd: Float64Array;
|
||||
timeBack: Float64Array;
|
||||
costFwd: Float64Array;
|
||||
costBack: Float64Array;
|
||||
modeFwd: Uint8Array;
|
||||
modeBack: Uint8Array;
|
||||
/** The fastest the search can go, metres of straight line per second of cost: the A* heuristic's speed. */
|
||||
maxSpeed: number;
|
||||
}
|
||||
|
||||
/** Edges walked or skied as a link between pieces of the network. */
|
||||
const WALK_KINDS: ReadonlySet<number> = new Set([Kind.LINK, Kind.CROSSING, Kind.CONNECTION, Kind.WALK]);
|
||||
/** Edges a run's difficulty applies to. */
|
||||
const PISTE_KINDS: ReadonlySet<number> = new Set([Kind.RUN, Kind.AREA, Kind.CONNECTION]);
|
||||
|
||||
/** The lift class the skier may leave out, by `edgeLift`. */
|
||||
export function liftClassOf(lift: number): SkiLiftClass | null {
|
||||
if (lift === Lift.CABLE_CAR) return 'cable_car';
|
||||
if (lift === Lift.GONDOLA || lift === Lift.MIXED) return 'gondola';
|
||||
if (lift === Lift.CHAIR_DETACHABLE || lift === Lift.CHAIR_FIXED) return 'open_chair';
|
||||
if (lift >= Lift.DRAG && lift <= Lift.MAGIC_CARPET) return 'drag';
|
||||
if (lift === Lift.FUNICULAR || lift === Lift.RACK) return 'rail';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a lift of this class may be ridden up. Cross-country skiers ride only enclosed transport. */
|
||||
export function liftAllowed(profile: SkiProfile, cls: SkiLiftClass | null): boolean {
|
||||
if (!profile.useLifts || cls === null || profile.options.avoid_lifts.includes(cls)) return false;
|
||||
if (profile.discipline === 'cross_country') return cls === 'gondola' || cls === 'cable_car' || cls === 'rail';
|
||||
// Nobody on snowshoes or boots is towed up a slope.
|
||||
if (profile.discipline === 'snowshoe' || profile.discipline === 'winter_hike') return cls !== 'drag';
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whether a lift of this class may be ridden down: a gondola, a cable car or a train, never a tow. */
|
||||
export function liftDownAllowed(profile: SkiProfile, cls: SkiLiftClass | null): boolean {
|
||||
return profile.options.allow_lift_down && cls !== 'drag' && liftAllowed(profile, cls);
|
||||
}
|
||||
|
||||
/** Whether a run of this difficulty is within the skier's limit; one the data does not grade always is. */
|
||||
export function difficultyAllowed(options: SkiRouteOptions, diff: number): boolean {
|
||||
if (diff === DIFF_UNKNOWN) return true;
|
||||
if (diff > DIFF_EXPERT) return options.allow_freeride;
|
||||
return diff <= DIFF_LEVEL[options.max_difficulty];
|
||||
}
|
||||
|
||||
const DIFF_LEVEL: Record<SkiRouteOptions['max_difficulty'], number> = {
|
||||
novice: 0,
|
||||
easy: 1,
|
||||
intermediate: 2,
|
||||
advanced: 3,
|
||||
expert: 4,
|
||||
};
|
||||
|
||||
function lifted(profile: SkiProfile, net: SkiNet, e: number, uses: number, board: number, down: number): number {
|
||||
const cls = liftClassOf(net.edgeLift[e] as number);
|
||||
if (uses & Use.LIFT_UP && liftAllowed(profile, cls)) return board;
|
||||
if (uses & Use.LIFT_DOWN && liftDownAllowed(profile, cls)) return down;
|
||||
return Mode.NONE;
|
||||
}
|
||||
|
||||
function nordicWanted(profile: SkiProfile): number {
|
||||
if (profile.technique === 'classic') return Use.NORDIC_CLASSIC;
|
||||
if (profile.technique === 'skating') return Use.NORDIC_SKATING;
|
||||
return Use.NORDIC_CLASSIC | Use.NORDIC_SKATING;
|
||||
}
|
||||
|
||||
function groomAllowed(profile: SkiProfile, net: SkiNet, e: number): boolean {
|
||||
const groom = net.edgeGroom[e] as number;
|
||||
if (profile.options.groomed_only && (groom === 0 || groom & Groom.BACKCOUNTRY)) return false;
|
||||
return !profile.options.lit_only || ((net.edgeFlags[e] as number) & EdgeFlag.LIT) !== 0;
|
||||
}
|
||||
|
||||
/** A downhill use of a piste, as long as its difficulty is within the limit. */
|
||||
function descent(profile: SkiProfile, net: SkiNet, e: number, mode: number): number {
|
||||
if (!PISTE_KINDS.has(net.edgeKind[e] as number)) return mode;
|
||||
return difficultyAllowed(profile.options, net.edgeDiff[e] as number) ? mode : Mode.NONE;
|
||||
}
|
||||
|
||||
/** A run short enough to be crossed on foot by someone who does not ski it. */
|
||||
function crossable(net: SkiNet, e: number): boolean {
|
||||
const kind = net.edgeKind[e] as number;
|
||||
return (kind === Kind.RUN || kind === Kind.AREA) && (net.edgeLen[e] as number) <= 80;
|
||||
}
|
||||
|
||||
function downhillMode(profile: SkiProfile, net: SkiNet, e: number, uses: number, other: number): number {
|
||||
const kind = net.edgeKind[e] as number;
|
||||
if (uses & Use.DOWNHILL) {
|
||||
const mode = descent(profile, net, e, Mode.DOWNHILL);
|
||||
if (mode !== Mode.NONE) return mode;
|
||||
}
|
||||
if (uses & Use.WALK && WALK_KINDS.has(kind)) return Mode.WALK;
|
||||
if (profile.walkUphill && (kind === Kind.RUN || kind === Kind.AREA) && other & Use.DOWNHILL) return Mode.WALK;
|
||||
return Mode.NONE;
|
||||
}
|
||||
|
||||
function touringMode(profile: SkiProfile, net: SkiNet, e: number, uses: number, other: number, dir: 0 | 1): number {
|
||||
const kind = net.edgeKind[e] as number;
|
||||
const up = dir === 0 ? (net.edgeUp[e] as number) : (net.edgeDown[e] as number);
|
||||
const down = dir === 0 ? (net.edgeDown[e] as number) : (net.edgeUp[e] as number);
|
||||
const canDescend = (uses & (Use.DOWNHILL | Use.SKITOUR_DOWN)) !== 0;
|
||||
const canClimb = (uses & (Use.SKITOUR_UP | Use.SNOWSHOE)) !== 0;
|
||||
if (canDescend && (!canClimb || down >= up)) {
|
||||
const mode = descent(profile, net, e, Mode.SKITOUR_DOWN);
|
||||
if (mode !== Mode.NONE) return mode;
|
||||
}
|
||||
if (canClimb) return Mode.SKITOUR_UP;
|
||||
if (profile.options.touring_uphill_on_pistes && (kind === Kind.RUN || kind === Kind.AREA) && other & Use.DOWNHILL) {
|
||||
return Mode.UPHILL_PISTE;
|
||||
}
|
||||
return uses & Use.WALK && WALK_KINDS.has(kind) ? Mode.WALK : Mode.NONE;
|
||||
}
|
||||
|
||||
/** How a profile travels an edge's snow (not its lifts) in one direction. */
|
||||
function snowMode(profile: SkiProfile, net: SkiNet, e: number, dir: 0 | 1): number {
|
||||
const uses = (dir === 0 ? net.edgeFwd[e] : net.edgeBack[e]) as number;
|
||||
const other = (dir === 0 ? net.edgeBack[e] : net.edgeFwd[e]) as number;
|
||||
const kind = net.edgeKind[e] as number;
|
||||
const walk = uses & Use.WALK && WALK_KINDS.has(kind) ? Mode.WALK : Mode.NONE;
|
||||
switch (profile.discipline) {
|
||||
case 'alpine':
|
||||
case 'snowboard':
|
||||
return downhillMode(profile, net, e, uses, other);
|
||||
case 'cross_country':
|
||||
if (uses & nordicWanted(profile) && groomAllowed(profile, net, e)) return Mode.NORDIC;
|
||||
return walk;
|
||||
case 'touring':
|
||||
return touringMode(profile, net, e, uses, other, dir);
|
||||
case 'snowshoe':
|
||||
// A run's ski touring bits are a tourer's way down it, not a snowshoe trail.
|
||||
if (!PISTE_KINDS.has(kind) && uses & (Use.SNOWSHOE | Use.WINTER_HIKE | Use.SKITOUR_UP | Use.SKITOUR_DOWN)) {
|
||||
return Mode.SNOWSHOE;
|
||||
}
|
||||
if (crossable(net, e) && (uses | other) !== 0) return Mode.WALK;
|
||||
return walk;
|
||||
case 'winter_hike':
|
||||
if (uses & Use.WINTER_HIKE) return Mode.HIKE;
|
||||
if (crossable(net, e) && (uses | other) !== 0) return Mode.WALK;
|
||||
return walk;
|
||||
}
|
||||
}
|
||||
|
||||
/** How a profile travels an edge in one direction, or {@link Mode.NONE}. */
|
||||
export function travelMode(profile: SkiProfile, net: SkiNet, e: number, dir: 0 | 1): number {
|
||||
const uses = (dir === 0 ? net.edgeFwd[e] : net.edgeBack[e]) as number;
|
||||
const kind = net.edgeKind[e] as number;
|
||||
if (kind === Kind.LIFT || kind === Kind.RAIL) return lifted(profile, net, e, uses, Mode.LIFT, Mode.LIFT_DOWN);
|
||||
if (kind === Kind.BOARD) return lifted(profile, net, e, uses, Mode.BOARD, Mode.BOARD_DOWN);
|
||||
if (kind === Kind.ALIGHT) return lifted(profile, net, e, uses, Mode.ALIGHT, Mode.ALIGHT_DOWN);
|
||||
return snowMode(profile, net, e, dir);
|
||||
}
|
||||
|
||||
// ── Times (spec 8.1) ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Downhill speed by difficulty, m/s: novice, easy, intermediate, advanced, expert, freeride, extreme. */
|
||||
const RUN_SPEED = [4, 7, 8.5, 7, 6, 5, 5];
|
||||
const UNKNOWN_RUN_SPEED = 7;
|
||||
const PACE_FACTOR: Record<SkiRouteOptions['pace'], number> = { relaxed: 0.7, normal: 1, sporty: 1.3 };
|
||||
|
||||
/** Ride speed by lift type when the data gives no ride time, m/s. */
|
||||
const LIFT_SPEED: Record<number, number> = {
|
||||
[Lift.CABLE_CAR]: 10,
|
||||
[Lift.GONDOLA]: 5,
|
||||
[Lift.MIXED]: 5,
|
||||
[Lift.CHAIR_DETACHABLE]: 5,
|
||||
[Lift.CHAIR_FIXED]: 2.3,
|
||||
[Lift.DRAG]: 3,
|
||||
[Lift.TBAR]: 3,
|
||||
[Lift.JBAR]: 3,
|
||||
[Lift.PLATTER]: 3,
|
||||
[Lift.ROPE_TOW]: 2,
|
||||
[Lift.MAGIC_CARPET]: 0.8,
|
||||
[Lift.FUNICULAR]: 8,
|
||||
[Lift.RACK]: 8,
|
||||
};
|
||||
|
||||
/** The wait at a lift's bottom station, a guess from its type and capacity, seconds. */
|
||||
export function boardWaitSeconds(lift: number, capacity: number): number {
|
||||
const cls = liftClassOf(lift);
|
||||
if (lift === Lift.MAGIC_CARPET) return 30;
|
||||
if (cls === 'drag') return 60;
|
||||
if (cls === 'cable_car' || cls === 'rail') return 240;
|
||||
return capacity >= 2400 ? 90 : 120;
|
||||
}
|
||||
|
||||
const ALIGHT_SECONDS = 15;
|
||||
const WALK_SPEED = 0.7;
|
||||
|
||||
/** DIN 33466: the longer of the two times plus half the shorter, both in seconds. */
|
||||
const din = (horizontal: number, vertical: number): number =>
|
||||
Math.max(horizontal, vertical) + Math.min(horizontal, vertical) / 2;
|
||||
|
||||
function runSeconds(profile: SkiProfile, net: SkiNet, e: number, length: number): number {
|
||||
const diff = net.edgeDiff[e] as number;
|
||||
let speed = (diff === DIFF_UNKNOWN ? UNKNOWN_RUN_SPEED : RUN_SPEED[diff]) ?? UNKNOWN_RUN_SPEED;
|
||||
if ((net.edgeGroom[e] as number) & Groom.MOGUL) speed *= 0.8;
|
||||
if ((net.edgeKind[e] as number) === Kind.AREA) speed *= 0.8;
|
||||
speed *= PACE_FACTOR[profile.options.pace];
|
||||
if (profile.discipline === 'snowboard') {
|
||||
if ((net.edgeFlags[e] as number) & EdgeFlag.FLAT_FOR_BOARD) return length / 1.5;
|
||||
speed *= 0.95;
|
||||
}
|
||||
return length / speed;
|
||||
}
|
||||
|
||||
/** Tobler's hiking function, normalised to the flat, for a nordic skier: slower up, a little faster down. */
|
||||
function toblerFactor(grade: number): number {
|
||||
const factor = Math.exp(-3.5 * Math.abs(grade + 0.05)) / Math.exp(-0.175);
|
||||
return Math.min(2.5, Math.max(0.25, factor));
|
||||
}
|
||||
|
||||
function nordicSeconds(profile: SkiProfile, net: SkiNet, e: number, length: number, climb: number, drop: number) {
|
||||
const flat = profile.technique === 'skating' ? 3.3 : 2.5;
|
||||
const grade = length > 0 ? (climb - drop) / length : 0;
|
||||
let speed = flat * toblerFactor(grade);
|
||||
if ((net.edgeGroom[e] as number) & Groom.BACKCOUNTRY) speed *= 0.6;
|
||||
return length / speed;
|
||||
}
|
||||
|
||||
/** What it takes to travel an edge in one direction in a mode, seconds; the wait at a lift included. */
|
||||
export function edgeSeconds(profile: SkiProfile, net: SkiNet, e: number, dir: 0 | 1, mode: number): number {
|
||||
const length = net.edgeLen[e] as number;
|
||||
const climb = dir === 0 ? (net.edgeUp[e] as number) : (net.edgeDown[e] as number);
|
||||
const drop = dir === 0 ? (net.edgeDown[e] as number) : (net.edgeUp[e] as number);
|
||||
const kind = net.edgeKind[e] as number;
|
||||
// A link, a crossing or a mapped walk is walked, whatever it lets through.
|
||||
if (mode === Mode.WALK || kind === Kind.LINK || kind === Kind.CROSSING || kind === Kind.WALK) {
|
||||
return length / WALK_SPEED + (climb / 300) * 3600;
|
||||
}
|
||||
switch (mode) {
|
||||
case Mode.DOWNHILL:
|
||||
return runSeconds(profile, net, e, length);
|
||||
case Mode.LIFT:
|
||||
case Mode.LIFT_DOWN: {
|
||||
const ride = net.edgeLiftSec[e] as number;
|
||||
return ride > 0 ? ride : length / (LIFT_SPEED[net.edgeLift[e] as number] ?? 5);
|
||||
}
|
||||
case Mode.BOARD:
|
||||
case Mode.BOARD_DOWN:
|
||||
return boardWaitSeconds(net.edgeLift[e] as number, net.edgeLiftCap[e] as number);
|
||||
case Mode.ALIGHT:
|
||||
case Mode.ALIGHT_DOWN:
|
||||
return ALIGHT_SECONDS;
|
||||
case Mode.NORDIC:
|
||||
return nordicSeconds(profile, net, e, length, climb, drop);
|
||||
case Mode.SKITOUR_UP:
|
||||
case Mode.UPHILL_PISTE:
|
||||
return din(length / (4000 / 3600), (climb / 350) * 3600);
|
||||
case Mode.SKITOUR_DOWN:
|
||||
return length / (kind === Kind.RUN || kind === Kind.AREA || kind === Kind.CONNECTION ? 7 : 3);
|
||||
case Mode.SNOWSHOE:
|
||||
return din(length / (3000 / 3600), (climb / 250) * 3600 + (drop / 500) * 3600);
|
||||
case Mode.HIKE:
|
||||
return din(length / (4000 / 3600), (climb / 300) * 3600 + (drop / 500) * 3600) / 0.85;
|
||||
default:
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
|
||||
/** The extra seconds of the node an edge arrives at: getting out of the bindings where the map says to. */
|
||||
export function dismountSeconds(net: SkiNet, node: number): number {
|
||||
const flags = net.nodeFlags[node] as number;
|
||||
if (flags & NodeFlag.DISMOUNT_YES) return 30;
|
||||
return flags & NodeFlag.DISMOUNT_SOMETIMES ? 10 : 0;
|
||||
}
|
||||
|
||||
// ── Costs (spec 8.2) ─────────────────────────────────────────────────────────
|
||||
|
||||
const SKIING_MODES: ReadonlySet<number> = new Set([Mode.DOWNHILL, Mode.NORDIC, Mode.SKITOUR_DOWN]);
|
||||
const LIFT_MODES: ReadonlySet<number> = new Set([Mode.LIFT, Mode.LIFT_DOWN, Mode.BOARD, Mode.BOARD_DOWN]);
|
||||
|
||||
/** OsmAnd's weighting for the easiest way: the harder a run against the limit, the dearer. */
|
||||
function easiestFactor(options: SkiRouteOptions, diff: number): number {
|
||||
if (diff === DIFF_UNKNOWN) return 1.5;
|
||||
const below = DIFF_LEVEL[options.max_difficulty] - Math.min(diff, DIFF_EXPERT);
|
||||
if (below <= 0) return 1.8;
|
||||
if (below === 1) return 1.5;
|
||||
return below === 2 ? 1.2 : 1;
|
||||
}
|
||||
|
||||
function nordicPenalty(profile: SkiProfile, net: SkiNet, e: number): number {
|
||||
const groom = net.edgeGroom[e] as number;
|
||||
let factor = 1;
|
||||
if (groom & Groom.BACKCOUNTRY) factor *= profile.technique === 'skating' ? 3 : 2;
|
||||
if ((net.edgeFlags[e] as number) & EdgeFlag.TECHNIQUE_ASSUMED) factor *= 1.1;
|
||||
return factor;
|
||||
}
|
||||
|
||||
/** The cost the search weighs for an edge travelled in one direction: its time, penalised and weighted. */
|
||||
export function edgeCost(profile: SkiProfile, net: SkiNet, e: number, dir: 0 | 1, mode: number, time: number): number {
|
||||
const flags = net.edgeFlags[e] as number;
|
||||
const kind = net.edgeKind[e] as number;
|
||||
let cost = time;
|
||||
if (mode === Mode.LIFT_DOWN) cost *= 3;
|
||||
if (mode === Mode.UPHILL_PISTE) cost *= 1.5;
|
||||
const uses = (dir === 0 ? net.edgeFwd[e] : net.edgeBack[e]) as number;
|
||||
if (mode === Mode.SNOWSHOE && uses & Use.SNOWSHOE) cost *= 0.8;
|
||||
if (mode === Mode.NORDIC) cost *= nordicPenalty(profile, net, e);
|
||||
if (dir === 1 && flags & EdgeFlag.DIR_CONFLICT && SKIING_MODES.has(mode)) cost *= 1.5;
|
||||
if (dir === 1 && flags & EdgeFlag.NORDIC_SOFT_ONEWAY && mode === Mode.NORDIC) cost *= 1.1;
|
||||
if (kind === Kind.AREA && flags & EdgeFlag.AREA_HAS_RUN) cost *= 1.3;
|
||||
if (flags & EdgeFlag.TYPE_UNKNOWN) cost *= 1.5;
|
||||
if (profile.discipline === 'snowboard') {
|
||||
if (mode === Mode.DOWNHILL && flags & EdgeFlag.FLAT_FOR_BOARD) cost *= 3;
|
||||
if (mode === Mode.BOARD && liftClassOf(net.edgeLift[e] as number) === 'drag') cost += 600;
|
||||
}
|
||||
if (profile.options.priority === 'most_skiing') {
|
||||
if (SKIING_MODES.has(mode)) cost /= 1.8;
|
||||
else if (LIFT_MODES.has(mode)) cost *= 1.2;
|
||||
}
|
||||
if (profile.options.priority === 'easiest' && mode === Mode.DOWNHILL && PISTE_KINDS.has(kind)) {
|
||||
cost *= easiestFactor(profile.options, net.edgeDiff[e] as number);
|
||||
}
|
||||
if (flags & EdgeFlag.INFERRED_LINK) {
|
||||
cost = (cost + 30) * ((net.edgeLen[e] as number) > 50 ? 2 : 1);
|
||||
if (profile.options.avoid_inferred_links) cost *= 5;
|
||||
}
|
||||
return cost;
|
||||
}
|
||||
|
||||
/** Every edge's time, cost and mode in both directions for one profile. */
|
||||
export function compileProfile(profile: SkiProfile, net: SkiNet): EdgeCosts {
|
||||
const m = net.edgeCount;
|
||||
const costs: EdgeCosts = {
|
||||
timeFwd: new Float64Array(m),
|
||||
timeBack: new Float64Array(m),
|
||||
costFwd: new Float64Array(m),
|
||||
costBack: new Float64Array(m),
|
||||
modeFwd: new Uint8Array(m),
|
||||
modeBack: new Uint8Array(m),
|
||||
maxSpeed: 1,
|
||||
};
|
||||
for (let e = 0; e < m; e++) {
|
||||
for (const dir of [0, 1] as const) {
|
||||
const mode = travelMode(profile, net, e, dir);
|
||||
const arrive = dir === 0 ? (net.edgeB[e] as number) : (net.edgeA[e] as number);
|
||||
const time =
|
||||
mode === Mode.NONE ? Infinity : edgeSeconds(profile, net, e, dir, mode) + dismountSeconds(net, arrive);
|
||||
const cost = mode === Mode.NONE ? Infinity : edgeCost(profile, net, e, dir, mode, time);
|
||||
if (dir === 0) {
|
||||
costs.timeFwd[e] = time;
|
||||
costs.costFwd[e] = cost;
|
||||
costs.modeFwd[e] = mode;
|
||||
} else {
|
||||
costs.timeBack[e] = time;
|
||||
costs.costBack[e] = cost;
|
||||
costs.modeBack[e] = mode;
|
||||
}
|
||||
// A little over the fastest edge, so the heuristic stays below any cost the lengths' rounding could give.
|
||||
const length = net.edgeLen[e] as number;
|
||||
if (Number.isFinite(cost) && cost > 0 && length > 0) {
|
||||
costs.maxSpeed = Math.max(costs.maxSpeed, (length / cost) * 1.02);
|
||||
}
|
||||
}
|
||||
}
|
||||
return costs;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { metresBetween } from '../../../lib/geo/geo';
|
||||
import { MinHeap } from '../../../lib/geo/min-heap';
|
||||
import type { EdgeCosts } from './profile';
|
||||
import type { Snap } from './snap';
|
||||
import type { SkiNet } from './stitch';
|
||||
|
||||
/**
|
||||
* The search (spec 7.5): A* over the network on cost, from any of a leg's
|
||||
* start places to any of its end places, with the straight line at the
|
||||
* fastest edge's speed as the heuristic, so the first way found is the
|
||||
* cheapest. A search has a budget of settled nodes and of time; spent, it
|
||||
* answers `timeout` rather than a guess.
|
||||
*/
|
||||
|
||||
/** A stretch of one edge, travelled from share `from` to share `to` of its length (both 0 to 1, a to b). */
|
||||
export interface Step {
|
||||
edge: number;
|
||||
dir: 0 | 1;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
export interface FoundPath {
|
||||
steps: Step[];
|
||||
cost: number;
|
||||
start: Snap;
|
||||
end: Snap;
|
||||
}
|
||||
|
||||
export type SearchOutcome =
|
||||
| { status: 'ok'; path: FoundPath; settled: number }
|
||||
| { status: 'none'; settled: number; nearest: { lat: number; lng: number } | null }
|
||||
| { status: 'timeout'; settled: number };
|
||||
|
||||
export interface SearchBudget {
|
||||
maxSettled: number;
|
||||
deadline: number;
|
||||
}
|
||||
|
||||
/** The most nodes one search settles before it gives up, and how long it may take. */
|
||||
export const MAX_SETTLED = 400_000;
|
||||
export const LEG_TIME_MS = 3000;
|
||||
|
||||
/** Per-node state of a search, reused between searches on the same network and told apart by a stamp. */
|
||||
class SearchState {
|
||||
readonly g: Float64Array;
|
||||
readonly prevEdge: Int32Array;
|
||||
readonly prevDir: Uint8Array;
|
||||
readonly startOf: Int32Array;
|
||||
readonly stamp: Uint32Array;
|
||||
readonly settled: Uint32Array;
|
||||
current = 0;
|
||||
readonly heap = new MinHeap(4096);
|
||||
|
||||
constructor(nodes: number) {
|
||||
this.g = new Float64Array(nodes);
|
||||
this.prevEdge = new Int32Array(nodes);
|
||||
this.prevDir = new Uint8Array(nodes);
|
||||
this.startOf = new Int32Array(nodes);
|
||||
this.stamp = new Uint32Array(nodes);
|
||||
this.settled = new Uint32Array(nodes);
|
||||
}
|
||||
|
||||
begin(): void {
|
||||
this.current++;
|
||||
this.heap.clear();
|
||||
}
|
||||
|
||||
cost(node: number): number {
|
||||
return this.stamp[node] === this.current ? (this.g[node] as number) : Infinity;
|
||||
}
|
||||
|
||||
set(node: number, cost: number, edge: number, dir: 0 | 1, start: number): void {
|
||||
this.stamp[node] = this.current;
|
||||
this.g[node] = cost;
|
||||
this.prevEdge[node] = edge;
|
||||
this.prevDir[node] = dir;
|
||||
this.startOf[node] = start;
|
||||
}
|
||||
}
|
||||
|
||||
const states = new WeakMap<SkiNet, SearchState>();
|
||||
|
||||
function stateOf(net: SkiNet): SearchState {
|
||||
let state = states.get(net);
|
||||
if (!state) {
|
||||
state = new SearchState(net.nodeCount);
|
||||
states.set(net, state);
|
||||
}
|
||||
state.begin();
|
||||
return state;
|
||||
}
|
||||
|
||||
/** How a search may begin: at a node (a lift station, or an edge's end reached from a start place on it). */
|
||||
interface Entry {
|
||||
snap: Snap;
|
||||
/** The partial stretch from the start place to the node, or null where the place is the node. */
|
||||
step: Step | null;
|
||||
}
|
||||
|
||||
/** How a search may end: from a node, along part of an edge to an end place, at an extra cost. */
|
||||
interface Exit {
|
||||
snap: Snap;
|
||||
step: Step | null;
|
||||
extra: number;
|
||||
}
|
||||
|
||||
/** What a stretch of an edge costs; none at all where it has no length (a place right at a node). */
|
||||
function stepCost(costs: EdgeCosts, step: Step, penalty: Float64Array | null): number {
|
||||
const share = Math.abs(step.to - step.from);
|
||||
if (share === 0) return 0;
|
||||
const cost = step.dir === 0 ? (costs.costFwd[step.edge] as number) : (costs.costBack[step.edge] as number);
|
||||
return cost * share * (penalty ? (penalty[step.edge] as number) : 1);
|
||||
}
|
||||
|
||||
function entriesOf(net: SkiNet, snap: Snap): Array<{ node: number; entry: Entry }> {
|
||||
if (snap.edge < 0) return [{ node: snap.node, entry: { snap, step: null } }];
|
||||
return [
|
||||
{ node: net.edgeB[snap.edge] as number, entry: { snap, step: { edge: snap.edge, dir: 0, from: snap.t, to: 1 } } },
|
||||
{ node: net.edgeA[snap.edge] as number, entry: { snap, step: { edge: snap.edge, dir: 1, from: snap.t, to: 0 } } },
|
||||
];
|
||||
}
|
||||
|
||||
function exitsOf(net: SkiNet, snap: Snap): Array<{ node: number; exit: Omit<Exit, 'extra'> }> {
|
||||
if (snap.edge < 0) return [{ node: snap.node, exit: { snap, step: null } }];
|
||||
return [
|
||||
{ node: net.edgeA[snap.edge] as number, exit: { snap, step: { edge: snap.edge, dir: 0, from: 0, to: snap.t } } },
|
||||
{ node: net.edgeB[snap.edge] as number, exit: { snap, step: { edge: snap.edge, dir: 1, from: 1, to: snap.t } } },
|
||||
];
|
||||
}
|
||||
|
||||
/** Both places on one edge (or at one station): the stretch between them, where the profile may go that way. */
|
||||
function directPath(costs: EdgeCosts, from: Snap, to: Snap, penalty: Float64Array | null): FoundPath | null {
|
||||
const sameSpot = from.edge < 0 ? to.edge < 0 && from.node === to.node : from.edge === to.edge && from.t === to.t;
|
||||
if (sameSpot) return { steps: [], cost: from.startCost + to.startCost, start: from, end: to };
|
||||
if (from.edge < 0 || from.edge !== to.edge) return null;
|
||||
const step: Step = { edge: from.edge, dir: to.t > from.t ? 0 : 1, from: from.t, to: to.t };
|
||||
const cost = stepCost(costs, step, penalty);
|
||||
if (!Number.isFinite(cost)) return null;
|
||||
return { steps: [step], cost: from.startCost + cost + to.startCost, start: from, end: to };
|
||||
}
|
||||
|
||||
interface Best {
|
||||
cost: number;
|
||||
node: number;
|
||||
exit: Exit | null;
|
||||
direct: FoundPath | null;
|
||||
}
|
||||
|
||||
/** The path back from the node an exit was taken at, to the entry it began with. */
|
||||
function unwind(net: SkiNet, state: SearchState, entries: Entry[], node: number, exit: Exit, cost: number): FoundPath {
|
||||
const steps: Step[] = [];
|
||||
if (exit.step) steps.push(exit.step);
|
||||
let at = node;
|
||||
while ((state.prevEdge[at] as number) >= 0) {
|
||||
const edge = state.prevEdge[at] as number;
|
||||
const dir = state.prevDir[at] as 0 | 1;
|
||||
steps.push(dir === 0 ? { edge, dir, from: 0, to: 1 } : { edge, dir, from: 1, to: 0 });
|
||||
at = dir === 0 ? (net.edgeA[edge] as number) : (net.edgeB[edge] as number);
|
||||
}
|
||||
const entry = entries[state.startOf[at] as number] as Entry;
|
||||
if (entry.step) steps.push(entry.step);
|
||||
steps.reverse();
|
||||
return { steps, cost, start: entry.snap, end: exit.snap };
|
||||
}
|
||||
|
||||
/**
|
||||
* The cheapest way from any start place to any end place, or why there is
|
||||
* none. `penalty` multiplies the cost of an edge (the alternatives' search);
|
||||
* `target` is the clicked end point, which the heuristic measures to.
|
||||
*/
|
||||
export function searchPath(
|
||||
net: SkiNet,
|
||||
costs: EdgeCosts,
|
||||
starts: readonly Snap[],
|
||||
ends: readonly Snap[],
|
||||
target: { lat: number; lng: number },
|
||||
budget: SearchBudget,
|
||||
penalty: Float64Array | null = null,
|
||||
): SearchOutcome {
|
||||
const state = stateOf(net);
|
||||
const h = (node: number) =>
|
||||
metresBetween(net.nodeLat[node] as number, net.nodeLng[node] as number, target.lat, target.lng) / costs.maxSpeed;
|
||||
const entries: Entry[] = [];
|
||||
const best: Best = { cost: Infinity, node: -1, exit: null, direct: null };
|
||||
|
||||
for (const start of starts) {
|
||||
for (const end of ends) {
|
||||
const direct = directPath(costs, start, end, penalty);
|
||||
if (direct && direct.cost < best.cost) Object.assign(best, { cost: direct.cost, direct });
|
||||
}
|
||||
for (const { node, entry } of entriesOf(net, start)) {
|
||||
const cost = start.startCost + (entry.step ? stepCost(costs, entry.step, penalty) : 0);
|
||||
if (!Number.isFinite(cost) || cost >= state.cost(node)) continue;
|
||||
entries.push(entry);
|
||||
state.set(node, cost, -1, 0, entries.length - 1);
|
||||
state.heap.push(node, cost + h(node));
|
||||
}
|
||||
}
|
||||
|
||||
const exits = new Map<number, Exit[]>();
|
||||
for (const end of ends) {
|
||||
for (const { node, exit } of exitsOf(net, end)) {
|
||||
const extra = end.startCost + (exit.step ? stepCost(costs, exit.step, penalty) : 0);
|
||||
if (!Number.isFinite(extra)) continue;
|
||||
exits.set(node, [...(exits.get(node) ?? []), { ...exit, extra }]);
|
||||
}
|
||||
}
|
||||
|
||||
let settledCount = 0;
|
||||
let nearest = -1;
|
||||
let nearestDistance = Infinity;
|
||||
while (state.heap.size > 0) {
|
||||
if (state.heap.peekKey() >= best.cost) break;
|
||||
const u = state.heap.pop();
|
||||
if (state.settled[u] === state.current) continue;
|
||||
state.settled[u] = state.current;
|
||||
settledCount++;
|
||||
if (settledCount > budget.maxSettled || ((settledCount & 1023) === 0 && Date.now() > budget.deadline)) {
|
||||
return { status: 'timeout', settled: settledCount };
|
||||
}
|
||||
const gu = state.cost(u);
|
||||
const remaining = h(u);
|
||||
if (remaining < nearestDistance) {
|
||||
nearestDistance = remaining;
|
||||
nearest = u;
|
||||
}
|
||||
for (const exit of exits.get(u) ?? []) {
|
||||
if (gu + exit.extra < best.cost) Object.assign(best, { cost: gu + exit.extra, node: u, exit, direct: null });
|
||||
}
|
||||
relax(net, costs, state, u, gu, h, penalty);
|
||||
}
|
||||
|
||||
if (best.direct) return { status: 'ok', path: best.direct, settled: settledCount };
|
||||
if (best.exit) {
|
||||
return { status: 'ok', path: unwind(net, state, entries, best.node, best.exit, best.cost), settled: settledCount };
|
||||
}
|
||||
const point = nearest >= 0 ? { lat: net.nodeLat[nearest] as number, lng: net.nodeLng[nearest] as number } : null;
|
||||
return { status: 'none', settled: settledCount, nearest: point };
|
||||
}
|
||||
|
||||
/** Every edge out of a settled node, in each direction the profile may travel it. */
|
||||
function relax(
|
||||
net: SkiNet,
|
||||
costs: EdgeCosts,
|
||||
state: SearchState,
|
||||
u: number,
|
||||
gu: number,
|
||||
h: (node: number) => number,
|
||||
penalty: Float64Array | null,
|
||||
): void {
|
||||
for (let i = net.adjStart[u] as number; i < (net.adjStart[u + 1] as number); i++) {
|
||||
const e = net.adjEdge[i] as number;
|
||||
const a = net.edgeA[e] as number;
|
||||
const b = net.edgeB[e] as number;
|
||||
if (a === b) continue;
|
||||
const dir: 0 | 1 = a === u ? 0 : 1;
|
||||
const v = dir === 0 ? b : a;
|
||||
const edgeCost = dir === 0 ? (costs.costFwd[e] as number) : (costs.costBack[e] as number);
|
||||
if (!Number.isFinite(edgeCost) || state.settled[v] === state.current) continue;
|
||||
const next = gu + edgeCost * (penalty ? (penalty[e] as number) : 1);
|
||||
if (next >= state.cost(v)) continue;
|
||||
state.set(v, next, e, dir, -1);
|
||||
state.heap.push(v, next + h(v));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { metresBetween } from '../../../lib/geo/geo';
|
||||
import { DIFF_UNKNOWN, DIFFICULTY_NAMES, EdgeFlag, Groom, Kind, LIFT_NAMES } from '../ski-graph.contract';
|
||||
import { dismountSeconds, liftClassOf, Mode, type EdgeCosts } from './profile';
|
||||
import type { Step } from './router';
|
||||
import type { SkiNet } from './stitch';
|
||||
import type { SkiFlag, SkiGeometryPoint, SkiLegSeconds, SkiRouteSegment, SkiSegmentKind, SkiWhy } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* A found path as the planner reads it (spec 9.2): one line of points with
|
||||
* their heights, cut into stretches of one kind and one name (a run, a lift
|
||||
* with its wait, a link the data does not hold), with the time of each and
|
||||
* the totals, which are the stretches added up and nothing else.
|
||||
*/
|
||||
|
||||
interface BuiltLeg {
|
||||
geometry: SkiGeometryPoint[];
|
||||
segments: SkiRouteSegment[];
|
||||
distance_m: number;
|
||||
climb_m: number;
|
||||
descent_m: number;
|
||||
seconds: SkiLegSeconds;
|
||||
/** Every flag on the way and how many stretches of edge carry it, for the notes. */
|
||||
flagCounts: Map<SkiFlag, number>;
|
||||
}
|
||||
|
||||
/** The segment kind of an edge kind, before the mode it is travelled in says otherwise. */
|
||||
const KIND_SEGMENT: readonly SkiSegmentKind[] = [
|
||||
'run',
|
||||
'lift',
|
||||
'rail',
|
||||
'lift',
|
||||
'lift',
|
||||
'nordic',
|
||||
'skitour',
|
||||
'hike',
|
||||
'hike',
|
||||
'connection',
|
||||
'area',
|
||||
'link',
|
||||
'crossing',
|
||||
'walk',
|
||||
];
|
||||
|
||||
const DOWN_MODES: ReadonlySet<number> = new Set([Mode.LIFT_DOWN, Mode.BOARD_DOWN, Mode.ALIGHT_DOWN]);
|
||||
const UP_MODES: ReadonlySet<number> = new Set([Mode.LIFT, Mode.BOARD, Mode.ALIGHT]);
|
||||
const ON_FOOT_KINDS: ReadonlySet<number> = new Set([Kind.LINK, Kind.CROSSING, Kind.WALK]);
|
||||
|
||||
const GROOMING: ReadonlyArray<readonly [number, string]> = [
|
||||
[Groom.CLASSIC, 'classic'],
|
||||
[Groom.SKATING, 'skating'],
|
||||
[Groom.SCOOTER, 'scooter'],
|
||||
[Groom.BACKCOUNTRY, 'backcountry'],
|
||||
[Groom.MOGUL, 'mogul'],
|
||||
];
|
||||
|
||||
const EDGE_FLAGS: ReadonlyArray<readonly [number, SkiFlag]> = [
|
||||
[EdgeFlag.INFERRED_LINK, 'inferred_link'],
|
||||
[EdgeFlag.INFERRED_CROSSING, 'inferred_crossing'],
|
||||
[EdgeFlag.DIR_FROM_DEM, 'dir_from_dem'],
|
||||
[EdgeFlag.DIR_CONFLICT, 'dir_conflict'],
|
||||
[EdgeFlag.RELATION_INHERITED, 'relation_inherited'],
|
||||
[EdgeFlag.AREA_DERIVED, 'area_derived'],
|
||||
[EdgeFlag.TUNNEL, 'tunnel'],
|
||||
];
|
||||
|
||||
/** The flags the notes of a planned leg are built from (spec 7.6). */
|
||||
const WHY_FLAGS: readonly SkiFlag[] = [
|
||||
'ride_down',
|
||||
'dir_from_dem',
|
||||
'technique_assumed',
|
||||
'inferred_link',
|
||||
'relation_inherited',
|
||||
'area_derived',
|
||||
'difficulty_unknown',
|
||||
];
|
||||
|
||||
function segmentKind(net: SkiNet, e: number, mode: number): SkiSegmentKind {
|
||||
const kind = net.edgeKind[e] as number;
|
||||
if (DOWN_MODES.has(mode)) return 'lift_down';
|
||||
if (UP_MODES.has(mode)) return liftClassOf(net.edgeLift[e] as number) === 'rail' ? 'rail' : 'lift';
|
||||
if (mode === Mode.WALK && (kind === Kind.RUN || kind === Kind.AREA)) return 'crossing';
|
||||
return KIND_SEGMENT[kind] ?? 'walk';
|
||||
}
|
||||
|
||||
export function difficultyName(diff: number): SkiRouteSegment['difficulty'] {
|
||||
return diff === DIFF_UNKNOWN ? null : (DIFFICULTY_NAMES[diff] ?? null);
|
||||
}
|
||||
|
||||
const modeOf = (costs: EdgeCosts, step: Step): number =>
|
||||
(step.dir === 0 ? costs.modeFwd[step.edge] : costs.modeBack[step.edge]) as number;
|
||||
|
||||
/** The flags one stretch of edge carries, as travelled. */
|
||||
function stepFlags(net: SkiNet, costs: EdgeCosts, step: Step): SkiFlag[] {
|
||||
const flags = net.edgeFlags[step.edge] as number;
|
||||
const kind = net.edgeKind[step.edge] as number;
|
||||
const mode = modeOf(costs, step);
|
||||
const out = EDGE_FLAGS.filter(([bit]) => flags & bit).map(([, flag]) => flag);
|
||||
if (flags & EdgeFlag.TECHNIQUE_ASSUMED && mode === Mode.NORDIC) out.push('technique_assumed');
|
||||
if (DOWN_MODES.has(mode)) out.push('ride_down');
|
||||
if (mode === Mode.UPHILL_PISTE) out.push('uphill_on_piste');
|
||||
const descending = mode === Mode.DOWNHILL || mode === Mode.SKITOUR_DOWN;
|
||||
if (descending && (kind === Kind.RUN || kind === Kind.AREA) && net.edgeDiff[step.edge] === DIFF_UNKNOWN) {
|
||||
out.push('difficulty_unknown');
|
||||
}
|
||||
const arrives = step.to === 1 || step.to === 0;
|
||||
const node = step.dir === 0 ? (net.edgeB[step.edge] as number) : (net.edgeA[step.edge] as number);
|
||||
if (arrives && dismountSeconds(net, node) > 0) out.push('dismount');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The points of an edge between two shares of its length, in the direction travelled. */
|
||||
function stepPoints(net: SkiNet, step: Step): SkiGeometryPoint[] {
|
||||
const first = net.geomStart[step.edge] as number;
|
||||
const last = (net.geomStart[step.edge + 1] as number) - 1;
|
||||
const at: number[] = [0];
|
||||
for (let i = first; i < last; i++) {
|
||||
const piece = metresBetween(
|
||||
net.geomLat[i] as number,
|
||||
net.geomLng[i] as number,
|
||||
net.geomLat[i + 1] as number,
|
||||
net.geomLng[i + 1] as number,
|
||||
);
|
||||
at.push((at[at.length - 1] as number) + piece);
|
||||
}
|
||||
const total = at[at.length - 1] as number;
|
||||
const share = (k: number) => (total > 0 ? (at[k] as number) / total : k / Math.max(1, last - first));
|
||||
const pointAt = (f: number): SkiGeometryPoint => {
|
||||
let k = 0;
|
||||
while (k < last - first - 1 && share(k + 1) < f) k++;
|
||||
const span = share(k + 1) - share(k);
|
||||
const r = span > 0 ? Math.min(1, Math.max(0, (f - share(k)) / span)) : 0;
|
||||
const a = first + k;
|
||||
const ele = (net.geomEle[a] as number) + ((net.geomEle[a + 1] as number) - (net.geomEle[a] as number)) * r;
|
||||
return [
|
||||
(net.geomLat[a] as number) + ((net.geomLat[a + 1] as number) - (net.geomLat[a] as number)) * r,
|
||||
(net.geomLng[a] as number) + ((net.geomLng[a + 1] as number) - (net.geomLng[a] as number)) * r,
|
||||
Number.isFinite(ele) ? Math.round(ele) : null,
|
||||
];
|
||||
};
|
||||
const low = Math.min(step.from, step.to);
|
||||
const high = Math.max(step.from, step.to);
|
||||
const points: SkiGeometryPoint[] = [pointAt(low)];
|
||||
for (let k = 1; k < last - first; k++) {
|
||||
const f = share(k);
|
||||
if (f > low && f < high) {
|
||||
const ele = net.geomEle[first + k] as number;
|
||||
points.push([
|
||||
net.geomLat[first + k] as number,
|
||||
net.geomLng[first + k] as number,
|
||||
Number.isFinite(ele) ? ele : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
points.push(pointAt(high));
|
||||
return step.from <= step.to ? points : points.reverse();
|
||||
}
|
||||
|
||||
const samePoint = (a: SkiGeometryPoint, b: SkiGeometryPoint) =>
|
||||
Math.abs(a[0] - b[0]) < 1e-9 && Math.abs(a[1] - b[1]) < 1e-9;
|
||||
|
||||
interface Piece {
|
||||
key: string;
|
||||
segment: SkiRouteSegment;
|
||||
}
|
||||
|
||||
/** One stretch of edge as a segment of its own, before neighbours of the same kind and name are joined. */
|
||||
function stepSegment(net: SkiNet, costs: EdgeCosts, step: Step, from: number, to: number): Piece {
|
||||
const e = step.edge;
|
||||
const mode = modeOf(costs, step);
|
||||
const share = Math.abs(step.to - step.from);
|
||||
const time = ((step.dir === 0 ? costs.timeFwd[e] : costs.timeBack[e]) as number) * share;
|
||||
const kind = segmentKind(net, e, mode);
|
||||
const waits = mode === Mode.BOARD || mode === Mode.BOARD_DOWN;
|
||||
const lift = net.edgeLift[e] as number;
|
||||
const liftType = lift > 0 ? (LIFT_NAMES[lift] ?? null) : null;
|
||||
const pisteKind = kind === 'run' || kind === 'area' || kind === 'connection';
|
||||
const difficulty = pisteKind ? difficultyName(net.edgeDiff[e] as number) : null;
|
||||
const name = net.edgeName[e] ?? null;
|
||||
const ref = net.edgeRef[e] ?? null;
|
||||
const way = net.edgeWay[e] as number;
|
||||
return {
|
||||
key: [kind, name, ref, difficulty, liftType].join('|'),
|
||||
segment: {
|
||||
kind,
|
||||
from,
|
||||
to,
|
||||
name,
|
||||
ref,
|
||||
difficulty,
|
||||
lift_type: liftType,
|
||||
grooming: GROOMING.filter(([bit]) => (net.edgeGroom[e] as number) & bit).map(([, label]) => label),
|
||||
seconds: waits ? 0 : time,
|
||||
wait_seconds: waits ? time : 0,
|
||||
length_m: (net.edgeLen[e] as number) * share,
|
||||
flags: stepFlags(net, costs, step),
|
||||
osm_way: way > 0 ? way : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Which of the four totals a stretch of edge counts towards. */
|
||||
function bucketOf(net: SkiNet, costs: EdgeCosts, step: Step): keyof SkiLegSeconds {
|
||||
const mode = modeOf(costs, step);
|
||||
if (mode === Mode.BOARD || mode === Mode.BOARD_DOWN) return 'wait';
|
||||
if (UP_MODES.has(mode) || DOWN_MODES.has(mode)) return 'lift';
|
||||
if (mode === Mode.WALK || ON_FOOT_KINDS.has(net.edgeKind[step.edge] as number)) return 'walk';
|
||||
return 'ski';
|
||||
}
|
||||
|
||||
/** A found path as a leg: its line, its stretches and its totals. */
|
||||
export function buildLeg(net: SkiNet, costs: EdgeCosts, steps: readonly Step[], start: [number, number, number]) {
|
||||
const geometry: SkiGeometryPoint[] = [];
|
||||
const pieces: Piece[] = [];
|
||||
const seconds: SkiLegSeconds = { ski: 0, lift: 0, wait: 0, walk: 0 };
|
||||
const flagCounts = new Map<SkiFlag, number>();
|
||||
let climb = 0;
|
||||
let descent = 0;
|
||||
let distance = 0;
|
||||
for (const step of steps) {
|
||||
if (Math.abs(step.to - step.from) < 1e-9) continue;
|
||||
const points = stepPoints(net, step);
|
||||
const last = geometry[geometry.length - 1];
|
||||
const joins = last !== undefined && samePoint(last, points[0] as SkiGeometryPoint);
|
||||
const from = joins ? geometry.length - 1 : geometry.length;
|
||||
geometry.push(...(joins ? points.slice(1) : points));
|
||||
const piece = stepSegment(net, costs, step, from, geometry.length - 1);
|
||||
pieces.push(piece);
|
||||
const share = Math.abs(step.to - step.from);
|
||||
const up = step.dir === 0 ? net.edgeUp[step.edge] : net.edgeDown[step.edge];
|
||||
const down = step.dir === 0 ? net.edgeDown[step.edge] : net.edgeUp[step.edge];
|
||||
climb += (up as number) * share;
|
||||
descent += (down as number) * share;
|
||||
distance += piece.segment.length_m;
|
||||
const bucket = bucketOf(net, costs, step);
|
||||
seconds[bucket] += piece.segment.seconds + piece.segment.wait_seconds;
|
||||
for (const flag of piece.segment.flags) flagCounts.set(flag, (flagCounts.get(flag) ?? 0) + 1);
|
||||
}
|
||||
if (geometry.length < 2) {
|
||||
// Both ends on one spot: a leg of no length, still a line of two points.
|
||||
const spot: SkiGeometryPoint = [start[0], start[1], Number.isFinite(start[2]) ? start[2] : null];
|
||||
geometry.splice(0, geometry.length, spot, [...spot]);
|
||||
pieces.splice(0, pieces.length, {
|
||||
key: 'walk',
|
||||
segment: {
|
||||
kind: 'walk',
|
||||
from: 0,
|
||||
to: 1,
|
||||
name: null,
|
||||
ref: null,
|
||||
difficulty: null,
|
||||
lift_type: null,
|
||||
grooming: [],
|
||||
seconds: 0,
|
||||
wait_seconds: 0,
|
||||
length_m: 0,
|
||||
flags: [],
|
||||
osm_way: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
geometry,
|
||||
segments: joinPieces(pieces),
|
||||
distance_m: round1(distance),
|
||||
climb_m: Math.round(climb),
|
||||
descent_m: Math.round(descent),
|
||||
// Not rounded, here or in the segments: the totals are the segments added up, to the second.
|
||||
seconds,
|
||||
flagCounts,
|
||||
} satisfies BuiltLeg;
|
||||
}
|
||||
|
||||
const round1 = (value: number): number => Math.round(value * 10) / 10;
|
||||
|
||||
/** Neighbouring stretches of one kind and name as one segment; a lift's wait, ride and exit become one. */
|
||||
function joinPieces(pieces: readonly Piece[]): SkiRouteSegment[] {
|
||||
const out: Array<{ key: string; segment: SkiRouteSegment }> = [];
|
||||
for (const piece of pieces) {
|
||||
const previous = out[out.length - 1];
|
||||
if (previous && previous.key === piece.key) {
|
||||
const joined = previous.segment;
|
||||
joined.to = piece.segment.to;
|
||||
joined.seconds += piece.segment.seconds;
|
||||
joined.wait_seconds += piece.segment.wait_seconds;
|
||||
joined.length_m += piece.segment.length_m;
|
||||
joined.flags = [...new Set([...joined.flags, ...piece.segment.flags])];
|
||||
joined.grooming = [...new Set([...joined.grooming, ...piece.segment.grooming])];
|
||||
joined.osm_way ??= piece.segment.osm_way;
|
||||
continue;
|
||||
}
|
||||
out.push({ key: piece.key, segment: { ...piece.segment } });
|
||||
}
|
||||
return out.map(({ segment }) => ({ ...segment, length_m: round1(segment.length_m) }));
|
||||
}
|
||||
|
||||
/** The flag notes of a planned leg, from the flags its stretches carry. */
|
||||
export function flagNotes(flagCounts: ReadonlyMap<SkiFlag, number>): SkiWhy[] {
|
||||
return WHY_FLAGS.flatMap((flag) => {
|
||||
const count = flagCounts.get(flag) ?? 0;
|
||||
return count > 0 ? [{ kind: 'flag' as const, flag, count }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** The total time of a built leg, waits included. */
|
||||
export const legSeconds = (leg: Pick<BuiltLeg, 'seconds'>): number =>
|
||||
leg.seconds.ski + leg.seconds.lift + leg.seconds.wait + leg.seconds.walk;
|
||||
@@ -0,0 +1,295 @@
|
||||
import { metresBetween, projectOnSegment } from '../../../lib/geo/geo';
|
||||
import { Kind, NodeFlag } from '../ski-graph.contract';
|
||||
import type { EdgeCosts } from './profile';
|
||||
import type { SkiNet } from './stitch';
|
||||
import type { SkiDiscipline } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Where a clicked point joins the network (spec 7.4). Up to five places on
|
||||
* the runs and trails the profile may use, the best one per edge, each a
|
||||
* virtual point at a share of its edge's length; the search may start from
|
||||
* any of them, so a click between two parallel runs finds the one that goes
|
||||
* somewhere. A click right beside a lift means the lift: its bottom station
|
||||
* for the start of a leg, its top station for the end.
|
||||
*/
|
||||
|
||||
/** How far a click may lie off the network and still join it, metres. */
|
||||
export const SNAP_RADIUS_M: Record<SkiDiscipline, number> = {
|
||||
alpine: 150,
|
||||
snowboard: 150,
|
||||
cross_country: 100,
|
||||
touring: 300,
|
||||
snowshoe: 300,
|
||||
winter_hike: 300,
|
||||
};
|
||||
|
||||
/** A click this close to a lift's line is taken for the lift. */
|
||||
export const LIFT_SNAP_M = 40;
|
||||
/** The most places a click joins the network at. */
|
||||
export const MAX_SNAPS = 5;
|
||||
/** Walking pace from the click to where it joins the network, m/s: what each place costs to reach. */
|
||||
const SNAP_WALK_SPEED = 1;
|
||||
|
||||
export interface Snap {
|
||||
/** The edge the point lies on, or -1 where the click took a lift station (`node`). */
|
||||
edge: number;
|
||||
node: number;
|
||||
/** The share of the edge's length from its first node to the point. */
|
||||
t: number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
ele: number;
|
||||
/** Metres from the click. */
|
||||
distance: number;
|
||||
/** Seconds the search starts (or ends) the point with. */
|
||||
startCost: number;
|
||||
}
|
||||
|
||||
/** A coarse grid of the network's line pieces, for finding what lies near a click. */
|
||||
interface SegmentGrid {
|
||||
cells: Map<number, number[]>;
|
||||
}
|
||||
|
||||
const CELL_DEG = 0.002;
|
||||
const cellKey = (y: number, x: number): number => (y + 50_000) * 200_000 + (x + 100_000);
|
||||
|
||||
const grids = new WeakMap<SkiNet, SegmentGrid>();
|
||||
|
||||
/** Each line piece of the network under every grid cell its box touches, stored as `point index`. */
|
||||
function gridOf(net: SkiNet): SegmentGrid {
|
||||
const known = grids.get(net);
|
||||
if (known) return known;
|
||||
const cells = new Map<number, number[]>();
|
||||
for (let e = 0; e < net.edgeCount; e++) {
|
||||
for (let i = net.geomStart[e] as number; i < (net.geomStart[e + 1] as number) - 1; i++) {
|
||||
const y0 = Math.floor(Math.min(net.geomLat[i] as number, net.geomLat[i + 1] as number) / CELL_DEG);
|
||||
const y1 = Math.floor(Math.max(net.geomLat[i] as number, net.geomLat[i + 1] as number) / CELL_DEG);
|
||||
const x0 = Math.floor(Math.min(net.geomLng[i] as number, net.geomLng[i + 1] as number) / CELL_DEG);
|
||||
const x1 = Math.floor(Math.max(net.geomLng[i] as number, net.geomLng[i + 1] as number) / CELL_DEG);
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
for (let x = x0; x <= x1; x++) {
|
||||
const key = cellKey(y, x);
|
||||
const list = cells.get(key);
|
||||
if (list) list.push(i);
|
||||
else cells.set(key, [i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const grid = { cells };
|
||||
grids.set(net, grid);
|
||||
return grid;
|
||||
}
|
||||
|
||||
/** The edge a point of the pool belongs to, by binary search over the edges' starts. */
|
||||
function edgeOfPoint(net: SkiNet, point: number): number {
|
||||
let lo = 0;
|
||||
let hi = net.edgeCount - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if ((net.geomStart[mid] as number) <= point) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
interface Near {
|
||||
edge: number;
|
||||
/** The first point of the nearest line piece, and the share along that piece. */
|
||||
point: number;
|
||||
share: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/** The nearest spot of every edge with a line piece within `radius` of a point. */
|
||||
function nearEdges(net: SkiNet, lat: number, lng: number, radius: number): Map<number, Near> {
|
||||
const grid = gridOf(net);
|
||||
const dLat = radius / 111_195;
|
||||
const dLng = radius / (111_195 * Math.max(0.05, Math.cos((lat * Math.PI) / 180)));
|
||||
const best = new Map<number, Near>();
|
||||
const seen = new Set<number>();
|
||||
for (let y = Math.floor((lat - dLat) / CELL_DEG); y <= Math.floor((lat + dLat) / CELL_DEG); y++) {
|
||||
for (let x = Math.floor((lng - dLng) / CELL_DEG); x <= Math.floor((lng + dLng) / CELL_DEG); x++) {
|
||||
for (const point of grid.cells.get(cellKey(y, x)) ?? []) {
|
||||
if (seen.has(point)) continue;
|
||||
seen.add(point);
|
||||
const { t, distance } = projectOnSegment(
|
||||
lat,
|
||||
lng,
|
||||
net.geomLat[point] as number,
|
||||
net.geomLng[point] as number,
|
||||
net.geomLat[point + 1] as number,
|
||||
net.geomLng[point + 1] as number,
|
||||
);
|
||||
if (distance > radius) continue;
|
||||
const edge = edgeOfPoint(net, point);
|
||||
const known = best.get(edge);
|
||||
if (!known || distance < known.distance) best.set(edge, { edge, point, share: t, distance });
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The share of an edge's length at a spot on one of its line pieces, with the spot's height. */
|
||||
function spotOn(net: SkiNet, near: Near): { t: number; lat: number; lng: number; ele: number } {
|
||||
const first = net.geomStart[near.edge] as number;
|
||||
const last = (net.geomStart[near.edge + 1] as number) - 1;
|
||||
let total = 0;
|
||||
let before = 0;
|
||||
for (let i = first; i < last; i++) {
|
||||
const piece = metresBetween(
|
||||
net.geomLat[i] as number,
|
||||
net.geomLng[i] as number,
|
||||
net.geomLat[i + 1] as number,
|
||||
net.geomLng[i + 1] as number,
|
||||
);
|
||||
if (i < near.point) before += piece;
|
||||
if (i === near.point) before += piece * near.share;
|
||||
total += piece;
|
||||
}
|
||||
const a = near.point;
|
||||
const lerp = (values: Float64Array) =>
|
||||
(values[a] as number) + ((values[a + 1] as number) - (values[a] as number)) * near.share;
|
||||
return { t: total > 0 ? before / total : 0, lat: lerp(net.geomLat), lng: lerp(net.geomLng), ele: lerp(net.geomEle) };
|
||||
}
|
||||
|
||||
const LIFT_KINDS: ReadonlySet<number> = new Set([Kind.LIFT, Kind.RAIL]);
|
||||
const RIDE_KINDS: ReadonlySet<number> = new Set([Kind.LIFT, Kind.RAIL, Kind.BOARD, Kind.ALIGHT]);
|
||||
|
||||
/**
|
||||
* The ground nodes of a lift's stations: every one (`grounds`), and those the
|
||||
* profile may board at (`boards`) and leave at (`alights`).
|
||||
*/
|
||||
function stationsOf(net: SkiNet, costs: EdgeCosts, liftEdge: number) {
|
||||
const chain = new Set<number>([net.edgeA[liftEdge] as number, net.edgeB[liftEdge] as number]);
|
||||
const queue = [...chain];
|
||||
const grounds = new Set<number>();
|
||||
const boards: number[] = [];
|
||||
const alights: number[] = [];
|
||||
while (queue.length > 0) {
|
||||
const node = queue.pop() as number;
|
||||
for (let i = net.adjStart[node] as number; i < (net.adjStart[node + 1] as number); i++) {
|
||||
const e = net.adjEdge[i] as number;
|
||||
const kind = net.edgeKind[e] as number;
|
||||
const usable = Number.isFinite(costs.costFwd[e]);
|
||||
if (kind === Kind.BOARD && net.edgeB[e] === node) {
|
||||
grounds.add(net.edgeA[e] as number);
|
||||
if (usable) boards.push(net.edgeA[e] as number);
|
||||
}
|
||||
if (kind === Kind.ALIGHT && net.edgeA[e] === node) {
|
||||
grounds.add(net.edgeB[e] as number);
|
||||
if (usable) alights.push(net.edgeB[e] as number);
|
||||
}
|
||||
if (!LIFT_KINDS.has(kind)) continue;
|
||||
for (const next of [net.edgeA[e] as number, net.edgeB[e] as number]) {
|
||||
if (!chain.has(next) && (net.nodeFlags[next] as number) & NodeFlag.LIFT_INTERNAL) {
|
||||
chain.add(next);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { grounds: [...grounds], boards, alights };
|
||||
}
|
||||
|
||||
function nodeSnap(net: SkiNet, node: number, lat: number, lng: number): Snap {
|
||||
return {
|
||||
edge: -1,
|
||||
node,
|
||||
t: 0,
|
||||
lat: net.nodeLat[node] as number,
|
||||
lng: net.nodeLng[node] as number,
|
||||
ele: net.nodeEle[node] as number,
|
||||
distance: metresBetween(lat, lng, net.nodeLat[node] as number, net.nodeLng[node] as number),
|
||||
startCost: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The station a click beside a lift means: the station it lies at, when it
|
||||
* lies at one; else the lift's lowest boarding for the start of a leg and its
|
||||
* highest exit for the end. Null when the profile may neither board nor leave.
|
||||
*/
|
||||
function stationSnap(
|
||||
net: SkiNet,
|
||||
costs: EdgeCosts,
|
||||
liftEdge: number,
|
||||
role: 'from' | 'to',
|
||||
lat: number,
|
||||
lng: number,
|
||||
): Snap | null {
|
||||
const { grounds: all, boards, alights } = stationsOf(net, costs, liftEdge);
|
||||
const atStation = all
|
||||
.map((ground) => nodeSnap(net, ground, lat, lng))
|
||||
.filter((snap) => snap.distance <= LIFT_SNAP_M)
|
||||
.sort((a, b) => a.distance - b.distance)[0];
|
||||
if (atStation) return atStation;
|
||||
const grounds = role === 'from' ? boards : alights;
|
||||
if (grounds.length === 0) return null;
|
||||
// A station without a height counts as neither the lowest nor the highest.
|
||||
const height = (ground: number) => {
|
||||
const ele = net.nodeEle[ground] as number;
|
||||
if (!Number.isNaN(ele)) return ele;
|
||||
return role === 'from' ? Infinity : -Infinity;
|
||||
};
|
||||
let node = grounds[0] as number;
|
||||
for (const ground of grounds) {
|
||||
const better = role === 'from' ? height(ground) < height(node) : height(ground) > height(node);
|
||||
if (better) node = ground;
|
||||
}
|
||||
return nodeSnap(net, node, lat, lng);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a clicked point joins the network for a profile: a lift station when
|
||||
* the click lies beside a lift and nearer to it than to anything skiable,
|
||||
* else up to five places on edges the profile may use, nearest first.
|
||||
*/
|
||||
export function snapPoint(
|
||||
net: SkiNet,
|
||||
costs: EdgeCosts,
|
||||
lat: number,
|
||||
lng: number,
|
||||
radius: number,
|
||||
role: 'from' | 'to',
|
||||
): Snap[] {
|
||||
const near = nearEdges(net, lat, lng, Math.max(radius, LIFT_SNAP_M));
|
||||
const usable: Near[] = [];
|
||||
let lift: Near | null = null;
|
||||
for (const spot of near.values()) {
|
||||
const kind = net.edgeKind[spot.edge] as number;
|
||||
if (LIFT_KINDS.has(kind)) {
|
||||
if (spot.distance <= LIFT_SNAP_M && (!lift || spot.distance < lift.distance)) lift = spot;
|
||||
continue;
|
||||
}
|
||||
if (RIDE_KINDS.has(kind) || spot.distance > radius) continue;
|
||||
if (Number.isFinite(costs.costFwd[spot.edge]) || Number.isFinite(costs.costBack[spot.edge])) usable.push(spot);
|
||||
}
|
||||
usable.sort((a, b) => a.distance - b.distance);
|
||||
if (lift && (usable.length === 0 || lift.distance < (usable[0] as Near).distance)) {
|
||||
const station = stationSnap(net, costs, lift.edge, role, lat, lng);
|
||||
if (station) return [station];
|
||||
}
|
||||
return usable.slice(0, MAX_SNAPS).map((spot) => {
|
||||
const at = spotOn(net, spot);
|
||||
return { edge: spot.edge, node: -1, ...at, distance: spot.distance, startCost: spot.distance / SNAP_WALK_SPEED };
|
||||
});
|
||||
}
|
||||
|
||||
/** The nearest edge a profile may use within `radius`, for telling how far off the network a point lies. */
|
||||
export function nearestUsable(
|
||||
net: SkiNet,
|
||||
costs: EdgeCosts,
|
||||
lat: number,
|
||||
lng: number,
|
||||
radius: number,
|
||||
): { edge: number; distance: number } | null {
|
||||
let best: { edge: number; distance: number } | null = null;
|
||||
for (const spot of nearEdges(net, lat, lng, radius).values()) {
|
||||
if (RIDE_KINDS.has(net.edgeKind[spot.edge] as number)) continue;
|
||||
if (!Number.isFinite(costs.costFwd[spot.edge]) && !Number.isFinite(costs.costBack[spot.edge])) continue;
|
||||
if (!best || spot.distance < best.distance) best = { edge: spot.edge, distance: spot.distance };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { poolString } from '../ski-graph';
|
||||
import { ELE_MISSING, EdgeFlag, NodeFlag, type SkiGraphData } from '../ski-graph.contract';
|
||||
|
||||
/**
|
||||
* The network the router searches: one area's graph, or up to four pieces of
|
||||
* a large network stitched together. Coordinates are degrees, heights metres
|
||||
* (NaN where the terrain data has none), names read out of the string pools,
|
||||
* and every node knows its edges.
|
||||
*
|
||||
* Stitching follows the writer's own check (`stitch` in `winter/qa.py`): a
|
||||
* node flagged `boundary` with a positive OSM id is one node in every piece
|
||||
* that holds it, and an edge two pieces both carry (the halo one piece keeps
|
||||
* of its neighbour) is kept once, the copy without `halo` winning. A synthetic
|
||||
* node (negative id) is never joined to anything.
|
||||
*/
|
||||
export interface SkiNet {
|
||||
areaIds: string[];
|
||||
colourScheme: 'europe' | 'north_america';
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
nodeLat: Float64Array;
|
||||
nodeLng: Float64Array;
|
||||
nodeEle: Float64Array;
|
||||
nodeFlags: Uint16Array;
|
||||
nodeOsm: Float64Array;
|
||||
edgeA: Uint32Array;
|
||||
edgeB: Uint32Array;
|
||||
edgeLen: Float64Array;
|
||||
edgeUp: Float64Array;
|
||||
edgeDown: Float64Array;
|
||||
edgeKind: Uint8Array;
|
||||
edgeFwd: Uint16Array;
|
||||
edgeBack: Uint16Array;
|
||||
edgeDiff: Uint8Array;
|
||||
edgeGroom: Uint8Array;
|
||||
edgeLift: Uint8Array;
|
||||
edgeLiftSec: Uint16Array;
|
||||
edgeLiftCap: Uint16Array;
|
||||
edgeFlags: Uint32Array;
|
||||
edgeName: Array<string | null>;
|
||||
edgeRef: Array<string | null>;
|
||||
edgeWay: Float64Array;
|
||||
/** Where each edge's points start in the point pool; `geomStart[e + 1]` is where they end. */
|
||||
geomStart: Uint32Array;
|
||||
geomLat: Float64Array;
|
||||
geomLng: Float64Array;
|
||||
geomEle: Float64Array;
|
||||
/** The edges at each node: `adjEdge[adjStart[n] .. adjStart[n + 1]]`. */
|
||||
adjStart: Uint32Array;
|
||||
adjEdge: Uint32Array;
|
||||
/** Roughly what the arrays take, for the cache's budget. */
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
interface EdgeRef {
|
||||
graph: SkiGraphData;
|
||||
index: number;
|
||||
remap: Int32Array;
|
||||
halo: boolean;
|
||||
}
|
||||
|
||||
/** An edge's identity across pieces: its way and its exact line. */
|
||||
function edgeKey(graph: SkiGraphData, e: number): string {
|
||||
const start = graph.edgeGeomOff[e] as number;
|
||||
const end = graph.edgeGeomOff[e + 1] as number;
|
||||
const parts: number[] = [graph.edgeWay[e] as number];
|
||||
for (let i = start; i < end; i++) parts.push(graph.geomLatE7[i] as number, graph.geomLngE7[i] as number);
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
const metres = (value: number): number => (value === ELE_MISSING ? Number.NaN : value);
|
||||
|
||||
/** The edges to keep of all pieces, in the order they were first met, and each piece's node numbering. */
|
||||
function collect(graphs: readonly SkiGraphData[]) {
|
||||
const byOsm = new Map<number, number>();
|
||||
const nodes: Array<{ graph: SkiGraphData; index: number }> = [];
|
||||
const edges = new Map<string, EdgeRef>();
|
||||
let anonymous = 0;
|
||||
for (const graph of graphs) {
|
||||
const remap = new Int32Array(graph.header.nodeCount);
|
||||
for (let i = 0; i < graph.header.nodeCount; i++) {
|
||||
const osm = graph.nodeOsm[i] as number;
|
||||
const stitchable = graphs.length > 1 && osm > 0 && ((graph.nodeFlags[i] as number) & NodeFlag.BOUNDARY) !== 0;
|
||||
const known = stitchable ? byOsm.get(osm) : undefined;
|
||||
if (known !== undefined) {
|
||||
remap[i] = known;
|
||||
continue;
|
||||
}
|
||||
remap[i] = nodes.length;
|
||||
if (stitchable) byOsm.set(osm, nodes.length);
|
||||
nodes.push({ graph, index: i });
|
||||
}
|
||||
for (let e = 0; e < graph.header.edgeCount; e++) {
|
||||
const halo = ((graph.edgeFlags[e] as number) & EdgeFlag.HALO) !== 0;
|
||||
// One piece alone has nothing to deduplicate.
|
||||
const key = graphs.length > 1 ? edgeKey(graph, e) : String(anonymous++);
|
||||
const kept = edges.get(key);
|
||||
if (kept && (halo || !kept.halo)) continue;
|
||||
edges.set(key, { graph, index: e, remap, halo });
|
||||
}
|
||||
}
|
||||
return { nodes, edges: [...edges.values()] };
|
||||
}
|
||||
|
||||
/** The network of one area (or of the pieces of one) as the router searches it. */
|
||||
export function stitchGraphs(graphs: readonly SkiGraphData[]): SkiNet {
|
||||
if (graphs.length === 0) throw new Error('No graph to stitch');
|
||||
const { nodes, edges } = collect(graphs);
|
||||
const n = nodes.length;
|
||||
const m = edges.length;
|
||||
let points = 0;
|
||||
for (const { graph, index } of edges) {
|
||||
points += (graph.edgeGeomOff[index + 1] as number) - (graph.edgeGeomOff[index] as number);
|
||||
}
|
||||
|
||||
const net: SkiNet = {
|
||||
areaIds: graphs.map((graph) => graph.header.areaId ?? ''),
|
||||
colourScheme: graphs[0]?.header.colourScheme === 'north_america' ? 'north_america' : 'europe',
|
||||
nodeCount: n,
|
||||
edgeCount: m,
|
||||
nodeLat: new Float64Array(n),
|
||||
nodeLng: new Float64Array(n),
|
||||
nodeEle: new Float64Array(n),
|
||||
nodeFlags: new Uint16Array(n),
|
||||
nodeOsm: new Float64Array(n),
|
||||
edgeA: new Uint32Array(m),
|
||||
edgeB: new Uint32Array(m),
|
||||
edgeLen: new Float64Array(m),
|
||||
edgeUp: new Float64Array(m),
|
||||
edgeDown: new Float64Array(m),
|
||||
edgeKind: new Uint8Array(m),
|
||||
edgeFwd: new Uint16Array(m),
|
||||
edgeBack: new Uint16Array(m),
|
||||
edgeDiff: new Uint8Array(m),
|
||||
edgeGroom: new Uint8Array(m),
|
||||
edgeLift: new Uint8Array(m),
|
||||
edgeLiftSec: new Uint16Array(m),
|
||||
edgeLiftCap: new Uint16Array(m),
|
||||
edgeFlags: new Uint32Array(m),
|
||||
edgeName: new Array<string | null>(m),
|
||||
edgeRef: new Array<string | null>(m),
|
||||
edgeWay: new Float64Array(m),
|
||||
geomStart: new Uint32Array(m + 1),
|
||||
geomLat: new Float64Array(points),
|
||||
geomLng: new Float64Array(points),
|
||||
geomEle: new Float64Array(points),
|
||||
adjStart: new Uint32Array(n + 1),
|
||||
adjEdge: new Uint32Array(2 * m),
|
||||
bytes: 0,
|
||||
};
|
||||
|
||||
nodes.forEach(({ graph, index }, i) => {
|
||||
net.nodeLat[i] = (graph.nodeLatE7[index] as number) / 1e7;
|
||||
net.nodeLng[i] = (graph.nodeLngE7[index] as number) / 1e7;
|
||||
net.nodeEle[i] = metres(graph.nodeEle[index] as number);
|
||||
net.nodeFlags[i] = graph.nodeFlags[index] as number;
|
||||
net.nodeOsm[i] = graph.nodeOsm[index] as number;
|
||||
});
|
||||
|
||||
let at = 0;
|
||||
edges.forEach(({ graph, index, remap }, e) => {
|
||||
net.edgeA[e] = remap[graph.edgeA[index] as number] as number;
|
||||
net.edgeB[e] = remap[graph.edgeB[index] as number] as number;
|
||||
net.edgeLen[e] = graph.edgeLen[index] as number;
|
||||
net.edgeUp[e] = graph.edgeUp[index] as number;
|
||||
net.edgeDown[e] = graph.edgeDown[index] as number;
|
||||
net.edgeKind[e] = graph.edgeKind[index] as number;
|
||||
net.edgeFwd[e] = graph.edgeFwdUses[index] as number;
|
||||
net.edgeBack[e] = graph.edgeBackUses[index] as number;
|
||||
net.edgeDiff[e] = graph.edgeDiff[index] as number;
|
||||
net.edgeGroom[e] = graph.edgeGroom[index] as number;
|
||||
net.edgeLift[e] = graph.edgeLift[index] as number;
|
||||
net.edgeLiftSec[e] = graph.edgeLiftSec[index] as number;
|
||||
net.edgeLiftCap[e] = graph.edgeLiftCap[index] as number;
|
||||
net.edgeFlags[e] = graph.edgeFlags[index] as number;
|
||||
net.edgeName[e] = poolString(graph.strings, graph.edgeName[index] as number);
|
||||
net.edgeRef[e] = poolString(graph.strings, graph.edgeRef[index] as number);
|
||||
net.edgeWay[e] = graph.edgeWay[index] as number;
|
||||
net.geomStart[e] = at;
|
||||
for (let i = graph.edgeGeomOff[index] as number; i < (graph.edgeGeomOff[index + 1] as number); i++) {
|
||||
net.geomLat[at] = (graph.geomLatE7[i] as number) / 1e7;
|
||||
net.geomLng[at] = (graph.geomLngE7[i] as number) / 1e7;
|
||||
net.geomEle[at] = metres(graph.geomEle[i] as number);
|
||||
at++;
|
||||
}
|
||||
});
|
||||
net.geomStart[m] = at;
|
||||
buildAdjacency(net);
|
||||
net.bytes = n * 34 + m * 80 + points * 24;
|
||||
return net;
|
||||
}
|
||||
|
||||
/** Every node's edges, a self-loop listed once. */
|
||||
function buildAdjacency(net: SkiNet): void {
|
||||
const degree = new Uint32Array(net.nodeCount + 1);
|
||||
for (let e = 0; e < net.edgeCount; e++) {
|
||||
const a = net.edgeA[e] as number;
|
||||
const b = net.edgeB[e] as number;
|
||||
degree[a + 1] = (degree[a + 1] as number) + 1;
|
||||
if (b !== a) degree[b + 1] = (degree[b + 1] as number) + 1;
|
||||
}
|
||||
for (let i = 1; i <= net.nodeCount; i++) degree[i] = (degree[i] as number) + (degree[i - 1] as number);
|
||||
net.adjStart.set(degree);
|
||||
const fill = degree.slice(0, net.nodeCount);
|
||||
for (let e = 0; e < net.edgeCount; e++) {
|
||||
const a = net.edgeA[e] as number;
|
||||
const b = net.edgeB[e] as number;
|
||||
net.adjEdge[fill[a] as number] = e;
|
||||
fill[a] = (fill[a] as number) + 1;
|
||||
if (b !== a) {
|
||||
net.adjEdge[fill[b] as number] = e;
|
||||
fill[b] = (fill[b] as number) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { logError, logInfo } from '../audit/audit-log.logger';
|
||||
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
|
||||
import { SkiAreasService } from './ski-areas.service';
|
||||
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* The daily look at the Places host's winter layer (spec 7.2): when it serves
|
||||
* a new build, the index is fetched, and every area kept here whose graph
|
||||
* changed is fetched again, so a planner opening a tour tomorrow does not
|
||||
* wait for it. Area files unused for 90 days go. Nothing runs while TREK
|
||||
* Places is switched off.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SkiAreasJob implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly areas: SkiAreasService,
|
||||
private readonly registrar: CronRegistrarService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
if (!this.registrar.isEnabled()) return;
|
||||
this.registrar.register('ski-areas', '40 4 * * *', () => this.tick());
|
||||
}
|
||||
|
||||
async tick(): Promise<void> {
|
||||
try {
|
||||
const swept = await this.areas.sweep(Date.now());
|
||||
if (swept > 0) logInfo(`[Ski] ${swept} ski area files unused for 90 days removed`);
|
||||
if (!this.areas.enabled()) return;
|
||||
if ((await this.areas.refresh()) !== 'updated') return;
|
||||
await this.refreshKept();
|
||||
} catch (err: unknown) {
|
||||
logError(`[Ski] daily ski area check: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The areas kept here whose graph the new build changed, fetched again. */
|
||||
private async refreshKept(): Promise<void> {
|
||||
const index = await this.areas.index();
|
||||
if (!index) return;
|
||||
const kept = await this.areas.keptAreas();
|
||||
const changed = index.areas.filter((area) =>
|
||||
kept.some((copy) => copy.id === area.row.id && copy.sha8 !== area.row.sha256.slice(0, 8)),
|
||||
);
|
||||
const outcomes = await Promise.allSettled(changed.map((area) => this.areas.ensureArea(area.row)));
|
||||
const failed = outcomes.filter((outcome) => outcome.status === 'rejected').length;
|
||||
if (changed.length === 0) return;
|
||||
logInfo(`[Ski] ${changed.length - failed} of ${changed.length} kept ski areas fetched anew`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { DataPathsService } from '../app-config/data-paths.service';
|
||||
import { logError, logInfo, logWarn } from '../audit/audit-log.logger';
|
||||
import { TrekPlacesWinterService } from '../maps/trek-places-winter.service';
|
||||
import { SGR_FORMAT } from './ski-graph.contract';
|
||||
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
import {
|
||||
winterAttributionSchema,
|
||||
winterIndexSchema,
|
||||
type WinterIndex,
|
||||
type WinterIndexRow,
|
||||
type WinterMeta,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readFile, readdir, rename, rm, stat, utimes, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* The ski areas the piste router plans on (spec 7.2, 7.3): the Places host's
|
||||
* index of every area, and each area's graph as it was downloaded. No table:
|
||||
* the index is a file `data/ski/index-<sha8>.json`, each area a file
|
||||
* `data/ski/areas/<id>@<sha8>.sgr.gz`, written beside its name and renamed
|
||||
* into place. An area is downloaded once, when a planner first plans in it,
|
||||
* and checked against the index's checksum before it is kept; a copy of an
|
||||
* older build stays as the fallback while the Places host cannot be reached.
|
||||
*/
|
||||
|
||||
/** One area of the index with where its padded box lies. */
|
||||
export interface IndexedArea {
|
||||
row: WinterIndexRow;
|
||||
minLat: number;
|
||||
minLng: number;
|
||||
maxLat: number;
|
||||
maxLng: number;
|
||||
}
|
||||
|
||||
export interface SkiIndex {
|
||||
build: string;
|
||||
indexSha: string;
|
||||
attribution: string;
|
||||
areas: IndexedArea[];
|
||||
/** Area positions by 0.1 degree cell of their padded box. */
|
||||
grid: Map<number, number[]>;
|
||||
}
|
||||
|
||||
/** An area's graph file, and whether it is an older build kept for when the Places host is down. */
|
||||
export interface AreaFile {
|
||||
file: string;
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
/** Why a leg has no area to be planned on. */
|
||||
type AreaPick =
|
||||
| { status: 'ok'; areas: IndexedArea[] }
|
||||
| { status: 'no_area'; at: { lat: number; lng: number } }
|
||||
| { status: 'cross_area'; at: { lat: number; lng: number } };
|
||||
|
||||
/** An area's box is widened by this much, so a click just past its outermost run still finds it. */
|
||||
const PAD_M = 300;
|
||||
/** At most this many areas are stitched for one leg. */
|
||||
const MAX_AREAS = 4;
|
||||
const CELL_DEG = 0.1;
|
||||
/** An area a download failed for counts as unavailable this long. */
|
||||
const FAILED_FOR_MS = 5 * 60 * 1000;
|
||||
/** Area files not used for this long are swept. */
|
||||
export const UNUSED_FOR_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
export const DEFAULT_ATTRIBUTION = '© OpenStreetMap contributors, ODbL. Terrain: Mapzen Terrarium';
|
||||
|
||||
const storedIndexSchema = z.object({
|
||||
meta: z.object({ build: z.string(), indexSha: z.string(), attribution: winterAttributionSchema }),
|
||||
index: winterIndexSchema,
|
||||
});
|
||||
|
||||
const cellKey = (y: number, x: number): number => (y + 1000) * 10_000 + (x + 2000);
|
||||
const safeId = (id: string): string => id.replace(':', '_');
|
||||
const sha256 = (bytes: Buffer): string => createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
function indexed(row: WinterIndexRow): IndexedArea {
|
||||
const [minLng, minLat, maxLng, maxLat] = row.bbox;
|
||||
const padLat = PAD_M / 111_195;
|
||||
const padLng = PAD_M / (111_195 * Math.max(0.05, Math.cos((((minLat + maxLat) / 2) * Math.PI) / 180)));
|
||||
return { row, minLat: minLat - padLat, minLng: minLng - padLng, maxLat: maxLat + padLat, maxLng: maxLng + padLng };
|
||||
}
|
||||
|
||||
function contains(area: IndexedArea, point: { lat: number; lng: number }): boolean {
|
||||
return point.lat >= area.minLat && point.lat <= area.maxLat && point.lng >= area.minLng && point.lng <= area.maxLng;
|
||||
}
|
||||
|
||||
function touches(a: IndexedArea, b: IndexedArea): boolean {
|
||||
return a.minLat <= b.maxLat && b.minLat <= a.maxLat && a.minLng <= b.maxLng && b.minLng <= a.maxLng;
|
||||
}
|
||||
|
||||
/** The id an area's pieces share: `w:12` for `w:12-1a`. */
|
||||
const baseId = (id: string): string => id.split('-')[0] as string;
|
||||
|
||||
function distanceTo(area: IndexedArea, point: { lat: number; lng: number }): number {
|
||||
const lat = (area.minLat + area.maxLat) / 2;
|
||||
const lng = (area.minLng + area.maxLng) / 2;
|
||||
return Math.hypot(lat - point.lat, (lng - point.lng) * Math.cos((point.lat * Math.PI) / 180));
|
||||
}
|
||||
|
||||
export function buildIndex(meta: Pick<WinterMeta, 'build' | 'indexSha' | 'attribution'>, index: WinterIndex): SkiIndex {
|
||||
const areas = index.areas.map(indexed);
|
||||
const grid = new Map<number, number[]>();
|
||||
areas.forEach((area, position) => {
|
||||
for (let y = Math.floor(area.minLat / CELL_DEG); y <= Math.floor(area.maxLat / CELL_DEG); y++) {
|
||||
for (let x = Math.floor(area.minLng / CELL_DEG); x <= Math.floor(area.maxLng / CELL_DEG); x++) {
|
||||
const key = cellKey(y, x);
|
||||
grid.set(key, [...(grid.get(key) ?? []), position]);
|
||||
}
|
||||
}
|
||||
});
|
||||
const attribution = `${meta.attribution.data}. ${meta.attribution.terrain}`;
|
||||
return { build: meta.build, indexSha: meta.indexSha, attribution, areas, grid };
|
||||
}
|
||||
|
||||
/** The areas whose padded box holds a point, nearest first. */
|
||||
export function areasAt(index: SkiIndex, point: { lat: number; lng: number }): IndexedArea[] {
|
||||
const key = cellKey(Math.floor(point.lat / CELL_DEG), Math.floor(point.lng / CELL_DEG));
|
||||
return (index.grid.get(key) ?? ([] as number[]))
|
||||
.map((position) => index.areas[position] as IndexedArea)
|
||||
.filter((area) => contains(area, point))
|
||||
.sort((a, b) => distanceTo(a, point) - distanceTo(b, point));
|
||||
}
|
||||
|
||||
/**
|
||||
* The areas a leg is planned on: those holding its ends, with the pieces of
|
||||
* the same network that touch them, at most four, nearest first. Ends in two
|
||||
* areas that do not touch are two ski areas, not one leg.
|
||||
*/
|
||||
export function pickAreas(
|
||||
index: SkiIndex,
|
||||
leg: { from: { lat: number; lng: number }; to: { lat: number; lng: number } },
|
||||
): AreaPick {
|
||||
const atFrom = areasAt(index, leg.from);
|
||||
if (atFrom.length === 0) return { status: 'no_area', at: leg.from };
|
||||
const atTo = areasAt(index, leg.to);
|
||||
if (atTo.length === 0) return { status: 'no_area', at: leg.to };
|
||||
const picked = new Map<string, IndexedArea>();
|
||||
for (const area of [...atFrom, ...atTo]) picked.set(area.row.id, area);
|
||||
const shared = atFrom.some((a) => atTo.some((b) => a.row.id === b.row.id));
|
||||
if (!shared && !atFrom.some((a) => atTo.some((b) => touches(a, b)))) return { status: 'cross_area', at: leg.to };
|
||||
// Pieces of a split network between the two ends, so the stitch has its halo.
|
||||
const bases = new Set([...picked.values()].map((area) => baseId(area.row.id)));
|
||||
const middle = { lat: (leg.from.lat + leg.to.lat) / 2, lng: (leg.from.lng + leg.to.lng) / 2 };
|
||||
for (const area of areasAt(index, middle)) {
|
||||
if (bases.has(baseId(area.row.id))) picked.set(area.row.id, area);
|
||||
}
|
||||
const areas = [...picked.values()].sort((a, b) => distanceTo(a, leg.from) - distanceTo(b, leg.from));
|
||||
return { status: 'ok', areas: areas.slice(0, MAX_AREAS) };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SkiAreasService implements OnApplicationBootstrap {
|
||||
private current: SkiIndex | null = null;
|
||||
private loading: Promise<SkiIndex | null> | null = null;
|
||||
private readonly downloads = new Map<string, Promise<void>>();
|
||||
private readonly failedAt = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly paths: DataPathsService,
|
||||
private readonly winter: TrekPlacesWinterService,
|
||||
) {}
|
||||
|
||||
private dir(): string {
|
||||
return join(this.paths.dataDir, 'ski');
|
||||
}
|
||||
|
||||
private areasDir(): string {
|
||||
return join(this.dir(), 'areas');
|
||||
}
|
||||
|
||||
fileOf(row: WinterIndexRow): string {
|
||||
return join(this.areasDir(), `${safeId(row.id)}@${row.sha256.slice(0, 8)}.sgr.gz`);
|
||||
}
|
||||
|
||||
/** Halves of a write a crash left are cleared at boot. */
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
try {
|
||||
await this.sweep(Date.now(), { tempOnly: true });
|
||||
} catch (err) {
|
||||
logError(`[Ski] clearing half-written files: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the operator lets anything go to the Places host. */
|
||||
enabled(): boolean {
|
||||
return this.winter.enabled();
|
||||
}
|
||||
|
||||
/** The index of ski areas: in memory, else as kept on disk, else fetched. Null when none can be had. */
|
||||
index(): Promise<SkiIndex | null> {
|
||||
if (this.current) return Promise.resolve(this.current);
|
||||
if (!this.loading) {
|
||||
this.loading = this.loadIndex().finally(() => {
|
||||
this.loading = null;
|
||||
});
|
||||
}
|
||||
return this.loading;
|
||||
}
|
||||
|
||||
private async loadIndex(): Promise<SkiIndex | null> {
|
||||
const kept = await this.readKeptIndex();
|
||||
if (kept) {
|
||||
this.current = kept;
|
||||
return kept;
|
||||
}
|
||||
if (!this.winter.enabled()) return null;
|
||||
try {
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
logWarn(`[Ski] fetching the ski area index: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
return this.current;
|
||||
}
|
||||
|
||||
private async readKeptIndex(): Promise<SkiIndex | null> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = (await readdir(this.dir())).filter((name) => name.startsWith('index-') && name.endsWith('.json'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const read = await Promise.all(names.map((name) => this.readIndexFile(name)));
|
||||
const newest = read
|
||||
.filter((kept): kept is { at: number; index: SkiIndex } => kept !== null)
|
||||
.sort((a, b) => b.at - a.at)[0];
|
||||
return newest?.index ?? null;
|
||||
}
|
||||
|
||||
private async readIndexFile(name: string): Promise<{ at: number; index: SkiIndex } | null> {
|
||||
const file = join(this.dir(), name);
|
||||
try {
|
||||
const stored = storedIndexSchema.parse(JSON.parse(await readFile(file, 'utf8')));
|
||||
return { at: (await stat(file)).mtimeMs, index: buildIndex(stored.meta, stored.index) };
|
||||
} catch {
|
||||
logWarn(`[Ski] the kept index ${name} does not read; it is skipped`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the Places host which build it serves and fetches the index when it
|
||||
* is not the one held. An index whose checksum is not the one the host
|
||||
* announced, or of a format this build cannot read, is not taken: the one
|
||||
* held stays. Throws when the host cannot be reached.
|
||||
*/
|
||||
async refresh(): Promise<'unchanged' | 'updated' | 'refused'> {
|
||||
const meta = await this.winter.meta();
|
||||
if (this.current?.indexSha === meta.indexSha) return 'unchanged';
|
||||
if (meta.format !== SGR_FORMAT) {
|
||||
logWarn(`[Ski] the Places host serves winter format ${meta.format}; this TREK reads ${SGR_FORMAT}`);
|
||||
return 'refused';
|
||||
}
|
||||
const answer = await this.winter.index(this.current?.indexSha ?? null);
|
||||
if (answer.status === 'not_modified') return 'unchanged';
|
||||
if (answer.sha256 !== meta.indexSha) {
|
||||
logWarn(`[Ski] the ski area index does not match its checksum; the one held stays`);
|
||||
return 'refused';
|
||||
}
|
||||
if (answer.dropped > 0) logWarn(`[Ski] ${answer.dropped} rows of the ski area index did not read`);
|
||||
const kept = { build: meta.build, indexSha: meta.indexSha, attribution: meta.attribution };
|
||||
const stored = { meta: kept, index: answer.index };
|
||||
await this.writeAtomically(join(this.dir(), `index-${meta.indexSha.slice(0, 8)}.json`), JSON.stringify(stored));
|
||||
this.current = buildIndex(stored.meta, stored.index);
|
||||
logInfo(`[Ski] ski area index of build ${meta.build}: ${answer.index.areas.length} areas`);
|
||||
await this.dropOtherIndexes(meta.indexSha);
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
private async dropOtherIndexes(keep: string): Promise<void> {
|
||||
const names = await readdir(this.dir());
|
||||
const stale = names.filter((name) => name.startsWith('index-') && name !== `index-${keep.slice(0, 8)}.json`);
|
||||
await Promise.all(stale.map((name) => rm(join(this.dir(), name), { force: true })));
|
||||
}
|
||||
|
||||
private async writeAtomically(file: string, content: string | Buffer): Promise<void> {
|
||||
await mkdir(join(file, '..'), { recursive: true });
|
||||
const temp = `${file}.tmp`;
|
||||
await writeFile(temp, content);
|
||||
await rename(temp, file);
|
||||
}
|
||||
|
||||
/** Whether an area's graph is here, on its way, or cannot be had right now. */
|
||||
stateOf(row: WinterIndexRow): 'ready' | 'loading' | 'unavailable' {
|
||||
if (existsSync(this.fileOf(row))) return 'ready';
|
||||
if (this.downloads.has(row.id)) return 'loading';
|
||||
const failed = this.failedAt.get(row.id);
|
||||
if (!this.winter.enabled() || (failed !== undefined && Date.now() - failed < FAILED_FOR_MS)) return 'unavailable';
|
||||
return 'loading';
|
||||
}
|
||||
|
||||
/**
|
||||
* An area's graph file: the current build's, downloaded if it is not here
|
||||
* yet (one download however many ask at once), or the newest older copy
|
||||
* while the download fails. Throws when there is neither.
|
||||
*/
|
||||
async ensureArea(row: WinterIndexRow): Promise<AreaFile> {
|
||||
const file = this.fileOf(row);
|
||||
if (existsSync(file)) {
|
||||
await this.touch(file);
|
||||
return { file, stale: false };
|
||||
}
|
||||
let download = this.downloads.get(row.id);
|
||||
if (!download) {
|
||||
download = this.download(row, file).finally(() => this.downloads.delete(row.id));
|
||||
this.downloads.set(row.id, download);
|
||||
}
|
||||
try {
|
||||
await download;
|
||||
this.failedAt.delete(row.id);
|
||||
return { file, stale: false };
|
||||
} catch (err) {
|
||||
this.failedAt.set(row.id, Date.now());
|
||||
const older = await this.keptCopy(row.id);
|
||||
if (older) return { file: older, stale: true };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async download(row: WinterIndexRow, file: string): Promise<void> {
|
||||
let answer: Awaited<ReturnType<TrekPlacesWinterService['area']>>;
|
||||
try {
|
||||
answer = await this.winter.area(row.id, null);
|
||||
} catch (err) {
|
||||
// An id the host does not know means the index is out of date.
|
||||
if ((err as { status?: number }).status === 404) void this.refresh().catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
if (answer.status !== 'ok') throw new Error(`The Places host sent no graph for ${row.id}`);
|
||||
if (sha256(answer.bytes) !== row.sha256) {
|
||||
throw new Error(`The graph of ${row.id} does not match the index's checksum; it was not kept`);
|
||||
}
|
||||
await this.writeAtomically(file, answer.bytes);
|
||||
}
|
||||
|
||||
/** The newest file of an area kept from an earlier build, if any. */
|
||||
private async keptCopy(id: string): Promise<string | null> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(this.areasDir());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const prefix = `${safeId(id)}@`;
|
||||
const copies = names.filter((name) => name.startsWith(prefix) && name.endsWith('.sgr.gz'));
|
||||
const dated = await Promise.all(
|
||||
copies.map(async (name) => ({ name, at: (await stat(join(this.areasDir(), name))).mtimeMs })),
|
||||
);
|
||||
const newest = dated.sort((a, b) => b.at - a.at)[0];
|
||||
return newest ? join(this.areasDir(), newest.name) : null;
|
||||
}
|
||||
|
||||
/** Marks a file used, so the sweep keeps it. */
|
||||
private async touch(file: string): Promise<void> {
|
||||
const now = new Date();
|
||||
await utimes(file, now, now).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** The ids of the areas with a graph kept here, with the checksum prefix each file carries. */
|
||||
async keptAreas(): Promise<Array<{ id: string; sha8: string }>> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(this.areasDir());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return names
|
||||
.filter((name) => name.endsWith('.sgr.gz') && name.includes('@'))
|
||||
.map((name) => {
|
||||
const [id, rest] = name.split('@') as [string, string];
|
||||
return { id: id.replace('_', ':'), sha8: rest.slice(0, 8) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears what is left of interrupted writes and, unless `tempOnly`, every
|
||||
* area file unused for 90 days. An area kept is never older than its last use.
|
||||
*/
|
||||
async sweep(now: number, { tempOnly = false }: { tempOnly?: boolean } = {}): Promise<number> {
|
||||
const removed = await Promise.all([this.dir(), this.areasDir()].map((dir) => this.sweepDir(dir, now, tempOnly)));
|
||||
return removed.reduce((sum, count) => sum + count, 0);
|
||||
}
|
||||
|
||||
private async sweepDir(dir: string, now: number, tempOnly: boolean): Promise<number> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(dir);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
const verdicts = await Promise.all(
|
||||
names.map(async (name) => {
|
||||
if (name.endsWith('.tmp')) return name;
|
||||
if (tempOnly || !name.endsWith('.sgr.gz')) return null;
|
||||
const at = (await stat(join(dir, name))).mtimeMs;
|
||||
return now - at > UNUSED_FOR_MS ? name : null;
|
||||
}),
|
||||
);
|
||||
const doomed = verdicts.filter((name): name is string => name !== null);
|
||||
await Promise.all(doomed.map((name) => rm(join(dir, name), { force: true })));
|
||||
return doomed.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* SGR1, the graph of one ski area as the Places host writes it
|
||||
* (trek-places-api `winter/sgr.py`, codes in `winter/model.py`). Every number
|
||||
* here is part of that contract: a change to an existing value is a new
|
||||
* format, never a fix. The golden file `tests/fixtures/ski/golden-v1.sgr` is
|
||||
* the same bytes the writer's own test pins.
|
||||
*/
|
||||
|
||||
export const SGR_MAGIC = 'SGR1';
|
||||
export const SGR_FORMAT = 1;
|
||||
|
||||
/** What an edge may be used for, per direction (`edgeFwdUses` a to b, `edgeBackUses` b to a). */
|
||||
export const Use = {
|
||||
DOWNHILL: 1 << 0,
|
||||
LIFT_UP: 1 << 1,
|
||||
LIFT_DOWN: 1 << 2,
|
||||
NORDIC_CLASSIC: 1 << 3,
|
||||
NORDIC_SKATING: 1 << 4,
|
||||
SKITOUR_UP: 1 << 5,
|
||||
SKITOUR_DOWN: 1 << 6,
|
||||
SNOWSHOE: 1 << 7,
|
||||
WINTER_HIKE: 1 << 8,
|
||||
WALK: 1 << 9,
|
||||
} as const;
|
||||
|
||||
export const Kind = {
|
||||
RUN: 0,
|
||||
LIFT: 1,
|
||||
RAIL: 2,
|
||||
BOARD: 3,
|
||||
ALIGHT: 4,
|
||||
NORDIC: 5,
|
||||
SKITOUR: 6,
|
||||
HIKE: 7,
|
||||
SLED_WALKUP: 8,
|
||||
CONNECTION: 9,
|
||||
AREA: 10,
|
||||
LINK: 11,
|
||||
CROSSING: 12,
|
||||
WALK: 13,
|
||||
} as const;
|
||||
|
||||
/** `edgeDiff`: novice to extreme, 255 where the data gives none. */
|
||||
export const DIFFICULTY_NAMES = [
|
||||
'novice',
|
||||
'easy',
|
||||
'intermediate',
|
||||
'advanced',
|
||||
'expert',
|
||||
'freeride',
|
||||
'extreme',
|
||||
] as const;
|
||||
export const DIFF_UNKNOWN = 255;
|
||||
export const DIFF_EXPERT = 4;
|
||||
|
||||
/** `edgeGroom` bits. */
|
||||
export const Groom = {
|
||||
CLASSIC: 1 << 0,
|
||||
SKATING: 1 << 1,
|
||||
SCOOTER: 1 << 2,
|
||||
BACKCOUNTRY: 1 << 3,
|
||||
MOGUL: 1 << 4,
|
||||
ASSUMED: 1 << 7,
|
||||
} as const;
|
||||
|
||||
/** `edgeLift`, as OpenStreetMap names each type. */
|
||||
export const LIFT_NAMES = [
|
||||
'none',
|
||||
'cable_car',
|
||||
'gondola',
|
||||
'mixed_lift',
|
||||
'chair_detachable',
|
||||
'chair_fixed',
|
||||
'drag_lift',
|
||||
't-bar',
|
||||
'j-bar',
|
||||
'platter',
|
||||
'rope_tow',
|
||||
'magic_carpet',
|
||||
'funicular',
|
||||
'rack_railway',
|
||||
] as const;
|
||||
|
||||
export const Lift = {
|
||||
NONE: 0,
|
||||
CABLE_CAR: 1,
|
||||
GONDOLA: 2,
|
||||
MIXED: 3,
|
||||
CHAIR_DETACHABLE: 4,
|
||||
CHAIR_FIXED: 5,
|
||||
DRAG: 6,
|
||||
TBAR: 7,
|
||||
JBAR: 8,
|
||||
PLATTER: 9,
|
||||
ROPE_TOW: 10,
|
||||
MAGIC_CARPET: 11,
|
||||
FUNICULAR: 12,
|
||||
RACK: 13,
|
||||
} as const;
|
||||
|
||||
/** `nodeFlags` bits. */
|
||||
export const NodeFlag = {
|
||||
STATION: 1 << 0,
|
||||
PORTAL: 1 << 1,
|
||||
DISMOUNT_YES: 1 << 2,
|
||||
DISMOUNT_SOMETIMES: 1 << 3,
|
||||
LIFT_INTERNAL: 1 << 4,
|
||||
BOUNDARY: 1 << 5,
|
||||
ELE_MISSING: 1 << 6,
|
||||
AREA_INTERIOR: 1 << 7,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `edgeFlags` bits. Bits 17 and 18 are the writer's beyond the first draft of
|
||||
* the format: an area edge whose polygon holds a mapped run (cost x1.3), and a
|
||||
* way tagged `piste:type=yes`, usable as a connection only (cost x1.5).
|
||||
*/
|
||||
export const EdgeFlag = {
|
||||
DIR_FROM_DEM: 1 << 0,
|
||||
DIR_CONFLICT: 1 << 1,
|
||||
RELATION_INHERITED: 1 << 2,
|
||||
TECHNIQUE_ASSUMED: 1 << 3,
|
||||
INFERRED_LINK: 1 << 4,
|
||||
INFERRED_CROSSING: 1 << 5,
|
||||
AREA_DERIVED: 1 << 6,
|
||||
TUNNEL: 1 << 7,
|
||||
LIT: 1 << 8,
|
||||
SKITOUR_UPHILL_OK: 1 << 9,
|
||||
HIGHWAY_SHARED: 1 << 10,
|
||||
FLAT_FOR_BOARD: 1 << 11,
|
||||
RIDE_DOWN: 1 << 12,
|
||||
HEATED: 1 << 13,
|
||||
BUBBLE: 1 << 14,
|
||||
NORDIC_SOFT_ONEWAY: 1 << 15,
|
||||
HALO: 1 << 16,
|
||||
AREA_HAS_RUN: 1 << 17,
|
||||
TYPE_UNKNOWN: 1 << 18,
|
||||
} as const;
|
||||
|
||||
/** A height the terrain data does not have. */
|
||||
export const ELE_MISSING = -32768;
|
||||
/** A string offset that names no string. */
|
||||
export const NO_STRING = 0xffffffff;
|
||||
|
||||
/** The head of an SGR1 file, as JSON. */
|
||||
export interface SkiGraphHeader {
|
||||
format: number;
|
||||
build: string | null;
|
||||
areaId: string | null;
|
||||
name: string | null;
|
||||
kind: string | null;
|
||||
country: string | null;
|
||||
colourScheme: string | null;
|
||||
/** [minLng, minLat, maxLng, maxLat] */
|
||||
bbox: [number, number, number, number] | null;
|
||||
counts: Record<string, number> | null;
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
geomCount: number;
|
||||
stringBytes: number;
|
||||
crc32: number;
|
||||
}
|
||||
|
||||
/** One area's graph as decoded: the file's arrays, as typed arrays, and its string pool. */
|
||||
export interface SkiGraphData {
|
||||
header: SkiGraphHeader;
|
||||
nodeLatE7: Int32Array;
|
||||
nodeLngE7: Int32Array;
|
||||
nodeEle: Int16Array;
|
||||
nodeFlags: Uint16Array;
|
||||
nodeOsm: Float64Array;
|
||||
edgeA: Uint32Array;
|
||||
edgeB: Uint32Array;
|
||||
edgeLen: Float32Array;
|
||||
edgeUp: Uint16Array;
|
||||
edgeDown: Uint16Array;
|
||||
edgeKind: Uint8Array;
|
||||
edgeFwdUses: Uint16Array;
|
||||
edgeBackUses: Uint16Array;
|
||||
edgeDiff: Uint8Array;
|
||||
edgeGroom: Uint8Array;
|
||||
edgeLift: Uint8Array;
|
||||
edgeLiftSec: Uint16Array;
|
||||
edgeLiftCap: Uint16Array;
|
||||
edgeFlags: Uint32Array;
|
||||
edgeName: Uint32Array;
|
||||
edgeRef: Uint32Array;
|
||||
edgeWay: Float64Array;
|
||||
edgeGeomOff: Uint32Array;
|
||||
geomLatE7: Int32Array;
|
||||
geomLngE7: Int32Array;
|
||||
geomEle: Int16Array;
|
||||
/** The string pool: UTF-8, each string ended by a NUL, addressed by its byte offset. */
|
||||
strings: Uint8Array;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { NO_STRING, SGR_FORMAT, SGR_MAGIC, type SkiGraphData, type SkiGraphHeader } from './ski-graph.contract';
|
||||
|
||||
import { crc32, gunzipSync } from 'node:zlib';
|
||||
|
||||
/**
|
||||
* The reader of SGR1 (`ski-graph.contract.ts`). Little-endian throughout: the
|
||||
* magic, the length of a JSON head, the head, zero bytes up to the next
|
||||
* multiple of 8 from the start of the file, then every section in a fixed
|
||||
* order, each padded to 8 bytes, and last the string pool. The head's `crc32`
|
||||
* covers every byte after the head's padding, so a damaged or cut file is
|
||||
* refused before any of it is used.
|
||||
*/
|
||||
|
||||
type SectionArray = keyof Omit<SkiGraphData, 'header' | 'strings'>;
|
||||
type Count = 'n' | 'e' | 'e1' | 'g';
|
||||
|
||||
const VIEWS = {
|
||||
i32: Int32Array,
|
||||
i16: Int16Array,
|
||||
u16: Uint16Array,
|
||||
u32: Uint32Array,
|
||||
u8: Uint8Array,
|
||||
f32: Float32Array,
|
||||
f64: Float64Array,
|
||||
} as const;
|
||||
|
||||
/** The sections in the writer's order (`SECTIONS` in `winter/sgr.py`), with their element type and length. */
|
||||
const SECTIONS: ReadonlyArray<readonly [SectionArray, keyof typeof VIEWS, Count]> = [
|
||||
['nodeLatE7', 'i32', 'n'],
|
||||
['nodeLngE7', 'i32', 'n'],
|
||||
['nodeEle', 'i16', 'n'],
|
||||
['nodeFlags', 'u16', 'n'],
|
||||
['nodeOsm', 'f64', 'n'],
|
||||
['edgeA', 'u32', 'e'],
|
||||
['edgeB', 'u32', 'e'],
|
||||
['edgeLen', 'f32', 'e'],
|
||||
['edgeUp', 'u16', 'e'],
|
||||
['edgeDown', 'u16', 'e'],
|
||||
['edgeKind', 'u8', 'e'],
|
||||
['edgeFwdUses', 'u16', 'e'],
|
||||
['edgeBackUses', 'u16', 'e'],
|
||||
['edgeDiff', 'u8', 'e'],
|
||||
['edgeGroom', 'u8', 'e'],
|
||||
['edgeLift', 'u8', 'e'],
|
||||
['edgeLiftSec', 'u16', 'e'],
|
||||
['edgeLiftCap', 'u16', 'e'],
|
||||
['edgeFlags', 'u32', 'e'],
|
||||
['edgeName', 'u32', 'e'],
|
||||
['edgeRef', 'u32', 'e'],
|
||||
['edgeWay', 'f64', 'e'],
|
||||
['edgeGeomOff', 'u32', 'e1'],
|
||||
['geomLatE7', 'i32', 'g'],
|
||||
['geomLngE7', 'i32', 'g'],
|
||||
['geomEle', 'i16', 'g'],
|
||||
];
|
||||
|
||||
/** Why a file was refused; `format` marks a file of a format this build does not read, which keeps the old one. */
|
||||
export class SkiGraphFormatError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly unknownFormat = false,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SkiGraphFormatError';
|
||||
}
|
||||
}
|
||||
|
||||
const padTo8 = (offset: number): number => (offset + 7) & ~7;
|
||||
|
||||
function count(value: unknown, what: string): number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
||||
throw new SkiGraphFormatError(`SGR1 head has no valid ${what}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readHeader(bytes: Buffer): { header: SkiGraphHeader; bodyStart: number } {
|
||||
if (bytes.length < 8 || bytes.toString('latin1', 0, 4) !== SGR_MAGIC) {
|
||||
throw new SkiGraphFormatError('Not an SGR1 file');
|
||||
}
|
||||
const headLength = bytes.readUInt32LE(4);
|
||||
if (8 + headLength > bytes.length) throw new SkiGraphFormatError('SGR1 head runs past the end of the file');
|
||||
let head: unknown;
|
||||
try {
|
||||
head = JSON.parse(bytes.toString('utf8', 8, 8 + headLength));
|
||||
} catch {
|
||||
throw new SkiGraphFormatError('SGR1 head is not JSON');
|
||||
}
|
||||
if (typeof head !== 'object' || head === null) throw new SkiGraphFormatError('SGR1 head is not an object');
|
||||
const header = head as SkiGraphHeader;
|
||||
if (header.format !== SGR_FORMAT) {
|
||||
throw new SkiGraphFormatError(`SGR1 format ${String(header.format)} is not ${SGR_FORMAT}`, true);
|
||||
}
|
||||
count(header.nodeCount, 'node count');
|
||||
count(header.edgeCount, 'edge count');
|
||||
count(header.geomCount, 'point count');
|
||||
count(header.stringBytes, 'string pool size');
|
||||
count(header.crc32, 'checksum');
|
||||
return { header, bodyStart: padTo8(8 + headLength) };
|
||||
}
|
||||
|
||||
/** An area's graph from the bytes of its SGR1 file. Throws {@link SkiGraphFormatError} on anything amiss. */
|
||||
export function decodeSkiGraph(bytes: Buffer): SkiGraphData {
|
||||
const { header, bodyStart } = readHeader(bytes);
|
||||
if (bodyStart > bytes.length) throw new SkiGraphFormatError('SGR1 file ends inside its head');
|
||||
if (crc32(bytes.subarray(bodyStart)) >>> 0 !== header.crc32) {
|
||||
throw new SkiGraphFormatError('SGR1 checksum differs');
|
||||
}
|
||||
const sizes: Record<Count, number> = {
|
||||
n: header.nodeCount,
|
||||
e: header.edgeCount,
|
||||
e1: header.edgeCount + 1,
|
||||
g: header.geomCount,
|
||||
};
|
||||
const arrays = new Map<SectionArray, ArrayBufferView>();
|
||||
let offset = bodyStart;
|
||||
for (const [name, type, length] of SECTIONS) {
|
||||
const View = VIEWS[type];
|
||||
const byteLength = sizes[length] * View.BYTES_PER_ELEMENT;
|
||||
if (offset + byteLength > bytes.length) {
|
||||
throw new SkiGraphFormatError(`SGR1 section ${name} runs past the end`);
|
||||
}
|
||||
// Copied out of the file's buffer, so every view starts aligned whatever Node's pool did with it.
|
||||
const copy = new Uint8Array(byteLength);
|
||||
copy.set(bytes.subarray(offset, offset + byteLength));
|
||||
arrays.set(name, new View(copy.buffer, 0, sizes[length]));
|
||||
offset = padTo8(offset + byteLength);
|
||||
}
|
||||
if (offset + header.stringBytes > bytes.length) {
|
||||
throw new SkiGraphFormatError('SGR1 string pool runs past the end');
|
||||
}
|
||||
const strings = Uint8Array.from(bytes.subarray(offset, offset + header.stringBytes));
|
||||
const take = <T extends ArrayBufferView>(name: SectionArray): T => {
|
||||
const view = arrays.get(name);
|
||||
if (!view) throw new SkiGraphFormatError(`SGR1 lacks ${name}`);
|
||||
return view as T;
|
||||
};
|
||||
const graph: SkiGraphData = {
|
||||
header,
|
||||
nodeLatE7: take('nodeLatE7'),
|
||||
nodeLngE7: take('nodeLngE7'),
|
||||
nodeEle: take('nodeEle'),
|
||||
nodeFlags: take('nodeFlags'),
|
||||
nodeOsm: take('nodeOsm'),
|
||||
edgeA: take('edgeA'),
|
||||
edgeB: take('edgeB'),
|
||||
edgeLen: take('edgeLen'),
|
||||
edgeUp: take('edgeUp'),
|
||||
edgeDown: take('edgeDown'),
|
||||
edgeKind: take('edgeKind'),
|
||||
edgeFwdUses: take('edgeFwdUses'),
|
||||
edgeBackUses: take('edgeBackUses'),
|
||||
edgeDiff: take('edgeDiff'),
|
||||
edgeGroom: take('edgeGroom'),
|
||||
edgeLift: take('edgeLift'),
|
||||
edgeLiftSec: take('edgeLiftSec'),
|
||||
edgeLiftCap: take('edgeLiftCap'),
|
||||
edgeFlags: take('edgeFlags'),
|
||||
edgeName: take('edgeName'),
|
||||
edgeRef: take('edgeRef'),
|
||||
edgeWay: take('edgeWay'),
|
||||
edgeGeomOff: take('edgeGeomOff'),
|
||||
geomLatE7: take('geomLatE7'),
|
||||
geomLngE7: take('geomLngE7'),
|
||||
geomEle: take('geomEle'),
|
||||
strings,
|
||||
};
|
||||
checkReferences(graph);
|
||||
return graph;
|
||||
}
|
||||
|
||||
/** Every edge names nodes that exist and points that exist, so the router never reads past an array. */
|
||||
function checkReferences(graph: SkiGraphData): void {
|
||||
const nodes = graph.header.nodeCount;
|
||||
const points = graph.header.geomCount;
|
||||
for (let e = 0; e < graph.header.edgeCount; e++) {
|
||||
if ((graph.edgeA[e] as number) >= nodes || (graph.edgeB[e] as number) >= nodes) {
|
||||
throw new SkiGraphFormatError(`SGR1 edge ${e} names a node that does not exist`);
|
||||
}
|
||||
const start = graph.edgeGeomOff[e] as number;
|
||||
const end = graph.edgeGeomOff[e + 1] as number;
|
||||
if (end < start + 2 || end > points) {
|
||||
throw new SkiGraphFormatError(`SGR1 edge ${e} has no line of its own`);
|
||||
}
|
||||
}
|
||||
if (graph.header.edgeCount > 0 && graph.edgeGeomOff[graph.header.edgeCount] !== points) {
|
||||
throw new SkiGraphFormatError('SGR1 points do not add up');
|
||||
}
|
||||
}
|
||||
|
||||
/** An area's graph from its file as the Places host sends it: SGR1, gzip-packed. */
|
||||
export function decodeSkiGraphGz(packed: Buffer): SkiGraphData {
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = gunzipSync(packed);
|
||||
} catch {
|
||||
throw new SkiGraphFormatError('SGR1 file is not gzip');
|
||||
}
|
||||
return decodeSkiGraph(bytes);
|
||||
}
|
||||
|
||||
const UTF8 = new TextDecoder('utf-8');
|
||||
|
||||
/** The string at a pool offset, null for none (or an offset the pool does not hold). */
|
||||
export function poolString(strings: Uint8Array, offset: number): string | null {
|
||||
if (offset === NO_STRING || offset >= strings.length) return null;
|
||||
const end = strings.indexOf(0, offset);
|
||||
return UTF8.decode(strings.subarray(offset, end < 0 ? strings.length : end));
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { networkFeatures } from './engine/network';
|
||||
import { SkiRouter, type LegInput } from './engine/plan';
|
||||
import type { SkiProfile } from './engine/profile';
|
||||
import { stitchGraphs } from './engine/stitch';
|
||||
import { decodeSkiGraphGz } from './ski-graph';
|
||||
import type { SkiNetworkFeature, SkiRouteLeg } from '@trek/shared';
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
/** The legs of one area (or of the pieces of one) to plan, on the graph files at `files`. */
|
||||
export interface SkiRouteJob {
|
||||
kind: 'route';
|
||||
files: string[];
|
||||
areaIds: string[];
|
||||
legs: LegInput[];
|
||||
profile: SkiProfile;
|
||||
alternatives: number;
|
||||
}
|
||||
|
||||
/** The drawn lines of one area, for the map. */
|
||||
interface SkiNetworkJob {
|
||||
kind: 'network';
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export type SkiJob = SkiRouteJob | SkiNetworkJob;
|
||||
|
||||
interface SkiNetworkLines {
|
||||
features: SkiNetworkFeature[];
|
||||
simplified_m: number;
|
||||
}
|
||||
|
||||
export type SkiJobResult<J extends SkiJob> = J extends SkiRouteJob ? SkiRouteLeg[] : SkiNetworkLines;
|
||||
|
||||
/** Plans on area graphs: in a worker thread in production, on the calling thread in tests. */
|
||||
export interface SkiRouterRunner {
|
||||
run<J extends SkiJob>(job: J): Promise<SkiJobResult<J>>;
|
||||
/** Drops every network built from a file, after the area was fetched again or removed. */
|
||||
forget(file: string): void;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export const SKI_ROUTER_RUNNER = Symbol('SKI_ROUTER_RUNNER');
|
||||
|
||||
/** What the decoded and stitched networks of a thread may take together (spec 7.3). */
|
||||
export const NETWORK_BUDGET_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The networks a thread keeps decoded, the least recently used dropped first
|
||||
* once they take more than the budget together. Keyed by the files a network
|
||||
* was built from, so a network stitched of two areas is one entry.
|
||||
*/
|
||||
export class NetCache {
|
||||
private readonly routers = new Map<string, { router: Promise<SkiRouter>; bytes: number }>();
|
||||
|
||||
constructor(private readonly budget = NETWORK_BUDGET_BYTES) {}
|
||||
|
||||
get(files: readonly string[]): Promise<SkiRouter> {
|
||||
const key = [...files].sort().join('|');
|
||||
const known = this.routers.get(key);
|
||||
if (known) {
|
||||
this.routers.delete(key);
|
||||
this.routers.set(key, known);
|
||||
return known.router;
|
||||
}
|
||||
const entry = { router: this.load(files), bytes: 0 };
|
||||
this.routers.set(key, entry);
|
||||
void entry.router.then(
|
||||
(router) => {
|
||||
entry.bytes = router.net.bytes;
|
||||
this.trim(key);
|
||||
},
|
||||
() => this.routers.delete(key),
|
||||
);
|
||||
return entry.router;
|
||||
}
|
||||
|
||||
private async load(files: readonly string[]): Promise<SkiRouter> {
|
||||
const graphs = await Promise.all(files.map(async (file) => decodeSkiGraphGz(await readFile(file))));
|
||||
return new SkiRouter(stitchGraphs(graphs));
|
||||
}
|
||||
|
||||
/** Drops the oldest networks until the rest fit, never the one just loaded. */
|
||||
private trim(keep: string): void {
|
||||
let total = [...this.routers.values()].reduce((sum, entry) => sum + entry.bytes, 0);
|
||||
for (const [key, entry] of this.routers) {
|
||||
if (total <= this.budget) break;
|
||||
if (key === keep) continue;
|
||||
this.routers.delete(key);
|
||||
total -= entry.bytes;
|
||||
}
|
||||
}
|
||||
|
||||
forget(file: string): void {
|
||||
for (const key of [...this.routers.keys()]) {
|
||||
if (key.split('|').includes(file)) this.routers.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.routers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSkiJob(cache: NetCache, job: SkiJob): Promise<SkiRouteLeg[] | SkiNetworkLines> {
|
||||
const router = await cache.get(job.files);
|
||||
if (job.kind === 'network') return networkFeatures(router.net);
|
||||
return router.plan({ legs: job.legs, profile: job.profile, alternatives: job.alternatives }, job.areaIds);
|
||||
}
|
||||
|
||||
/** The test runner: the same work on the calling thread. */
|
||||
export class InlineSkiRouterRunner implements SkiRouterRunner {
|
||||
private readonly cache = new NetCache();
|
||||
|
||||
run<J extends SkiJob>(job: J): Promise<SkiJobResult<J>> {
|
||||
return runSkiJob(this.cache, job) as Promise<SkiJobResult<J>>;
|
||||
}
|
||||
|
||||
forget(file: string): void {
|
||||
this.cache.forget(file);
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
this.cache.clear();
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NetCache, runSkiJob, type SkiJob } from './ski-router-runner';
|
||||
|
||||
import { parentPort } from 'node:worker_threads';
|
||||
|
||||
/** The piste router's thread: answers the jobs it is sent on networks it keeps decoded. */
|
||||
const cache = new NetCache();
|
||||
|
||||
parentPort?.on('message', (message: { id: number; job?: SkiJob; forget?: string }) => {
|
||||
if (message.forget) {
|
||||
cache.forget(message.forget);
|
||||
return;
|
||||
}
|
||||
if (!message.job) return;
|
||||
runSkiJob(cache, message.job).then(
|
||||
(result) => parentPort?.postMessage({ id: message.id, result }),
|
||||
(err: unknown) =>
|
||||
parentPort?.postMessage({ id: message.id, error: err instanceof Error ? err.message : String(err) }),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
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 { SkiAreaNearQueryDto, SkiRouteRequestDto } from './ski-routing.dto';
|
||||
import { SkiRoutingService } from './ski-routing.service';
|
||||
import { Body, Controller, Get, HttpCode, Param, Post, Query, Res, UseGuards } from '@nestjs/common';
|
||||
import { skiAreaNearResponseSchema, skiNetworkResponseSchema, skiRouteResponseSchema } from '@trek/shared';
|
||||
|
||||
import type { Response } from 'express';
|
||||
|
||||
/**
|
||||
* The piste router for a ski and winter tour (spec 7.2). It reads and writes
|
||||
* nothing of the trip; the trip in the path only says whose planner asks, so
|
||||
* anyone who can see the trip may. No route takes a point to the Places host:
|
||||
* the areas are downloaded by id and searched here.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/ski')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
|
||||
export class SkiRoutingController {
|
||||
constructor(private readonly routing: SkiRoutingService) {}
|
||||
|
||||
/** POST /api/trips/:tripId/ski/route: every leg planned on the pistes, or failed with its reason. */
|
||||
@Post('route')
|
||||
@HttpCode(200)
|
||||
@ResponseContract(skiRouteResponseSchema)
|
||||
async route(@Body() body: SkiRouteRequestDto) {
|
||||
return await this.routing.route(body);
|
||||
}
|
||||
|
||||
/** GET /api/trips/:tripId/ski/area-near: the ski area at a point; asking starts its download. */
|
||||
@Get('area-near')
|
||||
@ResponseContract(skiAreaNearResponseSchema)
|
||||
async areaNear(@Query() query: SkiAreaNearQueryDto) {
|
||||
return await this.routing.areaNear(query.lat, query.lng);
|
||||
}
|
||||
|
||||
/** GET /api/trips/:tripId/ski/network/:areaId: an area's runs, lifts and trails for the map, tagged by its checksum. */
|
||||
@Get('network/:areaId')
|
||||
@ResponseContract(skiNetworkResponseSchema)
|
||||
async network(@Param('areaId') areaId: string, @Res({ passthrough: true }) res: Response) {
|
||||
const { sha256, ...network } = await this.routing.network(areaId);
|
||||
res.setHeader('ETag', `"${sha256}"`);
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
return network;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { skiAreaNearQuerySchema, skiRouteRequestSchema } from '@trek/shared';
|
||||
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
|
||||
export class SkiRouteRequestDto extends createZodDto(skiRouteRequestSchema) {}
|
||||
|
||||
export class SkiAreaNearQueryDto extends createZodDto(skiAreaNearQuerySchema) {}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess } from '../../mcp/tools/_shared';
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { SkiRoutingService } from './ski-routing.service';
|
||||
import { idSchema, skiRouteRequestSchema, type SkiRouteInput, type SkiRouteLeg } from '@trek/shared';
|
||||
|
||||
/** A planned leg as a client reads it: its measures and its stretches, not its thousands of points. */
|
||||
function summary(leg: SkiRouteLeg) {
|
||||
if (leg.status === 'failed') return leg;
|
||||
return {
|
||||
status: leg.status,
|
||||
distance_m: leg.distance_m,
|
||||
climb_m: leg.climb_m,
|
||||
descent_m: leg.descent_m,
|
||||
seconds: leg.seconds,
|
||||
snapped_m: leg.snapped_m,
|
||||
points: leg.geometry.length,
|
||||
segments: leg.segments.map(({ from: _from, to: _to, grooming: _grooming, osm_way: _way, ...segment }) => segment),
|
||||
why: leg.why,
|
||||
alternatives: leg.alternatives.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The piste router for a client, read only and with the REST route's rules:
|
||||
* the caller must see the trip, Tours must be on, and the request is the same
|
||||
* contract, planned by the same service. It plans and answers; nothing is
|
||||
* saved, and no clicked point leaves the server.
|
||||
*/
|
||||
@McpController()
|
||||
export class SkiRoutingMcp {
|
||||
constructor(
|
||||
private readonly routing: SkiRoutingService,
|
||||
private readonly access: TripAccessService,
|
||||
// Read by the addon gate.
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'plan_ski_route',
|
||||
description:
|
||||
'Plan the legs of a ski and winter tour on the runs, lifts, cross-country trails, ski touring and winter paths of OpenStreetMap, for a discipline (alpine, snowboard, cross_country with a technique of classic, skating or unknown, touring, snowshoe, winter_hike) and its options (max_difficulty from novice to expert, allow_freeride, avoid_lifts by class, allow_lift_down, priority fastest, most_skiing or easiest, pace, groomed_only and lit_only for cross-country, touring_uphill_on_pistes, use_lifts, avoid_inferred_links). Each leg (from, to) answers its length, climb and descent, its time split into skiing, lifts, waiting at lifts and walking, how far its ends were moved onto the network, its stretches (run with difficulty, lift with type, link the map data does not hold, and so on) and notes on what it rests on; a leg that cannot be planned says why (not_on_piste, no_area, cross_area, disconnected, blocked_by_difficulty with the level it needs, blocked_by_lift_avoid, blocked_by_technique, needs_ride_down, uphill_only, loading while an area downloads, places_unavailable, disabled). Times are estimates from the run difficulties and lift types, waits are guesses, and nothing says what is open, groomed or safe today; nothing is saved.',
|
||||
inputSchema: { tripId: idSchema, ...skiRouteRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async planSkiRoute({ tripId, ...request }: SkiRouteInput & { tripId: number }, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
const planned = await this.routing.route(skiRouteRequestSchema.parse(request));
|
||||
return ok({
|
||||
legs: planned.legs.map(summary),
|
||||
build: planned.build,
|
||||
stale: planned.stale,
|
||||
area_names: planned.area_names,
|
||||
colour_scheme: planned.colour_scheme,
|
||||
attribution: planned.attribution,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { TrekPlacesWinterService } from '../maps/trek-places-winter.service';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
|
||||
import { SkiAreasJob } from './ski-areas.job';
|
||||
import { SkiAreasService } from './ski-areas.service';
|
||||
import { InlineSkiRouterRunner, SKI_ROUTER_RUNNER } from './ski-router-runner';
|
||||
import { SkiRoutingController } from './ski-routing.controller';
|
||||
import { SkiRoutingMcp } from './ski-routing.mcp';
|
||||
import { SkiRoutingService } from './ski-routing.service';
|
||||
import { WorkerSkiRouterRunner } from './worker-router-runner';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/** The router's own thread where the build ships its script; on the calling thread under the TypeScript sources (the tests). */
|
||||
function routerRunner() {
|
||||
const entry = join(__dirname, 'ski-router.worker.js');
|
||||
return existsSync(entry) ? new WorkerSkiRouterRunner(entry) : new InlineSkiRouterRunner();
|
||||
}
|
||||
|
||||
/**
|
||||
* The piste router (spec 7): ski and winter tours planned on the ski areas
|
||||
* the TREK Places host builds from OpenStreetMap. The areas are downloaded by
|
||||
* id and kept in the data folder (SkiAreasService), searched in a worker
|
||||
* thread, and checked against the host's new builds once a day (SkiAreasJob).
|
||||
*/
|
||||
@Module({
|
||||
imports: [AddonsModule, PermissionsModule, TripMembershipModule, SchedulingModule],
|
||||
controllers: [SkiRoutingController],
|
||||
providers: [
|
||||
SkiRoutingService,
|
||||
SkiRoutingMcp,
|
||||
SkiAreasService,
|
||||
SkiAreasJob,
|
||||
TrekPlacesWinterService,
|
||||
{ provide: SKI_ROUTER_RUNNER, useFactory: routerRunner },
|
||||
],
|
||||
})
|
||||
export class SkiRoutingModule {}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { logInfo, logWarn } from '../audit/audit-log.logger';
|
||||
import { DomainError, notFound } from '../common/domain-error';
|
||||
import type { LegInput } from './engine/plan';
|
||||
import type { SkiProfile } from './engine/profile';
|
||||
import {
|
||||
DEFAULT_ATTRIBUTION,
|
||||
pickAreas,
|
||||
SkiAreasService,
|
||||
type AreaFile,
|
||||
type IndexedArea,
|
||||
type SkiIndex,
|
||||
} from './ski-areas.service';
|
||||
import { SKI_ROUTER_RUNNER, type SkiRouterRunner } from './ski-router-runner';
|
||||
import { Inject, Injectable, type OnApplicationShutdown } from '@nestjs/common';
|
||||
import {
|
||||
skiOptionsForDiscipline,
|
||||
skiUsesLifts,
|
||||
type SkiAreaNearResponse,
|
||||
type SkiAreaState,
|
||||
type SkiFailCode,
|
||||
type SkiNetworkResponse,
|
||||
type SkiRouteFailedLeg,
|
||||
type SkiRouteLeg,
|
||||
type SkiRouteRequest,
|
||||
type SkiRouteResponse,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** A leg whose area is not here within this long is answered `loading`; the download goes on. */
|
||||
export const LOADING_MS = 2000;
|
||||
|
||||
type Settled<T> = { status: 'done'; value: T } | { status: 'failed'; error: unknown } | { status: 'pending' };
|
||||
|
||||
/**
|
||||
* A promise's outcome if it settles within `ms`, else `pending`. The promise
|
||||
* is always handled, so one that fails after the wait never goes unhandled.
|
||||
*/
|
||||
export function settleWithin<T>(promise: Promise<T>, ms: number): Promise<Settled<T>> {
|
||||
const settled = promise.then(
|
||||
(value): Settled<T> => ({ status: 'done', value }),
|
||||
(error: unknown): Settled<T> => ({ status: 'failed', error }),
|
||||
);
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const waited = new Promise<Settled<T>>((resolve) => {
|
||||
timer = setTimeout(() => resolve({ status: 'pending' }), ms);
|
||||
});
|
||||
return Promise.race([settled, waited]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
function failed(code: SkiFailCode, at: { lat: number; lng: number } | null = null): SkiRouteFailedLeg {
|
||||
return { status: 'failed', code, at, hint: null, detail: null };
|
||||
}
|
||||
|
||||
/** A leg that cannot be planned here at all: the planner draws it freely. */
|
||||
function freeDraw(code: SkiFailCode): SkiRouteFailedLeg {
|
||||
return { status: 'failed', code, at: null, hint: { free_draw: true }, detail: null };
|
||||
}
|
||||
|
||||
const samePoint = (a: { lat: number; lng: number }, b: { lat: number; lng: number }) =>
|
||||
Math.abs(a.lat - b.lat) < 1e-7 && Math.abs(a.lng - b.lng) < 1e-7;
|
||||
|
||||
/** What the router is asked: the request's discipline with its own options and technique, the defaults resolved. */
|
||||
export function profileOf(request: SkiRouteRequest): SkiProfile {
|
||||
const options = skiOptionsForDiscipline(request.discipline, request.options);
|
||||
return {
|
||||
discipline: request.discipline,
|
||||
technique: request.discipline === 'cross_country' ? (request.technique ?? 'unknown') : null,
|
||||
options,
|
||||
useLifts: skiUsesLifts(request.discipline, options),
|
||||
};
|
||||
}
|
||||
|
||||
interface Group {
|
||||
areas: IndexedArea[];
|
||||
indices: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The piste router (spec 7): plans the legs of a ski and winter tour on the
|
||||
* ski areas the Places host serves, for the REST route and the MCP tool
|
||||
* alike. A leg is planned on the areas holding its ends; the legs of the same
|
||||
* areas go to the router's thread as one job, in order, so each goes on from
|
||||
* where the one before ended. Nothing is written; nothing of the trip is read.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SkiRoutingService implements OnApplicationShutdown {
|
||||
constructor(
|
||||
private readonly areas: SkiAreasService,
|
||||
private readonly addons: AddonsService,
|
||||
@Inject(SKI_ROUTER_RUNNER) private readonly runner: SkiRouterRunner,
|
||||
) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.runner.close();
|
||||
}
|
||||
|
||||
/** Why nothing can be planned here at all, or null: TREK Places off, or the admin's switch off. */
|
||||
private async refusal(): Promise<SkiFailCode | null> {
|
||||
if (!this.areas.enabled()) return 'places_unavailable';
|
||||
return (await this.addons.getToursSkiRouting()).enabled ? null : 'disabled';
|
||||
}
|
||||
|
||||
async route(request: SkiRouteRequest): Promise<SkiRouteResponse> {
|
||||
const all = (leg: SkiRouteLeg): SkiRouteResponse => ({
|
||||
legs: request.legs.map(() => leg),
|
||||
build: null,
|
||||
stale: false,
|
||||
area_names: [],
|
||||
colour_scheme: 'europe',
|
||||
attribution: DEFAULT_ATTRIBUTION,
|
||||
});
|
||||
const refused = await this.refusal();
|
||||
if (refused) return all(freeDraw(refused));
|
||||
const index = await settleWithin(this.areas.index(), LOADING_MS);
|
||||
if (index.status === 'pending') return all(failed('loading'));
|
||||
if (index.status === 'failed' || !index.value) return all(freeDraw('places_unavailable'));
|
||||
return this.routeOn(index.value, request);
|
||||
}
|
||||
|
||||
private async routeOn(index: SkiIndex, request: SkiRouteRequest): Promise<SkiRouteResponse> {
|
||||
const legs: SkiRouteLeg[] = new Array<SkiRouteLeg>(request.legs.length);
|
||||
const groups = new Map<string, Group>();
|
||||
request.legs.forEach((leg, position) => {
|
||||
const pick = pickAreas(index, leg);
|
||||
if (pick.status !== 'ok') {
|
||||
legs[position] = failed(pick.status, pick.at);
|
||||
return;
|
||||
}
|
||||
const key = pick.areas.map((area) => area.row.id).join('+');
|
||||
const group: Group = groups.get(key) ?? { areas: pick.areas, indices: [] };
|
||||
group.indices.push(position);
|
||||
groups.set(key, group);
|
||||
});
|
||||
const profile = profileOf(request);
|
||||
const used = new Set<IndexedArea>();
|
||||
let stale = false;
|
||||
await Promise.all(
|
||||
[...groups.values()].map(async (group) => {
|
||||
const planned = await this.planGroup(group, request, profile);
|
||||
group.indices.forEach((position, at) => {
|
||||
legs[position] = planned.legs[at] as SkiRouteLeg;
|
||||
});
|
||||
if (planned.used) group.areas.forEach((area) => used.add(area));
|
||||
stale ||= planned.stale;
|
||||
}),
|
||||
);
|
||||
const names = [...new Set([...used].flatMap((area) => (area.row.name ? [area.row.name] : [])))];
|
||||
return {
|
||||
legs,
|
||||
build: used.size > 0 ? index.build : null,
|
||||
stale,
|
||||
area_names: names,
|
||||
colour_scheme: [...used][0]?.row.colourScheme ?? 'europe',
|
||||
attribution: index.attribution,
|
||||
};
|
||||
}
|
||||
|
||||
/** One group's legs on its areas: downloaded within the wait, then planned in the router's thread. */
|
||||
private async planGroup(
|
||||
group: Group,
|
||||
request: SkiRouteRequest,
|
||||
profile: SkiProfile,
|
||||
): Promise<{ legs: SkiRouteLeg[]; used: boolean; stale: boolean }> {
|
||||
const answer = (leg: SkiRouteLeg) => ({ legs: group.indices.map(() => leg), used: false, stale: false });
|
||||
const downloads = Promise.all(group.areas.map((area) => this.areas.ensureArea(area.row)));
|
||||
const files = await settleWithin(downloads, LOADING_MS);
|
||||
if (files.status === 'pending') {
|
||||
return answer({ ...failed('loading'), detail: { name: group.areas[0]?.row.name ?? null } });
|
||||
}
|
||||
if (files.status === 'failed') {
|
||||
logInfo(`[Ski] no graph for ${group.areas.map((area) => area.row.id).join(', ')}: ${String(files.error)}`);
|
||||
return answer(freeDraw('places_unavailable'));
|
||||
}
|
||||
const legs: LegInput[] = group.indices.map((position, at) => {
|
||||
const leg = request.legs[position] as SkiRouteRequest['legs'][number];
|
||||
// A leg goes on from the one before only where that one is the tour's leg right before it.
|
||||
const previous = at > 0 && group.indices[at - 1] === position - 1 ? request.legs[position - 1] : undefined;
|
||||
return { from: leg.from, to: leg.to, continues: !!previous && samePoint(previous.to, leg.from) };
|
||||
});
|
||||
try {
|
||||
const planned = await this.runner.run({
|
||||
kind: 'route',
|
||||
files: files.value.map((file: AreaFile) => file.file),
|
||||
areaIds: group.areas.map((area) => area.row.id),
|
||||
legs,
|
||||
profile,
|
||||
alternatives: request.alternatives,
|
||||
});
|
||||
return { legs: planned, used: true, stale: files.value.some((file) => file.stale) };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logWarn(`[Ski] routing on ${group.areas.map((area) => area.row.id).join(', ')}: ${message}`);
|
||||
if (message === 'timeout' || message === 'busy') return answer(failed(message));
|
||||
return answer(freeDraw('places_unavailable'));
|
||||
}
|
||||
}
|
||||
|
||||
/** The ski area at a point and whether its graph is here; asking starts its download (spec 7.3, prefetch). */
|
||||
async areaNear(lat: number, lng: number): Promise<SkiAreaNearResponse> {
|
||||
const index = await settleWithin(this.areas.index(), LOADING_MS);
|
||||
if (index.status !== 'done' || !index.value) return { area: null };
|
||||
const row = index.value.areas.find(
|
||||
(area) => lat >= area.minLat && lat <= area.maxLat && lng >= area.minLng && lng <= area.maxLng,
|
||||
)?.row;
|
||||
if (!row) return { area: null };
|
||||
const refused = await this.refusal();
|
||||
let state: SkiAreaState = refused ? 'unavailable' : this.areas.stateOf(row);
|
||||
if (state === 'loading') {
|
||||
const fetched = await settleWithin(this.areas.ensureArea(row), 0);
|
||||
if (fetched.status === 'done') state = 'ready';
|
||||
}
|
||||
return {
|
||||
area: {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind,
|
||||
colour_scheme: row.colourScheme,
|
||||
build: index.value.build,
|
||||
state,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** An area's runs, lifts and trails as lines for the map; the area is downloaded first if it has to be. */
|
||||
async network(areaId: string): Promise<SkiNetworkResponse & { sha256: string }> {
|
||||
if (await this.refusal()) throw new DomainError(503, 'Piste data is not available');
|
||||
const index = await this.areas.index();
|
||||
const area = index?.areas.find((candidate) => candidate.row.id === areaId);
|
||||
if (!index || !area) throw notFound('Ski area not found');
|
||||
let file: AreaFile;
|
||||
try {
|
||||
file = await this.areas.ensureArea(area.row);
|
||||
} catch {
|
||||
throw new DomainError(503, 'Piste data is not available');
|
||||
}
|
||||
const lines = await this.runner.run({ kind: 'network', files: [file.file] });
|
||||
return {
|
||||
area_id: area.row.id,
|
||||
build: index.build,
|
||||
colour_scheme: area.row.colourScheme,
|
||||
...lines,
|
||||
sha256: area.row.sha256,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { SkiJob, SkiJobResult, SkiRouterRunner } from './ski-router-runner';
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { Worker } from 'node:worker_threads';
|
||||
|
||||
/** A job waits this long for its answer, and four seconds more for every leg it plans. */
|
||||
const BASE_ANSWER_MS = 8000;
|
||||
const ANSWER_MS_PER_LEG = 4000;
|
||||
const MAX_ANSWER_MS = 60_000;
|
||||
/** With this many jobs already waiting, a new one is turned away as busy rather than queued. */
|
||||
export const MAX_WAITING_JOBS = 8;
|
||||
/** A thread with nothing to do ends after half an hour; the next job starts one again. */
|
||||
const IDLE_MS = 30 * 60 * 1000;
|
||||
|
||||
interface Waiting {
|
||||
settle: (outcome: { result: unknown } | { error: Error }) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
type Reply = { id: number; result?: unknown; error?: string };
|
||||
|
||||
export function answerTimeoutMs(job: SkiJob): number {
|
||||
const legs = job.kind === 'route' ? job.legs.length : 1;
|
||||
return Math.min(MAX_ANSWER_MS, BASE_ANSWER_MS + ANSWER_MS_PER_LEG * Math.max(0, legs - 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* The piste router's own thread, so a search never holds up the server's
|
||||
* requests. Started on the first job and ended when idle; a job not answered
|
||||
* in time fails as `timeout` and the thread is replaced, a thread that dies
|
||||
* fails what it held, and the next job finds a fresh one.
|
||||
*/
|
||||
export class WorkerSkiRouterRunner implements SkiRouterRunner {
|
||||
private thread: Worker | null = null;
|
||||
private lastId = 0;
|
||||
private readonly waiting = new Map<number, Waiting>();
|
||||
private idleTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(private readonly entry = join(__dirname, 'ski-router.worker.js')) {}
|
||||
|
||||
run<J extends SkiJob>(job: J): Promise<SkiJobResult<J>> {
|
||||
if (this.waiting.size >= MAX_WAITING_JOBS) return Promise.reject(new Error('busy'));
|
||||
const worker = this.ensureThread();
|
||||
const id = ++this.lastId;
|
||||
return new Promise<SkiJobResult<J>>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.waiting.delete(id);
|
||||
reject(new Error('timeout'));
|
||||
this.replaceThread();
|
||||
}, answerTimeoutMs(job));
|
||||
this.waiting.set(id, {
|
||||
timer,
|
||||
settle: (outcome) => ('error' in outcome ? reject(outcome.error) : resolve(outcome.result as SkiJobResult<J>)),
|
||||
});
|
||||
worker.postMessage({ id, job });
|
||||
});
|
||||
}
|
||||
|
||||
forget(file: string): void {
|
||||
this.thread?.postMessage({ id: 0, forget: file });
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.idleTimer) clearTimeout(this.idleTimer);
|
||||
this.failWaiting(new Error('closed'));
|
||||
const worker = this.thread;
|
||||
this.thread = null;
|
||||
await worker?.terminate();
|
||||
}
|
||||
|
||||
private ensureThread(): Worker {
|
||||
if (this.idleTimer) clearTimeout(this.idleTimer);
|
||||
this.idleTimer = setTimeout(() => void this.close(), IDLE_MS);
|
||||
this.idleTimer.unref();
|
||||
if (this.thread) return this.thread;
|
||||
// The thread runs the compiled code with the path aliases the server's own start registers.
|
||||
const execArgv = process.execArgv.filter(
|
||||
(arg, index, all) =>
|
||||
arg === '--require' || arg === '-r' || all[index - 1] === '--require' || all[index - 1] === '-r',
|
||||
);
|
||||
const worker = new Worker(this.entry, { execArgv });
|
||||
worker.on('message', (reply: Reply) => this.receive(reply));
|
||||
worker.on('error', (err: Error) => this.lost(worker, err));
|
||||
worker.on('exit', () => this.lost(worker, new Error('router thread ended')));
|
||||
worker.unref();
|
||||
this.thread = worker;
|
||||
return worker;
|
||||
}
|
||||
|
||||
private receive(reply: Reply): void {
|
||||
const waiting = this.waiting.get(reply.id);
|
||||
if (!waiting) return;
|
||||
this.waiting.delete(reply.id);
|
||||
clearTimeout(waiting.timer);
|
||||
if (reply.result !== undefined) waiting.settle({ result: reply.result });
|
||||
else waiting.settle({ error: new Error(reply.error ?? 'router failed') });
|
||||
}
|
||||
|
||||
/** A thread that ended or failed takes its jobs with it; one already replaced leaves the new thread's alone. */
|
||||
private lost(worker: Worker, err: Error): void {
|
||||
if (this.thread !== worker) return;
|
||||
this.thread = null;
|
||||
this.failWaiting(err);
|
||||
}
|
||||
|
||||
private replaceThread(): void {
|
||||
const worker = this.thread;
|
||||
this.thread = null;
|
||||
void worker?.terminate();
|
||||
}
|
||||
|
||||
private failWaiting(err: Error): void {
|
||||
for (const [id, waiting] of this.waiting) {
|
||||
clearTimeout(waiting.timer);
|
||||
this.waiting.delete(id);
|
||||
waiting.settle({ error: err });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Piste router e2e (spec 12.5): `POST /api/trips/:tripId/ski/route`,
|
||||
* `GET .../ski/area-near` and `GET .../ski/network/:areaId` through the real
|
||||
* AddonGuard, JwtAuthGuard and TripAccessGuard, the Zod pipe and the response
|
||||
* contracts, over the real services and router on a migrated temp SQLite db.
|
||||
* The Places host is a stub serving one area drawn by the test, so nothing
|
||||
* leaves the test; its client is covered in trek-places.winter.test.ts.
|
||||
*/
|
||||
import { AppConfigModule } from '../../src/nest/app-config/app-config.module';
|
||||
import { DataPathsService } from '../../src/nest/app-config/data-paths.service';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { TrekPlacesWinterService } from '../../src/nest/maps/trek-places-winter.service';
|
||||
import { SkiRoutingModule } from '../../src/nest/ski-routing/ski-routing.module';
|
||||
import { createSnapshotTestDb } from '../helpers/db-mock';
|
||||
import { setAddonEnabled, setAppSetting } from '../helpers/factories/settings';
|
||||
import { addTripMember, makeTrip } from '../helpers/factories/trips';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { SkiWorld } from '../helpers/ski-world';
|
||||
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { sessionCookie } from './harness';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { skiAreaNearResponseSchema, skiNetworkResponseSchema, skiRouteResponseSchema } from '@trek/shared';
|
||||
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
|
||||
const OWNER = 1;
|
||||
const MEMBER = 2;
|
||||
const STRANGER = 3;
|
||||
let tripId: number;
|
||||
let orm: TestOrm;
|
||||
let server: Server;
|
||||
let dir: string;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
/** Testalp: a chair up, a red run down. */
|
||||
const testalp = new SkiWorld();
|
||||
const valley = testalp.node(0, 0, 1200);
|
||||
const top = testalp.node(0, 2000, 2200);
|
||||
testalp.lift(valley, top, { name: 'Sonnenbahn', sec: 420 });
|
||||
testalp.run(top, valley, 2, { name: 'Rote Eins', via: [[300, 1000]] });
|
||||
const GZ = testalp.gz();
|
||||
const sha = (bytes: Buffer | string) => createHash('sha256').update(bytes).digest('hex');
|
||||
const south = testalp.at(-500, -500);
|
||||
const north = testalp.at(500, 2500);
|
||||
const ROW = {
|
||||
id: 'w:1',
|
||||
name: 'Testalp',
|
||||
kind: 'resort',
|
||||
country: 'CH',
|
||||
colourScheme: 'europe' as const,
|
||||
bbox: [south.lng, south.lat, north.lng, north.lat] as [number, number, number, number],
|
||||
counts: { runsKm: 2.1, nordicKm: 0, lifts: 1, skitourKm: 0, hikeKm: 0 },
|
||||
bytes: GZ.length,
|
||||
sha256: sha(GZ),
|
||||
};
|
||||
const INDEX = { build: '2026-11-02', format: 1, areas: [ROW] };
|
||||
const INDEX_SHA = sha(JSON.stringify(INDEX));
|
||||
|
||||
const winter = {
|
||||
enabled: vi.fn(() => true),
|
||||
meta: vi.fn(async () => ({
|
||||
build: '2026-11-02',
|
||||
format: 1,
|
||||
areas: 1,
|
||||
indexSha: INDEX_SHA,
|
||||
sourceTimestamp: null,
|
||||
attribution: { data: '© OpenStreetMap contributors, ODbL', terrain: 'Terrain: Mapzen Terrarium' },
|
||||
})),
|
||||
index: vi.fn(async () => ({ status: 'ok' as const, index: INDEX, sha256: INDEX_SHA, dropped: 0 })),
|
||||
area: vi.fn(async () => ({ status: 'ok' as const, bytes: GZ, etag: ROW.sha256 })),
|
||||
};
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
await TestUnitOfWorkModule.forRoot(db),
|
||||
await createTestMikroOrmModule(db),
|
||||
AppConfigModule,
|
||||
SkiRoutingModule,
|
||||
],
|
||||
})
|
||||
.overrideProvider(DataPathsService)
|
||||
.useValue({ dataDir: dir, tmpDir: dir })
|
||||
.overrideProvider(TrekPlacesWinterService)
|
||||
.useValue(winter)
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
const route = (body: unknown, userId?: number, trip = tripId) => {
|
||||
const req = request(server)
|
||||
.post(`/api/trips/${trip}/ski/route`)
|
||||
.send(body as object);
|
||||
return userId === undefined ? req : req.set({ Cookie: sessionCookie(userId) });
|
||||
};
|
||||
const down = { from: testalp.at(0, 2000), to: testalp.at(0, 0) };
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'trek-ski-routing-e2e-'));
|
||||
orm = await createTestOrm(db);
|
||||
await makeUser(orm, { id: OWNER, email: 'owner@example.test' });
|
||||
await makeUser(orm, { id: MEMBER, email: 'member@example.test' });
|
||||
await makeUser(orm, { id: STRANGER, email: 'stranger@example.test' });
|
||||
tripId = (await makeTrip(orm, OWNER)).id;
|
||||
await addTripMember(orm, tripId, MEMBER);
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await setAddonEnabled(orm, 'tours', true);
|
||||
await setAppSetting(orm, 'ski_routing_enabled', 'true');
|
||||
winter.enabled.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await orm.close();
|
||||
db.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('POST /api/trips/:tripId/ski/route', () => {
|
||||
it('SKIROUTE-E2E-001: 404 while Tours is off; 401 without a session', async () => {
|
||||
await setAddonEnabled(orm, 'tours', false);
|
||||
const off = await route({ legs: [down], discipline: 'alpine' }, OWNER);
|
||||
expect(off.status).toBe(404);
|
||||
expect(off.body).toEqual({ error: 'Tours addon is not enabled' });
|
||||
await setAddonEnabled(orm, 'tours', true);
|
||||
expect((await route({ legs: [down], discipline: 'alpine' })).status).toBe(401);
|
||||
});
|
||||
|
||||
it('SKIROUTE-E2E-002: a trip the caller is not on answers 404, as one that does not exist', async () => {
|
||||
expect((await route({ legs: [down], discipline: 'alpine' }, STRANGER)).status).toBe(404);
|
||||
expect((await route({ legs: [down], discipline: 'alpine' }, OWNER, 999_999)).status).toBe(404);
|
||||
});
|
||||
|
||||
it('SKIROUTE-E2E-003: refuses a request off the contract with 400', async () => {
|
||||
// One after another: supertest binds the unstarted server per request, and requests at once reset each other.
|
||||
const answers = [
|
||||
await route({ legs: [down] }, OWNER),
|
||||
await route({ legs: [], discipline: 'alpine' }, OWNER),
|
||||
await route({ legs: [{ from: { lat: 95, lng: 0 }, to: down.to }], discipline: 'alpine' }, OWNER),
|
||||
await route({ legs: [down], discipline: 'alpine', options: { colour: 'red' } }, OWNER),
|
||||
await route({ legs: [down], discipline: 'sledge' }, OWNER),
|
||||
];
|
||||
expect(answers.map((res) => res.status)).toEqual([400, 400, 400, 400, 400]);
|
||||
});
|
||||
|
||||
it('SKIROUTE-E2E-004: owner and member get the leg planned down the red run, on the contract', async () => {
|
||||
const answers = [
|
||||
await route({ legs: [down], discipline: 'alpine' }, OWNER),
|
||||
await route({ legs: [down], discipline: 'alpine' }, MEMBER),
|
||||
];
|
||||
for (const res of answers) {
|
||||
expect(res.status).toBe(200);
|
||||
const answer = skiRouteResponseSchema.parse(res.body);
|
||||
const [leg] = answer.legs;
|
||||
if (leg?.status !== 'ok') throw new Error('not planned');
|
||||
expect(leg.segments.map((segment) => segment.name)).toEqual(['Rote Eins']);
|
||||
expect(answer).toMatchObject({ build: '2026-11-02', stale: false, area_names: ['Testalp'] });
|
||||
}
|
||||
// The area was downloaded once, by its id; no point ever went to the host.
|
||||
expect(winter.area).toHaveBeenCalledTimes(1);
|
||||
expect(winter.area).toHaveBeenCalledWith('w:1', null);
|
||||
});
|
||||
|
||||
it('SKIROUTE-E2E-005: the admin switch off answers disabled; TREK Places off answers unavailable', async () => {
|
||||
await setAppSetting(orm, 'ski_routing_enabled', 'false');
|
||||
const off = await route({ legs: [down], discipline: 'alpine' }, OWNER);
|
||||
expect(off.status).toBe(200);
|
||||
expect(off.body.legs[0]).toMatchObject({ status: 'failed', code: 'disabled', hint: { free_draw: true } });
|
||||
await setAppSetting(orm, 'ski_routing_enabled', 'true');
|
||||
winter.enabled.mockReturnValue(false);
|
||||
const none = await route({ legs: [down], discipline: 'alpine' }, OWNER);
|
||||
expect(none.body.legs[0]).toMatchObject({ code: 'places_unavailable' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/trips/:tripId/ski/area-near and /network/:areaId', () => {
|
||||
it('SKIROUTE-E2E-010: names the area at a point and its state; none off every area; 400 off the globe', async () => {
|
||||
const at = testalp.at(0, 1000);
|
||||
const near = await request(server)
|
||||
.get(`/api/trips/${tripId}/ski/area-near`)
|
||||
.query({ lat: at.lat, lng: at.lng })
|
||||
.set({ Cookie: sessionCookie(MEMBER) });
|
||||
expect(near.status).toBe(200);
|
||||
expect(skiAreaNearResponseSchema.parse(near.body).area).toMatchObject({ id: 'w:1', name: 'Testalp' });
|
||||
const nowhere = await request(server)
|
||||
.get(`/api/trips/${tripId}/ski/area-near`)
|
||||
.query({ lat: 53.5, lng: 10 })
|
||||
.set({ Cookie: sessionCookie(MEMBER) });
|
||||
expect(nowhere.body).toEqual({ area: null });
|
||||
const bad = await request(server)
|
||||
.get(`/api/trips/${tripId}/ski/area-near`)
|
||||
.query({ lat: 95, lng: 10 })
|
||||
.set({ Cookie: sessionCookie(MEMBER) });
|
||||
expect(bad.status).toBe(400);
|
||||
});
|
||||
|
||||
it('SKIROUTE-E2E-011: an area lines for the map, tagged with its checksum; an unknown area 404', async () => {
|
||||
const lines = await request(server)
|
||||
.get(`/api/trips/${tripId}/ski/network/w:1`)
|
||||
.set({ Cookie: sessionCookie(OWNER) });
|
||||
expect(lines.status).toBe(200);
|
||||
expect(lines.headers.etag).toBe(`"${ROW.sha256}"`);
|
||||
const network = skiNetworkResponseSchema.parse(lines.body);
|
||||
expect(network.features.map((feature) => feature.kind).sort()).toEqual(['lift', 'run']);
|
||||
const unknown = await request(server)
|
||||
.get(`/api/trips/${tripId}/ski/network/w:404`)
|
||||
.set({ Cookie: sessionCookie(OWNER) });
|
||||
expect(unknown.status).toBe(404);
|
||||
expect(unknown.body).toEqual({ error: 'Ski area not found' });
|
||||
});
|
||||
});
|
||||
+502
@@ -0,0 +1,502 @@
|
||||
{
|
||||
"sha256": "9dd044605b8edf85c7fcc83e9f3bdb857f1e6451d09da7238d7f3071cf6c4b67",
|
||||
"bytes": 1728,
|
||||
"header": {
|
||||
"format": 1,
|
||||
"build": "2026-11-02",
|
||||
"areaId": "w:100",
|
||||
"name": "Sonnenbahn",
|
||||
"kind": "resort",
|
||||
"country": null,
|
||||
"colourScheme": "europe",
|
||||
"bbox": [
|
||||
7.0,
|
||||
45.999,
|
||||
7.005,
|
||||
46.0052
|
||||
],
|
||||
"counts": {
|
||||
"runsKm": 1.19,
|
||||
"nordicKm": 0.53,
|
||||
"lifts": 1,
|
||||
"skitourKm": 0.0,
|
||||
"hikeKm": 0.0
|
||||
},
|
||||
"nodeCount": 12,
|
||||
"edgeCount": 14,
|
||||
"geomCount": 33,
|
||||
"stringBytes": 43,
|
||||
"crc32": 2370316388
|
||||
},
|
||||
"strings": {
|
||||
"0": "Rote Eins",
|
||||
"10": "1",
|
||||
"12": "Blaue Zwei",
|
||||
"23": "Talloipe",
|
||||
"32": "Sonnenbahn"
|
||||
},
|
||||
"nodeLatE7": [
|
||||
460052000,
|
||||
460015000,
|
||||
460006075,
|
||||
460049894,
|
||||
460000000,
|
||||
459990000,
|
||||
460000000,
|
||||
460000000,
|
||||
460050000,
|
||||
460000000,
|
||||
460050000,
|
||||
460005000
|
||||
],
|
||||
"nodeLngE7": [
|
||||
70002000,
|
||||
70006000,
|
||||
70002430,
|
||||
70000266,
|
||||
70030000,
|
||||
70050000,
|
||||
70019000,
|
||||
70000000,
|
||||
70000000,
|
||||
70000000,
|
||||
70000000,
|
||||
70008000
|
||||
],
|
||||
"nodeEle": [
|
||||
1645,
|
||||
1542,
|
||||
1517,
|
||||
1639,
|
||||
1500,
|
||||
1472,
|
||||
1500,
|
||||
1500,
|
||||
1639,
|
||||
1500,
|
||||
1639,
|
||||
1514
|
||||
],
|
||||
"nodeFlags": [
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
16,
|
||||
1,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"nodeOsm": [
|
||||
10.0,
|
||||
14.0,
|
||||
-4.0,
|
||||
-3.0,
|
||||
20.0,
|
||||
22.0,
|
||||
24.0,
|
||||
-1.0,
|
||||
-2.0,
|
||||
1.0,
|
||||
2.0,
|
||||
13.0
|
||||
],
|
||||
"edgeA": [
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
9,
|
||||
8,
|
||||
3,
|
||||
10,
|
||||
2,
|
||||
11,
|
||||
6
|
||||
],
|
||||
"edgeB": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
5,
|
||||
4,
|
||||
4,
|
||||
8,
|
||||
7,
|
||||
10,
|
||||
11,
|
||||
3,
|
||||
9,
|
||||
2,
|
||||
11
|
||||
],
|
||||
"edgeLen": [
|
||||
429.64385986328125,
|
||||
103.00074005126953,
|
||||
26.973962783813477,
|
||||
265.6802673339844,
|
||||
265.68304443359375,
|
||||
84.96685028076172,
|
||||
555.9754028320312,
|
||||
0.0,
|
||||
0.0,
|
||||
558.4176635742188,
|
||||
2.3661956787109375,
|
||||
70.11087799072266,
|
||||
44.65337371826172,
|
||||
101.54007720947266
|
||||
],
|
||||
"edgeUp": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
28,
|
||||
0,
|
||||
139,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
14
|
||||
],
|
||||
"edgeDown": [
|
||||
103,
|
||||
25,
|
||||
6,
|
||||
28,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
125,
|
||||
0,
|
||||
17,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"edgeKind": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
5,
|
||||
9,
|
||||
1,
|
||||
3,
|
||||
4,
|
||||
0,
|
||||
11,
|
||||
0,
|
||||
11,
|
||||
11
|
||||
],
|
||||
"edgeFwdUses": [
|
||||
65,
|
||||
65,
|
||||
65,
|
||||
24,
|
||||
24,
|
||||
1017,
|
||||
2,
|
||||
514,
|
||||
514,
|
||||
65,
|
||||
577,
|
||||
65,
|
||||
577,
|
||||
1017
|
||||
],
|
||||
"edgeBackUses": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
24,
|
||||
24,
|
||||
1017,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
577,
|
||||
0,
|
||||
577,
|
||||
1017
|
||||
],
|
||||
"edgeDiff": [
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
1,
|
||||
255,
|
||||
2,
|
||||
255,
|
||||
255
|
||||
],
|
||||
"edgeGroom": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"edgeLift": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"edgeLiftSec": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
360,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"edgeLiftCap": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2400,
|
||||
2400,
|
||||
2400,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"edgeFlags": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
33024,
|
||||
33024,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
0,
|
||||
16,
|
||||
16
|
||||
],
|
||||
"edgeName": [
|
||||
0,
|
||||
0,
|
||||
12,
|
||||
23,
|
||||
23,
|
||||
4294967295,
|
||||
32,
|
||||
32,
|
||||
32,
|
||||
12,
|
||||
4294967295,
|
||||
0,
|
||||
4294967295,
|
||||
4294967295
|
||||
],
|
||||
"edgeRef": [
|
||||
10,
|
||||
10,
|
||||
4294967295,
|
||||
4294967295,
|
||||
4294967295,
|
||||
4294967295,
|
||||
4294967295,
|
||||
4294967295,
|
||||
4294967295,
|
||||
4294967295,
|
||||
4294967295,
|
||||
10,
|
||||
4294967295,
|
||||
4294967295
|
||||
],
|
||||
"edgeWay": [
|
||||
200.0,
|
||||
200.0,
|
||||
201.0,
|
||||
300.0,
|
||||
300.0,
|
||||
301.0,
|
||||
100.0,
|
||||
0.0,
|
||||
0.0,
|
||||
201.0,
|
||||
0.0,
|
||||
200.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"edgeGeomOff": [
|
||||
0,
|
||||
3,
|
||||
5,
|
||||
7,
|
||||
10,
|
||||
13,
|
||||
15,
|
||||
18,
|
||||
20,
|
||||
22,
|
||||
25,
|
||||
27,
|
||||
29,
|
||||
31,
|
||||
33
|
||||
],
|
||||
"geomLatE7": [
|
||||
460052000,
|
||||
460030000,
|
||||
460015000,
|
||||
460015000,
|
||||
460006075,
|
||||
460052000,
|
||||
460049894,
|
||||
460000000,
|
||||
460000000,
|
||||
459990000,
|
||||
459990000,
|
||||
459990000,
|
||||
460000000,
|
||||
460000000,
|
||||
460000000,
|
||||
460000000,
|
||||
460025000,
|
||||
460050000,
|
||||
460000000,
|
||||
460000000,
|
||||
460050000,
|
||||
460050000,
|
||||
460049894,
|
||||
460035000,
|
||||
460005000,
|
||||
460050000,
|
||||
460049894,
|
||||
460006075,
|
||||
460000000,
|
||||
460005000,
|
||||
460006075,
|
||||
460000000,
|
||||
460005000
|
||||
],
|
||||
"geomLngE7": [
|
||||
70002000,
|
||||
70012000,
|
||||
70006000,
|
||||
70006000,
|
||||
70002430,
|
||||
70002000,
|
||||
70000266,
|
||||
70030000,
|
||||
70050000,
|
||||
70050000,
|
||||
70050000,
|
||||
70030000,
|
||||
70030000,
|
||||
70019000,
|
||||
70030000,
|
||||
70000000,
|
||||
70000000,
|
||||
70000000,
|
||||
70000000,
|
||||
70000000,
|
||||
70000000,
|
||||
70000000,
|
||||
70000266,
|
||||
69988000,
|
||||
70008000,
|
||||
70000000,
|
||||
70000266,
|
||||
70002430,
|
||||
70000000,
|
||||
70008000,
|
||||
70002430,
|
||||
70019000,
|
||||
70008000
|
||||
],
|
||||
"geomEle": [
|
||||
1645,
|
||||
1583,
|
||||
1542,
|
||||
1542,
|
||||
1517,
|
||||
1645,
|
||||
1639,
|
||||
1500,
|
||||
1500,
|
||||
1472,
|
||||
1472,
|
||||
1472,
|
||||
1500,
|
||||
1500,
|
||||
1500,
|
||||
1500,
|
||||
1570,
|
||||
1639,
|
||||
1500,
|
||||
1500,
|
||||
1639,
|
||||
1639,
|
||||
1639,
|
||||
1597,
|
||||
1514,
|
||||
1639,
|
||||
1639,
|
||||
1517,
|
||||
1500,
|
||||
1514,
|
||||
1517,
|
||||
1500,
|
||||
1514
|
||||
]
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Planning legs on test worlds the way the service does: the request through
|
||||
* its contract, the profile as the service resolves it, the legs of one job
|
||||
* each going on from the one before.
|
||||
*/
|
||||
import { SkiRouter } from '../../src/nest/ski-routing/engine/plan';
|
||||
import { stitchGraphs } from '../../src/nest/ski-routing/engine/stitch';
|
||||
import { profileOf } from '../../src/nest/ski-routing/ski-routing.service';
|
||||
import type { SkiWorld } from './ski-world';
|
||||
import {
|
||||
skiRouteRequestSchema,
|
||||
type SkiDiscipline,
|
||||
type SkiRouteLeg,
|
||||
type SkiRouteOptionsInput,
|
||||
type SkiTechnique,
|
||||
} from '@trek/shared';
|
||||
|
||||
export type Point = { lat: number; lng: number };
|
||||
|
||||
export interface PlanOptions {
|
||||
discipline?: SkiDiscipline;
|
||||
options?: SkiRouteOptionsInput;
|
||||
technique?: SkiTechnique | null;
|
||||
alternatives?: number;
|
||||
/** Whether each leg goes on from where the one before ended (the default), as the service sends them. */
|
||||
chained?: boolean;
|
||||
}
|
||||
|
||||
export function planOn(
|
||||
worlds: SkiWorld | SkiWorld[],
|
||||
legs: Array<[Point, Point]>,
|
||||
{ discipline = 'alpine', options = {}, technique = null, alternatives = 0, chained = true }: PlanOptions = {},
|
||||
): SkiRouteLeg[] {
|
||||
const all = Array.isArray(worlds) ? worlds : [worlds];
|
||||
const router = new SkiRouter(stitchGraphs(all.map((world) => world.graph())));
|
||||
const request = skiRouteRequestSchema.parse({
|
||||
legs: legs.map(([from, to]) => ({ from, to })),
|
||||
discipline,
|
||||
technique,
|
||||
options,
|
||||
alternatives,
|
||||
});
|
||||
const input = request.legs.map((leg, index) => ({ from: leg.from, to: leg.to, continues: chained && index > 0 }));
|
||||
return router.plan({ legs: input, profile: profileOf(request), alternatives: request.alternatives }, ['w:1']);
|
||||
}
|
||||
|
||||
/** The leg as planned, or a failure that says why it was not. */
|
||||
export function okLeg(leg: SkiRouteLeg | undefined): Extract<SkiRouteLeg, { status: 'ok' }> {
|
||||
if (leg?.status !== 'ok') throw new Error(`leg not planned: ${JSON.stringify(leg)}`);
|
||||
return leg;
|
||||
}
|
||||
|
||||
export function failedLeg(leg: SkiRouteLeg | undefined): Extract<SkiRouteLeg, { status: 'failed' }> {
|
||||
if (leg?.status !== 'failed') throw new Error(`leg planned, a failure was expected`);
|
||||
return leg;
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Ski areas drawn by a test: nodes set in metres east and north of an origin,
|
||||
* runs, trails and lifts between them, written out as SGR1 exactly as the
|
||||
* Places host's writer lays it out (`winter/sgr.py`): magic, head, sections
|
||||
* padded to 8 bytes, string pool, CRC-32 over everything after the head. The
|
||||
* golden file pins the reader against the real writer; this builds the
|
||||
* networks the router tests need without a planet.
|
||||
*/
|
||||
import { decodeSkiGraph } from '../../src/nest/ski-routing/ski-graph';
|
||||
import {
|
||||
DIFF_UNKNOWN,
|
||||
ELE_MISSING,
|
||||
Kind,
|
||||
Lift,
|
||||
NO_STRING,
|
||||
NodeFlag,
|
||||
Use,
|
||||
type SkiGraphData,
|
||||
} from '../../src/nest/ski-routing/ski-graph.contract';
|
||||
|
||||
import { crc32, gzipSync } from 'node:zlib';
|
||||
|
||||
const M_PER_DEG = 111_195;
|
||||
|
||||
export interface WorldNode {
|
||||
x: number;
|
||||
y: number;
|
||||
ele: number | null;
|
||||
osm: number;
|
||||
flags: number;
|
||||
}
|
||||
|
||||
export interface WorldEdge {
|
||||
a: number;
|
||||
b: number;
|
||||
kind: number;
|
||||
fwd: number;
|
||||
back: number;
|
||||
diff: number;
|
||||
groom: number;
|
||||
lift: number;
|
||||
liftSec: number;
|
||||
liftCap: number;
|
||||
flags: number;
|
||||
name: string | null;
|
||||
ref: string | null;
|
||||
way: number;
|
||||
via: Array<[number, number]>;
|
||||
}
|
||||
|
||||
export type EdgeOptions = Partial<Omit<WorldEdge, 'a' | 'b'>>;
|
||||
|
||||
interface WorldPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
ele: number | null;
|
||||
}
|
||||
|
||||
/** The length of a line of points in metres. */
|
||||
function lineLength(points: readonly WorldPoint[]): number {
|
||||
let total = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const a = points[i - 1] as WorldPoint;
|
||||
const b = points[i] as WorldPoint;
|
||||
total += Math.hypot(b.x - a.x, b.y - a.y);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Both directions downhill-only runs carry: a skier down, a ski tourer down. */
|
||||
export const RUN_DOWN = Use.DOWNHILL | Use.SKITOUR_DOWN;
|
||||
export const NORDIC_BOTH = Use.NORDIC_CLASSIC | Use.NORDIC_SKATING;
|
||||
|
||||
export class SkiWorld {
|
||||
readonly nodes: WorldNode[] = [];
|
||||
readonly edges: WorldEdge[] = [];
|
||||
private nextOsm = 1;
|
||||
private nextSynthetic = -1;
|
||||
private nextWay = 1;
|
||||
|
||||
constructor(
|
||||
readonly origin = { lat: 46, lng: 7 },
|
||||
readonly head: { areaId: string; build: string; name: string; colourScheme?: string } = {
|
||||
areaId: 'w:1',
|
||||
build: '2026-11-02',
|
||||
name: 'Testalp',
|
||||
},
|
||||
) {}
|
||||
|
||||
/** A point `x` metres east and `y` metres north of the origin. */
|
||||
at(x: number, y: number): { lat: number; lng: number } {
|
||||
return {
|
||||
lat: this.origin.lat + y / M_PER_DEG,
|
||||
lng: this.origin.lng + x / (M_PER_DEG * Math.cos((this.origin.lat * Math.PI) / 180)),
|
||||
};
|
||||
}
|
||||
|
||||
node(x: number, y: number, ele: number | null, options: { flags?: number; osm?: number } = {}): number {
|
||||
const synthetic = options.flags !== undefined && (options.flags & NodeFlag.LIFT_INTERNAL) !== 0;
|
||||
const osm = options.osm ?? (synthetic ? this.nextSynthetic-- : this.nextOsm++);
|
||||
this.nodes.push({ x, y, ele, osm, flags: options.flags ?? 0 });
|
||||
return this.nodes.length - 1;
|
||||
}
|
||||
|
||||
edge(a: number, b: number, options: EdgeOptions = {}): number {
|
||||
this.edges.push({
|
||||
a,
|
||||
b,
|
||||
kind: Kind.RUN,
|
||||
fwd: 0,
|
||||
back: 0,
|
||||
diff: DIFF_UNKNOWN,
|
||||
groom: 0,
|
||||
lift: Lift.NONE,
|
||||
liftSec: 0,
|
||||
liftCap: 0,
|
||||
flags: 0,
|
||||
name: null,
|
||||
ref: null,
|
||||
way: this.nextWay++,
|
||||
via: [],
|
||||
...options,
|
||||
});
|
||||
return this.edges.length - 1;
|
||||
}
|
||||
|
||||
/** A run skied from a to b only. */
|
||||
run(a: number, b: number, diff: number, options: EdgeOptions = {}): number {
|
||||
return this.edge(a, b, { kind: Kind.RUN, fwd: RUN_DOWN, back: 0, diff, ...options });
|
||||
}
|
||||
|
||||
/** A cross-country trail, both ways, groomed as given. */
|
||||
nordic(a: number, b: number, options: EdgeOptions = {}): number {
|
||||
return this.edge(a, b, { kind: Kind.NORDIC, fwd: NORDIC_BOTH, back: NORDIC_BOTH, groom: 3, ...options });
|
||||
}
|
||||
|
||||
/** A short link the data does not hold, walked or skied either way. */
|
||||
link(a: number, b: number, options: EdgeOptions = {}): number {
|
||||
const uses = Use.WALK | Use.DOWNHILL | Use.SKITOUR_DOWN;
|
||||
return this.edge(a, b, { kind: Kind.LINK, fwd: uses, back: uses, way: 0, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* A lift from a bottom ground node to a top one: its own two ride nodes, a
|
||||
* board edge at the bottom, the ride, an alight edge at the top, as the
|
||||
* writer lays a lift out. `down` lets it be ridden down as well.
|
||||
*/
|
||||
lift(
|
||||
bottom: number,
|
||||
top: number,
|
||||
options: { type?: number; name?: string; sec?: number; cap?: number; down?: boolean } = {},
|
||||
): { ride: number; board: number; alight: number } {
|
||||
const lift = options.type ?? Lift.CHAIR_DETACHABLE;
|
||||
const name = options.name ?? null;
|
||||
const from = this.nodes[bottom] as WorldNode;
|
||||
const to = this.nodes[top] as WorldNode;
|
||||
const rideBottom = this.node(from.x, from.y, from.ele, { flags: NodeFlag.LIFT_INTERNAL });
|
||||
const rideTop = this.node(to.x, to.y, to.ele, { flags: NodeFlag.LIFT_INTERNAL });
|
||||
from.flags |= NodeFlag.STATION;
|
||||
to.flags |= NodeFlag.STATION;
|
||||
const down = options.down ? Use.LIFT_DOWN : 0;
|
||||
const way = this.nextWay++;
|
||||
const common = { lift, name, liftCap: options.cap ?? 0, way };
|
||||
const board = this.edge(bottom, rideBottom, { ...common, kind: Kind.BOARD, fwd: Use.WALK | Use.LIFT_UP });
|
||||
const kind = lift === Lift.FUNICULAR || lift === Lift.RACK ? Kind.RAIL : Kind.LIFT;
|
||||
const liftSec = options.sec ?? 0;
|
||||
const ride = this.edge(rideBottom, rideTop, { ...common, kind, fwd: Use.LIFT_UP, back: down, liftSec });
|
||||
const alight = this.edge(rideTop, top, { ...common, kind: Kind.ALIGHT, fwd: Use.WALK | Use.LIFT_UP });
|
||||
if (options.down) {
|
||||
// Ridden down: boarded at the top, left at the bottom.
|
||||
this.edge(top, rideTop, { ...common, kind: Kind.BOARD, fwd: Use.WALK | Use.LIFT_DOWN });
|
||||
this.edge(rideBottom, bottom, { ...common, kind: Kind.ALIGHT, fwd: Use.WALK | Use.LIFT_DOWN });
|
||||
}
|
||||
return { ride, board, alight };
|
||||
}
|
||||
|
||||
/** An edge's points, its heights straight between its ends where both are known. */
|
||||
private line(e: WorldEdge): WorldPoint[] {
|
||||
const a = this.nodes[e.a] as WorldNode;
|
||||
const b = this.nodes[e.b] as WorldNode;
|
||||
const points: WorldPoint[] = [a, ...e.via.map(([x, y]) => ({ x, y, ele: null })), b];
|
||||
return points.map((p, i) => {
|
||||
if (i === 0 || i === points.length - 1) return { x: p.x, y: p.y, ele: p.ele };
|
||||
const share = i / (points.length - 1);
|
||||
return { x: p.x, y: p.y, ele: a.ele !== null && b.ele !== null ? a.ele + (b.ele - a.ele) * share : null };
|
||||
});
|
||||
}
|
||||
|
||||
private point(x: number, y: number): [number, number] {
|
||||
const { lat, lng } = this.at(x, y);
|
||||
return [lat, lng];
|
||||
}
|
||||
|
||||
/** The graph as the reader would hand it over, through the bytes, so the writer's layout is exercised. */
|
||||
graph(): SkiGraphData {
|
||||
return decodeSkiGraph(this.sgr());
|
||||
}
|
||||
|
||||
gz(): Buffer {
|
||||
return gzipSync(this.sgr());
|
||||
}
|
||||
|
||||
/** The SGR1 bytes of the world. */
|
||||
sgr(): Buffer {
|
||||
const pool: Buffer[] = [];
|
||||
const offsets = new Map<string, number>();
|
||||
let poolBytes = 0;
|
||||
const sid = (text: string | null): number => {
|
||||
if (!text) return NO_STRING;
|
||||
const known = offsets.get(text);
|
||||
if (known !== undefined) return known;
|
||||
const bytes = Buffer.from(`${text}\0`, 'utf8');
|
||||
offsets.set(text, poolBytes);
|
||||
pool.push(bytes);
|
||||
poolBytes += bytes.length;
|
||||
return poolBytes - bytes.length;
|
||||
};
|
||||
const ele = (value: number | null) => (value === null ? ELE_MISSING : Math.round(value));
|
||||
const n = this.nodes.length;
|
||||
const m = this.edges.length;
|
||||
const geometry = this.edges.map((e) => this.line(e));
|
||||
const g = geometry.reduce((sum, points) => sum + points.length, 0);
|
||||
|
||||
const sections: Buffer[] = [];
|
||||
const add = (view: ArrayBufferView) => {
|
||||
const raw = Buffer.from(view.buffer, view.byteOffset, view.byteLength);
|
||||
sections.push(raw, Buffer.alloc((8 - (raw.length % 8)) % 8));
|
||||
};
|
||||
add(Int32Array.from(this.nodes, (p) => Math.round(this.point(p.x, p.y)[0] * 1e7)));
|
||||
add(Int32Array.from(this.nodes, (p) => Math.round(this.point(p.x, p.y)[1] * 1e7)));
|
||||
add(Int16Array.from(this.nodes, (p) => ele(p.ele)));
|
||||
add(Uint16Array.from(this.nodes, (p) => p.flags | (p.ele === null ? NodeFlag.ELE_MISSING : 0)));
|
||||
add(Float64Array.from(this.nodes, (p) => p.osm));
|
||||
add(Uint32Array.from(this.edges, (e) => e.a));
|
||||
add(Uint32Array.from(this.edges, (e) => e.b));
|
||||
add(Float32Array.from(geometry, (points) => lineLength(points)));
|
||||
const rise = (e: WorldEdge, up: boolean) => {
|
||||
const a = (this.nodes[e.a] as WorldNode).ele;
|
||||
const b = (this.nodes[e.b] as WorldNode).ele;
|
||||
if (a === null || b === null) return 0;
|
||||
return Math.max(0, Math.round(up ? b - a : a - b));
|
||||
};
|
||||
add(Uint16Array.from(this.edges, (e) => rise(e, true)));
|
||||
add(Uint16Array.from(this.edges, (e) => rise(e, false)));
|
||||
add(Uint8Array.from(this.edges, (e) => e.kind));
|
||||
add(Uint16Array.from(this.edges, (e) => e.fwd));
|
||||
add(Uint16Array.from(this.edges, (e) => e.back));
|
||||
add(Uint8Array.from(this.edges, (e) => e.diff));
|
||||
add(Uint8Array.from(this.edges, (e) => e.groom));
|
||||
add(Uint8Array.from(this.edges, (e) => e.lift));
|
||||
add(Uint16Array.from(this.edges, (e) => e.liftSec));
|
||||
add(Uint16Array.from(this.edges, (e) => e.liftCap));
|
||||
add(Uint32Array.from(this.edges, (e) => e.flags));
|
||||
add(Uint32Array.from(this.edges, (e) => sid(e.name)));
|
||||
add(Uint32Array.from(this.edges, (e) => sid(e.ref)));
|
||||
add(Float64Array.from(this.edges, (e) => e.way));
|
||||
const offsetsOfGeometry = new Uint32Array(m + 1);
|
||||
for (let i = 0; i < m; i++) {
|
||||
offsetsOfGeometry[i + 1] = (offsetsOfGeometry[i] as number) + (geometry[i] as WorldPoint[]).length;
|
||||
}
|
||||
add(offsetsOfGeometry);
|
||||
const flat = geometry.flat();
|
||||
add(Int32Array.from(flat, (p) => Math.round(this.point(p.x, p.y)[0] * 1e7)));
|
||||
add(Int32Array.from(flat, (p) => Math.round(this.point(p.x, p.y)[1] * 1e7)));
|
||||
add(Int16Array.from(flat, (p) => ele(p.ele)));
|
||||
const strings = Buffer.concat(pool);
|
||||
sections.push(strings, Buffer.alloc((8 - (strings.length % 8)) % 8));
|
||||
const body = Buffer.concat(sections);
|
||||
|
||||
const lats = this.nodes.map((p) => this.point(p.x, p.y)[0]);
|
||||
const lngs = this.nodes.map((p) => this.point(p.x, p.y)[1]);
|
||||
const bbox = [Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)];
|
||||
const header = {
|
||||
format: 1,
|
||||
build: this.head.build,
|
||||
areaId: this.head.areaId,
|
||||
name: this.head.name,
|
||||
kind: 'resort',
|
||||
country: null,
|
||||
colourScheme: this.head.colourScheme ?? 'europe',
|
||||
bbox,
|
||||
counts: { runsKm: 0, nordicKm: 0, lifts: 0, skitourKm: 0, hikeKm: 0 },
|
||||
nodeCount: n,
|
||||
edgeCount: m,
|
||||
geomCount: g,
|
||||
stringBytes: strings.length,
|
||||
crc32: crc32(body) >>> 0,
|
||||
};
|
||||
const json = Buffer.from(JSON.stringify(header), 'utf8');
|
||||
const prefix = Buffer.concat([Buffer.from('SGR1', 'latin1'), Buffer.alloc(4), json]);
|
||||
prefix.writeUInt32LE(json.length, 4);
|
||||
return Buffer.concat([prefix, Buffer.alloc((8 - (prefix.length % 8)) % 8), body]);
|
||||
}
|
||||
}
|
||||
@@ -455,6 +455,15 @@ describe('AddonsService addon/feature flags', () => {
|
||||
expect((await svc.list()).toursHikingMap).toBe(true);
|
||||
});
|
||||
|
||||
it('the piste router under Tours is on until an admin switches it off', async () => {
|
||||
expect(await svc.getToursSkiRouting()).toEqual({ enabled: true }); // absent row → ON
|
||||
expect(await svc.updateToursSkiRouting(false)).toEqual({ enabled: false });
|
||||
expect(await storedAppSetting('ski_routing_enabled')).toEqual({ key: 'ski_routing_enabled', value: 'false' });
|
||||
expect(await svc.getToursSkiRouting()).toEqual({ enabled: false });
|
||||
expect(await svc.updateToursSkiRouting(true)).toEqual({ enabled: true });
|
||||
expect(await svc.getToursSkiRouting()).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('getCollabFeatures is opt-out: absent rows default ON, only false disables', async () => {
|
||||
await setAppSetting('collab_chat_enabled', 'false');
|
||||
await setAppSetting('collab_polls_enabled', 'true');
|
||||
|
||||
@@ -50,6 +50,8 @@ const addonsStub = () =>
|
||||
updateToursHikingMap: vi.fn((enabled: boolean) => ({ enabled })),
|
||||
getToursCycleMap: vi.fn(() => ({ enabled: true })),
|
||||
updateToursCycleMap: vi.fn((enabled: boolean) => ({ enabled })),
|
||||
getToursSkiRouting: vi.fn(() => ({ enabled: true })),
|
||||
updateToursSkiRouting: vi.fn((enabled: boolean) => ({ enabled })),
|
||||
getPlacesPhotos: vi.fn(() => ({ enabled: false })),
|
||||
updatePlacesPhotos: vi.fn((enabled: boolean) => ({ enabled })),
|
||||
getPlacesAutocomplete: vi.fn(() => ({ enabled: false })),
|
||||
@@ -498,6 +500,11 @@ describe('AdminController feature toggles', () => {
|
||||
[() => c.updateToursWanderer(user, { enabled: true }, req), 'admin.tours_wanderer', 'updateToursWanderer'],
|
||||
[() => c.updateToursHikingMap(user, { enabled: true }, req), 'admin.tours_hiking_map', 'updateToursHikingMap'],
|
||||
[() => c.updateToursCycleMap(user, { enabled: true }, req), 'admin.tours_cycle_map', 'updateToursCycleMap'],
|
||||
[
|
||||
() => c.updateToursSkiRouting(user, { enabled: true }, req),
|
||||
'admin.tours_ski_routing',
|
||||
'updateToursSkiRouting',
|
||||
],
|
||||
[() => c.updatePlacesPhotos(user, { enabled: true }, req), 'admin.places_photos', 'updatePlacesPhotos'],
|
||||
[
|
||||
() => c.updatePlacesAutocomplete(user, { enabled: true }, req),
|
||||
@@ -521,6 +528,7 @@ describe('AdminController feature toggles', () => {
|
||||
expect(await c.getToursWanderer()).toEqual({ enabled: false });
|
||||
expect(await c.getToursHikingMap()).toEqual({ enabled: true });
|
||||
expect(await c.getToursCycleMap()).toEqual({ enabled: true });
|
||||
expect(await c.getToursSkiRouting()).toEqual({ enabled: true });
|
||||
expect(await c.getPlacesPhotos()).toEqual({ enabled: false });
|
||||
expect(await c.getPlacesAutocomplete()).toEqual({ enabled: false });
|
||||
expect(await c.getPlacesDetails()).toEqual({ enabled: false });
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Why a leg is not planned (spec 7.6): each rule relaxed in turn names the
|
||||
* reason and the one change that would plan it; the way a relaxed search found
|
||||
* is never handed out as the leg. A point off every run, and two runs nothing
|
||||
* joins, are said as such.
|
||||
*/
|
||||
import { Kind, Lift, Use } from '../../../../src/nest/ski-routing/ski-graph.contract';
|
||||
import { failedLeg, planOn } from '../../../helpers/ski-plan';
|
||||
import { NORDIC_BOTH, SkiWorld } from '../../../helpers/ski-world';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/** A chair up and only a black run down. */
|
||||
function steepAlp() {
|
||||
const world = new SkiWorld();
|
||||
const valley = world.node(0, 0, 1200);
|
||||
const top = world.node(0, 2000, 2200);
|
||||
world.lift(valley, top, { name: 'Sonnenbahn', sec: 420 });
|
||||
world.run(top, valley, 3, { name: 'Schwarze Wand', via: [[-300, 1000]] });
|
||||
return { world, valley: world.at(0, 0), top: world.at(0, 2000) };
|
||||
}
|
||||
|
||||
describe('a leg one rule keeps from being planned', () => {
|
||||
it('SKIEXPLAIN-001: only a harder run joins the points: the level it needs and the run, never its line', () => {
|
||||
const { world, top, valley } = steepAlp();
|
||||
const leg = failedLeg(planOn(world, [[top, valley]])[0]);
|
||||
expect(leg).toEqual({
|
||||
status: 'failed',
|
||||
code: 'blocked_by_difficulty',
|
||||
at: null,
|
||||
hint: { raise_difficulty: 'advanced' },
|
||||
detail: { name: 'Schwarze Wand' },
|
||||
});
|
||||
expect(leg).not.toHaveProperty('geometry');
|
||||
});
|
||||
|
||||
it('SKIEXPLAIN-002: only an avoided lift joins them: the class to allow, and the lift', () => {
|
||||
const { world, top, valley } = steepAlp();
|
||||
const leg = failedLeg(planOn(world, [[valley, top]], { options: { avoid_lifts: ['open_chair'] } })[0]);
|
||||
expect(leg).toMatchObject({
|
||||
code: 'blocked_by_lift_avoid',
|
||||
hint: { allow_lift: 'open_chair' },
|
||||
detail: { name: 'Sonnenbahn' },
|
||||
});
|
||||
});
|
||||
|
||||
it('SKIEXPLAIN-003: only a trail of the other technique joins them: any technique will do', () => {
|
||||
const world = new SkiWorld();
|
||||
const a = world.node(0, 0, 1200);
|
||||
const b = world.node(2000, 0, 1210);
|
||||
world.edge(a, b, { kind: Kind.NORDIC, fwd: Use.NORDIC_SKATING, back: Use.NORDIC_SKATING, groom: 2 });
|
||||
const leg = failedLeg(
|
||||
planOn(world, [[world.at(0, 0), world.at(2000, 0)]], { discipline: 'cross_country', technique: 'classic' })[0],
|
||||
);
|
||||
expect(leg).toMatchObject({ code: 'blocked_by_technique', hint: { technique: 'unknown' } });
|
||||
// With either technique the same leg is planned.
|
||||
const either = planOn(world, [[world.at(0, 0), world.at(2000, 0)]], { discipline: 'cross_country' });
|
||||
expect(either[0]?.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('SKIEXPLAIN-004: only riding the gondola down joins them: allow it', () => {
|
||||
const world = new SkiWorld();
|
||||
const foot = world.node(0, 0, 1600);
|
||||
const summit = world.node(0, 1500, 2500);
|
||||
world.lift(foot, summit, { type: Lift.GONDOLA, name: 'Gondel', down: true });
|
||||
const leg = failedLeg(
|
||||
planOn(world, [[world.at(0, 1500), world.at(0, 0)]], { options: { allow_lift_down: false } })[0],
|
||||
);
|
||||
expect(leg).toMatchObject({ code: 'needs_ride_down', hint: { allow_lift_down: true }, detail: { name: 'Gondel' } });
|
||||
});
|
||||
|
||||
it('SKIEXPLAIN-005: no way up without a lift: ski touring would go', () => {
|
||||
const world = new SkiWorld();
|
||||
const top = world.node(0, 2000, 2200);
|
||||
const valley = world.node(0, 0, 1200);
|
||||
world.run(top, valley, 1, { name: 'Talabfahrt' });
|
||||
const leg = failedLeg(planOn(world, [[world.at(0, 0), world.at(0, 2000)]])[0]);
|
||||
expect(leg).toMatchObject({ code: 'uphill_only', hint: { discipline: 'touring' } });
|
||||
// A ski tourer may climb a run only where asked to.
|
||||
const tour = planOn(world, [[world.at(0, 0), world.at(0, 2000)]], {
|
||||
discipline: 'touring',
|
||||
options: { touring_uphill_on_pistes: true },
|
||||
});
|
||||
expect(tour[0]).toMatchObject({ status: 'ok' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a leg nothing joins', () => {
|
||||
it('SKIEXPLAIN-010: two runs that never meet: where the search stopped nearest the target', () => {
|
||||
const world = new SkiWorld();
|
||||
world.run(world.node(0, 2000, 2000), world.node(0, 1000, 1600), 1, { name: 'Links' });
|
||||
world.run(world.node(2000, 1000, 1600), world.node(2000, 0, 1200), 1, { name: 'Rechts' });
|
||||
const leg = failedLeg(planOn(world, [[world.at(0, 2000), world.at(2000, 0)]])[0]);
|
||||
expect(leg.code).toBe('disconnected');
|
||||
expect(leg.hint).toBeNull();
|
||||
expect(leg.at?.lat).toBeCloseTo(world.at(0, 1000).lat, 6);
|
||||
});
|
||||
|
||||
it('SKIEXPLAIN-011: a point off every run says how far the nearest one is, and which', () => {
|
||||
const world = new SkiWorld();
|
||||
world.run(world.node(0, 2000, 2000), world.node(0, 1000, 1600), 1, { name: 'Gipfelhang' });
|
||||
const far = world.at(0, 3000);
|
||||
const leg = failedLeg(planOn(world, [[far, world.at(0, 1000)]])[0]);
|
||||
expect(leg).toEqual({
|
||||
status: 'failed',
|
||||
code: 'not_on_piste',
|
||||
at: far,
|
||||
hint: null,
|
||||
detail: { name: 'Gipfelhang', distance_m: 1000 },
|
||||
});
|
||||
const nowhere = world.at(0, 9000);
|
||||
expect(failedLeg(planOn(world, [[world.at(0, 2000), nowhere]])[0])).toMatchObject({
|
||||
code: 'not_on_piste',
|
||||
at: nowhere,
|
||||
detail: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('SKIEXPLAIN-012: a cross-country trail both ways still plans either way', () => {
|
||||
const world = new SkiWorld();
|
||||
world.nordic(world.node(0, 0, 1200), world.node(1000, 0, 1205), { fwd: NORDIC_BOTH, back: NORDIC_BOTH });
|
||||
const there = planOn(world, [[world.at(0, 0), world.at(1000, 0)]], { discipline: 'cross_country' });
|
||||
const back = planOn(world, [[world.at(1000, 0), world.at(0, 0)]], { discipline: 'cross_country' });
|
||||
expect([there[0]?.status, back[0]?.status]).toEqual(['ok', 'ok']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* The invariant sweep (spec 12.3): random point pairs on every area at hand,
|
||||
* for every discipline. Whatever the points, a leg is planned or failed, never
|
||||
* thrown; and a planned leg only uses an edge in a direction its uses allow,
|
||||
* enters a lift by its boarding and leaves it by its exit, keeps to the
|
||||
* difficulty limit, never climbs a run, takes no link longer than 120 m, runs
|
||||
* as one line from stretch to stretch, and its totals are its stretches added
|
||||
* up.
|
||||
*/
|
||||
import { SkiRouter } from '../../../../src/nest/ski-routing/engine/plan';
|
||||
import { compileProfile, Mode } from '../../../../src/nest/ski-routing/engine/profile';
|
||||
import { LEG_TIME_MS, searchPath, type Step } from '../../../../src/nest/ski-routing/engine/router';
|
||||
import { SNAP_RADIUS_M, snapPoint } from '../../../../src/nest/ski-routing/engine/snap';
|
||||
import { stitchGraphs, type SkiNet } from '../../../../src/nest/ski-routing/engine/stitch';
|
||||
import { decodeSkiGraph } from '../../../../src/nest/ski-routing/ski-graph';
|
||||
import { Kind, Lift } from '../../../../src/nest/ski-routing/ski-graph.contract';
|
||||
import { profileOf } from '../../../../src/nest/ski-routing/ski-routing.service';
|
||||
import { SkiWorld } from '../../../helpers/ski-world';
|
||||
import { SKI_DISCIPLINES, skiRouteRequestSchema, type SkiRouteLeg } from '@trek/shared';
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const PAIRS = 500;
|
||||
|
||||
/** A small, fixed random sequence, so a failing pair can be found again. */
|
||||
function random(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** A resort of everything: lifts of three kinds, runs of every grade, a link, a nordic loop, a winter path. */
|
||||
function resort(): SkiNet {
|
||||
const world = new SkiWorld();
|
||||
const valley = world.node(0, 0, 1200);
|
||||
const mid = world.node(400, 1200, 1700);
|
||||
const top = world.node(0, 2400, 2300);
|
||||
const shoulder = world.node(-600, 1600, 1900);
|
||||
const lake = world.node(1500, 100, 1210);
|
||||
const village = world.node(2600, 0, 1215);
|
||||
world.lift(valley, mid, { name: 'Talbahn', type: Lift.GONDOLA, down: true, cap: 2400 });
|
||||
world.lift(mid, top, { name: 'Gipfelsessel', type: Lift.CHAIR_DETACHABLE });
|
||||
world.lift(shoulder, top, { name: 'Schlepper', type: Lift.TBAR });
|
||||
world.run(top, mid, 2, { name: 'Rote' });
|
||||
world.run(top, shoulder, 4, { name: 'Expert' });
|
||||
world.run(shoulder, valley, 1, { name: 'Blaue', via: [[-400, 800]] });
|
||||
world.run(mid, valley, 0, { name: 'Kinderland' });
|
||||
world.run(top, valley, 5, { name: 'Freeride', via: [[-900, 1200]] });
|
||||
const gap = world.node(30, 0, 1201);
|
||||
world.link(valley, gap);
|
||||
world.nordic(gap, lake, { name: 'Seeloipe' });
|
||||
world.nordic(lake, village, { name: 'Dorfloipe' });
|
||||
world.edge(lake, mid, { kind: Kind.HIKE, fwd: 0x380, back: 0x380, name: 'Winterweg' });
|
||||
return stitchGraphs([world.graph()]);
|
||||
}
|
||||
|
||||
function golden(): SkiNet {
|
||||
return stitchGraphs([decodeSkiGraph(readFileSync(join(__dirname, '../../../fixtures/ski/golden-v1.sgr')))]);
|
||||
}
|
||||
|
||||
function box(net: SkiNet) {
|
||||
let minLat = 90;
|
||||
let minLng = 180;
|
||||
let maxLat = -90;
|
||||
let maxLng = -180;
|
||||
for (let i = 0; i < net.nodeCount; i++) {
|
||||
minLat = Math.min(minLat, net.nodeLat[i] as number);
|
||||
maxLat = Math.max(maxLat, net.nodeLat[i] as number);
|
||||
minLng = Math.min(minLng, net.nodeLng[i] as number);
|
||||
maxLng = Math.max(maxLng, net.nodeLng[i] as number);
|
||||
}
|
||||
const pad = 0.001;
|
||||
return { minLat: minLat - pad, minLng: minLng - pad, maxLat: maxLat + pad, maxLng: maxLng + pad };
|
||||
}
|
||||
|
||||
const RIDE: ReadonlySet<number> = new Set([Kind.LIFT, Kind.RAIL]);
|
||||
const INTO_RIDE: ReadonlySet<number> = new Set([Kind.BOARD, Kind.LIFT, Kind.RAIL]);
|
||||
const OUT_OF_RIDE: ReadonlySet<number> = new Set([Kind.ALIGHT, Kind.LIFT, Kind.RAIL]);
|
||||
const HARD: ReadonlySet<string | null> = new Set(['expert', 'freeride', 'extreme']);
|
||||
|
||||
/** The step-level rules of a found path, as the search left it. */
|
||||
function checkSteps(net: SkiNet, steps: readonly Step[], modeOf: (step: Step) => number, limit: number): void {
|
||||
const moving = steps.filter((step) => step.from !== step.to);
|
||||
moving.forEach((step, index) => {
|
||||
const mode = modeOf(step);
|
||||
const uses = step.dir === 0 ? net.edgeFwd[step.edge] : net.edgeBack[step.edge];
|
||||
expect(mode).not.toBe(Mode.NONE);
|
||||
const kind = net.edgeKind[step.edge] as number;
|
||||
// A walker crosses a short run whichever way it is drawn; every other way needs its uses.
|
||||
if (mode !== Mode.WALK || (kind !== Kind.RUN && kind !== Kind.AREA)) expect(uses).toBeGreaterThan(0);
|
||||
if (RIDE.has(kind)) {
|
||||
const before = moving[index - 1];
|
||||
const after = moving[index + 1];
|
||||
expect(before !== undefined && INTO_RIDE.has(net.edgeKind[before.edge] as number)).toBe(true);
|
||||
expect(after !== undefined && OUT_OF_RIDE.has(net.edgeKind[after.edge] as number)).toBe(true);
|
||||
}
|
||||
if (mode === Mode.DOWNHILL) {
|
||||
const climb = step.dir === 0 ? net.edgeUp[step.edge] : net.edgeDown[step.edge];
|
||||
expect(climb).toBeLessThanOrEqual(15);
|
||||
const diff = net.edgeDiff[step.edge] as number;
|
||||
if (kind === Kind.RUN || kind === Kind.AREA) expect(diff === 255 || diff <= limit).toBe(true);
|
||||
}
|
||||
if (kind === Kind.LINK) expect(net.edgeLen[step.edge]).toBeLessThanOrEqual(120);
|
||||
});
|
||||
}
|
||||
|
||||
/** The contract-level rules of an answered leg. */
|
||||
function checkLeg(leg: SkiRouteLeg | undefined): void {
|
||||
expect(['ok', 'failed']).toContain(leg?.status);
|
||||
if (leg?.status !== 'ok') return;
|
||||
const total = leg.seconds.ski + leg.seconds.lift + leg.seconds.wait + leg.seconds.walk;
|
||||
const summed = leg.segments.reduce((sum, segment) => sum + segment.seconds + segment.wait_seconds, 0);
|
||||
expect(Math.abs(total - summed)).toBeLessThan(1);
|
||||
for (let i = 1; i < leg.segments.length; i++) {
|
||||
expect(leg.segments[i]?.from).toBe(leg.segments[i - 1]?.to);
|
||||
}
|
||||
for (const segment of leg.segments) {
|
||||
expect(segment.from).toBeLessThanOrEqual(segment.to);
|
||||
expect(segment.to).toBeLessThan(leg.geometry.length);
|
||||
}
|
||||
expect(leg.segments.some((s) => s.kind === 'run' && HARD.has(s.difficulty))).toBe(false);
|
||||
}
|
||||
|
||||
describe.each([
|
||||
['the golden area', golden],
|
||||
['a resort of everything', resort],
|
||||
])('the invariant sweep on %s', (_name, build) => {
|
||||
it.each(SKI_DISCIPLINES.map((discipline) => [discipline]))(
|
||||
'SKIINV-001: %s, random pairs: planned or failed, and every planned leg keeps the rules',
|
||||
(discipline) => {
|
||||
const net = build();
|
||||
const router = new SkiRouter(net);
|
||||
const area = box(net);
|
||||
const next = random(discipline.length * 7919 + net.edgeCount);
|
||||
const point = () => ({
|
||||
lat: area.minLat + (area.maxLat - area.minLat) * next(),
|
||||
lng: area.minLng + (area.maxLng - area.minLng) * next(),
|
||||
});
|
||||
const request = skiRouteRequestSchema.parse({ legs: [{ from: point(), to: point() }], discipline });
|
||||
const profile = profileOf(request);
|
||||
const costs = compileProfile(profile, net);
|
||||
const modeOf = (step: Step) =>
|
||||
(step.dir === 0 ? costs.modeFwd[step.edge] : costs.modeBack[step.edge]) as number;
|
||||
let planned = 0;
|
||||
for (let pair = 0; pair < PAIRS; pair++) {
|
||||
const from = point();
|
||||
const to = point();
|
||||
const [leg] = router.plan({ legs: [{ from, to, continues: false }], profile, alternatives: 0 }, ['w:1']);
|
||||
checkLeg(leg);
|
||||
if (leg?.status !== 'ok') continue;
|
||||
planned++;
|
||||
const radius = SNAP_RADIUS_M[discipline];
|
||||
const starts = snapPoint(net, costs, from.lat, from.lng, radius, 'from');
|
||||
const ends = snapPoint(net, costs, to.lat, to.lng, radius, 'to');
|
||||
const budget = { maxSettled: 400_000, deadline: Date.now() + LEG_TIME_MS };
|
||||
const found = searchPath(net, costs, starts, ends, to, budget);
|
||||
expect(found.status).toBe('ok');
|
||||
if (found.status === 'ok') checkSteps(net, found.path.steps, modeOf, 2);
|
||||
}
|
||||
// The sweep would prove nothing if no pair were ever planned.
|
||||
if (discipline === 'alpine') expect(planned).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* The cost model of the piste router (spec 8): which discipline may travel an
|
||||
* edge in which direction, how long it takes, and what the search weighs it
|
||||
* at. Every case is one edge of a world drawn for it, read through the
|
||||
* network the router searches.
|
||||
*/
|
||||
import {
|
||||
boardWaitSeconds,
|
||||
compileProfile,
|
||||
difficultyAllowed,
|
||||
dismountSeconds,
|
||||
edgeCost,
|
||||
edgeSeconds,
|
||||
liftAllowed,
|
||||
liftClassOf,
|
||||
liftDownAllowed,
|
||||
Mode,
|
||||
travelMode,
|
||||
type SkiProfile,
|
||||
} from '../../../../src/nest/ski-routing/engine/profile';
|
||||
import { stitchGraphs, type SkiNet } from '../../../../src/nest/ski-routing/engine/stitch';
|
||||
import { EdgeFlag, Groom, Kind, Lift, NodeFlag, Use } from '../../../../src/nest/ski-routing/ski-graph.contract';
|
||||
import { profileOf } from '../../../../src/nest/ski-routing/ski-routing.service';
|
||||
import { NORDIC_BOTH, RUN_DOWN, SkiWorld, type EdgeOptions } from '../../../helpers/ski-world';
|
||||
import { skiRouteRequestSchema, type SkiDiscipline, type SkiRouteOptionsInput, type SkiTechnique } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const P = { lat: 46, lng: 7 };
|
||||
|
||||
function profile(discipline: SkiDiscipline, options: SkiRouteOptionsInput = {}, technique: SkiTechnique | null = null) {
|
||||
const request = skiRouteRequestSchema.parse({ legs: [{ from: P, to: P }], discipline, technique, options });
|
||||
return profileOf(request);
|
||||
}
|
||||
|
||||
/** One edge from a node `ele0` high to one 1000 m north and `ele1` high, with the options given. */
|
||||
function oneEdge(options: EdgeOptions, ele0 = 1500, ele1 = 1400, nodeFlags = 0): SkiNet {
|
||||
const world = new SkiWorld();
|
||||
const a = world.node(0, 0, ele0);
|
||||
const b = world.node(0, 1000, ele1, { flags: nodeFlags });
|
||||
world.edge(a, b, options);
|
||||
return stitchGraphs([world.graph()]);
|
||||
}
|
||||
|
||||
const run = (diff: number, extra: EdgeOptions = {}) =>
|
||||
oneEdge({ kind: Kind.RUN, fwd: RUN_DOWN, back: 0, diff, ...extra });
|
||||
|
||||
describe('who may travel an edge (spec 8.3)', () => {
|
||||
it('SKIPROF-001: a downhill skier skis a run down within the limit, never up, and walks a link either way', () => {
|
||||
const alpine = profile('alpine');
|
||||
expect(travelMode(alpine, run(2), 0, 0)).toBe(Mode.DOWNHILL);
|
||||
expect(travelMode(alpine, run(2), 0, 1)).toBe(Mode.NONE);
|
||||
expect(travelMode(alpine, run(3), 0, 0)).toBe(Mode.NONE);
|
||||
expect(travelMode(profile('alpine', { max_difficulty: 'advanced' }), run(3), 0, 0)).toBe(Mode.DOWNHILL);
|
||||
const link = oneEdge({ kind: Kind.LINK, fwd: Use.WALK, back: Use.WALK });
|
||||
expect(travelMode(alpine, link, 0, 1)).toBe(Mode.WALK);
|
||||
const track = oneEdge({ kind: Kind.HIKE, fwd: Use.WALK | Use.WINTER_HIKE, back: Use.WALK | Use.WINTER_HIKE });
|
||||
expect(travelMode(alpine, track, 0, 0)).toBe(Mode.NONE);
|
||||
// Only while explaining: walking up a run tells "no way up" from "no way".
|
||||
expect(travelMode({ ...alpine, walkUphill: true }, run(2), 0, 1)).toBe(Mode.WALK);
|
||||
});
|
||||
|
||||
it('SKIPROF-002: a run of unknown difficulty is allowed, freeride and extreme only when switched on', () => {
|
||||
const base = profile('alpine').options;
|
||||
expect(difficultyAllowed(base, 255)).toBe(true);
|
||||
expect(difficultyAllowed(base, 5)).toBe(false);
|
||||
expect(difficultyAllowed({ ...base, allow_freeride: true }, 6)).toBe(true);
|
||||
expect(difficultyAllowed({ ...base, max_difficulty: 'novice' }, 1)).toBe(false);
|
||||
expect(difficultyAllowed({ ...base, max_difficulty: 'expert' }, 4)).toBe(true);
|
||||
});
|
||||
|
||||
it('SKIPROF-003: lifts by class, the rider and the discipline', () => {
|
||||
const types = [Lift.NONE, Lift.CABLE_CAR, Lift.GONDOLA, Lift.MIXED, Lift.CHAIR_FIXED, Lift.TBAR, Lift.MAGIC_CARPET];
|
||||
expect([...types, Lift.RACK].map(liftClassOf)).toEqual([
|
||||
null,
|
||||
'cable_car',
|
||||
'gondola',
|
||||
'gondola',
|
||||
'open_chair',
|
||||
'drag',
|
||||
'drag',
|
||||
'rail',
|
||||
]);
|
||||
const alpine = profile('alpine');
|
||||
expect(liftAllowed(alpine, 'drag')).toBe(true);
|
||||
expect(liftAllowed(profile('alpine', { avoid_lifts: ['drag'] }), 'drag')).toBe(false);
|
||||
expect(liftAllowed(profile('alpine', { use_lifts: false }), 'gondola')).toBe(false);
|
||||
expect(liftAllowed(alpine, null)).toBe(false);
|
||||
const nordic = profile('cross_country', { use_lifts: true });
|
||||
expect(liftAllowed(nordic, 'gondola')).toBe(true);
|
||||
expect(liftAllowed(nordic, 'open_chair')).toBe(false);
|
||||
expect(liftAllowed(profile('cross_country'), 'gondola')).toBe(false);
|
||||
const walker = profile('winter_hike', { use_lifts: true });
|
||||
expect(liftAllowed(walker, 'open_chair')).toBe(true);
|
||||
expect(liftAllowed(walker, 'drag')).toBe(false);
|
||||
expect(liftDownAllowed(alpine, 'gondola')).toBe(true);
|
||||
expect(liftDownAllowed(alpine, 'drag')).toBe(false);
|
||||
expect(liftDownAllowed(profile('alpine', { allow_lift_down: false }), 'gondola')).toBe(false);
|
||||
});
|
||||
|
||||
it('SKIPROF-004: a lift, its boarding and its exit are ridden up, and down only where allowed', () => {
|
||||
const world = new SkiWorld();
|
||||
const valley = world.node(0, 0, 1200);
|
||||
const top = world.node(0, 1500, 2000);
|
||||
const { ride, board, alight } = world.lift(valley, top, { type: Lift.GONDOLA, down: true });
|
||||
const net = stitchGraphs([world.graph()]);
|
||||
const alpine = profile('alpine');
|
||||
expect(travelMode(alpine, net, ride, 0)).toBe(Mode.LIFT);
|
||||
expect(travelMode(alpine, net, ride, 1)).toBe(Mode.LIFT_DOWN);
|
||||
expect(travelMode(alpine, net, board, 0)).toBe(Mode.BOARD);
|
||||
expect(travelMode(alpine, net, alight, 0)).toBe(Mode.ALIGHT);
|
||||
expect(travelMode(alpine, net, board, 1)).toBe(Mode.NONE);
|
||||
// The boarding at the top and the exit at the bottom, of the ride down.
|
||||
expect(travelMode(alpine, net, 3, 0)).toBe(Mode.BOARD_DOWN);
|
||||
expect(travelMode(alpine, net, 4, 0)).toBe(Mode.ALIGHT_DOWN);
|
||||
expect(travelMode(profile('alpine', { allow_lift_down: false }), net, ride, 1)).toBe(Mode.NONE);
|
||||
expect(travelMode(profile('touring'), net, ride, 0)).toBe(Mode.NONE);
|
||||
});
|
||||
|
||||
it('SKIPROF-005: a cross-country skier keeps to the technique, the grooming and the lights asked for', () => {
|
||||
const skatingOnly = oneEdge({ kind: Kind.NORDIC, fwd: Use.NORDIC_SKATING, back: Use.NORDIC_SKATING, groom: 2 });
|
||||
expect(travelMode(profile('cross_country', {}, 'classic'), skatingOnly, 0, 0)).toBe(Mode.NONE);
|
||||
expect(travelMode(profile('cross_country', {}, 'skating'), skatingOnly, 0, 0)).toBe(Mode.NORDIC);
|
||||
expect(travelMode(profile('cross_country', {}, 'unknown'), skatingOnly, 0, 1)).toBe(Mode.NORDIC);
|
||||
const wild = oneEdge({ kind: Kind.NORDIC, fwd: NORDIC_BOTH, back: NORDIC_BOTH, groom: Groom.BACKCOUNTRY });
|
||||
expect(travelMode(profile('cross_country', { groomed_only: true }), wild, 0, 0)).toBe(Mode.NONE);
|
||||
expect(travelMode(profile('cross_country'), wild, 0, 0)).toBe(Mode.NORDIC);
|
||||
const dark = oneEdge({ kind: Kind.NORDIC, fwd: NORDIC_BOTH, back: NORDIC_BOTH, groom: 3 });
|
||||
expect(travelMode(profile('cross_country', { lit_only: true }), dark, 0, 0)).toBe(Mode.NONE);
|
||||
const lit = oneEdge({ kind: Kind.NORDIC, fwd: NORDIC_BOTH, back: NORDIC_BOTH, groom: 3, flags: EdgeFlag.LIT });
|
||||
expect(travelMode(profile('cross_country', { lit_only: true }), lit, 0, 0)).toBe(Mode.NORDIC);
|
||||
const link = oneEdge({ kind: Kind.LINK, fwd: Use.WALK, back: Use.WALK });
|
||||
expect(travelMode(profile('cross_country'), link, 0, 0)).toBe(Mode.WALK);
|
||||
expect(travelMode(profile('cross_country'), run(1), 0, 0)).toBe(Mode.NONE);
|
||||
});
|
||||
|
||||
it('SKIPROF-006: a ski tourer climbs skitour ways, skis runs down and climbs a piste only when asked to', () => {
|
||||
const touring = profile('touring');
|
||||
const both = Use.SKITOUR_UP | Use.SKITOUR_DOWN;
|
||||
const climb = oneEdge({ kind: Kind.SKITOUR, fwd: both, back: Use.SKITOUR_UP }, 1500, 1900);
|
||||
expect(travelMode(touring, climb, 0, 0)).toBe(Mode.SKITOUR_UP);
|
||||
expect(travelMode(touring, climb, 0, 1)).toBe(Mode.SKITOUR_UP);
|
||||
const descent = oneEdge({ kind: Kind.SKITOUR, fwd: both, back: 0 }, 1900, 1500);
|
||||
expect(travelMode(touring, descent, 0, 0)).toBe(Mode.SKITOUR_DOWN);
|
||||
expect(travelMode(touring, run(2), 0, 0)).toBe(Mode.SKITOUR_DOWN);
|
||||
expect(travelMode(touring, run(2), 0, 1)).toBe(Mode.NONE);
|
||||
expect(travelMode(profile('touring', { touring_uphill_on_pistes: true }), run(2), 0, 1)).toBe(Mode.UPHILL_PISTE);
|
||||
expect(travelMode(touring, run(4), 0, 0)).toBe(Mode.NONE);
|
||||
const snowshoeTrail = oneEdge({ kind: Kind.HIKE, fwd: Use.SNOWSHOE, back: Use.SNOWSHOE });
|
||||
expect(travelMode(touring, snowshoeTrail, 0, 1)).toBe(Mode.SKITOUR_UP);
|
||||
});
|
||||
|
||||
it('SKIPROF-007: snowshoes and boots keep to their trails and cross a run only where it is short', () => {
|
||||
const onFoot = Use.SNOWSHOE | Use.WINTER_HIKE;
|
||||
const trail = oneEdge({ kind: Kind.HIKE, fwd: onFoot, back: onFoot });
|
||||
expect(travelMode(profile('snowshoe'), trail, 0, 1)).toBe(Mode.SNOWSHOE);
|
||||
expect(travelMode(profile('winter_hike'), trail, 0, 1)).toBe(Mode.HIKE);
|
||||
const shoeOnly = oneEdge({ kind: Kind.HIKE, fwd: Use.SNOWSHOE, back: Use.SNOWSHOE });
|
||||
expect(travelMode(profile('winter_hike'), shoeOnly, 0, 0)).toBe(Mode.NONE);
|
||||
// The world's edges are 1000 m long: too long to cross on foot.
|
||||
expect(travelMode(profile('snowshoe'), run(1), 0, 0)).toBe(Mode.NONE);
|
||||
const world = new SkiWorld();
|
||||
world.run(world.node(0, 0, 1500), world.node(0, 60, 1495), 1);
|
||||
const short = stitchGraphs([world.graph()]);
|
||||
expect(travelMode(profile('snowshoe'), short, 0, 1)).toBe(Mode.WALK);
|
||||
expect(travelMode(profile('winter_hike'), short, 0, 0)).toBe(Mode.WALK);
|
||||
const link = oneEdge({ kind: Kind.LINK, fwd: Use.WALK, back: Use.WALK });
|
||||
expect(travelMode(profile('winter_hike'), link, 0, 0)).toBe(Mode.WALK);
|
||||
expect(travelMode(profile('snowshoe'), link, 0, 0)).toBe(Mode.WALK);
|
||||
});
|
||||
});
|
||||
|
||||
describe('how long it takes (spec 8.1)', () => {
|
||||
it('SKIPROF-010: a run by its difficulty, moguls, open slopes, the pace and the board', () => {
|
||||
const alpine = profile('alpine');
|
||||
expect(edgeSeconds(alpine, run(2), 0, 0, Mode.DOWNHILL)).toBeCloseTo(1000 / 8.5, 3);
|
||||
expect(edgeSeconds(alpine, run(0), 0, 0, Mode.DOWNHILL)).toBeCloseTo(250, 3);
|
||||
expect(edgeSeconds(alpine, run(255), 0, 0, Mode.DOWNHILL)).toBeCloseTo(1000 / 7, 3);
|
||||
expect(edgeSeconds(alpine, run(2, { groom: Groom.MOGUL }), 0, 0, Mode.DOWNHILL)).toBeCloseTo(1000 / 6.8, 3);
|
||||
const area = oneEdge({ kind: Kind.AREA, fwd: RUN_DOWN, back: 0, diff: 1 });
|
||||
expect(edgeSeconds(alpine, area, 0, 0, Mode.DOWNHILL)).toBeCloseTo(1000 / 5.6, 3);
|
||||
expect(edgeSeconds(profile('alpine', { pace: 'sporty' }), run(1), 0, 0, Mode.DOWNHILL)).toBeCloseTo(1000 / 9.1, 3);
|
||||
expect(edgeSeconds(profile('snowboard'), run(1), 0, 0, Mode.DOWNHILL)).toBeCloseTo(1000 / 6.65, 3);
|
||||
const flat = run(1, { flags: EdgeFlag.FLAT_FOR_BOARD });
|
||||
expect(edgeSeconds(profile('snowboard'), flat, 0, 0, Mode.DOWNHILL)).toBeCloseTo(1000 / 1.5, 3);
|
||||
});
|
||||
|
||||
it('SKIPROF-011: a lift by its ride time, else by its speed; the wait by type and capacity; 15 s to get off', () => {
|
||||
const alpine = profile('alpine');
|
||||
const timed = oneEdge({ kind: Kind.LIFT, fwd: Use.LIFT_UP, lift: Lift.GONDOLA, liftSec: 300 });
|
||||
expect(edgeSeconds(alpine, timed, 0, 0, Mode.LIFT)).toBe(300);
|
||||
const fixed = oneEdge({ kind: Kind.LIFT, fwd: Use.LIFT_UP, lift: Lift.CHAIR_FIXED });
|
||||
expect(edgeSeconds(alpine, fixed, 0, 0, Mode.LIFT)).toBeCloseTo(1000 / 2.3, 3);
|
||||
expect(boardWaitSeconds(Lift.CHAIR_DETACHABLE, 0)).toBe(120);
|
||||
expect(boardWaitSeconds(Lift.GONDOLA, 2400)).toBe(90);
|
||||
expect(boardWaitSeconds(Lift.CABLE_CAR, 3000)).toBe(240);
|
||||
expect(boardWaitSeconds(Lift.FUNICULAR, 0)).toBe(240);
|
||||
expect(boardWaitSeconds(Lift.TBAR, 0)).toBe(60);
|
||||
expect(boardWaitSeconds(Lift.MAGIC_CARPET, 0)).toBe(30);
|
||||
expect(edgeSeconds(alpine, fixed, 0, 0, Mode.ALIGHT)).toBe(15);
|
||||
});
|
||||
|
||||
it('SKIPROF-012: cross-country by technique and gradient, walking with its climb, touring and hiking', () => {
|
||||
const flatTrail = oneEdge({ kind: Kind.NORDIC, fwd: NORDIC_BOTH, back: NORDIC_BOTH, groom: 3 }, 1500, 1500);
|
||||
// On the flat Tobler's factor is 1.
|
||||
expect(edgeSeconds(profile('cross_country', {}, 'classic'), flatTrail, 0, 0, Mode.NORDIC)).toBeCloseTo(400, 3);
|
||||
expect(edgeSeconds(profile('cross_country', {}, 'skating'), flatTrail, 0, 0, Mode.NORDIC)).toBeCloseTo(
|
||||
1000 / 3.3,
|
||||
3,
|
||||
);
|
||||
const steep = oneEdge({ kind: Kind.NORDIC, fwd: NORDIC_BOTH, back: NORDIC_BOTH, groom: 3 }, 1500, 1600);
|
||||
const up = edgeSeconds(profile('cross_country', {}, 'classic'), steep, 0, 0, Mode.NORDIC);
|
||||
const down = edgeSeconds(profile('cross_country', {}, 'classic'), steep, 0, 1, Mode.NORDIC);
|
||||
expect(up).toBeGreaterThan(400);
|
||||
expect(down).toBeLessThan(up);
|
||||
const walk = oneEdge({ kind: Kind.LINK, fwd: Use.WALK, back: Use.WALK }, 1500, 1530);
|
||||
expect(edgeSeconds(profile('alpine'), walk, 0, 0, Mode.WALK)).toBeCloseTo(1000 / 0.7 + 360, 3);
|
||||
const climb = oneEdge({ kind: Kind.SKITOUR, fwd: Use.SKITOUR_UP, back: Use.SKITOUR_UP }, 1500, 1850);
|
||||
// DIN 33466: 900 s for the kilometre, 3600 s for the 350 m: the longer plus half the shorter.
|
||||
expect(edgeSeconds(profile('touring'), climb, 0, 0, Mode.SKITOUR_UP)).toBeCloseTo(3600 + 450, 3);
|
||||
expect(edgeSeconds(profile('touring'), climb, 0, 1, Mode.SKITOUR_DOWN)).toBeCloseTo(1000 / 3, 3);
|
||||
expect(edgeSeconds(profile('touring'), run(1), 0, 0, Mode.SKITOUR_DOWN)).toBeCloseTo(1000 / 7, 3);
|
||||
const shoe = oneEdge({ kind: Kind.HIKE, fwd: Use.SNOWSHOE, back: Use.SNOWSHOE }, 1500, 1750);
|
||||
expect(edgeSeconds(profile('snowshoe'), shoe, 0, 0, Mode.SNOWSHOE)).toBeCloseTo(3600 + 600, 3);
|
||||
const path = oneEdge({ kind: Kind.HIKE, fwd: Use.WINTER_HIKE, back: Use.WINTER_HIKE }, 1500, 1500);
|
||||
expect(edgeSeconds(profile('winter_hike'), path, 0, 0, Mode.HIKE)).toBeCloseTo(900 / 0.85, 3);
|
||||
expect(edgeSeconds(profile('alpine'), path, 0, 0, Mode.NONE)).toBe(Infinity);
|
||||
});
|
||||
|
||||
it('SKIPROF-013: getting out of the bindings where the map says so adds to the edge arriving there', () => {
|
||||
const always = oneEdge({ kind: Kind.LINK, fwd: Use.WALK, back: Use.WALK }, 1500, 1500, NodeFlag.DISMOUNT_YES);
|
||||
expect(dismountSeconds(always, 1)).toBe(30);
|
||||
expect(dismountSeconds(always, 0)).toBe(0);
|
||||
const sometimes = oneEdge({ kind: Kind.LINK }, 1500, 1500, NodeFlag.DISMOUNT_SOMETIMES);
|
||||
expect(dismountSeconds(sometimes, 1)).toBe(10);
|
||||
const costs = compileProfile(profile('alpine'), always);
|
||||
expect(costs.timeFwd[0]).toBeCloseTo(1000 / 0.7 + 30, 3);
|
||||
expect(costs.timeBack[0]).toBeCloseTo(1000 / 0.7, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what the search weighs (spec 8.2)', () => {
|
||||
const cost = (p: SkiProfile, net: SkiNet, dir: 0 | 1 = 0) => {
|
||||
const mode = travelMode(p, net, 0, dir);
|
||||
return edgeCost(p, net, 0, dir, mode, 100);
|
||||
};
|
||||
|
||||
it('SKIPROF-020: penalties: a guessed link, riding down, the wrong way, a slope with a run, an untyped way', () => {
|
||||
const alpine = profile('alpine');
|
||||
const shortLink = oneEdge({ kind: Kind.LINK, fwd: Use.WALK, back: Use.WALK, flags: EdgeFlag.INFERRED_LINK });
|
||||
expect(cost(alpine, shortLink)).toBe(260);
|
||||
expect(cost(profile('alpine', { avoid_inferred_links: true }), shortLink)).toBe(1300);
|
||||
const world = new SkiWorld();
|
||||
world.link(world.node(0, 0, 1500), world.node(0, 30, 1500), { flags: EdgeFlag.INFERRED_LINK });
|
||||
expect(cost(alpine, stitchGraphs([world.graph()]))).toBe(130);
|
||||
const gondola = oneEdge({ kind: Kind.LIFT, fwd: Use.LIFT_UP, back: Use.LIFT_DOWN, lift: Lift.GONDOLA });
|
||||
expect(cost(alpine, gondola, 1)).toBe(300);
|
||||
const conflict = oneEdge({ kind: Kind.RUN, fwd: RUN_DOWN, back: RUN_DOWN, diff: 1, flags: EdgeFlag.DIR_CONFLICT });
|
||||
expect(cost(alpine, conflict, 0)).toBe(100);
|
||||
expect(cost(alpine, conflict, 1)).toBe(150);
|
||||
const area = oneEdge({ kind: Kind.AREA, fwd: RUN_DOWN, back: 0, diff: 1, flags: EdgeFlag.AREA_HAS_RUN });
|
||||
expect(cost(alpine, area)).toBeCloseTo(130, 6);
|
||||
const untyped = oneEdge({ kind: Kind.CONNECTION, fwd: Use.WALK | Use.DOWNHILL, flags: EdgeFlag.TYPE_UNKNOWN });
|
||||
expect(cost(alpine, untyped)).toBe(150);
|
||||
});
|
||||
|
||||
it('SKIPROF-021: a snowboarder avoids flats and surface lifts, a nordic skier the wrong way and the wild', () => {
|
||||
const board = profile('snowboard');
|
||||
expect(cost(board, run(1, { flags: EdgeFlag.FLAT_FOR_BOARD }))).toBe(300);
|
||||
const world = new SkiWorld();
|
||||
const { board: tbarBoard } = world.lift(world.node(0, 0, 1500), world.node(0, 600, 1700), { type: Lift.TBAR });
|
||||
const tbar = stitchGraphs([world.graph()]);
|
||||
expect(edgeCost(board, tbar, tbarBoard, 0, Mode.BOARD, 60)).toBe(660);
|
||||
expect(edgeCost(profile('alpine'), tbar, tbarBoard, 0, Mode.BOARD, 60)).toBe(60);
|
||||
const nordic = { kind: Kind.NORDIC, fwd: NORDIC_BOTH, back: NORDIC_BOTH, groom: 1 };
|
||||
const soft = oneEdge({ ...nordic, flags: EdgeFlag.NORDIC_SOFT_ONEWAY });
|
||||
expect(cost(profile('cross_country'), soft, 1)).toBeCloseTo(110, 6);
|
||||
const wild = oneEdge({ kind: Kind.NORDIC, fwd: NORDIC_BOTH, back: NORDIC_BOTH, groom: Groom.BACKCOUNTRY });
|
||||
expect(cost(profile('cross_country', {}, 'classic'), wild)).toBe(200);
|
||||
expect(cost(profile('cross_country', {}, 'skating'), wild)).toBe(300);
|
||||
const assumed = oneEdge({ ...nordic, flags: EdgeFlag.TECHNIQUE_ASSUMED });
|
||||
expect(cost(profile('cross_country'), assumed)).toBeCloseTo(110, 6);
|
||||
const shoe = oneEdge({ kind: Kind.HIKE, fwd: Use.SNOWSHOE, back: Use.SNOWSHOE });
|
||||
expect(cost(profile('snowshoe'), shoe)).toBe(80);
|
||||
expect(cost(profile('touring', { touring_uphill_on_pistes: true }), run(1), 1)).toBe(150);
|
||||
});
|
||||
|
||||
it('SKIPROF-022: priorities: most skiing makes runs cheap and lifts dear, easiest makes the harder runs dear', () => {
|
||||
const most = profile('alpine', { priority: 'most_skiing' });
|
||||
expect(cost(most, run(1))).toBeCloseTo(100 / 1.8, 6);
|
||||
const lift = oneEdge({ kind: Kind.LIFT, fwd: Use.LIFT_UP, lift: Lift.GONDOLA });
|
||||
expect(cost(most, lift)).toBeCloseTo(120, 6);
|
||||
const easiest = profile('alpine', { priority: 'easiest', max_difficulty: 'advanced' });
|
||||
expect(cost(easiest, run(3))).toBeCloseTo(180, 6);
|
||||
expect(cost(easiest, run(2))).toBeCloseTo(150, 6);
|
||||
expect(cost(easiest, run(1))).toBeCloseTo(120, 6);
|
||||
expect(cost(easiest, run(0))).toBeCloseTo(100, 6);
|
||||
expect(cost(easiest, run(255))).toBeCloseTo(150, 6);
|
||||
});
|
||||
|
||||
it('SKIPROF-023: every edge compiled both ways, a forbidden one at Infinity, the heuristic under every edge', () => {
|
||||
const world = new SkiWorld();
|
||||
const top = world.node(0, 0, 2000);
|
||||
const valley = world.node(0, -1000, 1500);
|
||||
world.run(top, valley, 2);
|
||||
world.lift(valley, top, { sec: 200 });
|
||||
const net = stitchGraphs([world.graph()]);
|
||||
const costs = compileProfile(profile('alpine'), net);
|
||||
expect(costs.modeFwd[0]).toBe(Mode.DOWNHILL);
|
||||
expect(costs.costBack[0]).toBe(Infinity);
|
||||
for (let e = 0; e < net.edgeCount; e++) {
|
||||
for (const [cost, length] of [
|
||||
[costs.costFwd[e] as number, net.edgeLen[e] as number],
|
||||
[costs.costBack[e] as number, net.edgeLen[e] as number],
|
||||
]) {
|
||||
if (Number.isFinite(cost) && length > 0) expect(length / cost).toBeLessThanOrEqual(costs.maxSpeed);
|
||||
}
|
||||
}
|
||||
expect(costs.maxSpeed).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* The piste router on drawn ski areas (spec 7.4, 7.5, 12.2): runs one way,
|
||||
* lifts only through their stations, a start inside a run, the reachable one
|
||||
* of two parallel runs, legs that go on from each other, pieces of a split
|
||||
* network stitched, other ways, and the notes a planned leg carries.
|
||||
*/
|
||||
import { compileProfile } from '../../../../src/nest/ski-routing/engine/profile';
|
||||
import { searchPath } from '../../../../src/nest/ski-routing/engine/router';
|
||||
import { LIFT_SNAP_M, MAX_SNAPS, snapPoint } from '../../../../src/nest/ski-routing/engine/snap';
|
||||
import { stitchGraphs } from '../../../../src/nest/ski-routing/engine/stitch';
|
||||
import { EdgeFlag, Lift, NodeFlag } from '../../../../src/nest/ski-routing/ski-graph.contract';
|
||||
import { profileOf } from '../../../../src/nest/ski-routing/ski-routing.service';
|
||||
import { failedLeg, okLeg, planOn } from '../../../helpers/ski-plan';
|
||||
import { SkiWorld } from '../../../helpers/ski-world';
|
||||
import { skiRouteRequestSchema, skiRouteResponseSchema } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Testalp: a chair from the valley to the top, a red run to the middle
|
||||
* station and a blue one on down, and a black run straight down beside them.
|
||||
*/
|
||||
function testalp() {
|
||||
const world = new SkiWorld();
|
||||
const valley = world.node(0, 0, 1200);
|
||||
const top = world.node(0, 2000, 2200);
|
||||
const mid = world.node(600, 1000, 1700);
|
||||
world.lift(valley, top, { name: 'Sonnenbahn', sec: 420, cap: 2400 });
|
||||
world.run(top, mid, 2, { name: 'Rote Eins', ref: '1' });
|
||||
world.run(mid, valley, 1, { name: 'Blaue Zwei' });
|
||||
world.run(top, valley, 3, { name: 'Schwarze Wand', via: [[-300, 1000]] });
|
||||
return { world, valley: world.at(0, 0), top: world.at(0, 2000), mid: world.at(600, 1000) };
|
||||
}
|
||||
|
||||
const RED_M = Math.hypot(600, 1000);
|
||||
|
||||
describe('runs and lifts', () => {
|
||||
it('SKIROUTE-001: skis the runs within the limit, the harder one left out, at the times of the cost model', () => {
|
||||
const { world, top, valley } = testalp();
|
||||
const leg = okLeg(planOn(world, [[top, valley]])[0]);
|
||||
expect(leg.segments.map((s) => [s.kind, s.name, s.difficulty])).toEqual([
|
||||
['run', 'Rote Eins', 'intermediate'],
|
||||
['run', 'Blaue Zwei', 'easy'],
|
||||
]);
|
||||
expect(leg.segments[0]?.ref).toBe('1');
|
||||
expect(leg.distance_m).toBeCloseTo(2 * RED_M, 0);
|
||||
expect(leg.descent_m).toBe(1000);
|
||||
expect(leg.climb_m).toBe(0);
|
||||
expect(leg.seconds.ski).toBeCloseTo(RED_M / 8.5 + RED_M / 7, 3);
|
||||
expect(leg.seconds).toMatchObject({ lift: 0, wait: 0, walk: 0 });
|
||||
expect(leg.geometry[0]?.[0]).toBeCloseTo(top.lat, 6);
|
||||
expect(leg.geometry[leg.geometry.length - 1]?.[1]).toBeCloseTo(valley.lng, 6);
|
||||
expect(leg.geometry[0]?.[2]).toBe(2200);
|
||||
expect(leg.snapped_m).toEqual({ from: 0, to: 0 });
|
||||
expect(leg.area_ids).toEqual(['w:1']);
|
||||
expect(skiRouteResponseSchema.shape.legs.element.safeParse(leg).success).toBe(true);
|
||||
});
|
||||
|
||||
it('SKIROUTE-002: takes the black run once the limit allows it, it being quicker', () => {
|
||||
const { world, top, valley } = testalp();
|
||||
const leg = okLeg(planOn(world, [[top, valley]], { options: { max_difficulty: 'advanced' } })[0]);
|
||||
expect(leg.segments.map((s) => s.name)).toEqual(['Schwarze Wand']);
|
||||
expect(leg.segments[0]?.difficulty).toBe('advanced');
|
||||
});
|
||||
|
||||
it('SKIROUTE-003: goes up by the lift, boarded and left at its stations, with its wait and its ride', () => {
|
||||
const { world, top, valley } = testalp();
|
||||
const leg = okLeg(planOn(world, [[valley, top]])[0]);
|
||||
expect(leg.segments).toHaveLength(1);
|
||||
expect(leg.segments[0]).toMatchObject({
|
||||
kind: 'lift',
|
||||
name: 'Sonnenbahn',
|
||||
lift_type: 'chair_detachable',
|
||||
seconds: 435,
|
||||
wait_seconds: 90,
|
||||
difficulty: null,
|
||||
});
|
||||
expect(leg.seconds).toEqual({ ski: 0, lift: 435, wait: 90, walk: 0 });
|
||||
expect(leg.climb_m).toBe(1000);
|
||||
});
|
||||
|
||||
it('SKIROUTE-004: a start inside a run goes on down it from there, never up', () => {
|
||||
const { world, valley } = testalp();
|
||||
const halfway = world.at(300, 1500);
|
||||
const leg = okLeg(planOn(world, [[halfway, valley]])[0]);
|
||||
expect(leg.segments.map((s) => s.name)).toEqual(['Rote Eins', 'Blaue Zwei']);
|
||||
expect(leg.segments[0]?.length_m).toBeCloseTo(RED_M / 2, 0);
|
||||
expect(leg.snapped_m.from).toBeLessThan(1);
|
||||
expect(leg.geometry[0]?.[0]).toBeCloseTo(halfway.lat, 6);
|
||||
});
|
||||
|
||||
it('SKIROUTE-005: a click right beside a lift line means the lift, a click at a station that station', () => {
|
||||
const { world, valley, top } = testalp();
|
||||
const net = stitchGraphs([world.graph()]);
|
||||
const request = skiRouteRequestSchema.parse({ legs: [{ from: top, to: top }], discipline: 'alpine' });
|
||||
const costs = compileProfile(profileOf(request), net);
|
||||
const beside = world.at(25, 1000);
|
||||
expect(LIFT_SNAP_M).toBe(40);
|
||||
const from = snapPoint(net, costs, beside.lat, beside.lng, 150, 'from');
|
||||
expect(from).toHaveLength(1);
|
||||
expect(from[0]).toMatchObject({ edge: -1, startCost: 0 });
|
||||
expect(from[0]?.lat).toBeCloseTo(valley.lat, 6);
|
||||
const to = snapPoint(net, costs, beside.lat, beside.lng, 150, 'to');
|
||||
expect(to[0]?.lat).toBeCloseTo(top.lat, 6);
|
||||
const leg = okLeg(planOn(world, [[beside, top]])[0]);
|
||||
expect(leg.segments.map((s) => s.kind)).toEqual(['lift']);
|
||||
expect(leg.snapped_m.from).toBeCloseTo(Math.hypot(25, 1000), -1);
|
||||
expect(MAX_SNAPS).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('where a leg joins the network', () => {
|
||||
/** Two parallel runs 60 m apart: Ost reaches the valley station, West ends 200 m above it. */
|
||||
function parallel() {
|
||||
const world = new SkiWorld();
|
||||
const eastTop = world.node(0, 1000, 1800);
|
||||
const station = world.node(0, 0, 1300);
|
||||
const westTop = world.node(60, 1000, 1800);
|
||||
const westEnd = world.node(60, 200, 1400);
|
||||
world.run(eastTop, station, 1, { name: 'Ost' });
|
||||
world.run(westTop, westEnd, 1, { name: 'West' });
|
||||
return { world, station: world.at(0, 0) };
|
||||
}
|
||||
|
||||
it('SKIROUTE-010: of two parallel runs, the one that gets there, though the click is nearer the other', () => {
|
||||
const { world, station } = parallel();
|
||||
const leg = okLeg(planOn(world, [[world.at(35, 600), station]])[0]);
|
||||
expect(leg.segments.map((s) => s.name)).toEqual(['Ost']);
|
||||
expect(leg.snapped_m.from).toBeCloseTo(35, 0);
|
||||
});
|
||||
|
||||
it('SKIROUTE-011: a leg goes on from where the one before ended, not from wherever its point is nearest', () => {
|
||||
const { world, station } = parallel();
|
||||
const between = world.at(35, 500);
|
||||
const legs = planOn(world, [
|
||||
[world.at(35, 900), between],
|
||||
[between, station],
|
||||
]);
|
||||
// The first leg ends on West, the nearer run; from there no run reaches the station.
|
||||
expect(okLeg(legs[0]).segments.map((s) => s.name)).toEqual(['West']);
|
||||
expect(failedLeg(legs[1]).code).toBe('disconnected');
|
||||
// Planned on its own, the second leg finds Ost.
|
||||
const alone = planOn(world, [[between, station]]);
|
||||
expect(okLeg(alone[0]).segments.map((s) => s.name)).toEqual(['Ost']);
|
||||
const unchained = planOn(
|
||||
world,
|
||||
[
|
||||
[world.at(35, 900), between],
|
||||
[between, station],
|
||||
],
|
||||
{ chained: false },
|
||||
);
|
||||
expect(okLeg(unchained[1]).segments.map((s) => s.name)).toEqual(['Ost']);
|
||||
});
|
||||
|
||||
it('SKIROUTE-012: both ends on one run plan the stretch between them; ends at one spot, a leg of no length', () => {
|
||||
const { world } = parallel();
|
||||
const leg = okLeg(planOn(world, [[world.at(0, 900), world.at(0, 300)]])[0]);
|
||||
expect(leg.segments).toHaveLength(1);
|
||||
expect(leg.segments[0]?.length_m).toBeCloseTo(600, 0);
|
||||
const still = okLeg(planOn(world, [[world.at(0, 500), world.at(0, 500)]])[0]);
|
||||
expect(still.distance_m).toBe(0);
|
||||
expect(still.geometry).toHaveLength(2);
|
||||
expect(still.segments).toEqual([expect.objectContaining({ kind: 'walk', seconds: 0, length_m: 0 })]);
|
||||
expect(still.snapped_m).toEqual({ from: 0, to: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a network in pieces (spec 6.6)', () => {
|
||||
it('SKIROUTE-020: joins pieces at their shared boundary nodes, the halo copy of an edge kept once', () => {
|
||||
const upper = new SkiWorld({ lat: 46, lng: 7 }, { areaId: 'w:5-a', build: '2026-11-02', name: 'Nordnetz' });
|
||||
const x = upper.node(0, 2000, 2000, { osm: 101 });
|
||||
const y = upper.node(0, 1000, 1600, { osm: 102, flags: NodeFlag.BOUNDARY });
|
||||
const z = upper.node(0, 0, 1200, { osm: 103, flags: NodeFlag.BOUNDARY });
|
||||
upper.run(x, y, 1, { name: 'Oben', way: 1 });
|
||||
upper.run(y, z, 1, { name: 'Unten', way: 2, flags: EdgeFlag.HALO });
|
||||
const lower = new SkiWorld({ lat: 46, lng: 7 }, { areaId: 'w:5-b', build: '2026-11-02', name: 'Nordnetz' });
|
||||
const y2 = lower.node(0, 1000, 1600, { osm: 102, flags: NodeFlag.BOUNDARY });
|
||||
const z2 = lower.node(0, 0, 1200, { osm: 103, flags: NodeFlag.BOUNDARY });
|
||||
lower.run(y2, z2, 1, { name: 'Unten', way: 2 });
|
||||
const net = stitchGraphs([upper.graph(), lower.graph()]);
|
||||
expect(net.nodeCount).toBe(3);
|
||||
expect(net.edgeCount).toBe(2);
|
||||
expect(net.areaIds).toEqual(['w:5-a', 'w:5-b']);
|
||||
// The copy without the halo flag is the one kept.
|
||||
expect(Array.from(net.edgeFlags).some((flags) => (flags & EdgeFlag.HALO) !== 0)).toBe(false);
|
||||
const leg = okLeg(planOn([upper, lower], [[upper.at(0, 2000), upper.at(0, 0)]])[0]);
|
||||
expect(leg.segments.map((s) => s.name)).toEqual(['Oben', 'Unten']);
|
||||
});
|
||||
|
||||
it('SKIROUTE-021: never joins synthetic nodes, nor nodes of one piece alone', () => {
|
||||
const world = new SkiWorld();
|
||||
const a = world.node(0, 0, 1500, { osm: -5, flags: NodeFlag.BOUNDARY });
|
||||
const b = world.node(0, -500, 1400, { osm: -5, flags: NodeFlag.BOUNDARY });
|
||||
world.run(a, b, 1);
|
||||
expect(stitchGraphs([world.graph()]).nodeCount).toBe(2);
|
||||
expect(stitchGraphs([world.graph(), world.graph()]).nodeCount).toBe(4);
|
||||
expect(() => stitchGraphs([])).toThrow('No graph to stitch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('other ways and notes', () => {
|
||||
it('SKIROUTE-030: offers a second way that shares little with the first and is not much slower', () => {
|
||||
const world = new SkiWorld();
|
||||
const top = world.node(0, 2000, 2200);
|
||||
const valley = world.node(0, 0, 1200);
|
||||
const east = world.node(300, 1000, 1700);
|
||||
const west = world.node(-310, 1000, 1700);
|
||||
world.run(top, east, 1, { name: 'Ostabfahrt' });
|
||||
world.run(east, valley, 1, { name: 'Ostabfahrt' });
|
||||
world.run(top, west, 1, { name: 'Westabfahrt' });
|
||||
world.run(west, valley, 1, { name: 'Westabfahrt' });
|
||||
const leg = okLeg(planOn(world, [[world.at(0, 2000), world.at(0, 0)]], { alternatives: 2 })[0]);
|
||||
expect(leg.segments.map((s) => s.name)).toEqual(['Ostabfahrt']);
|
||||
expect(leg.alternatives).toHaveLength(1);
|
||||
expect(leg.alternatives[0]?.segments.map((s) => s.name)).toEqual(['Westabfahrt']);
|
||||
expect(leg.alternatives[0]?.seconds_total).toBeGreaterThan(leg.seconds.ski);
|
||||
expect(okLeg(planOn(world, [[world.at(0, 2000), world.at(0, 0)]])[0]).alternatives).toEqual([]);
|
||||
});
|
||||
|
||||
it('SKIROUTE-031: notes a harder run it kept clear of when that would have been clearly quicker', () => {
|
||||
const world = new SkiWorld();
|
||||
const top = world.node(0, 2000, 2200);
|
||||
const valley = world.node(0, 0, 1200);
|
||||
world.run(top, valley, 3, { name: 'Schwarze Wand' });
|
||||
world.run(top, valley, 1, { name: 'Umweg', via: [[1500, 1000]] });
|
||||
const leg = okLeg(planOn(world, [[world.at(0, 2000), world.at(0, 0)]])[0]);
|
||||
expect(leg.segments.map((s) => s.name)).toEqual(['Umweg']);
|
||||
const avoided = leg.why.find((note) => note.kind === 'avoided_difficulty');
|
||||
expect(avoided).toMatchObject({ kind: 'avoided_difficulty', level: 'advanced', name: 'Schwarze Wand' });
|
||||
expect(avoided?.kind === 'avoided_difficulty' ? avoided.saved_seconds : 0).toBeGreaterThan(200);
|
||||
// With the limit at expert nothing was avoided.
|
||||
const expert = { options: { max_difficulty: 'expert' as const } };
|
||||
const free = okLeg(planOn(world, [[world.at(0, 2000), world.at(0, 0)]], expert)[0]);
|
||||
expect(free.why.some((note) => note.kind === 'avoided_difficulty')).toBe(false);
|
||||
});
|
||||
|
||||
it('SKIROUTE-032: counts the flagged stretches a leg rests on: a guessed link, a direction from the terrain', () => {
|
||||
const world = new SkiWorld();
|
||||
const top = world.node(0, 2000, 2200);
|
||||
const gap = world.node(0, 1000, 1700);
|
||||
const after = world.node(0, 990, 1699);
|
||||
const valley = world.node(0, 0, 1200);
|
||||
world.run(top, gap, 1, { name: 'Oben', flags: EdgeFlag.DIR_FROM_DEM });
|
||||
world.link(gap, after, { flags: EdgeFlag.INFERRED_LINK });
|
||||
world.run(after, valley, 255, { name: 'Unten' });
|
||||
const leg = okLeg(planOn(world, [[world.at(0, 2000), world.at(0, 0)]])[0]);
|
||||
expect(leg.segments.map((s) => s.kind)).toEqual(['run', 'link', 'run']);
|
||||
expect(leg.segments[1]?.flags).toEqual(['inferred_link']);
|
||||
expect(leg.why).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: 'flag', flag: 'dir_from_dem', count: 1 },
|
||||
{ kind: 'flag', flag: 'inferred_link', count: 1 },
|
||||
{ kind: 'flag', flag: 'difficulty_unknown', count: 1 },
|
||||
]),
|
||||
);
|
||||
expect(leg.seconds.walk).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('SKIROUTE-033: rides a funicular as rail, and a gondola down only where allowed', () => {
|
||||
const world = new SkiWorld();
|
||||
const valley = world.node(0, 0, 1600);
|
||||
const top = world.node(0, 1500, 2300);
|
||||
world.lift(valley, top, { type: Lift.FUNICULAR, name: 'Sunnegga', sec: 180 });
|
||||
const up = okLeg(planOn(world, [[world.at(0, 0), world.at(0, 1500)]])[0]);
|
||||
expect(up.segments[0]).toMatchObject({ kind: 'rail', lift_type: 'funicular', wait_seconds: 240 });
|
||||
|
||||
const gondola = new SkiWorld();
|
||||
const foot = gondola.node(0, 0, 1600);
|
||||
const summit = gondola.node(0, 1500, 2500);
|
||||
gondola.lift(foot, summit, { type: Lift.GONDOLA, name: 'Gondel', down: true });
|
||||
const down = okLeg(planOn(gondola, [[gondola.at(0, 1500), gondola.at(0, 0)]])[0]);
|
||||
expect(down.segments[0]).toMatchObject({ kind: 'lift_down', name: 'Gondel' });
|
||||
expect(down.segments[0]?.flags).toContain('ride_down');
|
||||
expect(down.why).toContainEqual({ kind: 'flag', flag: 'ride_down', count: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the search budget', () => {
|
||||
it('SKIROUTE-040: answers timeout once it settled more nodes than allowed, and none where nothing joins', () => {
|
||||
const { world, top, valley } = testalp();
|
||||
const net = stitchGraphs([world.graph()]);
|
||||
const request = skiRouteRequestSchema.parse({ legs: [{ from: top, to: valley }], discipline: 'alpine' });
|
||||
const costs = compileProfile(profileOf(request), net);
|
||||
const starts = snapPoint(net, costs, top.lat, top.lng, 150, 'from');
|
||||
const ends = snapPoint(net, costs, valley.lat, valley.lng, 150, 'to');
|
||||
const budget = { maxSettled: 0, deadline: Date.now() + 1000 };
|
||||
expect(searchPath(net, costs, starts, ends, valley, budget).status).toBe('timeout');
|
||||
// A ski tourer rides no lift and climbs no run: from the valley nothing leads up.
|
||||
const touring = skiRouteRequestSchema.parse({ legs: [{ from: valley, to: top }], discipline: 'touring' });
|
||||
const climb = compileProfile(profileOf(touring), net);
|
||||
const none = searchPath(net, climb, ends, starts, top, { maxSettled: 100, deadline: Date.now() + 1000 });
|
||||
expect(none).toMatchObject({ status: 'none' });
|
||||
expect(none.status === 'none' ? none.nearest?.lat : null).toBeCloseTo(valley.lat, 6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* The daily look at the winter layer (spec 7.2): registered on the cron
|
||||
* registrar only, sweeping unused area files, fetching the index when the
|
||||
* host serves a new build, and fetching the kept areas whose graph changed.
|
||||
*/
|
||||
import type { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service';
|
||||
import { SkiAreasJob } from '../../../../src/nest/ski-routing/ski-areas.job';
|
||||
import type { SkiAreasService, SkiIndex } from '../../../../src/nest/ski-routing/ski-areas.service';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
const row = (id: string, sha: string) => ({
|
||||
row: { id, sha256: sha.repeat(64) },
|
||||
minLat: 0,
|
||||
minLng: 0,
|
||||
maxLat: 0,
|
||||
maxLng: 0,
|
||||
});
|
||||
|
||||
function setup(refreshed: 'updated' | 'unchanged' | 'refused' = 'updated') {
|
||||
const index = { areas: [row('w:1', 'a'), row('w:2', 'b'), row('w:3', 'c')] } as unknown as SkiIndex;
|
||||
const areas = {
|
||||
sweep: vi.fn().mockResolvedValue(2),
|
||||
enabled: vi.fn(() => true),
|
||||
refresh: vi.fn().mockResolvedValue(refreshed),
|
||||
index: vi.fn().mockResolvedValue(index),
|
||||
keptAreas: vi.fn().mockResolvedValue([
|
||||
{ id: 'w:1', sha8: 'aaaaaaaa' },
|
||||
{ id: 'w:2', sha8: '00000000' },
|
||||
]),
|
||||
ensureArea: vi.fn().mockResolvedValue({ file: 'x', stale: false }),
|
||||
};
|
||||
const registrar = { isEnabled: vi.fn(() => true), register: vi.fn() };
|
||||
const job = new SkiAreasJob(areas as unknown as SkiAreasService, registrar as unknown as CronRegistrarService);
|
||||
return { job, areas, registrar };
|
||||
}
|
||||
|
||||
describe('SkiAreasJob', () => {
|
||||
it('SKIJOB-001: registers once a day at 04:40, and not where the registrar runs nothing', () => {
|
||||
const { job, registrar } = setup();
|
||||
job.onApplicationBootstrap();
|
||||
expect(registrar.register).toHaveBeenCalledWith('ski-areas', '40 4 * * *', expect.any(Function));
|
||||
registrar.register.mockClear();
|
||||
registrar.isEnabled.mockReturnValue(false);
|
||||
job.onApplicationBootstrap();
|
||||
expect(registrar.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SKIJOB-002: sweeps, takes a new build and fetches the kept areas it changed', async () => {
|
||||
const { job, areas } = setup('updated');
|
||||
await job.tick();
|
||||
expect(areas.sweep).toHaveBeenCalled();
|
||||
expect(areas.refresh).toHaveBeenCalled();
|
||||
expect(areas.ensureArea).toHaveBeenCalledTimes(1);
|
||||
expect(areas.ensureArea).toHaveBeenCalledWith({ id: 'w:2', sha256: 'b'.repeat(64) });
|
||||
});
|
||||
|
||||
it('SKIJOB-003: fetches nothing when the build is the same, or TREK Places is off; a failure is logged', async () => {
|
||||
const same = setup('unchanged');
|
||||
await same.job.tick();
|
||||
expect(same.areas.ensureArea).not.toHaveBeenCalled();
|
||||
const off = setup();
|
||||
off.areas.enabled.mockReturnValue(false);
|
||||
await off.job.tick();
|
||||
expect(off.areas.refresh).not.toHaveBeenCalled();
|
||||
const broken = setup();
|
||||
broken.areas.refresh.mockRejectedValue(new Error('TREK Places API 503'));
|
||||
await expect(broken.job.tick()).resolves.toBeUndefined();
|
||||
const lost = setup();
|
||||
lost.areas.index.mockResolvedValue(null);
|
||||
await lost.job.tick();
|
||||
expect(lost.areas.keptAreas).not.toHaveBeenCalled();
|
||||
const failing = setup();
|
||||
failing.areas.ensureArea.mockRejectedValue(new Error('no'));
|
||||
await expect(failing.job.tick()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* The ski areas the piste router plans on (spec 7.2, 7.3, 11): the index of
|
||||
* the Places host kept in the data folder and fetched only when its checksum
|
||||
* changed, each area's graph downloaded once however many ask, checked
|
||||
* against the index before it is kept, an older copy used while the host is
|
||||
* down, and files nobody used for 90 days swept. The host is a stub here;
|
||||
* its client is tested in trek-places.winter.test.ts.
|
||||
*/
|
||||
import { DataPathsService } from '../../../../src/nest/app-config/data-paths.service';
|
||||
import { TrekPlacesWinterService } from '../../../../src/nest/maps/trek-places-winter.service';
|
||||
import {
|
||||
areasAt,
|
||||
buildIndex,
|
||||
pickAreas,
|
||||
SkiAreasService,
|
||||
UNUSED_FOR_MS,
|
||||
} from '../../../../src/nest/ski-routing/ski-areas.service';
|
||||
import { SkiWorld } from '../../../helpers/ski-world';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { WinterIndex, WinterIndexRow, WinterMeta } from '@trek/shared';
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, mkdtemp, readdir, rm, utimes, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
const sha = (bytes: Buffer | string) => createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
const world = new SkiWorld();
|
||||
world.run(world.node(0, 1000, 1800), world.node(0, 0, 1300), 1, { name: 'Talabfahrt' });
|
||||
const GZ = world.gz();
|
||||
|
||||
function row(id: string, bbox: [number, number, number, number], bytes = GZ): WinterIndexRow {
|
||||
return {
|
||||
id,
|
||||
name: `Gebiet ${id}`,
|
||||
kind: 'resort',
|
||||
country: null,
|
||||
colourScheme: 'europe',
|
||||
bbox,
|
||||
counts: { runsKm: 1, nordicKm: 0, lifts: 0, skitourKm: 0, hikeKm: 0 },
|
||||
bytes: bytes.length,
|
||||
sha256: sha(bytes),
|
||||
};
|
||||
}
|
||||
|
||||
const ROW = row('w:100', [6.99, 45.99, 7.01, 46.01]);
|
||||
|
||||
function hosted(rows: WinterIndexRow[], build = '2026-11-02') {
|
||||
const index: WinterIndex = { build, format: 1, areas: rows };
|
||||
const json = Buffer.from(JSON.stringify(index));
|
||||
const meta: WinterMeta = {
|
||||
build,
|
||||
format: 1,
|
||||
areas: rows.length,
|
||||
indexSha: sha(json),
|
||||
sourceTimestamp: null,
|
||||
attribution: { data: '© OpenStreetMap contributors, ODbL', terrain: 'Terrain: Mapzen Terrarium' },
|
||||
};
|
||||
return { index, meta, sha256: meta.indexSha };
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
let host: ReturnType<typeof hosted>;
|
||||
const winter = {
|
||||
enabled: vi.fn(() => true),
|
||||
meta: vi.fn(),
|
||||
index: vi.fn(),
|
||||
area: vi.fn(),
|
||||
};
|
||||
|
||||
async function service(): Promise<SkiAreasService> {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
SkiAreasService,
|
||||
{ provide: DataPathsService, useValue: { dataDir: dir } },
|
||||
{ provide: TrekPlacesWinterService, useValue: winter },
|
||||
],
|
||||
}).compile();
|
||||
return moduleRef.get(SkiAreasService);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'trek-ski-areas-'));
|
||||
host = hosted([ROW]);
|
||||
winter.enabled.mockReset().mockReturnValue(true);
|
||||
winter.meta.mockReset().mockImplementation(async () => host.meta);
|
||||
winter.index
|
||||
.mockReset()
|
||||
.mockImplementation(async () => ({ status: 'ok', index: host.index, sha256: host.sha256, dropped: 0 }));
|
||||
winter.area.mockReset().mockImplementation(async () => ({ status: 'ok', bytes: GZ, etag: ROW.sha256 }));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('the index of ski areas', () => {
|
||||
it('SKIAREA-001: fetched once and kept on disk; a later start reads it from there without asking', async () => {
|
||||
const areas = await service();
|
||||
const index = await areas.index();
|
||||
expect(index?.build).toBe('2026-11-02');
|
||||
expect(index?.areas.map((area) => area.row.id)).toEqual(['w:100']);
|
||||
expect(index?.attribution).toBe('© OpenStreetMap contributors, ODbL. Terrain: Mapzen Terrarium');
|
||||
await areas.index();
|
||||
expect(winter.meta).toHaveBeenCalledTimes(1);
|
||||
expect(await readdir(join(dir, 'ski'))).toEqual([`index-${host.meta.indexSha.slice(0, 8)}.json`]);
|
||||
|
||||
const later = await service();
|
||||
expect((await later.index())?.indexSha).toBe(host.meta.indexSha);
|
||||
expect(winter.meta).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('SKIAREA-002: none without TREK Places or the host, a broken kept file skipped', async () => {
|
||||
winter.enabled.mockReturnValue(false);
|
||||
expect(await (await service()).index()).toBeNull();
|
||||
winter.enabled.mockReturnValue(true);
|
||||
winter.meta.mockRejectedValue(new Error('TREK Places API 503'));
|
||||
expect(await (await service()).index()).toBeNull();
|
||||
await mkdir(join(dir, 'ski'), { recursive: true });
|
||||
await writeFile(join(dir, 'ski', 'index-broken00.json'), '{not json');
|
||||
expect(await (await service()).index()).toBeNull();
|
||||
});
|
||||
|
||||
it('SKIAREA-003: takes a new index only with its announced checksum and a format it reads', async () => {
|
||||
const areas = await service();
|
||||
await areas.index();
|
||||
expect(await areas.refresh()).toBe('unchanged');
|
||||
|
||||
const next = hosted([ROW, row('w:200', [8, 46, 8.01, 46.01])], '2026-11-09');
|
||||
winter.meta.mockResolvedValue(next.meta);
|
||||
winter.index.mockResolvedValueOnce({ status: 'ok', index: next.index, sha256: 'f'.repeat(64), dropped: 0 });
|
||||
expect(await areas.refresh()).toBe('refused');
|
||||
expect((await areas.index())?.build).toBe('2026-11-02');
|
||||
|
||||
winter.meta.mockResolvedValueOnce({ ...next.meta, format: 2 });
|
||||
expect(await areas.refresh()).toBe('refused');
|
||||
|
||||
winter.index.mockResolvedValueOnce({ status: 'not_modified' });
|
||||
expect(await areas.refresh()).toBe('unchanged');
|
||||
|
||||
winter.index.mockResolvedValueOnce({ status: 'ok', index: next.index, sha256: next.sha256, dropped: 1 });
|
||||
expect(await areas.refresh()).toBe('updated');
|
||||
expect((await areas.index())?.areas).toHaveLength(2);
|
||||
expect(winter.index).toHaveBeenLastCalledWith(host.meta.indexSha);
|
||||
expect(await readdir(join(dir, 'ski'))).toEqual([`index-${next.meta.indexSha.slice(0, 8)}.json`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an area graph', () => {
|
||||
it('SKIAREA-010: downloaded once however many ask at once, then read from disk', async () => {
|
||||
const areas = await service();
|
||||
const [a, b] = await Promise.all([areas.ensureArea(ROW), areas.ensureArea(ROW)]);
|
||||
expect(a).toEqual({ file: areas.fileOf(ROW), stale: false });
|
||||
expect(b).toEqual(a);
|
||||
expect(winter.area).toHaveBeenCalledTimes(1);
|
||||
expect(winter.area).toHaveBeenCalledWith('w:100', null);
|
||||
expect(areas.fileOf(ROW)).toMatch(/w_100@[0-9a-f]{8}\.sgr\.gz$/);
|
||||
expect(await areas.ensureArea(ROW)).toEqual(a);
|
||||
expect(winter.area).toHaveBeenCalledTimes(1);
|
||||
expect(areas.stateOf(ROW)).toBe('ready');
|
||||
});
|
||||
|
||||
it('SKIAREA-011: refuses a graph whose checksum is not the index one, and keeps nothing of it', async () => {
|
||||
const areas = await service();
|
||||
winter.area.mockResolvedValueOnce({ status: 'ok', bytes: Buffer.from('not the graph'), etag: null });
|
||||
await expect(areas.ensureArea(ROW)).rejects.toThrow("does not match the index's checksum");
|
||||
expect(existsSync(areas.fileOf(ROW))).toBe(false);
|
||||
expect(areas.stateOf(ROW)).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('SKIAREA-012: an older copy stands in while the host is down; without one the download fails', async () => {
|
||||
const areas = await service();
|
||||
await areas.ensureArea(ROW);
|
||||
const changed = { ...ROW, sha256: sha('a newer build') };
|
||||
winter.area.mockRejectedValueOnce(Object.assign(new Error('TREK Places API 503'), { status: 503 }));
|
||||
expect(await areas.ensureArea(changed)).toEqual({ file: areas.fileOf(ROW), stale: true });
|
||||
const unknown = row('w:300', [8, 46, 8.01, 46.01]);
|
||||
winter.area.mockRejectedValueOnce(Object.assign(new Error('TREK Places API 404'), { status: 404 }));
|
||||
await expect(areas.ensureArea(unknown)).rejects.toThrow('TREK Places API 404');
|
||||
// An id the host does not know sends TREK for a fresh index.
|
||||
await vi.waitFor(() => expect(winter.meta).toHaveBeenCalled());
|
||||
winter.area.mockResolvedValueOnce({ status: 'not_modified' });
|
||||
await expect(areas.ensureArea(unknown)).rejects.toThrow('sent no graph');
|
||||
});
|
||||
|
||||
it('SKIAREA-013: an area loading, ready or unavailable, and unavailable while TREK Places is off', async () => {
|
||||
const areas = await service();
|
||||
let release: (value: unknown) => void = () => undefined;
|
||||
winter.area.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (release = () => resolve({ status: 'ok', bytes: GZ, etag: null }))),
|
||||
);
|
||||
expect(areas.stateOf(ROW)).toBe('loading');
|
||||
const pending = areas.ensureArea(ROW);
|
||||
expect(areas.stateOf(ROW)).toBe('loading');
|
||||
release(undefined);
|
||||
await pending;
|
||||
expect(areas.stateOf(ROW)).toBe('ready');
|
||||
winter.enabled.mockReturnValue(false);
|
||||
expect(areas.enabled()).toBe(false);
|
||||
expect(areas.stateOf(row('w:400', [9, 46, 9.01, 46.01]))).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('SKIAREA-014: lists the areas kept, and sweeps half writes and files unused for 90 days', async () => {
|
||||
const areas = await service();
|
||||
await areas.ensureArea(ROW);
|
||||
const old = join(dir, 'ski', 'areas', 'w_9@deadbeef.sgr.gz');
|
||||
await writeFile(old, GZ);
|
||||
const long = new Date(Date.now() - UNUSED_FOR_MS - 60_000);
|
||||
await utimes(old, long, long);
|
||||
await writeFile(join(dir, 'ski', 'areas', 'w_1@00000000.sgr.gz.tmp'), 'half');
|
||||
await writeFile(join(dir, 'ski', 'index-00000000.json.tmp'), 'half');
|
||||
expect((await areas.keptAreas()).map((kept) => kept.id).sort()).toEqual(['w:100', 'w:9']);
|
||||
expect(await areas.sweep(Date.now(), { tempOnly: true })).toBe(2);
|
||||
expect(existsSync(old)).toBe(true);
|
||||
expect(await areas.sweep(Date.now())).toBe(1);
|
||||
expect(existsSync(old)).toBe(false);
|
||||
expect(existsSync(areas.fileOf(ROW))).toBe(true);
|
||||
await areas.onApplicationBootstrap();
|
||||
await rm(join(dir, 'ski'), { recursive: true, force: true });
|
||||
expect(await areas.sweep(Date.now())).toBe(0);
|
||||
expect(await areas.keptAreas()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the areas a leg is planned on', () => {
|
||||
const index = buildIndex(
|
||||
{ build: 'b', indexSha: 'x', attribution: { data: 'd', terrain: 't' } },
|
||||
{
|
||||
build: 'b',
|
||||
format: 1,
|
||||
areas: [
|
||||
row('w:1', [7, 46, 7.02, 46.02]),
|
||||
row('w:2', [7.03, 46, 7.05, 46.02]),
|
||||
row('w:3-a', [8, 46, 8.02, 46.02]),
|
||||
row('w:3-b', [8.015, 46, 8.04, 46.02]),
|
||||
row('w:4', [7.001, 46.001, 7.003, 46.003]),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
it('SKIAREA-020: the area holding both ends, the smaller one nearer first where two hold a point', () => {
|
||||
expect(areasAt(index, { lat: 46.002, lng: 7.002 }).map((area) => area.row.id)).toEqual(['w:4', 'w:1']);
|
||||
const pick = pickAreas(index, { from: { lat: 46.01, lng: 7.01 }, to: { lat: 46.012, lng: 7.012 } });
|
||||
expect(pick.status === 'ok' && pick.areas.map((area) => area.row.id)).toEqual(['w:1']);
|
||||
});
|
||||
|
||||
it('SKIAREA-021: no area at an end, and two areas that do not touch, are said as such', () => {
|
||||
expect(pickAreas(index, { from: { lat: 50, lng: 7 }, to: { lat: 46.01, lng: 7.01 } })).toEqual({
|
||||
status: 'no_area',
|
||||
at: { lat: 50, lng: 7 },
|
||||
});
|
||||
expect(pickAreas(index, { from: { lat: 46.01, lng: 7.01 }, to: { lat: 50, lng: 7 } }).status).toBe('no_area');
|
||||
expect(pickAreas(index, { from: { lat: 46.01, lng: 7.005 }, to: { lat: 46.01, lng: 7.045 } })).toEqual({
|
||||
status: 'cross_area',
|
||||
at: { lat: 46.01, lng: 7.045 },
|
||||
});
|
||||
});
|
||||
|
||||
it('SKIAREA-022: the pieces of one split network that touch are planned on together', () => {
|
||||
const pick = pickAreas(index, { from: { lat: 46.01, lng: 8.005 }, to: { lat: 46.01, lng: 8.035 } });
|
||||
expect(pick.status === 'ok' && pick.areas.map((area) => area.row.id)).toEqual(['w:3-a', 'w:3-b']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* SGR1, the graph format the Places host writes and TREK reads (spec 4). The
|
||||
* golden file is the writer's own (trek-places-api tests/fixtures/winter),
|
||||
* byte for byte, with every array written out beside it in golden-v1.json:
|
||||
* the reader must give exactly those arrays, the writer's two edge flags
|
||||
* beyond the first draft (17 area_has_run, 18 type_unknown) among the
|
||||
* constants, and refuse a file that is damaged, cut or of another format.
|
||||
*/
|
||||
import { stitchGraphs } from '../../../../src/nest/ski-routing/engine/stitch';
|
||||
import {
|
||||
decodeSkiGraph,
|
||||
decodeSkiGraphGz,
|
||||
poolString,
|
||||
SkiGraphFormatError,
|
||||
} from '../../../../src/nest/ski-routing/ski-graph';
|
||||
import {
|
||||
EdgeFlag,
|
||||
Kind,
|
||||
Lift,
|
||||
NO_STRING,
|
||||
SGR_FORMAT,
|
||||
Use,
|
||||
type SkiGraphData,
|
||||
} from '../../../../src/nest/ski-routing/ski-graph.contract';
|
||||
import { SkiWorld } from '../../../helpers/ski-world';
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { crc32, gzipSync } from 'node:zlib';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const FIXTURES = join(__dirname, '../../../fixtures/ski');
|
||||
const GOLDEN = readFileSync(join(FIXTURES, 'golden-v1.sgr'));
|
||||
const VECTORS = JSON.parse(readFileSync(join(FIXTURES, 'golden-v1.json'), 'utf8')) as Record<string, unknown> & {
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
header: Record<string, unknown>;
|
||||
strings: Record<string, string>;
|
||||
};
|
||||
/** The checksum the writer's test pins (GOLDEN_SHA256 in tests/test_winter_sgr.py). */
|
||||
const GOLDEN_SHA256 = '9dd044605b8edf85c7fcc83e9f3bdb857f1e6451d09da7238d7f3071cf6c4b67';
|
||||
|
||||
const ARRAYS: ReadonlyArray<keyof Omit<SkiGraphData, 'header' | 'strings'>> = [
|
||||
'nodeLatE7',
|
||||
'nodeLngE7',
|
||||
'nodeEle',
|
||||
'nodeFlags',
|
||||
'nodeOsm',
|
||||
'edgeA',
|
||||
'edgeB',
|
||||
'edgeLen',
|
||||
'edgeUp',
|
||||
'edgeDown',
|
||||
'edgeKind',
|
||||
'edgeFwdUses',
|
||||
'edgeBackUses',
|
||||
'edgeDiff',
|
||||
'edgeGroom',
|
||||
'edgeLift',
|
||||
'edgeLiftSec',
|
||||
'edgeLiftCap',
|
||||
'edgeFlags',
|
||||
'edgeName',
|
||||
'edgeRef',
|
||||
'edgeWay',
|
||||
'edgeGeomOff',
|
||||
'geomLatE7',
|
||||
'geomLngE7',
|
||||
'geomEle',
|
||||
];
|
||||
|
||||
/** The golden file with its head rewritten, the body as it was. */
|
||||
function withHead(change: (head: Record<string, unknown>) => void): Buffer {
|
||||
const length = GOLDEN.readUInt32LE(4);
|
||||
const head = JSON.parse(GOLDEN.toString('utf8', 8, 8 + length)) as Record<string, unknown>;
|
||||
change(head);
|
||||
const json = Buffer.from(JSON.stringify(head), 'utf8');
|
||||
const prefix = Buffer.concat([Buffer.from('SGR1', 'latin1'), Buffer.alloc(4), json]);
|
||||
prefix.writeUInt32LE(json.length, 4);
|
||||
const body = GOLDEN.subarray((8 + length + 7) & ~7);
|
||||
return Buffer.concat([prefix, Buffer.alloc((8 - (prefix.length % 8)) % 8), body]);
|
||||
}
|
||||
|
||||
/** Where the sections of a file start. */
|
||||
const bodyStart = (bytes: Buffer): number => (8 + bytes.readUInt32LE(4) + 7) & ~7;
|
||||
|
||||
/** A file whose body was changed, with its head's checksum made to fit again. */
|
||||
function reseal(bytes: Buffer): Buffer {
|
||||
const length = bytes.readUInt32LE(4);
|
||||
const head = JSON.parse(bytes.toString('utf8', 8, 8 + length)) as Record<string, unknown>;
|
||||
head.crc32 = crc32(bytes.subarray(bodyStart(bytes))) >>> 0;
|
||||
const json = Buffer.from(JSON.stringify(head), 'utf8');
|
||||
const prefix = Buffer.concat([Buffer.from('SGR1', 'latin1'), Buffer.alloc(4), json]);
|
||||
prefix.writeUInt32LE(json.length, 4);
|
||||
return Buffer.concat([prefix, Buffer.alloc((8 - (prefix.length % 8)) % 8), bytes.subarray(bodyStart(bytes))]);
|
||||
}
|
||||
|
||||
describe('the golden SGR1 file', () => {
|
||||
it('SKIGRAPH-001: is the writer file byte for byte', () => {
|
||||
expect(GOLDEN.length).toBe(VECTORS.bytes);
|
||||
expect(createHash('sha256').update(GOLDEN).digest('hex')).toBe(GOLDEN_SHA256);
|
||||
expect(VECTORS.sha256).toBe(GOLDEN_SHA256);
|
||||
});
|
||||
|
||||
it('SKIGRAPH-002: decodes to exactly the head and every array the writer wrote out', () => {
|
||||
const graph = decodeSkiGraph(GOLDEN);
|
||||
expect(graph.header).toEqual(VECTORS.header);
|
||||
for (const name of ARRAYS) {
|
||||
expect(Array.from(graph[name] as ArrayLike<number>), name).toEqual(VECTORS[name]);
|
||||
}
|
||||
expect(graph.header.format).toBe(SGR_FORMAT);
|
||||
expect(graph.header.areaId).toBe('w:100');
|
||||
});
|
||||
|
||||
it('SKIGRAPH-003: reads every string of the pool at its offset, and none for the empty offset', () => {
|
||||
const graph = decodeSkiGraph(GOLDEN);
|
||||
for (const [offset, text] of Object.entries(VECTORS.strings)) {
|
||||
expect(poolString(graph.strings, Number(offset))).toBe(text);
|
||||
}
|
||||
expect(poolString(graph.strings, NO_STRING)).toBeNull();
|
||||
expect(poolString(graph.strings, graph.strings.length + 4)).toBeNull();
|
||||
const names = new Set(Array.from(graph.edgeName, (offset) => poolString(graph.strings, offset)));
|
||||
expect(names).toEqual(new Set(['Rote Eins', 'Blaue Zwei', 'Talloipe', 'Sonnenbahn', null]));
|
||||
});
|
||||
|
||||
it('SKIGRAPH-004: holds the lift as board, ride and alight, and the use bits the contract names', () => {
|
||||
const graph = decodeSkiGraph(GOLDEN);
|
||||
const kinds = Array.from(graph.edgeKind);
|
||||
const board = kinds.indexOf(Kind.BOARD);
|
||||
const ride = kinds.indexOf(Kind.LIFT);
|
||||
const alight = kinds.indexOf(Kind.ALIGHT);
|
||||
expect(graph.edgeFwdUses[board]).toBe(Use.WALK | Use.LIFT_UP);
|
||||
expect(graph.edgeFwdUses[ride]).toBe(Use.LIFT_UP);
|
||||
expect(graph.edgeBackUses[ride]).toBe(0);
|
||||
expect(graph.edgeLift[ride]).toBe(Lift.CHAIR_DETACHABLE);
|
||||
expect(graph.edgeB[board]).toBe(graph.edgeA[ride]);
|
||||
expect(graph.edgeA[alight]).toBe(graph.edgeB[ride]);
|
||||
// Every link of the golden area is one the data does not hold.
|
||||
kinds.forEach((kind, e) => {
|
||||
if (kind === Kind.LINK) expect((graph.edgeFlags[e] as number) & EdgeFlag.INFERRED_LINK).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('SKIGRAPH-005: names the writer edge flags beyond the first draft', () => {
|
||||
expect(EdgeFlag.HALO).toBe(1 << 16);
|
||||
expect(EdgeFlag.AREA_HAS_RUN).toBe(1 << 17);
|
||||
expect(EdgeFlag.TYPE_UNKNOWN).toBe(1 << 18);
|
||||
});
|
||||
|
||||
it('SKIGRAPH-006: reads the file gzip-packed, as the Places host sends it', () => {
|
||||
expect(decodeSkiGraphGz(gzipSync(GOLDEN)).header).toEqual(VECTORS.header);
|
||||
expect(() => decodeSkiGraphGz(GOLDEN)).toThrow('SGR1 file is not gzip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a damaged or foreign file', () => {
|
||||
it('SKIGRAPH-010: refuses another magic, a cut file, a flipped byte and a head that is no JSON', () => {
|
||||
expect(() => decodeSkiGraph(Buffer.concat([Buffer.from('SGR2'), GOLDEN.subarray(4)]))).toThrow('Not an SGR1 file');
|
||||
expect(() => decodeSkiGraph(GOLDEN.subarray(0, 6))).toThrow('Not an SGR1 file');
|
||||
const flipped = Buffer.from(GOLDEN);
|
||||
flipped[flipped.length - 9] = (flipped[flipped.length - 9] as number) ^ 0xff;
|
||||
expect(() => decodeSkiGraph(flipped)).toThrow('SGR1 checksum differs');
|
||||
expect(() => decodeSkiGraph(GOLDEN.subarray(0, Math.floor(GOLDEN.length / 2)))).toThrow(SkiGraphFormatError);
|
||||
const longHead = Buffer.from(GOLDEN);
|
||||
longHead.writeUInt32LE(GOLDEN.length, 4);
|
||||
expect(() => decodeSkiGraph(longHead)).toThrow('SGR1 head runs past the end of the file');
|
||||
const notJson = Buffer.from(GOLDEN);
|
||||
notJson.write('{', 8 + 1, 'latin1');
|
||||
expect(() => decodeSkiGraph(notJson)).toThrow('SGR1 head is not JSON');
|
||||
});
|
||||
|
||||
it('SKIGRAPH-011: a newer format is refused as such, so the graph kept before stays in use', () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
decodeSkiGraph(withHead((head) => (head.format = 2)));
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SkiGraphFormatError);
|
||||
expect((caught as SkiGraphFormatError).unknownFormat).toBe(true);
|
||||
expect((caught as Error).message).toBe('SGR1 format 2 is not 1');
|
||||
});
|
||||
|
||||
it('SKIGRAPH-012: refuses a head without its counts and a body shorter than the head says', () => {
|
||||
const noNodes = withHead((head) => delete head.nodeCount);
|
||||
expect(() => decodeSkiGraph(noNodes)).toThrow('SGR1 head has no valid node count');
|
||||
expect(() => decodeSkiGraph(withHead((head) => (head.crc32 = -1)))).toThrow('SGR1 head has no valid checksum');
|
||||
expect(() => decodeSkiGraph(withHead((head) => (head.geomCount = 9_999)))).toThrow(SkiGraphFormatError);
|
||||
expect(() => decodeSkiGraph(withHead((head) => (head.stringBytes = 9_999)))).toThrow(
|
||||
'SGR1 string pool runs past the end',
|
||||
);
|
||||
});
|
||||
|
||||
it('SKIGRAPH-013: refuses an edge that names a node or points that do not exist', () => {
|
||||
const world = new SkiWorld();
|
||||
const a = world.node(0, 0, 1500);
|
||||
const b = world.node(0, -500, 1400);
|
||||
world.run(a, b, 2);
|
||||
const bytes = world.sgr();
|
||||
// Two nodes and one edge: nodeLatE7, nodeLngE7, nodeEle, nodeFlags, nodeOsm and edgeA, each padded to 8.
|
||||
const edgeB = bodyStart(bytes) + 8 + 8 + 8 + 8 + 16 + 8;
|
||||
expect(bytes.readUInt32LE(edgeB)).toBe(1);
|
||||
const foreignNode = Buffer.from(bytes);
|
||||
foreignNode.writeUInt32LE(7, edgeB);
|
||||
expect(() => decodeSkiGraph(reseal(foreignNode))).toThrow('SGR1 edge 0 names a node that does not exist');
|
||||
// edgeGeomOff follows edgeB, edgeLen, edgeUp, edgeDown, edgeKind, the two use sections, edgeDiff, edgeGroom,
|
||||
// edgeLift, edgeLiftSec, edgeLiftCap, edgeFlags, edgeName, edgeRef and edgeWay, each of one edge, padded to 8.
|
||||
const geomOff = edgeB + 8 * 15 + 8;
|
||||
expect(bytes.readUInt32LE(geomOff + 4)).toBe(2);
|
||||
const onePoint = Buffer.from(bytes);
|
||||
onePoint.writeUInt32LE(1, geomOff + 4);
|
||||
expect(() => decodeSkiGraph(reseal(onePoint))).toThrow('SGR1 edge 0 has no line of its own');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the test world writer', () => {
|
||||
it('SKIGRAPH-020: writes what the reader reads back, names, heights and missing heights included', () => {
|
||||
const world = new SkiWorld();
|
||||
const top = world.node(0, 0, 2000);
|
||||
const valley = world.node(0, -1000, null);
|
||||
world.run(top, valley, 1, { name: 'Talabfahrt', ref: '3', via: [[100, -500]] });
|
||||
const graph = world.graph();
|
||||
expect(graph.header.nodeCount).toBe(2);
|
||||
expect(Array.from(graph.edgeGeomOff)).toEqual([0, 3]);
|
||||
expect(poolString(graph.strings, graph.edgeName[0] as number)).toBe('Talabfahrt');
|
||||
expect(poolString(graph.strings, graph.edgeRef[0] as number)).toBe('3');
|
||||
expect(graph.nodeEle[1]).toBe(-32768);
|
||||
const net = stitchGraphs([graph]);
|
||||
expect(net.nodeEle[0]).toBe(2000);
|
||||
expect(Number.isNaN(net.nodeEle[1])).toBe(true);
|
||||
expect(net.edgeName[0]).toBe('Talabfahrt');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Where the piste router runs (spec 7.2): the networks a thread keeps
|
||||
* decoded within its budget, the runner on the calling thread, the router
|
||||
* thread's own script, and the runner that talks to a thread, here with
|
||||
* small threads written by the test that answer, fail, hang or die. Also the
|
||||
* area's lines for the map (spec 10.4).
|
||||
*/
|
||||
import { networkFeatures, simplifyLine } from '../../../../src/nest/ski-routing/engine/network';
|
||||
import { stitchGraphs } from '../../../../src/nest/ski-routing/engine/stitch';
|
||||
import { Lift } from '../../../../src/nest/ski-routing/ski-graph.contract';
|
||||
import {
|
||||
InlineSkiRouterRunner,
|
||||
NetCache,
|
||||
NETWORK_BUDGET_BYTES,
|
||||
type SkiRouteJob,
|
||||
} from '../../../../src/nest/ski-routing/ski-router-runner';
|
||||
import { profileOf } from '../../../../src/nest/ski-routing/ski-routing.service';
|
||||
import {
|
||||
answerTimeoutMs,
|
||||
MAX_WAITING_JOBS,
|
||||
WorkerSkiRouterRunner,
|
||||
} from '../../../../src/nest/ski-routing/worker-router-runner';
|
||||
import { decodePolyline } from '../../../../src/utils/polyline';
|
||||
import { SkiWorld } from '../../../helpers/ski-world';
|
||||
import { skiRouteRequestSchema } from '@trek/shared';
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/** The router thread's port, as its script sees it: messages in, messages out. */
|
||||
const port = vi.hoisted(() => {
|
||||
const listeners: Array<(message: unknown) => void> = [];
|
||||
return {
|
||||
on: (event: string, listener: (message: unknown) => void) => {
|
||||
if (event === 'message') listeners.push(listener);
|
||||
},
|
||||
emit: (_event: 'message', message: unknown) => listeners.forEach((listener) => listener(message)),
|
||||
postMessage: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('node:worker_threads', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('node:worker_threads')>()),
|
||||
parentPort: port,
|
||||
}));
|
||||
|
||||
let dir: string;
|
||||
let file: string;
|
||||
const world = new SkiWorld();
|
||||
const valley = world.node(0, 0, 1200);
|
||||
const top = world.node(0, 2000, 2200);
|
||||
world.lift(valley, top, { name: 'Sonnenbahn', type: Lift.CHAIR_DETACHABLE, sec: 420 });
|
||||
world.run(top, valley, 1, { name: 'Talabfahrt', via: [[200, 1000]] });
|
||||
|
||||
const job = (files: string[]): SkiRouteJob => {
|
||||
const request = skiRouteRequestSchema.parse({
|
||||
legs: [{ from: world.at(0, 2000), to: world.at(0, 0) }],
|
||||
discipline: 'alpine',
|
||||
});
|
||||
return {
|
||||
kind: 'route',
|
||||
files,
|
||||
areaIds: ['w:1'],
|
||||
legs: [{ from: world.at(0, 2000), to: world.at(0, 0), continues: false }],
|
||||
profile: profileOf(request),
|
||||
alternatives: 0,
|
||||
};
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'trek-ski-runner-'));
|
||||
file = join(dir, 'w_1@aaaaaaaa.sgr.gz');
|
||||
await writeFile(file, world.gz());
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('the decoded networks and the runner on the calling thread', () => {
|
||||
it('SKIRUN-001: decodes a network once, drops the oldest past the budget, forgets on request', async () => {
|
||||
const cache = new NetCache();
|
||||
const first = await cache.get([file]);
|
||||
expect(await cache.get([file])).toBe(first);
|
||||
const copy = join(dir, 'w_1@bbbbbbbb.sgr.gz');
|
||||
await writeFile(copy, world.gz());
|
||||
// A budget of one byte keeps only the network loaded last.
|
||||
const small = new NetCache(1);
|
||||
const a = await small.get([file]);
|
||||
await small.get([copy]);
|
||||
await vi.waitFor(async () => expect(await small.get([file])).not.toBe(a));
|
||||
cache.forget(file);
|
||||
expect(await cache.get([file])).not.toBe(first);
|
||||
await expect(cache.get([join(dir, 'missing.sgr.gz')])).rejects.toThrow();
|
||||
cache.clear();
|
||||
expect(NETWORK_BUDGET_BYTES).toBe(256 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('SKIRUN-002: the inline runner plans a job and draws the lines of an area', async () => {
|
||||
const runner = new InlineSkiRouterRunner();
|
||||
const [leg] = await runner.run(job([file]));
|
||||
expect(leg).toMatchObject({ status: 'ok', area_ids: ['w:1'] });
|
||||
const lines = await runner.run({ kind: 'network', files: [file] });
|
||||
expect(lines.features.map((feature) => feature.kind).sort()).toEqual(['lift', 'run']);
|
||||
runner.forget(file);
|
||||
await expect(runner.close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('SKIROUTE-WORKER-001: the thread answers a job, forgets on request, answers a failure as a message', async () => {
|
||||
await import('../../../../src/nest/ski-routing/ski-router.worker');
|
||||
port.emit('message', { id: 1, job: job([file]) });
|
||||
await vi.waitFor(() =>
|
||||
expect(port.postMessage).toHaveBeenCalledWith({ id: 1, result: [expect.objectContaining({ status: 'ok' })] }),
|
||||
);
|
||||
port.emit('message', { id: 0, forget: file });
|
||||
port.emit('message', { id: 2 });
|
||||
port.emit('message', { id: 3, job: job([join(dir, 'nowhere.sgr.gz')]) });
|
||||
await vi.waitFor(() =>
|
||||
expect(port.postMessage).toHaveBeenCalledWith({ id: 3, error: expect.stringContaining('nowhere.sgr.gz') }),
|
||||
);
|
||||
expect(port.postMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the lines of an area for the map', () => {
|
||||
it('SKIRUN-010: runs and lifts as lines with names, grades, types and one-way; links and stations left out', () => {
|
||||
const net = stitchGraphs([world.graph()]);
|
||||
const { features, simplified_m } = networkFeatures(net);
|
||||
expect(simplified_m).toBe(5);
|
||||
const run = features.find((feature) => feature.kind === 'run');
|
||||
expect(run).toMatchObject({ name: 'Talabfahrt', difficulty: 'easy', lift_type: null, oneway: true });
|
||||
const lift = features.find((feature) => feature.kind === 'lift');
|
||||
expect(lift).toMatchObject({ name: 'Sonnenbahn', lift_type: 'chair_detachable', oneway: true, difficulty: null });
|
||||
const line = decodePolyline(run?.line ?? '', 5);
|
||||
expect(line).toHaveLength(3);
|
||||
expect(line[0]?.[0]).toBeCloseTo(world.at(0, 2000).lat, 4);
|
||||
expect(features).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('SKIRUN-011: drops the points within the tolerance of the line, the ends always kept', () => {
|
||||
const straight: Array<[number, number]> = [
|
||||
[46, 7],
|
||||
[46.0005, 7.00001],
|
||||
[46.001, 7],
|
||||
];
|
||||
expect(simplifyLine(straight, 5)).toEqual([
|
||||
[46, 7],
|
||||
[46.001, 7],
|
||||
]);
|
||||
const bent: Array<[number, number]> = [
|
||||
[46, 7],
|
||||
[46.0005, 7.001],
|
||||
[46.001, 7],
|
||||
];
|
||||
expect(simplifyLine(bent, 5)).toHaveLength(3);
|
||||
expect(simplifyLine([[46, 7]], 5)).toEqual([[46, 7]]);
|
||||
});
|
||||
});
|
||||
|
||||
/** A thread script the runner can start: plain JS, so it runs without the server's loader. */
|
||||
async function script(name: string, body: string): Promise<string> {
|
||||
const path = join(dir, `${name}.cjs`);
|
||||
await writeFile(path, `const { parentPort } = require('node:worker_threads');\n${body}\n`);
|
||||
return path;
|
||||
}
|
||||
|
||||
describe('WorkerSkiRouterRunner', () => {
|
||||
it('SKIRUN-020: hands a job to its thread and answers its result; a failure the thread reports rejects', async () => {
|
||||
const entry = await script(
|
||||
'answers',
|
||||
`parentPort.on('message', (m) => {
|
||||
if (m.forget) return;
|
||||
if (m.job.files[0] === 'bad') parentPort.postMessage({ id: m.id, error: 'graph broken' });
|
||||
else if (m.job.files[0] === 'silent') parentPort.postMessage({ id: m.id });
|
||||
else parentPort.postMessage({ id: m.id, result: [{ status: 'failed', code: 'disconnected' }] });
|
||||
});`,
|
||||
);
|
||||
const runner = new WorkerSkiRouterRunner(entry);
|
||||
await expect(runner.run(job(['good']))).resolves.toEqual([{ status: 'failed', code: 'disconnected' }]);
|
||||
await expect(runner.run(job(['bad']))).rejects.toThrow('graph broken');
|
||||
await expect(runner.run(job(['silent']))).rejects.toThrow('router failed');
|
||||
runner.forget('good');
|
||||
await runner.close();
|
||||
});
|
||||
|
||||
it('SKIRUN-021: a thread that dies fails what it held, and the next job gets a fresh thread', async () => {
|
||||
const entry = await script(
|
||||
'dies',
|
||||
`parentPort.on('message', (m) => {
|
||||
if (m.job.files[0] === 'crash') process.exit(3);
|
||||
parentPort.postMessage({ id: m.id, result: [] });
|
||||
});`,
|
||||
);
|
||||
const runner = new WorkerSkiRouterRunner(entry);
|
||||
await expect(runner.run(job(['crash']))).rejects.toThrow('router thread ended');
|
||||
await expect(runner.run(job(['fine']))).resolves.toEqual([]);
|
||||
await runner.close();
|
||||
});
|
||||
|
||||
it('SKIRUN-022: a thread that throws fails its jobs; closing fails what is still waiting', async () => {
|
||||
const entry = await script(
|
||||
'throws',
|
||||
`parentPort.on('message', (m) => {
|
||||
if (m.job.files[0] === 'throw') setTimeout(() => { throw new Error('boom'); });
|
||||
});`,
|
||||
);
|
||||
const runner = new WorkerSkiRouterRunner(entry);
|
||||
await expect(runner.run(job(['throw']))).rejects.toThrow('boom');
|
||||
const waiting = runner.run(job(['never'])).catch((err: Error) => err.message);
|
||||
await runner.close();
|
||||
expect(await waiting).toBe('closed');
|
||||
});
|
||||
|
||||
it('SKIRUN-023: a job left unanswered times out, the thread is started over; a full queue is busy', async () => {
|
||||
const entry = await script('hangs', `parentPort.on('message', () => undefined);`);
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
try {
|
||||
const runner = new WorkerSkiRouterRunner(entry);
|
||||
const waiting = Array.from({ length: MAX_WAITING_JOBS }, () =>
|
||||
runner.run(job(['hang'])).catch((err: Error) => err.message),
|
||||
);
|
||||
await expect(runner.run(job(['hang']))).rejects.toThrow('busy');
|
||||
await vi.advanceTimersByTimeAsync(answerTimeoutMs(job(['hang'])));
|
||||
expect(await Promise.all(waiting)).toEqual(Array.from({ length: MAX_WAITING_JOBS }, () => 'timeout'));
|
||||
await runner.close();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('SKIRUN-024: a job of many legs waits longer for its answer, never beyond a minute', () => {
|
||||
const one = job(['x']);
|
||||
expect(answerTimeoutMs(one)).toBe(8000);
|
||||
expect(answerTimeoutMs({ ...one, legs: [...one.legs, ...one.legs, ...one.legs] })).toBe(16_000);
|
||||
const leg = one.legs[0] as SkiRouteJob['legs'][number];
|
||||
expect(answerTimeoutMs({ ...one, legs: Array.from({ length: 40 }, () => leg) })).toBe(60_000);
|
||||
expect(answerTimeoutMs({ kind: 'network', files: ['x'] })).toBe(8000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* The REST routes and the MCP tool of the piste router over a stub service:
|
||||
* the routes' guards, statuses and contracts, the tool's gate, scope and
|
||||
* input, and that both hand the same request to the same service. The answers
|
||||
* through the real guards are pinned in the e2e suite.
|
||||
*/
|
||||
import { ADDON_IDS } from '../../../../src/addons';
|
||||
import type { McpContext, McpTextResult, ToolOptions } from '../../../../src/nest-mcp';
|
||||
import { getEntry, type ClassRef } from '../../../../src/nest-mcp/metadata';
|
||||
import { AddonGuard } from '../../../../src/nest/addons/addon.guard';
|
||||
import type { AddonsService } from '../../../../src/nest/addons/addons.service';
|
||||
import { REQUIRE_ADDON } from '../../../../src/nest/addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../../../../src/nest/auth-core/jwt-auth.guard';
|
||||
import { RESPONSE_CONTRACT_KEY } from '../../../../src/nest/common/response-contract';
|
||||
import { TripAccessGuard } from '../../../../src/nest/permissions/trip-access.guard';
|
||||
import { SkiRoutingController } from '../../../../src/nest/ski-routing/ski-routing.controller';
|
||||
import { SkiRoutingMcp } from '../../../../src/nest/ski-routing/ski-routing.mcp';
|
||||
import type { SkiRoutingService } from '../../../../src/nest/ski-routing/ski-routing.service';
|
||||
import type { TripAccessService } from '../../../../src/nest/trip-membership/trip-access.service';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import { GUARDS_METADATA, HTTP_CODE_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
||||
import {
|
||||
skiAreaNearResponseSchema,
|
||||
skiNetworkResponseSchema,
|
||||
skiRouteRequestSchema,
|
||||
skiRouteResponseSchema,
|
||||
type SkiRouteResponse,
|
||||
} from '@trek/shared';
|
||||
|
||||
import type { Response } from 'express';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
const A = { lat: 45.98, lng: 7.75 };
|
||||
const B = { lat: 45.97, lng: 7.74 };
|
||||
|
||||
const ANSWER: SkiRouteResponse = {
|
||||
build: '2026-11-02',
|
||||
stale: false,
|
||||
area_names: ['Testalp'],
|
||||
colour_scheme: 'europe',
|
||||
attribution: '© OpenStreetMap contributors, ODbL. Terrain: Mapzen Terrarium',
|
||||
legs: [
|
||||
{
|
||||
status: 'ok',
|
||||
geometry: [
|
||||
[A.lat, A.lng, 2500],
|
||||
[B.lat, B.lng, 1600],
|
||||
],
|
||||
segments: [
|
||||
{
|
||||
kind: 'run',
|
||||
from: 0,
|
||||
to: 1,
|
||||
name: 'Rote Eins',
|
||||
ref: '1',
|
||||
difficulty: 'intermediate',
|
||||
lift_type: null,
|
||||
grooming: [],
|
||||
seconds: 300,
|
||||
wait_seconds: 0,
|
||||
length_m: 2500,
|
||||
flags: [],
|
||||
osm_way: 12,
|
||||
},
|
||||
],
|
||||
distance_m: 2500,
|
||||
climb_m: 0,
|
||||
descent_m: 900,
|
||||
seconds: { ski: 300, lift: 0, wait: 0, walk: 0 },
|
||||
snapped_m: { from: 3, to: 0 },
|
||||
area_ids: ['w:1'],
|
||||
why: [],
|
||||
alternatives: [],
|
||||
},
|
||||
{ status: 'failed', code: 'no_area', at: B, hint: null, detail: null },
|
||||
],
|
||||
};
|
||||
|
||||
function service() {
|
||||
return {
|
||||
route: vi.fn<SkiRoutingService['route']>().mockResolvedValue(ANSWER),
|
||||
areaNear: vi.fn<SkiRoutingService['areaNear']>().mockResolvedValue({ area: null }),
|
||||
network: vi.fn<SkiRoutingService['network']>().mockResolvedValue({
|
||||
area_id: 'w:1',
|
||||
build: '2026-11-02',
|
||||
colour_scheme: 'europe',
|
||||
simplified_m: 5,
|
||||
features: [],
|
||||
sha256: 'a'.repeat(64),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SkiRoutingController', () => {
|
||||
it('SKICTL-001: the routes sit behind Tours, the session and the trip, each on its contract', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, SkiRoutingController)).toBe('api/trips/:tripId/ski');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, SkiRoutingController)).toEqual([
|
||||
AddonGuard,
|
||||
JwtAuthGuard,
|
||||
TripAccessGuard,
|
||||
]);
|
||||
expect(Reflect.getMetadata(REQUIRE_ADDON, SkiRoutingController)).toEqual({ addonId: 'tours', label: 'Tours' });
|
||||
const { route, areaNear, network } = SkiRoutingController.prototype;
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, route)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, route)).toBe('route');
|
||||
expect(Reflect.getMetadata(HTTP_CODE_METADATA, route)).toBe(200);
|
||||
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, route)).toBe(skiRouteResponseSchema);
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, areaNear)).toBe(RequestMethod.GET);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, areaNear)).toBe('area-near');
|
||||
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, areaNear)).toBe(skiAreaNearResponseSchema);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, network)).toBe('network/:areaId');
|
||||
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, network)).toBe(skiNetworkResponseSchema);
|
||||
});
|
||||
|
||||
it('SKICTL-002: hands the parsed request to the service and answers what it planned', async () => {
|
||||
const routing = service();
|
||||
const controller = new SkiRoutingController(routing as unknown as SkiRoutingService);
|
||||
const body = skiRouteRequestSchema.parse({ legs: [{ from: A, to: B }], discipline: 'snowboard' });
|
||||
await expect(controller.route(body)).resolves.toBe(ANSWER);
|
||||
expect(routing.route).toHaveBeenCalledWith(body);
|
||||
await expect(controller.areaNear({ lat: 46, lng: 7 })).resolves.toEqual({ area: null });
|
||||
expect(routing.areaNear).toHaveBeenCalledWith(46, 7);
|
||||
});
|
||||
|
||||
it('SKICTL-003: the lines of an area go out tagged with its checksum, the checksum not in the body', async () => {
|
||||
const routing = service();
|
||||
const controller = new SkiRoutingController(routing as unknown as SkiRoutingService);
|
||||
const setHeader = vi.fn();
|
||||
const body = await controller.network('w:1', { setHeader } as unknown as Response);
|
||||
expect(body).toEqual({
|
||||
area_id: 'w:1',
|
||||
build: '2026-11-02',
|
||||
colour_scheme: 'europe',
|
||||
simplified_m: 5,
|
||||
features: [],
|
||||
});
|
||||
expect(setHeader).toHaveBeenCalledWith('ETag', `"${'a'.repeat(64)}"`);
|
||||
expect(setHeader).toHaveBeenCalledWith('Cache-Control', 'private, max-age=3600');
|
||||
});
|
||||
});
|
||||
|
||||
const ctx = { userId: 7, scopes: null, isStaticToken: false } as McpContext;
|
||||
|
||||
function toolOptions(): ToolOptions {
|
||||
const entry = getEntry(SkiRoutingMcp as unknown as ClassRef, 'planSkiRoute');
|
||||
if (!entry || entry.kind !== 'tool') throw new Error('plan_ski_route is not registered');
|
||||
return entry.options;
|
||||
}
|
||||
|
||||
function makeMcp(accessible: boolean) {
|
||||
const routing = service();
|
||||
const access = { findAccessible: vi.fn().mockResolvedValue(accessible ? { id: 3 } : null) };
|
||||
const mcp = new SkiRoutingMcp(
|
||||
routing as unknown as SkiRoutingService,
|
||||
access as unknown as TripAccessService,
|
||||
{} as AddonsService,
|
||||
);
|
||||
return { mcp, routing, access };
|
||||
}
|
||||
|
||||
describe('SkiRoutingMcp', () => {
|
||||
it('SKIMCP-001: plan_ski_route is read only, gated on Tours and scoped to reading trips', () => {
|
||||
const options = toolOptions();
|
||||
expect(options.name).toBe('plan_ski_route');
|
||||
expect(options.annotations?.readOnlyHint).toBe(true);
|
||||
expect(options.access).toEqual({ group: 'trips', mode: 'read' });
|
||||
const isAddonEnabled = vi.fn((id: string) => id === ADDON_IDS.TOURS);
|
||||
const self = { addons: { isAddonEnabled } } as unknown as SkiRoutingMcp;
|
||||
expect(options.when?.(ctx, self)).toBe(true);
|
||||
expect(isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.TOURS);
|
||||
});
|
||||
|
||||
it('SKIMCP-002: takes the REST contract plus the trip, with the same defaults and limits', () => {
|
||||
const input = z.object(toolOptions().inputSchema as z.ZodRawShape);
|
||||
expect(input.safeParse({ tripId: 3, legs: [{ from: A, to: B }], discipline: 'alpine' }).success).toBe(true);
|
||||
expect(input.safeParse({ tripId: 3, legs: [], discipline: 'alpine' }).success).toBe(false);
|
||||
expect(input.safeParse({ tripId: 3, legs: [{ from: A, to: B }] }).success).toBe(false);
|
||||
const three = { tripId: 3, legs: [{ from: A, to: B }], discipline: 'alpine', alternatives: 3 };
|
||||
expect(input.safeParse(three).success).toBe(false);
|
||||
expect(input.safeParse({ legs: [{ from: A, to: B }], discipline: 'alpine' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('SKIMCP-003: plans for a trip the caller sees, the same request as REST, legs without their points', async () => {
|
||||
const { mcp, routing, access } = makeMcp(true);
|
||||
const asked = { tripId: 3, legs: [{ from: A, to: B }], discipline: 'alpine' as const };
|
||||
const result = (await mcp.planSkiRoute(asked, ctx)) as McpTextResult;
|
||||
expect(access.findAccessible).toHaveBeenCalledWith(3, 7);
|
||||
const { tripId: _trip, ...asRest } = asked;
|
||||
expect(routing.route).toHaveBeenCalledWith(skiRouteRequestSchema.parse(asRest));
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(JSON.parse(result.content[0].text)).toEqual({
|
||||
build: '2026-11-02',
|
||||
stale: false,
|
||||
area_names: ['Testalp'],
|
||||
colour_scheme: 'europe',
|
||||
attribution: '© OpenStreetMap contributors, ODbL. Terrain: Mapzen Terrarium',
|
||||
legs: [
|
||||
{
|
||||
status: 'ok',
|
||||
distance_m: 2500,
|
||||
climb_m: 0,
|
||||
descent_m: 900,
|
||||
seconds: { ski: 300, lift: 0, wait: 0, walk: 0 },
|
||||
snapped_m: { from: 3, to: 0 },
|
||||
points: 2,
|
||||
segments: [
|
||||
{
|
||||
kind: 'run',
|
||||
name: 'Rote Eins',
|
||||
ref: '1',
|
||||
difficulty: 'intermediate',
|
||||
lift_type: null,
|
||||
seconds: 300,
|
||||
wait_seconds: 0,
|
||||
length_m: 2500,
|
||||
flags: [],
|
||||
},
|
||||
],
|
||||
why: [],
|
||||
alternatives: 0,
|
||||
},
|
||||
{ status: 'failed', code: 'no_area', at: B, hint: null, detail: null },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('SKIMCP-004: a trip the caller cannot see is refused before anything is planned', async () => {
|
||||
const { mcp, routing } = makeMcp(false);
|
||||
const asked = { tripId: 9, legs: [{ from: A, to: B }], discipline: 'alpine' as const };
|
||||
const result = (await mcp.planSkiRoute(asked, ctx)) as McpTextResult;
|
||||
expect(result.isError).toBe(true);
|
||||
expect(routing.route).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* The piste router service (spec 7, 11): what it answers when TREK Places or
|
||||
* the admin switch is off, while an index or an area is still on its way, on
|
||||
* an area kept from an older build, when the router's thread is busy or slow,
|
||||
* and with every leg planned on the areas that hold its ends. The Places host
|
||||
* is a stub; the areas are kept in a temp folder and planned on the calling
|
||||
* thread.
|
||||
*/
|
||||
import { AddonsService } from '../../../../src/nest/addons/addons.service';
|
||||
import { DataPathsService } from '../../../../src/nest/app-config/data-paths.service';
|
||||
import { DomainError } from '../../../../src/nest/common/domain-error';
|
||||
import { TrekPlacesWinterService } from '../../../../src/nest/maps/trek-places-winter.service';
|
||||
import { SkiAreasService } from '../../../../src/nest/ski-routing/ski-areas.service';
|
||||
import { InlineSkiRouterRunner, SKI_ROUTER_RUNNER } from '../../../../src/nest/ski-routing/ski-router-runner';
|
||||
import {
|
||||
LOADING_MS,
|
||||
profileOf,
|
||||
settleWithin,
|
||||
SkiRoutingService,
|
||||
} from '../../../../src/nest/ski-routing/ski-routing.service';
|
||||
import { SkiWorld } from '../../../helpers/ski-world';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { skiRouteRequestSchema, skiRouteResponseSchema, type SkiRouteInput, type WinterIndexRow } from '@trek/shared';
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
const sha = (bytes: Buffer | string) => createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
/** Testalp, a run and a lift; Nachbaralp 20 km east, a run of its own. */
|
||||
const testalp = new SkiWorld({ lat: 46, lng: 7 }, { areaId: 'w:1', build: '2026-11-02', name: 'Testalp' });
|
||||
const valley = testalp.node(0, 0, 1200);
|
||||
const top = testalp.node(0, 2000, 2200);
|
||||
testalp.lift(valley, top, { name: 'Sonnenbahn', sec: 420 });
|
||||
testalp.run(top, valley, 1, { name: 'Talabfahrt', via: [[300, 1000]] });
|
||||
const nachbar = new SkiWorld({ lat: 46, lng: 7.26 }, { areaId: 'w:2', build: '2026-11-02', name: 'Nachbaralp' });
|
||||
nachbar.run(nachbar.node(0, 1000, 1800), nachbar.node(0, 0, 1300), 1, { name: 'Nachbarpiste' });
|
||||
|
||||
function rowOf(world: SkiWorld, id: string, name: string): WinterIndexRow {
|
||||
const gz = world.gz();
|
||||
const south = world.at(-500, -500);
|
||||
const north = world.at(500, 2500);
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
kind: 'resort',
|
||||
country: null,
|
||||
colourScheme: 'europe',
|
||||
bbox: [south.lng, south.lat, north.lng, north.lat],
|
||||
counts: { runsKm: 2, nordicKm: 0, lifts: 1, skitourKm: 0, hikeKm: 0 },
|
||||
bytes: gz.length,
|
||||
sha256: sha(gz),
|
||||
};
|
||||
}
|
||||
|
||||
const ROWS = [rowOf(testalp, 'w:1', 'Testalp'), rowOf(nachbar, 'w:2', 'Nachbaralp')];
|
||||
const GZ: Record<string, Buffer> = { 'w:1': testalp.gz(), 'w:2': nachbar.gz() };
|
||||
|
||||
let dir: string;
|
||||
const winter = { enabled: vi.fn(), meta: vi.fn(), index: vi.fn(), area: vi.fn() };
|
||||
const addons = { getToursSkiRouting: vi.fn() };
|
||||
const runner = { run: vi.fn(), forget: vi.fn(), close: vi.fn() };
|
||||
|
||||
async function build(inline = true) {
|
||||
const inlineRunner = new InlineSkiRouterRunner();
|
||||
runner.run.mockImplementation((job) => inlineRunner.run(job));
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
SkiRoutingService,
|
||||
SkiAreasService,
|
||||
{ provide: DataPathsService, useValue: { dataDir: dir } },
|
||||
{ provide: TrekPlacesWinterService, useValue: winter },
|
||||
{ provide: AddonsService, useValue: addons },
|
||||
{ provide: SKI_ROUTER_RUNNER, useValue: inline ? inlineRunner : runner },
|
||||
],
|
||||
}).compile();
|
||||
return { routing: moduleRef.get(SkiRoutingService), areas: moduleRef.get(SkiAreasService) };
|
||||
}
|
||||
|
||||
const request = (input: Partial<SkiRouteInput> & Pick<SkiRouteInput, 'legs'>) =>
|
||||
skiRouteRequestSchema.parse({ discipline: 'alpine', ...input });
|
||||
|
||||
const down = { from: testalp.at(0, 2000), to: testalp.at(0, 0) };
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'trek-ski-routing-'));
|
||||
const json = Buffer.from(JSON.stringify({ build: '2026-11-02', format: 1, areas: ROWS }));
|
||||
const meta = {
|
||||
build: '2026-11-02',
|
||||
format: 1,
|
||||
areas: ROWS.length,
|
||||
indexSha: sha(json),
|
||||
sourceTimestamp: null,
|
||||
attribution: { data: '© OpenStreetMap contributors, ODbL', terrain: 'Terrain: Mapzen Terrarium' },
|
||||
};
|
||||
winter.enabled.mockReset().mockReturnValue(true);
|
||||
winter.meta.mockReset().mockResolvedValue(meta);
|
||||
winter.index.mockReset().mockResolvedValue({
|
||||
status: 'ok',
|
||||
index: { build: '2026-11-02', format: 1, areas: ROWS },
|
||||
sha256: meta.indexSha,
|
||||
dropped: 0,
|
||||
});
|
||||
winter.area.mockReset().mockImplementation(async (id: string) => ({ status: 'ok', bytes: GZ[id], etag: null }));
|
||||
addons.getToursSkiRouting.mockReset().mockResolvedValue({ enabled: true });
|
||||
runner.run.mockReset();
|
||||
runner.close.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('when nothing can be planned here', () => {
|
||||
it('SKISVC-001: TREK Places switched off: every leg drawn freely, nothing asked of the host', async () => {
|
||||
winter.enabled.mockReturnValue(false);
|
||||
const { routing } = await build();
|
||||
const answer = await routing.route(request({ legs: [down, down] }));
|
||||
expect(answer.legs).toEqual([
|
||||
{ status: 'failed', code: 'places_unavailable', at: null, hint: { free_draw: true }, detail: null },
|
||||
{ status: 'failed', code: 'places_unavailable', at: null, hint: { free_draw: true }, detail: null },
|
||||
]);
|
||||
expect(answer).toMatchObject({ build: null, stale: false, area_names: [] });
|
||||
expect(winter.meta).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SKISVC-002: the admin switched piste routing off: disabled, drawn freely', async () => {
|
||||
addons.getToursSkiRouting.mockResolvedValue({ enabled: false });
|
||||
const { routing } = await build();
|
||||
const answer = await routing.route(request({ legs: [down] }));
|
||||
expect(answer.legs[0]).toMatchObject({ status: 'failed', code: 'disabled', hint: { free_draw: true } });
|
||||
expect(skiRouteResponseSchema.parse(answer)).toEqual(answer);
|
||||
});
|
||||
|
||||
it('SKISVC-003: no index to be had: places unavailable; an index slower than the wait: loading', async () => {
|
||||
winter.meta.mockRejectedValue(new Error('TREK Places API 503'));
|
||||
const { routing } = await build();
|
||||
expect((await routing.route(request({ legs: [down] }))).legs[0]).toMatchObject({ code: 'places_unavailable' });
|
||||
|
||||
winter.meta.mockImplementation(() => new Promise(() => undefined));
|
||||
const slow = await build();
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
const pending = slow.routing.route(request({ legs: [down] }));
|
||||
await vi.waitFor(() => expect(vi.getTimerCount()).toBe(1));
|
||||
await vi.advanceTimersByTimeAsync(LOADING_MS);
|
||||
expect((await pending).legs[0]).toMatchObject({ status: 'failed', code: 'loading', hint: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('legs on the areas that hold them', () => {
|
||||
it('SKISVC-010: plans a leg on its area, with the build, the area name and the attribution', async () => {
|
||||
const { routing } = await build();
|
||||
const answer = await routing.route(request({ legs: [down] }));
|
||||
expect(answer.legs[0]).toMatchObject({ status: 'ok', area_ids: ['w:1'] });
|
||||
expect(answer).toMatchObject({
|
||||
build: '2026-11-02',
|
||||
stale: false,
|
||||
area_names: ['Testalp'],
|
||||
attribution: '© OpenStreetMap contributors, ODbL. Terrain: Mapzen Terrarium',
|
||||
});
|
||||
expect(skiRouteResponseSchema.parse(answer)).toEqual(answer);
|
||||
expect(winter.area).toHaveBeenCalledWith('w:1', null);
|
||||
});
|
||||
|
||||
it('SKISVC-011: answers each leg on its own: off every area, across two areas, on the next area', async () => {
|
||||
const { routing } = await build();
|
||||
const lowland = { lat: 53.5, lng: 10 };
|
||||
const answer = await routing.route(
|
||||
request({
|
||||
legs: [
|
||||
{ from: lowland, to: testalp.at(0, 0) },
|
||||
{ from: testalp.at(0, 2000), to: nachbar.at(0, 0) },
|
||||
{ from: nachbar.at(0, 1000), to: nachbar.at(0, 0) },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(answer.legs[0]).toEqual({ status: 'failed', code: 'no_area', at: lowland, hint: null, detail: null });
|
||||
expect(answer.legs[1]).toMatchObject({ status: 'failed', code: 'cross_area' });
|
||||
expect(answer.legs[2]).toMatchObject({ status: 'ok', area_ids: ['w:2'] });
|
||||
expect(answer.area_names).toEqual(['Nachbaralp']);
|
||||
});
|
||||
|
||||
it('SKISVC-012: a leg goes on from the leg before it on the same area', async () => {
|
||||
const { routing } = await build();
|
||||
const answer = await routing.route(
|
||||
request({
|
||||
legs: [
|
||||
{ from: testalp.at(0, 0), to: testalp.at(0, 2000) },
|
||||
{ from: testalp.at(0, 2000), to: testalp.at(0, 0) },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(answer.legs.map((leg) => leg.status)).toEqual(['ok', 'ok']);
|
||||
const [up, run] = answer.legs;
|
||||
if (up?.status !== 'ok' || run?.status !== 'ok') throw new Error('not planned');
|
||||
expect(up.segments.map((s) => s.kind)).toEqual(['lift']);
|
||||
expect(run.segments.map((s) => s.name)).toEqual(['Talabfahrt']);
|
||||
});
|
||||
|
||||
it('SKISVC-013: an area slower than the wait: loading, named; one that cannot be had: drawn freely', async () => {
|
||||
winter.area.mockImplementation(() => new Promise(() => undefined));
|
||||
const { routing, areas } = await build();
|
||||
// The index is here already; only the area is on its way.
|
||||
await areas.index();
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
const pending = routing.route(request({ legs: [down] }));
|
||||
await vi.waitFor(() => expect(vi.getTimerCount()).toBe(1));
|
||||
await vi.advanceTimersByTimeAsync(LOADING_MS);
|
||||
expect((await pending).legs[0]).toMatchObject({ code: 'loading', detail: { name: 'Testalp' } });
|
||||
vi.useRealTimers();
|
||||
|
||||
winter.area.mockRejectedValue(Object.assign(new Error('TREK Places API 503'), { status: 503 }));
|
||||
const fresh = await build();
|
||||
expect((await fresh.routing.route(request({ legs: [down] }))).legs[0]).toMatchObject({
|
||||
code: 'places_unavailable',
|
||||
hint: { free_draw: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('SKISVC-014: plans on an older copy of the area while the host is down, and says so', async () => {
|
||||
const { routing, areas } = await build();
|
||||
await routing.route(request({ legs: [down] }));
|
||||
const index = await areas.index();
|
||||
const kept = index?.areas[0];
|
||||
if (!kept) throw new Error('no index');
|
||||
kept.row = { ...kept.row, sha256: sha('a newer build') };
|
||||
winter.area.mockRejectedValue(Object.assign(new Error('TREK Places API 503'), { status: 503 }));
|
||||
const answer = await routing.route(request({ legs: [down] }));
|
||||
expect(answer.legs[0]).toMatchObject({ status: 'ok' });
|
||||
expect(answer.stale).toBe(true);
|
||||
});
|
||||
|
||||
it('SKISVC-015: a busy or slow thread answers busy or timeout; a graph it cannot read, drawn freely', async () => {
|
||||
const { routing } = await build(false);
|
||||
runner.run.mockRejectedValueOnce(new Error('busy'));
|
||||
expect((await routing.route(request({ legs: [down] }))).legs[0]).toMatchObject({ code: 'busy', hint: null });
|
||||
runner.run.mockRejectedValueOnce(new Error('timeout'));
|
||||
expect((await routing.route(request({ legs: [down] }))).legs[0]).toMatchObject({ code: 'timeout' });
|
||||
runner.run.mockRejectedValueOnce(new Error('SGR1 format 2 is not 1'));
|
||||
expect((await routing.route(request({ legs: [down] }))).legs[0]).toMatchObject({
|
||||
code: 'places_unavailable',
|
||||
hint: { free_draw: true },
|
||||
});
|
||||
await routing.onApplicationShutdown();
|
||||
expect(runner.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the area at a point and its lines', () => {
|
||||
it('SKISVC-020: the area at a point, its download started, ready once here; none off every area', async () => {
|
||||
const { routing } = await build();
|
||||
const near = await routing.areaNear(46.005, 7.001);
|
||||
expect(near.area).toMatchObject({ id: 'w:1', name: 'Testalp', kind: 'resort', colour_scheme: 'europe' });
|
||||
expect(['loading', 'ready']).toContain(near.area?.state);
|
||||
await vi.waitFor(async () => expect((await routing.areaNear(46.005, 7.001)).area?.state).toBe('ready'));
|
||||
expect(await routing.areaNear(53.5, 10)).toEqual({ area: null });
|
||||
addons.getToursSkiRouting.mockResolvedValue({ enabled: false });
|
||||
expect((await routing.areaNear(46.005, 7.001)).area?.state).toBe('unavailable');
|
||||
// TREK Places off: the area kept from before is named, but nothing is fetched for it.
|
||||
winter.enabled.mockReturnValue(false);
|
||||
const off = await build();
|
||||
expect((await off.routing.areaNear(46.005, 7.001)).area?.state).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('SKISVC-021: an area lines for the map with its checksum; an unknown one 404, none to be had 503', async () => {
|
||||
const { routing } = await build();
|
||||
const lines = await routing.network('w:1');
|
||||
expect(lines).toMatchObject({ area_id: 'w:1', build: '2026-11-02', colour_scheme: 'europe', simplified_m: 5 });
|
||||
expect(lines.sha256).toBe(ROWS[0]?.sha256);
|
||||
expect(lines.features.map((feature) => feature.name).sort()).toEqual(['Sonnenbahn', 'Talabfahrt']);
|
||||
await expect(routing.network('w:999')).rejects.toMatchObject({ status: 404 });
|
||||
winter.area.mockRejectedValue(Object.assign(new Error('TREK Places API 503'), { status: 503 }));
|
||||
await expect(routing.network('w:2')).rejects.toBeInstanceOf(DomainError);
|
||||
addons.getToursSkiRouting.mockResolvedValue({ enabled: false });
|
||||
await expect(routing.network('w:1')).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
it('SKISVC-030: settleWithin tells done, failed and pending, and leaves no failure unhandled', async () => {
|
||||
expect(await settleWithin(Promise.resolve(4), 10)).toEqual({ status: 'done', value: 4 });
|
||||
const error = new Error('no');
|
||||
expect(await settleWithin(Promise.reject(error), 10)).toEqual({ status: 'failed', error });
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
let fail: (err: Error) => void = () => undefined;
|
||||
const late = new Promise<number>((_resolve, reject) => (fail = reject));
|
||||
const settled = settleWithin(late, 50);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(await settled).toEqual({ status: 'pending' });
|
||||
fail(new Error('later'));
|
||||
});
|
||||
|
||||
it('SKISVC-031: a technique only for cross-country, either when none was named, lifts by discipline', () => {
|
||||
const legs = [down];
|
||||
expect(profileOf(request({ legs, technique: 'classic' })).technique).toBeNull();
|
||||
expect(profileOf(request({ legs, discipline: 'cross_country' })).technique).toBe('unknown');
|
||||
expect(profileOf(request({ legs, discipline: 'cross_country', technique: 'skating' })).technique).toBe('skating');
|
||||
expect(profileOf(request({ legs })).useLifts).toBe(true);
|
||||
expect(profileOf(request({ legs, discipline: 'touring' })).useLifts).toBe(false);
|
||||
expect(profileOf(request({ legs, options: { groomed_only: true } })).options.groomed_only).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* The winter layer of the TREK Places client (spec 7.2, 3.5): what it asks
|
||||
* (never a coordinate), its caps and timeouts, the ETag it holds, the answers
|
||||
* it refuses, and the operator's opt-out and the circuit breaker it shares
|
||||
* with the place search. fetch is stubbed; the service is not reached.
|
||||
*/
|
||||
import { TrekPlacesWinterService } from '../../../src/nest/maps/trek-places-winter.service';
|
||||
import {
|
||||
resetTrekPlacesBreaker,
|
||||
trekPlacesWinterArea,
|
||||
trekPlacesWinterIndex,
|
||||
trekPlacesWinterMeta,
|
||||
} from '../../../src/nest/maps/trek-places.client';
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { env } = vi.hoisted(() => ({
|
||||
env: { maps: { trekPlacesUrl: 'https://places.example.net', trekPlacesEnabled: true } },
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/app-config', () => ({
|
||||
readEnv: () => env,
|
||||
getAppUrl: () => 'https://trip.example.org',
|
||||
}));
|
||||
|
||||
interface Call {
|
||||
url: string;
|
||||
init: RequestInit & { headers: Record<string, string> };
|
||||
}
|
||||
let calls: Call[] = [];
|
||||
|
||||
/** One answer of the service: a status, a body as bytes (streamed or not) and its headers. */
|
||||
function answer(status: number, body: Buffer | string = '', headers: Record<string, string> = {}, stream = true) {
|
||||
const bytes = typeof body === 'string' ? Buffer.from(body) : body;
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: (name: string) => headers[name.toLowerCase()] ?? null },
|
||||
body: stream
|
||||
? new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(bytes));
|
||||
controller.close();
|
||||
},
|
||||
})
|
||||
: null,
|
||||
arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function stub(...answers: Response[]) {
|
||||
calls = [];
|
||||
const queue = [...answers];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: URL | string, init: Call['init']) => {
|
||||
calls.push({ url: String(url), init });
|
||||
const next = queue.shift();
|
||||
if (!next) throw new Error('no answer left');
|
||||
return next;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const SHA = 'c'.repeat(64);
|
||||
const META = {
|
||||
build: '2026-11-02',
|
||||
format: 1,
|
||||
areas: 1,
|
||||
indexSha: SHA,
|
||||
sourceTimestamp: '2026-11-01T00:00:00Z',
|
||||
attribution: { data: '© OpenStreetMap contributors, ODbL', terrain: 'Terrain: Mapzen Terrarium', note: 'x' },
|
||||
};
|
||||
const ROW = {
|
||||
id: 'w:100',
|
||||
name: 'Sonnenbahn',
|
||||
kind: 'resort',
|
||||
country: null,
|
||||
colourScheme: 'europe',
|
||||
bbox: [7.0, 45.999, 7.005, 46.0052],
|
||||
counts: { runsKm: 1.19, nordicKm: 0.53, lifts: 1, skitourKm: 0, hikeKm: 0 },
|
||||
bytes: 900,
|
||||
sha256: SHA,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetTrekPlacesBreaker();
|
||||
env.maps.trekPlacesEnabled = true;
|
||||
env.maps.trekPlacesUrl = 'https://places.example.net';
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('the winter meta', () => {
|
||||
it('PLACESWINTER-001: asks /v1/winter/meta without any parameter, and reads the build and checksum', async () => {
|
||||
stub(answer(200, JSON.stringify(META)));
|
||||
expect(await trekPlacesWinterMeta()).toMatchObject({ build: '2026-11-02', indexSha: SHA, format: 1 });
|
||||
const url = new URL(calls[0]?.url ?? '');
|
||||
expect(url.origin + url.pathname).toBe('https://places.example.net/v1/winter/meta');
|
||||
expect(url.search).toBe('');
|
||||
expect(calls[0]?.init.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(calls[0]?.init.headers['X-TREK-Instance']).toMatch(/^trek-/);
|
||||
expect(calls[0]?.init.headers['If-None-Match']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('PLACESWINTER-002: refuses an answer off the contract, too large, or not JSON', async () => {
|
||||
stub(answer(200, JSON.stringify({ ...META, indexSha: 'short' })));
|
||||
await expect(trekPlacesWinterMeta()).rejects.toThrow();
|
||||
stub(answer(200, 'x'.repeat(70 * 1024)));
|
||||
await expect(trekPlacesWinterMeta()).rejects.toThrow('TREK Places API response too large');
|
||||
stub(answer(200, '<html>'));
|
||||
await expect(trekPlacesWinterMeta()).rejects.toThrow(SyntaxError);
|
||||
stub(answer(304, ''));
|
||||
await expect(trekPlacesWinterMeta()).rejects.toThrow('TREK Places API 304');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the winter index', () => {
|
||||
it('PLACESWINTER-010: sends the checksum it holds as the ETag, and reads nothing new on a 304', async () => {
|
||||
stub(answer(304, '', { etag: `"${SHA}"` }));
|
||||
expect(await trekPlacesWinterIndex(SHA)).toEqual({ status: 'not_modified' });
|
||||
expect(calls[0]?.init.headers['If-None-Match']).toBe(`"${SHA}"`);
|
||||
});
|
||||
|
||||
it('PLACESWINTER-011: reads the index, checksums it as read, and drops a row that does not read', async () => {
|
||||
const json = JSON.stringify({ build: '2026-11-02', format: 1, areas: [ROW, { ...ROW, id: '../x' }] });
|
||||
stub(answer(200, json, { etag: '"abc"' }, false));
|
||||
const read = await trekPlacesWinterIndex(null);
|
||||
if (read.status !== 'ok') throw new Error('not read');
|
||||
expect(read.index.areas.map((row) => row.id)).toEqual(['w:100']);
|
||||
expect(read.dropped).toBe(1);
|
||||
expect(read.sha256).toBe(createHash('sha256').update(json).digest('hex'));
|
||||
expect(calls[0]?.init.headers['If-None-Match']).toBeUndefined();
|
||||
stub(answer(200, JSON.stringify({ build: '', format: 1, areas: [] })));
|
||||
await expect(trekPlacesWinterIndex(null)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('an area graph', () => {
|
||||
it('PLACESWINTER-020: asks for the area by its id, the bytes as sent with their tag', async () => {
|
||||
const bytes = Buffer.from([0x1f, 0x8b, 8, 0, 1, 2, 3]);
|
||||
stub(answer(200, bytes, { etag: `W/"${SHA}"` }));
|
||||
expect(await trekPlacesWinterArea('w:100', null)).toEqual({ status: 'ok', bytes, etag: SHA });
|
||||
expect(new URL(calls[0]?.url ?? '').pathname).toBe('/v1/winter/area/w%3A100');
|
||||
expect(calls[0]?.init.headers.Accept).toBe('application/vnd.trek.sgraph');
|
||||
stub(answer(304, ''));
|
||||
expect(await trekPlacesWinterArea('r:5-1a', SHA)).toEqual({ status: 'not_modified' });
|
||||
stub(answer(200, bytes, {}));
|
||||
expect(await trekPlacesWinterArea('w:100', null)).toMatchObject({ etag: null });
|
||||
});
|
||||
|
||||
it('PLACESWINTER-021: refuses an id that is not one before asking, and a 404 as a 404', async () => {
|
||||
stub();
|
||||
await expect(trekPlacesWinterArea('../../etc/passwd', null)).rejects.toThrow('Not a ski area id');
|
||||
expect(calls).toHaveLength(0);
|
||||
stub(answer(404, 'no such area'));
|
||||
await expect(trekPlacesWinterArea('w:9', null)).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the opt-out and the breaker', () => {
|
||||
it('PLACESWINTER-030: nothing leaves when TREK Places is off, and the provider says so', async () => {
|
||||
env.maps.trekPlacesEnabled = false;
|
||||
stub();
|
||||
await expect(trekPlacesWinterMeta()).rejects.toThrow('switched off');
|
||||
await expect(trekPlacesWinterIndex(null)).rejects.toThrow('switched off');
|
||||
expect(calls).toHaveLength(0);
|
||||
const winter = new TrekPlacesWinterService();
|
||||
expect(winter.enabled()).toBe(false);
|
||||
env.maps.trekPlacesEnabled = true;
|
||||
expect(winter.enabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('PLACESWINTER-031: four failures open the breaker for the winter layer too; a 404 does not count', async () => {
|
||||
stub(answer(404), answer(500), answer(500), answer(500), answer(500));
|
||||
await expect(trekPlacesWinterMeta()).rejects.toThrow('TREK Places API 404');
|
||||
await expect(trekPlacesWinterMeta()).rejects.toThrow('TREK Places API 500');
|
||||
await expect(trekPlacesWinterMeta()).rejects.toThrow('TREK Places API 500');
|
||||
await expect(trekPlacesWinterMeta()).rejects.toThrow('TREK Places API 500');
|
||||
await expect(trekPlacesWinterMeta()).rejects.toThrow('TREK Places API 500');
|
||||
await expect(trekPlacesWinterMeta()).rejects.toThrow('not retrying yet');
|
||||
expect(calls).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('PLACESWINTER-032: the provider hands each call to the client', async () => {
|
||||
const winter = new TrekPlacesWinterService();
|
||||
stub(answer(200, JSON.stringify(META)), answer(304, ''), answer(304, ''));
|
||||
expect((await winter.meta()).build).toBe('2026-11-02');
|
||||
expect(await winter.index(SHA)).toEqual({ status: 'not_modified' });
|
||||
expect(await winter.area('w:1', SHA)).toEqual({ status: 'not_modified' });
|
||||
});
|
||||
});
|
||||
@@ -188,6 +188,9 @@ export default defineConfig({
|
||||
'src/nest/sessions/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 },
|
||||
'src/nest/settings/**/*.ts': { statements: 87, branches: 71, functions: 99, lines: 88 },
|
||||
'src/nest/share/**/*.ts': { statements: 97, branches: 87, functions: 99, lines: 99 },
|
||||
// The piste router, new in this change and not measured yet: on the floor until the first full CI
|
||||
// run, then raised with scripts/coverage-thresholds.mjs (the spec aims at 90), never lowered.
|
||||
'src/nest/ski-routing/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 },
|
||||
'src/nest/storage/**/*.ts': { statements: 94, branches: 84, functions: 97, lines: 94 },
|
||||
'src/nest/system-notices/**/*.ts': { statements: 99, branches: 99, functions: 99, lines: 99 },
|
||||
'src/nest/tags/**/*.ts': { statements: 97, branches: 89, functions: 99, lines: 99 },
|
||||
|
||||
@@ -59,6 +59,8 @@ export * from './tours/tours.schema';
|
||||
export * from './tours/run-pace';
|
||||
export * from './tours/tour-time-estimate';
|
||||
export * from './tours/ski.schema';
|
||||
export * from './ski/ski-route.schema';
|
||||
export * from './ski/winter-index.schema';
|
||||
export * from './tours/tour-stages';
|
||||
export * from './tours/tour-provenance.schema';
|
||||
export * from './tours/tour-activity.schema';
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
SKI_DIFFICULTIES,
|
||||
skiDisciplineSchema,
|
||||
skiRouteBuildSchema,
|
||||
skiRouteOptionsSchema,
|
||||
skiTechniqueSchema,
|
||||
} from '../tours/ski.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* The piste router: legs of a ski and winter tour planned on the runs, lifts,
|
||||
* cross-country trails, ski touring and winter paths of OpenStreetMap. The
|
||||
* Places host builds a graph per ski area once and serves it by area id; TREK
|
||||
* downloads the areas a tour needs and searches them itself, so the points a
|
||||
* traveller clicks never leave the server. Every leg is planned on its own
|
||||
* and answered as planned or failed with the reason and, where one helps, the
|
||||
* one change that would plan it. A failed leg is never drawn by the server:
|
||||
* the planner draws it straight and says so.
|
||||
*/
|
||||
|
||||
export const skiRoutePointSchema = z.object({
|
||||
lat: z.number().min(-90).max(90),
|
||||
lng: z.number().min(-180).max(180),
|
||||
});
|
||||
export type SkiRoutePoint = z.infer<typeof skiRoutePointSchema>;
|
||||
|
||||
/** The most legs one request plans; a tour with more asks again. */
|
||||
export const MAX_SKI_ROUTE_LEGS = 40;
|
||||
|
||||
/** At most this many other ways per leg, besides the best one. */
|
||||
export const MAX_SKI_ALTERNATIVES = 2;
|
||||
|
||||
export const skiRouteRequestSchema = z.object({
|
||||
legs: z
|
||||
.array(
|
||||
z.object({
|
||||
from: skiRoutePointSchema,
|
||||
to: skiRoutePointSchema,
|
||||
/** The leg ends at a shaping point: it bends the way, the summary does not split there. */
|
||||
shaping_to: z.boolean().default(false),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(MAX_SKI_ROUTE_LEGS),
|
||||
discipline: skiDisciplineSchema,
|
||||
/** Cross-country only; any other discipline ignores it. Left out on cross-country, either technique will do. */
|
||||
technique: skiTechniqueSchema.nullable().default(null),
|
||||
options: skiRouteOptionsSchema.prefault({}),
|
||||
alternatives: z.number().int().min(0).max(MAX_SKI_ALTERNATIVES).default(0),
|
||||
});
|
||||
export type SkiRouteRequest = z.infer<typeof skiRouteRequestSchema>;
|
||||
export type SkiRouteInput = z.input<typeof skiRouteRequestSchema>;
|
||||
|
||||
/**
|
||||
* The stretches of a planned leg: a run, a lift ridden up, a funicular or rack
|
||||
* railway, a gondola ridden down, a cross-country trail, a ski touring or
|
||||
* winter path, a mapped connection between runs, an open slope, a short link
|
||||
* the map data does not hold, a crossing, or a stretch on foot.
|
||||
*/
|
||||
export const skiSegmentKindSchema = z.enum([
|
||||
'run',
|
||||
'lift',
|
||||
'rail',
|
||||
'lift_down',
|
||||
'nordic',
|
||||
'skitour',
|
||||
'hike',
|
||||
'connection',
|
||||
'area',
|
||||
'link',
|
||||
'crossing',
|
||||
'walk',
|
||||
]);
|
||||
export type SkiSegmentKind = z.infer<typeof skiSegmentKindSchema>;
|
||||
|
||||
/** What a stretch rests on that the traveller should know: guessed links, directions, techniques and the like. */
|
||||
export const skiFlagSchema = z.enum([
|
||||
'inferred_link',
|
||||
'inferred_crossing',
|
||||
'dir_from_dem',
|
||||
'dir_conflict',
|
||||
'technique_assumed',
|
||||
'relation_inherited',
|
||||
'area_derived',
|
||||
'ride_down',
|
||||
'uphill_on_piste',
|
||||
'dismount',
|
||||
'difficulty_unknown',
|
||||
'tunnel',
|
||||
]);
|
||||
export type SkiFlag = z.infer<typeof skiFlagSchema>;
|
||||
|
||||
/** A run's difficulty as mapped, freeride and extreme included. */
|
||||
export const skiRunDifficultySchema = z.enum([...SKI_DIFFICULTIES, 'freeride', 'extreme']);
|
||||
export type SkiRunDifficulty = z.infer<typeof skiRunDifficultySchema>;
|
||||
|
||||
/** A point of a planned leg: [lat, lng, height in metres], the height null where the terrain data has none. */
|
||||
export const skiGeometryPointSchema = z.tuple([z.number(), z.number(), z.number().nullable()]);
|
||||
export type SkiGeometryPoint = z.infer<typeof skiGeometryPointSchema>;
|
||||
|
||||
export const skiRouteSegmentSchema = z.object({
|
||||
kind: skiSegmentKindSchema,
|
||||
/** First and last index into the leg's geometry, both included. */
|
||||
from: z.number().int().nonnegative(),
|
||||
to: z.number().int().nonnegative(),
|
||||
name: z.string().nullable(),
|
||||
ref: z.string().nullable(),
|
||||
difficulty: skiRunDifficultySchema.nullable(),
|
||||
/** The lift's type as OpenStreetMap names it (gondola, chair_detachable, t-bar...); null off a lift. */
|
||||
lift_type: z.string().nullable(),
|
||||
/** How the trail is groomed, as mapped (classic, skating, scooter, backcountry, mogul). */
|
||||
grooming: z.array(z.string()),
|
||||
/** Time on the move, without waiting. */
|
||||
seconds: z.number().nonnegative(),
|
||||
/** Waiting at the lift's bottom station, a guess from the lift's type and capacity. */
|
||||
wait_seconds: z.number().nonnegative(),
|
||||
length_m: z.number().nonnegative(),
|
||||
flags: z.array(skiFlagSchema),
|
||||
osm_way: z.number().nullable(),
|
||||
});
|
||||
export type SkiRouteSegment = z.infer<typeof skiRouteSegmentSchema>;
|
||||
|
||||
/** Why the way is as it is: a harder run it kept clear of, or how often it rests on a flagged stretch. */
|
||||
export const skiWhySchema = z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
kind: z.literal('avoided_difficulty'),
|
||||
level: skiRunDifficultySchema,
|
||||
name: z.string().nullable(),
|
||||
saved_seconds: z.number().nonnegative(),
|
||||
}),
|
||||
z.object({ kind: z.literal('flag'), flag: skiFlagSchema, count: z.number().int().positive() }),
|
||||
]);
|
||||
export type SkiWhy = z.infer<typeof skiWhySchema>;
|
||||
|
||||
export const skiLegSecondsSchema = z.object({
|
||||
ski: z.number().nonnegative(),
|
||||
lift: z.number().nonnegative(),
|
||||
wait: z.number().nonnegative(),
|
||||
walk: z.number().nonnegative(),
|
||||
});
|
||||
export type SkiLegSeconds = z.infer<typeof skiLegSecondsSchema>;
|
||||
|
||||
export const skiRouteAlternativeSchema = z.object({
|
||||
geometry: z.array(skiGeometryPointSchema).min(2),
|
||||
segments: z.array(skiRouteSegmentSchema).min(1),
|
||||
seconds_total: z.number().nonnegative(),
|
||||
});
|
||||
export type SkiRouteAlternative = z.infer<typeof skiRouteAlternativeSchema>;
|
||||
|
||||
export const skiRouteOkLegSchema = z.object({
|
||||
status: z.literal('ok'),
|
||||
geometry: z.array(skiGeometryPointSchema).min(2),
|
||||
segments: z.array(skiRouteSegmentSchema).min(1),
|
||||
distance_m: z.number().nonnegative(),
|
||||
climb_m: z.number().nonnegative(),
|
||||
descent_m: z.number().nonnegative(),
|
||||
seconds: skiLegSecondsSchema,
|
||||
/** How far the ends were moved onto the network, in metres. */
|
||||
snapped_m: z.object({ from: z.number().nonnegative(), to: z.number().nonnegative() }),
|
||||
area_ids: z.array(z.string()),
|
||||
why: z.array(skiWhySchema),
|
||||
alternatives: z.array(skiRouteAlternativeSchema).max(MAX_SKI_ALTERNATIVES),
|
||||
});
|
||||
export type SkiRouteOkLeg = z.infer<typeof skiRouteOkLegSchema>;
|
||||
|
||||
export const skiFailCodeSchema = z.enum([
|
||||
/** A point lies farther from any run, trail or lift the discipline may use than the router reaches. */
|
||||
'not_on_piste',
|
||||
/** No ski area of the piste data covers the point. */
|
||||
'no_area',
|
||||
/** The ski area is being downloaded; asked again shortly it answers. */
|
||||
'loading',
|
||||
/** The piste data cannot be reached and no copy of the area is kept. */
|
||||
'places_unavailable',
|
||||
/** The admin switched piste routing off. */
|
||||
'disabled',
|
||||
'blocked_by_difficulty',
|
||||
'blocked_by_lift_avoid',
|
||||
'blocked_by_technique',
|
||||
'needs_ride_down',
|
||||
/** Only a way up joins the points, and the discipline has none without a lift. */
|
||||
'uphill_only',
|
||||
/** Nothing of the network joins the points. */
|
||||
'disconnected',
|
||||
/** The points lie in two ski areas that do not touch. */
|
||||
'cross_area',
|
||||
'busy',
|
||||
'timeout',
|
||||
]);
|
||||
export type SkiFailCode = z.infer<typeof skiFailCodeSchema>;
|
||||
|
||||
/** The one change that would plan a failed leg; each field is a separate suggestion. */
|
||||
export const skiFailHintSchema = z.object({
|
||||
raise_difficulty: skiRunDifficultySchema.optional(),
|
||||
allow_lift: z.string().optional(),
|
||||
technique: skiTechniqueSchema.optional(),
|
||||
allow_lift_down: z.boolean().optional(),
|
||||
discipline: skiDisciplineSchema.optional(),
|
||||
free_draw: z.boolean().optional(),
|
||||
});
|
||||
export type SkiFailHint = z.infer<typeof skiFailHintSchema>;
|
||||
|
||||
export const skiRouteFailedLegSchema = z.object({
|
||||
status: z.literal('failed'),
|
||||
code: skiFailCodeSchema,
|
||||
/** Where it failed: the point off the network, or the nearest point reached towards the target. */
|
||||
at: skiRoutePointSchema.nullable(),
|
||||
hint: skiFailHintSchema.nullable(),
|
||||
/** The run or lift the reason is about, and how far a point lies off the network. */
|
||||
detail: z.object({ name: z.string().nullable(), distance_m: z.number().optional() }).nullable(),
|
||||
});
|
||||
export type SkiRouteFailedLeg = z.infer<typeof skiRouteFailedLegSchema>;
|
||||
|
||||
export const skiRouteLegSchema = z.discriminatedUnion('status', [skiRouteOkLegSchema, skiRouteFailedLegSchema]);
|
||||
export type SkiRouteLeg = z.infer<typeof skiRouteLegSchema>;
|
||||
|
||||
/** The colours a run's difficulty is drawn in: the European scheme, or North America's. */
|
||||
export const skiColourSchemeSchema = z.enum(['europe', 'north_america']);
|
||||
export type SkiColourScheme = z.infer<typeof skiColourSchemeSchema>;
|
||||
|
||||
export const skiRouteResponseSchema = z.object({
|
||||
legs: z.array(skiRouteLegSchema),
|
||||
/** The piste data build the legs were planned on; null when no area was used. */
|
||||
build: z.string().nullable(),
|
||||
/** The Places host could not be reached and a kept, older copy of an area was used. */
|
||||
stale: z.boolean(),
|
||||
area_names: z.array(z.string()),
|
||||
/** How the areas planned on colour their runs; Europe's where no area was used. */
|
||||
colour_scheme: skiColourSchemeSchema,
|
||||
attribution: z.string(),
|
||||
});
|
||||
export type SkiRouteResponse = z.infer<typeof skiRouteResponseSchema>;
|
||||
|
||||
/** GET /ski/area-near: whether the ski area at a point is ready, on its way, or cannot be had. */
|
||||
export const skiAreaStateSchema = z.enum(['ready', 'loading', 'unavailable']);
|
||||
export type SkiAreaState = z.infer<typeof skiAreaStateSchema>;
|
||||
|
||||
export const skiAreaNearQuerySchema = z.object({
|
||||
lat: z.coerce.number().min(-90).max(90),
|
||||
lng: z.coerce.number().min(-180).max(180),
|
||||
});
|
||||
export type SkiAreaNearQuery = z.infer<typeof skiAreaNearQuerySchema>;
|
||||
|
||||
export const skiAreaNearResponseSchema = z.object({
|
||||
area: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullable(),
|
||||
kind: z.string(),
|
||||
colour_scheme: skiColourSchemeSchema,
|
||||
build: z.string().nullable(),
|
||||
state: skiAreaStateSchema,
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
export type SkiAreaNearResponse = z.infer<typeof skiAreaNearResponseSchema>;
|
||||
|
||||
/** An area id as the Places host spells it: `w:<way>` or `r:<relation>`, a piece of a split network with a suffix. */
|
||||
export const skiAreaIdSchema = z.string().max(64).regex(/^[a-z]:[0-9]+(-[0-9a-z]+)?$/);
|
||||
|
||||
/** One drawn line of an area's network: a run, a lift or a trail, as a Google-encoded polyline (precision 5). */
|
||||
export const skiNetworkFeatureSchema = z.object({
|
||||
kind: skiSegmentKindSchema,
|
||||
difficulty: skiRunDifficultySchema.nullable(),
|
||||
name: z.string().nullable(),
|
||||
lift_type: z.string().nullable(),
|
||||
/** Skied or ridden one way only, in the direction it is drawn. */
|
||||
oneway: z.boolean(),
|
||||
line: z.string().min(1),
|
||||
});
|
||||
export type SkiNetworkFeature = z.infer<typeof skiNetworkFeatureSchema>;
|
||||
|
||||
export const skiNetworkResponseSchema = z.object({
|
||||
area_id: z.string(),
|
||||
build: z.string().nullable(),
|
||||
colour_scheme: skiColourSchemeSchema,
|
||||
/** How far the lines were simplified, in metres. */
|
||||
simplified_m: z.number().nonnegative(),
|
||||
features: z.array(skiNetworkFeatureSchema),
|
||||
});
|
||||
export type SkiNetworkResponse = z.infer<typeof skiNetworkResponseSchema>;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* What the Places host's winter layer answers before any graph is fetched:
|
||||
* which build it serves (`/v1/winter/meta`) and every ski area with its box,
|
||||
* size and checksum (`/v1/winter/index`). Neither takes a coordinate. A new
|
||||
* build is told by `indexSha`, the SHA-256 of the unpacked index JSON, never
|
||||
* by the build's name, which repeats when a build is redone the same day.
|
||||
*/
|
||||
|
||||
const sha256Schema = z.string().regex(/^[0-9a-f]{64}$/);
|
||||
|
||||
/** The attribution block the winter layer carries; only the two lines TREK prints are required. */
|
||||
export const winterAttributionSchema = z.object({
|
||||
data: z.string(),
|
||||
terrain: z.string(),
|
||||
});
|
||||
export type WinterAttribution = z.infer<typeof winterAttributionSchema>;
|
||||
|
||||
export const winterMetaSchema = z.object({
|
||||
build: z.string().min(1).max(40),
|
||||
format: z.number().int(),
|
||||
areas: z.number().int().nonnegative(),
|
||||
indexSha: sha256Schema,
|
||||
sourceTimestamp: z.string().nullable().optional(),
|
||||
attribution: winterAttributionSchema,
|
||||
});
|
||||
export type WinterMeta = z.infer<typeof winterMetaSchema>;
|
||||
|
||||
/** The km of each kind of way an area holds, and its lift count. */
|
||||
export const winterAreaCountsSchema = z.object({
|
||||
runsKm: z.number().nonnegative(),
|
||||
nordicKm: z.number().nonnegative(),
|
||||
lifts: z.number().int().nonnegative(),
|
||||
skitourKm: z.number().nonnegative(),
|
||||
hikeKm: z.number().nonnegative(),
|
||||
});
|
||||
export type WinterAreaCounts = z.infer<typeof winterAreaCountsSchema>;
|
||||
|
||||
export const winterIndexRowSchema = z.object({
|
||||
id: z.string().max(64).regex(/^[a-z]:[0-9]+(-[0-9a-z]+)?$/),
|
||||
name: z.string().nullable(),
|
||||
kind: z.string(),
|
||||
country: z.string().nullable(),
|
||||
colourScheme: z.enum(['europe', 'north_america']),
|
||||
/** [minLng, minLat, maxLng, maxLat] */
|
||||
bbox: z.tuple([
|
||||
z.number().min(-180).max(180),
|
||||
z.number().min(-90).max(90),
|
||||
z.number().min(-180).max(180),
|
||||
z.number().min(-90).max(90),
|
||||
]),
|
||||
counts: winterAreaCountsSchema,
|
||||
/** The size of the area's gzip-packed graph, and its SHA-256, exactly as sent. */
|
||||
bytes: z.number().int().nonnegative(),
|
||||
sha256: sha256Schema,
|
||||
});
|
||||
export type WinterIndexRow = z.infer<typeof winterIndexRowSchema>;
|
||||
|
||||
export const winterIndexSchema = z.object({
|
||||
build: z.string().min(1).max(40),
|
||||
format: z.number().int(),
|
||||
areas: z.array(winterIndexRowSchema),
|
||||
});
|
||||
export type WinterIndex = z.infer<typeof winterIndexSchema>;
|
||||
@@ -2,12 +2,14 @@ import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* SKI-02: a ski and winter tour, planned in the planner or imported from a
|
||||
* GPX file. A planned one has every leg drawn between its points (the piste
|
||||
* router comes later), an imported one is kept as it was recorded. TREK works
|
||||
* out no ski time, and nothing is said about snow, avalanches or whether a
|
||||
* run or a trail is open. What the traveller names on purpose is the
|
||||
* discipline: alpine, snowboard, cross-country, ski touring, snowshoeing or
|
||||
* winter hiking (both of which live here and not on a hike), and for
|
||||
* GPX file. A planned one has its legs planned by the piste router where the
|
||||
* piste data reaches (runs, lifts, cross-country trails, ski touring and
|
||||
* winter paths from OpenStreetMap), and drawn straight where it does not; an
|
||||
* imported one is kept as it was recorded. A ski time is worked out only for
|
||||
* legs the router planned, and nothing is said about snow, avalanches or
|
||||
* whether a run or a trail is open. What the traveller names on purpose is
|
||||
* the discipline: alpine, snowboard, cross-country, ski touring, snowshoeing
|
||||
* or winter hiking (both of which live here and not on a hike), and for
|
||||
* cross-country the technique.
|
||||
*/
|
||||
export const SKI_DISCIPLINES = ['alpine', 'snowboard', 'cross_country', 'touring', 'snowshoe', 'winter_hike'] as const;
|
||||
@@ -65,3 +67,104 @@ export const tourSkiClassificationRequestSchema = z
|
||||
skiIssues(true, value).forEach((issue) => ctx.addIssue({ code: z.ZodIssueCode.custom, ...issue })),
|
||||
);
|
||||
export type TourSkiClassificationRequest = z.infer<typeof tourSkiClassificationRequestSchema>;
|
||||
|
||||
/** The run difficulties a skier may set as a limit, easiest first (freeride and extreme are a switch of their own). */
|
||||
export const SKI_DIFFICULTIES = ['novice', 'easy', 'intermediate', 'advanced', 'expert'] as const;
|
||||
export const skiDifficultySchema = z.enum(SKI_DIFFICULTIES);
|
||||
export type SkiDifficulty = z.infer<typeof skiDifficultySchema>;
|
||||
|
||||
/** The kinds of lift a skier may leave out: surface lifts, open chairs, gondolas, cable cars and rail. */
|
||||
export const SKI_LIFT_CLASSES = ['drag', 'open_chair', 'gondola', 'cable_car', 'rail'] as const;
|
||||
export const skiLiftClassSchema = z.enum(SKI_LIFT_CLASSES);
|
||||
export type SkiLiftClass = z.infer<typeof skiLiftClassSchema>;
|
||||
|
||||
/** What the router weighs most: the quickest way, the most skiing, or the easiest runs. */
|
||||
export const SKI_ROUTE_PRIORITIES = ['fastest', 'most_skiing', 'easiest'] as const;
|
||||
export type SkiRoutePriority = (typeof SKI_ROUTE_PRIORITIES)[number];
|
||||
|
||||
/** How fast the skier goes down a run, against an average skier. */
|
||||
export const SKI_PACES = ['relaxed', 'normal', 'sporty'] as const;
|
||||
export type SkiPace = (typeof SKI_PACES)[number];
|
||||
|
||||
/**
|
||||
* What the piste router is asked for besides the discipline. Every field has
|
||||
* its default, so an empty object is a complete answer. `use_lifts` left out
|
||||
* means the discipline's own default: lifts for alpine and snowboard, none for
|
||||
* the others.
|
||||
*/
|
||||
export const skiRouteOptionsSchema = z
|
||||
.object({
|
||||
max_difficulty: skiDifficultySchema.default('intermediate'),
|
||||
allow_freeride: z.boolean().default(false),
|
||||
avoid_lifts: z.array(skiLiftClassSchema).max(SKI_LIFT_CLASSES.length).default([]),
|
||||
allow_lift_down: z.boolean().default(true),
|
||||
priority: z.enum(SKI_ROUTE_PRIORITIES).default('fastest'),
|
||||
pace: z.enum(SKI_PACES).default('normal'),
|
||||
groomed_only: z.boolean().default(false),
|
||||
lit_only: z.boolean().default(false),
|
||||
touring_uphill_on_pistes: z.boolean().default(false),
|
||||
use_lifts: z.boolean().optional(),
|
||||
avoid_inferred_links: z.boolean().default(false),
|
||||
})
|
||||
.strict();
|
||||
export type SkiRouteOptions = z.infer<typeof skiRouteOptionsSchema>;
|
||||
export type SkiRouteOptionsInput = z.input<typeof skiRouteOptionsSchema>;
|
||||
|
||||
export const DEFAULT_SKI_ROUTE_OPTIONS: SkiRouteOptions = skiRouteOptionsSchema.parse({});
|
||||
|
||||
/** Disciplines that ride lifts unless the skier says otherwise. */
|
||||
const LIFTS_BY_DEFAULT: ReadonlySet<SkiDiscipline> = new Set(['alpine', 'snowboard']);
|
||||
|
||||
/** Whether a discipline rides lifts with these options: the skier's word, else the discipline's default. */
|
||||
export function skiUsesLifts(discipline: SkiDiscipline, options: Pick<SkiRouteOptions, 'use_lifts'>): boolean {
|
||||
return options.use_lifts ?? LIFTS_BY_DEFAULT.has(discipline);
|
||||
}
|
||||
|
||||
/** The options a downhill skier reads, on skis or a board. */
|
||||
const DOWNHILL_OPTIONS: ReadonlyArray<keyof SkiRouteOptions> = [
|
||||
'max_difficulty',
|
||||
'allow_freeride',
|
||||
'avoid_lifts',
|
||||
'allow_lift_down',
|
||||
'priority',
|
||||
'pace',
|
||||
'use_lifts',
|
||||
'avoid_inferred_links',
|
||||
];
|
||||
|
||||
/** The options each discipline reads; the rest are put back to their defaults. */
|
||||
const OPTIONS_BY_DISCIPLINE: Record<SkiDiscipline, ReadonlyArray<keyof SkiRouteOptions>> = {
|
||||
alpine: DOWNHILL_OPTIONS,
|
||||
snowboard: DOWNHILL_OPTIONS,
|
||||
cross_country: ['groomed_only', 'lit_only', 'use_lifts', 'avoid_inferred_links'],
|
||||
touring: [
|
||||
'max_difficulty',
|
||||
'allow_freeride',
|
||||
'avoid_lifts',
|
||||
'allow_lift_down',
|
||||
'touring_uphill_on_pistes',
|
||||
'use_lifts',
|
||||
'avoid_inferred_links',
|
||||
],
|
||||
snowshoe: ['allow_lift_down', 'use_lifts', 'avoid_inferred_links'],
|
||||
winter_hike: ['allow_lift_down', 'use_lifts', 'avoid_inferred_links'],
|
||||
};
|
||||
|
||||
/**
|
||||
* The options as a discipline reads them: whatever belongs to another
|
||||
* discipline goes back to its default rather than being refused, so switching
|
||||
* the discipline keeps a request that is valid as it is.
|
||||
*/
|
||||
export function skiOptionsForDiscipline(discipline: SkiDiscipline, options: SkiRouteOptionsInput): SkiRouteOptions {
|
||||
const parsed = skiRouteOptionsSchema.parse(options);
|
||||
const kept = new Set(OPTIONS_BY_DISCIPLINE[discipline]);
|
||||
const result: Record<string, unknown> = { ...DEFAULT_SKI_ROUTE_OPTIONS };
|
||||
for (const key of Object.keys(parsed) as Array<keyof SkiRouteOptions>) {
|
||||
if (kept.has(key)) result[key] = parsed[key];
|
||||
}
|
||||
if (!kept.has('use_lifts') || parsed.use_lifts === undefined) delete result.use_lifts;
|
||||
return result as SkiRouteOptions;
|
||||
}
|
||||
|
||||
/** The name of a piste data build a ski tour's legs were planned on, as the Places host names it. */
|
||||
export const skiRouteBuildSchema = z.string().trim().min(1).max(40).regex(/^[0-9A-Za-z._-]+$/);
|
||||
|
||||
@@ -266,6 +266,7 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how
|
||||
| `prepare_tour_undo` | Shows what taking back a saved step would do, without changing anything: the step and its changes, the tour now (distance and walking time as stored, each station with whether it was visited or skipped on a day) and the tour exactly as it was before the step (its stations, each kept or coming back with where it stands now, and the distance and walking time stored then), the stations taking back removes, and the `step_id` and `expected_revision` that `undo_tour_change` names. Only the tour's latest change can be taken back; a step something was saved on top of since is refused as a conflict, so no later edit is lost, and so is one that would remove a station visited or skipped since or bring back a station whose place of the trip is gone. Visits of stations that stay, the days and the tour's name and notes are not touched. Not the planner's undo: discarding a draft in the planner never changes a saved tour. Also says whether the traveller may change the trip's places (`can_undo`) | `trips:read` |
|
||||
| `undo_tour_change` | Takes back a saved step once the traveller said yes to the preview of exactly that step: `step_id` and `expected_revision` as `prepare_tour_undo` gave them, and `confirmed` set to true. Everything is checked again before anything is written, and a tour changed since the preview, a step no longer the latest change or taken back already, or a station visited or skipped meanwhile that would go, refuses it and leaves the tour as it is. Restores the tour whole or not at all, exactly as it was before the step: its line, its stations with their keys and the distance and walking time stored then. A step is taken back once, and taking it back raises the tour's revision like any other save. Needs `place_edit` on the trip | `trips:write` |
|
||||
| `plan_paddle_route` | Plans the legs of a paddle tour on the water network TREK builds from the paddle regions an admin added: each leg (`from`, `to`) along rivers and canals and across lakes, carried round weirs, dams and waterfalls, through or round locks (`lock_policy`), with `boat` and `lake_style` (`direct` or `shore`). Answers per leg its length, how far its ends were moved onto the water and its stretches (water, lake, whitewater, boat pass, portage with its source, lock, gap); a leg that cannot be planned says why (`not_on_water`, `no_region`, `cross_region`, `no_water_path`, `busy`, `timeout`). Nothing is saved; a planning aid, never a safety or legal clearance | `trips:read` |
|
||||
| `plan_ski_route` | Plans the legs of a ski and winter tour on the runs, lifts, cross-country trails, ski touring and winter paths of OpenStreetMap, which the Places host serves as one graph per ski area: for a `discipline` (`alpine`, `snowboard`, `cross_country` with a `technique`, `touring`, `snowshoe`, `winter_hike`) and its `options` (difficulty limit, freeride, lift classes to avoid, riding down, priority, pace, groomed or lit trails only, ascent along pistes, lifts at all). Answers per leg its length, climb and descent, its time split into skiing, lifts, waiting and walking, how far its ends were moved onto the network, its stretches and notes on what they rest on; a leg that cannot be planned says why (`not_on_piste`, `no_area`, `cross_area`, `disconnected`, `blocked_by_difficulty`, `blocked_by_lift_avoid`, `blocked_by_technique`, `needs_ride_down`, `uphill_only`, `loading`, `places_unavailable`, `disabled`, `busy`, `timeout`) and, where one helps, the one change that would plan it. No clicked point leaves the server. Times are estimates, nothing says what is open, groomed or safe today; nothing is saved | `trips:read` |
|
||||
|
||||
### Road trip _(Road trip addon required)_
|
||||
|
||||
|
||||
Reference in New Issue
Block a user