@@ -30,12 +30,23 @@ defmodule Color.Palette.Tonal do
3030 human vision perceives lightness — a phenomenon known as the
3131 Hunt effect — and gives the scale a more natural feel.
3232
33- 5. **Snap to seed**. Find the generated stop whose lightness is
33+ 5. **Cap to a fraction of the gamut envelope**. When
34+ `:chroma_ceiling` is below `1.0`, cap each stop's chroma at
35+ `ceiling × max_chroma(L, H, gamut)`. This produces a more
36+ muted ramp that sits strictly inside the gamut boundary
37+ instead of hugging it. At the default `1.0` this step is a
38+ no-op.
39+
40+ 6. **Snap to seed**. Find the generated stop whose lightness is
3441 closest to the seed's lightness and replace it with the seed
3542 itself. The `:seed_stop` field on the resulting struct
36- records which stop received the seed.
43+ records which stop received the seed. Note that when
44+ `:chroma_ceiling` is below `1.0` and the seed sits near the
45+ gamut boundary, the seed will be visibly more saturated than
46+ its capped neighbours — pick a wider `:gamut` instead if you
47+ want a smoother ramp without muting the seed.
3748
38- 6 . **Gamut-map** each stop into the requested working space
49+ 7 . **Gamut-map** each stop into the requested working space
3950 (default `:SRGB`) using the CSS Color 4 Oklch binary-search
4051 algorithm provided by `Color.Gamut.to_gamut/3`.
4152
@@ -51,11 +62,11 @@ defmodule Color.Palette.Tonal do
5162
5263 iex> palette = Color.Palette.Tonal.new("#3b82f6")
5364 iex> Map.fetch!(palette.stops, 50) |> Color.to_hex()
54- "#f4f9ff "
65+ "#f5f9ff "
5566
5667 iex> palette = Color.Palette.Tonal.new("#3b82f6")
5768 iex> Map.fetch!(palette.stops, 950) |> Color.to_hex()
58- "#000825 "
69+ "#000827 "
5970
6071 """
6172
@@ -123,13 +134,21 @@ defmodule Color.Palette.Tonal do
123134 dark_anchor = Keyword . fetch! ( options , :dark_anchor )
124135 hue_drift? = Keyword . fetch! ( options , :hue_drift )
125136 gamut = Keyword . fetch! ( options , :gamut )
137+ chroma_ceiling = Keyword . fetch! ( options , :chroma_ceiling )
126138
127139 base_h = seed_oklch . h || 0.0
128140 base_c = seed_oklch . c || 0.0
129141
130142 seed_l = seed_oklch . l || 0.5
131143 num_stops = length ( stops )
132144
145+ # Normalise the chroma ceiling so the damping curve passes
146+ # through the seed's chroma at the seed's lightness. Without
147+ # this, generated neighbours are damped by sin(π · L) while the
148+ # seed itself keeps its raw chroma, leaving the seed visibly
149+ # more saturated than its neighbours.
150+ peak_c = peak_chroma ( base_c , seed_l )
151+
133152 # First pass: find which stop the seed would snap to, and
134153 # compute the curve lightness at that position.
135154 snap_index = nearest_index ( stops , light_anchor , dark_anchor , seed_l )
@@ -165,9 +184,16 @@ defmodule Color.Palette.Tonal do
165184 lerp ( seed_l , dark_anchor , t )
166185 end
167186
168- c = base_c * chroma_damping ( l )
187+ curve_c = peak_c * chroma_damping ( l )
169188 h = if hue_drift? , do: drift_hue ( base_h , position ) , else: base_h
170189
190+ c =
191+ if chroma_ceiling < 1.0 do
192+ min ( curve_c , chroma_ceiling * max_chroma_at ( l , h , gamut ) )
193+ else
194+ curve_c
195+ end
196+
171197 oklch = % Color.Oklch { l: l , c: c , h: h , alpha: seed_srgb . alpha }
172198 { :ok , mapped } = Color.Gamut . to_gamut ( oklch , gamut )
173199 { label , mapped , l }
@@ -475,6 +501,38 @@ defmodule Color.Palette.Tonal do
475501 # and dark shades don't blow past the sRGB gamut.
476502 defp chroma_damping ( l ) , do: :math . sin ( :math . pi ( ) * clamp01 ( l ) )
477503
504+ # Solve for the pre-damping chroma ceiling that makes
505+ # `peak_c · chroma_damping(seed_l) = seed_c`. Floor the divisor
506+ # so seeds with extreme lightness don't blow peak_c up to
507+ # unreasonable values.
508+ @ min_seed_damping 0.05
509+ defp peak_chroma ( base_c , seed_l ) do
510+ damping = chroma_damping ( seed_l )
511+ if damping > @ min_seed_damping , do: base_c / damping , else: base_c
512+ end
513+
514+ # Binary-search for the maximum chroma that is in gamut at the
515+ # given lightness and hue, for the given working space. Used
516+ # only when :chroma_ceiling is below 1.0.
517+ @ max_chroma_search_upper 0.5
518+ @ max_chroma_search_iters 25
519+ defp max_chroma_at ( l , h , gamut ) do
520+ do_max_chroma_search ( l , h , gamut , 0.0 , @ max_chroma_search_upper , @ max_chroma_search_iters )
521+ end
522+
523+ defp do_max_chroma_search ( _l , _h , _gamut , lo , _hi , 0 ) , do: lo
524+
525+ defp do_max_chroma_search ( l , h , gamut , lo , hi , iters ) do
526+ mid = ( lo + hi ) / 2
527+ test = % Color.Oklch { l: l , c: mid , h: h , alpha: 1.0 }
528+
529+ if Color.Gamut . in_gamut? ( test , gamut ) do
530+ do_max_chroma_search ( l , h , gamut , mid , hi , iters - 1 )
531+ else
532+ do_max_chroma_search ( l , h , gamut , lo , mid , iters - 1 )
533+ end
534+ end
535+
478536 # Drift hue toward yellow at the light end and toward blue at
479537 # the dark end. The drift amount is small (a few degrees) and
480538 # scales with distance from the midpoint.
@@ -527,7 +585,15 @@ defmodule Color.Palette.Tonal do
527585
528586 # ---- options validation -------------------------------------------------
529587
530- @ valid_keys [ :stops , :light_anchor , :dark_anchor , :hue_drift , :gamut , :name ]
588+ @ valid_keys [
589+ :stops ,
590+ :light_anchor ,
591+ :dark_anchor ,
592+ :hue_drift ,
593+ :gamut ,
594+ :chroma_ceiling ,
595+ :name
596+ ]
531597
532598 defp validate_options! ( options ) do
533599 Enum . each ( Keyword . keys ( options ) , fn key ->
@@ -545,6 +611,7 @@ defmodule Color.Palette.Tonal do
545611 |> Keyword . put_new ( :dark_anchor , @ default_dark_anchor )
546612 |> Keyword . put_new ( :hue_drift , false )
547613 |> Keyword . put_new ( :gamut , :SRGB )
614+ |> Keyword . put_new ( :chroma_ceiling , 1.0 )
548615
549616 stops = Keyword . fetch! ( options , :stops )
550617
@@ -589,6 +656,14 @@ defmodule Color.Palette.Tonal do
589656 detail: ":light_anchor (#{ light } ) must be greater than :dark_anchor (#{ dark } )"
590657 end
591658
659+ ceiling = Keyword . fetch! ( options , :chroma_ceiling )
660+
661+ unless is_number ( ceiling ) and ceiling > 0.0 and ceiling <= 1.0 do
662+ raise Color.PaletteError ,
663+ reason: :invalid_chroma_ceiling ,
664+ detail: ":chroma_ceiling must be a number in (0.0, 1.0]"
665+ end
666+
592667 options
593668 end
594669end
0 commit comments