St. Olaf College's campus map data, scraped from the college's own ArcGIS
services and published as GeoJSON. The St. Olaf counterpart to
carls-app/map-data, and shaped to match it.
Two things, for two different readers.
| What it is | Read it if | |
|---|---|---|
data/*.geojson |
One file per source layer, the college's field names untouched | You want St. Olaf's data as St. Olaf publishes it |
map.json, map.geojson |
128 places in Carleton's schema | You are AAO, ccc-server, or anything already written against Carleton's |
routing.json |
A pedestrian routing graph | You want to walk someone from one door to another |
vendor/ole-compass/ |
The 2016 survey routing.json is built from |
You are changing how the graph is georeferenced |
Both are committed, and the commit history is the change log for campus data: when a building is renamed, a lot is restriped, or a footprint is corrected, that shows up here as a reviewable diff on the day the college makes it. Everything about the pipeline is arranged to protect that — see Determinism.
Everything is downstream of one ArcGIS Online web map,
St. Olaf Campus Map (2026) (48f433ddf4674d53b1754e307ffce1f4),
which is what the college's public dashboard embeds. Its layers are
shared publicly and advertise Query, so the scrape is unauthenticated and no
credential belongs in this repo.
| Layer | Geometry | Features |
|---|---|---|
buildings.geojson |
Polygon, MultiPolygon | 38 |
parking-lots.geojson |
Polygon | 52 |
accessible-parking.geojson |
Point | 26 |
athletic-fields.geojson |
Polygon, MultiPolygon | 16 |
points-of-interest.geojson |
Point | 10 |
campus-boundary.geojson |
Polygon | 1 |
water.geojson |
Polygon | 27 |
walkways.geojson |
LineString | 138 |
natural-lands-trails.geojson |
LineString, MultiLineString | 23 |
campus-roads.geojson |
LineString, MultiLineString | 38 |
main-campus-roads.geojson |
MultiLineString | 1 |
| 370 |
Taking the web map's operational layers, rather than every layer in every
service, is what keeps that list honest: it is by construction the data the
college publishes as its campus map. It also avoids a trap. The services carry
stale duplicates — St_Olaf_Campus_Data/13 is the same 52 parking polygons as
St__Olaf_Parking_Lots_WFL1/0 with every field but Id dropped — and the web
map draws the polygons from one while popping up the attributes from the other.
We take the one with the attributes. scripts/sources.py is the whole registry;
adding a layer is one entry.
points-of-interest.geojson merges seven single-purpose layers (the bookstore,
the chime tower, the EV charger, four dining locations, the visitor desk,
admissions, the windmill) that are one concept split across seven layer ids.
map.json and map.geojson hold 128 places, 92 with a footprint — against
Carleton's 124 and 96, which is a fair indication the two datasets are of
comparable use.
| Places | |
|---|---|
| Parking (lots, accessible stalls) | 72 |
| Buildings | 38 |
| Points of interest | 10 |
| Athletic fields | 8 |
| With a prose description | 87 |
| With departments linked | 17 |
| With floor plans | 13 |
The 36 places without a footprint are points: the accessible-parking stalls and
the points of interest. Fourteen more source features — unnamed parking polygons
and blank rows in the athletic-fields layer — are not places, because an
unnamed polygon cannot be labelled, searched for or linked to. Their geometry is
still published in data/.
carls-app/map-data publishes map.json and map.geojson; ccc-server serves
the GeoJSON at carleton.api.frogpond.tech/v1/map/geojson; AAO reads it.
Matching that shape means a stolaf.api.frogpond.tech/v1/map/geojson is a
configuration change rather than a second code path. Every key Carleton emits is
emitted here, including the ones St. Olaf has nothing to put in, so a consumer
never has to test for a missing key.
Three properties are added: abbreviation, type and links. Extra keys
are additive and safe, and dropping St. Olaf's building abbreviations — RNS,
BMC, TOH, the identifiers people on campus actually use — to preserve an
exact field list would be throwing away good data for a bad reason.
Carleton's two files disagree with each other. This reproduces that faithfully rather than fixing it, because consumers are written against it:
| Where | Order |
|---|---|
map.json — center, outline |
latitude first |
map.geojson — everything |
longitude first (GeoJSON, RFC 7946) |
overrides.yaml — centerpoint |
longitude first |
Swap either and every place lands in the Indian Ocean with no error anywhere, so
scripts/verify.py bounds-checks both files in their respective orders. That
check is tested by breaking it.
Carleton's places carry a hand-placed label anchor; that is what its
overrides.yaml exists to maintain. St. Olaf's layers have no such point, so one
is derived per place — and the obvious derivation is wrong.
An area-weighted centroid falls outside the shape for anything L-shaped or
horseshoe-shaped, which on this campus means Old Main, the Ade Christenson
Complex and most of the lots that wrap a building. A label anchored outside its
own building points at the wrong thing. So scripts/geometry.py uses the
centroid when it lands inside the polygon and computes a guaranteed-interior
point otherwise. All 92 polygonal places currently anchor inside their own
footprint, and verify.py fails the build if that ever stops being true.
A technically-interior anchor can still read badly. Set a centerpoint in
overrides.yaml for any that does.
Carleton's records carry fields St. Olaf has no source for. They are emitted as
null or [] rather than filled in with guesses.
Addresses. Not scraped, though they look scrapeable. Every page on
wp.stolaf.edu shows 1520 St. Olaf Avenue, Northfield, MN 55057 — in the
sidebar as an office's mailing address, and again in the site footer as the
college's. It is not the building's address, and no St. Olaf building has one.
Taking it would have stamped the same wrong address onto all twelve residence
halls with nothing in the data to reveal the error.
Floor plans are scraped, and are the one gap the college's web actually
fills. Each residence hall has a page under wp.stolaf.edu/residencelife/<slug>/
carrying labelled floor-plan PDFs, and each building's ArcGIS prose links to its
own page — so the pages to fetch are discovered from the scraped data rather
than hardcoded. Twelve pages, 13 buildings with floors.
Departments need no scraping at all: a building's Information HTML already
links to the departments it houses, with the department name as the link text.
Tomson Hall lists 21, Regents Hall of Natural Sciences 10. St. Olaf does not
separate academic departments from administrative offices the way Carleton does,
so they all land in departments and offices stays empty rather than being
filled by guesswork.
Photos and accessibility have no source. photo is null and accessibility
is "unknown" throughout.
The scrape must stay a faithful copy of what the college publishes — that is
what makes its diff a usable change log. Anything we know better goes in
overrides.yaml, where it is reviewable and survives the next scrape. Same job
as Carleton's file of the same name.
ids: # source name -> id, for names that slugify badly
removals: # ids to drop
changes: # fields merged onto a place, keyed by id (incl. centerpoint)
additions: # whole records, in map.json's shapeIt currently carries three id shortenings and three spelling corrections. The
ArcGIS layer spells two residence halls differently from the college's own
Residence Life pages and from the building signage — Hillboe for Hilleboe,
Kittlesby for Kittelsby — and calls the Carlson Tennis Courts a "Course", in a
record whose own description then refers to "the courts".
Ids default to the name with its spaces and punctuation removed, in Carleton's
style: oldmain, randhall, regentshallofnaturalsciences. Places from
non-building layers are prefixed (lot-, field-) because the parking lot
beside Rand Hall is also called Rand. Where several places genuinely share a
name — three separate polygons are all called "Porter" — the ids are numbered
from 1, so no arbitrary member of the group gets to be the unsuffixed one.
verify.py lists them, since a numbered id usually means the source wants an
override.
Committing scraped data is only worth doing if a diff means something. Three
things protect that, and verify.py enforces the third:
- Volatile fields are dropped.
OBJECTID,FIDandGlobalIDare database identity, not campus data, and renumber wholesale when a layer is republished.Shape__Areaand friends are recomputed server-side. - Feature order is imposed — by name, then by a hash of the geometry — since ArcGIS returns features in whatever order it likes.
- Nothing carries a timestamp. A "scraped at" field would make every scheduled run a commit, which is exactly the noise this avoids. When the data last changed is the commit date; when it was last checked is the workflow run history.
Coordinates are rounded to 7 decimals (~1 cm), which is far finer than the source data's real accuracy and coarse enough that float formatting cannot produce a spurious diff.
Needs mise, which installs Python, uv and actionlint at
exactly the versions CI uses.
$ curl https://mise.run | sh
$ mise install
$ uv sync --locked
$ uv run scripts/scrape.py # ArcGIS -> data/*.geojson
$ uv run scripts/enrich.py # WordPress -> data/residence-life.json
$ uv run scripts/build.py # + overrides.yaml -> map.json, map.geojson
$ uv run scripts/verify.pybuild.py and verify.py need no network. There is one dependency, PyYAML, for
overrides.yaml; the ArcGIS client, the HTML extraction and the geometry are all
stdlib, which keeps a job that runs unattended for months off a supply chain it
does not need.
Every failure listed here is one this pipeline can actually produce and that nothing else would catch — the scheduled run has nobody watching it, and the files it commits are read by an app, not by someone who would notice a building had moved to Nebraska.
- Both files bounds-checked, each in its own coordinate order.
- Every label anchor inside its own footprint.
map.jsonandmap.geojsonholding the same places in the same order.- Every key Carleton emits, present on every record.
- Ids unique; no volatile fields leaked into
data/; no layer empty. overrides.yamlentries still matching something.- The build is reproducible — it re-runs
build.pyand fails if the output moves.
.github/workflows/scrape.yml runs monthly, on the 1st at 06:00 UTC, and on
any push that changes how the data is derived (scripts/, overrides.yaml, the
lockfiles). Campus data changes a handful of times a year, so a daily poll was
almost all no-op runs; anything urgent can be dispatched by hand.
It scrapes, builds, verifies, and commits to main only if something
changed — a no-op commit on every run would destroy the history's value. Every
PR gets a dry run: same pipeline, nothing committed.
Two deliberate details:
- The push trigger excludes
data/,map.jsonandmap.geojson, which are what the workflow commits. Triggering on them would make it re-trigger itself forever. - The
wp.stolaf.edustep iscontinue-on-error. The college's WordPress being down is not a reason to throw away a good ArcGIS scrape:build.pyfalls back to the committeddata/residence-life.json, floor plans stay at their last known good values, everything else still updates, and the run summary says so.
./build-tiles.sh builds a MapLibre vector basemap with St. Olaf's campus
layers joined into it, and publishes it to GitHub Pages alongside the data.
Adapted from carls-app/map-tiles, which does the same job for
Carleton; the pipeline, the graduated extraction and most of the checks are
theirs.
$ sudo apt-get install -y tippecanoe # or: brew install tippecanoe
$ ./build-tiles.sh # ~40s from a clean checkoutIt pulls a few hundred tiles out of the Protomaps daily planet build over
HTTP range requests — no planet download, no OSM processing — tiles this repo's
own map.geojson with tippecanoe, and tile-joins the two into one archive.
Output lands in dist/, which is exactly what gets published. Nothing is
committed to the default branch.
campus.pmtiles |
6.2 MB |
tiles/**/*.pbf |
985 files, 11 MB (uncompressed — see below) |
fonts/, sprites/ |
14 MB |
| site total | 32 MB |
z0=1 z1=1 z2=1 z3=1 z4=1 z5=1 z6=1 z7=1
z8=1 z9=2 z10=6 z11=20 z12=64 z13=256 z14=144 z15=484
The geographic coverage matches map-tiles deliberately: full detail over both campuses and Northfield, street-level out to Apple Valley and Faribault. AAO is one app over two colleges and students move between them, so matching bboxes means the two tilesets are interchangeable rather than each being wrong outside its own campus.
map-tiles tiles Carleton's data as footprints and label anchors. St. Olaf's does not fit that shape:
buildings 38 parking 72 athletics 8 poi 10
More than half the places are parking. Putting 46 parking polygons in a
layer called campus_buildings would be a lie the style then has to work
around, and drawing 72 parking labels at the zoom where you want building names
buries the campus. So:
| Layer | Geometry | Features | Zooms |
|---|---|---|---|
campus_buildings |
MultiPolygon | 38 | z14+ |
campus_grounds |
MultiPolygon | 54 | z14+ |
campus_paths |
MultiLineString | 168 lines | z15+ |
campus_labels |
Point | 128 | z15+ |
Every anchor carries a kind, which is what lets the style bring each sort of
place in at the zoom where it stops being clutter — buildings at z15, points of
interest and fields at z16, parking at z17. buildingId is on all three layers
and is the source feature's id, the same property name map-tiles uses, so
AAO's selection code keys off it unchanged.
The 26 accessible-parking points get their own kind for one reason: they all
share the name "Accessible Parking", and without it the style stamps that label
26 times across campus.
campus_paths comes from data/ rather than map.geojson, because walkways
are not places — no name, no id, nothing to label — so build.py leaves them
out of the published dataset. They belong on the map regardless: this is
something people walk around a campus with, and the college's 10.8 km of
walkways plus 12 km of Natural Lands trails were being scraped and then dropped.
It has them; they were invisible. Protomaps draws footways at #ebebeb, which
is tuned to read as a pale line on stock's cooler #e2dfda earth. Against the
warmer, lighter earth this theme uses, it vanishes into the background — so the
paths were being drawn the whole time and could not be seen. They are darkened
here to read as a path on light ground, the way a trail map draws one.
That is a good example of why the cartography gets checked by rendering it: no amount of reading the style would have shown it, because nothing was wrong with the style — the two colours were simply chosen against different backgrounds.
St. Olaf's colours are black and old gold, and the temptation with a brand palette is to paint the map with it. That makes a bad basemap — a wayfinding map's ground has to recede so the app's markers can be read against it, and gold over the whole frame leaves nothing for the foreground to be brighter than.
So the brand is spent in exactly one place: the campus buildings, which are the subject of this map and the one thing on it that is the college's own data. Everything else is a warm neutral tuned to sit underneath — water muted from stock's vivid cyan, parks desaturated (the Natural Lands are 350 acres of the frame), institutional land a whisper above the earth tone.
The one departure that needed proving rather than taste: the OSM building
layer is warm, not grey. map-tiles has to paint both of its building layers
one colour, because Carleton's hand-drawn footprints disagree with OSM's badly
enough that any colour difference becomes a doubled, misregistered outline. The
question was whether St. Olaf's have the same problem — the source layer is
named buildings_openstreet, which is suggestive but proves nothing.
Rendering the tileset with the OSM buildings layer forced to pure red answers it: across the campus core the overhang is a handful of narrow strips, the widest being two slivers along the west edge of Rolvaag. Almost every OSM footprint on campus sits entirely under a college polygon, so the campus can safely have its own colour. The OSM layer is kept in the same warm hue family anyway, so that where a sliver does show it reads as part of the same building — and so downtown Northfield, which has no campus data at all, stays in the same palette instead of turning grey.
That diagnostic also corrected a wrong guess of my own: the pale shapes that
look like duplicate buildings beside New Hall and Rolvaag are not buildings.
They are OSM's school landuse, which blankets the campus, plus this repo's own
campus_grounds.
The same tileset is published twice, with a style for each:
| Style | Source | Works when |
|---|---|---|
style.json |
tiles/{z}/{x}/{y}.pbf |
always |
style-pmtiles.json |
pmtiles://…/campus.pmtiles |
only if the MapLibre binary was compiled with PMTiles support |
In MapLibre GL JS you register the pmtiles:// protocol at runtime with
addProtocol. In MapLibre Native you cannot — it is the compile-time CMake
option MLN_WITH_PMTILES, and AAO consumes a prebuilt MapLibre.xcframework.
So style.json is the supported path and style-pmtiles.json is there to try:
if it renders, it is one request for the whole tileset instead of one per tile.
Two constraints inherited from map-tiles, both of which bite silently:
.pbftiles are stored uncompressed. Vector tiles are normally gzipped, but Pages will not setContent-Encodingon an arbitrary.pbf, so a gzipped tile arrives as garbage with no error. The PMTiles archive keeps its internal gzip, because PMTiles readers decompress from a declared field rather than an HTTP header. CI asserts the tree is not gzipped on every build.- Schema and style must move together. The tiles are Protomaps Basemap
schema v4; the style is generated from
@protomaps/basemaps, the matching style package. A mismatch does not error — it renders a blank map.
export const MAP_STYLE_URL = 'https://stolaf.dev/campus-map-data/style.json'.github/workflows/tiles.yml builds the site into dist/, uploads it as a Pages
artifact and deploys it through the Pages Actions integration. It carries the
tileset and the campus data:
| URL | |
|---|---|
…/style.json |
the map style |
…/campus.pmtiles, …/tiles/{z}/{x}/{y}.pbf |
the tileset, twice |
…/map.json, …/map.geojson, …/data/ |
the campus data |
…/routing.json |
the pedestrian routing graph |
…/ |
a preview map you can pan around to check a build |
all under https://stolaf.dev/campus-map-data.
Not stodevx.github.io. The StoDevX Pages site has a custom domain, so
GitHub 301s every project page to stolaf.dev/<repo>/. That redirect is
harmless for ccc-server, which fetches one JSON file an hour and whose existing
GH_PAGES helper already relies on it — but the style bakes absolute URLs for
the glyphs, the sprites and every tile, and a redirect in front of ~985 tile
requests is not something to ship to MapLibre Native. The styles point at the
canonical host.
The data files are published because Pages serves one branch and ccc-server
needs a URL — carls-app/map-data serves Carleton's the same way. They are
copied into dist/ rather than rebuilt, and CI diffs the copy against the
committed files, so the tiles and the data are provably the same revision.
.github/workflows/tiles.yml runs daily at 07:00 UTC, matching
carls-app/map-tiles, and on any push that changes the data or the
pipeline.
The two schedules are deliberately different, because they track different things at different speeds. The scrape follows the college's ArcGIS services, which change a few times a year. The tiles follow OpenStreetMap, which changes constantly: Protomaps rebuilds the planet daily from OSM minutely replication, and the build this pulls from carries OSM data to about 04:00 UTC the same day, so a daily run means an edit near campus reaches the app about a day later. 07:00 leaves the upstream build a few hours to publish.
Rebuilding nightly is cheaper than it looks — the build is well under a minute, and each deployment replaces the last, so nothing accumulates. Campus data changes do not wait for either schedule: the scrape's commit fires this workflow's push trigger.
It used to force-push dist/ to a gh-pages branch. That tells you the push
worked and nothing whatever about the deployment, so the workflow polled the live
URL waiting for a sha256 match and guessed at a timeout.
It guessed wrong. A ~32 MB site of ~1,800 files took Pages ten to fourteen minutes on one measurement and over twenty-six on another, against a ten-minute budget — three consecutive publishes reported "Pages never served this build" over deployments that were completely fine. Raising the number once did not fix the shape of the problem: a publish job that blocks for half an hour to re-derive a fact GitHub already knows, and that cries wolf when it guesses low. A check that fails on healthy runs is worse than no check, because it trains you to ignore the one time it is right.
actions/deploy-pages reports the deployment status directly, so there is
nothing to poll and nothing to time out. The build job ends when dist/ is
uploaded; the deploy job ends when GitHub says the deployment succeeded.
Nothing verifies the deployment afterwards, because GitHub has already said it worked and re-deriving that over HTTP is how all of the above went wrong.
One thing is checked, and it is not GitHub's job — it is ours. The styles carry
absolute URLs, since MapLibre Native resolves relative ones inconsistently. So if
the host baked into them is not the host Pages serves from, every one of ~985
tile requests takes a redirect, and the deployment is perfectly successful
throughout. That is not hypothetical: the first publish shipped
stodevx.github.io in the styles while the org's Pages site redirects to
stolaf.dev, and no deployment status would ever have shown it.
The check is a string comparison — the host read back out of the built
style.json against deploy-pages' own page_url — normalised for scheme and
trailing slash, because GitHub reports http://host/path/ where the styles carry
https://host/path. It catches that bug at its source, instantly, with nothing
to race.
map.geojson says where places are. routing.json says how to walk between
them — a different question, and one the ArcGIS layers cannot answer. Their
walkway linework has no connectivity model: two lines that cross on screen may
or may not join, and nothing records where a building's door is.
StoDevX/ole-compass can answer it. Its data/ holds a hand-built graph
of 228 nodes tracing real sidewalks, indoor hallways and stairs, plus the node
id of each building's entrance. Nothing in ArcGIS or OpenStreetMap carries the
indoor links, the stairs or the doors — and on a campus built down a hillside,
knowing which connection is stairs is an accessibility feature, not a detail.
| Edge type | Length | |
|---|---|---|
sidewalk |
9.80 km | |
indoor |
1.19 km | |
stairs |
0.70 km | |
scraped walkway |
0.68 km | connectors, see below |
path |
0.09 km |
263 nodes, 371 edges, 32 of 38 buildings with a door.
ole-compass is a 2016 C++/OpenGL course project whose last commit is dated
2016-09-19 — an archive, not an upstream. There is no release, no tag and no
maintainer to track, so fetching it at build time would mean a scheduled job
depending on a dormant repository staying reachable, in exchange for updates
that are never coming. Its three data files are copied into
vendor/ole-compass/ instead, with provenance and a commit sha in its
README. That makes this repo the owner, which is
the honest position: nobody else is maintaining it.
228
0 535 369 0
Each row is id x y indoor, where x/y are pixels on the original
program's campus bitmap, not longitude and latitude.
scripts/build_routing.py fits them into WGS84 in two stages: an affine fit
from each entrance button to its building's label anchor, then ICP refinement
snapping outdoor nodes onto this repo's scraped walkways, iterated, with the
building anchors kept in the fit so it cannot drift. That lands 61 outdoor
nodes a median 4.0 m from the scraped paths.
Buildings ole-compass never surveyed are reached by tracing the scraped walkways
out to them, marked as a distinct edge type so a router can prefer the surveyed
network over a traced connector. Six buildings still have no door and are routed
to at their nearest node — aca, mfb, pumphouse, townhouses, tph,
watertank — and verify.py lists them on every run.
Beyond the bounds and reproducibility checks the rest of the data gets:
- Every entrance resolves to a real place id, and points at a node that exists.
- Every edge references two distinct nodes that exist, with a declared type.
- The graph is a single connected component. A router fails quietly in a way a map does not: a detached piece does not draw wrong, it just never returns a path, and the caller cannot tell that from "no route exists".
That last check earned its place immediately — it found two self-loops in the
2016 data (121 3 119 0 121 0 123 0, node 121 listing itself as a
neighbour). They are dropped during conversion and reported, because the defect
is the source's rather than the conversion's.
StoDevX/course-data-visualization independently reached the same ArcGIS
layer and, like this repo, keys buildings by their ABB abbreviation — useful
corroboration of both the source and the id scheme. Its
scripts/build_path_graph.py is also where the ole-compass georeferencing was
worked out, and scripts/build_routing.py here is adapted from it. Three things
changed, all of which are why the numbers above beat its own:
- It reads this repo's layers rather than copies of them.
- All 38 buildings, not 32. Its building table is a dict keyed by
ABB, and the seven buildings with no abbreviation collide on the blank key" "— six overwrite each other, leaving only the President's House, and the script carries a- {" "}to work around the wreckage. Keying by this repo's ids makes New Hall, the Townhouses and Tostrud Center routable destinations. - Anchors, not vertex means. Its centroid is the mean of a polygon's outer ring, which lands outside anything L-shaped.
It also dropped the GranskouTunnel button as unmappable. It is not a lost
building: Alumni Hall's own prose calls it part of the "Clemens V. Granskou
Compex [sic], named in the honor of the fifth president", so that button is
Alumni Hall's way in — which is why ahl has a door here and not there.