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
5 changes: 3 additions & 2 deletions providers/maplibre/src/maplibreProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export default class MapLibreProvider {
this.map.flyTo({
center: center || this.getCenter(),
zoom: zoom || this.getZoom(),
duration: DEFAULTS.animationDuration
duration: this.map.isStyleLoaded() ? DEFAULTS.animationDuration : 0
})
}

Expand Down Expand Up @@ -199,7 +199,8 @@ export default class MapLibreProvider {
*/
fitToBounds (bounds) {
const bbox = Array.isArray(bounds) ? bounds : getBboxFromGeoJSON(bounds)
this.map.fitBounds(bbox, { duration: DEFAULTS.animationDuration })
const duration = this.map.isStyleLoaded() ? DEFAULTS.animationDuration : 0
this.map.fitBounds(bbox, { duration })
}

/**
Expand Down
22 changes: 22 additions & 0 deletions providers/maplibre/src/maplibreProvider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,12 @@ describe('MapLibreProvider', () => {
map = {
touchZoomRotate: { disableRotation: jest.fn() },
on: jest.fn((e, fn) => { if (e === 'load') loadCallback = fn }),
once: jest.fn(),
off: jest.fn(),
remove: jest.fn(),
setPadding: jest.fn(),
fitBounds: jest.fn(),
isStyleLoaded: jest.fn(() => true),
flyTo: jest.fn(),
easeTo: jest.fn(),
panBy: jest.fn(),
Expand Down Expand Up @@ -156,6 +158,16 @@ describe('MapLibreProvider', () => {
expect(call.duration).toBe(400)
})

test('setView uses duration 0 when style is not yet loaded', async () => {
const p = makeProvider()
await doInitMap(p)
map.isStyleLoaded.mockReturnValue(false)

p.setView({ center: [1, 2], zoom: 8 })

expect(map.flyTo).toHaveBeenCalledWith({ center: [1, 2], zoom: 8, duration: 0 })
})

test('zoomIn, zoomOut, panBy, fitToBounds, setPadding delegate to map', async () => {
const p = makeProvider()
await doInitMap(p)
Expand All @@ -171,6 +183,16 @@ describe('MapLibreProvider', () => {
expect(map.setPadding).toHaveBeenCalledWith({ top: 5 })
})

test('fitToBounds uses duration 0 when style is not yet loaded', async () => {
const p = makeProvider()
await doInitMap(p)
map.isStyleLoaded.mockReturnValue(false)

p.fitToBounds([[0, 0], [1, 1]])

expect(map.fitBounds).toHaveBeenCalledWith([[0, 0], [1, 1]], { duration: 0 })
})

test('fitToBounds accepts GeoJSON: computes bbox via getBboxFromGeoJSON', async () => {
const { getBboxFromGeoJSON } = require('./utils/spatial.js')
const p = makeProvider()
Expand Down
4 changes: 2 additions & 2 deletions src/InteractiveMap/InteractiveMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ export default class InteractiveMap {
* @param {[number, number, number, number] | object} target - Bounds as [west, south, east, north] or [minX, minY, maxX, maxY] depending on the crs, or a GeoJSON Feature, FeatureCollection, or geometry.
*/
fitToBounds (target) {
this.eventBus.emit(events.MAP_FIT_TO_BOUNDS, target)
this.eventBus.emitWhenReady(events.MAP_FIT_TO_BOUNDS, target)
}

/**
Expand All @@ -446,7 +446,7 @@ export default class InteractiveMap {
* @param {{ center?: [number, number], zoom?: number }} opts - View options.
*/
setView (opts) {
this.eventBus.emit(events.MAP_SET_VIEW, opts)
this.eventBus.emitWhenReady(events.MAP_SET_VIEW, opts)
}

/**
Expand Down
8 changes: 4 additions & 4 deletions src/InteractiveMap/InteractiveMap.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ jest.mock('../utils/detectInterfaceType.js', () => ({
}))
jest.mock('../services/reverseGeocode.js', () => ({ createReverseGeocode: jest.fn() }))
jest.mock('../services/eventBus.js', () => ({
createEventBus: jest.fn(() => ({ on: jest.fn(), off: jest.fn(), emit: jest.fn(), destroy: jest.fn() })),
default: { on: jest.fn(), off: jest.fn(), emit: jest.fn(), destroy: jest.fn() }
createEventBus: jest.fn(() => ({ on: jest.fn(), off: jest.fn(), emit: jest.fn(), emitWhenReady: jest.fn(), destroy: jest.fn() })),
default: { on: jest.fn(), off: jest.fn(), emit: jest.fn(), emitWhenReady: jest.fn(), destroy: jest.fn() }
}))
jest.mock('../App/initialiseApp.js', () => ({ initialiseApp: jest.fn() }))

Expand Down Expand Up @@ -635,12 +635,12 @@ describe('InteractiveMap Public API Methods', () => {
it('fitToBounds emits MAP_FIT_TO_BOUNDS with bbox', () => {
const bbox = [-0.489, 51.28, 0.236, 51.686]
map.fitToBounds(bbox)
expect(map.eventBus.emit).toHaveBeenCalledWith('map:fittobounds', bbox)
expect(map.eventBus.emitWhenReady).toHaveBeenCalledWith('map:fittobounds', bbox)
})

it('setView emits MAP_SET_VIEW with opts', () => {
const opts = { center: [-0.1276, 51.5074], zoom: 12 }
map.setView(opts)
expect(map.eventBus.emit).toHaveBeenCalledWith('map:setview', opts)
expect(map.eventBus.emitWhenReady).toHaveBeenCalledWith('map:setview', opts)
})
})
61 changes: 61 additions & 0 deletions src/services/eventBus.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,43 @@
class EventBus {
constructor () {
this.events = {}
this._queued = {}
}

/**
* Register a handler for an event. If a value was queued via `emitWhenReady`
* before any listeners existed, the handler is called immediately with that
* value and the queue is cleared.
*
* @param {string} eventName
* @param {Function} handler
* @returns {this}
*/
on (eventName, handler) {
if (!this.events[eventName]) {
this.events[eventName] = []
}
this.events[eventName].push(handler)

if (this._queued[eventName] !== undefined) {
try {
handler(...this._queued[eventName])
} catch (error) {
console.error(`Error in event handler for '${eventName}':`, error)
}
delete this._queued[eventName]
}

return this
}

/**
* Remove a handler for an event. Omit `handler` to remove all handlers.
*
* @param {string} eventName
* @param {Function} [handler]
* @returns {this}
*/
off (eventName, handler) {
if (!this.events[eventName]) {
return this
Expand All @@ -23,6 +50,13 @@ class EventBus {
return this
}

/**
* Emit an event, calling all registered handlers synchronously.
*
* @param {string} eventName
* @param {...*} args
* @returns {this}
*/
emit (eventName, ...args) {
if (!this.events[eventName]) {
return this
Expand All @@ -38,13 +72,40 @@ class EventBus {
return this
}

/**
* Like `emit`, but queues the args if no listeners are registered yet.
* The first listener to subscribe will receive the queued value immediately,
* after which the queue is cleared. Subsequent `emitWhenReady` calls for the
* same event before a listener arrives replace the queued value.
*
* Use this for events that may be emitted before the listener side is ready
* (e.g. called from `map:ready` before React effects have run).
*
* @param {string} eventName
* @param {...*} args
* @returns {this}
*/
emitWhenReady (eventName, ...args) {
if (!this.events[eventName] || this.events[eventName].length === 0) {
this._queued[eventName] = args
return this
}
return this.emit(eventName, ...args)
}

/**
* Remove all handlers and clear any queued events.
*/
destroy () {
this.events = {}
this._queued = {}
}
}

/**
* Factory for map-local EventBus
*
* @returns {EventBus}
*/
export function createEventBus () {
return new EventBus()
Expand Down
70 changes: 70 additions & 0 deletions src/services/eventBus.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,76 @@ describe('EventBus singleton', () => {
})
})

describe('emitWhenReady', () => {
beforeEach(() => {
eventBus.destroy()
})

it('fires immediately when listeners are already registered', () => {
const handler = jest.fn()
eventBus.on('ready', handler)

eventBus.emitWhenReady('ready', 'value')

expect(handler).toHaveBeenCalledWith('value')
})

it('queues the call and replays to the first subscriber when no listeners exist', () => {
const handler = jest.fn()

eventBus.emitWhenReady('ready', 'value')
expect(handler).not.toHaveBeenCalled()

eventBus.on('ready', handler)
expect(handler).toHaveBeenCalledWith('value')
})

it('clears the queue after replaying so subsequent subscribers do not receive it', () => {
const first = jest.fn()
const second = jest.fn()

eventBus.emitWhenReady('ready', 'value')
eventBus.on('ready', first)
eventBus.on('ready', second)

expect(first).toHaveBeenCalledWith('value')
expect(second).not.toHaveBeenCalled()
})

it('replaces a queued value if emitWhenReady is called again before a subscriber arrives', () => {
const handler = jest.fn()

eventBus.emitWhenReady('ready', 'first')
eventBus.emitWhenReady('ready', 'second')
eventBus.on('ready', handler)

expect(handler).toHaveBeenCalledTimes(1)
expect(handler).toHaveBeenCalledWith('second')
})

it('catches and logs errors thrown by a handler during queued replay', () => {
const error = new Error('boom')
const handler = jest.fn(() => { throw error })
jest.spyOn(console, 'error').mockImplementation(() => {})

eventBus.emitWhenReady('ready', 'value')
eventBus.on('ready', handler)

expect(console.error).toHaveBeenCalledWith("Error in event handler for 'ready':", error)
console.error.mockRestore()
})

it('clears queued events on destroy', () => {
const handler = jest.fn()

eventBus.emitWhenReady('ready', 'value')
eventBus.destroy()
eventBus.on('ready', handler)

expect(handler).not.toHaveBeenCalled()
})
})

describe('createEventBus factory', () => {
/**
* Test to ensure coverage for the factory function (Line 50).
Expand Down
Loading