280 lines
12 KiB
JavaScript
280 lines
12 KiB
JavaScript
/* 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 = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> '
|
||
+ 'contributors © <a href="https://carto.com/attributions">CARTO</a>';
|
||
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, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Place-level helpers
|
||
// ------------------------------------------------------------------
|
||
|
||
// "Munich, Germany" -> "Munich". The country repeats across every pin in
|
||
// a country-sized view and earns no space in a marker label.
|
||
//
|
||
// A rounded coordinate can hold more than one place — Bagsværd and
|
||
// Kongens Lyngby are four kilometres apart and land on the same
|
||
// city-precision point — so the label lists every distinct one rather
|
||
// than naming whichever frame happened to come first and quietly
|
||
// mislabelling the rest.
|
||
function placeLabel(group) {
|
||
var seen = [];
|
||
group.pins.forEach(function (p) {
|
||
if (!p.location) return;
|
||
var name = p.location.split(',')[0].trim();
|
||
if (name && seen.indexOf(name) === -1) seen.push(name);
|
||
});
|
||
if (seen.length) return seen.join(' · ');
|
||
return group.pins[0].title || 'Untitled';
|
||
}
|
||
|
||
// A click should land somewhere that shows the place. If every frame here
|
||
// belongs to one series, that series is the answer; a lone frame goes to
|
||
// itself; a mixed pile has no single right destination, so it gets none
|
||
// and stays a hover-only marker.
|
||
function groupTarget(group) {
|
||
if (group.pins.length === 1) return group.pins[0].url || null;
|
||
var series = group.pins[0].series;
|
||
if (!series) return null;
|
||
var shared = group.pins.every(function (p) { return p.series === series; });
|
||
return shared ? '/photography/' + series + '/' : null;
|
||
}
|
||
|
||
function groupTooltipHtml(group) {
|
||
var count = group.pins.length;
|
||
if (count === 1) return tooltipHtml(group.pins[0]);
|
||
|
||
var strip = group.pins.slice(0, 4).map(function (p) {
|
||
return p.thumb
|
||
? '<img class="photography-map-strip-img" src="'
|
||
+ escapeHtml(p.thumb) + '" alt="" loading="lazy">'
|
||
: '';
|
||
}).join('');
|
||
|
||
var dates = group.pins
|
||
.map(function (p) { return p.captured; })
|
||
.filter(Boolean)
|
||
.sort();
|
||
var when = dates.length
|
||
? (dates[0] === dates[dates.length - 1]
|
||
? dates[0]
|
||
: dates[0] + ' – ' + dates[dates.length - 1])
|
||
: '';
|
||
|
||
return '<div class="photography-map-tooltip photography-map-tooltip--group">'
|
||
+ '<div class="photography-map-strip">' + strip + '</div>'
|
||
+ '<div class="photography-map-tooltip-title">' + escapeHtml(placeLabel(group)) + '</div>'
|
||
+ '<div class="photography-map-tooltip-meta">' + count + ' photographs'
|
||
+ (when ? ' · ' + escapeHtml(when) : '') + '</div>'
|
||
+ '</div>';
|
||
}
|
||
|
||
function tooltipHtml(pin) {
|
||
var thumb = pin.thumb
|
||
? '<img class="photography-map-tooltip-img" src="' + escapeHtml(pin.thumb) + '" alt="" loading="lazy">'
|
||
: '';
|
||
var date = pin.captured
|
||
? '<div class="photography-map-tooltip-date">' + escapeHtml(pin.captured) + '</div>'
|
||
: '';
|
||
return ''
|
||
+ '<div class="photography-map-tooltip">'
|
||
+ thumb
|
||
+ '<div class="photography-map-tooltip-title">' + escapeHtml(pin.title || '(untitled)') + '</div>'
|
||
+ date
|
||
+ '</div>';
|
||
}
|
||
|
||
function renderEmptyState(container) {
|
||
container.classList.add('photography-map--empty');
|
||
container.innerHTML =
|
||
'<p class="photography-map-empty">'
|
||
+ 'No geo-tagged photographs yet. Photos with a '
|
||
+ '<code>geo:</code> frontmatter field will appear here.'
|
||
+ '</p>';
|
||
}
|
||
|
||
function renderErrorState(container, message) {
|
||
container.classList.add('photography-map--error');
|
||
container.innerHTML =
|
||
'<p class="photography-map-error">'
|
||
+ escapeHtml(message || 'Could not load the map.')
|
||
+ '</p>';
|
||
}
|
||
|
||
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);
|
||
|
||
// One marker per PLACE, not per photograph.
|
||
//
|
||
// geo-precision rounds coordinates before they reach this
|
||
// file, so every frame from a city shares one position
|
||
// exactly. Adding them as individual markers gave
|
||
// markercluster nothing to separate: clicking a cluster
|
||
// spiderfied 28 identical points into a starburst of legs
|
||
// radiating from a single spot — a drawing of the clustering
|
||
// algorithm rather than of anywhere. Aggregating first says
|
||
// what the data actually says: two places, with counts.
|
||
var groups = {};
|
||
pins.forEach(function (pin) {
|
||
if (typeof pin.lat !== 'number' || typeof pin.lon !== 'number') return;
|
||
var key = pin.lat + ',' + pin.lon;
|
||
if (!groups[key]) {
|
||
groups[key] = { lat: pin.lat, lon: pin.lon, pins: [] };
|
||
}
|
||
groups[key].pins.push(pin);
|
||
});
|
||
|
||
var hasCluster = typeof L.markerClusterGroup === 'function';
|
||
var layer = hasCluster ? L.markerClusterGroup() : L.featureGroup();
|
||
var placed = 0;
|
||
|
||
Object.keys(groups).forEach(function (key) {
|
||
var g = groups[key];
|
||
var count = g.pins.length;
|
||
var first = g.pins[0];
|
||
|
||
// A div icon, not the default PNG: it scales with the
|
||
// count, carries the number, and removes the map's only
|
||
// dependency on a bitmap that has to resolve.
|
||
var size = count > 1 ? 34 : 22;
|
||
var marker = L.marker([g.lat, g.lon], {
|
||
icon: L.divIcon({
|
||
className: 'photography-map-pin',
|
||
html: count > 1 ? '<span>' + count + '</span>' : '<span></span>',
|
||
iconSize: [size, size],
|
||
iconAnchor: [size / 2, size / 2]
|
||
}),
|
||
title: placeLabel(g) + (count > 1 ? ' — ' + count + ' photographs' : '')
|
||
});
|
||
|
||
marker.bindTooltip(groupTooltipHtml(g), {
|
||
direction: 'top',
|
||
offset: [0, -18],
|
||
className: 'photography-map-tooltip-wrap',
|
||
opacity: 1
|
||
});
|
||
|
||
// Send a click somewhere that shows the whole place: the
|
||
// series when they share one, otherwise the single frame.
|
||
var target = groupTarget(g);
|
||
if (target) {
|
||
marker.on('click', function () { window.location.href = target; });
|
||
}
|
||
|
||
layer.addLayer(marker);
|
||
placed += 1;
|
||
});
|
||
|
||
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 (placed === 1) {
|
||
var only = groups[Object.keys(groups)[0]];
|
||
map.setView([only.lat, only.lon], 9);
|
||
} 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);
|
||
});
|
||
});
|
||
|
||
}());
|