fix(map): position place pins in the map's render cadence on MapLibre

The place pins visibly swam back and forth while the map was dragged and only
settled onto their real position when it stopped. The clustered bubbles never did,
and that is the clue: those are drawn inside the canvas, the pins are HTML.

A library Marker repositions itself on the map's 'move' event, which fires once per
pointer sample, while the canvas is redrawn once per animation frame. When the style
is heavy or tiles are still streaming, the pins therefore run ahead of the map they
are supposed to be pinned to.

They now live in their own layer inside the canvas container and are positioned from
the map's own 'render' event, so pin and map move on the same clock. Mapbox keeps the
library marker: its terrain path needs the altitude handling that comes with it, and
the drift was never reported there. Clicks, hover and dragging a pin onto a day are
untouched — they hang off the element, which is the same element as before.
This commit is contained in:
Maurice
2026-08-28 22:09:05 +02:00
committed by Maurice
parent 9e0fb19e79
commit 1d12535b1b
2 changed files with 135 additions and 8 deletions
@@ -42,6 +42,9 @@ const glMap = vi.hoisted(() => ({
queryRenderedFeatures: vi.fn().mockReturnValue([]),
querySourceFeatures: vi.fn().mockReturnValue([]),
unproject: vi.fn(() => ({ lng: 2.3522, lat: 48.8566 })),
// The MapLibre place pins position themselves with this, so a mock without it
// never exercises the path the real map takes.
project: vi.fn((lngLat: [number, number]) => ({ x: lngLat[0] * 10, y: lngLat[1] * 10 })),
getBounds: vi.fn(() => ({ getSouth: () => 0, getWest: () => 0, getNorth: () => 1, getEast: () => 1 })),
easeTo: vi.fn(),
getCanvas: vi.fn(() => document.createElement('canvas')),
@@ -411,6 +414,42 @@ describe('MapViewGL', () => {
expect(glMap.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-place-clusters-count' }))
})
it('FE-COMP-MAPVIEWGL-070: MapLibre place pins are positioned from the map render, not from pointer moves', async () => {
glMap.on.mockImplementation((event: string, handlerOrLayer: unknown) => {
if (event === 'load' && typeof handlerOrLayer === 'function') (handlerOrLayer as () => void)()
return glMap
})
useSettingsStore.setState({
settings: {
...useSettingsStore.getState().settings,
map_provider: 'maplibre-gl',
mapbox_access_token: '',
maplibre_style: 'https://tiles.openfreemap.org/styles/liberty',
},
} as any)
render(<MapViewGL places={[buildMapPlace({ id: 1, lat: 3, lng: 2 })]} fitKey={1} glProvider="maplibre-gl" />)
await act(async () => {})
// The pin sits in its own layer inside the canvas container, not in one of the
// library's marker wrappers — those follow 'move', which fires per pointer sample.
const layer = Array.from(glCanvasContainer.children).find(
c => c instanceof HTMLElement && c.style.pointerEvents === 'none',
) as HTMLElement | undefined
expect(layer).toBeTruthy()
expect(layer!.children).toHaveLength(1)
const pin = layer!.children[0] as HTMLElement
// project() is mocked as lng*10 / lat*10.
expect(pin.style.transform).toContain('translate(20px, 30px)')
// Moving the camera and drawing a frame moves the pin with it.
glMap.project.mockReturnValue({ x: 90, y: 70 })
const render1 = glMap.on.mock.calls.find(c => c[0] === 'render')?.[1] as (() => void) | undefined
expect(render1).toBeTypeOf('function')
act(() => { render1!() })
expect(pin.style.transform).toContain('translate(90px, 70px)')
})
function touchEvent(type: string, touches: Array<{ clientX: number; clientY: number }>) {
const ev = new Event(type, { bubbles: true })
Object.defineProperty(ev, 'touches', { value: touches })