Skip to content

Commit 028543e

Browse files
committed
Precipitation Overlay
1 parent c6f7cf5 commit 028543e

28 files changed

Lines changed: 309 additions & 12 deletions
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { useEffect, useRef } from 'react'
2+
3+
import { useQuery } from '@tanstack/react-query'
4+
import { useMap } from '@vis.gl/react-google-maps'
5+
6+
import { fetchOverlayTiles, OverlayTileData } from './overlayTiles'
7+
import { OverlayType } from './types'
8+
9+
const PLACEHOLDER_TILE = '/temp_world_efp/tile-placeholder.png'
10+
11+
/**
12+
* Normalizes tile coordinates per Google Maps conventions.
13+
* - Does NOT repeat across the y-axis (returns null for out-of-bounds y)
14+
* - Wraps across the x-axis (world repeats horizontally)
15+
*
16+
* Ported from the original WorldView implementation.
17+
*/
18+
function normalizeTileCoord(
19+
coord: google.maps.Point,
20+
zoom: number
21+
): { x: number; y: number } | null {
22+
const tileRange = 1 << zoom
23+
const y = coord.y
24+
let x = coord.x
25+
26+
if (y < 0 || y >= tileRange) return null
27+
if (x < 0 || x >= tileRange) {
28+
x = ((x % tileRange) + tileRange) % tileRange
29+
}
30+
return { x, y }
31+
}
32+
33+
interface ClimateOverlayProps {
34+
overlay: OverlayType
35+
}
36+
37+
const ClimateOverlay = ({ overlay }: ClimateOverlayProps) => {
38+
const map = useMap('WorldEFP')
39+
40+
// Tile data is fetched independently — doesn't block the map from rendering.
41+
const { data: tileData } = useQuery({
42+
queryKey: ['overlay-tiles', overlay],
43+
queryFn: () =>
44+
fetchOverlayTiles(overlay as Exclude<OverlayType, OverlayType.None>),
45+
enabled: overlay !== OverlayType.None,
46+
})
47+
48+
// Keep a ref to the latest tile data so the getTileUrl closure always reads
49+
// the most recent data without needing to recreate the ImageMapType layer.
50+
const tileDataRef = useRef<OverlayTileData | null>(null)
51+
useEffect(() => {
52+
tileDataRef.current = tileData ?? null
53+
}, [tileData])
54+
55+
// Keep a ref to the active layer so we can force a tile refresh when data loads.
56+
const layerRef = useRef<google.maps.ImageMapType | null>(null)
57+
58+
// Register / unregister the ImageMapType layer whenever the overlay or map changes.
59+
useEffect(() => {
60+
if (!map || !window.google?.maps || overlay === OverlayType.None) return
61+
62+
const layer = new window.google.maps.ImageMapType({
63+
getTileUrl: (coord: google.maps.Point, zoom: number): string | null => {
64+
if (zoom > 8) return null
65+
66+
const normalized = normalizeTileCoord(coord, zoom)
67+
if (!normalized) return null
68+
69+
const { x, y } = normalized
70+
const tileMap = tileDataRef.current?.tileMap
71+
const url = tileMap?.[`${zoom}_${x}_${y}`]
72+
return url ?? PLACEHOLDER_TILE
73+
},
74+
tileSize: new window.google.maps.Size(256, 256),
75+
opacity: 0.7,
76+
name: overlay,
77+
})
78+
79+
layerRef.current = layer
80+
map.overlayMapTypes.push(layer)
81+
82+
return () => {
83+
const index = map.overlayMapTypes.getArray().indexOf(layer)
84+
if (index !== -1) map.overlayMapTypes.removeAt(index)
85+
layerRef.current = null
86+
}
87+
}, [map, overlay])
88+
89+
// When tile data finishes loading, force a tile refresh by removing and
90+
// re-inserting the layer at the same position so grey placeholders are replaced.
91+
useEffect(() => {
92+
if (!tileData || !map || !layerRef.current) return
93+
const layer = layerRef.current
94+
const index = map.overlayMapTypes.getArray().indexOf(layer)
95+
if (index !== -1) {
96+
map.overlayMapTypes.removeAt(index)
97+
map.overlayMapTypes.insertAt(index, layer)
98+
}
99+
}, [tileData, map])
100+
101+
return null
102+
}
103+
104+
export default ClimateOverlay

Eplant/views/WorldEFP/MapContainer.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ import { getColor } from '../eFP/svg'
1313
import GeneDistributionChart from '../eFP/Viewer/GeneDistributionChart'
1414
import Legend from '../eFP/Viewer/legend'
1515

16+
import ClimateOverlay from './ClimateOverlay'
1617
import MapMarker from './MapMarker'
1718
import MapTypeSelector from './MapTypeSelector'
18-
import { WorldEFPData, WorldEFPState } from './types'
19+
import OverlaySelector from './OverlaySelector'
20+
import { ColorMode, WorldEFPData, WorldEFPState } from './types'
1921

2022
interface MapContainerProps {
2123
activeData: WorldEFPData
@@ -55,6 +57,7 @@ const MapContainer = ({ activeData, state, setState }: MapContainerProps) => {
5557
onZoomChanged={handleZoom}
5658
id='WorldEFP'
5759
>
60+
<ClimateOverlay overlay={state.overlay} />
5861
{activeData.positions.map((pos, index) => {
5962
const color = getColor(
6063
activeData.efpData.groups[index].mean,
@@ -91,6 +94,10 @@ const MapContainer = ({ activeData, state, setState }: MapContainerProps) => {
9194
mapTypeId={state.mapTypeId}
9295
onSelect={(mapTypeId) => setState({ ...state, mapTypeId })}
9396
/>
97+
<OverlaySelector
98+
overlay={state.overlay}
99+
onSelect={(overlay) => setState({ ...state, overlay })}
100+
/>
94101
</Box>
95102
<Box
96103
sx={(theme) => ({
@@ -120,7 +127,7 @@ const MapContainer = ({ activeData, state, setState }: MapContainerProps) => {
120127
marginLeft: '-12px',
121128
}}
122129
/>
123-
<Legend colorMode={'absolute'} data={activeData.efpData}></Legend>
130+
<Legend colorMode={ColorMode.Absolute} data={activeData.efpData}></Legend>
124131
</Box>
125132
</Map>
126133
)

Eplant/views/WorldEFP/MapTypeSelector.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import MapIcon from '@mui/icons-material/Map'
44
import { Box, Button, Collapse } from '@mui/material'
55
import { alpha } from '@mui/material/styles'
66

7-
import { WorldEFPState } from './types'
7+
import { MapTypeId, WorldEFPState } from './types'
88

9-
const MAP_TYPES = ['roadmap', 'satellite', 'hybrid', 'terrain'] as const
9+
const MAP_TYPES = Object.values(MapTypeId)
1010

1111
type MapTypeSelectorProps = {
1212
mapTypeId: WorldEFPState['mapTypeId']
@@ -16,12 +16,12 @@ type MapTypeSelectorProps = {
1616
const MapTypeSelector = ({ mapTypeId, onSelect }: MapTypeSelectorProps) => {
1717
const [isOpen, setIsOpen] = useState(false)
1818

19-
const handleMapTypeSelect = (nextType: WorldEFPState['mapTypeId']) => {
19+
const handleMapTypeSelect = (nextType: MapTypeId) => {
2020
onSelect(nextType)
2121
setIsOpen(false)
2222
}
2323

24-
const formatMapTypeLabel = (type: WorldEFPState['mapTypeId']) =>
24+
const formatMapTypeLabel = (type: MapTypeId) =>
2525
type.charAt(0).toUpperCase() + type.slice(1)
2626

2727
return (
@@ -79,7 +79,7 @@ const MapTypeSelector = ({ mapTypeId, onSelect }: MapTypeSelectorProps) => {
7979
<Button
8080
key={type}
8181
variant='text'
82-
onClick={() => handleMapTypeSelect(type)}
82+
onClick={() => handleMapTypeSelect(type as MapTypeId)}
8383
sx={(theme) => ({
8484
justifyContent: 'flex-start',
8585
textTransform: 'none',
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { useState } from 'react'
2+
3+
import LayersIcon from '@mui/icons-material/Layers'
4+
import { Box, Button, Collapse } from '@mui/material'
5+
import { alpha } from '@mui/material/styles'
6+
7+
import { OverlayType, WorldEFPState } from './types'
8+
9+
const OVERLAY_LABELS: Record<OverlayType, string> = {
10+
[OverlayType.None]: 'None',
11+
[OverlayType.Precipitation]: 'Annual Precip.',
12+
[OverlayType.HistoricalMinTemp]: 'Min. Temp.',
13+
[OverlayType.HistoricalMaxTemp]: 'Max. Temp.',
14+
}
15+
16+
const OVERLAY_TYPES = Object.values(OverlayType)
17+
18+
type OverlaySelectorProps = {
19+
overlay: WorldEFPState['overlay']
20+
onSelect: (overlay: WorldEFPState['overlay']) => void
21+
}
22+
23+
const OverlaySelector = ({ overlay, onSelect }: OverlaySelectorProps) => {
24+
const [isOpen, setIsOpen] = useState(false)
25+
26+
const handleSelect = (next: OverlayType) => {
27+
onSelect(next)
28+
setIsOpen(false)
29+
}
30+
31+
return (
32+
<Box
33+
sx={(theme) => ({
34+
width: isOpen ? theme.spacing(15) : theme.spacing(5),
35+
borderRadius: theme.spacing(1),
36+
backgroundColor: alpha(theme.palette.background.active, 0.7),
37+
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.18)',
38+
border: `1px solid ${alpha(theme.palette.background.active, 0.7)}`,
39+
backdropFilter: 'blur(8px)',
40+
WebkitBackdropFilter: 'blur(8px)',
41+
overflow: 'hidden',
42+
transition: 'width 220ms ease, box-shadow 220ms ease',
43+
})}
44+
>
45+
<Button
46+
variant='text'
47+
onClick={() => setIsOpen((open) => !open)}
48+
sx={(theme) => ({
49+
width: '100%',
50+
justifyContent: 'flex-start',
51+
textTransform: 'none',
52+
color:
53+
overlay !== OverlayType.None
54+
? theme.palette.primary.main
55+
: theme.palette.text.primary,
56+
padding: theme.spacing(1),
57+
'&:hover': {
58+
backgroundColor: alpha(theme.palette.background.active, 0.85),
59+
},
60+
})}
61+
>
62+
<LayersIcon fontSize='small' />
63+
<Box
64+
sx={(theme) => ({
65+
marginLeft: theme.spacing(1),
66+
opacity: isOpen ? 1 : 0,
67+
maxWidth: isOpen ? theme.spacing(12) : 0,
68+
overflow: 'hidden',
69+
whiteSpace: 'nowrap',
70+
transition: 'opacity 200ms ease, max-width 220ms ease',
71+
})}
72+
>
73+
{OVERLAY_LABELS[overlay]}
74+
</Box>
75+
</Button>
76+
<Collapse in={isOpen} timeout={200} unmountOnExit>
77+
<Box
78+
sx={(theme) => ({
79+
display: 'flex',
80+
flexDirection: 'column',
81+
gap: theme.spacing(0.5),
82+
padding: theme.spacing(0.5, 1, 1),
83+
})}
84+
>
85+
{OVERLAY_TYPES.map((type) => (
86+
<Button
87+
key={type}
88+
variant='text'
89+
onClick={() => handleSelect(type)}
90+
sx={(theme) => ({
91+
justifyContent: 'flex-start',
92+
textTransform: 'none',
93+
color: theme.palette.text.primary,
94+
padding: theme.spacing(0.5, 1),
95+
'&:hover': {
96+
backgroundColor: alpha(theme.palette.background.active, 0.45),
97+
},
98+
...(overlay === type && {
99+
fontWeight: 600,
100+
backgroundColor: alpha(theme.palette.background.active, 0.6),
101+
borderRadius: theme.spacing(0.75),
102+
'&:hover': {
103+
backgroundColor: alpha(
104+
theme.palette.background.active,
105+
0.7
106+
),
107+
},
108+
}),
109+
})}
110+
>
111+
{OVERLAY_LABELS[type]}
112+
</Button>
113+
))}
114+
</Box>
115+
</Collapse>
116+
</Box>
117+
)
118+
}
119+
120+
export default OverlaySelector
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { OverlayType } from './types'
2+
3+
export type TileMap = Record<string, string> // "zoom_x_y" -> URL
4+
5+
export type OverlayTileData = {
6+
tileMap: TileMap
7+
maxZoom: number
8+
}
9+
10+
const BASE_PATH = '/temp_world_efp'
11+
12+
/**
13+
* Builds a tileMap by constructing public URLs for all tiles in a grid up to maxZoom.
14+
* At each zoom level z the grid is 2^z x 2^z.
15+
*/
16+
function buildTileMap(dir: string, prefix: string, maxZoom: number): OverlayTileData {
17+
const tileMap: TileMap = {}
18+
for (let zoom = 0; zoom <= maxZoom; zoom++) {
19+
const count = 1 << zoom
20+
for (let x = 0; x < count; x++) {
21+
for (let y = 0; y < count; y++) {
22+
tileMap[`${zoom}_${x}_${y}`] =
23+
`${BASE_PATH}/${dir}/${prefix}&zoom=${zoom}&x=${x}&y=${y}.png`
24+
}
25+
}
26+
}
27+
return { tileMap, maxZoom }
28+
}
29+
30+
/**
31+
* Fetches overlay tile data for the given overlay type.
32+
*
33+
* Currently a mock backed by files in public/temp_world_efp.
34+
* Replace each case body with a fetch() call to the real tile API endpoint
35+
* when available — the return type stays the same.
36+
*/
37+
export async function fetchOverlayTiles(
38+
overlay: Exclude<OverlayType, OverlayType.None>
39+
): Promise<OverlayTileData> {
40+
switch (overlay) {
41+
case OverlayType.Precipitation:
42+
return buildTileMap('AnnualPrecip', 'Annual_Precipitation', 2)
43+
case OverlayType.HistoricalMinTemp:
44+
return buildTileMap('HistMin', 'Historical_Min_Temp_of_coldest_Month', 2)
45+
case OverlayType.HistoricalMaxTemp:
46+
return buildTileMap('HistMax', 'Historical_Max_temp_of_warmest_month', 2)
47+
}
48+
}

Eplant/views/WorldEFP/types.tsx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,37 @@ import { EFPData } from '../eFP/types'
44

55
export type Coordinates = { lat: number; lng: number }
66

7+
export enum MapTypeId {
8+
Roadmap = 'roadmap',
9+
Satellite = 'satellite',
10+
Hybrid = 'hybrid',
11+
Terrain = 'terrain',
12+
}
13+
14+
export enum ColorMode {
15+
Absolute = 'absolute',
16+
Relative = 'relative',
17+
}
18+
19+
export enum OverlayType {
20+
None = 'None',
21+
Precipitation = 'Precipitation',
22+
HistoricalMinTemp = 'HistoricalMinTemp',
23+
HistoricalMaxTemp = 'HistoricalMaxTemp',
24+
}
25+
726
export const WorldEFPStateSchema = z.object({
827
position: z.object({
928
lat: z.number().default(25),
1029
lng: z.number().default(0),
1130
}),
12-
zoom: z.number().min(0).max(22).default(2),
13-
mapTypeId: z
14-
.enum(['roadmap', 'satellite', 'hybrid', 'terrain'])
15-
.default('roadmap'),
31+
zoom: z.number().min(0).max(8).default(2),
32+
mapTypeId: z.nativeEnum(MapTypeId).default(MapTypeId.Roadmap),
1633
maskModalVisible: z.boolean().default(false),
1734
maskingEnabled: z.boolean().default(false),
1835
maskThreshold: z.number().min(0).max(100).default(100),
19-
colorMode: z.enum(['absolute', 'relative']).default('absolute'),
36+
colorMode: z.nativeEnum(ColorMode).default(ColorMode.Absolute),
37+
overlay: z.nativeEnum(OverlayType).default(OverlayType.None),
2038
})
2139

2240
export type WorldEFPState = z.infer<typeof WorldEFPStateSchema>
13.2 KB
Loading
15.6 KB
Loading
4.37 KB
Loading
21.1 KB
Loading

0 commit comments

Comments
 (0)