Created
February 27, 2026 11:17
-
-
Save mdoering/7207a7eda3065f497a93007becd21fcf to your computer and use it in GitHub Desktop.
GBIF Taxonomy Map Comparison
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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"> | |
| · 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,'&').replace(/</g,'<').replace(/>/g,'>'); | |
| } | |
| // ╔══════════════════════════════════════════════════════════════════════╗ | |
| // ║ 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