From 7716da8844fe7a6b13a6fb8787643e069f193e4f Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Sun, 26 Jun 2022 12:51:15 +0200 Subject: [PATCH] Add filter toggle for user-owned data to map UI --- frontend/src/components/Map/index.tsx | 133 +++++++++----- frontend/src/config.ts | 61 +++---- frontend/src/pages/MapPage/LayerSidebar.tsx | 79 +++++++-- frontend/src/pages/MapPage/index.tsx | 185 +++++++++++--------- frontend/src/reducers/mapConfig.ts | 6 + frontend/src/utils.js | 8 + 6 files changed, 293 insertions(+), 179 deletions(-) diff --git a/frontend/src/components/Map/index.tsx b/frontend/src/components/Map/index.tsx index 0fc3155..fd4440a 100644 --- a/frontend/src/components/Map/index.tsx +++ b/frontend/src/components/Map/index.tsx @@ -1,54 +1,61 @@ -import React, {useState, useCallback, useMemo, useEffect} from 'react' -import classnames from 'classnames' -import {connect} from 'react-redux' -import _ from 'lodash' -import ReactMapGl, {WebMercatorViewport, ScaleControl, NavigationControl} from 'react-map-gl' -import turfBbox from '@turf/bbox' -import {useHistory, useLocation} from 'react-router-dom' +import React, { useState, useCallback, useMemo, useEffect } from "react"; +import classnames from "classnames"; +import { connect } from "react-redux"; +import _ from "lodash"; +import ReactMapGl, { + WebMercatorViewport, + ScaleControl, + NavigationControl, +} from "react-map-gl"; +import turfBbox from "@turf/bbox"; +import { useHistory, useLocation } from "react-router-dom"; -import {useConfig} from 'config' +import { useConfig } from "config"; -import {baseMapStyles} from '../../mapstyles' +import { useCallbackRef } from "../../utils"; +import { baseMapStyles } from "../../mapstyles"; -import styles from './styles.module.less' +import styles from "./styles.module.less"; interface Viewport { - longitude: number - latitude: number - zoom: number + longitude: number; + latitude: number; + zoom: number; } -const EMPTY_VIEWPORT: Viewport = {longitude: 0, latitude: 0, zoom: 0} +const EMPTY_VIEWPORT: Viewport = { longitude: 0, latitude: 0, zoom: 0 }; -export const withBaseMapStyle = connect((state) => ({baseMapStyle: state.mapConfig?.baseMap?.style ?? 'positron'})) +export const withBaseMapStyle = connect((state) => ({ + baseMapStyle: state.mapConfig?.baseMap?.style ?? "positron", +})); function parseHash(v: string): Viewport | null { - if (!v) return null - const m = v.match(/^#([0-9\.]+)\/([0-9\.]+)\/([0-9\.]+)$/) - if (!m) return null + if (!v) return null; + const m = v.match(/^#([0-9\.]+)\/([0-9\.]+)\/([0-9\.]+)$/); + if (!m) return null; return { zoom: Number.parseFloat(m[1]), latitude: Number.parseFloat(m[2]), longitude: Number.parseFloat(m[3]), - } + }; } function buildHash(v: Viewport): string { - return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}` + return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}`; } function useViewportFromUrl(): [Viewport | null, (v: Viewport) => void] { - const history = useHistory() - const location = useLocation() - const value = useMemo(() => parseHash(location.hash), [location.hash]) + const history = useHistory(); + const location = useLocation(); + const value = useMemo(() => parseHash(location.hash), [location.hash]); const setter = useCallback( (v) => { history.replace({ hash: buildHash(v), - }) + }); }, [history] - ) - return [value || EMPTY_VIEWPORT, setter] + ); + return [value || EMPTY_VIEWPORT, setter]; } function Map({ @@ -58,29 +65,58 @@ function Map({ baseMapStyle, ...props }: { - viewportFromUrl?: boolean - children: React.ReactNode - boundsFromJson: GeoJSON.Geometry - baseMapStyle: string + viewportFromUrl?: boolean; + children: React.ReactNode; + boundsFromJson: GeoJSON.Geometry; + baseMapStyle: string; }) { - const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT) - const [viewportUrl, setViewportUrl] = useViewportFromUrl() + const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT); + const [viewportUrl, setViewportUrl] = useViewportFromUrl(); - const [viewport, setViewport] = viewportFromUrl ? [viewportUrl, setViewportUrl] : [viewportState, setViewportState] + const [viewport, setViewport] = viewportFromUrl + ? [viewportUrl, setViewportUrl] + : [viewportState, setViewportState]; - const config = useConfig() + const config = useConfig(); useEffect(() => { - if (config?.mapHome && viewport?.latitude === 0 && viewport?.longitude === 0 && !boundsFromJson) { - setViewport(config.mapHome) + if ( + config?.mapHome && + viewport?.latitude === 0 && + viewport?.longitude === 0 && + !boundsFromJson + ) { + setViewport(config.mapHome); } - }, [config, boundsFromJson]) + }, [config, boundsFromJson]); + + const mapSourceHosts = useMemo( + () => + _.uniq( + config?.obsMapSource?.tiles?.map( + (tileUrl: string) => new URL(tileUrl).host + ) ?? [] + ), + [config?.obsMapSource] + ); + + const transformRequest = useCallbackRef((url, resourceType) => { + if (resourceType === "Tile" && mapSourceHosts.includes(new URL(url).host)) { + return { + url, + credentials: "include", + }; + } + }); useEffect(() => { if (boundsFromJson) { const bbox = turfBbox(boundsFromJson); - if (bbox.every(v => Math.abs(v) !== Infinity)) { + if (bbox.every((v) => Math.abs(v) !== Infinity)) { const [minX, minY, maxX, maxY] = bbox; - const vp = new WebMercatorViewport({width: 1000, height: 800}).fitBounds( + const vp = new WebMercatorViewport({ + width: 1000, + height: 800, + }).fitBounds( [ [minX, minY], [maxX, maxY], @@ -89,11 +125,11 @@ function Map({ padding: 20, offset: [0, -100], } - ) - setViewport(_.pick(vp, ['zoom', 'latitude', 'longitude'])) + ); + setViewport(_.pick(vp, ["zoom", "latitude", "longitude"])); } } - }, [boundsFromJson]) + }, [boundsFromJson]); return ( - - + + {children} - ) + ); } -export default withBaseMapStyle(Map) +export default withBaseMapStyle(Map); diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 5e657eb..2a439be 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -1,50 +1,45 @@ -import React from 'react' +import React from "react"; -export type MapSoure = - | { - type: 'vector' - url: string - } - | { - type: 'vector' - tiles: string[] - minzoom: number - maxzoom: number - } +export type MapSource = { + type: "vector"; + tiles: string[]; + minzoom: number; + maxzoom: number; +}; export interface Config { - apiUrl: string + apiUrl: string; mapHome: { - latitude: number - longitude: number - zoom: number - } - obsMapSource?: MapSoure - imprintUrl?: string - privacyPolicyUrl?: string + latitude: number; + longitude: number; + zoom: number; + }; + obsMapSource?: MapSource; + imprintUrl?: string; + privacyPolicyUrl?: string; banner?: { - text: string - style?: 'warning' | 'info' - } + text: string; + style?: "warning" | "info"; + }; } async function loadConfig(): Promise { - const response = await fetch(__webpack_public_path__ + 'config.json') - const config = await response.json() - return config + const response = await fetch(__webpack_public_path__ + "config.json"); + const config = await response.json(); + return config; } -let _configPromise: Promise = loadConfig() -let _configCache: null | Config = null +let _configPromise: Promise = loadConfig(); +let _configCache: null | Config = null; export function useConfig() { - const [config, setConfig] = React.useState(_configCache) + const [config, setConfig] = React.useState(_configCache); React.useEffect(() => { if (!_configCache) { - _configPromise.then(setConfig) + _configPromise.then(setConfig); } - }, []) - return config + }, []); + return config; } -export default _configPromise +export default _configPromise; diff --git a/frontend/src/pages/MapPage/LayerSidebar.tsx b/frontend/src/pages/MapPage/LayerSidebar.tsx index caecb65..718bfd7 100644 --- a/frontend/src/pages/MapPage/LayerSidebar.tsx +++ b/frontend/src/pages/MapPage/LayerSidebar.tsx @@ -32,10 +32,14 @@ const ROAD_ATTRIBUTE_OPTIONS = [ "zone", ]; +type User = Object; + function LayerSidebar({ mapConfig, + login, setMapConfigFlag, }: { + login: User | null; mapConfig: MapConfig; setMapConfigFlag: (flag: string, value: unknown) => void; }) { @@ -44,6 +48,7 @@ function LayerSidebar({ baseMap: { style }, obsRoads: { show: showRoads, showUntagged, attribute, maxCount }, obsEvents: { show: showEvents }, + filters: { currentUser: filtersCurrentUser }, } = mapConfig; return ( @@ -134,22 +139,40 @@ function LayerSidebar({ /> - ) : attribute.endsWith('zone') ? ( - <> - - - - - ) : - ( + ) : attribute.endsWith("zone") ? ( <> - {_.upperFirst(t("general.zone.urban"))} - + + + + + ) : ( + <> + + + {_.upperFirst(t("general.zone.urban"))} + + - {_.upperFirst(t("general.zone.rural"))} - + + {_.upperFirst(t("general.zone.rural"))} + + )} @@ -172,15 +195,38 @@ function LayerSidebar({ {showEvents && ( <> - {_.upperFirst(t('general.zone.urban'))} - + {_.upperFirst(t("general.zone.urban"))} + - {_.upperFirst(t('general.zone.rural'))} - + {_.upperFirst(t("general.zone.rural"))} + )} + + +
+ Filter +
+ {login && ( + + setMapConfigFlag("filters.currentUser", !filtersCurrentUser) + } + label="Show only my own data" + /> + )} + {!login &&
No filters available without login.
} +
); @@ -194,6 +240,7 @@ export default connect( (state as any).mapConfig as MapConfig // ), + login: state.login, }), { setMapConfigFlag: setMapConfigFlagAction } // diff --git a/frontend/src/pages/MapPage/index.tsx b/frontend/src/pages/MapPage/index.tsx index a3196dd..7298a46 100644 --- a/frontend/src/pages/MapPage/index.tsx +++ b/frontend/src/pages/MapPage/index.tsx @@ -1,44 +1,45 @@ -import React, {useState, useCallback, useMemo} from 'react' -import _ from 'lodash' -import {Button} from 'semantic-ui-react' -import {Layer, Source} from 'react-map-gl' -import produce from 'immer' +import React, { useState, useCallback, useMemo } from "react"; +import _ from "lodash"; +import { connect } from "react-redux"; +import { Button } from "semantic-ui-react"; +import { Layer, Source } from "react-map-gl"; +import produce from "immer"; import {Page, Map} from 'components' import {useConfig} from 'config' import {colorByDistance, colorByCount, borderByZone, reds, isValidAttribute} from 'mapstyles' import {useMapConfig} from 'reducers/mapConfig' -import RoadInfo from './RoadInfo' -import LayerSidebar from './LayerSidebar' -import styles from './styles.module.less' +import RoadInfo from "./RoadInfo"; +import LayerSidebar from "./LayerSidebar"; +import styles from "./styles.module.less"; const untaggedRoadsLayer = { - id: 'obs_roads_untagged', - type: 'line', - source: 'obs', - 'source-layer': 'obs_roads', - filter: ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]], + id: "obs_roads_untagged", + type: "line", + source: "obs", + "source-layer": "obs_roads", + filter: ["!", ["to-boolean", ["get", "distance_overtaker_mean"]]], layout: { - 'line-cap': 'round', - 'line-join': 'round', + "line-cap": "round", + "line-join": "round", }, paint: { - 'line-width': ['interpolate', ['exponential', 1.5], ['zoom'], 12, 2, 17, 2], - 'line-color': '#ABC', - 'line-opacity': ['interpolate', ['linear'], ['zoom'], 14, 0, 15, 1], - 'line-offset': [ - 'interpolate', - ['exponential', 1.5], - ['zoom'], + "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 2, 17, 2], + "line-color": "#ABC", + "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1], + "line-offset": [ + "interpolate", + ["exponential", 1.5], + ["zoom"], 12, - ['get', 'offset_direction'], + ["get", "offset_direction"], 19, - ['*', ['get', 'offset_direction'], 8], + ["*", ["get", "offset_direction"], 8], ], }, minzoom: 12, -} +}; const getUntaggedRoadsLayer = (colorAttribute, maxCount) => produce(untaggedRoadsLayer, (draft) => { @@ -48,110 +49,122 @@ const getUntaggedRoadsLayer = (colorAttribute, maxCount) => const getRoadsLayer = (colorAttribute, maxCount) => produce(untaggedRoadsLayer, (draft) => { - draft.id = 'obs_roads_normal' + draft.id = "obs_roads_normal"; draft.filter = isValidAttribute(colorAttribute) - draft.paint['line-width'][6] = 6 // scale bigger on zoom - draft.paint['line-color'] = colorAttribute.startsWith('distance_') + draft.paint["line-width"][6] = 6; // scale bigger on zoom + draft.paint["line-color"] = colorAttribute.startsWith("distance_") ? colorByDistance(colorAttribute) - : colorAttribute.endsWith('_count') + : colorAttribute.endsWith("_count") ? colorByCount(colorAttribute, maxCount) : colorAttribute.endsWith('zone') ? borderByZone() - : '#DDD' - draft.paint['line-opacity'][3] = 12 - draft.paint['line-opacity'][5] = 13 - }) + : "#DDD"; + draft.paint["line-opacity"][3] = 12; + draft.paint["line-opacity"][5] = 13; + }); const getEventsLayer = () => ({ - id: 'obs_events', - type: 'circle', - source: 'obs', - 'source-layer': 'obs_events', + id: "obs_events", + type: "circle", + source: "obs", + "source-layer": "obs_events", paint: { - 'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 3, 17, 8], - 'circle-color': colorByDistance('distance_overtaker'), + "circle-radius": ["interpolate", ["linear"], ["zoom"], 14, 3, 17, 8], + "circle-color": colorByDistance("distance_overtaker"), }, minzoom: 11, -}) +}); const getEventsTextLayer = () => ({ - id: 'obs_events_text', - type: 'symbol', + id: "obs_events_text", + type: "symbol", minzoom: 18, - source: 'obs', - 'source-layer': 'obs_events', + source: "obs", + "source-layer": "obs_events", layout: { - 'text-field': [ - 'number-format', - ['get', 'distance_overtaker'], - {'min-fraction-digits': 2, 'max-fraction-digits': 2}, + "text-field": [ + "number-format", + ["get", "distance_overtaker"], + { "min-fraction-digits": 2, "max-fraction-digits": 2 }, ], - 'text-allow-overlap': true, - 'text-font': ['Open Sans Bold', 'Arial Unicode MS Regular'], - 'text-size': 14, - 'text-keep-upright': false, - 'text-anchor': 'left', - 'text-radial-offset': 1, - 'text-rotate': ['-', 90, ['*', ['get', 'course'], 180 / Math.PI]], - 'text-rotation-alignment': 'map', + "text-allow-overlap": true, + "text-font": ["Open Sans Bold", "Arial Unicode MS Regular"], + "text-size": 14, + "text-keep-upright": false, + "text-anchor": "left", + "text-radial-offset": 1, + "text-rotate": ["-", 90, ["*", ["get", "course"], 180 / Math.PI]], + "text-rotation-alignment": "map", }, paint: { - 'text-halo-color': 'rgba(255, 255, 255, 1)', - 'text-halo-width': 1, - 'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1], + "text-halo-color": "rgba(255, 255, 255, 1)", + "text-halo-width": 1, + "text-opacity": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.3, 1], }, -}) +}); -export default function MapPage() { - const {obsMapSource} = useConfig() || {} - const [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | null>(null) +function MapPage({ login }) { + const { obsMapSource } = useConfig() || {}; + const [clickLocation, setClickLocation] = useState<{ + longitude: number; + latitude: number; + } | null>(null); - const mapConfig = useMapConfig() + const mapConfig = useMapConfig(); const onClick = useCallback( (e) => { - let node = e.target + let node = e.target; while (node) { if (node?.classList?.contains(styles.mapInfoBox)) { - return + return; } - node = node.parentNode + node = node.parentNode; } - setClickLocation({longitude: e.lngLat[0], latitude: e.lngLat[1]}) + setClickLocation({ longitude: e.lngLat[0], latitude: e.lngLat[1] }); }, [setClickLocation] - ) + ); - const [layerSidebar, setLayerSidebar] = useState(true) + const [layerSidebar, setLayerSidebar] = useState(true); const { - obsRoads: {attribute, maxCount}, - } = mapConfig + obsRoads: { attribute, maxCount }, + } = mapConfig; - const layers = [] + const layers = []; const untaggedRoadsLayerCustom = useMemo(() => getUntaggedRoadsLayer(attribute), [attribute]) if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) { layers.push(untaggedRoadsLayerCustom) } - const roadsLayer = useMemo(() => getRoadsLayer(attribute, maxCount), [attribute, maxCount]) + const roadsLayer = useMemo( + () => getRoadsLayer(attribute, maxCount), + [attribute, maxCount] + ); if (mapConfig.obsRoads.show) { - layers.push(roadsLayer) + layers.push(roadsLayer); } - const eventsLayer = useMemo(() => getEventsLayer(), []) - const eventsTextLayer = useMemo(() => getEventsTextLayer(), []) + const eventsLayer = useMemo(() => getEventsLayer(), []); + const eventsTextLayer = useMemo(() => getEventsTextLayer(), []); if (mapConfig.obsEvents.show) { - layers.push(eventsLayer) - layers.push(eventsTextLayer) + layers.push(eventsLayer); + layers.push(eventsTextLayer); } if (!obsMapSource) { - return null + return null; } + const tiles = obsMapSource?.tiles?.map( + (tileUrl: string) => + tileUrl + + (login && mapConfig.filters.currentUser ? `?user=${login.username}` : "") + ); + return (
@@ -164,7 +177,7 @@ export default function MapPage() {
- ) + ); } + +export default connect( + (state) => ({login: state.login}), +)(MapPage); diff --git a/frontend/src/reducers/mapConfig.ts b/frontend/src/reducers/mapConfig.ts index e7c14cf..4d8d8dc 100644 --- a/frontend/src/reducers/mapConfig.ts +++ b/frontend/src/reducers/mapConfig.ts @@ -27,6 +27,9 @@ export type MapConfig = { obsEvents: { show: boolean; }; + filters: { + currentUser: boolean; + }; }; export const initialState: MapConfig = { @@ -42,6 +45,9 @@ export const initialState: MapConfig = { obsEvents: { show: false, }, + filters: { + currentUser: false, + }, }; type MapConfigAction = { diff --git a/frontend/src/utils.js b/frontend/src/utils.js index 25d2977..2fdb1a7 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -1,3 +1,5 @@ +import {useRef, useCallback} from 'react' + // Wraps the register callback from useForm into a new ref function, such that // any child of the provided element that is an input component will be // registered. @@ -22,3 +24,9 @@ export function* pairwise(it) { lastValue = i } } + +export function useCallbackRef(fn) { + const fnRef = useRef() + fnRef.current = fn + return useCallback(((...args) => fnRef.current(...args)), []) +}