HSL Color Wheel – Joystick + Bezel (joyCoef +/-)

by DOK_UA

HTML

<div class="card">
    <div class="row">
      <div class="readout" id="readout">H:0°  S:50%  L:50%</div>
      <div class="readout" id="accuracy">50%</div>
      <div class="row" style="gap:1px">
        <div class="swatch" id="oldSwatch" style="background:#999"></div>
        <div class="swatch" id="newSwatch"></div>
      </div>
    </div>

    <!-- Статичні сектори. Обертається лише сірий ринг (HUE). -->
    <svg id="ui" viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg" aria-label="Color wheel with joystick">
      <g id="segments" class="wheel-shadow">
        <defs>
          <path id="sector" d="M50,50 L50,2 A48,48 0 0 1 74,8 Z"/>
        </defs>
        <g>
          <use href="#sector" fill="hsl(0, 100%, 50%)"    transform="rotate(-15,50,50)"/>
          <use href="#sector" fill="hsl(30, 100%, 50%)"   transform="rotate(15,50,50)"/>
          <use href="#sector" fill="hsl(60, 100%, 50%)"   transform="rotate(45,50,50)"/>
          <use href="#sector" fill="hsl(90, 100%, 50%)"   transform="rotate(75,50,50)"/>
          <use href="#sector" fill="hsl(120, 100%, 50%)"  transform="rotate(105,50,50)"/>
          <use href="#sector" fill="hsl(150, 100%, 50%)"  transform="rotate(135,50,50)"/>
          <use href="#sector" fill="hsl(180, 100%, 50%)"  transform="rotate(165,50,50)"/>
          <use href="#sector" fill="hsl(210, 100%, 50%)"  transform="rotate(195,50,50)"/>
          <use href="#sector" fill="hsl(240, 100%, 50%)"  transform="rotate(225,50,50)"/>
          <use href="#sector" fill="hsl(270, 100%, 50%)"  transform="rotate(255,50,50)"/>
          <use href="#sector" fill="hsl(300, 100%, 50%)"  transform="rotate(285,50,50)"/>
          <use href="#sector" fill="hsl(330, 100%, 50%)"  transform="rotate(315,50,50)"/>
        </g>
        <circle cx="50" cy="50" r="30" fill="#2a2a2a"/>
      </g>

      <!-- Ринг (обертається за H) + сірий безель зі смужками -->
      <g id="ring" class="handle">
        <defs>
      ...

CSS

:root{
    --bg:#1f1f1f; --panel:#2a2a2a; --text:#eaeaea;
    --shadow: 0 10px 30px rgba(0,0,0,.35);
  }
  *{box-sizing:border-box}
  html,body{height:100%}
  body{
    margin:0; display:grid; place-items:center; background:var(--bg); color:var(--text);
    font:13px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
  }
  .card{
    background:var(--panel); padding:18px; border-radius:14px; box-shadow:var(--shadow);
    display:grid; gap:12px; width:min(380px, 92vw);
  }
  .row{display:flex; align-items:center; justify-content:space-between; gap:10px}
  .swatch{inline-size:52px; block-size:24px; border-radius:6px; border:1px solid #0008}
  .readout{opacity:.9}
  svg{width:100%; height:auto; display:block}
  .wheel-shadow { filter: drop-shadow(0 6px 16px rgba(0,0,0,.35)); }
  .joy-shadow   { filter: drop-shadow(0 6px 10px rgba(0,0,0,.35)); }
  .handle{ cursor: grab; touch-action: none; }
  #ui{ touch-action: none; }
  .handle:active{ cursor: grabbing; }

JavaScript

/* ======== STATE ======== */
let H = 0, S = 50, L = 50;

const readout   = document.getElementById('readout');
const accView   = document.getElementById('accuracy');
const newSwatch = document.getElementById('newSwatch');
const oldSwatch = document.getElementById('oldSwatch');

const ring      = document.getElementById('ring');
const segments  = document.getElementById('segments');

const joy       = document.getElementById('joy');
const joyUnder  = document.getElementById('joyUnder');
const newDot    = document.getElementById('newColor');
const svgRoot   = document.getElementById('ui');

const plusAccBtn  = document.getElementById('plusAccuracy');
const minusAccBtn = document.getElementById('minusAccuracy');

const center = { x: 50, y: 50 };
const joyMaxR   = 24;   // межа руху джойстика
const hueSnap   = 3;

/* ======== ЧУТЛИВІСТЬ ДЖОЙСТИКА ======== */
let joyCoef = 0.5;         // старт: 50% (повільніше, щоб збігалось з #accuracy)
const baseS = 50, baseL = 30;

function updateAccuracyView(){
  accView.textContent = `${Math.round(joyCoef * 100)}%`;
}
function clampNum(v, mn, mx){ return Math.min(mx, Math.max(mn, v)); }

/* ======== HELPERS ======== */
const clamp = (v, mn, mx)=> Math.min(mx, Math.max(mn, v));
const norm360 = (a)=> (a % 360 + 360) % 360;
const hsl   = ()=> `hsl(${Math.round(H)}, ${Math.round(S)}%, ${Math.round(L)}%)`;
function setReadout(){ readout.textContent = `H:${Math.round(H)}°  S:${Math.round(S)}%  L:${Math.round(L)}%`; }

/* правильне перетворення в координати viewBox (100x120) */
function svgPoint(evt, svgEl){
  const r  = svgEl.getBoundingClientRect();
  const vb = svgEl.viewBox.baseVal;
  const x = ((evt.clientX - r.left) / r.width ) * vb.width  + vb.x;
  const y = ((evt.clientY - r.top ) / r.height) * vb.height + vb.y;
  return { x, y };
}
function mouseAngleDeg(evt, svgEl){
  const p = svgPoint(evt, svgEl);
  const x = p.x - center.x;
  const y = p.y - center.y;
  let deg =...