| … | … | @@ -14,7 +14,9 @@ const STATIC_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), '. |
| 14 | 14 | const ASSETS = { |
| 15 | 15 | '/': ['index.html', 'text/html; charset=utf-8'], |
| 16 | 16 | '/app.js': ['app.js', 'text/javascript; charset=utf-8'], |
| 17 | '/app.css': ['app.css', 'text/css; charset=utf-8'] | |
| 17 | '/app.css': ['app.css', 'text/css; charset=utf-8'], | |
| 18 | '/color.js': ['color.js', 'text/javascript; charset=utf-8'], | |
| 19 | '/picker.js': ['picker.js', 'text/javascript; charset=utf-8'] | |
| 18 | 20 | } |
| 19 | 21 | const MAX_BODY = 64 * 1024 |
| 20 | 22 | const PING_MS = 15000 |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "cthuloid-web", |
| 3 | "version": "0.1.0", | |
| 3 | "version": "0.1.1", | |
| 4 | 4 | "description": "Web control for cthuloid ESP32 light units (esp324xlight) \u2014 discovers units over mDNS and drives them from a browser on the LAN.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -96,9 +96,28 @@ main { padding: 0 16px 2rem; max-width: 980px; margin: 0 auto; } |
| 96 | 96 | .where { margin: .15rem 0 .5rem; } |
| 97 | 97 | .note { margin: 0 0 .5rem; color: var(--warn); } |
| 98 | 98 | .slots { display: grid; gap: .4rem; } |
| 99 | .slot { display: grid; grid-template-columns: 3.2rem 2.6rem 1fr 2.8rem 1.6rem; gap: .5rem; align-items: center; } | |
| 100 | .slot input[type=color] { width: 2.6rem; height: 2rem; padding: 0; border: 1px solid var(--line); border-radius: 6px; background: none; } | |
| 101 | .slot input[type=range] { width: 100%; } | |
| 99 | .slot-row { display: grid; grid-template-columns: 3.2rem 2.6rem 1fr 2.8rem 1.6rem; gap: .5rem; align-items: center; } | |
| 100 | .swatch { width: 2.6rem; height: 2rem; padding: 0; border: 1px solid var(--line); border-radius: 6px; } | |
| 101 | .swatch.open { outline: 2px solid var(--accent); outline-offset: 1px; } | |
| 102 | .slot-row input[type=range] { width: 100%; } | |
| 103 | ||
| 104 | /* Blender-style picker: wheel + value bar, RGB / HSV / Hex fields. */ | |
| 105 | .picker { margin: .5rem 0 .3rem; padding: .7rem; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); } | |
| 106 | .pk-top { display: flex; gap: .8rem; align-items: stretch; justify-content: center; } | |
| 107 | /* touch-action: none, or a finger drag on the wheel scrolls the page instead. */ | |
| 108 | .pk-wheel, .pk-vbar { touch-action: none; cursor: crosshair; } | |
| 109 | .pk-wheel { border-radius: 50%; } | |
| 110 | .pk-vbar { position: relative; width: 22px; border-radius: 6px; border: 1px solid var(--line); } | |
| 111 | .pk-vhandle { position: absolute; left: -4px; right: -4px; height: 6px; margin-top: -3px; border: 2px solid #fff; outline: 1px solid #000; border-radius: 3px; pointer-events: none; } | |
| 112 | .pk-tabs { display: flex; gap: 0; margin: .7rem 0 .4rem; } | |
| 113 | .pk-tabs button { flex: 1; border-radius: 0; padding: .3rem; } | |
| 114 | .pk-tabs button:first-child { border-radius: 6px 0 0 6px; } | |
| 115 | .pk-tabs button:last-child { border-radius: 0 6px 6px 0; } | |
| 116 | .pk-tabs button.on { background: var(--accent); border-color: var(--accent); color: #fff; } | |
| 117 | .pk-fields { display: grid; gap: .3rem; } | |
| 118 | .pk-row { display: grid; grid-template-columns: 2.2rem 1fr 5rem; gap: .5rem; align-items: center; font-size: .85rem; } | |
| 119 | .pk-row input[type=number] { width: 100%; } | |
| 120 | .pk-row input[type=text] { grid-column: 2 / 4; font-family: ui-monospace, monospace; } | |
| 102 | 121 | .actual { width: 1.6rem; height: 1.6rem; border-radius: 50%; border: 1px solid var(--line); background: #000; } |
| 103 | 122 | .slots.empty { color: var(--muted); } |
| 104 | 123 | .unit-actions { margin-top: .6rem; } |
| … | … | @@ -1,6 +1,7 @@ |
| 1 | 1 | // Cthuloid Lights page. Server state arrives over /api/events (SSE) and is the |
| 2 | 2 | // only source of truth; controls send changes and wait to be told the result. |
| 3 | 'use strict' | |
| 3 | import { hsvToRgb, hsvToSlot, slotToHsv, toHex } from './color.js' | |
| 4 | import { Picker } from './picker.js' | |
| 4 | 5 | |
| 5 | 6 | const $ = (sel, el) => (el || document).querySelector(sel) |
| 6 | 7 | |
| … | … | @@ -55,13 +56,6 @@ function act (fn) { |
| 55 | 56 | |
| 56 | 57 | // ---- colour helpers --------------------------------------------------------- |
| 57 | 58 | |
| 58 | const hex2 = (v) => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, '0') | |
| 59 | const toHex = (s) => '#' + hex2(s.r) + hex2(s.g) + hex2(s.b) | |
| 60 | function fromHex (h) { | |
| 61 | const n = parseInt(h.slice(1), 16) | |
| 62 | return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255 } | |
| 63 | } | |
| 64 | ||
| 65 | 59 | // A unit's readback (BGR per slot, red capped) as a CSS colour. |
| 66 | 60 | function actualCss (values, i, redMax) { |
| 67 | 61 | const red = Math.min(redMax, 45000) || 1 |
| … | … | @@ -154,18 +148,41 @@ function makeCard (id) { |
| 154 | 148 | const base = '/api/units/' + encodeURIComponent(id) |
| 155 | 149 | const slots = [] |
| 156 | 150 | for (let i = 0; i < 4; i++) { |
| 157 | const row = $('#slot-tpl').content.firstElementChild.cloneNode(true) | |
| 158 | $('.slot-n', row).textContent = 'Slot ' + (i + 1) | |
| 159 | const color = $('.color', row) | |
| 160 | const level = $('.level', row) | |
| 151 | const el2 = $('#slot-tpl').content.firstElementChild.cloneNode(true) | |
| 152 | $('.slot-n', el2).textContent = 'Slot ' + (i + 1) | |
| 153 | // The slot as the user is editing it. V is the brightness: the picker's | |
| 154 | // value bar and the row's slider are the same control. | |
| 155 | const row = { el: el2, hsv: { h: 0, s: 0, v: 1 }, picker: null } | |
| 156 | const level = $('.level', el2) | |
| 157 | const panel = $('.slot-picker', el2) | |
| 158 | const swatch = $('.swatch', el2) | |
| 161 | 159 | const send = () => { |
| 162 | const c = fromHex(color.value) | |
| 163 | $('.pct', row).textContent = level.value + '%' | |
| 164 | sendLatest(id + ':' + i, base + '/slot', { index: i, r: c.r, g: c.g, b: c.b, level: Number(level.value) / 100 }) | |
| 160 | paintSlot(row) | |
| 161 | sendLatest(id + ':' + i, base + '/slot', { index: i, ...hsvToSlot(row.hsv.h, row.hsv.s, row.hsv.v) }) | |
| 162 | } | |
| 163 | level.addEventListener('input', () => { | |
| 164 | row.hsv = { ...row.hsv, v: Number(level.value) / 100 } | |
| 165 | if (row.picker) row.picker.set(row.hsv) | |
| 166 | send() | |
| 167 | }) | |
| 168 | swatch.onclick = () => { | |
| 169 | const opening = panel.hidden | |
| 170 | closePicker() | |
| 171 | if (!opening) return | |
| 172 | if (!row.picker) { | |
| 173 | row.picker = new Picker(panel, { | |
| 174 | onChange: (hsv) => { | |
| 175 | row.hsv = hsv | |
| 176 | send() | |
| 177 | } | |
| 178 | }) | |
| 179 | } | |
| 180 | row.picker.set(row.hsv) | |
| 181 | panel.hidden = false | |
| 182 | swatch.classList.add('open') | |
| 183 | openRow = row | |
| 165 | 184 | } |
| 166 | color.addEventListener('input', send) | |
| 167 | level.addEventListener('input', send) | |
| 168 | $('.slots', el).appendChild(row) | |
| 185 | $('.slots', el).appendChild(el2) | |
| 169 | 186 | slots.push(row) |
| 170 | 187 | } |
| 171 | 188 | |
| … | … | @@ -210,13 +227,16 @@ function updateCard (card, u, st) { |
| 210 | 227 | for (let i = 0; i < 4; i++) { |
| 211 | 228 | const row = card.slots[i] |
| 212 | 229 | const s = u.slots ? u.slots[i] : null |
| 213 | row.hidden = !u.slots | |
| 214 | if (s) { | |
| 215 | setValue($('.color', row), toHex(s)) | |
| 216 | setValue($('.level', row), Math.round(s.level * 100)) | |
| 217 | $('.pct', row).textContent = Math.round(s.level * 100) + '%' | |
| 230 | row.el.hidden = !u.slots | |
| 231 | // Skip the echo while the user is moving this slot's controls, or an | |
| 232 | // older value from the server would yank the handle back mid-drag. | |
| 233 | const busy = document.activeElement === $('.level', row.el) || (row.picker && row.picker.dragging) | |
| 234 | if (s && !busy) { | |
| 235 | row.hsv = slotToHsv(s, row.hsv.h) | |
| 236 | paintSlot(row) | |
| 237 | if (row.picker) row.picker.set(row.hsv) | |
| 218 | 238 | } |
| 219 | $('.actual', row).style.background = actual ? actualCss(actual, i, u.redMax) : 'transparent' | |
| 239 | $('.actual', row.el).style.background = actual ? actualCss(actual, i, u.redMax) : 'transparent' | |
| 220 | 240 | } |
| 221 | 241 | |
| 222 | 242 | setValue($('.fade', el), u.fadeMs) |
| … | … | @@ -231,6 +251,22 @@ function updateCard (card, u, st) { |
| 231 | 251 | $('.adopt', el).disabled = !u.actual |
| 232 | 252 | } |
| 233 | 253 | |
| 254 | // One picker open at a time, page-wide. | |
| 255 | let openRow = null | |
| 256 | function closePicker () { | |
| 257 | if (!openRow) return | |
| 258 | $('.slot-picker', openRow.el).hidden = true | |
| 259 | $('.swatch', openRow.el).classList.remove('open') | |
| 260 | openRow = null | |
| 261 | } | |
| 262 | ||
| 263 | function paintSlot (row) { | |
| 264 | const { h, s, v } = row.hsv | |
| 265 | $('.swatch', row.el).style.background = toHex(hsvToRgb(h, s, v)) | |
| 266 | setValue($('.level', row.el), Math.round(v * 100)) | |
| 267 | $('.pct', row.el).textContent = Math.round(v * 100) + '%' | |
| 268 | } | |
| 269 | ||
| 234 | 270 | // ---- wiring ------------------------------------------------------------------- |
| 235 | 271 | |
| 236 | 272 | $('#connect').onclick = act(() => post('/api/connect', { on: !(state && state.connected) })) |
| … | … | @@ -0,0 +1,65 @@ |
| 1 | // Colour maths for the page's Blender-style picker. A plain ES module so the | |
| 2 | // browser loads it and brittle-bare tests it (test/color.test.js). | |
| 3 | // | |
| 4 | // All values are 0..1. A slot is { r, g, b, level }: the colour at full drive, | |
| 5 | // normalised so its brightest channel is 1, and `level` the brightness. That | |
| 6 | // is exactly HSV with V = level, which is why the picker's value bar and the | |
| 7 | // row's brightness slider are the same control. | |
| 8 | ||
| 9 | export function hsvToRgb (h, s, v) { | |
| 10 | h = ((h % 1) + 1) % 1 | |
| 11 | const i = Math.floor(h * 6) | |
| 12 | const f = h * 6 - i | |
| 13 | const p = v * (1 - s) | |
| 14 | const q = v * (1 - f * s) | |
| 15 | const t = v * (1 - (1 - f) * s) | |
| 16 | switch (i % 6) { | |
| 17 | case 0: return { r: v, g: t, b: p } | |
| 18 | case 1: return { r: q, g: v, b: p } | |
| 19 | case 2: return { r: p, g: v, b: t } | |
| 20 | case 3: return { r: p, g: q, b: v } | |
| 21 | case 4: return { r: t, g: p, b: v } | |
| 22 | default: return { r: v, g: p, b: q } | |
| 23 | } | |
| 24 | } | |
| 25 | ||
| 26 | export function rgbToHsv (r, g, b) { | |
| 27 | const max = Math.max(r, g, b) | |
| 28 | const min = Math.min(r, g, b) | |
| 29 | const d = max - min | |
| 30 | let h = 0 | |
| 31 | if (d > 0) { | |
| 32 | if (max === r) h = ((g - b) / d) % 6 | |
| 33 | else if (max === g) h = (b - r) / d + 2 | |
| 34 | else h = (r - g) / d + 4 | |
| 35 | h /= 6 | |
| 36 | if (h < 0) h += 1 | |
| 37 | } | |
| 38 | return { h, s: max > 0 ? d / max : 0, v: max } | |
| 39 | } | |
| 40 | ||
| 41 | // Hue and saturation come from the slot's colour, V from its level -- so a | |
| 42 | // slot dimmed to 0 still knows its colour. `fallbackHue` is for a colourless | |
| 43 | // (grey or black) slot, where hue is undefined. | |
| 44 | export function slotToHsv (slot, fallbackHue = 0) { | |
| 45 | const { h, s } = rgbToHsv(slot.r, slot.g, slot.b) | |
| 46 | return { h: s > 0 ? h : fallbackHue, s, v: slot.level } | |
| 47 | } | |
| 48 | ||
| 49 | export function hsvToSlot (h, s, v) { | |
| 50 | const c = hsvToRgb(h, s, 1) | |
| 51 | return { r: c.r, g: c.g, b: c.b, level: v } | |
| 52 | } | |
| 53 | ||
| 54 | const hex2 = (x) => Math.round(Math.max(0, Math.min(1, x)) * 255).toString(16).padStart(2, '0') | |
| 55 | ||
| 56 | export function toHex (c) { | |
| 57 | return '#' + hex2(c.r) + hex2(c.g) + hex2(c.b) | |
| 58 | } | |
| 59 | ||
| 60 | export function fromHex (str) { | |
| 61 | const m = /^#?([0-9a-f]{6})$/i.exec(String(str).trim()) | |
| 62 | if (!m) return null | |
| 63 | const n = parseInt(m[1], 16) | |
| 64 | return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255 } | |
| 65 | } |
| … | … | @@ -75,14 +75,17 @@ |
| 75 | 75 | |
| 76 | 76 | <template id="slot-tpl"> |
| 77 | 77 | <div class="slot"> |
| 78 | <span class="slot-n"></span> | |
| 79 | <input type="color" class="color" aria-label="Colour"> | |
| 80 | <input type="range" class="level" min="0" max="100" step="1" aria-label="Brightness"> | |
| 81 | <span class="pct small"></span> | |
| 82 | <span class="actual" title="What the unit is actually showing"></span> | |
| 78 | <div class="slot-row"> | |
| 79 | <span class="slot-n"></span> | |
| 80 | <button type="button" class="swatch" title="Edit colour" aria-label="Edit colour"></button> | |
| 81 | <input type="range" class="level" min="0" max="100" step="1" aria-label="Brightness"> | |
| 82 | <span class="pct small"></span> | |
| 83 | <span class="actual" title="What the unit is actually showing"></span> | |
| 84 | </div> | |
| 85 | <div class="slot-picker" hidden></div> | |
| 83 | 86 | </div> |
| 84 | 87 | </template> |
| 85 | 88 | |
| 86 | <script src="/app.js"></script> | |
| 89 | <script type="module" src="/app.js"></script> | |
| 87 | 90 | </body> |
| 88 | 91 | </html> |
| … | … | @@ -0,0 +1,237 @@ |
| 1 | // A Blender-style colour picker: hue/saturation wheel, a value bar beside it, | |
| 2 | // and RGB / HSV / Hex fields underneath. Pointer events, so mouse and touch | |
| 3 | // both drag. Values are 0..1 throughout, as in Blender. | |
| 4 | // | |
| 5 | // const p = new Picker(el, { onChange: ({ h, s, v }) => ... }) | |
| 6 | // p.set({ h, s, v }) // from the server; ignored while the user drags | |
| 7 | ||
| 8 | import { hsvToRgb, rgbToHsv, toHex, fromHex } from './color.js' | |
| 9 | ||
| 10 | const WHEEL_CSS = 184 // px | |
| 11 | const TAU = Math.PI * 2 | |
| 12 | ||
| 13 | // The wheel at full value is the same for every picker: draw it once. | |
| 14 | let wheelImage = null | |
| 15 | function wheelBitmap (size) { | |
| 16 | if (wheelImage && wheelImage.width === size) return wheelImage | |
| 17 | const c = document.createElement('canvas') | |
| 18 | c.width = c.height = size | |
| 19 | const ctx = c.getContext('2d') | |
| 20 | const img = ctx.createImageData(size, size) | |
| 21 | const R = size / 2 | |
| 22 | for (let y = 0; y < size; y++) { | |
| 23 | for (let x = 0; x < size; x++) { | |
| 24 | const dx = x + 0.5 - R | |
| 25 | const dy = y + 0.5 - R | |
| 26 | const d = Math.sqrt(dx * dx + dy * dy) | |
| 27 | if (d > R) continue | |
| 28 | // Hue 0 (red) at the right, increasing counter-clockwise. | |
| 29 | const h = (Math.atan2(-dy, dx) / TAU + 1) % 1 | |
| 30 | const { r, g, b } = hsvToRgb(h, d / R, 1) | |
| 31 | const i = (y * size + x) * 4 | |
| 32 | img.data[i] = r * 255 | |
| 33 | img.data[i + 1] = g * 255 | |
| 34 | img.data[i + 2] = b * 255 | |
| 35 | img.data[i + 3] = Math.min(1, R - d) * 255 // soft edge | |
| 36 | } | |
| 37 | } | |
| 38 | ctx.putImageData(img, 0, 0) | |
| 39 | wheelImage = c | |
| 40 | return c | |
| 41 | } | |
| 42 | ||
| 43 | export class Picker { | |
| 44 | constructor (root, { onChange }) { | |
| 45 | this.onChange = onChange | |
| 46 | this.h = 0 | |
| 47 | this.s = 0 | |
| 48 | this.v = 1 | |
| 49 | this.dragging = false | |
| 50 | this.tab = 'rgb' | |
| 51 | ||
| 52 | root.classList.add('picker') | |
| 53 | root.innerHTML = ` | |
| 54 | <div class="pk-top"> | |
| 55 | <canvas class="pk-wheel" aria-label="Hue and saturation"></canvas> | |
| 56 | <div class="pk-vbar" aria-label="Value (brightness)"><div class="pk-vhandle"></div></div> | |
| 57 | </div> | |
| 58 | <div class="pk-tabs" role="tablist"> | |
| 59 | <button type="button" data-tab="rgb">RGB</button> | |
| 60 | <button type="button" data-tab="hsv">HSV</button> | |
| 61 | <button type="button" data-tab="hex">Hex</button> | |
| 62 | </div> | |
| 63 | <div class="pk-fields"></div>` | |
| 64 | this.root = root | |
| 65 | this.canvas = root.querySelector('.pk-wheel') | |
| 66 | this.vbar = root.querySelector('.pk-vbar') | |
| 67 | this.vhandle = root.querySelector('.pk-vhandle') | |
| 68 | this.fields = root.querySelector('.pk-fields') | |
| 69 | ||
| 70 | const dpr = window.devicePixelRatio || 1 | |
| 71 | this.px = Math.round(WHEEL_CSS * dpr) | |
| 72 | this.canvas.width = this.canvas.height = this.px | |
| 73 | this.canvas.style.width = this.canvas.style.height = WHEEL_CSS + 'px' | |
| 74 | this.ctx = this.canvas.getContext('2d') | |
| 75 | ||
| 76 | this._drag(this.canvas, (e) => this._fromWheel(e)) | |
| 77 | this._drag(this.vbar, (e) => this._fromBar(e)) | |
| 78 | for (const b of root.querySelectorAll('.pk-tabs button')) { | |
| 79 | b.onclick = () => { this.tab = b.dataset.tab; this._buildFields() } | |
| 80 | } | |
| 81 | this._buildFields() | |
| 82 | this._draw() | |
| 83 | } | |
| 84 | ||
| 85 | set ({ h, s, v }) { | |
| 86 | if (this.dragging) return | |
| 87 | this.h = h | |
| 88 | this.s = s | |
| 89 | this.v = v | |
| 90 | this._draw() | |
| 91 | } | |
| 92 | ||
| 93 | // ---- input -------------------------------------------------------------- | |
| 94 | ||
| 95 | _drag (el, apply) { | |
| 96 | el.addEventListener('pointerdown', (e) => { | |
| 97 | e.preventDefault() | |
| 98 | el.setPointerCapture(e.pointerId) | |
| 99 | this.dragging = true | |
| 100 | apply(e) | |
| 101 | }) | |
| 102 | el.addEventListener('pointermove', (e) => { | |
| 103 | if (this.dragging && el.hasPointerCapture(e.pointerId)) apply(e) | |
| 104 | }) | |
| 105 | const end = (e) => { | |
| 106 | if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId) | |
| 107 | this.dragging = false | |
| 108 | } | |
| 109 | el.addEventListener('pointerup', end) | |
| 110 | el.addEventListener('pointercancel', end) | |
| 111 | } | |
| 112 | ||
| 113 | _fromWheel (e) { | |
| 114 | const r = this.canvas.getBoundingClientRect() | |
| 115 | const R = r.width / 2 | |
| 116 | const dx = e.clientX - r.left - R | |
| 117 | const dy = e.clientY - r.top - R | |
| 118 | this.h = (Math.atan2(-dy, dx) / TAU + 1) % 1 | |
| 119 | this.s = Math.min(1, Math.sqrt(dx * dx + dy * dy) / R) | |
| 120 | this._changed() | |
| 121 | } | |
| 122 | ||
| 123 | _fromBar (e) { | |
| 124 | const r = this.vbar.getBoundingClientRect() | |
| 125 | this.v = Math.max(0, Math.min(1, 1 - (e.clientY - r.top) / r.height)) | |
| 126 | this._changed() | |
| 127 | } | |
| 128 | ||
| 129 | _changed () { | |
| 130 | this._draw() | |
| 131 | this.onChange({ h: this.h, s: this.s, v: this.v }) | |
| 132 | } | |
| 133 | ||
| 134 | // ---- fields ------------------------------------------------------------- | |
| 135 | ||
| 136 | _buildFields () { | |
| 137 | for (const b of this.root.querySelectorAll('.pk-tabs button')) { | |
| 138 | b.classList.toggle('on', b.dataset.tab === this.tab) | |
| 139 | } | |
| 140 | this.fields.innerHTML = '' | |
| 141 | if (this.tab === 'hex') { | |
| 142 | const row = document.createElement('label') | |
| 143 | row.className = 'pk-row' | |
| 144 | row.innerHTML = '<span>Hex</span><input type="text" class="pk-hex" maxlength="7" spellcheck="false">' | |
| 145 | const input = row.querySelector('input') | |
| 146 | input.addEventListener('change', () => { | |
| 147 | const c = fromHex(input.value) | |
| 148 | if (!c) return this._syncFields() | |
| 149 | this._setRgb(c.r, c.g, c.b) | |
| 150 | }) | |
| 151 | this.fields.appendChild(row) | |
| 152 | } else { | |
| 153 | const names = this.tab === 'rgb' ? ['R', 'G', 'B'] : ['H', 'S', 'V'] | |
| 154 | for (let i = 0; i < 3; i++) { | |
| 155 | const row = document.createElement('label') | |
| 156 | row.className = 'pk-row' | |
| 157 | row.innerHTML = '<span>' + names[i] + '</span>' + | |
| 158 | '<input type="range" min="0" max="1" step="0.001">' + | |
| 159 | '<input type="number" min="0" max="1" step="0.01">' | |
| 160 | const [range, num] = row.querySelectorAll('input') | |
| 161 | const apply = (val) => { | |
| 162 | val = Math.max(0, Math.min(1, Number(val) || 0)) | |
| 163 | if (this.tab === 'rgb') { | |
| 164 | const c = hsvToRgb(this.h, this.s, this.v) | |
| 165 | const rgb = [c.r, c.g, c.b] | |
| 166 | rgb[i] = val | |
| 167 | this._setRgb(rgb[0], rgb[1], rgb[2]) | |
| 168 | } else { | |
| 169 | if (i === 0) this.h = val | |
| 170 | else if (i === 1) this.s = val | |
| 171 | else this.v = val | |
| 172 | this._changed() | |
| 173 | } | |
| 174 | } | |
| 175 | range.addEventListener('input', () => apply(range.value)) | |
| 176 | num.addEventListener('change', () => apply(num.value)) | |
| 177 | this.fields.appendChild(row) | |
| 178 | } | |
| 179 | } | |
| 180 | this._syncFields() | |
| 181 | } | |
| 182 | ||
| 183 | _setRgb (r, g, b) { | |
| 184 | const hsv = rgbToHsv(r, g, b) | |
| 185 | // Grey or black has no hue: keep the one we had so the wheel doesn't jump. | |
| 186 | if (hsv.s > 0 && hsv.v > 0) this.h = hsv.h | |
| 187 | this.s = hsv.s | |
| 188 | this.v = hsv.v | |
| 189 | this._changed() | |
| 190 | } | |
| 191 | ||
| 192 | _syncFields () { | |
| 193 | const active = document.activeElement | |
| 194 | if (this.tab === 'hex') { | |
| 195 | const input = this.fields.querySelector('.pk-hex') | |
| 196 | if (input !== active) input.value = toHex(hsvToRgb(this.h, this.s, this.v)) | |
| 197 | return | |
| 198 | } | |
| 199 | const c = hsvToRgb(this.h, this.s, this.v) | |
| 200 | const vals = this.tab === 'rgb' ? [c.r, c.g, c.b] : [this.h, this.s, this.v] | |
| 201 | this.fields.querySelectorAll('.pk-row').forEach((row, i) => { | |
| 202 | const [range, num] = row.querySelectorAll('input') | |
| 203 | if (range !== active) range.value = vals[i] | |
| 204 | if (num !== active) num.value = vals[i].toFixed(3) | |
| 205 | }) | |
| 206 | } | |
| 207 | ||
| 208 | // ---- drawing ------------------------------------------------------------ | |
| 209 | ||
| 210 | _draw () { | |
| 211 | const { ctx, px } = this | |
| 212 | const R = px / 2 | |
| 213 | ctx.clearRect(0, 0, px, px) | |
| 214 | ctx.drawImage(wheelBitmap(px), 0, 0) | |
| 215 | // Like Blender, the wheel shows the current value: darken it toward V = 0. | |
| 216 | if (this.v < 1) { | |
| 217 | ctx.fillStyle = 'rgba(0,0,0,' + (1 - this.v) + ')' | |
| 218 | ctx.beginPath() | |
| 219 | ctx.arc(R, R, R, 0, TAU) | |
| 220 | ctx.fill() | |
| 221 | } | |
| 222 | const a = this.h * TAU | |
| 223 | const x = R + Math.cos(a) * this.s * R | |
| 224 | const y = R - Math.sin(a) * this.s * R | |
| 225 | const dpr = px / WHEEL_CSS | |
| 226 | ctx.lineWidth = 2 * dpr | |
| 227 | ctx.strokeStyle = this.v > 0.5 ? '#000' : '#fff' | |
| 228 | ctx.beginPath() | |
| 229 | ctx.arc(x, y, 6 * dpr, 0, TAU) | |
| 230 | ctx.stroke() | |
| 231 | ||
| 232 | const full = hsvToRgb(this.h, this.s, 1) | |
| 233 | this.vbar.style.background = 'linear-gradient(to top, #000, ' + toHex(full) + ')' | |
| 234 | this.vhandle.style.top = ((1 - this.v) * 100) + '%' | |
| 235 | this._syncFields() | |
| 236 | } | |
| 237 | } |
| … | … | @@ -0,0 +1,57 @@ |
| 1 | // Colour maths behind the page's Blender-style picker (static/color.js). | |
| 2 | import test from 'brittle' | |
| 3 | import { hsvToRgb, rgbToHsv, slotToHsv, hsvToSlot, toHex, fromHex } from '../static/color.js' | |
| 4 | ||
| 5 | const near = (t, a, b, msg) => t.ok(Math.abs(a - b) < 1e-6, msg + ' (' + a + ' vs ' + b + ')') | |
| 6 | ||
| 7 | test('primaries convert both ways', (t) => { | |
| 8 | t.alike(hsvToRgb(0, 1, 1), { r: 1, g: 0, b: 0 }) | |
| 9 | // 1/3 and 2/3 are not exact in binary: compare within float noise. | |
| 10 | for (const [h, want] of [[1 / 3, [0, 1, 0]], [2 / 3, [0, 0, 1]]]) { | |
| 11 | const c = hsvToRgb(h, 1, 1) | |
| 12 | near(t, c.r, want[0], 'r at h=' + h) | |
| 13 | near(t, c.g, want[1], 'g at h=' + h) | |
| 14 | near(t, c.b, want[2], 'b at h=' + h) | |
| 15 | } | |
| 16 | t.alike(hsvToRgb(0.5, 0, 0.25), { r: 0.25, g: 0.25, b: 0.25 }) | |
| 17 | const h = rgbToHsv(0, 0, 1) | |
| 18 | near(t, h.h, 2 / 3, 'blue hue') | |
| 19 | t.is(h.s, 1) | |
| 20 | t.is(h.v, 1) | |
| 21 | }) | |
| 22 | ||
| 23 | test('rgb -> hsv -> rgb round-trips', (t) => { | |
| 24 | for (const [r, g, b] of [[0.2, 0.7, 0.1], [1, 0.5, 0.25], [0.9, 0.9, 0.95], [0.01, 0, 0.6]]) { | |
| 25 | const { h, s, v } = rgbToHsv(r, g, b) | |
| 26 | const back = hsvToRgb(h, s, v) | |
| 27 | near(t, back.r, r, 'r') | |
| 28 | near(t, back.g, g, 'g') | |
| 29 | near(t, back.b, b, 'b') | |
| 30 | } | |
| 31 | }) | |
| 32 | ||
| 33 | test('a slot maps to HSV with V = brightness', (t) => { | |
| 34 | const hsv = slotToHsv({ r: 0, g: 0.5, b: 1, level: 0.4 }) | |
| 35 | near(t, hsv.v, 0.4, 'V is the level') | |
| 36 | near(t, hsv.s, 1, 'fully saturated') | |
| 37 | const slot = hsvToSlot(hsv.h, hsv.s, hsv.v) | |
| 38 | near(t, slot.level, 0.4, 'level back') | |
| 39 | near(t, slot.b, 1, 'colour normalised to full') | |
| 40 | near(t, slot.g, 0.5, 'g kept') | |
| 41 | }) | |
| 42 | ||
| 43 | test('a dark slot keeps the hue it was given', (t) => { | |
| 44 | // V = 0 must not lose the colour: dimming to black and back returns to it. | |
| 45 | const hsv = slotToHsv({ r: 1, g: 0, b: 0, level: 0 }, 0.3) | |
| 46 | t.is(hsv.v, 0) | |
| 47 | near(t, hsv.h, 0, 'hue from the slot colour, not the fallback') | |
| 48 | const grey = slotToHsv({ r: 0, g: 0, b: 0, level: 0 }, 0.3) | |
| 49 | near(t, grey.h, 0.3, 'no colour at all: fallback hue') | |
| 50 | }) | |
| 51 | ||
| 52 | test('hex conversion', (t) => { | |
| 53 | t.is(toHex({ r: 1, g: 0.5, b: 0 }), '#ff8000') | |
| 54 | t.alike(fromHex('#ff8000'), { r: 1, g: 128 / 255, b: 0 }) | |
| 55 | t.alike(fromHex('ff8000'), { r: 1, g: 128 / 255, b: 0 }, 'no #') | |
| 56 | t.is(fromHex('#zzz'), null) | |
| 57 | }) |
| … | … | @@ -53,6 +53,8 @@ test('page and assets are served', async (t) => { |
| 53 | 53 | t.ok(page.body.includes('<title>'), 'html') |
| 54 | 54 | t.is((await request(s.port, 'GET', '/app.js')).status, 200) |
| 55 | 55 | t.is((await request(s.port, 'GET', '/app.css')).status, 200) |
| 56 | t.is((await request(s.port, 'GET', '/color.js')).status, 200) | |
| 57 | t.is((await request(s.port, 'GET', '/picker.js')).status, 200) | |
| 56 | 58 | t.is((await request(s.port, 'GET', '/nope')).status, 404) |
| 57 | 59 | await s.teardown() |
| 58 | 60 | }) |