Dan Matthew
Service

Operator test

System
Nimbus CMS
Skin
Attract mode (arcade)
Entries
on this screen
Menus
8 items
Path
/blog/droppin-google-timeline-travel-map-no-upload
Credits
Free play
Press ~ to toggle · Esc to close

Writing · 6 Sep 2026

Your Google Timeline is the most sensitive file you own, so I built a map that never uploads it

droppin turns a Google Timeline export into a travel map entirely inside the browser tab, with a Web Worker, an offline gazetteer and d3-geo doing the work.

#javascript #privacy #react #cloudflare

Google will hand you a JSON file of everywhere your phone has been for a decade. It is about the most sensitive file most people own, and every Timeline visualizer I found wanted me to upload it to a server I had never heard of. That seemed like a bad trade for a pretty map.

droppin is my answer. Drop location-history.json onto a page, get a world map and travel stats, and have the file never leave the tab. That one constraint decided the whole architecture, which is what makes it worth writing about.

location-history.json Google Timeline export YOUR BROWSER nothing leaves this box Drop zone React · file.text() no upload · no account · no analytics raw pings never leave the worker Web Worker @danmat/waypoints-core parseTimeline (both export shapes) scrub home / work / layovers NearestCityGeocoder (offline) aggregate → TravelData Render @danmat/waypoints-ui WorldMap d3-geo · Natural Earth StatTiles · FunFacts PlaceList ShareCard → PNG modern-screenshot static app (dist/), one way gazetteer, fetched after drop no upload Cloudflare Worker · droppinmap.com src/worker.ts: www→apex · /gh · /example · 404 - never reads a request body [assets] dist/ · /data/cities.json · /data/airports.json (GeoNames + OurAirports) Sibling: waypoints runs the same core on my laptop and publishes only city-level places.json + stats.json to R2.
⤢ Enlarge

The constraint decides everything

There is no upload endpoint because there is no backend to upload to. The Cloudflare Worker in front of droppinmap.com is about thirty lines. It redirects www to the apex, handles two vanity paths (/gh and /example), and hands everything else to the static-assets binding. It never sees a request body.

You can check this yourself. Open the network tab, drop a file, and watch. Two same-origin fetches, neither carrying your data. That absence is the design. Everything else in this post is about how to do useful work with a file you refuse to send anywhere.

Read, parse, aggregate in a Web Worker

The drop zone is a small React component. handleFile calls file.text() and posts the raw string to a module Web Worker (aggregate.worker.ts). A multi-year export is tens of megabytes, and parsing plus geocoding it on the main thread would freeze the page for long enough that people would assume it was broken.

Inside the worker, the real engine is @danmat/waypoints-core, a package I published separately so this app is mostly glue. parseTimeline accepts both shapes Google has shipped: the newer on-device export with semanticSegments and the older Takeout format with timelineObjects. It reduces each place visit to a Stay: lat, lng, start, end, and Google's own semanticType label. Movement segments are thrown away. I care where you were, not the path between.

aggregate then runs a fixed filter chain, and the numbers in it are the result of looking at my own data and deciding what counts as travel:

Drop stays under 30 minutes. Drop anything Google labelled Home or Work, including the inferred variants. Drop anything within 25 km of any home point it has ever seen, which handles moving house without a config file. Drop airport layovers, where a layover is a stay under four hours within 3 km of a large or medium airport. Both conditions have to hold, because a two-week trip that starts at JFK is not a layover.

Reverse geocoding without a geocoder

Every surviving stay is snapped to the nearest city by NearestCityGeocoder. It is a linear scan over an offline gazetteer, which sounds crude and is completely fine for thousands of cities against dozens of stays. Nobody needs a spatial index for a holiday.

The gazetteer is the source of those two fetches I mentioned. scripts/prep-data.mjs runs at build time, pulls GeoNames cities15000 and the OurAirports table, and writes compact JSON into public/data/. The worker fetches them lazily, only after you have dropped something. Your coordinates go into the lookup and a city centroid comes out, so nothing downstream ever carries a raw ping. The map cannot leak a precise location because it never holds one.

One deliberate wrinkle: within 25 km the geocoder prefers the highest-population city in the same region. So Tokyo wins over one of its wards, but a stop in New Jersey is not absorbed into New York. Small rule, and it fixed most of the "why does it say I was there" complaints I had with my own data.

The output is a TravelData object: places keyed by country/region/city with visits, nights, and month-precision dates, plus a stats block of totals. That is all the UI ever sees, and month precision is the point. A retrospective does not need to know which Tuesday.

Rendering: an SVG, not a globe

@danmat/waypoints-ui draws the map with d3-geo. WorldMap projects a bundled countries-110m TopoJSON through geoNaturalEarth1, plots one circle per city sized by the square root of visits, and connects places in first-visit order with great-circle arcs from geoInterpolate.

Being plain SVG, it rasterises cleanly. "Share my map" renders a fixed-size ShareCard through modern-screenshot and hands the PNG to navigator.share, or to a download link if the browser does not support that. Still no upload. The image is made on your machine and goes wherever you send it.

waypoints: the same engine, my data, published on purpose

waypoints is the sibling project and the reason the core is a package. It is my own travel log, built with the same engine, but the pipeline runs on my laptop instead of in your browser.

pnpm update:data aggregates the newest export in a gitignored private/ folder, applies an overrides.json for drive-through states that a 30-minute filter would otherwise erase, and uploads only places.json and stats.json to R2. A Hono Worker serves them at /api/places and /api/stats with a five-minute cache, falling back to sample data if the bucket is empty. The raw export goes to a separate private bucket with a short lifecycle rule and never touches git.

So the two apps are the same aggregate call with different trust boundaries. droppin keeps the raw file in your tab. waypoints keeps mine on my machine and publishes the sanitized result. Neither ever puts a raw coordinate on the wire, and I like that the guarantee comes from where the code runs rather than from a privacy policy.

Try it with your own export at droppinmap.com, and the source is at github.com/DanMat/droppin. If you find a stay the filter chain gets wrong, I want to hear about it. The layover rule in particular has opinions.