Guide

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.

    Loading the map…
    The code below with twelve bakeries that do not exist. “Near me” asks for your position; it stays in your browser.

    Three steps

    1. Create a free account and, in the dashboard, create a key of the type “Maps only”, with your website as allowed referer.
    2. Save your spreadsheet as stores.csv next to the page, with the columns name, address, phone, lat and lon.
    3. 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) to show().

    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.

    PlanMap tiles a monthVisits at 28 tilesFor a business website
    Every account, free100'000about 3'500✗ No, for non-commercial websites only
    Maps Small, CHF 9 a month2 millionabout 71'000✓ Yes
    Maps Medium, CHF 29 a month10 millionabout 350'000✓ Yes

    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.