@kolkrabbi/kol-component 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/hooks/colorMath.js +214 -0
- package/src/hooks/useEyedropper.js +124 -0
- package/src/index.js +9 -0
- package/src/molecules/PaletteHarmonyWheel.jsx +192 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* colorMath — HSL/hex conversion + harmony generation for the KOL color
|
|
3
|
+
* pickers (SpectrumControls, PaletteHarmonyWheel).
|
|
4
|
+
*
|
|
5
|
+
* Plain functions, not a hook — it lives in src/hooks/ because that is the
|
|
6
|
+
* taxonomy's only non-component folder (same rationale as cssVar.js).
|
|
7
|
+
*
|
|
8
|
+
* Two harmony paths:
|
|
9
|
+
* - Deterministic — `harmonyColors(hue, harmony, {saturation, lightness})`
|
|
10
|
+
* / `generateHarmony(baseHex, harmony)`: role-offset hues off a base,
|
|
11
|
+
* no randomness, so dragging the harmony wheel is smooth + repeatable.
|
|
12
|
+
* - Seeded/jittered — `seedHarmony(baseHex, mode)`: slot 0 = the base,
|
|
13
|
+
* slots 1–5 derived via a mode with small random jitter, so consecutive
|
|
14
|
+
* "randomize" clicks produce related-but-different variants around a seed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/* ── HSL / hex conversion ─────────────────────────────────────────────── */
|
|
18
|
+
|
|
19
|
+
/** '#RRGGBB' → { h: 0–360, s: 0–100, l: 0–100 }. */
|
|
20
|
+
export function hexToHsl(hex) {
|
|
21
|
+
const r = parseInt(hex.slice(1, 3), 16) / 255
|
|
22
|
+
const g = parseInt(hex.slice(3, 5), 16) / 255
|
|
23
|
+
const b = parseInt(hex.slice(5, 7), 16) / 255
|
|
24
|
+
const max = Math.max(r, g, b)
|
|
25
|
+
const min = Math.min(r, g, b)
|
|
26
|
+
let h = 0, s = 0
|
|
27
|
+
const l = (max + min) / 2
|
|
28
|
+
if (max !== min) {
|
|
29
|
+
const d = max - min
|
|
30
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
|
31
|
+
switch (max) {
|
|
32
|
+
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
|
33
|
+
case g: h = (b - r) / d + 2; break
|
|
34
|
+
case b: h = (r - g) / d + 4; break
|
|
35
|
+
}
|
|
36
|
+
h *= 60
|
|
37
|
+
}
|
|
38
|
+
return { h, s: s * 100, l: l * 100 }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** (h: 0–360, s: 0–100, l: 0–100) → uppercase '#RRGGBB'. */
|
|
42
|
+
export function hslToHex(h, s, l) {
|
|
43
|
+
h = normHue(h)
|
|
44
|
+
s = Math.max(0, Math.min(100, s)) / 100
|
|
45
|
+
l = Math.max(0, Math.min(100, l)) / 100
|
|
46
|
+
const c = (1 - Math.abs(2 * l - 1)) * s
|
|
47
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
|
|
48
|
+
const m = l - c / 2
|
|
49
|
+
let r = 0, g = 0, b = 0
|
|
50
|
+
if (h < 60) { r = c; g = x; b = 0 }
|
|
51
|
+
else if (h < 120) { r = x; g = c; b = 0 }
|
|
52
|
+
else if (h < 180) { r = 0; g = c; b = x }
|
|
53
|
+
else if (h < 240) { r = 0; g = x; b = c }
|
|
54
|
+
else if (h < 300) { r = x; g = 0; b = c }
|
|
55
|
+
else { r = c; g = 0; b = x }
|
|
56
|
+
const toHex = (v) => Math.round((v + m) * 255).toString(16).padStart(2, '0')
|
|
57
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** RGB components (0–255) → uppercase '#RRGGBB'. Shared with the eyedropper. */
|
|
61
|
+
export function rgbToHex(r, g, b) {
|
|
62
|
+
return '#' + [r, g, b]
|
|
63
|
+
.map((n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'))
|
|
64
|
+
.join('')
|
|
65
|
+
.toUpperCase()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Wrap any hue into [0, 360). */
|
|
69
|
+
export const normHue = (h) => ((h % 360) + 360) % 360
|
|
70
|
+
|
|
71
|
+
/* ── Harmony schemes ──────────────────────────────────────────────────── */
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Hue offsets for the five palette roles (Primary / Secondary / Light /
|
|
75
|
+
* Dark / Accent) relative to the base hue. The wheel draws a satellite
|
|
76
|
+
* marker per unique offset; the generators emit one color per role.
|
|
77
|
+
*/
|
|
78
|
+
export const HARMONIES = [
|
|
79
|
+
{ id: 'analogous', label: 'Analogous', roleOffsets: [0, -30, -15, 15, 30] },
|
|
80
|
+
{ id: 'complementary', label: 'Complementary', roleOffsets: [0, 0, 0, 180, 180] },
|
|
81
|
+
{ id: 'split', label: 'Split complementary', roleOffsets: [0, 150, 210, 0, 150] },
|
|
82
|
+
{ id: 'triadic', label: 'Triadic', roleOffsets: [0, 120, 240, 0, 120] },
|
|
83
|
+
{ id: 'tetradic', label: 'Tetradic', roleOffsets: [0, 90, 180, 270, 0] },
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
/** Resolve a harmony id string OR a harmony object to the object. */
|
|
87
|
+
export const harmonyById = (harmony) => {
|
|
88
|
+
if (harmony && typeof harmony === 'object') return harmony
|
|
89
|
+
return HARMONIES.find((h) => h.id === harmony) ?? HARMONIES[0]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Deterministic role colors for a harmony, driven by a hue + fixed S/L.
|
|
94
|
+
* Preserves saturation/lightness across roles (only hue rotates), so the
|
|
95
|
+
* output tracks the wheel's satellite markers exactly.
|
|
96
|
+
*
|
|
97
|
+
* @param {number} hue base hue, 0–360
|
|
98
|
+
* @param {string|object} harmony harmony id or object
|
|
99
|
+
* @param {{saturation?: number, lightness?: number}} opts base S/L (0–100)
|
|
100
|
+
* @returns {string[]} one '#RRGGBB' per role offset
|
|
101
|
+
*/
|
|
102
|
+
export function harmonyColors(hue, harmony, { saturation = 100, lightness = 50 } = {}) {
|
|
103
|
+
const { roleOffsets } = harmonyById(harmony)
|
|
104
|
+
return roleOffsets.map((off) => hslToHex(normHue(hue + off), saturation, lightness))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Deterministic role colors derived from a base hex (S/L taken from the hex).
|
|
109
|
+
* Convenience wrapper over `harmonyColors` for callers holding a color, not
|
|
110
|
+
* a hue.
|
|
111
|
+
*/
|
|
112
|
+
export function generateHarmony(baseHex, harmony) {
|
|
113
|
+
const { h, s, l } = hexToHsl(baseHex)
|
|
114
|
+
return harmonyColors(h, harmony, { saturation: s, lightness: l })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ── Seeded (jittered) modes ──────────────────────────────────────────── */
|
|
118
|
+
|
|
119
|
+
const jitter = (range) => (Math.random() - 0.5) * 2 * range
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Seed-based generators — slot 0 = the base, slots 1–5 derived via the mode.
|
|
123
|
+
* Each call jitters parameters so repeated "randomize" produces visibly
|
|
124
|
+
* different but harmonically-related variants around the same seed.
|
|
125
|
+
*/
|
|
126
|
+
const SEED_MODES = {
|
|
127
|
+
random: (base) => {
|
|
128
|
+
const { h, s, l } = hexToHsl(base)
|
|
129
|
+
return [
|
|
130
|
+
base,
|
|
131
|
+
hslToHex(normHue(h + Math.random() * 360), s, l),
|
|
132
|
+
hslToHex(normHue(h + Math.random() * 360), s, l),
|
|
133
|
+
hslToHex(h, s, Math.max(10, l - 25 + jitter(10))),
|
|
134
|
+
hslToHex(h, s, Math.min(90, l + 25 + jitter(10))),
|
|
135
|
+
hslToHex(h, s * (0.4 + Math.random() * 0.4), l),
|
|
136
|
+
]
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
monochromatic: (base) => {
|
|
140
|
+
const { h, s, l } = hexToHsl(base)
|
|
141
|
+
const j = 8
|
|
142
|
+
return [
|
|
143
|
+
base,
|
|
144
|
+
hslToHex(h, s, Math.max(10, l - 30 + jitter(j))),
|
|
145
|
+
hslToHex(h, s, Math.max(20, l - 15 + jitter(j))),
|
|
146
|
+
hslToHex(h, s, Math.min(85, l + 15 + jitter(j))),
|
|
147
|
+
hslToHex(h, s, Math.min(95, l + 30 + jitter(j))),
|
|
148
|
+
hslToHex(h, s, Math.min(98, l + 45 + jitter(j))),
|
|
149
|
+
]
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
analogous: (base) => {
|
|
153
|
+
const { h, s, l } = hexToHsl(base)
|
|
154
|
+
return [
|
|
155
|
+
base,
|
|
156
|
+
hslToHex(normHue(h - 30 + jitter(8)), s, l),
|
|
157
|
+
hslToHex(normHue(h - 15 + jitter(8)), s, l),
|
|
158
|
+
hslToHex(normHue(h + 15 + jitter(8)), s, l),
|
|
159
|
+
hslToHex(normHue(h + 30 + jitter(8)), s, l),
|
|
160
|
+
hslToHex(normHue(h + 45 + jitter(8)), s, l),
|
|
161
|
+
]
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
complementary: (base) => {
|
|
165
|
+
const { h, s, l } = hexToHsl(base)
|
|
166
|
+
return [
|
|
167
|
+
base,
|
|
168
|
+
hslToHex(h, s, Math.max(15, l - 25 + jitter(8))),
|
|
169
|
+
hslToHex(h, s, Math.min(85, l + 25 + jitter(8))),
|
|
170
|
+
hslToHex(normHue(h + 180 + jitter(10)), s, l),
|
|
171
|
+
hslToHex(normHue(h + 180 + jitter(10)), s, Math.max(15, l - 15 + jitter(8))),
|
|
172
|
+
hslToHex(normHue(h + 180 + jitter(10)), s, Math.min(85, l + 15 + jitter(8))),
|
|
173
|
+
]
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
triadic: (base) => {
|
|
177
|
+
const { h, s, l } = hexToHsl(base)
|
|
178
|
+
return [
|
|
179
|
+
base,
|
|
180
|
+
hslToHex(normHue(h + 120 + jitter(10)), s, l),
|
|
181
|
+
hslToHex(normHue(h + 240 + jitter(10)), s, l),
|
|
182
|
+
hslToHex(h, s, Math.min(85, l + 20 + jitter(8))),
|
|
183
|
+
hslToHex(normHue(h + 120 + jitter(10)), s, Math.max(15, l - 20 + jitter(8))),
|
|
184
|
+
hslToHex(normHue(h + 240 + jitter(10)), s, Math.min(85, l + 20 + jitter(8))),
|
|
185
|
+
]
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
doubleComplementary: (base) => {
|
|
189
|
+
const { h, s, l } = hexToHsl(base)
|
|
190
|
+
return [
|
|
191
|
+
base,
|
|
192
|
+
hslToHex(normHue(h + 90 + jitter(10)), s, l),
|
|
193
|
+
hslToHex(normHue(h + 180 + jitter(10)), s, l),
|
|
194
|
+
hslToHex(normHue(h + 270 + jitter(10)), s, l),
|
|
195
|
+
hslToHex(h, s, Math.min(85, l + 20 + jitter(8))),
|
|
196
|
+
hslToHex(normHue(h + 180 + jitter(10)), s, Math.max(15, l - 20 + jitter(8))),
|
|
197
|
+
]
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The available seed-mode ids (for a consumer's mode selector). */
|
|
202
|
+
export const SEED_MODE_IDS = Object.keys(SEED_MODES)
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Generate a jittered 6-color palette from a seed hex.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} baseHex the seed color, occupies slot 0
|
|
208
|
+
* @param {string} mode one of SEED_MODE_IDS (falls back to 'random')
|
|
209
|
+
* @returns {string[]} six '#RRGGBB' values
|
|
210
|
+
*/
|
|
211
|
+
export function seedHarmony(baseHex, mode = 'random') {
|
|
212
|
+
const gen = SEED_MODES[mode] ?? SEED_MODES.random
|
|
213
|
+
return gen(baseHex)
|
|
214
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useEyedropper — cross-browser color sampling.
|
|
3
|
+
*
|
|
4
|
+
* Primary path is the native `window.EyeDropper` API (Chromium): one call,
|
|
5
|
+
* samples any pixel on screen, returns an sRGB hex. Where it is unavailable
|
|
6
|
+
* (Firefox, Safari) the hook falls back to an injected `fallback` sampler —
|
|
7
|
+
* e.g. `pickFromCanvasElement`, which samples a pixel from a <canvas> the
|
|
8
|
+
* consumer renders. The DS stays decoupled: it never reaches into an app's
|
|
9
|
+
* canvas/scene — the consumer supplies the fallback.
|
|
10
|
+
*
|
|
11
|
+
* Pairs with the EyedropPick affordance in SwatchControls: gate the button on
|
|
12
|
+
* `supported`, wire the button's `onPick` to `pick`.
|
|
13
|
+
*
|
|
14
|
+
* const { supported, pick } = useEyedropper({ fallback })
|
|
15
|
+
* // supported → render the eyedropper button
|
|
16
|
+
* const hex = await pick() // '#RRGGBB' or null (cancelled / unavailable)
|
|
17
|
+
*/
|
|
18
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
19
|
+
import { rgbToHex } from './colorMath.js'
|
|
20
|
+
|
|
21
|
+
/* Native support probe — read at call time so it is correct even before the
|
|
22
|
+
* post-mount state (below) has settled. */
|
|
23
|
+
function hasNativeEyeDropper() {
|
|
24
|
+
return typeof window !== 'undefined' && 'EyeDropper' in window
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Generic canvas-region sampler — the reusable core of a canvas eyedropper.
|
|
29
|
+
* Sets a crosshair cursor on `canvas`, awaits one pointer press on it, reads
|
|
30
|
+
* the pixel under the cursor via getImageData, and resolves the hex. Escape,
|
|
31
|
+
* a press outside the canvas bounds, or an aborted `signal` resolve to null.
|
|
32
|
+
*
|
|
33
|
+
* Backing-store aware: scales client coords by canvas.width/height ÷ CSS box,
|
|
34
|
+
* so a HiDPI or CSS-scaled canvas still samples the right pixel.
|
|
35
|
+
*
|
|
36
|
+
* @param {HTMLCanvasElement} canvas the canvas to sample from
|
|
37
|
+
* @param {{signal?: AbortSignal}} [opts]
|
|
38
|
+
* @returns {Promise<string|null>} uppercase '#RRGGBB' or null
|
|
39
|
+
*/
|
|
40
|
+
export async function pickFromCanvasElement(canvas, { signal } = {}) {
|
|
41
|
+
if (!canvas || typeof canvas.getContext !== 'function') return null
|
|
42
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
|
43
|
+
if (!ctx) return null
|
|
44
|
+
if (signal?.aborted) return null
|
|
45
|
+
|
|
46
|
+
const prevCursor = canvas.style.cursor
|
|
47
|
+
canvas.style.cursor = 'crosshair'
|
|
48
|
+
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
const cleanup = () => {
|
|
51
|
+
canvas.removeEventListener('pointerdown', onDown, true)
|
|
52
|
+
document.removeEventListener('keydown', onKey, true)
|
|
53
|
+
signal?.removeEventListener('abort', onAbort)
|
|
54
|
+
canvas.style.cursor = prevCursor
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const onDown = (e) => {
|
|
58
|
+
e.preventDefault()
|
|
59
|
+
e.stopPropagation()
|
|
60
|
+
const rect = canvas.getBoundingClientRect()
|
|
61
|
+
const sx = rect.width ? canvas.width / rect.width : 1
|
|
62
|
+
const sy = rect.height ? canvas.height / rect.height : 1
|
|
63
|
+
const vx = Math.floor((e.clientX - rect.left) * sx)
|
|
64
|
+
const vy = Math.floor((e.clientY - rect.top) * sy)
|
|
65
|
+
if (vx < 0 || vy < 0 || vx >= canvas.width || vy >= canvas.height) {
|
|
66
|
+
cleanup(); resolve(null); return
|
|
67
|
+
}
|
|
68
|
+
let data
|
|
69
|
+
try { data = ctx.getImageData(vx, vy, 1, 1).data }
|
|
70
|
+
catch { cleanup(); resolve(null); return } /* tainted canvas */
|
|
71
|
+
cleanup()
|
|
72
|
+
resolve(rgbToHex(data[0], data[1], data[2]))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const onKey = (e) => {
|
|
76
|
+
if (e.key === 'Escape') { e.preventDefault(); cleanup(); resolve(null) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const onAbort = () => { cleanup(); resolve(null) }
|
|
80
|
+
|
|
81
|
+
/* Capture phase so we beat the consumer's own canvas handlers. */
|
|
82
|
+
canvas.addEventListener('pointerdown', onDown, true)
|
|
83
|
+
document.addEventListener('keydown', onKey, true)
|
|
84
|
+
signal?.addEventListener('abort', onAbort)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {{fallback?: (opts?: {signal?: AbortSignal}) => Promise<string|null>}} [options]
|
|
90
|
+
* fallback — sampler used when the native EyeDropper API is absent. Return
|
|
91
|
+
* a hex or null. Without a fallback, unsupported browsers report
|
|
92
|
+
* `supported: false` and `pick` resolves null.
|
|
93
|
+
* @returns {{supported: boolean, nativeSupported: boolean,
|
|
94
|
+
* pick: (opts?: {signal?: AbortSignal}) => Promise<string|null>}}
|
|
95
|
+
*/
|
|
96
|
+
export function useEyedropper({ fallback } = {}) {
|
|
97
|
+
/* Resolved after mount so SSR / first paint stays stable (starts false,
|
|
98
|
+
* flips true only where the native API exists). */
|
|
99
|
+
const [nativeSupported, setNativeSupported] = useState(false)
|
|
100
|
+
useEffect(() => { setNativeSupported(hasNativeEyeDropper()) }, [])
|
|
101
|
+
|
|
102
|
+
const fallbackRef = useRef(fallback)
|
|
103
|
+
fallbackRef.current = fallback
|
|
104
|
+
|
|
105
|
+
const supported = nativeSupported || !!fallback
|
|
106
|
+
|
|
107
|
+
const pick = useCallback(async ({ signal } = {}) => {
|
|
108
|
+
if (hasNativeEyeDropper()) {
|
|
109
|
+
try {
|
|
110
|
+
const res = await new window.EyeDropper().open(signal ? { signal } : undefined)
|
|
111
|
+
return res?.sRGBHex ? res.sRGBHex.toUpperCase() : null
|
|
112
|
+
} catch {
|
|
113
|
+
return null /* user cancelled (Escape) rejects the promise */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (fallbackRef.current) return (await fallbackRef.current({ signal })) ?? null
|
|
117
|
+
return null
|
|
118
|
+
}, [])
|
|
119
|
+
|
|
120
|
+
return useMemo(
|
|
121
|
+
() => ({ supported, nativeSupported, pick }),
|
|
122
|
+
[supported, nativeSupported, pick],
|
|
123
|
+
)
|
|
124
|
+
}
|
package/src/index.js
CHANGED
|
@@ -76,6 +76,7 @@ export { default as MediaRow } from './molecules/MediaRow.jsx'
|
|
|
76
76
|
export { MenuItem, MenuDropdownItem, MenuDropdownDivider, MenuDropdownNest } from './molecules/MenuItem.jsx'
|
|
77
77
|
export { MenuPopover } from './molecules/MenuPopover.jsx'
|
|
78
78
|
export { ModalProvider, useModal } from './molecules/Modal.jsx'
|
|
79
|
+
export { default as PaletteHarmonyWheel } from './molecules/PaletteHarmonyWheel.jsx'
|
|
79
80
|
export { default as PropertyInput } from './molecules/PropertyInput.jsx'
|
|
80
81
|
export { default as ShapeDropdown } from './molecules/ShapeDropdown.jsx'
|
|
81
82
|
export { default as ShellDrawer } from './molecules/ShellDrawer.jsx'
|
|
@@ -123,4 +124,12 @@ export { default as useReveal } from './hooks/useReveal.js'
|
|
|
123
124
|
export { default as useScrollSpy } from './hooks/useScrollSpy.js'
|
|
124
125
|
export { default as useTilt } from './hooks/useTilt.js'
|
|
125
126
|
export { default as useAxisAnimation } from './hooks/useAxisAnimation.js'
|
|
127
|
+
export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
|
|
126
128
|
export { resolveCssVar, resolveCssColor, isLight } from './hooks/cssVar.js'
|
|
129
|
+
|
|
130
|
+
// color math (support module — HSL/hex conversion + harmony generation)
|
|
131
|
+
export {
|
|
132
|
+
hexToHsl, hslToHex, rgbToHex, normHue,
|
|
133
|
+
HARMONIES, harmonyById, harmonyColors, generateHarmony,
|
|
134
|
+
SEED_MODE_IDS, seedHarmony,
|
|
135
|
+
} from './hooks/colorMath.js'
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import { HARMONIES, harmonyById, harmonyColors, normHue } from '../hooks/colorMath.js'
|
|
3
|
+
|
|
4
|
+
/* taxonomy-ok: a pure-canvas hue + harmony picker in the SpectrumControls
|
|
5
|
+
* color-picker family. It nests no KOL component — its only import is the
|
|
6
|
+
* colorMath support module in hooks/ — but belongs beside its siblings
|
|
7
|
+
* SpectrumControls / SwatchControls, not down in atoms. */
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* PaletteHarmonyWheel — a hue ring with a draggable base-hue handle and
|
|
11
|
+
* satellite markers at the active harmony's scheme hues. Dragging (or the
|
|
12
|
+
* arrow keys) rotates the base hue; every change emits the full set of
|
|
13
|
+
* harmony colors so the caller never re-derives them.
|
|
14
|
+
*
|
|
15
|
+
* The scheme table is DATA-INJECTED (`harmonies`, defaulting to the shared
|
|
16
|
+
* HARMONIES); saturation/lightness of the generated colors are injected too,
|
|
17
|
+
* so this stays a pure hue picker — the caller owns S/L.
|
|
18
|
+
*
|
|
19
|
+
* Controlled on `hue`; `onChange({ hue, colors })` fires on press, drag, and
|
|
20
|
+
* arrow keys. `colors` is one hex per role offset of the active harmony
|
|
21
|
+
* (see colorMath.harmonyColors), matching the satellite markers 1:1.
|
|
22
|
+
*
|
|
23
|
+
* The ring hues, marker outlines and handle halo are literal color math
|
|
24
|
+
* (hsl / #FFFFFF / rgba) on purpose — a spectrum is not themeable, and the
|
|
25
|
+
* markers sit on fully-saturated ring hues, not on the surface (same
|
|
26
|
+
* rationale as SpectrumControls). No token belongs here.
|
|
27
|
+
*
|
|
28
|
+
* @param {number} size px, square (default 248)
|
|
29
|
+
* @param {number} hue controlled base hue, 0–360
|
|
30
|
+
* @param {string|object} harmony active harmony id or object (default 'analogous')
|
|
31
|
+
* @param {number} saturation base saturation for emitted colors, 0–100 (default 100)
|
|
32
|
+
* @param {number} lightness base lightness for emitted colors, 0–100 (default 50)
|
|
33
|
+
* @param {Array} harmonies injectable scheme table (default HARMONIES)
|
|
34
|
+
* @param {Function} onChange ({ hue, colors }) => void
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/* Marker outline — white for contrast against the fully-saturated ring hues
|
|
38
|
+
* (theme-independent: the wheel's colours, not the surface, sit behind it). */
|
|
39
|
+
const MARKER_STROKE = '#FFFFFF'
|
|
40
|
+
|
|
41
|
+
export default function PaletteHarmonyWheel({
|
|
42
|
+
size = 248,
|
|
43
|
+
hue,
|
|
44
|
+
harmony = 'analogous',
|
|
45
|
+
saturation = 100,
|
|
46
|
+
lightness = 50,
|
|
47
|
+
harmonies = HARMONIES,
|
|
48
|
+
onChange,
|
|
49
|
+
}) {
|
|
50
|
+
const canvasRef = useRef(null)
|
|
51
|
+
const draggingRef = useRef(false)
|
|
52
|
+
const emitRef = useRef(null)
|
|
53
|
+
|
|
54
|
+
const active = harmonyById(
|
|
55
|
+
typeof harmony === 'string'
|
|
56
|
+
? (harmonies.find((h) => h.id === harmony) ?? harmonies[0])
|
|
57
|
+
: harmony,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
/* Emit next hue + its harmony colors. Held in a ref so the pointer/key
|
|
61
|
+
* handlers stay stable while always seeing the latest props. */
|
|
62
|
+
emitRef.current = (nextHue) => {
|
|
63
|
+
const h = normHue(nextHue)
|
|
64
|
+
onChange?.({ hue: h, colors: harmonyColors(h, active, { saturation, lightness }) })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const outerR = size / 2 - 8
|
|
68
|
+
const innerR = outerR - 22
|
|
69
|
+
const midR = (outerR + innerR) / 2
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const canvas = canvasRef.current
|
|
73
|
+
if (!canvas) return
|
|
74
|
+
const dpr = window.devicePixelRatio || 1
|
|
75
|
+
canvas.width = size * dpr
|
|
76
|
+
canvas.height = size * dpr
|
|
77
|
+
const ctx = canvas.getContext('2d')
|
|
78
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
79
|
+
ctx.clearRect(0, 0, size, size)
|
|
80
|
+
const cx = size / 2
|
|
81
|
+
const cy = size / 2
|
|
82
|
+
|
|
83
|
+
/* Angle mapping: hue 0 at 12 o'clock, clockwise. */
|
|
84
|
+
const angleFor = (h) => ((h - 90) * Math.PI) / 180
|
|
85
|
+
|
|
86
|
+
/* Hue ring — 360 thin annulus wedges (1.5° overlap hides seams). */
|
|
87
|
+
for (let a = 0; a < 360; a++) {
|
|
88
|
+
const a0 = angleFor(a)
|
|
89
|
+
const a1 = angleFor(a + 1.5)
|
|
90
|
+
ctx.beginPath()
|
|
91
|
+
ctx.arc(cx, cy, outerR, a0, a1)
|
|
92
|
+
ctx.arc(cx, cy, innerR, a1, a0, true)
|
|
93
|
+
ctx.closePath()
|
|
94
|
+
ctx.fillStyle = `hsl(${a}, 100%, 50%)`
|
|
95
|
+
ctx.fill()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const baseHue = normHue(hue)
|
|
99
|
+
const offsets = [...new Set((active?.roleOffsets ?? [0]).map(normHue))]
|
|
100
|
+
|
|
101
|
+
/* Spokes + satellite markers at the scheme hues. */
|
|
102
|
+
for (const off of offsets) {
|
|
103
|
+
const h = normHue(baseHue + off)
|
|
104
|
+
const a = angleFor(h)
|
|
105
|
+
ctx.beginPath()
|
|
106
|
+
ctx.moveTo(cx, cy)
|
|
107
|
+
ctx.lineTo(cx + Math.cos(a) * innerR, cy + Math.sin(a) * innerR)
|
|
108
|
+
ctx.strokeStyle = 'rgba(128, 128, 128, 0.4)'
|
|
109
|
+
ctx.lineWidth = 1
|
|
110
|
+
ctx.stroke()
|
|
111
|
+
if (off === 0) continue /* base hue gets the big handle below */
|
|
112
|
+
const x = cx + Math.cos(a) * midR
|
|
113
|
+
const y = cy + Math.sin(a) * midR
|
|
114
|
+
ctx.beginPath()
|
|
115
|
+
ctx.arc(x, y, 5, 0, Math.PI * 2)
|
|
116
|
+
ctx.fillStyle = `hsl(${h}, 100%, 50%)`
|
|
117
|
+
ctx.fill()
|
|
118
|
+
ctx.strokeStyle = MARKER_STROKE
|
|
119
|
+
ctx.lineWidth = 1.5
|
|
120
|
+
ctx.stroke()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* Base-hue handle. */
|
|
124
|
+
const ba = angleFor(baseHue)
|
|
125
|
+
const bx = cx + Math.cos(ba) * midR
|
|
126
|
+
const by = cy + Math.sin(ba) * midR
|
|
127
|
+
ctx.beginPath()
|
|
128
|
+
ctx.arc(bx, by, 9, 0, Math.PI * 2)
|
|
129
|
+
ctx.fillStyle = `hsl(${baseHue}, 100%, 50%)`
|
|
130
|
+
ctx.fill()
|
|
131
|
+
ctx.strokeStyle = MARKER_STROKE
|
|
132
|
+
ctx.lineWidth = 2
|
|
133
|
+
ctx.stroke()
|
|
134
|
+
ctx.beginPath()
|
|
135
|
+
ctx.arc(bx, by, 10.5, 0, Math.PI * 2)
|
|
136
|
+
ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)'
|
|
137
|
+
ctx.lineWidth = 1
|
|
138
|
+
ctx.stroke()
|
|
139
|
+
}, [size, hue, active, outerR, innerR, midR])
|
|
140
|
+
|
|
141
|
+
const pointFromEvent = (e) => {
|
|
142
|
+
const rect = canvasRef.current.getBoundingClientRect()
|
|
143
|
+
const dx = e.clientX - rect.left - rect.width / 2
|
|
144
|
+
const dy = e.clientY - rect.top - rect.height / 2
|
|
145
|
+
return { dist: Math.hypot(dx, dy), hue: normHue((Math.atan2(dy, dx) * 180) / Math.PI + 90) }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const onPointerDown = (e) => {
|
|
149
|
+
const { dist, hue: h } = pointFromEvent(e)
|
|
150
|
+
if (dist < innerR - 14 || dist > outerR + 10) return
|
|
151
|
+
draggingRef.current = true
|
|
152
|
+
e.currentTarget.setPointerCapture(e.pointerId)
|
|
153
|
+
emitRef.current?.(h)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const onPointerMove = (e) => {
|
|
157
|
+
if (!draggingRef.current) return
|
|
158
|
+
emitRef.current?.(pointFromEvent(e).hue)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const endDrag = () => { draggingRef.current = false }
|
|
162
|
+
|
|
163
|
+
const onKeyDown = (e) => {
|
|
164
|
+
const step = e.shiftKey ? 15 : 3
|
|
165
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
|
166
|
+
e.preventDefault()
|
|
167
|
+
emitRef.current?.(normHue(hue + step))
|
|
168
|
+
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
|
169
|
+
e.preventDefault()
|
|
170
|
+
emitRef.current?.(normHue(hue - step))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<canvas
|
|
176
|
+
ref={canvasRef}
|
|
177
|
+
role="slider"
|
|
178
|
+
aria-label="Base hue"
|
|
179
|
+
aria-valuemin={0}
|
|
180
|
+
aria-valuemax={359}
|
|
181
|
+
aria-valuenow={Math.round(normHue(hue))}
|
|
182
|
+
tabIndex={0}
|
|
183
|
+
className="cursor-pointer touch-none"
|
|
184
|
+
style={{ width: size, height: size }}
|
|
185
|
+
onPointerDown={onPointerDown}
|
|
186
|
+
onPointerMove={onPointerMove}
|
|
187
|
+
onPointerUp={endDrag}
|
|
188
|
+
onPointerCancel={endDrag}
|
|
189
|
+
onKeyDown={onKeyDown}
|
|
190
|
+
/>
|
|
191
|
+
)
|
|
192
|
+
}
|