"""
Application state
-----------------
Although the state in a redux store is defined by reducer functions,
engineers need documentation to extend and understand :class:`State`. Python
dataclasses are a natural fit to make state self-describing.
Expressing data structure as a nested hierarchy of types allows readers
of the code to understand how state is organised. It also allows for
type-checking to simplify functions that manipulate state.
State can be generated programmatically, converted to/from dict
to be compatible with reducers and to make it easier to serialize.
>>> state = forest.state.State()
>>> state.colorbar.name
'Viridis'
Converting to/from dict can be achieved
using :meth:`State.to_dict` and :meth:`State.from_dict`
>>> s1 = forest.state.State()
>>> d = s1.to_dict()
>>> type(d)
<class 'dict'>
>>> s2 = forest.state.State.from_dict(d)
>>> s1 == s2
True
The only caveat to be aware of while mapping to/from dict is that
:class:`State` implements default values for missing entries. A
default State is not equal to an empty dict.
>>> forest.state.State().to_dict() == {}
False
.. note:: State structure may change in future releases, backwards
compatibility is not guaranteed
"""
import datetime as dt
import bokeh.palettes
from dataclasses import dataclass, field, asdict
[docs]@dataclass
class Bokeh:
"""Additional bokeh state
Image streaming behaves inconsistently when data is streamed before
the DOM is ready, ``image_shape[t] is undefined`` errors are triggered
in the compiled JS
.. note:: HTML loaded is merely convenience and may be unnecessary in
future bokeh releases
"""
html_loaded: bool = False
[docs]@dataclass
class Limits:
"""
Color map extent, high and low represent upper and lower limits
respectively
:param low: lower limit
:type low: float
:param high: upper limit
:type high: float
"""
low: float = 0.0
high: float = 1.0
[docs]@dataclass
class ColorbarLimits:
"""Define user and column data source limits
:param origin: either 'user' or 'column_data_source'
:type origin: str
:param column_data_source: column_data_source limits
:type column_data_source: Limits
:param user: user limits
:type user: Limits
"""
origin: str = "column_data_source"
column_data_source: Limits = field(default_factory=Limits)
user: Limits = field(default_factory=Limits)
def __post_init__(self):
if isinstance(self.column_data_source, dict):
self.column_data_source = Limits(**self.column_data_source)
if isinstance(self.user, dict):
self.user = Limits(**self.user)
def _names_factory():
return list(sorted(bokeh.palettes.all_palettes.keys()))
def _numbers_factory():
return list(sorted(bokeh.palettes.all_palettes["Viridis"].keys()))
[docs]@dataclass
class Colorbar:
"""
Colorbar settings allow users to change palettes and limits
based on data or user-specified limits
:param name: bokeh palette name
:param number: bokeh palette number
:param limits: user and column_data_source limits
:type limits: ColorbarLimits
:param reverse: reverse color palette order
:param invisible_min: hide/show values below minimum
:param invisible_max: hide/show values above maximum
"""
name: str = "Viridis"
names: list = field(default_factory=_names_factory)
number: int = 256
numbers: list = field(default_factory=_numbers_factory)
limits: ColorbarLimits = field(default_factory=ColorbarLimits)
low: float = 0.0
high: float = 1.0
reverse: bool = False
invisible_min: bool = False
invisible_max: bool = False
def __post_init__(self):
if isinstance(self.limits, dict):
self.limits = ColorbarLimits(**self.limits)
def to_dict(self):
return asdict(self)
[docs]@dataclass
class LayerMode:
"""Data to control UI presented to user
Contains meta-data to indicate whether a layer is being edited or added. If
the layer is being edited an index can be used to specify settings to
overwrite.
:param state: Edit mode, either 'edit' or 'add'
:param index: Index of layer being edited
"""
state: str = "add"
index: int = 0
[docs]@dataclass
class Layers:
"""Layer settings
:param figures: Number of figures to display
:param index: Map layer index to settings
:param active: List of active layers
:param mode: Edit/new mode to define UI
:type mode: LayerMode
"""
figures: int = 1
index: dict = field(default_factory=dict)
active: list = field(default_factory=list)
mode: LayerMode = field(default_factory=LayerMode)
def __post_init__(self):
if isinstance(self.mode, dict):
self.mode = LayerMode(**self.mode)
def to_dict(self):
return asdict(self)
[docs]@dataclass
class Borders:
"""Cartopy border overlay settings
:param line_color: Color of coastlines and country borders
:type line_color: str
:param visible: Turn all lines on/off
:type visible: bool
"""
line_color: str = "black"
visible: bool = False
[docs]@dataclass
class Tile:
"""Web map tiling user-settings
:param name: Keyword to specify WMTS source
:type name: str
:param labels: Turn overlay labels on/off
:type labels: bool
"""
name: str = "Open street map"
labels: bool = False
[docs]@dataclass
class Position:
"""X/Y position in WebMercator coordinates related to user interaction
:param x: coordinate of tap event
:param y: coordinate of tap event
"""
x: float = 0.0
y: float = -1e9 # South pole
[docs]@dataclass
class Presets:
"""Re-usable layer settings
Presets are cooked up once and re-used anywhere, they can also
be tweaked on the fly and instantly made available to all layers
using them. They can also be serialised to disk to store/re-load
them as needed
:param active: currently chosen preset
:type active: int
:param labels: map index to label
:type active: dict
:param meta: data used by user interface
:type active: dict
"""
active: int = 0
labels: dict = field(default_factory=dict)
meta: dict = field(default_factory=dict)
[docs]@dataclass
class State:
"""Application State container
Nested data structure to define components, views
and behaviour.
:param pattern: User-selected value
:param patterns: Dataset-specific values
:param variable: User-selected value
:param variables: Dataset-specific values
:param initial_time: User-selected value
:param initial_times: Dataset-specific values
:param valid_time: User-selected value
:param valid_times: Dataset-specific values
:param pressure: User-selected value
:param pressures: Dataset-specific values
:param layers: Layer-specific settings
:type layers: Layers
:param colorbar: Color mapper controls
:type colorbar: Colorbar
:param tile: Web map tiling configuration
:type tile: Tile
:param tools: Turn profile/time_series on/off
:type tools: Tools
:param position: Used by tools to determine geographic position
:type position: Position
:param presets: Save colorbar settings for later re-use
:type presets: Presets
:param borders: Cartopy coastline, lakes and border settings
:type borders: Borders
:param bokeh: Additional bokeh state
:type bokeh: Bokeh
"""
pattern: str = None # TODO: Support empty str as default
patterns: list = field(default_factory=list)
variable: str = None # TODO: Support empty str as default
variables: list = field(default_factory=list)
initial_time: dt.datetime = dt.datetime(1970, 1, 1)
initial_times: list = field(default_factory=list)
valid_time: dt.datetime = dt.datetime(1970, 1, 1)
valid_times: list = field(default_factory=list)
pressure: float = 0.0
pressures: list = field(default_factory=list)
colorbar: Colorbar = field(default_factory=Colorbar)
layers: Layers = field(default_factory=Layers)
dimension: dict = field(default_factory=dict) # TODO: Find code using it
tile: Tile = field(default_factory=Tile)
tools: Tools = field(default_factory=Tools)
position: Position = field(default_factory=Position)
presets: Presets = field(default_factory=Presets)
borders: Borders = field(default_factory=Borders)
bokeh: Bokeh = field(default_factory=Bokeh)
def __post_init__(self):
"""Type-checking"""
if isinstance(self.bokeh, dict):
self.bokeh = Bokeh(**self.bokeh)
if isinstance(self.borders, dict):
self.borders = Borders(**self.borders)
if isinstance(self.colorbar, dict):
self.colorbar = Colorbar(**self.colorbar)
if isinstance(self.tile, dict):
self.tile = Tile(**self.tile)
if isinstance(self.tools, dict):
self.tools = Tools(**self.tools)
if isinstance(self.position, dict):
self.position = Position(**self.position)
if isinstance(self.layers, dict):
self.layers = Layers(**self.layers)
if isinstance(self.presets, dict):
self.presets = Presets(**self.presets)
[docs] @classmethod
def from_dict(cls, data: dict):
"""Factory method to convert from dict to State
:returns: State instance
:rtype: State
"""
return cls(**data)
[docs] def to_dict(self):
"""Map to dict representation of State
:returns: dictionary containing nested state data
:rtype: dict
"""
return asdict(self)