|
<!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">🔍</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)">✎</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 • tap elsewhere to deselect</div> |
|
|
|
<div id="profile-panel" class="panel-surface"> |
|
<button id="profile-close" title="Close">×</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> · 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: '© 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: '© 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: '© IGN France' |
|
}, |
|
swisstopo: { |
|
type: 'vector', |
|
tiles: ['https://vectortiles.geo.admin.ch/tiles/ch.swisstopo.base.vt/v1.0.0/{z}/{x}/{y}.pbf'], |
|
attribution: '© swisstopo' |
|
}, |
|
openskimap: { |
|
type: 'vector', |
|
tiles: ['https://tiles.openskimap.org/openskimap/{z}/{x}/{y}.pbf'], |
|
attribution: '© 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: '© 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}">×</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> |