Skip to content

Instantly share code, notes, and snippets.

@eddy-geek
Last active March 14, 2026 12:12
Show Gist options
  • Select an option

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

Select an option

Save eddy-geek/ff8c4f3d74e1be6747baf3c91b41a50c to your computer and use it in GitHub Desktop.
Slope/Aspect map viewer & track editor — MapLibre GL JS
<!DOCTYPE html>
<html lang="en">
<head>
<!-- https://gist.github.com/eddy-geek/ff8c4f3d74e1be6747baf3c91b41a50c -->
<!-- .../gh api --method PATCH /gists/ff8c4f3d74e1be6747baf3c91b41a50c -F "files[slope.html][content]=@slope.html" -F "files[slope.md][content]=@slope.md" --jq '.html_url' -->
<meta charset="utf-8" />
<title>Hybrid DEM Slope/Aspect/Color Relief 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.20.0/dist/maplibre-gl.css" />
<script src="https://unpkg.com/maplibre-gl@5.20.0/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/maplibre-contour@0.0.5/dist/index.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3/dist/chartjs-plugin-annotation.min.js"></script>
<style>
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; }
#map { position: relative; width: 100%; height: 100%; }
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
#controls-wrapper {
position: absolute;
top: 10px;
left: 10px;
z-index: 10;
}
#toolbar {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
#controls-toggle {
background: rgba(255,255,255,0.94);
border: none;
border-radius: 8px;
padding: 6px 12px;
box-shadow: 0 1px 6px rgba(0,0,0,0.25);
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
#controls-toggle:hover { background: rgba(240,240,240,0.97); }
#search-box {
position: relative;
display: flex;
align-items: center;
}
#search-icon {
background: rgba(255,255,255,0.94);
border: none;
border-radius: 8px;
padding: 6px 8px;
box-shadow: 0 1px 6px rgba(0,0,0,0.25);
font-size: 15px;
cursor: pointer;
line-height: 1;
}
#search-icon:hover { background: rgba(240,240,240,0.97); }
#search-input {
width: 0;
opacity: 0;
border: none;
outline: none;
font-size: 13px;
padding: 6px 8px;
border-radius: 0 8px 8px 0;
background: rgba(255,255,255,0.94);
box-shadow: 0 1px 6px rgba(0,0,0,0.25);
transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease;
}
#search-box.expanded #search-input {
width: 200px;
opacity: 1;
padding: 6px 8px;
}
#search-box.expanded #search-icon {
border-radius: 8px 0 0 8px;
box-shadow: none;
}
#search-results {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: rgba(255,255,255,0.97);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
max-height: 240px;
overflow-y: auto;
font-size: 12px;
z-index: 20;
}
#search-results.visible { display: block; }
.search-result {
padding: 7px 10px;
cursor: pointer;
border-bottom: 1px solid rgba(0,0,0,0.06);
line-height: 1.3;
}
.search-result:last-child { border-bottom: none; }
.search-result:hover { background: rgba(0,0,0,0.05); }
.search-result-name { font-weight: 600; }
.search-result-detail { opacity: 0.6; font-size: 11px; }
.panel-surface {
background: rgba(255,255,255,0.7);
border-radius: 12px;
box-shadow: 0 1px 6px rgba(0,0,0,0.25);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
.panel-padded {
padding: 10px 12px;
}
#controls {
min-width: 180px;
font-size: 13px;
}
#controls.collapsed { display: none; }
#legend {
position: absolute;
left: 10px;
bottom: var(--bottom-overlay-bottom);
z-index: 10;
min-width: 260px;
font-size: 12px;
line-height: 1.3;
}
#legend.cursor-only .ramp,
#legend.cursor-only .labels {
display: none;
}
#legend.cursor-only #cursor-info {
margin-top: 0;
}
.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; position: relative; }
.legend-ticks { position: absolute; top: -5px; left: 0; right: 0; height: 5px; display: flex; justify-content: space-between; pointer-events: none; }
.legend-ticks span { width: 1px; height: 5px; background: rgba(0,0,0,0.35); }
#cursor-info { margin-top: 6px; font-size: 11px; opacity: 0.85; }
#cursor-info code { background: rgba(0,0,0,0.05); padding: 1px 4px; border-radius: 3px; }
#status { font-size: 12px; opacity: 0.85; }
#status code { background: rgba(0,0,0,0.05); padding: 1px 4px; border-radius: 3px; }
/* Track editor */
.tb-btn {
background: rgba(255,255,255,0.94);
border: none;
border-radius: 8px;
padding: 6px 8px;
box-shadow: 0 1px 6px rgba(0,0,0,0.25);
font-size: 15px;
cursor: pointer;
line-height: 1;
}
.tb-btn:hover { background: rgba(240,240,240,0.97); }
.tb-btn.active { background: #4a90d9; color: #fff; box-shadow: 0 1px 6px rgba(74,144,217,0.5); }
.tb-btn:disabled {
opacity: 0.4;
cursor: default;
color: rgba(0,0,0,0.45);
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
.tb-btn:disabled:hover { background: rgba(255,255,255,0.94); }
#track-panel-shell {
position: absolute;
top: 10px;
right: 10px;
z-index: 11;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
#track-panel-shell.visible {
padding: 8px 10px 10px;
align-items: stretch;
gap: 0;
}
#track-tool-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
#draw-btn,
#tracks-btn {
position: static;
z-index: auto;
}
#track-panel {
position: static;
z-index: auto;
background: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
font-size: 12px;
min-width: 180px;
max-height: 50vh;
overflow-y: auto;
display: none;
}
#track-panel.visible { display: block; }
.track-panel-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
#track-panel h3 { margin: 0; font-size: 13px; }
#track-panel .track-panel-header #profile-toggle-btn {
margin-left: auto;
}
#track-panel .track-panel-header #track-tool-row {
flex-shrink: 0;
}
#profile-toggle-btn {
background: rgba(74,144,217,0.12);
color: #245480;
border: none;
border-radius: 999px;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
#profile-toggle-btn:hover { background: rgba(74,144,217,0.18); }
#profile-toggle-btn.active { background: #4a90d9; color: #fff; }
#profile-toggle-btn:disabled { opacity: 0.45; cursor: default; }
.track-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 0;
border-bottom: 1px solid rgba(0,0,0,0.06);
cursor: pointer;
}
.track-item:last-child { border-bottom: none; }
.track-item.active { font-weight: 700; }
.track-item .track-color {
width: 10px; height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.track-item .track-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.track-stats { font-size: 10px; opacity: 0.65; margin-left: 14px; }
.track-item .track-del {
background: none; border: none; cursor: pointer; font-size: 13px; opacity: 0.5; padding: 0 2px;
}
.track-item .track-del:hover { opacity: 1; }
.track-export-bar { display: flex; gap: 4px; margin-top: 6px; }
.track-export-bar button {
flex: 1;
background: #4a90d9;
color: #fff;
border: none;
border-radius: 4px;
padding: 4px 6px;
font-size: 11px;
cursor: pointer;
}
.track-export-bar button:hover { background: #3a7bc8; }
#drop-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(74,144,217,0.25);
border: 3px dashed #4a90d9;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 700;
color: #2a5a8a;
pointer-events: none;
}
#drop-overlay.visible { display: flex; }
#mobile-move-hint {
display: none;
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
background: rgba(0,0,0,0.75);
color: #fff;
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
white-space: nowrap;
}
#mobile-move-hint.visible { display: block; }
:root {
--bottom-overlay-bottom: 12px;
--bottom-right-surface: rgba(255,255,255,0.28);
--bottom-right-surface-hover: rgba(255,255,255,0.62);
}
body.profile-open {
--bottom-overlay-bottom: 148px;
}
.maplibregl-ctrl-bottom-right {
right: 10px;
bottom: var(--bottom-overlay-bottom);
}
.maplibregl-ctrl-bottom-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.maplibregl-ctrl-bottom-right .maplibregl-ctrl-group,
.maplibregl-ctrl-bottom-right .maplibregl-ctrl {
margin: 0;
box-shadow: 0 1px 6px rgba(0,0,0,0.1);
background: var(--bottom-right-surface);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
width: fit-content;
max-width: none;
}
.maplibregl-ctrl-bottom-right .maplibregl-ctrl-group > button,
.maplibregl-ctrl-bottom-right .maplibregl-ctrl button {
background: transparent;
width: 40px;
height: 40px;
}
.maplibregl-ctrl-bottom-right .maplibregl-ctrl-group > button:hover,
.maplibregl-ctrl-bottom-right .maplibregl-ctrl button:hover {
background: rgba(255,255,255,0.14);
}
.maplibregl-ctrl-bottom-right .maplibregl-ctrl-group + .maplibregl-ctrl-group {
margin-top: 0;
}
.maplibregl-ctrl-bottom-right .maplibregl-ctrl-geolocate {
border-radius: 999px;
overflow: hidden;
}
.maplibregl-ctrl-bottom-right .maplibregl-ctrl-scale {
align-self: flex-end;
background: var(--bottom-right-surface);
border-radius: 8px;
padding: 3px 8px;
min-width: 0;
text-align: center;
color: rgba(0,0,0,0.8);
box-sizing: border-box;
}
/* Elevation profile */
#profile-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 20;
border-top: 1px solid rgba(0,0,0,0.12);
height: 140px;
display: none;
}
#profile-panel.visible { display: block; }
#profile-panel canvas { width: 100% !important; }
#profile-close {
position: absolute;
top: 2px;
right: 6px;
background: none;
border: none;
font-size: 16px;
cursor: pointer;
opacity: 0.5;
z-index: 1;
line-height: 1;
}
#profile-close:hover { opacity: 1; }
/* end track editor */
</style>
</head>
<body>
<div id="map"></div>
<div id="controls-wrapper">
<div id="toolbar">
<button id="controls-toggle">🌍 Settings ▾</button>
<div id="search-box">
<button id="search-icon">&#128269;</button>
<input id="search-input" type="text" placeholder="Search location..." autocomplete="off" />
<div id="search-results"></div>
</div>
</div>
<div id="controls" class="panel-surface panel-padded">
<div class="row">
<label for="mode"><strong>Mode</strong></label><br />
<select id="mode">
<option value=""></option>
<option value="slope" selected>Slope</option>
<option value="aspect">Aspect</option>
<option value="color-relief">Color relief</option>
</select>
</div>
<div class="row">
<label for="basemap"><strong>Basemap</strong></label><br />
<select id="basemap">
<option value="osm" selected>OSM</option>
<option value="otm">OTM</option>
<option value="ign-plan">IGN plan (FR)</option>
<option value="swisstopo-vector">SwissTopo vector</option>
<option value="kartverket">Kartverket topo (NO)</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>Analysis 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="showContours" type="checkbox" checked /> Show contour lines</label>
</div>
<div class="row">
<label><input id="showOpenSkiMap" type="checkbox" /> OpenSkiMap overlay</label>
</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 class="row">
<label><input id="terrain3d" type="checkbox" /> Enable 3D terrain</label><br />
<label for="terrainExaggeration"><strong>Terrain exaggeration</strong>: <span id="terrainExaggerationValue">1.40</span></label><br />
<input id="terrainExaggeration" type="range" min="1" max="3" step="0.1" value="1.4" disabled />
</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>
<div id="track-panel-shell">
<div id="track-tool-row">
<button id="draw-btn" class="tb-btn" title="Draw track (click to add points, double-click to finish)">&#9998;</button>
<button id="tracks-btn" class="tb-btn" title="Track list" disabled>📍</button>
</div>
<div id="track-panel">
<div class="track-panel-header">
<h3>Tracks</h3>
<button id="profile-toggle-btn" type="button">Profile</button>
</div>
<div id="track-list"></div>
<div class="track-export-bar">
<button id="export-gpx-btn">Export GPX</button>
<button id="export-geojson-btn">Export GeoJSON</button>
<button id="export-all-gpx-btn">Export All GPX</button>
</div>
</div>
</div>
<div id="drop-overlay">Drop GPX / GeoJSON file here</div>
<div id="mobile-move-hint">Pan map to move point &bull; tap elsewhere to deselect</div>
<div id="profile-panel" class="panel-surface">
<button id="profile-close" title="Close">&times;</button>
<canvas id="profile-canvas"></canvas>
</div>
<div id="legend" class="panel-surface panel-padded">
<div id="legendRamp" class="ramp" title="Slope (degrees)"></div>
<div id="legendLabels" class="labels"></div>
<div id="cursor-info">Elevation: <code id="cursorElevation">n/a</code> &middot; Slope: <code id="cursorSlope">n/a</code></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
20, "#c0ffff", // light sky blue
24, "#57ffff", // bright cyan
28, "#00d3db", // aqua blue
31, "#fffa32", // sunshine yellow
34, "#ffc256", // macaroni
37, "#fd7100", // orange
40, "#ff0000", // cherry red
43, "#e958ff", // heliotrope
46, "#a650ff", // lighter purple
49, "#5e1eff", // purplish blue
54, "#0000ff", // rich blue
60, "#aaaaaa", // cool grey
],
aspect: [
'step', ['aspect'],
'#ff0000',
45, '#ffff00',
135, '#00ff00',
225, '#00ffff',
315, '#0000ff'
],
'color-relief': [
'interpolate', ['linear'], ['elevation'],
-250, '#315C8D',
-0.1, '#A9D4E8',
0, '#A9D4E8',
0.1, '#A9D4E8',
50, '#809E47',
100, '#B3C57D',
250, '#D1D98C',
500, '#C8B75F',
750, '#A38766',
1000, '#836A4E',
1500, '#705B43',
2000, '#604E39',
2500, '#C2AB94',
3000, '#D9CCBF',
4000, '#ECE6DF',
5000, '#F6F2EF',
6000, '#FFFFFF',
8000, '#F5FDFF'
]
};
const ANALYSIS_RANGE = {
slope: [0, 90],
aspect: [0, 360],
'color-relief': [-250, 8000]
};
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};
}
function parseInterpolateStops(expression, expectedInput) {
if (!Array.isArray(expression) || expression[0] !== 'interpolate') {
throw new Error('Color expression must be an interpolate expression');
}
const input = expression[2];
if (!Array.isArray(input) || input[0] !== expectedInput) {
throw new Error(`Interpolate input must be ["${expectedInput}"]`);
}
const stops = [];
for (let i = 3; i < expression.length; i += 2) {
stops.push({
value: Number(expression[i]),
color: String(expression[i + 1])
});
}
return stops;
}
const PARSED_RAMPS = {
slope: parseStepRamp(ANALYSIS_COLOR.slope, 'slope'),
aspect: parseStepRamp(ANALYSIS_COLOR.aspect, 'aspect')
};
const COLOR_RELIEF_STOPS = parseInterpolateStops(ANALYSIS_COLOR['color-relief'], 'elevation');
function rampToLegendCss(mode) {
const ramp = PARSED_RAMPS[mode];
const range = ANALYSIS_RANGE[mode];
const min = range[0];
const max = range[1];
const parts = [];
function pct(value) {
return Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100));
}
function rgb(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);
return `rgb(${r}, ${g}, ${b})`;
}
// Build hard-edged step gradient: each color runs from its start to the next threshold.
for (let i = 0; i <= ramp.stepCount; i++) {
const startPct = (i === 0) ? 0 : pct(ramp.values[i - 1]);
const endPct = (i < ramp.stepCount) ? pct(ramp.values[i]) : 100;
parts.push(`${rgb(i)} ${startPct.toFixed(2)}% ${endPct.toFixed(2)}%`);
}
return `linear-gradient(to right, ${parts.join(', ')})`;
}
function interpolateStopsToLegendCss(stops) {
const parts = stops.map(({value, color}, index) => {
const position = Math.max(0, Math.min(100, ((value - stops[0].value) / (stops[stops.length - 1].value - stops[0].value)) * 100));
return `${color} ${position.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',
basemap: 'osm',
basemapOpacity: 1,
hillshadeOpacity: 0.10,
hillshadeMethod: 'igor',
slopeOpacity: 0.45,
showContours: true,
showOpenSkiMap: false,
showTileGrid: false,
multiplyBlend: true,
terrain3d: false,
terrainExaggeration: 1.4,
internalCount: 0,
fallbackCount: 0
};
const BASEMAP_LAYER_GROUPS = {
'osm': ['basemap-osm'],
'otm': ['basemap-otm'],
'ign-plan': ['basemap-ign'],
'swisstopo-vector': [
'basemap-swiss-landcover',
'basemap-swiss-water',
'basemap-swiss-transport',
'basemap-swiss-boundary',
'basemap-swiss-label'
],
'kartverket': ['basemap-kartverket']
};
// Default views for region-specific basemaps (fly here when selected from far away)
const BASEMAP_DEFAULT_VIEW = {
'kartverket': {center: [13.0, 67], zoom: 6, bounds: [3, 57, 32, 72]},
'ign-plan': {center: [2.35, 46.8], zoom: 6, bounds: [-5.5, 41, 10, 51.5]},
'swisstopo-vector': {center: [8.23, 46.82], zoom: 8, bounds: [5.9, 45.8, 10.5, 47.8]}
};
const OPENSKIMAP_LAYER_IDS = [
'basemap-ski-areas',
'basemap-ski-runs',
'basemap-ski-lifts',
'basemap-ski-spots'
];
const ALL_BASEMAP_LAYER_IDS = [...new Set(Object.values(BASEMAP_LAYER_GROUPS).flat())];
function basemapOpacityExpr(multiplier = 1) {
const base = ['coalesce', ['global-state', 'basemapOpacity'], 1];
if (multiplier === 1) return base;
return ['*', multiplier, base];
}
function setLayerVisibilitySafe(map, layerId, visible) {
if (!map.getLayer(layerId)) return;
map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none');
}
function applyBasemapSelection(map, flyIfOutside) {
const activeList = BASEMAP_LAYER_GROUPS[state.basemap] || BASEMAP_LAYER_GROUPS.osm;
const active = new Set(activeList);
for (const layerId of ALL_BASEMAP_LAYER_IDS) {
setLayerVisibilitySafe(map, layerId, active.has(layerId));
}
// Keep active basemap layers directly below hillshade to avoid ordering drift.
for (const layerId of activeList) {
if (map.getLayer(layerId) && map.getLayer('dem-loader')) {
map.moveLayer(layerId, 'dem-loader');
}
}
// Keep OpenSkiMap overlay above basemap but below hillshade
for (const layerId of OPENSKIMAP_LAYER_IDS) {
if (map.getLayer(layerId) && map.getLayer('dem-loader')) {
map.moveLayer(layerId, 'dem-loader');
}
}
// Fly to default view if the current viewport is outside the basemap's coverage
if (flyIfOutside) {
const dv = BASEMAP_DEFAULT_VIEW[state.basemap];
if (dv && dv.bounds) {
const c = map.getCenter();
const [w, s, e, n] = dv.bounds;
if (c.lng < w || c.lng > e || c.lat < s || c.lat > n) {
map.flyTo({center: dv.center, zoom: dv.zoom, duration: 1500});
}
}
}
// Auto show contours only for OSM (other basemaps have their own),
// unless the user or URL explicitly selected a contour visibility.
const shouldShowContours = (state.basemap === 'osm');
state.showContours = shouldShowContours;
const contourCb = document.getElementById('showContours');
if (contourCb) contourCb.checked = shouldShowContours;
applyContourVisibility(map);
}
function applyContourVisibility(map) {
setLayerVisibilitySafe(map, 'contours', state.showContours);
setLayerVisibilitySafe(map, 'contour-text', state.showContours);
}
function applyOpenSkiMapOverlay(map) {
for (const id of OPENSKIMAP_LAYER_IDS) {
setLayerVisibilitySafe(map, id, state.showOpenSkiMap);
}
}
function applyTerrainState(map) {
if (state.terrain3d) {
map.setTerrain({source: DEM_SOURCE_ID, exaggeration: state.terrainExaggeration});
} else {
map.setTerrain(null);
}
}
function parseBooleanParam(value) {
if (value == null || value === '') return null;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return null;
}
function parseHashParams() {
const hash = window.location.hash.replace(/^#/, '');
const fallback = {
center: [6.8652, 45.8326],
zoom: 12,
basemap: null,
mode: 'slope',
slopeOpacity: 0.45,
terrain3d: false,
terrainExaggeration: 1.4,
bearing: 0,
pitch: 0
};
if (hash.includes('=')) {
const params = new URLSearchParams(hash);
const lngRaw = Number(params.get('lng'));
const latRaw = Number(params.get('lat'));
const zoomRaw = Number(params.get('zoom'));
const basemapRaw = (params.get('basemap') || '').trim();
const modeRaw = (params.get('mode') || '').trim();
const opacityRaw = Number(params.get('opacity'));
const terrain3dRaw = parseBooleanParam(params.get('terrain'));
const terrainExaggerationRaw = Number(params.get('exaggeration'));
const bearingRaw = Number(params.get('bearing'));
const pitchRaw = Number(params.get('pitch'));
const hasLng = Number.isFinite(lngRaw) && lngRaw >= -180 && lngRaw <= 180;
const hasLat = Number.isFinite(latRaw) && latRaw >= -85.051129 && latRaw <= 85.051129;
const hasZoom = Number.isFinite(zoomRaw) && zoomRaw >= 0 && zoomRaw <= 24;
const hasOpacity = Number.isFinite(opacityRaw) && opacityRaw >= 0 && opacityRaw <= 1;
const hasTerrainExaggeration = Number.isFinite(terrainExaggerationRaw) && terrainExaggerationRaw >= 1 && terrainExaggerationRaw <= 3;
const hasBearing = Number.isFinite(bearingRaw);
const hasPitch = Number.isFinite(pitchRaw) && pitchRaw >= 0 && pitchRaw <= 85;
const validModes = new Set(['', 'slope', 'aspect', 'color-relief']);
return {
center: (hasLng && hasLat) ? [lngRaw, latRaw] : fallback.center,
zoom: hasZoom ? zoomRaw : fallback.zoom,
basemap: (basemapRaw && BASEMAP_LAYER_GROUPS[basemapRaw]) ? basemapRaw : null,
mode: validModes.has(modeRaw) ? modeRaw : fallback.mode,
slopeOpacity: hasOpacity ? opacityRaw : fallback.slopeOpacity,
terrain3d: terrain3dRaw == null ? fallback.terrain3d : terrain3dRaw,
terrainExaggeration: hasTerrainExaggeration ? terrainExaggerationRaw : fallback.terrainExaggeration,
bearing: hasBearing ? bearingRaw : fallback.bearing,
pitch: hasPitch ? pitchRaw : fallback.pitch
};
}
return fallback;
}
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 bearing = map.getBearing();
const pitch = map.getPitch();
const params = new URLSearchParams();
params.set('lng', center.lng.toFixed(6));
params.set('lat', center.lat.toFixed(6));
params.set('zoom', zoom.toFixed(2));
params.set('basemap', state.basemap);
params.set('mode', state.mode);
params.set('opacity', state.slopeOpacity.toFixed(2));
params.set('terrain', state.terrain3d ? '1' : '0');
params.set('exaggeration', state.terrainExaggeration.toFixed(2));
params.set('bearing', bearing.toFixed(2));
params.set('pitch', pitch.toFixed(2));
const hash = `#${params.toString()}`;
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}${hash}`);
}
function updateStatus() {
document.getElementById('internalCount').textContent = String(state.internalCount);
document.getElementById('fallbackCount').textContent = String(state.fallbackCount);
}
// <elevation sampling start>
function setCursorInfo(eleText, slopeText) {
const el = document.getElementById('cursorElevation');
if (el) el.textContent = eleText;
const sl = document.getElementById('cursorSlope');
if (sl) sl.textContent = slopeText || 'n/a';
}
function sampleElevationFromDEMData(dem, fx, fy) {
if (!dem || typeof dem.get !== 'function' || typeof dem.dim !== 'number') return null;
const dim = dem.dim;
const px = Math.max(0, Math.min(dim - 1, fx * dim));
const py = Math.max(0, Math.min(dim - 1, fy * dim));
const x0 = Math.floor(px);
const y0 = Math.floor(py);
const x1 = Math.min(x0 + 1, dim - 1);
const y1 = Math.min(y0 + 1, dim - 1);
const tx = px - x0;
const ty = py - y0;
const e00 = dem.get(x0, y0);
const e10 = dem.get(x1, y0);
const e01 = dem.get(x0, y1);
const e11 = dem.get(x1, y1);
return (1 - tx) * (1 - ty) * e00 + tx * (1 - ty) * e10 + (1 - tx) * ty * e01 + tx * ty * e11;
}
function queryLoadedElevationAtLngLat(map, lngLat) {
const style = map && map.style;
const tileManager = style && style.tileManagers && style.tileManagers[DEM_SOURCE_ID];
if (!tileManager || !tileManager.getRenderableIds || !tileManager.getTileByID) return null;
const tilesByCanonical = new Map();
for (const id of tileManager.getRenderableIds()) {
const tile = tileManager.getTileByID(id);
if (!tile || !tile.dem || !tile.tileID || !tile.tileID.canonical) continue;
const c = tile.tileID.canonical;
tilesByCanonical.set(`${c.z}/${c.x}/${c.y}`, tile);
}
const lat = Math.max(-85.051129, Math.min(85.051129, lngLat.lat));
const lngWrapped = ((lngLat.lng + 180) % 360 + 360) % 360 - 180;
const rad = lat * Math.PI / 180;
const mx = Math.max(0, Math.min(1 - 1e-15, (lngWrapped + 180) / 360));
const my = Math.max(0, Math.min(1 - 1e-15, (1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2));
const maxZ = Math.min(typeof tileManager.maxzoom === 'number' ? tileManager.maxzoom : DEM_MAX_Z, DEM_MAX_Z);
const minZ = Math.max(typeof tileManager.minzoom === 'number' ? tileManager.minzoom : 0, 0);
for (let z = maxZ; z >= minZ; z--) {
const n = Math.pow(2, z);
const x = Math.min(Math.floor(mx * n), n - 1);
const y = Math.min(Math.floor(my * n), n - 1);
const tile = tilesByCanonical.get(`${z}/${x}/${y}`);
if (!tile || !tile.dem) continue;
const fx = mx * n - x;
const fy = my * n - y;
const elevation = sampleElevationFromDEMData(tile.dem, fx, fy);
if (elevation === null) continue;
// Compute slope via central differences on the DEM grid
const dim = tile.dem.dim;
const px = fx * dim, py = fy * dim;
const dx = 1; // 1 pixel offset
// Sample neighbors (clamped)
const eL = sampleElevationFromDEMData(tile.dem, Math.max(0, (px - dx)) / dim, fy);
const eR = sampleElevationFromDEMData(tile.dem, Math.min(dim - 1, (px + dx)) / dim, fy);
const eD = sampleElevationFromDEMData(tile.dem, fx, Math.max(0, (py - dx)) / dim);
const eU = sampleElevationFromDEMData(tile.dem, fx, Math.min(dim - 1, (py + dx)) / dim);
let slopeDeg = null;
if (eL != null && eR != null && eD != null && eU != null) {
// Mercator is conformal: ground distance per pixel is the same for both axes
// cellMeters = earth_circ * cos(lat) / 2^z / dim
const latRad = lat * Math.PI / 180;
const cellMeters = (40075016.7 / n / dim) * Math.cos(latRad);
const dzx = (eR - eL) / (2 * cellMeters);
const dzy = (eU - eD) / (2 * cellMeters);
slopeDeg = Math.atan(Math.sqrt(dzx * dzx + dzy * dzy)) * 180 / Math.PI;
}
return {elevation, slopeDeg, tileZoom: z};
}
return null;
}
// </elevation sampling end>
function updateLegend() {
const legend = document.getElementById('legend');
const ramp = document.getElementById('legendRamp');
const labels = document.getElementById('legendLabels');
if (!state.mode) {
legend.classList.add('cursor-only');
ramp.title = '';
ramp.style.background = 'none';
labels.innerHTML = '';
} else if (state.mode === 'slope') {
legend.classList.remove('cursor-only');
ramp.title = 'Slope (degrees)';
ramp.style.background = rampToLegendCss('slope');
// semi-opaque legend-ticks are superposed at 15º (6+1 spans) and 15º (18+1 spans) intervals
labels.innerHTML = '<div class="legend-ticks"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>' +
'<div class="legend-ticks"><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>' +
'<span>0°</span><span>15°</span><span>30°</span><span>45°</span><span>60°</span><span>75°</span><span>90°</span>';
} else if (state.mode === 'aspect') {
legend.classList.remove('cursor-only');
ramp.title = 'Aspect (compass direction)';
ramp.style.background = rampToLegendCss('aspect');
labels.innerHTML = '<div class="legend-ticks"><span></span><span></span><span></span><span></span><span></span></div>' +
'<span>N</span><span>E</span><span>S</span><span>W</span><span>N</span>';
} else {
legend.classList.remove('cursor-only');
ramp.title = 'Elevation color relief (meters)';
ramp.style.background = interpolateStopsToLegendCss(COLOR_RELIEF_STOPS);
labels.innerHTML = '<div class="legend-ticks"><span></span><span></span><span></span><span></span><span></span><span></span></div>' +
'<span>-250 m</span><span>0 m</span><span>500 m</span><span>1500 m</span><span>3000 m</span><span>8000 m</span>';
}
}
function applyModeState(map) {
if (map.getLayer('dem-color-relief')) {
map.setLayoutProperty('dem-color-relief', 'visibility', state.mode === 'color-relief' ? 'visible' : 'none');
map.setPaintProperty('dem-color-relief', 'color-relief-opacity', state.slopeOpacity);
}
}
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 /= 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) {
if (!state.mode || state.mode === 'color-relief') {
state.internalCount = 0;
state.fallbackCount = 0;
updateStatus();
return;
}
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 = parseHashParams();
if (initialView.basemap) {
state.basemap = initialView.basemap;
document.getElementById('basemap').value = state.basemap;
}
state.mode = initialView.mode;
state.slopeOpacity = initialView.slopeOpacity;
state.terrain3d = initialView.terrain3d;
state.terrainExaggeration = initialView.terrainExaggeration;
document.getElementById('mode').value = state.mode;
document.getElementById('slopeOpacity').value = String(state.slopeOpacity);
document.getElementById('slopeOpacityValue').textContent = state.slopeOpacity.toFixed(2);
document.getElementById('terrain3d').checked = state.terrain3d;
document.getElementById('terrainExaggeration').value = String(state.terrainExaggeration);
document.getElementById('terrainExaggeration').disabled = !state.terrain3d;
document.getElementById('terrainExaggerationValue').textContent = state.terrainExaggeration.toFixed(2);
// Contour line source from DEM tiles
const demContourSource = new mlcontour.DemSource({
url: 'https://tiles.mapterhorn.com/{z}/{x}/{y}.webp',
encoding: 'terrarium',
maxzoom: 12,
worker: true
});
demContourSource.setupMaplibre(maplibregl);
const map = new maplibregl.Map({
container: 'map',
center: initialView.center,
zoom: initialView.zoom,
bearing: initialView.bearing,
pitch: initialView.pitch,
maxTileCacheZoomLevels: 20,
attributionControl: false,
style: {
version: 8,
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
sources: {
contourSource: {
type: 'vector',
tiles: [
demContourSource.contourProtocolUrl({
multiplier: 1,
overzoom: 1,
thresholds: {
10: [200, 1000],
11: [100, 500],
12: [100, 500],
13: [50, 200],
14: [20, 100],
16: [10, 50]
},
elevationKey: 'ele',
levelKey: 'level',
contourLayer: 'contours'
})
],
maxzoom: 16
},
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '&copy; OpenStreetMap contributors'
},
otm: {
type: 'raster',
tiles: [
'https://a.tile.opentopomap.org/{z}/{x}/{y}.png',
'https://b.tile.opentopomap.org/{z}/{x}/{y}.png',
'https://c.tile.opentopomap.org/{z}/{x}/{y}.png'
],
tileSize: 256,
attribution: '&copy; OpenStreetMap contributors, OpenTopoMap'
},
ignplan: {
type: 'raster',
tiles: ['https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2&STYLE=normal&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/png'],
tileSize: 256,
attribution: '&copy; IGN France'
},
swisstopo: {
type: 'vector',
tiles: ['https://vectortiles.geo.admin.ch/tiles/ch.swisstopo.base.vt/v1.0.0/{z}/{x}/{y}.pbf'],
attribution: '&copy; swisstopo'
},
openskimap: {
type: 'vector',
tiles: ['https://tiles.openskimap.org/openskimap/{z}/{x}/{y}.pbf'],
attribution: '&copy; OpenSkiMap, OpenStreetMap contributors'
},
kartverket: {
type: 'raster',
tiles: ['https://cache.kartverket.no/v1/wmts/1.0.0/topo/default/webmercator/{z}/{y}/{x}.png'],
tileSize: 256,
attribution: '&copy; Kartverket'
},
dem: {
type: 'raster-dem',
tiles: ['https://tiles.mapterhorn.com/{z}/{x}/{y}.webp'],
tileSize: 512,
maxzoom: DEM_MAX_Z,
encoding: 'terrarium'
}
},
layers: [
{
id: 'basemap-osm',
type: 'raster',
source: 'osm',
paint: {
'raster-opacity': basemapOpacityExpr(1)
}
},
{
id: 'basemap-otm',
type: 'raster',
source: 'otm',
layout: {visibility: 'none'},
paint: {
'raster-opacity': basemapOpacityExpr(1)
}
},
{
id: 'basemap-ign',
type: 'raster',
source: 'ignplan',
layout: {visibility: 'none'},
paint: {
'raster-opacity': basemapOpacityExpr(1)
}
},
{
id: 'basemap-kartverket',
type: 'raster',
source: 'kartverket',
layout: {visibility: 'none'},
paint: {
'raster-opacity': basemapOpacityExpr(1)
}
},
{
id: 'basemap-swiss-landcover',
type: 'fill',
source: 'swisstopo',
'source-layer': 'landcover',
layout: {visibility: 'none'},
paint: {
'fill-color': '#dce7cf',
'fill-opacity': basemapOpacityExpr(0.85)
}
},
{
id: 'basemap-swiss-water',
type: 'fill',
source: 'swisstopo',
'source-layer': 'water',
layout: {visibility: 'none'},
paint: {
'fill-color': '#b7d7ff',
'fill-opacity': basemapOpacityExpr(0.95)
}
},
{
id: 'basemap-swiss-transport',
type: 'line',
source: 'swisstopo',
'source-layer': 'transportation',
layout: {visibility: 'none'},
paint: {
'line-color': '#7a7a7a',
'line-width': ['interpolate', ['linear'], ['zoom'], 5, 0.2, 14, 1.8],
'line-opacity': basemapOpacityExpr(0.9)
}
},
{
id: 'basemap-swiss-boundary',
type: 'line',
source: 'swisstopo',
'source-layer': 'boundary',
layout: {visibility: 'none'},
paint: {
'line-color': '#7f4b63',
'line-width': ['interpolate', ['linear'], ['zoom'], 5, 0.25, 14, 1.25],
'line-opacity': basemapOpacityExpr(0.75)
}
},
{
id: 'basemap-swiss-label',
type: 'symbol',
source: 'swisstopo',
'source-layer': 'place',
layout: {
visibility: 'none',
'text-field': ['coalesce', ['get', 'name'], ['get', 'name_de'], ['get', 'name_fr'], ['get', 'name_it'], ''],
'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 14, 13]
},
paint: {
'text-color': '#2e2e2e',
'text-opacity': basemapOpacityExpr(0.9),
'text-halo-color': '#ffffff',
'text-halo-width': 1
}
},
{
id: 'basemap-ski-areas',
type: 'fill',
source: 'openskimap',
'source-layer': 'skiareas',
layout: {visibility: 'none'},
paint: {
'fill-color': '#dff1ff',
'fill-opacity': basemapOpacityExpr(0.35)
}
},
{
id: 'basemap-ski-runs',
type: 'line',
source: 'openskimap',
'source-layer': 'runs',
layout: {visibility: 'none'},
paint: {
'line-color': '#0d7cff',
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.9, 14, 2.6],
'line-opacity': basemapOpacityExpr(0.95)
}
},
{
id: 'basemap-ski-lifts',
type: 'line',
source: 'openskimap',
'source-layer': 'lifts',
layout: {visibility: 'none'},
paint: {
'line-color': '#121212',
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 14, 2.0],
'line-opacity': basemapOpacityExpr(0.9)
}
},
{
id: 'basemap-ski-spots',
type: 'symbol',
source: 'openskimap',
'source-layer': 'spots',
layout: {
visibility: 'none',
'text-field': ['coalesce', ['get', 'name'], ''],
'text-size': ['interpolate', ['linear'], ['zoom'], 8, 10, 14, 12]
},
paint: {
'text-color': '#10243f',
'text-opacity': basemapOpacityExpr(0.9),
'text-halo-color': '#ffffff',
'text-halo-width': 1
}
},
{
id: 'dem-loader',
type: 'hillshade',
source: DEM_SOURCE_ID,
paint: {
'hillshade-method': state.hillshadeMethod,
//'hillshade-illumination-anchor': 'map',
'hillshade-exaggeration': ['coalesce', ['global-state', 'hillshadeOpacity'], 0.35],
'hillshade-shadow-color': '#000000',
'hillshade-highlight-color': '#ffffff',
'hillshade-accent-color': '#000000',
}
}
]
},
canvasContextAttributes: {antialias: true}
});
const navigationControl = new maplibregl.NavigationControl({
visualizePitch: true,
visualizeRoll: true,
showZoom: true,
showCompass: true
});
const geolocateControl = new maplibregl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true
},
trackUserLocation: false,
showUserLocation: true,
showAccuracyCircle: false
});
const scaleControl = new maplibregl.ScaleControl({
unit: 'metric',
maxWidth: 120
});
map.addControl(scaleControl, 'bottom-right');
map.addControl(new maplibregl.AttributionControl(), 'bottom-right');
map.addControl(navigationControl, 'bottom-right');
map.addControl(geolocateControl, 'bottom-right');
const controlsPanel = document.getElementById('controls');
const controlsToggleBtn = document.getElementById('controls-toggle');
function syncControlsToggleLabel() {
controlsToggleBtn.textContent = controlsPanel.classList.contains('collapsed') ? '🌍 Settings ▸' : '🌍 Settings ▾';
}
function setControlsCollapsed(collapsed) {
controlsPanel.classList.toggle('collapsed', collapsed);
syncControlsToggleLabel();
}
controlsToggleBtn.addEventListener('click', () => {
setControlsCollapsed(!controlsPanel.classList.contains('collapsed'));
});
syncControlsToggleLabel();
map.on('dragstart', () => {
setControlsCollapsed(true);
});
// <search start>
const searchBox = document.getElementById('search-box');
const searchIcon = document.getElementById('search-icon');
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
let searchDebounce = 0;
let searchAbort = null;
function expandSearch() {
searchBox.classList.add('expanded');
searchInput.focus();
}
function collapseSearch() {
searchBox.classList.remove('expanded');
searchInput.value = '';
searchResults.classList.remove('visible');
searchResults.innerHTML = '';
}
searchIcon.addEventListener('click', () => {
if (searchBox.classList.contains('expanded')) {
collapseSearch();
} else {
expandSearch();
}
});
// Desktop: expand on hover
searchBox.addEventListener('mouseenter', () => {
if (!searchBox.classList.contains('expanded')) expandSearch();
});
// Close on click outside
document.addEventListener('click', (e) => {
if (!searchBox.contains(e.target)) collapseSearch();
});
// Close on Escape
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') collapseSearch();
});
async function nominatimSearch(query) {
if (searchAbort) searchAbort.abort();
searchAbort = new AbortController();
const url = `https://nominatim.openstreetmap.org/search?format=json&limit=5&q=${encodeURIComponent(query)}`;
const resp = await fetch(url, {
signal: searchAbort.signal,
headers: {'Accept-Language': navigator.language || 'en'}
});
return resp.json();
}
function renderResults(results) {
searchResults.innerHTML = '';
if (!results.length) {
searchResults.classList.remove('visible');
return;
}
for (const r of results) {
const div = document.createElement('div');
div.className = 'search-result';
const nameParts = r.display_name.split(', ');
const name = nameParts[0];
const detail = nameParts.slice(1, 3).join(', ');
div.innerHTML = `<div class="search-result-name">${name}</div><div class="search-result-detail">${detail}</div>`;
div.addEventListener('click', () => {
const lon = parseFloat(r.lon);
const lat = parseFloat(r.lat);
const bbox = r.boundingbox;
if (bbox) {
map.fitBounds([[parseFloat(bbox[2]), parseFloat(bbox[0])], [parseFloat(bbox[3]), parseFloat(bbox[1])]], {
padding: 40, maxZoom: 15, duration: 1500
});
} else {
map.flyTo({center: [lon, lat], zoom: 13, duration: 1500});
}
collapseSearch();
});
searchResults.appendChild(div);
}
searchResults.classList.add('visible');
}
searchInput.addEventListener('input', () => {
const q = searchInput.value.trim();
clearTimeout(searchDebounce);
if (q.length < 2) {
searchResults.classList.remove('visible');
searchResults.innerHTML = '';
return;
}
searchDebounce = setTimeout(async () => {
try {
const results = await nominatimSearch(q);
renderResults(results);
} catch (err) {
if (err.name !== 'AbortError') console.error('Search failed:', err);
}
}, 350);
});
// </search end>
document.getElementById('mode').addEventListener('change', (e) => {
state.mode = e.target.value;
updateLegend();
applyModeState(map);
syncViewToUrl(map);
map.triggerRepaint();
});
document.getElementById('basemap').addEventListener('change', (e) => {
state.basemap = e.target.value;
applyBasemapSelection(map, true);
syncViewToUrl(map);
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);
applyModeState(map);
syncViewToUrl(map);
map.triggerRepaint();
});
document.getElementById('showContours').addEventListener('change', (e) => {
state.showContours = Boolean(e.target.checked);
applyContourVisibility(map);
});
document.getElementById('showOpenSkiMap').addEventListener('change', (e) => {
state.showOpenSkiMap = Boolean(e.target.checked);
applyOpenSkiMapOverlay(map);
});
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();
});
document.getElementById('terrain3d').addEventListener('change', (e) => {
state.terrain3d = Boolean(e.target.checked);
document.getElementById('terrainExaggeration').disabled = !state.terrain3d;
applyTerrainState(map);
syncViewToUrl(map);
map.triggerRepaint();
});
document.getElementById('terrainExaggeration').addEventListener('input', (e) => {
state.terrainExaggeration = Number(e.target.value);
document.getElementById('terrainExaggerationValue').textContent = state.terrainExaggeration.toFixed(2);
if (state.terrain3d) {
applyTerrainState(map);
}
syncViewToUrl(map);
map.triggerRepaint();
});
// Allow Cmd+drag (Mac) to act like Ctrl+drag for rotate/pitch
map.getCanvas().addEventListener('mousedown', (e) => {
if (e.metaKey && !e.ctrlKey && e.button === 0) {
const synth = new MouseEvent('mousedown', {
bubbles: true, cancelable: true,
clientX: e.clientX, clientY: e.clientY,
button: e.button, buttons: e.buttons,
ctrlKey: true, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: false
});
e.target.dispatchEvent(synth);
e.preventDefault();
e.stopPropagation();
}
}, {capture: true});
updateLegend();
updateStatus();
map.on('load', () => {
ensureDebugGridLayer(map);
setGlobalStatePropertySafe(map, 'basemapOpacity', state.basemapOpacity);
setGlobalStatePropertySafe(map, 'hillshadeOpacity', state.hillshadeOpacity);
applyBasemapSelection(map);
applyOpenSkiMapOverlay(map);
applyTerrainState(map);
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.on('rotateend', () => {
syncViewToUrl(map);
});
map.on('pitchend', () => {
syncViewToUrl(map);
});
const hybridLayer = createHybridBorderLayer();
map.addLayer(hybridLayer);
map.addLayer({
id: 'dem-color-relief',
type: 'color-relief',
source: DEM_SOURCE_ID,
layout: {
visibility: state.mode === 'color-relief' ? 'visible' : 'none'
},
paint: {
'color-relief-opacity': state.slopeOpacity,
'color-relief-color': ANALYSIS_COLOR['color-relief']
}
});
applyModeState(map);
// Contour lines (on top of slope overlay)
map.addLayer({
id: 'contours',
type: 'line',
source: 'contourSource',
'source-layer': 'contours',
paint: {
'line-opacity': 0.2,
'line-width': ['match', ['get', 'level'], 1, 1, 0.5]
}
});
map.addLayer({
id: 'contour-text',
type: 'symbol',
source: 'contourSource',
'source-layer': 'contours',
filter: ['>', ['get', 'level'], 0],
paint: {
'text-halo-color': 'white',
'text-halo-width': 1
},
layout: {
'symbol-placement': 'line',
'text-size': 10,
'text-field': ['concat', ['number-format', ['get', 'ele'], {}], 'm'],
'text-font': ['Noto Sans Bold']
}
});
applyContourVisibility(map);
// <elevation sampling start>
let cursorRaf = 0;
let lastPointerLngLat = null;
const updateCursorElevation = () => {
cursorRaf = 0;
if (!lastPointerLngLat) {
setCursorInfo('n/a');
return;
}
const result = queryLoadedElevationAtLngLat(map, lastPointerLngLat);
if (!result) {
setCursorInfo('no loaded tile');
return;
}
const slopeStr = result.slopeDeg != null ? `${result.slopeDeg.toFixed(1)}°` : 'n/a';
setCursorInfo(`${result.elevation.toFixed(1)} m (z${result.tileZoom})`, slopeStr);
};
map.on('mousemove', (e) => {
lastPointerLngLat = e.lngLat;
if (!cursorRaf) cursorRaf = requestAnimationFrame(updateCursorElevation);
});
map.on('mouseout', () => {
lastPointerLngLat = null;
if (!cursorRaf) cursorRaf = requestAnimationFrame(updateCursorElevation);
});
// </elevation sampling end>
// <tile border fix start>
// When new DEM tiles finish loading, their neighbors already rendered
// with self-cloned borders need to be re-drawn with the real neighbor data.
// Invalidate cached internal textures so they get re-uploaded next frame.
let borderFixTimer = 0;
const flushInternalTextures = () => {
borderFixTimer = 0;
const gl = map.painter && map.painter.context && map.painter.context.gl;
if (gl) {
for (const entry of hybridLayer.internalTextures.values()) {
if (entry.texture) gl.deleteTexture(entry.texture);
}
}
hybridLayer.internalTextures.clear();
map.triggerRepaint();
};
map.on('data', (e) => {
if (e.sourceId === DEM_SOURCE_ID && e.dataType === 'source') {
// Debounce: multiple tiles may arrive in quick succession.
if (!borderFixTimer) {
borderFixTimer = setTimeout(flushInternalTextures, 100);
}
}
});
// </tile border fix end>
});
// Navigate map when user manually edits the hash and presses Enter
let hashNavInProgress = false;
window.addEventListener('hashchange', () => {
if (hashNavInProgress) return;
const p = parseHashParams();
hashNavInProgress = true;
map.jumpTo({center: p.center, zoom: p.zoom});
state.basemap = p.basemap || 'osm';
state.mode = p.mode;
state.slopeOpacity = p.slopeOpacity;
state.terrain3d = p.terrain3d;
state.terrainExaggeration = p.terrainExaggeration;
document.getElementById('basemap').value = state.basemap;
document.getElementById('mode').value = state.mode;
document.getElementById('slopeOpacity').value = String(state.slopeOpacity);
document.getElementById('slopeOpacityValue').textContent = state.slopeOpacity.toFixed(2);
document.getElementById('terrain3d').checked = state.terrain3d;
document.getElementById('terrainExaggeration').value = String(state.terrainExaggeration);
document.getElementById('terrainExaggeration').disabled = !state.terrain3d;
document.getElementById('terrainExaggerationValue').textContent = state.terrainExaggeration.toFixed(2);
updateLegend();
map.setBearing(p.bearing);
map.setPitch(p.pitch);
applyBasemapSelection(map, true);
applyModeState(map);
applyTerrainState(map);
hashNavInProgress = false;
syncViewToUrl(map);
map.triggerRepaint();
});
map.on('error', (e) => {
console.error('Map error:', e && e.error ? e.error.message : e);
});
// ========== TRACK EDITOR ==========
const TRACK_COLORS = ['#e040fb','#ff5252','#00e676','#ffab00','#2979ff','#00e5ff','#ff6e40','#d500f9'];
let trackColorIdx = 0;
const isMobile = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
const tracks = []; // {id, name, color, coords:[[lng,lat,ele],...]}
let activeTrackId = null;
let drawMode = false;
let dragVertexInfo = null; // {trackId, index} — desktop drag
let mobileSelectedVertex = null; // {trackId, index} — mobile move mode
let suppressNextMapClick = false;
const drawBtn = document.getElementById('draw-btn');
const tracksBtn = document.getElementById('tracks-btn');
const trackPanelShell = document.getElementById('track-panel-shell');
const trackToolRow = document.getElementById('track-tool-row');
const trackPanel = document.getElementById('track-panel');
const trackPanelHeader = trackPanel.querySelector('.track-panel-header');
const trackListEl = document.getElementById('track-list');
const profileToggleBtn = document.getElementById('profile-toggle-btn');
const dropOverlay = document.getElementById('drop-overlay');
const mobileHint = document.getElementById('mobile-move-hint');
function nextColor() {
const c = TRACK_COLORS[trackColorIdx % TRACK_COLORS.length];
trackColorIdx++;
return c;
}
function invalidateTrackStats(t) {
if (t) t._statsCache = null;
}
function invalidateAllTrackStats() {
for (const t of tracks) invalidateTrackStats(t);
}
function elevationAt(lngLat) {
const r = queryLoadedElevationAtLngLat(map, lngLat);
return r ? Math.round(r.elevation * 10) / 10 : null;
}
function representativeTrackSampleSpacingMeters(coords, totalDistMeters) {
if (!coords.length) return 4;
const meanLat = coords.reduce((sum, c) => sum + c[1], 0) / coords.length;
const nominal = (40075016.7 / Math.pow(2, DEM_MAX_Z) / CORE_DIM) * Math.max(0.25, Math.cos(meanLat * Math.PI / 180));
return Math.max(2, nominal, totalDistMeters / 2000);
}
function interpolateTrackLngLat(coords, cumulativeMeters, targetMeters) {
if (targetMeters <= 0) return {lng: coords[0][0], lat: coords[0][1]};
const totalMeters = cumulativeMeters[cumulativeMeters.length - 1];
if (targetMeters >= totalMeters) {
const last = coords[coords.length - 1];
return {lng: last[0], lat: last[1]};
}
let segIndex = 1;
while (segIndex < cumulativeMeters.length && cumulativeMeters[segIndex] < targetMeters) segIndex++;
const startMeters = cumulativeMeters[segIndex - 1];
const endMeters = cumulativeMeters[segIndex];
const spanMeters = endMeters - startMeters;
const t = spanMeters > 0 ? (targetMeters - startMeters) / spanMeters : 0;
const a = coords[segIndex - 1];
const b = coords[segIndex];
return {
lng: a[0] + (b[0] - a[0]) * t,
lat: a[1] + (b[1] - a[1]) * t
};
}
function computeTerrainSlopeAlongTrack(coords, cumulativeMeters, totalDistMeters) {
if (coords.length < 2 || totalDistMeters <= 0) {
return {average: null, maximum: null, sampleCount: 0, resolvedCount: 0};
}
const spacingMeters = representativeTrackSampleSpacingMeters(coords, totalDistMeters);
const sampleCount = Math.max(2, Math.ceil(totalDistMeters / spacingMeters) + 1);
let sum = 0;
let maximum = -Infinity;
let resolvedCount = 0;
for (let i = 0; i < sampleCount; i++) {
const distanceMeters = sampleCount === 1 ? 0 : (i / (sampleCount - 1)) * totalDistMeters;
const lngLat = interpolateTrackLngLat(coords, cumulativeMeters, distanceMeters);
const sample = queryLoadedElevationAtLngLat(map, lngLat);
if (!sample || !Number.isFinite(sample.slopeDeg)) continue;
sum += sample.slopeDeg;
maximum = Math.max(maximum, sample.slopeDeg);
resolvedCount++;
}
return {
average: resolvedCount ? sum / resolvedCount : null,
maximum: resolvedCount ? maximum : null,
sampleCount,
resolvedCount
};
}
function enrichElevation(coords) {
for (const c of coords) {
if (c[2] != null) continue;
const e = elevationAt({lng: c[0], lat: c[1]});
if (e != null) c[2] = e;
}
}
function enrichAllTracks() {
let anyUpdated = false;
for (const t of tracks) {
for (const c of t.coords) {
if (c[2] != null) continue;
const e = elevationAt({lng: c[0], lat: c[1]});
if (e != null) { c[2] = e; anyUpdated = true; }
}
}
if (anyUpdated) {
invalidateAllTrackStats();
refreshAllTrackSources();
renderTrackList();
updateProfile();
}
}
// Re-enrich when new DEM tiles load
map.on('data', (e) => {
if (e.sourceId === DEM_SOURCE_ID && e.dataType === 'source' && tracks.length) {
invalidateAllTrackStats();
setTimeout(() => {
enrichAllTracks();
renderTrackList();
}, 200);
}
});
// --- Map sources & layers ---
function trackSourceId(t) { return 'track-' + t.id; }
function trackLineLayerId(t) { return 'track-line-' + t.id; }
function trackPtsLayerId(t) { return 'track-pts-' + t.id; }
const PROFILE_HOVER_SOURCE_ID = 'profile-hover-point';
const PROFILE_HOVER_LAYER_ID = 'profile-hover-point-layer';
function trackGeoJSON(t) {
const features = [];
if (t.coords.length >= 2) {
features.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: t.coords.map(c => c.slice()) },
properties: { id: t.id }
});
}
const last = t.coords.length - 1;
for (let i = 0; i < t.coords.length; i++) {
const role = i === 0 ? 'start' : i === last ? 'end' : 'mid';
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: t.coords[i].slice() },
properties: { id: t.id, idx: i, role }
});
if (i < last) {
const a = t.coords[i], b = t.coords[i + 1];
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [(a[0]+b[0])/2, (a[1]+b[1])/2] },
properties: { id: t.id, insertAfter: i, role: 'insert' }
});
}
}
return { type: 'FeatureCollection', features };
}
function addTrackToMap(t) {
const isActive = ['==', ['get', 'id'], ['global-state', 'activeTrackId']];
const isStartOrEnd = ['any', ['==', ['get', 'role'], 'start'], ['==', ['get', 'role'], 'end']];
map.addSource(trackSourceId(t), { type: 'geojson', data: trackGeoJSON(t) });
map.addLayer({
id: trackLineLayerId(t), type: 'line', source: trackSourceId(t),
filter: ['==', '$type', 'LineString'],
paint: { 'line-color': t.color, 'line-width': 3, 'line-opacity': 0.9 }
});
map.addLayer({
id: trackPtsLayerId(t), type: 'circle', source: trackSourceId(t),
filter: ['==', '$type', 'Point'],
paint: {
'circle-radius': ['case',
isActive, ['match', ['get', 'role'], 'insert', 3, 4],
isStartOrEnd, 3,
0],
'circle-color': ['match', ['get', 'role'],
'start', '#22c55e', 'end', '#ef4444',
'insert', 'rgba(128,128,128,0.5)', t.color],
'circle-stroke-color': '#fff',
'circle-stroke-width': ['case',
isActive, ['match', ['get', 'role'], 'insert', 0.5, 1.5],
isStartOrEnd, 1,
0]
}
});
}
function ensureProfileHoverLayer() {
if (!map.getSource(PROFILE_HOVER_SOURCE_ID)) {
map.addSource(PROFILE_HOVER_SOURCE_ID, {
type: 'geojson',
data: {type: 'FeatureCollection', features: []}
});
}
if (!map.getLayer(PROFILE_HOVER_LAYER_ID)) {
map.addLayer({
id: PROFILE_HOVER_LAYER_ID,
type: 'circle',
source: PROFILE_HOVER_SOURCE_ID,
paint: {
'circle-radius': 7,
'circle-color': ['coalesce', ['get', 'color'], '#4a90d9'],
'circle-opacity': 0.95,
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 2.5
}
});
}
}
function removeTrackFromMap(t) {
if (map.getLayer(trackPtsLayerId(t))) map.removeLayer(trackPtsLayerId(t));
if (map.getLayer(trackLineLayerId(t))) map.removeLayer(trackLineLayerId(t));
if (map.getSource(trackSourceId(t))) map.removeSource(trackSourceId(t));
}
function refreshTrackSource(t) {
const src = map.getSource(trackSourceId(t));
if (src) src.setData(trackGeoJSON(t));
}
function refreshAllTrackSources() {
for (const t of tracks) refreshTrackSource(t);
}
function updateVertexHighlight() {
map.setGlobalStateProperty('activeTrackId', activeTrackId);
}
function syncProfileToggleButton() {
if (typeof profileClosed === 'undefined') return;
const t = getActiveTrack();
const canShow = Boolean(t && t.coords.length >= 2);
profileToggleBtn.disabled = !canShow;
const isVisible = canShow && profilePanel.classList.contains('visible') && !profileClosed;
profileToggleBtn.classList.toggle('active', isVisible);
profileToggleBtn.textContent = isVisible ? 'Profile' : 'Show Profile';
}
function syncTrackPanelShell() {
const isVisible = trackPanel.classList.contains('visible');
const hasTracks = tracks.length > 0;
const toolRowParent = isVisible ? trackPanelHeader : trackPanelShell;
const toolRowNextSibling = isVisible ? null : trackPanel;
if (trackToolRow.parentElement !== toolRowParent) {
toolRowParent.insertBefore(trackToolRow, toolRowNextSibling);
}
trackPanelShell.classList.toggle('visible', isVisible);
trackPanelShell.classList.toggle('panel-surface', isVisible);
tracksBtn.disabled = !hasTracks;
tracksBtn.classList.toggle('active', isVisible && hasTracks);
tracksBtn.textContent = isVisible && hasTracks ? '×' : '📍'; // FIXME align icon
tracksBtn.title = hasTracks ? (isVisible ? 'Close tracks' : 'Track list') : 'No tracks';
}
function setTrackPanelVisible(visible) {
trackPanel.classList.toggle('visible', visible && tracks.length > 0);
syncTrackPanelShell();
}
syncTrackPanelShell();
// --- Track panel UI ---
function trackStats(t) {
if (!t || t.coords.length < 2) return null;
if (t._statsCache) return t._statsCache;
const coords = t.coords;
let dist = 0, gain = 0, loss = 0;
let weightedSlopeSum = 0;
let weightedSlopeDist = 0;
const cumulativeMeters = [0];
for (let i = 1; i < coords.length; i++) {
const segKm = haversineKm(coords[i - 1], coords[i]);
const segMeters = segKm * 1000;
dist += segKm;
cumulativeMeters.push(cumulativeMeters[i - 1] + segMeters);
if (coords[i][2] != null && coords[i-1][2] != null) {
const dh = coords[i][2] - coords[i-1][2];
if (dh > 0) gain += dh; else loss -= dh;
if (segMeters > 0) {
weightedSlopeSum += Math.atan2(Math.abs(dh), segMeters) * 180 / Math.PI * segMeters;
weightedSlopeDist += segMeters;
}
}
}
const terrainSlopeAlongTrack = computeTerrainSlopeAlongTrack(coords, cumulativeMeters, dist * 1000);
const segmentMaxSlope = weightedSlopeDist > 0
? coords.reduce((maxSlope, _coord, index) => {
if (index === 0) return maxSlope;
const segMeters = cumulativeMeters[index] - cumulativeMeters[index - 1];
if (segMeters <= 0 || coords[index][2] == null || coords[index - 1][2] == null) return maxSlope;
const dh = coords[index][2] - coords[index - 1][2];
const slopeDeg = Math.atan2(Math.abs(dh), segMeters) * 180 / Math.PI;
return Math.max(maxSlope, slopeDeg);
}, -Infinity)
: null;
const maxTerrainSlope = terrainSlopeAlongTrack.maximum != null
? terrainSlopeAlongTrack.maximum
: (segmentMaxSlope != null && Number.isFinite(segmentMaxSlope) ? segmentMaxSlope : null);
t._statsCache = {
dist,
gain,
loss,
avgSlope: weightedSlopeDist > 0 ? weightedSlopeSum / weightedSlopeDist : null,
terrainSlopeAlongTrack: terrainSlopeAlongTrack.average,
maxTerrainSlope,
terrainSlopeResolvedCount: terrainSlopeAlongTrack.resolvedCount,
terrainSlopeSampleCount: terrainSlopeAlongTrack.sampleCount
};
return t._statsCache;
}
function renderTrackList() {
trackListEl.innerHTML = '';
for (const t of tracks) {
const div = document.createElement('div');
div.className = 'track-item' + (t.id === activeTrackId ? ' active' : '');
const s = t.coords.length >= 2 ? trackStats(t) : null;
const statsStr = s ? `${s.dist.toFixed(1)} km · ↑${Math.round(s.gain)} m · ↓${Math.round(s.loss)} m · ${t.coords.length} pts` : `${t.coords.length} pts`;
const detailStatsStr = (s && t.id === activeTrackId)
? `Avg slope: ${s.avgSlope != null ? `${s.avgSlope.toFixed(1)}°` : 'n/a'} · Max slope: ${s.maxTerrainSlope != null ? `${s.maxTerrainSlope.toFixed(1)}°` : 'n/a'}`
: '';
div.innerHTML = `<span class="track-color" style="background:${t.color}"></span>` +
`<span class="track-name">${t.name}` +
(statsStr ? `<br><span class="track-stats">${statsStr}</span>` : '') +
(detailStatsStr ? `<br><span class="track-stats">${detailStatsStr}</span>` : '') +
`</span>` +
`<button class="track-del" data-id="${t.id}">&times;</button>`;
div.addEventListener('click', (e) => {
if (e.target.classList.contains('track-del')) return;
setActiveTrack(t.id);
});
div.querySelector('.track-del').addEventListener('click', () => deleteTrack(t.id));
trackListEl.appendChild(div);
}
if (!tracks.length) setTrackPanelVisible(false);
else syncTrackPanelShell();
syncProfileToggleButton();
}
function setActiveTrack(id) {
if (id !== activeTrackId && typeof profileClosed !== 'undefined') profileClosed = false;
activeTrackId = id;
renderTrackList();
updateVertexHighlight();
updateProfile();
}
function deleteTrack(id) {
const idx = tracks.findIndex(t => t.id === id);
if (idx < 0) return;
removeTrackFromMap(tracks[idx]);
tracks.splice(idx, 1);
if (activeTrackId === id) activeTrackId = tracks.length ? tracks[tracks.length - 1].id : null;
renderTrackList();
updateVertexHighlight();
}
let mapReady = false;
function createTrack(name, coords) {
const t = { id: 'trk-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6), name, color: nextColor(), coords, _statsCache: null };
enrichElevation(t.coords);
tracks.push(t);
if (mapReady) addTrackToMap(t);
setTrackPanelVisible(true);
setActiveTrack(t.id);
return t;
}
function getActiveTrack() {
return tracks.find(t => t.id === activeTrackId) || null;
}
function setDefaultMapCursor() {
if (!drawMode) map.getCanvas().style.cursor = 'cell';
}
// --- Draw mode ---
function enterDrawMode() {
drawMode = true;
drawBtn.classList.add('active');
map.getCanvas().style.cursor = 'crosshair';
createTrack('Track ' + (tracks.length + 1), []);
}
function exitDrawMode() {
drawMode = false;
drawBtn.classList.remove('active');
setDefaultMapCursor();
const t = getActiveTrack();
if (t && t.coords.length < 2) deleteTrack(t.id);
else updateProfile();
}
drawBtn.addEventListener('click', () => {
if (drawMode) exitDrawMode();
else enterDrawMode();
});
tracksBtn.addEventListener('click', () => {
setTrackPanelVisible(!trackPanel.classList.contains('visible'));
});
// --- Map click: add vertex in draw mode, or select vertex for mobile ---
map.on('click', (e) => {
if (suppressNextMapClick) {
suppressNextMapClick = false;
return;
}
if (drawMode) {
const t = getActiveTrack();
if (!t) return;
const ele = elevationAt(e.lngLat);
t.coords.push([e.lngLat.lng, e.lngLat.lat, ele]);
invalidateTrackStats(t);
refreshTrackSource(t);
renderTrackList();
updateProfile();
return;
}
// Click on insert-point: splice a new vertex
if (activeTrackId) {
const hit = hitTestVertex(e.point);
if (hit && hit.insertAfter != null) {
const t = tracks.find(tr => tr.id === hit.trackId);
if (t) {
const a = t.coords[hit.insertAfter], b = t.coords[hit.insertAfter + 1];
const midLng = (a[0]+b[0])/2, midLat = (a[1]+b[1])/2;
const ele = elevationAt({lng: midLng, lat: midLat});
t.coords.splice(hit.insertAfter + 1, 0, [midLng, midLat, ele]);
invalidateTrackStats(t);
refreshTrackSource(t);
renderTrackList();
updateProfile();
}
return;
}
}
// Modified click: delete vertex
if (e.originalEvent.shiftKey || e.originalEvent.ctrlKey || e.originalEvent.metaKey) {
const hit = hitTestVertex(e.point);
if (hit && hit.index != null) {
const t = tracks.find(tr => tr.id === hit.trackId);
if (t) {
t.coords.splice(hit.index, 1);
invalidateTrackStats(t);
refreshTrackSource(t);
renderTrackList();
updateProfile();
if (t.coords.length === 0) deleteTrack(t.id);
}
return;
}
}
// Mobile: select vertex for move
if (isMobile && activeTrackId) {
const hitPt = hitTestVertex(e.point);
if (hitPt) {
mobileSelectedVertex = hitPt;
mobileHint.classList.add('visible');
map.dragPan.disable();
return;
}
if (mobileSelectedVertex) {
cancelMobileMove();
}
}
});
setDefaultMapCursor();
map.on('dblclick', (e) => {
if (drawMode) {
e.preventDefault();
exitDrawMode();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && drawMode) exitDrawMode();
if (e.key === 'Escape' && mobileSelectedVertex) cancelMobileMove();
});
// --- Vertex hit test ---
function hitTestVertex(point) {
if (!activeTrackId) return null;
const t = tracks.find(tr => tr.id === activeTrackId);
if (!t) return null;
const layerId = trackPtsLayerId(t);
if (!map.getLayer(layerId)) return null;
const r = 12;
const features = map.queryRenderedFeatures(
[[point.x - r, point.y - r], [point.x + r, point.y + r]],
{ layers: [layerId] }
);
if (!features.length) return null;
// Prefer real vertices over insert-points
const real = features.find(f => f.properties.role !== 'insert');
if (real) return { trackId: t.id, index: real.properties.idx };
const ins = features.find(f => f.properties.role === 'insert');
if (ins) return { trackId: t.id, insertAfter: ins.properties.insertAfter };
return null;
}
// --- Desktop: drag vertices ---
if (!isMobile) {
let hoveredVertex = false;
let dragMoved = false;
function finishVertexDrag() {
if (!dragVertexInfo) return;
const t = tracks.find(tr => tr.id === dragVertexInfo.trackId);
dragVertexInfo = null;
map.dragPan.enable();
hoveredVertex = false;
if (dragMoved) suppressNextMapClick = true;
dragMoved = false;
setDefaultMapCursor();
if (t) {
invalidateTrackStats(t);
renderTrackList();
updateProfile();
}
}
map.on('mousedown', (e) => {
if (drawMode || !activeTrackId) return;
const hit = hitTestVertex(e.point);
if (!hit || hit.index == null) return;
e.preventDefault();
e.originalEvent.stopPropagation();
dragVertexInfo = hit;
dragMoved = false;
setActiveTrack(hit.trackId);
map.dragPan.disable();
map.getCanvas().style.cursor = 'grabbing';
});
map.on('mousemove', (e) => {
if (dragVertexInfo) {
const t = tracks.find(tr => tr.id === dragVertexInfo.trackId);
if (!t) return;
const c = t.coords[dragVertexInfo.index];
c[0] = e.lngLat.lng;
c[1] = e.lngLat.lat;
c[2] = elevationAt(e.lngLat);
dragMoved = true;
refreshTrackSource(t);
return;
}
if (!drawMode) {
const hit = hitTestVertex(e.point);
const isRealVertex = Boolean(hit && hit.index != null);
if (isRealVertex && !hoveredVertex) {
hoveredVertex = true;
map.getCanvas().style.cursor = 'grab';
} else if (!isRealVertex && hoveredVertex) {
hoveredVertex = false;
setDefaultMapCursor();
}
}
});
map.on('mouseup', () => {
finishVertexDrag();
});
window.addEventListener('mouseup', finishVertexDrag);
}
// --- Mobile: tap vertex then pan map to move it ---
if (isMobile) {
map.on('move', () => {
if (!mobileSelectedVertex) return;
const t = tracks.find(tr => tr.id === mobileSelectedVertex.trackId);
if (!t) return;
const center = map.getCenter();
const c = t.coords[mobileSelectedVertex.index];
c[0] = center.lng;
c[1] = center.lat;
c[2] = elevationAt(center);
invalidateTrackStats(t);
refreshTrackSource(t);
});
map.on('touchend', () => {
if (!mobileSelectedVertex) return;
// Finish move on second tap or when user lifts finger after panning
});
// tap elsewhere = deselect (handled in map click above)
}
function cancelMobileMove() {
mobileSelectedVertex = null;
mobileHint.classList.remove('visible');
map.dragPan.enable();
const t = getActiveTrack();
if (t) { renderTrackList(); updateProfile(); }
}
// --- Drag & drop import ---
let dragCounter = 0;
document.addEventListener('dragenter', (e) => {
e.preventDefault();
dragCounter++;
dropOverlay.classList.add('visible');
});
document.addEventListener('dragleave', (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) { dragCounter = 0; dropOverlay.classList.remove('visible'); }
});
document.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
document.addEventListener('drop', (e) => {
e.preventDefault();
dragCounter = 0;
dropOverlay.classList.remove('visible');
const file = e.dataTransfer.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => importFileContent(file.name, reader.result);
reader.readAsText(file);
});
function importFileContent(filename, text) {
const baseName = filename.replace(/\.[^.]+$/, '');
if (filename.endsWith('.gpx')) {
const parsed = parseGPX(text, baseName);
if (!parsed.length) { console.warn('No tracks found in', filename); return; }
for (const {name, coords} of parsed) {
const t = createTrack(name, coords);
fitToTrack(t);
}
} else {
const coordsList = parseGeoJSON(text);
if (!coordsList.length) { console.warn('No tracks found in', filename); return; }
for (let i = 0; i < coordsList.length; i++) {
const name = coordsList.length > 1 ? `${baseName} (${i + 1})` : baseName;
const t = createTrack(name, coordsList[i]);
fitToTrack(t);
}
}
}
function gpxParsePoints(ptEls) {
const coords = [];
for (const pt of ptEls) {
const lat = +pt.getAttribute('lat');
const lon = +pt.getAttribute('lon');
const eleEl = pt.querySelector('ele');
coords.push([lon, lat, eleEl ? +eleEl.textContent : null]);
}
return coords;
}
function parseGPX(text, baseName) {
const doc = new DOMParser().parseFromString(text, 'text/xml');
const results = [];
// Tracks: each <trk> may have a <name> and multiple <trkseg>
for (const trk of doc.querySelectorAll('trk')) {
const nameEl = trk.querySelector(':scope > name');
const trkName = nameEl ? nameEl.textContent.trim() : baseName;
const segs = trk.querySelectorAll('trkseg');
if (segs.length === 0) continue;
if (segs.length === 1) {
const coords = gpxParsePoints(segs[0].querySelectorAll('trkpt'));
if (coords.length) results.push({name: trkName, coords});
} else {
for (let i = 0; i < segs.length; i++) {
const coords = gpxParsePoints(segs[i].querySelectorAll('trkpt'));
if (coords.length) results.push({name: `${trkName} seg${i + 1}`, coords});
}
}
}
// Routes
for (const rte of doc.querySelectorAll('rte')) {
const nameEl = rte.querySelector(':scope > name');
const rteName = nameEl ? nameEl.textContent.trim() : baseName;
const coords = gpxParsePoints(rte.querySelectorAll('rtept'));
if (coords.length) results.push({name: rteName, coords});
}
return results;
}
function parseGeoJSON(text) {
const gj = JSON.parse(text);
const results = [];
function extractCoords(geom) {
if (geom.type === 'LineString') {
results.push(geom.coordinates.map(c => [c[0], c[1], c[2] != null ? c[2] : null]));
} else if (geom.type === 'MultiLineString') {
for (const line of geom.coordinates) {
results.push(line.map(c => [c[0], c[1], c[2] != null ? c[2] : null]));
}
}
}
if (gj.type === 'FeatureCollection') {
for (const f of gj.features) extractCoords(f.geometry);
} else if (gj.type === 'Feature') {
extractCoords(gj.geometry);
} else {
extractCoords(gj);
}
return results;
}
function fitToTrack(t) {
if (t.coords.length < 1) return;
const bounds = t.coords.reduce(
(b, c) => b.extend([c[0], c[1]]),
new maplibregl.LngLatBounds()
);
map.fitBounds(bounds, { padding: 60, maxZoom: 15, duration: 1000 });
}
// --- Export (active track only) ---
function downloadFile(name, content, mime) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([content], { type: mime }));
a.download = name;
a.click();
URL.revokeObjectURL(a.href);
}
function exportActiveGPX() {
const t = getActiveTrack();
if (!t || !t.coords.length) return;
const pts = t.coords.map(c => {
const ele = c[2] != null ? `<ele>${c[2]}</ele>` : '';
return ` <trkpt lat="${c[1]}" lon="${c[0]}">${ele}</trkpt>`;
}).join('\n');
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="slope-editor">
<trk>
<name>${t.name}</name>
<trkseg>
${pts}
</trkseg>
</trk>
</gpx>`;
downloadFile(t.name + '.gpx', gpx, 'application/gpx+xml');
}
function exportActiveGeoJSON() {
const t = getActiveTrack();
if (!t || !t.coords.length) return;
const gj = {
type: 'Feature',
properties: { name: t.name },
geometry: {
type: 'LineString',
coordinates: t.coords.map(c => c[2] != null ? [c[0], c[1], c[2]] : [c[0], c[1]])
}
};
downloadFile(t.name + '.geojson', JSON.stringify(gj, null, 2), 'application/geo+json');
}
function exportAllGPX() {
if (!tracks.length) return;
const segs = tracks.map(t => {
const pts = t.coords.map(c => {
const ele = c[2] != null ? `<ele>${c[2]}</ele>` : '';
return ` <trkpt lat="${c[1]}" lon="${c[0]}">${ele}</trkpt>`;
}).join('\n');
return ` <trkseg>\n${pts}\n </trkseg>`;
}).join('\n');
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="slope-editor">
<trk>
<name>All tracks</name>
${segs}
</trk>
</gpx>`;
downloadFile('all-tracks.gpx', gpx, 'application/gpx+xml');
}
document.getElementById('export-gpx-btn').addEventListener('click', exportActiveGPX);
document.getElementById('export-geojson-btn').addEventListener('click', exportActiveGeoJSON);
document.getElementById('export-all-gpx-btn').addEventListener('click', exportAllGPX);
// Add map layers for tracks once map is loaded
map.on('load', () => {
mapReady = true;
ensureProfileHoverLayer();
for (const t of tracks) {
if (!map.getSource(trackSourceId(t))) addTrackToMap(t);
}
});
// ========== END TRACK EDITOR ==========
function syncBottomRightOffset() {
document.body.classList.toggle('profile-open', profilePanel.classList.contains('visible'));
}
// ========== ELEVATION PROFILE ==========
const profilePanel = document.getElementById('profile-panel');
const profileCanvas = document.getElementById('profile-canvas');
let profileChart = null;
let profileClosed = false;
let hoveredProfileTrackId = null;
let hoveredProfileVertexIndex = null;
function clearProfileHoverVertex() {
hoveredProfileTrackId = null;
hoveredProfileVertexIndex = null;
const src = map.getSource(PROFILE_HOVER_SOURCE_ID);
if (src) src.setData({type: 'FeatureCollection', features: []});
}
function ensureVertexInView(lngLat) {
const canvas = map.getCanvas();
const padding = {
top: 50,
right: trackPanel.classList.contains('visible') ? 220 : 60,
bottom: profilePanel.classList.contains('visible') ? 170 : 60,
left: 60
};
const point = map.project([lngLat.lng, lngLat.lat]);
const withinX = point.x >= padding.left && point.x <= canvas.clientWidth - padding.right;
const withinY = point.y >= padding.top && point.y <= canvas.clientHeight - padding.bottom;
if (withinX && withinY) return;
map.easeTo({
center: [lngLat.lng, lngLat.lat],
duration: 1000,
essential: true,
padding
});
}
function setProfileHoverVertex(index) {
const t = getActiveTrack();
if (!t || index == null || index < 0 || index >= t.coords.length) {
clearProfileHoverVertex();
return;
}
if (hoveredProfileTrackId === t.id && hoveredProfileVertexIndex === index) return;
hoveredProfileTrackId = t.id;
hoveredProfileVertexIndex = index;
const coord = t.coords[index];
const src = map.getSource(PROFILE_HOVER_SOURCE_ID);
if (src) {
src.setData({
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: {type: 'Point', coordinates: [coord[0], coord[1]]},
properties: {color: t.color}
}]
});
}
ensureVertexInView({lng: coord[0], lat: coord[1]});
}
function destroyProfileChart() {
clearProfileHoverVertex();
if (profileChart) { profileChart.destroy(); profileChart = null; }
}
function closeProfile(markClosed) {
profilePanel.classList.remove('visible');
if (markClosed) profileClosed = true;
destroyProfileChart();
syncBottomRightOffset();
syncProfileToggleButton();
}
document.getElementById('profile-close').addEventListener('click', () => {
closeProfile(true);
});
profileToggleBtn.addEventListener('click', () => {
const t = getActiveTrack();
if (!t || t.coords.length < 2) return;
if (profilePanel.classList.contains('visible') && !profileClosed) {
closeProfile(true);
return;
}
profileClosed = false;
updateProfile();
});
profileCanvas.addEventListener('mouseleave', () => {
clearProfileHoverVertex();
});
function haversineKm(a, b) {
const R = 6371;
const dLat = (b[1] - a[1]) * Math.PI / 180;
const dLon = (b[0] - a[0]) * Math.PI / 180;
const la = a[1] * Math.PI / 180, lb = b[1] * Math.PI / 180;
const h = Math.sin(dLat/2)**2 + Math.cos(la) * Math.cos(lb) * Math.sin(dLon/2)**2;
return R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
}
function computeProfile(coords) {
const distances = [0];
const elevations = [];
const slopes = [];
const terrainSlopes = [];
for (let i = 0; i < coords.length; i++) {
if (i > 0) distances.push(distances[i-1] + haversineKm(coords[i-1], coords[i]));
elevations.push(coords[i][2] != null ? coords[i][2] : null);
const terrainSample = queryLoadedElevationAtLngLat(map, {lng: coords[i][0], lat: coords[i][1]});
terrainSlopes.push(terrainSample && Number.isFinite(terrainSample.slopeDeg) ? terrainSample.slopeDeg : null);
if (i > 0) {
const dh = (coords[i][2] != null && coords[i-1][2] != null)
? coords[i][2] - coords[i-1][2] : null;
const dd = (distances[i] - distances[i-1]) * 1000; // m
slopes.push(dh != null && dd > 0 ? Math.sign(dh) * Math.atan2(Math.abs(dh), dd) * 180 / Math.PI : null); // degrees, negative=downhill
} else {
slopes.push(null);
}
}
return {distances, elevations, slopes, terrainSlopes};
}
function updateProfile() {
const t = getActiveTrack();
if (!t || t.coords.length < 2 || profileClosed) {
if (!t || t.coords.length < 2) {
closeProfile(false);
}
syncProfileToggleButton();
return;
}
const {distances, elevations, slopes, terrainSlopes} = computeProfile(t.coords);
const labels = distances.map(d => d.toFixed(2));
destroyProfileChart();
profilePanel.classList.add('visible');
syncBottomRightOffset();
syncProfileToggleButton();
profileChart = new Chart(profileCanvas, {
type: 'line',
data: {
labels,
datasets: [
{
label: 'Elevation (m)',
data: elevations,
borderColor: '#4a90d9',
backgroundColor: 'rgba(74,144,217,0.12)',
fill: true,
pointRadius: 0,
borderWidth: 1.5,
tension: 0.3,
yAxisID: 'yEle',
spanGaps: true
},
{
label: 'Track slope (°)',
data: slopes,
borderColor: '#e53935',
pointRadius: 0,
borderWidth: 1,
tension: 0.3,
yAxisID: 'ySlope',
spanGaps: true
},
{
label: 'Terrain slope (°)',
data: terrainSlopes,
borderColor: '#7c3aed',
pointRadius: 0,
borderWidth: 1,
borderDash: [5, 3],
tension: 0.3,
yAxisID: 'ySlope',
spanGaps: true
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
onHover: (_event, activeElements) => {
if (activeElements && activeElements.length) setProfileHoverVertex(activeElements[0].index);
else clearProfileHoverVertex();
},
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: true, position: 'top', labels: { boxWidth: 12, font: {size: 10} } },
annotation: {
annotations: {
zeroLine: {
type: 'line',
yMin: 0,
yMax: 0,
yScaleID: 'ySlope',
borderColor: 'rgba(0,0,0,0.25)',
borderWidth: 1,
borderDash: [4, 3]
}
}
},
tooltip: {
callbacks: {
title: (items) => items[0] ? `${items[0].label} km` : ''
}
}
},
scales: {
x: {
display: true,
title: { display: true, text: 'km', font: {size: 10} },
ticks: { font: {size: 9}, maxTicksLimit: 10 }
},
yEle: {
type: 'linear',
position: 'left',
title: { display: true, text: 'm', font: {size: 10} },
ticks: { font: {size: 9} },
grid: { drawOnChartArea: true }
},
ySlope: {
type: 'linear',
position: 'right',
title: { display: true, text: '°', font: {size: 10} },
ticks: { font: {size: 9} },
grid: { drawOnChartArea: false }
}
}
}
});
}
// ========== END ELEVATION PROFILE ==========
</script>
</body>
</html>

slope-hybrid-mltimap-2d — Feature List

Map & Visualization

  • Analysis modesSlope, Aspect, Color relief, and an empty none mode that disables the DEM analysis overlay entirely
  • Slope / Aspect overlay — custom WebGL layer using Horn's algorithm on raster-DEM tiles, with configurable opacity and color ramp
  • Color relief — DEM color ramp mode rendered with the built-in color-relief layer
  • Hillshade — multiple methods (standard, basic, combined, multidirectional, igor), configurable opacity
  • Contour lines — generated client-side from DEM via maplibre-contour, auto-shown for OSM only (other basemaps have their own contours), manual toggle available
  • Multiply blend — optional compositing mode for the DEM analysis overlay on the basemap
  • 3D terrain — toggle with configurable exaggeration

Basemaps

  • OSM (default), OTM, IGN plan (FR), SwissTopo vector, Kartverket topo (NO)
  • Basemap opacity — slider for the visible basemap stack
  • Auto fly-to — selecting a regional basemap outside its supported area recenters the view
  • URL persistence — center, zoom, basemap, mode, opacity, terrain state, bearing, and pitch are encoded in the URL hash

Overlays

  • OpenSkiMap — independent checkbox overlay (ski areas, runs, lifts, spots) on top of any basemap
  • DEM tile grid — debug overlay toggle for visible DEM tile coverage

Track Editor

  • Drag & drop import — GPX (tracks, segments, routes with names) and GeoJSON files, with visual drop overlay
  • Top-right track workspace — compact floating panel for track management and export
  • Draw mode — pen button in the track header; click to add vertices, double-click or Escape to finish
  • Track list button state — pin button is greyed out when there are no tracks and becomes a close button while the track panel is open
  • Multi-track management — color-coded tracks with selection, deletion, export actions, and active-track emphasis
  • Track stats — total distance (km), elevation gain (↑), loss (↓), point count, average slope, and max slope for the active track
  • Elevation enrichment — all track points (imported and drawn) are enriched from the same DEM source and re-enriched when new DEM tiles load
  • Track markers — green start / red end dots; mid-points and insert-point handles shown only for the active track
  • Insert vertex — click a midpoint handle between two vertices to insert a new point
  • Ctrl+click delete — remove individual track vertices
  • Desktop vertex editing — drag vertices to reposition
  • Mobile vertex editing — tap a vertex, then pan the map to move it
  • Export — active track as GPX or GeoJSON; all tracks as a single GPX with multiple segments

Profile

  • Elevation profile — bottom panel showing elevation (m), track slope (°), and terrain slope (°) vs distance (km), with dual Y-axes and a zero-line
  • Reopenable profile — the track panel includes a profile toggle so closing the chart is not terminal
  • Profile-to-map hover linkage — hovering the profile highlights the corresponding track vertex on the map
  • Hover pan assist — if the hovered vertex is out of view, the map pans to bring it back on screen

UX

  • Settings toggle — top-left 🌍 Settings button with auto-collapse when you start dragging the map
  • Track panel header layout — when open, the header row is Tracks, Profile, draw button, close button; track details stay below
  • Panel styling — controls, legend, profile, and the open track panel share the same translucent blurred panel surface
  • Bottom-right controls — native MapLibre bottom-right stack with navigation, geolocate, ruler, and attribution
  • Legend behavior — dynamic color ramp for the current mode; in Mode: none, the legend collapses to cursor info only
  • Cursor elevation & slope — live DEM readout at the pointer (Elevation and Slope)
  • Search — Nominatim geocoding with collapsible search box
  • Ctrl/Cmd+drag — tilt and rotate the map (same as right-click drag)

Technical gotchas

  • Contour initialization order — contour visibility must be re-applied after the contour layers are added, otherwise first-load state can disagree with the checkbox
  • Contour/basemap coupling — contour lines are auto-enabled only for OSM; switching basemaps intentionally resets the contour checkbox unless you change the logic
  • Mode: none behavior — empty mode disables the custom DEM analysis render path and hides the legend ramp/labels, but keeps cursor info visible
  • Color relief split pathcolor-relief is rendered via a separate MapLibre layer, not the custom WebGL analysis layer used for slope/aspect
  • Track button statetracks-btn must be explicitly synced on startup so the disabled state matches the empty track list before any interaction
  • Native attribution control — when adding attribution manually in the bottom-right stack, the map must be created with attributionControl: false to avoid duplicate attribution UI

Layer Z-Order (bottom to top)

  1. Basemap
  2. OpenSkiMap overlay
  3. Hillshade
  4. DEM analysis overlay (Slope / Aspect) or Color relief
  5. Contour lines
  6. Track lines, vertices, and profile-hover marker
// Extracted from track-bug.html (Approach A2)
// Demonstrates the mixed filter form that triggers the bug:
// ['all', ['==', '$type', 'Point'], ['case', ... ['global-state', ...] ...]]
const isActive = ['==', ['get', 'id'], ['global-state', 'activeTrackId']];
map.addLayer({
id: 'pts-A2',
type: 'circle',
source: 'src-A2',
filter: [
'all',
['==', '$type', 'Point'],
['case', isActive, true,
['any',
['==', ['get', 'role'], 'start'],
['==', ['get', 'role'], 'end']
]
]
],
paint: {
'circle-radius': ['case', isActive,
['match', ['get', 'role'], 'insert', 3, 4],
3
],
'circle-color': ['match', ['get', 'role'],
'start', '#22c55e',
'end', '#ef4444',
'insert', 'rgba(128,128,128,0.5)',
'#06b6d4'
],
'circle-stroke-color': '#fff',
'circle-stroke-width': ['case', isActive,
['match', ['get', 'role'], 'insert', 0.5, 1.5],
1
]
}
});
map.setGlobalStateProperty('activeTrackId', 'A2');
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Track Circle Bug — Minimal Repro</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.19.0/dist/maplibre-gl.css" />
<script src="https://unpkg.com/maplibre-gl@5.19.0/dist/maplibre-gl.js"></script>
<style>
body { margin: 0; font-family: system-ui, sans-serif; }
#map { position: absolute; top: 0; bottom: 0; width: 100%; }
#panel {
position: absolute; top: 10px; left: 10px; z-index: 10;
background: rgba(255,255,255,0.95); border-radius: 8px;
padding: 12px; box-shadow: 0 1px 6px rgba(0,0,0,0.3);
font-size: 13px; max-width: 260px;
}
#panel h3 { margin: 0 0 8px; font-size: 14px; }
#panel label { display: block; margin: 4px 0; cursor: pointer; }
#panel hr { margin: 8px 0; border: none; border-top: 1px solid #ddd; }
.legend { font-size: 11px; color: #666; margin-top: 6px; }
</style>
</head>
<body>
<div id="map"></div>
<div id="panel">
<h3>Active track</h3>
<label><input type="radio" name="active" value="none" checked> None</label>
<label><input type="radio" name="active" value="A"> Track A — global-state (paint only)</label>
<label><input type="radio" name="active" value="A2"> Track A2 — global-state (paint+filter) ⚠️ BROKEN</label>
<label><input type="radio" name="active" value="B"> Track B — setFilter/setPaint</label>
<label><input type="radio" name="active" value="C"> Track C — two layers</label>
<hr>
<div class="legend">
<b>Active:</b> all points (green start, red end, grey insert, blue mid, r=4/3)<br>
<b>Inactive:</b> start (green, r=3) + end (red, r=3) only
</div>
</div>
<script>
const map = new maplibregl.Map({
container: 'map',
center: [10, 20],
zoom: 2,
style: {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap'
}
},
layers: [{ id: 'osm', type: 'raster', source: 'osm' }]
}
});
// --- Data: 4 tracks, each with 4 points + 3 insert midpoints ---
const TRACKS = {
A: { color: '#3b82f6', coords: [[-30,35],[-10,35],[10,35],[30,35]] },
A2: { color: '#06b6d4', coords: [[-30,20],[-10,20],[10,20],[30,20]] },
B: { color: '#f59e0b', coords: [[-30,5],[-10,5],[10,5],[30,5]] },
C: { color: '#8b5cf6', coords: [[-30,-10],[-10,-10],[10,-10],[30,-10]] },
};
function makeGeoJSON(id, coords) {
const features = [];
// Line
features.push({
type: 'Feature',
geometry: { type: 'LineString', coordinates: coords },
properties: { id }
});
// Vertices + insert midpoints
const last = coords.length - 1;
for (let i = 0; i <= last; i++) {
const role = i === 0 ? 'start' : i === last ? 'end' : 'mid';
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: coords[i] },
properties: { id, idx: i, role }
});
if (i < last) {
const a = coords[i], b = coords[i + 1];
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [(a[0]+b[0])/2, (a[1]+b[1])/2] },
properties: { id, insertAfter: i, role: 'insert' }
});
}
}
return { type: 'FeatureCollection', features };
}
// Shared paint helpers
const COLOR_EXPR = ['match', ['get', 'role'],
'start', '#22c55e', 'end', '#ef4444',
'insert', 'rgba(128,128,128,0.5)', '#3b82f6' // fallback = track color placeholder
];
function colorExpr(trackColor) {
return ['match', ['get', 'role'],
'start', '#22c55e', 'end', '#ef4444',
'insert', 'rgba(128,128,128,0.5)', trackColor];
}
const ACTIVE_RADIUS = ['match', ['get', 'role'], 'insert', 3, 4];
const ACTIVE_STROKE = ['match', ['get', 'role'], 'insert', 0.5, 1.5];
// =====================================================
// APPROACH A: global-state in PAINT only (no filter)
// Show all points always; use radius=0 to hide non-start/end when inactive
// =====================================================
function setupA() {
const t = TRACKS.A;
const isActive = ['==', ['get', 'id'], ['global-state', 'activeTrackId']];
const isStartOrEnd = ['any', ['==', ['get', 'role'], 'start'], ['==', ['get', 'role'], 'end']];
map.addSource('src-A', { type: 'geojson', data: makeGeoJSON('A', t.coords) });
map.addLayer({
id: 'line-A', type: 'line', source: 'src-A',
filter: ['==', '$type', 'LineString'],
paint: { 'line-color': t.color, 'line-width': 3 }
});
map.addLayer({
id: 'pts-A', type: 'circle', source: 'src-A',
filter: ['==', '$type', 'Point'],
paint: {
'circle-radius': ['case',
isActive, ['match', ['get', 'role'], 'insert', 3, 4],
isStartOrEnd, 3,
0],
'circle-color': colorExpr(t.color),
'circle-stroke-color': '#fff',
'circle-stroke-width': ['case',
isActive, ['match', ['get', 'role'], 'insert', 0.5, 1.5],
isStartOrEnd, 1,
0]
}
});
}
// =====================================================
// APPROACH A2: global-state in BOTH filter + paint
// =====================================================
function setupA2() {
const t = TRACKS.A2;
const isActive = ['==', ['get', 'id'], ['global-state', 'activeTrackId']];
map.addSource('src-A2', { type: 'geojson', data: makeGeoJSON('A2', t.coords) });
map.addLayer({
id: 'line-A2', type: 'line', source: 'src-A2',
filter: ['==', '$type', 'LineString'],
paint: { 'line-color': t.color, 'line-width': 3 }
});
map.addLayer({
id: 'pts-A2', type: 'circle', source: 'src-A2',
filter: ['all', ['==', '$type', 'Point'],
['case', isActive, true,
['any', ['==', ['get', 'role'], 'start'], ['==', ['get', 'role'], 'end']]]],
paint: {
'circle-radius': ['case', isActive,
['match', ['get', 'role'], 'insert', 3, 4], 3],
'circle-color': colorExpr(t.color),
'circle-stroke-color': '#fff',
'circle-stroke-width': ['case', isActive,
['match', ['get', 'role'], 'insert', 0.5, 1.5], 1]
}
});
}
// =====================================================
// APPROACH B: imperative setFilter + setPaintProperty
// =====================================================
const INACTIVE_RADIUS = ['case',
['any', ['==', ['get', 'role'], 'start'], ['==', ['get', 'role'], 'end']], 3, 0];
const INACTIVE_STROKE = ['case',
['any', ['==', ['get', 'role'], 'start'], ['==', ['get', 'role'], 'end']], 1, 0];
function setupB() {
const t = TRACKS.B;
map.addSource('src-B', { type: 'geojson', data: makeGeoJSON('B', t.coords) });
map.addLayer({
id: 'line-B', type: 'line', source: 'src-B',
filter: ['==', '$type', 'LineString'],
paint: { 'line-color': t.color, 'line-width': 3 }
});
map.addLayer({
id: 'pts-B', type: 'circle', source: 'src-B',
filter: ['==', '$type', 'Point'],
paint: {
'circle-radius': INACTIVE_RADIUS,
'circle-color': colorExpr(t.color),
'circle-stroke-color': '#fff',
'circle-stroke-width': INACTIVE_STROKE
}
});
}
function updateB(isActive) {
if (!map.getLayer('pts-B')) return;
// No filter change — just paint properties
map.setPaintProperty('pts-B', 'circle-radius', isActive ? ACTIVE_RADIUS : INACTIVE_RADIUS);
map.setPaintProperty('pts-B', 'circle-stroke-width', isActive ? ACTIVE_STROKE : INACTIVE_STROKE);
}
// =====================================================
// APPROACH C: two layers (always-on start/end + toggled all-points)
// =====================================================
function setupC() {
const t = TRACKS.C;
map.addSource('src-C', { type: 'geojson', data: makeGeoJSON('C', t.coords) });
map.addLayer({
id: 'line-C', type: 'line', source: 'src-C',
filter: ['==', '$type', 'LineString'],
paint: { 'line-color': t.color, 'line-width': 3 }
});
// Layer 1: all points but only start/end visible via radius, always on
map.addLayer({
id: 'pts-C-ends', type: 'circle', source: 'src-C',
filter: ['==', '$type', 'Point'],
paint: {
'circle-radius': ['case',
['any', ['==', ['get', 'role'], 'start'], ['==', ['get', 'role'], 'end']], 3, 0],
'circle-color': colorExpr(t.color),
'circle-stroke-color': '#fff',
'circle-stroke-width': ['case',
['any', ['==', ['get', 'role'], 'start'], ['==', ['get', 'role'], 'end']], 1, 0]
}
});
// Layer 2: all points when active (hidden by default)
map.addLayer({
id: 'pts-C-all', type: 'circle', source: 'src-C',
filter: ['==', '$type', 'Point'],
layout: { visibility: 'none' },
paint: {
'circle-radius': ACTIVE_RADIUS,
'circle-color': colorExpr(t.color),
'circle-stroke-color': '#fff',
'circle-stroke-width': ACTIVE_STROKE
}
});
}
function updateC(isActive) {
if (map.getLayer('pts-C-all'))
map.setLayoutProperty('pts-C-all', 'visibility', isActive ? 'visible' : 'none');
if (map.getLayer('pts-C-ends'))
map.setLayoutProperty('pts-C-ends', 'visibility', isActive ? 'none' : 'visible');
}
// --- Wiring ---
let activeId = 'none';
function setActive(id) {
activeId = id;
// A + A2: global-state (automatic — one call drives both)
map.setGlobalStateProperty('activeTrackId', id);
// B: imperative
updateB(id === 'B');
// C: two-layer toggle
updateC(id === 'C');
}
map.on('load', () => {
setupA();
setupA2();
setupB();
setupC();
setActive('none');
});
document.querySelectorAll('input[name="active"]').forEach(r => {
r.addEventListener('change', () => setActive(r.value));
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment