"""
This module contains code related to colors.
Many of the objects gewel can render can be
rendered in a variety of colors. There are two
main classes of color that you are likely to
use, :py:class:`gewel.color.Color` and
:py:class:`gewel.color.ColorMap`,
both of which derive from the abstract base class
:py:class:`gewel.color.BaseColor`.
In many cases, you will not explicitly construct
objects of these classes, but rather use existing
color objects that are already defined for you. For
example:
.. code-block:: python
import gewel.color as color
my_color = color.RED
my_other_color = color.DARK_GREY
my_drawable.line_color = color.PURPLE
Of course, you can also create your own custom colors.
For example:
.. code-block:: python
import gewel.color as color
my_favorite_color = color.Color(0.6235, 0.8863, 0.7490)
dark_shadow_color = color.Color(0.75, 0.75, 0.75, 0.25)
In the first case, ``my_favorite_color``, we
created a custom color by specifying the relative amounts of
red, green, and blue on a scale of 0.0 (none of the color) to 1.0
(the maximum amount).
In the second case, ``dark_shadow_color``, we
added an optional fourth
argument to specify the alpha of the color.
The alpha indicates how transparent the color
should be. 0.0 means completely transparent. 1.0 means
completely opaque. Leaving it out as we did
in ``my_favorite_color`` is equivalent to passing
in the default value of 1.0.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Callable, Iterable, Optional, List, Tuple, Union
import colorcet as cc
import tvx
from gewel._timekeeper import TimekeeperMixin
from tvx import FloatOrTVF
[docs]class BaseColor(ABC):
"""
The abstract base class for colors.
"""
[docs] @abstractmethod
def tuple(
self, t: float, alpha_multiplier: tvx.FloatOrTVF = 1.0
) -> Tuple[float, float, float, float]:
"""
Construct a `(red, green, blue, alpha)` tuple representing the color.
The red, green, and blue
components are determined by the color. The alpha component may be affected
by the value of the alpha_multiplier parameter.
Parameters
----------
t
The time for which we want the tuple.
alpha_multiplier
A multiplicative factor for the alpha. The value should be between
0.0 and 1.0. It will be multiplied by the alpha of the color to
produce the fourth element of the resulting tuple. Passing in 0.0
will produce alpha transparent result (alpha = 0.0) while passing in
the default value of 1.0 will leave the alpha of the color unchanged.
Returns
-------
tuple
A four element tuple, `(red, green, blue, alpha)` of color components for red,
green, blue, and alpha (opacity). All are in the range [0.0, 1.0].
"""
raise NotImplementedError(str(type(self)) + " is abstract.")
[docs] @abstractmethod
def is_transparent(self) -> bool:
"""
Indicates whether the color is transparent or not, i.e. whether
its alpha is certain to be zero. Checking this may be useful in
checking whether something needs to be rendered or not. If the
color it would be rendered is transparent then it does not.
Returns
-------
bool
True if the color is transparent. False otherwise.
"""
raise NotImplementedError(str(type(self)) + " is abstract.")
[docs]@dataclass(init=False)
class Color(BaseColor):
"""
A color with three components red, green, and blue representing
the relative level or red, green, and blue respectively,
and alpha fourth component alpha (alpha) representing opacity.
All should be in the range [0.0, 1.0]. Note that these
do not need to be fixed floating point values. They can
also be time varying floats of type ~tvx.Tvf.
An alpha value of 1.0 indicates opaque and 0.0 indicates
transparent. Values in between are semi-transparent.
Parameters
----------
red
Red color component.
green
Blue color component.
blue
Green color component.
alpha
Opacity. 0.0 means transparent. 1.0 means opaque.
Values in between indicate partial transparency.
"""
red: FloatOrTVF
"""Red color component."""
green: FloatOrTVF
"""Green color component."""
blue: FloatOrTVF
"""Blue color component."""
alpha: FloatOrTVF
"""Opacity. 0.0 means transparent. 1.0 means opaque.
Values in between indicate partial transparency.
"""
def __init__(
self,
red: FloatOrTVF,
green: FloatOrTVF,
blue: FloatOrTVF,
alpha: Optional[FloatOrTVF] = None
):
if alpha is None:
alpha = 1.0
self.red = red
self.green = green
self.blue = blue
self.alpha = alpha
[docs] def tuple(self, t: float, alpha_multiplier: tvx.FloatOrTVF = 1.0) -> Tuple[float, float, float, float]:
am = float(alpha_multiplier)
t = float(self.red), float(self.green), float(self.blue), float(self.alpha) * am
return t
[docs] def is_transparent(self) -> bool:
return float(self.alpha) == 0.0
_hex_digits = set('0123456789abcdefABCDEF')
[docs] @classmethod
def from_string(cls, hex_str: str) -> 'Color':
"""
Construct color from a hexadecimal string.
The string
should be of the form ``"#RRGGBB"`` or ``"#RRGGBBAA"`` where
the characters ``RR`` are two hexadecimal digits representing
the level of red in the color, ``BB`` is for blue, ``GG`` is for
green, and ``AA`` is for the alpha component. Note that if
``AA`` is not present the color will be fully opaque (equivalent
to an alpha of ``"FF"`` in hex.
For example::
red = Color.from_string('#FF0000') # implied alpha
red_a = Color.from_string('#FF0000FF') # explicit alpha
semi_transparent_red = Color.from_string('#FF00007F')
produces three color objects. The first two are the exact same
opaque red color. The third is also red, but it is semi-transparent,
so when it is drawn, objects drawn under it will partially show
through.
There are many different color tables and pickers available to
help you choose the hex strings for colors you might wish to
use. The `Wikipedia page on web colors https://en.wikipedia.org/wiki/Web_colors`
is a good place to start.
Parameters
----------
hex_str
The hex string to parse out into a color.
Returns
-------
Color
A newly constructed object representing the color
specified by the hex string.
Raises
------
ValueError
if the string is not a proper hex string of the
form ``"#RRGGBB"`` or ``"#RRGGBBAA"``.
"""
# Make sure the format is good.
good_string = True
good_string = good_string and hex_str.startswith('#')
good_string = good_string and len(hex_str) in [7, 9]
for c in hex_str[1:]:
good_string = good_string and c in Color._hex_digits
if not good_string:
break
if not good_string:
raise ValueError("Color string must be of the format '#RRGGBB' or '#RRGGBBAA' " +
"with components specified as hex digits. Received '{:s}'".format(hex_str))
red = int(hex_str[1:3], 16) / 255.0
green = int(hex_str[3:5], 16) / 255.0
blue = int(hex_str[5:7], 16) / 255.0
alpha = int(hex_str[7:9], 16) / 255.0 if len(hex_str) == 9 else 1.0
return Color(red, green, blue, alpha)
@classmethod
def from_tuple(
cls,
tup: Union[
Tuple[FloatOrTVF, FloatOrTVF, FloatOrTVF],
Tuple[FloatOrTVF, FloatOrTVF, FloatOrTVF, FloatOrTVF]
]
) -> 'Color':
return Color(*tup)
def with_alpha(self, alpha: float) -> 'Color':
return Color(self.red, self.green, self.blue, alpha)
TRANSPARENT = Color(0.0, 0.0, 0.0, 0.0)
"""
This is a completely transparent color. It is useful
when there are components of a drawable object that
we don't want to render. It is also commonly used as
a default color for portions of a drawable object.
For example, the class :py:class:`~gewel.draw.Box`
has both a color used to draw the border of the box
and a fill color to fill it with. Either can be transparent
as follows::
from gewel.draw import Box
from gewel.color import RED, TRANSPARENT
# A red box that is not filled with any color.
box1 = Box(color=RED, fill_color=TRANSPARENT)
# A box filled with red but with no border.
box2 = Box(color=TRANSPARENT, fill_color=RED)
"""
WHITE = Color(1.0, 1.0, 1.0) #: White
LIGHT_GRAY = Color(0.75, 0.75, 0.75) #: Light Gray
GRAY = Color(0.5, 0.5, 0.5) #: Gray
DARK_GRAY = Color(0.25, 0.25, 0.25) #: Dark Gray
BLACK = Color(0.0, 0.0, 0.0) #: Black
RED = Color(1.0, 0.0, 0.0) #: Red
GREEN = Color(0.0, 1.0, 0.0) #: Green
BLUE = Color(0.0, 0.0, 1.0) #: Blue
CYAN = Color(0.0, 1.0, 1.0) #: Cyan
MAGENTA = Color(1.0, 0.0, 1.0) #: Magenta
YELLOW = Color(1.0, 1.0, 0.0) #: Yellow
MAROON = Color(0.5, 0.0, 0.0) #: Maroon
DARK_GREEN = Color(0.0, 0.5, 0.0) #: Dark Green
NAVY = Color(0.0, 0.0, 0.5) #: Navy
TEAL = Color(0.0, 0.5, 0.5) #: Teal
PURPLE = Color(0.5, 0.0, 0.5) #: Purple
OLIVE = Color(0.5, 0.5, 0.0) #: Olive
ORANGE = Color(1.0, 165.0 / 255.0, 0.0) #: Orange
PINK = Color(1.0, 192.0 / 255.0, 203.0 / 255.0) #: Pink
BACKGROUND = Color(240.0 / 255.0, 240.0 / 255.0, 240.0 / 255.0)
"""
This is a neutral color suitable for use as a background
in a variety of settings. It is a not quite bright white.
It is the default color for background objects of the class
:py:class:`~gewel.draw.Background`.
"""
# Follow matplotlib, which follows Vega and d3, which follow Tableau.
# https://matplotlib.org/stable/users/dflt_style_changes.html#colors-in-default-property-cycle
# https://github.com/vega/vega/wiki/Scales#scale-range-literals
# https://github.com/d3/d3-3.x-api-reference/blob/master/Ordinal-Scales.md#category10
CATEGORY_10 = [
Color.from_string(s)
for s in [
'#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf'
]
]
[docs]def category10(index: int) -> Color:
return CATEGORY_10[index % 10]
# d3: https://github.com/d3/d3-3.x-api-reference/blob/master/Ordinal-Scales.md#category20
CATEGORY_20 = [
Color.from_string(s)
for s in [
'#1f77b4', '#aec7e8', '#ff7f0e', '#ffbb78', '#2ca02c',
'#98df8a', '#d62728', '#ff9896', '#9467bd', '#c5b0d5',
'#8c564b', '#c49c94', '#e377c2', '#f7b6d2', '#7f7f7f',
'#c7c7c7', '#bcbd22', '#dbdb8d', '#17becf', '#9edae5'
]
]
[docs]def category20(index: int) -> Color:
return CATEGORY_20[index % 20]
[docs]class ColorMap(BaseColor, TimekeeperMixin):
"""
A color map is a color that is derived from other colors.
Parameters
----------
rgb_colors
A list of colors that make up the map.
position
The location in the map whose color should be
returned by :py:meth:`~ColorMap.tuple`.
"""
def __init__(
self,
rgb_colors: Iterable[List[float]],
position: Optional[FloatOrTVF] = None,
min_position: float = 0.0,
max_position: float = 1.0,
):
self._rgb_colors = list(rgb_colors)
if position is None:
position = min_position
self._position = position
self._min_position = min_position
self._max_position = max_position
self._time = 0.0
@property
def r(self) -> 'ColorMap':
return ColorMap(
reversed(self._rgb_colors),
self.position, self._min_position, self._max_position
)
@property
def time(self) -> float:
return self._time
def set_time(self, time: float):
self._time = time
@property
def position(self) -> FloatOrTVF:
return self._position
@position.setter
def position(self, position: FloatOrTVF):
self._position = position
[docs] def tuple(self, t: float, alpha_multiplier: tvx.FloatOrTVF = 1.0) -> Tuple[float, float, float, float]:
position = tvx.float_at_time(self._position, t)
pos = ((position - self._min_position) /
(self._max_position - self._min_position))
pos = min(1.0, max(0.0, pos))
n = len(self._rgb_colors)
ii, rem = divmod(pos * (n - 1), 1)
ii = int(ii)
if rem == 0.0:
rgb = self._rgb_colors[ii]
else:
rgb = [
c0 * (1 - rem) + c1 * rem
for c0, c1 in zip(self._rgb_colors[ii], self._rgb_colors[ii + 1])
]
alpha = 1.0 if len(rgb) < 4 else rgb[3]
return rgb[0], rgb[1], rgb[2], alpha * float(alpha_multiplier)
[docs] def is_transparent(self) -> bool:
return False
def fade_to_position(self, position: FloatOrTVF, duration: float):
self.ramp_attr_to('position', position, duration)
@classmethod
def from_colors(
cls,
colors: Iterable[Color],
position: Optional[FloatOrTVF] = None,
min_position: float = 0.0,
max_position: float = 1.0,
) -> 'ColorMap':
return ColorMap(
[list(color.tuple(0)) for color in colors],
position=position,
min_position=min_position,
max_position=max_position,
)
def _reversible_color_map(func: Callable[[FloatOrTVF, float, float], ColorMap]):
def reverse_color_func(
position: FloatOrTVF = 0.0,
min_position: float = 0.0,
max_position: float = 1.0,
) -> ColorMap:
return func(position, min_position, max_position).r
func.r = reverse_color_func
return func
def _color_map_factory(rgb_list: List[List[float]]):
@_reversible_color_map
def func(
position: FloatOrTVF = 0.0,
min_position: float = 0.0, max_position: float = 1.0
) -> ColorMap:
return ColorMap(rgb_list, position, min_position, max_position)
return func
# Slurp in all of the colors from cc.
for a in dir(cc):
if not a.startswith('_'):
v = getattr(cc, a)
if isinstance(v, list) and isinstance(v[0], list) and len(v[0]) == 3:
globals()[a] = _color_map_factory(v)
for mapping in [cc.aliases, cc.aliases_v2, cc.mapping_flipped]:
name = mapping.get(a, None)
if name is not None:
globals()[name] = globals()[a]
# Like blues, but swapping blue and green.
[docs]def greens(
position,
min_position: float = 0.0,
max_position: float = 1.0
):
return ColorMap(
[[row[0], row[2], row[1]] for row in cc.linear_blue_95_50_c20],
position=position,
min_position=min_position,
max_position=max_position
)