properties.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839
  1. from __future__ import annotations
  2. import itertools
  3. import warnings
  4. import numpy as np
  5. from pandas import Series
  6. import matplotlib as mpl
  7. from matplotlib.colors import to_rgb, to_rgba, to_rgba_array
  8. from matplotlib.path import Path
  9. from seaborn._core.scales import Scale, Boolean, Continuous, Nominal, Temporal
  10. from seaborn._core.rules import categorical_order, variable_type
  11. from seaborn._compat import MarkerStyle
  12. from seaborn.palettes import QUAL_PALETTES, color_palette, blend_palette
  13. from seaborn.utils import get_color_cycle
  14. from typing import Any, Callable, Tuple, List, Union, Optional
  15. try:
  16. from numpy.typing import ArrayLike
  17. except ImportError:
  18. # numpy<1.20.0 (Jan 2021)
  19. ArrayLike = Any
  20. RGBTuple = Tuple[float, float, float]
  21. RGBATuple = Tuple[float, float, float, float]
  22. ColorSpec = Union[RGBTuple, RGBATuple, str]
  23. DashPattern = Tuple[float, ...]
  24. DashPatternWithOffset = Tuple[float, Optional[DashPattern]]
  25. MarkerPattern = Union[
  26. float,
  27. str,
  28. Tuple[int, int, float],
  29. List[Tuple[float, float]],
  30. Path,
  31. MarkerStyle,
  32. ]
  33. Mapping = Callable[[ArrayLike], ArrayLike]
  34. # =================================================================================== #
  35. # Base classes
  36. # =================================================================================== #
  37. class Property:
  38. """Base class for visual properties that can be set directly or be data scaling."""
  39. # When True, scales for this property will populate the legend by default
  40. legend = False
  41. # When True, scales for this property normalize data to [0, 1] before mapping
  42. normed = False
  43. def __init__(self, variable: str | None = None):
  44. """Initialize the property with the name of the corresponding plot variable."""
  45. if not variable:
  46. variable = self.__class__.__name__.lower()
  47. self.variable = variable
  48. def default_scale(self, data: Series) -> Scale:
  49. """Given data, initialize appropriate scale class."""
  50. var_type = variable_type(data, boolean_type="boolean", strict_boolean=True)
  51. if var_type == "numeric":
  52. return Continuous()
  53. elif var_type == "datetime":
  54. return Temporal()
  55. elif var_type == "boolean":
  56. return Boolean()
  57. else:
  58. return Nominal()
  59. def infer_scale(self, arg: Any, data: Series) -> Scale:
  60. """Given data and a scaling argument, initialize appropriate scale class."""
  61. # TODO put these somewhere external for validation
  62. # TODO putting this here won't pick it up if subclasses define infer_scale
  63. # (e.g. color). How best to handle that? One option is to call super after
  64. # handling property-specific possibilities (e.g. for color check that the
  65. # arg is not a valid palette name) but that could get tricky.
  66. trans_args = ["log", "symlog", "logit", "pow", "sqrt"]
  67. if isinstance(arg, str):
  68. if any(arg.startswith(k) for k in trans_args):
  69. # TODO validate numeric type? That should happen centrally somewhere
  70. return Continuous(trans=arg)
  71. else:
  72. msg = f"Unknown magic arg for {self.variable} scale: '{arg}'."
  73. raise ValueError(msg)
  74. else:
  75. arg_type = type(arg).__name__
  76. msg = f"Magic arg for {self.variable} scale must be str, not {arg_type}."
  77. raise TypeError(msg)
  78. def get_mapping(self, scale: Scale, data: Series) -> Mapping:
  79. """Return a function that maps from data domain to property range."""
  80. def identity(x):
  81. return x
  82. return identity
  83. def standardize(self, val: Any) -> Any:
  84. """Coerce flexible property value to standardized representation."""
  85. return val
  86. def _check_dict_entries(self, levels: list, values: dict) -> None:
  87. """Input check when values are provided as a dictionary."""
  88. missing = set(levels) - set(values)
  89. if missing:
  90. formatted = ", ".join(map(repr, sorted(missing, key=str)))
  91. err = f"No entry in {self.variable} dictionary for {formatted}"
  92. raise ValueError(err)
  93. def _check_list_length(self, levels: list, values: list) -> list:
  94. """Input check when values are provided as a list."""
  95. message = ""
  96. if len(levels) > len(values):
  97. message = " ".join([
  98. f"\nThe {self.variable} list has fewer values ({len(values)})",
  99. f"than needed ({len(levels)}) and will cycle, which may",
  100. "produce an uninterpretable plot."
  101. ])
  102. values = [x for _, x in zip(levels, itertools.cycle(values))]
  103. elif len(values) > len(levels):
  104. message = " ".join([
  105. f"The {self.variable} list has more values ({len(values)})",
  106. f"than needed ({len(levels)}), which may not be intended.",
  107. ])
  108. values = values[:len(levels)]
  109. # TODO look into custom PlotSpecWarning with better formatting
  110. if message:
  111. warnings.warn(message, UserWarning)
  112. return values
  113. # =================================================================================== #
  114. # Properties relating to spatial position of marks on the plotting axes
  115. # =================================================================================== #
  116. class Coordinate(Property):
  117. """The position of visual marks with respect to the axes of the plot."""
  118. legend = False
  119. normed = False
  120. # =================================================================================== #
  121. # Properties with numeric values where scale range can be defined as an interval
  122. # =================================================================================== #
  123. class IntervalProperty(Property):
  124. """A numeric property where scale range can be defined as an interval."""
  125. legend = True
  126. normed = True
  127. _default_range: tuple[float, float] = (0, 1)
  128. @property
  129. def default_range(self) -> tuple[float, float]:
  130. """Min and max values used by default for semantic mapping."""
  131. return self._default_range
  132. def _forward(self, values: ArrayLike) -> ArrayLike:
  133. """Transform applied to native values before linear mapping into interval."""
  134. return values
  135. def _inverse(self, values: ArrayLike) -> ArrayLike:
  136. """Transform applied to results of mapping that returns to native values."""
  137. return values
  138. def infer_scale(self, arg: Any, data: Series) -> Scale:
  139. """Given data and a scaling argument, initialize appropriate scale class."""
  140. # TODO infer continuous based on log/sqrt etc?
  141. var_type = variable_type(data, boolean_type="boolean", strict_boolean=True)
  142. if var_type == "boolean":
  143. return Boolean(arg)
  144. elif isinstance(arg, (list, dict)):
  145. return Nominal(arg)
  146. elif var_type == "categorical":
  147. return Nominal(arg)
  148. elif var_type == "datetime":
  149. return Temporal(arg)
  150. # TODO other variable types
  151. else:
  152. return Continuous(arg)
  153. def get_mapping(self, scale: Scale, data: Series) -> Mapping:
  154. """Return a function that maps from data domain to property range."""
  155. if isinstance(scale, Nominal):
  156. return self._get_nominal_mapping(scale, data)
  157. elif isinstance(scale, Boolean):
  158. return self._get_boolean_mapping(scale, data)
  159. if scale.values is None:
  160. vmin, vmax = self._forward(self.default_range)
  161. elif isinstance(scale.values, tuple) and len(scale.values) == 2:
  162. vmin, vmax = self._forward(scale.values)
  163. else:
  164. if isinstance(scale.values, tuple):
  165. actual = f"{len(scale.values)}-tuple"
  166. else:
  167. actual = str(type(scale.values))
  168. scale_class = scale.__class__.__name__
  169. err = " ".join([
  170. f"Values for {self.variable} variables with {scale_class} scale",
  171. f"must be 2-tuple; not {actual}.",
  172. ])
  173. raise TypeError(err)
  174. def mapping(x):
  175. return self._inverse(np.multiply(x, vmax - vmin) + vmin)
  176. return mapping
  177. def _get_nominal_mapping(self, scale: Nominal, data: Series) -> Mapping:
  178. """Identify evenly-spaced values using interval or explicit mapping."""
  179. levels = categorical_order(data, scale.order)
  180. values = self._get_values(scale, levels)
  181. def mapping(x):
  182. ixs = np.asarray(x, np.intp)
  183. out = np.full(len(x), np.nan)
  184. use = np.isfinite(x)
  185. out[use] = np.take(values, ixs[use])
  186. return out
  187. return mapping
  188. def _get_boolean_mapping(self, scale: Boolean, data: Series) -> Mapping:
  189. """Identify evenly-spaced values using interval or explicit mapping."""
  190. values = self._get_values(scale, [True, False])
  191. def mapping(x):
  192. out = np.full(len(x), np.nan)
  193. use = np.isfinite(x)
  194. out[use] = np.where(x[use], *values)
  195. return out
  196. return mapping
  197. def _get_values(self, scale: Scale, levels: list) -> list:
  198. """Validate scale.values and identify a value for each level."""
  199. if isinstance(scale.values, dict):
  200. self._check_dict_entries(levels, scale.values)
  201. values = [scale.values[x] for x in levels]
  202. elif isinstance(scale.values, list):
  203. values = self._check_list_length(levels, scale.values)
  204. else:
  205. if scale.values is None:
  206. vmin, vmax = self.default_range
  207. elif isinstance(scale.values, tuple):
  208. vmin, vmax = scale.values
  209. else:
  210. scale_class = scale.__class__.__name__
  211. err = " ".join([
  212. f"Values for {self.variable} variables with {scale_class} scale",
  213. f"must be a dict, list or tuple; not {type(scale.values)}",
  214. ])
  215. raise TypeError(err)
  216. vmin, vmax = self._forward([vmin, vmax])
  217. values = list(self._inverse(np.linspace(vmax, vmin, len(levels))))
  218. return values
  219. class PointSize(IntervalProperty):
  220. """Size (diameter) of a point mark, in points, with scaling by area."""
  221. _default_range = 2, 8 # TODO use rcparams?
  222. def _forward(self, values):
  223. """Square native values to implement linear scaling of point area."""
  224. return np.square(values)
  225. def _inverse(self, values):
  226. """Invert areal values back to point diameter."""
  227. return np.sqrt(values)
  228. class LineWidth(IntervalProperty):
  229. """Thickness of a line mark, in points."""
  230. @property
  231. def default_range(self) -> tuple[float, float]:
  232. """Min and max values used by default for semantic mapping."""
  233. base = mpl.rcParams["lines.linewidth"]
  234. return base * .5, base * 2
  235. class EdgeWidth(IntervalProperty):
  236. """Thickness of the edges on a patch mark, in points."""
  237. @property
  238. def default_range(self) -> tuple[float, float]:
  239. """Min and max values used by default for semantic mapping."""
  240. base = mpl.rcParams["patch.linewidth"]
  241. return base * .5, base * 2
  242. class Stroke(IntervalProperty):
  243. """Thickness of lines that define point glyphs."""
  244. _default_range = .25, 2.5
  245. class Alpha(IntervalProperty):
  246. """Opacity of the color values for an arbitrary mark."""
  247. _default_range = .3, .95
  248. # TODO validate / enforce that output is in [0, 1]
  249. class Offset(IntervalProperty):
  250. """Offset for edge-aligned text, in point units."""
  251. _default_range = 0, 5
  252. _legend = False
  253. class FontSize(IntervalProperty):
  254. """Font size for textual marks, in points."""
  255. _legend = False
  256. @property
  257. def default_range(self) -> tuple[float, float]:
  258. """Min and max values used by default for semantic mapping."""
  259. base = mpl.rcParams["font.size"]
  260. return base * .5, base * 2
  261. # =================================================================================== #
  262. # Properties defined by arbitrary objects with inherently nominal scaling
  263. # =================================================================================== #
  264. class ObjectProperty(Property):
  265. """A property defined by arbitrary an object, with inherently nominal scaling."""
  266. legend = True
  267. normed = False
  268. # Object representing null data, should appear invisible when drawn by matplotlib
  269. # Note that we now drop nulls in Plot._plot_layer and thus may not need this
  270. null_value: Any = None
  271. def _default_values(self, n: int) -> list:
  272. raise NotImplementedError()
  273. def default_scale(self, data: Series) -> Scale:
  274. var_type = variable_type(data, boolean_type="boolean", strict_boolean=True)
  275. return Boolean() if var_type == "boolean" else Nominal()
  276. def infer_scale(self, arg: Any, data: Series) -> Scale:
  277. var_type = variable_type(data, boolean_type="boolean", strict_boolean=True)
  278. return Boolean(arg) if var_type == "boolean" else Nominal(arg)
  279. def get_mapping(self, scale: Scale, data: Series) -> Mapping:
  280. """Define mapping as lookup into list of object values."""
  281. boolean_scale = isinstance(scale, Boolean)
  282. order = getattr(scale, "order", [True, False] if boolean_scale else None)
  283. levels = categorical_order(data, order)
  284. values = self._get_values(scale, levels)
  285. if boolean_scale:
  286. values = values[::-1]
  287. def mapping(x):
  288. ixs = np.asarray(np.nan_to_num(x), np.intp)
  289. return [
  290. values[ix] if np.isfinite(x_i) else self.null_value
  291. for x_i, ix in zip(x, ixs)
  292. ]
  293. return mapping
  294. def _get_values(self, scale: Scale, levels: list) -> list:
  295. """Validate scale.values and identify a value for each level."""
  296. n = len(levels)
  297. if isinstance(scale.values, dict):
  298. self._check_dict_entries(levels, scale.values)
  299. values = [scale.values[x] for x in levels]
  300. elif isinstance(scale.values, list):
  301. values = self._check_list_length(levels, scale.values)
  302. elif scale.values is None:
  303. values = self._default_values(n)
  304. else:
  305. msg = " ".join([
  306. f"Scale values for a {self.variable} variable must be provided",
  307. f"in a dict or list; not {type(scale.values)}."
  308. ])
  309. raise TypeError(msg)
  310. values = [self.standardize(x) for x in values]
  311. return values
  312. class Marker(ObjectProperty):
  313. """Shape of points in scatter-type marks or lines with data points marked."""
  314. null_value = MarkerStyle("")
  315. # TODO should we have named marker "palettes"? (e.g. see d3 options)
  316. # TODO need some sort of "require_scale" functionality
  317. # to raise when we get the wrong kind explicitly specified
  318. def standardize(self, val: MarkerPattern) -> MarkerStyle:
  319. return MarkerStyle(val)
  320. def _default_values(self, n: int) -> list[MarkerStyle]:
  321. """Build an arbitrarily long list of unique marker styles.
  322. Parameters
  323. ----------
  324. n : int
  325. Number of unique marker specs to generate.
  326. Returns
  327. -------
  328. markers : list of string or tuples
  329. Values for defining :class:`matplotlib.markers.MarkerStyle` objects.
  330. All markers will be filled.
  331. """
  332. # Start with marker specs that are well distinguishable
  333. markers = [
  334. "o", "X", (4, 0, 45), "P", (4, 0, 0), (4, 1, 0), "^", (4, 1, 45), "v",
  335. ]
  336. # Now generate more from regular polygons of increasing order
  337. s = 5
  338. while len(markers) < n:
  339. a = 360 / (s + 1) / 2
  340. markers.extend([(s + 1, 1, a), (s + 1, 0, a), (s, 1, 0), (s, 0, 0)])
  341. s += 1
  342. markers = [MarkerStyle(m) for m in markers[:n]]
  343. return markers
  344. class LineStyle(ObjectProperty):
  345. """Dash pattern for line-type marks."""
  346. null_value = ""
  347. def standardize(self, val: str | DashPattern) -> DashPatternWithOffset:
  348. return self._get_dash_pattern(val)
  349. def _default_values(self, n: int) -> list[DashPatternWithOffset]:
  350. """Build an arbitrarily long list of unique dash styles for lines.
  351. Parameters
  352. ----------
  353. n : int
  354. Number of unique dash specs to generate.
  355. Returns
  356. -------
  357. dashes : list of strings or tuples
  358. Valid arguments for the ``dashes`` parameter on
  359. :class:`matplotlib.lines.Line2D`. The first spec is a solid
  360. line (``""``), the remainder are sequences of long and short
  361. dashes.
  362. """
  363. # Start with dash specs that are well distinguishable
  364. dashes: list[str | DashPattern] = [
  365. "-", (4, 1.5), (1, 1), (3, 1.25, 1.5, 1.25), (5, 1, 1, 1),
  366. ]
  367. # Now programmatically build as many as we need
  368. p = 3
  369. while len(dashes) < n:
  370. # Take combinations of long and short dashes
  371. a = itertools.combinations_with_replacement([3, 1.25], p)
  372. b = itertools.combinations_with_replacement([4, 1], p)
  373. # Interleave the combinations, reversing one of the streams
  374. segment_list = itertools.chain(*zip(list(a)[1:-1][::-1], list(b)[1:-1]))
  375. # Now insert the gaps
  376. for segments in segment_list:
  377. gap = min(segments)
  378. spec = tuple(itertools.chain(*((seg, gap) for seg in segments)))
  379. dashes.append(spec)
  380. p += 1
  381. return [self._get_dash_pattern(x) for x in dashes]
  382. @staticmethod
  383. def _get_dash_pattern(style: str | DashPattern) -> DashPatternWithOffset:
  384. """Convert linestyle arguments to dash pattern with offset."""
  385. # Copied and modified from Matplotlib 3.4
  386. # go from short hand -> full strings
  387. ls_mapper = {"-": "solid", "--": "dashed", "-.": "dashdot", ":": "dotted"}
  388. if isinstance(style, str):
  389. style = ls_mapper.get(style, style)
  390. # un-dashed styles
  391. if style in ["solid", "none", "None"]:
  392. offset = 0
  393. dashes = None
  394. # dashed styles
  395. elif style in ["dashed", "dashdot", "dotted"]:
  396. offset = 0
  397. dashes = tuple(mpl.rcParams[f"lines.{style}_pattern"])
  398. else:
  399. options = [*ls_mapper.values(), *ls_mapper.keys()]
  400. msg = f"Linestyle string must be one of {options}, not {repr(style)}."
  401. raise ValueError(msg)
  402. elif isinstance(style, tuple):
  403. if len(style) > 1 and isinstance(style[1], tuple):
  404. offset, dashes = style
  405. elif len(style) > 1 and style[1] is None:
  406. offset, dashes = style
  407. else:
  408. offset = 0
  409. dashes = style
  410. else:
  411. val_type = type(style).__name__
  412. msg = f"Linestyle must be str or tuple, not {val_type}."
  413. raise TypeError(msg)
  414. # Normalize offset to be positive and shorter than the dash cycle
  415. if dashes is not None:
  416. try:
  417. dsum = sum(dashes)
  418. except TypeError as err:
  419. msg = f"Invalid dash pattern: {dashes}"
  420. raise TypeError(msg) from err
  421. if dsum:
  422. offset %= dsum
  423. return offset, dashes
  424. class TextAlignment(ObjectProperty):
  425. legend = False
  426. class HorizontalAlignment(TextAlignment):
  427. def _default_values(self, n: int) -> list:
  428. vals = itertools.cycle(["left", "right"])
  429. return [next(vals) for _ in range(n)]
  430. class VerticalAlignment(TextAlignment):
  431. def _default_values(self, n: int) -> list:
  432. vals = itertools.cycle(["top", "bottom"])
  433. return [next(vals) for _ in range(n)]
  434. # =================================================================================== #
  435. # Properties with RGB(A) color values
  436. # =================================================================================== #
  437. class Color(Property):
  438. """Color, as RGB(A), scalable with nominal palettes or continuous gradients."""
  439. legend = True
  440. normed = True
  441. def standardize(self, val: ColorSpec) -> RGBTuple | RGBATuple:
  442. # Return color with alpha channel only if the input spec has it
  443. # This is so that RGBA colors can override the Alpha property
  444. if to_rgba(val) != to_rgba(val, 1):
  445. return to_rgba(val)
  446. else:
  447. return to_rgb(val)
  448. def _standardize_color_sequence(self, colors: ArrayLike) -> ArrayLike:
  449. """Convert color sequence to RGB(A) array, preserving but not adding alpha."""
  450. def has_alpha(x):
  451. return to_rgba(x) != to_rgba(x, 1)
  452. if isinstance(colors, np.ndarray):
  453. needs_alpha = colors.shape[1] == 4
  454. else:
  455. needs_alpha = any(has_alpha(x) for x in colors)
  456. if needs_alpha:
  457. return to_rgba_array(colors)
  458. else:
  459. return to_rgba_array(colors)[:, :3]
  460. def infer_scale(self, arg: Any, data: Series) -> Scale:
  461. # TODO when inferring Continuous without data, verify type
  462. # TODO need to rethink the variable type system
  463. # (e.g. boolean, ordered categories as Ordinal, etc)..
  464. var_type = variable_type(data, boolean_type="boolean", strict_boolean=True)
  465. if var_type == "boolean":
  466. return Boolean(arg)
  467. if isinstance(arg, (dict, list)):
  468. return Nominal(arg)
  469. if isinstance(arg, tuple):
  470. if var_type == "categorical":
  471. # TODO It seems reasonable to allow a gradient mapping for nominal
  472. # scale but it also feels "technically" wrong. Should this infer
  473. # Ordinal with categorical data and, if so, verify orderedness?
  474. return Nominal(arg)
  475. return Continuous(arg)
  476. if callable(arg):
  477. return Continuous(arg)
  478. # TODO Do we accept str like "log", "pow", etc. for semantics?
  479. if not isinstance(arg, str):
  480. msg = " ".join([
  481. f"A single scale argument for {self.variable} variables must be",
  482. f"a string, dict, tuple, list, or callable, not {type(arg)}."
  483. ])
  484. raise TypeError(msg)
  485. if arg in QUAL_PALETTES:
  486. return Nominal(arg)
  487. elif var_type == "numeric":
  488. return Continuous(arg)
  489. # TODO implement scales for date variables and any others.
  490. else:
  491. return Nominal(arg)
  492. def get_mapping(self, scale: Scale, data: Series) -> Mapping:
  493. """Return a function that maps from data domain to color values."""
  494. # TODO what is best way to do this conditional?
  495. # Should it be class-based or should classes have behavioral attributes?
  496. if isinstance(scale, Nominal):
  497. return self._get_nominal_mapping(scale, data)
  498. elif isinstance(scale, Boolean):
  499. return self._get_boolean_mapping(scale, data)
  500. if scale.values is None:
  501. # TODO Rethink best default continuous color gradient
  502. mapping = color_palette("ch:", as_cmap=True)
  503. elif isinstance(scale.values, tuple):
  504. # TODO blend_palette will strip alpha, but we should support
  505. # interpolation on all four channels
  506. mapping = blend_palette(scale.values, as_cmap=True)
  507. elif isinstance(scale.values, str):
  508. # TODO for matplotlib colormaps this will clip extremes, which is
  509. # different from what using the named colormap directly would do
  510. # This may or may not be desireable.
  511. mapping = color_palette(scale.values, as_cmap=True)
  512. elif callable(scale.values):
  513. mapping = scale.values
  514. else:
  515. scale_class = scale.__class__.__name__
  516. msg = " ".join([
  517. f"Scale values for {self.variable} with a {scale_class} mapping",
  518. f"must be string, tuple, or callable; not {type(scale.values)}."
  519. ])
  520. raise TypeError(msg)
  521. def _mapping(x):
  522. # Remove alpha channel so it does not override alpha property downstream
  523. # TODO this will need to be more flexible to support RGBA tuples (see above)
  524. invalid = ~np.isfinite(x)
  525. out = mapping(x)[:, :3]
  526. out[invalid] = np.nan
  527. return out
  528. return _mapping
  529. def _get_nominal_mapping(self, scale: Nominal, data: Series) -> Mapping:
  530. levels = categorical_order(data, scale.order)
  531. colors = self._get_values(scale, levels)
  532. def mapping(x):
  533. ixs = np.asarray(np.nan_to_num(x), np.intp)
  534. use = np.isfinite(x)
  535. out = np.full((len(ixs), colors.shape[1]), np.nan)
  536. out[use] = np.take(colors, ixs[use], axis=0)
  537. return out
  538. return mapping
  539. def _get_boolean_mapping(self, scale: Boolean, data: Series) -> Mapping:
  540. colors = self._get_values(scale, [True, False])
  541. def mapping(x):
  542. use = np.isfinite(x)
  543. x = np.asarray(np.nan_to_num(x)).astype(bool)
  544. out = np.full((len(x), colors.shape[1]), np.nan)
  545. out[x & use] = colors[0]
  546. out[~x & use] = colors[1]
  547. return out
  548. return mapping
  549. def _get_values(self, scale: Scale, levels: list) -> ArrayLike:
  550. """Validate scale.values and identify a value for each level."""
  551. n = len(levels)
  552. values = scale.values
  553. if isinstance(values, dict):
  554. self._check_dict_entries(levels, values)
  555. colors = [values[x] for x in levels]
  556. elif isinstance(values, list):
  557. colors = self._check_list_length(levels, values)
  558. elif isinstance(values, tuple):
  559. colors = blend_palette(values, n)
  560. elif isinstance(values, str):
  561. colors = color_palette(values, n)
  562. elif values is None:
  563. if n <= len(get_color_cycle()):
  564. # Use current (global) default palette
  565. colors = color_palette(n_colors=n)
  566. else:
  567. colors = color_palette("husl", n)
  568. else:
  569. scale_class = scale.__class__.__name__
  570. msg = " ".join([
  571. f"Scale values for {self.variable} with a {scale_class} mapping",
  572. f"must be string, list, tuple, or dict; not {type(scale.values)}."
  573. ])
  574. raise TypeError(msg)
  575. return self._standardize_color_sequence(colors)
  576. # =================================================================================== #
  577. # Properties that can take only two states
  578. # =================================================================================== #
  579. class Fill(Property):
  580. """Boolean property of points/bars/patches that can be solid or outlined."""
  581. legend = True
  582. normed = False
  583. def default_scale(self, data: Series) -> Scale:
  584. var_type = variable_type(data, boolean_type="boolean", strict_boolean=True)
  585. return Boolean() if var_type == "boolean" else Nominal()
  586. def infer_scale(self, arg: Any, data: Series) -> Scale:
  587. var_type = variable_type(data, boolean_type="boolean", strict_boolean=True)
  588. return Boolean(arg) if var_type == "boolean" else Nominal(arg)
  589. def standardize(self, val: Any) -> bool:
  590. return bool(val)
  591. def _default_values(self, n: int) -> list:
  592. """Return a list of n values, alternating True and False."""
  593. if n > 2:
  594. msg = " ".join([
  595. f"The variable assigned to {self.variable} has more than two levels,",
  596. f"so {self.variable} values will cycle and may be uninterpretable",
  597. ])
  598. # TODO fire in a "nice" way (see above)
  599. warnings.warn(msg, UserWarning)
  600. return [x for x, _ in zip(itertools.cycle([True, False]), range(n))]
  601. def get_mapping(self, scale: Scale, data: Series) -> Mapping:
  602. """Return a function that maps each data value to True or False."""
  603. boolean_scale = isinstance(scale, Boolean)
  604. order = getattr(scale, "order", [True, False] if boolean_scale else None)
  605. levels = categorical_order(data, order)
  606. values = self._get_values(scale, levels)
  607. if boolean_scale:
  608. values = values[::-1]
  609. def mapping(x):
  610. ixs = np.asarray(np.nan_to_num(x), np.intp)
  611. return [
  612. values[ix] if np.isfinite(x_i) else False
  613. for x_i, ix in zip(x, ixs)
  614. ]
  615. return mapping
  616. def _get_values(self, scale: Scale, levels: list) -> list:
  617. """Validate scale.values and identify a value for each level."""
  618. if isinstance(scale.values, list):
  619. values = [bool(x) for x in scale.values]
  620. elif isinstance(scale.values, dict):
  621. values = [bool(scale.values[x]) for x in levels]
  622. elif scale.values is None:
  623. values = self._default_values(len(levels))
  624. else:
  625. msg = " ".join([
  626. f"Scale values for {self.variable} must be passed in",
  627. f"a list or dict; not {type(scale.values)}."
  628. ])
  629. raise TypeError(msg)
  630. return values
  631. # =================================================================================== #
  632. # Enumeration of properties for use by Plot and Mark classes
  633. # =================================================================================== #
  634. # TODO turn this into a property registry with hooks, etc.
  635. # TODO Users do not interact directly with properties, so how to document them?
  636. PROPERTY_CLASSES = {
  637. "x": Coordinate,
  638. "y": Coordinate,
  639. "color": Color,
  640. "alpha": Alpha,
  641. "fill": Fill,
  642. "marker": Marker,
  643. "pointsize": PointSize,
  644. "stroke": Stroke,
  645. "linewidth": LineWidth,
  646. "linestyle": LineStyle,
  647. "fillcolor": Color,
  648. "fillalpha": Alpha,
  649. "edgewidth": EdgeWidth,
  650. "edgestyle": LineStyle,
  651. "edgecolor": Color,
  652. "edgealpha": Alpha,
  653. "text": Property,
  654. "halign": HorizontalAlignment,
  655. "valign": VerticalAlignment,
  656. "offset": Offset,
  657. "fontsize": FontSize,
  658. "xmin": Coordinate,
  659. "xmax": Coordinate,
  660. "ymin": Coordinate,
  661. "ymax": Coordinate,
  662. "group": Property,
  663. # TODO pattern?
  664. # TODO gradient?
  665. }
  666. PROPERTIES = {var: cls(var) for var, cls in PROPERTY_CLASSES.items()}