Skip to content

Commit 179ca24

Browse files
authored
feat: jump to area (#1149)
1 parent f10750b commit 179ca24

File tree

2 files changed

+151
-3
lines changed

2 files changed

+151
-3
lines changed

packages/locales/lib/human/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"pokemon": "Pokémon",
6666
"wayfarer": "Wayfarer",
6767
"scan_areas": "Scan Areas",
68+
"jump_to_areas_attribution": "Search powered by OpenStreetMap",
6869
"s2cells": "S2 Cells",
6970
"weather": "Weather",
7071
"admin": "Admin",

src/features/drawer/areas/AreaTable.jsx

Lines changed: 150 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,51 @@
11
// @ts-check
22
import * as React from 'react'
33
import { useQuery } from '@apollo/client'
4+
import { useTranslation } from 'react-i18next'
5+
import Box from '@mui/material/Box'
6+
import Button from '@mui/material/Button'
7+
import CircularProgress from '@mui/material/CircularProgress'
48
import Paper from '@mui/material/Paper'
59
import Table from '@mui/material/Table'
610
import TableBody from '@mui/material/TableBody'
11+
import TableCell from '@mui/material/TableCell'
712
import TableRow from '@mui/material/TableRow'
813
import TableContainer from '@mui/material/TableContainer'
14+
import Typography from '@mui/material/Typography'
915

1016
import { Query } from '@services/queries'
1117
import { useMemory } from '@store/useMemory'
1218
import { useStorage } from '@store/useStorage'
19+
import { useMapStore } from '@store/useMapStore'
20+
21+
/** @typedef {{ id: string, name: string, lat: number, lon: number }} JumpResult */
1322

1423
import { AreaParent } from './Parent'
1524
import { AreaChild } from './Child'
1625

1726
export function ScanAreasTable() {
1827
/** @type {import('@apollo/client').QueryResult<{ scanAreasMenu: import('@rm/types').Config['areas']['scanAreasMenu'][string] }>} */
1928
const { data, loading, error } = useQuery(Query.scanAreasMenu())
20-
const search = useStorage(
21-
(s) => s.filters.scanAreas?.filter?.search?.toLowerCase() || '',
29+
const { t, i18n } = useTranslation()
30+
const rawSearch = useStorage((s) => s.filters.scanAreas?.filter?.search || '')
31+
const search = React.useMemo(() => rawSearch.toLowerCase(), [rawSearch])
32+
const trimmedSearch = React.useMemo(() => rawSearch.trim(), [rawSearch])
33+
const { misc, general } = useMemory.getState().config
34+
const jumpZoom = general?.scanAreasZoom || general?.startZoom || 12
35+
/** @type {[JumpResult[], React.Dispatch<React.SetStateAction<JumpResult[]>>]} */
36+
const [jumpResults, setJumpResults] = React.useState([])
37+
const [jumpLoading, setJumpLoading] = React.useState(false)
38+
const [jumpError, setJumpError] = React.useState(false)
39+
40+
const handleJump = React.useCallback(
41+
(target) => {
42+
const mapInstance = useMapStore.getState().map
43+
if (mapInstance) {
44+
mapInstance.flyTo([target.lat, target.lon], jumpZoom)
45+
}
46+
},
47+
[jumpZoom],
2248
)
23-
const { misc } = useMemory.getState().config
2449

2550
/** @type {string[]} */
2651
const allAreas = React.useMemo(
@@ -64,6 +89,73 @@ export function ScanAreasTable() {
6489
[data, search],
6590
)
6691

92+
const totalMatches = React.useMemo(
93+
() => allRows.reduce((sum, area) => sum + area.children.length, 0),
94+
[allRows],
95+
)
96+
97+
const showJumpResults =
98+
trimmedSearch.length >= 3 && totalMatches === 0 && !loading && !error
99+
100+
React.useEffect(() => {
101+
if (!showJumpResults) {
102+
if (trimmedSearch.length < 3) {
103+
setJumpResults([])
104+
}
105+
setJumpLoading(false)
106+
setJumpError(false)
107+
return
108+
}
109+
110+
setJumpResults([])
111+
setJumpLoading(true)
112+
const controller = new AbortController()
113+
const timer = window.setTimeout(() => {
114+
fetch(
115+
`https://nominatim.openstreetmap.org/search?format=json&limit=5&q=${encodeURIComponent(trimmedSearch)}&accept-language=${encodeURIComponent(i18n.language || 'en')}`,
116+
{ signal: controller.signal, headers: { Accept: 'application/json' } },
117+
)
118+
.then((res) => {
119+
if (!res.ok) throw new Error('Nominatim request failed')
120+
return res.json()
121+
})
122+
.then((json) => {
123+
if (!Array.isArray(json)) {
124+
setJumpResults([])
125+
return
126+
}
127+
setJumpResults(
128+
json
129+
.slice(0, 5)
130+
.map((item) => ({
131+
id: String(item.place_id),
132+
name: item.display_name,
133+
lat: Number(item.lat),
134+
lon: Number(item.lon),
135+
}))
136+
.filter(
137+
(item) =>
138+
Number.isFinite(item.lat) && Number.isFinite(item.lon),
139+
),
140+
)
141+
setJumpError(false)
142+
})
143+
.catch((err) => {
144+
if (err.name === 'AbortError') return
145+
setJumpResults([])
146+
setJumpError(true)
147+
})
148+
.finally(() => {
149+
setJumpLoading(false)
150+
})
151+
}, 400)
152+
153+
return () => {
154+
clearTimeout(timer)
155+
controller.abort()
156+
}
157+
}, [showJumpResults, trimmedSearch, i18n.language])
158+
67159
if (loading || error) return null
68160

69161
return (
@@ -122,6 +214,61 @@ export function ScanAreasTable() {
122214
</React.Fragment>
123215
)
124216
})}
217+
{showJumpResults && (
218+
<TableRow>
219+
<TableCell colSpan={2}>
220+
<Box display="flex" flexDirection="column" gap={1}>
221+
{jumpLoading ? (
222+
<Box display="flex" alignItems="center" gap={1}>
223+
<CircularProgress size={16} />
224+
<Typography variant="caption">
225+
{t('searching')}
226+
</Typography>
227+
</Box>
228+
) : jumpError ? (
229+
<Typography variant="caption" color="error">
230+
{t('local_error')}
231+
</Typography>
232+
) : jumpResults.length ? (
233+
jumpResults.map((result) => (
234+
<Button
235+
key={result.id}
236+
variant="outlined"
237+
color="secondary"
238+
onClick={() => handleJump(result)}
239+
sx={{
240+
justifyContent: 'flex-start',
241+
textTransform: 'none',
242+
width: '100%',
243+
whiteSpace: 'normal',
244+
lineHeight: 1.3,
245+
textAlign: 'left',
246+
}}
247+
>
248+
<Typography
249+
variant="body2"
250+
component="span"
251+
sx={{
252+
textAlign: 'left',
253+
width: '100%',
254+
overflowWrap: 'anywhere',
255+
wordBreak: 'break-word',
256+
}}
257+
>
258+
{result.name}
259+
</Typography>
260+
</Button>
261+
))
262+
) : (
263+
<Typography variant="caption">{t('no_options')}</Typography>
264+
)}
265+
<Typography variant="caption" color="text.secondary">
266+
{t('jump_to_areas_attribution')}
267+
</Typography>
268+
</Box>
269+
</TableCell>
270+
</TableRow>
271+
)}
125272
</TableBody>
126273
</Table>
127274
</TableContainer>

0 commit comments

Comments
 (0)