Skip to content
10 changes: 9 additions & 1 deletion apps/src/components/map-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ interface MapContainerProps {
onClick: (e: { latlng: L.LatLng; layer: { properties: unknown } }) => void;
selectedLocation: SelectedLocationInfo | null;
clearSelectedLocation: () => void;
selectGriddedLocation?: (
input: { latlng: L.LatLng; title: string },
) => void;
// @ts-expect-error: L.VectorGrid is a valid type
layerRef?: React.MutableRefObject<L.VectorGrid | null>;
}
Expand All @@ -76,6 +79,7 @@ export default function MapContainer({
onClick,
selectedLocation,
clearSelectedLocation,
selectGriddedLocation,
layerRef,
}: MapContainerProps): React.ReactElement {
const [locationModalContent, setLocationModalContent] = useState<React.ReactNode>(null);
Expand Down Expand Up @@ -245,7 +249,11 @@ export default function MapContainer({
<ZoomControl />

{/* Show search control if not a comparison map. */}
{ !isComparisonMap && <SearchControl /> }
{ !isComparisonMap && (
<SearchControl
onSelectGriddedLocation={selectGriddedLocation}
/>
) }

<LocationModal
isOpen={canShowModal}
Expand Down
74 changes: 71 additions & 3 deletions apps/src/components/map-layers/search-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { __ } from '@/context/locale-provider';
Expand All @@ -20,13 +21,15 @@ import 'leaflet-search/dist/leaflet-search.min.css';
import 'leaflet-search';
import mapPinIcon from '@/assets/map-pin.svg';

import { useClimateVariable } from '@/hooks/use-climate-variable';
import { cn, parseLatLon } from '@/lib/utils';
import { dispatchMapClick } from '@/lib/dispatch-map-click';
import { fetchLocationByCoords } from '@/services/services';
import {
type SearchControlLocationItem,
type SearchControlResponse,
} from '@/types/types';
import { InteractiveRegionOption } from '@/types/climate-variable-interface';
import {
LOCATION_SEARCH_ENDPOINT,
SEARCH_DEFAULT_ZOOM,
Expand Down Expand Up @@ -57,6 +60,14 @@ function convertSearchLatLng(inputLatLng: SearchLatLng): L.LatLng {
*/
export interface SearchControlProps {
className?: string;
/**
* Set selected location directly from an autocomplete pick when the
* climate variable's interactive region is GRIDDED_DATA. Bypasses the
* synthetic-click + reverse-coords-lookup path.
*/
onSelectGriddedLocation?: (
input: { latlng: L.LatLng; title: string },
) => void;
}

/**
Expand All @@ -71,7 +82,16 @@ export interface SearchControlProps {
*/
const SearchControl = ({
className,
onSelectGriddedLocation,
}: SearchControlProps): ReactElement => {
const { climateVariable } = useClimateVariable();
// Captured at the point formatData() builds entries, keyed by the same
// title string leaflet-search later passes back to moveToLocation().
// `^same-intent-same-pattern`: a closure-scoped Map keeps lookup local
// to the search lifecycle and avoids any cross-render leakage.
const formattedItemsRef = useRef<Map<string, SearchControlLocationItem>>(
new Map(),
);
const [isGeolocationEnabled, setIsGeolocationEnabled] =
useState<boolean>(false);
const [isTracking, setIsTracking] = useState<boolean>(false);
Expand All @@ -95,7 +115,10 @@ const SearchControl = ({
const map = useMap();

const handleLocationChange = useCallback(
async (inputLatlng: SearchLatLng) => {
async (
inputLatlng: SearchLatLng,
autocompleteItem?: { item: SearchControlLocationItem; title: string },
) => {
const latlng = convertSearchLatLng(inputLatlng);
// clear all existing markers from the map
map.eachLayer(layer => {
Expand All @@ -104,10 +127,33 @@ const SearchControl = ({
}
});
map.setView(latlng, SEARCH_DEFAULT_ZOOM);

const interactiveRegion = climateVariable?.getInteractiveRegion()
?? InteractiveRegionOption.GRIDDED_DATA;

// UC-Search in GRIDDED_DATA: the autocomplete row already has a
// usable title — set the selected location directly. Skip the
// synthetic-click reverse-lookup path that the other modes need.
if (
autocompleteItem
&& interactiveRegion === InteractiveRegionOption.GRIDDED_DATA
&& onSelectGriddedLocation
) {
onSelectGriddedLocation({
latlng,
title: autocompleteItem.title,
});
return;
}

// UC-LocateMe, UC-RawCoordPaste, and all polygon modes:
// fall through to existing behaviour from main.
await dispatchMapClick(map, latlng);
},
[
climateVariable,
map,
onSelectGriddedLocation,
],
);

Expand Down Expand Up @@ -205,6 +251,11 @@ const SearchControl = ({
SearchControlLocationItem & { loc: number[]; lng: string; }
> = {};

// Reset and rebuild the title→item index for the current
// suggestion set, so moveToLocation can look the row up by
// title without round-tripping through the server.
formattedItemsRef.current.clear();

response.items.forEach((item: SearchControlLocationItem) => {
const title = buildLocationTitle(item);
const loc = [parseFloat(item.lat), parseFloat(item.lon)];
Expand All @@ -213,6 +264,7 @@ const SearchControl = ({
lng: item.lon,
loc,
};
formattedItemsRef.current.set(title, item);
});

return formattedData;
Expand Down Expand Up @@ -250,8 +302,24 @@ const SearchControl = ({
popupAnchor: [0, -41], // Popup position relative to the icon
}),
}),
moveToLocation: (latlng: SearchLatLng) => {
handleLocationChange(latlng);
moveToLocation: (latlng: SearchLatLng, title: string) => {
// leaflet-search invokes this with (latlng, title, map);
// `title` is the same key formatData() used in formattedData
// (the verbose buildLocationTitle output, kept for the
// dropdown display), so we can recover the original
// autocomplete row from it.
const item = formattedItemsRef.current.get(title);
if (item) {
// UC-Search popup title uses the short server-resolved
// shape ("Montréal, QC"), matching cdc_get_location_by_coords,
// not the verbose dropdown display.
const popupTitle = `${item.text}, ${item.province_short}`;
handleLocationChange(latlng, { item, title: popupTitle });
} else {
// No matching row (e.g. raw lat/lon paste branch from
// locationNotFound) — fall through to existing behaviour.
handleLocationChange(latlng);
}
},
});

Expand Down
4 changes: 3 additions & 1 deletion apps/src/components/map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ export default function Map(): React.ReactElement {
handleOver,
handleOut,
handleClick,
handleClearSelectedLocation
handleClearSelectedLocation,
selectGriddedLocation,
} = useMapInteractions({
primaryLayerRef,
comparisonLayerRef,
Expand Down Expand Up @@ -95,6 +96,7 @@ export default function Map(): React.ReactElement {
onClick={handleClick}
selectedLocation={selectedLocation}
clearSelectedLocation={handleClearSelectedLocation}
selectGriddedLocation={selectGriddedLocation}
layerRef={primaryLayerRef}
/>
{showComparisonMap && (
Expand Down
20 changes: 20 additions & 0 deletions apps/src/hooks/use-map-interactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,25 @@ export function useMapInteractions({ primaryLayerRef, comparisonLayerRef }: UseM
clearMarkers();
}, [clearMarkers]);

// Used by the search control's autocomplete branch in GRIDDED_DATA mode:
// the autocomplete row already carries a usable display title, so we set
// the selected location directly without a reverse-coords lookup.
const selectGriddedLocation = useCallback((
{ latlng, title }: { latlng: L.LatLng; title: string },
) => {
clearMarkers();
addMarker(latlng, title);

dispatch(addRecentLocation({
id: `${latlng.lat}|${latlng.lng}`,
title,
lat: latlng.lat,
lng: latlng.lng,
}));

setSelectedLocation({ featureId: 0, title, latlng });
}, [clearMarkers, addMarker, dispatch]);

// Effect to handle location updates when climate variables change
useEffect(() => {
const interactiveRegion = climateVariable?.getInteractiveRegion() ?? null;
Expand Down Expand Up @@ -140,5 +159,6 @@ export function useMapInteractions({ primaryLayerRef, comparisonLayerRef }: UseM
handleOut,
handleClick,
handleClearSelectedLocation,
selectGriddedLocation,
};
}
6 changes: 6 additions & 0 deletions apps/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,12 @@ export interface SearchControlLocationItem {
term: string;
location: string;
province: string;
/**
* 2-letter province code derived server-side via short_province().
* @example 'QC', 'NS', 'BC' — pairs with `text` to form the popup
* title shape used by cdc_get_location_by_coords ("Montréal, QC").
*/
province_short: string;
lat: string;
lon: string;
}
Expand Down
Loading