Explore the changing landscape of Baltimore, one building at a time.
An interactive 3D data twin of Baltimore City's housing records β open vacant building notices, rehab permits, city demolitions and building permits β built for HopHacks 2026 on real Baltimore City open data.
Live app Β· Quick start Β· Data sources Β· Pipeline Β· Findings Β· Notebook (hosted) Β· Notebook (molab) Β· Notebook docs
How is Baltimore's housing landscape changing, and what can the city's public records tell us about different neighborhoods?
Note
Housing statistics in the app, notebook and this README are computed from the downloaded records. The property film uses Google Photorealistic 3D Tiles for a camera tour of the real building and surroundings. A separate photo-based renovation film is explicitly labeled as an AI concept.
Table of contents
| πΊοΈ Web app | Next.js 16 Β· TypeScript Β· Tailwind 4 Β· MapLibre GL 6 Β· deck.gl 9 |
| π§ͺ Data pipeline | Python 3.12+ Β· uv Β· httpx Β· Polars Β· DuckDB Β· Shapely |
| π Notebook | marimo Β· Plotly Β· DuckDB β a standalone reactive app |
| π€ Optional AI | Gemini (grounded Q&A and tour scripts) Β· Veo (renovation films) Β· ElevenLabs (narration) |
| π¬ Property film | Three.js Β· NASA-AMMOS 3D Tiles Renderer Β· Google photogrammetry |
| ποΈ Optional photoreal 3D | Google Photorealistic 3D Tiles via deck.gl Tile3DLayer (Cesium ion token or Maps key) |
The repo has two halves: web/ (the Next.js app) and notebook/ (the marimo notebook, the Python
pipeline and its Parquet data). Processed data ships in the repo (web/public/data,
notebook/data/processed/*.parquet), so both run straight after a clone.
# 1. web app
cd web
bun install # also copies the MapLibre worker and Draco decoders (postinstall)
bun run dev # http://localhost:3000
# 2. marimo notebook
cd ../notebook
uv sync
uv run marimo run baltimore.py # interactive app -> http://localhost:2718
uv run marimo edit baltimore.py # development / editing modeTip
npm install / npm run dev work too. With Nix + direnv, direnv allow provides bun, node and uv.
Just want the notebook? Grab bmore-casa-submission.zip from the
latest release, extract it and run
uvx marimo run --sandbox baltimore.py β no clone needed.
uv commands run from notebook/, bun commands from web/.
| Command | What it does |
|---|---|
uv run bmore-casa refresh |
Download every layer from ArcGIS, then rebuild Parquet, DuckDB and frontend assets |
uv run bmore-casa download [--only permits β¦] |
Download only (raw batches β data/raw/) |
uv run bmore-casa process |
Re-process existing raw data (no network) |
uv run bmore-casa verify |
30 independent checks: counts, dates, coordinates, aggregates |
uv run marimo run baltimore.py |
Notebook as an interactive application |
uv run python baltimore.py |
Execute every notebook cell headlessly (CI check) |
uv run python scripts/package_notebook.py |
Build the portable submission ZIP into dist/ |
bun run dev / bun run build && bun run start |
Development / production web app |
bun run typecheck |
tsc --noEmit |
bun test |
Reference, geometry, tile-proxy, camera-path and video API tests |
A full refresh downloads ~322k records and takes about two minutes.
Everything below is optional. Create web/.env (git-ignored) β keys stay on the server and are never sent to the browser.
Without keys the matching controls report that they are unconfigured; everything else works.
| Variable | Enables |
|---|---|
GEMINI_API_KEY |
"Ask Baltimore", tour scripts and Veo renovation films |
ELEVENLABS_API_KEY |
Answer and property narration |
CESIUM_ION_TOKEN |
Photorealistic 3D city and property films β free for non-commercial use: cesium.com/ion β Asset Depot β add "Google Photorealistic 3D Tiles" β Access Tokens. CESIUM_API_KEY and CECIUM_API_KEY are accepted as aliases |
GOOGLE_MAPS_API_KEY |
Alternative to the Cesium token: a Maps Platform key with the "Map Tiles API" enabled (billing, metered) |
GEMINI_MODEL, GEMINI_PROPERTY_MODEL, GEMINI_VIDEO_MODEL |
Model overrides |
ELEVENLABS_VOICE_ID, ELEVENLABS_MODEL_ID |
Voice and speech-model overrides |
SITE_URL |
Public origin used for share-card and canonical URLs (default https://bmore.casa) |
# web/.env
GEMINI_API_KEY=...
ELEVENLABS_API_KEY=...
CESIUM_ION_TOKEN=... # or GOOGLE_MAPS_API_KEY=...Warning
Before a public deployment, add authentication, usage limits and an upload-size limit at the reverse proxy: the AI endpoints consume the server account's quota and are intended for a local/demo app.
Select a property record, then Explore building in 3D.
Existing β a narrated camera tour of the real building
Existing opens a full-screen textured 3D view using the same imagery source as God's Eye View. A 36-second camera path approaches the record coordinate, orbits it, then pulls back to the neighborhood. Play/pause, scrub, reset, and manual orbit controls are available. A Cesium ion token or Google Maps key is required (see below).
Generate narration asks Gemini for a script grounded only in the server-resolved public record, then uses ElevenLabs for speech. The transcript is in Property details. Record tour captures the camera sequence and optional narration; Download film saves WebM or MP4 depending on browser support. Google attribution remains visible in the viewer and recording. Keep the tab visible while recording; manual navigation, changing views, or hiding the tab pauses capture.
Proposed β an AI renovation concept from your own photo
Proposed accepts a property photo you own or have permission to use (JPEG/PNG, up to 8 MB) and an exterior renovation brief. Veo generates an 8-second, 720p concept video with realistic camera motion. It is not an editable 3D model or evidence of an actual renovation. Google tile imagery is not used as an AI reference. Generation requires Veo access and available billing/quota on the Gemini key. Pending and completed operation tokens persist in this browser tab for up to 11 hours, so reopening the same property can resume polling. A newly selected photo replaces that saved session.
API routes and defaults
| Route | Purpose |
|---|---|
GET /api/property/record |
Resolves a validated local record reference |
POST /api/property/story |
Generates the narration script |
POST /api/property/video |
Starts a photo-based Veo job |
GET /api/property/video |
Polls or streams the result using an expiring, signed token |
API keys stay server-side. Defaults are gemini-3.6-flash (with a fallback) and
veo-3.1-fast-generate-preview; override with GEMINI_PROPERTY_MODEL and GEMINI_VIDEO_MODEL.
Provider errors are shown without inventing output. The older schematic OSM endpoint remains available at
/api/property but is not used by the property film.
Run bun test lib/property (from web/) for reference, geometry, tile-proxy, camera-path and video API tests.
Important
Imagery has variable capture dates and resolution. The coordinate is not a verified parcel/building match. Neither aerial imagery nor records establish current condition, interiors, structural capacity, or renovation feasibility.
The Photorealistic 3D city toggle draws the records on top of Google Photorealistic 3D Tiles β Google's textured
photogrammetry of the real buildings (the same source used by projects such as gods-eye-view). It is real measured
geometry, labelled in-app as context imagery rather than DHCD data. Add CESIUM_ION_TOKEN or GOOGLE_MAPS_API_KEY
to web/.env (see above) and restart.
Tiles are fetched through /api/tiles3d/*, a streaming server-side proxy, so the key never reaches the browser and
nothing is stored. With no key the toggle is disabled and the rest of the app is unaffected.
Note
A Gemini / AI Studio key does not work here β Google rejects it for the Map Tiles API.
3D map only. Under the photoreal toggle, 3D map only shows just the city: every record layer, label and panel is hidden and the camera re-centres on the full viewport. Show records & panels (or Esc) brings everything back exactly as it was β handy for opening a demo on the real city before revealing the data.
Once the data is downloaded the app makes no ArcGIS calls at runtime β it reads static files from web/public/data.
The street basemap (OpenFreeMap) is a progressive enhancement: if it cannot be reached within 3.5 s, the map falls back
to a fully local style where the official neighborhood polygons are the geography. The notebook's map is offline by default.
Tip
Force the offline look any time with http://localhost:3000/?basemap=off β a useful safety net on venue Wi-Fi.
| Opening | A slow orbit over the live map with the title card; Explore Baltimore flies in. |
| 3D density | 320 m hexagons whose height is the number of records, stacked by layer color. The height scale is pinned to the all-years maximum so dragging the timeline never rescales columns. They are statistics, not buildings: no footprints or heights are drawn because none were verified. |
| Records | Every notice, rehab and demolition as a clickable point; permits load per neighborhood. The card shows source layer, address, dates, status, parcel ID and any BLOCKLOT-linked records. |
| Time machine | A dual-handle year range with play; per-layer histograms show each source's coverage, and What is counted? spells out the semantics. |
| Neighborhood insight | Counts, rates per 1,000 parcels, rank, age of open notices, five-year windows, permit categories. |
| Compare | Two neighborhoods side by side (pick B from the list or click the map), shared-axis yearly charts, limits stated inline. |
| Search | Addresses across notices/rehabs/demolitions (plus the selected neighborhood's permits) and neighborhood names. |
| Ask Baltimore (optional) | The server computes a fact sheet for the current scope and year range; Gemini may only use those numbers, must name metric/place/period, and must refuse causal or "is this building vacant today" questions. Falls through to older models if one is overloaded. |
The city map uses MapLibre + deck.gl. The property film loads Three.js and 3D Tiles Renderer on demand and disposes its renderer when closed; it does not use React Three Fiber.
All data is public and published by Baltimore City. Downloaded 2026-09-19.
| Layer (as titled by the city) | Service | Records |
|---|---|---|
| Vacant Building Notice - Open | DHCD_Open_Baltimore_Datasets/FeatureServer/1 | 11,550 |
| Rehabs of Vacant Buildings | .../FeatureServer/2 | 12,863 |
| Completed City Demo | .../FeatureServer/0 | 4,276 (4,274 after 2 exact duplicates) |
| Building Permits | .../FeatureServer/3 | 292,806 |
| All Vacant Building Notices | Housing/dmxLandPlanning/MapServer/37 | 11,481 (cross-check only, see below) |
| Neighborhood_NSA | CityView/Neighborhoods/FeatureServer/0 | 279 polygons + Census housing units |
| Property Information | CityView/Realproperty_OB/FeatureServer/0 | 238,160 parcels (counted per neighborhood) |
notebook/src/bmore_casa/ β download.py β process.py β verify.py.
flowchart LR
A["ArcGIS services<br/>6 layers, ~322k records"] -->|download.py| B[("data/raw<br/>gzip batches + manifest")]
B -->|process.py| C[("data/processed<br/>Parquet + DuckDB")]
B -.->|"verify.py (30 checks)"| C
C --> D["notebook/baltimore.py<br/>marimo app"]
C -->|"~4 MB of JSON"| E["web/public/data"]
E --> F["Next.js app<br/>MapLibre + deck.gl"]
- Metadata first. Each layer's metadata is fetched and saved (
notebook/data/raw/<layer>/metadata.json): fields, OID field,maxRecordCount, spatial reference. - Count, then IDs.
returnCountOnlygives the API count;returnIdsOnly(not subject tomaxRecordCount) gives every OBJECTID. - Object-ID batching. Features are pulled in OID ranges of β€1,000, requested as WGS84 (
outSR=4326; the services are natively Maryland State Plane, WKID 2248). - Failure detection. ArcGIS returns errors as HTTP 200 +
{"error":β¦}β treated as failures and retried with backoff. A batch that reportsexceededTransferLimitor returns a different number of features than expected is split in half and re-fetched. The run aborts unless downloaded == API count. - Raw preserved. Every batch is kept gzip-compressed in
notebook/data/raw/, with amanifest.jsonof counts and timestamps. - Normalize. Dates β Baltimore calendar dates; addresses upper-cased and whitespace-collapsed;
BLOCKLOTcleaned; exact duplicate rows dropped; coordinates validated against the city bounding box; neighborhood labels validated against the 279 official names, with point-in-polygon filling blanks. - Store. Parquet tables +
baltimore.duckdb(tables and aneighborhood_summaryview) innotebook/data/processed/, plusquality_report.jsonandfindings.json. - Ship light. The browser gets ~4 MB: three point files (29k records), a hex-binned count grid for the 3D columns, neighborhood polygons, and year Γ type aggregates. The 293k permits are never sent in bulk β citywide they appear as aggregated columns, and individual permits load per neighborhood on demand.
- "All Vacant Building Notices" is not a history.
DateCancelandDateAbateare null on every record in both vacancy layers β they only contain notices still open. So the timeline shows when today's open notices were issued, never "vacant buildings in year X". The second layer is used only to cross-check the first. - Two timestamp conventions. The DHCD service stores true UTC instants; the land-planning layer stores local wall-clock time labelled UTC (it trails by exactly 4 h in EDT / 5 h in EST). Both are normalized to local calendar dates.
- OBJECTID is not unique in the demolition layer: 9 IDs are reused for different demolition events. They are kept as separate records, and the downloader/processor never use OBJECTID as a join key.
- Rehab records are a subset of permits. All are
USE/BUSE(use & occupancy) permits; every rehab record since 2019 also appears in the permit layer. The two are never added together. - Permits β buildings. 292,806 permits touch 90,556 parcels; 42,938 are modifications of earlier permits. Permit categories are inferred from case-number prefixes (checked against descriptions) because no code list is published. The permit system changed in early 2025 (
COM/USE/DEMβ¦βBRCM/BCCM/BUSEβ¦) and counts dip that year. - Costs are unusable in aggregate (β$6,000 to $4.4 billion): shown per record only, never summed.
BLOCKLOTis a reliable shared key, so cross-layer links use it exclusively β never fuzzy address matching.- Denominator. Rates are per 1,000 real-property parcels in each neighborhood (408 of 238,160 parcels lack a matching neighborhood label and are excluded).
| Metric | One record is⦠| Placed on the timeline by |
|---|---|---|
| Open vacant building notices | a notice still open at download (one per parcel) | date the notice was issued |
| Rehab permits on vacant buildings | a use & occupancy permit on a building that had a notice (one per parcel) β not proof of completed work | permit issue date |
| Completed city demolitions | a city-led demolition marked complete β a one-time event; private demolitions are absent | recorded finish date |
| Building permits | an issued permit or permit modification β not a building, not completed construction | issue date |
Generated by process.py into notebook/data/processed/findings.json; re-computed live in the notebook.
- Many open notices are old. Of 11,550 open notices, 3,383 (29%) were issued before 2016 β the oldest on 2004-11-05 β while 5,775 (50%) date from 2022 or later.
- Vacancy is concentrated. 10 of 279 neighborhoods hold 38% of open notices; 65 have none. Carrollton Ridge has 745 β 329 per 1,000 parcels, the highest rate among neighborhoods with β₯100 parcels.
- The recorded response changed shape. Completed city demolitions peak at 780 in 2019 and fall to 163 in 2025, while rehab permits on vacant buildings rise from 959 (2015) to 1,253 (2024).
- A permit is not an ending. 202 open-notice parcels also have a rehab permit record β in all 202 cases issued before the currently open notice. The records cannot say whether those rehabs were completed.
- Neighbors can look very different. Broadway East vs adjacent McElderry Park, all years: 187 vs 67 open notices, 138 vs 10 demolitions, and 118 vs 212 rehab records per 1,000 parcels.
Important
These are descriptive. The records do not establish why places differ or change.
web/ Next.js app (bun project)
app/ App Router (page, layout, /api/ask, /api/speak, /api/tiles3d),
favicon (icon.svg, apple-icon) and the generated share card (opengraph-image)
components/map/ CityMap - MapLibre + deck.gl overlay
components/dashboard/ Explorer, panels, timeline, search, record card, Ask Baltimore
components/charts/ YearBars (SVG)
components/property/ Photorealistic tour, narration, recording and renovation video
lib/data/ asset loading, statistics, fact sheet, types
lib/property/ Record lookup, tile rewriting, camera path, recording and video tokens
lib/geo/ colors, layer definitions, map style + offline fallback
scripts/ copy-maplibre-worker.mjs (postinstall)
public/data/ frontend assets (written by the pipeline)
notebook/ marimo notebook + data pipeline (uv project)
baltimore.py marimo notebook / app
baltimore-data.zip data snapshot for standalone / molab copies
src/bmore_casa/ Python pipeline: sources, download, process, verify, cli
scripts/ package_notebook.py (builds the submission ZIP)
data/raw/ raw ArcGIS batches (git-ignored, reproducible)
data/processed/ Parquet, quality_report.json, findings.json (+ DuckDB, git-ignored)
deploy/ Caddy config + run commands: web app at /, notebook at /notebook
.github/workflows/ ci.yml (every push / PR), release.yml (tags)
Every push and pull request runs two jobs (ci.yml), neither of which needs secrets:
| Job | Steps |
|---|---|
| Web | bun install --frozen-lockfile β typecheck β bun test β next build |
| Notebook | uv sync --locked β marimo check β headless run of every cell β build the submission ZIP β run that ZIP standalone outside the repo (what someone without the repo gets) β check the tracked baltimore-data.zip still matches the data it was built from. The ZIP is kept as a workflow artifact. |
Pushing a tag publishes a GitHub release with
bmore-casa-submission.zip, baltimore-data.zip and checksums, after re-running the notebook checks
(release.yml):
git tag v0.1.0 && git push origin v0.1.0- Housing records β Baltimore City Department of Housing & Community Development (DHCD) via Open Baltimore.
- Boundaries and parcels β Baltimore City Enterprise GIS.
- Basemap β OpenFreeMap Β© OpenMapTiles, data Β© OpenStreetMap contributors.
- Photorealistic 3D β Google Photorealistic 3D Tiles; attribution stays visible in the viewer and in recordings.