|
<!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: '© 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> |