library/maps/label-font.js picks a symbol layer's text-font by engine: Noto Sans
(Regular, or Bold for medium/bold) under MapLibre, which is what OpenFreeMap
serves, and exactly the caller's fonts under Mapbox. Mapbox maps render as
before.
useMapClustering hard-coded 'DIN Offc Pro Medium, Arial Unicode MS Bold', which
OpenFreeMap does not have. Under MapLibre every native cluster count requested
it and got a 404; MapLibre then drew the counts in its fallback weight instead
of the intended bold. It now uses the helper, as do upg-neighbor (replacing its
own copy), prayer-walk-planner, and key-cities' city-name labels, which named no
font and so asked for MapLibre's default (also a 404 on OpenFreeMap).
Measured by importing the real composable in a browser, 3,000 points in native
mode:
- main under MapLibre: 1 glyph 404 (DIN Offc Pro Medium,Arial Unicode MS Bold),
and the counts drawn in the fallback weight;
- this change under MapLibre: 0 glyph 404s, text-font Noto Sans Bold, 22
clusters with counts in bold;
- this change under Mapbox: text-font still exactly DIN Offc Pro Medium /
Arial Unicode MS Bold.
No production map reaches native cluster counts today (Simple Research and
Country Posters force the 'mst' line mode), so this was latent in shared code.
Correction to b1e0c7fa: it said those labels "drew nothing". The screenshots here
show MapLibre falls back and draws them. The defect was the failed glyph request
on every load and the wrong weight, not missing text.
Rebuilt: country-posters, doxa-research-map, upg-neighbor, prayer-walk-planner,
key-cities.
691 lines
28 KiB
JavaScript
691 lines
28 KiB
JavaScript
// useMapClustering.js — Map Clustering Composable
|
|
// Vue 3 Composition API — ES Module
|
|
//
|
|
// Three clustering modes:
|
|
// 1. 'mapbox' — native Supercluster (cluster: true on the GeoJSON source)
|
|
// 2. 'mst' — Kruskal Minimum Spanning Tree network lines (no orphans)
|
|
// 3. 'network' — Nearest-neighbor shard/gem effect (denser visual)
|
|
//
|
|
// PORT NOTES:
|
|
// - Algorithm code (Kruskal, nearest-neighbor) ported from the upstream clustering project.
|
|
// - Pinia/inject wiring pattern adopted from doxa-simple-map-component, but
|
|
// adapted to the upstream convention which uses provide('mapStore', …)
|
|
// in ProfileLoader.vue (NOT a `stores` umbrella). We `inject('mapStore')`
|
|
// synchronously in setup so multi-instance Pinia stays isolated.
|
|
// - The composable never imports stores directly — keeps each <doxa-map>
|
|
// web-component embed isolated from sibling embeds on the same page.
|
|
//
|
|
// COMPANION DOCS:
|
|
// - documentation/mapbox/clustering.md — three-mode convention reference
|
|
// - documentation/composables/map-clustering.md — composable contract
|
|
|
|
import { labelFont } from '../maps/label-font.js'
|
|
import { ref, computed, inject } from 'vue';
|
|
import { emissivePaint } from './useMapProvider.js';
|
|
|
|
import MSTClusteringUtil from '../utils/MSTClusteringUtil.js';
|
|
import NetworkClusteringUtil from '../utils/NetworkClusteringUtil.js';
|
|
import {
|
|
groupBy,
|
|
DEFAULT_MAPBOX_CLUSTER_OPTIONS,
|
|
DEFAULT_CLUSTER_COLOR_RAMP,
|
|
DEFAULT_CLUSTER_RADIUS_RAMP
|
|
} from '../utils/ClusterHelpers.js';
|
|
|
|
/**
|
|
* @typedef {'mapbox' | 'mst' | 'network'} ClusteringMode
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} UseMapClusteringOptions
|
|
* @property {() => mapboxgl.Map|null} [getMap] - Returns the live Mapbox map
|
|
* @property {string} [mapId] - Logging tag
|
|
* @property {() => Array<Object>} [getPeopleGroups] - Returns the active dataset
|
|
* @property {string} [pinSourceId] - Source ID of the pins layer (default: 'language-families')
|
|
* @property {string} [pinLayerId] - Layer ID of the pins layer (default: 'language-family-pins')
|
|
* @property {string} [groupKey] - Property to group by for MST/network (default: 'languageFamily')
|
|
* @property {(name: string) => string} [getGroupColor] - Color resolver per group name
|
|
* @property {Object} [mapboxClusterOptions] - Overrides for source clustering options
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} UseMapClusteringApi
|
|
* @property {import('vue').ComputedRef<boolean>} enabled
|
|
* @property {import('vue').ComputedRef<ClusteringMode>} mode
|
|
* @property {import('vue').Ref<Array<Object>>} clusterLines
|
|
* @property {(next: ClusteringMode) => void} setMode
|
|
* @property {(on: boolean) => void} setEnabled
|
|
* @property {(handler: ((evt: Object) => void)|null) => void} onClusterClick
|
|
* @property {() => void} applyClustering
|
|
* @property {() => void} clearClustering
|
|
* @property {() => void} cleanup
|
|
* @property {MSTClusteringUtil} mstUtil
|
|
* @property {NetworkClusteringUtil} networkUtil
|
|
*/
|
|
|
|
// Internal layer/source IDs owned by this composable. Kept distinct from the
|
|
// host pin layer so we can toggle without disturbing the underlying source.
|
|
const CLUSTER_LINES_SOURCE = 'cluster-lines-source';
|
|
const CLUSTER_LINES_LAYER = 'cluster-lines';
|
|
const MAPBOX_CLUSTER_SOURCE = 'mapbox-clusters';
|
|
const MAPBOX_CLUSTER_CIRCLE = 'mapbox-clusters-circle';
|
|
const MAPBOX_CLUSTER_COUNT = 'mapbox-clusters-count';
|
|
|
|
// Reusable empty FeatureCollection — clearing via setData(EMPTY_FC) preserves
|
|
// the GL source/layer pair across toggles. This avoids the heap leak documented
|
|
// in mapbox-gl-js issues #2607, #4862, #5252 where addLayer/removeLayer chains
|
|
// retain GPU buffers in the WebGL backbuffer.
|
|
const EMPTY_FC = Object.freeze({ type: 'FeatureCollection', features: [] });
|
|
|
|
/**
|
|
* Map clustering composable — supports three clustering modes with a single
|
|
* unified API.
|
|
*
|
|
* @param {UseMapClusteringOptions} [options]
|
|
* @returns {UseMapClusteringApi}
|
|
*/
|
|
export function useMapClustering(options = {}) {
|
|
const {
|
|
getMap = () => null,
|
|
mapId = 'unknown',
|
|
getPeopleGroups = () => [],
|
|
pinSourceId = 'language-families',
|
|
pinLayerId = 'language-family-pins',
|
|
groupKey = 'languageFamily',
|
|
getGroupColor = null,
|
|
mapboxClusterOptions = {}
|
|
} = options;
|
|
|
|
// ── Inject Pinia store synchronously — DO NOT import directly ──────────
|
|
// ProfileLoader.vue uses provide('mapStore', useMapStore()) so each
|
|
// <doxa-map> instance gets its own store. Direct `useMapStore()` here
|
|
// would resolve to the wrong (or undefined) Pinia when this composable
|
|
// is invoked outside the active app instance.
|
|
const mapStore = inject('mapStore', null);
|
|
|
|
// Algorithm utilities — instantiated once per composable.
|
|
const mstUtil = new MSTClusteringUtil({ maxConnectionDistance: Infinity });
|
|
const networkUtil = new NetworkClusteringUtil({
|
|
maxDistance: 15,
|
|
maxNeighbors: 15,
|
|
sortKey: 'longitude'
|
|
});
|
|
|
|
// Local fallback state for environments without a mapStore.
|
|
const _enabledLocal = ref(false);
|
|
const _modeLocal = /** @type {import('vue').Ref<ClusteringMode>} */ (ref('mapbox'));
|
|
|
|
/**
|
|
* Whether clustering is currently enabled. Reads from `mapStore.clusteringEnabled`
|
|
* if present, else falls back to local ref.
|
|
*/
|
|
const enabled = computed(() => {
|
|
if (mapStore && 'clusteringEnabled' in mapStore) return !!mapStore.clusteringEnabled;
|
|
return _enabledLocal.value;
|
|
});
|
|
|
|
/**
|
|
* Active clustering mode. Reads from `mapStore.clusteringMode` if present,
|
|
* else falls back to local ref. Defaults to `'mapbox'`.
|
|
*/
|
|
const mode = computed(() => {
|
|
if (mapStore && mapStore.clusteringMode) return /** @type {ClusteringMode} */ (mapStore.clusteringMode);
|
|
return _modeLocal.value;
|
|
});
|
|
|
|
/** Last computed cluster lines (MST or network). Empty for 'mapbox' mode. */
|
|
const clusterLines = ref([]);
|
|
|
|
/** Click handler registered via `onClusterClick`. */
|
|
let _clusterClickHandler = null;
|
|
let _attachedClusterLayerId = null;
|
|
|
|
// Per-selection filter — when set, clustering only operates on pins whose
|
|
// `property` equals `value`. Pushed by the host when a legend row is
|
|
// clicked (lang-fam / doxa-region). Cleared when no row is selected.
|
|
// See research-map.vue → applyDimFilter() for the wiring.
|
|
let _selectionFilter = /** @type {{property: string, value: any}|null} */ (null);
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Mode + enabled setters — wire through to mapStore when available.
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Set the active clustering mode. Updates mapStore and re-applies the
|
|
* current clustering pass if enabled.
|
|
*
|
|
* @param {ClusteringMode} next
|
|
*/
|
|
function setMode(next) {
|
|
const allowed = ['mapbox', 'mst', 'network'];
|
|
if (!allowed.includes(next)) {
|
|
console.warn(`[${mapId}] useMapClustering.setMode: unknown mode "${next}". Allowed: ${allowed.join(', ')}`);
|
|
return;
|
|
}
|
|
|
|
if (mapStore && typeof mapStore.setClusteringMode === 'function') {
|
|
mapStore.setClusteringMode(next);
|
|
} else if (mapStore && 'clusteringMode' in mapStore) {
|
|
mapStore.clusteringMode = next;
|
|
} else {
|
|
_modeLocal.value = next;
|
|
}
|
|
|
|
if (enabled.value) {
|
|
// Tear down old mode before applying new — prevents pin double-render
|
|
// and orphan layers from previous mode lingering.
|
|
clearClustering();
|
|
applyClustering();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle clustering on/off. Updates mapStore and applies/clears layers.
|
|
*
|
|
* @param {boolean} on
|
|
*/
|
|
function setEnabled(on) {
|
|
const next = !!on;
|
|
|
|
if (mapStore && typeof mapStore.setClusteringEnabled === 'function') {
|
|
mapStore.setClusteringEnabled(next);
|
|
} else if (mapStore && 'clusteringEnabled' in mapStore) {
|
|
mapStore.clusteringEnabled = next;
|
|
} else {
|
|
_enabledLocal.value = next;
|
|
}
|
|
|
|
if (next) applyClustering();
|
|
else clearClustering();
|
|
}
|
|
|
|
/**
|
|
* Constrain clustering to pins matching `{ property, value }`. Pass a
|
|
* falsy filter (or `{ property: null, value: null }`) to clear and let
|
|
* clustering operate on the full dataset again.
|
|
*
|
|
* Re-applies clustering immediately if currently enabled so the visible
|
|
* MST/network lines update without waiting for a toggle.
|
|
*
|
|
* @param {{property: string|null, value: any}|null} filter
|
|
*/
|
|
function setSelectionFilter(filter) {
|
|
if (!filter || !filter.property || filter.value == null) {
|
|
_selectionFilter = null;
|
|
} else {
|
|
_selectionFilter = { property: filter.property, value: filter.value };
|
|
}
|
|
|
|
// Mirror onto mapStore so other observers (e.g. UI badges) can read it.
|
|
if (mapStore) {
|
|
if ('clusterFilterProperty' in mapStore) {
|
|
mapStore.clusterFilterProperty = _selectionFilter?.property ?? null;
|
|
}
|
|
if ('clusterFilterValue' in mapStore) {
|
|
mapStore.clusterFilterValue = _selectionFilter?.value ?? null;
|
|
}
|
|
}
|
|
|
|
if (enabled.value) {
|
|
clearClustering();
|
|
applyClustering();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply the active selection filter (if any) to the candidate
|
|
* people-groups array. No-op when no filter is set.
|
|
*
|
|
* Comparison is lenient: both sides are lowercased + alphanumeric-stripped
|
|
* before equality. This handles the doxa-research dataset where legend
|
|
* rows expose canonical labels ("Asia", "Latin America & Caribbean")
|
|
* while pin properties still carry raw lowercase codes ("asia",
|
|
* "latin_america_&_caribbean", "na"). language-family + language values
|
|
* already agree across legend + pin data, so loose matching is safe.
|
|
*
|
|
* @param {Array<Object>} peopleGroups
|
|
* @returns {Array<Object>}
|
|
* @private
|
|
*/
|
|
function _filterBySelection(peopleGroups) {
|
|
if (!_selectionFilter) return peopleGroups;
|
|
const { property, value } = _selectionFilter;
|
|
const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
const target = norm(value);
|
|
if (!target) return peopleGroups;
|
|
return peopleGroups.filter(pg => {
|
|
const v = pg?.[property] ?? pg?.properties?.[property] ?? pg?._normalized?.[property];
|
|
return norm(v) === target;
|
|
});
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Click handler registration
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Register a handler invoked when a cluster (mapbox-mode bubble) is
|
|
* clicked. Pass `null` to detach.
|
|
*
|
|
* The default handler — when `null` is passed and clusters become active —
|
|
* zooms the map to `cluster_expansion_zoom`. To override this behavior,
|
|
* register your own handler.
|
|
*
|
|
* @param {((evt: { clusterId: number, coordinates: [number, number], pointCount: number, originalEvent: Object }) => void)|null} handler
|
|
*/
|
|
function onClusterClick(handler) {
|
|
_clusterClickHandler = handler;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Apply / clear pipeline
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Apply the currently selected clustering mode to the map. Idempotent
|
|
* within a mode — safe to call multiple times.
|
|
*/
|
|
function applyClustering() {
|
|
const map = getMap();
|
|
if (!map) return;
|
|
|
|
// Always tear down whichever mode was active before re-applying. This
|
|
// is the fix for the "pin double-rendering on toggle" pitfall — leaving
|
|
// the cluster source attached while switching modes leaves stale layers.
|
|
clearClustering();
|
|
|
|
switch (mode.value) {
|
|
case 'mapbox':
|
|
_applyMapboxClustering(map);
|
|
break;
|
|
case 'mst':
|
|
_applyLineClustering(map, 'mst');
|
|
break;
|
|
case 'network':
|
|
_applyLineClustering(map, 'network');
|
|
break;
|
|
default:
|
|
console.warn(`[${mapId}] Unknown clustering mode: ${mode.value}`);
|
|
}
|
|
|
|
// Dim base pins when clustering is active so the cluster takes visual
|
|
// precedence. The host can override this paint property if it wants
|
|
// a different dimming rule.
|
|
_setPinDimming(map, true);
|
|
}
|
|
|
|
/**
|
|
* Hide all clustering layers and empty their sources. Sources stay attached
|
|
* for the lifetime of the map — see EMPTY_FC comment above and mapbox-gl-js
|
|
* issues #2607, #4862, #5252.
|
|
*
|
|
* Idempotent: safe to call when sources/layers haven't been created yet.
|
|
*/
|
|
function clearClustering() {
|
|
const map = getMap();
|
|
if (!map) return;
|
|
|
|
// Mapbox-native cluster artefacts — hide layers, empty source
|
|
_setLayerVisibility(map, MAPBOX_CLUSTER_COUNT, false);
|
|
_setLayerVisibility(map, MAPBOX_CLUSTER_CIRCLE, false);
|
|
_emptySource(map, MAPBOX_CLUSTER_SOURCE);
|
|
|
|
// MST/network line artefacts — hide layer, empty source
|
|
_setLayerVisibility(map, CLUSTER_LINES_LAYER, false);
|
|
_emptySource(map, CLUSTER_LINES_SOURCE);
|
|
|
|
clusterLines.value = [];
|
|
|
|
// Detach mapbox-mode click handler if present
|
|
_detachClusterClickHandler(map);
|
|
|
|
// Restore pin opacity
|
|
_setPinDimming(map, false);
|
|
}
|
|
|
|
/**
|
|
* Cleanup hook — call this from the host's `onBeforeUnmount`.
|
|
* This is the ONLY place where sources/layers are physically removed.
|
|
*/
|
|
function cleanup() {
|
|
const map = getMap();
|
|
if (map) {
|
|
// Detach handlers first so removeLayer doesn't fire stale events
|
|
_detachClusterClickHandler(map);
|
|
|
|
if (map.getLayer(MAPBOX_CLUSTER_COUNT)) map.removeLayer(MAPBOX_CLUSTER_COUNT);
|
|
if (map.getLayer(MAPBOX_CLUSTER_CIRCLE)) map.removeLayer(MAPBOX_CLUSTER_CIRCLE);
|
|
if (map.getSource(MAPBOX_CLUSTER_SOURCE)) map.removeSource(MAPBOX_CLUSTER_SOURCE);
|
|
|
|
if (map.getLayer(CLUSTER_LINES_LAYER)) map.removeLayer(CLUSTER_LINES_LAYER);
|
|
if (map.getSource(CLUSTER_LINES_SOURCE)) map.removeSource(CLUSTER_LINES_SOURCE);
|
|
|
|
_setPinDimming(map, false);
|
|
}
|
|
clusterLines.value = [];
|
|
_clusterClickHandler = null;
|
|
}
|
|
|
|
/**
|
|
* Toggle a layer's visibility without removing it. Safe no-op if the layer
|
|
* doesn't exist yet.
|
|
* @private
|
|
*/
|
|
function _setLayerVisibility(map, layerId, visible) {
|
|
if (!map.getLayer(layerId)) return;
|
|
try {
|
|
map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none');
|
|
} catch (_e) {
|
|
// Style may be loading — safe to swallow
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replace a source's data with an empty FeatureCollection. Safe no-op if
|
|
* the source doesn't exist yet.
|
|
* @private
|
|
*/
|
|
function _emptySource(map, sourceId) {
|
|
const src = map.getSource(sourceId);
|
|
if (src && typeof src.setData === 'function') {
|
|
try { src.setData(EMPTY_FC); } catch (_e) { /* swallow */ }
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Mode implementations
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Native Mapbox clustering — adds a parallel clustered source and three
|
|
* layers (cluster circles, cluster counts, individual pins handled by
|
|
* the host pin layer being dimmed but still rendered).
|
|
*
|
|
* Why a parallel source? The host pin source is shared with other layers
|
|
* that don't want clustering. Adding a second source with `cluster: true`
|
|
* keeps responsibilities separated.
|
|
*
|
|
* @param {Object} map
|
|
* @private
|
|
*/
|
|
function _applyMapboxClustering(map) {
|
|
const peopleGroups = _filterBySelection(getPeopleGroups());
|
|
if (!peopleGroups.length) return;
|
|
|
|
const features = peopleGroups
|
|
.map(pg => {
|
|
const lng = parseFloat(pg.longitude ?? pg.lng);
|
|
const lat = parseFloat(pg.latitude ?? pg.lat);
|
|
if (isNaN(lng) || isNaN(lat)) return null;
|
|
return {
|
|
type: 'Feature',
|
|
geometry: { type: 'Point', coordinates: [lng, lat] },
|
|
properties: { ...(pg.properties || pg) }
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
|
|
const data = { type: 'FeatureCollection', features };
|
|
|
|
// Reuse the existing source if it's already attached — setData avoids
|
|
// the GL-backbuffer leak from add/removeSource churn.
|
|
const existingSrc = map.getSource(MAPBOX_CLUSTER_SOURCE);
|
|
if (existingSrc && typeof existingSrc.setData === 'function') {
|
|
existingSrc.setData(data);
|
|
} else {
|
|
map.addSource(MAPBOX_CLUSTER_SOURCE, {
|
|
type: 'geojson',
|
|
data,
|
|
...DEFAULT_MAPBOX_CLUSTER_OPTIONS,
|
|
...mapboxClusterOptions
|
|
});
|
|
}
|
|
|
|
// Add layers only if they don't yet exist; otherwise just unhide.
|
|
if (!map.getLayer(MAPBOX_CLUSTER_CIRCLE)) {
|
|
map.addLayer({
|
|
id: MAPBOX_CLUSTER_CIRCLE,
|
|
type: 'circle',
|
|
source: MAPBOX_CLUSTER_SOURCE,
|
|
filter: ['has', 'point_count'],
|
|
paint: {
|
|
'circle-color': DEFAULT_CLUSTER_COLOR_RAMP,
|
|
'circle-radius': DEFAULT_CLUSTER_RADIUS_RAMP,
|
|
'circle-stroke-width': 2,
|
|
'circle-stroke-color': '#ffffff',
|
|
// Bypass Standard v3 scene-lighting so cluster bubbles keep their ramp
|
|
// colour on the night preset instead of darkening (mirror useMapLayers.js:248).
|
|
...emissivePaint(map),
|
|
'circle-opacity': 0.85
|
|
}
|
|
});
|
|
} else {
|
|
_setLayerVisibility(map, MAPBOX_CLUSTER_CIRCLE, true);
|
|
}
|
|
|
|
if (!map.getLayer(MAPBOX_CLUSTER_COUNT)) {
|
|
map.addLayer({
|
|
id: MAPBOX_CLUSTER_COUNT,
|
|
type: 'symbol',
|
|
source: MAPBOX_CLUSTER_SOURCE,
|
|
filter: ['has', 'point_count'],
|
|
layout: {
|
|
'text-field': ['get', 'point_count_abbreviated'],
|
|
// By engine: OpenFreeMap has no DIN, so under MapLibre every count was blank.
|
|
...labelFont(map, { weight: 'medium', mapbox: ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'] }),
|
|
'text-size': 12
|
|
},
|
|
paint: { 'text-color': '#000000' }
|
|
});
|
|
} else {
|
|
_setLayerVisibility(map, MAPBOX_CLUSTER_COUNT, true);
|
|
}
|
|
|
|
_attachClusterClickHandler(map);
|
|
console.log(`[${mapId}] Clustering: mapbox-native applied (${features.length} features)`);
|
|
}
|
|
|
|
/**
|
|
* MST or network line clustering — adds a single GeoJSON line source +
|
|
* line layer below the host pins.
|
|
*
|
|
* @param {Object} map
|
|
* @param {'mst'|'network'} algorithm
|
|
* @private
|
|
*/
|
|
function _applyLineClustering(map, algorithm) {
|
|
const peopleGroups = _filterBySelection(getPeopleGroups());
|
|
if (!peopleGroups.length) return;
|
|
|
|
const groups = groupBy(
|
|
peopleGroups,
|
|
pg => pg[groupKey] ?? pg._normalized?.[groupKey] ?? 'Unknown'
|
|
);
|
|
|
|
const colorFunc = typeof getGroupColor === 'function'
|
|
? getGroupColor
|
|
: (() => '#666666');
|
|
|
|
let lines = [];
|
|
let stats = null;
|
|
|
|
if (algorithm === 'mst') {
|
|
lines = mstUtil.generateAllMSTLines(groups, colorFunc);
|
|
stats = mstUtil.getMSTStats(groups);
|
|
} else {
|
|
lines = networkUtil.generateAllNetworkLines(groups, colorFunc);
|
|
stats = networkUtil.getClusteringStats(groups);
|
|
}
|
|
|
|
clusterLines.value = lines;
|
|
if (lines.length === 0) return;
|
|
|
|
const data = { type: 'FeatureCollection', features: lines };
|
|
|
|
// Reuse the line source across toggles — see EMPTY_FC comment.
|
|
const existingSrc = map.getSource(CLUSTER_LINES_SOURCE);
|
|
if (existingSrc && typeof existingSrc.setData === 'function') {
|
|
existingSrc.setData(data);
|
|
} else {
|
|
map.addSource(CLUSTER_LINES_SOURCE, {
|
|
type: 'geojson',
|
|
data
|
|
});
|
|
}
|
|
|
|
if (!map.getLayer(CLUSTER_LINES_LAYER)) {
|
|
const layerConfig = {
|
|
id: CLUSTER_LINES_LAYER,
|
|
type: 'line',
|
|
source: CLUSTER_LINES_SOURCE,
|
|
paint: {
|
|
'line-color': ['coalesce', ['get', 'color'], '#666666'],
|
|
'line-width': [
|
|
'interpolate', ['linear'], ['zoom'],
|
|
0, 0.5, 4, 0.75, 8, 1, 12, 1.5
|
|
],
|
|
// Bypass Standard v3 scene-lighting so MST/network lines keep their colour
|
|
// on the night preset instead of darkening (mirror useMapLayers.js:248).
|
|
...emissivePaint(map, 'line'),
|
|
'line-opacity': 0.5
|
|
},
|
|
layout: {
|
|
'line-cap': 'round',
|
|
'line-join': 'round'
|
|
}
|
|
};
|
|
|
|
// Insert below the host pin layer so pins stay clickable on top.
|
|
if (map.getLayer(pinLayerId)) {
|
|
map.addLayer(layerConfig, pinLayerId);
|
|
} else {
|
|
map.addLayer(layerConfig);
|
|
}
|
|
} else {
|
|
_setLayerVisibility(map, CLUSTER_LINES_LAYER, true);
|
|
}
|
|
|
|
console.log(
|
|
`[${mapId}] Clustering: ${algorithm} applied — ` +
|
|
`${lines.length} lines across ${stats?.totalGroups ?? 0} groups`
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Mapbox-native cluster click — zoom to expansion zoom by default
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
function _attachClusterClickHandler(map) {
|
|
if (!map.getLayer(MAPBOX_CLUSTER_CIRCLE)) return;
|
|
|
|
map.on('click', MAPBOX_CLUSTER_CIRCLE, _handleClusterClick);
|
|
map.on('mouseenter', MAPBOX_CLUSTER_CIRCLE, _setCursorPointer);
|
|
map.on('mouseleave', MAPBOX_CLUSTER_CIRCLE, _setCursorDefault);
|
|
_attachedClusterLayerId = MAPBOX_CLUSTER_CIRCLE;
|
|
}
|
|
|
|
function _detachClusterClickHandler(map) {
|
|
if (!_attachedClusterLayerId) return;
|
|
const layerId = _attachedClusterLayerId;
|
|
try {
|
|
map.off('click', layerId, _handleClusterClick);
|
|
map.off('mouseenter', layerId, _setCursorPointer);
|
|
map.off('mouseleave', layerId, _setCursorDefault);
|
|
} catch (_e) {
|
|
// Safe to swallow — Mapbox throws if the layer is already gone
|
|
}
|
|
_attachedClusterLayerId = null;
|
|
}
|
|
|
|
function _handleClusterClick(e) {
|
|
const map = getMap();
|
|
if (!map) return;
|
|
|
|
const features = map.queryRenderedFeatures(e.point, { layers: [MAPBOX_CLUSTER_CIRCLE] });
|
|
if (!features.length) return;
|
|
|
|
const feature = features[0];
|
|
const clusterId = feature.properties.cluster_id;
|
|
const coordinates = feature.geometry.coordinates;
|
|
const pointCount = feature.properties.point_count;
|
|
|
|
// Custom handler takes precedence — let the host decide what to do.
|
|
if (typeof _clusterClickHandler === 'function') {
|
|
_clusterClickHandler({
|
|
clusterId,
|
|
coordinates,
|
|
pointCount,
|
|
originalEvent: e
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Default behavior — zoom to cluster expansion zoom.
|
|
const source = map.getSource(MAPBOX_CLUSTER_SOURCE);
|
|
if (!source || typeof source.getClusterExpansionZoom !== 'function') return;
|
|
|
|
source.getClusterExpansionZoom(clusterId, (err, zoom) => {
|
|
if (err) return;
|
|
map.easeTo({ center: coordinates, zoom });
|
|
});
|
|
}
|
|
|
|
function _setCursorPointer() {
|
|
const map = getMap();
|
|
if (map) map.getCanvas().style.cursor = 'pointer';
|
|
}
|
|
|
|
function _setCursorDefault() {
|
|
const map = getMap();
|
|
if (map) map.getCanvas().style.cursor = '';
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Pin opacity dimming
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Dim the host pin layer when clustering is active so clusters take
|
|
* visual precedence. Restores opacity 0.9 when clustering is off.
|
|
*
|
|
* Safe no-op when the host pin layer hasn't been added yet.
|
|
*
|
|
* @param {Object} map
|
|
* @param {boolean} dim
|
|
* @private
|
|
*/
|
|
function _setPinDimming(map, dim) {
|
|
if (!map.getLayer(pinLayerId)) return;
|
|
try {
|
|
map.setPaintProperty(pinLayerId, 'circle-opacity', dim ? 0.25 : 0.9);
|
|
} catch (_e) {
|
|
// Layer may have been removed mid-toggle — safe to swallow
|
|
}
|
|
}
|
|
|
|
return {
|
|
// State
|
|
enabled,
|
|
mode,
|
|
clusterLines,
|
|
|
|
// Setters
|
|
setMode,
|
|
setEnabled,
|
|
setSelectionFilter,
|
|
|
|
// Click handler registration
|
|
onClusterClick,
|
|
|
|
// Lifecycle
|
|
applyClustering,
|
|
clearClustering,
|
|
cleanup,
|
|
|
|
// Algorithm utilities (exposed for testing / advanced use)
|
|
mstUtil,
|
|
networkUtil
|
|
};
|
|
}
|
|
|
|
export default useMapClustering;
|