Building Sunny Coffee: finding sunny Paris terraces in real time
I realize that publishing this today in the middle of a very hot weather episode in Europe will disappoint people yearning for a map of air-conditioned Paris cafés, but this idea has been on the back burner for 12 years since this tweet from Snips, an AI startup in Paris that drew my attention in 2014.
At the time, I offered help to design it, and eventually joined the company in 2017, but by then they had pivoted from context-aware mobile devices into private-by-design on-device voice recognition.
Recently, I set out on what I thought would be "a weekend hack" with Claude Code. Hours turned to a few days and eventually into a small data pipeline, two shadow-calculating algorithms, and a 600,000-building cache.
I present you Sunny Coffee, a map of 220 of the most popular cafés in Paris that have a sunny terrace. Have a great coffee in the sun and if you're curious about how I built it with Claude Code, read on.
---
1. The idea
Parisians have a sixth sense for sun. The minute it appears in March, every café terrace fills up, but who likes to sit & sip in the shade?
The question I wanted to answer was simple:
> Where in Paris is there a café terrace in the sun right now?
But it wasn't a simple task. Getting there required a few things to come together:
- A reliable list of terraces with permits and rough geometry. (Paris Open Data)
- Accurate 3D building data for the whole city, because shadows are cast by buildings, not by coordinates. (OpenStreetMap, then IGN BD TOPO)
- A way to compute, for every terrace, when during the day it sees the sun. (built with Claude Code)
- A live shadow renderer for the detail view, where users zoom in and expect pixel-accurate shadows for the current minute. (ShadeMap)
- A daily refresh with a weather forecast layered on top. (Open-Meteo)
This article is the build log: the steps, the why, and the bugs that ate a weekend.
---
2. The data foundations
Terraces from Paris Open Data
The City of Paris publishes the full terrace permits dataset: every café that has filed for a sidewalk terrace, with its address, licensed length and width, and a GPS point.
This dataset was the only realistic starting point; the permit registry is authoritative and updated by the city.
Two early decisions about this dataset shaped everything downstream:
1. Freeze a curated subset. I trimmed `terraces.json` to a fixed list of 100 curated terraces.
2. The GPS pins might be wrong. The coordinates in the permit data didn't seem surveyed: they're either the cadastral parcel centroid, or maybe whatever the applicant typed into the form. I built a `pin-corrections.json` overlay for the worst offenders and re-geocoded through the French BAN (Base Adresse Nationale).
!Sunny Coffee overview map of Paris with a detail panel showing a single terrace
Buildings: the journey from OSM to IGN BD TOPO
Shadows need building footprints and heights. The first version queried OpenStreetMap via the Overpass API at click time. OSM has excellent footprint coverage of Paris, but only ~30% of buildings carry an explicit `height=` or `building:levels=` tag. The other 70% fall back to whatever default you pick.
The first default was 3 floors × 3 m = 9 m. Both numbers are wrong for Paris: the Haussmann standard is 5–6 floors, and floor-to-floor is closer to 3.5 m than 3 m. So a 35 m boulevard block was being rendered as a 9 m bungalow, casting a shadow less than a third of its real length. The risk: terraces marked sunny on the overview map could turn out to be deep in shade when you actually went there.
I bumped the default to 5 × 3.5 m = 17.5 m, which matched Paris's measured mean of 19.2 m, but defaults are still defaults. The real fix was switching to IGN BD TOPO, France's official 3D building database, derived from aerial photogrammetry by the national mapping agency.
For Paris alone, BD TOPO has:
- 603,382 buildings vs OSM's ~225,000. BD TOPO captures outbuildings, garages, and garden structures that OSM misses.
- 87% with a measured `hauteur` field, with ±1 to ±2.5 m precision depending on whether the height was derived from stereo imagery or interpolation.
- A documented schema, quarterly updates, and a free public WFS API.
I use it in two ways:
- Server-side (precompute): download the GeoPackage once, convert with `ogr2ogr`, store as a 100+ MB local cache. The cache is refreshed quarterly when IGN ships a new edition.
- Browser-side (live detail view): query the IGN WFS endpoint directly with a bounding box around the clicked terrace.
The unification of both paths to BD TOPO took the live scan from ~30% measured heights to 87% measured, exactly the same quality as the precompute. That alone removed most of the false-positive "sunny" markers.
---
3. Two shadow methods, one product
The ideal solution would be one shadow algorithm everywhere. I ended up with two, for one hard constraint: ShadeMap requires a browser.
ShadeMap is the WebGL renderer I use for accurate shadows in the detail panel. It draws shadow maps onto a GPU canvas and reads pixel colours back to determine sun/shade. There is no Node port. The daily server-side precompute script can't use it.
So I run two methods, and the architecture is built around the trade-off.
!Sunny Coffee terrace timeline showing hour-by-hour sun exposure
Method 1: Geometric ray-casting (server-side)
For each terrace, at every 15-minute slot from sunrise to sunset:
1. Compute the sun azimuth and altitude with SunCalc.
2. Cast a 2D ray from the terrace sample point toward the sun, in local metric space.
3. Intersect that ray with every building polygon within `height / tan(altitude)` metres, the maximum possible shadow length from that building at that sun angle.
4. First intersection: shaded slot. No intersection: sunny slot.
The output is a boolean array per terrace, ~48 booleans per day, compressed to a few hundred bytes per terrace in JSON. The full city processes in ~13 seconds.
It's pure maths: no network calls, no GPU, fully reproducible. Its limitations are that it assumes flat roofs, ignores terrain slope, and treats every building as an opaque box. For an overview map (and a weekend project), that's fine.
Method 2: ShadeMap pixel sampling (browser-side)
When a user opens a terrace's detail panel:
1. Buildings are fetched from the IGN WFS for ±550 m around the terrace.
2. ShadeMap renders a WebGL shadow map for the current time. Every pixel is either lit or shaded, based on real 3D building geometry and the exact sun position.
3. An `ExposureScanner` advances the simulation in 15-minute steps across the day, sampling the pixel at the terrace's position each time.
This is the ground truth. It's slower and needs WebGL, but it correctly handles arbitrary 3D occlusion, not just axis-aligned rays.
The product rule
The two methods occasionally disagree, and that's fine if you tell the user which is which: The detail panel is always the ground truth. The overview map is an approximation, optimised for instant load.
---
4. The precompute pipeline
Why precompute at all?
A full ShadeMap scan is ~10–15 seconds per terrace. With 220 terraces on the overview map, doing this at page load would mean ~37 minutes of loading and 220 hidden browser tabs running WebGL.
The precompute path:
```text
Daily (GitHub Actions, 05:00 UTC):
paris-buildings-cache.json (BD TOPO, 603k buildings, 145 MB)
+
terraces.json (220 curated terraces)
+
pin-corrections.json (manual GPS fixes)
↓
normalize-terrace-geometry.mjs (find each terrace's wall + corners)
↓
precompute-sun.mjs (geometric ray-casting, ~13 s)
↓
sun-precomputed.json (~5 KB per terrace, day-stamped)
terrace-geometry.json (wall-aligned polygons for the UI)
At page load:
sun-precomputed.json → coloured dots on the overview map (instant)
On terrace click:
IGN WFS API (buildings for ±550 m, prefetched on click)
↓
ShadeMap WebGL → pixel-accurate timeline + shadow animation
```
Daily automation, with a quarterly twist
The `precompute.yml` workflow runs every day at 05:00 UTC. The 145 MB building cache is stored in `actions/cache`, keyed on the BD TOPO edition date. A normal day is a cache hit and finishes in under a minute. When IGN ships a new quarterly edition, I bump the date in `fetch-paris-buildings-bdtopo.mjs`, the cache key changes, and the workflow downloads and converts the new GeoPackage (~10 minutes, once per quarter).
The workflow guards itself against unnecessary commits:
```bash
git diff --staged --quiet \
|| git commit -m "chore: daily sun precompute $(date -u +%Y-%m-%d)"
```
---
5. The bugs that ate a weekend
Bug 1: MultiPolygon buildings silently ignored
BD TOPO represents large or complex buildings, Haussmann blocks with internal courtyards, shopping centres, anything on a diagonal avenue, as `MultiPolygon` GeoJSON features. The wall-detection function had this:
```ts
for (const feature of buildings) {
if (feature.geometry.type !== 'Polygon') continue; // ← skips MultiPolygon
}
```
So if a terrace's own building was a MultiPolygon, its walls were invisible to the algorithm. The function fell back to the nearest simple Polygon, typically a neighbouring building with a different orientation. The terrace footprint then ended up inside the wrong building.
That had a knock-on effect on ShadeMap: when sample points land inside a building polygon, ShadeMap reads them as rooftop pixels, always lit. Live scans of 99–100% sun were common for these buildings, regardless of orientation.
The fix iterates each ring of a MultiPolygon independently:
```ts
const rings: [number, number][][] = [];
if (feature.geometry.type === 'Polygon') {
rings.push(feature.geometry.coordinates[0]);
} else if (feature.geometry.type === 'MultiPolygon') {
for (const poly of feature.geometry.coordinates) rings.push(poly[0]);
}
for (const ring of rings) { / process with its own centroid / }
```
The precompute wasn't affected because the BD TOPO cache pre-extracts the largest ring of each MultiPolygon during the GeoPackage conversion. Only the live, in-browser path had the bug.
Bug 2: 50+ terraces silently returning 100% sun
Both the live scan and the precompute place a grid of sample points across the terrace footprint. The number of rows (depth-wise from the wall) is governed by a margin formula:
```ts
const nMargin = Math.max(terraceWidth * 0.1, 0.75); // ← buggy
const nSpan = terraceWidth - 2 * nMargin;
```
Intent: keep sample points at least 0.75 m from the building wall, so they don't accidentally land on a rooftop pixel. Bug: there's no upper bound. When `terraceWidth < 1.5 m`, `nMargin > terraceWidth / 2` and `nSpan` goes negative. Some sample points then land inside the building wall, where ShadeMap reads them as rooftop = sunny, forever.
!ShadeMap detail showing a terrace pin landing on a rooftop instead of the sidewalk
For PARISTANBUL (`largeur = 0.6 m`):
```text
nMargin = max(0.06, 0.75) = 0.75 m
nSpan = 0.6 − 2×0.75 = −0.9 m ← negative
Row 0: offset = 0.75 m → in the street
Row 2: offset = −0.15 m → INSIDE the wall = always sunny
```
Audit result: 50% of terraces had `largeur < 1.5 m`. Paris permits frequently record very narrow strips (0.6–1.2 m). The mean is 2.2 m but the distribution is right-skewed; the median is around 0.9 m. The bug was silently inflating roughly half the dataset.
The fix is a one-liner:
```ts
const nMargin = Math.min(Math.max(terraceWidth * 0.1, 0.75), terraceWidth / 2);
```
For very narrow terraces, all sample points collapse to the centre. For wider terraces, behaviour is unchanged.
After the fix, PARISTANBUL dropped from a multi-hour inflated result to 2h15 / 19% of the day, consistent with its west-facing, narrow-street geometry.
Bug 3: Stale precompute hides everything
`sun-precomputed.json` is date-stamped, and the loader rejects anything that isn't from today:
```ts
const today = new Date().toISOString().slice(0, 10);
if (data.date !== today) return null;
```
There's a "trust hierarchy" rule: the live scan is accepted only if its sun ratio is within 2× (+15 percentage points) of the precomputed ratio. When the live scan is wildly higher, typically because of the bugs above or because a WFS request failed, I keep the more conservative precomputed timeline.
This rule needs a precomputed baseline to compare against. When yesterday's data is the only thing on disk, the loader returns `null`, and the live scan runs unguarded. Inflated results sail through.
This is why "100% sun" appeared intermittently: fine on the day of a fresh precompute, broken the next morning until the new one shipped. The fix isn't code. It's the daily GitHub Actions workflow that guarantees a fresh `sun-precomputed.json` exists before the first user opens the app.
Bug 4: The 5–8 second click delay
When a user clicked a terrace, the panel opened and showed a spinner for 5–8 seconds before any shadows appeared. Two compounding causes:
1. The Overpass API (when I still used it) was rate-limited and sometimes timed out.
2. Buildings were only fetched after the panel mounted and the mini-map initialised (~800 ms of UI before the network call even started).
I fixed all three:
- Switched to IGN WFS (no rate limits, no API key, deterministic).
- Prefetched on click rather than on panel mount. The network request fires the instant the user taps the dot, before any UI renders. By the time the panel and ShadeMap are ready (~2.8 s total), the response is already in cache.
- Stable cache key on map centre, not bounds, so the prefetch (which has no map yet) and the in-panel fetch (which has a map centred on the same terrace) produce the same key.
Click-to-shadow is now sub-second.
Bug 5: SunCalc's astronomical azimuth convention
This is the cheapest bug to fix and the most expensive to find. SunCalc measures azimuth from South going West, the astronomical convention, not the compass convention (North = 0, clockwise). When converting azimuth to a 2D direction vector with x = East and y = North, the correct transform is:
```js
const dx = -Math.sin(azimuth);
const dy = -Math.cos(azimuth);
```
Get the signs wrong and your rays point at the mirror image of the sun. Terraces facing south-east are identified as facing north-west, all wall normals are inverted, and every shadow is on the wrong side of every building. Claude Code caught it on a re-read of the SunCalc README.
---
6. The UX layer: making "sunny right now" feel obvious
Once the data was right, I focused on one thing: making the answer feel obvious at a glance.
Default to sunny. Only show terraces whose precomputed slot for the current 15-minute window says sunny. A "Show all terraces" toggle reveals the full set for comparison.
The filter is reactive, re-evaluated every 60 seconds against `new Date()`, so a terrace that enters shade at 16:30 disappears at 16:30 without a reload.
Time-travel via weather pills. The hourly weather forecast bar (cloud cover + temperature from Open-Meteo) doubles as a time selector. Tap any hour and the map filters to terraces sunny at that hour, and the heading copy updates: "X sunny terraces in Paris at 2pm". Tap again to deselect and return to live-now. Same data, two functions.
Contextual heading copy. The headline changes by time of day:
| State | Heading |
| -------------------- | ----------------------------------------------- |
| Daytime, no pill | X sunny terraces in Paris right now |
| Daytime, pill at 2pm | X sunny terraces in Paris at 2pm |
| After sunset | The sun has set on Paris. See you tomorrow. |
| Pre-dawn | Sunrise in 6h43 (live countdown) |
These are tiny touches but they're the difference between an app that lists terraces and an app that knows what time it is.
Instant polygon rendering. The orange terrace footprint used to appear only after the live ShadeMap scan completed (10–12 s). I now persist the wall geometry from the precompute: the wall point, the tangent and normal vectors, the corner coordinates, into `terrace-geometry.json`. The polygon renders the instant the panel opens; the live scan refines it in the background.
There was also a visible bug this fixed: terrace rectangles used to be axis-aligned, drawn east–west regardless of which way the building faced. On Paris's many oblique streets, the orange footprint would sit at a jaunty angle to the façade. Persisting the wall tangent and normal means every polygon now hugs its actual building edge, whatever the street bearing.
!Misaligned terrace rectangle drawn east–west across an oblique Paris street
---
7. Lessons that generalise
A few things I'd do the same way next time:
- Pick the authoritative data source, even when it's harder to ingest. BD TOPO needs `ogr2ogr`, a quarterly release schedule, and a 145 MB cache. OSM needed nothing. Worth every bit of the extra complexity.
- Pre-compute the boring path. Live-compute the interesting one. Static JSON for the overview, ShadeMap for the detail. Different trade-offs deserve different machinery.
- Treat narrow datasets as adversarial. The 0.6 m terrace bug affected 53% of the dataset. Anything you assume about input ranges needs a clamp.
One thing I'd do differently: start with the audit script. The bulk distance-to-wall audit that found LA NUIT SHANGHAI's 1.7 km error, the `largeur < 1.5 m` audit that found the sample-point bug, the precompute-vs-live ratio audit that revealed the MultiPolygon bug: each one cracked open a class of bugs that manual debugging would never have surfaced.
---
Twelve years ago, a tweet asked why nobody had built this. I offered to help, joined the company two years later, and watched them pivot into voice recognition.
Nobody got to it, and I couldn't build it then. With the help of Claude Code, I did eventually with a "weekend hack" that turned into a lesson in Parisian building heights, azimuth conventions, and the many ways a 0.6 m terrace can ruin your afternoon.
To everyone who arrived here looking for air-conditioned cafés: I see you. The heat will break. When it does, Sunny Coffee will be ready.
PS: If you have thoughts, comments or want to roll it out in your town, hit me up.
← Back to all posts