mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
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:
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user