AccessMap
Teaching project

How an indoor accessibility map is built.

AccessMap is a self-contained walkthrough of the design, end to end: the JSON that describes a floor, the graph algorithm that routes a wheelchair around a flight of stairs, and the assistant that answers “how do I get to room 404?”. Every layer uses the same primitives you can read in the source.

  • A. Web a11yWCAG 2.2 AA basics
  • B. Map a11yPer-profile routing
  • C. AlgorithmsA* + Dijkstra
  • D. AssistantTwo tools, real data

Data

Floors are JSON

A small, validated schema with rooms, doors, walls, and a graph. The renderer, router, and assistant all read the same structure.

Routing

A* over a weighted graph

Edge cost scales with each user's profile. Stairs are infinity for wheelchairs, a ramp is a small surcharge. One algorithm, three answers.

Assistant

Two tools, real data

The assistant calls find_room and find_route, the same library code the manual route picker uses. The map sits in the cached system prompt.

Web accessibility

The product itself has to be reachable.

Before a map can route a wheelchair through a building, the page that hosts it has to be operable by a keyboard, parsable by a screen reader, and legible to someone with low vision. Those are the table stakes, WCAG 2.2 AA, encoded in our component primitives rather than bolted on at the end.

Keyboard navigable

Every interactive element is reachable with Tab, operable with Enter / Space, and shows a visible focus ring. No mouse-only menus, no traps.

Screen-reader friendly

Semantic HTML first, ARIA only where the platform doesn't already convey meaning. Map controls expose roles and labels; the SVG carries an aria-label.

Contrast that holds up

Body text clears 4.5:1 against its surface; large text and UI states clear 3:1. Tested in light and dark themes alike.

Touch targets ≥ 44px

Pills, buttons, and the map drawer trigger meet WCAG 2.5.5 target size. No 24×24 icon buttons hiding the only way to dismiss a modal.

Why it matters in practice. About 15%of the world’s population lives with some form of disability (WHO, World Report on Disability). For most of them, web accessibility is not a feature; it’s the difference between being able to enrol in a class and being locked out of one.

Map accessibility

A route that doesn't exist for everyone is the wrong route.

Most online maps treat the world as if everyone walks the same way. They don't. AccessMap routes per profile, encoding the constraints of mobility, vision, and sensory impairment as edge weights on the same graph, so the answer to “how do I get there?” is shaped by who is asking.

Feature on the pathDefaultWheelchairLow vision
Stairsblocked (∞)1.5×
Stepblocked (∞)
Narrow passage1.5×
Ramp1.1×
Elevator1.2×
Automatic door

The numbers are knobs, not gospel: they’re tuned for teaching. The principle is the load-bearing part: same graph, different weights. Adding a profile (deafblind, stroller, with-luggage) is a few lines in PROFILES: no schema change, no second renderer.

Architecture

One graph, three readers.

A floor is described as data: geometry plus a graph that lives alongside it. The renderer reads the geometry to draw, the pathfinder reads the graph to route, and the assistant reads both through tools. None of them duplicates the other.

Geometry + graph

A simplified floor: two rooms either side of a corridor, an elevator, a stairwell, and the routing graph drawn on top.

101102OfficeELSTInES
Room polygonGraph nodeFeature node (E/S)Computed routeStairs edge (dashed)

The four primitives

Rooms
Polygons in a flat 2-D coordinate frame. Each carries a kind (classroom, office, lab, …) and a bilingual name.
Doors
Points on a wall shared by two rooms. The renderer marks them; the graph already encodes that connectivity, so doors are visual sugar, not routing data.
Nodes
Vertices in the routing graph, placed at room centres, corridor junctions, and floor-changers (elevator, stairs). A node may carry feature tags that profiles weigh.
Edges
Connections between nodes, each carrying its own feature tags. Edge cost = base distance × profile multiplier. Infinity blocks the edge entirely.

Floor schema, simplified

A real floor has more fields and walls/doors. This abridged shape shows the spine.
{
  "outline": [{ "x": 0, "y": 0 }, …],
  "walls":   [{ "kind": "window", … }],
  "rooms":   [{ "id": "room-101", "code": "101", "polygon": […] }],
  "nodes":   [{ "id": "n-elev", "features": ["elevator"],
                "connectsToFloor": { "floorSlug": "first", "nodeId": "n-elev" } }],
  "edges":   [{ "from": "c4", "to": "n-stair", "features": ["stairs"] }]
}
Algorithms

A* on a single floor, Dijkstra across them.

The pathfinder is small enough to read in one sitting. The interesting move is in the cost function: distance multiplied by the heaviest profile multiplier on the edge, which is what makes the same graph yield three different routes.

A* in plain terms

  • Open set, nodes we've reached but not finished expanding. We always pop the one with the smallest f = g + h.
  • g(n), best-known cost to reach n from the start.
  • h(n), straight-line distance from n to the goal. Admissible because every edge cost is at least its straight-line length, so we never overestimate.
  • Reconstruction, once the goal is popped, follow cameFrom backwards to recover the path.
For multi-floor routing the heuristic is 0. Euclidean distance doesn’t generalise across floors, and falling back to Dijkstra is correct and trivially fast for floor-plan-sized graphs.

Edge cost, in code

function edgeMultiplier(features, profile) {
  let m = 1;
  for (const f of features) {
    const w = profile.weights[f];
    if (w === undefined) continue;
    if (w === Infinity) return Infinity;   // blocks edge
    if (w > m) m = w;                      // worst-case
  }
  return m;
}

const cost = baseDistance * edgeMultiplier(edge.features, profile);

Why the worst case? An edge tagged narrow_passage AND step should be as bad as the worse of the two for the chosen profile, never softened by averaging. That single rule keeps profile composition predictable as you add features.

How to draw a map

JSON in, top-view floor plan out.

Authoring a new floor is six small decisions, in order. None of them require a graphics tool: the renderer composes the SVG from the JSON at runtime.

  1. Step 01

    Pick a coordinate frame

    A flat 2-D plane, no real-world projection. Pick a unit (we use “1 unit ≈ 0.1 m”) and a bounding box. { minX, minY, maxX, maxY } becomes the SVG viewBox.

  2. Step 02

    Trace the building outline

    A polygon describing the exterior wall. The renderer uses it for the floor base fill and the heavy exterior stroke; cuts in that stroke are walls tagged window.

  3. Step 03

    Cut the floor into rooms

    Each room is a polygon plus a kind (classroom, office, lab, stairwell, …). Adjacent rooms share an edge: that edge is the wall between them, and the door placed on it is where you can pass.

  4. Step 04

    Drop graph nodes

    One per room (the centroid, usually) and a chain along corridors. Tag the elevator and stairwell nodes with features: ["elevator"] / ["stairs"] and link them to the equivalent node on adjacent floors via connectsToFloor.

  5. Step 05

    Wire edges

    Connect nodes that you can walk between in a straight line without crossing a wall. Tag with the features the path actually carries: stairs, narrow_passage, ramp. The profile system does the rest.

  6. Step 06

    Validate and ship

    Drop the file under src/data/maps/<building>/<floor>.json. Zod parses it on load: wrong shape, fast failure. The home page, floor switcher, and assistant pick it up automatically.

Assistant

A wayfinder that doesn't make up rooms.

The assistant is a chat client with exactly two tools, find_room and find_route, both of which call the same library code the manual route picker uses. It never has to invent a node id or a path; it asks.

Tools, not prompts

Tool inputs are typed with Zod. The runner handles the agent loop, so the API code is < 200 lines. When find_route succeeds, the same path object the UI’s manual picker uses gets piped back to the map.

Cached map data

The whole building’s JSON sits in the system prompt with a cache_control: ephemeral breakpoint. The first turn writes the cache; every turn after that reads it back at roughly a tenth of the price.

Now route a wheelchair around a flight of stairs.

Open the demo, switch the profile to Wheelchair, ask the assistant to take you from the entrance to the lecture hall on the first floor, and watch the route pick the elevator over the stairwell.