Skip to content

Commit 047ba22

Browse files
committed
feat: add saturation rings for enhanced color adjustment and interaction
1 parent 57c5e6f commit 047ba22

File tree

1 file changed

+291
-5
lines changed

1 file changed

+291
-5
lines changed

dist/index.html

Lines changed: 291 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,72 @@
280280
stroke: var(--onBg);
281281
}
282282

283+
.wheel__saturation-ring {
284+
fill: none;
285+
stroke: var(--onBg);
286+
stroke-width: 0.15;
287+
stroke-linecap: round;
288+
pointer-events: none;
289+
}
290+
.wheel__ring-tick {
291+
fill: none;
292+
stroke: var(--onBg);
293+
stroke-width: 0.15;
294+
stroke-linecap: round;
295+
pointer-events: none;
296+
opacity: 0;
297+
}
298+
.wheel__ring-bg {
299+
fill: none;
300+
stroke: var(--bg);
301+
stroke-width: 0.15;
302+
pointer-events: none;
303+
opacity: 0;
304+
}
305+
.wheel__ring-group--hover .wheel__ring-tick {
306+
opacity: 1;
307+
}
308+
.wheel__ring-group--hover .wheel__ring-bg {
309+
opacity: 1;
310+
}
311+
.wheel__ring-group {
312+
opacity: 0;
313+
transform: scale(0);
314+
transform-origin: center;
315+
transform-box: fill-box;
316+
display: none;
317+
}
318+
.picker.rings-enabled .wheel__ring-group {
319+
display: block;
320+
animation: ringScaleIn .2s cubic-bezier(0.3, 0.7, 0, 1) forwards;
321+
animation-delay: 3.1s;
322+
}
323+
.is-loaded .picker.rings-enabled .wheel__ring-group {
324+
animation: ringScaleIn .2s cubic-bezier(0.3, 0.7, 0, 1) forwards;
325+
animation-delay: 0s;
326+
}
327+
@media (pointer: coarse) {
328+
.picker.rings-enabled .wheel__ring-group {
329+
display: none;
330+
}
331+
}
332+
@keyframes ringScaleIn {
333+
0% {
334+
opacity: 0;
335+
transform: scale(0);
336+
}
337+
100% {
338+
opacity: 1;
339+
transform: scale(1);
340+
}
341+
}
342+
.picker.rings-enabled.ring-hover {
343+
cursor: col-resize;
344+
}
345+
.picker.rings-enabled.ring-adjusting {
346+
cursor: grabbing;
347+
}
348+
283349
.is-loaded .wheel__anchor {
284350
animation: none;
285351
opacity: 1;
@@ -1156,8 +1222,9 @@
11561222
stroke: var(--dark);
11571223
}*/
11581224
.key {
1159-
background: var(--light);
1160-
color: var(--dark);
1225+
background: transparent;
1226+
box-shadow: 0 2px 0 var(--light);
1227+
border-color: var(--light);
11611228
}
11621229
}
11631230
</style>
@@ -1610,6 +1677,7 @@ <h2 id="playground">Playground</h2>
16101677
</p>
16111678
<p>
16121679
Use the browser's color picker to change the color of the <strong>last selected anchor</strong> <strong class="key">C</strong>, or use the <strong class="key" aria-label="left arrow"></strong> and <strong class="key" aria-label="right arrow"></strong> keys to change the hue of all colors.
1680+
Toggle the <strong>saturation rings</strong> with <strong class="key">S</strong>.
16131681
</p>
16141682
<div class="l-sec__controls">
16151683
<label>
@@ -2128,6 +2196,49 @@ <h2 class="export__title">${paletteTitle}</h2>
21282196
}
21292197
}
21302198

2199+
const ROTARY_TURNS_TO_FULL = 1.0;
2200+
const ROTARY_TURNS_TO_FULL_SHIFT = 2.5;
2201+
2202+
let ringAdjust = null;
2203+
let ringHoverIndex = null;
2204+
2205+
// Helper to pick ring from coordinates
2206+
function pickRing(normalizedX, normalizedY) {
2207+
if (!poline) return null;
2208+
const svgX = normalizedX * svgscale;
2209+
const svgY = normalizedY * svgscale;
2210+
for (let i = 0; i < poline.anchorPoints.length; i++) {
2211+
const anchor = poline.anchorPoints[i];
2212+
const cx = anchor.x * svgscale;
2213+
const cy = anchor.y * svgscale;
2214+
const dist = Math.hypot(svgX - cx, svgY - cy);
2215+
if (dist > 2 && dist <= 5) {
2216+
return i;
2217+
}
2218+
}
2219+
return null;
2220+
}
2221+
2222+
// Helper to describe SVG arc
2223+
function describeArc(cx, cy, r, startAngle, endAngle) {
2224+
const angleDiff = endAngle - startAngle;
2225+
if (Math.abs(angleDiff) < 0.001) return "";
2226+
if (Math.abs(angleDiff) > Math.PI * 2 - 0.01) {
2227+
const midAngle = startAngle + Math.PI;
2228+
const startX = cx + r * Math.cos(startAngle);
2229+
const startY = cy + r * Math.sin(startAngle);
2230+
const midX = cx + r * Math.cos(midAngle);
2231+
const midY = cy + r * Math.sin(midAngle);
2232+
return `M ${startX} ${startY} A ${r} ${r} 0 1 1 ${midX} ${midY} A ${r} ${r} 0 1 1 ${startX} ${startY}`;
2233+
}
2234+
const startX = cx + r * Math.cos(startAngle);
2235+
const startY = cy + r * Math.sin(startAngle);
2236+
const endX = cx + r * Math.cos(endAngle);
2237+
const endY = cy + r * Math.sin(endAngle);
2238+
const largeArc = angleDiff > Math.PI ? 1 : 0;
2239+
return `M ${startX} ${startY} A ${r} ${r} 0 ${largeArc} 1 ${endX} ${endY}`;
2240+
}
2241+
21312242
function updateSVG () {
21322243
if (untilDrawTimer) clearTimeout(untilDrawTimer);
21332244
untilDrawTimer = setTimeout(() => {
@@ -2159,8 +2270,75 @@ <h2 class="export__title">${paletteTitle}</h2>
21592270
});
21602271
});
21612272

2162-
// 1. Update Anchors
2273+
// 0. Update Saturation Ring Groups (before anchors so they render behind)
21632274
const anchors = poline.anchorPoints;
2275+
let ringGroups = Array.from($svg.querySelectorAll('.wheel__ring-group'));
2276+
2277+
// Remove excess
2278+
while (ringGroups.length > anchors.length) ringGroups.pop().remove();
2279+
2280+
anchors.forEach((anchor, i) => {
2281+
const cx = anchor.x * svgscale;
2282+
const cy = anchor.y * svgscale;
2283+
const saturation = anchor.z;
2284+
const ringRadius = 2.5;
2285+
const isHovered = ringHoverIndex === i || (ringAdjust && ringAdjust.anchorIndex === i);
2286+
2287+
// Get or create the group for this anchor's ring elements
2288+
let group = ringGroups[i];
2289+
if (!group) {
2290+
group = document.createElementNS(namespaceURI, 'g');
2291+
group.classList.add('wheel__ring-group');
2292+
// Create elements inside the group: bg first, then arc, then tick
2293+
const bgRing = document.createElementNS(namespaceURI, 'circle');
2294+
bgRing.classList.add('wheel__ring-bg');
2295+
group.appendChild(bgRing);
2296+
const satArc = document.createElementNS(namespaceURI, 'path');
2297+
satArc.classList.add('wheel__saturation-ring');
2298+
group.appendChild(satArc);
2299+
const tick = document.createElementNS(namespaceURI, 'line');
2300+
tick.classList.add('wheel__ring-tick');
2301+
group.appendChild(tick);
2302+
// Insert before anchors
2303+
const firstAnchor = $svg.querySelector('.wheel__anchor');
2304+
if (firstAnchor) {
2305+
$svg.insertBefore(group, firstAnchor);
2306+
} else {
2307+
$svg.appendChild(group);
2308+
}
2309+
ringGroups = Array.from($svg.querySelectorAll('.wheel__ring-group'));
2310+
}
2311+
2312+
// Toggle hover on the group
2313+
group.classList.toggle('wheel__ring-group--hover', !!isHovered);
2314+
2315+
// Update bg ring
2316+
const bgRing = group.querySelector('.wheel__ring-bg');
2317+
bgRing.setAttribute('cx', cx);
2318+
bgRing.setAttribute('cy', cy);
2319+
bgRing.setAttribute('r', ringRadius);
2320+
2321+
// Update saturation arc
2322+
const satArc = group.querySelector('.wheel__saturation-ring');
2323+
const startAngle = -Math.PI / 2;
2324+
const endAngle = startAngle + saturation * Math.PI * 2;
2325+
satArc.setAttribute('d', describeArc(cx, cy, ringRadius, startAngle, endAngle));
2326+
2327+
// Update tick
2328+
const tick = group.querySelector('.wheel__ring-tick');
2329+
const tickGap = 0.5;
2330+
const tickLength = 1.5;
2331+
const tickStartX = cx + (ringRadius + tickGap) * Math.cos(endAngle);
2332+
const tickStartY = cy + (ringRadius + tickGap) * Math.sin(endAngle);
2333+
const tickEndX = tickStartX + Math.cos(endAngle) * tickLength;
2334+
const tickEndY = tickStartY + Math.sin(endAngle) * tickLength;
2335+
tick.setAttribute('x1', tickStartX);
2336+
tick.setAttribute('y1', tickStartY);
2337+
tick.setAttribute('x2', tickEndX);
2338+
tick.setAttribute('y2', tickEndY);
2339+
});
2340+
2341+
// 1. Update Anchors
21642342
let anchorCircles = Array.from($svg.querySelectorAll('.wheel__anchor'));
21652343

21662344
// Remove excess
@@ -2367,10 +2545,38 @@ <h2 class="export__title">${paletteTitle}</h2>
23672545
const x = lastX = e.offsetX / $picker.offsetWidth;
23682546
const y = lastY = e.offsetY / $picker.offsetHeight;
23692547

2548+
// Check for ring hit first (skip on touch devices and if rings not enabled)
2549+
const isTouch = window.matchMedia('(pointer: coarse)').matches;
2550+
const ringsEnabled = $picker.classList.contains('rings-enabled');
2551+
const ringHit = (isTouch || !ringsEnabled) ? null : pickRing(x, y);
2552+
if (ringHit !== null) {
2553+
const anchor = poline.anchorPoints[ringHit];
2554+
if (!anchor) return;
2555+
const cx = anchor.x * svgscale;
2556+
const cy = anchor.y * svgscale;
2557+
const svgX = x * svgscale;
2558+
const svgY = y * svgscale;
2559+
const startAngle = Math.atan2(svgY - cy, svgX - cx);
2560+
ringAdjust = {
2561+
anchorIndex: ringHit,
2562+
startSaturation: anchor.color[1],
2563+
startAngle,
2564+
prevAngle: startAngle,
2565+
accumulatedAngle: 0,
2566+
};
2567+
ringHoverIndex = ringHit;
2568+
$picker.classList.add('ring-adjusting');
2569+
updateSVG();
2570+
try { $picker.setPointerCapture(e.pointerId); } catch {}
2571+
return;
2572+
}
2573+
23702574
if (!currentPoint) {
2575+
// Larger grab distance when rings aren't enabled
2576+
const grabDistance = ringsEnabled ? 0.05 : 0.1;
23712577
currentPoint = poline.getClosestAnchorPoint({
23722578
xyz: [x, y, null],
2373-
maxDistance: .1
2579+
maxDistance: grabDistance
23742580
});
23752581
lastSelectedPoint = currentPoint;
23762582
} else {
@@ -2382,16 +2588,87 @@ <h2 class="export__title">${paletteTitle}</h2>
23822588
const x = lastX = e.offsetX / $picker.offsetWidth;
23832589
const y = lastY = e.offsetY / $picker.offsetHeight;
23842590

2591+
// Handle ring adjustment (rotary drag)
2592+
if (ringAdjust) {
2593+
const anchor = poline.anchorPoints[ringAdjust.anchorIndex];
2594+
if (!anchor) return;
2595+
const cx = anchor.x * svgscale;
2596+
const cy = anchor.y * svgscale;
2597+
const svgX = x * svgscale;
2598+
const svgY = y * svgscale;
2599+
const curAngle = Math.atan2(svgY - cy, svgX - cx);
2600+
let dA = curAngle - ringAdjust.prevAngle;
2601+
if (dA > Math.PI) dA -= Math.PI * 2;
2602+
else if (dA < -Math.PI) dA += Math.PI * 2;
2603+
ringAdjust.accumulatedAngle += dA;
2604+
ringAdjust.prevAngle = curAngle;
2605+
const turns = ringAdjust.accumulatedAngle / (Math.PI * 2);
2606+
const turnsToFull = e.shiftKey ? ROTARY_TURNS_TO_FULL_SHIFT : ROTARY_TURNS_TO_FULL;
2607+
const deltaSat = turns / turnsToFull;
2608+
let newSaturation = Math.max(0, Math.min(1, ringAdjust.startSaturation + deltaSat));
2609+
if (newSaturation > 0.99) newSaturation = 1;
2610+
if (newSaturation < 0.01) newSaturation = 0;
2611+
const atBound = newSaturation === 0 || newSaturation === 1;
2612+
const movingPastBound = (newSaturation === 1 && deltaSat > 0) || (newSaturation === 0 && deltaSat < 0);
2613+
if (atBound && movingPastBound) {
2614+
ringAdjust.startSaturation = newSaturation;
2615+
ringAdjust.accumulatedAngle = 0;
2616+
ringAdjust.prevAngle = curAngle;
2617+
}
2618+
poline.updateAnchorPoint({
2619+
point: anchor,
2620+
color: [anchor.color[0], newSaturation, anchor.color[2]]
2621+
});
2622+
updateSVG();
2623+
updateFullCode();
2624+
return;
2625+
}
2626+
23852627
if (currentPoint) {
23862628
e.stopPropagation();
23872629
poline.updateAnchorPoint({point: currentPoint, xyz: [x, y, currentPoint.z]});
23882630
updateSVG();
23892631
updateFullCode();
2390-
}
2632+
return;
2633+
}
2634+
2635+
// Handle ring hover detection (skip on touch devices and if rings not enabled)
2636+
if (!window.matchMedia('(pointer: coarse)').matches && $picker.classList.contains('rings-enabled')) {
2637+
const ringHover = pickRing(x, y);
2638+
if (ringHover !== ringHoverIndex) {
2639+
ringHoverIndex = ringHover;
2640+
$picker.classList.toggle('ring-hover', ringHover !== null);
2641+
// Just update hover classes, don't call full updateSVG
2642+
const ringGroups = $svg.querySelectorAll('.wheel__ring-group');
2643+
ringGroups.forEach((group, i) => {
2644+
group.classList.toggle('wheel__ring-group--hover', i === ringHoverIndex);
2645+
});
2646+
}
2647+
}
23912648
});
23922649

23932650
$picker.addEventListener('pointerup', (e) => {
2651+
if (ringAdjust) {
2652+
try { $picker.releasePointerCapture(e.pointerId); } catch {}
2653+
$picker.classList.remove('ring-adjusting');
2654+
}
2655+
ringAdjust = null;
23942656
currentPoint = null;
2657+
// Re-check hover state (only if rings enabled)
2658+
if ($picker.classList.contains('rings-enabled') && !window.matchMedia('(pointer: coarse)').matches) {
2659+
const x = e.offsetX / $picker.offsetWidth;
2660+
const y = e.offsetY / $picker.offsetHeight;
2661+
const ringHover = pickRing(x, y);
2662+
if (ringHover !== ringHoverIndex) {
2663+
ringHoverIndex = ringHover;
2664+
$picker.classList.toggle('ring-hover', ringHover !== null);
2665+
// Just update hover classes, don't call full updateSVG
2666+
const ringGroups = $svg.querySelectorAll('.wheel__ring-group');
2667+
ringGroups.forEach((group, i) => {
2668+
group.classList.toggle('wheel__ring-group--hover', i === ringHoverIndex);
2669+
});
2670+
}
2671+
}
23952672
});
23962673

23972674
// Color At functionality for sections that don't have their own handler
@@ -2466,6 +2743,10 @@ <h2 class="export__title">${paletteTitle}</h2>
24662743
updateSVG();
24672744
updateFullCode();
24682745
}
2746+
2747+
if (e.key === 's' || e.key === 'S') {
2748+
$picker.classList.toggle('rings-enabled');
2749+
}
24692750
});
24702751

24712752
let exStartHue = Math.random() * 360;
@@ -2476,6 +2757,8 @@ <h2 class="export__title">${paletteTitle}</h2>
24762757
section: 'intro',
24772758
fn: () => {
24782759
console.log('intro');
2760+
// Disable saturation rings when scrolling back to intro
2761+
$picker.classList.remove('rings-enabled');
24792762
poline = new Poline({
24802763
numPoints: steps,
24812764
invertedLightness: false,
@@ -2882,6 +3165,9 @@ <h2 class="export__title">${paletteTitle}</h2>
28823165
fn: (section) => {
28833166
console.log('Playground');
28843167

3168+
// Enable saturation rings
3169+
$picker.classList.add('rings-enabled');
3170+
28853171
currentHueModel = 'okhsl';
28863172
currentModelFn = hueBasedModels.find(m => m.key === currentHueModel).fn;
28873173
$models.forEach($model => $model.value = currentHueModel);

0 commit comments

Comments
 (0)