Skip to content
87 changes: 23 additions & 64 deletions app/components/map/layers/mobile/mobile-box-layer.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,6 @@
import bbox from '@turf/bbox'
import {
featureCollection,
lineString,
multiLineString,
point,
} from '@turf/helpers'
import { type MultiLineString, type Point } from 'geojson'
import {
type CircleLayerSpecification,
type LineLayerSpecification,
} from 'maplibre-gl'
import { featureCollection, point } from '@turf/helpers'
import { type Point } from 'geojson'
import { type CircleLayerSpecification } from 'maplibre-gl'
import { createContext, useContext, useEffect, useMemo } from 'react'
import { Layer, Popup, Source, useMap } from 'react-map-gl/maplibre'
import { HIGH_COLOR, LOW_COLOR, createPalette } from './color-palette'
Expand All @@ -18,7 +9,7 @@ import { type Sensor } from '~/db/schema'
interface CustomGeoJsonProperties {
locationId: number
value: number
createdAt: Date
time: string
color: string
}

Expand All @@ -36,47 +27,46 @@ export default function MobileBoxLayer({
maxColor = HIGH_COLOR,
}: {
sensor: Sensor
minColor?:
| NonNullable<CircleLayerSpecification['paint']>['circle-color']
| NonNullable<LineLayerSpecification['paint']>['line-color']
maxColor?:
| NonNullable<CircleLayerSpecification['paint']>['circle-color']
| NonNullable<LineLayerSpecification['paint']>['line-color']
minColor?: NonNullable<CircleLayerSpecification['paint']>['circle-color']
maxColor?: NonNullable<CircleLayerSpecification['paint']>['circle-color']
}) {
const { hoveredPoint, setHoveredPoint } = useContext(HoveredPointContext)
const { osem: mapRef } = useMap()

const sourceData = useMemo<GeoJSON.FeatureCollection | null>(() => {
const sensorData = (sensor.data ?? []) as unknown as {
value: string
location: { x: number; y: number; id: number }
createdAt: Date
value: number | string | null
location: { x: number; y: number; id: number } | null
time: Date | string | null
}[]
const mappableData = sensorData.filter(
(measurement) =>
measurement.location !== null &&
measurement.value !== null &&
Number.isFinite(Number(measurement.value)),
Comment on lines +43 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate coordinates before creating point features.

The filter accepts a non-null location with NaN or infinite coordinates. Those values create invalid GeoJSON positions and can prevent MapLibre from rendering the source. Filter both coordinates with Number.isFinite.

Proposed fix
 		const mappableData = sensorData.filter(
 			(measurement) =>
 				measurement.location !== null &&
+				Number.isFinite(measurement.location.x) &&
+				Number.isFinite(measurement.location.y) &&
 				measurement.value !== null &&
 				Number.isFinite(Number(measurement.value)),
 		)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const mappableData = sensorData.filter(
(measurement) =>
measurement.location !== null &&
measurement.value !== null &&
Number.isFinite(Number(measurement.value)),
const mappableData = sensorData.filter(
(measurement) =>
measurement.location !== null &&
Number.isFinite(measurement.location.x) &&
Number.isFinite(measurement.location.y) &&
measurement.value !== null &&
Number.isFinite(Number(measurement.value)),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/map/layers/mobile/mobile-box-layer.tsx` around lines 42 - 46,
Update the sensorData filter used to build mappableData so it also requires both
location coordinates to pass Number.isFinite, while preserving the existing
non-null measurement location/value and finite value checks. Use the coordinate
fields from measurement.location before creating point features.

)

if (sensorData.length === 0) return null
if (mappableData.length === 0) return null

const minValue = Math.min(...sensorData.map((d) => Number(d.value)))
const maxValue = Math.max(...sensorData.map((d) => Number(d.value)))
const minValue = Math.min(...mappableData.map((d) => Number(d.value)))
const maxValue = Math.max(...mappableData.map((d) => Number(d.value)))
const palette = createPalette(
minValue,
maxValue,
minColor as string,
maxColor as string,
)

const points = sensorData.map((measurement) =>
point([measurement.location.x, measurement.location.y], {
const points = mappableData.map((measurement) =>
point([measurement.location!.x, measurement.location!.y], {
value: Number(measurement.value),
createdAt: new Date(measurement.createdAt),
time: measurement.time ? new Date(measurement.time).toISOString() : '',
color: palette(Number(measurement.value)).hex(),
locationId: measurement.location.id,
locationId: measurement.location!.id,
}),
)

const line = lineString(points.map((p) => p.geometry.coordinates))
const lines = multiLineString([line.geometry.coordinates])

return featureCollection<Point | MultiLineString>([...points, lines])
return featureCollection<Point>(points)
}, [maxColor, minColor, sensor.data])

const hoveredFeature = useMemo(() => {
Expand All @@ -92,26 +82,6 @@ export default function MobileBoxLayer({
)
}, [hoveredPoint, sourceData])

useEffect(() => {
if (!mapRef || !sourceData) return

const bounds = bbox(sourceData).slice(0, 4) as [
number,
number,
number,
number,
]

mapRef.fitBounds(bounds, {
padding: {
top: 100,
bottom: 400,
left: 500,
right: 100,
},
})
}, [mapRef, sourceData])

useEffect(() => {
if (!mapRef) return

Expand Down Expand Up @@ -152,17 +122,6 @@ export default function MobileBoxLayer({
return (
<>
<Source id="box-source" type="geojson" data={sourceData}>
<Layer
id="box-layer-line"
source="box-source"
type="line"
filter={['==', '$type', 'LineString']}
paint={{
'line-color': '#333',
'line-width': 2,
'line-opacity': 0.7,
}}
/>
<Layer
id="box-layer-point"
source="box-source"
Expand Down
29 changes: 23 additions & 6 deletions app/components/map/layers/mobile/mobile-box-view.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ArrowDownUp } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { calculateColorRange } from './color-palette'
import MobileBoxLayer from './mobile-box-layer'
import { Button } from '~/components/ui/button'
Expand All @@ -26,9 +27,9 @@ export default function MobileBoxView({
}

return (
<div className="absolute top-10 right-0 flex flex-col gap-4 p-4">
<div className="absolute top-80 right-0 flex max-h-[calc(100vh-21rem)] flex-col gap-4 overflow-y-auto p-4">
{sensors.map((sensor, index) => (
<div key={index} className="flex flex-col items-center gap-4">
<div key={sensor.id} className="flex flex-col items-center gap-4">
{index === 1 && sensors.length === 2 && (
<Button
className="self-center rounded-full px-4 py-2"
Expand Down Expand Up @@ -86,6 +87,7 @@ function Legend({
sensor: SensorWithColor
onColorChange?: (min: string, max: string) => void
}) {
const { t } = useTranslation('mobile-map')
const { lowColor, highColor } = calculateColorRange(sensor.color)

const minColorInputRef = useRef<HTMLInputElement>(null)
Expand All @@ -95,16 +97,31 @@ function Legend({
const [maxColor, setMaxColor] = useState(highColor)

useEffect(() => {
onColorChange && onColorChange(minColor, maxColor)
onColorChange?.(minColor, maxColor)
}, [minColor, maxColor, onColorChange])

const sensorData = Array.isArray(sensor.data) ? sensor.data : []
const sensorData = Array.isArray(sensor.data)
? sensor.data.filter(
(measurement) =>
measurement.value !== null &&
Number.isFinite(Number(measurement.value)),
)
: []

const minValue = Math.min(...sensorData.map((d) => Number(d.value)))
const maxValue = Math.max(...sensorData.map((d) => Number(d.value)))
const minValue =
sensorData.length > 0
? Math.min(...sensorData.map((d) => Number(d.value)))
: 0
const maxValue =
sensorData.length > 0
? Math.max(...sensorData.map((d) => Number(d.value)))
: 0
Comment on lines +103 to +119

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Calculate legend bounds from the rendered point set.

MobileBoxLayer excludes measurements with location === null before it calculates point colors. Legend includes their values in minValue and maxValue. If such a measurement contains an endpoint value, the legend range does not match the range used for rendered points. Filter out measurements without a location here too.

Proposed fix
 		? sensor.data.filter(
 				(measurement) =>
+					measurement.location !== null &&
 					measurement.value !== null &&
 					Number.isFinite(Number(measurement.value)),
 			)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const sensorData = Array.isArray(sensor.data)
? sensor.data.filter(
(measurement) =>
measurement.value !== null &&
Number.isFinite(Number(measurement.value)),
)
: []
const minValue = Math.min(...sensorData.map((d) => Number(d.value)))
const maxValue = Math.max(...sensorData.map((d) => Number(d.value)))
const minValue =
sensorData.length > 0
? Math.min(...sensorData.map((d) => Number(d.value)))
: 0
const maxValue =
sensorData.length > 0
? Math.max(...sensorData.map((d) => Number(d.value)))
: 0
const sensorData = Array.isArray(sensor.data)
? sensor.data.filter(
(measurement) =>
measurement.location !== null &&
measurement.value !== null &&
Number.isFinite(Number(measurement.value)),
)
: []
const minValue =
sensorData.length > 0
? Math.min(...sensorData.map((d) => Number(d.value)))
: 0
const maxValue =
sensorData.length > 0
? Math.max(...sensorData.map((d) => Number(d.value)))
: 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/map/layers/mobile/mobile-box-view.tsx` around lines 103 - 118,
Update the sensorData filtering used by minValue and maxValue to exclude
measurements whose location is null, matching MobileBoxLayer’s rendered point
set. Preserve the existing value-null and finite-number checks so the legend
bounds continue to use only valid rendered measurements.


return (
<div className="z-50 flex w-40 flex-col gap-2 rounded-lg border-gray-200 bg-white p-2 shadow-xs">
<span className="text-muted-foreground text-[10px] font-medium tracking-wide uppercase">
{t('sensorValues')}
</span>
<span className="font-semibold">{sensor.title}</span>
<div
className="flex w-full items-center justify-between rounded-sm p-1"
Expand Down
54 changes: 39 additions & 15 deletions app/components/map/layers/mobile/mobile-overview-layer.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import bbox from '@turf/bbox'
import { point, featureCollection } from '@turf/helpers'
import { format } from 'date-fns'
import { type FeatureCollection, type Point } from 'geojson'
import { CalendarClock } from 'lucide-react'
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Source, Layer, useMap, Popup } from 'react-map-gl/maplibre'
import MapLegend from './mobile-overview-legend'
import {
type LocationPoint,
categorizeIntoTrips,
} from '~/lib/mobile-box-helper'
import { type LocationPoint, getLatestTrips } from '~/lib/mobile-box-helper'

const FIT_PADDING = 100

Expand Down Expand Up @@ -121,8 +118,17 @@ export default function MobileOverviewLayer({
}: {
locations: LocationPoint[]
}) {
// Generate trips and assign colors once
const trips = useMemo(() => categorizeIntoTrips(locations, 50), [locations])
const { i18n } = useTranslation('mobile-map')
const tripDateTimeFormatter = useMemo(
() =>
new Intl.DateTimeFormat(i18n.language, {
dateStyle: 'medium',
timeStyle: 'short',
}),
[i18n.language],
)
// Apply the same trip definition and limit as the server-side overview loader.
const trips = useMemo(() => getLatestTrips(locations), [locations])

// Cluster points within each trip
const clusteredTrips = useMemo(() => {
Expand Down Expand Up @@ -176,7 +182,7 @@ export default function MobileOverviewLayer({

// Legend items state
const [legendItems, setLegendItems] = useState<
{ label: string; color: string }[]
{ label: string; color: string; isLatest: boolean }[]
>([])

// State to track the highlighted trip number
Expand All @@ -198,7 +204,12 @@ export default function MobileOverviewLayer({
const [showOriginalColors, setShowOriginalColors] = useState(true)

useEffect(() => {
if (!clusteredTrips || clusteredTrips.length === 0) return
if (clusteredTrips.length === 0) {
setSourceData(null)
setExpandedSourceData(null)
setLegendItems([])
return
}
Comment on lines +211 to +217

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear interaction state with empty map data.

Line 207 clears the map sources but retains highlightedTrip, hoveredCluster, and popupInfo. If a user hovers a trip before the data becomes empty, the next non-empty update can render the old popup and highlight the wrong trip number. Reset these states in this branch.

Proposed fix
 		if (clusteredTrips.length === 0) {
 			setSourceData(null)
 			setExpandedSourceData(null)
 			setLegendItems([])
+			setHighlightedTrip(null)
+			setHoveredCluster(null)
+			setPopupInfo(null)
 			return
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (clusteredTrips.length === 0) {
setSourceData(null)
setExpandedSourceData(null)
setLegendItems([])
return
}
if (clusteredTrips.length === 0) {
setSourceData(null)
setExpandedSourceData(null)
setLegendItems([])
setHighlightedTrip(null)
setHoveredCluster(null)
setPopupInfo(null)
return
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/map/layers/mobile/mobile-overview-layer.tsx` around lines 207
- 212, Update the empty-data branch in the clusteredTrips update flow to also
reset highlightedTrip, hoveredCluster, and popupInfo alongside the existing
source and legend state resets. Ensure the next non-empty update cannot reuse
stale hover, highlight, or popup interaction state.


const colors = generateColors(clusteredTrips.length)

Expand Down Expand Up @@ -235,15 +246,20 @@ export default function MobileOverviewLayer({
)

// Set legend items for the trips
const legend = clusteredTrips.map((_, index) => ({
label: `Trip ${index + 1}`,
const legend = clusteredTrips.map((trip, index) => ({
label: formatTripTimeRange(
trip.startTime,
trip.endTime,
tripDateTimeFormatter,
),
color: colors[index],
isLatest: index === clusteredTrips.length - 1,
}))

setSourceData(featureCollection(points))
setExpandedSourceData(featureCollection(expandedPoints))
setLegendItems(legend)
}, [clusteredTrips])
}, [clusteredTrips, tripDateTimeFormatter])

useEffect(() => {
if (!mapRef || !sourceData) return
Expand Down Expand Up @@ -451,7 +467,7 @@ export default function MobileOverviewLayer({
)}
<div>
<p className="text-primary text-sm font-bold">
{format(new Date(popupInfo.startTime), 'Pp')}
{tripDateTimeFormatter.format(new Date(popupInfo.startTime))}
</p>
</div>
{popupInfo.isCluster &&
Expand All @@ -461,7 +477,7 @@ export default function MobileOverviewLayer({
To
</span>
<p className="text-primary text-sm font-bold">
{format(new Date(popupInfo.endTime), 'Pp')}
{tripDateTimeFormatter.format(new Date(popupInfo.endTime))}
</p>
</div>
)}
Expand All @@ -472,7 +488,7 @@ export default function MobileOverviewLayer({
<MapLegend
items={legendItems}
position="top-right"
toggleTrips={() => setShowOriginalColors(!showOriginalColors)}
onColorByTripChange={setShowOriginalColors}
showOriginalColors={showOriginalColors}
onLegendItemHover={(color) => {
setHighlightedTrip(
Expand All @@ -485,3 +501,11 @@ export default function MobileOverviewLayer({
</>
)
}

function formatTripTimeRange(
startTime: string,
endTime: string,
dateTimeFormatter: Intl.DateTimeFormat,
) {
return dateTimeFormatter.formatRange(new Date(startTime), new Date(endTime))
}
Loading
Loading