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;