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.
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.
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 path | Default | Wheelchair | Low vision |
|---|---|---|---|
| Stairs | 1× | blocked (∞) | 1.5× |
| Step | 1× | blocked (∞) | 1× |
| Narrow passage | 1× | 3× | 1.5× |
| Ramp | 1× | 1.1× | 1× |
| Elevator | 1× | 1.2× | 1× |
| Automatic door | 1× | 1× | 1× |
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.
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.
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"] }]
}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
nfrom the start. - h(n), straight-line distance from
nto 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
cameFrombackwards to recover the path.
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.
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.
- 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. - 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
wallstaggedwindow. - 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
doorplaced on it is where you can pass. - 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 viaconnectsToFloor. - 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. - 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.
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.