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"