forked from hasadna/open-bus-map-search
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathVelocityHeatmapRectangles.tsx
More file actions
117 lines (108 loc) · 3.66 KB
/
Copy pathVelocityHeatmapRectangles.tsx
File metadata and controls
117 lines (108 loc) · 3.66 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import type { SiriVelocityAggregationPydanticModel } from '@hasadna/open-bus-api-client'
import { useContext, useEffect } from 'react'
import { Popup, Rectangle } from 'react-leaflet'
import dayjs, { ISRAEL_TIMEZONE } from 'src/dayjs'
import { GlobalSearchContext } from 'src/model/globalState'
import { useVelocityAggregationData } from '../useVelocityAggregationData'
import { VelocityHeatmapPopup } from './VelocityHeatmapPopup'
import { useZoomLevel } from './ZoomComponent'
type VisMode = 'avg' | 'std' | 'cv'
function getValue(point: SiriVelocityAggregationPydanticModel, visMode: VisMode): number {
const avg = point.averageRollingAvg ?? 0
const stdev = point.stddevRollingAvg ?? 0
if (visMode === 'avg') return avg
if (visMode === 'std') return stdev
if (visMode === 'cv') {
return stdev > 0 ? stdev / avg : 0
}
return 0
}
function getRedOpacityColor(value: number, minV = 0, maxV = 1): string {
// Clamp and normalize value to [0,1]
// 0 = fully transparent, 1 = solid red
if (maxV === minV) return 'rgba(255,0,0,0)'
const maxOpacity = 0.9
const norm = Math.max(0, Math.min(1, (value - minV) / (maxV - minV))) * maxOpacity
// norm = norm ** 2 / maxOpacity ** 2
return `rgba(255,0,0,${norm.toFixed(3)})`
}
interface VelocityHeatmapRectanglesProps {
visMode: VisMode
setMinMax?: (min: number, max: number) => void
setStatus?: (loading: boolean, hasError: boolean) => void
}
const DEFAULT_BOUNDS = {
minLat: 29.5,
maxLat: 33.33,
minLon: 34.25,
maxLon: 35.7,
}
export const VelocityHeatmapRectangles = ({
visMode,
setMinMax,
setStatus,
}: VelocityHeatmapRectanglesProps) => {
const { search } = useContext(GlobalSearchContext)
const zoom = useZoomLevel()
const { data, loading, error, currZoom } = useVelocityAggregationData(
{
minLat: DEFAULT_BOUNDS.minLat,
maxLat: DEFAULT_BOUNDS.maxLat,
minLon: DEFAULT_BOUNDS.minLon,
maxLon: DEFAULT_BOUNDS.maxLon,
},
dayjs.tz(search.date, ISRAEL_TIMEZONE),
zoom - 6,
)
const half = 0.5 / Math.pow(2, currZoom)
// Compute min/max for normalization
let minV = 0,
maxV = 1
if (data && data.length > 0) {
const values = data
.map((p) => getValue(p, visMode))
.filter((v): v is number => typeof v === 'number' && !isNaN(v))
if (values.length > 0) {
minV = Math.min(...values)
maxV = Math.max(...values)
if (minV === maxV) {
// Avoid division by zero
minV = 0
maxV = minV + 1
}
}
}
// Pass min/max to parent for legend
useEffect(() => {
setMinMax?.(minV, maxV)
}, [minV, maxV, setMinMax])
// Pass loading/error status to parent - a plain element rendered here would
// be a child of react-leaflet's MapContainer, which only knows how to
// position actual map layers, so it would never appear on screen.
useEffect(() => {
setStatus?.(loading, !!error)
}, [loading, error, setStatus])
return (
<>
{data?.map((point, idx) => {
const bounds: [[number, number], [number, number]] = [
[point.roundedLat - half, point.roundedLon - half],
[point.roundedLat + half, point.roundedLon + half],
]
const value = getValue(point, visMode)
const color = getRedOpacityColor(value, minV, maxV)
// fillOpacity is always 1, alpha is controlled by color's 4th channel
return (
<Rectangle
key={idx}
bounds={bounds}
pathOptions={{ weight: 1, fillColor: color, fillOpacity: 1, stroke: false }}>
<Popup>
<VelocityHeatmapPopup point={point} color={color} minV={minV} maxV={maxV} />
</Popup>
</Rectangle>
)
})}
</>
)
}