Skip to content

Instantly share code, notes, and snippets.

@patte
Last active September 5, 2026 18:30
Show Gist options
  • Select an option

  • Save patte/567df9ac182f9b8f217726232324e1e9 to your computer and use it in GitHub Desktop.

Select an option

Save patte/567df9ac182f9b8f217726232324e1e9 to your computer and use it in GitHub Desktop.
maplibre-gl #6584: getCameraAltitude() NaN on the globe, before/after side by side
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>maplibre-gl: getCameraAltitude() on the globe, before and after the fix for #6584</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.7.0/dist/maplibre-gl.css" rel="stylesheet">
<style>
html, body { margin: 0; height: 100%; background: #141418; color: #eee; font: 15px/1.45 system-ui, sans-serif; }
#panel { padding: 12px 16px; background: #1c1c22; border-bottom: 1px solid #2c2c34; }
#panel b { font-size: 17px; }
#panel p { margin: 4px 0 8px; color: #bbb; max-width: 900px; }
#panel a { color: #7cc4ff; }
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 8px; }
.row span.lbl { color: #9a9aa4; min-width: 82px; }
button { font: inherit; padding: 6px 12px; border-radius: 8px; border: 1px solid #44444c; background: #2a2a30; color: #eee; cursor: pointer; }
button:hover { background: #34343c; }
button.active { background: #3a6df0; border-color: #3a6df0; color: #fff; }
input[type=range] { width: 200px; vertical-align: middle; }
.val { display: inline-block; min-width: 3.5em; font-variant-numeric: tabular-nums; }
#maps { position: absolute; top: 0; bottom: 0; left: 0; right: 0; display: flex; }
#wrap { position: absolute; inset: 0; display: flex; flex-direction: column; }
#maps { position: relative; flex: 1; }
.map { position: relative; flex: 1; }
.map + .map { border-left: 2px solid #2c2c34; }
.readout {
position: absolute; top: 10px; left: 10px; z-index: 1; pointer-events: none;
background: rgba(20, 20, 24, 0.94); padding: 10px 14px; border-radius: 10px;
font-variant-numeric: tabular-nums;
}
.readout .name { color: #9a9aa4; font-size: 13px; }
.readout .alt { font-size: 26px; font-weight: 600; margin: 2px 0; }
.readout .alt.nan { color: #ff7b7b; }
.readout .alt.ok { color: #7ee081; }
.readout .sub { color: #bbb; font-size: 13px; }
.maplibregl-ctrl-attrib { font-size: 11px; }
</style>
</head>
<body>
<div id="wrap">
<div id="panel">
<b><code>getCameraAltitude()</code> returns NaN on the globe</b>
<p>Same camera in two builds. <b>Left</b>: maplibre-gl 6.7.0 as released. <b>Right</b>: the fix for
<a href="https://github.com/maplibre/maplibre-gl-js/issues/6584">#6584</a>.
Drag, scroll or pitch either map and the other follows. Under <b>globe</b> and <b>vertical-perspective</b>
the released build reads <span style="color:#ff7b7b">NaN</span>; the fix derives the altitude from the globe geometry.
Where the map is nearly flat that equals mercator's value; at low zoom with pitch it does not. Try <b>z4 · pitch 100</b>:
on the globe the camera is still above the planet and stays put, while mercator's flat formula puts it below sea level,
so mercator lifts the camera back to the ground (pitch snaps to 90°). Zoom in from there on the globe and the pitch eases
down along the angle where the camera touches the planet, with the camera kept on the surface.</p>
<div class="row"><span class="lbl">projection</span>
<button data-proj="mercator">mercator</button>
<button data-proj="globe" class="active">globe</button>
<button data-proj="vertical-perspective">vertical-perspective</button>
</div>
<div class="row"><span class="lbl">presets</span>
<button data-preset="15,0">z15 · pitch 0</button>
<button data-preset="15,60">z15 · pitch 60</button>
<button data-preset="10,0">z10 · pitch 0</button>
<button data-preset="4,0">z4 · pitch 0</button>
<button data-preset="4,60">z4 · pitch 60</button>
<button data-preset="4,100">z4 · pitch 100</button>
<button data-preset="2,0">z2 · pitch 0</button>
</div>
<div class="row"><span class="lbl">zoom</span><input id="zoom" type="range" min="0" max="18" step="0.01"><span class="val" id="zoomv"></span>
<span class="lbl" style="margin-left:16px">pitch</span><input id="pitch" type="range" min="0" max="120" step="1"><span class="val" id="pitchv"></span></div>
</div>
<div id="maps">
<div class="map"><div id="before" style="position:absolute;inset:0"></div>
<div class="readout"><div class="name">before · maplibre-gl 6.7.0</div><div class="alt" id="alt-before"></div><div class="sub" id="sub-before"></div></div></div>
<div class="map"><div id="after" style="position:absolute;inset:0"></div>
<div class="readout"><div class="name">after · fix for #6584</div><div class="alt" id="alt-after"></div><div class="sub" id="sub-after"></div></div></div>
</div>
</div>
<script type="module">
import * as before from 'https://cdn.jsdelivr.net/npm/maplibre-gl@6.7.0/dist/maplibre-gl.mjs';
import * as after from './maplibre-gl.mjs';
const START = {center: [13.445995, 52.499935], zoom: 15.29, pitch: 0, bearing: -111.9};
// the projection lives in the style, so it is applied once the style has loaded
const mk = (lib, id) => {
const map = new lib.Map({
container: id, style: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
...START, fadeDuration: 0,
// allow the camera past 90° pitch, looking at the horizon, with the center staying at sea level
maxPitch: 120, centerClampedToGround: false,
});
map.once('style.load', () => map.setProjection({type: 'globe'}));
return map;
};
const maps = {before: mk(before, 'before'), after: mk(after, 'after')};
window.maps = maps;
const tr = (m) => m.transform ?? m._camera.transform;
// camera sync: whichever map moves, the other follows
let syncing = false;
for (const [name, m] of Object.entries(maps)) {
const other = maps[name === 'before' ? 'after' : 'before'];
m.on('move', () => {
if (syncing) return;
syncing = true;
other.jumpTo({center: m.getCenter(), zoom: m.getZoom(), pitch: m.getPitch(), bearing: m.getBearing()});
syncing = false;
});
}
const fmt = (v) => !Number.isFinite(v) ? String(v)
: v >= 100000 ? `${(v / 1000).toFixed(0)} km` : v >= 10000 ? `${(v / 1000).toFixed(1)} km` : `${v.toFixed(1)} m`;
function readout() {
for (const [name, m] of Object.entries(maps)) {
const alt = tr(m).getCameraAltitude();
const el = document.getElementById(`alt-${name}`);
el.textContent = fmt(alt);
el.className = `alt ${Number.isFinite(alt) ? 'ok' : 'nan'}`;
document.getElementById(`sub-${name}`).textContent =
`${m.getProjection()?.type ?? 'mercator'} · z${m.getZoom().toFixed(2)} · pitch ${m.getPitch().toFixed(0)}°`;
}
const m = maps.after;
document.getElementById('zoom').value = m.getZoom();
document.getElementById('zoomv').textContent = m.getZoom().toFixed(2);
document.getElementById('pitch').value = m.getPitch();
document.getElementById('pitchv').textContent = `${m.getPitch().toFixed(0)}°`;
requestAnimationFrame(readout);
}
requestAnimationFrame(readout);
// controls
document.querySelectorAll('[data-proj]').forEach((b) => b.addEventListener('click', () => {
document.querySelectorAll('[data-proj]').forEach((x) => x.classList.toggle('active', x === b));
for (const m of Object.values(maps)) m.setProjection({type: b.dataset.proj});
}));
document.querySelectorAll('[data-preset]').forEach((b) => b.addEventListener('click', () => {
const [zoom, pitch] = b.dataset.preset.split(',').map(Number);
maps.after.jumpTo({center: START.center, zoom, pitch, bearing: START.bearing});
}));
document.getElementById('zoom').addEventListener('input', (e) => maps.after.jumpTo({zoom: +e.target.value}));
document.getElementById('pitch').addEventListener('input', (e) => maps.after.jumpTo({pitch: +e.target.value}));
</script>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>maplibre-gl: markers behind terrain on the globe, before and after the fix for #6584</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.7.0/dist/maplibre-gl.css" rel="stylesheet">
<style>
html, body { margin: 0; height: 100%; background: #141418; color: #eee; font: 15px/1.45 system-ui, sans-serif; }
#panel { padding: 12px 16px; background: #1c1c22; border-bottom: 1px solid #2c2c34; }
#panel b { font-size: 17px; }
#panel p { margin: 4px 0 8px; color: #bbb; max-width: 900px; }
#panel a { color: #7cc4ff; }
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 8px; }
.row span.lbl { color: #9a9aa4; min-width: 82px; }
button { font: inherit; padding: 6px 12px; border-radius: 8px; border: 1px solid #44444c; background: #2a2a30; color: #eee; cursor: pointer; }
button:hover { background: #34343c; }
button.active { background: #3a6df0; border-color: #3a6df0; color: #fff; }
input[type=range] { width: 200px; vertical-align: middle; }
.val { display: inline-block; min-width: 3.5em; font-variant-numeric: tabular-nums; }
#wrap { position: absolute; inset: 0; display: flex; flex-direction: column; }
#maps { position: relative; flex: 1; display: flex; }
.map { position: relative; flex: 1; }
.map + .map { border-left: 2px solid #2c2c34; }
.readout {
position: absolute; top: 10px; left: 10px; z-index: 1; pointer-events: none;
background: rgba(20, 20, 24, 0.94); padding: 10px 14px; border-radius: 10px;
font-variant-numeric: tabular-nums;
}
.readout .name { color: #9a9aa4; font-size: 13px; }
.readout .big { font-size: 26px; font-weight: 600; margin: 2px 0; }
.readout .big.bad { color: #ff7b7b; }
.readout .big.ok { color: #7ee081; }
.readout .sub { color: #bbb; font-size: 13px; }
.maplibregl-ctrl-attrib { font-size: 11px; }
</style>
</head>
<body>
<div id="wrap">
<div id="panel">
<b>Markers behind terrain on the globe never dim</b>
<p>Same camera, same markers, two builds. <b>Left</b>: maplibre-gl 6.7.0 as released. <b>Right</b>: the fix for
<a href="https://github.com/maplibre/maplibre-gl-js/issues/6584">#6584</a>.
Markers hidden by a ridge should drop to their <code>opacityWhenCovered</code>. On the globe, the released build
computes that check with <code>NaN</code>, so every marker stays fully opaque. Switch to <b>mercator</b> and both builds agree.</p>
<div class="row"><span class="lbl">projection</span>
<button data-proj="mercator">mercator</button>
<button data-proj="globe" class="active">globe</button>
<button data-proj="vertical-perspective">vertical-perspective</button>
</div>
<div class="row"><span class="lbl">terrain</span>
<button data-terrain="on" class="active">on</button>
<button data-terrain="off">off</button>
<span class="lbl" style="margin-left:16px">pitch</span><input id="pitch" type="range" min="0" max="60" step="1"><span class="val" id="pitchv"></span>
<button id="reset" style="margin-left:16px">reset view</button>
</div>
</div>
<div id="maps">
<div class="map"><div id="before" style="position:absolute;inset:0"></div>
<div class="readout"><div class="name">before · maplibre-gl 6.7.0</div><div class="big" id="cov-before"></div><div class="sub" id="sub-before"></div></div></div>
<div class="map"><div id="after" style="position:absolute;inset:0"></div>
<div class="readout"><div class="name">after · fix for #6584</div><div class="big" id="cov-after"></div><div class="sub" id="sub-after"></div></div></div>
</div>
</div>
<script type="module">
import * as before from 'https://cdn.jsdelivr.net/npm/maplibre-gl@6.7.0/dist/maplibre-gl.mjs';
import * as after from './maplibre-gl.mjs';
const START = {center: [7.3735, 46.5550], zoom: 12.6, pitch: 60, bearing: 35};
const DEM = {
type: 'raster-dem', tiles: ['https://tiles.mapterhorn.com/{z}/{x}/{y}.webp'], encoding: 'terrarium',
tileSize: 256, maxzoom: 17, attribution: '<a href="https://mapterhorn.com/attribution" target="_blank">© Mapterhorn</a>',
};
// a grid of markers around the start center, roughly 1.2 km apart, so some land behind ridges
const MARKERS = [];
for (let i = -3; i <= 3; i++) for (let j = -3; j <= 3; j++) {
MARKERS.push([START.center[0] + i * 0.016, START.center[1] + j * 0.011]);
}
const mk = (lib, id) => {
const map = new lib.Map({
container: id, style: 'https://tiles.openfreemap.org/styles/liberty', ...START, fadeDuration: 0,
});
map.once('style.load', () => {
map.setProjection({type: 'globe'});
map.addSource('dem', DEM);
map.setTerrain({source: 'dem', exaggeration: 1});
// hillshade below the labels so the ridges the markers hide behind are visible
const firstSymbol = map.getStyle().layers.find((l) => l.type === 'symbol')?.id;
map.addLayer({id: 'hillshade', type: 'hillshade', source: 'dem', paint: {'hillshade-exaggeration': 0.6}}, firstSymbol);
});
window.markers = window.markers ?? {};
window.markers[id] = MARKERS.map((lngLat) => new lib.Marker().setLngLat(lngLat).addTo(map));
return map;
};
const maps = {before: mk(before, 'before'), after: mk(after, 'after')};
window.maps = maps;
const tr = (m) => m._camera.transform;
// camera sync: whichever map moves, the other follows
let syncing = false;
for (const [name, m] of Object.entries(maps)) {
const other = maps[name === 'before' ? 'after' : 'before'];
m.on('move', () => {
if (syncing) return;
syncing = true;
other.jumpTo({center: m.getCenter(), zoom: m.getZoom(), pitch: m.getPitch(), bearing: m.getBearing()});
syncing = false;
});
}
const fmt = (v) => !Number.isFinite(v) ? String(v) : v >= 10000 ? `${(v / 1000).toFixed(1)} km` : `${v.toFixed(0)} m`;
window.covered = (name) => document.getElementById(name).querySelectorAll('.maplibregl-marker-covered').length;
function readout() {
for (const [name, m] of Object.entries(maps)) {
const n = window.covered(name);
const el = document.getElementById(`cov-${name}`);
el.textContent = `${n} of ${MARKERS.length} markers covered`;
el.className = `big ${n > 0 ? 'ok' : 'bad'}`;
const alt = tr(m).getCameraAltitude();
document.getElementById(`sub-${name}`).textContent =
`getCameraAltitude() ${fmt(alt)} · ${m.getProjection()?.type ?? 'mercator'} · z${m.getZoom().toFixed(2)} · pitch ${m.getPitch().toFixed(0)}°`;
}
document.getElementById('pitch').value = maps.after.getPitch();
document.getElementById('pitchv').textContent = `${maps.after.getPitch().toFixed(0)}°`;
requestAnimationFrame(readout);
}
requestAnimationFrame(readout);
// controls
const setActive = (sel, b) => document.querySelectorAll(sel).forEach((x) => x.classList.toggle('active', x === b));
document.querySelectorAll('[data-proj]').forEach((b) => b.addEventListener('click', () => {
setActive('[data-proj]', b);
for (const m of Object.values(maps)) m.setProjection({type: b.dataset.proj});
}));
document.querySelectorAll('[data-terrain]').forEach((b) => b.addEventListener('click', () => {
setActive('[data-terrain]', b);
for (const m of Object.values(maps)) m.setTerrain(b.dataset.terrain === 'on' ? {source: 'dem', exaggeration: 1} : null);
}));
document.getElementById('pitch').addEventListener('input', (e) => maps.after.jumpTo({pitch: +e.target.value}));
document.getElementById('reset').addEventListener('click', () => maps.after.jumpTo(START));
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment