0

I have a line chart that allows multiple lines to appear on the same chart depending on the items. However, the amount of items is different as it is based on individuals. Therefore, I would need to have get distinct colors for all the items. The users can pick which items to be visible on the chart. Therefore, the amount of colors needed will be increased when the user picking more items to be visible. As the amount of colors increases, each color should be distinct and distinguishable at first but gradually become similar to each other. Would like to know whether there is a library or segment of code to achieve the objective above?

Alvin
  • 3
  • 1

2 Answers2

1

Edit: rewrote functions with ES6 Arrow Function Syntax. Added randomColours(quan) function. Added snippets for Method 1 and Method 2 to better show how they work. Added more parameters to Method 2 function.

Try something like this:

Method 1: returns a random colour represented by an rgb() colour value. Downside with this method: because the colours are random, you can get very different colours but also very similar ones. If you want a more strict way of generating colours which are equally spaced on the colour wheel, see Method 2.

function randomColour() {
    return `rgb(${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)})`;
}

// ES6 Arrow Function Syntax (still does same thing)
const randomColour = () => `rgb(${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)})`;

use:

let colOne = randomColour();
// colOne = "rbg(236, 174, 254)"

let colTwo = randomColour();
// colTwo = "rbg(47, 83, 164)"

let colThree = randomColour();
// colThree = "rbg(145, 214, 39)"

modified function using same principles as method one but instead of returning a single random colour, returns an array of random colours.

function randomColours(quan) {
    let colours = [];
    for (let i = 0; i < quan; i++) {
        colours.push(`rgb(${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)})`);
    }
    return colours;
}

// ES6 Arrow Function Syntax (still does same thing)
const randomColours = quan => {
    let colours = [];
    for (let i = 0; i < quan; i++) {
        colours.push(`rgb(${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)})`);
    }
    return colours;
}

use:

const coloursArr = randomColours(5);

// coloursArr = [
//     "rgb(183, 110, 102)", 
//     "rgb(59, 250, 185)", 
//     "rgb(188, 160, 67)", 
//     "rgb(241, 15, 18)", 
//     "rgb(136, 125, 129)"
// ]

Snippet for Method 1:

const inp = document.querySelector("input");
const btn = document.querySelector("button");
const parentDiv = document.querySelector("div.container");

const validNumber = ({ value, min = undefined, max = undefined, integer = false, ignoreStrings = [] }) => {
    if (typeof value != "string" && typeof value != "number") return console.error(`Value must be a number or string.`);

    let valid = true;
    let messages = [];

    for (let i = 0; i < ignoreStrings.length; i++) {
        value = String(value).split(ignoreStrings[i]).join("");
    }

    if (String(value) == "" || value == null || isNaN(value)) {
        valid = false;
        messages.push("Not a Number");
    }
    if (!messages.includes("Not a Number") && min && Number(value) < min) {
        valid = false;
        messages.push(`Minimum Value is ${min}`);
    }
    if (!messages.includes("Not a Number") && max && Number(value) > max) {
        valid = false;
        messages.push(`Maximum Value is ${max}`);
    }
    if (!messages.includes("Not a Number") && integer && !(!isNaN(value) && parseInt(Number(value)) == value && !isNaN(parseInt(value, 10)))) {
        valid = false;
        messages.push("Not an integer");
    }

    return { valid, messages };
}

// returns random colour!
const randomColour = () => `rgb(${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)})`;

// returns array of random colours!
const randomColours = quan => {
    let colours = [];
    for (let i = 0; i < quan; i++) {
        colours.push(`rgb(${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)}, ${Math.round(Math.random() * 255)})`);
    }
    return colours;
}

const generateCircles = quan => {
    // get an array of random colours
    const colours = randomColours(quan);

    parentDiv.innerHTML = "";

    for (let i = 0; i < quan; i++) {
        const elem = document.createElement("div");
        elem.className = "circle";

        // random colour for a circle
        elem.style.backgroundColor = colours[i];
        parentDiv.appendChild(elem);
    }
}

btn.addEventListener("click", () => {
    let details = validNumber({ value: inp.value, min: 1, max: 1000, integer: true, ignoreStrings: [" "] });
    
    if (details.valid) {
        generateCircles(Number(inp.value));
    } else {
        details.messagesStr = "";
        details.messages.forEach((str, i) => details.messagesStr += `    ${i+1}. ${str}\n`);
        alert(`Field_1 - Quantity:\n${details.messagesStr}`);
    }
});
*, ::before, ::after {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    min-height: 100vh;
    height: fit-content;
}

div.form {
    padding: 2rem;
    display: flex;
    flex-direction: column;
    align-items: start;
}
div.form > * { margin-bottom: 1rem; }
div.form input, div.form button {
    padding: 0.6rem 0.8rem;
    outline: none;
    border-radius: 0.2rem;
    font-family: sans-serif;
    font-size: inherit;
    font-weight: 500;
}
div.form input {
    box-sizing: border-box;
    height: fit-content;
    border: 2px solid rgb(179, 179, 179);
    transition: border-color 200ms ease-out;
}
div.form input:focus {
    border-color: rgb(45, 160, 255);
}
div.form button {
    border: 2px solid rgb(78, 175, 255);
    background-color: rgb(78, 175, 255);
    transition: background-color 200ms ease-out;
}
div.form button:hover {
    border-color: rgb(45, 160, 255);
    background-color: rgb(45, 160, 255);
}
div.form button:active {
    transform: scale(0.98);
}

div.container {
    padding: 0rem 1.5rem 2rem 1.5rem;
    display: flex;
    flex-wrap: wrap;
}

div.container div.circle {
    width: 5rem;
    height: 5rem;
    border-radius: 100%;
    margin: 0.5rem;
}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Colour Generation</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="form">
        <input type="number" placeholder="no. of circles">
        <button>Generate circles with random colours</button>
    </div>
    
    <div class="container">
    </div>

    <script src="script.js"></script>
</body>
</html>

Method 2

function generateColours(quan, shuffle=false) {
    let colours = [];
    for (let i = 0; i < quan; i++) {
        colours.push(`hsl(${(360/quan)*(quan-i)}, 80%, 50%)`);
    }

    if (shuffle) {
        // uses the Fisher-Yates Shuffle to shuffle the colours
        let currentIndex = colours.length, randomIndex;

        while (currentIndex != 0) {
            randomIndex = Math.floor(Math.random() * currentIndex);
            currentIndex--;
            [colours[currentIndex], colours[randomIndex]] = [colours[randomIndex], colours[currentIndex]];
        }
    }

    return colours;
}

// ES6 Arrow Function Syntax (does same thing)
const generateColours = (quan, shuffle=false) => {
    let colours = [];
    for (let i = 0; i < quan; i++) {
        colours.push(`hsl(${(360/quan)*(quan-i)}, 80%, 50%)`);
    }

    if (shuffle) {
        // uses the Fisher-Yates Shuffle to shuffle the colours
        let currentIndex = colours.length, randomIndex;

        while (currentIndex != 0) {
            randomIndex = Math.floor(Math.random() * currentIndex);
            currentIndex--;
            [colours[currentIndex], colours[randomIndex]] = [colours[randomIndex], colours[currentIndex]];
        }
    }

    return colours;
}

use:

let coloursArr = generateColours(5);

// coloursArr = [
//     "hsl(360, 80%, 50%)", 
//     "hsl(288, 80%, 50%)", 
//     "hsl(216, 80%, 50%)", 
//     "hsl(144, 80%, 50%)", 
//     "hsl(72, 80%, 50%)"
// ]

Colours are equally distinguishable.

I added some more parameters to the above function. It takes an object containing its arguments. Also makes use of Object De-structuring which came with ES6, allowing for many default parameters. This is what I came up with:

const generateColours = ({ quantity = 1, shuffle = false, order = "0,360", offset = 0, saturation = 80, lightness = 50 }) => {
    let colours = [];
    for (let i = 0; i < quantity; i++) {
        let hue;
        if (order == "0,360") hue = ((360/quantity) * (quantity+i)) - 360;
        if (order == "360,0") hue = (360/quantity) * (quantity-i);

        hue += offset;

        colours.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`);
    }

    if (shuffle) {
        // uses the Fisher-Yates Shuffle to shuffle the colours
        let currentIndex = colours.length, randomIndex;

        while (currentIndex != 0) {
            randomIndex = Math.floor(Math.random() * currentIndex);
            currentIndex--;
            [colours[currentIndex], colours[randomIndex]] = [colours[randomIndex], colours[currentIndex]];
        }
    }

    return colours;
}

using:

// all default parameters (one colour, shuffle set to false, order is '0,360', hue offset is 0, saturation is 80, lightness is 50)

generateColours({  });

// will return
// => [
//     'hsl(0, 80%, 50%)' // red
// ]

// ----------

// default parameters other than quantity is 6 (default is 1)
generateColours({ quantity: 6 });

// will return
// => [
//     'hsl(0, 80%, 50%)',   // red
//     'hsl(60, 80%, 50%)',  // yellow
//     'hsl(120, 80%, 50%)', // green
//     'hsl(180, 80%, 50%)', // cyan
//     'hsl(240, 80%, 50%)', // blue
//     'hsl(300, 80%, 50%)'  // magenta
// ]

Shuffle parameter. By default it is set to false. When set to true, it uses the Fisher-Yates Shuffle, an unbiased shuffle algorithm, to shuffle the array of colours before returning them.

generateColours({ quantity: 6, shuffle: true });

// might return
// => [
//     'hsl(60, 80%, 50%)',  // yellow
//     'hsl(180, 80%, 50%)', // cyan
//     'hsl(0, 80%, 50%)',   // red
//     'hsl(240, 80%, 50%)', // blue
//     'hsl(300, 80%, 50%)', // magenta
//     'hsl(120, 80%, 50%)'  // green
// ]

Order parameter. Changes order of the colours in the array (always starts from red though - if the offset is 0). Order can either be '0,360' or '360,0'. Default is '0,360'. Note: order will have no effect if shuffle is set to true.

generateColours({ quantity: 6, order: "360,0" });

// will return
// => [
//     'hsl(360, 80%, 50%)', // red
//     'hsl(300, 80%, 50%)', // magenta
//     'hsl(240, 80%, 50%)', // blue
//     'hsl(180, 80%, 50%)', // cyan
//     'hsl(120, 80%, 50%)', // green
//     'hsl(60, 80%, 50%)'   // yellow
// ]

Offset parameter. Offsets hue for every colour by specified amount. Default is 0. Offset can be positive or negative.

generateColours({ quantity: 3, offset: 30 });

// will return
// => [
//     'hsl(30, 80%, 50%)'  // orange
//     'hsl(150, 80%, 50%)' // cyan - green
//     'hsl(270, 80%, 50%)' // violet
// ]

Saturation and Lightness are self-explanatory. I've attached a snippet below so you can play with the different parameters. Enjoy. Hope it helps anyone.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Colour Generation</title>
    <style>
    *, ::before, ::after {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
    }

    body {
        min-height: 100vh;
        height: fit-content;
    }

    div.form {
        padding: 2rem;
        display: flex;
        flex-direction: column;
        align-items: start;
    }
    div.form div.inputs {
        display: grid;
        grid-template-columns: 1fr 1fr;
        grid-template-rows: 1fr 1fr;
        grid-gap: 1rem;
    }
    div.form div.inputs div.wrapper { width: fit-content; }
    div.form div.inputs div.wrapper h2 { display: inline-block; }
    div.form div.buttons { display: flex; }
    div.form div.buttons > div { margin-right: 2rem; padding-left: 0.6rem; border-left: 3px solid rgb(78, 175, 255); }
    div.form div.buttons > div > label { margin-left: 0.3rem; }
    div.form > * { margin-bottom: 1rem; }
    div.form input[type="number"], div.form button {
        padding: 0.6rem 0.8rem;
        outline: none;
        border-radius: 0.2rem;
        font-family: sans-serif;
        font-size: inherit;
        font-weight: 500;
    }
    div.form input[type="number"] {
        box-sizing: border-box;
        height: fit-content;
        border: 2px solid rgb(179, 179, 179);
        transition: border-color 200ms ease-out;
    }
    div.form input[type="number"]:focus {
        border-color: rgb(45, 160, 255);
    }
    div.form button {
        border: 2px solid rgb(78, 175, 255);
        background-color: rgb(78, 175, 255);
        transition: background-color 200ms ease-out, border-colour 200ms ease-out;
    }
    div.form button:hover {
        border-color: rgb(45, 160, 255);
        background-color: rgb(45, 160, 255);
    }
    div.form button:active {
        transform: scale(0.98);
    }
    div.form h3 { margin-bottom: 0.25rem; font-family: sans-serif }

    div.container {
        padding: 0rem 1.5rem 2rem 1.5rem;
        display: flex;
        flex-wrap: wrap;
    }

    div.container div.circle {
        width: 5rem;
        height: 5rem;
        border-radius: 100%;
        margin: 0.5rem;
    }

  </style>
</head>
<body>
    <div class="form">
        <div class="inputs">
            <div class="wrapper">
                <h3>Amount</h3>
                <input type="number" placeholder="no. of circles (def. = 1)" id="amount" value="1">
            </div>
            <div class="wrapper">
                <h3>Lightness</h3>
                <input type="number" placeholder="lightness (def. = 50)" id="lightness" value="50">%
            </div>
            <div class="wrapper">
                <h3>Hue Offset</h3>
                <input type="number" placeholder="hue offset (def. = 0)" id="offset" value="0">
            </div>
            <div class="wrapper">
                <h3>Saturation</h3>
                <input type="number" placeholder="saturation (def. = 80)" id="saturation" value="80">%
            </div>
        </div>
        <div class="buttons">
            <div class="checkboxes">
                <h3>Shuffle</h3>
                <input type="radio" id="shuffle-first" name="shuffle" checked>
                <label for="shuffle-first">False</label><br>
                <input type="radio" id="shuffle-second" name="shuffle">
                <label for="shuffle-second">True</label>
            </div>
            <div class="radio-buttons">
                <h3>Order</h3>
                <input type="radio" id="order-first" name="order" checked>
                <label for="order-first">0,360</label><br>
                <input type="radio" id="order-second" name="order">
                <label for="order-second">360,0</label>
            </div>
        </div>

        <button>Generate circles with equally spaced colours</button>
    </div>
    
    <div class="container">
    </div>

    <script>
      const inpAmount = document.querySelector("input#amount");
      const inpOffset = document.querySelector("input#offset");
      const inpLightness = document.querySelector("input#lightness");
      const inpSaturation = document.querySelector("input#saturation");
      const radioShuffleFirst = document.querySelector("input#shuffle-first");
      const radioOrderFirst = document.querySelector("input#order-first");
      const btn = document.querySelector("button");
      const parentDiv = document.querySelector("div.container");

      const validNumber = ({ value, min = undefined, max = undefined, integer = false, ignoreStrings = [] }) => {
          if (typeof value != "string" && typeof value != "number") return console.error(`Value must be a number or string.`);

          let valid = true;
          let messages = [];

          for (let i = 0; i < ignoreStrings.length; i++) {
              value = String(value).split(ignoreStrings[i]).join("");
          }

          if (String(value) == "" || value == null || isNaN(value)) {
              valid = false;
              messages.push("Not a Number");
          }
          if (!messages.includes("Not a Number") && min && Number(value) < min) {
              valid = false;
              messages.push(`Minimum Value is ${min}`);
          }
          if (!messages.includes("Not a Number") && max && Number(value) > max) {
              valid = false;
              messages.push(`Maximum Value is ${max}`);
          }
          if (!messages.includes("Not a Number") && integer && !(!isNaN(value) && parseInt(Number(value)) == value && !isNaN(parseInt(value, 10)))) {
              valid = false;
              messages.push("Not an integer");
          }

          return { valid, messages };
      }

      // returns an array of equally spaced colours!
      const generateColours = ({ quantity = 1, shuffle = false, order = "0,360", offset = 0, saturation = 80, lightness = 50 }) => {
          let colours = [];
          for (let i = 0; i < quantity; i++) {
              let hue;
              if (order == "0,360") hue = ((360/quantity) * (quantity+i)) - 360;
              if (order == "360,0") hue = (360/quantity) * (quantity-i);

              hue += offset;

              colours.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`);
          }

          if (shuffle) {
              // uses the Fisher-Yates Shuffle to shuffle the colours
              let currentIndex = colours.length, randomIndex;

              while (currentIndex != 0) {
                  randomIndex = Math.floor(Math.random() * currentIndex);
                  currentIndex--;
                  [colours[currentIndex], colours[randomIndex]] = [colours[randomIndex], colours[currentIndex]];
              }
          }

          return colours;
      }

      const generateCircles = (args) => {
          // get an array of equally spaced colours
          const colours = generateColours(args);

          parentDiv.innerHTML = "";

          for (let i = 0; i < args.quantity; i++) {
              const elem = document.createElement("div");
              elem.className = "circle";

              // random colour for a circle
              elem.style.backgroundColor = colours[i];
              parentDiv.appendChild(elem);
          }
      }

      btn.addEventListener("click", () => {
          let quanInput = validNumber({ value: inpAmount.value, min: 1, max: 1000, integer: true, ignoreStrings: [" "] });
          let offsetInput = validNumber({ value: inpOffset.value, min: -360, max: 360, ignoreStrings: [" "] });
          let lightInput = validNumber({ value: inpLightness.value, min: 0, max: 100, ignoreStrings: ["%", " "] });
          let satInput = validNumber({ value: inpSaturation.value, min: 0, max: 100, ignoreStrings: ["%", " "] });

          if (quanInput.valid && offsetInput.valid && lightInput.valid && satInput.valid) {
              let arguments = {
                  quantity: Number(inpAmount.value),
                  shuffle: (radioShuffleFirst.checked) ? false : true,
                  order: (radioOrderFirst.checked) ? "0,360" : "360,0",
                  offset: Number(inpOffset.value),
                  saturation: Number(inpSaturation.value),
                  lightness: Number(inpLightness.value)
              }

              generateCircles(arguments);
          }
          else {
              quanInput.messagesStr = "";
              quanInput.messages.forEach((str, i) => quanInput.messagesStr += `    ${i+1}. ${str}\n`);
              offsetInput.messagesStr = "";
              offsetInput.messages.forEach((str, i) => offsetInput.messagesStr += `    ${i+1}. ${str}\n`);
              satInput.messagesStr = "";
              satInput.messages.forEach((str, i) => satInput.messagesStr += `    ${i+1}. ${str}\n`);
              lightInput.messagesStr = "";
              lightInput.messages.forEach((str, i) => lightInput.messagesStr += `    ${i+1}. ${str}\n`);
              alert(`Field_1 - Quantity:\n${quanInput.messagesStr}\nField_2 - Offset:\n${offsetInput.messagesStr}\nField_3 - Lightness:\n${lightInput.messagesStr}\nField_4 - Saturation:\n${satInput.messagesStr}`);
          }
      });
    </script>
</body>
</html>
Adam Khalish
  • 146
  • 5
  • method 2 is a great solution to the problem above, just that the amount of colors is limited to 360. Is it ? – Alvin Dec 20 '21 at 14:01
  • Go as high as you like, you just need a very good pair of eyes to distinguish 360 or more colours. I will add 2 snippets using both methods so you can see. – Adam Khalish Dec 20 '21 at 17:32
0

One method I thought of:

If you go around the HSV color wheel at equal intervals then you get distinct colors. HSV are in the range of [0,1]. Set Saturation and Value to 1 unless you need more than a few dozen colors.

HSV color cylinder

Method to create an array of the amount of colors you would like:

const colorCount = 24;
const dh = 1 / colorCount;
let colors = [];
for(let i=0;i<colorCount;i++) {
     let rgb = HSVtoRGB(dh*i,1,1);
     colors.push(rgb);
}

// And our helper function:

/* accepts parameters
 * h  Object = {h:x, s:y, v:z}
 * OR 
 * h, s, v
*/
function HSVtoRGB(h, s, v) {
    var r, g, b, i, f, p, q, t;
    if (arguments.length === 1) {
        s = h.s, v = h.v, h = h.h;
    }
    i = Math.floor(h * 6);
    f = h * 6 - i;
    p = v * (1 - s);
    q = v * (1 - f * s);
    t = v * (1 - (1 - f) * s);
    switch (i % 6) {
        case 0: r = v, g = t, b = p; break;
        case 1: r = q, g = v, b = p; break;
        case 2: r = p, g = v, b = t; break;
        case 3: r = p, g = q, b = v; break;
        case 4: r = t, g = p, b = v; break;
        case 5: r = v, g = p, b = q; break;
    }
    return {
        r: Math.round(r * 255),
        g: Math.round(g * 255),
        b: Math.round(b * 255)
    };
}

Sources: Javascript convert HSB/HSV color to RGB accurately

Euthyphro
  • 84
  • 7