Chain store rivals map with MapLibre GL JS clusters
Every store of two rival chains, grouped into clusters that split apart as you zoom in. Each cluster takes the color of the chain with more stores in it, and turns purple where the two are even, so the line between their territories shows up on its own. Pick a pair, or look up your town to see who wins there.
Build it yourself
A Node.js script downloads two chains' stores from All The Places as one GeoJSON file, and a page draws them as clusters that add up each chain's stores. It's the map above with one pair and no search. It was tested with MapLibre GL JS 6.15 in Chromium and Node.js 22.
What you need
- Node.js 18 or later.
- A browser key with the Map tiles product, made as API keys shows. Add
http://localhost:*to its allowed origins, so it works from your computer while you build. Map tiles are free, and signing up takes a minute, with no credit card. All The Places needs no key.
Collect the stores
All The Places runs a scraper, which it calls a spider, for each chain's store finder every week, and publishes each spider's stores as a GeoJSON file. Make a folder for the map, and in it a file called stores.mjs:
import { writeFile } from "node:fs/promises";
const chains = ["dunkin_us", "starbucks_us"];
async function download(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
return response.json();
}
const { run_id } = await download("https://alltheplaces-data.openaddresses.io/runs/latest.json");
const features = [];
for (const [chain, spider] of chains.entries()) {
const stores = await download(`https://alltheplaces-data.openaddresses.io/runs/${run_id}/output/${spider}.geojson`);
for (const { geometry } of stores.features) {
if (geometry) features.push({ type: "Feature", properties: { chain }, geometry });
}
console.log(`${spider}: ${stores.features.length} stores`);
}
await writeFile("stores.geojson", JSON.stringify({ type: "FeatureCollection", features }));
latest.jsonnames the latest run, and each run keeps a file for every spider, so the script downloads only the two chains instead of the whole 2.7 GB run.- Each store keeps only its place and
chain, 0 for the first chain and 1 for the second, which is all the clusters need. - A few stores have no location, so they're left out.
Run it:
node stores.mjs
It prints how many stores each chain has, about 10,500 for Dunkin' and 13,800 for Starbucks, and writes stores.geojson, about 2.5 MB.
Make the page
Next to stores.geojson, make index.html, with your browser key in place of mapricot_pk_…:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dunkin' vs. Starbucks</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/dist/maplibre-gl.css">
<script type="importmap">
{
"imports": {
"maplibre-gl": "https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/+esm"
}
}
</script>
</head>
<body style="margin: 0">
<div id="map" style="height: 100vh"></div>
<script type="module">
import * as maplibregl from "maplibre-gl";
const colors = ["#e03131", "#1c7ed6"];
const map = new maplibregl.Map({
container: "map",
style: "https://api.mapricot.com/tiles/v1/styles/light.json?key=mapricot_pk_…",
bounds: [-125, 24, -66, 50],
});
await map.once("load");
map.addSource("stores", {
type: "geojson",
data: "stores.geojson",
cluster: true,
clusterRadius: 40,
clusterMaxZoom: 9,
clusterProperties: { first: ["+", ["case", ["==", ["get", "chain"], 0], 1, 0]] },
attribution: '<a href="https://alltheplaces.xyz/">All The Places</a>',
});
map.addLayer({
id: "clusters",
type: "circle",
source: "stores",
filter: ["has", "point_count"],
paint: {
"circle-color": [
"interpolate-lab", ["linear"], ["/", ["get", "first"], ["get", "point_count"]],
0, colors[1],
0.5, "#8c4fc4",
1, colors[0],
],
"circle-radius": ["interpolate", ["linear"], ["sqrt", ["get", "point_count"]], 1, 9, 10, 20, 40, 34],
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1.5,
},
});
map.addLayer({
id: "cluster-counts",
type: "symbol",
source: "stores",
filter: ["has", "point_count"],
layout: { "text-field": ["get", "point_count_abbreviated"], "text-font": ["Noto Sans Medium"], "text-size": 12 },
paint: { "text-color": "#ffffff" },
});
map.addLayer({
id: "stores",
type: "circle",
source: "stores",
filter: ["!", ["has", "point_count"]],
paint: {
"circle-color": ["match", ["get", "chain"], 0, colors[0], colors[1]],
"circle-radius": 5,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1.5,
},
});
map.on("click", "clusters", async ({ features: [cluster] }) => {
const zoom = await map.getSource("stores").getClusterExpansionZoom(cluster.properties.cluster_id);
map.easeTo({ center: cluster.geometry.coordinates, zoom });
});
</script>
</body>
</html>
clusterPropertiesadds up a property of its own on every cluster. Herefirstcounts the stores of the first chain, andpoint_countcounts all of them.- The cluster's color follows the first chain's share, from blue for all of the second chain, through purple for an even split, to red for all of the first.
interpolate-labmixes the colors the way eyes see them, so the shades in between change evenly. - The circle's area follows the number of stores, through the square root.
- Past zoom 9, every store shows by itself, in its chain's color. Clicking a cluster zooms in until it splits.
The clustering guide explains the source and its layers step by step.
Open it
Serve the folder with npx serve or python3 -m http.server, and open the address it prints. Opened straight from the file, the map stays blank, as Open the page explains. You should see red clusters in New England, where Dunkin' started, blue ones across the West, where Starbucks did, and purples in between, where the two are close.
Any two of All The Places' spiders work in chains, such as sheetz and wawa. Each one is only as complete as its chain's store finder, so check its count against the chain's own figure before you trust a map of it.
Data
- Stores: All The Places (CC0), from the chains' own store finders. Pret's are its UK stores only. Chain names belong to their owners, and the map isn't connected to any of them.
- Map: OpenStreetMap contributors (ODbL), in Mapricot's map tiles