Add filter toggle for user-owned data to map UI
This commit is contained in:
parent
5beb5ac0d3
commit
7716da8844
6 changed files with 293 additions and 179 deletions
|
@ -1,54 +1,61 @@
|
||||||
import React, {useState, useCallback, useMemo, useEffect} from 'react'
|
import React, { useState, useCallback, useMemo, useEffect } from "react";
|
||||||
import classnames from 'classnames'
|
import classnames from "classnames";
|
||||||
import {connect} from 'react-redux'
|
import { connect } from "react-redux";
|
||||||
import _ from 'lodash'
|
import _ from "lodash";
|
||||||
import ReactMapGl, {WebMercatorViewport, ScaleControl, NavigationControl} from 'react-map-gl'
|
import ReactMapGl, {
|
||||||
import turfBbox from '@turf/bbox'
|
WebMercatorViewport,
|
||||||
import {useHistory, useLocation} from 'react-router-dom'
|
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 {
|
interface Viewport {
|
||||||
longitude: number
|
longitude: number;
|
||||||
latitude: number
|
latitude: number;
|
||||||
zoom: 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 {
|
function parseHash(v: string): Viewport | null {
|
||||||
if (!v) return null
|
if (!v) return null;
|
||||||
const m = v.match(/^#([0-9\.]+)\/([0-9\.]+)\/([0-9\.]+)$/)
|
const m = v.match(/^#([0-9\.]+)\/([0-9\.]+)\/([0-9\.]+)$/);
|
||||||
if (!m) return null
|
if (!m) return null;
|
||||||
return {
|
return {
|
||||||
zoom: Number.parseFloat(m[1]),
|
zoom: Number.parseFloat(m[1]),
|
||||||
latitude: Number.parseFloat(m[2]),
|
latitude: Number.parseFloat(m[2]),
|
||||||
longitude: Number.parseFloat(m[3]),
|
longitude: Number.parseFloat(m[3]),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHash(v: Viewport): string {
|
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] {
|
function useViewportFromUrl(): [Viewport | null, (v: Viewport) => void] {
|
||||||
const history = useHistory()
|
const history = useHistory();
|
||||||
const location = useLocation()
|
const location = useLocation();
|
||||||
const value = useMemo(() => parseHash(location.hash), [location.hash])
|
const value = useMemo(() => parseHash(location.hash), [location.hash]);
|
||||||
const setter = useCallback(
|
const setter = useCallback(
|
||||||
(v) => {
|
(v) => {
|
||||||
history.replace({
|
history.replace({
|
||||||
hash: buildHash(v),
|
hash: buildHash(v),
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
[history]
|
[history]
|
||||||
)
|
);
|
||||||
return [value || EMPTY_VIEWPORT, setter]
|
return [value || EMPTY_VIEWPORT, setter];
|
||||||
}
|
}
|
||||||
|
|
||||||
function Map({
|
function Map({
|
||||||
|
@ -58,29 +65,58 @@ function Map({
|
||||||
baseMapStyle,
|
baseMapStyle,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
viewportFromUrl?: boolean
|
viewportFromUrl?: boolean;
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
boundsFromJson: GeoJSON.Geometry
|
boundsFromJson: GeoJSON.Geometry;
|
||||||
baseMapStyle: string
|
baseMapStyle: string;
|
||||||
}) {
|
}) {
|
||||||
const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT)
|
const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT);
|
||||||
const [viewportUrl, setViewportUrl] = useViewportFromUrl()
|
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(() => {
|
useEffect(() => {
|
||||||
if (config?.mapHome && viewport?.latitude === 0 && viewport?.longitude === 0 && !boundsFromJson) {
|
if (
|
||||||
setViewport(config.mapHome)
|
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(() => {
|
useEffect(() => {
|
||||||
if (boundsFromJson) {
|
if (boundsFromJson) {
|
||||||
const bbox = turfBbox(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 [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],
|
[minX, minY],
|
||||||
[maxX, maxY],
|
[maxX, maxY],
|
||||||
|
@ -89,11 +125,11 @@ function Map({
|
||||||
padding: 20,
|
padding: 20,
|
||||||
offset: [0, -100],
|
offset: [0, -100],
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
setViewport(_.pick(vp, ['zoom', 'latitude', 'longitude']))
|
setViewport(_.pick(vp, ["zoom", "latitude", "longitude"]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [boundsFromJson])
|
}, [boundsFromJson]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactMapGl
|
<ReactMapGl
|
||||||
|
@ -101,16 +137,21 @@ function Map({
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
onViewportChange={setViewport}
|
onViewportChange={setViewport}
|
||||||
|
{...{ transformRequest }}
|
||||||
{...viewport}
|
{...viewport}
|
||||||
{...props}
|
{...props}
|
||||||
className={classnames(styles.map, props.className)}
|
className={classnames(styles.map, props.className)}
|
||||||
>
|
>
|
||||||
<NavigationControl style={{left: 10, top: 10}} />
|
<NavigationControl style={{ left: 10, top: 10 }} />
|
||||||
<ScaleControl maxWidth={200} unit="metric" style={{left: 10, bottom: 10}} />
|
<ScaleControl
|
||||||
|
maxWidth={200}
|
||||||
|
unit="metric"
|
||||||
|
style={{ left: 10, bottom: 10 }}
|
||||||
|
/>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</ReactMapGl>
|
</ReactMapGl>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withBaseMapStyle(Map)
|
export default withBaseMapStyle(Map);
|
||||||
|
|
|
@ -1,50 +1,45 @@
|
||||||
import React from 'react'
|
import React from "react";
|
||||||
|
|
||||||
export type MapSoure =
|
export type MapSource = {
|
||||||
| {
|
type: "vector";
|
||||||
type: 'vector'
|
tiles: string[];
|
||||||
url: string
|
minzoom: number;
|
||||||
}
|
maxzoom: number;
|
||||||
| {
|
};
|
||||||
type: 'vector'
|
|
||||||
tiles: string[]
|
|
||||||
minzoom: number
|
|
||||||
maxzoom: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
apiUrl: string
|
apiUrl: string;
|
||||||
mapHome: {
|
mapHome: {
|
||||||
latitude: number
|
latitude: number;
|
||||||
longitude: number
|
longitude: number;
|
||||||
zoom: number
|
zoom: number;
|
||||||
}
|
};
|
||||||
obsMapSource?: MapSoure
|
obsMapSource?: MapSource;
|
||||||
imprintUrl?: string
|
imprintUrl?: string;
|
||||||
privacyPolicyUrl?: string
|
privacyPolicyUrl?: string;
|
||||||
banner?: {
|
banner?: {
|
||||||
text: string
|
text: string;
|
||||||
style?: 'warning' | 'info'
|
style?: "warning" | "info";
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadConfig(): Promise<Config> {
|
async function loadConfig(): Promise<Config> {
|
||||||
const response = await fetch(__webpack_public_path__ + 'config.json')
|
const response = await fetch(__webpack_public_path__ + "config.json");
|
||||||
const config = await response.json()
|
const config = await response.json();
|
||||||
return config
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _configPromise: Promise<Config> = loadConfig()
|
let _configPromise: Promise<Config> = loadConfig();
|
||||||
let _configCache: null | Config = null
|
let _configCache: null | Config = null;
|
||||||
|
|
||||||
export function useConfig() {
|
export function useConfig() {
|
||||||
const [config, setConfig] = React.useState<Config>(_configCache)
|
const [config, setConfig] = React.useState<Config>(_configCache);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!_configCache) {
|
if (!_configCache) {
|
||||||
_configPromise.then(setConfig)
|
_configPromise.then(setConfig);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
return config
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default _configPromise
|
export default _configPromise;
|
||||||
|
|
|
@ -32,10 +32,14 @@ const ROAD_ATTRIBUTE_OPTIONS = [
|
||||||
"zone",
|
"zone",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type User = Object;
|
||||||
|
|
||||||
function LayerSidebar({
|
function LayerSidebar({
|
||||||
mapConfig,
|
mapConfig,
|
||||||
|
login,
|
||||||
setMapConfigFlag,
|
setMapConfigFlag,
|
||||||
}: {
|
}: {
|
||||||
|
login: User | null;
|
||||||
mapConfig: MapConfig;
|
mapConfig: MapConfig;
|
||||||
setMapConfigFlag: (flag: string, value: unknown) => void;
|
setMapConfigFlag: (flag: string, value: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
|
@ -44,6 +48,7 @@ function LayerSidebar({
|
||||||
baseMap: { style },
|
baseMap: { style },
|
||||||
obsRoads: { show: showRoads, showUntagged, attribute, maxCount },
|
obsRoads: { show: showRoads, showUntagged, attribute, maxCount },
|
||||||
obsEvents: { show: showEvents },
|
obsEvents: { show: showEvents },
|
||||||
|
filters: { currentUser: filtersCurrentUser },
|
||||||
} = mapConfig;
|
} = mapConfig;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -134,22 +139,40 @@ function LayerSidebar({
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</>
|
</>
|
||||||
) : attribute.endsWith('zone') ? (
|
) : attribute.endsWith("zone") ? (
|
||||||
<>
|
|
||||||
<List.Item>
|
|
||||||
<Label size="small" style={{background: "blue",color:"white"}}>{t("general.zone.urban")} (1.5 m)</Label>
|
|
||||||
<Label size="small" style={{background: "cyan", color:"black"}}>{t("general.zone.rural")}(2 m)</Label>
|
|
||||||
</List.Item></>
|
|
||||||
) :
|
|
||||||
(
|
|
||||||
<>
|
<>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>{_.upperFirst(t("general.zone.urban"))}</List.Header>
|
<Label
|
||||||
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} />
|
size="small"
|
||||||
|
style={{ background: "blue", color: "white" }}
|
||||||
|
>
|
||||||
|
{t("general.zone.urban")} (1.5 m)
|
||||||
|
</Label>
|
||||||
|
<Label
|
||||||
|
size="small"
|
||||||
|
style={{ background: "cyan", color: "black" }}
|
||||||
|
>
|
||||||
|
{t("general.zone.rural")}(2 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.Item>
|
<List.Item>
|
||||||
<List.Header>{_.upperFirst(t("general.zone.rural"))}</List.Header>
|
<List.Header>
|
||||||
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} />
|
{_.upperFirst(t("general.zone.rural"))}
|
||||||
|
</List.Header>
|
||||||
|
<DiscreteColorMapLegend
|
||||||
|
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
|
||||||
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -172,15 +195,38 @@ function LayerSidebar({
|
||||||
{showEvents && (
|
{showEvents && (
|
||||||
<>
|
<>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>{_.upperFirst(t('general.zone.urban'))}</List.Header>
|
<List.Header>{_.upperFirst(t("general.zone.urban"))}</List.Header>
|
||||||
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} />
|
<DiscreteColorMapLegend
|
||||||
|
map={colorByDistance("distance_overtaker")[3][5].slice(2)}
|
||||||
|
/>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<List.Header>{_.upperFirst(t('general.zone.rural'))}</List.Header>
|
<List.Header>{_.upperFirst(t("general.zone.rural"))}</List.Header>
|
||||||
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} />
|
<DiscreteColorMapLegend
|
||||||
|
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
|
||||||
|
/>
|
||||||
</List.Item>
|
</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>
|
</List>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -194,6 +240,7 @@ export default connect(
|
||||||
(state as any).mapConfig as MapConfig
|
(state as any).mapConfig as MapConfig
|
||||||
//
|
//
|
||||||
),
|
),
|
||||||
|
login: state.login,
|
||||||
}),
|
}),
|
||||||
{ setMapConfigFlag: setMapConfigFlagAction }
|
{ setMapConfigFlag: setMapConfigFlagAction }
|
||||||
//
|
//
|
||||||
|
|
|
@ -1,44 +1,45 @@
|
||||||
import React, {useState, useCallback, useMemo} from 'react'
|
import React, { useState, useCallback, useMemo } from "react";
|
||||||
import _ from 'lodash'
|
import _ from "lodash";
|
||||||
import {Button} from 'semantic-ui-react'
|
import { connect } from "react-redux";
|
||||||
import {Layer, Source} from 'react-map-gl'
|
import { Button } from "semantic-ui-react";
|
||||||
import produce from 'immer'
|
import { Layer, Source } from "react-map-gl";
|
||||||
|
import produce from "immer";
|
||||||
|
|
||||||
import {Page, Map} from 'components'
|
import {Page, Map} from 'components'
|
||||||
import {useConfig} from 'config'
|
import {useConfig} from 'config'
|
||||||
import {colorByDistance, colorByCount, borderByZone, reds, isValidAttribute} from 'mapstyles'
|
import {colorByDistance, colorByCount, borderByZone, reds, isValidAttribute} from 'mapstyles'
|
||||||
import {useMapConfig} from 'reducers/mapConfig'
|
import {useMapConfig} from 'reducers/mapConfig'
|
||||||
|
|
||||||
import RoadInfo from './RoadInfo'
|
import RoadInfo from "./RoadInfo";
|
||||||
import LayerSidebar from './LayerSidebar'
|
import LayerSidebar from "./LayerSidebar";
|
||||||
import styles from './styles.module.less'
|
import styles from "./styles.module.less";
|
||||||
|
|
||||||
const untaggedRoadsLayer = {
|
const untaggedRoadsLayer = {
|
||||||
id: 'obs_roads_untagged',
|
id: "obs_roads_untagged",
|
||||||
type: 'line',
|
type: "line",
|
||||||
source: 'obs',
|
source: "obs",
|
||||||
'source-layer': 'obs_roads',
|
"source-layer": "obs_roads",
|
||||||
filter: ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]],
|
filter: ["!", ["to-boolean", ["get", "distance_overtaker_mean"]]],
|
||||||
layout: {
|
layout: {
|
||||||
'line-cap': 'round',
|
"line-cap": "round",
|
||||||
'line-join': 'round',
|
"line-join": "round",
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
'line-width': ['interpolate', ['exponential', 1.5], ['zoom'], 12, 2, 17, 2],
|
"line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 2, 17, 2],
|
||||||
'line-color': '#ABC',
|
"line-color": "#ABC",
|
||||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 14, 0, 15, 1],
|
"line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1],
|
||||||
'line-offset': [
|
"line-offset": [
|
||||||
'interpolate',
|
"interpolate",
|
||||||
['exponential', 1.5],
|
["exponential", 1.5],
|
||||||
['zoom'],
|
["zoom"],
|
||||||
12,
|
12,
|
||||||
['get', 'offset_direction'],
|
["get", "offset_direction"],
|
||||||
19,
|
19,
|
||||||
['*', ['get', 'offset_direction'], 8],
|
["*", ["get", "offset_direction"], 8],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
minzoom: 12,
|
minzoom: 12,
|
||||||
}
|
};
|
||||||
|
|
||||||
const getUntaggedRoadsLayer = (colorAttribute, maxCount) =>
|
const getUntaggedRoadsLayer = (colorAttribute, maxCount) =>
|
||||||
produce(untaggedRoadsLayer, (draft) => {
|
produce(untaggedRoadsLayer, (draft) => {
|
||||||
|
@ -48,110 +49,122 @@ const getUntaggedRoadsLayer = (colorAttribute, maxCount) =>
|
||||||
|
|
||||||
const getRoadsLayer = (colorAttribute, maxCount) =>
|
const getRoadsLayer = (colorAttribute, maxCount) =>
|
||||||
produce(untaggedRoadsLayer, (draft) => {
|
produce(untaggedRoadsLayer, (draft) => {
|
||||||
draft.id = 'obs_roads_normal'
|
draft.id = "obs_roads_normal";
|
||||||
draft.filter = isValidAttribute(colorAttribute)
|
draft.filter = isValidAttribute(colorAttribute)
|
||||||
draft.paint['line-width'][6] = 6 // scale bigger on zoom
|
draft.paint["line-width"][6] = 6; // scale bigger on zoom
|
||||||
draft.paint['line-color'] = colorAttribute.startsWith('distance_')
|
draft.paint["line-color"] = colorAttribute.startsWith("distance_")
|
||||||
? colorByDistance(colorAttribute)
|
? colorByDistance(colorAttribute)
|
||||||
: colorAttribute.endsWith('_count')
|
: colorAttribute.endsWith("_count")
|
||||||
? colorByCount(colorAttribute, maxCount)
|
? colorByCount(colorAttribute, maxCount)
|
||||||
: colorAttribute.endsWith('zone')
|
: colorAttribute.endsWith('zone')
|
||||||
? borderByZone()
|
? borderByZone()
|
||||||
: '#DDD'
|
: "#DDD";
|
||||||
draft.paint['line-opacity'][3] = 12
|
draft.paint["line-opacity"][3] = 12;
|
||||||
draft.paint['line-opacity'][5] = 13
|
draft.paint["line-opacity"][5] = 13;
|
||||||
})
|
});
|
||||||
|
|
||||||
const getEventsLayer = () => ({
|
const getEventsLayer = () => ({
|
||||||
id: 'obs_events',
|
id: "obs_events",
|
||||||
type: 'circle',
|
type: "circle",
|
||||||
source: 'obs',
|
source: "obs",
|
||||||
'source-layer': 'obs_events',
|
"source-layer": "obs_events",
|
||||||
paint: {
|
paint: {
|
||||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 3, 17, 8],
|
"circle-radius": ["interpolate", ["linear"], ["zoom"], 14, 3, 17, 8],
|
||||||
'circle-color': colorByDistance('distance_overtaker'),
|
"circle-color": colorByDistance("distance_overtaker"),
|
||||||
},
|
},
|
||||||
minzoom: 11,
|
minzoom: 11,
|
||||||
})
|
});
|
||||||
|
|
||||||
const getEventsTextLayer = () => ({
|
const getEventsTextLayer = () => ({
|
||||||
id: 'obs_events_text',
|
id: "obs_events_text",
|
||||||
type: 'symbol',
|
type: "symbol",
|
||||||
minzoom: 18,
|
minzoom: 18,
|
||||||
source: 'obs',
|
source: "obs",
|
||||||
'source-layer': 'obs_events',
|
"source-layer": "obs_events",
|
||||||
layout: {
|
layout: {
|
||||||
'text-field': [
|
"text-field": [
|
||||||
'number-format',
|
"number-format",
|
||||||
['get', 'distance_overtaker'],
|
["get", "distance_overtaker"],
|
||||||
{'min-fraction-digits': 2, 'max-fraction-digits': 2},
|
{ "min-fraction-digits": 2, "max-fraction-digits": 2 },
|
||||||
],
|
],
|
||||||
'text-allow-overlap': true,
|
"text-allow-overlap": true,
|
||||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Regular'],
|
"text-font": ["Open Sans Bold", "Arial Unicode MS Regular"],
|
||||||
'text-size': 14,
|
"text-size": 14,
|
||||||
'text-keep-upright': false,
|
"text-keep-upright": false,
|
||||||
'text-anchor': 'left',
|
"text-anchor": "left",
|
||||||
'text-radial-offset': 1,
|
"text-radial-offset": 1,
|
||||||
'text-rotate': ['-', 90, ['*', ['get', 'course'], 180 / Math.PI]],
|
"text-rotate": ["-", 90, ["*", ["get", "course"], 180 / Math.PI]],
|
||||||
'text-rotation-alignment': 'map',
|
"text-rotation-alignment": "map",
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
'text-halo-color': 'rgba(255, 255, 255, 1)',
|
"text-halo-color": "rgba(255, 255, 255, 1)",
|
||||||
'text-halo-width': 1,
|
"text-halo-width": 1,
|
||||||
'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1],
|
"text-opacity": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.3, 1],
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
export default function MapPage() {
|
function MapPage({ login }) {
|
||||||
const {obsMapSource} = useConfig() || {}
|
const { obsMapSource } = useConfig() || {};
|
||||||
const [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | null>(null)
|
const [clickLocation, setClickLocation] = useState<{
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const mapConfig = useMapConfig()
|
const mapConfig = useMapConfig();
|
||||||
|
|
||||||
const onClick = useCallback(
|
const onClick = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
let node = e.target
|
let node = e.target;
|
||||||
while (node) {
|
while (node) {
|
||||||
if (node?.classList?.contains(styles.mapInfoBox)) {
|
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]
|
[setClickLocation]
|
||||||
)
|
);
|
||||||
|
|
||||||
const [layerSidebar, setLayerSidebar] = useState(true)
|
const [layerSidebar, setLayerSidebar] = useState(true);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
obsRoads: {attribute, maxCount},
|
obsRoads: { attribute, maxCount },
|
||||||
} = mapConfig
|
} = mapConfig;
|
||||||
|
|
||||||
const layers = []
|
const layers = [];
|
||||||
|
|
||||||
const untaggedRoadsLayerCustom = useMemo(() => getUntaggedRoadsLayer(attribute), [attribute])
|
const untaggedRoadsLayerCustom = useMemo(() => getUntaggedRoadsLayer(attribute), [attribute])
|
||||||
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
|
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
|
||||||
layers.push(untaggedRoadsLayerCustom)
|
layers.push(untaggedRoadsLayerCustom)
|
||||||
}
|
}
|
||||||
|
|
||||||
const roadsLayer = useMemo(() => getRoadsLayer(attribute, maxCount), [attribute, maxCount])
|
const roadsLayer = useMemo(
|
||||||
|
() => getRoadsLayer(attribute, maxCount),
|
||||||
|
[attribute, maxCount]
|
||||||
|
);
|
||||||
if (mapConfig.obsRoads.show) {
|
if (mapConfig.obsRoads.show) {
|
||||||
layers.push(roadsLayer)
|
layers.push(roadsLayer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventsLayer = useMemo(() => getEventsLayer(), [])
|
const eventsLayer = useMemo(() => getEventsLayer(), []);
|
||||||
const eventsTextLayer = useMemo(() => getEventsTextLayer(), [])
|
const eventsTextLayer = useMemo(() => getEventsTextLayer(), []);
|
||||||
if (mapConfig.obsEvents.show) {
|
if (mapConfig.obsEvents.show) {
|
||||||
layers.push(eventsLayer)
|
layers.push(eventsLayer);
|
||||||
layers.push(eventsTextLayer)
|
layers.push(eventsTextLayer);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!obsMapSource) {
|
if (!obsMapSource) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tiles = obsMapSource?.tiles?.map(
|
||||||
|
(tileUrl: string) =>
|
||||||
|
tileUrl +
|
||||||
|
(login && mapConfig.filters.currentUser ? `?user=${login.username}` : "")
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page fullScreen title="Map">
|
<Page fullScreen title="Map">
|
||||||
<div className={styles.mapContainer}>
|
<div className={styles.mapContainer}>
|
||||||
|
@ -164,7 +177,7 @@ export default function MapPage() {
|
||||||
<Map viewportFromUrl onClick={onClick}>
|
<Map viewportFromUrl onClick={onClick}>
|
||||||
<Button
|
<Button
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
left: 44,
|
left: 44,
|
||||||
top: 9,
|
top: 9,
|
||||||
}}
|
}}
|
||||||
|
@ -173,16 +186,20 @@ export default function MapPage() {
|
||||||
active={layerSidebar}
|
active={layerSidebar}
|
||||||
onClick={() => setLayerSidebar(layerSidebar ? false : true)}
|
onClick={() => setLayerSidebar(layerSidebar ? false : true)}
|
||||||
/>
|
/>
|
||||||
<Source id="obs" {...obsMapSource}>
|
<Source id="obs" {...obsMapSource} tiles={tiles}>
|
||||||
{layers.map((layer) => (
|
{layers.map((layer) => (
|
||||||
<Layer key={layer.id} {...layer} />
|
<Layer key={layer.id} {...layer} />
|
||||||
))}
|
))}
|
||||||
</Source>
|
</Source>
|
||||||
|
|
||||||
<RoadInfo {...{clickLocation}} />
|
<RoadInfo {...{ clickLocation }} />
|
||||||
</Map>
|
</Map>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default connect(
|
||||||
|
(state) => ({login: state.login}),
|
||||||
|
)(MapPage);
|
||||||
|
|
|
@ -27,6 +27,9 @@ export type MapConfig = {
|
||||||
obsEvents: {
|
obsEvents: {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
};
|
};
|
||||||
|
filters: {
|
||||||
|
currentUser: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initialState: MapConfig = {
|
export const initialState: MapConfig = {
|
||||||
|
@ -42,6 +45,9 @@ export const initialState: MapConfig = {
|
||||||
obsEvents: {
|
obsEvents: {
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
|
filters: {
|
||||||
|
currentUser: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
type MapConfigAction = {
|
type MapConfigAction = {
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import {useRef, useCallback} from 'react'
|
||||||
|
|
||||||
// Wraps the register callback from useForm into a new ref function, such that
|
// 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
|
// any child of the provided element that is an input component will be
|
||||||
// registered.
|
// registered.
|
||||||
|
@ -22,3 +24,9 @@ export function* pairwise(it) {
|
||||||
lastValue = i
|
lastValue = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCallbackRef(fn) {
|
||||||
|
const fnRef = useRef()
|
||||||
|
fnRef.current = fn
|
||||||
|
return useCallback(((...args) => fnRef.current(...args)), [])
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue