diff --git a/apps/src/components/map-container.tsx b/apps/src/components/map-container.tsx index f454e3deb..24fd4c26e 100644 --- a/apps/src/components/map-container.tsx +++ b/apps/src/components/map-container.tsx @@ -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; } @@ -76,6 +79,7 @@ export default function MapContainer({ onClick, selectedLocation, clearSelectedLocation, + selectGriddedLocation, layerRef, }: MapContainerProps): React.ReactElement { const [locationModalContent, setLocationModalContent] = useState(null); @@ -245,7 +249,11 @@ export default function MapContainer({ {/* Show search control if not a comparison map. */} - { !isComparisonMap && } + { !isComparisonMap && ( + + ) } void; } /** @@ -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>( + new Map(), + ); const [isGeolocationEnabled, setIsGeolocationEnabled] = useState(false); const [isTracking, setIsTracking] = useState(false); @@ -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 => { @@ -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, + }); + // 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, ], ); @@ -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)]; @@ -213,6 +263,7 @@ const SearchControl = ({ lng: item.lon, loc, }; + formattedItemsRef.current.set(title, item); }); return formattedData; @@ -221,6 +272,13 @@ const SearchControl = ({ void _; // intentionally ignore to suppress typescript error return `
${buildLocationTitle(item)}
`; }, + + /** + * 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 @@ -228,14 +286,45 @@ const SearchControl = ({ 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; + } + + this.showLocation( + { + ...locationByCoords, + lat: latLng.lat, + lng: latLng.lng, + }, + locationByCoords.title, + ); } else { // Show error alert. @@ -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); + } }, }); diff --git a/apps/src/components/map.tsx b/apps/src/components/map.tsx index ba37a4515..378b3d75c 100644 --- a/apps/src/components/map.tsx +++ b/apps/src/components/map.tsx @@ -33,7 +33,8 @@ export default function Map(): React.ReactElement { handleOver, handleOut, handleClick, - handleClearSelectedLocation + handleClearSelectedLocation, + selectGriddedLocation, } = useMapInteractions({ primaryLayerRef, comparisonLayerRef, @@ -95,6 +96,7 @@ export default function Map(): React.ReactElement { onClick={handleClick} selectedLocation={selectedLocation} clearSelectedLocation={handleClearSelectedLocation} + selectGriddedLocation={selectGriddedLocation} layerRef={primaryLayerRef} /> {showComparisonMap && ( diff --git a/apps/src/components/sidebar-footer-links/recent-locations.tsx b/apps/src/components/sidebar-footer-links/recent-locations.tsx index 966501c5b..aebe1f1ee 100644 --- a/apps/src/components/sidebar-footer-links/recent-locations.tsx +++ b/apps/src/components/sidebar-footer-links/recent-locations.tsx @@ -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 ( diff --git a/apps/src/hooks/use-map-interactions.tsx b/apps/src/hooks/use-map-interactions.tsx index 34e9d819c..35471ea95 100644 --- a/apps/src/hooks/use-map-interactions.tsx +++ b/apps/src/hooks/use-map-interactions.tsx @@ -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); @@ -140,5 +179,6 @@ export function useMapInteractions({ primaryLayerRef, comparisonLayerRef }: UseM handleOut, handleClick, handleClearSelectedLocation, + selectGriddedLocation, }; } diff --git a/apps/src/lib/dispatch-map-click.ts b/apps/src/lib/dispatch-map-click.ts index 090247f97..b260efc45 100644 --- a/apps/src/lib/dispatch-map-click.ts +++ b/apps/src/lib/dispatch-map-click.ts @@ -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 latlng; await Promise.race([ new Promise((resolve) => map.once('moveend', () => resolve())), new Promise((resolve) => setTimeout(resolve, 1_000)), @@ -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(); diff --git a/apps/src/services/services.ts b/apps/src/services/services.ts index 71f14f607..3adae3dd8 100644 --- a/apps/src/services/services.ts +++ b/apps/src/services/services.ts @@ -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`) */ diff --git a/apps/src/types/types.ts b/apps/src/types/types.ts index 60b4c7155..4c06a2c47 100644 --- a/apps/src/types/types.ts +++ b/apps/src/types/types.ts @@ -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; } diff --git a/fw-child/resources/functions/rest.php b/fw-child/resources/functions/rest.php index afd6ca522..e8c146479 100644 --- a/fw-child/resources/functions/rest.php +++ b/fw-child/resources/functions/rest.php @@ -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" ); @@ -589,6 +589,11 @@ 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], @@ -596,6 +601,7 @@ function cdc_location_search() { "term" => $row[2], "location" => $row[3], "province" => $row[4], + "province_short" => short_province ( $row[4] ), "lat" => $row[5], "lon" => $row[6] );