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
resolve_color(params, sdata) -> ColorSpec — pure function extracting the color-resolution logic now inlined in the renderers; add a colortype field describing each layer.
- Move backend selection + extent computation into the resolve pass, populating the IR.
- 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.
- (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.
Summary
This is the architectural capstone of the refactor audit: introduce a
RenderParamsbase class and, in phases, evolve the*RenderParamsdataclasses into a real intermediate representation (IR) that fully resolves what to draw before any matplotlib object exists — then haverender.pyconsume that IR as a pure "how to draw it" backend.The pain today
render_params.py's 5 dataclasses duplicate 15+ fields (element/color/cmap_params/colorbar/zorderin all 5;transfunc/table_name/palette/groupsin 4). This propagates into 5 constructor call sites inbasic.py, 5_validate_*_render_paramsinutils.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 → datashaderbackend 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):
RenderParamsbase classA
@dataclass(kw_only=True) class RenderParamsholding 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
resolve_color(params, sdata) -> ColorSpec— pure function extracting the color-resolution logic now inlined in the renderers; add acolortypefield describing each layer.SceneSpec/LayerSpecas the formal boundary;show()builds the Scene, aMatplotlibBackendconsumes 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.Scene.to_dict()+ a vega-like exporter.Why this direction, done this way
The maintainers already attempted this on the stalled
origin/viewconfigbranch (PR #267), but built the spec post-hoc by reading back matplotlib state (fig.subplotpars,Text,transAxes) at the end ofshow()— which ballooned to 1500+ lines, required acolortypefield that never reachedmain, 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.