diff --git a/python/.pylintrc b/python/.pylintrc index 0aed22e524f..0df20a6e3ba 100644 --- a/python/.pylintrc +++ b/python/.pylintrc @@ -568,7 +568,7 @@ max-args=12 # Maximum number of attributes for a class (see R0902). # We tend to have classes with more attributes than the default 7. -max-attributes=12 +max-attributes=15 # Maximum number of boolean expressions in an if statement (see R0916). max-bool-expr=5 diff --git a/python/grass/jupyter/Makefile b/python/grass/jupyter/Makefile index 102a70cbc80..cb39436a3e6 100644 --- a/python/grass/jupyter/Makefile +++ b/python/grass/jupyter/Makefile @@ -14,7 +14,8 @@ MODULES = \ seriesmap \ reprojection_renderer \ utils \ - timeseriesmap + timeseriesmap \ + baseseriesmap PYFILES := $(patsubst %,$(DSTDIR)/%.py,$(MODULES) __init__) PYCFILES := $(patsubst %,$(DSTDIR)/%.pyc,$(MODULES) __init__) diff --git a/python/grass/jupyter/baseseriesmap.py b/python/grass/jupyter/baseseriesmap.py new file mode 100644 index 00000000000..e28572e1f3e --- /dev/null +++ b/python/grass/jupyter/baseseriesmap.py @@ -0,0 +1,188 @@ +"""Base class for SeriesMap and TimeSeriesMap""" + +import os +import tempfile +import weakref +import shutil + +import grass.script as gs + +from .map import Map + + +class BaseSeriesMap: + """ + Base class for SeriesMap and TimeSeriesMap + """ + + def __init__(self, width=None, height=None, env=None): + """Creates an instance of the visualizations class. + + :param int width: width of map in pixels + :param int height: height of map in pixels + :param str env: environment + """ + + # Copy Environment + if env: + self._env = env.copy() + else: + self._env = os.environ.copy() + + self.baseseries = None + self._base_layer_calls = [] + self._base_calls = [] + self._baseseries_added = False + self._layers_rendered = False + self._base_filename_dict = {} + self._width = width + self._height = height + self._slider_description = "" + self._labels = [] + self._indices = [] + self.base_file = None + + # Create a temporary directory for our PNG images + # Resource managed by weakref.finalize. + self._tmpdir = ( + # pylint: disable=consider-using-with + tempfile.TemporaryDirectory() + ) + + def cleanup(tmpdir): + tmpdir.cleanup() + + weakref.finalize(self, cleanup, self._tmpdir) + + def __getattr__(self, name): + """ + Parse attribute to GRASS display module. Attribute should be in + the form 'd_module_name'. For example, 'd.rast' is called with 'd_rast'. + """ + # Check to make sure format is correct + if not name.startswith("d_"): + raise AttributeError(_("Module must begin with 'd_'")) + # Reformat string + grass_module = name.replace("_", ".") + # Assert module exists + if not shutil.which(grass_module): + raise AttributeError(_("Cannot find GRASS module {}").format(grass_module)) + # if this function is called, the images need to be rendered again + self._layers_rendered = False + + def wrapper(**kwargs): + if not self._baseseries_added: + self._base_layer_calls.append((grass_module, kwargs)) + else: + if self._base_calls is not None: + for row in self._base_calls: + row.append((grass_module, kwargs)) + else: + self._base_calls.append((grass_module, kwargs)) + + return wrapper + + def _render_baselayers(self, img): + """Add collected baselayers to Map instance""" + for grass_module, kwargs in self._base_layer_calls: + img.run(grass_module, **kwargs) + + def _render(self): + """ + Renders the base image for the dataset. + + Saves PNGs to a temporary directory. + This method must be run before creating a visualization (e.g., show or save). + It can be time-consuming to run with large space-time datasets. + + Child classes should override the `render` method + to define specific rendering behaviors, such as: + - Rendering images for each time-step in a space-time dataset (e.g., class1). + - Rendering images for each raster in a series (e.g., class2). + """ + # Runtime error in respective classes + + # Make base image (background and baselayers) + # Random name needed to avoid potential conflict with layer names + random_name_base = gs.append_random("base", 8) + ".png" + self.base_file = os.path.join(self._tmpdir.name, random_name_base) + img = Map( + width=self._width, + height=self._height, + filename=self.base_file, + use_region=True, + env=self._env, + read_file=True, + ) + # We have to call d_erase to ensure the file is created. If there are no + # base layers, then there is nothing to render in random_base_name + img.d_erase() + # Add baselayers + self._render_baselayers(img) + + # Render layers in respective classes + + def show(self, slider_width=None): + """Create interactive timeline slider. + + param str slider_width: width of datetime selection slider + + The slider_width parameter sets the width of the slider in the output cell. + It should be formatted as a percentage (%) between 0 and 100 of the cell width + or in pixels (px). Values should be formatted as strings and include the "%" + or "px" suffix. For example, slider_width="80%" or slider_width="500px". + slider_width is passed to ipywidgets in ipywidgets.Layout(width=slider_width). + """ + # Lazy Imports + import ipywidgets as widgets # pylint: disable=import-outside-toplevel + + # Render images if they have not been already + if not self._layers_rendered: + self.render() + + # Set default slider width + if not slider_width: + slider_width = "70%" + + lookup = list(zip(self._labels, self._indices)) + description = self._slider_description # modify description + + # Datetime selection slider + slider = widgets.SelectionSlider( + options=lookup, + value=self._indices[0], + description=description, + disabled=False, + continuous_update=True, + orientation="horizontal", + readout=True, + layout=widgets.Layout(width=slider_width), + ) + play = widgets.Play( + interval=500, + value=0, + min=0, + max=len(self._labels) - 1, + step=1, + description="Press play", + disabled=False, + ) + out_img = widgets.Image(value=b"", format="png") + + def change_slider(change): + slider.value = slider.options[change.new][1] + + play.observe(change_slider, names="value") + + # Display image associated with datetime + def change_image(index): + filename = self._base_filename_dict[index] + with open(filename, "rb") as rfile: + out_img.value = rfile.read() + + widgets.interactive_output(change_image, {"index": slider}) + + layout = widgets.Layout( + width="100%", display="inline-flex", flex_flow="row wrap" + ) + return widgets.HBox([play, slider, out_img], layout=layout) diff --git a/python/grass/jupyter/seriesmap.py b/python/grass/jupyter/seriesmap.py index 2c3a3860130..c61c886aabb 100644 --- a/python/grass/jupyter/seriesmap.py +++ b/python/grass/jupyter/seriesmap.py @@ -12,20 +12,18 @@ # for details. """Create and display visualizations for a series of rasters.""" -import tempfile import os -import weakref import shutil -import grass.script as gs from grass.grassdb.data import map_exists from .map import Map from .region import RegionManagerForSeries from .utils import save_gif +from .baseseriesmap import BaseSeriesMap -class SeriesMap: +class SeriesMap(BaseSeriesMap): """Creates visualizations from a series of rasters or vectors in Jupyter Notebooks. @@ -63,34 +61,7 @@ def __init__( :param saved_region: if name of saved_region is provided, this region is then used for rendering """ - - # Copy Environment - if env: - self._env = env.copy() - else: - self._env = os.environ.copy() - - self._series_length = None - self._base_layer_calls = [] - self._calls = [] - self._series_added = False - self._layers_rendered = False - self._layer_filename_dict = {} - self._names = [] - self._width = width - self._height = height - - # Create a temporary directory for our PNG images - # Resource managed by weakref.finalize. - self._tmpdir = ( - # pylint: disable=consider-using-with - tempfile.TemporaryDirectory() - ) - - def cleanup(tmpdir): - tmpdir.cleanup() - - weakref.finalize(self, cleanup, self._tmpdir) + super().__init__(width, height, env) # Handle Regions self._region_manager = RegionManagerForSeries( @@ -110,22 +81,23 @@ def add_rasters(self, rasters, **kwargs): raise NameError(_("Could not find a raster named {}").format(raster)) # Update region to rasters if not use_region or saved_region self._region_manager.set_region_from_rasters(rasters) - if self._series_added: - assert self._series_length == len(rasters), _( + if self._baseseries_added: + assert self.baseseries == len(rasters), _( "Number of vectors in series must match number of vectors" ) - for i in range(self._series_length): + for i in range(self.baseseries): kwargs["map"] = rasters[i] - self._calls[i].append(("d.rast", kwargs.copy())) + self._base_calls[i].append(("d.rast", kwargs.copy())) else: - self._series_length = len(rasters) + self.baseseries = len(rasters) for raster in rasters: kwargs["map"] = raster - self._calls.append([("d.rast", kwargs.copy())]) - self._series_added = True - if not self._names: - self._names = rasters + self._base_calls.append([("d.rast", kwargs.copy())]) + self._baseseries_added = True + if not self._labels: + self._labels = rasters self._layers_rendered = False + self._indices = list(range(len(self._labels))) def add_vectors(self, vectors, **kwargs): """ @@ -136,60 +108,32 @@ def add_vectors(self, vectors, **kwargs): raise NameError(_("Could not find a vector named {}").format(vector)) # Update region extent to vectors if not use_region or saved_region self._region_manager.set_region_from_vectors(vectors) - if self._series_added: - assert self._series_length == len(vectors), _( + if self._baseseries_added: + assert self.baseseries == len(vectors), _( "Number of rasters in series must match number of vectors" ) - for i in range(self._series_length): + for i in range(self.baseseries): kwargs["map"] = vectors[i] - self._calls[i].append(("d.vect", kwargs.copy())) + self._base_calls[i].append(("d.vect", kwargs.copy())) else: - self._series_length = len(vectors) + self.baseseries = len(vectors) for vector in vectors: kwargs["map"] = vector - self._calls.append([("d.vect", kwargs.copy())]) - self._series_added = True - if not self._names: - self._names = vectors + self._base_calls.append([("d.vect", kwargs.copy())]) + self._baseseries_added = True + if not self._labels: + self._labels = vectors self._layers_rendered = False - - def __getattr__(self, name): - """ - Parse attribute to GRASS display module. Attribute should be in - the form 'd_module_name'. For example, 'd.rast' is called with 'd_rast'. - """ - # Check to make sure format is correct - if not name.startswith("d_"): - raise AttributeError(_("Module must begin with 'd_'")) - # Reformat string - grass_module = name.replace("_", ".") - # Assert module exists - if not shutil.which(grass_module): - raise AttributeError(_("Cannot find GRASS module {}").format(grass_module)) - # if this function is called, the images need to be rendered again - self._layers_rendered = False - - def wrapper(**kwargs): - if not self._series_added: - self._base_layer_calls.append((grass_module, kwargs)) - else: - for row in self._calls: - row.append((grass_module, kwargs)) - - return wrapper + self._indices = range(len(self._labels)) def add_names(self, names): """Add list of names associated with layers. Default will be names of first series added.""" - assert self._series_length == len(names), _( + assert self.baseseries == len(names), _( "Number of vectors in series must match number of vectors" ) - self._names = names - - def _render_baselayers(self, img): - """Add collected baselayers to Map instance""" - for grass_module, kwargs in self._base_layer_calls: - img.run(grass_module, **kwargs) + self._labels = names + self._indices = list(range(len(self._labels))) def render(self): """Renders image for each raster in series. @@ -197,38 +141,20 @@ def render(self): Save PNGs to temporary directory. Must be run before creating a visualization (i.e. show or save). """ - - if not self._series_added: + self._render() + if not self._baseseries_added: raise RuntimeError( "Cannot render series since none has been added." "Use SeriesMap.add_rasters() or SeriesMap.add_vectors()" ) - # Make base image (background and baselayers) - # Random name needed to avoid potential conflict with layer names - random_name_base = gs.append_random("base", 8) + ".png" - base_file = os.path.join(self._tmpdir.name, random_name_base) - img = Map( - width=self._width, - height=self._height, - filename=base_file, - use_region=True, - env=self._env, - read_file=True, - ) - # We have to call d_erase to ensure the file is created. If there are no - # base layers, then there is nothing to render in random_base_name - img.d_erase() - # Add baselayers - self._render_baselayers(img) - # Render each layer - for i in range(self._series_length): + for i in range(self.baseseries): # Create file filename = os.path.join(self._tmpdir.name, f"{i}.png") # Copying the base_file ensures that previous results are overwritten - shutil.copyfile(base_file, filename) - self._layer_filename_dict[i] = filename + shutil.copyfile(self.base_file, filename) + self._base_filename_dict[i] = filename # Render image img = Map( width=self._width, @@ -238,76 +164,11 @@ def render(self): env=self._env, read_file=True, ) - for grass_module, kwargs in self._calls[i]: + for grass_module, kwargs in self._base_calls[i]: img.run(grass_module, **kwargs) self._layers_rendered = True - def show(self, slider_width=None): - """Create interactive timeline slider. - - param str slider_width: width of datetime selection slider - - The slider_width parameter sets the width of the slider in the output cell. - It should be formatted as a percentage (%) between 0 and 100 of the cell width - or in pixels (px). Values should be formatted as strings and include the "%" - or "px" suffix. For example, slider_width="80%" or slider_width="500px". - slider_width is passed to ipywidgets in ipywidgets.Layout(width=slider_width). - """ - # Lazy Imports - import ipywidgets as widgets # pylint: disable=import-outside-toplevel - - # Render images if they have not been already - if not self._layers_rendered: - self.render() - - # Set default slider width - if not slider_width: - slider_width = "70%" - - # Create lookup table for slider - lookup = list(zip(self._names, range(self._series_length))) - - # Datetime selection slider - slider = widgets.SelectionSlider( - options=lookup, - value=0, - disabled=False, - continuous_update=True, - orientation="horizontal", - readout=True, - layout=widgets.Layout(width=slider_width), - ) - play = widgets.Play( - interval=500, - value=0, - min=0, - max=self._series_length - 1, - step=1, - description="Press play", - disabled=False, - ) - out_img = widgets.Image(value=b"", format="png") - - def change_slider(change): - slider.value = slider.options[change.new][1] - - play.observe(change_slider, names="value") - - # Display image associated with datetime - def change_image(index): - # Look up layer name for date - filename = self._layer_filename_dict[index] - with open(filename, "rb") as rfile: - out_img.value = rfile.read() - - # Return interact widget with image and slider - widgets.interactive_output(change_image, {"index": slider}) - layout = widgets.Layout( - width="100%", display="inline-flex", flex_flow="row wrap" - ) - return widgets.HBox([play, slider, out_img], layout=layout) - def save( self, filename, @@ -337,7 +198,7 @@ def save( self.render() tmp_files = [] - for _, file in self._layer_filename_dict.items(): + for _, file in self._base_filename_dict.items(): tmp_files.append(file) save_gif( @@ -345,7 +206,7 @@ def save( filename, duration=duration, label=label, - labels=self._names, + labels=self._labels, font=font, text_size=text_size, text_color=text_color, diff --git a/python/grass/jupyter/tests/seriesmap_test.py b/python/grass/jupyter/tests/seriesmap_test.py index e19faf5f663..34b073308fc 100644 --- a/python/grass/jupyter/tests/seriesmap_test.py +++ b/python/grass/jupyter/tests/seriesmap_test.py @@ -20,7 +20,7 @@ def test_default_init(space_time_raster_dataset): """Check that TimeSeriesMap init runs with default parameters""" img = gj.SeriesMap() img.add_rasters(space_time_raster_dataset.raster_names) - assert img._names == space_time_raster_dataset.raster_names + assert img._labels == space_time_raster_dataset.raster_names def test_render_layers(space_time_raster_dataset): @@ -37,7 +37,7 @@ def test_render_layers(space_time_raster_dataset): # check files exist # We need to check values which are only in protected attributes # pylint: disable=protected-access - for unused_layer, filename in img._layer_filename_dict.items(): + for unused_layer, filename in img._base_filename_dict.items(): assert Path(filename).is_file() diff --git a/python/grass/jupyter/tests/timeseriesmap_test.py b/python/grass/jupyter/tests/timeseriesmap_test.py index d75ec8372e4..e99f03683f0 100644 --- a/python/grass/jupyter/tests/timeseriesmap_test.py +++ b/python/grass/jupyter/tests/timeseriesmap_test.py @@ -44,7 +44,7 @@ def test_default_init(space_time_raster_dataset): """Check that TimeSeriesMap init runs with default parameters""" img = gj.TimeSeriesMap() img.add_raster_series(space_time_raster_dataset.name) - assert img.timeseries == space_time_raster_dataset.name + assert img.baseseries == space_time_raster_dataset.name @pytest.mark.parametrize("fill_gaps", [False, True]) @@ -63,7 +63,7 @@ def test_render_layers(space_time_raster_dataset, fill_gaps): # check files exist # We need to check values which are only in protected attributes # pylint: disable=protected-access - for unused_date, filename in img._date_filename_dict.items(): + for unused_date, filename in img._base_filename_dict.items(): assert Path(filename).is_file() diff --git a/python/grass/jupyter/timeseriesmap.py b/python/grass/jupyter/timeseriesmap.py index 0d9c365b387..2dc0b4ede56 100644 --- a/python/grass/jupyter/timeseriesmap.py +++ b/python/grass/jupyter/timeseriesmap.py @@ -12,9 +12,7 @@ # for details. """Create and display visualizations for space-time datasets.""" -import tempfile import os -import weakref import shutil import grass.script as gs @@ -22,6 +20,7 @@ from .map import Map from .region import RegionManagerForTimeSeries from .utils import save_gif +from .baseseriesmap import BaseSeriesMap def fill_none_values(names): @@ -114,7 +113,7 @@ def check_timeseries_exists(timeseries, element_type): ) -class TimeSeriesMap: +class TimeSeriesMap(BaseSeriesMap): """Creates visualizations of time-space raster and vector datasets in Jupyter Notebooks. @@ -151,133 +150,84 @@ def __init__( :param saved_region: if name of saved_region is provided, this region is then used for rendering """ + super().__init__(width, height, env) - # Copy Environment - if env: - self._env = env.copy() - else: - self._env = os.environ.copy() - - self.timeseries = None self._element_type = None self._fill_gaps = None self._legend = None - self._base_layer_calls = [] - self._overlay_calls = [] - self._timeseries_added = False - self._layers_rendered = False self._layers = None - self._dates = None self._date_layer_dict = {} - self._date_filename_dict = {} - self._width = width - self._height = height - - # Create a temporary directory for our PNG images - # Resource managed by weakref.finalize. - self._tmpdir = ( - # pylint: disable=consider-using-with - tempfile.TemporaryDirectory() - ) - - def cleanup(tmpdir): - tmpdir.cleanup() - - weakref.finalize(self, cleanup, self._tmpdir) + self._slider_description = _("Date/Time") # Handle Regions self._region_manager = RegionManagerForTimeSeries( use_region, saved_region, self._env ) - def add_raster_series(self, timeseries, fill_gaps=False): + def add_raster_series(self, baseseries, fill_gaps=False): """ - :param str timeseries: name of space-time dataset + :param str baseseries: name of space-time dataset :param bool fill_gaps: fill empty time steps with data from previous step """ - if self._timeseries_added and self.timeseries != timeseries: + if self._baseseries_added and self.baseseries != baseseries: raise AttributeError("Cannot add more than one space time dataset") self._element_type = "strds" - check_timeseries_exists(timeseries, self._element_type) - self.timeseries = timeseries + check_timeseries_exists(baseseries, self._element_type) + self.baseseries = baseseries self._fill_gaps = fill_gaps - self._timeseries_added = True + self._baseseries_added = True # create list of layers to render and date/times - self._layers, self._dates = collect_layers( - self.timeseries, self._element_type, self._fill_gaps + self._layers, self._labels = collect_layers( + self.baseseries, self._element_type, self._fill_gaps ) self._date_layer_dict = { - self._dates[i]: self._layers[i] for i in range(len(self._dates)) + self._labels[i]: self._layers[i] for i in range(len(self._labels)) } # Update Region - self._region_manager.set_region_from_timeseries(self.timeseries) + self._region_manager.set_region_from_timeseries(self.baseseries) + self._indices = self._labels - def add_vector_series(self, timeseries, fill_gaps=False): + def add_vector_series(self, baseseries, fill_gaps=False): """ - :param str timeseries: name of space-time dataset + :param str baseseries: name of space-time dataset :param bool fill_gaps: fill empty time steps with data from previous step """ - if self._timeseries_added and self.timeseries != timeseries: + if self._baseseries_added and self.baseseries != baseseries: raise AttributeError("Cannot add more than one space time dataset") self._element_type = "stvds" - check_timeseries_exists(timeseries, self._element_type) - self.timeseries = timeseries + check_timeseries_exists(baseseries, self._element_type) + self.baseseries = baseseries self._fill_gaps = fill_gaps - self._timeseries_added = True + self._baseseries_added = True # create list of layers to render and date/times - self._layers, self._dates = collect_layers( - self.timeseries, self._element_type, self._fill_gaps + self._layers, self._labels = collect_layers( + self.baseseries, self._element_type, self._fill_gaps ) self._date_layer_dict = { - self._dates[i]: self._layers[i] for i in range(len(self._dates)) + self._labels[i]: self._layers[i] for i in range(len(self._labels)) } # Update Region - self._region_manager.set_region_from_timeseries(self.timeseries) - - def __getattr__(self, name): - """Parse attribute to GRASS display module. Attribute should be in - the form 'd_module_name'. For example, 'd.rast' is called with 'd_rast'. - """ - # Check to make sure format is correct - if not name.startswith("d_"): - raise AttributeError(_("Module must begin with 'd_'")) - # Reformat string - grass_module = name.replace("_", ".") - # Assert module exists - if not shutil.which(grass_module): - raise AttributeError(_("Cannot find GRASS module {}").format(grass_module)) - - def wrapper(**kwargs): - if not self._timeseries_added: - self._base_layer_calls.append((grass_module, kwargs)) - if self._timeseries_added: - self._overlay_calls.append((grass_module, kwargs)) - - return wrapper + self._region_manager.set_region_from_timeseries(self.baseseries) + self._indices = self._labels def d_legend(self, **kwargs): """Display legend. Wraps d.legend and uses same keyword arguments. """ - if "raster" in kwargs and not self._timeseries_added: + if "raster" in kwargs and not self._baseseries_added: self._base_layer_calls.append(("d.legend", kwargs)) - if "raster" in kwargs and self._timeseries_added: - self._overlay_calls.append(("d.legend", kwargs)) + if "raster" in kwargs and self._baseseries_added: + self._base_calls.append(("d.legend", kwargs)) else: self._legend = kwargs # If d_legend has been called, we need to re-render layers self._layers_rendered = False - def _render_baselayers(self, img): - """Add collected baselayers to Map instance""" - for grass_module, kwargs in self._base_layer_calls: - img.run(grass_module, **kwargs) - def _render_legend(self, img): """Add legend to Map instance""" info = gs.parse_command( - "t.info", input=self.timeseries, flags="g", env=self._env + "t.info", input=self.baseseries, flags="g", env=self._env ) min_min = info["min_min"] max_max = info["max_max"] @@ -289,7 +239,7 @@ def _render_legend(self, img): def _render_overlays(self, img): """Add collected overlays to Map instance""" - for grass_module, kwargs in self._overlay_calls: + for grass_module, kwargs in self._base_calls: img.run(grass_module, **kwargs) def _render_blank_layer(self, filename): @@ -339,31 +289,14 @@ def render(self): space-time datasets. """ - if not self._timeseries_added: + self._render() + if not self._baseseries_added: raise RuntimeError( "Cannot render space time dataset since none has been added." "Use TimeSeriesMap.add_raster_series() or " "TimeSeriesMap.add_vector_series() to add dataset" ) - # Make base image (background and baselayers) - # Random name needed to avoid potential conflict with layer names - random_name_base = gs.append_random("base", 8) + ".png" - base_file = os.path.join(self._tmpdir.name, random_name_base) - img = Map( - width=self._width, - height=self._height, - filename=base_file, - use_region=True, - env=self._env, - read_file=True, - ) - # We have to call d_erase to ensure the file is created. If there are no - # base layers, then there is nothing to render in random_base_name - img.d_erase() - # Add baselayers - self._render_baselayers(img) - # Create name for empty layers # Random name needed to avoid potential conflict with layer names # A new random_name_none is created each time the render function is run, @@ -375,83 +308,21 @@ def render(self): if layer == "None": # Create file filename = os.path.join(self._tmpdir.name, random_name_none) - self._date_filename_dict[date] = filename + self._base_filename_dict[date] = filename # Render blank layer if it hasn't been done already if not os.path.exists(filename): - shutil.copyfile(base_file, filename) + shutil.copyfile(self.base_file, filename) self._render_blank_layer(filename) else: # Create file filename = os.path.join(self._tmpdir.name, f"{layer}.png") # Copying the base_file ensures that previous results are overwritten - shutil.copyfile(base_file, filename) - self._date_filename_dict[date] = filename + shutil.copyfile(self.base_file, filename) + self._base_filename_dict[date] = filename # Render image self._render_layer(layer, filename) - self._layers_rendered = True - - def show(self, slider_width=None): - """Create interactive timeline slider. - - param str slider_width: width of datetime selection slider - - The slider_width parameter sets the width of the slider in the output cell. - It should be formatted as a percentage (%) between 0 and 100 of the cell width - or in pixels (px). Values should be formatted as strings and include the "%" - or "px" suffix. For example, slider_width="80%" or slider_width="500px". - slider_width is passed to ipywidgets in ipywidgets.Layout(width=slider_width). - """ - # Lazy Imports - import ipywidgets as widgets # pylint: disable=import-outside-toplevel - - # Render images if they have not been already - if not self._layers_rendered: - self.render() - # Set default slider width - if not slider_width: - slider_width = "70%" - - # Datetime selection slider - slider = widgets.SelectionSlider( - options=self._dates, - value=self._dates[0], - description=_("Date/Time"), - disabled=False, - continuous_update=True, - orientation="horizontal", - readout=True, - layout=widgets.Layout(width=slider_width), - ) - play = widgets.Play( - interval=500, - value=0, - min=0, - max=len(self._dates) - 1, - step=1, - description="Press play", - disabled=False, - ) - out_img = widgets.Image(value=b"", format="png") - - def change_slider(change): - slider.value = slider.options[change.new] - - play.observe(change_slider, names="value") - - # Display image associated with datetime - def change_image(date): - # Look up layer name for date - filename = self._date_filename_dict[date] - with open(filename, "rb") as rfile: - out_img.value = rfile.read() - - # Return interact widget with image and slider - widgets.interactive_output(change_image, {"date": slider}) - layout = widgets.Layout( - width="100%", display="inline-flex", flex_flow="row wrap" - ) - return widgets.HBox([play, slider, out_img], layout=layout) + self._layers_rendered = True def save( self, @@ -482,15 +353,15 @@ def save( self.render() input_files = [] - for date in self._dates: - input_files.append(self._date_filename_dict[date]) + for date in self._labels: + input_files.append(self._base_filename_dict[date]) save_gif( input_files, filename, duration=duration, label=label, - labels=self._dates, + labels=self._labels, font=font, text_size=text_size, text_color=text_color,