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;
}
+1
View File
@@ -13,6 +13,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'قرّب الخريطة لتحميل مسارات التسلق الحديدية',
'ferratas.offline': 'تحتاج مسارات التسلق الحديدية إلى اتصال',
'ferratas.unknownGrade': 'درجة الصعوبة غير معروفة',
'ferratas.gradeRange': '{from} إلى {to}',
'ferratas.unnamed': 'مسار تسلق حديدي بلا اسم',
'ferratas.viewInOsm': 'عرض في OpenStreetMap',
'ferratas.attribution': 'البيانات © مساهمو OpenStreetMap',
+1
View File
@@ -65,6 +65,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'اسم الجولة',
'tours.planner.namePlaceholder': 'جولة مشي بلا اسم',
'tours.planner.mapHint': 'انقر على الخريطة لإضافة نقاط.',
'tours.planner.mapSearch': 'ابحث عن مكان أو عنوان',
'tours.planner.waypoints': 'نقاط المرور',
'tours.planner.waypointLabel': 'نقطة المرور {n}',
'tours.durationMinutes': '{count} د',
+1
View File
@@ -9,6 +9,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Via ferrata marşrutlarını yükləmək üçün yaxınlaşdırın',
'ferratas.offline': 'Via ferrata marşrutları üçün bağlantı lazımdır',
'ferratas.unknownGrade': 'Çətinlik naməlumdur',
'ferratas.gradeRange': '{from} - {to}',
'ferratas.unnamed': 'Adsız via ferrata marşrutu',
'ferratas.viewInOsm': 'OpenStreetMap-də bax',
'ferratas.attribution': 'Məlumatlar © OpenStreetMap iştirakçıları',
+1
View File
@@ -63,6 +63,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Turun adı',
'tours.planner.namePlaceholder': 'Adsız yürüş',
'tours.planner.mapHint': 'Nöqtə əlavə etmək üçün xəritəyə klikləyin.',
'tours.planner.mapSearch': 'Yer və ya ünvan axtar',
'tours.planner.waypoints': 'Yol nöqtələri',
'tours.planner.waypointLabel': 'Yol nöqtəsi {n}',
'tours.durationMinutes': '{count} dəq.',
+1
View File
@@ -9,6 +9,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Aproxime o mapa para carregar vias ferratas',
'ferratas.offline': 'As vias ferratas precisam de conexão',
'ferratas.unknownGrade': 'Dificuldade desconhecida',
'ferratas.gradeRange': '{from} a {to}',
'ferratas.unnamed': 'Via ferrata sem nome',
'ferratas.viewInOsm': 'Ver no OpenStreetMap',
'ferratas.attribution': 'Dados © colaboradores do OpenStreetMap',
+1
View File
@@ -64,6 +64,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Nome do passeio',
'tours.planner.namePlaceholder': 'Trilha sem título',
'tours.planner.mapHint': 'Clique no mapa para adicionar pontos.',
'tours.planner.mapSearch': 'Buscar lugar ou endereço',
'tours.planner.waypoints': 'Pontos de passagem',
'tours.planner.waypointLabel': 'Ponto de passagem {n}',
'tours.durationMinutes': '{count} min',
+1
View File
@@ -9,6 +9,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Apropa el mapa per carregar vies ferrades',
'ferratas.offline': 'Les vies ferrades necessiten connexió',
'ferratas.unknownGrade': 'Dificultat desconeguda',
'ferratas.gradeRange': '{from} a {to}',
'ferratas.unnamed': 'Via ferrada sense nom',
'ferratas.viewInOsm': 'Mostra a OpenStreetMap',
'ferratas.attribution': "Dades © col·laboradors d'OpenStreetMap",
+1
View File
@@ -63,6 +63,7 @@ const tours: TranslationStrings = {
'tours.planner.name': "Nom de l'excursió",
'tours.planner.namePlaceholder': 'Ruta de senderisme sense títol',
'tours.planner.mapHint': 'Fes clic al mapa per afegir punts.',
'tours.planner.mapSearch': 'Cerca un lloc o una adreça',
'tours.planner.waypoints': 'Punts de pas',
'tours.planner.waypointLabel': 'Punt de pas {n}',
'tours.durationMinutes': '{count} min',
+1
View File
@@ -10,6 +10,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Přibližte mapu pro načtení ferrat',
'ferratas.offline': 'Ferraty vyžadují připojení',
'ferratas.unknownGrade': 'Obtížnost neznámá',
'ferratas.gradeRange': '{from} až {to}',
'ferratas.unnamed': 'Ferrata bez názvu',
'ferratas.viewInOsm': 'Zobrazit v OpenStreetMap',
'ferratas.attribution': 'Data © přispěvatelé OpenStreetMap',
+1
View File
@@ -62,6 +62,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Název výletu',
'tours.planner.namePlaceholder': 'Túra bez názvu',
'tours.planner.mapHint': 'Kliknutím do mapy přidáte body.',
'tours.planner.mapSearch': 'Hledat místo nebo adresu',
'tours.planner.waypoints': 'Průjezdní body',
'tours.planner.waypointLabel': 'Bod {n}',
'tours.durationMinutes': '{count} min',
+1
View File
@@ -9,6 +9,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Für Klettersteige näher heranzoomen',
'ferratas.offline': 'Klettersteige brauchen eine Verbindung',
'ferratas.unknownGrade': 'Schwierigkeit unbekannt',
'ferratas.gradeRange': '{from} bis {to}',
'ferratas.unnamed': 'Klettersteig ohne Namen',
'ferratas.viewInOsm': 'In OpenStreetMap ansehen',
'ferratas.attribution': 'Daten © OpenStreetMap-Mitwirkende',
+1
View File
@@ -63,6 +63,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Name der Tour',
'tours.planner.namePlaceholder': 'Wanderung ohne Titel',
'tours.planner.mapHint': 'Klicke auf die Karte, um Punkte zu setzen.',
'tours.planner.mapSearch': 'Ort oder Adresse suchen',
'tours.planner.waypoints': 'Wegpunkte',
'tours.planner.waypointLabel': 'Wegpunkt {n}',
'tours.durationMinutes': '{count} Min.',
+1
View File
@@ -9,6 +9,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Zoom in to load via ferratas',
'ferratas.offline': 'Via ferratas need a connection',
'ferratas.unknownGrade': 'Grade unknown',
'ferratas.gradeRange': '{from} to {to}',
'ferratas.unnamed': 'Unnamed via ferrata',
'ferratas.viewInOsm': 'View in OpenStreetMap',
'ferratas.attribution': 'Data © OpenStreetMap contributors',
+1
View File
@@ -62,6 +62,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Tour name',
'tours.planner.namePlaceholder': 'Untitled hike',
'tours.planner.mapHint': 'Click the map to add points.',
'tours.planner.mapSearch': 'Search place or address',
'tours.planner.waypoints': 'Waypoints',
'tours.planner.waypointLabel': 'Waypoint {n}',
'tours.durationMinutes': '{count} min',
+1
View File
@@ -9,6 +9,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Acerca el mapa para cargar vías ferratas',
'ferratas.offline': 'Las vías ferratas necesitan conexión',
'ferratas.unknownGrade': 'Dificultad desconocida',
'ferratas.gradeRange': '{from} a {to}',
'ferratas.unnamed': 'Vía ferrata sin nombre',
'ferratas.viewInOsm': 'Ver en OpenStreetMap',
'ferratas.attribution': 'Datos © colaboradores de OpenStreetMap',
+1
View File
@@ -63,6 +63,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Nombre de la excursión',
'tours.planner.namePlaceholder': 'Ruta de senderismo sin título',
'tours.planner.mapHint': 'Haz clic en el mapa para añadir puntos.',
'tours.planner.mapSearch': 'Buscar lugar o dirección',
'tours.planner.waypoints': 'Puntos de paso',
'tours.planner.waypointLabel': 'Punto de paso {n}',
'tours.durationMinutes': '{count} min',
+1
View File
@@ -9,6 +9,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Via ferrata radade laadimiseks suumi lähemale',
'ferratas.offline': 'Via ferrata rajad vajavad ühendust',
'ferratas.unknownGrade': 'Raskusaste teadmata',
'ferratas.gradeRange': '{from} kuni {to}',
'ferratas.unnamed': 'Nimeta via ferrata',
'ferratas.viewInOsm': 'Vaata OpenStreetMapis',
'ferratas.attribution': 'Andmed © OpenStreetMapi kaastöötajad',
+1
View File
@@ -62,6 +62,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Retke nimi',
'tours.planner.namePlaceholder': 'Nimetu matk',
'tours.planner.mapHint': 'Klõpsa kaardil punktide lisamiseks.',
'tours.planner.mapSearch': 'Otsi kohta või aadressi',
'tours.planner.waypoints': 'Teekonnapunktid',
'tours.planner.waypointLabel': 'Teekonnapunkt {n}',
'tours.durationMinutes': '{count} min',
+1
View File
@@ -9,6 +9,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Zoomez pour charger les via ferrata',
'ferratas.offline': 'Les via ferrata nécessitent une connexion',
'ferratas.unknownGrade': 'Difficulté inconnue',
'ferratas.gradeRange': '{from} à {to}',
'ferratas.unnamed': 'Via ferrata sans nom',
'ferratas.viewInOsm': 'Voir dans OpenStreetMap',
'ferratas.attribution': 'Données © contributeurs OpenStreetMap',
+1
View File
@@ -65,6 +65,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Nom de la sortie',
'tours.planner.namePlaceholder': 'Randonnée sans titre',
'tours.planner.mapHint': 'Cliquez sur la carte pour ajouter des points.',
'tours.planner.mapSearch': 'Rechercher un lieu ou une adresse',
'tours.planner.waypoints': 'Points de passage',
'tours.planner.waypointLabel': 'Point de passage {n}',
'tours.durationMinutes': '{count} min',
+1
View File
@@ -9,6 +9,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Μεγεθύνετε για να φορτωθούν διαδρομές via ferrata',
'ferratas.offline': 'Οι διαδρομές via ferrata χρειάζονται σύνδεση',
'ferratas.unknownGrade': 'Άγνωστη δυσκολία',
'ferratas.gradeRange': '{from} έως {to}',
'ferratas.unnamed': 'Διαδρομή via ferrata χωρίς όνομα',
'ferratas.viewInOsm': 'Προβολή στο OpenStreetMap',
'ferratas.attribution': 'Δεδομένα © συντελεστές του OpenStreetMap',
+1
View File
@@ -65,6 +65,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Όνομα εκδρομής',
'tours.planner.namePlaceholder': 'Πεζοπορία χωρίς όνομα',
'tours.planner.mapHint': 'Κάντε κλικ στον χάρτη για να προσθέσετε σημεία.',
'tours.planner.mapSearch': 'Αναζήτηση τόπου ή διεύθυνσης',
'tours.planner.waypoints': 'Σημεία διέλευσης',
'tours.planner.waypointLabel': 'Σημείο διέλευσης {n}',
'tours.durationMinutes': '{count} λεπτά',
+1
View File
@@ -9,6 +9,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Nagyíts rá a vasalt utak betöltéséhez',
'ferratas.offline': 'A vasalt utakhoz kapcsolat szükséges',
'ferratas.unknownGrade': 'Ismeretlen nehézség',
'ferratas.gradeRange': '{from}–{to}',
'ferratas.unnamed': 'Névtelen vasalt út',
'ferratas.viewInOsm': 'Megtekintés az OpenStreetMapen',
'ferratas.attribution': 'Adatok © OpenStreetMap-közreműködők',
+1
View File
@@ -62,6 +62,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Túra neve',
'tours.planner.namePlaceholder': 'Névtelen gyalogtúra',
'tours.planner.mapHint': 'Kattints a térképre pontok hozzáadásához.',
'tours.planner.mapSearch': 'Hely vagy cím keresése',
'tours.planner.waypoints': 'Útvonalpontok',
'tours.planner.waypointLabel': '{n}. útvonalpont',
'tours.durationMinutes': '{count} perc',
+1
View File
@@ -8,6 +8,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Perbesar peta untuk memuat jalur via ferrata',
'ferratas.offline': 'Jalur via ferrata memerlukan koneksi',
'ferratas.unknownGrade': 'Tingkat kesulitan tidak diketahui',
'ferratas.gradeRange': '{from} hingga {to}',
'ferratas.unnamed': 'Via ferrata tanpa nama',
'ferratas.viewInOsm': 'Lihat di OpenStreetMap',
'ferratas.attribution': 'Data © kontributor OpenStreetMap',
+1
View File
@@ -62,6 +62,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Nama tur',
'tours.planner.namePlaceholder': 'Pendakian tanpa judul',
'tours.planner.mapHint': 'Klik peta untuk menambahkan titik.',
'tours.planner.mapSearch': 'Cari tempat atau alamat',
'tours.planner.waypoints': 'Titik lintasan',
'tours.planner.waypointLabel': 'Titik lintasan {n}',
'tours.durationMinutes': '{count} menit',
+1
View File
@@ -9,6 +9,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Ingrandisci per caricare le vie ferrate',
'ferratas.offline': 'Le vie ferrate richiedono una connessione',
'ferratas.unknownGrade': 'Difficoltà sconosciuta',
'ferratas.gradeRange': 'da {from} a {to}',
'ferratas.unnamed': 'Via ferrata senza nome',
'ferratas.viewInOsm': 'Vedi su OpenStreetMap',
'ferratas.attribution': 'Dati © contributori di OpenStreetMap',
+1
View File
@@ -64,6 +64,7 @@ const tours: TranslationStrings = {
'tours.planner.name': "Nome dell'escursione",
'tours.planner.namePlaceholder': 'Escursione a piedi senza titolo',
'tours.planner.mapHint': 'Fai clic sulla mappa per aggiungere punti.',
'tours.planner.mapSearch': 'Cerca luogo o indirizzo',
'tours.planner.waypoints': 'Punti di passaggio',
'tours.planner.waypointLabel': 'Punto di passaggio {n}',
'tours.durationMinutes': '{count} min',
+1
View File
@@ -8,6 +8,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'ヴィア・フェラータを読み込むには拡大してください',
'ferratas.offline': 'ヴィア・フェラータの表示には接続が必要です',
'ferratas.unknownGrade': '難易度不明',
'ferratas.gradeRange': '{from}〜{to}',
'ferratas.unnamed': '名称未設定のヴィア・フェラータ',
'ferratas.viewInOsm': 'OpenStreetMap で表示',
'ferratas.attribution': 'データ © OpenStreetMap 貢献者',
+1
View File
@@ -60,6 +60,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'ツアー名',
'tours.planner.namePlaceholder': '名前のないハイキング',
'tours.planner.mapHint': '地図をクリックして地点を追加します。',
'tours.planner.mapSearch': '場所や住所を検索',
'tours.planner.waypoints': 'ルート上の地点',
'tours.planner.waypointLabel': '地点{n}',
'tours.durationMinutes': '{count}分',
+1
View File
@@ -8,6 +8,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': '비아 페라타를 불러오려면 확대하세요',
'ferratas.offline': '비아 페라타를 보려면 연결이 필요합니다',
'ferratas.unknownGrade': '난이도 알 수 없음',
'ferratas.gradeRange': '{from}~{to}',
'ferratas.unnamed': '이름 없는 비아 페라타',
'ferratas.viewInOsm': 'OpenStreetMap에서 보기',
'ferratas.attribution': '데이터 © OpenStreetMap 기여자',
+1
View File
@@ -61,6 +61,7 @@ const tours: TranslationStrings = {
'tours.planner.name': '투어 이름',
'tours.planner.namePlaceholder': '이름 없는 하이킹',
'tours.planner.mapHint': '지도를 클릭해 지점을 추가하세요.',
'tours.planner.mapSearch': '장소 또는 주소 검색',
'tours.planner.waypoints': '경로 지점',
'tours.planner.waypointLabel': '지점 {n}',
'tours.durationMinutes': '{count}분',
+1
View File
@@ -9,6 +9,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': "Zoom in om via ferrata's te laden",
'ferratas.offline': "Via ferrata's hebben een verbinding nodig",
'ferratas.unknownGrade': 'Moeilijkheid onbekend',
'ferratas.gradeRange': '{from} tot {to}',
'ferratas.unnamed': 'Via ferrata zonder naam',
'ferratas.viewInOsm': 'Bekijken in OpenStreetMap',
'ferratas.attribution': 'Gegevens © OpenStreetMap-bijdragers',
+1
View File
@@ -63,6 +63,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Naam van de tocht',
'tours.planner.namePlaceholder': 'Naamloze wandeling',
'tours.planner.mapHint': 'Klik op de kaart om punten toe te voegen.',
'tours.planner.mapSearch': 'Zoek plaats of adres',
'tours.planner.waypoints': 'Routepunten',
'tours.planner.waypointLabel': 'Routepunt {n}',
'tours.durationMinutes': '{count} min',
+1
View File
@@ -11,6 +11,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Przybliż mapę, aby wczytać ferraty',
'ferratas.offline': 'Ferraty wymagają połączenia',
'ferratas.unknownGrade': 'Nieznana trudność',
'ferratas.gradeRange': 'od {from} do {to}',
'ferratas.unnamed': 'Ferrata bez nazwy',
'ferratas.viewInOsm': 'Pokaż w OpenStreetMap',
'ferratas.attribution': 'Dane © współtwórcy OpenStreetMap',
+1
View File
@@ -65,6 +65,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Nazwa wycieczki',
'tours.planner.namePlaceholder': 'Wędrówka bez nazwy',
'tours.planner.mapHint': 'Kliknij mapę, aby dodać punkty.',
'tours.planner.mapSearch': 'Szukaj miejsca lub adresu',
'tours.planner.waypoints': 'Punkty trasy',
'tours.planner.waypointLabel': 'Punkt trasy {n}',
'tours.durationMinutes': '{count} min',
+1
View File
@@ -11,6 +11,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Приблизьте карту, чтобы загрузить виа ферраты',
'ferratas.offline': 'Для виа феррат нужно подключение',
'ferratas.unknownGrade': 'Сложность неизвестна',
'ferratas.gradeRange': 'от {from} до {to}',
'ferratas.unnamed': 'Виа феррата без названия',
'ferratas.viewInOsm': 'Открыть в OpenStreetMap',
'ferratas.attribution': 'Данные © участники OpenStreetMap',
+1
View File
@@ -65,6 +65,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Название тура',
'tours.planner.namePlaceholder': 'Поход без названия',
'tours.planner.mapHint': 'Нажмите на карту, чтобы добавить точки.',
'tours.planner.mapSearch': 'Поиск места или адреса',
'tours.planner.waypoints': 'Путевые точки',
'tours.planner.waypointLabel': 'Путевая точка {n}',
'tours.durationMinutes': '{count} мин',
+1
View File
@@ -10,6 +10,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Priblížte mapu na načítanie ferrát',
'ferratas.offline': 'Ferraty vyžadujú pripojenie',
'ferratas.unknownGrade': 'Neznáma obťažnosť',
'ferratas.gradeRange': '{from} až {to}',
'ferratas.unnamed': 'Ferrata bez názvu',
'ferratas.viewInOsm': 'Zobraziť v OpenStreetMap',
'ferratas.attribution': 'Údaje © prispievatelia OpenStreetMap',
+1
View File
@@ -62,6 +62,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Názov túry',
'tours.planner.namePlaceholder': 'Pešia túra bez názvu',
'tours.planner.mapHint': 'Kliknutím do mapy pridáte body.',
'tours.planner.mapSearch': 'Hľadať miesto alebo adresu',
'tours.planner.waypoints': 'Body trasy',
'tours.planner.waypointLabel': 'Bod trasy {n}',
'tours.durationMinutes': '{count} min',
+1
View File
@@ -9,6 +9,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Zooma in för att läsa in via ferrata-leder',
'ferratas.offline': 'Via ferrata-leder kräver en anslutning',
'ferratas.unknownGrade': 'Okänd svårighetsgrad',
'ferratas.gradeRange': '{from} till {to}',
'ferratas.unnamed': 'Namnlös via ferrata',
'ferratas.viewInOsm': 'Visa i OpenStreetMap',
'ferratas.attribution': 'Data © OpenStreetMap-bidragsgivare',
+1
View File
@@ -62,6 +62,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Turens namn',
'tours.planner.namePlaceholder': 'Vandring utan namn',
'tours.planner.mapHint': 'Klicka på kartan för att lägga till punkter.',
'tours.planner.mapSearch': 'Sök plats eller adress',
'tours.planner.waypoints': 'Ruttpunkter',
'tours.planner.waypointLabel': 'Ruttpunkt {n}',
'tours.durationMinutes': '{count} min',
+1
View File
@@ -8,6 +8,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'ซูมเข้าเพื่อโหลดเส้นทางเวียเฟอร์ราตา',
'ferratas.offline': 'เส้นทางเวียเฟอร์ราตาต้องใช้การเชื่อมต่อ',
'ferratas.unknownGrade': 'ไม่ทราบระดับความยาก',
'ferratas.gradeRange': '{from} ถึง {to}',
'ferratas.unnamed': 'เส้นทางเวียเฟอร์ราตาไม่มีชื่อ',
'ferratas.viewInOsm': 'ดูใน OpenStreetMap',
'ferratas.attribution': 'ข้อมูล © ผู้ร่วมให้ข้อมูล OpenStreetMap',
+1
View File
@@ -61,6 +61,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'ชื่อทัวร์',
'tours.planner.namePlaceholder': 'เส้นทางเดินป่าที่ยังไม่มีชื่อ',
'tours.planner.mapHint': 'คลิกแผนที่เพื่อเพิ่มจุด',
'tours.planner.mapSearch': 'ค้นหาสถานที่หรือที่อยู่',
'tours.planner.waypoints': 'จุดแวะ',
'tours.planner.waypointLabel': 'จุดแวะ {n}',
'tours.durationMinutes': '{count} นาที',
+2 -2
View File
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
// placeholder spec; here only the general keys are.
const groups = pluralGroups(Object.keys(english));
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
const GENERAL_KEY_COUNT = 147;
const GENERAL_KEY_COUNT = 148;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
@@ -34,7 +34,7 @@ function literalKeys(source: string): string[] {
}
describe('Tours locale contracts', () => {
it('covers all 27 supported locales with 147 general source keys', () => {
it('covers all 27 supported locales with 148 general source keys', () => {
expect(Object.keys(domains)).toHaveLength(27);
expect(Object.keys(domains).sort()).toEqual(SUPPORTED_LANGUAGE_CODES.map((code) => `./${code}/tours.ts`).sort());
expect(generalKeys(Object.keys(english))).toHaveLength(GENERAL_KEY_COUNT);
+1
View File
@@ -9,6 +9,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Via ferrata rotalarını yüklemek için yakınlaştırın',
'ferratas.offline': 'Via ferrata rotaları için bağlantı gerekiyor',
'ferratas.unknownGrade': 'Zorluk bilinmiyor',
'ferratas.gradeRange': '{from} ile {to} arası',
'ferratas.unnamed': 'Adsız via ferrata',
'ferratas.viewInOsm': "OpenStreetMap'te görüntüle",
'ferratas.attribution': 'Veriler © OpenStreetMap katkıcıları',
+1
View File
@@ -62,6 +62,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Tur adı',
'tours.planner.namePlaceholder': 'Adsız doğa yürüyüşü',
'tours.planner.mapHint': 'Nokta eklemek için haritaya tıklayın.',
'tours.planner.mapSearch': 'Yer veya adres ara',
'tours.planner.waypoints': 'Rota noktaları',
'tours.planner.waypointLabel': 'Rota noktası {n}',
'tours.durationMinutes': '{count} dk',
+1
View File
@@ -11,6 +11,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Наблизьте мапу, щоб завантажити віа ферати',
'ferratas.offline': 'Для віа ферат потрібне з’єднання',
'ferratas.unknownGrade': 'Складність невідома',
'ferratas.gradeRange': 'від {from} до {to}',
'ferratas.unnamed': 'Віа ферата без назви',
'ferratas.viewInOsm': 'Відкрити в OpenStreetMap',
'ferratas.attribution': 'Дані © учасники OpenStreetMap',
+1
View File
@@ -64,6 +64,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Назва туру',
'tours.planner.namePlaceholder': 'Похід без назви',
'tours.planner.mapHint': 'Натисніть на карту, щоб додати точки.',
'tours.planner.mapSearch': 'Пошук місця або адреси',
'tours.planner.waypoints': 'Маршрутні точки',
'tours.planner.waypointLabel': 'Маршрутна точка {n}',
'tours.durationMinutes': '{count} хв',
+1
View File
@@ -8,6 +8,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': 'Phóng to để tải các tuyến via ferrata',
'ferratas.offline': 'Các tuyến via ferrata cần kết nối mạng',
'ferratas.unknownGrade': 'Không rõ độ khó',
'ferratas.gradeRange': '{from} đến {to}',
'ferratas.unnamed': 'Via ferrata chưa có tên',
'ferratas.viewInOsm': 'Xem trên OpenStreetMap',
'ferratas.attribution': 'Dữ liệu © những người đóng góp OpenStreetMap',
+1
View File
@@ -62,6 +62,7 @@ const tours: TranslationStrings = {
'tours.planner.name': 'Tên chuyến tham quan',
'tours.planner.namePlaceholder': 'Chuyến đi bộ đường dài chưa có tên',
'tours.planner.mapHint': 'Nhấp vào bản đồ để thêm điểm.',
'tours.planner.mapSearch': 'Tìm địa điểm hoặc địa chỉ',
'tours.planner.waypoints': 'Các điểm trên tuyến',
'tours.planner.waypointLabel': 'Điểm {n}',
'tours.durationMinutes': '{count} phút',
+1
View File
@@ -8,6 +8,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': '放大地圖以載入飛拉達',
'ferratas.offline': '飛拉達需要網路連線',
'ferratas.unknownGrade': '難度不明',
'ferratas.gradeRange': '{from} 至 {to}',
'ferratas.unnamed': '未命名的飛拉達',
'ferratas.viewInOsm': '在 OpenStreetMap 中檢視',
'ferratas.attribution': '資料 © OpenStreetMap 貢獻者',
+1
View File
@@ -58,6 +58,7 @@ const tours: TranslationStrings = {
'tours.planner.name': '遊程名稱',
'tours.planner.namePlaceholder': '未命名的健行',
'tours.planner.mapHint': '點選地圖以加入地點。',
'tours.planner.mapSearch': '搜尋地點或地址',
'tours.planner.waypoints': '路線點',
'tours.planner.waypointLabel': '路線點 {n}',
'tours.durationMinutes': '{count} 分鐘',
+1
View File
@@ -8,6 +8,7 @@ const ferratas: TranslationStrings = {
'ferratas.zoomIn': '放大地图以加载飞拉达',
'ferratas.offline': '飞拉达需要网络连接',
'ferratas.unknownGrade': '难度未知',
'ferratas.gradeRange': '{from} 至 {to}',
'ferratas.unnamed': '未命名飞拉达',
'ferratas.viewInOsm': '在 OpenStreetMap 中查看',
'ferratas.attribution': '数据 © OpenStreetMap 贡献者',
+1
View File
@@ -58,6 +58,7 @@ const tours: TranslationStrings = {
'tours.planner.name': '游览名称',
'tours.planner.namePlaceholder': '未命名的徒步活动',
'tours.planner.mapHint': '点击地图添加地点。',
'tours.planner.mapSearch': '搜索地点或地址',
'tours.planner.waypoints': '路线点',
'tours.planner.waypointLabel': '路线点 {n}',
'tours.durationMinutes': '{count} 分钟',
+2
View File
@@ -118,3 +118,5 @@ export * from './permissions/permissions';
// Via ferratas around a trip, from OpenStreetMap through Overpass (GET /api/trips/:tripId/ferratas).
export * from './ferratas/ferratas.schema';
// The 0.05 degree tile grid both ends load, cache and persist the layer on.
export * from './ferratas/ferrataGrid';