feat: implement location management with interactive maps
- Introduced a new `LocationMap` component for handling latitude and longitude inputs in forms and detail views. - Updated form and detail components to utilize the `LocationMap`, enhancing user experience by allowing map-based location selection. - Added internationalization support for map-related labels in English and Indonesian. - Developed unit tests for the `LocationMap` to ensure functionality and reliability. This commit enhances the application by providing a user-friendly interface for managing geographical locations, improving data accuracy and user interaction.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react';
|
||||
import { CircleMarker, MapContainer, TileLayer, useMap, useMapEvents } from 'react-leaflet';
|
||||
import { Box, Text } from '@mantine/core';
|
||||
import { OSM_ATTRIBUTION, OSM_TILE_URL } from './osm';
|
||||
import {
|
||||
DEFAULT_MAP_CENTER,
|
||||
DEFAULT_MAP_ZOOM,
|
||||
locationFromLatLng,
|
||||
SELECTED_MAP_ZOOM,
|
||||
toLocationLatLng,
|
||||
} from './location-point';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
export interface LocationMapProps {
|
||||
latitude?: unknown;
|
||||
longitude?: unknown;
|
||||
onChange?: (point: { latitude: number; longitude: number }) => void;
|
||||
height?: number;
|
||||
emptyLabel?: string;
|
||||
helperLabel?: string;
|
||||
defaultCenter?: [number, number];
|
||||
defaultZoom?: number;
|
||||
tileUrl?: string;
|
||||
attribution?: string;
|
||||
}
|
||||
|
||||
function SyncMapView({
|
||||
latitude,
|
||||
longitude,
|
||||
lastPickedRef,
|
||||
}: {
|
||||
latitude: unknown;
|
||||
longitude: unknown;
|
||||
lastPickedRef: RefObject<{ latitude: number; longitude: number } | null>;
|
||||
}) {
|
||||
const map = useMap();
|
||||
const point = toLocationLatLng(latitude, longitude);
|
||||
const lat = point?.[0] ?? null;
|
||||
const lng = point?.[1] ?? null;
|
||||
const hasCentered = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => map.invalidateSize(), 0);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [map]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lat === null || lng === null) return;
|
||||
const picked = lastPickedRef.current;
|
||||
if (picked && picked.latitude === lat && picked.longitude === lng) {
|
||||
return;
|
||||
}
|
||||
if (!hasCentered.current) {
|
||||
hasCentered.current = true;
|
||||
map.setView([lat, lng], SELECTED_MAP_ZOOM);
|
||||
return;
|
||||
}
|
||||
map.panTo([lat, lng]);
|
||||
}, [map, lat, lng, lastPickedRef]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function MapClickPicker({ onPick }: { onPick: (lat: number, lng: number) => void }) {
|
||||
const onPickRef = useRef(onPick);
|
||||
onPickRef.current = onPick;
|
||||
const handlers = useMemo(
|
||||
() => ({
|
||||
click(event: { latlng: { lat: number; lng: number } }) {
|
||||
onPickRef.current(event.latlng.lat, event.latlng.lng);
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
useMapEvents(handlers);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function LocationMap({
|
||||
latitude,
|
||||
longitude,
|
||||
onChange,
|
||||
height = 280,
|
||||
emptyLabel = 'No location',
|
||||
helperLabel,
|
||||
defaultCenter = DEFAULT_MAP_CENTER,
|
||||
defaultZoom = DEFAULT_MAP_ZOOM,
|
||||
tileUrl = OSM_TILE_URL,
|
||||
attribution = OSM_ATTRIBUTION,
|
||||
}: LocationMapProps) {
|
||||
const interactive = Boolean(onChange);
|
||||
const position = toLocationLatLng(latitude, longitude);
|
||||
const lastPickedRef = useRef<{ latitude: number; longitude: number } | null>(null);
|
||||
const center = position ?? defaultCenter;
|
||||
const zoom = position ? SELECTED_MAP_ZOOM : defaultZoom;
|
||||
|
||||
const handlePick = useCallback(
|
||||
(lat: number, lng: number) => {
|
||||
const point = locationFromLatLng(lat, lng);
|
||||
lastPickedRef.current = point;
|
||||
onChange?.(point);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
if (!interactive && !position) {
|
||||
return (
|
||||
<Box h={height} bdrs="md" bd="1px solid var(--mantine-color-default-border)" p="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{emptyLabel}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
h={height}
|
||||
bdrs="md"
|
||||
bd="1px solid var(--mantine-color-default-border)"
|
||||
style={{ overflow: 'hidden', cursor: interactive ? 'crosshair' : undefined }}
|
||||
>
|
||||
<MapContainer center={center} zoom={zoom} style={{ height: '100%', width: '100%' }} scrollWheelZoom={interactive}>
|
||||
<TileLayer attribution={attribution} url={tileUrl} />
|
||||
{position ? (
|
||||
<CircleMarker center={position} radius={10} pathOptions={{ color: 'var(--mantine-color-blue-8)', fillOpacity: 0.9 }} />
|
||||
) : null}
|
||||
<SyncMapView latitude={latitude} longitude={longitude} lastPickedRef={lastPickedRef} />
|
||||
{onChange ? <MapClickPicker onPick={handlePick} /> : null}
|
||||
</MapContainer>
|
||||
</Box>
|
||||
{interactive && helperLabel ? (
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{helperLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user