Source code for cottax.consumers.visualization.xdsm

'''
A graph as an XDSM (Lambe & Martins, 2012) in SVG: the stylesheet and the painting in order.
'''

from __future__ import annotations

import os
from collections.abc import Mapping
from ...core import NestingGraph, ExecutableGraph, Plan

from .interface import Formatter
from .lookup import spell_plain  # noqa: F401  (imported from here by the docs)
from .naming import PlainFormat
from .style import DARK, TERRACOTTA, Style, style_of
from .svg import Measure, SvgDoc, _baseline, _text  # noqa: F401  (`_baseline`, `_text`: the tests)
from .xdsm_layout import CELL_GAP, layout  # noqa: F401  (`CELL_GAP`: the tests)
from .xdsm_paint import (paint_boundary, paint_couplings, paint_data_boxes, paint_diagonal,
                         paint_io, paint_key, paint_rings)
from .xdsm_struct import RING_LEVELS, key_entries, xdsm_struct


# the structure of the stylesheet, whatever the colours: weights, dashes, the type
BASE_CSS = (
    'svg{font-family:ui-sans-serif,system-ui,-apple-system,sans-serif;font-size:13px}'
    '.diag,.ext{stroke-width:1.4}.data{stroke-width:1}.dlbl,.io{font-size:12px}'
    '.deco{stroke-width:1.1;stroke-linecap:round;stroke-linejoin:round}'  # the hat
    '.group{stroke-width:1.7}'                                    # a block with a cycle
    '.group-ff{stroke-width:1.3;stroke-dasharray:1 4;stroke-linecap:round}'
)


[docs] def xdsm_css(sty: Style, levels: int = RING_LEVELS) -> str: ''' `sty` as the XDSM's stylesheet, colours only: `BASE_CSS` states the structure. The class names are `render_xdsm_svg`'s -- `.diag` a model, `.data` a coupling, `.ext` a boundary box, `.group` a solve ring's band (`.group-l<d>` its depth, `.group-nested` below the top), `.group-ff` a block run in order. A problem's hexagon and a solve ring's contour are coloured on the shape, not here. ''' weight = 600 if sty.bold else 'normal' css = [ f'svg{{background:{sty.surface}}}' if sty.surface != '#ffffff' else '', f'.diag{{fill:{sty.model[0]};stroke:{sty.model[1]}}}', f'.ext{{fill:{sty.boundary[0]};stroke:{sty.boundary[1]}}}', f'.data{{fill:{sty.variable[0]};stroke:{sty.variable[1]}}}', f'.lbl{{fill:{sty.ink};font-weight:{weight}}}', f'.dlbl{{fill:{sty.variable_ink};font-weight:{weight}}}', f'.io{{fill:{sty.muted}}}', f'.deco{{stroke:{sty.ink}}}', f'.edge{{stroke:{sty.edge}}}.arr{{fill:{sty.edge}}}', ] top = sty.band(0) css.append(f'.group{{fill:{top or "none"};' f'stroke-dasharray:{"6 4" if sty.dashed else "none"}}}') if sty.dashed: css.append('.group-l1{stroke-dasharray:10 4}.group-l2{stroke-dasharray:2 3}') for depth in range(1, levels): if (fill := sty.band(depth)) is not None: css.append(f'.group.group-nested.group-l{depth}{{fill:{fill}}}') css.append(f'.group-ff{{fill:none;stroke:{sty.run_ring or "none"}}}') return ''.join(css)
LINE_WEIGHTS = {'.diag,.ext': 1.4, '.data': 1.0, '.deco': 1.1, '.group': 1.7, '.group-ff': 1.3, '.edge': 2.0, '.io': 1.4} '''Every line weight in px, by class: `.diag,.ext` a model box and the boundary boxes, `.data` a coupling box, `.deco` a minted name's caret, `.group` a solve's ring, `.group-ff` a block run in order, `.edge` a coupling line, `.io` an external arrow. `line_weights` overrides one, `line_scale` multiplies all.''' RING_THICK = 4.0 '''A ring's weight under `thick_blocks=True`, in px.''' _ALIGN = {'center': (0.5, 0.5), 'top-left': (0.0, 0.0), 'top': (0.5, 0.0), 'left': (0.0, 0.5)} '''`render_xdsm_svg`'s `align`, as the fraction of the canvas's slack to the drawing's left and above it.'''
[docs] def stylesheet(sty: Style, *, follow_system: bool, css: str, font: str | None, font_size: float | None, size: float, vsize: float, weights: Mapping[str, float], line_scale: float, line_weights: Mapping[str, float] | None, thick_blocks: bool | float) -> str: '''The whole of one drawing's `<style>`: `BASE_CSS`, `sty`'s colours, and every setting of `render_xdsm_svg` that is said in CSS.''' sheet = BASE_CSS + xdsm_css(sty) if follow_system: # the viewer decides sheet += '@media (prefers-color-scheme:dark){' + xdsm_css(DARK) + '}' sheet += css # the type last, to win over `BASE_CSS` if font: sheet += f'svg{{font-family:{font}}}' if font_size: sheet += (f'svg{{font-size:{size:.2f}px}}' f'.dlbl,.io{{font-size:{vsize:.2f}px}}') if line_scale != 1 or line_weights: sheet += ''.join(f'{sel}{{stroke-width:{w * line_scale:.2f}}}' for sel, w in weights.items() if sel.startswith('.') and sel not in ('.edge', '.io')) if thick_blocks: ring = RING_THICK if isinstance(thick_blocks, bool) else float(thick_blocks) sheet += ('.group,.group-ff{stroke-width:%.2f;stroke-dasharray:none}' % (ring * line_scale)) return sheet
[docs] def render_xdsm_svg( graph : NestingGraph | ExecutableGraph | Plan | dict, *, blocks : bool | ExecutableGraph = False, file_name : str = 'xdsm', outdir : str = '.', style : Style | str = TERRACOTTA, follow_system : bool = False, css : str = '', input_label : str | None = None, output_label : str | None = None, write : bool = False, legend : bool = True, font : str | None = None, font_size : float | None = None, names : bool = True, edges : bool = False, thick_blocks : bool | float = False, line_scale : float = 1.0, line_weights : Mapping[str, float] | None = None, box_scale : float = 1.0, var_box_scale : float = 1.0, condition_slots: bool | str = False, problem_first : bool = True, stack_inputs : bool = False, collapse_names : bool = False, inline_names : bool = False, square_names : bool = False, gap : float | None = None, gap_y : float | None = None, ring_gap : float | None = None, nest_gap : float | None = None, head_gap : float | None = None, cell : tuple[float, float] | None = None, canvas : tuple[float, float] | None = None, align : str = 'center', formatter : Formatter = PlainFormat(), measure : Measure | None = None, ) -> SvgDoc: ''' `graph` as a self-contained XDSM in SVG: the steps on the diagonal, a coupling's box at (producer's row, consumer's column), a free input entering from the top-left box and a result leaving to the bottom-right one. Returns an `SvgDoc`, whose `.cell` is the widest step column and tallest row used and `.size` the drawing's `(width, height)`. `graph`: a `NestingGraph`, a `Plan` (its graph), a proof (its graph, ringed as proved) or an `xdsm_struct` already built. `blocks`: ring every solve, and order the diagonal to run order (an `ExecutableGraph`: ring as that proof has it); otherwise the diagonal is binding order. `file_name`, `outdir`, `write`: with `write=True`, also write `<outdir>/<file_name>.svg` (`.path`). `style`: a `Style` or a name in `STYLES`. `follow_system`: add `DARK` under the viewer's `prefers-color-scheme` (the default style only); a hexagon and a ring's contour keep the light ground's colours. `css`: appended to the stylesheet. `input_label`, `output_label`: the boundary boxes' text (`None`: `'Coor'`, `'Out'`). `legend`: draw the key beneath the matrix. `font`: the face, a CSS family list (`None`: the style's). `font_size`: a step label's size in px; every other size and distance follows it. `names`: draw the text; off, the boxes keep the size of the names they would hold. `edges`: also draw the line from producer through a coupling box to its consumer, and with no boundary variable the labelled arrows in and out. `thick_blocks`: rings at `RING_THICK` px and solid, or at the number given. `line_scale`: multiplies every line weight. `line_weights`: merged over `LINE_WEIGHTS` before `line_scale`, per class. `box_scale`: the air around a step's label and its row's floor, times this. `var_box_scale`: the air around a coupling box's names, times this. `condition_slots`: a condition node's reads in a column per kind (`min`, `= 0`, `≤ 0`, `= û`, `≤ u`, `g ≤ g_max`); `True` for optimise and feasibility problems and every requirement (what it requires), `'all'` for every condition node. `problem_first`: a problem at the head of the block it drives; off keeps the graph's order within a block. `stack_inputs`: the free inputs on one row, not one row each. `collapse_names`: a coupling box shows a count, `N vars`, its names in its `<title>`. `inline_names`: a coupling box's names on one line. `square_names`: a coupling box's names in the fewest lines keeping it about square. `gap`: px between neighbouring boxes, both ways (`CELL_GAP`); `gap_y`: the vertical one alone. `ring_gap`: px from a ring to the boxes and rings beside it (`gap / 2`). `nest_gap`, `head_gap`: px from a ring to a ring nested in it and to its problem's hexagon (`ring_gap`). `cell`: `(w, h)` in px, every step column and every row at this pitch (another drawing's `.cell`), so two drawings share a grid. `canvas`: `(w, h)` in px to pad the drawing to (another's `.size`), so two share a scale; a smaller canvas is ignored. `align`: where the drawing sits in `canvas`: `'center'`, `'top-left'`, `'top'`, `'left'`. `formatter`: how a name is written; markup (`MARKUP`) is measured as it is set. `measure`: `(label, size, bold) -> (ink_left, ink_width)` in px, replacing the width estimate (`latex.xdsm.CairoMeasure`). ''' sty = style_of(style) if follow_system and sty is not TERRACOTTA: raise ValueError(f"style {sty.name!r} has no reading on a dark system: follow_system " f"draws the default style's, DARK") if font is None: font = sty.font if isinstance(graph, dict): # a struct, already built struct = graph else: struct = xdsm_struct(graph, blocks=blocks, formatter=formatter, problem_first=problem_first) keys = key_entries(struct, sty) if legend else [] lay = layout(struct, sty, keys, formatter=formatter, font_size=font_size, measure=measure, condition_slots=condition_slots, stack_inputs=stack_inputs, collapse_names=collapse_names, inline_names=inline_names, square_names=square_names, input_label=input_label, output_label=output_label, edges=edges, box_scale=box_scale, var_box_scale=var_box_scale, gap=gap, gap_y=gap_y, ring_gap=ring_gap, nest_gap=nest_gap, head_gap=head_gap, cell=cell) W0, H0 = W, H = lay.width, lay.height # the drawing's own size, before any padding # line weights are scaled by `line_scale` alone, not by the type weights = dict(LINE_WEIGHTS, **(line_weights or {})) edge_w = weights['.edge'] * line_scale io_w = weights['.io'] * line_scale hex_w = weights['.diag,.ext'] * line_scale # a hexagon weighs what a model does cut = sty.cut_em * lay.setting.size / 2 # half the gap an open contour leaves # the rings first, under everything: a band is filled parts = (paint_rings(struct, lay, sty, cut) + (paint_couplings(lay, edge_w) if edges else []) + paint_io(lay, io_w) + paint_boundary(lay) + paint_diagonal(struct, lay, sty, cut, hex_w, line_scale) + paint_data_boxes(struct, lay, collapse_names) + paint_key(keys, lay, sty, cut, hex_w)) sheet = stylesheet(sty, follow_system=follow_system, css=css, font=font, font_size=font_size, size=lay.setting.size, vsize=lay.setting.vsize, weights=weights, line_scale=line_scale, line_weights=line_weights, thick_blocks=thick_blocks) # names off takes the text (and a minted name's caret) out, after layout: the geometry # is the same either way if not names: parts = [p for p in parts if not p.startswith(('<text', '<polyline class="deco"'))] # a canvas is padding around the drawing, placed by `align` body = ''.join(parts) if canvas is not None: W, H = max(W, canvas[0]), max(H, canvas[1]) fx, fy = _ALIGN[align] body = (f'<g transform="translate({(W - W0) * fx:.1f},{(H - H0) * fy:.1f})">' f'{body}</g>') svg = ( f'<svg xmlns="http://www.w3.org/2000/svg" width="{W:.0f}" height="{H:.0f}" ' f'viewBox="0 0 {W:.0f} {H:.0f}">' f'<style>{sheet}</style>' f'<defs>' f'<marker id="arr" markerWidth="8" markerHeight="8" refX="6" refY="3" ' f'orient="auto"><path class="arr" d="M0,0 L6,3 L0,6 z"/></marker>' f'</defs>' + body + '</svg>' ) doc = SvgDoc(svg) # `.cell` is what `cell=` pins: the widest step column, the tallest row g = lay.grid doc.cell, doc.size = (max(lay.col_w[g.n_ext_col:g.out_col], default=0.0), max(lay.row_h)), (W, H) if write: os.makedirs(outdir, exist_ok=True) path = os.path.join(outdir, file_name + '.svg') with open(path, 'w') as fh: fh.write(svg) doc.path = path return doc