ReviewOS

stacks/stx

Heatmap legend uses a rainbow ramp, which reads as bands and fails in greyscale

#1938
Closed chris opened this 10 days ago ·
10 days ago

<Heatmap>'s legend uses a blue to cyan to green to yellow to red rainbow:

linear-gradient(to right, rgb(0, 0, 255), rgb(0, 255, 255), rgb(0, 255, 0), rgb(255, 255, 0), rgb(255, 0, 0))

(stx-heatmap__legend-gradient, from dist/heatmap.js.)

A rainbow is the one ramp a sequential scale should not use, for two reasons that both show up in real reading:

It invents boundaries that are not in the data. The transitions through cyan and yellow are perceptually much sharper than the ones through blue-cyan or red-yellow, so a reader sees bands and infers categories where the data is continuous.

It is not monotonic in lightness. Yellow is far lighter than both the blue and the red ends, so the ramp goes light in the middle. Printed in greyscale, or seen by a reader with a colour vision deficiency, "high" and "low" become indistinguishable while the middle stands out.

The conventional fix is a single hue from light to dark, which keeps lightness monotonic and stays readable in greyscale and under every common deficiency. If a two-ended scale is genuinely wanted, that is a diverging ramp: two hues with a neutral grey midpoint, never a hue at the middle.

A theme-able default would be better still. In an app with its own tokens the heatmap is currently the one component that cannot follow them, since the ramp is hardcoded; reading a custom property with the current gradient as a fallback would let an app supply its own hue without changing any behaviour for existing users.

Noticed because an app component named Heatmap resolved to this built-in rather than the app's own, and the rainbow was the giveaway: it could not have come from that app's tokens. Worth considering whether a built-in should lose a name collision to an app's resources/components, or at least say that it won.

Environment: @stacksjs/stx as resolved by stacks@0.70.371, Bun 1.3.14.

10 days ago

Fixed in 5f9ec57 — default is now a sequential single-hue blue, rainbow keeps the old colours under its own name (removing a named scheme would break whoever asked for it), and diverging is added for the genuinely two-ended case: two hues meeting at a neutral grey, never a hue at the middle.

The ramp is also five CSS custom properties now — --stx-heatmap-stop-1 through -5 — each falling back to the scheme's own colour, so an app can supply its own hue and anyone who sets nothing sees no change. The canvas resolves the same properties, not just the legend: the legend is plain CSS and picks them up for free, so without that an app's themed legend would describe a plot it does not match, which is worse than not being themeable at all.

The tests assert monotonic lightness computed from the hex rather than comparing against expected colours — a colour list would pin the palette someone happened to pick, this pins the property that makes a palette legible. The rainbow's own non-monotonicity is asserted too, so if that ever passes the luminance helper has stopped measuring and the rest of the file is vacuous.

What I did not address, so it does not get lost: your closing question about name collisions — whether a built-in should lose to an app's resources/components, or at least say that it won. That is a real and separate question and I have deliberately not answered it here. Note the mechanism is not quite what it looks like from the outside: stx builtins (StxLink, StxImage, …) already yield to a user component of the same name, but Heatmap is not a builtin — it is a file in @stacksjs/components, resolved through a different path, which is why yours lost. Worth its own issue; if you file one with the layout that lost, I will pick it up.

Sign in to comment on this issue.