├── .github ├── CONTRIBUTING.md └── ISSUE_TEMPLATE.md ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── build └── rollup.config.js ├── images ├── checkered.png ├── elevation-locate.png ├── elevation-poi.png ├── elevation-position.png ├── elevation-position.svg ├── elevation-pushpin.png ├── elevation-pushpin.svg └── elevation.svg ├── libs ├── fullpage.css ├── leaflet-distance-marker.css ├── leaflet-distance-marker.js ├── leaflet-distance-marker.min.css ├── leaflet-distance-marker.min.js ├── leaflet-edgescale.js ├── leaflet-edgescale.min.js ├── leaflet-gpxgroup.js ├── leaflet-gpxgroup.min.js ├── leaflet-hotline.js ├── leaflet-hotline.min.js ├── leaflet-ruler.css ├── leaflet-ruler.js ├── leaflet-ruler.min.css └── leaflet-ruler.min.js ├── package-lock.json ├── package.json ├── src ├── components │ ├── chart.js │ ├── d3.js │ ├── marker.js │ └── summary.js ├── control.js ├── handlers │ ├── acceleration.js │ ├── altitude.js │ ├── cadence.js │ ├── distance.js │ ├── heart.js │ ├── labels.js │ ├── lineargradient.js │ ├── pace.js │ ├── runner.js │ ├── slope.js │ ├── speed.js │ ├── temperature.js │ └── time.js ├── index.css ├── index.js ├── options.js ├── utils.js └── utils.spec.js └── test ├── index.html ├── multi-2.gpx ├── multi.gpx └── setup ├── http_server.js └── jsdom.js /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: https://github.com/Raruto/leaflet-elevation/fork 4 | [pr]: https://github.com/Raruto/leaflet-elevation/compare 5 | 6 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 7 | 8 | Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE). 9 | 10 | By participating in this project you agree to abide by its terms. 11 | 12 | ## Developing locally 13 | 14 | For those wishing to try, clone this repository into your localhost folder and inside move to the leaflet-elevation folder 15 | 16 | 0. `git clone https://github.com/raruto/leaflet-elevation` (inside your localhost folder, eg. `/var/www`) 17 | 18 | 1. `cd leaflet-elevation` 19 | 20 | 2. `npm i` 21 | 22 | 3. `npm run dev` 23 | 24 | After that you can start developing inside the `src` and `test` folders (open "http://localhost/leaflet-elevation/test" in your browser to see your changes). 25 | 26 | ## Submitting a pull request 27 | 28 | 0. [Fork][fork] and clone the repository 29 | 0. Configure and install the dependencies: `npm i` 30 | 0. Make sure the tests pass on your machine: `npm t` 31 | 0. Create a new branch: `git checkout -b my-branch-name` 32 | 0. Make your change, add tests, and make sure the tests still pass 33 | 0. Push to your fork and [submit a pull request][pr] 34 | 0. Pat your self on the back and wait for your pull request to be reviewed and merged. 35 | 36 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 37 | 38 | - Write tests. 39 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 40 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 41 | 42 | ## Resources 43 | 44 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 45 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 46 | - [GitHub Help](https://help.github.com) 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### Subject of the issue 4 | Describe your issue here. 5 | 6 | ### Your environment 7 | * leaflet-elevation: __version__ 8 | * leaflet: __version__ 9 | * browser: __name__ 10 | * operating system: __name and version (desktop or mobile)__ 11 | * link to your project: __url or screenshots__ 12 | 13 | ### Steps to reproduce 14 | Tell everyone how to reproduce this issue. Please provide a working demo (you can use [this online template](https://jsfiddle.net/wgckfu03/) as a base), otherwise, you can edit the following in order to show other some portions of your code: 15 | 16 | ```js 17 | var map = new L.Map('map'); 18 | var controlElevation = L.control.elevation(options).addTo(map); 19 | controlElevation.load("https://raruto.github.io/leaflet-elevation/examples/via-emilia.gpx"); 20 | ``` 21 | 22 | ### Expected behaviour 23 | Tell everyone what should happen 24 | 25 | ### Actual behaviour 26 | Tell everyone what happens instead 27 | 28 | 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | build/**/*.min.js 4 | spec/**/*.min.js 5 | src/**/*.min.js 6 | src/**/*.min.css 7 | dist/ 8 | e2e/results/ 9 | e2e/reports/ 10 | /playwright/.cache/ 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /build 2 | /.github 3 | /examples 4 | /test 5 | /spec 6 | .jshintrc 7 | .gitattributes 8 | .babelrc 9 | _config.yml 10 | src/**/*.min.js 11 | src/**/*.min.css 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # leaflet-elevation.js 2 | 3 | [![NPM version](https://img.shields.io/npm/v/@raruto/leaflet-elevation.svg?color=red)](https://www.npmjs.com/package/@raruto/leaflet-elevation) 4 | [![License](https://img.shields.io/badge/license-GPL%203-blue.svg?style=flat)](LICENSE) 5 | 6 | A Leaflet plugin that allows to add elevation profiles using d3js 7 | 8 |

9 | Leaflet elevation viewer 10 |

11 | 12 | --- 13 | 14 | _For a working example see one of the following demos:_ 15 | 16 | - [loading .gpx file](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation.html) 17 | - [loading .geojson file](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_geojson-data.html) 18 | - [loading .kml file](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_kml-data.html) 19 | - [loading .tcx file](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_tcx-data.html) 20 | - [loading a local .gpx file](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_upload-gpx.html) 21 | - [loading data from a textarea](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_string-data.html) 22 | - [loading individual .geojson tracks](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_geojson-group.html) 23 | - [loading individual .gpx tracks](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_toggable-tracks.html) 24 | - [loading multiple .gpx tracks (hover to toggle)](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_hoverable-tracks.html) 25 | - [loading multiple .gpx tracks (click to toggle)](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_toggable-charts.html) 26 | - [loading multiple maps](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_multiple-maps.html) 27 | - [stacking multiple charts (elevation, slope, speed)](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_multiple-charts.html) 28 | - [translating plugin labels](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_i18n-strings.html) 29 | - [rotating chart labels](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_chart_labels.html) 30 | - [using custom colors](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_custom-theme.html) 31 | - [using .gpx extensions (cadence, heart, pace)](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_extended-ui.html) 32 | - [using .gpx waypoint icons](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_gpx-waypoints.html) 33 | - [using .geojson waypoint icons](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_geojson-waypoints.html) 34 | 35 | 36 |
37 | 38 | - [autohide map](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_hidden-map.html) 39 | - [autohide chart](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_hidden-chart.html) 40 | - [clear button](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_clear-button.html) 41 | - [collapsible button](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_close-button.html) 42 | - [custom summary](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_custom-summary.html) 43 | - [edge scale control](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_edge-scale.html) 44 | - [follow marker](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_follow-marker.html) 45 | - [layer almostover](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_almost-over.html) 46 | - [linear gradient](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_linear-gradient.html) 47 | - [slope chart](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_slope-chart.html) 48 | - [speed chart](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_speed-chart.html) 49 | - [temperature chart](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_temperature-chart.html) 50 | - [walking marker](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_dynamic-runner.html) 51 | 52 | --- 53 | 54 |
55 |

56 | Initially based on the work of Felix “MrMufflon” Bache 57 |

58 |
59 | 60 | --- 61 | 62 | ## How to use 63 | 64 | 1. **include CSS & JavaScript** 65 | ```html 66 | 67 | ... 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | ... 78 | 79 | ``` 80 | 2. **choose the div container used for the slippy map** 81 | ```html 82 | 83 | ... 84 |
85 | ... 86 | 87 | ``` 88 | 3. **create your first simple “leaflet-elevation” slippy map** 89 | ```html 90 | 203 | ``` 204 | 205 | ### Build Guide 206 | 207 | Within your local development environment: 208 | 209 | ```shell 210 | git clone git@github.com:Raruto/leaflet-elevation.git 211 | cd ./leaflet-elevation 212 | 213 | npm i # install dependencies 214 | npm run dev # start dev server at: http://localhost:8080 215 | npm run build # generate "dist" files (once) 216 | npm run test # test all "*.spec.js" files (once) 217 | ``` 218 | 219 | After that you can start developing inside the `src` and `test` folders (eg. open "http://localhost:8080/test" in your browser to preview changes). Check also [CONTRIBUTING.md](.github/CONTRIBUTING.md) file for some information about it. 220 | 221 | ### FAQ 222 | 223 |
224 | 1. How can I change the color of the elevation plot?
225 | 226 | There are multiple options to achieve this: 227 | 228 | * You could either use some default presets (see: theme: "lightblue-theme" option in readme file and the following file `leaflet-elevation.css` for some other default "*-theme" names). 229 | * check out [this example](https://raruto.github.io/leaflet-elevation/examples/leaflet-elevation_custom-theme.html) 230 | * Or add the following lines for custom colors. 231 | ```css 232 | .elevation-control .area { 233 | fill: red; 234 | } 235 | .elevation-control .background { 236 | background-color: white; 237 | ``` 238 |
239 | 240 |
241 | 2. How to enable/disable the leaflet user interface customizations?
242 | 243 | These customizations are actually part of the [leaflet-ui](https://github.com/Raruto/leaflet-ui) and can be toggled on/off using e.g. the following options: 244 | ```js 245 | var map = L.map('map', { 246 | center: [41.4583, 12.7059], // needs value to initialize 247 | zoom: 5, // needs value to initialize 248 | mapTypeId: 'topo', 249 | mapTypeIds: ['osm', 'terrain', 'satellite', 'topo'], 250 | gestureHandling: false, // zoom with Cmd + Scroll 251 | zoomControl: true, // plus minus buttons 252 | pegmanControl: false, 253 | locateControl: false, 254 | fullscreenControl: true, 255 | layersControl: true, 256 | minimapControl: false, 257 | editInOSMControl: false, 258 | loadingControl: false, 259 | searchControl: false, 260 | disableDefaultUI: false, 261 | printControl: false, 262 | }); 263 | ``` 264 |
265 | 266 |
3. How can I import this library as ES module? 267 | 268 | Usually, when working with a js bundler like [Vite](https://vitest.dev/) or [Webpack](https://webpack.js.org/), you need to provide to this library the full path to some dynamically imported files from the [`srcFolder`](./src/): 269 | 270 | ```js 271 | import './your-custom-style.css'; 272 | import 'leaflet/dist/leaflet.css'; 273 | import L from 'leaflet'; 274 | import '@raruto/leaflet-elevation/src/index.js'; 275 | import '@raruto/leaflet-elevation/src/index.css'; 276 | 277 | const map = L.map('map', { 278 | center: [41.4583, 12.7059] 279 | zoom: 5, 280 | }); 281 | 282 | const controlElevation = L.control.elevation({ 283 | // CHANGE ME: with your own http server folder (eg. "http://custom-server/public/path/to/leaflet-elevation/src/") 284 | srcFolder: 'http://unpkg.com/@raruto/leaflet-elevation/src/' 285 | }).addTo(map); 286 | 287 | // Load track from url (allowed data types: "*.geojson", "*.gpx", "*.tcx") 288 | controlElevation.load("https://raruto.github.io/leaflet-elevation/examples/via-emilia.gpx"); 289 | ``` 290 | 291 |
292 | 293 |
294 | 4. Some real world projects based on this plugin?
295 | 296 | - https://parcours.scasb.org/ 297 | - https://velocat.ru/velo/phpBB3/map.php 298 | - https://plugins.qgis.org/plugins/track_profile_2_web/ 299 | - https://wordpress.org/plugins/os-datahub-maps/ 300 | - https://wordpress.org/plugins/extensions-leaflet-map/ 301 | - https://github.com/der-stefan/OpenTopoMap 302 | - https://gpx.n-peloton.fr/ 303 | - https://walkaholic.me/map 304 | 305 |
306 | 307 | _Related: [Leaflet-UI presets](https://github.com/raruto/leaflet-ui), [QGIS Integration](https://github.com/faunalia/trackprofile2web)_ 308 | 309 | ### Changelog 310 | 311 | All notable changes to this project are documented in the [releases](https://github.com/Raruto/leaflet-elevation/releases) page. 312 | 313 | --- 314 | 315 | **Compatibile with:** 316 | [![Leaflet 1.x compatible!](https://img.shields.io/badge/Leaflet-1.7.0-1EB300.svg?style=flat)](http://leafletjs.com/reference.html) 317 | [![d3.js v7 compatibile!](https://img.shields.io/badge/d3.js-7.8-1EB300.svg?style=flat)](https://www.npmjs.com/package/d3) 318 | [![@tmcw/togeojson v5 compatibile!](https://img.shields.io/badge/@tmcw/togeojson-5.6-1EB300.svg?style=flat)](https://www.npmjs.com/package/@tmcw/togeojson) 319 | 320 | 321 | --- 322 | 323 | **Contributors:** [MrMufflon](https://github.com/MrMufflon/Leaflet.Elevation), [HostedDinner](https://github.com/HostedDinner/Leaflet.Elevation), [ADoroszlai](http://ADoroszlai.github.io/joebed/), [Raruto](https://github.com/Raruto/leaflet-elevation) 324 | 325 | --- 326 | 327 | **License:** GPL-3.0+ 328 | -------------------------------------------------------------------------------- /build/rollup.config.js: -------------------------------------------------------------------------------- 1 | import terser from '@rollup/plugin-terser'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import commonJS from '@rollup/plugin-commonjs'; 4 | import postcss from 'rollup-plugin-postcss'; 5 | import postcssImport from 'postcss-import'; 6 | 7 | // import postcssCopy from 'postcss-copy'; 8 | // import rollupGitVersion from 'rollup-plugin-git-version'; 9 | 10 | import plugin from '../package.json' assert { type: "json" }; 11 | 12 | let plugin_name = plugin.name.replace("@raruto/", ""); 13 | 14 | let input = plugin.module; 15 | let output = { 16 | file: "dist/" + plugin_name + ".js", 17 | format: "umd", 18 | sourcemap: true, 19 | name: plugin_name, 20 | freeze: false, 21 | }; 22 | 23 | let plugins = [ 24 | resolve(), 25 | commonJS({ 26 | include: '../node_modules/**' 27 | }), 28 | // rollupGitVersion(), 29 | ]; 30 | 31 | export default [ 32 | //** "leaflet-elevation.js" **// 33 | { 34 | input: input, 35 | output: output, 36 | plugins: plugins, 37 | }, 38 | 39 | //** "leaflet-elevation.min.js" **// 40 | { 41 | input: input, 42 | output: Object.assign({}, output, { 43 | file: "dist/" + plugin_name + ".min.js" 44 | }), 45 | plugins: plugins.concat(terser()), 46 | }, 47 | 48 | //** "leaflet-elevation.css" **// 49 | { 50 | input: input.replace(".js", ".css"), 51 | output: { 52 | file: "dist/" + plugin_name + ".css", 53 | format: 'es' 54 | }, 55 | plugins: [ 56 | postcss({ 57 | extract: true, 58 | inject: false, 59 | minimize: false, 60 | plugins: [ 61 | postcssImport({}), 62 | // postcssCopy({ 63 | // basePath: 'node_modules', 64 | // dest: "dist", 65 | // template: "images/[path][name].[ext]", 66 | // }) 67 | ] 68 | }) 69 | ] 70 | }, 71 | 72 | //** "leaflet-elevation.min.css" **// 73 | { 74 | input: input.replace(".js", ".css"), 75 | output: { 76 | file: "dist/" + plugin_name + ".min.css", 77 | format: 'es' 78 | }, 79 | plugins: [ 80 | postcss({ 81 | extract: true, 82 | inject: false, 83 | minimize: true, 84 | plugins: [ 85 | postcssImport({}), 86 | // postcssCopy({ 87 | // basePath: 'node_modules', 88 | // dest: "dist", 89 | // template: "images/[path][name].[ext]", 90 | // }) 91 | ] 92 | }) 93 | ] 94 | }, 95 | 96 | ]; 97 | -------------------------------------------------------------------------------- /images/checkered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Raruto/leaflet-elevation/8658bfad0ee00ede92bdf4b6d130cc959e90cdd7/images/checkered.png -------------------------------------------------------------------------------- /images/elevation-locate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Raruto/leaflet-elevation/8658bfad0ee00ede92bdf4b6d130cc959e90cdd7/images/elevation-locate.png -------------------------------------------------------------------------------- /images/elevation-poi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Raruto/leaflet-elevation/8658bfad0ee00ede92bdf4b6d130cc959e90cdd7/images/elevation-poi.png -------------------------------------------------------------------------------- /images/elevation-position.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Raruto/leaflet-elevation/8658bfad0ee00ede92bdf4b6d130cc959e90cdd7/images/elevation-position.png -------------------------------------------------------------------------------- /images/elevation-position.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /images/elevation-pushpin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Raruto/leaflet-elevation/8658bfad0ee00ede92bdf4b6d130cc959e90cdd7/images/elevation-pushpin.png -------------------------------------------------------------------------------- /images/elevation-pushpin.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /images/elevation.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /libs/fullpage.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | .leaflet-map { 4 | height: 100%; 5 | width: 100%; 6 | padding: 0px; 7 | margin: 0px; 8 | } 9 | 10 | body { 11 | display: flex; 12 | flex-direction: column; 13 | } 14 | -------------------------------------------------------------------------------- /libs/leaflet-distance-marker.css: -------------------------------------------------------------------------------- 1 | .dist-marker { 2 | font-size: 9px; 3 | border: 1px solid #777; 4 | border-radius: 10px; 5 | text-align: center; 6 | color: #000; 7 | background: #fff; 8 | } 9 | -------------------------------------------------------------------------------- /libs/leaflet-distance-marker.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2022, GPL-3.0+ Project, Raruto 3 | * 4 | * This file is free software: you may copy, redistribute and/or modify it 5 | * under the terms of the GNU General Public License as published by the 6 | * Free Software Foundation, either version 2 of the License, or (at your 7 | * option) any later version. 8 | * 9 | * This file is distributed in the hope that it will be useful, but 10 | * WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | * General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU General Public License 15 | * along with this program. If not, see . 16 | * 17 | * This file incorporates work covered by the following copyright and 18 | * permission notice: 19 | * 20 | * Copyright (c) 2014- Doroszlai Attila, 2016- Phil Whitehurst 21 | * 22 | * Permission to use, copy, modify, and/or distribute this software 23 | * for any purpose with or without fee is hereby granted, provided 24 | * that the above copyright notice and this permission notice appear 25 | * in all copies. 26 | * 27 | * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL 28 | * WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED 29 | * WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE 30 | * AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR 31 | * CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 32 | * OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, 33 | * NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 34 | * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 35 | */ 36 | 37 | // TODO: the "L.DistanceMarker" (canvas marker) class could be alternatively provided by "leaflet-rotate"? 38 | 39 | L.DistanceMarker = L.CircleMarker.extend({ 40 | _updatePath: function () { 41 | let ctx = this._renderer._ctx; 42 | let p = this._point; 43 | 44 | // Calculate image direction (rotation) 45 | this.options.rotation = this.options.rotation || 0; 46 | 47 | // Draw circle marker (canvas point) 48 | if (this.options.radius && this._renderer._updateCircle) { 49 | this._renderer._updateCircle(this); 50 | } 51 | 52 | // Draw image over circle (distance marker) 53 | if (this.options.icon && this.options.icon.url) { 54 | if (!this.options.icon.element) { 55 | const icon = document.createElement('img'); 56 | this.options.icon = L.extend({ rotate: 0, size: [40, 40], offset: { x: 0, y: 0 } }, this.options.icon); 57 | this.options.icon.rotate += this.options.rotation; 58 | this.options.icon.element = icon; 59 | icon.src = this.options.icon.url; 60 | icon.onload = () => this.redraw(); 61 | icon.onerror = () => this.options.icon = null; 62 | } else { 63 | const icon = this.options.icon; 64 | let cx = p.x + icon.offset.x; 65 | let cy = p.y + icon.offset.y; 66 | ctx.save(); 67 | if (icon.rotate) { 68 | ctx.translate(p.x, p.y); 69 | ctx.rotate(icon.rotate); 70 | cx = 0; 71 | cy = 0; 72 | } 73 | ctx.drawImage(icon.element, cx - icon.size[0] / 2, cy - icon.size[1] / 2, icon.size[0], icon.size[1]); 74 | ctx.restore(); 75 | } 76 | } 77 | 78 | // Add a label inside the circle (distance marker) 79 | if (this.options.label) { 80 | let cx = p.x, cy = p.y; 81 | ctx.save(); 82 | 83 | ctx.font = this.options.font || 'normal 7pt "Helvetica Neue", Arial, Helvetica, sans-serif'; 84 | ctx.textAlign = "center"; 85 | ctx.textBaseline = "middle"; 86 | ctx.fillStyle = this.options.fillStyle || 'black'; 87 | 88 | // TODO rescale circle to fit text 89 | // let fontSize = Number(/[0-9\.]+/.exec(ctx.font)[0]); 90 | // let fontWidth = ctx.measureText(this.options.html).width; 91 | 92 | if (this.options.rotation) { 93 | ctx.translate(p.x, p.y); 94 | ctx.rotate(this.options.rotation); 95 | cx = 0; 96 | cy = 0; 97 | } 98 | 99 | // Temporary fix to prevent stroke blurs at higher zoom levels 100 | if (this._map.getZoom() > 17) { 101 | ctx.fillStyle = this.options.strokeStyle || 'black'; 102 | } 103 | 104 | ctx.fillText(this.options.label, cx, cy); 105 | 106 | if (this.options.strokeStyle && this._map.getZoom() <= 17) { 107 | ctx.strokeStyle = this.options.strokeStyle; 108 | ctx.strokeText(this.options.label, cx, cy); 109 | } 110 | 111 | ctx.restore(); 112 | } 113 | } 114 | }); 115 | 116 | L.DistanceMarkers = L.LayerGroup.extend({ 117 | options: { 118 | cssClass: 'dist-marker', 119 | iconSize: [12, 12], 120 | arrowSize: [10, 10], 121 | arrowUrl: "data:image/svg+xml,%3Csvg transform='rotate(90)' xmlns='http://www.w3.org/2000/svg' width='560px' height='560px' viewBox='0 0 560 560'%3E%3Cpath stroke-width='35' fill='%23000' stroke='%23FFF' d='M280,40L522,525L280,420L38,525z'/%3E%3C/svg%3E", 122 | offset: 1000, 123 | showAll: 12, 124 | textFunction: (distance, i, offset) => i, 125 | distance: true, 126 | direction: true, 127 | }, 128 | initialize: function (line, map, options) { 129 | 130 | this._layers = {}; 131 | this._zoomLayers = {}; 132 | 133 | options = L.setOptions(this, options); 134 | 135 | let preferCanvas = map.options.preferCanvas; 136 | let showAll = Math.min(map.getMaxZoom(), options.showAll); 137 | 138 | // You should use "leaflet-rotate" to show rotated arrow markers (preferCanvas: false) 139 | if (!preferCanvas && !map.options.rotate) { 140 | console.warn('Missing dependency: "leaflet-rotate"'); 141 | } 142 | 143 | // Get line coords as an array 144 | let coords = typeof line.getLatLngs == 'function' ? line.getLatLngs() : line; 145 | 146 | // Handle "MultiLineString" features 147 | coords = L.LineUtil.isFlat(coords) ? [coords] : coords; 148 | 149 | coords.forEach(latlngs => { 150 | // Get accumulated line lengths as well as overall length 151 | let accumulated = L.GeometryUtil.accumulatedLengths(latlngs); 152 | let length = accumulated.length > 0 ? accumulated[accumulated.length - 1] : 0; 153 | 154 | // count = Number of distance markers to be added 155 | // j = Position in accumulated line length array 156 | for (let i = 1, count = Math.floor(length / options.offset), j = 0; i <= count; ++i) { 157 | 158 | let distance = options.offset * i; 159 | 160 | // Find the first accumulated distance that is greater than the distance of this marker 161 | while (j < accumulated.length - 1 && accumulated[j] < distance) ++j; 162 | 163 | // Grab two nearest points either side marker position 164 | let p1 = latlngs[j - 1]; 165 | let p2 = latlngs[j]; 166 | let m_line = L.polyline([p1, p2]); 167 | 168 | // and create a simple line to interpolate on 169 | let ratio = (distance - accumulated[j - 1]) / (accumulated[j] - accumulated[j - 1]); 170 | let position = L.GeometryUtil.interpolateOnLine(map, m_line, ratio); 171 | let delta = map.project(p2).subtract(map.project(p1)); 172 | let angle = Math.atan2(delta.y, delta.x); 173 | 174 | // Generate distance marker label 175 | let text = options.textFunction.call(this, distance, i, options.offset); 176 | 177 | // Grouping layer of visible layers at zoom level (arrow + distance) 178 | let zoom = this._minimumZoomLevelForItem(i, showAll); 179 | let markers = this._zoomLayers[zoom] = this._zoomLayers[zoom] || L.layerGroup() 180 | 181 | // create arrow markers 182 | if (options.direction && ((options.distance && i % 2 == 1) || !options.distance)) { 183 | if (preferCanvas) { 184 | markers.addLayer( 185 | new L.DistanceMarker(p1, { 186 | radius: 0, 187 | icon: { 188 | url: options.arrowUrl, //image link 189 | size: options.arrowSize, //image size ( default [40, 40] ) 190 | rotate: 0, //image base rotate ( default 0 ) 191 | offset: { x: 0, y: 0 }, //image offset ( default { x: 0, y: 0 } ) 192 | }, 193 | rotation: angle, 194 | interactive: false, 195 | // label: '⮞', //'➜', 196 | // font: 'normal 20pt "Helvetica Neue", Arial, Helvetica, sans-serif', 197 | // fillStyle: 'white',//'#3366CC', 198 | // strokeStyle: 'black', 199 | }) 200 | ); 201 | } else { 202 | markers.addLayer( 203 | L.marker(position.latLng, { 204 | icon: L.icon({ 205 | iconUrl: options.arrowUrl, 206 | iconSize: options.arrowSize, 207 | }), 208 | // NB the following option is added by "leaflet-rotate" 209 | rotation: angle, 210 | interactive: false, 211 | }) 212 | ); 213 | } 214 | } 215 | 216 | // create distance markers 217 | if (options.distance && i % 2 == 0) { 218 | if (preferCanvas) { 219 | markers.addLayer( 220 | new L.DistanceMarker(position.latLng, { 221 | label: text, // TODO: handle text rotation (leaflet-rotate) 222 | radius: 7, 223 | fillColor: '#fff', 224 | fillOpacity: 1, 225 | fillStyle: 'black', 226 | color: '#777', 227 | weight: 1, 228 | interactive: false, 229 | }) 230 | ); 231 | } else { 232 | markers.addLayer( 233 | L.marker(position.latLng, { 234 | title: text, 235 | icon: L.divIcon({ 236 | className: options.cssClass, 237 | html: text, 238 | iconSize: options.iconSize 239 | }), 240 | interactive: false, 241 | }) 242 | ); 243 | } 244 | } 245 | } 246 | }); 247 | 248 | const updateMarkerVisibility = () => { 249 | let oldZoom = this._lastZoomLevel || 0; 250 | let newZoom = map.getZoom(); 251 | if (newZoom > oldZoom) { 252 | for (let i = oldZoom + 1; i <= newZoom; ++i) { 253 | if (this._zoomLayers[i] !== undefined) { 254 | this.addLayer(this._zoomLayers[i]); 255 | } 256 | } 257 | } else if (newZoom < oldZoom) { 258 | for (let i = oldZoom; i > newZoom; --i) { 259 | if (this._zoomLayers[i] !== undefined) { 260 | this.removeLayer(this._zoomLayers[i]); 261 | } 262 | } 263 | } 264 | this._lastZoomLevel = newZoom; 265 | }; 266 | map.on('zoomend', updateMarkerVisibility); 267 | updateMarkerVisibility(); 268 | }, 269 | 270 | _minimumZoomLevelForItem: function (i, zoom) { 271 | while (i > 0 && i % 2 === 0) { 272 | --zoom; 273 | i = Math.floor(i / 2); 274 | } 275 | return zoom; 276 | }, 277 | 278 | }); 279 | 280 | L.Polyline.include({ 281 | 282 | _originalOnAdd: L.Polyline.prototype.onAdd, 283 | _originalOnRemove: L.Polyline.prototype.onRemove, 284 | 285 | addDistanceMarkers: function () { 286 | if (this._map && this._distanceMarkers) { 287 | this._map.addLayer(this._distanceMarkers); 288 | } 289 | }, 290 | 291 | removeDistanceMarkers: function () { 292 | if (this._map && this._distanceMarkers) { 293 | this._map.removeLayer(this._distanceMarkers); 294 | } 295 | }, 296 | 297 | onAdd: function (map) { 298 | this._originalOnAdd(map); 299 | 300 | let opts = this.options.distanceMarkers || {}; 301 | if (this.options.distanceMarkers) { 302 | this._distanceMarkers = this._distanceMarkers || new L.DistanceMarkers(this, map, opts); 303 | } 304 | if (opts.lazy === undefined || opts.lazy === false) { 305 | this.addDistanceMarkers(); 306 | } 307 | }, 308 | 309 | onRemove: function (map) { 310 | this.removeDistanceMarkers(); 311 | this._originalOnRemove(map); 312 | } 313 | 314 | }); 315 | -------------------------------------------------------------------------------- /libs/leaflet-distance-marker.min.css: -------------------------------------------------------------------------------- 1 | .dist-marker{font-size:9px;border:1px solid #777;border-radius:10px;text-align:center;color:#000;background:#fff} -------------------------------------------------------------------------------- /libs/leaflet-distance-marker.min.js: -------------------------------------------------------------------------------- 1 | L.DistanceMarker=L.CircleMarker.extend({_updatePath:function(){let ctx=this._renderer._ctx,p=this._point;if(this.options.rotation=this.options.rotation||0,this.options.radius&&this._renderer._updateCircle&&this._renderer._updateCircle(this),this.options.icon&&this.options.icon.url)if(this.options.icon.element){const icon=this.options.icon;let cx=p.x+icon.offset.x,cy=p.y+icon.offset.y;ctx.save(),icon.rotate&&(ctx.translate(p.x,p.y),ctx.rotate(icon.rotate),cx=0,cy=0),ctx.drawImage(icon.element,cx-icon.size[0]/2,cy-icon.size[1]/2,icon.size[0],icon.size[1]),ctx.restore()}else{const icon=document.createElement("img");this.options.icon=L.extend({rotate:0,size:[40,40],offset:{x:0,y:0}},this.options.icon),this.options.icon.rotate+=this.options.rotation,this.options.icon.element=icon,icon.src=this.options.icon.url,icon.onload=()=>this.redraw(),icon.onerror=()=>this.options.icon=null}if(this.options.label){let cx=p.x,cy=p.y;ctx.save(),ctx.font=this.options.font||'normal 7pt "Helvetica Neue", Arial, Helvetica, sans-serif',ctx.textAlign="center",ctx.textBaseline="middle",ctx.fillStyle=this.options.fillStyle||"black",this.options.rotation&&(ctx.translate(p.x,p.y),ctx.rotate(this.options.rotation),cx=0,cy=0),this._map.getZoom()>17&&(ctx.fillStyle=this.options.strokeStyle||"black"),ctx.fillText(this.options.label,cx,cy),this.options.strokeStyle&&this._map.getZoom()<=17&&(ctx.strokeStyle=this.options.strokeStyle,ctx.strokeText(this.options.label,cx,cy)),ctx.restore()}}}),L.DistanceMarkers=L.LayerGroup.extend({options:{cssClass:"dist-marker",iconSize:[12,12],arrowSize:[10,10],arrowUrl:"data:image/svg+xml,%3Csvg transform='rotate(90)' xmlns='http://www.w3.org/2000/svg' width='560px' height='560px' viewBox='0 0 560 560'%3E%3Cpath stroke-width='35' fill='%23000' stroke='%23FFF' d='M280,40L522,525L280,420L38,525z'/%3E%3C/svg%3E",offset:1e3,showAll:12,textFunction:(distance,i,offset)=>i,distance:!0,direction:!0},initialize:function(line,map,options){this._layers={},this._zoomLayers={},options=L.setOptions(this,options);let preferCanvas=map.options.preferCanvas,showAll=Math.min(map.getMaxZoom(),options.showAll);preferCanvas||map.options.rotate||console.warn('Missing dependency: "leaflet-rotate"');let coords="function"==typeof line.getLatLngs?line.getLatLngs():line;coords=L.LineUtil.isFlat(coords)?[coords]:coords,coords.forEach(latlngs=>{let accumulated=L.GeometryUtil.accumulatedLengths(latlngs),length=accumulated.length>0?accumulated[accumulated.length-1]:0;for(let i=1,count=Math.floor(length/options.offset),j=0;i<=count;++i){let distance=options.offset*i;for(;j{let oldZoom=this._lastZoomLevel||0,newZoom=map.getZoom();if(newZoom>oldZoom)for(let i=oldZoom+1;i<=newZoom;++i)void 0!==this._zoomLayers[i]&&this.addLayer(this._zoomLayers[i]);else if(newZoomnewZoom;--i)void 0!==this._zoomLayers[i]&&this.removeLayer(this._zoomLayers[i]);this._lastZoomLevel=newZoom};map.on("zoomend",updateMarkerVisibility),updateMarkerVisibility()},_minimumZoomLevelForItem:function(i,zoom){for(;i>0&&i%2==0;)--zoom,i=Math.floor(i/2);return zoom}}),L.Polyline.include({_originalOnAdd:L.Polyline.prototype.onAdd,_originalOnRemove:L.Polyline.prototype.onRemove,addDistanceMarkers:function(){this._map&&this._distanceMarkers&&this._map.addLayer(this._distanceMarkers)},removeDistanceMarkers:function(){this._map&&this._distanceMarkers&&this._map.removeLayer(this._distanceMarkers)},onAdd:function(map){this._originalOnAdd(map);let opts=this.options.distanceMarkers||{};this.options.distanceMarkers&&(this._distanceMarkers=this._distanceMarkers||new L.DistanceMarkers(this,map,opts)),void 0!==opts.lazy&&!1!==opts.lazy||this.addDistanceMarkers()},onRemove:function(map){this.removeDistanceMarkers(),this._originalOnRemove(map)}}); -------------------------------------------------------------------------------- /libs/leaflet-edgescale.min.js: -------------------------------------------------------------------------------- 1 | L.Control.EdgeScale=L.Control.extend({options:{position:"bottomleft",icon:!0,coords:!0,bar:!0,onMove:!0,template:"{y} | {x}",projected:!1,formatProjected:"#.##0,000",latlngFormat:"DD",latlngDesignators:!0,latLngFormatter:void 0,iconStyle:{background:"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xml:space='preserve' viewBox='0 0 100 100'%3E%3Cg stroke='%23fff'%3E%3Ccircle cx='50' cy='50.2' r='3.9' stroke-width='2' /%3E%3Cpath stroke-width='3' d='M5 54h32a4 4 0 1 0 0-8H5a4 4 0 1 0 0 8z M54 5a4 4 0 1 0-8 0v32a4 4 0 1 0 8 0V5z M99 50c0-2-2-4-4-4H63a4 4 0 1 0 0 8h32c2 0 4-1 4-4zM46 95a4 4 0 1 0 8 0V64a4 4 0 1 0-8 0v31z'/%3E%3C/g%3E%3C/svg%3E%0A\")",width:"24px",height:"24px",left:"calc(50% - 12px)",top:"calc(50% - 12px)",content:"",display:"block",position:"absolute",zIndex:999,pointerEvents:"none"},containerStyle:{backgroundColor:"rgba(255, 255, 255, 0.7)",boxShadow:"0 0 5px #bbb",borderRadius:"3px",padding:"3px 2px",color:"#333",font:"11px/1.5 Consolas, monaco, monospace",writingMode:"vertical-lr"}},initialize:function(options){L.setOptions(this,options)},onAdd:function(map){return this.options.bar&&(this._scaleBar=new L.Control.EdgeScale.Layer(!0===this.options.bar?{}:this.options.bar).addTo(map)),this.options.icon&&(this._icon=L.DomUtil.create("div","leaflet-crosshair"),Object.assign(this._icon.style,this.options.iconStyle),map.getContainer().insertBefore(this._icon,map.getContainer().firstChild)),this._container=L.DomUtil.create("div","leaflet-control-mapcentercoord"),Object.assign(this._container.style,this.options.containerStyle),this.options.coords||(this._container.style.display="none"),L.DomEvent.disableClickPropagation(this._container),this._container.innerHTML=this._getMapCenterCoord(),map.on("move",this._onMapMove,this),map.on("moveend",this._onMapMove,this),this._container},onRemove:function(map){this.options.bar&&this._scaleBar.remove(),this.options.icon&&map.getContainer().removeChild(this._icon),map.off("move",this._onMapMove,this),map.off("moveend",this._onMapMove,this)},_onMapMove:function(e){(this.options.onMove||"moveend"===e.type)&&(this._container.innerHTML=this._getMapCenterCoord())},_getMapCenterCoord:function(){const center=this._map.getCenter();return this.options.projected?this._getProjectedCoord(this._map.options.crs.project(center)):this._getLatLngCoord(center)},_getProjectedCoord:function(center){return L.Util.template(this.options.template,{x:this._format(this.options.formatProjected,center.x),y:this._format(this.options.formatProjected,center.y)})},_getLatLngCoord:function(latLng){const{latLngFormatter:latLngFormatter,latlngFormat:latlngFormat,latlngDesignators:designators}=this.options;if(void 0!==latLngFormatter)return latLngFormatter(latLng.lat,latLng.lng);let lat,lng,deg,min,center={lat:latLng.lat,lng:latLng.lng,lng_neg:latLng.lng<0,lat_neg:latLng.lat<0};return center.lng<0&&(center.lng=Math.abs(center.lng)),center.lng>180&&(center.lng=360-center.lng,center.lng_neg=!center.lng_neg),center.lat<0&&(center.lat=Math.abs(center.lat)),"DM"===latlngFormat?(deg=parseInt(center.lng),lng=deg+"º "+this._format("00.000",60*(center.lng-deg))+"'",deg=parseInt(center.lat),lat=deg+"º "+this._format("00.000",60*(center.lat-deg))+"'"):"DMS"===latlngFormat?(deg=parseInt(center.lng),min=60*(center.lng-deg),lng=deg+"º "+this._format("00",parseInt(min))+"' "+this._format("00.0",60*(min-parseInt(min)))+"''",deg=parseInt(center.lat),min=60*(center.lat-deg),lat=deg+"º "+this._format("00",parseInt(min))+"' "+this._format("00.0",60*(min-parseInt(min)))+"''"):(lng=this._format("#0.00000",center.lng)+"º",lat=this._format("##0.00000",center.lat)+"º"),L.Util.template(this.options.template,{x:(!designators&¢er.lng_neg?"-":"")+lng+(designators?center.lng_neg?" W":" E":""),y:(!designators&¢er.lat_neg?"-":"")+lat+(designators?center.lat_neg?" S":" N":"")})},_format:function(m,v){if(!m||isNaN(+v))return v;let isNegative=(v="-"==m.charAt(0)?-v:+v)<0?v=-v:0,result=m.match(/[^\d\-\+#]/g),Decimal=result&&result[result.length-1]||".",Group=result&&result[1]&&result[0]||",";m=m.split(Decimal),v=+(v=v.toFixed(m[1]&&m[1].length))+"";let pos_trail_zero=m[1]&&m[1].lastIndexOf("0"),part=v.split(".");(!part[1]||part[1]&&part[1].length<=pos_trail_zero)&&(v=(+v).toFixed(pos_trail_zero+1));let szSep=m[0].split(Group);m[0]=szSep.join("");let pos_lead_zero=m[0]&&m[0].indexOf("0");if(pos_lead_zero>-1)for(;part[0].length=zoom){this._interval=dict.interval;break}}else this._interval=L.Control.Scale.prototype._getRoundNum(this._map.containerPointToLatLng([0,this._map.getSize().y/2]).distanceTo(this._map.containerPointToLatLng([L.Control.Scale.prototype.options.maxWidth,this._map.getSize().y/2])));this._currZoom=zoom},_draw:function(){this._ctx.strokeStyle=this.options.color,this._create_lat_ticks(),this._create_lon_ticks(),this._ctx.fillStyle=this.options.color,this._ctx.font=this.options.font;const size=this._map.getSize(),text=this._interval>=1e3?this._interval/1e3+" km":this._interval+" m";this._ctx.textAlign="left",this._ctx.textBaseline="middle",this._ctx.fillText(text,12,size.y/2),this._ctx.textAlign="center",this._ctx.textBaseline="top",this._ctx.fillText(text,size.x/2,12)},_create_lat_ticks:function(){const{weight:weight}=this.options,size=this._map.getSize(),to_rad=Math.PI/180,center=this._merLength(this._map.containerPointToLatLng(L.point(0,size.y/2)).lat*to_rad),top=this._merLength(this._map.containerPointToLatLng(L.point(0,0)).lat*to_rad),bottom=this._merLength(this._map.containerPointToLatLng(L.point(0,size.y)).lat*to_rad);for(let i=center+this._interval/2;i-this._LIMIT_PHI&&this._draw_lat_tick(phi,10,1.5*weight)}for(let i=center-this._interval/2;i>bottom;i-=this._interval){const phi=this._invmerLength(i);phi>-this._LIMIT_PHI&&phi-this._LIMIT_PHI&&this._draw_lat_tick(phi,4,weight)}for(let i=center-this._interval/10;i>bottom;i-=this._interval/10){const phi=this._invmerLength(i);phi>-this._LIMIT_PHI&&phileft.lng;i-=dl)this._draw_lon_tick(i,10,1.5*weight);for(let i=center.lng;ileft.lng;i-=dl/10)this._draw_lon_tick(i,4,weight)},_setCanvasPosition:function(){let lt=this._map.containerPointToLayerPoint([0,0]);this._map._bearing&&(lt=this._map.rotatedPointToMapPanePoint(this._map.containerPointToLayerPoint(L.point(this._map._container.getBoundingClientRect())))),L.DomUtil.setPosition(this._canvas,lt)},_latLngToCanvasPoint:function(latlng){return L.point(this._map.project(L.latLng(latlng))._subtract(this._map.getPixelOrigin())).add(this._map._getMapPanePos())},_draw_lat_tick:function(phi,lenght,weight){const to_deg=180/Math.PI,size=this._map.getSize(),y=this._latLngToCanvasPoint(L.latLng(phi*to_deg,0)).y;this._ctx.lineWidth=weight,this._ctx.beginPath(),this._ctx.moveTo(0,y),this._ctx.lineTo(+lenght,y),this._ctx.stroke()},_draw_lon_tick:function(lam,lenght,weight){const x=this._latLngToCanvasPoint(L.latLng(0,lam)).x;this._ctx.lineWidth=weight,this._ctx.beginPath(),this._ctx.moveTo(x,0),this._ctx.lineTo(x,lenght),this._ctx.stroke()},_merLength:function(phi){const cos2=Math.cos(2*phi),sin2=Math.sin(2*phi);return this._A*(phi+sin2*(this._c1+(this._c2+(this._c3+(this._c4+this._c5*cos2)*cos2)*cos2)*cos2))},_invmerLength:function(s){const psi=s/this._A,cos2=Math.cos(2*psi),sin2=Math.sin(2*psi);return psi+sin2*(this._ic1+(this._ic2+(this._ic3+(this._ic4+this._ic5*cos2)*cos2)*cos2)*cos2)}}),L.control.edgeScale=function(options){return new L.Control.EdgeScale(options)},L.Map.mergeOptions({edgeScaleControl:!1}),L.Map.addInitHook((function(){this.options.edgeScaleControl&&(this.edgeScaleControl=new L.Control.EdgeScale,this.addControl(this.edgeScaleControl))})); -------------------------------------------------------------------------------- /libs/leaflet-gpxgroup.js: -------------------------------------------------------------------------------- 1 | /* 2 | * https://github.com/adoroszlai/joebed/tree/gh-pages 3 | * 4 | * The MIT License (MIT) 5 | * 6 | * Copyright (c) 2014- Doroszlai Attila, 2019- Raruto 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 9 | * this software and associated documentation files (the "Software"), to deal in 10 | * the Software without restriction, including without limitation the rights to 11 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 12 | * the Software, and to permit persons to whom the Software is furnished to do so, 13 | * subject to the following conditions: 14 | * 15 | * The above copyright notice and this permission notice shall be included in all 16 | * copies or substantial portions of the Software. 17 | * 18 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 20 | * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 21 | * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 22 | * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 23 | * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | */ 25 | 26 | L.Mixin.Selectable = { 27 | includes: L.Mixin.Events, 28 | 29 | setSelected: function(s) { 30 | var selected = !!s; 31 | if (this._selected !== selected) { 32 | this._selected = selected; 33 | this.fire('selected'); 34 | } 35 | }, 36 | 37 | isSelected: function() { 38 | return !!this._selected; 39 | }, 40 | }; 41 | 42 | L.Mixin.Selection = { 43 | includes: L.Mixin.Events, 44 | 45 | getSelection: function() { 46 | return this._selected; 47 | }, 48 | 49 | setSelection: function(item) { 50 | if (this._selected === item) { 51 | if (item !== null) { 52 | item.setSelected(!item.isSelected()); 53 | if (!item.isSelected()) { 54 | this._selected = null; 55 | } 56 | } 57 | } else { 58 | if (this._selected) { 59 | this._selected.setSelected(false); 60 | } 61 | this._selected = item; 62 | if (this._selected) { 63 | this._selected.setSelected(true); 64 | } 65 | } 66 | this.fire('selection_changed'); 67 | }, 68 | }; 69 | 70 | L.Control.LayersLegend = L.Control.Layers.extend({ 71 | _onInputClick: function() { 72 | this._handlingClick = true; 73 | 74 | this._layerControlInputs.reduceRight((_,input) => { 75 | if (input.checked) { 76 | this._map.fireEvent("legend_selected", { 77 | layer: this._getLayer(input.layerId).layer, 78 | input: input, 79 | }, true); 80 | return input; 81 | } 82 | }, 0); 83 | 84 | this._handlingClick = false; 85 | 86 | this._refocusOnMap(); 87 | } 88 | }); 89 | 90 | L.control.layersLegend = (baseLayers, overlays, options) => new L.Control.LayersLegend(baseLayers, overlays, options); 91 | 92 | L.GeoJSON.include(L.Mixin.Selectable); 93 | 94 | L.GpxGroup = L.Class.extend({ 95 | options: { 96 | highlight: { 97 | color: '#ff0', 98 | opacity: 1, 99 | }, 100 | points: [], 101 | points_options: { 102 | icon: { 103 | iconUrl: '../images/elevation-poi.png', 104 | iconSize: [12, 12], 105 | } 106 | }, 107 | flyToBounds: true, 108 | legend: false, 109 | legend_options: { 110 | position: "topright", 111 | collapsed: false, 112 | }, 113 | elevation: true, 114 | elevation_options: { 115 | theme: 'yellow-theme', 116 | detached: true, 117 | elevationDiv: '#elevation-div', 118 | }, 119 | distanceMarkers: true, 120 | distanceMarkers_options: { 121 | lazy: true 122 | }, 123 | }, 124 | 125 | initialize: function(tracks, options) { 126 | 127 | L.Util.setOptions(this, options); 128 | 129 | this._count = 0; 130 | this._loadedCount = 0; 131 | this._tracks = tracks; 132 | this._layers = L.featureGroup(); 133 | this._markers = L.featureGroup(); 134 | this._elevation = L.control.elevation(this.options.elevation_options); 135 | this._legend = L.control.layersLegend(null, null, this.options.legend_options); 136 | 137 | this.options.points.forEach((poi) => 138 | L 139 | .marker(poi.latlng, { icon: L.icon(this.options.points_options.icon) }) 140 | .bindTooltip(poi.name, { direction: 'auto' }).addTo(this._markers) 141 | ); 142 | 143 | }, 144 | 145 | getBounds: function() { 146 | return this._layers.getBounds(); 147 | }, 148 | 149 | addTo: function(map) { 150 | this._layers.addTo(map); 151 | this._markers.addTo(map); 152 | 153 | this._map = map; 154 | 155 | this.on('selection_changed', this._onSelectionChanged, this); 156 | this._map.on('legend_selected', this._onLegendSelected, this); 157 | this._tracks.forEach(this.addTrack, this); 158 | 159 | }, 160 | 161 | addTrack: function(track) { 162 | if (track instanceof Object) { 163 | this._loadGeoJSON(track); 164 | } else { 165 | fetch(track) 166 | .then(response => response.ok && response.text()) 167 | .then(text => this._elevation._parseFromString(text)) 168 | .then(geojson => this._loadGeoJSON(geojson, track.split('/').pop().split('#')[0].split('?')[0])); 169 | } 170 | }, 171 | 172 | _loadGeoJSON: function(geojson, fallbackName) { 173 | if (geojson) { 174 | geojson.name = geojson.name || (geojson[0] && geojson[0].properties.name) || fallbackName; 175 | this._loadRoute(geojson); 176 | } 177 | }, 178 | 179 | _loadRoute: function(data) { 180 | if (!data) return; 181 | 182 | var line_style = { 183 | color: this._uniqueColors(this._tracks.length)[this._count++], 184 | opacity: 0.75, 185 | weight: 5, 186 | distanceMarkers: this.options.distanceMarkers_options, 187 | }; 188 | 189 | var route = L.geoJson(data, { 190 | name: data.name || '', 191 | style: (feature) => line_style, 192 | distanceMarkers: line_style.distanceMarkers, 193 | originalStyle: line_style, 194 | filter: feature => feature.geometry.type != "Point", 195 | }); 196 | 197 | 198 | this._elevation.import(this._elevation.__LGEOMUTIL).then(() => { 199 | route.addTo(this._layers); 200 | 201 | route.eachLayer((layer) => this._onEachRouteLayer(route, layer)); 202 | this._onEachRouteLoaded(route); 203 | }); 204 | 205 | }, 206 | 207 | _onEachRouteLayer: function(route, layer) { 208 | var polyline = layer; 209 | 210 | route.on('selected', L.bind(this._onRouteSelected, this, route, polyline)); 211 | 212 | polyline.on('mouseover', L.bind(this._onRouteMouseOver, this, route, polyline)); 213 | polyline.on('mouseout', L.bind(this._onRouteMouseOut, this, route, polyline)); 214 | polyline.on('click', L.bind(this._onRouteClick, this, route, polyline)); 215 | 216 | polyline.bindTooltip(route.options.name, { direction: 'auto', sticky: true, }); 217 | }, 218 | 219 | _onEachRouteLoaded: function(route) { 220 | if (this.options.legend) { 221 | this._legend.addBaseLayer(route, '' + '' + ' ' + route.options.name); 222 | } 223 | 224 | this.fire('route_loaded', { route: route }); 225 | 226 | if (++this._loadedCount === this._tracks.length) { 227 | this.fire('loaded'); 228 | if (this.options.flyToBounds) { 229 | this._map.flyToBounds(this.getBounds(), { duration: 0.25, easeLinearity: 0.25, noMoveStart: true }); 230 | } 231 | if (this.options.legend) { 232 | this._legend.addTo(this._map); 233 | } 234 | } 235 | }, 236 | 237 | highlight: function(route, polyline) { 238 | polyline.setStyle(this.options.highlight); 239 | if (this.options.distanceMarkers) { 240 | polyline.addDistanceMarkers(); 241 | } 242 | }, 243 | 244 | unhighlight: function(route, polyline) { 245 | polyline.setStyle(route.options.originalStyle); 246 | if (this.options.distanceMarkers) { 247 | polyline.removeDistanceMarkers(); 248 | } 249 | }, 250 | 251 | _onRouteMouseOver: function(route, polyline) { 252 | if (!route.isSelected()) { 253 | this.highlight(route, polyline); 254 | if (this.options.legend) { 255 | this.setSelection(route); 256 | L.DomUtil.get('legend_' + route._leaflet_id).parentNode.previousSibling.click(); 257 | } 258 | } 259 | this.fire('route_mouseover', { route: route, polyline: polyline }); 260 | }, 261 | 262 | _onRouteMouseOut: function(route, polyline) { 263 | if (!route.isSelected()) { 264 | this.unhighlight(route, polyline); 265 | } 266 | this.fire('route_mouseout', { route: route, polyline: polyline }); 267 | }, 268 | 269 | _onRouteClick: function(route, polyline) { 270 | this.setSelection(route); 271 | }, 272 | 273 | _onRouteSelected: function(route, polyline) { 274 | if (!route.isSelected()) { 275 | this.unhighlight(route, polyline); 276 | } 277 | }, 278 | 279 | _onSelectionChanged: function(e) { 280 | var elevation = this._elevation; 281 | var eleDiv = elevation.getContainer(); 282 | var route = this.getSelection(); 283 | 284 | elevation.clear(); 285 | 286 | if (route && route.isSelected()) { 287 | if (!eleDiv) { 288 | elevation.addTo(this._map); 289 | } 290 | route.getLayers().forEach(function(layer) { 291 | if (layer instanceof L.Polyline) { 292 | elevation.addData(layer, false); 293 | layer.bringToFront(); 294 | } 295 | }); 296 | } else { 297 | if (eleDiv) { 298 | elevation.remove(); 299 | } 300 | } 301 | }, 302 | 303 | _onLegendSelected: function(e) { 304 | var parent = e.input.closest('.leaflet-control-layers-list'); 305 | var route = e.layer; 306 | 307 | if (!route.isSelected()) { 308 | this.setSelection(route); 309 | for (var i in route._layers) { 310 | this.highlight(route, route._layers[i]); 311 | } 312 | this._map.flyToBounds(e.layer.getBounds()); 313 | } 314 | 315 | parent.scroll({ top: (e.input.offsetTop - parent.offsetTop) || 0, behavior: 'smooth' }); 316 | 317 | this._layers.eachLayer(layer => { 318 | var legend = L.DomUtil.get('legend_' + layer._leaflet_id); 319 | legend.querySelector("line").style.stroke = layer.isSelected() ? this.options.highlight.color : ""; 320 | legend.parentNode.style.fontWeight = layer.isSelected() ? "700" : ""; 321 | }); 322 | }, 323 | 324 | _uniqueColors: function(count) { 325 | return count === 1 ? ['#0000ff'] : new Array(count).fill(null).map((_,i) => this._hsvToHex(i * (1 / count), 1, 0.7)); 326 | }, 327 | 328 | _hsvToHex: function(h, s, v) { 329 | var i = Math.floor(h * 6); 330 | var f = h * 6 - i; 331 | var p = v * (1 - s); 332 | var q = v * (1 - f * s); 333 | var t = v * (1 - (1 - f) * s); 334 | var rgb = { 0: [v, t, p], 1: [q, v, p], 2: [p, v, t], 3: [p, q, v], 4: [t, p, v], 5: [v, p, q] }[i % 6]; 335 | return rgb.map(d => d * 255).reduce((hex, byte) => hex + ((byte >> 4) & 0x0F).toString(16) + (byte & 0x0F).toString(16), "#"); 336 | }, 337 | 338 | removeFrom: function(map) { 339 | this._layers.removeFrom(map); 340 | }, 341 | 342 | }); 343 | 344 | L.GpxGroup.include(L.Mixin.Events); 345 | L.GpxGroup.include(L.Mixin.Selection); 346 | 347 | L.gpxGroup = (tracks, options) => new L.GpxGroup(tracks, options); -------------------------------------------------------------------------------- /libs/leaflet-gpxgroup.min.js: -------------------------------------------------------------------------------- 1 | L.Mixin.Selectable={includes:L.Mixin.Events,setSelected:function(s){var selected=!!s;this._selected!==selected&&(this._selected=selected,this.fire("selected"))},isSelected:function(){return!!this._selected}},L.Mixin.Selection={includes:L.Mixin.Events,getSelection:function(){return this._selected},setSelection:function(item){this._selected===item?null!==item&&(item.setSelected(!item.isSelected()),item.isSelected()||(this._selected=null)):(this._selected&&this._selected.setSelected(!1),this._selected=item,this._selected&&this._selected.setSelected(!0)),this.fire("selection_changed")}},L.Control.LayersLegend=L.Control.Layers.extend({_onInputClick:function(){this._handlingClick=!0,this._layerControlInputs.reduceRight((_,input)=>{if(input.checked)return this._map.fireEvent("legend_selected",{layer:this._getLayer(input.layerId).layer,input:input},!0),input},0),this._handlingClick=!1,this._refocusOnMap()}}),L.control.layersLegend=(baseLayers,overlays,options)=>new L.Control.LayersLegend(baseLayers,overlays,options),L.GeoJSON.include(L.Mixin.Selectable),L.GpxGroup=L.Class.extend({options:{highlight:{color:"#ff0",opacity:1},points:[],points_options:{icon:{iconUrl:"../images/elevation-poi.png",iconSize:[12,12]}},flyToBounds:!0,legend:!1,legend_options:{position:"topright",collapsed:!1},elevation:!0,elevation_options:{theme:"yellow-theme",detached:!0,elevationDiv:"#elevation-div"},distanceMarkers:!0,distanceMarkers_options:{lazy:!0}},initialize:function(tracks,options){L.Util.setOptions(this,options),this._count=0,this._loadedCount=0,this._tracks=tracks,this._layers=L.featureGroup(),this._markers=L.featureGroup(),this._elevation=L.control.elevation(this.options.elevation_options),this._legend=L.control.layersLegend(null,null,this.options.legend_options),this.options.points.forEach(poi=>L.marker(poi.latlng,{icon:L.icon(this.options.points_options.icon)}).bindTooltip(poi.name,{direction:"auto"}).addTo(this._markers))},getBounds:function(){return this._layers.getBounds()},addTo:function(map){this._layers.addTo(map),this._markers.addTo(map),this._map=map,this.on("selection_changed",this._onSelectionChanged,this),this._map.on("legend_selected",this._onLegendSelected,this),this._tracks.forEach(this.addTrack,this)},addTrack:function(track){track instanceof Object?this._loadGeoJSON(track):fetch(track).then(response=>response.ok&&response.text()).then(text=>this._elevation._parseFromString(text)).then(geojson=>this._loadGeoJSON(geojson,track.split("/").pop().split("#")[0].split("?")[0]))},_loadGeoJSON:function(geojson,fallbackName){geojson&&(geojson.name=geojson.name||geojson[0]&&geojson[0].properties.name||fallbackName,this._loadRoute(geojson))},_loadRoute:function(data){if(data){var line_style={color:this._uniqueColors(this._tracks.length)[this._count++],opacity:.75,weight:5,distanceMarkers:this.options.distanceMarkers_options},route=L.geoJson(data,{name:data.name||"",style:feature=>line_style,distanceMarkers:line_style.distanceMarkers,originalStyle:line_style,filter:feature=>"Point"!=feature.geometry.type});this._elevation.import(this._elevation.__LGEOMUTIL).then(()=>{route.addTo(this._layers),route.eachLayer(layer=>this._onEachRouteLayer(route,layer)),this._onEachRouteLoaded(route)})}},_onEachRouteLayer:function(route,layer){var polyline=layer;route.on("selected",L.bind(this._onRouteSelected,this,route,polyline)),polyline.on("mouseover",L.bind(this._onRouteMouseOver,this,route,polyline)),polyline.on("mouseout",L.bind(this._onRouteMouseOut,this,route,polyline)),polyline.on("click",L.bind(this._onRouteClick,this,route,polyline)),polyline.bindTooltip(route.options.name,{direction:"auto",sticky:!0})},_onEachRouteLoaded:function(route){this.options.legend&&this._legend.addBaseLayer(route,' '+route.options.name),this.fire("route_loaded",{route:route}),++this._loadedCount===this._tracks.length&&(this.fire("loaded"),this.options.flyToBounds&&this._map.flyToBounds(this.getBounds(),{duration:.25,easeLinearity:.25,noMoveStart:!0}),this.options.legend&&this._legend.addTo(this._map))},highlight:function(route,polyline){polyline.setStyle(this.options.highlight),this.options.distanceMarkers&&polyline.addDistanceMarkers()},unhighlight:function(route,polyline){polyline.setStyle(route.options.originalStyle),this.options.distanceMarkers&&polyline.removeDistanceMarkers()},_onRouteMouseOver:function(route,polyline){route.isSelected()||(this.highlight(route,polyline),this.options.legend&&(this.setSelection(route),L.DomUtil.get("legend_"+route._leaflet_id).parentNode.previousSibling.click())),this.fire("route_mouseover",{route:route,polyline:polyline})},_onRouteMouseOut:function(route,polyline){route.isSelected()||this.unhighlight(route,polyline),this.fire("route_mouseout",{route:route,polyline:polyline})},_onRouteClick:function(route,polyline){this.setSelection(route)},_onRouteSelected:function(route,polyline){route.isSelected()||this.unhighlight(route,polyline)},_onSelectionChanged:function(e){var elevation=this._elevation,eleDiv=elevation.getContainer(),route=this.getSelection();elevation.clear(),route&&route.isSelected()?(eleDiv||elevation.addTo(this._map),route.getLayers().forEach((function(layer){layer instanceof L.Polyline&&(elevation.addData(layer,!1),layer.bringToFront())}))):eleDiv&&elevation.remove()},_onLegendSelected:function(e){var parent=e.input.closest(".leaflet-control-layers-list"),route=e.layer;if(!route.isSelected()){for(var i in this.setSelection(route),route._layers)this.highlight(route,route._layers[i]);this._map.flyToBounds(e.layer.getBounds())}parent.scroll({top:e.input.offsetTop-parent.offsetTop||0,behavior:"smooth"}),this._layers.eachLayer(layer=>{var legend=L.DomUtil.get("legend_"+layer._leaflet_id);legend.querySelector("line").style.stroke=layer.isSelected()?this.options.highlight.color:"",legend.parentNode.style.fontWeight=layer.isSelected()?"700":""})},_uniqueColors:function(count){return 1===count?["#0000ff"]:new Array(count).fill(null).map((_,i)=>this._hsvToHex(i*(1/count),1,.7))},_hsvToHex:function(h,s,v){var i=Math.floor(6*h),f=6*h-i,p=v*(1-s),q=v*(1-f*s),t=v*(1-(1-f)*s),rgb;return{0:[v,t,p],1:[q,v,p],2:[p,v,t],3:[p,q,v],4:[t,p,v],5:[v,p,q]}[i%6].map(d=>255*d).reduce((hex,byte)=>hex+(byte>>4&15).toString(16)+(15&byte).toString(16),"#")},removeFrom:function(map){this._layers.removeFrom(map)}}),L.GpxGroup.include(L.Mixin.Events),L.GpxGroup.include(L.Mixin.Selection),L.gpxGroup=(tracks,options)=>new L.GpxGroup(tracks,options); -------------------------------------------------------------------------------- /libs/leaflet-hotline.js: -------------------------------------------------------------------------------- 1 | /* 2 | (c) 2017, iosphere GmbH 3 | Leaflet.hotline, a Leaflet plugin for drawing gradients along polylines. 4 | https://github.com/iosphere/Leaflet.hotline/ 5 | */ 6 | 7 | (function (root, plugin) { 8 | /** 9 | * UMD wrapper. 10 | * When used directly in the Browser it expects Leaflet to be globally 11 | * available as `L`. The plugin then adds itself to Leaflet. 12 | * When used as a CommonJS module (e.g. with browserify) only the plugin 13 | * factory gets exported, so one hast to call the factory manually and pass 14 | * Leaflet as the only parameter. 15 | * @see {@link https://github.com/umdjs/umd} 16 | */ 17 | if (typeof define === 'function' && define.amd) { 18 | define(['leaflet'], plugin); 19 | } else if (typeof exports === 'object') { 20 | module.exports = plugin; 21 | } else { 22 | plugin(root.L); 23 | } 24 | }(window, function (L) { 25 | // Plugin is already added to Leaflet 26 | if (L.Hotline) { 27 | return L; 28 | } 29 | 30 | /** 31 | * Core renderer. 32 | * @constructor 33 | * @param {HTMLElement | string} canvas - <canvas> element or its id 34 | * to initialize the instance on. 35 | */ 36 | var Hotline = function (canvas) { 37 | if (!(this instanceof Hotline)) { return new Hotline(canvas); } 38 | 39 | var defaultPalette = { 40 | 0.0: 'green', 41 | 0.5: 'yellow', 42 | 1.0: 'red' 43 | }; 44 | 45 | this._canvas = canvas = ('string' === typeof canvas) 46 | ? document.getElementById(canvas) 47 | : canvas; 48 | 49 | this._ctx = canvas.getContext('2d'); 50 | this._width = canvas.width; 51 | this._height = canvas.height; 52 | 53 | this._weight = 5; 54 | this._outlineWidth = 1; 55 | this._outlineColor = 'black'; 56 | 57 | this._min = 0; 58 | this._max = 1; 59 | 60 | this._data = []; 61 | 62 | this.palette(defaultPalette); 63 | }; 64 | 65 | Hotline.prototype = { 66 | /** 67 | * Sets the width of the canvas. Used when clearing the canvas. 68 | * @param {number} width - Width of the canvas. 69 | */ 70 | width: function (width) { 71 | this._width = width; 72 | return this; 73 | }, 74 | 75 | /** 76 | * Sets the height of the canvas. Used when clearing the canvas. 77 | * @param {number} height - Height of the canvas. 78 | */ 79 | height: function (height) { 80 | this._height = height; 81 | return this; 82 | }, 83 | 84 | /** 85 | * Sets the weight of the path. 86 | * @param {number} weight - Weight of the path in px. 87 | */ 88 | weight: function (weight) { 89 | this._weight = weight; 90 | return this; 91 | }, 92 | 93 | /** 94 | * Sets the width of the outline around the path. 95 | * @param {number} outlineWidth - Width of the outline in px. 96 | */ 97 | outlineWidth: function (outlineWidth) { 98 | this._outlineWidth = outlineWidth; 99 | return this; 100 | }, 101 | 102 | /** 103 | * Sets the color of the outline around the path. 104 | * @param {string} outlineColor - A CSS color value. 105 | */ 106 | outlineColor: function (outlineColor) { 107 | this._outlineColor = outlineColor; 108 | return this; 109 | }, 110 | 111 | /** 112 | * Sets the palette gradient. 113 | * @param {Object.} palette - Gradient definition. 114 | * e.g. { 0.0: 'white', 1.0: 'black' } 115 | */ 116 | palette: function (palette) { 117 | var canvas = document.createElement('canvas'), 118 | ctx = canvas.getContext('2d'), 119 | gradient = ctx.createLinearGradient(0, 0, 0, 256); 120 | 121 | canvas.width = 1; 122 | canvas.height = 256; 123 | 124 | for (var i in palette) { 125 | gradient.addColorStop(i, palette[i]); 126 | } 127 | 128 | ctx.fillStyle = gradient; 129 | ctx.fillRect(0, 0, 1, 256); 130 | 131 | this._palette = ctx.getImageData(0, 0, 1, 256).data; 132 | 133 | return this; 134 | }, 135 | 136 | /** 137 | * Sets the value used at the start of the palette gradient. 138 | * @param {number} min 139 | */ 140 | min: function (min) { 141 | this._min = min; 142 | return this; 143 | }, 144 | 145 | /** 146 | * Sets the value used at the end of the palette gradient. 147 | * @param {number} max 148 | */ 149 | max: function (max) { 150 | this._max = max; 151 | return this; 152 | }, 153 | 154 | /** 155 | * A path to rander as a hotline. 156 | * @typedef Array.<{x:number, y:number, z:number}> Path - Array of x, y and z coordinates. 157 | */ 158 | 159 | /** 160 | * Sets the data that gets drawn on the canvas. 161 | * @param {(Path|Path[])} data - A single path or an array of paths. 162 | */ 163 | data: function (data) { 164 | this._data = data; 165 | return this; 166 | }, 167 | 168 | /** 169 | * Adds a path to the list of paths. 170 | * @param {Path} path 171 | */ 172 | add: function (path) { 173 | this._data.push(path); 174 | return this; 175 | }, 176 | 177 | /** 178 | * Draws the currently set paths. 179 | */ 180 | draw: function () { 181 | var ctx = this._ctx; 182 | 183 | ctx.globalCompositeOperation = 'source-over'; 184 | ctx.lineCap = 'round'; 185 | 186 | this._drawOutline(ctx); 187 | this._drawHotline(ctx); 188 | 189 | return this; 190 | }, 191 | 192 | /** 193 | * Gets the RGB values of a given z value of the current palette. 194 | * @param {number} value - Value to get the color for, should be between min and max. 195 | * @returns {Array.} The RGB values as an array [r, g, b] 196 | */ 197 | getRGBForValue: function (value) { 198 | var valueRelative = Math.min(Math.max((value - this._min) / (this._max - this._min), 0), 0.999); 199 | var paletteIndex = Math.floor(valueRelative * 256) * 4; 200 | 201 | return [ 202 | this._palette[paletteIndex], 203 | this._palette[paletteIndex + 1], 204 | this._palette[paletteIndex + 2] 205 | ]; 206 | }, 207 | 208 | /** 209 | * Draws the outline of the graphs. 210 | * @private 211 | */ 212 | _drawOutline: function (ctx) { 213 | var i, j, dataLength, path, pathLength, pointStart, pointEnd; 214 | 215 | if (this._outlineWidth) { 216 | for (i = 0, dataLength = this._data.length; i < dataLength; i++) { 217 | path = this._data[i]; 218 | ctx.lineWidth = this._weight + 2 * this._outlineWidth; 219 | 220 | for (j = 1, pathLength = path.length; j < pathLength; j++) { 221 | pointStart = path[j - 1]; 222 | pointEnd = path[j]; 223 | 224 | ctx.strokeStyle = this._outlineColor; 225 | ctx.beginPath(); 226 | ctx.moveTo(pointStart.x, pointStart.y); 227 | ctx.lineTo(pointEnd.x, pointEnd.y); 228 | ctx.stroke(); 229 | } 230 | } 231 | } 232 | }, 233 | 234 | /** 235 | * Draws the color encoded hotline of the graphs. 236 | * @private 237 | */ 238 | _drawHotline: function (ctx) { 239 | var i, j, dataLength, path, pathLength, pointStart, pointEnd, 240 | gradient, gradientStartRGB, gradientEndRGB; 241 | 242 | ctx.lineWidth = this._weight; 243 | 244 | for (i = 0, dataLength = this._data.length; i < dataLength; i++) { 245 | path = this._data[i]; 246 | 247 | for (j = 1, pathLength = path.length; j < pathLength; j++) { 248 | pointStart = path[j - 1]; 249 | pointEnd = path[j]; 250 | 251 | // Create a gradient for each segment, pick start end end colors from palette gradient 252 | gradient = ctx.createLinearGradient(pointStart.x, pointStart.y, pointEnd.x, pointEnd.y); 253 | gradientStartRGB = this.getRGBForValue(pointStart.z); 254 | gradientEndRGB = this.getRGBForValue(pointEnd.z); 255 | gradient.addColorStop(0, 'rgb(' + gradientStartRGB.join(',') + ')'); 256 | gradient.addColorStop(1, 'rgb(' + gradientEndRGB.join(',') + ')'); 257 | 258 | ctx.strokeStyle = gradient; 259 | ctx.beginPath(); 260 | ctx.moveTo(pointStart.x, pointStart.y); 261 | ctx.lineTo(pointEnd.x, pointEnd.y); 262 | ctx.stroke(); 263 | } 264 | } 265 | } 266 | }; 267 | 268 | 269 | var Renderer = L.Canvas.extend({ 270 | _initContainer: function () { 271 | L.Canvas.prototype._initContainer.call(this); 272 | this._hotline = new Hotline(this._container); 273 | }, 274 | 275 | _update: function () { 276 | L.Canvas.prototype._update.call(this); 277 | this._hotline.width(this._container.width); 278 | this._hotline.height(this._container.height); 279 | }, 280 | 281 | _updatePoly: function (layer) { 282 | if (!this._drawing) { return; } 283 | 284 | var parts = layer._parts; 285 | 286 | if (!parts.length) { return; } 287 | 288 | this._updateOptions(layer); 289 | 290 | this._hotline 291 | .data(parts) 292 | .draw(); 293 | }, 294 | 295 | _updateOptions: function (layer) { 296 | if (layer.options.min != null) { 297 | this._hotline.min(layer.options.min); 298 | } 299 | if (layer.options.max != null) { 300 | this._hotline.max(layer.options.max); 301 | } 302 | if (layer.options.weight != null) { 303 | this._hotline.weight(layer.options.weight); 304 | } 305 | if (layer.options.outlineWidth != null) { 306 | this._hotline.outlineWidth(layer.options.outlineWidth); 307 | } 308 | if (layer.options.outlineColor != null) { 309 | this._hotline.outlineColor(layer.options.outlineColor); 310 | } 311 | if (layer.options.palette) { 312 | this._hotline.palette(layer.options.palette); 313 | } 314 | } 315 | }); 316 | 317 | var renderer = function (options) { 318 | return L.Browser.canvas ? new Renderer(options) : null; 319 | }; 320 | 321 | 322 | var Util = { 323 | /** 324 | * This is just a copy of the original Leaflet version that support a third z coordinate. 325 | * @see {@link http://leafletjs.com/reference.html#lineutil-clipsegment|Leaflet} 326 | */ 327 | clipSegment: function (a, b, bounds, useLastCode, round) { 328 | var codeA = useLastCode ? this._lastCode : L.LineUtil._getBitCode(a, bounds), 329 | codeB = L.LineUtil._getBitCode(b, bounds), 330 | codeOut, p, newCode; 331 | 332 | // save 2nd code to avoid calculating it on the next segment 333 | this._lastCode = codeB; 334 | 335 | while (true) { 336 | // if a,b is inside the clip window (trivial accept) 337 | if (!(codeA | codeB)) { 338 | return [a, b]; 339 | // if a,b is outside the clip window (trivial reject) 340 | } else if (codeA & codeB) { 341 | return false; 342 | // other cases 343 | } else { 344 | codeOut = codeA || codeB; 345 | p = L.LineUtil._getEdgeIntersection(a, b, codeOut, bounds, round); 346 | newCode = L.LineUtil._getBitCode(p, bounds); 347 | 348 | if (codeOut === codeA) { 349 | p.z = a.z; 350 | a = p; 351 | codeA = newCode; 352 | } else { 353 | p.z = b.z; 354 | b = p; 355 | codeB = newCode; 356 | } 357 | } 358 | } 359 | } 360 | }; 361 | 362 | 363 | L.Hotline = L.Polyline.extend({ 364 | statics: { 365 | Renderer: Renderer, 366 | renderer: renderer 367 | }, 368 | 369 | options: { 370 | renderer: renderer(), 371 | min: 0, 372 | max: 1, 373 | palette: { 374 | 0.0: 'green', 375 | 0.5: 'yellow', 376 | 1.0: 'red' 377 | }, 378 | weight: 5, 379 | outlineColor: 'black', 380 | outlineWidth: 1 381 | }, 382 | 383 | getRGBForValue: function (value) { 384 | return this._renderer._hotline.getRGBForValue(value); 385 | }, 386 | 387 | /** 388 | * Just like the Leaflet version, but with support for a z coordinate. 389 | */ 390 | _projectLatlngs: function (latlngs, result, projectedBounds) { 391 | var flat = latlngs[0] instanceof L.LatLng, 392 | len = latlngs.length, 393 | i, ring; 394 | 395 | if (flat) { 396 | ring = []; 397 | for (i = 0; i < len; i++) { 398 | ring[i] = this._map.latLngToLayerPoint(latlngs[i]); 399 | // Add the altitude of the latLng as the z coordinate to the point 400 | ring[i].z = latlngs[i].alt; 401 | projectedBounds.extend(ring[i]); 402 | } 403 | result.push(ring); 404 | } else { 405 | for (i = 0; i < len; i++) { 406 | this._projectLatlngs(latlngs[i], result, projectedBounds); 407 | } 408 | } 409 | }, 410 | 411 | /** 412 | * Just like the Leaflet version, but uses `Util.clipSegment()`. 413 | */ 414 | _clipPoints: function () { 415 | if (this.options.noClip) { 416 | this._parts = this._rings; 417 | return; 418 | } 419 | 420 | this._parts = []; 421 | 422 | var parts = this._parts, 423 | bounds = this._renderer._bounds, 424 | i, j, k, len, len2, segment, points; 425 | 426 | for (i = 0, k = 0, len = this._rings.length; i < len; i++) { 427 | points = this._rings[i]; 428 | 429 | for (j = 0, len2 = points.length; j < len2 - 1; j++) { 430 | segment = Util.clipSegment(points[j], points[j + 1], bounds, j, true); 431 | 432 | if (!segment) { continue; } 433 | 434 | parts[k] = parts[k] || []; 435 | parts[k].push(segment[0]); 436 | 437 | // if segment goes out of screen, or it's the last one, it's the end of the line part 438 | if ((segment[1] !== points[j + 1]) || (j === len2 - 2)) { 439 | parts[k].push(segment[1]); 440 | k++; 441 | } 442 | } 443 | } 444 | }, 445 | 446 | _clickTolerance: function () { 447 | return this.options.weight / 2 + this.options.outlineWidth + (L.Browser.touch ? 10 : 0); 448 | } 449 | }); 450 | 451 | L.hotline = function (latlngs, options) { 452 | return new L.Hotline(latlngs, options); 453 | }; 454 | 455 | 456 | return L; 457 | })); -------------------------------------------------------------------------------- /libs/leaflet-hotline.min.js: -------------------------------------------------------------------------------- 1 | !function(root,plugin){"function"==typeof define&&define.amd?define(["leaflet"],plugin):"object"==typeof exports?module.exports=plugin:plugin(root.L)}(window,(function(L){if(L.Hotline)return L;var Hotline=function(canvas){if(!(this instanceof Hotline))return new Hotline(canvas);var defaultPalette={0:"green",.5:"yellow",1:"red"};this._canvas=canvas="string"==typeof canvas?document.getElementById(canvas):canvas,this._ctx=canvas.getContext("2d"),this._width=canvas.width,this._height=canvas.height,this._weight=5,this._outlineWidth=1,this._outlineColor="black",this._min=0,this._max=1,this._data=[],this.palette(defaultPalette)};Hotline.prototype={width:function(width){return this._width=width,this},height:function(height){return this._height=height,this},weight:function(weight){return this._weight=weight,this},outlineWidth:function(outlineWidth){return this._outlineWidth=outlineWidth,this},outlineColor:function(outlineColor){return this._outlineColor=outlineColor,this},palette:function(palette){var canvas=document.createElement("canvas"),ctx=canvas.getContext("2d"),gradient=ctx.createLinearGradient(0,0,0,256);for(var i in canvas.width=1,canvas.height=256,palette)gradient.addColorStop(i,palette[i]);return ctx.fillStyle=gradient,ctx.fillRect(0,0,1,256),this._palette=ctx.getImageData(0,0,1,256).data,this},min:function(min){return this._min=min,this},max:function(max){return this._max=max,this},data:function(data){return this._data=data,this},add:function(path){return this._data.push(path),this},draw:function(){var ctx=this._ctx;return ctx.globalCompositeOperation="source-over",ctx.lineCap="round",this._drawOutline(ctx),this._drawHotline(ctx),this},getRGBForValue:function(value){var valueRelative=Math.min(Math.max((value-this._min)/(this._max-this._min),0),.999),paletteIndex=4*Math.floor(256*valueRelative);return[this._palette[paletteIndex],this._palette[paletteIndex+1],this._palette[paletteIndex+2]]},_drawOutline:function(ctx){var i,j,dataLength,path,pathLength,pointStart,pointEnd;if(this._outlineWidth)for(i=0,dataLength=this._data.length;iIcons made by Freepik from www.flaticon.com is licensed by CC 3.0 BY */ 5 | background-repeat: no-repeat; 6 | background-position: center; 7 | } 8 | .leaflet-ruler-clicked, 9 | .leaflet-ruler:hover { 10 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAE/ElEQVRIS6WVeWwUVRzHfzOzs7Pdi+7Rdq/ubrH0sNaKBFMCSEvBQLivFkhAE8GDYAyHATWgETUoMZ7BGCMBNKYiQbTKpYQKBqKlWmBbadmW7bbbFjv0mu2eM298M5SlpS3U+LL/zL6Zz/f7vr/3e4+AsQ6j80EgxTkKhGwkQRqQiLp5UtEOSDwOXf660TDEPfkGR75RTT8VF1EpJRJ6LsbrVuaZg2YtrWND8WD5FVajV9IcTyCOoYhv2QgcgE5fzWDmaAKU0eHa3RMStszNMqIdM+zUY3YdPFvRCG+VOCFFQ0N3mIfNJ3ywb3Em/BHgYNeZFnTc20OOU1Efd7eZtwBUxyWh4QLJGS6jFv04MU2bNWeCQRnoi8L7czPuG+RLJ31g0Smhsqk3dqGVa7wZZOZBb/31oQKm8ZMZRbxyU6FV9XaJmyQGZsNxBD/Ud8FXNWy/rzci9ER4Spqa5tYrit3jmNI8M2DnCRNvnm1Bu35tjcR4lH1HIDtbp+2NNuSmqCyTrBr4dMEDuH4A754LCLvOtQo44zouKp7hQagGIK+CIFgoipqPEHru6OocWJhtlAWk6GZ8cSXs64sd49qbSxMCaenu7+ZkGuftW5JJSy/FBARlh+vDF1tDfWEerYWullNDcjK5c9Q0Or/jcad++3SbbP8ueBn+S7glkOIsNTDUQf+mRxmtkoIIj6Bgb024hYtVhGPK9dDl7RsJvhPDtw3AcWzYuSd8vTcqOS8DrcUIKsUiWcDgyKghRLFgZ1E6vFhohfXfN8YP1bFVfW3N0/E0Ggu8CMNxfY9z7b5SCU4o6dMkhc4RYM61aulwU8fWSSqaIqEqEIRZBzzBSCg+HoIdncPglHgBG9ENdj4inEAXhZuBdQSYXBuWZOnfOLI61yTB1ld4Y1/+xe6Ndvo33Q+ON5Rc0GHOB+DS6gmF2XF6dkZy0eeLM8lU3EDGd6qiQRAnQnvz3wkBXFANdr5jkHMJXrS/NtTIRk5yN3wrErEMgsuNpkp13ViRZ0zdOtUOWiUJ2R/WxPibfmYk+Mp8E/XCT01y4y0/1HBfuCygMDljVzYW0DnmJKhuC8Lsg7WB7kCzQxa4y7nUF08euQblHlZMoqij93J+2yBBpzgjlzcUMJLAmeu9sOjrq+1cR7PtNvzVYrv+5WkOUvpAiqUYx+JlI6cwfPlosQyuHcGYnYECq9r2AV62O5mB9PcuItxjj2ho6uz/hcsRESbH4TX5qQv3LsigNbjJ7Huq+tkIT75enM4Mdx76mbvhXzYW54mIwOBeNd2l+eTs03nyYYIPqrhaSVGbC213xfLf4fIKAB9yqp4IW/VMvtKqVYJJrUhEeCdzGS5lbki22Wu5btYg0IwV2hrYIb0ywoN8VCTbXadDUTRz/9IJsCrfLL82CP7LQCyGcVab56HColQkCKL3ctWxTs/5+WMSAHwPGBih0rNxotqGL42R4DqLpebhKTNta7fvgWBfN7yybIoQjYdzoLPVey+RxHFtdrhfy05J2laxKjep5EAd3oqhhHNtqrm6YOoT6RKcIOXSwEdb1ojXLv35fDRQ99mYBKR6GOyu3zBhcowXTvR3+JdCWhqjVhk9FEW7Z5Wtw1vuFlwatb9XQmuD51J/MFQ+qgAheIdemSl5FoDgOuhs3o0/4vWOKcZkh/4bEJFKxAMA/6QhgsjzAvrH3+SXn0YZAinU/wuTosgK53+p+wAAAABJRU5ErkJggg=="); /*
Icons made by Freepik from www.flaticon.com is licensed by CC 3.0 BY
*/ 11 | } 12 | .leaflet-ruler-clicked { 13 | height: 35px; 14 | width: 35px; 15 | background-repeat: no-repeat; 16 | background-position: center; 17 | border-color: chartreuse !important; 18 | } 19 | .leaflet-bar.leaflet-ruler { 20 | background-color: #ffffff; 21 | } 22 | .leaflet-control.leaflet-ruler { 23 | cursor: pointer; 24 | } 25 | .result-tooltip { 26 | background-color: white; 27 | border-width: medium; 28 | border-color: #de0000; 29 | font-size: smaller; 30 | } 31 | .moving-tooltip { 32 | background-color: rgba(255, 255, 255, .7); 33 | background-clip: padding-box; 34 | opacity: 0.5; 35 | border: dotted; 36 | border-color: red; 37 | font-size: smaller; 38 | } 39 | .plus-length { 40 | padding-left: 45px; 41 | } 42 | -------------------------------------------------------------------------------- /libs/leaflet-ruler.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2022, GPL-3.0+ Project, Raruto 3 | * 4 | * This file is free software: you may copy, redistribute and/or modify it 5 | * under the terms of the GNU General Public License as published by the 6 | * Free Software Foundation, either version 2 of the License, or (at your 7 | * option) any later version. 8 | * 9 | * This file is distributed in the hope that it will be useful, but 10 | * WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | * General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU General Public License 15 | * along with this program. If not, see . 16 | * 17 | * This file incorporates work covered by the following copyright and 18 | * permission notice: 19 | * 20 | * Copyright (c) 2017 Goker Tanrisever 21 | * 22 | * Permission to use, copy, modify, and/or distribute this software 23 | * for any purpose with or without fee is hereby granted, provided 24 | * that the above copyright notice and this permission notice appear 25 | * in all copies. 26 | * 27 | * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL 28 | * WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED 29 | * WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE 30 | * AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR 31 | * CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 32 | * OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, 33 | * NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 34 | * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 35 | */ 36 | 37 | L.Control.Ruler = L.Control.extend({ 38 | options: { 39 | position: 'topright', 40 | circleMarker: { 41 | color: 'red', 42 | radius: 2, 43 | }, 44 | lineStyle: { 45 | color: 'red', 46 | dashArray: '1,6' 47 | }, 48 | lengthUnit: { 49 | display: 'km', 50 | decimal: 2, 51 | factor: 0.001, // meters -> kilometers 52 | label: 'Distance:' 53 | }, 54 | angleUnit: { 55 | display: '°', 56 | decimal: 2, 57 | factor: 360, 58 | label: 'Bearing:' 59 | } 60 | }, 61 | initialize: function (options) { 62 | L.setOptions(this, options); 63 | this._layers = L.layerGroup(); 64 | this._enabled = false; 65 | }, 66 | onAdd: function (map) { 67 | this._defaultCursor = map._container.style.cursor; 68 | this._map = map; 69 | let container = L.DomUtil.create('div', 'leaflet-bar'); 70 | container.classList.add('leaflet-ruler'); 71 | L.DomEvent.disableClickPropagation(container); 72 | L.DomEvent.on(container, 'click', this._toggleMeasure, this); 73 | return this._container = container; 74 | }, 75 | onRemove: function () { 76 | L.DomEvent.off(this._container, 'click', this._toggleMeasure, this); 77 | }, 78 | _attachMouseEvents: function () { 79 | let map = this._map; 80 | map.doubleClickZoom.disable(); 81 | L.DomEvent.on(map._container, 'keydown', this._escape, this); 82 | L.DomEvent.on(map._container, 'dblclick', this._closePath, this); 83 | map._container.style.cursor = 'crosshair'; 84 | map.on('click', this._addPoint, this); 85 | map.on('mousemove', this._moving, this); 86 | }, 87 | _removeMouseEvents: function () { 88 | let map = this._map; 89 | map.doubleClickZoom.enable(); 90 | L.DomEvent.off(map._container, 'keydown', this._escape, this); 91 | L.DomEvent.off(map._container, 'dblclick', this._closePath, this); 92 | map._container.style.cursor = this._defaultCursor; 93 | map.off('click', this._addPoint, this); 94 | map.off('mousemove', this._moving, this); 95 | }, 96 | _disable: function () { 97 | this._enabled = false; 98 | this._container.classList.remove("leaflet-ruler-clicked"); 99 | this._layers.remove().clearLayers(); 100 | this._latlngs = []; 101 | this._totalLength = 0; 102 | this._removeMouseEvents(); 103 | }, 104 | _enable: function () { 105 | this._enabled = true; 106 | this._container.classList.add("leaflet-ruler-clicked"); 107 | this._circles = L.featureGroup().addTo(this._layers); 108 | this._polyline = L.polyline([], this.options.lineStyle).addTo(this._layers); 109 | this._layers.addTo(this._map); 110 | this._latlngs = []; 111 | this._totalLength = 0; 112 | this._attachMouseEvents(); 113 | }, 114 | _toggleMeasure: function () { 115 | this._enabled ? this._disable() : this._enable(); 116 | }, 117 | _drawTooltip: function (latlng, layer, incremental) { 118 | let lastClick = this._latlngs[this._latlngs.length - 1] ?? latlng; 119 | let bearing = this._calculateBearing(lastClick, latlng); 120 | let distance = lastClick.distanceTo(latlng) * this.options.lengthUnit.factor; 121 | let accumulated = this._totalLength + distance; 122 | let totalLength = accumulated.toFixed(this.options.lengthUnit.decimal); 123 | let plusLength = incremental ? '
(+' + distance.toFixed(this.options.lengthUnit.decimal) + ')
' : ''; 124 | this._totalLength = incremental ? this._totalLength : accumulated; 125 | if (!layer.getTooltip()) layer.bindTooltip('', incremental ? { direction: "auto", sticky: true, offset: L.point(0, -40), className: 'moving-tooltip' } : { permanent: true, className: 'result-tooltip' }).openTooltip(); 126 | layer.setLatLng(latlng).setTooltipContent('' + this.options.angleUnit.label + ' ' + bearing.toFixed(this.options.angleUnit.decimal) + ' ' + this.options.angleUnit.display + '
' + this.options.lengthUnit.label + ' ' + totalLength + ' ' + this.options.lengthUnit.display + plusLength); 127 | }, 128 | _addPoint: function (e) { 129 | let latlng = e.latlng || e; 130 | let point = L.circleMarker(latlng, this.options.circleMarker).addTo(this._circles); 131 | this._polyline.addLatLng(latlng); 132 | if(this._latlngs.length && !latlng.equals(this._latlngs[this._latlngs.length - 1])){ 133 | this._drawTooltip(latlng, point, false); 134 | } 135 | this._latlngs.push(latlng); 136 | }, 137 | _moving: function (e) { 138 | if (this._latlngs.length) { 139 | let lastCLick = this._latlngs[this._latlngs.length - 1]; 140 | if (!this._tempLine) this._tempLine = L.polyline([], this.options.lineStyle).addTo(this._map); 141 | if (!this._tempPoint) this._tempPoint = L.circleMarker(e.latlng, this.options.circleMarker).addTo(this._map); 142 | this._tempLine.setLatLngs([lastCLick, e.latlng]); 143 | this._drawTooltip(e.latlng, this._tempPoint, true); 144 | L.DomEvent.off(this._container, 'click', this._toggleMeasure, this); 145 | } 146 | }, 147 | _escape: function (e) { 148 | if (e.keyCode === 27) { 149 | if (this._latlngs.length) { 150 | this._closePath(); 151 | } else { 152 | this._enabled = true; 153 | this._toggleMeasure(); 154 | } 155 | } 156 | }, 157 | _calculateBearing: function (start, end) { 158 | const toRad = L.DomUtil.DEG_TO_RAD; 159 | const toDeg = (this.options.angleUnit.factor / 2) / Math.PI; 160 | let y = Math.sin((end.lng - start.lng) * toRad) * Math.cos(end.lat * toRad); 161 | let x = Math.cos(start.lat * toRad) * Math.sin(end.lat * toRad) - Math.sin(start.lat * toRad) * Math.cos(end.lat * toRad) * Math.cos((end.lng - start.lng) * toRad); 162 | return (Math.atan2(y, x) * toDeg + this.options.angleUnit.factor) % this.options.angleUnit.factor; 163 | }, 164 | _closePath: function () { 165 | if (this._tempLine) { 166 | this._tempLine.remove(); 167 | this._tempLine = null; 168 | } 169 | if (this._tempPoint) { 170 | this._tempPoint.remove(); 171 | this._tempLine = null; 172 | } 173 | if (this._latlngs.length <= 1) { 174 | this._circles.remove(); 175 | } 176 | this._enabled = false; 177 | L.DomEvent.on(this._container, 'click', this._toggleMeasure, this); 178 | this._toggleMeasure(); 179 | }, 180 | }); 181 | L.control.ruler = function (options) { 182 | return new L.Control.Ruler(options); 183 | }; -------------------------------------------------------------------------------- /libs/leaflet-ruler.min.css: -------------------------------------------------------------------------------- 1 | .leaflet-ruler{height:35px;width:35px;background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAACx0lEQVRIS52VWahOURTHXRSJzBkiJcpYxgeS4oUi90GR4VK6DzcUD3gQIQ8UCXkwlCJRkjJEpqT7oGTMeHONmacyRRl/v9ve2n2d7/vOtevXPmefff5r7bXXXruiSf42gKkToTu0hY/wEk7CnWIyFWX0B/N9OkyF1tANdsEz6AIL4Sl8h8NwEK6nmsUMNGPSalgePFxLfwl2hrG39O1hM8yFobAKpsAGWAE/NJRloBfj++ErnIYesDj1qsjzRsYN2VhwdTPgUaGBkUF0e/D0TxDrRD8ehsFw6AcP4FrgCL17EpsrWAKDUgNtwuQP9FehJsyeSb8FbsOV8O0efVeYHOZV0h8N8w3dWXBOVWpgDwO/oBqc1BxciV7Pg3OJhz66ivPB+PoM8TnqRQPTeNkKfeALtAQ39QYsgE85xNsFz+voFe8AldFALS8DYU3waAd9b5gAv3OKn2HefagK4q64VgPm9k0we0wtN9pN6w+mY9qywqLnWeKXGa/WwHwYFSwrZtw/w9Ic4p5oxesLPG8Qd/UacMf13IE38ArGwN3EQJbnintOHsJsMOaG5Z+4/2vAY282eALd4FvQqoj4Aca3gQfPw1hSPBr4xoNH3bz1EO0D428r9LwpY3vB+nSolOfRQVeg1yOCgXH01pu+GeL+Y1hOwWOYBZlhieJxBU94eA2Lwo9WRw+X8fUEx0PUaPFowKW6Cg+UBc6M6AzrMsR1xtJR1vO4CkNk1TMLJoXBlcHQpvAePW+0eFyBRe45jAbL7fskhqm4MbdGmYruUU94l8zNfIyl4hhfLQteHqaiLYq7J4YlipvnXkgWRotgyRYNWB4st6bpizLiHsiO4H1gMrhnRVtarpcxy0vd+9eLPMvzhuMf1E7QmyC78xrQmDk+BC6Am98CLoKXi/dvWll1xjvjeAkD9YVXpkJ6ae7/BOPus4b+p9X9BdB/zv8zawwXAAAAAElFTkSuQmCC);background-repeat:no-repeat;background-position:center}.leaflet-ruler-clicked,.leaflet-ruler:hover{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAE/ElEQVRIS6WVeWwUVRzHfzOzs7Pdi+7Rdq/ubrH0sNaKBFMCSEvBQLivFkhAE8GDYAyHATWgETUoMZ7BGCMBNKYiQbTKpYQKBqKlWmBbadmW7bbbFjv0mu2eM298M5SlpS3U+LL/zL6Zz/f7vr/3e4+AsQ6j80EgxTkKhGwkQRqQiLp5UtEOSDwOXf660TDEPfkGR75RTT8VF1EpJRJ6LsbrVuaZg2YtrWND8WD5FVajV9IcTyCOoYhv2QgcgE5fzWDmaAKU0eHa3RMStszNMqIdM+zUY3YdPFvRCG+VOCFFQ0N3mIfNJ3ywb3Em/BHgYNeZFnTc20OOU1Efd7eZtwBUxyWh4QLJGS6jFv04MU2bNWeCQRnoi8L7czPuG+RLJ31g0Smhsqk3dqGVa7wZZOZBb/31oQKm8ZMZRbxyU6FV9XaJmyQGZsNxBD/Ud8FXNWy/rzci9ER4Spqa5tYrit3jmNI8M2DnCRNvnm1Bu35tjcR4lH1HIDtbp+2NNuSmqCyTrBr4dMEDuH4A754LCLvOtQo44zouKp7hQagGIK+CIFgoipqPEHru6OocWJhtlAWk6GZ8cSXs64sd49qbSxMCaenu7+ZkGuftW5JJSy/FBARlh+vDF1tDfWEerYWullNDcjK5c9Q0Or/jcad++3SbbP8ueBn+S7glkOIsNTDUQf+mRxmtkoIIj6Bgb024hYtVhGPK9dDl7RsJvhPDtw3AcWzYuSd8vTcqOS8DrcUIKsUiWcDgyKghRLFgZ1E6vFhohfXfN8YP1bFVfW3N0/E0Ggu8CMNxfY9z7b5SCU4o6dMkhc4RYM61aulwU8fWSSqaIqEqEIRZBzzBSCg+HoIdncPglHgBG9ENdj4inEAXhZuBdQSYXBuWZOnfOLI61yTB1ld4Y1/+xe6Ndvo33Q+ON5Rc0GHOB+DS6gmF2XF6dkZy0eeLM8lU3EDGd6qiQRAnQnvz3wkBXFANdr5jkHMJXrS/NtTIRk5yN3wrErEMgsuNpkp13ViRZ0zdOtUOWiUJ2R/WxPibfmYk+Mp8E/XCT01y4y0/1HBfuCygMDljVzYW0DnmJKhuC8Lsg7WB7kCzQxa4y7nUF08euQblHlZMoqij93J+2yBBpzgjlzcUMJLAmeu9sOjrq+1cR7PtNvzVYrv+5WkOUvpAiqUYx+JlI6cwfPlosQyuHcGYnYECq9r2AV62O5mB9PcuItxjj2ho6uz/hcsRESbH4TX5qQv3LsigNbjJ7Huq+tkIT75enM4Mdx76mbvhXzYW54mIwOBeNd2l+eTs03nyYYIPqrhaSVGbC213xfLf4fIKAB9yqp4IW/VMvtKqVYJJrUhEeCdzGS5lbki22Wu5btYg0IwV2hrYIb0ywoN8VCTbXadDUTRz/9IJsCrfLL82CP7LQCyGcVab56HColQkCKL3ctWxTs/5+WMSAHwPGBih0rNxotqGL42R4DqLpebhKTNta7fvgWBfN7yybIoQjYdzoLPVey+RxHFtdrhfy05J2laxKjep5EAd3oqhhHNtqrm6YOoT6RKcIOXSwEdb1ojXLv35fDRQ99mYBKR6GOyu3zBhcowXTvR3+JdCWhqjVhk9FEW7Z5Wtw1vuFlwatb9XQmuD51J/MFQ+qgAheIdemSl5FoDgOuhs3o0/4vWOKcZkh/4bEJFKxAMA/6QhgsjzAvrH3+SXn0YZAinU/wuTosgK53+p+wAAAABJRU5ErkJggg==)}.leaflet-ruler-clicked{height:35px;width:35px;background-repeat:no-repeat;background-position:center;border-color:#7fff00!important}.leaflet-bar.leaflet-ruler{background-color:#fff}.leaflet-control.leaflet-ruler{cursor:pointer}.result-tooltip{background-color:#fff;border-width:medium;border-color:#de0000;font-size:smaller}.moving-tooltip{background-color:rgba(255,255,255,.7);background-clip:padding-box;opacity:.5;border:dotted;border-color:red;font-size:smaller}.plus-length{padding-left:45px} -------------------------------------------------------------------------------- /libs/leaflet-ruler.min.js: -------------------------------------------------------------------------------- 1 | L.Control.Ruler=L.Control.extend({options:{position:"topright",circleMarker:{color:"red",radius:2},lineStyle:{color:"red",dashArray:"1,6"},lengthUnit:{display:"km",decimal:2,factor:.001,label:"Distance:"},angleUnit:{display:"°",decimal:2,factor:360,label:"Bearing:"}},initialize:function(options){L.setOptions(this,options),this._layers=L.layerGroup(),this._enabled=!1},onAdd:function(map){this._defaultCursor=map._container.style.cursor,this._map=map;let container=L.DomUtil.create("div","leaflet-bar");return container.classList.add("leaflet-ruler"),L.DomEvent.disableClickPropagation(container),L.DomEvent.on(container,"click",this._toggleMeasure,this),this._container=container},onRemove:function(){L.DomEvent.off(this._container,"click",this._toggleMeasure,this)},_attachMouseEvents:function(){let map=this._map;map.doubleClickZoom.disable(),L.DomEvent.on(map._container,"keydown",this._escape,this),L.DomEvent.on(map._container,"dblclick",this._closePath,this),map._container.style.cursor="crosshair",map.on("click",this._addPoint,this),map.on("mousemove",this._moving,this)},_removeMouseEvents:function(){let map=this._map;map.doubleClickZoom.enable(),L.DomEvent.off(map._container,"keydown",this._escape,this),L.DomEvent.off(map._container,"dblclick",this._closePath,this),map._container.style.cursor=this._defaultCursor,map.off("click",this._addPoint,this),map.off("mousemove",this._moving,this)},_disable:function(){this._enabled=!1,this._container.classList.remove("leaflet-ruler-clicked"),this._map.removeLayer(this._layers),this._layers=L.layerGroup(),this._latlngs=[],this._totalLength=0,this._removeMouseEvents()},_enable:function(){this._enabled=!0,this._container.classList.add("leaflet-ruler-clicked"),this._circles=L.featureGroup().addTo(this._layers),this._polyline=L.polyline([],this.options.lineStyle).addTo(this._layers),this._layers.addTo(this._map),this._latlngs=[],this._totalLength=0,this._attachMouseEvents()},_toggleMeasure:function(){this._enabled?this._disable():this._enable()},_drawTooltip:function(latlng,layer,incremental){let clickCount=this._latlngs.length,lastClick=clickCount?this._latlngs[clickCount-1]:latlng,bearing=this._calculateBearing(lastClick,latlng),distance=lastClick.distanceTo(latlng)*this.options.lengthUnit.factor,totalLength,plusLength;incremental?(totalLength=(clickCount?distance+this._totalLength||0:distance).toFixed(this.options.lengthUnit.decimal),plusLength=clickCount?'
(+'+distance.toFixed(this.options.lengthUnit.decimal)+")
":""):(this._totalLength+=distance,totalLength=(clickCount?this._totalLength:distance).toFixed(this.options.lengthUnit.decimal),plusLength="");var text=""+this.options.angleUnit.label+" "+bearing.toFixed(this.options.angleUnit.decimal)+" "+this.options.angleUnit.display+"
"+this.options.lengthUnit.label+" "+totalLength+" "+this.options.lengthUnit.display+plusLength;layer.setLatLng(latlng);let tooltip=layer.getTooltip();return tooltip?tooltip.setTooltipContent(text):layer.bindTooltip(text,incremental?{direction:"auto",sticky:!0,offset:L.point(0,-40),className:"moving-tooltip"}:{permanent:!0,className:"result-tooltip"}).openTooltip(),layer},_addPoint:function(e){let latlng=e.latlng||e,point=L.circleMarker(latlng,this.options.circleMarker).addTo(this._circles);this._polyline.addLatLng(latlng),this._latlngs.length&&!latlng.equals(this._latlngs[this._latlngs.length-1])&&this._drawTooltip(latlng,point,!1),this._latlngs.push(latlng)},_moving:function(e){if(this._latlngs.length){let lastCLick=this._latlngs[this._latlngs.length-1];this._tempLine||(this._tempLine=L.polyline([],this.options.lineStyle).addTo(this._map)),this._tempPoint||(this._tempPoint=L.circleMarker(e.latlng,this.options.circleMarker).addTo(this._map)),this._tempLine.setLatLngs([lastCLick,e.latlng]),this._drawTooltip(e.latlng,this._tempPoint,!0),L.DomEvent.off(this._container,"click",this._toggleMeasure,this)}},_escape:function(e){27===e.keyCode&&(this._latlngs.length?this._closePath():(this._enabled=!0,this._toggleMeasure()))},_calculateBearing:function(start,end){const toRad=L.DomUtil.DEG_TO_RAD,toDeg=this.options.angleUnit.factor/2/Math.PI;let y=Math.sin((end.lng-start.lng)*toRad)*Math.cos(end.lat*toRad),x=Math.cos(start.lat*toRad)*Math.sin(end.lat*toRad)-Math.sin(start.lat*toRad)*Math.cos(end.lat*toRad)*Math.cos((end.lng-start.lng)*toRad);return(Math.atan2(y,x)*toDeg+this.options.angleUnit.factor)%this.options.angleUnit.factor},_closePath:function(){this._tempLine&&(this._map.removeLayer(this._tempLine),this._tempLine=null),this._tempPoint&&(this._map.removeLayer(this._tempPoint),this._tempLine=null),this._latlngs.length<=1&&this._map.removeLayer(this._circles),this._enabled=!1,L.DomEvent.on(this._container,"click",this._toggleMeasure,this),this._toggleMeasure()}}),L.control.ruler=function(options){return new L.Control.Ruler(options)}; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@raruto/leaflet-elevation", 3 | "version": "2.5.1", 4 | "description": "A Leaflet plugin that allows to add elevation profiles using d3js", 5 | "type": "module", 6 | "main": "dist/leaflet-elevation.min.js", 7 | "browser": "dist/leaflet-elevation.min.js", 8 | "module": "src/index.js", 9 | "scripts": { 10 | "test": "uvu . \"(examples|src)[\\\\/].*\\.spec\\.js$\"", 11 | "build": "rollup -c build/rollup.config.js", 12 | "watch": "rollup -c build/rollup.config.js --watch", 13 | "dev": "npm-run-all --print-label --parallel serve watch", 14 | "serve": "http-server", 15 | "version": "npm run build && git add -A" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/Raruto/leaflet-elevation.git" 20 | }, 21 | "keywords": [ 22 | "leaflet", 23 | "d3js", 24 | "javascript", 25 | "elevation", 26 | "highchart", 27 | "kml", 28 | "altitude", 29 | "gpx", 30 | "gps-visualizer", 31 | "tcx" 32 | ], 33 | "author": { 34 | "name": "Felix “MrMufflon” Bache", 35 | "url": "https: //github.com/MrMufflon/Leaflet.Elevation" 36 | }, 37 | "contributors": [ 38 | { 39 | "name": "Raruto", 40 | "url": "https://github.com/Raruto/leaflet-elevation" 41 | }, 42 | { 43 | "name": "Felix Bache", 44 | "web": "https://github.com/HostedDinner/Leaflet.Elevation" 45 | } 46 | ], 47 | "license": "GPL-3.0", 48 | "bugs": { 49 | "url": "https://github.com/Raruto/leaflet-elevation/issues" 50 | }, 51 | "homepage": "https://github.com/Raruto/leaflet-elevation#readme", 52 | "devDependencies": { 53 | "@rollup/plugin-commonjs": "^24.1.0", 54 | "@rollup/plugin-node-resolve": "^15.0.2", 55 | "@rollup/plugin-terser": "^0.4.1", 56 | "http-server": "^14.1.1", 57 | "jsdom": "^21.1.1", 58 | "leaflet": "^1.7.1", 59 | "npm-run-all": "^4.1.5", 60 | "playwright": "^1.32.3", 61 | "postcss": "^8.4.23", 62 | "postcss-copy": "^7.1.0", 63 | "postcss-import": "^15.1.0", 64 | "rollup": "^3.21.0", 65 | "rollup-plugin-postcss": "^4.0.2", 66 | "uvu": "^0.5.6" 67 | }, 68 | "peerDependencies": { 69 | "@tmcw/togeojson": "5.6.2", 70 | "d3": "7.8.4", 71 | "leaflet": "^1.7.0", 72 | "leaflet-i18n": "^0.3.1" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/components/d3.js: -------------------------------------------------------------------------------- 1 | export const Area = ({ 2 | width, 3 | height, 4 | xAttr, 5 | yAttr, 6 | scaleX, 7 | scaleY, 8 | interpolation = "curveLinear" 9 | }) => { 10 | return d3.area() 11 | .curve(typeof interpolation === 'string' ? d3[interpolation] : interpolation) 12 | .x(d => (d.xDiagCoord = scaleX(d[xAttr]))) 13 | .y0(height) 14 | .y1(d => scaleY(d[yAttr])); 15 | }; 16 | 17 | export const Path = ({ 18 | name, 19 | color, 20 | strokeColor, 21 | strokeOpacity, 22 | fillOpacity, 23 | className = '' 24 | }) => { 25 | let path = d3.create('svg:path') 26 | 27 | if (name) path.classed(name + ' ' + className, true); 28 | 29 | path.style("pointer-events", "none"); 30 | 31 | path 32 | .attr("fill", color || '#3366CC') 33 | .attr("stroke", strokeColor || '#000') 34 | .attr("stroke-opacity", strokeOpacity || '1') 35 | .attr("fill-opacity", fillOpacity || '0.8'); 36 | 37 | return path; 38 | }; 39 | 40 | export const Axis = ({ 41 | type = "axis", 42 | tickSize = 6, 43 | tickPadding = 3, 44 | position, 45 | height, 46 | width, 47 | axis, 48 | scale, 49 | ticks, 50 | tickFormat, 51 | label, 52 | labelX, 53 | labelY, 54 | name = "", 55 | onAxisMount, 56 | }) => { 57 | return g => { 58 | let [w, h] = [0, 0]; 59 | if (position == "bottom") h = height; 60 | if (position == "right") w = width; 61 | 62 | if (axis == "x" && type == "grid") { 63 | tickSize = -height; 64 | } else if (axis == "y" && type == "grid") { 65 | tickSize = -width; 66 | } 67 | 68 | let axisScale = d3["axis" + position.replace(/\b\w/g, l => l.toUpperCase())]() 69 | .scale(scale) 70 | .ticks(typeof ticks === 'function' ? ticks() : ticks) 71 | .tickPadding(tickPadding) 72 | .tickSize(tickSize) 73 | .tickFormat(tickFormat); 74 | 75 | let axisGroup = g.append("g") 76 | .attr("class", [axis, type, position, name].join(" ")) 77 | .attr("transform", "translate(" + w + "," + h + ")") 78 | .call(axisScale); 79 | 80 | if (label) { 81 | axisGroup.append("svg:text") 82 | .attr("x", labelX) 83 | .attr("y", labelY) 84 | .text(label); 85 | } 86 | 87 | if (onAxisMount) { 88 | axisGroup.call(onAxisMount); 89 | } 90 | 91 | return axisGroup; 92 | }; 93 | }; 94 | 95 | export const Grid = (props) => { 96 | props.type = "grid"; 97 | return Axis(props); 98 | }; 99 | 100 | export const PositionMarker = ({ 101 | theme, 102 | xCoord = 0, 103 | yCoord = 0, 104 | labels = {}, 105 | item = {}, 106 | length = 0, 107 | }) => { 108 | return g => { 109 | 110 | g.attr("class", "height-focus-group"); 111 | 112 | let line = g.select('.height-focus.line'); 113 | let circle = g.select('.height-focus.circle-lower'); 114 | let text = g.select('.height-focus-label'); 115 | 116 | if (!line.node()) line = g.append('svg:line'); 117 | if (!circle.node()) circle = g.append('svg:circle'); 118 | if (!text.node()) text = g.append('svg:text'); 119 | 120 | if (isNaN(xCoord) || isNaN(yCoord)) return g; 121 | 122 | circle 123 | .attr("class", theme + " height-focus circle-lower") 124 | .attr("transform", "translate(" + xCoord + "," + yCoord + ")") 125 | .attr("r", 6) 126 | .attr("cx", 0) 127 | .attr("cy", 0); 128 | 129 | line 130 | .attr("class", theme + " height-focus line") 131 | .attr("x1", xCoord) 132 | .attr("x2", xCoord) 133 | .attr("y1", yCoord) 134 | .attr("y2", length); 135 | 136 | text 137 | .attr("class", theme + " height-focus-label") 138 | .style("pointer-events", "none") 139 | .attr("x", xCoord + 5) 140 | .attr("y", length); 141 | 142 | let label; 143 | 144 | Object 145 | .keys(labels) 146 | .sort((a, b) => labels[a].order - labels[b].order) // TODO: any performance issues? 147 | .forEach((i)=> { 148 | label = text.select(".height-focus-" + labels[i].name); 149 | 150 | if (!label.size()) { 151 | label = text.append("svg:tspan") 152 | .attr("class", "height-focus-" + labels[i].name /*+ " " + "order-" + labels[i].order*/) 153 | .attr("dy", "1.5em"); 154 | } 155 | 156 | label.text(typeof labels[i].value !== "function" ? labels[i].value : labels[i].value(item)); 157 | 158 | }); 159 | 160 | text.select('tspan').attr("dy", text.selectAll('tspan').size() > 1 ? "-1.5em" : "0em" ); 161 | text.selectAll('tspan').attr("x", xCoord + 5); 162 | 163 | return g; 164 | } 165 | }; 166 | 167 | export const LegendItem = ({ 168 | name, 169 | label, 170 | width, 171 | height, 172 | margins = {}, 173 | color, 174 | path, 175 | className = '' 176 | }) => { 177 | return g => { 178 | g 179 | .attr("class", "legend-item legend-" + name.toLowerCase()) 180 | .attr("data-name", name); 181 | 182 | g.on('click.legend', () => d3.select(g.node().ownerSVGElement || g) 183 | .dispatch("legend_clicked", { 184 | detail: { 185 | path: path.node(), 186 | name: name, 187 | legend: g.node(), 188 | enabled: !path.classed('leaflet-hidden'), 189 | } 190 | }) 191 | ); 192 | 193 | g.append("svg:rect") 194 | .attr("x", (width / 2) - 50) 195 | .attr("y", height + margins.bottom / 2) 196 | .attr("width", 50) 197 | .attr("height", 10) 198 | .attr("fill", color) 199 | .attr("stroke", "#000") 200 | .attr("stroke-opacity", "0.5") 201 | .attr("fill-opacity", "0.25") 202 | .attr("class", className); 203 | 204 | g.append('svg:text') 205 | .text(L._(label || name)) 206 | .attr("x", (width / 2) + 5) 207 | .attr("font-size", 10) 208 | .style("font-weight", "700") 209 | .attr('y', height + margins.bottom / 2) 210 | .attr('dy', "0.75em"); 211 | 212 | return g; 213 | } 214 | }; 215 | 216 | export const LegendSmall = ({ 217 | width, 218 | height, 219 | items, 220 | onClick 221 | }) => { 222 | return g => { 223 | let idx = 0; 224 | 225 | g.data([{ 226 | x: width, 227 | y: height + 40 228 | }]).attr("transform", d => "translate(" + d.x + "," + d.y + ")").classed('legend-switcher'); 229 | 230 | // let label = g.selectAll(".legend-switcher-label") .data([{ idx: 0 }]); 231 | let label = g.append('svg:text') 232 | .attr("class", "legend-switcher-label") 233 | .attr("text-anchor", "end") 234 | .attr("x", -25) 235 | .attr("y", 5) 236 | .on("mousedown", (e, d) => setIdx(L.Util.wrapNum((idx + 1), [0, items.length]))); 237 | 238 | let symbol = g.selectAll(".legend-switcher-symbol").data([ 239 | { type: d3.symbolTriangle, x: 0, y: 3, angle: 0, size: 50, id: "down" }, 240 | { type: d3.symbolTriangle, x: -13, y: 0, angle: 180, size: 50, id: "up" } 241 | ]); 242 | 243 | symbol.exit().remove(); 244 | label.exit().remove(); 245 | 246 | symbol.enter() 247 | .append("svg:path") 248 | .attr("class", "legend-switcher-symbol") 249 | .attr("cursor", 'pointer') 250 | .attr("fill", "#000") 251 | .merge(symbol) 252 | .attr("d", 253 | d3.symbol() 254 | .type(d => d.type) 255 | .size(d => d.size) 256 | ) 257 | .attr("transform", d => "translate(" + d.x + "," + d.y + ") rotate(" + d.angle + ")") 258 | .on("mousedown", (e, d) => setIdx(L.Util.wrapNum((d.id === "up" ? idx + 1 : idx - 1), [0, items.length]))); 259 | 260 | const setIdx = (id) => { 261 | idx = id; 262 | // label.enter() 263 | // .append('text') 264 | // .attr("class", "legend-switcher-label") 265 | // .attr("text-anchor", "end") 266 | // .attr("x", -25) 267 | // .attr("y", 5) 268 | // .merge(label.data([{ idx: id }])) 269 | // .text(d => items.length ? (items[idx][0].toUpperCase() + items[idx].slice(1)) : '') 270 | // .on("mousedown", (e, d) => setIdx(L.Util.wrapNum((idx + 1), [0, items.length]))); 271 | label.text(items.length ? L._((items[idx][0].toUpperCase() + items[idx].slice(1))) : ''); 272 | onClick(items[idx]); 273 | }; 274 | setIdx(0); 275 | 276 | return g; 277 | }; 278 | }; 279 | 280 | export const Tooltip = ({ 281 | xCoord, 282 | yCoord, 283 | width, 284 | height, 285 | labels = {}, 286 | item = {}, 287 | }) => { 288 | return g => { 289 | 290 | let line = g.select('.mouse-focus-line'); 291 | let box = g.select('.mouse-focus-label'); 292 | 293 | if (!line.node()) line = g.append('svg:line'); 294 | if (!box.node()) box = g.append("g"); 295 | 296 | let rect = box.select(".mouse-focus-label-rect"); 297 | let text = box.select(".mouse-focus-label-text"); 298 | 299 | if (!rect.node()) rect = box.append("svg:rect"); 300 | if (!text.node()) text = box.append("svg:text"); 301 | 302 | // Sets focus-label-text position to the left / right of the mouse-focus-line 303 | let xAlign = 0; 304 | let yAlign = 0; 305 | let bbox = { width: 0, height: 0 }; 306 | try { bbox = text.node().getBBox(); } catch (e) { return g; } 307 | 308 | if (xCoord) xAlign = xCoord + (xCoord < width / 2 ? 10 : -bbox.width - 10); 309 | if (yCoord) yAlign = Math.max(yCoord - bbox.height, L.Browser.webkit ? 0 : -Infinity); 310 | 311 | line 312 | .attr('class', 'mouse-focus-line') 313 | .attr('x2', xCoord) 314 | .attr('y2', 0) 315 | .attr('x1', xCoord) 316 | .attr('y1', height); 317 | 318 | box 319 | .attr('class', 'mouse-focus-label'); 320 | 321 | rect 322 | .attr("class", "mouse-focus-label-rect") 323 | .attr("x", xAlign - 5) 324 | .attr("y", yAlign - 5) 325 | .attr("width", bbox.width + 10) 326 | .attr("height", bbox.height + 10) 327 | .attr("rx", 3) 328 | .attr("ry", 3); 329 | 330 | text 331 | .attr("class", "mouse-focus-label-text") 332 | .style("font-weight", "700") 333 | .attr("y", yAlign); 334 | 335 | let label; 336 | 337 | Object 338 | .keys(labels) 339 | .sort((a, b) => labels[a].order - labels[b].order) // TODO: any performance issues? 340 | .forEach((i)=> { 341 | label = text.select(".mouse-focus-label-" + labels[i].name); 342 | 343 | if (!label.size()) { 344 | label = text.append("svg:tspan", ".mouse-focus-label-x") 345 | .attr("class", "mouse-focus-label-" + labels[i].name /*+ " " + "order-" + labels[i].order*/) 346 | .attr("dy", "1.5em"); 347 | } 348 | 349 | label.text(typeof labels[i].value !== "function" ? labels[i].value : labels[i].value(item)); 350 | }); 351 | 352 | text.select('tspan').attr("dy", "1em"); 353 | text.selectAll('tspan').attr("x", xAlign); 354 | 355 | return g; 356 | }; 357 | }; 358 | 359 | export const Ruler = ({ 360 | height, 361 | width, 362 | }) => { 363 | return g => { 364 | 365 | g.data([{ 366 | x: 0, 367 | y: height, 368 | }]) 369 | .attr("transform", d => "translate(" + d.x + "," + d.y + ")"); 370 | 371 | let rect = g.selectAll('.horizontal-drag-rect').data([{ w: width }]); 372 | let line = g.selectAll('.horizontal-drag-line').data([{ w: width }]); 373 | let label = g.selectAll('.horizontal-drag-label').data([{ w: width - 8 }]); 374 | let symbol = g.selectAll('.horizontal-drag-symbol') 375 | .data([{ 376 | type: d3.symbolTriangle, 377 | x: width + 7, 378 | y: 0, 379 | angle: -90, 380 | size: 50 381 | }]); 382 | 383 | rect.exit().remove(); 384 | line.exit().remove(); 385 | label.exit().remove(); 386 | symbol.exit().remove(); 387 | 388 | rect.enter() 389 | .append("svg:rect") 390 | .attr("class", "horizontal-drag-rect") 391 | .attr("x", 0) 392 | .attr("y", -8) 393 | .attr("height", 8) 394 | .attr('fill', 'none') 395 | .attr('pointer-events', 'all') 396 | .merge(rect) 397 | .attr("width", d => d.w); 398 | 399 | line.enter() 400 | .append("svg:line") 401 | .attr("class", "horizontal-drag-line") 402 | .attr("x1", 0) 403 | .merge(line) 404 | .attr("x2", d => d.w); 405 | 406 | label.enter() 407 | .append("svg:text") 408 | .attr("class", "horizontal-drag-label") 409 | .attr("text-anchor", "end") 410 | .attr("y", -8) 411 | .merge(label) 412 | .attr("x", d => d.w) 413 | 414 | symbol 415 | .enter() 416 | .append("svg:path") 417 | .attr("class", "horizontal-drag-symbol") 418 | .merge(symbol) 419 | .attr("d", 420 | d3.symbol() 421 | .type(d => d.type) 422 | .size(d => d.size) 423 | ) 424 | .attr("transform", d => "translate(" + d.x + "," + d.y + ") rotate(" + d.angle + ")"); 425 | 426 | return g; 427 | } 428 | }; 429 | 430 | export const CheckPoint = ({ 431 | point, 432 | height, 433 | width, 434 | x, 435 | y 436 | }) => { 437 | return g => { 438 | 439 | if (isNaN(x) || isNaN(y)) return g; 440 | 441 | if (!point.item || !point.item.property('isConnected')) { 442 | point.position = point.position || "bottom"; 443 | 444 | point.item = g.append('g'); 445 | 446 | point.item.append("svg:line") 447 | .attr("y1", 0) 448 | .attr("x1", 0) 449 | .attr("style","stroke: rgb(51, 51, 51); stroke-width: 0.5; stroke-dasharray: 2, 2;"); 450 | 451 | point.item 452 | .append("svg:circle") 453 | .attr("class", " height-focus circle-lower") 454 | .attr("r", 3); 455 | 456 | if (point.label) { 457 | point.item.append("svg:text") 458 | .attr("dx", "4px") 459 | .attr("dy", "-4px"); 460 | } 461 | } 462 | 463 | point.item 464 | .datum({ 465 | pos: point.position, 466 | x: x, 467 | y: y 468 | }) 469 | .attr("class", d => "point " + d.pos) 470 | .attr("transform", d => "translate(" + d.x + "," + d.y + ")"); 471 | 472 | point.item.select('line') 473 | .datum({ 474 | y2: ({'top': -y, 'bottom': height - y})[point.position], 475 | x2: ({'left': -x, 'right': width - x})[point.position] || 0 476 | }) 477 | .attr("y2", d => d.y2) 478 | .attr("x2", d => d.x2) 479 | 480 | if (point.label) { 481 | point.item.select('text') 482 | .text(point.label); 483 | } 484 | 485 | return g; 486 | } 487 | }; 488 | 489 | export const Domain = ({ 490 | min, 491 | max, 492 | attr, 493 | name, 494 | forceBounds, 495 | scale 496 | }) => function(data) { 497 | attr = (scale && scale.attr) || attr || name; 498 | let domain = data && data.length ? d3.extent(data, d => d[attr]) : [0, 1]; 499 | if (typeof min !== "undefined" && (min < domain[0] || forceBounds)) { 500 | domain[0] = min; 501 | } 502 | if (typeof max !== "undefined" && (max > domain[1] || forceBounds)) { 503 | domain[1] = max; 504 | } 505 | return domain; 506 | }; 507 | 508 | export const Range = ({ 509 | axis 510 | }) => function(width, height) { 511 | if (axis == 'x') return [0, width]; 512 | else if (axis == 'y') return [height, 0]; 513 | }; 514 | 515 | export const Scale = ({ 516 | data, 517 | attr, 518 | min, 519 | max, 520 | forceBounds, 521 | range, 522 | }) => { 523 | return d3.scaleLinear() 524 | .range(range) 525 | .domain(Domain({min, max, attr, forceBounds})(data)); 526 | }; 527 | 528 | export const Bisect = ({ 529 | data = [0, 1], 530 | scale, 531 | x, 532 | attr 533 | }) => { 534 | return d3 535 | .bisector(d => d[attr]) 536 | .left(data, scale.invert(x)); 537 | }; 538 | 539 | export const Chart = ({ 540 | width, 541 | height, 542 | margins = {}, 543 | ruler, 544 | }) => { 545 | 546 | const w = width - margins.left - margins.right; 547 | const h = height - margins.top - margins.bottom; 548 | 549 | // SVG Container 550 | const svg = d3.create("svg:svg").attr("class", "background"); 551 | 552 | const defs = svg.append("svg:defs"); 553 | 554 | // SVG Groups 555 | const g = svg.append("g"); 556 | const panes = { 557 | grid : g.append("g").attr("class", "grid"), 558 | area : g.append('g').attr("class", "area"), 559 | axis : g.append('g').attr("class", "axis"), 560 | point : g.append('g').attr("class", "point"), 561 | brush : g.append("g").attr("class", "brush"), 562 | tooltip: g.append("g").attr("class", "tooltip").attr('display', 'none'), 563 | ruler : g.append('g').attr('class', 'ruler'), 564 | legend : g.append('g').attr("class", "legend"), 565 | }; 566 | 567 | // SVG Paths 568 | const mask = panes.area .append("svg:mask") .attr("id", 'elevation-clipper-' + Math.random().toString(36).substr(2, 9)).attr('fill-opacity', 1); 569 | const maskRect = mask .append("svg:rect") .attr('class', 'zoom') .attr('fill', 'white'); // white = transparent 570 | 571 | // Canvas Paths 572 | const foreignObject = panes.area .append('svg:foreignObject').attr('mask', 'url(#' + mask.attr('id') + ')'); 573 | const canvas = foreignObject.append('xhtml:canvas') .attr('class', 'canvas-plot'); 574 | const context = canvas.node().getContext('2d'); 575 | 576 | // Add tooltip 577 | panes.tooltip.call(Tooltip({ xCoord: 0, yCoord: 0, height: h, width : w, labels: {} })); 578 | 579 | // Add the brushing 580 | let brush = d3.brushX().on('start.cursor end.cursor brush.cursor', () => panes.brush.select(".overlay").attr('cursor', null)); 581 | 582 | // Scales 583 | const scale = (opts) => ({ x: Scale(opts.x), y: Scale(opts.y)}); 584 | 585 | let utils = { 586 | defs, 587 | mask, 588 | canvas, 589 | context, 590 | brush, 591 | }; 592 | 593 | let chart = { 594 | svg, 595 | g, 596 | panes, 597 | utils, 598 | scale, 599 | }; 600 | 601 | // Resize 602 | chart._resize = ({ 603 | width, 604 | height, 605 | margins = {}, 606 | ruler, 607 | }) => { 608 | 609 | const w = width - margins.left - margins.right; 610 | const h = height - margins.top - margins.bottom; 611 | 612 | svg .attr("width", width).attr("height", height).attr("viewBox", `0 0 ${width} ${height}`); 613 | g .attr("transform", "translate(" + margins.left + "," + margins.top + ")"); 614 | 615 | // Fix: https://github.com/Raruto/leaflet-elevation/issues/123 616 | // Fix: https://github.com/Raruto/leaflet-elevation/issues/232 617 | if ( 618 | /Mac|iPod|iPhone|iPad/.test(navigator.platform) && 619 | /AppleWebkit/i.test(navigator.userAgent) && 620 | !/Chrome/.test(navigator.userAgent) 621 | ) { 622 | canvas .style("transform", "translate(" + margins.left + "px," + margins.top + "px)"); 623 | } 624 | 625 | maskRect .attr("width", w).attr("height", h).attr("x", 0).attr("y", 0); 626 | foreignObject.attr('width', w).attr('height', h); 627 | canvas .attr('width', w).attr('height', h); 628 | 629 | if (ruler) { 630 | panes.ruler.call(Ruler({ height: h, width: w })); 631 | } 632 | 633 | panes.brush.call(brush.extent( [ [0,0], [w, h] ] )); 634 | panes.brush.select(".overlay").attr('cursor', null); 635 | 636 | chart._width = w; 637 | chart._height = h; 638 | 639 | chart.svg.dispatch('resize', { detail: { width: w, height: h } } ); 640 | 641 | }; 642 | 643 | chart.pane = (name) => (panes[name] || (panes[name] = g.append('g').attr("class", name))); 644 | chart.get = (name) => utils[name]; 645 | 646 | chart._resize({ width, height, margins}); 647 | 648 | return chart; 649 | }; 650 | -------------------------------------------------------------------------------- /src/components/marker.js: -------------------------------------------------------------------------------- 1 | import * as D3 from './d3.js'; 2 | 3 | const _ = L.Control.Elevation.Utils; 4 | 5 | export var Marker = L.Class.extend({ 6 | 7 | initialize(options, control) { 8 | this.options = options; 9 | this.control = control 10 | 11 | switch(this.options.marker) { 12 | case 'elevation-line': 13 | // this._container = d3.create("g").attr("class", "height-focus-group"); 14 | break; 15 | case 'position-marker': 16 | // this._marker = L.circleMarker([0, 0], { pane: 'overlayPane', radius: 6, fillColor: '#fff', fillOpacity:1, color: '#000', weight:1, interactive: false }); 17 | this._marker = L.marker([0, 0], { icon: this.options.markerIcon, zIndexOffset: 1000000, interactive: false }); 18 | break; 19 | } 20 | 21 | this._labels = {}; 22 | 23 | return this; 24 | }, 25 | 26 | addTo(map) { 27 | this._map = map; 28 | switch(this.options.marker) { 29 | case 'elevation-line': this._container = d3.select(map.getPane('elevationPane')).select("svg > g").call(D3.PositionMarker({})); break; 30 | case 'position-marker': this._marker.addTo(map, { pane: 'overlayPane' }); break; 31 | } 32 | return this; 33 | }, 34 | 35 | /** 36 | * Update position marker ("leaflet-marker"). 37 | */ 38 | update(props) { 39 | if (props) this._props = props; 40 | else props = this._props; 41 | 42 | if (!props) return; 43 | 44 | if (props.options) this.options = props.options; 45 | if (!this._map) this.addTo(props.map); 46 | 47 | this._latlng = props.item.latlng; 48 | 49 | switch(this.options.marker) { 50 | case 'elevation-line': 51 | if (this._container) { 52 | let point = this._map.latLngToLayerPoint(this._latlng); 53 | point = L.extend({}, props.item, this._map._rotate ? this._map.rotatedPointToMapPanePoint(point) : point); 54 | 55 | let yMax = (this.control._height() / props.yCoordMax * point[this.options.yAttr]); 56 | 57 | if (!isFinite(yMax) || isNaN(yMax)) yMax = 0; 58 | 59 | this._container.classed("leaflet-hidden", false); 60 | this._container.call(D3.PositionMarker({ 61 | theme : this.options.theme, 62 | xCoord: point.x, 63 | yCoord: point.y, 64 | length: point.y - yMax, // normalized Y 65 | labels: this._labels, 66 | item: point, 67 | })); 68 | } 69 | break; 70 | case 'position-marker': 71 | _.removeClass(this._marker.getElement(), 'leaflet-hidden'); 72 | this._marker.setLatLng(this._latlng); 73 | break; 74 | } 75 | }, 76 | 77 | /* 78 | * Hides the position/height indicator marker drawn onto the map 79 | */ 80 | remove() { 81 | this._props = null; 82 | switch(this.options.marker) { 83 | case 'elevation-line': this._container && this._container.classed("leaflet-hidden", true); break; 84 | case 'position-marker': _.addClass(this._marker.getElement(), 'leaflet-hidden'); break; 85 | } 86 | }, 87 | 88 | getLatLng() { 89 | return this._latlng; 90 | }, 91 | 92 | _registerTooltip(props) { 93 | this._labels[props.name] = props; 94 | } 95 | 96 | }); 97 | -------------------------------------------------------------------------------- /src/components/summary.js: -------------------------------------------------------------------------------- 1 | const _ = L.Control.Elevation.Utils; 2 | 3 | export var Summary = L.Class.extend({ 4 | initialize(opts, control) { 5 | this.options = opts; 6 | this.control = control; 7 | this.labels = {}; 8 | 9 | let summary = this._container = _.create("div", "elevation-summary " + (opts.summary ? opts.summary + "-summary" : '')); 10 | _.style(summary, 'max-width', opts.width ? opts.width + 'px' : ''); 11 | }, 12 | 13 | render() { 14 | return container => container.append(() => this._container); 15 | }, 16 | 17 | reset() { 18 | this._container.innerHTML = ''; 19 | }, 20 | 21 | append(className, label, value) { 22 | this._container.innerHTML += `${label}${value}`; 23 | return this; 24 | }, 25 | 26 | update() { 27 | Object 28 | .keys(this.labels) 29 | .sort((a, b) => this.labels[a].order - this.labels[b].order) // TODO: any performance issues? 30 | .forEach((i)=> { 31 | let value = typeof this.labels[i].value !== "function" ? this.labels[i].value : this.labels[i].value(this.control.track_info, this.labels[i].unit || ''); 32 | this.append(i /*+ " order-" + this.labels[i].order*/, L._(this.labels[i].label), value, this.labels[i].order); 33 | }); 34 | }, 35 | 36 | _registerSummary(data) { 37 | for (let i in data) { 38 | data[i].order = data[i].order ?? 1000; 39 | this.labels[i] = data[i]; 40 | } 41 | } 42 | 43 | }); 44 | -------------------------------------------------------------------------------- /src/handlers/acceleration.js: -------------------------------------------------------------------------------- 1 | export function Acceleration() { 2 | 3 | const _ = L.Control.Elevation.Utils; 4 | 5 | let opts = this.options; 6 | let acceleration = {}; 7 | 8 | acceleration.label = opts.accelerationLabel || L._(opts.imperial ? 'ft/s²' : 'm/s²'); 9 | opts.accelerationFactor = opts.accelerationFactor || 1; 10 | 11 | return { 12 | name: 'acceleration', 13 | unit: acceleration.label, 14 | deltaMax: this.options.accelerationDeltaMax, 15 | clampRange: this.options.accelerationRange, 16 | decimals: 2, 17 | pointToAttr: (_, i) => { 18 | let dv = (this._data[i].speed - this._data[i > 0 ? i - 1 : i].speed) * (1000 / opts.timeFactor); 19 | let dt = (this._data[i].time - this._data[i > 0 ? i - 1 : i].time) / 1000; 20 | return dt > 0 ? Math.abs((dv / dt)) * opts.accelerationFactor : NaN; 21 | }, 22 | stats: { max: _.iMax, min: _.iMin, avg: _.iAvg }, 23 | scale: { 24 | axis : "y", 25 | position : "right", 26 | scale : { min: 0, max: +1 }, 27 | tickPadding: 16, 28 | labelX : 25, 29 | labelY : -8, 30 | }, 31 | path: { 32 | label : 'Acceleration', 33 | yAttr : 'acceleration', 34 | scaleX : 'distance', 35 | scaleY : 'acceleration', 36 | color : '#050402', 37 | strokeColor : '#000', 38 | strokeOpacity: "0.5", 39 | fillOpacity : "0.25", 40 | }, 41 | tooltip: { 42 | chart: (item) => L._("a: ") + item.acceleration + " " + acceleration.label, 43 | marker: (item) => Math.round(item.acceleration) + " " + acceleration.label, 44 | order: 60, 45 | }, 46 | summary: { 47 | "minacceleration" : { 48 | label: "Min Acceleration: ", 49 | value: (track, unit) => Math.round(track.acceleration_min || 0) + ' ' + unit, 50 | order: 60 51 | }, 52 | "maxacceleration" : { 53 | label: "Max Acceleration: ", 54 | value: (track, unit) => Math.round(track.acceleration_max || 0) + ' ' + unit, 55 | order: 61 56 | }, 57 | "avgacceleration": { 58 | label: "Avg Acceleration: ", 59 | value: (track, unit) => Math.round(track.acceleration_avg || 0) + ' ' + unit, 60 | order: 62 61 | }, 62 | } 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /src/handlers/altitude.js: -------------------------------------------------------------------------------- 1 | export function Altitude() { 2 | 3 | const _ = L.Control.Elevation.Utils; 4 | 5 | let opts = this.options; 6 | let altitude = {}; 7 | 8 | let theme = opts.theme.split(' ')[0].replace('-theme', ''); 9 | let color = _.Colors[theme] || {}; 10 | 11 | opts.altitudeFactor = opts.imperial ? this.__footFactor : (opts.altitudeFactor || 1); // 1 m = (1 m) 12 | altitude.label = opts.imperial ? "ft" : opts.yLabel; 13 | 14 | return { 15 | name: 'altitude', 16 | required: this.options.slope, 17 | meta: 'z', 18 | unit: altitude.label, 19 | statsName: 'elevation', 20 | deltaMax: this.options.altitudeDeltaMax, 21 | clampRange: this.options.altitudeRange, 22 | // init: ({point}) => { 23 | // // "alt" property is generated inside "leaflet" 24 | // if ("alt" in point) point.meta.ele = point.alt; 25 | // }, 26 | pointToAttr: (point, i) => { 27 | if ("alt" in point) point.meta.ele = point.alt; // "alt" property is generated inside "leaflet" 28 | return this._data[i].z *= opts.altitudeFactor; 29 | }, 30 | stats: { max: _.iMax, min: _.iMin, avg: _.iAvg }, 31 | grid: { 32 | axis : "y", 33 | position : "left", 34 | scale : "y" // this._chart._y, 35 | }, 36 | scale: { 37 | axis : "y", 38 | position: "left", 39 | scale : "y", // this._chart._y, 40 | labelX : -3, 41 | labelY : -8, 42 | }, 43 | path: { 44 | label : 'Altitude', 45 | scaleX : 'distance', 46 | scaleY : 'altitude', 47 | className : 'area', 48 | color : color.area || theme, 49 | strokeColor : opts.detached ? color.stroke : '#000', 50 | strokeOpacity: "1", 51 | fillOpacity : opts.detached ? (color.alpha || '0.8') : 1, 52 | preferCanvas : opts.preferCanvas, 53 | }, 54 | tooltip: { 55 | name: 'y', 56 | chart: (item) => L._("y: ") + _.round(item[opts.yAttr], opts.decimalsY) + " " + altitude.label, 57 | marker: (item) => _.round(item[opts.yAttr], opts.decimalsY) + " " + altitude.label, 58 | order: 10, 59 | }, 60 | summary: { 61 | "minele" : { 62 | label: "Min Elevation: ", 63 | value: (track, unit) => (track.elevation_min || 0).toFixed(2) + ' ' + unit, 64 | order: 30, 65 | }, 66 | "maxele" : { 67 | label: "Max Elevation: ", 68 | value: (track, unit) => (track.elevation_max || 0).toFixed(2) + ' ' + unit, 69 | order: 31, 70 | }, 71 | "avgele" : { 72 | label: "Avg Elevation: ", 73 | value: (track, unit) => (track.elevation_avg || 0).toFixed(2) + ' ' + unit, 74 | order: 32, 75 | }, 76 | } 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /src/handlers/cadence.js: -------------------------------------------------------------------------------- 1 | export function Cadence() { 2 | 3 | const _ = L.Control.Elevation.Utils; 4 | 5 | return { 6 | name: 'cadence', // <-- Your custom option name (eg. "cadence: true") 7 | unit: 'rpm', 8 | meta: 'cad', // <-- point.meta.cad 9 | coordinateProperties: ["cads", "cadences", "cad", "cadence"], // List of GPX Extensions ("coordinateProperties") to be handled by "@tmcw/toGeoJSON" 10 | pointToAttr: (point, i) => (point.cad ?? point.meta.cad ?? point.prev('cadence')) || 0, 11 | stats: { max: _.iMax, min: _.iMin, avg: _.iAvg }, 12 | scale: { 13 | axis : "y", 14 | position : "right", 15 | scale : { min: -1, max: +1 }, 16 | tickPadding: 16, 17 | labelX : 25, 18 | labelY : -8, 19 | }, 20 | path: { 21 | label : 'RPM', 22 | yAttr : 'cadence', 23 | scaleX : 'distance', 24 | scaleY : 'cadence', 25 | color : '#FFF', 26 | strokeColor : 'blue', 27 | strokeOpacity: "0.85", 28 | fillOpacity : "0.1", 29 | }, 30 | tooltip: { 31 | name: 'cadence', 32 | chart: (item) => L._("cad: ") + item.cadence + " " + 'rpm', 33 | marker: (item) => Math.round(item.cadence) + " " + 'rpm', 34 | order: 1 35 | }, 36 | summary: { 37 | "minrpm": { 38 | label: "Min RPM: ", 39 | value: (track, unit) => Math.round(track.cadence_min || 0) + ' ' + unit, 40 | // order: 30 41 | }, 42 | "maxrpm": { 43 | label: "Max RPM: ", 44 | value: (track, unit) => Math.round(track.cadence_max || 0) + ' ' + unit, 45 | // order: 30 46 | }, 47 | "avgrpm": { 48 | label: "Avg RPM: ", 49 | value: (track, unit) => Math.round(track.cadence_avg || 0) + ' ' + unit, 50 | // order: 20 51 | }, 52 | } 53 | }; 54 | } -------------------------------------------------------------------------------- /src/handlers/distance.js: -------------------------------------------------------------------------------- 1 | export function Distance() { 2 | 3 | const _ = L.Control.Elevation.Utils; 4 | 5 | let opts = this.options; 6 | let distance = {}; 7 | 8 | opts.distanceFactor = opts.imperial ? this.__mileFactor : (opts.distanceFactor || 1); // 1 km = (1000 m) 9 | distance.label = opts.imperial ? "mi" : opts.xLabel; 10 | 11 | return { 12 | name: 'distance', 13 | required: true, 14 | attr: 'dist', 15 | unit: distance.label, 16 | decimals: 5, 17 | pointToAttr: (_, i) => (i > 0 ? this._data[i - 1].dist : 0) + (this._data[i].latlng.distanceTo(this._data[i > 0 ? i - 1 : i].latlng) * opts.distanceFactor) / 1000, // convert back km to meters 18 | // stats: { total: _.iSum }, 19 | onPointAdded: (distance, i) => this.track_info.distance = distance, 20 | scale: opts.distance && { 21 | axis : "x", 22 | position: "bottom", 23 | scale : "x", // this._chart._x, 24 | labelY : 25, 25 | labelX : () => this._width() + 6, 26 | ticks : () => _.clamp(this._chart._xTicks() / 2, [4, +Infinity]), 27 | }, 28 | grid: opts.distance && { 29 | axis : "x", 30 | position : "bottom", 31 | scale : "x" // this._chart._x, 32 | }, 33 | tooltip: opts.distance && { 34 | name: 'x', 35 | chart: (item) => L._("x: ") + _.round(item[opts.xAttr], opts.decimalsX) + " " + distance.label, 36 | order: 20 37 | }, 38 | summary: opts.distance && { 39 | "totlen" : { 40 | label: "Total Length: ", 41 | value: (track) => (track.distance || 0).toFixed(2) + ' ' + distance.label, 42 | order: 10 43 | } 44 | } 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/handlers/heart.js: -------------------------------------------------------------------------------- 1 | export function Heart() { 2 | 3 | const _ = L.Control.Elevation.Utils; 4 | 5 | return { 6 | name: 'heart', // <-- Your custom option name (eg. "heart: true") 7 | unit: 'bpm', 8 | meta: 'hr', // <-- point.meta.hr 9 | coordinateProperties: ["heart", "heartRates", "heartRate"], // List of GPX Extensions ("coordinateProperties") to be handled by "@tmcw/toGeoJSON" 10 | pointToAttr: (point, i) => (point.hr ?? point.meta.hr ?? point.prev('heart')) || 0, 11 | stats: { max: _.iMax, min: _.iMin, avg: _.iAvg }, 12 | scale: { 13 | axis : "y", 14 | position : "left", 15 | scale : { min: -1, max: +1 }, 16 | tickPadding: 25, 17 | labelX : -30, 18 | labelY : -8, 19 | }, 20 | path: { 21 | label : 'ECG', 22 | yAttr : 'heart', 23 | scaleX : 'distance', 24 | scaleY : 'heart', 25 | color : 'white', 26 | strokeColor : 'red', 27 | strokeOpacity: "0.85", 28 | fillOpacity : "0.1", 29 | }, 30 | tooltip: { 31 | chart: (item) => L._("hr: ") + item.heart + " " + 'bpm', 32 | marker: (item) => Math.round(item.heart) + " " + 'bpm', 33 | order: 1 34 | }, 35 | summary: { 36 | "minbpm": { 37 | label: "Min BPM: ", 38 | value: (track, unit) => Math.round(track.heart_min || 0) + ' ' + unit, 39 | // order: 30 40 | }, 41 | "maxbpm": { 42 | label: "Max BPM: ", 43 | value: (track, unit) => Math.round(track.heart_max || 0) + ' ' + unit, 44 | // order: 30 45 | }, 46 | "avgbpm": { 47 | label: "Avg BPM: ", 48 | value: (track, unit) => Math.round(track.heart_avg || 0) + ' ' + unit, 49 | // order: 20 50 | }, 51 | } 52 | }; 53 | }; -------------------------------------------------------------------------------- /src/handlers/labels.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://github.com/Raruto/leaflet-elevation/issues/211 3 | * 4 | * @example 5 | * ```js 6 | * L.control.Elevation({ handlers: [ 'Labels' ], labelsRotation: 25, labelsAlign: 'start' }) 7 | * ``` 8 | */ 9 | export function Labels() { 10 | 11 | this.on('elechart_updated', function(e) { 12 | 13 | const pointG = this._chart._chart.pane('point'); 14 | 15 | const textRotation = this.options.labelsRotation ?? -90; 16 | const textAnchor = this.options.labelsAlign; 17 | 18 | if (90 == Math.abs(textRotation)) { 19 | 20 | pointG.selectAll('text') 21 | .attr('dy', '4px') 22 | .attr("dx", (d, i, el) => Math.sign(textRotation) * (this._height() - d3.select(el[i].parentElement).datum().y - 8) + 'px') 23 | .attr('text-anchor', textRotation > 0 ? 'end' : 'start') 24 | .attr('transform', 'rotate(' + textRotation + ')') 25 | 26 | pointG.selectAll('circle') 27 | .attr('r', '2.5') 28 | .attr('fill', '#fff') 29 | .attr("stroke", '#000') 30 | .attr("stroke-width", '1.1'); 31 | 32 | } else if (!isNaN(textRotation)) { 33 | 34 | pointG.selectAll('text') 35 | .attr("dx", "4px") 36 | .attr("dy", "-9px") 37 | .attr('text-anchor', textAnchor ?? (0 == textRotation ? 'start' : 'middle')) 38 | .attr('transform', 'rotate('+ -textRotation +')') 39 | 40 | } 41 | 42 | }); 43 | 44 | return { }; 45 | } 46 | -------------------------------------------------------------------------------- /src/handlers/lineargradient.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://github.com/Raruto/leaflet-elevation/issues/251 3 | * 4 | * @example 5 | * ```js 6 | * L.control.Elevation({ 7 | * altitude: true, 8 | * distance: true, 9 | * handlers: [ 'Altitude', 'Distance', 'LinearGradient', ], 10 | * linearGradient: { 11 | * attr: 'z', 12 | * path: 'altitude', 13 | * range: { 0.0: '#008800', 0.5: '#ffff00', 1.0: '#ff0000' }, 14 | * min: 'elevation_min', 15 | * max: 'elevation_max', 16 | * }, 17 | * }) 18 | * ``` 19 | */ 20 | export function LinearGradient() { 21 | 22 | if (!this.options.linearGradient) { 23 | return {}; 24 | } 25 | 26 | const _ = L.Control.Elevation.Utils; 27 | 28 | /** 29 | * Initialize gradient color palette. 30 | */ 31 | const get_palette = function ({range, min, max, depth = 256}) { 32 | const canvas = document.createElement('canvas'), 33 | ctx = canvas.getContext('2d'), 34 | gradient = ctx.createLinearGradient(0, 0, 0, depth); 35 | 36 | canvas.width = 1; 37 | canvas.height = depth; 38 | 39 | for (let i in range) { 40 | gradient.addColorStop(i, range[i]); 41 | } 42 | 43 | ctx.fillStyle = gradient; 44 | ctx.fillRect(0, 0, 1, depth); 45 | 46 | const { data } = ctx.getImageData(0, 0, 1, depth); 47 | 48 | return { 49 | /** 50 | * Gets the RGB values of a given z value of the current palette. 51 | * 52 | * @param {number} value - Value to get the color for, should be between min and max. 53 | * @returns {string} The RGB values as `rgb(r, g, b)` string 54 | */ 55 | getRGBColor(value) { 56 | const idx = Math.floor(Math.min(Math.max((value - min) / (max - min), 0), 0.999) * depth) * 4; 57 | return 'rgb(' + [data[idx], data[idx + 1], data[idx + 2]].join(',') + ')'; 58 | } 59 | }; 60 | }; 61 | 62 | const { preferCanvas } = this.options; 63 | const { attr, path: path_name, range, min, max } = L.extend({ 64 | attr: 'z', 65 | path: 'altitude', 66 | range: { 0.0: '#008800', 0.5: '#ffff00', 1.0: '#ff0000' }, 67 | min: 'elevation_min', 68 | max: 'elevation_max', 69 | }, (true === this.options.linearGradient) ? {} : this.options.linearGradient); 70 | 71 | const gradient_id = path_name + '-gradient-' + _.randomId(); 72 | const legend_id = 'legend-' + gradient_id; 73 | 74 | // Charte profile gradient 75 | this.on('elechart_axis', () => { 76 | if (!this._data.length) return; 77 | 78 | const chart = this._chart; 79 | const path = chart._paths[path_name]; 80 | const { defs } = chart._chart.utils; 81 | 82 | const palette = get_palette({ 83 | min: isFinite(this.track_info[min]) ? this.track_info[min] : 0, 84 | max: isFinite(this.track_info[max]) ? this.track_info[max] : 1, 85 | range, 86 | }); 87 | 88 | let gradient; 89 | 90 | if (preferCanvas) { 91 | /** ref: `path.__fillStyle` within L.Control.Elevation.Utils::drawCanvas(ctx, path) */ 92 | path.__fillStyle = gradient = chart._context.createLinearGradient(0, 0, chart._width(), 0); 93 | } else { 94 | defs.select('#' + gradient_id).remove(); 95 | gradient = defs.append('svg:linearGradient').attr('id', gradient_id); 96 | gradient.addColorStop = function(offset, color) { gradient.append('svg:stop').attr('offset', offset).attr('stop-color', color) }; 97 | path.attr('fill', 'url(#' + gradient_id + ')').classed('area', false); 98 | } 99 | 100 | // Generate gradient for each segment picking colors from palette 101 | for (let i = 0, data = this._data; i < data.length; i++) { 102 | gradient.addColorStop((i) / data.length, palette.getRGBColor(data[i][attr])); 103 | } 104 | 105 | }); 106 | 107 | // Legend item gradient 108 | this.on('elechart_updated', () => { 109 | const chart = this._chart; 110 | const { defs } = chart._chart.utils; 111 | defs.select('#' + legend_id).remove(); 112 | const legendGradient = defs.append('svg:linearGradient').attr('id', legend_id); 113 | Object.keys(range).sort().forEach(i => legendGradient.append('svg:stop').attr('offset', i).attr('stop-color', range[i])); 114 | 115 | chart._container 116 | .select('.legend-' + path_name + ' > rect') 117 | .attr('fill', 'url(#' + legend_id + ')') 118 | .classed('area', false); 119 | }); 120 | 121 | return { }; 122 | } -------------------------------------------------------------------------------- /src/handlers/pace.js: -------------------------------------------------------------------------------- 1 | export function Pace() { 2 | 3 | const _ = L.Control.Elevation.Utils; 4 | 5 | let opts = this.options; 6 | let pace = {}; 7 | 8 | pace.label = opts.paceLabel || L._(opts.imperial ? 'min/mi' : 'min/km'); 9 | opts.paceFactor = opts.paceFactor || 60; // 1 min = 60 sec 10 | 11 | return { 12 | name: 'pace', 13 | unit: pace.label, 14 | deltaMax: this.options.paceDeltaMax, 15 | clampRange: this.options.paceRange, 16 | decimals: 2, 17 | pointToAttr: (_, i) => { 18 | let dx = (this._data[i].dist - this._data[i > 0 ? i - 1 : i].dist) * 1000; 19 | let dt = this._data[i].time - this._data[ i > 0 ? i - 1 : i].time; 20 | return dx > 0 ? Math.abs((dt / dx) / opts.paceFactor) : NaN; 21 | }, 22 | stats: { max: _.iMax, min: _.iMin, avg: _.iAvg }, 23 | scale : (this.options.pace && this.options.pace != "summary") && { 24 | axis : "y", 25 | position : "right", 26 | scale : { min : 0, max : +1 }, 27 | tickPadding: 16, 28 | labelX : 25, 29 | labelY : -8, 30 | }, 31 | path: (this.options.pace && this.options.pace != "summary") && { 32 | // name : 'pace', 33 | label : 'Pace', 34 | yAttr : "pace", 35 | scaleX : 'distance', 36 | scaleY : 'pace', 37 | color : '#03ffff', 38 | strokeColor : '#000', 39 | strokeOpacity: "0.5", 40 | fillOpacity : "0.25", 41 | }, 42 | tooltip: (this.options.pace) && { 43 | chart: (item) => L._('pace: ') + item.pace + " " + pace.label, 44 | marker: (item) => Math.round(item.pace) + " " + pace.label, 45 | order: 50, 46 | }, 47 | summary: (this.options.pace) && { 48 | "minpace" : { 49 | label: "Min Pace: ", 50 | value: (track, unit) => Math.round(track.pace_min || 0) + ' ' + unit, 51 | order: 51 52 | }, 53 | "maxpace" : { 54 | label: "Max Pace: ", 55 | value: (track, unit) => Math.round(track.pace_max || 0) + ' ' + unit, 56 | order: 51 57 | }, 58 | "avgpace": { 59 | label: "Avg Pace: ", 60 | value: (track, unit) => Math.round(track.pace_avg || 0) + ' ' + unit, 61 | order: 52 62 | }, 63 | } 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /src/handlers/runner.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://github.com/Igor-Vladyka/leaflet.motion 3 | * 4 | * @example 5 | * ```js 6 | * L.control.Elevation({ handlers: [ 'Runner' ], runnerOptions: { polyline: {..}, motion: {..}, marker {..} } }) 7 | * ``` 8 | */ 9 | export async function Runner() { 10 | 11 | await this.import(this.__LMOTION || 'https://unpkg.com/leaflet.motion@0.3.2/dist/leaflet.motion.min.js', typeof L.Motion !== 'object') 12 | 13 | let { runnerOptions } = this.options; 14 | 15 | runnerOptions = L.extend( 16 | { polyline: {}, motion: {}, marker: undefined }, 17 | 'object' === typeof runnerOptions ? runnerOptions : {} 18 | ); 19 | 20 | // Custom tooltips 21 | this._registerTooltip({ 22 | name: 'here', 23 | marker: (item) => L._("You are here: "), 24 | order: 1, 25 | }); 26 | 27 | this._registerTooltip({ 28 | name: 'distance', 29 | marker: (item) => Math.round(item.dist) + " " + this.options.xLabel, 30 | order: 2, 31 | }); 32 | 33 | this.addCheckpoint = function (checkpoint) { 34 | return this._registerCheckPoint({ // <-- NB these are private functions use them at your own risk! 35 | latlng: this._findItemForX(this._x(checkpoint.dist)).latlng, 36 | label: checkpoint.label || '' 37 | }); 38 | } 39 | 40 | this.addRunner = function (runner) { 41 | let x = this._x(runner.dist); 42 | let y = this._y(this._findItemForX(x).z) 43 | let g = d3.select(this._container) 44 | .select('svg > g') 45 | .append("svg:circle") 46 | .attr("class", "runner " + this.options.theme + " height-focus circle-lower") 47 | .attr("r", 6) 48 | .attr("cx", x) 49 | .attr("cy", y); 50 | return g; 51 | } 52 | 53 | this.setPositionFromLatLng = function (latlng) { 54 | this._onMouseMoveLayer({ latlng: latlng }); // Update map and chart "markers" from latlng 55 | }; 56 | 57 | this.tick = function (runner, dist = 0, inc = 0.1) { 58 | dist = (dist <= this.track_info.distance - inc) ? dist + inc : 0; 59 | this.updateRunnerPos(runner, dist); 60 | setTimeout(() => this.tick(runner, dist), 150); 61 | }; 62 | 63 | this.updateRunnerPos = function (runner, pos) { 64 | let curr, x; 65 | 66 | if (pos instanceof L.LatLng) { 67 | curr = this._findItemForLatLng(pos); 68 | x = this._x(curr.dist); 69 | } else { 70 | x = this._x(pos); 71 | curr = this._findItemForX(x); 72 | } 73 | 74 | runner 75 | .attr("cx", x) 76 | .attr("cy", this._y(curr.z)); 77 | this.setPositionFromLatLng(curr.latlng); 78 | }; 79 | 80 | this.animate = function (layer, speed = 1500) { 81 | 82 | if (this._runner) { 83 | this._runner.remove(); 84 | } 85 | 86 | layer.setStyle({ opacity: 0.5 }); 87 | 88 | const geo = L.geoJson(layer.toGeoJSON(), { coordsToLatLng: (coords) => L.latLng(coords[0], coords[1], coords[2] * (this.options.altitudeFactor || 1)) }); 89 | this._runner = L.motion.polyline( 90 | geo.toGeoJSON().features[0].geometry.coordinates, 91 | L.extend({}, { color: 'red', pane: 'elevationPane', attribution: '' }, runnerOptions.polyline), 92 | L.extend({}, { auto: true, speed: speed, }, runnerOptions.motion), 93 | runnerOptions.marker || undefined 94 | ); 95 | 96 | // Override default function behavior: `L.Motion.Polyline::_drawMarker()` 97 | this._runner._drawMarker = new Proxy(this._runner._drawMarker, { 98 | apply: (target, thisArg, argArray) => { 99 | thisArg._runner = thisArg._runner || this.addRunner({ dist: 0 }); 100 | this.updateRunnerPos(thisArg._runner, argArray[0]); 101 | return target.apply(thisArg, argArray); 102 | } 103 | }); 104 | 105 | this._runner.addTo(this._map); 106 | }; 107 | 108 | return {}; 109 | } 110 | -------------------------------------------------------------------------------- /src/handlers/slope.js: -------------------------------------------------------------------------------- 1 | export function Slope() { 2 | 3 | const _ = L.Control.Elevation.Utils; 4 | 5 | let opts = this.options; 6 | let slope = {}; 7 | 8 | slope.label = opts.slopeLabel || '%'; 9 | 10 | return { 11 | name: 'slope', 12 | meta: 'slope', 13 | unit: slope.label, 14 | deltaMax: this.options.slopedDeltaMax, 15 | clampRange: this.options.slopeRange, 16 | decimals: 2, 17 | pointToAttr: (_, i) => { // slope in % = ( dy / dx ) * 100; 18 | let dx = (this._data[i].dist - this._data[i > 0 ? i - 1 : i].dist) * 1000; 19 | let dy = this._data[i][this.options.yAttr] - this._data[i > 0 ? i - 1 : i][this.options.yAttr]; 20 | return dx !== 0 ? (dy / dx) * 100 : NaN; 21 | }, 22 | onPointAdded: (_, i) => { 23 | let dz = this._data[i][this.options.yAttr] - this._data[i > 0 ? i - 1 : i][this.options.yAttr]; 24 | if (dz > 0) this.track_info.ascent = (this.track_info.ascent || 0) + dz; // Total Ascent 25 | else if (dz < 0) this.track_info.descent = (this.track_info.descent || 0) - dz; // Total Descent 26 | }, 27 | stats: { max: _.iMax, min: _.iMin, avg: _.iAvg, }, 28 | scale: { 29 | axis : "y", 30 | position : "right", 31 | scale : { min: -1, max: +1 }, 32 | tickPadding: 16, 33 | labelX : 25, 34 | labelY : -8, 35 | }, 36 | path: { 37 | label : 'Slope', 38 | yAttr : 'slope', 39 | scaleX : 'distance', 40 | scaleY : 'slope', 41 | color : '#F00', 42 | strokeColor : '#000', 43 | strokeOpacity: "0.5", 44 | fillOpacity : "0.25", 45 | }, 46 | tooltip: { 47 | chart: (item) => L._("m: ") + item.slope + slope.label, 48 | marker: (item) => Math.round(item.slope) + slope.label, 49 | order: 40, 50 | }, 51 | summary: { 52 | "minslope": { 53 | label: "Min Slope: ", 54 | value: (track, unit) => Math.round(track.slope_min || 0) + ' ' + unit, 55 | order: 40 56 | }, 57 | "maxslope": { 58 | label: "Max Slope: ", 59 | value: (track, unit) => Math.round(track.slope_max || 0) + ' ' + unit, 60 | order: 41 61 | }, 62 | "avgslope": { 63 | label: "Avg Slope: ", 64 | value: (track, unit) => Math.round(track.slope_avg || 0) + ' ' + unit, 65 | order: 42 66 | }, 67 | "ascent" : { 68 | label: "Total Ascent: ", 69 | value: (track, unit) => Math.round(track.ascent || 0) + ' ' + (this.options.imperial ? 'ft' : 'm'), 70 | order: 43 71 | }, 72 | "descent" : { 73 | label: "Total Descent: ", 74 | value: (track, unit) => Math.round(track.descent || 0) + ' ' + (this.options.imperial ? 'ft' : 'm'), 75 | order: 45 76 | }, 77 | } 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /src/handlers/speed.js: -------------------------------------------------------------------------------- 1 | export function Speed() { 2 | 3 | const _ = L.Control.Elevation.Utils; 4 | 5 | let opts = this.options; 6 | let speed = {}; 7 | 8 | speed.label = opts.speedLabel || L._(opts.imperial ? 'mph' : 'km/h'); 9 | opts.speedFactor = opts.speedFactor || 1; 10 | 11 | return { 12 | name: 'speed', 13 | required: (this.options.acceleration), 14 | unit: speed.label, 15 | deltaMax: this.options.speedDeltaMax, 16 | clampRange: this.options.speedRange, 17 | decimals: 2, 18 | pointToAttr: (_, i) => { 19 | let dx = (this._data[i].dist - this._data[i > 0 ? i - 1 : i].dist) * 1000; 20 | let dt = this._data[i].time - this._data[ i > 0 ? i - 1 : i].time; 21 | return dt > 0 ? Math.abs((dx / dt) * opts.timeFactor) * opts.speedFactor : NaN; 22 | }, 23 | stats: { max: _.iMax, min: _.iMin, avg: _.iAvg }, 24 | scale : (this.options.speed && this.options.speed != "summary") && { 25 | axis : "y", 26 | position : "right", 27 | scale : { min : 0, max : +1 }, 28 | tickPadding: 16, 29 | labelX : 25, 30 | labelY : -8, 31 | }, 32 | path: (this.options.speed && this.options.speed != "summary") && { 33 | // name : 'speed', 34 | label : 'Speed', 35 | yAttr : "speed", 36 | scaleX : 'distance', 37 | scaleY : 'speed', 38 | color : '#03ffff', 39 | strokeColor : '#000', 40 | strokeOpacity: "0.5", 41 | fillOpacity : "0.25", 42 | }, 43 | tooltip: (this.options.speed) && { 44 | chart: (item) => L._('v: ') + item.speed + " " + speed.label, 45 | marker: (item) => Math.round(item.speed) + " " + speed.label, 46 | order: 50, 47 | }, 48 | summary: (this.options.speed) && { 49 | "minspeed" : { 50 | label: "Min Speed: ", 51 | value: (track, unit) => Math.round(track.speed_min || 0) + ' ' + unit, 52 | order: 51 53 | }, 54 | "maxspeed" : { 55 | label: "Max Speed: ", 56 | value: (track, unit) => Math.round(track.speed_max || 0) + ' ' + unit, 57 | order: 51 58 | }, 59 | "avgspeed": { 60 | label: "Avg Speed: ", 61 | value: (track, unit) => Math.round(track.speed_avg || 0) + ' ' + unit, 62 | order: 52 63 | }, 64 | } 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /src/handlers/temperature.js: -------------------------------------------------------------------------------- 1 | export function Temperature() { 2 | 3 | const _ = L.Control.Elevation.Utils; 4 | 5 | let temperature = {}; 6 | let opts = this.options; 7 | temperature.label = opts.label || L._(opts.imperial ? '°F' : '°C'); 8 | 9 | // Fahrenheit = (Celsius * 9/5) + 32 10 | opts.temperatureFactor1 = opts.temperatureFactor1 ?? (opts.imperial ? 1.8 : 1); 11 | opts.temperatureFactor2 = opts.temperatureFactor2 ?? (opts.imperial ? 32 : 0); 12 | 13 | return { 14 | name: 'temperature', // <-- Your custom option name (eg. "temperature: true") 15 | unit: temperature.label, 16 | meta: 'atemps', // <-- point.meta.atemps 17 | coordinateProperties: ["atemps"], // List of GPX Extensions ("coordinateProperties") to be handled by "@tmcw/toGeoJSON" 18 | deltaMax: this.options.temperatureDeltaMax, 19 | clampRange: this.options.temperatureRange, 20 | decimals: 2, 21 | pointToAttr: (point, i) => (point.meta.atemps ?? point.meta.atemps ?? point.prev('temperature')) * opts.temperatureFactor1 + opts.temperatureFactor2, 22 | stats: { max: _.iMax, min: _.iMin, avg: _.iAvg }, 23 | scale: { 24 | axis : "y", 25 | position : "right", 26 | scale : { min: -1, max: +1 }, 27 | tickPadding: 16, 28 | labelX : +18, 29 | labelY : -8, 30 | }, 31 | path: { 32 | label : temperature.label, 33 | yAttr : 'temperature', 34 | scaleX : 'distance', 35 | scaleY : 'temperature', 36 | color : 'transparent', 37 | strokeColor : '#000', 38 | strokeOpacity: "0.85", 39 | // fillOpacity : "0.1", 40 | }, 41 | tooltip: { 42 | name: 'temperature', 43 | chart: (item) => L._("Temp: ") + Math.round(item.temperature).toLocaleString() + " " + temperature.label, 44 | marker: (item) => Math.round(item.temperature).toLocaleString() + " " + temperature.label, 45 | order: 1 46 | }, 47 | summary: { 48 | "mintemp": { 49 | label: "Min Temp: ", 50 | value: (track, unit) => Math.round(track.temperature_min || 0) + ' ' + unit, 51 | }, 52 | "maxtemp": { 53 | label: "Max Temp: ", 54 | value: (track, unit) => Math.round(track.temperature_max || 0) + ' ' + unit, 55 | }, 56 | "avgtemp": { 57 | label: "Avg Temp: ", 58 | value: (track, unit) => Math.round(track.temperature_avg || 0) + ' ' + unit, 59 | }, 60 | } 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /src/handlers/time.js: -------------------------------------------------------------------------------- 1 | export function Time() { 2 | 3 | const _ = L.Control.Elevation.Utils; 4 | 5 | let opts = this.options; 6 | let time = {}; 7 | 8 | time.label = opts.timeLabel || 't'; 9 | opts.timeFactor = opts.timeFactor || 3600; 10 | 11 | /** 12 | * Common AVG speeds: 13 | * ---------------------- 14 | * slow walk = 1.8 km/h 15 | * walking = 3.6 km/h <-- default: 3.6 16 | * running = 10.8 km/h 17 | * cycling = 18 km/h 18 | * driving = 72 km/h 19 | * ---------------------- 20 | */ 21 | this._timeAVGSpeed = (opts.timeAVGSpeed || 3.6) * (opts.speedFactor || 1); 22 | 23 | if (!opts.timeFormat) { 24 | opts.timeFormat = (time) => (new Date(time)).toLocaleString().replaceAll('/', '-').replaceAll(',', ' '); 25 | } else if (opts.timeFormat == 'time') { 26 | opts.timeFormat = (time) => (new Date(time)).toLocaleTimeString(); 27 | } else if (opts.timeFormat == 'date') { 28 | opts.timeFormat = (time) => (new Date(time)).toLocaleDateString(); 29 | } 30 | 31 | opts.xTimeFormat = opts.xTimeFormat || ((t) => _.formatTime(t).split("'")[0]); 32 | 33 | return { 34 | name: 'time', 35 | required: (this.options.speed || this.options.acceleration || this.options.timestamps), 36 | coordinateProperties: ["coordTimes", "times", "time"], 37 | coordPropsToMeta: _.parseDate, 38 | pointToAttr: function(point, i) { 39 | // Add missing timestamps (see: options.timeAVGSpeed) 40 | if (!point.meta || !point.meta.time) { 41 | point.meta = point.meta || {}; 42 | if (i > 0) { 43 | let dx = (this._data[i].dist - this._data[i - 1].dist); 44 | let t0 = this._data[i - 1].time.getTime(); 45 | point.meta.time = new Date(t0 + ( dx / this._timeAVGSpeed) * this.options.timeFactor * 1000); 46 | } else { 47 | point.meta.time = new Date(Date.now()) 48 | } 49 | } 50 | // Handle timezone offset 51 | let time = (point.meta.time.getTime() - point.meta.time.getTimezoneOffset() * 60 * 1000 !== 0) ? point.meta.time : 0; 52 | // Update duration 53 | this._data[i].duration = i > 0 ? (this._data[i - 1].duration || 0) + Math.abs(time - this._data[i - 1].time) : 0; 54 | return time; 55 | }, 56 | onPointAdded: (_, i) => this.track_info.time = this._data[i].duration, 57 | scale: (opts.time && opts.time != "summary" && !L.Browser.mobile) && { 58 | axis : "x", 59 | position : "top", 60 | scale : { 61 | attr : "duration", 62 | min : 0, 63 | }, 64 | label : time.label, 65 | labelY : -10, 66 | labelX : () => this._width(), 67 | name : "time", 68 | ticks : () => _.clamp(this._chart._xTicks() / 2, [4, +Infinity]), 69 | tickFormat : (d) => (d == 0 ? '' : opts.xTimeFormat(d)), 70 | onAxisMount: axis => { 71 | axis.select(".domain") 72 | .remove(); 73 | axis.selectAll("text") 74 | .attr('opacity', 0.65) 75 | .style('font-family', 'Monospace') 76 | .style('font-size', '110%'); 77 | axis.selectAll(".tick line") 78 | .attr('y2', this._height()) 79 | .attr('stroke-dasharray', 2) 80 | .attr('opacity', 0.75); 81 | } 82 | }, 83 | tooltips: [ 84 | (this.options.time) && { 85 | name: 'time', 86 | chart: (item) => L._("T: ") + _.formatTime(item.duration || 0), 87 | order: 20 88 | }, 89 | (this.options.timestamps) && { 90 | name: 'date', 91 | chart: (item) => L._("t: ") + this.options.timeFormat(item.time), 92 | order: 21, 93 | } 94 | ], 95 | summary: (this.options.time) && { 96 | "tottime" : { 97 | label: "Total Time: ", 98 | value: (track) => _.formatTime(track.time || 0), 99 | order: 20 100 | } 101 | } 102 | }; 103 | } -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | .leaflet-hidden { 2 | visibility: hidden; 3 | } 4 | 5 | .legend { 6 | cursor: pointer; 7 | } 8 | 9 | .leaflet-container { 10 | z-index: 0; 11 | /* prevent overlapping the .elevation-detached chart */ 12 | } 13 | 14 | .elevation-control .background { 15 | background-color: var(--ele-bg, rgba(70, 130, 180, 0.2)); 16 | border-radius: 5px; 17 | overflow: visible; 18 | display: block; 19 | touch-action: none; 20 | user-select: none; 21 | max-width: 100%; 22 | } 23 | 24 | .elevation-control .grid, 25 | .elevation-control .area > foreignObject, 26 | .elevation-control .axis, 27 | .elevation-control .tooltip, 28 | .height-focus.line { 29 | pointer-events: none; 30 | } 31 | 32 | .elevation-control .axis line, 33 | .elevation-control .axis path { 34 | stroke: var(--ele-axis, #2D1130); 35 | stroke-width: 1; 36 | fill: none; 37 | } 38 | 39 | .elevation-control .grid .tick line { 40 | stroke: var(--ele-grid, #EEE); 41 | stroke-width: 1px; 42 | shape-rendering: crispEdges; 43 | } 44 | 45 | .elevation-control .grid path { 46 | stroke-width: 0; 47 | } 48 | 49 | .elevation-control .axis text, 50 | .elevation-control .legend text, 51 | .elevation-control .point text { 52 | fill: #000; 53 | font-weight: 700; 54 | paint-order: stroke fill; 55 | stroke: #fff; 56 | stroke-width: 2px 57 | } 58 | 59 | .elevation-control .y.axis text { 60 | text-anchor: end; 61 | } 62 | 63 | .elevation-control .area { 64 | fill: var(--ele-area, #4682B4); 65 | stroke: var(--ele-stroke, #000); 66 | stroke-width: 1.2; 67 | paint-order: stroke fill; 68 | } 69 | 70 | .elevation-control .horizontal-drag-line { 71 | cursor: row-resize; 72 | stroke: transparent; 73 | stroke-dasharray: 5; 74 | stroke-width: 1.1; 75 | } 76 | 77 | .elevation-control .active .horizontal-drag-line { 78 | stroke: #000; 79 | } 80 | 81 | .elevation-control .horizontal-drag-label { 82 | fill: #000; 83 | font-weight: 700; 84 | paint-order: stroke; 85 | stroke: #FFF; 86 | stroke-width: 2px; 87 | } 88 | 89 | .elevation-control .ruler { 90 | color: #000; 91 | cursor: row-resize; 92 | } 93 | 94 | .elevation-control .mouse-focus-line { 95 | stroke: #000; 96 | stroke-width: 1; 97 | } 98 | 99 | .elevation-control .mouse-focus-label-rect { 100 | fill: #000; 101 | fill-opacity: 0.75; 102 | stroke-width: 1; 103 | stroke: #444; 104 | } 105 | 106 | .elevation-control .mouse-focus-label-text { 107 | fill: #FFF; 108 | font-size: 10px; 109 | } 110 | 111 | .elevation-control .brush .overlay { 112 | cursor: unset; 113 | } 114 | 115 | .elevation-control .brush .selection { 116 | fill: var(--ele-brush, rgba(23, 74, 117, 0.4)); 117 | stroke: none; 118 | fill-opacity: unset; 119 | } 120 | 121 | .elevation-summary { 122 | font-family: "Lucida Grande", "Lucida Sans Unicode", Verdana, Arial, Helvetica, sans-serif; 123 | font-size: 12px; 124 | margin: var(--ele-sum-margin, 0 auto); 125 | text-shadow: var(--ele-sum-shadow, 1px 0 0 #FFF, -1px 0 0 #FFF, 0 1px 0 #FFF, 0 -1px 0 #FFF, 1px 1px #FFF, -1px -1px 0 #FFF, 1px -1px 0 #FFF, -1px 1px 0 #FFF); 126 | } 127 | 128 | .elevation-summary>span:not(:last-child):after { 129 | content: var(--ele-sum-sep, ''); 130 | } 131 | 132 | .multiline-summary>span { 133 | display: block; 134 | } 135 | 136 | .multiline-summary .download { 137 | float: right; 138 | margin-top: -3em; 139 | margin-right: 2em; 140 | font-weight: bold; 141 | font-size: 1.2em; 142 | } 143 | 144 | .elevation-summary .summaryvalue { 145 | font-weight: bold; 146 | } 147 | 148 | .elevation-toggle-icon { 149 | background-color: #fff; 150 | right: 5px; 151 | top: 5px; 152 | height: var(--ele-toggle-size, 36px); 153 | width: var(--ele-toggle-size, 36px); 154 | cursor: pointer; 155 | box-shadow: 0 1px 7px rgba(0, 0, 0, 0.4); 156 | border-radius: 5px; 157 | display: inline-block; 158 | position: var(--ele-toggle-pos, relative); 159 | } 160 | 161 | .elevation-toggle-icon:before { 162 | content: '\2716'; 163 | display: var(--ele-close-btn, none); 164 | color: #000; 165 | width: 100%; 166 | line-height: 20px; 167 | text-align: center; 168 | font-weight: bold; 169 | font-size: 15px; 170 | } 171 | 172 | .leaflet-elevation-pane .height-focus, 173 | .leaflet-overlay-pane .height-focus { 174 | stroke: #000; 175 | fill: var(--ele-circle, var(--ele-area, #FFF)); 176 | } 177 | 178 | .leaflet-elevation-pane .height-focus.line, 179 | .leaflet-overlay-pane .height-focus.line { 180 | stroke-width: 2; 181 | } 182 | 183 | .leaflet-elevation-pane .height-focus-label, 184 | .leaflet-overlay-pane .height-focus-label { 185 | font-size: 12px; 186 | font-weight: 600; 187 | fill: #000; 188 | paint-order: stroke; 189 | stroke: #FFF; 190 | stroke-width: 2px; 191 | } 192 | 193 | .elevation-waypoint-icon:before, 194 | .elevation-position-icon:before { 195 | content: ""; 196 | width: 100%; 197 | height: 100%; 198 | display: inline-block; 199 | background: var(--ele-marker) no-repeat center center / contain; 200 | } 201 | 202 | .elevation-polyline { 203 | stroke: var(--ele-poly, var(--ele-area, #000)); 204 | filter: drop-shadow(1px 1px 0 #FFF) drop-shadow(-1px -1px 0 #FFF) drop-shadow(1px -1px 0 #FFF) drop-shadow(-1px 1px 0 #FFF); 205 | } 206 | 207 | /* CHART STATES /////////////////////////////////////////////////// */ 208 | 209 | .elevation-detached { 210 | font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif; 211 | height: auto; 212 | width: 100%; 213 | position: relative; 214 | z-index: 0; 215 | } 216 | 217 | .elevation-detached .area { 218 | fill-opacity: var(--ele-alpha, 0.8); 219 | } 220 | 221 | .elevation-detached.elevation-collapsed .elevation-summary { 222 | display: block; 223 | } 224 | 225 | .elevation-detached.elevation-collapsed .elevation-toggle-icon { 226 | top: 5px; 227 | right: 9px; 228 | bottom: 5px; 229 | margin: auto; 230 | } 231 | 232 | .elevation-control.elevation-collapsed > * { 233 | display: none; 234 | } 235 | 236 | .elevation-control.elevation-collapsed > .elevation-toggle-icon { 237 | display: inline-block; 238 | } 239 | 240 | .elevation-detached { 241 | --ele-sum-margin: 12px 35px; 242 | --ele-sum-shadow: none; 243 | --ele-toggle-pos: absolute; 244 | } 245 | 246 | .elevation-expanded { 247 | --ele-close-btn: inline-block; 248 | --ele-toggle-bg: none; 249 | --ele-toggle-pos: absolute; 250 | --ele-toggle-size: 20px; 251 | } 252 | 253 | .inline-summary { 254 | --ele-sum-sep: "\0020\2014\0020"; 255 | } 256 | 257 | .elevation-waypoint-icon { 258 | --ele-marker: url(../images/elevation-pushpin.svg); 259 | } 260 | 261 | .elevation-position-icon { 262 | --ele-marker: url(../images/elevation-position.svg); 263 | } 264 | 265 | /* LIME THEME ///////////////////////////////////////////////////// */ 266 | .lime-theme { 267 | --ele-bg: rgba(156, 194, 34, 0.2); 268 | --ele-axis: #566B13; 269 | --ele-area: #9CC222; 270 | --ele-grid: #CCC; 271 | --ele-brush: rgba(99, 126, 11, 0.4); 272 | --ele-poly: #566B13; 273 | --ele-line: #70ab00; 274 | } 275 | 276 | /* STEELBLUE THEME //////////////////////////////////////////////// */ 277 | .steelblue-theme { 278 | --ele-axis: #0D1821; 279 | --ele-area: #4682B4; 280 | --ele-brush: rgba(23, 74, 117, 0.4); 281 | --ele-line: #174A75; 282 | } 283 | 284 | /* PURPLE THEME /////////////////////////////////////////////////// */ 285 | .purple-theme { 286 | --ele-bg: rgba(115, 44, 123, 0.2); 287 | --ele-area: #732C7B; 288 | --ele-brush: rgba(74, 14, 80, 0.4); 289 | --ele-line: #732c7b; 290 | } 291 | 292 | /* YELLOW THEME /////////////////////////////////////////////////// */ 293 | .yellow-theme { 294 | --ele-area: #FF0; 295 | } 296 | 297 | /* RED THEME ////////////////////////////////////////////////////// */ 298 | .red-theme { 299 | --ele-area: #F00; 300 | } 301 | 302 | /* MAGENTA THEME ////////////////////////////////////////////////// */ 303 | .magenta-theme { 304 | --ele-bg: rgba(255, 255, 255, 0.47); 305 | --ele-area: #FF005E; 306 | } 307 | 308 | /* LIGHTBLUE THEME //////////////////////////////////////////////// */ 309 | .lightblue-theme { 310 | --ele-area: #3366CC; 311 | --ele-alpha: 0.45; 312 | --ele-stroke: #4682B4; 313 | --ele-circle: #fff; 314 | --ele-line: #000; 315 | } 316 | 317 | .elevation-detached.lightblue-theme .area { 318 | stroke: #3366CC; 319 | } 320 | 321 | /* leaflet-distance-markers */ 322 | .dist-marker { 323 | font-size: 0.5rem; 324 | border: 1px solid #777; 325 | border-radius: 10px; 326 | text-align: center; 327 | color: #000; 328 | background: #fff; 329 | } 330 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2019, GPL-3.0+ Project, Raruto 3 | * 4 | * This file is free software: you may copy, redistribute and/or modify it 5 | * under the terms of the GNU General Public License as published by the 6 | * Free Software Foundation, either version 2 of the License, or (at your 7 | * option) any later version. 8 | * 9 | * This file is distributed in the hope that it will be useful, but 10 | * WITHOUT ANY WARRANTY; without even the implied warranty of 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12 | * General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU General Public License 15 | * along with this program. If not, see . 16 | * 17 | * This file incorporates work covered by the following copyright and 18 | * permission notice: 19 | * 20 | * Copyright (c) 2013-2016, MIT License, Felix “MrMufflon” Bache 21 | * 22 | * Permission to use, copy, modify, and/or distribute this software 23 | * for any purpose with or without fee is hereby granted, provided 24 | * that the above copyright notice and this permission notice appear 25 | * in all copies. 26 | * 27 | * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL 28 | * WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED 29 | * WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE 30 | * AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR 31 | * CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 32 | * OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, 33 | * NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN 34 | * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 35 | */ 36 | 37 | import * as _ from './utils'; 38 | import { Elevation } from './control'; 39 | 40 | Elevation.Utils = _; 41 | 42 | L.control.elevation = (options) => new Elevation(options); 43 | -------------------------------------------------------------------------------- /src/options.js: -------------------------------------------------------------------------------- 1 | import * as _ from './utils'; 2 | 3 | export var Options = { 4 | autofitBounds: true, 5 | autohide: false, 6 | autohideMarker: true, 7 | almostover: true, 8 | altitude: true, 9 | closeBtn: true, 10 | collapsed: false, 11 | detached: true, 12 | distance: true, 13 | distanceMarkers: { lazy: true, distance: true, direction: true }, 14 | dragging: !L.Browser.mobile, 15 | downloadLink: 'link', 16 | elevationDiv: "#elevation-div", 17 | edgeScale: { bar: true, icon: false, coords: false }, 18 | followMarker: true, 19 | imperial: false, 20 | legend: true, 21 | handlers: ["Distance", "Time", "Altitude", "Slope", "Speed", "Acceleration"], 22 | hotline: 'elevation', 23 | marker: 'elevation-line', 24 | markerIcon: L.divIcon({ 25 | className: 'elevation-position-marker', 26 | html: '', 27 | iconSize: [32, 32], 28 | iconAnchor: [16, 16], 29 | }), 30 | position: "topright", 31 | polyline: { 32 | className: 'elevation-polyline', 33 | color: '#000', 34 | opacity: 0.75, 35 | weight: 5, 36 | lineCap: 'round' 37 | }, 38 | polylineSegments: { 39 | className: 'elevation-polyline-segments', 40 | color: '#F00', 41 | interactive: false, 42 | }, 43 | preferCanvas: false, 44 | reverseCoords: false, 45 | ruler: true, 46 | theme: "lightblue-theme", 47 | summary: 'inline', 48 | slope: false, 49 | speed: false, 50 | time: true, 51 | timeFactor: 3600, 52 | timestamps: false, 53 | trkStart: { className: 'start-marker', radius: 6, weight: 2, color: '#fff', fillColor: '#00d800', fillOpacity: 1, interactive: false }, 54 | trkEnd: { className: 'end-marker', radius: 6, weight: 2, color: '#fff', fillColor: '#ff0606', fillOpacity: 1, interactive: false }, 55 | waypoints: true, 56 | wptIcons: { 57 | '': L.divIcon({ 58 | className: 'elevation-waypoint-marker', 59 | html: '', 60 | iconSize: [30, 30], 61 | iconAnchor: [8, 30], 62 | }), 63 | }, 64 | wptLabels: true, 65 | xAttr: "dist", 66 | xLabel: "km", 67 | yAttr: "z", 68 | yLabel: "m", 69 | zFollow: false, 70 | zooming: !L.Browser.Mobile, 71 | 72 | // Quite uncommon and undocumented options 73 | margins: { top: 30, right: 30, bottom: 30, left: 40 }, 74 | height: (screen.height * 0.3) || 200, 75 | width: (screen.width * 0.6) || 600, 76 | xTicks: undefined, 77 | yTicks: undefined, 78 | 79 | decimalsX: 2, 80 | decimalsY: 0, 81 | forceAxisBounds: false, 82 | interpolation: "curveLinear", 83 | yAxisMax: undefined, 84 | yAxisMin: undefined, 85 | 86 | // Prevent CORS issues for relative locations (dynamic import) 87 | srcFolder: ((document.currentScript && document.currentScript.src) || (import.meta && import.meta.url)).split("/").slice(0,-1).join("/") + '/', 88 | }; 89 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * TODO: exget computed styles of theese values from actual "CSS vars" 3 | **/ 4 | export const Colors = { 5 | 'lightblue': { area: '#3366CC', alpha: 0.45, stroke: '#3366CC' }, 6 | 'magenta' : { area: '#FF005E' }, 7 | 'yellow' : { area: '#FF0' }, 8 | 'purple' : { area: '#732C7B' }, 9 | 'steelblue': { area: '#4682B4' }, 10 | 'red' : { area: '#F00' }, 11 | 'lime' : { area: '#9CC222', line: '#566B13' } 12 | }; 13 | 14 | const SEC = 1000; 15 | const MIN = SEC * 60; 16 | const HOUR = MIN * 60; 17 | const DAY = HOUR * 24; 18 | 19 | export function resolveURL(src, baseUrl) { 20 | return (new URL(src, (src.startsWith('../') || src.startsWith('./')) ? baseUrl : undefined)).toString() 21 | }; 22 | 23 | /** 24 | * Convert a time (millis) to a human readable duration string (%Dd %H:%M'%S") 25 | */ 26 | export function formatTime(t) { 27 | let d = Math.floor(t / DAY); 28 | let h = Math.floor( (t - d * DAY) / HOUR); 29 | let m = Math.floor( (t - d * DAY - h * HOUR) / MIN); 30 | let s = Math.round( (t - d * DAY - h * HOUR - m * MIN) / SEC); 31 | if ( s === 60 ) { m++; s = 0; } 32 | if ( m === 60 ) { h++; m = 0; } 33 | if ( h === 24 ) { d++; h = 0; } 34 | return (d ? d + "d " : '') + h.toString().padStart(2, 0) + ':' + m.toString().padStart(2, 0) + "'" + s.toString().padStart(2, 0) + '"'; 35 | } 36 | 37 | /** 38 | * Convert a time (millis) to human readable date string (dd-mm-yyyy hh:mm:ss) 39 | */ 40 | export function formatDate(format) { 41 | if (!format) { 42 | return (time) => (new Date(time)).toLocaleString().replaceAll('/', '-').replaceAll(',', ' '); 43 | } else if (format == 'time') { 44 | return (time) => (new Date(time)).toLocaleTimeString(); 45 | } else if (format == 'date') { 46 | return (time) => (new Date(time)).toLocaleDateString(); 47 | } 48 | return (time) => format(time); 49 | } 50 | 51 | /** 52 | * Generate download data event. 53 | */ 54 | export function saveFile(dataURI, fileName) { 55 | let a = create('a', '', { href: dataURI, target: '_new', download: fileName || "", style: "display:none;" }); 56 | let b = document.body; 57 | b.appendChild(a); 58 | a.click(); 59 | b.removeChild(a); 60 | } 61 | 62 | 63 | /** 64 | * Convert SVG Path into Path2D and then update canvas 65 | */ 66 | export function drawCanvas(ctx, path) { 67 | path.classed('canvas-path', true); 68 | 69 | ctx.beginPath(); 70 | ctx.moveTo(0, 0); 71 | let p = new Path2D(path.attr('d')); 72 | 73 | ctx.strokeStyle = path.__strokeStyle || path.attr('stroke'); 74 | ctx.fillStyle = path.__fillStyle || path.attr('fill'); 75 | ctx.lineWidth = 1.25; 76 | ctx.globalCompositeOperation = 'source-over'; 77 | 78 | // stroke opacity 79 | ctx.globalAlpha = path.attr('stroke-opacity') || 0.3; 80 | ctx.stroke(p); 81 | 82 | // fill opacity 83 | ctx.globalAlpha = path.attr('fill-opacity') || 0.45; 84 | ctx.fill(p); 85 | 86 | ctx.globalAlpha = 1; 87 | 88 | ctx.closePath(); 89 | } 90 | 91 | /** 92 | * Loop and extract GPX Extensions handled by "@tmcw/toGeoJSON" (eg. "coordinateProperties" > "times") 93 | */ 94 | export function coordPropsToMeta(coordProps, name, parser) { 95 | return coordProps && (({props, point, id, isMulti }) => { 96 | if (props) { 97 | for (const key of coordProps) { 98 | if (key in props) { 99 | point.meta[name] = (parser || parseNumeric).call(this, (isMulti ? props[key][isMulti] : props[key]), id); 100 | break; 101 | } 102 | } 103 | } 104 | }); 105 | } 106 | 107 | /** 108 | * Extract numeric property (id) from GeoJSON object 109 | */ 110 | export const parseNumeric = (property, id) => parseInt((typeof property === 'object' ? property[id] : property)); 111 | 112 | /** 113 | * Extract datetime property (id) from GeoJSON object 114 | */ 115 | export const parseDate = (property, id) => new Date(Date.parse((typeof property === 'object' ? property[id] : property))); 116 | 117 | /** 118 | * A little bit shorter than L.DomUtil 119 | */ 120 | export const addClass = (n, str) => n && str.split(" ").every(s => s && L.DomUtil.addClass(n, s)); 121 | export const removeClass = (n, str) => n && str.split(" ").every(s => s && L.DomUtil.removeClass(n, s)); 122 | export const toggleClass = (n, str, cond) => (cond ? addClass : removeClass)(n, str); 123 | export const replaceClass = (n, rem, add) => (rem && removeClass(n, rem)) || (add && addClass(n, add)); 124 | export const style = (n, k, v) => (typeof v === "undefined" && L.DomUtil.getStyle(n, k)) || n.style.setProperty(k, v); 125 | export const toggleStyle = (n, k, v, cond) => style(n, k, cond ? v : ''); 126 | export const setAttributes = (n, attrs) => { for (let k in attrs) { n.setAttribute(k, attrs[k]); } }; 127 | export const toggleEvent = (el, e, fn, cond) => el[cond ? 'on' : 'off'](e, fn); 128 | export const create = (tag, str, attrs, n) => { let elem = L.DomUtil.create(tag, str || ""); if (attrs) setAttributes(elem, attrs); if (n) append(n, elem); return elem; }; 129 | export const append = (n, c) => n.appendChild(c); 130 | export const insert = (n, c, pos) => n.insertAdjacentElement(pos, c); 131 | export const select = (str, n) => (n || document).querySelector(str); 132 | export const each = (obj, fn) => { for (let i in obj) fn(obj[i], i); }; 133 | export const randomId = () => Math.random().toString(36).substr(2, 9); 134 | 135 | /** 136 | * TODO: use generators instead? (ie. "yield") 137 | */ 138 | export const iMax = (iVal, max = -Infinity) => (iVal > max ? iVal : max); 139 | export const iMin = (iVal, min = +Infinity) => (iVal < min ? iVal : min); 140 | export const iAvg = (iVal, avg = 0, idx = 1) => (iVal + avg * (idx - 1)) / idx; 141 | export const iSum = (iVal, sum = 0) => iVal + sum; 142 | 143 | /** 144 | * Alias for some leaflet core functions 145 | */ 146 | export const { on, off } = L.DomEvent; 147 | export const { throttle, wrapNum } = L.Util; 148 | export const { hasClass } = L.DomUtil; 149 | 150 | /** 151 | * Limit floating point precision 152 | */ 153 | export const round = L.Util.formatNum; 154 | 155 | /** 156 | * Limit a number between min / max values 157 | */ 158 | export const clamp = (val, range) => range ? (val < range[0] ? range[0] : val > range[1] ? range[1] : val) : val; 159 | 160 | /** 161 | * Limit a delta difference between two values 162 | */ 163 | export const wrapDelta = (curr, prev, deltaMax) => Math.abs(curr - prev) > deltaMax ? prev + deltaMax * Math.sign(curr - prev) : curr; 164 | 165 | /** 166 | * A deep copy implementation that takes care of correct prototype chain and cycles, references 167 | * 168 | * @see https://web.dev/structured-clone/#features-and-limitations 169 | */ 170 | export function cloneDeep(o, skipProps = [], cache = []) { 171 | switch(!o || typeof o) { 172 | case 'object': 173 | const hit = cache.filter(c => o === c.original)[0]; 174 | if (hit) return hit.copy; // handle circular structures 175 | const copy = Array.isArray(o) ? [] : Object.create(Object.getPrototypeOf(o)); 176 | cache.push({ original: o, copy }); 177 | Object 178 | .getOwnPropertyNames(o) 179 | .forEach(function (prop) { 180 | const propdesc = Object.getOwnPropertyDescriptor(o, prop); 181 | Object.defineProperty( 182 | copy, 183 | prop, 184 | propdesc.get || propdesc.set 185 | ? propdesc // just copy accessor properties 186 | : { // deep copy data properties 187 | writable: propdesc.writable, 188 | configurable: propdesc.configurable, 189 | enumerable: propdesc.enumerable, 190 | value: skipProps.includes(prop) ? propdesc.value : cloneDeep(propdesc.value, skipProps, cache), 191 | } 192 | ); 193 | }); 194 | return copy; 195 | case 'function': 196 | case 'symbol': 197 | console.warn('cloneDeep: ' + typeof o + 's not fully supported:', o); 198 | case true: 199 | // null, undefined or falsy primitive 200 | default: 201 | return o; 202 | } 203 | } -------------------------------------------------------------------------------- /src/utils.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | * src/utils.js 3 | */ 4 | 5 | import { suite } from 'uvu'; 6 | import * as assert from 'uvu/assert'; 7 | import '../test/setup/jsdom.js' 8 | import { iAvg, iMin, iMax, iSum } from "../src/utils.js"; 9 | 10 | const toFixed = (n) => +n.toFixed(2); 11 | 12 | const test = suite('src/utils.js'); 13 | 14 | test('iAvg()', () => { 15 | let avg; 16 | avg = iAvg(100, undefined, 1); assert.is(toFixed(avg), 100); // average for [100] is 100 17 | avg = iAvg(100, avg, 2); assert.is(toFixed(avg), 100); // average for [100, 100] is 100 18 | avg = iAvg(200, avg, 3); assert.is(toFixed(avg), 133.33); // average for [100, 100, 200] is 133.33 19 | avg = iAvg(200, avg, 4); assert.is(toFixed(avg), 150); // average for [100, 100, 200, 200] is 150 20 | avg = iAvg(NaN, avg, 5); assert.ok(isNaN(avg)); // average for [100, 100, 200, 200, NaN] is NaN 21 | }); 22 | 23 | test('iMin()', () => { 24 | let min; 25 | min = iMin(100, undefined); assert.is(toFixed(min), 100); // min for [100] is 100 26 | min = iMin(NaN, min); assert.is(toFixed(min), 100); // min for [100, NaN] is 100 27 | min = iMin(0, min); assert.is(toFixed(min), 0); // min for [100, NaN, 0] is 100 28 | min = iMin(-200, min); assert.is(toFixed(min), -200); // min for [100, NaN, 0, -200] is -200 29 | min = iMin(200, min); assert.is(toFixed(min), -200); // min for [100, NaN, -100, -200, 200] is -200 30 | }); 31 | 32 | test('iMax()', () => { 33 | let max; 34 | max = iMax(100, undefined); assert.is(toFixed(max), 100); // max for [100] is 100 35 | max = iMax(NaN, max); assert.is(toFixed(max), 100); // max for [100, NaN] is 100 36 | max = iMax(0, max); assert.is(toFixed(max), 100); // max for [100, NaN, 0] is 100 37 | max = iMax(-200, max); assert.is(toFixed(max), 100); // max for [100, NaN, 0, -200] is 100 38 | max = iMax(200, max); assert.is(toFixed(max), 200); // max for [100, NaN, -100, -200, 200] is 200 39 | }); 40 | 41 | test('iSum()', () => { 42 | let sum; 43 | sum = iSum(10.25, undefined); assert.is(toFixed(sum), 10.25); // sum for [10.25] is 10.25 44 | sum = iSum(0, sum); assert.is(toFixed(sum), 10.25); // sum for [10.25, 0] is 10.25 45 | sum = iSum(-0.25, sum); assert.is(toFixed(sum), 10); // sum for [10.25, 0, -0.25] is 10 46 | sum = iSum(-10, sum); assert.is(toFixed(sum), 0); // sum for [10.25, 0, -0.25, -10] is 0 47 | sum = iSum(NaN, sum); assert.ok(isNaN(sum)); // sum for [10.25, 0, -0.25, -10, NaN] is NaN 48 | }); 49 | 50 | test.run(); -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | leaflet-elevation.js 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 101 | 102 |
103 | 104 | 284 | 285 | 319 | 320 | View on Github 321 | 322 | 323 | 324 | 325 | -------------------------------------------------------------------------------- /test/setup/http_server.js: -------------------------------------------------------------------------------- 1 | import { suite as uvu_suite } from 'uvu'; 2 | import { exec } from 'child_process'; 3 | import { chromium } from 'playwright'; 4 | 5 | process.on('exit', async () => { 6 | await globalThis.server.kill('SIGTERM'); 7 | }) 8 | 9 | /** 10 | * Start HTTP server 11 | */ 12 | export async function setup(ctx) { 13 | if (!globalThis.server) { 14 | await new Promise((resolve) => { 15 | globalThis.server = exec('http-server'); 16 | globalThis.server.stdout.on('data', (msg) => { 17 | // console.log(msg); 18 | // if (msg.toString().match(/Starting up/)) { 19 | if (msg.toString().indexOf('Hit CTRL-C to stop the server')) { 20 | resolve(); 21 | // setTimeout(resolve, 1500); 22 | } 23 | }); 24 | }); 25 | } 26 | ctx.localhost = 'http://localhost:8080'; 27 | ctx.browser = await chromium.launch(); 28 | ctx.context = await ctx.browser.newContext(); 29 | ctx.context.route(/.html$/, await mock_cdn_urls); 30 | ctx.page = await ctx.context.newPage(); 31 | } 32 | 33 | /** 34 | * Stop HTTP server 35 | */ 36 | export async function reset(ctx) { 37 | await ctx.context.close(); 38 | await ctx.browser.close(); 39 | } 40 | 41 | /** 42 | * Sample wrapper for uvu `suite` 43 | * 44 | * @example start a new test session at: http://localhost:8080/examples/leaflet-elevation.html 45 | * 46 | * ```js 47 | * const test = suite('examples/leaflet-elevation.html'); 48 | * 49 | * test('eledata_loaded', async ({ page }) => { 50 | * const gpx = await page.evaluate(() => new Promise(resolve => { 51 | * controlElevation.on('eledata_loaded', (gpx) => resolve(gpx)); 52 | * })); 53 | * assert.is(gpx.name, 'via-emilia.gpx'); 54 | * }); 55 | * ``` 56 | * 57 | * @see https://github.com/lukeed/uvu 58 | */ 59 | export function suite() { 60 | const test = uvu_suite(...arguments); 61 | test.before(setup); 62 | test.after(reset); 63 | test.before.each(async ({ localhost, page }) => { 64 | page.on('console', msg => console.log(msg.text())); 65 | page.on('requestfailed', request => { console.log(request.failure().errorText, request.url()); }); 66 | page.on('pageerror', exception => { console.log(exception); }); 67 | await page.goto((new URL(arguments[0], localhost)).toString()); 68 | await page.waitForLoadState('domcontentloaded'); 69 | }); 70 | // augment uvu `test` function with a third parameter `timeout` 71 | return new Proxy(test, { 72 | apply: (object, _, argsList) => { 73 | return object(argsList[0], timeout(argsList[1], argsList[2])); 74 | } 75 | }); 76 | } 77 | 78 | /** 79 | * Sets maximum execution time for a function 80 | * 81 | * @see https://github.com/lukeed/uvu/issues/33#issuecomment-879870292 82 | */ 83 | function timeout(handler, ms = 10000) { 84 | return (ctx) => { 85 | let timer 86 | return Promise.race([ 87 | handler(ctx), 88 | new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('[TIMEOUT] Maximum execution time exceeded: ' + ms + 'ms')), ms) }) 89 | ]).finally(() => { clearTimeout(timer) }) 90 | } 91 | } 92 | 93 | /** 94 | * Replace CDN URLs with locally developed files within Network response. 95 | * 96 | * @requires playwright 97 | */ 98 | async function mock_cdn_urls(route) { 99 | const response = await route.fetch(); 100 | let body = await response.text(); 101 | body = body.replace(new RegExp('https://unpkg.com/@raruto/leaflet-elevation@(.*?)/', 'g'), '../'); 102 | body = body.replace(new RegExp('@raruto/leaflet-elevation@(.*?)/', 'g'), '../'); 103 | route.fulfill({ response, body, headers: response.headers() }); 104 | } -------------------------------------------------------------------------------- /test/setup/jsdom.js: -------------------------------------------------------------------------------- 1 | import { JSDOM } from 'jsdom'; 2 | 3 | const { window } = new JSDOM('
'); 4 | 5 | global.window = window; 6 | global.document = window.document; 7 | global.navigator = window.navigator; 8 | global.getComputedStyle = window.getComputedStyle; 9 | global.requestAnimationFrame = null; 10 | 11 | global.L = { 12 | DomEvent: { 13 | on: () => {}, 14 | off: () => {} 15 | }, 16 | Util: { 17 | throttle: () => {}, 18 | wrapNum: () => {}, 19 | formatNum: () => {} 20 | }, 21 | DomUtil: { 22 | hasClass: () => {} 23 | } 24 | }; --------------------------------------------------------------------------------