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:
@@ -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.
|
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`.
|
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.
|
Every page header (`pageHeaderProps`) should include i18n `title`, `description`, `breadcrumbs`, and Lucide `icon` when useful.
|
||||||
|
|
||||||
|
|||||||
@@ -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`)
|
||||||
+5
-3
@@ -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 { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
import type { BranchEntity } from '../../../domain/entities';
|
import type { BranchEntity } from '../../../domain/entities';
|
||||||
|
|
||||||
@@ -13,13 +14,14 @@ export function DetailLocation() {
|
|||||||
<Text fw={600} mb="md">
|
<Text fw={600} mb="md">
|
||||||
{t('section_location')}
|
{t('section_location')}
|
||||||
</Text>
|
</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">
|
<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.latitude')} value={data?.latitude} />
|
||||||
<FieldValue label={t('common:fields.longitude')} value={data?.longitude} />
|
<FieldValue label={t('common:fields.longitude')} value={data?.longitude} />
|
||||||
<FieldValue label={t('common:fields.division')} value={divisionLabel} />
|
<FieldValue label={t('common:fields.division')} value={divisionLabel} />
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Box>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
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 { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||||
import { loadDivisionOptions } from '../../../../shared/load-division-options';
|
import { loadDivisionOptions } from '../../../../shared/load-division-options';
|
||||||
import { divisionsDataService } from '../../../../divisions/domain/factories';
|
import { divisionsDataService } from '../../../../divisions/domain/factories';
|
||||||
@@ -9,6 +10,8 @@ export function FormLocation() {
|
|||||||
const { formControl } = useFormPageContext();
|
const { formControl } = useFormPageContext();
|
||||||
const { t } = useEnterpriseModuleTranslationContext();
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
const division = formControl.watch('division');
|
const division = formControl.watch('division');
|
||||||
|
const latitude = formControl.watch('latitude');
|
||||||
|
const longitude = formControl.watch('longitude');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const current = formControl.getValues('division') as DivisionEntity | null | undefined;
|
const current = formControl.getValues('division') as DivisionEntity | null | undefined;
|
||||||
@@ -26,6 +29,16 @@ export function FormLocation() {
|
|||||||
<Text fw={600} mb="md">
|
<Text fw={600} mb="md">
|
||||||
{t('section_location')}
|
{t('section_location')}
|
||||||
</Text>
|
</Text>
|
||||||
|
<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>
|
<Box>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
<FieldNumberInput
|
<FieldNumberInput
|
||||||
@@ -59,6 +72,7 @@ export function FormLocation() {
|
|||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -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 { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
import type { CustomerEntity } from '../../../domain/entities';
|
import type { CustomerEntity } from '../../../domain/entities';
|
||||||
|
|
||||||
@@ -12,12 +13,13 @@ export function DetailLocation() {
|
|||||||
<Text fw={600} mb="md">
|
<Text fw={600} mb="md">
|
||||||
{t('section_location')}
|
{t('section_location')}
|
||||||
</Text>
|
</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">
|
<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.latitude')} value={data?.latitude} />
|
||||||
<FieldValue label={t('common:fields.longitude')} value={data?.longitude} />
|
<FieldValue label={t('common:fields.longitude')} value={data?.longitude} />
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Box>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -1,15 +1,28 @@
|
|||||||
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';
|
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||||
|
|
||||||
export function FormLocation() {
|
export function FormLocation() {
|
||||||
const { formControl } = useFormPageContext();
|
const { formControl } = useFormPageContext();
|
||||||
const { t } = useEnterpriseModuleTranslationContext();
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
const latitude = formControl.watch('latitude');
|
||||||
|
const longitude = formControl.watch('longitude');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||||
<Text fw={600} mb="md">
|
<Text fw={600} mb="md">
|
||||||
{t('section_location')}
|
{t('section_location')}
|
||||||
</Text>
|
</Text>
|
||||||
|
<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>
|
<Box>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
<FieldNumberInput
|
<FieldNumberInput
|
||||||
@@ -30,6 +43,7 @@ export function FormLocation() {
|
|||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,6 +220,10 @@
|
|||||||
"from": "From",
|
"from": "From",
|
||||||
"to": "To"
|
"to": "To"
|
||||||
},
|
},
|
||||||
|
"map": {
|
||||||
|
"pickLocation": "Click the map to set latitude and longitude",
|
||||||
|
"noLocation": "No location"
|
||||||
|
},
|
||||||
"systemPages": {
|
"systemPages": {
|
||||||
"comingSoon": {
|
"comingSoon": {
|
||||||
"title": "Coming Soon",
|
"title": "Coming Soon",
|
||||||
|
|||||||
@@ -220,6 +220,10 @@
|
|||||||
"from": "Dari",
|
"from": "Dari",
|
||||||
"to": "Sampai"
|
"to": "Sampai"
|
||||||
},
|
},
|
||||||
|
"map": {
|
||||||
|
"pickLocation": "Klik peta untuk mengatur latitude dan longitude",
|
||||||
|
"noLocation": "Tidak ada lokasi"
|
||||||
|
},
|
||||||
"systemPages": {
|
"systemPages": {
|
||||||
"comingSoon": {
|
"comingSoon": {
|
||||||
"title": "Segera Hadir",
|
"title": "Segera Hadir",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
export { RouteMap } from './route-map';
|
export { RouteMap } from './route-map';
|
||||||
export type { RouteMapProps } 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 { toLeafletLatLngs } from './route-geometry';
|
||||||
export type { RouteGeometry } 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 { Box, Text } from '@mantine/core';
|
||||||
import type { RouteGeometry } from './route-geometry';
|
import type { RouteGeometry } from './route-geometry';
|
||||||
import { toLeafletLatLngs } from './route-geometry';
|
import { toLeafletLatLngs } from './route-geometry';
|
||||||
|
import { OSM_ATTRIBUTION, OSM_TILE_URL } from './osm';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
function FitRouteBounds({ positions }: { positions: Array<[number, number]> }) {
|
function FitRouteBounds({ positions }: { positions: Array<[number, number]> }) {
|
||||||
@@ -39,7 +40,7 @@ export function RouteMap({ geometry, height = 280 }: RouteMapProps) {
|
|||||||
return (
|
return (
|
||||||
<Box h={height} bdrs="md" style={{ overflow: 'hidden' }}>
|
<Box h={height} bdrs="md" style={{ overflow: 'hidden' }}>
|
||||||
<MapContainer center={positions[0]} zoom={12} style={{ height: '100%', width: '100%' }} scrollWheelZoom>
|
<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 }} />
|
<Polyline positions={positions} pathOptions={{ color: 'var(--mantine-color-blue-6)', weight: 4 }} />
|
||||||
{positions.map((position, index) => (
|
{positions.map((position, index) => (
|
||||||
<CircleMarker key={`${position[0]}-${position[1]}-${index}`} center={position} radius={8} pathOptions={{ color: 'var(--mantine-color-blue-8)' }}>
|
<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