MediaWiki:Common.js: Difference between revisions
MediaWiki interface page
More actions
Eloise Zomia (talk | contribs) No edit summary |
Eloise Zomia (talk | contribs) No edit summary Tag: Reverted |
||
| Line 1: | Line 1: | ||
/* Landrace.wiki – Leaflet map w/ SMW | /* Landrace.wiki – Leaflet map w/ SMW Ask API (JSON) (Styled) */ | ||
(function () { | (function () { | ||
var LVER = '1.9.4'; | var LVER = '1.9.4'; | ||
| Line 215: | Line 215: | ||
var status = p.status || ''; | var status = p.status || ''; | ||
var url = p.page_url || '#'; | var url = p.page_url || '#'; | ||
var statusHtml = ''; | var statusHtml = ''; | ||
if (status) { | if (status) { | ||
| Line 223: | Line 223: | ||
'</div>'; | '</div>'; | ||
} | } | ||
return '<div class="lw-popup-inner">' + | return '<div class="lw-popup-inner">' + | ||
'<div class="lw-popup-header">' + | '<div class="lw-popup-header">' + | ||
| Line 239: | Line 239: | ||
function parseCoords(coordStr) { | function parseCoords(coordStr) { | ||
if (!coordStr) return null; | if (!coordStr) return null; | ||
var decimalMatch = coordStr.match(/(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)/); | // Decimal "lat, lon" | ||
var decimalMatch = String(coordStr).match(/(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)/); | |||
if (decimalMatch) { | if (decimalMatch) { | ||
var | var a = parseFloat(decimalMatch[1]); | ||
var | var b = parseFloat(decimalMatch[2]); | ||
if (!isNaN( | |||
if (!isNaN(a) && !isNaN(b)) { | |||
// Sanity check, swap if obviously reversed | |||
var lat = a, lon = b; | |||
var aLooksLon = Math.abs(a) > 90 && Math.abs(a) <= 180; | |||
var bLooksLat = Math.abs(b) <= 90; | |||
if (aLooksLon && bLooksLat) { | |||
lat = b; | |||
lon = a; | |||
} | |||
if (Math.abs(lat) <= 90 && Math.abs(lon) <= 180) return { lat: lat, lon: lon }; | |||
} | |||
} | } | ||
// DMS like 26° 12' 00" N 89° 30' 00" E | |||
var dmsRegex = /(\d+)[°]\s*(\d+)[′']\s*(\d+\.?\d*)[″"]?\s*([NSEW])/gi; | var dmsRegex = /(\d+)[°]\s*(\d+)[′']\s*(\d+\.?\d*)[″"]?\s*([NSEW])/gi; | ||
var matches = []; | var matches = []; | ||
var m; | var m; | ||
while ((m = dmsRegex.exec(coordStr)) !== null) { | while ((m = dmsRegex.exec(String(coordStr))) !== null) { | ||
matches.push(m); | matches.push(m); | ||
if (matches.length >= 2) break; | if (matches.length >= 2) break; | ||
} | } | ||
if (matches.length >= 2) { | if (matches.length >= 2) { | ||
var | var lat2 = dmsToDecimal(matches[0][1], matches[0][2], matches[0][3], matches[0][4]); | ||
var | var lon2 = dmsToDecimal(matches[1][1], matches[1][2], matches[1][3], matches[1][4]); | ||
var h0 = matches[0][4].toUpperCase(); | var h0 = String(matches[0][4] || '').toUpperCase(); | ||
if (h0 === 'E' || h0 === 'W') { | if (h0 === 'E' || h0 === 'W') { | ||
var tmp = | var tmp = lat2; lat2 = lon2; lon2 = tmp; | ||
} | } | ||
if ( | if (lat2 !== null && lon2 !== null) return { lat: lat2, lon: lon2 }; | ||
} | } | ||
return null; | return null; | ||
} | } | ||
function dmsToDecimal(deg, min, sec, hemi) { | function dmsToDecimal(deg, min, sec, hemi) { | ||
var d = parseFloat(deg); | var d = parseFloat(deg); | ||
| Line 281: | Line 296: | ||
} | } | ||
function | // SMW Ask API fetch | ||
function fetchAskGeoJSON(query, cb) { | |||
if ( | if (!window.mw || !mw.loader) { | ||
cb(new Error('MediaWiki mw.loader not found'), null); | |||
return; | |||
} | } | ||
return | |||
mw.loader.using('mediawiki.api').then(function () { | |||
var api = new mw.Api(); | |||
api.post({ | |||
action: 'ask', | |||
query: query, | |||
format: 'json', | |||
formatversion: 2 | |||
}).then(function (data) { | |||
var geojson = askToGeoJSON(data); | |||
cb(null, geojson); | |||
}).catch(function (err) { | |||
console.error('[lw-map] Ask API error:', err); | |||
cb(err, null); | |||
}); | |||
}).catch(function (err) { | |||
console.error('[lw-map] Failed to load mediawiki.api:', err); | |||
cb(err, null); | |||
}); | |||
} | |||
function firstPrintout(printouts, propName) { | |||
var v = printouts ? printouts[propName] : null; | |||
if (!v) return ''; | |||
if (Array.isArray(v)) return v[0] == null ? '' : v[0]; | |||
return v; | |||
} | } | ||
function | function normalizeCoordValue(v) { | ||
if (!v) return ''; | |||
if (typeof v === 'string') return v; | |||
if (typeof v === 'object') { | |||
var lat = v.lat != null ? v.lat : v.latitude; | |||
var | var lon = v.lon != null ? v.lon : v.longitude; | ||
var | if (lat != null && lon != null) return String(lat) + ', ' + String(lon); | ||
// Some SMW setups return objects with value strings inside | |||
if (v.value != null) return String(v.value); | |||
return JSON.stringify(v); | |||
} | } | ||
return String(v); | |||
} | } | ||
function | function askToGeoJSON(data) { | ||
var results = (data && data.query && data.query.results) ? data.query.results : {}; | |||
var features = []; | var features = []; | ||
Object.keys(results).forEach(function (key) { | |||
var | var r = results[key] || {}; | ||
var | var po = r.printouts || {}; | ||
var pageTitle = r.fulltext || key || ''; | |||
var pageUrl = r.fullurl || ('/wiki/' + encodeURIComponent(String(pageTitle).replace(/ /g, '_'))); | |||
var coordRaw = normalizeCoordValue(firstPrintout(po, 'Has GPS coordinates')); | |||
var | var coords = parseCoords(coordRaw); | ||
if (!coords) return; | |||
var name = | |||
var status = | var name = firstPrintout(po, 'Has descriptive name') || pageTitle; | ||
var accessionId = | var status = firstPrintout(po, 'Has conservation priority') || ''; | ||
var accessionId = firstPrintout(po, 'Has accession ID') || ''; | |||
features.push({ | features.push({ | ||
type: 'Feature', | type: 'Feature', | ||
| Line 355: | Line 374: | ||
name: name, | name: name, | ||
status: status, | status: status, | ||
page_url: | page_url: pageUrl | ||
}, | }, | ||
geometry: { | geometry: { | ||
| Line 363: | Line 382: | ||
}); | }); | ||
}); | }); | ||
console.log('[lw-map] Converted', features.length, 'features from | console.log('[lw-map] Converted', features.length, 'features from Ask JSON'); | ||
return { type: 'FeatureCollection', features: features }; | return { type: 'FeatureCollection', features: features }; | ||
} | } | ||
| Line 411: | Line 394: | ||
injectStyles(); | injectStyles(); | ||
var map = L.map(el, { | var map = L.map(el, { | ||
minZoom: 2, | minZoom: 2, | ||
maxZoom: 17, | maxZoom: 17, | ||
zoomControl: false | zoomControl: false | ||
}).setView([26.4, 89.5], 8); | }).setView([26.4, 89.5], 8); | ||
// Add zoom control to top-right | // Add zoom control to top-right | ||
L.control.zoom({ position: 'topright' }).addTo(map); | L.control.zoom({ position: 'topright' }).addTo(map); | ||
// Base layers | // Base layers | ||
var baseLayers = { | var baseLayers = { | ||
basic: L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { | basic: L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { | ||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/attributions">CARTO</a>', | attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/attributions">CARTO</a>', | ||
subdomains: 'abcd', | subdomains: 'abcd', | ||
| Line 432: | Line 415: | ||
}) | }) | ||
}; | }; | ||
// Start with basic layer | // Start with basic layer | ||
baseLayers.basic.addTo(map); | baseLayers.basic.addTo(map); | ||
var currentLayer = 'basic'; | var currentLayer = 'basic'; | ||
// Custom layer toggle control | // Custom layer toggle control | ||
var layerToggle = L.control({ position: 'topleft' }); | var layerToggle = L.control({ position: 'topleft' }); | ||
layerToggle.onAdd = function() { | layerToggle.onAdd = function () { | ||
var container = L.DomUtil.create('div', 'lw-layer-toggle'); | var container = L.DomUtil.create('div', 'lw-layer-toggle'); | ||
container.innerHTML = | container.innerHTML = | ||
'<button class="lw-layer-btn lw-layer-btn--active" data-layer="basic" title="Basic view">' + | '<button class="lw-layer-btn lw-layer-btn--active" data-layer="basic" title="Basic view">' + | ||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>' + | '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>' + | ||
| Line 448: | Line 431: | ||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 21l4-10 4 10M12 11V3M4 21l4.5-9L12 17l3.5-5L20 21"/></svg>' + | '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 21l4-10 4 10M12 11V3M4 21l4.5-9L12 17l3.5-5L20 21"/></svg>' + | ||
'</button>'; | '</button>'; | ||
L.DomEvent.disableClickPropagation(container); | L.DomEvent.disableClickPropagation(container); | ||
container.querySelectorAll('.lw-layer-btn').forEach(function(btn) { | container.querySelectorAll('.lw-layer-btn').forEach(function (btn) { | ||
btn.addEventListener('click', function() { | btn.addEventListener('click', function () { | ||
var layer = this.dataset.layer; | var layer = this.dataset.layer; | ||
if (layer === currentLayer) return; | if (layer === currentLayer) return; | ||
map.removeLayer(baseLayers[currentLayer]); | map.removeLayer(baseLayers[currentLayer]); | ||
baseLayers[layer].addTo(map); | baseLayers[layer].addTo(map); | ||
currentLayer = layer; | currentLayer = layer; | ||
container.querySelectorAll('.lw-layer-btn').forEach(function (b) { | |||
container.querySelectorAll('.lw-layer-btn').forEach(function(b) { | |||
b.classList.remove('lw-layer-btn--active'); | b.classList.remove('lw-layer-btn--active'); | ||
}); | }); | ||
| Line 470: | Line 449: | ||
}); | }); | ||
}); | }); | ||
return container; | return container; | ||
}; | }; | ||
| Line 481: | Line 460: | ||
} | } | ||
console.log('[lw-map] Fetching Ask JSON for query:', query); | |||
fetchAskGeoJSON(query, function (err, geojson) { | |||
if (err || !geojson || !geojson.features || !geojson.features.length) { | if (err || !geojson || !geojson.features || !geojson.features.length) { | ||
var msg = L.control({ position: 'bottomleft' }); | var msg = L.control({ position: 'bottomleft' }); | ||
| Line 496: | Line 477: | ||
var layer = L.geoJSON(geojson, { | var layer = L.geoJSON(geojson, { | ||
pointToLayer: function (f, ll) { | pointToLayer: function (f, ll) { | ||
return L.circleMarker(ll, { | return L.circleMarker(ll, { | ||
radius: 8, | radius: 8, | ||
color: BRAND.white, | color: BRAND.white, | ||
weight: 2.5, | weight: 2.5, | ||
fillColor: BRAND.primary, | fillColor: BRAND.primary, | ||
fillOpacity: 0.9 | fillOpacity: 0.9 | ||
}); | }); | ||
| Line 506: | Line 487: | ||
onEachFeature: function (f, ly) { | onEachFeature: function (f, ly) { | ||
var p = f.properties || {}; | var p = f.properties || {}; | ||
ly.bindPopup(popupForAccession(p), { | ly.bindPopup(popupForAccession(p), { | ||
className: 'lw-popup', | className: 'lw-popup', | ||
| Line 514: | Line 494: | ||
offset: [0, -5] | offset: [0, -5] | ||
}); | }); | ||
var tip = p.name || p.id || ''; | var tip = p.name || p.id || ''; | ||
if (tip) { | if (tip) { | ||
ly.bindTooltip(esc(tip), { | ly.bindTooltip(esc(tip), { | ||
direction: 'top', | direction: 'top', | ||
offset: [0, -10], | offset: [0, -10], | ||
opacity: 1, | opacity: 1, | ||
className: 'lw-map-tooltip' | className: 'lw-map-tooltip' | ||
}); | }); | ||
} | } | ||
ly.on('mouseover', function () { | |||
ly.on('mouseover', function() { | this.setStyle({ | ||
this.setStyle({ | |||
fillColor: BRAND.primaryLight, | fillColor: BRAND.primaryLight, | ||
radius: 10 | radius: 10 | ||
}); | }); | ||
}); | }); | ||
ly.on('mouseout', function() { | ly.on('mouseout', function () { | ||
this.setStyle({ | this.setStyle({ | ||
fillColor: BRAND.primary, | fillColor: BRAND.primary, | ||
radius: 8 | radius: 8 | ||
| Line 556: | Line 534: | ||
init(); | init(); | ||
if (window.mw && mw.hook) { | if (window.mw && mw.hook) { | ||
mw.hook('wikipage.content').add(function ($c) { | mw.hook('wikipage.content').add(function ($c) { | ||
init($c && $c[0] ? $c[0] : document); | init($c && $c[0] ? $c[0] : document); | ||
}); | }); | ||
} | } | ||
}); | }); | ||
})(); | })(); | ||
Revision as of 17:32, 11 January 2026
/* Landrace.wiki – Leaflet map w/ SMW Ask API (JSON) (Styled) */
(function () {
var LVER = '1.9.4';
var CDN = 'https://unpkg.com/leaflet@' + LVER + '/dist/';
// Brand colors
var BRAND = {
primary: '#2d6a4f', // Deep forest green
primaryLight: '#40916c', // Lighter green for hover
accent: '#74c69d', // Soft green accent
dark: '#1b4332', // Dark green
neutral: '#495057', // Text gray
light: '#f8f9fa', // Light background
white: '#ffffff'
};
function addCSS(href, id) {
if (id && document.getElementById(id)) return;
var l = document.createElement('link');
l.rel = 'stylesheet';
l.href = href;
if (id) l.id = id;
document.head.appendChild(l);
}
function addJS(src, cb) {
if (window.L && typeof window.L.map === 'function') return cb();
var s = document.querySelector('script[data-lw-leaflet="1"]');
if (s) { s.addEventListener('load', cb); return; }
s = document.createElement('script');
s.src = src;
s.setAttribute('data-lw-leaflet', '1');
s.onload = cb;
s.onerror = function () { console.error('[lw-map] Leaflet failed to load:', src); };
document.head.appendChild(s);
}
// Inject custom popup styles
function injectStyles() {
if (document.getElementById('lw-map-styles')) return;
var style = document.createElement('style');
style.id = 'lw-map-styles';
style.textContent = [
'.lw-popup .leaflet-popup-content-wrapper {',
' background: ' + BRAND.white + ';',
' border-radius: 12px;',
' box-shadow: 0 4px 20px rgba(0,0,0,0.12), 0 2px 6px rgba(0,0,0,0.08);',
' padding: 0;',
' overflow: hidden;',
'}',
'.lw-popup .leaflet-popup-content {',
' margin: 0;',
' min-width: 220px;',
' max-width: 280px;',
'}',
'.lw-popup .leaflet-popup-tip {',
' background: ' + BRAND.white + ';',
' box-shadow: 0 2px 6px rgba(0,0,0,0.1);',
'}',
'.lw-popup .leaflet-popup-close-button {',
' color: ' + BRAND.neutral + ' !important;',
' font-size: 20px !important;',
' padding: 8px 10px !important;',
' right: 4px !important;',
' top: 4px !important;',
' transition: color 0.2s ease;',
'}',
'.lw-popup .leaflet-popup-close-button:hover {',
' color: ' + BRAND.dark + ' !important;',
'}',
'.lw-popup-inner {',
' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;',
'}',
'.lw-popup-header {',
' background: linear-gradient(135deg, ' + BRAND.primary + ' 0%, ' + BRAND.dark + ' 100%);',
' padding: 16px 18px;',
' color: ' + BRAND.white + ';',
'}',
'.lw-popup-id {',
' font-size: 10px;',
' font-weight: 600;',
' letter-spacing: 0.5px;',
' text-transform: uppercase;',
' opacity: 0.85;',
' margin-bottom: 4px;',
'}',
'.lw-popup-name {',
' font-size: 15px;',
' font-weight: 600;',
' line-height: 1.3;',
'}',
'.lw-popup-body {',
' padding: 14px 18px;',
'}',
'.lw-popup-meta {',
' display: flex;',
' flex-direction: column;',
' align-items: flex-start;',
' gap: 6px;',
' margin-bottom: 8px;',
'}',
'.lw-popup-label {',
' font-size: 11px;',
' color: ' + BRAND.neutral + ';',
' font-weight: 500;',
' text-transform: uppercase;',
' letter-spacing: 0.3px;',
'}',
'.lw-popup-status {',
' display: inline-flex;',
' align-items: center;',
' padding: 4px 10px;',
' border-radius: 20px;',
' font-size: 11px;',
' font-weight: 600;',
' background: ' + BRAND.light + ';',
' color: ' + BRAND.neutral + ';',
'}',
'.lw-popup-status::before {',
' content: "";',
' width: 6px;',
' height: 6px;',
' border-radius: 50%;',
' margin-right: 6px;',
' background: currentColor;',
'}',
'.lw-popup-status--critical { color: #c92a2a; background: #fff5f5; }',
'.lw-popup-status--high { color: #d9480f; background: #fff4e6; }',
'.lw-popup-status--medium { color: #e67700; background: #fff9db; }',
'.lw-popup-status--low { color: #2b8a3e; background: #ebfbee; }',
'.lw-popup-link {',
' display: block;',
' text-align: center;',
' padding: 10px 18px;',
' background: ' + BRAND.light + ';',
' color: ' + BRAND.primary + ';',
' text-decoration: none;',
' font-size: 13px;',
' font-weight: 600;',
' transition: background 0.2s ease, color 0.2s ease;',
' border-top: 1px solid #e9ecef;',
'}',
'.lw-popup-link:hover {',
' background: ' + BRAND.primary + ';',
' color: ' + BRAND.white + ';',
'}',
'.lw-map-tooltip {',
' background: ' + BRAND.dark + ' !important;',
' border: none !important;',
' border-radius: 6px !important;',
' color: ' + BRAND.white + ' !important;',
' font-size: 12px !important;',
' font-weight: 500 !important;',
' padding: 6px 10px !important;',
' box-shadow: 0 2px 8px rgba(0,0,0,0.2) !important;',
'}',
'.lw-map-tooltip::before {',
' border-top-color: ' + BRAND.dark + ' !important;',
'}',
'.lw-layer-toggle {',
' display: flex;',
' background: ' + BRAND.white + ';',
' border-radius: 8px;',
' box-shadow: 0 2px 8px rgba(0,0,0,0.12);',
' overflow: hidden;',
'}',
'.lw-layer-btn {',
' display: flex;',
' align-items: center;',
' justify-content: center;',
' width: 36px;',
' height: 36px;',
' border: none;',
' background: ' + BRAND.white + ';',
' color: ' + BRAND.neutral + ';',
' cursor: pointer;',
' transition: all 0.2s ease;',
'}',
'.lw-layer-btn:hover {',
' background: ' + BRAND.light + ';',
' color: ' + BRAND.primary + ';',
'}',
'.lw-layer-btn--active {',
' background: ' + BRAND.primary + ' !important;',
' color: ' + BRAND.white + ' !important;',
'}',
'.lw-layer-btn:first-child {',
' border-right: 1px solid #e9ecef;',
'}',
'.lw-layer-btn svg {',
' pointer-events: none;',
'}'
].join('\n');
document.head.appendChild(style);
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]);
});
}
function statusClass(s) {
var status = String(s || '').toLowerCase();
if (status === 'critical') return 'lw-popup-status--critical';
if (status === 'high') return 'lw-popup-status--high';
if (status === 'medium') return 'lw-popup-status--medium';
if (status === 'low') return 'lw-popup-status--low';
return '';
}
function popupForAccession(p) {
var id = p.id || '';
var name = p.name || 'Unknown';
var status = p.status || '';
var url = p.page_url || '#';
var statusHtml = '';
if (status) {
statusHtml = '<div class="lw-popup-meta">' +
'<span class="lw-popup-label">Conservation Priority</span>' +
'<span class="lw-popup-status ' + statusClass(status) + '">' + esc(status) + '</span>' +
'</div>';
}
return '<div class="lw-popup-inner">' +
'<div class="lw-popup-header">' +
(id ? '<div class="lw-popup-id">' + esc(id) + '</div>' : '') +
'<div class="lw-popup-name">' + esc(name) + '</div>' +
'</div>' +
'<div class="lw-popup-body">' +
statusHtml +
'</div>' +
'<a href="' + esc(url) + '" class="lw-popup-link">View Accession →</a>' +
'</div>';
}
// Parse coordinates from various formats
function parseCoords(coordStr) {
if (!coordStr) return null;
// Decimal "lat, lon"
var decimalMatch = String(coordStr).match(/(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)/);
if (decimalMatch) {
var a = parseFloat(decimalMatch[1]);
var b = parseFloat(decimalMatch[2]);
if (!isNaN(a) && !isNaN(b)) {
// Sanity check, swap if obviously reversed
var lat = a, lon = b;
var aLooksLon = Math.abs(a) > 90 && Math.abs(a) <= 180;
var bLooksLat = Math.abs(b) <= 90;
if (aLooksLon && bLooksLat) {
lat = b;
lon = a;
}
if (Math.abs(lat) <= 90 && Math.abs(lon) <= 180) return { lat: lat, lon: lon };
}
}
// DMS like 26° 12' 00" N 89° 30' 00" E
var dmsRegex = /(\d+)[°]\s*(\d+)[′']\s*(\d+\.?\d*)[″"]?\s*([NSEW])/gi;
var matches = [];
var m;
while ((m = dmsRegex.exec(String(coordStr))) !== null) {
matches.push(m);
if (matches.length >= 2) break;
}
if (matches.length >= 2) {
var lat2 = dmsToDecimal(matches[0][1], matches[0][2], matches[0][3], matches[0][4]);
var lon2 = dmsToDecimal(matches[1][1], matches[1][2], matches[1][3], matches[1][4]);
var h0 = String(matches[0][4] || '').toUpperCase();
if (h0 === 'E' || h0 === 'W') {
var tmp = lat2; lat2 = lon2; lon2 = tmp;
}
if (lat2 !== null && lon2 !== null) return { lat: lat2, lon: lon2 };
}
return null;
}
function dmsToDecimal(deg, min, sec, hemi) {
var d = parseFloat(deg);
var m = parseFloat(min) || 0;
var s = parseFloat(sec) || 0;
if (isNaN(d)) return null;
var dec = Math.abs(d) + (m / 60) + (s / 3600);
hemi = String(hemi || '').toUpperCase();
if (hemi === 'S' || hemi === 'W') dec = -dec;
return dec;
}
// SMW Ask API fetch
function fetchAskGeoJSON(query, cb) {
if (!window.mw || !mw.loader) {
cb(new Error('MediaWiki mw.loader not found'), null);
return;
}
mw.loader.using('mediawiki.api').then(function () {
var api = new mw.Api();
api.post({
action: 'ask',
query: query,
format: 'json',
formatversion: 2
}).then(function (data) {
var geojson = askToGeoJSON(data);
cb(null, geojson);
}).catch(function (err) {
console.error('[lw-map] Ask API error:', err);
cb(err, null);
});
}).catch(function (err) {
console.error('[lw-map] Failed to load mediawiki.api:', err);
cb(err, null);
});
}
function firstPrintout(printouts, propName) {
var v = printouts ? printouts[propName] : null;
if (!v) return '';
if (Array.isArray(v)) return v[0] == null ? '' : v[0];
return v;
}
function normalizeCoordValue(v) {
if (!v) return '';
if (typeof v === 'string') return v;
if (typeof v === 'object') {
var lat = v.lat != null ? v.lat : v.latitude;
var lon = v.lon != null ? v.lon : v.longitude;
if (lat != null && lon != null) return String(lat) + ', ' + String(lon);
// Some SMW setups return objects with value strings inside
if (v.value != null) return String(v.value);
return JSON.stringify(v);
}
return String(v);
}
function askToGeoJSON(data) {
var results = (data && data.query && data.query.results) ? data.query.results : {};
var features = [];
Object.keys(results).forEach(function (key) {
var r = results[key] || {};
var po = r.printouts || {};
var pageTitle = r.fulltext || key || '';
var pageUrl = r.fullurl || ('/wiki/' + encodeURIComponent(String(pageTitle).replace(/ /g, '_')));
var coordRaw = normalizeCoordValue(firstPrintout(po, 'Has GPS coordinates'));
var coords = parseCoords(coordRaw);
if (!coords) return;
var name = firstPrintout(po, 'Has descriptive name') || pageTitle;
var status = firstPrintout(po, 'Has conservation priority') || '';
var accessionId = firstPrintout(po, 'Has accession ID') || '';
features.push({
type: 'Feature',
properties: {
id: accessionId,
name: name,
status: status,
page_url: pageUrl
},
geometry: {
type: 'Point',
coordinates: [coords.lon, coords.lat]
}
});
});
console.log('[lw-map] Converted', features.length, 'features from Ask JSON');
return { type: 'FeatureCollection', features: features };
}
function initOne(el) {
if (el.dataset.init) return;
el.dataset.init = '1';
if (el.clientHeight < 100) el.style.height = '70vh';
injectStyles();
var map = L.map(el, {
minZoom: 2,
maxZoom: 17,
zoomControl: false
}).setView([26.4, 89.5], 8);
// Add zoom control to top-right
L.control.zoom({ position: 'topright' }).addTo(map);
// Base layers
var baseLayers = {
basic: L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 19
}),
terrain: L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}', {
attribution: '© <a href="https://www.esri.com">Esri</a>',
maxZoom: 18
})
};
// Start with basic layer
baseLayers.basic.addTo(map);
var currentLayer = 'basic';
// Custom layer toggle control
var layerToggle = L.control({ position: 'topleft' });
layerToggle.onAdd = function () {
var container = L.DomUtil.create('div', 'lw-layer-toggle');
container.innerHTML =
'<button class="lw-layer-btn lw-layer-btn--active" data-layer="basic" title="Basic view">' +
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>' +
'</button>' +
'<button class="lw-layer-btn" data-layer="terrain" title="Terrain view">' +
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 21l4-10 4 10M12 11V3M4 21l4.5-9L12 17l3.5-5L20 21"/></svg>' +
'</button>';
L.DomEvent.disableClickPropagation(container);
container.querySelectorAll('.lw-layer-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var layer = this.dataset.layer;
if (layer === currentLayer) return;
map.removeLayer(baseLayers[currentLayer]);
baseLayers[layer].addTo(map);
currentLayer = layer;
container.querySelectorAll('.lw-layer-btn').forEach(function (b) {
b.classList.remove('lw-layer-btn--active');
});
this.classList.add('lw-layer-btn--active');
});
});
return container;
};
layerToggle.addTo(map);
var query = el.dataset.smwQuery;
if (!query) {
console.log('[lw-map] No data-smw-query attribute found');
return;
}
console.log('[lw-map] Fetching Ask JSON for query:', query);
fetchAskGeoJSON(query, function (err, geojson) {
if (err || !geojson || !geojson.features || !geojson.features.length) {
var msg = L.control({ position: 'bottomleft' });
msg.onAdd = function () {
var d = L.DomUtil.create('div');
d.style.cssText = 'background:white;padding:12px 16px;border-radius:8px;font-size:13px;color:#495057;box-shadow:0 2px 8px rgba(0,0,0,0.1);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
d.innerHTML = '<strong>No accessions found</strong><br><span style="opacity:0.7;">No mappable results for this query.</span>';
return d;
};
msg.addTo(map);
return;
}
var layer = L.geoJSON(geojson, {
pointToLayer: function (f, ll) {
return L.circleMarker(ll, {
radius: 8,
color: BRAND.white,
weight: 2.5,
fillColor: BRAND.primary,
fillOpacity: 0.9
});
},
onEachFeature: function (f, ly) {
var p = f.properties || {};
ly.bindPopup(popupForAccession(p), {
className: 'lw-popup',
closeButton: true,
maxWidth: 300,
offset: [0, -5]
});
var tip = p.name || p.id || '';
if (tip) {
ly.bindTooltip(esc(tip), {
direction: 'top',
offset: [0, -10],
opacity: 1,
className: 'lw-map-tooltip'
});
}
ly.on('mouseover', function () {
this.setStyle({
fillColor: BRAND.primaryLight,
radius: 10
});
});
ly.on('mouseout', function () {
this.setStyle({
fillColor: BRAND.primary,
radius: 8
});
});
}
}).addTo(map);
var b = layer.getBounds();
if (b && b.isValid()) map.fitBounds(b.pad(0.3), { maxZoom: 11 });
setTimeout(function () { map.invalidateSize(true); }, 100);
});
}
function init(root) {
(root || document).querySelectorAll('.lw-map').forEach(initOne);
}
addCSS(CDN + 'leaflet.css', 'leaflet-css');
addJS(CDN + 'leaflet.js', function () {
init();
if (window.mw && mw.hook) {
mw.hook('wikipage.content').add(function ($c) {
init($c && $c[0] ? $c[0] : document);
});
}
});
})();