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:
shancheas
2026-08-26 07:56:04 +07:00
parent 5287da918c
commit 27dfc167d2
15 changed files with 578 additions and 62 deletions
+1
View File
@@ -27,6 +27,7 @@ Reference usage: `apps/showcase` (ui-components, forms, action-tools, shell-demo
Detail **content** layout (section stack, key-value grids, status blocks, tabs for many categories): follow `.agents/skills/detail-layout/SKILL.md` — layout/position only.
Form **content** layout: follow `.agents/skills/form-layout/SKILL.md`.
Entities with `latitude` / `longitude`: follow `web-location-maps.mdc` — form must pick coords on `LocationMap`; detail must render `LocationMap`.
Every page header (`pageHeaderProps`) should include i18n `title`, `description`, `breadcrumbs`, and Lucide `icon` when useful.
+60
View File
@@ -0,0 +1,60 @@
---
description: Entities with latitude/longitude must pick coords on OSM LocationMap in forms and render LocationMap on detail pages
globs: apps/web/src/apps/main/modules/**/*.{ts,tsx}
alwaysApply: false
---
# Location maps (lat / lng)
If an entity, DTO, or Zod schema has `latitude` and `longitude`, it is a **location entity**. Do not ship lat/lng as number fields alone.
Canonical component: `LocationMap` from `@repo/ui/map` (OpenStreetMap / Leaflet). Do not add Google Maps, Mapbox, or a second Leaflet wrapper.
Line geometry (`routeGeometry`) uses `RouteMap`, not `LocationMap`.
## Form (required)
Full-width `LocationMap` **above** the lat/lng inputs. Clicking the map writes both fields. Keep `FieldNumberInput` so values can still be typed.
```tsx
// ✅ GOOD
<LocationMap
latitude={formControl.watch('latitude')}
longitude={formControl.watch('longitude')}
helperLabel={t('common:map.pickLocation')}
onChange={(point) => {
formControl.setValue('latitude', point.latitude, { shouldDirty: true, shouldValidate: true });
formControl.setValue('longitude', point.longitude, { shouldDirty: true, shouldValidate: true });
}}
/>
// ❌ BAD — lat/lng inputs with no map picker
<FieldNumberInput name="latitude" />
<FieldNumberInput name="longitude" />
```
Layout: map is full width (form-layout compound field). Lat/lng stay a two-column pair under the map. Copy `customers` / `branches` `form-location.tsx`.
## Detail (required)
Render `LocationMap` **without** `onChange` (read-only). Pass `emptyLabel` when coords are missing. Keep `FieldValue` for the numeric pair under the map.
```tsx
// ✅ GOOD
<LocationMap
latitude={data?.latitude}
longitude={data?.longitude}
emptyLabel={t('common:map.noLocation')}
/>
// ❌ BAD — coordinates as text only
<FieldValue label={t('common:fields.latitude')} value={data?.latitude} />
```
Copy `customers` / `branches` `detail-location.tsx`.
## i18n and validation
- Helper / empty copy: `common:map.pickLocation`, `common:map.noLocation`
- Labels: `common:fields.latitude`, `common:fields.longitude`
- Keep existing Zod lat/lng range checks (`optionalLatitudeSchema` / `optionalLongitudeSchema`)
@@ -1,4 +1,5 @@
import { Box, Paper, SimpleGrid, FieldValue, Text } from '@repo/ui/components';
import { Paper, SimpleGrid, FieldValue, Stack, Text } from '@repo/ui/components';
import { LocationMap } from '@repo/ui/map';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import type { BranchEntity } from '../../../domain/entities';
@@ -13,13 +14,14 @@ export function DetailLocation() {
<Text fw={600} mb="md">
{t('section_location')}
</Text>
<Box>
<Stack gap="md">
<LocationMap latitude={data?.latitude} longitude={data?.longitude} emptyLabel={t('common:map.noLocation')} />
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
<FieldValue label={t('common:fields.latitude')} value={data?.latitude} />
<FieldValue label={t('common:fields.longitude')} value={data?.longitude} />
<FieldValue label={t('common:fields.division')} value={divisionLabel} />
</SimpleGrid>
</Box>
</Stack>
</Paper>
);
}
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { Box, FieldNumberInput, FieldAsyncSelect, Paper, SimpleGrid, Text } from '@repo/ui/components';
import { Box, FieldNumberInput, FieldAsyncSelect, Paper, SimpleGrid, Stack, Text } from '@repo/ui/components';
import { LocationMap } from '@repo/ui/map';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { loadDivisionOptions } from '../../../../shared/load-division-options';
import { divisionsDataService } from '../../../../divisions/domain/factories';
@@ -9,6 +10,8 @@ export function FormLocation() {
const { formControl } = useFormPageContext();
const { t } = useEnterpriseModuleTranslationContext();
const division = formControl.watch('division');
const latitude = formControl.watch('latitude');
const longitude = formControl.watch('longitude');
useEffect(() => {
const current = formControl.getValues('division') as DivisionEntity | null | undefined;
@@ -26,39 +29,50 @@ export function FormLocation() {
<Text fw={600} mb="md">
{t('section_location')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldNumberInput
control={formControl.control}
name="latitude"
label={t('common:fields.latitude')}
placeholder="-6.2"
decimalScale={6}
radius="md"
/>
<FieldNumberInput
control={formControl.control}
name="longitude"
label={t('common:fields.longitude')}
placeholder="106.8"
decimalScale={6}
radius="md"
/>
<FieldAsyncSelect<DivisionEntity>
control={formControl.control}
name="division"
label={t('common:fields.division')}
placeholder={t('common:fields.division')}
valueKey="id"
labelKey="name"
clearable
searchable
loadOptions={loadDivisionOptions}
defaultOptions={division ? [division] : []}
renderLabel={(item) => `${item.code} - ${item.name}`}
/>
</SimpleGrid>
</Box>
<Stack gap="md">
<LocationMap
latitude={latitude}
longitude={longitude}
helperLabel={t('common:map.pickLocation')}
onChange={(point) => {
formControl.setValue('latitude', point.latitude, { shouldDirty: true, shouldValidate: true });
formControl.setValue('longitude', point.longitude, { shouldDirty: true, shouldValidate: true });
}}
/>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldNumberInput
control={formControl.control}
name="latitude"
label={t('common:fields.latitude')}
placeholder="-6.2"
decimalScale={6}
radius="md"
/>
<FieldNumberInput
control={formControl.control}
name="longitude"
label={t('common:fields.longitude')}
placeholder="106.8"
decimalScale={6}
radius="md"
/>
<FieldAsyncSelect<DivisionEntity>
control={formControl.control}
name="division"
label={t('common:fields.division')}
placeholder={t('common:fields.division')}
valueKey="id"
labelKey="name"
clearable
searchable
loadOptions={loadDivisionOptions}
defaultOptions={division ? [division] : []}
renderLabel={(item) => `${item.code} - ${item.name}`}
/>
</SimpleGrid>
</Box>
</Stack>
</Paper>
);
}
@@ -1,4 +1,5 @@
import { Box, Paper, SimpleGrid, FieldValue, Text } from '@repo/ui/components';
import { Paper, SimpleGrid, FieldValue, Stack, Text } from '@repo/ui/components';
import { LocationMap } from '@repo/ui/map';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import type { CustomerEntity } from '../../../domain/entities';
@@ -12,12 +13,13 @@ export function DetailLocation() {
<Text fw={600} mb="md">
{t('section_location')}
</Text>
<Box>
<Stack gap="md">
<LocationMap latitude={data?.latitude} longitude={data?.longitude} emptyLabel={t('common:map.noLocation')} />
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
<FieldValue label={t('common:fields.latitude')} value={data?.latitude} />
<FieldValue label={t('common:fields.longitude')} value={data?.longitude} />
</SimpleGrid>
</Box>
</Stack>
</Paper>
);
}
@@ -1,35 +1,49 @@
import { Box, FieldNumberInput, Paper, SimpleGrid, Text } from '@repo/ui/components';
import { Box, FieldNumberInput, Paper, SimpleGrid, Stack, Text } from '@repo/ui/components';
import { LocationMap } from '@repo/ui/map';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
export function FormLocation() {
const { formControl } = useFormPageContext();
const { t } = useEnterpriseModuleTranslationContext();
const latitude = formControl.watch('latitude');
const longitude = formControl.watch('longitude');
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_location')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldNumberInput
control={formControl.control}
name="latitude"
label={t('common:fields.latitude')}
placeholder="-6.2"
decimalScale={6}
radius="md"
/>
<FieldNumberInput
control={formControl.control}
name="longitude"
label={t('common:fields.longitude')}
placeholder="106.8"
decimalScale={6}
radius="md"
/>
</SimpleGrid>
</Box>
<Stack gap="md">
<LocationMap
latitude={latitude}
longitude={longitude}
helperLabel={t('common:map.pickLocation')}
onChange={(point) => {
formControl.setValue('latitude', point.latitude, { shouldDirty: true, shouldValidate: true });
formControl.setValue('longitude', point.longitude, { shouldDirty: true, shouldValidate: true });
}}
/>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldNumberInput
control={formControl.control}
name="latitude"
label={t('common:fields.latitude')}
placeholder="-6.2"
decimalScale={6}
radius="md"
/>
<FieldNumberInput
control={formControl.control}
name="longitude"
label={t('common:fields.longitude')}
placeholder="106.8"
decimalScale={6}
radius="md"
/>
</SimpleGrid>
</Box>
</Stack>
</Paper>
);
}
@@ -220,6 +220,10 @@
"from": "From",
"to": "To"
},
"map": {
"pickLocation": "Click the map to set latitude and longitude",
"noLocation": "No location"
},
"systemPages": {
"comingSoon": {
"title": "Coming Soon",
@@ -220,6 +220,10 @@
"from": "Dari",
"to": "Sampai"
},
"map": {
"pickLocation": "Klik peta untuk mengatur latitude dan longitude",
"noLocation": "Tidak ada lokasi"
},
"systemPages": {
"comingSoon": {
"title": "Segera Hadir",
+3
View File
@@ -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 };
}
+3
View File
@@ -0,0 +1,3 @@
export const OSM_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
export const OSM_ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
+2 -1
View File
@@ -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="&copy; 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)' }}>