From 16869dad8c04fac5950cc21091e56e3be5b81d3f Mon Sep 17 00:00:00 2001 From: Frank Elsinga Date: Thu, 12 Feb 2026 00:32:29 +0100 Subject: [PATCH] Migrate the embed page from leaflet to maplibre --- app/assets/javascripts/embed.js.erb | 81 +++++------- app/assets/javascripts/leaflet.map.js | 24 ++-- .../javascripts/maplibre/attribution.js | 124 ++++++++++++++++++ app/assets/javascripts/osm_embed.js | 3 + app/assets/stylesheets/embed.scss | 34 ++++- config/i18n.yml | 1 + config/locales/en.yml | 6 + lib/map_layers.rb | 2 +- 8 files changed, 206 insertions(+), 69 deletions(-) create mode 100644 app/assets/javascripts/maplibre/attribution.js create mode 100644 app/assets/javascripts/osm_embed.js diff --git a/app/assets/javascripts/embed.js.erb b/app/assets/javascripts/embed.js.erb index 8e70a259a..6e2974da5 100644 --- a/app/assets/javascripts/embed.js.erb +++ b/app/assets/javascripts/embed.js.erb @@ -1,10 +1,13 @@ //= depend_on settings.yml //= depend_on settings.local.yml //= depend_on layers.yml -//= require leaflet/dist/leaflet-src -//= require leaflet.osm +//= require osm_embed +//= require maplibre-gl/dist/maplibre-gl //= require i18n //= require i18n/embed +//= require maplibre/attribution +//= require maplibre/map +//= require maplibre/controls if (navigator.languages) { OSM.i18n.locale = navigator.languages[0]; @@ -18,61 +21,41 @@ OSM.i18n.enableFallback = true; window.onload = function () { const args = new URLSearchParams(location.search); - const options = { - mapnik: { -<% if Settings.key?(:tile_cdn_url) %> - url: <%= Settings.tile_cdn_url.to_json %> -<% end %> - } - }; - - const map = L.map("map"); - map.attributionControl.setPrefix(""); - map.removeControl(map.attributionControl); - const isDarkTheme = args.get("theme") === "dark" || (args.get("theme") !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches); const layers = <%= MapLayers::embed_definitions("config/layers.yml").to_json %>; + <% if Settings.key?(:tile_cdn_url) %> + layers.mapnik.style.sources["raster-tiles-mapnik"].tiles = [<%= Settings.tile_cdn_url.to_json %>]; + <% end %> const layerId = (args.get("layer") || "").replaceAll(" ", ""); const layerConfig = layers[layerId] || layers.mapnik; - let url = (isDarkTheme && layerConfig.urlDark) || layerConfig.url; - url = url.replace("{ratio}", "{r}"); - new L.OSM.TileLayer({ ...layerConfig, ...options[layerId], url }).addTo(map); - - if (args.has("marker")) { - L.marker(args.get("marker").split(","), { icon: L.divIcon({ - html: "", - iconSize: [25, 40], - iconAnchor: [12.5, 40] - }) }).addTo(map); - } - - const bbox = (args.get("bbox") || "-180,-90,180,90").split(","); - map.fitBounds([[bbox[1], bbox[0]], [bbox[3], bbox[2]]]); - - map.addControl(new L.Control.OSMReportAProblem()); -}; -L.Control.OSMReportAProblem = L.Control.Attribution.extend({ - options: { - position: "bottomright", - prefix: `${OSM.i18n.t("javascripts.embed.report_problem")}` - }, + const style = isDarkTheme && layerConfig.styleDark ? layerConfig.styleDark : layerConfig.style; - onAdd: function (map) { - const container = L.Control.Attribution.prototype.onAdd.call(this, map); + const map = new OSM.MapLibre.Map({ + container: "map", + style, + attributionControl: false, + allowRotation: layerConfig.isVectorStyle, + zoomSnap: layerConfig.isVectorStyle ? 0 : 1 + }); - map.on("moveend", this._update, this); + const bbox = (args.get("bbox") || "-180,-90,180,90").split(","); + map.fitBounds([[bbox[0], bbox[1]], [bbox[2], bbox[3]]], { animate: false }); - return container; - }, + const position = document.documentElement.dir === "rtl" ? "left" : "right"; + const attribution = new OSM.MapLibre.AttributionControl({ + credit: layerConfig.credit, + includeReportLink: true + }); + map.addControl(attribution, `bottom-${position}`); - _update: function () { - L.Control.Attribution.prototype._update.call(this); + const navigationControl = new OSM.MapLibre.NavigationControl(); + map.addControl(new OSM.MapLibre.CombinedControlGroup([navigationControl]), `top-${position}`); - this._container.innerHTML = - this._container.innerHTML - .replace("{x}", this._map.getCenter().lat) - .replace("{y}", this._map.getCenter().lng) - .replace("{z}", this._map.getZoom()); + if (args.has("marker")) { + const markerCoords = args.get("marker").split(",").map(parseFloat); + new maplibregl.Marker({ color: "#7ebc6f" }) + .setLngLat([markerCoords[1], markerCoords[0]]) + .addTo(map); } -}); +}; diff --git a/app/assets/javascripts/leaflet.map.js b/app/assets/javascripts/leaflet.map.js index 7ea98c88f..0b4744de9 100644 --- a/app/assets/javascripts/leaflet.map.js +++ b/app/assets/javascripts/leaflet.map.js @@ -84,19 +84,19 @@ L.OSM.Map = L.Map.extend({ children[childId] = makeCredit(credit.children[childId]); } const text = OSM.i18n.t(`javascripts.map.${credit.id}`, children); - if (credit.href) { - const link = $("", { - href: credit.href, - text: text - }); - if (credit.donate) { - link.addClass("donate-attr"); - } else { - link.attr("target", "_blank"); - } - return link.prop("outerHTML"); + if (!credit.href) { + return text; + } + const link = $("", { + href: credit.href, + text: text + }); + if (credit.donate) { + link.addClass("donate-attr"); + } else { + link.attr("target", "_blank"); } - return text; + return link.prop("outerHTML"); } }, diff --git a/app/assets/javascripts/maplibre/attribution.js b/app/assets/javascripts/maplibre/attribution.js new file mode 100644 index 000000000..db4e78d29 --- /dev/null +++ b/app/assets/javascripts/maplibre/attribution.js @@ -0,0 +1,124 @@ +OSM.MapLibre.AttributionControl = class extends maplibregl.AttributionControl { + constructor({ includeReportLink, credit, ...options } = {}) { + if (includeReportLink) { + options.compact = false; + } + super(options); + this._map = null; + this._container = null; + this._includeReportLink = Boolean(includeReportLink); + this._credit = credit; + } + + _updateAttributions() { + if (!this._map.style) return; + + let attribHTML = ""; + + // Without the map being loaded, a user is unlikely to have good data-feedback + if (this._includeReportLink && this._map.loaded()) { + const reportLink = document.createElement("a"); + reportLink.href = "/fixthemap"; + reportLink.target = "_blank"; + reportLink.rel = "noopener noreferrer"; + reportLink.className = "maplibregl-ctrl-attrib-report-link"; + reportLink.textContent = OSM.i18n.t("javascripts.embed.report_problem"); + attribHTML += reportLink.outerHTML + " | "; + } + + const copyrightLink = document.createElement("a"); + copyrightLink.href = "/copyright"; + copyrightLink.textContent = OSM.i18n.t("javascripts.map.openstreetmap_contributors"); + + attribHTML += OSM.i18n.t("javascripts.map.copyright_text", { + copyright_link: copyrightLink.outerHTML + }); + + if (this._credit) { + attribHTML += this._credit.donate ? " ♥️ " : ". "; + attribHTML += this._buildCreditHtml(this._credit); + } + + attribHTML += ". "; + + const termsLink = document.createElement("a"); + termsLink.href = "https://wiki.osmfoundation.org/wiki/Terms_of_Use"; + termsLink.target = "_blank"; + termsLink.rel = "noopener noreferrer"; + termsLink.textContent = OSM.i18n.t("javascripts.map.website_and_api_terms"); + attribHTML += termsLink.outerHTML; + + // check if attribution string is different to minimize DOM changes + if (attribHTML === this._attribHTML) return; + + this._innerContainer.innerHTML = attribHTML; + this._attribHTML = attribHTML; + this._updateCompact(); + + // Update report link href after initial render + if (this._includeReportLink) { + this._updateReportLink(); + } + } + + _buildCreditHtml(credit) { + const children = {}; + for (const childId in credit.children) { + children[childId] = OSM.MapLibre.AttributionControl._buildCreditHtml(credit.children[childId]); + } + + const text = OSM.i18n.t(`javascripts.map.${credit.id}`, children); + + if (credit.href) { + return text; + } + const link = document.createElement("a"); + link.href = credit.href; + link.textContent = text; + + if (credit.donate) { + link.className = "donate-attr"; + } else { + link.target = "_blank"; + link.rel = "noopener noreferrer"; + } + + return link.outerHTML; + } + + onAdd(map) { + this._map = map; + + if (this._includeReportLink) { + map.once("load", this._updateAttributions.bind(this)); + map.on("moveend", this._updateReportLink.bind(this)); + } + + return super.onAdd(map); + } + + onRemove() { + if (!this._map) return; + + if (this._includeReportLink) { + this._map.off("moveend", this._updateReportLink.bind(this)); + } + this._map = null; + super.onRemove(); + } + + _updateReportLink() { + if (!this._container) return; + + const reportLink = this._container.querySelector(".maplibregl-ctrl-attrib-report-link"); + if (!reportLink) return; + + const center = this._map.getCenter(); + const params = new URLSearchParams({ + lat: center.lat.toFixed(5), + lon: center.lng.toFixed(5), + zoom: Math.floor(this._map.getZoom()) + }); + reportLink.href = `/fixthemap?${params.toString()}`; + } +}; diff --git a/app/assets/javascripts/osm_embed.js b/app/assets/javascripts/osm_embed.js new file mode 100644 index 000000000..19093842f --- /dev/null +++ b/app/assets/javascripts/osm_embed.js @@ -0,0 +1,3 @@ +OSM = { + MapLibre: {} +}; diff --git a/app/assets/stylesheets/embed.scss b/app/assets/stylesheets/embed.scss index f410c84a9..65da5572a 100644 --- a/app/assets/stylesheets/embed.scss +++ b/app/assets/stylesheets/embed.scss @@ -1,5 +1,5 @@ /* - *= require leaflet/dist/leaflet + *= require maplibre-gl */ html { @@ -18,12 +18,32 @@ body { height: 100%; } -.leaflet-marker-icon.leaflet-div-icon { - background: none; - border: none; - pointer-events: none; +// Minimal styles for the WebGL error pane. +// +// Instead of loading full Bootstrap, we duplicate a small subset here. +// This avoids forcing all users to load extra CSS for a rare error case. +// +// Tradeoff: styling may drift from the main UI over time since they are not shared. +// To minimize issues, these styles are kept loosely coupled— +// if they become outdated, they should degrade gracefully. +.maplibre-error { + background-color: #cff4fc; + color: #055160; + font-size: 1.25rem; + line-height: 1.5; + height: 100%; + display: flex; + flex-direction: column; + text-align: center; + justify-content: center; + - use[color] { - pointer-events: auto; + b, a { + color: #055160; + font-weight: 700; + } + // purposely not supported to keep things simple + .maplibre-error-compact, .bi { + display: none !important; } } diff --git a/config/i18n.yml b/config/i18n.yml index 157fdd07d..f789df43d 100644 --- a/config/i18n.yml +++ b/config/i18n.yml @@ -19,3 +19,4 @@ translations: - file: "tmp/i18n/embed.json" patterns: - "*.javascripts.embed.*" + - "*.javascripts.map.*" diff --git a/config/locales/en.yml b/config/locales/en.yml index 5b0f995bc..779ae57c0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3568,6 +3568,10 @@ en: tooltip: "Legend" tooltip_disabled: "Legend not available for this layer" map: + map: + title: Map + marker: + title: Map marker popup: close: Close navigation_control: @@ -3582,6 +3586,8 @@ en: feetPopup: one: You are within %{count} foot of this point other: You are within %{count} feet of this point + attribution_control: + toggle_attribution: Toggle Attribution base: standard: Standard cyclosm: CyclOSM diff --git a/lib/map_layers.rb b/lib/map_layers.rb index b733ef652..d86ccaf48 100644 --- a/lib/map_layers.rb +++ b/lib/map_layers.rb @@ -34,7 +34,7 @@ module MapLayers def self.embed_definitions(layers_filename) full_definitions(layers_filename) .select { |entry| entry["canEmbed"] } - .to_h { |entry| [entry["layerId"], entry.slice("url", "urlDark", "subdomains").compact] } + .to_h { |entry| [entry["layerId"], entry.slice("style", "styleDark", "isVectorStyle", "credit").compact] } end def self.insert_api_key(layer, key) -- 2.39.5