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)
+ })
+ })
+})