"""Base classes for all behaviors and forces."""
# Copyright (c) 2023 ipyforcegraph contributors.
# Distributed under the terms of the Modified BSD License.
from typing import Any, Optional, Union
import ipywidgets as W
import traitlets as T
from .._base import ForceBase
from ..trait_utils import JSON_TYPES, coerce
TFeature = Optional[Union["Column", "Nunjucks", str]]
TNumFeature = Optional[Union["Column", "Nunjucks", str, int, float]]
TBoolFeature = Optional[Union["Column", "Nunjucks", str, bool]]
[docs]class DEFAULT_RANK:
"""Ranks applied to different behaviors: lower values are applied first.
Ties are resolved by the arbitrary (but monotonically increasing) model id."
"""
#: selection behaviors should generally come earlier
selection = 10
#: shapes should resolve after selection, but before styling of default circles, etc.
shapes = 20
#: a default rank for behaviors: ties are resolved by class name, then "age"
behavior = 100
[docs]class Behavior(ForceBase):
"""The base class for all IPyForceGraph graph behaviors."""
_model_name: str = T.Unicode("BehaviorModel").tag(sync=True)
rank: int = T.Int(
DEFAULT_RANK.behavior,
help=("order in which behaviors are applied: lower numbers are applied first."),
).tag(sync=True)
[docs]class BaseD3Force(Behavior):
"""A base for all ``d3-force-3d`` force wrappers."""
_model_name: str = T.Unicode("BaseD3ForceModel").tag(sync=True)
active: bool = T.Bool(True, help="whether the force is currently active").tag(
sync=True
)
[docs]class ShapeBase(ForceBase):
"""A base class from which all :mod:`~ipyforcegraph.behaviors.shapes` inherit."""
_model_name: str = T.Unicode("ShapeBaseModel").tag(sync=True)
[docs]class DynamicValue(ForceBase):
"""An abstract class to describe what a Dynamic Widget Trait is and does."""
_model_name: str = T.Unicode("DynamicModel").tag(sync=True)
JSON_DATA_TYPES = JSON_TYPES.get_supported_types()
value: str = T.Unicode(
"", help="the source used to compute the value for the trait"
).tag(sync=True)
coerce: str = T.Unicode(
help="name of a JSON Schema ``type`` into which to coerce the final value",
allow_none=True,
).tag(sync=True)
def __init__(self, value: Optional[str], **kwargs: Any):
if value is not None:
kwargs["value"] = value
super().__init__(**kwargs)
@T.validate("coerce")
def _validate_coercer(self, proposal: T.Bunch) -> Optional[str]:
coerce: Optional[str] = proposal.value
if coerce is None:
return None
if not isinstance(coerce, str):
raise T.TraitError(f"'coerce' must be a string, not {type(coerce)}")
coerce = coerce.lower()
if coerce not in self.JSON_DATA_TYPES:
raise T.TraitError(
f"'coerce' must be one of {self.JSON_DATA_TYPES}, not '{coerce}'"
)
return coerce
[docs]class Column(DynamicValue):
"""A column from a :class:`~ipyforcegraph.sources.dataframe.DataFrameSource`."""
_model_name: str = T.Unicode("ColumnModel").tag(sync=True)
[docs]class Nunjucks(DynamicValue):
"""A `nunjucks template <https://mozilla.github.io/nunjucks/templating.html>`_ for calculating
dynamic values on the client.
The syntax is intentionally very similar to
`jinja2 <https://jinja.palletsprojects.com/en/3.1.x/templates>`_, and a number of extra
template functions are provided, including the methods and properties in
`JS Math <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math>`_.
All the data in the ``source`` is available as ``graphData``, which has ``nodes`` and ``links``.
Depending on the context, inside of a template, one can use ``node`` or ``link``, which will have
all their available columns available using the dot notation, e.g., ``node.id``. In addition,
``link`` will have ``source`` and ``target`` as realized ``nodes``.
For example, to dynamically set the ``attribute`` property of a ``behavior`` in the front-end
based on the ``id`` property of the source ``node`` of a given ``link``, you would:
.. code-block:: python
behavior.attribute = Nunjucks("{{ link.source.id }}")
With these, and basic template tools, one can generate all kinds of interesting effects.
.. code-block:: js+jinja
:caption: color by group
{{ ["red", "yellow", "blue", "orange", "purple", "magenta"][node.group] }}
.. code-block:: js+jinja
:caption: color by out-degree
{% set n = 0 %}
{% for link in graphData.links %}
{% if link.source.id == node.id %}{% set n = n + 1 %}{% endif %}
{% endfor %}
{% set c = 256 * (7-n) / 7 %}
rgb({{ c }},0,0)
"""
_model_name: str = T.Unicode("NunjucksModel").tag(sync=True)
def _make_trait(
help: str,
*,
default_value: Optional[Any] = None,
allow_none: bool = True,
boolish: bool = False,
by_column: bool = True,
by_template: bool = True,
by_wrapper: bool = True,
numeric: bool = False,
stringy: bool = True,
) -> Any:
"""Makes a Trait that can accept a Column, a Nunjuck Template, and a literal."""
types = (
([T.Bool()] if boolish else [])
+ ([T.Unicode()] if stringy else [])
+ ([T.Int(), T.Float()] if numeric else [])
+ ([T.Instance(Column)] if by_column else [])
+ ([T.Instance(Nunjucks)] if by_template else [])
+ (
[T.Instance("ipyforcegraph.behaviors.wrappers.WrapperBase")]
if by_wrapper
else []
)
)
return T.Union(
types, help=help, allow_none=allow_none, default_value=default_value
).tag(sync=True, **W.widget_serialization)
[docs]class HasScale(ShapeBase):
"""A shape that has ``scale_on_zoom``."""
_model_name: str = T.Unicode("HasScaleModel").tag(sync=True)
scale_on_zoom: TBoolFeature = _make_trait(
"whether font size/stroke respects the global scale. Has no impact on `link` shapes.",
boolish=True,
)
@T.validate("scale_on_zoom")
def _validate_scale_bools(self, proposal: T.Bunch) -> Any:
return coerce(proposal, JSON_TYPES.boolean)
[docs]class HasFillAndStroke(HasScale):
"""A shape that has ``fill`` and ``stroke``."""
_model_name: str = T.Unicode("HasFillModel").tag(sync=True)
fill: TFeature = _make_trait("the fill color of a shape")
stroke: TFeature = _make_trait("the stroke color of a shape")
stroke_width: TNumFeature = _make_trait("the stroke width of a shape", numeric=True)
line_dash: TFeature = _make_trait(
"the dash line pattern of the stroke, e.g., [2, 1] for ``-- -- --``",
stringy=False,
by_column=False,
)
@T.validate("stroke_width")
def _validate_has_fill_and_stroke_numerics(self, proposal: T.Bunch) -> Any:
return coerce(proposal, JSON_TYPES.number)
@T.validate("line_dash")
def _validate_has_fill_and_stroke_arrays(self, proposal: T.Bunch) -> Any:
return coerce(proposal, JSON_TYPES.array)
[docs]class HasOffsets(ShapeBase):
"""A shape that can be offset in the horizontal, vertical, or elevation dimensions."""
_model_name: str = T.Unicode("HasOffsetsModel").tag(sync=True)
offset_x: float = _make_trait(
"the relative horizontal offset from the middle of the shape in ``px``",
numeric=True,
)
offset_y: float = _make_trait(
"the relative vertical offset from the middle of the shape in ``px``",
numeric=True,
)
offset_z: float = _make_trait(
"the relative elevation offset from the middle of the shape in ``px``",
numeric=True,
)
@T.validate("offset_x", "offset_y", "offset_z")
def _validate_offset_numerics(self, proposal: T.Bunch) -> Any:
return coerce(proposal, JSON_TYPES.number)
[docs]class HasDimensions(HasFillAndStroke, HasOffsets):
"""A shape that has ``width``, ``height`` and ``depth``."""
_model_name: str = T.Unicode("HasDimensionsModel").tag(sync=True)
width: TNumFeature = _make_trait("the width of a shape in ``px``", numeric=True)
height: TNumFeature = _make_trait("the height of a shape in ``px``", numeric=True)
depth: TNumFeature = _make_trait("the depth of a shape in ``px``", numeric=True)
opacity: TNumFeature = _make_trait("the opacity of a shape", numeric=True)
@T.validate("width", "height", "depth", "opacity")
def _validate_dimension_numerics(self, proposal: T.Bunch) -> Any:
return coerce(proposal, JSON_TYPES.number)