Skip to content

Instantly share code, notes, and snippets.

@eddy-geek
Last active February 27, 2026 19:39
Show Gist options
  • Select an option

  • Save eddy-geek/229711887f8509b55f671ab6c838ab83 to your computer and use it in GitHub Desktop.

Select an option

Save eddy-geek/229711887f8509b55f671ab6c838ab83 to your computer and use it in GitHub Desktop.
maplibre-gl slope/aspect debug viewer

What this does

full interactive slope/aspect debug viewer using only maplibre-gl-js with:

  • Hybrid DEM pipeline

    • Uses internal backfilled DEM tiles when available
    • Falls back to custom border-fixed tiles when needed
    • Status counters show internal vs fallback usage
  • Terrain analysis overlay

    • Switch between Slope and Aspect
    • Configurable color ramps (MapLibre-style step expressions)
    • Separate overlay opacity control
  • Basemap + hillshade composition controls

    • Independent basemap opacity
    • Independent hillshade opacity
    • Multiply blend toggle for slope overlay readability
  • Hillshade algorithm switcher

    • Choose among standard / basic / combined / multidirectional / igor
  • Debug tooling

    • Optional DEM tile grid overlay
    • URL-synced camera (lng, lat, zoom) for reproducible inspection/share links
  • Artifact fix integrated by default

    • Slope derivatives are evaluated at DEM texel centers to avoid sampling-phase artifacts.

Slope Shader Artifact Investigation and Fix

This document explains the artifact investigation for slope-hybrid-maplibre.html, why the initial strategy was insufficient, and the final shader fix.

Context

At z=14 (DEM tileSize=512), around:

  • lng=7.408525
  • lat=44.097279

we observed two visual issues:

  1. localized slope artifacts (sharp/unnatural patches)
  2. slope behavior near tile edges that did not match expectations

The setup uses Terrarium-encoded DEM tiles (.webp) and computes slope/aspect in a custom fragment shader.


Initial strategy

The first pass focused on the most likely causes:

  1. Tile seam handling
    • use padded DEM (1px border)
    • backfill border from neighbor DEM tiles when available
  2. Derivative scaling and projection correction
    • Horn derivatives in tile space
    • per-fragment latitude correction for Mercator (1 / cos(lat))
    • dynamic u_tile_size instead of hardcoded constants
  3. Noise mitigation
    • optional smooth-before-slope (3x3 average before derivative)

Targeted shader sample (initial derivative path)

vec2 hornDeriv(vec2 uvCore) {
  vec2 uv = paddedUV(uvCore);
  vec2 e = u_texel;

  float a = elevationForDeriv(uv + vec2(-e.x, -e.y));
  float b = elevationForDeriv(uv + vec2( 0.0, -e.y));
  float c = elevationForDeriv(uv + vec2( e.x, -e.y));
  float d = elevationForDeriv(uv + vec2(-e.x,  0.0));
  float f = elevationForDeriv(uv + vec2( e.x,  0.0));
  float g = elevationForDeriv(uv + vec2(-e.x,  e.y));
  float h = elevationForDeriv(uv + vec2( 0.0,  e.y));
  float i = elevationForDeriv(uv + vec2( e.x,  e.y));

  float dzdx = (c + 2.0 * f + i) - (a + 2.0 * d + g);
  float dzdy = (g + 2.0 * h + i) - (a + 2.0 * b + c);

  vec2 deriv = vec2(dzdx, dzdy) * u_tile_size / pow(2.0, 28.2562 - u_zoom);
  float lat = (u_latrange.x - u_latrange.y) * (1.0 - uvCore.y) + u_latrange.y;
  deriv.x = deriv.x / max(cos(radians(lat)), 0.0001);
  return deriv;
}

Why this was not enough

Even with correct border backfill and projection scaling, a sampling-phase mismatch can still produce artifacts:

  • the derivative kernel is conceptually a discrete DEM texel stencil
  • but runtime shader sampling can become interpolated between texel centers depending on the interpolated v_uv phase
  • at max zoom and high contrast terrain, this phase drift is visible

In short: the algorithm was right, but the sampling coordinates were not always aligned with DEM texel centers.


Reproduction and proof

A dedicated probe was used:

  • test/artifacts/dem_slope_artifact_probe.py

It compares:

  1. discrete Horn reference
  2. shader-like Horn with pixel-center UVs
  3. shader-like Horn with endpoint UVs

Observed at the problematic tile:

  • pixel-center emulation vs discrete reference: mean=0.000, p95=0.000, max=0.000
  • endpoint emulation vs discrete reference: mean=1.280, p95=4.409, max=32.570

Interpretation:

  • when sample phase is texel-centered, the shader matches the reference exactly
  • when sample phase drifts (endpoint-like), large errors appear

This isolates sampling alignment as a root cause for the remaining artifact behavior.


Final fix

Principle

Always snap derivative evaluation UV to DEM texel centers before running Horn sampling.

Targeted shader sample (final)

vec2 coreUVForDeriv(vec2 uvCore) {
  vec2 tileCoord = floor(clamp(uvCore, vec2(0.0), vec2(1.0)) * u_tile_size);
  tileCoord = clamp(tileCoord, vec2(0.0), vec2(u_tile_size - 1.0));
  return (tileCoord + vec2(0.5)) / u_tile_size;
}

void main() {
  vec2 d = hornDeriv(coreUVForDeriv(v_uv));
  float gradient = length(d);
  float slopeDeg = clamp(degrees(atan(gradient)), 0.0, 90.0);
  // ... aspect + color ramp
}

Why this works

  • forces derivative stencil evaluation on the same discrete lattice as DEM texels
  • removes phase-dependent interpolation bias
  • keeps border backfill/projection logic unchanged
  • preserves optional smoothing and blending controls

Additional notes

  • DEM seam mismatch still exists in source data (measured in probe), so some hard edges can still be data-driven.
  • The fix addresses shader-introduced amplification/attenuation from sampling misalignment.
  • This behavior is most visible at max DEM zoom, where each texel contributes directly to high-frequency slope response.

Files impacted

  • Runtime shader integration (custom layer):
    • slope-hybrid-border.html
  • Investigation and reproducibility:
    • test/artifacts/dem_slope_artifact_probe.py

Validation commands

npm run test-build -- test/build/dev.test.ts
python3 test/artifacts/dem_slope_artifact_probe.py --lon 7.408525 --lat 44.097279 --zoom 14

Both passed after the final fix.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hybrid DEM Slope/Aspect with Border Backfill — MapLibre GL JS</title>
<meta name="viewport" content="width=device-width, initial-scale=1,maximum-scale=1,user-scalable=no" />
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.18.0/dist/maplibre-gl.css" />
<script src="https://unpkg.com/maplibre-gl@5.18.0/dist/maplibre-gl.js"></script>
<style>
html, body, #map { margin: 0; width: 100%; height: 100%; }
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
#controls {
position: absolute;
top: 10px;
left: 10px;
z-index: 10;
background: rgba(255,255,255,0.94);
border-radius: 8px;
padding: 10px 12px;
box-shadow: 0 1px 6px rgba(0,0,0,0.25);
min-width: 280px;
font-size: 13px;
}
#legend {
position: absolute;
left: 10px;
bottom: 20px;
z-index: 10;
background: rgba(255,255,255,0.94);
border-radius: 8px;
padding: 10px 12px;
box-shadow: 0 1px 6px rgba(0,0,0,0.25);
min-width: 260px;
font-size: 12px;
line-height: 1.3;
}
.row { margin: 6px 0; }
.legend-title { font-weight: 700; margin-bottom: 6px; }
.ramp {
height: 12px;
border-radius: 4px;
border: 1px solid rgba(0,0,0,0.15);
margin: 6px 0 4px;
}
.labels { display: flex; justify-content: space-between; opacity: 0.85; }
#status { font-size: 12px; opacity: 0.85; }
#status code { background: rgba(0,0,0,0.05); padding: 1px 4px; border-radius: 3px; }
</style>
</head>
<body>
<div id="map"></div>
<div id="controls">
<div class="row">
<label for="mode"><strong>Mode</strong></label><br />
<select id="mode">
<option value="slope" selected>Slope</option>
<option value="aspect">Aspect</option>
</select>
</div>
<div class="row">
<label for="basemapOpacity"><strong>Basemap opacity</strong>: <span id="basemapOpacityValue">1.00</span></label><br />
<input id="basemapOpacity" type="range" min="0" max="1" step="0.01" value="1" />
</div>
<div class="row">
<label for="hillshadeOpacity"><strong>Hillshade opacity</strong>: <span id="hillshadeOpacityValue">0.10</span></label><br />
<input id="hillshadeOpacity" type="range" min="0" max="1" step="0.01" value="0.10" />
</div>
<div class="row">
<label for="hillshadeMethod"><strong>Hillshade method</strong></label><br />
<select id="hillshadeMethod">
<option value="standard">Standard</option>
<option value="basic">Basic</option>
<option value="combined">Combined</option>
<option value="multidirectional" selected>Multidirectional</option>
<option value="igor">Igor</option>
</select>
</div>
<div class="row">
<label for="slopeOpacity"><strong>Slope opacity</strong>: <span id="slopeOpacityValue">0.45</span></label><br />
<input id="slopeOpacity" type="range" min="0" max="1" step="0.01" value="0.45" />
</div>
<div class="row">
<label><input id="showTileGrid" type="checkbox" /> Show DEM tile grid</label>
</div>
<div class="row">
<label><input id="multiplyBlend" type="checkbox" checked /> Multiply blend</label>
</div>
<div id="status" class="row">
Internal backfilled DEM tiles: <code id="internalCount">0</code><br />
Fallback border-fixed tiles: <code id="fallbackCount">0</code>
</div>
</div>
<div id="legend">
<div id="legendTitle" class="legend-title">Slope (degrees)</div>
<div id="legendRamp" class="ramp"></div>
<div id="legendLabels" class="labels"></div>
</div>
<script>
const DEM_TILE_URL_TEMPLATE = 'https://tiles.mapterhorn.com/{z}/{x}/{y}.webp';
const MAX_STEP_STOPS = 16;
const ANALYSIS_COLOR = {
slope: [
'step', ['slope'],
"#ffffff", // white
21, "#aaffff", // pale turquoise / celeste
25, "#56ffff", // cyan
28, "#fefe50", // titanium yellow before 8
31, "#f5bf00", // golden poppy befor 61
34, "#ff9b00", // orange peel was 40.3 7
37, "#ff6900", // dark orange 2 was 24
40, "#ff0000", // red
43, "#dc00f5", // magenta 2 299.5 53.8
46, "#a719ff", // purple 282 47
49, "#6e00ff", // electric indigo / violet
52, "#4106ff", // blue1
55, "#0000ff", // blue
58, "#595959", // gray 30
],
aspect: [
'step', ['aspect'],
'#ff0000',
45, '#ffff00',
135, '#00ff00',
225, '#00ffff',
315, '#0000ff'
]
};
const ANALYSIS_RANGE = {
slope: [0, 90],
aspect: [0, 360]
};
const colorCanvas = document.createElement('canvas');
colorCanvas.width = 1;
colorCanvas.height = 1;
const colorCtx = colorCanvas.getContext('2d', {willReadFrequently: true});
function demTileUrl(z, x, y) {
return DEM_TILE_URL_TEMPLATE
.replace('{z}', String(z))
.replace('{x}', String(x))
.replace('{y}', String(y));
}
function cssColorToRgb01(color) {
colorCtx.clearRect(0, 0, 1, 1);
colorCtx.fillStyle = '#000000';
colorCtx.fillStyle = color;
colorCtx.fillRect(0, 0, 1, 1);
const pixel = colorCtx.getImageData(0, 0, 1, 1).data;
return [pixel[0] / 255, pixel[1] / 255, pixel[2] / 255];
}
function parseStepRamp(expression, expectedInput) {
if (!Array.isArray(expression) || expression[0] !== 'step') {
throw new Error('Color expression must be a step expression');
}
const input = expression[1];
if (!Array.isArray(input) || input[0] !== expectedInput) {
throw new Error(`Step input must be ["${expectedInput}"]`);
}
const defaultColor = expression[2];
const stepCount = Math.floor((expression.length - 3) / 2);
if (stepCount > MAX_STEP_STOPS) {
throw new Error(`Too many step stops. Max supported is ${MAX_STEP_STOPS}`);
}
const values = new Float32Array(MAX_STEP_STOPS);
const colors = new Float32Array((MAX_STEP_STOPS + 1) * 3);
const c0 = cssColorToRgb01(defaultColor);
colors[0] = c0[0];
colors[1] = c0[1];
colors[2] = c0[2];
for (let i = 0; i < stepCount; i++) {
values[i] = Number(expression[3 + i * 2]);
const c = cssColorToRgb01(expression[4 + i * 2]);
colors[(i + 1) * 3 + 0] = c[0];
colors[(i + 1) * 3 + 1] = c[1];
colors[(i + 1) * 3 + 2] = c[2];
}
return {stepCount, values, colors};
}
const PARSED_RAMPS = {
slope: parseStepRamp(ANALYSIS_COLOR.slope, 'slope'),
aspect: parseStepRamp(ANALYSIS_COLOR.aspect, 'aspect')
};
function rampToLegendCss(mode) {
const ramp = PARSED_RAMPS[mode];
const range = ANALYSIS_RANGE[mode];
const min = range[0];
const max = range[1];
const parts = [];
for (let i = 0; i <= ramp.stepCount; i++) {
const r = Math.round(ramp.colors[i * 3 + 0] * 255);
const g = Math.round(ramp.colors[i * 3 + 1] * 255);
const b = Math.round(ramp.colors[i * 3 + 2] * 255);
let percent = 0;
if (i === 0) {
percent = 0;
} else {
percent = Math.max(0, Math.min(100, ((ramp.values[i - 1] - min) / (max - min)) * 100));
}
parts.push(`rgb(${r}, ${g}, ${b}) ${percent.toFixed(2)}%`);
}
return `linear-gradient(to right, ${parts.join(', ')})`;
}
const DEM_SOURCE_ID = 'dem';
const DEM_MAX_Z = 14;
const CORE_DIM = 512;
const PAD_STRIDE = CORE_DIM + 2;
const state = {
mode: 'slope',
basemapOpacity: 1,
hillshadeOpacity: 0.10,
hillshadeMethod: 'igor',
slopeOpacity: 0.45,
showTileGrid: false,
multiplyBlend: true,
internalCount: 0,
fallbackCount: 0
};
function parseInitialViewFromUrl() {
// Read params from e.g. https://example.com/?someRouterStuff#lng=6.1&lat=45.2&zoom=12.00
const hash = window.location.hash.startsWith('#')
? window.location.hash.slice(1)
: window.location.hash;
const params = new URLSearchParams(hash);
const lngRaw = Number(params.get('lng'));
const latRaw = Number(params.get('lat'));
const zoomRaw = Number(params.get('zoom'));
const hasLng = params.has('lng') && Number.isFinite(lngRaw) && lngRaw >= -180 && lngRaw <= 180;
const hasLat = params.has('lat') && Number.isFinite(latRaw) && latRaw >= -85.051129 && latRaw <= 85.051129;
const hasZoom = params.has('zoom') && Number.isFinite(zoomRaw) && zoomRaw >= 0 && zoomRaw <= 24;
return {
center: (hasLng && hasLat) ? [lngRaw, latRaw] : [6.8652, 45.8326],
zoom: hasZoom ? zoomRaw : 12
};
}
function getVisibleTriplesForMap(map) {
const z = Math.min(DEM_MAX_Z, Math.max(0, Math.floor(map.getZoom())));
const bounds = map.getBounds();
const north = bounds.getNorth();
const south = bounds.getSouth();
const west = bounds.getWest();
const east = bounds.getEast();
const lonRanges = (west <= east) ? [[west, east]] : [[west, 180], [-180, east]];
const out = [];
for (const range of lonRanges) {
const nw = lonLatToTile(range[0], north, z);
const se = lonLatToTile(range[1], south, z);
const xMin = Math.min(nw.x, se.x);
const xMax = Math.max(nw.x, se.x);
const yMin = Math.min(nw.y, se.y);
const yMax = Math.max(nw.y, se.y);
for (let y = yMin; y <= yMax; y++) {
if (y < 0 || y >= Math.pow(2, z)) continue;
for (let x = xMin; x <= xMax; x++) {
const wx = normalizeTileX(x, z);
out.push({z, x: wx, y, key: `${z}/${wx}/${y}`});
}
}
}
return out;
}
function buildDebugGridGeoJSON(map) {
const visible = getVisibleTriplesForMap(map);
const features = [];
const dedupe = new Set();
for (const t of visible) {
const id = `${t.z}/${t.x}/${t.y}`;
if (dedupe.has(id)) continue;
dedupe.add(id);
const b = tileToLngLatBounds(t.x, t.y, t.z);
features.push({
type: 'Feature',
properties: {id, z: t.z, x: t.x, y: t.y},
geometry: {
type: 'Polygon',
coordinates: [[
[b.west, b.north],
[b.east, b.north],
[b.east, b.south],
[b.west, b.south],
[b.west, b.north]
]]
}
});
}
return {
type: 'FeatureCollection',
features
};
}
function setGlobalStatePropertySafe(map, name, value) {
if (typeof map.setGlobalStateProperty === 'function') {
map.setGlobalStateProperty(name, value);
}
}
function updateDebugGridSource(map) {
const src = map.getSource('dem-debug-grid');
if (!src) return;
src.setData(buildDebugGridGeoJSON(map));
}
function ensureDebugGridLayer(map) {
if (!map.getSource('dem-debug-grid')) {
map.addSource('dem-debug-grid', {
type: 'geojson',
data: {type: 'FeatureCollection', features: []}
});
}
if (!map.getLayer('dem-debug-grid-line')) {
map.addLayer({
id: 'dem-debug-grid-line',
type: 'line',
source: 'dem-debug-grid',
layout: {
visibility: state.showTileGrid ? 'visible' : 'none'
},
paint: {
'line-color': '#111111',
'line-width': 1,
'line-opacity': 0.8
}
});
}
}
function syncViewToUrl(map) {
const center = map.getCenter();
const zoom = map.getZoom();
const url = new URL(window.location.href);
// Preserve existing query EXACTLY; only update hash
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ''));
hashParams.set('lng', center.lng.toFixed(6));
hashParams.set('lat', center.lat.toFixed(6));
hashParams.set('zoom', zoom.toFixed(2));
url.hash = hashParams.toString();
// replaceState accepts a full URL string; safest
window.history.replaceState(null, '', url.toString());
}
function updateStatus() {
document.getElementById('internalCount').textContent = String(state.internalCount);
document.getElementById('fallbackCount').textContent = String(state.fallbackCount);
}
function updateLegend() {
const title = document.getElementById('legendTitle');
const ramp = document.getElementById('legendRamp');
const labels = document.getElementById('legendLabels');
if (state.mode === 'slope') {
title.textContent = 'Slope (degrees)';
ramp.style.background = rampToLegendCss('slope');
labels.innerHTML = '<span>0°</span><span>15°</span><span>30°</span><span>45°</span><span>90°</span>';
} else {
title.textContent = 'Aspect (compass direction)';
ramp.style.background = rampToLegendCss('aspect');
labels.innerHTML = '<span>N</span><span>E</span><span>S</span><span>W</span><span>N</span>';
}
}
function normalizeTileX(x, z) {
const n = Math.pow(2, z);
return ((x % n) + n) % n;
}
function lonLatToTile(lon, lat, z) {
const n = Math.pow(2, z);
const x = Math.floor((lon + 180) / 360 * n);
const latRad = lat * Math.PI / 180;
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
return {x, y};
}
function tileLatCenter(y, z) {
const n = Math.pow(2, z);
const northRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n)));
const southRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 1) / n)));
return (northRad + southRad) * 90 / Math.PI;
}
function tileToLngLatBounds(x, y, z) {
const n = Math.pow(2, z);
const west = x / n * 360 - 180;
const east = (x + 1) / n * 360 - 180;
const northRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n)));
const southRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 1) / n)));
return {
west,
east,
north: northRad * 180 / Math.PI,
south: southRad * 180 / Math.PI
};
}
function mercatorVertsForTile(z, x, y, wrap) {
const n = Math.pow(2, z);
const x0 = x / n + wrap;
const x1 = (x + 1) / n + wrap;
const y0 = y / n;
const y1 = (y + 1) / n;
return new Float32Array([
x0, y0, 0, 0,
x0, y1, 0, 1,
x1, y0, 1, 0,
x1, y1, 1, 1
]);
}
function decodeTerrarium(r, g, b) {
return (r * 256.0 + g + b / 256.0) - 32768.0;
}
function encodeTerrarium(elevation, out, byteIndex) {
const v = elevation + 32768.0;
let r = Math.floor(v / 256.0);
let g = Math.floor(v - r * 256.0);
let b = Math.round((v - r * 256.0 - g) * 256.0);
r = Math.max(0, Math.min(255, r));
g = Math.max(0, Math.min(255, g));
b = Math.max(0, Math.min(255, b));
out[byteIndex] = r;
out[byteIndex + 1] = g;
out[byteIndex + 2] = b;
out[byteIndex + 3] = 255;
}
function idxPad(x, y) {
return (y + 1) * PAD_STRIDE + (x + 1);
}
function createFallbackTileRecord(z, x, y) {
return {
key: `${z}/${x}/${y}`,
z,
x,
y,
core: new Float32Array(CORE_DIM * CORE_DIM),
padded: new Float32Array(PAD_STRIDE * PAD_STRIDE),
texture: null,
loaded: false,
dirtyTexture: true
};
}
function initPaddedFromCore(tile) {
for (let y = 0; y < CORE_DIM; y++) {
for (let x = 0; x < CORE_DIM; x++) {
tile.padded[idxPad(x, y)] = tile.core[y * CORE_DIM + x];
}
}
for (let x = 0; x < CORE_DIM; x++) {
tile.padded[idxPad(x, -1)] = tile.padded[idxPad(x, 0)];
tile.padded[idxPad(x, CORE_DIM)] = tile.padded[idxPad(x, CORE_DIM - 1)];
}
for (let y = 0; y < CORE_DIM; y++) {
tile.padded[idxPad(-1, y)] = tile.padded[idxPad(0, y)];
tile.padded[idxPad(CORE_DIM, y)] = tile.padded[idxPad(CORE_DIM - 1, y)];
}
tile.padded[idxPad(-1, -1)] = tile.padded[idxPad(0, 0)];
tile.padded[idxPad(CORE_DIM, -1)] = tile.padded[idxPad(CORE_DIM - 1, 0)];
tile.padded[idxPad(-1, CORE_DIM)] = tile.padded[idxPad(0, CORE_DIM - 1)];
tile.padded[idxPad(CORE_DIM, CORE_DIM)] = tile.padded[idxPad(CORE_DIM - 1, CORE_DIM - 1)];
tile.dirtyTexture = true;
}
function adjustedDx(aTile, bTile) {
const world = Math.pow(2, aTile.z);
let dx = bTile.x - aTile.x;
if (Math.abs(dx) > 1) {
if (Math.abs(dx + world) === 1) dx += world;
else if (Math.abs(dx - world) === 1) dx -= world;
}
return dx;
}
function backfillBorder(dstTile, srcTile, dx, dy) {
let xMin = dx * CORE_DIM;
let xMax = dx * CORE_DIM + CORE_DIM;
let yMin = dy * CORE_DIM;
let yMax = dy * CORE_DIM + CORE_DIM;
if (dx === -1) xMin = xMax - 1;
if (dx === 1) xMax = xMin + 1;
if (dy === -1) yMin = yMax - 1;
if (dy === 1) yMax = yMin + 1;
const ox = -dx * CORE_DIM;
const oy = -dy * CORE_DIM;
for (let y = yMin; y < yMax; y++) {
for (let x = xMin; x < xMax; x++) {
dstTile.padded[idxPad(x, y)] = srcTile.padded[idxPad(x + ox, y + oy)];
}
}
dstTile.dirtyTexture = true;
}
function uploadPaddedTexture(gl, tile) {
if (!tile.loaded || !tile.dirtyTexture) return;
const bytes = new Uint8Array(PAD_STRIDE * PAD_STRIDE * 4);
for (let i = 0; i < tile.padded.length; i++) {
encodeTerrarium(tile.padded[i], bytes, i * 4);
}
if (!tile.texture) tile.texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tile.texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, PAD_STRIDE, PAD_STRIDE, 0, gl.RGBA, gl.UNSIGNED_BYTE, bytes);
tile.dirtyTexture = false;
}
function createHybridBorderLayer() {
return {
id: 'dem-analysis-hybrid-border',
type: 'custom',
renderingMode: '2d',
map: null,
program: null,
buffer: null,
aPos: -1,
aUv: -1,
uMatrix: null,
uDem: null,
uOpacity: null,
uMode: null,
uZoom: null,
uLatRange: null,
uTileSize: null,
uTexel: null,
uUvOffset: null,
uUvScale: null,
internalTextures: new Map(),
fallbackTiles: new Map(),
fallbackInFlight: new Set(),
onAdd(map, gl) {
this.map = map;
const vertexSource = `
precision highp float;
uniform mat4 u_matrix;
attribute vec2 a_pos;
attribute vec2 a_uv;
varying vec2 v_uv;
void main() {
v_uv = a_uv;
gl_Position = u_matrix * vec4(a_pos, 0.0, 1.0);
}
`;
const fragmentSource = `
precision highp float;
uniform sampler2D u_dem;
uniform float u_opacity;
uniform int u_mode;
uniform float u_zoom;
uniform vec2 u_latrange;
uniform float u_tile_size;
uniform vec2 u_texel;
uniform float u_uv_offset;
uniform float u_uv_scale;
uniform int u_step_count;
uniform float u_step_values[${MAX_STEP_STOPS}];
uniform vec3 u_step_colors[${MAX_STEP_STOPS + 1}];
varying vec2 v_uv;
float decodeTerrarium(vec4 c) {
float r = c.r * 255.0;
float g = c.g * 255.0;
float b = c.b * 255.0;
return (r * 256.0 + g + b / 256.0) - 32768.0;
}
vec2 paddedUV(vec2 uv) {
return uv * u_uv_scale + vec2(u_uv_offset);
}
float elevationAt(vec2 uv) {
return decodeTerrarium(texture2D(u_dem, clamp(uv, vec2(0.0), vec2(1.0))));
}
vec2 coreUVForDeriv(vec2 uvCore) {
vec2 tileCoord = floor(clamp(uvCore, vec2(0.0), vec2(1.0)) * u_tile_size);
tileCoord = clamp(tileCoord, vec2(0.0), vec2(u_tile_size - 1.0));
return (tileCoord + vec2(0.5)) / u_tile_size;
}
vec2 hornDeriv(vec2 uvCore) {
vec2 uv = paddedUV(uvCore);
vec2 e = u_texel;
float a = elevationAt(uv + vec2(-e.x, -e.y));
float b = elevationAt(uv + vec2(0.0, -e.y));
float c = elevationAt(uv + vec2(e.x, -e.y));
float d = elevationAt(uv + vec2(-e.x, 0.0));
float f = elevationAt(uv + vec2(e.x, 0.0));
float g = elevationAt(uv + vec2(-e.x, e.y));
float h = elevationAt(uv + vec2(0.0, e.y));
float i = elevationAt(uv + vec2(e.x, e.y));
float dzdx = (c + 2.0 * f + i) - (a + 2.0 * d + g);
float dzdy = (g + 2.0 * h + i) - (a + 2.0 * b + c);
vec2 deriv = vec2(dzdx, dzdy) * u_tile_size / pow(2.0, 28.2562 - u_zoom);
float lat = (u_latrange.x - u_latrange.y) * (1.0 - uvCore.y) + u_latrange.y;
deriv.x = deriv.x / max(cos(radians(lat)), 0.0001);
return deriv;
}
vec3 colorFromStep(float value) {
vec3 color = u_step_colors[0];
for (int i = 0; i < ${MAX_STEP_STOPS}; i++) {
if (i >= u_step_count) break;
if (value >= u_step_values[i]) {
color = u_step_colors[i + 1];
}
}
return color;
}
void main() {
vec2 d = hornDeriv(coreUVForDeriv(v_uv));
float gradient = length(d);
float slopeDeg = clamp(degrees(atan(gradient)), 0.0, 90.0);
float aspectDeg = degrees(atan(d.y, -d.x));
aspectDeg = mod(90.0 - aspectDeg, 360.0);
if (gradient < 0.0001) aspectDeg = 0.0;
float scalar = (u_mode == 0) ? slopeDeg : aspectDeg;
vec3 color = colorFromStep(scalar);
gl_FragColor = vec4(color * u_opacity, u_opacity);
}
`;
function compile(gl, type, src) {
const shader = gl.createShader(type);
gl.shaderSource(shader, src);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new Error(gl.getShaderInfoLog(shader) || 'Shader compile failed');
}
return shader;
}
const vs = compile(gl, gl.VERTEX_SHADER, vertexSource);
const fs = compile(gl, gl.FRAGMENT_SHADER, fragmentSource);
this.program = gl.createProgram();
gl.attachShader(this.program, vs);
gl.attachShader(this.program, fs);
gl.linkProgram(this.program);
if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(this.program) || 'Program link failed');
}
this.aPos = gl.getAttribLocation(this.program, 'a_pos');
this.aUv = gl.getAttribLocation(this.program, 'a_uv');
this.uMatrix = gl.getUniformLocation(this.program, 'u_matrix');
this.uDem = gl.getUniformLocation(this.program, 'u_dem');
this.uOpacity = gl.getUniformLocation(this.program, 'u_opacity');
this.uMode = gl.getUniformLocation(this.program, 'u_mode');
this.uZoom = gl.getUniformLocation(this.program, 'u_zoom');
this.uLatRange = gl.getUniformLocation(this.program, 'u_latrange');
this.uTileSize = gl.getUniformLocation(this.program, 'u_tile_size');
this.uTexel = gl.getUniformLocation(this.program, 'u_texel');
this.uUvOffset = gl.getUniformLocation(this.program, 'u_uv_offset');
this.uUvScale = gl.getUniformLocation(this.program, 'u_uv_scale');
this.uStepCount = gl.getUniformLocation(this.program, 'u_step_count');
this.uStepValues = gl.getUniformLocation(this.program, 'u_step_values');
this.uStepColors = gl.getUniformLocation(this.program, 'u_step_colors');
this.buffer = gl.createBuffer();
},
getDemTileManager() {
const style = this.map && this.map.style;
if (!style || !style.tileManagers) return null;
return style.tileManagers[DEM_SOURCE_ID] || null;
},
getVisibleTriples() {
return getVisibleTriplesForMap(this.map);
},
updateInternalTexture(gl, internalTile) {
if (!internalTile.dem || !internalTile.dem.getPixels || typeof internalTile.dem.stride !== 'number') return null;
const uid = String(internalTile.dem.uid);
const cacheKey = `dem:${uid}`;
const stride = internalTile.dem.stride;
const cached = this.internalTextures.get(cacheKey);
if (cached) return cached;
const pixels = internalTile.dem.getPixels();
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, stride, stride, 0, gl.RGBA, gl.UNSIGNED_BYTE, pixels.data);
const entry = {
texture: tex,
stride
};
this.internalTextures.set(cacheKey, entry);
return entry;
},
async ensureFallbackTile(v, gl) {
if (!this.fallbackTiles.has(v.key)) {
this.fallbackTiles.set(v.key, createFallbackTileRecord(v.z, v.x, v.y));
}
const tile = this.fallbackTiles.get(v.key);
if (tile.loaded || this.fallbackInFlight.has(v.key)) return tile;
this.fallbackInFlight.add(v.key);
try {
const url = demTileUrl(v.z, v.x, v.y);
const response = await fetch(url, {mode: 'cors', cache: 'force-cache'});
if (!response.ok) throw new Error(`DEM tile ${v.key}: HTTP ${response.status}`);
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
const canvas = document.createElement('canvas');
canvas.width = CORE_DIM;
canvas.height = CORE_DIM;
const ctx = canvas.getContext('2d', {willReadFrequently: true});
ctx.drawImage(bitmap, 0, 0, CORE_DIM, CORE_DIM);
const bytes = ctx.getImageData(0, 0, CORE_DIM, CORE_DIM).data;
for (let i = 0; i < CORE_DIM * CORE_DIM; i++) {
const bi = i * 4;
tile.core[i] = decodeTerrarium(bytes[bi], bytes[bi + 1], bytes[bi + 2]);
}
initPaddedFromCore(tile);
tile.loaded = true;
// Backfill from loaded fallback neighbors both directions.
for (let dy = -1; dy <= 1; dy++) {
for (let dx0 = -1; dx0 <= 1; dx0++) {
if (dx0 === 0 && dy === 0) continue;
const nx = normalizeTileX(tile.x + dx0, tile.z);
const ny = tile.y + dy;
if (ny < 0 || ny >= Math.pow(2, tile.z)) continue;
const nKey = `${tile.z}/${nx}/${ny}`;
const neighbor = this.fallbackTiles.get(nKey);
if (!neighbor || !neighbor.loaded) continue;
const dx = adjustedDx(tile, neighbor);
const ddy = neighbor.y - tile.y;
if (Math.abs(dx) > 1 || Math.abs(ddy) > 1 || (dx === 0 && ddy === 0)) continue;
backfillBorder(tile, neighbor, dx, ddy);
backfillBorder(neighbor, tile, -dx, -ddy);
}
}
uploadPaddedTexture(gl, tile);
this.map.triggerRepaint();
} catch (err) {
console.error('Fallback DEM fetch failed:', v.key, err);
} finally {
this.fallbackInFlight.delete(v.key);
}
return tile;
},
render(gl, args) {
state.internalCount = 0;
state.fallbackCount = 0;
gl.useProgram(this.program);
gl.uniformMatrix4fv(this.uMatrix, false, args.defaultProjectionData.mainMatrix);
gl.uniform1f(this.uOpacity, state.slopeOpacity);
gl.uniform1i(this.uMode, state.mode === 'slope' ? 0 : 1);
const ramp = state.mode === 'slope' ? PARSED_RAMPS.slope : PARSED_RAMPS.aspect;
gl.uniform1i(this.uStepCount, ramp.stepCount);
gl.uniform1fv(this.uStepValues, ramp.values);
gl.uniform3fv(this.uStepColors, ramp.colors);
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
gl.enableVertexAttribArray(this.aPos);
gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, 16, 0);
gl.enableVertexAttribArray(this.aUv);
gl.vertexAttribPointer(this.aUv, 2, gl.FLOAT, false, 16, 8);
gl.enable(gl.BLEND);
if (state.multiplyBlend) {
gl.blendFunc(gl.DST_COLOR, gl.ONE_MINUS_SRC_ALPHA);
} else {
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
}
const tileManager = this.getDemTileManager();
const visible = this.getVisibleTriples();
const internalByCanonical = new Map();
if (tileManager && tileManager.getRenderableIds && tileManager.getTileByID) {
const ids = tileManager.getRenderableIds();
for (const id of ids) {
const tile = tileManager.getTileByID(id);
if (!tile || !tile.tileID || !tile.tileID.canonical) continue;
const cz = tile.tileID.canonical.z;
const cx = tile.tileID.canonical.x;
const cy = tile.tileID.canonical.y;
internalByCanonical.set(`${cz}/${cx}/${cy}`, tile);
}
}
for (const v of visible) {
const tileBounds = tileToLngLatBounds(v.x, v.y, v.z);
const canonicalKey = `${v.z}/${v.x}/${v.y}`;
let texture = null;
let stride = PAD_STRIDE;
let wrap = 0;
const internalTile = internalByCanonical.get(canonicalKey);
if (internalTile) {
const internalTex = this.updateInternalTexture(gl, internalTile);
if (internalTex) {
texture = internalTex.texture;
stride = internalTex.stride;
wrap = internalTile.tileID && typeof internalTile.tileID.wrap === 'number' ? internalTile.tileID.wrap : 0;
state.internalCount += 1;
}
}
if (!texture) {
this.ensureFallbackTile(v, gl);
const fallback = this.fallbackTiles.get(v.key);
if (!fallback || !fallback.loaded) continue;
uploadPaddedTexture(gl, fallback);
if (!fallback.texture) continue;
texture = fallback.texture;
stride = PAD_STRIDE;
wrap = 0;
state.fallbackCount += 1;
}
const verts = mercatorVertsForTile(v.z, v.x, v.y, wrap);
gl.uniform1f(this.uZoom, v.z);
gl.uniform2f(this.uLatRange, tileBounds.north, tileBounds.south);
gl.uniform1f(this.uTileSize, stride - 2);
gl.uniform2f(this.uTexel, 1 / stride, 1 / stride);
gl.uniform1f(this.uUvOffset, 1 / stride);
gl.uniform1f(this.uUvScale, (stride - 2) / stride);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(this.uDem, 0);
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STREAM_DRAW);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
updateStatus();
},
onRemove(_map, gl) {
for (const entry of this.internalTextures.values()) {
if (entry.texture) gl.deleteTexture(entry.texture);
}
for (const tile of this.fallbackTiles.values()) {
if (tile.texture) gl.deleteTexture(tile.texture);
}
this.internalTextures.clear();
this.fallbackTiles.clear();
this.fallbackInFlight.clear();
if (this.buffer) gl.deleteBuffer(this.buffer);
if (this.program) gl.deleteProgram(this.program);
}
};
}
const initialView = parseInitialViewFromUrl();
const map = new maplibregl.Map({
container: 'map',
center: initialView.center,
zoom: initialView.zoom,
style: {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 512,
attribution: '&copy; OpenStreetMap contributors'
},
dem: {
type: 'raster-dem',
tiles: ['https://tiles.mapterhorn.com/{z}/{x}/{y}.webp'],
tileSize: 512,
maxzoom: DEM_MAX_Z,
encoding: 'terrarium'
}
},
layers: [
{
id: 'osm',
type: 'raster',
source: 'osm',
paint: {
'raster-opacity': ['coalesce', ['global-state', 'basemapOpacity'], 1]
}
},
{
id: 'dem-loader',
type: 'hillshade',
source: DEM_SOURCE_ID,
paint: {
'hillshade-method': state.hillshadeMethod,
'hillshade-exaggeration': ['coalesce', ['global-state', 'hillshadeOpacity'], 0.35],
'hillshade-shadow-color': '#000000',
'hillshade-highlight-color': '#ffffff',
'hillshade-accent-color': '#000000',
// or to hide it :
//'hillshade-exaggeration': 0.01,
//'hillshade-shadow-color': 'rgba(0,0,0,0)',
//'hillshade-highlight-color': 'rgba(255,255,255,0)',
//'hillshade-accent-color': 'rgba(0,0,0,0)'
}
}
]
},
canvasContextAttributes: {antialias: true}
});
document.getElementById('mode').addEventListener('change', (e) => {
state.mode = e.target.value;
updateLegend();
map.triggerRepaint();
});
document.getElementById('basemapOpacity').addEventListener('input', (e) => {
state.basemapOpacity = Number(e.target.value);
document.getElementById('basemapOpacityValue').textContent = state.basemapOpacity.toFixed(2);
setGlobalStatePropertySafe(map, 'basemapOpacity', state.basemapOpacity);
map.triggerRepaint();
});
document.getElementById('hillshadeOpacity').addEventListener('input', (e) => {
state.hillshadeOpacity = Number(e.target.value);
document.getElementById('hillshadeOpacityValue').textContent = state.hillshadeOpacity.toFixed(2);
setGlobalStatePropertySafe(map, 'hillshadeOpacity', state.hillshadeOpacity);
map.triggerRepaint();
});
document.getElementById('hillshadeMethod').addEventListener('change', (e) => {
state.hillshadeMethod = e.target.value;
if (map.getLayer('dem-loader')) {
map.setPaintProperty('dem-loader', 'hillshade-method', state.hillshadeMethod);
}
map.triggerRepaint();
});
document.getElementById('slopeOpacity').addEventListener('input', (e) => {
state.slopeOpacity = Number(e.target.value);
document.getElementById('slopeOpacityValue').textContent = state.slopeOpacity.toFixed(2);
map.triggerRepaint();
});
document.getElementById('showTileGrid').addEventListener('change', (e) => {
state.showTileGrid = Boolean(e.target.checked);
if (map.getLayer('dem-debug-grid-line')) {
map.setLayoutProperty('dem-debug-grid-line', 'visibility', state.showTileGrid ? 'visible' : 'none');
}
if (state.showTileGrid) {
updateDebugGridSource(map);
}
map.triggerRepaint();
});
document.getElementById('multiplyBlend').addEventListener('change', (e) => {
state.multiplyBlend = Boolean(e.target.checked);
map.triggerRepaint();
});
updateLegend();
updateStatus();
map.on('load', () => {
ensureDebugGridLayer(map);
setGlobalStatePropertySafe(map, 'basemapOpacity', state.basemapOpacity);
setGlobalStatePropertySafe(map, 'hillshadeOpacity', state.hillshadeOpacity);
updateDebugGridSource(map);
syncViewToUrl(map);
map.on('moveend', () => {
syncViewToUrl(map);
if (state.showTileGrid) updateDebugGridSource(map);
});
map.on('zoomend', () => {
syncViewToUrl(map);
if (state.showTileGrid) updateDebugGridSource(map);
});
map.addLayer(createHybridBorderLayer());
});
map.on('error', (e) => {
console.error('Map error:', e && e.error ? e.error.message : e);
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment