diff --git a/README.md b/README.md index 4228ebd74..ee50cd131 100644 --- a/README.md +++ b/README.md @@ -23,19 +23,27 @@ # EOmaps - Interactive maps in python! -EOmaps is a python package to visualize and analyze geographical datasets. +**EOmaps** is a python package to visualize and analyze geographical datasets. -It is built on top of [matplotlib](matplotlib.org/) and [cartopy](https://scitools.org.uk/cartopy/docs/latest/) and aims to provide an -intuitive and easy-to-use interface to speed up and simplify the creation and comparison of maps. +It is built on top of [matplotlib](matplotlib.org/) and [cartopy](https://scitools.org.uk/cartopy/docs/latest/) and provides an intuitive and easy-to-use interface to speed up and simplify the creation and comparison of maps. + +### What can EOmaps do for you? +- Create [β–€ multi-layered maps](https://eomaps.readthedocs.io/en/dev/api.html#basics) and interactively compare different layers with each other +- [πŸ”΄ Visualize datasets](https://eomaps.readthedocs.io/en/dev/api.html#data-visualization) with millions of datapoints and handle reprojections +- Provide a comprehensive set of tools to customize the map + - [🌡NaturalEarth features](https://eomaps.readthedocs.io/en/dev/api.html#naturalearth-features) + - [πŸ“Scalebars](https://eomaps.readthedocs.io/en/dev/api.html#scalebars) + - [β–¦ Gridlines](https://eomaps.readthedocs.io/en/dev/api.html#gridlines) + - [πŸ›° WebMap layers](https://eomaps.readthedocs.io/en/dev/api.html#webmap-layers) + - [πŸ• Annotations, Markers, Lines, Logos...](https://eomaps.readthedocs.io/en/latest/api.html#annotations-markers-lines-logos-etc) + - . . . +- Use [πŸ›Έ Callbacks](https://eomaps.readthedocs.io/en/latest/api.html#callbacks-make-the-map-interactive) and the [🧰 CompanionWidget](https://eomaps.readthedocs.io/en/dev/api.html#companion-widget) to interact with the figure +- Interactively re-arrange multiple maps in a figure with the [πŸ—οΈ LayoutEditor](https://eomaps.readthedocs.io/en/dev/api.html#layout-editor) +- [πŸ—Ί Export](https://eomaps.readthedocs.io/en/dev/api.html#export-the-map-as-jpeg-png-etc) publication ready high resolution images (png, jpeg etc.) +- . . . and much more! + +Checkout the [πŸš€ Basics](https://eomaps.readthedocs.io/en/dev/api.html#basics) in the documentation to get started! -- Visualize small datasets as well as millions of datapoints -- Handle 1D and 2D datasets with the same interface and create plots from NetCDF, GeoTIFF or CSV files -- Take care of re-projecting the data -- Compare, combine or (transparently) overlay multiple plot-layers -- Turn the maps into interactive data-analysis widgets with a few lines of code -- Provide a versatile set of tools to customize the maps (Features, WebMaps, Markers, Annotations etc.) -- Simplify the process of composing multiple maps (and other plots/images) in a single figure -- Export high resolution images (png, jpeg etc.) ## πŸ”¨ Installation @@ -54,7 +62,7 @@ Need more information? ## πŸ“– Documentation -Make sure to have a look at the 🌳 Documentation 🌳 which provides a lot of 🌐Examples on how to create awesome interactive maps (incl. 🐍 source code)! +Make sure to have a look at the πŸ“– Documentation which provides a lot of 🌐Examples on how to create awesome interactive maps (incl. 🐍 source code)! ## βœ”οΈ Citation Did EOmaps help in your research? diff --git a/docs/EOmaps_examples.rst b/docs/EOmaps_examples.rst index bc53915b1..99d24edcd 100644 --- a/docs/EOmaps_examples.rst +++ b/docs/EOmaps_examples.rst @@ -2,7 +2,7 @@ .. _EOmaps_examples: -🌐 EOmaps examples +πŸ—Ί EOmaps examples ================== ... a collection of examples that show how to create beautiful interactive maps. @@ -301,3 +301,22 @@ Connect the anchor-points via: .. image:: _static/example_lines.png :width: 75% + + + +🌐 Gridlines and Grid Labels +----------------------------- + +Draw custom grids and add grid labels. + +(requires EOmaps >= v6.5) + +|toggleStart| + +.. literalinclude:: ../tests/example_gridlines.py + +|toggleEnd| + + +.. image:: _static/example_gridlines.png + :width: 75% diff --git a/docs/_static/example_gridlines.png b/docs/_static/example_gridlines.png new file mode 100644 index 000000000..7afd14fb1 Binary files /dev/null and b/docs/_static/example_gridlines.png differ diff --git a/docs/_static/intro.png b/docs/_static/intro.png new file mode 100644 index 000000000..2a13f35fe Binary files /dev/null and b/docs/_static/intro.png differ diff --git a/docs/_static/minigifs/grid_labels_01.png b/docs/_static/minigifs/grid_labels_01.png new file mode 100644 index 000000000..390d5edc9 Binary files /dev/null and b/docs/_static/minigifs/grid_labels_01.png differ diff --git a/docs/_static/minigifs/inset_maps.png b/docs/_static/minigifs/inset_maps.png index f9bb2c5ed..ad75834f7 100644 Binary files a/docs/_static/minigifs/inset_maps.png and b/docs/_static/minigifs/inset_maps.png differ diff --git a/docs/api.rst b/docs/api.rst index 2741fad27..09581a786 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4,23 +4,32 @@ πŸš€ Basics --------- +.. image:: _static/intro.png + :width: 50% + 🌐 Initialization of Maps objects ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -| EOmaps is all about ``Maps`` objects. -| To start creating a new map (in this case a plot in ``epsg=4326``, e.g. lon/lat), simply use: +.. currentmodule:: eomaps + +| EOmaps is all about :py:class:`Maps` objects. +| The first :py:class:`Maps` object that is created will initialize a `matplotlib.Figure `_ +and a `cartopy.GeoAxes `_ +for a map. .. code-block:: python :name: test_init_map_objects from eomaps import Maps - m = Maps(crs=4326, layer="first layer", figsize=(7, 5)) # initialize a Maps-object - m.set_extent((-25, 35, 25, 70)) # set the map extent - m.add_feature.preset.coastline() # add coastlines to the map + m = Maps(crs=4326, # create a Maps-object with a map in lon/lat (epsg=4326) projection + layer="first layer", # assign the layer "first_layer" + figsize=(7, 5)) # set the figure-size to 7x5 + m.set_extent((-25, 35, 25, 70)) # set the extent of the map + m.add_feature.preset.coastline() # add coastlines to the map - ``crs`` represents the projection used for plotting -- ``layer`` represents the name of the layer associated with the Maps-object (see below) -- all additional keyword arguments are forwarded to the creation of the matplotlib-figure +- ``layer`` represents the name of the layer associated with the Maps-object (see :ref:`layers`). +- all additional keyword arguments are forwarded to the creation of the `matplotlib-figure `_ (e.g.: ``figsize``, ``frameon``, ``edgecolor`` etc). @@ -30,14 +39,12 @@ Possible ways for specifying the ``crs`` for plotting are: - 4326 defaults to `PlateCarree` projection -- All other CRS usable for plotting are accessible via ``Maps.CRS``, - e.g.: ``crs=Maps.CRS.Orthographic()``, ``crs=Maps.CRS.GOOGLE_MERCATOR`` or ``crs=Maps.CRS.Equi7_EU``. - (``Maps.CRS`` is just an accessor for ``cartopy.crs``. +- All other CRS usable for plotting are accessible via ``Maps.CRS``, e.g.: ``crs=Maps.CRS.Orthographic()``, ``crs=Maps.CRS.Equi7_EU``... - - For a full list of available projections see: `Cartopy projections `_) + - ``Maps.CRS`` is just an accessor for ``cartopy.crs`` + - For a full list of available projections see: `Cartopy projections `_ -.. currentmodule:: eomaps .. autosummary:: :toctree: generated @@ -49,24 +56,25 @@ Possible ways for specifying the ``crs`` for plotting are: Maps.set_extent +.. _layers: + + β–€ Layers ~~~~~~~~~ -| A map can have multiple plot-layers. -| Each ``Maps`` object represents a collection of features **on the assigned layer**. +A :py:class:`Maps` object represents a collection of features, callbacks,.. **on the assigned layer**. -Once you have created your first ``Maps`` object, you can: +Once you have created a map, you can create **additional** :py:class:`Maps` **objects for the same map** by using :py:meth:`Maps.new_layer`. -🌱 Create **additional** ``Maps`` **objects on the same layer** by using ``m2 = m.new_layer()`` +🌱 If no explicit layer-name is provided, the returned :py:class:`Maps` object will use the same layer as the parent :py:class:`Maps` object. -- If no explicit layer-name is provided, ``m2`` will use the same layer as ``m`` -- This is especially useful if you want to plot **multiple datasets on the same layer** + - This is especially useful if you want to plot **multiple datasets on the same map and layer**. -🌱 Create **a NEW layer** named ``"my_layer"`` by using ``m2 = m.new_layer("my_layer")`` +🌱 To create **a NEW layer** named ``"my_layer"``, use ``m2 = m.new_layer("my_layer")`` -- All artists / features / callbacks etc. assigned to ``m2`` will only be visible / executed if the layer ``"my_layer"`` is visible - -πŸŽ„ **Transparently overlay** existing layers with ``m.show_layer(....)"`` (see :ref:`combine_layers`) + - Features, Colorbars etc. added to a :py:class:`Maps` object are only visible if the associated layer is visible. + - Callbacks are only executed if the associated layer is visible. + - See :ref:`combine_layers` on how to select the currently visible layer(s). .. code-block:: python @@ -77,24 +85,18 @@ Once you have created your first ``Maps`` object, you can: m_ocean = m.new_layer(layer="ocean") # create a new layer named "ocean" m_ocean.add_feature.preset.ocean() # features on this layer will only be visible if the "ocean" layer is visible! - m2 = m_ocean.new_layer() # "m2" is just another Maps-object on the same layer as "m_ocean"! - m2.set_data(data=[.14,.25,.38], # assign a dataset to this Maps-object - x=[1,2,3], y=[3,5,7], - crs=4326) - m2.set_shape.ellipses() # set the shape that is used to represent the datapoints - m2.plot_map() # plot the data + m_ocean2 = m_ocean.new_layer() # "m_ocean2" is just another Maps-object on the same layer as "m_ocean"! + m_ocean2.set_data( # assign a dataset to this Maps-object + data=[.14,.25,.38], + x=[1,2,3], y=[3,5,7], + crs=4326) + m_ocean2.set_shape.ellipses() # set the shape that is used to represent the datapoints + m_ocean2.plot_map() # plot the data m.show_layer("ocean") # show the "ocean" layer m.util.layer_selector() # get a utility widget to quickly switch between existing layers -.. admonition:: Map-features, colorbars and callbacks are layer-sensitive! - - - Features, colorbars etc. added to a ``Maps`` object are only visible if the associated layer is visible - - Callbacks are only executed if the associated layer is visible - - To switch between layers, use ``m.show_layer("the layer name")``, call ``m.show()`` or have a look at the :ref:`utility` and the :ref:`companion_widget`. - .. admonition:: The "all" layer | There is one layer-name that has a special meaning... the ``"all"`` layer. @@ -148,11 +150,33 @@ Once you have created your first ``Maps`` object, you can: πŸ—— Combine & compare multiple layers ************************************ -To switch between layers or view a layer that represents a **combination of multiple existing layers**, use ``m.show_layer(...)``. +.. admonition:: Using the :ref:`companion_widget` + + Usually it is most convenient to combine and compare layers via the :ref:`companion_widget`. + + - Use the **dropdown-list** at the top-right to select a single layer or overlay multiple layers. + + - Click on a single layer to make it the visible layer. + - Hold down ``control`` or ``shift`` to overlay multiple layers. + + .. image:: _static/minigifs/select_layers_dropdown.gif + + | + + - Select one or more layers to dynamically adjust the stacking-order via the **layer-tabs** of the **Compare** and **Edit** views. + + - Hold down ``control`` while clicking on a tab to make it the visible layer. + - Hold down ``shift`` while clicking on a tab to overlay multiple layers. + - Re-arrange the tabs to change the stacking-order of the layers. + + .. image:: _static/minigifs/rearrange_layers.gif + -- If you provide a single layer-name, the map will show the corresponding layer, e.g. ``m.show_layer("my_layer")`` +To programmatically switch between layers or view a layer that represents a **combination of multiple existing layers**, use :py:meth:`Maps.show_layer`. -To **(transparently) overlay multiple existing layers**, use one of the following options: +🌱 If you provide a single layer-name, the map will show the corresponding layer, e.g. ``m.show_layer("my_layer")`` + +🌱 To **(transparently) overlay multiple existing layers**, use one of the following options: - Provide **multiple layer names or tuples** of the form ``(< layer-name >, < transparency [0-1] >)`` @@ -182,8 +206,17 @@ To **(transparently) overlay multiple existing layers**, use one of the followin m.show_layer("first", ("second", .75)) # overlay the second layer with 25% transparency +.. currentmodule:: eomaps.callbacks.click_callbacks + +🌱 If you want to overlay a part of the screen with a different layer, have a look at :py:meth:`peek_layer` callbacks**! + +.. autosummary:: + :toctree: generated + :nosignatures: + :template: only_names_in_toc.rst + + peek_layer -If you want to overlay a part of the screen with a different layer, have a look at **peek-layer callbacks**! .. code-block:: python @@ -196,15 +229,6 @@ If you want to overlay a part of the screen with a different layer, have a look m.cb.click.attach.peek_layer(layer=["ocean", ("land", 0.5)], shape="round", how=0.4) -.. currentmodule:: eomaps.callbacks.click_callbacks - -.. autosummary:: - :toctree: generated - :nosignatures: - :template: only_names_in_toc.rst - - peek_layer - .. admonition:: The "stacking order" of features and layers @@ -218,29 +242,6 @@ If you want to overlay a part of the screen with a different layer, have a look - e.g. ``m.show_layer("A", "B")`` will show the layer ``"B"`` on top of the layer ``"A"`` - you can stack as many layers as you like! ``m.show_layer("A", "B", ("C", 0.5), "D", ...)`` -.. admonition:: Using the :ref:`companion_widget` - - Usually it is most convenient to combine and compare layers via the :ref:`companion_widget` via one - of the following options: - - - Use the **dropdown-list** at the top-right to select a single layer or overlay multiple layers. - - - Click on a single layer to make it the visible layer. - - Hold down ``control`` or ``shift`` to overlay multiple layers. - - .. image:: _static/minigifs/select_layers_dropdown.gif - - | - - - Select one or more layers and dynamically adjust the stacking-order via the **layer-tabs** of the **Compare** and **Edit** views. - - - Hold down ``control`` while clicking on a tab to make it the visible layer. - - Hold down ``shift`` while clicking on a tab to overlay multiple layers. - - Re-arrange the tabs to change the stacking-order of the layers. - - .. image:: _static/minigifs/rearrange_layers.gif - - .. currentmodule:: eomaps .. autosummary:: @@ -254,22 +255,27 @@ If you want to overlay a part of the screen with a different layer, have a look Maps.show_layer Maps.fetch_layers +πŸ—Ί Export the map as jpeg/png, etc. +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Once the map is ready, an image of the map can be saved at any time by using :py:meth:`Maps.savefig` + +.. code-block:: python -πŸ”΄ Visualizing data -~~~~~~~~~~~~~~~~~~~~ + m = Maps() + ... + m.savefig("snapshot1.png", dpi=100, transparent=False, ...) -To visualize a dataset, first assign the dataset to the ``Maps``-object, -then select how you want to visualize the data and finally call ``m.plot_map()``. -1. Assign the data to a ``Maps`` object via ``m.set_data()`` -2. (optional) set the shape used to represent the data via ``m.set_shape.< shape >(...)`` -3. (optional) assign a classification scheme for the data via ``m.set_classify.< scheme >(...)`` -4. Plot the data by calling ``m.plot_map(...)`` +To adjust the margins of the subplots, use ``m.subplots_adjust()``, ``m.f.tight_layout()`` or +have a look at the :ref:`layout_editor`! -πŸ—ƒ Assign the data -****************** +.. code-block:: python + :name: test_subplots_adjust -To assign a dataset to a ``Maps`` object, use ``m.set_data(...)``. + from eomaps import Maps + m = Maps() + m.subplots_adjust(left=0.1, right=0.9, bottom=0.05, top=0.95) .. currentmodule:: eomaps @@ -278,969 +284,1106 @@ To assign a dataset to a ``Maps`` object, use ``m.set_data(...)``. :nosignatures: :template: only_names_in_toc.rst - Maps.set_data - -A dataset is fully specified by setting the following properties: - -- ``data`` : The data-values -- ``x``, ``y``: The coordinates of the provided data -- ``crs``: The coordinate-reference-system of the provided coordinates -- ``parameter`` (optional): The parameter name -- ``encoding`` (optional): The encoding of the data -- ``cpos``, ``cpos_radius`` (optional): the pixel offset - + Maps.subplots_adjust -.. note:: - Make sure to use a individual ``Maps`` object (e.g. with ``m2 = m.new_layer()`` for each dataset! - Calling ``m.plot_map()`` multiple times on the same ``Maps`` object will remove - and override the previously plotted dataset! +.. admonition:: Notes on exporting high-dpi figures + EOmaps tries its best to follow the WYSIWYG concept (e.g. *"What You See Is What You Get"*). + However, if you export the map with a dpi-value other than ``100``, there are certain circumstances + where the final image might look different. + To summarize: -.. admonition:: A note on data-reprojection... + - Changing the dpi of the figure requires a re-draw of all plotted datasets. - EOmaps handles the reprojection of the data from the input-crs to the plot-crs. + - if you use ``shade`` shapes to represent the data, using a higher dpi-value can result in a very different appearance of the data! - - Plotting data in its native crs will omit the reprojection step and is therefore a lot faster! - - If your dataset is 2D (e.g. a raster), it is best (for speed and memory) to provide the coordinates as 1D vectors! + - WebMap services usually come as image-tiles with 96 dpi - - Note that reprojecting 1D coordinate vectors to a different crs will result in (possibly very large) 2D coordinate arrays! + - by default, images are not re-fetched when saving the map to keep the original appearance + - If you want to re-fetch the WebMap based on the export-dpi, use ``m.savefig(refetch_wms=True)``. + - Note: increasing the dpi will result in an increase in the number of tiles that have to be fetched. If the number of required tiles is too large, the server might reject the request and the map might have gaps or no tiles at all. -The following data-types are accepted as input: -+---------------------------------------------------------------------+------------------------------------------------------------------------------------+ -| **pandas DataFrames** | .. code-block:: python | -| | | -| - ``data``: ``pandas.DataFrame`` | from eomaps import Maps | -| - ``x``, ``y``: The column-names to use as coordinates (``string``) | import pandas as pd | -| - ``parameter``: The column-name to use as data-values (``string``) | | -| | df = pd.DataFrame(dict(lon=[1,2,3], lat=[2,5,4], data=[12, 43, 2])) | -| | m = Maps() | -| | m.set_data(df, x="lon", y="lat", crs=4326, parameter="data") | -| | m.plot_map() | -+---------------------------------------------------------------------+------------------------------------------------------------------------------------+ -| **pandas Series** | .. code-block:: python | -| | | -| - ``data``, ``x``, ``y``: ``pandas.Series`` | from eomaps import Maps | -| - ``parameter``: (optional) parameter name (``string``) | import pandas as pd | -| | | -| | x, y, data = pd.Series([1,2,3]), pd.Series([2, 5, 4]), pd.Series([12, 43, 2]) | -| | m = Maps() | -| | m.set_data(data, x=x, y=y, crs=4326, parameter="param_name") | -| | m.plot_map() | -+---------------------------------------------------------------------+------------------------------------------------------------------------------------+ -| **1D** or **2D** data **and** coordinates | .. code-block:: python | -| | | -| - ``data``, ``x``, ``y``: equal-size ``numpy.array`` (or ``list``) | from eomaps import Maps | -| - ``parameter``: (optional) parameter name (``string``) | import numpy as np | -| | | -| | x, y = np.mgrid[-20:20, -40:40] | -| | data = x + y | -| | m = Maps() | -| | m.set_data(data=data, x=x, y=y, crs=4326, parameter="param_name") | -| | m.plot_map() | -+---------------------------------------------------------------------+------------------------------------------------------------------------------------+ -| **1D** coordinates and **2D** data | .. code-block:: python | -| | | -| - ``data``: ``numpy.array`` (or ``list``) with shape ``(n, m)`` | from eomaps import Maps | -| - ``x``: ``numpy.array`` (or ``list``) with shape ``(n,)`` | import numpy as np | -| - ``y``: ``numpy.array`` (or ``list``) with shape ``(m,)`` | | -| - ``parameter``: (optional) parameter name (``string``) | x = np.linspace(10, 50, 100) | -| | y = np.linspace(10, 50, 50) | -| | data = np.random.normal(size=(100, 50)) | -| | | -| | m = Maps() | -| | m.set_data(data=data, x=x, y=y, crs=4326, parameter="param_name") | -| | m.plot_map() | -+---------------------------------------------------------------------+------------------------------------------------------------------------------------+ +.. _multiple_maps: -πŸ’  Set the shape used to represent the data -******************************************** +🍱 Multiple Maps (and/or plots) in one figure +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -To specify how the data is represented on the map, you have to set the *"plot-shape"* via ``m.set_shape``. +It is possible to combine multiple ``EOmaps`` maps and/or ordinary ``matpltolib`` plots in one figure. -.. currentmodule:: eomaps +The **figure** used by a ``Maps`` object is set via the ``f`` argument, e.g.: ``m = Maps(f=...)``. +If no figure is provided, a new figure is created whenever you initialize a ``Maps`` object. +The figure-instance of an existing ``Maps`` object is accessible via ``m.f`` -.. autosummary:: - :toctree: generated - :nosignatures: - :template: only_names_in_toc.rst - Maps.set_shape +- To add a map to an existing figure, use ``m2 = m.new_map()`` (requires EOmaps >= v6.1) or pass the figure-instance on initialization of a new ``Maps`` object. +- To add a ordinary ``matplotlib`` plot to a figure containing an eomaps-map, use ``m.f.add_subplot()`` or ``m.f.add_axes()``. -.. admonition:: A note on speed and performance +The **initial position of the axes** used by a ``Maps`` object is set via the ``ax`` argument, +e.g.: ``m = Maps(ax=...)`` or ``m2 = m.new_map(ax=...)`` - Some *"plot-shapes"* require more computational effort than others! - Make sure to select an appropriate shape based on the size of the dataset you want to plot! +- The syntax for positioning axes is similar to matplotlibs ``f.add_subplot()`` or ``f.add_axes()`` +- The axis-instance of an existing ``Maps`` object is accessible via ``m.ax`` +- ...for more information, checkout the matplotlib tutorial: `Customizing Figure Layouts `_ - EOmaps dynamically pre-selects the data with respect to the current plot-extent before the actual plot is created! - If you do not need to see the whole extent of the data, make sure to **set the desired plot-extent** - via ``m.set_extent(...)`` or ``m.set_shape_to_extent(...)`` **BEFORE** calling ``m.plot_map()`` to get a (possibly huge) speedup! - The numbers of datapoints mentioned in the following always refer to the number of datapoints that are - visible in the desired plot-extent. +.. note:: + Make sure to have a look at the :ref:`layout_editor` on how to re-position and re-scale axes to arbitrary positions! -Possible shapes that work nicely for datasets with up to ~500 000 data-points: -.. currentmodule:: eomaps._shapes.shapes +.. currentmodule:: eomaps .. autosummary:: :toctree: generated :nosignatures: :template: only_names_in_toc.rst - geod_circles - ellipses - rectangles - voronoi_diagram - delaunay_triangulation + Maps + Maps.new_map -Possible shapes that work nicely for up to a few million data-points: -.. autosummary:: - :toctree: generated - :nosignatures: - :template: only_names_in_toc.rst +In the following, the most commonly used cases are introduced: - raster +Grid positioning +**************** +To position the map in a (virtual) grid, one of the following options are possible: -While ``raster`` can still be used for datasets with a few million datapoints, for extremely large datasets -(> 10 million datapoints) it is recommended to use "shading" to **greatly speed-up plotting**. -If shading is used, a dynamic averaging of the data based on the screen-resolution and the -currently visible plot-extent is performed (resampling based on the mean-value is used by default). +- Three integers ``(nrows, ncols, index)`` (or 2 integers and a tuple). -Possible shapes that can be used to quickly generate a plot for extremely large datasets are: + - The map will take the ``index`` position on a grid with ``nrows`` rows and ``ncols`` columns. + - ``index`` starts at 1 in the upper left corner and increases to the right. + - ``index`` can also be a two-tuple specifying the (first, last) + indices (1-based, and including last) of the map, e.g., ``Maps(ax=(3, 1, (1, 2)))`` makes a map that spans the upper 2/3 of the figure. -.. autosummary:: - :toctree: generated - :nosignatures: - :template: only_names_in_toc.rst +.. table:: - shade_points - shade_raster + +----------------------------------------------------+------------------------------------+ + | .. code-block:: python | .. image:: _static/grids/grid1.png | + | :name: test_gridpos_1 | :align: center | + | | | + | from eomaps import Maps | | + | # ----- initialize a figure with an EOmaps map | | + | # position = item 1 of a 2x1 grid | | + | m = Maps(ax=(2, 1, 1)) | | + | # ----- add a normal matplotlib axes | | + | # position = item 2 of a 2x1 grid | | + | ax = m.f.add_subplot(2, 1, 2) | | + +----------------------------------------------------+------------------------------------+ +.. table:: -.. code-block:: python - :name: test_set_shape + +----------------------------------------------------+------------------------------------+ + | .. code-block:: python | .. image:: _static/grids/grid2.png | + | :name: test_gridpos_2 | :align: center | + | | | + | from eomaps import Maps | | + | # ----- initialize a figure with an EOmaps map | | + | # position = item 1 of a 2x2 grid | | + | m = Maps(ax=(2, 2, 1)) | | + | # ----- add another Map to the same figure | | + | # position = item 3 of a 2x2 grid | | + | m2 = m.new_map(ax=(2, 2, 3)) | | + | # ----- add a normal matplotlib axes | | + | # position = second item of a 1x2 grid | | + | ax = m.f.add_subplot(1, 2, 2) | | + +----------------------------------------------------+------------------------------------+ - from eomaps import Maps - data, x, y = [.3,.64,.2,.5,1], [1,2,3,4,5], [2,5,3,7,5] +.. table:: - m = Maps() # create a Maps-object - m.set_data(data, x, y) # assign some data to the Maps-object - m.set_shape.rectangles(radius=1, # represent the datapoints as 1x1 degree rectangles - radius_crs=4326) # (in epsg=4326 projection) - m.plot_map(cmap="viridis", zorder=1) # plot the data + +----------------------------------------------------+------------------------------------+ + | .. code-block:: python | .. image:: _static/grids/grid3.png | + | :name: test_gridpos_3 | :align: center | + | | | + | from eomaps import Maps | | + | # ----- initialize a figure with an EOmaps map | | + | # position = span 2 rows of a 3x1 grid | | + | m = Maps(ax=(3, 1, (1, 2))) | | + | # ----- add a normal matplotlib axes | | + | # position = item 3 of a 3x1 grid | | + | ax = m.f.add_subplot(3, 1, 3) | | + +----------------------------------------------------+------------------------------------+ - m2 = m.new_layer() # create a new Maps-object on the same layer - m2.set_data(data, x, y) # assign another dataset to the new Maps object - m2.set_shape.geod_circles(radius=50000, # draw geodetic circles with 50km radius - n=100) # use 100 intermediate points to represent the shape - m2.plot_map(ec="k", cmap="Reds", # plot the data - zorder=2, set_extent=False) # (and avoid resetting the plot-extent) +- A 3-digit integer. -.. note:: + - The digits are interpreted as if given separately as three single-digit integers, i.e. ``Maps(ax=235)`` is the same as ``Maps(ax=(2, 3, 5))``. + - Note that this can only be used if there are no more than 9 subplots. - The "shade"-shapes require the additional ``datashader`` dependency! - You can install it via: - ``mamba install -c conda-forge datashader`` +.. table:: -.. admonition:: What's used by default? + +----------------------------------------------------+------------------------------------+ + | .. code-block:: python | .. image:: _static/grids/grid4.png | + | :name: test_gridpos_4 | :align: center | + | | | + | from eomaps import Maps | | + | # ----- initialize a figure with an EOmaps map | | + | m = Maps(ax=211) | | + | # ----- add a normal matplotlib axes | | + | ax = m.f.add_subplot(212) | | + +----------------------------------------------------+------------------------------------+ - By default, the plot-shape is assigned based on the associated dataset. +.. table:: - - For datasets with less than 500 000 pixels, ``m.set_shape.ellipses()`` is used. - - | For larger 2D datasets ``m.set_shape.shade_raster()`` is used - | ... and ``m.set_shape.shade_points()`` is used for the rest. + +----------------------------------------------------+------------------------------------+ + | .. code-block:: python | .. image:: _static/grids/grid5.png | + | :name: test_gridpos_5 | :align: center | + | | | + | from eomaps import Maps | | + | # ----- initialize a figure with an EOmaps map | | + | m = Maps(ax=221) | | + | # ----- add 2 more Maps to the same figure | | + | m2 = m.new_map(ax=222) | | + | m3 = m.new_map(ax=223) | | + | # ----- add a normal matplotlib axes | | + | ax = m.f.add_subplot(224) | | + +----------------------------------------------------+------------------------------------+ -To get an overview of the existing shapes and their main use-cases, here's a simple decision-tree: -(... and don't forget to set the plot-extent if you only want to see a subset of the data!) -.. image:: _static/shapes_decision_tree.png +- A matplotlib `GridSpec `_ -.. image:: _static/minigifs/plot_shapes.gif +.. table:: -πŸ“Š Classify the data -********************* + +----------------------------------------------+------------------------------------+ + | .. code-block:: python | .. image:: _static/grids/grid6.png | + | :name: test_gridpos_6 | :align: center | + | | | + | from matplotlib.gridspec import GridSpec | | + | from eomaps import Maps | | + | | | + | gs = GridSpec(2, 2) | | + | m = Maps(ax=gs[0,0]) | | + | m2 = m.new_map(ax=gs[0,1]) | | + | ax = m.f.add_subplot(gs[1,:]) | | + +----------------------------------------------+------------------------------------+ -EOmaps provides an interface for `mapclassify `_ to classify datasets prior to plotting. +Absolute positioning +******************** -There are 2 (synonymous) ways to assign a classification-scheme: +To set the absolute position of the map, provide a list of 4 floats representing ``(left, bottom, width, height)``. -- ``m.set_classify_specs(scheme=..., ...)``: set classification scheme by providing name and relevant parameters. -- ``m.set_classify.(...)``: use autocompletion to get available classification schemes (with appropriate docstrings) + - The absolute position of the map expressed in relative figure coordinates (e.g. ranging from 0 to 1) - - The big advantage of this method is that it supports autocompletion (once the Maps-object has been instantiated) - and provides relevant docstrings to get additional information on the classification schemes. +.. note:: -Available classifier names are also accessible via ``Maps.CLASSIFIERS``. + Since the effective size of the Map is dependent on the current zoom-region, the position always + represents the **maximal area** that can be occupied by the map! -.. currentmodule:: eomaps + Also, using ``m.f.tight_layout()`` will not work with axes added in this way. -.. autosummary:: - :toctree: generated - :nosignatures: - :template: only_names_in_toc.rst - Maps.set_classify - Maps.set_classify_specs +.. table:: + +----------------------------------------------------+------------------------------------+ + | .. code-block:: python | .. image:: _static/grids/grid7.png | + | | :align: center | + | from eomaps import Maps | | + | # ----- initialize a figure with an EOmaps map | | + | m = Maps(ax=(.07, 0.53, .6, .3)) | | + | # ----- add a normal matplotlib axes | | + | ax = m.f.add_axes((.35, .15, .6, .2)) | | + +----------------------------------------------------+------------------------------------+ -The preferred way for assigning a classification-scheme to a ``Maps`` object is via ``m.set_classify``: +Using already existing figures / axes +************************************* -.. code-block:: python +It is also possible to insert an EOmaps map into an existing figure or re-use an existing axes. - m = Maps() - m.set_data(...) - m.set_shape.ellipses(...) - m.set_classify.Quantiles(k=5) - m.plot_map() + - To put a map on an existing figure, provide the figure-instance via ``m = Maps(f= )`` + - To use an existing axes, provide the axes-instance via ``m = Maps(ax= )`` -Alternatively, one can also use ``m.set_classify_specs(...)`` to assign a classification scheme: + - NOTE: The axes **MUST** be a cartopy-``GeoAxes``! .. code-block:: python - m = Maps() - m.set_data(...) - m.set_shape.ellipses(...) + import matplotlib.pyplot as plt + import cartopy + from eomaps import Maps - m.set_classify_specs(scheme="Quantiles", k=5) - m.classify_specs.k = 10 # alternative way for setting classify-specs - m.plot_map() + f = plt.figure(figsize=(10, 7)) + ax = f.add_subplot(projection=cartopy.crs.Mollweide()) + m = Maps(f=f, ax=ax) -Currently available classification-schemes are (see `mapclassify `_ for details): +Dynamic updates of plots in the same figure +******************************************* + + As soon as a ``Maps``-object is attached to a figure, EOmaps will handle re-drawing of the figure! + Therefore **dynamically updated** artists must be added to the "blit-manager" (``m.BM``) to ensure + that they are correctly updated. -- BoxPlot (hinge) -- EqualInterval (k) -- FisherJenks (k) -- FisherJenksSampled (k, pct, truncate) -- HeadTailBreaks () -- JenksCaspall (k) -- JenksCaspallForced (k) -- JenksCaspallSampled (k, pct) -- MaxP (k, initial) -- MaximumBreaks (k, mindiff) -- NaturalBreaks (k, initial) -- Quantiles (k) -- Percentiles (pct) -- StdMean (multiples) -- UserDefined (bins) + - use ``m.BM.add_artist(artist, layer=...)`` if the artist should be re-drawn on **any event** in the figure + - use ``m.BM.add_bg_artist(artist, layer=...)`` if the artist should **only** be re-drawn if the extent of the map changes +.. note:: + In most cases it is sufficient to simply add the whole axes-object as artist via ``m.BM.add_artist(...)``. -πŸ–¨ Plot the data -***************** + This ensures that all artists of the axes are updated as well! -If you want to plot a map based on a dataset, first set the data and then -call ``m.plot_map()``. -Any additional keyword-arguments passed to ``m.plot_map()`` are forwarded to the actual -plot-command for the selected shape. +Here's an example to show how it works: -Useful arguments that are supported by all shapes are: +.. table:: - - "cmap" : the colormap to use - - "vmin", "vmax" : the range of values used when assigning the colors - - "alpha" : the alpha-transparency - - "zorder" : the "stacking-order" of the feature + +-------------------------------------------------------------------------------------+------------------------------------------------------+ + | .. code-block:: python | .. image:: _static/minigifs/dynamic_axes_updates.gif | + | | :align: center | + | from eomaps import Maps | | + | | | + | # Initialize a new figure with an EOmaps map | | + | m = Maps(ax=223) | | + | m.ax.set_title("click me!") | | + | m.add_feature.preset.coastline() | | + | m.cb.click.attach.mark(radius=20, fc="none", ec="r", lw=2) | | + | | | + | # Add another map to the figure | | + | m2 = m.new_map(ax=224, crs=Maps.CRS.Mollweide()) | | + | m2.add_feature.preset.coastline() | | + | m2.add_feature.preset.ocean() | | + | m2.cb.click.attach.mark(radius=20, fc="none", ec="r", lw=2, n=200) | | + | | | + | # Add a "normal" matplotlib plot to the figure | | + | ax = m.f.add_subplot(211) | | + | # Since we want to dynamically update the data on the axis, it must be | | + | # added to the BlitManager to ensure that the artists are properly updated. | | + | # (EOmaps handles interactive re-drawing of the figure) | | + | m.BM.add_artist(ax, layer=m.layer) | | + | | | + | # plot some static data on the axis | | + | ax.plot([10, 20, 30, 40, 50], [10, 20, 30, 40, 50]) | | + | | | + | # define a callback that plots markers on the axis if you click on the map | | + | def cb(pos, **kwargs): | | + | ax.plot(*pos, marker="o") | | + | | | + | m.cb.click.attach(cb) # attach the callback to the first map | | + | m.cb.click.share_events(m2) # share click events between the 2 maps | | + +-------------------------------------------------------------------------------------+------------------------------------------------------+ -Arguments that are supported by all shapes except ``shade`` shapes are: - - "fc" or "facecolor" : set the face-color for the whole dataset - - "ec" or "edgecolor" : set the edge-color for the whole dataset - - "lw" or "linewidth" : the linewidth of the shapes -By default, the plot-extent of the axis is adjusted to the extent of the data **if the extent has not been set explicitly before**. -To always keep the extent as-is, use ``m.plot_map(set_extent=False)``. -.. code-block:: python - :name: test_plot_data +π„œ MapsGrid objects +******************* - from eomaps import Maps - m = Maps() - m.add_feature.preset.coastline(lw=0.5) +``MapsGrid`` objects can be used to create (and manage) multiple maps in one figure. - m.set_data([1,2,3,4,5], [10,20,40,60,70], [10,20,50,70,30], crs=4326) - m.set_shape.geod_circles(radius=7e5) - m.plot_map(cmap="viridis", ec="b", lw=1.5, alpha=0.85, set_extent=False) +.. note:: + While ``MapsGrid`` objects provide some convenience, starting with EOmaps v6.x, + the preferred way of combining multiple maps and/or matplotlib axes in a figure + is by using one of the options presented in the previous sections! -You can then continue to add :ref:`colorbar`, :ref:`annotations_and_markers`, -:ref:`scalebar`, :ref:`compass`, :ref:`webmap_layers`, :ref:`ne_features` or :ref:`geodataframe` to the map, -or you can start to :ref:`shape_drawer`, add :ref:`utility` and :ref:`callbacks`. +A ``MapsGrid`` creates a grid of ``Maps`` objects (and/or ordinary ``matpltolib`` axes), +and provides convenience-functions to perform actions on all maps of the figure. +.. code-block:: python + from eomaps import MapsGrid + mg = MapsGrid(r=2, c=2, crs=..., layer=..., ... ) + # you can then access the individual Maps-objects via: + mg.m_0_0 + mg.m_0_1 + mg.m_1_0 + mg.m_1_1 -.. currentmodule:: eomaps + m2 = mg.m_0_0.new_layer("newlayer") + ... -.. autosummary:: - :toctree: generated - :nosignatures: - :template: only_names_in_toc.rst + # there are many convenience-functions to perform actions on all Maps-objects: + mg.add_feature.preset.coastline() + mg.add_compass() + ... - Maps.plot_map - Maps.savefig + # to perform more complex actions on all Maps-objects, simply loop over the MapsGrid object + for m in mg: + ... -πŸ—Ί Exporting the map as jpeg/png, etc. -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # set the margins of the plot-grid + mg.subplots_adjust(left=0.1, right=0.9, bottom=0.05, top=0.95, hspace=0.1, wspace=0.05) -Once the map is ready, an image of the map can be saved at any time by using: -.. code-block:: python +Make sure to checkout the :ref:`layout_editor` which greatly simplifies the arrangement of multiple axes within a figure! - m.savefig("snapshot1.png", dpi=100, transparent=False, ...) +Custom grids and mixed axes ++++++++++++++++++++++++++++ +Fully customized grid-definitions can be specified by providing ``m_inits`` and/or ``ax_inits`` dictionaries +of the following structure: -To adjust the margins of the subplots, use ``m.subplots_adjust()``, ``m.f.tight_layout()`` or -have a look at the :ref:`layout_editor`! +- The keys of the dictionary are used to identify the objects +- The values of the dictionary are used to identify the position of the associated axes +- The position can be either an integer ``N``, a tuple of integers or slices ``(row, col)`` +- Axes that span over multiple rows or columns, can be specified via ``slice(start, stop)`` .. code-block:: python - :name: test_subplots_adjust - from eomaps import Maps - m = Maps() - m.subplots_adjust(left=0.1, right=0.9, bottom=0.05, top=0.95) + dict( + name1 = N # position the axis at the Nth grid cell (counting firs) + name2 = (row, col), # position the axis at the (row, col) grid-cell + name3 = (row, slice(col_start, col_end)) # span the axis over multiple columns + name4 = (slice(row_start, row_end), col) # span the axis over multiple rows + ) -.. currentmodule:: eomaps +- ``m_inits`` is used to initialize ``Maps`` objects +- ``ax_inits`` is used to initialize ordinary ``matplotlib`` axes -.. autosummary:: - :toctree: generated - :nosignatures: - :template: only_names_in_toc.rst +The individual ``Maps``-objects and ``matpltolib-Axes`` are then accessible via: - Maps.subplots_adjust +.. code-block:: python + mg = MapsGrid(2, 3, + m_inits=dict(left=(0, 0), right=(0, 2)), + ax_inits=dict(someplot=(1, slice(0, 3))) + ) + mg.m_left # the Maps object with the name "left" + mg.m_right # the Maps object with the name "right" -.. admonition:: Notes on exporting high-dpi figures - - EOmaps tries its best to follow the WYSIWYG concept (e.g. *"What You See Is What You Get"*). - However, if you export the map with a dpi-value other than ``100``, there are certain circumstances - where the final image might look different. - To summarize: - - - Changing the dpi of the figure requires a re-draw of all plotted datasets. - - - if you use ``shade`` shapes to represent the data, using a higher dpi-value can result in a very different appearance of the data! - - - WebMap services usually come as image-tiles with 96 dpi - - - by default, images are not re-fetched when saving the map to keep the original appearance - - If you want to re-fetch the WebMap based on the export-dpi, use ``m.savefig(refetch_wms=True)``. - - - Note: increasing the dpi will result in an increase in the number of tiles that have to be fetched. If the number of required tiles is too large, the server might reject the request and the map might have gaps or no tiles at all. - - - -🎨 Customizing the plot -~~~~~~~~~~~~~~~~~~~~~~~ - -All arguments to customize the appearance of a dataset are passed to ``m.plot_map(...)``. + mg.ax_someplot # the ordinary matplotlib-axis with the name "someplot" -In general, the colors assigned to the shapes are specified by -- selecting a colormap (``cmap``) +❗ NOTE: if ``m_inits`` and/or ``ax_inits`` are provided, ONLY the explicitly defined objects are initialized! - - either a name of a pre-defined ``matplotlib`` colormap (e.g. ``"viridis"``, ``"RdYlBu"`` etc.) - - or a general ``matplotlib`` colormap object (see `matplotlib-docs `_ for more details) -- (optionally) setting appropriate data-limits via ``vmin`` and ``vmax``. +- The initialization of the axes is based on matplotlib's `GridSpec `_ functionality. + All additional keyword-arguments (``width_ratios, height_ratios, etc.``) are passed to the initialization of the ``GridSpec`` object. - - ``vmin`` and ``vmax`` set the range of data-values that are mapped to the colorbar-colors - - Any values outside this range will get the colormaps ``over`` and ``under`` colors assigned. +- To specify unique ``crs`` for each ``Maps`` object, provide a dictionary of ``crs`` specifications. .. code-block:: python - m = Maps() - m.set_data(...) - m.plot_map(cmap="viridis", vmin=0, vmax=1) - ------- - -Colors can also be set **manually** by providing one of the following arguments to ``m.plot_map(...)``: - -- to set both **facecolor** AND **edgecolor** use ``color=...`` -- to set the **facecolor** use ``fc=...`` or ``facecolor=...`` -- to set the **edgecolor** use ``ec=...`` or ``edgecolor=...`` + from eomaps import MapsGrid -.. note:: + # initialize a grid with 2 Maps objects and 1 ordinary matplotlib axes + mgrid = MapsGrid(2, 2, + m_inits=dict(top_row=(0, slice(0, 2)), + bottom_left=(1, 0)), + crs=dict(top_row=4326, + bottom_left=3857), + ax_inits=dict(bottom_right=(1, 1)), + width_ratios=(1, 2), + height_ratios=(2, 1)) - - Manual color specifications do **not** work with the ``shade_raster`` and ``shade_points`` shapes! - - Providing manual colors will **override** the colors assigned by the ``cmap``! - - The ``colorbar`` does **not** represent manually defined colors! + mgrid.m_top_row # a map extending over the entire top-row of the grid (in epsg=4326) + mgrid.m_bottom_left # a map in the bottom left corner of the grid (in epsg=3857) + mgrid.ax_bottom_right # an ordinary matplotlib axes in the bottom right corner of the grid -Uniform colors -************** -To apply a uniform color to all datapoints, you can use `matpltolib's named colors `_ or pass an RGB or RGBA tuple. +.. currentmodule:: eomaps -- ``m.plot_map(fc="orange")`` -- ``m.plot_map(fc=(0.4, 0.3, 0.2))`` -- ``m.plot_map(fc=(1, 0, 0.2, 0.5))`` +.. autosummary:: + :toctree: generated + :nosignatures: + :template: only_names_in_toc.rst -.. code-block:: python - :name: test_uniform_colors + MapsGrid + MapsGrid.join_limits + MapsGrid.share_click_events + MapsGrid.share_pick_events + MapsGrid.set_data_specs + MapsGrid.set_classify_specs + MapsGrid.add_wms + MapsGrid.add_feature + MapsGrid.add_annotation + MapsGrid.add_marker + MapsGrid.add_gdf - from eomaps import Maps - m = Maps() - m.set_data(data=None, x=[10,20,30], y=[10,20,30]) +🧱 Naming conventions and autocompletion +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - # Use any of matplotlibs "named colors" - m1 = m.new_layer(copy_data_specs=True) - m1.set_shape.ellipses(radius=10) - m1.plot_map(fc="r", zorder=0) +The goal of EOmaps is to provide a comprehensive, yet easy-to-use interface. - m2 = m.new_layer(copy_data_specs=True) - m2.set_shape.ellipses(radius=8) - m2.plot_map(fc="orange", zorder=1) +To avoid having to remember a lot of names, a concise naming-convention is applied so +that autocompletion can quickly narrow-down the search to relevant functions and properties. - # Use RGB or RGBA tuples - m3 = m.new_layer(copy_data_specs=True) - m3.set_shape.ellipses(radius=6) - m3.plot_map(fc=(1, 0, 0.5), zorder=2) +Once a few basics keywords have been remembered, finding the right functions and properties should be quick and easy. - m4 = m.new_layer(copy_data_specs=True) - m4.set_shape.ellipses(radius=4) - m4.plot_map(fc=(1, 1, 1, .75), zorder=3) +.. note:: - # For grayscale use a string of a number between 0 and 1 - m5 = m.new_layer(copy_data_specs=True) - m5.set_shape.ellipses(radius=2) - m5.plot_map(fc="0.3", zorder=4) + EOmaps works best in conjunction with "dynamic autocompletion", e.g. by using an interactive + console where you instantiate a ``Maps`` object first and then access dynamically updated properties + and docstrings on the object. + To clarify: -Explicit colors -*************** + - First, execute ``m = Maps()`` in an interactive console + - then (inside the console, not inside the editor!) use autocompletion on ``m.`` to get + autocompletion for dynamically updated attributes. -To explicitly color each datapoint with a pre-defined color, simply provide a list or array of the aforementioned types. + For example the following accessors only work properly after the ``Maps`` object has been initialized: + - ``m.add_wms``: browse available WebMap services + - ``m.set_classify``: browse available classification schemes -.. code-block:: python - :name: test_explicit_colors - from eomaps import Maps +The following list provides an overview of the naming-conventions used within EOmaps: - m = Maps() - m.set_data(data=None, x=[10, 20, 30], y=[10, 20, 30]) +Add features to a map - "m.add\_" +********************************* +All functions that add features to a map start with ``add_``, e.g.: +- ``m.add_feature``, ``m.add_wms``, ``m.add_annotation``, ``m.add_marker``, ``m.add_gdf``, ... - # Use any of matplotlibs "named colors" - # (https://matplotlib.org/stable/gallery/color/named_colors.html) - m1 = m.new_layer(copy_data_specs=True) - m1.set_shape.ellipses(radius=10) - m1.plot_map(fc=["indigo", "g", "orange"], zorder=1) +WebMap services (e.g. ``m.add_wms``) are fetched dynamically from the respective APIs. +Therefore the structure can vary from one WMS to another. +The used convention is the following: +- You can navigate into the structure of the API by using "dot-access" continuously +- once you reach a level that provides layers that can be added to the map, the ``.add_layer.`` directive will be visible +- any ```` returned by ``.add_layer.`` can be added to the map by simply calling it, e.g.: - # Use RGB tuples - m2 = m.new_layer(copy_data_specs=True) - m2.set_shape.ellipses(radius=6) - m2.plot_map(fc=[(1, 0, 0.5), - (0.3, 0.4, 0.5), - (1, 1, 0)], zorder=2) + - ``m.add_wms.OpenStreetMap.add_layer.default()`` + - ``m.add_wms.OpenStreetMap.OSM_mundialis.add_layer.OSM_WMS()`` - # Use RGBA tuples - m3 = m.new_layer(copy_data_specs=True) - m3.set_shape.ellipses(radius=8) - m3.plot_map(fc=[(1, 0, 0.5, 0.25), - (1, 0, 0.5, 0.75), - (0.1, 0.2, 0.5, 0.5)], zorder=3) +Set data specifications - "m.set\_" +*********************************** +All functions that set properties of the associated dataset start with ``set_``, e.g.: +- ``m.set_data``, ``m.set_classify``, ``m.set_shape``, ... - # For grayscale use a string of a number between 0 and 1 - m4 = m.new_layer(copy_data_specs=True) - m4.set_shape.ellipses(radius=4) - m4.plot_map(fc=[".1", ".2", "0.3"], zorder=4) +Create new Maps-objects - "m.new\_" +*********************************** +Actions that result in a new ``Maps`` objects start with ``new_``. +- ``m.new_layer``, ``m.new_inset_map``, ... -RGB/RGBA composites +Callbacks - "m.cb." ******************* +Everything related to callbacks is grouped under the ``cb`` accessor. -To create an RGB or RGBA composite from 3 (or 4) datasets, pass the datasets as tuple: - -- the datasets must have the same size as the coordinate arrays! -- the datasets must be scaled between 0 and 1 - -If you pass a tuple of 3 or 4 arrays, they will be used to set the -RGB (or RGBA) colors of the shapes, e.g.: - -- ``m.plot_map(fc=(, , ))`` -- ``m.plot_map(fc=(, , , ))`` +- use ``m.cb..attach.()`` to attach pre-defined callbacks -You can fix individual color channels by passing a list with 1 element, e.g.: + - ```` hereby can be one of ``click``, ``pick`` or ``keypress`` + (but there's no need to remember since autocompletion will do the job!). -- ``m.plot_map(fc=(, [0.12345], , ))`` +- use ``m.cb..attach(custom_cb)`` to attach a custom callback -.. code-block:: python - :name: test_rgba_composites - from eomaps import Maps - import numpy as np - x, y = np.meshgrid(np.linspace(-20, 40, 100), - np.linspace(50, 70, 50)) - # values must be between 0 and 1 - r = np.random.randint(0, 100, x.shape) / 100 - g = np.random.randint(0, 100, x.shape) / 100 - b = [0.4] - a = np.random.randint(0, 100, x.shape) / 100 +.. _visualize_data: - m = Maps() - m.add_feature.preset.ocean() - m.set_data(data=None, x=x, y=y) - m.plot_map(fc=(r, g, b, a)) +πŸ”΄ Data Visualization +---------------------- +To visualize a dataset, first assign the dataset to the :py:class:`Maps` object, +then select how you want to visualize the data and finally call :py:meth:`Maps.plot_map`. -🍱 Multiple Maps (and/or plots) in one figure -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +1. **Assign the data** to a :py:class:`Maps` object via :py:meth:`Maps.set_data` +2. (optional) **set the shape** used to represent the data via :py:class:`Maps.set_shape` +3. (optional) **assign a classification scheme** for the data via :py:class:`Maps.set_classify` +4. **Plot the data** by calling :py:meth:`Maps.plot_map` -It is possible to combine multiple ``EOmaps`` maps and/or ordinary ``matpltolib`` plots in one figure. +πŸ—ƒ Assign the data +~~~~~~~~~~~~~~~~~~ -The **figure** used by a ``Maps`` object is set via the ``f`` argument, e.g.: ``m = Maps(f=...)``. -If no figure is provided, a new figure is created whenever you initialize a ``Maps`` object. -The figure-instance of an existing ``Maps`` object is accessible via ``m.f`` +To assign a dataset to a :py:class:`Maps` object, use :py:meth:`Maps.set_data`. +.. currentmodule:: eomaps -- To add a map to an existing figure, use ``m2 = m.new_map()`` (requires EOmaps >= v6.1) or pass the figure-instance on initialization of a new ``Maps`` object. -- To add a ordinary ``matplotlib`` plot to a figure containing an eomaps-map, use ``m.f.add_subplot()`` or ``m.f.add_axes()``. +.. autosummary:: + :toctree: generated + :nosignatures: + :template: only_names_in_toc.rst + Maps.set_data -The **initial position of the axes** used by a ``Maps`` object is set via the ``ax`` argument, -e.g.: ``m = Maps(ax=...)`` or ``m2 = m.new_map(ax=...)`` +A dataset is fully specified by setting the following properties: -- The syntax for positioning axes is similar to matplotlibs ``f.add_subplot()`` or ``f.add_axes()`` -- The axis-instance of an existing ``Maps`` object is accessible via ``m.ax`` -- ...for more information, checkout the matplotlib tutorial: `Customizing Figure Layouts `_ +- ``data`` : The data-values +- ``x``, ``y``: The coordinates of the provided data +- ``crs``: The coordinate-reference-system of the provided coordinates +- ``parameter`` (optional): The parameter name +- ``encoding`` (optional): The encoding of the data +- ``cpos``, ``cpos_radius`` (optional): the pixel offset .. note:: - Make sure to have a look at the :ref:`layout_editor` on how to re-position and re-scale axes to arbitrary positions! + Make sure to use a individual :py:class:`Maps` object (e.g. with ``m2 = m.new_layer()`` for each dataset! + Calling :py:meth:`Maps.plot_map` multiple times on the same :py:class:`Maps`object will remove + and override the previously plotted dataset! -.. currentmodule:: eomaps +.. admonition:: A note on data-reprojection... -.. autosummary:: - :toctree: generated - :nosignatures: - :template: only_names_in_toc.rst + EOmaps handles the reprojection of the data from the input-crs to the plot-crs. - Maps - Maps.new_map + - Plotting data in its native crs will omit the reprojection step and is therefore a lot faster! + - If your dataset is 2D (e.g. a raster), it is best (for speed and memory) to provide the coordinates as 1D vectors! + - Note that reprojecting 1D coordinate vectors to a different crs will result in (possibly very large) 2D coordinate arrays! -In the following, the most commonly used cases are introduced: -Grid positioning -**************** -To position the map in a (virtual) grid, one of the following options are possible: +The following data-types are accepted as input: -- Three integers ``(nrows, ncols, index)`` (or 2 integers and a tuple). ++---------------------------------------------------------------------+------------------------------------------------------------------------------------+ +| **pandas DataFrames** | .. code-block:: python | +| | | +| - ``data``: ``pandas.DataFrame`` | from eomaps import Maps | +| - ``x``, ``y``: The column-names to use as coordinates (``string``) | import pandas as pd | +| - ``parameter``: The column-name to use as data-values (``string``) | | +| | df = pd.DataFrame(dict(lon=[1,2,3], lat=[2,5,4], data=[12, 43, 2])) | +| | m = Maps() | +| | m.set_data(df, x="lon", y="lat", crs=4326, parameter="data") | +| | m.plot_map() | ++---------------------------------------------------------------------+------------------------------------------------------------------------------------+ +| **pandas Series** | .. code-block:: python | +| | | +| - ``data``, ``x``, ``y``: ``pandas.Series`` | from eomaps import Maps | +| - ``parameter``: (optional) parameter name (``string``) | import pandas as pd | +| | | +| | x, y, data = pd.Series([1,2,3]), pd.Series([2, 5, 4]), pd.Series([12, 43, 2]) | +| | m = Maps() | +| | m.set_data(data, x=x, y=y, crs=4326, parameter="param_name") | +| | m.plot_map() | ++---------------------------------------------------------------------+------------------------------------------------------------------------------------+ +| **1D** or **2D** data **and** coordinates | .. code-block:: python | +| | | +| - ``data``, ``x``, ``y``: equal-size ``numpy.array`` (or ``list``) | from eomaps import Maps | +| - ``parameter``: (optional) parameter name (``string``) | import numpy as np | +| | | +| | x, y = np.mgrid[-20:20, -40:40] | +| | data = x + y | +| | m = Maps() | +| | m.set_data(data=data, x=x, y=y, crs=4326, parameter="param_name") | +| | m.plot_map() | ++---------------------------------------------------------------------+------------------------------------------------------------------------------------+ +| **1D** coordinates and **2D** data | .. code-block:: python | +| | | +| - ``data``: ``numpy.array`` (or ``list``) with shape ``(n, m)`` | from eomaps import Maps | +| - ``x``: ``numpy.array`` (or ``list``) with shape ``(n,)`` | import numpy as np | +| - ``y``: ``numpy.array`` (or ``list``) with shape ``(m,)`` | | +| - ``parameter``: (optional) parameter name (``string``) | x = np.linspace(10, 50, 100) | +| | y = np.linspace(10, 50, 50) | +| | data = np.random.normal(size=(100, 50)) | +| | | +| | m = Maps() | +| | m.set_data(data=data, x=x, y=y, crs=4326, parameter="param_name") | +| | m.plot_map() | ++---------------------------------------------------------------------+------------------------------------------------------------------------------------+ - - The map will take the ``index`` position on a grid with ``nrows`` rows and ``ncols`` columns. - - ``index`` starts at 1 in the upper left corner and increases to the right. - - ``index`` can also be a two-tuple specifying the (first, last) - indices (1-based, and including last) of the map, e.g., ``Maps(ax=(3, 1, (1, 2)))`` makes a map that spans the upper 2/3 of the figure. -.. table:: +πŸ’  Specify how to visualize the data +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - +----------------------------------------------------+------------------------------------+ - | .. code-block:: python | .. image:: _static/grids/grid1.png | - | :name: test_gridpos_1 | :align: center | - | | | - | from eomaps import Maps | | - | # ----- initialize a figure with an EOmaps map | | - | # position = item 1 of a 2x1 grid | | - | m = Maps(ax=(2, 1, 1)) | | - | # ----- add a normal matplotlib axes | | - | # position = item 2 of a 2x1 grid | | - | ax = m.f.add_subplot(2, 1, 2) | | - +----------------------------------------------------+------------------------------------+ +To specify how a dataset is visualized on the map, you have to set the *"plot-shape"* via :py:meth:`Maps.set_shape`. -.. table:: +.. currentmodule:: eomaps - +----------------------------------------------------+------------------------------------+ - | .. code-block:: python | .. image:: _static/grids/grid2.png | - | :name: test_gridpos_2 | :align: center | - | | | - | from eomaps import Maps | | - | # ----- initialize a figure with an EOmaps map | | - | # position = item 1 of a 2x2 grid | | - | m = Maps(ax=(2, 2, 1)) | | - | # ----- add another Map to the same figure | | - | # position = item 3 of a 2x2 grid | | - | m2 = m.new_map(ax=(2, 2, 3)) | | - | # ----- add a normal matplotlib axes | | - | # position = second item of a 1x2 grid | | - | ax = m.f.add_subplot(1, 2, 2) | | - +----------------------------------------------------+------------------------------------+ +.. autosummary:: + :toctree: generated + :nosignatures: + :template: only_names_in_toc.rst -.. table:: + Maps.set_shape - +----------------------------------------------------+------------------------------------+ - | .. code-block:: python | .. image:: _static/grids/grid3.png | - | :name: test_gridpos_3 | :align: center | - | | | - | from eomaps import Maps | | - | # ----- initialize a figure with an EOmaps map | | - | # position = span 2 rows of a 3x1 grid | | - | m = Maps(ax=(3, 1, (1, 2))) | | - | # ----- add a normal matplotlib axes | | - | # position = item 3 of a 3x1 grid | | - | ax = m.f.add_subplot(3, 1, 3) | | - +----------------------------------------------------+------------------------------------+ -- A 3-digit integer. +.. admonition:: A note on speed and performance - - The digits are interpreted as if given separately as three single-digit integers, i.e. ``Maps(ax=235)`` is the same as ``Maps(ax=(2, 3, 5))``. - - Note that this can only be used if there are no more than 9 subplots. + Some ways to visualize the data require more computational effort than others! + Make sure to select an appropriate shape based on the size of the dataset you want to plot! -.. table:: + EOmaps dynamically pre-selects the data with respect to the current plot-extent before the actual plot is created! + If you do not need to see the whole extent of the data, make sure to **set the desired plot-extent** + via :py:meth:`Maps.set_extent` or :py:meth:`Maps.set_shape_to_extent` **BEFORE** calling :py:meth:`Maps.plot_map` to get a (possibly huge) speedup! - +----------------------------------------------------+------------------------------------+ - | .. code-block:: python | .. image:: _static/grids/grid4.png | - | :name: test_gridpos_4 | :align: center | - | | | - | from eomaps import Maps | | - | # ----- initialize a figure with an EOmaps map | | - | m = Maps(ax=211) | | - | # ----- add a normal matplotlib axes | | - | ax = m.f.add_subplot(212) | | - +----------------------------------------------------+------------------------------------+ + The number of datapoints mentioned in the following always refer to the number of datapoints that are + visible in the desired plot-extent. -.. table:: - +----------------------------------------------------+------------------------------------+ - | .. code-block:: python | .. image:: _static/grids/grid5.png | - | :name: test_gridpos_5 | :align: center | - | | | - | from eomaps import Maps | | - | # ----- initialize a figure with an EOmaps map | | - | m = Maps(ax=221) | | - | # ----- add 2 more Maps to the same figure | | - | m2 = m.new_map(ax=222) | | - | m3 = m.new_map(ax=223) | | - | # ----- add a normal matplotlib axes | | - | ax = m.f.add_subplot(224) | | - +----------------------------------------------------+------------------------------------+ +Possible shapes that work nicely for datasets with up to ~500 000 data-points: +.. currentmodule:: eomaps._shapes.shapes -- A matplotlib `GridSpec `_ +.. autosummary:: + :toctree: generated + :nosignatures: + :template: only_names_in_toc.rst -.. table:: + geod_circles + ellipses + rectangles + voronoi_diagram + delaunay_triangulation - +----------------------------------------------+------------------------------------+ - | .. code-block:: python | .. image:: _static/grids/grid6.png | - | :name: test_gridpos_6 | :align: center | - | | | - | from matplotlib.gridspec import GridSpec | | - | from eomaps import Maps | | - | | | - | gs = GridSpec(2, 2) | | - | m = Maps(ax=gs[0,0]) | | - | m2 = m.new_map(ax=gs[0,1]) | | - | ax = m.f.add_subplot(gs[1,:]) | | - +----------------------------------------------+------------------------------------+ +Possible shapes that work nicely for up to a few million data-points: -Absolute positioning -******************** +.. autosummary:: + :toctree: generated + :nosignatures: + :template: only_names_in_toc.rst -To set the absolute position of the map, provide a list of 4 floats representing ``(left, bottom, width, height)``. + raster - - The absolute position of the map expressed in relative figure coordinates (e.g. ranging from 0 to 1) +While :py:class:`raster` can still be used for datasets with a few million datapoints, for extremely large datasets +(> 10 million datapoints) it is recommended to use "shading" to **greatly speed-up plotting**. +If shading is used, a dynamic averaging of the data based on the screen-resolution and the +currently visible plot-extent is performed (resampling based on the mean-value is used by default). + +Possible shapes that can be used to quickly generate a plot for extremely large datasets are: + +.. autosummary:: + :toctree: generated + :nosignatures: + :template: only_names_in_toc.rst + + shade_points + shade_raster + + +.. code-block:: python + :name: test_set_shape + + from eomaps import Maps + data, x, y = [.3,.64,.2,.5,1], [1,2,3,4,5], [2,5,3,7,5] + + m = Maps() # create a Maps-object + m.set_data(data, x, y) # assign some data to the Maps-object + m.set_shape.rectangles(radius=1, # represent the datapoints as 1x1 degree rectangles + radius_crs=4326) # (in epsg=4326 projection) + m.plot_map(cmap="viridis", zorder=1) # plot the data + + m2 = m.new_layer() # create a new Maps-object on the same layer + m2.set_data(data, x, y) # assign another dataset to the new Maps object + m2.set_shape.geod_circles(radius=50000, # draw geodetic circles with 50km radius + n=100) # use 100 intermediate points to represent the shape + m2.plot_map(ec="k", cmap="Reds", # plot the data + zorder=2, set_extent=False) # (and avoid resetting the plot-extent) .. note:: - Since the effective size of the Map is dependent on the current zoom-region, the position always - represents the **maximal area** that can be occupied by the map! + The "shade"-shapes require the additional `datashader `_ dependency! + You can install it via: - Also, using ``m.f.tight_layout()`` will not work with axes added in this way. + .. code-block:: python + mamba install -c conda-forge datashader -.. table:: +.. admonition:: What's used by default? - +----------------------------------------------------+------------------------------------+ - | .. code-block:: python | .. image:: _static/grids/grid7.png | - | | :align: center | - | from eomaps import Maps | | - | # ----- initialize a figure with an EOmaps map | | - | m = Maps(ax=(.07, 0.53, .6, .3)) | | - | # ----- add a normal matplotlib axes | | - | ax = m.f.add_axes((.35, .15, .6, .2)) | | - +----------------------------------------------------+------------------------------------+ + By default, the plot-shape is assigned based on the associated dataset. -Using already existing figures / axes -************************************* + - For datasets with less than 500 000 pixels, ``m.set_shape.ellipses()`` is used. + - | For larger 2D datasets ``m.set_shape.shade_raster()`` is used + | ... and ``m.set_shape.shade_points()`` is used for the rest. -It is also possible to insert an EOmaps map into an existing figure or re-use an existing axes. +To get an overview of the existing shapes and their main use-cases, here's a simple decision-tree: +(... and don't forget to set the plot-extent if you only want to see a subset of the data!) - - To put a map on an existing figure, provide the figure-instance via ``m = Maps(f= )`` - - To use an existing axes, provide the axes-instance via ``m = Maps(ax= )`` +.. image:: _static/shapes_decision_tree.png - - NOTE: The axes **MUST** be a cartopy-``GeoAxes``! +.. image:: _static/minigifs/plot_shapes.gif + +πŸ“Š Classify the data +~~~~~~~~~~~~~~~~~~~~~ + +.. currentmodule:: eomaps + +EOmaps provides an interface for `mapclassify `_ to classify datasets prior to plotting. + +To assign a classification scheme to a :py:class:`Maps` object, use ``m.set_classify.< SCHEME >(...)``. + +- Available classifier names are accessible via ``Maps.CLASSIFIERS``. + + +.. autosummary:: + :toctree: generated + :nosignatures: + :template: only_names_in_toc.rst + + Maps.set_classify .. code-block:: python - import matplotlib.pyplot as plt - import cartopy + m = Maps() + m.set_data(...) + m.set_shape.ellipses(...) + m.set_classify.Quantiles(k=5) + m.plot_map() + +Currently available classification-schemes are (see `mapclassify `_ for details): + +- `BoxPlot(hinge) `_ +- `EqualInterval(k) `_ +- `FisherJenks(k) `_ +- `FisherJenksSampled(k, pct, truncate) `_ +- `HeadTailBreaks() `_ +- `JenksCaspall(k) `_ +- `JenksCaspallForced(k) `_ +- `JenksCaspallSampled(k, pct) `_ +- `MaxP(k, initial) `_ +- `MaximumBreaks(k, mindiff) `_ +- `NaturalBreaks(k, initial) `_ +- `Quantiles(k) `_ +- `Percentiles(pct) `_ +- `StdMean(multiples) `_ +- `UserDefined(bins) `_ + +πŸ–¨ Plot the data +~~~~~~~~~~~~~~~~ + +If you want to plot a map based on a dataset, first set the data and then +call :py:meth:`Maps.plot_map`. + +Any additional keyword-arguments passed to :py:meth:`Maps.plot_map` are forwarded to the actual +plot-command for the selected shape. + +Useful arguments that are supported by all shapes are: + + - "cmap" : the colormap to use + - "vmin", "vmax" : the range of values used when assigning the colors + - "alpha" : the color transparency + - "zorder" : the "stacking-order" of the feature + +Arguments that are supported by all shapes except ``shade`` shapes are: + - "fc" or "facecolor" : set the face color for the whole dataset + - "ec" or "edgecolor" : set the edge color for the whole dataset + - "lw" or "linewidth" : the line width of the shapes + + +By default, the plot-extent of the axis is adjusted to the extent of the data **if the extent has not been set explicitly before**. +To always keep the extent as-is, use ``m.plot_map(set_extent=False)``. + +.. code-block:: python + :name: test_plot_data + from eomaps import Maps + m = Maps() + m.add_feature.preset.coastline(lw=0.5) - f = plt.figure(figsize=(10, 7)) - ax = f.add_subplot(projection=cartopy.crs.Mollweide()) - m = Maps(f=f, ax=ax) + m.set_data([1,2,3,4,5], [10,20,40,60,70], [10,20,50,70,30], crs=4326) + m.set_shape.geod_circles(radius=7e5) + m.plot_map(cmap="viridis", ec="b", lw=1.5, alpha=0.85, set_extent=False) -Dynamic updates of plots in the same figure -******************************************* +You can then continue to add a :ref:`colorbar` or create :ref:`zoomed_in_views_on_datasets`. - As soon as a ``Maps``-object is attached to a figure, EOmaps will handle re-drawing of the figure! - Therefore **dynamically updated** artists must be added to the "blit-manager" (``m.BM``) to ensure - that they are correctly updated. +.. currentmodule:: eomaps - - use ``m.BM.add_artist(artist, layer=...)`` if the artist should be re-drawn on **any event** in the figure - - use ``m.BM.add_bg_artist(artist, layer=...)`` if the artist should **only** be re-drawn if the extent of the map changes +.. autosummary:: + :toctree: generated + :nosignatures: + :template: only_names_in_toc.rst + + Maps.plot_map + Maps.savefig + + +🎨 Customize the plot +~~~~~~~~~~~~~~~~~~~~~~ + +.. currentmodule:: eomaps + +All arguments to customize the appearance of a dataset are passed to :py:meth:`Maps.plot_map`. + +In general, the colors assigned to the shapes are specified by + +- selecting a colormap (``cmap``) + + - either a name of a pre-defined ``matplotlib`` colormap (e.g. ``"viridis"``, ``"RdYlBu"`` etc.) + - or a general ``matplotlib`` colormap object (see `matplotlib-docs `_ for more details) + +- (optionally) setting appropriate data-limits via ``vmin`` and ``vmax``. + + - ``vmin`` and ``vmax`` set the range of data-values that are mapped to the colorbar-colors + - Any values outside this range will get the colormaps ``over`` and ``under`` colors assigned. + +.. code-block:: python + + m = Maps() + m.set_data(...) + m.plot_map(cmap="viridis", vmin=0, vmax=1) + +------ + +Colors can also be set **manually** by providing one of the following arguments to :py:meth:`Maps.plot_map`: + +- to set both **facecolor** AND **edgecolor** use ``color=...`` +- to set the **facecolor** use ``fc=...`` or ``facecolor=...`` +- to set the **edgecolor** use ``ec=...`` or ``edgecolor=...`` .. note:: - In most cases it is sufficient to simply add the whole axes-object as artist via ``m.BM.add_artist(...)``. + - Manual color specifications do **not** work with the ``shade_raster`` and ``shade_points`` shapes! + - Providing manual colors will **override** the colors assigned by the ``cmap``! + - The ``colorbar`` does **not** represent manually defined colors! - This ensures that all artists of the axes are updated as well! +Uniform colors +************** -Here's an example to show how it works: +To apply a uniform color to all datapoints, you can use `matpltolib's named colors `_ or pass an RGB or RGBA tuple. -.. table:: +- ``m.plot_map(fc="orange")`` +- ``m.plot_map(fc=(0.4, 0.3, 0.2))`` +- ``m.plot_map(fc=(1, 0, 0.2, 0.5))`` - +-------------------------------------------------------------------------------------+------------------------------------------------------+ - | .. code-block:: python | .. image:: _static/minigifs/dynamic_axes_updates.gif | - | | :align: center | - | from eomaps import Maps | | - | | | - | # Initialize a new figure with an EOmaps map | | - | m = Maps(ax=223) | | - | m.ax.set_title("click me!") | | - | m.add_feature.preset.coastline() | | - | m.cb.click.attach.mark(radius=20, fc="none", ec="r", lw=2) | | - | | | - | # Add another map to the figure | | - | m2 = m.new_map(ax=224, crs=Maps.CRS.Mollweide()) | | - | m2.add_feature.preset.coastline() | | - | m2.add_feature.preset.ocean() | | - | m2.cb.click.attach.mark(radius=20, fc="none", ec="r", lw=2, n=200) | | - | | | - | # Add a "normal" matplotlib plot to the figure | | - | ax = m.f.add_subplot(211) | | - | # Since we want to dynamically update the data on the axis, it must be | | - | # added to the BlitManager to ensure that the artists are properly updated. | | - | # (EOmaps handles interactive re-drawing of the figure) | | - | m.BM.add_artist(ax, layer=m.layer) | | - | | | - | # plot some static data on the axis | | - | ax.plot([10, 20, 30, 40, 50], [10, 20, 30, 40, 50]) | | - | | | - | # define a callback that plots markers on the axis if you click on the map | | - | def cb(pos, **kwargs): | | - | ax.plot(*pos, marker="o") | | - | | | - | m.cb.click.attach(cb) # attach the callback to the first map | | - | m.cb.click.share_events(m2) # share click events between the 2 maps | | - +-------------------------------------------------------------------------------------+------------------------------------------------------+ +.. code-block:: python + :name: test_uniform_colors + from eomaps import Maps + m = Maps() + m.set_data(data=None, x=[10,20,30], y=[10,20,30]) + # Use any of matplotlibs "named colors" + m1 = m.new_layer(copy_data_specs=True) + m1.set_shape.ellipses(radius=10) + m1.plot_map(fc="r", zorder=0) -π„œ MapsGrid objects + m2 = m.new_layer(copy_data_specs=True) + m2.set_shape.ellipses(radius=8) + m2.plot_map(fc="orange", zorder=1) + + # Use RGB or RGBA tuples + m3 = m.new_layer(copy_data_specs=True) + m3.set_shape.ellipses(radius=6) + m3.plot_map(fc=(1, 0, 0.5), zorder=2) + + m4 = m.new_layer(copy_data_specs=True) + m4.set_shape.ellipses(radius=4) + m4.plot_map(fc=(1, 1, 1, .75), zorder=3) + + # For grayscale use a string of a number between 0 and 1 + m5 = m.new_layer(copy_data_specs=True) + m5.set_shape.ellipses(radius=2) + m5.plot_map(fc="0.3", zorder=4) + + +Explicit colors +*************** + +To explicitly color each datapoint with a pre-defined color, simply provide a list or array of the aforementioned types. + +.. code-block:: python + :name: test_explicit_colors + + from eomaps import Maps + + m = Maps() + m.set_data(data=None, x=[10, 20, 30], y=[10, 20, 30]) + + # Use any of matplotlibs "named colors" + # (https://matplotlib.org/stable/gallery/color/named_colors.html) + m1 = m.new_layer(copy_data_specs=True) + m1.set_shape.ellipses(radius=10) + m1.plot_map(fc=["indigo", "g", "orange"], zorder=1) + + # Use RGB tuples + m2 = m.new_layer(copy_data_specs=True) + m2.set_shape.ellipses(radius=6) + m2.plot_map(fc=[(1, 0, 0.5), + (0.3, 0.4, 0.5), + (1, 1, 0)], zorder=2) + + # Use RGBA tuples + m3 = m.new_layer(copy_data_specs=True) + m3.set_shape.ellipses(radius=8) + m3.plot_map(fc=[(1, 0, 0.5, 0.25), + (1, 0, 0.5, 0.75), + (0.1, 0.2, 0.5, 0.5)], zorder=3) + + # For grayscale use a string of a number between 0 and 1 + m4 = m.new_layer(copy_data_specs=True) + m4.set_shape.ellipses(radius=4) + m4.plot_map(fc=[".1", ".2", "0.3"], zorder=4) + +RGB/RGBA composites ******************* -``MapsGrid`` objects can be used to create (and manage) multiple maps in one figure. +To create an RGB or RGBA composite from 3 (or 4) datasets, pass the datasets as tuple: -.. note:: +- the datasets must have the same size as the coordinate arrays! +- the datasets must be scaled between 0 and 1 - While ``MapsGrid`` objects provide some convenience, starting with EOmaps v6.x, - the preferred way of combining multiple maps and/or matplotlib axes in a figure - is by using one of the options presented in the previous sections! +If you pass a tuple of 3 or 4 arrays, they will be used to set the +RGB (or RGBA) colors of the shapes, e.g.: -A ``MapsGrid`` creates a grid of ``Maps`` objects (and/or ordinary ``matpltolib`` axes), -and provides convenience-functions to perform actions on all maps of the figure. +- ``m.plot_map(fc=(, , ))`` +- ``m.plot_map(fc=(, , , ))`` + +You can fix individual color channels by passing a list with 1 element, e.g.: + +- ``m.plot_map(fc=(, [0.12345], , ))`` .. code-block:: python + :name: test_rgba_composites - from eomaps import MapsGrid - mg = MapsGrid(r=2, c=2, crs=..., layer=..., ... ) - # you can then access the individual Maps-objects via: - mg.m_0_0 - mg.m_0_1 - mg.m_1_0 - mg.m_1_1 - - m2 = mg.m_0_0.new_layer("newlayer") - ... + from eomaps import Maps + import numpy as np - # there are many convenience-functions to perform actions on all Maps-objects: - mg.add_feature.preset.coastline() - mg.add_compass() - ... + x, y = np.meshgrid(np.linspace(-20, 40, 100), + np.linspace(50, 70, 50)) - # to perform more complex actions on all Maps-objects, simply loop over the MapsGrid object - for m in mg: - ... + # values must be between 0 and 1 + r = np.random.randint(0, 100, x.shape) / 100 + g = np.random.randint(0, 100, x.shape) / 100 + b = [0.4] + a = np.random.randint(0, 100, x.shape) / 100 - # set the margins of the plot-grid - mg.subplots_adjust(left=0.1, right=0.9, bottom=0.05, top=0.95, hspace=0.1, wspace=0.05) + m = Maps() + m.add_feature.preset.ocean() + m.set_data(data=None, x=x, y=y) + m.plot_map(fc=(r, g, b, a)) -Make sure to checkout the :ref:`layout_editor` which greatly simplifies the arrangement of multiple axes within a figure! -Custom grids and mixed axes -+++++++++++++++++++++++++++ +.. _colorbar: -Fully customized grid-definitions can be specified by providing ``m_inits`` and/or ``ax_inits`` dictionaries -of the following structure: +🌈 Colorbars (with a histogram) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -- The keys of the dictionary are used to identify the objects -- The values of the dictionary are used to identify the position of the associated axes -- The position can be either an integer ``N``, a tuple of integers or slices ``(row, col)`` -- Axes that span over multiple rows or columns, can be specified via ``slice(start, stop)`` +.. currentmodule:: eomaps -.. code-block:: python +Before adding a colorbar, you must plot the data using ``m.plot_map(vmin=..., vmax=...)``. - dict( - name1 = N # position the axis at the Nth grid cell (counting firs) - name2 = (row, col), # position the axis at the (row, col) grid-cell - name3 = (row, slice(col_start, col_end)) # span the axis over multiple columns - name4 = (slice(row_start, row_end), col) # span the axis over multiple rows - ) +- ``vmin`` and ``vmax`` hereby specify the value-range used for assigning colors (e.g. the limits of the colorbar). +- If no explicit limits are provided, the min/max values of the data are used. +- For more details, see :ref:`visualize_data`. -- ``m_inits`` is used to initialize ``Maps`` objects -- ``ax_inits`` is used to initialize ordinary ``matplotlib`` axes +Once a dataset has been plotted, a colorbar with a colored histogram on top can be added to the map by calling :py:meth:`Maps.add_colorbar`. -The individual ``Maps``-objects and ``matpltolib-Axes`` are then accessible via: -.. code-block:: python +.. note:: + | The colorbar always represents the dataset that was used in the last call to :py:meth:`Maps.plot_map`. + | If you need multiple colorbars, use an individual ``Maps`` object for each dataset! (e.g. via ``m2 = m.new_layer()``) - mg = MapsGrid(2, 3, - m_inits=dict(left=(0, 0), right=(0, 2)), - ax_inits=dict(someplot=(1, slice(0, 3))) - ) - mg.m_left # the Maps object with the name "left" - mg.m_right # the Maps object with the name "right" - mg.ax_someplot # the ordinary matplotlib-axis with the name "someplot" +.. note:: + Colorbars are only visible if the layer at which the data was plotted is visible! + .. code-block:: python -❗ NOTE: if ``m_inits`` and/or ``ax_inits`` are provided, ONLY the explicitly defined objects are initialized! + m = Maps(layer=0) + ... + m.plot_map() + m.add_colorbar() # this colorbar is only visible on the layer 0 + m2 = m.new_layer("data") + ... + m2.plot_map() + m2.add_colorbar() # this colorbar is only visible on the "data" layer -- The initialization of the axes is based on matplotlib's `GridSpec `_ functionality. - All additional keyword-arguments (``width_ratios, height_ratios, etc.``) are passed to the initialization of the ``GridSpec`` object. -- To specify unique ``crs`` for each ``Maps`` object, provide a dictionary of ``crs`` specifications. +.. autosummary:: + :toctree: generated + :nosignatures: + :template: only_names_in_toc.rst -.. code-block:: python + Maps.add_colorbar - from eomaps import MapsGrid +.. table:: + :widths: 70 30 + :align: center - # initialize a grid with 2 Maps objects and 1 ordinary matplotlib axes - mgrid = MapsGrid(2, 2, - m_inits=dict(top_row=(0, slice(0, 2)), - bottom_left=(1, 0)), - crs=dict(top_row=4326, - bottom_left=3857), - ax_inits=dict(bottom_right=(1, 1)), - width_ratios=(1, 2), - height_ratios=(2, 1)) + +--------------------------------------------------------------------+------------------------------------------+ + | .. code-block:: python | .. image:: _static/minigifs/colorbar.png | + | | :align: center | + | from eomaps import Maps | | + | import numpy as np | | + | x, y = np.mgrid[-45:45, 20:60] | | + | | | + | m = Maps() | | + | m.add_feature.preset.coastline() | | + | m.set_data(data=x+y, x=x, y=y, crs=4326) | | + | m.set_classify_specs(scheme=Maps.CLASSIFIERS.EqualInterval, k=5) | | + | m.plot_map() | | + | m.add_colorbar(label="what a nice colorbar", hist_bins="bins") | | + | | | + +--------------------------------------------------------------------+------------------------------------------+ - mgrid.m_top_row # a map extending over the entire top-row of the grid (in epsg=4326) - mgrid.m_bottom_left # a map in the bottom left corner of the grid (in epsg=3857) - mgrid.ax_bottom_right # an ordinary matplotlib axes in the bottom right corner of the grid +Once the colorbar has been created, the colorbar-object can be accessed via ``m.colorbar``. +It has the following useful methods defined: -.. currentmodule:: eomaps +.. currentmodule:: eomaps.colorbar .. autosummary:: :toctree: generated :nosignatures: :template: only_names_in_toc.rst - MapsGrid - MapsGrid.join_limits - MapsGrid.share_click_events - MapsGrid.share_pick_events - MapsGrid.set_data_specs - MapsGrid.set_classify_specs - MapsGrid.add_wms - MapsGrid.add_feature - MapsGrid.add_annotation - MapsGrid.add_marker - MapsGrid.add_gdf - - -🧱 Naming conventions and autocompletion -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ColorBar.set_position + ColorBar.set_labels + ColorBar.set_hist_size + ColorBar.tick_params + ColorBar.set_visible + ColorBar.remove -The goal of EOmaps is to provide a comprehensive, yet easy-to-use interface. -To avoid having to remember a lot of names, a concise naming-convention is applied so -that autocompletion can quickly narrow-down the search to relevant functions and properties. +πŸ“Ž Set colorbar tick labels based on bins +***************************************** -Once a few basics keywords have been remembered, finding the right functions and properties should be quick and easy. +.. currentmodule:: eomaps.colorbar -.. note:: +To label the colorbar with custom names for a given set of bins, use :py:meth:`ColorBar.set_bin_labels`: - EOmaps works best in conjunction with "dynamic autocompletion", e.g. by using an interactive - console where you instantiate a ``Maps`` object first and then access dynamically updated properties - and docstrings on the object. ++-------------------------------------------------------------------------------+------------------------------------------------+ +| .. code-block:: python | .. image:: _static/minigifs/colorbar_ticks.png | +| | :align: center | +| import numpy as np | | +| from eomaps import Maps | | +| # specify some random data | | +| lon, lat = np.mgrid[-45:45, -45:45] | | +| data = np.random.normal(0, 50, lon.shape) | | +| | | +| # use a custom set of bins to classify the data | | +| bins = np.array([-50, -30, -20, 20, 30, 40, 50]) | | +| names = np.array(["below -50", "A", "B", "C", "D", "E", "F", "above 50"]) | | +| | | +| m = Maps() | | +| m.add_feature.preset.coastline() | | +| m.set_data(data, lon, lat) | | +| m.set_classify.UserDefined(bins=bins) | | +| m.plot_map(cmap="tab10") | | +| m.add_colorbar() | | +| | | +| # set custom colorbar-ticks based on the bins | | +| m.colorbar.set_bin_labels(bins, names) | | ++-------------------------------------------------------------------------------+------------------------------------------------+ - To clarify: - - First, execute ``m = Maps()`` in an interactive console - - then (inside the console, not inside the editor!) use autocompletion on ``m.`` to get - autocompletion for dynamically updated attributes. +.. autosummary:: + :toctree: generated + :nosignatures: + :template: only_names_in_toc.rst - For example the following accessors only work properly after the ``Maps`` object has been initialized: - - ``m.add_wms``: browse available WebMap services - - ``m.set_classify``: browse available classification schemes + ColorBar.set_bin_labels -The following list provides an overview of the naming-conventions used within EOmaps: -Add features to a map - "m.add\_" -********************************* -All functions that add features to a map start with ``add_``, e.g.: -- ``m.add_feature``, ``m.add_wms``, ``m.add_annotation``, ``m.add_marker``, ``m.add_gdf``, ... +🌠 Using the colorbar as a "dynamic shade indicator" +***************************************************** -WebMap services (e.g. ``m.add_wms``) are fetched dynamically from the respective APIs. -Therefore the structure can vary from one WMS to another. -The used convention is the following: -- You can navigate into the structure of the API by using "dot-access" continuously -- once you reach a level that provides layers that can be added to the map, the ``.add_layer.`` directive will be visible -- any ```` returned by ``.add_layer.`` can be added to the map by simply calling it, e.g.: - - ``m.add_wms.OpenStreetMap.add_layer.default()`` - - ``m.add_wms.OpenStreetMap.OSM_mundialis.add_layer.OSM_WMS()`` +.. note:: -Set data specifications - "m.set\_" -*********************************** -All functions that set properties of the associated dataset start with ``set_``, e.g.: -- ``m.set_data``, ``m.set_classify``, ``m.set_shape``, ... + This will only work if you use ``m.set_shape.shade_raster()`` or ``m.set_shape.shade_points()`` as plot-shape! -Create new Maps-objects - "m.new\_" -*********************************** -Actions that result in a new ``Maps`` objects start with ``new_``. -- ``m.new_layer``, ``m.new_inset_map``, ... -Callbacks - "m.cb." -******************* -Everything related to callbacks is grouped under the ``cb`` accessor. +For shade shapes, the colorbar can be used to indicate the distribution of the shaded +pixels within the current field of view by setting ``dynamic_shade_indicator=True``. -- use ``m.cb..attach.()`` to attach pre-defined callbacks + +--------------------------------------------------------------------+--------------------------------------------------+ + | .. code-block:: python | .. image:: _static/minigifs/dynamic_colorbar.gif | + | | :align: center | + | from eomaps import Maps | | + | import numpy as np | | + | x, y = np.mgrid[-45:45, 20:60] | | + | | | + | m = Maps() | | + | m.add_feature.preset.coastline() | | + | m.set_data(data=x+y, x=x, y=y, crs=4326) | | + | m.set_shape.shade_raster() | | + | m.plot_map() | | + | m.add_colorbar(dynamic_shade_indicator=True, hist_bins=20) | | + | | | + +--------------------------------------------------------------------+--------------------------------------------------+ - - ```` hereby can be one of ``click``, ``pick`` or ``keypress`` - (but there's no need to remember since autocompletion will do the job!). -- use ``m.cb..attach(custom_cb)`` to attach a custom callback .. _companion_widget: @@ -1302,17 +1445,19 @@ a python-script, such as: πŸ›Έ Callbacks - make the map interactive! ---------------------------------------- -Callbacks are used to execute functions when you click on the map. +.. currentmodule:: eomaps + +Callbacks are used to execute functions when you click on the map or press a key on the keyboard. -They can be attached to a map via the ``.attach`` directive: +They can be attached to a :py:class:`Maps` object via: .. code-block:: python m = Maps() ... - m.cb.< METHOD >.attach.< CALLBACK >( **kwargs ) + m.cb.< EVENT >.attach.< CALLBACK >( **kwargs ) -``< METHOD >`` defines the way how callbacks are executed. +``< EVENT >`` specifies the event that will trigger the callback: .. table:: :width: 100 % @@ -1329,9 +1474,9 @@ They can be attached to a map via the ``.attach`` directive: +--------------------------------------------------------------+----------------------------------------------------------------------------------+ -``< CALLBACK >`` indicates the action you want to assign to the event. -There are many pre-defined callbacks, but it is also possible to define custom -functions and attach them to the map (see below). +``< CALLBACK >`` specifies the action you want to assign to the event. + +There are many :ref:`predefined_callbacks`, but it is also possible to define :ref:`custom_callbacks` and attach them to the map. .. table:: @@ -1369,7 +1514,7 @@ functions and attach them to the map (see below). .. Note:: - Callbacks are only executed if the layer of the associated ``Maps`` object is actually visible! + Callbacks are only executed if the layer of the associated :py:class:`Maps` object is actually visible! (This assures that pick-callbacks always refer to the visible dataset.) To define callbacks that are executed independent of the visible layer, attach it to the ``"all"`` @@ -1404,6 +1549,8 @@ In addition, each callback-container supports the following useful methods: add_temporary_artist +.. _predefined_callbacks: + 🍬 Pre-defined callbacks ~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1467,10 +1614,33 @@ Callbacks that can be used with ``m.cb.keypress`` switch_layer fetch_layers -πŸ‘½ Custom callbacks -~~~~~~~~~~~~~~~~~~~ -Custom callback functions can be attached to the map via ``m.cb.< METHOD >.attach(< CALLBACK FUNCTION >, **kwargs)``: +.. _custom_callbacks: + +πŸ‘½ Custom callbacks +~~~~~~~~~~~~~~~~~~~ + +Custom callback functions can be attached to the map via: + +.. code-block:: python + + m = Maps() + ... + m.cb.< EVENT >.attach(< CALLBACK FUNCTION >, **kwargs ) + + +The ``< CALLBACK FUNCTION >`` must accept the following keyword-arguments: + +- ``ID``: The ID of the picked data point + + - The index-value if a ``pandas.DataFrame`` is used as data + - The (flattened) numerical index if a ``list`` or ``numpy.array`` is used as data + +- ``ind``: The (flattened) numerical index (even if ``pandas.DataFrames`` are used) +- ``pos``: The coordinates of the picked data point in the crs of the plot +- ``val``: The value of the picked data point +- ``val_color``: The color of the picked data point + .. code-block:: python @@ -1493,11 +1663,8 @@ Custom callback functions can be attached to the map via ``m.cb.< METHOD >.attac .. note:: - Custom callbacks **must** always accept the following keyword arguments: - ``pos``, ``ID``, ``val``, ``val_color``, ``ind`` - - - ❗ for click callbacks the kwargs ``ID``, ``val`` and ``val_color`` are set to ``None``! - - ❗ for keypress callbacks the kwargs ``ID``, ``val``, ``val_color``, ``ind`` and ``pos`` are set to ``None``! + - ❗ for click callbacks, ``ID``, ``ind``, ``val`` and ``val_color`` are set to ``None``! + - ❗ for keypress callbacks, ``ID``, ``ind``, ``pos`` ,``val`` and ``val_color`` are set to ``None``! For better readability it is recommended that you "unpack" used arguments like this: @@ -1564,18 +1731,18 @@ NOTE: sticky modifiers are defined for each callback method individually! [requires EOmaps >= 5.4] -By default pick-callbacks pick the nearest datapoint with respect to the click position. +By default pick-callbacks pick the nearest data point with respect to the click position. To customize the picking-behavior, use ``m.cb.pick.set_props()``. The following properties can be adjusted: -- ``n``: The (maximum) number of datapoints to pick within the search-circle. +- ``n``: The (maximum) number of data points to pick within the search-circle. - ``search_radius``: The radius of a circle (in units of the plot-crs) that is used to identify the nearest neighbours. - ``pick_relative_to_closest``: Set the center of the search-circle. - - If True, the nearest neighbours are searched relative to the closest identified datapoint. + - If True, the nearest neighbours are searched relative to the closest identified data point. - If False, the nearest neighbours are searched relative to the click position. -- ``consecutive_pick``: Pick datapoints individually or alltogether. +- ``consecutive_pick``: Pick data points individually or altogether. - If True, callbacks are executed for each picked point individually - If False, callbacks are executed only once and get lists of all picked values as input-arguments. @@ -1633,8 +1800,11 @@ To customize the picking-behavior, use ``m.cb.pick.set_props()``. The following πŸ“ Picking a dataset without plotting it first ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. currentmodule:: eomaps + It is possible to attach ``pick`` callbacks to a ``Maps`` object without plotting the data first -by using ``m.make_dataset_pickable()``. +by using :py:meth:`Maps.make_dataset_pickable`. .. code-block:: python @@ -1648,13 +1818,10 @@ by using ``m.make_dataset_pickable()``. .. note:: - Using ``m.make_dataset_pickable()`` is ONLY necessary if you want to use ``pick`` - callbacks without actually plotting the data! Otherwise a call to ``m.plot_map()`` + Using :py:meth:`make_dataset_pickable` is ONLY necessary if you want to use ``pick`` + callbacks without actually plotting the data! Otherwise a call to :py:meth:`Maps.plot_map` is sufficient! - -.. currentmodule:: eomaps - .. autosummary:: :toctree: generated :nosignatures: @@ -1668,7 +1835,9 @@ by using ``m.make_dataset_pickable()``. πŸ›° WebMap layers ---------------- -WebMap services (TS/WMS/WMTS) can be attached to the map via: +.. currentmodule:: eomaps + +WebMap services (TS/WMS/WMTS) can be attached to the map via :py:meth:`Maps.add_wms` .. code-block:: python @@ -1683,7 +1852,6 @@ and ``< LAYER >`` indicates the actual layer-name. m = Maps(Maps.CRS.GOOGLE_MERCATOR) # (the native crs of the service) m.add_wms.OpenStreetMap.add_layer.default() -.. currentmodule:: eomaps .. autosummary:: :toctree: generated @@ -1783,11 +1951,11 @@ Pre-defined WebMap services: Using custom WebMap services ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -It is also possible to use custom WMS/WMTS/XYZ services. -(see docstring of ``m.add_wms.get_service`` for more details and examples) - .. currentmodule:: eomaps._webmap_containers.wms_container +It is also possible to use custom WMS/WMTS/XYZ services. +(see docstring of :py:meth:`get_service` for more details and examples) + .. autosummary:: :toctree: generated :nosignatures: @@ -1881,10 +2049,10 @@ To show an example, here's how to fetch multiple timestamps for the UV-index of 🌡 NaturalEarth features ------------------------ -Feature-layers provided by `NaturalEarth `_ can be directly added to the map via ``m.add_feature``. - .. currentmodule:: eomaps +Feature-layers provided by `NaturalEarth `_ can be directly added to the map via :py:meth:`Maps.add_feature`. + .. autosummary:: :toctree: generated :nosignatures: @@ -1961,16 +2129,24 @@ The most commonly used features are accessible with pre-defined colors via the ` .. _geodataframe: -πŸ’  GeoDataFrames ------------------ +πŸ’  Vector Data (or GeoDataFrames) +---------------------------------- -A ``geopandas.GeoDataFrame`` can be added to the map via ``m.add_gdf()``. +.. currentmodule:: eomaps -- This is basically just a wrapper for the plotting capabilities of ``geopandas`` (e.g. ``gdf.plot(...)`` ) - supercharged with EOmaps features. +For vector data visualization, EOmaps utilizes the plotting capabilities of `geopandas `_ . + +A ``geopandas.GeoDataFrame`` can be added to the map via :py:meth:`Maps.add_gdf`. +This is basically just a wrapper for the plotting capabilities of `geopandas `_ +(e.g. `GeoDataFrame.plot(...) `_ ) +supercharged with EOmaps features. + +- If you provide a string or `pathlib.Path` object to :py:meth:`Maps.add_gdf`, the contents of the file will be read into a ``GeoDataFrame`` + via `geopandas.read_file() `_. + + - Many file-types such as *shapefile*, *GeoPackage*, *geojson* ... are supported! -.. currentmodule:: eomaps .. autosummary:: :toctree: generated @@ -2039,7 +2215,9 @@ Once the ``picker_name`` is specified, pick-callbacks can be attached via: πŸ”΄ Markers ~~~~~~~~~~~ -Static markers can be added to the map via ``m.add_marker()``. +.. currentmodule:: eomaps + +Static markers can be added to the map via :py:meth:`Maps.add_marker`. - If a dataset has been plotted, you can mark any datapoint via its ID, e.g. ``ID=...`` - To add a marker at an arbitrary position, use ``xy=(...)`` @@ -2065,8 +2243,6 @@ Static markers can be added to the map via ``m.add_marker()``. πŸ›Έ For dynamic markers checkout ``m.cb.click.attach.mark()`` or ``m.cb.pick.attach.mark()`` -.. currentmodule:: eomaps - .. autosummary:: :toctree: generated :nosignatures: @@ -2121,20 +2297,22 @@ Static markers can be added to the map via ``m.add_marker()``. πŸ“‘ Annotations ~~~~~~~~~~~~~~ -Static annotations can be added to the map via ``m.add_annotation()``. +.. currentmodule:: eomaps + +Static annotations can be added to the map via :py:meth:`Maps.add_annotation`. -- The location is defined completely similar to ``m.add_marker()`` above. +- The location is defined completely similar to :py:meth:`m.add_marker` above. - You can annotate a datapoint via its ID, or arbitrary coordinates in any crs. -- Additional arguments are passed to matplotlibs ``plt.annotate`` and ``plt.text`` +- Additional arguments are passed to `matplotlib.pyplot.annotate `_ + and `matplotlib.pyplot.text `_ - This gives a lot of flexibility to style the annotations! πŸ›Έ For dynamic annotations checkout ``m.cb.click.attach.annotate()`` or ``m.cb.pick.attach.annotate()`` -.. currentmodule:: eomaps .. autosummary:: :toctree: generated @@ -2204,7 +2382,9 @@ Static annotations can be added to the map via ``m.add_annotation()``. 🚲 Lines ~~~~~~~~~ -Lines can be added to a map with ``m.add_line()``. +.. currentmodule:: eomaps + +Lines can be added to a map with :py:meth:`Maps.add_line`. - A line is defined by a list of **anchor-points** and a **connection-method** @@ -2217,7 +2397,7 @@ Lines can be added to a map with ``m.add_line()``. - use ``n=10`` to calculate 10 intermediate points between each anchor-point - or use ``del_s=1000`` to calculate intermediate points (approximately) every 1000 meters - - check the return-values of ``m.add_line()`` to get the actual distances used in each line-segment + - check the return-values of :py:meth:`Maps.add_line` to get the actual distances used in each line-segment - ``connect="straight"``: connect points via **straight lines** - ``connect="straight_crs"``: connect points with reprojected lines that are **straight in a given projection** @@ -2225,11 +2405,10 @@ Lines can be added to a map with ``m.add_line()``. - use ``n=10`` to calculate 10 (equally-spaced) intermediate points between each anchor-point -- Additional keyword-arguments are passed to matpltolib's ``plt.plot`` +- Additional keyword-arguments are passed to `matplotlib.pyplot.plot `_ - This gives a lot of flexibility to style the lines! -.. currentmodule:: eomaps .. autosummary:: :toctree: generated @@ -2278,10 +2457,10 @@ Lines can be added to a map with ``m.add_line()``. β–­ Rectangular areas ~~~~~~~~~~~~~~~~~~~ -To indicate rectangular areas in any given crs, simply use ``m.indicate_extent``: - .. currentmodule:: eomaps +To indicate rectangular areas in any given crs, simply use :py:meth:`Maps.indicate_extent`: + .. autosummary:: :toctree: generated :nosignatures: @@ -2324,7 +2503,9 @@ To indicate rectangular areas in any given crs, simply use ``m.indicate_extent`` πŸ₯¦ Logos ~~~~~~~~ -To add a logo (or basically any image file ``.png``, ``.jpeg`` etc.) to the map, you can use ``m.add_logo``. +.. currentmodule:: eomaps + +To add a logo (or basically any image file ``.png``, ``.jpeg`` etc.) to the map, you can use :py:meth:`Maps.add_logo`. Logos can be re-positioned and re-sized with the :ref:`layout_editor`! @@ -2344,7 +2525,6 @@ Logos can be re-positioned and re-sized with the :ref:`layout_editor`! | m.add_logo(position="ll", size=.4, fix_position=True) | | +--------------------------------------------------------------------------------------------+---------------------------------------+ -.. currentmodule:: eomaps .. autosummary:: :toctree: generated @@ -2354,164 +2534,14 @@ Logos can be re-positioned and re-sized with the :ref:`layout_editor`! Maps.add_logo -.. _colorbar: - -🌈 Colorbars (with a histogram) -------------------------------- - -Before adding a colorbar, you must plot the data using ``m.plot_map(vmin=..., vmax=...)``. - -- ``vmin`` and ``vmax`` hereby specify the value-range used for assigning colors (e.g. the limits of the colorbar). -- If no explicit limits are provided, the min/max values of the data are used. - -Once a dataset has been plotted, a colorbar with a colored histogram on top can be added to the map by calling ``m.add_colorbar()``. - - -.. note:: - | The colorbar always represents the dataset that was used in the last call to ``m.plot_map()``. - | If you need multiple colorbars, use an individual ``Maps`` object for each dataset! (e.g. via ``m2 = m.new_layer()``) - - -.. note:: - Colorbars are only visible if the layer at which the data was plotted is visible! - - .. code-block:: python - - m = Maps(layer=0) - ... - m.plot_map() - m.add_colorbar() # this colorbar is only visible on the layer 0 - - m2 = m.new_layer("data") - ... - m2.plot_map() - m2.add_colorbar() # this colorbar is only visible on the "data" layer - - -.. currentmodule:: eomaps - -.. autosummary:: - :toctree: generated - :nosignatures: - :template: only_names_in_toc.rst - - Maps.add_colorbar - -.. table:: - :widths: 70 30 - :align: center - - +--------------------------------------------------------------------+------------------------------------------+ - | .. code-block:: python | .. image:: _static/minigifs/colorbar.png | - | | :align: center | - | from eomaps import Maps | | - | import numpy as np | | - | x, y = np.mgrid[-45:45, 20:60] | | - | | | - | m = Maps() | | - | m.add_feature.preset.coastline() | | - | m.set_data(data=x+y, x=x, y=y, crs=4326) | | - | m.set_classify_specs(scheme=Maps.CLASSIFIERS.EqualInterval, k=5) | | - | m.plot_map() | | - | m.add_colorbar(label="what a nice colorbar", hist_bins="bins") | | - | | | - +--------------------------------------------------------------------+------------------------------------------+ - - - -Once the colorbar has been created, the colorbar-object can be accessed via ``m.colorbar``. -It has the following useful methods defined: - -.. currentmodule:: eomaps.colorbar - -.. autosummary:: - :toctree: generated - :nosignatures: - :template: only_names_in_toc.rst - - ColorBar.set_position - ColorBar.set_hist_size - ColorBar.tick_params - ColorBar.set_visible - ColorBar.remove - - -πŸ“Ž Set colorbar tick labels based on bins -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -To label the colorbar with custom names for a given set of bins, use ``m.colorbar.set_bin_labels()``: - -+-------------------------------------------------------------------------------+------------------------------------------------+ -| .. code-block:: python | .. image:: _static/minigifs/colorbar_ticks.png | -| | :align: center | -| import numpy as np | | -| from eomaps import Maps | | -| # specify some random data | | -| lon, lat = np.mgrid[-45:45, -45:45] | | -| data = np.random.normal(0, 50, lon.shape) | | -| | | -| # use a custom set of bins to classify the data | | -| bins = np.array([-50, -30, -20, 20, 30, 40, 50]) | | -| names = np.array(["below -50", "A", "B", "C", "D", "E", "F", "above 50"]) | | -| | | -| m = Maps() | | -| m.add_feature.preset.coastline() | | -| m.set_data(data, lon, lat) | | -| m.set_classify.UserDefined(bins=bins) | | -| m.plot_map(cmap="tab10") | | -| m.add_colorbar() | | -| | | -| # set custom colorbar-ticks based on the bins | | -| m.colorbar.set_bin_labels(bins, names) | | -+-------------------------------------------------------------------------------+------------------------------------------------+ - - -.. currentmodule:: eomaps.colorbar - -.. autosummary:: - :toctree: generated - :nosignatures: - :template: only_names_in_toc.rst - - ColorBar.set_bin_labels - - - -🌠 Using the colorbar as a "dynamic shade indicator" -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - -.. note:: - - This will only work if you use ``m.set_shape.shade_raster()`` or ``m.set_shape.shade_points()`` as plot-shape! - - -For shade shapes, the colorbar can be used to indicate the distribution of the shaded -pixels within the current field of view by setting ``dynamic_shade_indicator=True``. - - +--------------------------------------------------------------------+--------------------------------------------------+ - | .. code-block:: python | .. image:: _static/minigifs/dynamic_colorbar.gif | - | | :align: center | - | from eomaps import Maps | | - | import numpy as np | | - | x, y = np.mgrid[-45:45, 20:60] | | - | | | - | m = Maps() | | - | m.add_feature.preset.coastline() | | - | m.set_data(data=x+y, x=x, y=y, crs=4326) | | - | m.set_shape.shade_raster() | | - | m.plot_map() | | - | m.add_colorbar(dynamic_shade_indicator=True, hist_bins=20) | | - | | | - +--------------------------------------------------------------------+--------------------------------------------------+ - - .. _scalebar: πŸ“ Scalebars ------------ -A scalebar can be added to a map via ``s = m.add_scalebar()``. +.. currentmodule:: eomaps + +A scalebar can be added to a map via :py:meth:`Maps.add_scalebar`. - By default, the scalebar will **dynamically estimate an appropriate scale and position** based on the currently visible map extent. @@ -2523,7 +2553,6 @@ In addition, many style properties of the scalebar can be adjusted to get the lo - check the associated setter-functions ``ScaleBar.set_< label / scale / lines / labels >_props`` below! -.. currentmodule:: eomaps .. autosummary:: :toctree: generated @@ -2600,8 +2629,9 @@ The returned ``ScaleBar`` object provides the following useful methods: 🧭 Compass (or North Arrow) --------------------------- +.. currentmodule:: eomaps -A compass can be added to the map via ``m.add_compass()``: +A compass can be added to the map via :py:meth:`Maps.add_compass`: - To add a **North-Arrow**, use ``m.add_compass(style="north arrow")`` @@ -2619,7 +2649,6 @@ A compass can be added to the map via ``m.add_compass()``: -.. currentmodule:: eomaps .. autosummary:: :toctree: generated @@ -2667,7 +2696,9 @@ The returned ``compass`` object has the following useful methods assigned: β–¦ Gridlines ------------ -Gridlines can be added to the map via ``m.add_gridlines()``. +.. currentmodule:: eomaps + +Gridlines can be added to the map via :py:meth:`Maps.add_gridlines`. If ``d`` is provided, the gridlines will be **fixed** @@ -2738,21 +2769,73 @@ useful methods: GridLines.set_bounds GridLines.update_line_props GridLines.remove + GridLines.add_labels +.. _add_labels_to_the_grid: +✍ Add Labels to the Grid +~~~~~~~~~~~~~~~~~~~~~~~~~~ +Labels can be added to a grid via the :py:meth:`GridLines.add_labels` directive. +In general, labels are added at points where the lines of the grid intersects with the axis-boundary. +(Note that this provides a lot of flexibility since a map can have as many grids as you like and each grid can have its own labels!) -.. _utility: +The ``where`` parameter can be used to **control where grid labels are added**: + +- Use an arbitrary combination of the letters ``"tblr"`` to draw labels at the top, bottom, left or right boundaries. + + - If this option is used, longitude-lines are only labeled top/bottom and latitude-lines are only labeled left/right. + +- Use ``"all"`` to label all intersection points. +- Use an integer to draw labels only at the nth found intersection-points. + +In addition, the ``exclude`` parameter can be used to exclude specific labels based on their lon/lat values and the ``every`` parameter can +be used to add a label only to every n\ :sup:`th` grid line. + +To **change the appearance of the labels**, any kwarg supported by `matplotlib.pyplot.text `_ +can be used (e.g. `color`, `fontsize`, `fontweight`, ...). + +.. table:: + :widths: 50 50 + :align: center + + +--------------------------------------------------------------------------+------------------------------------------------+ + | .. code-block:: python | .. image:: _static/minigifs/grid_labels_01.png | + | :name: test_grid_labels_01 | :align: center | + | | | + | from eomaps import Maps | | + | m = Maps(Maps.CRS.Stereographic(), figsize=(5, 6)) | | + | m.set_extent((-83, -20, -59, 13)) | | + | m.add_feature.preset.coastline() | | + | m.add_feature.preset.ocean() | | + | | | + | # draw a regular grid with 10 degree grid-spacing | | + | # and add labels to all lines except some selected lines | | + | g = m.add_gridlines(10, lw=0.25, alpha=0.5) | | + | g.add_labels(fontsize=6, exclude=([-40, -30], [-30])) | | + | | | + | # draw some specific gridlines and add bold green labels | | + | g = m.add_gridlines(([-40, -30], [-30]), c="g", lw=1.5) | | + | gl0 = g.add_labels(where="tlr", c="g", offset=15, fontweight="bold") | | + | | | + | # draw a bounded grid and add labels | | + | g = m.add_gridlines(10, bounds=[-50, -20, -40, -20], c="b", lw=2) | | + | g = m.add_gridlines(5, bounds=[-50, -20, -40, -20], c="b") | | + | gl = g.add_labels(where=0, fontsize=8, every=(1, -1, 2), c="b") | | + +--------------------------------------------------------------------------+------------------------------------------------+ +.. _utility: + 🦜 Utility widgets ------------------ -Some helpful utility widgets can be added to a map via ``m.util.<...>`` - .. currentmodule:: eomaps +Some helpful utility widgets can be added to a map via :py:class:`Maps.util`. + + .. autosummary:: :toctree: generated :nosignatures: @@ -2763,10 +2846,12 @@ Some helpful utility widgets can be added to a map via ``m.util.<...>`` Layer switching ~~~~~~~~~~~~~~~ +.. currentmodule:: eomaps.utilities + To simplify switching between layers, there are currently 2 widgets available: -- ``m.util.layer_selector()`` : Add a set of clickable buttons to the map that activates the corresponding layers. -- ``m.util.layer_slider()`` : Add a slider to the map that iterates through the available layers. +- ``m.util.layer_selector()`` : Add a set of clickable :py:class:`LayerSelector` buttons to the map that activates the corresponding layers. +- ``m.util.layer_slider()`` : Add a :py:class:`LayerSlider` to the map that iterates through the available layers. By default, the widgets will show all available layers (except the "all" layer) and the widget will be **automatically updated** whenever a new layer is created on the map. @@ -2775,7 +2860,6 @@ By default, the widgets will show all available layers (except the "all" layer) - To exclude certain layers from the widget, use ``exclude_layers=[...layer-names to exclude...]`` - To remove a previously created widget ``s`` from the map, simply use ``s.remove()`` - .. currentmodule:: eomaps.utilities.utilities .. autosummary:: @@ -2808,7 +2892,7 @@ By default, the widgets will show all available layers (except the "all" layer) πŸ”¬ Inset-maps - zoom-in on interesting areas -------------------------------------------- -Inset maps that show zoomed-in regions can be created with ``m.new_inset_map()``. +Inset maps that show zoomed-in regions can be created with :py:meth:`Maps.new_inset_map`. .. code-block:: python @@ -2827,13 +2911,20 @@ Inset maps that show zoomed-in regions can be created with ``m.new_inset_map()`` For convenience, inset-map objects have the following special methods defined: -- ``m.set_inset_position()``: Set the size and (center) position of the inset-map relative to the figure size. -- ``m.indicate_inset_extent()``: Indicate the extent of the inset-map on arbitrary Maps-objects. +.. currentmodule:: eomaps.eomaps + +.. autosummary:: + :toctree: generated + :nosignatures: + :template: only_names_in_toc.rst + InsetMaps.set_inset_position + InsetMaps.indicate_inset_extent + InsetMaps.add_indicator_line Checkout the associated example on how to use inset-maps: :ref:`EOmaps_examples_inset_maps` -Make sure to checkout the :ref:`layout_editor` which can be used to quickly re-position (and re-size) inset-maps with the mouse! +To quickly re-position (and re-size) inset-maps, have a look at the :ref:`layout_editor`! .. table:: :widths: 60 40 @@ -2841,7 +2932,9 @@ Make sure to checkout the :ref:`layout_editor` which can be used to quickly re-p +-----------------------------------------------------------------+--------------------------------------------+ | .. code-block:: python | .. image:: _static/minigifs/inset_maps.png | - | | :align: center | + | :name: test_inset_maps_01 | :align: center | + | | | + | from eomaps import Maps | | | m = Maps(Maps.CRS.PlateCarree(central_longitude=-60)) | | | m.add_feature.preset.ocean() | | | | | @@ -2851,10 +2944,11 @@ Make sure to checkout the :ref:`layout_editor` which can be used to quickly re-p | indicate_extent=dict(fc=(1,0,0,.5), | | | ec="r", lw=1) | | | ) | | + | m_i.add_indicator_line(m, c="r") | | + | | | | m_i.add_feature.preset.coastline() | | | m_i.add_feature.preset.countries() | | | m_i.add_feature.preset.ocean() | | - | | | | m_i.add_feature.cultural.urban_areas(fc="r", scale=10) | | | m_i.add_feature.physical.rivers_europe(ec="b", lw=0.25, | | | fc="none", scale=10) | | @@ -2872,11 +2966,58 @@ Make sure to checkout the :ref:`layout_editor` which can be used to quickly re-p new_inset_map +.. _zoomed_in_views_on_datasets: + +πŸ”Ž Zoomed in views on datasets +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. currentmodule:: eomaps + +To simplify the creation of "zoomed-in" views on datasets, both the data and the classification +of the data must be the same. + +For this purpose, EOmaps provides 2 convenience-functions: + +- :py:meth:`Maps.inherit_data` : Use the same dataset as another :py:class:`Maps` object +- :py:meth:`Maps.inherit_classification`: Use the same classification as another :py:class:`Maps` object + + - Note that this means that the classification specs as well as ``vmin``, ``vmax`` and the used ``colormap`` will be the same! + + +.. code-block:: python + :name: test_zoomed_in_data_maps + + from eomaps import Maps + import numpy as np + + x, y = np.meshgrid(np.linspace(-20, 20, 50), np.linspace(-50, 60, 100)) + data = x + y + + m = Maps(ax=131) + m.set_data(data, x, y) + m.set_shape.raster() + m.set_classify.Quantiles(k=10) + m.plot_map(cmap="tab10", vmin=-10, vmax=40) + + # Create a new inset-map that shows a zoomed-in view on a given dataset + m_inset = m.new_inset_map(xy=(5, 20), radius=8, plot_position=(0.75, .5)) + + # inherit both the data and the classification specs from "m" + m_inset.inherit_data(m) + m_inset.inherit_classification(m) + + m_inset.set_shape.rectangles() + m_inset.plot_map(ec="k", lw=0.25) + + .. _shape_drawer: ✏️ Draw Shapes on the map ------------------------- -Starting with EOmaps v5.0 it is possible to draw simple shapes on the map using ``m.draw``. + +.. currentmodule:: eomaps + +Starting with EOmaps v5.0 it is possible to draw simple shapes on the map using :py:class:`Maps.draw`. - | The shapes can be saved to disk as geo-coded shapefiles using ``m.draw.save_shapes(filepath)``. | (Saving shapes requires the ``geopandas`` module!) @@ -2964,11 +3105,13 @@ You can use it to simply drag the axes the mouse to the desired locations and ch Save and restore layouts ~~~~~~~~~~~~~~~~~~~~~~~~ +.. currentmodule:: eomaps + Once a layout (e.g. the desired position of the axes within a figure) has been arranged, the layout can be saved and re-applied with: -- 🌟 ``m.get_layout()``: get the current layout (or dump the layout as a json-file) -- 🌟 ``m.apply_layout()``: apply a given layout (or load and apply the layout from a json-file) +- 🌟 :py:meth:`Maps.get_layout`: get the current layout (or dump the layout as a json-file) +- 🌟 :py:meth:`Maps.apply_layout`: apply a given layout (or load and apply the layout from a json-file) It is also possible to enter the **Layout Editor** and save the layout automatically on exit with: @@ -2999,9 +3142,11 @@ It is also possible to enter the **Layout Editor** and save the layout automatic πŸ“¦ Reading data (NetCDF, GeoTIFF, CSV...) ----------------------------------------- +.. currentmodule:: eomaps + EOmaps provides some basic capabilities to read and plot directly from commonly used file-types. -By default, the ``Maps.from_file`` and ``m.new_layer_from_file`` functions try to plot the data +By default, :py:class:`Maps.from_file` and :py:class:`Maps.new_layer_from_file` will attempt to plot the data with ``shade_raster`` (if it fails it will fallback to ``shade_points`` and finally to ``ellipses``). @@ -3016,6 +3161,16 @@ with ``shade_raster`` (if it fails it will fallback to ``shade_points`` and fina - NetCDF (``xarray.open_dataset()``) - CSV (``pandas.read_csv()``) +.. currentmodule:: eomaps + +.. autosummary:: + :toctree: generated + :nosignatures: + :template: only_names_in_toc.rst + + + Maps.from_file + Maps.new_layer_from_file Read relevant data from a file ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -3094,8 +3249,6 @@ Similar to ``Maps.from_file``, a new layer based on a file can be added to an ex cmap="RdBu" ) -.. currentmodule:: eomaps - .. currentmodule:: eomaps.reader .. autosummary:: diff --git a/eomaps/_version.py b/eomaps/_version.py index 6b5a92b66..cb79f6b5c 100644 --- a/eomaps/_version.py +++ b/eomaps/_version.py @@ -1 +1 @@ -__version__ = "6.4.1" +__version__ = "6.5" diff --git a/eomaps/callbacks.py b/eomaps/callbacks.py index aa60a89e6..aac06c0b4 100644 --- a/eomaps/callbacks.py +++ b/eomaps/callbacks.py @@ -132,10 +132,8 @@ def print_to_console(self, **kwargs): printstr = ( "---------------\n" - f"x = {self._fmt(pos[0], **kwargs)}\n" - f"y = {self._fmt(pos[1], **kwargs)}\n" - f"lon = {self._fmt(lon, **kwargs)}\n" - f"lat = {self._fmt(lat, **kwargs)}" + f"xy = ({self._fmt(pos[0], **kwargs)}, {self._fmt(pos[1], **kwargs)})\n" + f"lonlat = ({self._fmt(lon, **kwargs)}, {self._fmt(lat, **kwargs)})\n" ) print(printstr) diff --git a/eomaps/colorbar.py b/eomaps/colorbar.py index a64353fa0..5323c2283 100644 --- a/eomaps/colorbar.py +++ b/eomaps/colorbar.py @@ -222,7 +222,10 @@ def __init__( A dictionary with keyword-arguments passed to the creation of the histogram (e.g. passed to `plt.hist()` ) label : str, optional - The label used for the colorbar. The default is None + The label used for the colorbar. + Use `ColorBar.set_labels()` to set the labels (and styling) for the + colorbar and the histogram. + The default is None. ylabel : str, optional The label used for the y-axis of the colorbar. The default is None kwargs : @@ -231,8 +234,7 @@ def __init__( See Also -------- - - - label_bin_center + set_bin_labels: Use custom names for classified colorbar bins. Examples -------- @@ -256,6 +258,7 @@ def __init__( self._m = m self._pos = pos self._margin = margin + self._orientation = orientation self._init_extend = extend self._extend_frac = extend_frac @@ -284,8 +287,9 @@ def __init__( self._histogram_plotted = False # indicator if histogram has been plotted - self._orientation = orientation self._dynamic_shade_indicator = dynamic_shade_indicator + self._hist_label_kwargs = None + self._show_outline = show_outline self._tick_precision = tick_precision self._tick_formatter = tick_formatter @@ -321,7 +325,7 @@ def __init__( def set_visible(self, vis): """ - Set the visibility of the colorbar + Set the visibility of the colorbar. Parameters ---------- @@ -339,6 +343,68 @@ def set_visible(self, vis): else: self.ax_cb_plot.set_visible(vis) + def _set_labels(self, cb_label=None, hist_label=None, **kwargs): + if self._dynamic_shade_indicator and hist_label is not None: + # remember kwargs to re-draw the histogram + self._hist_label_kwargs = { + "cb_label": None, + "hist_label": hist_label, + **kwargs, + } + + if self._orientation == "horizontal": + if cb_label: + self._cb_label = self.ax_cb.set_xlabel(cb_label, **kwargs) + if hist_label: + self._hist_label = self.ax_cb_plot.set_ylabel(hist_label, **kwargs) + else: + if cb_label: + self._cb_label = self.ax_cb.set_ylabel(cb_label, **kwargs) + if hist_label: + self._hist_label = self.ax_cb_plot.set_xlabel(hist_label, **kwargs) + + def set_labels(self, cb_label=None, hist_label=None, **kwargs): + """ + Set the labels (and the styling) for the colorbar (and the histogram). + + For more details, see `ColorBar.ax_cb.set_xlabel(..)` and matplotlib's `.Text` + properties. + + Parameters + ---------- + cb_label : str or None + The label of the colorbar. If None, the existing label is maintained. + The default is None. + hist_label : str or None + The label of the histogram. If None, the existing label is maintained. + The default is None. + + Other Parameters + ---------------- + kwargs : + Additional kwargs passed to `Axes.set_xlabel` to control the appearance of + the label (e.g. color, fontsize, labelpad etc.). + + Examples + -------- + Set both colorbar and histogram label in one go + + >>> cb.set_labels("The parameter", "histogram count", fontsize=10, color="r") + + Use different styles for the colorbar and histogram labels + + >>> cb.set_labels(cb_label="The parameter", color="r", labelpad=10) + >>> cb.set_labels(hist_label="histogram count", fontsize=6, color="k") + + """ + self._set_labels(cb_label=cb_label, hist_label=hist_label, **kwargs) + + if not self._dynamic_shade_indicator: + # no need to redraw the background for dynamically updated artists + self._m.redraw(self._m.layer) + else: + self._m.BM.update() + def _default_cb_tick_formatter(self, x, pos, precision=None): """ A formatter to format the tick-labels of the colorbar for encoded datasets. @@ -447,8 +513,10 @@ def _identify_parent_cb(self): if m.colorbar._parent_cb is None: parent_cb = m.colorbar break - - return parent_cb + if parent_cb and parent_cb._orientation == self._orientation: + return parent_cb + else: + return None def _get_parent_cb(self): if self._parent_cb is None: @@ -825,9 +893,7 @@ def _plot_colorbar(self): # ensure that ticklabels are correct if a classification is used if self._classified and "ticks" not in self._kwargs: - self.cb.set_ticks( - [i for i in self._bins if i >= self._vmin and i <= self._vmax] - ) + self.cb.set_ticks(np.unique(np.clip(self._bins, self._vmin, self._vmax))) if self._tick_formatter is None: self._tick_formatter = self._classified_cb_tick_formatter @@ -1019,6 +1085,9 @@ def _redraw_colorbar(self, *args, **kwargs): self.ax_cb_plot.clear() self._plot_histogram() + if self._hist_label_kwargs: + self._set_labels(**self._hist_label_kwargs) + def set_bin_labels(self, bins, names, tick_lines="center", show_values=False): """ Set the tick-labels of the colorbar to custom names with respect to a given @@ -1147,6 +1216,7 @@ def set_bin_labels(self, bins, names, tick_lines="center", show_values=False): self.ax_cb.tick_params( labelright=True, which="minor", labelsize="xx-small" ) + else: if horizontal: self.ax_cb.tick_params( @@ -1160,9 +1230,18 @@ def set_bin_labels(self, bins, names, tick_lines="center", show_values=False): which="minor", ) - self.ax_cb_plot.tick_params( - right=False, bottom=False, labelright=False, labelbottom=False, which="both" - ) + if horizontal: + self.ax_cb_plot.tick_params( + right=False, + bottom=False, + labelright=False, + labelbottom=False, + which="both", + ) + else: + self.ax_cb_plot.tick_params( + left=False, top=False, labelleft=False, labeltop=False, which="both" + ) self._m.BM._refetch_layer(self._m.layer) @@ -1203,6 +1282,7 @@ def tick_params(self, what="colorbar", **kwargs): self._m.redraw(self._m.layer) tick_params.__doc__ = ( + "Set the appearance of the colorbar (or histogram) ticks.\n\n" "NOTE\n" "----\n" "This is a wrapper for `m.colorbar.ax_cb.tick_params` or " diff --git a/eomaps/compass.py b/eomaps/compass.py index 77b406e8f..3efffd750 100644 --- a/eomaps/compass.py +++ b/eomaps/compass.py @@ -18,6 +18,9 @@ def __init__(self, m): self._patch = False self._txt = "N" + self._last_patch_ec = None + self._last_patch_lw = None + def __call__( self, pos=None, @@ -67,8 +70,8 @@ def __call__( The default is "compass". patch : False, str or tuple, optional - The color of the background-patch. - (can be any color specification supported by matplotlib) + The color of the background-patch (can be any color specification supported + by matplotlib). See `Compass.set_patch(...)` for more styling options. The default is "w". txt : str, optional Indicator which directions should be indicated. @@ -153,6 +156,8 @@ def __call__( if self._update_offset not in self._m.BM._before_fetch_bg_actions: self._m.BM._before_fetch_bg_actions.append(self._update_offset) + self._m.BM.update() + def _get_artist(self, pos): if self._style == "north arrow": bg_patch = PolyCollection( @@ -319,6 +324,8 @@ def _on_scroll(self, event): if self._check_still_parented() and self._got_artist: self.set_scale(max(1, self._scale + event.step)) + self._m.BM.update(artists=[self._artist]) + def _on_pick(self, evt): if not self._layer_visible: return @@ -331,6 +338,12 @@ def _on_pick(self, evt): self._c1 = self._canvas.mpl_connect("motion_notify_event", self._on_motion) self._c2 = self._canvas.mpl_connect("key_press_event", self._on_keypress) + # make red 1pt edgecolor while compass is picked + self._last_patch_ec = self._artist.get_children()[0].get_edgecolor() + self._last_patch_lw = self._artist.get_children()[0].get_linewidth()[0] + + self.set_patch(edgecolor="r", linewidth=1) + def _on_keypress(self, event): if not self._layer_visible: return @@ -361,6 +374,12 @@ def _on_release(self, event): pass else: self._canvas.mpl_disconnect(c2) + + self.set_patch( + edgecolor=self._last_patch_ec, + linewidth=self._last_patch_lw, + ) + self._m.BM.update() def _check_still_parented(self): diff --git a/eomaps/eomaps.py b/eomaps/eomaps.py index 07c4381df..22b734403 100644 --- a/eomaps/eomaps.py +++ b/eomaps/eomaps.py @@ -819,6 +819,7 @@ def new_inset_map( background_color="w", shape="ellipses", indicate_extent=True, + indicator_line=False, ): """ Create a new (empty) inset-map that shows a zoomed-in view on a given extent. @@ -875,10 +876,12 @@ def new_inset_map( The layer associated with the inset-map. If None (the default), the layer of the Maps-object used to create the inset-map is used. - boundary: bool or dict, optional + boundary: bool, str or dict, optional - If True: indicate the boundary of the inset-map with default colors (e.g.: {"ec":"r", "lw":2}) - If False: don't add edgecolors to the boundary of the inset-map + - If a string is provided, it is identified as the edge-color of the + boundary (e.g. any named matplotlib color like "r", "g", "darkblue"...) - if dict: use the provided values for "ec" (e.g. edgecolor) and "lw" (e.g. linewidth) @@ -903,6 +906,18 @@ def new_inset_map( NOTE: you can also use `m_inset.indicate_inset_extent(...)` to manually indicate the inset-shape on arbitrary Maps-objects. + The default is True. + indicator_line : bool or dict, optional + + - If True: add a line that connects the inset-map to the indicated extent + on the parent map + - If a dict is provided, it is used to update the appearance of the line + (e.g. c="r", lw=2, ...) + + NOTE: you can also use `m_inset.add_indicator_line(...)` to manually + indicate the inset-shape on arbitrary Maps-objects. + + The default is False. Returns ------- m : eomaps.Maps @@ -911,7 +926,7 @@ def new_inset_map( See Also -------- - The following additional methods are defined on `_InsetMaps` objects + The following additional methods are defined on `InsetMaps` objects m.indicate_inset_extent : Plot a polygon representing the extent of the inset map on another Maps @@ -968,7 +983,7 @@ def new_inset_map( >>> m.util.layer_selector() """ - m2 = _InsetMaps( + m2 = InsetMaps( parent=self, crs=inset_crs, layer=layer, @@ -982,6 +997,7 @@ def new_inset_map( background_color=background_color, shape=shape, indicate_extent=indicate_extent, + indicator_line=indicator_line, ) return m2 @@ -1145,7 +1161,44 @@ def set_data_specs( @property def set_classify(self): - """Accessor to set the data-classification.""" + """ + Interface to the classifiers provided by the 'mapclassify' module. + + To set a classification scheme for a given Maps-object, simply use: + + >>> m.set_classify.< SCHEME >(...) + + Where `< SCHEME >` is the name of the desired classification and additional + parameters are passed in the call. (check docstrings for more info!) + + A list of available classification-schemes is accessible via + `m.classify_specs.SCHEMES` + + - BoxPlot (hinge) + - EqualInterval (k) + - FisherJenks (k) + - FisherJenksSampled (k, pct, truncate) + - HeadTailBreaks () + - JenksCaspall (k) + - JenksCaspallForced (k) + - JenksCaspallSampled (k, pct) + - MaxP (k, initial) + - MaximumBreaks (k, mindiff) + - NaturalBreaks (k, initial) + - Quantiles (k) + - Percentiles (pct) + - StdMean (multiples) + - UserDefined (bins) + + Examples + -------- + >>> m.set_classify.Quantiles(k=5) + + >>> m.set_classify.EqualInterval(k=5) + + >>> m.set_classify.UserDefined(bins=[5, 10, 25, 50]) + + """ assert _register_mapclassify(), ( "EOmaps: Missing dependency: 'mapclassify' \n ... please install" + " (conda install -c conda-forge mapclassify) to use data-classifications." @@ -1158,38 +1211,13 @@ def set_classify(self): } ) - s.__doc__ = dedent( - """ - Interface to the classifiers provided by the 'mapclassify' module. - - To set a classification scheme for a given Maps-object, simply use: - - >>> m.set_classify.(...) - - Where `` is the name of the desired classification and additional - parameters are passed in the call. (check docstrings for more info!) - - - Note - ---- - The following calls have the same effect: - - >>> m.set_classify.Quantiles(k=5) - >>> m.set_classify_specs(scheme="Quantiles", k=5) - - Using `m.set_classify()` is the same as using `m.set_classify_specs()`! - However, `m.set_classify()` will provide autocompletion and proper - docstrings once the Maps-object is initialized which greatly enhances - the usability. - - """ - ) + s.__doc__ = Maps.set_classify.__doc__ return s def set_classify_specs(self, scheme=None, **kwargs): """ - Set classification specifications for the data. + Optional way to set classification specifications for the data. The classification is ultimately performed by the `mapclassify` module! @@ -1233,6 +1261,7 @@ def set_classify_specs(self, scheme=None, **kwargs): kwargs passed to the call to the respective mapclassify classifier (dependent on the selected scheme... see above) """ + assert _register_mapclassify(), ( "EOmaps: Missing dependency: 'mapclassify' \n ... please install" + " (conda install -c conda-forge mapclassify) to use data-classifications." @@ -2358,10 +2387,50 @@ def get_extent(self, crs=None): return self.ax.get_extent(crs=crs) + def _calc_vmin_vmax(self, vmin=None, vmax=None): + if self._data_manager.z_data is None: + return vmin, vmax + + calc_min, calc_max = vmin is None, vmax is None + + # ignore fill_values when evaluating vmin/vmax on integer-encoded datasets + if ( + self.data_specs.encoding is not None + and isinstance(self._data_manager.z_data, np.ndarray) + and issubclass(self._data_manager.z_data.dtype.type, np.integer) + ): + + # note the specific way how to check for integer-dtype based on issubclass + # since isinstance() fails to identify all integer dtypes!! + # isinstance(np.dtype("uint8"), np.integer) (incorrect) False + # issubclass(np.dtype("uint8").type, np.integer) (correct) True + # for details, see https://stackoverflow.com/a/934652/9703451 + + fill_value = self.data_specs.encoding.get("_FillValue", None) + if fill_value: + # find values that are not fill-values + use_vals = self._data_manager.z_data[ + self._data_manager.z_data != fill_value + ] + + if calc_min: + vmin = np.min(use_vals) + if calc_max: + vmax = np.max(use_vals) + else: + # use nanmin/nanmax for all other arrays + if calc_min: + vmin = np.nanmin(self._data_manager.z_data) + if calc_max: + vmax = np.nanmax(self._data_manager.z_data) + + return vmin, vmax + def _set_vmin_vmax(self, vmin=None, vmax=None): self._vmin = self._encode_values(vmin) self._vmax = self._encode_values(vmax) + # handle inherited bounds if self._inherit_classification is not None: if not (vmin is None and vmax is None): raise TypeError( @@ -2379,22 +2448,16 @@ def _set_vmin_vmax(self, vmin=None, vmax=None): return if not self.shape.name.startswith("shade_"): - if vmin is None and self.data is not None: - self._vmin = np.nanmin(self._data_manager.z_data) - if vmax is None and self.data is not None: - self._vmax = np.nanmax(self._data_manager.z_data) + # ignore fill_values when evaluating vmin/vmax on integer-encoded datasets + self._vmin, self._vmax = self._calc_vmin_vmax(vmin=vmin, vmax=vmax) else: # get the name of the used aggretation reduction aggname = self.shape.aggregator.__class__.__name__ if aggname in ["first", "last", "max", "min", "mean", "mode"]: # set vmin/vmax in case the aggregation still represents data-values - if vmin is None: - self._vmin = np.nanmin(self._data_manager.z_data) - if vmax is None: - self._vmax = np.nanmax(self._data_manager.z_data) + self._vmin, self._vmax = self._calc_vmin_vmax(vmin=vmin, vmax=vmax) else: # set vmin/vmax for aggregations that do NOT represent data values - # allow vmin/vmax = None (e.g. autoscaling) if "count" in aggname: # if the reduction represents a count, don't count empty pixels @@ -2566,7 +2629,6 @@ def plot_map( self._set_vmin_vmax( vmin=kwargs.pop("vmin", None), vmax=kwargs.pop("vmax", None) ) - cbcmap, norm, bins, classified = self._classify_data( vmin=self._vmin, vmax=self._vmax, @@ -4634,6 +4696,7 @@ def _get_cartopy_crs(crs): elif isinstance(crs, (int, np.integer)): cartopy_proj = ccrs.epsg(crs) elif isinstance(crs, CRS): # pyproj CRS + cartopy_proj = None for ( subgrid, equi7crs, @@ -4641,8 +4704,12 @@ def _get_cartopy_crs(crs): if equi7crs == crs: cartopy_proj = Maps.CRS.Equi7Grid_projection(subgrid) break + if cartopy_proj is None: + cartopy_proj = ccrs.CRS(crs) + else: raise AssertionError(f"EOmaps: cannot identify the CRS for: {crs}") + return cartopy_proj @staticmethod @@ -4742,7 +4809,7 @@ def refetch_wms_on_size_change(self, *args, **kwargs): refetch_wms_on_size_change(*args, **kwargs) -class _InsetMaps(Maps): +class InsetMaps(Maps): # a subclass of Maps that includes some special functions for inset maps def __init__( @@ -4758,15 +4825,18 @@ def __init__( plot_size=0.5, shape="ellipses", indicate_extent=True, + indicator_line=False, boundary=True, background_color="w", **kwargs, ): + self._parent = self._proxy(parent) + # inherit the layer from the parent Maps-object if not explicitly # provided if layer is None: - layer = parent.layer + layer = self._parent.layer # put all inset-map artists on dedicated layers # NOTE: all artists of inset-map axes are put on a dedicated layer @@ -4789,6 +4859,7 @@ def __init__( radius_crs = xy_crs extent_kwargs = dict(ec="r", lw=1, fc="none") + line_kwargs = dict(c="r", lw=2) boundary_kwargs = dict(ec="r", lw=2) if isinstance(boundary, dict): @@ -4799,10 +4870,19 @@ def __init__( boundary_kwargs.update(boundary) # use same edgecolor for boundary and indicator by default extent_kwargs["ec"] = boundary["ec"] + line_kwargs["c"] = boundary["ec"] + elif isinstance(boundary, str): + boundary_kwargs.update({"ec": boundary}) + # use same edgecolor for boundary and indicator by default + extent_kwargs["ec"] = boundary + line_kwargs["c"] = boundary if isinstance(indicate_extent, dict): extent_kwargs.update(indicate_extent) + if isinstance(indicator_line, dict): + line_kwargs.update(indicator_line) + x, y = xy plot_x, plot_y = plot_position left = plot_x - plot_size / 2 @@ -4811,7 +4891,7 @@ def __init__( # initialize a new maps-object with a new axis super().__init__( crs=crs, - f=parent.f, + f=self._parent.f, ax=(left, bottom, plot_size, plot_size), layer=layer, **kwargs, @@ -4844,9 +4924,13 @@ def __init__( if indicate_extent is not False: self.indicate_inset_extent( - parent, layer=parent.layer, permanent=True, **extent_kwargs + self._parent, layer=self._parent.layer, permanent=True, **extent_kwargs ) + self._indicator_lines = [] + if indicator_line is not False: + self.add_indicator_line(**line_kwargs) + # add a background patch to the "all" layer if background_color is not None: self._bg_patch = self._add_background_patch( @@ -4917,6 +5001,79 @@ def indicate_inset_extent(self, m, n=100, **kwargs): **kwargs, ) + def add_indicator_line(self, m=None, **kwargs): + """ + Add a line that connects the inset-map to the inset location on a given map. + + The line connects the current inset-map (center) position to the center of the + inset extent on the provided Maps-object. + + It is possible to add multiple indicator-lines for different maps! + + The lines will be automatically updated if axes sizes or positions change. + + Parameters + ---------- + m : eomaps.Maps or None + The Maps object for which the inset-line should be added. + If None, the parent Maps-object that was used to create the inset-map + is used. The default is None. + + kwargs : + Additional kwargs are passed to plt.Line2D to style the appearance of the + line (e.g. "c", "ls", "lw", ...) + + + Examples + -------- + + """ + if m is None: + m = self._parent + + kwargs.setdefault("c", "r") + kwargs.setdefault("lw", 2) + kwargs.setdefault("zorder", -np.inf) + + l = plt.Line2D([0, 0], [1, 1], **kwargs) + l = self.f.add_artist(l) + self.BM.add_bg_artist(l, "__inset_all") + self._indicator_lines.append((l, m)) + + if isinstance(m, InsetMaps): + # in order to make the line visible on top of another inset-map + # but NOT on the inset-map whose extent is indicated, the line has to + # be drawn on the inset-map explicitly. + + # This is because all artists on inset-map axes are always on top of other + # (normal map) artists... (and so the line would be behind the background) + + kwargs["zorder"] = np.inf + l2 = plt.Line2D([0, 0], [1, 1], **kwargs, transform=m.f.transFigure) + l2 = m.ax.add_artist(l2) + self.BM.add_bg_artist(l2, "__inset_all") + self._indicator_lines.append((l2, m)) + + self._update_indicator_lines() + self.BM._before_fetch_bg_actions.append(self._update_indicator_lines) + + def _update_indicator_lines(self, *args, **kwargs): + props = self._inset_props + bbox = self.ax.get_position() + # get current inset-map position + x1 = (bbox.x1 + bbox.x0) / 2 + y1 = (bbox.y1 + bbox.y0) / 2 + + for l, m in self._indicator_lines: + # get inset map extent in ax projection + t = m._get_transformer(props["xy_crs"], m.ax.projection) + xy = t.transform(*props["xy"]) + # get inset map extent in figure coordinates + x0, y0 = (m.ax.transData + m.f.transFigure.inverted()).transform(xy) + + l.set_xdata([x0, x1]) + l.set_ydata([y0, y1]) + # a convenience-method to set the position based on the center of the axis def set_inset_position(self, x=None, y=None, size=None): """ @@ -4987,6 +5144,9 @@ def _get_inset_boundary(self, x, y, xy_crs, radius, radius_crs, shape, n=100): ) bnd_verts = np.stack(shp_pts[:2], axis=2)[0] + # make sure vertices are right-handed + bnd_verts = bnd_verts[::-1] + elif shape == "rectangles": shp_pts = shp._get_rectangle_verts( x=np.atleast_1d(x), @@ -5008,6 +5168,9 @@ def _get_inset_boundary(self, x, y, xy_crs, radius, radius_crs, shape, n=100): n=n, ) bnd_verts = np.stack(shp_pts[:2], axis=2).squeeze() + # make sure vertices are right-handed + bnd_verts = bnd_verts[::-1] + boundary = mpl.path.Path(bnd_verts) return boundary, bnd_verts diff --git a/eomaps/grid.py b/eomaps/grid.py index f02816570..932eeacfc 100644 --- a/eomaps/grid.py +++ b/eomaps/grid.py @@ -1,11 +1,12 @@ from matplotlib.collections import LineCollection import numpy as np +from itertools import chain +from functools import lru_cache +from .helpers import pairwise class GridLines: - def __init__( - self, m, d=None, auto_n=10, layer=None, bounds=(-180, 180, -90, 90), n=100 - ): + def __init__(self, m, d=None, auto_n=10, layer=None, bounds=None, n=100): self.m = m._proxy(m) self._d = d @@ -17,6 +18,7 @@ def __init__( self._coll = None self._layer = layer + self._grid_labels = [] @property def d(self): @@ -39,6 +41,8 @@ def n(self): @property def bounds(self): + if self._bounds is None: + return (-180, 180, -90, 90) return self._bounds def set_bounds(self, bounds): @@ -52,8 +56,6 @@ def set_bounds(self, bounds): If None, global boundaries are used (e.g. (-180, 180, -90, 90)) """ - if bounds is None: - bounds = (-180, 180, -90, 90) self._bounds = bounds self._redraw() @@ -142,55 +144,71 @@ def update_line_props(self, **kwargs): self._update_line_props(**kwargs) self._redraw() - def _get_lines(self): - lons, lats = None, None + @staticmethod + def _calc_lines(d, bounds, n=100): + lons, lats, dlon, dlat = None, None, None, None - if self.d is not None: - if isinstance(self.d, tuple): - # tuples are used to - if len(self.d) == 2: - if all(isinstance(i, (int, float, np.number)) for i in self.d): - dlon, dlat = self.d - elif all(isinstance(i, (list, np.ndarray)) for i in self.d): - dlon = dlat = "manual" - lons, lats = map(np.asanyarray, self.d) + if isinstance(d, tuple): + # tuples are used to + if len(d) == 2: + if isinstance(d[0], (list, tuple, np.ndarray)): + lons = np.asanyarray(d[0]) else: - raise TypeError( - f"EOmaps: If you provide a tuple as grid-spacing " - "'d=(dlon, dlat)' it must contain 2 items!" - ) - elif isinstance(self.d, (int, float, np.number)): - dlon = dlat = self.d - elif isinstance(self.d, (list, np.ndarray)): - dlon = dlat = "manual" - lons = lats = np.asanyarray(self.d) - else: - raise TypeError(f"EOmaps: d={self.d} is not a valid grid-spacing.") + dlon = d[0] - # evaluate line positions if no explicit positions are provided - if lons is None and lats is None: - if all(isinstance(i, (int, float, np.number)) for i in (dlon, dlat)): - lons = np.arange(self.bounds[0], self.bounds[1] + dlon, dlon) - lats = np.arange(self.bounds[2], self.bounds[3] + dlat, dlat) + if isinstance(d[1], (list, tuple, np.ndarray)): + lats = np.asanyarray(d[1]) else: - raise TypeError("EOmaps: dlon and dlat must be numbers!") - - lines = [ - np.linspace( - [x, self.bounds[2]], [x, self.bounds[3]], self.n, endpoint=True - ) - for x in np.unique(lons.clip(*self.bounds[:2])) - ] - linesy = [ - np.linspace( - [self.bounds[0], y], [self.bounds[1], y], self.n, endpoint=True + dlat = d[1] + else: + raise TypeError( + "EOmaps: If you provide a tuple as grid-spacing " + "'d=(dlon, dlat)' it must contain 2 items!" ) - for y in np.unique(lats.clip(*self.bounds[2:])) - ] - lines.extend(linesy) + elif isinstance(d, (int, float, np.number)): + dlon = dlat = d + elif isinstance(d, (list, np.ndarray)): + d = np.asanyarray(d) + if len(d.shape) == 2: + lons, lats = np.asanyarray(d) + else: + lons = lats = np.asanyarray(d) + else: + raise TypeError(f"EOmaps: d={d} is not a valid grid-spacing.") + + # evaluate line positions if no explicit positions are provided + if lons is None: + if dlon is not None: + lons = np.arange(bounds[0], bounds[1] + dlon, dlon) + lons = lons[lons <= bounds[1]] + lons = lons[lons >= bounds[0]] + else: + lons = np.array([]) + + if lats is None: + if dlat is not None: + lats = np.arange(bounds[2], bounds[3] + dlat, dlat) + lats = lats[lats <= bounds[3]] + lats = lats[lats >= bounds[2]] + else: + lats = np.array([]) + + lines = [ + np.linspace([x, bounds[2]], [x, bounds[3]], n, endpoint=True) + for x in np.unique(lons.clip(*bounds[:2])) + ] + + linesy = [ + np.linspace([bounds[0], y], [bounds[1], y], n, endpoint=True) + for y in np.unique(lats.clip(*bounds[2:])) + ] - return np.array(lines) + return lines, linesy + @lru_cache() + def _get_lines(self): + if self.d is not None: + return self._calc_lines(self.d, self.bounds, self.n) else: return self._get_auto_grid_lines() @@ -252,12 +270,15 @@ def _get_auto_grid_lines(self): for y in np.unique(lats.clip(*self.bounds[2:])) ] - lines.extend(linesy) + # lines.extend(linesy) - return np.array(lines) + # return np.array(lines) + return lines, linesy def _get_coll(self, **kwargs): - lines = self._get_lines() + lines = np.array(list(chain(*self._get_lines()))) + if len(lines) == 0: + return l0, l1 = lines[..., 0], lines[..., 1] @@ -270,19 +291,24 @@ def _add_grid(self, **kwargs): self._update_line_props(**kwargs) self._coll = self._get_coll(**self._kwargs) - - self.m.ax.add_collection(self._coll) - self.m.BM.add_bg_artist(self._coll, layer=self.layer) + if self._coll is not None: + self.m.ax.add_collection(self._coll) + self.m.BM.add_bg_artist(self._coll, layer=self.layer) def _redraw(self): + self._get_lines.cache_clear() try: self._remove() except Exception as ex: # catch exceptions to avoid issues with dynamic re-drawing of # invisible grids pass + self._add_grid() + for l in self._grid_labels: + l._redraw() + def _remove(self): if self._coll is None: return @@ -302,6 +328,517 @@ def remove(self): if self in self.m._grid._gridlines: self.m._grid._gridlines.remove(self) + def add_labels( + self, + where="tblr", + offset=10, + precision=2, + every=None, + exclude="corners", + labels=None, + rotation=0, + rotation_relative=True, + **kwargs, + ): + """ + Add labels to the gridlines. + + Parameters + ---------- + where : str or int, optional + Specify where labels should be added to the gridlines. + + In general, the position of labels is determined by the intersection-points + of the gridlines with the axis-boundary. + + To select specific labels, use one of the following options: + + - Use a combination of the letters `"tblr"` (top, bottom, left, right) to + draw labels only at the selected sides of the plot. + (NOTE: With this option, latitude-lines are labeled only on "l" and "r", + and longitude-lines are labeled only on "t" and "b"!) + - Use `"all"` to add all labels without any restrictions + - Use an integer to add labels only to the nth found intersection point + (e.g. 0 for the first intersection-point, 1 for the second etc.) + + The default is "tblr". + offset : number or tuple of numbers, optional + The offset of the labels relative to the intersection point of the + gridline with the axes-boundary. + + - number (d): the offset in the direction of of the rotation of the label. + - 2-tuple (x, y): the offset in x- and y- direction. + - 3-tuple (d, x, y): all of the above + + The default is 10. + precision : int, optional + The floating point precision of the labels. + The default is 2. + every : int, slice, tuple or None, optional + Specify if all labels (None) or only every nth label should be drawn. + + - if int: draw every nth label + - if 3-tuple: draw every nth label according to `(start, stop, step)` + (use stop=-1 if you want to draw all labels) + + The default is None. + exclude : str, list, tuple or None, optional + Exclude one (or more) labels. + + - A list of grid values to exclude as lon/lat labels. (e.g. `[10, -45]`) + - Provide a tuple of lists to exclude lon/lat values separately + (e.g. `([-120, 60], [-40, 40])`). + - If `"corners"`, the corner-points of the boundaries are excluded (this + is the default to avoid overlapping lon/lat labels at the corners). + - If `None`, no points are excluded. + + The default is "corners". + labels : list or None, optional + A list of strings to use as custom labels. + If None, the grid-values are used. + The default is None. + rotation : float, optional + The rotation of the label. The default is 0. + rotation_relative : bool, optional + Indicator if the rotation is performed relative to the current + label-rotation (True) or the rotation is set to the provided value (False). + The default is True. + kwargs : + Additional kwargs passed to matplotlib's `plt.text(...)` for additional + styling of the labels. + (e.g. fontsize, fontweight, color, ...) + + Returns + ------- + gl : GridLabels + The class that handles the drawing of the grid-labels. + + """ + gl = GridLabels( + self, + where=where, + offset=offset, + precision=precision, + every=every, + exclude=exclude, + labels=labels, + rotation=rotation, + rotation_relative=rotation_relative, + **kwargs, + ) + gl.add_labels() + + # remember attached labels + self._grid_labels.append(gl) + + return gl + + +class GridLabels: + def __init__( + self, + g, + where="tblr", + offset=10, + precision=2, + every=None, + exclude="corners", + labels=None, + rotation=0, + rotation_relative=True, + **kwargs, + ): + self._g = g + self._texts = [] + + self._last_extent = None + self._last_ax_pos = None + self._last_dpi = None # to avoid wrong label positions on dpi changes + self._default_dpi = 100 + + if isinstance(where, int): + self._where = "all" + # draw only the nth found point + self._n_pts = slice(where, where + 1) + else: + self._where = where + # label max. 2 points (to avoid issues with corner points etc.) + self._n_pts = slice(0, 2, 1) + + self._labels = labels + self._rotation_relative = rotation_relative + self._precision = precision + self._every = every + self._kwargs = kwargs + + self._set_offset(offset) + self._set_rotation(rotation) + self._set_exclude(exclude) + + # in case a custom boundary is used for gridlines, + # draw the labels only inside the axes + if self._g._bounds is not None: + self._kwargs.setdefault("clip_on", True) + self._kwargs.setdefault("clip_box", self._g.m.ax.bbox) + + self._g.m.BM._before_fetch_bg_actions.append(self._redraw) + + def _set_exclude(self, exclude): + # a list of tick values to exclude + if exclude is None: + self._exclude = ([], []) + elif isinstance(exclude, str) and exclude == "corners": + # if grid-labels are added to a grid that uses a custom boundary, + # exclude corner-values by default to avoid overlaps + bnds = self._g._bounds + if bnds is not None: + self._exclude = (bnds[:2], bnds[2:]) + else: + self._exclude = ([-180, 180], [-90, 90]) + + elif isinstance(exclude, (list, np.ndarray)): + self._exclude = (exclude, exclude) + elif isinstance(exclude, tuple): + self._exclude = exclude + else: + raise TypeError(f"EOmaps: {exclude} is not a valid value for exclude.") + + def _set_offset(self, offset): + # float : offset in text-rotation direction (r) + # (float, float) : offsets in x-y direction (x, y) + # (float, float, float) : both (r, x, y) + + if isinstance(offset, (int, float, np.number)): + self._offset = (0, 0) + self._relative_offset = offset + elif len(offset) == 2: + self._offset = offset + self._relative_offset = 0 + elif len(offset) == 3: + self._offset = (offset[1], offset[2]) + self._relative_offset = offset[0] + + def _set_rotation(self, rotation): + self._rotation = np.deg2rad(rotation) + + def _ccw(self, A, B, C): + # determine if 3 points are listed in a counter-clockwise order + return (C[..., 1] - A[..., 1]) * (B[..., 0] - A[..., 0]) > ( + B[..., 1] - A[..., 1] + ) * (C[..., 0] - A[..., 0]) + + def _intersect(self, A, B, C, D): + # determine if 2 line-segments intersect with each other + # see https://stackoverflow.com/a/9997374/9703451 + # see https://bryceboe.com/2006/10/23/line-segment-intersection-algorithm/ + + A, B, C, D = map(np.atleast_2d, (A, B, C, D)) + return np.logical_and( + self._ccw(A, C, D) != self._ccw(B, C, D), + self._ccw(A, B, C) != self._ccw(A, B, D), + ) + + def _get_intersect(self, a1, a2, b1, b2): + # get the intersection-point between 2 lines defined by points + # taken from https://stackoverflow.com/a/42727584/9703451 + + s = np.vstack([a1, a2, b1, b2]) # s for stacked + h = np.hstack((s, np.ones((4, 1)))) # h for homogeneous + l1 = np.cross(h[0], h[1]) # get first line + l2 = np.cross(h[2], h[3]) # get second line + x, y, z = np.cross(l1, l2) # point of intersection + if z == 0: # lines are parallel + return (float("inf"), float("inf")) + return (x / z, y / z) + + def _redraw(self, **kwargs): + try: + m = self._g.m + extent = m.get_extent(self._g.m.crs_plot) + pos = m.ax.get_position() + dpi = m.f.dpi + + if ( + self._last_ax_pos is not None + and self._last_extent is not None + and self._last_dpi is not None + and self._last_dpi == dpi + and self._last_extent == extent + and self._last_ax_pos.bounds == pos.bounds + ): + return + + self._last_extent = extent + self._last_ax_pos = pos + + while len(self._texts) > 0: + try: + t = self._texts.pop(-1) + t.remove() + self._g.m.BM.remove_bg_artist(t) + except Exception as ex: + print("EOmaps: Problem while trying to remove a grid-label:", ex) + pass + + self.add_labels() + except Exception as ex: + import traceback + + print( + "EOmaps: Encountered a problem while re-drawing grid-labels:", + ex, + traceback.format_exc(), + ) + pass + + def _get_bound_verts(self, n=300): + # get vertices of the grid-boundaries with n intermediate points + m = self._g.m + if self._g._bounds is not None: + x0, x1, y0, y1 = self._g.bounds + + xs, ys = np.linspace([x0, y0], [x1, y1], n).T + x0, y0, x1, y1, xs, ys = np.broadcast_arrays(x0, y0, x1, y1, xs, ys) + verts = np.column_stack( + ((x0, ys), (xs, y1), (x1, ys[::-1]), (xs[::-1], y0)) + ).T + + verts = m._transf_lonlat_to_plot.transform(*verts.T) + verts = m.ax.transData.transform(np.column_stack(verts)) + else: + verts = m.ax.spines["geo"].get_verts() + + return verts + + def _get_spine_intersections(self, lines, axis=None): + + m = self._g.m + + # get boundary vertices of current axis spine (in figure coordinates) + bl = self._get_bound_verts() + + # get gridlines + uselines = np.array(lines[axis]) + + if len(uselines) == 0: + return + + # get the tick-label values + tick_label_values = [*uselines[:, 0, axis]] + + # elongate the gridlines to make sure they reach outside the spine or boundary + # (elongate in lon/lat for grids defined inside the axes (e.g. with bounds)) + if self._g._bounds is not None: + uselines[:, 0, 0 if axis == 1 else 1] -= 0.1 + uselines[:, -1, 0 if axis == 1 else 1] += 0.1 + + # get gridline vertices in plot-coordinates + lines_plot = np.stack( + m._transf_lonlat_to_plot.transform(uselines[..., 0], uselines[..., 1]), + axis=-1, + ) + + # transform grid-lines to figure coordinates + lines_fig = m.ax.transData.transform(lines_plot.reshape(-1, 2)).reshape( + uselines.shape + ) + + # elongate the gridlines to make sure they reach outside the spine or boundary + # (do this in figure coordinates for gridlines outside the axes since otherwise + # the transformations would result in infinite values!) + if self._g._bounds is None: + lines_fig[:, 0, 0 if axis == 1 else 1] -= 0.01 + lines_fig[:, -1, 0 if axis == 1 else 1] += 0.01 + + tr = m.ax.transData.inverted() + tr_ax = m.ax.transAxes.inverted() + + # TODO would be nice to vectorize over gridlines as well + intersection_points = dict() + for l, label in zip(lines_fig, tick_label_values): + if axis == 0 and label == -180: + label = 180.0 + if axis == 1 and label == -90: + label = 90.0 + + if label in self._exclude[axis]: + continue + + label = np.format_float_positional( + label, precision=self._precision, trim="-", fractional=True + ) + ("Β°E" if axis == 0 else "Β°N") + + l0x, l0y, l1x, l1y, b0x, b0y, b1x, b1y = np.broadcast_arrays( + l[:-1, 0], + l[:-1, 1], + l[1:, 0], + l[1:, 1], + bl[:-1, 0][:, np.newaxis], + bl[:-1, 1][:, np.newaxis], + bl[1:, 0][:, np.newaxis], + bl[1:, 1][:, np.newaxis], + ) + + l0 = np.stack((l0x, l0y), axis=2) + l1 = np.stack((l1x, l1y), axis=2) + b0 = np.stack((b0x, b0y), axis=2) + b1 = np.stack((b1x, b1y), axis=2) + + q = self._intersect(l0, l1, b0, b1) + + for la, lb, ba, bb in zip(l0[q], l1[q], b0[q], b1[q]): + x, y = self._get_intersect(la, lb, ba, bb) + + xt, yt = tr_ax.transform((x, y)) + + # TODO find a better way to identify position of label + # select which lines to draw (e.g. tblr) + if self._where != "all": + if axis == 0: + if xt > 0.99 or xt < 0.01: + continue + + if "t" in self._where: + if "b" not in self._where: + # don't draw the second intersection point + if yt <= 0.5: + continue + elif "b" in self._where: + if yt > 0.5: + continue + else: + if yt > 0.99 or yt < 0.01: + continue + + if "r" in self._where: + if "l" not in self._where: + # don't draw the second intersection point + if xt <= 0.5: + continue + elif "l" in self._where: + if xt > 0.5: + continue + + # calculate rotation angle of boundary segment + r = np.pi + np.arctan2( + (ba[1] - bb[1]), + (ba[0] - bb[0]), + ) + + r = (r + self._rotation) if self._rotation_relative else self._rotation + + # add offset to label positions + x = ( + x + - self._relative_offset * np.sin(r) * m.f.dpi / self._default_dpi + + self._offset[0] + ) + y = ( + y + + self._relative_offset * np.cos(r) * m.f.dpi / self._default_dpi + + self._offset[1] + ) + + # round to avoid "jumpy" labels + x, y = np.round((x, y)) + + intersection_points.setdefault(label, list()).append([x, y, r]) + + return intersection_points + + def _get_grid_line_intersections(self, lines, axis=0): + # calculate intersection point of a grid witih a set of lines + + if self._every: + if isinstance(self._every, int): + every = slice(0, -1, self._every) + elif isinstance(self._every, (list, tuple)) and len(self._every) <= 3: + every = slice(*self._every) + elif isinstance(self._every, slice): + every = self._every + else: + raise TypeError( + f"EOmaps: {self._every} is not a valid input for 'every'" + ) + uselines = [i[every] for i in lines] + else: + uselines = lines + + intersection_points = self._get_spine_intersections(uselines, axis=axis) + + return intersection_points + + def _add_axis_labels(self, lines, axis): + m = self._g.m + + if self._kwargs is None: + txt_kwargs = dict() + else: + txt_kwargs = self._kwargs + + intersection_points = self._get_grid_line_intersections(lines, axis) + + if intersection_points is None: + return + + if len(intersection_points) > 0: + # make sure only unique pairs of coordinates are used + # pts = np.unique(np.rec.fromarrays(pts)).view((pts.dtype, 2)).T + for i, (label, pts) in enumerate(intersection_points.items()): + # TODO currently we take only the first 2 points + # to avoid issues with 180Β° lines etc. + for (x, y, r) in pts[self._n_pts]: + if self._rotation_relative: + r = np.rad2deg(r) + # make sure that labels on straight axes are oriented the same + if r == 180: + r = 0 + if r == 270: + r = 90 + + r = r + self._rotation + else: + r = self._rotation + + if self._labels is None: + uselabel = label + else: + # use the provided labels (keep existing if None) + uselabel = self._labels[i] + if uselabel is None: + uselabel = label + + t = m.ax.text( + x, + y, + uselabel, + transform=None, # None is the same as using IdentityTransform() + animated=True, + rotation=r, + ha="center", + va="center", + **txt_kwargs, + ) + m.BM.add_bg_artist(t, layer=self._g.layer) + self._texts.append(t) + + def add_labels(self): + m = self._g.m + lines = self._g._get_lines() + aspect = m.ax.bbox.height / m.ax.bbox.width + + if self._where == "all": + use_axes = (0, 1) + else: + use_axes = [] + if "t" in self._where or "b" in self._where: + use_axes.append(0) + if "l" in self._where or "r" in self._where: + use_axes.append(1) + + for axis in use_axes: + self._add_axis_labels(lines=lines, axis=axis) + class GridFactory: def __init__(self, m): @@ -331,15 +868,21 @@ def add_grid( Parameters ---------- d : int, float, 2-tuple, list, numpy.array or None - Set the properties (separation or specific coordinates) for a fixed grid. + Set the location of the gridlines (for a fixed grid). - - If `int` or `float`, the provided number is used as grid-spacing. - - If a `list` or `numpy.array` is provided, it is used to draw gridlines - at the provided coordinates. - - If a `tuple` of lengh 2 is provided, it represents separate assignments of - the aforementioned types for longitude/latitude , e.g.: `(d_lon, d_lat)`. - - If `None`, gridlines are automatically determined based on the "auto_n" - parameter. + - For a regular grid with a fixed spacing, provide a number or a `tuple` + of numbers to set the lon/lat distance between the grid-lines. + + >>> d = 10 # a regular 10 degree grid + >>> d = (5, 10) # a regular grid with d_lon=5 and d_lat=10 + + - To draw only specific gridlines, provide a `tuple` of lists or + numpy-arrays of (lon, lat) values. + + >>> d = ([lon0, lon1, lon2, ...], [lat0, lat1, ...]) + + - If `d = None`, gridlines are automatically determined based on + the "auto_n" parameter. The default is None auto_n : int or 2-tuple @@ -365,8 +908,15 @@ def add_grid( Returns ------- - m_grid : EOmaps.Maps - The Maps-object used to draw the gridlines. + g : GridLines + The GridLines-object. + + Note + ---- + To add labels to the grid, use: + + >>> g = m.add_gridlines(...) + >>> g.add_labels(...) Examples -------- @@ -388,9 +938,6 @@ def add_grid( kwargs.setdefault("lw", lw) kwargs.setdefault("zorder", 100) - if bounds is None: - bounds = (-180, 180, -90, 90) - g = GridLines(m=m, d=d, auto_n=auto_n, n=n, bounds=bounds, layer=layer) g._add_grid(**kwargs) self._gridlines.append(g) diff --git a/eomaps/ne_features.py b/eomaps/ne_features.py index 78fa65b4c..261bb410d 100644 --- a/eomaps/ne_features.py +++ b/eomaps/ne_features.py @@ -205,6 +205,12 @@ def __init__(self, m, category, name, **kwargs): f"PRESET using {kwargs} \n", self.feature.__doc__, add_params ) + def _handle_synonyms(self, kwargs): + # make sure to replace shortcuts with long names + # (since "facecolor=..." will override "fc=..." if both are specified) + subst = dict(fc="facecolor", ec="edgecolor", lw="linewidth", ls="linestyle") + return {subst.get(key, key): val for key, val in kwargs.items()} + def __call__(self, scale=50, **kwargs): k = dict(**self.kwargs) k.update(kwargs) @@ -215,7 +221,7 @@ def __call__(self, scale=50, **kwargs): ) self.__doc__ = self.feature.__doc__ - return self.feature(scale=scale, **k) + return self.feature(scale=scale, **self._handle_synonyms(k)) _NE_features_path = Path(__file__).parent / "NE_features.json" diff --git a/eomaps/qtcompanion/widgets/editor.py b/eomaps/qtcompanion/widgets/editor.py index b46954be7..cf6b47607 100644 --- a/eomaps/qtcompanion/widgets/editor.py +++ b/eomaps/qtcompanion/widgets/editor.py @@ -3,7 +3,7 @@ from matplotlib.colors import to_rgba_array -from ...eomaps import _InsetMaps +from ...eomaps import InsetMaps from ..common import iconpath from .wms import AddWMSMenuButton from .utils import GetColorWidget, AlphaSlider @@ -1173,7 +1173,7 @@ def populate_layer(self, layer=None): # make sure we fetch artists of inset-maps from the layer with # the "__inset_" prefix - if isinstance(self.m, _InsetMaps) and not layer.startswith("__inset_"): + if isinstance(self.m, InsetMaps) and not layer.startswith("__inset_"): layer = "__inset_" + layer widget = self.currentWidget() diff --git a/eomaps/reader.py b/eomaps/reader.py index 321abfa0e..f84e5aa02 100644 --- a/eomaps/reader.py +++ b/eomaps/reader.py @@ -119,6 +119,7 @@ def GeoTIFF( isel=None, set_data=None, mask_and_scale=False, + fill_values="mask", ): """ Read all relevant information necessary to add a GeoTIFF to the map. @@ -169,6 +170,29 @@ def GeoTIFF( values for callbacks and colorbars, even if `mask_and_scale=False`! The default is False. + fill_values : str ("mask", "keep") + Indicator how to treat fill-values to avoid performance issues for + extremely large (integer encoded) datasets. + + Only relevant for integer-encoded data if "mask_and_scale" is False + and a "_FillValue" is provided in the metadata. + + - If "mask", a "numpy.masked_array" is used to incorporate fill-value + masking while maintaining integer dtype. NOTE that this can lead to + performance issues for very large datasets due to the increased memory + usage (and reduced performance) of masked_arrays. + - If "keep", no masking with respect to fill-values is performed and a + normal "numpy.array" is returned that still contains fill_values. + + The fill-value is accessible via `m.data_specs.encoding["_FillValue"]` + + To adjust the color of fill_values in the plot without explicit masking, + set the "over" and "unuder" colors of the used colorbar: + + - `plt.cm.viridis.with_extremes(over=... under=...)` + - `cmap.set_over(...)`, `cmap.set_under(...)`) + + (fill-values are excluded when evaluating data-limits) Returns ------- @@ -275,8 +299,16 @@ def GeoTIFF( if mask_and_scale is False: encoding = usencfile.attrs fill_value = encoding.get("_FillValue", None) - if fill_value: - data = np.ma.masked_where(data == fill_value, data, copy=False) + + if fill_value and fill_values == "mask": + data = np.ma.MaskedArray( + data=data, + mask=data == fill_value, + copy=False, + fill_value=fill_value, + hard_mask=True, + ) + else: encoding = None @@ -311,6 +343,7 @@ def NetCDF( isel=None, set_data=None, mask_and_scale=False, + fill_values="mask", ): """ Read all relevant information necessary to add a NetCDF to the map. @@ -370,6 +403,29 @@ def NetCDF( values for callbacks and colorbars, even if `mask_and_scale=False`! The default is False. + fill_values : str ("mask", "keep") + Indicator how to treat fill-values to avoid performance issues for + extremely large (integer encoded) datasets. + + Only relevant for integer-encoded data if "mask_and_scale" is False + and a "_FillValue" is provided in the metadata. + + - If "mask", a "numpy.masked_array" is used to incorporate fill-value + masking while maintaining integer dtype. NOTE that this can lead to + performance issues for very large datasets due to the increased memory + usage (and reduced performance) of masked_arrays. + - If "keep", no masking with respect to fill-values is performed and a + normal "numpy.array" is returned that still contains fill_values. + + The fill-value is accessible via `m.data_specs.encoding["_FillValue"]` + + To adjust the color of fill_values in the plot without explicit masking, + set the "over" and "unuder" colors of the used colorbar: + + - `plt.cm.viridis.with_extremes(over=... under=...)` + - `cmap.set_over(...)`, `cmap.set_under(...)`) + + (fill-values are excluded when evaluating data-limits) Returns ------- @@ -514,8 +570,15 @@ def NetCDF( _FillValue=getattr(usencfile[parameter], "_FillValue", None), ) fill_value = encoding.get("_FillValue", None) - if fill_value: - data = np.ma.masked_where(data == fill_value, data, copy=False) + if fill_value and fill_values == "mask": + data = np.ma.MaskedArray( + data=data, + mask=data == fill_value, + copy=False, + fill_value=fill_value, + hard_mask=True, + ) + else: encoding = None @@ -796,6 +859,7 @@ def NetCDF( val_transform=None, coastline=False, mask_and_scale=False, + fill_values="mask", extent=None, **kwargs, ): @@ -895,6 +959,29 @@ def NetCDF( values for callbacks and colorbars, even if `mask_and_scale=False`! The default is False. + fill_values : str ("mask", "keep") + Indicator how to treat fill-values to avoid performance issues for + extremely large (integer encoded) datasets. + + Only relevant for integer-encoded data if "mask_and_scale" is False + and a "_FillValue" is provided in the metadata. + + - If "mask", a "numpy.masked_array" is used to incorporate fill-value + masking while maintaining integer dtype. NOTE that this can lead to + performance issues for very large datasets due to the increased memory + usage (and reduced performance) of masked_arrays. + - If "keep", no masking with respect to fill-values is performed and a + normal "numpy.array" is returned that still contains fill_values. + + The fill-value is accessible via `m.data_specs.encoding["_FillValue"]` + + To adjust the color of fill_values in the plot without explicit masking, + set the "over" and "unuder" colors of the used colorbar: + + - `plt.cm.viridis.with_extremes(over=... under=...)` + - `cmap.set_over(...)`, `cmap.set_under(...)`) + + (fill-values are excluded when evaluating data-limits) extent : tuple or string Set the extent of the map prior to plotting (can provide great speedups if only a subset of the dataset is shown!) @@ -959,6 +1046,7 @@ def NetCDF( isel=isel, set_data=None, mask_and_scale=mask_and_scale, + fill_values=fill_values, ) if val_transform: @@ -988,6 +1076,7 @@ def GeoTIFF( val_transform=None, coastline=False, mask_and_scale=False, + fill_values="mask", extent=None, **kwargs, ): @@ -1075,6 +1164,29 @@ def GeoTIFF( values for callbacks and colorbars, even if `mask_and_scale=False`! The default is False. + fill_values : str ("mask", "keep") + Indicator how to treat fill-values to avoid performance issues for + extremely large (integer encoded) datasets. + + Only relevant for integer-encoded data if "mask_and_scale" is False + and a "_FillValue" is provided in the metadata. + + - If "mask", a "numpy.masked_array" is used to incorporate fill-value + masking while maintaining integer dtype. NOTE that this can lead to + performance issues for very large datasets due to the increased memory + usage (and reduced performance) of masked_arrays. + - If "keep", no masking with respect to fill-values is performed and a + normal "numpy.array" is returned that still contains fill_values. + + The fill-value is accessible via `m.data_specs.encoding["_FillValue"]` + + To adjust the color of fill_values in the plot without explicit masking, + set the "over" and "unuder" colors of the used colorbar: + + - `plt.cm.viridis.with_extremes(over=... under=...)` + - `cmap.set_over(...)`, `cmap.set_under(...)`) + + (fill-values are excluded when evaluating data-limits) extent : tuple or string Set the extent of the map prior to plotting (can provide great speedups if only a subset of the dataset is shown!) @@ -1135,6 +1247,7 @@ def GeoTIFF( data_crs=data_crs, crs_key=data_crs_key, mask_and_scale=mask_and_scale, + fill_values=fill_values, ) if val_transform: diff --git a/eomaps/utilities.py b/eomaps/utilities.py index 793e88ff4..6699f4cf4 100644 --- a/eomaps/utilities.py +++ b/eomaps/utilities.py @@ -565,7 +565,14 @@ def remove(self): class utilities: """ - A collection of utility tools that can be added to EOmaps plots + A collection of utility tools that can be added to EOmaps plots. + + Methods + ------- + layer_selector: A legend-like widget to switch between layers + + layer_slider: A slider widget to switch between layers + """ def __init__(self, m): diff --git a/tests/example_gridlines.py b/tests/example_gridlines.py new file mode 100644 index 000000000..703188761 --- /dev/null +++ b/tests/example_gridlines.py @@ -0,0 +1,71 @@ +# EOmaps Example: Customized gridlines + +from eomaps import Maps + +m = Maps(crs=Maps.CRS.Stereographic()) +m.subplots_adjust(left=0.1, right=0.9, bottom=0.1, top=0.9) + +m.add_feature.preset.ocean() +m.add_feature.preset.land() + +# draw a regular 5 degree grid +m.add_gridlines(5, lw=0.25, alpha=0.5) +# draw a grid with 20 degree longitude spacing and add labels +g = m.add_gridlines((20, None), c="b", n=500) +g.add_labels(offset=10, fontsize=8, c="b") +# draw a grid with 20 degree latitude spacing, add labels and exclude the 10Β° tick +g = m.add_gridlines((None, 20), c="g", n=500) +g.add_labels(where="l", offset=10, fontsize=8, c="g", exclude=[10]) +# explicitly highlight 10Β°N line and add a label on one side of the map +g = m.add_gridlines((None, [10]), c="indigo", n=500, lw=1.5) +g.add_labels(where="l", fontsize=12, fontweight="bold", c="indigo") + + +# ----------------- first inset-map +mi = m.new_inset_map(xy=(45, 45), radius=10, inset_crs=m.crs_plot) +mi.add_feature.preset.ocean() +mi.add_feature.preset.land() + +# draw a regular 1 degree grid +g = mi.add_gridlines((None, 1), c="g", lw=0.6) +# add some specific latitude gridlines and add labels +g = mi.add_gridlines((None, [40, 45, 50]), c="g", lw=2) +g.add_labels(where="lr", offset=7, fontsize=6, c="g") +# add some specific longitude gridlines and add labels +g = mi.add_gridlines(([40, 45, 50], None), c="b", lw=2) +g.add_labels(where="tb", offset=7, fontsize=6, c="b") + +mi.indicate_inset_extent(m, fc="darkred", ec="none", alpha=0.5) +mi.add_indicator_line() + +# ----------------- second inset-map +mi = m.new_inset_map( + inset_crs=m.crs_plot, + xy=(-10, 10), + radius=20, + shape="rectangles", + boundary=dict(ec="k"), +) +mi.add_feature.preset.ocean() +mi.add_feature.preset.land() + +mi.indicate_inset_extent(m, fc=".5", ec="none", alpha=0.5) +mi.add_indicator_line(c="k") + +# draw a regular 1 degree grid +g = mi.add_gridlines(5, lw=0.25) +# add some specific latitude gridlines and add labels +g = mi.add_gridlines((None, [0, 10, 25]), c="g", lw=2) +g.add_labels(where="l", fontsize=10, c="g") +# add some specific longitude gridlines and add labels +g = mi.add_gridlines(([-25, -10, 0], None), c="b", lw=2) +g.add_labels(where="t", fontsize=10, c="b") + +m.apply_layout( + { + "figsize": [7.39, 4.8], + "0_map": [0.025, 0.07698, 0.5625, 0.86602], + "1_inset_map": [0.7, 0.53885, 0.225, 0.41681], + "2_inset_map": [0.6625, 0.03849, 0.275, 0.42339], + } +) diff --git a/tests/test_basic_functions.py b/tests/test_basic_functions.py index 9e5bde71a..c349a0c3a 100644 --- a/tests/test_basic_functions.py +++ b/tests/test_basic_functions.py @@ -546,14 +546,19 @@ def test_add_colorbar(self): m = Maps(ax=gs[0, 0]) m.set_data_specs(data=self.data, x="x", y="y", crs=3857) m.plot_map() - cb1 = m.add_colorbar( - gs[1, 0], - orientation="horizontal", - ) + cb1 = m.add_colorbar(gs[1, 0], orientation="horizontal") + cb1.set_labels("colorbar", "histogram", fontsize=10, color="r") + + self.assertTrue(cb1.ax_cb.get_xlabel() == "colorbar") + self.assertTrue(cb1.ax_cb_plot.get_ylabel() == "histogram") self.assertTrue(len(m._colorbars) == 1) self.assertTrue(m.colorbar is cb1) cb2 = m.add_colorbar(gs[0, 1], orientation="vertical") + cb2.set_labels("colorbar", "histogram", fontsize=10, color="r") + + self.assertTrue(cb2.ax_cb.get_ylabel() == "colorbar") + self.assertTrue(cb2.ax_cb_plot.get_xlabel() == "histogram") self.assertTrue(len(m._colorbars) == 2) self.assertTrue(m.colorbar is cb2) @@ -633,6 +638,24 @@ def test_add_colorbar(self): cb6.set_bin_labels(bins, labels, show_values=True) cb6.tick_params(which="minor", rotation=90) + + # test setting custom bins (same but for "vertical" colorbars) + bins = [-5e6, 5e6, 1e7] + labels = ["A", "b", "c", "d"] + m3 = m.new_layer("asdf_vert") + m3.set_data_specs(data=self.data, x="x", y="y", crs=3857) + m3.set_classify.UserDefined(bins=bins) + m3.plot_map() + cb6 = m3.add_colorbar(orientation="vertical") + cb6.set_bin_labels(bins, labels) + m.show_layer(m3.layer) + m.redraw() + + self.assertTrue(labels == [i.get_text() for i in cb6.ax_cb.get_yticklabels()]) + + cb6.set_bin_labels(bins, labels, show_values=True) + cb6.tick_params(which="minor", rotation=90) + plt.close("all") def test_MapsGrid(self): diff --git a/tests/test_examples.py b/tests/test_examples.py index ee720284d..3d8420d93 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -37,3 +37,6 @@ def test_example_row_col_selector(self): def test_example_lines(self): import example_lines + + def test_example_gridlines(self): + import example_gridlines