diff --git a/.gitignore b/.gitignore index ed84fc933..c1a864a70 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ yarn-error.log* .env.local .env.sentry-build-plugin /public/icons-indoor +/.xata/migrations/*.json diff --git a/.xata/migrations/.ledger b/.xata/migrations/.ledger new file mode 100644 index 000000000..a54052c88 --- /dev/null +++ b/.xata/migrations/.ledger @@ -0,0 +1,6 @@ +mig_csqvnk4icce8859die00 +mig_csqvvssicce8859die5g +mig_ct2scmdc21vap51ejf8g +sql_28451505e1dd5d +sql_c642d1b1707c7a +sql_8f7a225a10aa7d diff --git a/.xata/version/compatibility.json b/.xata/version/compatibility.json new file mode 100644 index 000000000..f4a7c9253 --- /dev/null +++ b/.xata/version/compatibility.json @@ -0,0 +1,10 @@ +{ + "@xata.io/cli": { + "latest": "0.16.12", + "compatibility": [{ "range": ">=0.0.0" }] + }, + "@xata.io/client": { + "latest": "0.30.1", + "compatibility": [{ "range": ">=0.0.0" }] + } +} diff --git a/.xatarc b/.xatarc new file mode 100644 index 000000000..0d565b7d9 --- /dev/null +++ b/.xatarc @@ -0,0 +1,6 @@ +{ + "databaseURL": "https://osmapp-tvgiad.us-east-1.xata.sh/db/db_with_direct_access", + "codegen": { + "output": "src/server/db/xata-generated.ts" + } +} \ No newline at end of file diff --git a/package.json b/package.json index 07ff9f8a5..14fde42eb 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@emotion/react": "^11.13.3", "@emotion/server": "^11.11.0", "@emotion/styled": "^11.13.0", + "@mapbox/tilebelt": "^2.0.2", "@mui/icons-material": "^6.1.3", "@mui/lab": "^6.0.0-beta.11", "@mui/material": "^6.1.3", @@ -33,6 +34,7 @@ "@openstreetmap/id-tagging-schema": "^6.8.1", "@sentry/nextjs": "^8.34.0", "@teritorio/openmaptiles-gl-language": "^1.5.4", + "@xata.io/client": "^0.0.0-next.va121e4207b94bfe0a3c025fc00b247b923880930", "@xmldom/xmldom": "^0.9.3", "accept-language-parser": "^1.5.0", "autosuggest-highlight": "^3.3.4", @@ -40,6 +42,7 @@ "canvg": "^4.0.2", "date-fns": "^4.1.0", "dice-coefficient": "^2.1.1", + "geohashing": "^2.0.1", "image-size": "^1.1.1", "isomorphic-unfetch": "^4.0.2", "isomorphic-xml2js": "^0.1.3", @@ -55,6 +58,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", diff --git a/pages/api/climbing-tiles/refresh.ts b/pages/api/climbing-tiles/refresh.ts new file mode 100644 index 000000000..87e4718d9 --- /dev/null +++ b/pages/api/climbing-tiles/refresh.ts @@ -0,0 +1,24 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { refresh } from '../../../src/server/climbing-tiles/refresh.js'; + +export default async (req: NextApiRequest, res: NextApiResponse) => { + try { + res.writeHead(200, { + Connection: 'keep-alive', + 'Content-Type': 'text/plain; charset=utf-8', + 'Transfer-Encoding': 'chunked', + }); + + const writeCallback = (line: string) => { + console.log(line); // eslint-disable-line no-console + res.write(line + '\n'); + }; + + await refresh(writeCallback); + + res.end(); + } 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 000000000..133448acd --- /dev/null +++ b/pages/api/climbing-tiles/tile.ts @@ -0,0 +1,22 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { + climbingTile, + TileNumber, +} from '../../../src/server/climbing-tiles/climbing-tile'; + +export default async (req: NextApiRequest, res: NextApiResponse) => { + try { + const tileNumber = [req.query.z, req.query.x, req.query.y].map( + Number, + ) as TileNumber; + + const buffer = await climbingTile(tileNumber, req.query.type); + res + .setHeader('Content-Type', 'application/x-protobuf') + .status(200) + .end(buffer); + } catch (err) { + console.error(err); // eslint-disable-line no-console + res.status(err.code ?? 400).send(String(err)); + } +}; diff --git a/src/components/Map/behaviour/useUpdateStyle.tsx b/src/components/Map/behaviour/useUpdateStyle.tsx index c2dc66459..6316402b0 100644 --- a/src/components/Map/behaviour/useUpdateStyle.tsx +++ b/src/components/Map/behaviour/useUpdateStyle.tsx @@ -64,19 +64,25 @@ const addRasterOverlay = ( }; const addClimbingOverlay = (style: StyleSpecification, map: Map) => { - style.sources.climbing = EMPTY_GEOJSON_SOURCE; - style.layers.push(...climbingLayers); // must be also in `layersWithOsmId` because of hover effect - style.sprite = [...OSMAPP_SPRITE, CLIMBING_SPRITE]; - - fetchCrags().then( - (geojson) => { - const geojsonSource = map.getSource('climbing') as GeoJSONSource; - geojsonSource?.setData(geojson); // TODO can be undefined at first map render - }, - (error) => { - console.warn('Climbing Layer failed to fetch.', error); // eslint-disable-line no-console - }, - ); + // style.sources.climbing = EMPTY_GEOJSON_SOURCE; + style.layers.push( + ...climbingLayers.map((x) => ({ + ...x, + source: 'climbingTiles', + 'source-layer': 'groups', + })), + ); // must be also in `layersWithOsmId` because of hover effect + // style.sprite = [...OSMAPP_SPRITE, CLIMBING_SPRITE]; + + // fetchCrags().then( + // (geojson) => { + // const geojsonSource = map.getSource('climbing') as GeoJSONSource; + // geojsonSource?.setData(geojson); // TODO can be undefined at first map render + // }, + // (error) => { + // console.warn('Climbing Layer failed to fetch.', error); // eslint-disable-line no-console + // }, + // ); }; const addOverlaysToStyle = ( diff --git a/src/components/Map/consts.ts b/src/components/Map/consts.ts index 838e779c7..1165d0124 100644 --- a/src/components/Map/consts.ts +++ b/src/components/Map/consts.ts @@ -50,6 +50,11 @@ export const OSMAPP_SOURCES: Record = { type: 'vector' as const, }, overpass: EMPTY_GEOJSON_SOURCE, + climbingTiles: { + type: 'vector' as const, + tiles: ['http://localhost:3000/api/climbing-tiles/tile?z={z}&x={x}&y={y}'], + maxzoom: 10, + }, }; export const BACKGROUND = [ diff --git a/src/components/Map/styles/layers/climbingLayers.ts b/src/components/Map/styles/layers/climbingLayers.ts index a625fbad9..6a81563d9 100644 --- a/src/components/Map/styles/layers/climbingLayers.ts +++ b/src/components/Map/styles/layers/climbingLayers.ts @@ -37,7 +37,7 @@ const linearByRouteCount = ( ): ExpressionSpecification => [ 'interpolate', ['linear'], - ['get', 'osmappRouteCount'], + ['coalesce', ['get', 'osmappRouteCount'], 0], from, a, to, @@ -75,8 +75,9 @@ const sortKey = [ -1, [ '+', - ['get', 'osmappRouteCount'], - ['case', ['get', 'osmappHasImages'], 99999, 0], // preference for items with images + ['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; @@ -99,16 +100,19 @@ const step = ( ]; export const routes: LayerSpecification[] = [ + { + id: 'climbing-3-routes-line', + type: 'line', + source: 'climbing', + minzoom: 16, + filter: ['all', ['==', 'type', 'route']], + }, { id: 'climbing-3-routes-circle', type: 'circle', source: 'climbing', minzoom: 16, - filter: [ - 'all', - ['==', 'osmappType', 'node'], - ['==', 'climbing', 'route_bottom'], - ], + filter: ['all', ['==', 'type', 'route']], paint: { 'circle-color': [ 'case', @@ -125,11 +129,7 @@ export const routes: LayerSpecification[] = [ type: 'symbol', source: 'climbing', minzoom: 19, - filter: [ - 'all', - ['==', 'osmappType', 'node'], - ['==', 'climbing', 'route_bottom'], - ], + filter: ['all', ['==', 'type', 'route']], layout: { 'text-padding': 2, 'text-font': ['Noto Sans Medium'], @@ -179,11 +179,7 @@ const mixed: LayerSpecification = { type: 'symbol', source: 'climbing', maxzoom: 20, - filter: [ - 'all', - ['==', 'osmappType', 'relationPoint'], - ['in', 'climbing', 'area', 'crag'], - ], + filter: ['all', ['==', 'type', 'group']], layout: { 'icon-image': ifCrag( byHasImages(CRAG, 'IMAGE'), diff --git a/src/server/climbing-tiles/climbing-tile.ts b/src/server/climbing-tiles/climbing-tile.ts new file mode 100644 index 000000000..b5b99cd1e --- /dev/null +++ b/src/server/climbing-tiles/climbing-tile.ts @@ -0,0 +1,60 @@ +import vtpbf from 'vt-pbf'; +import geojsonVt from 'geojson-vt'; +import { BBox } from 'geojson'; +import { getClient } from './db'; + +const r2d = 180 / Math.PI; + +function tile2lon(x: number, z: number): number { + return (x / Math.pow(2, z)) * 360 - 180; +} + +function 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 function tileToBBOX([z, x, y]: TileNumber): 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]; +} + +export type TileNumber = [number, number, number]; // z,x,y + +const fetchFromDb = async ([z, x, y]: TileNumber) => { + const start = performance.now(); + + const client = await getClient(); + + const bbox = tileToBBOX([z, x, y]); + + const query = + z < 10 + ? `SELECT geojson FROM climbing_tiles WHERE type='group' AND lon >= ${bbox[0]} AND lon <= ${bbox[2]} AND lat >= ${bbox[1]} AND lat <= ${bbox[3]}` + : `SELECT geojson FROM climbing_tiles WHERE type IN ('group', 'route') AND lon >= ${bbox[0]} AND lon <= ${bbox[2]} AND lat >= ${bbox[1]} AND lat <= ${bbox[3]}`; + const result = await client.query(query); + const geojson = { + type: 'FeatureCollection', + features: result.rows.map((record) => record.geojson), + } as GeoJSON.FeatureCollection; + console.log('climbingTilePg', performance.now() - start, result.rows.length); + + return geojson; +}; + +export const climbingTile = async ([z, x, y]: TileNumber, type: string) => { + if (type === 'json') { + const orig = await fetchFromDb([z, x, y]); + return JSON.stringify(orig); + } + + const orig = await fetchFromDb([z, x, y]); + + const tileindex = geojsonVt(orig, { tolerance: 0 }); + const tile = tileindex.getTile(z, x, y); + console.log(tile); + + return tile ? vtpbf.fromGeojsonVt({ groups: tile }) : null; +}; diff --git a/src/server/climbing-tiles/db.ts b/src/server/climbing-tiles/db.ts new file mode 100644 index 000000000..ada0345d5 --- /dev/null +++ b/src/server/climbing-tiles/db.ts @@ -0,0 +1,27 @@ +import { Client } from 'pg'; + +if (!global.db) { + global.db = { pool: false }; +} + +export async function getClient() { + if (!global.db.pool) { + console.log('No pool available, creating new pool.'); + + const client = new Client({ + user: 'tvgiad', + password: 'xau_E0h76BAWwiiGCOqEYZsRoCUQqXEQ3jpM', + host: 'us-east-1.sql.xata.sh', + port: 5432, + database: 'db_with_direct_access:main', + ssl: { + rejectUnauthorized: false, + }, + }); + + await client.connect(); + + global.db.pool = client; + } + return global.db.pool; +} 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 000000000..cd2221ccd --- /dev/null +++ b/src/server/climbing-tiles/overpass/__tests__/basic.test.ts @@ -0,0 +1,136 @@ +const elements: OsmItem[] = [ + // { + // "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 000000000..67d4a4a7e --- /dev/null +++ b/src/server/climbing-tiles/overpass/overpassToGeojsons.ts @@ -0,0 +1,235 @@ +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 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, + parseFloat(element.tags['climbing:sport'] ?? '0'), // TODO sum all types + ) + : 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/refresh.ts b/src/server/climbing-tiles/refresh.ts new file mode 100644 index 000000000..d3ce8a14e --- /dev/null +++ b/src/server/climbing-tiles/refresh.ts @@ -0,0 +1,229 @@ +import { + GeojsonFeature, + OsmResponse, + overpassToGeojsons, +} from './overpass/overpassToGeojsons'; +import { EditableData } from '@xata.io/client'; +import { ClimbingTilesRecord } from '../db/xata-generated'; +import { encodeUrl } from '../../helpers/utils'; +import { fetchJson } from '../../services/fetch'; +import { LineString, LonLat, Point } from '../../services/types'; +import * as fs from 'node:fs'; +import { Client } from 'pg'; +import format from 'pg-format'; +import { encodeBase32, decodeBase32 } from 'geohashing'; + +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 = 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, + geohash: encodeBase32(lat, lon, 2), + 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; +}; + +export const refresh = async (log: (line: string) => void) => { + const client = new Client({ + user: 'tvgiad', + password: 'xau_E0h76BAWwiiGCOqEYZsRoCUQqXEQ3jpM', + host: 'us-east-1.sql.xata.sh', + port: 5432, + database: 'db_with_direct_access:main', + ssl: { + rejectUnauthorized: false, + }, + }); + + await client.connect(); + + const start = performance.now(); + + log('Fetching data from Overpass...'); + const data = await fetchFromOverpass(); + // fs.writeFileSync('../overpass.json', JSON.stringify(data)); + // const data = JSON.parse( + // fs.readFileSync('../overpass.json').toString(), + // ) as OsmResponse; + 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( + `DELETE FROM climbing_tiles;INSERT INTO climbing_tiles(%I) VALUES %L`, + columns, + values, + ); + log(`SQL Query length: ${query.length} chars`); + + await client.query(query); + await client.end(); + + log('Done.'); + log(`Duration: ${Math.round(performance.now() - start)} ms`); +}; diff --git a/src/server/db/db.ts b/src/server/db/db.ts new file mode 100644 index 000000000..a5cc4a3b5 --- /dev/null +++ b/src/server/db/db.ts @@ -0,0 +1,3 @@ +import { getXataClient } from './xata-generated'; + +export const xata = getXataClient(); diff --git a/src/server/db/xata-generated.ts b/src/server/db/xata-generated.ts new file mode 100644 index 000000000..d97d49ce2 --- /dev/null +++ b/src/server/db/xata-generated.ts @@ -0,0 +1,224 @@ +// Generated by Xata Codegen 0.30.1. Please do not edit. +import { buildClient } from '@xata.io/client'; +import type { + BaseClientOptions, + SchemaInference, + XataRecord, +} from '@xata.io/client'; + +const tables = [ + { + name: 'climbing_tiles', + checkConstraints: { + climbing_tiles_xata_id_length_xata_id: { + name: 'climbing_tiles_xata_id_length_xata_id', + columns: ['xata_id'], + definition: 'CHECK ((length(xata_id) < 256))', + }, + }, + foreignKeys: {}, + primaryKey: [], + uniqueConstraints: { + _pgroll_new_climbing_tiles_xata_id_key: { + name: '_pgroll_new_climbing_tiles_xata_id_key', + columns: ['xata_id'], + }, + }, + columns: [ + { + name: 'count', + type: 'int', + notNull: false, + unique: false, + defaultValue: null, + comment: '', + }, + { + name: 'geohash', + type: 'text', + notNull: true, + unique: false, + defaultValue: null, + comment: '', + }, + { + name: 'geojson', + type: 'json', + notNull: true, + unique: false, + defaultValue: null, + comment: '', + }, + { + name: 'lat', + type: 'float', + notNull: true, + unique: false, + defaultValue: null, + comment: '', + }, + { + name: 'lon', + type: 'float', + notNull: true, + unique: false, + defaultValue: null, + comment: '', + }, + { + name: 'name', + type: 'text', + notNull: false, + unique: false, + defaultValue: null, + comment: '', + }, + { + name: 'osmId', + type: 'int', + notNull: true, + unique: false, + defaultValue: null, + comment: '', + }, + { + name: 'osmType', + type: 'text', + notNull: true, + unique: false, + defaultValue: null, + comment: '', + }, + { + name: 'type', + type: 'text', + notNull: true, + unique: false, + defaultValue: null, + comment: '', + }, + { + name: 'xata_createdat', + type: 'datetime', + notNull: true, + unique: false, + defaultValue: 'now()', + comment: '', + }, + { + name: 'xata_id', + type: 'text', + notNull: true, + unique: true, + defaultValue: "('rec_'::text || (xata_private.xid())::text)", + comment: '', + }, + { + name: 'xata_updatedat', + type: 'datetime', + notNull: true, + unique: false, + defaultValue: 'now()', + comment: '', + }, + { + name: 'xata_version', + type: 'int', + notNull: true, + unique: false, + defaultValue: '0', + comment: '', + }, + ], + }, + { + name: 'test_xata_cols', + checkConstraints: { + test_xata_cols_xata_id_length_xata_id: { + name: 'test_xata_cols_xata_id_length_xata_id', + columns: ['xata_id'], + definition: 'CHECK ((length(xata_id) < 256))', + }, + }, + foreignKeys: {}, + primaryKey: [], + uniqueConstraints: { + _pgroll_new_test_xata_cols_xata_id_key: { + name: '_pgroll_new_test_xata_cols_xata_id_key', + columns: ['xata_id'], + }, + }, + columns: [ + { + name: 'xata_createdat', + type: 'datetime', + notNull: true, + unique: false, + defaultValue: 'now()', + comment: '', + }, + { + name: 'xata_id', + type: 'text', + notNull: true, + unique: true, + defaultValue: "('rec_'::text || (xata_private.xid())::text)", + comment: '', + }, + { + name: 'xata_updatedat', + type: 'datetime', + notNull: true, + unique: false, + defaultValue: 'now()', + comment: '', + }, + { + name: 'xata_version', + type: 'int', + notNull: true, + unique: false, + defaultValue: '0', + comment: '', + }, + ], + }, +] as const; + +export type SchemaTables = typeof tables; +export type InferredTypes = SchemaInference; + +export type ClimbingTiles = InferredTypes['climbing_tiles']; +export type ClimbingTilesRecord = ClimbingTiles & XataRecord; + +export type TestXataCols = InferredTypes['test_xata_cols']; +export type TestXataColsRecord = TestXataCols & XataRecord; + +export type DatabaseSchema = { + climbing_tiles: ClimbingTilesRecord; + test_xata_cols: TestXataColsRecord; +}; + +const DatabaseClient = buildClient(); + +const defaultOptions = { + databaseURL: + 'https://osmapp-tvgiad.us-east-1.xata.sh/db/db_with_direct_access', + apiKey: 'xau_E0h76BAWwiiGCOqEYZsRoCUQqXEQ3jpM', + branch: 'main', +}; + +export class XataClient extends DatabaseClient { + constructor(options?: BaseClientOptions) { + super({ ...defaultOptions, ...options }, tables); + } +} + +let instance: XataClient | undefined = undefined; + +export const getXataClient = () => { + if (instance) return instance; + + instance = new XataClient(); + return instance; +}; diff --git a/src/services/fetchCrags.tsx b/src/services/fetchCrags.tsx index 8ae9088bd..daab551a3 100644 --- a/src/services/fetchCrags.tsx +++ b/src/services/fetchCrags.tsx @@ -183,6 +183,13 @@ export const cragsToGeojson = (response: any): Feature[] => { // on CZ 48,11,51,19 makes 12 MB (only crags is 700kB) export const fetchCrags = async () => { + const tile = await fetchJson('/api/climbing-tiles/tile', { nocache: true }); + publishDbgObject('fetchCrags', tile); + return { + type: 'FeatureCollection', + features: tile, + } as GeoJSON.FeatureCollection; + const query = `[out:json][timeout:25]; ( nwr["climbing"](49.64474,14.21855,49.67273,14.28025); diff --git a/src/services/getCenter.ts b/src/services/getCenter.ts index 836490aae..9ecb0d29d 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/types.ts b/src/services/types.ts index 9ccb13c51..f3b537832 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/tsconfig.json b/tsconfig.json index cb770944d..39c8fa219 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "node16", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", diff --git a/vercel.json b/vercel.json new file mode 100644 index 000000000..83d1ecde7 --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "functions": { + "pages/api/climbing-tiles/refresh.ts": { + "memory": 3009, + "maxDuration": 300 + } + } +} diff --git a/yarn.lock b/yarn.lock index dedb7e949..5a3c3b8c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1001,6 +1001,11 @@ resolved "https://registry.yarnpkg.com/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz#8a83f9335c7860effa2eeeca254332aa0aeed8f2" integrity sha1-ioP5M1x4YO/6Lu7KJUMyqgru2PI= +"@mapbox/tilebelt@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@mapbox/tilebelt/-/tilebelt-2.0.2.tgz#8176d82a05541acee8104fe1d280981ebd8d4b7a" + integrity sha512-pzFgrmsCUjOAAeYns1MKJ1dJICYdEC+sYwtWLdxO7iJwmDlepGrEXDqP9ejApO/YUVctA/SYKDS4Oc0mBNNLZw== + "@mapbox/tiny-sdf@^2.0.6": version "2.0.6" resolved "https://registry.yarnpkg.com/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz#9a1d33e5018093e88f6a4df2343e886056287282" @@ -2276,6 +2281,11 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== +"@xata.io/client@^0.0.0-next.va121e4207b94bfe0a3c025fc00b247b923880930": + version "0.0.0-next.va121e4207b94bfe0a3c025fc00b247b923880930" + resolved "https://registry.yarnpkg.com/@xata.io/client/-/client-0.0.0-next.va121e4207b94bfe0a3c025fc00b247b923880930.tgz#c3feb0f27a11c1418540fe4c0f219ecf6804e77d" + integrity sha512-tRhsrV6vSJk4r+Cn7WkE1hyV2WfnHOoMzpQeLujqrbemPtVj82R1uhqHM2fPFuUKMfD+19PVF+Gist0ePlX5SQ== + "@xmldom/xmldom@^0.9.3": version "0.9.3" resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.9.3.tgz#a5d5362050767d8823b2b74e36cb2f059f58e797" @@ -4135,6 +4145,11 @@ gensync@^1.0.0-beta.2: resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== +geohashing@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/geohashing/-/geohashing-2.0.1.tgz#c081070a2b6ca551930f75ce8586afe0dd179944" + integrity sha512-u0H29yhqHEyBBgwv1GJrvqbD33AnQm2lbWOLNbXLZEWVrCKNoblyiWWnVGeYGaM4NNM7rBBTqvJZhZ/CDfrBVw== + geojson-vt@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/geojson-vt/-/geojson-vt-4.0.2.tgz#1162f6c7d61a0ba305b1030621e6e111f847828a" @@ -6446,6 +6461,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 +6486,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 +6525,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 +7347,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"