diff --git a/css/ol.css b/css/ol.css index 67b3b4a08b..824c28cc8e 100644 --- a/css/ol.css +++ b/css/ol.css @@ -296,3 +296,40 @@ button.ol-full-screen-true:after { .ol-has-tooltip [role=tooltip] { font-family: 'Lucida Grande',Verdana,Geneva,Lucida,Arial,Helvetica,sans-serif; } + +.ol-overviewmap { + position: absolute; + left: 0.5em; + bottom: 0.5em; +} +.ol-overviewmap.ol-uncollapsible { + bottom: 0; + left: 0; + border-radius: 0 4px 0 0; +} +.ol-overviewmap .ol-overviewmap-map, +.ol-overviewmap button { + display: inline-block; +} +.ol-overviewmap .ol-overviewmap-map { + border: 1px solid #7b98bc; + height: 150px; + margin: 2px; + width: 150px; +} +.ol-overviewmap:not(.ol-collapsed) button{ + bottom: 1px; + left: 2px; + position: absolute; +} +.ol-overviewmap:not(.ol-collapsed) button:hover [role=tooltip], +.ol-overviewmap.ol-collapsed .ol-overviewmap-map, +.ol-overviewmap.ol-uncollapsible button { + display: none; +} +.ol-overviewmap:not(.ol-collapsed) { + background: rgba(255,255,255,0.8); +} +.ol-overviewmap-box { + border: 2px dotted rgba(0,60,136,0.7); +} diff --git a/examples/overviewmap-custom.html b/examples/overviewmap-custom.html new file mode 100644 index 0000000000..d00be1357d --- /dev/null +++ b/examples/overviewmap-custom.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + ol3 OverviewMap control with advanced customization example + + + + + +
+

OverviewMap control, advanced

+
+ +
+

Example of OverviewMap control with advanced customization.

+
+

See the overviewmap-custom.js source to see how this is done.

+

This example demonstrates how you can customize the overviewmap control using its supported options as well as defining custom CSS. You can also rotate the map using the shift key to see how the overview map reacts.

+
+
overview, overviewmap
+
+
+ + + + + + + diff --git a/examples/overviewmap-custom.js b/examples/overviewmap-custom.js new file mode 100644 index 0000000000..c9aa0c181d --- /dev/null +++ b/examples/overviewmap-custom.js @@ -0,0 +1,43 @@ +goog.require('ol.Map'); +goog.require('ol.View'); +goog.require('ol.control'); +goog.require('ol.control.OverviewMap'); +goog.require('ol.interaction'); +goog.require('ol.interaction.DragRotateAndZoom'); +goog.require('ol.layer.Tile'); +goog.require('ol.source.OSM'); + + +var overviewMapControl = new ol.control.OverviewMap({ + // see in overviewmap-custom.html to see the custom CSS used + className: 'ol-overviewmap ol-custom-overviewmap', + layers: [ + new ol.layer.Tile({ + source: new ol.source.OSM({ + 'url': '//{a-c}.tile.opencyclemap.org/cycle/{z}/{x}/{y}.png' + }) + }) + ], + collapseLabel: '\u00BB', + label: '\u00AB', + collapsed: false +}); + +var map = new ol.Map({ + controls: ol.control.defaults().extend([ + overviewMapControl + ]), + interactions: ol.interaction.defaults().extend([ + new ol.interaction.DragRotateAndZoom() + ]), + layers: [ + new ol.layer.Tile({ + source: new ol.source.OSM() + }) + ], + target: 'map', + view: new ol.View({ + center: [500000, 6000000], + zoom: 7 + }) +}); diff --git a/examples/overviewmap.html b/examples/overviewmap.html new file mode 100644 index 0000000000..ca0a0da1a4 --- /dev/null +++ b/examples/overviewmap.html @@ -0,0 +1,41 @@ + + + + + + + + + + + ol3 OverviewMap control example + + + + + +
+

OverviewMap control

+
+ +
+

Example of OverviewMap control.

+
+

See the overviewmap.js source to see how this is done.

+
+
overview, overviewmap
+
+
+ + + + + + + diff --git a/examples/overviewmap.js b/examples/overviewmap.js new file mode 100644 index 0000000000..96f55ad75b --- /dev/null +++ b/examples/overviewmap.js @@ -0,0 +1,22 @@ +goog.require('ol.Map'); +goog.require('ol.View'); +goog.require('ol.control'); +goog.require('ol.control.OverviewMap'); +goog.require('ol.layer.Tile'); +goog.require('ol.source.OSM'); + +var map = new ol.Map({ + controls: ol.control.defaults().extend([ + new ol.control.OverviewMap() + ]), + layers: [ + new ol.layer.Tile({ + source: new ol.source.OSM() + }) + ], + target: 'map', + view: new ol.View({ + center: [500000, 6000000], + zoom: 7 + }) +}); diff --git a/externs/olx.js b/externs/olx.js index 62288792e6..4180b053c7 100644 --- a/externs/olx.js +++ b/externs/olx.js @@ -1037,6 +1037,78 @@ olx.control.MousePositionOptions.prototype.target; olx.control.MousePositionOptions.prototype.undefinedHTML; +/** + * @typedef {{collapsed: (boolean|undefined), + * collapseLabel: (string|undefined), + * collapsible: (boolean|undefined), + * label: (string|undefined), + * layers: (Array.|ol.Collection|undefined), + * target: (Element|undefined), + * tipLabel: (string|undefined)}} + * @api + */ +olx.control.OverviewMapOptions; + + +/** + * Whether the control should start collapsed or not (expanded). + * Default to `true`. + * @type {boolean|undefined} + * @api + */ +olx.control.OverviewMapOptions.prototype.collapsed; + + +/** + * Text label to use for the expanded overviewmap button. Default is `«` + * @type {string|undefined} + * @api + */ +olx.control.OverviewMapOptions.prototype.collapseLabel; + + +/** + * Whether the control can be collapsed or not. Default to `true`. + * @type {boolean|undefined} + * @api + */ +olx.control.OverviewMapOptions.prototype.collapsible; + + +/** + * Text label to use for the collapsed overviewmap button. Default is `»` + * @type {string|undefined} + * @api + */ +olx.control.OverviewMapOptions.prototype.label; + + +/** + * Layers for the overview map. If not set, then all main map layers are used + * instead. + * @type {!Array.|!ol.Collection|undefined} + * @api + */ +olx.control.OverviewMapOptions.prototype.layers; + + +/** + * Specify a target if you want the control to be rendered outside of the map's + * viewport. + * @type {Element|undefined} + * @api + */ +olx.control.OverviewMapOptions.prototype.target; + + +/** + * Text label to use for the button tip. Default is `Overview map` + * @type {string|undefined} + * @api + */ +olx.control.OverviewMapOptions.prototype.tipLabel; + + /** * @typedef {{className: (string|undefined), * minWidth: (number|undefined), diff --git a/src/ol/control/overviewmapcontrol.js b/src/ol/control/overviewmapcontrol.js new file mode 100644 index 0000000000..3e2a6d9ae0 --- /dev/null +++ b/src/ol/control/overviewmapcontrol.js @@ -0,0 +1,510 @@ +goog.provide('ol.control.OverviewMap'); + +goog.require('goog.asserts'); +goog.require('goog.dom'); +goog.require('goog.dom.TagName'); +goog.require('goog.dom.classlist'); +goog.require('goog.events'); +goog.require('goog.events.EventType'); +goog.require('goog.math.Size'); +goog.require('goog.style'); +goog.require('ol.Collection'); +goog.require('ol.Map'); +goog.require('ol.MapEventType'); +goog.require('ol.Object'); +goog.require('ol.Overlay'); +goog.require('ol.OverlayPositioning'); +goog.require('ol.View'); +goog.require('ol.control.Control'); +goog.require('ol.coordinate'); +goog.require('ol.css'); +goog.require('ol.extent'); +goog.require('ol.pointer.PointerEventHandler'); + + + +/** + * Create a new control with a map acting as an overview map for an other + * defined map. + * @constructor + * @extends {ol.control.Control} + * @param {olx.control.OverviewMapOptions=} opt_options OverviewMap options. + * @api + */ +ol.control.OverviewMap = function(opt_options) { + + var options = goog.isDef(opt_options) ? opt_options : {}; + + /** + * @type {boolean} + * @private + */ + this.collapsed_ = goog.isDef(options.collapsed) ? options.collapsed : true; + + /** + * @private + * @type {boolean} + */ + this.collapsible_ = goog.isDef(options.collapsible) ? + options.collapsible : true; + + if (!this.collapsible_) { + this.collapsed_ = false; + } + + var className = goog.isDef(options.className) ? + options.className : 'ol-overviewmap'; + + var tipLabel = goog.isDef(options.tipLabel) ? + options.tipLabel : 'Overview map'; + var tip = goog.dom.createDom(goog.dom.TagName.SPAN, { + 'role' : 'tooltip' + }, tipLabel); + + /** + * @private + * @type {string} + */ + this.collapseLabel_ = goog.isDef(options.collapseLabel) ? + options.collapseLabel : '\u00AB'; + + /** + * @private + * @type {string} + */ + this.label_ = goog.isDef(options.label) ? options.label : '\u00BB'; + var label = goog.dom.createDom(goog.dom.TagName.SPAN, {}, + (this.collapsible_ && !this.collapsed_) ? + this.collapseLabel_ : this.label_); + + /** + * @private + * @type {Element} + */ + this.labelSpan_ = label; + var button = goog.dom.createDom(goog.dom.TagName.BUTTON, { + 'class': 'ol-has-tooltip', + 'type': 'button' + }, this.labelSpan_); + goog.dom.appendChild(button, tip); + + var buttonHandler = new ol.pointer.PointerEventHandler(button); + this.registerDisposable(buttonHandler); + goog.events.listen(buttonHandler, ol.pointer.EventType.POINTERUP, + this.handlePointerUp_, false, this); + goog.events.listen(button, goog.events.EventType.CLICK, + this.handleClick_, false, this); + + goog.events.listen(button, [ + goog.events.EventType.MOUSEOUT, + goog.events.EventType.FOCUSOUT + ], function() { + this.blur(); + }, false); + + var ovmapDiv = goog.dom.createDom(goog.dom.TagName.DIV, 'ol-overviewmap-map'); + + /** + * @type {ol.Map} + * @private + */ + this.ovmap_ = new ol.Map({ + controls: new ol.Collection(), + interactions: new ol.Collection(), + target: ovmapDiv + }); + var ovmap = this.ovmap_; + + if (goog.isDef(options.layers)) { + options.layers.forEach( + /** + * @param {ol.layer.Layer} layer Layer. + */ + function(layer) { + ovmap.addLayer(layer); + }, this); + } + + var box = goog.dom.createDom(goog.dom.TagName.DIV, 'ol-overviewmap-box'); + + /** + * @type {ol.Overlay} + * @private + */ + this.boxOverlay_ = new ol.Overlay({ + position: [0, 0], + positioning: ol.OverlayPositioning.BOTTOM_LEFT, + element: box + }); + this.ovmap_.addOverlay(this.boxOverlay_); + + var element = goog.dom.createDom(goog.dom.TagName.DIV, { + 'class': className + ' ' + ol.css.CLASS_UNSELECTABLE + ' ' + + ol.css.CLASS_CONTROL + + (this.collapsed_ && this.collapsible_ ? ' ol-collapsed' : '') + + (this.collapsible_ ? '' : ' ol-uncollapsible') + }, ovmapDiv, button); + + goog.base(this, { + element: element, + target: options.target + }); +}; +goog.inherits(ol.control.OverviewMap, ol.control.Control); + + +/** + * @inheritDoc + * @api + */ +ol.control.OverviewMap.prototype.setMap = function(map) { + var currentMap = this.getMap(); + + if (goog.isNull(map) && !goog.isNull(currentMap)) { + goog.events.unlisten( + currentMap, ol.Object.getChangeEventType(ol.MapProperty.VIEW), + this.handleViewChanged_, false, this); + } + + goog.base(this, 'setMap', map); + + if (!goog.isNull(map)) { + + // if no layers were set for the overviewmap map, then bind with + // those in the main map + if (this.ovmap_.getLayers().getLength() === 0) { + this.ovmap_.bindTo(ol.MapProperty.LAYERGROUP, map); + } + + // bind current map view, or any new one + this.bindView_(); + + goog.events.listen( + map, ol.Object.getChangeEventType(ol.MapProperty.VIEW), + this.handleViewChanged_, false, this); + + this.ovmap_.updateSize(); + this.resetExtent_(); + } +}; + + +/** + * Bind some actions to the main map view. + * @private + */ +ol.control.OverviewMap.prototype.bindView_ = function() { + var map = this.getMap(); + var view = map.getView(); + + // if the map does not have a view, we can't act upon it + if (goog.isNull(view)) { + return; + } + + // FIXME - the overviewmap view rotation currently follows the one used + // by the main map view. We could support box rotation instead. The choice + // between the 2 modes would be made in a single option + this.ovmap_.getView().bindTo(ol.ViewProperty.ROTATION, view); +}; + + +/** + * Function called on each map render. Executes in a requestAnimationFrame + * callback. Manage the extent of the overview map accordingly, + * then update the overview map box. + * @param {goog.events.Event} event Event. + */ +ol.control.OverviewMap.prototype.handleMapPostrender = function(event) { + this.validateExtent_(); + this.updateBox_(); +}; + + +/** + * Called on main map view changed. + * @param {goog.events.Event} event Event. + * @private + */ +ol.control.OverviewMap.prototype.handleViewChanged_ = function(event) { + this.bindView_(); +}; + + +/** + * Reset the overview map extent if the box size (width or + * height) is less than the size of the overview map size times minRatio + * or is greater than the size of the overview size times maxRatio. + * + * If the map extent was not reset, the box size can fits in the defined + * ratio sizes. This method then checks if is contained inside the overview + * map current extent. If not, recenter the overview map to the current + * main map center location. + * @private + */ +ol.control.OverviewMap.prototype.validateExtent_ = function() { + var map = this.getMap(); + var ovmap = this.ovmap_; + + if (!map.isRendered() || !ovmap.isRendered()) { + return; + } + + var mapSize = map.getSize(); + goog.asserts.assertArray(mapSize); + + var view = map.getView(); + goog.asserts.assert(goog.isDef(view)); + var extent = view.calculateExtent(mapSize); + + var ovmapSize = ovmap.getSize(); + goog.asserts.assertArray(ovmapSize); + + var ovview = ovmap.getView(); + goog.asserts.assert(goog.isDef(ovview)); + var ovextent = ovview.calculateExtent(ovmapSize); + + var topLeftPixel = + ovmap.getPixelFromCoordinate(ol.extent.getTopLeft(extent)); + var bottomRightPixel = + ovmap.getPixelFromCoordinate(ol.extent.getBottomRight(extent)); + var boxSize = new goog.math.Size( + Math.abs(topLeftPixel[0] - bottomRightPixel[0]), + Math.abs(topLeftPixel[1] - bottomRightPixel[1])); + + var ovmapWidth = ovmapSize[0]; + var ovmapHeight = ovmapSize[1]; + + if (boxSize.width < ovmapWidth * ol.OVERVIEWMAP_MIN_RATIO || + boxSize.height < ovmapHeight * ol.OVERVIEWMAP_MIN_RATIO || + boxSize.width > ovmapWidth * ol.OVERVIEWMAP_MAX_RATIO || + boxSize.height > ovmapHeight * ol.OVERVIEWMAP_MAX_RATIO) { + this.resetExtent_(); + } else if (!ol.extent.containsExtent(ovextent, extent)) { + this.recenter_(); + } +}; + + +/** + * Reset the overview map extent to half calculated min and max ratio times + * the extent of the main map. + * @private + */ +ol.control.OverviewMap.prototype.resetExtent_ = function() { + if (ol.OVERVIEWMAP_MAX_RATIO === 0 || ol.OVERVIEWMAP_MIN_RATIO === 0) { + return; + } + + var map = this.getMap(); + var ovmap = this.ovmap_; + + var mapSize = map.getSize(); + goog.asserts.assertArray(mapSize); + + var view = map.getView(); + goog.asserts.assert(goog.isDef(view)); + var extent = view.calculateExtent(mapSize); + + var ovmapSize = ovmap.getSize(); + goog.asserts.assertArray(ovmapSize); + + var ovview = ovmap.getView(); + goog.asserts.assert(goog.isDef(ovview)); + + // get how many times the current map overview could hold different + // box sizes using the min and max ratio, pick the step in the middle used + // to calculate the extent from the main map to set it to the overview map, + var steps = Math.log( + ol.OVERVIEWMAP_MAX_RATIO / ol.OVERVIEWMAP_MIN_RATIO) / Math.LN2; + var ratio = 1 / (Math.pow(2, steps / 2) * ol.OVERVIEWMAP_MIN_RATIO); + ol.extent.scaleFromCenter(extent, ratio); + ovview.fitExtent(extent, ovmapSize); +}; + + +/** + * Set the center of the overview map to the map center without changing its + * resolution. + * @private + */ +ol.control.OverviewMap.prototype.recenter_ = function() { + var map = this.getMap(); + var ovmap = this.ovmap_; + + var view = map.getView(); + goog.asserts.assert(goog.isDef(view)); + + var ovview = ovmap.getView(); + goog.asserts.assert(goog.isDef(ovview)); + + ovview.setCenter(view.getCenter()); +}; + + +/** + * Update the box using the main map extent + * @private + */ +ol.control.OverviewMap.prototype.updateBox_ = function() { + var map = this.getMap(); + var ovmap = this.ovmap_; + + if (!map.isRendered() || !ovmap.isRendered()) { + return; + } + + var mapSize = map.getSize(); + goog.asserts.assertArray(mapSize); + + var view = map.getView(); + goog.asserts.assert(goog.isDef(view)); + + var ovview = ovmap.getView(); + goog.asserts.assert(goog.isDef(ovview)); + + var ovmapSize = ovmap.getSize(); + goog.asserts.assertArray(ovmapSize); + + var rotation = view.getRotation(); + goog.asserts.assert(goog.isDef(rotation)); + + var overlay = this.boxOverlay_; + var box = this.boxOverlay_.getElement(); + var extent = view.calculateExtent(mapSize); + var ovresolution = ovview.getResolution(); + var bottomLeft = ol.extent.getBottomLeft(extent); + var topRight = ol.extent.getTopRight(extent); + + // set position using bottom left coordinates + var rotateBottomLeft = this.calculateCoordinateRotate_(rotation, bottomLeft); + overlay.setPosition(rotateBottomLeft); + + // set box size calculated from map extent size and overview map resolution + if (goog.isDefAndNotNull(box)) { + var boxWidth = Math.abs((bottomLeft[0] - topRight[0]) / ovresolution); + var boxHeight = Math.abs((topRight[1] - bottomLeft[1]) / ovresolution); + goog.style.setBorderBoxSize(box, new goog.math.Size( + boxWidth, boxHeight)); + } +}; + + +/** + * @param {number} rotation Target rotation. + * @param {ol.Coordinate} coordinate Coordinate. + * @return {ol.Coordinate|undefined} Coordinate for rotation and center anchor. + * @private + */ +ol.control.OverviewMap.prototype.calculateCoordinateRotate_ = function( + rotation, coordinate) { + var coordinateRotate; + + var map = this.getMap(); + var view = map.getView(); + goog.asserts.assert(goog.isDef(view)); + + var currentCenter = view.getCenter(); + + if (goog.isDef(currentCenter)) { + coordinateRotate = [ + coordinate[0] - currentCenter[0], + coordinate[1] - currentCenter[1] + ]; + ol.coordinate.rotate(coordinateRotate, rotation); + ol.coordinate.add(coordinateRotate, currentCenter); + } + return coordinateRotate; +}; + + +/** + * @param {goog.events.BrowserEvent} event The event to handle + * @private + */ +ol.control.OverviewMap.prototype.handleClick_ = function(event) { + if (event.screenX !== 0 && event.screenY !== 0) { + return; + } + this.handleToggle_(); +}; + + +/** + * @param {ol.pointer.PointerEvent} pointerEvent The event to handle + * @private + */ +ol.control.OverviewMap.prototype.handlePointerUp_ = function(pointerEvent) { + pointerEvent.browserEvent.preventDefault(); + this.handleToggle_(); +}; + + +/** + * @private + */ +ol.control.OverviewMap.prototype.handleToggle_ = function() { + goog.dom.classlist.toggle(this.element, 'ol-collapsed'); + goog.dom.setTextContent(this.labelSpan_, + (this.collapsed_) ? this.collapseLabel_ : this.label_); + this.collapsed_ = !this.collapsed_; + + // manage overview map if it had not been rendered before and control + // is expanded + var ovmap = this.ovmap_; + if (!this.collapsed_ && !ovmap.isRendered()) { + ovmap.updateSize(); + this.resetExtent_(); + goog.events.listenOnce(ovmap, ol.MapEventType.POSTRENDER, + function(event) { + this.updateBox_(); + }, + false, this); + } +}; + + +/** + * @return {boolean} True if the widget is collapsible. + * @api stable + */ +ol.control.OverviewMap.prototype.getCollapsible = function() { + return this.collapsible_; +}; + + +/** + * @param {boolean} collapsible True if the widget is collapsible. + * @api stable + */ +ol.control.OverviewMap.prototype.setCollapsible = function(collapsible) { + if (this.collapsible_ === collapsible) { + return; + } + this.collapsible_ = collapsible; + goog.dom.classlist.toggle(this.element, 'ol-uncollapsible'); + if (!collapsible && this.collapsed_) { + this.handleToggle_(); + } +}; + + +/** + * @param {boolean} collapsed True if the widget is collapsed. + * @api stable + */ +ol.control.OverviewMap.prototype.setCollapsed = function(collapsed) { + if (!this.collapsible_ || this.collapsed_ === collapsed) { + return; + } + this.handleToggle_(); +}; + + +/** + * @return {boolean} True if the widget is collapsed. + * @api stable + */ +ol.control.OverviewMap.prototype.getCollapsed = function() { + return this.collapsed_; +}; diff --git a/src/ol/ol.js b/src/ol/ol.js index 2af3b89101..6e0968f9d3 100644 --- a/src/ol/ol.js +++ b/src/ol/ol.js @@ -187,6 +187,20 @@ ol.MOUSEWHEELZOOM_MAXDELTA = 1; ol.MOUSEWHEELZOOM_TIMEOUT_DURATION = 80; +/** + * @define {number} Maximum width and/or height extent ratio that determines + * when the overview map should be zoomed out. + */ +ol.OVERVIEWMAP_MAX_RATIO = 0.75; + + +/** + * @define {number} Minimum width and/or height extent ratio that determines + * when the overview map should be zoomed in. + */ +ol.OVERVIEWMAP_MIN_RATIO = 0.1; + + /** * @define {number} Rotate animation duration. */