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