Source code for cottax.consumers.visualization.style

'''
How every drawing is coloured and marked: a `Style`, and the three shipped (`STYLES`).
'''

from __future__ import annotations

from abc import abstractmethod
from collections.abc import Mapping

from ...core.frozen import Frozen

type Pair = tuple[str, str]
'''`(fill, stroke)`.'''


[docs] def mix(colour: str, towards: str, t: float) -> str: '''`colour` taken `t` of the way to `towards`, per channel in sRGB.''' a = [int(colour[i:i + 2], 16) for i in (1, 3, 5)] b = [int(towards[i:i + 2], 16) for i in (1, 3, 5)] return '#' + ''.join(f'{round(x + (y - x) * t):02x}' for x, y in zip(a, b))
def _oklab(colour: str) -> tuple[float, float, float]: '''`colour` in Oklab: lightness, then the two opponent axes.''' r, g, b = (c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in (int(colour[i:i + 2], 16) / 255 for i in (1, 3, 5))) l, m, s = ((0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) ** (1 / 3), (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) ** (1 / 3), (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) ** (1 / 3)) return (0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s) def _srgb(lightness: float, a: float, b: float) -> str: '''An Oklab colour as `#rrggbb`, clipped to the gamut per channel.''' l, m, s = ((lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3, (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3, (lightness - 0.0894841775 * a - 1.2914855480 * b) ** 3) rgb = (4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s) gamma = (12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055 for c in (min(max(c, 0.0), 1.0) for c in rgb)) return '#' + ''.join(f'{round(c * 255):02x}' for c in gamma)
[docs] class Ground(Frozen): ''' The ground a style is drawn on, as the rule taking a category's colour -- its `(fill, stroke)` on a light ground, `Kind.colour` -- to the pair drawn on this one. A category is coloured once, by its language, and every ground draws it. '''
[docs] @abstractmethod def paint(self, colour: Pair) -> Pair: ...
@property @abstractmethod def scheme(self) -> str: '''The CSS `color-scheme` it is: `'light'` or `'dark'`.'''
[docs] class Light(Ground): '''A light ground: the pair as the category gives it.'''
[docs] def paint(self, colour: Pair) -> Pair: return colour
@property def scheme(self) -> str: return 'light'
[docs] class Dark(Ground): ''' A dark ground: each colour keeps its hue (in Oklab), takes a lightness of its own and its chroma scaled -- the fill dark and muted, the stroke light, so a hexagon reads on the ground and its contour on the hexagon. ''' fill_lightness : float fill_chroma : float stroke_lightness : float stroke_chroma : float
[docs] def paint(self, colour: Pair) -> Pair: def relit(c: str, lightness: float, chroma: float) -> str: _, a, b = _oklab(c) return _srgb(lightness, a * chroma, b * chroma) fill, stroke = colour return (relit(fill, self.fill_lightness, self.fill_chroma), relit(stroke, self.stroke_lightness, self.stroke_chroma))
@property def scheme(self) -> str: return 'dark'
[docs] class Style(Frozen): ''' The colours and marks of every drawing in this package. A problem's hue is its category's; value is structure; fill and contour are how far a problem is decided. `surface` is the ground, `ink` a box's label, `muted` a boundary label or a key entry. `model`, `variable` and `boundary` are `(fill, stroke)` for a box that runs, a coupling and the two boxes standing for the world outside; `variable_ink` a variable's name. `edge` a coupling line. `chip` is a condition slot's `(fill, stroke)`, drawn translucent over its problem. A category's colour is its own (`Kind.colour`), and `ground` paints it (`paint`): the hexagon of a problem of that category, and the contour of the ring it answers. A renderer holding a category's name takes the colour from its struct. `stated` is a condition that determines nothing and whose type is unknown; one whose type is known is drawn hollow in that type's stroke. `unanswered` is the contour of a ring no driver answers (a bare cycle, or several problems with nothing nested). `bands` fills a solve ring, one entry per nesting depth (cycling); empty draws the ring as a contour alone. `band_stroke` is the contour of a band's key sample, `dashed` whether a solve ring's contour is dashed. `run_ring` is the contour of a block merely run in order, `None` to draw none. `undriven` says how a problem without a driver is drawn: `'tint'` fills it `tint` of the way to its type's fill (`driven_tint` once driven) and cuts its contour, and a ring nothing answers, open by `cut_em` in the middle of top and bottom; `'solid'` draws every problem alike. `bold` sets every name in a heavier weight. `font` is a CSS family list, `None` for the renderer's own. `ring_gap` is a solve ring's clearance as a fraction of the gap between two boxes, `nest_em` what a nested ring and a driver's chevron stand off a ring (in ems, floored at that clearance), `ring_radius_em` a ring's corner. `groups` is the categorical palette a drawing colours groups of nodes with; it keeps clear of the categories' hues. ''' name : str surface : str ink : str muted : str model : Pair variable : Pair variable_ink : str boundary : Pair edge : str chip : Pair ground : Ground stated : Pair unanswered : str bands : tuple[str, ...] band_stroke : str dashed : bool run_ring : str | None undriven : str tint : float driven_tint : float cut_em : float bold : bool font : str | None ring_gap : float nest_em : float ring_radius_em : float groups : tuple[str, ...]
[docs] def paint(self, colour: Pair | None) -> Pair: '''The `(fill, stroke)` of a problem of a category coloured `colour`, or `stated`'s for none.''' return self.stated if colour is None else self.ground.paint(colour)
[docs] def fill(self, colour: Pair | None, driven: bool) -> str: '''What a problem of a category coloured `colour` is filled with, driven or not; `stated`'s grey, tinted as any, for none.''' fill = self.paint(colour)[0] if self.undriven != 'tint': return fill return mix(fill, self.surface, 1 - (self.driven_tint if driven else self.tint))
[docs] def band(self, depth: int) -> str | None: '''The fill of a solve ring at `depth`, or `None` for a contour alone.''' return self.bands[depth % len(self.bands)] if self.bands else None
@property def cuts(self) -> bool: '''Whether a problem without a driver has its contour cut open.''' return self.undriven == 'tint' and self.cut_em > 0
GROUP_HUES = ('#4c78a8', '#f58518', '#3fa7b8', '#e45756', '#cc79a7', '#5c9ecf', '#f2a35e', '#ff9da6', '#8ab6d6', '#bab0ac', '#d98c8c', '#6b8fb8') '''Mid-luminance, readable on white and on black, and clear of green, ocher and violet, which a problem's category may wear.''' TERRACOTTA = Style( name='terracotta', surface='#ffffff', ink='#3d3330', muted='#7d716c', model=('#f8ece7', '#b5583f'), # terracotta: something that runs variable=('#e9f0f6', '#5b83a6'), # steel: a variable variable_ink='#39617f', boundary=('#f1ece9', '#8a7d76'), # warm neutral: outside the model edge='#b9aea9', chip=('#fff', '#5e5e5e'), ground=Light(), # each category's own hue stated=('#d9d9d9', '#5e5e5e'), # graphite: no driver, no hue unanswered='#9d9d9d', bands=('#f0ede8', '#e5dfd3', '#d9d1c1'), # L* 94, 89, 84: one step per level band_stroke='#bdb4a9', dashed=False, run_ring=None, undriven='tint', tint=0.65, driven_tint=0.8, cut_em=0.5, bold=False, font=None, ring_gap=1.0, nest_em=0.7, ring_radius_em=0.3, groups=GROUP_HUES, ) '''The default: terracotta models and steel variables, a solve a neutral band ringed in its driver's hue, a problem without a driver tinted with its contour cut open.''' PYXDSM = Style( name='pyxdsm', surface='#ffffff', ink='#1a1a1a', muted='#555555', model=('#e4f2e0', '#3f9c35'), variable=('#f3ddd0', '#c1704f'), variable_ink='#8a3a1f', boundary=('#eef1f5', '#8c9bb0'), edge='#8a8a8a', chip=('#fff', '#5e5e5e'), ground=Light(), stated=('#f1f2f4', '#8c9bb0'), unanswered='#8c9bb0', bands=(), band_stroke='#e08a2b', dashed=True, run_ring='#8c9bb0', undriven='solid', tint=1.0, driven_tint=1.0, cut_em=0.0, bold=True, font=None, ring_gap=0.5, nest_em=0.0, ring_radius_em=0.6, groups=GROUP_HUES, ) '''pyXDSM's green / orange / white, the convention in that literature: rings as dashed contours in their driver's hue, every problem drawn alike, in its category's own colour.''' DARK = Style( name='dark', surface='#1a1a19', ink='#f0f0f0', muted='#aaaaaa', model=('#4a2c22', '#d9795c'), variable=('#1f3347', '#6f9cc4'), variable_ink='#a9c8e4', boundary=('#2b2f33', '#8c959e'), edge='#6b6a65', chip=('#1a1a19', '#aaaaaa'), ground=Dark(fill_lightness=0.34, fill_chroma=0.77, stroke_lightness=0.74, stroke_chroma=1.13), stated=('#333333', '#a0a0a0'), unanswered='#6e6e6e', bands=('#242322', '#2e2c2a', '#383532'), band_stroke='#6e6a66', dashed=False, run_ring=None, undriven='tint', tint=0.65, driven_tint=0.8, cut_em=0.5, bold=False, font=None, ring_gap=1.0, nest_em=0.7, ring_radius_em=0.3, groups=GROUP_HUES, ) '''`TERRACOTTA`'s reading on a dark ground, each category's colour relit for it.''' STYLES : Mapping[str, Style] = {s.name: s for s in (TERRACOTTA, PYXDSM, DARK)} '''The styles by name, for any drawing's `style`.'''
[docs] def style_of(style: 'Style | str') -> Style: '''`style`, or the one of that name.''' if isinstance(style, Style): return style try: return STYLES[style] except KeyError: raise ValueError(f'no style named {style!r}: one of {sorted(STYLES)}') from None