# Map

> An interactive map with markers, popups, routes, arcs, clusters and controls, built on MapLibre.

## Anatomy

A `.map` is an interactive map built on [MapLibre](https://maplibre.org/), which the page supplies. Markers, popups and controls are HTML. Routes, arcs and clusters are data added from script.

Loop

### Loop store

Open until 9 p.m.

Lincoln Park

### Lincoln Park store

Open until 8 p.m.

```
<div class="map" role="region" aria-label="Chicago stores">
  <button type="button" class="map-marker" data-lng-lat="-87.6278,41.8820"
          aria-label="Loop store" aria-controls="loop">
    <span class="map-marker-label">Loop</span>
  </button>
  <div class="map-popup" id="loop">
    <h3>Loop store</h3>
    <p>Open until 9 p.m.</p>
  </div>
  <div class="map-controls">
    <div class="map-control-group">
      <button type="button" class="icon-button" data-action="zoom-in" aria-label="Zoom in">
        <span class="material-symbols" aria-hidden="true">add</span>
      </button>
      <button type="button" class="icon-button" data-action="zoom-out" aria-label="Zoom out">
        <span class="material-symbols" aria-hidden="true">remove</span>
      </button>
    </div>
  </div>
</div>
```

## Setup

ExpressiveCSS does not bundle MapLibre. To set it up:

1.  Install MapLibre.
2.  Load its stylesheet before ExpressiveCSS.
3.  Pass the module to `MapView.init()`.

`AutoInit()` does not start the map. The class is `MapView` so importing it does not hide JavaScript's `Map`.

```
npm install maplibre-gl
```

```
import 'maplibre-gl/dist/maplibre-gl.css';
import * as maplibregl from 'maplibre-gl';
import { MapView } from '@expressivecss/expressive';

const map = MapView.init(document.querySelector('.map'), {
  maplibregl,
  center: [-87.65, 41.89],
  zoom: 11
});
```

Without a bundler, import `maplibre-gl.mjs` from a CDN. MapLibre loads its worker from the same folder, so a page with a Content Security Policy needs `worker-src blob:` and the CDN in `script-src`.

## Styles

By default the map uses [OpenFreeMap](https://openfreemap.org/)'s Positron style in the light theme and its Dark style in the dark theme. Neither needs an API key. The map changes style when the `theme` attribute or the system color scheme changes. To keep one style, pass `style` as a style URL or object. To choose both, pass `{ light, dark }`. Any MapLibre style works, such as MapTiler, Stadia Maps or Protomaps. Follow your provider's terms and keep its attribution visible.

```
MapView.init(el, {
  maplibregl,
  style: {
    light: 'https://tiles.openfreemap.org/styles/liberty',
    dark: 'https://tiles.openfreemap.org/styles/dark'
  }
});
```

## Markers and popups

Set `data-lng-lat` on a `.map-marker` to place it, written as longitude then latitude. The script skips a value that is not two numbers with a latitude between -90 and 90, and logs a console warning. A marker with no content of its own is a primary dot. Put an [avatar](/avatar.html.md), icon or image inside to replace the dot. A `.map-marker-label` shows below it, or above with `.top`. A child [`.tooltip`](/tooltips.html.md) shows on hover and focus.

Make a marker a `<button>` with `aria-controls` naming a `.map-popup`. The marker then opens that popup and sets `aria-expanded`. A click on the map or Escape closes it, and focus returns to the marker. A `.map-popup` with its own `data-lng-lat` and no marker opens when the map starts.

Add `data-draggable` to let the reader drag a marker, and make the marker a named `<button>`. A drag is not the only way to move it. A tap picks the marker up and sets `aria-pressed="true"`. The next tap on the map drops it there. Escape or a second tap on the marker puts it back. The arrow keys nudge a focused marker. Each move leaves the new position in its `data-lng-lat` and fires `change`.

```
<button type="button" class="map-marker" data-lng-lat="-87.62,41.88" data-draggable aria-label="Drop-off point">
  <span class="map-marker-label top">Drop-off</span>
</button>
<div class="map-popup" data-lng-lat="-87.70,41.85">Depot</div>

<script>
document.querySelector('.map').addEventListener('change', (event) => {
  console.log(event.target.dataset.lngLat);
});
</script>
```

## Routes and arcs

`addRoute(id, coordinates, options)` draws a line through `[longitude, latitude]` points in the primary color. `addArcs(id, arcs, options)` draws a curve for each `{ from, to }` pair in the tertiary color. Both take `color`, `width`, `opacity` and `dashArray`. The map does not plan routes, so get the points from a routing service such as OSRM or Valhalla.

```
const instance = MapView.init(el, { maplibregl });
instance.addRoute('delivery', [[-87.70, 41.85], [-87.65, 41.87], [-87.62, 41.88]]);
instance.addArcs('flights', [
  { from: [-87.90, 41.97], to: [-73.78, 40.64] },
  { from: [-87.90, 41.97], to: [-118.41, 33.94] }
], { dashArray: [2, 2] });
```

## Clusters

`addClusters(id, data, options)` draws GeoJSON points, or the URL of a GeoJSON file, and groups those close together. A cluster shows its count when the style has fonts. A click on a cluster zooms in until it splits. Its layers are `${id}-clusters`, `${id}-count` and `${id}-points`. To handle a click on a point, use ``instance.map.on('click', `${id}-points`, …)``.

```
instance.addClusters('stores', 'https://example.com/stores.geojson', { radius: 50, maxZoom: 14 });
instance.map.on('click', 'stores-points', (event) => {
  console.log(event.features[0].properties);
});
```

Lines and clusters take their colors from the theme tokens where the map is. When the style changes, the map redraws them in the new theme's colors. `removeOverlay(id)` removes one.

## Controls

Put [icon buttons](/icon-buttons.html.md) in a `.map-controls` to stack them in the top right corner. Add `.top-left`, `.bottom-left` or `.bottom-right` for another corner. A `.map-control-group` joins buttons on one surface. Each button's `data-action` sets what it does:

-   `zoom-in` and `zoom-out` zoom the map. They are disabled at the zoom limits.
-   `compass` turns the map back to north. Its icon points north as the map rotates.
-   `locate` centers the map on the reader's location and marks it. It is disabled if the reader refuses.
-   `fullscreen` shows the map full screen and sets `aria-pressed`. It is hidden where full screen is unavailable.
-   `pan-up`, `pan-down`, `pan-left` and `pan-right` move the view a third of the map's size, so it pans without a drag.

## Accessibility

Name the map with `aria-label` and `role="region"`. MapLibre makes the canvas focusable. On it, the arrow keys pan, + and \- zoom, and Shift with the arrows rotates. A marker that does something should be a named `<button>`. A decorative one can be a `<span>`. Put what matters, such as store addresses, in text on the page as well, because a map is hard to use without sight.

## Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `maplibregl` | Module | `null` | The `maplibre-gl` module. Required. |
| `style` | String, Object | OpenFreeMap Positron and Dark | A style URL or object, or `{ light, dark }` to follow the theme. |
| `center` | Array | `[0, 20]` | Starting `[longitude, latitude]`. |
| `zoom` | Number | `1` | Starting zoom level. |
| `mapOptions` | Object | `{}` | More MapLibre `Map` options, such as `maxZoom` or `bearing`. |

## Methods

```
const instance = Expressive.MapView.getInstance(document.querySelector('.map'));
instance.map; // the MapLibre map, for anything else
instance.markers; // the MapLibre markers, in markup order
instance.addRoute(id, coordinates, options);
instance.addArcs(id, arcs, options);
instance.addClusters(id, data, options);
instance.removeOverlay(id);
instance.destroy(); // removes the map and puts the markup back
```

## Tokens

Override these on the `.map`.

| Token | Default |
| --- | --- |
| `--md-comp-map-height` | 400px |
| `--md-comp-map-shape` | 16px |
| `--md-comp-map-marker-color` | primary |
| `--md-comp-map-marker-ring-color` | surface |
| `--md-comp-map-route-color` | primary |
| `--md-comp-map-arc-color` | tertiary |
| `--md-comp-map-cluster-color` | primary |
| `--md-comp-map-cluster-label-color` | on-primary |
| `--md-comp-map-popup-max-width` | 280px |
| `--md-comp-map-control-offset` | 12px |
