Skip to content

Let colormaps and the series palette be authored, not chosen from a list#291

Merged
Alek99 merged 2 commits into
alek/colorbar-formatfrom
alek/custom-colormaps
Jul 25, 2026
Merged

Let colormaps and the series palette be authored, not chosen from a list#291
Alek99 merged 2 commits into
alek/colorbar-formatfrom
alek/custom-colormaps

Conversation

@Alek99

@Alek99 Alek99 commented Jul 25, 2026

Copy link
Copy Markdown
Member

Stacked on #290.

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 — so a team with brand colors had to pass color= on
every 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 evenly
across the domain.

xy.scatter(x, y, color=spend, colormap=["#0d1b2a", "#1b6ca8", "#48cae4", "#ffd166", "#ef476f"])
before after

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. 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.

Series palette

xy.theme(palette=[...]) replaces the series and category cycle.

xy.line_chart(*lines, xy.legend(), xy.theme(palette=["#ff5c00", "#111827", "#00b3a4", "#8b5cf6"]))
before after

The wire already carried color.palette and both exporters already read it;
only the Python side was hardcoded. This is a Figure.palette field threaded
to 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

  • A user palette skips the eight-slot wrap warning. That warning polices the
    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.
  • A stop must resolve to RGB in-process. var() and color-mix() pass the
    CSS 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 in
tests/test_colormaps_and_palette.py, including the wire shape, the colorbar
gradient, heatmaps, palette repeat, the unchanged default, and 8 parametrized
rejection cases.

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.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9ec5b9a9-6288-4b93-97a8-96450245667d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch alek/custom-colormaps

Comment @coderabbitai help to get the list of available commands.

@codspeed-hq

codspeed-hq Bot commented Jul 25, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 102 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing alek/custom-colormaps (1198117) with alek/colorbar-format (72bfca0)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

* 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.
@Alek99
Alek99 merged commit 2756af7 into alek/colorbar-format Jul 25, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant