perf: code-split maplibre-gl in AddressMapPicker

This commit is contained in:
Kirill
2026-05-24 19:37:43 +05:00
parent 261233442e
commit 3b10d8764d
3 changed files with 181 additions and 108 deletions
@@ -1,30 +1,24 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import CircularProgress from '@mui/material/CircularProgress' import CircularProgress from '@mui/material/CircularProgress'
import IconButton from '@mui/material/IconButton'
import List from '@mui/material/List' import List from '@mui/material/List'
import ListItemButton from '@mui/material/ListItemButton' import ListItemButton from '@mui/material/ListItemButton'
import ListItemText from '@mui/material/ListItemText' import ListItemText from '@mui/material/ListItemText'
import Stack from '@mui/material/Stack' import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import * as maplibregl from 'maplibre-gl'
import Map, { Marker, type MapMouseEvent, type MapRef } from 'react-map-gl/maplibre'
import { reverseGeocode, searchPlaces } from '../api/map-geocoding' import { reverseGeocode, searchPlaces } from '../api/map-geocoding'
import type { LatLng, NominatimItem } from '../model/types' import type { LatLng, NominatimItem } from '../model/types'
import { MapPickerMap } from './MapPickerMap'
export function AddressMapPicker(props: { export function AddressMapPicker(props: {
value: { lat: number; lng: number } | null value: { lat: number; lng: number } | null
onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void
}) { }) {
const { value, onChange } = props const { value, onChange } = props
const mapRef = useRef<MapRef | null>(null)
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [searching, setSearching] = useState(false) const [searching, setSearching] = useState(false)
const [locating, setLocating] = useState(false)
const [results, setResults] = useState<NominatimItem[]>([]) const [results, setResults] = useState<NominatimItem[]>([])
const [hint, setHint] = useState<string | null>(null) const [hint, setHint] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(null) const abortRef = useRef<AbortController | null>(null)
@@ -36,7 +30,7 @@ export function AddressMapPicker(props: {
const center = useMemo(() => { const center = useMemo(() => {
if (value) return { lat: value.lat, lng: value.lng } if (value) return { lat: value.lat, lng: value.lng }
return { lat: 55.751244, lng: 37.618423 } // Москва (fallback) return { lat: 55.751244, lng: 37.618423 }
}, [value]) }, [value])
const pick = async (pos: LatLng) => { const pick = async (pos: LatLng) => {
@@ -60,7 +54,6 @@ export function AddressMapPicker(props: {
} }
const t = window.setTimeout(async () => { const t = window.setTimeout(async () => {
// throttle: не чаще 1 запроса в 900ms
const now = Date.now() const now = Date.now()
if (now - lastRequestAtRef.current < 900) return if (now - lastRequestAtRef.current < 900) return
if (s === lastQueryRef.current) return if (s === lastQueryRef.current) return
@@ -128,7 +121,6 @@ export function AddressMapPicker(props: {
const lat = Number(r.lat) const lat = Number(r.lat)
const lng = Number(r.lon) const lng = Number(r.lon)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
mapRef.current?.flyTo({ center: [lng, lat], zoom: 13, duration: 800 })
void pick({ lat, lng }) void pick({ lat, lng })
}} }}
> >
@@ -138,103 +130,7 @@ export function AddressMapPicker(props: {
</List> </List>
)} )}
<Box <MapPickerMap value={value} onChange={onChange} center={center} />
sx={{
height: 280,
borderRadius: 2,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
position: 'relative',
}}
>
<Map
mapLib={maplibregl}
ref={mapRef}
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>
<Tooltip title="Моё местоположение">
<span>
<IconButton
size="small"
disabled={locating || !('geolocation' in navigator)}
onClick={() => {
if (!('geolocation' in navigator)) return
setLocating(true)
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude
const lng = pos.coords.longitude
mapRef.current?.flyTo({ center: [lng, lat], zoom: 15, duration: 800 })
void pick({ lat, lng })
setLocating(false)
},
() => {
setLocating(false)
},
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 60_000 },
)
}}
sx={{
position: 'absolute',
top: 10,
right: 10,
bgcolor: 'background.paper',
border: 1,
borderColor: 'divider',
boxShadow: 2,
'&:hover': { bgcolor: 'background.paper' },
}}
aria-label="Моё местоположение"
>
{locating ? <CircularProgress size={16} /> : <MyLocationOutlinedIcon fontSize="small" />}
</IconButton>
</span>
</Tooltip>
</Box>
<Box sx={{ minHeight: 32, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <Box sx={{ minHeight: 32, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{hint && ( {hint && (
@@ -0,0 +1,178 @@
import { useEffect, useRef, useState } from 'react'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import Map, { Marker, type MapMouseEvent, type MapRef } from 'react-map-gl/maplibre'
import type * as maplibregl from 'maplibre-gl'
import { reverseGeocode } from '../api/map-geocoding'
import type { LatLng } from '../model/types'
let maplibreglPromise: Promise<typeof maplibregl> | null = null
function loadMaplibre() {
if (!maplibreglPromise) {
maplibreglPromise = Promise.all([
import('maplibre-gl'),
import('maplibre-gl/dist/maplibre-gl.css'),
]).then(([mod]) => mod)
}
return maplibreglPromise
}
type MapPickerMapProps = {
value: { lat: number; lng: number } | null
onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void
center: { lat: number; lng: number }
}
export function MapPickerMap({ value, onChange, center }: MapPickerMapProps) {
const mapRef = useRef<MapRef | null>(null)
const [maplibre, setMaplibre] = useState<typeof maplibregl | null>(null)
const [loading, setLoading] = useState(true)
const [locating, setLocating] = useState(false)
useEffect(() => {
let cancelled = false
loadMaplibre().then((mod) => {
if (!cancelled) {
setMaplibre(mod)
setLoading(false)
}
})
return () => {
cancelled = true
}
}, [])
const pick = async (pos: LatLng) => {
onChange({ lat: pos.lat, lng: pos.lng })
try {
const addr = await reverseGeocode(pos)
if (addr) {
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
}
} catch {
// ignore
}
}
if (loading || !maplibre) {
return (
<Box
sx={{
height: 280,
borderRadius: 2,
border: 1,
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress size={24} />
</Box>
)
}
return (
<Box
sx={{
height: 280,
borderRadius: 2,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
position: 'relative',
}}
>
<Map
mapLib={maplibre}
ref={mapRef}
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>
<Tooltip title="Моё местоположение">
<span>
<IconButton
size="small"
disabled={locating || !('geolocation' in navigator)}
onClick={() => {
if (!('geolocation' in navigator)) return
setLocating(true)
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude
const lng = pos.coords.longitude
mapRef.current?.flyTo({ center: [lng, lat], zoom: 15, duration: 800 })
void pick({ lat, lng })
setLocating(false)
},
() => {
setLocating(false)
},
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 60_000 },
)
}}
sx={{
position: 'absolute',
top: 10,
right: 10,
bgcolor: 'background.paper',
border: 1,
borderColor: 'divider',
boxShadow: 2,
'&:hover': { bgcolor: 'background.paper' },
}}
aria-label="Моё местоположение"
>
{locating ? <CircularProgress size={16} /> : <MyLocationOutlinedIcon fontSize="small" />}
</IconButton>
</span>
</Tooltip>
</Box>
)
}
-1
View File
@@ -2,7 +2,6 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { App } from '@/app/App' import { App } from '@/app/App'
import '@/app/styles/global.css' import '@/app/styles/global.css'
import 'maplibre-gl/dist/maplibre-gl.css'
import { readStoredToken, tokenSet } from '@/shared/model/auth' import { readStoredToken, tokenSet } from '@/shared/model/auth'
tokenSet(readStoredToken()) tokenSet(readStoredToken())