feat(ferratas): keep loaded via ferratas in a tile cache on server and device, group segments, own look and count badge

This commit is contained in:
Maurice
2026-10-14 16:21:30 +02:00
parent a9882a6415
commit 160bc89e78
126 changed files with 5003 additions and 1153 deletions
+124
View File
@@ -0,0 +1,124 @@
import {
FERRATA_TILE_DEG,
FERRATA_TILES_PER_REQUEST_SIDE,
ferrataTileAt,
ferrataTileCount,
ferrataTileKey,
ferrataTileRange,
ferrataTileRangeBbox,
ferrataTileBlockKey,
ferrataTileRequests,
ferrataTilesOf,
growFerrataTileRange,
parseFerrataTileKey,
snapFerrataBbox,
} from './ferrataGrid';
import { ferrataBboxSpanOk } from './ferratas.schema';
import { describe, expect, it } from 'vitest';
describe('ferrataGrid', () => {
it('uses one 0.05 degree cell and two cells per request side', () => {
expect(FERRATA_TILE_DEG).toBe(0.05);
expect(FERRATA_TILES_PER_REQUEST_SIDE).toBe(2);
});
it('snaps a box outwards to the grid and clamps it to the globe', () => {
expect(snapFerrataBbox({ south: 46.51, west: 11.72, north: 46.58, east: 11.81 })).toEqual({
south: 46.5,
west: 11.7,
north: 46.6,
east: 11.85,
});
// An edge already on a grid line stays where it is, float noise or not.
expect(snapFerrataBbox({ south: 46.15, west: 11.7, north: 46.2, east: 11.75 })).toEqual({
south: 46.15,
west: 11.7,
north: 46.2,
east: 11.75,
});
expect(snapFerrataBbox({ south: -90, west: -180, north: 90, east: 180 })).toEqual({
south: -90,
west: -180,
north: 90,
east: 180,
});
});
it('names the tile a point lies in, a point on a grid line in the tile north east of it', () => {
expect(ferrataTileAt(46.52, 11.81)).toEqual({ row: 930, col: 236 });
expect(ferrataTileAt(46.15, 11.7)).toEqual({ row: 923, col: 234 });
expect(ferrataTileAt(-0.01, -0.01)).toEqual({ row: -1, col: -1 });
// The poles and the antimeridian stay inside the grid.
expect(ferrataTileAt(90, 180)).toEqual({ row: 1799, col: 3599 });
});
it('round-trips a tile through its key and refuses anything else', () => {
expect(parseFerrataTileKey(ferrataTileKey({ row: -12, col: 340 }))).toEqual({ row: -12, col: 340 });
expect(parseFerrataTileKey('930:236')).toEqual({ row: 930, col: 236 });
expect(parseFerrataTileKey('930,236')).toBeNull();
expect(parseFerrataTileKey('99999:1')).toBeNull();
expect(parseFerrataTileKey('')).toBeNull();
});
it('covers a box with the tiles it touches, not the next row on a grid line', () => {
const range = ferrataTileRange({ south: 46.5, west: 11.7, north: 46.6, east: 11.81 });
expect(range).toEqual({ minRow: 930, maxRow: 931, minCol: 234, maxCol: 236 });
expect(ferrataTileCount(range)).toBe(6);
expect(ferrataTilesOf(range).map(ferrataTileKey)).toEqual([
'930:234',
'930:235',
'930:236',
'931:234',
'931:235',
'931:236',
]);
expect(ferrataTileRangeBbox(range)).toEqual({ south: 46.5, west: 11.7, north: 46.6, east: 11.85 });
});
it('grows a range by a ring on every side', () => {
const range = { minRow: 930, maxRow: 931, minCol: 234, maxCol: 236 };
expect(growFerrataTileRange(range, 1)).toEqual({ minRow: 929, maxRow: 932, minCol: 233, maxCol: 237 });
expect(growFerrataTileRange({ minRow: 1799, maxRow: 1799, minCol: 0, maxCol: 0 }, 1).maxRow).toBe(1799);
});
it('asks for tiles of one grid-aligned block with one box around them', () => {
const requests = ferrataTileRequests([
{ row: 930, col: 234 },
{ row: 931, col: 235 },
]);
expect(requests).toEqual([
{
bbox: { south: 46.5, west: 11.7, north: 46.6, east: 11.8 },
range: { minRow: 930, maxRow: 931, minCol: 234, maxCol: 235 },
},
]);
expect(ferrataTileRequests([])).toEqual([]);
});
it('splits tiles that straddle a block line along it, so each request is one block of the server', () => {
const requests = ferrataTileRequests([
{ row: 931, col: 235 },
{ row: 932, col: 236 },
]);
expect(requests.map(({ range }) => range)).toEqual([
{ minRow: 931, maxRow: 931, minCol: 235, maxCol: 235 },
{ minRow: 932, maxRow: 932, minCol: 236, maxCol: 236 },
]);
expect(ferrataTileBlockKey(931, 235)).toBe('465:117');
expect(ferrataTileBlockKey(-1, -3)).toBe('-1:-2');
});
it('cuts a wider set into boxes of at most 0.1 degree a side, each covering only tiles it holds', () => {
const tiles = ferrataTilesOf({ minRow: 900, maxRow: 911, minCol: 200, maxCol: 202 });
const requests = ferrataTileRequests(tiles);
expect(requests).toHaveLength(12);
for (const { bbox } of requests) {
expect(bbox.north - bbox.south).toBeLessThanOrEqual(0.1 + 1e-9);
expect(bbox.east - bbox.west).toBeLessThanOrEqual(0.1 + 1e-9);
}
for (const { bbox } of requests) expect(ferrataBboxSpanOk(bbox)).toBe(true);
const covered = requests.flatMap(({ range }) => ferrataTilesOf(range).map(ferrataTileKey)).sort();
expect(covered).toEqual(tiles.map(ferrataTileKey).sort());
});
});
+187
View File
@@ -0,0 +1,187 @@
import type { FerrataBbox } from './ferratas.schema';
/**
* The tile grid of the via ferrata layer, shared by both ends.
*
* The server caches Overpass answers per box snapped outwards to this grid,
* and the client loads, keeps and persists the layer per tile of it. One cell
* size on both sides means a box the client asks for is always a whole
* number of cells, which is exactly a cache entry on the server.
*
* A tile is named by its row and column: row = floor(lat / 0.05), column =
* floor(lng / 0.05). Pure, no I/O.
*/
/** The cell size, in degrees, on both axes. */
export const FERRATA_TILE_DEG = 0.05;
/**
* The most tiles one Overpass query covers per side: two, a box of 0.1 degree.
* A box of that size answers in a second or two, where one of half a degree
* often ran into the mirrors' timeout. The client asks in blocks of this size
* and the server queries Overpass in the same blocks, both aligned to the
* grid (a block starts at a row and a column divisible by it), so one request
* of the client is exactly one query of the server.
*/
export const FERRATA_TILES_PER_REQUEST_SIDE = 2;
/** Room for float noise when an edge sits exactly on a grid line (46.15 / 0.05 is 922.9999999999999). */
const GRID_EPSILON = 1e-9;
const MIN_ROW = Math.round(-90 / FERRATA_TILE_DEG);
const MAX_ROW = Math.round(90 / FERRATA_TILE_DEG) - 1;
const MIN_COL = Math.round(-180 / FERRATA_TILE_DEG);
const MAX_COL = Math.round(180 / FERRATA_TILE_DEG) - 1;
export interface FerrataTile {
row: number;
col: number;
}
/** A rectangle of tiles, both ends inclusive. */
export interface FerrataTileRange {
minRow: number;
maxRow: number;
minCol: number;
maxCol: number;
}
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const snapDown = (v: number) => Math.floor(v / FERRATA_TILE_DEG + GRID_EPSILON) * FERRATA_TILE_DEG;
const snapUp = (v: number) => Math.ceil(v / FERRATA_TILE_DEG - GRID_EPSILON) * FERRATA_TILE_DEG;
/** Every grid line is a multiple of 0.05, so two decimals write it exactly. */
const fixed = (v: number) => Number(v.toFixed(2));
/**
* The box grown outwards to the grid and clamped to the globe. The server
* queries Overpass for this box and caches the answer under it.
*/
export function snapFerrataBbox(bbox: FerrataBbox): FerrataBbox {
return {
south: fixed(Math.max(-90, snapDown(bbox.south))),
west: fixed(Math.max(-180, snapDown(bbox.west))),
north: fixed(Math.min(90, snapUp(bbox.north))),
east: fixed(Math.min(180, snapUp(bbox.east))),
};
}
/** The tile a point lies in; a point on a grid line belongs to the tile north and east of it. */
export function ferrataTileAt(lat: number, lng: number): FerrataTile {
return {
row: clamp(Math.floor(lat / FERRATA_TILE_DEG + GRID_EPSILON), MIN_ROW, MAX_ROW),
col: clamp(Math.floor(lng / FERRATA_TILE_DEG + GRID_EPSILON), MIN_COL, MAX_COL),
};
}
/** The tile's stable name, as it is kept in memory and in IndexedDB. */
export function ferrataTileKey(tile: FerrataTile): string {
return `${tile.row}:${tile.col}`;
}
/** The tile a key names, or null for anything that is not one. */
export function parseFerrataTileKey(key: string): FerrataTile | null {
const match = /^(-?\d+):(-?\d+)$/.exec(key);
if (!match) return null;
const row = Number(match[1]);
const col = Number(match[2]);
if (row < MIN_ROW || row > MAX_ROW || col < MIN_COL || col > MAX_COL) return null;
return { row, col };
}
/** The tiles a box touches, as a range. A box on a grid line does not reach into the next row or column. */
export function ferrataTileRange(bbox: FerrataBbox): FerrataTileRange {
const sw = ferrataTileAt(bbox.south, bbox.west);
const lastRow = Math.ceil(bbox.north / FERRATA_TILE_DEG - GRID_EPSILON) - 1;
const lastCol = Math.ceil(bbox.east / FERRATA_TILE_DEG - GRID_EPSILON) - 1;
return {
minRow: sw.row,
maxRow: clamp(Math.max(sw.row, lastRow), MIN_ROW, MAX_ROW),
minCol: sw.col,
maxCol: clamp(Math.max(sw.col, lastCol), MIN_COL, MAX_COL),
};
}
/** The range grown by `rings` tiles on every side, inside the globe. */
export function growFerrataTileRange(range: FerrataTileRange, rings: number): FerrataTileRange {
return {
minRow: clamp(range.minRow - rings, MIN_ROW, MAX_ROW),
maxRow: clamp(range.maxRow + rings, MIN_ROW, MAX_ROW),
minCol: clamp(range.minCol - rings, MIN_COL, MAX_COL),
maxCol: clamp(range.maxCol + rings, MIN_COL, MAX_COL),
};
}
/** How many tiles a range holds. */
export function ferrataTileCount(range: FerrataTileRange): number {
return (range.maxRow - range.minRow + 1) * (range.maxCol - range.minCol + 1);
}
/** Every tile of a range, row by row from the south west. */
export function ferrataTilesOf(range: FerrataTileRange): FerrataTile[] {
const tiles: FerrataTile[] = [];
for (let row = range.minRow; row <= range.maxRow; row += 1) {
for (let col = range.minCol; col <= range.maxCol; col += 1) tiles.push({ row, col });
}
return tiles;
}
/** The box a range of tiles covers, edges on the grid. */
export function ferrataTileRangeBbox(range: FerrataTileRange): FerrataBbox {
return {
south: fixed(Math.max(-90, range.minRow * FERRATA_TILE_DEG)),
west: fixed(Math.max(-180, range.minCol * FERRATA_TILE_DEG)),
north: fixed(Math.min(90, (range.maxRow + 1) * FERRATA_TILE_DEG)),
east: fixed(Math.min(180, (range.maxCol + 1) * FERRATA_TILE_DEG)),
};
}
/** The smallest range holding every tile, or null for none. */
function enclosing(tiles: readonly FerrataTile[]): FerrataTileRange | null {
if (tiles.length === 0) return null;
let minRow = Infinity;
let maxRow = -Infinity;
let minCol = Infinity;
let maxCol = -Infinity;
for (const { row, col } of tiles) {
minRow = Math.min(minRow, row);
maxRow = Math.max(maxRow, row);
minCol = Math.min(minCol, col);
maxCol = Math.max(maxCol, col);
}
return { minRow, maxRow, minCol, maxCol };
}
/** One request: the box to ask for and the range of tiles its answer completes. */
export interface FerrataTileRequest {
bbox: FerrataBbox;
range: FerrataTileRange;
}
/** The grid-aligned block of FERRATA_TILES_PER_REQUEST_SIDE tiles a side that a tile falls in. */
export function ferrataTileBlockKey(row: number, col: number): string {
const side = FERRATA_TILES_PER_REQUEST_SIDE;
return `${Math.floor(row / side)}:${Math.floor(col / side)}`;
}
/**
* The requests that load `tiles`: one per grid-aligned block of
* FERRATA_TILES_PER_REQUEST_SIDE tiles a side that holds any of them, each box
* shrunk to the tiles it holds. Every box is a whole number of tiles and well
* within the server's span limit, so none is refused, each answer completes
* every tile its box covers, and the server answers it with one query.
*/
export function ferrataTileRequests(tiles: readonly FerrataTile[]): FerrataTileRequest[] {
const blocks = new Map<string, FerrataTile[]>();
for (const tile of tiles) {
const key = ferrataTileBlockKey(tile.row, tile.col);
const block = blocks.get(key);
if (block) block.push(tile);
else blocks.set(key, [tile]);
}
const requests: FerrataTileRequest[] = [];
for (const block of blocks.values()) {
const range = enclosing(block);
if (range) requests.push({ bbox: ferrataTileRangeBbox(range), range });
}
return requests;
}