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
127 changes: 116 additions & 11 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().
// 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,32 @@ const SearchControl = ({
}
});
map.setView(latlng, SEARCH_DEFAULT_ZOOM);
await dispatchMapClick(map, latlng);

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

// The autocomplete row already has a usable title — set the selected location directly.
if (
autocompleteItem
&& interactiveRegion === InteractiveRegionOption.GRIDDED_DATA
&& onSelectGriddedLocation
) {
onSelectGriddedLocation({
latlng,
title: autocompleteItem.title,
});
Comment on lines +140 to +143
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have the impression that's what opens up the PopUp thing. And the lat,lon at.

// Skip the synthetic-click reverse-lookup path that the other modes need.
return;
}

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

Expand Down Expand Up @@ -205,6 +250,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 +263,7 @@ const SearchControl = ({
lng: item.lon,
loc,
};
formattedItemsRef.current.set(title, item);
});

return formattedData;
Expand All @@ -221,21 +272,59 @@ const SearchControl = ({
void _; // intentionally ignore to suppress typescript error
return `<div>${buildLocationTitle(item)}</div>`;
},

/**
* Search by raw "lat,lon" coordinates or anything else entry point.
*
* The autocomplete-hit path (UC-Search) never reaches here; it lands
* in `moveToLocation` above with a matching row in formattedItemsRef.
*/
locationNotFound: async function() {
// If the list of suggestions is still shown, no error message is shown.
// See #86862
if (Object.keys(this._recordsCache).length > 0) {
this.showTooltip(this._recordsCache)
return;
}
// Check if the coordinates are valid if the location is empty.
const latLng = parseLatLon(this._input.value);

// parseLatLon is both PARSER and DETECTOR: a non-partial result means
// the input was a valid "lat, lon" string.
const parsedSearchControlInput = parseLatLon(this._input.value);
// If the coordinates are valid, move to that location.
if (latLng && !latLng.isPartial) {
// Fetch location data
const locationByCoords = await fetchLocationByCoords({ lat: latLng.lat, lng: latLng.lon });
// Trigger show location.
this.showLocation(locationByCoords, locationByCoords.geo_id);
if (parsedSearchControlInput && !parsedSearchControlInput.isPartial) {
const latLng = new L.LatLng(parsedSearchControlInput.lat, parsedSearchControlInput.lon);
const locationByCoords = await fetchLocationByCoords(latLng);

// Bypass leaflet-search's showLocation pipeline for GRIDDED mode so the
// marker lands at the typed coordinates rather than dispatchMapClick
// calculated center (container-center reprojection).
// Other `InteractiveRegionOption.` modes (CENSUS/HEALTH/WATERSHED)
// fall through because their popup title comes from
// `layer.properties.label_*` and the small drift is
// benign at polygon scale.
const interactiveRegion = climateVariable?.getInteractiveRegion()
?? InteractiveRegionOption.GRIDDED_DATA;

if (
interactiveRegion === InteractiveRegionOption.GRIDDED_DATA
&& onSelectGriddedLocation
) {
map.setView(latLng, SEARCH_DEFAULT_ZOOM);
onSelectGriddedLocation({
latlng: latLng,
title: locationByCoords.title,
});
return;
}
Comment on lines +298 to +318
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically the same pattern as when selecting one of the autocomplete suggestion, but this time for when it's raw coordinates.


this.showLocation(
{
...locationByCoords,
lat: latLng.lat,
lng: latLng.lng,
},
locationByCoords.title,
);
}
else {
// Show error alert.
Expand All @@ -250,8 +339,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
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const RecentLocationsPanel: React.FC = () => {

const moveToLocation = async (location: MapLocation) => {
map.setView(location, SEARCH_DEFAULT_ZOOM);
await dispatchMapClick(map, location);
await dispatchMapClick(map);
};

return (
Expand Down
44 changes: 42 additions & 2 deletions apps/src/hooks/use-map-interactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,47 @@ export function useMapInteractions({ primaryLayerRef, comparisonLayerRef }: UseM
...latlng,
}));

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

/**
* Used by the `search-control.tsx`'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,
latlng,
title,
});
}, [
addMarker,
clearMarkers,
dispatch,
]);

const handleClearSelectedLocation = useCallback(() => {
setSelectedLocation(null);
Expand Down Expand Up @@ -140,5 +179,6 @@ export function useMapInteractions({ primaryLayerRef, comparisonLayerRef }: UseM
handleOut,
handleClick,
handleClearSelectedLocation,
selectGriddedLocation,
};
}
12 changes: 5 additions & 7 deletions apps/src/lib/dispatch-map-click.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,13 @@ import L from 'leaflet';
* Dispatches a real DOM PointerEvent on the VectorGrid canvas tile at the
* map container center after the view settles.
*
* `pointer-events: none` on Leaflet's pane wrapper divs causes
* `document.elementFromPoint` to return the outermost container — canvas
* tiles are queried via `gridPane.querySelectorAll('canvas')` and filtered
* by bounding rect instead.
*
* `Promise.race` with a 1,000 ms timeout guards the case where `setView`
* fires `moveend` synchronously (short pan, no animation) before the
* listener is attached.
*/
export const dispatchMapClick = async (
map: L.Map,
latlng: { lat: number; lng: number },
): Promise<void> => {
void latlng;
await Promise.race([
new Promise<void>((resolve) => map.once('moveend', () => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, 1_000)),
Expand All @@ -31,6 +24,11 @@ export const dispatchMapClick = async (
if (!gridPane) {
return;
}
// `pointer-events: none` on Leaflet's pane wrapper divs causes
// `document.elementFromPoint` to return the outermost container — the
// canvas tiles themselves are not hit-testable through that API. We
// enumerate the grid pane's canvases and filter by their bounding rect
// to find the one under (clientX, clientY) instead.
const canvases = Array.from(gridPane.querySelectorAll('canvas'));
const target = canvases.find((canvas) => {
const r = canvas.getBoundingClientRect();
Expand Down
2 changes: 2 additions & 0 deletions apps/src/services/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,8 @@ export const fetchPostsData = async (
/**
* Fetches location data from the API
*
* Call to WordPress wp-api endpoint `/wp-json/cdc/v2/get_location_by_coords`
*
* @param latlng Latitude and Longitude of the location
* @param fetchOptions Any other options to pass to fetch (ex: `signal`)
*/
Expand Down
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