mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
a782e0e471
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
122 lines
5.6 KiB
TypeScript
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)
|
|
})
|
|
})
|