From a68b9988c2275c021e530f75b92c92defb9ac120 Mon Sep 17 00:00:00 2001 From: Andreas Schmitz Date: Tue, 20 Mar 2018 10:19:38 +0100 Subject: [PATCH] added geolocation button --- assets/geolocation-marker-heading.png | Bin 0 -> 1673 bytes assets/geolocation-marker.png | Bin 0 -> 829 bytes .../GeoLocationButton.example.jsx | 63 +++++ .../GeoLocationButton.example.md | 8 + .../GeoLocationButton/GeoLocationButton.jsx | 265 ++++++++++++++++++ .../GeoLocationButton/GeoLocationButton.less | 60 ++++ .../GeoLocationButton.spec.jsx | 28 ++ src/Util/MathUtil/MathUtil.js | 19 ++ src/Util/MathUtil/MathUtil.spec.js | 34 +++ src/index.js | 4 + webpack.examples.common.config.js | 1 + 11 files changed, 482 insertions(+) create mode 100644 assets/geolocation-marker-heading.png create mode 100644 assets/geolocation-marker.png create mode 100644 src/Button/GeoLocationButton/GeoLocationButton.example.jsx create mode 100644 src/Button/GeoLocationButton/GeoLocationButton.example.md create mode 100644 src/Button/GeoLocationButton/GeoLocationButton.jsx create mode 100644 src/Button/GeoLocationButton/GeoLocationButton.less create mode 100644 src/Button/GeoLocationButton/GeoLocationButton.spec.jsx create mode 100644 src/Util/MathUtil/MathUtil.js create mode 100644 src/Util/MathUtil/MathUtil.spec.js diff --git a/assets/geolocation-marker-heading.png b/assets/geolocation-marker-heading.png new file mode 100644 index 0000000000000000000000000000000000000000..5edfadccce934226dcbc7c30e4b774b07cc6991c GIT binary patch literal 1673 zcmV;426p+0P)(V6D@%)Fb#LzC9 zow}1F$+BffmSjzTp6)*AKIhq1X>#D8tNZT#-TTfv=iGA@Aq2HVQ53~!niN2o6!A3x zYfb7SQ2~qmRb^jAsxE(PN=>lbtpT+GcG*S(i>9%aTDw;Fc1WlqL3Y3a)W_rT-^b(e zcY%7@XDp_e%=Sox5W7@Dt$-6~0-g?qLg$5)P$+aBcv?Pl%CX8`j$(cblL@TA0YC!| zrZzUl3Rc&50qV-Rdrggv1HeNd1N;vt1G`MJ*A#ofuJQ5lGmUO{`?XsS)N8jMsEuxS z`}p|y8NemSSmm7E=GbEbiyZ6*d~I!QU72idbs?G4)wgGb`u40?NaplRHn-Z=*4731 z^-qE-Y{1iUf1qSm{do^UvPLE5;k)~;2PZHg*)TwBepZ~zayFY3Z zmyRFeaJ^mmVm*)3rYb*dZ+muodwb%}ojWO@#G`23=eLJ|V~Ip!QV1b#E^is`E7nt~ z<@Ho*+4yffOC%DLz%e;@$FB`L+o(pAcWi8I(3o7-_0r7D%xHgq|I5IOz>EF;{V&hV z%#4i`;oBf$5Ehlek2Zf-6LlN9xeDC!k+m)P9gTpS)Az6d-g=Qc`~ zRgte-C+&0a=FOWIdwP0)r6`K^t8|gyO~;vy)g3>zf}~)`saVG(f{5eQ!80w*%G<{T|g{bVlJLz zvB4sbCZ*k396Y+SAcH;n&0nlou8k7Pt!EtpXOHi<-~vY23$?~ zW8V>e5%7}9WWMU?==f&ixm<3=Zc%vN>wIGH^Ij)*i$X4!TbZ7o-U2Er)$l8A zY;61+py!D8#9(8NWRw71rRvbdNF*`^aP4FZPigkYhCZd)xpuMz0Lj=%LK@X@ZcHQ+ zBVMn!>mT>iycGTq(GaVu@VCKd`0;ldSY2J6Iehr=&wvdeYcBZIcszdh^y$-Q z{LRj~m!3Juos}%f?Q%`%_YOJu`&mET-_|IVN}0=-FAps&EF`FAxB^rYvy1A{a&%;5 zsuFO+CHS$ywd=Xj4DME-^^{|adOSsVpG{ zsU=LuzYs#~RGTKav4BMajkfu!pi3>A+7(O{XRR{Utj0pM?jXjMVdqX_E@S@>+${xS Tjt zp$~i@2|TZS^9?*-O+?T%O#_moRQdozB?ajGsTk-0ZIwvfcK`uIMC2f-D%@TMFD&*#Y)cI_kxO0u2RafKlL-<2cimN~Kt<)x1`#)eu#!R;y8~)x1ij zQgj?=8aSnTGRiQLuS=;_Wq>hY!gbvn&1UnH9H>^*kyg|>_(+<~<|o&6ZvYdjH>0c+ z>a#8N)(Ow^9>sASiHOwvJ-M^|Mb18bFUEs6Vmx>wXP>^8JIi0B=I@D!NF2wJ=XsA5 zKB}yAvf2T_a$WcQ+}zw_P1B6|weMVe{t>Slu=z707KjBle@48n|Ki!&7DosBm>wO} zbGh7wbUOWdb#?Wd+W%MsY`}6HC%eAB{@k|h@%gpy+45Kr^Ca_CIHIVhf{ztG&FP?;MLclYc#2(M;Qi?(gCedC}d6390}Vo?;7Eu;dhOY zupMKX=18GX$O4-F&#wP09gtF~)Cj|H%QSS(WX(e(XR;=yp%aGTR;g5K0HUJ?%J%m5 z2Y^e*tV6>|k22aoM^_iS)oS%E!2OG392rP;jUE|DasT2N0Ls_}+WT-mpI`8Of5Wl* zC|=HW4HqxxaI8Lj-`~jR^9u=_%w8=B0xZk=I5RVIZF+Q2Kc4C5Wi?=@Eq`6}b+@b(sDJzP{T|T4;%800000NkvXX Hu0mjfcPoHH literal 0 HcmV?d00001 diff --git a/src/Button/GeoLocationButton/GeoLocationButton.example.jsx b/src/Button/GeoLocationButton/GeoLocationButton.example.jsx new file mode 100644 index 0000000000..9a154081d5 --- /dev/null +++ b/src/Button/GeoLocationButton/GeoLocationButton.example.jsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { render } from 'react-dom'; + +import OlMap from 'ol/map'; +import OlView from 'ol/view'; +import OlLayerTile from 'ol/layer/tile'; +import OlSourceOsm from 'ol/source/osm'; +import olProj from 'ol/proj'; + +import { + GeoLocationButton, + ToggleGroup +} from '../../index.js'; + +// +// ***************************** SETUP ***************************************** +// +const map = new OlMap({ + layers: [ + new OlLayerTile({ + name: 'OSM', + source: new OlSourceOsm() + }) + ], + view: new OlView({ + center: olProj.fromLonLat([37.40570, 8.81566]), + zoom: 4 + }) +}); + +// +// ***************************** SETUP END ************************************* +// +render( +
+
+ +
+ + undefined} + map={map} + showMarker={true} + follow={true} + > + Track location + + + +
+ +
, + + // Target + document.getElementById('exampleContainer'), + + // Callback + () => { + map.setTarget('map'); + } +); diff --git a/src/Button/GeoLocationButton/GeoLocationButton.example.md b/src/Button/GeoLocationButton/GeoLocationButton.example.md new file mode 100644 index 0000000000..0cdc834335 --- /dev/null +++ b/src/Button/GeoLocationButton/GeoLocationButton.example.md @@ -0,0 +1,8 @@ +--- +layout: basic.hbs +title: GeoLocationButton example +description: This is an example showing a geolocation toggle button. +collection: Examples +--- + +This demonstrates the use of the geolocation button. diff --git a/src/Button/GeoLocationButton/GeoLocationButton.jsx b/src/Button/GeoLocationButton/GeoLocationButton.jsx new file mode 100644 index 0000000000..5b60faf3ac --- /dev/null +++ b/src/Button/GeoLocationButton/GeoLocationButton.jsx @@ -0,0 +1,265 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import OlMap from 'ol/map'; +import OlGeolocation from 'ol/geolocation'; +import OlGeomLineString from 'ol/geom/linestring'; +import OlOverlay from 'ol/overlay'; + +import ToggleButton from '../ToggleButton/ToggleButton.jsx'; +import { MathUtil } from '../../index'; + +import './GeoLocationButton.less'; +import mapMarker from '../../../assets/geolocation-marker.png'; +import mapMarkerHeading from '../../../assets/geolocation-marker-heading.png'; + +/** + * The GeoLocationButton. + * + * @class The GeoLocationButton + * @extends React.Component + */ +class GeoLocationButton extends React.Component { + + /** + * The className added to this component. + * + * @type {String} + * @private + */ + className = 'react-geo-geolocationbutton'; + + /** + * The properties. + * @type {Object} + */ + static propTypes = { + /** + * The className which should be added. + * + * @type {String} + */ + className: PropTypes.string, + + /** + * Instance of OL map this component is bound to. + * + * @type {OlMap} + */ + map: PropTypes.instanceOf(OlMap).isRequired, + + /** + * Will be called if geolocation fails. + * + * @type {Function} + */ + onError: PropTypes.func, + + /** + * Will be called when position changes. Receives an object with the properties + * position, accuracy, heading and speed + * + * @type {Function} + */ + onGeolocationChange: PropTypes.func, + + /** + * Whether to show a map marker at the current position. + * + * @type {Boolean} + */ + showMarker: PropTypes.bool, + + /** + * Whether to follow the current position. + * + * @type {Boolean} + */ + follow: PropTypes.bool + }; + + /** + * The default properties. + * @type {Object} + */ + static defaultProps = { + onGeolocationChange: () => undefined, + onError: () => undefined, + showMarker: true, + follow: true + } + + /** + * Creates the MeasureButton. + * + * @constructs MeasureButton + */ + constructor(props) { + super(props); + + this.positions = new OlGeomLineString([], 'XYZM'); + + this.state = { + }; + } + + onGeolocationChange = () => { + const position = this.geolocationInteraction.getPosition(); + const accuracy = this.geolocationInteraction.getAccuracy(); + let heading = this.geolocationInteraction.getHeading() || 0; + const speed = this.geolocationInteraction.getSpeed() || 0; + + const x = position[0]; + const y = position[1]; + const fCoords = this.positions.getCoordinates(); + const previous = fCoords[fCoords.length - 1]; + const prevHeading = previous && previous[2]; + if (prevHeading) { + let headingDiff = heading - MathUtil.mod(prevHeading); + + // force the rotation change to be less than 180° + if (Math.abs(headingDiff) > Math.PI) { + var sign = (headingDiff >= 0) ? 1 : -1; + headingDiff = -sign * (2 * Math.PI - Math.abs(headingDiff)); + } + heading = prevHeading + headingDiff; + } + this.positions.appendCoordinate([x, y, heading, Date.now()]); + + // only keep the 20 last coordinates + this.positions.setCoordinates(this.positions.getCoordinates().slice(-20)); + + if (this.markerEl) { + if (heading && speed) { + this.markerEl.src = mapMarkerHeading; + } else { + this.markerEl.src = mapMarker; + } + } + + this.updateView(); + + this.props.onGeolocationChange({ + position, + accuracy, + heading, + speed + }); + } + + onGeolocationError = () => { + this.props.onError(); + } + + /** + * Called when the button is toggled, this method ensures that everything + * is cleaned up when unpressed, and that geolocating can start when pressed. + * + * @method + */ + onToggle = (pressed) => { + if (!pressed && this.geolocationInteraction) { + this.geolocationInteraction.un('change', this.onGeolocationChange); + this.geolocationInteraction = null; + if (this.marker) { + this.props.map.removeOverlay(this.marker); + this.markerEl.parentNode.removeChild(this.markerEl); + } + } + if (!pressed) { + return; + } + const map = this.props.map; + const view = map.getView(); + + // Geolocation Control + this.geolocationInteraction = new OlGeolocation({ + projection: view.getProjection(), + trackingOptions: { + maximumAge: 10000, + enableHighAccuracy: true, + timeout: 600000 + } + }); + this.geolocationInteraction.setTracking(true); + if (this.props.showMarker) { + this.markerEl = document.getElementById('react-geolocation-overlay').cloneNode(); + this.markerEl.id = null; + this.marker = new OlOverlay({ + positioning: 'center-center', + element: this.markerEl, + stopEvent: false + }); + this.props.map.addOverlay(this.marker); + } + + // add listeners + this.geolocationInteraction.on('change', this.onGeolocationChange); + this.geolocationInteraction.on('error', this.onGeolocationError); + } + + // recenters the view by putting the given coordinates at 3/4 from the top or + // the screen + getCenterWithHeading = (position, rotation, resolution) => { + var size = this.props.map.getSize(); + var height = size[1]; + + return [ + position[0] - Math.sin(rotation) * height * resolution * 1 / 4, + position[1] + Math.cos(rotation) * height * resolution * 1 / 4 + ]; + } + + updateView = () => { + const view = this.props.map.getView(); + const deltaMean = 500; // the geolocation sampling period mean in ms + let previousM = 0; + // use sampling period to get a smooth transition + let m = Date.now() - deltaMean * 1.5; + m = Math.max(m, previousM); + previousM = m; + // interpolate position along positions LineString + const c = this.positions.getCoordinateAtM(m, true); + if (c) { + if (this.props.follow) { + view.setCenter(this.getCenterWithHeading(c, -c[2], view.getResolution())); + view.setRotation(-c[2]); + } + if (this.props.showMarker) { + this.marker.setPosition(c); + } + } + } + + /** + * The render function. + */ + render() { + const { + className, + map, + showMarker, + follow, + onGeolocationChange, + onError, + ...passThroughProps + } = this.props; + + const finalClassName = className + ? `${className} ${this.className}` + : this.className; + + return ( +
+ + +
+ ); + } +} + +export default GeoLocationButton; diff --git a/src/Button/GeoLocationButton/GeoLocationButton.less b/src/Button/GeoLocationButton/GeoLocationButton.less new file mode 100644 index 0000000000..f417381b9e --- /dev/null +++ b/src/Button/GeoLocationButton/GeoLocationButton.less @@ -0,0 +1,60 @@ +@import '../../style/variables.less'; + +button.react-geo-measurebutton { + color: #fff; + background-color: @primary-color; + border-color: darken(@component-background, 10); + + &:focus{ + background-color: lighten(@primary-color, 10); + border-color: lighten(@component-background, 10); + } + + &:hover { + background-color: lighten(@primary-color, 10); + border-color: lighten(@component-background, 10); + } + + &:disabled { + background-color: @component-background; + border-color: darken(@component-background, 10); + } + + &.btn-pressed { + background-color: darken(@primary-color, 20); + border-color: darken(@component-background, 10); + } +} + +.react-geo-measure-tooltip { + position: relative; + background: @measure-dynamic-tooltip-background-color; + border-radius: 4px; + color: white; + padding: 4px 8px; + opacity: 0.7; + white-space: nowrap; +} +.react-geo-measure-tooltip-dynamic { + opacity: 1; + font-weight: bold; +} +.react-geo-measure-tooltip-static { + background-color: @measure-static-tooltip-background-color; + color: black; + border: 1px solid white; +} +.react-geo-measure-tooltip-dynamic:before, +.react-geo-measure-tooltip-static:before { + border-top: 6px solid @measure-dynamic-tooltip-background-color; + border-right: 6px solid transparent; + border-left: 6px solid transparent; + content: ""; + position: absolute; + bottom: -6px; + margin-left: -7px; + left: 50%; +} +.react-geo-measure-tooltip-static:before { + border-top-color: @measure-static-tooltip-background-color; +} diff --git a/src/Button/GeoLocationButton/GeoLocationButton.spec.jsx b/src/Button/GeoLocationButton/GeoLocationButton.spec.jsx new file mode 100644 index 0000000000..31f7d731b3 --- /dev/null +++ b/src/Button/GeoLocationButton/GeoLocationButton.spec.jsx @@ -0,0 +1,28 @@ +/*eslint-env jest*/ +import TestUtil from '../../Util/TestUtil'; + +import { GeoLocationButton } from '../../index'; + +describe('', () => { + + let map; + + beforeEach(() => { + map = TestUtil.createMap(); + }); + + describe('#Basics', () => { + + it('is defined', () => { + expect(GeoLocationButton).not.toBeUndefined(); + }); + + it('can be rendered', () => { + const wrapper = TestUtil.mountComponent(GeoLocationButton, { + map: map + }); + expect(wrapper).not.toBeUndefined(); + }); + + }); +}); diff --git a/src/Util/MathUtil/MathUtil.js b/src/Util/MathUtil/MathUtil.js new file mode 100644 index 0000000000..93756e50bc --- /dev/null +++ b/src/Util/MathUtil/MathUtil.js @@ -0,0 +1,19 @@ +/** + * Helper Class for various calculations. + * + * @class MathUtil + */ +class MathUtil { + + // convert radians to degrees + static radToDeg = (rad) => rad * 360 / (Math.PI * 2); + + // convert degrees to radians + static degToRad = (deg) => deg * Math.PI * 2 / 360; + + // modulo for negative values + static mod = (n) => ((n % (2 * Math.PI)) + (2 * Math.PI)) % (2 * Math.PI); + +} + +export default MathUtil; diff --git a/src/Util/MathUtil/MathUtil.spec.js b/src/Util/MathUtil/MathUtil.spec.js new file mode 100644 index 0000000000..67390040a4 --- /dev/null +++ b/src/Util/MathUtil/MathUtil.spec.js @@ -0,0 +1,34 @@ +/*eslint-env jest*/ + +import { + MathUtil, +} from '../../index'; + +describe('MathUtil', () => { + + describe('Basics', () => { + it('is defined', () => { + expect(MathUtil).toBeDefined(); + }); + }); + + describe('Static methods', () => { + describe('#mod', () => { + it('properly handles negative values', () => { + expect(MathUtil.mod(-1.4)).toBeCloseTo(4.883185307179); + }); + }); + + describe('#radToDeg', () => { + it('converts radians to degrees', () => { + expect(MathUtil.radToDeg(Math.PI)).toBeCloseTo(180); + }); + }); + + describe('#degToRad', () => { + it('converts degrees to radians', () => { + expect(MathUtil.degToRad(180)).toBeCloseTo(Math.PI); + }); + }); + }); +}); diff --git a/src/index.js b/src/index.js index c08c7692d0..12ebeaa1ed 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ import SimpleButton from './Button/SimpleButton/SimpleButton.jsx'; import ToggleButton from './Button/ToggleButton/ToggleButton.jsx'; import ToggleGroup from './Button/ToggleGroup/ToggleGroup.jsx'; import MeasureButton from './Button/MeasureButton/MeasureButton.jsx'; +import GeoLocationButton from './Button/GeoLocationButton/GeoLocationButton.jsx'; import DigitizeButton from './Button/DigitizeButton/DigitizeButton.jsx'; import CoordinateReferenceSystemCombo from './Field/CoordinateReferenceSystemCombo/CoordinateReferenceSystemCombo.jsx'; import NominatimSearch from './Field/NominatimSearch/NominatimSearch.jsx'; @@ -31,6 +32,7 @@ import AnimateUtil from './Util/AnimateUtil/AnimateUtil'; import FeatureUtil from './Util/FeatureUtil/FeatureUtil'; import GeometryUtil from './Util/GeometryUtil/GeometryUtil'; import MapUtil from './Util/MapUtil/MapUtil'; +import MathUtil from './Util/MathUtil/MathUtil'; import MeasureUtil from './Util/MeasureUtil/MeasureUtil'; import ObjectUtil from './Util/ObjectUtil/ObjectUtil'; import ProjectionUtil from './Util/ProjectionUtil/ProjectionUtil'; @@ -52,6 +54,7 @@ export { ToggleButton, ToggleGroup, MeasureButton, + GeoLocationButton, DigitizeButton, LayerTree, LayerTreeNode, @@ -74,6 +77,7 @@ export { GeometryUtil, Logger, MapUtil, + MathUtil, MeasureUtil, CoordinateReferenceSystemCombo, NominatimSearch, diff --git a/webpack.examples.common.config.js b/webpack.examples.common.config.js index 03f740e1ad..c0d9aa4e3a 100644 --- a/webpack.examples.common.config.js +++ b/webpack.examples.common.config.js @@ -9,6 +9,7 @@ const config = { 'Button/ToggleButton/ToggleButton': './src/Button/ToggleButton/ToggleButton.example.jsx', 'Button/ToggleGroup/ToggleGroup': './src/Button/ToggleGroup/ToggleGroup.example.jsx', 'Button/MeasureButton/MeasureButton': './src/Button/MeasureButton/MeasureButton.example.jsx', + 'Button/GeoLocationButton/GeoLocationButton': './src/Button/GeoLocationButton/GeoLocationButton.example.jsx', 'Button/DigitizeButton/DigitizeButton': './src/Button/DigitizeButton/DigitizeButton.example.jsx', 'Button/UploadButton/UploadButton': './src/Button/UploadButton/UploadButton.example.jsx', 'CircleMenu/CircleMenu': './src/CircleMenu/CircleMenu.example.jsx',