fifteen

ui/ux, product, mapping, geospatial, react, typescript, privacy

fifteen — a London-only 15-minute isochrone discovery app. A design and engineering case study of mapping, taste-ranking, and privacy-by-architecture.

The live application is the fastest way to understand the idea. Open it before reading — drop a pin anywhere in London and watch the polygon draw.

Overview

fifteen is a product and a piece of cartographic software built around a single, stubborn idea: the most useful thing a local-discovery tool can do is take things away. Every other app in this space gives you an infinite pannable world and a ranked list of everything. fifteen gives you a hard boundary — the 15-minute walk or cycle polygon — and a small, opinionated set of picks inside it. The polygon does the editing that infinite maps refuse to do.

It is London-only, by decision rather than by accident. The product is a single instrument trained on one city: it serves the 15-minute walk polygon, dims everything outside it to near-invisibility, and ranks the places that remain by taste rather than by star ratings. Origins are snapped to H3 cells before anything leaves the device, so the server literally cannot be told where you are standing. The constraint is the identity, and privacy is a property of the architecture, not a setting in a menu.

The original motivation

The idea started from a frustration with discovery itself. When you open a maps app and type "coffee" in an unfamiliar part of London, you do not get an answer — you get 4,000 results sorted by an algorithm optimised to show you the nearest franchise. The tool knows your location to the metre and still hands you a list that could be anywhere. The information is there; the editing is not.

The 15-minute city gave the project its name and its shape. The urban-planning concept — that daily needs should be reachable within a short walk or ride — is normally a policy argument about how cities should be built. fifteen treats it as an interface: a way to ask a question of the city as it already is. Not "is this neighbourhood well-served?" but "given that I am standing here, now, what is actually worth doing within fifteen minutes?" The polygon becomes the lens, and the question becomes answerable in under fifteen seconds.

The problem being solved

Local discovery has three structural failures that no amount of more data fixes:

1. Infinite canvas, no editing. A map you can pan forever shows you the whole world and asks you to decide what matters. The decision is the hard part, and it is exactly the part the tool offloads to you.

2. Ranking by popularity, not taste. Sort by rating and you get the chain that has optimised for meeting expectations. Ask "eat near me, best rated" and the system will, every time, surface McDonald's. A star rating measures met expectations averaged over a self-selected population; chains are machines for meeting expectations, so rating-mass ranking is structurally a chain detector pointed the wrong way.

3. A coordinate is a burden, not a feature. Handing an app your exact position feels harmless until you read the privacy fine print. fifteen refuses the premise: it never asks for a coordinate it can act on, and the server never receives one.

The product is an attempt to solve all three at once. The polygon solves the first. A taste engine that never sorts by rating solves the second. H3 origin snapping solves the third.

Inspiration and influences

The visual lineage is real and specific. Francis Galton drew isochronic charts from London in 1881; the Ordnance Survey has spent two centuries marking benchmarks, grid references and setting-out lines into the ground. Surveyors still spray fluorescent pink on tarmac to say this boundary is real, somebody measured it. That is exactly what fifteen does: it measures a boundary and asserts it.

So the design language is a survey instrument operated at night — flat, hard-edged, visibly constructed, with the discipline of an OS sheet rather than the pastel neobrutalism of a Dribbble shot. The severity is where the edge comes from; the typography is where the class comes from; the single flash of survey pink is the only warmth in the system, and it is spent on exactly one thing: the boundary and what lies inside it.

Architecturally, the influence is the Unix philosophy applied to a maps product: small, composable layers, ports instead of vendors, behaviour expressed as data. The taste engine in particular is a direct response to the failure modes of recommenders — it is deterministic, testable, and tunable by a non-engineer with a YAML file.

Why existing mapping products were insufficient

Google Maps, Apple Maps, Citymapper, and the rest are routing and navigation tools. They answer "how do I get from A to B?" They are engineered to be universal and therefore refuse to make a judgement. When they do surface "things to do," they surface a feed — popularity-ranked, ad-influenced, and identical whether you are in Soho or Zone 3.

None of them encode the central claim fifteen makes: that the reachable world, bounded by time, is more useful than the whole world. Their polygons (when they offer them at all) are radii or traffic-weighted shapes tuned for driving, not the irregular notched outline of a genuine walking-network isochrone. And critically, they are built to keep you in the app — fifteen is built to hand you back to the street, deep-linking out to Citymapper or Apple or Google Maps the moment you pick something. fifteen refuses to own the routing experience on purpose.

The fifteen-minute city as an interface

The conceptual move is to take a planning slogan and make it the query surface. The 15-minute city is usually discussed at the scale of policy: zoning, density, transport investment. fifteen drops the scale by about twelve orders of magnitude and asks the question of a single person at a single H3 cell.

Seen as an interface, the fifteen-minute city is just a filter — a hard spatial AND over the set of everything. But because it is a time filter rather than a distance filter, it follows the street network instead of a circle. That single fact is what makes the product feel honest: the boundary is a measurement of how the city is actually wired, not a guess drawn with a compass. The constraint becomes the thing you can trust on screen.

Product goals

The spec fixes four product invariants. Breaking any one of them is a pivot, not an iteration:

1. The visible world is the isochrone. Everything outside it is dimmed to near-invisibility. The mask is the signature visual.

2. Never more than 15 picks at once. A small opinionated set, not a feed.

3. Ranking is never rating-weighted. Ratings are a floor filter and a liveness signal, nothing else.

4. London only. The abstraction for other cities exists in the code; no product effort goes there.

The core loop target: open the app, resolve an origin, render the polygon, pick an intent, get the pins and cards, deep-link out to walk there — in under fifteen seconds from open to walking.

Design philosophy

Every element is load-bearing. If a screen could plausibly be a SaaS dashboard, it is wrong. The system is built on a few hard rules that hold across both of its visual identities:

Zero radius everywhere. border-radius: 0 is a house rule; curves are a per-theme decision, but a surface is paper and a control is a thing you press. Rounding them is what turns any interface into the same soft SaaS card.

Active state is inversion, never opacity or glow. A selected control inverts to the accent fill with ink text. There is exactly one permitted shadow in the whole system: a hard, blur-free offset cast by the detail panel along its map edge.

Default motion is instant. Hover and press get at most 120ms; there is one orchestrated moment — on origin or mode change the boundary redraws like a plotter, stroke travelling around the polygon before the tick marks stamp in — and one element that never stops, the bearing needle on the selected row, which is rationed to a single 12px mark so it is a shimmer rather than a compositor load.

Two families, no third. One instrument face for data and labels, one voice face for the taste engine's reason line. Nothing between.

User experience decisions

The origin semantics differ by platform, and the difference is a feature. Mobile is GPS-first — "around me, now." Desktop has no meaningful GPS, so it is pin-first — "around Angel at 7pm" — a planning surface running the same engine. Long-press re-origins on both.

The mask is deliberate claustrophobia. The user cannot pan more than about one polygon-width beyond the boundary. This is the product's whole personality: it is not a map you explore, it is a frame you are handed. The card stack — a bottom sheet on mobile, a side rail on desktop — carries the ≤15 picks. Each card shows a name, a one-line reason ("only Georgian restaurant in your patch", "café in a Grade II listed toilet block"), walk minutes, and a bearing arrow. No star ratings appear anywhere.

The list scrolls now, on every screen rather than only on a phone — a real cost of raising the cap from seven to fifteen (see Trade-offs). The rail is the accessible representation of the map, so the keyboard reaches the first nine picks by digit and the rest by arrow. A keyboard has nine digits that mean what they say; inventing a tenth is how a shortcut becomes something you have to be told twice.

Information architecture

The app is one screen. The IA is the polygon: origin, mode, intent, picks. The only navigation away from the map is the About page, reached from the rail footer, which carries the licensing attribution for every data source — OSM, Protomaps, Foursquare OS Places, FHRS, TfL, DfT BODS. The client keeps one attribution list, read from the /discover response and falling back to the region pack, so where it is displayed is a rendering decision and cannot become a second, staler answer about what the product owes.

Vocabulary is fixed on purpose. The picks are the picks (the list). The polygon is the fifteen. The origin is the benchmark. The map markers are stations. Sentence case for sentences, caps for microlabels, plain verbs, no marketing adjectives.

Interaction design

The signature interactions are the ones that make the boundary feel measured rather than drawn:

The setting-out boundary. A 2px accent stroke with perpendicular ticks (computed geometry, 600m apart, 140m long) and crosshair stations at the vertices — a surveyor's boundary on a site plan, not a highlight.

The mask as unprinted paper. Outside the polygon is a Bayer dither in the field colour, so the unreachable world reads as unsurveyed rather than merely dark.

Benchmark origin. "You are here" is an OS-style benchmark: crosshair over a broad arrow. Re-origin stamps a new one.

Numbered stations. ≤15 picks as squares, dim at rest, inverted when hovered or selected, with a numeral in the instrument face's heaviest weight. The numbers match the rail exactly — map and list are one instrument.

Telemetry frame. A hairline-ruled strip along the bottom of the map, mono 10px, populated from the real /discover meta: MODE WALK · BUDGET 15:00 · CELL 8A19…3F · DATA 2026-07-19 · 231 ELIGIBLE · 15 SHOWN. Honest instrument chrome that replaces a decorative footer.

Visual design system

The product ships with two identities that live behind one data-theme attribute on the document, and switching between them is a proof rather than a feature: it is only true if nothing anywhere branches on which theme is on.

SETTING OUT — a survey instrument operated at night. Blue-black field, bone type, one saturated pink, an ordered dither over everything outside, an OS cut mark at the origin, square numbered stations, survey ticks along the boundary. Zero radius everywhere, one hard shadow, nothing eased.

CONCIERGE — a note written for you at a good hotel's front desk. Warm white, near-black ink, one deep claret. One sans family throughout, with the engine's voice in that family's drawn italic. A halftone screen outside the fifteen, a solid star at the origin, circled numerals, an arrow before each outbound link.

Both identities are driven by a single source of truth: a ThemeSpec in packages/ui/src/themes/. A generator emits the CSS custom properties for the DOM; a second generator emits one MapLibre style per identity, including the app's own layers with empty sources so a setStyle carries them across. Nothing anywhere hardcodes a colour. Two rules hold it together and are enforced in CI: no component branches on a theme's name, and no colour literal lives outside a theme module.

Mapping decisions

The map stack is MapLibre GL over a Protomaps PMTiles extract of London — a single static file, range-request-served from any dumb host, with no tile-server process. The basemap is muted so the polygon treatment carries the visual identity.

The mask is an inverted GeoJSON mask layer: a world rectangle with a polygon hole, high-opacity dark fill, plus a boundary stroke. Picks render as minimal pins; a selected pick draws a straight bearing line and a minutes label — not a route. The walk is the user's problem, and half the point. The product refuses to own routing UX and deep-links out instead.

The polygon itself is the honest part. Served from a real Valhalla instance against the real Greater London extract, it comes back with the irregular, notched outline of a genuine street-network isochrone — visibly not the disc a radius would draw. Measured at M0: a 15-minute walk polygon covers 3.09 km² against a spec estimate of 2.5–3.5; a 15-minute cycle polygon covers 24.59 km² and is 7.9× the walk — "the toggle that transforms the product" is a measured fact, not a slogan.

Technical architecture

The system is a monorepo of thin clients around one brain. All intelligence lives server-side behind a tiny API; clients are rendering shells — map, cards, sensors, cache. This makes desktop/mobile parity trivial, lets taste iterate weekly without touching app stores, and centralises provider keys, caching, and cost control.

Exactly three things in the system are volatile externalities, and each gets a port (interface) in core with adapters as plugins. Providers are configuration, not architecture:

IsochroneProvider: self-hosted Valhalla by default, with Google Isochrones, Mapbox, and ORS as swappable adapters.

PlaceSource: OSM, Foursquare OS Places, and a curated seed file, conflated into one table.

TileSource: PMTiles (Protomaps London extract), with any raster/vector fallback.

No adapter type leaks past the port boundary. The BFF, the taste engine, and the clients never know which routing engine produced a polygon.

The dependency arrows are enforced, not aspirational, by pnpm workspace boundaries plus an ESLint import-boundary rule plus the Turborepo task graph: core imports nothing; api-client and ui import core; apps import those three and never each other or the services; the API and ingest services import core. Wire contracts are zod schemas in core/contracts — the contract is a package, not a document — so the server validates in and clients parse out against the same truth.

Data sources

Three sources feed one conflated POI table, each chosen for what the others lack:

OSM (Geofabrik Greater London) gives the best indie and oddity coverage — markets, towpaths, lidos, micro-venues — and rich category tags, at the cost of stale hours.

Foursquare OS Places (open, Apache 2.0) gives a chain_id — a gift for chain suppression — plus better opening hours and liveness timestamps.

Curated seed (data/taxonomy/seeds.yml) is ground-truth taste: a few hundred hand-picked places plus a denylist. It doesn't scale and doesn't need to.

Enrichment side-channel: FHRS (UK Food Hygiene Ratings, OGL) — "registered since 1987" is a longevity signal no rating can fake. Conflation matches candidates within 75m, with a trigram name similarity above threshold and a category-compatible tag; field precedence is curation > Foursquare for hours > OSM for categories and geometry, with provenance kept per field so a "why" line can cite its source.

Routing and geospatial reasoning

The routing core is self-hosted Valhalla, chosen over Google Isochrones deliberately: Greater London is a ~122 MB OSM extract and the tile build, while it takes ~18.6 minutes on NAS-class hardware, is a once-per-extract cost; after that every request is zero marginal cost. Valhalla's output is deterministic, which makes polygons aggressively cacheable and precomputable — and a traffic-aware polygon that changed every request would make the boundary feel like a mood rather than a fact. London-specific quirks (canal towpaths, parks with gates, Thames crossings) come straight from OSM tagging.

Origins are H3 cells, not coordinates. Clients snap GPS or a pin to an H3 resolution-10 cell — about a 66m edge, under a minute at walking pace and invisible against a 15-minute budget — before anything leaves the device. This buys three things at once: a finite cache keyspace (cell, mode, budget), the option to precompute the entire launch surface (~105k cells × 2 modes), and privacy by architecture — the API signature literally cannot accept a lat/lon, so the server never receives a raw position. The exact position exists only client-side, to draw the "you are here" dot.

Polygons are simplified with Douglas-Peucker at ~10m tolerance before storage (0.7 KB walk, 5.0 KB cycle in measurement), versioned by OSM extract date so a new extract cold-starts the cache rather than silently mixing with the old generation. An optional overnight batch precomputes all of Zones 1–2, making the hot path a static lookup.

Performance considerations

Performance is treated as a correctness problem. The eligible set is small — measured at ~20.8k intent-eligible places from nodes alone in the M0 spike, with way geometry pushing that higher — so every serve-time spatial query is a "points in polygon" the database eats for breakfast, and polygon-local rarity frequency is computed over hundreds of rows. The heavy work (tile builds, ingest) is offline and weekly; the live path is a cache lookup in front of Valhalla.

On the client, the mask, pins, and cards render over a basemap that is a single static PMTiles file served by range requests — no tile-server round trips. The whole backend, Valvalla, PostGIS, and the API, comes up from one compose file on a NAS or a €5 VPS. Offline behaviour is real: the last N /discover responses plus the PMTiles file are cached, so a tube-station-exit cold start still renders instantly.

Progressive enhancement

The backend runs with no infrastructure at all in its M0 state: an in-memory fixture of places. With no isochrone provider, the app still renders — the mask and the picks draw over an empty background, which is the M0 evidence that the product does not depend on the basemap to mean something. The two are different things and worth not confusing: routing tiles are how the polygon is computed; the basemap is the streets you see underneath.

At the layer above, behaviour is data. What counts as "Do", how much curation is worth, which chains get rescued — all versioned YAML and JSON under data/taxonomy/, hot-swappable without a deploy. Retuning taste is a data pull request, not a code change. The same discipline keeps London-only as product scope rather than code scope: no source file contains a London literal. Bounding polygon, extract URLs, GTFS feeds, tile paths all live in data/regions/london.yml. A second city is a second YAML plus new extracts — or it never happens, and the discipline cost nothing.

Mobile-first considerations

Mobile is the GPS-first surface and the harder one. Location is foreground-only; the origin re-snaps only when the device crosses into a new H3 cell (~66m) rather than continuously, which keeps the privacy promise and the battery bill low. The card stack is a bottom sheet; the map pans are clamped to the polygon. Touch is the primary carousel and map gesture surface.

Locating is a deliberate chain: ask the device; if it cannot answer, derive an approximate position from the connection. The fallback is labelled network all the way to the telemetry strip and carries its own note, because it is city-accurate at best — two providers asked from one machine disagreed by three kilometres, which is further than a fifteen-minute walk. Three constraints hold: a denial is never routed around (asking a third party where the connection is would answer a question the user just declined); it is off with one variable for anyone who would rather have a button that fails; and §4.2 is untouched — whatever comes back is snapped to an H3 cell before it reaches state, so the server still cannot be told a coordinate.

Challenges encountered

Building the visual system surfaced six issues that were only findable by looking, not by reading:

1. MapLibre's stylesheet loaded after the app's and collapsed the map container to zero height. Vendor CSS now loads first so the app's can win.

2. The canvas was measured before the grid settled and stayed 300px tall inside an 800px hole. A ResizeObserver on the container fixes it; MapLibre only watches the window.

3. The telemetry strip rendered underneath the map it sits below, because an absolutely-positioned map took itself out of the grid flow.

4. symbol-placement: line put two ticks on a boundary that wanted a dozen. Ticks are now computed geometry — deterministic, evenly spaced, exactly perpendicular.

5. Tick spacing tuned at 3× zoom made the boundary look like a gear at 1:1. Judge density at the size people will see it.

6. The narrow layout fitted the polygon around a bottom sheet that had become a grid row, zooming the map past its own boundary.

Two contrast failures were caught by a script rather than by eye: a tertiary text token at 2.91:1 and a dim accent at 2.37:1, both under the 3:1 non-text and large-text require. Lifted minimally, hue held within two degrees.

Trade-offs made

The cap moved from 7 to 15. The spec says breaking an invariant is a pivot rather than an iteration, and that is what happened: the product owner raised it to 15 knowingly, with the consequence written in front of them. Seven was the constraint made countable — a list you could hold in your head, and the reason the polygon could be described as doing the editing infinite maps refuse to do. Fifteen is still a refusal, but a weaker one: it is more than a glance, it does not fit a phone without scrolling, and the diversification now has to spread a page nearly twice as wide over patches that often do not hold that many good answers. The thesis survives; the sharpest evidence for it does not. The change also retired a noun — "the seven" — in favour of "the picks," a noun rather than a count, so the next time the cap moves it does not have to be re-agreed.

Bus is deferred to v1.5. A 15-minute bus polygon is mostly the walking polygon plus two narrow lobes, while the engineering cost is the largest in the system: GTFS ingest, a time-dependent engine (OpenTripPlanner 2 behind the same port), and a cache keyspace that grows ~100× from time-bucketing. It is additive behind the port, so deferring it is not forgetting it.

Google is excluded on principle. Google Places is proprietary with no caching beyond session and a display-on-Google-Maps coupling — structurally incompatible with the architecture. Google Isochrones is built as an adapter and kept behind the port, but never made the default, because pre-GA preview terms can change without notice and likely carry display coupling that conflicts with the MapLibre/PMTiles stack.

Attribution moved off the map. The basemap carries no on-canvas credit (MapLibre's attribution control is off); every source is instead attributed on the About page, one navigation away. This was a product-owner decision taken with the obligation stated, and it is documented as chosen rather than lost.

Major iterations during development

The product was built in phases — tokens, map, signature elements, components, states, accessibility — screenshotting after each and revising against a banned list before moving on. The biggest iteration was the move from a single identity (SETTING OUT) to a themed system with two. That refactor exposed a long list of places where a colour, a weight, a radius, or a duration had been written as if it were universal when it was one theme's answer: a hand-kept font preload that preloaded the wrong identity's faces; a shell that hardcoded the old default's theme attribute and flashed blue-black on every cold visit; a map-style registry that silently fell back to the wrong basemap; weights pinned globally while the per-theme weights sat read by nothing.

The discipline that caught these was simple and brutal: SETTING OUT must render bit-identically before and after every change. But that discipline has a blind spot — a token nothing consumes photographs perfectly. So the checks now assert consumption rather than existence: a contrast check fails a CSS variable that resolves nowhere, a font check fails a weight a spec asks for and no loader fetches, a style check fails a class no source reaches.

Features that were removed or redesigned

The serif voice was withdrawn. The taste engine's reason line was to be set in a serif italic as the one place the product speaks in its own words. The serifs were withdrawn by decision; the italic was restored on its own as a real cut of the instrument face, because a browser handed a roman will shear it, and a sheared roman is the letterforms of the neighbouring face at an angle — the appearance of the decision and none of it. A cross-theme invariant enforcing the voice's exclusivity was withdrawn with it rather than weakened into something that would pass while meaning nothing.

"The seven" became "the picks." A count noun carrying a constraint became a noun that does not have to be re-agreed.

The on-map attribution line was removed in favour of the About page, as described above.

Bus mode, a budget slider, and weather weighting were scoped out of v1 deliberately, each recorded as an open question rather than a dropped promise.

Accessibility considerations

The system treats contrast as a build gate, not a review step: a script asserts every foreground/background pair against WCAG AA for its intended size and fails the build on a drop. The rail is the accessible representation of the map, so the keyboard reaches picks by digit and arrow and the bottom of the list is somewhere a reader can travel to. Every icon button carries a label that becomes both its accessible name and its tooltip — not a formality but the only thing between a mark and a guess, and "Do" in particular cannot be drawn, so its flag mark is labelled in words. prefers-reduced-motion removes the orchestrated plot and the bearing-needle twitch outright rather than shortening them, because an animation of zero duration that repeats forever is still an animation. The map labels are the one known deviation: MapLibre renders from signed-distance-field glyph PBFs and the Protomaps asset host only serves Noto, so area labels use the available family with the intended treatment until a glyph build step exists — everything else on the map is DOM and correctly set.

Future roadmap

v1.5 adds bus mode via an OpenTripPlanner 2 adapter and BODS GTFS, with a time-bucketed polygon cache, plus a coffee/booze sub-toggle if Drink feels muddy. v2 consumes the signals logged from day one — saved / been / skip / opened against an anonymous device id — into a per-user taste model over category, price, and indie-ness features, the only real metric of delight being the saved + been rate. A budget slider (10/15/20/30) is possible but weighed against identity dilution, and a second-city dry run uses the region-pack discipline that already costs nothing to maintain.

Lessons learned

A constraint is a feature if you commit to it. The polygon is the whole product, and its honesty (a real network isochrone, not a radius) is what makes the rest trustworthy.

Ratings are a trap. Sorting by popularity is structurally a chain detector. Taste is five small deterministic layers with three weights and two exponents — every new term is a new way to be wrong invisibly.

Privacy is cheapest at the boundary. Snapping to an H3 cell before the request is made costs one line and removes a class of problem forever. An API that cannot accept a coordinate cannot leak one.

Theme-switching is a proof, not a feature. The moment you have two identities, every hardcoded colour or branched name is a bug waiting for the second theme. Make the checks assert consumption, or they will only catch what someone is watching.

Measure the spine before you believe the spec. The M0 spike confirmed every estimate within an order of magnitude — and corrected one: "a few minutes" for a tile build is 18.6 minutes on NAS hardware. True, but plan for twenty.

Reflection

fifteen is the most opinionated thing built here, and the opinion is the point. It is a refusal dressed as a map: refuse the infinite canvas, refuse the popularity ranking, refuse the coordinate. Each refusal is a small piece of engineering — a mask, a layer stack, an H3 cell — and together they add up to a tool that answers a question most maps will not even let you ask. It is also, quietly, the most principled: the privacy model is not a policy bolted on after launch, it is the shape of the request itself. If the project has a thesis, it is that good local discovery is mostly subtraction, and the hard part was having the discipline to take things away and then defend the emptiness.