From dc62a9a57de7a97110ba213b4f4652bb2f5252d7 Mon Sep 17 00:00:00 2001 From: Art Furrer <2823246+bonham@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:14:33 +0200 Subject: [PATCH] openlayers controls overlap with set to extent button in mobile Fixes #180 --- client/src/components/MapComponent.vue | 6 +- client/src/lib/mapservices/ManagedMap.ts | 9 +- .../lib/mapservices/MapZoomControlGroup.ts | 63 +++++++++ .../lib/mapservices/ZoomToTracksControl.ts | 33 ----- .../mapservices/MapZoomControlGroup.spec.ts | 127 ++++++++++++++++++ 5 files changed, 197 insertions(+), 41 deletions(-) create mode 100644 client/src/lib/mapservices/MapZoomControlGroup.ts delete mode 100644 client/src/lib/mapservices/ZoomToTracksControl.ts create mode 100644 client/tests/unit/lib/mapservices/MapZoomControlGroup.spec.ts diff --git a/client/src/components/MapComponent.vue b/client/src/components/MapComponent.vue index e00cff8a..1153ab76 100644 --- a/client/src/components/MapComponent.vue +++ b/client/src/components/MapComponent.vue @@ -253,9 +253,9 @@ watch( min-height: 100%; } -.map-control-expand { - top: 4em; - left: .5em; +.map-zoom-control-group { + top: 0.5em; + left: 0.5em; } .mapspinner { diff --git a/client/src/lib/mapservices/ManagedMap.ts b/client/src/lib/mapservices/ManagedMap.ts index 477d97d5..60b99b42 100644 --- a/client/src/lib/mapservices/ManagedMap.ts +++ b/client/src/lib/mapservices/ManagedMap.ts @@ -4,9 +4,8 @@ import { TrackSelectionManager } from './TrackSelectionManager' import { MapViewManager } from './MapViewManager' import { TrackPopoverController } from './TrackPopoverController' -// Original imports preserved for compatibility import { ExtentCollection } from './ExtentCollection' -import { ZoomToTracksControl } from './ZoomToTracksControl' +import { MapZoomControlGroup } from './MapZoomControlGroup' import { Feature, Map as OlMap, View } from 'ol' // rename needed not to conflict with javascript native Map() import type { Extent } from 'ol/extent' import { Tile as TileLayer } from 'ol/layer' @@ -206,9 +205,9 @@ export class ManagedMap { */ private _createMap(center = [0, 0], zoom = 0): OlMap { const map = new OlMap({ - controls: defaultControls().extend([ - new ZoomToTracksControl({ - actionCallBack: this.setExtentAndZoomOut.bind(this) + controls: defaultControls({ zoom: false }).extend([ + new MapZoomControlGroup({ + zoomToTracksCallback: this.setExtentAndZoomOut.bind(this) }) ]), layers: [ diff --git a/client/src/lib/mapservices/MapZoomControlGroup.ts b/client/src/lib/mapservices/MapZoomControlGroup.ts new file mode 100644 index 00000000..cadcce9c --- /dev/null +++ b/client/src/lib/mapservices/MapZoomControlGroup.ts @@ -0,0 +1,63 @@ +import { Control } from 'ol/control' + +interface Options { + zoomToTracksCallback: () => void +} + +/** + * A single OpenLayers control that groups zoom-in, zoom-out, and zoom-to-tracks + * buttons in one flex-column container. All three buttons share the same + * parent element so alignment is handled by the browser layout engine rather + * than by hard-coded CSS top/left offsets. + */ +export class MapZoomControlGroup extends Control { + constructor(options: Options) { + const container = document.createElement('div') + container.className = 'map-zoom-control-group ol-unselectable ol-control' + + const zoomInButton = document.createElement('button') + zoomInButton.innerHTML = '+' + zoomInButton.type = 'button' + zoomInButton.title = 'Zoom in' + + const zoomOutButton = document.createElement('button') + zoomOutButton.innerHTML = '−' // proper minus sign (−) + zoomOutButton.type = 'button' + zoomOutButton.title = 'Zoom out' + + const zoomToTracksButton = document.createElement('button') + zoomToTracksButton.innerHTML = + '' + + '' + + '' + zoomToTracksButton.type = 'button' + zoomToTracksButton.title = 'Zoom to tracks' + + container.appendChild(zoomInButton) + container.appendChild(zoomOutButton) + container.appendChild(zoomToTracksButton) + + super({ element: container }) + + zoomInButton.addEventListener('click', () => { + const view = this.getMap()?.getView() + const zoom = view?.getZoom() + if (view !== undefined && zoom !== undefined) { + view.setZoom(zoom + 1) + } + }) + + zoomOutButton.addEventListener('click', () => { + const view = this.getMap()?.getView() + const zoom = view?.getZoom() + if (view !== undefined && zoom !== undefined) { + view.setZoom(zoom - 1) + } + }) + + zoomToTracksButton.addEventListener('click', options.zoomToTracksCallback) + } +} diff --git a/client/src/lib/mapservices/ZoomToTracksControl.ts b/client/src/lib/mapservices/ZoomToTracksControl.ts deleted file mode 100644 index debfd09a..00000000 --- a/client/src/lib/mapservices/ZoomToTracksControl.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Options as ZoomOptions } from 'ol/control/Zoom' -import { Control } from 'ol/control' - - -interface ZoomToTracksControlOptions extends ZoomOptions { - actionCallBack: (() => void) -} -export class ZoomToTracksControl extends Control { - /** - * @param {Object} [optOptions] Control options. - */ - constructor(optOptions: ZoomToTracksControlOptions) { - // options: - // actionsCallback - const options = optOptions - - const button = document.createElement('button') - // const svg = document.createElement('svg') - - button.innerHTML = '' - - const element = document.createElement('div') - element.className = 'map-control-expand ol-unselectable ol-control' - element.appendChild(button) - - super({ - element, - target: options.target - }) - - button.addEventListener('click', optOptions.actionCallBack, false) - } -} diff --git a/client/tests/unit/lib/mapservices/MapZoomControlGroup.spec.ts b/client/tests/unit/lib/mapservices/MapZoomControlGroup.spec.ts new file mode 100644 index 00000000..492a8f2f --- /dev/null +++ b/client/tests/unit/lib/mapservices/MapZoomControlGroup.spec.ts @@ -0,0 +1,127 @@ +import { vi, describe, test, beforeEach, expect } from 'vitest' +import { MapZoomControlGroup } from '@/lib/mapservices/MapZoomControlGroup' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Access the protected OL Control.element without casting in every test */ +function getElement(control: MapZoomControlGroup): HTMLElement { + return (control as unknown as { element: HTMLElement }).element +} + +/** Grab buttons by title so tests are independent of button order */ +function getButton(control: MapZoomControlGroup, title: string): HTMLButtonElement { + const btn = getElement(control).querySelector(`button[title="${title}"]`) + if (!btn) throw new Error(`Button with title "${title}" not found`) + return btn +} + +/** Inject a minimal map-like object so click handlers can resolve getMap() */ +function injectMapMock(control: MapZoomControlGroup, currentZoom: number) { + const mockView = { + getZoom: vi.fn(() => currentZoom), + setZoom: vi.fn(), + } + const mockMap = { getView: vi.fn(() => mockView) } + // Bypass setMap() side-effects by setting the private reference directly + ; (control as unknown as Record).map_ = mockMap + return { mockView, mockMap } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('MapZoomControlGroup', () => { + let control: MapZoomControlGroup + let mockCallbackSpy: ReturnType + + beforeEach(() => { + mockCallbackSpy = vi.fn() + control = new MapZoomControlGroup({ zoomToTracksCallback: mockCallbackSpy as () => void }) + }) + + // ------------------------------------------------------------------------- + // DOM structure + // ------------------------------------------------------------------------- + + describe('DOM structure', () => { + test('container has map-zoom-control-group, ol-control and ol-unselectable CSS classes', () => { + const el = getElement(control) + expect(el.classList.contains('map-zoom-control-group')).toBe(true) + expect(el.classList.contains('ol-control')).toBe(true) + expect(el.classList.contains('ol-unselectable')).toBe(true) + }) + + test('container holds exactly 3 buttons', () => { + expect(getElement(control).querySelectorAll('button')).toHaveLength(3) + }) + + test('contains a button with title "Zoom in"', () => { + expect(getButton(control, 'Zoom in')).toBeInstanceOf(HTMLButtonElement) + }) + + test('contains a button with title "Zoom out"', () => { + expect(getButton(control, 'Zoom out')).toBeInstanceOf(HTMLButtonElement) + }) + + test('contains a button with title "Zoom to tracks"', () => { + expect(getButton(control, 'Zoom to tracks')).toBeInstanceOf(HTMLButtonElement) + }) + }) + + // ------------------------------------------------------------------------- + // Zoom-in button + // ------------------------------------------------------------------------- + + describe('zoom-in button', () => { + test('increases view zoom by 1', () => { + const { mockView } = injectMapMock(control, 5) + getButton(control, 'Zoom in').dispatchEvent(new MouseEvent('click')) + expect(mockView.setZoom).toHaveBeenCalledWith(6) + }) + + test('does not throw when no map is set', () => { + expect(() => + getButton(control, 'Zoom in').dispatchEvent(new MouseEvent('click')), + ).not.toThrow() + }) + }) + + // ------------------------------------------------------------------------- + // Zoom-out button + // ------------------------------------------------------------------------- + + describe('zoom-out button', () => { + test('decreases view zoom by 1', () => { + const { mockView } = injectMapMock(control, 5) + getButton(control, 'Zoom out').dispatchEvent(new MouseEvent('click')) + expect(mockView.setZoom).toHaveBeenCalledWith(4) + }) + + test('does not throw when no map is set', () => { + expect(() => + getButton(control, 'Zoom out').dispatchEvent(new MouseEvent('click')), + ).not.toThrow() + }) + }) + + // ------------------------------------------------------------------------- + // Zoom-to-tracks button + // ------------------------------------------------------------------------- + + describe('zoom-to-tracks button', () => { + test('calls the provided callback', () => { + getButton(control, 'Zoom to tracks').dispatchEvent(new MouseEvent('click')) + expect(mockCallbackSpy).toHaveBeenCalledOnce() + }) + + test('calls the callback again on each subsequent click', () => { + const btn = getButton(control, 'Zoom to tracks') + btn.dispatchEvent(new MouseEvent('click')) + btn.dispatchEvent(new MouseEvent('click')) + expect(mockCallbackSpy).toHaveBeenCalledTimes(2) + }) + }) +})