Add filter toggle for user-owned data to map UI

This commit is contained in:
Paul Bienkowski 2022-06-26 12:51:15 +02:00
parent 5beb5ac0d3
commit 7716da8844
6 changed files with 293 additions and 179 deletions

View file

@ -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 (
<ReactMapGl
@ -101,16 +137,21 @@ function Map({
width="100%"
height="100%"
onViewportChange={setViewport}
{...{ transformRequest }}
{...viewport}
{...props}
className={classnames(styles.map, props.className)}
>
<NavigationControl style={{left: 10, top: 10}} />
<ScaleControl maxWidth={200} unit="metric" style={{left: 10, bottom: 10}} />
<NavigationControl style={{ left: 10, top: 10 }} />
<ScaleControl
maxWidth={200}
unit="metric"
style={{ left: 10, bottom: 10 }}
/>
{children}
</ReactMapGl>
)
);
}
export default withBaseMapStyle(Map)
export default withBaseMapStyle(Map);

View file

@ -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<Config> {
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<Config> = loadConfig()
let _configCache: null | Config = null
let _configPromise: Promise<Config> = loadConfig();
let _configCache: null | Config = null;
export function useConfig() {
const [config, setConfig] = React.useState<Config>(_configCache)
const [config, setConfig] = React.useState<Config>(_configCache);
React.useEffect(() => {
if (!_configCache) {
_configPromise.then(setConfig)
_configPromise.then(setConfig);
}
}, [])
return config
}, []);
return config;
}
export default _configPromise
export default _configPromise;

View file

@ -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({
/>
</List.Item>
</>
) : attribute.endsWith('zone') ? (
<>
<List.Item>
<Label size="small" style={{background: "blue",color:"white"}}>{t("general.zone.urban")} (1.5&nbsp;m)</Label>
<Label size="small" style={{background: "cyan", color:"black"}}>{t("general.zone.rural")}(2&nbsp;m)</Label>
</List.Item></>
) :
(
) : attribute.endsWith("zone") ? (
<>
<List.Item>
<List.Header>{_.upperFirst(t("general.zone.urban"))}</List.Header>
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} />
<Label
size="small"
style={{ background: "blue", color: "white" }}
>
{t("general.zone.urban")} (1.5&nbsp;m)
</Label>
<Label
size="small"
style={{ background: "cyan", color: "black" }}
>
{t("general.zone.rural")}(2&nbsp;m)
</Label>
</List.Item>
</>
) : (
<>
<List.Item>
<List.Header>
{_.upperFirst(t("general.zone.urban"))}
</List.Header>
<DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][5].slice(2)}
/>
</List.Item>
<List.Item>
<List.Header>{_.upperFirst(t("general.zone.rural"))}</List.Header>
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} />
<List.Header>
{_.upperFirst(t("general.zone.rural"))}
</List.Header>
<DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
/>
</List.Item>
</>
)}
@ -172,15 +195,38 @@ function LayerSidebar({
{showEvents && (
<>
<List.Item>
<List.Header>{_.upperFirst(t('general.zone.urban'))}</List.Header>
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} />
<List.Header>{_.upperFirst(t("general.zone.urban"))}</List.Header>
<DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][5].slice(2)}
/>
</List.Item>
<List.Item>
<List.Header>{_.upperFirst(t('general.zone.rural'))}</List.Header>
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} />
<List.Header>{_.upperFirst(t("general.zone.rural"))}</List.Header>
<DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
/>
</List.Item>
</>
)}
<Divider />
<List.Item>
<Header as="h4" style={{ marginBottom: 8 }}>
Filter
</Header>
{login && (
<Checkbox
toggle
size="small"
id="filters.currentUser"
checked={filtersCurrentUser}
onChange={() =>
setMapConfigFlag("filters.currentUser", !filtersCurrentUser)
}
label="Show only my own data"
/>
)}
{!login && <div>No filters available without login.</div>}
</List.Item>
</List>
</div>
);
@ -194,6 +240,7 @@ export default connect(
(state as any).mapConfig as MapConfig
//
),
login: state.login,
}),
{ setMapConfigFlag: setMapConfigFlagAction }
//

View file

@ -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 (
<Page fullScreen title="Map">
<div className={styles.mapContainer}>
@ -164,7 +177,7 @@ export default function MapPage() {
<Map viewportFromUrl onClick={onClick}>
<Button
style={{
position: 'absolute',
position: "absolute",
left: 44,
top: 9,
}}
@ -173,16 +186,20 @@ export default function MapPage() {
active={layerSidebar}
onClick={() => setLayerSidebar(layerSidebar ? false : true)}
/>
<Source id="obs" {...obsMapSource}>
<Source id="obs" {...obsMapSource} tiles={tiles}>
{layers.map((layer) => (
<Layer key={layer.id} {...layer} />
))}
</Source>
<RoadInfo {...{clickLocation}} />
<RoadInfo {...{ clickLocation }} />
</Map>
</div>
</div>
</Page>
)
);
}
export default connect(
(state) => ({login: state.login}),
)(MapPage);

View file

@ -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 = {

View file

@ -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)), [])
}