mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user