Describe a map in words for screen readers with MapLibre GL JS

Most web maps aren't accessible. To a screen reader, they're a line of alt text at best. This demo reads out where you are and what's nearby each time the map moves.

Search for a place, or tab to the map and walk with the arrow keys or the walk buttons. Each step keeps you on a street.

Each description has the street under the crosshair, the nearest corner, the nearest address, and up to three places within 500 feet, with how far and which way. Read aloud has the browser say it too. Plus and minus zoom the map. Each step lands on the nearest street that way, so in Manhattan, up walks up the avenue you're on, and right takes you to the next cross street.

The street and the corner come from the map's own tiles, so they cost nothing. The nearest address is Mapricot's reverse geocoding, and the places are a nearby search in ten categories, such as food, shops, and transit, so each stop sends two requests. The map doesn't rotate, so up is always north, and directions are read out by the compass. Walking turns to fit the street grid, as much as 45° from the compass. Clicking or dragging the map also ends on the nearest street, and so does a place you search for. The search is Mapricot's autocomplete, one request for each pause in your typing. Its suggestions follow the combobox pattern screen readers know: the up and down arrows read each one out while you stay in the box, and Enter goes there.

This is a small experiment, not a map that works as well without sight as with it. Blind people and researchers have built much more for years: Audiom turns whole maps into sound, and BlindSquare and VoiceVista, which carries on Microsoft's Soundscape, describe what's around you as you walk. We tried this page with Orca, the Linux screen reader, but not yet with people who use a screen reader every day, so if you do, tell us how it went.

Build it yourself

The page draws a map with a crosshair, and each time the map stops moving, writes what's under the crosshair into an aria-live region, which screen readers read out. It's the map above without the search, the nearest corner, or walking along streets. It was tested with MapLibre GL JS 6.15 in Chromium, and with Orca 46 on Linux.

What you need

A web server to serve the page, and a browser key with the Map tiles, Geocoding, and Places products, made as API keys shows. Add http://localhost:* to its allowed origins, so it works from your computer while you build. Each stop costs two requests from the free allowance, and map tiles are free. Signing up takes a minute, with no credit card.

Make the page

Make a folder, and in it index.html, with your browser key in place of mapricot_pk_…:

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Screen reader map</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/dist/maplibre-gl.css">
    <script type="importmap">
      {
        "imports": {
          "maplibre-gl": "https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/+esm"
        }
      }
    </script>
  </head>
  <body style="margin: 0; font-family: system-ui">
    <div style="position: relative">
      <div id="map" style="height: 70vh"></div>
      <span aria-hidden="true" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 2rem; pointer-events: none">+</span>
    </div>
    <div id="said" aria-live="polite" style="padding: 1rem"></div>
    <script type="module">
      import * as maplibregl from "maplibre-gl";

      const key = "mapricot_pk_…";
      const said = document.getElementById("said");
      const map = new maplibregl.Map({
        container: "map",
        style: `https://api.mapricot.com/tiles/v1/styles/light.json?key=${key}`,
        center: [-73.9855, 40.758],
        zoom: 17,
        dragRotate: false,
      });
      map.keyboard.disableRotation();

      const canvas = map.getCanvas();
      canvas.setAttribute("role", "application");
      canvas.setAttribute("aria-roledescription", "map");
      canvas.setAttribute("aria-label", "Map. Arrow keys walk, plus and minus zoom.");

      async function call(path) {
        const response = await fetch(`https://api.mapricot.com${path}`, { headers: { Authorization: `Bearer ${key}` } });
        const json = await response.json();
        if (!response.ok) throw new Error(json.error?.message ?? `The API answered ${response.status}.`);
        return json;
      }

      const compass = ["north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest"];
      function away(from, to, meters) {
        const feet = Math.round((meters * 3.28084) / 10) * 10;
        if (feet < 30) return "right here";
        const angle = Math.atan2((to.lon - from.lon) * Math.cos((from.lat * Math.PI) / 180), to.lat - from.lat);
        return `${feet} ft ${compass[(Math.round(angle / (Math.PI / 4)) + 8) % 8]}`;
      }

      function underfoot() {
        const { x, y } = map.project(map.getCenter());
        const features = map.queryRenderedFeatures([[x - 6, y - 6], [x + 6, y + 6]]);
        const streets = [
          ...new Set(
            features
              .filter(({ sourceLayer, properties }) => sourceLayer === "roads" && properties.name && properties.kind !== "rail")
              .map(({ properties }) => properties.name),
          ),
        ];
        if (streets.length > 1) return `At ${streets[0]} and ${streets[1]}.`;
        if (streets.length) return `On ${streets[0]}.`;
        if (features.some(({ sourceLayer }) => sourceLayer === "buildings")) return "Inside a building.";
        if (features.some(({ sourceLayer }) => sourceLayer === "water")) return "On the water.";
        return "Somewhere with no street on the map.";
      }

      async function describe() {
        const { lat, lng: lon } = map.getCenter();
        const here = { lat, lon };
        const categories = "food,retail,transport,recreation,entertainment,education,health,religion,accommodation,nightlife";
        try {
          const [{ results: addresses }, { results: places }] = await Promise.all([
            call(`/geocoding/v1/reverse?lat=${lat}&lon=${lon}&limit=1`),
            call(`/places/v1/nearby?lat=${lat}&lon=${lon}&radius=152&limit=3&category=${categories}`),
          ]);
          const lines = [underfoot()];
          const [address] = addresses;
          if (address) lines.push(`Nearest address: ${address.label}, ${away(here, address, address.distanceMeters)}.`);
          for (const place of places) lines.push(`${place.name}, ${away(here, place, place.distanceMeters)}.`);
          said.textContent = lines.join(" ");
        } catch (error) {
          said.textContent = `The map couldn't describe this spot: ${error.message}`;
        }
      }

      let timer;
      map.on("moveend", () => {
        clearTimeout(timer);
        timer = setTimeout(() => (map.loaded() ? describe() : map.once("idle", describe)), 300);
      });
      map.once("idle", describe);
    </script>
  </body>
</html>
  • MapLibre already moves the map with the arrow keys and zooms with plus and minus once the map has focus. disableRotation and dragRotate: false keep north up.
  • A screen reader uses the arrow keys itself, to read the page. The canvas's application role tells NVDA and JAWS to pass them to the map instead, and its label says what they do. Orca, the Linux screen reader, says nothing when the map gets focus in Chrome, and passes the arrow keys on only after Orca+A.
  • aria-live="polite" on the description has screen readers read each new one when they finish what they're saying. It's one string, since Orca reads a live region that changes in several places in pieces, out of order. The crosshair is aria-hidden, since it means nothing to them.
  • underfoot asks the tiles what's drawn under the crosshair with queryRenderedFeatures: the names of the streets it's on, leaving out rail lines, or else a building or water.
  • The two requests go out together, and a description waits for the map to stop for 300 ms, so holding down an arrow key sends them once, where it stops.
  • Directions come from the compass bearing to each place, rounded to the nearest of eight. Distances are in feet, as the API's distanceMeters converted.

Open it

Serve the folder with npx serve or python3 -m http.server, and open the address it prints. Opened straight from the file, the map stays blank, as Open the page explains. You should see Times Square, with "At 7th Avenue and West 45th Street." and the nearest address and places below the map. Click the map, or tab to it, and press an arrow key. With a screen reader on, such as VoiceOver (Command-F5 on a Mac) or NVDA on Windows, you'll hear each new description.

To have the browser read it aloud without a screen reader, as the map above does, pass the same lines to speechSynthesis.speak(new SpeechSynthesisUtterance(text)), and call speechSynthesis.cancel() first so a new stop interrupts the last one.

Data

  • Streets, buildings, and water: OpenStreetMap contributors (ODbL), in Mapricot's map tiles
  • Addresses: Mapricot's geocoding, from OpenStreetMap, OpenAddresses, Who's On First, and GeoNames
  • Places: Mapricot's places, from OpenStreetMap and Overture Maps