diff --git a/src/ui/marker.ts b/src/ui/marker.ts index c34e3683a6c..b2d8732b34f 100644 --- a/src/ui/marker.ts +++ b/src/ui/marker.ts @@ -3,7 +3,7 @@ import Point from '@mapbox/point-geometry'; import * as DOM from '../util/dom'; import LngLat from '../geo/lng_lat'; import smartWrap from '../util/smart_wrap'; -import {bindAll, radToDeg, smoothstep} from '../util/util'; +import {bindAll, radToDeg, smoothstep, warnOnce} from '../util/util'; import {anchorTranslate} from './anchor'; import {Event, Evented} from '../util/evented'; import {GLOBE_ZOOM_THRESHOLD_MAX} from '../geo/projection/globe_constants'; @@ -52,7 +52,7 @@ type MarkerEvents = { * Creates a marker component. * * @param {Object} [options] - * @param {HTMLElement} [options.element] DOM element to use as a marker. The default is a light blue, droplet-shaped SVG marker. + * @param {HTMLElement} [options.element] DOM element to use as a marker. The default is a light blue, droplet-shaped SVG marker. The marker root is positioned via `transform` and must keep `position: absolute` (provided by the built-in `.mapboxgl-marker` class). If your custom element needs `position: relative` to anchor absolutely-positioned descendants, apply that rule to a child of the marker root instead. * @param {string} [options.anchor='center'] A string indicating the part of the Marker that should be positioned closest to the coordinate set via {@link Marker#setLngLat}. * Options are `'center'`, `'top'`, `'bottom'`, `'left'`, `'right'`, `'top-left'`, `'top-right'`, `'bottom-left'`, and `'bottom-right'`. * @param {PointLike} [options.offset] The offset in pixels as a {@link PointLike} object to apply relative to the element's center. Negatives indicate left and up. @@ -260,6 +260,18 @@ export default class Marker extends Evented { this.setDraggable(this._draggable); this._update(); + // Markers are positioned via `transform: translate(...)` on a `position: absolute` + // root (provided by the `.mapboxgl-marker` class). If a user-supplied element + // overrides `position` to anything that participates in normal flow, additional + // markers stack vertically in the canvas container instead of sharing the same + // origin, and pins after the first land at incorrect screen positions. + if (!this._defaultMarker && typeof window !== 'undefined' && window.getComputedStyle) { + const position = window.getComputedStyle(this._element).position; + if (position !== 'absolute' && position !== 'fixed') { + warnOnce(`Marker element has computed CSS \`position: ${position}\`, expected \`absolute\`. Mapbox positions markers via \`transform\` on a \`position: absolute\` root; overriding it (often via a user style like \`position: relative\` on the marker root) breaks placement of additional markers. Move that rule to a child of the marker element instead.`); + } + } + // If we attached the `click` listener to the marker element, the popup // would close once the event propogated to `map` due to the // `Popup#_onClickClose` listener. diff --git a/test/unit/ui/marker.test.ts b/test/unit/ui/marker.test.ts index 5e9a33df6ca..5aa0f1e6ebc 100644 --- a/test/unit/ui/marker.test.ts +++ b/test/unit/ui/marker.test.ts @@ -107,6 +107,28 @@ test('Marker#addTo adds the marker element to the canvas container', () => { map.remove(); }); +test('Marker warns when a custom element overrides position to non-absolute', () => { + const map = createMap(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const relativeEl = window.document.createElement('div'); + relativeEl.style.position = 'relative'; + new Marker({element: relativeEl}).setLngLat([0, 0]).addTo(map); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('relative'); + expect(warnSpy.mock.calls[0][0]).toContain('absolute'); + + warnSpy.mockClear(); + + const absoluteEl = window.document.createElement('div'); + absoluteEl.style.position = 'absolute'; + new Marker({element: absoluteEl}).setLngLat([0, 0]).addTo(map); + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + map.remove(); +}); + test('Marker adds classes from className option, methods for class manipulation work properly', () => { const map = createMap(); const marker = new Marker({className: 'some classes'})