A store locator map for your website
All your shops, branches or dealers on one map, with a list beside it, a button that sorts the list by distance from the visitor, and markers that group into numbers while the map shows the whole country. The shops come from a spreadsheet, so a new branch is a new line in it.
Three steps
- Create a free account and, in the dashboard, create a key of the type “Maps only”, with your website as allowed referer.
- Save your spreadsheet as
stores.csvnext to the page, with the columnsname,address,phone,latandlon. - Copy the code below, put in your key, and paste it into the page.
The spreadsheet
One line per shop, and the column names in the first line. Further columns do no harm.
name,address,phone,lat,lon Muster Bakery Zürich HB,"Bahnhofplatz 15, 8001 Zürich",+41 44 000 00 01,47.3779,8.5403 Muster Bakery Bern,"Marktgasse 30, 3011 Bern",+41 31 000 00 06,46.9481,7.4446
In Excel, choose File › Save As › “CSV UTF-8”. In Numbers, File › Export To › CSV. In Google Sheets, File › Download › Comma-separated values. Excel in German, French or Italian saves with semicolons and decimal commas; the code reads both. How to find lat and lon of a shop: the position of your business.
The code
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.css">
<style>
.locator { display: grid; grid-template-columns: 300px 1fr; height: 520px; border: 1px solid #ddd; border-radius: 8px; overflow: hidden; font: 15px/1.4 system-ui, sans-serif; }
.locator .list { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid #ddd; }
.locator button { margin: 10px 12px; padding: 8px; font: inherit; cursor: pointer; }
.locator ol { flex: 1; margin: 0; padding: 0; list-style: none; overflow-y: auto; }
.locator li { padding: 10px 12px; border-top: 1px solid #eee; cursor: pointer; }
.locator li:hover, .locator li.on { background: #f3f6fa; }
.locator small { color: #666; }
@media (max-width: 700px) {
.locator { grid-template-columns: 1fr; grid-template-rows: 320px 1fr; height: 640px; }
.locator .list { order: 2; border-right: 0; }
}
</style>
<div class="locator">
<div class="list">
<button id="near" type="button">Show the shops near me</button>
<ol id="shops"></ol>
</div>
<div id="map"></div>
</div>
<script type="module">
import * as maplibregl from "https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.mjs";
const map = new maplibregl.Map({
container: "map",
style: "https://api.overpass.ch/v1/YOUR_KEY/maps/styles/light.json?lang=en",
center: [8.2, 46.8],
zoom: 6.5,
cooperativeGestures: true
});
map.addControl(new maplibregl.NavigationControl({ showCompass: false }));
const loaded = map.once("load"); // a promise: the style may load before the CSV
// The shops come from a spreadsheet saved as CSV (Excel, Numbers, Google
// Sheets), with the columns name, address, phone, lat, lon.
const shops = parseCSV(await (await fetch("stores.csv")).text())
.map((s, i) => ({ ...s, id: i, lat: number(s.lat), lon: number(s.lon) }))
.filter((s) => Number.isFinite(s.lat) && Number.isFinite(s.lon));
const popup = new maplibregl.Popup({ offset: 12 });
// one shop: on the map, in a popup, and marked in the list
function show(s) {
map.flyTo({ center: [s.lon, s.lat], zoom: Math.max(map.getZoom(), 14) });
const box = document.createElement("div");
box.append(text("strong", s.name), text("div", s.address), text("div", s.phone));
popup.setLngLat([s.lon, s.lat]).setDOMContent(box).addTo(map);
for (const li of document.querySelectorAll("#shops li")) {
li.classList.toggle("on", +li.dataset.id === s.id);
}
}
function list(items) {
document.getElementById("shops").replaceChildren(...items.map((s) => {
const li = document.createElement("li");
li.dataset.id = s.id;
const where = s.km == null ? s.address : `${s.address} · ${s.km.toFixed(1)} km`;
li.append(text("strong", s.name), text("br", ""), text("small", where));
li.addEventListener("click", () => show(s));
return li;
}));
}
list(shops);
await loaded;
{
// nearby shops form a circle with their number until you zoom in
map.addSource("shops", {
type: "geojson",
cluster: true,
clusterRadius: 40,
clusterMaxZoom: 11,
data: {
type: "FeatureCollection",
features: shops.map((s) => ({
type: "Feature",
properties: { id: s.id },
geometry: { type: "Point", coordinates: [s.lon, s.lat] }
}))
}
});
map.addLayer({
id: "clusters", type: "circle", source: "shops", filter: ["has", "point_count"],
paint: { "circle-color": "#c0392b", "circle-radius": 18, "circle-stroke-width": 2, "circle-stroke-color": "#fff" }
});
map.addLayer({
id: "cluster-count", type: "symbol", source: "shops", filter: ["has", "point_count"],
layout: { "text-field": ["get", "point_count_abbreviated"], "text-font": ["Noto Sans Medium"], "text-size": 14 },
paint: { "text-color": "#fff" }
});
map.addLayer({
id: "shop", type: "circle", source: "shops", filter: ["!", ["has", "point_count"]],
paint: { "circle-color": "#c0392b", "circle-radius": 8, "circle-stroke-width": 2, "circle-stroke-color": "#fff" }
});
map.on("click", "clusters", async (e) => {
const c = e.features[0];
const zoom = await map.getSource("shops").getClusterExpansionZoom(c.properties.cluster_id);
map.easeTo({ center: c.geometry.coordinates, zoom });
});
map.on("click", "shop", (e) => show(shops[e.features[0].properties.id]));
for (const layer of ["clusters", "shop"]) {
map.on("mouseenter", layer, () => (map.getCanvas().style.cursor = "pointer"));
map.on("mouseleave", layer, () => (map.getCanvas().style.cursor = ""));
}
const all = new maplibregl.LngLatBounds();
shops.forEach((s) => all.extend([s.lon, s.lat]));
map.fitBounds(all, { padding: 40, duration: 0 });
}
// "near me": the browser asks the visitor first
const near = document.getElementById("near");
near.addEventListener("click", () => navigator.geolocation.getCurrentPosition((pos) => {
const { latitude, longitude } = pos.coords;
for (const s of shops) s.km = km(latitude, longitude, s.lat, s.lon);
const sorted = [...shops].sort((a, b) => a.km - b.km);
list(sorted);
show(sorted[0]);
}, () => (near.textContent = "Your position is not available")));
// --- helpers
function text(tag, value) {
const el = document.createElement(tag);
el.textContent = value; // text, never HTML: a cell of the spreadsheet cannot run as code
return el;
}
// "47.37" and "47,37" (spreadsheets in German, French or Italian)
function number(v) {
return parseFloat(String(v).replace(",", "."));
}
// distance in km between two points (haversine)
function km(lat1, lon1, lat2, lon2) {
const r = Math.PI / 180;
const a = Math.sin((lat2 - lat1) * r / 2) ** 2 +
Math.cos(lat1 * r) * Math.cos(lat2 * r) * Math.sin((lon2 - lon1) * r / 2) ** 2;
return 12742 * Math.asin(Math.sqrt(a));
}
// CSV with a header line, comma or semicolon between fields, "quoted" fields
function parseCSV(csv) {
const sep = csv.split("\n", 1)[0].includes(";") ? ";" : ",";
const rows = [[]];
let field = "", quoted = false;
for (let i = 0; i < csv.length; i++) {
const c = csv[i];
if (quoted && c === '"' && csv[i + 1] === '"') { field += '"'; i++; }
else if (c === '"') quoted = !quoted;
else if (!quoted && c === sep) { rows.at(-1).push(field); field = ""; }
else if (!quoted && c === "\n") { rows.at(-1).push(field.replace(/\r$/, "")); field = ""; rows.push([]); }
else field += c;
}
rows.at(-1).push(field);
const [head, ...body] = rows.filter((r) => r.some((f) => f.trim() !== ""));
const keys = head.map((h) => h.trim().toLowerCase());
return body.map((r) => Object.fromEntries(keys.map((k, i) => [k, (r[i] ?? "").trim()])));
}
</script>
How it works
- Shops less than 40 pixels apart show as one circle with their number, up to zoom 11. A click on the circle zooms in until they separate.
- A click on a shop, in the list or on the map, flies there and opens its popup.
- “Near me” asks the visitor for the position, sorts the list by distance and opens the nearest shop. Browsers allow this only on pages served over HTTPS. The distances are straight lines.
- The cells of the spreadsheet go into the page as text, never as HTML, so a cell cannot run as code.
- For more information per shop, such as opening hours or a link, add a column to the spreadsheet and a
text("div", s.hours)toshow().
What it costs
The first view of Switzerland with twelve shops loaded 9 map tiles; a visitor who then opens one shop loads about 28 in all, and “near me” on a phone 14. A browser keeps the tiles for a day and then checks them again without cost. We measured with MapLibre GL JS 6.13.0 in windows of 1280 × 720 and 390 × 800 pixels.
| Plan | Map tiles a month | Visits at 28 tiles |
|---|---|---|
| Every account, free | 100'000 | about 3'500 |
| Maps Small, CHF 9 a month | 2 million | about 71'000 |
| Maps Medium, CHF 29 a month | 10 million | about 350'000 |
Prices plus VAT. All plans: maps plans.
Good to know
- A few thousand shops work well in the browser. For more, load the shops of the visible area from your server.
- The © OpenStreetMap credit must stay on the map. The (i) button in the corner opens it.
- The map sets no cookies: neither MapLibre nor our map server uses any.