Fix map auto-fit handling and add controller (#311)

This commit is contained in:
l5y
2025-10-13 09:26:35 +02:00
committed by GitHub
parent e4c48682b0
commit 874c8fd73c
4 changed files with 393 additions and 8 deletions
@@ -0,0 +1,162 @@
/**
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { createMapAutoFitController } from '../map-auto-fit-controller.js';
class ToggleStub extends EventTarget {
constructor(checked = true) {
super();
this.checked = checked;
}
/**
* @param {Event} event - Event to dispatch to listeners.
* @returns {boolean} Dispatch status.
*/
dispatchEvent(event) {
return super.dispatchEvent(event);
}
}
class WindowStub {
constructor() {
this.listeners = new Map();
}
addEventListener(type, listener) {
this.listeners.set(type, listener);
}
removeEventListener(type, listener) {
const existing = this.listeners.get(type);
if (existing === listener) {
this.listeners.delete(type);
}
}
emit(type) {
const listener = this.listeners.get(type);
if (listener) listener();
}
}
test('recordFit stores and clones the last fit snapshot', () => {
const toggle = new ToggleStub(true);
const controller = createMapAutoFitController({ toggleEl: toggle, defaultPaddingPx: 20 });
assert.equal(controller.getLastFit(), null);
controller.recordFit([[10, 20], [30, 40]], { paddingPx: 12, maxZoom: 9 });
const snapshot = controller.getLastFit();
assert.ok(snapshot);
assert.deepEqual(snapshot.bounds, [[10, 20], [30, 40]]);
assert.deepEqual(snapshot.options, { paddingPx: 12, maxZoom: 9 });
snapshot.bounds[0][0] = -999;
snapshot.options.paddingPx = -1;
const secondSnapshot = controller.getLastFit();
assert.deepEqual(secondSnapshot?.bounds, [[10, 20], [30, 40]]);
assert.deepEqual(secondSnapshot?.options, { paddingPx: 12, maxZoom: 9 });
});
test('recordFit ignores invalid bounds and normalises fit options', () => {
const controller = createMapAutoFitController({ defaultPaddingPx: 16 });
controller.recordFit(null);
assert.equal(controller.getLastFit(), null);
controller.recordFit([[10, Number.NaN], [20, 30]]);
assert.equal(controller.getLastFit(), null);
controller.recordFit([[10, 11], [12, 13]], { paddingPx: -5, maxZoom: 0 });
const snapshot = controller.getLastFit();
assert.ok(snapshot);
assert.deepEqual(snapshot.options, { paddingPx: 16 });
});
test('handleUserInteraction disables auto-fit unless suppressed', () => {
const toggle = new ToggleStub(true);
let changeEvents = 0;
toggle.addEventListener('change', () => {
changeEvents += 1;
});
const controller = createMapAutoFitController({ toggleEl: toggle });
controller.runAutoFitOperation(() => {
assert.equal(controller.handleUserInteraction(), false);
assert.equal(toggle.checked, true);
});
assert.equal(changeEvents, 0);
assert.equal(controller.handleUserInteraction(), true);
assert.equal(toggle.checked, false);
assert.equal(changeEvents, 1);
assert.equal(controller.handleUserInteraction(), false);
assert.equal(changeEvents, 1);
});
test('isAutoFitEnabled reflects the toggle state', () => {
const toggle = new ToggleStub(false);
const controller = createMapAutoFitController({ toggleEl: toggle });
assert.equal(controller.isAutoFitEnabled(), false);
toggle.checked = true;
assert.equal(controller.isAutoFitEnabled(), true);
});
test('runAutoFitOperation returns callback results and tolerates missing functions', () => {
const controller = createMapAutoFitController();
assert.equal(controller.runAutoFitOperation(), undefined);
let active = false;
const result = controller.runAutoFitOperation(() => {
active = true;
return 42;
});
assert.equal(active, true);
assert.equal(result, 42);
});
test('attachResizeListener forwards snapshots and supports teardown', () => {
const windowStub = new WindowStub();
const controller = createMapAutoFitController({ windowObject: windowStub, defaultPaddingPx: 24 });
controller.recordFit([[1, 2], [3, 4]], { paddingPx: 30 });
let snapshots = [];
const detach = controller.attachResizeListener(snapshot => {
snapshots.push(snapshot);
});
windowStub.emit('resize');
windowStub.emit('orientationchange');
assert.equal(snapshots.length, 2);
assert.deepEqual(snapshots[0], { bounds: [[1, 2], [3, 4]], options: { paddingPx: 30 } });
detach();
windowStub.emit('resize');
assert.equal(snapshots.length, 2);
const noop = controller.attachResizeListener();
assert.equal(typeof noop, 'function');
noop();
});
+48 -1
View File
@@ -15,6 +15,7 @@
*/
import { computeBoundingBox, computeBoundsForPoints, haversineDistanceKm } from './map-bounds.js';
import { createMapAutoFitController } from './map-auto-fit-controller.js';
/**
* Entry point for the interactive dashboard. Wires up event listeners,
@@ -299,6 +300,12 @@ export function initializeApp(config) {
'msfullscreenchange'
];
const autoFitController = createMapAutoFitController({
toggleEl: fitBoundsEl,
windowObject: typeof window !== 'undefined' ? window : undefined,
defaultPaddingPx: AUTO_FIT_PADDING_PX
});
/**
* Fit the Leaflet map to the provided geographic bounds.
*
@@ -316,7 +323,28 @@ export function initializeApp(config) {
if (Number.isFinite(options.maxZoom) && options.maxZoom > 0) {
fitOptions.maxZoom = options.maxZoom;
}
map.fitBounds(bounds, fitOptions);
autoFitController.recordFit(bounds, { paddingPx: padding, maxZoom: fitOptions.maxZoom });
autoFitController.runAutoFitOperation(() => {
map.fitBounds(bounds, fitOptions);
});
}
/**
* Attempt to reapply the last recorded bounds when auto-fit is enabled.
*
* @param {{ animate?: boolean }} [options] Animation preferences.
* @returns {void}
*/
function applyLastRecordedBounds(options = {}) {
if (!autoFitController.isAutoFitEnabled()) return;
const snapshot = autoFitController.getLastFit();
if (!snapshot) return;
const { bounds, options: fitOptions } = snapshot;
fitMapToBounds(bounds, {
animate: Boolean(options.animate),
paddingPx: fitOptions.paddingPx,
maxZoom: fitOptions.maxZoom
});
}
/**
@@ -458,6 +486,17 @@ export function initializeApp(config) {
}
}
autoFitController.attachResizeListener(snapshot => {
refreshMapSize();
if (!snapshot) return;
if (!autoFitController.isAutoFitEnabled()) return;
fitMapToBounds(snapshot.bounds, {
animate: false,
paddingPx: snapshot.options.paddingPx,
maxZoom: snapshot.options.maxZoom
});
});
/**
* Respond to fullscreen change events originating from the browser.
*
@@ -967,13 +1006,21 @@ export function initializeApp(config) {
map.whenReady(() => {
applyFiltersToAllTiles();
refreshMapSize();
applyLastRecordedBounds({ animate: false });
});
} else {
applyFiltersToAllTiles();
applyLastRecordedBounds({ animate: false });
}
map.on('moveend', applyFiltersToAllTiles);
map.on('zoomend', applyFiltersToAllTiles);
map.on('movestart', () => {
autoFitController.handleUserInteraction();
});
map.on('zoomstart', () => {
autoFitController.handleUserInteraction();
});
neighborLinesLayer = L.layerGroup().addTo(map);
markersLayer = L.layerGroup().addTo(map);
@@ -0,0 +1,178 @@
/**
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @typedef {[number, number]} LatLngTuple
* @typedef {[LatLngTuple, LatLngTuple]} LatLngBoundsTuple
* @typedef {{ paddingPx: number, maxZoom?: number }} FitOptionsSnapshot
*/
/**
* Safely clone a Leaflet-compatible bounds tuple to avoid accidental mutation.
*
* @param {LatLngBoundsTuple} bounds - Bounds tuple to duplicate.
* @returns {LatLngBoundsTuple} Deep copy of the provided bounds.
*/
function cloneBounds(bounds) {
return [
[bounds[0][0], bounds[0][1]],
[bounds[1][0], bounds[1][1]]
];
}
/**
* Determine whether the provided structure resembles a Leaflet bounds tuple.
*
* @param {unknown} value - Potential bounds input.
* @returns {value is LatLngBoundsTuple} True when the input is structurally valid.
*/
function isValidBounds(value) {
if (!Array.isArray(value) || value.length !== 2) return false;
const [southWest, northEast] = value;
if (!Array.isArray(southWest) || !Array.isArray(northEast)) return false;
if (southWest.length !== 2 || northEast.length !== 2) return false;
const numbers = [southWest[0], southWest[1], northEast[0], northEast[1]];
return numbers.every(number => Number.isFinite(number));
}
/**
* Create a controller for coordinating map auto-fit behaviour.
*
* @param {object} options - Controller configuration options.
* @param {HTMLInputElement|null} [options.toggleEl] - Checkbox controlling auto-fit.
* @param {Window|undefined} [options.windowObject] - Browser window instance.
* @param {number} [options.defaultPaddingPx=32] - Padding fallback when none supplied.
* @returns {{
* attachResizeListener(callback: (snapshot: { bounds: LatLngBoundsTuple, options: FitOptionsSnapshot } | null) => void): () => void,
* getLastFit(): { bounds: LatLngBoundsTuple, options: FitOptionsSnapshot } | null,
* handleUserInteraction(): boolean,
* isAutoFitEnabled(): boolean,
* recordFit(bounds: LatLngBoundsTuple, options?: { paddingPx?: number, maxZoom?: number }): void,
* runAutoFitOperation(fn: () => unknown): unknown
* }} Map auto-fit controller instance.
*/
export function createMapAutoFitController({
toggleEl = null,
windowObject = typeof window !== 'undefined' ? window : undefined,
defaultPaddingPx = 32
} = {}) {
/** @type {LatLngBoundsTuple|null} */
let lastBounds = null;
/** @type {FitOptionsSnapshot} */
let lastOptions = { paddingPx: defaultPaddingPx };
let autoFitInProgress = false;
/**
* Record the most recent set of bounds used for auto-fitting.
*
* @param {LatLngBoundsTuple} bounds - Leaflet bounds tuple.
* @param {{ paddingPx?: number, maxZoom?: number }} [options] - Fit options to persist.
* @returns {void}
*/
function recordFit(bounds, options = {}) {
if (!isValidBounds(bounds)) return;
const paddingPx = Number.isFinite(options.paddingPx) && options.paddingPx >= 0 ? options.paddingPx : defaultPaddingPx;
const maxZoom = Number.isFinite(options.maxZoom) && options.maxZoom > 0 ? options.maxZoom : undefined;
lastBounds = cloneBounds(bounds);
lastOptions = { paddingPx };
if (maxZoom !== undefined) {
lastOptions.maxZoom = maxZoom;
} else {
delete lastOptions.maxZoom;
}
}
/**
* Return a snapshot of the most recently recorded fit bounds.
*
* @returns {{ bounds: LatLngBoundsTuple, options: FitOptionsSnapshot } | null} Snapshot or ``null`` when unavailable.
*/
function getLastFit() {
if (!lastBounds) return null;
return { bounds: cloneBounds(lastBounds), options: { ...lastOptions } };
}
/**
* Test whether auto-fit is currently enabled by the user.
*
* @returns {boolean} True when the toggle exists and is checked.
*/
function isAutoFitEnabled() {
return Boolean(toggleEl && toggleEl.checked);
}
/**
* Execute a callback while marking auto-fit as in-progress.
*
* @template T
* @param {() => T} fn - Operation to run while suppressing interaction side-effects.
* @returns {T | undefined} Result of ``fn`` when provided.
*/
function runAutoFitOperation(fn) {
if (typeof fn !== 'function') return undefined;
autoFitInProgress = true;
try {
return fn();
} finally {
autoFitInProgress = false;
}
}
/**
* Disable auto-fit in response to manual user interactions with the map.
*
* @returns {boolean} True when the toggle was modified.
*/
function handleUserInteraction() {
if (!toggleEl || !toggleEl.checked || autoFitInProgress) {
return false;
}
toggleEl.checked = false;
const event = new Event('change', { bubbles: true });
toggleEl.dispatchEvent(event);
return true;
}
/**
* Attach resize listeners that notify the consumer when a refit may be required.
*
* @param {(snapshot: { bounds: LatLngBoundsTuple, options: FitOptionsSnapshot } | null) => void} callback - Resize handler.
* @returns {() => void} Function that removes the registered listeners.
*/
function attachResizeListener(callback) {
if (!windowObject || typeof windowObject.addEventListener !== 'function' || typeof callback !== 'function') {
return () => {};
}
const handler = () => {
callback(getLastFit());
};
windowObject.addEventListener('resize', handler, { passive: true });
windowObject.addEventListener('orientationchange', handler, { passive: true });
return () => {
windowObject.removeEventListener('resize', handler);
windowObject.removeEventListener('orientationchange', handler);
};
}
return {
attachResizeListener,
getLastFit,
handleUserInteraction,
isAutoFitEnabled,
recordFit,
runAutoFitOperation
};
}
+5 -7
View File
@@ -162,14 +162,12 @@ h1 {
#map {
position: relative;
flex: 1;
width: 100%;
height: 60vh;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
display: block;
}
.map-panel.is-fullscreen,
@@ -347,9 +345,9 @@ th {
.map-panel {
position: relative;
flex: 1;
display: flex;
flex: 1 1 0%;
min-width: 0;
display: block;
}
.map-toolbar {
@@ -900,7 +898,7 @@ footer {
#map {
order: 1;
flex: none;
width: 100%;
max-width: 100%;
height: 50vh;
}