diff --git a/.cursor/rules/web-design-layout.mdc b/.cursor/rules/web-design-layout.mdc index 2d7f0cf..7e0e554 100644 --- a/.cursor/rules/web-design-layout.mdc +++ b/.cursor/rules/web-design-layout.mdc @@ -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. diff --git a/.cursor/rules/web-location-maps.mdc b/.cursor/rules/web-location-maps.mdc new file mode 100644 index 0000000..0c86983 --- /dev/null +++ b/.cursor/rules/web-location-maps.mdc @@ -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 + { + 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 + + +``` + +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 + + +// ❌ BAD — coordinates as text only + +``` + +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`) diff --git a/apps/web/src/apps/main/modules/configuration/branches/presentation/components/detail-component/detail-location.tsx b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/detail-component/detail-location.tsx index 5c8c4c9..f210188 100644 --- a/apps/web/src/apps/main/modules/configuration/branches/presentation/components/detail-component/detail-location.tsx +++ b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/detail-component/detail-location.tsx @@ -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() { {t('section_location')} - + + - + ); } diff --git a/apps/web/src/apps/main/modules/configuration/branches/presentation/components/form-component/form-location.tsx b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/form-component/form-location.tsx index 8255523..4b3f708 100644 --- a/apps/web/src/apps/main/modules/configuration/branches/presentation/components/form-component/form-location.tsx +++ b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/form-component/form-location.tsx @@ -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() { {t('section_location')} - - - - - - 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}`} - /> - - + + { + formControl.setValue('latitude', point.latitude, { shouldDirty: true, shouldValidate: true }); + formControl.setValue('longitude', point.longitude, { shouldDirty: true, shouldValidate: true }); + }} + /> + + + + + + 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}`} + /> + + + ); } diff --git a/apps/web/src/apps/main/modules/configuration/customers/presentation/components/detail-component/detail-location.tsx b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/detail-component/detail-location.tsx index df03079..606215d 100644 --- a/apps/web/src/apps/main/modules/configuration/customers/presentation/components/detail-component/detail-location.tsx +++ b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/detail-component/detail-location.tsx @@ -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() { {t('section_location')} - + + - + ); } diff --git a/apps/web/src/apps/main/modules/configuration/customers/presentation/components/form-component/form-location.tsx b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/form-component/form-location.tsx index f6a01cd..1d12c36 100644 --- a/apps/web/src/apps/main/modules/configuration/customers/presentation/components/form-component/form-location.tsx +++ b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/form-component/form-location.tsx @@ -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 ( {t('section_location')} - - - - - - + + { + formControl.setValue('latitude', point.latitude, { shouldDirty: true, shouldValidate: true }); + formControl.setValue('longitude', point.longitude, { shouldDirty: true, shouldValidate: true }); + }} + /> + + + + + + + ); } diff --git a/packages/core-i18n/src/languages/en/common.json b/packages/core-i18n/src/languages/en/common.json index 4d0c1d0..e7f5037 100644 --- a/packages/core-i18n/src/languages/en/common.json +++ b/packages/core-i18n/src/languages/en/common.json @@ -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", diff --git a/packages/core-i18n/src/languages/id/common.json b/packages/core-i18n/src/languages/id/common.json index 73792d5..e3f44f6 100644 --- a/packages/core-i18n/src/languages/id/common.json +++ b/packages/core-i18n/src/languages/id/common.json @@ -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", diff --git a/packages/ui/src/components/map/index.ts b/packages/ui/src/components/map/index.ts index 7f7b164..c23bfe2 100644 --- a/packages/ui/src/components/map/index.ts +++ b/packages/ui/src/components/map/index.ts @@ -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'; diff --git a/packages/ui/src/components/map/location-map.test.tsx b/packages/ui/src/components/map/location-map.test.tsx new file mode 100644 index 0000000..05fa327 --- /dev/null +++ b/packages/ui/src/components/map/location-map.test.tsx @@ -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 }) =>
{children}
, + TileLayer: () =>
, + CircleMarker: ({ center }: { center: [number, number] }) =>
{`${center[0]},${center[1]}`}
, + useMap: () => ({ setView, panTo, invalidateSize }), + useMapEvents: (handlers: { click?: (event: LeafletClick) => void }) => { + mapClick = handlers.click; + return null; + }, +})); + +function renderMap(ui: React.ReactElement) { + return render({ui}); +} + +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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(mapClick).toBeUndefined(); + }); + + it('centers once when coordinates first become valid', () => { + const onChange = vi.fn(); + const { rerender } = renderMap(); + expect(setView).not.toHaveBeenCalled(); + + rerender( + + + , + ); + expect(setView).toHaveBeenCalledWith([-6.2, 106.8], SELECTED_MAP_ZOOM); + expect(setView).toHaveBeenCalledTimes(1); + + rerender( + + + , + ); + 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(); + mapClick?.({ latlng: { lat: -6.2, lng: 106.8 } }); + const point = onChange.mock.calls[0][0] as { latitude: number; longitude: number }; + + rerender( + + + , + ); + expect(setView).not.toHaveBeenCalled(); + expect(panTo).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/components/map/location-map.tsx b/packages/ui/src/components/map/location-map.tsx new file mode 100644 index 0000000..8f002f8 --- /dev/null +++ b/packages/ui/src/components/map/location-map.tsx @@ -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 ( + + + {emptyLabel} + + + ); + } + + return ( + + + + + {position ? ( + + ) : null} + + {onChange ? : null} + + + {interactive && helperLabel ? ( + + {helperLabel} + + ) : null} + + ); +} diff --git a/packages/ui/src/components/map/location-point.test.ts b/packages/ui/src/components/map/location-point.test.ts new file mode 100644 index 0000000..1cd4484 --- /dev/null +++ b/packages/ui/src/components/map/location-point.test.ts @@ -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, + }); + }); +}); diff --git a/packages/ui/src/components/map/location-point.ts b/packages/ui/src/components/map/location-point.ts new file mode 100644 index 0000000..bcca7d6 --- /dev/null +++ b/packages/ui/src/components/map/location-point.ts @@ -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 }; +} diff --git a/packages/ui/src/components/map/osm.ts b/packages/ui/src/components/map/osm.ts new file mode 100644 index 0000000..440194e --- /dev/null +++ b/packages/ui/src/components/map/osm.ts @@ -0,0 +1,3 @@ +export const OSM_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; +export const OSM_ATTRIBUTION = '© OpenStreetMap contributors'; + diff --git a/packages/ui/src/components/map/route-map.tsx b/packages/ui/src/components/map/route-map.tsx index 5d9b4a4..911f724 100644 --- a/packages/ui/src/components/map/route-map.tsx +++ b/packages/ui/src/components/map/route-map.tsx @@ -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 ( - + {positions.map((position, index) => (