Skip to content

Instantly share code, notes, and snippets.

@mdoering
Created February 27, 2026 11:17
Show Gist options
  • Select an option

  • Save mdoering/7207a7eda3065f497a93007becd21fcf to your computer and use it in GitHub Desktop.

Select an option

Save mdoering/7207a7eda3065f497a93007becd21fcf to your computer and use it in GitHub Desktop.
GBIF Taxonomy Map Comparison
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GBIF Map Comparison</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.css">
<script src="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background: #fff;
color: #1a1a1a;
}
/* ── Header ── */
#header {
background: #fff;
padding: 12px 20px;
border-bottom: 1px solid #e0e0e0;
flex-shrink: 0;
}
#header h1 {
font-size: 13px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 9px;
}
#search-row {
display: flex;
gap: 8px;
}
#name-input {
flex: 1;
padding: 9px 14px;
font-size: 15px;
background: #fff;
border: 1px solid #ccc;
border-radius: 6px;
color: #1a1a1a;
outline: none;
}
#name-input:focus { border-color: #4a6fa5; box-shadow: 0 0 0 2px rgba(74,111,165,0.15); }
#name-input::placeholder { color: #aaa; }
#search-btn {
padding: 9px 22px;
background: #4a6fa5;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
#search-btn:hover { background: #3a5f95; }
#search-btn:disabled { background: #a0b4cc; cursor: not-allowed; }
/* ── Split panels ── */
#panels {
display: flex;
flex: 1;
overflow: hidden;
}
.panel {
flex: 1;
display: flex;
flex-direction: column;
}
.panel:first-child { border-right: 2px solid #e0e0e0; }
/* ── Panel info header ── */
.panel-info {
background: #f7f8fa;
padding: 10px 16px;
border-bottom: 1px solid #e0e0e0;
flex-shrink: 0;
min-height: 76px;
}
.panel-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #999;
margin-bottom: 5px;
}
.sname {
font-style: italic;
font-size: 17px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.3;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 5px;
}
.meta span {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: #e8edf3;
color: #4a5568;
font-weight: 500;
}
.status-accepted { background: #d4edda !important; color: #1a6b35 !important; }
.status-synonym { background: #fff3cd !important; color: #856404 !important; }
.status-doubtful { background: #fde8d8 !important; color: #8a3a00 !important; }
.placeholder { color: #bbb; font-size: 13px; }
.no-match { color: #c0392b; font-size: 13px; }
/* ── Map ── */
.map-wrap {
flex: 1;
position: relative;
}
.map {
position: absolute;
inset: 0;
}
</style>
</head>
<body>
<div id="header">
<h1>GBIF Map Comparison</h1>
<div id="search-row">
<input id="name-input" type="text"
placeholder="Enter scientific name — e.g. Puma concolor"
autocomplete="off">
<button id="search-btn" onclick="doSearch()">Search</button>
</div>
</div>
<div id="panels">
<div class="panel">
<div class="panel-info">
<div class="panel-label">GBIF Backbone Taxonomy</div>
<div id="info-left"><span class="placeholder">Enter a name above to search</span></div>
</div>
<div class="map-wrap">
<div id="map-left" class="map"></div>
</div>
</div>
<div class="panel">
<div class="panel-info">
<div class="panel-label">Checklist Taxonomy
<span style="font-weight:400;letter-spacing:0;text-transform:none;font-size:10px;color:#bbb">
&nbsp;·&nbsp;key: 7ddf754f-d193-4cc9-b351-99906754a03b
</span>
</div>
<div id="info-right"><span class="placeholder">Enter a name above to search</span></div>
</div>
<div class="map-wrap">
<div id="map-right" class="map"></div>
</div>
</div>
</div>
<script>
'use strict';
// ╔══════════════════════════════════════════════════════════════════════╗
// ║ CONFIGURATION ║
// ╚══════════════════════════════════════════════════════════════════════╝
const CHECKLIST_KEY = '7ddf754f-d193-4cc9-b351-99906754a03b';
const TAXONOMY_API = 'https://api.gbif-test.org/v2/species/match';
const OCCURRENCE_API = 'https://api.gbif-test.org/v2/map/occurrence/density';
const BASE_TILE_URL = 'https://tile.gbif.org/3857/omt/{z}/{x}/{y}.pbf';
// ╔══════════════════════════════════════════════════════════════════════╗
// ║ VECTOR LAYER STYLES ║
// ║ Edit these objects to restyle the occurrence overlay on both maps. ║
// ╚══════════════════════════════════════════════════════════════════════╝
// Fill style for hexagonal occurrence bins
// The 'total' feature property holds the occurrence count.
// Colors are interpolated on a log10 scale.
const HEX_FILL_PAINT = {
'fill-color': [
'interpolate', ['linear'],
['log10', ['+', ['get', 'total'], 1]],
// log10(total+1) → color
0.0, 'rgba(254, 229, 217, 0.00)', // 0 occurrences – transparent
0.3, 'rgba(252, 174, 145, 0.70)', // ~2 – light salmon
1.0, 'rgba(251, 106, 74, 0.80)', // ~10 – orange-red
2.0, 'rgba(222, 45, 38, 0.88)', // ~100 – red
3.5, 'rgba(165, 15, 21, 1.00)' // ~3 000+ – dark red
],
'fill-outline-color': 'rgba(0, 0, 0, 0.10)'
};
// Alternatively, for square grid bins (bin=square) use:
// const SQUARE_FILL_PAINT = {
// 'fill-color': [
// 'interpolate', ['linear'], ['log10', ['+', ['get', 'total'], 1]],
// 0, 'rgba(255,255,204,0)',
// 1, 'rgba(161,218,180,0.75)',
// 2, 'rgba(65,182,196,0.85)',
// 3, 'rgba(44,127,184,0.90)',
// 4, 'rgba(37,52,148,1.0)'
// ],
// 'fill-outline-color': 'rgba(0,0,0,0.08)'
// };
// ╔══════════════════════════════════════════════════════════════════════╗
// ║ BASE MAP STYLE (OpenMapTiles schema from tile.gbif.org) ║
// ╚══════════════════════════════════════════════════════════════════════╝
function makeBaseStyle() {
return {
version: 8,
glyphs: 'https://fonts.openmaptiles.org/{fontstack}/{range}.pbf',
sources: {
omt: {
type: 'vector',
tiles: [BASE_TILE_URL],
maxzoom: 14,
attribution:
'© <a href="https://openmaptiles.org/">OpenMapTiles</a> ' +
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>'
}
},
layers: [
// Background
{ id: 'bg', type: 'background',
paint: { 'background-color': '#f8f4f0' } },
// Landcover
{ id: 'lc-wood', type: 'fill', source: 'omt', 'source-layer': 'landcover',
filter: ['==', 'class', 'wood'],
paint: { 'fill-color': '#cce2bb', 'fill-opacity': 0.55 } },
{ id: 'lc-scrub', type: 'fill', source: 'omt', 'source-layer': 'landcover',
filter: ['==', 'class', 'scrub'],
paint: { 'fill-color': '#d8e8c4', 'fill-opacity': 0.45 } },
{ id: 'lc-grass', type: 'fill', source: 'omt', 'source-layer': 'landcover',
filter: ['==', 'class', 'grass'],
paint: { 'fill-color': '#e4f0d4', 'fill-opacity': 0.40 } },
// Water
{ id: 'water-fill', type: 'fill', source: 'omt', 'source-layer': 'water',
paint: { 'fill-color': '#b8d4e8' } },
{ id: 'waterway-line', type: 'line', source: 'omt', 'source-layer': 'waterway',
paint: { 'line-color': '#b8d4e8',
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.4, 12, 2] } },
// Landuse
{ id: 'lu-park', type: 'fill', source: 'omt', 'source-layer': 'landuse',
filter: ['in', 'class', 'park', 'pitch', 'recreation_ground'],
paint: { 'fill-color': '#c8e6c9', 'fill-opacity': 0.50 } },
// Boundaries
{ id: 'bnd-state', type: 'line', source: 'omt', 'source-layer': 'boundary',
minzoom: 3,
filter: ['==', 'admin_level', 4],
paint: { 'line-color': '#c0b0a0', 'line-width': 0.5, 'line-dasharray': [4, 3] } },
{ id: 'bnd-country', type: 'line', source: 'omt', 'source-layer': 'boundary',
filter: ['==', 'admin_level', 2],
paint: { 'line-color': '#9a8878',
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 0.6, 8, 1.6] } },
// Roads (visible at higher zoom only)
{ id: 'road-minor', type: 'line', source: 'omt', 'source-layer': 'transportation',
minzoom: 12,
filter: ['in', 'class', 'minor', 'service', 'track'],
paint: { 'line-color': '#fff', 'line-width': 0.8, 'line-opacity': 0.6 } },
{ id: 'road-main', type: 'line', source: 'omt', 'source-layer': 'transportation',
minzoom: 8,
filter: ['in', 'class', 'primary', 'secondary', 'tertiary', 'motorway', 'trunk'],
paint: { 'line-color': '#fff',
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.6, 14, 3.5] } },
// Labels
{ id: 'lbl-country', type: 'symbol', source: 'omt', 'source-layer': 'place',
maxzoom: 7,
filter: ['==', 'class', 'country'],
layout: {
'text-field': ['coalesce', ['get', 'name_en'], ['get', 'name']],
'text-font': ['Open Sans Bold'],
'text-size': ['interpolate', ['linear'], ['zoom'], 1, 8, 6, 13],
'text-transform': 'uppercase',
'text-letter-spacing': 0.08,
'text-max-width': 6
},
paint: {
'text-color': '#666',
'text-halo-color': 'rgba(255,255,255,0.9)',
'text-halo-width': 1.5
}
},
{ id: 'lbl-city', type: 'symbol', source: 'omt', 'source-layer': 'place',
minzoom: 5,
filter: ['in', 'class', 'city', 'town'],
layout: {
'text-field': ['coalesce', ['get', 'name_en'], ['get', 'name']],
'text-font': ['Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 5, 10, 12, 14],
'text-max-width': 6
},
paint: {
'text-color': '#444',
'text-halo-color': 'rgba(255,255,255,0.9)',
'text-halo-width': 1.2
}
}
]
};
}
// ╔══════════════════════════════════════════════════════════════════════╗
// ║ MAP INITIALIZATION & SYNCHRONIZATION ║
// ╚══════════════════════════════════════════════════════════════════════╝
let mapL, mapR;
// Track when each map's style has been applied (safe to addSource/addLayer).
// We do this ourselves because isStyleLoaded() uses style.loaded() which
// stays false while source tiles are still fetching — causing the
// once('style.load') fallback to register a listener that never fires.
const styleReady = { L: false, R: false };
function whenStyleReady(map, fn) {
const key = map === mapL ? 'L' : 'R';
if (styleReady[key]) fn();
else map.once('style.load', fn);
}
function initMaps() {
const commonOpts = { center: [0, 20], zoom: 1.5, minZoom: 0, maxZoom: 16 };
mapL = new maplibregl.Map({
container: 'map-left',
style: JSON.parse(JSON.stringify(makeBaseStyle())),
...commonOpts
});
mapR = new maplibregl.Map({
container: 'map-right',
style: JSON.parse(JSON.stringify(makeBaseStyle())),
...commonOpts
});
mapL.once('style.load', () => { styleReady.L = true; });
mapR.once('style.load', () => { styleReady.R = true; });
// Bidirectional sync — guard flags prevent recursive loops
const guard = { fromL: false, fromR: false };
mapL.on('move', () => {
if (guard.fromR) return;
guard.fromL = true;
mapR.jumpTo({
center: mapL.getCenter(),
zoom: mapL.getZoom(),
bearing: mapL.getBearing(),
pitch: mapL.getPitch()
});
guard.fromL = false;
});
mapR.on('move', () => {
if (guard.fromL) return;
guard.fromR = true;
mapL.jumpTo({
center: mapR.getCenter(),
zoom: mapR.getZoom(),
bearing: mapR.getBearing(),
pitch: mapR.getPitch()
});
guard.fromR = false;
});
mapL.addControl(new maplibregl.NavigationControl(), 'top-right');
mapR.addControl(new maplibregl.NavigationControl(), 'top-right');
}
// ╔══════════════════════════════════════════════════════════════════════╗
// ║ SEARCH ║
// ╚══════════════════════════════════════════════════════════════════════╝
async function doSearch() {
const name = document.getElementById('name-input').value.trim();
if (!name) return;
const btn = document.getElementById('search-btn');
btn.disabled = true;
const searching = '<em style="color:#4a6fa5">Searching…</em>';
document.getElementById('info-left').innerHTML = searching;
document.getElementById('info-right').innerHTML = searching;
try {
const [r1, r2] = await Promise.all([
fetchMatch(name, null),
fetchMatch(name, CHECKLIST_KEY)
]);
renderPanel('left', r1, null);
renderPanel('right', r2, CHECKLIST_KEY);
} catch (err) {
const msg = `<span class="no-match">Error: ${escHtml(err.message)}</span>`;
document.getElementById('info-left').innerHTML = msg;
document.getElementById('info-right').innerHTML = msg;
} finally {
btn.disabled = false;
}
}
async function fetchMatch(name, checklistKey) {
let url = `${TAXONOMY_API}?scientificName=${encodeURIComponent(name)}`;
if (checklistKey) url += `&checklistKey=${encodeURIComponent(checklistKey)}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} from taxonomy API`);
return res.json();
}
function renderPanel(side, taxon, checklistKey) {
const infoEl = document.getElementById(`info-${side}`);
const map = side === 'left' ? mapL : mapR;
// v2 API: match result lives in taxon.usage; no match when usage is absent
// or diagnostics.matchType is NONE
const usage = taxon?.usage;
const diagnostics = taxon?.diagnostics;
if (!usage || diagnostics?.matchType === 'NONE') {
infoEl.innerHTML = '<span class="no-match">No match found</span>';
clearOverlay(map);
return;
}
const status = usage.status || '';
const statusCls = status ? 'status-' + status.toLowerCase().replace(/_/g, '-') : '';
// Family from the classification array
const family = (taxon.classification || []).find(e => e.rank === 'FAMILY')?.name;
infoEl.innerHTML = `
<div class="sname">${escHtml(usage.name || usage.canonicalName || '–')}</div>
<div class="meta">
${usage.rank ? `<span>${escHtml(usage.rank)}</span>` : ''}
${status ? `<span class="${statusCls}">${escHtml(status)}</span>` : ''}
${usage.key ? `<span>Key: ${escHtml(usage.key)}</span>` : ''}
${diagnostics?.matchType ? `<span>Match: ${escHtml(diagnostics.matchType)}</span>` : ''}
${diagnostics?.confidence != null ? `<span>Confidence: ${diagnostics.confidence}%</span>` : ''}
</div>
${family ? `<div style="font-size:12px;color:#888;margin-top:4px">Family: <em>${escHtml(family)}</em></div>` : ''}
`.trim();
if (usage.key) {
applyOverlay(map, usage.key, checklistKey);
} else {
clearOverlay(map);
}
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ╔══════════════════════════════════════════════════════════════════════╗
// ║ OCCURRENCE OVERLAY ║
// ╚══════════════════════════════════════════════════════════════════════╝
function buildTileUrl(taxonKey, checklistKey) {
let url = `${OCCURRENCE_API}/{z}/{x}/{y}.mvt?taxonKey=${taxonKey}&bin=hex&hexPerTile=117`;
if (checklistKey) url += `&checklistKey=${encodeURIComponent(checklistKey)}`;
return url;
}
function applyOverlay(map, taxonKey, checklistKey) {
const tileUrl = buildTileUrl(taxonKey, checklistKey);
const add = () => {
const src = map.getSource('occurrence');
if (src) {
// Source already exists — update tiles in place to avoid
// remove+add race conditions when tiles are still in-flight.
src.setTiles([tileUrl]);
if (!map.getLayer('occurrence-hex')) {
map.addLayer({
id: 'occurrence-hex',
type: 'fill',
source: 'occurrence',
'source-layer': 'occurrence',
paint: HEX_FILL_PAINT
});
}
} else {
map.addSource('occurrence', {
type: 'vector',
tiles: [tileUrl],
minzoom: 0,
maxzoom: 16,
attribution: '© <a href="https://www.gbif.org/">GBIF</a>'
});
map.addLayer({
id: 'occurrence-hex',
type: 'fill',
source: 'occurrence',
'source-layer': 'occurrence',
paint: HEX_FILL_PAINT
});
}
};
whenStyleReady(map, add);
}
function clearOverlay(map) {
if (map.getLayer('occurrence-hex')) map.removeLayer('occurrence-hex');
if (map.getSource('occurrence')) map.removeSource('occurrence');
}
// ── Bootstrap ────────────────────────────────────────────────────────
document.getElementById('name-input').addEventListener('keydown', e => {
if (e.key === 'Enter') doSearch();
});
initMaps();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment