diff --git a/.env b/.env
index 249f64a2..3232d4e0 100644
--- a/.env
+++ b/.env
@@ -24,3 +24,6 @@ NEXT_PUBLIC_API_KEY_GRAPHHOPPER=f189b841-6529-46c6-8a91-51f17477dcda
# Umami anylytics is used to track server load only. We don't want to track clicks in browser.
# optional, fill blank to disable
UMAMI_WEBSITE_ID=5e4d4917-9031-42f1-a26a-e71d7ab8e3fe
+
+# Use climbingTiles loaded from openclimbing.org (instead of Overpass version)
+#NEXT_PUBLIC_ENABLE_CLIMBING_TILES=true
diff --git a/package.json b/package.json
index 07ff9f8a..aecdccf1 100644
--- a/package.json
+++ b/package.json
@@ -55,6 +55,8 @@
"open-location-code": "^1.0.3",
"opening_hours": "^3.8.0",
"osm-auth": "^2.5.0",
+ "pg": "^8.13.1",
+ "pg-format": "^1.0.4",
"react": "^18.3.1",
"react-custom-scrollbars": "^4.2.1",
"react-dom": "^18.3.1",
@@ -69,6 +71,7 @@
"@types/jest": "^29.5.13",
"@types/js-cookie": "^3.0.6",
"@types/lodash": "^4.17.10",
+ "@types/pg-format": "^1.0.5",
"@types/react-custom-scrollbars": "^4.0.13",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/typescript-estree": "^8.8.1",
diff --git a/pages/api/climbing-tiles/refresh.ts b/pages/api/climbing-tiles/refresh.ts
new file mode 100644
index 00000000..483d2fee
--- /dev/null
+++ b/pages/api/climbing-tiles/refresh.ts
@@ -0,0 +1,17 @@
+import type { NextApiRequest, NextApiResponse } from 'next';
+import { refreshClimbingTiles } from '../../../src/server/climbing-tiles/refreshClimbingTiles';
+
+export default async (req: NextApiRequest, res: NextApiResponse) => {
+ try {
+ if (!process.env.XATA_PASSWORD) {
+ throw new Error('XATA_PASSWORD must be set');
+ }
+
+ const log = await refreshClimbingTiles();
+
+ res.status(200).send(log);
+ } catch (err) {
+ console.error(err); // eslint-disable-line no-console
+ res.status(err.code ?? 400).send(String(err));
+ }
+};
diff --git a/pages/api/climbing-tiles/tile.ts b/pages/api/climbing-tiles/tile.ts
new file mode 100644
index 00000000..f11f836b
--- /dev/null
+++ b/pages/api/climbing-tiles/tile.ts
@@ -0,0 +1,41 @@
+import type { NextApiRequest, NextApiResponse } from 'next';
+import { getClimbingTile } from '../../../src/server/climbing-tiles/getClimbingTile';
+import { Tile } from '../../../src/types';
+
+const CORS_ORIGINS = [
+ 'http://localhost:3000',
+ 'http://127.0.0.1:3000',
+ 'https://osmapp.org',
+];
+
+const addCorsHeaders = (req: NextApiRequest, res: NextApiResponse) => {
+ const origin = req.headers.origin;
+ if (CORS_ORIGINS.includes(origin)) {
+ res.setHeader('Access-Control-Allow-Origin', origin);
+ }
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
+};
+
+export default async (req: NextApiRequest, res: NextApiResponse) => {
+ addCorsHeaders(req, res);
+ try {
+ if (!process.env.XATA_PASSWORD) {
+ throw new Error('XATA_PASSWORD must be set');
+ }
+
+ const tileNumber: Tile = {
+ z: Number(req.query.z),
+ x: Number(req.query.x),
+ y: Number(req.query.y),
+ };
+
+ const geojson = await getClimbingTile(tileNumber);
+
+ res.status(200).send(geojson);
+ } catch (err) {
+ console.error(err); // eslint-disable-line no-console
+ res.status(err.code ?? 400).send(String(err));
+ }
+};
diff --git a/src/components/Map/TopMenu/HamburgerMenu.tsx b/src/components/Map/TopMenu/HamburgerMenu.tsx
index 4813286d..5b03c25f 100644
--- a/src/components/Map/TopMenu/HamburgerMenu.tsx
+++ b/src/components/Map/TopMenu/HamburgerMenu.tsx
@@ -204,12 +204,6 @@ export const HamburgerMenu = () => {
- {isOpenClimbing && (
- <>
-
-
- >
- )}
diff --git a/src/components/Map/behaviour/useUpdateStyle.tsx b/src/components/Map/behaviour/useUpdateStyle.tsx
index a611c682..d648e943 100644
--- a/src/components/Map/behaviour/useUpdateStyle.tsx
+++ b/src/components/Map/behaviour/useUpdateStyle.tsx
@@ -22,6 +22,7 @@ import { setUpHover } from './featureHover';
import { layersWithOsmId } from '../helpers';
import { Theme } from '../../../helpers/theme';
import { addIndoorEqual, removeIndoorEqual } from './indoor';
+import { addClimbingTilesSource } from '../climbingTiles/climbingTilesSource';
const ofrBasicStyle = {
...basicStyle,
@@ -85,12 +86,18 @@ const addOverlaysToStyle = (
overlays: string[],
currentTheme: Theme,
) => {
+ // removeClimbingTilesSource(); // TODO call when climbing removed
+
overlays
.filter((key: string) => osmappLayers[key]?.type === 'overlay')
.forEach((key: string) => {
switch (key) {
case 'climbing':
- addClimbingOverlay(style, map);
+ if (process.env.NEXT_PUBLIC_ENABLE_CLIMBING_TILES) {
+ addClimbingTilesSource(style);
+ } else {
+ addClimbingOverlay(style, map); // TODO remove this when climbingTiles are tested
+ }
break;
case 'indoor':
diff --git a/src/components/Map/climbingTiles/climbingLayers.ts b/src/components/Map/climbingTiles/climbingLayers.ts
new file mode 100644
index 00000000..dd60e360
--- /dev/null
+++ b/src/components/Map/climbingTiles/climbingLayers.ts
@@ -0,0 +1,223 @@
+import {
+ ExpressionSpecification,
+ LayerSpecification,
+ SymbolLayerSpecification,
+} from '@maplibre/maplibre-gl-style-spec';
+import type { DataDrivenPropertyValueSpecification } from 'maplibre-gl';
+import { AREA, CRAG } from '../MapFooter/ClimbingLegend';
+
+export const CLIMBING_TILES_SOURCE = 'climbing-tiles';
+
+export const CLIMBING_SPRITE = {
+ id: 'climbing',
+ url: `${window.location.protocol}//${window.location.host}/icons-climbing/sprites/climbing`,
+};
+
+const linear = (
+ from: number,
+ a: number | ExpressionSpecification,
+ mid: number,
+ b: number | ExpressionSpecification,
+ to?: number,
+ c?: number | ExpressionSpecification,
+): ExpressionSpecification => [
+ 'interpolate',
+ ['linear'],
+ ['zoom'],
+ from,
+ a,
+ mid,
+ b,
+ ...(to && c ? [to, c] : []),
+];
+
+const linearByRouteCount = (
+ from: number,
+ a: number | ExpressionSpecification,
+ to: number,
+ b: number | ExpressionSpecification,
+): ExpressionSpecification => [
+ 'interpolate',
+ ['linear'],
+ ['coalesce', ['get', 'osmappRouteCount'], 0],
+ from,
+ a,
+ to,
+ b,
+];
+
+const ifHasImages = (
+ value: string,
+ elseValue: string,
+): ExpressionSpecification => [
+ 'case',
+ ['get', 'osmappHasImages'],
+ value,
+ elseValue,
+];
+
+const byHasImages = (
+ spec: typeof AREA | typeof CRAG,
+ property: 'IMAGE' | 'COLOR',
+): ExpressionSpecification =>
+ ifHasImages(spec.HAS_IMAGES[property], spec.NO_IMAGES[property]);
+
+const ifCrag = (
+ value: ExpressionSpecification | number,
+ elseValue: ExpressionSpecification | number,
+): ExpressionSpecification => [
+ 'case',
+ ['==', ['get', 'climbing'], 'crag'],
+ value,
+ elseValue,
+];
+
+const sortKey = [
+ '*',
+ -1,
+ [
+ '+',
+ ['to-number', ['get', 'osmappRouteCount']],
+ ['case', ['get', 'osmappHasImages'], 10000, 0], // preference for items with images
+ ['case', ['to-boolean', ['get', 'name']], 2, 0], // prefer items with name
+ ],
+] as DataDrivenPropertyValueSpecification;
+
+const hover = (basic: number, hovered: number): ExpressionSpecification => [
+ 'case',
+ ['boolean', ['feature-state', 'hover'], false],
+ hovered,
+ basic,
+];
+const step = (
+ a: unknown,
+ from: number,
+ b: unknown,
+): ExpressionSpecification => [
+ 'step',
+ ['zoom'],
+ ['literal', a],
+ from,
+ ['literal', b],
+];
+
+export const routes: LayerSpecification[] = [
+ {
+ id: 'climbing routes (line)',
+ type: 'line',
+ source: CLIMBING_TILES_SOURCE,
+ minzoom: 16,
+ filter: ['all', ['==', 'type', 'route']],
+ paint: {
+ 'line-width': 2,
+ },
+ },
+ {
+ id: 'climbing routes (circle)',
+ type: 'circle',
+ source: CLIMBING_TILES_SOURCE,
+ minzoom: 16,
+ filter: ['all', ['==', 'type', 'route']],
+ paint: {
+ 'circle-color': [
+ 'case',
+ ['boolean', ['feature-state', 'hover'], false],
+ '#4150a0',
+ '#ea5540',
+ ],
+ 'circle-radius': linear(16, 1, 21, 6),
+ 'circle-opacity': linear(16, 0.4, 21, 1),
+ },
+ } as LayerSpecification,
+ {
+ id: 'climbing routes (labels)',
+ type: 'symbol',
+ source: CLIMBING_TILES_SOURCE,
+ minzoom: 19,
+ filter: ['all', ['==', 'type', 'route']],
+ layout: {
+ 'text-padding': 2,
+ 'text-font': ['Noto Sans Medium'],
+ 'text-anchor': 'left',
+ 'text-field': '{name} {climbing:grade:uiaa}',
+ 'text-offset': [1, 0],
+ 'text-size': linear(20, 12, 26, 30),
+ 'text-max-width': 9,
+ 'text-allow-overlap': false,
+ 'text-optional': true,
+ },
+ paint: {
+ 'text-halo-blur': 0.5,
+ 'text-color': '#666',
+ 'text-halo-width': 1,
+ 'text-halo-color': '#ffffff',
+ },
+ },
+];
+
+const COMMON_LAYOUT: SymbolLayerSpecification['layout'] = {
+ 'icon-optional': false,
+ 'icon-ignore-placement': false,
+ 'icon-allow-overlap': ['step', ['zoom'], true, 4, false],
+ 'text-field': ['step', ['zoom'], '', 4, ['get', 'osmappLabel']],
+ 'text-padding': 2,
+ 'text-font': ['Noto Sans Bold'],
+ 'text-anchor': 'top',
+ 'text-max-width': 9,
+ 'text-ignore-placement': false,
+ 'text-allow-overlap': false,
+ 'text-optional': true,
+ 'symbol-sort-key': sortKey,
+};
+
+const COMMON_PAINT: SymbolLayerSpecification['paint'] = {
+ 'text-halo-color': '#ffffff',
+ 'text-halo-width': 2,
+};
+
+const areaSize = linearByRouteCount(0, 0.4, 400, 1);
+const cragSize = linearByRouteCount(0, 0.4, 50, 0.7);
+const cragSizeBig = 1;
+
+const mixed: LayerSpecification = {
+ id: 'climbing crags and areas',
+ type: 'symbol',
+ source: CLIMBING_TILES_SOURCE,
+ maxzoom: 20,
+ filter: ['all', ['==', 'type', 'group']],
+ layout: {
+ 'icon-image': ifCrag(
+ byHasImages(CRAG, 'IMAGE'),
+ byHasImages(AREA, 'IMAGE'),
+ ),
+ 'icon-size': linear(
+ 6,
+ 0.4,
+ 8,
+ ifCrag(cragSize, areaSize),
+ 21,
+ ifCrag(cragSizeBig, areaSize),
+ ),
+ 'text-size': linear(
+ 5,
+ ifCrag(12, 12),
+ 15,
+ ifCrag(12, 14),
+ 21,
+ ifCrag(20, 14),
+ ),
+ 'text-offset': [0, 0.6],
+ ...COMMON_LAYOUT,
+ },
+ paint: {
+ 'icon-opacity': hover(1, 0.6),
+ 'text-opacity': hover(1, 0.6),
+ 'text-color': ifCrag(
+ byHasImages(CRAG, 'COLOR'),
+ byHasImages(AREA, 'COLOR'),
+ ),
+ ...COMMON_PAINT,
+ },
+};
+
+export const climbingLayers: LayerSpecification[] = [...routes, mixed];
diff --git a/src/components/Map/climbingTiles/climbingTilesSource.ts b/src/components/Map/climbingTiles/climbingTilesSource.ts
new file mode 100644
index 00000000..12f165e9
--- /dev/null
+++ b/src/components/Map/climbingTiles/climbingTilesSource.ts
@@ -0,0 +1,71 @@
+import { GeoJSONSource } from 'maplibre-gl';
+import { fetchJson } from '../../../services/fetch';
+import { EMPTY_GEOJSON_SOURCE, OSMAPP_SPRITE } from '../consts';
+import { getGlobalMap } from '../../../services/mapStorage';
+import {
+ CLIMBING_SPRITE,
+ CLIMBING_TILES_SOURCE,
+ climbingLayers,
+} from './climbingLayers';
+import type { StyleSpecification } from '@maplibre/maplibre-gl-style-spec';
+import { Tile } from '../../../types';
+import { computeTiles } from './computeTiles';
+
+const HOST = process.env.NEXT_PUBLIC_CLIMBING_TILES_LOCAL
+ ? '/'
+ : 'https://openclimbing.org/';
+
+const getTileJson = async ({ z, x, y }: Tile) => {
+ const url = `${HOST}api/climbing-tiles/tile?z=${z}&x=${x}&y=${y}`;
+ const data = await fetchJson(url);
+ return data.features || [];
+};
+
+const updateData = async () => {
+ const map = getGlobalMap();
+ const mapZoom = map.getZoom();
+ const z = mapZoom >= 13 ? 12 : mapZoom >= 10 ? 9 : mapZoom >= 7 ? 6 : 0;
+
+ const bounds = map.getBounds();
+ const northWest = bounds.getNorthWest();
+ const southEast = bounds.getSouthEast();
+
+ const tiles = computeTiles(z, northWest, southEast);
+
+ const promises = tiles.map((tile) => getTileJson(tile)); // TODO consider showing results after each tile is loaded
+ const data = await Promise.all(promises);
+
+ const features = [];
+ for (const tileFeatures of data) {
+ features.push(...tileFeatures);
+ }
+
+ map?.getSource(CLIMBING_TILES_SOURCE)?.setData({
+ type: 'FeatureCollection' as const,
+ features,
+ });
+};
+
+let eventsAdded = false;
+
+export const addClimbingTilesSource = (style: StyleSpecification) => {
+ style.sources[CLIMBING_TILES_SOURCE] = EMPTY_GEOJSON_SOURCE;
+ style.sprite = [...OSMAPP_SPRITE, CLIMBING_SPRITE];
+ style.layers.push(...climbingLayers); // must be also in `layersWithOsmId` because of hover effect
+
+ if (!eventsAdded) {
+ const map = getGlobalMap();
+ map.on('load', updateData);
+ map.on('moveend', updateData);
+ eventsAdded = true;
+ }
+};
+
+export const removeClimbingTilesSource = () => {
+ if (eventsAdded) {
+ const map = getGlobalMap();
+ map.off('load', updateData);
+ map.off('moveend', updateData);
+ eventsAdded = false;
+ }
+};
diff --git a/src/components/Map/climbingTiles/computeTiles.ts b/src/components/Map/climbingTiles/computeTiles.ts
new file mode 100644
index 00000000..5b7ab276
--- /dev/null
+++ b/src/components/Map/climbingTiles/computeTiles.ts
@@ -0,0 +1,48 @@
+import { LngLat } from 'maplibre-gl';
+import { Tile } from '../../../types';
+import { publishDbgObject } from '../../../utils';
+
+/*
+ -180 +180 = x = longitude
++90 +----------------+
+ | |
+ | |
+-90 +----------------+
+ = y = latitude
+* */
+const getTile = (z: number, { lng, lat }: LngLat): Tile => {
+ const xNorm = (lng + 180) / 360;
+ const x = Math.floor(xNorm * Math.pow(2, z));
+
+ const yRad = (lat * Math.PI) / 180;
+ const correction = 1 / Math.cos(yRad); // on pole (90°) = infinity
+ const yNorm = (1 - Math.log(Math.tan(yRad) + correction) / Math.PI) / 2; // defined on 0° - 85.0511°
+ const yNormBounded = Math.min(Math.max(yNorm, 0), 1);
+ const maxTileMinusOne = yNormBounded === 1 ? 1 : 0;
+ const y = Math.floor(yNormBounded * Math.pow(2, z)) - maxTileMinusOne;
+
+ // Note: this won't work on wrapped coordinates around +-180° (but we use projection:globe, so it doesn't happen)
+
+ return { z, x, y };
+};
+
+export const computeTiles = (
+ z: number,
+ northWest: LngLat,
+ southEast: LngLat,
+): Tile[] => {
+ const nwTile = getTile(z, northWest);
+ const seTile = getTile(z, southEast);
+
+ const tiles: Tile[] = [];
+ for (let x = nwTile.x; x <= seTile.x; x++) {
+ for (let y = nwTile.y; y <= seTile.y; y++) {
+ tiles.push({ z, x, y });
+ }
+ }
+
+ const asString = tiles.map(({ z, x, y }) => `${z}/${x}/${y}`);
+ publishDbgObject('climbingTiles', asString);
+
+ return tiles;
+};
diff --git a/src/server/climbing-tiles/db.sql b/src/server/climbing-tiles/db.sql
new file mode 100644
index 00000000..e2d79cc1
--- /dev/null
+++ b/src/server/climbing-tiles/db.sql
@@ -0,0 +1,34 @@
+create table if not exists public.climbing_features
+(
+ id SERIAL primary key,
+ type text not null,
+ lon double precision not null,
+ lat double precision not null,
+ "osmType" text not null,
+ "osmId" bigint not null,
+ name text,
+ count integer,
+ geojson json not null
+);
+
+create table if not exists public.climbing_tiles_stats
+(
+ id SERIAL primary key,
+ timestamp timestamp with time zone default now() not null,
+ osm_data_timestamp timestamp with time zone not null,
+ build_log text,
+ build_duration bigint not null,
+ max_size bigint not null,
+ max_size_zxy text not null,
+ max_time bigint not null,
+ max_time_zxy text not null,
+ prev_tiles_stats text
+);
+
+create table if not exists public.climbing_tiles_cache
+(
+ zxy text not null primary key,
+ tile_geojson text not null,
+ duration integer,
+ feature_count integer
+);
diff --git a/src/server/climbing-tiles/db.ts b/src/server/climbing-tiles/db.ts
new file mode 100644
index 00000000..2d04c340
--- /dev/null
+++ b/src/server/climbing-tiles/db.ts
@@ -0,0 +1,29 @@
+import { Client } from 'pg';
+
+if (!global.db) {
+ global.db = { pool: false };
+}
+
+export async function getClient(): Promise {
+ if (!global.db.pool) {
+ const client = new Client({
+ user: 'tvgiad',
+ password: process.env.XATA_PASSWORD,
+ host: 'us-east-1.sql.xata.sh',
+ port: 5432,
+ database: 'osmapp_db:main',
+ ssl: {
+ rejectUnauthorized: false,
+ },
+ });
+
+ await client.connect();
+
+ global.db.pool = client;
+ }
+ return global.db.pool;
+}
+
+export async function closeClient(client: Client): Promise {
+ await client.end();
+}
diff --git a/src/server/climbing-tiles/getClimbingTile.ts b/src/server/climbing-tiles/getClimbingTile.ts
new file mode 100644
index 00000000..0e8dcf37
--- /dev/null
+++ b/src/server/climbing-tiles/getClimbingTile.ts
@@ -0,0 +1,63 @@
+import { getClient } from './db';
+import { tileToBBOX } from './tileToBBOX';
+import { Tile } from '../../types';
+import { optimizeGeojsonToGrid } from './optimizeGeojsonToGrid';
+import { BBox } from 'geojson';
+
+const getBboxCondition = (bbox: BBox) =>
+ `lon >= ${bbox[0]} AND lon <= ${bbox[2]} AND lat >= ${bbox[1]} AND lat <= ${bbox[3]}`;
+
+const logCacheMiss = (duration: number, count: number) =>
+ console.log(`climbing_tiles_cache MISS ${duration}ms ${count}`); //eslint-disable-line no-console
+
+const logCacheHit = (start: number) => {
+ const duration = Math.round(performance.now() - start);
+ console.log(`climbing_tiles_cache HIT ${duration}ms`); //eslint-disable-line no-console
+};
+
+const ZOOM_LEVELS = [0, 6, 9, 12];
+
+export const getClimbingTile = async ({ z, x, y }: Tile) => {
+ const start = performance.now();
+ const client = await getClient();
+ const isOptimizedToGrid = z <= 6;
+ const hasRoutes = z == 12;
+ if (!ZOOM_LEVELS.includes(z)) {
+ throw new Error('Zoom level not available');
+ }
+
+ const cacheKey = `${z}/${x}/${y}`;
+ const cache = await client.query(
+ `SELECT tile_geojson FROM climbing_tiles_cache WHERE zxy = $1`,
+ [cacheKey],
+ );
+ if (cache.rowCount > 0) {
+ logCacheHit(start);
+ return cache.rows[0].tile_geojson as string;
+ }
+
+ const bbox = tileToBBOX({ z, x, y });
+ const bboxCondition = getBboxCondition(bbox);
+ const query = hasRoutes
+ ? `SELECT geojson FROM climbing_features WHERE type IN ('group', 'route') AND ${bboxCondition}`
+ : `SELECT geojson FROM climbing_features WHERE type = 'group' AND ${bboxCondition}`;
+ const result = await client.query(query);
+ const allGeojson: GeoJSON.FeatureCollection = {
+ type: 'FeatureCollection',
+ features: result.rows.map((record) => record.geojson),
+ };
+ const geojson = isOptimizedToGrid
+ ? optimizeGeojsonToGrid(allGeojson, bbox)
+ : allGeojson;
+
+ const duration = Math.round(performance.now() - start);
+ logCacheMiss(duration, geojson.features.length);
+
+ // intentionally not awaited to make quicker return of data
+ client.query(
+ `INSERT INTO climbing_tiles_cache VALUES ($1, $2, $3, $4) ON CONFLICT (zxy) DO NOTHING`,
+ [cacheKey, geojson, duration, geojson.features.length],
+ );
+
+ return JSON.stringify(geojson);
+};
diff --git a/src/server/climbing-tiles/optimizeGeojsonToGrid.ts b/src/server/climbing-tiles/optimizeGeojsonToGrid.ts
new file mode 100644
index 00000000..a1602900
--- /dev/null
+++ b/src/server/climbing-tiles/optimizeGeojsonToGrid.ts
@@ -0,0 +1,42 @@
+import { BBox, FeatureCollection } from 'geojson';
+
+// rows or columns count
+const COUNT = 500;
+
+export const optimizeGeojsonToGrid = (
+ geojson: FeatureCollection,
+ [west, south, east, north]: BBox,
+): GeoJSON.FeatureCollection => {
+ const intervalX = (east - west) / COUNT;
+ const intervalY = (north - south) / COUNT;
+ const grid = Array.from({ length: COUNT }, () =>
+ Array.from({ length: COUNT }, () => null as GeoJSON.Feature | null),
+ );
+
+ for (const feature of geojson.features) {
+ if (!feature.geometry || feature.geometry.type !== 'Point') {
+ continue;
+ }
+
+ const [lon, lat] = feature.geometry.coordinates;
+
+ if (lon >= west && lon <= east && lat >= south && lat <= north) {
+ const xIndex = Math.floor((lon - west) / intervalX);
+ const yIndex = Math.floor((lat - south) / intervalY);
+ const current = grid[xIndex][yIndex];
+ const shouldReplaceCell =
+ !current ||
+ !current.properties.osmappRouteCount ||
+ current.properties.osmappRouteCount <
+ feature.properties.osmappRouteCount;
+
+ if (shouldReplaceCell) {
+ grid[xIndex][yIndex] = feature;
+ }
+ }
+ }
+
+ const features = grid.flat().filter((f) => f !== null) as GeoJSON.Feature[];
+
+ return { type: 'FeatureCollection', features };
+};
diff --git a/src/server/climbing-tiles/overpass/__tests__/basic.test.ts b/src/server/climbing-tiles/overpass/__tests__/basic.test.ts
new file mode 100644
index 00000000..d2714f5f
--- /dev/null
+++ b/src/server/climbing-tiles/overpass/__tests__/basic.test.ts
@@ -0,0 +1,137 @@
+const elements = [
+ // OsmElement[]
+ // {
+ // "type": "node",
+ // "id": 313822575,
+ // "lat": 50.0547464,
+ // "lon": 14.4056821,
+ // "tags": {
+ // "climbing:boulder": "yes",
+ // "climbing:toprope": "yes",
+ // "leisure": "sports_centre",
+ // "name": "SmíchOFF",
+ // "opening_hours": "Mo 07:00-23:00; Tu-Th 07:00-23:30; Fr 07:00-23:00; Sa,Su 08:00-23:00",
+ // "sport": "climbing",
+ // "website": "https://www.lezeckecentrum.cz/"
+ // }
+ // },
+ {
+ type: 'node',
+ id: 11580052710,
+ lat: 49.6600391,
+ lon: 14.2573987,
+ tags: {
+ climbing: 'route_bottom',
+ 'climbing:grade:uiaa': '9-',
+ name: 'Lída',
+ sport: 'climbing',
+ wikimedia_commons: 'File:Roviště - Hafty2.jpg',
+ 'wikimedia_commons:2': 'File:Roviště - Hafty3.jpg',
+ 'wikimedia_commons:2:path':
+ '0.273,0.904|0.229,0.566B|0.317,0.427B|0.433,0.329B|0.515,0.21B|0.526,0.126B|0.495,0.075A',
+ 'wikimedia_commons:path':
+ '0.67,0.601|0.66,0.442B|0.682,0.336B|0.739,0.236B|0.733,0.16B|0.72,0.1B|0.688,0.054A',
+ },
+ },
+ {
+ type: 'relation',
+ id: 17130663,
+ members: [
+ {
+ type: 'node',
+ ref: 11580052710,
+ role: '',
+ },
+ ],
+ tags: {
+ climbing: 'crag',
+ name: 'Yosemite (Hafty)',
+ site: 'climbing',
+ sport: 'climbing',
+ type: 'site',
+ wikimedia_commons: 'File:Roviště - Hafty.jpg',
+ 'wikimedia_commons:10': 'File:Roviště - Hafty10.jpg',
+ 'wikimedia_commons:2': 'File:Roviště - Hafty2.jpg',
+ 'wikimedia_commons:3': 'File:Roviště - Hafty3.jpg',
+ 'wikimedia_commons:4': 'File:Roviště - Hafty4.jpg',
+ 'wikimedia_commons:5': 'File:Roviště - Hafty5.jpg',
+ 'wikimedia_commons:6': 'File:Roviště - Hafty6.jpg',
+ 'wikimedia_commons:7': 'File:Roviště - Hafty7.jpg',
+ 'wikimedia_commons:8': 'File:Roviště - Hafty8.jpg',
+ 'wikimedia_commons:9': 'File:Roviště - Hafty9.jpg',
+ },
+ },
+ {
+ type: 'relation',
+ id: 17130099,
+ members: [
+ {
+ type: 'relation',
+ ref: 17130663,
+ role: '',
+ },
+ ],
+ tags: {
+ climbing: 'area',
+ description:
+ 'Roviště je klasická vltavská žula. Jedná se o velmi vyhlášenou oblast. Nabízí cesty prakticky všech obtížností, zpravidla dobře odjištěné.',
+ name: 'Roviště',
+ site: 'climbing',
+ type: 'site',
+ website: 'https://www.horosvaz.cz/skaly-sektor-289/',
+ 'website:2': 'https://www.lezec.cz/pruvodcx.php?key=5',
+ },
+ },
+
+ // two nodes and a climbing=route way
+ {
+ type: 'node',
+ id: 1,
+ lat: 50,
+ lon: 14,
+ tags: {},
+ },
+ {
+ type: 'node',
+ id: 2,
+ lat: 51,
+ lon: 15,
+ tags: {},
+ },
+ {
+ type: 'way',
+ id: 3,
+ nodes: [1, 2],
+ tags: {
+ climbing: 'route',
+ name: 'Route of type way starting on 14,50',
+ },
+ },
+
+ // two nodes and natural=cliff way ("crag")
+ {
+ type: 'node',
+ id: 4,
+ lat: 52,
+ lon: 16,
+ tags: {},
+ },
+ {
+ type: 'node',
+ id: 5,
+ lat: 53,
+ lon: 17,
+ tags: {},
+ },
+ {
+ type: 'way',
+ id: 6,
+ nodes: [4, 5],
+ tags: {
+ natural: 'cliff',
+ name: 'Cliff of type way at 16.5,52.5',
+ },
+ },
+];
+
+test('noop', () => {});
diff --git a/src/server/climbing-tiles/overpass/overpassToGeojsons.ts b/src/server/climbing-tiles/overpass/overpassToGeojsons.ts
new file mode 100644
index 00000000..709619f0
--- /dev/null
+++ b/src/server/climbing-tiles/overpass/overpassToGeojsons.ts
@@ -0,0 +1,243 @@
+import {
+ FeatureGeometry,
+ FeatureTags,
+ GeometryCollection,
+ LineString,
+ OsmId,
+ Point,
+} from '../../../services/types';
+import { join } from '../../../utils';
+import { getCenter } from '../../../services/getCenter';
+
+type OsmType = 'node' | 'way' | 'relation';
+type OsmNode = {
+ type: 'node';
+ id: number;
+ lat: number;
+ lon: number;
+ tags?: Record;
+};
+type OsmWay = {
+ type: 'way';
+ id: number;
+ nodes: number[];
+ tags?: Record;
+};
+type OsmRelation = {
+ type: 'relation';
+ id: number;
+ members: {
+ type: OsmType;
+ ref: number;
+ role: string;
+ }[];
+ tags?: Record;
+ center?: { lat: number; lon: number }; // only for overpass `out center` queries
+};
+type OsmItem = OsmNode | OsmWay | OsmRelation;
+export type OsmResponse = {
+ elements: OsmItem[];
+};
+
+export type GeojsonFeature = {
+ type: 'Feature';
+ id: number;
+ osmMeta: OsmId;
+ tags: FeatureTags;
+ properties: {
+ climbing?: string;
+ osmappRouteCount?: number;
+ osmappHasImages?: boolean;
+ osmappType?: 'node' | 'way' | 'relation';
+ osmappLabel?: string;
+ };
+ geometry: T;
+ center?: number[];
+ members?: OsmRelation['members'];
+};
+
+type Lookup = {
+ node: Record>;
+ way: Record>;
+ relation: Record>;
+};
+
+const convertOsmIdToMapId = (apiId: OsmId) => {
+ const osmToMapType = { node: 0, way: 1, relation: 4 };
+ return parseInt(`${apiId.id}${osmToMapType[apiId.type]}`, 10);
+};
+
+const getItems = (elements: OsmItem[]) => {
+ const nodes: OsmNode[] = [];
+ const ways: OsmWay[] = [];
+ const relations: OsmRelation[] = [];
+ elements.forEach((element) => {
+ if (element.type === 'node') {
+ nodes.push(element);
+ } else if (element.type === 'way') {
+ ways.push(element);
+ } else if (element.type === 'relation') {
+ relations.push(element);
+ }
+ });
+ return { nodes, ways, relations };
+};
+
+const numberToSuperScript = (number?: number) =>
+ number?.toString().replace(/\d/g, (d) => '⁰¹²³⁴⁵⁶⁷⁸⁹'[+d]);
+
+const getLabel = (tags: FeatureTags, osmappRouteCount: number) =>
+ join(tags?.name, '\n', numberToSuperScript(osmappRouteCount));
+
+const getRouteNumberFromTags = (element: OsmItem) => {
+ // TODO sum all types
+ const number = parseFloat(element.tags['climbing:sport'] ?? '0');
+
+ // can be eg. "yes" .. eg. relation/15056469
+ return Number.isNaN(number) ? 1 : number;
+};
+
+const convert = (
+ element: T,
+ geometryFn: (element: T) => TGeometry,
+): GeojsonFeature => {
+ const { type, id, tags = {} } = element;
+ const geometry = geometryFn(element);
+ const center = getCenter(geometry) ?? undefined;
+ const osmappRouteCount =
+ element.tags?.climbing === 'crag'
+ ? Math.max(
+ element.type === 'relation'
+ ? element.members.filter((member) => member.role === '').length
+ : 0,
+ getRouteNumberFromTags(element),
+ )
+ : undefined;
+ const properties = {
+ climbing: tags?.climbing,
+ name: tags?.name,
+ osmappType: type,
+ osmappRouteCount,
+ osmappLabel: getLabel(tags, osmappRouteCount),
+ osmappHasImages: Object.keys(tags).some((key) =>
+ key.startsWith('wikimedia_commons'),
+ ),
+ };
+
+ return {
+ type: 'Feature',
+ id: convertOsmIdToMapId({ type, id }),
+ osmMeta: { type, id },
+ tags,
+ properties,
+ geometry,
+ center,
+ members: element.type === 'relation' ? element.members : undefined,
+ };
+};
+
+const getNodeGeomFn =
+ () =>
+ (node: any): Point => ({
+ type: 'Point',
+ coordinates: [node.lon, node.lat],
+ });
+
+const getWayGeomFn =
+ (lookup: Lookup) =>
+ ({ nodes }: OsmWay): LineString => ({
+ type: 'LineString' as const,
+ coordinates: nodes
+ .map((ref) => lookup.node[ref]?.geometry?.coordinates)
+ .filter(Boolean), // some nodes may be missing
+ });
+
+const getRelationGeomFn =
+ (lookup: Lookup) =>
+ ({ members, center }: OsmRelation): FeatureGeometry => {
+ const geometries = members
+ .map(({ type, ref }) => lookup[type][ref]?.geometry)
+ .filter(Boolean); // some members may be undefined in first pass
+
+ return geometries.length
+ ? {
+ type: 'GeometryCollection',
+ geometries,
+ }
+ : center
+ ? { type: 'Point', coordinates: [center.lon, center.lat] }
+ : undefined;
+ };
+
+const addToLookup = (
+ items: GeojsonFeature[],
+ lookup: Lookup,
+) => {
+ items.forEach((item) => {
+ // @ts-ignore
+ lookup[item.osmMeta.type][item.osmMeta.id] = item; // eslint-disable-line no-param-reassign
+ });
+};
+
+const getRelationWithAreaCount = (
+ relations: GeojsonFeature[],
+ lookup: Record>,
+) =>
+ relations.map((relation) => {
+ if (relation.tags?.climbing === 'area') {
+ const members = relation.members.map(
+ ({ type, ref }) => lookup[type][ref]?.properties,
+ );
+ const osmappRouteCount = members
+ .map((member) => member?.osmappRouteCount ?? 0)
+ .reduce((acc, count) => acc + count);
+ const osmappHasImages = members
+ .map((member) => member?.osmappHasImages)
+ .some((value) => value === true);
+
+ return {
+ ...relation,
+ properties: {
+ ...relation.properties,
+ osmappRouteCount,
+ osmappHasImages,
+ osmappLabel: getLabel(relation.tags, osmappRouteCount),
+ },
+ };
+ }
+
+ return relation;
+ });
+
+export const overpassToGeojsons = (response: OsmResponse) => {
+ const { nodes, ways, relations } = getItems(response.elements);
+
+ const lookup = { node: {}, way: {}, relation: {} } as Lookup;
+
+ const NODE_GEOM = getNodeGeomFn();
+ const nodesOut = nodes.map((node) => convert(node, NODE_GEOM));
+ addToLookup(nodesOut, lookup);
+
+ const WAY_GEOM = getWayGeomFn(lookup);
+ const waysOut = ways.map((way) => convert(way, WAY_GEOM));
+ addToLookup(waysOut, lookup);
+
+ // first pass
+ const RELATION_GEOM1 = getRelationGeomFn(lookup);
+ const relationsOut1 = relations.map((relation) =>
+ convert(relation, RELATION_GEOM1),
+ );
+ addToLookup(relationsOut1, lookup);
+
+ // second pass for climbing=area geometries
+ // TODO: loop while number of geometries changes
+ // TODO: update only geometries (?)
+ const RELATION_GEOM2 = getRelationGeomFn(lookup);
+ const relationsOut2 = relations.map((relation) =>
+ convert(relation, RELATION_GEOM2),
+ );
+
+ const relationsOut3 = getRelationWithAreaCount(relationsOut2, lookup);
+
+ return { node: nodesOut, way: waysOut, relation: relationsOut3 };
+};
diff --git a/src/server/climbing-tiles/refreshClimbingTiles.ts b/src/server/climbing-tiles/refreshClimbingTiles.ts
new file mode 100644
index 00000000..f99f3338
--- /dev/null
+++ b/src/server/climbing-tiles/refreshClimbingTiles.ts
@@ -0,0 +1,223 @@
+import {
+ GeojsonFeature,
+ OsmResponse,
+ overpassToGeojsons,
+} from './overpass/overpassToGeojsons';
+import { encodeUrl } from '../../helpers/utils';
+import { fetchJson } from '../../services/fetch';
+import { LineString, LonLat, Point } from '../../services/types';
+import format from 'pg-format';
+import { closeClient, getClient } from './db';
+
+const centerGeometry = (feature: GeojsonFeature): GeojsonFeature => ({
+ ...feature,
+ geometry: {
+ type: 'Point',
+ coordinates: feature.center,
+ },
+});
+
+const firstPointGeometry = (
+ feature: GeojsonFeature,
+): GeojsonFeature => ({
+ ...feature,
+ geometry: {
+ type: 'Point',
+ coordinates: feature.geometry.coordinates[0],
+ },
+});
+
+const prepareGeojson = (
+ type: string,
+ { id, geometry, properties }: GeojsonFeature,
+) =>
+ JSON.stringify({
+ type: 'Feature',
+ id,
+ geometry,
+ properties: { ...properties, type },
+ });
+
+const fetchFromOverpass = async () => {
+ // takes about 42 secs, 25MB
+ const query = `[out:json][timeout:80];(nwr["climbing"];nwr["sport"="climbing"];);>>;out qt;`;
+ const data = await fetchJson(
+ 'https://overpass-api.de/api/interpreter',
+ {
+ body: encodeUrl`data=${query}`,
+ method: 'POST',
+ nocache: true,
+ },
+ );
+
+ if (data.elements.length < 1000) {
+ throw new Error(
+ `Overpass returned too few elements. Data:${JSON.stringify(data).substring(0, 200)}`,
+ );
+ }
+
+ return data;
+};
+
+type Records = any; //TODO Partial>[];
+
+const recordsFactory = () => {
+ const records: Records = [];
+ const addRecordRaw = (
+ type: string,
+ coordinates: LonLat,
+ feature: GeojsonFeature,
+ ) => {
+ const lon = coordinates[0];
+ const lat = coordinates[1];
+ return records.push({
+ type,
+ osmType: feature.osmMeta.type,
+ osmId: feature.osmMeta.id,
+ name: feature.tags.name,
+ count: feature.properties.osmappRouteCount || 0,
+ lon,
+ lat,
+ geojson: prepareGeojson(type, feature),
+ });
+ };
+
+ const addRecord = (type: string, feature: GeojsonFeature) => {
+ addRecordRaw(type, feature.geometry.coordinates, feature);
+ };
+
+ const addRecordWithLine = (type: string, way: GeojsonFeature) => {
+ addRecord(type, firstPointGeometry(way));
+ addRecordRaw(type, way.center, way);
+ };
+
+ return { records, addRecord, addRecordWithLine };
+};
+
+const getNewRecords = (data: OsmResponse) => {
+ const geojsons = overpassToGeojsons(data); // 700 ms on 16k items
+ const { records, addRecord, addRecordWithLine } = recordsFactory();
+
+ for (const node of geojsons.node) {
+ if (!node.tags) continue;
+ if (
+ node.tags.climbing === 'area' ||
+ node.tags.climbing === 'boulder' ||
+ node.tags.climbing === 'crag' ||
+ node.tags.natural === 'peak'
+ ) {
+ addRecord('group', node);
+ }
+
+ //
+ else if (
+ node.tags.climbing === 'route' ||
+ node.tags.climbing === 'route_bottom'
+ ) {
+ addRecord('route', node);
+ }
+
+ //
+ else if (node.tags.climbing === 'route_top') {
+ // later + update climbingLayer
+ }
+
+ // 120 k nodes ???
+ else {
+ //addRecord('_otherNodes', node);
+ }
+ }
+
+ for (const way of geojsons.way) {
+ // climbing=route -> route + line
+ // highway=via_ferrata -> route + line
+ if (way.tags.climbing === 'route' || way.tags.highway === 'via_ferrata') {
+ addRecordWithLine('route', way);
+ }
+
+ // natural=cliff + sport=climbing -> group
+ // natural=rock + sport=climbing -> group
+ else if (
+ way.tags.sport === 'climbing' &&
+ (way.tags.natural === 'cliff' || way.tags.natural === 'rock')
+ ) {
+ addRecord('group', centerGeometry(way));
+ }
+
+ // _otherWays to debug
+ else {
+ addRecord('_otherWays', centerGeometry(way));
+ // TODO way/167416816 is natural=cliff with parent relation type=site
+ }
+ }
+
+ for (const relation of geojsons.relation) {
+ // climbing=area -> group
+ // climbing=boulder -> group
+ // climbing=crag -> group
+ // climbing=route -> group // multipitch or via_ferrata
+ // type=site -> group
+ // type=multipolygon -> group + delete nodes
+ if (
+ relation.tags.climbing === 'area' ||
+ relation.tags.type === 'boulder' ||
+ relation.tags.type === 'crag' ||
+ relation.tags.climbing === 'route' ||
+ relation.tags.type === 'site' ||
+ relation.tags.type === 'multipolygon'
+ ) {
+ addRecord('group', centerGeometry(relation));
+ }
+
+ // _otherRelations to debug
+ else {
+ addRecord('group', centerGeometry(relation));
+ }
+
+ // TODO no center -> write to log
+ }
+
+ return records;
+};
+
+const buildLogFactory = () => {
+ const buildLog: string[] = [];
+ const log = (message: string) => {
+ buildLog.push(message);
+ console.log(message); //eslint-disable-line no-console
+ };
+ log('Starting...');
+ return { buildLog, log };
+};
+
+export const refreshClimbingTiles = async () => {
+ const { buildLog, log } = buildLogFactory();
+ const start = performance.now();
+ const client = await getClient();
+
+ const data = await fetchFromOverpass();
+ log(`Overpass elements: ${data.elements.length}`);
+
+ const records = getNewRecords(data); // ~ 16k records
+ log(`Records: ${records.length}`);
+
+ const columns = Object.keys(records[0]);
+ const values = records.map((record) => Object.values(record));
+ const query = format(
+ `TRUNCATE TABLE climbing_features;
+ INSERT INTO climbing_features(%I) VALUES %L;
+ TRUNCATE TABLE climbing_tiles_cache;
+ `,
+ columns,
+ values,
+ );
+ log(`SQL Query length: ${query.length} chars`);
+
+ await client.query(query);
+ await closeClient(client);
+
+ log('Done.');
+ log(`Duration: ${Math.round(performance.now() - start)} ms`);
+
+ return buildLog.join('\n');
+};
diff --git a/src/server/climbing-tiles/tileToBBOX.ts b/src/server/climbing-tiles/tileToBBOX.ts
new file mode 100644
index 00000000..ea36c46c
--- /dev/null
+++ b/src/server/climbing-tiles/tileToBBOX.ts
@@ -0,0 +1,20 @@
+import { BBox } from 'geojson';
+import { Tile } from '../../types';
+
+const r2d = 180 / Math.PI;
+
+const tile2lon = (x: number, z: number): number =>
+ (x / Math.pow(2, z)) * 360 - 180;
+
+const tile2lat = (y: number, z: number): number => {
+ const n = Math.PI - (2 * Math.PI * y) / Math.pow(2, z);
+ return r2d * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
+};
+
+export const tileToBBOX = ({ z, x, y }: Tile): BBox => {
+ const e = tile2lon(x + 1, z);
+ const w = tile2lon(x, z);
+ const s = tile2lat(y + 1, z);
+ const n = tile2lat(y, z);
+ return [w, s, e, n];
+};
diff --git a/src/services/fetchCache.ts b/src/services/fetchCache.ts
index 2c14cbaa..aa5adbe4 100644
--- a/src/services/fetchCache.ts
+++ b/src/services/fetchCache.ts
@@ -1,24 +1,26 @@
import { isBrowser } from '../components/helpers';
+import { prod } from './helpers';
const cache = {};
-const fetchCache = isBrowser()
- ? {
- get: (key: string) => sessionStorage.getItem(key),
- remove: (key: string) => sessionStorage.removeItem(key),
- put: (key: string, value: string) => sessionStorage.setItem(key, value),
- clear: () => sessionStorage.clear(), // this is little dirty, but we use sessionStorage only for this
- }
- : {
- get: (key: string) => cache[key],
- remove: (key: string) => delete cache[key],
- put: (key: string, value: string) => {
- cache[key] = value;
- },
- clear: () => {
- Object.keys(cache).forEach((key) => delete cache[key]);
- },
- };
+const fetchCache =
+ !prod && isBrowser() // lets leave the sessionStorage cache only for DEV mode
+ ? {
+ get: (key: string) => sessionStorage.getItem(key),
+ remove: (key: string) => sessionStorage.removeItem(key),
+ put: (key: string, value: string) => sessionStorage.setItem(key, value),
+ clear: () => sessionStorage.clear(), // this is little dirty, but we use sessionStorage only for this
+ }
+ : {
+ get: (key: string) => cache[key],
+ remove: (key: string) => delete cache[key],
+ put: (key: string, value: string) => {
+ cache[key] = value;
+ },
+ clear: () => {
+ Object.keys(cache).forEach((key) => delete cache[key]);
+ },
+ };
export const getKey = (url: string, opts: Record) => {
if (['POST', 'PUT', 'DELETE'].includes(opts.method)) {
diff --git a/src/services/getCenter.ts b/src/services/getCenter.ts
index 836490aa..9ecb0d29 100644
--- a/src/services/getCenter.ts
+++ b/src/services/getCenter.ts
@@ -5,7 +5,7 @@ import {
isLineString,
isPoint,
isPolygon,
- Position,
+ LonLat,
} from './types';
export type NamedBbox = {
@@ -15,7 +15,7 @@ export type NamedBbox = {
n: number;
};
-export const getBbox = (coordinates: Position[]): NamedBbox => {
+export const getBbox = (coordinates: LonLat[]): NamedBbox => {
const [firstX, firstY] = coordinates[0];
const initialBbox = { w: firstX, s: firstY, e: firstX, n: firstY };
@@ -30,16 +30,17 @@ export const getBbox = (coordinates: Position[]): NamedBbox => {
);
};
-const getCenterOfBbox = (points: Position[]) => {
+const getCenterOfBbox = (points: LonLat[]): LonLat | undefined => {
if (!points.length) return undefined;
- const { w, s, e, n } = getBbox(points); // [WSEN]
- const lon = (w + e) / 2; // flat earth rulezz
- const lat = (s + n) / 2;
+ const { w, s, e, n } = getBbox(points);
+ const lon = w + (e - w) / 2; // flat earth rulezz
+ const lat = s + (n - s) / 2;
+
return [lon, lat];
};
-const getPointsRecursive = (geometry: GeometryCollection): Position[] =>
+const getPointsRecursive = (geometry: GeometryCollection): LonLat[] =>
geometry.geometries.flatMap((subGeometry) => {
if (isGeometryCollection(subGeometry)) {
return getPointsRecursive(subGeometry);
@@ -53,7 +54,7 @@ const getPointsRecursive = (geometry: GeometryCollection): Position[] =>
return [];
});
-export const getCenter = (geometry: FeatureGeometry): Position => {
+export const getCenter = (geometry: FeatureGeometry): LonLat => {
if (isPoint(geometry)) {
return geometry.coordinates;
}
diff --git a/src/services/osm/types.ts b/src/services/osm/types.ts
index dea253ad..0ed731f5 100644
--- a/src/services/osm/types.ts
+++ b/src/services/osm/types.ts
@@ -12,6 +12,7 @@ export type OsmElement = {
uid: number;
tags: Record;
members?: RelationMember[];
+ nodes?: number[];
};
export type OsmResponse = {
diff --git a/src/services/types.ts b/src/services/types.ts
index 9ccb13c5..f3b53783 100644
--- a/src/services/types.ts
+++ b/src/services/types.ts
@@ -79,7 +79,7 @@ export type RelationMember = {
};
// TODO split in two types /extend/
-export interface Feature {
+export type Feature = {
point?: boolean; // TODO rename to isMarker or isCoords
type: 'Feature';
id?: number; // for map hover effect
@@ -125,7 +125,7 @@ export interface Feature {
state?: { hover: boolean };
skeleton?: boolean; // that means loading is in progress
nonOsmObject?: boolean;
-}
+};
export type MessagesType = typeof Vocabulary;
export type TranslationId = keyof MessagesType;
diff --git a/src/types.ts b/src/types.ts
index 5035bcdf..ab5f4fa1 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1 +1,3 @@
export type Setter = React.Dispatch>;
+
+export type Tile = { z: number; x: number; y: number };
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 00000000..940a44ab
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1,14 @@
+{
+ "functions": {
+ "pages/api/climbing-tiles/refresh.ts": {
+ "memory": 1200,
+ "maxDuration": 200
+ }
+ },
+ "crons": [
+ {
+ "path": "/api/climbing-tiles/refresh",
+ "schedule": "0 2 * * *"
+ }
+ ]
+}
diff --git a/yarn.lock b/yarn.lock
index dedb7e94..bfd026bf 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2069,6 +2069,11 @@
resolved "https://registry.yarnpkg.com/@types/pbf/-/pbf-3.0.5.tgz#a9495a58d8c75be4ffe9a0bd749a307715c07404"
integrity sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==
+"@types/pg-format@^1.0.5":
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/@types/pg-format/-/pg-format-1.0.5.tgz#329c4b64ec77d442407f59631c6b86fdb6461672"
+ integrity sha512-i+oEEJEC+1I3XAhgqtVp45Faj8MBbV0Aoq4rHsHD7avgLjyDkaWKObd514g0Q/DOUkdxU0P4CQ0iq2KR4SoJcw==
+
"@types/pg-pool@2.0.6":
version "2.0.6"
resolved "https://registry.yarnpkg.com/@types/pg-pool/-/pg-pool-2.0.6.tgz#1376d9dc5aec4bb2ec67ce28d7e9858227403c77"
@@ -6446,6 +6451,21 @@ performance-now@^2.1.0:
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==
+pg-cloudflare@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz#e6d5833015b170e23ae819e8c5d7eaedb472ca98"
+ integrity sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==
+
+pg-connection-string@^2.7.0:
+ version "2.7.0"
+ resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.7.0.tgz#f1d3489e427c62ece022dba98d5262efcb168b37"
+ integrity sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==
+
+pg-format@^1.0.4:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/pg-format/-/pg-format-1.0.4.tgz#27734236c2ad3f4e5064915a59334e20040a828e"
+ integrity sha512-YyKEF78pEA6wwTAqOUaHIN/rWpfzzIuMh9KdAhc3rSLQ/7zkRFcCgYBAEGatDstLyZw4g0s9SNICmaTGnBVeyw==
+
pg-int8@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c"
@@ -6456,12 +6476,22 @@ pg-numeric@1.0.2:
resolved "https://registry.yarnpkg.com/pg-numeric/-/pg-numeric-1.0.2.tgz#816d9a44026086ae8ae74839acd6a09b0636aa3a"
integrity sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==
+pg-pool@^3.7.0:
+ version "3.7.0"
+ resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.7.0.tgz#d4d3c7ad640f8c6a2245adc369bafde4ebb8cbec"
+ integrity sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==
+
pg-protocol@*:
version "1.6.1"
resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.6.1.tgz#21333e6d83b01faaebfe7a33a7ad6bfd9ed38cb3"
integrity sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==
-pg-types@^2.2.0:
+pg-protocol@^1.7.0:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.7.0.tgz#ec037c87c20515372692edac8b63cf4405448a93"
+ integrity sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==
+
+pg-types@^2.1.0, pg-types@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3"
integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==
@@ -6485,6 +6515,26 @@ pg-types@^4.0.1:
postgres-interval "^3.0.0"
postgres-range "^1.1.1"
+pg@^8.13.1:
+ version "8.13.1"
+ resolved "https://registry.yarnpkg.com/pg/-/pg-8.13.1.tgz#6498d8b0a87ff76c2df7a32160309d3168c0c080"
+ integrity sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==
+ dependencies:
+ pg-connection-string "^2.7.0"
+ pg-pool "^3.7.0"
+ pg-protocol "^1.7.0"
+ pg-types "^2.1.0"
+ pgpass "1.x"
+ optionalDependencies:
+ pg-cloudflare "^1.1.1"
+
+pgpass@1.x:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d"
+ integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==
+ dependencies:
+ split2 "^4.1.0"
+
picocolors@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
@@ -7287,6 +7337,11 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
+split2@^4.1.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4"
+ integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==
+
sprintf-js@~1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"