base commit
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import List from '@mui/material/List'
|
||||
import ListItemButton from '@mui/material/ListItemButton'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import Map, { Marker } from 'react-map-gl/maplibre'
|
||||
import type { MapMouseEvent } from 'react-map-gl/maplibre'
|
||||
|
||||
type NominatimItem = { display_name: string; lat: string; lon: string }
|
||||
|
||||
async function reverseGeocode(pos: { lat: number; lng: number }): Promise<string | null> {
|
||||
const url = new URL('https://nominatim.openstreetmap.org/reverse')
|
||||
url.searchParams.set('format', 'jsonv2')
|
||||
url.searchParams.set('lat', String(pos.lat))
|
||||
url.searchParams.set('lon', String(pos.lng))
|
||||
url.searchParams.set('accept-language', 'ru')
|
||||
const res = await fetch(url.toString(), { headers: { 'User-Agent': 'craftshop-demo' } })
|
||||
if (!res.ok) return null
|
||||
const data = (await res.json()) as { display_name?: string }
|
||||
return data.display_name ? String(data.display_name) : null
|
||||
}
|
||||
|
||||
type LatLng = { lat: number; lng: number }
|
||||
|
||||
async function searchPlaces(q: string, signal?: AbortSignal): Promise<NominatimItem[]> {
|
||||
const url = new URL('https://nominatim.openstreetmap.org/search')
|
||||
url.searchParams.set('format', 'jsonv2')
|
||||
url.searchParams.set('q', q)
|
||||
url.searchParams.set('accept-language', 'ru')
|
||||
url.searchParams.set('limit', '5')
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { 'User-Agent': 'craftshop-demo' },
|
||||
signal,
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = (await res.json()) as NominatimItem[]
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
export function AddressMapPicker(props: {
|
||||
value: { lat: number; lng: number } | null
|
||||
onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void
|
||||
}) {
|
||||
const { value, onChange } = props
|
||||
const [q, setQ] = useState('')
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [results, setResults] = useState<NominatimItem[]>([])
|
||||
const [hint, setHint] = useState<string | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const lastQueryRef = useRef<string>('')
|
||||
const lastRequestAtRef = useRef<number>(0)
|
||||
|
||||
const qTrimmed = q.trim()
|
||||
const visibleResults = qTrimmed.length >= 3 ? results : []
|
||||
|
||||
const center = useMemo(() => {
|
||||
if (value) return { lat: value.lat, lng: value.lng }
|
||||
return { lat: 55.751244, lng: 37.618423 } // Москва (fallback)
|
||||
}, [value])
|
||||
|
||||
const pick = async (pos: LatLng) => {
|
||||
setHint(null)
|
||||
onChange({ lat: pos.lat, lng: pos.lng })
|
||||
try {
|
||||
const addr = await reverseGeocode(pos)
|
||||
if (addr) {
|
||||
setHint(addr)
|
||||
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const s = qTrimmed
|
||||
if (s.length < 3) {
|
||||
return
|
||||
}
|
||||
|
||||
const t = window.setTimeout(async () => {
|
||||
// throttle: не чаще 1 запроса в 900ms
|
||||
const now = Date.now()
|
||||
if (now - lastRequestAtRef.current < 900) return
|
||||
if (s === lastQueryRef.current) return
|
||||
|
||||
lastQueryRef.current = s
|
||||
lastRequestAtRef.current = now
|
||||
|
||||
abortRef.current?.abort()
|
||||
const ac = new AbortController()
|
||||
abortRef.current = ac
|
||||
|
||||
setSearching(true)
|
||||
try {
|
||||
setResults(await searchPlaces(s, ac.signal))
|
||||
} catch (e) {
|
||||
if ((e as { name?: string })?.name !== 'AbortError') {
|
||||
setResults([])
|
||||
}
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}, 450)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(t)
|
||||
}
|
||||
}, [qTrimmed])
|
||||
|
||||
return (
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Выбор на карте</Typography>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
||||
<TextField size="small" label="Найти адрес" value={q} onChange={(e) => setQ(e.target.value)} fullWidth />
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={async () => {
|
||||
const s = q.trim()
|
||||
if (!s) return
|
||||
abortRef.current?.abort()
|
||||
const ac = new AbortController()
|
||||
abortRef.current = ac
|
||||
setSearching(true)
|
||||
try {
|
||||
lastQueryRef.current = s
|
||||
lastRequestAtRef.current = Date.now()
|
||||
setResults(await searchPlaces(s, ac.signal))
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}}
|
||||
disabled={searching || !q.trim()}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
{searching ? <CircularProgress size={18} /> : 'Найти'}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{visibleResults.length > 0 && (
|
||||
<List dense sx={{ border: 1, borderColor: 'divider', borderRadius: 2 }}>
|
||||
{visibleResults.map((r) => (
|
||||
<ListItemButton
|
||||
key={`${r.lat}:${r.lon}:${r.display_name}`}
|
||||
onClick={() => {
|
||||
const lat = Number(r.lat)
|
||||
const lng = Number(r.lon)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
|
||||
void pick({ lat, lng })
|
||||
}}
|
||||
>
|
||||
<ListItemText primary={r.display_name} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
height: 280,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Map
|
||||
mapLib={maplibregl}
|
||||
initialViewState={{ latitude: center.lat, longitude: center.lng, zoom: 12 }}
|
||||
style={{ width: '100%', height: 280 }}
|
||||
mapStyle={{
|
||||
version: 8,
|
||||
sources: {
|
||||
osm: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
|
||||
}}
|
||||
onClick={(e: MapMouseEvent) => {
|
||||
const { lng, lat } = e.lngLat
|
||||
void pick({ lat, lng })
|
||||
}}
|
||||
>
|
||||
{value && (
|
||||
<Marker
|
||||
longitude={value.lng}
|
||||
latitude={value.lat}
|
||||
draggable
|
||||
onDragEnd={(e) => {
|
||||
const { lng, lat } = e.lngLat
|
||||
void pick({ lat, lng })
|
||||
}}
|
||||
anchor="bottom"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
bgcolor: 'primary.main',
|
||||
borderRadius: '50%',
|
||||
border: 2,
|
||||
borderColor: 'background.paper',
|
||||
boxShadow: 3,
|
||||
}}
|
||||
/>
|
||||
</Marker>
|
||||
)}
|
||||
</Map>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ minHeight: 18 }}>
|
||||
{hint && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Подсказка адреса: {hint}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user