Source code for cottax.consumers.visualization.xdsm_paint

'''The XDSM's paint: each part of a laid-out struct as SVG elements, in the order drawn.'''

from __future__ import annotations

from collections.abc import Sequence

from .kinds import STATED_TITLE, answered, ring_stroke
from .naming import Shown, marked, markup_html
from .style import Style
from .svg import (Measure, _baseline, _ink, _plain, _span, _text, attr, cut_hexagon,
                  cut_rect, hexagon, xesc)
from .xdsm_layout import SEP, VAR_FONT_SIZE, Layout
from .xdsm_struct import ring_class


RING_NEST = 0.25
'''How far a `run` ring steps in from the cell boundary per level of depth, as a fraction
of the half-gap, capped at 0.75. (A `solve` ring has a lane of its own, `ring_gap`.)'''


def _var_line(names: Sequence[Shown], xc: float, y: float,
              *, size: float = VAR_FONT_SIZE, bold: bool = True,
              measure: Measure | None = None) -> list[str]:
    '''
    One line of a coupling box: the shown names centred on `(xc, y)` on one baseline, each
    its own `<text>` anchored at its centre with its mark over it (a caret for `'hat'`, a
    rule for `'bar'`, any other a combining character), a comma flush after each but the
    last.
    '''
    shown  = [(t, m) if m in ('hat', 'bar', None) else (marked((t, m)), None) for t, m in names]
    spans  = [_span(t, size, bold=bold, measure=measure) for t, _ in shown]
    widths = [w for _, w in spans]
    sepw   = _span(SEP, size, bold=bold, measure=measure)[1]
    x      = xc - (sum(widths) + sepw * (len(names) - 1)) / 2
    k      = size / VAR_FONT_SIZE
    base   = _baseline(''.join(t for t, _ in shown), y, size)

    out : list[str] = []
    for i, ((text, mark), w) in enumerate(zip(shown, widths)):
        mid = x + w / 2
        out.append(_text(text, mid, base, size, 'dlbl', span=spans[i]))
        if mark:
            hw = min(w * 0.5, 9.0 * k)        # flat and wide: a caret, not a roof
            top = base - _ink(text, size)[0]  # clear of the glyphs it sits on
            peak = top - (4.2 if mark == 'hat' else 2.8) * k
            low = top - (1.4 if mark == 'hat' else 2.8) * k
            out.append(f'<polyline class="deco" fill="none" points="'
                       f'{mid - hw:.1f},{low:.1f} {mid:.1f},{peak:.1f} '
                       f'{mid + hw:.1f},{low:.1f}"/>')
        if i + 1 < len(names):
            out.append(f'<text class="dlbl" x="{x + w:.1f}" y="{base:.1f}" '
                       f'text-anchor="start">,</text>')
        x += w + sepw
    return out


[docs] def paint_couplings(lay: Layout, edge_w: float) -> list[str]: '''Each coupling's line: along its row out of the source's box, then down its column into the consumer's. No arrowheads.''' g, cx, cy = lay.grid, lay.cx, lay.cy parts : list[str] = [] for (r, c) in g.nodes: dst = g.diag_row(c) pts = [(cx(g.diag_col(r)), cy(r)), (cx(c), cy(r))] \ + ([(cx(c), cy(dst))] if dst is not None else []) parts.append( f'<polyline class="edge" fill="none" stroke-width="{edge_w:.2f}" points="' + ' '.join(f'{x:.1f},{y:.1f}' for x, y in pts) + '"/>') return parts
[docs] def paint_io(lay: Layout, io_w: float) -> list[str]: '''The bare arrows in and out, drawn when there is no boundary box.''' g, cx, cy, k, vsize = lay.grid, lay.cx, lay.cy, lay.setting.k, lay.setting.vsize mT, mL = lay.m_top, lay.m_left parts : list[str] = [] if (in_target := lay.in_target) is not None: x, ytop = cx(in_target), cy(in_target) - lay.bh_of(in_target) / 2 parts.append(f'<line class="edge" x1="{x:.1f}" y1="{mT - 24 * k:.1f}" ' f'x2="{x:.1f}" y2="{ytop:.1f}" stroke-width="{io_w:.2f}" ' f'marker-end="url(#arr)"/>') parts.append(_text(lay.inputs_label, x, mT - 30 * k, vsize, 'io arrow')) if (out_arrow := lay.out_arrow) is not None: y, xleft = cy(out_arrow), cx(g.diag_col(out_arrow)) - lay.bw_of(g.diag_col(out_arrow)) / 2 parts.append(f'<line class="edge" x1="{xleft:.1f}" y1="{y:.1f}" ' f'x2="{mL - 44 * k:.1f}" y2="{y:.1f}" ' f'stroke-width="{io_w:.2f}" marker-end="url(#arr)"/>') parts.append(_text(lay.outputs_label, mL - 50 * k, _baseline(lay.outputs_label, y, vsize), vsize, 'io arrow', anchor='end')) return parts
[docs] def paint_boundary(lay: Layout) -> list[str]: '''The two boundary boxes.''' g, t = lay.grid, lay.setting parts : list[str] = [] # `Coor` spanning the input rows, top left; the results box at the foot of its column def _ext_box(col: int, y_mid: float, height: float, width: float, text: str) -> None: bw = width - lay.gap parts.append(f'<rect class="ext" x="{lay.cx(col) - bw / 2:.1f}" ' f'y="{y_mid - height / 2:.1f}" width="{bw:.1f}" ' f'height="{height:.1f}" rx="{6 * t.k:.1f}"/>') parts.append(_text(text, lay.cx(col), _baseline(text, y_mid, t.size), t.size, 'lbl', bold=t.bold, span=t.spanl(text))) if g.n_ext_col: rowY = lay.row_y _ext_box(0, (rowY[0] + rowY[g.n_in]) / 2, rowY[g.n_in] - rowY[0] - lay.gap_y, lay.ext_w, lay.inputs_label) if g.n_out: _ext_box(g.out_col, lay.cy(g.out_row), lay.bh_of(g.out_row), lay.out_w, lay.outputs_label) return parts
[docs] def paint_rings(struct: dict, lay: Layout, sty: Style, cut: float) -> list[str]: '''Each block's ring around its square of the diagonal.''' g, size = lay.grid, lay.setting.size head_col, span, n_in = g.head_col, g.span, g.n_in colX, rowY, gap, gap_y = lay.col_x, lay.row_y, lay.gap, lay.gap_y parts : list[str] = [] for b in struct.get('blocks', []): kind, depth, btype = b['ring'], b['depth'], b['type'] ds = [g.idx[s] for s in b['steps'] if s in g.idx] if not ds or (kind == 'run' and sty.run_ring is None): # a style drawing none continue lo, hi = min(ds), max(ds) if kind == 'solve': # in its lanes (`Layout.ring_at`) lane = lay.ring_at gx = lane['x', head_col[lo], 'L', depth] gy = lane['y', n_in + lo, 'L', depth] gw = lane['x', head_col[hi] + span[hi], 'R', depth] - gx gh = lane['y', n_in + hi + 1, 'R', depth] - gy else: # on the cell boundary, stepped in by depth nest = min(RING_NEST * depth, 0.75) padx, pady = nest * gap / 2, nest * gap_y / 2 gx = colX[head_col[lo]] + padx gy = rowY[n_in + lo] + pady gw = colX[head_col[hi] + span[hi]] - padx - gx gh = rowY[n_in + hi + 1] - pady - gy # a solve's contour in the hue of the problem answering it, grey where none does ring_cls = ring_class(kind, depth) contour = '' if kind != 'solve' else ring_stroke(btype, struct['kinds'], sty) drawn_as = f' stroke="{contour}"' if contour else '' rx = min(sty.ring_radius_em * size, gw / 2, gh / 2) rect = (f'<rect class="{ring_cls}" x="{gx:.1f}" y="{gy:.1f}" ' f'width="{gw:.1f}" height="{gh:.1f}" rx="{rx:.1f}"{drawn_as}') # no driver: the band unedged, the edge as two halves stopping short in the middle if kind == 'solve' and sty.cuts and not answered(btype): parts.append(f'{rect} style="stroke:none"/>') parts += [f'<path class="{ring_cls}" d="{d}"{drawn_as} style="fill:none"/>' for d in cut_rect(gx, gy, gw, gh, rx, cut)] else: parts.append(f'{rect}/>') return parts
[docs] def paint_diagonal(struct: dict, lay: Layout, sty: Style, cut: float, hex_w: float, line_scale: float) -> list[str]: '''Each step on the diagonal: a box, or a problem's hexagon with its slots.''' g, t = lay.grid, lay.setting size, ssize, k, chip_h = t.size, t.ssize, t.k, t.chip_h cx, cy, bw_of, bh_of = lay.cx, lay.cy, lay.bw_of, lay.bh_of steps, slots, n_in = g.steps, g.slots, g.n_in problem_types, named = struct['problem_types'], struct['kinds'] stated_shapes, driven = struct['stated_shapes'], struct['driven'] diag_labels = lay.labels parts : list[str] = [] # a step's box is one line tall (or its floor), whatever its row holds diag_h = max(t.line_h, lay.box_floor) for i in range(g.n): c0, sp = g.head_col[i], g.span[i] by = cy(n_in + i) if slots[i]: # one shape over the slots, two bands tall x0 = cx(c0) - bw_of(c0) / 2 x1 = cx(c0 + sp - 1) + bw_of(c0 + sp - 1) / 2 bx, bw = (x0 + x1) / 2, x1 - x0 # ... or as tall as its row, if that is taller bh = max(diag_h + chip_h + 5 * k, bh_of(n_in + i)) cap = min(4.0 * k, bw * 0.12) # short of the chips' corners else: bx, bw = cx(c0), bw_of(c0) bh = min(bh_of(n_in + i), diag_h) cap = None bx_head = bx ptype = problem_types.get(steps[i]) hover = STATED_TITLE if ptype == 'stated' else \ named[ptype].title if ptype in named else None title = f'<title>{hover}</title>' if hover else '' at = f' data-i="{i}"' # the step a page hovers if ptype is None: box = (f'<rect class="diag"{at} x="{bx - bw / 2:.1f}" y="{by - bh / 2:.1f}" ' f'width="{bw:.1f}" height="{bh:.1f}" rx="{6 * k:.1f}"') parts.append(f'{box}>{title}</rect>' if title else f'{box}/>') else: # a problem in its category's colour, filled as far as it is decided; a # requirement hollow in the colour it will take, else grey if ptype in named: colour = named[ptype].colour fill = sty.fill(colour, steps[i] in driven) stroke = sty.paint(colour)[1] elif (w := stated_shapes.get(steps[i])) in named: fill, stroke = sty.surface, sty.paint(named[w].colour)[1] else: fill, stroke = sty.stated cls = 'prob prob-stated' if ptype == 'stated' else 'prob' coloured = f' fill="{fill}" stroke="{stroke}" stroke-width="{hex_w:.2f}"' pts = hexagon(bx, by, bw, bh, k, cap) if sty.cuts and steps[i] not in driven: # no driver: the fill unedged, the edge as two chevrons cut at the head parts.append(f'<polygon class="{cls}"{at} points="{pts}"{coloured} ' f'style="stroke:none">{title}</polygon>') parts += [f'<path class="{cls}" d="{d}"{coloured} style="fill:none"/>' for d in cut_hexagon(pts, bx_head, cut)] else: parts.append(f'<polygon class="{cls}"{at} points="{pts}"{coloured}>' f'{title}</polygon>') # the name: in the top band of a slotted shape, the middle of a plain one y_name = by - bh / 2 + (bh - chip_h - 5 * k) / 2 if slots[i] else by parts.append(_text(diag_labels[i], bx, _baseline(diag_labels[i], y_name, size), size, 'lbl', bold=t.bold, span=t.spanl(diag_labels[i]))) # the slots: a chip per kind in the bottom band, over its own column for s_i, (lab, _v) in enumerate(slots[i]): c = c0 + s_i # inside the shape: clear of the chevron at either end, and of the neighbour xl = max(cx(c) - bw_of(c) / 2 + 1.5 * k, bx - bw / 2 + cap + 1.5 * k) xr = min(cx(c) + bw_of(c) / 2 - 1.5 * k, bx + bw / 2 - cap - 1.5 * k) cw = xr - xl cy_chip = by + bh / 2 - 3 * k - chip_h / 2 parts.append(f'<rect class="slot" x="{xl:.1f}" y="{cy_chip - chip_h / 2:.1f}" ' f'width="{cw:.1f}" height="{chip_h:.1f}" rx="{2.5 * k:.1f}" ' f'fill="{sty.chip[0]}" fill-opacity="0.6" stroke="{sty.chip[1]}" stroke-opacity="0.45" ' f'stroke-width="{0.7 * line_scale:.2f}"/>') # `.lbl` carries no size of its own, so the chip's is said on the text parts.append(_text(lab, (xl + xr) / 2, _baseline(lab, cy_chip, ssize), ssize, 'lbl', extra=f' font-size="{ssize:.2f}px"', span=_span(lab, ssize, measure=t.measure))) return parts
[docs] def paint_data_boxes(struct: dict, lay: Layout, collapse_names: bool) -> list[str]: '''Each coupling box and its names.''' g, t = lay.grid, lay.setting k, lineH, n_in = t.k, t.line_h, g.n_in cx, cy = lay.cx, lay.cy shown_vars = struct['shown_vars'] def sv(f: str) -> Shown: return tuple(shown_vars[f]) parts : list[str] = [] for (r, c), fs in g.nodes.items(): lines = lay.lines[r, c] bw, bh = lay.box_size[r, c] # for a page's hover: its two ends' steps (-1 the boundary) and its names dst = g.diag_row(c) ends = (f' data-r="{r - n_in if r >= n_in else -1}"' f' data-c="{dst - n_in if dst is not None and dst - n_in < g.n else -1}"' f' data-v="{attr(chr(10).join(markup_html(marked(sv(f))) for f in fs))}"') box = (f'<rect class="data"{ends} x="{cx(c) - bw / 2:.1f}" y="{cy(r) - bh / 2:.1f}" ' f'width="{bw:.1f}" height="{bh:.1f}" rx="{4 * k:.1f}"') if collapse_names: # the names the count stands for names = SEP.join(_plain(marked(sv(f))) for f in fs) parts.append(f'{box}><title>{names}</title></rect>') else: parts.append(f'{box}/>') y0 = cy(r) - (len(lines) - 1) * lineH / 2 for kk, line in enumerate(lines): parts += _var_line(line, cx(c), y0 + kk * lineH, size=t.vsize, bold=t.bold, measure=t.measure) return parts
[docs] def paint_key(keys: list[dict], lay: Layout, sty: Style, cut: float, hex_w: float) -> list[str]: '''The key under the matrix, each sample in the class and colour of what it stands for.''' t = lay.setting k, vsize = t.k, t.vsize keyH, keyW, keyG, mB = lay.key_h, lay.key_w, lay.key_g, lay.m_bottom parts : list[str] = [] if keys: ky = lay.height - mB / 2 - keyH / 2 + 6 * k kx = lay.m_left for entry in keys: text, cls = entry['text'], entry['class'] sample = (f'<rect class="key {cls}" x="{kx:.1f}" y="{ky - 7 * k:.1f}" ' f'width="{18 * k:.1f}" height="{14 * k:.1f}" rx="{4 * k:.1f}"') if entry['shape'] == 'open' and sty.cuts: parts.append(f'{sample} style="stroke:none"/>') parts += [f'<path class="key {cls}" d="{d}" stroke="{entry["stroke"]}" ' f'style="fill:none"/>' for d in cut_rect(kx, ky - 7 * k, 18 * k, 14 * k, 4 * k, cut)] elif entry['shape'] != 'hex': contour = '' if cls == 'group-ff' else f' stroke="{entry["stroke"]}"' parts.append(f'{sample}{contour}/>') else: parts.append(f'<polygon class="key {cls}" ' f'points="{hexagon(kx + 9 * k, ky, 18 * k, 14 * k)}" ' f'fill="{entry["fill"]}" stroke="{entry["stroke"]}" ' f'stroke-width="{hex_w:.2f}"><title>{xesc(entry["title"])}' f'</title></polygon>') parts.append(f'<text class="io" x="{kx + keyW:.1f}" ' f'y="{_baseline(xesc(text), ky, vsize):.1f}">' f'{xesc(text)}</text>') kx += keyW + t.twv(text) + keyG return parts