Files
TREK/server/tests/helpers/ski-world.ts
T
Maurice c8eb510bd4 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.
2026-10-20 21:13:48 +02:00

295 lines
11 KiB
TypeScript

/**
* 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]);
}
}