diff --git a/frontend/src/lib/components/BaseMap.svelte b/frontend/src/lib/components/BaseMap.svelte index b5eb0fa..d6342d3 100644 --- a/frontend/src/lib/components/BaseMap.svelte +++ b/frontend/src/lib/components/BaseMap.svelte @@ -22,6 +22,9 @@ const MAP_HEIGHT_SMALL = '400px'; const MAP_HEIGHT_LARGE = 'max(100vh, 800px)'; + // Minimum zoom level for elevation queries (contours available from zoom 9) + const MIN_ZOOM_FOR_ELEVATION_QUERY = 9; + export let mode: 'single' | 'multi'; // Common props @@ -34,6 +37,12 @@ export let longitude: number | null = null; export let editable: boolean = false; + export let onElevationLookup: + | ((data: {elevation: number | null; zoomTooLow: boolean}) => void) + | undefined = undefined; + export let onCountryLookup: ((data: {countryCode: string | null}) => void) | undefined = + undefined; + // Props only used for mode 'multi' export let markers: NamedCoordinates[] = []; @@ -59,6 +68,80 @@ } } + /** + * Query elevation at a given point using terrain contours. + * Returns the elevation in meters, or null if not available. + */ + function queryElevation( + initializedMap: Map, + lngLat: {lng: number; lat: number}, + ): { + elevation: number | null; + zoomTooLow: boolean; + } { + const currentZoom = initializedMap.getZoom(); + if (currentZoom < MIN_ZOOM_FOR_ELEVATION_QUERY) { + return {elevation: null, zoomTooLow: true}; + } + + const point = initializedMap.project([lngLat.lng, lngLat.lat]); + const features = initializedMap.queryRenderedFeatures(point, { + layers: ['terrain-contours-data'], + }); + + if (features.length === 0) { + return {elevation: null, zoomTooLow: false}; + } + + // Get the highest elevation from overlapping contour polygons + const elevations = features + .map((f) => f.properties?.ele as number | undefined) + .filter((ele): ele is number => ele !== undefined); + + if (elevations.length === 0) { + return {elevation: null, zoomTooLow: false}; + } + + return {elevation: Math.max(...elevations), zoomTooLow: false}; + } + + /** + * Query country code at a given point. + * Returns the ISO 3166-1 alpha-2 country code, or null if not available. + * Country boundaries are available at all zoom levels. + */ + function queryCountryCode( + initializedMap: Map, + lngLat: {lng: number; lat: number}, + ): string | null { + const point = initializedMap.project([lngLat.lng, lngLat.lat]); + const features = initializedMap.queryRenderedFeatures(point, { + layers: ['countries-data'], + }); + + if (features.length === 0) { + return null; + } + + // Get the country code from the first feature + return (features[0].properties?.iso_3166_1 as string | undefined) ?? null; + } + + /** + * Perform location data lookup (elevation and country) and dispatch events. + */ + function performLocationDataLookup(initializedMap: Map, lngLat: {lng: number; lat: number}) { + if (!editable) { + return; + } + + const elevationResult = queryElevation(initializedMap, lngLat); + const countryCode = queryCountryCode(initializedMap, lngLat); + + onElevationLookup?.(elevationResult); + onCountryLookup?.({countryCode}); + } + /** * Toggle map height. */ @@ -93,15 +176,28 @@ longitude = Number(lngLat.lng.toFixed(5)); }; - // Function to update marker position and coordinates + // Function to update marker position, coordinates, and location lookup data const updateMarkerPosition = (lngLat: LngLatLike) => { marker.setLngLat(lngLat); ensureSingleMarkerVisible(); updateCoordinatesFromMarker(); + // Lookup elevation and country code + const markerLngLat = marker.getLngLat(); + performLocationDataLookup(initializedMap, { + lng: markerLngLat.lng, + lat: markerLngLat.lat, + }); }; - // Update coordinates on marker drag - marker.on('dragend', updateCoordinatesFromMarker); + // Update coordinates and lookup data on marker drag + marker.on('dragend', () => { + updateCoordinatesFromMarker(); + const markerLngLat = marker.getLngLat(); + performLocationDataLookup(initializedMap, { + lng: markerLngLat.lng, + lat: markerLngLat.lat, + }); + }); // Set up double click detection, update marker and coordinates on double click (desktop) // or double tap (mobile) @@ -230,6 +326,38 @@ break; } + // Add invisible data layers for location lookups (elevation and country code). + // Only loaded when adding/editing a location. + if (editable) { + // Terrain contours for elevation lookup + initializedMap.addLayer({ + 'id': 'terrain-contours-data', + 'type': 'fill', + 'source': { + type: 'vector', + url: `https://api.mapbox.com/v4/mapbox.mapbox-terrain-v2.json?access_token=${MAPBOX_ACCESS_TOKEN}`, + }, + 'source-layer': 'contour', + 'paint': { + 'fill-opacity': 0, + }, + }); + + // Country boundaries for country code lookup + initializedMap.addLayer({ + 'id': 'countries-data', + 'type': 'fill', + 'source': { + type: 'vector', + url: `https://api.mapbox.com/v4/mapbox.country-boundaries-v1.json?access_token=${MAPBOX_ACCESS_TOKEN}`, + }, + 'source-layer': 'country_boundaries', + 'paint': { + 'fill-opacity': 0, + }, + }); + } + // Map markers and labels addMapMarkersAndLabels(initializedMap); }); diff --git a/frontend/src/lib/components/SingleMap.svelte b/frontend/src/lib/components/SingleMap.svelte index cdd6efb..64c170c 100644 --- a/frontend/src/lib/components/SingleMap.svelte +++ b/frontend/src/lib/components/SingleMap.svelte @@ -10,6 +10,22 @@ export let editable: boolean = false; export let center: LngLatLike = DEFAULT_MAP_CENTER; export let zoom: number = 6; + + // Callback props for location data lookups (forwarded to BaseMap) + export let onElevationLookup: + | ((data: {elevation: number | null; zoomTooLow: boolean}) => void) + | undefined = undefined; + export let onCountryLookup: ((data: {countryCode: string | null}) => void) | undefined = + undefined; - + diff --git a/frontend/src/routes/locations/LocationForm.svelte b/frontend/src/routes/locations/LocationForm.svelte index 23259ba..8beee9a 100644 --- a/frontend/src/routes/locations/LocationForm.svelte +++ b/frontend/src/routes/locations/LocationForm.svelte @@ -22,6 +22,30 @@ let latitude: number | null = location?.coordinates?.lat ?? null; let longitude: number | null = location?.coordinates?.lon ?? null; + // Hint for elevation auto-fill (shown when zoom is too low) + let showElevationHint = false; + + // Handle elevation lookup from map + function handleElevationLookup(data: {elevation: number | null; zoomTooLow: boolean}) { + const {elevation: newElevation, zoomTooLow} = data; + if (newElevation !== null) { + elevation = newElevation; + showElevationHint = false; + } else { + // Clear outdated value when zoomed out + elevation = null; + showElevationHint = zoomTooLow; + } + } + + // Handle country code lookup from map + function handleCountryLookup(data: {countryCode: string | null}) { + const {countryCode: newCountryCode} = data; + if (newCountryCode !== null) { + countryCode = newCountryCode; + } + } + // Input transformations $: if (countryCode.length > 0) { countryCode = countryCode.toLocaleUpperCase(); @@ -270,6 +294,12 @@ {$i18n.t('common.error', {message: fieldErrors.elevation})} {/if} + {#if showElevationHint} +
+ + {$i18n.t('location.hint--zoom-in-elevation')} +
+ {/if}