Skip to content

CLIM-1322 (2): Map popup name resolution — server-side rewrite + dispatchMapClick refactor#682

Draft
renoirb wants to merge 11 commits into
mainfrom
CLIM-1322_2_Popup-Title-Name-Resolution
Draft

CLIM-1322 (2): Map popup name resolution — server-side rewrite + dispatchMapClick refactor#682
renoirb wants to merge 11 commits into
mainfrom
CLIM-1322_2_Popup-Title-Name-Resolution

Conversation

@renoirb
Copy link
Copy Markdown
Contributor

@renoirb renoirb commented May 1, 2026

Popup Title Display Discrepancy When InteractiveRegionOption.GRIDDED_DATA

Related:

WIP

Summary

Rewrites cdc_get_location_by_coords() and refactors dispatchMapClick so the map popup title and pin position match what the user asked for, across all four entry paths (autocomplete selection, raw lat,lng paste, locate-me, manual map click) and both languages.

What was wrong

Per the original report and clarifications we've established back around 2026-04-17:

  • Search "Vancouver" → popup says "Mount Pleasant" (a Vancouver neighbourhood). Same shape on Toronto → North York. Caused by the server's cdc_get_location_by_coords() two-pass loop: it first filtered candidate rows to gen_term IN ('Community','Metropolitan Area') and only fell through to a broader query when that failed. Search and click ran through the same endpoint and produced different name spaces because the click-side filter excluded rows autocomplete returned.
  • Raw lat,lng paste snaps to the nearest DB row: typing 44.646445,-63.619798 (slightly West of Halifax) sent the marker to 44.647650,-63.590240 instead. Two distinct user inputs collapsed to the same response.
  • FR popup titles end with a trailing comma: "Sainte-Adèle, ", "Victoria, ". The PHP function selected province_fr via column-name concatenation but never aliased the returned column back to the province key, so $result['province'] was undefined in FR. province_short came out null and the title slot for the province abbreviation was empty.
  • Search-bar selection produces a popup at a slightly-drifted coord: dispatchMapClick(map, latlng) dropped its latlng argument (literally void latlng;) and dispatched the synthetic click at the container's centre. After setView(latlng) tile-snapped, the centre's actual lat/lng was off the typed value by 30–120 m — observable as the Session 8 DoD-2 coord-drift hypothesis.
  • Raw-coord popups display an opaque 5-character string (EJK8O, EPYTF, …) instead of a place name. The locationNotFound branch passed locationByCoords.geo_id to showLocation as the title argument; the server already returned a human-readable title field, but it wasn't being used.

What this changes

Eight atomic commits on CLIM-1322_2_Popup-Title-Name-Resolution, one per concrete change:

  1. Alias province_fr back to province in cdc_get_location_by_coords() SELECT — matches the pattern already used by cdc_get_location_by_id() in the same file. Fixes the trailing-comma title.
  2. Drop the preferred_terms two-pass; harmonise gen_term exclusion with cdc_location_search() (('Administrative Region','Province','Territory')). Single nearest-neighbour query at range 0.2°. Fixes the Vancouver/Toronto and Victoria search/click name divergence.
  3. Use locationByCoords.title for raw-coord popup, not geo_id in apps/src/components/map-layers/search-control.tsx's locationNotFound branch. Fixes the opaque 5-char popup title.
  4. Add Census Division to the gen_term exclusion — first attempt at the Sainte-Adèle case where the Town and the Census Division share centroid coords.
  5. Add Census Subdivision to the gen_term exclusion — same family.
  6. Rename dispatchMapClickmoveAndPointAt; honour the typed lat/lng — the function now takes responsibility for setView (folded in) and computes the synthetic click's clientX/clientY via map.latLngToContainerPoint(latlng) so the click fires at the typed coord, not the tile-snapped centre. Both call sites updated. The "dispatch click" framing was the implementation detail; the new name describes the purpose.
  7. Broaden the gen_term exclusion to drop all administrative-grouping rows — adds County, County Regional Municipality, all variants of *Municipality, *District, Region, Regional District, Subdivision, Unorganized Territory, plus Administrative Sector (lost from the original 4-term exclusion in the previous harmonisation step). None of these are place names a user types into a search bar to find a specific spot. Resolves Sainte-Adèle.
  8. Restore the canvas-vs-elementFromPoint workaround comment as an inline code comment near the canvas-enumeration block — the original JSDoc carried the explanation; it was dropped during the rename refactor and restored where it actually applies.

Why these specific changes

Carrington's reply collapsed the (mode × entry path) ambiguity matrix — search and click must return the same names; raw lat,lng must keep the typed coords. The fix needed to be at the server, not in the client's title plumbing. An earlier illustration (PR #674, draft) tried passing the autocomplete title through to the popup via a searchProvidedTitle argument; the present PR replaces that approach. The server-side rewrite reaches the click-on-map path that no client-side title plumbing could reach, and matches behaviour across the autocomplete, raw-coord-paste, and locate-me paths because they all go through the same endpoint.

The exclusion list grew through three iterations because the data has multiple administrative-grouping rows at city centroids — Census Division (Atom 4), Census Subdivision (Atom 5), then County Regional Municipality and others (Atom 7). Each step was driven by what the data actually returned at the verification anchors. The autocomplete-only filter (Atom 2's exclusion of just Administrative Region/Province/Territory) was sufficient for major cities (Toronto, Vancouver, Halifax) but not for towns whose centroid coincides with their administrative parent (Sainte-Adèle).

The dispatchMapClick refactor was on the same path: the function existed to bridge "the user said they want to go here" with "trigger the same cascade as a manual click would", but it had been dropping the latlng argument and approximating the destination via the container centre. The Session 8 coord-drift was one symptom; Renoir flagged it independently this session as a "the name is a misnomer" observation about the function's purpose vs its name.

Verification

API-level probes against dev-en and dev-fr (raw HTTP, JSON parse, no Xdebug pollution observed in any probe):

Anchor Pre-fix Post-fix
Vancouver search-by-name Mount Pleasant, BC Vancouver, BC
Toronto search-by-name North York, ON Toronto, ON
Sainte-Adèle search-by-name Les Pays-d'en-Haut, Sainte-Adèle, QC
Victoria search-by-name (EN) Fairfield, BC Victoria, BC
Victoria search-by-name (FR) Victoria, (trailing comma) Victoria, BC
Halifax raw-coord centre Halifax, NS (unchanged) Halifax, NS
Halifax raw-coord West offset Halifax, NS (snap — original client complaint) Chocolate Lake, NS (no snap; coords carries typed coords back)

Click-precision spot-checks (5–10 km offsets from city centroids) confirm clicks far from a city return locally-appropriate places, not the city — exactly the behaviour the wide 0.2° bounding box was a theoretical concern about. The bounding box is a SQL optimisation; ORDER BY DISTANCE LIMIT 1 finds the truly-nearest allowed row.

Known limitations / not done

  • Browser-level UAT pending — the API path is verified end-to-end; the visual marker placement after Atom 6's coord-precision fix and the popup-rendering path have not been exercised in a real browser run.
  • Urban Community / Railway Point / Railway Junction rows are now reachable through the click path (the original 4-term exclusion no longer applies). Empirically: a click in downtown Toronto can land on Cabbagetown, ON (Urban Community) instead of Toronto, ON because Cabbagetown's stored centroid is closer to the click. Within Carrington's "match autocomplete" directive, but flagged for domain-expert review on whether it's the desired behaviour for InteractiveRegionOption.GRID map clicks.
  • Xdebug suppression on dev for cdc/v2 endpoints (Slice C, separable) — not addressed. Tracked as T23.
  • EN/FR parallel-WebKit comparison workflow lift (T25, LEARN phase) — not addressed.
  • Popup "(sometimes) doesn't open" intermittent timing — observed during Session 8, not investigated this session. Past notes from a prior session may have a path; deferred.
  • Stakeholder direction on explicit-typing of admin-grouping names — if a user explicitly types "Les Pays-d'en-Haut" expecting that exact popup, the broadened exclusion now returns the nearest non-grouping place at the centroid. Honouring "show what was typed verbatim" requires passing the autocomplete row's id_code through to a fetch-by-id, which is the architectural change descoped in the analysis plan as D1c (preserved as TechDebt FI1).

What this does not touch

Frozen historical artefact: branch CLIM-1322_Popup-Title-Name-Resolution and its draft PR #674. Not amended, not pushed, not closed — preserved per Renoir's instruction.

Documentation

  • [[LLM-Context-ClimateData-Ticket-CLIM-1322#Session 9]] — work session narrative, files modified, verification matrix.
  • [[LLM-Context-ClimateData-Ticket-CLIM-1322-Analysis-Plan#D4]] — filter-list shape decision (D4b broader admin-grouping list picked).
  • [[LLM-Context-ClimateData-Ticket-CLIM-1322-Analysis-Plan#D5]] — moveAndPointAt refactor decision (D5b rename+fold+honour-latlng picked).
  • New CIs added to the analysis plan: CI-17 (dispatchMapClick latlng-discard), CI-18 (all_areas schema audit), CI-19 (Sainte-Adèle admin-grouping coord-tie), CI-20 (Halifax raw-coord post-fix behaviour).

renoirb added 9 commits May 1, 2026 00:27
…coords()

The function selected "province_fr" (or "province") via $term_append but
the result row was always read as $result['province']. In FR this produced
"Undefined array key" warnings and a null province_short, which in turn
yielded titles like "Sainte-Adèle, " (trailing comma).

Aliases the column back to the "province" key, matching the pattern
already used by cdc_get_location_by_id() at the same file.
…autocomplete

cdc_get_location_by_coords() previously ran a two-pass loop that only
accepted rows whose gen_term was in ('Community','Metropolitan Area'),
falling through to a generic nearest-neighbour query when none was found.
The two paths used different gen_term exclusion lists than
cdc_location_search() — so search and click returned different name
spaces for the same coordinates (e.g. searching "Victoria" landed on
"Fairfield" via the snap; the autocomplete row was filtered out).

Per client clarification 2026-04-17 (Carrington Pomeroy / ECCC), search
and click must return the same names. This commit:

- Removes the $preferred_terms / $found_community two-pass loop entirely.
- Replaces it with a single nearest-neighbour query (range 0.2°,
  ORDER BY distance, LIMIT 1).
- Aligns the gen_term NOT IN exclusion with cdc_location_search()'s
  ('Administrative Region','Province','Territory'), removing
  asymmetry between the two endpoints.
In the locationNotFound branch (raw lat,lng paste), showLocation()'s
title argument was the opaque 5-char geo_id (e.g. "EPYTF", "EJK8O"),
producing a popup title users could not read.

The fetched response already carries a human-readable title field built
server-side; pass that instead.
…ion_by_coords()

Verification at Sainte-Adèle (autocomplete row EQMGV / Town at
45.95,-74.1333334) showed the reverse-lookup at the exact same coord
returned EPYTF / Les Pays-d'en-Haut / Census Division at distance 0 —
tied distance with the Town row, Census Division winning.

Census Division rows are administrative groupings; users never type
their names into the search box (autocomplete LIKE matches city-level
names), so they never appear in autocomplete suggestions. They should
not appear in reverse-lookup either.

Note: this change does NOT fully resolve Sainte-Adèle. With Census
Division excluded, the next admin row at the same coord wins —
EKTUS / Les Pays-d'en-Haut / County Regional Municipality. The
asymmetry between LIKE-driven autocomplete and lat/lon-driven
reverse-lookup at densely-populated centroids needs broader treatment
(see follow-up commit / open question).
Mirrors the Census Division exclusion from the previous commit on the
same reasoning: census-level administrative groupings are never
selectable through autocomplete (their geo_name doesn't match typed
place names) and should not be returnable from reverse-lookup either.

Open question (not resolved by this commit): the underlying asymmetry
between autocomplete (geo_name LIKE) and reverse-lookup (lat/lon range)
allows multiple administrative-grouping rows to coexist at a city's
centroid coordinates. Sainte-Adèle (45.95,-74.1333334) currently still
returns EKTUS / County Regional Municipality after this change. A
broader treatment is needed: either an inclusion list of "user-pickable"
gen_terms, or a tiebreaker on the all_areas.scale column so smaller
units win at tied distances. Decision deferred — surfacing for review.
… lat/lng

The old function had three problems: it dropped its latlng argument
(literally `void latlng`) and dispatched the synthetic click at the map
container's centre, it required every caller to call map.setView() just
before it, and its name described the implementation detail (dispatch a
click event) rather than the purpose (drive the map to where the user
asked to go and trigger the location-selection cascade there).

Renames the file and symbol to moveAndPointAt and folds the setView call
inside the function. The synthetic click is now dispatched at
map.latLngToContainerPoint(latlng) instead of the container centre, so
the click — and therefore the downstream popup-title and data-fetch —
lands at exactly the typed coordinates rather than at whatever the map
view tile-snapped to.

Both call sites updated:
- handleLocationChange in search-control.tsx (search and locate-me)
- moveToLocation in recent-locations.tsx (recent-locations panel)

Removes the now-redundant SEARCH_DEFAULT_ZOOM import from both callers.
…ping rows

Atoms 4 and 5 added Census Division and Census Subdivision to the
exclusion but Sainte-Adèle still fell through to other admin-grouping
rows that share the city's stored centroid coords (Les Pays-d'en-Haut,
gen_term = "County Regional Municipality" — an MRC, encompassing many
cities; users searching "Sainte-Adèle" do not want that as the popup
result).

This commit excludes the broader family of administrative-grouping
gen_term values that exist in all_areas: counties, regional districts
and municipalities, county regional municipalities, district
municipalities, generic municipalities, regions, subdivisions, plus
the various flavours of districts and unorganised-territory rows.
None of these are places a user types into a search bar to find a
specific spot on the map; they are containers naming areas that
encompass many cities.

Verified on dev: Sainte-Adèle, Toronto, Montréal, Québec, Ottawa,
Vancouver, Halifax, Calgary, Banff — autocomplete row and reverse-
lookup at autocomplete coords now return the same place name in
every case.

Known limitation: if a user explicitly types the name of an
administrative grouping (e.g. "Les Pays-d'en-Haut"), the popup
at its centroid will show the nearest non-excluded place instead
of the grouping itself. Honouring that case requires passing the
selected autocomplete row's id_code through to a fetch-by-id, an
architectural change descoped per the analysis plan's D1c.
… its site

The original dispatch-map-click.ts top-of-file JSDoc carried a useful
note explaining why the function enumerates gridPane canvases and
filters by bounding rect rather than calling document.elementFromPoint.
The note was dropped during the rename to moveAndPointAt because the
docblock was reorganised around purpose ("move and point at") and the
implementation detail no longer fit there.

Putting it back as a code comment immediately above the canvas
enumeration where it actually applies — a future reader of that block
needs to know why elementFromPoint is not used (Leaflet's pane wrapper
divs have pointer-events: none, so elementFromPoint returns the
outermost container instead of the canvas tile underneath).
@renoirb renoirb self-assigned this May 1, 2026
@renoirb renoirb marked this pull request as ready for review May 1, 2026 15:07
@renoirb renoirb requested review from ChaamC and xfrenette-crim May 1, 2026 15:07
@renoirb
Copy link
Copy Markdown
Contributor Author

renoirb commented May 1, 2026

I'm sharing this but it's still a draft of sorts.

This should be about functionality and making sure it solves what we want to solve, and confirming our hypothesis of the improvements we've assumed such changes would give.

Once we're settled with all examples of inputs and comparing what this Change-Set does, I'll clean up any excess edits.

What I'd like to adjust if applicable:

  • Ensure the SQL query is safe, we should use modern/current prepared statements (basically a "dumb" string with placeholders and native functions to properly sanitize the SQL query)

Code clarifications:

  • The function dispatchClick function is meaningless to what it's actually about: move the map and show information at a specific location.
  • ...

renoirb added 2 commits May 4, 2026 15:40
…c click

The synthetic PointerEvent dispatched by moveAndPointAt forces Leaflet's
downstream click handler to rebuild `latlng` via containerPointToLatLng on
the integer client pixel coords we just computed via latLngToContainerPoint.
That round-trip drops sub-pixel precision (~80 m drift at typical zooms),
and for InteractiveRegionOption.GRIDDED_DATA the drifted coord is what
fetchLocationByCoords resolves into the popup title — landing on the wrong
gridded row at places like Montreal under the current broadened gen_term
exclusion list.

Restore the caller-supplied precise latlng via a per-map WeakMap channel
inside move-and-point-at.ts: the producer (the only path that creates the
synthetic click) stashes `latlng` keyed by the L.Map instance immediately
before dispatch; the consumer (useMapInteractions.handleClick) reads-and-
deletes via consumeIntendedLatlng. One-shot read so a stale entry cannot
bleed into a later real user click. WeakMap keying lets entries get GC'd
with the map.

The channel lives in move-and-point-at.ts (not threaded through React)
because the producers — SearchControl and RecentLocationsPanel — and the
consumer (useMapInteractions, mounted in Map) sit in three different React
subtrees that share only the Leaflet L.Map instance. This way every
moveAndPointAt caller benefits with no caller-side opt-in: search, locate-
me, and recent-locations all get the precise coordinate.

Polygon modes (CENSUS / HEALTH / WATERSHED) are unaffected behaviorally —
they override locationTitle from layer.properties.label_en/label_fr — but
the latlng now reaching setSelectedLocation is more precise, which feeds
R4 downstream consumers more accurately too.
@renoirb-crim renoirb-crim marked this pull request as draft May 5, 2026 20:56
renoirb added a commit that referenced this pull request May 19, 2026
Edited: 2026-05-19

This branch was originally written BEFORE creating `dispatchMapClick` and has been
now merged to main in PR #676. During the rebase that followed 676 the important
aspect of `CLIM-1322_Popup-Title-Name-Resolution` was to pass the title.

Passing the title was an illustrative "band aid" and we've kept this branch
to return back to it later.

After Passes 2 supplemental passes (...):
- #682 branch `CLIM-1322_2_Popup-Title-Name-Resolution`
- #684 branch `CLIM-1322_3_Popup-Title-Name-Resolution`

We've decided to return to what this branch initially suggested.
Simply to pass the title from the click handler.

This commit is there to illustrate what was lost during the iterations to set the record straight.

Commits that had gotten lost and were part of this branch this commit is replacing:
- 72a0df4
- f9169bb

Earlier commit message:

Map search popup was showing neighboring Community names (e.g. "Toronto" →
"North York") because handleLocationChange fired a synthetic click that
discarded the search item's pre-resolved title, forcing handleClick to
re-derive from lat/lng via fetchLocationByCoords, which snaps to the
nearest 'Community' / 'Metropolitan Area' and filters out 'City' entries
server-side.

D1a from the Analysis Plan: plumb buildLocationTitle(item) through
handleLocationChange into the synthetic click payload as an optional
searchProvidedTitle field. handleClick prefers it over fetchLocationByCoords
when the mode is GRIDDED_DATA, skipping the network call on the happy path.

This is the third optional field on the synthetic click payload (joining
layer.properties from CLIM-1223). Submitted as a draft PR for team
discussion on whether to continue the bandaid pattern or escalate to the
structural refactor in D1c. See [[LLM-Context-ClimateData-Ticket-CLIM-1322-Analysis-Plan]]
for the full design trade-off.
renoirb added a commit that referenced this pull request May 19, 2026
Edited: 2026-05-19

This branch was originally written BEFORE creating `dispatchMapClick` and has been
now merged to main in PR #676. During the rebase that followed 676 the important
aspect of `CLIM-1322_Popup-Title-Name-Resolution` was to pass the title.

Passing the title was an illustrative "band aid" and we've kept this branch
to return back to it later.

After Passes 2 supplemental passes (...):
- #682 branch `CLIM-1322_2_Popup-Title-Name-Resolution`
- #684 branch `CLIM-1322_3_Popup-Title-Name-Resolution`

We've decided to return to what this branch initially suggested.
Simply to pass the title from the click handler.

This commit is there to illustrate what was lost during the iterations to set the record straight.

Commits that had gotten lost and were part of this branch this commit is replacing:
- 72a0df4
- f9169bb

Earlier commit message:

Map search popup was showing neighboring Community names (e.g. "Toronto" →
"North York") because handleLocationChange fired a synthetic click that
discarded the search item's pre-resolved title, forcing handleClick to
re-derive from lat/lng via fetchLocationByCoords, which snaps to the
nearest 'Community' / 'Metropolitan Area' and filters out 'City' entries
server-side.

D1a from the Analysis Plan: plumb buildLocationTitle(item) through
handleLocationChange into the synthetic click payload as an optional
searchProvidedTitle field. handleClick prefers it over fetchLocationByCoords
when the mode is GRIDDED_DATA, skipping the network call on the happy path.

This is the third optional field on the synthetic click payload (joining
layer.properties from CLIM-1223). Submitted as a draft PR for team
discussion on whether to continue the bandaid pattern or escalate to the
structural refactor in D1c. See [[LLM-Context-ClimateData-Ticket-CLIM-1322-Analysis-Plan]]
for the full design trade-off.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant