/* Photography section — Leaflet map.
*
* Loaded only on /photography/map/ via the photography-map context flag
* gating in templates/partials/head.html and templates/default.html.
*
* Pin source: /photography/map.json — emitted by the Hakyll
* photographyMapDataRule, with city-precision (or per-photo override)
* coordinate rounding applied at build time. Full-precision coords
* never reach the client.
*
* Tile source: CartoDB Positron — free for all volumes; required
* attribution is wired in below. Subdomains a-d are load-balanced.
*
* Marker behavior:
* * Click: navigate to the photo entry page.
* * Hover: tooltip with thumbnail + title + captured date.
* * Dense areas: leaflet.markercluster groups overlapping pins,
* expanding on click.
*
* The page chrome (header, toggle, attribution paragraph) renders
* pre-JS so search engines and no-JS readers see the orientation
* copy. Only the map viewport itself depends on Leaflet loading.
*/
(function () {
'use strict';
var MAP_DATA_URL = '/photography/map.json';
var MAP_ELEMENT = 'photography-map';
var TILE_URL = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
var TILE_ATTRIB = '© OpenStreetMap '
+ 'contributors © CARTO';
var TILE_SUBDOMS = 'abcd';
var FALLBACK_VIEW = [20, 0]; // [lat, lon] when there are zero pins
var FALLBACK_ZOOM = 2;
// Override the default Leaflet marker icon paths so they resolve
// to the vendored copy under /leaflet/images/. Leaflet's default
// resolution uses the URL of leaflet.js, which fails for vendored
// setups since the script lives in /js/, not /leaflet/.
function configureMarkerIconPaths() {
if (typeof L === 'undefined' || !L.Icon || !L.Icon.Default) return;
L.Icon.Default.mergeOptions({
iconRetinaUrl: '/leaflet/images/marker-icon-2x.png',
iconUrl: '/leaflet/images/marker-icon.png',
shadowUrl: '/leaflet/images/marker-shadow.png'
});
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function tooltipHtml(pin) {
var thumb = pin.thumb
? ''
: '';
var date = pin.captured
? '
'
+ 'No geo-tagged photographs yet. Photos with a '
+ 'geo: frontmatter field will appear here.'
+ '
' + escapeHtml(message || 'Could not load the map.') + '
'; } document.addEventListener('DOMContentLoaded', function () { var container = document.getElementById(MAP_ELEMENT); if (!container) return; // Leaflet must be present; the conditional script load in // default.html should guarantee this on /photography/map/, but // a defensive fallback is cheap. if (typeof L === 'undefined') { renderErrorState(container, 'Map library failed to load.'); return; } configureMarkerIconPaths(); fetch(MAP_DATA_URL, { cache: 'force-cache' }) .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function (pins) { if (!Array.isArray(pins) || pins.length === 0) { renderEmptyState(container); return; } var map = L.map(container, { scrollWheelZoom: false, // require explicit interaction zoomControl: true, attributionControl: true }).setView(FALLBACK_VIEW, FALLBACK_ZOOM); L.tileLayer(TILE_URL, { attribution: TILE_ATTRIB, subdomains: TILE_SUBDOMS, maxZoom: 19 }).addTo(map); // markercluster — groups overlapping pins; falls back // to plain L.featureGroup if the plugin failed to load. var hasCluster = typeof L.markerClusterGroup === 'function'; var layer = hasCluster ? L.markerClusterGroup() : L.featureGroup(); pins.forEach(function (pin) { if (typeof pin.lat !== 'number' || typeof pin.lon !== 'number') return; var marker = L.marker([pin.lat, pin.lon]); marker.bindTooltip(tooltipHtml(pin), { direction: 'top', offset: [0, -36], className: 'photography-map-tooltip-wrap', opacity: 1 }); if (pin.url) { marker.on('click', function () { window.location.href = pin.url; }); } layer.addLayer(marker); }); map.addLayer(layer); // Frame the visible pins with a small padding. Single- // pin portfolios get a moderate zoom rather than the // hard-coded zoom level so the marker doesn't feel // marooned in negative space. if (pins.length === 1) { map.setView([pins[0].lat, pins[0].lon], 8); } else { var bounds = layer.getBounds(); if (bounds.isValid()) { map.fitBounds(bounds.pad(0.15)); } } // Allow scroll-wheel zoom only after the user clicks // into the map — prevents the page from "trapping" the // scroll on someone passing through. map.once('focus', function () { map.scrollWheelZoom.enable(); }); map.on('blur', function () { map.scrollWheelZoom.disable(); }); }) .catch(function (err) { renderErrorState(container, 'Could not load map data: ' + err.message); }); }); }());