diff --git a/frontend/package.json b/frontend/package.json index 2c52da7..575510a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/components/Map/index.js b/frontend/src/components/Map/index.js deleted file mode 100644 index 1255642..0000000 --- a/frontend/src/components/Map/index.js +++ /dev/null @@ -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 ( - <> -
- {map && ( - - {children} - - )} -
- - ) -} - -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 {children} - } else { - return null - } -} - -export function TileLayer({osm, ...props}) { - return ({source: new OSM(osm)})} {...props} /> -} - -export function BaseLayer(props) { - const config = useConfig() - if (!config) { - return null - } - - return ( - - ) -} - -export function VectorLayer(props) { - return -} - -export function GroupLayer(props) { - return -} - -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 diff --git a/frontend/src/components/Page/Page.module.scss b/frontend/src/components/Page/Page.module.scss index ceb337d..06304b0 100644 --- a/frontend/src/components/Page/Page.module.scss +++ b/frontend/src/components/Page/Page.module.scss @@ -14,3 +14,7 @@ .fullScreen { margin: 0; } + +.hasStage { + margin-top: 0; +} diff --git a/frontend/src/components/Page/index.tsx b/frontend/src/components/Page/index.tsx index 407c81c..e6c4c4a 100644 --- a/frontend/src/components/Page/index.tsx +++ b/frontend/src/components/Page/index.tsx @@ -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 ( -
+
+ {stage} {fullScreen ? children : {children}}
) diff --git a/frontend/src/components/RoadsLayer.tsx b/frontend/src/components/RoadsLayer.tsx deleted file mode 100644 index f7a757e..0000000 --- a/frontend/src/components/RoadsLayer.tsx +++ /dev/null @@ -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 += '
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 = '' -// -// s += -// '' -// d = feature.get('distance_overtaker_limit') -// s += '' -// -// d = feature.get('distance_overtaker_n') -// s += '' -// -// 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 += -// '' -// -// d = feature.get('distance_overtaker_mean') -// s += '' -// -// d = feature.get('distance_overtaker_median') -// s += '' -// -// d = feature.get('distance_overtaker_minimum') -// s += '' -// -// s += '
Straßenname:' + -// feature.get('name') + -// '
Mindestüberholabstand:' + (d == null ? 'n/a' : d.toFixed(2)) + ' m
Anzahl Messungen:' + (d == null ? 'n/a' : d.toFixed(0)) + '
Unterschreitung Mindestabstand:' + -// (p == null ? 'n/a' : p.toFixed(1)) + -// '% der Überholenden
Durchschnitt Überholabstand:' + (d == null ? 'n/a' : d.toFixed(2)) + ' m
Median Überholabstand:' + (d == null ? 'n/a' : d.toFixed(2)) + ' m
Minimum Überholabstand:' + (d == null ? 'n/a' : d.toFixed(2)) + ' m
' -// -// 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 -} - -// 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], '%') diff --git a/frontend/src/components/index.js b/frontend/src/components/index.js index 741b859..16bdb66 100644 --- a/frontend/src/components/index.js +++ b/frontend/src/components/index.js @@ -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' diff --git a/frontend/src/mapstyles/index.js b/frontend/src/mapstyles/index.js index 648851d..f1347b5 100644 --- a/frontend/src/mapstyles/index.js +++ b/frontend/src/mapstyles/index.js @@ -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" - ] - ] - ], - 2, - 6 - ] + ['case', ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]], 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) diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index f17239c..65262e2 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -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() {
- +
diff --git a/frontend/src/pages/MapPage.tsx b/frontend/src/pages/MapPage.tsx index 2459ed0..5fe1b20 100644 --- a/frontend/src/pages/MapPage.tsx +++ b/frontend/src/pages/MapPage.tsx @@ -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 ( © OpenStreetMap contributors', - '© OpenMapTiles', - '© OpenBikeSensor', + '© OpenStreetMap contributors', + '© OpenMapTiles', + '© OpenBikeSensor', ]} /> + + {children} ) } -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 ( - + ) } @@ -56,7 +62,7 @@ export default function MapPage() { return (
- +
) diff --git a/frontend/src/pages/TrackPage/TrackDetails.tsx b/frontend/src/pages/TrackPage/TrackDetails.tsx index 04e1455..778f07c 100644 --- a/frontend/src/pages/TrackPage/TrackDetails.tsx +++ b/frontend/src/pages/TrackPage/TrackDetails.tsx @@ -73,7 +73,7 @@ export default function TrackDetails({track, isAuthor}) { )} - {track?.processingStatus != null && ( + {track?.processingStatus != null && track?.processingStatus != 'error' && ( Processing {track.processingStatus} diff --git a/frontend/src/pages/TrackPage/TrackMap.tsx b/frontend/src/pages/TrackPage/TrackMap.tsx index 42ecca0..d06020f 100644 --- a/frontend/src/pages/TrackPage/TrackMap.tsx +++ b/frontend/src/pages/TrackPage/TrackMap.tsx @@ -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) -} - -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' +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 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 ( - - ) -} - -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[] = [] - const trackPointsD2: Feature[] = [] - const trackPointsUntaggedD1: Feature[] = [] - const trackPointsUntaggedD2: Feature[] = [] - 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 ( - - - +
+ + {showTrack && ( + + + + )} - - - - + {pointsMode !== 'none' && ( + + - - - - - - - - + {[ + ['distance_overtaker', 'right'], + ['distance_stationary', 'left'], + ].map(([p, a]) => ( + + ))} + + )} + +
) } diff --git a/frontend/src/pages/TrackPage/TrackPage.module.scss b/frontend/src/pages/TrackPage/TrackPage.module.scss new file mode 100644 index 0000000..927871a --- /dev/null +++ b/frontend/src/pages/TrackPage/TrackPage.module.scss @@ -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; +} diff --git a/frontend/src/pages/TrackPage/index.tsx b/frontend/src/pages/TrackPage/index.tsx index 6a8fcc9..99f2313 100644 --- a/frontend/src/pages/TrackPage/index.tsx +++ b/frontend/src/pages/TrackPage/index.tsx @@ -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 ( + <> +
Map settings
+ + + setShowTrack(d.checked)} /> Show track + + + Points + setPointsMode(d.value)} + options={[ + {key: 'none', value: 'none', text: 'None'}, + {key: 'overtakingEvents', value: 'overtakingEvents', text: 'Confirmed'}, + {key: 'measurements', value: 'measurements', text: 'All measurements'}, + ]} + /> + + + Side (for color) + setSide(d.value)} + options={[ + {key: 'overtaker', value: 'overtaker', text: 'Overtaker (Left)'}, + {key: 'stationary', value: 'stationary', text: 'Stationary (Right)'}, + ]} + /> + + + + ) +} + 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( - () => { - api.downloadFile(`/tracks/${slug}/download/original.csv`) - }, - [slug] - ) + const onDownloadOriginal = React.useCallback(() => { + api.downloadFile(`/tracks/${slug}/download/original.csv`) + }, [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 ( - - {processing && ( - - - Track data is still being processed, please reload page in a while. - - - )} + + + + + - {error && ( - - - The processing of this track failed, please ask your site - administrator for help in debugging the issue. - - - )} +
+ {processing && ( + + Track data is still being processed, please reload page in a while. + + )} + + {error && ( + + + The processing of this track failed, please ask your site administrator for help in debugging the + issue. + + + )} - - - -
- - - - -
-
- {track && ( <> @@ -168,58 +195,34 @@ const TrackPage = connect((state) => ({login: state.login}))(function TrackPage( )} +
+ + } + > + + + + {track?.description && ( + +
+ Description +
+ {track.description} +
+ )} -
Map settings
- - - - - Left - Show distance of - Right - - - - - - - setLeft(d.checked)} />{' '} - - Events - - setRight(d.checked)} />{' '} - - - - - setLeftUnconfirmed(d.checked)} />{' '} - - Other points - - setRightUnconfirmed(d.checked)} />{' '} - - - -
+ +
+ +
- {track?.description && ( - -
- Description -
- {track.description} -
- )} - - - {/*
{JSON.stringify(data, null, 2)}
*/}
)