1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HEX・RGB・HSLを相互変換するカラーピッカーを作る——色空間変換の数式と「stateは1つだけ」設計

1
Posted at

Figma で作った色を CSS に持っていくとき、HEX で欲しいのか rgb() なのか hsl() なのかは場面による。3形式を相互変換できるカラーピッカーは定番ツールだが、自分で作ると「色空間変換の数式」と「3つの入力をどう同期させるか」という2つの地味な課題が出てくる。

HEX・RGB・HSL を相互変換しつつスライダーで色を作れるツールをぱんだツールズの1機能として作った。ライブラリは使わず、変換は全部自前。

この記事では、RGB↔HSL の変換数式と、3形式の入力を破綻なく同期させる「state を1つに絞る」設計を解説する。

HEX ↔ RGB は16進数の読み書きだけ

一番簡単なのが HEX と RGB の相互変換。HEX(#RRGGBB)は各2桁が R/G/B の16進数なので、切り出して parseInt(_, 16) するだけ。

function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
  const m = hex.replace('#', '').match(/^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i)
  return m ? { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) } : null
}

function rgbToHex(r: number, g: number, b: number): string {
  return '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('')
}

hexToRgb は正規表現でちゃんと6桁16進かを検証し、ダメなら null を返す(入力途中の不正な文字列をはじくため)。#fff のような3桁ショートハンドは対象外で、6桁の #RRGGBB のみ受け付ける割り切り。rgbToHex で忘れがちなのが padStart(2, '0')。0 を toString(16) すると "0" の1桁になるので、2桁に詰めないと #000 のように桁が崩れる。

RGB → HSL:最大・最小から色相を求める

HSL(色相・彩度・明度)への変換はもう少し数式が要る。まず RGB を 0〜1 に正規化し、最大値と最小値を取る。明度 L は単純に最大と最小の中点。

function rgbToHsl(r: number, g: number, b: number) {
  const rn = r / 255, gn = g / 255, bn = b / 255
  const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn)
  const l = (max + min) / 2

  // 無彩色(R=G=B)は色相・彩度を定義できないので 0 にする
  if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) }

  const d = max - min
  // 彩度 S は明度が 0.5 を境に分母が変わる
  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)

  // 色相 H は「どのチャンネルが最大か」で式を切り替える
  let h = 0
  if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6
  else if (max === gn) h = ((bn - rn) / d + 2) / 6
  else h = ((rn - gn) / d + 4) / 6

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

ポイントが3つある。

無彩色の特別扱い。 max === min(R=G=B、つまりグレー)のときは色相も彩度も意味を持たない(d が0で割り算が壊れる)ので、h=0, s=0 で早期 return する。

彩度の分母が明度で変わる。 同じ「鮮やかさ」でも、明るい色と暗い色では計算式の分母が違う。l > 0.5 で 2 - max - min、それ以外で max + min を使う。HSL の定義そのもの。

色相は最大チャンネルで場合分け。 赤が最大なら緑と青の差、緑が最大なら青と赤の差、という具合に、どの原色が支配的かで基準が変わる。赤が最大かつ緑<青のときだけ +6 するのは、色相環をぐるっと回って負にならないようにするため。

HSL → RGB:色相環を3分割する hue2rgb

逆方向はもっとトリッキー。色相(角度)から RGB を復元するには、補助関数 hue2rgb で「色相環のどの位置にいるか」を区間ごとに線形補間する。

function hslToRgb(h: number, s: number, l: number) {
  const hn = h / 360, sn = s / 100, ln = l / 100

  // 彩度0は完全な無彩色。L をそのまま 3チャンネルに
  if (sn === 0) {
    const v = Math.round(ln * 255)
    return { r: v, g: v, b: v }
  }

  const q = ln < 0.5 ? ln * (1 + sn) : ln + sn - ln * sn
  const p = 2 * ln - q
  const hue2rgb = (t: number) => {
    if (t < 0) t += 1; if (t > 1) t -= 1     // 色相環なので 0〜1 に巻き戻す
    if (t < 1 / 6) return p + (q - p) * 6 * t
    if (t < 1 / 2) return q
    if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
    return p
  }
  return {
    r: Math.round(hue2rgb(hn + 1 / 3) * 255),   // R は色相を +1/3 ずらす
    g: Math.round(hue2rgb(hn) * 255),
    b: Math.round(hue2rgb(hn - 1 / 3) * 255),   // B は -1/3
  }
}

p と q は明度・彩度から決まる下限・上限の値で、hue2rgb がその間を色相位置に応じて補間する。R/G/B はそれぞれ色相環上で 120 度(= 1/3)ずつずれた位置をサンプリングするので、hn に +1/3・0・-1/3 を与えている。t が範囲外になったら +1/-1 で巻き戻すのは、色相が円環状(360度で一周)だから。

このあたりは HSL の標準的な変換アルゴリズムそのままで、CSS の hsl() と同じ結果になる。

設計の肝:state は HEX 1つだけ、RGB と HSL は「導出」する

数式以上に効くのが、3形式をどう同期させるかという設計。HEX・RGB・HSL それぞれを独立した state にすると、片方を変えたら他2つを更新する処理が3×2方向で必要になり、丸め誤差や更新漏れですぐ食い違う。

このツールは state を hex 1つだけにした。RGB と HSL は毎レンダリングで hex から導出する。

const [hex, setHex] = useState('#3b82f6')

// RGB・HSL は state ではなく hex からの「導出値」
const rgb = hexToRgb(hex) ?? { r: 59, g: 130, b: 246 }
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b)

入力がどの形式であっても、最終的に hex を更新するだけ。RGB スライダーをいじったら RGB→HEX に変換してから setHex、HSL スライダーなら HSL→RGB→HEX と変換してから setHex する。

function updateFromRgb(key: 'r' | 'g' | 'b', raw: string) {
  const v = clamp(parseInt(raw) || 0, 0, 255)
  const next = { ...rgb, [key]: v }
  setHex(rgbToHex(next.r, next.g, next.b))   // 結局 hex を更新するだけ
}

function updateFromHsl(key: 'h' | 's' | 'l', raw: string) {
  const max = key === 'h' ? 360 : 100
  const v = clamp(parseInt(raw) || 0, 0, max)
  const next = { ...hsl, [key]: v }
  const nextRgb = hslToRgb(next.h, next.s, next.l)
  setHex(rgbToHex(nextRgb.r, nextRgb.g, nextRgb.b))   // HSL→RGB→HEX で hex に集約
}

これが単一の真実源(single source of truth)。表示される RGB・HSL は常に hex から再計算されるので、3形式が食い違う余地がそもそもない。「複数の state を相互に同期させる」という最もバグりやすいパターンを、「state を1つに絞って残りは導出」に置き換えることで消している。

副作用として、HSL は丸め(Math.round)を挟むので、HSL スライダーを動かして戻すと元の値とわずかにズレることがある。ただし HEX を真実源にしている分、表示の一貫性は崩れない。色の同期 UI では「全部 state にしない」が効くという好例。

まとめ

カラーピッカーは「変換数式」と「state 設計」の2本立てだった。

  • HEX↔RGB は16進の読み書き。rgbToHex は padStart(2, '0') を忘れると桁が崩れる
  • RGB→HSL は正規化して max/min から L を出し、無彩色を特別扱い、彩度の分母は L で切り替え、色相は最大チャンネルで場合分け
  • HSL→RGB は p/q と hue2rgb で色相環を区間補間。R/G/B は ±1/3 ずらしてサンプリング
  • state は hex 1つだけ、RGB/HSL は毎回導出。単一の真実源にすれば3形式の同期バグが原理的に起きない

数式は標準アルゴリズムを写すだけだが、「3つの値を同期させない(1つから導出する)」という設計判断の方が、実は再利用の効くテクニックだった。

ぱんだツールズ では他にも PDF・画像・CSV・テキスト処理など、開発者向けのツールを多数公開している。全部無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com


この記事は Zenn にも同じ内容を投稿しています。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?