ReactでClip Studio PaintやSAIのようなカラーピッカー

SAIやクリップスタジオペイントなどの和製ペイントソフトにありがちなカラーピッカーが直感的で好きなんですが、いかがでしょうか。
Win/Chromeで動作確認しています。
技術要素的にはIE以外では動きそう。
React初心者です。

![color-picker.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126428/86175a34-6297-7b34-ca7b-67eadcf76218.png)

```tsx:adialColorPicker.tsx
import React, { useEffect, useRef, useState } from 'react';
import './RadialColorPicker.scss';

function rgbToHsv(rgb) {
  let { r, g, b } = rgb;

  r /= 255;
  g /= 255;
  b /= 255;

  const max = Math.max(r, g, b),
    min = Math.min(r, g, b);
  let h,
    s,
    v = max;

  const d = max - min;
  s = max == 0 ? 0 : d / max;

  if (max == min) {
    h = 0; // achromatic
  } else {
    switch (max) {
      case r:
        h = (g - b) / d + (g < b ? 6 : 0);
        break;
      case g:
        h = (b - r) / d + 2;
        break;
      case b:
        h = (r - g) / d + 4;
        break;
    }

    h /= 6;
  }

  return { h, s, v };
}

function hsvToRgb(hsv) {
  let { h, s, v } = hsv;
  let r, g, b;

  const i = Math.floor(h * 6);
  const f = h * 6 - i;
  const p = v * (1 - s);
  const q = v * (1 - f * s);
  const 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;
  }

  r *= 255;
  g *= 255;
  b *= 255;

  return { r, g, b };
}

function sanitize(val) {
  return Math.min(1, Math.max(0, val));
}

function sanitizeHue(val) {
  return val - Math.floor(val);
}

const colorPattern = /^#[0-9a-fA-F]{6}$/;

const RadialColorPicker: React.FC<{ value: string; onChange: (hash: string) => void }> = ({
  value,
  onChange
}) => {
  const [hsv, setHsv] = useState({ h: 0, s: 0, v: 100 });
  const [hash, setHash] = useState(`#ffffff`);

  useEffect(() => {
    if (value !== hash && colorPattern.test(value)) {
      const hex = value?.replace('#', '');
      const hsv = rgbToHsv({
        r: parseInt(hex.substring(0, 2), 16),
        g: parseInt(hex.substring(2, 4), 16),
        b: parseInt(hex.substring(4, 6), 16)
      });
      setHsv(hsv);
    }
  }, [value]);

  const [hueMouseDown, setHueMouseDown] = useState(false);
  const [satValMouseDown, setSatValMouseDown] = useState(false);

  const circleRef = useRef<HTMLDivElement>();
  const rectRef = useRef<HTMLDivElement>();

  useEffect(() => {
    const handleDocumentMouseUp = () => {
      setHueMouseDown(false);
      setSatValMouseDown(false);
    };

    const handleDocumentMouseMove = (event) => {
      if (event.changedTouches) {
        if (hueMouseDown) onClickHue(event.changedTouches[0]);
        else if (satValMouseDown) onClickValSat(event.changedTouches[0]);
      } else {
        if (hueMouseDown) onClickHue(event);
        else if (satValMouseDown) onClickValSat(event);
      }
    };

    document.addEventListener('mouseup', handleDocumentMouseUp);
    document.addEventListener('touchend', handleDocumentMouseUp);
    document.addEventListener('touchcancel', handleDocumentMouseUp);
    document.addEventListener('mousemove', handleDocumentMouseMove);
    document.addEventListener('touchmove', handleDocumentMouseMove);
    return () => {
      document.removeEventListener('mouseup', handleDocumentMouseUp);
      document.removeEventListener('touchend', handleDocumentMouseUp);
      document.removeEventListener('touchcancel', handleDocumentMouseUp);
      document.removeEventListener('mousemove', handleDocumentMouseMove);
      document.removeEventListener('touchmove', handleDocumentMouseMove);
    };
  }, [hueMouseDown, satValMouseDown]);

  useEffect(() => {
    const { r, g, b } = hsvToRgb(hsv);
    setHash(
      '#' +
        Math.round(r).toString(16).padStart(2, '0') +
        Math.round(g).toString(16).padStart(2, '0') +
        Math.round(b).toString(16).padStart(2, '0')
    );
  }, [hsv]);

  useEffect(() => {
    onChange(hash);
  }, [hash]);

  const onClickHue = (ev: { clientX: number; clientY: number }) => {
    const rect = circleRef.current.getBoundingClientRect();
    setHsv({
      h: sanitizeHue(
        Math.atan2(
          -(ev.clientX - rect.left - rect.width / 2),
          ev.clientY - rect.top - rect.height / 2
        ) /
          (Math.PI * 2) +
          0.625
      ),
      s: hsv.s,
      v: hsv.v
    });
  };

  const onClickValSat = (ev: { clientX: number; clientY: number }) => {
    const rect = rectRef.current.getBoundingClientRect();
    setHsv({
      h: hsv.h,
      s: sanitize((ev.clientX - rect.left) / rect.width),
      v: sanitize(1 - (ev.clientY - rect.top) / rect.height)
    });
  };

  const cursorSize = 16;
  const size = 200;

  return (
    <div
      className="wrapper"
      style={
        {
          '--size': size + 'px',
          '--cursor-size': cursorSize + 'px'
        } as React.CSSProperties
      }>
      <div
        className="hue-circle"
        onClick={onClickHue}
        onMouseDown={() => setHueMouseDown(true)}
        onTouchStart={() => setHueMouseDown(true)}
        ref={circleRef}>
        <div
          className="hue-cursor"
          style={{
            top: ((size - cursorSize) / 2) * (1 - Math.sin((hsv.h + 0.125) * Math.PI * 2)) + 'px',
            left: ((size - cursorSize) / 2) * (1 - Math.cos((hsv.h + 0.125) * Math.PI * 2)) + 'px'
          }}
        />
      </div>
      <div
        className="val-sat-rect"
        onClick={onClickValSat}
        onMouseDown={() => setSatValMouseDown(true)}
        onTouchStart={() => setSatValMouseDown(true)}
        ref={rectRef}
        style={
          {
            '--hue': Math.round(hsv.h * 360)
          } as React.CSSProperties
        }>
        <div
          className="val-sat-cursor"
          style={{
            top: (size / Math.sqrt(2) - cursorSize * 2) * (1 - hsv.v) - cursorSize / 2 + 'px',
            left: (size / Math.sqrt(2) - cursorSize * 2) * hsv.s - cursorSize / 2 + 'px'
          }}
        />
      </div>
    </div>
  );
};

export default RadialColorPicker;
```

```scss:RadialColorPicker.scss
.wrapper {
  $sqrt2: 1.414;

  position: relative;
  width: var(--size);
  background: #eee;
  touch-action: none;

  .hue-cursor,
  .val-sat-cursor {
    position: absolute;
    border: 3px solid white;
    width: var(--cursor-size);
    height: var(--cursor-size);
    border-radius: 100%;
    z-index: 2;
  }

  .hue-circle {
    position: relative;
    width: 100%;
    border-radius: 100%;
    mask-image: radial-gradient(
      transparent calc(100% / $sqrt2 - var(--cursor-size)),
      white calc(100% / $sqrt2 - var(--cursor-size))
    );

    &::before {
      content: '';
      display: block;
      width: 100%;
      padding-top: 100%;
      overflow: hidden;
      background: conic-gradient(
        hsl(45, 100%, 50%),
        hsl(90, 100%, 50%),
        hsl(135, 100%, 50%),
        hsl(180, 100%, 50%),
        hsl(225, 100%, 50%),
        hsl(270, 100%, 50%),
        hsl(315, 100%, 50%),
        hsl(360, 100%, 50%),
        hsl(45, 100%, 50%)
      );
    }
  }

  .val-sat-rect {
    position: absolute;
    width: calc(100% / $sqrt2 - var(--cursor-size) * 2);
    height: calc(100% / $sqrt2 - var(--cursor-size) * 2);
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background-blend-mode: multiply;
    background: linear-gradient(to right, hsl(var(--hue), 0%, 100%), hsl(var(--hue), 100%, 50%)),
      linear-gradient(to bottom, hsl(var(--hue), 0%, 100%), hsl(var(--hue), 0%, 0%)), white;
    z-index: 1;
  }
}
```