hackertours/assets/js/tour-map.js

157 lines
5.4 KiB
JavaScript

// Tour map shortcode: draws the tour locations and their routes from the Congress on
// OpenStreetMap tiles with Leaflet. The data comes from the data-tour-map attribute.
const POPUP = { minWidth: 220, maxWidth: 320 };
const decodePolyline = (encoded, precision = 5) => {
const factor = 10 ** precision;
const points = [];
let index = 0;
let lat = 0;
let lon = 0;
const next = () => {
let result = 0;
let shift = 0;
let byte;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
return result & 1 ? ~(result >> 1) : result >> 1;
};
while (index < encoded.length) {
lat += next();
lon += next();
points.push([lat / factor, lon / factor]);
}
return points;
};
const legPoints = (leg) =>
leg.coordinates
? leg.coordinates.map(([lon, lat]) => [lat, lon])
: decodePolyline(leg.polyline ?? '', leg.precision ?? 5);
// icon is a data: URI from the vendored icon sets (see the tour-map-icon partial), or empty.
const markerIcon = (color, icon, modifier) => {
const symbol = document.createElement('span');
if (color) symbol.style.setProperty('--tour-map-color', color);
if (icon) {
const image = document.createElement('i');
image.style.setProperty('--tour-map-icon', `url("${icon}")`);
symbol.append(image);
}
return L.divIcon({
className: `tour-map__marker${modifier ? ` tour-map__marker--${modifier}` : ''}`,
html: symbol,
iconSize: null,
});
};
const popup = (tour) => {
const content = document.createElement('div');
const title = document.createElement(tour.url ? 'a' : 'strong');
title.className = 'tour-map__popup-title';
title.textContent = tour.title;
if (tour.url) title.href = tour.url;
content.append(title);
if (tour.directions) {
// Rendered from the site's own data files by Hugo.
const directions = document.createElement('div');
directions.className = 'tour-map__directions';
directions.innerHTML = tour.directions;
content.append(directions);
}
return content;
};
// 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);
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: '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M7 2h2v5h5v2H9v5H7V9H2V7h5z" fill="currentColor"/></svg>',
zoomInTitle: element.dataset.labelZoomIn,
zoomOutText: '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M2 7h12v2H2z" fill="currentColor"/></svg>',
zoomOutTitle: element.dataset.labelZoomOut,
})
.addTo(map);
// Without the flag icon, so the credit line is plain text.
map.attributionControl.setPrefix('<a href="https://leafletjs.com">Leaflet</a>');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);
// Zooming with the scroll wheel only once the map is in use, so it doesn't catch page scrolling.
map.on('click focus', () => map.scrollWheelZoom.enable());
map.on('mouseout blur', () => map.scrollWheelZoom.disable());
const bounds = L.latLngBounds([]);
for (const tour of tours) {
for (const leg of tour.route) {
const points = legPoints(leg);
L.polyline(points, {
color: tour.color || '#555',
weight: 4,
opacity: 0.8,
dashArray: leg.mode === 'walk' ? '2 8' : null,
})
.bindPopup(popup(tour), POPUP)
.addTo(map);
points.forEach((point) => bounds.extend(point));
}
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) {
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);
}