diff --git a/app/components/map/layers/mobile/mobile-overview-layer.tsx b/app/components/map/layers/mobile/mobile-overview-layer.tsx index 97c5fd63..374d1746 100644 --- a/app/components/map/layers/mobile/mobile-overview-layer.tsx +++ b/app/components/map/layers/mobile/mobile-overview-layer.tsx @@ -16,6 +16,7 @@ const FIT_PADDING = 100 // Clustering configuration const CLUSTER_DISTANCE_METERS = 8 // Distance threshold for clustering const MIN_CLUSTER_SIZE = 15 // Minimum points to form a cluster +const DENSITY_THRESHOLD = 0.5 // Only cluster the most dense 50% of candidate points // Function to calculate distance between two points in meters function calculateDistance(point1: LocationPoint, point2: LocationPoint): number { @@ -33,56 +34,221 @@ function calculateDistance(point1: LocationPoint, point2: LocationPoint): number return R * c } -// Cluster points within a single trip -function clusterTripPoints(points: LocationPoint[], distanceThreshold: number, minClusterSize: number) { - const clusters: LocationPoint[][] = [] - const visited = new Set() +// Function to calculate density score for each point based on nearby neighbors +function calculateDensityScore(points: LocationPoint[], pointIndex: number, distanceThreshold: number): number { + const targetPoint = points[pointIndex] + let nearbyCount = 0 + let totalDistance = 0 for (let i = 0; i < points.length; i++) { - if (visited.has(i)) continue + if (i === pointIndex) continue + + const distance = calculateDistance(targetPoint, points[i]) + if (distance <= distanceThreshold) { + nearbyCount++ + totalDistance += distance + } + } - const cluster: LocationPoint[] = [points[i]] - visited.add(i) + // Higher score = more dense (more neighbors, closer together) + if (nearbyCount === 0) return 0 + + // Score combines neighbor count with average proximity + const averageDistance = totalDistance / nearbyCount + const maxDistance = distanceThreshold + const proximityScore = (maxDistance - averageDistance) / maxDistance + + return nearbyCount * (1 + proximityScore) +} - // Find all points within distance threshold - for (let j = i + 1; j < points.length; j++) { - if (visited.has(j)) continue +// Function to find all points within distance threshold from a center point +function findPointsInRadius(points: LocationPoint[], centerIndex: number, distanceThreshold: number): number[] { + const pointsInRadius: number[] = [centerIndex] // Include the center point itself + const centerPoint = points[centerIndex] - const distance = calculateDistance(points[i], points[j]) - if (distance <= distanceThreshold) { - cluster.push(points[j]) - visited.add(j) - } + for (let i = 0; i < points.length; i++) { + if (i === centerIndex) continue + + const distance = calculateDistance(centerPoint, points[i]) + if (distance <= distanceThreshold) { + pointsInRadius.push(i) + } + } + + return pointsInRadius +} + +// Function to select only the most densely packed points from candidates +function selectDensestPoints(points: LocationPoint[], candidateIndices: number[], distanceThreshold: number, densityThreshold: number): number[] { + // Calculate density score for each candidate point + const pointsWithDensity = candidateIndices.map(index => ({ + index, + densityScore: calculateDensityScore(points, index, distanceThreshold) + })) + + // Sort by density score (highest first) + pointsWithDensity.sort((a, b) => b.densityScore - a.densityScore) + + // Take only the top percentage based on density threshold + const numToTake = Math.max(MIN_CLUSTER_SIZE, Math.ceil(pointsWithDensity.length * densityThreshold)) + const selectedPoints = pointsWithDensity.slice(0, numToTake) + + return selectedPoints.map(p => p.index) +} + +// Function to calculate cluster quality focusing on spatial centrality +function calculateSpatialCentrality(points: LocationPoint[], centerIndex: number, clusterIndices: number[]): number { + const centerPoint = points[centerIndex] + let totalDistanceSquared = 0 + + // Calculate sum of squared distances (minimize this for better centrality) + for (const idx of clusterIndices) { + if (idx === centerIndex) continue + const distance = calculateDistance(centerPoint, points[idx]) + totalDistanceSquared += distance * distance + } + + // Lower score means better centrality (center point minimizes total squared distances) + return totalDistanceSquared / clusterIndices.length +} + +// Function to find the most spatially central point within a potential cluster +function findOptimalClusterCenter(points: LocationPoint[], candidateIndices: number[], distanceThreshold: number): { + centerIndex: number + clusterIndices: number[] + quality: number +} { + // First, select only the most densely packed points from all candidates + const densePointIndices = selectDensestPoints(points, candidateIndices, distanceThreshold, DENSITY_THRESHOLD) + + if (densePointIndices.length < MIN_CLUSTER_SIZE) { + // If we don't have enough dense points, fall back to the original approach + const fallbackCenter = candidateIndices[0] + const fallbackCluster = findPointsInRadius(points, fallbackCenter, distanceThreshold) + return { + centerIndex: fallbackCenter, + clusterIndices: fallbackCluster.length >= MIN_CLUSTER_SIZE ? fallbackCluster : [], + quality: fallbackCluster.length >= MIN_CLUSTER_SIZE ? calculateSpatialCentrality(points, fallbackCenter, fallbackCluster) : Infinity } + } - // Only create cluster if it meets minimum size requirement - if (cluster.length >= minClusterSize) { - clusters.push(cluster) - } else { - // Add individual points that don't form clusters - cluster.forEach(point => clusters.push([point])) + let bestCenter = densePointIndices[0] + let bestClusterIndices = densePointIndices + let bestQuality = calculateSpatialCentrality(points, bestCenter, bestClusterIndices) + + // Test each dense point as a potential cluster center + for (const candidateIndex of densePointIndices) { + // For this center, find which dense points are within radius + const clusterIndices = densePointIndices.filter(idx => { + if (idx === candidateIndex) return true + return calculateDistance(points[candidateIndex], points[idx]) <= distanceThreshold + }) + + // Only consider if cluster is large enough + if (clusterIndices.length >= MIN_CLUSTER_SIZE) { + const quality = calculateSpatialCentrality(points, candidateIndex, clusterIndices) + + // Better quality = lower sum of squared distances (more central) + if (quality < bestQuality) { + bestCenter = candidateIndex + bestClusterIndices = clusterIndices + bestQuality = quality + } } } + + return { + centerIndex: bestCenter, + clusterIndices: bestClusterIndices, + quality: bestQuality + } +} +// clustering algorithm that finds truly optimal spatial centers +function spatiallyOptimizedClustering(points: LocationPoint[], distanceThreshold: number, minClusterSize: number) { + const clusters: LocationPoint[][] = [] + const visited = new Set() + + // Evaluate ALL points as potential cluster centers + const allPotentialClusters: Array<{ + centerIndex: number + clusterIndices: number[] + quality: number + }> = [] + + // First pass: evaluate EVERY point as a potential cluster center + for (let i = 0; i < points.length; i++) { + const pointsInRadius = findPointsInRadius(points, i, distanceThreshold) + + if (pointsInRadius.length >= minClusterSize) { + // Find the optimal center within this dense region, focusing only on densest points + const optimalCenter = findOptimalClusterCenter(points, pointsInRadius, distanceThreshold) + + // Only add if we found a valid cluster + if (optimalCenter.clusterIndices.length >= minClusterSize) { + allPotentialClusters.push(optimalCenter) + } + } + } + + //Sort ALL potential clusters by quality (better spatial centrality first) + allPotentialClusters.sort((a, b) => a.quality - b.quality) + + for (const potentialCluster of allPotentialClusters) { + // Check if any points in this cluster are already assigned + if (potentialCluster.clusterIndices.some(idx => visited.has(idx))) { + continue + } + + // Mark all points in this cluster as visited + potentialCluster.clusterIndices.forEach(idx => visited.add(idx)) + + // Create the cluster using the optimal center's point collection + const cluster = potentialCluster.clusterIndices.map(idx => points[idx]) + clusters.push(cluster) + } + + // Add remaining unvisited points as individual clusters + for (let i = 0; i < points.length; i++) { + if (!visited.has(i)) { + clusters.push([points[i]]) + } + } + return clusters } -// Calculate cluster center and metadata -function calculateClusterCenter(cluster: LocationPoint[], clusterId: string) { +// Calculate cluster center using the actual geometric centroid +function calculateOptimalClusterCenter(cluster: LocationPoint[], clusterId: string) { + // Calculate geometric centroid (true center of mass) const centerX = cluster.reduce((sum, point) => sum + point.geometry.x, 0) / cluster.length const centerY = cluster.reduce((sum, point) => sum + point.geometry.y, 0) / cluster.length // Sort by timestamp to get earliest and latest const sortedByTime = cluster.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()) + // Calculate additional metrics + const startTime = sortedByTime[0].time + const endTime = sortedByTime[sortedByTime.length - 1].time + const duration = new Date(endTime).getTime() - new Date(startTime).getTime() + + // Calculate cluster spread (max distance from centroid) + const maxDistanceFromCenter = Math.max(...cluster.map(point => { + const dx = point.geometry.x - centerX + const dy = point.geometry.y - centerY + return Math.sqrt(dx * dx + dy * dy) * 111320 // Convert to approximate meters + })) + return { coordinates: [centerX, centerY], pointCount: cluster.length, - startTime: sortedByTime[0].time, - endTime: sortedByTime[sortedByTime.length - 1].time, + startTime, + endTime, + duration, + maxSpread: maxDistanceFromCenter, isCluster: cluster.length > 1, clusterId: clusterId, - originalPoints: cluster // Keep reference to original points + originalPoints: cluster } } @@ -107,20 +273,22 @@ export default function MobileOverviewLayer({ }: { locations: LocationPoint[] }) { - // Generate trips and assign colors once + const trips = useMemo(() => categorizeIntoTrips(locations, 50), [locations]) - - // Cluster points within each trip const clusteredTrips = useMemo(() => { if (!trips || trips.length === 0) return [] return trips.map((trip, tripIndex) => { - const clusters = clusterTripPoints(trip.points, CLUSTER_DISTANCE_METERS, MIN_CLUSTER_SIZE) + const clusters = spatiallyOptimizedClustering( + trip.points, + CLUSTER_DISTANCE_METERS, + MIN_CLUSTER_SIZE + ) return { ...trip, clusters: clusters.map((cluster, clusterIndex) => - calculateClusterCenter(cluster, `trip-${tripIndex}-cluster-${clusterIndex}`) + calculateOptimalClusterCenter(cluster, `trip-${tripIndex}-cluster-${clusterIndex}`) ) } }) @@ -136,6 +304,8 @@ export default function MobileOverviewLayer({ isCluster?: boolean startTime?: string endTime?: string + duration?: number + maxSpread?: number clusterId?: string } > | null>(null) @@ -152,24 +322,22 @@ export default function MobileOverviewLayer({ const { osem: mapRef } = useMap() - // Legend items state const [legendItems, setLegendItems] = useState< { label: string; color: string }[] >([]) - // State to track the highlighted trip number const [highlightedTrip, setHighlightedTrip] = useState(null) - // State to track the hovered cluster const [hoveredCluster, setHoveredCluster] = useState(null) - // State to track the popup information const [popupInfo, setPopupInfo] = useState<{ longitude: number latitude: number startTime: string endTime: string pointCount?: number + duration?: number + maxSpread?: number isCluster?: boolean } | null>(null) @@ -191,12 +359,13 @@ export default function MobileOverviewLayer({ isCluster: cluster.isCluster, startTime: cluster.startTime, endTime: cluster.endTime, + duration: cluster.duration, + maxSpread: cluster.maxSpread, clusterId: cluster.clusterId, }), ), ) - // Create expanded points data for cluster hover const expandedPoints = clusteredTrips.flatMap((trip, tripIndex) => trip.clusters.flatMap((cluster) => { if (!cluster.isCluster || !cluster.originalPoints) return [] @@ -212,7 +381,6 @@ export default function MobileOverviewLayer({ }) ) - // Set legend items for the trips const legend = clusteredTrips.map((_, index) => ({ label: `Trip ${index + 1}`, color: colors[index], @@ -253,7 +421,7 @@ export default function MobileOverviewLayer({ if (event.features && event.features.length > 0) { const feature = event.features[0] - const { tripNumber, startTime, endTime, pointCount, isCluster, clusterId } = feature.properties + const { tripNumber, startTime, endTime, pointCount, duration, maxSpread, isCluster, clusterId } = feature.properties setHighlightedTrip(tripNumber) // Set hovered cluster if it's a cluster @@ -270,6 +438,8 @@ export default function MobileOverviewLayer({ startTime, endTime, pointCount, + duration, + maxSpread, isCluster }) } else { @@ -310,104 +480,131 @@ export default function MobileOverviewLayer({ mapRef.off('mouseleave', 'box-overview-layer', onMouseLeave) } }, [mapRef, handleHover, showOriginalColors]) + + const formatDuration = (durationMs: number): string => { + const minutes = Math.floor(durationMs / (1000 * 60)) + const hours = Math.floor(minutes / 60) + + if (hours > 0) { + const remainingMinutes = minutes % 60 + return `${hours}h ${remainingMinutes}m` + } + return `${minutes}m` + } if (!sourceData) return null return ( <> - - - {/* Text layer for cluster point counts */} - - - - {/* Expanded cluster points - shown on hover */} - {hoveredCluster && expandedSourceData && ( - - - - )} - + + + {/* Text layer for cluster point counts */} + + + +{/* Expanded cluster points - shown on hover */} +{hoveredCluster && expandedSourceData && ( + + + +)} + {highlightedTrip && popupInfo && ( Cluster of {popupInfo.pointCount} points

+ {popupInfo.duration && ( +

+ Duration: {formatDuration(popupInfo.duration)} +

+ )} + {popupInfo.maxSpread && ( +

+ Spread: {Math.round(popupInfo.maxSpread)}m +

+ )} )}
@@ -461,5 +668,4 @@ export default function MobileOverviewLayer({ /> ) -} - +} \ No newline at end of file