Home button fits all of Slovakia; add an overview map, top-right
CI / check (push) Successful in 1m33s
CI / deploy (push) Successful in 27s

Home now fitBounds()s a country-wide bbox instead of resetting to
CENTER/ZOOM's Bratislava point, since Home resetting into the same
city you'd already be looking at wasn't useful.

The overview map is NPS's own overview plugin (npmap5-plugins, still
on an unmerged branch), vendored and converted from its UMD dist
build to a plain ES module. Patched one MapLibre API break found while
verifying it: the plugin reads map.transform (public in the MapLibre
~2.4 it targets) to relax the inset's polar pan limit; MapLibre 6 (this
app's vendored version) no longer exposes .transform at all, so it
threw on load. Guarded to skip that cosmetic adjustment when absent
instead.
This commit is contained in:
randy committed 2026-09-07 22:30:56 +02:00
1 parent ddf2ee4990
commit 96065b1237
7 files changed
+441 -31

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
### LICENSE.md
This project constitutes a work of the United States Government and is not subject to domestic copyright protection under [17 USC § 105](https://www.govinfo.gov/app/details/USCODE-2010-title17/USCODE-2010-title17-chap1-sec105).
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+346
View File
@@ -0,0 +1,346 @@
// Vendored from nationalparkservice/npmap5-plugins, branch
// maplibre-gl-overview (not yet merged to main — see
// docs/nps-resources.adoc). MIT-licensed per that branch's package.json;
// see LICENSE.md alongside this file for the repo's own notice.
//
// Converted from its UMD dist build (dist/maplibre-gl-overview.min.js) to a
// plain ES module export — the only change from upstream besides that
// wrapper swap.
const blankGeoJsonFeature = () => ({
"type": "FeatureCollection",
"features": []
});
const between = (min, value, max) => Math.min(max, Math.max(min, value));
function OverviewMap(mapLibrary) {
return class OverviewMapControl {
constructor(options) {
this._extent = blankGeoJsonFeature();
this._moving = false;
this._easingMain = false;
this._keyboardEvents = (e) => {
if (e.altKey || e.ctrlKey || e.metaKey)
return;
// The +1 directions are in Pixel space, not LngLat, in pixel space 0,0 it the top left
const events = {
"ArrowLeft": () => {
e.preventDefault();
return { xDir: 1 };
},
"ArrowRight": () => {
e.preventDefault();
return { xDir: -1 };
},
"ArrowUp": () => {
e.preventDefault();
return { yDir: 1 };
},
"ArrowDown": () => {
e.preventDefault();
return { yDir: -1 };
},
"+": () => ({ zoomDir: +1 }),
"-": () => ({ zoomDir: -1 }),
};
if (events[e.key]) {
// Update the variables
let { xDir, yDir, zoomDir } = events[e.key]();
xDir = xDir || 0;
yDir = yDir || 0;
zoomDir = zoomDir || 0;
if (this._mainMap) {
const zoomMain = this._mainMap.getZoom();
// Convert the current center of the overview map to pixel space
const overviewMapCenterPixels = this._overviewMap.project(this._overviewMap.getCenter());
// Get the point that would be panned that center
const newCenter = this._overviewMap.unproject(new this._mapLibrary.Point(overviewMapCenterPixels.x + (xDir * this._overviewMap.keyboard._panStep), overviewMapCenterPixels.y + (yDir * this._overviewMap.keyboard._panStep)));
// Get the current center of the main map
const mainMapCenterPixels = this._mainMap.project(this._mainMap.getCenter());
// Get the new point on the main map in pixels
const mainMapNewCenterPixels = this._mainMap.project(newCenter);
// Define the offset to pan to
const xyOffset = {
x: mainMapNewCenterPixels.x - mainMapCenterPixels.x,
y: mainMapNewCenterPixels.y - mainMapCenterPixels.y
};
this._mainMap.easeTo({
duration: 300,
easeId: 'keyboardHandler',
easing: (t) => t * (2 - t),
zoom: zoomDir ? Math.round(zoomMain) + zoomDir * (e.shiftKey ? 2 : 1) : zoomMain,
offset: [xyOffset.x, xyOffset.y],
center: this._mainMap.getCenter()
}, { originalEvent: e });
}
}
};
// Create a container for the overview map
this._container = document.createElement("div");
this._container.className = "maplibregl-ctrl-overview-map maplibregl-ctrl";
this._container.addEventListener("contextmenu", (e) => e.preventDefault());
// options
const defaultOptions = {
zoomLevelOffset: -5,
watchEvents: ['move', 'rotate', 'pitch'],
overlayPaint: {
'fill-color': '#d29700',
'fill-opacity': 0.75
},
selectionPaint: {
'fill-color': '#d29700',
'fill-opacity': 0.25,
'fill-outline-color': '#000000'
},
width: 150,
height: 150,
style: '',
_keyboardPanStep: 15
};
this.options = { ...defaultOptions, ...options };
this._mapLibrary = mapLibrary;
this._container.setAttribute('style', `width: ${this.options.width}px; height: ${this.options.height}px;`);
// Create the map for the overview
this._overviewMap = new mapLibrary.Map({
container: this._container,
style: this.options.style,
interactive: true,
pitchWithRotate: false,
attributionControl: false,
boxZoom: false,
dragRotate: false,
touchZoomRotate: false,
touchPitch: false,
minZoom: -2
});
this._overviewMap.on('load', () => {
// `.transform` existed as a public Map property when this
// plugin was built (~MapLibre 2.4.0); this app vendors a
// newer MapLibre where it's gone entirely, so this relaxes
// nothing and the overview keeps MapLibre's default ~85°
// Web Mercator pan limit instead — harmless, just means the
// inset can't scroll arbitrarily close to the poles.
if (this._overviewMap.transform) {
const maxLat = this._overviewMap.transform.maxValidLatitude || 85.051129;
this._overviewMap.transform.latRange = [maxLat * -2, maxLat * 2];
}
this._overviewMap.resize();
this._overviewMap.addSource('bboxSource', {
'type': 'geojson',
'data': this._extent
});
this._overviewMap.addSource('selectionSource', {
'type': 'geojson',
'data': blankGeoJsonFeature()
});
// Add a new layer to visualize the polygon.
this._overviewMap.addLayer({
'id': 'bboxLayer',
'type': 'fill',
'source': 'bboxSource',
'layout': {},
'paint': this.options.overlayPaint
});
this._overviewMap.addLayer({
'id': 'selectionFillLayer',
'type': 'fill',
'source': 'selectionSource',
'layout': {},
'paint': this.options.selectionPaint
});
this._overviewMap.addLayer({
'id': 'selectionLineLayer',
'type': 'line',
'source': 'selectionSource',
'layout': {},
'paint': {
'line-color': this.options.selectionPaint['fill-outline-color'] || '#000000',
'line-width': 2
}
});
});
}
onAdd(map) {
this._mainMap = map;
if (this.options.style === '') {
this._overviewMap.setStyle(this._mainMap.style.serialize());
}
// Disable the keyboard events, but use our own
this._overviewMap.keyboard.disable();
// we still use the _panstep from the keyboard events in out custom function
this._overviewMap.keyboard._panStep = this.options._keyboardPanStep;
//this._overviewMap.getCanvas().addEventListener('keydown', this._keyboardEvents, true);
// Update the overview map when the main map moves
this.options.watchEvents.forEach(event => map.on(event, () => this._updateOverview(map)));
/////////////////////////////
// Update Overview Map triggers
// Once the style is loaded, start listening
map.once('style.load', () => this._updateOverview(map));
// Start listening when it's added
this._updateOverview(map);
// Update the main map when the overview moves if the map is loaded (otherwise the style.load will pick up the change)
this._overviewMap.on('movestart', () => map.loaded() && this._updateMain(map));
// Make the overview map take control whenever the viewview map gets clicked
const takeControl = () => {
map.stop();
this._moving = false;
this._updateMain(map);
};
this._overviewMap.on('mousedown', takeControl);
this._overviewMap.on('touchstart', takeControl);
// Forward keydown events to the main map
this._overviewMap.getCanvas().addEventListener('keydown', (e) => {
//e.preventDefault();
map.stop();
this._keyboardEvents(e);
});
/////////////////////////////
return this._container;
}
onRemove() {
if (this._mainMap) {
//this._overviewMap.off('movestart', () => this._mainMap && this._moveOverview(this._mainMap));
this.options.watchEvents
.forEach(event => this._mainMap && this._mainMap
.off(event, () => this._updateOverview(this._mainMap)));
this._overviewMap.off('movestart', () => this._updateMain(this._mainMap));
}
if (this.options.style === '') {
this._overviewMap.setStyle('');
}
this._mainMap = undefined;
}
_updateOverview(map) {
var _a;
// Don't update the overview map if it's already moving
if (this._moving || !this._overviewMap)
return;
const pixelRatio = map.getPixelRatio();
const geojson = blankGeoJsonFeature();
if (map) {
const center = map.unproject([(map.getCanvas().width / pixelRatio) / 2, (map.getCanvas().height / pixelRatio) / 2]);
const bounds = map.getBounds();
let extentCoordinates = [
[-179.99, bounds.getNorth()],
[179.99, bounds.getNorth()],
[179.99, bounds.getSouth()],
[-179.99, bounds.getSouth()],
[-179.99, bounds.getNorth()],
];
// If the extent isn't larger that the earth, create a better one
if ((bounds.getEast() - bounds.getWest()) < 360 + (Math.abs(90 - Math.abs(90 - Math.abs(map.getBearing())))) * (map.getPitch() / 60)) {
extentCoordinates = [
map.unproject([0, 0]).toArray(),
map.unproject([map.getCanvas().width / pixelRatio, 0]).toArray(),
map.unproject([map.getCanvas().width / pixelRatio, map.getCanvas().height / pixelRatio]).toArray(),
map.unproject([0, map.getCanvas().height / pixelRatio]).toArray(),
map.unproject([0, 0]).toArray()
];
}
geojson.features.push({
"type": "Feature",
"properties": {},
"geometry": {
"type": "Polygon",
"coordinates": [extentCoordinates]
}
});
const newOverviewZoom = between(this._overviewMap.getMinZoom(), map.getZoom() + this.options.zoomLevelOffset, this._overviewMap.getMaxZoom());
if (!this._easingMain) {
this._moving = true;
this._overviewMap.setCenter(center);
this._overviewMap.setZoom(newOverviewZoom);
this._moving = false;
}
(_a = this._overviewMap.getSource('bboxSource')) === null || _a === void 0 ? void 0 : _a.setData(geojson);
this._extent = geojson;
}
}
_updateMain(map) {
var _a;
// Don't update the main map if it's already moving or if it doesn't exist
if (this._moving || !this._mainMap)
return;
const moveStartCenter = this._overviewMap.getCenter();
// Take control of the main map
this._mainMap.stop();
// Get the current extent, and copy it
const selectionExtent = JSON.parse(JSON.stringify(this._extent));
// Get the coordinates on the screen for the current map extent
const screenCoords = (selectionExtent.features[0]).geometry.coordinates[0].map(pos => this._overviewMap.project(pos));
// Update the GeoJSON object in the overview map to match the extent
(_a = this._overviewMap.getSource('selectionSource')) === null || _a === void 0 ? void 0 : _a.setData(selectionExtent);
// Used when overzoomed
const overZoomOffset = Math.max(this._overviewMap.getZoom() - this._mainMap.getZoom(), this.options.zoomLevelOffset);
// Called when the overview map is moving
const moving = () => {
var _a;
// Gets the current position
const currentSelectionExtentCoordinates = screenCoords
.map(pos => this._overviewMap.unproject([pos.x, pos.y]))
.map(xy => [xy.lng, xy.lat]);
// Updates the GeoJSON to match the new position
const geojson = blankGeoJsonFeature();
geojson.features.push({
"type": "Feature",
"properties": {},
"geometry": {
"type": "Polygon",
"coordinates": [currentSelectionExtentCoordinates]
}
});
// Updates the box on the overview map
(_a = this._overviewMap.getSource('selectionSource')) === null || _a === void 0 ? void 0 : _a.setData(geojson);
};
const doneEvents = () => {
var _a;
// Draw the current box on the overview map
(_a = this._overviewMap.getSource('selectionSource')) === null || _a === void 0 ? void 0 : _a.setData(blankGeoJsonFeature());
this._moving = false;
this._easingMain = false;
// Start up the event listeners again
this._updateOverview(map);
};
// Called when the overview map is done moving
const moveend = () => {
// Stop listening for events
this._overviewMap.off('move', moving);
this._overviewMap.off('moveend', moveend);
this._overviewMap.off('mouseup', moveend);
this._overviewMap.off('touchend', moveend);
if (this._easingMain)
return;
// If the map and the oveview map are ready, then we can update the map's center
if (this._mainMap) {
// Deal with the overview map being outside of its minZoom
let newCenter = this._overviewMap.getCenter();
const newMainZoom = between(this._mainMap.getMinZoom(), this._overviewMap.getZoom() - overZoomOffset, this._mainMap.getMaxZoom());
// Check if the overview map has moved, if so, ease the main map, otherwise finish events
if (newCenter.lng !== moveStartCenter.lng || newCenter.lat !== moveStartCenter.lat) {
// Execute the "doneEvents" code when the map stops moving
this._mainMap.once('moveend', (event) => event.doneEvents && event.doneEvents());
this._moving = true;
this._easingMain = true;
this._mainMap.easeTo({
center: newCenter,
zoom: newMainZoom
}, {
doneEvents
});
}
else {
doneEvents();
}
}
};
this._overviewMap.on('move', moving);
this._overviewMap.once('moveend', moveend);
this._overviewMap.once('mouseup', moveend);
this._overviewMap.once('touchend', moveend);
}
_getCenterAtPitch(map, pitch, rotation) {
const pixelRatio = map.getPixelRatio();
const center = map.unproject([(map.getCanvas().width / pixelRatio) / 2, (map.getCanvas().height / pixelRatio) / 2]);
return center;
}
};
}
export default OverviewMap;