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:
@@ -1,4 +1,7 @@
|
||||
export { RouteMap } from './route-map';
|
||||
export type { RouteMapProps } from './route-map';
|
||||
export { LocationMap } from './location-map';
|
||||
export type { LocationMapProps } from './location-map';
|
||||
export { toLeafletLatLngs } from './route-geometry';
|
||||
export type { RouteGeometry } from './route-geometry';
|
||||
export { toLocationLatLng, locationFromLatLng } from './location-point';
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { LocationMap } from './location-map';
|
||||
import { SELECTED_MAP_ZOOM } from './location-point';
|
||||
|
||||
type LeafletClick = { latlng: { lat: number; lng: number } };
|
||||
|
||||
let mapClick: ((event: LeafletClick) => void) | undefined;
|
||||
|
||||
const { setView, panTo, invalidateSize } = vi.hoisted(() => ({
|
||||
setView: vi.fn(),
|
||||
panTo: vi.fn(),
|
||||
invalidateSize: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-leaflet', () => ({
|
||||
MapContainer: ({ children }: { children: React.ReactNode }) => <div data-testid="osm-map">{children}</div>,
|
||||
TileLayer: () => <div data-testid="osm-tiles" />,
|
||||
CircleMarker: ({ center }: { center: [number, number] }) => <div data-testid="location-marker">{`${center[0]},${center[1]}`}</div>,
|
||||
useMap: () => ({ setView, panTo, invalidateSize }),
|
||||
useMapEvents: (handlers: { click?: (event: LeafletClick) => void }) => {
|
||||
mapClick = handlers.click;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
function renderMap(ui: React.ReactElement) {
|
||||
return render(<MantineProvider>{ui}</MantineProvider>);
|
||||
}
|
||||
|
||||
describe('LocationMap', () => {
|
||||
beforeEach(() => {
|
||||
mapClick = undefined;
|
||||
setView.mockClear();
|
||||
panTo.mockClear();
|
||||
});
|
||||
|
||||
it('shows an empty state when coordinates are missing and the map is read-only', () => {
|
||||
renderMap(<LocationMap emptyLabel="No location" />);
|
||||
expect(screen.getByText('No location')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('osm-map')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders OSM tiles and a marker when coordinates are present', () => {
|
||||
renderMap(<LocationMap latitude={-6.2} longitude={106.8} />);
|
||||
expect(screen.getByTestId('osm-map')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('osm-tiles')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('location-marker')).toHaveTextContent('-6.2,106.8');
|
||||
});
|
||||
|
||||
it('always shows the map when picking is enabled, even without coordinates', () => {
|
||||
renderMap(<LocationMap onChange={vi.fn()} helperLabel="Click the map" />);
|
||||
expect(screen.getByTestId('osm-map')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('location-marker')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Click the map')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reports rounded latitude and longitude when the map is clicked', () => {
|
||||
const onChange = vi.fn();
|
||||
renderMap(<LocationMap onChange={onChange} />);
|
||||
expect(mapClick).toBeTypeOf('function');
|
||||
mapClick?.({ latlng: { lat: -6.208812345, lng: 106.8456789 } });
|
||||
expect(onChange).toHaveBeenCalledWith({ latitude: -6.208812, longitude: 106.845679 });
|
||||
});
|
||||
|
||||
it('wraps unwrapped longitudes from world-copy clicks into range', () => {
|
||||
const onChange = vi.fn();
|
||||
renderMap(<LocationMap onChange={onChange} />);
|
||||
mapClick?.({ latlng: { lat: -6.2, lng: 466.8 } });
|
||||
expect(onChange).toHaveBeenCalledWith({ latitude: -6.2, longitude: 106.8 });
|
||||
});
|
||||
|
||||
it('does not register a click handler on a read-only map', () => {
|
||||
renderMap(<LocationMap latitude={-6.2} longitude={106.8} />);
|
||||
expect(mapClick).toBeUndefined();
|
||||
});
|
||||
|
||||
it('centers once when coordinates first become valid', () => {
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = renderMap(<LocationMap onChange={onChange} />);
|
||||
expect(setView).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<LocationMap latitude={-6.2} longitude={106.8} onChange={onChange} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(setView).toHaveBeenCalledWith([-6.2, 106.8], SELECTED_MAP_ZOOM);
|
||||
expect(setView).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<LocationMap latitude={-6.3} longitude={106.9} onChange={onChange} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(panTo).toHaveBeenCalledWith([-6.3, 106.9]);
|
||||
expect(setView).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not move the view for a point that was just picked', () => {
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = renderMap(<LocationMap onChange={onChange} />);
|
||||
mapClick?.({ latlng: { lat: -6.2, lng: 106.8 } });
|
||||
const point = onChange.mock.calls[0][0] as { latitude: number; longitude: number };
|
||||
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<LocationMap latitude={point.latitude} longitude={point.longitude} onChange={onChange} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(setView).not.toHaveBeenCalled();
|
||||
expect(panTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_MAP_CENTER,
|
||||
DEFAULT_MAP_ZOOM,
|
||||
SELECTED_MAP_ZOOM,
|
||||
locationFromLatLng,
|
||||
mapViewForLocation,
|
||||
roundCoordinate,
|
||||
toFiniteNumber,
|
||||
toLocationLatLng,
|
||||
} from './location-point';
|
||||
|
||||
describe('toFiniteNumber', () => {
|
||||
it('returns finite numbers and parses numeric strings', () => {
|
||||
expect(toFiniteNumber(-6.2)).toBe(-6.2);
|
||||
expect(toFiniteNumber('106.8')).toBe(106.8);
|
||||
expect(toFiniteNumber(' 1.5 ')).toBe(1.5);
|
||||
});
|
||||
|
||||
it('returns null for empty or non-numeric values', () => {
|
||||
expect(toFiniteNumber(null)).toBeNull();
|
||||
expect(toFiniteNumber(undefined)).toBeNull();
|
||||
expect(toFiniteNumber('')).toBeNull();
|
||||
expect(toFiniteNumber(' ')).toBeNull();
|
||||
expect(toFiniteNumber('abc')).toBeNull();
|
||||
expect(toFiniteNumber(Number.NaN)).toBeNull();
|
||||
expect(toFiniteNumber(Number.POSITIVE_INFINITY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toLocationLatLng', () => {
|
||||
it('returns Leaflet [lat, lng] when both coordinates are valid', () => {
|
||||
expect(toLocationLatLng(-6.2, 106.8)).toEqual([-6.2, 106.8]);
|
||||
expect(toLocationLatLng('-6.2', '106.8')).toEqual([-6.2, 106.8]);
|
||||
});
|
||||
|
||||
it('returns null when a coordinate is missing or out of range', () => {
|
||||
expect(toLocationLatLng(undefined, 106.8)).toBeNull();
|
||||
expect(toLocationLatLng(-6.2, undefined)).toBeNull();
|
||||
expect(toLocationLatLng(-91, 106.8)).toBeNull();
|
||||
expect(toLocationLatLng(-6.2, 181)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundCoordinate', () => {
|
||||
it('rounds to 6 decimal places by default', () => {
|
||||
expect(roundCoordinate(-6.208812345)).toBe(-6.208812);
|
||||
expect(roundCoordinate(106.8456789)).toBe(106.845679);
|
||||
});
|
||||
});
|
||||
|
||||
describe('locationFromLatLng', () => {
|
||||
it('returns rounded latitude and longitude', () => {
|
||||
expect(locationFromLatLng(-6.208812345, 106.8456789)).toEqual({
|
||||
latitude: -6.208812,
|
||||
longitude: 106.845679,
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps longitude from repeated world copies back into [-180, 180]', () => {
|
||||
expect(locationFromLatLng(-6.2, 466.8)).toEqual({ latitude: -6.2, longitude: 106.8 });
|
||||
expect(locationFromLatLng(-6.2, -253.2)).toEqual({ latitude: -6.2, longitude: 106.8 });
|
||||
expect(locationFromLatLng(-6.2, 180)).toEqual({ latitude: -6.2, longitude: 180 });
|
||||
expect(locationFromLatLng(-6.2, -180)).toEqual({ latitude: -6.2, longitude: -180 });
|
||||
});
|
||||
|
||||
it('clamps latitude to the valid range', () => {
|
||||
expect(locationFromLatLng(120, 106.8).latitude).toBe(90);
|
||||
expect(locationFromLatLng(-120, 106.8).latitude).toBe(-90);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapViewForLocation', () => {
|
||||
it('uses the selected point at close zoom when coordinates exist', () => {
|
||||
expect(mapViewForLocation(-6.2, 106.8)).toEqual({
|
||||
center: [-6.2, 106.8],
|
||||
zoom: SELECTED_MAP_ZOOM,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the default Indonesia view when empty', () => {
|
||||
expect(mapViewForLocation(null, null)).toEqual({
|
||||
center: DEFAULT_MAP_CENTER,
|
||||
zoom: DEFAULT_MAP_ZOOM,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
export const DEFAULT_MAP_CENTER: [number, number] = [-6.2088, 106.8456];
|
||||
export const DEFAULT_MAP_ZOOM = 6;
|
||||
export const SELECTED_MAP_ZOOM = 15;
|
||||
export const COORDINATE_DECIMALS = 6;
|
||||
|
||||
export function toFiniteNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isValidLatitude(value: number): boolean {
|
||||
return value >= -90 && value <= 90;
|
||||
}
|
||||
|
||||
export function isValidLongitude(value: number): boolean {
|
||||
return value >= -180 && value <= 180;
|
||||
}
|
||||
|
||||
export function toLocationLatLng(latitude: unknown, longitude: unknown): [number, number] | null {
|
||||
const lat = toFiniteNumber(latitude);
|
||||
const lng = toFiniteNumber(longitude);
|
||||
if (lat === null || lng === null) return null;
|
||||
if (!isValidLatitude(lat) || !isValidLongitude(lng)) return null;
|
||||
return [lat, lng];
|
||||
}
|
||||
|
||||
export function roundCoordinate(value: number, decimals = COORDINATE_DECIMALS): number {
|
||||
const factor = 10 ** decimals;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
export function clampLatitude(value: number): number {
|
||||
return Math.min(90, Math.max(-90, value));
|
||||
}
|
||||
|
||||
// Leaflet reports clicks on repeated world copies with an unwrapped longitude (e.g. 466.8),
|
||||
// which the form schemas reject. Keep 180 and -180 as authored instead of collapsing them.
|
||||
export function wrapLongitude(value: number): number {
|
||||
if (value >= -180 && value <= 180) return value;
|
||||
const wrapped = ((value + 180) % 360 + 360) % 360 - 180;
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
export function locationFromLatLng(lat: number, lng: number): { latitude: number; longitude: number } {
|
||||
return {
|
||||
latitude: roundCoordinate(clampLatitude(lat)),
|
||||
longitude: roundCoordinate(wrapLongitude(lng)),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapViewForLocation(
|
||||
latitude: unknown,
|
||||
longitude: unknown,
|
||||
): { center: [number, number]; zoom: number } {
|
||||
const point = toLocationLatLng(latitude, longitude);
|
||||
if (!point) {
|
||||
return { center: DEFAULT_MAP_CENTER, zoom: DEFAULT_MAP_ZOOM };
|
||||
}
|
||||
return { center: point, zoom: SELECTED_MAP_ZOOM };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const OSM_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
export const OSM_ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CircleMarker, MapContainer, Polyline, TileLayer, Tooltip, useMap } from
|
||||
import { Box, Text } from '@mantine/core';
|
||||
import type { RouteGeometry } from './route-geometry';
|
||||
import { toLeafletLatLngs } from './route-geometry';
|
||||
import { OSM_ATTRIBUTION, OSM_TILE_URL } from './osm';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
function FitRouteBounds({ positions }: { positions: Array<[number, number]> }) {
|
||||
@@ -39,7 +40,7 @@ export function RouteMap({ geometry, height = 280 }: RouteMapProps) {
|
||||
return (
|
||||
<Box h={height} bdrs="md" style={{ overflow: 'hidden' }}>
|
||||
<MapContainer center={positions[0]} zoom={12} style={{ height: '100%', width: '100%' }} scrollWheelZoom>
|
||||
<TileLayer attribution="© OpenStreetMap contributors" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<TileLayer attribution={OSM_ATTRIBUTION} url={OSM_TILE_URL} />
|
||||
<Polyline positions={positions} pathOptions={{ color: 'var(--mantine-color-blue-6)', weight: 4 }} />
|
||||
{positions.map((position, index) => (
|
||||
<CircleMarker key={`${position[0]}-${position[1]}-${index}`} center={position} radius={8} pathOptions={{ color: 'var(--mantine-color-blue-8)' }}>
|
||||
|
||||
Reference in New Issue
Block a user