Anleitung

Ein Filialfinder für Ihre Website

Alle Ihre Läden, Filialen oder Händler auf einer Karte, mit einer Liste daneben, einer Schaltfläche, die die Liste nach Entfernung von der Besucherin oder dem Besucher sortiert, und Markern, die sich zu Zahlen gruppieren, solange die Karte das ganze Land zeigt. Die Läden kommen aus einer Tabelle; eine neue Filiale ist also eine neue Zeile darin.

    Die Karte wird geladen…
    Der Code unten mit zwölf Bäckereien, die es nicht gibt. «In meiner Nähe» fragt nach Ihrem Standort; er bleibt in Ihrem Browser.

    Drei Schritte

    1. Erstellen Sie ein kostenloses Konto, und erstellen Sie im Dashboard einen Schlüssel vom Typ «Nur Karten», mit Ihrer Website als erlaubtem Referer.
    2. Speichern Sie Ihre Tabelle als stores.csv neben der Seite, mit den Spalten name, address, phone, lat und lon.
    3. Kopieren Sie den Code unten, setzen Sie Ihren Schlüssel ein, und fügen Sie ihn in die Seite ein.

    Die Tabelle

    Eine Zeile pro Laden, und die Spaltennamen in der ersten Zeile. Weitere Spalten schaden nicht.

    name,address,phone,lat,lon
    Bäckerei Muster Zürich HB,"Bahnhofplatz 15, 8001 Zürich",+41 44 000 00 01,47.3779,8.5403
    Bäckerei Muster Bern,"Marktgasse 30, 3011 Bern",+41 31 000 00 06,46.9481,7.4446

    In Excel wählen Sie Datei › Speichern unter › «CSV UTF-8». In Numbers Ablage › Exportieren › CSV. In Google Sheets Datei › Herunterladen › Kommagetrennte Werte. Excel auf Deutsch, Französisch oder Italienisch speichert mit Semikolons und Dezimalkommas; der Code liest beides. So finden Sie lat und lon eines Ladens: die Position Ihres Geschäfts.

    Der 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">Läden in meiner Nähe zeigen</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/IHR_SCHLÜSSEL/maps/styles/light.json?lang=de",
      center: [8.2, 46.8],
      zoom: 6.5,
      cooperativeGestures: true
    });
    map.addControl(new maplibregl.NavigationControl({ showCompass: false }));
    const loaded = map.once("load"); // ein Promise: Der Stil kann vor der CSV-Datei geladen sein
    
    // Die Läden kommen aus einer als CSV gespeicherten Tabelle (Excel, Numbers,
    // Google Sheets), mit den Spalten 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 });
    
    // ein Laden: auf der Karte, in einem Popup und in der Liste markiert
    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;
    {
      // nahe Läden bilden einen Kreis mit ihrer Anzahl, bis Sie hineinzoomen
      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 });
    }
    
    // "In meiner Nähe": Der Browser fragt zuerst nach
    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 = "Ihr Standort ist nicht verfügbar")));
    
    // --- Hilfsfunktionen
    
    function text(tag, value) {
      const el = document.createElement(tag);
      el.textContent = value; // Text, nie HTML: Eine Zelle der Tabelle kann nicht als Code laufen
      return el;
    }
    
    // "47.37" und "47,37" (Tabellen auf Deutsch, Französisch oder Italienisch)
    function number(v) {
      return parseFloat(String(v).replace(",", "."));
    }
    
    // Entfernung in km zwischen zwei Punkten (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 mit Kopfzeile, Komma oder Semikolon zwischen den Feldern, Felder in "Anführungszeichen"
    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>

    So funktioniert es

    • Läden, die weniger als 40 Pixel auseinander liegen, erscheinen bis Zoomstufe 11 als ein Kreis mit ihrer Anzahl. Ein Klick auf den Kreis zoomt hinein, bis sie sich trennen.
    • Ein Klick auf einen Laden, in der Liste oder auf der Karte, fliegt dorthin und öffnet sein Popup.
    • «In meiner Nähe» fragt nach dem Standort, sortiert die Liste nach Entfernung und öffnet den nächsten Laden. Browser erlauben das nur auf Seiten, die über HTTPS kommen. Die Entfernungen sind Luftlinien.
    • Die Zellen der Tabelle kommen als Text in die Seite, nie als HTML; eine Zelle kann also nicht als Code laufen.
    • Für mehr Angaben pro Laden, etwa Öffnungszeiten oder einen Link, ergänzen Sie eine Spalte in der Tabelle und ein text("div", s.hours) in show().

    Was es kostet

    Die erste Ansicht der Schweiz mit zwölf Läden lud 9 Kartenkacheln; wer danach einen Laden öffnet, lädt insgesamt etwa 28, und «In meiner Nähe» auf dem Handy 14. Ein Browser behält die Kacheln einen Tag und prüft sie danach kostenlos. Wir haben mit MapLibre GL JS 6.13.0 in Fenstern von 1280 × 720 und 390 × 800 Pixeln gemessen.

    TarifKartenkacheln im MonatBesuche mit 28 KachelnFür eine Firmenwebsite
    Jedes Konto, kostenlos100'000etwa 3'500✗ Nein, nur für nicht kommerzielle Websites
    Maps Small, €9 im Monat2 Millionenetwa 71'000✓ Ja
    Maps Medium, €29 im Monat10 Millionenetwa 350'000✓ Ja

    Preise zuzüglich MWST. Alle Tarife: Maps-Tarife.

    Gut zu wissen

    • Einige tausend Läden funktionieren im Browser gut. Für mehr laden Sie die Läden des sichtbaren Ausschnitts von Ihrem Server.
    • Die Quellenangabe © OpenStreetMap muss auf der Karte bleiben. Die Schaltfläche (i) in der Ecke öffnet sie.
    • Die Karte setzt keine Cookies: Weder MapLibre noch unser Kartenserver verwenden welche.