See how any place has changed.
Plotline turns a US address into a rich, scrollable timeline (decades of satellite and aerial imagery, neighborhood demographics, and property records) so you can watch a place evolve from farmland to subdivision, warehouse district to condo corridor, or quiet town to sprawling suburb.
π Try it live: COMING SOON
Or run it locally β one command, see Getting Started below.
Enter any US address. Plotline geocodes it, then searches public archives for every piece of history it can find β aerial photos from NAIP going back to 2003, Landsat satellite imagery reaching into the 1980s, Census demographic data across four decades, and county property sales and building permits. It stitches all of it into a single interactive timeline synced to a zoomable map, so you can scrub through time and watch the landscape change while the data tells you who lived there, what they paid, and what they built.
These locations are pre-loaded and ready to explore:
ποΈ Green Valley Ranch, Denver β Prairie to planned community in 15 years. NAIP imagery from 2003 shows empty grassland; by 2023 it's a dense subdivision with schools and parks. Census population grew over 4,000%.
ποΈ RiNo District, Denver β Industrial warehouses to breweries and condos. Demolition and new construction permits cluster between 2014β2020. Median home values tripled in a decade.
graph LR
User[Browser] --> Frontend[React + MapLibre]
Frontend --> API[FastAPI]
API --> Titiler[Titiler Tile Server]
API --> DB[(PostGIS)]
API --> Redis[(Redis)]
Redis --> Worker[Celery Worker]
Worker --> STAC[Planetary Computer<br/>NAIP Β· Landsat Β· Sentinel-2]
Worker --> Census[Census Bureau API]
Worker --> Socrata[County Open Data<br/>Denver Β· Adams Β· and more]
Worker --> DB
A user enters an address. The API geocodes it via the Census Geocoder, stores the parcel in PostGIS, and kicks off a Celery task. The worker searches the Microsoft Planetary Computer STAC API for historical imagery, pulls demographic snapshots from the Census Bureau, and fetches property records from county open data portals. Titiler dynamically serves Cloud-Optimized GeoTIFF tiles so imagery is zoomable at full resolution. The frontend renders everything on a MapLibre map with a synchronized timeline, demographic charts, and property event cards.
| Layer | Technology | Why |
|---|---|---|
| Frontend | React 18, TypeScript, Vite | Type-safe SPA with fast dev iteration |
| Map | MapLibre GL JS | GPU-accelerated map rendering, COG tile support, open-source |
| Charts | Recharts | Lightweight, composable, React-native charting |
| Animation | Framer Motion | Smooth timeline transitions and layout animations |
| Styling | Tailwind CSS | Utility-first, dark theme, consistent design system |
| API | FastAPI (Python 3.12) | Async-capable, Pydantic validation, OpenAPI docs for free |
| Database | PostgreSQL 16 + PostGIS 3.4 | Spatial indexes, geometry operations, industry standard for geospatial |
| Migrations | Alembic | Versioned schema changes, repeatable deployments |
| Task Queue | Celery + Redis | Async imagery/census/property fetching with per-source progress tracking |
| Tile Server | Titiler | Dynamic COG rendering β full-resolution zoom without downloading entire GeoTIFFs |
| Deployment | Fly.io | Auto-stop machines for API, worker, and tile server |
| Source | What it provides | Coverage |
|---|---|---|
| NAIP via Planetary Computer | Aerial imagery, ~1m resolution | Continental US, 2003βpresent |
| Landsat via Planetary Computer | Satellite imagery, 30m resolution | Global, 1984βpresent |
| Sentinel-2 via Planetary Computer | Satellite imagery, 10m resolution | Global, 2015βpresent |
| US Census Bureau | Population, income, housing, demographics by tract | Nationwide, 1990β2023 |
| Census Geocoder | Address geocoding + census tract lookup | Nationwide |
| County Open Data (Socrata) | Property sales, building permits | Denver metro β see SUPPORTED_COUNTIES.md |
- Docker and Docker Compose
- A free Census Bureau API key (register here)
# Clone the repo
git clone https://github.com/log0s/plotline.git
cd plotline
# Configure environment
cp .env.example .env
# Edit .env and add your CENSUS_API_KEY
# Start everything
docker compose upThat's it. Open http://localhost:5173.
The first startup takes a minute or two while Docker pulls images and runs migrations. Subsequent starts are fast.
make featuredThis pre-computes timelines for the featured example locations so they load instantly.
make up # Start all services (detached)
make down # Stop all services
make logs # Tail logs from all services
make migrate # Run database migrations
make test # Run backend tests
make lint # Lint backend (ruff + mypy)
make clean # Stop services and remove volumesplotline/
βββ docker-compose.yml # Full local stack: PostGIS, Redis, API, Worker, Titiler, Frontend
βββ fly.toml # API deployment config
βββ fly.worker.toml # Worker deployment config
βββ fly.titiler.toml # Tile server deployment config
βββ Makefile
βββ .env.example
β
βββ backend/
β βββ Dockerfile
β βββ pyproject.toml
β βββ alembic/ # Database migrations
β βββ app/
β β βββ main.py # FastAPI app factory
β β βββ config.py # Environment-based settings (pydantic-settings)
β β βββ models/ # SQLAlchemy + GeoAlchemy2 models
β β βββ schemas/ # Pydantic request/response schemas
β β βββ api/v1/ # Route handlers: geocode, parcels, imagery, demographics, events
β β βββ services/ # Business logic: geocoder, STAC client, Census client, county adapters
β β βββ tasks/ # Celery tasks: imagery fetch, census fetch, property fetch
β βββ tests/
β
βββ frontend/
β βββ Dockerfile
β βββ package.json
β βββ vite.config.ts
β βββ src/
β βββ components/ # SearchBar, MapView, Timeline, DemographicsPanel, CompareView
β βββ hooks/ # useTimeline, useGeocoder, useDemographics, usePropertyEvents
β βββ api/ # Typed API client
β βββ types/
β
βββ scripts/
β βββ seed.py # Seed example parcels
β βββ seed_featured.py # Pre-compute featured location timelines
β
βββ DEVELOPMENT.md # Claude Code build process journal
βββ SUPPORTED_COUNTIES.md # County data source documentation
This project was built using Claude Code as the primary development tool β from initial scaffolding through deployment. See DEVELOPMENT.md for a detailed, honest account of the process: which prompts worked, where Claude Code excelled, where I had to intervene, and what I learned about AI-assisted development as a senior engineer.
County data coverage is limited. Property sales and permits are currently available for Denver, Adams, DC, Santa Clara, and New York counties. The adapter architecture makes adding new counties straightforward, but each county's data portal has different schemas, field names, and API quirks that require manual integration work.
Address matching is imperfect. County records use inconsistent address formats. The app normalizes and fuzzy-matches, but some parcels won't find their property history, especially condos with unit numbers or addresses with unusual formatting.
Census tract boundaries change across decades. The demographic data for a given parcel uses the current tract boundary for ACS data and the contemporaneous boundary for decennial data. In rapidly growing areas where tracts have been split, the 1990 data may represent a much larger geographic area than the 2020 data. The UI notes this, but doesn't attempt cross-decade tract normalization.
Income and home values are nominal dollars. The demographic charts show dollar values as reported in each year's Census data, not adjusted for inflation. A median income of $40,000 in 1990 is not directly comparable to $75,000 in 2023. This is noted in the UI.
Imagery availability varies by location. NAIP coverage starts around 2003 and is limited to the continental US. Landsat goes back to 1984 but at 30m resolution β you can see land use changes but not individual buildings. Very rural areas may have sparse NAIP coverage. Areas outside the US have no NAIP or Census data.
MIT
