mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
c8eb510bd4
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.
295 lines
11 KiB
TypeScript
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]);
|
|
}
|
|
}
|