← Maps

How an accessible map is built

The maps in this family look different on purpose — a building, a subdivision, a city — but underneath they are one idea, built one way. This page is that idea, worked through in enough detail to build your own. It is deliberately not a recipe to copy: the code is here to make the model legible, not to be lifted. You have to understand the model before any recipe would help — so the prose, not the snippets, is the point.

A map is information before it is a picture

Open any digital map and ask what is actually stored. It is a list of points with coordinates, and some attributes hung off each one. That is all. Everything that makes it feel like a map happens in the head of the person looking at it.

A sighted reader does an enormous amount of unconscious work on that list. The eye runs over the whole plane at once, groups the clusters, judges what is near what, and supplies context from experience: the row of pins along the top edge is the shopping parade; the dense knot in the corner is the school catchment; this lot backs onto the green space, that one is hemmed in by the arterial road. None of those relationships are in the data. They are inferred — for free, instantly, and without anyone noticing it is happening — from sight, proximity, and prior knowledge of how places work.

Assistive technology has none of that. It cannot scan a plane in parallel, it has no gestalt, and it brings no context. It can only report what is actually encoded. So a map that stores coordinates and nothing else hands a screen-reader user a bag of points and no way to understand how any of them relate — which is to say, no map at all. As the rest of this section’s parent work puts it: a map of nodes and cartesian coordinates provides no design intent for assistive technology to follow. The sighted user is trusted to look and infer; the non-sighted user is left to reverse-engineer a structure from a list, with nothing to go on.

The fix is not to apologise for the picture or to bolt a text caption onto it. It is to make the relationships a sighted reader infers explicit in the data: which points are of which kind, which are near which, and which matter most for the task at hand. Those relationships — the edges and their weights — are not metadata decorating a map. On a non-visual map they are the map. Equitable access comes from expressing them in the rendering: not just the lines, icons, and shapes, but the semantic relationships between them, in a form assistive technology can pick up.

This is also why the obvious “accessible map” fails. Put alt text on every pin, or expose a list of coordinates, and you have given a screen-reader user every node and not one relationship. It passes a label audit and conveys nothing about how the places relate — which is the whole map. WCAG 1.3.1 is literally named Info and Relationships; the second half is the load-bearing half, and the coordinate dump is exactly what throws it away. Everything below is one long answer to a single question: how do you make the relationships explicit, and then render them so a screen reader, a magnifier, and a keyboard can each get at them?

One model, two projections

A map of this kind has two faces. There is the list of search results, and there is the rendered map. The tempting mistake is to treat them as two features that happen to sit on the same page. They are not. They are two projections of a single underlying model: a property exists once, as data, and the list and the map are both ways of reading that one thing. Search results and map renderings, in other words, are just different projections of the same information.

Holding that line matters because it rules out the move most “accessible maps” quietly make: serving the non-sighted user whichever projection is easiestto make accessible — usually the list — and calling the map done. That is not access to the map. It is a polite refusal of it. The results list is not the map; the map’s distinctive content is spatial: how things cluster, what lies in which direction, what is near what, how convenience varies from one edge of the subdivision to the other. None of that is in the list.

So the job is not to pick a projection. It is to give every user as much access to eachprojection as the medium allows — an accessible results list andan accessible map, both, each on its own terms. That phrase — as much as you can— is doing real work: it is maximisation, not a promise of perfect parity. Some of the spatial reading will always be richer in one modality than another, and pretending otherwise helps no one. The discipline is to close the gap as far as the medium can, per projection, rather than to collapse the two into whichever is convenient.

The information model: a typed digraph

If the relationships are the map, then the model has to be a structure that holds relationships. The one that fits is a typed directed graph— a digraph — and it is worth being precise about its parts, because two different kinds of structure are tangled together in it and they do different jobs.

Pins are typed nodes

A pin is a point in space that carries information unique to that location, and every pin has a type. There are two classes — property and amenity— and amenities carry their own subtypes: education, places of worship, retail, recreation, and so on. A property is a thing for sale, with an address and the attributes a buyer filters on (bedrooms, bathrooms, style, price). An amenity is a thing in the world around it, with a name and a class. The type is not decoration; it is what lets a reader say “show me the schools” or “take me to the next property” and have that mean something.

The classification is a tree

That type system is a tree: a clean is-a hierarchy in which every node has exactly one parent. A school is an education amenity is an amenity; it is not also, simultaneously, a property. The tree is the part of the structure that answers “what kindof thing is this,” and because it is a strict hierarchy it gives you categorical navigation almost for free.

The classification is a TREE — every node has exactly one parent:

        pin
        |-- property
        `-- amenity
              |-- education
              |-- place of worship
              |-- retail
              `-- recreation

What makes the whole thing a DIGRAPH is a second kind of edge that does
NOT follow that hierarchy — the convenience relation, directed and weighted:

        property  --(distance, bearing)-->  amenity

The edges are what make it a digraph

A tree on its own is not enough, and this is the distinction that does the most work: what turns the classification tree into a digraph is a second kind of edge that does not follow the hierarchy. The obvious one is the convenience relation — a directed, weightedlink from a property to a nearby amenity, carrying the distance and bearing between them. It runs property → amenity because that is the direction a buyer reasons in (“how near is the school to thishome?”), and it is weighted because not every nearby thing matters equally. The tree says what a node is; the edges say how nodes relate. Both are needed, and they are not the same shape.

Stored vs computed: the digraph is a function, not a file

Here is the part that surprises people: none of those edges is stored. What is stored is smaller and dumber than the digraph — it is points in space with grouped properties.

// What is actually stored: points in space, with grouped properties.
// No edges. No circuits. No "convenience". Just coordinates + attributes.

const pins = [
  { id: "lot27",   type: "property",
    at: [x, y],                          // cartesian, on the rendered plane
    props: { collection: "Shield", beds: 2, baths: 2, price: 761880 } },

  { id: "stmarks", type: "amenity", class: "education",
    at: [x, y],
    props: { name: "St Mark's Primary" } },

  { id: "parade",  type: "amenity", class: "retail",
    at: [x, y],
    props: { name: "The Parade shops" } },
];

The edges, the weights, and the circuits a reader will navigate are all interpreted from that substrate at runtime. The distance and bearing on a convenience edge are read off the coordinates the moment they are needed, not kept in a table:

// The convenience relation is DERIVED, per selected property, at runtime.
// distance and bearing are read off the coordinates — never stored.

edge("lot27" -> "stmarks") = { distance: 320 /* m */, bearing: 47  /* deg, NE */ }
edge("lot27" -> "parade")  = { distance: 610 /* m */, bearing: 122 /* deg, SE */ }

So the digraph is not an object you persist; it is a function you evaluate. And once you see it that way, its two arguments fall out naturally: the user’s need(which edges and weights matter) and the user’s current selection (where the graph is anchored). Re-anchoring on a different property is not a structural change; it is just a different argument to the same evaluation over the same fixed substrate.

The digraph is needs-driven

That is what makes the model general rather than a one-off for property search. The same pins, drawn in the same positions, yield a differentdigraph for a different reader, because the weighting is a function of who is asking. A home-buyer weights proximity to shops and a good school. A map built for prospective parents would weight a quite different set, even though the picture on screen is identical. Someone choosing where to retire weights quiet and access to healthcare. Same coordinates, same pins, same rendered image — and a different weighted digraph underneath each time. The digraph is needs-driven, and it is weighted; architecturally that means the weighting is a parameter of the model, not a constant baked into it.

And this is precisely where the accessibility pays off, because the two structures map onto the two things a non-visual reader needs to do. The type tree is the spine of categoricalnavigation: group and filter by class, step through “next education pin,” collapse a whole category you do not care about. The directed edges are relational navigation: stand on a property and walk its edges out to the amenities that make it convenient. Get the model right and the navigation is implied by it.

Cartesian to polar: rendering for a reader who listens in sequence

Now the model has to be read, and the medium changes everything about how. A sighted reader takes the plane in two dimensions at once: the whole map is present, and the eye chooses where to go and in what order. A screen-reader user receives the map as a sequence— one announcement after another, in time — and has no two-dimensional frame to hold those announcements in. The visual map is Cartesian. The experience the non-sighted user actually inhabits is polar: a series of things described relative to a chosen origin. That origin is the pin-as-datum— the selected property — and everything else is given as a distance and a direction from it.

Weight gives you importance, not sequence

Building that polar reading well turns on a distinction that is easy to miss and easy to get wrong: the weight tells you importance, not sequence. You cannot simply read the amenities out in order of weight. The second-most-important amenity may sit on the opposite side of the subdivision from the most important, and a description that jumps north, then south, then back north is no easier to hold in the head than the bag of points we started with — the user just hops randomly around the map. Two genuinely different things have to be decided: which amenities matter (importance), and in what order to walk them (sequence). A single weight answers only the first.

Two keys: rings for priority, a sweep for continuity

The resolution is to sort on two keys, in order. Distance — the weight — sets priority, and it does so in bands: an inner ring of the closest amenities, then the next ring out, and so on. Within a ring, a second key — bearing — gives a stable sweep, clockwise from north. The result is an onion-ring, or spiral, traversal: closest things first, and within each band a predictable turn around the compass rather than a scatter.

// Importance and sequence are two DIFFERENT decisions.
//   distance (the weight)  ->  IMPORTANCE  ->  which ring   (priority)
//   bearing                ->  SEQUENCE    ->  where in it  (continuity)

nearby(origin)
  .sort((a, b) =>
       band(a.distance) - band(b.distance)         // 1st key: distance band (ring)
    || clockwiseFromNorth(a.bearing, b.bearing));   // 2nd key: sweep within the ring

It is worth naming why this works, because it is the whole trick. Radius is the priority axis— which amenities matter most. Angle is the continuity axis— what stops the description teleporting across the map between two things of near-equal weight. A pure weight-sort has only the first axis, so it hops. The spiral has both, in the right order: radius primary, angle secondary. That is the difference between a description you can follow and one you cannot. (In the demo, with a handful of pins, this shows up in its simplest form — describe the amenities starting at north and rotating clockwise; on a larger, real subdivision it becomes the full onion-ring spiral, closest ring first.)

Banded rings vs a smooth spiral

Banding the distance is a deliberate choice over a continuous spiral, and it is a real trade-off. A continuous spiral needs no threshold — radius just grows as you sweep — but it blurs distance into a smooth gradient. Banded rings cost you a policy decision (what counts as “near”?) and repay it with something a screen-reader user can actually hear: announceable bands — “within five minutes: the school and the park; within fifteen: …” — where the very step from one ring to the next becomes an orientation cue (“now further out”). Distance stops being a number and becomes a spoken category. For a non-visual reader that legibility is usually worth the threshold policy.

Where the origin comes from

Because the whole construction hangs off the origin, the obvious question is what the origin is when the user has not selected a single property. The answer follows selection count. With oneproperty selected, the origin is that property — the per-property spiral. With none, it falls back to the centre of the subdivision, and amenities are described relative to that. With severalselected for comparison, there is no single origin, and — honestly — this is where the model still has open questions: do you anchor on a centroid and report each amenity’s convenience per property (“the school is 300 m from A, 600 m from B”), or nominate a primary? The demo keeps it simple, anchors on the centre, and leaves multi-property comparison as unfinished business rather than pretending it is solved.

Expressing it so assistive technology can pick it up

A model that lives only in the developer’s head helps no one. It has to be expressed in the rendering, in a form assistive technology can read — and the first principle is to treat the whole page as one structure, not as an accessible HTML part with an inaccessible picture bolted to the side.

One page, one structure

There is a single set of navigation landmarks for a screen reader to move through, and a single hierarchy of headings. The SVG map participates in both. It makes no conceptual difference that the results list is HTML and the map is SVG; they are the same model, drawn twice, and they live in one landmark tree and one heading outline.

<header role="banner"> ... </header>
<nav aria-label="Filters"> ... </nav>                <!-- the visual filters -->

<main>
  <section aria-label="Results">
    ... one heading + button per property ...        <!-- the LIST projection -->
  </section>

  <svg aria-label="Map of the subdivision">
    ... the SAME properties, the SAME headings + buttons ...   <!-- the MAP projection -->
  </svg>
</main>

<!-- One landmark set. One heading hierarchy. The SVG is not a separate
     world bolted on; it is the same model, drawn. -->

Every pin is a node you can find and act on

Within that structure, each pin is exposed as something a reader can both find and act on. In the demo a property is a heading (so it appears when a screen-reader user skims by heading) and a button(so it appears when they skim by control, and so it can be activated) — two independent ways to land on the same pin.

<!-- The heading and the button are SIBLINGS — never nested. -->
<g class="pin">                                        <!-- grouping / position only -->

  <text id="lot27-name" role="heading" aria-level="4">   <!-- found via the headings rotor -->
    Plot 27
  </text>

  <g role="button" tabindex="0"                          <!-- found via the controls rotor -->
     aria-labelledby="lot27-name lot27-meta">            <!-- borrows the heading for its name -->
    <text id="lot27-meta">2 bed, 2 bath — $761,880</text>
  </g>

</g>

The detail that matters here — and the reason this page keeps insisting you understand rather than copy — is that the heading and the button are siblings, never nested. ARIA defines button as a role whose descendants are presentational: by the letter of the spec, a heading placed insidea button should be folded into the button’s name and vanish from the headings list. A heading nested in a button is therefore a heading you cannot rely on. Keeping them as siblings — the button borrowing the heading’s text for its name via aria-labelledbyrather than swallowing it — guarantees the heading stays a heading. (In SVG specifically the visible title often has to remain inside the card for paint reasons, so the robust form is a screen-reader-only heading as the sibling; the principle is the same.) The point is general: the structure is right, or the rotor lies to you, and no validator will warn you that it has.

The three success criteria that carry it

In WCAG terms, three criteria do most of the work, and it is worth knowing which is responsible for what:

  • 1.3.1 Info and Relationships owns the structure itself — the typed nodes and the edges between them, made programmatically determinable rather than left to be inferred from pixels. The “relationships” clause is exactly the half a coordinate dump violates; this is where the digraph has to actually surface.
  • 4.1.2 Name, Role, Value governs the interactive surface— a pin announcing its name, its role (property, or amenity-of-a-subtype), and its state (selected; current; “2 of 5 in this ring”); the filter controls; the traversal cursor — and keeping all of that in sync as the user moves.
  • 2.4.3 Focus Order is the one the whole circuit discussion was really about. The spiral isa focus order, and 2.4.3’s requirement that order “preserve meaning and operability” is the formal statement of the rule that the sequence must not scatter. Importance-versus-sequence is not a nicety; it is how you satisfy 2.4.3 for a map.

One honesty runs under all of this: ARIA support in SVG is lax,and the laxity cuts both ways. You can get away with markup that HTML tooling would reject — but the same gap means nothing warns you when it is wrong, and behaviour varies across screen readers and browser versions. SVG is therefore a place to test more across real assistive technology, not less. The quiet success in one screen reader is not the same as a correct accessibility tree.

Navigating it

The food chain: arrow keys, then headings, then landmarks

Readers arrive with very different fluency in their assistive technology, and a map that serves only the most expert locks everyone else out. The demo treats the navigation methods as a ladder of increasing power, each a fallback for the one above it. Arrow keyswalk the content in order — the most basic skill, and the reason the focus order, the circuit, is the floor everyone stands on. Heading navigation jumps between pins for those who know it. Landmark navigationjumps between whole regions for those who know that. Landmark navigation is a little more efficient, but each rung is built to work alone, and the heading structure and the landmark structure are kept close to parallel on purpose — so a reader who only knows heading navigation is not stranded, and gets nearly the same reach as one who knows landmarks. It is redundancy as resilience, not duplication.

Filters and the rotor are one capability

Changing what the map shows is a single capability expressed twice: a filter in the visual projection, the screen-reader rotorin the non-visual one. Both choose which nodes and edges are live; each is its projection’s native control for the same operation. What that manipulation touches differs by map. On this search-and-pins demo it is almost entirely node-side— the property search narrows which property-nodes are shown, and the amenity graph beneath barely moves. On a wayfinding map it acts heavily on edges, because there the routes are edges. Same control, very different consequences, depending on which part of the graph the map is about.

Why focus stays on the results

When a selection changes, the change is announced through a live region rather than by moving focus.

<div aria-live="polite">Plot 27 displayed on the map</div>

That decision repays a close look, because it is counter-intuitive and deliberate. When a user picks a result, focus stays on the results list; it does notjump to the property on the map. The reason is the buyer’s actual task: people want to select several candidates and thengo and compare where they sit relative to the things they care about, and yanking focus onto the map at every pick would wreck that workflow. Keeping focus put avoids an unexpected change of context (the concern behind WCAG 3.2.1 / 3.2.2), and the live-region announcement — “Plot 27 displayed on the map” — does the work the focus move would have done, as a status message (4.1.3). The cost is real: the property now has to be foundon the map by navigation rather than handed to you. That cost is exactly why every pin is both a heading and a button — so it is cheap to land on from whichever rotor the reader reaches for. The decision and its compensation are a pair; you cannot make the first without paying for it with the second.

Why the maps in the family differ

Everything above is one model, and the maps in this family are that model evaluated for different jobs. The difference between them is not a difference of principle — they share the digraph, the polar reading, the one-structure rendering. It is a difference in which part of the graph does the work,and the rendering follows from that.

The search and map pin demo is almost entirely about its nodes. The user filters properties; the amenity graph beneath barely moves; the edges are simple; and the streets are only context. That is precisely why it can run on a raster base with an addressable pin overlay rather than a fully drawn map — only the pins need to be addressable, because only the pins are what the map is about. The East Toronto streetmap and the terminal map are about their edges: in a wayfinding map the routes are the edges, so filtering and the rotor act on the edges directly, the graph is dense, and every feature has to be drawn as addressable SVG because the space itself is the content. Same model; the weight simply falls in a different place, and the right rendering follows the job rather than a house style.

Honest limits

A model that hides its seams teaches the wrong lesson, so three honesties to close on.

  • SVG accessibility is under-specified in practice. The validation around ARIA and roles in SVG is lax; that lets you get away with things, and it equally means nothing flags the things you got wrong. Treat SVG as the place that needs more testing across real screen readers, not less.
  • The numbers in the demo are placeholder.The distances, the prices, the amenities — mathematical lorem ipsum, not real geography. The demo shows the form of an accessible description (a thing named, placed, and ordered relative to an anchor), not a working subdivision tool. That is the right scope for a demonstration of the model; it is not a product.
  • Multi-property comparison is genuinely open. The question from the polar section — what the origin is when several properties are selected at once — does not have a settled answer here. It is a real piece of the map still being drawn, and it is more honest to say so than to paper over it.

Reading on