import type { MapMarkerData } from './map-marker'; import type { LatLng } from './map.types'; export interface MapClusterConfig { /** * Groups markers by a key you derive — a municipality, a unit, a case number. * Records sharing a key collapse into one marker carrying a count. */ by?: (marker: MapMarkerData, index: number) => string; /** Above this zoom the cluster splits back into individual markers. */ maxZoom?: number; /** * Proximity threshold in pixels. Markers closer than this merge even when * `by` did not group them. Requires a projection to be meaningful. */ radiusPx?: number; } /** A marker that stands for several records. */ export interface MapClusterResult { /** The marker to render — either the original, or a synthesized cluster. */ marker: MapMarkerData; /** Indices into the source array that this marker represents. */ memberIndices: number[]; } function averagePosition(positions: LatLng[]): LatLng { const total = positions.reduce( (sum, position) => ({ lat: sum.lat + position.lat, lng: sum.lng + position.lng }), { lat: 0, lng: 0 } ); return { lat: total.lat / positions.length, lng: total.lng / positions.length }; } /** * Collapses co-located records into counted markers. * * @description * Several records at the same position used to draw several pins on top of each * other, which reads as one point and undercounts the data. A cluster carries a * `badge` with the number it stands for. * * This is also the anonymization hook: `minAggregation` forces grouping whenever * a group holds fewer than N records, so a single individual is never exposed as * an isolated point. That is **display** anonymization — it changes what the * screen shows, not what the payload contains. Coordinates precise enough to * identify someone must be withheld server-side; this cannot do that for you. * * @param minAggregation - Groups smaller than this are merged into the nearest * remaining group rather than plotted individually. `0` disables it. */ export function clusterMarkers( markers: MapMarkerData[], cluster: MapClusterConfig | undefined, minAggregation = 0 ): MapClusterResult[] { const identity = markers.map((marker, index) => ({ marker, index })); if (!cluster?.by && minAggregation <= 0) { return identity.map(entry => ({ marker: entry.marker, memberIndices: [entry.index] })); } // Group by the caller's key, falling back to exact position so that stacked // pins still collapse when no `by` was supplied. const keyOf = (marker: MapMarkerData, index: number) => cluster?.by ? cluster.by(marker, index) : `${marker.position.lat},${marker.position.lng}`; const groups = new Map(); markers.forEach((marker, index) => { const key = keyOf(marker, index); const bucket = groups.get(key); if (bucket) bucket.push(index); else groups.set(key, [index]); }); let entries = Array.from(groups.entries()); /* * Anonymization pass: any group below the threshold is folded into the * geographically nearest group that is at or above it. When no such group * exists, the small groups merge with each other rather than being dropped — * hiding records outright would silently misreport the totals. */ if (minAggregation > 1) { const centroidOf = (indices: number[]) => averagePosition(indices.map(index => markers[index].position)); const large = entries.filter(([, indices]) => indices.length >= minAggregation); const small = entries.filter(([, indices]) => indices.length < minAggregation); if (small.length > 0) { if (large.length === 0) { entries = [['__aggregated', small.flatMap(([, indices]) => indices)]]; } else { const merged = new Map(large); small.forEach(([, indices]) => { const from = centroidOf(indices); let nearestKey = large[0][0]; let nearestDistance = Infinity; large.forEach(([key, targetIndices]) => { const to = centroidOf(targetIndices); const distance = (to.lat - from.lat) ** 2 + (to.lng - from.lng) ** 2; if (distance < nearestDistance) { nearestDistance = distance; nearestKey = key; } }); merged.set(nearestKey, [...(merged.get(nearestKey) ?? []), ...indices]); }); entries = Array.from(merged.entries()); } } } return entries.map(([key, indices]) => { if (indices.length === 1) { return { marker: markers[indices[0]], memberIndices: indices }; } const members = indices.map(index => markers[index]); const first = members[0]; return { marker: { ...first, id: `cluster:${key}`, position: averagePosition(members.map(member => member.position)), badge: indices.length, badgeLabel: first.badgeLabel, // A cluster's position is an average, never a real record's location. precision: 'centroid', precisionLabel: first.precisionLabel, }, memberIndices: indices, }; }); }