Add contour lines to the Overlays panel via maplibre-contour
5th Overlays item, generated client-side from the same online Terrarium DEM tiles Terrain shading already fetches (no offline fallback — the plugin's DemSource has no public hook to route through the pmtiles:// protocol the offline basemap uses, so this quietly renders nothing without a network, same as Radar/the Wikipedia layer). A plain vector source/layer under MapLibre's own tile lifecycle, no manual refetch-on-move code needed. Thresholds are the plugin's own generic starting point, not yet tuned against real terrain. Labels skipped for now (font-name matching across the online/offline styles' own glyph sets adds real risk for a nice-to-have, not the actual ask).
This commit is contained in:
1 parent
b4a1c6c0cc
commit
fba8ca10a2
7 files changed
+140
-2
No files matched your search
@@ -21,6 +21,10 @@ BSD-3-Clause licensed.
|
||||
| protomaps-themes-base
|
||||
| 4.5.0
|
||||
| https://github.com/protomaps/basemaps
|
||||
|
||||
| maplibre-contour (Overlays panel's "Contour lines", `public/js/contour.js`)
|
||||
| 0.1.0
|
||||
| https://github.com/onthegomap/maplibre-contour
|
||||
|===
|
||||
|
||||
Also vendored under `public/vendor/`, but not BSD-3-Clause like the
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { addProtocol } from "/vendor/maplibre-gl/maplibre-gl.mjs";
|
||||
import { ONLINE_TERRAIN_TILES } from "/js/overlays.js";
|
||||
import { firstSymbolLayerId } from "/js/radar.js";
|
||||
import { CONTOUR_ICON } from "/js/icons.js";
|
||||
|
||||
const SOURCE_ID = "contour-lines";
|
||||
const LINE_LAYER_ID = "contour-lines-line";
|
||||
|
||||
// Topographic contour lines, generated client-side by the vendored
|
||||
// maplibre-contour plugin (public/vendor/maplibre-contour/) from the same
|
||||
// online Terrarium DEM tiles overlays.js's HillshadeControl already
|
||||
// fetches. Online only, unlike Terrain shading/Terrain shadow: the
|
||||
// plugin's DemSource has no public hook to route its own tile fetch
|
||||
// through the pmtiles:// protocol HillshadeControl uses for the offline
|
||||
// basemap, so — same story as the Wikipedia layer and Radar — this just
|
||||
// quietly renders nothing without a network.
|
||||
export class ContourControl {
|
||||
constructor(map) {
|
||||
this._map = map;
|
||||
this._active = false;
|
||||
this._demSource = new window.mlcontour.DemSource({
|
||||
url: ONLINE_TERRAIN_TILES[0],
|
||||
encoding: "terrarium",
|
||||
maxzoom: 15,
|
||||
worker: true,
|
||||
cacheSize: 100,
|
||||
});
|
||||
this._demSource.setupMaplibre({ addProtocol });
|
||||
|
||||
// setStyle() (switching Online/Offline) tears down all custom sources/
|
||||
// layers — put contour lines back if they were on, same as every other
|
||||
// Overlays control.
|
||||
map.on("style.load", () => {
|
||||
if (this._active) this._addLayer();
|
||||
});
|
||||
}
|
||||
|
||||
mount(li) {
|
||||
li.innerHTML = `
|
||||
<label>
|
||||
<input type="checkbox" class="overlays-panel__toggle">
|
||||
<span class="overlays-panel__icon">${CONTOUR_ICON}</span>
|
||||
<span>Contour lines</span>
|
||||
</label>
|
||||
`;
|
||||
this._checkbox = li.querySelector(".overlays-panel__toggle");
|
||||
this._checkbox.addEventListener("change", (event) => this._toggle(event.target.checked));
|
||||
}
|
||||
|
||||
_toggle(active) {
|
||||
this._active = active;
|
||||
if (active) this._addLayer();
|
||||
else this._removeLayer();
|
||||
}
|
||||
|
||||
_addLayer() {
|
||||
if (this._map.getSource(SOURCE_ID)) return;
|
||||
|
||||
// Starting-point thresholds (README's own generic example) — meters,
|
||||
// more/tighter contours at higher zoom. Expect to eyeball-tune against
|
||||
// real Tatras-scale terrain.
|
||||
const tiles = [
|
||||
this._demSource.contourProtocolUrl({
|
||||
thresholds: {
|
||||
11: [200, 1000],
|
||||
12: [100, 500],
|
||||
14: [50, 200],
|
||||
15: [20, 100],
|
||||
},
|
||||
contourLayer: "contours",
|
||||
elevationKey: "ele",
|
||||
levelKey: "level",
|
||||
}),
|
||||
];
|
||||
this._map.addSource(SOURCE_ID, { type: "vector", tiles, maxzoom: 15 });
|
||||
this._map.addLayer(
|
||||
{
|
||||
id: LINE_LAYER_ID,
|
||||
type: "line",
|
||||
source: SOURCE_ID,
|
||||
"source-layer": "contours",
|
||||
paint: {
|
||||
// Roughly the same brown as the mid-slope band of overlays.js's
|
||||
// own ELEVATION_COLOR_RAMP, so this reads as part of the same
|
||||
// terrain treatment rather than a bolted-on layer.
|
||||
"line-color": "#8a6d3b",
|
||||
"line-width": ["match", ["get", "level"], 1, 1.2, 0.6],
|
||||
"line-opacity": 0.6,
|
||||
},
|
||||
},
|
||||
firstSymbolLayerId(this._map),
|
||||
);
|
||||
}
|
||||
|
||||
_removeLayer() {
|
||||
if (this._map.getLayer(LINE_LAYER_ID)) this._map.removeLayer(LINE_LAYER_ID);
|
||||
if (this._map.getSource(SOURCE_ID)) this._map.removeSource(SOURCE_ID);
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,18 @@ export const TERRAIN_SHADOW_ICON = `
|
||||
<polygon points="0,19 6,9 9,13 13,6 22,19"/>
|
||||
</svg>`;
|
||||
|
||||
// Hand-drawn, not NPS-sourced — no contour-line pictogram in that set
|
||||
// either. Concentric rings via fill-rule evenodd, same solid-glyph
|
||||
// convention as WILDERNESS_ICON/RADAR_CLOUD_ICON/TERRAIN_SHADOW_ICON above —
|
||||
// used as the Overlays panel's Contour lines glyph (contour.js).
|
||||
export const CONTOUR_ICON = `
|
||||
<svg viewBox="0 0 22 22" fill="currentColor" fill-rule="evenodd">
|
||||
<path d="M21,11 A10,10 0 1,0 1,11 A10,10 0 1,0 21,11 Z
|
||||
M18.5,11 A7.5,7.5 0 1,0 3.5,11 A7.5,7.5 0 1,0 18.5,11 Z
|
||||
M16,11 A5,5 0 1,0 6,11 A5,5 0 1,0 16,11 Z
|
||||
M13.5,11 A2.5,2.5 0 1,0 8.5,11 A2.5,2.5 0 1,0 13.5,11 Z"/>
|
||||
</svg>`;
|
||||
|
||||
// src/bicycle-trail-22.svg — pulled out to its own constant since it's also
|
||||
// used unrasterized, inline, as PARK4NIGHT_TAGS.vtt below.
|
||||
const BICYCLE_TRAIL_ICON = `
|
||||
|
||||
@@ -4,6 +4,7 @@ import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
||||
import { svgToImageData, badgeImageId, escapeHtml } from "/js/gpx.js";
|
||||
import { RadarControl } from "/js/radar.js";
|
||||
import { ShadowControl } from "/js/shadow.js";
|
||||
import { ContourControl } from "/js/contour.js";
|
||||
|
||||
const ICON_IMAGE_SIZE = 48; // rasterized once at this size, rendered much smaller by icon-size below
|
||||
const ICON_SIZE = 0.3; // 48 * 0.3 = ~14px, sized to sit inside the marker badge below
|
||||
@@ -125,6 +126,7 @@ export class OverlaysControl {
|
||||
this._hillshade = new HillshadeControl(map);
|
||||
this._radar = new RadarControl(map);
|
||||
this._shadow = new ShadowControl(map);
|
||||
this._contour = new ContourControl(map);
|
||||
this._wikiEnabled = false; // the checkbox's own state
|
||||
this._wikiMoveHandler = null; // the moveend listener driving its live refetch, while enabled
|
||||
this._wikiRequestSeq = 0; // ignores an in-flight refetch a newer one has already superseded
|
||||
@@ -157,12 +159,14 @@ export class OverlaysControl {
|
||||
<li class="overlays-panel__item"></li>
|
||||
<li class="overlays-panel__item overlays-panel__item--expandable"></li>
|
||||
<li class="overlays-panel__item overlays-panel__item--expandable"></li>
|
||||
<li class="overlays-panel__item"></li>
|
||||
`;
|
||||
const [hillshadeItem, wikiItem, radarItem, shadowItem] = list.children;
|
||||
const [hillshadeItem, wikiItem, radarItem, shadowItem, contourItem] = list.children;
|
||||
this._hillshade.mount(hillshadeItem);
|
||||
this._mountWiki(wikiItem);
|
||||
this._radar.mount(radarItem);
|
||||
this._shadow.mount(shadowItem);
|
||||
this._contour.mount(contourItem);
|
||||
|
||||
onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active"));
|
||||
|
||||
|
||||
+3
-1
@@ -357,7 +357,9 @@ function readCachedForecast() {
|
||||
}
|
||||
}
|
||||
|
||||
function firstSymbolLayerId(map) {
|
||||
// Exported: contour.js's own contour-line layers want the same
|
||||
// "sit above base fill/roads, below place labels" placement.
|
||||
export function firstSymbolLayerId(map) {
|
||||
const layers = map.getStyle()?.layers || [];
|
||||
const symbolLayer = layers.find((layer) => layer.type === "symbol");
|
||||
return symbolLayer ? symbolLayer.id : undefined;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).mlcontour=e()}(this,(function(){"use strict";var t,e,i;function r(r,s){if(t)if(e){var n="var sharedChunk = {}; ("+t+")(sharedChunk); ("+e+")(sharedChunk);",o={};t(o),i=s(o),"undefined"!=typeof window&&(i.workerUrl=window.URL.createObjectURL(new Blob([n],{type:"text/javascript"})))}else e=s;else t=s}return r(0,(function(t){class e{constructor(t,e){this.start=t,this.end=e,this.points=[],this.append=this.append.bind(this),this.prepend=this.prepend.bind(this)}append(t,e){this.points.push(Math.round(t),Math.round(e))}prepend(t,e){this.points.splice(0,0,Math.round(t),Math.round(e))}lineString(){return this.toArray()}isEmpty(){return this.points.length<2}appendFragment(t){this.points.push(...t.points),this.end=t.end}toArray(){return this.points}}const i=[[],[[[1,2],[0,1]]],[[[2,1],[1,2]]],[[[2,1],[0,1]]],[[[1,0],[2,1]]],[[[1,2],[0,1]],[[1,0],[2,1]]],[[[1,0],[1,2]]],[[[1,0],[0,1]]],[[[0,1],[1,0]]],[[[1,2],[1,0]]],[[[0,1],[1,0]],[[2,1],[1,2]]],[[[2,1],[1,0]]],[[[0,1],[2,1]]],[[[1,2],[2,1]]],[[[0,1],[1,2]]],[]];function r(t,e,i,r){return(e=2*e+r[0])+(i=2*i+r[1])*(t+1)*2}function s(t,e,i){return(e-t)/(i-t)}function n(t,n,o=4096,a=1){if(!t)return{};const h=o/(n.width-1);let l,c,d,u,f,p;const w={},g=new Map,m=new Map;function b(t,e,i){0===t[0]?i(h*(p-1),h*(f-s(d,e,l))):2===t[0]?i(h*p,h*(f-s(u,e,c))):0===t[1]?i(h*(p-s(c,e,l)),h*(f-1)):i(h*(p-s(u,e,d)),h*f)}for(f=1-a;f<n.height+a;f++){c=n.get(0,f-1),u=n.get(0,f);let s=Math.min(c,u),o=Math.max(c,u);for(p=1-a;p<n.width+a;p++){l=c,d=u,c=n.get(p,f-1),u=n.get(p,f);const a=s,h=o;if(s=Math.min(c,u),o=Math.max(c,u),isNaN(l)||isNaN(c)||isNaN(u)||isNaN(d))continue;const y=Math.min(a,s),v=Math.max(h,o),P=Math.ceil(y/t)*t,x=Math.floor(v/t)*t;for(let s=P;s<=x;s+=t){const t=l>s,o=c>s,a=d>s,h=u>s;for(const l of i[(t?8:0)|(o?4:0)|(h?2:0)|(a?1:0)]){let t=g.get(s);t||g.set(s,t=new Map);let i=m.get(s);i||m.set(s,i=new Map);const o=l[0],a=l[1],h=r(n.width,p,f,o),c=r(n.width,p,f,a);let d,u;if(d=i.get(h))if(i.delete(h),u=t.get(c))if(t.delete(c),d===u){if(b(a,s,d.append),!d.isEmpty()){let t=w[s];t||(w[s]=t=[]),t.push(d.lineString())}}else d.appendFragment(u),i.set(d.end=u.end,d);else b(a,s,d.append),i.set(d.end=c,d);else if(d=t.get(c))t.delete(c),b(o,s,d.prepend),t.set(d.start=h,d);else{const r=new e(h,c);b(o,s,r.append),b(a,s,r.append),t.set(h,r),i.set(c,r)}}}}}for(const[t,e]of g.entries()){let i=null;for(const r of e.values())r.isEmpty()||(null==i&&(i=w[t]||(w[t]=[])),i.push(r.lineString()))}return w}
|
||||
/*! *****************************************************************************
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
***************************************************************************** */function o(t,e){var i={};for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&e.indexOf(r)<0&&(i[r]=t[r]);if(null!=t&&"function"==typeof Object.getOwnPropertySymbols){var s=0;for(r=Object.getOwnPropertySymbols(t);s<r.length;s++)e.indexOf(r[s])<0&&Object.prototype.propertyIsEnumerable.call(t,r[s])&&(i[r[s]]=t[r[s]])}return i}function a(t,e,i,r){return new(i||(i=Promise))((function(s,n){function o(t){try{h(r.next(t))}catch(t){n(t)}}function a(t){try{h(r.throw(t))}catch(t){n(t)}}function h(t){var e;t.done?s(t.value):(e=t.value,e instanceof i?e:new i((function(t){t(e)}))).then(o,a)}h((r=r.apply(t,e||[])).next())}))}function h(t){const e=Object.entries(t);return e.sort((([t],[e])=>t<e?-1:t>e?1:0)),e}function l(t){return h(t).map((([t,e])=>[t,..."number"==typeof e?[e]:e].join("*"))).join("~")}function c(t){return h(t).map((([t,e])=>`${encodeURIComponent(t)}=${encodeURIComponent(e)}`)).join(",")}let d=null;function u(){return null==d&&(d="undefined"!=typeof OffscreenCanvas&&new OffscreenCanvas(1,1).getContext("2d")&&"function"==typeof createImageBitmap),d||!1}let f=null;function p(t,e,i){let r=()=>{};const s=setTimeout((()=>{r(new Error("timed out")),null==i||i.abort()}),t);w(i,(()=>{r(new Error("aborted")),clearTimeout(s)}));const n=new Promise(((t,e)=>{r=e}));return Promise.race([n,e.finally((()=>clearTimeout(s)))])}function w(t,e){e&&(null==t||t.signal.addEventListener("abort",e))}function g(t){var e;return Boolean(null===(e=null==t?void 0:t.signal)||void 0===e?void 0:e.aborted)}let m,b,y,v,P=0;class x{constructor(t=100){this.size=()=>this.items.size,this.get=(t,e,i)=>{let r=this.items.get(t);if(r)r.lastUsed=++P,r.waiting++;else{const i=new AbortController,s=e(t,i);r={abortController:i,item:s,lastUsed:++P,waiting:1},this.items.set(t,r),this.prune()}const s=this.items,n=r.item.then((t=>t),(e=>(s.delete(t),Promise.reject(e))));let o=!1;return w(i,(()=>{var e;r&&r.abortController&&!o&&(o=!0,--r.waiting<=0&&(null===(e=r.abortController)||void 0===e||e.abort(),s.delete(t)))})),n},this.clear=()=>this.items.clear(),this.maxSize=t,this.items=new Map}prune(){if(this.items.size>this.maxSize){let t,e=1/0;this.items.forEach(((i,r)=>{i.lastUsed<e&&(e=i.lastUsed,t=r)})),void 0!==t&&this.items.delete(t)}}}function F(t,e){return m||(m=new OffscreenCanvas(t.width,t.height),b=m.getContext("2d",{willReadFrequently:!0})),V(t,e,m,b)}const k=function(){if(null==f&&(f=!1,u()&&"undefined"!=typeof VideoFrame)){const t=5,e=new OffscreenCanvas(5,5).getContext("2d",{willReadFrequently:!0});if(e){for(let i=0;i<t*t;i++){const r=4*i;e.fillStyle=`rgb(${r},${r+1},${r+2})`,e.fillRect(i%t,Math.floor(i/t),1,1)}const i=e.getImageData(0,0,t,t).data;for(let e=0;e<t*t*4;e++)if(e%4!=3&&i[e]!==e){f=!0;break}}}return f||!1}()?function(t,e,i){return a(this,void 0,void 0,(function*(){var r,s,n;const o=yield createImageBitmap(t);if(g(i))return null;const a=new VideoFrame(o,{timestamp:0});try{if(!((null===(r=null==a?void 0:a.format)||void 0===r?void 0:r.startsWith("BGR"))||(null===(s=null==a?void 0:a.format)||void 0===s?void 0:s.startsWith("RGB"))))throw new Error(`Unrecognized format: ${null==a?void 0:a.format}`);const t=null===(n=null==a?void 0:a.format)||void 0===n?void 0:n.startsWith("BGR"),i=a.allocationSize(),h=new Uint8ClampedArray(i);if(yield a.copyTo(h),t)for(let t=0;t<h.length;t+=4){const e=h[t];h[t]=h[t+2],h[t+2]=e}return M(o.width,o.height,e,h)}catch(t){return g(i)?null:F(o,e)}finally{a.close()}}))}:u()?function(t,e,i){return a(this,void 0,void 0,(function*(){const r=yield createImageBitmap(t);return g(i)?null:F(r,e)}))}:"undefined"!=typeof WorkerGlobalScope&&"undefined"!=typeof self&&self instanceof WorkerGlobalScope?function(t,e,i){return self.actor.send("decodeImage",[],i,void 0,t,e)}:function(t,e,i){return a(this,void 0,void 0,(function*(){y||(y=document.createElement("canvas"),v=y.getContext("2d",{willReadFrequently:!0}));const r=new Image;w(i,(()=>r.src=""));return V(yield new Promise(((e,s)=>{r.onload=()=>{g(i)||e(r),URL.revokeObjectURL(r.src),r.onload=null},r.onerror=()=>s(new Error("Could not load image.")),r.src=t.size?URL.createObjectURL(t):""})),e,y,v)}))};function V(t,e,i,r){if(i.width=t.width,i.height=t.height,!r)throw new Error("failed to get context");r.drawImage(t,0,0,t.width,t.height);const s=r.getImageData(0,0,t.width,t.height).data;return M(t.width,t.height,e,s)}function M(t,e,i,r){const s="mapbox"===i?(t,e,i)=>.1*(256*t*256+256*e+i)-1e4:(t,e,i)=>256*t+e+i/256-32768,n=new Float32Array(t*e);for(let t=0;t<r.length;t+=4)n[t/4]=s(r[t],r[t+1],r[t+2]);return{width:t,height:e,data:n}}class S{constructor(t,e,i){this.split=(t,e,i)=>{if(0===t)return this;const r=1<<t,s=e*this.width/r,n=i*this.height/r;return new S(this.width/r,this.height/r,((t,e)=>this.get(t+s,e+n)))},this.subsamplePixelCenters=t=>{const e=(t,e,i)=>isNaN(t)?e:isNaN(e)?t:t+(e-t)*i;if(t<=1)return this;const i=.5-1/(2*t);return new S(this.width*t,this.height*t,((r,s)=>{const n=r/t-iLine truncated
|
||||
@@ -21,6 +21,7 @@
|
||||
</script>
|
||||
<script src="/vendor/pmtiles/pmtiles.js"></script>
|
||||
<script src="/vendor/protomaps-themes-base/protomaps-themes-base.js"></script>
|
||||
<script src="/vendor/maplibre-contour/maplibre-contour.min.js"></script>
|
||||
<%# Vendored libs above don't change between deploys, so they're left
|
||||
unversioned; app.js's own modules do, and map.js statically imports
|
||||
them by bare path — an import map remaps those paths to a versioned
|
||||
@@ -31,6 +32,7 @@
|
||||
{
|
||||
"imports": {
|
||||
"/js/auth.js": "/js/auth.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/contour.js": "/js/contour.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/dem.js": "/js/dem.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/gnss.js": "/js/gnss.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/gpx.js": "/js/gpx.js?v=<%= ASSET_VERSION %>",
|
||||
|
||||
Reference in new issue
Block a user