cd /news/ai-products/sunny-coffee-real-time-map-of-paris-… · home topics ai-products article
[ARTICLE · art-15103] src=aka.me pub= topic=ai-products verified=true sentiment=↑ positive

Sunny Coffee: real-time map of Paris café terraces using ray-casting

A developer built Sunny Coffee, a real-time map that uses ray-casting and 3D building data to show which of 220 popular Parisian café terraces are currently in sunlight. The tool combines Paris Open Data terrace permits, IGN building heights, and weather forecasts to calculate shadow patterns across the city. The project, created with Claude Code, solves a 12-year-old idea to help Parisians find sunny spots to sit outdoors.

read16 min publishedMay 27, 2026

I realize that publishing this today in the middle of a very hot weather episode in Europe will disappoint people who'd rather be offered 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.

PS: There are obviously thousands of café terraces in Paris, but this was only ever intended as a fun tech proof of concept and not a full directory.


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 coffee in the shade?

The question I wanted to answer was simple:

Where in Paris is there a café terrace in the sun right now?

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 shadow engine to calculate and display the shadows cast by buildings on the sidewalks. (ShadeMap)
  • A weather forecast with hour-by-hour prediction of sun and cloud cover percentage. (Open-Meteo)
  • A way to bring it all together and compute for every selected café terrace, when during the day it sees the sun. (built with Claude Code)

2. 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. Starting from ~5,000 licensed terraces in the Paris Open Data feed, I matched each one against the Google Places API and kept only operational cafés with rating ≥ 4.9 and ≥ 50 reviews — about 221 places. It's arbitrary, but I didn't want to have too many spots on the map since this is a proof of concept.

  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.1 m = 9.3 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.1 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 #

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. 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 and 220 hidden browser tabs running WebGL.

The precompute path:

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).


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:

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:

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:

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):

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:

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 rejects anything that isn't from today:

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 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: Misaligned terrace polygons and slow render

The orange terrace footprint used to appear only after the live ShadeMap scan completed (10–12 s). Worse, terrace rectangles were 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.

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, hugging its actual building edge whatever the street bearing; the live scan refines it in the background.

Misaligned terrace rectangle drawn east–west across an oblique Paris street

Bug 5: 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 6: 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:

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. A sunny design #

Once the data was right, I focused on one thing: making the answer feel obvious at a glance, joyful to look at, and fun to use.

Time-travel via weather pills. The hourly weather forecast bar offer an instant view into how much sun each hour will provide (how full is the pill), and the temperature from Open-Meteo. It also 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.

Default to sunny. The Paris map only shows 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.

The day at a glance. When you tap a terrace, the detail panel shows the full day as a slider: sunny slots are bright, shaded slots are dark, and the current moment is marked. Before you read a single number, you already know whether the terrace gets morning sun, afternoon sun, or a narrow window around noon.

Making the shadows dance. Drag the handle and the shadow map on the chosen café's mini-map updates in real time, so you can watch the Haussmann block across the street slowly swallow the terrace at 15:30. It turns a data point into something you can feel, and something fun.

Coffee in the Sun — full page screenshot


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.
  • Start small. Start with high quality data but a small subset of it so your execution speed remains fast. Once you've got your pipeline working, you can decide what to include in the final dataset.
  • 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.
  • Make it simple to understand, and a pleasure to use. The best data products hide their complexity and reward curiosity. The weather pills, the dancing shadows, the contextual headings, none are strictly necessary, but together they turn a lookup tool into something you want to explore.

One thing I'd do differently: start with the audit script. The bulk distance-to-wall audit that found a 1.7 km error, the width < 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.


TL;DR #

Snips AI tweeted about building a Paris sunny café terraces finder in 2014. I joined them in 2017 but they had pivoted to voice recognition before anyone built it.

Twelve years later, I did it with Claude Code: 603k Paris buildings polygons, ShadeMap WebGL, and 220 curated cafés. The "weekend hack" turned into a bug hunt and usability challenge, but I had lots of fun.

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

── more in #ai-products 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/sunny-coffee-real-ti…] indexed:0 read:16min 2026-05-27 ·