From ffc9dc614768ba6eb3254077e72e114a51e3419f Mon Sep 17 00:00:00 2001 From: Renoir Boulanger Date: Tue, 5 May 2026 20:18:17 -0400 Subject: [PATCH 1/8] CLIM-1322 (3): BEGIN From 4cf6c91ac6e24c84e84ee88d32aaa9d0663ffcff Mon Sep 17 00:00:00 2001 From: Renoir Boulanger Date: Tue, 5 May 2026 20:22:18 -0400 Subject: [PATCH 2/8] CLIM-1322 (3): use autocomplete row for gridded popup title --- apps/src/components/map-container.tsx | 10 ++- .../components/map-layers/search-control.tsx | 68 ++++++++++++++++++- apps/src/components/map.tsx | 4 +- apps/src/hooks/use-map-interactions.tsx | 20 ++++++ 4 files changed, 97 insertions(+), 5 deletions(-) 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,18 @@ 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, + // so we can recover the original autocomplete row from it. + const item = formattedItemsRef.current.get(title); + if (item) { + handleLocationChange(latlng, { item, title }); + } 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, }; } From 9f1c9b26987ab3b74fd8f113cb64ebacd7d2833c Mon Sep 17 00:00:00 2001 From: Renoir Boulanger Date: Wed, 6 May 2026 01:53:07 -0400 Subject: [PATCH 3/8] CLIM-1322 (3): Atom10 Tier gen_term by preference; prepared statements geocoder.all_areas has 648 distinct gen_term values and no categorization column, so cdc_get_location_by_coords() and cdc_location_search() were playing whack-a-mole with string filter lists that drifted apart over time. Replace both functions' filtering with a shared tiered-preference table (City/Town/Township > Village/Municipality/Hamlet > Borough; rest falls to tier 99) plus a single hard-exclusion list, both living in fw-child/resources/functions/gen-term-preferences.php so any future adjustment is a one-file edit consumed by both endpoints. cdc_get_location_by_coords() drops the preferred_terms two-pass and the progressive-widening loop ($ranges = [0.05, 0.1, 0.2]) in favour of a single bounding-box query (knob: \$bbox_range = 0.5, commented at the call site) ordered by preference_tier ASC, distance ASC LIMIT 1. Folds in CLIM-1322_2_'s Atom 1 (province_fr aliased AS province) so the same fix lives in one place across branches. Both functions now use mysqli_prepare + bind_param + stmt_get_result; \$_GET['lat'], \$_GET['lng'], and \$_GET['q'] are bound rather than interpolated. Response shapes preserved: location_search keeps {id, text, term, location, province, lat, lon}; get_location_by_coords keeps {geo_id, geo_name, generic_term, location, province, lat, lon, lng, distance, coords, province_short, title} for parity with sibling Atom 1+2 in CLIM-1322_2_. --- .../functions/gen-term-preferences.php | 158 ++++++++++ fw-child/resources/functions/rest.php | 293 +++++++++--------- 2 files changed, 310 insertions(+), 141 deletions(-) create mode 100644 fw-child/resources/functions/gen-term-preferences.php 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..2d3600248 --- /dev/null +++ b/fw-child/resources/functions/gen-term-preferences.php @@ -0,0 +1,158 @@ + [ + 'City', + 'Town', + 'Separated Town', + 'Township', + 'Township Municipality', + 'Geographic Township', + 'United Townships Municipality', + ], + 2 => [ + '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', + 'Municipality', + 'Specialized Municipality', + 'District Municipality', + 'Rural Municipality', + 'Parish Municipality', + 'Resort Municipality', + 'Mountain Resort Municipality', + 'Hamlet', + 'Northern Hamlet', + 'Organized Hamlet', + 'Townsite', + ], + 3 => [ + '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. + * + * Given the tiers map, returns: + * - case_sql: "CASE WHEN gen_term IN (?, ?, ...) THEN 1 WHEN ... ELSE 99 END" + * — used in SELECT (aliased as preference_tier) and ORDER BY. + * - excl_sql: "gen_term NOT IN (?, ?, ...)" — used in WHERE. + * - case_values: flat array of strings for the CASE WHEN binds, in order. + * - excl_values: flat array of strings for the NOT IN binds, in order. + * - case_types: 's' repeated for each case_value (for mysqli_stmt_bind_param). + * - excl_types: 's' repeated for each excl_value. + * + * Both endpoints (cdc_location_search, cdc_get_location_by_coords) call + * this and stitch the fragments into their own query. + * + * @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; + } + + $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..4870c3407 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,120 @@ 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 ); + + $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 +502,93 @@ 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'; + $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 +596,8 @@ 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. while ( $row = mysqli_fetch_row ( $main_query ) ) { $row = array ( "id" => $row[0], @@ -603,6 +612,8 @@ function cdc_location_search() { $response['items'][] = $row; } + mysqli_stmt_close ( $stmt ); + } // locate db header('Cache-Control: no-cache'); From 7f57a48de8d6b9f57a8b9abf89d5207b24bfe7a2 Mon Sep 17 00:00:00 2001 From: Renoir Boulanger Date: Wed, 6 May 2026 01:59:24 -0400 Subject: [PATCH 4/8] CLIM-1322 (3): Atom11 Promote City/Town/Metropolitan Area to tier 1; Township to tier 2 Probes after Atom 10 surfaced two principled-but-undesired results: Toronto resolved to York (Geographic Township) instead of Toronto (City), and Halifax fell to Stewiacke because Metropolitan Area was absent from the tier list and got dumped into the Tier 99 fallback. Re-tiers Township-class down to Tier 2, and reinstates the legacy two-pass preferred_terms ('Metropolitan Area', 'Community') in Tier 1 so metro-class places (Halifax, Vancouver, Edmonton) resolve correctly. --- .../functions/gen-term-preferences.php | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/fw-child/resources/functions/gen-term-preferences.php b/fw-child/resources/functions/gen-term-preferences.php index 2d3600248..6ca670b4c 100644 --- a/fw-child/resources/functions/gen-term-preferences.php +++ b/fw-child/resources/functions/gen-term-preferences.php @@ -14,8 +14,15 @@ * Anything not listed here falls into the implicit "rest" tier (rank 99) * and only wins if no Tier-1/2/3 row exists in the search bounding box. * - * Tier 1: City / Town / Township — primary settlement classes. - * Tier 2: Village / Municipality / Hamlet variants — named settlements. + * Tier 1: City / Town / Metropolitan Area / Community — addressable + * population centers. Metropolitan Area / Community are reinstated + * from the legacy two-pass logic so Halifax / Vancouver / Edmonton- + * class metros (which carry one of those gen_terms in + * geocoder.all_areas rather than 'City') resolve correctly. + * Tier 2: Township / Village / Municipality / Hamlet variants — named + * settlements smaller than a City/Town. Townships sit here (not + * Tier 1) so a Geographic Township can't outrank a nearby City + * (the York-vs-Toronto symptom from Atom 10). * Tier 3: Borough (Arrondissement) — sub-divisions of cities, returned * when a user is inside one (e.g. Saint-Hubert in Longueuil). * @@ -27,15 +34,31 @@ $gen_term_preference_tiers = [ 1 => [ + // Major settlements with addressable population centers. + // "City" / "Town" are the dominant Canadian municipal types. + // "Metropolitan Area" / "Community" are the original + // preferred_terms from the legacy two-pass logic — reinstated + // here because Halifax / Vancouver / Edmonton-class metros + // often carry one of these two gen_terms in geocoder.all_areas + // rather than 'City'/'Town'. 'City', 'Town', 'Separated Town', + 'Metropolitan Area', + 'Community', + ], + 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', - ], - 2 => [ + // Named settlements smaller than City/Town. 'Village', 'Village Municipality', 'Northern Village', @@ -65,6 +88,7 @@ 'Townsite', ], 3 => [ + // Sub-divisions of cities (Saint-Hubert, Greenfield Park, etc.). 'Borough', ], ]; From 2bea45bcd45094fddfe41f150c565240fdc98498 Mon Sep 17 00:00:00 2001 From: Renoir Boulanger Date: Wed, 6 May 2026 02:03:15 -0400 Subject: [PATCH 5/8] CLIM-1322 (3): Atom11a Drop Community from tier 1; Metropolitan Area alone for metros MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atom 11 promoted Community to Tier 1 alongside Metropolitan Area to fix the Halifax-class metro case, but Community is double-purpose in geocoder.all_areas — it serves both as a canonical city name (e.g. Toronto downtown) AND as neighborhood names within larger cities. The neighborhood usage regressed Vancouver to "West End, BC" (Community at 919m) beating "Vancouver, BC" (City at 2.5km) — exactly the original Carrington-class wrong-name pathology this ticket is fixing. Metropolitan Area alone in Tier 1 keeps Halifax fixed without the Community bleed; Community now falls to the implicit Tier 99 fallback and only wins when no Tier-1/2/3 row exists in the bounding box. Probe matrix — all 10 anchors return the canonical city/town: ┌─────────────────────────┬──────────────────┬─────────────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────────┐ │ Anchor │ Returned │ tier 1 generic_term │ distance │ ├─────────────────────────┼──────────────────┼─────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ Vancouver │ Vancouver, BC │ City │ 2.5 km ✅ │ ├─────────────────────────┼──────────────────┼─────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ McMasterville │ Beloeil, QC │ Ville │ 3.2 km ✅ (closest Town per spec — UC-Search uses autocomplete row directly via Atom 9-equiv anyway) │ ├─────────────────────────┼──────────────────┼─────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ Bedford │ Bedford, QC │ Town │ 0 ✅ │ ├─────────────────────────┼──────────────────┼─────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ Sainte-Adèle │ Sainte-Adèle, QC │ Ville │ 0 ✅ │ ├─────────────────────────┼──────────────────┼─────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ Montréal downtown │ Montréal, QC │ Town │ 1.3 km ✅ │ ├─────────────────────────┼──────────────────┼─────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ Edmonton │ Edmonton, AB │ City │ 1.3 km ✅ │ ├─────────────────────────┼──────────────────┼─────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────┤ │ Calgary │ Calgary, AB │ City │ 1.0 km ✅ │ └─────────────────────────┴──────────────────┴─────────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────┘ --- .../functions/gen-term-preferences.php | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/fw-child/resources/functions/gen-term-preferences.php b/fw-child/resources/functions/gen-term-preferences.php index 6ca670b4c..ef0925f7e 100644 --- a/fw-child/resources/functions/gen-term-preferences.php +++ b/fw-child/resources/functions/gen-term-preferences.php @@ -14,11 +14,21 @@ * Anything not listed here falls into the implicit "rest" tier (rank 99) * and only wins if no Tier-1/2/3 row exists in the search bounding box. * - * Tier 1: City / Town / Metropolitan Area / Community — addressable - * population centers. Metropolitan Area / Community are reinstated - * from the legacy two-pass logic so Halifax / Vancouver / Edmonton- - * class metros (which carry one of those gen_terms in - * geocoder.all_areas rather than 'City') resolve correctly. + * Tier 1: City / Town / Separated Town / Metropolitan Area — addressable + * population centers. Metropolitan Area is reinstated from the + * legacy two-pass logic so Halifax-class metros (which carry that + * gen_term in geocoder.all_areas rather than 'City') resolve + * correctly. 'Community' is intentionally NOT in Tier 1: it is + * double-purpose in geocoder.all_areas — it serves both as a + * canonical city name (e.g. Toronto downtown) AND as neighborhood + * names within larger cities (West End in Vancouver, Mount Pleasant, + * Fairfield, …). The neighborhood usage is far more common and + * produces the wrong-name-for-search/locate-me/click pathology + * this ticket is fixing (Atom 11 promoted Community and regressed + * Vancouver to "West End, BC" at 919m beating "Vancouver, BC" City + * at 2.5km — exactly the original Carrington-class bug). Community + * falls to the implicit Tier 99 fallback and only wins when no + * Tier-1/2/3 row exists in the bounding box. * Tier 2: Township / Village / Municipality / Hamlet variants — named * settlements smaller than a City/Town. Townships sit here (not * Tier 1) so a Geographic Township can't outrank a nearby City @@ -36,16 +46,19 @@ 1 => [ // Major settlements with addressable population centers. // "City" / "Town" are the dominant Canadian municipal types. - // "Metropolitan Area" / "Community" are the original - // preferred_terms from the legacy two-pass logic — reinstated - // here because Halifax / Vancouver / Edmonton-class metros - // often carry one of these two gen_terms in geocoder.all_areas - // rather than 'City'/'Town'. + // "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'. + // "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', - 'Community', ], 2 => [ // Township-class. In Canadian municipal hierarchies a Township From 4e44357b299dfe3abf58850b894857c930cf9178 Mon Sep 17 00:00:00 2001 From: Renoir Boulanger Date: Wed, 6 May 2026 03:02:48 -0400 Subject: [PATCH 6/8] CLIM-1322 (3): Atom12 Use short-form title for UC-Search popup; expose province_short in autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atom 9-equivalent's buildLocationTitle() produced the verbose autocomplete dropdown display ("Montréal, (Ville), Montréal; Montréal, Québec") in the popup title; make UC-Search's popup match the server-resolved short shape ("Montréal, QC") that cdc_get_location_by_coords already returns. Server-side cdc_location_search() now adds province_short to each item, derived via the existing short_province() helper from fw-child/functions.php (already used by cdc_get_location_by_coords and cdc_get_location_by_id), so the client doesn't need its own province-name mapping. buildLocationTitle() is unchanged — leaflet-search still uses it as the dropdown display key. --- apps/src/components/map-layers/search-control.tsx | 12 +++++++++--- apps/src/types/types.ts | 6 ++++++ fw-child/resources/functions/rest.php | 7 ++++++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/src/components/map-layers/search-control.tsx b/apps/src/components/map-layers/search-control.tsx index 6ed0c7771..dca793d15 100644 --- a/apps/src/components/map-layers/search-control.tsx +++ b/apps/src/components/map-layers/search-control.tsx @@ -304,11 +304,17 @@ const SearchControl = ({ }), moveToLocation: (latlng: SearchLatLng, title: string) => { // leaflet-search invokes this with (latlng, title, map); - // `title` is the same key formatData() used in formattedData, - // so we can recover the original autocomplete row from it. + // `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) { - handleLocationChange(latlng, { item, title }); + // 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. 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 4870c3407..dcb576220 100644 --- a/fw-child/resources/functions/rest.php +++ b/fw-child/resources/functions/rest.php @@ -597,7 +597,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. + // {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], @@ -605,6 +609,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] ); From 9bce4004f048705403b436b32d7d93ef30a2c7f4 Mon Sep 17 00:00:00 2001 From: Renoir Boulanger Date: Wed, 6 May 2026 15:30:33 -0400 Subject: [PATCH 7/8] CLIM-1322 (3): Atom13 Promote Municipality to tier 1; specialized variants stay in tier 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking near Verchères returned L'Assomption because Tier-1 Town beat Tier-2 Municipality regardless of distance. In Quebec — and broadly across Canada — Ville and Municipalité are equivalent municipal types: both are populated places governed by an elected council. Promote the generic 'Municipality' to Tier 1; keep specialized variants (Specialized / District / Rural / Parish / Resort / Mountain Resort / Township / United Townships / Village / Northern Village / Cree Village / Naskapi Village Municipality) in Tier 2 since 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. --- .../functions/gen-term-preferences.php | 51 +++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/fw-child/resources/functions/gen-term-preferences.php b/fw-child/resources/functions/gen-term-preferences.php index ef0925f7e..87fb97fc3 100644 --- a/fw-child/resources/functions/gen-term-preferences.php +++ b/fw-child/resources/functions/gen-term-preferences.php @@ -14,11 +14,25 @@ * Anything not listed here falls into the implicit "rest" tier (rank 99) * and only wins if no Tier-1/2/3 row exists in the search bounding box. * - * Tier 1: City / Town / Separated Town / Metropolitan Area — addressable - * population centers. Metropolitan Area is reinstated from the - * legacy two-pass logic so Halifax-class metros (which carry that - * gen_term in geocoder.all_areas rather than 'City') resolve - * correctly. 'Community' is intentionally NOT in Tier 1: it is + * Tier 1: City / Town / Separated Town / Metropolitan Area / Municipality — + * addressable population centers. Metropolitan Area is reinstated + * from the legacy two-pass logic so Halifax-class metros (which + * carry that gen_term in geocoder.all_areas rather than 'City') + * resolve correctly. 'Municipality' joins City/Town/Metropolitan + * Area here because in Quebec — and broadly across Canada — Ville + * and Municipalité are equivalent municipal types: both are + * populated places governed by an elected council. Keeping the + * generic 'Municipality' in Tier 2 produced wrong-place behavior + * (clicking near Verchères returned L'Assomption because Tier-1 + * Town beat Tier-2 Municipality regardless of distance). The + * specialized municipality 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 intentionally NOT in Tier 1: it is * double-purpose in geocoder.all_areas — it serves both as a * canonical city name (e.g. Toronto downtown) AND as neighborhood * names within larger cities (West End in Vancouver, Mount Pleasant, @@ -29,10 +43,14 @@ * at 2.5km — exactly the original Carrington-class bug). Community * falls to the implicit Tier 99 fallback and only wins when no * Tier-1/2/3 row exists in the bounding box. - * Tier 2: Township / Village / Municipality / Hamlet variants — named - * settlements smaller than a City/Town. Townships sit here (not - * Tier 1) so a Geographic Township can't outrank a nearby City - * (the York-vs-Toronto symptom from Atom 10). + * Tier 2: Township / Village / Hamlet variants and specialized + * Municipality sub-types — named settlements smaller than a + * City/Town, plus special-purpose municipal designations. + * Townships sit here (not Tier 1) so a Geographic Township can't + * outrank a nearby City (the York-vs-Toronto symptom from Atom + * 10). Specialized Municipality variants sit here (not Tier 1) + * so a Parish/Rural/Resort Municipality can't outrank a generic + * Municipality or Town nearby. * Tier 3: Borough (Arrondissement) — sub-divisions of cities, returned * when a user is inside one (e.g. Saint-Hubert in Longueuil). * @@ -49,6 +67,19 @@ // "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 @@ -59,6 +90,7 @@ 'Town', 'Separated Town', 'Metropolitan Area', + 'Municipality', ], 2 => [ // Township-class. In Canadian municipal hierarchies a Township @@ -88,7 +120,6 @@ 'Naskapi Village Municipality', 'First Nation Village', 'Former First Nation Village', - 'Municipality', 'Specialized Municipality', 'District Municipality', 'Rural Municipality', From b05a4c50f21909a1f9feb550478b09439b24c2cb Mon Sep 17 00:00:00 2001 From: Renoir Boulanger Date: Thu, 7 May 2026 13:53:15 -0400 Subject: [PATCH 8/8] CLIM-1322 (3): Atom14 Comment SQL plumbing for reader orientation WHY: Renoir reading the data-driven SQL-fragment helper found the placeholder/bind-type/value arrays hard to follow on first read. The existing helper docblock described outputs abstractly without showing the input -> output shape with concrete values, and the call-site array_merge sequences had no reminder that order is positionally tied to $types. Add a worked-example block to the helper docblock (showing actual gen_term strings flowing into case_values, case_types, etc), one inline comment in the foreach loop explaining what each iteration emits, and a two-line "order MUST mirror $types" reminder above each array_merge call site in rest.php. No logic changes. --- .../functions/gen-term-preferences.php | 36 ++++++++++++++----- fw-child/resources/functions/rest.php | 4 +++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/fw-child/resources/functions/gen-term-preferences.php b/fw-child/resources/functions/gen-term-preferences.php index 87fb97fc3..a727f9fb1 100644 --- a/fw-child/resources/functions/gen-term-preferences.php +++ b/fw-child/resources/functions/gen-term-preferences.php @@ -172,18 +172,32 @@ * Build the SQL fragments needed to apply gen_term preference tiers and * hard exclusions in a prepared statement. * - * Given the tiers map, returns: - * - case_sql: "CASE WHEN gen_term IN (?, ?, ...) THEN 1 WHEN ... ELSE 99 END" - * — used in SELECT (aliased as preference_tier) and ORDER BY. - * - excl_sql: "gen_term NOT IN (?, ?, ...)" — used in WHERE. - * - case_values: flat array of strings for the CASE WHEN binds, in order. - * - excl_values: flat array of strings for the NOT IN binds, in order. - * - case_types: 's' repeated for each case_value (for mysqli_stmt_bind_param). - * - excl_types: 's' repeated for each excl_value. - * * 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} @@ -202,6 +216,10 @@ function cdc_build_gen_term_fragments ( array $tiers, array $excluded ) { 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 ); diff --git a/fw-child/resources/functions/rest.php b/fw-child/resources/functions/rest.php index dcb576220..85a94a4c4 100644 --- a/fw-child/resources/functions/rest.php +++ b/fw-child/resources/functions/rest.php @@ -427,6 +427,8 @@ function cdc_get_location_by_coords () { $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'], @@ -561,6 +563,8 @@ function cdc_location_search() { // 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'],