Files
TREK/client/src/components/Map/markerCluster.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

110 lines
4.7 KiB
TypeScript

import L from 'leaflet'
import { STACK_RADIUS_PX } from './coincidentPlaces'
import { clusterBubbleSize } from './placeClusterLayers'
/** Up to here the map is an overview, where stops merge to keep the world readable. */
export const CLUSTER_UNTIL_ZOOM = 9
/** The overview radius, in pixels at the current zoom. */
export const CLUSTER_RADIUS_PX = 20
/**
* The cluster group the planner and the public share page both draw places with,
* so a shared link clusters a trip the way the planner does (#2343).
*/
export const CLUSTER_OPTIONS = {
chunkedLoading: true,
chunkInterval: 30,
chunkDelay: 0,
/**
* Pixels at the current zoom, so ordinary stops still come apart by zooming in.
* From the day zooms upwards only the width of a pin counts: stops sharing one
* coordinate cannot be separated by any zoom, and a cluster is the only thing
* that can fan them apart again. There is deliberately no `disableClusteringAtZoom`
* here — it redefines the group's own maximum zoom, which is what the spiderfy
* branch is measured against.
*
* A whole pin's third is affordable precisely because the fold is reversible here:
* what comes out of it is a counted bubble the user can click open. The GL renderer
* has no fan to offer and folds far tighter — see `COINCIDENT_RADIUS_PX`.
*/
maxClusterRadius: (zoom: number) => (zoom < CLUSTER_UNTIL_ZOOM ? CLUSTER_RADIUS_PX : STACK_RADIUS_PX),
spiderfyOnMaxZoom: true,
// The default legs put four 36px pins barely a pin apart and six of them back on
// top of each other, which is the stack the fan is there to undo.
spiderfyDistanceMultiplier: 1.6,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
animate: false,
}
/** The part of a cluster group that `revealInCluster` reads. */
export interface ClusterGroupLike {
getVisibleParent?: (marker: unknown) => unknown
}
/** The part of a cluster bubble that `revealInCluster` reads. */
interface RevealableCluster {
spiderfy: () => void
getChildCount: () => number
getBounds?: () => { getNorthEast: () => L.LatLng; getSouthWest: () => L.LatLng }
}
/**
* The most stops a selection fans open around itself. Up to here the fan stays a small
* ring around the bubble; a city-sized bubble from the overview would throw hundreds of
* legs across the whole map instead (#2990).
*/
export const REVEAL_FAN_MAX_CHILDREN = 12
/**
* Whether opening this bubble shows the selection or buries the map.
*
* A small bubble always opens. A bigger one only when every stop in it sits on the same
* coordinate, the stack no zoom can take apart, which is the case the fan is there for.
* Anything else is a pile of ordinary neighbours that zooming in separates, so it stays
* closed and the selection is shown by the pan alone.
*/
function fansOpenOnSelect(cluster: RevealableCluster): boolean {
if (cluster.getChildCount() <= REVEAL_FAN_MAX_CHILDREN) return true
const bounds = cluster.getBounds?.()
return !!bounds && bounds.getNorthEast().equals(bounds.getSouthWest())
}
function isRevealableCluster(layer: unknown): layer is RevealableCluster {
const candidate = layer as Partial<RevealableCluster> | null
return !!candidate && typeof candidate.spiderfy === 'function' && typeof candidate.getChildCount === 'function'
}
/**
* Fan open the bubble a stop is hidden inside, if it is in one and small enough.
*
* Picking a stop from the places rail used to raise its pin out of the stack with a
* z-index. A stop sharing its coordinates now sits in a cluster instead and has no pin
* of its own to raise, so the bubble has to open for the selection to be visible at all.
* Deliberately not `zoomToShowLayer`: the zoom belongs to the day fit, and the selection
* only ever pans. See `fansOpenOnSelect` for which bubbles are opened.
*/
export function revealInCluster(group: ClusterGroupLike | null | undefined, marker: unknown): boolean {
if (!group || !marker || typeof group.getVisibleParent !== 'function') return false
const parent = group.getVisibleParent(marker)
if (parent === marker || !isRevealableCluster(parent) || !fansOpenOnSelect(parent)) return false
parent.spiderfy()
return true
}
/** The part of a Leaflet cluster this factory reads. */
export interface ClusterLike {
getChildCount: () => number
}
/** A count bubble sized by its count, styled by `.marker-cluster-custom` in index.css. */
export function createClusterIcon(cluster: ClusterLike) {
const count = cluster.getChildCount()
const size = clusterBubbleSize(count)
return L.divIcon({
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
className: 'marker-cluster-wrapper',
iconSize: L.point(size, size),
})
}