/** * Spiderfy Utilities * * Handle overlapping markers by spreading them out in a spiral/circle pattern. * Provides both static offset for initial display and animated spiderfy on click. */ import type { MarkerData } from '../types' /** Configuration for spiderfy behavior */ export interface SpiderfyOptions { /** Minimum distance (in degrees) to consider points as overlapping. Default: 0.00001 (~1m) */ overlapThreshold?: number /** Base radius for spiral in degrees. Default: 0.0001 (~10m) */ spiralRadius?: number /** Number of points per spiral rotation. Default: 8 */ pointsPerRotation?: number /** Whether to apply random jitter (vs deterministic spiral). Default: false */ useJitter?: boolean /** Jitter amount in degrees when useJitter=true. Default: 0.00005 */ jitterAmount?: number } const DEFAULT_OPTIONS: Required = { overlapThreshold: 0.00001, spiralRadius: 0.0001, pointsPerRotation: 8, useJitter: false, jitterAmount: 0.00005, } /** * Group markers by their location (within threshold) * Returns map of "location key" -> markers at that location */ export function groupOverlappingMarkers( markers: MarkerData[], threshold: number = DEFAULT_OPTIONS.overlapThreshold ): Map { const groups = new Map() const assigned = new Set() for (const marker of markers) { if (assigned.has(marker.id)) continue // Find all markers within threshold of this one const group: MarkerData[] = [marker] assigned.add(marker.id) for (const other of markers) { if (assigned.has(other.id)) continue const distance = Math.sqrt( Math.pow(marker.longitude - other.longitude, 2) + Math.pow(marker.latitude - other.latitude, 2) ) if (distance <= threshold) { group.push(other) assigned.add(other.id) } } // Use first marker's coords as key const key = `${marker.longitude.toFixed(6)},${marker.latitude.toFixed(6)}` groups.set(key, group) } return groups } /** * Calculate spiral position for a point in a group * Uses Fermat's spiral for even distribution */ function getSpiralOffset( index: number, total: number, options: Required ): { dLng: number; dLat: number } { if (total === 1 || index === 0) { return { dLng: 0, dLat: 0 } } const { spiralRadius, pointsPerRotation } = options // Fermat spiral: r = a * sqrt(n), theta = n * golden_angle const goldenAngle = Math.PI * (3 - Math.sqrt(5)) // ~137.5 degrees const angle = index * goldenAngle const radius = spiralRadius * Math.sqrt(index) return { dLng: radius * Math.cos(angle), dLat: radius * Math.sin(angle), } } /** * Calculate jitter offset (random but deterministic based on id) */ function getJitterOffset( markerId: string, options: Required ): { dLng: number; dLat: number } { // Simple hash from id for deterministic "randomness" let hash = 0 for (let i = 0; i < markerId.length; i++) { hash = ((hash << 5) - hash) + markerId.charCodeAt(i) hash = hash & hash } const { jitterAmount } = options const angle = (hash % 360) * (Math.PI / 180) const distance = (Math.abs(hash % 1000) / 1000) * jitterAmount return { dLng: distance * Math.cos(angle), dLat: distance * Math.sin(angle), } } /** * Apply offset to overlapping markers * * Returns new array with adjusted coordinates. * Original markers are not modified. * * @example * ```tsx * const adjustedMarkers = offsetOverlappingMarkers(markers, { * spiralRadius: 0.0002, // Larger spread * }) * ``` */ export function offsetOverlappingMarkers( markers: MarkerData[], options?: SpiderfyOptions ): MarkerData[] { const opts = { ...DEFAULT_OPTIONS, ...options } const groups = groupOverlappingMarkers(markers, opts.overlapThreshold) const result: MarkerData[] = [] for (const group of groups.values()) { if (group.length === 1) { // Single marker, no offset needed result.push(group[0]) continue } // Multiple markers at same location - apply offset group.forEach((marker, index) => { const offset = opts.useJitter ? getJitterOffset(marker.id, opts) : getSpiralOffset(index, group.length, opts) result.push({ ...marker, longitude: marker.longitude + offset.dLng, latitude: marker.latitude + offset.dLat, data: { ...marker.data, _originalLongitude: marker.longitude, _originalLatitude: marker.latitude, _isOffset: true, _groupSize: group.length, }, }) }) } return result } /** * Get spiderfy positions for animation * * Returns positions for markers to animate TO when expanding. * Use for click-to-expand behavior. * * @example * ```tsx * const expanded = getSpiderfyPositions(overlappingMarkers, center) * // Animate markers to their expanded positions * ``` */ export function getSpiderfyPositions( markers: MarkerData[], center: { longitude: number; latitude: number }, options?: SpiderfyOptions ): Array<{ marker: MarkerData; position: { longitude: number; latitude: number } }> { const opts = { ...DEFAULT_OPTIONS, ...options } return markers.map((marker, index) => { const offset = getSpiralOffset(index, markers.length, opts) return { marker, position: { longitude: center.longitude + offset.dLng, latitude: center.latitude + offset.dLat, }, } }) } /** * Check if markers have overlapping points */ export function hasOverlappingMarkers( markers: MarkerData[], threshold: number = DEFAULT_OPTIONS.overlapThreshold ): boolean { const groups = groupOverlappingMarkers(markers, threshold) return Array.from(groups.values()).some(group => group.length > 1) } /** * Get statistics about overlapping markers */ export function getOverlapStats( markers: MarkerData[], threshold: number = DEFAULT_OPTIONS.overlapThreshold ): { totalMarkers: number uniqueLocations: number maxOverlap: number overlappingGroups: number } { const groups = groupOverlappingMarkers(markers, threshold) const groupSizes = Array.from(groups.values()).map(g => g.length) return { totalMarkers: markers.length, uniqueLocations: groups.size, maxOverlap: Math.max(...groupSizes, 0), overlappingGroups: groupSizes.filter(s => s > 1).length, } }