Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Contrasting text color? #540

Open
mbostock opened this issue Sep 12, 2021 · 7 comments
Open

Contrasting text color? #540

mbostock opened this issue Sep 12, 2021 · 7 comments
Labels
enhancement New feature or request question Further information is needed

Comments

@mbostock
Copy link
Member

mbostock commented Sep 12, 2021

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.

@mbostock mbostock added enhancement New feature or request question Further information is needed labels Sep 12, 2021
@Fil
Copy link
Contributor

Fil commented Sep 14, 2021

Two ideas:

  1. This effect could also be achieved with css filters (imageFilter (support for the CSS filter attribute) #409)
fill: "imdb_rating",
colorFilter: "grayscale(1) brightness(72%) contrast(999) invert(95%)"

these numbers are hard to get right, though:

  • grayscale(1) converts to gray
  • brightness(72%) sets the threshold where contrast will go either black or white
  • contrast(999) posterizes darker grays to black, lighter grays to white (*)
  • invert(95%) converts white to 5% black, and black to 95% white

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

Other remarks:

  • filters can make svg slow
  • it's not perfect, since one shade in the middle will stay gray

@Fil
Copy link
Contributor

Fil commented Sep 14, 2021

  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 Fil mentioned this issue Jan 27, 2022
@Fil
Copy link
Contributor

Fil commented Mar 14, 2022

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
Copy link

any follow-up on this?

@Fil
Copy link
Contributor

Fil commented Jul 18, 2022

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
Copy link
Contributor

Fil commented Oct 25, 2022

@Fil
Copy link
Contributor

Fil commented Nov 10, 2022

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

it could maybe become a thing?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request question Further information is needed
Projects
None yet
Development

No branches or pull requests

3 participants