diff --git a/frontend/css/styles.css b/frontend/css/styles.css index b15b6da..3fe7f16 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1059,9 +1059,53 @@ select { height: 100%; } -.rec-map { +.rec-panel { + flex-shrink: 0; +} + +.rec-map-wrap { flex: 1; - min-height: 300px; + min-height: 200px; + position: relative; +} +.rec-map { + position: absolute; + inset: 0; +} +.gps-placeholder { + position: absolute; + inset: 0; + z-index: 1000; + background: url('../img/gps-placeholder.png') center/cover; + display: flex; + align-items: center; + justify-content: center; +} +.gps-placeholder-text { + background: rgba(0,0,0,0.7); + color: var(--on-bg); + padding: 12px 24px; + border-radius: 8px; + font-size: 18px; +} + +/* Landscape: CSS rotation compensates physical rotation. + flex-direction stays column: element-bottom = visual-right after rotate. + No font-size changes — everything stays portrait-sized, just rearranged. */ +.rec-screen.landscape-right, +.rec-screen.landscape-left { + position: fixed; + width: 100vh; + height: 100vw; + top: calc((100vh - 100vw) / 2); + left: calc((100vw - 100vh) / 2); + z-index: 9999; +} +.rec-screen.landscape-right { + transform: rotate(-90deg); +} +.rec-screen.landscape-left { + transform: rotate(90deg); } .rec-stats { diff --git a/frontend/img/gps-placeholder.png b/frontend/img/gps-placeholder.png new file mode 100644 index 0000000..4437c5a Binary files /dev/null and b/frontend/img/gps-placeholder.png differ diff --git a/frontend/index.html b/frontend/index.html index 94fce5f..a182061 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,6 +13,7 @@ + @@ -21,6 +22,7 @@ + diff --git a/frontend/js/main.js b/frontend/js/main.js index 4653dbc..fa36e85 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -1,6 +1,10 @@ // BalDav main app — pure JS, no framework, no bundler. // Depends on window.DB (db.js) and window.BMR (bmr.js) loaded before this script. +// Disable leaflet-rotate's compass control on all maps by default; +// only the recording map enables rotate: true explicitly. +if (L && L.Map) L.Map.mergeOptions({ rotateControl: false }); + const app = document.getElementById("app"); const UPDATE_BASE_URL = "https://4aiman.top/baldav"; @@ -176,6 +180,9 @@ async function init() { // Restore last screen (fromPopstate=true to avoid duplicate navStack push) renderScreen(navStack[navStack.length - 1], true); + // Initialize sync + try { await Sync.init(); if (Sync.userId) Sync.now(); } catch {} + // Check for updates in background (non-blocking) checkForUpdate(); } @@ -185,8 +192,9 @@ async function init() { // --------------------------------------------------------------------------- function renderScreen(name, fromPopstate) { - // Release screen wake lock when leaving recording screen + // Release screen wake lock and compass listener when leaving recording screen if (window._screenLock) { try { window._screenLock.release(); } catch {} window._screenLock = null; } + if (GPS.recording._compassCleanup) { GPS.recording._compassCleanup(); GPS.recording._compassCleanup = null; } const prevScreen = state.screen; state.screen = name; @@ -2733,23 +2741,32 @@ function renderRecordingInline(container) { container.innerHTML = `
-
- +
+
Синхронизация
+
+
+
${Sync.userId ? "Подключено" : "Не настроено"}
+
${Sync.userId ? (state.profile.sync_last_time || "Не синхронизировано") : "Привяжите устройство"}
+
+ +
+
+
+ +
+ + +
+
+ +
+
+ + +
+
+
О приложении
@@ -4599,6 +4716,57 @@ async function renderSettings(container) { }; } + // Sync handlers + const syncNowBtn = container.querySelector("#s-sync-now"); + if (syncNowBtn) { + syncNowBtn.onclick = async () => { + if (!Sync.userId) { + // Show setup + container.querySelector("#s-sync-setup").style.display = ""; + return; + } + syncNowBtn.textContent = "..."; + syncNowBtn.disabled = true; + const r = await Sync.now(); + container.querySelector("#s-sync-desc").textContent = `Отправлено: ${r.pushed}, получено: ${r.pulled}`; + syncNowBtn.textContent = "Синхр."; + syncNowBtn.disabled = false; + }; + } + container.querySelector("#s-sync-register")?.addEventListener("click", async () => { + try { + const code = await Sync.register(); + container.querySelector("#s-sync-status").textContent = "Подключено"; + container.querySelector("#s-sync-setup").style.display = "none"; + container.querySelector("#s-sync-info").style.display = ""; + container.querySelector("#s-sync-code-display").style.display = ""; + container.querySelector("#s-sync-code-display").textContent = code; + syncNowBtn.textContent = "Синхр."; + showToast("Аккаунт создан. Код: " + code); + } catch (e) { showToast("Ошибка: " + e.message); } + }); + container.querySelector("#s-sync-pair")?.addEventListener("click", async () => { + const code = container.querySelector("#s-sync-code").value.trim().toUpperCase(); + if (code.length !== 6) { showToast("Введите 6-символьный код"); return; } + try { + await Sync.pair(code); + container.querySelector("#s-sync-status").textContent = "Подключено"; + container.querySelector("#s-sync-setup").style.display = "none"; + container.querySelector("#s-sync-info").style.display = ""; + syncNowBtn.textContent = "Синхр."; + showToast("Устройство привязано!"); + await Sync.now(); + } catch (e) { showToast("Ошибка: " + e.message); } + }); + container.querySelector("#s-sync-show-code")?.addEventListener("click", async () => { + try { + const code = await Sync.newCode(); + const el = container.querySelector("#s-sync-code-display"); + el.style.display = ""; + el.textContent = code; + } catch (e) { showToast("Ошибка: " + e.message); } + }); + // App version info + update check try { const info = await window.__TAURI__.core.invoke("get_app_info"); diff --git a/frontend/lib/leaflet-rotate.js b/frontend/lib/leaflet-rotate.js new file mode 100644 index 0000000..6b8a343 --- /dev/null +++ b/frontend/lib/leaflet-rotate.js @@ -0,0 +1,1936 @@ +(function (factory) { + typeof define === 'function' && define.amd ? define(factory) : + factory(); +})((function () { 'use strict'; + + /** + * @external L.DomUtil + * + * @see https://github.com/Leaflet/Leaflet/tree/v1.9.3/src/dom/DomUtil.js + */ + + const domUtilProto = L.extend({}, L.DomUtil); + + L.extend(L.DomUtil, { + + /** + * Resets the 3D CSS transform of `el` so it is + * translated by `offset` pixels and optionally + * scaled by `scale`. Does not have an effect if + * the browser doesn't support 3D CSS transforms. + * + * @param {HTMLElement} el + * @param {L.Point} offset + * @param {Number} scale + * @param {Number} bearing + * @param {L.Point} pivot + */ + setTransform: function(el, offset, scale, bearing, pivot) { + var pos = offset || new L.Point(0, 0); + + if (!bearing) { + offset = pos._round(); + return domUtilProto.setTransform.apply(this, arguments); + } + + pos = pos.rotateFrom(bearing, pivot); + + el.style[L.DomUtil.TRANSFORM] = + 'translate3d(' + pos.x + 'px,' + pos.y + 'px' + ',0)' + + (scale ? ' scale(' + scale + ')' : '') + + ' rotate(' + bearing + 'rad)'; + }, + + /** + * Sets the position of `el` to coordinates specified by + * `position`, using CSS translate or top/left positioning + * depending on the browser (used by Leaflet internally + * to position its layers). + * + * @param {HTMLElement} el + * @param {L.Point} point + * @param {Number} bearing + * @param {L.Point} pivot + * @param {Number} scale + */ + setPosition: function(el, point, bearing, pivot, scale) { + if (!bearing) { + return domUtilProto.setPosition.apply(this, arguments); + } + + /*eslint-disable */ + el._leaflet_pos = point; + /*eslint-enable */ + + if (L.Browser.any3d) { + L.DomUtil.setTransform(el, point, scale, bearing, pivot); + } else { + el.style.left = point.x + 'px'; + el.style.top = point.y + 'px'; + } + }, + + /** + * @constant radians = degrees × π/180° + */ + DEG_TO_RAD: Math.PI / 180, + + /** + * @constant degrees = radians × 180°/π + */ + RAD_TO_DEG: 180 / Math.PI, + + }); + + /** + * @external L.Draggable + * + * @see https://github.com/Leaflet/Leaflet/tree/v1.9.3/src/dom/Draggable.js + */ + + /** + * A class for making DOM elements draggable (including touch support). + * Used internally for map and marker dragging. Only works for elements + * that were positioned with [`L.DomUtil.setPosition`](#domutil-setposition). + */ + + L.Draggable.include({ + + /** @TODO */ + // updateMapBearing: function(mapBearing) { + // this._mapBearing = mapBearing; + // }, + + }); + + /** + * @external L.Point + * + * @see https://github.com/Leaflet/Leaflet/tree/v1.9.3/src/geometry/Point.js + */ + + L.extend(L.Point.prototype, { + + /** + * Rotate around (0,0) by applying the 2D rotation matrix: + * + * ⎡ x' ⎤ = ⎡ cos θ -sin θ ⎤ ⎡ x ⎤ + * ⎣ y' ⎦ ⎣ sin θ cos θ ⎦ ⎣ y ⎦ + * + * @param theta must be given in radians. + */ + rotate: function(theta) { + return this.rotateFrom(theta, new L.Point(0,0)) + }, + + /** + * Rotate around (pivot.x, pivot.y) by: + * + * 1. subtract (pivot.x, pivot.y) + * 2. rotate around (0, 0) + * 3. add (pivot.x, pivot.y) back + * + * same as `this.subtract(pivot).rotate(theta).add(pivot)` + * + * @param {Number} theta + * @param {L.Point} pivot + * + * @returns {L.Point} + */ + rotateFrom: function(theta, pivot) { + if (!theta) { return this; } + var sinTheta = Math.sin(theta); + var cosTheta = Math.cos(theta); + var cx = pivot.x, + cy = pivot.y; + var x = this.x - cx, + y = this.y - cy; + + return new L.Point( + x * cosTheta - y * sinTheta + cx, + x * sinTheta + y * cosTheta + cy + ); + }, + + }); + + /** + * @external L.DivOverlay + * + * @see https://github.com/Leaflet/Leaflet/tree/v1.9.3/src/layer/DivOverlay.js + */ + + const divOverlayProto = L.extend({}, L.DivOverlay.prototype); + + L.DivOverlay.include({ + + /** + * Update L.Popup and L.Tooltip anchor positions after + * the map is moved by calling `map.setBearing(theta)` + * + * @listens L.Map~rotate + */ + getEvents: function() { + return L.extend(divOverlayProto.getEvents.apply(this, arguments), { rotate: this._updatePosition }); + }, + + /** + * 0. update element anchor point (divOverlayProto v1.9.3) + * 1. rotate around anchor point (subtract anchor -> rotate point -> add anchor) + */ + _updatePosition: function() { + if (!this._map) { return; } + divOverlayProto._updatePosition.apply(this, arguments); + if (this._map && this._map._rotate && this._zoomAnimated) { + var anchor = this._getAnchor(); + var pos = L.DomUtil.getPosition(this._container).subtract(anchor); + L.DomUtil.setPosition(this._container, this._map.rotatedPointToMapPanePoint(pos).add(anchor)); + } + + }, + + }); + + /** + * @external L.Popup + * + * @see https://github.com/Leaflet/Leaflet/tree/v1.9.3/src/layer/Popup.js + */ + + const popupProto = L.extend({}, L.Popup.prototype); + + L.Popup.include({ + + /** + * 0. update element anchor point (popupProto v1.9.3) + * 1. rotate around anchor point (subtract anchor -> rotate point -> add anchor) + */ + _animateZoom: function(e) { + popupProto._animateZoom.apply(this, arguments); + if (this._map && this._map._rotate) { + var anchor = this._getAnchor(); + var pos = L.DomUtil.getPosition(this._container).subtract(anchor); + L.DomUtil.setPosition(this._container, this._map.rotatedPointToMapPanePoint(pos).add(anchor)); + } + }, + + /** + * Fix for L.popup({ keepInView = true }) + * + * @see https://github.com/fnicollet/Leaflet/pull/21 + */ + _adjustPan: function() { + if (!this.options.autoPan || (this._map._panAnim && this._map._panAnim._inProgress)) { return; } + + // We can endlessly recurse if keepInView is set and the view resets. + // Let's guard against that by exiting early if we're responding to our own autopan. + if (this._autopanning) { + this._autopanning = false; + return; + } + + var map = this._map, + marginBottom = parseInt(L.DomUtil.getStyle(this._container, 'marginBottom'), 10) || 0, + containerHeight = this._container.offsetHeight + marginBottom, + containerWidth = this._containerWidth, + layerPos = new L.Point(this._containerLeft, -containerHeight - this._containerBottom); + + layerPos._add(L.DomUtil.getPosition(this._container)); + + /** @TODO use popupProto._adjustPan */ + // var containerPos = map.layerPointToContainerPoint(layerPos); + var containerPos = layerPos._add(this._map._getMapPanePos()), + padding = L.point(this.options.autoPanPadding), + paddingTL = L.point(this.options.autoPanPaddingTopLeft || padding), + paddingBR = L.point(this.options.autoPanPaddingBottomRight || padding), + size = map.getSize(), + dx = 0, + dy = 0; + + if (containerPos.x + containerWidth + paddingBR.x > size.x) { // right + dx = containerPos.x + containerWidth - size.x + paddingBR.x; + } + if (containerPos.x - dx - paddingTL.x < 0) { // left + dx = containerPos.x - paddingTL.x; + } + if (containerPos.y + containerHeight + paddingBR.y > size.y) { // bottom + dy = containerPos.y + containerHeight - size.y + paddingBR.y; + } + if (containerPos.y - dy - paddingTL.y < 0) { // top + dy = containerPos.y - paddingTL.y; + } + + // @namespace Map + // @section Popup events + // @event autopanstart: Event + // Fired when the map starts autopanning when opening a popup. + if (dx || dy) { + // Track that we're autopanning, as this function will be re-ran on moveend + if (this.options.keepInView) { + this._autopanning = true; + } + map + .fire('autopanstart') + .panBy([dx, dy]); + } + }, + + }); + + /** + * @external L.Tooltip + * + * @see https://github.com/Leaflet/Leaflet/tree/v1.9.3/src/layer/Tooltip.js + */ + + const tooltipProto = L.extend({}, L.Tooltip.prototype); + + L.Tooltip.include({ + + _animateZoom: function(e) { + if (!this._map._rotate) { + return tooltipProto._animateZoom.apply(this, arguments); + } + var pos = this._map._latLngToNewLayerPoint(this._latlng, e.zoom, e.center); + + pos = this._map.rotatedPointToMapPanePoint(pos); + this._setPosition(pos); + }, + + _updatePosition: function() { + if (!this._map._rotate) { + return tooltipProto._updatePosition.apply(this, arguments); + } + var pos = this._map.latLngToLayerPoint(this._latlng); + + pos = this._map.rotatedPointToMapPanePoint(pos); + this._setPosition(pos); + }, + + }); + + /** + * @external L.Icon + * + * @see https://github.com/Leaflet/Leaflet/tree/v1.9.3/src/layer/marker/Icon.js + */ + + L.extend({}, L.Icon.prototype); + + L.Icon.include({ + + _setIconStyles: function(img, name) { + var options = this.options; + var sizeOption = options[name + 'Size']; + + if (typeof sizeOption === 'number') { + sizeOption = [sizeOption, sizeOption]; + } + + var size = L.point(sizeOption), + anchor = L.point(name === 'shadow' && options.shadowAnchor || options.iconAnchor || + size && size.divideBy(2, true)); + + img.className = 'leaflet-marker-' + name + ' ' + (options.className || ''); + + if (anchor) { + img.style.marginLeft = (-anchor.x) + 'px'; + img.style.marginTop = (-anchor.y) + 'px'; + /** @TODO use iconProto._setIconStyles */ + img.style[L.DomUtil.TRANSFORM + "Origin"] = anchor.x + "px " + anchor.y + "px 0px"; + } + + if (size) { + img.style.width = size.x + 'px'; + img.style.height = size.y + 'px'; + } + }, + + }); + + /** + * @external L.Marker + * @external L.Handler.MarkerDrag + * + * @see https://github.com/Leaflet/Leaflet/tree/v1.9.3/src/layer/marker/Marker.js + * @see https://github.com/Leaflet/Leaflet/tree/v1.9.3/src/layer/marker/Marker.Drag.js + * @see https://github.com/Leaflet/Leaflet/tree/v1.9.3/src/dom/Draggable.js + */ + + const markerProto = L.extend({}, L.Marker.prototype); + + L.Marker.mergeOptions({ + + /** + * Rotation of this marker in rad + * + * @type {Number} + */ + rotation: 0, + + /** + * Rotate this marker when map rotates + * + * @type {Boolean} + */ + rotateWithView: false, + + /** + * Scale of the marker icon + * + * @type {Number} + */ + scale: undefined, + + }); + + var markerDragProto; // retrived at runtime (see below: L.Marker::_initInteraction()) + + var MarkerDrag = { + + // _onDragStart: function() { + // if (!this._marker._map._rotate) { + // return markerDragProto._onDragStart.apply(this, arguments); + // } + // this._draggable.updateMapBearing(this._marker._map._bearing); + // }, + + _onDrag: function(e) { + var marker = this._marker, + /** @TODO use markerDragProto._onDrag */ + rotated_marker = marker.options.rotation || marker.options.rotateWithView, + shadow = marker._shadow, + iconPos = L.DomUtil.getPosition(marker._icon); + + /** @TODO use markerDragProto._onDrag */ + // update shadow position + if (!rotated_marker && shadow) { + L.DomUtil.setPosition(shadow, iconPos); + } + + /** @TODO use markerDragProto._onDrag */ + if (marker._map._rotate) { + // Reverse calculation from mapPane coordinates to rotatePane coordinates + iconPos = marker._map.mapPanePointToRotatedPoint(iconPos); + } + var latlng = marker._map.layerPointToLatLng(iconPos); + + marker._latlng = latlng; + e.latlng = latlng; + e.oldLatLng = this._oldLatLng; + + /** @TODO use markerDragProto._onDrag */ + if (rotated_marker) marker.setLatLng(latlng); // use `setLatLng` to presisit rotation. low efficiency + else marker.fire('move', e); // `setLatLng` will trig 'move' event. we imitate here. + + // @event drag: Event + // Fired repeatedly while the user drags the marker. + marker + .fire('drag', e); + }, + + _onDragEnd: function(e) { + if (this._marker._map._rotate) { + this._marker.update(); + } + markerDragProto._onDragEnd.apply(this, arguments); + }, + + }; + + L.Marker.include({ + + /** + * Update L.Marker anchor position after the map + * is moved by calling `map.setBearing(theta)` + * + * @listens L.Map~rotate + */ + getEvents: function() { + return L.extend(markerProto.getEvents.apply(this, arguments), { rotate: this.update }); + }, + + _initInteraction: function() { + var ret = markerProto._initInteraction.apply(this, arguments); + if (this.dragging && this.dragging.enabled() && this._map && this._map._rotate) { + // L.Handler.MarkerDrag is used internally by L.Marker to make the markers draggable + markerDragProto = markerDragProto || Object.getPrototypeOf(this.dragging); + this.dragging.disable(); + Object.assign(this.dragging, { + // _onDragStart: MarkerDrag._onDragStart.bind(this.dragging), + _onDrag: MarkerDrag._onDrag.bind(this.dragging), + _onDragEnd: MarkerDrag._onDragEnd.bind(this.dragging), + }); + this.dragging.enable(); + } + return ret; + }, + + _setPos: function(pos) { + + /** @TODO use markerProto._setPos */ + if (this._map._rotate) { + pos = this._map.rotatedPointToMapPanePoint(pos); + } + + /** @TODO use markerProto._setPos */ + var bearing = this.options.rotation || 0; + if (this.options.rotateWithView) { + bearing += this._map._bearing; + } + + /** @TODO use markerProto._setPos */ + if (this._icon) { + L.DomUtil.setPosition(this._icon, pos, bearing, pos, this.options.scale); + } + + /** @TODO use markerProto._setPos */ + if (this._shadow) { + L.DomUtil.setPosition(this._shadow, pos, bearing, pos, this.options.scale); + } + + this._zIndex = pos.y + this.options.zIndexOffset; + + this._resetZIndex(); + }, + + // _updateZIndex: function(offset) { + // if (!this._map._rotate) { + // return markerProto._updateZIndex.apply(this, arguments); + // } + // this._icon.style.zIndex = Math.round(this._zIndex + offset); + // }, + + setRotation: function(rotation) { + this.options.rotation = rotation; + this.update(); + }, + + }); + + /** + * @external L.GridLayer + * + * @see https://github.com/Leaflet/Leaflet/tree/v1.9.3/src/layer/tile/GridLayer.js + */ + + const gridLayerProto = L.extend({}, L.GridLayer.prototype); + + L.GridLayer.include({ + + /** + * Redraw L.TileLayer bounds after the map is + * moved by just calling `map.setBearing(theta)` + * + * @listens L.Map~rotate + */ + getEvents: function() { + var events = gridLayerProto.getEvents.apply(this, arguments); + if (this._map._rotate && !this.options.updateWhenIdle) { + if (!this._onRotate) { + this._onRotate = L.Util.throttle(this._onMoveEnd, this.options.updateInterval, this); + } + events.rotate = this._onRotate; + } + return events; + }, + + _getTiledPixelBounds: function(center) { + if (!this._map._rotate) { + return gridLayerProto._getTiledPixelBounds.apply(this, arguments); + } + + return this._map._getNewPixelBounds(center, this._tileZoom); + }, + + }); + + /** + * @external L.Renderer + * + * @see https://github.com/Leaflet/Leaflet/tree/v1.9.3/src/layer/vector/Renderer.js + */ + + const rendererProto = L.extend({}, L.Renderer.prototype); + + L.Renderer.include({ + + /** + * Redraw L.Canvas and L.SVG renderer bounds after the + * map is moved by just calling `map.setBearing(theta)` + * + * @listens L.Map~rotate + */ + getEvents: function() { + return L.extend(rendererProto.getEvents.apply(this, arguments), { rotate: this._update }); + }, + + /** + * Fix for `map.flyTo()` when `false === map.options.zoomAnimation` + * + * @see https://github.com/Leaflet/Leaflet/pull/8794 + */ + onAdd: function() { + rendererProto.onAdd.apply(this, arguments); + if (L.version <= "1.9.3") { + // always keep transform-origin as 0 0 + this._container.classList.add('leaflet-zoom-animated'); + } + }, + + /** + * @FIXME layer drifts on `map.setZoom()` (eg. zoom during animation) + * + * the main cause seems to be related to `this._updateTransform(path._center, path._zoom))` + * and `this._topLeft = this._map.layerPointToLatLng(this._bounds.min);` + * + * @example + * map.setZoom(2); + * path._renderer._update(); + * path._renderer._updateTransform(path._renderer._center, path._renderer._zoom); + * + * @see https://github.com/Leaflet/Leaflet/pull/8794 + * @see https://github.com/Leaflet/Leaflet/pull/8103 + * @see https://github.com/Leaflet/Leaflet/issues/7466 + * + * @TODO rechek this changes from leaflet@v1.9.3 + * + * @see https://github.com/Leaflet/Leaflet/compare/v1.7.0...v1.9.3 + */ + _updateTransform: function(center, zoom) { + if (!this._map._rotate) { + return rendererProto._updateTransform.apply(this, arguments); + } + /** + * @FIXME see path._renderer._reset(); + */ + var scale = this._map.getZoomScale(zoom, this._zoom), + offset = this._map._latLngToNewLayerPoint(this._topLeft, zoom, center); + + L.DomUtil.setTransform(this._container, offset, scale); + + }, + + // getEvents() { + // const events = { + // viewreset: this._reset, + // zoom: this._onZoom, + // moveend: this._update, + // zoomend: this._onZoomEnd + // }; + // if (this._zoomAnimated) { + // events.zoomanim = this._onAnimZoom; + // } + // return events; + // }, + + // _onAnimZoom(ev) { + // this._updateTransform(ev.center, ev.zoom); + // }, + + // _onZoom() { + // this._updateTransform(this._map.getCenter(), this._map.getZoom()); + // }, + + // _onZoomEnd() { + // for (const id in this._layers) { + // this._layers[id]._project(); + // } + // }, + + // _reset() { + // this._update(); + // this._updateTransform(this._center, this._zoom); + + // for (const id in this._layers) { + // this._layers[id]._reset(); + // } + // }, + + // _updatePaths() { + // for (const id in this._layers) { + // this._layers[id]._update(); + // } + // }, + + _update: function() { + if (!this._map._rotate) { + return rendererProto._update.apply(this, arguments); + } + // Update pixel bounds of renderer container (for positioning/sizing/clipping later) + // Subclasses are responsible of firing the 'update' event. + this._bounds = this._map._getPaddedPixelBounds(this.options.padding); + this._topLeft = this._map.layerPointToLatLng(this._bounds.min); + this._center = this._map.getCenter(); + this._zoom = this._map.getZoom(); + }, + + }); + + /** + * @external L.Map + * + * @see https://github.com/Leaflet/Leaflet/blob/v1.9.3/src/map/Map.js + */ + + const mapProto = L.extend({}, L.Map.prototype); + + L.Map.mergeOptions({ rotate: false, bearing: 0, }); + + L.Map.include({ + + /** + * @param {(HTMLElement|String)} id html selector + * @param {Object} [options={}] leaflet map options + */ + initialize: function(id, options) { + if (options.rotate) { + this._rotate = true; + this._bearing = 0; + } + mapProto.initialize.apply(this, arguments); + if(this.options.rotate){ + this.setBearing(this.options.bearing); + } + }, + + /** + * Given a pixel coordinate relative to the map container, + * returns the corresponding pixel coordinate relative to + * the [origin pixel](#map-getpixelorigin). + * + * @param {L.Point} point pixel screen coordinates + * @returns {L.Point} transformed pixel point + */ + containerPointToLayerPoint: function(point) { + if (!this._rotate) { + return mapProto.containerPointToLayerPoint.apply(this, arguments); + } + return L.point(point) + .subtract(this._getMapPanePos()) + .rotateFrom(-this._bearing, this._getRotatePanePos()) + .subtract(this._getRotatePanePos()); + }, + + /** + * Given a pixel coordinate relative to the [origin pixel](#map-getpixelorigin), + * returns the corresponding pixel coordinate relative to the map container. + * + * @param {L.Point} point pixel screen coordinates + * @returns {L.Point} transformed pixel point + */ + layerPointToContainerPoint: function(point) { + if (!this._rotate) { + return mapProto.layerPointToContainerPoint.apply(this, arguments); + } + return L.point(point) + .add(this._getRotatePanePos()) + .rotateFrom(this._bearing, this._getRotatePanePos()) + .add(this._getMapPanePos()); + }, + + /** + * Converts a coordinate from the rotated pane reference system + * to the reference system of the not rotated map pane. + * + * (rotatePane) --> (mapPane) + * (rotatePane) --> (norotatePane) + * + * @param {L.Point} point pixel screen coordinates + * @returns {L.Point} + * + * @since leaflet-rotate (v0.1) + */ + rotatedPointToMapPanePoint: function(point) { + return L.point(point) + .rotate(this._bearing) + ._add(this._getRotatePanePos()); + }, + + /** + * Converts a coordinate from the not rotated map pane reference system + * to the reference system of the rotated pane. + * + * (mapPane) --> (rotatePane) + * (norotatePane) --> (rotatePane) + * + * @param {L.Point} point pixel screen coordinates + * + * @since leaflet-rotate (v0.1) + */ + mapPanePointToRotatedPoint: function(point) { + return L.point(point) + ._subtract(this._getRotatePanePos()) + .rotate(-this._bearing); + }, + + // latLngToLayerPoint: function (latlng) { + // var projectedPoint = this.project(L.latLng(latlng))._round(); + // return projectedPoint._subtract(this.getPixelOrigin()); + // }, + + // latLngToContainerPoint: function (latlng) { + // return this.layerPointToContainerPoint(this.latLngToLayerPoint(toLatLng(latlng))); + // }, + + /** + * Given latlng bounds, returns the bounds in projected pixel + * relative to the map container. + * + * @see https://github.com/ronikar/Leaflet/blob/5c480ef959b947c3beed7065425a5a36c486262b/src/map/Map.js#L1114-L1135 + * + * @param {L.LatLngBounds} bounds + * @returns {L.Bounds} + * + * @since leaflet-rotate (v0.2) + */ + mapBoundsToContainerBounds: function (bounds) { + if (!this._rotate && mapProto.mapBoundsToContainerBounds) { + return mapProto.mapBoundsToContainerBounds.apply(this, arguments); + } + + // const nw = this.latLngToContainerPoint(bounds.getNorthWest()), + // ne = this.latLngToContainerPoint(bounds.getNorthEast()), + // sw = this.latLngToContainerPoint(bounds.getSouthWest()), + // se = this.latLngToContainerPoint(bounds.getSouthEast()); + + // same as `this.latLngToContainerPoint(latlng)` but with floating point precision + const origin = this.getPixelOrigin(); + const nw = this.layerPointToContainerPoint(this.project(bounds.getNorthWest())._subtract(origin)), + ne = this.layerPointToContainerPoint(this.project(bounds.getNorthEast())._subtract(origin)), + sw = this.layerPointToContainerPoint(this.project(bounds.getSouthWest())._subtract(origin)), + se = this.layerPointToContainerPoint(this.project(bounds.getSouthEast())._subtract(origin)); + + return L.bounds([ + L.point(Math.min(nw.x, ne.x, se.x, sw.x), Math.min(nw.y, ne.y, se.y, sw.y)), // [ minX, minY ] + L.point(Math.max(nw.x, ne.x, se.x, sw.x), Math.max(nw.y, ne.y, se.y, sw.y)) // [ maxX, maxY ] + ]); + }, + + /** + * Returns geographical bounds visible in the current map view + * + * @TODO find out if map bounds calculated by `L.Map::getBounds()` + * function should match the `rotatePane` or `norotatePane` bounds + * + * @see https://github.com/fnicollet/Leaflet/issues/7 + * + * @returns {L.LatLngBounds} + */ + getBounds: function() { + if (!this._rotate) { + return mapProto.getBounds.apply(this, arguments); + } + + // SEE: https://github.com/fnicollet/Leaflet/pull/22 + // + // var bounds = this.getPixelBounds(), + // sw = this.unproject(bounds.getBottomLeft()), + // ne = this.unproject(bounds.getTopRight()); + // return new LatLngBounds(sw, ne); + // + + // LatLngBounds' constructor automatically + // extends the bounds to fit the passed points + var size = this.getSize(); + return new L.LatLngBounds([ + this.containerPointToLatLng([0, 0]), // topleft + this.containerPointToLatLng([size.x, 0]), // topright + this.containerPointToLatLng([size.x, size.y]), // bottomright + this.containerPointToLatLng([0, size.y]), // bottomleft + ]); + }, + + /** + * Returns the bounds of the current map view in projected pixel + * coordinates (sometimes useful in layer and overlay implementations). + * + * @TODO find out if map bounds calculated by `L.Map::getPixelBounds()` + * function should match the `rotatePane` or `norotatePane` bounds + * + * @see https://github.com/fnicollet/Leaflet/issues/7 + * + * @returns {L.Bounds} + */ + // getPixelBounds(center, zoom) { + // // const topLeftPoint = map.containerPointToLayerPoint(this._getTopLeftPoint()); + // const topLeftPoint = this._getTopLeftPoint(center, zoom); + // return new L.Bounds(topLeftPoint, topLeftPoint.add(this.getSize())); + // }, + + /** + * Change map rotation + * + * @param {number} theta map degrees + * + * @since leaflet-rotate (v0.1) + */ + setBearing: function(theta) { + if (!L.Browser.any3d || !this._rotate) { return; } + + var bearing = L.Util.wrapNum(theta, [0, 360]) * L.DomUtil.DEG_TO_RAD, + center = this._getPixelCenter(), + oldPos = this._getRotatePanePos().rotateFrom(-this._bearing, center), + newPos = oldPos.rotateFrom(bearing, center); + + // CSS transform + L.DomUtil.setPosition(this._rotatePane, oldPos, bearing, center); + + this._pivot = center; + this._bearing = bearing; + this._rotatePanePos = newPos; + + this.fire('rotate'); + }, + + /** + * Get current map rotation + * + * @returns {number} theta map degrees + * + * @since leaflet-rotate (v0.1) + */ + getBearing: function() { + return this._bearing * L.DomUtil.RAD_TO_DEG; + }, + + /** + * Creates a new [map pane](#map-pane) with the given name if it doesn't + * exist already, then returns it. The pane is created as a child of + * `container`, or as a child of the main map pane if not set. + * + * @param {String} name leaflet pane + * @param {HTMLElement} [container] parent element + * @returns {HTMLElement} pane container + */ + // createPane: function(name, container) { + // if (!this._rotate || name == 'mapPane') { + // return mapProto.createPane.apply(this, arguments); + // } + // // init "rotatePane" + // if (!this._rotatePane) { + // // this._pivot = this.getSize().divideBy(2); + // this._rotatePane = mapProto.createPane.call(this, 'rotatePane', this._mapPane); + // L.DomUtil.setPosition(this._rotatePane, new L.Point(0, 0), this._bearing, this._pivot); + // } + // return mapProto.createPane.call(this, name, container || this._rotatePane); + // }, + + /** + * Panes are DOM elements used to control the ordering of layers on + * the map. You can access panes with [`map.getPane`](#map-getpane) + * or [`map.getPanes`](#map-getpanes) methods. New panes can be created + * with the [`map.createPane`](#map-createpane) method. + * + * Every map has the following default panes that differ only in zIndex: + * + * - mapPane [HTMLElement = 'auto'] - Pane that contains all other map panes + * - tilePane [HTMLElement = 2] - Pane for tile layers + * - overlayPane [HTMLElement = 4] - Pane for overlays like polylines and polygons + * - shadowPane [HTMLElement = 5] - Pane for overlay shadows (e.g. marker shadows) + * - markerPane [HTMLElement = 6] - Pane for marker icons + * - tooltipPane [HTMLElement = 650] - Pane for tooltips. + * - popupPane [HTMLElement = 700] - Pane for popups. + */ + _initPanes: function() { + var panes = this._panes = {}; + this._paneRenderers = {}; + + this._mapPane = this.createPane('mapPane', this._container); + L.DomUtil.setPosition(this._mapPane, new L.Point(0, 0)); + + if (this._rotate) { + this._rotatePane = this.createPane('rotatePane', this._mapPane); + this._norotatePane = this.createPane('norotatePane', this._mapPane); + // rotatePane + this.createPane('tilePane', this._rotatePane); + this.createPane('overlayPane', this._rotatePane); + // norotatePane + this.createPane('shadowPane', this._norotatePane); + this.createPane('markerPane', this._norotatePane); + this.createPane('tooltipPane', this._norotatePane); + this.createPane('popupPane', this._norotatePane); + } else { + this.createPane('tilePane'); + this.createPane('overlayPane'); + this.createPane('shadowPane'); + this.createPane('markerPane'); + this.createPane('tooltipPane'); + this.createPane('popupPane'); + } + + if (!this.options.markerZoomAnimation) { + L.DomUtil.addClass(panes.markerPane, 'leaflet-zoom-hide'); + L.DomUtil.addClass(panes.shadowPane, 'leaflet-zoom-hide'); + } + }, + + /** + * Pans the map the minimum amount to make the `latlng` visible. Use + * padding options to fit the display to more restricted bounds. + * If `latlng` is already within the (optionally padded) display bounds, + * the map will not be panned. + * + * @see https://github.com/Raruto/leaflet-rotate/issues/18 + * + * @param {L.LatLng} latlng coordinates + * @param {Object} [options={}] padding options + * + * @returns {L.Map} current map instance + */ + panInside(latlng, options) { + if (!this._rotate || Math.abs(this._bearing).toFixed(1) < 0.1) { + return mapProto.panInside.apply(this, arguments); + } + + options = options || {}; + + const paddingTL = L.point(options.paddingTopLeft || options.padding || [0, 0]), + paddingBR = L.point(options.paddingBottomRight || options.padding || [0, 0]), + /** @TODO use mapProto.panInside */ + // pixelPoint = this.project(latlng), + // pixelBounds = this.getPixelBounds(), + // pixelCenter = this.project(this.getCenter()), + rect = this._container.getBoundingClientRect(), + pixelPoint = this.latLngToContainerPoint(latlng), + pixelBounds = L.bounds([ L.point(rect), L.point(rect).add(this.getSize()) ]), + pixelCenter = pixelBounds.getCenter(), + // + paddedBounds = L.bounds([pixelBounds.min.add(paddingTL), pixelBounds.max.subtract(paddingBR)]), + paddedSize = paddedBounds.getSize(); + + if (!paddedBounds.contains(pixelPoint)) { + this._enforcingBounds = true; + const centerOffset = pixelPoint.subtract(paddedBounds.getCenter()); + const offset = paddedBounds.extend(pixelPoint).getSize().subtract(paddedSize); + pixelCenter.x += centerOffset.x < 0 ? -offset.x : offset.x; + pixelCenter.y += centerOffset.y < 0 ? -offset.y : offset.y; + /** @TODO use mapProto.panInside */ + // this.panTo(this.unproject(pixelCenter), options); + this.panTo(this.containerPointToLatLng(pixelCenter), options); + // + this._enforcingBounds = false; + } + return this; + }, + + /** + * Pans the map to the closest view that would lie inside the given bounds + * (if it's not already), controlling the animation using the options specific, + * if any. + * + * @TODO check if map bounds calculated by `L.Map::panInsideBounds()` + * function should match the `rotatePane` or `norotatePane` bounds + * + * @see https://github.com/fnicollet/Leaflet/issues/7 + * + * @param {L.LatLngBounds} bounds coordinates + * @param {Object} [options] pan options + * @returns {L.Map} current map instance + */ + // panInsideBounds: function (bounds, options) { + // this._enforcingBounds = true; + // var center = this.getCenter(), + // newCenter = this._limitCenter(center, this._zoom, L.latLngBounds(bounds)); + // + // if (!center.equals(newCenter)) { + // this.panTo(newCenter, options); + // } + // + // this._enforcingBounds = false; + // return this; + // }, + + // adjust center for view to get inside bounds + // _limitCenter(center, zoom, bounds) { + // + // if (!bounds) { return center; } + // + // const centerPoint = this.project(center, zoom), + // viewHalf = this.getSize().divideBy(2), + // viewBounds = new Bounds(centerPoint.subtract(viewHalf), centerPoint.add(viewHalf)), + // offset = this._getBoundsOffset(viewBounds, bounds, zoom); + // + // // If offset is less than a pixel, ignore. + // // This prevents unstable projections from getting into + // // an infinite loop of tiny offsets. + // if (Math.abs(offset.x) <= 1 && Math.abs(offset.y) <= 1) { + // return center; + // } + // + // return this.unproject(centerPoint.add(offset), zoom); + // }, + + // @method flyToBounds(bounds: LatLngBounds, options?: fitBounds options): this + // Sets the view of the map with a smooth animation like [`flyTo`](#map-flyto), + // but takes a bounds parameter like [`fitBounds`](#map-fitbounds). + // flyToBounds(bounds, options) { + // const target = this._getBoundsCenterZoom(bounds, options); + // return this.flyTo(target.center, target.zoom, options); + // }, + + // _getBoundsCenterZoom(bounds, options) { + // + // options = options || {}; + // bounds = bounds.getBounds ? bounds.getBounds() : toLatLngBounds(bounds); + // + // const paddingTL = L.point(options.paddingTopLeft || options.padding || [0, 0]), + // paddingBR = L.point(options.paddingBottomRight || options.padding || [0, 0]); + // + // let zoom = this.getBoundsZoom(bounds, false, paddingTL.add(paddingBR)); + // + // zoom = (typeof options.maxZoom === 'number') ? Math.min(options.maxZoom, zoom) : zoom; + // + // if (zoom === Infinity) { + // return { center: bounds.getCenter(), zoom }; + // } + // + // return { center, zoom }; + // + // }, + + /** + * Returns the maximum zoom level on which the given bounds fit to the map + * view in its entirety. If `inside` (optional) is set to `true`, the method + * instead returns the minimum zoom level on which the map view fits into + * the given bounds in its entirety. + * + * @param {L.LatLngBounds} bounds + * @param {Boolean} [inside=false] + * @param {L.Point} [padding=[0,0]] + * + * @returns {Number} zoom level + */ + getBoundsZoom(bounds, inside, padding) { + if (!this._rotate || Math.abs(this._bearing).toFixed(1) < 0.1) { + return mapProto.getBoundsZoom.apply(this, arguments); + } + + bounds = L.latLngBounds(bounds); + padding = L.point(padding || [0, 0]); + + let zoom = this.getZoom() || 0; + const min = this.getMinZoom(), + max = this.getMaxZoom(), + /** @TODO use mapProto.getBoundsZoom */ + // nw = bounds.getNorthWest(), + // se = bounds.getSouthEast(), + // size = this.getSize().subtract(padding), + // boundsSize = L.bounds(this.project(se, zoom), this.project(nw, zoom)).getSize(), + size = this.getSize().subtract(padding), + boundsSize = this.mapBoundsToContainerBounds(bounds).getSize(), + snap = this.options.zoomSnap, + scalex = size.x / boundsSize.x, + scaley = size.y / boundsSize.y, + scale = inside ? Math.max(scalex, scaley) : Math.min(scalex, scaley); + + zoom = this.getScaleZoom(scale, zoom); + + if (snap) { + zoom = Math.round(zoom / (snap / 100)) * (snap / 100); // don't jump if within 1% of a snap level + zoom = inside ? Math.ceil(zoom / snap) * snap : Math.floor(zoom / snap) * snap; + } + + return Math.max(min, Math.min(max, zoom)); + }, + + /** + * Layer point of the current center + * + * @returns {L.Point} layer center + */ + // _getCenterLayerPoint: function () { + // return this.containerPointToLayerPoint(this.getSize()._divideBy(2)); + // }, + + /** + * Offset of the specified place to the current center in pixels + * + * @param {L.LatLng} latlng map coordinates + */ + _getCenterOffset: function(latlng) { + var centerOffset = mapProto._getCenterOffset.apply(this, arguments); + if (this._rotate) { + centerOffset = centerOffset.rotate(this._bearing); + } + return centerOffset; + }, + + /** + * @since leaflet-rotate (v0.1) + */ + _getRotatePanePos: function() { + return this._rotatePanePos || new L.Point(0, 0); + // return L.DomUtil.getPosition(this._rotatePane) || new L.Point(0, 0); + }, + + // _latLngToNewLayerPoint(latlng, zoom, center) { + // const topLeft = this._getNewPixelOrigin(center, zoom); + // return this.project(latlng, zoom)._subtract(topLeft); + //}, + + _getNewPixelOrigin: function(center, zoom) { + if (!this._rotate) { + return mapProto._getNewPixelOrigin.apply(this, arguments); + } + var viewHalf = this.getSize()._divideBy(2); + return this.project(center, zoom) + .rotate(this._bearing) + ._subtract(viewHalf) + ._add(this._getMapPanePos()) + ._add(this._getRotatePanePos()) + .rotate(-this._bearing) + ._round(); + }, + + /** + * @since leaflet-rotate (v0.2) + * + * @see src\layer\tile\GridLayer::_getTiledPixelBounds() + */ + _getNewPixelBounds: function(center, zoom) { + center = center || this.getCenter(); + zoom = zoom || this.getZoom(); + if (!this._rotate && mapProto._getNewPixelBounds) { + return mapProto._getNewPixelBounds.apply(this, arguments); + } + var mapZoom = this._animatingZoom ? Math.max(this._animateToZoom, this.getZoom()) : this.getZoom(), + scale = this.getZoomScale(mapZoom, zoom), + pixelCenter = this.project(center, zoom).floor(), + size = this.getSize(), + halfSize = new L.Bounds([ + this.containerPointToLayerPoint([0, 0]).floor(), + this.containerPointToLayerPoint([size.x, 0]).floor(), + this.containerPointToLayerPoint([0, size.y]).floor(), + this.containerPointToLayerPoint([size.x, size.y]).floor() + ]).getSize().divideBy(scale * 2); + + return new L.Bounds(pixelCenter.subtract(halfSize), pixelCenter.add(halfSize)); + }, + + /** + * @since leaflet-rotate (v0.2) + * + * @return {L.Point} map pivot point (center) + */ + _getPixelCenter: function() { + if (!this._rotate && mapProto._getPixelCenter) { + return mapProto._getPixelCenter.apply(this, arguments); + } + return this.getSize()._divideBy(2)._subtract(this._getMapPanePos()); + }, + + /** + * @since leaflet-rotate (v0.2) + * + * @see src\layer\vector\Renderer::_update() + */ + _getPaddedPixelBounds: function(padding) { + if (!this._rotate && mapProto._getPaddedPixelBounds) { + return mapProto._getPaddedPixelBounds.apply(this, arguments); + } + var p = padding, + size = this.getSize(), + padMin = size.multiplyBy(-p), + padMax = size.multiplyBy(1 + p); + //min = this.containerPointToLayerPoint(size.multiplyBy(-p)).round(); + + return new L.Bounds([ + this.containerPointToLayerPoint([padMin.x, padMin.y]).floor(), + this.containerPointToLayerPoint([padMin.x, padMax.y]).floor(), + this.containerPointToLayerPoint([padMax.x, padMin.y]).floor(), + this.containerPointToLayerPoint([padMax.x, padMax.y]).floor() + ]); + }, + + _handleGeolocationResponse: function(pos) { + if (!this._container._leaflet_id) { return; } + + var lat = pos.coords.latitude, + lng = pos.coords.longitude, + /** @TODO use mapProto._handleGeolocationResponse */ + hdg = pos.coords.heading, + latlng = new L.LatLng(lat, lng), + bounds = latlng.toBounds(pos.coords.accuracy), + options = this._locateOptions; + + if (options.setView) { + var zoom = this.getBoundsZoom(bounds); + this.setView(latlng, options.maxZoom ? Math.min(zoom, options.maxZoom) : zoom); + } + + var data = { + latlng: latlng, + bounds: bounds, + timestamp: pos.timestamp, + /** @TODO use mapProto._handleGeolocationResponse */ + heading: hdg + }; + + for (var i in pos.coords) { + if (typeof pos.coords[i] === 'number') { + data[i] = pos.coords[i]; + } + } + + // @event locationfound: LocationEvent + // Fired when geolocation (using the [`locate`](#map-locate) method) + // went successfully. + this.fire('locationfound', data); + }, + + /** + * @see https://github.com/ronikar/Leaflet/blob/5c480ef959b947c3beed7065425a5a36c486262b/src/geo/LatLngBounds.js#L253-L264 + * + * @param {L.Bounds} points + * @returns {L.Bounds} + */ + // toCircumscribedBounds(points) { + // var minX = points.reduce(function (pv, v) { return Math.min(pv, v.x); }, points[0].x), + // maxX = points.reduce(function (pv, v) { return Math.max(pv, v.x); }, points[0].x), + // minY = points.reduce(function (pv, v) { return Math.min(pv, v.y); }, points[0].y), + // maxY = points.reduce(function (pv, v) { return Math.max(pv, v.y); }, points[0].y); + // + // return L.bounds(L.point(minX, minY), L.point(maxX, maxY)); + // }, + + }); + + /** + * Rotates the map according to a smartphone's compass. + * + * @typedef L.Map.CompassBearing + */ + + L.Map.CompassBearing = L.Handler.extend({ + + initialize: function(map) { + this._map = map; + /** @see https://caniuse.com/?search=DeviceOrientation */ + if ('ondeviceorientationabsolute' in window) { + this.__deviceOrientationEvent = 'deviceorientationabsolute'; + } else if('ondeviceorientation' in window) { + this.__deviceOrientationEvent = 'deviceorientation'; + } + this._throttled = L.Util.throttle(this._onDeviceOrientation, 100, this); + }, + + addHooks: function() { + if (this._map._rotate && this.__deviceOrientationEvent) { + L.DomEvent.on(window, this.__deviceOrientationEvent, this._throttled, this); + } else { + // L.Map.CompassBearing handler will be automatically + // disabled if device orientation is not supported. + this.disable(); + } + }, + + removeHooks: function() { + if (this._map._rotate && this.__deviceOrientationEvent) { + L.DomEvent.off(window, this.__deviceOrientationEvent, this._throttled, this); + } + }, + + /** + * `DeviceOrientationEvent.absolute` - Indicates whether the device is providing absolute + * orientation values (relatives to Magnetic North) or + * using some arbitrary frame determined by the device. + * + * `DeviceOrientationEvent.alpha` - Returns the rotation of the device around the Z axis; + * that is, the number of degrees by which the device is + * being twisted around the center of the screen. + * + * `window.orientation` - Returns the screen orientation in degrees (in 90-degree increments) + * of the viewport relative to the device's natural orientation. + * Its only possible values are -90, 0, 90, and 180. Positive + * values are counterclockwise; negative values are clockwise. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/DeviceOrientationEvent/absolute + * @see https://developer.mozilla.org/en-US/docs/Web/API/DeviceOrientationEvent/alpha + * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/orientation + */ + _onDeviceOrientation: function(e) { + var angle = e.webkitCompassHeading || e.alpha; + var deviceOrientation = 0; + + // Safari iOS + if (!e.absolute && e.webkitCompassHeading) { + angle = 360 - angle; + } + + // Older browsers + if (!e.absolute && 'undefined' !== typeof window.orientation) { + deviceOrientation = window.orientation; + } + + this._map.setBearing(angle - deviceOrientation); + }, + + }); + + /** + * Add Compass bearing handler to L.Map (disabled unless `window.DeviceOrientationEvent` is set). + * + * @property {L.Map.CompassBearing} compassBearing + */ + L.Map.addInitHook('addHandler', 'compassBearing', L.Map.CompassBearing); + + /** + * Triggers `invalidateResize` when the map's DOM container mutates. + * + * @typedef L.Map.ContainerMutation + */ + + /** + * @TODO check again this file after leaflet v1.9.3 (eg. L.Browser.mutation). + * Mutation Observer support will likely be added by default in next releases. + */ + + L.Map.mergeOptions({ + + /** + * Whether the map uses mutation observers to + * detect changes in its container and trigger + * `invalidateSize`. Disabled by default due to + * support not being available in all web browsers. + * + * @type {Boolean} + * + * @see https://developer.mozilla.org/docs/Web/API/MutationObserver + */ + trackContainerMutation: false + + }); + + L.Map.ContainerMutation = L.Handler.extend({ + + addHooks: function() { + // if (!L.Browser.mutation) { return; } + if (!this._observer) { + this._observer = new MutationObserver(L.Util.bind(this._map.invalidateSize, this._map)); + } + this._observer.observe(this._map.getContainer(), { + childList: false, + attributes: true, + characterData: false, + subtree: false, + attributeFilter: ['style'] + }); + }, + + removeHooks: function() { + // if (!L.Browser.mutation) { return; } + this._observer.disconnect(); + }, + + }); + + /** + * Add Container mutation handler to L.Map (disabled unless `trackContainerMutation` is set). + * + * @property {L.Map.ContainerMutation} trackContainerMutation + */ + L.Map.addInitHook('addHandler', 'trackContainerMutation', L.Map.ContainerMutation); + + /** + * TouchGestures is both TouchZoom plus TouchRotate + * + * @see https://github.com/fnicollet/Leaflet/commit/a77af51a6b10f308d1b9a16552091d1d0aee8834 + * @see https://github.com/Leaflet/Leaflet/blob/v1.9.3/src/map/handler/Map.TouchZoom.js + * + * @typedef L.Map.TouchGestures + */ + + L.Map.mergeOptions({ + + /** + * Set it to false if you don't want the map to + * zoom beyond min/max zoom and then bounce back + * when pinch-zooming. + * + * @type {Boolean} + */ + bounceAtZoomLimits: true, + + }); + + L.Map.TouchGestures = L.Handler.extend({ + + initialize: function(map) { + this._map = map; + this.rotate = !!this._map.options.touchRotate; + this.zoom = !!this._map.options.touchZoom; + }, + + addHooks: function() { + L.DomEvent.on(this._map._container, 'touchstart', this._onTouchStart, this); + }, + + removeHooks: function() { + L.DomEvent.off(this._map._container, 'touchstart', this._onTouchStart, this); + }, + + _onTouchStart: function(e) { + var map = this._map; + + if (!e.touches || e.touches.length !== 2 || map._animatingZoom || this._zooming || this._rotating) { return; } + + var p1 = map.mouseEventToContainerPoint(e.touches[0]), + p2 = map.mouseEventToContainerPoint(e.touches[1]), + vector = p1.subtract(p2); + + this._centerPoint = map.getSize()._divideBy(2); + this._startLatLng = map.containerPointToLatLng(this._centerPoint); + + if (this.zoom) { + if (map.options.touchZoom !== 'center') { + this._pinchStartLatLng = map.containerPointToLatLng(p1.add(p2)._divideBy(2)); + } + this._startDist = p1.distanceTo(p2); + this._startZoom = map.getZoom(); + this._zooming = true; + } else { + this._zooming = false; + } + + if (this.rotate) { + this._startTheta = Math.atan(vector.x / vector.y); + this._startBearing = map.getBearing(); + if (vector.y < 0) { this._startBearing += 180; } + this._rotating = true; + } else { + this._rotating = false; + } + + this._moved = false; + + map._stop(); + + L.DomEvent + .on(document, 'touchmove', this._onTouchMove, this) + .on(document, 'touchend touchcancel', this._onTouchEnd, this); + + L.DomEvent.preventDefault(e); + }, + + _onTouchMove: function(e) { + if (!e.touches || e.touches.length !== 2 || !(this._zooming || this._rotating)) { return; } + + var map = this._map, + p1 = map.mouseEventToContainerPoint(e.touches[0]), + p2 = map.mouseEventToContainerPoint(e.touches[1]), + vector = p1.subtract(p2), + scale = p1.distanceTo(p2) / this._startDist, + delta; + + if (this._rotating) { + var theta = Math.atan(vector.x / vector.y); + var bearingDelta = (theta - this._startTheta) * L.DomUtil.RAD_TO_DEG; + if (vector.y < 0) { bearingDelta += 180; } + if (bearingDelta) { + /** + * @TODO the pivot should be the last touch point, + * but zoomAnimation manages to overwrite the rotate + * pane position. Maybe related to #3529. + * + * @see https://github.com/Leaflet/Leaflet/pull/3529 + * @see https://github.com/fnicollet/Leaflet/commit/a77af51a6b10f308d1b9a16552091d1d0aee8834 + */ + map.setBearing(this._startBearing - bearingDelta); + } + } + + if (this._zooming) { + this._zoom = map.getScaleZoom(scale, this._startZoom); + + if (!map.options.bounceAtZoomLimits && ( + (this._zoom < map.getMinZoom() && scale < 1) || + (this._zoom > map.getMaxZoom() && scale > 1))) { + this._zoom = map._limitZoom(this._zoom); + } + + if (map.options.touchZoom === 'center') { + this._center = this._startLatLng; + if (scale === 1) { return; } + } else { + // Get delta from pinch to center, so centerLatLng is delta applied to initial pinchLatLng + delta = p1._add(p2)._divideBy(2)._subtract(this._centerPoint); + if (scale === 1 && delta.x === 0 && delta.y === 0) { return; } + + var alpha = -map.getBearing() * L.DomUtil.DEG_TO_RAD; + + this._center = map.unproject(map.project(this._pinchStartLatLng).subtract(delta.rotate(alpha))); + } + + } + + if (!this._moved) { + map._moveStart(true, false); + this._moved = true; + } + + L.Util.cancelAnimFrame(this._animRequest); + + var moveFn = map._move.bind(map, this._center, this._zoom, { pinch: true, round: false }, undefined); + this._animRequest = L.Util.requestAnimFrame(moveFn, this, true); + + L.DomEvent.preventDefault(e); + }, + + _onTouchEnd: function() { + if (!this._moved || !(this._zooming || this._rotating)) { + this._zooming = false; + return; + } + + this._zooming = false; + this._rotating = false; + L.Util.cancelAnimFrame(this._animRequest); + + L.DomEvent + .off(document, 'touchmove', this._onTouchMove, this) + .off(document, 'touchend touchcancel', this._onTouchEnd, this); + + if (this.zoom) { + // Pinch updates GridLayers' levels only when zoomSnap is off, so zoomSnap becomes noUpdate. + if (this._map.options.zoomAnimation) { + this._map._animateZoom(this._center, this._map._limitZoom(this._zoom), true, this._map.options.zoomSnap); + } else { + this._map._resetView(this._center, this._map._limitZoom(this._zoom)); + } + } + }, + + }); + + /** + * Add Touch Gestures handler (enabled unless `touchGestures` is unset). + * + * @property {L.Map.TouchGestures} touchGestures + */ + L.Map.addInitHook('addHandler', 'touchGestures', L.Map.TouchGestures); + + /** + * Rotates the map on two-finger (touch devices). + * + * @typedef L.Map.TouchRotate + */ + + L.Map.mergeOptions({ + + /** + * Whether the map can be rotated with a two-finger rotation gesture + * + * @type {Boolean} + */ + touchRotate: false, + + }); + + L.Map.TouchRotate = L.Handler.extend({ + + addHooks: function() { + this._map.touchGestures.enable(); + this._map.touchGestures.rotate = true; + }, + + removeHooks: function() { + this._map.touchGestures.rotate = false; + }, + + }); + + /** + * Add Touch Rotate handler (disabled unless `touchGestures` is set). + * + * @property {L.Map.TouchGestures} touchGestures + */ + L.Map.addInitHook('addHandler', 'touchRotate', L.Map.TouchRotate); + + /** + * Rotates the map on shift key + mousewheel scrolling (desktop). + * + * @typedef L.Map.ShiftKeyRotate + */ + + L.Map.mergeOptions({ + + /** + * Whether the map can be rotated with shift + wheel scroll + * @type {Boolean} + */ + shiftKeyRotate: true, + + }); + + L.Map.ShiftKeyRotate = L.Handler.extend({ + + addHooks: function() { + L.DomEvent.on(this._map._container, "wheel", this._handleShiftScroll, this); + // this._map.shiftKeyRotate.enable(); + this._map.shiftKeyRotate.rotate = true; + }, + + removeHooks: function() { + L.DomEvent.off(this._map._container, "wheel", this._handleShiftScroll, this); + this._map.shiftKeyRotate.rotate = false; + }, + + _handleShiftScroll: function(e) { + if (e.shiftKey) { + e.preventDefault(); + this._map.scrollWheelZoom.disable(); + this._map.setBearing((this._map._bearing * L.DomUtil.RAD_TO_DEG) + Math.sign(e.deltaY) * 5); + } else { + this._map.scrollWheelZoom.enable(); + } + }, + + }); + + /** + * Add ShiftKey handler to L.Map (enabled unless `shiftKeyRotate` is unset). + * + * @property {L.Map.ShiftKeyRotate} shiftKeyRotate + */ + L.Map.addInitHook('addHandler', 'shiftKeyRotate', L.Map.ShiftKeyRotate); + + // decrease `scrollWheelZoom` handler priority over `shiftKeyRotate` handler + L.Map.addInitHook(function() { + if (this.scrollWheelZoom.enabled() && this.shiftKeyRotate.enabled()) { + this.scrollWheelZoom.disable(); + this.scrollWheelZoom.enable(); + } + }); + + /** + * Adds pinch zoom rotation on mobile browsers + * + * @see https://github.com/Leaflet/Leaflet/blob/v1.9.3/src/map/handler/Map.TouchZoom.js + * + * @external L.Map.TouchZoom + */ + + L.Map.mergeOptions({ + + /** + * Whether the map can be zoomed by touch-dragging + * with two fingers. If passed `'center'`, it will + * zoom to the center of the view regardless of + * where the touch events (fingers) were. Enabled + * for touch-capable web browsers. + * + * @type {(Boolean|String)} + */ + touchZoom: L.Browser.touch, + + /** + * @TODO check if this is a duplicate of `L.Map.TouchGestures::bounceAtZoomLimits` + * + * Set it to false if you don't want the map to + * zoom beyond min/max zoom and then bounce back + * when pinch-zooming. + * + * @type {Boolean} + */ + bounceAtZoomLimits: false, + + }); + + L.Map.TouchZoom = L.Handler.extend({ + + addHooks: function() { + L.DomUtil.addClass(this._map._container, 'leaflet-touch-zoom'); + this._map.touchGestures.enable(); + this._map.touchGestures.zoom = true; + }, + + removeHooks: function() { + L.DomUtil.removeClass(this._map._container, 'leaflet-touch-zoom'); + this._map.touchGestures.zoom = false; + }, + + }); + + /** + * Add Touch Zoom handler (disabled unless `L.Browser.touch` is set). + * + * @property {L.Map.TouchGestures} touchGestures + */ + L.Map.addInitHook('addHandler', 'touchZoom', L.Map.TouchZoom); + + /** + * A tri-state control for map rotation, states are: + * + * - Locked (default) + * - Unlocked (user can pinch-rotate) + * - Follow (rotation follows device orientation, if available) + * + * @typedef L.Control.Rotate + */ + + L.Control.Rotate = L.Control.extend({ + + options: { + position: 'topleft', + closeOnZeroBearing: true + }, + + onAdd: function(map) { + var container = this._container = L.DomUtil.create('div', 'leaflet-control-rotate leaflet-bar'); + + // this.button = L.Control.Zoom.prototype._createButton.call(this, 'R', 'leaflet-control-rotate', 'leaflet-control-rotate', container, this._toggleLock); + + var arrow = this._arrow = L.DomUtil.create('span', 'leaflet-control-rotate-arrow'); + + arrow.style.backgroundImage = `url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E")`; + arrow.style.cursor = 'grab'; + arrow.style.display = 'block'; + arrow.style.width = '100%'; + arrow.style.height = '100%'; + arrow.style.backgroundRepeat = 'no-repeat'; + arrow.style.backgroundPosition = '50%'; + + // Copy-pasted from L.Control.Zoom + var link = this._link = L.DomUtil.create('a', 'leaflet-control-rotate-toggle', container); + link.appendChild(arrow); + link.href = '#'; + link.title = 'Rotate map'; + + L.DomEvent + .on(link, 'dblclick', L.DomEvent.stopPropagation) + .on(link, 'mousedown', this._handleMouseDown, this) + .on(link, 'click', L.DomEvent.stop) + .on(link, 'click', this._cycleState, this) + .on(link, 'click', this._refocusOnMap, this); + + if (!L.Browser.any3d) { + L.DomUtil.addClass(link, 'leaflet-disabled'); + } + + this._restyle(); + + map.on('rotate', this._restyle, this); + + // State flag + this._follow = false; + this._canFollow = false; + + if (this.options.closeOnZeroBearing && map.getBearing() === 0) { + container.style.display = 'none'; + } + + return container; + }, + + onRemove: function(map) { + map.off('rotate', this._restyle, this); + }, + + _handleMouseDown: function(e) { + L.DomEvent.stop(e); + this.dragging = true; + this.dragstartX = e.pageX; + this.dragstartY = e.pageY; + L.DomEvent + .on(document, 'mousemove', this._handleMouseDrag, this) + .on(document, 'mouseup', this._handleMouseUp, this); + }, + + _handleMouseUp: function(e) { + L.DomEvent.stop(e); + this.dragging = false; + + L.DomEvent + .off(document, 'mousemove', this._handleMouseDrag, this) + .off(document, 'mouseup', this._handleMouseUp, this); + }, + + _handleMouseDrag: function(e) { + if (!this.dragging) { return; } + var deltaX = e.clientX - this.dragstartX; + this._map.setBearing(deltaX); + }, + + _cycleState: function(ev) { + if (!this._map) { + return; + } + + var map = this._map; + + // Touch mode + if (!map.touchRotate.enabled() && !map.compassBearing.enabled()) { + map.touchRotate.enable(); + } + + // Compass mode + else if (!map.compassBearing.enabled()) { + map.touchRotate.disable(); + map.compassBearing.enable(); + } + + // Locked mode + else { + map.compassBearing.disable(); + map.setBearing(0); + if (this.options.closeOnZeroBearing) { + map.touchRotate.enable(); + } + } + this._restyle(); + }, + + _restyle: function() { + if (!this._map.options.rotate) { + L.DomUtil.addClass(this._link, 'leaflet-disabled'); + } else { + var map = this._map; + var bearing = map.getBearing(); + + this._arrow.style.transform = 'rotate(' + bearing + 'deg)'; + + if (bearing && this.options.closeOnZeroBearing) { + this._container.style.display = 'block'; + } + + // Compass mode + if (map.compassBearing.enabled()) { + this._link.style.backgroundColor = 'orange'; + } + + // Touch mode + else if (map.touchRotate.enabled()) { + this._link.style.backgroundColor = null; + } + + // Locked mode + else { + this._link.style.backgroundColor = 'grey'; + if (0 === bearing && this.options.closeOnZeroBearing) { + this._container.style.display = 'none'; + } + } + } + }, + + }); + + L.control.rotate = function(options) { + return new L.Control.Rotate(options); + }; + + L.Map.mergeOptions({ + rotateControl: true, + }); + + L.Map.addInitHook(function() { + if (this.options.rotateControl) { + var options = typeof this.options.rotateControl === 'object' ? this.options.rotateControl : {}; + this.rotateControl = L.control.rotate(options); + this.addControl(this.rotateControl); + } + }); + +})); +//# sourceMappingURL=leaflet-rotate-src.js.map diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f7c6bac..26e84ef 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.244" +version = "0.1.262" dependencies = [ "btleplug", "lazy_static", @@ -151,6 +151,7 @@ dependencies = [ "tauri-plugin-sql", "tokio", "tokio-stream", + "ureq", "uuid", ] @@ -3198,6 +3199,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rkyv" version = "0.7.46" @@ -3306,6 +3321,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -4893,6 +4943,41 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4924,6 +5009,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5198,6 +5289,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -5447,6 +5547,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c4ea30a..9ed4f75 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.244" +version = "0.1.262" edition = "2021" [lib] @@ -18,6 +18,7 @@ serde_json = "1" rusqlite = { version = "0.31", features = ["bundled"] } lazy_static = "1" +ureq = "3" [target.'cfg(not(target_os = "android"))'.dependencies] btleplug = { version = "0.11", features = ["serde"] } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ca69eb8..8f1b9a1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.244", + "version": "0.1.262", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend" @@ -9,7 +9,7 @@ "app": { "withGlobalTauri": true, "security": { - "csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' https://4aiman.top https://*.tile.openstreetmap.org https://*.tile.thunderforest.com https://api.open-meteo.com; img-src 'self' blob: data: https://*.tile.openstreetmap.org https://*.tile.thunderforest.com" + "csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' https://4aiman.top https://*.tile.openstreetmap.org https://*.tile.thunderforest.com https://api.open-meteo.com http://192.168.1.1:88; img-src 'self' blob: data: https://*.tile.openstreetmap.org https://*.tile.thunderforest.com" }, "windows": [ {