From f1b8c42ab649063f2e1f826887cefd9a508fee5c Mon Sep 17 00:00:00 2001 From: Stefan Bethke Date: Sat, 17 Oct 2026 10:50:38 +0200 Subject: [PATCH] Fix pa11y issues with the map --- README.md | 1 + assets/css/_tour-map.css | 27 ++++++++++++++ assets/js/tour-map.js | 61 ++++++++++++++++++++++++++----- i18n/de.yaml | 12 +++--- i18n/en.yaml | 12 +++--- layouts/_shortcodes/tour-map.html | 2 +- 6 files changed, 94 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index e81845b..0885b43 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Each entry starts at the time given, in Hamburg time, at the Hackertours desk (w - Each tour gets a marker in its colour, with its `icon` in white, and its route as a line; walking legs are dotted. Clicking a marker or a route shows the tour title, linked to the tour page, and the directions. - The map shows all tours at once. Scroll-wheel zoom only starts after clicking into the map, so the page still scrolls normally. +- The map is only created when it scrolls near the visible part of the page, so visitors who don't scroll down load no map tiles. That also means the accessibility check (pa11y) doesn't see the map: it would otherwise report the text above the map, because axe wrongly takes the clipped map tiles to overlap it. Check changes to the map's markers and controls by hand, e.g. with the axe browser extension. - Without JavaScript, and for screen readers, it shows a list of the tours with their directions and a link to each location on openstreetmap.org. - If a text is missing in the page's language, the other language is used. - Without any tours with `geo`, the shortcode renders nothing and the build logs a warning with the ID `warning-tour-map-empty`. diff --git a/assets/css/_tour-map.css b/assets/css/_tour-map.css index 35685d9..55c8f89 100644 --- a/assets/css/_tour-map.css +++ b/assets/css/_tour-map.css @@ -88,6 +88,33 @@ } } +/* The credit line on an opaque background, so its contrast doesn't depend on the map below. + As specific as Leaflet's own semi-transparent rule plus one, which it would lose to otherwise. */ +.tour-map .leaflet-container .leaflet-control-attribution { + background: #fff; + color: #333; + + a { + color: #0b5394; + } +} + +/* Leaflet blends tiles with plus-lighter to hide seams while they fade in; the map doesn't fade + tiles (tour-map.js), and axe, which pa11y uses, fails on that blend mode. */ +.tour-map .leaflet-container img.leaflet-tile { + mix-blend-mode: normal; +} + +.tour-map .leaflet-bar a { + color: #111; +} + +/* The zoom icons (SVG, see tour-map.js) centred in their buttons. */ +.tour-map .leaflet-control-zoom a { + display: grid; + place-items: center; +} + /* Dark map tiles to match the dark colour scheme. */ @media (prefers-color-scheme: dark) { .tour-map .leaflet-tile-pane { diff --git a/assets/js/tour-map.js b/assets/js/tour-map.js index fa71099..933a051 100644 --- a/assets/js/tour-map.js +++ b/assets/js/tour-map.js @@ -66,11 +66,29 @@ const popup = (tour) => { return content; }; -for (const element of document.querySelectorAll('[data-tour-map]')) { +// Leaflet makes markers buttons, but only gives them a title, which isn't an accessible name. +const named = (marker, label) => + marker.on('add', () => marker.getElement().setAttribute('aria-label', label)); + +const createMap = (element) => { const { origin, tours } = JSON.parse(element.dataset.tourMap); - // Show the map container first: Leaflet needs its size. - element.classList.add('tour-map--ready'); - const map = L.map(element.querySelector('.tour-map__canvas'), { scrollWheelZoom: false }); + const map = L.map(element.querySelector('.tour-map__canvas'), { + scrollWheelZoom: false, + zoomControl: false, + // No fading in of tiles, see the tile blend mode in _tour-map.css. + fadeAnimation: false, + }); + // Plus and minus as icons rather than characters, with names in the page's language. + L.control + .zoom({ + zoomInText: '', + zoomInTitle: element.dataset.labelZoomIn, + zoomOutText: '', + zoomOutTitle: element.dataset.labelZoomOut, + }) + .addTo(map); + // Without the flag icon, so the credit line is plain text. + map.attributionControl.setPrefix('Leaflet'); L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap', @@ -95,22 +113,45 @@ for (const element of document.querySelectorAll('[data-tour-map]')) { .addTo(map); points.forEach((point) => bounds.extend(point)); } - L.marker([tour.lat, tour.lon], { icon: markerIcon(tour.color, tour.icon), title: tour.title }) + named(L.marker([tour.lat, tour.lon], { icon: markerIcon(tour.color, tour.icon), title: tour.title }), tour.title) .bindPopup(popup(tour), POPUP) .addTo(map); bounds.extend([tour.lat, tour.lon]); } if (origin.lat !== undefined) { - L.marker([origin.lat, origin.lon], { - icon: markerIcon('', origin.icon, 'origin'), - title: origin.name, - zIndexOffset: 1000, - }) + named( + L.marker([origin.lat, origin.lon], { + icon: markerIcon('', origin.icon, 'origin'), + title: origin.name, + zIndexOffset: 1000, + }), + origin.name, + ) .bindPopup(popup({ title: origin.name }), POPUP) .addTo(map); bounds.extend([origin.lat, origin.lon]); } map.fitBounds(bounds, { padding: [24, 24], maxZoom: 16 }); +}; + +// The map is only created when it comes near the visible part of the page: visitors who don't +// scroll down load no map tiles, and accessibility checkers that look at the page as loaded +// don't see the tiles, which axe wrongly takes to overlap the text above the map. +const nearView = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + nearView.unobserve(entry.target); + createMap(entry.target); + } + }, + { rootMargin: '200px' }, +); + +for (const element of document.querySelectorAll('[data-tour-map]')) { + // Shows the (still empty) map area at its final size, so the page doesn't jump later on. + element.classList.add('tour-map--ready'); + nearView.observe(element); } diff --git a/i18n/de.yaml b/i18n/de.yaml index f7e670a..65062a4 100644 --- a/i18n/de.yaml +++ b/i18n/de.yaml @@ -1,6 +1,11 @@ calendar_meeting_point: "Hackertours-Desk" calendar_ics: "iCal Download" calendar_google: "Google Calendar" +gallery_enlarge: "Großansicht" +gallery_dialog: "Bildansicht" +gallery_close: "Schließen" +gallery_previous: "Vorheriges Bild" +gallery_next: "Nächstes Bild" logo_alt: "Hamburg Hackertours – Startseite" map_39c3_title: "Karte der 39C3-Touren mit Wegen vom CCH (uMap)" schedule_day: "Tag {{ .Day }}" @@ -9,8 +14,5 @@ schedule_soldout: "ausgebucht" skip_to_content: "Zum Inhalt springen" tour_map_label: "Karte der Tourorte" tour_map_osm: "auf OpenStreetMap" -gallery_enlarge: "Großansicht" -gallery_dialog: "Bildansicht" -gallery_close: "Schließen" -gallery_previous: "Vorheriges Bild" -gallery_next: "Nächstes Bild" +tour_map_zoom_in: "Vergrößern" +tour_map_zoom_out: "Verkleinern" diff --git a/i18n/en.yaml b/i18n/en.yaml index fafd9a0..3a754f3 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -1,6 +1,11 @@ calendar_ics: "iCal download" calendar_google: "Google calendar" calendar_meeting_point: "Hackertours Desk" +gallery_enlarge: "enlarge" +gallery_dialog: "Image viewer" +gallery_close: "Close" +gallery_previous: "Previous image" +gallery_next: "Next image" logo_alt: "Hamburg Hackertours – home page" map_39c3_title: "Map of the 39C3 tours with routes from the CCH (uMap)" schedule_day: "Day {{ .Day }}" @@ -9,8 +14,5 @@ schedule_soldout: "fully booked" skip_to_content: "Skip to content" tour_map_label: "Map of the tour locations" tour_map_osm: "on OpenStreetMap" -gallery_enlarge: "enlarge" -gallery_dialog: "Image viewer" -gallery_close: "Close" -gallery_previous: "Previous image" -gallery_next: "Next image" +tour_map_zoom_in: "Zoom in" +tour_map_zoom_out: "Zoom out" diff --git a/layouts/_shortcodes/tour-map.html b/layouts/_shortcodes/tour-map.html index e81ac02..1520d46 100644 --- a/layouts/_shortcodes/tour-map.html +++ b/layouts/_shortcodes/tour-map.html @@ -65,7 +65,7 @@ or {{< tour-map 39c3 >}}. {{- warnidf "warning-tour-map-empty" "tour-map: no tours with geo in data/schedule/%s.yaml: %s" $event .Position -}} {{- else -}} {{- $tours = sort $tours "title" -}} -
+
    {{- range $tours }}