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,
6
]
], ],
"line-color": [ 'line-color': colorByDistance(),
"case", 'line-opacity': [
[ 'interpolate',
"!", ['linear'],
[ ['zoom'],
"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,
const WARN_DISTANCE = 2 pointsMode = 'overtakingEvents',
const MIN_DISTANCE = 1.5 side = 'overtaker',
...props
const evaluateDistanceColor = function (distance: number) { }: {
if (distance < MIN_DISTANCE) { trackData: TrackData
return 'red' showTrack: boolean
} else if (distance < WARN_DISTANCE) { pointsMode: 'none' | 'overtakingEvents' | 'measurements'
return 'orange' side: 'overtaker' | 'stationary'
} else { }) {
return 'green' if (!trackData) {
return null
} }
}
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 (
<Map.VectorLayer {...{title, visible, zIndex}} style={pointStyleFunction} source={new VectorSource({features})} />
)
}
const trackStroke = new Stroke({width: 4, color: 'rgb(30,144,255)'})
const trackLayerStyle = new Style({stroke: trackStroke})
function trackLayerStyleWithArrows(feature, resolution) {
const geometry = feature.getGeometry()
let styles = [trackLayerStyle]
// Numbers are in pixels
const arrowLength = 10 * resolution
const arrowSpacing = 200 * resolution
const a = arrowLength / Math.sqrt(2)
let spaceSinceLast = 0
geometry.forEachSegment(function (start, end) {
const dx = end[0] - start[0]
const dy = end[1] - start[1]
const d = Math.sqrt(dx * dx + dy * dy)
const rotation = Math.atan2(dy, dx)
spaceSinceLast += d
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},
} = feature
const p = fromLonLat([longitude, latitude])
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 ( return (
<Map {...props}> <div style={props.style}>
<Map.BaseLayer zIndex={10} /> <CustomMap>
<Map.VectorLayer {showTrack && (
visible <Source id="route" type="geojson" data={trackData.track}>
updateWhileAnimating={false} <Layer
updateWhileInteracting={false} id="route"
source={trackVectorSource} type="line"
style={trackLayerStyleWithArrows} paint={{
zIndex={100} 'line-width': ['interpolate', ['linear'], ['zoom'], 14, 2, 17, 5],
/> 'line-color': '#F06292',
}}
/>
</Source>
)}
<Map.GroupLayer title="Tagged Points" visible> {pointsMode !== 'none' && (
<PointLayer features={trackPointsD1} title="Left" visible={show.left} zIndex={101} /> <Source id="overtakingEvents" type="geojson" data={trackData[pointsMode]}>
<PointLayer features={trackPointsD2} title="Right" visible={show.right} zIndex={101} /> <Layer
</Map.GroupLayer> id="overtakingEvents"
type="circle"
paint={{
'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 5, 17, 10],
'circle-color': colorByDistance('distance_' + side),
}}
/>
<Map.GroupLayer title="Untagged Points" fold="close" visible> {[
<PointLayer ['distance_overtaker', 'right'],
features={trackPointsUntaggedD1} ['distance_stationary', 'left'],
title="Left Untagged" ].map(([p, a]) => (
visible={show.leftUnconfirmed} <Layer
zIndex={101} key={p}
/> {...{
<PointLayer id: p,
features={trackPointsUntaggedD2} type: 'symbol',
title="Right Untagged" minzoom: 15,
visible={show.rightUnconfirmed} layout: {
zIndex={101} 'text-field': ['number-format', ['get', p], {'min-fraction-digits': 2, 'max-fraction-digits': 2}],
/> 'text-allow-overlap': true,
</Map.GroupLayer> 'text-font': ['Open Sans Bold', 'Arial Unicode MS Regular'],
'text-size': 12,
<Map.View /> 'text-keep-upright': false,
<Map.FitView extent={viewExtent} /> 'text-anchor': a,
</Map> 'text-radial-offset': 1,
'text-rotate': ['get', 'course'],
},
paint: {
'text-halo-color': 'rgba(255, 255, 255, 1)',
'text-halo-width': 1,
'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1],
},
}}
/>
))}
</Source>
)}
</CustomMap>
</div>
) )
} }

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
{processing && ( stage={
<Message warning> <div className={styles.stage}>
<Message.Content> <Loader active={loading} />
Track data is still being processed, please reload page in a while. <Dimmer.Dimmable blurring dimmed={loading}>
</Message.Content> <TrackMap {...{track, trackData, pointsMode, side, showTrack}} style={{height: '80vh'}} />
</Message> </Dimmer.Dimmable>
)}
{error && ( <div className={styles.details}>
<Message error> {processing && (
<Message.Content> <Message warning>
The processing of this track failed, please ask your site <Message.Content>Track data is still being processed, please reload page in a while.</Message.Content>
administrator for help in debugging the issue. </Message>
</Message.Content> )}
</Message>
)} {error && (
<Message error>
<Message.Content>
The processing of this track failed, please ask your site administrator for help in debugging the
issue.
</Message.Content>
</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,58 +195,34 @@ const TrackPage = connect((state) => ({login: state.login}))(function TrackPage(
</> </>
)} )}
</Segment> </Segment>
</div>
</div>
}
>
<Grid stackable>
<Grid.Row>
<Grid.Column width={12}>
{track?.description && (
<Segment basic>
<Header as="h2" dividing>
Description
</Header>
<Markdown>{track.description}</Markdown>
</Segment>
)}
<Header as="h4">Map settings</Header> <TrackComments
{...{hideLoader: loading, comments, login}}
<Table compact> onSubmit={onSubmitComment}
<Table.Header> onDelete={onDeleteComment}
<Table.Row> />
<Table.HeaderCell>Left</Table.HeaderCell> </Grid.Column>
<Table.HeaderCell textAlign="center">Show distance of</Table.HeaderCell> <Grid.Column width={4}>
<Table.HeaderCell textAlign="right">Right</Table.HeaderCell> <TrackMapSettings {...{showTrack, setShowTrack, pointsMode, setPointsMode, side, setSide}} />
</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.Column>
</Grid.Row> </Grid.Row>
</Grid> </Grid>
{track?.description && (
<Segment basic>
<Header as="h2" dividing>
Description
</Header>
<Markdown>{track.description}</Markdown>
</Segment>
)}
<TrackComments
{...{hideLoader: loading, comments, login}}
onSubmit={onSubmitComment}
onDelete={onDeleteComment}
/>
{/* <pre>{JSON.stringify(data, null, 2)}</pre> */} {/* <pre>{JSON.stringify(data, null, 2)}</pre> */}
</Page> </Page>
) )