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 ;
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