From 6fa9080fcce4fb3d29608d411c1478d569cb99a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Zbytovsk=C3=BD?= <zbytovsky@gmail.com> Date: Sun, 3 Dec 2023 18:05:05 +0100 Subject: [PATCH] SearchBox: render relations in overpass search (#213) --- .../Map/styles/layers/overpassLayers.ts | 32 ++- src/components/SearchBox/SearchBox.tsx | 12 +- src/services/__tests__/overpassSearch.test.ts | 202 +++++++++--------- src/services/getCenter.ts | 28 ++- src/services/overpassSearch.ts | 89 ++++---- src/services/types.ts | 10 +- 6 files changed, 206 insertions(+), 167 deletions(-) diff --git a/src/components/Map/styles/layers/overpassLayers.ts b/src/components/Map/styles/layers/overpassLayers.ts index b9ff5278..db8e70b2 100644 --- a/src/components/Map/styles/layers/overpassLayers.ts +++ b/src/components/Map/styles/layers/overpassLayers.ts @@ -9,7 +9,7 @@ export const overpassLayers: LayerSpecification[] = [ 'line-color': '#f8f4f0', 'line-width': 6, }, - }, + } as LayerSpecification, { id: 'overpass-line', type: 'line', @@ -24,7 +24,7 @@ export const overpassLayers: LayerSpecification[] = [ 1, ], }, - }, + } as LayerSpecification, { id: 'overpass-line-text', type: 'symbol', @@ -47,7 +47,7 @@ export const overpassLayers: LayerSpecification[] = [ 1, ], }, - }, + } as LayerSpecification, { id: 'overpass-fill', type: 'fill', @@ -62,12 +62,12 @@ export const overpassLayers: LayerSpecification[] = [ 0.5, ], }, - }, + } as LayerSpecification, { id: 'overpass-circle', type: 'circle', source: 'overpass', - filter: ['all', ['==', '$type', 'Point']], + filter: ['all', ['==', '$type', 'Point'], ['!=', 'osmappType', 'relation']], paint: { 'circle-color': 'rgba(255,255,255,0.9)', 'circle-radius': 12, @@ -80,12 +80,28 @@ export const overpassLayers: LayerSpecification[] = [ 1, ], }, - }, + } as LayerSpecification, + { + id: 'overpass-circle-relation', + type: 'circle', + source: 'overpass', + filter: ['all', ['==', '$type', 'Point'], ['==', 'osmappType', 'relation']], + paint: { + 'circle-color': 'rgba(255,0,0,0.7)', + 'circle-radius': 5, + 'circle-opacity': [ + 'case', + ['boolean', ['feature-state', 'hover'], false], + 0.5, + 1, + ], + }, + } as LayerSpecification, { id: 'overpass-symbol', type: 'symbol', source: 'overpass', - filter: ['all', ['==', '$type', 'Point']], + filter: ['all', ['==', '$type', 'Point'], ['!=', 'osmappType', 'relation']], layout: { 'text-padding': 2, 'text-font': ['Noto Sans Regular'], @@ -116,5 +132,5 @@ export const overpassLayers: LayerSpecification[] = [ 1, ], }, - }, + } as LayerSpecification, ]; diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index c455af7b..639256e2 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -79,6 +79,7 @@ fetchSchemaTranslations().then(() => { presetsForSearch = Object.values(presets) .filter(({ searchable }) => searchable === undefined || searchable) .filter(({ locationSet }) => !locationSet?.include) + .filter(({ tags }) => Object.keys(tags).length > 0) .map(({ name, presetKey, tags, terms }) => { const tagsAsStrings = Object.entries(tags).map(([k, v]) => `${k}=${v}`); return { @@ -120,7 +121,7 @@ const findInPresets = (inputValue) => { .filter((result) => result.name === 0 && result.sum > 0) .map((result) => ({ preset: result })); - // // experiment with sorting by number of matches + // // experiment with sorting by number of matches // TODO search in all words // const options = results // .filter((result) => result.sum > 0) // .sort((a, b) => { @@ -169,11 +170,13 @@ const fetchOptions = debounce( const useFetchOptions = (inputValue: string, setOptions) => { const { view } = useMapStateContext(); + useEffect(() => { if (inputValue === '') { setOptions([]); return; } + if (inputValue.length > 2) { const overpassQuery = getOverpassQuery(inputValue); const { nameMatches, rest } = findInPresets(inputValue); @@ -184,10 +187,11 @@ const useFetchOptions = (inputValue: string, setOptions) => { ]); const before = [...overpassQuery, ...nameMatches]; fetchOptions(inputValue, view, setOptions, before, rest); - } else { - setOptions([{ loader: true }]); - fetchOptions(inputValue, view, setOptions); + return; } + + setOptions([{ loader: true }]); + fetchOptions(inputValue, view, setOptions); }, [inputValue]); }; diff --git a/src/services/__tests__/overpassSearch.test.ts b/src/services/__tests__/overpassSearch.test.ts index e95e9fcf..380d5667 100644 --- a/src/services/__tests__/overpassSearch.test.ts +++ b/src/services/__tests__/overpassSearch.test.ts @@ -1,4 +1,4 @@ -import { osmJsonToSkeletons } from '../overpassSearch'; +import { overpassGeomToGeojson } from '../overpassSearch'; /* [out:json][timeout:25]; @@ -19,6 +19,36 @@ const response = { 'The data included in this document is from www.openstreetmap.org. The data is made available under ODbL.', }, elements: [ + { + type: 'node', + id: 761541677, + lat: 49.9594996, + lon: 14.3231551, + tags: { + highway: 'crossing', + crossing: 'marked', + }, + }, + { + type: 'way', + id: 11005021, + bounds: { + minlat: 49.9414065, + minlon: 14.2801555, + maxlat: 49.944281, + maxlon: 14.2929424, + }, + nodes: [73359754, 9441919612, 97951778], + geometry: [ + { lat: 49.9414065, lon: 14.2929424 }, + { lat: 49.942755, lon: 14.2892883 }, + { lat: 49.944281, lon: 14.2801555 }, + ], + tags: { + highway: 'track', + tracktype: 'grade3', + }, + }, { type: 'relation', id: 8337908, @@ -36,30 +66,6 @@ const response = { lat: 49.9511921, lon: 14.3409309, }, - { - type: 'node', - ref: 4303193147, - role: 'platform', - lat: 49.9560895, - lon: 14.3420546, - }, - { - type: 'node', - ref: 5650107055, - role: 'platform', - lat: 49.95976, - lon: 14.35261, - }, - { - type: 'way', - ref: 143079042, - role: '', - geometry: [ - { lat: 49.9510996, lon: 14.3406283 }, - { lat: 49.9512392, lon: 14.340966 }, - { lat: 49.9512882, lon: 14.3409961 }, - ], - }, { type: 'way', ref: 431070311, @@ -72,75 +78,9 @@ const response = { ], }, { - type: 'way', - ref: 143079039, - role: '', - geometry: [ - { lat: 49.9512723, lon: 14.3405453 }, - { lat: 49.9512769, lon: 14.3403348 }, - { lat: 49.9512638, lon: 14.3402265 }, - { lat: 49.9512376, lon: 14.3399046 }, - { lat: 49.9512538, lon: 14.3397224 }, - { lat: 49.9513307, lon: 14.3396011 }, - { lat: 49.9514674, lon: 14.339525 }, - { lat: 49.9516502, lon: 14.3394962 }, - { lat: 49.9519514, lon: 14.3395323 }, - { lat: 49.9524812, lon: 14.3397353 }, - { lat: 49.9528361, lon: 14.3398737 }, - { lat: 49.9529293, lon: 14.3399175 }, - { lat: 49.9533275, lon: 14.3401047 }, - { lat: 49.9536045, lon: 14.3402959 }, - { lat: 49.9543101, lon: 14.340794 }, - { lat: 49.9555376, lon: 14.3415765 }, - { lat: 49.9564591, lon: 14.3422582 }, - { lat: 49.9569866, lon: 14.3425944 }, - { lat: 49.9574162, lon: 14.3429583 }, - { lat: 49.9576284, lon: 14.3431366 }, - { lat: 49.9578467, lon: 14.3434496 }, - { lat: 49.9582484, lon: 14.3441399 }, - { lat: 49.9588316, lon: 14.3454072 }, - { lat: 49.9589787, lon: 14.3459137 }, - { lat: 49.9590815, lon: 14.3465416 }, - ], - }, - { - type: 'way', - ref: 538959927, - role: '', - geometry: [ - { lat: 49.9590815, lon: 14.3465416 }, - { lat: 49.9596598, lon: 14.3496964 }, - { lat: 49.9598834, lon: 14.3509159 }, - { lat: 49.959959, lon: 14.3513285 }, - { lat: 49.9600528, lon: 14.3518402 }, - { lat: 49.9600898, lon: 14.3520419 }, - { lat: 49.9602015, lon: 14.3522125 }, - ], - }, - { - type: 'way', - ref: 311389592, - role: '', - geometry: [ - { lat: 49.9598062, lon: 14.3530042 }, - { lat: 49.9598857, lon: 14.3529381 }, - { lat: 49.9599703, lon: 14.3528484 }, - { lat: 49.9600301, lon: 14.3527557 }, - { lat: 49.9600872, lon: 14.3526668 }, - { lat: 49.9601506, lon: 14.3525137 }, - { lat: 49.9602015, lon: 14.3522125 }, - ], - }, - { - type: 'way', - ref: 166349501, - role: '', - geometry: [ - { lat: 49.9598062, lon: 14.3530042 }, - { lat: 49.9597388, lon: 14.3526991 }, - { lat: 49.9596846, lon: 14.3526796 }, - { lat: 49.9595677, lon: 14.3527257 }, - ], + type: 'relation', + ref: 388266, + role: 'subarea', }, ], tags: { @@ -160,11 +100,80 @@ const response = { ], }; -const skeletons = [ +const geojson = [ { + center: [14.3231551, 49.9594996], geometry: { + coordinates: [14.3231551, 49.9594996], + type: 'Point', + }, + id: 7615416770, + osmMeta: { + id: 761541677, + type: 'node', + }, + properties: { + class: 'information', + crossing: 'marked', + highway: 'crossing', + osmappType: 'node', + subclass: 'crossing', + }, + tags: { + crossing: 'marked', + highway: 'crossing', + }, + type: 'Feature', + }, + { + center: [14.28654895, 49.942843749999994], + geometry: { + coordinates: [ + [14.2929424, 49.9414065], + [14.2892883, 49.942755], + [14.2801555, 49.944281], + ], type: 'LineString', }, + id: 110050211, + osmMeta: { + id: 11005021, + type: 'way', + }, + properties: { + class: 'information', + highway: 'track', + osmappType: 'way', + subclass: 'track', + tracktype: 'grade3', + }, + tags: { + highway: 'track', + tracktype: 'grade3', + }, + type: 'Feature', + }, + { + center: [14.3407707, 49.95124845], + geometry: { + geometries: [ + { + coordinates: [14.3409309, 49.9511921], + type: 'Point', + }, + { + coordinates: [ + [14.3409961, 49.9512882], + [14.3408764, 49.9513048], + [14.3406756, 49.9512958], + [14.3405453, 49.9512723], + ], + type: 'LineString', + }, + ], + type: 'GeometryCollection', + }, + id: 83379084, osmMeta: { id: 8337908, type: 'relation', @@ -175,6 +184,7 @@ const skeletons = [ name: '243: Kazín ⇒ Lipence', network: 'PID', operator: 'cz:DPP', + osmappType: 'relation', 'public_transport:version': '2', ref: '243', route: 'bus', @@ -202,5 +212,5 @@ const skeletons = [ ]; test('conversion', () => { - expect(osmJsonToSkeletons(response)).toEqual(skeletons); + expect(overpassGeomToGeojson(response)).toEqual(geojson); }); diff --git a/src/services/getCenter.ts b/src/services/getCenter.ts index 6e89fb0f..6e1bf59d 100644 --- a/src/services/getCenter.ts +++ b/src/services/getCenter.ts @@ -1,4 +1,4 @@ -import { FeatureGeometry, isPoint, isWay, Position } from './types'; +import { FeatureGeometry, isPoint, isRelation, isWay, Position } from './types'; interface NamedBbox { w: number; @@ -22,18 +22,32 @@ const getBbox = (coordinates: Position[]): NamedBbox => { ); }; +const getCenterOfBbox = (coordinates: Position[]) => { + if (!coordinates.length) return undefined; + + const { w, s, e, n } = getBbox(coordinates); // [WSEN] + const lon = (w + e) / 2; // flat earth rulezz + const lat = (s + n) / 2; + return [lon, lat]; +}; + export const getCenter = (geometry: FeatureGeometry): Position => { if (isPoint(geometry)) { return geometry.coordinates; } - if (isWay(geometry) && geometry.coordinates?.length) { - const { w, s, e, n } = getBbox(geometry.coordinates); // [WSEN] - const lon = (w + e) / 2; // flat earth rulezz - const lat = (s + n) / 2; - return [lon, lat]; + if (isWay(geometry)) { + return getCenterOfBbox(geometry.coordinates); + } + + if (isRelation(geometry)) { + const allCoords = geometry.geometries.flatMap((subGeometry) => + isPoint(subGeometry) + ? [subGeometry.coordinates] + : subGeometry.coordinates, + ); + return getCenterOfBbox(allCoords); } - // relation return undefined; }; diff --git a/src/services/overpassSearch.ts b/src/services/overpassSearch.ts index 17c58eec..f7513b07 100644 --- a/src/services/overpassSearch.ts +++ b/src/services/overpassSearch.ts @@ -1,4 +1,4 @@ -import { Feature, LineString, Point } from './types'; +import { Feature, GeometryCollection, LineString, Point } from './types'; import { getPoiClass } from './getPoiClass'; import { getCenter } from './getCenter'; import { OsmApiId } from './helpers'; @@ -15,10 +15,7 @@ const overpassQuery = (bbox, tags) => { way${query}(${bbox}); relation${query}(${bbox}); ); - out body; - >; - out skel qt;`; - // consider: out body geom + out geom qt;`; // "out geom;>;out geom qt;" to get all full subitems as well }; const getOverpassUrl = ([a, b, c, d], tags) => @@ -26,71 +23,61 @@ const getOverpassUrl = ([a, b, c, d], tags) => overpassQuery([d, a, b, c], tags), )}`; -const notNull = (x) => x != null; +const GEOMETRY = { + node: ({ lat, lon }): Point => ({ type: 'Point', coordinates: [lon, lat] }), -// maybe take inspiration from https://github.com/tyrasd/osmtogeojson/blob/gh-pages/index.js -export const osmJsonToSkeletons = (response: any): Feature[] => { - const nodesById = response.elements - .filter((element) => element.type === 'node') - .reduce((acc, node) => { - acc[node.id] = node; - return acc; - }, {}); + way: ({ geometry }): LineString => ({ + type: 'LineString', + coordinates: geometry.map(({ lat, lon }) => [lon, lat]), + }), + + relation: ({ members }): GeometryCollection => ({ + type: 'GeometryCollection', + geometries: + members + ?.map((el) => + el.type === 'node' + ? GEOMETRY.node(el) + : el.type === 'way' + ? GEOMETRY.way(el) + : null, + ) + .filter(Boolean) ?? [], + }), +}; - const getGeometry2 = { - node: ({ lat, lon }): Point => ({ type: 'Point', coordinates: [lon, lat] }), - way: (way): LineString => { - const { nodes } = way; - return { - type: 'LineString', // TODO distinguish area - match id-presets, then add icon for polygons - coordinates: nodes - ?.map((nodeId) => nodesById[nodeId]) - .map(({ lat, lon }) => [lon, lat]), - }; - }, - relation: ({ members }): LineString => ({ - type: 'LineString', - coordinates: members[0]?.geometry // TODO make proper relation handling - ?.filter(notNull) - ?.map(({ lat, lon }) => [lon, lat]), - }), - }; +const convertOsmIdToMapId = (apiId: OsmApiId) => { + const osmToMapType = { node: 0, way: 1, relation: 4 }; + return parseInt(`${apiId.id}${osmToMapType[apiId.type]}`, 10); +}; + +// maybe take inspiration from https://github.com/tyrasd/osmtogeojson/blob/gh-pages/index.js - return response.elements.map((element) => { +export const overpassGeomToGeojson = (response: any): Feature[] => + response.elements.map((element) => { const { type, id, tags = {} } = element; - const geometry = getGeometry2[type]?.(element); + const geometry = GEOMETRY[type]?.(element); return { type: 'Feature', + id: convertOsmIdToMapId({ type, id }), osmMeta: { type, id }, tags, - properties: { ...getPoiClass(tags), ...tags }, + properties: { ...getPoiClass(tags), ...tags, osmappType: type }, geometry, center: getCenter(geometry) ?? undefined, }; }); -}; - -const convertOsmIdToMapId = (apiId: OsmApiId) => { - const osmToMapType = { node: 0, way: 1, relation: 4 }; - return parseInt(`${apiId.id}${osmToMapType[apiId.type]}`, 10); -}; -export async function performOverpassSearch( +export const performOverpassSearch = async ( bbox, tags: Record<string, string>, -) { +) => { console.log('seaching overpass for tags: ', tags); // eslint-disable-line no-console const overpass = await fetchJson(getOverpassUrl(bbox, Object.entries(tags))); console.log('overpass result:', overpass); // eslint-disable-line no-console - const features = osmJsonToSkeletons(overpass) - .filter((feature) => feature.center && Object.keys(feature.tags).length > 0) - .map((feature) => ({ - ...feature, - id: convertOsmIdToMapId(feature.osmMeta), - })); - + const features = overpassGeomToGeojson(overpass); console.log('overpass geojson', features); // eslint-disable-line no-console return { type: 'FeatureCollection', features }; -} +}; diff --git a/src/services/types.ts b/src/services/types.ts index fd5abe26..14b2b927 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -31,12 +31,20 @@ export interface LineString { coordinates: Position[]; } -export type FeatureGeometry = Point | LineString; +export interface GeometryCollection { + type: 'GeometryCollection'; + geometries: Array<Point | LineString>; +} + +export type FeatureGeometry = Point | LineString | GeometryCollection; export const isPoint = (geometry: FeatureGeometry): geometry is Point => geometry?.type === 'Point'; export const isWay = (geometry: FeatureGeometry): geometry is LineString => geometry?.type === 'LineString'; +export const isRelation = ( + geometry: FeatureGeometry, +): geometry is GeometryCollection => geometry?.type === 'GeometryCollection'; export interface FeatureTags { [key: string]: string;