Skip to content

Architecture: RenderParams base class -> formal IR (resolve -> IR -> backend) #700

@timtreis

Description

@timtreis

Summary

This is the architectural capstone of the refactor audit: introduce a RenderParams base class and, in phases, evolve the *RenderParams dataclasses into a real intermediate representation (IR) that fully resolves what to draw before any matplotlib object exists — then have render.py consume that IR as a pure "how to draw it" backend.

Filed as a discussion: the phased plan and the IR boundary deserve maintainer input before execution.

The pain today

render_params.py's 5 dataclasses duplicate 15+ fields (element/color/cmap_params/colorbar/zorder in all 5; transfunc/table_name/palette/groups in 4). This propagates into 5 constructor call sites in basic.py, 5 _validate_*_render_params in utils.py, and 21 hardcoded element-type-string dispatch sites. Adding one cross-cutting field today means editing ~4 dataclasses + 4 constructors + 4 validators.

Deeper: the params are half-baked specs. They carry user input, not resolved semantics. The actual decisions about what to draw (categorical-vs-continuous color resolution via _set_color_source_vec; the >10000 elements → datashader backend choice; extents) happen inside the render functions, fused with matplotlib calls. There is no testable boundary between "scene" and "pixels".

Step 1 (low-risk, do now): RenderParams base class

A @dataclass(kw_only=True) class RenderParams holding the shared fields; the 5 become thin subclasses with only their unique fields. Purely internal (these dataclasses are not public). Removes the plumbing duplication and creates the attachment point for the IR.

Steps 2+ (phased): resolve → IR → backend

  1. resolve_color(params, sdata) -> ColorSpec — pure function extracting the color-resolution logic now inlined in the renderers; add a colortype field describing each layer.
  2. Move backend selection + extent computation into the resolve pass, populating the IR.
  3. Introduce SceneSpec/LayerSpec as the formal boundary; show() builds the Scene, a MatplotlibBackend consumes it. Because layout (extent, colorbar slots) is declared in the IR, axes can be sized once up front — dissolving the two-pass colorbar hack and the imshow-resize intensity problem.
  4. (Optional, separate) Scene.to_dict() + a vega-like exporter.

Why this direction, done this way

The maintainers already attempted this on the stalled origin/viewconfig branch (PR #267), but built the spec post-hoc by reading back matplotlib state (fig.subplotpars, Text, transAxes) at the end of show() — which ballooned to 1500+ lines, required a colortype field that never reached main, and never landed. Building the IR as the source of truth that drives rendering (resolve → IR → backend), rather than an artifact reverse-engineered from pixels, is the structural inversion that makes it tractable. It unlocks matplotlib-free testing (the biggest win given the image-baseline-heavy suite), correct serialization, and a clean backend seam — all behind a frozen public API.

Risk / effort

Impact: very high · Effort: high (multi-PR) · Risk: medium, controllable — public API untouched, each phase behavior-preserving and guarded by existing baselines. Do Step 1 now; sequence the IR after the utils split, show() decomposition, render-quartet, and color-pipeline issues create the seams.


Part of a maintainability/refactor audit of main.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions