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(). + // `^same-intent-same-pattern`: 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,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, ], ); @@ -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)]; @@ -213,6 +264,7 @@ const SearchControl = ({ lng: item.lon, loc, }; + formattedItemsRef.current.set(title, item); }); return formattedData; @@ -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); + } }, }); 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/hooks/use-map-interactions.tsx b/apps/src/hooks/use-map-interactions.tsx index 34e9d819c..54dbd57ca 100644 --- a/apps/src/hooks/use-map-interactions.tsx +++ b/apps/src/hooks/use-map-interactions.tsx @@ -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; @@ -140,5 +159,6 @@ export function useMapInteractions({ primaryLayerRef, comparisonLayerRef }: UseM handleOut, handleClick, handleClearSelectedLocation, + selectGriddedLocation, }; } 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/gen-term-preferences.php b/fw-child/resources/functions/gen-term-preferences.php new file mode 100644 index 000000000..a727f9fb1 --- /dev/null +++ b/fw-child/resources/functions/gen-term-preferences.php @@ -0,0 +1,244 @@ + [ + // Major settlements with addressable population centers. + // "City" / "Town" are the dominant Canadian municipal types. + // "Metropolitan Area" reinstated from the legacy two-pass logic + // because Halifax-class metros carry that gen_term in + // geocoder.all_areas rather than 'City'/'Town'. + // "Municipality" (the generic, NOT specialized variants) sits + // here because in Quebec — Ville and Municipalité — and + // broadly across Canada, both are equivalent municipal types: + // populated places governed by an elected council. Without + // this, Tier-1 Town beat Tier-2 Municipality regardless of + // distance (e.g. clicking near Verchères returned L'Assomption). + // Specialized variants (Specialized / District / Rural / Parish + // / Resort / Mountain Resort / Township / United Townships / + // Village / Northern Village / Cree Village / Naskapi Village + // Municipality) stay in Tier 2 — they signal special-purpose + // context (parish = legacy religious admin, rural = prairie + // units, resort = tourism zoning) that shouldn't outrank a + // generic Municipality or Town nearby. + // "Community" is deliberately excluded here — it is double- + // purpose in geocoder.all_areas (canonical city names AND + // neighborhood names within larger cities). The neighborhood + // usage produces the wrong-name pathology this ticket fixes + // (West End-Community at 919m beating Vancouver-City at 2.5km). + // Community falls through to the implicit Tier 99 fallback. + 'City', + 'Town', + 'Separated Town', + 'Metropolitan Area', + 'Municipality', + ], + 2 => [ + // Township-class. In Canadian municipal hierarchies a Township + // is roughly equivalent to a Town/Village in size, but a + // Geographic Township is a survey-grid artifact and shouldn't + // outrank an actual City when both are nearby (e.g. York-Geographic- + // Township beating Toronto-City was the symptom that motivated + // this re-tiering). + 'Township', + 'Township Municipality', + 'Geographic Township', + 'United Townships Municipality', + // Named settlements smaller than City/Town. + 'Village', + 'Village Municipality', + 'Northern Village', + 'Northern Village Municipality', + 'Resort Village', + 'Summer Village', + 'Rural Village', + 'Police Village', + 'Forest Village', + 'Provincial Historic Village', + 'Cree Village', + 'Cree Village Municipality', + 'Naskapi Village', + 'Naskapi Village Municipality', + 'First Nation Village', + 'Former First Nation Village', + 'Specialized Municipality', + 'District Municipality', + 'Rural Municipality', + 'Parish Municipality', + 'Resort Municipality', + 'Mountain Resort Municipality', + 'Hamlet', + 'Northern Hamlet', + 'Organized Hamlet', + 'Townsite', + ], + 3 => [ + // Sub-divisions of cities (Saint-Hubert, Greenfield Park, etc.). + 'Borough', + ], +]; + +$gen_term_excluded = [ + // Original baseline (autocomplete-aligned). + 'Administrative Region', + 'Province', + 'Territory', + // Census-statistical groupings — never an addressable place. + 'Census Division', + 'Census Subdivision', + // Admin shells without an identifiable population center. + 'Regional Municipality', + 'County Regional Municipality', + 'County Municipality', + 'Municipal County', + 'Municipal District', + 'Region', + 'Regional District', + 'Restructured County', + 'County', + 'Improvement District', + 'Local Government District', + 'Local Service District', + 'Local Urban District', + 'Subdivision', + 'Unorganized Territory', + // Misc admin / data-quality outliers (Atoms 4/5/7 territory). + 'Urban Community', + 'Administrative Sector', + 'Railway Point', + 'Railway Junction', +]; + +/** + * Build the SQL fragments needed to apply gen_term preference tiers and + * hard exclusions in a prepared statement. + * + * Both endpoints (cdc_location_search, cdc_get_location_by_coords) call + * this and stitch the fragments into their own query. + * + * Worked example with the live data above: + * Input: + * $tiers = [ 1 => ['City', 'Town', 'Separated Town', 'Metropolitan Area', 'Municipality'], + * 2 => ['Township', 'Township Municipality', ..., 'Townsite'], // ~30 terms + * 3 => ['Borough'] ] + * $excluded = ['Administrative Region', 'Province', ..., 'Railway Junction'] // ~22 terms + * Output: + * case_sql = "CASE WHEN gen_term IN (?,?,?,?,?) THEN 1 + * WHEN gen_term IN (?,?,...,?) THEN 2 + * WHEN gen_term IN (?) THEN 3 ELSE 99 END" + * case_values = ['City','Town','Separated Town','Metropolitan Area','Municipality', + * 'Township',...,'Townsite', 'Borough'] // flat, ordered to match ?s + * case_types = "sssssssss...s" // one 's' per gen_term placeholder above + * excl_sql = "gen_term NOT IN (?,?,...,?)" + * excl_values = ['Administrative Region','Province',...,'Railway Junction'] + * excl_types = "sss...s" // one 's' per excluded gen_term + * + * Returned as fragments (not a complete SQL string) because the same + * fragments embed into two different queries with different surrounding + * clauses — bbox + DISTANCE_BETWEEN for cdc_get_location_by_coords, LIKE + * for cdc_location_search. The caller composes $types in whatever order + * its `?` placeholders appear; mysqli_stmt_bind_param is positional. + * + * @param array $tiers Map of int rank => list of gen_term strings. + * @param array $excluded Flat list of gen_term strings to exclude. + * @return array{case_sql:string,excl_sql:string,case_values:string[],excl_values:string[],case_types:string,excl_types:string} + */ +function cdc_build_gen_term_fragments ( array $tiers, array $excluded ) { + + $case_parts = []; + $case_values = []; + + // Sort tiers by key ascending so lower rank = earlier WHEN. + ksort ( $tiers ); + + foreach ( $tiers as $rank => $terms ) { + + if ( empty ( $terms ) ) { + continue; + } + + // Per iteration: emit one "WHEN gen_term IN (?, ?, ...) THEN " + // and append this tier's terms to the flat case_values list. Bind + // order in case_values must follow the placeholder order across all + // tiers — that's why we append rather than nest. + $placeholders = implode ( ', ', array_fill ( 0, count ( $terms ), '?' ) ); + $case_parts[] = "WHEN gen_term IN ($placeholders) THEN " . intval ( $rank ); + + foreach ( $terms as $term ) { + $case_values[] = $term; + } + } + + $case_sql = 'CASE ' . implode ( ' ', $case_parts ) . ' ELSE 99 END'; + + $excl_placeholders = implode ( ', ', array_fill ( 0, count ( $excluded ), '?' ) ); + $excl_sql = "gen_term NOT IN ($excl_placeholders)"; + + return [ + 'case_sql' => $case_sql, + 'excl_sql' => $excl_sql, + 'case_values' => $case_values, + 'excl_values' => array_values ( $excluded ), + 'case_types' => str_repeat ( 's', count ( $case_values ) ), + 'excl_types' => str_repeat ( 's', count ( $excluded ) ), + ]; +} diff --git a/fw-child/resources/functions/rest.php b/fw-child/resources/functions/rest.php index afd6ca522..85a94a4c4 100644 --- a/fw-child/resources/functions/rest.php +++ b/fw-child/resources/functions/rest.php @@ -341,6 +341,11 @@ function cdc_get_location_by_coords () { ( isset ( $_GET['lng'] ) && !empty ( $_GET['lng'] ) ) ) { + // Normalize $_GET first so the early-exit "no db" branch can build + // its fallback Point response without referencing undefined vars. + $lat = floatval ( $_GET['lat'] ); + $lng = floatval ( $_GET['lng'] ); + if ( locate_template ( 'resources/app/db.php' ) == '' ) { echo json_encode ( array ( @@ -355,131 +360,122 @@ function cdc_get_location_by_coords () { } else { require_once locate_template ( 'resources/app/db.php' ); + require_once dirname ( __FILE__ ) . '/gen-term-preferences.php'; - $lat = floatval ( $_GET['lat'] ); - $lng = floatval ( $_GET['lng'] ); + $con = $GLOBALS['vars']['con']; // add _fr if needed $term_append = ( $GLOBALS['fw']['current_lang_code'] == 'fr' ) ? '_fr' : ''; - $columns = array ( - "all_areas.id_code as geo_id", - "geo_name", - "gen_term" . $term_append . " as generic_term", - "location", - "province" . $term_append, - "lat", - "lon" - ); + // Bounding-box width (degrees) for the single-pass nearest-place + // query. Replaces the previous progressive widening loop + // ($ranges = [0.05, 0.1, 0.2]) with a single pass — preference + // tiers + hard exclusions (see gen-term-preferences.php) do the + // filtering work that the widening loop was approximating. + // + // 0.5° ≈ ~55 km N-S and 35-40 km E-W at Canadian latitudes — + // wide enough to cover sparse-area lookups, narrow enough that + // LIMIT 1 + ORDER BY distance still returns the nearest place. + // Adjust here if probe results show a regression. + $bbox_range = 0.5; - // $columns = implode ( ",", $columns ); $join = ""; if ( $_GET['sealevel'] == 'true' ) { $join = "JOIN all_areas_sealevel ON (all_areas.id_code=all_areas_sealevel.id_code)"; } - $ranges = [ 0.05, 0.1, 0.2 ]; - $preferred_terms = [ 'Community', 'Metropolitan Area' ]; - $found_community = false; - - // gradually increase the range until we find a community - - foreach ( $ranges as $range ) { - - if ( $found_community == false ) { - $main_query = mysqli_query($GLOBALS['vars']['con'], "SELECT " . implode(",", $columns) . " - , DISTANCE_BETWEEN($lat, $lng, lat,lon) as distance - FROM all_areas - $join - WHERE lat BETWEEN " . (round($lat, 2) - $range) . " AND " . (round($lat, 2) + $range) . " - AND lon BETWEEN " . (round($lng, 2) - $range) . " AND " . (round($lng, 2) + $range) . " - AND gen_term NOT IN ('Railway Point', 'Railway Junction', 'Urban Community', 'Administrative Sector') - ORDER BY DISTANCE - LIMIT 50");// or die (mysqli_error($GLOBALS['vars']['con'])); - - if ($main_query->num_rows > 0) { - - while ( $row = mysqli_fetch_assoc ( $main_query ) ) { - - if ( in_array ( $row['generic_term'], $preferred_terms ) ) { - $result = $row; - - // might be good to know - // what range is the community in from the click - $result['range'] = $range; - - // send back the original coords - $result['coords'] = [ $lat, $lng ]; + $frags = cdc_build_gen_term_fragments ( + $gen_term_preference_tiers, + $gen_term_excluded + ); - // lon -> lng - $result['lng'] = $result['lon']; + // One-action-per-line in the SQL string for legibility — diffs + // stay scoped to the line that actually changed. + $sql = "SELECT + all_areas.id_code as geo_id, + geo_name, + gen_term" . $term_append . " as generic_term, + location, + province" . $term_append . " as province, + lat, + lon, + DISTANCE_BETWEEN(?, ?, lat, lon) as distance, + " . $frags['case_sql'] . " as preference_tier + FROM all_areas + $join + WHERE lat BETWEEN ? AND ? + AND lon BETWEEN ? AND ? + AND " . $frags['excl_sql'] . " + ORDER BY preference_tier ASC, distance ASC + LIMIT 1"; - // province abbreviation - $result['province_short'] = short_province ( $result['province'] ); + $stmt = mysqli_prepare ( $con, $sql ); - // nice name - $result['title'] = $result['geo_name'] . ', ' . $result['province_short']; + if ( $stmt === false ) { + die ( mysqli_error ( $con ) ); + } - $found_community = true; + // Bind order matches placeholder order in $sql: + // DISTANCE_BETWEEN(?, ?, ...) -> lat, lng (dd) + // CASE WHEN gen_term IN (?, ?, ...) -> case_values (s...) + // lat BETWEEN ? AND ? -> lat-r, lat+r (dd) + // lon BETWEEN ? AND ? -> lng-r, lng+r (dd) + // gen_term NOT IN (?, ?, ...) -> excl_values (s...) + $types = 'dd' . $frags['case_types'] . 'dddd' . $frags['excl_types']; + + $lat_r = round ( $lat, 2 ); + $lng_r = round ( $lng, 2 ); + + // Order MUST mirror $types segment-for-segment; + // mysqli_stmt_bind_param is positional, no named binds. + $bind_values = array_merge ( + [ $lat, $lng ], + $frags['case_values'], + [ $lat_r - $bbox_range, $lat_r + $bbox_range, $lng_r - $bbox_range, $lng_r + $bbox_range ], + $frags['excl_values'] + ); - break; - } - } + // mysqli_stmt_bind_param needs values by reference. + $bind_refs = []; + $bind_refs[] = &$types; + foreach ( $bind_values as $k => $v ) { + $bind_refs[] = &$bind_values[$k]; + } + call_user_func_array ( 'mysqli_stmt_bind_param', array_merge ( [ $stmt ], $bind_refs ) ); - } + mysqli_stmt_execute ( $stmt ); + $main_query = mysqli_stmt_get_result ( $stmt ); - } + if ( $main_query && $main_query->num_rows > 0 ) { - } + $result = mysqli_fetch_assoc ( $main_query ); - if ( $found_community == true ) { + $result['lat'] = floatval ( $result['lat'] ); + $result['lon'] = floatval ( $result['lon'] ); + $result['lng'] = $result['lon']; + // Send back the original click coords (typed) so the + // caller can correlate request vs returned place. + $result['coords'] = [ $lat, $lng ]; + $result['province_short'] = short_province ( $result['province'] ); + $result['title'] = $result['geo_name'] . ', ' . $result['province_short']; - // found a community in range echo json_encode ( $result ); } else { - // no preferred results, grab the nearest one - - // range of coordinates to search between - $range = 0.1; - - $main_query = mysqli_query($GLOBALS['vars']['con'], "SELECT " . implode(",", $columns) . " - , DISTANCE_BETWEEN($lat, $lng, lat,lon) as distance - FROM all_areas - $join - WHERE lat BETWEEN " . (round($lat, 2) - $range) . " AND " . (round($lat, 2) + $range) . " - AND lon BETWEEN " . (round($lng, 2) - $range) . " AND " . (round($lng, 2) + $range) . " - AND gen_term NOT IN ('Railway Point', 'Railway Junction', 'Urban Community', 'Administrative Sector') - ORDER BY DISTANCE - LIMIT 1");// or die (mysqli_error($GLOBALS['vars']['con'])); - - if ($main_query->num_rows > 0) { - - $result = mysqli_fetch_assoc ( $main_query ); - - $result['coords'] = [ $lat, $lng ]; - $result['lng'] = $result['lon']; - $result['province_short'] = short_province ( $result['province'] ); - $result['title'] = $result['geo_name'] . ', ' . $result['province_short']; - - echo json_encode ( $result ); - - } else { - - echo json_encode ( array ( - 'lat' => $lat, - 'lng' => $lng, - 'coords' => [ $lat, $lng ], - 'geo_name' => __ ( 'Point', 'cdc' ), - 'title' => __ ( 'Point', 'cdc' ) . ' (' . number_format ( $lat, 4, '.', '') . ', ' . number_format ( $lng, 4, '.', '') . ')' - ) ); - - } + echo json_encode ( array ( + 'lat' => $lat, + 'lng' => $lng, + 'coords' => [ $lat, $lng ], + 'geo_name' => __ ( 'Point', 'cdc' ), + 'title' => __ ( 'Point', 'cdc' ) . ' (' . number_format ( $lat, 4, '.', '') . ', ' . number_format ( $lng, 4, '.', '') . ')' + ) ); } + mysqli_stmt_close ( $stmt ); + } // if db.php } // if $_GET @@ -508,79 +504,95 @@ function cdc_location_search() { } else { require_once locate_template ( 'resources/app/db.php' ); + require_once dirname ( __FILE__ ) . '/gen-term-preferences.php'; $con = $GLOBALS['vars']['con']; - $get_sSearch = isset($_GET['q']) ? $_GET['q'] : ''; - $post_draw = isset($_POST['draw']) ? $_POST['draw'] : ''; + $get_sSearch = isset ( $_GET['q'] ) ? $_GET['q'] : ''; + $post_draw = isset ( $_POST['draw'] ) ? $_POST['draw'] : ''; - if (isset ( $GLOBALS['fw']['current_lang_code'] ) ){ + if ( isset ( $GLOBALS['fw']['current_lang_code'] ) ) { $get_lang = $GLOBALS['fw']['current_lang_code']; } else { $get_lang = 'en'; } - // the columns to be filtered, ordered and returned - // must be in the same order as displayed in the table - - $columns = array ( - "id_code", - "geo_name", - "gen_term", - "location", - "province", - "lat", - "lon" - ); - - if ( $get_lang == 'fr' ) { - $columns = array ( - "id_code", - "geo_name", - "gen_term_fr", - "location", - "province_fr", - "lat", - "lon" - ); - } + // Backtick → single-quote: existing input normalization, kept + // even though the value is now bound (defensive for downstream). + $get_sSearch = str_replace ( '`', '\'', $get_sSearch ); - $search_columns = [ "geo_name" ]; + // Column list — _fr suffix when language is French. Gen_term + // returned as `term` and geo_name as `text` in the response shape + // callers depend on; do not rename here. + $gen_term_col = ( $get_lang == 'fr' ) ? 'gen_term_fr' : 'gen_term'; + $province_col = ( $get_lang == 'fr' ) ? 'province_fr' : 'province'; $table = "all_areas"; - $joins = ""; - - $get_sSearch = str_replace('`','\'', $get_sSearch); - // filtering - $sql_where = "where gen_term not in ('Administrative Region', 'Province', 'Territory') and geo_name like '%" . mysqli_real_escape_string ( $con,$get_sSearch ) . "%'" ; + $frags = cdc_build_gen_term_fragments ( + $gen_term_preference_tiers, + $gen_term_excluded + ); - // ordering - $sql_order = "order by scale desc"; + // Paging if query string is shorter than 5 + $sql_limit = ( strlen ( $get_sSearch ) < 5 ) ? 'LIMIT 0,10' : ''; + + // LIKE bind value — wildcards live in the value, not the SQL. + $like_value = '%' . $get_sSearch . '%'; + + $sql = "SELECT SQL_CALC_FOUND_ROWS + id_code, + geo_name, + $gen_term_col, + location, + $province_col, + lat, + lon, + " . $frags['case_sql'] . " as preference_tier + FROM {$table} + WHERE " . $frags['excl_sql'] . " + AND geo_name LIKE ? + ORDER BY preference_tier ASC, scale DESC + $sql_limit"; + + $stmt = mysqli_prepare ( $con, $sql ); + + if ( $stmt === false ) { + die ( mysqli_error ( $con ) ); + } - $sql_limit = ""; + // Bind order: case_values (s...), excl_values (s...), like_value (s). + $types = $frags['case_types'] . $frags['excl_types'] . 's'; + // Order MUST mirror $types segment-for-segment; + // mysqli_stmt_bind_param is positional, no named binds. + $bind_values = array_merge ( + $frags['case_values'], + $frags['excl_values'], + [ $like_value ] + ); - // paging if query string is shorter than 5 - if ( strlen ( $get_sSearch ) < 5) { - $sql_limit = "LIMIT 0,10"; + $bind_refs = []; + $bind_refs[] = &$types; + foreach ( $bind_values as $k => $v ) { + $bind_refs[] = &$bind_values[$k]; } + call_user_func_array ( 'mysqli_stmt_bind_param', array_merge ( [ $stmt ], $bind_refs ) ); - $main_query = mysqli_query($con,"SELECT SQL_CALC_FOUND_ROWS " . implode(", ", $columns) . " FROM {$table} {$joins} {$sql_where} {$sql_order} {$sql_limit}") - or die ( mysqli_error ( $con ) ); + mysqli_stmt_execute ( $stmt ); + $main_query = mysqli_stmt_get_result ( $stmt ); // send back the number requested $response['draw'] = intval ( $post_draw ); // get the number of filtered rows - - $filtered_rows_query = mysqli_query ( $con,"SELECT FOUND_ROWS()" ) + $filtered_rows_query = mysqli_query ( $con, "SELECT FOUND_ROWS()" ) or die ( mysqli_error ( $con ) ); $row = mysqli_fetch_array ( $filtered_rows_query ); $response['recordsFiltered'] = $row[0]; // get the number of rows in total - $total_query = mysqli_query ( $con,"SELECT COUNT(*) FROM {$table}" ) + $total_query = mysqli_query ( $con, "SELECT COUNT(*) FROM {$table}" ) or die ( mysqli_error ( $con ) ); $row = mysqli_fetch_array ( $total_query ); @@ -588,7 +600,12 @@ function cdc_location_search() { $response['items'] = array(); - // finish getting rows from the main query + // 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 +613,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] ); @@ -603,6 +621,8 @@ function cdc_location_search() { $response['items'][] = $row; } + mysqli_stmt_close ( $stmt ); + } // locate db header('Cache-Control: no-cache');