Building a Map for a School I Was About to Leave
I turned two school floor plans into an offline indoor-navigation PWA with wall-aware, cross-floor A* routing.
Fremd Maps is an indoor map for Fremd High School. Pick a starting room and a destination, and it draws a route between them. The building has two floors connected by several stairways, and the app handles routes across both. The app builds a wall-aware visibility graph in a Web Worker and uses A* to find a path through it.
I started the project because I knew the building well enough to notice how much context a map leaves out. I knew which stairs connected which hallways and which routes actually made sense between classes. A new student usually doesn’t. I wanted to see if I could turn that knowledge into something useful.
This wasn’t the first version of this idea. During my junior year, I worked on a Jetpack Compose group project for my mobile applications class. I handled the routing algorithm and most of the map display work. It only covered the second floor, but it proved that room-to-room routing could work. At the start of second semester senior year, I came back to the idea, rebuilt it as a web app, mapped both floors, and kept working on it through the summer after graduation.

Data problem
I started with two floor-plan images. They show room names, walls, doors, and hallways, but they don’t tell code where someone can walk. They are architectural plans, not navigation data.
The original plans also contain useful context. Room labels, door markers, and hallway names already match what students see in the building. I tried using wall data gathered through OCR to draw a cleaner version of the school, but the simplified map lost too much of that context. Rebuilding room outlines, labels, doors, and smaller details by hand would have taken a lot of work for a result that was harder to read. I kept the original floor-plan images for display and used the extracted wall data for routing.
I added a separate data layer for navigation. Each floor has JSON files for nodes, walls, and traffic zones.
- Nodes represent rooms, hallway points, bathrooms, and stairways.
- Walls stop routes from crossing through parts of the building.
- Traffic zones add a penalty to discourage routes through selected areas.
The current map has about 230 navigation nodes across the two floors, including 7 stairway portals. Most node coordinates were placed by hand on top of the floor-plan image. A hallway that looks obvious in the image can be surprisingly easy to model wrong. One misplaced point can create a route through a room, and one missing wall can create a shortcut through something solid.
The data lives in the repository with the app. When a route looks wrong, I can inspect the points and wall segments that produced it. I also built development-only overlays for nodes, walls, zones, and the graph. The graph overlay ended up being especially useful because it lets me tell whether a bad route comes from missing wall data, two nodes being too close, or a hallway point being in the wrong place. The dev tools let me adjust positions while looking at the floor plan, but those edits only last for the session. The actual data stays in JSON files so I can review changes before deployment.
Routing engine
I used Leaflet because it already handles panning, zooming, overlays, and the interaction patterns people expect from a map. It also works with non-geographic coordinates, so lat is just the vertical position on the floor-plan image and lng is the horizontal position.
I considered using Leaflet tiles, but the app only has two floor-plan images. Tiling would add a pipeline I didn’t need. I used image overlays instead, with each floor stored as a single image with fixed bounds. That kept the original plans intact and still gave me Leaflet’s pan and zoom behavior.
The app doesn’t route on the image itself. It builds a graph from the node and wall data. For each floor, it checks nearby node pairs. If the straight line between two nodes doesn’t intersect a wall, the graph gets an edge between them. If a wall blocks the line, it doesn’t. That produces a visibility graph, and A* searches it for a route.
I first considered connecting each hallway point only to the next point I had entered. That would have been simpler, but it would make routes depend too much on the order of the data. The visibility graph gives the pathfinder more choices and lets it skip points that don’t matter.
There are enough possible node pairs that graph construction still needs some care. I limit the maximum distance for an automatic connection, then check each possible edge against the wall data. Graph construction runs in a Web Worker so the map stays responsive, with a main-thread fallback if the worker can’t run.
Stairways link the floors. A stair node on one floor connects to its matching node on the other. I treat that as an edge with a fixed cost, not as a line drawn between two floor-plan images. When a route reaches a stair node, the app pauses and asks the user to switch floors.

A cross-floor route pauses at the stairs and asks the user to switch floors.
The route cost isn’t just distance. I add a small penalty to diagonal edges and a fixed penalty for stairs. Those values are routing weights, not walking-time estimates. They help the pathfinder prefer routes that match the shape of the building instead of taking short but awkward-looking shortcuts.
Interface
The first routes I generated reached the destination, but the directions exposed every small bend in the graph. A short walk could turn into a list of unnecessary left and right turns.
I now simplify the route line and the written directions separately. Small bends disappear from the line, and the directions only show turns that are worth mentioning. That was one of the points where the project stopped being only a pathfinding problem and became an interface problem too.

A same-floor route from room 129 to room 115, with the route and directions shown together.
Search is part of that interface too. Room names aren’t always what people type, so the search uses fuzzy matching and aliases. For example, biblioteca can find the Library while the displayed name still matches the sign in the building. There’s also a “Nearest bathroom” button that runs Dijkstra’s algorithm from the starting room and returns the closest option.
The phone layout got the most attention. On a phone, route controls sit in a bottom sheet with large buttons. On a wider screen, they move into a sidebar. I wanted the mobile layout to feel intentional instead of like a desktop page squeezed onto a smaller screen.
Architecture
I didn’t build a backend. The floor plans and navigation data are static assets, routing happens in the browser, and Cloudflare serves the site. That keeps the system simple: if I need to fix a wall or move a node, I can update the data and deploy the site. The production app is backed by unit, end-to-end, accessibility, and navigation-data validation checks in CI.
The app is also a PWA. After the first load, it caches the app shell, floor plans, and navigation data so it can keep working when school Wi-Fi or mobile data is slow or unavailable. When a new version is detected, the app prompts the user to refresh so updated map data doesn’t stay stuck behind an old cache.
The biggest limitation is that Fremd Maps doesn’t know where a person is standing. The user has to choose a starting point, which works if they know the room they just left but not if they are already lost. The map data is also manual, so a renovation or locked door can make a route inaccurate until I update it. The app doesn’t estimate travel time or offer accessibility-aware routes, because I don’t have enough data to do either reliably.
Takeaways
The web version ended up being much more than a rewrite of the Android prototype. A* mattered, but it was only one piece. The harder parts were turning floor plans into usable data, checking routes against walls, handling stairs, writing directions that made sense to a person, and testing the result against a building I already knew.
I also got a better sense of how much ordinary map software hides. A line on a map looks simple, but it depends on choices about where someone can walk, what counts as a connection, and which route should be preferred. Working on one building made those choices visible.
I’m glad I came back to the project before leaving Fremd. The Android version proved the idea could work; this version turned it into something I’d actually want to use. I won’t need the map in the same way anymore, but other students might. If it saves someone a few minutes between classes, that’s enough.
You can try it at maps.mananlalwani.com, or look through the code on GitHub.