From ba157893ec9af78b5b70d9ad1cc641541cbf86e0 Mon Sep 17 00:00:00 2001 From: Christian <58629404+chrstnbwnkl@users.noreply.github.com> Date: Thu, 15 Feb 2024 20:32:13 +0100 Subject: [PATCH] Improve isochrones (#213) --- .../Isochrones/ContoursInformation.jsx | 46 ++--- src/Controls/Isochrones/Waypoints/index.jsx | 170 +++++++++++++++++- src/Controls/index.jsx | 17 ++ src/Controls/settings-options.js | 27 +++ src/Controls/settings-panel.jsx | 6 +- src/Map/Map.jsx | 77 +++++--- src/actions/commonActions.js | 5 +- src/actions/isochronesActions.js | 5 +- src/reducers/isochrones.js | 4 + src/utils/geom.js | 2 +- src/utils/valhalla.js | 9 +- 11 files changed, 317 insertions(+), 51 deletions(-) diff --git a/src/Controls/Isochrones/ContoursInformation.jsx b/src/Controls/Isochrones/ContoursInformation.jsx index accc1b2..34ab990 100644 --- a/src/Controls/Isochrones/ContoursInformation.jsx +++ b/src/Controls/Isochrones/ContoursInformation.jsx @@ -26,30 +26,34 @@ class ContoursInformation extends React.Component { return ( {features ? ( - features.map((feature, key) => { - return ( -
-
- -
- {feature.properties.contour + ' minutes'} + features + .filter((feature) => !feature.properties.type) + .map((feature, key) => { + return ( +
+
+ +
+ {feature.properties.contour + ' minutes'} +
-
-
- -
- {feature.properties.area.toFixed(0) + ' km²'} +
+ +
+ {(feature.properties.area > 1 + ? feature.properties.area.toFixed(0) + : feature.properties.area.toFixed(1)) + ' km²'} +
-
- ) - }) + ) + }) ) : (
No isochrones found
)} diff --git a/src/Controls/Isochrones/Waypoints/index.jsx b/src/Controls/Isochrones/Waypoints/index.jsx index eb30301..da4b12d 100644 --- a/src/Controls/Isochrones/Waypoints/index.jsx +++ b/src/Controls/Isochrones/Waypoints/index.jsx @@ -16,6 +16,12 @@ import { clearIsos, } from 'actions/isochronesActions' +import { + denoise as denoiseParam, + generalize as generalizeParam, + settingsInit, +} from 'Controls/settings-options' + import { updatePermalink, zoomTo } from 'actions/commonActions' import { debounce } from 'throttle-debounce' @@ -115,6 +121,28 @@ class Waypoints extends Component { }) } + handleDenoiseChange = (e, { value }) => { + value = isNaN(parseFloat(value)) ? settingsInit.denoise : parseFloat(value) + + const denoiseName = 'denoise' + + this.handleIsoSliderUpdateSettings({ + denoiseName, + value, + }) + } + + handleGeneralizeChange = (e, { value }) => { + value = isNaN(parseInt(value)) ? settingsInit.generalize : parseInt(value) + + const generalizeName = 'generalize' + + this.handleIsoSliderUpdateSettings({ + generalizeName, + value, + }) + } + handleRangeChange = (e, { value }) => { value = isNaN(parseInt(value)) ? 0 : parseInt(value) if (value > 120) { @@ -132,14 +160,22 @@ class Waypoints extends Component { this.makeIsochronesRequest() } - handleIsoSliderUpdateSettings = ({ value, maxRangeName, intervalName }) => { + handleIsoSliderUpdateSettings = ({ + value, + maxRangeName, + intervalName, + denoiseName, + generalizeName, + }) => { const { dispatch } = this.props // maxRangeName can be undefined if interval is being updated dispatch( updateIsoSettings({ maxRangeName, intervalName, - value: parseInt(value), + denoiseName, + generalizeName, + value: parseFloat(value), }) ) @@ -165,8 +201,15 @@ class Waypoints extends Component { ) render() { - const { isFetching, geocodeResults, userInput, maxRange, interval } = - this.props.isochrones + const { + isFetching, + geocodeResults, + userInput, + maxRange, + interval, + denoise, + generalize, + } = this.props.isochrones const { activeIndex } = this.state const controlSettings = { @@ -192,6 +235,8 @@ class Waypoints extends Component { step: 1, }, }, + generalize: generalizeParam, + denoise: denoiseParam, } return ( @@ -378,6 +423,123 @@ class Waypoints extends Component { />
+
+ + + + {controlSettings.denoise.name} + + + } + /> +
+ } + value={denoise} + placeholder="Enter Value" + name={controlSettings.denoise.param} + onChange={this.handleDenoiseChange} + /> + +
+ { + const param = controlSettings.denoise.param + this.handleIsoSliderUpdateSettings({ + denoiseName: param, + value: e.target.value, + }) + }} + onChangeCommitted={() => { + this.makeIsochronesRequest() + }} + /> +
+
+
+ + + + {controlSettings.generalize.name} + + + } + /> +
+ } + value={generalize} + placeholder="Enter Value" + name={controlSettings.generalize.param} + onChange={this.handleGeneralizeChange} + /> + + {controlSettings.generalize.unit} + + } + /> + +
+ { + const param = controlSettings.generalize.param + this.handleIsoSliderUpdateSettings({ + generalizeName: param, + value: e.target.value, + }) + }} + onChangeCommitted={() => { + this.makeIsochronesRequest() + }} + /> +
+ diff --git a/src/Controls/index.jsx b/src/Controls/index.jsx index f67ebe2..c6e25e9 100644 --- a/src/Controls/index.jsx +++ b/src/Controls/index.jsx @@ -125,6 +125,23 @@ class MainControl extends React.Component { }) ) } + + if ('denoise' in params) { + dispatch( + updateIsoSettings({ + denoiseName: 'denoise', + value: params.denoise, + }) + ) + } + if ('generalize' in params) { + dispatch( + updateIsoSettings({ + generalizeName: 'generalize', + value: params.generalize, + }) + ) + } } }) dispatch(zoomTo(processedCoords)) diff --git a/src/Controls/settings-options.js b/src/Controls/settings-options.js index 51e0227..6d76530 100644 --- a/src/Controls/settings-options.js +++ b/src/Controls/settings-options.js @@ -646,6 +646,31 @@ const useTrails = { step: 0.1, }, } +export const denoise = { + name: 'Denoise', + description: + 'A floating point value from 0 to 1 (default of 1) which can be used to remove smaller contours. A value of 1 will only return the largest contour for a given time value. A value of 0.5 drops any contours that are less than half the area of the largest contour in the set of contours for that same time value.', + param: 'denoise', + unit: '', + settings: { + min: 0, + max: 1, + step: 0.1, + }, +} + +export const generalize = { + name: 'Generalize', + description: + 'A floating point value in meters used as the tolerance for Douglas-Peucker generalization. Note: Generalization of contours can lead to self-intersections, as well as intersections of adjacent contours.', + param: 'generalize', + unit: 'meters', + settings: { + min: 0, + max: 1000, + step: 1, + }, +} export const settingsInit = { maneuver_penalty: 5, @@ -701,6 +726,8 @@ export const settingsInit = { transit_transfer_max_distance: 800, disable_hierarchy_pruning: false, use_trails: 0, + denoise: 0.1, + generalize: 0, } export const settingsInitTruckOverride = { diff --git a/src/Controls/settings-panel.jsx b/src/Controls/settings-panel.jsx index 9f64af0..8cb3758 100644 --- a/src/Controls/settings-panel.jsx +++ b/src/Controls/settings-panel.jsx @@ -81,6 +81,7 @@ class SettingsPanel extends React.Component { activeIndexGeneral: 0, generalSettings: {}, extraSettings: {}, + isochroneSettings: {}, copied: false, } } @@ -99,7 +100,7 @@ class SettingsPanel extends React.Component { // we however only want this component to update if the // settings really change, therefor deep check with ramda shouldComponentUpdate(nextProps, nextState) { - const { settings, profile, showSettings } = this.props + const { settings, profile, showSettings, activeTab } = this.props if (!R.equals(settings, nextProps.settings)) { return true @@ -113,6 +114,9 @@ class SettingsPanel extends React.Component { if (!R.equals(this.state, nextState)) { return true } + if (!R.equals(activeTab, nextProps.activeTab)) { + return true + } return false } // we really only want to call the valhalla backend if settings have changed diff --git a/src/Map/Map.jsx b/src/Map/Map.jsx index 31e8389..fc35be9 100644 --- a/src/Map/Map.jsx +++ b/src/Map/Map.jsx @@ -55,6 +55,7 @@ const convertDDToDMS = (decimalDegrees) => // for this app we create two leaflet layer groups to control, one for the isochrone centers and one for the isochrone contours const isoCenterLayer = L.featureGroup() const isoPolygonLayer = L.featureGroup() +const isoLocationsLayer = L.featureGroup() const routeMarkersLayer = L.featureGroup() const routeLineStringLayer = L.featureGroup() const highlightRouteSegmentlayer = L.featureGroup() @@ -95,6 +96,7 @@ const mapParams = { isoCenterLayer, routeMarkersLayer, isoPolygonLayer, + isoLocationsLayer, routeLineStringLayer, highlightRouteSegmentlayer, highlightRouteIndexLayer, @@ -157,6 +159,7 @@ class Map extends React.Component { 'Isochrone Center': isoCenterLayer, Routes: routeLineStringLayer, Isochrones: isoPolygonLayer, + 'Isochrones (locations)': isoLocationsLayer, } this.layerControl = L.control.layers(baseMaps, overlayMaps).addTo(this.map) @@ -432,6 +435,7 @@ class Map extends React.Component { } if (!isochrones.successful) { isoPolygonLayer.clearLayers() + isoLocationsLayer.clearLayers() } } @@ -504,6 +508,16 @@ class Map extends React.Component { ` } + getIsoLocationTooltip = () => { + return ` +
+
+ Snapped location +
+
+ ` + } + handleHighlightSegment = () => { const { highlightSegment, results } = this.props.directions @@ -532,6 +546,7 @@ class Map extends React.Component { addIsochrones = () => { const { results } = this.props.isochrones isoPolygonLayer.clearLayers() + isoLocationsLayer.clearLayers() for (const provider of [VALHALLA_OSM_URL]) { if ( @@ -543,27 +558,47 @@ class Map extends React.Component { for (const latLng of feature.geometry.coordinates) { coords_reversed.push([latLng[1], latLng[0]]) } - - L.polygon(coords_reversed, { - ...feature.properties, - color: 'white', - weight: 2, - opacity: 1.0, - pane: 'isochronesPane', - pmIgnore: true, - }) - .bindTooltip( - this.getIsoTooltip( - feature.properties.contour, - feature.properties.area.toFixed(2), - provider - ), - { - permanent: false, - sticky: true, - } - ) - .addTo(isoPolygonLayer) + if (['Polygon', 'MultiPolygon'].includes(feature.geometry.type)) { + L.geoJSON(feature, { + style: (feat) => ({ + ...feat.properties, + color: '#fff', + opacity: 1, + }), + }) + .bindTooltip( + this.getIsoTooltip( + feature.properties.contour, + feature.properties.area.toFixed(2), + provider + ), + { + permanent: false, + sticky: true, + } + ) + .addTo(isoPolygonLayer) + } else { + // locations + + if (feature.properties.type === 'input') { + return + } + L.geoJSON(feature, { + pointToLayer: (feat, ll) => { + return L.circleMarker(ll, { + radius: 6, + color: '#000', + fillColor: '#fff', + fill: true, + fillOpacity: 1, + }).bindTooltip(this.getIsoLocationTooltip(), { + permanent: false, + sticky: true, + }) + }, + }).addTo(isoLocationsLayer) + } } } } diff --git a/src/actions/commonActions.js b/src/actions/commonActions.js index 1eec409..9757746 100644 --- a/src/actions/commonActions.js +++ b/src/actions/commonActions.js @@ -68,7 +68,8 @@ export const doUpdateDateTime = (key, value) => ({ export const updatePermalink = () => (dispatch, getState) => { const { waypoints } = getState().directions - const { geocodeResults, maxRange, interval } = getState().isochrones + const { geocodeResults, maxRange, interval, generalize, denoise } = + getState().isochrones const { profile, /*settings,*/ activeTab } = getState().common const queryParams = new URLSearchParams() queryParams.set('profile', profile) @@ -100,6 +101,8 @@ export const updatePermalink = () => (dispatch, getState) => { } queryParams.set('range', maxRange) queryParams.set('interval', interval) + queryParams.set('generalize', generalize) + queryParams.set('denoise', denoise) } window.history.replaceState(null, null, path + queryParams.toString()) } diff --git a/src/actions/isochronesActions.js b/src/actions/isochronesActions.js index d6ca95f..05bb2a3 100644 --- a/src/actions/isochronesActions.js +++ b/src/actions/isochronesActions.js @@ -28,7 +28,8 @@ const serverMapping = { } export const makeIsochronesRequest = () => (dispatch, getState) => { - const { geocodeResults, maxRange, interval } = getState().isochrones + const { geocodeResults, maxRange, interval, denoise, generalize } = + getState().isochrones const { profile } = getState().common let { settings } = getState().common @@ -53,6 +54,8 @@ export const makeIsochronesRequest = () => (dispatch, getState) => { center, settings, maxRange, + denoise, + generalize, interval, }) dispatch(fetchValhallaIsochrones(valhallaRequest)) diff --git a/src/reducers/isochrones.js b/src/reducers/isochrones.js index 389a008..684a5c6 100644 --- a/src/reducers/isochrones.js +++ b/src/reducers/isochrones.js @@ -18,6 +18,8 @@ const initialState = { selectedAddress: '', maxRange: 10, interval: 10, + denoise: 0.1, + generalize: 0, results: { [VALHALLA_OSM_URL]: { data: {}, @@ -68,6 +70,8 @@ export const isochrones = (state = initialState, action) => { ...state, [payload.maxRangeName]: payload.value, [payload.intervalName]: payload.value, + [payload.generalizeName]: payload.value, + [payload.denoiseName]: payload.value, } case UPDATE_TEXTINPUT_ISO: diff --git a/src/utils/geom.js b/src/utils/geom.js index 9ea0e56..636e96b 100644 --- a/src/utils/geom.js +++ b/src/utils/geom.js @@ -2,7 +2,7 @@ import * as turf from '@turf/turf' export const calcArea = (feature) => { try { - const polygon = turf.polygon([feature.geometry.coordinates]) + const polygon = turf.polygon(feature.geometry.coordinates) return turf.area(polygon) / 1000000 } catch (e) { return -1 diff --git a/src/utils/valhalla.js b/src/utils/valhalla.js index b8fd51b..5bff0db 100644 --- a/src/utils/valhalla.js +++ b/src/utils/valhalla.js @@ -71,6 +71,8 @@ export const buildIsochronesRequest = ({ profile, center, settings, + denoise, + generalize, maxRange, interval, }) => { @@ -78,11 +80,16 @@ export const buildIsochronesRequest = ({ if (profile === 'car') { valhalla_profile = 'auto' } + return { json: { + polygons: true, + denoise, + generalize, + show_locations: true, costing: valhalla_profile, costing_options: { - [valhalla_profile]: { ...settings }, + [valhalla_profile]: settings, }, contours: makeContours({ maxRange, interval }), locations: makeLocations([center]),