Notes in Openstreetmap via MapLibre Control und OAuth
Ich möchte es in einer MapLibre-Karte ermöglichen, dass Notes angelegt werden können.
In OpenStreetMap bietet eine Note eine Möglichkeit für Nutzer, Probleme, fehlende Informationen oder Hinweise direkt auf der Karte zu hinterlassen, ohne sie selbst sofort zu bearbeiten oder zu ändern. Es handelt sich also um Feedback oder Kommentare, die andere Mapper sehen und später bearbeiten können.
Dies soll es sowohl anonym als auch angemeldet möglich sein. Hier dokumentiere ich meine Lernschritte mit plain vanilla JavaScript. Verbesserungsvorschläge oder Kritik sind willkommen.
OAuth und Notes in OpenStreetMap
Abschnitt betitelt „OAuth und Notes in OpenStreetMap“OAuth auf OpenStreetMap ist ein Mechanismus, der es Drittanbieteranwendungen erlaubt, bestimmte Aktionen im eigenen OSM-Benutzerkonto durchzuführen, ohne dass das Passwort weitergegeben wird.
Als erster Schritt muss ein Token erstellt werden.
Token erstellen
Abschnitt betitelt „Token erstellen“- Zunächst meldet man sich mit seinem Konto an oder registriert sich, falls noch keines besteht: https://www.openstreetmap.org/login
- Anschließend geht man zur OAuth-Anwendungsverwaltung:
https://www.openstreetmap.org/user/<eigener_benutzername>/oauth_clients/new- Dort gibt man die Anwendungsdaten ein.
Man sieht ein Formular mit mehreren Feldern, wie im folgenden Bild. Wichtig ist, dass die Redirect-URL exakt mit der eigenen Anwendung übereinstimmt. Dabei ist besondere Sorgfalt nötig: Sie muss exakt mit der registrierten App übereinstimmen, bis auf den letzten Slash. Für lokale Tests kann man 127.0.0.1 verwenden; im Gegensatz zu localhost wird hier kein HTTPS erzwungen.

- Schließlich klickt man auf Register, um die App zu registrieren.
Der Client-ID kommt später in der Anwendung zum Einsatz.
A First Application
Abschnitt betitelt „A First Application“In the HTML and CSS, the only noteworthy line in my view is:
<script src="osm-auth.iife.js"></script>I followed https://github.com/osmlab/osm-auth, and the file osm-auth.iife.js comes from there.
<!DOCTYPE html><html lang="de"> <head> <title>Demo Notes 1</title> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Demo Navication Control 1"> <link href="https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.css" rel="stylesheet" > <script src="https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.js" ></script> <script src="osm-auth.iife.js"></script> <link rel="stylesheet" href="index.css"> <script type="module" src="index.js" defer></script> </head> <body> <div id="map"></div> <div id="message" role="status" aria-live="polite"></div> </body></html>body { margin: 0; padding: 0;}
html,body,#map { height: 100%;}Der JavaScript-Code implementiert die OAuth-Anmeldung für OpenStreetMap innerhalb einer MapLibre-Karte und bewirkt Folgendes, soweit ich das verstanden habe:
- Der Benutzer klickt auf „Login mit OSM“.
- Ein OSM-Login-Popup öffnet sich.
- Nach erfolgreichem Login leitet OSM zurück zur
redirect_urimit einemcodein der URL als GET-Variable. osm-authtauscht diesencodegegen ein Token aus und speichert die Authentifizierung im Browser.- Der Login-Button zeigt nun „Angemeldet“ an.
Nach erfolgreichem Login kann die App OSM-API-Anfragen im Namen des Benutzers stellen, z. B. zum Erstellen von Notes oder Fehlerhinweisen. Mein Code sieht wie folgt aus:
const map = new maplibregl.Map({ container: "map", center: [12, 50], zoom: 6, style: "https://tiles.versatiles.org/assets/styles/colorful/style.json",});
class OSMAuthControl { constructor(options = {}) { this.options = Object.assign( { client_id: "DEIN_KEY", scope: "read_prefs", redirect_uri: window.location.origin + window.location.pathname, singlepage: true, }, options, );
this.container = document.createElement("div"); this.container.className = "maplibregl-ctrl osm-auth-ctrl";
this.auth = osmAuth.osmAuth({ client_id: this.options.client_id, scope: this.options.scope, redirect_uri: this.options.redirect_uri, singlepage: this.options.singlepage, });
if ( window.location.search .slice(1) .split("&") .some((p) => p.indexOf("code=") === 0) ) { this.auth.authenticate(() => { history.pushState({}, null, window.location.pathname); this.update(); }); } }
onAdd(map) { this.map = map;
this.container.innerHTML = ` <div class="osm-auth-ui"> <button id="osm-auth-login" class="osm-btn">Login mit OSM</button> </div> `;
this.container.querySelector("#osm-auth-login").onclick = () => { if (!this.auth.bringPopupWindowToFront()) { this.auth.authenticate(() => this.update()); } };
this.update(); return this.container; }
update() { const loginBtn = this.container.querySelector("#osm-auth-login");
if (this.auth.authenticated()) { loginBtn.disabled = true; } else { loginBtn.disabled = false; } }}
const osmAuthControl = new OSMAuthControl();map.addControl(osmAuthControl, "top-right");Wenn alles funktioniert, sieht man im Benutzerprofil einen Eintrag, dass man die App autorisiert hat, und kann dies dort widerrufen, falls gewünscht.
Eigentlich sollten MapLibre-Plugins die Methoden
onAddundonRemoveimplementieren (siehe MapLibre GL JS API). In diesem Fall habe ichonRemovebewusst weggelassen, da ich das Plugin ausschließlich selbst nutze und es voraussichtlich nur hinzugefügt, aber nie entfernt wird.
Redirect anpassen
Abschnitt betitelt „Redirect anpassen“Nun trat folgendes Problem auf: Meine Karte verfügt über einen Permalink. Diesen kann ich nicht als Redirect-URL angeben, da er sich ständig ändert. Ich habe mir dafür folgende Lösung überlegt:
const map = new maplibregl.Map({ container: "map", center: [12, 50], zoom: 6, hash: "map", style: "https://tiles.versatiles.org/assets/styles/colorful/style.json",});
class OSMAuthControl { constructor(options = {}) { this.options = Object.assign( { client_id: "DEIN_KEY", scope: "read_prefs", redirect_uri: window.location.origin + window.location.pathname, singlepage: true, }, options, );
this.container = document.createElement("div"); this.container.className = "maplibregl-ctrl osm-auth-ctrl";
this.auth = osmAuth.osmAuth({ client_id: this.options.client_id, scope: this.options.scope, redirect_uri: this.options.redirect_uri, singlepage: this.options.singlepage, });
if (window.location.search.includes("code=")) { this.auth.authenticate(() => { this.restoreURLState(); this.update(); }); } }
onAdd(map) { this.map = map;
this.container.innerHTML = ` <div class="osm-auth-ui"> <button id="osm-auth-login" class="osm-btn">Login mit OSM</button> </div> `;
const loginBtn = this.container.querySelector("#osm-auth-login"); loginBtn.onclick = () => { if (!this.auth.bringPopupWindowToFront()) { if (this.map) { const center = this.map.getCenter(); sessionStorage.setItem( "pre_auth_map_center", JSON.stringify([center.lng, center.lat]), ); sessionStorage.setItem("pre_auth_map_zoom", this.map.getZoom()); } this.auth.authenticate(() => this.update()); } };
this.update(); return this.container; }
update() { const loginBtn = this.container.querySelector("#osm-auth-login");
if (this.auth.authenticated()) { loginBtn.disabled = true; loginBtn.textContent = "Angemeldet bei OSM"; } else { loginBtn.disabled = false; loginBtn.textContent = "Login mit OSM"; } }
restoreURLState() { const mapCenter = sessionStorage.getItem("pre_auth_map_center"); const mapZoom = sessionStorage.getItem("pre_auth_map_zoom");
if (mapCenter && mapZoom && this.map) { const center = JSON.parse(mapCenter); this.map.jumpTo({ center, zoom: parseFloat(mapZoom) }); }
sessionStorage.removeItem("pre_auth_map_center"); sessionStorage.removeItem("pre_auth_map_zoom"); }}
const osmAuthControl = new OSMAuthControl();map.addControl(osmAuthControl, "top-right");Die neue Version des Codes speichert vor dem Login den aktuellen Kartenmittelpunkt und den Zoom-Level im sessionStorage:
if (this.map) { const center = this.map.getCenter(); sessionStorage.setItem("pre_auth_map_center", JSON.stringify([center.lng, center.lat])); sessionStorage.setItem("pre_auth_map_zoom", this.map.getZoom());}Nach dem Login wird die Karte über restoreURLState() { ... } wieder in denselben Zustand versetzt (jumpTo).
Später soll es möglich sein, zusätzlich zu den Koordinaten und dem Zoom auch einen Layer zu speichern. Dies lässt sich dann auf ähnliche Weise handhaben.
Note erstellen
Abschnitt betitelt „Note erstellen“Eine anonyme Note anlegen
Abschnitt betitelt „Eine anonyme Note anlegen“Die OpenStreetMap API bietet dafür folgenden Endpunkt: Create a new note: POST /api/0.6/notes
class OSMNoteControl { onAdd(map) { this.map = map; this.container = document.createElement("div"); this.container.className = "maplibregl-ctrl maplibregl-ctrl-group";
const button = document.createElement("button"); button.innerHTML = ` <svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"> <path role="img" aria-label="OSM Note hinzufügen" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M364.13 125.25L87 403l-23 45 44.99-23 277.76-277.13-22.62-22.62zM420.69 68.69l-22.62 22.62 22.62 22.63 22.62-22.63a16 16 0 000-22.62h0a16 16 0 00-22.62 0z" /> </svg> `; button.title = "OSM Note hinzufügen"; button.onclick = () => this.enableNoteMode();
this.container.appendChild(button); return this.container; }
onRemove() { this.container.parentNode.removeChild(this.container); this.map = undefined; }
enableNoteMode() { showTemporaryMessage( "Klicke auf die Karte, um die Note zu platzieren", 3000, ); this.map.once("click", (e) => this.addNotePopup(e.lngLat)); }
addNotePopup(lngLat) { const popup = new maplibregl.Popup({ closeOnClick: true }) .setLngLat(lngLat) .setHTML(` <div> <textarea id="note-text" placeholder="Fehler beschreiben..."></textarea><br/> <button id="submit-note">Senden</button> </div> `) .addTo(this.map);
popup.getElement().querySelector("#submit-note").onclick = () => { const text = popup.getElement().querySelector("#note-text").value; this.sendNote(lngLat, text); popup.remove(); }; }
async sendNote(lngLat, text) { try { const response = await fetch( "https://api.openstreetmap.org/api/0.6/notes.json", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ lat: lngLat.lat, lon: lngLat.lng, text: text, }), }, ); const _data = await response.json(); showTemporaryMessage("Note erfolgreich erstellt!", 3000); } catch (err) { showTemporaryMessage(`Fehler beim Erstellen der Note: ${err}`, 3000); } }}
map.addControl(new OSMNoteControl(), "top-right");
function showTemporaryMessage(msg, duration = 2000) { const messageDiv = document.getElementById("message"); messageDiv.textContent = msg; messageDiv.style.display = "block";
requestAnimationFrame(() => messageDiv.classList.add("show"));
setTimeout(() => { messageDiv.classList.remove("show"); setTimeout(() => { messageDiv.style.display = "none"; }, 500); }, duration);}Das Icon auf dem Button stammt von Ionicons.
Note anlegen mit Zoom und Authentifizierung
Abschnitt betitelt „Note anlegen mit Zoom und Authentifizierung“Wenn der Benutzer angemeldet ist, soll die Note unter seinem OSM-Account erstellt werden, anstatt anonym.
Der OAuth-Login und die Note-Erstellung über die OSM-API sind bereits eingebaut. Was noch fehlt:
- Beim angemeldeten Benutzer wird die Note mit dem OAuth-Token erstellt, nicht anonym.
- Außerdem wird der aktuelle Zoom-Level der Karte angepasst.
Die URL bleibt dieselbe, nur dass nun das OAuth-Token genutzt wird.
const map = new maplibregl.Map({ container: "map", center: [12, 50], zoom: 6, hash: "map", style: "https://tiles.versatiles.org/assets/styles/colorful/style.json",});
class OSMAuthControl { constructor(options = {}) { this.options = Object.assign( { client_id: "DEIN_KEY", scope: "read_prefs write_notes", redirect_uri: window.location.origin + window.location.pathname, singlepage: true, }, options, );
this.container = document.createElement("div"); this.container.className = "maplibregl-ctrl osm-auth-ctrl";
this.auth = osmAuth.osmAuth({ client_id: this.options.client_id, scope: this.options.scope, redirect_uri: this.options.redirect_uri, singlepage: this.options.singlepage, });
if (window.location.search.includes("code=")) { this.auth.authenticate(() => { this.restoreURLState(); this.update(); }); } }
onAdd(map) { this.map = map;
this.container.innerHTML = ` <div class="osm-auth-ui"> <button id="osm-auth-login" class="osm-btn">Login mit OSM</button> </div> `;
const loginBtn = this.container.querySelector("#osm-auth-login"); loginBtn.onclick = () => { if (!this.auth.bringPopupWindowToFront()) { if (this.map) { const center = this.map.getCenter(); sessionStorage.setItem( "pre_auth_map_center", JSON.stringify([center.lng, center.lat]), ); sessionStorage.setItem("pre_auth_map_zoom", this.map.getZoom()); } this.auth.authenticate(() => this.update()); } };
this.update(); return this.container; }
update() { const loginBtn = this.container.querySelector("#osm-auth-login");
if (this.auth.authenticated()) { loginBtn.disabled = true; loginBtn.textContent = "Angemeldet bei OSM"; } else { loginBtn.disabled = false; loginBtn.textContent = "Login mit OSM"; } }
restoreURLState() { const mapCenter = sessionStorage.getItem("pre_auth_map_center"); const mapZoom = sessionStorage.getItem("pre_auth_map_zoom");
if (mapCenter && mapZoom && this.map) { const center = JSON.parse(mapCenter); this.map.jumpTo({ center, zoom: parseFloat(mapZoom) }); }
sessionStorage.removeItem("pre_auth_map_center"); sessionStorage.removeItem("pre_auth_map_zoom"); }}
const osmAuthControl = new OSMAuthControl();map.addControl(osmAuthControl, "top-right");
class OSMNoteControl { constructor(authControl) { this.authControl = authControl; } onAdd(map) { this.map = map; this.container = document.createElement("div"); this.container.className = "maplibregl-ctrl maplibregl-ctrl-group";
const button = document.createElement("button"); button.innerHTML = ` <svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"> <path role="img" aria-label="OSM Note hinzufügen" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M364.13 125.25L87 403l-23 45 44.99-23 277.76-277.13-22.62-22.62zM420.69 68.69l-22.62 22.62 22.62 22.63 22.62-22.63a16 16 0 000-22.62h0a16 16 0 00-22.62 0z" /> </svg> `; button.title = "OSM Note hinzufügen"; button.onclick = () => this.enableNoteMode();
this.container.appendChild(button); return this.container; }
enableNoteMode() { if (this.map.getZoom < 15) this.map.zoomTo(15, { duration: 3000 });
showTemporaryMessage( "Klicke auf die Karte, um die Note zu platzieren", 3000, ); this.map.once("click", (e) => this.addNotePopup(e.lngLat)); }
addNotePopup(lngLat) { const auth = this.authControl?.auth; const popup = new maplibregl.Popup({ closeOnClick: true }) .setLngLat(lngLat) .setHTML(` <div> <textarea id="note-text" placeholder="Fehler anonym beschreiben..."></textarea><br/> <button id="submit-note">Senden</button> </div> `) .addTo(this.map);
popup.getElement().querySelector("#submit-note").onclick = () => { const text = popup.getElement().querySelector("#note-text").value;
if (auth?.authenticated()) { this.sendNoteAuthenticated(lngLat, text, auth); } else { this.sendNoteAnonymous(lngLat, text); }
popup.remove(); }; }
async sendNoteAnonymous(lngLat, text) { try { const response = await fetch( "https://api.openstreetmap.org/api/0.6/notes.json", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ lat: lngLat.lat, lon: lngLat.lng, text: text, }), }, ); const _data = await response.json(); showTemporaryMessage("Note erfolgreich erstellt!", 3000); } catch (err) { showTemporaryMessage(`Fehler beim Erstellen der Note: ${err}`, 3000); } }
async sendNoteAuthenticated(lngLat, text, auth) { const note = { lat: lngLat.lat, lon: lngLat.lng, text: text, }; const content = new URLSearchParams(note).toString(); try { const response = await new Promise((resolve, reject) => { auth.xhr( { method: "POST", path: "/api/0.6/notes.json", content: content, }, (err, result) => { if (err) reject(err); else resolve(result); }, ); });
showTemporaryMessage("Note erfolgreich erstellt (auth)!", 3000); } catch (err) { showTemporaryMessage(`Fehler beim Erstellen der Note: ${err}`, 3000); } }}
map.addControl(new OSMNoteControl(osmAuthControl), "top-right");
function showTemporaryMessage(msg, duration = 2000) { const messageDiv = document.getElementById("message"); messageDiv.textContent = msg; messageDiv.style.display = "block";
requestAnimationFrame(() => messageDiv.classList.add("show"));
setTimeout(() => { messageDiv.classList.remove("show"); setTimeout(() => { messageDiv.style.display = "none"; }, 500); }, duration);}Man kann nun authentifizierte Notes posten, wenn man einen Ort auf der Karte auswählt. Der Code ist soweit wie möglich separat, indem das Auth-Control an das Note-Control übergeben wird. Um gezielt einen Ort zu markieren, wird beim Platzieren genauer gezoomt.
Da der OAuth-Scope um write_notes erweitert wurde, darf der Nutzer jetzt OSM Notes schreiben, nicht nur lesen.
Die neue Version verknüpft OSMNoteControl mit OSMAuthControl:
class OSMNoteControl {class OSMNoteControl { constructor(authControl) { this.authControl = authControl; }Auf diese Weise weiß OSMNoteControl, ob der Benutzer eingeloggt ist. Ist der Benutzer nicht eingeloggt, bleibt alles wie bisher. Ist er eingeloggt, wird die Note in seinem Namen erstellt.
Beispielcode und Demo