はじめに
以前に書いた記事のネタ元になったアプリの話です。機密情報に当たる部分もございますので、詳細は伏せますが、ある特殊染料を扱うクライアント様からのご相談で、染料調合割合を計算するためのカラーサンプラーアプリを制作した時の話です。1 クライアント様の了承を得て、デモ用に作成したプロトタイプ(ブラウザベース)2 をQiita用に改修したものを少しだけ公開します。
仕様について
タブレットでの使用を前提とし、その他の要望は概ね以下の通りでした。
- CMYそれぞれの濃度100%時の色成分初期値(RGB)を変更したい
- 3色間の調合割合ごとの混色結果を一括で確認したい
- タップした部分の各色調合割合を表示したい
濃度100%時の色成分初期値
画面上の色と実物の色との誤差は了解の上で実際の見た目に近づけたい、という要望もあったため、アプリでは設定画面で各色の濃度100%時の初期値を設定できるようにしました。本記事のコードでは、設定部分のUIは用意せず、コード中で指定するようにしています。
//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}
3色間の調合割合ごとの混色結果を一括で確認
カラーピッカーのようなグラデーションモデルをイメージされていたようで、四角形ではなく三角形にしてほしいということでした。具体的にはそれぞれの頂点にCMYを配置し、頂点から対角辺に向けて濃度100%から0%のグラデーションを描き、それぞれが重なっている部分の混色をシミュレートするというイメージです。
タップした部分の各色調合割合を表示
ブラウザベースのため、クリックに変更しています。
問題点など
イメージのような表現方法の問題点は、グラデーションの開始ポイントを変動させない限り、掛け合わせの濃度比率が限定され、シミュレーションから漏れる色が発生する3 ということです。具体例ではそれぞれ濃度100%同士の掛け合わせ、つまり黒が表示されません。
プロトタイプの仕様まとめ
問題点や要望を踏まえ、要件定義を行ない、UI及びUXに関するプロトタイプの仕様をまとめました。以下は関連部分の抜粋です。
- タイル状のカラーブロックを濃度10%刻み固定でピラミッド状に並べる(カラーテーブル)
- 問題点の補完用として直線状に並べたカラーブロックを別に用意する
- スライダーにて各色個別の割合調整、全体の濃度調整を行う
- カラーブロックをクリックすることで当該ブロックの調合割合を表示する
上記、1が「指定した段数でピラミッド状の要素リストを自動生成する」、2、3が「CMYの減法混色をmix-blend-modeを使ってシミュレートするためのベースロジック」に該当します。
ロジック
1-カラーブロックの集合体、カラーテーブル生成
ベースのロジックは以前の記事を参照いただければと思いますが、今回は自動生成部分をアプリ用にクラスにしたもの4 をそのまま使用しています。簡単に説明すると、カラーブロックを直線状に並べるクラスと、それを拡張してピラミッド状に並べるクラスになります。
上記のインスタンスはCMY各色ブロック、クリックイベントを設定するためのブロック、背景用の白のブロックと、合計5層のカラーテーブルで構成されます。
2-mix-blend-modeで減法混色をシミュレート
上記の内、CMY各色カラーテーブルのopacity
を増減させて濃度をコントロールし、mix-blend-mode
により減法混色をシミュレートします。こちらでも述べています通り、演算の方がロジック面、リソース面でもよりスマートです。仕様を満たす演算方法が思い浮かばず、力技のようになってしまいましたが、表現方法としては面白いと思っています。(元々DTP屋であったこともありますが、クライアント様にとっても感覚的に理解しやすかったようです。)
ただ、この場合、各色のopacity
が0になった場合、背景と同化してしまう不具合があったため、濃度コントロール下にない背景が白のカラーテーブルを追加しました。
3-全体の濃度と個別の濃度調整
当初、全体の濃度調整はカラーテーブル全体を包括するDIV要素のOpacityをコントロールするようにしていましたが、先述の問題もあり、CMY各色のOpacityをコントロールするように変更しています。
4-調合割合の計算
最前面に無色のカラーテーブルを配置し、各ブロックに対しイベントリスナーを設定しています。各ブロックにはDATA属性にてCMYの比率がセットされているので、現在の濃度を参照し調合割合を計算5 しています。
最後に
動作デモ用のアニメーションとコードの一部を公開します。
<div class="sampler_container">
<div class="sampler_sub_container">
<!-- サンプラー01 -->
<div id="sampler01" class="sampler isolate">
<!-- サンプラー挿入位置 -->
<ul id="sampler01_bg" class="multiply">
<li class="spectrumRow">
<span data-cmy="0,0,100"></span>
</li>
<!-- 省略 (合計11段) -->
</ul>
<!-- 省略 (合計5層)-->
</div>
<!-- サンプラー02 -->
<div id="sampler02" class="sampler isolate">
<!-- サンプラー挿入位置 -->
<ul id="sampler02_bg" class="multiply">
<li class="spectrumRow">
<span data-cmy="0,0,0"></span>
</li>
<!-- 省略 (合計11段) -->
</ul>
<!-- 省略 (合計5層)-->
</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">C</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">M</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">Y</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>
/* 初期設定省略 */
//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 = `C:${colr[0]}% M:${colr[1]}% Y:${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)
}
}
余談
ピラミッド状のカラーサンプラーでは、各頂点に配置する色の指定ができるようになっています。また、直線状のカラーサンプラーでは、縦横表示の他、グラデーションの方向も指定できるようになっています。