Skip to content

Visualization

The dissmodel.visualization module provides graphical and interactive representations of running simulations. All visualization components inherit from Model and are therefore integrated into the simulation clock — they update automatically at each step.

Three main components are available:

Component Substrate Description
Chart Any Time-series plots from tracked model variables
Map Vector (GeoDataFrame) Dynamic spatial maps updated each step
RasterMap Raster (NumPy) Raster array rendering — categorical or continuous

All three support three output targets: local matplotlib window, Jupyter inline display, and Streamlit st.empty() placeholder.


@track_plot

The track_plot decorator marks model attributes to be collected and plotted by Chart. Each call defines the variable label, colour, and plot type.

from dissmodel.core import Model
from dissmodel.visualization import track_plot

@track_plot("Susceptible", "green")
@track_plot("Infected",    "red")
@track_plot("Recovered",   "blue")
class SIR(Model):

    def setup(self, susceptible=9998, infected=2, recovered=0,
              duration=2, contacts=6, probability=0.25):
        self.susceptible = susceptible
        self.infected    = infected
        self.recovered   = recovered
        self.duration    = duration
        self.contacts    = contacts
        self.probability = probability

    def execute(self):
        total   = self.susceptible + self.infected + self.recovered
        alpha   = self.contacts * self.probability
        new_inf = self.infected * alpha * (self.susceptible / total)
        new_rec = self.infected / self.duration
        self.susceptible -= new_inf
        self.infected    += new_inf - new_rec
        self.recovered   += new_rec

Chart

Displays time-series data from variables annotated with @track_plot.

from dissmodel.core import Environment
from dissmodel.models.sysdyn import SIR
from dissmodel.visualization import Chart

env = Environment(end_time=30)
SIR()
Chart(show_legend=True)
env.run()

Streamlit:

Chart(plot_area=st.empty())

Map

Renders spatial data from a GeoDataFrame, updated at every simulation step.

from dissmodel.visualization.map  import Map
from matplotlib.colors import ListedColormap

Map(
    gdf=gdf,
    plot_params={
        "column": "state",
        "cmap": ListedColormap(["white", "black"]),
        "ec": "gray",
    },
)

RasterMap

Renders a named NumPy array from a RasterBackend. Supports categorical (value → colour mapping) and continuous (colormap + colorbar) modes.

Categorical:

from dissmodel.visualization.raster_map import RasterMap

RasterMap(
    backend   = b,
    band      = "uso",
    title     = "Land Use",
    color_map = {1: "#006400", 3: "#00008b", 5: "#d2b48c"},
    labels    = {1: "Mangrove", 3: "Sea", 5: "Bare soil"},
)

Continuous:

RasterMap(
    backend        = b,
    band           = "alt",
    title          = "Altimetry",
    cmap           = "terrain",
    colorbar_label = "Altitude (m)",
    mask_band      = "uso",
    mask_value     = 3,      # mask SEA cells
)

Headless (default when no display is available): frames are saved to raster_map_frames/<band>_step_NNN.png.


display_inputs

Generates Streamlit input widgets automatically from a model's type annotations. Integer and float attributes become sliders; booleans become checkboxes.

from dissmodel.visualization import display_inputs

sir = SIR()
display_inputs(sir, st.sidebar)

Full Streamlit example

import streamlit as st
from dissmodel.core import Environment
from dissmodel.models.sysdyn import SIR
from dissmodel.visualization import Chart, display_inputs

st.set_page_config(page_title="SIR Model", layout="centered")
st.title("SIR Model — DisSModel")

st.sidebar.title("Parameters")
steps   = st.sidebar.slider("Steps", min_value=1, max_value=50, value=10)
run_btn = st.button("Run")

env = Environment(end_time=steps, start_time=0)
sir = SIR()
display_inputs(sir, st.sidebar)
Chart(plot_area=st.empty())

if run_btn:
    env.run()

API Reference

dissmodel.visualization.chart.Chart

Bases: Model

Simulation model that renders a live time-series chart.

Extends :class:~dissmodel.core.Model and redraws the chart at every time step. Supports three rendering targets:

  • Streamlit — pass a plot_area (st.empty()).
  • Jupyter — detected automatically via :func:is_notebook.
  • Matplotlib window — fallback for plain Python scripts.

Parameters:

Name Type Description Default
select list of str

Subset of labels to plot. If None, all tracked variables are shown.

required
pause bool

If True, call plt.pause() after each update, by default True. Required for live updates outside notebooks.

required
plot_area any

Streamlit st.empty() placeholder, by default None.

required
show_legend bool

Whether to display the plot legend, by default True.

required
show_grid bool

Whether to display the plot grid, by default False.

required
title str

Chart title, by default "Variable History".

required

Examples:

>>> env = Environment(end_time=30)
>>> Chart(show_legend=True, show_grid=True, title="SIR Model")
>>> env.run()
Source code in dissmodel/visualization/chart.py
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
class Chart(Model):
    """
    Simulation model that renders a live time-series chart.

    Extends :class:`~dissmodel.core.Model` and redraws the chart at every
    time step. Supports three rendering targets:

    - **Streamlit** — pass a ``plot_area`` (``st.empty()``).
    - **Jupyter** — detected automatically via :func:`is_notebook`.
    - **Matplotlib window** — fallback for plain Python scripts.

    Parameters
    ----------
    select : list of str, optional
        Subset of labels to plot. If ``None``, all tracked variables are shown.
    pause : bool, optional
        If ``True``, call ``plt.pause()`` after each update, by default ``True``.
        Required for live updates outside notebooks.
    plot_area : any, optional
        Streamlit ``st.empty()`` placeholder, by default ``None``.
    show_legend : bool, optional
        Whether to display the plot legend, by default ``True``.
    show_grid : bool, optional
        Whether to display the plot grid, by default ``False``.
    title : str, optional
        Chart title, by default ``"Variable History"``.

    Examples
    --------
    >>> env = Environment(end_time=30)
    >>> Chart(show_legend=True, show_grid=True, title="SIR Model")
    >>> env.run()
    """

    fig: matplotlib.figure.Figure
    ax: matplotlib.axes.Axes
    select: Optional[list[str]]
    interval: int
    time_points: list[float]
    pause: bool
    plot_area: Any
    show_legend: bool
    show_grid: bool
    title: str

    def setup(
        self,
        select: Optional[list[str]] = None,
        pause: bool = True,
        plot_area: Any = None,
        show_legend: bool = True,
        show_grid: bool = False,
        title: str = "Variable History",
    ) -> None:
        """
        Configure the chart.

        Called automatically by salabim during component initialisation.

        Parameters
        ----------
        select : list of str, optional
            Subset of labels to plot. If ``None``, all tracked variables
            are shown.
        pause : bool, optional
            If ``True``, call ``plt.pause()`` after each update,
            by default ``True``.
        plot_area : any, optional
            Streamlit ``st.empty()`` placeholder, by default ``None``.
        show_legend : bool, optional
            Whether to display the plot legend, by default ``True``.
        show_grid : bool, optional
            Whether to display the plot grid, by default ``False``.
        title : str, optional
            Chart title, by default ``"Variable History"``.
        """
        self.select = select
        self.interval = 1
        self.time_points = []
        self.pause = pause
        self.plot_area = plot_area
        self.show_legend = show_legend
        self.show_grid = show_grid
        self.title = title

        if not is_notebook():
            self.fig, self.ax = plt.subplots()
            self.ax.set_xlabel("Time")
            self.ax.set_title(self.title)

    def execute(self) -> None:
        """
        Redraw the chart for the current simulation time step.

        Raises
        ------
        RuntimeError
            If no interactive matplotlib backend is detected and the code is
            not running in a notebook or Streamlit context.
        """
        if is_notebook():
            from IPython.display import clear_output
            clear_output(wait=True)
            self.fig, self.ax = plt.subplots()
            self.ax.set_xlabel("Time")
            self.ax.set_title(self.title)

        plt.sca(self.ax)
        self.time_points.append(self.env.now())

        plot_metadata: dict[str, Any] = getattr(self.env, "_plot_metadata", {})

        self.ax.clear()
        self.ax.set_xlabel("Time")
        self.ax.set_title(self.title)

        for label, info in plot_metadata.items():
            if self.select is None or label in self.select:
                self.ax.plot(info["data"], label=label, color=info["color"])

        if self.show_grid:
            self.ax.grid(True)

        if self.show_legend:
            self.ax.legend()

        self.ax.relim()
        self.ax.autoscale_view()
        plt.tight_layout()
        plt.draw()

        if self.plot_area is not None:
            self.plot_area.pyplot(self.fig)
        elif is_notebook():
            from IPython.display import display, Image
            buf = io.BytesIO()
            self.fig.savefig(buf, format="png")
            buf.seek(0)
            display(Image(data=buf.read()))
            plt.close(self.fig)
        elif self.pause:
            if is_interactive_backend():
                plt.pause(0.1)
                if self.env.now() == self.env.end_time:
                    plt.show()
            else:
                raise RuntimeError(
                    "No interactive matplotlib backend detected. "
                    "On Linux, install tkinter:\n\n"
                    "    sudo apt install python3-tk\n"
                )

execute()

Redraw the chart for the current simulation time step.

Raises:

Type Description
RuntimeError

If no interactive matplotlib backend is detected and the code is not running in a notebook or Streamlit context.

Source code in dissmodel/visualization/chart.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
def execute(self) -> None:
    """
    Redraw the chart for the current simulation time step.

    Raises
    ------
    RuntimeError
        If no interactive matplotlib backend is detected and the code is
        not running in a notebook or Streamlit context.
    """
    if is_notebook():
        from IPython.display import clear_output
        clear_output(wait=True)
        self.fig, self.ax = plt.subplots()
        self.ax.set_xlabel("Time")
        self.ax.set_title(self.title)

    plt.sca(self.ax)
    self.time_points.append(self.env.now())

    plot_metadata: dict[str, Any] = getattr(self.env, "_plot_metadata", {})

    self.ax.clear()
    self.ax.set_xlabel("Time")
    self.ax.set_title(self.title)

    for label, info in plot_metadata.items():
        if self.select is None or label in self.select:
            self.ax.plot(info["data"], label=label, color=info["color"])

    if self.show_grid:
        self.ax.grid(True)

    if self.show_legend:
        self.ax.legend()

    self.ax.relim()
    self.ax.autoscale_view()
    plt.tight_layout()
    plt.draw()

    if self.plot_area is not None:
        self.plot_area.pyplot(self.fig)
    elif is_notebook():
        from IPython.display import display, Image
        buf = io.BytesIO()
        self.fig.savefig(buf, format="png")
        buf.seek(0)
        display(Image(data=buf.read()))
        plt.close(self.fig)
    elif self.pause:
        if is_interactive_backend():
            plt.pause(0.1)
            if self.env.now() == self.env.end_time:
                plt.show()
        else:
            raise RuntimeError(
                "No interactive matplotlib backend detected. "
                "On Linux, install tkinter:\n\n"
                "    sudo apt install python3-tk\n"
            )

setup(select=None, pause=True, plot_area=None, show_legend=True, show_grid=False, title='Variable History')

Configure the chart.

Called automatically by salabim during component initialisation.

Parameters:

Name Type Description Default
select list of str

Subset of labels to plot. If None, all tracked variables are shown.

None
pause bool

If True, call plt.pause() after each update, by default True.

True
plot_area any

Streamlit st.empty() placeholder, by default None.

None
show_legend bool

Whether to display the plot legend, by default True.

True
show_grid bool

Whether to display the plot grid, by default False.

False
title str

Chart title, by default "Variable History".

'Variable History'
Source code in dissmodel/visualization/chart.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
def setup(
    self,
    select: Optional[list[str]] = None,
    pause: bool = True,
    plot_area: Any = None,
    show_legend: bool = True,
    show_grid: bool = False,
    title: str = "Variable History",
) -> None:
    """
    Configure the chart.

    Called automatically by salabim during component initialisation.

    Parameters
    ----------
    select : list of str, optional
        Subset of labels to plot. If ``None``, all tracked variables
        are shown.
    pause : bool, optional
        If ``True``, call ``plt.pause()`` after each update,
        by default ``True``.
    plot_area : any, optional
        Streamlit ``st.empty()`` placeholder, by default ``None``.
    show_legend : bool, optional
        Whether to display the plot legend, by default ``True``.
    show_grid : bool, optional
        Whether to display the plot grid, by default ``False``.
    title : str, optional
        Chart title, by default ``"Variable History"``.
    """
    self.select = select
    self.interval = 1
    self.time_points = []
    self.pause = pause
    self.plot_area = plot_area
    self.show_legend = show_legend
    self.show_grid = show_grid
    self.title = title

    if not is_notebook():
        self.fig, self.ax = plt.subplots()
        self.ax.set_xlabel("Time")
        self.ax.set_title(self.title)

dissmodel.visualization.map.Map

Bases: Model

Simulation model that renders a live choropleth map of a GeoDataFrame.

Parameters:

Name Type Description Default
gdf GeoDataFrame

GeoDataFrame to render.

required
plot_params dict

Keyword arguments forwarded to :meth:GeoDataFrame.plot.

required
figsize tuple[int, int]

Figure size in inches. Default: (10, 6).

required
pause bool

Call plt.pause() after each update in interactive mode.

required
interval float

Seconds passed to plt.pause(). Default: 0.01.

required
plot_area empty() | None

Streamlit placeholder. Default: None.

required
save_frames bool

Save one PNG per step to map_frames/. Default: False.

required

Examples:

>>> env = Environment(end_time=10)
>>> Map(gdf=grid, plot_params={"column": "state", "cmap": "viridis"})
>>> env.run()
Source code in dissmodel/visualization/map.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
class Map(Model):
    """
    Simulation model that renders a live choropleth map of a GeoDataFrame.

    Parameters
    ----------
    gdf : geopandas.GeoDataFrame
        GeoDataFrame to render.
    plot_params : dict
        Keyword arguments forwarded to :meth:`GeoDataFrame.plot`.
    figsize : tuple[int, int]
        Figure size in inches. Default: ``(10, 6)``.
    pause : bool
        Call ``plt.pause()`` after each update in interactive mode.
    interval : float
        Seconds passed to ``plt.pause()``. Default: ``0.01``.
    plot_area : st.empty() | None
        Streamlit placeholder. Default: ``None``.
    save_frames : bool
        Save one PNG per step to ``map_frames/``. Default: ``False``.

    Examples
    --------
    >>> env = Environment(end_time=10)
    >>> Map(gdf=grid, plot_params={"column": "state", "cmap": "viridis"})
    >>> env.run()
    """

    def setup(
        self,
        gdf:         gpd.GeoDataFrame,
        plot_params: dict[str, Any],
        figsize:     tuple[int, int] = (10, 6),
        pause:       bool            = True,
        interval:    float           = 0.01,
        plot_area:   Any             = None,
        save_frames: bool            = False,
    ) -> None:
        self.gdf         = gdf
        self.plot_params = plot_params
        self.figsize     = figsize
        self.pause       = pause
        self.interval    = interval
        self.plot_area   = plot_area
        self.save_frames = save_frames

        # always create fig so _render() can always call self.fig.clf()
        self.fig, self.ax = plt.subplots(1, 1, figsize=self.figsize)

        # close immediately if not needed for interactive mode
        if is_notebook() or plot_area is not None:
            plt.close(self.fig)

    # ── rendering ─────────────────────────────────────────────────────────────

    def _render(self, step: float) -> matplotlib.figure.Figure:
        if is_notebook() or self.plot_area is not None:
            # create a fresh figure every step
            self.fig, self.ax = plt.subplots(1, 1, figsize=self.figsize)
        else:
            # reuse existing figure — clear and redraw
            self.fig.clf()
            self.ax = self.fig.add_subplot(1, 1, 1)

        self.gdf.plot(ax=self.ax, **self.plot_params)
        self.ax.set_title(f"Map — Step {int(step)}")
        plt.tight_layout()
        plt.draw()
        return self.fig

    def _save_frame(self, fig: matplotlib.figure.Figure, step: float) -> None:
        col     = self.plot_params.get("column", "map")
        out_dir = pathlib.Path("map_frames")
        out_dir.mkdir(exist_ok=True)
        fname = out_dir / f"{col}_step_{int(step):03d}.png"
        fig.savefig(fname, dpi=100, bbox_inches="tight",
                    facecolor=fig.get_facecolor())
        plt.close(fig)
        end_time = getattr(self.env, "end_time", step)
        if int(step) % 10 == 0 or step == end_time:
            print(f"  Map [{col}] step {int(step):3d}{fname}")

    # ── execute ───────────────────────────────────────────────────────────────

    def execute(self) -> None:
        step = self.env.now()
        fig  = self._render(step)

        if self.plot_area is not None:
            # Streamlit
            self.plot_area.pyplot(fig)
            plt.close(fig)

        elif is_notebook():
            # Jupyter
            from IPython.display import clear_output, display
            clear_output(wait=True)
            display(fig)
            plt.close(fig)

        elif self.save_frames or not is_interactive_backend():
            # headless / CI
            self._save_frame(fig, step)

        else:
            # interactive window
            if self.pause:
                plt.pause(self.interval)
            end_time = getattr(self.env, "end_time", step)
            if step == end_time:
                plt.show()

dissmodel.visualization.raster_map.RasterMap

Bases: Model

Visualization model for RasterBackend.

Parameters:

Name Type Description Default
backend RasterBackend
required
band str

Array to visualize.

required
title str

Figure title prefix. Default: "RasterMap".

required
figsize tuple[int, int]

Default: (7, 7).

required
pause bool

Use plt.pause() in interactive mode. Default: True.

required
interval float

Seconds between steps in interactive mode. Default: 0.5.

required
plot_area empty() | None

Streamlit placeholder.

required
auto_mask bool

Apply the backend's extent mask automatically so pixels outside the study area are transparent. Default: True. Set to False to restore the pre-v2 behaviour.

required
Categorical mode (color_map provided)

color_map : dict[int, str] {value: "#rrggbb"} labels : dict[int, str] {value: "label"} — used in the legend.

Continuous mode (color_map absent)

cmap : str Matplotlib colormap name. Default: "viridis". scheme : str "manual" — use vmin / vmax (default). "equal_interval" — divide [min, max] of valid data into k classes. "quantiles" — p2–p98 of valid data, robust to outliers. k : int Number of colour classes for scheme="equal_interval". Default: 5. vmin, vmax : float | None Bounds for scheme="manual". legend : bool Show the colorbar. Default: True. colorbar_label : str Colorbar label. Default: band. mask_band : str | None Additional domain mask (e.g. mask sea cells for altimetry). Applied on top of the automatic extent mask. mask_value : int | float | None Value in mask_band to mask.

Source code in dissmodel/visualization/raster_map.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
class RasterMap(Model):
    """
    Visualization model for RasterBackend.

    Parameters
    ----------
    backend : RasterBackend
    band : str
        Array to visualize.
    title : str
        Figure title prefix. Default: ``"RasterMap"``.
    figsize : tuple[int, int]
        Default: ``(7, 7)``.
    pause : bool
        Use ``plt.pause()`` in interactive mode. Default: ``True``.
    interval : float
        Seconds between steps in interactive mode. Default: ``0.5``.
    plot_area : st.empty() | None
        Streamlit placeholder.
    auto_mask : bool
        Apply the backend's extent mask automatically so pixels outside
        the study area are transparent. Default: ``True``.
        Set to ``False`` to restore the pre-v2 behaviour.

    Categorical mode  (``color_map`` provided)
    -------------------------------------------
    color_map : dict[int, str]
        ``{value: "#rrggbb"}``
    labels : dict[int, str]
        ``{value: "label"}`` — used in the legend.

    Continuous mode  (``color_map`` absent)
    ----------------------------------------
    cmap : str
        Matplotlib colormap name. Default: ``"viridis"``.
    scheme : str
        ``"manual"``         — use ``vmin`` / ``vmax`` (default).
        ``"equal_interval"`` — divide [min, max] of valid data into ``k`` classes.
        ``"quantiles"``      — p2–p98 of valid data, robust to outliers.
    k : int
        Number of colour classes for ``scheme="equal_interval"``. Default: ``5``.
    vmin, vmax : float | None
        Bounds for ``scheme="manual"``.
    legend : bool
        Show the colorbar. Default: ``True``.
    colorbar_label : str
        Colorbar label. Default: ``band``.
    mask_band : str | None
        Additional domain mask (e.g. mask sea cells for altimetry).
        Applied on top of the automatic extent mask.
    mask_value : int | float | None
        Value in ``mask_band`` to mask.
    """

    def setup(
        self,
        backend,
        band:            str              = "state",
        title:           str              = "RasterMap",
        figsize:         tuple[int, int]  = (7, 7),
        pause:           bool             = True,
        interval:        float            = 0.5,
        plot_area:       Any              = None,
        auto_mask:       bool             = True,
        save_frames:     bool             = False,
        # categorical
        color_map:       dict[int, str] | None = None,
        labels:          dict[int, str] | None = None,
        # continuous
        cmap:            str              = "viridis",
        scheme:          str              = "manual",
        k:               int              = 5,
        vmin:            float | None     = None,
        vmax:            float | None     = None,
        legend:          bool             = True,
        colorbar_label:  str | None       = None,
        mask_band:       str | None       = None,
        mask_value:      int | float | None = None,
    ) -> None:
        self.backend        = backend
        self.band           = band
        self.title          = title
        self.figsize        = figsize
        self.pause          = pause
        self.interval       = interval
        self.plot_area      = plot_area
        self.auto_mask      = auto_mask
        self.save_frames    = save_frames
        self.color_map      = color_map
        self.labels         = labels or {}
        self.cmap           = cmap
        self.scheme         = scheme
        self.k              = k
        self.vmin           = vmin
        self.vmax           = vmax
        self.legend         = legend
        self.colorbar_label = colorbar_label or band
        self.mask_band      = mask_band
        self.mask_value     = mask_value

        # resolve extent mask once at setup — not on every frame
        self._extent_mask: np.ndarray | None = (
            _get_nodata_mask(backend) if auto_mask else None
        )

    # ── rendering ─────────────────────────────────────────────────────────────

    def _render(self, step: float) -> matplotlib.figure.Figure:
        # each RasterMap owns its own figure — reuse across steps
        # so multiple instances show as separate windows simultaneously
        if not hasattr(self, "_fig") or self._fig is None \
                or not plt.fignum_exists(self._fig.number):
            self._fig, self._ax = plt.subplots(figsize=self.figsize)
        else:
            self._fig.clf()
            self._ax = self._fig.add_subplot(1, 1, 1)

        fig, ax = self._fig, self._ax

        arr = self.backend.arrays.get(self.band)
        if arr is None:
            ax.text(0.5, 0.5, f"band '{self.band}' not found",
                    ha="center", va="center", transform=ax.transAxes)
            ax.set_title(f"{self.title} [{self.band}] — Step {int(step)}")
            return fig

        if self.color_map:
            self._render_categorical(ax, arr)
        else:
            self._render_continuous(ax, arr)

        ax.set_xticks([])
        ax.set_yticks([])
        ax.set_title(f"{self.title} [{self.band}] — Step {int(step)}")
        plt.tight_layout()
        return fig

    def _apply_masks(self, data: np.ndarray) -> np.ma.MaskedArray:
        """
        Aplica todas as máscaras em ordem e retorna um MaskedArray:
        1. extent mask automático (auto_mask=True)
        2. mask_band / mask_value  (domínio, ex: mar para altimetria)
        3. NaN / Inf residuais
        """
        # 1. extent mask — pixels fora do estudo
        if self._extent_mask is not None:
            data = np.where(self._extent_mask, data, np.nan)

        # 2. domain mask — ex: mascarar mar na visualização de altimetria
        if self.mask_band is not None and self.mask_value is not None:
            mask_arr = self.backend.arrays.get(self.mask_band)
            if mask_arr is not None:
                data = np.where(mask_arr == self.mask_value, np.nan, data)

        # 3. cobre NaN / Inf (inclui os inseridos pelos passos acima)
        return np.ma.masked_invalid(data)

    def _render_categorical(self, ax, arr: np.ndarray) -> None:
        vals = sorted(self.color_map)
        cmap = mcolors.ListedColormap([self.color_map[v] for v in vals])
        norm = mcolors.BoundaryNorm(
            [v - 0.5 for v in vals] + [vals[-1] + 0.5], cmap.N
        )
        cmap.set_bad(color="white", alpha=0)
        data = self._apply_masks(arr.astype(float))

        ax.imshow(data, cmap=cmap, norm=norm, aspect="equal", interpolation="nearest")

        present = set(np.unique(arr[~np.isnan(arr.astype(float))]))
        patches = [
            matplotlib.patches.Patch(color=self.color_map[v],
                                     label=self.labels.get(v, str(v)))
            for v in vals if v in present
        ]
        if patches:
            ax.legend(handles=patches, loc="lower right", fontsize=7, framealpha=0.7)

    def _render_continuous(self, ax, arr: np.ndarray) -> None:
        data  = self._apply_masks(arr.astype(float))
        cmap  = plt.get_cmap(self.cmap).copy()
        cmap.set_bad(color="white", alpha=0)

        valid = data.compressed()

        if len(valid) == 0:
            vmin, vmax = 0.0, 1.0

        elif self.scheme == "equal_interval":
            vmin = float(valid.min())
            vmax = float(valid.max())
            if vmin != vmax and self.k > 1:
                bounds = np.linspace(vmin, vmax, self.k + 1)
                norm   = mcolors.BoundaryNorm(bounds, plt.get_cmap(self.cmap).N)
                im = ax.imshow(data, cmap=cmap, norm=norm, aspect="equal",
                               interpolation="nearest")
                if self.legend:
                    plt.colorbar(im, ax=ax, label=self.colorbar_label,
                                 fraction=0.03, pad=0.02)
                return

        elif self.scheme == "quantiles":
            vmin = float(np.percentile(valid, 2))
            vmax = float(np.percentile(valid, 98))

        else:   # "manual"
            vmin = self.vmin if self.vmin is not None else float(valid.min())
            vmax = self.vmax if self.vmax is not None else float(valid.max())

        if vmin == vmax:
            vmax = vmin + 1.0

        im = ax.imshow(data, cmap=cmap, aspect="equal",
                       interpolation="nearest", vmin=vmin, vmax=vmax)
        if self.legend:
            plt.colorbar(im, ax=ax, label=self.colorbar_label,
                         fraction=0.03, pad=0.02)

    # ── execute ───────────────────────────────────────────────────────────────

    def execute(self) -> None:
        step = self.env.now()
        fig  = self._render(step)
        plt.draw()

        if self.plot_area is not None:
            self.plot_area.pyplot(fig)
            plt.close(fig)

        elif is_notebook():
            from IPython.display import clear_output, display
            clear_output(wait=True)
            display(fig)
            plt.close(fig)

        elif self.save_frames or not is_interactive_backend():
            out_dir = pathlib.Path("raster_map_frames")
            out_dir.mkdir(exist_ok=True)
            fname = out_dir / f"{self.band}_step_{int(step):03d}.png"
            fig.savefig(fname, dpi=100, bbox_inches="tight",
                        facecolor=fig.get_facecolor())
            plt.close(fig)
            end_time = getattr(self.env, "end_time", step)
            if int(step) % 10 == 0 or step == end_time:
                print(f"  RasterMap [{self.band}] step {int(step):3d}{fname}")

        else:
            if self.pause:
                plt.pause(self.interval)
            end_time = getattr(self.env, "end_time", step)
            if step == end_time:
                plt.show()