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. 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 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 https://x.com/snips/status/454173641048350720 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 https://www.sunnycoffee.live , 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 https://opendata.paris.fr/ - Accurate 3D building data for the whole city, because shadows are cast by buildings, not by coordinates. OpenStreetMap https://www.openstreetmap.org/ , then IGN BD TOPO https://geoservices.ign.fr/bdtopo - A shadow engine to calculate and display the shadows cast by buildings on the sidewalks. ShadeMap https://shademap.app/ - A weather forecast with hour-by-hour prediction of sun and cloud cover percentage. Open-Meteo https://open-meteo.com/ - 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 https://claude.ai/code --- 2. Data foundations Terraces from Paris Open Data The City of Paris publishes the full terrace permits dataset https://opendata.paris.fr/ : 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 /images/blog/sunny-header.webp 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 https://shademap.app/ 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 /images/blog/sunny-timeline.webp 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 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 . --- 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 /images/blog/sunny-roof.webp 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: 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 /images/blog/sunny-misaligned.webp 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: 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. 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 /images/blog/sunny-full.webp --- 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 https://www.sunnycoffee.live 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 https://aka.me/blog