[Javascript] CMY３色間のカラーサンプラーアプリを制作した時の話

## はじめに
以前に書いた記事のネタ元になったアプリの話です。機密情報に当たる部分もございますので、詳細は伏せますが、ある特殊染料を扱うクライアント様からのご相談で、染料調合割合を計算するためのカラーサンプラーアプリを制作した時の話です。[^1] クライアント様の了承を得て、デモ用に作成したプロトタイプ（ブラウザベース）[^2] をQiita用に改修したものを少しだけ公開します。

[^1]: 某クラウドソーシングサイトにて同様の案件がございましたが、本記事のクライアント様及び当方とは全く無関係です。潜在的なニーズがあるということかもしれませんね。

[^2]: 実際のアプリでは、タブレット使用前提のため、`monaca`と`Onsen UI`で構築しました。プライベートアプリのため公開はしていません。

https://qiita.com/STSHISHO/items/3611b16186ddeff958e8

https://qiita.com/STSHISHO/items/cf6e1ba25cabf40cd0c0

## 仕様について
タブレットでの使用を前提とし、その他の要望は概ね以下の通りでした。

1. ＣＭＹそれぞれの濃度１００％時の色成分初期値（ＲＧＢ）を変更したい
2. ３色間の調合割合ごとの混色結果を一括で確認したい
3. タップした部分の各色調合割合を表示したい


### 濃度１００％時の色成分初期値
画面上の色と実物の色との誤差は了解の上で実際の見た目に近づけたい、という要望もあったため、アプリでは設定画面で各色の濃度１００％時の初期値を設定できるようにしました。本記事のコードでは、設定部分のＵＩは用意せず、コード中で指定するようにしています。

```Javascript
//CMYのRGB値設定
const Cyan = {R:5,   G:191, B:255}
const Mgnt = {R:242, G:10,   B:230}
const Yllw = {R:255, G:229, B:0}
```

### ３色間の調合割合ごとの混色結果を一括で確認
カラーピッカーのようなグラデーションモデルをイメージされていたようで、四角形ではなく三角形にしてほしいということでした。具体的にはそれぞれの頂点にＣＭＹを配置し、頂点から対角辺に向けて濃度１００％から０％のグラデーションを描き、それぞれが重なっている部分の混色をシミュレートするというイメージです。

![CMYClrSpctrm.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1111613/97c0c71a-bbe6-ec8d-95d7-034b8bb5620c.jpeg)
01：ＣＭＹの円形グラデーションを重ねた際のイメージ

### タップした部分の各色調合割合を表示
ブラウザベースのため、クリックに変更しています。

### 問題点など
イメージのような表現方法の問題点は、グラデーションの開始ポイントを変動させない限り、掛け合わせの濃度比率が限定され、**シミュレーションから漏れる色が発生する**[^3] ということです。具体例ではそれぞれ濃度１００％同士の掛け合わせ、つまり**黒が表示されません**。

[^3]: 濃度そのものの合計は１００％となります。全体の濃度を変更しても、比率は一定のため、濃度合計が１００％を超える混色結果は表示できません。

### プロトタイプの仕様まとめ
問題点や要望を踏まえ、要件定義を行ない、ＵＩ及びＵＸに関するプロトタイプの仕様をまとめました。以下は関連部分の抜粋です。

1. タイル状のカラーブロックを濃度１０％刻み固定でピラミッド状に並べる（カラーテーブル）
2. 問題点の補完用として直線状に並べたカラーブロックを別に用意する
3. スライダーにて各色個別の割合調整、全体の濃度調整を行う
4. カラーブロックをクリックすることで当該ブロックの調合割合を表示する

上記、１が[「指定した段数でピラミッド状の要素リストを自動生成する」](https://qiita.com/STSHISHO/items/3611b16186ddeff958e8)、２、３が[「CMYの減法混色をmix-blend-modeを使ってシミュレートするためのベースロジック」](https://qiita.com/STSHISHO/items/cf6e1ba25cabf40cd0c0)に該当します。

そして以下が完成後のＵＩキャプチャです。
![スクリーンショット 2021-03-09 121930.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1111613/4654c28a-9a22-64bb-8871-d4bdf6b777df.png)

## ロジック
### 1-カラーブロックの集合体、カラーテーブル生成
ベースのロジックは[以前の記事](https://qiita.com/STSHISHO/items/3611b16186ddeff958e8)を参照いただければと思いますが、今回は自動生成部分をアプリ用にクラスにしたもの[^4] をそのまま使用しています。簡単に説明すると、カラーブロックを直線状に並べるクラスと、それを拡張してピラミッド状に並べるクラスになります。

上記のインスタンスはＣＭＹ各色ブロック、クリックイベントを設定するためのブロック、背景用の白のブロックと、合計５層のカラーテーブルで構成されます。

[^4]: クライアント様の要望もあり、非公開となります。

### 2-mix-blend-modeで減法混色をシミュレート
上記の内、ＣＭＹ各色カラーテーブルの`opacity`を増減させて濃度をコントロールし、`mix-blend-mode`により減法混色をシミュレートします。[こちら](https://qiita.com/STSHISHO/items/cf6e1ba25cabf40cd0c0)でも述べています通り、演算の方がロジック面、リソース面でもよりスマートです。仕様を満たす演算方法が思い浮かばず、力技のようになってしまいましたが、表現方法としては面白いと思っています。（元々ＤＴＰ屋であったこともありますが、クライアント様にとっても感覚的に理解しやすかったようです。）

ただ、この場合、各色の`opacity`が０になった場合、背景と同化してしまう不具合があったため、濃度コントロール下にない背景が白のカラーテーブルを追加しました。

### 3-全体の濃度と個別の濃度調整
当初、全体の濃度調整はカラーテーブル全体を包括するDIV要素のOpacityをコントロールするようにしていましたが、先述の問題もあり、ＣＭＹ各色のOpacityをコントロールするように変更しています。

### 4-調合割合の計算
最前面に無色のカラーテーブルを配置し、各ブロックに対しイベントリスナーを設定しています。各ブロックにはDATA属性にてＣＭＹの比率がセットされているので、現在の濃度を参照し調合割合を計算[^5] しています。

[^5]: 実案件では各色ごとの計算式がありましたが、サンプルのため単純化しました。

## 最後に
動作デモ用のアニメーションとコードの一部を公開します。

![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1111613/edb3b4db-0e06-b32a-bdf9-32aaed91dde2.gif)

```HTML: Sample01.html
<div class="sampler_container">
  <div class="sampler_sub_container">
    <!-- サンプラー０１ -->
    <div id="sampler01" class="sampler isolate">
      <!-- サンプラー挿入位置 -->
      <ul id="sampler01_bg" class="multiply">
        <li class="spectrumRow">
          <span data-cmy="0,0,100"></span>
        </li>

        <!-- 省略 （合計１１段） -->

      </ul>

      <!-- 省略  （合計５層）-->
    </div>
    <!-- サンプラー０２ -->
    <div id="sampler02" class="sampler isolate">
      <!-- サンプラー挿入位置 -->
      <ul id="sampler02_bg" class="multiply">
        <li class="spectrumRow">
          <span data-cmy="0,0,0"></span>
        </li>

        <!-- 省略 （合計１１段） -->

      </ul>

      <!-- 省略  （合計５層）-->
    </div>

    <!-- 濃度調整用スライダー -->
    <div class="slider">
      <ul id="sampler_slider">
        <li class="sliderRow">
          <span class="rowHead">濃度</span><input type="range" id="sampler_slider_all" min="0" max="100" step="1" value="100"><span class="rowTail">100%</span>
        </li>
        <li class="sliderRow">
          <span class="rowHead">Ｃ</span><input type="range" id="sampler_slider_c" min="0" max="100" step="1" value="100"><span class="rowTail">100%</span>
        </li>
        <li class="sliderRow">
          <span class="rowHead">Ｍ</span><input type="range" id="sampler_slider_m" min="0" max="100" step="1" value="100"><span class="rowTail">100%</span>
        </li>
        <li class="sliderRow">
          <span class="rowHead">Ｙ</span><input type="range" id="sampler_slider_y" min="0" max="100" step="1" value="100"><span class="rowTail">100%</span>
      </ul>
    </div>
  </div>
</div>

<!-- 調合割合表示 -->
<div id="answer_container"><p></p></div>
```

```Javascript: Sample02.js
/* 初期設定省略 */
//CMYのRGB値設定
const Cyan = {R:0,   G:191, B:255}
const Mgnt = {R:242, G:0,   B:230}
const Yllw = {R:255, G:229, B:0}
const cmy  = [0,1,2] // CMYの表示位置設定

window.onload = () => {
  const sampler = document.querySelectorAll('div[id^=sampler]')

  /* クラスインスタンスを配列にセットしsampler直下にカラーブロックを生成 */
  const ARRINS  = [
    new st3ColorsSpectrum({C:Cyan, M:Mgnt, Y:Yllw, step:10, pos:cmy}),
    new st3Colors({C:Cyan, M:Mgnt, Y:Yllw, step:10, deg:3})
  ]
  ARRINS.forEach((INS, i) => {
    INS.init() // インスタンスの初期化
    INS.createUl(sampler[i]) //カラーブロックテンプレート生成
    INS.createChart(sampler[i]) // CMYブロックの生成と表示
  })

  /* 最前面の空ブロックにイベントリスナー設定 */
  document.querySelectorAll('ul.multiply').forEach(ul => {
    let span = ul.querySelectorAll('span')
    span.forEach(elm => {elm.addEventListener('click', calcCMY)})
  })

  /* スライダーにイベントリスナー設定 */
  const sliders = document.querySelectorAll('input[id*="slider"]')
  for(let slider of sliders){
    slider.addEventListener('input', chngColorConc, false)
  }
}

/* 関数設定
------------------------------------------------------------- */
// クリックしたブロックの色成分を計算
const calcCMY = e => {
  const elm  = e.target
  const CMY  = elm.dataset.cmy.split(',')
  const ids  = [`input[id$=_c]`, `input[id$=_m]`, `input[id$=_y]`]
  const conc = document.querySelector(`input[id$=_all]`).value * 0.01
  const colr = []

  ids.forEach((id, i) => {
    let num = document.querySelector(id).value * (CMY[i] * 0.01) * conc
    colr.push(Math.round((num*10)/10))
  })
  document.querySelector('#answer_container>p').innerText = `Ｃ:${colr[0]}％ Ｍ:${colr[1]}％ Ｙ:${colr[2]}％`
}

// スライダ連動によるターゲット濃度リアルタイム変更と数値表示
const chngColorConc = (e) => {
  const id   = e.target.id
  const arr  = id.split('_')
  let   conc = document.querySelectorAll(`input[type=range]`)

  //濃度表示のリアルタイム変更
  document.querySelector(`#${id}+.rowTail`).innerText = `${e.target.value}%`

  //カラーテーブルの濃度変更
  if(arr[2] === 'all') {
    for(let i = 1; i <= 3; i++){
      let id = ('00'+(cmy[i-1]+1)).slice(-2)
      document.querySelectorAll(`ul[id$=_${id}]`)
      .forEach(el => el.style.opacity = conc[i].value * e.target.value * 0.0001)
    }
  }
  else {
    const colr = arr[2] === 'c'? cmy[0]: arr[2] === 'm'? cmy[1]: cmy[2]
    document.querySelectorAll(`ul[id$=_${('00'+(colr+1)).slice(-2)}]`)
    .forEach(el => el.style.opacity = e.target.value * conc[0].value * 0.0001)
  }
}
```

### 余談
ピラミッド状のカラーサンプラーでは、各頂点に配置する色の指定ができるようになっています。また、直線状のカラーサンプラーでは、縦横表示の他、グラデーションの方向も指定できるようになっています。