Skip to content

Commit 3022989

Browse files
committed
Update the HTMLMapmlViewerElement doc for zoomToExtent method.
Add custom handler tutorial draft Update search.md user guide page, factored out the tutorial bits Update plan prompt to keep track of what's done / left to do
1 parent 1387642 commit 3022989

5 files changed

Lines changed: 430 additions & 167 deletions

File tree

docs/api/mapml-viewer-api.mdx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,8 @@ let zoom = map.zoom;
223223
| [viewSource()](#viewsource) | View the source of the map. |
224224
| [defineCustomProjection(options)](#definecustomprojectionoptions) | Define a custom projection for use by the page. |
225225
| [zoomTo(lat, lon, zoom)](#zoomtolat-lon-zoom) | Fly or pan the map to a (new) location and zoom level.|
226-
| [geojson2mapml(json, options)](#zoomtolat-lon-zoom) | Add a GeoJSON Layer to the map. |
226+
| [zoomToExtent(west, south, east, north)](#zoomtoextentwest-south-east-north) | Fit the map view to a geographic extent. |
227+
| [geojson2mapml(json, options)](#geojson2mapml) | Add a GeoJSON Layer to the map. |
227228
| [matchMedia(mediaQueryString)](#matchmediamediaquerystring) | Returns a [MediaQueryList](https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList)-like object.
228229

229230

@@ -330,6 +331,19 @@ navigator.geolocation.getCurrentPosition(success, error, options);
330331

331332
---
332333

334+
### zoomToExtent(west, south, east, north)
335+
336+
Fit the map view to the geographic extent defined by the four coordinate values,
337+
automatically choosing the appropriate maximum zoom level.
338+
339+
```js
340+
let map = document.querySelector('mapml-viewer');
341+
// Fit the map to show southern Ontario
342+
map.zoomToExtent(-80, 43, -70, 48);
343+
```
344+
345+
---
346+
333347
### geojson2mapml(json, options)
334348

335349
Convert a GeoJSON feature or feature collection string or object to MapML [`<map-layer>`](/web-map-doc/docs/elements/layer/) containing one or more [`<map-feature>`](/web-map-doc/docs/elements/feature/) elements. Returns and adds the converted layer element to the map.

docs/user-guide/custom-handlers.md

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
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

Comments
 (0)