Files
TREK/client/src/components/Map/markerCluster.test.ts
T
Maurice 4c0583845a fix(map): keep the selected place findable on the map
Selecting a place no longer spiderfies a city-sized cluster: the reveal
only fans open bubbles of up to 12 stops, or stacks whose stops all share
one coordinate, so a zoomed-out trip with hundreds of places stays usable
(#2990).

The selected pin now carries a white border inside a pulsing blue ring on
both renderers (static under reduced motion), and the map measures the
place card instead of assuming a fixed 320px, so the selection pans the
pin above even a tall card on portrait tablets and eases it back out when
the card grows over it later (#2991).

Closes #2990
Closes #2991
2026-11-06 12:50:02 +01:00

162 lines
7.6 KiB
TypeScript

/**
* What the shared cluster options do to stops that share a coordinate (#2344).
*
* Nothing is mocked: the behaviour under test is leaflet.markercluster's own. The
* library's single answer to markers no zoom can separate is to fan them out on click,
* and whether that can fire at all is decided entirely by this option block — which is
* why the block is worth a test of its own rather than a snapshot in a component suite.
*/
import { describe, it, expect, afterEach } from 'vitest'
import L from 'leaflet'
import 'leaflet.markercluster'
import { CLUSTER_OPTIONS, CLUSTER_RADIUS_PX, CLUSTER_UNTIL_ZOOM, REVEAL_FAN_MAX_CHILDREN, revealInCluster } from './markerCluster'
import { COINCIDENT_RADIUS_PX, STACK_RADIUS_PX } from './coincidentPlaces'
import { MAP_MAX_ZOOM } from '../../constants/mapDefaults'
/**
* `leaflet.markercluster` ships no types and we do not depend on
* `@types/leaflet.markercluster`, so the factory and the two internals these tests read
* back are named here rather than pulling a package in.
*/
type Group = L.Layer & {
_maxZoom: number
addLayers: (layers: L.Marker[]) => Group
getVisibleParent: (marker: L.Marker) => L.Layer | null
}
const markerClusterGroup = (L as unknown as { markerClusterGroup: (options: unknown) => Group }).markerClusterGroup
const containers: HTMLElement[] = []
function mapAt(zoom: number, maxZoom = MAP_MAX_ZOOM): L.Map {
const el = document.createElement('div')
Object.defineProperty(el, 'clientWidth', { value: 800 })
Object.defineProperty(el, 'clientHeight', { value: 600 })
document.body.appendChild(el)
containers.push(el)
return L.map(el, { center: [48.8584, 2.2945], zoom, maxZoom })
}
/** A group already on the map, holding a marker per coordinate. */
function stopsOn(map: L.Map, coords: [number, number][]) {
const group = markerClusterGroup(CLUSTER_OPTIONS)
group.addTo(map)
const markers = coords.map(coord => L.marker(coord))
group.addLayers(markers)
return { group, markers }
}
const HOTEL: [number, number] = [48.8584, 2.2945]
// ~300 m east: two ordinary neighbouring stops in a city day plan.
const NEARBY: [number, number] = [48.8584, 2.2986]
afterEach(() => { for (const el of containers.splice(0)) el.remove() })
describe('the shared cluster options', () => {
it('MARKERCLUSTER-001: cluster by the overview radius in the overview, by a pin width above it', () => {
const radius = CLUSTER_OPTIONS.maxClusterRadius
expect(radius(0)).toBe(CLUSTER_RADIUS_PX)
expect(radius(CLUSTER_UNTIL_ZOOM - 1)).toBe(CLUSTER_RADIUS_PX)
expect(radius(CLUSTER_UNTIL_ZOOM)).toBe(STACK_RADIUS_PX)
expect(radius(MAP_MAX_ZOOM)).toBe(STACK_RADIUS_PX)
// Wider than what the GL renderer folds at, and meant to stay wider: a bubble here
// hands the stops in it back on a click, where a folded GL pin is simply gone.
expect(STACK_RADIUS_PX).toBeGreaterThan(COINCIDENT_RADIUS_PX)
})
it('MARKERCLUSTER-002: carry no clustering cutoff at all, not even an undefined one', () => {
// The library guards on `!== null`, so the key spelled with `undefined` gives the
// group a NaN maximum zoom, builds no grid at all and throws on the first marker.
expect('disableClusteringAtZoom' in CLUSTER_OPTIONS).toBe(false)
})
it('MARKERCLUSTER-003: take their maximum zoom from the map, which is what makes the fan reachable', () => {
expect(stopsOn(mapAt(14), []).group._maxZoom).toBe(MAP_MAX_ZOOM)
})
it('MARKERCLUSTER-004: follow a mount site that lowers its own ceiling, silently', () => {
// Nothing throws and nothing warns: a map mounted with a lower ceiling simply moves
// the zoom at which a stack can still be fanned open.
expect(stopsOn(mapAt(14, 15), []).group._maxZoom).toBe(15)
})
it('MARKERCLUSTER-005: keep stops on one coordinate in a bubble at a day zoom, and fan them open', () => {
const map = mapAt(16)
const { group, markers } = stopsOn(map, [HOTEL, HOTEL, HOTEL])
const bubble = group.getVisibleParent(markers[0])
expect(bubble).not.toBe(markers[0])
expect(group.getVisibleParent(markers[2])).toBe(bubble)
expect(revealInCluster(group, markers[0])).toBe(true)
const spread = markers.map(marker => `${marker.getLatLng().lat},${marker.getLatLng().lng}`)
expect(new Set(spread).size).toBe(3)
})
it('MARKERCLUSTER-006: leave neighbouring stops alone at a day zoom, as they are today', () => {
const map = mapAt(16)
const { group, markers } = stopsOn(map, [HOTEL, NEARBY])
expect(group.getVisibleParent(markers[0])).toBe(markers[0])
expect(group.getVisibleParent(markers[1])).toBe(markers[1])
})
it('MARKERCLUSTER-007: still merge those same stops in the overview the radius was tuned for', () => {
const map = mapAt(5)
const { group, markers } = stopsOn(map, [HOTEL, NEARBY])
expect(group.getVisibleParent(markers[0])).not.toBe(markers[0])
expect(group.getVisibleParent(markers[0])).toBe(group.getVisibleParent(markers[1]))
})
})
describe('reaching a stop inside a bubble', () => {
it('MARKERCLUSTER-008: does nothing when there is no group, no marker or no bubble to open', () => {
expect(revealInCluster(null, {})).toBe(false)
expect(revealInCluster({ getVisibleParent: () => null }, null)).toBe(false)
expect(revealInCluster({}, {})).toBe(false)
expect(revealInCluster({ getVisibleParent: () => null }, {})).toBe(false)
const marker = {}
// A stop with a pin of its own is its own visible parent: nothing to fan.
expect(revealInCluster({ getVisibleParent: () => marker }, marker)).toBe(false)
// And a parent from a group that cannot spiderfy is left alone rather than called.
expect(revealInCluster({ getVisibleParent: () => ({}) }, {})).toBe(false)
})
/** A city's worth of stops around HOTEL, a few hundred metres apart, as a big trip has them. */
function city(count: number): [number, number][] {
return Array.from({ length: count }, (_, i) => [HOTEL[0] + (i % 8) * 0.003, HOTEL[1] + Math.floor(i / 8) * 0.004])
}
const fanned = (markers: L.Marker[], start: [number, number][]) =>
markers.some((marker, i) => marker.getLatLng().lat !== start[i][0] || marker.getLatLng().lng !== start[i][1])
it('MARKERCLUSTER-009: leaves a city-sized bubble of the overview closed (#2990)', () => {
const coords = city(60)
const { group, markers } = stopsOn(mapAt(5), coords)
const bubble = group.getVisibleParent(markers[17])
expect(bubble).not.toBe(markers[17])
expect(revealInCluster(group, markers[17])).toBe(false)
expect(fanned(markers, coords)).toBe(false)
})
it('MARKERCLUSTER-010: still opens a small bubble of the overview', () => {
const coords = city(REVEAL_FAN_MAX_CHILDREN)
const { group, markers } = stopsOn(mapAt(5), coords)
expect(group.getVisibleParent(markers[0])).not.toBe(markers[0])
expect(revealInCluster(group, markers[0])).toBe(true)
expect(fanned(markers, coords)).toBe(true)
})
it('MARKERCLUSTER-011: opens a stack of any size when every stop shares one coordinate', () => {
const coords = Array.from({ length: REVEAL_FAN_MAX_CHILDREN + 8 }, () => HOTEL)
const { group, markers } = stopsOn(mapAt(16), coords)
expect(revealInCluster(group, markers[3])).toBe(true)
expect(new Set(markers.map(marker => `${marker.getLatLng().lat},${marker.getLatLng().lng}`)).size).toBe(coords.length)
})
it('MARKERCLUSTER-012: reads a bubble without bounds as one that cannot be told apart from a city', () => {
const spiderfy = () => { throw new Error('must not fan') }
const big = { spiderfy, getChildCount: () => REVEAL_FAN_MAX_CHILDREN + 1 }
expect(revealInCluster({ getVisibleParent: () => big }, {})).toBe(false)
})
})