Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions .github/skills/map-link-markup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ defines several uses of existing and new `rel` keyword values.
| `zoomout` | The link `href` is followed automatically by the polyfill when the map is zoomed out by the user to a value less than the minimum value of the zoom range of the current layer. The referenced map layer resource replaces the current map layer. The polyfill does not represent this link as a user-visible affordance, it is followed automatically. If the remote resource does not contain a reciprocal `zoomin` link, the map state change is one-way i.e. the layer is permanently replaced. |
| `legend` | The `legend` link relation designates a link to metadata, typically an image, describing the symbology used by the current layer. Currently, the polyfill creates a hyperlink for the label of the layer in the layer control, which opens in a new browsing context. |
| `query` | The `query` link relation is used in combination with the `tref="..."` attribute to establish a URL template that composes a map query URL based on user map gestures such as click or touch. These URLs are fetched and the response presented on top of the map as a popup. Such queries can return text/html or text/mapml responses. In the latter case, the response may contain more than one feature, in which case a 'paged' popup is generated, allowing the user to cycle through the features' individual metadata. |
| `search` | The `search` link relation is used with the `tref="..."` attribute to define a URL template for a search endpoint. The template must contain the `{searchTerms}` variable reference, which is replaced with the user's URL-encoded search query. The search is triggered when the user presses Enter or clicks a suggestion. The response is expected to be a GeoJSON `FeatureCollection` (the default handler format). Only the first `<map-link rel="search">` per `<map-layer>` is honored. The link must be a direct child of `<map-layer>` (for local/inline layers) or placed inside `<map-head>` (for remote `.mapml` layers). The search control is opt-in via `controlslist="search"` on the `<mapml-viewer>` or `<map is="web-map">` element. The search button is disabled when no visible (checked) layer provides a `<map-link rel="search">`. |
| `suggestions` | The `suggestions` link relation is used with the `tref="..."` attribute to define a URL template for a suggestions/autocomplete endpoint. Like `search`, the template must contain the `{searchTerms}` variable reference. Suggestions are fetched automatically as the user types (debounced, minimum 2 characters). The default handler expects a GeoJSON `FeatureCollection` response, rendering each feature as a clickable result button (using `properties.display_name` or `properties.name`). Only the first `<map-link rel="suggestions">` per `<map-layer>` is honored. Suggestions are optional — if no `rel="suggestions"` link exists, only Enter triggers a search. |
| `stylesheet` | The link imports a CSS stylesheet from the `href` value. |


Expand Down Expand Up @@ -135,4 +137,206 @@ Projection values [defined by the polyfill](../mapml-viewer#projection) include:
</map-extent>
</layer->
</mapml-viewer>
```

### Search and Suggestions

The search control is opt-in: add `controlslist="search"` to your `<mapml-viewer>`
or `<map is="web-map">` element. A magnifying-glass button appears in the
top-left controls. The button is disabled (grayed out, `aria-disabled="true"`)
when no visible (checked) `<map-layer>` has a descendant `<map-link rel="search">`.

The `{searchTerms}` template variable in `tref` is the only required variable.
It is replaced with the user's URL-encoded query string. No sibling
`<map-input>` elements are needed for search/suggestions links.

#### Placement rules

- **Inline (local) layers:** place `<map-link rel="search">` and
`<map-link rel="suggestions">` as direct children of `<map-layer>`.
- **Remote layers (`.mapml` files):** place them inside `<map-head>`.
- Do **not** place search/suggestions links inside `<map-extent>`.
- Only the **first** `<map-link rel="search">` and first
`<map-link rel="suggestions">` per `<map-layer>` are honored.
- Multiple layers may each contribute their own search/suggestions links;
responses are merged.

#### Default handler format (GeoJSON)

The default handler expects a **GeoJSON `FeatureCollection`** response from
both search and suggestions endpoints. Each `Feature` should include:

- `geometry` with coordinates (used for `setView` fallback)
- `bbox` (4-element array `[west, south, east, north]`; used for `fitBounds`)
- `properties.display_name` or `properties.name` (rendered as button text)

This format is compatible with Nominatim (`format=geojson`) and Photon out of
the box. Example minimal response:

```json
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"bbox": [-75.76, 45.35, -75.62, 45.46],
"geometry": { "type": "Point", "coordinates": [-75.69, 45.42] },
"properties": { "display_name": "Ottawa, Ontario, Canada" }
}
]
}
```

#### Default handler — inline layer example

```html
<mapml-viewer projection="OSMTILE" zoom="14" lat="45.4" lon="-75.7"
controls controlslist="search">
<map-layer label="OpenStreetMap" checked>
<!-- suggestions fetched as the user types (debounced, min 2 chars) -->
<map-link rel="suggestions"
tref="https://nominatim.openstreetmap.org/search?q={searchTerms}&format=geojson&limit=5"></map-link>
<!-- search fetched on Enter -->
<map-link rel="search"
tref="https://nominatim.openstreetmap.org/search?q={searchTerms}&format=geojson&limit=10"></map-link>
<map-extent units="OSMTILE" checked>
<map-input name="z" type="zoom" min="0" max="18"></map-input>
<map-input name="x" type="location" units="tilematrix" axis="column"></map-input>
<map-input name="y" type="location" units="tilematrix" axis="row"></map-input>
<map-link rel="tile" tref="https://tile.openstreetmap.org/{z}/{x}/{y}.png"></map-link>
</map-extent>
</map-layer>
</mapml-viewer>
```

#### Default handler — remote layer example

In the HTML page:

```html
<mapml-viewer projection="CBMTILE" zoom="5" lat="45.4" lon="-75.7"
controls controlslist="search">
<map-layer label="Canada Base Map" src="canada.mapml" checked></map-layer>
</mapml-viewer>
```

In `canada.mapml`:

```xml
<mapml->
<map-head>
<map-meta charset="utf-8"></map-meta>
<map-link rel="suggestions" tref="https://geogratis.gc.ca/services/geoname/en/geonames.json?q={searchTerms}*&num=20"></map-link>
<map-link rel="search" tref="https://geogratis.gc.ca/services/geoname/en/geonames.json?q={searchTerms}&num=20"></map-link>
</map-head>
<map-body>
<map-extent units="CBMTILE" checked>
<map-input name="z" type="zoom" min="0" max="17"></map-input>
<map-input name="y" type="location" units="tilematrix" axis="row"></map-input>
<map-input name="x" type="location" units="tilematrix" axis="column"></map-input>
<map-link rel="tile" tref="https://example.com/tiles/{z}/{y}/{x}.png"></map-link>
</map-extent>
</map-body>
</mapml->
```

#### Custom handler — overriding the default with `preventDefault()`

When the server's response format does not match GeoJSON `FeatureCollection`,
use `preventDefault()` on the `mapsuggestions` and/or `mapsearch` events to
suppress the default handler and render results yourself.

The polyfill dispatches two cancelable `CustomEvent`s on the `<mapml-viewer>`
(or `<map is="web-map">`) element:

| Event | Fires when | `e.detail` properties |
|-------------------|-------------------------------------------------|-----------------------------------|
| `mapsuggestions` | Suggestion responses arrive (user is typing) | `query`, `responses` |
| `mapsearch` | Search responses arrive (user pressed Enter) | `query`, `responses` |

`e.detail.responses` is an array of `{ data, link, layer }` objects — one per
layer that contributed a link. `data` is the parsed JSON response body; `link`
is the `<map-link>` element; `layer` is the `<map-layer>` element.

To render results, query the search panel's results container from the map and
append your own HTML. Results should use the class `mapml-search-result` on
`<button>` elements for consistent styling.

Example using the geonames.gc.ca API (non-GeoJSON response shape):

```html
<mapml-viewer projection="CBMTILE" zoom="5" lat="45.4" lon="-75.7"
controls controlslist="search">
<map-layer label="Geonames Layer" src="geonames-layer.mapml" checked></map-layer>
</mapml-viewer>

<script>
const viewer = document.querySelector('mapml-viewer');

viewer.addEventListener('mapsuggestions', (e) => {
e.preventDefault();
// Access the results container via the map's internal DOM
const container = viewer._map._container.querySelector('.mapml-search-results');
container.innerHTML = '';
for (const { data } of e.detail.responses) {
if (!data || !data.items) continue;
for (const item of data.items) {
const btn = document.createElement('button');
btn.className = 'mapml-search-result';
btn.setAttribute('type', 'button');
btn.textContent = item.name;
btn.addEventListener('click', () => {
if (item.bbox && item.bbox.length === 4) {
const [west, south, east, north] = item.bbox;
viewer._map.fitBounds([[south, west], [north, east]]);
} else {
viewer._map.setView([item.latitude, item.longitude], 10);
}
});
container.appendChild(btn);
}
}
});

viewer.addEventListener('mapsearch', (e) => {
e.preventDefault();
// Same pattern — parse the non-standard response and render results
const container = viewer._map._container.querySelector('.mapml-search-results');
container.innerHTML = '';
for (const { data } of e.detail.responses) {
if (!data || !data.items) continue;
for (const item of data.items) {
const btn = document.createElement('button');
btn.className = 'mapml-search-result';
btn.setAttribute('type', 'button');
btn.textContent = item.name;
btn.addEventListener('click', () => {
if (item.bbox && item.bbox.length === 4) {
const [west, south, east, north] = item.bbox;
viewer._map.fitBounds([[south, west], [north, east]]);
} else {
viewer._map.setView([item.latitude, item.longitude], 10);
}
});
container.appendChild(btn);
}
}
});
</script>
```

#### Search without suggestions

Suggestions are optional. If only `<map-link rel="search">` is provided
(no `rel="suggestions"`), the control will not fetch anything as the user
types — only pressing Enter will trigger a search request.

```html
<map-layer label="Search Only" checked>
<map-link rel="search"
tref="https://nominatim.openstreetmap.org/search?q={searchTerms}&format=geojson&limit=10"></map-link>
<map-extent units="OSMTILE" checked>
<!-- ... map-inputs and tile link ... -->
</map-extent>
</map-layer>
```
2 changes: 1 addition & 1 deletion .github/skills/mapml-viewer-markup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ The default projection is `OSMTILE`.

### `controlslist`

`controlslist` - an enumerated attribute, possible values are: "`nofullscreen`", "`nolayer`", "`noreload`", "`noscale`" and "`nozoom`". Occasionally, you may not want your users to have access to a particular control, so you may prune the set of controls automatically presented (when you have used the `controls` boolean attribute).
`controlslist` - an enumerated attribute, possible values are: "`nofullscreen`", "`nolayer`", "`noreload`", "`noscale`", "`nozoom`", "`geolocation`" and "`search`". The `no*` tokens let you prune the set of controls automatically presented (when you have used the `controls` boolean attribute), while unlike the `no*` tokens (which hide default controls), the `geolocation` and `search` tokens are **opt-in**. `geolocation` and `search` add location and search controls to the map, respectively. The search control is `disabled` by default, and becomes enabled only when at least one `checked` [`<map-layer>`](../layer/) contains a [`<map-link rel="search">`](../link/) element.

---

Expand Down
22 changes: 14 additions & 8 deletions .github/workflows/ci-testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,20 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: latest
- run: sudo apt-get install xvfb
- run: npm install
- run: npx playwright install --with-deps
- run: npm install -g grunt-cli
- run: grunt default
node-version: 22
cache: npm
- run: npm ci --ignore-scripts
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
- run: npx playwright install-deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
- run: npx grunt default
- run: xvfb-run --auto-servernum -- npx playwright test --grep-invert="popupTabNavigation\.test\.js|layerContextMenuKeyboard\.test\.js" --workers=1 --retries=3
# - run: xvfb-run --auto-servernum -- npx playwright test --grep="popupTabNavigation\.test\.js|layerContextMenuKeyboard\.test\.js" --workers=1 --retries=3
# - run: xvfb-run --auto-servernum -- npm run jest
env:
CI: true
Loading
Loading