fix(tours): lake legs keep off the shore instead of hugging it one cell out

The lake cost only added half again within 12 m of the bank, so the cheapest line round a bend ran one grid cell off the water's edge (7 to 19 m on the big lakes), and pulling it straight could draw it over land. A clearance ramp now makes the water nearer than 80 m (40 m along the shore) steadily dearer, which also centres the route in anything narrower than twice that, the shore style rides a band 100 to 350 m out instead of the bank itself, and a shortcut may not come nearer the shore than the path it replaces. Measured on nine lakes: no sample on land (was up to two), direct keeps 51 to 78 m at its closest (was one cell), along the shore the median is 60 to 100 m (was 30 to 40) and under 1 % of it within 25 m (was up to 22 %); lengths change by under 1 %.
This commit is contained in:
Maurice
2026-10-18 22:13:30 +02:00
parent 8662cb0ac0
commit 0619a6b324
@@ -6,10 +6,15 @@ import type { PaddleLakeStyle } from '@trek/shared';
/**
* Routing across one lake on its grid of water cells. Every cell knows how
* far it lies from the shore; a crossing costs its length weighed by that:
* straight over is cheapest with the default style, staying near the shore
* is with `shore`, and water farther out than half a boat's open-water limit
* costs so much that the route takes it only when there is no other way.
* far it lies from the shore; a crossing costs its length weighed by that.
* Closer to the shore than a style's clearance gets steadily dearer, so a
* route keeps off the bank and runs down the middle of anything narrower than
* twice that: without it the cheapest line hugs every inner bend one grid
* cell off the water's edge, which on the map reads as paddling over land.
* With `shore` the open water beyond a band along the bank costs more too, so
* the route follows the shore at a distance rather than on it. Water farther
* out than half a boat's open-water limit costs so much that the route takes
* it only when there is no other way.
*
* Two levels keep a large lake quick: the costs between the lake's portals
* come from a coarse copy of the grid (a few ten thousand cells, however big
@@ -17,11 +22,13 @@ import type { PaddleLakeStyle } from '@trek/shared';
* by A* towards its end.
*/
/** Keep this far off the shore where the lake is wide enough, in metres. */
const SHORE_MARGIN_M = 12;
/** How far off the shore each style keeps where the water is wide enough, in metres. */
const CLEARANCE_M: Readonly<Record<PaddleLakeStyle, number>> = { direct: 80, shore: 40 };
/** What a metre right at the shore costs on top, falling off squared to nothing at the clearance. */
const CLEARANCE_WEIGHT = 4;
/** With `shore`, open water costs more from here out, up to SHORE_FAR_M. */
const SHORE_NEAR_M = 40;
const SHORE_FAR_M = 200;
const SHORE_NEAR_M = 100;
const SHORE_FAR_M = 350;
const SHORE_WEIGHT = 1.5;
/** Water beyond a boat's open-water reach costs this many times its length. */
const OVER_LIMIT_WEIGHT = 20;
@@ -58,7 +65,8 @@ interface Grid {
function weightOf(shore: number, profile: LakeProfile): number {
let weight = 1;
if (shore < SHORE_MARGIN_M) weight += 0.5;
const clearance = CLEARANCE_M[profile.style];
if (shore < clearance) weight += CLEARANCE_WEIGHT * (1 - shore / clearance) ** 2;
if (profile.style === 'shore') {
weight += SHORE_WEIGHT * Math.min(1, Math.max(0, (shore - SHORE_NEAR_M) / (SHORE_FAR_M - SHORE_NEAR_M)));
}
@@ -314,7 +322,8 @@ export class LakeField {
/**
* The line from one cell to another as a paddler would take it: the
* cheapest cell path, then pulled straight wherever a straight line stays on
* water no more costly than the cells it joins.
* water no more costly than the cells it joins and no nearer the shore than
* the path it replaces.
*/
path(from: number, to: number, profile: LakeProfile): Array<[number, number]> | null {
const cells = astar(this.fine(profile), from, to);
@@ -322,21 +331,38 @@ export class LakeField {
return this.pull(cells, profile).map((cell) => this.centre(cell));
}
/**
* Straightens a cell path. A shortcut may not run nearer the shore than the
* stretch of path it replaces (up to the style's clearance): pulling a line
* taut round a bend otherwise draws it onto the bank the path kept off.
*/
private pull(cells: number[], profile: LakeProfile): number[] {
const shore = this.shoreDistance();
const clearance = CLEARANCE_M[profile.style];
const depth = (cell: number) => Math.min(clearance, shore[cell] as number);
const kept = [cells[0] as number];
let anchor = 0;
let floor = depth(cells[0] as number);
for (let i = 2; i < cells.length; i++) {
if (!this.clear(cells[anchor] as number, cells[i] as number, profile)) {
floor = Math.min(floor, depth(cells[i - 1] as number), depth(cells[i] as number));
if (!this.clear(cells[anchor] as number, cells[i] as number, profile, floor)) {
kept.push(cells[i - 1] as number);
anchor = i - 1;
floor = Math.min(depth(cells[i - 1] as number), depth(cells[i] as number));
}
}
if (cells.length > 1) kept.push(cells[cells.length - 1] as number);
return kept;
}
/** Whether a straight line between two cells runs on water no more costly than its ends. */
private clear(a: number, b: number, profile: LakeProfile): boolean {
/**
* Whether a straight line between two cells runs on water no more costly
* than its ends and nowhere nearer the shore than `floor` (less half a cell,
* for the line's rounding onto cells).
*/
private clear(a: number, b: number, profile: LakeProfile, floor: number): boolean {
const shore = this.shoreDistance();
const least = floor - this.lake.cell / 2;
const { width } = this.lake;
const ac = a % width;
const ar = (a - ac) / width;
@@ -348,7 +374,7 @@ export class LakeField {
const c = Math.round(ac + ((bc - ac) * s) / steps);
const r = Math.round(ar + ((br - ar) * s) / steps);
const cell = r * width + c;
if (!this.isWater(cell) || this.weight(cell, profile) > ceiling) return false;
if (!this.isWater(cell) || this.weight(cell, profile) > ceiling || (shore[cell] as number) < least) return false;
}
return true;
}