|
| 1 | +--- |
| 2 | +id: custom-handlers |
| 3 | +title: Custom Search Handlers |
| 4 | +slug: /user-guide/custom-handlers |
| 5 | +--- |
| 6 | + |
| 7 | +The [search control](../user-guide/search) works out of the box with GeoJSON |
| 8 | +APIs, such as [Photon](https://photon.komoot.io/). When your geocoding service |
| 9 | +returns a different format, you can write **custom event handlers** to transform |
| 10 | +the responses and feed them to the control via its `setResults()` method. |
| 11 | + |
| 12 | +This tutorial walks through connecting |
| 13 | +[the CGDI Geolocator API](https://natural-resources.canada.ca/maps-tools-publications/satellite-elevation-air-photos/geolocation-service) — which returns a |
| 14 | +flat JSON array, not GeoJSON — to the search control. |
| 15 | + |
| 16 | +## What You Will Build |
| 17 | + |
| 18 | +A map that: |
| 19 | + |
| 20 | +- shows **typeahead suggestions** as the user types, |
| 21 | +- performs a **full search** when the user presses Enter or selects a suggestion, |
| 22 | +- renders **clickable results** in the search dropdown that navigate the map to |
| 23 | + the chosen location. |
| 24 | + |
| 25 | +## Prerequisites |
| 26 | + |
| 27 | +- Basic HTML and JavaScript knowledge. |
| 28 | +- A page that loads MapML.js (see [Installation](../installation)). |
| 29 | + |
| 30 | +## Step 1 — Map Markup |
| 31 | + |
| 32 | +Create an HTML page with a `<mapml-viewer>` including the `controls` and `controlslist="search"` attributes. Inside a `<map-layer>`, add two `<map-link>` elements — one with `rel="search"` and |
| 33 | +one with `rel="suggestions"` — both pointing at the Geolocator API endpoint. The |
| 34 | +Geolocator service uses the same URL for both, so the `tref` values are identical: |
| 35 | + |
| 36 | +```html |
| 37 | +<mapml-viewer projection="CBMTILE" zoom="2" lat="65" lon="-96" |
| 38 | + controls controlslist="search" |
| 39 | + style="width:100%;height:50vh;"> |
| 40 | + <map-layer label="Canada Base Map" checked> |
| 41 | + <!-- highlight-start --> |
| 42 | + <map-link rel="suggestions" |
| 43 | + tref="https://geolocator.api.geo.ca/?q={searchTerms}&lang=en&keys=geonames"></map-link> |
| 44 | + <map-link rel="search" |
| 45 | + tref="https://geolocator.api.geo.ca/?q={searchTerms}&lang=en&keys=geonames"></map-link> |
| 46 | + <!-- highlight-end --> |
| 47 | + <map-extent units="CBMTILE" checked hidden> |
| 48 | + <map-input name="z" type="zoom" min="0" max="17" value="17"></map-input> |
| 49 | + <map-input name="y" type="location" axis="row" units="tilematrix"></map-input> |
| 50 | + <map-input name="x" type="location" axis="column" units="tilematrix"></map-input> |
| 51 | + <map-link rel="tile" |
| 52 | + tref="https://geoappext.nrcan.gc.ca/arcgis/rest/services/BaseMaps/CBMT3978/MapServer/tile/{z}/{y}/{x}?m4h=t"></map-link> |
| 53 | + </map-extent> |
| 54 | + </map-layer> |
| 55 | +</mapml-viewer> |
| 56 | +``` |
| 57 | + |
| 58 | +## Step 2 — Understand the Response Format |
| 59 | + |
| 60 | +Call the API in your <a href="https://geolocator.api.geo.ca/?q=ottawa&lang=en&keys=geonames" target="_blank">browser</a> to see the response format. Each result object looks like: |
| 61 | + |
| 62 | +| Field | Meaning | |
| 63 | +|------------|---------| |
| 64 | +| `name` | Place name | |
| 65 | +| `province` | Province name | |
| 66 | +| `category` | Feature type (City, River, etc.) | |
| 67 | +| `lat` | Latitude | |
| 68 | +| `lng` | Longitude | |
| 69 | +| `bbox` | `[west, south, east, north]` | |
| 70 | + |
| 71 | +This is not GeoJSON, so the default handler cannot use it directly. |
| 72 | + |
| 73 | +## Step 3 — The `setResults()` Item Structure |
| 74 | + |
| 75 | +Both the `mapsuggestions` and `mapsearch` events carry a `setResults()` method |
| 76 | +on `e.detail`. You call it with an array of item objects: |
| 77 | + |
| 78 | +```js |
| 79 | +e.detail.setResults([ |
| 80 | + { text: "Ottawa, Ontario (City)", value: "Ottawa", lat: 45.4, lng: -75.7, bbox: [...] }, |
| 81 | + // ... |
| 82 | +]); |
| 83 | +``` |
| 84 | + |
| 85 | +Each item object can have these properties: |
| 86 | + |
| 87 | +| Property | Required | Purpose | |
| 88 | +|----------|----------|---------| |
| 89 | +| `text` | yes | Display label for the dropdown button | |
| 90 | +| `value` | no | If present, clicking the item **re-searches** with this string. If absent, clicking **navigates** the map to the location. | |
| 91 | +| `lat` | no | Latitude for navigation | |
| 92 | +| `lng` | no | Longitude for navigation | |
| 93 | +| `bbox` | no | `[west, south, east, north]` — preferred for navigation | |
| 94 | + |
| 95 | +The `value` property is the key difference between a **suggestion** and a |
| 96 | +**result**: |
| 97 | + |
| 98 | +- **Suggestions** (`mapsuggestions` handler) — include `value` so that clicking |
| 99 | + a suggestion fills the search input and triggers a full search. |
| 100 | +- **Results** (`mapsearch` handler) — omit `value` so that clicking a result |
| 101 | + navigates the map to the item's location. |
| 102 | + |
| 103 | +## Step 4 — Handle `mapsuggestions` |
| 104 | + |
| 105 | +Listen for the `mapsuggestions` event on the `<mapml-viewer>`. Call `e.preventDefault()` |
| 106 | +to suppress the default GeoJSON parsing, transform the response to item structure, and call |
| 107 | +`setResults()`: |
| 108 | + |
| 109 | +```html |
| 110 | +<script> |
| 111 | + const viewer = document.querySelector('mapml-viewer'); |
| 112 | +
|
| 113 | + viewer.addEventListener('mapsuggestions', (e) => { |
| 114 | + e.preventDefault(); |
| 115 | + const items = []; |
| 116 | + for (const { data } of e.detail.responses) { |
| 117 | + if (!Array.isArray(data)) continue; |
| 118 | + for (const r of data) { |
| 119 | + items.push({ |
| 120 | + text: `${r.name}, ${r.province} (${r.category})`, |
| 121 | + // highlight-next-line |
| 122 | + value: r.name, // ← includes value: clicking re-searches |
| 123 | + lat: r.lat, |
| 124 | + lng: r.lng, |
| 125 | + bbox: r.bbox || undefined |
| 126 | + }); |
| 127 | + } |
| 128 | + } |
| 129 | + e.detail.setResults(items); |
| 130 | + }); |
| 131 | +</script> |
| 132 | +``` |
| 133 | + |
| 134 | +The control creates the dropdown buttons, wires up keyboard navigation (Arrow |
| 135 | +keys, Escape), and — because each item has a `value` — clicking a suggestion |
| 136 | +puts `value` into the search input and fires a new search automatically. |
| 137 | + |
| 138 | +## Step 5 — Handle `mapsearch` |
| 139 | + |
| 140 | +Add a second listener for `mapsearch`. This time, **omit `value`** from the items so that |
| 141 | +clicking a result navigates the map: |
| 142 | + |
| 143 | +```js |
| 144 | +viewer.addEventListener('mapsearch', (e) => { |
| 145 | + e.preventDefault(); |
| 146 | + const items = []; |
| 147 | + for (const { data } of e.detail.responses) { |
| 148 | + if (!Array.isArray(data)) continue; |
| 149 | + for (const r of data) { |
| 150 | + items.push({ |
| 151 | + text: `${r.name}, ${r.province} (${r.category})`, |
| 152 | + // highlight-next-line |
| 153 | + // no value — clicking navigates to the location |
| 154 | + lat: r.lat, |
| 155 | + lng: r.lng, |
| 156 | + bbox: r.bbox || undefined |
| 157 | + }); |
| 158 | + } |
| 159 | + } |
| 160 | + e.detail.setResults(items); |
| 161 | + // Navigate to the first result (like the default handler does) |
| 162 | + // highlight-start |
| 163 | + if (items.length > 0 && items[0].bbox) { |
| 164 | + viewer.zoomToExtent(...items[0].bbox); |
| 165 | + } else if (items.length > 0 && items[0].lat != null) { |
| 166 | + viewer.zoomTo(items[0].lat, items[0].lng, 14); |
| 167 | + } |
| 168 | + // highlight-end |
| 169 | +}); |
| 170 | +``` |
| 171 | + |
| 172 | +When the user clicks a result, the control navigates to that item's `bbox` or |
| 173 | +`lat`/`lng`. The [`zoomToExtent`](../api/mapml-viewer-api/#zoomtoextent) call |
| 174 | +above handles the initial auto-navigation to the first result when the search |
| 175 | +fires — matching the default handler's behaviour. |
| 176 | + |
| 177 | +## Step 6 — (Optional) Render Results on the Map |
| 178 | + |
| 179 | +If you want search results to appear as markers/features on the map, convert |
| 180 | +the Geolocator data to GeoJSON and pass it to |
| 181 | +[`geojson2mapml()`](../api/mapml-viewer-api/#geojson2mapml): |
| 182 | + |
| 183 | +```js |
| 184 | +let resultsLayer = null; |
| 185 | + |
| 186 | +viewer.addEventListener('mapsearch', (e) => { |
| 187 | + e.preventDefault(); |
| 188 | + |
| 189 | + // Clean up previous results |
| 190 | + if (resultsLayer) { |
| 191 | + resultsLayer.remove(); |
| 192 | + resultsLayer = null; |
| 193 | + } |
| 194 | + |
| 195 | + // Build the dropdown (same as above) |
| 196 | + const items = []; |
| 197 | + const features = []; |
| 198 | + for (const { data } of e.detail.responses) { |
| 199 | + if (!Array.isArray(data)) continue; |
| 200 | + for (const r of data) { |
| 201 | + items.push({ |
| 202 | + text: `${r.name}, ${r.province} (${r.category})`, |
| 203 | + lat: r.lat, lng: r.lng, bbox: r.bbox || undefined |
| 204 | + }); |
| 205 | + features.push({ |
| 206 | + type: 'Feature', |
| 207 | + geometry: { type: 'Point', coordinates: [r.lng, r.lat] }, |
| 208 | + properties: { name: r.name, province: r.province, category: r.category }, |
| 209 | + bbox: r.bbox || undefined |
| 210 | + }); |
| 211 | + } |
| 212 | + } |
| 213 | + e.detail.setResults(items); |
| 214 | + |
| 215 | + // Navigate to the first result |
| 216 | + if (items.length > 0 && items[0].bbox) { |
| 217 | + viewer.zoomToExtent(...items[0].bbox); |
| 218 | + } else if (items.length > 0 && items[0].lat != null) { |
| 219 | + viewer.zoomTo(items[0].lat, items[0].lng, 14); |
| 220 | + } |
| 221 | + |
| 222 | + if (features.length === 0) return; |
| 223 | + const fc = { type: 'FeatureCollection', features }; |
| 224 | + |
| 225 | + resultsLayer = viewer.geojson2mapml(fc, { |
| 226 | + label: 'Search Results', |
| 227 | + projection: viewer.projection, |
| 228 | + caption: (f) => f.properties.name |
| 229 | + }); |
| 230 | +}); |
| 231 | +``` |
| 232 | + |
| 233 | +The `geojson2mapml()` method returns a `<map-layer>` element that is |
| 234 | +automatically added to the map. Store the reference so you can remove it |
| 235 | +before adding the next round of results. |
| 236 | + |
| 237 | +## Complete Example |
| 238 | + |
| 239 | +Putting it all together: |
| 240 | + |
| 241 | +```html |
| 242 | +<!DOCTYPE html> |
| 243 | +<html lang="en"> |
| 244 | +<head> |
| 245 | + <meta charset="utf-8"> |
| 246 | + <title>Geolocator Search</title> |
| 247 | + <script type="module" src="mapml.js"></script> |
| 248 | + <style> |
| 249 | + html, body { height: 100%; margin: 0; } |
| 250 | + mapml-viewer { display: block; box-sizing: border-box; } |
| 251 | + </style> |
| 252 | +</head> |
| 253 | +<body> |
| 254 | + |
| 255 | +<mapml-viewer projection="CBMTILE" zoom="2" lat="65" lon="-96" |
| 256 | + controls controlslist="search" |
| 257 | + style="width:100%;height:100dvh;"> |
| 258 | + <map-layer label="Canada Base Map" checked> |
| 259 | + <map-link rel="suggestions" |
| 260 | + tref="https://geolocator.api.geo.ca/?q={searchTerms}&lang=en&keys=geonames"></map-link> |
| 261 | + <map-link rel="search" |
| 262 | + tref="https://geolocator.api.geo.ca/?q={searchTerms}&lang=en&keys=geonames"></map-link> |
| 263 | + <map-extent units="CBMTILE" checked hidden> |
| 264 | + <map-input name="z" type="zoom" min="0" max="17" value="17"></map-input> |
| 265 | + <map-input name="y" type="location" axis="row" units="tilematrix"></map-input> |
| 266 | + <map-input name="x" type="location" axis="column" units="tilematrix"></map-input> |
| 267 | + <map-link rel="tile" |
| 268 | + tref="https://geoappext.nrcan.gc.ca/arcgis/rest/services/BaseMaps/CBMT3978/MapServer/tile/{z}/{y}/{x}?m4h=t"></map-link> |
| 269 | + </map-extent> |
| 270 | + </map-layer> |
| 271 | +</mapml-viewer> |
| 272 | + |
| 273 | +<script> |
| 274 | + const viewer = document.querySelector('mapml-viewer'); |
| 275 | +
|
| 276 | + // --- Suggestions (typeahead) --- |
| 277 | + viewer.addEventListener('mapsuggestions', (e) => { |
| 278 | + e.preventDefault(); |
| 279 | + const items = []; |
| 280 | + for (const { data } of e.detail.responses) { |
| 281 | + if (!Array.isArray(data)) continue; |
| 282 | + for (const r of data) { |
| 283 | + items.push({ |
| 284 | + text: `${r.name}, ${r.province} (${r.category})`, |
| 285 | + value: r.name, |
| 286 | + lat: r.lat, |
| 287 | + lng: r.lng, |
| 288 | + bbox: r.bbox || undefined |
| 289 | + }); |
| 290 | + } |
| 291 | + } |
| 292 | + e.detail.setResults(items); |
| 293 | + }); |
| 294 | +
|
| 295 | + // --- Search results --- |
| 296 | + viewer.addEventListener('mapsearch', (e) => { |
| 297 | + e.preventDefault(); |
| 298 | + const items = []; |
| 299 | + for (const { data } of e.detail.responses) { |
| 300 | + if (!Array.isArray(data)) continue; |
| 301 | + for (const r of data) { |
| 302 | + items.push({ |
| 303 | + text: `${r.name}, ${r.province} (${r.category})`, |
| 304 | + lat: r.lat, |
| 305 | + lng: r.lng, |
| 306 | + bbox: r.bbox || undefined |
| 307 | + }); |
| 308 | + } |
| 309 | + } |
| 310 | + e.detail.setResults(items); |
| 311 | + // Navigate to the first result |
| 312 | + if (items.length > 0 && items[0].bbox) { |
| 313 | + viewer.zoomToExtent(...items[0].bbox); |
| 314 | + } else if (items.length > 0 && items[0].lat != null) { |
| 315 | + viewer.zoomTo(items[0].lat, items[0].lng, 14); |
| 316 | + } |
| 317 | + }); |
| 318 | +</script> |
| 319 | + |
| 320 | +</body> |
| 321 | +</html> |
| 322 | +``` |
| 323 | + |
| 324 | +## Next Steps |
| 325 | + |
| 326 | +- See the [Search](../user-guide/search) user guide for the full list of |
| 327 | + control options, multi-layer search, and accessibility details. |
| 328 | +- See the [`mapsearch`](../api/mapml-viewer-api/#mapsearch) and |
| 329 | + [`mapsuggestions`](../api/mapml-viewer-api/#mapsuggestions) event reference |
| 330 | + for the complete `e.detail` structure. |
| 331 | + |
| 332 | +## Things to Watch Out For |
| 333 | + |
| 334 | +- **Coordinate order**: GeoJSON uses `[longitude, latitude]` arrays, but `setResults()` items use named `lat` and `lng` properties — there is no array order to worry about. Just be careful when building GeoJSON features in Step 6: the `coordinates` array must be `[lng, lat]`, not `[lat, lng]`. |
| 335 | +- **The response is an array, not an object**: The Geolocator returns a bare JSON array `[{...}, {...}]` at the top level — no wrapping `features` or `results` property. |
| 336 | +- **CORS must be enabled on the remote API**: The search control fetches the geocoding URL directly from the browser using `fetch()`. If the API server does not include an `Access-Control-Allow-Origin` header in its response, the browser will block the request and no results will appear. The Geolocator API used in this tutorial supports CORS, so no proxy is needed. If you try a different geocoding service and get no suggestions or results, open the browser's developer console (F12 → Console) and look for an error like _"has been blocked by CORS policy"_. If you see that, the API does not permit cross-origin requests and you will need to either use a server-side proxy or choose a CORS-friendly endpoint. |
| 337 | +- **Rate limits**: The API is free for normal use but avoid hammering it. The search control already debounces suggestions (300ms), which helps. |
| 338 | +- **The `hidden` attribute vs `checked`**: `hidden` hides the layer from the layer control panel; `checked` controls whether it renders. You want both — `hidden` (to keep it out of the UI) and `checked` (so the features actually show). `geojson2mapml()` sets `checked` by default, so you only need to add `hidden` yourself. |
| 339 | +- **`value` controls click behaviour**: Both events use the same `e.detail.setResults()` method and the same item shape `{ text, value?, lat?, lng?, bbox? }`. If `value` is present, clicking triggers a search (suggestion mode). If `value` is absent but `lat`/`lng` are present, clicking navigates the map (result mode). Include `value` for suggestions, omit it for final results. |
0 commit comments