Files
TREK/client/src/components/Map/markerCluster.test.ts
T
Maurice a782e0e471 fix(map): make stops that share a spot reachable again
A trip that models one building as several stops — drop the bags, check in,
the museum inside the hotel — drew every pin on the same pixel, and only the
top one could be clicked, hovered or seen. Clustering is the mechanism for
exactly that: the plugin keeps children no zoom level can separate in one
cluster and fans them apart on click. disableClusteringAtZoom pinned the
group's own maximum zoom to 8, which both removed every cluster above it and
moved the only fan trigger into a zoom band a trip planner never operates in,
so the spiderfyOnMaxZoom set beside it could never fire.

The cutoff is gone. maxClusterRadius is a function of zoom instead: the same
generous radius as before in the overview, a pin's width above it, so stops
that are genuinely apart keep their own pins and a pile becomes a counted
bubble that fans open. Selecting a buried stop from the places rail now opens
the bubble it is hiding in rather than doing nothing visible, and it does that
without moving the camera, which stays the selection's own concern.

The GL renderer has no fan to open, so folding a pin there removes the only
thing that was left rather than providing a way in. It folds at a far tighter
radius — genuine coincidence, where nothing was reachable to begin with — and
draws the selected member of a pile, so the rail reaches it there too. A map
object without a projector now skips folding instead of dropping every marker.

The public share page spreads the same options and has no rail to fall back
on, which made it the worse case; it is fixed by the same change.

Closes #2344
2026-09-20 12:48:44 +02:00

122 lines
5.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, 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)
})
})