OpenStreetMap logo OpenStreetMap

I like to enable creating Notes in a MapLibre map. This should be possible both anonymously and while logged in. The following documents the learning steps using plain vanilla JavaScript. Improvements or feedback welcome.

In OpenStreetMap, a note allows users to leave feedback, report missing information, or provide hints directly on the map without editing it themselves. In other words, it serves as feedback or comments that other mappers can see and later act upon.

OAuth and Notes in OpenStreetMap

OAuth

OAuth on OpenStreetMap is a mechanism that allows third-party applications to perform specific actions in a user’s OSM account without requiring the user’s password.

The first step is to create a token.

Creating a Token

  1. First, I log in to my account or register if you do not have one: https://www.openstreetmap.org/login
  2. Then I go to the OAuth application management page:
https://www.openstreetmap.org/user/<your_username>/oauth_clients/new
  1. Enter the application details.

A form with several fields appears, as shown in the image below. It is important that the redirect URL exactly matches the one used in your application. Special attention is required: it must match the registered app exactly, including the trailing slash. For local testing, 127.0.0.1 can be used; unlike localhost, HTTPS is not enforced here.

A simple MapLibre map

  1. Finally, click Register to register the app.

The Client ID will be needed later in the application.

A first App

In HTML and CSS only the first line ist remarkable:

<script src="osm-auth.iife.js"></script>

I used https://github.com/osmlab/osm-auth, and the file osm-auth.iife.js is from there.

<!DOCTYPE html>
<html lang="de">
  <head>
    <title>Demo Notes 1</title>
    <meta charset="utf-8">
    <meta name="viewport" content="https://wiki.openstreetmap.org/wiki/Tag:width=device-width, https://wiki.openstreetmap.org/wiki/Tag: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%;
}

The JavaScript code implements OAuth login for OpenStreetMap within a MapLibre map and works as follows, as I understand it:

  1. The user clicks on “Login with OSM.”
  2. An OSM login popup opens.
  3. After a successful login, OSM redirects to the redirect_uri with a code in the URL as a GET parameter.
  4. osm-auth exchanges this code for a token and stores the authentication in the browser.
  5. The login button now displays “Logged in.”

After a successful login, the app can make OSM API requests on behalf of the user, for example, to create notes or report issues. The code looks as follows:

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=") https://wiki.openstreetmap.org/wiki/Tag:=== 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");

If everything works, one can see in the user profile that the app has been authorized, and it can be revoked there if desired.

Technically, MapLibre plugins are expected to implement the onAdd and onRemove methods (see MapLibre GL JS API). In this case, I intentionally left out onRemove because I only use the plugin myself, and it will likely only ever be added, never removed.

Adjusting the Redirect

The following problem arose: My map has a permalink, which cannot be used as a redirect URL because it constantly changes. I came up with the following solution:

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");

The new version of the code stores the current map center and zoom level in sessionStorage before login:

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());
}

After login, the map is restored to the same state using restoreURLState() { ... } (jumpTo).

In the future, it should also be possible to save a layer in addition to the coordinates and zoom. This can be handled in a similar way.

Creating a Note

Creating an anonymous note

The OpenStreetMap API provides the following endpoint for this: 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);
}

The icon on the button comes from Ionicons.

Creating a Note with Zoom and Authentication

If the user is logged in, the note should be created under their OSM account instead of anonymously.

OAuth login and note creation via the OSM API are already implemented. What still needs to be done:

  • For a logged-in user, the note is created using the OAuth token, not anonymously.
  • Additionally, the current zoom level of the map is applied.

The URL remains the same, only the OAuth token is used.

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);
}

It is now possible to post authenticated notes when placing a location on the map. The code is cleanly modularized by passing the auth control to the note control. To mark a specific spot accurately, the map zooms in when placing a note.

Since the OAuth scope has been extended with write_notes, the user can now create OSM notes, not just read them.

The new version links OSMNoteControl with OSMAuthControl:

- class OSMNoteControl {
+ class OSMNoteControl {
+   constructor(authControl) {
+     this.authControl = authControl;
+   }

This way, OSMNoteControl knows whether the user is logged in. If the user is not logged in, everything behaves as before. If logged in, the note is created under their account.

Example code and demo

Email icon Bluesky Icon Facebook Icon LinkedIn Icon Mastodon Icon Telegram Icon X Icon

Discussion

Log in to leave a comment