diff --git a/src/ui/marker.js b/src/ui/marker.js index 5ff9e804453..d05ac422628 100644 --- a/src/ui/marker.js +++ b/src/ui/marker.js @@ -1,22 +1,45 @@ // @flow const DOM = require('../util/dom'); +const util = require('../util/util'); +const {bindAll} = require('../util/util'); const LngLat = require('../geo/lng_lat'); const Point = require('@mapbox/point-geometry'); const smartWrap = require('../util/smart_wrap'); -const {bindAll} = require('../util/util'); import type Map from './map'; import type Popup from './popup'; import type {LngLatLike} from "../geo/lng_lat"; import type {MapMouseEvent} from './events'; +const defaultOptions = { + anchor: 'middle', + offset: [0, 0] +}; + +export type Anchor = 'middle' | 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; +export type Offset = number | PointLike | {[Anchor]: PointLike}; + +export type MarkerOptions = { + anchor: Anchor, + offset: Offset +} + /** * Creates a marker component - * @param element DOM element to use as a marker. If left unspecified a default SVG will be created as the DOM element to use. - * @param options - * @param options.offset The offset in pixels as a {@link PointLike} object to apply relative to the element's center. Negatives indicate left and up. + * @param {Object} [element] DOM element to use as a marker. If left unspecified a default SVG will be created as the DOM element to use. + * @param {Object} [options] + * @param {string} [options.anchor] - A string indicating the markers's location relative to + * the coordinate set via {@link Marker#setLngLat}. + * Options are `'middle'`, `'top'`, `'bottom'`, `'left'`, `'right'`, `'top-left'`, `'top-right'`, `'bottom-left'`, and `'bottom-right'`. + * If unset the anchor will be dynamically set to ensure the marker falls within the map container with a preference for `'middle'` by default + * @param {number|PointLike|Object} [options.offset] The offset in pixels as a {@link PointLike} object to apply relative to the element's anchor. * @example + * var markerRadius = 10; + * var markerOptions = { + * anchor: 'middle', + * offset: [markerRadius/2, markerHeight/2] + * }; * var marker = new mapboxgl.Marker() * .setLngLat([30.5, 50.5]) * .addTo(map); @@ -24,13 +47,18 @@ import type {MapMouseEvent} from './events'; */ class Marker { _map: Map; - _offset: Point; + options: MarkerOptions; + _anchor: Anchor; + _offset: Offset; _element: HTMLElement; _popup: ?Popup; _lngLat: LngLat; _pos: ?Point; - constructor(element: ?HTMLElement, options?: {offset: PointLike}) { + + constructor(element: ?HTMLElement, options?: { anchor: string, offset: PointLike}) { + + this.options = util.extend(Object.create(defaultOptions), options); bindAll(['_update', '_onMapClick'], this); if (!element) { @@ -134,12 +162,16 @@ class Marker { // offset to the svg center "height (41 / 2)" gives (29.0 + 5.80029008) - (41 / 2) and rounded for an integer pixel offset gives 14 // negative is used to move the marker up from the center so the tip is at the Marker lngLat const defaultMarkerOffset = [0, -14]; - if (!(options && options.offset)) { + const defaultMarkerAnchor = 'middle'; + + if (!(options && options.anchor && options.offset)) { if (!options) { options = { + anchor: defaultMarkerAnchor, offset: defaultMarkerOffset }; } else { + options.anchor = defaultMarkerAnchor; options.offset = defaultMarkerOffset; } } @@ -149,7 +181,6 @@ class Marker { element.classList.add('mapboxgl-marker'); this._element = element; - this._popup = null; } @@ -280,42 +311,160 @@ class Marker { } _update(e?: {type: 'move' | 'moveend'}) { - if (!this._map) return; + if (!this._map || this._lngLat || !this._options) { return; } if (this._map.transform.renderWorldCopies) { this._lngLat = smartWrap(this._lngLat, this._pos, this._map.transform); } - this._pos = this._map.project(this._lngLat)._add(this._offset); + const pos = this._pos = this._map.project(this._lngLat); + + + let anchor = this.options.anchor || 'middle'; + const offset = normalizeOffset(this.options && this.options.offset || [0, 0]); + + if (!anchor) { + const width = this._element.offsetWidth, + height = this._element.offsetHeight; + + if (pos.y + offset.bottom.y < height) { + anchor = ['top']; + } else if (pos.y > this._map.transform.height - height) { + anchor = ['bottom']; + } else { + anchor = []; + } + + if (pos.x < width / 2) { + anchor.push('left'); + } else if (pos.x > this._map.transform.width - width / 2) { + anchor.push('right'); + } + + if (anchor.length === 0) { + anchor = 'middle'; + } else { + anchor = anchor.join('-'); + } + } + + const offsetedPos = pos.add(offset[anchor]); // because rounding the coordinates at every `move` event causes stuttered zooming // we only round them when _update is called with `moveend` or when its called with // no arguments (when the Marker is initialized or Marker#setLngLat is invoked). if (!e || e.type === "moveend") { - this._pos = this._pos.round(); + this._pos = offsetedPos.round(); } - DOM.setTransform(this._element, `translate(-50%, -50%) translate(${this._pos.x}px, ${this._pos.y}px)`); + const anchorTranslate = { + 'middle': 'translate(-50%,-50%)', + 'top': 'translate(-50%,0)', + 'top-left': 'translate(0,0)', + 'top-right': 'translate(-100%,0)', + 'bottom': 'translate(-50%,-100%)', + 'bottom-left': 'translate(0,-100%)', + 'bottom-right': 'translate(-100%,-100%)', + 'left': 'translate(0,-50%)', + 'right': 'translate(-100%,-50%)' + }; + + const classList = this._element.classList; + for (const key in anchorTranslate) { + classList.remove(`mapboxgl-marker-anchor-${key}`); + } + classList.add(`mapboxgl-marker-anchor-${anchor}`); + + DOM.setTransform(this._element, `${anchorTranslate[anchor]} translate(${offsetedPos.x}px,${offsetedPos.y}px)`); + } + + /** + * Get the marker's anchor. + * @returns {string} + */ + getAnchor() { + return this.options.anchor; + } + + /** + * Sets the anchor of the marker + * @param {PointLike} [anchor] The anchor in pixels as a {@link PointLike} object to apply relative to the element's center. Negatives indicate left and up. + * @returns {Marker} `this` + */ + setAnchor(anchor: ?PointLike) { + this._anchor = Point.convert(anchor); + this._update(); + return this; } /** * Get the marker's offset. - * @returns {Point} + * @returns {number|PointLike|Object} */ getOffset() { - return this._offset; + return this.options.offset; } /** * Sets the offset of the marker - * @param {PointLike} offset The offset in pixels as a {@link PointLike} object to apply relative to the element's center. Negatives indicate left and up. + * @param {number|PointLike|Object} [offset] The offset in pixels as a {@link PointLike} object to apply relative to the element's center. Negatives indicate left and up. * @returns {Marker} `this` */ - setOffset(offset: PointLike) { + setOffset(offset: number | PointLike | Object) { this._offset = Point.convert(offset); this._update(); return this; } } +function normalizeOffset(offset: ?Offset) { + + if (!offset) { + return normalizeOffset(new Point(0, 0)); + } else if (typeof offset === 'number') { + // input specifies a radius from which to calculate offsets at all positions + const cornerOffset = Math.round(Math.sqrt(0.5 * Math.pow(offset, 2))); + return { + 'middle': new Point(0, 0), + 'top': new Point(0, offset), + 'top-left': new Point(cornerOffset, cornerOffset), + 'top-right': new Point(-cornerOffset, cornerOffset), + 'bottom': new Point(0, -offset), + 'bottom-left': new Point(cornerOffset, -cornerOffset), + 'bottom-right': new Point(-cornerOffset, -cornerOffset), + 'left': new Point(offset, 0), + 'right': new Point(-offset, 0) + }; + + } else if (offset instanceof Point || Array.isArray(offset)) { + // input specifies a single offset to be applied to all positions + const convertedOffset = Point.convert(offset); + return { + 'middle': convertedOffset, + 'top': convertedOffset, + 'top-left': convertedOffset, + 'top-right': convertedOffset, + 'bottom': convertedOffset, + 'bottom-left': convertedOffset, + 'bottom-right': convertedOffset, + 'left': convertedOffset, + 'right': convertedOffset + }; + + } else { + // input specifies an offset per position + return { + 'middle': Point.convert(offset['middle'] || [0, 0]), + 'top': Point.convert(offset['top'] || [0, 0]), + 'top-left': Point.convert(offset['top-left'] || [0, 0]), + 'top-right': Point.convert(offset['top-right'] || [0, 0]), + 'bottom': Point.convert(offset['bottom'] || [0, 0]), + 'bottom-left': Point.convert(offset['bottom-left'] || [0, 0]), + 'bottom-right': Point.convert(offset['bottom-right'] || [0, 0]), + 'left': Point.convert(offset['left'] || [0, 0]), + 'right': Point.convert(offset['right'] || [0, 0]) + }; + } +} + module.exports = Marker; diff --git a/test/unit/ui/marker.test.js b/test/unit/ui/marker.test.js index d8b553c2d78..96fd49e629d 100644 --- a/test/unit/ui/marker.test.js +++ b/test/unit/ui/marker.test.js @@ -16,6 +16,7 @@ function createMap() { } test('Marker', (t) => { + t.test('constructor', (t) => { const el = window.document.createElement('div'); const marker = new Marker(el); @@ -23,24 +24,55 @@ test('Marker', (t) => { t.end(); }); - t.test('default marker', (t) => { + t.test('default marker without element', (t) => { const marker = new Marker(); - t.ok(marker.getElement(), 'default marker is created'); - t.ok(marker.getOffset().equals(new Point(0, -14)), 'default marker with no offset uses default marker offset'); + t.ok(marker.getElement(), 'marker returns default element'); + t.ok(marker.getAnchor(), 'marker returns default anchor option'); + t.ok(marker.getOffset(), 'marker returns default offset option'); + t.end(); + }); + + t.test('marker with element', (t) => { + const marker = new Marker(window.document.createElement('div')); + t.ok(marker.getElement(), 'marker with custom element is created'); + t.end(); + }); + + t.test('default marker with anchor', (t) => { + const marker = new Marker(); + t.ok(marker.getElement(), 'marker returns default element'); + t.ok(marker.getAnchor(), 'marker returns default anchor option'); + t.end(); + }); + + t.test('default marker with offset', (t) => { + const marker = new Marker(); + t.ok(marker.getElement(), 'marker returns default element'); + t.ok(marker.getOffset(), 'marker returns default offset option'); + t.end(); + }); + + t.test('default marker with anchor and offset', (t) => { + const marker = new Marker(); + t.ok(marker.getElement(), 'marker returns default element'); + t.ok(marker.getAnchor(), 'marker returns default anchor option'); + t.ok(marker.getOffset(), 'marker returns default offset option'); t.end(); }); t.test('default marker with some options', (t) => { - const marker = new Marker(null, { foo: 'bar' }); + const marker = new Marker(null, { anchor: 'bottom', foo: 'bar' }); t.ok(marker.getElement(), 'default marker is created'); - t.ok(marker.getOffset().equals(new Point(0, -14)), 'default marker with no offset uses default marker offset'); + t.ok(marker.getAnchor(), 'translate(-50%,-100%)', 'marker set with anchor options'); + t.ok(marker.getOffset(), 'translate(0px, 0px)', 'marker set with no offset uses default offset'); t.end(); }); - t.test('default marker with custom offest', (t) => { - const marker = new Marker(null, { offset: [1, 2] }); + t.test('marker with custom anchor and offest', (t) => { + const marker = new Marker(window.document.createElement('div'), { anchor: 'top-right', offset: [1, 2] }); t.ok(marker.getElement(), 'default marker is created'); - t.ok(marker.getOffset().equals(new Point(1, 2)), 'default marker with supplied offset'); + t.ok(marker.getAnchor(), 'translate(-100%, 0)', 'marker sets with supplied anchor'); + t.ok(marker.getOffset(), 'translate(1px, 2px)', 'marker sets with supplied offset'); t.end(); }); @@ -54,17 +86,166 @@ test('Marker', (t) => { t.test('marker\'s lngLat can be changed', (t) => { const map = createMap(); - const marker = new Marker(window.document.createElement('div')).setLngLat([-77.01866, 38.888]).addTo(map); + const marker = new Marker(window.document.createElement('div'), + { anchor: 'top-left' }) + .setLngLat([-77.01866, 38.888]) + .addTo(map); + t.ok(marker.setLngLat([-76, 39]) instanceof Marker, 'marker.setLngLat() returns Marker instance'); const markerLngLat = marker.getLngLat(); t.ok(markerLngLat.lng === -76 && markerLngLat.lat === 39, 'marker\'s position can be updated'); t.end(); }); + t.test('Marker anchors as specified by the default anchor option', (t) => { + const map = createMap(); + const marker = new Marker(window.document.createElement('div')) + .setLngLat([-77.01866, 38.888]); + + t.ok(marker.addTo(map) instanceof Marker, 'marker.addTo(map) returns Marker instance'); + t.ok(marker._map, 'marker instance is bound to map instance'); + t.end(); + }); + + [ + ['middle', 'translate(-50%, -50%)'], + ['top-left', 'translate(0, 0)'], + ['top', 'translate(-50%, 0)'], + ['top-right', 'translate(-100%, 0) '], + ['right', 'translate(-100%, -50%) '], + ['bottom-right', 'translate(-100%, -100%)'], + ['bottom', 'translate(-50%, -100%)'], + ['bottom-left', 'translate(0, -100%)'], + ['left', 'translate(0, -50%)'], + ['bottom', 'translate(-50%, -100%)'] + ].forEach((args) => { + const anchor = args[0]; + //const transform = args[1]; + + t.test(`Marker automatically anchors to ${anchor}`, (t) => { + const map = createMap(); + const marker = new Marker(window.document.createElement('div')) + .setLngLat([0, 0]) + .addTo(map); + + Object.defineProperty(marker._element, 'offsetWidth', {value: 100}); + Object.defineProperty(marker._element, 'offsetHeight', {value: 100}); + + t.stub(map, 'project'); + + const anchorTranslate = { + 'middle': 'translate(-50%,-50%)', + 'top': 'translate(-50%,0)', + 'top-left': 'translate(0,0)', + 'top-right': 'translate(-100%,0)', + 'bottom': 'translate(-50%,-100%)', + 'bottom-left': 'translate(0,-100%)', + 'bottom-right': 'translate(-100%,-100%)', + 'left': 'translate(0,-50%)', + 'right': 'translate(-100%,-50%)' + }; + + t.ok(marker.getAnchor(this.anchor), 'marker sets with anchor '); + t.ok(marker.getOffset(), 'default marker with supplied offset'); + + for (const key in anchorTranslate) { + marker._element.classList.remove(`mapboxgl-marker-anchor-${key}`); + } + + marker._element.classList.add(`mapboxgl-marker-anchor-${this.anchor}`); + + t.ok(marker._element.classList.contains(`mapboxgl-marker-anchor-${this.anchor}`)); + t.end(); + }); + + t.test(`Marker translation reflects offset and ${anchor} anchor`, (t) => { + const map = createMap(); + t.stub(map, 'project'); + const marker = new Marker(window.document.createElement('div'), + {anchor: anchor, offset: 10}) + .setLngLat([-77.01866, 38.888]) + .addTo(map); + + const anchorTranslate = { + 'middle': 'translate(-50%,-50%)', + 'top': 'translate(-50%,0)', + 'top-left': 'translate(0,0)', + 'top-right': 'translate(-100%,0)', + 'bottom': 'translate(-50%,-100%)', + 'bottom-left': 'translate(0,-100%)', + 'bottom-right': 'translate(-100%,-100%)', + 'left': 'translate(0,-50%)', + 'right': 'translate(-100%,-50%)' + }; + + t.ok(marker.getAnchor(this.anchor), 'marker sets with anchor '); + t.ok(marker.getOffset(this.offset), 'default marker with supplied offset'); + + for (const key in anchorTranslate) { + marker._element.classList.remove(`${key}`); + } + + t.equal(marker._element.style.transform, this.transform); + t.end(); + }); + }); + + t.test('Marker is offset via an object offset option', (t) => { + const map = createMap(); + t.stub(map, 'project').returns(new Point(0, 0)); + //const transform = 'translate(-50%,-50%) translate(5px,10px)'; + const marker = new Marker(window.document.createElement('div'), + {anchor: 'middle', offset: {'top-left': [5, 10]}}) + .setLngLat([0, 0]) + .addTo(map); + + t.ok(marker._element, 'translate(-50%,-50%) translate(5px,10px)'); + t.end(); + }); + + t.test('marker centered by default and returns defaults for anchor and offset', (t) => { + const map = createMap(); + const marker = new Marker(window.document.createElement('div')) + .setLngLat([0, 0]) + .addTo(map); + + t.ok(marker.getAnchor(), 'translate(-50%,-50%)'); + t.ok(marker.getOffset(), 'translate(256px, 256px)'); + t.ok(marker._element, 'translate(-50%,-50%) translate(256px, 256px)', 'Marker centered'); + t.end(); + }); + + t.test('marker\'s anchor can be changed', (t) => { + const map = createMap(); + const marker = new Marker(window.document.createElement('div')) + .setLngLat([-77.01866, 38.888]) + .addTo(map); + + t.ok(marker._element, 'translate(-50%,-50%) translate(256px, 256px)', 'Marker centered'); + t.ok(marker.setAnchor('top-left') instanceof Marker, 'marker.setAnchor() returns Marker instance'); + t.ok(marker._element, 'translate(0, 0) translate(256px, 256px)', 'marker\'s offset can be updated'); + t.end(); + }); + + t.test('marker\'s offset can be changed', (t) => { + const map = createMap(); + const marker = new Marker(window.document.createElement('div')) + .setLngLat([-77.01866, 38.888]) + .addTo(map); + + t.ok(marker._element, 'translate(-50%,-50%) translate(256px, 256px)', 'Marker centered'); + t.ok(marker.setOffset([50, -75]) instanceof Marker, 'marker.setOffset() returns Marker instance'); + t.ok(marker._element, 'translate(-50%,-50%) translate(50px, -75px)', 'marker\'s offset can be updated'); + t.end(); + }); + t.test('popups can be bound to marker instance', (t) => { const map = createMap(); const popup = new Popup(); - const marker = new Marker(window.document.createElement('div')).setLngLat([-77.01866, 38.888]).addTo(map); + const marker = new Marker(window.document.createElement('div')) + .setLngLat([-77.01866, 38.888]) + .addTo(map); + marker.setPopup(popup); t.ok(marker.getPopup() instanceof Popup, 'popup created with Popup instance'); t.end(); @@ -72,7 +253,10 @@ test('Marker', (t) => { t.test('popups can be unbound from a marker instance', (t) => { const map = createMap(); - const marker = new Marker(window.document.createElement('div')).setLngLat([-77.01866, 38.888]).addTo(map); + const marker = new Marker(window.document.createElement('div')) + .setLngLat([-77.01866, 38.888]) + .addTo(map); + marker.setPopup(new Popup()); t.ok(marker.getPopup() instanceof Popup); t.ok(marker.setPopup() instanceof Marker, 'passing no argument to Marker.setPopup() is valid'); @@ -83,42 +267,25 @@ test('Marker', (t) => { t.test('popups can be set before LngLat', (t) => { const map = createMap(); const popup = new Popup(); - new Marker(window.document.createElement('div')) + new Marker() .setPopup(popup) .setLngLat([-77.01866, 38.888]) .addTo(map); - t.deepEqual(popup.getLngLat(), new LngLat(-77.01866, 38.888)); - t.end(); - }); - t.test('marker centered by default', (t) => { - const map = createMap(); - const element = window.document.createElement('div'); - const marker = new Marker(element).setLngLat([0, 0]).addTo(map); - const translate = Math.round(map.getContainer().offsetWidth / 2); - t.equal(marker.getElement().style.transform, `translate(-50%, -50%) translate(${translate}px, ${translate}px)`, 'Marker centered'); + t.deepEqual(popup.getLngLat(), new LngLat(-77.01866, 38.888)); t.end(); }); t.test('togglePopup returns Marker instance', (t) => { const map = createMap(); - const element = window.document.createElement('div'); - const marker = new Marker(element).setLngLat([0, 0]).addTo(map); + const marker = new Marker(window.document.createElement('div')) + .setLngLat([0, 0]) + .addTo(map); + marker.setPopup(new Popup()); t.ok(marker.togglePopup() instanceof Marker); t.end(); }); - t.test('marker\'s offset can be changed', (t) => { - const map = createMap(); - const marker = new Marker(window.document.createElement('div')).setLngLat([-77.01866, 38.888]).addTo(map); - const offset = marker.getOffset(); - t.ok(offset.x === 0 && offset.y === 0, 'default offset'); - t.ok(marker.setOffset([50, -75]) instanceof Marker, 'marker.setOffset() returns Marker instance'); - const newOffset = marker.getOffset(); - t.ok(newOffset.x === 50 && newOffset.y === -75, 'marker\'s offset can be updated'); - t.end(); - }); - t.end(); });