refactor(maps): move the place clusters out of MapViewGL

The cluster source, its layers, the expansion zoom and the DOM bubbles
now live in placeClusterLayers.ts with their own tests, which brings
MapViewGL back under its size baseline instead of growing it. Lowers the
size and eslint baselines that this change made smaller.
This commit is contained in:
Maurice
2026-10-28 09:42:12 +01:00
parent 7e790c3fb7
commit 3bfef73a46
6 changed files with 215 additions and 141 deletions
+5 -108
View File
@@ -50,16 +50,10 @@ import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, createClusterBubble } from './placeClusterLayers'
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
// HTML photo markers *and* cluster them natively, so we feed the place points
// into a clustered GeoJSON source. The cluster bubbles render as GL circles +
// a count label; the individual rich HTML markers are then only drawn for the
// points the source reports as currently unclustered. Grouping is always on,
// matching the Leaflet map's MarkerClusterGroup.
const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
import {
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
addPlaceClusterLayers, drawClusterBubbles, expandCluster,
} from './placeClusterLayers'
type PlaceWithCoords = Place & { lat: number; lng: number }
@@ -112,93 +106,6 @@ interface Props extends MapViewProps {
gl: any
}
/**
* How eagerly place markers merge into a cluster.
*
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
* kept as plain numbers rather than shared constants so the GL bundle does not have to
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
* own, and the stops that share a coordinate are folded together at the pin level
* instead — see the reconcile below.
*/
const CLUSTER_RADIUS = 20
const CLUSTER_MAX_ZOOM = 8
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
* change and the initial build cannot drift apart. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function addPlaceClusterLayers(map: any): void {
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: CLUSTER_RADIUS,
clusterMaxZoom: CLUSTER_MAX_ZOOM,
})
map.addLayer({
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
// Invisible: the bubble the user sees is DOM (createClusterBubble), these two
// layers stay as the hit area the track and route layers check a click against.
paint: {
'circle-opacity': 0,
'circle-stroke-opacity': 0,
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
},
})
map.addLayer({
id: PLACE_CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12,
'text-allow-overlap': true,
},
paint: { 'text-opacity': 0 },
})
map.addLayer({
id: PLACE_UNCLUSTERED_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 24,
'circle-opacity': 0,
'circle-stroke-opacity': 0,
},
})
}
/** Eases the map in to the zoom a cluster falls apart at. Both SDKs answer that
* question, one through a callback and one through a promise. */
function expandCluster(map: ClusterMap, clusterId: unknown, coordinates: unknown): void {
if (clusterId == null || !Array.isArray(coordinates)) return
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as ClusterSource | undefined
const easeToZoom = (nextZoom: number) => {
try { map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 }) } catch { /* noop */ }
}
try {
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err, nextZoom) => {
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom)
})
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom)
else if (maybeZoom instanceof Promise) maybeZoom.then(easeToZoom).catch(() => {})
} catch { /* noop */ }
}
/** The two map calls `expandCluster` makes, the same on both SDKs. */
interface ClusterMap {
getSource: (id: string) => unknown
easeTo: (options: { center: unknown[]; zoom: number; duration: number }) => void
}
interface ClusterSource {
getClusterExpansionZoom?: (id: unknown, done: (err: Error | null, zoom: number) => void) => unknown
}
function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
// A stop that interrupts the drive gets its own small disc, decided before the photo
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
@@ -1718,19 +1625,9 @@ export function MapViewGL({
clusterPinsRef.current.forEach(pin => pin.remove())
clusterPinsRef.current = []
}
// One bubble per cluster id: a cluster crossing a tile edge comes back once per tile.
const reconcileClusterPins = () => {
dropClusterPins()
const seenClusters = new Set<unknown>()
for (const feature of map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['has', 'point_count'] }) || []) {
const { cluster_id: clusterId, point_count: count } = feature?.properties ?? {}
const coordinates = feature?.geometry?.coordinates
if (typeof count !== 'number' || !Array.isArray(coordinates) || seenClusters.has(clusterId)) continue
seenClusters.add(clusterId)
const el = createClusterBubble(count)
el.addEventListener('click', (ev) => { ev.stopPropagation(); expandCluster(map, clusterId, coordinates) })
clusterPinsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, coordinates[0], coordinates[1]))
}
clusterPinsRef.current = drawClusterBubbles(map, (el, lng, lat) => attachPin(map, gl, pinLayerRef.current, el, lng, lat))
}
const runReconcile = () => {
raf = null