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
119 changes: 109 additions & 10 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 @@ -229,13 +280,45 @@ const SearchControl = ({
return;
}
// Check if the coordinates are valid if the location is empty.
const latLng = parseLatLon(this._input.value);
const latLon = 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 (latLon && !latLon.isPartial) {
const latLng = {
lat: latLon.lat,
lng: latLon.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 the synthetic-click
// container-center reprojection. Polygon modes (CENSUS/HEALTH/WATERSHED)
// fall through to the existing showLocation path 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: L.latLng(latLng.lat, latLng.lng),
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.


const locationAtTypedCoords = {
...locationByCoords,
lat: latLng.lat,
lng: latLng.lng,
};
this.showLocation(
locationAtTypedCoords,
locationByCoords.title,
);
}
else {
// Show error alert.
Expand All @@ -250,8 +333,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
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,
};
}
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
8 changes: 7 additions & 1 deletion fw-child/resources/functions/rest.php
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ function cdc_get_location_by_coords () {
"geo_name",
"gen_term" . $term_append . " as generic_term",
"location",
"province" . $term_append,
"province" . $term_append . " as province",
"lat",
"lon"
);
Expand Down Expand Up @@ -589,13 +589,19 @@ function cdc_location_search() {
$response['items'] = array();

// finish getting rows from the main query
// preserve existing `{id, text, term, location, province, lat, lon} response shape;
// `province_short` is additive, derived via the same `short_province()`
// helper used by cdc_get_location_by_coords/_by_id so the client can
// render the same "geo_name, XX" shape as those endpoints without
// duplicating the province-name → 2-letter mapping in JS.
while ( $row = mysqli_fetch_row ( $main_query ) ) {
$row = array (
"id" => $row[0],
"text" => $row[1],
"term" => $row[2],
"location" => $row[3],
"province" => $row[4],
"province_short" => short_province ( $row[4] ),
"lat" => $row[5],
"lon" => $row[6]
);
Expand Down