doxa-maps/library/composables/useMapClustering.js

689 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:
// - docs/mapbox/clustering.md — three-mode convention reference
// - docs/composables/map-clustering.md — composable contract
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'],
'text-font': ['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).
'line-emissive-strength': 1,
'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;