Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions client/src/components/MapComponent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 4 additions & 5 deletions client/src/lib/mapservices/ManagedMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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: [
Expand Down
63 changes: 63 additions & 0 deletions client/src/lib/mapservices/MapZoomControlGroup.ts
Original file line number Diff line number Diff line change
@@ -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 =
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">' +
'<path d="M1.5 1a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4A1.5 1.5 0 0 1 1.5 0h4a.5.5 0 0 1 0 1h-4z' +
'M10 .5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 16 1.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5z' +
'M.5 10a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 0 14.5v-4a.5.5 0 0 1 .5-.5z' +
'm15 0a.5.5 0 0 1 .5.5v4a1.5 1.5 0 0 1-1.5 1.5h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5z"/>' +
'</svg>'
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)
}
}
33 changes: 0 additions & 33 deletions client/src/lib/mapservices/ZoomToTracksControl.ts

This file was deleted.

127 changes: 127 additions & 0 deletions client/tests/unit/lib/mapservices/MapZoomControlGroup.spec.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(`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<string, unknown>).map_ = mockMap
return { mockView, mockMap }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('MapZoomControlGroup', () => {
let control: MapZoomControlGroup
let mockCallbackSpy: ReturnType<typeof vi.fn>

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