Client story · Hearth Plot · 2025

The map finally matched what was in view

Hearth Plot stopped drawing homes as a list, then stopped trusting cached map tiles. The map that stayed asks for the exact view, clusters a million listings, and recenters only when the filters actually change.

7 min read

In viewcounts match the pins

  1. Capture
  2. Model
  3. Insight
  4. Interface
  5. Test
  6. Hold

Summary

Looking for a home starts with where it is. A list, however well filtered, cannot show several listings against each other and against the place a person already lives or works. Hearth Plot took the property category onto a map. The first version was a field of pins. The version that stayed is a viewport query plus clusters, because a city-wide view of every pin is not a way to choose a street.

The early map was built in Mapbox, first as a prototype off to the side of the main list, by engineers who had just joined from a maps product. Listings had always been ordered by time. A map has to be ordered by place, and only then by how new the listing is. Latitude and longitude were already in the search index, which could filter a bounding box. That was the easy part. The hard part was keeping the picture honest while the person moved.

Cached square tiles were simple and felt fast, and they were wrong. A small pan or a small zoom reused the last tiles, so pins from outside the frame stayed drawn while the count for the area had already changed. The two numbers stopped being the same set. The fix was to send the visible rectangle on every real move, then to fold nearby listings into one marker with a count until the person zoomed in. The server still does not remember the last view. A hash of the filters says whether the camera should move.

A list cannot answer where

Property search is not like searching for a lamp. The first question is location: near work, near a school, on this side of a river. Hearth Plot showed homes in the same list as every other category. Filters could narrow a price. They could not show scatter. The decision to use a map was that visual comparison, not a preference for a newer interface.

The goal was a map a person could read, and a system that would still be readable when the catalog was larger. Both had to be true. A beautiful map that fell over at city zoom would have been a demo.

From time to place

Until then, display rank was age. Newer listings won. On a map, place is the rank, and recency is the tie-break. The coordinates were already stored, and the search index could take a geographic filter, so the first query was a box: return the listings inside it. That index change is what made every later argument possible. Without it, the map was a picture pasted on a chronological feed.

Each session after that stored the camera, the zoom, the filters, the pins actually drawn, and the count the interface claimed. The research question was whether those last two were the same set.

Static tiles, and a count that drifted

Map tiles are small squares. The library takes a base address and fills in x, y, and the zoom, then asks the server for the listings in that square. It also keeps the squares on the phone. If the person comes back to a square, the old pins draw immediately and a refresh is requested beside them.

That is fast, and it took control away. A small move of the camera, or a small change in zoom, often stayed inside squares the phone already held. No new request went out. The number of listings the person could see changed, because some pins had slid off the frame, but the count Hearth Plot showed for the area had been computed as if those pins were still in view. The data was static. A small gesture did not earn a new answer. The count and the pins diverged, and the divergence was the bug a person could feel without being able to name it.

The visible rectangle is the query

The viewport approach throws away the square as the unit of truth. Whatever rectangle is on the screen is what the server is asked for, on every pan and every zoom that actually changes it. The server returns the listings inside that rectangle and nothing else. The set is dynamic. The count and the pins are forced to be the same set, because they are the same response.

This is also what comparable property maps do, for the same reason. Caching is a performance trick. It is not allowed to be the source of the number printed next to the map.

A city is not a hundred pins

Once the viewport was honest, a wide view of a large city put thousands of listings in frame. Drawing every one covered the map in a single color. Capping the pins at a fixed hundred or two hundred kept the map legible and lied about density: the neighborhoods with the most homes were the ones that disappeared. Navid Karim, who runs maps, put it as a choice between a red field and a missing neighborhood. Neither helps a person pick a street.

Clustering replaces a pile of neighboring pins with one marker and a count. Zooming into that marker opens the pins inside it, or smaller clusters. The heatmap of the old wide view is hottest in the center, where the pins occupied the same cells. That heat is the argument for the cluster. It is not interest in one home. It is the failure to tell homes apart.

HeatmapAttention on the old map. Heat piles where pins overlap, not on a single home.

How a cluster was allowed to be built

A cluster needs a base geography, a count inside each piece of it, and a rule for merging those pieces into a marker a person can hit. The base can be a regular grid: the map’s own tiles, a geohash, or a hex grid such as H3. Or it can be an irregular shape: a city, a neighborhood, or a blob drawn around where listings actually are. A regular grid covers everything and can put its center in an empty field. An irregular shape follows life and leaves gaps.

Counting was the next argument. One request per shape, a multi-search, is easy to explain and expensive to run. One aggregation, where the index groups the counts itself, was faster in the benchmarks, especially the grid aggregations the index already knew. Static clusters were tried first: counts for each province, city, and neighborhood, computed daily and stored for the low zooms. They were simple, and their centers did not sit on the listings. Dynamic clusters take the fine grid counts from the live index and merge them so the marker sits nearer the actual density. That is the combination that shipped: a geotile grid, an aggregation for the counts, and a dynamic merge into the markers on screen.

What the person sees

The blueprint is that map. The view is the grid. Clusters sit on it, with a count, where the old heat was a pile of pins. Open a home is the action, and it is quiet, because the decision happens on the cluster first. Before, square tiles stayed cached and a small pan reused them. After, the visible rectangle is the query. A wide zoom shows the cluster. Zooming opens it.

Hearth Plot
MapList
Map
Where it sits.
View
240 homes
80
Open a home
Map
  • Quiet
  • Low
  • Warm
  • Hot
  • Tension

Map blueprint for Hearth Plot. Clusters stand in for overlapping pins. Tension sits on the wide view.

A stateful map on a server that forgets

The service is stateless on purpose. Each request stands alone. The server does not keep what this person filtered last. A map is the opposite. The camera, the zoom, the filters, and the selected pin are a state, and they change together. When someone picks a new neighborhood, they expect the camera to go there. That means something has to notice that the neighborhood changed.

The older method sent the previous state and the new state on every request. It worked in a side prototype and did not belong on the main platform: too much payload, too much branching, and a memory of the user the rest of the system had refused to keep. The replacement is a hash. The filters that move the camera, city and neighborhood and category among them, are hashed on each request. The client sends the hash it last rendered. The server hashes the filters it just received and compares the two strings. It does not need to know what the old hash meant. If they differ, a filter that matters has changed, and the camera is computed again and sent back. If they match, the camera stays. The person gets a map that follows their filters. The server still forgets them between requests.

Do not remember the last view

Navid’s constraint was the platform’s: ask for the view every time, and do not store where this person last looked. Leah’s proposal was the hash. Send it with the view. If it changes, move the camera. If it does not, leave the camera. The server still has nothing to remember.

The count had to equal the pins

The viewport map ran against the tiled one. Primary: the area count equals the pins inside the frame. Hold: people still opened a listing. A correct map that nobody used would have been a regression. The count matched. Opens did not fall. The tile cache did not come back.

The map is not finished. Every time it is looked at the way a person looks at it, another gap shows up: a cluster centered on a park, a zoom that opens too late, a filter the hash forgot. That is the hold, in the longer sense. The infrastructure can take more listings. The reading of it stays open.

The test for Hearth Plot: control, variant, sample, primary result, and the measure that had to agree.
ControlCached map tiles
VariantViewport query and dynamic clusters
SampleHome-search sessions, two weeks
PrimaryArea count equals the pins in view
HeldChosen listings did not fall