From 5c7aaab3c64cc836e978e662a529f3e582b6034e Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 29 Sep 2026 20:55:00 +0200 Subject: [PATCH] fix(share): the shared map opens framed on the whole trip, not on a corner of it The opening camera of the public share map was computed for the browser window, while the map lives in a card a fraction of that size. Leaflet then rounded the fractional zoom up, and the first fit skipped its correction because it believed the map had already opened framed. Only picking a day and going back to the whole trip ran a real fit. The card is now measured before the map mounts and the opening frame is computed for it, on a whole zoom level, with the same padding and zoom cap the later fits use. A card without layout falls back to the first fit. Closes #2549 --- .../src/pages/sharedTrip/SharedMap.test.tsx | 103 ++++++++++++++++++ client/src/pages/sharedTrip/SharedMap.tsx | 47 ++++++-- 2 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 client/src/pages/sharedTrip/SharedMap.test.tsx diff --git a/client/src/pages/sharedTrip/SharedMap.test.tsx b/client/src/pages/sharedTrip/SharedMap.test.tsx new file mode 100644 index 000000000..24e7357cf --- /dev/null +++ b/client/src/pages/sharedTrip/SharedMap.test.tsx @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { render } from '@testing-library/react' +import type { ReactNode } from 'react' +import { computeMapViewport, TILE_SIZE_RASTER } from '../../utils/mapViewport' +import { SharedMap, type MapPlace } from './SharedMap' + +// The camera the map opened with, and every fit made after it. +const mounted: { center: [number, number]; zoom: number }[] = [] +const fitBounds = vi.fn() + +vi.mock('react-leaflet', () => ({ + MapContainer: ({ center, zoom, children }: { center: [number, number]; zoom: number; children: ReactNode }) => { + mounted.push({ center, zoom }) + return
{children}
+ }, + TileLayer: () => null, + Marker: ({ children }: { children?: ReactNode }) =>
{children}
, + Polyline: () => null, + Tooltip: ({ children }: { children?: ReactNode }) =>
{children}
, + useMap: () => ({ fitBounds }), +})) +vi.mock('react-leaflet-cluster', () => ({ + default: ({ children }: { children?: ReactNode }) =>
{children}
, +})) +vi.mock('../../components/Map/VectorBasemap', () => ({ default: () => null })) + +// Synthetic points: a dense cluster plus a spread over a small region, wider than tall. +const places: MapPlace[] = [ + { id: 1, name: 'A', lat: 52.37, lng: 4.89 }, + { id: 2, name: 'B', lat: 52.372, lng: 4.893 }, + { id: 3, name: 'C', lat: 52.33, lng: 5.42 }, + { id: 4, name: 'D', lat: 52.35, lng: 4.3 }, + { id: 5, name: 'E', lat: 52.4, lng: 4.98 }, +] + +const PADDING = { top: 64, right: 48, bottom: 48, left: 48 } + +let cardSize = { width: 0, height: 0 } +const realWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth') +const realHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight') + +function renderMap(list: MapPlace[] = places) { + return render( + {}} />, + ) +} + +describe('SharedMap opening frame (#2549)', () => { + beforeEach(() => { + mounted.length = 0 + fitBounds.mockClear() + // A desktop window far larger than the map card, which is what exposed the bug. + vi.stubGlobal('innerWidth', 1920) + vi.stubGlobal('innerHeight', 1080) + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => cardSize.width }) + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => cardSize.height }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + if (realWidth) Object.defineProperty(HTMLElement.prototype, 'clientWidth', realWidth) + if (realHeight) Object.defineProperty(HTMLElement.prototype, 'clientHeight', realHeight) + }) + + it('FE-SHARED-MAP-001: frames the places for the card it sits in, not for the browser window', () => { + cardSize = { width: 750, height: 338 } + renderMap() + + const forCard = computeMapViewport(places, { tileSize: TILE_SIZE_RASTER, padding: PADDING, maxZoom: 14, width: 750, height: 338 })! + const forWindow = computeMapViewport(places, { tileSize: TILE_SIZE_RASTER, padding: PADDING, maxZoom: 14 })! + expect(mounted).toHaveLength(1) + expect(mounted[0].zoom).toBe(Math.floor(forCard.zoom)) + expect(mounted[0].center).toEqual(forCard.center) + // The window-sized frame is the over-zoomed one the issue saw. + expect(mounted[0].zoom).toBeLessThan(Math.floor(forWindow.zoom)) + // Opened framed, so there is nothing for the first fit to correct. + expect(fitBounds).not.toHaveBeenCalled() + }) + + it('FE-SHARED-MAP-002: opens on a whole zoom level, as fitBounds does', () => { + cardSize = { width: 750, height: 338 } + renderMap() + expect(Number.isInteger(mounted[0].zoom)).toBe(true) + }) + + it('FE-SHARED-MAP-003: a card without layout lets the first fit do the framing', () => { + cardSize = { width: 0, height: 0 } + renderMap() + expect(fitBounds).toHaveBeenCalledTimes(1) + expect(fitBounds.mock.calls[0][1]).toEqual({ paddingTopLeft: [48, 64], paddingBottomRight: [48, 48], maxZoom: 14 }) + }) + + it('FE-SHARED-MAP-004: a new set of places (a picked day) still refits with the same frame', () => { + cardSize = { width: 750, height: 338 } + const { rerender } = renderMap() + expect(fitBounds).not.toHaveBeenCalled() + rerender( + {}} />, + ) + expect(fitBounds).toHaveBeenCalledTimes(1) + expect(fitBounds.mock.calls[0][1]).toEqual({ paddingTopLeft: [48, 64], paddingBottomRight: [48, 48], maxZoom: 14 }) + }) +}) diff --git a/client/src/pages/sharedTrip/SharedMap.tsx b/client/src/pages/sharedTrip/SharedMap.tsx index 3ed817267..1f34e5776 100644 --- a/client/src/pages/sharedTrip/SharedMap.tsx +++ b/client/src/pages/sharedTrip/SharedMap.tsx @@ -1,5 +1,5 @@ import L from 'leaflet' -import { createElement, useEffect, useRef, type ReactNode } from 'react' +import { createElement, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react' import { ChevronLeft, ChevronRight } from 'lucide-react' import { MapContainer, Marker, Polyline, TileLayer, Tooltip, useMap } from 'react-leaflet' import MarkerClusterGroup from 'react-leaflet-cluster' @@ -55,6 +55,12 @@ function createMarkerIcon(place: MapPlace, orderNumbers?: number[] | null) { }) } +// One frame for both the opening camera and every later fit, so the map opens on +// exactly the view that picking a day and going back to the whole trip produces. +// The top edge leaves room for the day picker floating over the map. +const FRAME_PADDING = { top: 64, right: 48, bottom: 48, left: 48 } +const FRAME_MAX_ZOOM = 14 + function FitBoundsToPlaces({ places, framedOnMount }: { places: MapPlace[]; framedOnMount: boolean }) { const map = useMap() const fitRan = useRef(false) @@ -72,7 +78,11 @@ function FitBoundsToPlaces({ places, framedOnMount }: { places: MapPlace[]; fram } fitRan.current = true const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng])) - map.fitBounds(bounds, { padding: [48, 48], maxZoom: 14 }) + map.fitBounds(bounds, { + paddingTopLeft: [FRAME_PADDING.left, FRAME_PADDING.top], + paddingBottomRight: [FRAME_PADDING.right, FRAME_PADDING.bottom], + maxZoom: FRAME_MAX_ZOOM, + }) }, [fitKey, map]) // eslint-disable-line react-hooks/exhaustive-deps return null } @@ -94,10 +104,31 @@ interface SharedMapProps { * map and the list can never disagree on which day is open. */ export function SharedMap({ places, line, orderByPlace, cartoApiKey, days, selectedDay, onSelectDay }: SharedMapProps) { + // The card's own size, read before the map mounts. Framing for the browser window + // instead zoomed a card a fraction of its size far too close, and the fit below + // then skipped the correction because it believed the map had opened framed (#2549). + const cardRef = useRef(null) + const [card, setCard] = useState<{ width: number; height: number } | null>(null) + useLayoutEffect(() => { + const el = cardRef.current + if (el) setCard({ width: el.clientWidth, height: el.clientHeight }) + }, []) + // A card with no layout yet (hidden, or a test DOM) has nothing to frame against, + // so the map opens near the places and the first fit does the real framing. + const measured = card !== null && card.width > 0 && card.height > 0 // Open framed on the trip's places instead of on Paris. MapContainer only reads // center/zoom at mount, so recomputing this per render is free. - const framed = computeMapViewport(places, { tileSize: TILE_SIZE_RASTER, padding: { top: 64, right: 48, bottom: 48, left: 48 } }) - const initialView = framed ?? { center: DEFAULT_MAP_CENTER, zoom: DEFAULT_MAP_ZOOM } + const framed = computeMapViewport(places, { + tileSize: TILE_SIZE_RASTER, + padding: FRAME_PADDING, + maxZoom: FRAME_MAX_ZOOM, + ...(measured ? { width: card.width, height: card.height } : {}), + }) + // Whole levels, as fitBounds settles on: Leaflet rounds a fractional opening zoom + // to the nearest level, and rounding up crops the outermost places. + const initialView = framed + ? { center: framed.center, zoom: Math.floor(framed.zoom) } + : { center: DEFAULT_MAP_CENTER, zoom: DEFAULT_MAP_ZOOM } // A visitor of a share link has no settings of their own, so the basemap is the // app default: OpenFreeMap, a vector style that needs no key at all. The owner's // CARTO key still travels in the payload and is still applied, because a raster @@ -107,8 +138,8 @@ export function SharedMap({ places, line, orderByPlace, cartoApiKey, days, selec return ( // `isolate` keeps Leaflet's pane z-indexes inside the card, under the sticky tab bar. -
- + {card && )} - + {selectedDay != null && line.length > 1 && ( [p.lat, p.lng])} @@ -142,7 +173,7 @@ export function SharedMap({ places, line, orderByPlace, cartoApiKey, days, selec ))} - + } {days.length > 0 && }