Skip to content

Commit 93ac8e6

Browse files
committed
map: add map tile buffer to prevent unrendered tiles on rotation and drag
Signed-off-by: Arturo Manzoli <[email protected]>
1 parent 331e965 commit 93ac8e6

2 files changed

Lines changed: 145 additions & 10 deletions

File tree

src/components/widgets/Map.vue

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
:class="widgetStore.editingMode ? 'pointer-events-none' : 'pointer-events-auto'"
77
:style="glassMenuCssVars"
88
>
9-
<div :id="mapId" ref="map" class="map">
9+
<div class="map-clip-wrapper">
10+
<div :id="mapId" ref="map" class="map" />
11+
</div>
12+
<!-- Map UI buttons live outside the oversized map div so they stay within the visible widget bounds -->
13+
<div class="map-buttons-overlay">
1014
<v-menu v-model="downloadMenuOpen" :close-on-content-click="false" location="top end">
1115
<template #activator="{ props: menuProps }">
1216
<v-tooltip location="top" text="Download tiles for offline use">
@@ -447,11 +451,15 @@ onBeforeMount(() => {
447451
targetFollower.enableAutoUpdate()
448452
})
449453
454+
// Extra tile buffer to keep around the viewport so rotated views don't show white gaps
455+
const rotationTileBuffer = 10
456+
450457
// Configure the available map tile providers
451458
const osm = tileLayerOffline('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
452459
maxZoom: 23,
453460
maxNativeZoom: 19,
454461
attribution: '© OpenStreetMap',
462+
keepBuffer: rotationTileBuffer,
455463
})
456464
457465
const esri = tileLayerOffline(
@@ -460,13 +468,15 @@ const esri = tileLayerOffline(
460468
maxZoom: 23,
461469
maxNativeZoom: 19,
462470
attribution: '© Esri World Imagery',
471+
keepBuffer: rotationTileBuffer,
463472
}
464473
)
465474
466475
// Overlays
467476
const seamarks = tileLayerOffline('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
468477
maxZoom: 18,
469478
attribution: '© OpenSeaMap contributors',
479+
keepBuffer: rotationTileBuffer,
470480
})
471481
472482
const marineProfile = L.tileLayer.wms('https://geoserver.openseamap.org/geoserver/gwc/service/wms', {
@@ -477,6 +487,7 @@ const marineProfile = L.tileLayer.wms('https://geoserver.openseamap.org/geoserve
477487
attribution: '© GEBCO, OpenSeaMap',
478488
tileSize: 256,
479489
maxZoom: 19,
490+
keepBuffer: rotationTileBuffer,
480491
})
481492
482493
const baseMaps = {
@@ -648,6 +659,38 @@ const loadLeafletRotate = async (): Promise<void> => {
648659
const script = document.createElement('script')
649660
script.textContent = (mod as unknown as Record<string, string>).default
650661
document.head.appendChild(script)
662+
663+
const ControlRotate = (
664+
L.Control as unknown as Record<
665+
string,
666+
{
667+
/**
668+
* Rotate control prototype
669+
*/
670+
prototype: Record<string, CallableFunction>
671+
}
672+
>
673+
).Rotate
674+
if (ControlRotate) {
675+
ControlRotate.prototype._handleMouseDown = function (e: MouseEvent): void {
676+
L.DomEvent.stop(e)
677+
this.dragging = true
678+
this.dragstartX = e.pageX
679+
this.dragstartY = e.pageY
680+
this._startBearing = this._map.getBearing()
681+
L.DomEvent.on(document as unknown as HTMLElement, 'mousemove', this._handleMouseDrag, this).on(
682+
document as unknown as HTMLElement,
683+
'mouseup',
684+
this._handleMouseUp,
685+
this
686+
)
687+
}
688+
ControlRotate.prototype._handleMouseDrag = function (e: MouseEvent): void {
689+
if (!this.dragging) return
690+
const deltaX = e.clientX - this.dragstartX
691+
this._map.setBearing((this._startBearing || 0) + deltaX)
692+
}
693+
}
651694
}
652695
653696
onMounted(async () => {
@@ -2022,11 +2065,45 @@ watch(
20222065
justify-content: center;
20232066
}
20242067
2068+
/* The clip wrapper is the visual boundary of the map widget.
2069+
The actual map element is made larger so Leaflet loads extra tiles
2070+
that fill in the corners when the map is rotated via leaflet-rotate. */
2071+
.map-clip-wrapper {
2072+
position: absolute;
2073+
inset: 0;
2074+
z-index: 0;
2075+
overflow: hidden;
2076+
}
2077+
2078+
.map-buttons-overlay {
2079+
position: absolute;
2080+
inset: 0;
2081+
z-index: 1;
2082+
pointer-events: none;
2083+
}
2084+
2085+
.map-buttons-overlay > :deep(*) {
2086+
pointer-events: auto;
2087+
}
2088+
20252089
.map {
20262090
position: absolute;
20272091
z-index: 0;
2028-
height: 100%;
2029-
width: 100%;
2092+
/* ~42% larger covers the worst-case diagonal at 45° rotation (sqrt(2) ≈ 1.414) */
2093+
width: 150%;
2094+
height: 150%;
2095+
top: -25%;
2096+
left: -25%;
2097+
}
2098+
2099+
/* Reposition Leaflet's control container to match the visible (clipped) area.
2100+
Since the map div is 150% of the widget, the visible region starts at 1/6 ≈ 16.67% from each edge. */
2101+
:deep(.leaflet-control-container) {
2102+
position: absolute !important;
2103+
top: 16.67%;
2104+
left: 16.67%;
2105+
right: 16.67%;
2106+
bottom: 16.67%;
20302107
}
20312108
20322109
.waypoint-marker-icon {

src/views/MissionPlanningView.vue

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<template>
22
<div class="mission-planning" :style="glassMenuCssVars">
3-
<div id="planningMap" ref="planningMap" class="relative" />
3+
<div class="map-clip-wrapper">
4+
<div id="planningMap" ref="planningMap" class="planning-map" />
5+
</div>
46
<v-tooltip location="top" text="Generate waypoints">
57
<template #activator="{ props }">
68
<div
@@ -3108,34 +3110,67 @@ const attachOfflineProgress = (layer: any, layerName: string): void => {
31083110
})
31093111
}
31103112
3111-
let leafletRotateLoaded = false
3112-
31133113
/**
31143114
* Loads the leaflet-rotate plugin by injecting its source into a script tag.
31153115
* This ensures the IIFE runs in the global scope where it can access window.L.
3116+
* Skips loading if the plugin is already available on L.control.
31163117
* @returns {Promise<void>} Resolves when the plugin has been loaded and executed
31173118
*/
31183119
const loadLeafletRotate = async (): Promise<void> => {
3119-
if (leafletRotateLoaded) return
3120+
if ((L.control as unknown as Record<string, unknown>).rotate) return
31203121
const mod = await import('leaflet-rotate/dist/leaflet-rotate.js?raw')
31213122
const script = document.createElement('script')
31223123
script.textContent = (mod as unknown as Record<string, string>).default
31233124
document.head.appendChild(script)
3124-
leafletRotateLoaded = true
3125+
3126+
const ControlRotate = (
3127+
L.Control as unknown as Record<
3128+
string,
3129+
{
3130+
/**
3131+
* Rotate control prototype
3132+
*/
3133+
prototype: Record<string, CallableFunction>
3134+
}
3135+
>
3136+
).Rotate
3137+
if (ControlRotate) {
3138+
ControlRotate.prototype._handleMouseDown = function (e: MouseEvent): void {
3139+
L.DomEvent.stop(e)
3140+
this.dragging = true
3141+
this.dragstartX = e.pageX
3142+
this.dragstartY = e.pageY
3143+
this._startBearing = this._map.getBearing()
3144+
L.DomEvent.on(document as unknown as HTMLElement, 'mousemove', this._handleMouseDrag, this).on(
3145+
document as unknown as HTMLElement,
3146+
'mouseup',
3147+
this._handleMouseUp,
3148+
this
3149+
)
3150+
}
3151+
ControlRotate.prototype._handleMouseDrag = function (e: MouseEvent): void {
3152+
if (!this.dragging) return
3153+
const deltaX = e.clientX - this.dragstartX
3154+
this._map.setBearing((this._startBearing || 0) + deltaX)
3155+
}
3156+
}
31253157
}
31263158
31273159
onMounted(async () => {
3160+
const rotationTileBuffer = 10
31283161
const osm = tileLayerOffline('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
31293162
maxZoom: 23,
31303163
maxNativeZoom: 19,
31313164
attribution: '© OpenStreetMap',
3165+
keepBuffer: rotationTileBuffer,
31323166
})
31333167
const esri = tileLayerOffline(
31343168
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
31353169
{
31363170
maxZoom: 23,
31373171
maxNativeZoom: 19,
31383172
attribution: '© Esri World Imagery',
3173+
keepBuffer: rotationTileBuffer,
31393174
}
31403175
)
31413176
@@ -3737,15 +3772,38 @@ watch(
37373772
#planningMap {
37383773
position: absolute;
37393774
z-index: 0;
3740-
height: 100%;
3741-
width: 100%;
3775+
width: 150%;
3776+
height: 150%;
3777+
top: -25%;
3778+
left: -25%;
3779+
}
3780+
3781+
/* Reposition Leaflet's control container to match the visible (clipped) area.
3782+
Since the map div is 150% of the widget, the visible region starts at 1/6 ≈ 16.67% from each edge. */
3783+
#planningMap .leaflet-control-container {
3784+
position: absolute !important;
3785+
top: 16.67%;
3786+
left: 16.67%;
3787+
right: 16.67%;
3788+
bottom: 16.67%;
37423789
}
37433790
.mission-planning {
37443791
min-height: 100vh;
37453792
display: flex;
37463793
align-items: center;
37473794
justify-content: center;
37483795
}
3796+
3797+
/* The clip wrapper is the visual boundary of the map.
3798+
The actual map element is made larger so Leaflet loads extra tiles
3799+
that fill in the corners when the map is rotated via leaflet-rotate. */
3800+
.map-clip-wrapper {
3801+
position: absolute;
3802+
inset: 0;
3803+
z-index: 0;
3804+
overflow: hidden;
3805+
}
3806+
37493807
.waypoint-marker-icon {
37503808
background: none;
37513809
border: none;

0 commit comments

Comments
 (0)