├── .babelrc ├── .gitignore ├── screenshots ├── camera.jpg ├── example.png └── crosshair.jpg ├── images ├── camera.svg ├── crosshair-icon.svg ├── crosshair.svg ├── marker.svg └── camera-icon.svg ├── rollup.config.browser.js ├── rollup.config.browser.min.js ├── css └── Leaflet.GeotagPhoto.css ├── LICENSE.md ├── package.json ├── index.js ├── src ├── Leaflet.GeotagPhoto.Crosshair.js ├── Leaflet.GeotagPhoto.CameraControl.js └── Leaflet.GeotagPhoto.Camera.js ├── examples ├── crosshair.html └── camera.html └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015-rollup"] 3 | } 4 | 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _examples 2 | node_modules 3 | dist 4 | npm-debug.log 5 | -------------------------------------------------------------------------------- /screenshots/camera.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nypl-spacetime/Leaflet.GeotagPhoto/HEAD/screenshots/camera.jpg -------------------------------------------------------------------------------- /screenshots/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nypl-spacetime/Leaflet.GeotagPhoto/HEAD/screenshots/example.png -------------------------------------------------------------------------------- /screenshots/crosshair.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nypl-spacetime/Leaflet.GeotagPhoto/HEAD/screenshots/crosshair.jpg -------------------------------------------------------------------------------- /images/camera.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /images/crosshair-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /rollup.config.browser.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel' 2 | import nodeResolve from 'rollup-plugin-node-resolve' 3 | import commonjs from 'rollup-plugin-commonjs' 4 | 5 | export default { 6 | entry: 'index.js', 7 | dest: 'dist/Leaflet.GeotagPhoto.js', 8 | format: 'iife', 9 | moduleName: 'leaflet-geotag-photo', 10 | globals: { 11 | Leaflet: 'L' 12 | }, 13 | plugins: [ 14 | nodeResolve({ 15 | jsnext: true, 16 | skip: [ 17 | 'Leaflet' 18 | ] 19 | }), 20 | commonjs(), 21 | babel({ 22 | exclude: 'node_modules/**', 23 | presets: 'es2015-rollup' 24 | }) 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /images/crosshair.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /images/marker.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /rollup.config.browser.min.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel' 2 | import uglify from 'rollup-plugin-uglify' 3 | import nodeResolve from 'rollup-plugin-node-resolve' 4 | import commonjs from 'rollup-plugin-commonjs' 5 | 6 | export default { 7 | entry: 'index.js', 8 | dest: 'dist/Leaflet.GeotagPhoto.min.js', 9 | format: 'iife', 10 | moduleName: 'leaflet-geotag-photo', 11 | sourceMap: true, 12 | globals: { 13 | Leaflet: 'L' 14 | }, 15 | plugins: [ 16 | nodeResolve({ 17 | jsnext: true, 18 | skip: [ 19 | 'Leaflet' 20 | ] 21 | }), 22 | commonjs(), 23 | babel({ 24 | exclude: 'node_modules/**', 25 | presets: 'es2015-rollup' 26 | }), 27 | uglify() 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /css/Leaflet.GeotagPhoto.css: -------------------------------------------------------------------------------- 1 | .leaflet-geotag-photo-crosshair { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100%; 7 | pointer-events: none; 8 | overflow: hidden; 9 | z-index: 999; 10 | 11 | display: -webkit-box; 12 | display: -moz-box; 13 | display: -ms-flexbox; 14 | display: -webkit-flex; 15 | display: flex; 16 | -webkit-box-align: center; 17 | -webkit-flex-align: center; 18 | -ms-flex-align: center; 19 | -webkit-align-items: center; 20 | align-items: center; 21 | } 22 | 23 | .leaflet-geotag-photo-crosshair > * { 24 | position: relative; 25 | z-index: 1001; 26 | margin: auto; 27 | } 28 | 29 | .leaflet-control-geotag-photo-camera, 30 | .leaflet-control-geotag-photo-crosshair { 31 | width: 100%; 32 | padding: 3px; 33 | box-sizing: border-box; 34 | } -------------------------------------------------------------------------------- /images/camera-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 The New York Public Library 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "leaflet-geotag-photo", 3 | "version": "0.6.2", 4 | "description": "Leaflet plugin for photo geotagging", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/nypl-spacetime/Leaflet.GeotagPhoto" 8 | }, 9 | "files": [ 10 | "dist", 11 | "src", 12 | "index.js", 13 | "images" 14 | ], 15 | "main": "dist/Leaflet.GeotagPhoto.js", 16 | "browser": "dist/Leaflet.GeotagPhoto.min.js", 17 | "module": "index", 18 | "jsnext:main": "index", 19 | "scripts": { 20 | "prepublish": "npm run test", 21 | "pretest": "rimraf dist && mkdir dist && npm run build", 22 | "build": "cp css/* dist && ./node_modules/.bin/rollup -c rollup.config.browser.js && ./node_modules/.bin/rollup -c rollup.config.browser.min.js", 23 | "test": "npm run lint", 24 | "lint": "standard *.js src/*" 25 | }, 26 | "author": "Bert Spaan", 27 | "license": "MIT", 28 | "devDependencies": { 29 | "babel-preset-es2015-rollup": "^1.2.0", 30 | "rimraf": "^2.5.4", 31 | "rollup": "^0.36.4", 32 | "rollup-plugin-babel": "^2.6.1", 33 | "rollup-plugin-commonjs": "^5.0.5", 34 | "rollup-plugin-node-resolve": "^2.0.0", 35 | "rollup-plugin-uglify": "^1.0.1", 36 | "standard": "^8.6.0", 37 | "tap": "^8.0.1" 38 | }, 39 | "dependencies": { 40 | "field-of-view": "^0.2.2", 41 | "leaflet": "^1.0.2" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import L from 'Leaflet' 4 | import GeotagPhotoCrosshair from './src/Leaflet.GeotagPhoto.Crosshair' 5 | import GeotagPhotoCamera from './src/Leaflet.GeotagPhoto.Camera' 6 | 7 | // Object.assign polyfill, for IE<=11. From: 8 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#Polyfill 9 | // TODO: I'm sure Babel can add this polyfill, too. 10 | if (typeof Object.assign !== 'function') { 11 | Object.assign = function (target, varArgs) { 12 | if (target == null) { 13 | throw new TypeError('Cannot convert undefined or null to object') 14 | } 15 | 16 | var to = Object(target) 17 | 18 | for (var index = 1; index < arguments.length; index++) { 19 | var nextSource = arguments[index] 20 | 21 | if (nextSource != null) { 22 | for (var nextKey in nextSource) { 23 | if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) { 24 | to[nextKey] = nextSource[nextKey] 25 | } 26 | } 27 | } 28 | } 29 | return to 30 | } 31 | } 32 | 33 | L.geotagPhoto = { 34 | crosshair: function (options) { 35 | return new GeotagPhotoCrosshair(options) 36 | }, 37 | camera: function (feature, options) { 38 | return new GeotagPhotoCamera(feature, options) 39 | } 40 | } 41 | 42 | L.GeotagPhoto = { 43 | Crosshair: GeotagPhotoCrosshair, 44 | Camera: GeotagPhotoCamera 45 | } 46 | -------------------------------------------------------------------------------- /src/Leaflet.GeotagPhoto.Crosshair.js: -------------------------------------------------------------------------------- 1 | import L from 'leaflet' 2 | 3 | export default L.Evented.extend({ 4 | options: { 5 | controlCrosshairImg: '../images/crosshair-icon.svg', 6 | crosshairHTML: 'Center of the map; crosshair location' 7 | }, 8 | 9 | initialize: function (options) { 10 | L.setOptions(this, options) 11 | }, 12 | 13 | addTo: function (map) { 14 | this._map = map 15 | var container = map.getContainer() 16 | this._element = L.DomUtil.create('div', 'leaflet-geotag-photo-crosshair', container) 17 | this._element.innerHTML = this.options.crosshairHTML 18 | 19 | this._boundOnInput = this._onInput.bind(this) 20 | this._boundOnChange = this._onChange.bind(this) 21 | 22 | this._map.on('move', this._boundOnInput) 23 | this._map.on('moveend', this._boundOnChange) 24 | 25 | return this 26 | }, 27 | 28 | removeFrom: function (map) { 29 | if (this._map && this._boundOnInput && this._boundOnChange) { 30 | this._map.off('move', this._boundOnInput) 31 | this._map.off('moveend', this._boundOnChange) 32 | } 33 | 34 | if (this._element) { 35 | L.DomUtil.remove(this._element) 36 | } 37 | 38 | return this 39 | }, 40 | 41 | _onInput: function () { 42 | this.fire('input') 43 | }, 44 | 45 | _onChange: function () { 46 | this.fire('change') 47 | }, 48 | 49 | getCrosshairLatLng: function () { 50 | return this._map.getCenter() 51 | }, 52 | 53 | getCrosshairPoint: function () { 54 | if (this._map) { 55 | var center = this.getCrosshairLatLng() 56 | return { 57 | type: 'Point', 58 | coordinates: [ 59 | center.lng, 60 | center.lat 61 | ] 62 | } 63 | } 64 | } 65 | 66 | }) 67 | -------------------------------------------------------------------------------- /src/Leaflet.GeotagPhoto.CameraControl.js: -------------------------------------------------------------------------------- 1 | import L from 'leaflet' 2 | 3 | export default L.Control.extend({ 4 | options: { 5 | position: 'topleft' 6 | }, 7 | 8 | initialize: function (geotagPhotoCamera, options) { 9 | this._geotagPhotoCamera = geotagPhotoCamera 10 | L.setOptions(this, options) 11 | }, 12 | 13 | onAdd: function (map) { 14 | this._map = map 15 | 16 | var controlName = 'leaflet-control-geotag-photo-' 17 | var container = L.DomUtil.create('div', controlName + ' leaflet-bar') 18 | 19 | var cameraImg = '' 20 | var crosshairImg = '' 21 | 22 | this._cameraButton = this._createButton(cameraImg, 'Move camera back to map (C)', 23 | controlName + 'camera', container, this._centerCamera) 24 | 25 | this._crosshairButton = this._createButton(crosshairImg, 'Move map back to camera (M)', 26 | controlName + 'crosshair', container, this._centerMap) 27 | 28 | this._boundMapKeyPress = this._mapKeyPress.bind(this) 29 | this._map.on('keypress', this._boundMapKeyPress) 30 | 31 | return container 32 | }, 33 | 34 | _createButton: function (html, title, className, container, fn) { 35 | var link = L.DomUtil.create('a', className, container) 36 | link.innerHTML = html 37 | link.href = '#' 38 | link.title = title 39 | 40 | /* 41 | * Will force screen readers like VoiceOver to read this as "Zoom in - button" 42 | */ 43 | link.setAttribute('role', 'button') 44 | link.setAttribute('aria-label', title) 45 | 46 | L.DomEvent 47 | .on(link, 'mousedown dblclick', L.DomEvent.stopPropagation) 48 | .on(link, 'click', L.DomEvent.stop) 49 | .on(link, 'click', fn, this) 50 | .on(link, 'click', this._refocusOnMap, this) 51 | 52 | return link 53 | }, 54 | 55 | onRemove: function (map) { 56 | L.DomUtil.remove(this._element) 57 | map.off('keypress', this._boundMapKeyPress) 58 | }, 59 | 60 | _mapKeyPress: function (evt) { 61 | if (evt.originalEvent.charCode === 99) { 62 | // C key 63 | this._centerCamera() 64 | } else if (evt.originalEvent.charCode === 109) { 65 | // M key 66 | this._centerMap() 67 | } 68 | }, 69 | 70 | _centerCamera: function () { 71 | if (this._map && this._geotagPhotoCamera) { 72 | this._geotagPhotoCamera.centerBounds(this._map.getBounds()) 73 | } 74 | }, 75 | 76 | _centerMap: function () { 77 | if (this._map && this._geotagPhotoCamera) { 78 | this._map.fitBounds(this._geotagPhotoCamera.getBounds()) 79 | } 80 | } 81 | 82 | }) 83 | -------------------------------------------------------------------------------- /examples/crosshair.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Crosshair - Leaflet.GeotagPhoto 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 67 | 68 | 69 |
70 |
71 |
72 |
73 | 83 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /examples/camera.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Camera - Leaflet.GeotagPhoto 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 94 | 95 | 96 |
97 |
98 |
99 |
100 | 101 | 102 | 103 |
104 |
105 | 115 | 189 | 190 | 191 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Leaflet.GeotagPhoto 2 | 3 | Leaflet plugin for photo geotagging. 4 | 5 | Examples: 6 | 7 | - [Crosshair mode](http://spacetime.nypl.org/Leaflet.GeotagPhoto/examples/crosshair.html) 8 | - [Camera mode](http://spacetime.nypl.org/Leaflet.GeotagPhoto/examples/camera.html) 9 | 10 | [![Screenshot of camera module](screenshots/example.png)](http://spacetime.nypl.org/Leaflet.GeotagPhoto/examples/camera.html) 11 | 12 | Leaflet.GeotagPhoto is part of The New York Public Library's [NYC Space/Time Directory](http://spacetime.nypl.org/). 13 | 14 | You can also find Leaflet.GeotagPhoto on [Leaflet's plugin page](http://leafletjs.com/plugins.html). 15 | 16 | ## Usage 17 | 18 | Include the following HTML in your page's `` tag: 19 | 20 | ```html 21 | 22 | 23 | ``` 24 | 25 | The HTML above links to the latest version of Leaflet.GeotagPhoto. In production, you should link to a specific version, to prevent newer versions breaking your application: 26 | 27 | ```html 28 | 29 | 30 | ``` 31 | 32 | ## Modes 33 | 34 | ### `L.GeotagPhoto.Crosshair` 35 | 36 | ![Crosshair mode](screenshots/crosshair.jpg) 37 | 38 | #### Example 39 | 40 | ```js 41 | L.geotagPhoto.crosshair().addTo(map) 42 | .on('input', function (event) { 43 | var point = this.getCrosshairPoint() 44 | }) 45 | ``` 46 | 47 | #### API 48 | 49 | `L.GeotagPhoto.Crosshair` extends [L.Evented](http://leafletjs.com/reference-1.0.0.html#evented). 50 | 51 | | Function | Description | 52 | |:------------------------------------|:------------------------------------------------| 53 | | `L.geotagPhoto.crosshair(options?)` | Creation | 54 | | `addTo (map)` | Add `L.GeotagPhoto.Crosshair` to `map` | 55 | | `removeFrom (map)` | Remove `L.GeotagPhoto.Crosshair` from `map` | 56 | | `getCrosshairLatLng ()` | Returns crosshair's [`LatLng`](http://leafletjs.com/reference-1.0.0.html#latlng) | 57 | | `getCrosshairPoint ()` | Returns crosshair's GeoJSON Point | 58 | 59 | #### Options 60 | 61 | | Option | Type | Default | Description 62 | |:----------------|:--------------|:------------------------------------------------------|:-------------| 63 | | `crosshairHTML` | `HTML String` | `` | HTML string of crosshair element | 64 | | `controlCrosshairImg` | `url` | `../images/crosshair.svg` | Crosshair image URL used by the default `crosshairHTML` | 65 | 66 | ### `L.GeotagPhoto.Camera` 67 | 68 | ![Camera mode](screenshots/camera.jpg) 69 | 70 | #### Example 71 | 72 | ```js 73 | var cameraPoint = [6.83442, 52.43369] 74 | var targetPoint = [6.83342, 52.43469] 75 | 76 | var points = { 77 | type: 'Feature', 78 | properties: { 79 | angle: 20 80 | }, 81 | geometry: { 82 | type: 'GeometryCollection', 83 | geometries: [ 84 | { 85 | type: 'Point', 86 | coordinates: cameraPoint 87 | }, 88 | { 89 | type: 'Point', 90 | coordinates: targetPoint 91 | } 92 | ] 93 | } 94 | } 95 | 96 | var options = { 97 | draggable: true 98 | } 99 | 100 | L.geotagPhoto.camera(points, options).addTo(map) 101 | .on('change', function (event) { 102 | // Get camera field of view 103 | // See: 104 | // https://github.com/nypl-spacetime/field-of-view#output 105 | var fieldOfView = this.getFieldOfView() 106 | }) 107 | ``` 108 | 109 | #### API 110 | 111 | `L.GeotagPhoto.Camera` extends [L.FeatureGroup](http://leafletjs.com/reference-1.0.0.html#featuregroup). 112 | 113 | | Function | Description | 114 | |:------------------------------------------|:------------------------------------------------| 115 | | `L.geotagPhoto.camera(feature, options?)` | Creation, `feature` is [input for field-of-view](https://github.com/nypl-spacetime/field-of-view#input) | 116 | | `getFieldOfView ()` | Returns [field of view of camera](https://github.com/nypl-spacetime/field-of-view#output) | 117 | | `getCameraLatLng ()` | Returns camera's [`LatLng`](http://leafletjs.com/reference-1.0.0.html#latlng) | 118 | | `getTargetLatLng ()` | Returns target's [`LatLng`](http://leafletjs.com/reference-1.0.0.html#latlng) | 119 | | `getCameraPoint ()` | Returns camera's GeoJSON Point | 120 | | `getTargetPoint ()` | Returns target's GeoJSON Point | 121 | | `getCenter ()` | Returns [`LatLng`](http://leafletjs.com/reference-1.0.0.html#latlng) of point halfway camera and target | 122 | | `getBounds ()` | Returns [`LatLngBounds`](http://leafletjs.com/reference-1.0.0.html#latlngbounds) of field of view triangle | 123 | | `centerBounds (bounds)` | Moves camera and target so their center lies in the middle of `bounds` | 124 | | `setAngle (angle)` | Set angle of view | 125 | | `setCameraLatLng (latLng)` | Set `LatLng` of camera | 126 | | `setTargetLatLng (latLng)` | Set `LatLng` of target | 127 | | `setCameraAndTargetLatLng (cameraLatLng, targetLatLng)` | Set `LatLng` of camera and `LatLng` of target | 128 | | `setDraggable (boolean)` | Toggle between static or draggable camera | 129 | 130 | #### Options 131 | 132 | | Option | Type | Default | Description 133 | |:----------------------|:----------|--------:|:----------------------------------------------------------| 134 | | `draggable` | `Boolean` | `true` | Whether the camera is draggable with mouse/touch or not | 135 | | `angleMarker` | `Boolean` | `true` | Whether the angle of the field-of-view can be changed with a draggable marker 136 | | `minAngle` | `Number` | 5 | Minimum angle of field-of-view 137 | | `maxAngle` | `Number` | 120 | Maximum angle of field-of-view 138 | | `cameraIcon` | [`L.Icon`](http://leafletjs.com/reference-1.0.2.html#icon) | See below | Camera icon 139 | | `targetIcon` | [`L.Icon`](http://leafletjs.com/reference-1.0.2.html#icon) | See below | Target icon 140 | | `angleIcon` | [`L.Icon`](http://leafletjs.com/reference-1.0.2.html#icon) | See below | Angle icon 141 | | `outlineStyle` | [`L.Path options`](http://leafletjs.com/reference-1.0.2.html#path-option) | See below | Style of field-of-view triangle's outline 142 | | `fillStyle` | [`L.Path options`](http://leafletjs.com/reference-1.0.2.html#path-option) | See below | Style of field-of-view triangle's fill polygon 143 | | `control` | `Boolean` | `true` | Whether to show camera control buttons | 144 | | `controlCameraImg` | `String` | `../images/camera-icon.svg` | URL to icon displayed in camera control button 145 | | `controlCrosshairImg` | `String` | `../images/crosshair-icon.svg` | URL to icon displayed in crosshair control button 146 | 147 | ##### Defaults 148 | 149 | ```js 150 | const defaults = { 151 | cameraIcon: L.icon({ 152 | iconUrl: '../images/camera.svg', 153 | iconSize: [38, 38], 154 | iconAnchor: [19, 19] 155 | }), 156 | 157 | targetIcon: L.icon({ 158 | iconUrl: '../images/marker.svg', 159 | iconSize: [32, 32], 160 | iconAnchor: [16, 16] 161 | }), 162 | 163 | angleIcon: L.icon({ 164 | iconUrl: '../images/marker.svg', 165 | iconSize: [32, 32], 166 | iconAnchor: [16, 16] 167 | }), 168 | 169 | outlineStyle: { 170 | color: 'black', 171 | opacity: 0.5, 172 | weight: 2, 173 | dashArray: '5, 7', 174 | lineCap: 'round', 175 | lineJoin: 'round' 176 | }, 177 | 178 | fillStyle: { 179 | weight: 0, 180 | fillOpacity: 0.2, 181 | fillColor: '#3388ff' 182 | } 183 | } 184 | ``` 185 | 186 | #### Keyboard navigation 187 | 188 | - Use `tab` to switch between map, camera and target 189 | - Use arrow keys to move map, camera or target 190 | - Press `C` to move camera to center of current map view 191 | - Press `M` to center map on current camera position 192 | 193 | ## Building & Publishing 194 | 195 | To build the plugin, run: 196 | 197 | npm run build 198 | 199 | The resulting files will be available in the `dist` directory. 200 | 201 | To publish the plugin to [npm](https://www.npmjs.com/package/leaflet-geotag-photo) and [unpkg.com](https://unpkg.com/leaflet-geotag-photo), run: 202 | 203 | npm publish 204 | 205 | ## See also 206 | 207 | - http://spacetime.nypl.org/ 208 | - https://github.com/nypl-spacetime/field-of-view 209 | - https://github.com/nypl-spacetime/surveyor 210 | - http://turfjs.org/ 211 | -------------------------------------------------------------------------------- /src/Leaflet.GeotagPhoto.Camera.js: -------------------------------------------------------------------------------- 1 | import L from 'leaflet' 2 | import { fromFeature } from 'field-of-view' 3 | 4 | import GeotagPhotoCameraControl from './Leaflet.GeotagPhoto.CameraControl' 5 | 6 | L.geotagPhotoCameraControl = function (geotagPhotoCamera, options) { 7 | return new GeotagPhotoCameraControl(geotagPhotoCamera, options) 8 | } 9 | 10 | export default L.FeatureGroup.extend({ 11 | 12 | options: { 13 | // Whether the camera is draggable with mouse/touch or not 14 | draggable: true, 15 | 16 | // Whether to show camera control buttons 17 | control: true, 18 | 19 | // Whether the angle of the field-of-view can be changed with a draggable marker 20 | angleMarker: true, 21 | 22 | minAngle: 5, 23 | maxAngle: 120, 24 | 25 | // Control button images 26 | controlCameraImg: '../images/camera-icon.svg', 27 | controlCrosshairImg: '../images/crosshair-icon.svg', 28 | 29 | cameraIcon: L.icon({ 30 | iconUrl: '../images/camera.svg', 31 | iconSize: [38, 38], 32 | iconAnchor: [19, 19] 33 | }), 34 | 35 | targetIcon: L.icon({ 36 | iconUrl: '../images/marker.svg', 37 | iconSize: [32, 32], 38 | iconAnchor: [16, 16] 39 | }), 40 | 41 | angleIcon: L.icon({ 42 | iconUrl: '../images/marker.svg', 43 | iconSize: [32, 32], 44 | iconAnchor: [16, 16] 45 | }), 46 | 47 | outlineStyle: { 48 | color: 'black', 49 | opacity: 0.5, 50 | weight: 2, 51 | dashArray: '5, 7', 52 | lineCap: 'round', 53 | lineJoin: 'round' 54 | }, 55 | 56 | fillStyle: { 57 | weight: 0, 58 | fillOpacity: 0.2, 59 | fillColor: '#3388ff' 60 | } 61 | }, 62 | 63 | initialize: function (feature, options) { 64 | L.setOptions(this, options) 65 | 66 | this.options.minAngle = Math.max(this.options.minAngle, 1) 67 | this.options.maxAngle = Math.min(this.options.maxAngle, 179) 68 | 69 | this._fieldOfView = fromFeature(feature) 70 | this._angle = this._fieldOfView.properties.angle 71 | 72 | var layers = this._createLayers() 73 | L.LayerGroup.prototype.initialize.call(this, layers) 74 | 75 | this.setDraggable(this.options.draggable) 76 | }, 77 | 78 | _createLayers: function () { 79 | this._cameraIcon = this.options.cameraIcon 80 | this._targetIcon = this.options.targetIcon 81 | this._angleIcon = this.options.angleIcon 82 | 83 | var pointList = this._getPointList(this._fieldOfView) 84 | var cameraLatLng = this._getCameraFromPointList(pointList) 85 | var targetLatLng = this._getTargetFromPointList(pointList) 86 | var angleLatLng = this._getAngleFromPointList(pointList) 87 | 88 | this._polyline = L.polyline(pointList, this.options.outlineStyle) 89 | this._polygon = L.polygon(pointList, Object.assign(this.options.fillStyle, { 90 | className: 'field-of-view' 91 | })) 92 | 93 | this._control = L.geotagPhotoCameraControl(this, { 94 | cameraImg: this.options.controlCameraImg, 95 | crosshairImg: this.options.controlCrosshairImg 96 | }) 97 | 98 | this._cameraMarker = L.marker(cameraLatLng, { 99 | icon: this._cameraIcon, 100 | draggable: this.options.draggable, 101 | zIndexOffset: 600, 102 | title: 'Camera', 103 | alt: 'Location of the camera' 104 | }).on('drag', this._onMarkerDrag, this) 105 | .on('dragend', this._onMarkerDragEnd, this) 106 | 107 | this._targetMarker = L.marker(targetLatLng, { 108 | icon: this._targetIcon, 109 | draggable: this.options.draggable, 110 | zIndexOffset: 200, 111 | title: 'Target', 112 | alt: 'Location of the target' 113 | }).on('drag', this._onMarkerDrag, this) 114 | .on('dragend', this._onMarkerDragEnd, this) 115 | 116 | this._angleMarker = L.marker(angleLatLng, { 117 | icon: this._angleIcon, 118 | draggable: this.options.draggable, 119 | zIndexOffset: 400, 120 | title: 'Angle', 121 | alt: 'Field of view angle' 122 | }).on('drag', this._onAngleMarkerDrag, this) 123 | .on('dragend', this._onMarkerDragEnd, this) 124 | 125 | var boundUpdateMarkerBearings = this._updateMarkerBearings.bind(this) 126 | var markerSetPos = function (pos) { 127 | var protoMarkerSetPos = L.Marker.prototype._setPos 128 | protoMarkerSetPos.call(this, pos) 129 | boundUpdateMarkerBearings() 130 | } 131 | 132 | this._cameraMarker._setPos = this._targetMarker._setPos = markerSetPos 133 | 134 | return [ 135 | this._polygon, 136 | this._polyline, 137 | this._targetMarker, 138 | this._angleMarker, 139 | this._cameraMarker 140 | ] 141 | }, 142 | 143 | addTo: function (map) { 144 | this._map = map 145 | 146 | L.FeatureGroup.prototype.addTo.call(this, map) 147 | 148 | if (this.options.control) { 149 | this._control.addTo(map) 150 | } 151 | 152 | this._boundOnDocumentKeyDown = this._onDocumentKeyDown.bind(this) 153 | document.addEventListener('keydown', this._boundOnDocumentKeyDown) 154 | 155 | this.setDraggable(this.options.draggable) 156 | this._updateMarkerBearings(this._fieldOfView) 157 | 158 | return this 159 | }, 160 | 161 | removeFrom: function (map) { 162 | this._map = undefined 163 | 164 | L.FeatureGroup.prototype.removeFrom.call(this, map) 165 | 166 | if (this._boundOnDocumentKeyDown) { 167 | document.removeEventListener('keydown', this._boundOnDocumentKeyDown) 168 | } 169 | 170 | return this 171 | }, 172 | 173 | _getPointList: function (fieldOfView) { 174 | return [ 175 | [ 176 | fieldOfView.geometry.geometries[1].coordinates[0][1], 177 | fieldOfView.geometry.geometries[1].coordinates[0][0] 178 | ], 179 | [ 180 | fieldOfView.geometry.geometries[0].coordinates[1], 181 | fieldOfView.geometry.geometries[0].coordinates[0] 182 | ], 183 | [ 184 | fieldOfView.geometry.geometries[1].coordinates[1][1], 185 | fieldOfView.geometry.geometries[1].coordinates[1][0] 186 | ] 187 | ] 188 | }, 189 | 190 | _getCameraFromPointList: function (pointList) { 191 | return pointList[1] 192 | }, 193 | 194 | _getTargetFromPointList: function (pointList) { 195 | return [ 196 | (pointList[0][0] + pointList[2][0]) / 2, 197 | (pointList[0][1] + pointList[2][1]) / 2 198 | ] 199 | }, 200 | 201 | _getAngleFromPointList: function (pointList) { 202 | return pointList[2] 203 | }, 204 | 205 | _addRotateTransform: function (element, rotation) { 206 | if (!element) { 207 | return 208 | } 209 | 210 | var transform = element.style[L.DomUtil.TRANSFORM] 211 | var rotate = 'rotate(' + rotation + ')' 212 | 213 | element.style.transformOrigin = 'center center' 214 | 215 | if (transform.indexOf('rotate') !== -1) { 216 | element.style[L.DomUtil.TRANSFORM] = transform.replace(/rotate\(.*?\)/, rotate) 217 | } else { 218 | element.style[L.DomUtil.TRANSFORM] = transform + ' ' + rotate 219 | } 220 | }, 221 | 222 | _updateMarkerBearings: function (fieldOfView) { 223 | fieldOfView = fieldOfView || this._fieldOfView 224 | 225 | var bearing = fieldOfView.properties.bearing 226 | var angle = fieldOfView.properties.angle 227 | this._addRotateTransform(this._cameraMarker._icon, bearing + 'deg') 228 | this._addRotateTransform(this._targetMarker._icon, bearing + 'deg') 229 | this._addRotateTransform(this._angleMarker._icon, (bearing + angle / 2) + 'deg') 230 | }, 231 | 232 | _drawFieldOfView: function (fieldOfView) { 233 | fieldOfView = fieldOfView || this._fieldOfView 234 | 235 | var pointList = this._getPointList(fieldOfView) 236 | this._polyline.setLatLngs(pointList) 237 | this._polygon.setLatLngs(pointList) 238 | }, 239 | 240 | _updateFieldOfView: function () { 241 | var angle = this._angle 242 | var cameraLatLng = this._cameraMarker.getLatLng() 243 | var targetLatLng = this._targetMarker.getLatLng() 244 | 245 | var cameraTarget = { 246 | type: 'Feature', 247 | properties: { 248 | angle: angle 249 | }, 250 | geometry: { 251 | type: 'GeometryCollection', 252 | geometries: [ 253 | this._geoJsonPoint(cameraLatLng), 254 | this._geoJsonPoint(targetLatLng) 255 | ] 256 | } 257 | } 258 | 259 | this._fieldOfView = fromFeature(cameraTarget) 260 | 261 | var angleLatLng = this._getAngleFromPointList(this._getPointList(this._fieldOfView)) 262 | this._angleMarker.setLatLng(angleLatLng) 263 | 264 | this._updateMarkerBearings(this._fieldOfView) 265 | this._drawFieldOfView(this._fieldOfView) 266 | }, 267 | 268 | _onAngleMarkerDrag: function (evt) { 269 | var cameraLatLng = this._cameraMarker.getLatLng() 270 | var targetLatLng = this._targetMarker.getLatLng() 271 | var angleLatLng = this._angleMarker.getLatLng() 272 | 273 | var points = { 274 | type: 'Feature', 275 | geometry: { 276 | type: 'GeometryCollection', 277 | geometries: [ 278 | this._geoJsonPoint(cameraLatLng), 279 | this._geoJsonPoint(targetLatLng), 280 | this._geoJsonPoint(angleLatLng) 281 | ] 282 | } 283 | } 284 | 285 | this._fieldOfView = fromFeature(points) 286 | this.setAngle(this._fieldOfView.properties.angle) 287 | }, 288 | 289 | _onMarkerDrag: function (evt) { 290 | this._updateFieldOfView() 291 | this.fire('input') 292 | }, 293 | 294 | _onMarkerDragEnd: function (evt) { 295 | this.fire('change') 296 | }, 297 | 298 | _moveMarker: function (marker, offset) { 299 | var point = this._map.latLngToContainerPoint(marker.getLatLng()) 300 | point = point.add(offset) 301 | var latLng = this._map.containerPointToLatLng(point) 302 | marker.setLatLng(latLng) 303 | 304 | this._updateFieldOfView() 305 | this.fire('change') 306 | }, 307 | 308 | _onMarkerKeyDown: function (marker, evt) { 309 | var moveDelta = 20 310 | if (evt.shiftKey) { 311 | moveDelta = moveDelta * 4 312 | } 313 | 314 | if (evt.keyCode === 37) { 315 | // left 316 | this._moveMarker(marker, L.point(-moveDelta, 0)) 317 | } else if (evt.keyCode === 38) { 318 | // up 319 | this._moveMarker(marker, L.point(0, -moveDelta)) 320 | } else if (evt.keyCode === 39) { 321 | // right 322 | this._moveMarker(marker, L.point(moveDelta, 0)) 323 | } else if (evt.keyCode === 40) { 324 | // down 325 | this._moveMarker(marker, L.point(0, moveDelta)) 326 | } 327 | }, 328 | 329 | _onAngleMarkerKeyDown: function (evt) { 330 | var angleDelta = 2.5 331 | if (evt.shiftKey) { 332 | angleDelta = angleDelta * 4 333 | } 334 | 335 | if (evt.keyCode === 37) { 336 | // left 337 | this.setAngle(this._angle - angleDelta) 338 | } else if (evt.keyCode === 39) { 339 | // right 340 | this.setAngle(this._angle + angleDelta) 341 | } 342 | }, 343 | 344 | _onDocumentKeyDown: function (evt) { 345 | if (document.activeElement === this._cameraMarker._icon) { 346 | this._onMarkerKeyDown(this._cameraMarker, evt) 347 | } else if (document.activeElement === this._targetMarker._icon) { 348 | this._onMarkerKeyDown(this._targetMarker, evt) 349 | } else if (document.activeElement === this._angleMarker._icon) { 350 | this._onAngleMarkerKeyDown(evt) 351 | } 352 | }, 353 | 354 | _setMarkerVisible: function (marker, visible) { 355 | marker._icon.style.display = visible ? 'inherit' : 'none' 356 | }, 357 | 358 | _geoJsonPoint: function (latLng) { 359 | return { 360 | type: 'Point', 361 | coordinates: [latLng.lng, latLng.lat] 362 | } 363 | }, 364 | 365 | getFieldOfView: function () { 366 | return this._fieldOfView 367 | }, 368 | 369 | getCameraLatLng: function () { 370 | return this._cameraMarker.getLatLng() 371 | }, 372 | 373 | getTargetLatLng: function () { 374 | return this._targetMarker.getLatLng() 375 | }, 376 | 377 | getCameraPoint: function () { 378 | return this._geoJsonPoint(this.getCameraLatLng()) 379 | }, 380 | 381 | getTargetPoint: function () { 382 | return this._geoJsonPoint(this.getTargetLatLng()) 383 | }, 384 | 385 | getCenter: function () { 386 | if (!this._map) { 387 | return 388 | } 389 | 390 | return L.latLngBounds([ 391 | this.getCameraLatLng(), 392 | this.getTargetLatLng() 393 | ]).getCenter() 394 | }, 395 | 396 | centerBounds: function (bounds) { 397 | var cameraBounds = this.getBounds() 398 | 399 | if (!bounds.contains(cameraBounds)) { 400 | var center = this.getCenter() 401 | var cameraLatLng = this.getCameraLatLng() 402 | var targetLatLng = this.getTargetLatLng() 403 | 404 | var boundsCenter = bounds.getCenter() 405 | 406 | var newCameraLatLng = [ 407 | boundsCenter.lat - (center.lat - cameraLatLng.lat), 408 | boundsCenter.lng - (center.lng - cameraLatLng.lng) 409 | ] 410 | 411 | var newTargetLatLng = [ 412 | boundsCenter.lat - (center.lat - targetLatLng.lat), 413 | boundsCenter.lng - (center.lng - targetLatLng.lng) 414 | ] 415 | 416 | this.setCameraAndTargetLatLng(newCameraLatLng, newTargetLatLng) 417 | } 418 | }, 419 | 420 | setCameraLatLng: function (latLng) { 421 | if (!this._map) { 422 | return 423 | } 424 | 425 | this._cameraMarker.setLatLng(latLng) 426 | this._updateFieldOfView() 427 | this.fire('change') 428 | }, 429 | 430 | setTargetLatLng: function (latLng) { 431 | if (!this._map) { 432 | return 433 | } 434 | 435 | this._targetMarker.setLatLng(latLng) 436 | this._updateFieldOfView() 437 | this.fire('change') 438 | }, 439 | 440 | setCameraAndTargetLatLng: function (cameraLatLng, targetLatLng) { 441 | if (!this._map) { 442 | return 443 | } 444 | 445 | this._cameraMarker.setLatLng(cameraLatLng) 446 | this._targetMarker.setLatLng(targetLatLng) 447 | this._updateFieldOfView() 448 | this.fire('change') 449 | }, 450 | 451 | getBounds: function () { 452 | if (!this._fieldOfView) { 453 | return 454 | } 455 | 456 | var pointList = this._getPointList(this._fieldOfView) 457 | return L.latLngBounds(pointList) 458 | }, 459 | 460 | setAngle: function (angle) { 461 | this._angle = Math.max(Math.min(angle, this.options.maxAngle), this.options.minAngle) 462 | this._updateFieldOfView() 463 | this.fire('input') 464 | }, 465 | 466 | setDraggable: function (draggable) { 467 | if (!this._map) { 468 | return 469 | } 470 | 471 | if (draggable) { 472 | this._cameraMarker.dragging.enable() 473 | this._targetMarker.dragging.enable() 474 | this._angleMarker.dragging.enable() 475 | } else { 476 | this._cameraMarker.dragging.disable() 477 | this._targetMarker.dragging.disable() 478 | this._angleMarker.dragging.disable() 479 | } 480 | 481 | this._setMarkerVisible(this._targetMarker, draggable) 482 | this._setMarkerVisible(this._angleMarker, draggable && this.options.angleMarker) 483 | } 484 | 485 | }) 486 | --------------------------------------------------------------------------------