Flatlas
An interactive map of every Singapore HDB resale transaction since 1990 — live at flatlas.sg. This page is the engineering story.
Singapore publishes every public-housing resale transaction as open data, but as CSV files spanning five dataset eras with inconsistent schemas. Flatlas turns that into something you can actually reason with: a map where prices resolve through three zoom tiers — town choropleth, per-block dots, and 3D buildings extruded from their real footprints — with a time slider that replays 36 years of the market in inflation-adjusted or nominal dollars, and a pricing model that explains why a given block costs what it does.
The core decision: two channels, one database. Vector tiles are for drawing; a JSON API is for interacting. They never mix.
┌─────────────────────────────┐
│ PostGIS (source of truth) │
│ transactions · blocks · │
│ matviews · model coefs │
└──────┬───────────────┬──────┘
│ │
MVT tiles │ │ SQL (bbox, windowed)
▼ ▼
┌────────────┐ ┌────────────┐
│ Martin │ │ Go API │
│ tile server │ │ txn history │
└──────┬─────┘ └──────┬─────┘
│ render │ interact
▼ ▼
┌─────────────────────────────┐
│ React + MapLibre GL │
│ 3 zoom tiers · 1 price ramp│
└─────────────────────────────┘ Tiles come straight out of PostGIS as Mapbox Vector Tiles via ST_AsMVT, served by Martin. Anything you click — a
block's transaction history, the price explanation — hits a small Go
API that runs viewport-bounded, per-block-windowed queries. One
gotcha that shaped the API: a global LIMIT on a bounding-box
query silently drops entire blocks at dense zoom levels, so the cap is
applied per block with a window function instead.
The whole stack — database, tile server, API, ETL — runs in Docker on a single small VM. Nothing here needs more than that, and refusing bigger infrastructure kept every decision honest.
The data has addresses, not coordinates. The obvious move — geocode a million rows — is also wrong twice over. First, only the ~10k unique blocks need coordinates, so those are geocoded once and cached permanently. Second, and more dangerous: Singapore's official geocoder fuzzy-matches street names. Query "JALAN BATU" and you can get "JALAN JAMBU BATU" — a confident hit that puts a Kallang block eight kilometres away in Bukit Timah, with no error to catch.
The fix is defensive: every hit is validated against the block number and road name before it's accepted, unresolvable blocks are skipped and logged rather than forced to a wrong coordinate, and a second pass flags any block landing more than 5 km from its town's centroid and re-geocodes it, preferring the candidate nearest the centroid. Match rate: 98.6% of blocks, with the misses accounted for — mostly demolished blocks — instead of silently misplaced.
A 1995 flat that sold for $180k did not cost "$180k" in any sense a 2026 buyer understands, so the map defaults to inflation-adjusted prices. The subtle question is which index to deflate by. The first version used HDB's own Resale Price Index — and produced nonsense: deflating housing prices by a housing price index strips out the market appreciation itself, so 1990s flats read as expensive as today's. The index has risen roughly 8× since 1990; general consumer prices, about 1.9×.
The rebuild deflates by CPI (All Items) instead, which answers the question people are actually asking — "what is that in today's purchasing power?" — and produces the intuitive curve. Both nominal and real prices are precomputed in the ETL and shipped together, so the toggle flips instantly with no refetch. Because the choice is baked into a million rows, getting the definition right mattered more than any rendering decision in the project.
The "why this price" panel is a hedonic regression: log-price on flat attributes (area, storey, remaining lease), location features (distance to MRT and CBD, plot ratio, nearby commercial/industrial land use), and month fixed effects, with spline terms where the relationship is genuinely non-linear. Current vintage: test R² 0.94, median absolute error 5.6%, with 84% of held-out sales predicted within 10%.
Two disciplines kept it honest. Every candidate feature had to earn its place in a controlled out-of-sample test — several plausible ones (walking-route distance instead of straight-line, relative floor height, town×year interactions) were rejected because they didn't improve held-out accuracy, and the rejections are recorded so they don't get re-litigated. And the serving path is audited against the fitting path: one bug — an unqualified column in a correlated PostGIS subquery, valid SQL that computed every block's MRT distance as zero — skewed the explanation endpoint by the full MRT premium. It was caught by measuring the endpoint's residual bias over random blocks, a check that now runs whenever the model ships.
PostGIS · Martin · Go · DuckDB ETL · Python · React · TypeScript · MapLibre GL · Caddy · Docker