Add date-range filters to map

This commit is contained in:
Paul Bienkowski 2022-07-22 13:29:51 +02:00 committed by gluap
parent c4b9cbb607
commit 115e871ee9
No known key found for this signature in database
4 changed files with 126 additions and 74 deletions

View file

@ -1,10 +1,6 @@
from gzip import decompress from gzip import decompress
from sqlite3 import connect from sqlite3 import connect
from datetime import datetime, time, timedelta from sanic.exceptions import Forbidden
from typing import Optional, Tuple
import dateutil.parser
from sanic.exceptions import Forbidden, InvalidUsage
from sanic.response import raw from sanic.response import raw
from sqlalchemy import select, text from sqlalchemy import select, text
@ -93,6 +89,10 @@ async def tiles(req, zoom: int, x: int, y: str):
else: else:
user_id, start, end = get_filter_options(req) user_id, start, end = get_filter_options(req)
parse_date = lambda s: dateutil.parser.parse(s)
start = req.ctx.get_single_arg("start", default=None, convert=parse_date)
end = req.ctx.get_single_arg("end", default=None, convert=parse_date)
tile = await req.ctx.db.scalar( tile = await req.ctx.db.scalar(
text( text(
f"select data from getmvt(:zoom, :x, :y, :user_id, :min_time, :max_time) as b(data, key);" f"select data from getmvt(:zoom, :x, :y, :user_id, :min_time, :max_time) as b(data, key);"

View file

@ -11,7 +11,6 @@ import {
Header, Header,
} from "semantic-ui-react"; } from "semantic-ui-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import { import {
@ -33,6 +32,22 @@ const ROAD_ATTRIBUTE_OPTIONS = [
{value: 'usage_count', key: 'usage_count', text: 'Usage count'}, {value: 'usage_count', key: 'usage_count', text: 'Usage count'},
{value: 'zone', key: 'zone', text: 'Overtaking distance zone'} {value: 'zone', key: 'zone', text: 'Overtaking distance zone'}
] ]
"distance_overtaker_mean",
"distance_overtaker_min",
"distance_overtaker_max",
"distance_overtaker_median",
"overtaking_event_count",
"usage_count",
"zone",
];
const DATE_FILTER_MODES = [
{ value: "none", key: "none", text: "All time" },
{ value: "range", key: "range", text: "Start and end range" },
{ value: "threshold", key: "threshold", text: "Before/after comparison" },
];
type User = Object;
function LayerSidebar({ function LayerSidebar({
mapConfig, mapConfig,
@ -45,11 +60,11 @@ function LayerSidebar({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
baseMap: {style}, baseMap: { style },
obsRoads: {show: showRoads, showUntagged, attribute, maxCount}, obsRoads: { show: showRoads, showUntagged, attribute, maxCount },
obsEvents: {show: showEvents}, obsEvents: { show: showEvents },
obsRegions: {show: showRegions}, obsRegions: {show: showRegions},
filters: { filters: {
currentUser: filtersCurrentUser, currentUser: filtersCurrentUser,
dateMode, dateMode,
startDate, startDate,

View file

@ -1,7 +1,7 @@
import React, { useState, useCallback, useMemo, useRef } from "react"; import React, { useState, useCallback, useMemo , useRef } from "react";
import _ from "lodash"; import _ from "lodash";
import { connect } from "react-redux"; import { connect } from "react-redux";
import {Button } from "semantic-ui-react"; import { Button } from "semantic-ui-react";
import { Layer, Source } from "react-map-gl"; import { Layer, Source } from "react-map-gl";
import produce from "immer"; import produce from "immer";
@ -11,38 +11,38 @@ import {useConfig} from 'config'
import {colorByDistance, colorByCount, borderByZone, reds, isValidAttribute, getRegionLayers} from 'mapstyles' import {colorByDistance, colorByCount, borderByZone, reds, isValidAttribute, getRegionLayers} from 'mapstyles'
import {useMapConfig} from 'reducers/mapConfig' import {useMapConfig} from 'reducers/mapConfig'
import RoadInfo from './RoadInfo' import RoadInfo from "./RoadInfo";
import RegionInfo from "./RegionInfo"; import RegionInfo from "./RegionInfo";
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",
minzoom: 12, minzoom: 12,
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) => {
@ -57,7 +57,7 @@ const getRoadsLayer = (colorAttribute, maxCount) =>
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()
@ -67,42 +67,42 @@ const getRoadsLayer = (colorAttribute, maxCount) =>
}); });
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],
}, },
}); });
@ -150,15 +150,15 @@ type Details =
| { type: "road"; road: Object } | { type: "road"; road: Object }
| { type: "region"; region: RegionInfo }; | { type: "region"; region: RegionInfo };
function MapPage({ login }) { function MapPage({ login }) {
const {obsMapSource, banner } = useConfig() || {} const { obsMapSource , banner } = useConfig() || {};
const [details, setDetails] = useState<null | Details>(null); const [details, setDetails] = useState<null | Details>(null);
const [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | null>(null) const [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | null>(null)
const onCloseDetails = useCallback(() => setDetails(null), [setDetails]); const onCloseDetails = useCallback(() => setDetails(null), [setDetails]);
const mapConfig = useMapConfig(); const mapConfig = useMapConfig();
const viewportRef = useRef(); const viewportRef = useRef();
@ -180,9 +180,9 @@ function MapPage({ login }) {
node?.classList?.contains(className) node?.classList?.contains(className)
) )
) { ) {
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]})
@ -208,13 +208,13 @@ function MapPage({ login }) {
setClickLocation(null); setClickLocation(null);
}, [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( const untaggedRoadsLayerCustom = useMemo(
() => getUntaggedRoadsLayer(attribute), () => getUntaggedRoadsLayer(attribute),
@ -224,7 +224,10 @@ function MapPage({ login }) {
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);
} }
@ -237,8 +240,8 @@ function MapPage({ login }) {
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);
} }
const onToggleLayerSidebarButtonClick = useCallback( const onToggleLayerSidebarButtonClick = useCallback(
@ -252,9 +255,35 @@ function MapPage({ login }) {
); );
if (!obsMapSource) { if (!obsMapSource) {
return null return null;
} }
const tiles = obsMapSource?.tiles?.map(
(tileUrl: string) => {
const query = new URLSearchParams()
if (login) {
if (mapConfig.filters.currentUser) {
query.append('user', login.username)
}
if (mapConfig.filters.dateMode === "range") {
if (mapConfig.filters.startDate) {
query.append('start', mapConfig.filters.startDate)
}
if (mapConfig.filters.endDate) {
query.append('end', mapConfig.filters.endDate)
}
} else if (mapConfig.filters.dateMode === "threshold") {
if (mapConfig.filters.startDate) {
query.append(mapConfig.filters.thresholdAfter ? 'start' : 'end', mapConfig.filters.startDate)
}
}
}
const queryString = String(query)
return tileUrl + (queryString ? '?' : '') + queryString
}
);
const tiles = obsMapSource?.tiles?.map((tileUrl: string) => { const tiles = obsMapSource?.tiles?.map((tileUrl: string) => {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (login) { if (login) {
@ -303,7 +332,7 @@ function MapPage({ login }) {
<Map viewportFromUrl onClick={onClick} onViewportChange={onViewportChange}hasToolbar> <Map viewportFromUrl onClick={onClick} onViewportChange={onViewportChange}hasToolbar>
<Button <Button
style={{ style={{
position: 'absolute', position: "absolute",
left: 44, left: 44,
top: 9, top: 9,
}} }}
@ -333,12 +362,12 @@ function MapPage({ login }) {
/> />
)} )}
<RoadInfo {...{clickLocation}} /> <RoadInfo {...{ clickLocation }} />
</Map> </Map>
</div> </div>
</div> </div>
</Page> </Page>
) );
} }
export default connect((state) => ({ login: state.login }))(MapPage); export default connect((state) => ({ login: state.login }))(MapPage);

View file

@ -29,6 +29,10 @@ export type MapConfig = {
}; };
filters: { filters: {
currentUser: boolean; currentUser: boolean;
dateMode: "none" | "range" | "threshold";
startDate?: null | string;
endDate?: null | string;
thresholdAfter?: null | boolean;
}; };
obsRegions: { obsRegions: {
show: boolean; show: boolean;
@ -57,6 +61,10 @@ export const initialState: MapConfig = {
}, },
filters: { filters: {
currentUser: false, currentUser: false,
dateMode: "none",
startDate: null,
endDate: null,
thresholdAfter: true,
}, },
obsRegions: { obsRegions: {
show: true, show: true,