Chart.js Pie with best random background color

268 Views Asked by At

I have some data that will be visualized in Pie Chart form.

However, I want 2 adjacent colors not to be identical. For example, if I have 3 data then I don't want them to have the background colors #90EE90, #A1FFA1, and #FF0000 because the colors #90EE90 and #A1FFA1 have a bad contrast score according to coolors.co.

let randomColor = () => {
  let characters='0123456789ABCDEF';
  let randomString='';
  for (let i=0; i<6; i++) {
    let randomIndex=Math.floor(Math.random()*characters.length);
    randomString+=characters.charAt(randomIndex);
  }
  return '#'+randomString;
}
let round = (num) => Math.round(num*100)/100;

let chart = null;
$('#generate').click(()=>{
  let data = [];
  let labels = [];
  let pieces = $('#pieces').val();
  for(let i=0; i<pieces; i++) {
    labels.push(randomColor());
    data.push(round(1/pieces));
  }
  if(chart) chart.destroy();
  chart = new Chart($('#chart'), {
    type: 'pie',
    data: {
      labels: labels,
      datasets: [{
        data: data,
        backgroundColor: labels,
        borderWidth: 0
      }]
    },
    options: {
      plugins: {
        tooltip: {
          callbacks: {
            label: (context) => {
              return context.parsed;
            }
          }
        }
      }
    }
  });
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<div>
  <input id="pieces" type="number" value="20" />
  <button id="generate">Generate Chart</button>
</div>
<canvas id="chart"></canvas>

Can you help me generate some random background colors but adjacent colors have a good contrast score or don't hurt the eyes?

3

There are 3 best solutions below

8
On BEST ANSWER

A possible solution is to use a color-contrast checker library, like this one: https://www.npmjs.com/package/wcag-contrast

It's based on the WCAG contrast ratio standard: https://www.w3.org/TR/WCAG20/#contrast-ratiodef

Using the hex() function, you can check the contrast ratio between two hex values, which is a value between 1 (low contrast) and 21 (high contrast).

Your code can be improved in many different ways, anyway i only added the "core" improvements to add the discussed functionality (so you can better understand what i've done)

const CONTRAST_THRESHOLD = 6;

let randomColor = (prevColor = '#fff') => {
    const characters='0123456789ABCDEF';
    let currentColor;
    do {
        currentColor = '';
        for (let i = 0; i < 6; i++) {
            let randomIndex=Math.floor(Math.random()*characters.length);
            currentColor+=characters.charAt(randomIndex);
        }
    } while (wcagContrast.hex(prevColor, currentColor) < CONTRAST_THRESHOLD)

    return '#'+currentColor;
}

let checkFirstAndLastColor = (colorsArray) => {
    while (wcagContrast.hex(colorsArray[0], colorsArray[colorsArray.length -1]) < CONTRAST_THRESHOLD || wcagContrast.hex(colorsArray[0], colorsArray[1]) < CONTRAST_THRESHOLD) {
        colorsArray[0] = randomColor(colorsArray[1]);
    }
}

let round = (num) => Math.round(num*100)/100;

let chart = null;
$('#generate').click(()=>{
    let data = [];
    let labels = [];
    let pieces = $('#pieces').val();
    for(let i=0; i<pieces; i++) {
        labels.push(randomColor(labels[i - 1]));
        data.push(round(1/pieces));
    }
    if (labels.length > 2) {
        checkFirstAndLastColor(labels);
    }
    if(chart) chart.destroy();
    chart = new Chart($('#chart'), {
        type: 'pie',
        data: {
            labels: labels,
            datasets: [{
                data: data,
                backgroundColor: labels,
                borderWidth: 0
            }]
        },
        options: {
            plugins: {
                tooltip: {
                    callbacks: {
                        label: (context) => {
                            return context.parsed;
                        }
                    }
                }
            }
        }
    });
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>

<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="
https://cdn.jsdelivr.net/npm/[email protected]/dist/index.umd.min.js
"></script>
<div>
  <input id="pieces" type="number" value="20" />
  <button id="generate">Generate Chart</button>
</div>
<canvas id="chart"></canvas>

The main idea is:

  1. Upgrade the randomColor() function to check if currentColor has a high contrast with the previous color (prevColor). Otherwise, generate another color until contrast ratio is >= CONTRAST_THRESHOLD (set a number between 1 and 21).

  2. Run checkFirstAndLastColor when all colors are set to check if the last color and the first one have an high contrast. If not, change the first color until it reach an high contrast with both the second and the last colors.

As already said, code could be improved in many ways, i hope my answer gives you the right hint to reach your result.

4
On
const CONTRAST_THRESHOLD = 4.5;
let generatedColors = new Set();

let randomColor = () => {
    let currentColor;
    let contrastIsOk;
    // Generate a random color and ensure it has a high enough contrast ratio with all previously generated colors
    do {
        currentColor = '#' + Math.floor(Math.random() * 16777215).toString(16).padEnd(6, '0');
        contrastIsOk = [...generatedColors].every(col => wcagContrast.hex(col, currentColor) >= CONTRAST_THRESHOLD);
    } while (generatedColors.has(currentColor) || !contrastIsOk);
    
    generatedColors.add(currentColor);
    return currentColor;
}

let round = (num) => Math.round(num * 100) / 100;

let chart = null;
$('#generate').click(() => {
    let data = [];
    let colors = [];
    generatedColors.clear();
    let pieces = $('#pieces').val();

    // Generate a unique color for each piece of the pie chart
    for (let i = 0; i < pieces; i++) {
        colors.push(randomColor());
        data.push(round(1 / pieces));
    }

    // Special handling for odd numbers of pieces to avoid low contrast between the first and last segments
    if (pieces % 2 !== 0 && wcagContrast.hex(colors[0], colors[colors.length - 1]) < CONTRAST_THRESHOLD) {
        // Re-generate the last color to ensure contrast
        let lastColor;
        do {
            lastColor = randomColor();
        } while (wcagContrast.hex(colors[0], lastColor) < CONTRAST_THRESHOLD);
        colors[colors.length - 1] = lastColor;
    }

    if (chart) chart.destroy();

    // Create the pie chart with the generated colors
    chart = new Chart($('#chart'), {
        type: 'pie',
        data: {
            labels: colors.map((color, index) => `Color ${index + 1}`), // Label each color for clarity
            datasets: [{
                data: data,
                backgroundColor: colors,
                borderWidth: 0
            }]
        },
        options: {
            plugins: {
                tooltip: {
                    callbacks: {
                        label: (context) => `${context.label}: ${context.parsed}`
                    }
                }
            }
        }
    });
});

And ensure to include the WCAG contrast library:

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/index.umd.min.js"></script>
2
On

There is plugin called chroma js which can help a lot to work with colors https://gka.github.io/chroma.js/

Edit:

  • Now it check also with if first and last have contrast
  • regeneratColorIteration can be changed to any positive number to change starting color
  • minContrast now is more external to configurate minimal contrast of coresponding chart parts
  • check if exact color isn't already used in another chart part

Code utilize answers of

let minContrast = 2;

let randomByHsl = (colorNum) =>
{
    let hues = [0, 20, 30, 40, 50, 60, 80, 120, 160, 190, 210, 230, 260, 290, 330, 360];
    //hues = [...Array(37).keys()].map(x=>x*10);
    let lights = [20, 25, 30, 35, 40, 43, 45, 48, 50, 53, 55, 58, 60, 63, 65, 68, 70, 73, 75, 80];
    let goldenFrac = 0.5 * (3 - Math.sqrt(5));
    let x = (colorNum * goldenFrac % 1.0) * (hues.length - 1);
    //x=colorNum%(hues.length-1); // for point visualisation
    let i = Math.floor(x);
    let f = x % 1.0;
    let hue = (1.0 - f) * hues[i] + f * hues[i + 1];
    let light = (1.0 - f) * lights[i] + f * lights[i + 1];
    return [Math.round(hue * 100) / 100, 100, Math.round(light * 100) / 100];
}

let hslToHex = (h, s, l) =>
{
  l /= 100;
  s /= 100;

  const a = s * Math.min(l, 1 - l);
  const f = (n, h, l, a) => {
    const k = (n + h / 30) % 12;
    const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
    return Math.round(255 * color).toString(16).padStart(2, '0');
  };

  const red = f(0, h, l, a);
  const green = f(8, h, l, a);
  const blue = f(4, h, l, a);

  return `#${red}${green}${blue}`;
}

let makeColor = (colorNum) =>
{
  let newColor = randomByHsl(colorNum);
  return hslToHex(...newColor);
}

let calculateContrast = (color1, color2) =>
{
  return chroma.contrast(color1, color2);
}

let contrastCheck = (color, toCompare) =>
{
  if(toCompare.length)
  {
    let contrasts = [];

    toCompare.forEach((colorToCompare) =>
    {
      contrasts.push(calculateContrast(color, colorToCompare));
    });

    return contrasts.every(contrast => contrast > minContrast);
  }
  else
  {
    return true;
  }
}

let generateColors = (pieces) =>
{
  let labels = [];
  let data = [];
  let previousColor = null;
  let regeneratColorIteration = 1;

  for (let i = 0; i < pieces; i++)
  {
    regeneratColorIteration++;

    let newColor = makeColor(regeneratColorIteration);
    let colorsToCompare = [];

    if(previousColor)
    {
      colorsToCompare.push(previousColor);
    }

    if(i == pieces - 1
    && i != 0)
    {
      colorsToCompare.push(labels[0]);
    }

    while(labels.includes(newColor)
    && ! contrastCheck(newColor, colorsToCompare))
    {
        regeneratColorIteration++;
        newColor = makeColor(regeneratColorIteration);
    }

    labels.push(newColor);
    data.push(round(1 / pieces));
    previousColor = newColor;
  }

  return { labels, data };
}

let round = (num) => Math.round(num * 100) / 100;
let chart = null;

$('#generate').click(() =>
{
    let pieces = $('#pieces').val();

    if(chart)
    {
        chart.destroy();
    }

    const { labels, data } = generateColors(pieces);

    chart = new Chart($('#chart'),
    {
        type: 'pie',
        data:
        {
            labels: labels,
            datasets:
            [{
                data: data,
                backgroundColor: labels,
                borderWidth: 0
            }]
        },
        options:
        {
            plugins:
            {
                tooltip:
                {
                    callbacks:
                    {
                        label: (context) => 
                        {
                            return context.parsed;
                        }
                    }
                }
            }
        }
    });
})
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
        <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/chroma-js/2.1.1/chroma.min.js"></script>

        <div>
          <input id="pieces" type="number" value="20" />
          <button id="generate">Generate Chart</button>
        </div>
        <canvas id="chart"></canvas>