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 |
||
| Line 46: | Line 46: | ||
}); | }); | ||
/* Landrace.wiki – Leaflet map w/ SMW CSV export (Styled) */ | /* Landrace.wiki – Leaflet map w/ SMW CSV export (Styled + Clustering) */ | ||
(function () { | (function () { | ||
var LVER = '1.9.4'; | var LVER = '1.9.4'; | ||
var CDN = 'https://unpkg.com/leaflet@' + LVER + '/dist/'; | var CDN = 'https://unpkg.com/leaflet@' + LVER + '/dist/'; | ||
var CLUSTER_CDN = 'https://unpkg.com/leaflet.markercluster@1.4.1/dist/'; | |||
// Brand colors | // Brand colors | ||
var BRAND = { | var BRAND = { | ||
primary: '#2d6a4f', | primary: '#2d6a4f', | ||
primaryLight: '#40916c', | primaryLight: '#40916c', | ||
accent: '#74c69d', | accent: '#74c69d', | ||
dark: '#1b4332', | dark: '#1b4332', | ||
neutral: '#495057', | neutral: '#495057', | ||
light: '#f8f9fa', | light: '#f8f9fa', | ||
white: '#ffffff' | white: '#ffffff' | ||
}; | }; | ||
// Status colors for clusters | |||
var STATUS_COLORS = { | |||
'Critical': '#c92a2a', | |||
'High': '#d9480f', | |||
'Medium': '#e67700', | |||
'Low': '#2b8a3e' | |||
}; | |||
// Status priority (lower = worse) | |||
var STATUS_PRIORITY = ['Critical', 'High', 'Medium', 'Low']; | |||
function addCSS(href, id) { | function addCSS(href, id) { | ||
| Line 71: | Line 83: | ||
} | } | ||
function addJS(src, cb) { | function addJS(src, cb, id) { | ||
var existing = id ? document.getElementById(id) : document.querySelector('script[src="' + src + '"]'); | |||
if (existing) { | |||
if ( | if (existing.dataset.loaded === '1') return cb(); | ||
s = document.createElement('script'); | existing.addEventListener('load', cb); | ||
return; | |||
} | |||
var s = document.createElement('script'); | |||
s.src = src; | s.src = src; | ||
s. | if (id) s.id = id; | ||
s.onload = function() { | |||
s.onerror = function () { console.error('[lw-map] | s.dataset.loaded = '1'; | ||
cb(); | |||
}; | |||
s.onerror = function () { console.error('[lw-map] Script failed to load:', src); }; | |||
document.head.appendChild(s); | document.head.appendChild(s); | ||
} | } | ||
// Inject custom popup styles | // Get worst status from array of markers | ||
function getWorstStatus(markers) { | |||
var worst = 'Low'; | |||
var worstIndex = STATUS_PRIORITY.indexOf(worst); | |||
markers.forEach(function(marker) { | |||
var status = marker.options.status || 'Low'; | |||
var index = STATUS_PRIORITY.indexOf(status); | |||
if (index !== -1 && index < worstIndex) { | |||
worst = status; | |||
worstIndex = index; | |||
} | |||
}); | |||
return worst; | |||
} | |||
// Inject custom popup and cluster styles | |||
function injectStyles() { | function injectStyles() { | ||
if (document.getElementById('lw-map-styles')) return; | if (document.getElementById('lw-map-styles')) return; | ||
| Line 232: | Line 267: | ||
' color: ' + BRAND.white + ' !important;', | ' color: ' + BRAND.white + ' !important;', | ||
'}', | '}', | ||
'.lw-layer-btn:not(:last-child) {', | |||
' border-right: 1px solid #e9ecef;', | ' border-right: 1px solid #e9ecef;', | ||
'}', | '}', | ||
'.lw-layer-btn svg {', | '.lw-layer-btn svg {', | ||
' pointer-events: none;', | ' pointer-events: none;', | ||
'}', | |||
'/* Cluster styles */', | |||
'.lw-cluster-icon {', | |||
' background: transparent !important;', | |||
'}', | |||
'.lw-cluster {', | |||
' width: 40px;', | |||
' height: 40px;', | |||
' border-radius: 50%;', | |||
' color: #fff;', | |||
' font-weight: 700;', | |||
' font-size: 14px;', | |||
' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;', | |||
' display: flex;', | |||
' align-items: center;', | |||
' justify-content: center;', | |||
' box-shadow: 0 3px 10px rgba(0,0,0,0.25);', | |||
' border: 3px solid rgba(255,255,255,0.9);', | |||
' transition: transform 0.2s ease;', | |||
'}', | |||
'.lw-cluster:hover {', | |||
' transform: scale(1.1);', | |||
'}', | |||
'.lw-cluster--small {', | |||
' width: 36px;', | |||
' height: 36px;', | |||
' font-size: 13px;', | |||
'}', | |||
'.lw-cluster--large {', | |||
' width: 48px;', | |||
' height: 48px;', | |||
' font-size: 15px;', | |||
'}', | |||
'/* Override default MarkerCluster styles */', | |||
'.marker-cluster {', | |||
' background: transparent !important;', | |||
'}', | |||
'.marker-cluster div {', | |||
' background: transparent !important;', | |||
'}' | '}' | ||
].join('\n'); | ].join('\n'); | ||
| Line 283: | Line 357: | ||
} | } | ||
function parseCoords(coordStr) { | function parseCoords(coordStr) { | ||
if (!coordStr) return null; | if (!coordStr) return null; | ||
| Line 415: | Line 488: | ||
} | } | ||
function buildCSVUrl(query) { | function buildCSVUrl(query) { | ||
var parts = query.split('|'); | var parts = query.split('|'); | ||
var encodedParts = parts.map(function(part) { | var encodedParts = parts.map(function(part) { | ||
| Line 429: | Line 502: | ||
var baseUrl = mw.config.get('wgScriptPath') || ''; | var baseUrl = mw.config.get('wgScriptPath') || ''; | ||
return baseUrl + '/wiki/Special:Ask/' + encodedParts.join('/') + '/format=csv'; | return baseUrl + '/wiki/Special:Ask/' + encodedParts.join('/') + '/format=csv'; | ||
} | } | ||
function fetchCSV(query, cb) { | function fetchCSV(query, cb) { | ||
| Line 463: | Line 536: | ||
minZoom: 2, | minZoom: 2, | ||
maxZoom: 17, | maxZoom: 17, | ||
zoomControl: false | zoomControl: false | ||
}).setView([26.4, 89.5], 8); | }).setView([26.4, 89.5], 8); | ||
// Store map instance on element | |||
el._leaflet_map = map; | |||
// 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', { | |||
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 | |||
}), | |||
satellite: L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { | |||
attribution: '© <a href="https://www.esri.com">Esri</a>', | |||
maxZoom: 18 | |||
}) | |||
}; | }; | ||
// Start with basic layer | // Start with basic layer | ||
| Line 495: | Line 571: | ||
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">' + | |||
'<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>' + | |||
'<button class="lw-layer-btn" data-layer="satellite" title="Satellite view">' + | |||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20M2 12h20"/></svg>' + | |||
'</button>'; | |||
L.DomEvent.disableClickPropagation(container); | L.DomEvent.disableClickPropagation(container); | ||
| Line 512: | Line 588: | ||
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 550: | Line 622: | ||
} | } | ||
var layer = L.geoJSON(geojson, { | // Check if this is an infobox map (small) or full map | ||
var isInfoboxMap = el.closest('.lw-infobox') !== null; | |||
return L.circleMarker( | |||
if (isInfoboxMap || !window.L.markerClusterGroup) { | |||
// For infobox maps or if clustering not loaded, use simple markers | |||
var layer = L.geoJSON(geojson, { | |||
pointToLayer: function (f, ll) { | |||
var status = f.properties.status || ''; | |||
var color = STATUS_COLORS[status] || BRAND.primary; | |||
return L.circleMarker(ll, { | |||
radius: 8, | |||
color: BRAND.white, | |||
weight: 2.5, | |||
fillColor: color, | |||
fillOpacity: 0.9, | |||
status: status | |||
}); | |||
}, | |||
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() { | |||
var status = f.properties.status || ''; | |||
var color = STATUS_COLORS[status] || BRAND.primary; | |||
this.setStyle({ | |||
fillColor: color, | |||
radius: 8 | |||
}); | |||
}); | |||
} | |||
}).addTo(map); | |||
var b = layer.getBounds(); | |||
if (b && b.isValid()) map.fitBounds(b.pad(0.3), { maxZoom: 11 }); | |||
} else { | |||
// For full maps, use clustering | |||
var markers = L.markerClusterGroup({ | |||
maxClusterRadius: 50, | |||
spiderfyOnMaxZoom: true, | |||
showCoverageOnHover: false, | |||
zoomToBoundsOnClick: true, | |||
iconCreateFunction: function(cluster) { | |||
var children = cluster.getAllChildMarkers(); | |||
var count = children.length; | |||
var worstStatus = getWorstStatus(children); | |||
var color = STATUS_COLORS[worstStatus] || BRAND.primary; | |||
var sizeClass = 'lw-cluster'; | |||
if (count < 10) sizeClass += ' lw-cluster--small'; | |||
else if (count >= 50) sizeClass += ' lw-cluster--large'; | |||
return L.divIcon({ | |||
html: '<div class="' + sizeClass + '" style="background:' + color + '">' + count + '</div>', | |||
className: 'lw-cluster-icon', | |||
iconSize: [40, 40] | |||
}); | |||
} | |||
}); | |||
geojson.features.forEach(function(f) { | |||
var coords = f.geometry.coordinates; | |||
var p = f.properties; | |||
var status = p.status || ''; | |||
var color = STATUS_COLORS[status] || BRAND.primary; | |||
var marker = L.circleMarker([coords[1], coords[0]], { | |||
radius: 8, | radius: 8, | ||
color: BRAND.white, | color: BRAND.white, | ||
weight: 2.5, | weight: 2.5, | ||
fillColor: | fillColor: color, | ||
fillOpacity: 0.9 | fillOpacity: 0.9, | ||
status: status | |||
}); | }); | ||
marker.bindPopup(popupForAccession(p), { | |||
className: 'lw-popup', | className: 'lw-popup', | ||
closeButton: true, | closeButton: true, | ||
| Line 571: | Line 726: | ||
}); | }); | ||
var tip = p.name || p.id || ''; | var tip = p.name || p.id || ''; | ||
if (tip) { | if (tip) { | ||
marker.bindTooltip(esc(tip), { | |||
direction: 'top', | direction: 'top', | ||
offset: [0, -10], | offset: [0, -10], | ||
| Line 582: | Line 736: | ||
} | } | ||
marker.on('mouseover', function() { | |||
this.setStyle({ | this.setStyle({ | ||
fillColor: BRAND.primaryLight, | fillColor: BRAND.primaryLight, | ||
| Line 589: | Line 742: | ||
}); | }); | ||
}); | }); | ||
marker.on('mouseout', function() { | |||
this.setStyle({ | this.setStyle({ | ||
fillColor: | fillColor: color, | ||
radius: 8 | radius: 8 | ||
}); | }); | ||
}); | }); | ||
markers.addLayer(marker); | |||
}); | |||
map.addLayer(markers); | |||
var b = markers.getBounds(); | |||
if (b && b.isValid()) map.fitBounds(b.pad(0.3), { maxZoom: 11 }); | |||
} | |||
setTimeout(function () { map.invalidateSize(true); }, 100); | setTimeout(function () { map.invalidateSize(true); }, 100); | ||
}); | }); | ||
| Line 608: | Line 766: | ||
} | } | ||
// Load Leaflet CSS | |||
addCSS(CDN + 'leaflet.css', 'leaflet-css'); | addCSS(CDN + 'leaflet.css', 'leaflet-css'); | ||
// Load MarkerCluster CSS | |||
addCSS(CLUSTER_CDN + 'MarkerCluster.css', 'markercluster-css'); | |||
addCSS(CLUSTER_CDN + 'MarkerCluster.Default.css', 'markercluster-default-css'); | |||
// Load Leaflet JS, then MarkerCluster JS, then init | |||
addJS(CDN + 'leaflet.js', function () { | addJS(CDN + 'leaflet.js', function () { | ||
init(); | addJS(CLUSTER_CDN + 'leaflet.markercluster.js', function() { | ||
init(); | |||
if (window.mw && mw.hook) { | |||
mw.hook('wikipage.content').add(function ($c) { | |||
init($c && $c[0] ? $c[0] : document); | |||
} | }); | ||
}); | } | ||
}, 'markercluster-js'); | |||
}, 'leaflet-js'); | |||
})(); | })(); | ||
Revision as of 21:51, 12 January 2026
/* Infobox map expand toggle */
$(function() {
// Create overlay container once
var $overlay = $('<div class="lw-map-overlay"></div>').appendTo('body');
$('.lw-infobox__map-expand').on('click', function() {
var $mapContainer = $(this).closest('.lw-infobox__map-container');
var $map = $mapContainer.find('.lw-infobox__map');
if ($overlay.hasClass('lw-map-overlay--active')) {
// Close: move map back
$map.appendTo($mapContainer);
$overlay.removeClass('lw-map-overlay--active');
$('body').css('overflow', '');
} else {
// Open: move map to overlay
$map.appendTo($overlay);
$overlay.addClass('lw-map-overlay--active');
$('body').css('overflow', 'hidden');
}
// Trigger Leaflet resize
setTimeout(function() {
$map.each(function() {
var mapEl = this;
if (mapEl._leaflet_map) {
mapEl._leaflet_map.invalidateSize();
}
});
}, 100);
});
// Close on ESC
$(document).on('keydown', function(e) {
if (e.key === 'Escape' && $overlay.hasClass('lw-map-overlay--active')) {
$('.lw-infobox__map-expand').trigger('click');
}
});
// Close on overlay background click
$overlay.on('click', function(e) {
if (e.target === this) {
$('.lw-infobox__map-expand').trigger('click');
}
});
});
/* Landrace.wiki – Leaflet map w/ SMW CSV export (Styled + Clustering) */
(function () {
var LVER = '1.9.4';
var CDN = 'https://unpkg.com/leaflet@' + LVER + '/dist/';
var CLUSTER_CDN = 'https://unpkg.com/leaflet.markercluster@1.4.1/dist/';
// Brand colors
var BRAND = {
primary: '#2d6a4f',
primaryLight: '#40916c',
accent: '#74c69d',
dark: '#1b4332',
neutral: '#495057',
light: '#f8f9fa',
white: '#ffffff'
};
// Status colors for clusters
var STATUS_COLORS = {
'Critical': '#c92a2a',
'High': '#d9480f',
'Medium': '#e67700',
'Low': '#2b8a3e'
};
// Status priority (lower = worse)
var STATUS_PRIORITY = ['Critical', 'High', 'Medium', 'Low'];
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, id) {
var existing = id ? document.getElementById(id) : document.querySelector('script[src="' + src + '"]');
if (existing) {
if (existing.dataset.loaded === '1') return cb();
existing.addEventListener('load', cb);
return;
}
var s = document.createElement('script');
s.src = src;
if (id) s.id = id;
s.onload = function() {
s.dataset.loaded = '1';
cb();
};
s.onerror = function () { console.error('[lw-map] Script failed to load:', src); };
document.head.appendChild(s);
}
// Get worst status from array of markers
function getWorstStatus(markers) {
var worst = 'Low';
var worstIndex = STATUS_PRIORITY.indexOf(worst);
markers.forEach(function(marker) {
var status = marker.options.status || 'Low';
var index = STATUS_PRIORITY.indexOf(status);
if (index !== -1 && index < worstIndex) {
worst = status;
worstIndex = index;
}
});
return worst;
}
// Inject custom popup and cluster 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:not(:last-child) {',
' border-right: 1px solid #e9ecef;',
'}',
'.lw-layer-btn svg {',
' pointer-events: none;',
'}',
'/* Cluster styles */',
'.lw-cluster-icon {',
' background: transparent !important;',
'}',
'.lw-cluster {',
' width: 40px;',
' height: 40px;',
' border-radius: 50%;',
' color: #fff;',
' font-weight: 700;',
' font-size: 14px;',
' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;',
' display: flex;',
' align-items: center;',
' justify-content: center;',
' box-shadow: 0 3px 10px rgba(0,0,0,0.25);',
' border: 3px solid rgba(255,255,255,0.9);',
' transition: transform 0.2s ease;',
'}',
'.lw-cluster:hover {',
' transform: scale(1.1);',
'}',
'.lw-cluster--small {',
' width: 36px;',
' height: 36px;',
' font-size: 13px;',
'}',
'.lw-cluster--large {',
' width: 48px;',
' height: 48px;',
' font-size: 15px;',
'}',
'/* Override default MarkerCluster styles */',
'.marker-cluster {',
' background: transparent !important;',
'}',
'.marker-cluster div {',
' background: transparent !important;',
'}'
].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>';
}
function parseCoords(coordStr) {
if (!coordStr) return null;
var decimalMatch = coordStr.match(/(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)/);
if (decimalMatch) {
var lat = parseFloat(decimalMatch[1]);
var lon = parseFloat(decimalMatch[2]);
if (!isNaN(lat) && !isNaN(lon)) return { lat: lat, lon: lon };
}
var dmsRegex = /(\d+)[°]\s*(\d+)[′']\s*(\d+\.?\d*)[″"]?\s*([NSEW])/gi;
var matches = [];
var m;
while ((m = dmsRegex.exec(coordStr)) !== null) {
matches.push(m);
if (matches.length >= 2) break;
}
if (matches.length >= 2) {
var lat = dmsToDecimal(matches[0][1], matches[0][2], matches[0][3], matches[0][4]);
var lon = dmsToDecimal(matches[1][1], matches[1][2], matches[1][3], matches[1][4]);
var h0 = matches[0][4].toUpperCase();
if (h0 === 'E' || h0 === 'W') {
var tmp = lat; lat = lon; lon = tmp;
}
if (lat !== null && lon !== null) return { lat: lat, lon: lon };
}
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;
}
function parseCSV(text) {
var lines = text.trim().split('\n');
if (lines.length < 2) return [];
var headers = parseCSVLine(lines[0]);
var results = [];
for (var i = 1; i < lines.length; i++) {
var values = parseCSVLine(lines[i]);
var obj = {};
for (var j = 0; j < headers.length; j++) {
obj[headers[j]] = values[j] || '';
}
results.push(obj);
}
return results;
}
function parseCSVLine(line) {
var result = [];
var current = '';
var inQuotes = false;
for (var i = 0; i < line.length; i++) {
var c = line[i];
var next = line[i + 1];
if (inQuotes) {
if (c === '"' && next === '"') {
current += '"';
i++;
} else if (c === '"') {
inQuotes = false;
} else {
current += c;
}
} else {
if (c === '"') {
inQuotes = true;
} else if (c === ',') {
result.push(current.trim());
current = '';
} else {
current += c;
}
}
}
result.push(current.trim());
return result;
}
function csvToGeoJSON(data) {
var features = [];
data.forEach(function(row) {
var coordStr = row['Has GPS coordinates'] || row['GPS coordinates'] || '';
var coords = parseCoords(coordStr);
if (!coords) {
console.log('[lw-map] Skipping row, no valid coords:', coordStr);
return;
}
var pageName = row[''] || Object.values(row)[0] || '';
var name = row['Has descriptive name'] || row['descriptive name'] || pageName;
var status = row['Has conservation priority'] || row['conservation priority'] || '';
var accessionId = row['Has accession ID'] || row['accession ID'] || '';
features.push({
type: 'Feature',
properties: {
id: accessionId,
name: name,
status: status,
page_url: '/wiki/' + encodeURIComponent(pageName.replace(/ /g, '_'))
},
geometry: {
type: 'Point',
coordinates: [coords.lon, coords.lat]
}
});
});
console.log('[lw-map] Converted', features.length, 'features from CSV');
return { type: 'FeatureCollection', features: features };
}
function buildCSVUrl(query) {
var parts = query.split('|');
var encodedParts = parts.map(function(part) {
return part
.replace(/-/g, '-2D')
.replace(/\[/g, '-5B')
.replace(/\]/g, '-5D')
.replace(/\?/g, '-3F')
.replace(/:/g, '-3A')
.replace(/ /g, '-20');
});
var baseUrl = mw.config.get('wgScriptPath') || '';
return baseUrl + '/wiki/Special:Ask/' + encodedParts.join('/') + '/format=csv';
}
function fetchCSV(query, cb) {
var url = buildCSVUrl(query);
console.log('[lw-map] Fetching CSV from:', url);
fetch(url)
.then(function(r) {
if (!r.ok) throw new Error('CSV fetch error: ' + r.status);
return r.text();
})
.then(function(text) {
console.log('[lw-map] CSV response length:', text.length);
var data = parseCSV(text);
console.log('[lw-map] Parsed', data.length, 'rows');
var geojson = csvToGeoJSON(data);
cb(null, geojson);
})
.catch(function(err) {
console.error('[lw-map] CSV fetch error:', err);
cb(err, null);
});
}
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);
// Store map instance on element
el._leaflet_map = map;
// 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
}),
satellite: L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/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>' +
'<button class="lw-layer-btn" data-layer="satellite" title="Satellite view">' +
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20M2 12h20"/></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;
}
fetchCSV(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;
}
// Check if this is an infobox map (small) or full map
var isInfoboxMap = el.closest('.lw-infobox') !== null;
if (isInfoboxMap || !window.L.markerClusterGroup) {
// For infobox maps or if clustering not loaded, use simple markers
var layer = L.geoJSON(geojson, {
pointToLayer: function (f, ll) {
var status = f.properties.status || '';
var color = STATUS_COLORS[status] || BRAND.primary;
return L.circleMarker(ll, {
radius: 8,
color: BRAND.white,
weight: 2.5,
fillColor: color,
fillOpacity: 0.9,
status: status
});
},
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() {
var status = f.properties.status || '';
var color = STATUS_COLORS[status] || BRAND.primary;
this.setStyle({
fillColor: color,
radius: 8
});
});
}
}).addTo(map);
var b = layer.getBounds();
if (b && b.isValid()) map.fitBounds(b.pad(0.3), { maxZoom: 11 });
} else {
// For full maps, use clustering
var markers = L.markerClusterGroup({
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
iconCreateFunction: function(cluster) {
var children = cluster.getAllChildMarkers();
var count = children.length;
var worstStatus = getWorstStatus(children);
var color = STATUS_COLORS[worstStatus] || BRAND.primary;
var sizeClass = 'lw-cluster';
if (count < 10) sizeClass += ' lw-cluster--small';
else if (count >= 50) sizeClass += ' lw-cluster--large';
return L.divIcon({
html: '<div class="' + sizeClass + '" style="background:' + color + '">' + count + '</div>',
className: 'lw-cluster-icon',
iconSize: [40, 40]
});
}
});
geojson.features.forEach(function(f) {
var coords = f.geometry.coordinates;
var p = f.properties;
var status = p.status || '';
var color = STATUS_COLORS[status] || BRAND.primary;
var marker = L.circleMarker([coords[1], coords[0]], {
radius: 8,
color: BRAND.white,
weight: 2.5,
fillColor: color,
fillOpacity: 0.9,
status: status
});
marker.bindPopup(popupForAccession(p), {
className: 'lw-popup',
closeButton: true,
maxWidth: 300,
offset: [0, -5]
});
var tip = p.name || p.id || '';
if (tip) {
marker.bindTooltip(esc(tip), {
direction: 'top',
offset: [0, -10],
opacity: 1,
className: 'lw-map-tooltip'
});
}
marker.on('mouseover', function() {
this.setStyle({
fillColor: BRAND.primaryLight,
radius: 10
});
});
marker.on('mouseout', function() {
this.setStyle({
fillColor: color,
radius: 8
});
});
markers.addLayer(marker);
});
map.addLayer(markers);
var b = markers.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);
}
// Load Leaflet CSS
addCSS(CDN + 'leaflet.css', 'leaflet-css');
// Load MarkerCluster CSS
addCSS(CLUSTER_CDN + 'MarkerCluster.css', 'markercluster-css');
addCSS(CLUSTER_CDN + 'MarkerCluster.Default.css', 'markercluster-default-css');
// Load Leaflet JS, then MarkerCluster JS, then init
addJS(CDN + 'leaflet.js', function () {
addJS(CLUSTER_CDN + 'leaflet.markercluster.js', function() {
init();
if (window.mw && mw.hook) {
mw.hook('wikipage.content').add(function ($c) {
init($c && $c[0] ? $c[0] : document);
});
}
}, 'markercluster-js');
}, 'leaflet-js');
})();