|
1 | 1 | // @ts-check |
2 | 2 | import * as React from 'react' |
3 | 3 | 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' |
4 | 8 | import Paper from '@mui/material/Paper' |
5 | 9 | import Table from '@mui/material/Table' |
6 | 10 | import TableBody from '@mui/material/TableBody' |
| 11 | +import TableCell from '@mui/material/TableCell' |
7 | 12 | import TableRow from '@mui/material/TableRow' |
8 | 13 | import TableContainer from '@mui/material/TableContainer' |
| 14 | +import Typography from '@mui/material/Typography' |
9 | 15 |
|
10 | 16 | import { Query } from '@services/queries' |
11 | 17 | import { useMemory } from '@store/useMemory' |
12 | 18 | import { useStorage } from '@store/useStorage' |
| 19 | +import { useMapStore } from '@store/useMapStore' |
| 20 | + |
| 21 | +/** @typedef {{ id: string, name: string, lat: number, lon: number }} JumpResult */ |
13 | 22 |
|
14 | 23 | import { AreaParent } from './Parent' |
15 | 24 | import { AreaChild } from './Child' |
16 | 25 |
|
17 | 26 | export function ScanAreasTable() { |
18 | 27 | /** @type {import('@apollo/client').QueryResult<{ scanAreasMenu: import('@rm/types').Config['areas']['scanAreasMenu'][string] }>} */ |
19 | 28 | 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], |
22 | 48 | ) |
23 | | - const { misc } = useMemory.getState().config |
24 | 49 |
|
25 | 50 | /** @type {string[]} */ |
26 | 51 | const allAreas = React.useMemo( |
@@ -64,6 +89,73 @@ export function ScanAreasTable() { |
64 | 89 | [data, search], |
65 | 90 | ) |
66 | 91 |
|
| 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 | + |
67 | 159 | if (loading || error) return null |
68 | 160 |
|
69 | 161 | return ( |
@@ -122,6 +214,61 @@ export function ScanAreasTable() { |
122 | 214 | </React.Fragment> |
123 | 215 | ) |
124 | 216 | })} |
| 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 | + )} |
125 | 272 | </TableBody> |
126 | 273 | </Table> |
127 | 274 | </TableContainer> |
|
0 commit comments