From 4bf23143e0e3b15f14cc7d7c18fe0e89a59b77c8 Mon Sep 17 00:00:00 2001
From: Paul Bienkowski <pb@opatut.de>
Date: Fri, 3 Dec 2021 19:28:07 +0100
Subject: [PATCH] feat: Split road statistics by direction (fixes #117)

---
 api/obs/api/routes/mapdetails.py |  63 +++++++++++++--
 frontend/src/pages/MapPage.tsx   | 130 ++++++++++++++++++++++---------
 2 files changed, 149 insertions(+), 44 deletions(-)

diff --git a/api/obs/api/routes/mapdetails.py b/api/obs/api/routes/mapdetails.py
index d34c9b1..2c9ce59 100644
--- a/api/obs/api/routes/mapdetails.py
+++ b/api/obs/api/routes/mapdetails.py
@@ -1,6 +1,7 @@
 import json
 from functools import partial
 import numpy
+import math
 
 from sqlalchemy import select, func, column
 
@@ -36,6 +37,16 @@ def get_single_arg(req, name, default=RAISE, convert=None):
     return value
 
 
+def get_bearing(a, b):
+    # longitude, latitude
+    dL = b[0] - a[0]
+    X = numpy.cos(b[1]) * numpy.sin(dL)
+    Y = numpy.cos(a[1]) * numpy.sin(b[1]) - numpy.sin(a[1]) * numpy.cos(
+        b[1]
+    ) * numpy.cos(dL)
+    return numpy.arctan2(X, Y)
+
+
 @api.route("/mapdetails/road", methods=["GET"])
 async def mapdetails_road(req):
     longitude = get_single_arg(req, "longitude", convert=float)
@@ -77,22 +88,40 @@ async def mapdetails_road(req):
                     OvertakingEvent.distance_overtaker,
                     OvertakingEvent.distance_stationary,
                     OvertakingEvent.speed,
+                    # Keep this as the last entry always for numpy.partition
+                    # below to work.
+                    OvertakingEvent.direction_reversed,
                 ]
             ).where(OvertakingEvent.way_id == road.way_id)
         )
     ).all()
 
-    arrays = numpy.array(arrays).T.astype(numpy.float64)
+    arrays = numpy.array(arrays).T
 
     if len(arrays) == 0:
-        arrays = numpy.array([[], [], []])
+        arrays = numpy.array([[], [], [], []], dtype=numpy.float)
+
+    data, mask = arrays[:-1], arrays[-1]
+    data = data.astype(numpy.float64)
+    mask = mask.astype(numpy.bool)
+
+    def partition(arr, cond):
+        return arr[:, cond], arr[:, ~cond]
+
+    forwards, backwards = partition(data, mask)
+    print("for", forwards.dtype, "back", backwards.dtype)
 
     def array_stats(arr, rounder):
-        arr = arr[~numpy.isnan(arr)]
+        if len(arr):
+            print("ARR DTYPE", arr.dtype)
+            print("ARR", arr)
+            arr = arr[~numpy.isnan(arr)]
+
         n = len(arr)
+
         return {
             "statistics": {
-                "count": len(arr),
+                "count": n,
                 "mean": rounder(numpy.mean(arr)) if n else None,
                 "min": rounder(numpy.min(arr)) if n else None,
                 "max": rounder(numpy.max(arr)) if n else None,
@@ -101,11 +130,31 @@ async def mapdetails_road(req):
             "values": list(map(rounder, arr.tolist())),
         }
 
+    bearing = None
+
+    geom = json.loads(road.geometry)
+    if geom["type"] == "LineString":
+        coordinates = geom["coordinates"]
+        bearing = get_bearing(coordinates[0], coordinates[-1])
+        # convert to degrees, as this is more natural to understand for consumers
+        bearing = round_to((bearing / math.pi * 180 + 360) % 360, 1)
+
+    print(road.geometry)
+
+    def get_direction_stats(direction_arrays, backwards=False):
+        return {
+            "bearing": ((bearing + 180) % 360 if backwards else bearing)
+            if bearing is not None
+            else None,
+            "distanceOvertaker": array_stats(direction_arrays[0], round_distance),
+            "distanceStationary": array_stats(direction_arrays[1], round_distance),
+            "speed": array_stats(direction_arrays[2], round_speed),
+        }
+
     return response.json(
         {
             "road": road.to_dict(),
-            "distanceOvertaker": array_stats(arrays[0], round_distance),
-            "distanceStationary": array_stats(arrays[1], round_distance),
-            "speed": array_stats(arrays[2], round_speed),
+            "forwards": get_direction_stats(forwards),
+            "backwards": get_direction_stats(backwards, True),
         }
     )
diff --git a/frontend/src/pages/MapPage.tsx b/frontend/src/pages/MapPage.tsx
index bb8ea0d..9341f3d 100644
--- a/frontend/src/pages/MapPage.tsx
+++ b/frontend/src/pages/MapPage.tsx
@@ -1,6 +1,6 @@
-import React from 'react'
+import React, {useState, useCallback, useMemo, useEffect} from 'react'
 import _ from 'lodash'
-import {Segment, List, Header, Label, Icon, Table} from 'semantic-ui-react'
+import {Segment, Menu, Header, Label, Icon, Table} from 'semantic-ui-react'
 import ReactMapGl, {WebMercatorViewport, AttributionControl, NavigationControl, Layer, Source} from 'react-map-gl'
 import turfBbox from '@turf/bbox'
 import {useHistory, useLocation} from 'react-router-dom'
@@ -9,7 +9,7 @@ import {useObservable} from 'rxjs-hooks'
 import {switchMap, distinctUntilChanged} from 'rxjs/operators'
 
 import {Page} from 'components'
-import {useConfig, Config} from 'config'
+import {useConfig} from 'config'
 import api from 'api'
 
 import {roadsLayer, basemap} from '../mapstyles'
@@ -36,8 +36,8 @@ function buildHash(v) {
 function useViewportFromUrl() {
   const history = useHistory()
   const location = useLocation()
-  const value = React.useMemo(() => parseHash(location.hash), [location.hash])
-  const setter = React.useCallback(
+  const value = useMemo(() => parseHash(location.hash), [location.hash])
+  const setter = useCallback(
     (v) => {
       history.replace({
         hash: buildHash(v),
@@ -58,19 +58,19 @@ export function CustomMap({
   children: React.ReactNode
   boundsFromJson: GeoJSON.Geometry
 }) {
-  const [viewportState, setViewportState] = React.useState(EMPTY_VIEWPORT)
+  const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT)
   const [viewportUrl, setViewportUrl] = useViewportFromUrl()
 
   const [viewport, setViewport] = viewportFromUrl ? [viewportUrl, setViewportUrl] : [viewportState, setViewportState]
 
   const config = useConfig()
-  React.useEffect(() => {
+  useEffect(() => {
     if (config?.mapHome && viewport.latitude === 0 && viewport.longitude === 0 && !boundsFromJson) {
       setViewport(config.mapHome)
     }
   }, [config, boundsFromJson])
 
-  React.useEffect(() => {
+  useEffect(() => {
     if (boundsFromJson) {
       const [minX, minY, maxX, maxY] = turfBbox(boundsFromJson)
       const vp = new WebMercatorViewport({width: 1000, height: 800}).fitBounds(
@@ -107,8 +107,55 @@ export function CustomMap({
 const UNITS = {distanceOvertaker: 'm', distanceStationary: 'm', speed: 'm/s'}
 const LABELS = {distanceOvertaker: 'Overtaker', distanceStationary: 'Stationary', speed: 'Speed'}
 const ZONE_COLORS = {urban: 'olive', rural: 'brown', motorway: 'purple'}
+const CARDINAL_DIRECTIONS = ['north', 'north-east', 'east', 'south-east', 'south', 'south-west', 'west', 'north-west']
+const getCardinalDirection = (bearing) =>
+  bearing == null
+    ? 'unknown'
+    : CARDINAL_DIRECTIONS[
+        Math.floor(((bearing / 360.0) * CARDINAL_DIRECTIONS.length + 0.5) % CARDINAL_DIRECTIONS.length)
+      ] + ' bound'
+
+function RoadStatsTable({data}) {
+  return (
+    <Table size="small" compact>
+      <Table.Header>
+        <Table.Row>
+          <Table.HeaderCell>Property</Table.HeaderCell>
+          <Table.HeaderCell>n</Table.HeaderCell>
+          <Table.HeaderCell>min</Table.HeaderCell>
+          <Table.HeaderCell>q50</Table.HeaderCell>
+          <Table.HeaderCell>max</Table.HeaderCell>
+          <Table.HeaderCell>mean</Table.HeaderCell>
+          <Table.HeaderCell>unit</Table.HeaderCell>
+        </Table.Row>
+      </Table.Header>
+      <Table.Body>
+        {['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => (
+          <Table.Row key={prop}>
+            <Table.Cell>{LABELS[prop]}</Table.Cell>
+            {['count', 'min', 'median', 'max', 'mean'].map((stat) => (
+              <Table.Cell key={stat}>{data[prop]?.statistics?.[stat]?.toFixed(stat === 'count' ? 0 : 3)}</Table.Cell>
+            ))}
+            <Table.Cell>{UNITS[prop]}</Table.Cell>
+          </Table.Row>
+        ))}
+      </Table.Body>
+    </Table>
+  )
+}
 
 function CurrentRoadInfo({clickLocation}) {
+  const [direction, setDirection] = useState('forwards')
+
+  const onClickDirection = useCallback(
+    (e, {name}) => {
+      e.preventDefault()
+      e.stopPropagation()
+      setDirection(name)
+    },
+    [setDirection]
+  )
+
   const info = useObservable(
     (_$, inputs$) =>
       inputs$.pipe(
@@ -139,6 +186,8 @@ function CurrentRoadInfo({clickLocation}) {
 
   const loading = info == null
 
+  const offsetDirection = info?.road.oneway ? 0 : direction === 'forwards' ? 1 : -1; // TODO: change based on left-hand/right-hand traffic
+
   const content =
     !loading && !info.road ? (
       'No road found.'
@@ -158,32 +207,19 @@ function CurrentRoadInfo({clickLocation}) {
           </Label>
         )}
 
-        <Table size="small" compact>
-          <Table.Header>
-            <Table.Row>
-              <Table.HeaderCell>Property</Table.HeaderCell>
-              <Table.HeaderCell>n</Table.HeaderCell>
-              <Table.HeaderCell>min</Table.HeaderCell>
-              <Table.HeaderCell>q50</Table.HeaderCell>
-              <Table.HeaderCell>max</Table.HeaderCell>
-              <Table.HeaderCell>mean</Table.HeaderCell>
-              <Table.HeaderCell>unit</Table.HeaderCell>
-            </Table.Row>
-          </Table.Header>
-          <Table.Body>
-            {['distanceOvertaker', 'distanceStationary', 'speed'].map((prop) => (
-              <Table.Row key={prop}>
-                <Table.Cell>{LABELS[prop]}</Table.Cell>
-                {['count', 'min', 'median', 'max', 'mean'].map((stat) => (
-                  <Table.Cell key={stat}>
-                    {info?.[prop]?.statistics?.[stat]?.toFixed(stat === 'count' ? 0 : 3)}
-                  </Table.Cell>
-                ))}
-                <Table.Cell>{UNITS[prop]}</Table.Cell>
-              </Table.Row>
-            ))}
-          </Table.Body>
-        </Table>
+        {info?.road.oneway ? null : (
+          <Menu size="tiny" fluid secondary>
+            <Menu.Item header>Direction</Menu.Item>
+            <Menu.Item name="forwards" active={direction === 'forwards'} onClick={onClickDirection}>
+              {getCardinalDirection(info?.forwards?.bearing)}
+            </Menu.Item>
+            <Menu.Item name="backwards" active={direction === 'backwards'} onClick={onClickDirection}>
+              {getCardinalDirection(info?.backwards?.bearing)}
+            </Menu.Item>
+          </Menu>
+        )}
+
+        {info?.[direction] && <RoadStatsTable data={info[direction]} />}
       </>
     )
 
@@ -197,7 +233,19 @@ function CurrentRoadInfo({clickLocation}) {
             paint={{
               'line-width': ['interpolate', ['linear'], ['zoom'], 14, 6, 17, 12],
               'line-color': '#18FFFF',
-              'line-opacity': 0.8,
+              'line-opacity': 0.5,
+              ...({
+                'line-offset': [
+                  'interpolate',
+                  ['exponential', 1.5],
+                  ['zoom'],
+                  12,
+                  offsetDirection,
+                  19,
+                  offsetDirection * 8,
+                ],
+              })
+
             }}
           />
         </Source>
@@ -214,10 +262,18 @@ function CurrentRoadInfo({clickLocation}) {
 
 export default function MapPage() {
   const {obsMapSource} = useConfig() || {}
-  const [clickLocation, setClickLocation] = React.useState<{longitude: number; latitude: number} | null>(null)
+  const [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | null>(null)
 
-  const onClick = React.useCallback(
+  const onClick = useCallback(
     (e) => {
+      let node = e.target
+      while (node) {
+        if (node?.classList?.contains(styles.mapInfoBox)) {
+          return
+        }
+        node = node.parentNode
+      }
+
       setClickLocation({longitude: e.lngLat[0], latitude: e.lngLat[1]})
     },
     [setClickLocation]