diff --git a/frontend/src/features/map/defaultMapGeometry.test.ts b/frontend/src/features/map/defaultMapGeometry.test.ts index f88117b..c859971 100644 --- a/frontend/src/features/map/defaultMapGeometry.test.ts +++ b/frontend/src/features/map/defaultMapGeometry.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { + defaultCayoStyle, + defaultMainlandStyle, defaultMapPercentToWorld, defaultMapWorldToPercent, } from '@/features/map/defaultMapGeometry' @@ -13,4 +15,18 @@ describe('default map geometry', () => { expect(restored.x).toBeCloseTo(world.x, 6) expect(restored.y).toBeCloseTo(world.y, 6) }) + + it('keeps every rendered map layer inside the composite canvas', () => { + for (const style of [defaultMainlandStyle, defaultCayoStyle]) { + const left = Number.parseFloat(style.left) + const top = Number.parseFloat(style.top) + const width = Number.parseFloat(style.width) + const height = Number.parseFloat(style.height) + + expect(left).toBeGreaterThanOrEqual(0) + expect(top).toBeGreaterThanOrEqual(0) + expect(left + width).toBeLessThanOrEqual(100.000001) + expect(top + height).toBeLessThanOrEqual(100.000001) + } + }) }) diff --git a/frontend/src/features/map/defaultMapGeometry.ts b/frontend/src/features/map/defaultMapGeometry.ts index f103375..12618ed 100644 --- a/frontend/src/features/map/defaultMapGeometry.ts +++ b/frontend/src/features/map/defaultMapGeometry.ts @@ -15,19 +15,29 @@ const cayoMapCoordinates = { height: 1994.4, } -const cayoTerritoryBounds = { - maxX: 7542.07, - minY: -7170.07, +const cayoLayerBounds = { + minX: cayoMapCoordinates.centerX - cayoMapCoordinates.width / 2, + minY: + defaultMainlandCoordinates.yFlipOffset - + (cayoMapCoordinates.centerY + cayoMapCoordinates.height / 2), + width: cayoMapCoordinates.width, + height: cayoMapCoordinates.height, } +const mapMaximumX = Math.max( + defaultMainlandCoordinates.minX + defaultMainlandCoordinates.width, + cayoLayerBounds.minX + cayoLayerBounds.width, +) +const mapMaximumY = Math.max( + defaultMainlandCoordinates.minY + defaultMainlandCoordinates.height, + cayoLayerBounds.minY + cayoLayerBounds.height, +) + export const defaultMapCoordinates = { minX: defaultMainlandCoordinates.minX, minY: defaultMainlandCoordinates.minY, - width: cayoTerritoryBounds.maxX - defaultMainlandCoordinates.minX, - height: - defaultMainlandCoordinates.yFlipOffset - - cayoTerritoryBounds.minY - - defaultMainlandCoordinates.minY, + width: mapMaximumX - defaultMainlandCoordinates.minX, + height: mapMaximumY - defaultMainlandCoordinates.minY, yFlipOffset: defaultMainlandCoordinates.yFlipOffset, } @@ -47,12 +57,7 @@ export const defaultMainlandStyle = toDefaultMapLayerStyle( defaultMainlandCoordinates, ) export const defaultCayoStyle = toDefaultMapLayerStyle({ - minX: cayoMapCoordinates.centerX - cayoMapCoordinates.width / 2, - minY: - defaultMapCoordinates.yFlipOffset - - (cayoMapCoordinates.centerY + cayoMapCoordinates.height / 2), - width: cayoMapCoordinates.width, - height: cayoMapCoordinates.height, + ...cayoLayerBounds, }) export const clampDefaultMapPoint = (point: MapPoint): MapPoint => ({ diff --git a/frontend/src/features/map/mapViewport.test.ts b/frontend/src/features/map/mapViewport.test.ts new file mode 100644 index 0000000..d18fff2 --- /dev/null +++ b/frontend/src/features/map/mapViewport.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { + clampMapPan, + clientPointToMapPercent, + minimumCoverZoom, + zoomPanAtPoint, +} from '@/features/map/mapViewport' + +describe('map viewport geometry', () => { + const metrics = { + canvasHeight: 900, + canvasWidth: 600, + viewportHeight: 720, + viewportWidth: 360, + } + + it('raises the minimum zoom until the canvas covers the viewport', () => { + expect(minimumCoverZoom(metrics)).toBe(0.8) + }) + + it('clamps panning so no empty edge enters the viewport', () => { + expect(clampMapPan({ x: 999, y: -999 }, 1, metrics)).toEqual({ + x: 120, + y: -90, + }) + }) + + it('converts scaled client positions into bounded map percentages', () => { + const canvas = { height: 800, left: 100, top: 50, width: 400 } + + expect(clientPointToMapPercent({ x: 300, y: 250 }, canvas)).toEqual({ + x: 0.5, + y: 0.25, + }) + expect(clientPointToMapPercent({ x: 900, y: -100 }, canvas)).toEqual({ + x: 1, + y: 0, + }) + }) + + it('keeps the focal map point stationary while zooming', () => { + expect( + zoomPanAtPoint( + { x: 20, y: -10 }, + 1, + 2, + { x: 270, y: 180 }, + { x: 360, y: 720 }, + ), + ).toEqual({ x: -50, y: 160 }) + }) +}) diff --git a/frontend/src/features/map/mapViewport.ts b/frontend/src/features/map/mapViewport.ts new file mode 100644 index 0000000..68f33a5 --- /dev/null +++ b/frontend/src/features/map/mapViewport.ts @@ -0,0 +1,86 @@ +import type { MapPoint } from './defaultMapGeometry' + +export type MapViewportMetrics = { + canvasHeight: number + canvasWidth: number + viewportHeight: number + viewportWidth: number +} + +export type MapRect = { + height: number + left: number + top: number + width: number +} + +const clamp = (value: number, minimum: number, maximum: number): number => + Math.min(maximum, Math.max(minimum, value)) + +export function minimumCoverZoom( + metrics: MapViewportMetrics, + minimum = 0.7, +): number { + if ( + metrics.canvasWidth <= 0 || + metrics.canvasHeight <= 0 || + metrics.viewportWidth <= 0 || + metrics.viewportHeight <= 0 + ) { + return minimum + } + + return Math.max( + minimum, + metrics.viewportWidth / metrics.canvasWidth, + metrics.viewportHeight / metrics.canvasHeight, + ) +} + +export function clampMapPan( + pan: MapPoint, + zoom: number, + metrics: MapViewportMetrics, +): MapPoint { + const maximumX = Math.max( + 0, + (metrics.canvasWidth * zoom - metrics.viewportWidth) / 2, + ) + const maximumY = Math.max( + 0, + (metrics.canvasHeight * zoom - metrics.viewportHeight) / 2, + ) + + return { + x: clamp(pan.x, -maximumX, maximumX), + y: clamp(pan.y, -maximumY, maximumY), + } +} + +export function clientPointToMapPercent( + point: MapPoint, + canvas: MapRect, +): MapPoint { + if (canvas.width <= 0 || canvas.height <= 0) return { x: 0.5, y: 0.5 } + + return { + x: clamp((point.x - canvas.left) / canvas.width, 0, 1), + y: clamp((point.y - canvas.top) / canvas.height, 0, 1), + } +} + +export function zoomPanAtPoint( + pan: MapPoint, + currentZoom: number, + nextZoom: number, + focalPoint: MapPoint, + viewportSize: MapPoint, +): MapPoint { + if (currentZoom <= 0 || nextZoom <= 0) return pan + const scale = nextZoom / currentZoom + + return { + x: pan.x + (focalPoint.x - viewportSize.x / 2 - pan.x) * (1 - scale), + y: pan.y + (focalPoint.y - viewportSize.y / 2 - pan.y) * (1 - scale), + } +} diff --git a/frontend/src/views/apps/GarageApp.contract.test.ts b/frontend/src/views/apps/GarageApp.contract.test.ts index d0e737a..f369cc1 100644 --- a/frontend/src/views/apps/GarageApp.contract.test.ts +++ b/frontend/src/views/apps/GarageApp.contract.test.ts @@ -31,10 +31,19 @@ describe('GarageApp Sky UI contract', () => { expect(source).not.toContain('overview.system') }) - it('raises only the Garage title block', () => { + it('raises the Garage title and subtitle when no navbar action exists', () => { expect(source).toContain('class="garage-navbar"') expect(source).toMatch( - /\.garage-navbar :deep\(\.sky-navbar__title-container > div\)\s*\{[^}]*translateY\(-20px\)/s, + /\.garage-navbar :deep\(\.sky-navbar__title-container > div\)\s*\{[^}]*translateY\(-30px\)/s, + ) + }) + + it('centers filter labels and counters inside their segmented controls', () => { + expect(source).toMatch( + /\.garage-filters :deep\(button\)\s*\{[^}]*height: 36px;[^}]*align-items: center;[^}]*line-height: 1;/s, + ) + expect(source).toMatch( + /\.garage-filters span,\s*\.garage-filters small\s*\{[^}]*height: 18px;[^}]*align-items: center;[^}]*line-height: 1;/s, ) }) diff --git a/frontend/src/views/apps/GarageApp.vue b/frontend/src/views/apps/GarageApp.vue index e379a66..847c601 100644 --- a/frontend/src/views/apps/GarageApp.vue +++ b/frontend/src/views/apps/GarageApp.vue @@ -590,7 +590,7 @@ onBeforeUnmount(() => { --garage-separator: var(--sky-hairline); } .garage-navbar :deep(.sky-navbar__title-container > div) { - transform: translateY(-20px); + transform: translateY(-30px); } .garage-scroll { padding-top: 0; @@ -679,12 +679,12 @@ onBeforeUnmount(() => { height: 40px; min-height: 40px; margin-bottom: 13px; - padding: 0; + padding: 2px; } .garage-filters :deep(button) { min-width: 0; - height: 40px; - min-height: 40px; + height: 36px; + min-height: 36px; padding-right: 4px; padding-left: 4px; align-items: center; @@ -692,14 +692,23 @@ onBeforeUnmount(() => { gap: 4px; font-size: 11px; font-weight: 600; + line-height: 1; } .garage-filters :deep(.sky-segmented__highlight) { - top: 0; - bottom: 0; + top: 2px; + bottom: 2px; +} +.garage-filters span, +.garage-filters small { + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; } .garage-filters small { min-width: 17px; - padding: 1px 4px; + padding: 0 4px; border-radius: var(--sky-radius-pill); background: rgb(36 120 255 / 13%); font-size: 10px; diff --git a/frontend/src/views/apps/MapApp.contract.test.ts b/frontend/src/views/apps/MapApp.contract.test.ts new file mode 100644 index 0000000..ab60792 --- /dev/null +++ b/frontend/src/views/apps/MapApp.contract.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const source = readFileSync(new URL('./MapApp.vue', import.meta.url), 'utf8') + +describe('MapApp interaction contract', () => { + it('uses Sky UI controls and the central EasyShare store', () => { + expect(source).not.toContain("from 'konsta/vue'") + expect(source).toContain(' { + expect(source).toContain('minimumCoverZoom(metrics, baseMinZoom)') + expect(source).toContain('clampMapPan(nextPan, nextZoom, metrics)') + expect(source).toContain('zoomPanAtPoint(') + expect(source).not.toContain("width: 'max(120%, 120vh)'") + }) + + it('allows the marker crosshair to be placed by tapping the map', () => { + expect(source).toContain('placementPoint.value = viewportPoint({') + expect(source).toContain(':style="placementCrosshairStyle"') + expect(source).toContain('clientPointToMapPercent(') + }) +}) diff --git a/frontend/src/views/apps/MapApp.vue b/frontend/src/views/apps/MapApp.vue index aaaf209..40d18db 100644 --- a/frontend/src/views/apps/MapApp.vue +++ b/frontend/src/views/apps/MapApp.vue @@ -5,6 +5,7 @@ import { SkyList, SkyField, SkyAppPage, + SkyGlass, SkySpinner, SkySheet, SkyToast, @@ -32,6 +33,13 @@ import { defaultMapWorldToPercent, type MapPoint, } from '@/features/map/defaultMapGeometry' +import { + clampMapPan, + clientPointToMapPercent, + minimumCoverZoom, + zoomPanAtPoint, + type MapViewportMetrics, +} from '@/features/map/mapViewport' import { useMapStore } from '@/stores/map' import { useEasyShareStore } from '@/stores/easyshare' import { usePhoneStore } from '@/stores/phone' @@ -47,6 +55,7 @@ const easyShare = useEasyShareStore() const mapStyle = ref('default') const zoom = ref(1.1) const pan = ref({ x: 0, y: 0 }) +const viewportSize = ref({ x: 390, y: 844 }) const currentLocation = ref(null) const mapAspect = ref( defaultMapCoordinates.width / defaultMapCoordinates.height, @@ -55,10 +64,10 @@ const imageError = ref(false) const locating = ref(false) const viewportRef = ref(null) const canvasRef = ref(null) -const locationRef = ref(null) const isPointerDown = ref(false) const isPanning = ref(false) const placingMarker = ref(false) +const placementPoint = ref(null) const draftCoords = ref<(MapPoint & { z: number }) | null>(null) const markerLabel = ref('') const markerColor = ref('blue') @@ -68,8 +77,9 @@ const toastText = ref('') let pointerMoveFrame: number | undefined let wheelZoomFrame: number | undefined let toastTimer: number | undefined -let pendingWheelDirection: -1 | 0 | 1 = 0 +let pendingWheelDelta = 0 let pendingWheelPoint: MapPoint | undefined +let resizeObserver: ResizeObserver | undefined const pointerStart = { x: 0, @@ -86,8 +96,7 @@ const mapBounds = { } const mapOrigin = { x: -336.8, y: -1412.2 } const mapScale = { x: -2340 / -1548.1, y: 291 / 189.3 } -const zoomFactor = 1.35 -const minZoom = 0.7 +const baseMinZoom = 0.7 const maxZoom = 12.3 const mapStyles = [ @@ -120,10 +129,20 @@ const mapImageUrl = computed(() => { }) const cayoMapImageUrl = `${import.meta.env.BASE_URL}img/maps/cayo-perico.svg` +const canvasWidth = computed(() => + Math.max( + viewportSize.value.x * 1.2, + viewportSize.value.y * 1.2 * mapAspect.value, + ), +) const canvasStyle = computed(() => ({ aspectRatio: String(mapAspect.value), transform: `translate(-50%, -50%) translate(${pan.value.x}px, ${pan.value.y}px) scale(${zoom.value})`, - width: 'max(120%, 120vh)', + width: `${canvasWidth.value}px`, +})) +const placementCrosshairStyle = computed(() => ({ + left: `${placementPoint.value?.x ?? viewportSize.value.x / 2}px`, + top: `${placementPoint.value?.y ?? viewportSize.value.y / 2}px`, })) const mapToWorld = (coords: MapPoint): MapPoint => ({ @@ -216,6 +235,7 @@ function markerErrorText(error?: string): string { function setMapStyle(style: MapStyle): void { mapStyle.value = style imageError.value = false + void nextTick(normalizeViewport) } function cycleMapStyle(): void { @@ -229,37 +249,50 @@ function startMarkerPlacement(): void { selectedMarker.value = null draftCoords.value = null markerError.value = '' + placementPoint.value = { + x: viewportRef.value?.clientWidth + ? viewportRef.value.clientWidth / 2 + : viewportSize.value.x / 2, + y: viewportRef.value?.clientHeight + ? viewportRef.value.clientHeight / 2 + : viewportSize.value.y / 2, + } placingMarker.value = true } function cancelMarkerPlacement(): void { placingMarker.value = false + placementPoint.value = null } function openMarkerEditor(): void { - const viewport = viewportRef.value?.getBoundingClientRect() + const viewportElement = viewportRef.value + const viewport = viewportElement?.getBoundingClientRect() const canvas = canvasRef.value?.getBoundingClientRect() - if (!viewport || !canvas || canvas.width <= 0 || canvas.height <= 0) { + if ( + !viewportElement || + !viewport || + !canvas || + canvas.width <= 0 || + canvas.height <= 0 + ) { showToast(phone.t('Apps.map.errors.request_failed')) return } - const percent = { - x: Math.min( - 1, - Math.max( - 0, - (viewport.left + viewport.width / 2 - canvas.left) / canvas.width, - ), - ), - y: Math.min( - 1, - Math.max( - 0, - (viewport.top + viewport.height / 2 - canvas.top) / canvas.height, - ), - ), + const renderedScaleX = viewport.width / viewportElement.clientWidth + const renderedScaleY = viewport.height / viewportElement.clientHeight + const target = placementPoint.value ?? { + x: viewportElement.clientWidth / 2, + y: viewportElement.clientHeight / 2, } + const percent = clientPointToMapPercent( + { + x: viewport.left + target.x * renderedScaleX, + y: viewport.top + target.y * renderedScaleY, + }, + canvas, + ) const coords = percentToWorld(percent) draftCoords.value = { x: Math.round(coords.x * 100) / 100, @@ -270,6 +303,7 @@ function openMarkerEditor(): void { markerColor.value = 'blue' markerError.value = '' placingMarker.value = false + placementPoint.value = null } function updateMarkerLabel(event: Event): void { @@ -335,30 +369,69 @@ async function setSelectedMarkerWaypoint(): Promise { selectedMarker.value = null } -function changeZoom(direction: -1 | 1, focalPoint?: MapPoint): void { - const targetZoom = - direction > 0 ? zoom.value * zoomFactor : zoom.value / zoomFactor +function viewportMetrics(): MapViewportMetrics | null { + const viewport = viewportRef.value + const canvas = canvasRef.value + if (!viewport || !canvas) return null + + return { + canvasHeight: canvas.clientHeight, + canvasWidth: canvas.clientWidth, + viewportHeight: viewport.clientHeight, + viewportWidth: viewport.clientWidth, + } +} + +function normalizeViewport( + requestedPan: MapPoint = pan.value, + requestedZoom: number = zoom.value, +): void { + const metrics = viewportMetrics() + if (!metrics) return const nextZoom = Math.min( - Math.max(Math.round(targetZoom * 1000) / 1000, minZoom), + maxZoom, + Math.max(minimumCoverZoom(metrics, baseMinZoom), requestedZoom), + ) + zoom.value = Math.round(nextZoom * 1000) / 1000 + pan.value = clampMapPan(requestedPan, zoom.value, metrics) +} + +function viewportPoint(point: MapPoint): MapPoint | null { + const viewport = viewportRef.value + const bounds = viewport?.getBoundingClientRect() + if (!viewport || !bounds || bounds.width <= 0 || bounds.height <= 0) { + return null + } + + return { + x: (point.x - bounds.left) / (bounds.width / viewport.clientWidth), + y: (point.y - bounds.top) / (bounds.height / viewport.clientHeight), + } +} + +function changeZoom(targetZoom: number, focalClientPoint?: MapPoint): void { + const metrics = viewportMetrics() + if (!metrics) return + const nextZoom = Math.min( + Math.max( + Math.round(targetZoom * 1000) / 1000, + minimumCoverZoom(metrics, baseMinZoom), + ), maxZoom, ) if (nextZoom === zoom.value) return - if (focalPoint && canvasRef.value && viewportRef.value) { - const rect = canvasRef.value.getBoundingClientRect() - const viewport = viewportRef.value.getBoundingClientRect() - const renderedScaleX = viewport.width / viewportRef.value.clientWidth - const renderedScaleY = viewport.height / viewportRef.value.clientHeight - const offsetX = focalPoint.x - rect.left - rect.width / 2 - const offsetY = focalPoint.y - rect.top - rect.height / 2 - const scale = nextZoom / zoom.value - pan.value = { - x: pan.value.x - (offsetX * (scale - 1)) / renderedScaleX, - y: pan.value.y - (offsetY * (scale - 1)) / renderedScaleY, - } - } - + const focalPoint = focalClientPoint + ? viewportPoint(focalClientPoint) + : { x: metrics.viewportWidth / 2, y: metrics.viewportHeight / 2 } + const nextPan = focalPoint + ? zoomPanAtPoint(pan.value, zoom.value, nextZoom, focalPoint, { + x: metrics.viewportWidth, + y: metrics.viewportHeight, + }) + : pan.value zoom.value = nextZoom + pan.value = clampMapPan(nextPan, nextZoom, metrics) } function onPointerDown(event: PointerEvent): void { @@ -388,14 +461,22 @@ function onPointerMove(event: PointerEvent): void { if (!viewportElement || !viewport) return const renderedScaleX = viewport.width / viewportElement.clientWidth const renderedScaleY = viewport.height / viewportElement.clientHeight - pan.value = { + const metrics = viewportMetrics() + const nextPan = { x: pointerStart.panX + deltaX / renderedScaleX, y: pointerStart.panY + deltaY / renderedScaleY, } + pan.value = metrics ? clampMapPan(nextPan, zoom.value, metrics) : nextPan }) } -function onPointerUp(): void { +function onPointerUp(event: PointerEvent): void { + if (placingMarker.value && !isPanning.value) { + placementPoint.value = viewportPoint({ + x: event.clientX, + y: event.clientY, + }) + } isPointerDown.value = false isPanning.value = false } @@ -403,15 +484,19 @@ function onPointerUp(): void { function onWheel(event: WheelEvent): void { event.preventDefault() event.stopPropagation() - pendingWheelDirection = event.deltaY > 0 ? -1 : 1 + pendingWheelDelta += event.deltaY pendingWheelPoint = { x: event.clientX, y: event.clientY } if (wheelZoomFrame) return wheelZoomFrame = requestAnimationFrame(() => { wheelZoomFrame = undefined - if (pendingWheelDirection !== 0) { - changeZoom(pendingWheelDirection, pendingWheelPoint) + if (pendingWheelDelta !== 0) { + const normalizedDelta = Math.min(240, Math.max(-240, pendingWheelDelta)) + changeZoom( + zoom.value * Math.exp(-normalizedDelta * 0.0024), + pendingWheelPoint, + ) } - pendingWheelDirection = 0 + pendingWheelDelta = 0 pendingWheelPoint = undefined }) } @@ -423,6 +508,7 @@ function onImageLoad(event: Event): void { ? defaultMapCoordinates.width / defaultMapCoordinates.height : image.naturalWidth / image.naturalHeight imageError.value = false + void nextTick(normalizeViewport) } async function loadCurrentLocation(center: boolean): Promise { @@ -434,40 +520,53 @@ async function loadCurrentLocation(center: boolean): Promise { currentLocation.value = clampDefaultMapPoint(response.data.coords) if (!center) return - zoom.value = 3 - pan.value = { x: 0, y: 0 } await nextTick() - const viewportElement = viewportRef.value - const viewport = viewportElement?.getBoundingClientRect() - const location = locationRef.value?.getBoundingClientRect() - if (!viewportElement || !viewport || !location) return - const renderedScaleX = viewport.width / viewportElement.clientWidth - const renderedScaleY = viewport.height / viewportElement.clientHeight - pan.value = { - x: - (viewport.left + - viewport.width / 2 - - (location.left + location.width / 2)) / - renderedScaleX, - y: - (viewport.top + - viewport.height / 2 - - (location.top + location.height / 2)) / - renderedScaleY, - } + const metrics = viewportMetrics() + if (!metrics) return + const nextZoom = Math.max(3, minimumCoverZoom(metrics, baseMinZoom)) + const percent = worldToPercent(currentLocation.value) + normalizeViewport( + { + x: -(percent.x - 0.5) * metrics.canvasWidth * nextZoom, + y: -(percent.y - 0.5) * metrics.canvasHeight * nextZoom, + }, + nextZoom, + ) } function shareCurrentLocation(): void { + const location = currentLocation.value + if (!location) { + showToast(phone.t('Apps.map.locationUnavailable')) + return + } + const coordinates = `${location.x.toFixed(1)}, ${location.y.toFixed(1)}` easyShare.open({ appId: 'map', - copyText: phone.t('Apps.map.currentLocation'), + copyText: `${phone.t('Apps.map.currentLocation')}\n${coordinates}`, + id: `${location.x.toFixed(2)}:${location.y.toFixed(2)}`, kind: 'location', - link: 'skyphone://location/current', + link: `skyphone://location/${location.x.toFixed(2)}/${location.y.toFixed(2)}`, + meta: { coords: { ...location } }, + subtitle: coordinates, title: phone.t('Apps.map.currentLocation'), }) } onMounted(() => { + const viewport = viewportRef.value + if (viewport) { + const updateViewport = (): void => { + viewportSize.value = { + x: viewport.clientWidth, + y: viewport.clientHeight, + } + void nextTick(normalizeViewport) + } + resizeObserver = new ResizeObserver(updateViewport) + resizeObserver.observe(viewport) + updateViewport() + } void loadCurrentLocation(false) void mapStore.load() }) @@ -476,11 +575,18 @@ onBeforeUnmount(() => { if (pointerMoveFrame) cancelAnimationFrame(pointerMoveFrame) if (wheelZoomFrame) cancelAnimationFrame(wheelZoomFrame) if (toastTimer) window.clearTimeout(toastTimer) + resizeObserver?.disconnect() })