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",
"maplibre-gl": "^2.0.0-pre.1",
"node-sass": "^4.14.1",
"ol": "^6.5.0",
"ol-mapbox-style": "^6.5.1",
"pkce": "^1.0.0-beta2",
"proj4": "^2.7.2",
"react": "^17.0.2",
@ -64,7 +62,6 @@
"@craco/craco": "^6.1.2",
"@semantic-ui-react/craco-less": "^1.2.1",
"@types/lodash": "^4.14.169",
"@types/ol": "^6.5.0",
"@types/react-redux": "^7.1.16",
"@types/react-router-dom": "^5.1.7",
"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 {
margin: 0;
}
.hasStage {
margin-top: 0;
}

View file

@ -4,9 +4,10 @@ import {Container} from 'semantic-ui-react'
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 (
<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>}
</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 FormattedDate} from './FormattedDate'
export {default as LoginButton} from './LoginButton'
export {default as Map} from './Map'
export {default as Page} from './Page'
export {default as RoadsLayer} from './RoadsLayer'
export {default as Stats} from './Stats'
export {default as StripMarkdown} from './StripMarkdown'

View file

@ -3,138 +3,83 @@ import _ from 'lodash'
import bright from './bright.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) {
style.sources.obs = mapSource
// 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) {
idx = style.layers.length
}
style.layers.splice(idx, 0, {
"id": "obs",
"type": "line",
"source": "obs",
"source-layer": "obs_roads",
"layout": {
"line-cap": "round",
"line-join": "round"
id: 'obs',
type: 'line',
source: 'obs',
'source-layer': 'obs_roads',
layout: {
'line-cap': 'round',
'line-join': 'round',
},
"paint": {
"line-width": [
"interpolate",
["exponential", 1.5],
["zoom"],
paint: {
'line-width': [
'interpolate',
['exponential', 1.5],
['zoom'],
12,
2,
17,
[
"case",
[
"!",
[
"to-boolean",
[
"get",
"distance_overtaker_mean"
]
]
['case', ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]], 2, 6],
],
2,
6
]
],
"line-color": [
"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"],
'line-color': colorByDistance(),
'line-opacity': [
'interpolate',
['linear'],
['zoom'],
12,
0,
13,
[
"case",
[
"!",
[
"to-boolean",
[
"get",
"distance_overtaker_mean"
]
]
],
0,
1
],
['case', ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]], 0, 1],
14,
[
"case",
[
"!",
[
"to-boolean",
[
"get",
"distance_overtaker_mean"
]
]
],
0,
1
],
['case', ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]], 0, 1],
15,
1
1,
],
"line-offset": [
"interpolate",
["exponential", 1.5],
["zoom"],
'line-offset': [
'interpolate',
['exponential', 1.5],
['zoom'],
12,
["get", "offset_direction"],
['get', 'offset_direction'],
19,
[
"*",
["get", "offset_direction"],
8
]
]
['*', ['get', 'offset_direction'], 8],
],
},
"minzoom": 12
minzoom: 12,
})
return style
}
export const basemap = bright
export const obsRoads = (sourceUrl) => addRoadsStyle(_.cloneDeep(positron), sourceUrl)
export const basemap = positron
export const obsRoads = (sourceUrl) => addRoadsStyle(_.cloneDeep(basemap), sourceUrl)

View file

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

View file

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

View file

@ -1,239 +1,85 @@
import React from 'react'
import {Vector as VectorSource} from 'ol/source'
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 {Source, Layer} from 'react-map-gl'
import {Map} from 'components'
import type {TrackData, TrackPoint} from 'types'
import type {TrackData} from 'types'
import {CustomMap} from '../MapPage'
const isValidTrackPoint = (point: TrackPoint): boolean => {
const longitude = point.geometry?.coordinates?.[0]
const latitude = point.geometry?.coordinates?.[1]
import {colorByDistance} from '../../mapstyles'
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 (
<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)'})
const trackLayerStyle = new Style({stroke: trackStroke})
{pointsMode !== 'none' && (
<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()
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],
{[
['distance_overtaker', 'right'],
['distance_stationary', 'left'],
].map(([p, a]) => (
<Layer
key={p}
{...{
id: p,
type: 'symbol',
minzoom: 15,
layout: {
'text-field': ['number-format', ['get', p], {'min-fraction-digits': 2, 'max-fraction-digits': 2}],
'text-allow-overlap': true,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Regular'],
'text-size': 12,
'text-keep-upright': false,
'text-anchor': a,
'text-radial-offset': 1,
'text-rotate': ['get', 'course'],
},
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 (
<Map {...props}>
<Map.BaseLayer zIndex={10} />
<Map.VectorLayer
visible
updateWhileAnimating={false}
updateWhileInteracting={false}
source={trackVectorSource}
style={trackLayerStyleWithArrows}
zIndex={100}
paint: {
'text-halo-color': 'rgba(255, 255, 255, 1)',
'text-halo-width': 1,
'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1],
},
}}
/>
<Map.GroupLayer title="Tagged Points" visible>
<PointLayer features={trackPointsD1} title="Left" visible={show.left} zIndex={101} />
<PointLayer features={trackPointsD2} title="Right" visible={show.right} zIndex={101} />
</Map.GroupLayer>
<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>
))}
</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 {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 {concat, combineLatest, of, from, Subject} from 'rxjs'
import {pluck, distinctUntilChanged, map, switchMap, startWith, catchError} from 'rxjs/operators'
@ -16,12 +16,52 @@ import TrackComments from './TrackComments'
import TrackDetails from './TrackDetails'
import TrackMap from './TrackMap'
import styles from './TrackPage.module.scss'
function useTriggerSubject() {
const subject$ = React.useMemo(() => new Subject(), [])
const trigger = React.useCallback(() => subject$.next(null), [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 {slug} = useParams()
@ -105,60 +145,47 @@ const TrackPage = connect((state) => ({login: state.login}))(function TrackPage(
[slug, reloadComments]
)
const onDownloadOriginal = React.useCallback(
() => {
const onDownloadOriginal = React.useCallback(() => {
api.downloadFile(`/tracks/${slug}/download/original.csv`)
},
[slug]
)
}, [slug])
const isAuthor = login?.username === data?.track?.author?.username
const {track, trackData, comments} = data || {}
console.log({track, trackData})
const loading = track == null || trackData === undefined
const processing = ['processing', 'queued', 'created'].includes(track?.processingStatus)
const error = track?.processingStatus === 'error'
const [left, setLeft] = React.useState(true)
const [right, setRight] = React.useState(false)
const [leftUnconfirmed, setLeftUnconfirmed] = React.useState(false)
const [rightUnconfirmed, setRightUnconfirmed] = React.useState(false)
const [showTrack, setShowTrack] = React.useState(true)
const [pointsMode, setPointsMode] = React.useState('overtakingEvents') // none|overtakingEvents|measurements
const [side, setSide] = React.useState('overtaker') // overtaker|stationary
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 && (
<Message warning>
<Message.Content>
Track data is still being processed, please reload page in a while.
</Message.Content>
<Message.Content>Track data is still being processed, please reload page in a while.</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.
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>
{track && (
<>
@ -168,43 +195,13 @@ const TrackPage = connect((state) => ({login: state.login}))(function TrackPage(
</>
)}
</Segment>
<Header as="h4">Map settings</Header>
<Table compact>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Left</Table.HeaderCell>
<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>
</div>
</div>
}
>
<Grid stackable>
<Grid.Row>
<Grid.Column width={12}>
{track?.description && (
<Segment basic>
<Header as="h2" dividing>
@ -219,6 +216,12 @@ const TrackPage = connect((state) => ({login: state.login}))(function TrackPage(
onSubmit={onSubmitComment}
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> */}
</Page>