diff --git a/web/public/assets/js/app/__tests__/map-auto-fit-controller.test.js b/web/public/assets/js/app/__tests__/map-auto-fit-controller.test.js new file mode 100644 index 0000000..d580c62 --- /dev/null +++ b/web/public/assets/js/app/__tests__/map-auto-fit-controller.test.js @@ -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(); +}); diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index d5adb28..05f6519 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -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); diff --git a/web/public/assets/js/app/map-auto-fit-controller.js b/web/public/assets/js/app/map-auto-fit-controller.js new file mode 100644 index 0000000..cdfb717 --- /dev/null +++ b/web/public/assets/js/app/map-auto-fit-controller.js @@ -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 + }; +} diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index b667a1c..fef0934 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -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; }