From 1fe1d13c600b7dfe421b3b99b833b7d5d1232724 Mon Sep 17 00:00:00 2001 From: Dennis Fink Date: Thu, 23 Jul 2026 00:18:21 +0200 Subject: Add GPX trail maps to blog posts This adds a trail flow block so posts can include attached GPX files as interactive maps with route display, elevation charts, activity summaries, and start/finish markers. The trail assets are loaded only when needed, keeping the shared templates clean while vendoring the Leaflet dependencies required for offline, self-hosted map rendering. --- assets/static/css/trail.css | 334 + assets/static/images/trail/parking.svg | 20 + assets/static/js/trail.js | 482 + .../vendor/leaflet-elevation/images/checkered.png | Bin 0 -> 8632 bytes .../leaflet-elevation/images/elevation-locate.png | Bin 0 -> 274 bytes .../leaflet-elevation/images/elevation-poi.png | Bin 0 -> 640 bytes .../images/elevation-position.png | Bin 0 -> 542 bytes .../images/elevation-position.svg | 1 + .../leaflet-elevation/images/elevation-pushpin.png | Bin 0 -> 3068 bytes .../leaflet-elevation/images/elevation-pushpin.svg | 1 + .../vendor/leaflet-elevation/images/elevation.svg | 1 + .../vendor/leaflet-elevation/libs/fullpage.css | 13 + .../libs/leaflet-distance-marker.css | 8 + .../libs/leaflet-distance-marker.js | 314 + .../libs/leaflet-distance-marker.min.css | 1 + .../libs/leaflet-distance-marker.min.js | 1 + .../leaflet-elevation/libs/leaflet-edgescale.js | 636 + .../libs/leaflet-edgescale.min.js | 1 + .../leaflet-elevation/libs/leaflet-gpxgroup.js | 347 + .../leaflet-elevation/libs/leaflet-gpxgroup.min.js | 1 + .../leaflet-elevation/libs/leaflet-hotline.js | 457 + .../leaflet-elevation/libs/leaflet-hotline.min.js | 1 + .../leaflet-elevation/libs/leaflet-ruler.css | 42 + .../vendor/leaflet-elevation/libs/leaflet-ruler.js | 183 + .../leaflet-elevation/libs/leaflet-ruler.min.css | 1 + .../leaflet-elevation/libs/leaflet-ruler.min.js | 1 + .../leaflet-elevation/src/components/chart.js | 783 + .../vendor/leaflet-elevation/src/components/d3.js | 649 + .../leaflet-elevation/src/components/marker.js | 96 + .../leaflet-elevation/src/components/summary.js | 43 + .../static/vendor/leaflet-elevation/src/control.js | 1335 ++ .../leaflet-elevation/src/handlers/acceleration.js | 64 + .../leaflet-elevation/src/handlers/altitude.js | 78 + .../leaflet-elevation/src/handlers/cadence.js | 54 + .../leaflet-elevation/src/handlers/distance.js | 46 + .../vendor/leaflet-elevation/src/handlers/heart.js | 53 + .../leaflet-elevation/src/handlers/labels.js | 45 + .../src/handlers/lineargradient.js | 122 + .../vendor/leaflet-elevation/src/handlers/pace.js | 65 + .../leaflet-elevation/src/handlers/runner.js | 109 + .../vendor/leaflet-elevation/src/handlers/slope.js | 79 + .../vendor/leaflet-elevation/src/handlers/speed.js | 66 + .../leaflet-elevation/src/handlers/temperature.js | 62 + .../vendor/leaflet-elevation/src/handlers/time.js | 103 + .../static/vendor/leaflet-elevation/src/index.css | 329 + .../static/vendor/leaflet-elevation/src/index.js | 42 + .../static/vendor/leaflet-elevation/src/options.js | 88 + .../static/vendor/leaflet-elevation/src/utils.js | 203 + .../vendor/leaflet-elevation/src/utils.spec.js | 50 + .../leaflet-fullscreen/Control.FullScreen.css | 30 + .../leaflet-fullscreen/Control.FullScreen.umd.js | 299 + assets/static/vendor/leaflet-i18n/leaflet-i18n.js | 41 + .../vendor/leaflet-providers/leaflet-providers.js | 1121 ++ assets/static/vendor/leaflet/images/layers-2x.png | Bin 0 -> 1259 bytes assets/static/vendor/leaflet/images/layers.png | Bin 0 -> 696 bytes .../vendor/leaflet/images/marker-icon-2x.png | Bin 0 -> 2464 bytes .../static/vendor/leaflet/images/marker-icon.png | Bin 0 -> 1466 bytes .../static/vendor/leaflet/images/marker-shadow.png | Bin 0 -> 618 bytes assets/static/vendor/leaflet/leaflet-src.esm.js | 14419 ++++++++++++++++++ .../static/vendor/leaflet/leaflet-src.esm.js.map | 1 + assets/static/vendor/leaflet/leaflet-src.js | 14512 +++++++++++++++++++ assets/static/vendor/leaflet/leaflet-src.js.map | 1 + assets/static/vendor/leaflet/leaflet.css | 661 + assets/static/vendor/leaflet/leaflet.js | 6 + assets/static/vendor/leaflet/leaflet.js.map | 1 + 65 files changed, 38502 insertions(+) create mode 100644 assets/static/css/trail.css create mode 100644 assets/static/images/trail/parking.svg create mode 100644 assets/static/js/trail.js create mode 100644 assets/static/vendor/leaflet-elevation/images/checkered.png create mode 100644 assets/static/vendor/leaflet-elevation/images/elevation-locate.png create mode 100644 assets/static/vendor/leaflet-elevation/images/elevation-poi.png create mode 100644 assets/static/vendor/leaflet-elevation/images/elevation-position.png create mode 100644 assets/static/vendor/leaflet-elevation/images/elevation-position.svg create mode 100644 assets/static/vendor/leaflet-elevation/images/elevation-pushpin.png create mode 100644 assets/static/vendor/leaflet-elevation/images/elevation-pushpin.svg create mode 100644 assets/static/vendor/leaflet-elevation/images/elevation.svg create mode 100644 assets/static/vendor/leaflet-elevation/libs/fullpage.css create mode 100644 assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.css create mode 100644 assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.js create mode 100644 assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.min.css create mode 100644 assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.min.js create mode 100644 assets/static/vendor/leaflet-elevation/libs/leaflet-edgescale.js create mode 100644 assets/static/vendor/leaflet-elevation/libs/leaflet-edgescale.min.js create mode 100644 assets/static/vendor/leaflet-elevation/libs/leaflet-gpxgroup.js create mode 100644 assets/static/vendor/leaflet-elevation/libs/leaflet-gpxgroup.min.js create mode 100644 assets/static/vendor/leaflet-elevation/libs/leaflet-hotline.js create mode 100644 assets/static/vendor/leaflet-elevation/libs/leaflet-hotline.min.js create mode 100644 assets/static/vendor/leaflet-elevation/libs/leaflet-ruler.css create mode 100644 assets/static/vendor/leaflet-elevation/libs/leaflet-ruler.js create mode 100644 assets/static/vendor/leaflet-elevation/libs/leaflet-ruler.min.css create mode 100644 assets/static/vendor/leaflet-elevation/libs/leaflet-ruler.min.js create mode 100644 assets/static/vendor/leaflet-elevation/src/components/chart.js create mode 100644 assets/static/vendor/leaflet-elevation/src/components/d3.js create mode 100644 assets/static/vendor/leaflet-elevation/src/components/marker.js create mode 100644 assets/static/vendor/leaflet-elevation/src/components/summary.js create mode 100644 assets/static/vendor/leaflet-elevation/src/control.js create mode 100644 assets/static/vendor/leaflet-elevation/src/handlers/acceleration.js create mode 100644 assets/static/vendor/leaflet-elevation/src/handlers/altitude.js create mode 100644 assets/static/vendor/leaflet-elevation/src/handlers/cadence.js create mode 100644 assets/static/vendor/leaflet-elevation/src/handlers/distance.js create mode 100644 assets/static/vendor/leaflet-elevation/src/handlers/heart.js create mode 100644 assets/static/vendor/leaflet-elevation/src/handlers/labels.js create mode 100644 assets/static/vendor/leaflet-elevation/src/handlers/lineargradient.js create mode 100644 assets/static/vendor/leaflet-elevation/src/handlers/pace.js create mode 100644 assets/static/vendor/leaflet-elevation/src/handlers/runner.js create mode 100644 assets/static/vendor/leaflet-elevation/src/handlers/slope.js create mode 100644 assets/static/vendor/leaflet-elevation/src/handlers/speed.js create mode 100644 assets/static/vendor/leaflet-elevation/src/handlers/temperature.js create mode 100644 assets/static/vendor/leaflet-elevation/src/handlers/time.js create mode 100644 assets/static/vendor/leaflet-elevation/src/index.css create mode 100644 assets/static/vendor/leaflet-elevation/src/index.js create mode 100644 assets/static/vendor/leaflet-elevation/src/options.js create mode 100644 assets/static/vendor/leaflet-elevation/src/utils.js create mode 100644 assets/static/vendor/leaflet-elevation/src/utils.spec.js create mode 100644 assets/static/vendor/leaflet-fullscreen/Control.FullScreen.css create mode 100644 assets/static/vendor/leaflet-fullscreen/Control.FullScreen.umd.js create mode 100644 assets/static/vendor/leaflet-i18n/leaflet-i18n.js create mode 100644 assets/static/vendor/leaflet-providers/leaflet-providers.js create mode 100644 assets/static/vendor/leaflet/images/layers-2x.png create mode 100644 assets/static/vendor/leaflet/images/layers.png create mode 100644 assets/static/vendor/leaflet/images/marker-icon-2x.png create mode 100644 assets/static/vendor/leaflet/images/marker-icon.png create mode 100644 assets/static/vendor/leaflet/images/marker-shadow.png create mode 100644 assets/static/vendor/leaflet/leaflet-src.esm.js create mode 100644 assets/static/vendor/leaflet/leaflet-src.esm.js.map create mode 100644 assets/static/vendor/leaflet/leaflet-src.js create mode 100644 assets/static/vendor/leaflet/leaflet-src.js.map create mode 100644 assets/static/vendor/leaflet/leaflet.css create mode 100644 assets/static/vendor/leaflet/leaflet.js create mode 100644 assets/static/vendor/leaflet/leaflet.js.map (limited to 'assets') diff --git a/assets/static/css/trail.css b/assets/static/css/trail.css new file mode 100644 index 0000000..5637668 --- /dev/null +++ b/assets/static/css/trail.css @@ -0,0 +1,334 @@ +@charset "UTF-8"; + +:root { + --trail-map-height: 30rem; + --trail-breakout: var(--breakout-xs); + + --trail-route-color: var(--dim-red); + --trail-route-width: 4px; + --trail-route-opacity: 1; + + --trail-chart-color: var(--dim-red); + --trail-chart-opacity: 0.25; + + --trail-start-color: var(--green); + --trail-finish-color: var(--red); + --trail-loop-color: var(--blue); +} + +.trail { + box-sizing: border-box; + width: calc(100% + 2 * var(--trail-breakout)); + max-width: 100vw; + min-width: 0; + margin: 2.5rem 0; + transform: translateX(calc(-1 * var(--trail-breakout))); + + > [data-trail-map] { + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + overflow: hidden; + border: 1px solid var(--foreground); + border-radius: 0.5rem; + background: var(--background); + } + + > [data-trail-map] { + position: relative; + height: var(--trail-map-height); + + img.leaflet-tile { + max-width: none !important; + max-height: none !important; + } + + .leaflet-marker-icon:focus-visible, + .leaflet-interactive:focus-visible, + .leaflet-control a:focus-visible { + outline: 3px solid var(--yellow); + outline-offset: 2px; + } + } + + > details { + margin-top: 1rem; + border: 1px solid var(--foreground); + border-radius: 0.5rem; + + > summary { + padding: 0.75rem 1rem; + cursor: pointer; + color: var(--green); + font-weight: bold; + } + + > div { + display: grid; + gap: 1rem; + padding: 0 1rem 1rem; + } + + [data-trail-chart] { + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + overflow: hidden; + background: var(--background); + + .elevation-control { + box-sizing: border-box; + width: 100% !important; + max-width: none !important; + height: 100%; + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: "Libre Baskerville", Georgia, serif; + + text { + fill: var(--foreground) !important; + font: inherit; + font-size: 1rem; + font-weight: normal; + text-shadow: none !important; + paint-order: normal; + stroke: none !important; + } + + .background { + background-color: var(--background); + } + } + + svg { + display: block; + max-width: none; + } + + text { + fill: var(--foreground); + } + + .area { + fill: var(--trail-chart-color); + fill-opacity: var(--trail-chart-opacity); + stroke: var(--trail-chart-color); + } + + .axis { + path, + line { + stroke: var(--dim-foreground); + } + + text { + font-size: 0.85rem; + } + } + + .grid line, + .mouse-focus-line { + stroke: var(--dim-foreground); + } + + .elevation-summary { + display: none; + } + + .mouse-focus-label-text { + fill: var(--foreground) !important; + font: inherit !important; + font-size: 1rem; + font-weight: normal !important; + text-shadow: none !important; + stroke: none !important; + } + + .mouse-focus-label-rect { + fill: var(--background); + fill-opacity: 0.95; + stroke: var(--foreground); + stroke-width: 1px; + } + + .mouse-focus-label-time, + .mouse-focus-label-slope, + .mouse-focus-label-speed { + display: none; + } + } + + [data-trail-summary] { + overflow: hidden; + + table { + width: 100%; + margin: 0.5rem 0; + border-collapse: collapse; + font-size: 1rem; + + tr { + &:not(:last-of-type) { + border-bottom: 1px solid var(--foreground); + + th, + td { + padding-bottom: 0.5rem; + } + } + &:not(:first-of-type) { + th, + td { + padding-top: 0.5rem; + } + } + } + + th { + padding-right: 0.5rem; + color: var(--blue); + text-align: left; + } + + td { + padding-right: 0.5rem; + text-align: right; + } + } + + @media (max-width: 1300px) { + table, + tbody, + tr { + display: contents; + } + + table { + display: grid; + grid-template-columns: + minmax(0, 1fr) + max-content; + margin: 0; + } + + th, + td { + box-sizing: border-box; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--foreground); + } + + th { + text-align: left; + + &:first-child { + padding-left: 0.75rem; + } + } + + td { + text-align: right; + } + + tbody > tr:last-child > :last-child, + tbody > tr:last-child > :nth-last-child(2) { + border-bottom: 0; + } + } + } + + > div > p { + max-inline-size: none; + margin: 0; + text-align: center; + } + + @media (max-width: 700px) { + [data-trail-chart] { + height: 18rem; + } + } + + a { + font-size: 1.2rem; + } + } + + /* + * Classes below are required by Leaflet or generated + * dynamically for custom Leaflet markers. + */ + + .trail-marker { + border: 0; + background: transparent; + + .trail-marker-symbol { + box-sizing: border-box; + display: grid; + place-items: center; + width: 1.75rem; + height: 1.75rem; + border: 2px solid var(--background); + border-radius: 50%; + color: var(--background); + box-shadow: 0 0 0 1px var(--foreground); + font-family: system-ui, sans-serif; + font-size: 0.8rem; + font-weight: 700; + line-height: 1; + } + + &.trail-marker-start { + .trail-marker-symbol { + background: var(--trail-start-color); + + &::before { + content: "S"; + } + } + } + + &.trail-marker-finish { + .trail-marker-symbol { + background: var(--trail-finish-color); + + &::before { + content: "F"; + } + } + } + + &.trail-marker-loop { + .trail-marker-symbol { + background: var(--trail-loop-color); + + &::before { + content: "↻"; + } + } + } + } + + .trail-marker-hidden { + display: none; + } + + .trail-theme.elevation-polyline { + stroke: var(--trail-route-color); + stroke-width: var(--trail-route-width); + stroke-opacity: var(--trail-route-opacity); + } + + .trail-parking-marker { + filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.4)); + } + + @media (max-width: 500px) { + width: 100%; + transform: none; + } +} diff --git a/assets/static/images/trail/parking.svg b/assets/static/images/trail/parking.svg new file mode 100644 index 0000000..0991710 --- /dev/null +++ b/assets/static/images/trail/parking.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/assets/static/js/trail.js b/assets/static/js/trail.js new file mode 100644 index 0000000..bca224d --- /dev/null +++ b/assets/static/js/trail.js @@ -0,0 +1,482 @@ +"use strict"; + +let trail_chart_number = 0; + +const create_map = (map_element) => { + const open_street_map_mapnik = L.tileLayer.provider("OpenStreetMap.Mapnik", { + referrerPolicy: "strict-origin-when-cross-origin", + }); + const open_street_map_de = L.tileLayer.provider("OpenStreetMap.DE", { + referrerPolicy: "strict-origin-when-cross-origin", + }); + const open_street_map_france = L.tileLayer.provider("OpenStreetMap.France", { + referrerPolicy: "strict-origin-when-cross-origin", + }); + const opnv_karte = L.tileLayer.provider("OPNVKarte", { + referrerPolicy: "strict-origin-when-cross-origin", + }); + const open_topo_map = L.tileLayer.provider("OpenTopoMap", { + referrerPolicy: "strict-origin-when-cross-origin", + }); + const cycl_osm = L.tileLayer.provider("CyclOSM", { + referrerPolicy: "strict-origin-when-cross-origin", + }); + const mtb_map = L.tileLayer.provider("MtbMap", { + referrerPolicy: "strict-origin-when-cross-origin", + }); + + const map = L.map(map_element, { + center: [49.75, 6.1], + zoom: 8, + zoomControl: true, + scrollWheelZoom: true, + keyboard: true, + layers: [open_street_map_mapnik], + }); + + L.control + .layers( + { + OpenStreetMap: open_street_map_mapnik, + OpenStreetMapDE: open_street_map_de, + OpenStreetMapFrance: open_street_map_france, + OPNVKarte: opnv_karte, + OpenTopoMap: open_topo_map, + CycleOSM: cycl_osm, + MtbMap: mtb_map, + }, + null, + { + collapsed: true, + position: "topright", + }, + ) + .addTo(map); + + L.control + .scale({ + imperial: false, + metric: true, + position: "bottomleft", + }) + .addTo(map); + + new L.Control.FullScreen({ + position: "topleft", + title: "View map fullscreen", + titleCancel: "Exit fullscreen", + }).addTo(map); + + return map; +}; + +const create_parking_icon = () => + L.icon({ + iconUrl: "/static/images/trail/parking.svg", + iconSize: [32, 32], + iconAnchor: [16, 32], + popupAnchor: [0, -32], + tooltipAnchor: [0, -28], + className: "trail-parking-marker", + }); + +const create_display_control = (map, chart_element) => { + if (!chart_element.id) { + trail_chart_number += 1; + chart_element.id = `trail-chart-${trail_chart_number}`; + } + + const parking_icon = create_parking_icon(); + + const display_control = L.control.elevation({ + theme: "trail-theme", + + detached: true, + elevationDiv: `#${chart_element.id}`, + + altitude: true, + distance: true, + time: "summary", + speed: "summary", + pace: "summary", + slope: "summary", + acceleration: false, + + summary: false, + downloadLink: false, + legend: true, + ruler: false, + + autofitBounds: true, + followMarker: false, + + marker: "position-marker", + distanceMarkers: false, + hotline: false, + + waypoints: "markers", + wptLabels: "markers", + + wptIcons: { + Parking: parking_icon, + parking: parking_icon, + "Parking Area": parking_icon, + "parking area": parking_icon, + "parking-area": parking_icon, + Car: parking_icon, + car: parking_icon, + }, + + trkStart: false, + trkEnd: false, + closeBtn: false, + }); + + display_control.addTo(map); + + return display_control; +}; + +const create_summary_control = () => { + const map_element = document.createElement("div"); + + map_element.hidden = true; + document.body.append(map_element); + + const map = L.map(map_element, { + center: [0, 0], + zoom: 1, + zoomControl: false, + attributionControl: false, + keyboard: false, + dragging: false, + scrollWheelZoom: false, + doubleClickZoom: false, + boxZoom: false, + touchZoom: false, + }); + + const summary_control = L.control.elevation({ + detached: false, + collapsed: true, + + altitude: "summary", + distance: "summary", + time: "summary", + speed: "summary", + pace: "summary", + slope: "summary", + acceleration: false, + + summary: false, + downloadLink: false, + legend: false, + ruler: false, + + autofitBounds: false, + followMarker: false, + + marker: false, + distanceMarkers: false, + hotline: false, + + waypoints: false, + wptLabels: false, + + trkStart: false, + trkEnd: false, + closeBtn: false, + }); + + summary_control.addTo(map); + + return { + control: summary_control, + destroy: () => { + map.remove(); + map_element.remove(); + }, + }; +}; + +function visit_leaflet_layer(layer, callback) { + if (!layer) { + return; + } + + if (typeof layer.eachLayer === "function") { + layer.eachLayer((child_layer) => { + visit_leaflet_layer(child_layer, callback); + }); + + return; + } + + callback(layer); +} + +const create_trail_icon = (type, label) => + L.divIcon({ + className: `trail-marker trail-marker-${type}`, + html: + '' + + `${label}`, + iconSize: [30, 30], + iconAnchor: [15, 15], + popupAnchor: [0, -16], + tooltipAnchor: [0, -16], + }); + +function add_trail_marker(map, position, type, label) { + const marker = L.marker(position, { + icon: create_trail_icon(type, label), + keyboard: true, + title: label, + alt: label, + }); + + marker.bindTooltip(label, { + direction: "top", + }); + + marker.addTo(map); +} + +const add_endpoint_markers = (map, loaded_layer) => { + const lat_lngs = []; + + visit_leaflet_layer(loaded_layer, (layer) => { + if (typeof layer.getLatLngs !== "function") { + return; + } + + lat_lngs.push( + ...layer + .getLatLngs() + .flat(Infinity) + .filter( + (item) => + item && + typeof item.lat === "number" && + typeof item.lng === "number", + ), + ); + }); + + if (lat_lngs.length < 2) { + console.warn( + "The loaded GPX did not contain enough track points for endpoint markers.", + ); + return; + } + + const start = lat_lngs[0]; + const finish = lat_lngs[lat_lngs.length - 1]; + + if (start.distanceTo(finish) <= 50) { + add_trail_marker(map, start, "loop", "Start and finish"); + return; + } + + add_trail_marker(map, start, "start", "Start"); + + add_trail_marker(map, finish, "finish", "Finish"); +}; + +const format_duration = (milliseconds) => { + if (!Number.isFinite(milliseconds) || milliseconds < 0) { + return "–"; + } + + const total_seconds = Math.floor(milliseconds / 1000); + + const days = Math.floor(total_seconds / 86400); + + const hours = Math.floor((total_seconds % 86400) / 3600); + + const minutes = Math.floor((total_seconds % 3600) / 60); + + const seconds = total_seconds % 60; + + const time = [hours, minutes, seconds] + .map((value) => String(value).padStart(2, "0")) + .join(":"); + + if (days === 0) { + return time; + } + + const day_label = days === 1 ? "day" : "days"; + + return `${days} ${day_label} ${time}`; +}; + +const format_pace = (time_milliseconds, distance_kilometres) => { + if ( + !Number.isFinite(time_milliseconds) || + !Number.isFinite(distance_kilometres) || + time_milliseconds < 0 || + distance_kilometres <= 0 + ) { + return "–"; + } + + const seconds_per_kilometre = Math.round( + time_milliseconds / 1000 / distance_kilometres, + ); + + const minutes = Math.floor(seconds_per_kilometre / 60); + + const seconds = seconds_per_kilometre % 60; + + return `${minutes}:${String(seconds).padStart(2, "0")} min/km`; +}; + +const initialize_trail_map = (trail) => { + const map_element = trail.querySelector("[data-trail-map]"); + + const chart_element = trail.querySelector("[data-trail-chart]"); + + const message_element = trail.querySelector("[data-trail-message]"); + + const trail_gpx_url = trail.dataset.trailGpxUrl; + + const activity_gpx_url = trail.dataset.activityGpxUrl; + + if (!map_element || !chart_element || (!trail_gpx_url && !activity_gpx_url)) { + console.error( + "Could not initialize trail map.", + new Error( + "The trail block requires a map, a chart and at least one GPX file.", + ), + ); + + if (map_element) { + map_element.hidden = true; + } + + if (chart_element) { + chart_element.hidden = true; + } + + if (message_element) { + message_element.hidden = false; + } + + return; + } + + const display_gpx_url = trail_gpx_url || activity_gpx_url; + + const summary_gpx_url = activity_gpx_url || trail_gpx_url; + + const summary_uses_separate_gpx = summary_gpx_url !== display_gpx_url; + + try { + const map = create_map(map_element); + + const display_control = create_display_control(map, chart_element); + + const set_summary_values = (track_info) => { + const set_value = (selector, value) => { + const element = trail.querySelector(selector); + + if (element) { + element.textContent = value; + } + }; + + set_value( + "[data-trail-distance]", + `${track_info.distance.toFixed(2)} km`, + ); + + set_value("[data-trail-time]", format_duration(track_info.time)); + + set_value( + "[data-trail-pace]", + format_pace(track_info.time, track_info.distance), + ); + + set_value( + "[data-trail-maximum-elevation]", + `${track_info.elevation_max.toFixed(0)} m`, + ); + + set_value( + "[data-trail-minimum-elevation]", + `${track_info.elevation_min.toFixed(0)} m`, + ); + + set_value( + "[data-trail-average-elevation]", + `${track_info.elevation_avg.toFixed(0)} m`, + ); + + set_value("[data-trail-ascent]", `${track_info.ascent.toFixed(0)} m`); + + set_value("[data-trail-descent]", `${track_info.descent.toFixed(0)} m`); + + set_value( + "[data-trail-average-speed]", + `${track_info.speed_avg.toFixed(2)} km/h`, + ); + + set_value( + "[data-trail-maximum-speed]", + `${track_info.speed_max.toFixed(2)} km/h`, + ); + }; + + display_control.once("eledata_loaded", ({ layer, track_info }) => { + add_endpoint_markers(map, layer); + + if (!summary_uses_separate_gpx) { + set_summary_values(track_info); + } + + map.invalidateSize(false); + display_control.redraw(); + }); + + display_control.load(display_gpx_url); + + if (summary_uses_separate_gpx) { + const { control: summary_control, destroy: destroy_summary_control } = + create_summary_control(); + + summary_control.once("eledata_loaded", ({ track_info }) => { + set_summary_values(track_info); + + destroy_summary_control(); + }); + + summary_control.load(summary_gpx_url); + } + } catch (error) { + console.error("Could not initialize trail map.", error); + + map_element.hidden = true; + chart_element.hidden = true; + + if (message_element) { + message_element.hidden = false; + } + } +}; + +document.addEventListener("DOMContentLoaded", () => { + L.registerLocale("en:trail", { + "x: ": "Distance: ", + "y: ": "Elevation: ", + Altitude: "Elevation", + }); + + L.setLocale("en:trail"); + + const trails = document.querySelectorAll(".trail"); + + for (const trail of trails) { + initialize_trail_map(trail); + } +}); diff --git a/assets/static/vendor/leaflet-elevation/images/checkered.png b/assets/static/vendor/leaflet-elevation/images/checkered.png new file mode 100644 index 0000000..1ae989d Binary files /dev/null and b/assets/static/vendor/leaflet-elevation/images/checkered.png differ diff --git a/assets/static/vendor/leaflet-elevation/images/elevation-locate.png b/assets/static/vendor/leaflet-elevation/images/elevation-locate.png new file mode 100644 index 0000000..9505daa Binary files /dev/null and b/assets/static/vendor/leaflet-elevation/images/elevation-locate.png differ diff --git a/assets/static/vendor/leaflet-elevation/images/elevation-poi.png b/assets/static/vendor/leaflet-elevation/images/elevation-poi.png new file mode 100644 index 0000000..d29e63e Binary files /dev/null and b/assets/static/vendor/leaflet-elevation/images/elevation-poi.png differ diff --git a/assets/static/vendor/leaflet-elevation/images/elevation-position.png b/assets/static/vendor/leaflet-elevation/images/elevation-position.png new file mode 100644 index 0000000..509f063 Binary files /dev/null and b/assets/static/vendor/leaflet-elevation/images/elevation-position.png differ diff --git a/assets/static/vendor/leaflet-elevation/images/elevation-position.svg b/assets/static/vendor/leaflet-elevation/images/elevation-position.svg new file mode 100644 index 0000000..9a3b180 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/images/elevation-position.svg @@ -0,0 +1 @@ + diff --git a/assets/static/vendor/leaflet-elevation/images/elevation-pushpin.png b/assets/static/vendor/leaflet-elevation/images/elevation-pushpin.png new file mode 100644 index 0000000..f4ec493 Binary files /dev/null and b/assets/static/vendor/leaflet-elevation/images/elevation-pushpin.png differ diff --git a/assets/static/vendor/leaflet-elevation/images/elevation-pushpin.svg b/assets/static/vendor/leaflet-elevation/images/elevation-pushpin.svg new file mode 100644 index 0000000..fcc5a01 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/images/elevation-pushpin.svg @@ -0,0 +1 @@ + diff --git a/assets/static/vendor/leaflet-elevation/images/elevation.svg b/assets/static/vendor/leaflet-elevation/images/elevation.svg new file mode 100644 index 0000000..43c0e25 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/images/elevation.svg @@ -0,0 +1 @@ + diff --git a/assets/static/vendor/leaflet-elevation/libs/fullpage.css b/assets/static/vendor/leaflet-elevation/libs/fullpage.css new file mode 100644 index 0000000..c2b8539 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/libs/fullpage.css @@ -0,0 +1,13 @@ +html, +body, +.leaflet-map { + height: 100%; + width: 100%; + padding: 0px; + margin: 0px; +} + +body { + display: flex; + flex-direction: column; +} diff --git a/assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.css b/assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.css new file mode 100644 index 0000000..fcf9a40 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.css @@ -0,0 +1,8 @@ +.dist-marker { + font-size: 9px; + border: 1px solid #777; + border-radius: 10px; + text-align: center; + color: #000; + background: #fff; +} diff --git a/assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.js b/assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.js new file mode 100644 index 0000000..43beb77 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.js @@ -0,0 +1,314 @@ +/* + * Copyright (c) 2022, GPL-3.0+ Project, Raruto + * + * This file is free software: you may copy, redistribute and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 2 of the License, or (at your + * option) any later version. + * + * This file is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright (c) 2014- Doroszlai Attila, 2016- Phil Whitehurst + * + * Permission to use, copy, modify, and/or distribute this software + * for any purpose with or without fee is hereby granted, provided + * that the above copyright notice and this permission notice appear + * in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL + * WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE + * AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR + * CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS + * OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, + * NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +// TODO: the "L.DistanceMarker" (canvas marker) class could be alternatively provided by "leaflet-rotate"? + +L.DistanceMarker = L.CircleMarker.extend({ + _updatePath: function () { + let ctx = this._renderer._ctx; + let p = this._point; + + // Calculate image direction (rotation) + this.options.rotation = this.options.rotation || 0; + + // Draw circle marker (canvas point) + if (this.options.radius && this._renderer._updateCircle) { + this._renderer._updateCircle(this); + } + + // Draw image over circle (distance marker) + if (this.options.icon && this.options.icon.url) { + if (!this.options.icon.element) { + 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; + } else { + const icon = this.options.icon; + let cx = p.x + icon.offset.x; + let cy = p.y + icon.offset.y; + ctx.save(); + if (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(); + } + } + + // Add a label inside the circle (distance marker) + 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'; + + // TODO rescale circle to fit text + // let fontSize = Number(/[0-9\.]+/.exec(ctx.font)[0]); + // let fontWidth = ctx.measureText(this.options.html).width; + + if (this.options.rotation) { + ctx.translate(p.x, p.y); + ctx.rotate(this.options.rotation); + cx = 0; + cy = 0; + } + + // Temporary fix to prevent stroke blurs at higher zoom levels + if (this._map.getZoom() > 17) { + ctx.fillStyle = this.options.strokeStyle || 'black'; + } + + ctx.fillText(this.options.label, cx, cy); + + if (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: 1000, + showAll: 12, + textFunction: (distance, i, offset) => i, + distance: true, + direction: true, + }, + initialize: function (line, map, options) { + + this._layers = {}; + this._zoomLayers = {}; + + options = L.setOptions(this, options); + + let preferCanvas = map.options.preferCanvas; + let showAll = Math.min(map.getMaxZoom(), options.showAll); + + // You should use "leaflet-rotate" to show rotated arrow markers (preferCanvas: false) + if (!preferCanvas && !map.options.rotate) { + console.warn('Missing dependency: "leaflet-rotate"'); + } + + // Get line coords as an array + let coords = typeof line.getLatLngs == 'function' ? line.getLatLngs() : line; + + // Handle "MultiLineString" features + coords = L.LineUtil.isFlat(coords) ? [coords] : coords; + + coords.forEach(latlngs => { + // Get accumulated line lengths as well as overall length + let accumulated = L.GeometryUtil.accumulatedLengths(latlngs); + let length = accumulated.length > 0 ? accumulated[accumulated.length - 1] : 0; + + // count = Number of distance markers to be added + // j = Position in accumulated line length array + for (let i = 1, count = Math.floor(length / options.offset), j = 0; i <= count; ++i) { + + let distance = options.offset * i; + + // Find the first accumulated distance that is greater than the distance of this marker + while (j < accumulated.length - 1 && accumulated[j] < distance) ++j; + + // Grab two nearest points either side marker position + let p1 = latlngs[j - 1]; + let p2 = latlngs[j]; + let m_line = L.polyline([p1, p2]); + + // and create a simple line to interpolate on + let ratio = (distance - accumulated[j - 1]) / (accumulated[j] - accumulated[j - 1]); + let position = L.GeometryUtil.interpolateOnLine(map, m_line, ratio); + let delta = map.project(p2).subtract(map.project(p1)); + let angle = Math.atan2(delta.y, delta.x); + + // Generate distance marker label + let text = options.textFunction.call(this, distance, i, options.offset); + + // Grouping layer of visible layers at zoom level (arrow + distance) + let zoom = this._minimumZoomLevelForItem(i, showAll); + let markers = this._zoomLayers[zoom] = this._zoomLayers[zoom] || L.layerGroup() + + // create arrow markers + if (options.direction && ((options.distance && i % 2 == 1) || !options.distance)) { + if (preferCanvas) { + markers.addLayer( + new L.DistanceMarker(p1, { + radius: 0, + icon: { + url: options.arrowUrl, //image link + size: options.arrowSize, //image size ( default [40, 40] ) + rotate: 0, //image base rotate ( default 0 ) + offset: { x: 0, y: 0 }, //image offset ( default { x: 0, y: 0 } ) + }, + rotation: angle, + interactive: false, + // label: '⮞', //'➜', + // font: 'normal 20pt "Helvetica Neue", Arial, Helvetica, sans-serif', + // fillStyle: 'white',//'#3366CC', + // strokeStyle: 'black', + }) + ); + } else { + markers.addLayer( + L.marker(position.latLng, { + icon: L.icon({ + iconUrl: options.arrowUrl, + iconSize: options.arrowSize, + }), + // NB the following option is added by "leaflet-rotate" + rotation: angle, + interactive: false, + }) + ); + } + } + + // create distance markers + if (options.distance && i % 2 == 0) { + if (preferCanvas) { + markers.addLayer( + new L.DistanceMarker(position.latLng, { + label: text, // TODO: handle text rotation (leaflet-rotate) + radius: 7, + fillColor: '#fff', + fillOpacity: 1, + fillStyle: 'black', + color: '#777', + weight: 1, + interactive: false, + }) + ); + } else { + markers.addLayer( + L.marker(position.latLng, { + title: text, + icon: L.divIcon({ + className: options.cssClass, + html: text, + iconSize: options.iconSize + }), + interactive: false, + }) + ); + } + } + } + }); + + const updateMarkerVisibility = () => { + let oldZoom = this._lastZoomLevel || 0; + let newZoom = map.getZoom(); + if (newZoom > oldZoom) { + for (let i = oldZoom + 1; i <= newZoom; ++i) { + if (this._zoomLayers[i] !== undefined) { + this.addLayer(this._zoomLayers[i]); + } + } + } else if (newZoom < oldZoom) { + for (let i = oldZoom; i > newZoom; --i) { + if (this._zoomLayers[i] !== undefined) { + this.removeLayer(this._zoomLayers[i]); + } + } + } + this._lastZoomLevel = newZoom; + }; + map.on('zoomend', updateMarkerVisibility); + updateMarkerVisibility(); + }, + + _minimumZoomLevelForItem: function (i, zoom) { + while (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 () { + if (this._map && this._distanceMarkers) { + this._map.addLayer(this._distanceMarkers); + } + }, + + removeDistanceMarkers: function () { + if (this._map && this._distanceMarkers) { + this._map.removeLayer(this._distanceMarkers); + } + }, + + onAdd: function (map) { + this._originalOnAdd(map); + + let opts = this.options.distanceMarkers || {}; + if (this.options.distanceMarkers) { + this._distanceMarkers = this._distanceMarkers || new L.DistanceMarkers(this, map, opts); + } + if (opts.lazy === undefined || opts.lazy === false) { + this.addDistanceMarkers(); + } + }, + + onRemove: function (map) { + this.removeDistanceMarkers(); + this._originalOnRemove(map); + } + +}); diff --git a/assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.min.css b/assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.min.css new file mode 100644 index 0000000..473e8f3 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.min.css @@ -0,0 +1 @@ +.dist-marker{font-size:9px;border:1px solid #777;border-radius:10px;text-align:center;color:#000;background:#fff} \ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.min.js b/assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.min.js new file mode 100644 index 0000000..b8bc58b --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/libs/leaflet-distance-marker.min.js @@ -0,0 +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)}}); \ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/libs/leaflet-edgescale.js b/assets/static/vendor/leaflet-elevation/libs/leaflet-edgescale.js new file mode 100644 index 0000000..74c672d --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/libs/leaflet-edgescale.js @@ -0,0 +1,636 @@ +/* + * Copyright (c) 2023, GPL-3.0+ Project, Raruto + * + * This file is free software: you may copy, redistribute and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 2 of the License, or (at your + * option) any later version. + * + * This file is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright (c) GPL-3.0+ Project - 2018- Dražen Tutić - https://github.com/dtutic/Leaflet.EdgeScaleBar + * Copyright (c) MIT License (MIT) - 2015- Xisco Guaita - https://github.com/xguaita/Leaflet.MapCenterCoord + */ + +/** + * Original source: https://github.com/xguaita/Leaflet.MapCenterCoord + */ +L.Control.EdgeScale = L.Control.extend({ + + // Defaults + options: { + position: 'bottomleft', + icon: true, + coords: true, + bar: true, + onMove: true, + template: '{y} | {x}', // https://en.wikipedia.org/wiki/ISO_6709 + projected: false, + formatProjected: '#.##0,000', + latlngFormat: 'DD', // DD, DM, DMS + latlngDesignators: true, + latLngFormatter: undefined, + 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) { + + if (this.options.bar) { + this._scaleBar = (new L.Control.EdgeScale.Layer(true === this.options.bar ? {} : this.options.bar)).addTo(map); + } + + // create a DOM element and put it into overlayPane + if (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); + } + + // Control container + this._container = L.DomUtil.create('div', 'leaflet-control-mapcentercoord'); + + Object.assign(this._container.style, this.options.containerStyle); + + if (!this.options.coords) { + this._container.style.display = 'none'; + } + + L.DomEvent.disableClickPropagation(this._container); + + this._container.innerHTML = this._getMapCenterCoord(); + + // Add events listeners for updating coordinates & icon's position + map.on('move', this._onMapMove, this); + map.on('moveend', this._onMapMove, this); + + return this._container; + }, + + onRemove: function (map) { + if (this.options.bar) { + this._scaleBar.remove(); + } + + // remove icon's DOM elements and listeners + if (this.options.icon) { + map.getContainer().removeChild(this._icon); + } + map.off('move', this._onMapMove, this); + map.off('moveend', this._onMapMove, this); + }, + + // update coordinates + _onMapMove: function (e) { + if (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, latlngFormat, latlngDesignators: designators } = this.options; + + if (undefined !== latLngFormatter ) { + return latLngFormatter(latLng.lat, latLng.lng); + } + + let lat, lng, deg, min; + + // make a copy of center so we aren't affecting leaflet's internal state + let center = { + lat: latLng.lat, + lng: latLng.lng, + lng_neg: latLng.lng < 0, + lat_neg: latLng.lat < 0, + }; + + // 180 degrees & negative + if (center.lng < 0) { + center.lng = Math.abs(center.lng); + } + if (center.lng > 180) { + center.lng = 360 - center.lng; + center.lng_neg = !center.lng_neg; + } + if (center.lat < 0) { + center.lat = Math.abs(center.lat); + } + + // format + if ('DM' === latlngFormat) { + deg = parseInt(center.lng); + lng = deg + 'º ' + this._format('00.000', (center.lng - deg) * 60) + "'"; + deg = parseInt(center.lat); + lat = deg + 'º ' + this._format('00.000', (center.lat - deg) * 60) + "'"; + } else if ('DMS' === latlngFormat) { + deg = parseInt(center.lng); + min = (center.lng - deg) * 60; + lng = deg + 'º ' + this._format('00', parseInt(min)) + "' " + this._format('00.0', (min - parseInt(min)) * 60) + "''"; + deg = parseInt(center.lat); + min = (center.lat - deg) * 60; + lat = deg + 'º ' + this._format('00', parseInt(min)) + "' " + this._format('00.0', (min - parseInt(min)) * 60) + "''"; + } else { // 'DD' + lng = this._format('#0.00000', center.lng) + 'º'; + lat = this._format('##0.00000', center.lat) + 'º'; + } + + return L.Util.template(this.options.template, { + x: (!designators && center.lng_neg ? '-' : '') + lng + (designators ? (center.lng_neg ? ' W' : ' E') : ''), + y: (!designators && center.lat_neg ? '-' : '') + lat + (designators ? (center.lat_neg ? ' S' : ' N') : '') + }); + }, + + /** + * IntegraXor Web SCADA - JavaScript Number Formatter + * + * @see https://code.google.com/p/javascript-number-formatter + * @authors KPL, KHL + */ + _format: function (m, v) { + if (!m || isNaN(+v)) { + return v; // return as it is. + } + + v = m.charAt(0) == '-' ? -v : +v; // convert any string to number according to formation sign. + let isNegative = v < 0 ? v = -v : 0; // process only abs(), and turn on flag. + + let result = m.match(/[^\d\-\+#]/g); // search for separator for grp & decimal, anything not digit, not +/- sign, not #. + let Decimal = (result && result[result.length - 1]) || '.'; // treat the right most symbol as decimal + let Group = (result && result[1] && result[0]) || ','; // treat the left most symbol as group separator + + m = m.split(Decimal); // split the decimal for the format string if any. + v = v.toFixed(m[1] && m[1].length); // Fix the decimal first, toFixed will auto fill trailing zero. + v = +(v) + ''; // convert number to string to trim off *all* trailing decimal zero(es) + + let pos_trail_zero = m[1] && m[1].lastIndexOf('0'); // fill back any trailing zero according to format (look for last zero in format) + let part = v.split('.'); + + if (!part[1] || part[1] && part[1].length <= pos_trail_zero) { // integer will get !part[1] + v = (+v).toFixed(pos_trail_zero + 1); + } + let szSep = m[0].split(Group); // look for separator + m[0] = szSep.join(''); // join back without separator for counting the pos of any leading 0. + + let pos_lead_zero = m[0] && m[0].indexOf('0'); + if (pos_lead_zero > -1) { + while (part[0].length < (m[0].length - pos_lead_zero)) { + part[0] = '0' + part[0]; + } + } else if (+part[0] == 0) { + part[0] = ''; + } + v = v.split('.'); + v[0] = part[0]; + + var pos_separator = (szSep[1] && szSep[szSep.length - 1].length); // process the first group separator from decimal (.) only, the rest ignore. Get the length of the last slice of split result. + if (pos_separator) { + let integer = v[0]; + let str = ''; + let offset = integer.length % pos_separator; + for (let i = 0, l = integer.length; i < l; i++) { + str += integer.charAt(i); // ie6 only support charAt for sz. + if ( + !((i - offset + 1) % pos_separator) && + i < l - pos_separator // -pos_separator so that won't trail separator on full length + ) { + str += Group; + } + } + v[0] = str; + } + + v[1] = (m[1] && v[1]) ? Decimal + v[1] : ""; + return (isNegative ? '-' : '') + v[0] + v[1]; // put back any negation and combine integer and fraction. + } + +}); + +/** + * Original Source: https://github.com/dtutic/Leaflet.EdgeScaleBar + * + * Draws the metric scale bars in Web Mercator map along top and right edges. + * Authors: Dražen Tutić (dtutic@geof.hr), Ana Kuveždić Divjak (akuvezdic@geof.hr) + * University of Zagreb, Faculty of Geodesy, GEOF-OSGL Lab + * Inspired by LatLonGraticule Leaflet plugin by: lanwei@cloudybay.com.tw + */ +L.Control.EdgeScale.Layer = L.Layer.extend({ + + includes: L.Evented ? L.Evented.prototype : L.Mixin.Events, + + options: { + opacity: 1, + weight: 0.8, + gradient: { + size: 10, + opacity: 0.5, + }, + color: '#000', + font: '11px Arial', + zoomInterval: [ + {start: 0, end: 2, interval: 5000000}, + {start: 3, end: 3, interval: 2000000}, + {start: 4, end: 4, interval: 1000000}, + {start: 5, end: 5, interval: 500000}, + {start: 6, end: 7, interval: 200000}, + {start: 8, end: 8, interval: 100000}, + {start: 9, end: 9, interval: 50000}, + {start: 10, end: 10, interval: 20000}, + {start: 11, end: 11, interval: 10000}, + {start: 12, end: 12, interval: 5000}, + {start: 13, end: 13, interval: 2000}, + {start: 14, end: 14, interval: 1000}, + {start: 15, end: 15, interval: 500}, + {start: 16, end: 16, interval: 200}, + {start: 17, end: 17, interval: 100}, + {start: 18, end: 18, interval: 50}, + {start: 19, end: 19, interval: 20}, + {start: 20, end: 20, interval: 10} + ], + pane: 'edgescalePane' + }, + + initialize: function (options) { + + L.setOptions(this, options); + + // Constants of the WGS84 ellipsoid needed to calculate meridian length or latitute + const a = this._a = 6378137.0; + const b = this._b = 6356752.3142; + const n = this._n = (a - b)/(a + b); + const a2 = a * a; + const b2 = b * b; + const n2 = n * n; + const n3 = n2 * n; + const n4 = n3 * n; + const n5 = n4 * n; + this._A = a * (1.0 - n) * (1.0 - n2) * (1.0 + 9.0/4.0 * n2 + 225.0/64.0 * n4); + this._e2 = (a2 - b2) / a2; + this._ic1 = 1.5 * n - 29.0/12.0 * n3 + 553.0/80.0 * n5; + this._ic2 = 21.0/8.0 * n2 - 1537.0/128.0 * n4; + this._ic3 = 151.0/24.0 * n3 - 32373.0/640.0 * n5; + this._ic4 = 1097.0/64.0 * n4; + this._ic5 = 8011.0/150.0 * n5; + this._c1 = -1.5 * n + 31.0/24.0 * n3 - 669.0/640.0 * n5; + this._c2 = 15.0/18.0 * n2 - 435.0/128.0 * n4; + this._c3 = -35.0/12.0 * n3 + 651.0/80.0 * n5; + this._c4 = 315.0/64.0 * n4; + this._c5 = -693.0/80.0 * n5; + + // Latitude limit of the Web Mercator projection + this._LIMIT_PHI = 1.484419982; + }, + + onAdd: function (map) { + this._map = map; + + let pane = map.getPane(this.options.pane); + if (!pane) { + pane = this._pane = map.createPane('edgescalePane', map.getPane('norotatePane') || map.getPane('mapPane')); + pane.style.zIndex = 625; // This pane is above markers but below popups. + pane.style.pointerEvents = 'none'; + } + + this._pane = pane; + + // if (this._renderer) this._renderer.remove() + // this._renderer = L.canvas({ pane: "edgescalePane" }).addTo(this._map); // default leaflet svg renderer + + if (!this._canvas) { + this._initCanvas(); + } + + this._pane.appendChild(this._canvas); + + map.on('viewreset', this._reset, this); + map.on('move', this._reset, this); + map.on('moveend', this._reset, this); + map.on('rotate', this._reset, this); + + this._reset(); + }, + + onRemove: function (map) { + this._pane.removeChild(this._canvas); + + map.off('viewreset', this._reset, this); + map.off('move', this._reset, this); + map.off('moveend', this._reset, this); + }, + + addTo: function (map) { + map.addLayer(this); + return this; + }, + + setOpacity: function (opacity) { + this.options.opacity = opacity; + L.DomUtil.setOpacity(this._canvas, this.options.opacity); + return this; + }, + + bringToFront: function () { + if (this._canvas) { + this._pane.appendChild(this._canvas); + } + return this; + }, + + bringToBack: function () { + if (this._canvas) { + this._pane.insertBefore(this._canvas, pane.firstChild); + } + return this; + }, + + _initCanvas: function () { + this._canvas = L.DomUtil.create('canvas', ''); + this._ctx = this._canvas.getContext('2d'); + + this.setOpacity(); + + L.extend(this._canvas, { + onselectstart: L.Util.falseFn, + onmousemove: L.Util.falseFn, + onload: L.bind(this._onCanvasLoad, this) + }); + }, + + _reset: function () { + var canvas = this._canvas, + size = this._map.getSize(); + + this._setCanvasPosition(); + + canvas.width = size.x; + canvas.height = size.y; + canvas.style.width = size.x + 'px'; + canvas.style.height = size.y + 'px'; + + /** + * @TODO add support for "leaflet-rotate" + */ + if (this._map._bearing) { + return; + } + + const { gradient } = this.options; + + // horizontal gradient + if (!this._hor_gradient) { + this._hor_gradient = this._ctx.createLinearGradient(0, 0, 0, gradient.size); + this._hor_gradient.addColorStop(0,"rgba(255, 255, 255, " + gradient.opacity + ")"); + this._hor_gradient.addColorStop(1,"rgba(255, 255, 255, 0)"); + } + this._ctx.fillStyle = this._hor_gradient; + this._ctx.fillRect(0, 0, size.x, gradient.size); + + // vertical gradient + if (!this._vert_gradient) { + this._vert_gradient = this._ctx.createLinearGradient(0, 0, gradient.size, 0); + this._vert_gradient.addColorStop(0,"rgba(255, 255, 255, " + gradient.opacity + ")"); + this._vert_gradient.addColorStop(1,"rgba(255, 255, 255, 0)"); + } + this._ctx.fillStyle = this._vert_gradient; + this._ctx.fillRect(0, 0, gradient.size, size.y); + + this._ctx.beginPath(); + this._ctx.moveTo(0,0); + this._ctx.lineTo(size.x,0); + this._ctx.lineTo(size.x,size.y); + this._ctx.stroke(); + + this._calcInterval(); + this._draw(); + }, + + _onCanvasLoad: function () { + this.fire('load'); + }, + + _calcInterval: function() { + const { zoomInterval } = this.options; + const zoom = this._map.getZoom(); + + if (undefined !== zoomInterval) { + // Manually set scale using a custom this.options.zoomInterval object + for (const idx in zoomInterval) { + const dict = zoomInterval[idx]; + if (dict.start <= zoom && dict.end && dict.end >= zoom) { + this._interval = dict.interval; + break; + } + } + } else { + // Autamatically get current scale using L.Control.Scale + // Source: https://gis.stackexchange.com/a/198444 + 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(); + const text = this._interval >= 1000 ? (this._interval / 1000 + ' 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 } = this.options; + const size = this._map.getSize(); + const to_rad = Math.PI/180.0; + const center = this._merLength(this._map.containerPointToLatLng(L.point(0, size.y / 2)).lat * to_rad); + const top = this._merLength(this._map.containerPointToLatLng(L.point(0,0)).lat * to_rad); + const bottom = this._merLength(this._map.containerPointToLatLng(L.point(0, size.y)).lat * to_rad); + + // draw major ticks + for (let i = center + this._interval / 2; i < top; i = i + this._interval) { + const phi = this._invmerLength(i); + if ((phi < this._LIMIT_PHI) && (phi > -this._LIMIT_PHI)) { + this._draw_lat_tick(phi, 10, weight * 1.5); + } + } + for (let i = center - this._interval / 2; i > bottom; i = i - this._interval) { + const phi = this._invmerLength(i); + if ((phi > -this._LIMIT_PHI) && (phi < this._LIMIT_PHI)) { + this._draw_lat_tick(phi, 10, weight * 1.5); + } + } + + // draw minor ticks + for (let i = center; i < top; i = i + this._interval / 10.0) { + const phi = this._invmerLength(i); + if ((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 = i - this._interval / 10.0) { + const phi = this._invmerLength(i); + if ((phi > -this._LIMIT_PHI) && (phi < this._LIMIT_PHI)) { + this._draw_lat_tick(phi, 4, weight); + } + } + }, + + _create_lon_ticks: function() { + const { weight } = this.options; + const size = this._map.getSize(); + const to_rad = Math.PI/180.0; + const to_deg = 180.0/Math.PI; + const center = this._map.containerPointToLatLng(L.point(size.x / 2, 0)); + const left = this._map.containerPointToLatLng(L.point(0, 0)); + const right = this._map.containerPointToLatLng(L.point(size.x, 0)); + const sinPhi2 = Math.pow(Math.sin(center.lat * to_rad), 2); + const N = this._a / Math.sqrt(1.0 - this._e2 * sinPhi2); + const dl = this._interval / (N * Math.cos(center.lat * to_rad)) * to_deg; + + // draw major ticks + for (let i = center.lng + dl / 2; i < right.lng; i = i + dl) this._draw_lon_tick(i, 10, weight * 1.5); + for (let i = center.lng - dl / 2; i > left.lng; i = i - dl) this._draw_lon_tick(i, 10, weight * 1.5); + + // draw minor ticks + for (let i = center.lng; i < right.lng; i = i + dl / 10) this._draw_lon_tick(i, 4, weight); + for (let i = center.lng - dl / 10; i > left.lng; i = i - dl / 10) this._draw_lon_tick(i, 4, weight); + }, + + _setCanvasPosition: function() { + let lt = this._map.containerPointToLayerPoint([0, 0]); + /** + * @TODO add support for "leaflet-rotate" + */ + if (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.0/Math.PI; + const size = this._map.getSize(); + const y = this._latLngToCanvasPoint(L.latLng(phi * to_deg, 0.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.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.0 * phi); + const sin2 = Math.sin(2.0 * 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; + const cos2 = Math.cos(2.0 * psi); + const sin2 = Math.sin(2.0 * 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: false +}); + +L.Map.addInitHook(function () { + if (this.options.edgeScaleControl) { + this.edgeScaleControl = new L.Control.EdgeScale(); + this.addControl(this.edgeScaleControl); + } +}); \ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/libs/leaflet-edgescale.min.js b/assets/static/vendor/leaflet-elevation/libs/leaflet-edgescale.min.js new file mode 100644 index 0000000..81237d5 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/libs/leaflet-edgescale.min.js @@ -0,0 +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))})); \ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/libs/leaflet-gpxgroup.js b/assets/static/vendor/leaflet-elevation/libs/leaflet-gpxgroup.js new file mode 100644 index 0000000..c7a9dd6 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/libs/leaflet-gpxgroup.js @@ -0,0 +1,347 @@ +/* + * https://github.com/adoroszlai/joebed/tree/gh-pages + * + * The MIT License (MIT) + * + * Copyright (c) 2014- Doroszlai Attila, 2019- Raruto + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +L.Mixin.Selectable = { + includes: L.Mixin.Events, + + setSelected: function(s) { + var selected = !!s; + if (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) { + if (this._selected === item) { + if (item !== null) { + item.setSelected(!item.isSelected()); + if (!item.isSelected()) { + this._selected = null; + } + } + } else { + if (this._selected) { + this._selected.setSelected(false); + } + this._selected = item; + if (this._selected) { + this._selected.setSelected(true); + } + } + this.fire('selection_changed'); + }, +}; + +L.Control.LayersLegend = L.Control.Layers.extend({ + _onInputClick: function() { + this._handlingClick = true; + + this._layerControlInputs.reduceRight((_,input) => { + if (input.checked) { + this._map.fireEvent("legend_selected", { + layer: this._getLayer(input.layerId).layer, + input: input, + }, true); + return input; + } + }, 0); + + this._handlingClick = false; + + 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: true, + legend: false, + legend_options: { + position: "topright", + collapsed: false, + }, + elevation: true, + elevation_options: { + theme: 'yellow-theme', + detached: true, + elevationDiv: '#elevation-div', + }, + distanceMarkers: true, + distanceMarkers_options: { + lazy: true + }, + }, + + 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) { + if (track instanceof Object) { + this._loadGeoJSON(track); + } else { + 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) { + if (geojson) { + geojson.name = geojson.name || (geojson[0] && geojson[0].properties.name) || fallbackName; + this._loadRoute(geojson); + } + }, + + _loadRoute: function(data) { + if (!data) return; + + var line_style = { + color: this._uniqueColors(this._tracks.length)[this._count++], + opacity: 0.75, + weight: 5, + distanceMarkers: this.options.distanceMarkers_options, + }; + + var route = L.geoJson(data, { + name: data.name || '', + style: (feature) => line_style, + distanceMarkers: line_style.distanceMarkers, + originalStyle: line_style, + filter: feature => feature.geometry.type != "Point", + }); + + + 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: true, }); + }, + + _onEachRouteLoaded: function(route) { + if (this.options.legend) { + this._legend.addBaseLayer(route, '' + '' + ' ' + route.options.name); + } + + this.fire('route_loaded', { route: route }); + + if (++this._loadedCount === this._tracks.length) { + this.fire('loaded'); + if (this.options.flyToBounds) { + this._map.flyToBounds(this.getBounds(), { duration: 0.25, easeLinearity: 0.25, noMoveStart: true }); + } + if (this.options.legend) { + this._legend.addTo(this._map); + } + } + }, + + highlight: function(route, polyline) { + polyline.setStyle(this.options.highlight); + if (this.options.distanceMarkers) { + polyline.addDistanceMarkers(); + } + }, + + unhighlight: function(route, polyline) { + polyline.setStyle(route.options.originalStyle); + if (this.options.distanceMarkers) { + polyline.removeDistanceMarkers(); + } + }, + + _onRouteMouseOver: function(route, polyline) { + if (!route.isSelected()) { + this.highlight(route, polyline); + if (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) { + if (!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) { + if (!route.isSelected()) { + this.unhighlight(route, polyline); + } + }, + + _onSelectionChanged: function(e) { + var elevation = this._elevation; + var eleDiv = elevation.getContainer(); + var route = this.getSelection(); + + elevation.clear(); + + if (route && route.isSelected()) { + if (!eleDiv) { + elevation.addTo(this._map); + } + route.getLayers().forEach(function(layer) { + if (layer instanceof L.Polyline) { + elevation.addData(layer, false); + layer.bringToFront(); + } + }); + } else { + if (eleDiv) { + elevation.remove(); + } + } + }, + + _onLegendSelected: function(e) { + var parent = e.input.closest('.leaflet-control-layers-list'); + var route = e.layer; + + if (!route.isSelected()) { + this.setSelection(route); + for (var i in 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 count === 1 ? ['#0000ff'] : new Array(count).fill(null).map((_,i) => this._hsvToHex(i * (1 / count), 1, 0.7)); + }, + + _hsvToHex: function(h, s, v) { + var i = Math.floor(h * 6); + var f = h * 6 - i; + var p = v * (1 - s); + var q = v * (1 - f * s); + var t = v * (1 - (1 - f) * s); + 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]; + return rgb.map(d => d * 255).reduce((hex, byte) => hex + ((byte >> 4) & 0x0F).toString(16) + (byte & 0x0F).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); \ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/libs/leaflet-gpxgroup.min.js b/assets/static/vendor/leaflet-elevation/libs/leaflet-gpxgroup.min.js new file mode 100644 index 0000000..5b30fb0 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/libs/leaflet-gpxgroup.min.js @@ -0,0 +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); \ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/libs/leaflet-hotline.js b/assets/static/vendor/leaflet-elevation/libs/leaflet-hotline.js new file mode 100644 index 0000000..1684560 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/libs/leaflet-hotline.js @@ -0,0 +1,457 @@ +/* +(c) 2017, iosphere GmbH +Leaflet.hotline, a Leaflet plugin for drawing gradients along polylines. +https://github.com/iosphere/Leaflet.hotline/ +*/ + +(function (root, plugin) { + /** + * UMD wrapper. + * When used directly in the Browser it expects Leaflet to be globally + * available as `L`. The plugin then adds itself to Leaflet. + * When used as a CommonJS module (e.g. with browserify) only the plugin + * factory gets exported, so one hast to call the factory manually and pass + * Leaflet as the only parameter. + * @see {@link https://github.com/umdjs/umd} + */ + if (typeof define === 'function' && define.amd) { + define(['leaflet'], plugin); + } else if (typeof exports === 'object') { + module.exports = plugin; + } else { + plugin(root.L); + } +}(window, function (L) { + // Plugin is already added to Leaflet + if (L.Hotline) { + return L; + } + + /** + * Core renderer. + * @constructor + * @param {HTMLElement | string} canvas - <canvas> element or its id + * to initialize the instance on. + */ + var Hotline = function (canvas) { + if (!(this instanceof Hotline)) { return new Hotline(canvas); } + + var defaultPalette = { + 0.0: 'green', + 0.5: 'yellow', + 1.0: '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 = { + /** + * Sets the width of the canvas. Used when clearing the canvas. + * @param {number} width - Width of the canvas. + */ + width: function (width) { + this._width = width; + return this; + }, + + /** + * Sets the height of the canvas. Used when clearing the canvas. + * @param {number} height - Height of the canvas. + */ + height: function (height) { + this._height = height; + return this; + }, + + /** + * Sets the weight of the path. + * @param {number} weight - Weight of the path in px. + */ + weight: function (weight) { + this._weight = weight; + return this; + }, + + /** + * Sets the width of the outline around the path. + * @param {number} outlineWidth - Width of the outline in px. + */ + outlineWidth: function (outlineWidth) { + this._outlineWidth = outlineWidth; + return this; + }, + + /** + * Sets the color of the outline around the path. + * @param {string} outlineColor - A CSS color value. + */ + outlineColor: function (outlineColor) { + this._outlineColor = outlineColor; + return this; + }, + + /** + * Sets the palette gradient. + * @param {Object.} palette - Gradient definition. + * e.g. { 0.0: 'white', 1.0: 'black' } + */ + palette: function (palette) { + var canvas = document.createElement('canvas'), + ctx = canvas.getContext('2d'), + gradient = ctx.createLinearGradient(0, 0, 0, 256); + + canvas.width = 1; + canvas.height = 256; + + for (var i in palette) { + gradient.addColorStop(i, palette[i]); + } + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, 1, 256); + + this._palette = ctx.getImageData(0, 0, 1, 256).data; + + return this; + }, + + /** + * Sets the value used at the start of the palette gradient. + * @param {number} min + */ + min: function (min) { + this._min = min; + return this; + }, + + /** + * Sets the value used at the end of the palette gradient. + * @param {number} max + */ + max: function (max) { + this._max = max; + return this; + }, + + /** + * A path to rander as a hotline. + * @typedef Array.<{x:number, y:number, z:number}> Path - Array of x, y and z coordinates. + */ + + /** + * Sets the data that gets drawn on the canvas. + * @param {(Path|Path[])} data - A single path or an array of paths. + */ + data: function (data) { + this._data = data; + return this; + }, + + /** + * Adds a path to the list of paths. + * @param {Path} path + */ + add: function (path) { + this._data.push(path); + return this; + }, + + /** + * Draws the currently set paths. + */ + draw: function () { + var ctx = this._ctx; + + ctx.globalCompositeOperation = 'source-over'; + ctx.lineCap = 'round'; + + this._drawOutline(ctx); + this._drawHotline(ctx); + + return this; + }, + + /** + * Gets the RGB values of a given z value of the current palette. + * @param {number} value - Value to get the color for, should be between min and max. + * @returns {Array.} The RGB values as an array [r, g, b] + */ + getRGBForValue: function (value) { + var valueRelative = Math.min(Math.max((value - this._min) / (this._max - this._min), 0), 0.999); + var paletteIndex = Math.floor(valueRelative * 256) * 4; + + return [ + this._palette[paletteIndex], + this._palette[paletteIndex + 1], + this._palette[paletteIndex + 2] + ]; + }, + + /** + * Draws the outline of the graphs. + * @private + */ + _drawOutline: function (ctx) { + var i, j, dataLength, path, pathLength, pointStart, pointEnd; + + if (this._outlineWidth) { + for (i = 0, dataLength = this._data.length; i < dataLength; i++) { + path = this._data[i]; + ctx.lineWidth = this._weight + 2 * this._outlineWidth; + + for (j = 1, pathLength = path.length; j < pathLength; j++) { + pointStart = path[j - 1]; + pointEnd = path[j]; + + ctx.strokeStyle = this._outlineColor; + ctx.beginPath(); + ctx.moveTo(pointStart.x, pointStart.y); + ctx.lineTo(pointEnd.x, pointEnd.y); + ctx.stroke(); + } + } + } + }, + + /** + * Draws the color encoded hotline of the graphs. + * @private + */ + _drawHotline: function (ctx) { + var i, j, dataLength, path, pathLength, pointStart, pointEnd, + gradient, gradientStartRGB, gradientEndRGB; + + ctx.lineWidth = this._weight; + + for (i = 0, dataLength = this._data.length; i < dataLength; i++) { + path = this._data[i]; + + for (j = 1, pathLength = path.length; j < pathLength; j++) { + pointStart = path[j - 1]; + pointEnd = path[j]; + + // Create a gradient for each segment, pick start end end colors from palette gradient + gradient = ctx.createLinearGradient(pointStart.x, pointStart.y, pointEnd.x, pointEnd.y); + gradientStartRGB = this.getRGBForValue(pointStart.z); + gradientEndRGB = this.getRGBForValue(pointEnd.z); + gradient.addColorStop(0, 'rgb(' + gradientStartRGB.join(',') + ')'); + gradient.addColorStop(1, 'rgb(' + gradientEndRGB.join(',') + ')'); + + ctx.strokeStyle = gradient; + ctx.beginPath(); + ctx.moveTo(pointStart.x, pointStart.y); + ctx.lineTo(pointEnd.x, pointEnd.y); + ctx.stroke(); + } + } + } + }; + + + var Renderer = L.Canvas.extend({ + _initContainer: function () { + L.Canvas.prototype._initContainer.call(this); + this._hotline = new Hotline(this._container); + }, + + _update: function () { + L.Canvas.prototype._update.call(this); + this._hotline.width(this._container.width); + this._hotline.height(this._container.height); + }, + + _updatePoly: function (layer) { + if (!this._drawing) { return; } + + var parts = layer._parts; + + if (!parts.length) { return; } + + this._updateOptions(layer); + + this._hotline + .data(parts) + .draw(); + }, + + _updateOptions: function (layer) { + if (layer.options.min != null) { + this._hotline.min(layer.options.min); + } + if (layer.options.max != null) { + this._hotline.max(layer.options.max); + } + if (layer.options.weight != null) { + this._hotline.weight(layer.options.weight); + } + if (layer.options.outlineWidth != null) { + this._hotline.outlineWidth(layer.options.outlineWidth); + } + if (layer.options.outlineColor != null) { + this._hotline.outlineColor(layer.options.outlineColor); + } + if (layer.options.palette) { + this._hotline.palette(layer.options.palette); + } + } + }); + + var renderer = function (options) { + return L.Browser.canvas ? new Renderer(options) : null; + }; + + + var Util = { + /** + * This is just a copy of the original Leaflet version that support a third z coordinate. + * @see {@link http://leafletjs.com/reference.html#lineutil-clipsegment|Leaflet} + */ + clipSegment: function (a, b, bounds, useLastCode, round) { + var codeA = useLastCode ? this._lastCode : L.LineUtil._getBitCode(a, bounds), + codeB = L.LineUtil._getBitCode(b, bounds), + codeOut, p, newCode; + + // save 2nd code to avoid calculating it on the next segment + this._lastCode = codeB; + + while (true) { + // if a,b is inside the clip window (trivial accept) + if (!(codeA | codeB)) { + return [a, b]; + // if a,b is outside the clip window (trivial reject) + } else if (codeA & codeB) { + return false; + // other cases + } else { + codeOut = codeA || codeB; + p = L.LineUtil._getEdgeIntersection(a, b, codeOut, bounds, round); + newCode = L.LineUtil._getBitCode(p, bounds); + + if (codeOut === codeA) { + p.z = a.z; + a = p; + codeA = newCode; + } else { + p.z = b.z; + b = p; + codeB = newCode; + } + } + } + } + }; + + + L.Hotline = L.Polyline.extend({ + statics: { + Renderer: Renderer, + renderer: renderer + }, + + options: { + renderer: renderer(), + min: 0, + max: 1, + palette: { + 0.0: 'green', + 0.5: 'yellow', + 1.0: 'red' + }, + weight: 5, + outlineColor: 'black', + outlineWidth: 1 + }, + + getRGBForValue: function (value) { + return this._renderer._hotline.getRGBForValue(value); + }, + + /** + * Just like the Leaflet version, but with support for a z coordinate. + */ + _projectLatlngs: function (latlngs, result, projectedBounds) { + var flat = latlngs[0] instanceof L.LatLng, + len = latlngs.length, + i, ring; + + if (flat) { + ring = []; + for (i = 0; i < len; i++) { + ring[i] = this._map.latLngToLayerPoint(latlngs[i]); + // Add the altitude of the latLng as the z coordinate to the point + ring[i].z = latlngs[i].alt; + projectedBounds.extend(ring[i]); + } + result.push(ring); + } else { + for (i = 0; i < len; i++) { + this._projectLatlngs(latlngs[i], result, projectedBounds); + } + } + }, + + /** + * Just like the Leaflet version, but uses `Util.clipSegment()`. + */ + _clipPoints: function () { + if (this.options.noClip) { + this._parts = this._rings; + return; + } + + this._parts = []; + + var parts = this._parts, + bounds = this._renderer._bounds, + i, j, k, len, len2, segment, points; + + for (i = 0, k = 0, len = this._rings.length; i < len; i++) { + points = this._rings[i]; + + for (j = 0, len2 = points.length; j < len2 - 1; j++) { + segment = Util.clipSegment(points[j], points[j + 1], bounds, j, true); + + if (!segment) { continue; } + + parts[k] = parts[k] || []; + parts[k].push(segment[0]); + + // if segment goes out of screen, or it's the last one, it's the end of the line part + if ((segment[1] !== points[j + 1]) || (j === len2 - 2)) { + parts[k].push(segment[1]); + k++; + } + } + } + }, + + _clickTolerance: function () { + return this.options.weight / 2 + this.options.outlineWidth + (L.Browser.touch ? 10 : 0); + } + }); + + L.hotline = function (latlngs, options) { + return new L.Hotline(latlngs, options); + }; + + + return L; +})); \ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/libs/leaflet-hotline.min.js b/assets/static/vendor/leaflet-elevation/libs/leaflet-hotline.min.js new file mode 100644 index 0000000..a1466b8 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/libs/leaflet-hotline.min.js @@ -0,0 +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 */ + 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=="); /*
Icons made by Freepik from www.flaticon.com is licensed by CC 3.0 BY
*/ +} +.leaflet-ruler-clicked { + height: 35px; + width: 35px; + background-repeat: no-repeat; + background-position: center; + border-color: chartreuse !important; +} +.leaflet-bar.leaflet-ruler { + background-color: #ffffff; +} +.leaflet-control.leaflet-ruler { + cursor: pointer; +} +.result-tooltip { + background-color: white; + border-width: medium; + border-color: #de0000; + font-size: smaller; +} +.moving-tooltip { + background-color: rgba(255, 255, 255, .7); + background-clip: padding-box; + opacity: 0.5; + border: dotted; + border-color: red; + font-size: smaller; +} +.plus-length { + padding-left: 45px; +} + \ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/libs/leaflet-ruler.js b/assets/static/vendor/leaflet-elevation/libs/leaflet-ruler.js new file mode 100644 index 0000000..847f9b8 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/libs/leaflet-ruler.js @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2022, GPL-3.0+ Project, Raruto + * + * This file is free software: you may copy, redistribute and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 2 of the License, or (at your + * option) any later version. + * + * This file is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright (c) 2017 Goker Tanrisever + * + * Permission to use, copy, modify, and/or distribute this software + * for any purpose with or without fee is hereby granted, provided + * that the above copyright notice and this permission notice appear + * in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL + * WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE + * AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR + * CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS + * OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, + * NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +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: 0.001, // meters -> kilometers + label: 'Distance:' + }, + angleUnit: { + display: '°', + decimal: 2, + factor: 360, + label: 'Bearing:' + } + }, + initialize: function (options) { + L.setOptions(this, options); + this._layers = L.layerGroup(); + this._enabled = false; + }, + onAdd: function (map) { + this._defaultCursor = map._container.style.cursor; + this._map = map; + let container = L.DomUtil.create('div', 'leaflet-bar'); + container.classList.add('leaflet-ruler'); + L.DomEvent.disableClickPropagation(container); + L.DomEvent.on(container, 'click', this._toggleMeasure, this); + return 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 = false; + this._container.classList.remove("leaflet-ruler-clicked"); + this._layers.remove().clearLayers(); + this._latlngs = []; + this._totalLength = 0; + this._removeMouseEvents(); + }, + _enable: function () { + this._enabled = true; + 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 lastClick = this._latlngs[this._latlngs.length - 1] ?? latlng; + let bearing = this._calculateBearing(lastClick, latlng); + let distance = lastClick.distanceTo(latlng) * this.options.lengthUnit.factor; + let accumulated = this._totalLength + distance; + let totalLength = accumulated.toFixed(this.options.lengthUnit.decimal); + let plusLength = incremental ? '
(+' + distance.toFixed(this.options.lengthUnit.decimal) + ')
' : ''; + this._totalLength = incremental ? this._totalLength : accumulated; + if (!layer.getTooltip()) layer.bindTooltip('', incremental ? { direction: "auto", sticky: true, offset: L.point(0, -40), className: 'moving-tooltip' } : { permanent: true, className: 'result-tooltip' }).openTooltip(); + 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); + }, + _addPoint: function (e) { + let latlng = e.latlng || e; + let point = L.circleMarker(latlng, this.options.circleMarker).addTo(this._circles); + this._polyline.addLatLng(latlng); + if(this._latlngs.length && !latlng.equals(this._latlngs[this._latlngs.length - 1])){ + this._drawTooltip(latlng, point, false); + } + this._latlngs.push(latlng); + }, + _moving: function (e) { + if (this._latlngs.length) { + let lastCLick = this._latlngs[this._latlngs.length - 1]; + if (!this._tempLine) this._tempLine = L.polyline([], this.options.lineStyle).addTo(this._map); + if (!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, true); + L.DomEvent.off(this._container, 'click', this._toggleMeasure, this); + } + }, + _escape: function (e) { + if (e.keyCode === 27) { + if (this._latlngs.length) { + this._closePath(); + } else { + this._enabled = true; + this._toggleMeasure(); + } + } + }, + _calculateBearing: function (start, end) { + const toRad = L.DomUtil.DEG_TO_RAD; + const toDeg = (this.options.angleUnit.factor / 2) / Math.PI; + let y = Math.sin((end.lng - start.lng) * toRad) * Math.cos(end.lat * toRad); + 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); + return (Math.atan2(y, x) * toDeg + this.options.angleUnit.factor) % this.options.angleUnit.factor; + }, + _closePath: function () { + if (this._tempLine) { + this._tempLine.remove(); + this._tempLine = null; + } + if (this._tempPoint) { + this._tempPoint.remove(); + this._tempLine = null; + } + if (this._latlngs.length <= 1) { + this._circles.remove(); + } + this._enabled = false; + L.DomEvent.on(this._container, 'click', this._toggleMeasure, this); + this._toggleMeasure(); + }, +}); +L.control.ruler = function (options) { + return new L.Control.Ruler(options); +}; \ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/libs/leaflet-ruler.min.css b/assets/static/vendor/leaflet-elevation/libs/leaflet-ruler.min.css new file mode 100644 index 0000000..9532825 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/libs/leaflet-ruler.min.css @@ -0,0 +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} \ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/libs/leaflet-ruler.min.js b/assets/static/vendor/leaflet-elevation/libs/leaflet-ruler.min.js new file mode 100644 index 0000000..e732020 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/libs/leaflet-ruler.min.js @@ -0,0 +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)}; \ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/src/components/chart.js b/assets/static/vendor/leaflet-elevation/src/components/chart.js new file mode 100644 index 0000000..b0b5458 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/components/chart.js @@ -0,0 +1,783 @@ +import * as D3 from './d3.js'; + +const _ = L.Control.Elevation.Utils; + +export var Chart = L.Control.Elevation.Chart = L.Class.extend({ + + includes: L.Evented ? L.Evented.prototype : L.Mixin.Events, + + initialize(opts, control) { + this.options = opts; + this.control = control; + + this._data = control._data || []; + + // cache registered components + this._props = { + scales : {}, + paths : {}, + areas : {}, + grids : {}, + axes : {}, + legendItems : {}, + tooltipItems: {}, + }; + + this._scales = {}; + this._domains = {}; + this._ranges = {}; + this._paths = {}; + + this._brushEnabled = opts.dragging; + this._zoomEnabled = opts.zooming; + + opts.xTicks = this._xTicks(); + opts.yTicks = this._yTicks(); + + let chart = this._chart = D3.Chart(opts); + + // SVG Container + this._container = chart.svg; + + // Panes + this._grid = chart.pane('grid'); + this._area = chart.pane('area'); + this._point = chart.pane('point'); + this._axis = chart.pane('axis'); + this._legend = chart.pane('legend'); + this._tooltip = chart.pane('tooltip'); + this._ruler = chart.pane('ruler'); + + // Scales + this._initScale(); + + // Helpers + this._mask = chart.get('mask'); + this._context = chart.get('context'); + this._brush = chart.get('brush'); + + this._zoom = d3.zoom(); + this._drag = d3.drag(); + + // Interactions + this._initInteractions(); + + // svg.on('resize', (e)=>console.log(e.detail)); + + // Handle multi-track segments + this._maskGaps = []; + control.on('eletrack_added', ({index}) => { + this._maskGaps.push(index); + control.once('elepoint_added', ({index}) => this._maskGaps.push(index)); + }); + + }, + + update(props) { + if (props) { + if (props.data) this._data = props.data; + if (props.options) this.options = props.options; + } + + this.options.xTicks = this._xTicks(); + this.options.yTicks = this._yTicks(); + + this._updateScale(); + this._updateAxis(); + this._updateMargins(); + this._updateLegend(); + this._updateClipper(); + this._updateArea(); + + return this; + }, + + render() { + return container => container.append(() => this._container.node()); + }, + + clear() { + this._resetDrag(); + this._hideDiagramIndicator() + this._area.selectAll('path').attr("d", "M0 0"); + this._context.clearRect(0, 0, this._width(), this._height()); + this._mask.selectAll(".gap").remove(); + this._maskGaps = []; + + // if (this._path) { + // this._x.domain([0, 1]); + // this._y.domain([0, 1]); + // } + }, + + _drawPath(name) { + let path = this._paths[name]; + let area = this._props.areas[name]; + + path.datum(this._data).attr("d", + D3.Area( + L.extend({}, area, { + width : this._width(), + height : this._height(), + scaleX : this._scales[area.scaleX], + scaleY : this._scales[area.scaleY] + }) + ) + ); + + if (!path.classed('leaflet-hidden')) { + this.options.preferCanvas + ? _.drawCanvas(this._context, path) + : _.append(this._area.node(), path.node()); + } + }, + + _hasActiveLayers() { + const paths = this._paths; + for (var i in paths) { + if (!paths[i].classed('leaflet-hidden')) { + return true; + } + } + return false; + }, + + /** + * Initialize "d3-brush". + */ + _initBrush(e) { + const brush = ({selection}) => { + if (selection && this._data.length) { + let start = this._findIndexForXCoord(selection[0]); + let end = this._findIndexForXCoord(selection[1]); + this.fire('dragged', { dragstart: this._data[start], dragend: this._data[end] }); + } + } + + const focus = (e) => { + if (this._data.length && (e.type != 'brush' /*|| e.sourceEvent*/)) { + let rect = this._chart.panes.brush.select('.overlay').node(); + let coords = d3.pointers(e, rect)[0]; + let xCoord = _.clamp(coords[0], [0, this._width()]); // Clamps cursor position to chart's bounds (see issues: #270, #301) + let item = this._data[this._findIndexForXCoord(xCoord)]; + + this.fire("mouse_move", { item: item, xCoord: xCoord }); + } + }; + + this._brush + .filter(({shiftKey, button}) => !shiftKey && !button && this._brushEnabled) + .on("end.update", brush) + .on("brush.update", focus); + + this._chart.panes.brush + .on("mouseenter.focus touchstart.focus", this.fire.bind(this, "mouse_enter")) + .on("mouseout.focus touchend.focus", this.fire.bind(this, "mouse_out") ) + .on("mousemove.focus touchmove.focus", focus ); + + }, + + /** + * Initialize "d3-zoom" + */ + _initClipper() { + let svg = this._container; + let margin = this.options.margins; + + const zoom = this._zoom; + + const onStart = ({transform, sourceEvent}) => { + if (sourceEvent && sourceEvent.type == "mousedown") svg.style('cursor', 'grabbing'); + if (transform.k == 1 && transform.x == 0) { + this._container.classed('zoomed', true); + // Apply d3-zoom and bind + if (this._mask) { + this._point.attr('mask', 'url(#' + this._mask.attr('id') + ')'); + } + } + this.zooming = true; + }; + + const onEnd = ({transform}) => { + if (transform.k ==1 && transform.x == 0) { + this._container.classed('zoomed', false); + // Reset d3-zoom and remove + if (this._mask) { + this._point.attr('mask', null); + } + } + this.zooming = false; + svg.style('cursor', ''); + }; + + const onZoom = ({transform, sourceEvent}) => { + // TODO: find a faster way to redraw the chart. + this.zooming = false; + this._updateScale(); // hacky way for restoring x scale when zooming out + this.zooming = true; + this._scales.distance = this._x = transform.rescaleX(this._x); // calculate x scale at zoom level + if (this._scales.time) this._scales.time = transform.rescaleX(this._scales.time); // calculate x scale at zoom level + this._resetDrag(); + if (sourceEvent && sourceEvent.type == "mousemove") { + this._hideDiagramIndicator(); + } + this.fire('zoom'); + }; + + zoom + .scaleExtent([1, 10]) + .extent([ + [margin.left, 0], + [this._width() - margin.right, this._height()] + ]) + .translateExtent([ + [margin.left, -Infinity], + [this._width() - margin.right, Infinity] + ]) + .filter(({shiftKey, buttons}) => (shiftKey || buttons == 4) && this._zoomEnabled) + .on("start", onStart) + .on("end", onEnd) + .on("zoom", onZoom); + + svg.call(zoom); // add zoom functionality to "svg" group + + // d3.select("body").on("keydown.grabzoom keyup.grabzoom", (e) => svg.style('cursor', e.shiftKey ? 'move' : '')); + + }, + + _initInteractions() { + this._initBrush(); + this._initRuler(); + this._initClipper(); + this._initLegend(); + }, + + /** + * Toggle chart data on legend click + */ + _initLegend() { + this._container.on('legend_clicked', ({detail}) => { + let { path, legend, name, enabled } = detail; + if (path) { + let label = _.select('text', legend); + let rect = _.select('rect', legend); + _.toggleStyle(label, 'text-decoration-line', 'line-through', enabled); + _.toggleStyle(rect, 'fill-opacity', '0', enabled); + _.toggleClass(path, 'leaflet-hidden', enabled); + this._updateArea(); + this.fire("elepath_toggle", { path, name, legend, enabled }) + } + }); + }, + + /** + * Initialize "ruler". + */ + _initRuler() { + if (!this.options.ruler) return; + + // const yMax = this._height(); + const formatNum = d3.format(".0f"); + const drag = this._drag; + + const label = (e, d) => { + let yMax = this._height(); + let y = this._ruler.data()[0].y; + if (y >= yMax || y <= 0) this._ruler.select(".horizontal-drag-label").text(''); + this._hideDiagramIndicator(); + }; + + const position = (e, d) => { + let yCoord = d3.pointers(e, this._area.node())[0][1]; + let yMax = this._height(); + let y = _.clamp(yCoord, [0, yMax]); + + this._ruler + .data([L.extend(this._ruler.data()[0], { y: y })]) + .attr("transform", d => "translate(" + d.x + "," + d.y + ")") + .classed('active', y < yMax); + + this._container + .select(".horizontal-drag-label") + .text(formatNum(this._y.invert(y)) + " " + (this.options.imperial ? 'ft' : 'm')); + + this.fire('ruler_filter', { coords: yCoord < yMax && yCoord > 0 ? this._findCoordsForY(yCoord) : [] }); + } + + drag + .on("start end", label) + .on("drag", position); + + this._ruler.call(drag); + + }, + + /** + * Initialize x and y scales + */ + _initScale() { + let opts = this.options; + + this._registerAxisScale({ + axis : 'x', + position: 'bottom', + attr : opts.xAttr, + min : opts.xAxisMin, + max : opts.xAxisMax, + name : 'distance' + }); + + this._registerAxisScale({ + axis : 'y', + position : 'left', + attr : opts.yAttr, + min : opts.yAxisMin, + max : opts.yAxisMax, + name : 'altitude' + }); + + this._x = this._scales.distance; + this._y = this._scales.altitude; + }, + + _registerAreaPath(props) { + if (props.scale == 'y') props.scale = this._y; + else if (props.scale == 'x') props.scale = this._x; + + let opts = this.options; + + if (!props.xAttr) props.xAttr = opts.xAttr; + if (!props.yAttr) props.yAttr = opts.yAttr; + if (typeof props.preferCanvas === "undefined") props.preferCanvas = opts.preferCanvas; + + // Save paths in memory for latter usage + this._paths[props.name] = D3.Path(props); + this._props.areas[props.name] = props; + + if (opts.legend) { + this._props.legendItems[props.name] = { + name : props.name, + label : props.label, + color : props.color, + className: props.className, + path : this._paths[props.name] + }; + } + + }, + + _registerAxisGrid(props) { + if (props.scale == 'y') props.scale = this._y; + else if (props.scale == 'x') props.scale = this._x; + + this._props.grids[props.name || props.axis] = props; + }, + + _registerAxisScale(props) { + if (props.scale == 'y') props.scale = this._y; + else if (props.scale == 'x') props.scale = this._x; + + let opts = this.options; + let scale = props.scale; + + if (typeof this._scales[props.name] === 'function') { + props.scale = this._scales[props.name]; // retrieve cached scale + } else if (typeof scale !== 'function') { + scale = L.extend({ + data : this._data, + forceBounds: opts.forceAxisBounds + }, scale); + + scale.attr = scale.attr || props.name; + + let domain = this._domains[props.name] = D3.Domain(props); + let range = this._ranges[props.name] = D3.Range(props); + scale.range = scale.range || range(this._width(),this._height()); + scale.domain = scale.domain || domain(this._data); + + this._props.scales[props.name] = scale; + + props.scale = this._scales[props.name] = D3.Scale(scale); + } + + if (!props.ticks) { + if (props.axis == 'x') props.ticks = this._xTicks.bind(this); + else if (props.axis == 'y') props.ticks = this._yTicks.bind(this); + } + + this._props.axes[props.name] = props; + + if (props.name == this.options.yScale) this._y = scale; + if (props.name == this.options.xScale) this._x = scale; + + return scale; + }, + + /** + * Add a point of interest over the chart + */ + _registerCheckPoint(point) { + if (!this._data.length) return; + + const {xAttr, yAttr} = this.options; + let item, x, y; + + if (point.latlng) { + item = this._data[this._findIndexForLatLng(point.latlng)]; + x = this._x(item[xAttr]); + y = this._y(item[yAttr]); + } else if (!isNaN(point[xAttr])) { + x = this._x(point[xAttr]); + item = this._data[this._findIndexForXCoord(x)] + y = this._y(item[yAttr]); + } + + this._point.call(D3.CheckPoint({ + point: point, + width: this._width(), + height: this._height(), + x: x, + y: y, + })); + }, + + _registerTooltip(props) { + props.order = props.order ?? 1000; + this._props.tooltipItems[props.name] = props; + }, + + _updateArea() { + // Reset and update chart profiles + this._context.clearRect(0, 0, this._width(), this._height()); + _.each(this._paths, (path, i) => !path.classed('leaflet-hidden') && this._drawPath(i)); + }, + + _updateAxis() { + + let opts = this.options; + + const gridOpts = { + width : this._width(), + height : this._height(), + tickFormat: "", + }; + + const axesOpts = { + width : this._width(), + height : this._height(), + }; + + // Reset grids + this._grid.selectAll('g').remove(); + _.each(this._props.grids, (grid, i) => { + if (opts[i] !== false && opts[i] !== 'summary') { + this._grid.call(D3.Grid(L.extend({}, gridOpts, grid))) + } + }); + + // Rest axis + this._axis.selectAll('g').remove(); + _.each(this._props.axes, (axis, i) => { + if (opts[i] !== false && opts[i] !== 'summary') { + this._axis.call(D3.Axis(L.extend({}, axesOpts, axis))); + } + }); + + // Adjust axis scale positions + this._axis + .selectAll('.y.axis.right') + .each((d, i, n) => { + + let axis = d3.select(n[i]); + let transform = axis.attr('transform'); + let translate = transform.substring(transform.indexOf("(") + 1, transform.indexOf(")")).split(","); + + axis.attr('transform', 'translate(' + (+translate[0] + (i * 40)) + ',' + translate[1] + ')') + + if (i > 0) { + axis.select(':scope > path') .attr('opacity', 0.25); + axis.selectAll(':scope > .tick line').attr('opacity', 0.75); + } + + }); + + }, + + _updateClipper() { + const { xAttr, margins } = this.options; + const data = this._data; + + this._zoom + .scaleExtent([1, 10]) + .extent([ + [margins.left, 0], + [this._width() - margins.right, this._height()] + ]) + .translateExtent([ + [margins.left, -Infinity], + [this._width() - margins.right, Infinity] + ]); + + // Apply svg mask on multi-track segments + this._mask.selectAll(".gap").remove() + this._maskGaps.forEach((d, i) => { + if (i >= this._maskGaps.length - 2) return; + let x1 = this._x(data[this._findIndexForLatLng(data[this._maskGaps[i]].latlng)][xAttr]); + let x2 = this._x(data[this._findIndexForLatLng(data[this._maskGaps[i + 1]].latlng)][xAttr]); + this._mask + .append("rect") + .attr("x", x1) + .attr("y", 0) + .attr("width", x2 - x1 ) + .attr("height", this._height()) + .attr('class', 'gap') + .attr('fill-opacity', '0.8') + .attr("fill", 'black'); // hide = black (mask) + }); + }, + + _updateLegend: function () { + let xAxesB = this._axis.selectAll('.x.axis.bottom').nodes().length; + + // Get legend items + let items = Object.keys(this._paths); + + // Calculate legend item positions + let n = items.length; + let v = Array(Math.floor(n / 2)).fill(null).map((d, i) => (i + 1) * 2 - (1 - Math.sign(n % 2))); + let rev = v.slice().reverse().map((d) => -(d)); + + // push a fake element to handle center alignment (odd numbers) + if (n % 2 !== 0) { + rev.push(0); + } + v = rev.concat(v); + + // Reset legend items + this._legend.selectAll('g').remove(); + + // Render legend items + _.each(this._props.legendItems, (legend) => { + this._legend.append("g").call( + D3.LegendItem(L.extend({ + width : this._width(), + height : this._height(), + margins: this.options.margins, + }, legend)) + ); + }); + + // Render legend item switcher + if (n > 1) { + this._legend.append("g").call( + D3.LegendSmall({ + width : this._width(), + height : this._height(), + items : items, + onClick: (selected) => { + _.each(items, name => this._togglePath(name, selected == name, true)); + this._updateArea(); + } + }) + ); + } + + _.each(items, (name, i) => { + // Adjust legend item positions + this._legend.select('[data-name=' + name + ']').attr("transform", "translate(" + (v[i] * 55) + ", " + (xAxesB * 2) + ")"); + // Set initial state (disabled controls) + this._togglePath(name, !(name in this.options && this.options[name] == 'disabled'), true); + }); + + }, + + _updateMargins() { + // Get chart margins + let xAxesB = this._axis.selectAll('.x.axis.bottom').nodes().length; + let xAxesL = this._axis.selectAll('.y.axis.left').nodes().length; + let xAxesR = this._axis.selectAll('.y.axis.right').nodes().length; + let marginB = 60 + (xAxesB * 2); + let marginL = 10 + (xAxesL * 30); + let marginR = 40 + (xAxesR * 30); + + let marginsUpdated = false + + // Adjust right margin + if (xAxesR && this.options.margins.right < marginR) { + this.options.margins.right = marginR; + marginsUpdated = true; + } + + // Adjust left margin + if (xAxesL && this.options.margins.left < marginL) { + this.options.margins.left = marginL; + marginsUpdated = true; + } + + // Adjust bottom margin + if (xAxesB && this.options.margins.bottom < marginB) { + this.options.margins.bottom = marginB; + marginsUpdated = true; + } + + if (marginsUpdated) { + this.fire('margins_updated'); + } + }, + + _updateScale() { + if (this.zooming) return { x: this._x, y: this._y }; + + for (let i in this._scales) { + this._scales[i] + .domain(this._domains[i](this._data)) + .range(this._ranges[i](this._width(), this._height())) + } + + return { x: this._x, y: this._y }; + }, + + /** + * Calculates chart width. + */ + _width() { + if (this._chart) this._chart._width; + const { width, margins } = this.options; + return width - margins.left - margins.right; + }, + + /** + * Calculates chart height. + */ + _height() { + if (this._chart) return this._chart._height; + const { height, margins } = this.options; + return height - margins.top - margins.bottom; + }, + + /* + * Finds data entries above a given y-elevation value and returns geo-coordinates + */ + _findCoordsForY(y) { + let data = this._data; + let z = this._y.invert(y); + + // save indexes of elevation values above the horizontal line + const list = data.reduce((array, item, index) => { + if (item[this.options.yAttr] >= z) array.push(index); + return array; + }, []); + + let start = 0; + let next; + + // split index list into blocks of coordinates + const coords = list.reduce((array, _, curr) => { + next = curr + 1; + if (list[next] !== list[curr] + 1 || next === list.length) { + array.push( + list + .slice(start, next) + .map(i => data[i].latlng) + ); + start = next; + } + return array; + }, []); + + return coords; + }, + + /* + * Finds a data entry for a given x-coordinate of the diagram + */ + _findIndexForXCoord(x) { + return d3 + .bisector(d => d[this.options.xAttr]) + .left(this._data || [0, 1], this._x.invert(x)); + }, + + /* + * Finds a data entry for a given latlng of the map + */ + _findIndexForLatLng(latlng) { + let result = null; + let d = Infinity; + this._data.forEach((item, index) => { + let dist = latlng.distanceTo(item.latlng); + if (dist < d) { + d = dist; + result = index; + } + }); + return result; + }, + + /* + * Removes the drag rectangle and zoms back to the total extent of the data. + */ + _resetDrag() { + if (this._chart.panes.brush.select(".selection").attr('width')) { + this._chart.panes.brush.call(this._brush.clear); + this._hideDiagramIndicator(); + this.fire('reset_drag'); + } + }, + + _resetZoom() { + if (this._zoom) { + this._zoom.transform(this._chart.svg, d3.zoomIdentity); + } + }, + + /** + * Display distance and altitude level ("focus-rect"). + */ + _showDiagramIndicator(item, xCoordinate) { + this._tooltip + .attr("display", null) + .call(D3.Tooltip({ + xCoord: xCoordinate, + yCoord: this._y(item[this.options.yAttr]), + height: this._height(), + width : this._width(), + labels: this._props.tooltipItems, + item: item + })); + }, + + _togglePath(name, enabled = true, lazy = false) { + let path = this._paths[name]; + let legend = this._container.select('.legend [data-name=' + name + ']'); + path.classed('leaflet-hidden', !enabled); + legend.select('text').style('text-decoration-line', enabled ? '' : 'line-through'); + legend.select('rect').style('fill-opacity', enabled ? '': '0'); + // Apply d3-zoom (bind mask) + if (this._mask) { + path.attr('mask', 'url(#' + this._mask.attr('id') + ')'); + } + if (!lazy) { + this._updateArea(); + } + }, + + _hideDiagramIndicator() { + this._tooltip.attr("display", 'none'); + }, + + /** + * Calculate chart xTicks + */ + _xTicks() { + if (this.__xTicks) this.__xTicks = this.options.xTicks; + return this.__xTicks || Math.round(this._width() / 75); +}, + + /** + * Calculate chart yTicks + */ + _yTicks() { + if (this.__yTicks) this.__yTicks = this.options.yTicks; + return this.__yTicks || Math.round(this._height() / 30); + } +}); diff --git a/assets/static/vendor/leaflet-elevation/src/components/d3.js b/assets/static/vendor/leaflet-elevation/src/components/d3.js new file mode 100644 index 0000000..2c35d57 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/components/d3.js @@ -0,0 +1,649 @@ +export const Area = ({ + width, + height, + xAttr, + yAttr, + scaleX, + scaleY, + interpolation = "curveLinear" +}) => { + return d3.area() + .curve(typeof interpolation === 'string' ? d3[interpolation] : interpolation) + .x(d => (d.xDiagCoord = scaleX(d[xAttr]))) + .y0(height) + .y1(d => scaleY(d[yAttr])); +}; + +export const Path = ({ + name, + color, + strokeColor, + strokeOpacity, + fillOpacity, + className = '' +}) => { + let path = d3.create('svg:path') + + if (name) path.classed(name + ' ' + className, true); + + path.style("pointer-events", "none"); + + path + .attr("fill", color || '#3366CC') + .attr("stroke", strokeColor || '#000') + .attr("stroke-opacity", strokeOpacity || '1') + .attr("fill-opacity", fillOpacity || '0.8'); + + return path; +}; + +export const Axis = ({ + type = "axis", + tickSize = 6, + tickPadding = 3, + position, + height, + width, + axis, + scale, + ticks, + tickFormat, + label, + labelX, + labelY, + name = "", + onAxisMount, +}) => { + return g => { + let [w, h] = [0, 0]; + if (position == "bottom") h = height; + if (position == "right") w = width; + + if (axis == "x" && type == "grid") { + tickSize = -height; + } else if (axis == "y" && type == "grid") { + tickSize = -width; + } + + let axisScale = d3["axis" + position.replace(/\b\w/g, l => l.toUpperCase())]() + .scale(scale) + .ticks(typeof ticks === 'function' ? ticks() : ticks) + .tickPadding(tickPadding) + .tickSize(tickSize) + .tickFormat(tickFormat); + + let axisGroup = g.append("g") + .attr("class", [axis, type, position, name].join(" ")) + .attr("transform", "translate(" + w + "," + h + ")") + .call(axisScale); + + if (label) { + axisGroup.append("svg:text") + .attr("x", labelX) + .attr("y", labelY) + .text(label); + } + + if (onAxisMount) { + axisGroup.call(onAxisMount); + } + + return axisGroup; + }; +}; + +export const Grid = (props) => { + props.type = "grid"; + return Axis(props); +}; + +export const PositionMarker = ({ + theme, + xCoord = 0, + yCoord = 0, + labels = {}, + item = {}, + length = 0, +}) => { + return g => { + + g.attr("class", "height-focus-group"); + + let line = g.select('.height-focus.line'); + let circle = g.select('.height-focus.circle-lower'); + let text = g.select('.height-focus-label'); + + if (!line.node()) line = g.append('svg:line'); + if (!circle.node()) circle = g.append('svg:circle'); + if (!text.node()) text = g.append('svg:text'); + + if (isNaN(xCoord) || isNaN(yCoord)) return g; + + circle + .attr("class", theme + " height-focus circle-lower") + .attr("transform", "translate(" + xCoord + "," + yCoord + ")") + .attr("r", 6) + .attr("cx", 0) + .attr("cy", 0); + + line + .attr("class", theme + " height-focus line") + .attr("x1", xCoord) + .attr("x2", xCoord) + .attr("y1", yCoord) + .attr("y2", length); + + text + .attr("class", theme + " height-focus-label") + .style("pointer-events", "none") + .attr("x", xCoord + 5) + .attr("y", length); + + let label; + + Object + .keys(labels) + .sort((a, b) => labels[a].order - labels[b].order) // TODO: any performance issues? + .forEach((i)=> { + label = text.select(".height-focus-" + labels[i].name); + + if (!label.size()) { + label = text.append("svg:tspan") + .attr("class", "height-focus-" + labels[i].name /*+ " " + "order-" + labels[i].order*/) + .attr("dy", "1.5em"); + } + + label.text(typeof labels[i].value !== "function" ? labels[i].value : labels[i].value(item)); + + }); + + text.select('tspan').attr("dy", text.selectAll('tspan').size() > 1 ? "-1.5em" : "0em" ); + text.selectAll('tspan').attr("x", xCoord + 5); + + return g; + } +}; + +export const LegendItem = ({ + name, + label, + width, + height, + margins = {}, + color, + path, + className = '' +}) => { + return g => { + g + .attr("class", "legend-item legend-" + name.toLowerCase()) + .attr("data-name", name); + + g.on('click.legend', () => d3.select(g.node().ownerSVGElement || g) + .dispatch("legend_clicked", { + detail: { + path: path.node(), + name: name, + legend: g.node(), + enabled: !path.classed('leaflet-hidden'), + } + }) + ); + + g.append("svg:rect") + .attr("x", (width / 2) - 50) + .attr("y", height + margins.bottom / 2) + .attr("width", 50) + .attr("height", 10) + .attr("fill", color) + .attr("stroke", "#000") + .attr("stroke-opacity", "0.5") + .attr("fill-opacity", "0.25") + .attr("class", className); + + g.append('svg:text') + .text(L._(label || name)) + .attr("x", (width / 2) + 5) + .attr("font-size", 10) + .style("font-weight", "700") + .attr('y', height + margins.bottom / 2) + .attr('dy', "0.75em"); + + return g; + } +}; + +export const LegendSmall = ({ + width, + height, + items, + onClick +}) => { + return g => { + let idx = 0; + + g.data([{ + x: width, + y: height + 40 + }]).attr("transform", d => "translate(" + d.x + "," + d.y + ")").classed('legend-switcher'); + + // let label = g.selectAll(".legend-switcher-label") .data([{ idx: 0 }]); + let label = g.append('svg:text') + .attr("class", "legend-switcher-label") + .attr("text-anchor", "end") + .attr("x", -25) + .attr("y", 5) + .on("mousedown", (e, d) => setIdx(L.Util.wrapNum((idx + 1), [0, items.length]))); + + let symbol = g.selectAll(".legend-switcher-symbol").data([ + { type: d3.symbolTriangle, x: 0, y: 3, angle: 0, size: 50, id: "down" }, + { type: d3.symbolTriangle, x: -13, y: 0, angle: 180, size: 50, id: "up" } + ]); + + symbol.exit().remove(); + label.exit().remove(); + + symbol.enter() + .append("svg:path") + .attr("class", "legend-switcher-symbol") + .attr("cursor", 'pointer') + .attr("fill", "#000") + .merge(symbol) + .attr("d", + d3.symbol() + .type(d => d.type) + .size(d => d.size) + ) + .attr("transform", d => "translate(" + d.x + "," + d.y + ") rotate(" + d.angle + ")") + .on("mousedown", (e, d) => setIdx(L.Util.wrapNum((d.id === "up" ? idx + 1 : idx - 1), [0, items.length]))); + + const setIdx = (id) => { + idx = id; + // label.enter() + // .append('text') + // .attr("class", "legend-switcher-label") + // .attr("text-anchor", "end") + // .attr("x", -25) + // .attr("y", 5) + // .merge(label.data([{ idx: id }])) + // .text(d => items.length ? (items[idx][0].toUpperCase() + items[idx].slice(1)) : '') + // .on("mousedown", (e, d) => setIdx(L.Util.wrapNum((idx + 1), [0, items.length]))); + label.text(items.length ? L._((items[idx][0].toUpperCase() + items[idx].slice(1))) : ''); + onClick(items[idx]); + }; + setIdx(0); + + return g; + }; +}; + +export const Tooltip = ({ + xCoord, + yCoord, + width, + height, + labels = {}, + item = {}, +}) => { + return g => { + + let line = g.select('.mouse-focus-line'); + let box = g.select('.mouse-focus-label'); + + if (!line.node()) line = g.append('svg:line'); + if (!box.node()) box = g.append("g"); + + let rect = box.select(".mouse-focus-label-rect"); + let text = box.select(".mouse-focus-label-text"); + + if (!rect.node()) rect = box.append("svg:rect"); + if (!text.node()) text = box.append("svg:text"); + + // Sets focus-label-text position to the left / right of the mouse-focus-line + let xAlign = 0; + let yAlign = 0; + let bbox = { width: 0, height: 0 }; + try { bbox = text.node().getBBox(); } catch (e) { return g; } + + if (xCoord) xAlign = xCoord + (xCoord < width / 2 ? 10 : -bbox.width - 10); + if (yCoord) yAlign = Math.max(yCoord - bbox.height, L.Browser.webkit ? 0 : -Infinity); + + line + .attr('class', 'mouse-focus-line') + .attr('x2', xCoord) + .attr('y2', 0) + .attr('x1', xCoord) + .attr('y1', height); + + box + .attr('class', 'mouse-focus-label'); + + rect + .attr("class", "mouse-focus-label-rect") + .attr("x", xAlign - 5) + .attr("y", yAlign - 5) + .attr("width", bbox.width + 10) + .attr("height", bbox.height + 10) + .attr("rx", 3) + .attr("ry", 3); + + text + .attr("class", "mouse-focus-label-text") + .style("font-weight", "700") + .attr("y", yAlign); + + let label; + + Object + .keys(labels) + .sort((a, b) => labels[a].order - labels[b].order) // TODO: any performance issues? + .forEach((i)=> { + label = text.select(".mouse-focus-label-" + labels[i].name); + + if (!label.size()) { + label = text.append("svg:tspan", ".mouse-focus-label-x") + .attr("class", "mouse-focus-label-" + labels[i].name /*+ " " + "order-" + labels[i].order*/) + .attr("dy", "1.5em"); + } + + label.text(typeof labels[i].value !== "function" ? labels[i].value : labels[i].value(item)); + }); + + text.select('tspan').attr("dy", "1em"); + text.selectAll('tspan').attr("x", xAlign); + + return g; + }; +}; + +export const Ruler = ({ + height, + width, +}) => { + return g => { + + g.data([{ + x: 0, + y: height, + }]) + .attr("transform", d => "translate(" + d.x + "," + d.y + ")"); + + let rect = g.selectAll('.horizontal-drag-rect').data([{ w: width }]); + let line = g.selectAll('.horizontal-drag-line').data([{ w: width }]); + let label = g.selectAll('.horizontal-drag-label').data([{ w: width - 8 }]); + let symbol = g.selectAll('.horizontal-drag-symbol') + .data([{ + type: d3.symbolTriangle, + x: width + 7, + y: 0, + angle: -90, + size: 50 + }]); + + rect.exit().remove(); + line.exit().remove(); + label.exit().remove(); + symbol.exit().remove(); + + rect.enter() + .append("svg:rect") + .attr("class", "horizontal-drag-rect") + .attr("x", 0) + .attr("y", -8) + .attr("height", 8) + .attr('fill', 'none') + .attr('pointer-events', 'all') + .merge(rect) + .attr("width", d => d.w); + + line.enter() + .append("svg:line") + .attr("class", "horizontal-drag-line") + .attr("x1", 0) + .merge(line) + .attr("x2", d => d.w); + + label.enter() + .append("svg:text") + .attr("class", "horizontal-drag-label") + .attr("text-anchor", "end") + .attr("y", -8) + .merge(label) + .attr("x", d => d.w) + + symbol + .enter() + .append("svg:path") + .attr("class", "horizontal-drag-symbol") + .merge(symbol) + .attr("d", + d3.symbol() + .type(d => d.type) + .size(d => d.size) + ) + .attr("transform", d => "translate(" + d.x + "," + d.y + ") rotate(" + d.angle + ")"); + + return g; + } +}; + +export const CheckPoint = ({ + point, + height, + width, + x, + y +}) => { + return g => { + + if (isNaN(x) || isNaN(y)) return g; + + if (!point.item || !point.item.property('isConnected')) { + point.position = point.position || "bottom"; + + point.item = g.append('g'); + + point.item.append("svg:line") + .attr("y1", 0) + .attr("x1", 0) + .attr("style","stroke: rgb(51, 51, 51); stroke-width: 0.5; stroke-dasharray: 2, 2;"); + + point.item + .append("svg:circle") + .attr("class", " height-focus circle-lower") + .attr("r", 3); + + if (point.label) { + point.item.append("svg:text") + .attr("dx", "4px") + .attr("dy", "-4px"); + } + } + + point.item + .datum({ + pos: point.position, + x: x, + y: y + }) + .attr("class", d => "point " + d.pos) + .attr("transform", d => "translate(" + d.x + "," + d.y + ")"); + + point.item.select('line') + .datum({ + y2: ({'top': -y, 'bottom': height - y})[point.position], + x2: ({'left': -x, 'right': width - x})[point.position] || 0 + }) + .attr("y2", d => d.y2) + .attr("x2", d => d.x2) + + if (point.label) { + point.item.select('text') + .text(point.label); + } + + return g; + } +}; + +export const Domain = ({ + min, + max, + attr, + name, + forceBounds, + scale +}) => function(data) { + attr = (scale && scale.attr) || attr || name; + let domain = data && data.length ? d3.extent(data, d => d[attr]) : [0, 1]; + if (typeof min !== "undefined" && (min < domain[0] || forceBounds)) { + domain[0] = min; + } + if (typeof max !== "undefined" && (max > domain[1] || forceBounds)) { + domain[1] = max; + } + return domain; +}; + +export const Range = ({ + axis +}) => function(width, height) { + if (axis == 'x') return [0, width]; + else if (axis == 'y') return [height, 0]; +}; + +export const Scale = ({ + data, + attr, + min, + max, + forceBounds, + range, +}) => { + return d3.scaleLinear() + .range(range) + .domain(Domain({min, max, attr, forceBounds})(data)); +}; + +export const Bisect = ({ + data = [0, 1], + scale, + x, + attr +}) => { + return d3 + .bisector(d => d[attr]) + .left(data, scale.invert(x)); +}; + +export const Chart = ({ + width, + height, + margins = {}, + ruler, +}) => { + + const w = width - margins.left - margins.right; + const h = height - margins.top - margins.bottom; + + // SVG Container + const svg = d3.create("svg:svg").attr("class", "background"); + + const defs = svg.append("svg:defs"); + + // SVG Groups + const g = svg.append("g"); + const panes = { + grid : g.append("g").attr("class", "grid"), + area : g.append('g').attr("class", "area"), + axis : g.append('g').attr("class", "axis"), + point : g.append('g').attr("class", "point"), + brush : g.append("g").attr("class", "brush"), + tooltip: g.append("g").attr("class", "tooltip").attr('display', 'none'), + ruler : g.append('g').attr('class', 'ruler'), + legend : g.append('g').attr("class", "legend"), + }; + + // SVG Paths + const mask = panes.area .append("svg:mask") .attr("id", 'elevation-clipper-' + Math.random().toString(36).substr(2, 9)).attr('fill-opacity', 1); + const maskRect = mask .append("svg:rect") .attr('class', 'zoom') .attr('fill', 'white'); // white = transparent + + // Canvas Paths + const foreignObject = panes.area .append('svg:foreignObject').attr('mask', 'url(#' + mask.attr('id') + ')'); + const canvas = foreignObject.append('xhtml:canvas') .attr('class', 'canvas-plot'); + const context = canvas.node().getContext('2d'); + + // Add tooltip + panes.tooltip.call(Tooltip({ xCoord: 0, yCoord: 0, height: h, width : w, labels: {} })); + + // Add the brushing + let brush = d3.brushX().on('start.cursor end.cursor brush.cursor', () => panes.brush.select(".overlay").attr('cursor', null)); + + // Scales + const scale = (opts) => ({ x: Scale(opts.x), y: Scale(opts.y)}); + + let utils = { + defs, + mask, + canvas, + context, + brush, + }; + + let chart = { + svg, + g, + panes, + utils, + scale, + }; + + // Resize + chart._resize = ({ + width, + height, + margins = {}, + ruler, + }) => { + + const w = width - margins.left - margins.right; + const h = height - margins.top - margins.bottom; + + svg .attr("width", width).attr("height", height).attr("viewBox", `0 0 ${width} ${height}`); + g .attr("transform", "translate(" + margins.left + "," + margins.top + ")"); + + // Fix: https://github.com/Raruto/leaflet-elevation/issues/123 + // Fix: https://github.com/Raruto/leaflet-elevation/issues/232 + if ( + /Mac|iPod|iPhone|iPad/.test(navigator.platform) && + /AppleWebkit/i.test(navigator.userAgent) && + !/Chrome/.test(navigator.userAgent) + ) { + canvas .style("transform", "translate(" + margins.left + "px," + margins.top + "px)"); + } + + maskRect .attr("width", w).attr("height", h).attr("x", 0).attr("y", 0); + foreignObject.attr('width', w).attr('height', h); + canvas .attr('width', w).attr('height', h); + + if (ruler) { + panes.ruler.call(Ruler({ height: h, width: w })); + } + + panes.brush.call(brush.extent( [ [0,0], [w, h] ] )); + panes.brush.select(".overlay").attr('cursor', null); + + chart._width = w; + chart._height = h; + + chart.svg.dispatch('resize', { detail: { width: w, height: h } } ); + + }; + + chart.pane = (name) => (panes[name] || (panes[name] = g.append('g').attr("class", name))); + chart.get = (name) => utils[name]; + + chart._resize({ width, height, margins}); + + return chart; +}; diff --git a/assets/static/vendor/leaflet-elevation/src/components/marker.js b/assets/static/vendor/leaflet-elevation/src/components/marker.js new file mode 100644 index 0000000..6f1f6af --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/components/marker.js @@ -0,0 +1,96 @@ +import * as D3 from './d3.js'; + +const _ = L.Control.Elevation.Utils; + +export var Marker = L.Class.extend({ + + initialize(options, control) { + this.options = options; + this.control = control + + switch(this.options.marker) { + case 'elevation-line': + // this._container = d3.create("g").attr("class", "height-focus-group"); + break; + case 'position-marker': + // this._marker = L.circleMarker([0, 0], { pane: 'overlayPane', radius: 6, fillColor: '#fff', fillOpacity:1, color: '#000', weight:1, interactive: false }); + this._marker = L.marker([0, 0], { icon: this.options.markerIcon, zIndexOffset: 1000000, interactive: false }); + break; + } + + this._labels = {}; + + return this; + }, + + addTo(map) { + this._map = map; + switch(this.options.marker) { + case 'elevation-line': this._container = d3.select(map.getPane('elevationPane')).select("svg > g").call(D3.PositionMarker({})); break; + case 'position-marker': this._marker.addTo(map, { pane: 'overlayPane' }); break; + } + return this; + }, + + /** + * Update position marker ("leaflet-marker"). + */ + update(props) { + if (props) this._props = props; + else props = this._props; + + if (!props) return; + + if (props.options) this.options = props.options; + if (!this._map) this.addTo(props.map); + + this._latlng = props.item.latlng; + + switch(this.options.marker) { + case 'elevation-line': + if (this._container) { + let point = this._map.latLngToLayerPoint(this._latlng); + point = L.extend({}, props.item, this._map._rotate ? this._map.rotatedPointToMapPanePoint(point) : point); + + let yMax = (this.control._height() / props.yCoordMax * point[this.options.yAttr]); + + if (!isFinite(yMax) || isNaN(yMax)) yMax = 0; + + this._container.classed("leaflet-hidden", false); + this._container.call(D3.PositionMarker({ + theme : this.options.theme, + xCoord: point.x, + yCoord: point.y, + length: point.y - yMax, // normalized Y + labels: this._labels, + item: point, + })); + } + break; + case 'position-marker': + _.removeClass(this._marker.getElement(), 'leaflet-hidden'); + this._marker.setLatLng(this._latlng); + break; + } + }, + + /* + * Hides the position/height indicator marker drawn onto the map + */ + remove() { + this._props = null; + switch(this.options.marker) { + case 'elevation-line': this._container && this._container.classed("leaflet-hidden", true); break; + case 'position-marker': _.addClass(this._marker.getElement(), 'leaflet-hidden'); break; + } + }, + + getLatLng() { + return this._latlng; + }, + + _registerTooltip(props) { + this._labels[props.name] = props; + } + +}); diff --git a/assets/static/vendor/leaflet-elevation/src/components/summary.js b/assets/static/vendor/leaflet-elevation/src/components/summary.js new file mode 100644 index 0000000..e8da57f --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/components/summary.js @@ -0,0 +1,43 @@ +const _ = L.Control.Elevation.Utils; + +export var Summary = L.Class.extend({ + initialize(opts, control) { + this.options = opts; + this.control = control; + this.labels = {}; + + let summary = this._container = _.create("div", "elevation-summary " + (opts.summary ? opts.summary + "-summary" : '')); + _.style(summary, 'max-width', opts.width ? opts.width + 'px' : ''); + }, + + render() { + return container => container.append(() => this._container); + }, + + reset() { + this._container.innerHTML = ''; + }, + + append(className, label, value) { + this._container.innerHTML += `${label}${value}`; + return this; + }, + + update() { + Object + .keys(this.labels) + .sort((a, b) => this.labels[a].order - this.labels[b].order) // TODO: any performance issues? + .forEach((i)=> { + let value = typeof this.labels[i].value !== "function" ? this.labels[i].value : this.labels[i].value(this.control.track_info, this.labels[i].unit || ''); + this.append(i /*+ " order-" + this.labels[i].order*/, L._(this.labels[i].label), value, this.labels[i].order); + }); + }, + + _registerSummary(data) { + for (let i in data) { + data[i].order = data[i].order ?? 1000; + this.labels[i] = data[i]; + } + } + +}); diff --git a/assets/static/vendor/leaflet-elevation/src/control.js b/assets/static/vendor/leaflet-elevation/src/control.js new file mode 100644 index 0000000..27ecf77 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/control.js @@ -0,0 +1,1335 @@ +import * as _ from './utils'; +import { Options } from './options'; + +// "leaflet-i18n" fallback +if (!L._ || !L.i18n) { + L._ = L.i18n = (string, data) => string; +} + +export const Elevation = L.Control.Elevation = L.Control.extend({ + + includes: L.Evented ? L.Evented.prototype : L.Mixin.Events, + + options: Options, + __mileFactor: 0.621371, // 1 km = (0.621371 mi) + __footFactor: 3.28084, // 1 m = (3.28084 ft) + __D3: 'https://unpkg.com/d3@7.8.4/dist/d3.min.js', + __TOGEOJSON: 'https://unpkg.com/@tmcw/togeojson@5.6.2/dist/togeojson.umd.js', + __LGEOMUTIL: 'https://unpkg.com/leaflet-geometryutil@0.10.1/src/leaflet.geometryutil.js', + __LALMOSTOVER: 'https://unpkg.com/leaflet-almostover@1.0.1/src/leaflet.almostover.js', + __LHOTLINE: '../libs/leaflet-hotline.min.js', + __LDISTANCEM: '../libs/leaflet-distance-marker.min.js', + __LEDGESCALE: '../libs/leaflet-edgescale.min.js', + __LCHART: '../src/components/chart.js', + __LMARKER: '../src/components/marker.js', + __LSUMMARY: '../src/components/summary.js', + __modulesFolder: '../src/handlers/', + __btnIcon: '../images/elevation.svg', + + /* + * Add data to the diagram either from GPX or GeoJSON and update the axis domain and data + */ + addData(d, layer) { + this.import(this.__D3) + .then(() => { + if (this._modulesLoaded) { + layer = layer ?? (d.on && d); + this._addData(d); + this._addLayer(layer); + this._fireEvt("eledata_added", { data: d, layer: layer, track_info: this.track_info }); + } else { + this.once('modules_loaded', () => this.addData(d,layer)); + } + }); + }, + + /** + * Adds the control to the given map. + */ + addTo(map) { + if (this.options.detached) { + let parent = _.select(this.options.elevationDiv); + let eleDiv = this.onAdd(map); + parent ? _.append(parent, eleDiv) : _.insert(map.getContainer(), eleDiv, 'afterend'); + } else { + L.Control.prototype.addTo.call(this, map); + } + return this; + }, + + /* + * Reset data and display + */ + clear() { + if (this._marker) this._marker.remove(); + if (this._chart) this._clearChart(); + if (this._layers) this._clearLayers(this._layers); + if (this._markers) this._clearLayers(this._markers); + if (this._circleMarkers) this._circleMarkers.remove(); + if (this._hotline) this._hotline.eachLayer(l => l.options.renderer.remove()); // hotfix for: https://github.com/Raruto/leaflet-elevation/issues/233 + if (this._hotline) this._clearLayers(this._hotline); + + this._data = []; + this.track_info = {}; + + this._fireEvt("eledata_clear"); + + this._updateChart(); + }, + + _clearChart() { + if (this._events && this._events.elechart_updated) { + this._events.elechart_updated.forEach(({fn, ctx}) => this.off('elechart_updated', fn, ctx)); + } + if (this._chart && this._chart._container) { + this._chart._container.selectAll('g.point .point').remove(); + this._chart.clear(); + } + }, + + _clearLayers(l) { + l = l || this._layers; + if (l && l.eachLayer) { + l.eachLayer(f => f.remove()) + l.clearLayers(); + } + }, + + /** + * TODO: Create a base class to handle custom data attributes (heart rate, cadence, temperature, ...) + * + * @link https://leafletjs.com/examples/extending/extending-3-controls.html#handlers + */ + // addHandler: function (name, HandlerClass) { + // if (HandlerClass) { + // let handler = this[name] = new HandlerClass(this); + // this.handlers.push(handler); + // if (this.options[name]) { + // handler.enable(); + // } + // } + // return this; + // }, + + /** + * Disable chart brushing. + */ + disableBrush() { + this._chart._brushEnabled = false; + this._resetDrag(); + }, + + /** + * Enable chart brushing. + */ + enableBrush() { + this._chart._brushEnabled = true; + }, + + /** + * Disable chart zooming. + */ + disableZoom() { + this._chart._zoomEnabled = false; + this._chart._resetZoom(); + }, + + /** + * Enable chart zooming. + */ + enableZoom() { + this._chart._zoomEnabled = true; + }, + + /** + * Sets a map view that contains the given geographical bounds. + */ + fitBounds(bounds) { + bounds = bounds || this.getBounds(); + if (this._map && bounds.isValid()) this._map.fitBounds(bounds); + }, + + getBounds(data) { + return L.latLngBounds((data || this._data).map((d) => d.latlng)); + }, + + /** + * Get default zoom level (followMarker: true). + */ + getZFollow() { + return this.options.zFollow; + }, + + /** + * Hide current elevation chart profile. + */ + hide() { + _.style(this._container, "display", "none"); + }, + + /** + * Initialize chart control "options" and "container". + */ + initialize(opts) { + + // opts = L.setOptions(this, opts); + + // Fixes: https://github.com/Raruto/leaflet-elevation/pull/240 + opts = L.setOptions(this, L.extend({}, _.cloneDeep(Options), opts)); // "deep copy" nested objects (multiple charts) + + this._data = []; + this._layers = L.featureGroup(); + this._markers = L.featureGroup(); + this._hotline = L.featureGroup(); + this._circleMarkers = L.featureGroup(); + this._markedSegments = L.polyline([]); + this._start = L.circleMarker([0,0], (opts.trkStart || Options.trkStart)); + this._end = L.circleMarker([0,0], (opts.trkEnd || Options.trkEnd)); + this._chartEnabled = true; + this._yCoordMax = -Infinity; + this.track_info = {}; + // this.handlers = []; + + if (opts.followMarker) this._setMapView = _.throttle(this._setMapView, 300, this); + if (opts.legend) opts.margins.bottom += 30; + if (opts.theme) opts.polylineSegments.className += ' ' + opts.theme; + if (opts.wptIcons === true) opts.wptIcons = Options.wptIcons; + if (opts.distanceMarkers === true) opts.distanceMarkers = Options.distanceMarkers; + if (opts.trkStart) this._start.addTo(this._circleMarkers); + if (opts.trkEnd) this._end.addTo(this._circleMarkers); + + + this._markedSegments.setStyle(opts.polylineSegments); + + // Leaflet canvas renderer colors + L.extend(_.Colors, opts.colors || {}); + + // Various stuff + this._fixCanvasPaths(); + this._fixTooltipSize(); + + }, + + /** + * Javascript scripts downloader (lazy loader) + */ + import(src, condition) { + if (Array.isArray(src)) { + return Promise.all(src.map(m => this.import(m))); + } + switch(src) { + case this.__D3: condition = typeof d3 !== 'object'; break; + case this.__TOGEOJSON: condition = typeof toGeoJSON !== 'object'; break; + case this.__LGEOMUTIL: condition = typeof L.GeometryUtil !== 'object'; break; + case this.__LALMOSTOVER: condition = typeof L.Handler.AlmostOver !== 'function'; break; + case this.__LDISTANCEM: condition = typeof L.DistanceMarkers !== 'function'; break; + case this.__LEDGESCALE: condition = typeof L.Control.EdgeScale !== 'function'; break; + case this.__LHOTLINE: condition = typeof L.Hotline !== 'function'; break; + } + return condition !== false ? import(_.resolveURL(src, this.options.srcFolder)) : Promise.resolve(); + }, + + /** + * Load elevation data (GPX, GeoJSON, KML or TCX). + */ + load(data) { + this._parseFromString(data).then( geojson => geojson ? this._loadLayer(geojson) : this._loadFile(data)); + }, + + /** + * Create container DOM element and related event listeners. + * Called on control.addTo(map). + */ + onAdd(map) { + this._map = map; + + let container = this._container = _.create("div", "elevation-control " + this.options.theme + " " + (this.options.detached ? 'elevation-detached' : 'leaflet-control'), this.options.detached ? { id: 'elevation-' + _.randomId() } : {}); + + if (!this.eleDiv) this.eleDiv = container; + + this._loadModules(this.options.handlers).then(() => { // Inject here required modules (data handlers) + this._initChart(container); + this._initButton(container); + this._initSummary(container); + this._initMarker(map); + this._initLayer(map); + this._modulesLoaded = true; + this.fire('modules_loaded'); + }); + + this.fire('add'); + + return container; + }, + + /** + * Clean up control code and related event listeners. + * Called on control.remove(). + */ + onRemove(map) { + this._container = null; + + map + .off('zoom viewreset zoomanim', this._hideMarker, this) + .off('resize', this._resetView, this) + .off('resize', this._resizeChart, this) + .off('mousedown', this._resetDrag, this); + + _.off(map.getContainer(), 'mousewheel', this._resetDrag, this); + _.off(map.getContainer(), 'touchstart', this._resetDrag, this); + _.off(document, 'keydown', this._onKeyDown, this); + + this + .off('eledata_added eledata_loaded', this._updateChart, this) + .off('eledata_added eledata_loaded', this._updateSummary, this); + + this.fire('remove'); + }, + + /** + * Redraws the chart control. Sometimes useful after screen resize. + */ + redraw() { + this._resizeChart(); + }, + + /** + * Set default zoom level (followMarker: true). + */ + setZFollow(zoom) { + this.options.zFollow = zoom; + }, + + /** + * Hide current elevation chart profile. + */ + show() { + _.style(this._container, "display", "block"); + }, + + /* + * Parsing data either from GPX or GeoJSON and update the diagram data + */ + _addData(d) { + if (!d) { + return; + } + + // Standard GeoJSON + if (d.type === "FeatureCollection" ) { + return _.each(d.features, feature => this._addData(feature)); + } else if (d.type === "Feature") { + let geom = d.geometry; + if (geom) { + switch (geom.type) { + case 'LineString': return this._addGeoJSONData(geom.coordinates, d.properties); + case 'MultiLineString': return _.each(geom.coordinates, (coords, i) => this._addGeoJSONData(coords, d.properties, i)); + case 'Point': + default: return console.warn('Unsopperted GeoJSON feature geometry type:' + geom.type); + } + } + } + + // Fallback for leaflet layers (eg. L.Gpx) + if (d._latlngs) { + return this._addGeoJSONData(d._latlngs, d.feature && d.feature.properties); + } + + }, + + /* + * Parsing of GeoJSON data lines and their elevation in z-coordinate + */ + _addGeoJSONData(coords, properties, nestingLevel) { + + // "coordinateProperties" property is generated inside "@tmcw/toGeoJSON" + let props = (properties && properties.coordinateProperties) || properties; + + coords.forEach((point, i) => { + + // GARMIN_EXTENSIONS = ["hr", "cad", "atemp", "wtemp", "depth", "course", "bearing"]; + point.meta = point.meta ?? { time: null, ele: null }; + + point.prev = (attr) => (attr ? this._data[i > 0 ? i - 1 : 0][attr] : this._data[i > 0 ? i - 1 : 0]); + + this.fire("elepoint_init", { point: point, props: props, id: i, isMulti: nestingLevel }); + + this._addPoint( + point.lat ?? point[1], + point.lng ?? point[0], + point.alt ?? point.meta.ele ?? point[2] + ); + + this.fire("elepoint_added", { point: point, index: this._data.length - 1 }); + + if (this._yCoordMax < this._data[this._data.length - 1][this.options.yAttr]) this._yCoordMax = this._data[this._data.length - 1][this.options.yAttr]; + }); + + this.fire("eletrack_added", { coords: coords, index: this._data.length - 1 }); + }, + + /* + * Parse and push a single (x, y, z) point to current elevation profile. + */ + _addPoint(x, y, z) { + if (this.options.reverseCoords) { + [x, y] = [y, x]; + } + + this._data.push({ + x: x, + y: y, + z: z, + latlng: L.latLng(x, y, z) + }); + + this.fire("eledata_updated", { index: this._data.length - 1 }); + }, + + _addLayer(layer) { + if (layer) this._layers.addLayer(layer) + // Postpone adding the distance markers (lazy: true) + if (layer && this.options.distanceMarkers && this.options.distanceMarkers.lazy) { + layer.on('add remove', ({target, type}) => L.DistanceMarkers && target instanceof L.Polyline && target[type + 'DistanceMarkers']()); + } + return layer; + }, + + _addMarker(marker) { + if (marker) this._markers.addLayer(marker) + return marker; + }, + + /** + * Initialize "L.AlmostOver" integration + */ + _initAlmostOverHandler(map, layer) { + return (map && this.options.almostOver && !L.Browser.mobile) ? this.import([this.__LGEOMUTIL, this.__LALMOSTOVER]) + .then(() => { + map.addHandler('almostOver', L.Handler.AlmostOver) + if (L.GeometryUtil && map.almostOver && map.almostOver.enabled()) { + map.almostOver.addLayer(layer); + map + .on('almost:move', this._onMouseMoveLayer, this) + .on('almost:out', this._onMouseOut, this); + this.once('eledata_clear', () => { + map.almostOver.removeLayer(layer); + map + .off('almost:move', this._onMouseMoveLayer, this) + .off('almost:out', this._onMouseOut, this); + }) + } + }) : Promise.resolve(); + }, + + /** + * Initialize "L.DistanceMarkers" integration + */ + _initDistanceMarkers() { + return this.options.distanceMarkers ? this.import([this.__LGEOMUTIL, this.__LDISTANCEM]) : Promise.resolve(); + }, + + /** + * Initialize "L.Control.EdgeScale" integration + */ + _initEdgeScale(map) { + return this.options.edgeScale ? this.import(this.__LEDGESCALE) + .then(() => { + map.edgeScaleControl = map.edgeScaleControl || L.control.edgeScale('boolean' !== typeof this.options.edgeScale ? this.options.edgeScale : {}).addTo(map); + }) : Promise.resolve(); + }, + + _initHotLine(layer) { + let prop = typeof this.options.hotline == 'string' ? this.options.hotline : 'elevation'; + return this.options.hotline ? this.import(this.__LHOTLINE) + .then(() => { + layer.eachLayer((trkseg) => { + if (trkseg.feature.geometry.type != "Point") { + let geo = L.geoJson(trkseg.toGeoJSON(), { coordsToLatLng: (coords) => L.latLng(coords[0], coords[1], coords[2] * (this.options.altitudeFactor || 1))}); + let line = L.hotline(geo.toGeoJSON().features[0].geometry.coordinates, { + renderer: L.Hotline.renderer(), + min: isFinite(this.track_info[prop + '_min']) ? this.track_info[prop + '_min'] : 0, + max: isFinite(this.track_info[prop + '_max']) ? this.track_info[prop + '_max'] : 1, + palette: { + 0.0: '#008800', + 0.5: '#ffff00', + 1.0: '#ff0000' + }, + weight: 5, + outlineColor: '#000000', + outlineWidth: 1 + }).addTo(this._hotline); + let alpha = trkseg.options.style && trkseg.options.style.opacity || 1; + trkseg.on('add remove', ({type}) => { + trkseg.setStyle({opacity: (type == 'add' ? 0 : alpha)}); + line[(type == 'add' ? 'addTo' : 'removeFrom')](trkseg._map); + if (line._renderer) line._renderer._container.parentElement.insertBefore(line._renderer._container, line._renderer._container.parentElement.firstChild); + }); + } + }); + }) : Promise.resolve(); + }, + + /** + * Initialize "L.AlmostOver" and "L.DistanceMarkers" + */ + _initMapIntegrations(layer) { + let map = this._map; + if (map) { + if (this._data.length) { + this._start.setLatLng(this._data[0].latlng); + this._end.setLatLng(this._data[this._data.length -1].latlng); + } + Promise.all([ + this._initHotLine(layer), + this._initAlmostOverHandler(map, layer), + this._initDistanceMarkers(), + this._initEdgeScale(map), + ]).then(() => { + if (this.options.polyline) { + this._layers.addLayer(layer.addTo(map)); // hotfix for: https://github.com/Raruto/leaflet-elevation/issues/233 + this._circleMarkers.addTo(map); + } + if (this.options.autofitBounds) { + this.fitBounds(layer.getBounds()); + } + map.invalidateSize(); + }); + } else { + this.once('add', () => this._initMapIntegrations(layer)); + } + }, + + /* + * Collapse current chart control. + */ + _collapse() { + _.replaceClass(this._container, 'elevation-expanded', 'elevation-collapsed'); + if (this._map) this._map.invalidateSize(); + }, + + /* + * Expand current chart control. + */ + _expand() { + _.replaceClass(this._container, 'elevation-collapsed', 'elevation-expanded'); + if (this._map) this._map.invalidateSize(); + }, + + /** + * Add some basic colors to leaflet canvas renderer (preferCanvas: true). + */ + _fixCanvasPaths() { + let oldProto = L.Canvas.prototype._fillStroke; + let control = this; + + let theme = this.options.theme.split(' ')[0].replace('-theme', ''); + let color = _.Colors[theme] || {}; + + L.Canvas.include({ + _fillStroke(ctx, layer) { + if (control._layers.hasLayer(layer)) { + + let options = layer.options; + + options.color = color.line || color.area || theme; + options.stroke = !!options.color; + + oldProto.call(this, ctx, layer); + + if (options.stroke && options.weight !== 0) { + let oldVal = ctx.globalCompositeOperation || 'source-over'; + ctx.globalCompositeOperation = 'destination-over' + ctx.strokeStyle = color.outline || '#FFF'; + ctx.lineWidth = options.weight * 1.75; + ctx.stroke(); + ctx.globalCompositeOperation = oldVal; + } + + } else { + oldProto.call(this, ctx, layer); + } + } + }); + }, + + /** + * Partial fix for initial tooltip size + * + * @link https://github.com/Raruto/leaflet-elevation/issues/81#issuecomment-713477050 + */ + _fixTooltipSize() { + this.on('elechart_init', () => + this.once('elechart_change elechart_hover', ({data, xCoord}) => { + if (this._chartEnabled) { + this._chart._showDiagramIndicator(data, xCoord); + this._chart._showDiagramIndicator(data, xCoord); + } + this._updateMarker(data); + }) + ); + }, + + /* + * Finds a data entry for the given LatLng + */ + _findItemForLatLng(latlng) { + return this._data[this._chart._findIndexForLatLng(latlng)]; + }, + + /* + * Finds a data entry for the given xDiagCoord + */ + _findItemForX(x) { + return this._data[this._chart._findIndexForXCoord(x)]; + }, + + /** + * Fires an event of the specified type. + */ + _fireEvt(type, data, propagate) { + if (this.fire) this.fire(type, data, propagate); + if (this._map) this._map.fire(type, data, propagate); + }, + + /* + * Hides the position/height indicator marker drawn onto the map + */ + _hideMarker() { + if (this.options.autohideMarker) { + this._marker.remove(); + } + }, + + /** + * Generate "svg" chart (DOM element). + */ + _initChart(container) { + let opts = this.options; + let map = this._map; + + if (opts.detached) { + let { offsetWidth, offsetHeight} = this.eleDiv; + if (offsetWidth > 0) opts.width = offsetWidth; + if (offsetHeight > 20) opts.height = offsetHeight - 20; // 20 = horizontal scrollbar size. + } else { + let { clientWidth } = map.getContainer(); + opts._maxWidth = opts._maxWidth > opts.width ? opts._maxWidth : opts.width; + this._container.style.maxWidth = opts._maxWidth + 'px'; + if (opts._maxWidth > clientWidth) opts.width = clientWidth - 30; + } + + this + .import([this.__D3, this.__LCHART]) + .then((m) => { + + let chart = this._chart = new (m[1] || Elevation).Chart(opts, this); + + this._x = this._chart._x; + this._y = this._chart._y; + + d3 + .select(container) + .call(chart.render()) + + chart + .on('reset_drag', this._hideMarker, this) + .on('mouse_enter', this._onMouseEnter, this) + .on('dragged', this._onDragEnd, this) + .on('mouse_move', this._onMouseMove, this) + .on('mouse_out', this._onMouseOut, this) + .on('ruler_filter', this._onRulerFilter, this) + .on('zoom', this._updateChart, this) + .on('elepath_toggle', this._onToggleChart, this) + .on('margins_updated', this._resizeChart, this); + + + this.fire("elechart_init"); + + map + .on('zoom viewreset zoomanim', this._hideMarker, this) + .on('resize', this._resetView, this) + .on('resize', this._resizeChart, this) + .on('rotate', this._rotateMarker, this) + .on('mousedown', this._resetDrag, this); + + _.on(map.getContainer(), 'mousewheel', this._resetDrag, this); + _.on(map.getContainer(), 'touchstart', this._resetDrag, this); + _.on(document, 'keydown', this._onKeyDown, this); + + this + .on('eledata_added eledata_loaded', this._updateChart, this) + .on('eledata_added eledata_loaded', this._updateSummary, this); + + this._updateChart(); + this._updateSummary(); + }); + + + }, + + _initLayer() { + this._layers + .on('layeradd layerremove', ({layer, type}) => { + let node = layer.getElement && layer.getElement(); + _.toggleClass(node, this.options.polyline.className + ' ' + this.options.theme, type == 'layeradd'); + _.toggleEvent(layer, "mousemove", this._onMouseMoveLayer.bind(this), type == 'layeradd') + _.toggleEvent(layer, "mouseout", this._onMouseOut.bind(this), type == 'layeradd'); + }); + }, + + _initMarker(map) { + let pane = map.getPane('elevationPane'); + if (!pane) { + pane = this._pane = map.createPane('elevationPane', map.getPane('norotatePane') || map.getPane('mapPane')); + pane.style.zIndex = 625; // This pane is above markers but below popups. + pane.style.pointerEvents = 'none'; + } + + if (this._renderer) this._renderer.remove() + this._renderer = L.svg({ pane: "elevationPane" }).addTo(this._map); // default leaflet svg renderer + + this.import([this.__D3, this.__LMARKER]) + .then((m) => { + this._marker = new (m[1] || Elevation).Marker(this.options, this); + this.fire("elechart_marker"); + }); + }, + + /** + * Inspired by L.Control.Layers + */ + _initButton(container) { + L.DomEvent + .disableClickPropagation(container) + .disableScrollPropagation(container); + + this.options.collapsed ? this._collapse() : this._expand(); + + if (this.options.autohide) { + _.on(container, 'mouseover', this._expand, this); + _.on(container, 'mouseout', this._collapse, this); + this._map.on('click', this._collapse, this); + } + + if (this.options.closeBtn) { + let link = this._button = _.create('a', "elevation-toggle-icon", { href: '#', title: L._('Elevation'), }, container); + _.on(link, 'click', L.DomEvent.stop); + _.on(link, 'click', this._toggle, this); + _.on(link, 'focus', this._toggle, this); + fetch(_.resolveURL(this.__btnIcon, this.options.srcFolder)).then(r => r.ok && r.text().then(img => link.innerHTML = img)); + } + }, + + _initSummary(container) { + this.import(this.__LSUMMARY).then((m)=>{ + this._summary = new (m || Elevation).Summary({ summary: this.options.summary }, this); + + this.on('elechart_init', () => { + d3.select(container).call(this._summary.render()); + }); + }); + }, + + /** + * Retrieve data from a remote url (HTTP). + */ + _loadFile(url) { + fetch(url) + .then((response) => response.text()) + .then((data) => { + this._downloadURL = url; // TODO: handle multiple urls? + this._parseFromString(data) + .then( geojson => geojson && this._loadLayer(geojson)); + }).catch((err) => console.warn(err)); + }, + + /** + * Dynamically import only required javascript modules (code splitting) + */ + _loadModules(handlers) { + // First map known classnames (eg. "Altitude" --> L.Control.Elevation.Altitude) + handlers = handlers.map((h) => typeof h === 'string' && typeof Elevation[h] !== "undefined" ? Elevation[h] : h); + // Then load optional classes and custom imports (eg. "Cadence" --> import('../src/handlers/cadence.js')) + let modules = handlers.map(file => (typeof file === 'string' && this.import(this.__modulesFolder + file.toLowerCase() + '.js')) || (file instanceof Promise && file) || Promise.resolve()); + return Promise.all(modules).then((m) => { + _.each(m, (exported, i) => { + let fn = exported && Object.keys(exported)[0]; + if (fn) { + handlers[i] = Elevation[fn] = (Elevation[fn] ?? exported[fn]); + } + }); + _.each(handlers, h => ["function", "object"].includes(typeof h) && this._registerHandler(h)); + }); + }, + + /** + * Simple GeoJSON data loader (L.GeoJSON). + */ + _loadLayer(geojson) { + let { polyline, theme, waypoints, wptIcons, wptLabels, distanceMarkers } = this.options; + let style = L.extend({}, polyline); + + if (theme) { + style.className += ' ' + theme; + } + + if (geojson.name) { + this.track_info.name = geojson.name; + } + + let layer = L.geoJson(geojson, { + distanceMarkers: distanceMarkers, + style: style, + pointToLayer: (feature, latlng) => { + if (waypoints) { + let { desc, name, sym } = feature.properties; + desc = desc || ''; + name = name || ''; + // Handle chart waypoints (dots) + if ([true, 'dots'].includes(waypoints)) { + this._registerCheckPoint({ + latlng: latlng, + label : ([true, 'dots'].includes(wptLabels) ? name : '') + }); + } + // Handle map waypoints (markers) + if ([true, 'markers'].includes(waypoints) && wptIcons != false) { + return this._registerMarker({ + latlng : latlng, + sym : (sym ?? name).replace(' ', '-').replace('"', '').replace("'", '').toLowerCase(), + content: [true, 'markers'].includes(wptLabels) && (name || desc) && decodeURI("" + name + "" + (desc.length > 0 ? '
' + desc : '')) + }); + } + } + }, + onEachFeature: (feature, layer) => feature.geometry && feature.geometry.type != 'Point' && this.addData(feature, layer), + }); + + this.import(this.__D3).then(() => { + this._initMapIntegrations(layer); + const event_data = { data: geojson, layer: layer, name: this.track_info.name, track_info: this.track_info }; + if (this._modulesLoaded) { + this._fireEvt("eledata_loaded", event_data); + } else { + this.once('modules_loaded', () => this._fireEvt("eledata_loaded", event_data)); + } + }); + + return layer; + }, + + _onDragEnd({ dragstart, dragend}) { + this._hideMarker(); + this.fitBounds(L.latLngBounds([dragstart.latlng, dragend.latlng])); + + this.fire("elechart_dragged"); + }, + + _onKeyDown({key}) { + if (!this.options.detached && key === "Escape"){ + this._collapse() + }; + }, + + /** + * Trigger mouseenter event. + */ + _onMouseEnter() { + this.fire('elechart_enter'); + }, + + /* + * Handles the moueseover the chart and displays distance and altitude level. + */ + _onMouseMove({xCoord}) { + if (this._chartEnabled && this._data.length) { + let item = this._findItemForX(xCoord); + if (item) { + if (this._chartEnabled) this._chart._showDiagramIndicator(item, xCoord); + + this._updateMarker(item); + this._setMapView(item); + + if (this._map) { + _.addClass(this._map.getContainer(), 'elechart-hover'); + } + + this.fire("elechart_change", { data: item, xCoord: xCoord }); + this.fire("elechart_hover", { data: item, xCoord: xCoord }); + } + } + }, + + /* + * Handles mouseover events of the data layers on the map. + */ + _onMouseMoveLayer({latlng}) { + if (this._data.length) { + let item = this._findItemForLatLng(latlng); + if (item) { + let xCoord = item.xDiagCoord; + + if (this._chartEnabled) this._chart._showDiagramIndicator(item, xCoord); + + this._updateMarker(item); + + this.fire("elechart_change", { data: item, xCoord: xCoord }); + } + } + }, + + /* + * Handles the moueseout over the chart. + */ + _onMouseOut() { + if (!this.options.detached) { + this._hideMarker(); + this._chart._hideDiagramIndicator(); + } + + if (this._map) { + _.removeClass(this._map.getContainer(), 'elechart-hover'); + } + + this.fire("elechart_leave"); + }, + + /** + * Handles the drag event over the ruler filter. + */ + _onRulerFilter({coords}) { + this._updateMapSegments(coords); + }, + + /** + * Toggle chart data on legend click + */ + _onToggleChart({ name, enabled }) { + + this._chartEnabled = this._chart._hasActiveLayers(); + + // toggle layer visibility on empty chart + this._layers.eachLayer(layer => _.toggleClass(layer.getElement && layer.getElement(), this.options.polyline.className + ' ' + this.options.theme, this._chartEnabled)); + + // toggle option value (eg. altitude = { 'disabled' || 'enabled' }) + this.options[name] = !enabled && this.options[name] == 'disabled' ? 'enabled' : 'disabled'; + + // remove marker on empty chart + if (!this._chartEnabled) { + this._chart._hideDiagramIndicator(); + this._marker.remove(); + } + }, + + /** + * Simple GeoJSON Parser + */ + _parseFromGeoJSONString(data) { + try { + return JSON.parse(data); + } catch (e) { } + }, + + /** + * Attempt to parse raw response data (GeoJSON or XML > GeoJSON) + */ + _parseFromString(data) { + return new Promise(resolve => + this.import(this.__TOGEOJSON).then(() => { + let geojson; + try { + geojson = this._parseFromXMLString(data.trim()); + } catch (e) { + geojson = this._parseFromGeoJSONString(data.toString()); + } + if (geojson) { + geojson.name = geojson.name || (this._downloadURL || '').split('/').pop().split('#')[0].split('?')[0]; + } + resolve(geojson); + }) + ); + }, + + /** + * Simple XML Parser (GPX, KML, TCX) + */ + _parseFromXMLString(data) { + if (data.indexOf("<") != 0) { + throw 'Invalid XML'; + } + let xml = (new DOMParser()).parseFromString(data, "text/xml"); + let type = xml.documentElement.tagName.toLowerCase(); // "kml" or "gpx" + let name = xml.getElementsByTagName('name'); + if (xml.getElementsByTagName('parsererror').length) { + throw 'Invalid XML'; + } + if (!(type in toGeoJSON)) { + type = xml.documentElement.tagName == "TrainingCenterDatabase" ? 'tcx' : 'gpx'; + } + let geojson = toGeoJSON[type](xml); + geojson.name = name.length > 0 ? (Array.from(name).find(tag => tag.parentElement.tagName == "trk") ?? name[0]).textContent : ''; + return geojson; + }, + + /** + * Add chart profile to diagram + */ + _registerAreaPath(props) { + this.on("elechart_init", () => this._chart._registerAreaPath(props)); + }, + + /** + * Add chart grid to diagram + */ + _registerAxisGrid(props) { + this.on("elechart_axis", () => this._chart._registerAxisGrid(props)); + }, + + /** + * Add chart axis to diagram + */ + _registerAxisScale(props) { + this.on("elechart_axis", () => this._chart._registerAxisScale(props)); + }, + + /** + * Add a point of interest over the diagram + */ + _registerCheckPoint(props) { + const cb = () => this._chart._registerCheckPoint(props); + this + .on("elechart_updated", cb) + .once("eledata_clear", () => this.off("elechart_updated", cb)); + }, + + /** + * Base handler for iterative track statistics (dist, time, z, slope, speed, acceleration, ...) + */ + _registerDataAttribute(props) { + + // parse of "coordinateProperties" for later usage + if (props.coordPropsToMeta) { + this.on("elepoint_init", (e) => props.coordPropsToMeta.call(this, e)); + } + + // prevent excessive variabile instanstations + let i, curr, prev, attr = props.attr || props.name; + + // save here a reference to last used point + let lastValid = {}; + + // iteration + this.on("elepoint_added", ({index, point}) => { + i = index; + + prev = curr ?? this._data[i]; // same as: this._data[i > 0 ? i - 1 : i] + curr = this._data[i]; + + // retrieve point value + curr[attr] = props.pointToAttr.call(this, point, i); + + // check and fix missing data on last added point + if (i > 0 && isNaN(prev[attr])) { + if (!isNaN(lastValid[attr]) && !isNaN(curr[attr])) { + prev[attr] = (lastValid[attr] + curr[attr]) / 2; + } else if (!isNaN(lastValid[attr])) { + prev[attr] = lastValid[attr]; + } else if (!isNaN(curr[attr])) { + prev[attr] = curr[attr]; + } + // update "yAttr" and "xAttr" + if (props.meta) { + prev[props.meta] = prev[attr]; + } + } + + // skip to next iteration for invalid or missing data (eg. i == 0) + if (isNaN(curr[attr])) { + return; + } + + // update reference to last used point + lastValid[attr] = curr[attr]; + + // Limit "crazy" delta values. + if (props.deltaMax) { + curr[attr] =_.wrapDelta(curr[attr], prev[attr], props.deltaMax); + } + + // Range of acceptable values. + if (props.clampRange) { + curr[attr] = _.clamp(curr[attr], props.clampRange); + } + + // Limit floating point precision. + if (!isNaN(props.decimals)) { + curr[attr] = _.round(curr[attr], props.decimals); + } + + // update "track_info" stats (min, max, avg, ...) + if (props.stats) { + for (const key in props.stats) { + let sname = (props.statsName || attr) + (key != '' ? '_' : ''); + this.track_info[sname + key] = props.stats[key].call(this, curr[attr], this.track_info[sname + key], this._data.length); + } + } + + // update here some mixins (eg. complex "track_info" stuff) + if (props.onPointAdded) props.onPointAdded.call(this, curr[attr], i, point); + }); + }, + + /** + * Parse a module definition and attach related function listeners + */ + _registerHandler(props) { + + // eg. L.Control.Altitude + if (typeof props === "function") { + return this._registerHandler(props.call(this)); + } + + let { + name, + attr, + required, + deltaMax, + clampRange, + decimals, + meta, + unit, + coordinateProperties, + coordPropsToMeta, + pointToAttr, + onPointAdded, + stats, + statsName, + grid, + scale, + path, + tooltip, + summary + } = props; + + // eg. "altitude" == true + if (this.options[name] || required) { + + this._registerDataAttribute({ + name, + attr, + meta, + deltaMax, + clampRange, + decimals, + coordPropsToMeta: _.coordPropsToMeta(coordinateProperties, meta || name, coordPropsToMeta), + pointToAttr, + onPointAdded, + stats, + statsName, + }); + + if (grid) { + this._registerAxisGrid(L.extend({ name }, grid)); + } + + if (this.options[name] !== "summary") { + if (scale) this._registerAxisScale(L.extend({ name, label: unit }, scale)); + if (path) this._registerAreaPath(L.extend({ name }, path)); + } + + if (tooltip || props.tooltips) { + _.each([tooltip, ...(props.tooltips || [])], t => t && this._registerTooltip(L.extend({ name }, t))); + } + + if (summary) { + _.each(summary, (s, k) => summary[k] = L.extend({ unit }, s)); + this._registerSummary(summary); + } + } + }, + + _registerMarker({latlng, sym, content}) { + let { wptIcons } = this.options; + // generate and cache appropriate icon symbol + if (!wptIcons.hasOwnProperty(sym)) { + wptIcons[sym] = L.divIcon(L.extend({}, wptIcons[""].options, { html: '' } )); + } + let marker = L.marker(latlng, { icon: wptIcons[sym] }); + if (content) { + marker.bindPopup(content, { className: 'elevation-popup', keepInView: true }).openPopup(); + marker.bindTooltip(content, { className: 'elevation-tooltip', direction: 'auto', sticky: true, opacity: 1 }).openTooltip(); + } + return this._addMarker(marker) + }, + + /** + * Add chart or marker tooltip info + */ + _registerTooltip(props) { + props.chart && this.on("elechart_init", () => this._chart._registerTooltip(L.extend({}, props, { value: props.chart }))); + props.marker && this.on("elechart_marker", () => this._marker._registerTooltip(L.extend({}, props, { value: props.marker }))); + }, + + /** + * Add summary info to diagram + */ + _registerSummary(props) { + this.on('elechart_summary', () => this._summary._registerSummary(props)); + }, + + /* + * Removes the drag rectangle and zoms back to the total extent of the data. + */ + _resetDrag() { + this._chart._resetDrag(); + this._hideMarker(); + }, + + /** + * Resets drag, marker and bounds. + */ + _resetView() { + if (this._map && this._map._isFullscreen) return; + this._resetDrag(); + this._hideMarker(); + if (this.options.autofitBounds) { + this.fitBounds(); + } + }, + + /** + * Hacky way for handling chart resize. Deletes it and redraw chart. + */ + _resizeChart() { + if (this._container && _.style(this._container, "display") != "none") { + let opts = this.options; + let newWidth = opts.detached ? (this.eleDiv || this._container).offsetWidth : _.clamp(opts._maxWidth, [0, this._map.getContainer().clientWidth - 30]); + if (newWidth) { + opts.width = newWidth; + if (this._chart && this._chart._chart) { + this._chart._chart._resize(opts); + this._updateChart(); + } + } + this._updateMapSegments(); + } + }, + + /** + * Collapse or Expand chart control. + */ + _toggle() { + _.hasClass(this._container, "elevation-expanded") ? this._collapse() : this._expand(); + }, + + /** + * Update map center and zoom (followMarker: true) + */ + _setMapView(item) { + if (this._map && this.options.followMarker) { + let zoom = this._map.getZoom(); + let z = this.options.zFollow; + if (typeof z === "number") { + this._map.setView(item.latlng, (zoom < z ? z : zoom), { animate: true, duration: 0.25 }); + } else if (!this._map.getBounds().contains(item.latlng)) { + this._map.setView(item.latlng, zoom, { animate: true, duration: 0.25 }); + } + } + }, + + /** + * Calculates [x, y] domain and then update chart. + */ + _updateChart() { + if (this._chart && this._container) { + this.fire("elechart_axis"); + + this._chart.update({ data: this._data, options: this.options }); + + this._x = this._chart._x; + this._y = this._chart._y; + + this.fire('elechart_updated'); + } + }, + + /* + * Update the position/height indicator marker drawn onto the map + */ + _updateMarker(item) { + if (this._marker) { + this._marker.update({ + map : this._map, + item : item, + yCoordMax : this._yCoordMax || 0, + options : this.options + }); + } + }, + + /** + * Fix marker rotation on rotated maps + */ + _rotateMarker() { + if (this._marker) { + this._marker.update(); + } + }, + + /** + * Highlight track segments on the map. + */ + _updateMapSegments(coords) { + this._markedSegments.setLatLngs(coords || []); + if (coords && this._map && !this._map.hasLayer(this._markedSegments)) { + this._markedSegments.addTo(this._map); + } + }, + + /** + * Update chart summary. + */ + _updateSummary() { + if (this._summary) { + this._summary.reset(); + if (this.options.summary) { + this.fire("elechart_summary"); + this._summary.update(); + } + if (this.options.downloadLink && this._downloadURL) { // TODO: generate dynamically file content instead of using static file urls. + this._summary._container.innerHTML += '' + L._('Download') + '' + _.select('.download a', this._summary._container).onclick = (e) => { + e.preventDefault(); + let event = { downloadLink: this.options.downloadLink, confirm: _.saveFile.bind(this, this._downloadURL) }; + if (this.options.downloadLink == 'modal' && typeof CustomEvent === "function") { + document.dispatchEvent(new CustomEvent("eletrack_download", { detail: event })); + } else if (this.options.downloadLink == 'link' || this.options.downloadLink === true) { + event.confirm(); + } + this.fire('eletrack_download', event); + }; + } + } + }, + + + /** + * Calculates chart width. + */ + _width() { + if (this._chart) return this._chart._width(); + const { width, margins } = this.options; + return width - margins.left - margins.right; + }, + + /** + * Calculates chart height. + */ + _height() { + if (this._chart) return this._chart._height(); + const { height, margins } = this.options; + return height - margins.top - margins.bottom; + }, + +}); diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/acceleration.js b/assets/static/vendor/leaflet-elevation/src/handlers/acceleration.js new file mode 100644 index 0000000..9b31b5c --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/acceleration.js @@ -0,0 +1,64 @@ +export function Acceleration() { + + const _ = L.Control.Elevation.Utils; + + let opts = this.options; + let acceleration = {}; + + acceleration.label = opts.accelerationLabel || L._(opts.imperial ? 'ft/s²' : 'm/s²'); + opts.accelerationFactor = opts.accelerationFactor || 1; + + return { + name: 'acceleration', + unit: acceleration.label, + deltaMax: this.options.accelerationDeltaMax, + clampRange: this.options.accelerationRange, + decimals: 2, + pointToAttr: (_, i) => { + let dv = (this._data[i].speed - this._data[i > 0 ? i - 1 : i].speed) * (1000 / opts.timeFactor); + let dt = (this._data[i].time - this._data[i > 0 ? i - 1 : i].time) / 1000; + return dt > 0 ? Math.abs((dv / dt)) * opts.accelerationFactor : NaN; + }, + stats: { max: _.iMax, min: _.iMin, avg: _.iAvg }, + scale: { + axis : "y", + position : "right", + scale : { min: 0, max: +1 }, + tickPadding: 16, + labelX : 25, + labelY : -8, + }, + path: { + label : 'Acceleration', + yAttr : 'acceleration', + scaleX : 'distance', + scaleY : 'acceleration', + color : '#050402', + strokeColor : '#000', + strokeOpacity: "0.5", + fillOpacity : "0.25", + }, + tooltip: { + chart: (item) => L._("a: ") + item.acceleration + " " + acceleration.label, + marker: (item) => Math.round(item.acceleration) + " " + acceleration.label, + order: 60, + }, + summary: { + "minacceleration" : { + label: "Min Acceleration: ", + value: (track, unit) => Math.round(track.acceleration_min || 0) + ' ' + unit, + order: 60 + }, + "maxacceleration" : { + label: "Max Acceleration: ", + value: (track, unit) => Math.round(track.acceleration_max || 0) + ' ' + unit, + order: 61 + }, + "avgacceleration": { + label: "Avg Acceleration: ", + value: (track, unit) => Math.round(track.acceleration_avg || 0) + ' ' + unit, + order: 62 + }, + } + }; +} diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/altitude.js b/assets/static/vendor/leaflet-elevation/src/handlers/altitude.js new file mode 100644 index 0000000..b7fe6e3 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/altitude.js @@ -0,0 +1,78 @@ +export function Altitude() { + + const _ = L.Control.Elevation.Utils; + + let opts = this.options; + let altitude = {}; + + let theme = opts.theme.split(' ')[0].replace('-theme', ''); + let color = _.Colors[theme] || {}; + + opts.altitudeFactor = opts.imperial ? this.__footFactor : (opts.altitudeFactor || 1); // 1 m = (1 m) + altitude.label = opts.imperial ? "ft" : opts.yLabel; + + return { + name: 'altitude', + required: this.options.slope, + meta: 'z', + unit: altitude.label, + statsName: 'elevation', + deltaMax: this.options.altitudeDeltaMax, + clampRange: this.options.altitudeRange, + // init: ({point}) => { + // // "alt" property is generated inside "leaflet" + // if ("alt" in point) point.meta.ele = point.alt; + // }, + pointToAttr: (point, i) => { + if ("alt" in point) point.meta.ele = point.alt; // "alt" property is generated inside "leaflet" + return this._data[i].z *= opts.altitudeFactor; + }, + stats: { max: _.iMax, min: _.iMin, avg: _.iAvg }, + grid: { + axis : "y", + position : "left", + scale : "y" // this._chart._y, + }, + scale: { + axis : "y", + position: "left", + scale : "y", // this._chart._y, + labelX : -3, + labelY : -8, + }, + path: { + label : 'Altitude', + scaleX : 'distance', + scaleY : 'altitude', + className : 'area', + color : color.area || theme, + strokeColor : opts.detached ? color.stroke : '#000', + strokeOpacity: "1", + fillOpacity : opts.detached ? (color.alpha || '0.8') : 1, + preferCanvas : opts.preferCanvas, + }, + tooltip: { + name: 'y', + chart: (item) => L._("y: ") + _.round(item[opts.yAttr], opts.decimalsY) + " " + altitude.label, + marker: (item) => _.round(item[opts.yAttr], opts.decimalsY) + " " + altitude.label, + order: 10, + }, + summary: { + "minele" : { + label: "Min Elevation: ", + value: (track, unit) => (track.elevation_min || 0).toFixed(2) + ' ' + unit, + order: 30, + }, + "maxele" : { + label: "Max Elevation: ", + value: (track, unit) => (track.elevation_max || 0).toFixed(2) + ' ' + unit, + order: 31, + }, + "avgele" : { + label: "Avg Elevation: ", + value: (track, unit) => (track.elevation_avg || 0).toFixed(2) + ' ' + unit, + order: 32, + }, + } + }; +} diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/cadence.js b/assets/static/vendor/leaflet-elevation/src/handlers/cadence.js new file mode 100644 index 0000000..0ad7704 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/cadence.js @@ -0,0 +1,54 @@ +export function Cadence() { + + const _ = L.Control.Elevation.Utils; + + return { + name: 'cadence', // <-- Your custom option name (eg. "cadence: true") + unit: 'rpm', + meta: 'cad', // <-- point.meta.cad + coordinateProperties: ["cads", "cadences", "cad", "cadence"], // List of GPX Extensions ("coordinateProperties") to be handled by "@tmcw/toGeoJSON" + pointToAttr: (point, i) => (point.cad ?? point.meta.cad ?? point.prev('cadence')) || 0, + stats: { max: _.iMax, min: _.iMin, avg: _.iAvg }, + scale: { + axis : "y", + position : "right", + scale : { min: -1, max: +1 }, + tickPadding: 16, + labelX : 25, + labelY : -8, + }, + path: { + label : 'RPM', + yAttr : 'cadence', + scaleX : 'distance', + scaleY : 'cadence', + color : '#FFF', + strokeColor : 'blue', + strokeOpacity: "0.85", + fillOpacity : "0.1", + }, + tooltip: { + name: 'cadence', + chart: (item) => L._("cad: ") + item.cadence + " " + 'rpm', + marker: (item) => Math.round(item.cadence) + " " + 'rpm', + order: 1 + }, + summary: { + "minrpm": { + label: "Min RPM: ", + value: (track, unit) => Math.round(track.cadence_min || 0) + ' ' + unit, + // order: 30 + }, + "maxrpm": { + label: "Max RPM: ", + value: (track, unit) => Math.round(track.cadence_max || 0) + ' ' + unit, + // order: 30 + }, + "avgrpm": { + label: "Avg RPM: ", + value: (track, unit) => Math.round(track.cadence_avg || 0) + ' ' + unit, + // order: 20 + }, + } + }; +} \ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/distance.js b/assets/static/vendor/leaflet-elevation/src/handlers/distance.js new file mode 100644 index 0000000..fa6fa1d --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/distance.js @@ -0,0 +1,46 @@ +export function Distance() { + + const _ = L.Control.Elevation.Utils; + + let opts = this.options; + let distance = {}; + + opts.distanceFactor = opts.imperial ? this.__mileFactor : (opts.distanceFactor || 1); // 1 km = (1000 m) + distance.label = opts.imperial ? "mi" : opts.xLabel; + + return { + name: 'distance', + required: true, + attr: 'dist', + unit: distance.label, + decimals: 5, + 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 + // stats: { total: _.iSum }, + onPointAdded: (distance, i) => this.track_info.distance = distance, + scale: opts.distance && { + axis : "x", + position: "bottom", + scale : "x", // this._chart._x, + labelY : 25, + labelX : () => this._width() + 6, + ticks : () => _.clamp(this._chart._xTicks() / 2, [4, +Infinity]), + }, + grid: opts.distance && { + axis : "x", + position : "bottom", + scale : "x" // this._chart._x, + }, + tooltip: opts.distance && { + name: 'x', + chart: (item) => L._("x: ") + _.round(item[opts.xAttr], opts.decimalsX) + " " + distance.label, + order: 20 + }, + summary: opts.distance && { + "totlen" : { + label: "Total Length: ", + value: (track) => (track.distance || 0).toFixed(2) + ' ' + distance.label, + order: 10 + } + } + }; +} diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/heart.js b/assets/static/vendor/leaflet-elevation/src/handlers/heart.js new file mode 100644 index 0000000..1930f60 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/heart.js @@ -0,0 +1,53 @@ +export function Heart() { + + const _ = L.Control.Elevation.Utils; + + return { + name: 'heart', // <-- Your custom option name (eg. "heart: true") + unit: 'bpm', + meta: 'hr', // <-- point.meta.hr + coordinateProperties: ["heart", "heartRates", "heartRate"], // List of GPX Extensions ("coordinateProperties") to be handled by "@tmcw/toGeoJSON" + pointToAttr: (point, i) => (point.hr ?? point.meta.hr ?? point.prev('heart')) || 0, + stats: { max: _.iMax, min: _.iMin, avg: _.iAvg }, + scale: { + axis : "y", + position : "left", + scale : { min: -1, max: +1 }, + tickPadding: 25, + labelX : -30, + labelY : -8, + }, + path: { + label : 'ECG', + yAttr : 'heart', + scaleX : 'distance', + scaleY : 'heart', + color : 'white', + strokeColor : 'red', + strokeOpacity: "0.85", + fillOpacity : "0.1", + }, + tooltip: { + chart: (item) => L._("hr: ") + item.heart + " " + 'bpm', + marker: (item) => Math.round(item.heart) + " " + 'bpm', + order: 1 + }, + summary: { + "minbpm": { + label: "Min BPM: ", + value: (track, unit) => Math.round(track.heart_min || 0) + ' ' + unit, + // order: 30 + }, + "maxbpm": { + label: "Max BPM: ", + value: (track, unit) => Math.round(track.heart_max || 0) + ' ' + unit, + // order: 30 + }, + "avgbpm": { + label: "Avg BPM: ", + value: (track, unit) => Math.round(track.heart_avg || 0) + ' ' + unit, + // order: 20 + }, + } + }; +}; \ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/labels.js b/assets/static/vendor/leaflet-elevation/src/handlers/labels.js new file mode 100644 index 0000000..31b3cd6 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/labels.js @@ -0,0 +1,45 @@ +/** + * @see https://github.com/Raruto/leaflet-elevation/issues/211 + * + * @example + * ```js + * L.control.Elevation({ handlers: [ 'Labels' ], labelsRotation: 25, labelsAlign: 'start' }) + * ``` + */ +export function Labels() { + + this.on('elechart_updated', function(e) { + + const pointG = this._chart._chart.pane('point'); + + const textRotation = this.options.labelsRotation ?? -90; + const textAnchor = this.options.labelsAlign; + + if (90 == Math.abs(textRotation)) { + + pointG.selectAll('text') + .attr('dy', '4px') + .attr("dx", (d, i, el) => Math.sign(textRotation) * (this._height() - d3.select(el[i].parentElement).datum().y - 8) + 'px') + .attr('text-anchor', textRotation > 0 ? 'end' : 'start') + .attr('transform', 'rotate(' + textRotation + ')') + + pointG.selectAll('circle') + .attr('r', '2.5') + .attr('fill', '#fff') + .attr("stroke", '#000') + .attr("stroke-width", '1.1'); + + } else if (!isNaN(textRotation)) { + + pointG.selectAll('text') + .attr("dx", "4px") + .attr("dy", "-9px") + .attr('text-anchor', textAnchor ?? (0 == textRotation ? 'start' : 'middle')) + .attr('transform', 'rotate('+ -textRotation +')') + + } + + }); + + return { }; +} diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/lineargradient.js b/assets/static/vendor/leaflet-elevation/src/handlers/lineargradient.js new file mode 100644 index 0000000..90e33ce --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/lineargradient.js @@ -0,0 +1,122 @@ +/** + * @see https://github.com/Raruto/leaflet-elevation/issues/251 + * + * @example + * ```js + * L.control.Elevation({ + * altitude: true, + * distance: true, + * handlers: [ 'Altitude', 'Distance', 'LinearGradient', ], + * linearGradient: { + * attr: 'z', + * path: 'altitude', + * range: { 0.0: '#008800', 0.5: '#ffff00', 1.0: '#ff0000' }, + * min: 'elevation_min', + * max: 'elevation_max', + * }, + * }) + * ``` + */ +export function LinearGradient() { + + if (!this.options.linearGradient) { + return {}; + } + + const _ = L.Control.Elevation.Utils; + + /** + * Initialize gradient color palette. + */ + const get_palette = function ({range, min, max, depth = 256}) { + const canvas = document.createElement('canvas'), + ctx = canvas.getContext('2d'), + gradient = ctx.createLinearGradient(0, 0, 0, depth); + + canvas.width = 1; + canvas.height = depth; + + for (let i in range) { + gradient.addColorStop(i, range[i]); + } + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, 1, depth); + + const { data } = ctx.getImageData(0, 0, 1, depth); + + return { + /** + * Gets the RGB values of a given z value of the current palette. + * + * @param {number} value - Value to get the color for, should be between min and max. + * @returns {string} The RGB values as `rgb(r, g, b)` string + */ + getRGBColor(value) { + const idx = Math.floor(Math.min(Math.max((value - min) / (max - min), 0), 0.999) * depth) * 4; + return 'rgb(' + [data[idx], data[idx + 1], data[idx + 2]].join(',') + ')'; + } + }; + }; + + const { preferCanvas } = this.options; + const { attr, path: path_name, range, min, max } = L.extend({ + attr: 'z', + path: 'altitude', + range: { 0.0: '#008800', 0.5: '#ffff00', 1.0: '#ff0000' }, + min: 'elevation_min', + max: 'elevation_max', + }, (true === this.options.linearGradient) ? {} : this.options.linearGradient); + + const gradient_id = path_name + '-gradient-' + _.randomId(); + const legend_id = 'legend-' + gradient_id; + + // Charte profile gradient + this.on('elechart_axis', () => { + if (!this._data.length) return; + + const chart = this._chart; + const path = chart._paths[path_name]; + const { defs } = chart._chart.utils; + + const palette = get_palette({ + min: isFinite(this.track_info[min]) ? this.track_info[min] : 0, + max: isFinite(this.track_info[max]) ? this.track_info[max] : 1, + range, + }); + + let gradient; + + if (preferCanvas) { + /** ref: `path.__fillStyle` within L.Control.Elevation.Utils::drawCanvas(ctx, path) */ + path.__fillStyle = gradient = chart._context.createLinearGradient(0, 0, chart._width(), 0); + } else { + defs.select('#' + gradient_id).remove(); + gradient = defs.append('svg:linearGradient').attr('id', gradient_id); + gradient.addColorStop = function(offset, color) { gradient.append('svg:stop').attr('offset', offset).attr('stop-color', color) }; + path.attr('fill', 'url(#' + gradient_id + ')').classed('area', false); + } + + // Generate gradient for each segment picking colors from palette + for (let i = 0, data = this._data; i < data.length; i++) { + gradient.addColorStop((i) / data.length, palette.getRGBColor(data[i][attr])); + } + + }); + + // Legend item gradient + this.on('elechart_updated', () => { + const chart = this._chart; + const { defs } = chart._chart.utils; + defs.select('#' + legend_id).remove(); + const legendGradient = defs.append('svg:linearGradient').attr('id', legend_id); + Object.keys(range).sort().forEach(i => legendGradient.append('svg:stop').attr('offset', i).attr('stop-color', range[i])); + + chart._container + .select('.legend-' + path_name + ' > rect') + .attr('fill', 'url(#' + legend_id + ')') + .classed('area', false); + }); + + return { }; +} \ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/pace.js b/assets/static/vendor/leaflet-elevation/src/handlers/pace.js new file mode 100644 index 0000000..9017f39 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/pace.js @@ -0,0 +1,65 @@ +export function Pace() { + + const _ = L.Control.Elevation.Utils; + + let opts = this.options; + let pace = {}; + + pace.label = opts.paceLabel || L._(opts.imperial ? 'min/mi' : 'min/km'); + opts.paceFactor = opts.paceFactor || 60; // 1 min = 60 sec + + return { + name: 'pace', + unit: pace.label, + deltaMax: this.options.paceDeltaMax, + clampRange: this.options.paceRange, + decimals: 2, + pointToAttr: (_, i) => { + let dx = (this._data[i].dist - this._data[i > 0 ? i - 1 : i].dist) * 1000; + let dt = this._data[i].time - this._data[ i > 0 ? i - 1 : i].time; + return dx > 0 ? Math.abs((dt / dx) / opts.paceFactor) : NaN; + }, + stats: { max: _.iMax, min: _.iMin, avg: _.iAvg }, + scale : (this.options.pace && this.options.pace != "summary") && { + axis : "y", + position : "right", + scale : { min : 0, max : +1 }, + tickPadding: 16, + labelX : 25, + labelY : -8, + }, + path: (this.options.pace && this.options.pace != "summary") && { + // name : 'pace', + label : 'Pace', + yAttr : "pace", + scaleX : 'distance', + scaleY : 'pace', + color : '#03ffff', + strokeColor : '#000', + strokeOpacity: "0.5", + fillOpacity : "0.25", + }, + tooltip: (this.options.pace) && { + chart: (item) => L._('pace: ') + item.pace + " " + pace.label, + marker: (item) => Math.round(item.pace) + " " + pace.label, + order: 50, + }, + summary: (this.options.pace) && { + "minpace" : { + label: "Min Pace: ", + value: (track, unit) => Math.round(track.pace_min || 0) + ' ' + unit, + order: 51 + }, + "maxpace" : { + label: "Max Pace: ", + value: (track, unit) => Math.round(track.pace_max || 0) + ' ' + unit, + order: 51 + }, + "avgpace": { + label: "Avg Pace: ", + value: (track, unit) => Math.round(track.pace_avg || 0) + ' ' + unit, + order: 52 + }, + } + }; +} diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/runner.js b/assets/static/vendor/leaflet-elevation/src/handlers/runner.js new file mode 100644 index 0000000..30dd80a --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/runner.js @@ -0,0 +1,109 @@ +/** + * @see https://github.com/Igor-Vladyka/leaflet.motion + * + * @example + * ```js + * L.control.Elevation({ handlers: [ 'Runner' ], runnerOptions: { polyline: {..}, motion: {..}, marker {..} } }) + * ``` + */ +export async function Runner() { + + await this.import(this.__LMOTION || 'https://unpkg.com/leaflet.motion@0.3.2/dist/leaflet.motion.min.js', typeof L.Motion !== 'object') + + let { runnerOptions } = this.options; + + runnerOptions = L.extend( + { polyline: {}, motion: {}, marker: undefined }, + 'object' === typeof runnerOptions ? runnerOptions : {} + ); + + // Custom tooltips + this._registerTooltip({ + name: 'here', + marker: (item) => L._("You are here: "), + order: 1, + }); + + this._registerTooltip({ + name: 'distance', + marker: (item) => Math.round(item.dist) + " " + this.options.xLabel, + order: 2, + }); + + this.addCheckpoint = function (checkpoint) { + return this._registerCheckPoint({ // <-- NB these are private functions use them at your own risk! + latlng: this._findItemForX(this._x(checkpoint.dist)).latlng, + label: checkpoint.label || '' + }); + } + + this.addRunner = function (runner) { + let x = this._x(runner.dist); + let y = this._y(this._findItemForX(x).z) + let g = d3.select(this._container) + .select('svg > g') + .append("svg:circle") + .attr("class", "runner " + this.options.theme + " height-focus circle-lower") + .attr("r", 6) + .attr("cx", x) + .attr("cy", y); + return g; + } + + this.setPositionFromLatLng = function (latlng) { + this._onMouseMoveLayer({ latlng: latlng }); // Update map and chart "markers" from latlng + }; + + this.tick = function (runner, dist = 0, inc = 0.1) { + dist = (dist <= this.track_info.distance - inc) ? dist + inc : 0; + this.updateRunnerPos(runner, dist); + setTimeout(() => this.tick(runner, dist), 150); + }; + + this.updateRunnerPos = function (runner, pos) { + let curr, x; + + if (pos instanceof L.LatLng) { + curr = this._findItemForLatLng(pos); + x = this._x(curr.dist); + } else { + x = this._x(pos); + curr = this._findItemForX(x); + } + + runner + .attr("cx", x) + .attr("cy", this._y(curr.z)); + this.setPositionFromLatLng(curr.latlng); + }; + + this.animate = function (layer, speed = 1500) { + + if (this._runner) { + this._runner.remove(); + } + + layer.setStyle({ opacity: 0.5 }); + + const geo = L.geoJson(layer.toGeoJSON(), { coordsToLatLng: (coords) => L.latLng(coords[0], coords[1], coords[2] * (this.options.altitudeFactor || 1)) }); + this._runner = L.motion.polyline( + geo.toGeoJSON().features[0].geometry.coordinates, + L.extend({}, { color: 'red', pane: 'elevationPane', attribution: '' }, runnerOptions.polyline), + L.extend({}, { auto: true, speed: speed, }, runnerOptions.motion), + runnerOptions.marker || undefined + ); + + // Override default function behavior: `L.Motion.Polyline::_drawMarker()` + this._runner._drawMarker = new Proxy(this._runner._drawMarker, { + apply: (target, thisArg, argArray) => { + thisArg._runner = thisArg._runner || this.addRunner({ dist: 0 }); + this.updateRunnerPos(thisArg._runner, argArray[0]); + return target.apply(thisArg, argArray); + } + }); + + this._runner.addTo(this._map); + }; + + return {}; +} diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/slope.js b/assets/static/vendor/leaflet-elevation/src/handlers/slope.js new file mode 100644 index 0000000..37291b3 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/slope.js @@ -0,0 +1,79 @@ +export function Slope() { + + const _ = L.Control.Elevation.Utils; + + let opts = this.options; + let slope = {}; + + slope.label = opts.slopeLabel || '%'; + + return { + name: 'slope', + meta: 'slope', + unit: slope.label, + deltaMax: this.options.slopedDeltaMax, + clampRange: this.options.slopeRange, + decimals: 2, + pointToAttr: (_, i) => { // slope in % = ( dy / dx ) * 100; + let dx = (this._data[i].dist - this._data[i > 0 ? i - 1 : i].dist) * 1000; + let dy = this._data[i][this.options.yAttr] - this._data[i > 0 ? i - 1 : i][this.options.yAttr]; + return dx !== 0 ? (dy / dx) * 100 : NaN; + }, + onPointAdded: (_, i) => { + let dz = this._data[i][this.options.yAttr] - this._data[i > 0 ? i - 1 : i][this.options.yAttr]; + if (dz > 0) this.track_info.ascent = (this.track_info.ascent || 0) + dz; // Total Ascent + else if (dz < 0) this.track_info.descent = (this.track_info.descent || 0) - dz; // Total Descent + }, + stats: { max: _.iMax, min: _.iMin, avg: _.iAvg, }, + scale: { + axis : "y", + position : "right", + scale : { min: -1, max: +1 }, + tickPadding: 16, + labelX : 25, + labelY : -8, + }, + path: { + label : 'Slope', + yAttr : 'slope', + scaleX : 'distance', + scaleY : 'slope', + color : '#F00', + strokeColor : '#000', + strokeOpacity: "0.5", + fillOpacity : "0.25", + }, + tooltip: { + chart: (item) => L._("m: ") + item.slope + slope.label, + marker: (item) => Math.round(item.slope) + slope.label, + order: 40, + }, + summary: { + "minslope": { + label: "Min Slope: ", + value: (track, unit) => Math.round(track.slope_min || 0) + ' ' + unit, + order: 40 + }, + "maxslope": { + label: "Max Slope: ", + value: (track, unit) => Math.round(track.slope_max || 0) + ' ' + unit, + order: 41 + }, + "avgslope": { + label: "Avg Slope: ", + value: (track, unit) => Math.round(track.slope_avg || 0) + ' ' + unit, + order: 42 + }, + "ascent" : { + label: "Total Ascent: ", + value: (track, unit) => Math.round(track.ascent || 0) + ' ' + (this.options.imperial ? 'ft' : 'm'), + order: 43 + }, + "descent" : { + label: "Total Descent: ", + value: (track, unit) => Math.round(track.descent || 0) + ' ' + (this.options.imperial ? 'ft' : 'm'), + order: 45 + }, + } + }; +} diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/speed.js b/assets/static/vendor/leaflet-elevation/src/handlers/speed.js new file mode 100644 index 0000000..534b340 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/speed.js @@ -0,0 +1,66 @@ +export function Speed() { + + const _ = L.Control.Elevation.Utils; + + let opts = this.options; + let speed = {}; + + speed.label = opts.speedLabel || L._(opts.imperial ? 'mph' : 'km/h'); + opts.speedFactor = opts.speedFactor || 1; + + return { + name: 'speed', + required: (this.options.acceleration), + unit: speed.label, + deltaMax: this.options.speedDeltaMax, + clampRange: this.options.speedRange, + decimals: 2, + pointToAttr: (_, i) => { + let dx = (this._data[i].dist - this._data[i > 0 ? i - 1 : i].dist) * 1000; + let dt = this._data[i].time - this._data[ i > 0 ? i - 1 : i].time; + return dt > 0 ? Math.abs((dx / dt) * opts.timeFactor) * opts.speedFactor : NaN; + }, + stats: { max: _.iMax, min: _.iMin, avg: _.iAvg }, + scale : (this.options.speed && this.options.speed != "summary") && { + axis : "y", + position : "right", + scale : { min : 0, max : +1 }, + tickPadding: 16, + labelX : 25, + labelY : -8, + }, + path: (this.options.speed && this.options.speed != "summary") && { + // name : 'speed', + label : 'Speed', + yAttr : "speed", + scaleX : 'distance', + scaleY : 'speed', + color : '#03ffff', + strokeColor : '#000', + strokeOpacity: "0.5", + fillOpacity : "0.25", + }, + tooltip: (this.options.speed) && { + chart: (item) => L._('v: ') + item.speed + " " + speed.label, + marker: (item) => Math.round(item.speed) + " " + speed.label, + order: 50, + }, + summary: (this.options.speed) && { + "minspeed" : { + label: "Min Speed: ", + value: (track, unit) => Math.round(track.speed_min || 0) + ' ' + unit, + order: 51 + }, + "maxspeed" : { + label: "Max Speed: ", + value: (track, unit) => Math.round(track.speed_max || 0) + ' ' + unit, + order: 51 + }, + "avgspeed": { + label: "Avg Speed: ", + value: (track, unit) => Math.round(track.speed_avg || 0) + ' ' + unit, + order: 52 + }, + } + }; +} diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/temperature.js b/assets/static/vendor/leaflet-elevation/src/handlers/temperature.js new file mode 100644 index 0000000..cf05308 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/temperature.js @@ -0,0 +1,62 @@ +export function Temperature() { + + const _ = L.Control.Elevation.Utils; + + let temperature = {}; + let opts = this.options; + temperature.label = opts.label || L._(opts.imperial ? '°F' : '°C'); + + // Fahrenheit = (Celsius * 9/5) + 32 + opts.temperatureFactor1 = opts.temperatureFactor1 ?? (opts.imperial ? 1.8 : 1); + opts.temperatureFactor2 = opts.temperatureFactor2 ?? (opts.imperial ? 32 : 0); + + return { + name: 'temperature', // <-- Your custom option name (eg. "temperature: true") + unit: temperature.label, + meta: 'atemps', // <-- point.meta.atemps + coordinateProperties: ["atemps"], // List of GPX Extensions ("coordinateProperties") to be handled by "@tmcw/toGeoJSON" + deltaMax: this.options.temperatureDeltaMax, + clampRange: this.options.temperatureRange, + decimals: 2, + pointToAttr: (point, i) => (point.meta.atemps ?? point.meta.atemps ?? point.prev('temperature')) * opts.temperatureFactor1 + opts.temperatureFactor2, + stats: { max: _.iMax, min: _.iMin, avg: _.iAvg }, + scale: { + axis : "y", + position : "right", + scale : { min: -1, max: +1 }, + tickPadding: 16, + labelX : +18, + labelY : -8, + }, + path: { + label : temperature.label, + yAttr : 'temperature', + scaleX : 'distance', + scaleY : 'temperature', + color : 'transparent', + strokeColor : '#000', + strokeOpacity: "0.85", + // fillOpacity : "0.1", + }, + tooltip: { + name: 'temperature', + chart: (item) => L._("Temp: ") + Math.round(item.temperature).toLocaleString() + " " + temperature.label, + marker: (item) => Math.round(item.temperature).toLocaleString() + " " + temperature.label, + order: 1 + }, + summary: { + "mintemp": { + label: "Min Temp: ", + value: (track, unit) => Math.round(track.temperature_min || 0) + ' ' + unit, + }, + "maxtemp": { + label: "Max Temp: ", + value: (track, unit) => Math.round(track.temperature_max || 0) + ' ' + unit, + }, + "avgtemp": { + label: "Avg Temp: ", + value: (track, unit) => Math.round(track.temperature_avg || 0) + ' ' + unit, + }, + } + }; +} diff --git a/assets/static/vendor/leaflet-elevation/src/handlers/time.js b/assets/static/vendor/leaflet-elevation/src/handlers/time.js new file mode 100644 index 0000000..d94b136 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/handlers/time.js @@ -0,0 +1,103 @@ +export function Time() { + + const _ = L.Control.Elevation.Utils; + + let opts = this.options; + let time = {}; + + time.label = opts.timeLabel || 't'; + opts.timeFactor = opts.timeFactor || 3600; + + /** + * Common AVG speeds: + * ---------------------- + * slow walk = 1.8 km/h + * walking = 3.6 km/h <-- default: 3.6 + * running = 10.8 km/h + * cycling = 18 km/h + * driving = 72 km/h + * ---------------------- + */ + this._timeAVGSpeed = (opts.timeAVGSpeed || 3.6) * (opts.speedFactor || 1); + + if (!opts.timeFormat) { + opts.timeFormat = (time) => (new Date(time)).toLocaleString().replaceAll('/', '-').replaceAll(',', ' '); + } else if (opts.timeFormat == 'time') { + opts.timeFormat = (time) => (new Date(time)).toLocaleTimeString(); + } else if (opts.timeFormat == 'date') { + opts.timeFormat = (time) => (new Date(time)).toLocaleDateString(); + } + + opts.xTimeFormat = opts.xTimeFormat || ((t) => _.formatTime(t).split("'")[0]); + + return { + name: 'time', + required: (this.options.speed || this.options.acceleration || this.options.timestamps), + coordinateProperties: ["coordTimes", "times", "time"], + coordPropsToMeta: _.parseDate, + pointToAttr: function(point, i) { + // Add missing timestamps (see: options.timeAVGSpeed) + if (!point.meta || !point.meta.time) { + point.meta = point.meta || {}; + if (i > 0) { + let dx = (this._data[i].dist - this._data[i - 1].dist); + let t0 = this._data[i - 1].time.getTime(); + point.meta.time = new Date(t0 + ( dx / this._timeAVGSpeed) * this.options.timeFactor * 1000); + } else { + point.meta.time = new Date(Date.now()) + } + } + // Handle timezone offset + let time = (point.meta.time.getTime() - point.meta.time.getTimezoneOffset() * 60 * 1000 !== 0) ? point.meta.time : 0; + // Update duration + this._data[i].duration = i > 0 ? (this._data[i - 1].duration || 0) + Math.abs(time - this._data[i - 1].time) : 0; + return time; + }, + onPointAdded: (_, i) => this.track_info.time = this._data[i].duration, + scale: (opts.time && opts.time != "summary" && !L.Browser.mobile) && { + axis : "x", + position : "top", + scale : { + attr : "duration", + min : 0, + }, + label : time.label, + labelY : -10, + labelX : () => this._width(), + name : "time", + ticks : () => _.clamp(this._chart._xTicks() / 2, [4, +Infinity]), + tickFormat : (d) => (d == 0 ? '' : opts.xTimeFormat(d)), + onAxisMount: axis => { + axis.select(".domain") + .remove(); + axis.selectAll("text") + .attr('opacity', 0.65) + .style('font-family', 'Monospace') + .style('font-size', '110%'); + axis.selectAll(".tick line") + .attr('y2', this._height()) + .attr('stroke-dasharray', 2) + .attr('opacity', 0.75); + } + }, + tooltips: [ + (this.options.time) && { + name: 'time', + chart: (item) => L._("T: ") + _.formatTime(item.duration || 0), + order: 20 + }, + (this.options.timestamps) && { + name: 'date', + chart: (item) => L._("t: ") + this.options.timeFormat(item.time), + order: 21, + } + ], + summary: (this.options.time) && { + "tottime" : { + label: "Total Time: ", + value: (track) => _.formatTime(track.time || 0), + order: 20 + } + } + }; +} \ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/src/index.css b/assets/static/vendor/leaflet-elevation/src/index.css new file mode 100644 index 0000000..9b29174 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/index.css @@ -0,0 +1,329 @@ +.leaflet-hidden { + visibility: hidden; +} + +.legend { + cursor: pointer; +} + +.leaflet-container { + z-index: 0; + /* prevent overlapping the .elevation-detached chart */ +} + +.elevation-control .background { + background-color: var(--ele-bg, rgba(70, 130, 180, 0.2)); + border-radius: 5px; + overflow: visible; + display: block; + touch-action: none; + user-select: none; + max-width: 100%; +} + +.elevation-control .grid, +.elevation-control .area > foreignObject, +.elevation-control .axis, +.elevation-control .tooltip, +.height-focus.line { + pointer-events: none; +} + +.elevation-control .axis line, +.elevation-control .axis path { + stroke: var(--ele-axis, #2D1130); + stroke-width: 1; + fill: none; +} + +.elevation-control .grid .tick line { + stroke: var(--ele-grid, #EEE); + stroke-width: 1px; + shape-rendering: crispEdges; +} + +.elevation-control .grid path { + stroke-width: 0; +} + +.elevation-control .axis text, +.elevation-control .legend text, +.elevation-control .point text { + fill: #000; + font-weight: 700; + paint-order: stroke fill; + stroke: #fff; + stroke-width: 2px +} + +.elevation-control .y.axis text { + text-anchor: end; +} + +.elevation-control .area { + fill: var(--ele-area, #4682B4); + stroke: var(--ele-stroke, #000); + stroke-width: 1.2; + paint-order: stroke fill; +} + +.elevation-control .horizontal-drag-line { + cursor: row-resize; + stroke: transparent; + stroke-dasharray: 5; + stroke-width: 1.1; +} + +.elevation-control .active .horizontal-drag-line { + stroke: #000; +} + +.elevation-control .horizontal-drag-label { + fill: #000; + font-weight: 700; + paint-order: stroke; + stroke: #FFF; + stroke-width: 2px; +} + +.elevation-control .ruler { + color: #000; + cursor: row-resize; +} + +.elevation-control .mouse-focus-line { + stroke: #000; + stroke-width: 1; +} + +.elevation-control .mouse-focus-label-rect { + fill: #000; + fill-opacity: 0.75; + stroke-width: 1; + stroke: #444; +} + +.elevation-control .mouse-focus-label-text { + fill: #FFF; + font-size: 10px; +} + +.elevation-control .brush .overlay { + cursor: unset; +} + +.elevation-control .brush .selection { + fill: var(--ele-brush, rgba(23, 74, 117, 0.4)); + stroke: none; + fill-opacity: unset; +} + +.elevation-summary { + font-family: "Lucida Grande", "Lucida Sans Unicode", Verdana, Arial, Helvetica, sans-serif; + font-size: 12px; + margin: var(--ele-sum-margin, 0 auto); + 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); +} + +.elevation-summary>span:not(:last-child):after { + content: var(--ele-sum-sep, ''); +} + +.multiline-summary>span { + display: block; +} + +.multiline-summary .download { + float: right; + margin-top: -3em; + margin-right: 2em; + font-weight: bold; + font-size: 1.2em; +} + +.elevation-summary .summaryvalue { + font-weight: bold; +} + +.elevation-toggle-icon { + background-color: #fff; + right: 5px; + top: 5px; + height: var(--ele-toggle-size, 36px); + width: var(--ele-toggle-size, 36px); + cursor: pointer; + box-shadow: 0 1px 7px rgba(0, 0, 0, 0.4); + border-radius: 5px; + display: inline-block; + position: var(--ele-toggle-pos, relative); +} + +.elevation-toggle-icon:before { + content: '\2716'; + display: var(--ele-close-btn, none); + color: #000; + width: 100%; + line-height: 20px; + text-align: center; + font-weight: bold; + font-size: 15px; +} + +.leaflet-elevation-pane .height-focus, +.leaflet-overlay-pane .height-focus { + stroke: #000; + fill: var(--ele-circle, var(--ele-area, #FFF)); +} + +.leaflet-elevation-pane .height-focus.line, +.leaflet-overlay-pane .height-focus.line { + stroke-width: 2; +} + +.leaflet-elevation-pane .height-focus-label, +.leaflet-overlay-pane .height-focus-label { + font-size: 12px; + font-weight: 600; + fill: #000; + paint-order: stroke; + stroke: #FFF; + stroke-width: 2px; +} + +.elevation-waypoint-icon:before, +.elevation-position-icon:before { + content: ""; + width: 100%; + height: 100%; + display: inline-block; + background: var(--ele-marker) no-repeat center center / contain; +} + +.elevation-polyline { + stroke: var(--ele-poly, var(--ele-area, #000)); + 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); +} + +/* CHART STATES /////////////////////////////////////////////////// */ + +.elevation-detached { + font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif; + height: auto; + width: 100%; + position: relative; + z-index: 0; +} + +.elevation-detached .area { + fill-opacity: var(--ele-alpha, 0.8); +} + +.elevation-detached.elevation-collapsed .elevation-summary { + display: block; +} + +.elevation-detached.elevation-collapsed .elevation-toggle-icon { + top: 5px; + right: 9px; + bottom: 5px; + margin: auto; +} + +.elevation-control.elevation-collapsed > * { + display: none; +} + +.elevation-control.elevation-collapsed > .elevation-toggle-icon { + display: inline-block; +} + +.elevation-detached { + --ele-sum-margin: 12px 35px; + --ele-sum-shadow: none; + --ele-toggle-pos: absolute; +} + +.elevation-expanded { + --ele-close-btn: inline-block; + --ele-toggle-bg: none; + --ele-toggle-pos: absolute; + --ele-toggle-size: 20px; +} + +.inline-summary { + --ele-sum-sep: "\0020\2014\0020"; +} + +.elevation-waypoint-icon { + --ele-marker: url(../images/elevation-pushpin.svg); +} + +.elevation-position-icon { + --ele-marker: url(../images/elevation-position.svg); +} + +/* LIME THEME ///////////////////////////////////////////////////// */ +.lime-theme { + --ele-bg: rgba(156, 194, 34, 0.2); + --ele-axis: #566B13; + --ele-area: #9CC222; + --ele-grid: #CCC; + --ele-brush: rgba(99, 126, 11, 0.4); + --ele-poly: #566B13; + --ele-line: #70ab00; +} + +/* STEELBLUE THEME //////////////////////////////////////////////// */ +.steelblue-theme { + --ele-axis: #0D1821; + --ele-area: #4682B4; + --ele-brush: rgba(23, 74, 117, 0.4); + --ele-line: #174A75; +} + +/* PURPLE THEME /////////////////////////////////////////////////// */ +.purple-theme { + --ele-bg: rgba(115, 44, 123, 0.2); + --ele-area: #732C7B; + --ele-brush: rgba(74, 14, 80, 0.4); + --ele-line: #732c7b; +} + +/* YELLOW THEME /////////////////////////////////////////////////// */ +.yellow-theme { + --ele-area: #FF0; +} + +/* RED THEME ////////////////////////////////////////////////////// */ +.red-theme { + --ele-area: #F00; +} + +/* MAGENTA THEME ////////////////////////////////////////////////// */ +.magenta-theme { + --ele-bg: rgba(255, 255, 255, 0.47); + --ele-area: #FF005E; +} + +/* LIGHTBLUE THEME //////////////////////////////////////////////// */ +.lightblue-theme { + --ele-area: #3366CC; + --ele-alpha: 0.45; + --ele-stroke: #4682B4; + --ele-circle: #fff; + --ele-line: #000; +} + +.elevation-detached.lightblue-theme .area { + stroke: #3366CC; +} + +/* leaflet-distance-markers */ +.dist-marker { + font-size: 0.5rem; + border: 1px solid #777; + border-radius: 10px; + text-align: center; + color: #000; + background: #fff; +} diff --git a/assets/static/vendor/leaflet-elevation/src/index.js b/assets/static/vendor/leaflet-elevation/src/index.js new file mode 100644 index 0000000..4e094ff --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/index.js @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2019, GPL-3.0+ Project, Raruto + * + * This file is free software: you may copy, redistribute and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 2 of the License, or (at your + * option) any later version. + * + * This file is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Copyright (c) 2013-2016, MIT License, Felix “MrMufflon” Bache + * + * Permission to use, copy, modify, and/or distribute this software + * for any purpose with or without fee is hereby granted, provided + * that the above copyright notice and this permission notice appear + * in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL + * WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE + * AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR + * CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS + * OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, + * NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +import * as _ from './utils'; +import { Elevation } from './control'; + +Elevation.Utils = _; + +L.control.elevation = (options) => new Elevation(options); diff --git a/assets/static/vendor/leaflet-elevation/src/options.js b/assets/static/vendor/leaflet-elevation/src/options.js new file mode 100644 index 0000000..5923163 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/options.js @@ -0,0 +1,88 @@ +import * as _ from './utils'; + +export var Options = { + autofitBounds: true, + autohide: false, + autohideMarker: true, + almostover: true, + altitude: true, + closeBtn: true, + collapsed: false, + detached: true, + distance: true, + distanceMarkers: { lazy: true, distance: true, direction: true }, + dragging: !L.Browser.mobile, + downloadLink: 'link', + elevationDiv: "#elevation-div", + edgeScale: { bar: true, icon: false, coords: false }, + followMarker: true, + imperial: false, + legend: true, + handlers: ["Distance", "Time", "Altitude", "Slope", "Speed", "Acceleration"], + hotline: 'elevation', + marker: 'elevation-line', + markerIcon: L.divIcon({ + className: 'elevation-position-marker', + html: '', + iconSize: [32, 32], + iconAnchor: [16, 16], + }), + position: "topright", + polyline: { + className: 'elevation-polyline', + color: '#000', + opacity: 0.75, + weight: 5, + lineCap: 'round' + }, + polylineSegments: { + className: 'elevation-polyline-segments', + color: '#F00', + interactive: false, + }, + preferCanvas: false, + reverseCoords: false, + ruler: true, + theme: "lightblue-theme", + summary: 'inline', + slope: false, + speed: false, + time: true, + timeFactor: 3600, + timestamps: false, + trkStart: { className: 'start-marker', radius: 6, weight: 2, color: '#fff', fillColor: '#00d800', fillOpacity: 1, interactive: false }, + trkEnd: { className: 'end-marker', radius: 6, weight: 2, color: '#fff', fillColor: '#ff0606', fillOpacity: 1, interactive: false }, + waypoints: true, + wptIcons: { + '': L.divIcon({ + className: 'elevation-waypoint-marker', + html: '', + iconSize: [30, 30], + iconAnchor: [8, 30], + }), + }, + wptLabels: true, + xAttr: "dist", + xLabel: "km", + yAttr: "z", + yLabel: "m", + zFollow: false, + zooming: !L.Browser.Mobile, + + // Quite uncommon and undocumented options + margins: { top: 30, right: 30, bottom: 30, left: 40 }, + height: (screen.height * 0.3) || 200, + width: (screen.width * 0.6) || 600, + xTicks: undefined, + yTicks: undefined, + + decimalsX: 2, + decimalsY: 0, + forceAxisBounds: false, + interpolation: "curveLinear", + yAxisMax: undefined, + yAxisMin: undefined, + + // Prevent CORS issues for relative locations (dynamic import) + srcFolder: ((document.currentScript && document.currentScript.src) || (import.meta && import.meta.url)).split("/").slice(0,-1).join("/") + '/', +}; diff --git a/assets/static/vendor/leaflet-elevation/src/utils.js b/assets/static/vendor/leaflet-elevation/src/utils.js new file mode 100644 index 0000000..90035f7 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/utils.js @@ -0,0 +1,203 @@ +/** + * TODO: exget computed styles of theese values from actual "CSS vars" + **/ +export const Colors = { + 'lightblue': { area: '#3366CC', alpha: 0.45, stroke: '#3366CC' }, + 'magenta' : { area: '#FF005E' }, + 'yellow' : { area: '#FF0' }, + 'purple' : { area: '#732C7B' }, + 'steelblue': { area: '#4682B4' }, + 'red' : { area: '#F00' }, + 'lime' : { area: '#9CC222', line: '#566B13' } +}; + +const SEC = 1000; +const MIN = SEC * 60; +const HOUR = MIN * 60; +const DAY = HOUR * 24; + +export function resolveURL(src, baseUrl) { + return (new URL(src, (src.startsWith('../') || src.startsWith('./')) ? baseUrl : undefined)).toString() +}; + +/** + * Convert a time (millis) to a human readable duration string (%Dd %H:%M'%S") + */ +export function formatTime(t) { + let d = Math.floor(t / DAY); + let h = Math.floor( (t - d * DAY) / HOUR); + let m = Math.floor( (t - d * DAY - h * HOUR) / MIN); + let s = Math.round( (t - d * DAY - h * HOUR - m * MIN) / SEC); + if ( s === 60 ) { m++; s = 0; } + if ( m === 60 ) { h++; m = 0; } + if ( h === 24 ) { d++; h = 0; } + return (d ? d + "d " : '') + h.toString().padStart(2, 0) + ':' + m.toString().padStart(2, 0) + "'" + s.toString().padStart(2, 0) + '"'; +} + +/** + * Convert a time (millis) to human readable date string (dd-mm-yyyy hh:mm:ss) + */ + export function formatDate(format) { + if (!format) { + return (time) => (new Date(time)).toLocaleString().replaceAll('/', '-').replaceAll(',', ' '); + } else if (format == 'time') { + return (time) => (new Date(time)).toLocaleTimeString(); + } else if (format == 'date') { + return (time) => (new Date(time)).toLocaleDateString(); + } + return (time) => format(time); +} + +/** + * Generate download data event. + */ + export function saveFile(dataURI, fileName) { + let a = create('a', '', { href: dataURI, target: '_new', download: fileName || "", style: "display:none;" }); + let b = document.body; + b.appendChild(a); + a.click(); + b.removeChild(a); +} + + +/** + * Convert SVG Path into Path2D and then update canvas + */ + export function drawCanvas(ctx, path) { + path.classed('canvas-path', true); + + ctx.beginPath(); + ctx.moveTo(0, 0); + let p = new Path2D(path.attr('d')); + + ctx.strokeStyle = path.__strokeStyle || path.attr('stroke'); + ctx.fillStyle = path.__fillStyle || path.attr('fill'); + ctx.lineWidth = 1.25; + ctx.globalCompositeOperation = 'source-over'; + + // stroke opacity + ctx.globalAlpha = path.attr('stroke-opacity') || 0.3; + ctx.stroke(p); + + // fill opacity + ctx.globalAlpha = path.attr('fill-opacity') || 0.45; + ctx.fill(p); + + ctx.globalAlpha = 1; + + ctx.closePath(); +} + +/** + * Loop and extract GPX Extensions handled by "@tmcw/toGeoJSON" (eg. "coordinateProperties" > "times") + */ +export function coordPropsToMeta(coordProps, name, parser) { + return coordProps && (({props, point, id, isMulti }) => { + if (props) { + for (const key of coordProps) { + if (key in props) { + point.meta[name] = (parser || parseNumeric).call(this, (isMulti ? props[key][isMulti] : props[key]), id); + break; + } + } + } + }); +} + +/** + * Extract numeric property (id) from GeoJSON object + */ +export const parseNumeric = (property, id) => parseInt((typeof property === 'object' ? property[id] : property)); + +/** + * Extract datetime property (id) from GeoJSON object + */ +export const parseDate = (property, id) => new Date(Date.parse((typeof property === 'object' ? property[id] : property))); + +/** + * A little bit shorter than L.DomUtil + */ +export const addClass = (n, str) => n && str.split(" ").every(s => s && L.DomUtil.addClass(n, s)); +export const removeClass = (n, str) => n && str.split(" ").every(s => s && L.DomUtil.removeClass(n, s)); +export const toggleClass = (n, str, cond) => (cond ? addClass : removeClass)(n, str); +export const replaceClass = (n, rem, add) => (rem && removeClass(n, rem)) || (add && addClass(n, add)); +export const style = (n, k, v) => (typeof v === "undefined" && L.DomUtil.getStyle(n, k)) || n.style.setProperty(k, v); +export const toggleStyle = (n, k, v, cond) => style(n, k, cond ? v : ''); +export const setAttributes = (n, attrs) => { for (let k in attrs) { n.setAttribute(k, attrs[k]); } }; +export const toggleEvent = (el, e, fn, cond) => el[cond ? 'on' : 'off'](e, fn); +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; }; +export const append = (n, c) => n.appendChild(c); +export const insert = (n, c, pos) => n.insertAdjacentElement(pos, c); +export const select = (str, n) => (n || document).querySelector(str); +export const each = (obj, fn) => { for (let i in obj) fn(obj[i], i); }; +export const randomId = () => Math.random().toString(36).substr(2, 9); + +/** + * TODO: use generators instead? (ie. "yield") + */ +export const iMax = (iVal, max = -Infinity) => (iVal > max ? iVal : max); +export const iMin = (iVal, min = +Infinity) => (iVal < min ? iVal : min); +export const iAvg = (iVal, avg = 0, idx = 1) => (iVal + avg * (idx - 1)) / idx; +export const iSum = (iVal, sum = 0) => iVal + sum; + +/** + * Alias for some leaflet core functions + */ +export const { on, off } = L.DomEvent; +export const { throttle, wrapNum } = L.Util; +export const { hasClass } = L.DomUtil; + +/** + * Limit floating point precision + */ +export const round = L.Util.formatNum; + +/** + * Limit a number between min / max values + */ +export const clamp = (val, range) => range ? (val < range[0] ? range[0] : val > range[1] ? range[1] : val) : val; + +/** + * Limit a delta difference between two values + */ +export const wrapDelta = (curr, prev, deltaMax) => Math.abs(curr - prev) > deltaMax ? prev + deltaMax * Math.sign(curr - prev) : curr; + +/** + * A deep copy implementation that takes care of correct prototype chain and cycles, references + * + * @see https://web.dev/structured-clone/#features-and-limitations + */ +export function cloneDeep(o, skipProps = [], cache = []) { + switch(!o || typeof o) { + case 'object': + const hit = cache.filter(c => o === c.original)[0]; + if (hit) return hit.copy; // handle circular structures + const copy = Array.isArray(o) ? [] : Object.create(Object.getPrototypeOf(o)); + cache.push({ original: o, copy }); + Object + .getOwnPropertyNames(o) + .forEach(function (prop) { + const propdesc = Object.getOwnPropertyDescriptor(o, prop); + Object.defineProperty( + copy, + prop, + propdesc.get || propdesc.set + ? propdesc // just copy accessor properties + : { // deep copy data properties + writable: propdesc.writable, + configurable: propdesc.configurable, + enumerable: propdesc.enumerable, + value: skipProps.includes(prop) ? propdesc.value : cloneDeep(propdesc.value, skipProps, cache), + } + ); + }); + return copy; + case 'function': + case 'symbol': + console.warn('cloneDeep: ' + typeof o + 's not fully supported:', o); + case true: + // null, undefined or falsy primitive + default: + return o; + } +} \ No newline at end of file diff --git a/assets/static/vendor/leaflet-elevation/src/utils.spec.js b/assets/static/vendor/leaflet-elevation/src/utils.spec.js new file mode 100644 index 0000000..7371a96 --- /dev/null +++ b/assets/static/vendor/leaflet-elevation/src/utils.spec.js @@ -0,0 +1,50 @@ +/** + * src/utils.js + */ + +import { suite } from 'uvu'; +import * as assert from 'uvu/assert'; +import '../test/setup/jsdom.js' +import { iAvg, iMin, iMax, iSum } from "../src/utils.js"; + +const toFixed = (n) => +n.toFixed(2); + +const test = suite('src/utils.js'); + +test('iAvg()', () => { + let avg; + avg = iAvg(100, undefined, 1); assert.is(toFixed(avg), 100); // average for [100] is 100 + avg = iAvg(100, avg, 2); assert.is(toFixed(avg), 100); // average for [100, 100] is 100 + avg = iAvg(200, avg, 3); assert.is(toFixed(avg), 133.33); // average for [100, 100, 200] is 133.33 + avg = iAvg(200, avg, 4); assert.is(toFixed(avg), 150); // average for [100, 100, 200, 200] is 150 + avg = iAvg(NaN, avg, 5); assert.ok(isNaN(avg)); // average for [100, 100, 200, 200, NaN] is NaN +}); + +test('iMin()', () => { + let min; + min = iMin(100, undefined); assert.is(toFixed(min), 100); // min for [100] is 100 + min = iMin(NaN, min); assert.is(toFixed(min), 100); // min for [100, NaN] is 100 + min = iMin(0, min); assert.is(toFixed(min), 0); // min for [100, NaN, 0] is 100 + min = iMin(-200, min); assert.is(toFixed(min), -200); // min for [100, NaN, 0, -200] is -200 + min = iMin(200, min); assert.is(toFixed(min), -200); // min for [100, NaN, -100, -200, 200] is -200 +}); + +test('iMax()', () => { + let max; + max = iMax(100, undefined); assert.is(toFixed(max), 100); // max for [100] is 100 + max = iMax(NaN, max); assert.is(toFixed(max), 100); // max for [100, NaN] is 100 + max = iMax(0, max); assert.is(toFixed(max), 100); // max for [100, NaN, 0] is 100 + max = iMax(-200, max); assert.is(toFixed(max), 100); // max for [100, NaN, 0, -200] is 100 + max = iMax(200, max); assert.is(toFixed(max), 200); // max for [100, NaN, -100, -200, 200] is 200 +}); + +test('iSum()', () => { + let sum; + sum = iSum(10.25, undefined); assert.is(toFixed(sum), 10.25); // sum for [10.25] is 10.25 + sum = iSum(0, sum); assert.is(toFixed(sum), 10.25); // sum for [10.25, 0] is 10.25 + sum = iSum(-0.25, sum); assert.is(toFixed(sum), 10); // sum for [10.25, 0, -0.25] is 10 + sum = iSum(-10, sum); assert.is(toFixed(sum), 0); // sum for [10.25, 0, -0.25, -10] is 0 + sum = iSum(NaN, sum); assert.ok(isNaN(sum)); // sum for [10.25, 0, -0.25, -10, NaN] is NaN +}); + +test.run(); \ No newline at end of file diff --git a/assets/static/vendor/leaflet-fullscreen/Control.FullScreen.css b/assets/static/vendor/leaflet-fullscreen/Control.FullScreen.css new file mode 100644 index 0000000..7bd117e --- /dev/null +++ b/assets/static/vendor/leaflet-fullscreen/Control.FullScreen.css @@ -0,0 +1,30 @@ +:root { + --fullscreen-icon-enter: url('data:image/svg+xml,'); + --fullscreen-icon-exit: url('data:image/svg+xml,'); +} + +.leaflet-fullscreen-icon { + background-image: var(--fullscreen-icon-enter); + background-size: 26px 26px; +} + +.leaflet-fullscreen-icon.leaflet-fullscreen-on { + background-image: var(--fullscreen-icon-exit); +} + +/* Safari still needs this vendor-prefix: https://caniuse.com/mdn-css_selectors_fullscreen */ +.leaflet-container:-webkit-full-screen, +.leaflet-container:fullscreen { + width: 100% !important; + height: 100% !important; + z-index: 99999; +} + +.leaflet-pseudo-fullscreen { + position: fixed !important; + width: 100% !important; + height: 100% !important; + top: 0 !important; + left: 0 !important; + z-index: 99999; +} diff --git a/assets/static/vendor/leaflet-fullscreen/Control.FullScreen.umd.js b/assets/static/vendor/leaflet-fullscreen/Control.FullScreen.umd.js new file mode 100644 index 0000000..91b081d --- /dev/null +++ b/assets/static/vendor/leaflet-fullscreen/Control.FullScreen.umd.js @@ -0,0 +1,299 @@ +/*! + * leaflet.fullscreen + * (c) Bruno B.; MIT License + * https://github.com/brunob/leaflet.fullscreen + */ +/*! GENERATED FILE - DO NOT EDIT DIRECTLY. Edit files in src/ and run 'npm run build' */ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('leaflet')) : + typeof define === 'function' && define.amd ? define(['leaflet'], factory) : + (global = typeof globalThis !== 'undefined' ? globalThis : global || self, (global.L = global.L || {}, global.L.Control = global.L.Control || {}, global.L.Control.FullScreen = factory(global.L))); +})(this, (function (leaflet) { 'use strict'; + + if (typeof document === 'undefined') { + console.warn('"window.document" is undefined; leaflet.fullscreen requires this object to access the DOM'); + } + + const nativeAPI = (() => { + const methodMap = [ + // Standard + [ + 'requestFullscreen', + 'exitFullscreen', + 'fullscreenElement', + 'fullscreenEnabled', + 'fullscreenchange', + 'fullscreenerror' + ], + // New WebKit + [ + 'webkitRequestFullscreen', + 'webkitExitFullscreen', + 'webkitFullscreenElement', + 'webkitFullscreenEnabled', + 'webkitfullscreenchange', + 'webkitfullscreenerror' + ] + ]; + + const baseList = methodMap[0]; + const ret = {}; + + for (const methodList of methodMap) { + if (methodList[1] in document) { + for (let i = 0; i < methodList.length; i++) { + ret[baseList[i]] = methodList[i]; + } + return ret; + } + } + + return false; + })(); + + const eventNameMap = { + change: nativeAPI.fullscreenchange, + error: nativeAPI.fullscreenerror, + }; + + const fullscreenAPI = { + // Request fullscreen for a specific element + async request(element, options) { + element = element || document.documentElement; + const requestMethod = nativeAPI.requestFullscreen; + + await this._callNative(() => { + return element[requestMethod](options); + }); + }, + // Exit fullscreen mode + async exit() { + if (!this.isFullscreen) { + return; + } + const exitMethod = nativeAPI.exitFullscreen; + + await this._callNative(() => { + return document[exitMethod](); + }); + }, + // Helper to handle browser differences (Promise vs Event) + async _callNative(action) { + const result = action(); + + // Modern browsers return a Promise from requestFullscreen/exitFullscreen + if (result instanceof Promise) { + await result; + return; + } + + // Fallback for Safari < 16.4 (March 2023) which doesn't return a Promise + await new Promise((resolve) => { + const listener = () => { + this.off('change', listener); + resolve(); + }; + this.on('change', listener); + }); + }, + // Add event listener for fullscreen changes + on(event, callback) { + const eventName = eventNameMap[event]; + if (eventName) { + document.addEventListener(eventName, callback); + } + }, + // Remove event listener + off(event, callback) { + const eventName = eventNameMap[event]; + if (eventName) { + document.removeEventListener(eventName, callback); + } + }, + nativeAPI: nativeAPI + }; + + Object.defineProperties(fullscreenAPI, { + isFullscreen: { + get() { + return Boolean(document[nativeAPI.fullscreenElement]); + } + }, + isEnabled: { + enumerable: true, + get() { + // Coerce to boolean in case of old WebKit + return Boolean(document[nativeAPI.fullscreenEnabled]); + } + } + }); + + const FullScreen = leaflet.Control.extend({ + options: { + position: 'topleft', + title: 'Full Screen', + titleCancel: 'Exit Full Screen', + forceSeparateButton: false, + forcePseudoFullscreen: false, + fullscreenElement: false + }, + + _screenfull: fullscreenAPI, + + onAdd(map) { + let className = 'leaflet-control-zoom-fullscreen'; + let container; + let content = ''; + + if (map.zoomControl && !this.options.forceSeparateButton) { + container = map.zoomControl._container; + } else { + container = document.createElement('div'); + container.className = 'leaflet-bar'; + } + + if (this.options.content) { + content = this.options.content; + } else { + className += ' leaflet-fullscreen-icon'; + } + + this._createButton(this.options.title, className, content, container, this.toggleFullScreen, this); + this._map.fullscreenControl = this; + + this._map.on('enterFullscreen exitFullscreen', this._toggleState, this); + + return container; + }, + + onRemove() { + leaflet.DomEvent + .off(this.link, 'click', leaflet.DomEvent.stop) + .off(this.link, 'click', this.toggleFullScreen, this); + + if (this._screenfull.isEnabled) { + leaflet.DomEvent + .off(document, this._screenfull.nativeAPI.fullscreenchange, leaflet.DomEvent.stop) + .off(document, this._screenfull.nativeAPI.fullscreenchange, this._handleFullscreenChange, this); + } + }, + + _createButton(title, className, content, container, fn, context) { + this.link = document.createElement('a'); + this.link.className = className; + container.appendChild(this.link); + this.link.href = '#'; + this.link.title = title; + this.link.innerHTML = content; + + this.link.setAttribute('role', 'button'); + this.link.setAttribute('aria-label', title); + + leaflet.DomEvent.disableClickPropagation(container); + + leaflet.DomEvent + .on(this.link, 'click', leaflet.DomEvent.stop) + .on(this.link, 'click', fn, context); + + if (this._screenfull.isEnabled) { + leaflet.DomEvent + .on(document, this._screenfull.nativeAPI.fullscreenchange, leaflet.DomEvent.stop) + .on(document, this._screenfull.nativeAPI.fullscreenchange, this._handleFullscreenChange, context); + } + + return this.link; + }, + + async toggleFullScreen() { + const map = this._map; + const { fullscreenElement, forcePseudoFullscreen } = this.options; + + // Target element: use custom element if provided, otherwise the map container + const container = fullscreenElement || map.getContainer(); + + // Check if we should use the native Fullscreen API + const useNative = this._screenfull.isEnabled && !forcePseudoFullscreen; + + if (map._isFullscreen) { + // --- EXIT FULLSCREEN --- + + // Set flag to prevent the 'fullscreenchange' listener from firing a duplicate exit event + map._exitFired = true; + + if (useNative) { + await this._screenfull.exit(); + } else { + container.classList.remove('leaflet-pseudo-fullscreen'); + } + + map.invalidateSize(); + map._isFullscreen = false; + map.fire('exitFullscreen'); + } else { + // --- ENTER FULLSCREEN --- + + map._exitFired = false; + + if (useNative) { + await this._screenfull.request(container); + } else { + container.classList.add('leaflet-pseudo-fullscreen'); + } + + map.invalidateSize(); + map._isFullscreen = true; + map.fire('enterFullscreen'); + } + }, + + _toggleState() { + const { title, titleCancel } = this.options; + const isFullscreen = this._map._isFullscreen; + + // Update Title & Aria Label + this.link.title = isFullscreen ? titleCancel : title; + this.link.setAttribute('aria-label', this.link.title); + this.link.setAttribute('aria-pressed', isFullscreen.toString()); + + // Update Icon Class + this.link.classList.toggle('leaflet-fullscreen-on', isFullscreen); + }, + + _handleFullscreenChange() { + const map = this._map; + + // Check if fullscreen was exited via browser (ESC key or browser UI) + // and we didn't trigger the exit ourselves + const wasExitedExternally = !this._screenfull.isFullscreen && map._isFullscreen && !map._exitFired; + + if (wasExitedExternally) { + // Sync internal state with browser state + map._exitFired = true; + map._isFullscreen = false; + + // Notify listeners and adjust map size + map.fire('exitFullscreen'); + map.invalidateSize(); + } + } + }); + + leaflet.Map.include({ + toggleFullscreen() { + this.fullscreenControl.toggleFullScreen(); + } + }); + + leaflet.Map.mergeOptions({ + fullscreenControl: false + }); + + leaflet.Map.addInitHook(function() { + if (this.options.fullscreenControl) { + this.addControl(new FullScreen(this.options.fullscreenControlOptions)); + } + }); + + return FullScreen; + +})); diff --git a/assets/static/vendor/leaflet-i18n/leaflet-i18n.js b/assets/static/vendor/leaflet-i18n/leaflet-i18n.js new file mode 100644 index 0000000..9456214 --- /dev/null +++ b/assets/static/vendor/leaflet-i18n/leaflet-i18n.js @@ -0,0 +1,41 @@ +// Following https://github.com/Leaflet/Leaflet/blob/master/PLUGIN-GUIDE.md +(function (factory, window) { + + // define an AMD module that relies on 'leaflet' + if (typeof define === 'function' && define.amd) { + define(['leaflet'], factory); + + // define a Common JS module that relies on 'leaflet' + } else if (typeof exports === 'object') { + module.exports = factory(require('leaflet')); + } + + // attach your plugin to the global 'L' variable + if (typeof window !== 'undefined' && window.L) { + factory(window.L); + + } +}(function (L) { + L.locales = {}; + L.locale = null; + L.registerLocale = function registerLocale(code, locale) { + L.locales[code] = L.Util.extend({}, L.locales[code], locale); + }; + L.setLocale = function setLocale(code) { + L.locale = code; + }; + return L.i18n = L._ = function translate(string, data) { + if (L.locale && L.locales[L.locale] && typeof L.locales[L.locale][string] !== "undefined") { + string = L.locales[L.locale][string]; + } + try { + // Do not fail if some data is missing + // a bad translation should not break the app + string = L.Util.template(string, data); + } + catch (err) {/*pass*/ + } + + return string; + }; +}, window)); diff --git a/assets/static/vendor/leaflet-providers/leaflet-providers.js b/assets/static/vendor/leaflet-providers/leaflet-providers.js new file mode 100644 index 0000000..a134d58 --- /dev/null +++ b/assets/static/vendor/leaflet-providers/leaflet-providers.js @@ -0,0 +1,1121 @@ +(function(root, factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['leaflet'], factory); + } else if (typeof modules === 'object' && module.exports) { + // define a Common JS module that relies on 'leaflet' + module.exports = factory(require('leaflet')); + } else { + // Assume Leaflet is loaded into global object L already + factory(L); + } +}(this, function(L) { + 'use strict'; + + L.TileLayer.Provider = L.TileLayer.extend({ + initialize: function(arg, options) { + var providers = L.TileLayer.Provider.providers; + + var parts = arg.split('.'); + + var providerName = parts[0]; + var variantName = parts[1]; + + if (!providers[providerName]) { + throw 'No such provider (' + providerName + ')'; + } + + var provider = { + url: providers[providerName].url, + options: providers[providerName].options + }; + + // overwrite values in provider from variant. + if (variantName && 'variants' in providers[providerName]) { + if (!(variantName in providers[providerName].variants)) { + throw 'No such variant of ' + providerName + ' (' + variantName + ')'; + } + var variant = providers[providerName].variants[variantName]; + var variantOptions; + if (typeof variant === 'string') { + variantOptions = { + variant: variant + }; + } else { + variantOptions = variant.options; + } + provider = { + url: variant.url || provider.url, + options: L.Util.extend({}, provider.options, variantOptions) + }; + } + + // replace attribution placeholders with their values from toplevel provider attribution, + // recursively + var attributionReplacer = function(attr) { + if (attr.indexOf('{attribution.') === -1) { + return attr; + } + return attr.replace( + /\{attribution.(\w*)\}/g, + function(match, attributionName) { + return attributionReplacer(providers[attributionName].options.attribution); + } + ); + }; + provider.options.attribution = attributionReplacer(provider.options.attribution); + + // Compute final options combining provider options with any user overrides + var layerOpts = L.Util.extend({}, provider.options, options); + L.TileLayer.prototype.initialize.call(this, provider.url, layerOpts); + } + }); + + /** + * Definition of providers. + * see http://leafletjs.com/reference.html#tilelayer for options in the options map. + */ + + L.TileLayer.Provider.providers = { + OpenStreetMap: { + url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + options: { + maxZoom: 19, + attribution: + '© OpenStreetMap contributors' + }, + variants: { + Mapnik: {}, + DE: { + url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png', + options: { + maxZoom: 18 + } + }, + CH: { + url: 'https://tile.osm.ch/switzerland/{z}/{x}/{y}.png', + options: { + maxZoom: 18, + bounds: [[45, 5], [48, 11]] + } + }, + France: { + url: 'https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png', + options: { + maxZoom: 20, + attribution: '© OpenStreetMap France | {attribution.OpenStreetMap}' + } + }, + HOT: { + url: 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', + options: { + attribution: + '{attribution.OpenStreetMap}, ' + + 'Tiles style by Humanitarian OpenStreetMap Team ' + + 'hosted by OpenStreetMap France' + } + }, + BZH: { + url: 'https://tile.openstreetmap.bzh/br/{z}/{x}/{y}.png', + options: { + attribution: '{attribution.OpenStreetMap}, Tiles courtesy of Breton OpenStreetMap Team', + bounds: [[46.2, -5.5], [50, 0.7]] + } + }, + CAT: { + url: 'https://tile.openstreetmap.bzh/ca/{z}/{x}/{y}.png', + options: { + attribution: '{attribution.OpenStreetMap}, Tiles courtesy of Breton OpenStreetMap Team', + } + } + } + }, + MapTilesAPI: { + url: 'https://maptiles.p.rapidapi.com/{variant}/{z}/{x}/{y}.png?rapidapi-key={apikey}', + options: { + attribution: + '© MapTiles API, {attribution.OpenStreetMap}', + variant: 'en/map/v1', + // Get your own MapTiles API access token here : https://www.maptilesapi.com/ + // NB : this is a demonstration key that comes with no guarantee and not to be used in production + apikey: '', + maxZoom: 19 + }, + variants: { + OSMEnglish: { + options: { + variant: 'en/map/v1' + } + }, + OSMFrancais: { + options: { + variant: 'fr/map/v1' + } + }, + OSMEspagnol: { + options: { + variant: 'es/map/v1' + } + } + } + }, + OpenSeaMap: { + url: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', + options: { + attribution: 'Map data: © OpenSeaMap contributors' + } + }, + OPNVKarte: { + url: 'https://tileserver.memomaps.de/tilegen/{z}/{x}/{y}.png', + options: { + maxZoom: 18, + attribution: 'Map memomaps.de CC-BY-SA, map data {attribution.OpenStreetMap}' + } + }, + OpenTopoMap: { + url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', + options: { + maxZoom: 17, + attribution: 'Map data: {attribution.OpenStreetMap}, SRTM | Map style: © OpenTopoMap (CC-BY-SA)' + } + }, + OpenRailwayMap: { + url: 'https://{s}.tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png', + options: { + maxZoom: 19, + attribution: 'Map data: {attribution.OpenStreetMap} | Map style: © OpenRailwayMap (CC-BY-SA)' + } + }, + OpenFireMap: { + url: 'http://openfiremap.org/hytiles/{z}/{x}/{y}.png', + options: { + maxZoom: 19, + attribution: 'Map data: {attribution.OpenStreetMap} | Map style: © OpenFireMap (CC-BY-SA)' + } + }, + SafeCast: { + url: 'https://s3.amazonaws.com/te512.safecast.org/{z}/{x}/{y}.png', + options: { + maxZoom: 16, + attribution: 'Map data: {attribution.OpenStreetMap} | Map style: © SafeCast (CC-BY-SA)' + } + }, + Stadia: { + url: 'https://tiles.stadiamaps.com/tiles/{variant}/{z}/{x}/{y}{r}.{ext}', + options: { + minZoom: 0, + maxZoom: 20, + attribution: + '© Stadia Maps ' + + '© OpenMapTiles ' + + '{attribution.OpenStreetMap}', + variant: 'alidade_smooth', + ext: 'png' + }, + variants: { + AlidadeSmooth: 'alidade_smooth', + AlidadeSmoothDark: 'alidade_smooth_dark', + AlidadeSatellite: { + options: { + attribution: + '© CNES, Distribution Airbus DS, © Airbus DS, © PlanetObserver (Contains Copernicus Data) | ' + + '© Stadia Maps ' + + '© OpenMapTiles ' + + '{attribution.OpenStreetMap}', + variant: 'alidade_satellite', + ext: 'jpg', + } + }, + OSMBright: 'osm_bright', + Outdoors: 'outdoors', + StamenToner: { + options: { + attribution: + '© Stadia Maps ' + + '© Stamen Design ' + + '© OpenMapTiles ' + + '{attribution.OpenStreetMap}', + variant: 'stamen_toner' + } + }, + StamenTonerBackground: { + options: { + attribution: + '© Stadia Maps ' + + '© Stamen Design ' + + '© OpenMapTiles ' + + '{attribution.OpenStreetMap}', + variant: 'stamen_toner_background' + } + }, + StamenTonerLines: { + options: { + attribution: + '© Stadia Maps ' + + '© Stamen Design ' + + '© OpenMapTiles ' + + '{attribution.OpenStreetMap}', + variant: 'stamen_toner_lines' + } + }, + StamenTonerLabels: { + options: { + attribution: + '© Stadia Maps ' + + '© Stamen Design ' + + '© OpenMapTiles ' + + '{attribution.OpenStreetMap}', + variant: 'stamen_toner_labels' + } + }, + StamenTonerLite: { + options: { + attribution: + '© Stadia Maps ' + + '© Stamen Design ' + + '© OpenMapTiles ' + + '{attribution.OpenStreetMap}', + variant: 'stamen_toner_lite' + } + }, + StamenWatercolor: { + url: 'https://tiles.stadiamaps.com/tiles/{variant}/{z}/{x}/{y}.{ext}', + options: { + attribution: + '© Stadia Maps ' + + '© Stamen Design ' + + '© OpenMapTiles ' + + '{attribution.OpenStreetMap}', + variant: 'stamen_watercolor', + ext: 'jpg', + minZoom: 1, + maxZoom: 16 + } + }, + StamenTerrain: { + options: { + attribution: + '© Stadia Maps ' + + '© Stamen Design ' + + '© OpenMapTiles ' + + '{attribution.OpenStreetMap}', + variant: 'stamen_terrain', + minZoom: 0, + maxZoom: 18 + } + }, + StamenTerrainBackground: { + options: { + attribution: + '© Stadia Maps ' + + '© Stamen Design ' + + '© OpenMapTiles ' + + '{attribution.OpenStreetMap}', + variant: 'stamen_terrain_background', + minZoom: 0, + maxZoom: 18 + } + }, + StamenTerrainLabels: { + options: { + attribution: + '© Stadia Maps ' + + '© Stamen Design ' + + '© OpenMapTiles ' + + '{attribution.OpenStreetMap}', + variant: 'stamen_terrain_labels', + minZoom: 0, + maxZoom: 18 + } + }, + StamenTerrainLines: { + options: { + attribution: + '© Stadia Maps ' + + '© Stamen Design ' + + '© OpenMapTiles ' + + '{attribution.OpenStreetMap}', + variant: 'stamen_terrain_lines', + minZoom: 0, + maxZoom: 18 + } + } + } + }, + Thunderforest: { + url: 'https://{s}.tile.thunderforest.com/{variant}/{z}/{x}/{y}{r}.png?apikey={apikey}', + options: { + attribution: + '© Thunderforest, {attribution.OpenStreetMap}', + variant: 'cycle', + apikey: '', + maxZoom: 22 + }, + variants: { + OpenCycleMap: 'cycle', + Transport: { + options: { + variant: 'transport' + } + }, + TransportDark: { + options: { + variant: 'transport-dark' + } + }, + SpinalMap: { + options: { + variant: 'spinal-map' + } + }, + Landscape: 'landscape', + Outdoors: 'outdoors', + Pioneer: 'pioneer', + MobileAtlas: 'mobile-atlas', + Neighbourhood: 'neighbourhood' + } + }, + BaseMapDE: { + url: 'https://sgx.geodatenzentrum.de/wmts_basemapde/tile/1.0.0/{variant}/default/GLOBAL_WEBMERCATOR/{z}/{y}/{x}.png', + options: { + attribution: 'Map data: © dl-de/by-2-0', + variant: 'de_basemapde_web_raster_farbe', + }, + variants: { + Color: 'de_basemapde_web_raster_farbe', + Grey: 'de_basemapde_web_raster_grau' + } + }, + CyclOSM: { + url: 'https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png', + options: { + maxZoom: 20, + attribution: 'CyclOSM | Map data: {attribution.OpenStreetMap}' + } + }, + Jawg: { + url: 'https://tile.jawg.io/{variant}/{z}/{x}/{y}{r}.png?access-token={accessToken}', + options: { + attribution: + '© JawgMaps ' + + '{attribution.OpenStreetMap}', + minZoom: 0, + maxZoom: 22, + variant: 'jawg-streets', + // Get your own Jawg access token here : https://www.jawg.io/lab/ + // NB : this is a demonstration key that comes with no guarantee + accessToken: '', + }, + variants: { + Streets: 'jawg-streets', + Terrain: 'jawg-terrain', + Lagoon: 'jawg-lagoon', + Sunny: 'jawg-sunny', + Dark: 'jawg-dark', + Light: 'jawg-light', + Matrix: 'jawg-matrix' + } + }, + MapBox: { + url: 'https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}{r}?access_token={accessToken}', + options: { + attribution: + '© Mapbox ' + + '{attribution.OpenStreetMap} ' + + 'Improve this map', + tileSize: 512, + maxZoom: 18, + zoomOffset: -1, + id: 'mapbox/streets-v11', + accessToken: '', + } + }, + MapTiler: { + url: 'https://api.maptiler.com/maps/{variant}/{z}/{x}/{y}{r}.{ext}?key={key}', + options: { + attribution: + '© MapTiler © OpenStreetMap contributors', + variant: 'streets', + ext: 'png', + key: '', + tileSize: 512, + zoomOffset: -1, + minZoom: 0, + maxZoom: 21 + }, + variants: { + Streets: 'streets-v2', + Basic: 'basic-v2', + Bright: 'bright-v2', + Pastel: 'pastel', + Positron: 'positron', + Hybrid: { + options: { + variant: 'hybrid', + ext: 'jpg' + } + }, + Toner: 'toner-v2', + Topo: 'topo-v2', + Voyager: 'voyager-v2', + Ocean: 'ocean', + Backdrop: 'backdrop', + Dataviz: 'dataviz', + DatavizLight: 'dataviz-light', + DatavizDark: 'dataviz-dark', + Aquarelle: { + options: { + variant: 'aquarelle', + ext: 'webp' + } + }, + Landscape: 'landscape', + Openstreetmap: { + options: { + variant: 'openstreetmap', + ext: 'jpg' + } + }, + Outdoor: 'outdoor-v2', + Satellite: { + options: { + variant: 'satellite', + ext: 'jpg' + } + }, + Winter: 'winter-v2', + } + }, + TomTom: { + url: 'https://{s}.api.tomtom.com/map/1/tile/{variant}/{style}/{z}/{x}/{y}.{ext}?key={apikey}', + options: { + variant: 'basic', + maxZoom: 22, + attribution: + '© 1992 - ' + new Date().getFullYear() + ' TomTom. ', + subdomains: 'abcd', + style: 'main', + ext: 'png', + apikey: '', + }, + variants: { + Basic: 'basic', + Hybrid: 'hybrid', + Labels: 'labels' + } + }, + Esri: { + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/{variant}/MapServer/tile/{z}/{y}/{x}', + options: { + variant: 'World_Street_Map', + attribution: 'Tiles © Esri' + }, + variants: { + WorldStreetMap: { + options: { + attribution: + '{attribution.Esri} — ' + + 'Source: Esri, DeLorme, NAVTEQ, USGS, Intermap, iPC, NRCAN, Esri Japan, METI, Esri China (Hong Kong), Esri (Thailand), TomTom, 2012' + } + }, + WorldTopoMap: { + options: { + variant: 'World_Topo_Map', + attribution: + '{attribution.Esri} — ' + + 'Esri, DeLorme, NAVTEQ, TomTom, Intermap, iPC, USGS, FAO, NPS, NRCAN, GeoBase, Kadaster NL, Ordnance Survey, Esri Japan, METI, Esri China (Hong Kong), and the GIS User Community' + } + }, + WorldImagery: { + options: { + variant: 'World_Imagery', + attribution: + '{attribution.Esri} — ' + + 'Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community' + } + }, + WorldTerrain: { + options: { + variant: 'World_Terrain_Base', + maxZoom: 13, + attribution: + '{attribution.Esri} — ' + + 'Source: USGS, Esri, TANA, DeLorme, and NPS' + } + }, + WorldShadedRelief: { + options: { + variant: 'World_Shaded_Relief', + maxZoom: 13, + attribution: '{attribution.Esri} — Source: Esri' + } + }, + WorldPhysical: { + options: { + variant: 'World_Physical_Map', + maxZoom: 8, + attribution: '{attribution.Esri} — Source: US National Park Service' + } + }, + OceanBasemap: { + options: { + variant: 'Ocean/World_Ocean_Base', + maxZoom: 13, + attribution: '{attribution.Esri} — Sources: GEBCO, NOAA, CHS, OSU, UNH, CSUMB, National Geographic, DeLorme, NAVTEQ, and Esri' + } + }, + NatGeoWorldMap: { + options: { + variant: 'NatGeo_World_Map', + maxZoom: 16, + attribution: '{attribution.Esri} — National Geographic, Esri, DeLorme, NAVTEQ, UNEP-WCMC, USGS, NASA, ESA, METI, NRCAN, GEBCO, NOAA, iPC' + } + }, + WorldGrayCanvas: { + options: { + variant: 'Canvas/World_Light_Gray_Base', + maxZoom: 16, + attribution: '{attribution.Esri} — Esri, DeLorme, NAVTEQ' + } + } + } + }, + OpenWeatherMap: { + url: 'http://{s}.tile.openweathermap.org/map/{variant}/{z}/{x}/{y}.png?appid={apiKey}', + options: { + maxZoom: 19, + attribution: 'Map data © OpenWeatherMap', + apiKey: '', + opacity: 0.5 + }, + variants: { + Clouds: 'clouds', + CloudsClassic: 'clouds_cls', + Precipitation: 'precipitation', + PrecipitationClassic: 'precipitation_cls', + Rain: 'rain', + RainClassic: 'rain_cls', + Pressure: 'pressure', + PressureContour: 'pressure_cntr', + Wind: 'wind', + Temperature: 'temp', + Snow: 'snow' + } + }, + + HERE: { + + /* + * HERE maps API Version 3. + * documentation at: https://www.here.com/docs/bundle/raster-tile-api-developer-guide/page/README.html + * These basemaps are free, but you need an API key. Please sign up at + * https://platform.here.com/portal/ + * Subscription remains for free, pricing details can be found here: https://www.here.com/get-started/pricing + * Version 3 deprecates the app_id and app_code access in favor of apiKey + */ + url: + 'https://maps.hereapi.com/v3/base/mc/' + // new base url for HERE maptile v3 api + '{z}/{x}/{y}/{format}?style={variant}&size={size}' + // slightly modified parameters + '&apiKey={app_id}&lg={language}', // replacing app-id with apikey + options: { + attribution: + 'Map © 1987-' + new Date().getFullYear() + ' HERE', + subdomains: '1234', + mapID: 'newest', + apiKey: '', // switching from appid and appcode to apikey + base: 'base', + variant: 'explore.day', + maxZoom: 20, + type: 'maptile', + language: 'eng', + format: 'png8', + size: '256' + }, + variants: { + exploreDay: 'explore.day', + liteDay: 'lite.day', + logisticsDay: 'logistics.day', + topoDay: 'topo.day', + logisticsNight: 'logistics.night', + exploreNight: 'explore.night', + topoNight: 'topo.night', + liteNight: 'lite.night', + exploreSatelliteDay: 'explore.satellite.day', + liteSatelliteDay: 'lite.satellite.day', + logisticsSatelliteDay: 'logistics.satellite.day', + basicMap: { + options: { + type: 'basetile' + } + }, + mapLabels: { + options: { + type: 'labeltile', + format: 'png' + } + }, + trafficFlow: { + options: { + base: 'traffic', + type: 'flowtile' + } + }, + carnavDayGrey: 'carnav.day.grey', + hybridDay: { + options: { + base: 'aerial', + variant: 'hybrid.day' + } + }, + hybridDayMobile: { + options: { + base: 'aerial', + variant: 'hybrid.day.mobile' + } + }, + hybridDayTransit: { + options: { + base: 'aerial', + variant: 'hybrid.day.transit' + } + }, + hybridDayGrey: { + options: { + base: 'aerial', + variant: 'hybrid.grey.day' + } + }, + pedestrianDay: 'pedestrian.day', + pedestrianNight: 'pedestrian.night', + satelliteDay: { + options: { + base: 'aerial', + variant: 'satellite.day' + } + }, + terrainDay: { + options: { + base: 'aerial', + variant: 'terrain.day' + } + }, + terrainDayMobile: { + options: { + base: 'aerial', + variant: 'terrain.day.mobile' + } + } + } + }, + FreeMapSK: { + url: 'https://{s}.freemap.sk/T/{z}/{x}/{y}.jpeg', + options: { + minZoom: 8, + maxZoom: 16, + subdomains: 'abcd', + bounds: [[47.204642, 15.996093], [49.830896, 22.576904]], + attribution: + '{attribution.OpenStreetMap}, visualization CC-By-SA 2.0 Freemap.sk' + } + }, + MtbMap: { + url: 'http://tile.mtbmap.cz/mtbmap_tiles/{z}/{x}/{y}.png', + options: { + attribution: + '{attribution.OpenStreetMap} & USGS' + } + }, + CartoDB: { + url: 'https://{s}.basemaps.cartocdn.com/{variant}/{z}/{x}/{y}{r}.png', + options: { + attribution: '{attribution.OpenStreetMap} © CARTO', + subdomains: 'abcd', + maxZoom: 20, + variant: 'light_all' + }, + variants: { + Positron: 'light_all', + PositronNoLabels: 'light_nolabels', + PositronOnlyLabels: 'light_only_labels', + DarkMatter: 'dark_all', + DarkMatterNoLabels: 'dark_nolabels', + DarkMatterOnlyLabels: 'dark_only_labels', + Voyager: 'rastertiles/voyager', + VoyagerNoLabels: 'rastertiles/voyager_nolabels', + VoyagerOnlyLabels: 'rastertiles/voyager_only_labels', + VoyagerLabelsUnder: 'rastertiles/voyager_labels_under' + } + }, + HikeBike: { + url: 'https://tiles.wmflabs.org/{variant}/{z}/{x}/{y}.png', + options: { + maxZoom: 19, + attribution: '{attribution.OpenStreetMap}', + variant: 'hikebike' + }, + variants: { + HikeBike: {}, + HillShading: { + options: { + maxZoom: 15, + variant: 'hillshading' + } + } + } + }, + BasemapAT: { + url: 'https://mapsneu.wien.gv.at/basemap/{variant}/{type}/google3857/{z}/{y}/{x}.{format}', + options: { + maxZoom: 19, + attribution: 'Datenquelle: basemap.at', + type: 'normal', + format: 'png', + bounds: [[46.358770, 8.782379], [49.037872, 17.189532]], + variant: 'geolandbasemap' + }, + variants: { + basemap: { + options: { + maxZoom: 20, // currently only in Vienna + variant: 'geolandbasemap' + } + }, + grau: 'bmapgrau', + overlay: 'bmapoverlay', + terrain: { + options: { + variant: 'bmapgelaende', + type: 'grau', + format: 'jpeg' + } + }, + surface: { + options: { + variant: 'bmapoberflaeche', + type: 'grau', + format: 'jpeg' + } + }, + highdpi: { + options: { + variant: 'bmaphidpi', + format: 'jpeg' + } + }, + orthofoto: { + options: { + maxZoom: 20, // currently only in Vienna + variant: 'bmaporthofoto30cm', + format: 'jpeg' + } + } + } + }, + nlmaps: { + url: 'https://service.pdok.nl/brt/achtergrondkaart/wmts/v2_0/{variant}/EPSG:3857/{z}/{x}/{y}.png', + options: { + minZoom: 6, + maxZoom: 19, + bounds: [[50.5, 3.25], [54, 7.6]], + attribution: 'Kaartgegevens © Kadaster' + }, + variants: { + standaard: 'standaard', + pastel: 'pastel', + grijs: 'grijs', + water: 'water', + luchtfoto: { + url: 'https://service.pdok.nl/hwh/luchtfotorgb/wmts/v1_0/Actueel_ortho25/EPSG:3857/{z}/{x}/{y}.jpeg', + } + } + }, + NASAGIBS: { + url: 'https://map1.vis.earthdata.nasa.gov/wmts-webmerc/{variant}/default/{time}/{tilematrixset}{maxZoom}/{z}/{y}/{x}.{format}', + options: { + attribution: + 'Imagery provided by services from the Global Imagery Browse Services (GIBS), operated by the NASA/GSFC/Earth Science Data and Information System ' + + '(ESDIS) with funding provided by NASA/HQ.', + bounds: [[-85.0511287776, -179.999999975], [85.0511287776, 179.999999975]], + minZoom: 1, + maxZoom: 9, + format: 'jpg', + time: '', + tilematrixset: 'GoogleMapsCompatible_Level' + }, + variants: { + ModisTerraTrueColorCR: 'MODIS_Terra_CorrectedReflectance_TrueColor', + ModisTerraBands367CR: 'MODIS_Terra_CorrectedReflectance_Bands367', + ViirsEarthAtNight2012: { + options: { + variant: 'VIIRS_CityLights_2012', + maxZoom: 8 + } + }, + ModisTerraLSTDay: { + options: { + variant: 'MODIS_Terra_Land_Surface_Temp_Day', + format: 'png', + maxZoom: 7, + opacity: 0.75 + } + }, + ModisTerraSnowCover: { + options: { + variant: 'MODIS_Terra_NDSI_Snow_Cover', + format: 'png', + maxZoom: 8, + opacity: 0.75 + } + }, + ModisTerraAOD: { + options: { + variant: 'MODIS_Terra_Aerosol', + format: 'png', + maxZoom: 6, + opacity: 0.75 + } + }, + ModisTerraChlorophyll: { + options: { + variant: 'MODIS_Terra_L2_Chlorophyll_A', + format: 'png', + maxZoom: 7, + opacity: 0.75 + } + } + } + }, + NLS: { + // NLS maps are copyright National library of Scotland. + // http://maps.nls.uk/projects/subscription-api + // Please contact NLS for anything other than non-commercial low volume usage + // + // Map sources: Ordnance Survey 1:1m to 1:63K, 1880s-1961 + // z0-9 - 1:1m + // z10-11 - quarter inch (1:253440) + // z12-18 - one inch (1:63360) + url: 'https://api.maptiler.com/tiles/{variant}/{z}/{x}/{y}.jpg?key={apikey}', + options: { + attribution: 'National Library of Scotland Historic Maps', + bounds: [[49.6, -12], [61.7, 3]], + minZoom: 1, + maxZoom: 18, + apikey: '' + }, + variants: { + osgb63k1885: 'uk-osgb63k1885', + osgb1888: 'uk-osgb1888', + osgb10k1888: 'uk-osgb10k1888', + osgb1919: 'uk-osgb1919', + osgb25k1937: 'uk-osgb25k1937', + osgb63k1955: 'uk-osgb63k1955', + oslondon1k1893: 'uk-oslondon1k1893' + } + }, + JusticeMap: { + // Justice Map (http://www.justicemap.org/) + // Visualize race and income data for your community, county and country. + // Includes tools for data journalists, bloggers and community activists. + url: 'https://www.justicemap.org/tile/{size}/{variant}/{z}/{x}/{y}.png', + options: { + attribution: 'Justice Map', + // one of 'county', 'tract', 'block' + size: 'county', + // Bounds for USA, including Alaska and Hawaii + bounds: [[14, -180], [72, -56]] + }, + variants: { + income: 'income', + americanIndian: 'indian', + asian: 'asian', + black: 'black', + hispanic: 'hispanic', + multi: 'multi', + nonWhite: 'nonwhite', + white: 'white', + plurality: 'plural' + } + }, + GeoportailFrance: { + url: 'https://data.geopf.fr/wmts?REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0&STYLE={style}&TILEMATRIXSET=PM&FORMAT={format}&LAYER={variant}&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}', + options: { + attribution: 'Geoportail France', + bounds: [[-75, -180], [81, 180]], + minZoom: 2, + maxZoom: 18, + format: 'image/png', + style: 'normal', + variant: 'GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2' + }, + variants: { + plan: 'GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2', + parcels: { + options: { + variant: 'CADASTRALPARCELS.PARCELLAIRE_EXPRESS', + style: 'PCI vecteur', + maxZoom: 20 + } + }, + orthos: { + options: { + maxZoom: 19, + format: 'image/jpeg', + variant: 'ORTHOIMAGERY.ORTHOPHOTOS' + } + } + } + }, + OneMapSG: { + url: 'https://maps-{s}.onemap.sg/v3/{variant}/{z}/{x}/{y}.png', + options: { + variant: 'Default', + minZoom: 11, + maxZoom: 18, + bounds: [[1.56073, 104.11475], [1.16, 103.502]], + attribution: ' New OneMap | Map data © contributors, Singapore Land Authority' + }, + variants: { + Default: 'Default', + Night: 'Night', + Original: 'Original', + Grey: 'Grey', + LandLot: 'LandLot' + } + }, + USGS: { + url: 'https://basemap.nationalmap.gov/arcgis/rest/services/USGSTopo/MapServer/tile/{z}/{y}/{x}', + options: { + maxZoom: 20, + attribution: 'Tiles courtesy of the U.S. Geological Survey' + }, + variants: { + USTopo: {}, + USImagery: { + url: 'https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryOnly/MapServer/tile/{z}/{y}/{x}' + }, + USImageryTopo: { + url: 'https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryTopo/MapServer/tile/{z}/{y}/{x}' + } + } + }, + WaymarkedTrails: { + url: 'https://tile.waymarkedtrails.org/{variant}/{z}/{x}/{y}.png', + options: { + maxZoom: 18, + attribution: 'Map data: {attribution.OpenStreetMap} | Map style: © waymarkedtrails.org (CC-BY-SA)' + }, + variants: { + hiking: 'hiking', + cycling: 'cycling', + mtb: 'mtb', + slopes: 'slopes', + riding: 'riding', + skating: 'skating' + } + }, + OpenAIP: { + url: 'https://{s}.tile.maps.openaip.net/geowebcache/service/tms/1.0.0/openaip_basemap@EPSG%3A900913@png/{z}/{x}/{y}.{ext}', + options: { + attribution: 'openAIP Data (CC-BY-NC-SA)', + ext: 'png', + minZoom: 4, + maxZoom: 14, + tms: true, + detectRetina: true, + subdomains: '12' + } + }, + OpenSnowMap: { + url: 'https://tiles.opensnowmap.org/{variant}/{z}/{x}/{y}.png', + options: { + minZoom: 9, + maxZoom: 18, + attribution: 'Map data: {attribution.OpenStreetMap} & ODbL, © www.opensnowmap.org CC-BY-SA' + }, + variants: { + pistes: 'pistes', + } + }, + AzureMaps: { + url: + 'https://atlas.microsoft.com/map/tile?api-version={apiVersion}' + + '&tilesetId={variant}&x={x}&y={y}&zoom={z}&language={language}' + + '&subscription-key={subscriptionKey}', + options: { + attribution: 'See https://docs.microsoft.com/en-us/rest/api/maps/render-v2/get-map-tile for details.', + apiVersion: '2.0', + variant: 'microsoft.imagery', + subscriptionKey: '', + language: 'en-US', + }, + variants: { + MicrosoftImagery: 'microsoft.imagery', + MicrosoftBaseDarkGrey: 'microsoft.base.darkgrey', + MicrosoftBaseRoad: 'microsoft.base.road', + MicrosoftBaseHybridRoad: 'microsoft.base.hybrid.road', + MicrosoftTerraMain: 'microsoft.terra.main', + MicrosoftWeatherInfraredMain: { + url: + 'https://atlas.microsoft.com/map/tile?api-version={apiVersion}' + + '&tilesetId={variant}&x={x}&y={y}&zoom={z}' + + '&timeStamp={timeStamp}&language={language}' + + '&subscription-key={subscriptionKey}', + options: { + timeStamp: '2021-05-08T09:03:00Z', + attribution: 'See https://docs.microsoft.com/en-us/rest/api/maps/render-v2/get-map-tile#uri-parameters for details.', + variant: 'microsoft.weather.infrared.main', + }, + }, + MicrosoftWeatherRadarMain: { + url: + 'https://atlas.microsoft.com/map/tile?api-version={apiVersion}' + + '&tilesetId={variant}&x={x}&y={y}&zoom={z}' + + '&timeStamp={timeStamp}&language={language}' + + '&subscription-key={subscriptionKey}', + options: { + timeStamp: '2021-05-08T09:03:00Z', + attribution: 'See https://docs.microsoft.com/en-us/rest/api/maps/render-v2/get-map-tile#uri-parameters for details.', + variant: 'microsoft.weather.radar.main', + }, + } + }, + }, + SwissFederalGeoportal: { + url: 'https://wmts.geo.admin.ch/1.0.0/{variant}/default/current/3857/{z}/{x}/{y}.jpeg', + options: { + attribution: '© swisstopo', + minZoom: 2, + maxZoom: 18, + bounds: [[45.398181, 5.140242], [48.230651, 11.47757]] + }, + variants: { + NationalMapColor: 'ch.swisstopo.pixelkarte-farbe', + NationalMapGrey: 'ch.swisstopo.pixelkarte-grau', + SWISSIMAGE: { + options: { + variant: 'ch.swisstopo.swissimage', + maxZoom: 19 + } + } + } + }, + TopPlusOpen: { + url: 'http://sgx.geodatenzentrum.de/wmts_topplus_open/tile/1.0.0/{variant}/default/WEBMERCATOR/{z}/{y}/{x}.png', + options: { + maxZoom: 18, + attribution: 'Map data: © dl-de/by-2-0', + variant: 'web', + }, + variants: { + Color: 'web', + Grey: 'web_grau' + } + } + }; + + L.tileLayer.provider = function(provider, options) { + return new L.TileLayer.Provider(provider, options); + }; + + return L; +})); diff --git a/assets/static/vendor/leaflet/images/layers-2x.png b/assets/static/vendor/leaflet/images/layers-2x.png new file mode 100644 index 0000000..200c333 Binary files /dev/null and b/assets/static/vendor/leaflet/images/layers-2x.png differ diff --git a/assets/static/vendor/leaflet/images/layers.png b/assets/static/vendor/leaflet/images/layers.png new file mode 100644 index 0000000..1a72e57 Binary files /dev/null and b/assets/static/vendor/leaflet/images/layers.png differ diff --git a/assets/static/vendor/leaflet/images/marker-icon-2x.png b/assets/static/vendor/leaflet/images/marker-icon-2x.png new file mode 100644 index 0000000..88f9e50 Binary files /dev/null and b/assets/static/vendor/leaflet/images/marker-icon-2x.png differ diff --git a/assets/static/vendor/leaflet/images/marker-icon.png b/assets/static/vendor/leaflet/images/marker-icon.png new file mode 100644 index 0000000..950edf2 Binary files /dev/null and b/assets/static/vendor/leaflet/images/marker-icon.png differ diff --git a/assets/static/vendor/leaflet/images/marker-shadow.png b/assets/static/vendor/leaflet/images/marker-shadow.png new file mode 100644 index 0000000..9fd2979 Binary files /dev/null and b/assets/static/vendor/leaflet/images/marker-shadow.png differ diff --git a/assets/static/vendor/leaflet/leaflet-src.esm.js b/assets/static/vendor/leaflet/leaflet-src.esm.js new file mode 100644 index 0000000..8f91d5d --- /dev/null +++ b/assets/static/vendor/leaflet/leaflet-src.esm.js @@ -0,0 +1,14419 @@ +/* @preserve + * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com + * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */ + +var version = "1.9.4"; + +/* + * @namespace Util + * + * Various utility functions, used by Leaflet internally. + */ + +// @function extend(dest: Object, src?: Object): Object +// Merges the properties of the `src` object (or multiple objects) into `dest` object and returns the latter. Has an `L.extend` shortcut. +function extend(dest) { + var i, j, len, src; + + for (j = 1, len = arguments.length; j < len; j++) { + src = arguments[j]; + for (i in src) { + dest[i] = src[i]; + } + } + return dest; +} + +// @function create(proto: Object, properties?: Object): Object +// Compatibility polyfill for [Object.create](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object/create) +var create$2 = Object.create || (function () { + function F() {} + return function (proto) { + F.prototype = proto; + return new F(); + }; +})(); + +// @function bind(fn: Function, …): Function +// Returns a new function bound to the arguments passed, like [Function.prototype.bind](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Function/bind). +// Has a `L.bind()` shortcut. +function bind(fn, obj) { + var slice = Array.prototype.slice; + + if (fn.bind) { + return fn.bind.apply(fn, slice.call(arguments, 1)); + } + + var args = slice.call(arguments, 2); + + return function () { + return fn.apply(obj, args.length ? args.concat(slice.call(arguments)) : arguments); + }; +} + +// @property lastId: Number +// Last unique ID used by [`stamp()`](#util-stamp) +var lastId = 0; + +// @function stamp(obj: Object): Number +// Returns the unique ID of an object, assigning it one if it doesn't have it. +function stamp(obj) { + if (!('_leaflet_id' in obj)) { + obj['_leaflet_id'] = ++lastId; + } + return obj._leaflet_id; +} + +// @function throttle(fn: Function, time: Number, context: Object): Function +// Returns a function which executes function `fn` with the given scope `context` +// (so that the `this` keyword refers to `context` inside `fn`'s code). The function +// `fn` will be called no more than one time per given amount of `time`. The arguments +// received by the bound function will be any arguments passed when binding the +// function, followed by any arguments passed when invoking the bound function. +// Has an `L.throttle` shortcut. +function throttle(fn, time, context) { + var lock, args, wrapperFn, later; + + later = function () { + // reset lock and call if queued + lock = false; + if (args) { + wrapperFn.apply(context, args); + args = false; + } + }; + + wrapperFn = function () { + if (lock) { + // called too soon, queue to call later + args = arguments; + + } else { + // call and lock until later + fn.apply(context, arguments); + setTimeout(later, time); + lock = true; + } + }; + + return wrapperFn; +} + +// @function wrapNum(num: Number, range: Number[], includeMax?: Boolean): Number +// Returns the number `num` modulo `range` in such a way so it lies within +// `range[0]` and `range[1]`. The returned value will be always smaller than +// `range[1]` unless `includeMax` is set to `true`. +function wrapNum(x, range, includeMax) { + var max = range[1], + min = range[0], + d = max - min; + return x === max && includeMax ? x : ((x - min) % d + d) % d + min; +} + +// @function falseFn(): Function +// Returns a function which always returns `false`. +function falseFn() { return false; } + +// @function formatNum(num: Number, precision?: Number|false): Number +// Returns the number `num` rounded with specified `precision`. +// The default `precision` value is 6 decimal places. +// `false` can be passed to skip any processing (can be useful to avoid round-off errors). +function formatNum(num, precision) { + if (precision === false) { return num; } + var pow = Math.pow(10, precision === undefined ? 6 : precision); + return Math.round(num * pow) / pow; +} + +// @function trim(str: String): String +// Compatibility polyfill for [String.prototype.trim](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/Trim) +function trim(str) { + return str.trim ? str.trim() : str.replace(/^\s+|\s+$/g, ''); +} + +// @function splitWords(str: String): String[] +// Trims and splits the string on whitespace and returns the array of parts. +function splitWords(str) { + return trim(str).split(/\s+/); +} + +// @function setOptions(obj: Object, options: Object): Object +// Merges the given properties to the `options` of the `obj` object, returning the resulting options. See `Class options`. Has an `L.setOptions` shortcut. +function setOptions(obj, options) { + if (!Object.prototype.hasOwnProperty.call(obj, 'options')) { + obj.options = obj.options ? create$2(obj.options) : {}; + } + for (var i in options) { + obj.options[i] = options[i]; + } + return obj.options; +} + +// @function getParamString(obj: Object, existingUrl?: String, uppercase?: Boolean): String +// Converts an object into a parameter URL string, e.g. `{a: "foo", b: "bar"}` +// translates to `'?a=foo&b=bar'`. If `existingUrl` is set, the parameters will +// be appended at the end. If `uppercase` is `true`, the parameter names will +// be uppercased (e.g. `'?A=foo&B=bar'`) +function getParamString(obj, existingUrl, uppercase) { + var params = []; + for (var i in obj) { + params.push(encodeURIComponent(uppercase ? i.toUpperCase() : i) + '=' + encodeURIComponent(obj[i])); + } + return ((!existingUrl || existingUrl.indexOf('?') === -1) ? '?' : '&') + params.join('&'); +} + +var templateRe = /\{ *([\w_ -]+) *\}/g; + +// @function template(str: String, data: Object): String +// Simple templating facility, accepts a template string of the form `'Hello {a}, {b}'` +// and a data object like `{a: 'foo', b: 'bar'}`, returns evaluated string +// `('Hello foo, bar')`. You can also specify functions instead of strings for +// data values — they will be evaluated passing `data` as an argument. +function template(str, data) { + return str.replace(templateRe, function (str, key) { + var value = data[key]; + + if (value === undefined) { + throw new Error('No value provided for variable ' + str); + + } else if (typeof value === 'function') { + value = value(data); + } + return value; + }); +} + +// @function isArray(obj): Boolean +// Compatibility polyfill for [Array.isArray](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray) +var isArray = Array.isArray || function (obj) { + return (Object.prototype.toString.call(obj) === '[object Array]'); +}; + +// @function indexOf(array: Array, el: Object): Number +// Compatibility polyfill for [Array.prototype.indexOf](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf) +function indexOf(array, el) { + for (var i = 0; i < array.length; i++) { + if (array[i] === el) { return i; } + } + return -1; +} + +// @property emptyImageUrl: String +// Data URI string containing a base64-encoded empty GIF image. +// Used as a hack to free memory from unused images on WebKit-powered +// mobile devices (by setting image `src` to this string). +var emptyImageUrl = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; + +// inspired by https://paulirish.com/2011/requestanimationframe-for-smart-animating/ + +function getPrefixed(name) { + return window['webkit' + name] || window['moz' + name] || window['ms' + name]; +} + +var lastTime = 0; + +// fallback for IE 7-8 +function timeoutDefer(fn) { + var time = +new Date(), + timeToCall = Math.max(0, 16 - (time - lastTime)); + + lastTime = time + timeToCall; + return window.setTimeout(fn, timeToCall); +} + +var requestFn = window.requestAnimationFrame || getPrefixed('RequestAnimationFrame') || timeoutDefer; +var cancelFn = window.cancelAnimationFrame || getPrefixed('CancelAnimationFrame') || + getPrefixed('CancelRequestAnimationFrame') || function (id) { window.clearTimeout(id); }; + +// @function requestAnimFrame(fn: Function, context?: Object, immediate?: Boolean): Number +// Schedules `fn` to be executed when the browser repaints. `fn` is bound to +// `context` if given. When `immediate` is set, `fn` is called immediately if +// the browser doesn't have native support for +// [`window.requestAnimationFrame`](https://developer.mozilla.org/docs/Web/API/window/requestAnimationFrame), +// otherwise it's delayed. Returns a request ID that can be used to cancel the request. +function requestAnimFrame(fn, context, immediate) { + if (immediate && requestFn === timeoutDefer) { + fn.call(context); + } else { + return requestFn.call(window, bind(fn, context)); + } +} + +// @function cancelAnimFrame(id: Number): undefined +// Cancels a previous `requestAnimFrame`. See also [window.cancelAnimationFrame](https://developer.mozilla.org/docs/Web/API/window/cancelAnimationFrame). +function cancelAnimFrame(id) { + if (id) { + cancelFn.call(window, id); + } +} + +var Util = { + __proto__: null, + extend: extend, + create: create$2, + bind: bind, + get lastId () { return lastId; }, + stamp: stamp, + throttle: throttle, + wrapNum: wrapNum, + falseFn: falseFn, + formatNum: formatNum, + trim: trim, + splitWords: splitWords, + setOptions: setOptions, + getParamString: getParamString, + template: template, + isArray: isArray, + indexOf: indexOf, + emptyImageUrl: emptyImageUrl, + requestFn: requestFn, + cancelFn: cancelFn, + requestAnimFrame: requestAnimFrame, + cancelAnimFrame: cancelAnimFrame +}; + +// @class Class +// @aka L.Class + +// @section +// @uninheritable + +// Thanks to John Resig and Dean Edwards for inspiration! + +function Class() {} + +Class.extend = function (props) { + + // @function extend(props: Object): Function + // [Extends the current class](#class-inheritance) given the properties to be included. + // Returns a Javascript function that is a class constructor (to be called with `new`). + var NewClass = function () { + + setOptions(this); + + // call the constructor + if (this.initialize) { + this.initialize.apply(this, arguments); + } + + // call all constructor hooks + this.callInitHooks(); + }; + + var parentProto = NewClass.__super__ = this.prototype; + + var proto = create$2(parentProto); + proto.constructor = NewClass; + + NewClass.prototype = proto; + + // inherit parent's statics + for (var i in this) { + if (Object.prototype.hasOwnProperty.call(this, i) && i !== 'prototype' && i !== '__super__') { + NewClass[i] = this[i]; + } + } + + // mix static properties into the class + if (props.statics) { + extend(NewClass, props.statics); + } + + // mix includes into the prototype + if (props.includes) { + checkDeprecatedMixinEvents(props.includes); + extend.apply(null, [proto].concat(props.includes)); + } + + // mix given properties into the prototype + extend(proto, props); + delete proto.statics; + delete proto.includes; + + // merge options + if (proto.options) { + proto.options = parentProto.options ? create$2(parentProto.options) : {}; + extend(proto.options, props.options); + } + + proto._initHooks = []; + + // add method for calling all hooks + proto.callInitHooks = function () { + + if (this._initHooksCalled) { return; } + + if (parentProto.callInitHooks) { + parentProto.callInitHooks.call(this); + } + + this._initHooksCalled = true; + + for (var i = 0, len = proto._initHooks.length; i < len; i++) { + proto._initHooks[i].call(this); + } + }; + + return NewClass; +}; + + +// @function include(properties: Object): this +// [Includes a mixin](#class-includes) into the current class. +Class.include = function (props) { + var parentOptions = this.prototype.options; + extend(this.prototype, props); + if (props.options) { + this.prototype.options = parentOptions; + this.mergeOptions(props.options); + } + return this; +}; + +// @function mergeOptions(options: Object): this +// [Merges `options`](#class-options) into the defaults of the class. +Class.mergeOptions = function (options) { + extend(this.prototype.options, options); + return this; +}; + +// @function addInitHook(fn: Function): this +// Adds a [constructor hook](#class-constructor-hooks) to the class. +Class.addInitHook = function (fn) { // (Function) || (String, args...) + var args = Array.prototype.slice.call(arguments, 1); + + var init = typeof fn === 'function' ? fn : function () { + this[fn].apply(this, args); + }; + + this.prototype._initHooks = this.prototype._initHooks || []; + this.prototype._initHooks.push(init); + return this; +}; + +function checkDeprecatedMixinEvents(includes) { + /* global L: true */ + if (typeof L === 'undefined' || !L || !L.Mixin) { return; } + + includes = isArray(includes) ? includes : [includes]; + + for (var i = 0; i < includes.length; i++) { + if (includes[i] === L.Mixin.Events) { + console.warn('Deprecated include of L.Mixin.Events: ' + + 'this property will be removed in future releases, ' + + 'please inherit from L.Evented instead.', new Error().stack); + } + } +} + +/* + * @class Evented + * @aka L.Evented + * @inherits Class + * + * A set of methods shared between event-powered classes (like `Map` and `Marker`). Generally, events allow you to execute some function when something happens with an object (e.g. the user clicks on the map, causing the map to fire `'click'` event). + * + * @example + * + * ```js + * map.on('click', function(e) { + * alert(e.latlng); + * } ); + * ``` + * + * Leaflet deals with event listeners by reference, so if you want to add a listener and then remove it, define it as a function: + * + * ```js + * function onClick(e) { ... } + * + * map.on('click', onClick); + * map.off('click', onClick); + * ``` + */ + +var Events = { + /* @method on(type: String, fn: Function, context?: Object): this + * Adds a listener function (`fn`) to a particular event type of the object. You can optionally specify the context of the listener (object the this keyword will point to). You can also pass several space-separated types (e.g. `'click dblclick'`). + * + * @alternative + * @method on(eventMap: Object): this + * Adds a set of type/listener pairs, e.g. `{click: onClick, mousemove: onMouseMove}` + */ + on: function (types, fn, context) { + + // types can be a map of types/handlers + if (typeof types === 'object') { + for (var type in types) { + // we don't process space-separated events here for performance; + // it's a hot path since Layer uses the on(obj) syntax + this._on(type, types[type], fn); + } + + } else { + // types can be a string of space-separated words + types = splitWords(types); + + for (var i = 0, len = types.length; i < len; i++) { + this._on(types[i], fn, context); + } + } + + return this; + }, + + /* @method off(type: String, fn?: Function, context?: Object): this + * Removes a previously added listener function. If no function is specified, it will remove all the listeners of that particular event from the object. Note that if you passed a custom context to `on`, you must pass the same context to `off` in order to remove the listener. + * + * @alternative + * @method off(eventMap: Object): this + * Removes a set of type/listener pairs. + * + * @alternative + * @method off: this + * Removes all listeners to all events on the object. This includes implicitly attached events. + */ + off: function (types, fn, context) { + + if (!arguments.length) { + // clear all listeners if called without arguments + delete this._events; + + } else if (typeof types === 'object') { + for (var type in types) { + this._off(type, types[type], fn); + } + + } else { + types = splitWords(types); + + var removeAll = arguments.length === 1; + for (var i = 0, len = types.length; i < len; i++) { + if (removeAll) { + this._off(types[i]); + } else { + this._off(types[i], fn, context); + } + } + } + + return this; + }, + + // attach listener (without syntactic sugar now) + _on: function (type, fn, context, _once) { + if (typeof fn !== 'function') { + console.warn('wrong listener type: ' + typeof fn); + return; + } + + // check if fn already there + if (this._listens(type, fn, context) !== false) { + return; + } + + if (context === this) { + // Less memory footprint. + context = undefined; + } + + var newListener = {fn: fn, ctx: context}; + if (_once) { + newListener.once = true; + } + + this._events = this._events || {}; + this._events[type] = this._events[type] || []; + this._events[type].push(newListener); + }, + + _off: function (type, fn, context) { + var listeners, + i, + len; + + if (!this._events) { + return; + } + + listeners = this._events[type]; + if (!listeners) { + return; + } + + if (arguments.length === 1) { // remove all + if (this._firingCount) { + // Set all removed listeners to noop + // so they are not called if remove happens in fire + for (i = 0, len = listeners.length; i < len; i++) { + listeners[i].fn = falseFn; + } + } + // clear all listeners for a type if function isn't specified + delete this._events[type]; + return; + } + + if (typeof fn !== 'function') { + console.warn('wrong listener type: ' + typeof fn); + return; + } + + // find fn and remove it + var index = this._listens(type, fn, context); + if (index !== false) { + var listener = listeners[index]; + if (this._firingCount) { + // set the removed listener to noop so that's not called if remove happens in fire + listener.fn = falseFn; + + /* copy array in case events are being fired */ + this._events[type] = listeners = listeners.slice(); + } + listeners.splice(index, 1); + } + }, + + // @method fire(type: String, data?: Object, propagate?: Boolean): this + // Fires an event of the specified type. You can optionally provide a data + // object — the first argument of the listener function will contain its + // properties. The event can optionally be propagated to event parents. + fire: function (type, data, propagate) { + if (!this.listens(type, propagate)) { return this; } + + var event = extend({}, data, { + type: type, + target: this, + sourceTarget: data && data.sourceTarget || this + }); + + if (this._events) { + var listeners = this._events[type]; + if (listeners) { + this._firingCount = (this._firingCount + 1) || 1; + for (var i = 0, len = listeners.length; i < len; i++) { + var l = listeners[i]; + // off overwrites l.fn, so we need to copy fn to a var + var fn = l.fn; + if (l.once) { + this.off(type, fn, l.ctx); + } + fn.call(l.ctx || this, event); + } + + this._firingCount--; + } + } + + if (propagate) { + // propagate the event to parents (set with addEventParent) + this._propagateEvent(event); + } + + return this; + }, + + // @method listens(type: String, propagate?: Boolean): Boolean + // @method listens(type: String, fn: Function, context?: Object, propagate?: Boolean): Boolean + // Returns `true` if a particular event type has any listeners attached to it. + // The verification can optionally be propagated, it will return `true` if parents have the listener attached to it. + listens: function (type, fn, context, propagate) { + if (typeof type !== 'string') { + console.warn('"string" type argument expected'); + } + + // we don't overwrite the input `fn` value, because we need to use it for propagation + var _fn = fn; + if (typeof fn !== 'function') { + propagate = !!fn; + _fn = undefined; + context = undefined; + } + + var listeners = this._events && this._events[type]; + if (listeners && listeners.length) { + if (this._listens(type, _fn, context) !== false) { + return true; + } + } + + if (propagate) { + // also check parents for listeners if event propagates + for (var id in this._eventParents) { + if (this._eventParents[id].listens(type, fn, context, propagate)) { return true; } + } + } + return false; + }, + + // returns the index (number) or false + _listens: function (type, fn, context) { + if (!this._events) { + return false; + } + + var listeners = this._events[type] || []; + if (!fn) { + return !!listeners.length; + } + + if (context === this) { + // Less memory footprint. + context = undefined; + } + + for (var i = 0, len = listeners.length; i < len; i++) { + if (listeners[i].fn === fn && listeners[i].ctx === context) { + return i; + } + } + return false; + + }, + + // @method once(…): this + // Behaves as [`on(…)`](#evented-on), except the listener will only get fired once and then removed. + once: function (types, fn, context) { + + // types can be a map of types/handlers + if (typeof types === 'object') { + for (var type in types) { + // we don't process space-separated events here for performance; + // it's a hot path since Layer uses the on(obj) syntax + this._on(type, types[type], fn, true); + } + + } else { + // types can be a string of space-separated words + types = splitWords(types); + + for (var i = 0, len = types.length; i < len; i++) { + this._on(types[i], fn, context, true); + } + } + + return this; + }, + + // @method addEventParent(obj: Evented): this + // Adds an event parent - an `Evented` that will receive propagated events + addEventParent: function (obj) { + this._eventParents = this._eventParents || {}; + this._eventParents[stamp(obj)] = obj; + return this; + }, + + // @method removeEventParent(obj: Evented): this + // Removes an event parent, so it will stop receiving propagated events + removeEventParent: function (obj) { + if (this._eventParents) { + delete this._eventParents[stamp(obj)]; + } + return this; + }, + + _propagateEvent: function (e) { + for (var id in this._eventParents) { + this._eventParents[id].fire(e.type, extend({ + layer: e.target, + propagatedFrom: e.target + }, e), true); + } + } +}; + +// aliases; we should ditch those eventually + +// @method addEventListener(…): this +// Alias to [`on(…)`](#evented-on) +Events.addEventListener = Events.on; + +// @method removeEventListener(…): this +// Alias to [`off(…)`](#evented-off) + +// @method clearAllEventListeners(…): this +// Alias to [`off()`](#evented-off) +Events.removeEventListener = Events.clearAllEventListeners = Events.off; + +// @method addOneTimeEventListener(…): this +// Alias to [`once(…)`](#evented-once) +Events.addOneTimeEventListener = Events.once; + +// @method fireEvent(…): this +// Alias to [`fire(…)`](#evented-fire) +Events.fireEvent = Events.fire; + +// @method hasEventListeners(…): Boolean +// Alias to [`listens(…)`](#evented-listens) +Events.hasEventListeners = Events.listens; + +var Evented = Class.extend(Events); + +/* + * @class Point + * @aka L.Point + * + * Represents a point with `x` and `y` coordinates in pixels. + * + * @example + * + * ```js + * var point = L.point(200, 300); + * ``` + * + * All Leaflet methods and options that accept `Point` objects also accept them in a simple Array form (unless noted otherwise), so these lines are equivalent: + * + * ```js + * map.panBy([200, 300]); + * map.panBy(L.point(200, 300)); + * ``` + * + * Note that `Point` does not inherit from Leaflet's `Class` object, + * which means new classes can't inherit from it, and new methods + * can't be added to it with the `include` function. + */ + +function Point(x, y, round) { + // @property x: Number; The `x` coordinate of the point + this.x = (round ? Math.round(x) : x); + // @property y: Number; The `y` coordinate of the point + this.y = (round ? Math.round(y) : y); +} + +var trunc = Math.trunc || function (v) { + return v > 0 ? Math.floor(v) : Math.ceil(v); +}; + +Point.prototype = { + + // @method clone(): Point + // Returns a copy of the current point. + clone: function () { + return new Point(this.x, this.y); + }, + + // @method add(otherPoint: Point): Point + // Returns the result of addition of the current and the given points. + add: function (point) { + // non-destructive, returns a new point + return this.clone()._add(toPoint(point)); + }, + + _add: function (point) { + // destructive, used directly for performance in situations where it's safe to modify existing point + this.x += point.x; + this.y += point.y; + return this; + }, + + // @method subtract(otherPoint: Point): Point + // Returns the result of subtraction of the given point from the current. + subtract: function (point) { + return this.clone()._subtract(toPoint(point)); + }, + + _subtract: function (point) { + this.x -= point.x; + this.y -= point.y; + return this; + }, + + // @method divideBy(num: Number): Point + // Returns the result of division of the current point by the given number. + divideBy: function (num) { + return this.clone()._divideBy(num); + }, + + _divideBy: function (num) { + this.x /= num; + this.y /= num; + return this; + }, + + // @method multiplyBy(num: Number): Point + // Returns the result of multiplication of the current point by the given number. + multiplyBy: function (num) { + return this.clone()._multiplyBy(num); + }, + + _multiplyBy: function (num) { + this.x *= num; + this.y *= num; + return this; + }, + + // @method scaleBy(scale: Point): Point + // Multiply each coordinate of the current point by each coordinate of + // `scale`. In linear algebra terms, multiply the point by the + // [scaling matrix](https://en.wikipedia.org/wiki/Scaling_%28geometry%29#Matrix_representation) + // defined by `scale`. + scaleBy: function (point) { + return new Point(this.x * point.x, this.y * point.y); + }, + + // @method unscaleBy(scale: Point): Point + // Inverse of `scaleBy`. Divide each coordinate of the current point by + // each coordinate of `scale`. + unscaleBy: function (point) { + return new Point(this.x / point.x, this.y / point.y); + }, + + // @method round(): Point + // Returns a copy of the current point with rounded coordinates. + round: function () { + return this.clone()._round(); + }, + + _round: function () { + this.x = Math.round(this.x); + this.y = Math.round(this.y); + return this; + }, + + // @method floor(): Point + // Returns a copy of the current point with floored coordinates (rounded down). + floor: function () { + return this.clone()._floor(); + }, + + _floor: function () { + this.x = Math.floor(this.x); + this.y = Math.floor(this.y); + return this; + }, + + // @method ceil(): Point + // Returns a copy of the current point with ceiled coordinates (rounded up). + ceil: function () { + return this.clone()._ceil(); + }, + + _ceil: function () { + this.x = Math.ceil(this.x); + this.y = Math.ceil(this.y); + return this; + }, + + // @method trunc(): Point + // Returns a copy of the current point with truncated coordinates (rounded towards zero). + trunc: function () { + return this.clone()._trunc(); + }, + + _trunc: function () { + this.x = trunc(this.x); + this.y = trunc(this.y); + return this; + }, + + // @method distanceTo(otherPoint: Point): Number + // Returns the cartesian distance between the current and the given points. + distanceTo: function (point) { + point = toPoint(point); + + var x = point.x - this.x, + y = point.y - this.y; + + return Math.sqrt(x * x + y * y); + }, + + // @method equals(otherPoint: Point): Boolean + // Returns `true` if the given point has the same coordinates. + equals: function (point) { + point = toPoint(point); + + return point.x === this.x && + point.y === this.y; + }, + + // @method contains(otherPoint: Point): Boolean + // Returns `true` if both coordinates of the given point are less than the corresponding current point coordinates (in absolute values). + contains: function (point) { + point = toPoint(point); + + return Math.abs(point.x) <= Math.abs(this.x) && + Math.abs(point.y) <= Math.abs(this.y); + }, + + // @method toString(): String + // Returns a string representation of the point for debugging purposes. + toString: function () { + return 'Point(' + + formatNum(this.x) + ', ' + + formatNum(this.y) + ')'; + } +}; + +// @factory L.point(x: Number, y: Number, round?: Boolean) +// Creates a Point object with the given `x` and `y` coordinates. If optional `round` is set to true, rounds the `x` and `y` values. + +// @alternative +// @factory L.point(coords: Number[]) +// Expects an array of the form `[x, y]` instead. + +// @alternative +// @factory L.point(coords: Object) +// Expects a plain object of the form `{x: Number, y: Number}` instead. +function toPoint(x, y, round) { + if (x instanceof Point) { + return x; + } + if (isArray(x)) { + return new Point(x[0], x[1]); + } + if (x === undefined || x === null) { + return x; + } + if (typeof x === 'object' && 'x' in x && 'y' in x) { + return new Point(x.x, x.y); + } + return new Point(x, y, round); +} + +/* + * @class Bounds + * @aka L.Bounds + * + * Represents a rectangular area in pixel coordinates. + * + * @example + * + * ```js + * var p1 = L.point(10, 10), + * p2 = L.point(40, 60), + * bounds = L.bounds(p1, p2); + * ``` + * + * All Leaflet methods that accept `Bounds` objects also accept them in a simple Array form (unless noted otherwise), so the bounds example above can be passed like this: + * + * ```js + * otherBounds.intersects([[10, 10], [40, 60]]); + * ``` + * + * Note that `Bounds` does not inherit from Leaflet's `Class` object, + * which means new classes can't inherit from it, and new methods + * can't be added to it with the `include` function. + */ + +function Bounds(a, b) { + if (!a) { return; } + + var points = b ? [a, b] : a; + + for (var i = 0, len = points.length; i < len; i++) { + this.extend(points[i]); + } +} + +Bounds.prototype = { + // @method extend(point: Point): this + // Extends the bounds to contain the given point. + + // @alternative + // @method extend(otherBounds: Bounds): this + // Extend the bounds to contain the given bounds + extend: function (obj) { + var min2, max2; + if (!obj) { return this; } + + if (obj instanceof Point || typeof obj[0] === 'number' || 'x' in obj) { + min2 = max2 = toPoint(obj); + } else { + obj = toBounds(obj); + min2 = obj.min; + max2 = obj.max; + + if (!min2 || !max2) { return this; } + } + + // @property min: Point + // The top left corner of the rectangle. + // @property max: Point + // The bottom right corner of the rectangle. + if (!this.min && !this.max) { + this.min = min2.clone(); + this.max = max2.clone(); + } else { + this.min.x = Math.min(min2.x, this.min.x); + this.max.x = Math.max(max2.x, this.max.x); + this.min.y = Math.min(min2.y, this.min.y); + this.max.y = Math.max(max2.y, this.max.y); + } + return this; + }, + + // @method getCenter(round?: Boolean): Point + // Returns the center point of the bounds. + getCenter: function (round) { + return toPoint( + (this.min.x + this.max.x) / 2, + (this.min.y + this.max.y) / 2, round); + }, + + // @method getBottomLeft(): Point + // Returns the bottom-left point of the bounds. + getBottomLeft: function () { + return toPoint(this.min.x, this.max.y); + }, + + // @method getTopRight(): Point + // Returns the top-right point of the bounds. + getTopRight: function () { // -> Point + return toPoint(this.max.x, this.min.y); + }, + + // @method getTopLeft(): Point + // Returns the top-left point of the bounds (i.e. [`this.min`](#bounds-min)). + getTopLeft: function () { + return this.min; // left, top + }, + + // @method getBottomRight(): Point + // Returns the bottom-right point of the bounds (i.e. [`this.max`](#bounds-max)). + getBottomRight: function () { + return this.max; // right, bottom + }, + + // @method getSize(): Point + // Returns the size of the given bounds + getSize: function () { + return this.max.subtract(this.min); + }, + + // @method contains(otherBounds: Bounds): Boolean + // Returns `true` if the rectangle contains the given one. + // @alternative + // @method contains(point: Point): Boolean + // Returns `true` if the rectangle contains the given point. + contains: function (obj) { + var min, max; + + if (typeof obj[0] === 'number' || obj instanceof Point) { + obj = toPoint(obj); + } else { + obj = toBounds(obj); + } + + if (obj instanceof Bounds) { + min = obj.min; + max = obj.max; + } else { + min = max = obj; + } + + return (min.x >= this.min.x) && + (max.x <= this.max.x) && + (min.y >= this.min.y) && + (max.y <= this.max.y); + }, + + // @method intersects(otherBounds: Bounds): Boolean + // Returns `true` if the rectangle intersects the given bounds. Two bounds + // intersect if they have at least one point in common. + intersects: function (bounds) { // (Bounds) -> Boolean + bounds = toBounds(bounds); + + var min = this.min, + max = this.max, + min2 = bounds.min, + max2 = bounds.max, + xIntersects = (max2.x >= min.x) && (min2.x <= max.x), + yIntersects = (max2.y >= min.y) && (min2.y <= max.y); + + return xIntersects && yIntersects; + }, + + // @method overlaps(otherBounds: Bounds): Boolean + // Returns `true` if the rectangle overlaps the given bounds. Two bounds + // overlap if their intersection is an area. + overlaps: function (bounds) { // (Bounds) -> Boolean + bounds = toBounds(bounds); + + var min = this.min, + max = this.max, + min2 = bounds.min, + max2 = bounds.max, + xOverlaps = (max2.x > min.x) && (min2.x < max.x), + yOverlaps = (max2.y > min.y) && (min2.y < max.y); + + return xOverlaps && yOverlaps; + }, + + // @method isValid(): Boolean + // Returns `true` if the bounds are properly initialized. + isValid: function () { + return !!(this.min && this.max); + }, + + + // @method pad(bufferRatio: Number): Bounds + // Returns bounds created by extending or retracting the current bounds by a given ratio in each direction. + // For example, a ratio of 0.5 extends the bounds by 50% in each direction. + // Negative values will retract the bounds. + pad: function (bufferRatio) { + var min = this.min, + max = this.max, + heightBuffer = Math.abs(min.x - max.x) * bufferRatio, + widthBuffer = Math.abs(min.y - max.y) * bufferRatio; + + + return toBounds( + toPoint(min.x - heightBuffer, min.y - widthBuffer), + toPoint(max.x + heightBuffer, max.y + widthBuffer)); + }, + + + // @method equals(otherBounds: Bounds): Boolean + // Returns `true` if the rectangle is equivalent to the given bounds. + equals: function (bounds) { + if (!bounds) { return false; } + + bounds = toBounds(bounds); + + return this.min.equals(bounds.getTopLeft()) && + this.max.equals(bounds.getBottomRight()); + }, +}; + + +// @factory L.bounds(corner1: Point, corner2: Point) +// Creates a Bounds object from two corners coordinate pairs. +// @alternative +// @factory L.bounds(points: Point[]) +// Creates a Bounds object from the given array of points. +function toBounds(a, b) { + if (!a || a instanceof Bounds) { + return a; + } + return new Bounds(a, b); +} + +/* + * @class LatLngBounds + * @aka L.LatLngBounds + * + * Represents a rectangular geographical area on a map. + * + * @example + * + * ```js + * var corner1 = L.latLng(40.712, -74.227), + * corner2 = L.latLng(40.774, -74.125), + * bounds = L.latLngBounds(corner1, corner2); + * ``` + * + * All Leaflet methods that accept LatLngBounds objects also accept them in a simple Array form (unless noted otherwise), so the bounds example above can be passed like this: + * + * ```js + * map.fitBounds([ + * [40.712, -74.227], + * [40.774, -74.125] + * ]); + * ``` + * + * Caution: if the area crosses the antimeridian (often confused with the International Date Line), you must specify corners _outside_ the [-180, 180] degrees longitude range. + * + * Note that `LatLngBounds` does not inherit from Leaflet's `Class` object, + * which means new classes can't inherit from it, and new methods + * can't be added to it with the `include` function. + */ + +function LatLngBounds(corner1, corner2) { // (LatLng, LatLng) or (LatLng[]) + if (!corner1) { return; } + + var latlngs = corner2 ? [corner1, corner2] : corner1; + + for (var i = 0, len = latlngs.length; i < len; i++) { + this.extend(latlngs[i]); + } +} + +LatLngBounds.prototype = { + + // @method extend(latlng: LatLng): this + // Extend the bounds to contain the given point + + // @alternative + // @method extend(otherBounds: LatLngBounds): this + // Extend the bounds to contain the given bounds + extend: function (obj) { + var sw = this._southWest, + ne = this._northEast, + sw2, ne2; + + if (obj instanceof LatLng) { + sw2 = obj; + ne2 = obj; + + } else if (obj instanceof LatLngBounds) { + sw2 = obj._southWest; + ne2 = obj._northEast; + + if (!sw2 || !ne2) { return this; } + + } else { + return obj ? this.extend(toLatLng(obj) || toLatLngBounds(obj)) : this; + } + + if (!sw && !ne) { + this._southWest = new LatLng(sw2.lat, sw2.lng); + this._northEast = new LatLng(ne2.lat, ne2.lng); + } else { + sw.lat = Math.min(sw2.lat, sw.lat); + sw.lng = Math.min(sw2.lng, sw.lng); + ne.lat = Math.max(ne2.lat, ne.lat); + ne.lng = Math.max(ne2.lng, ne.lng); + } + + return this; + }, + + // @method pad(bufferRatio: Number): LatLngBounds + // Returns bounds created by extending or retracting the current bounds by a given ratio in each direction. + // For example, a ratio of 0.5 extends the bounds by 50% in each direction. + // Negative values will retract the bounds. + pad: function (bufferRatio) { + var sw = this._southWest, + ne = this._northEast, + heightBuffer = Math.abs(sw.lat - ne.lat) * bufferRatio, + widthBuffer = Math.abs(sw.lng - ne.lng) * bufferRatio; + + return new LatLngBounds( + new LatLng(sw.lat - heightBuffer, sw.lng - widthBuffer), + new LatLng(ne.lat + heightBuffer, ne.lng + widthBuffer)); + }, + + // @method getCenter(): LatLng + // Returns the center point of the bounds. + getCenter: function () { + return new LatLng( + (this._southWest.lat + this._northEast.lat) / 2, + (this._southWest.lng + this._northEast.lng) / 2); + }, + + // @method getSouthWest(): LatLng + // Returns the south-west point of the bounds. + getSouthWest: function () { + return this._southWest; + }, + + // @method getNorthEast(): LatLng + // Returns the north-east point of the bounds. + getNorthEast: function () { + return this._northEast; + }, + + // @method getNorthWest(): LatLng + // Returns the north-west point of the bounds. + getNorthWest: function () { + return new LatLng(this.getNorth(), this.getWest()); + }, + + // @method getSouthEast(): LatLng + // Returns the south-east point of the bounds. + getSouthEast: function () { + return new LatLng(this.getSouth(), this.getEast()); + }, + + // @method getWest(): Number + // Returns the west longitude of the bounds + getWest: function () { + return this._southWest.lng; + }, + + // @method getSouth(): Number + // Returns the south latitude of the bounds + getSouth: function () { + return this._southWest.lat; + }, + + // @method getEast(): Number + // Returns the east longitude of the bounds + getEast: function () { + return this._northEast.lng; + }, + + // @method getNorth(): Number + // Returns the north latitude of the bounds + getNorth: function () { + return this._northEast.lat; + }, + + // @method contains(otherBounds: LatLngBounds): Boolean + // Returns `true` if the rectangle contains the given one. + + // @alternative + // @method contains (latlng: LatLng): Boolean + // Returns `true` if the rectangle contains the given point. + contains: function (obj) { // (LatLngBounds) or (LatLng) -> Boolean + if (typeof obj[0] === 'number' || obj instanceof LatLng || 'lat' in obj) { + obj = toLatLng(obj); + } else { + obj = toLatLngBounds(obj); + } + + var sw = this._southWest, + ne = this._northEast, + sw2, ne2; + + if (obj instanceof LatLngBounds) { + sw2 = obj.getSouthWest(); + ne2 = obj.getNorthEast(); + } else { + sw2 = ne2 = obj; + } + + return (sw2.lat >= sw.lat) && (ne2.lat <= ne.lat) && + (sw2.lng >= sw.lng) && (ne2.lng <= ne.lng); + }, + + // @method intersects(otherBounds: LatLngBounds): Boolean + // Returns `true` if the rectangle intersects the given bounds. Two bounds intersect if they have at least one point in common. + intersects: function (bounds) { + bounds = toLatLngBounds(bounds); + + var sw = this._southWest, + ne = this._northEast, + sw2 = bounds.getSouthWest(), + ne2 = bounds.getNorthEast(), + + latIntersects = (ne2.lat >= sw.lat) && (sw2.lat <= ne.lat), + lngIntersects = (ne2.lng >= sw.lng) && (sw2.lng <= ne.lng); + + return latIntersects && lngIntersects; + }, + + // @method overlaps(otherBounds: LatLngBounds): Boolean + // Returns `true` if the rectangle overlaps the given bounds. Two bounds overlap if their intersection is an area. + overlaps: function (bounds) { + bounds = toLatLngBounds(bounds); + + var sw = this._southWest, + ne = this._northEast, + sw2 = bounds.getSouthWest(), + ne2 = bounds.getNorthEast(), + + latOverlaps = (ne2.lat > sw.lat) && (sw2.lat < ne.lat), + lngOverlaps = (ne2.lng > sw.lng) && (sw2.lng < ne.lng); + + return latOverlaps && lngOverlaps; + }, + + // @method toBBoxString(): String + // Returns a string with bounding box coordinates in a 'southwest_lng,southwest_lat,northeast_lng,northeast_lat' format. Useful for sending requests to web services that return geo data. + toBBoxString: function () { + return [this.getWest(), this.getSouth(), this.getEast(), this.getNorth()].join(','); + }, + + // @method equals(otherBounds: LatLngBounds, maxMargin?: Number): Boolean + // Returns `true` if the rectangle is equivalent (within a small margin of error) to the given bounds. The margin of error can be overridden by setting `maxMargin` to a small number. + equals: function (bounds, maxMargin) { + if (!bounds) { return false; } + + bounds = toLatLngBounds(bounds); + + return this._southWest.equals(bounds.getSouthWest(), maxMargin) && + this._northEast.equals(bounds.getNorthEast(), maxMargin); + }, + + // @method isValid(): Boolean + // Returns `true` if the bounds are properly initialized. + isValid: function () { + return !!(this._southWest && this._northEast); + } +}; + +// TODO International date line? + +// @factory L.latLngBounds(corner1: LatLng, corner2: LatLng) +// Creates a `LatLngBounds` object by defining two diagonally opposite corners of the rectangle. + +// @alternative +// @factory L.latLngBounds(latlngs: LatLng[]) +// Creates a `LatLngBounds` object defined by the geographical points it contains. Very useful for zooming the map to fit a particular set of locations with [`fitBounds`](#map-fitbounds). +function toLatLngBounds(a, b) { + if (a instanceof LatLngBounds) { + return a; + } + return new LatLngBounds(a, b); +} + +/* @class LatLng + * @aka L.LatLng + * + * Represents a geographical point with a certain latitude and longitude. + * + * @example + * + * ``` + * var latlng = L.latLng(50.5, 30.5); + * ``` + * + * All Leaflet methods that accept LatLng objects also accept them in a simple Array form and simple object form (unless noted otherwise), so these lines are equivalent: + * + * ``` + * map.panTo([50, 30]); + * map.panTo({lon: 30, lat: 50}); + * map.panTo({lat: 50, lng: 30}); + * map.panTo(L.latLng(50, 30)); + * ``` + * + * Note that `LatLng` does not inherit from Leaflet's `Class` object, + * which means new classes can't inherit from it, and new methods + * can't be added to it with the `include` function. + */ + +function LatLng(lat, lng, alt) { + if (isNaN(lat) || isNaN(lng)) { + throw new Error('Invalid LatLng object: (' + lat + ', ' + lng + ')'); + } + + // @property lat: Number + // Latitude in degrees + this.lat = +lat; + + // @property lng: Number + // Longitude in degrees + this.lng = +lng; + + // @property alt: Number + // Altitude in meters (optional) + if (alt !== undefined) { + this.alt = +alt; + } +} + +LatLng.prototype = { + // @method equals(otherLatLng: LatLng, maxMargin?: Number): Boolean + // Returns `true` if the given `LatLng` point is at the same position (within a small margin of error). The margin of error can be overridden by setting `maxMargin` to a small number. + equals: function (obj, maxMargin) { + if (!obj) { return false; } + + obj = toLatLng(obj); + + var margin = Math.max( + Math.abs(this.lat - obj.lat), + Math.abs(this.lng - obj.lng)); + + return margin <= (maxMargin === undefined ? 1.0E-9 : maxMargin); + }, + + // @method toString(): String + // Returns a string representation of the point (for debugging purposes). + toString: function (precision) { + return 'LatLng(' + + formatNum(this.lat, precision) + ', ' + + formatNum(this.lng, precision) + ')'; + }, + + // @method distanceTo(otherLatLng: LatLng): Number + // Returns the distance (in meters) to the given `LatLng` calculated using the [Spherical Law of Cosines](https://en.wikipedia.org/wiki/Spherical_law_of_cosines). + distanceTo: function (other) { + return Earth.distance(this, toLatLng(other)); + }, + + // @method wrap(): LatLng + // Returns a new `LatLng` object with the longitude wrapped so it's always between -180 and +180 degrees. + wrap: function () { + return Earth.wrapLatLng(this); + }, + + // @method toBounds(sizeInMeters: Number): LatLngBounds + // Returns a new `LatLngBounds` object in which each boundary is `sizeInMeters/2` meters apart from the `LatLng`. + toBounds: function (sizeInMeters) { + var latAccuracy = 180 * sizeInMeters / 40075017, + lngAccuracy = latAccuracy / Math.cos((Math.PI / 180) * this.lat); + + return toLatLngBounds( + [this.lat - latAccuracy, this.lng - lngAccuracy], + [this.lat + latAccuracy, this.lng + lngAccuracy]); + }, + + clone: function () { + return new LatLng(this.lat, this.lng, this.alt); + } +}; + + + +// @factory L.latLng(latitude: Number, longitude: Number, altitude?: Number): LatLng +// Creates an object representing a geographical point with the given latitude and longitude (and optionally altitude). + +// @alternative +// @factory L.latLng(coords: Array): LatLng +// Expects an array of the form `[Number, Number]` or `[Number, Number, Number]` instead. + +// @alternative +// @factory L.latLng(coords: Object): LatLng +// Expects an plain object of the form `{lat: Number, lng: Number}` or `{lat: Number, lng: Number, alt: Number}` instead. + +function toLatLng(a, b, c) { + if (a instanceof LatLng) { + return a; + } + if (isArray(a) && typeof a[0] !== 'object') { + if (a.length === 3) { + return new LatLng(a[0], a[1], a[2]); + } + if (a.length === 2) { + return new LatLng(a[0], a[1]); + } + return null; + } + if (a === undefined || a === null) { + return a; + } + if (typeof a === 'object' && 'lat' in a) { + return new LatLng(a.lat, 'lng' in a ? a.lng : a.lon, a.alt); + } + if (b === undefined) { + return null; + } + return new LatLng(a, b, c); +} + +/* + * @namespace CRS + * @crs L.CRS.Base + * Object that defines coordinate reference systems for projecting + * geographical points into pixel (screen) coordinates and back (and to + * coordinates in other units for [WMS](https://en.wikipedia.org/wiki/Web_Map_Service) services). See + * [spatial reference system](https://en.wikipedia.org/wiki/Spatial_reference_system). + * + * Leaflet defines the most usual CRSs by default. If you want to use a + * CRS not defined by default, take a look at the + * [Proj4Leaflet](https://github.com/kartena/Proj4Leaflet) plugin. + * + * Note that the CRS instances do not inherit from Leaflet's `Class` object, + * and can't be instantiated. Also, new classes can't inherit from them, + * and methods can't be added to them with the `include` function. + */ + +var CRS = { + // @method latLngToPoint(latlng: LatLng, zoom: Number): Point + // Projects geographical coordinates into pixel coordinates for a given zoom. + latLngToPoint: function (latlng, zoom) { + var projectedPoint = this.projection.project(latlng), + scale = this.scale(zoom); + + return this.transformation._transform(projectedPoint, scale); + }, + + // @method pointToLatLng(point: Point, zoom: Number): LatLng + // The inverse of `latLngToPoint`. Projects pixel coordinates on a given + // zoom into geographical coordinates. + pointToLatLng: function (point, zoom) { + var scale = this.scale(zoom), + untransformedPoint = this.transformation.untransform(point, scale); + + return this.projection.unproject(untransformedPoint); + }, + + // @method project(latlng: LatLng): Point + // Projects geographical coordinates into coordinates in units accepted for + // this CRS (e.g. meters for EPSG:3857, for passing it to WMS services). + project: function (latlng) { + return this.projection.project(latlng); + }, + + // @method unproject(point: Point): LatLng + // Given a projected coordinate returns the corresponding LatLng. + // The inverse of `project`. + unproject: function (point) { + return this.projection.unproject(point); + }, + + // @method scale(zoom: Number): Number + // Returns the scale used when transforming projected coordinates into + // pixel coordinates for a particular zoom. For example, it returns + // `256 * 2^zoom` for Mercator-based CRS. + scale: function (zoom) { + return 256 * Math.pow(2, zoom); + }, + + // @method zoom(scale: Number): Number + // Inverse of `scale()`, returns the zoom level corresponding to a scale + // factor of `scale`. + zoom: function (scale) { + return Math.log(scale / 256) / Math.LN2; + }, + + // @method getProjectedBounds(zoom: Number): Bounds + // Returns the projection's bounds scaled and transformed for the provided `zoom`. + getProjectedBounds: function (zoom) { + if (this.infinite) { return null; } + + var b = this.projection.bounds, + s = this.scale(zoom), + min = this.transformation.transform(b.min, s), + max = this.transformation.transform(b.max, s); + + return new Bounds(min, max); + }, + + // @method distance(latlng1: LatLng, latlng2: LatLng): Number + // Returns the distance between two geographical coordinates. + + // @property code: String + // Standard code name of the CRS passed into WMS services (e.g. `'EPSG:3857'`) + // + // @property wrapLng: Number[] + // An array of two numbers defining whether the longitude (horizontal) coordinate + // axis wraps around a given range and how. Defaults to `[-180, 180]` in most + // geographical CRSs. If `undefined`, the longitude axis does not wrap around. + // + // @property wrapLat: Number[] + // Like `wrapLng`, but for the latitude (vertical) axis. + + // wrapLng: [min, max], + // wrapLat: [min, max], + + // @property infinite: Boolean + // If true, the coordinate space will be unbounded (infinite in both axes) + infinite: false, + + // @method wrapLatLng(latlng: LatLng): LatLng + // Returns a `LatLng` where lat and lng has been wrapped according to the + // CRS's `wrapLat` and `wrapLng` properties, if they are outside the CRS's bounds. + wrapLatLng: function (latlng) { + var lng = this.wrapLng ? wrapNum(latlng.lng, this.wrapLng, true) : latlng.lng, + lat = this.wrapLat ? wrapNum(latlng.lat, this.wrapLat, true) : latlng.lat, + alt = latlng.alt; + + return new LatLng(lat, lng, alt); + }, + + // @method wrapLatLngBounds(bounds: LatLngBounds): LatLngBounds + // Returns a `LatLngBounds` with the same size as the given one, ensuring + // that its center is within the CRS's bounds. + // Only accepts actual `L.LatLngBounds` instances, not arrays. + wrapLatLngBounds: function (bounds) { + var center = bounds.getCenter(), + newCenter = this.wrapLatLng(center), + latShift = center.lat - newCenter.lat, + lngShift = center.lng - newCenter.lng; + + if (latShift === 0 && lngShift === 0) { + return bounds; + } + + var sw = bounds.getSouthWest(), + ne = bounds.getNorthEast(), + newSw = new LatLng(sw.lat - latShift, sw.lng - lngShift), + newNe = new LatLng(ne.lat - latShift, ne.lng - lngShift); + + return new LatLngBounds(newSw, newNe); + } +}; + +/* + * @namespace CRS + * @crs L.CRS.Earth + * + * Serves as the base for CRS that are global such that they cover the earth. + * Can only be used as the base for other CRS and cannot be used directly, + * since it does not have a `code`, `projection` or `transformation`. `distance()` returns + * meters. + */ + +var Earth = extend({}, CRS, { + wrapLng: [-180, 180], + + // Mean Earth Radius, as recommended for use by + // the International Union of Geodesy and Geophysics, + // see https://rosettacode.org/wiki/Haversine_formula + R: 6371000, + + // distance between two geographical points using spherical law of cosines approximation + distance: function (latlng1, latlng2) { + var rad = Math.PI / 180, + lat1 = latlng1.lat * rad, + lat2 = latlng2.lat * rad, + sinDLat = Math.sin((latlng2.lat - latlng1.lat) * rad / 2), + sinDLon = Math.sin((latlng2.lng - latlng1.lng) * rad / 2), + a = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon, + c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return this.R * c; + } +}); + +/* + * @namespace Projection + * @projection L.Projection.SphericalMercator + * + * Spherical Mercator projection — the most common projection for online maps, + * used by almost all free and commercial tile providers. Assumes that Earth is + * a sphere. Used by the `EPSG:3857` CRS. + */ + +var earthRadius = 6378137; + +var SphericalMercator = { + + R: earthRadius, + MAX_LATITUDE: 85.0511287798, + + project: function (latlng) { + var d = Math.PI / 180, + max = this.MAX_LATITUDE, + lat = Math.max(Math.min(max, latlng.lat), -max), + sin = Math.sin(lat * d); + + return new Point( + this.R * latlng.lng * d, + this.R * Math.log((1 + sin) / (1 - sin)) / 2); + }, + + unproject: function (point) { + var d = 180 / Math.PI; + + return new LatLng( + (2 * Math.atan(Math.exp(point.y / this.R)) - (Math.PI / 2)) * d, + point.x * d / this.R); + }, + + bounds: (function () { + var d = earthRadius * Math.PI; + return new Bounds([-d, -d], [d, d]); + })() +}; + +/* + * @class Transformation + * @aka L.Transformation + * + * Represents an affine transformation: a set of coefficients `a`, `b`, `c`, `d` + * for transforming a point of a form `(x, y)` into `(a*x + b, c*y + d)` and doing + * the reverse. Used by Leaflet in its projections code. + * + * @example + * + * ```js + * var transformation = L.transformation(2, 5, -1, 10), + * p = L.point(1, 2), + * p2 = transformation.transform(p), // L.point(7, 8) + * p3 = transformation.untransform(p2); // L.point(1, 2) + * ``` + */ + + +// factory new L.Transformation(a: Number, b: Number, c: Number, d: Number) +// Creates a `Transformation` object with the given coefficients. +function Transformation(a, b, c, d) { + if (isArray(a)) { + // use array properties + this._a = a[0]; + this._b = a[1]; + this._c = a[2]; + this._d = a[3]; + return; + } + this._a = a; + this._b = b; + this._c = c; + this._d = d; +} + +Transformation.prototype = { + // @method transform(point: Point, scale?: Number): Point + // Returns a transformed point, optionally multiplied by the given scale. + // Only accepts actual `L.Point` instances, not arrays. + transform: function (point, scale) { // (Point, Number) -> Point + return this._transform(point.clone(), scale); + }, + + // destructive transform (faster) + _transform: function (point, scale) { + scale = scale || 1; + point.x = scale * (this._a * point.x + this._b); + point.y = scale * (this._c * point.y + this._d); + return point; + }, + + // @method untransform(point: Point, scale?: Number): Point + // Returns the reverse transformation of the given point, optionally divided + // by the given scale. Only accepts actual `L.Point` instances, not arrays. + untransform: function (point, scale) { + scale = scale || 1; + return new Point( + (point.x / scale - this._b) / this._a, + (point.y / scale - this._d) / this._c); + } +}; + +// factory L.transformation(a: Number, b: Number, c: Number, d: Number) + +// @factory L.transformation(a: Number, b: Number, c: Number, d: Number) +// Instantiates a Transformation object with the given coefficients. + +// @alternative +// @factory L.transformation(coefficients: Array): Transformation +// Expects an coefficients array of the form +// `[a: Number, b: Number, c: Number, d: Number]`. + +function toTransformation(a, b, c, d) { + return new Transformation(a, b, c, d); +} + +/* + * @namespace CRS + * @crs L.CRS.EPSG3857 + * + * The most common CRS for online maps, used by almost all free and commercial + * tile providers. Uses Spherical Mercator projection. Set in by default in + * Map's `crs` option. + */ + +var EPSG3857 = extend({}, Earth, { + code: 'EPSG:3857', + projection: SphericalMercator, + + transformation: (function () { + var scale = 0.5 / (Math.PI * SphericalMercator.R); + return toTransformation(scale, 0.5, -scale, 0.5); + }()) +}); + +var EPSG900913 = extend({}, EPSG3857, { + code: 'EPSG:900913' +}); + +// @namespace SVG; @section +// There are several static functions which can be called without instantiating L.SVG: + +// @function create(name: String): SVGElement +// Returns a instance of [SVGElement](https://developer.mozilla.org/docs/Web/API/SVGElement), +// corresponding to the class name passed. For example, using 'line' will return +// an instance of [SVGLineElement](https://developer.mozilla.org/docs/Web/API/SVGLineElement). +function svgCreate(name) { + return document.createElementNS('http://www.w3.org/2000/svg', name); +} + +// @function pointsToPath(rings: Point[], closed: Boolean): String +// Generates a SVG path string for multiple rings, with each ring turning +// into "M..L..L.." instructions +function pointsToPath(rings, closed) { + var str = '', + i, j, len, len2, points, p; + + for (i = 0, len = rings.length; i < len; i++) { + points = rings[i]; + + for (j = 0, len2 = points.length; j < len2; j++) { + p = points[j]; + str += (j ? 'L' : 'M') + p.x + ' ' + p.y; + } + + // closes the ring for polygons; "x" is VML syntax + str += closed ? (Browser.svg ? 'z' : 'x') : ''; + } + + // SVG complains about empty path strings + return str || 'M0 0'; +} + +/* + * @namespace Browser + * @aka L.Browser + * + * A namespace with static properties for browser/feature detection used by Leaflet internally. + * + * @example + * + * ```js + * if (L.Browser.ielt9) { + * alert('Upgrade your browser, dude!'); + * } + * ``` + */ + +var style = document.documentElement.style; + +// @property ie: Boolean; `true` for all Internet Explorer versions (not Edge). +var ie = 'ActiveXObject' in window; + +// @property ielt9: Boolean; `true` for Internet Explorer versions less than 9. +var ielt9 = ie && !document.addEventListener; + +// @property edge: Boolean; `true` for the Edge web browser. +var edge = 'msLaunchUri' in navigator && !('documentMode' in document); + +// @property webkit: Boolean; +// `true` for webkit-based browsers like Chrome and Safari (including mobile versions). +var webkit = userAgentContains('webkit'); + +// @property android: Boolean +// **Deprecated.** `true` for any browser running on an Android platform. +var android = userAgentContains('android'); + +// @property android23: Boolean; **Deprecated.** `true` for browsers running on Android 2 or Android 3. +var android23 = userAgentContains('android 2') || userAgentContains('android 3'); + +/* See https://stackoverflow.com/a/17961266 for details on detecting stock Android */ +var webkitVer = parseInt(/WebKit\/([0-9]+)|$/.exec(navigator.userAgent)[1], 10); // also matches AppleWebKit +// @property androidStock: Boolean; **Deprecated.** `true` for the Android stock browser (i.e. not Chrome) +var androidStock = android && userAgentContains('Google') && webkitVer < 537 && !('AudioNode' in window); + +// @property opera: Boolean; `true` for the Opera browser +var opera = !!window.opera; + +// @property chrome: Boolean; `true` for the Chrome browser. +var chrome = !edge && userAgentContains('chrome'); + +// @property gecko: Boolean; `true` for gecko-based browsers like Firefox. +var gecko = userAgentContains('gecko') && !webkit && !opera && !ie; + +// @property safari: Boolean; `true` for the Safari browser. +var safari = !chrome && userAgentContains('safari'); + +var phantom = userAgentContains('phantom'); + +// @property opera12: Boolean +// `true` for the Opera browser supporting CSS transforms (version 12 or later). +var opera12 = 'OTransition' in style; + +// @property win: Boolean; `true` when the browser is running in a Windows platform +var win = navigator.platform.indexOf('Win') === 0; + +// @property ie3d: Boolean; `true` for all Internet Explorer versions supporting CSS transforms. +var ie3d = ie && ('transition' in style); + +// @property webkit3d: Boolean; `true` for webkit-based browsers supporting CSS transforms. +var webkit3d = ('WebKitCSSMatrix' in window) && ('m11' in new window.WebKitCSSMatrix()) && !android23; + +// @property gecko3d: Boolean; `true` for gecko-based browsers supporting CSS transforms. +var gecko3d = 'MozPerspective' in style; + +// @property any3d: Boolean +// `true` for all browsers supporting CSS transforms. +var any3d = !window.L_DISABLE_3D && (ie3d || webkit3d || gecko3d) && !opera12 && !phantom; + +// @property mobile: Boolean; `true` for all browsers running in a mobile device. +var mobile = typeof orientation !== 'undefined' || userAgentContains('mobile'); + +// @property mobileWebkit: Boolean; `true` for all webkit-based browsers in a mobile device. +var mobileWebkit = mobile && webkit; + +// @property mobileWebkit3d: Boolean +// `true` for all webkit-based browsers in a mobile device supporting CSS transforms. +var mobileWebkit3d = mobile && webkit3d; + +// @property msPointer: Boolean +// `true` for browsers implementing the Microsoft touch events model (notably IE10). +var msPointer = !window.PointerEvent && window.MSPointerEvent; + +// @property pointer: Boolean +// `true` for all browsers supporting [pointer events](https://msdn.microsoft.com/en-us/library/dn433244%28v=vs.85%29.aspx). +var pointer = !!(window.PointerEvent || msPointer); + +// @property touchNative: Boolean +// `true` for all browsers supporting [touch events](https://developer.mozilla.org/docs/Web/API/Touch_events). +// **This does not necessarily mean** that the browser is running in a computer with +// a touchscreen, it only means that the browser is capable of understanding +// touch events. +var touchNative = 'ontouchstart' in window || !!window.TouchEvent; + +// @property touch: Boolean +// `true` for all browsers supporting either [touch](#browser-touch) or [pointer](#browser-pointer) events. +// Note: pointer events will be preferred (if available), and processed for all `touch*` listeners. +var touch = !window.L_NO_TOUCH && (touchNative || pointer); + +// @property mobileOpera: Boolean; `true` for the Opera browser in a mobile device. +var mobileOpera = mobile && opera; + +// @property mobileGecko: Boolean +// `true` for gecko-based browsers running in a mobile device. +var mobileGecko = mobile && gecko; + +// @property retina: Boolean +// `true` for browsers on a high-resolution "retina" screen or on any screen when browser's display zoom is more than 100%. +var retina = (window.devicePixelRatio || (window.screen.deviceXDPI / window.screen.logicalXDPI)) > 1; + +// @property passiveEvents: Boolean +// `true` for browsers that support passive events. +var passiveEvents = (function () { + var supportsPassiveOption = false; + try { + var opts = Object.defineProperty({}, 'passive', { + get: function () { // eslint-disable-line getter-return + supportsPassiveOption = true; + } + }); + window.addEventListener('testPassiveEventSupport', falseFn, opts); + window.removeEventListener('testPassiveEventSupport', falseFn, opts); + } catch (e) { + // Errors can safely be ignored since this is only a browser support test. + } + return supportsPassiveOption; +}()); + +// @property canvas: Boolean +// `true` when the browser supports [``](https://developer.mozilla.org/docs/Web/API/Canvas_API). +var canvas$1 = (function () { + return !!document.createElement('canvas').getContext; +}()); + +// @property svg: Boolean +// `true` when the browser supports [SVG](https://developer.mozilla.org/docs/Web/SVG). +var svg$1 = !!(document.createElementNS && svgCreate('svg').createSVGRect); + +var inlineSvg = !!svg$1 && (function () { + var div = document.createElement('div'); + div.innerHTML = ''; + return (div.firstChild && div.firstChild.namespaceURI) === 'http://www.w3.org/2000/svg'; +})(); + +// @property vml: Boolean +// `true` if the browser supports [VML](https://en.wikipedia.org/wiki/Vector_Markup_Language). +var vml = !svg$1 && (function () { + try { + var div = document.createElement('div'); + div.innerHTML = ''; + + var shape = div.firstChild; + shape.style.behavior = 'url(#default#VML)'; + + return shape && (typeof shape.adj === 'object'); + + } catch (e) { + return false; + } +}()); + + +// @property mac: Boolean; `true` when the browser is running in a Mac platform +var mac = navigator.platform.indexOf('Mac') === 0; + +// @property mac: Boolean; `true` when the browser is running in a Linux platform +var linux = navigator.platform.indexOf('Linux') === 0; + +function userAgentContains(str) { + return navigator.userAgent.toLowerCase().indexOf(str) >= 0; +} + + +var Browser = { + ie: ie, + ielt9: ielt9, + edge: edge, + webkit: webkit, + android: android, + android23: android23, + androidStock: androidStock, + opera: opera, + chrome: chrome, + gecko: gecko, + safari: safari, + phantom: phantom, + opera12: opera12, + win: win, + ie3d: ie3d, + webkit3d: webkit3d, + gecko3d: gecko3d, + any3d: any3d, + mobile: mobile, + mobileWebkit: mobileWebkit, + mobileWebkit3d: mobileWebkit3d, + msPointer: msPointer, + pointer: pointer, + touch: touch, + touchNative: touchNative, + mobileOpera: mobileOpera, + mobileGecko: mobileGecko, + retina: retina, + passiveEvents: passiveEvents, + canvas: canvas$1, + svg: svg$1, + vml: vml, + inlineSvg: inlineSvg, + mac: mac, + linux: linux +}; + +/* + * Extends L.DomEvent to provide touch support for Internet Explorer and Windows-based devices. + */ + +var POINTER_DOWN = Browser.msPointer ? 'MSPointerDown' : 'pointerdown'; +var POINTER_MOVE = Browser.msPointer ? 'MSPointerMove' : 'pointermove'; +var POINTER_UP = Browser.msPointer ? 'MSPointerUp' : 'pointerup'; +var POINTER_CANCEL = Browser.msPointer ? 'MSPointerCancel' : 'pointercancel'; +var pEvent = { + touchstart : POINTER_DOWN, + touchmove : POINTER_MOVE, + touchend : POINTER_UP, + touchcancel : POINTER_CANCEL +}; +var handle = { + touchstart : _onPointerStart, + touchmove : _handlePointer, + touchend : _handlePointer, + touchcancel : _handlePointer +}; +var _pointers = {}; +var _pointerDocListener = false; + +// Provides a touch events wrapper for (ms)pointer events. +// ref https://www.w3.org/TR/pointerevents/ https://www.w3.org/Bugs/Public/show_bug.cgi?id=22890 + +function addPointerListener(obj, type, handler) { + if (type === 'touchstart') { + _addPointerDocListener(); + } + if (!handle[type]) { + console.warn('wrong event specified:', type); + return falseFn; + } + handler = handle[type].bind(this, handler); + obj.addEventListener(pEvent[type], handler, false); + return handler; +} + +function removePointerListener(obj, type, handler) { + if (!pEvent[type]) { + console.warn('wrong event specified:', type); + return; + } + obj.removeEventListener(pEvent[type], handler, false); +} + +function _globalPointerDown(e) { + _pointers[e.pointerId] = e; +} + +function _globalPointerMove(e) { + if (_pointers[e.pointerId]) { + _pointers[e.pointerId] = e; + } +} + +function _globalPointerUp(e) { + delete _pointers[e.pointerId]; +} + +function _addPointerDocListener() { + // need to keep track of what pointers and how many are active to provide e.touches emulation + if (!_pointerDocListener) { + // we listen document as any drags that end by moving the touch off the screen get fired there + document.addEventListener(POINTER_DOWN, _globalPointerDown, true); + document.addEventListener(POINTER_MOVE, _globalPointerMove, true); + document.addEventListener(POINTER_UP, _globalPointerUp, true); + document.addEventListener(POINTER_CANCEL, _globalPointerUp, true); + + _pointerDocListener = true; + } +} + +function _handlePointer(handler, e) { + if (e.pointerType === (e.MSPOINTER_TYPE_MOUSE || 'mouse')) { return; } + + e.touches = []; + for (var i in _pointers) { + e.touches.push(_pointers[i]); + } + e.changedTouches = [e]; + + handler(e); +} + +function _onPointerStart(handler, e) { + // IE10 specific: MsTouch needs preventDefault. See #2000 + if (e.MSPOINTER_TYPE_TOUCH && e.pointerType === e.MSPOINTER_TYPE_TOUCH) { + preventDefault(e); + } + _handlePointer(handler, e); +} + +/* + * Extends the event handling code with double tap support for mobile browsers. + * + * Note: currently most browsers fire native dblclick, with only a few exceptions + * (see https://github.com/Leaflet/Leaflet/issues/7012#issuecomment-595087386) + */ + +function makeDblclick(event) { + // in modern browsers `type` cannot be just overridden: + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Getter_only + var newEvent = {}, + prop, i; + for (i in event) { + prop = event[i]; + newEvent[i] = prop && prop.bind ? prop.bind(event) : prop; + } + event = newEvent; + newEvent.type = 'dblclick'; + newEvent.detail = 2; + newEvent.isTrusted = false; + newEvent._simulated = true; // for debug purposes + return newEvent; +} + +var delay = 200; +function addDoubleTapListener(obj, handler) { + // Most browsers handle double tap natively + obj.addEventListener('dblclick', handler); + + // On some platforms the browser doesn't fire native dblclicks for touch events. + // It seems that in all such cases `detail` property of `click` event is always `1`. + // So here we rely on that fact to avoid excessive 'dblclick' simulation when not needed. + var last = 0, + detail; + function simDblclick(e) { + if (e.detail !== 1) { + detail = e.detail; // keep in sync to avoid false dblclick in some cases + return; + } + + if (e.pointerType === 'mouse' || + (e.sourceCapabilities && !e.sourceCapabilities.firesTouchEvents)) { + + return; + } + + // When clicking on an , the browser generates a click on its + //