Let colormaps and the series palette be authored, not chosen from a list#291
Conversation
Color was the one encoding a user could not supply: `colormap=` took one of 20 compiled-in names, and the series/category cycle was `config.DEFAULT_PALETTE` with no public override. A team with brand colors had to pass `color=` on every single series, which forfeits the categorical channel entirely, and a custom continuous ramp was simply unreachable — the previous commit could only promise "not supported; pass an (n, 3) array as color=". `colormap=` now also accepts a sequence of CSS colors, interpolated evenly across the domain. Resolution happens once, in Python: `resolve_colormap` turns the colors into the same `(r, g, b)` stop list the built-in tables already are, and the wire carries stops in place of a name. Every renderer already funnelled through one stop resolver — `_svg._colormap_stops` for the static exporters, `colormapStops` for the client — so teaching those two to accept either form covers marks, density surfaces, heatmaps, and the colorbar gradient, and the native rasterizer needs no change at all because Python has always handed it explicit stops. `resolve_colormap` is idempotent so a mark may resolve for its own use and hand the result on. `xy.theme(palette=[...])` replaces the series and category cycle. The wire already carried `color.palette` and both exporters already read it; only the Python side was hardcoded, so this is a `Figure.palette` field threaded to the one place that assigns a default trace color. A user palette deliberately skips the eight-slot wrap warning: that warning polices the CVD-safety guarantee of the shipped default, which a replacement never claimed. Two things worth flagging for review: - A stop must be a color this process can resolve to RGB. `var()` and `color-mix()` pass the CSS shape check but only a browser cascade can evaluate them, and the exporters and the LUT builder do not run one — so they are rejected rather than silently painted a fallback. - The colorbar's gradient id is derived from the resolved stops rather than the colormap name, since a custom ramp has no name to hash.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Merging this PR will not alter performance
Comparing Footnotes
|
* Give a chart a typeface and a base text size `xy.theme(font_family=...)` and `theme(font_size=...)` were accepted — theme takes `**tokens` — validated, serialized, and then read by nobody. The font stack was `_FONT`, a module constant in `_svg.py`, and every chrome size was a literal: 11 for ticks, 12 for the axis title, 14 for the chart title. A chart in a document set in a serif had no way to match it, and the accepted-then- ignored spelling is exactly what `styles.py` promises not to do. Express every default as a multiple of one base (`_BASE_FONT_SIZE = 11`) and read the two values off the chart-root style the theme already writes. An unthemed chart resolves to the same 11/12/14 it always did — byte-identical output, asserted — and `font_size=22` scales the set to 22/24/28 rather than enlarging one element out of proportion. The base also feeds the left-gutter measurement from the previous commit, so larger type reserves more room instead of running under the axis title. The browser side needed the same treatment for a different reason: the three hardcoded chrome sizes in the stylesheet are now `em` multiples of the chart root, which the theme writes directly, so they scale with `font_size` while keeping their current pixel values. `font_family` is honored by the browser, SVG, and PDF. The native raster exporter draws with XY's baked bitmap font and runs no cascade, so it takes the size and keeps its own face; the docs now state which half applies where, and a test pins it rather than leaving it to be discovered. The stack is declaration-checked like any other style value, so it cannot smuggle CSS into the SVG, and the size is bounded — below ~6 px text stops being text, and a runaway value would blow the reserved chrome room rather than produce a bigger chart. * Draw categorical legends and reference-line labels in static exports (#293) Two pieces of chrome the browser client drew and the exporters silently did not. A categorical color channel got no legend at all. `_legend` was fed `[t for t in traces if t.get("name")]`, and a scatter colored by a category column has no trace name — the identities live in `color.categories`, which the client expands into one row per category. So `scatter(color=continent)` showed a three-color legend in a notebook and an unlabelled scatter in the PNG that went into the deck. `legend_entries` moves that expansion to the shared layer both exporters already call, shaping each row like a trace so the existing layout and painters need no special case. Named traces are unaffected, and an unnamed trace still contributes nothing. `xy.hline(2.5, text="target")` reached the wire with its text and was dropped: the label block ran only for `kind in ("text", "callout")`, so `rule` and `band` annotations rendered their geometry and threw away their label. They have no coordinates of their own — the label rides the line, or the band's middle, pinned to a plot edge — so `_rule_label_anchor` ports the client's placement rule (`js/src/51_annotations.ts`) once and both exporters use it. A guard falls out of doing this: a scale can map an annotation off the finite plane, and emitting `x="nan"` produces an SVG document no renderer will parse. Those labels are now skipped.
Stacked on #290.
Color was the one encoding a user could not supply.
colormap=took one of 20compiled-in names, and the series/category cycle was
config.DEFAULT_PALETTEwith no public override — so a team with brand colors had to pass
color=onevery series, which forfeits the categorical channel entirely. #288 could only
improve the error text: "not supported; pass an (n, 3) array as color=".
Custom colormaps
colormap=now also accepts a sequence of CSS colors, interpolated evenlyacross the domain.
Resolution happens once, in Python:
resolve_colormapturns the colors intothe same
(r, g, b)stop list the built-in tables already are, and the wirecarries stops in place of a name. Every renderer already funnelled through one
stop resolver —
_svg._colormap_stopsfor the static exporters,colormapStopsfor the client — so teaching those two to accept either formcovers marks, density surfaces, heatmaps, and the colorbar gradient. The native
rasterizer needs no change at all, because Python has always handed it explicit
stops.
resolve_colormapis idempotent, so a mark may resolve for its own useand hand the result on.
Series palette
xy.theme(palette=[...])replaces the series and category cycle.The wire already carried
color.paletteand both exporters already read it;only the Python side was hardcoded. This is a
Figure.palettefield threadedto the one place that assigns a default trace color, so it covers series
colors, categorical channels, and legend swatches at once.
Two things worth a look
CVD-safety guarantee of the shipped default, which a replacement never
claimed — firing it on someone's brand palette is noise. The docs say plainly
that a replacement is not checked.
var()andcolor-mix()pass theCSS shape check but only a browser cascade can evaluate them, and neither the
exporters nor the LUT builder run one — so they are rejected rather than
silently painted a fallback.
The colorbar's gradient id is now derived from the resolved stops rather than
the colormap name, since a custom ramp has no name to hash.
Verification
uv run pytest— 2298 passed. 20 new tests intests/test_colormaps_and_palette.py, including the wire shape, the colorbargradient, heatmaps, palette repeat, the unchanged default, and 8 parametrized
rejection cases.