mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
4c0583845a
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
162 lines
7.6 KiB
TypeScript
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)
|
|
})
|
|
})
|