From 3e1692364941f8111eadb9dc650e64b54a514330 Mon Sep 17 00:00:00 2001 From: Frank Elsinga Date: Wed, 5 Nov 2025 23:29:07 +0100 Subject: [PATCH] Migrate the dashboard entry page from leaflet to maplibre Co-authored-by: Marwin Hochfelsner <50826859+hlfan@users.noreply.github.com> --- app/assets/javascripts/dashboard.js | 39 +++++----- app/assets/javascripts/maplibre.map.js | 77 +++++++++++++++++++ app/assets/stylesheets/common.scss | 12 +++ app/assets/stylesheets/maplibre-gl-all.scss | 69 +++++++++++++++++ app/views/layouts/_head.html.erb | 1 + .../initializers/content_security_policy.rb | 2 +- test/system/dashboard_test.rb | 2 +- 7 files changed, 182 insertions(+), 20 deletions(-) create mode 100644 app/assets/javascripts/maplibre.map.js create mode 100644 app/assets/stylesheets/maplibre-gl-all.scss diff --git a/app/assets/javascripts/dashboard.js b/app/assets/javascripts/dashboard.js index cd9af7f59..04f6c616c 100644 --- a/app/assets/javascripts/dashboard.js +++ b/app/assets/javascripts/dashboard.js @@ -1,32 +1,35 @@ -//= require leaflet.locate +//= require maplibre.map $(function () { - const defaultHomeZoom = 12; + const defaultHomeZoom = 11; let map; if ($("#map").length) { - map = L.map("map", { + map = new maplibregl.Map({ + container: "map", + style: OSM.Mapnik, attributionControl: false, - zoomControl: false - }).addLayer(new L.OSM.Mapnik()); - - const position = $("html").attr("dir") === "rtl" ? "topleft" : "topright"; - - L.OSM.zoom({ position }).addTo(map); - - L.OSM.locate({ position }).addTo(map); + center: OSM.home ? [OSM.home.lon, OSM.home.lat] : [0, 0], + zoom: OSM.home ? defaultHomeZoom : 0 + }); - if (OSM.home) { - map.setView([OSM.home.lat, OSM.home.lon], defaultHomeZoom); - } else { - map.setView([0, 0], 0); - } + const position = $("html").attr("dir") === "rtl" ? "top-left" : "top-right"; + map.addControl(new maplibregl.NavigationControl({ showCompass: false }), position); + const geolocate = new maplibregl.GeolocateControl({ + positionOptions: { + enableHighAccuracy: true + }, + trackUserLocation: true + }); + map.addControl(geolocate, position); $("[data-user]").each(function () { const user = $(this).data("user"); if (user.lon && user.lat) { - L.marker([user.lat, user.lon], { icon: OSM.getMarker({ color: user.color }) }).addTo(map) - .bindPopup(user.description, { minWidth: 200 }); + OSM.createMapLibreMarker({ icon: "dot", color: user.color }) + .setLngLat([user.lon, user.lat]) + .setPopup(OSM.createMapLibrePopup(user.description)) + .addTo(map); } }); } diff --git a/app/assets/javascripts/maplibre.map.js b/app/assets/javascripts/maplibre.map.js new file mode 100644 index 000000000..0b2093731 --- /dev/null +++ b/app/assets/javascripts/maplibre.map.js @@ -0,0 +1,77 @@ +//= require maplibre-gl/dist/maplibre-gl + +OSM.Mapnik = { + version: 8, + sources: { + osm: { + type: "raster", + tiles: [ + "https://tile.openstreetmap.org/{z}/{x}/{y}.png" + ], + tileSize: 256, + maxzoom: 19 + } + }, + layers: [ + { + id: "osm", + type: "raster", + source: "osm" + } + ] +}; + +// Helper function to create Leaflet style (SVG comes from Leaflet) markers for MapLibre +// new maplibregl.Marker({ color: color }) is simpler, but does not have the exact same gradient +OSM.createMapLibreMarker = function ({ icon = "dot", color = "var(--marker-red)", ...options }) { + const el = document.createElement("div"); + el.className = "maplibre-gl-marker"; + el.style.width = "25px"; + el.style.height = "40px"; + + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 25 40"); + svg.setAttribute("width", "25"); + svg.setAttribute("height", "40"); + svg.classList.add("pe-none"); + svg.style.overflow = "visible"; + + // Use the marker icons from NoteMarker.svg + const use1 = document.createElementNS("http://www.w3.org/2000/svg", "use"); + use1.setAttribute("href", "#pin-shadow"); + + const use2 = document.createElementNS("http://www.w3.org/2000/svg", "use"); + use2.setAttribute("href", `#pin-${icon}`); + use2.setAttribute("color", color); + use2.classList.add("pe-auto"); + + svg.appendChild(use1); + svg.appendChild(use2); + el.appendChild(svg); + + return new maplibregl.Marker({ + element: el, + anchor: "bottom", + offset: [0, 0], + ...options + }); +}; + +// Helper function to create MapLibre popups that don't overlap with Leaflets' markers +OSM.createMapLibrePopup = function (content) { + // General offset 5px for each side, but the offset depends on the popup position: + // Popup above the marker -> lift it by height + 5px = 45px + // Popup left the marker -> lift it by width/2 + 5px = 22.5px ~= 17px + const offset = { + "bottom": [0, -45], + "bottom-left": [0, -45], + "bottom-right": [0, -45], + "top": [0, 5], + "top-left": [0, 5], + "top-right": [0, 5], + // our marker is bigger at the top, but this does not attach there -> tucked 2px more + "right": [-15, -10], + "left": [15, -10] + }; + return new maplibregl.Popup({ offset }).setHTML(content); +}; diff --git a/app/assets/stylesheets/common.scss b/app/assets/stylesheets/common.scss index befbec8de..3f4f096f2 100644 --- a/app/assets/stylesheets/common.scss +++ b/app/assets/stylesheets/common.scss @@ -419,6 +419,18 @@ body.small-nav { border-top: 0px !important; } +@each $anchor, $border in ( + left: right, + right: left, + top: bottom, + bottom: top +) { + .maplibregl-popup#{'[class*="anchor-#{$anchor}"]'} .maplibregl-popup-tip { + border-#{$border}-color: var(--bs-body-bg); + } +} + +.maplibregl-popup-content, .leaflet-popup-content-wrapper, .leaflet-popup-tip, .leaflet-control-attribution, .leaflet-control-scale-line { @extend .bg-body, .text-body; diff --git a/app/assets/stylesheets/maplibre-gl-all.scss b/app/assets/stylesheets/maplibre-gl-all.scss new file mode 100644 index 000000000..7d94bbdd9 --- /dev/null +++ b/app/assets/stylesheets/maplibre-gl-all.scss @@ -0,0 +1,69 @@ +/* + *= require maplibre-gl + */ + +@import "parameters"; + +.maplibregl-ctrl { + margin-right: 0 !important; +} + +.maplibregl-control-container .maplibregl-ctrl button { + display: block; + height: 40px; + width: 40px; + padding: 10px; + background-color: rgba(0, 0, 0, 0.6); + + &:hover, &:focus { + background-color: black !important; + } + + &:first-child, + &:first-child:focus { + border-radius: 4px 0 0 0; + } + + &:last-child, + &:last-child:focus { + border-radius: 0 0 0 4px; + } +} + +.maplibregl-ctrl-group { + background-color: transparent !important; +} + +.maplibregl-ctrl-group button+button { + border-top: 0; +} + +.maplibregl-user-location-accuracy-circle { + background-color: $green !important; + opacity: 0.2 !important; +} + +.maplibregl-user-location-dot, .maplibregl-user-location-dot:before { + background-color: $vibrant-green !important; +} + +.maplibregl-ctrl button .maplibregl-ctrl-icon { + background-image: none !important; + background-color: white; +} + +.maplibregl-ctrl button.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon { + mask-image: image-url("map-controls/zoomin.svg"); +} + +.maplibregl-ctrl button.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon { + mask-image: image-url("map-controls/zoomout.svg"); +} + +.maplibregl-ctrl button.maplibregl-ctrl-geolocate .maplibregl-ctrl-icon { + mask-image: image-url("map-controls/geolocate.svg"); +} + +.maplibregl-ctrl button.maplibregl-ctrl-geolocate.maplibregl-ctrl-geolocate-active .maplibregl-ctrl-icon { + background-color: $vibrant-green; +} diff --git a/app/views/layouts/_head.html.erb b/app/views/layouts/_head.html.erb index 9c7d1ff81..3f9714301 100644 --- a/app/views/layouts/_head.html.erb +++ b/app/views/layouts/_head.html.erb @@ -10,6 +10,7 @@ <% end %> <%= stylesheet_link_tag "print-#{dir}", :media => "print" %> <%= stylesheet_link_tag "leaflet-all", :media => "screen, print" %> + <%= stylesheet_link_tag "maplibre-gl-all", :media => "screen, print" %> <%= yield :head %> <%= yield :auto_discovery_link_tag %> <%= csrf_meta_tag %> diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index a46aad1fb..c02ab9996 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -7,7 +7,7 @@ # https://guides.rubyonrails.org/security.html#content-security-policy-header Rails.application.configure do - connect_src = [:self] + connect_src = [:self, "tile.openstreetmap.org"] img_src = [:self, :data, "www.gravatar.com", "*.wp.com", "tile.openstreetmap.org", "gps.tile.openstreetmap.org", "*.tile.thunderforest.com", "tile.tracestrack.com", "*.openstreetmap.fr"] script_src = [:self] diff --git a/test/system/dashboard_test.rb b/test/system/dashboard_test.rb index fe8e0e388..df72ec832 100644 --- a/test/system/dashboard_test.rb +++ b/test/system/dashboard_test.rb @@ -62,7 +62,7 @@ class DashboardSystemTest < ApplicationSystemTestCase assert_no_text "Your location" assert_no_link "Fred Tester" - find(".leaflet-marker-icon").click + find(".maplibre-gl-marker").click assert_text "Your location" assert_link "Fred Tester" -- 2.39.5