observablehq / plot

A concise API for exploratory data visualization implementing a layered grammar of graphics
https://observablehq.com/plot/
ISC License
4.27k stars 174 forks source link

Contrasting text color? #540

Open mbostock opened 3 years ago

mbostock commented 3 years ago

Often it’s desirable for a text label to have a strong contrast against its background. If the background color is constant (e.g., white in the empty space of the chart), then it’s fine to hard-code the text color. But if the background color is variable, as when labeling cells, then it’s more difficult. For example, the extreme values (e.g., 4.5 and 9.2) are hard to read here:

Screen Shot 2021-09-12 at 10 09 06 AM

It’s difficult to do this well in Plot currently.

One approach is to define the fill color as a channel. However, since the fill channel is bound to the color scale, you cannot express the color literally; you must specify an abstract value that is passed through the color scale. Hence, this is generally not an option. This problem was discussed in #56, and identity scales were added in #305, however, this isn’t really a good fix for this problem because we don’t want to disable the color scale entirely; we just want to disable it for this specific mark channel.

Another approach is to repeat the text mark definition twice, once for light text and once for dark text, and use the filter option to control which data gets which color. This produces the desired result, but it’s tedious.

Screen Shot 2021-09-12 at 10 17 36 AM
Plot.text(simpsons, {
  x: "season",
  y: "number_in_season",
  text: d => d.imdb_rating?.toFixed(1),
  title: "title",
  filter: d => d.imdb_rating >= 5.5 && d.imdb_rating <= 8.5,
  fill: "black"
}),
Plot.text(simpsons, {
  x: "season",
  y: "number_in_season",
  text: d => d.imdb_rating?.toFixed(1),
  title: "title",
  filter: d => d.imdb_rating < 5.5 || d.imdb_rating > 8.5,
  fill: "white"
})

This can be alleviated with a helper mark:

function ctext(data, {invert, ...options} = {}) {
  const filter = Plot.valueof(data, invert);
  return Plot.marks(
    Plot.text(data, {...options, filter, fill: "white"}),
    Plot.text(data, {...options, filter: filter.map(d => !d)})
  );
}

But even so, it requires manually specifying thresholds in data space to determine which text labels should be inverted, which is tedious and prone to error. It’d be better to take advantage of the color scale definition, but this isn’t available at the time the mark channels are being constructed (since there’s a potential circularity there).

It’s sort of possible to do this in CSS using mixBlendMode:

Screen Shot 2021-09-12 at 10 09 54 AM
Plot.text(simpsons, {
  x: "season",
  y: "number_in_season",
  text: d => d.imdb_rating?.toFixed(1),
  title: "title",
  fill: "white",
  mixBlendMode: "difference"
})

The result isn’t perfect (the text inherits a color rather than being either white or black), and the technique isn’t especially memorable but it is concise.

One thought is that maybe text marks could have a “background” color channel, which if specified, will automatically invert the fill color to maximize contrast.

Fil commented 3 years ago

Two ideas:

1) This effect could also be achieved with css filters (https://github.com/observablehq/plot/pull/409)

fill: "imdb_rating",
colorFilter: "grayscale(1) brightness(72%) contrast(999) invert(95%)"

these numbers are hard to get right, though:

Capture d’écran 2021-09-14 à 10 14 10

Other remarks:

Fil commented 3 years ago
  1. If we wanted to hardcode it (with proper color-analysis), we could have something like
    fill: "imdb_rating", fillContrast: ["white", "black", "lime"]

and replace the (computed, scaled) fill by the color from the contrast array that maximizes the contrast or color difference, something like

function mostContrasted(f, colors) {
  const l = lab(f).l;
  return greatest(colors, c => Math.abs(l - lab(c).l));
}

export function applyChannelStyles(selection, {title: L, fill: F, fillOpacity: FO, stroke: S, strokeOpacity: SO, strokeWidth: SW}, mark) {
  if (mark && mark.fillContrast) {
    applyAttr(selection, "fill", F && (i => mostContrasted(F[i], mark.fillContrast)));
  } else {
    applyAttr(selection, "fill", F && (i => F[i]));
  }
  ...

fillContrast: true would be a shorthand notation for ["white", "black"] or even ["#fefefe", "#333"].

This can also be done as a plugin that would be triggered by some kind of markup (maybe the illegal mixBlendMode: "contrast"…? or a class name…). See https://observablehq.com/d/fa614ef721cdfc99 for a quick'n'dirty implementation.

Fil commented 2 years ago

Here's a way to do it with #801

Plot.text(…, {
        initialize: (facets, {fill}, {color}) => {
          if (fill && fill.scale === "color" && color) {
            return {facets, channels: {fill: {
              label: fill.label,
              value: Array.from(fill.value, d => d3.hsl(color(d)).l > 0.7 ? "black" : "white")
            }}};
          }
          return {facets};
        },
        fill: "imdb_rating",
        ...
})

(the actual choice of lightness measurement and threshold is still tbd)

himself65 commented 2 years ago

any follow-up on this?

Fil commented 2 years ago

There is now a unit test for this, which uses a remap function to build the darker transform. Not sure how to best package it for easier consumption.

Fil commented 1 year ago

See also https://observablehq.com/@argyleink/contrast-color

Fil commented 1 year ago

current best approach is this plugin https://observablehq.com/@observablehq/plot-colorcontrast-custom-transform

it could maybe become a thing?