frontend: Replace openlayers track map with gl version

This commit is contained in:
Paul Bienkowski 2021-11-21 19:57:26 +01:00
parent f54fe701e7
commit 12686abe14
13 changed files with 261 additions and 1001 deletions

View file

@ -15,8 +15,6 @@
"luxon": "^1.27.0", "luxon": "^1.27.0",
"maplibre-gl": "^2.0.0-pre.1", "maplibre-gl": "^2.0.0-pre.1",
"node-sass": "^4.14.1", "node-sass": "^4.14.1",
"ol": "^6.5.0",
"ol-mapbox-style": "^6.5.1",
"pkce": "^1.0.0-beta2", "pkce": "^1.0.0-beta2",
"proj4": "^2.7.2", "proj4": "^2.7.2",
"react": "^17.0.2", "react": "^17.0.2",
@ -64,7 +62,6 @@
"@craco/craco": "^6.1.2", "@craco/craco": "^6.1.2",
"@semantic-ui-react/craco-less": "^1.2.1", "@semantic-ui-react/craco-less": "^1.2.1",
"@types/lodash": "^4.14.169", "@types/lodash": "^4.14.169",
"@types/ol": "^6.5.0",
"@types/react-redux": "^7.1.16", "@types/react-redux": "^7.1.16",
"@types/react-router-dom": "^5.1.7", "@types/react-router-dom": "^5.1.7",
"semantic-ui-less": "^2.4.1" "semantic-ui-less": "^2.4.1"

View file

@ -1,168 +0,0 @@
import React from 'react'
import OlMap from 'ol/Map'
import OlView from 'ol/View'
import OlTileLayer from 'ol/layer/Tile'
import OlVectorLayer from 'ol/layer/Vector'
import OlGroupLayer from 'ol/layer/Group'
import OSM from 'ol/source/OSM'
import proj4 from 'proj4'
import {register} from 'ol/proj/proj4'
import {fromLonLat} from 'ol/proj'
// Import styles for open layers + addons
import 'ol/ol.css'
import {useConfig} from 'config'
// Prepare projection
proj4.defs(
'projLayer1',
'+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs'
)
register(proj4)
export const MapContext = React.createContext()
const MapLayerContext = React.createContext()
export function Map({children, ...props}) {
const ref = React.useRef()
const [map, setMap] = React.useState(null)
React.useLayoutEffect(() => {
const map = new OlMap({target: ref.current})
setMap(map)
return () => {
map.setTarget(null)
setMap(null)
}
}, [])
return (
<>
<div ref={ref} {...props}>
{map && (
<MapContext.Provider value={map}>
<MapLayerContext.Provider value={map.getLayers()}>{children}</MapLayerContext.Provider>
</MapContext.Provider>
)}
</div>
</>
)
}
export function Layer({layerClass, getDefaultOptions, children, ...props}) {
const context = React.useContext(MapContext)
const layer = React.useMemo(
() =>
new layerClass({
...(getDefaultOptions ? getDefaultOptions() : {}),
...props,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
layer.setProperties(props)
React.useEffect(() => {
context?.addLayer(layer)
return () => context?.removeLayer(layer)
}, [layer, context])
if (typeof layer.getLayers === 'function') {
return <MapLayerContext.Provider value={layer.getLayers()}>{children}</MapLayerContext.Provider>
} else {
return null
}
}
export function TileLayer({osm, ...props}) {
return <Layer layerClass={OlTileLayer} getDefaultOptions={() => ({source: new OSM(osm)})} {...props} />
}
export function BaseLayer(props) {
const config = useConfig()
if (!config) {
return null
}
return (
<TileLayer
osm={{
url: config.mapTileset?.url ?? 'https://tiles.wmflabs.org/bw-mapnik/{z}/{x}/{y}.png',
crossOrigin: null,
}}
{...props}
/>
)
}
export function VectorLayer(props) {
return <Layer layerClass={OlVectorLayer} {...props} />
}
export function GroupLayer(props) {
return <Layer layerClass={OlGroupLayer} {...props} />
}
function FitView({extent}) {
const map = React.useContext(MapContext)
React.useEffect(() => {
if (extent && map) {
map.getView().fit(extent)
}
}, [extent, map])
return null
}
function View({...options}) {
const map = React.useContext(MapContext)
const config = useConfig()
const view = React.useMemo(
() => {
if (!config) return null
const minZoom = config.mapTileset?.minZoom ?? 0
const maxZoom = config.mapTileset?.maxZoom ?? 18
const mapHomeZoom = config.mapHome?.zoom ?? 15
const mapHomeLongitude = config.mapHome?.longitude ?? 9.1797
const mapHomeLatitude = config.mapHome?.latitude ?? 48.7784
return new OlView({
minZoom,
maxZoom,
zoom: Math.max(Math.min(mapHomeZoom, maxZoom), minZoom),
center: fromLonLat([mapHomeLongitude, mapHomeLatitude]),
...options,
})
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[config]
)
React.useEffect(() => {
if (view && map) {
map.setView(view)
}
}, [view, map])
return null
}
Map.FitView = FitView
Map.GroupLayer = GroupLayer
Map.TileLayer = TileLayer
Map.VectorLayer = VectorLayer
Map.View = View
Map.Layer = Layer
Map.BaseLayer = BaseLayer
export default Map

View file

@ -14,3 +14,7 @@
.fullScreen { .fullScreen {
margin: 0; margin: 0;
} }
.hasStage {
margin-top: 0;
}

View file

@ -4,9 +4,10 @@ import {Container} from 'semantic-ui-react'
import styles from './Page.module.scss' import styles from './Page.module.scss'
export default function Page({small, children, fullScreen}: {small?: boolean, children: ReactNode, fullScreen?: boolean}) { export default function Page({small, children, fullScreen, stage}: {small?: boolean, children: ReactNode, fullScreen?: boolean,stage?: ReactNode}) {
return ( return (
<main className={classnames(styles.page, small && styles.small, fullScreen && styles.fullScreen)}> <main className={classnames(styles.page, small && styles.small, fullScreen && styles.fullScreen,stage && styles.hasStage)}>
{stage}
{fullScreen ? children : <Container>{children}</Container>} {fullScreen ? children : <Container>{children}</Container>}
</main> </main>
) )

View file

@ -1,383 +0,0 @@
import React from 'react'
import VectorSource from 'ol/source/Vector'
import GeoJSON from 'ol/format/GeoJSON'
import {Stroke, Style} from 'ol/style'
import Map from './Map'
import {paletteUrban, paletteRural, palettePercentage, palettePercentageInverted} from 'palettes'
// var criterion = "d_mean";
// var criterion = "p_above";
var criterion = 'p_below'
// var hist_xa = 0.0
// var hist_xb = 2.55
// var hist_dx = 0.25
// var hist_n = Math.ceil((hist_xb - hist_xa) / hist_dx)
// function histogramLabels() {
// var labels = Array(hist_n)
// for (var i = 0; i < hist_n; i++) {
// var xa = hist_xa + hist_dx * i
// var xb = xa + hist_dx
// var xc = xa + 0.5 * hist_dx
// labels[i] = (xa * 100).toFixed(0) + '-' + (xb * 100).toFixed(0)
// }
//
// return labels
// }
//
// function histogramColors(palette) {
// var colors = Array(hist_n)
// for (var i = 0; i < hist_n; i++) {
// var xc = hist_xa + hist_dx * i
// colors[i] = palette.rgb_hex(xc)
// }
//
// return colors
// }
//
// function histogram(samples) {
// var binCounts = new Array(hist_n).fill(0)
//
// for (var i = 0; i < samples.length; i++) {
// var v = samples[i]
// var j = Math.floor((v - hist_xa) / hist_dx)
// if (j >= 0 && j < hist_n) {
// binCounts[j]++
// }
// }
//
// return binCounts
// }
//
// function annotation_verbose(feature) {
// var s = ''
//
// s += 'name: ' + feature.get('name') + '\n'
// s += 'way_id: ' + feature.get('way_id') + '\n'
// s += 'direction: ' + feature.get('direction') + '\n'
// s += 'zone: ' + feature.get('zone') + '\n'
// s += 'valid: ' + feature.get('valid') + '\n'
//
// d = feature.get('distance_overtaker_limit')
// s += 'distance_overtaker_limit: ' + (d == null ? 'n/a' : d.toFixed(2)) + ' m \n'
//
// s += '<hr></hr>statistics\n'
//
// d = feature.get('distance_overtaker_mean')
// s += 'distance_overtaker_mean: ' + (d == null ? 'n/a' : d.toFixed(2)) + ' m \n'
//
// d = feature.get('distance_overtaker_median')
// s += 'distance_overtaker_median: ' + (d == null ? 'n/a' : d.toFixed(2)) + ' m \n'
//
// d = feature.get('distance_overtaker_minimum')
// s += 'distance_overtaker_minimum: ' + (d == null ? 'n/a' : d.toFixed(2)) + ' m \n'
//
// d = feature.get('distance_overtaker_n')
// s += 'distance_overtaker_n: ' + (d == null ? 'n/a' : d.toFixed(0)) + '\n'
//
// d = feature.get('distance_overtaker_n_above_limit')
// s += 'distance_overtaker_n_above_limit: ' + (d == null ? 'n/a' : d.toFixed(0)) + '\n'
//
// d = feature.get('distance_overtaker_n_below_limit')
// s += 'distance_overtaker_n_below_limit: ' + (d == null ? 'n/a' : d.toFixed(0)) + '\n'
//
// var n_below = feature.get('distance_overtaker_n_below_limit')
// var n = feature.get('distance_overtaker_n')
// var p = (n_below / n) * 100.0
// s += 'overtakers below limit: ' + (p == null ? 'n/a' : p.toFixed(1)) + ' %\n'
//
// return s
// }
//
// function annotation(feature) {
// var s = '<table>'
//
// s +=
// '<tr><td>Stra&szlig;enname:</td><td><a href="https://www.openstreetmap.org/way/' +
// feature.get('way_id') +
// '" target="_blank">' +
// feature.get('name') +
// '</a></td></tr>'
// d = feature.get('distance_overtaker_limit')
// s += '<tr><td>Mindest&uuml;berholabstand:</td><td>' + (d == null ? 'n/a' : d.toFixed(2)) + ' m </td>'
//
// d = feature.get('distance_overtaker_n')
// s += '<tr><td>Anzahl Messungen:</td><td>' + (d == null ? 'n/a' : d.toFixed(0)) + '</td></tr>'
//
// var n_below = feature.get('distance_overtaker_n_below_limit')
// var n = feature.get('distance_overtaker_n')
// var p = (n_below / n) * 100.0
// s +=
// '<tr><td>Unterschreitung Mindestabstand:</td><td>' +
// (p == null ? 'n/a' : p.toFixed(1)) +
// '% der &Uuml;berholenden</td></tr>'
//
// d = feature.get('distance_overtaker_mean')
// s += '<tr><td>Durchschnitt &Uuml;berholabstand:</td><td>' + (d == null ? 'n/a' : d.toFixed(2)) + ' m </td></tr>'
//
// d = feature.get('distance_overtaker_median')
// s += '<tr><td>Median &Uuml;berholabstand:</td><td>' + (d == null ? 'n/a' : d.toFixed(2)) + ' m </td></tr>'
//
// d = feature.get('distance_overtaker_minimum')
// s += '<tr><td>Minimum &Uuml;berholabstand:</td><td>' + (d == null ? 'n/a' : d.toFixed(2)) + ' m </td></tr>'
//
// s += '</table>'
//
// return s
// }
function styleFunction(feature, resolution, active = false) {
const {
distance_overtaker_n: n,
distance_overtaker_n_above_limit: n_above_limit,
distance_overtaker_n_below_limit: n_below_limit,
distance_overtaker_mean: mean,
distance_overtaker_median: median,
distance_overtaker_minimum: minimum,
zone,
valid,
} = feature.getProperties()
let palette
if (zone === 'urban') {
palette = paletteUrban
} else if (zone === 'rural') {
palette = paletteRural
} else {
palette = paletteUrban
}
var color = [0, 0, 0, 255]
if (valid) {
switch (criterion) {
case 'd_mean':
color = palette.rgba_css(mean)
break
case 'd_median':
color = palette.rgba_css(median)
break
case 'd_min':
color = palette.rgba_css(minimum)
break
case 'p_above':
color = palettePercentage.rgba_css(n > 0 ? (n_above_limit / n * 100) : undefined)
break
case 'p_below':
color = palettePercentageInverted.rgba_css(n > 0 ? (n_below_limit / n * 100) : undefined)
break
}
} else {
color = [128, 128, 128, 255]
}
// var width = 2 + 1*Math.log10(n);
var width = active ? 6 : 3
// width =Math.max(2.0, width*1/resolution);
var style = new Style({
stroke: new Stroke({
color: color,
width: width,
}),
})
return style
}
// var map = new ol.Map({
// target: 'map',
// layers: [
// new ol.layer.Tile({
// source: new ol.source.OSM({
// url: 'https://tiles.wmflabs.org/bw-mapnik/{z}/{x}/{y}.png',
// crossOrigin: null,
// // url: 'https://{a-c}.tile.openstreetmap.org/{z}/{x}/{y}.png'
// }),
// }),
// ],
// view: new ol.View({
// center: ol.proj.fromLonLat([9.1798, 48.7759]),
// zoom: 13,
// }),
// })
export default function RoadsLayer() {
const dataSource = React.useMemo(
() =>
new VectorSource({
format: new GeoJSON(),
url: 'https://dev.openbikesensor.org/public/json/roads.json',
}),
[]
)
return <Map.VectorLayer source={dataSource} style={styleFunction} zIndex={1000} />
}
// var histogramColorsRural = histogramColors(paletteRural).reverse()
// var histogramColorsUrban = histogramColors(paletteUrban).reverse()
//
// var chartOptions = {
// series: [
// {
// name: 'Überholende',
// data: Array(hist_n).fill(0),
// },
// ],
// chart: {
// type: 'bar',
// height: 350,
// animations: {
// animateGradually: {
// enabled: false,
// },
// },
// },
// plotOptions: {
// bar: {
// horizontal: false,
// columnWidth: '95%',
// endingShape: 'flat',
// distributed: true,
// },
// },
// dataLabels: {
// enabled: true,
// },
// stroke: {
// show: false,
// },
// xaxis: {
// title: {
// text: 'Überholabstand in Zentimeter',
// },
// categories: histogramLabels().reverse(),
// },
// yaxis: {
// title: {
// text: 'Anzahl Überholende',
// },
// labels: {
// show: false,
// },
// },
// fill: {
// opacity: 1,
// },
// legend: {
// show: false,
// },
// tooltip: {
// y: {
// formatter: function (val) {
// return val
// },
// },
// },
// }
// var chart = new ApexCharts(document.querySelector('#chart'), chartOptions)
// chart.render()
// var noFeatureActive = true
// map.on('singleclick', function (evt) {
// var feature = map.forEachFeatureAtPixel(evt.pixel, function (feature, layer) {
// return feature
// })
//
// var resolution = map.getView().getResolution()
//
// if (!noFeatureActive) {
// vectorLayer
// .getSource()
// .getFeatures()
// .forEach((f) => {
// f.setStyle(styleFunction(f, resolution, false))
// })
// noFeatureActive = true
// }
//
// if (feature && dataSource.hasFeature(feature)) {
// console.log(annotation_verbose(feature))
// caption.innerHTML = annotation(feature)
// caption.style.alignItems = 'flex-start'
//
// var zone = feature.get('zone')
// var colors = undefined
// switch (zone) {
// case 'urban':
// colors = histogramColorsUrban
// break
// case 'rural':
// colors = histogramColorsRural
// break
// default:
// colors = histogramColorsUrban
// }
//
// chart.updateOptions({
// colors: colors,
// })
//
// var hist = histogram(feature.get('distance_overtaker_measurements')).reverse()
//
// chart.updateSeries([
// {
// name: 'Überholende',
// data: hist,
// },
// ])
//
// feature.setStyle(styleFunction(feature, resolution, true))
// noFeatureActive = false
// }
// })
// function writeLegend(palette, target, ticks, postfix) {
// const div = document.getElementById(target)
// const canvas = document.createElement('canvas')
// const context = canvas.getContext('2d')
//
// const barWidth = palette.n
// const barLeft = 25
// const barHeight = 25
//
// canvas.width = 300
// canvas.height = 50
//
// const imgData = context.getImageData(0, 0, barWidth, barHeight)
// const data = imgData.data
//
// let k = 0
// for (let y = 0; y < barHeight; y++) {
// for (let x = 0; x < barWidth; x++) {
// for (let c = 0; c < 4; c++) {
// data[k] = palette.rgba_sampled[x][c]
// k += 1
// }
// }
// }
// context.putImageData(imgData, barLeft, 0)
//
// context.font = '12px Arial'
// context.textAlign = 'center'
// context.textBaseline = 'top'
// for (let i = 0; i < ticks.length; i++) {
// const v = ticks[i]
// const x = barLeft + ((v - palette.a) / (palette.b - palette.a)) * (palette.n - 1)
// const y = 25
// context.fillText(v.toFixed(2) + postfix, x, y)
// }
//
// const image = new Image()
// image.src = canvas.toDataURL()
// image.height = canvas.height
// image.width = canvas.width
// div.appendChild(image)
// }
//
// writeLegend(palettePercentageInverted, 'colorbar', [0, 100.0], '%')

View file

@ -3,8 +3,6 @@ export {default as FileDrop} from './FileDrop'
export {default as FileUploadField} from './FileUploadField' export {default as FileUploadField} from './FileUploadField'
export {default as FormattedDate} from './FormattedDate' export {default as FormattedDate} from './FormattedDate'
export {default as LoginButton} from './LoginButton' export {default as LoginButton} from './LoginButton'
export {default as Map} from './Map'
export {default as Page} from './Page' export {default as Page} from './Page'
export {default as RoadsLayer} from './RoadsLayer'
export {default as Stats} from './Stats' export {default as Stats} from './Stats'
export {default as StripMarkdown} from './StripMarkdown' export {default as StripMarkdown} from './StripMarkdown'

View file

@ -3,138 +3,83 @@ import _ from 'lodash'
import bright from './bright.json' import bright from './bright.json'
import positron from './positron.json' import positron from './positron.json'
export function colorByDistance(attribute = 'distance_overtaker_mean', fallback = '#ABC') {
return [
'case',
['!', ['to-boolean', ['get', attribute]]],
fallback,
[
'interpolate-hcl',
['linear'],
['get', attribute],
1,
'rgba(255, 0, 0, 1)',
1.3,
'rgba(255, 200, 0, 1)',
1.5,
'rgba(67, 200, 0, 1)',
1.7,
'rgba(67, 150, 0, 1)',
],
]
}
function addRoadsStyle(style, mapSource) { function addRoadsStyle(style, mapSource) {
style.sources.obs = mapSource style.sources.obs = mapSource
// insert before "road_oneway" layer // insert before "road_oneway" layer
let idx = style.layers.findIndex(l => l.id === 'road_oneway') let idx = style.layers.findIndex((l) => l.id === 'road_oneway')
if (idx === -1) { if (idx === -1) {
idx = style.layers.length idx = style.layers.length
} }
style.layers.splice(idx, 0, { style.layers.splice(idx, 0, {
"id": "obs", id: 'obs',
"type": "line", type: 'line',
"source": "obs", source: 'obs',
"source-layer": "obs_roads", 'source-layer': 'obs_roads',
"layout": { layout: {
"line-cap": "round", 'line-cap': 'round',
"line-join": "round" 'line-join': 'round',
}, },
"paint": { paint: {
"line-width": [ 'line-width': [
"interpolate", 'interpolate',
["exponential", 1.5], ['exponential', 1.5],
["zoom"], ['zoom'],
12, 12,
2, 2,
17, 17,
[ ['case', ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]], 2, 6],
"case",
[
"!",
[
"to-boolean",
[
"get",
"distance_overtaker_mean"
]
]
], ],
2, 'line-color': colorByDistance(),
6 'line-opacity': [
] 'interpolate',
], ['linear'],
"line-color": [ ['zoom'],
"case",
[
"!",
[
"to-boolean",
[
"get",
"distance_overtaker_mean"
]
]
],
"#ABC",
[
"interpolate-hcl",
["linear"],
[
"get",
"distance_overtaker_mean"
],
1,
"rgba(255, 0, 0, 1)",
1.3,
"rgba(255, 200, 0, 1)",
1.5,
"rgba(67, 200, 0, 1)",
1.7,
"rgba(67, 150, 0, 1)"
]
],
"line-opacity": [
"interpolate",
["linear"],
["zoom"],
12, 12,
0, 0,
13, 13,
[ ['case', ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]], 0, 1],
"case",
[
"!",
[
"to-boolean",
[
"get",
"distance_overtaker_mean"
]
]
],
0,
1
],
14, 14,
[ ['case', ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]], 0, 1],
"case",
[
"!",
[
"to-boolean",
[
"get",
"distance_overtaker_mean"
]
]
],
0,
1
],
15, 15,
1 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,
}) })
return style return style
} }
export const basemap = positron
export const basemap = bright export const obsRoads = (sourceUrl) => addRoadsStyle(_.cloneDeep(basemap), sourceUrl)
export const obsRoads = (sourceUrl) => addRoadsStyle(_.cloneDeep(positron), sourceUrl)

View file

@ -9,11 +9,9 @@ import api from 'api'
import {Stats, Page} from 'components' import {Stats, Page} from 'components'
import {TrackListItem} from './TracksPage' import {TrackListItem} from './TracksPage'
import {RoadsMap} from './MapPage' import {CustomMap} from './MapPage'
import styles from './HomePage.module.scss' import styles from './HomePage.module.scss'
import 'ol/ol.css'
function MostRecentTrack() { function MostRecentTrack() {
const track: Track | null = useObservable( const track: Track | null = useObservable(
() => () =>
@ -49,7 +47,7 @@ export default function HomePage() {
<Grid.Row> <Grid.Row>
<Grid.Column width={10}> <Grid.Column width={10}>
<div className={styles.welcomeMap}> <div className={styles.welcomeMap}>
<RoadsMap /> <CustomMap mode='roads' />
</div> </div>
</Grid.Column> </Grid.Column>
<Grid.Column width={6}> <Grid.Column width={6}>

View file

@ -1,18 +1,22 @@
import React from 'react' import React from 'react'
// import {Grid, Loader, Header, Item} from 'semantic-ui-react'
// import api from 'api'
import {Page} from 'components' import {Page} from 'components'
import {useConfig, Config} from 'config' import {useConfig, Config} from 'config'
import ReactMapGl, {AttributionControl } from 'react-map-gl'
import styles from './MapPage.module.scss' import styles from './MapPage.module.scss'
import 'ol/ol.css' import {obsRoads, basemap } from '../mapstyles'
import {obsRoads} from '../mapstyles'
import ReactMapGl, {AttributionControl } from 'react-map-gl' function CustomMapInner({mapSource, config, mode, children}: {mapSource: string, config: Config, mode?: 'roads'}) {
const mapStyle = React.useMemo(() => {
if (mode === 'roads') {
return mapSource && obsRoads(mapSource)
} else {
return basemap
}
}, [mapSource, mode])
function RoadsMapInner({mapSource, config}: {mapSource: string ,config: Config}) {
const mapStyle = React.useMemo(() => mapSource && obsRoads(mapSource), [mapSource])
const [viewport, setViewport] = React.useState({ const [viewport, setViewport] = React.useState({
longitude: 0, longitude: 0,
latitude: 0, latitude: 0,
@ -32,15 +36,17 @@ function RoadsMapInner({mapSource, config}: {mapSource: string ,config: Config})
return ( return (
<ReactMapGl mapStyle={mapStyle} width="100%" height="100%" onViewportChange={setViewport} {...viewport}> <ReactMapGl mapStyle={mapStyle} width="100%" height="100%" onViewportChange={setViewport} {...viewport}>
<AttributionControl style={{right: 0, bottom: 0}} customAttribution={[ <AttributionControl style={{right: 0, bottom: 0}} customAttribution={[
'<a href="https://openstreetmap.org/copyright" target="_blank" rel="nofollow noopener">© OpenStreetMap contributors</a>', '<a href="https://openstreetmap.org/copyright" target="_blank" rel="nofollow noopener noreferrer">© OpenStreetMap contributors</a>',
'<a href="https://openmaptiles.org/" target="_blank" rel="nofollow noopener">© OpenMapTiles</a>', '<a href="https://openmaptiles.org/" target="_blank" rel="nofollow noopener noreferrer">© OpenMapTiles</a>',
'<a href="https://openbikesensor.org/" target="_blank" rel="nofollow noopener">© OpenBikeSensor</a>', '<a href="https://openbikesensor.org/" target="_blank" rel="nofollow noopener noreferrer">© OpenBikeSensor</a>',
]} /> ]} />
{children}
</ReactMapGl> </ReactMapGl>
) )
} }
export function RoadsMap(props) { export function CustomMap(props) {
const config = useConfig() || {} const config = useConfig() || {}
if (!config) return null; if (!config) return null;
const {obsMapSource: mapSource} = config const {obsMapSource: mapSource} = config
@ -48,7 +54,7 @@ export function RoadsMap(props) {
if (!mapSource) return null; if (!mapSource) return null;
return ( return (
<RoadsMapInner {...{mapSource, config}} {...props} /> <CustomMapInner {...{mapSource, config}} {...props} />
) )
} }
@ -56,7 +62,7 @@ export default function MapPage() {
return ( return (
<Page fullScreen> <Page fullScreen>
<div className={styles.mapContainer}> <div className={styles.mapContainer}>
<RoadsMap /> <CustomMap mode='roads' />
</div> </div>
</Page> </Page>
) )

View file

@ -73,7 +73,7 @@ export default function TrackDetails({track, isAuthor}) {
)} )}
{track?.processingStatus != null && ( {track?.processingStatus != null && track?.processingStatus != 'error' && (
<List.Item> <List.Item>
<List.Header>Processing</List.Header> <List.Header>Processing</List.Header>
{track.processingStatus} {track.processingStatus}

View file

@ -1,239 +1,85 @@
import React from 'react' import React from 'react'
import {Vector as VectorSource} from 'ol/source' import {Source, Layer} from 'react-map-gl'
import {LineString, Point} from 'ol/geom'
import Feature from 'ol/Feature'
import {fromLonLat} from 'ol/proj'
import {Fill, Stroke, Style, Text, Circle} from 'ol/style'
import {Map} from 'components' import type {TrackData} from 'types'
import type {TrackData, TrackPoint} from 'types' import {CustomMap} from '../MapPage'
const isValidTrackPoint = (point: TrackPoint): boolean => { import {colorByDistance} from '../../mapstyles'
const longitude = point.geometry?.coordinates?.[0]
const latitude = point.geometry?.coordinates?.[1]
return latitude != null && longitude != null && (latitude !== 0 || longitude !== 0) export default function TrackMap({
trackData,
showTrack,
pointsMode = 'overtakingEvents',
side = 'overtaker',
...props
}: {
trackData: TrackData
showTrack: boolean
pointsMode: 'none' | 'overtakingEvents' | 'measurements'
side: 'overtaker' | 'stationary'
}) {
if (!trackData) {
return null
} }
const WARN_DISTANCE = 2
const MIN_DISTANCE = 1.5
const evaluateDistanceColor = function (distance: number) {
if (distance < MIN_DISTANCE) {
return 'red'
} else if (distance < WARN_DISTANCE) {
return 'orange'
} else {
return 'green'
}
}
const evaluateDistanceForFillColor = function (distance: number) {
const redFill = new Fill({color: 'rgba(255, 0, 0, 0.2)'})
const orangeFill = new Fill({color: 'rgba(245,134,0,0.2)'})
const greenFill = new Fill({color: 'rgba(50, 205, 50, 0.2)'})
switch (evaluateDistanceColor(distance)) {
case 'red':
return redFill
case 'orange':
return orangeFill
case 'green':
return greenFill
}
}
const evaluateDistanceForStrokeColor = function (distance: number) {
const redStroke = new Stroke({color: 'rgb(255, 0, 0)'})
const orangeStroke = new Stroke({color: 'rgb(245,134,0)'})
const greenStroke = new Stroke({color: 'rgb(50, 205, 50)'})
switch (evaluateDistanceColor(distance)) {
case 'red':
return redStroke
case 'orange':
return orangeStroke
case 'green':
return greenStroke
}
}
const createTextStyle = function (distance: number, resolution: number) {
return new Text({
textAlign: 'center',
textBaseline: 'middle',
font: 'normal 18px/1 Arial',
text: resolution < 6 ? '' + Number(distance).toFixed(2) : '',
fill: new Fill({color: evaluateDistanceColor(distance)}),
stroke: new Stroke({color: 'white', width: 2}),
offsetX: 0,
offsetY: 0,
})
}
function pointStyleFunction(feature, resolution) {
let distance = feature.get('distance')
let radius = 200 / resolution
return new Style({
image: new Circle({
radius: radius < 20 ? radius : 20,
fill: evaluateDistanceForFillColor(distance),
stroke: evaluateDistanceForStrokeColor(distance),
}),
text: createTextStyle(distance, resolution),
})
}
function PointLayer({features, title, visible, zIndex}) {
return ( return (
<Map.VectorLayer {...{title, visible, zIndex}} style={pointStyleFunction} source={new VectorSource({features})} /> <div style={props.style}>
) <CustomMap>
} {showTrack && (
<Source id="route" type="geojson" data={trackData.track}>
<Layer
id="route"
type="line"
paint={{
'line-width': ['interpolate', ['linear'], ['zoom'], 14, 2, 17, 5],
'line-color': '#F06292',
}}
/>
</Source>
)}
const trackStroke = new Stroke({width: 4, color: 'rgb(30,144,255)'}) {pointsMode !== 'none' && (
const trackLayerStyle = new Style({stroke: trackStroke}) <Source id="overtakingEvents" type="geojson" data={trackData[pointsMode]}>
<Layer
id="overtakingEvents"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 5, 17, 10],
'circle-color': colorByDistance('distance_' + side),
}}
/>
function trackLayerStyleWithArrows(feature, resolution) { {[
const geometry = feature.getGeometry() ['distance_overtaker', 'right'],
['distance_stationary', 'left'],
let styles = [trackLayerStyle] ].map(([p, a]) => (
<Layer
// Numbers are in pixels key={p}
const arrowLength = 10 * resolution {...{
const arrowSpacing = 200 * resolution id: p,
type: 'symbol',
const a = arrowLength / Math.sqrt(2) minzoom: 15,
let spaceSinceLast = 0 layout: {
'text-field': ['number-format', ['get', p], {'min-fraction-digits': 2, 'max-fraction-digits': 2}],
geometry.forEachSegment(function (start, end) { 'text-allow-overlap': true,
const dx = end[0] - start[0] 'text-font': ['Open Sans Bold', 'Arial Unicode MS Regular'],
const dy = end[1] - start[1] 'text-size': 12,
const d = Math.sqrt(dx * dx + dy * dy) 'text-keep-upright': false,
const rotation = Math.atan2(dy, dx) 'text-anchor': a,
spaceSinceLast += d 'text-radial-offset': 1,
'text-rotate': ['get', 'course'],
while (spaceSinceLast > arrowSpacing) {
spaceSinceLast -= arrowSpacing
let offsetAlongLine = (d - spaceSinceLast) / d
let pos = [start[0] + dx * offsetAlongLine, start[1] + dy * offsetAlongLine]
const lineStr1 = new LineString([pos, [pos[0] - a, pos[1] + a]])
lineStr1.rotate(rotation, pos)
const lineStr2 = new LineString([pos, [pos[0] - a, pos[1] - a]])
lineStr2.rotate(rotation, pos)
styles.push(
new Style({
geometry: lineStr1,
stroke: trackStroke,
})
)
styles.push(
new Style({
geometry: lineStr2,
stroke: trackStroke,
})
)
}
})
return styles
}
export default function TrackMap({trackData, show, ...props}: {trackData: TrackData}) {
const {
trackVectorSource,
trackPointsD1,
trackPointsD2,
trackPointsUntaggedD1,
trackPointsUntaggedD2,
viewExtent,
} = React.useMemo(() => {
const trackPointsD1: Feature<Point>[] = []
const trackPointsD2: Feature<Point>[] = []
const trackPointsUntaggedD1: Feature<Point>[] = []
const trackPointsUntaggedD2: Feature<Point>[] = []
const filteredPoints: TrackPoint[] = trackData?.measurements?.features.filter(isValidTrackPoint) ?? []
for (const feature of filteredPoints) {
const {
geometry: {
coordinates: [latitude, longitude],
}, },
properties: {confirmed: flag, distanceOvertaker: d1, distanceStationary: d2}, paint: {
} = feature 'text-halo-color': 'rgba(255, 255, 255, 1)',
'text-halo-width': 1,
const p = fromLonLat([longitude, latitude]) 'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1],
},
const geometry = new Point(p) }}
if (flag && d1) {
trackPointsD1.push(new Feature({distance: d1, geometry}))
}
if (flag && d2) {
trackPointsD2.push(new Feature({distance: d2, geometry}))
}
if (!flag && d1) {
trackPointsUntaggedD1.push(new Feature({distance: d1, geometry}))
}
if (!flag && d2) {
trackPointsUntaggedD2.push(new Feature({distance: d2, geometry}))
}
}
const points: Coordinate[] =
trackData?.track.geometry.coordinates.map(([latitude, longitude]) => {
return fromLonLat([longitude, latitude])
}) ?? []
//Simplify to 1 point per 2 meter
const trackVectorSource = new VectorSource({
features: [new Feature(new LineString(points).simplify(2))],
})
const viewExtent = points.length ? trackVectorSource.getExtent() : null
return {trackVectorSource, trackPointsD1, trackPointsD2, trackPointsUntaggedD1, trackPointsUntaggedD2, viewExtent}
}, [trackData?.measurements?.features])
return (
<Map {...props}>
<Map.BaseLayer zIndex={10} />
<Map.VectorLayer
visible
updateWhileAnimating={false}
updateWhileInteracting={false}
source={trackVectorSource}
style={trackLayerStyleWithArrows}
zIndex={100}
/> />
))}
<Map.GroupLayer title="Tagged Points" visible> </Source>
<PointLayer features={trackPointsD1} title="Left" visible={show.left} zIndex={101} /> )}
<PointLayer features={trackPointsD2} title="Right" visible={show.right} zIndex={101} /> </CustomMap>
</Map.GroupLayer> </div>
<Map.GroupLayer title="Untagged Points" fold="close" visible>
<PointLayer
features={trackPointsUntaggedD1}
title="Left Untagged"
visible={show.leftUnconfirmed}
zIndex={101}
/>
<PointLayer
features={trackPointsUntaggedD2}
title="Right Untagged"
visible={show.rightUnconfirmed}
zIndex={101}
/>
</Map.GroupLayer>
<Map.View />
<Map.FitView extent={viewExtent} />
</Map>
) )
} }

View file

@ -0,0 +1,13 @@
.stage {
position: relative;
margin-bottom: 32px;
}
.details.details {
position: absolute;
width: 320px;
top: 16px;
right: 16px;
max-height: calc(100% - 32px);
overflow: auto;
}

View file

@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import {connect} from 'react-redux' import {connect} from 'react-redux'
import {Table, Checkbox, Segment, Dimmer, Grid, Loader, Header, Message} from 'semantic-ui-react' import {List, Dropdown, Checkbox, Segment, Dimmer, Grid, Loader, Header, Message, Container} from 'semantic-ui-react'
import {useParams, useHistory} from 'react-router-dom' import {useParams, useHistory} from 'react-router-dom'
import {concat, combineLatest, of, from, Subject} from 'rxjs' import {concat, combineLatest, of, from, Subject} from 'rxjs'
import {pluck, distinctUntilChanged, map, switchMap, startWith, catchError} from 'rxjs/operators' import {pluck, distinctUntilChanged, map, switchMap, startWith, catchError} from 'rxjs/operators'
@ -16,12 +16,52 @@ import TrackComments from './TrackComments'
import TrackDetails from './TrackDetails' import TrackDetails from './TrackDetails'
import TrackMap from './TrackMap' import TrackMap from './TrackMap'
import styles from './TrackPage.module.scss'
function useTriggerSubject() { function useTriggerSubject() {
const subject$ = React.useMemo(() => new Subject(), []) const subject$ = React.useMemo(() => new Subject(), [])
const trigger = React.useCallback(() => subject$.next(null), [subject$]) const trigger = React.useCallback(() => subject$.next(null), [subject$])
return [trigger, subject$] return [trigger, subject$]
} }
function TrackMapSettings({showTrack, setShowTrack, pointsMode, setPointsMode, side, setSide}) {
return (
<>
<Header as="h4">Map settings</Header>
<List>
<List.Item>
<Checkbox checked={showTrack} onChange={(e, d) => setShowTrack(d.checked)} /> Show track
</List.Item>
<List.Item>
<List.Header>Points</List.Header>
<Dropdown
selection
value={pointsMode}
onChange={(e, d) => setPointsMode(d.value)}
options={[
{key: 'none', value: 'none', text: 'None'},
{key: 'overtakingEvents', value: 'overtakingEvents', text: 'Confirmed'},
{key: 'measurements', value: 'measurements', text: 'All measurements'},
]}
/>
</List.Item>
<List.Item>
<List.Header>Side (for color)</List.Header>
<Dropdown
selection
value={side}
onChange={(e, d) => setSide(d.value)}
options={[
{key: 'overtaker', value: 'overtaker', text: 'Overtaker (Left)'},
{key: 'stationary', value: 'stationary', text: 'Stationary (Right)'},
]}
/>
</List.Item>
</List>
</>
)
}
const TrackPage = connect((state) => ({login: state.login}))(function TrackPage({login}) { const TrackPage = connect((state) => ({login: state.login}))(function TrackPage({login}) {
const {slug} = useParams() const {slug} = useParams()
@ -105,60 +145,47 @@ const TrackPage = connect((state) => ({login: state.login}))(function TrackPage(
[slug, reloadComments] [slug, reloadComments]
) )
const onDownloadOriginal = React.useCallback( const onDownloadOriginal = React.useCallback(() => {
() => {
api.downloadFile(`/tracks/${slug}/download/original.csv`) api.downloadFile(`/tracks/${slug}/download/original.csv`)
}, }, [slug])
[slug]
)
const isAuthor = login?.username === data?.track?.author?.username const isAuthor = login?.username === data?.track?.author?.username
const {track, trackData, comments} = data || {} const {track, trackData, comments} = data || {}
console.log({track, trackData})
const loading = track == null || trackData === undefined const loading = track == null || trackData === undefined
const processing = ['processing', 'queued', 'created'].includes(track?.processingStatus) const processing = ['processing', 'queued', 'created'].includes(track?.processingStatus)
const error = track?.processingStatus === 'error' const error = track?.processingStatus === 'error'
const [left, setLeft] = React.useState(true) const [showTrack, setShowTrack] = React.useState(true)
const [right, setRight] = React.useState(false) const [pointsMode, setPointsMode] = React.useState('overtakingEvents') // none|overtakingEvents|measurements
const [leftUnconfirmed, setLeftUnconfirmed] = React.useState(false) const [side, setSide] = React.useState('overtaker') // overtaker|stationary
const [rightUnconfirmed, setRightUnconfirmed] = React.useState(false)
return ( return (
<Page> <Page
stage={
<div className={styles.stage}>
<Loader active={loading} />
<Dimmer.Dimmable blurring dimmed={loading}>
<TrackMap {...{track, trackData, pointsMode, side, showTrack}} style={{height: '80vh'}} />
</Dimmer.Dimmable>
<div className={styles.details}>
{processing && ( {processing && (
<Message warning> <Message warning>
<Message.Content> <Message.Content>Track data is still being processed, please reload page in a while.</Message.Content>
Track data is still being processed, please reload page in a while.
</Message.Content>
</Message> </Message>
)} )}
{error && ( {error && (
<Message error> <Message error>
<Message.Content> <Message.Content>
The processing of this track failed, please ask your site The processing of this track failed, please ask your site administrator for help in debugging the
administrator for help in debugging the issue. issue.
</Message.Content> </Message.Content>
</Message> </Message>
)} )}
<Grid stackable>
<Grid.Row>
<Grid.Column width={12}>
<div style={{position: 'relative'}}>
<Loader active={loading} />
<Dimmer.Dimmable blurring dimmed={loading}>
<TrackMap
{...{track, trackData, show: {left, right, leftUnconfirmed, rightUnconfirmed}}}
style={{height: '60vh', minHeight: 400}}
/>
</Dimmer.Dimmable>
</div>
</Grid.Column>
<Grid.Column width={4}>
<Segment> <Segment>
{track && ( {track && (
<> <>
@ -168,43 +195,13 @@ const TrackPage = connect((state) => ({login: state.login}))(function TrackPage(
</> </>
)} )}
</Segment> </Segment>
</div>
<Header as="h4">Map settings</Header> </div>
}
<Table compact> >
<Table.Header> <Grid stackable>
<Table.Row> <Grid.Row>
<Table.HeaderCell>Left</Table.HeaderCell> <Grid.Column width={12}>
<Table.HeaderCell textAlign="center">Show distance of</Table.HeaderCell>
<Table.HeaderCell textAlign="right">Right</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell>
<Checkbox checked={left} onChange={(e, d) => setLeft(d.checked)} />{' '}
</Table.Cell>
<Table.Cell textAlign="center">Events</Table.Cell>
<Table.Cell textAlign="right">
<Checkbox checked={right} onChange={(e, d) => setRight(d.checked)} />{' '}
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>
<Checkbox checked={leftUnconfirmed} onChange={(e, d) => setLeftUnconfirmed(d.checked)} />{' '}
</Table.Cell>
<Table.Cell textAlign="center">Other points</Table.Cell>
<Table.Cell textAlign="right">
<Checkbox checked={rightUnconfirmed} onChange={(e, d) => setRightUnconfirmed(d.checked)} />{' '}
</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
</Grid.Column>
</Grid.Row>
</Grid>
{track?.description && ( {track?.description && (
<Segment basic> <Segment basic>
<Header as="h2" dividing> <Header as="h2" dividing>
@ -219,6 +216,12 @@ const TrackPage = connect((state) => ({login: state.login}))(function TrackPage(
onSubmit={onSubmitComment} onSubmit={onSubmitComment}
onDelete={onDeleteComment} onDelete={onDeleteComment}
/> />
</Grid.Column>
<Grid.Column width={4}>
<TrackMapSettings {...{showTrack, setShowTrack, pointsMode, setPointsMode, side, setSide}} />
</Grid.Column>
</Grid.Row>
</Grid>
{/* <pre>{JSON.stringify(data, null, 2)}</pre> */} {/* <pre>{JSON.stringify(data, null, 2)}</pre> */}
</Page> </Page>