diff --git a/.gitignore b/.gitignore index a1f91c14..58b012d5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,7 @@ debug/ release/ # artefacts for Visual Studio Code -/.vscode/ \ No newline at end of file +/.vscode/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* \ No newline at end of file diff --git a/css/main.css b/css/main.css index 0dbcb47d..3cf288e5 100644 --- a/css/main.css +++ b/css/main.css @@ -491,6 +491,7 @@ html.has-log .log-graph-config { #craftCanvas, #analyserCanvas, +#mapContainer, #stickCanvas { position:absolute; top:0; @@ -501,6 +502,7 @@ html.has-log .log-graph-config { html.has-craft #craftCanvas, html.has-analyser #analyserCanvas, +html.has-map #mapContainer, html.has-sticks #stickCanvas { display:block; } @@ -513,6 +515,19 @@ html.has-analyser-fullscreen.has-analyser .analyser input:not(.onlyFullScreenExc z-index: 10; } +/* This filters change the color of a black png image. For new colors check: https://codepen.io/sosuke/pen/Pjoqqp */ +.isBF #mapContainer .icon { + filter: invert(36%) sepia(28%) saturate(3957%) hue-rotate(28deg) brightness(93%) contrast(103%); +} + +.isCF #mapContainer .icon { + filter: invert(28%) sepia(100%) saturate(2050%) hue-rotate(134deg) brightness(100%) contrast(104%); +} + +.isINAV #mapContainer .icon { + filter: invert(14%) sepia(100%) saturate(4698%) hue-rotate(244deg) brightness(64%) contrast(130%); +} + .analyser:hover .non-shift #analyserResize { opacity: 1; height: auto; @@ -606,7 +621,7 @@ html.has-analyser-fullscreen.has-analyser .analyser input:not(.onlyFullScreenExc position: absolute; } -.analyser { +.analyser, .map-container { position: absolute; } @@ -774,6 +789,7 @@ html .view-analyser-fullscreen { html.has-analyser-sticks.isBF .view-analyser-sticks, html.has-analyser.isBF .view-analyser, +html.has-map.isBF .view-map, html.has-table.isBF .view-table, html.has-sticks.isBF .view-sticks, html.has-craft.isBF .view-craft, @@ -783,6 +799,7 @@ html:not(.video-hidden).isBF .view-video { html.has-analyser-sticks.isCF .view-analyser-sticks, html.has-analyser.isCF .view-analyser, +html.has-map.isCF .view-map, html.has-table.isCF .view-table, html.has-sticks.isCF .view-sticks, html.has-craft.isCF .view-craft, @@ -792,6 +809,7 @@ html:not(.video-hidden).isCF .view-video { html.has-analyser-sticks.isINAV .view-analyser-sticks, html.has-analyser.isINAV .view-analyser, +html.has-map.isINAV .view-map, html.has-table.isINAV .view-table, html.has-sticks.isINAV .view-sticks, html.has-craft.isINAV .view-craft, @@ -1205,6 +1223,11 @@ html:not(.has-log) #status-bar { display: none; } +html:not(.has-gps) .view-map, +html:not(.has-gps) .map-container { + display: none !important; +} + #status-bar .bookmark-1, #status-bar .bookmark-2, #status-bar .bookmark-3, diff --git a/images/markers/craft.png b/images/markers/craft.png new file mode 100644 index 00000000..9c7e5eb0 Binary files /dev/null and b/images/markers/craft.png differ diff --git a/images/markers/home.png b/images/markers/home.png new file mode 100644 index 00000000..8bc8f701 Binary files /dev/null and b/images/markers/home.png differ diff --git a/index.html b/index.html index da0bb93a..e6442d74 100644 --- a/index.html +++ b/index.html @@ -16,8 +16,13 @@ + + + + + @@ -252,6 +257,11 @@

View

A +
  • + + Map + +
  • @@ -285,6 +295,9 @@

    Overlay

    +
    @@ -490,6 +503,7 @@

    Workspace

    +
    @@ -2893,6 +2907,44 @@ +
    +
    +
    Map Settings
    +
    +
    +
    + +
    + + + + + + + +
    + + + + +

    %

    +
    + + +

    %

    +
    + + +

    %

    +
    +
    +
    Watermark Settings
    @@ -3112,6 +3164,7 @@ + diff --git a/js/flightlog.js b/js/flightlog.js index 43423a03..8eef811a 100644 --- a/js/flightlog.js +++ b/js/flightlog.js @@ -1059,6 +1059,10 @@ function FlightLog(logData) { return true; }; + + this.hasGpsData = function() { + return this.getStats()?.frame?.G ? true : false;; + }; } FlightLog.prototype.accRawToGs = function(value) { diff --git a/js/graph_map.js b/js/graph_map.js new file mode 100644 index 00000000..0fe7e047 --- /dev/null +++ b/js/graph_map.js @@ -0,0 +1,409 @@ +"use strict"; + +function MapGrapher() { + let userSettings, + myMap, + currentLogStartDateTime, + currentTime, + craftPosition, + groundCourse, + homePosition, + craftMarker, + homeMarker, + trailLayers = new Map(), + previousLogIndex, + latIndexAtFrame, + lngIndexAtFrame, + altitudeIndexAtFrame, + groundCourseIndexAtFrame, + flightLog; + + const coordinateDivider = 10000000; + const altitudeDivider = 10; + const grounCourseDivider = 10; + + const mapOptions = { + center: [0, 0], + zoom: 1, + }; + + const craftIcon = L.icon({ + iconUrl: "../images/markers/craft.png", + iconSize: [30, 30], + iconAnchor: [15, 15], + className: "icon", + }); + + const homeIcon = L.icon({ + iconUrl: "../images/markers/home.png", + iconSize: [40, 40], + iconAnchor: [20, 35], + className: "icon", + }); + + const polylineOptions = { + color: "#2db0e3", + opacity: 0.8, + smoothFactor: 1, + }; + + // flight trail colors + const colorTrailGradient = [ + { color: "#00ffe0bf" }, + { color: "#00ff8cbf" }, + { color: "#00ff02bf" }, + { color: "#75ff00bf" }, + { color: "#e5ff00bf" }, + { color: "#ffb100bf" }, + { color: "#ff4c00bf" }, + { color: "#ff1414" }, + ]; + + // debug circles can be used to aligh icons at the correct coordinates + const debugCircle = false; + const debugCircleOptions = { + color: "red", + fillColor: "red", + fillOpacity: 0.8, + radius: 1, + }; + + this.initialize = function () { + if (myMap) { + return; + } + + myMap = L.map("mapContainer", mapOptions); + + L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + minZoom: 1, + attribution: + '© OpenStreetMap', + }).addTo(myMap); + }; + + this.reset = function () { + if (!myMap) { + return; + } + this.clearMap(previousLogIndex); + previousLogIndex = null; + currentTime = null; + craftPosition = null; + groundCourse = null; + homePosition = null; + craftMarker = null; + homeMarker = null; + trailLayers = new Map(); + previousLogIndex = null; + latIndexAtFrame = null; + lngIndexAtFrame = null; + altitudeIndexAtFrame = null; + groundCourseIndexAtFrame = null; + myMap.setView(mapOptions.center, mapOptions.zoom); + }; + + this.setFlightLog = function (newFlightLog) { + flightLog = newFlightLog; + + const newLogStartDateTime = flightLog.getSysConfig()["Log start datetime"]; + if (currentLogStartDateTime != newLogStartDateTime) { + this.reset(); + currentLogStartDateTime = newLogStartDateTime; + } + + const logIndex = flightLog.getLogIndex(); + + // if this log is already proccesed its skipped + if (trailLayers.has(logIndex)) { + return; + } + + this.setFlightLogIndexs(); + + let { latlngs, maxAlt, minAlt } = this.getPolylinesData(); + + const polyline = L.polyline(latlngs, polylineOptions); + + const polylineC = this.createAltitudeColoredPolyline( + latlngs, + maxAlt, + minAlt + ); + + trailLayers.set(logIndex, { polyline, polylineC }); + + if (latlngs.length > 0) { + homePosition = this.getHomeCoordinatesFromFlightLog(flightLog); + } + }; + + this.setFlightLogIndexs = function () { + latIndexAtFrame = flightLog.getMainFieldIndexByName("GPS_coord[0]"); + lngIndexAtFrame = flightLog.getMainFieldIndexByName("GPS_coord[1]"); + altitudeIndexAtFrame = flightLog.getMainFieldIndexByName("GPS_altitude"); + groundCourseIndexAtFrame = + flightLog.getMainFieldIndexByName("GPS_ground_course"); + }; + + this.getPolylinesData = function () { + let latlngs = []; + let maxAlt = Number.MIN_VALUE; + let minAlt = Number.MAX_VALUE; + + const chunks = flightLog.getChunksInTimeRange( + flightLog.getMinTime(), + flightLog.getMaxTime() + ); + + for (const chunk of chunks) { + for (const fi in chunk.frames) { + const frame = chunk.frames[fi]; + const coordinates = this.getCoordinatesFromFrame( + frame, + latIndexAtFrame, + lngIndexAtFrame, + altitudeIndexAtFrame + ); + + // if there are no coordinates the frame is skipped + if (!coordinates) { + continue; + } + + // Altitude max and min values can be obtained from the stats but fixing the index at 4 doesn't seem safe + // const maxAlt = flightLog.getStats().frame.G.field[4].max / altitudeDivider; + // const minAlt = flightLog.getStats().frame.G.field[4].min / altitudeDivider; + maxAlt = coordinates.alt > maxAlt ? coordinates.alt : maxAlt; + minAlt = coordinates.alt < minAlt ? coordinates.alt : minAlt; + + // 1/4 of the dots is enough to draw the line + if (fi % 4 == 0) { + latlngs.push(coordinates); + } + } + } + return { latlngs, maxAlt, minAlt }; + }; + + this.createAltitudeColoredPolyline = function (latlngs, maxAlt, minAlt) { + const divider = colorTrailGradient.length - 1; + + const delta = maxAlt - minAlt; + + const thresholdIncrement = delta / divider; + + let altThresholds = []; + let threshold = minAlt; + for (let i = 0; i < divider; i++) { + //amount of colors - min and max that are set + threshold += thresholdIncrement; + altThresholds.push(threshold); + } + + return L.multiOptionsPolyline(latlngs, { + multiOptions: { + optionIdxFn: function (latLng) { + for (let i = 0; i < altThresholds.length; i++) { + if (latLng.alt <= altThresholds[i]) { + return i; + } + } + return altThresholds.length; + }, + options: colorTrailGradient, + }, + weight: 3, + lineCap: "butt", + opacity: 1, + smoothFactor: 1, + }); + }; + + this.updateCurrentPosition = function () { + try { + const frame = flightLog.getCurrentFrameAtTime(currentTime); + craftPosition = this.getCoordinatesFromFrame( + frame.current, + latIndexAtFrame, + lngIndexAtFrame, + altitudeIndexAtFrame + ); + groundCourse = this.getGroundCourseFromFrame( + frame.current, + groundCourseIndexAtFrame + ); + } catch (e) {} + }; + + this.setUserSettings = function (newUserSettings) { + userSettings = newUserSettings; + }; + + this.redrawAll = function () { + if (trailLayers.size <= 0 || !myMap) { + return; + } + + this.redrawFlightTrail(); + this.redrawHomeMarker(); + this.redrawCraftMarker(); + }; + + this.redrawFlightTrail = function () { + // If flightLog has changed redraw flight trail + const currentLogIndex = flightLog.getLogIndex(); + if (previousLogIndex != currentLogIndex) { + this.clearMap(previousLogIndex); + if (trailLayers.has(currentLogIndex)) { + const polyline = userSettings.mapTrailAltitudeColored + ? trailLayers.get(currentLogIndex).polylineC + : trailLayers.get(currentLogIndex).polyline; + polyline.addTo(myMap); + myMap.fitBounds(polyline.getBounds()); + } + + previousLogIndex = currentLogIndex; + } + }; + + this.redrawHomeMarker = function () { + if (homePosition) { + if (homeMarker) { + homeMarker.icon.setLatLng(homePosition).addTo(myMap); + + // debug circle + if (debugCircle) { + homeMarker.circle.setLatLng(homePosition).addTo(myMap); + } + } else { + homeMarker = {}; + + homeMarker.icon = L.marker(homePosition, { + icon: homeIcon, + }).addTo(myMap); + + // debug circle + if (debugCircle) { + homeMarker.circle = L.circle(homePosition, debugCircleOptions).addTo( + myMap + ); + } + } + } + }; + + this.redrawCraftMarker = function () { + if (craftPosition) { + if (craftMarker) { + craftMarker.icon.setLatLng(craftPosition); + craftMarker.icon.setRotationAngle(groundCourse).addTo(myMap); + // debug circle + if (debugCircle) { + homeMarker.circle.setLatLng(craftPosition).addTo(myMap); + } + } else { + craftMarker = {}; + craftMarker.icon = L.rotatedMarker(craftPosition, { + icon: craftIcon, + rotationAngle: groundCourse, + rotationOrigin: "center center", + }).addTo(myMap); + + // debug circle + if (debugCircle) { + craftMarker.circle = L.circle( + craftPosition, + debugCircleOptions + ).addTo(myMap); + } + } + } + }; + + this.clearMap = function (trailIndex) { + this.clearMapFlightTrails(trailIndex); + this.clearMapMarkers(); + }; + + this.clearMapFlightTrails = function (trailIndex) { + if (trailLayers.has(trailIndex)) { + const p = trailLayers.get(trailIndex).polyline; + const pc = trailLayers.get(trailIndex).polylineC; + if (p) { + myMap.removeLayer(p); + } + if (pc) { + myMap.removeLayer(pc); + } + } + }; + + this.clearMapMarkers = function () { + if (homeMarker) { + if (myMap.hasLayer(homeMarker.icon)) { + myMap.removeLayer(homeMarker.icon); + } + if (debugCircle && myMap.hasLayer(homeMarker.circle)) { + myMap.removeLayer(homeMarker.circle); + } + } + if (craftMarker) { + if (myMap.hasLayer(craftMarker.icon)) { + myMap.removeLayer(craftMarker.icon); + } + if (debugCircle && myMap.hasLayer(craftMarker.circle)) { + myMap.removeLayer(craftMarker.circle); + } + } + }; + + this.resize = function (width, height) { + if (!userSettings) { + return; + } + const containerstyle = { + height: (height * parseInt(userSettings.map.size)) / 100.0, + width: (width * parseInt(userSettings.map.size)) / 100.0, + left: (width * parseInt(userSettings.map.left)) / 100.0, + top: (height * parseInt(userSettings.map.top)) / 100.0, + }; + $("#mapContainer").css(containerstyle); + }; + + this.getCoordinatesFromFrame = function ( + frame, + latIndex, + lngIndex, + altitudeIndex + ) { + const lat = frame[latIndex]; + const lng = frame[lngIndex]; + const alt = frame[altitudeIndex]; + + return typeof lat == "number" || typeof lng == "number" + ? L.latLng( + lat / coordinateDivider, + lng / coordinateDivider, + alt / altitudeDivider + ) + : null; + }; + + this.getGroundCourseFromFrame = function (frame, groundCourseIndex) { + const gc = frame[groundCourseIndex]; + return typeof gc == "number" ? gc / grounCourseDivider : 0; + }; + + this.getHomeCoordinatesFromFlightLog = function (flightLog) { + const home = flightLog.getStats().frame.H.field; + return [home[0].min / coordinateDivider, home[1].min / coordinateDivider]; + }; + + this.setCurrentTime = function (newTime) { + currentTime = newTime; + this.updateCurrentPosition(); + this.redrawAll(); + }; +} diff --git a/js/main.js b/js/main.js index e6ca375d..b7a1793b 100644 --- a/js/main.js +++ b/js/main.js @@ -58,7 +58,6 @@ function BlackboxLogViewer() { // JSON graph configuration: graphConfig = {}, - offsetCache = [], // Storage for the offset cache (last 20 files) currentOffsetCache = {log:null, index:null, video:null, offset:null}, @@ -77,7 +76,7 @@ function BlackboxLogViewer() { fieldPresenter = FlightLogFieldPresenter, hasVideo = false, hasLog = false, hasMarker = false, // add measure feature - hasTable = true, hasAnalyser, hasAnalyserFullscreen, + hasTable = true, hasAnalyser, hasMap, hasAnalyserFullscreen, hasAnalyserSticks = false, viewVideo = true, hasTableOverlay = false, hadTable, hasConfig = false, hasConfigOverlay = false, @@ -112,9 +111,12 @@ function BlackboxLogViewer() { animationFrameIsQueued = false, playbackRate = PLAYBACK_DEFAULT_RATE, - + graphZoom = GRAPH_DEFAULT_ZOOM, - lastGraphZoom = GRAPH_DEFAULT_ZOOM; // QuickZoom function. + lastGraphZoom = GRAPH_DEFAULT_ZOOM, // QuickZoom function. + + mapGrapher = new MapGrapher(); + function createNewBlackboxWindow(fileToOpen) { @@ -269,6 +271,10 @@ function BlackboxLogViewer() { seekBar.setCurrentTime(currentBlackboxTime); seekBar.setWindow(graph.getWindowWidthTime()); + if (flightLog.hasGpsData()) { + mapGrapher.setCurrentTime(currentBlackboxTime); + } + updateValuesChartRateLimited(); if (graphState == GRAPH_STATE_PLAY) { @@ -300,6 +306,9 @@ function BlackboxLogViewer() { if (graph) { graph.resize(width, height); seekBar.resize(canvas.offsetWidth, 50); + if(flightLog.hasGpsData()) { + mapGrapher.resize(width, height); + } invalidateGraph(); } @@ -408,6 +417,14 @@ function BlackboxLogViewer() { seekBar.setActivity(activity.times, activity.avgThrottle, activity.hasEvent); seekBar.repaint(); + + // Add flightLog to map + html.toggleClass("has-gps", flightLog.hasGpsData()); + if(flightLog.hasGpsData()) { + mapGrapher.setUserSettings(userSettings); + mapGrapher.setFlightLog(flightLog); + } + } function setGraphState(newState) { @@ -1110,6 +1127,15 @@ function BlackboxLogViewer() { invalidateGraph(); }); + $(".view-map").click(function() { + hasMap = !hasMap; + html.toggleClass("has-map", hasMap); + prefs.set('hasMap', hasMap); + if(flightLog.hasGpsData()) { + mapGrapher.initialize(userSettings); + } + }); + $(".view-analyser-fullscreen").click(function() { if(hasAnalyser) { hasAnalyserFullscreen = !hasAnalyserFullscreen; @@ -1370,6 +1396,9 @@ function BlackboxLogViewer() { graph.refreshOptions(newSettings); graph.refreshLogo(); graph.initializeCraftModel(); + if(flightLog.hasGpsData()) { + mapGrapher.setUserSettings(newSettings); + } updateCanvasSize(); } diff --git a/js/user_settings_dialog.js b/js/user_settings_dialog.js index dffb0765..9f3aea00 100644 --- a/js/user_settings_dialog.js +++ b/js/user_settings_dialog.js @@ -66,7 +66,7 @@ function UserSettingsDialog(dialog, onLoad, onSave) { overdrawSpectrumType: 0, // By default, show all filters craft : { left : '15%', // position from left (as a percentage of width) - top : '25%', // position from top (as a percentage of height) + top : '48%', // position from top (as a percentage of height) size : '40%' // size (as a percentage of width) }, sticks : { @@ -75,10 +75,15 @@ function UserSettingsDialog(dialog, onLoad, onSave) { size : '30%' // size (as a percentage of width) }, analyser : { - left : '5%', // position from left (as a percentage of width) + left : '2%', // position from left (as a percentage of width) top : '60%', // position from top (as a percentage of height) size : '35%' // size (as a percentage of width) }, + map : { + left : '2%', // position from left (as a percentage of width) + top : '5%', // position from top (as a percentage of height) + size : '35%' // size (as a percentage of width) + }, watermark : { left : '3%', // position from left (as a percentage of width) top : '90%', // position from top (as a percentage of height) @@ -125,9 +130,12 @@ function UserSettingsDialog(dialog, onLoad, onSave) { craft: {top: $('.craft-settings input[name="craft-top"]').val() + '%', left: $('.craft-settings input[name="craft-left"]').val() + '%', size: $('.craft-settings input[name="craft-size"]').val() + '%', }, - analyser: {top: $('.analyser-settings input[name="analyser-top"]').val() + '%', + analyser: {top: $('.analyser-settings input[name="analyser-top"]').val() + '%', left: $('.analyser-settings input[name="analyser-left"]').val() + '%', size: $('.analyser-settings input[name="analyser-size"]').val() + '%', }, + map: {top: $('.map-settings input[name="map-top"]').val() + '%', + left: $('.map-settings input[name="map-left"]').val() + '%', + size: $('.map-settings input[name="map-size"]').val() + '%', }, watermark: {top: $('.watermark-settings input[name="watermark-top"]').val() + '%', left: $('.watermark-settings input[name="watermark-left"]').val() + '%', size: $('.watermark-settings input[name="watermark-size"]').val() + '%', @@ -298,6 +306,10 @@ function UserSettingsDialog(dialog, onLoad, onSave) { currentSettings.analyserHanning = $(this).is(":checked"); }); + $(".map-trail-altitude-colored").click(function() { + currentSettings.mapTrailAltitudeColored = $(this).is(":checked"); + }); + $(".legend-units").click(function() { currentSettings.legendUnits = $(this).is(":checked"); }); @@ -368,6 +380,11 @@ function UserSettingsDialog(dialog, onLoad, onSave) { $(".analyser-hanning").prop('checked', currentSettings.analyserHanning); } + if(currentSettings.mapTrailAltitudeColored!=null) { + // set the toggle switch + $(".map-trail-altitude-colored").prop('checked', currentSettings.mapTrailAltitudeColored); + } + if(currentSettings.legendUnits!=null) { // set the toggle switch $(".legend-units").prop('checked', currentSettings.legendUnits); @@ -390,6 +407,9 @@ function UserSettingsDialog(dialog, onLoad, onSave) { $('.analyser-settings input[name="analyser-top"]').val(parseInt(currentSettings.analyser.top)); $('.analyser-settings input[name="analyser-left"]').val(parseInt(currentSettings.analyser.left)); $('.analyser-settings input[name="analyser-size"]').val(parseInt(currentSettings.analyser.size)); + $('.map-settings input[name="map-top"]').val(parseInt(currentSettings.map.top)); + $('.map-settings input[name="map-left"]').val(parseInt(currentSettings.map.left)); + $('.map-settings input[name="map-size"]').val(parseInt(currentSettings.map.size)); if(currentSettings.drawWatermark!=null) { // set the toggle switch diff --git a/package.json b/package.json index 82620a8c..b988ad53 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,11 @@ "author": "The Betaflight open source project.", "license": "GPL-3.0", "dependencies": { + "Leaflet.MultiOptionsPolyline": "hgoebl/Leaflet.MultiOptionsPolyline", "bootstrap": "~3.4.1", "html2canvas": "^1.0.0-rc.5", + "leaflet": "^1.9.3", + "leaflet-marker-rotation": "^0.4.0", "lodash": "^4.17.21" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 1e77fdad..c4da4cc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -72,6 +72,10 @@ dependencies: "@types/node" "*" +Leaflet.MultiOptionsPolyline@hgoebl/Leaflet.MultiOptionsPolyline: + version "1.0.0" + resolved "https://codeload.github.com/hgoebl/Leaflet.MultiOptionsPolyline/tar.gz/ebd929f3f3c9f0eca9ef13d46ff04533aa1d3116" + aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" @@ -2494,6 +2498,16 @@ lead@^1.0.0: dependencies: flush-write-stream "^1.0.2" +leaflet-marker-rotation@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/leaflet-marker-rotation/-/leaflet-marker-rotation-0.4.0.tgz#c375db65e8e8f0ef7449c5d7ffb9d96d475e41de" + integrity sha512-7I+l/Ky3mP7LQ+nfVA+Gzjl5NOb02mph3v9SD2dPoCEQ/vV3zuZp+7Sewm2Pgk2zH9Aa4+mPqNTSSSLe3D7lmg== + +leaflet@^1.9.3: + version "1.9.3" + resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.3.tgz#52ec436954964e2d3d39e0d433da4b2500d74414" + integrity sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ== + liftoff@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/liftoff/-/liftoff-3.1.0.tgz#c9ba6081f908670607ee79062d700df062c52ed3"