πΊοΈ Maps, charts and dashboards
Put data on a map or a chart without a framework and without getting rate-limited: tiles, fetch storms, geocoding and charts that don't mislead.
You'll leave with
- A working map of your data that never shows a grey box
- A map that pans without fetch storms or tile bans
- Charts drawn as plain SVG with checked colors and one unit each
A map is the fastest way to make public data feel real, and the fastest way to get your visitors blocked by someone else's server. This lesson puts your data on a map and into small charts with no framework: one free map library, your data file and plain SVG. No map in your project? Read step 1, then jump to step 6.
Your first map in ten minutes
You need rows with a latitude and longitude in data.json (none yet? Claude can write one from your DATA block). Only addresses or ZIP codes? Do step 4 first.
What are latitude and longitude?
Two numbers that pin a spot: degrees north of the equator (Phoenix: about 33.45) and degrees east or west of London (Phoenix: about -112.07; west is negative).
Leaflet is a small, free map library, and the one to start with. MapLibre GL JS draws with your graphics chip (WebGL) and stays smoother with many thousands of shapes.
What's a map tile?
The map is a grid of square pictures, usually 256 pixels each, from a tile provider's server. The library fetches only the ones on screen, at the current zoom. In a tile address, {z}, {x} and {y} stand for zoom, column and row.
EXAMPLE β YOU ASK CLAUDE CODE
What's happening underneath, in VS Code's terminal (Git Bash or Mac; in PowerShell, type curl.exe):
mkdir vendor
curl -o vendor/leaflet.js https://unpkg.com/leaflet@1.9.4/dist/leaflet.js
curl -o vendor/leaflet.css https://unpkg.com/leaflet@1.9.4/dist/leaflet.css
Then index.html (replace lat, lon and name with your field names):
<link rel="stylesheet" href="vendor/leaflet.css">
<style>#map { height: 70vh; }</style>
<div id="map"></div>
<script src="vendor/leaflet.js"></script>
<script>
const map = L.map('map', { center: [33.45, -112.07], zoom: 11 }); // a starting view, always
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
const asText = s => Object.assign(document.createElement('span'), { textContent: s });
fetch('data.json').then(r => r.json()).then(draw);
function draw(rows) {
const dots = rows.map(r =>
L.circleMarker([r.lat, r.lon], { radius: 6 }).bindPopup(() => asText(r.name)));
const group = L.featureGroup(dots).addTo(map);
if (dots.length) map.fitBounds(group.getBounds(), { maxZoom: 13 });
}
</script>
Three lines carry the weight: the height (without it, Leaflet draws into a box 0 pixels tall), the center and zoom at creation, and fitBounds, which zooms to your dots but never closer than 13. textContent keeps a < or & in your data from breaking the page.
π A radius map on a private build of my price tool shipped as a grey box: 0 tiles, 0 markers. Created without a center or zoom, the map never fully started, its layers waited forever, and the first call needing map coordinates crashed. Every test passed, because every test checked server responses.
Check it yourself in Chrome or Edge on a computer (Safari hides these tools by default; phones can't). Run npx serve and open http://localhost:3000: streets, with your dots on top. Press Ctrl+Shift+J (Mac: Cmd+Option+J) to open DevTools on its Console tab, and paste this line, which only counts things on your own page (if it asks you to type allow pasting first, do):
[document.querySelectorAll('.leaflet-tile-loaded').length, document.querySelectorAll('.leaflet-interactive').length]
Two numbers: tiles that arrived (above 0) and dots drawn (one per row). Both 0, or a grey box with a red Cannot read properties of undefined (reading 'layerPointToLatLng'), most likely means no center and zoom.
OpenStreetMap's standard tiles (https://tile.openstreetmap.org/{z}/{x}/{y}.png) need no key, but they require the Β© OpenStreetMap contributors credit and allow light use only. Switch to a keyed provider's free tier before launch. A key used in the browser is public by design, unlike a server password, so restrict it to your domain in the provider's dashboard.
π On one of my side projects, the dark map provider changed its rules and stamped API KEY REQUIRED across every tile. Someone looking at a screenshot caught it. The fix: a domain-restricted key, plus a keyless fallback written down, Esri's dark-gray map.
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles © Esri, HERE, Garmin, © OpenStreetMap contributors'
}).addTo(map);
β οΈ Copy each tile address exactly: most end in {z}/{x}/{y}, Esri's in {z}/{y}/{x}, and a swap puts every tile in the wrong place. Read a fallback's terms, too.
Ship data as small static files
Ticket Heat, Who Owns Phoenix and The List have no database and no server code of mine: the browser downloads prepared files and filters them itself, instantly. The work happens beforehand, in your build.mjs (the script from Build static-first that turns your download into data.json; the big version is Big public data on a small server).
Ticket Heat packs about 583k records, roughly 9 MB of plain JSON, into about 1 MB on the wire:
- Pre-summarize in your build.mjs: only the fields a filter uses. Ticket Heat snaps each record to a grid cell about 450 m across.
- Columns, not rows: one array per field, so field names aren't repeated half a million times.
- Sorted so repeats sit together (by category, then place): compression feeds on repeats.
[{"cat":"speed","lat":33.4512,"lon":-112.0731},{"cat":"speed","lat":33.4511,"lon":-112.0702}]
{"cat":["speed","speed"],"lat":[33.4512,33.4511],"lon":[-112.0731,-112.0702]}
What's gzip?
Standard compression for the trip: the server squeezes each file, the browser unpacks it. On your server, the front door does it with one line (encode, in Deploy behind one front door).
EXAMPLE β YOU ASK CLAUDE CODE
What's happening underneath: Node's built-in zlib measures the gzipped size; four decimals is about 11 meters. These lines replace step 1's fetch line:
const toRows = c => c[Object.keys(c)[0]].map((_, i) =>
Object.fromEntries(Object.keys(c).map(k => [k, c[k][i]])));
fetch('data.json').then(r => r.json()).then(c => draw(toRows(c)));
If phones get slow, the printed size shows the file grew: summarize harder, or split by area.
Splitting is how Who Owns Phoenix works. The pro tool for hundreds of thousands of shapes is vector tiles (map data cut into tiles of shapes, like PMTiles), whose build tools I didn't have. So it shipped the lazy way and has worked fine since: one small summary file shading every ZIP code, plus one GeoJSON file per ZIP, fetched once when you zoom in. A Set (a list that ignores duplicates) remembers which ZIPs are loaded; without it, a ZIP downloads on every pan.
What's GeoJSON?
JSON for map shapes: each feature is a point, line or outline (longitude first) plus your fields. Leaflet and MapLibre read it directly.
Ask Claude: "Have build.mjs write one GeoJSON file per ZIP plus a summary file. When the map stops at zoom 13 or closer, fetch each visible ZIP's file once, using a Set." Check in DevTools' Network tab (one row per downloaded file): panning back over a loaded ZIP adds no row.
π¦₯ Skipped: a framework, a database, an API (a URL that returns data) and a tile pipeline. Add vector tiles when per-area files get too many, and a database when people write data, not only read it.
Tiles and fetch storms
Every tile is a request (one ask for one file) to someone else's server, and free providers cap how many each visitor may make.
What's a rate limit?
A cap on requests per visitor per minute, counted by IP address (a device's number on the internet). Go over it and requests are slowed, refused or dropped: blank or blocky squares.
π Habooby's radar once showed "only squares". Its animation kept 13 frames attached to the map, hidden at zero opacity (fully see-through), and every attached layer downloads tiles on every pan and zoom. One zoom-out fired 312 tile requests and hit the provider's limit (500 a minute, bursts of 300; extras queued, then dropped). Now only the visible frame and the one fading in are attached: 20 to 28 requests.
- Attach only what's visible, plus the layer fading in.
- 512-pixel tiles where offered (
tileSize: 512, zoomOffset: -1): each covers four 256-pixel ones. - The provider's real maximum zoom in
maxNativeZoom(plus one whenzoomOffsetis -1), so Leaflet enlarges the last real level instead of fetching "Zoom Level Not Supported" tiles. - Cross-fade two layers: add the next frame, wait for its
loadevent, fade it in with CSS, remove the old one 300 ms later. Swapping one layer's address (setUrl) made Habooby's radar flash blank between frames.
If your map also fetches data for the area on screen, as Habooby does, every drag can start a download. Habooby's panning once stacked up overlapping 1 MB downloads until the browser failed with ERR_NETWORK_CHANGED, killing the radar and the location report too. Four guards stop these fetch storms: wait until the moving stops (debouncing), cancel (abort) the old request, drop out-of-date replies, and skip refetching while the view is inside data you have.
EXAMPLE β YOU ASK CLAUDE CODE
What's happening underneath, one numbered comment per guard:
let timer, ctrl, seq = 0, loadedBox = null, loadedZoom = 0;
map.on('moveend', () => {
clearTimeout(timer);
timer = setTimeout(loadView, 500); // 1. wait until the moving stops
});
function needReload() { // 4. still inside what we have? skip
return !loadedBox || !loadedBox.contains(map.getBounds())
|| Math.abs(map.getZoom() - loadedZoom) >= 2;
}
async function loadView() {
if (!needReload()) return;
const box = map.getBounds().pad(0.35); // 35% extra on every side
const my = ++seq; // 3. number this request
ctrl?.abort(); // 2. cancel the one still running
ctrl = new AbortController();
try {
const res = await fetch(urlFor(box), { signal: ctrl.signal });
const data = await res.json();
if (my !== seq) return; // 3. a newer request exists: drop this
loadedBox = box; loadedZoom = map.getZoom();
drawLayer(data);
} catch (e) {
if (e.name !== 'AbortError') console.error(e);
}
}
loadView();
urlFor(box) and drawLayer(data) are yours: where that area's data comes from, and how you draw it. Without guard 3, a slow early request that finishes last flashes old data over new.
And read a plugin's fine print. Habooby's wind plugin (an add-on for Leaflet) accepted a bigger area through setData but kept animating inside the old box. Now the layer is rebuilt when the area changes, setData only handles a new hour, and while a bigger area loads, the wind fades out instead of ending in a hard rectangle.
Geocoding on a budget
Only addresses or ZIP codes in your rows? A map needs coordinates. (Already have them? Skim this for the day someone types an address.)
What are geocoding and a cache?
Geocoding turns an address, ZIP code or place name into coordinates; reverse geocoding goes back. A cache is a saved copy of every answer, so you never ask twice.
| You have | Free tool, no key | Rules |
|---|---|---|
| ZIP codes | zippopotam.us: https://api.zippopotam.us/us/85001 | One rough point per ZIP; cache it |
| Addresses a visitor types | Nominatim, OpenStreetMap's geocoder | On Search only, about one a second, cached |
| Thousands of addresses or intersections in your file | The U.S. Census batch geocoder | In build.mjs, 1,000 rows per batch |
Geocode once, in build.mjs: Node can send batches straight to the Census batch geocoder with no proxy. The proxy route is only for lookups typed into the browser. Ticket Heat's pipeline placed about 79% of 355k addresses this way, for $0, in 1,000-row batches, clipped to the Phoenix metro.
Try it by hand. In VS Code, make batch.csv with these two lines (id, street, city, state, ZIP; the second is an intersection):
1,200 W Washington St,Phoenix,AZ,85003
2,N 7th St & E Roosevelt St,Phoenix,AZ,
Then, in the terminal in that folder (in PowerShell, type curl.exe):
curl --form addressFile=@batch.csv --form benchmark=Public_AR_Current https://geocoding.geo.census.gov/geocoder/locations/addressbatch
Both matched, intersection included. The coordinates come longitude first; Leaflet wants latitude first.
EXAMPLE β YOU ASK CLAUDE CODE
What's happening underneath: Node has fetch built in, so build.mjs uploads each batch like the curl above. A match outside the box is usually a same-named street in another city. Expect unmatched rows (about 1 in 5 for Ticket Heat), and say so on your about page.
Lookups people type are different. Nominatim answers browsers directly: send one request per press of Search, never as they type (from Node, add a User-Agent header naming your app):
https://nominatim.openstreetmap.org/search?q=QUERY&format=json&limit=1&countrycodes=us
The Census sends no CORS permission (a browser rule: a page may read another site's answer only if that site allows it), so Who Owns Phoenix's search uses Nominatim. Ticket Heat needs intersections like "A & B", which Nominatim fumbles, so it asks the Census through a proxy (a route on your own server that fetches the answer for the browser: Add a backend only when forced).
To pin one building, search its name, not a rough point. My price tool's distance sort started with city-centre coordinates and a free ZIP lookup, plenty for a first version. Later, on a private build of that tool, name search found the right hospital 6 times out of 6, where reverse-geocoding a rough point had moved two facilities to the wrong address. It still once returned a system's psychiatric hospital instead of its main one, so check what kind of place came back.
When points pile up
On an unlisted public-health map of mine, incidents only had state-level locations, so each state's dots sat on one spot. Clustering (merging nearby dots into one bubble with a count) hid what people came to see; removing it stacked a state onto one dot. Now dots sharing a spot fan out in rings measured in screen pixels and recomputed after every zoom, so a group looks the same size at any zoom. Offsets in degrees would smear dots across neighbouring states.
EXAMPLE β YOU ASK CLAUDE CODE
What's happening underneath: Leaflet converts each coordinate to a screen point (latLngToLayerPoint), adds the offset in pixels, and converts back (layerPointToLatLng). Check the AI's work covers self-checks.
A map alone doesn't help anyone choose. Pair it with a ranked list (hovering a card highlights its dot; clicking a dot scrolls to its card) and make the order a visible choice. On a private build of my price tool, one facility missed the top 10 by price but ranked second by complication rate. That's a policy call, so the page offers cheapest, closest and best outcomes, shows both numbers on every row, and lets a person choose.
Put the whole view in the URL hash (the part after #): layer, filters, selection, center and zoom. Ticket Heat's links carry category, year and hour too, so a shared link opens what the sender saw, and a test script can jump straight to a state: Habooby's #locate= links are how its location features get tested in a headless browser.
EXAMPLE β YOU ASK CLAUDE CODE
function saveView() { // runs on moveend; call it after filter changes too
const c = map.getCenter();
const p = new URLSearchParams({ cat, year, hour, z: map.getZoom(), lat: c.lat.toFixed(4), lon: c.lng.toFixed(4) });
history.replaceState(null, '', '#' + p); // no Back-button step per pan
}
map.on('moveend', saveView);
const h = new URLSearchParams(location.hash.slice(1)); // on load, before any fetch
if (h.has('cat')) cat = h.get('cat'); // one line like this per filter
if (h.has('lat')) map.setView([+h.get('lat'), +h.get('lon')], +h.get('z'));
Use your filters' names for cat, year and hour. Copy link is navigator.clipboard.writeText(location.href).
On phones, one design pass gave Habooby four fixes every map needs:
- Safe areas:
viewport-fit=coverin the viewport tag (the line in your page's head that tells phones how to size it), then pad fixed controls byenv(safe-area-inset-bottom)and friends, the space a notch or home bar takes. - Pinch zoom stays on: never
maximum-scale=1. - Controls in a bottom sheet, a panel resting as a strip at the bottom that slides up when tapped.
- Attribution visible above the sheet: providers require the credit.
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
For distances: a degree north is about 69 miles everywhere, but a degree east shrinks with the cosine of the latitude, to about 58 miles at Phoenix. Use one reference latitude, your area's middle, for the whole shape:
const k = Math.cos(midLat * Math.PI / 180); // once, for the whole shape
const x = (lon - lon0) * k * 69, y = (lat - lat0) * 69; // miles from a starting point
On one of my side projects, a cosine per point made a river that runs due north drift east and stretched a 24.5-mile section of it to 32 miles. With one reference latitude it measures 24.5, against the source's stated 24.89. Check one length against a published one.
Charts without a chart library
Most data apps need a few small charts, not a charting library.
What's SVG?
A drawing language the browser speaks: shapes written as tags, like <rect> for a bar and <polyline> for a line, sharp at any size. The viewBox sets the drawing's own coordinates.
A sparkline (a tiny line chart with no axes) is one polyline; bars are a few rects:
function sparkline(v) { // numbers in, an SVG string out
const lo = Math.min(...v), span = Math.max(...v) - lo || 1;
const pts = v.map((y, i) => `${(i * 100 / (v.length - 1 || 1)).toFixed(1)},${(30 - (y - lo) / span * 30).toFixed(1)}`);
return `<svg viewBox="0 0 100 30" width="100" height="30"><polyline fill="none" stroke="currentColor" points="${pts.join(' ')}"/></svg>`;
}
function hourBars(counts) { // 24 numbers in, 24 bars out
const max = Math.max(...counts, 1);
return `<svg viewBox="0 0 240 100" width="240" height="100" fill="currentColor">` + counts.map((n, h) =>
`<rect x="${h * 10}" y="${100 - n / max * 100}" width="8" height="${n / max * 100}" data-hour="${h}"/>`).join('') + `</svg>`;
}
Insert one with element.innerHTML = hourBars(counts), safe here because the string holds only numbers you computed.
Ticket Heat's 24-bar hour chart, worst-blocks leaderboard and one-line insight ("peaks around 8 PM, busiest Saturday") all come from the same filtered records and recompute on every filter change; click a bar and the map jumps to that hour. They were added because people found the civil-versus-criminal split boring: they share rankings and findings. Derive every insight from the filtered records, or the sentence contradicts the map beside it.
When values span tens to tens of thousands (river flows, prices, visits), a normal scale flattens the small ones. Use a log scale, where each step up is ten times the last (h is the chart's height):
const y = v => h - (Math.log10(v) - Math.log10(min)) / (Math.log10(max) - Math.log10(min)) * h;
Log scales can't show zero, so clamp to a floor and say so. Then shade a typical band: the middle half of the past years' values for each day, between the 25th and 75th percentiles (a quarter and three quarters of the history fall below them), so anyone sees whether now is normal.
EXAMPLE β YOU ASK CLAUDE CODE
My river side project draws exactly that, with the ideal-flow range as a band behind. It also highlights only what's rare and happening now: a river section gets a "!", 1.3Γ size and a glow ring only when it's boatable under 5% of the year and is boatable today. That new metric exposed about ten sections wired to the wrong river, so check the top and bottom 10 of any new number.
One chart, one unit. In a Sankey (a flow diagram whose band widths are amounts), width must always mean one thing. The List's citywide view draws two: one in properties, one in dollars, each with its unit printed. One mixed diagram would have made a few expensive buildings look like a crowd. Make the unit a required argument of your chart function; The List's hand-written one is about 90 lines.
Different questions, different controls. "How long is the trip?" and "how far away is it?" are two labeled filters on my river side project. Duration is a simple formula, miles over a speed for the difficulty (about 4.5 mph on easy water, 1.5 on the hardest) plus 30 minutes of rigging, with hand-set overrides at the extremes: no formula knows that a 226-mile canyon trip takes 14 to 21 days.
Colors and the eye test
Check chart colors with a tool, not your eyes. The List's colors looked fine on my screen and failed: about 2.0 to 2.6:1 contrast on white, and the wrong lightness in dark mode. Its five borough colors are now a checked set, light and dark, in a fixed order: the color-blind check compares neighbours, so re-sorting the categories breaks it.
What's a contrast ratio?
How much lighter one color is than another, from 1:1 (identical) to 21:1 (black on white). The accessibility standard, WCAG, asks for 3:1 for chart marks and 4.5:1 for normal text.
EXAMPLE β YOU ASK CLAUDE CODE
What's happening underneath: contrast is a published formula, so the script can be exact; the color-blind half simulates red-green color blindness and measures how far apart neighbours stay.
Color should never be the only signal. Red-green color blindness affects roughly 1 in 12 men, and red-to-green is the usual "bad to good" scale. My river side project's color-blind mode (a toggle, saved in the browser) switches to deep blue (too low), bright yellow (ideal) and dark brown (too high), and every status has its own glyph (a small symbol: β β² β Β· β), in the legend too.
Then look at the chart at phone width, as in Make it look right everywhere: with DevTools open, press Ctrl+Shift+M (Mac: Cmd+Shift+M) for the device toolbar and type 390 in its width box. Looking caught The List's bugs:
- A diagram drawn 900 px wide shrank on phones until labels were 5 px tall. Now it scrolls sideways in its own box (
overflow-x: autoplus amin-width). - A formatter (the code that turns 0.79 into a label) written for changes put "+" on shares: "+79% of the gross". Shares got their own.
- Labels on colored bands got a halo in the background color (
paint-order: stroke fill).
When a look is a judgment call, render the options side by side and let eyes pick. Habooby's dust overlay started as the chunky heat-map grid I'd asked for; once it was on screen, the grid lines had to go. It became smooth, see-through color bands in the green-yellow-red-magenta palette everyone knows from weather radar.
Ask Claude: "Screenshot the map at 390 px wide three times, with the tile layer's CSS brightness at 0.9, 1.0 and 1.1, side by side." (On my river side project's dark tiles, above about 2.2 the terrain flattened out.)
Do it with Claude: the prompts
The three prompts behind this lesson, in order. Paste one, check it the way its step shows, then the next. Replace YOUR_CITY.
Add a Leaflet map centered on YOUR_CITY at zoom 11 with our rows as circle markers, and vendor leaflet.js.
Debounce map moves, abort stale fetches, and only refetch when the view leaves the loaded area.
Draw a log-scale SVG chart with a shaded 25th to 75th percentile band. No chart library.
What's happening underneath: tiles are pictures fetched by zoom and position, so every visible layer is a stream of requests someone else pays for. Push back when Claude's code has a map with no center or height, hidden layers left attached, offsets in degrees, a geocoder called on every keystroke, maximum-scale=1, or a chart library for a sparkline.
Your turn
- Plot twenty rows of your data on a Leaflet map created with a center and zoom. Done when step 1's Console line shows tiles above 0 and one dot per row.
- Make
node build.mjsprint the raw and gzipped size ofdata.json, and note both numbers. - With the Network tab on Fetch/XHR, drag your map for ten seconds. Done when each stop makes at most one data request and small pans make none. (Nothing fetched on pan? Click Img and check only the visible layer's tiles load.)
- Draw one SVG chart, no library, from the same filtered data as your map, with its unit printed on it.
- Run your color check until it passes in light and dark, then read every label at 390 px wide.
- Open a copied view link in a private window and land on the same view.
Next, Your own server: rent one small server, log in with a key, and let Claude manage your DNS, so this map gets a real address.