WebDesign Dackel

JavaScriptで複数のランダムな色を虹色に並び替えてみる

JavaScriptで複数のランダムな色を虹色に並び替えてみる

Hatena0
Google+0
Pocket0
Feedly0

はじめに

例えば、ランダムに並べたとってもカラフルなブロックを、手作業で虹色に並び替えるのはそれほど難しくないかもしれません。(実際やってみたら難しいかも…)
ではそれをプログラムで実現にはどうすれば??ってなったのでサンプルを作ってみました。

ただ、結果を先に言ってしまうと「完璧っ!期待通りだー!」とはならず、そこそこな感じで終わってしまいました。

とりあえずやってみた内容は残しておきたかったのでブログに書いてみます。


結構グダグダ書いていたら長くなってしまったので、先に結果を確認したい方は以下のリンクより確認できます!

実際の動作サンプル

サンプルに使用したファイルはGitHubにあげています。

tsuyoshiwada/sample-sort-colors

実装する前に、前提の整理から

並び替えの対象は16進数のカラーコード(RGB値)がランダムに入った配列を想定します。

var colors = [
  "#af8919",
  "#2387e1",
  "#995103",
  ...
];

この配列の中身を虹色に並び替える、というのが今回の目標です!

どうやって色の並び替えを実現するか

今回は下記2つの方法で試してみました。

  1. RGBHSV色空間に変換し、色相彩度を使って並び替え
  2. 基準となる色を用意し、それぞれ照らしあわせて最も近い色で並び替え

それぞれの実装内容は後述します。

確認用のひな形を用意

下記の様なファイルを用意しました。

.
├── dist
│   └── app.bundle.js #HTMLに読み込むJS
├── gulpfile.coffee
├── index.html
├── node_modules
├── package.json
└── src
    ├── Color.js
    ├── ColorSort.js #並び替えの基底クラス
    ├── DistanceColorSort.js #基準色を使った並び替え
    ├── HSVColorSort.js #HSVを使った並び替え
    └── app.js #エントリーファイル

サンプルのファイルを全部書いてしまいましたが、DistanceColorSort.jsHSVColorSort.jsがメインとなります。

ちなみに、本題とは関係ないですが、勉強がてらタスクランナーにはgulpbrowserify+babelを使い、ES6(ES2015の方が正しい?)で書いてみました!

HTML

結果の確認をするために、下記の様なHTMLを用意。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
  <title>JavaScriptで複数のランダムな色を虹色に並び替えてみる</title>
  <style type="text/css">
  * {
    box-sizing:border-box;
  }
  body,
  html {
    margin:0;
    font-size:18px;
    font-family:"Monaco","Menlo","Ubuntu Mono","Consolas","source-code-pro",monospace;
    color:#444;
  }
  h1 {
    margin:30px 0;
    text-align:center;
  }
  h2 {
    margin:0;
    padding:10px;
    text-align:center;
  }
  .row {
    display:table;
    width:100%;
    table-layout:fixed;
    border-collapse:collapse;
  }
  .col {
    display:table-cell;
    border:4px solid #fff;
  }
  .results {
    overflow:hidden;
  }
  .results > div {
    height:15px;
  }
  </style>
</head>
<body>

  <h1>Sort Colors</h1>
  <div class="row">
    <div class="col">
      <h2>Original</h2>
      <div class="results" id="results-original"></div>
    </div>
    <div class="col">
      <h2>HSV</h2>
      <div class="results" id="results-hsv"></div>
    </div>
    <div class="col">
      <h2>Distance</h2>
      <div class="results" id="results-distance"></div>
    </div>
  </div>

  <script src="./dist/app.bundle.js"></script>
</body>
</html>

div.resultsにそれぞれの並び替え結果を一覧するようにします。

app.js

HSVColorSort.jsDistanceColorSortを呼び出して並び替えを実行、画面へ描画しています。

import Color from "./Color";
import HSVColorSort from "./HSVColorSort";
import DistanceColorSort from "./DistanceColorSort";


// 色の一覧を描画
function render($results, colors){
  let html = [];

  colors.forEach((hex, i) => {
    html.push(`<div style="background:${hex}"></div>`)
  });

  $results.innerHTML = html.join("");
}


// DOMの構築が完了したら、描画を開始
document.addEventListener("DOMContentLoaded", () => {
  var $original = document.getElementById("results-original"),
      $hsv = document.getElementById("results-hsv"),
      $distance = document.getElementById("results-distance"),
      colors = Color.createRandomColors(100); //100件のランダムな色

  render($original, colors);
  render($hsv, new HSVColorSort(colors).exec());
  render($distance, new DistanceColorSort(colors).exec());
}, false);

Color.js

RGBHSVの変換、2色間の色空間上の距離を算出する処理などを実装したクラスです。

export default class Color {

  /**
   * ランダムな色を生成
   * @param integer
   * @return array
   */
  static createRandomColors(length=50) {
    var colors = [];
    for( var i = 0; i < length; i++ ){
      colors.push("#" + ("00000" + Math.floor(Math.random() * 0x1000000).toString(16)).substr(-6));
    }
    return colors;
  }

  /**
   * RGBをHSVへ変換
   * http://stackoverflow.com/questions/8022885/rgb-to-hsv-color-in-javascript
   * @param integer
   * @param integer
   * @param integer
   * @return object
   */
  static RGBtoHSV(r, g, b) {
    var rr, gg, bb,
        r = arguments[0] / 255,
        g = arguments[1] / 255,
        b = arguments[2] / 255,
        h, s,
        v = Math.max(r, g, b),
        diff = v - Math.min(r, g, b),
        diffc = function(c){
            return ( v - c ) / 6 / diff + 1 / 2;
        };

    if( diff === 0 ){
      h = s = 0;
    }else{
      s = diff / v;
      rr = diffc(r);
      gg = diffc(g);
      bb = diffc(b);

      if( r === v ){
        h = bb - gg;
      }else if( g === v ){
        h = ( 1 / 3 ) + rr - bb;
      }else if( b === v ){
        h = ( 2 / 3 ) + gg - rr;
      }
      if( h < 0 ){
        h += 1;
      }else if( h > 1 ){
        h -= 1;
      }
    }

    return {
      h: Math.round(h * 360),
      s: Math.round(h * 100),
      v: Math.round(h * 100)
    }
  }

  /**
   * HEXをRGBへ変換
   * http://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb
   * @param integer
   * @param integer
   * @param integer
   * @return object
   */
  static HEXtoRGB(hex) {
    hex = hex.replace(/^#?([a-f\d])([a-f\d])([a-f\d])$/i, (m, r, g, b) => {
      return r + r + g + g + b + b;
    });

    let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
    return result ? {
      r: parseInt(result[1], 16),
      g: parseInt(result[2], 16),
      b: parseInt(result[3], 16)
    } : null;
  }

  /**
   * RGBをHEXへ変換
   * @param integer
   * @param integer
   * @param integer
   * @return object
   */
  static RGBtoHEX(r, g, b) {
    return "#" + ( ( 1 << 24 ) + ( r << 16 ) + ( g << 8 ) + b ).toString(16).slice(1);
  }

  /**
   * コンストラクタ
   * @param string | integer
   * @param integer
   * @param integer
   */
  constructor(r, g=null, b=null) {
    if( g == null && b == null ){
      let rgb = this.constructor.HEXtoRGB(r);
      r = rgb.r;
      g = rgb.g;
      b = rgb.b;
    }
    this.r = r;
    this.g = g;
    this.b = b;

    let hsv = this.constructor.RGBtoHSV(r, g, b);
    this.h = hsv.h;
    this.s = hsv.s;
    this.v = hsv.v;
  }

  /**
   * HEX値を返す
   * @return string
   */
  toHEXString() {
    return this.constructor.RGBtoHEX(this.r, this.g, this.b);
  }

  /**
   * 指定されたColorインスタンスとの色空間上の距離を算出
   * @param Color
   * @return number
   */
  getDistance(color) {
    return Math.sqrt(Math.pow(this.r - color.r, 2) + Math.pow(this.g - color.g, 2) + Math.pow(this.b - color.b, 2));
  }
}

ColorSort.js

export default class ColorSort {

  constructor(colors=[]) {
    this.colors = [...colors];
  }

  exec() {
    return [];
  }
}

コンストラクタで受け取った配列を保持しているだけです。

このクラスを継承してexecメソッドの中身を実装していく、という流れでいきたいと思います。

並び替えの実装

いよいよ並び替えのロジック部分の実装です。

色相、彩度を使った並び替え

色相、彩度を使った並び替え

このロジックは簡単で、渡ってきたRGB値をHSV色空間に変換後、色相と彩度を使ってArray.prototype.sortに掛けるだけです。

そんなわけでHSVColorSort.jsの中身は以下のようになります。

import Color from "./Color";
import ColorSort from "./ColorSort";


export default class HSVColorSort extends ColorSort {

  /**
   * 並び替えを実行し配列を返す
   * @return array
   */
  exec() {
    let results = [];

    this.colors.forEach((hex, i) => {
      let color = new Color(hex);
      results.push(color);
    });

    results.sort((a, b) => {
      if( a.h < b.h ) return 1;
      if( a.h > b.h ) return -1;
      if( a.s < b.s ) return 1;
      if( a.s > b.s ) return -1;
    });

    return results.map((color) => { return color.toHEXString(); });
  }
}

HSVでは他に明度をパラメータとして持っていますが、実際に試してみると結果が微妙な感じだったので今回は外してみました。


用意しておいた色と照らし合わせた並び替え

用意しておいた色と照らし合わせた並び替え

予め、基準となる色(今回は虹色)を用意しておきます。
対照の色がどの基準色に一番近いかをざっくりとグループ分けしておき、分けられたグループの中で更に近い順に並び替えを行います。

DistanceColorSort.jsの中身はこんな感じになります。

import Color from "./Color";
import ColorSort from "./ColorSort";


export default class DistanceColorSort extends ColorSort {

  /**
   * 基準色
   */
  static get baseColors() {
    return [
      "#ff0000",
      "#ff3333",
      "#ff6666",
      "#ff7a7a",

      "#ff007f",
      "#ff3399",
      "#ff66b2",
      "#ff7abc",

      "#ff00ff",
      "#ff33ff",
      "#ff66ff",
      "#ff7aff",

      "#7f00ff",
      "#9933ff",
      "#b266ff",
      "#bc7aff",

      "#0000ff",
      "#3333ff",
      "#6666ff",
      "#7a7aff",

      "#007fff",
      "#3399ff",
      "#66b2ff",
      "#7abcff",

      "#00ffff",
      "#33ffff",
      "#66ffff",
      "#7affff",

      "#00ff7f",
      "#33ff99",
      "#66ffb2",
      "#7affbc",

      "#00ff00",
      "#33ff33",
      "#66ff66",
      "#7aff7a",

      "#7fff00",
      "#99ff33",
      "#b2ff66",
      "#bcff7a",

      "#ffff00",
      "#ffff33",
      "#ffff66",
      "#ffff7a",

      "#ff7f00",
      "#ff9933",
      "#ffb266",
      "#ffbc7a"
    ];
  }

  /**
   * 並び替えを実行し配列を返す
   * @return array
   */
  exec() {
    let baseColors = this.constructor.baseColors,
        results = [];

    // もっとも近い基準色へグループ分け
    this.colors.forEach((hex1, i) => {
      let color1 = new Color(hex1),
          color2,
          ranking = [];

      baseColors.forEach((hex2, n) => {
        color2 = new Color(hex2);
        ranking.push({
          color: color1,
          group: n,
          distance: color1.getDistance(color2)
        });
      });

      ranking.sort((a, b) => {
        if( a.distance < b.distance ) return -1;
        if( a.distance > b.distance ) return 1;
      });

      results.push(ranking[0]);
    });

    // グループと距離を元に並び替え
    results.sort((a, b) => {
      if( a.group < b.group ) return -1;
      if( a.group > b.group ) return 1;
      if( a.distance < b.distance ) return -1;
      if( a.distance > b.distance ) return 1;
    });

    return results.map((obj) => { return obj.color.toHEXString(); });
  }
}

baseColorsが基準となる色になっています。かなり細かく分けてみましたが、正直もっと少なくても大差無さそうでした。

先ほどのロジックに比べると2重に並び替えの処理をを行っている分、やや煩雑な感じになってしまいました。もっとスマートな方法がありそうな気がしてなりません。。。

実際の動作サンプル

2つの方法を実装し終えましたので早速結果を確認してみます!

実際の動作サンプル

実際の動作サンプル

左の列が並び替えをする前のデータ、中央列がHSVを使った結果、右の列が基準色を使った結果です。
違う色での結果を確認したい場合はブラウザを更新してみてください。

冒頭でも書いたように、なんだか微妙な結果に感じます。
HSVが一番綺麗なのかな?と思う時があれば、Distanceが一番綺麗だなぁ〜と思う時もあります。

あと、HSVの結果では最後の方に赤色が混ざってしまっています。
これは色相が0~360°と表され、が赤色、330~340°くらいからまた赤色に変わってくるためです。必要に応じてここは調整が必要かなーなんて思ってます。
(今回はとりあえず見送り!)

おわりに

イマイチな仕上がりとなり、自分の技術力不足を痛感する結果となってしまいました…。次回挑戦する時は納得の行く結果を出せるように精進したいと思います!