Skip to content

Commit 9649e67

Browse files
committed
Add :chroma_ceiling option and expose :gamut in tonal visualizer
1 parent a909ea2 commit 9649e67

4 files changed

Lines changed: 135 additions & 16 deletions

File tree

lib/color/palette.ex

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,16 @@ defmodule Color.Palette do
7272
matching how human vision perceives lightness. Default `false`.
7373
7474
* `:gamut` is the working space to gamut-map each stop into,
75-
default `:SRGB`.
75+
default `:SRGB`. Widening the gamut (for example `:P3_D65` or
76+
`:Rec2020`) gives non-seed stops more chroma headroom and
77+
produces a smoother ramp for saturated seeds, at the cost of
78+
colours that may not display accurately on sRGB-only monitors.
79+
80+
* `:chroma_ceiling` is a float in `(0.0, 1.0]` that caps each
81+
stop's chroma at `ceiling × max_chroma(L, H, gamut)`. The
82+
default `1.0` lets stops hug the gamut boundary. Lowering it
83+
(for example `0.85`) produces a more muted, evenly
84+
saturated-looking ramp.
7685
7786
* `:name` is an optional string label stored on the struct.
7887

lib/color/palette/tonal.ex

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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
594669
end

lib/color/palette/visualizer.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ defmodule Color.Palette.Visualizer do
138138
%{
139139
seed: resolve_seed(params, "#3b82f6"),
140140
hue_drift: truthy?(Map.get(params, "hue_drift")),
141+
gamut: gamut_atom(Map.get(params, "gamut")) || :SRGB,
142+
chroma_ceiling: number_default(Map.get(params, "chroma_ceiling"), 1.0),
141143
name: Map.get(params, "name") |> blank_default(nil)
142144
}
143145
end

lib/color/palette/visualizer/tonal_view.ex

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,24 @@ defmodule Color.Palette.Visualizer.TonalView do
44
alias Color.Palette.Tonal
55
alias Color.Palette.Visualizer.Render
66

7+
@gamut_options [
8+
{:SRGB, "sRGB"},
9+
{:P3_D65, "P3 (D65)"},
10+
{:Rec2020, "Rec2020"},
11+
{:Adobe, "Adobe RGB"},
12+
{:ProPhoto, "ProPhoto"}
13+
]
14+
715
def render(params, base) do
816
seed = Map.get(params, :seed, "#3b82f6")
917
hue_drift? = Map.get(params, :hue_drift, false)
18+
gamut = Map.get(params, :gamut, :SRGB)
19+
chroma_ceiling = Map.get(params, :chroma_ceiling, 1.0)
1020
name = Map.get(params, :name)
1121
error = Map.get(params, :error)
1222

1323
body =
14-
case build(seed, hue_drift?, name) do
24+
case build(seed, hue_drift?, gamut, chroma_ceiling, name) do
1525
{:ok, palette} -> success_body(palette)
1626
{:error, message} -> ["<div class=\"vz-error\">", Render.escape(message), "</div>"]
1727
end
@@ -23,16 +33,39 @@ defmodule Color.Palette.Visualizer.TonalView do
2333
body: body,
2434
error: error,
2535
base: base,
26-
extra_fields: [
27-
"<label><input type=\"checkbox\" name=\"hue_drift\" value=\"1\"",
28-
if(hue_drift?, do: " checked", else: ""),
29-
"> hue drift</label>"
30-
]
36+
extra_fields: extra_fields(hue_drift?, gamut, chroma_ceiling)
3137
)
3238
end
3339

34-
defp build(seed, hue_drift?, name) do
35-
options = [hue_drift: hue_drift?]
40+
defp extra_fields(hue_drift?, gamut, chroma_ceiling) do
41+
[
42+
"<label>gamut <select name=\"gamut\">",
43+
Enum.map(@gamut_options, fn {atom, label} ->
44+
selected = if atom == gamut, do: " selected", else: ""
45+
["<option value=\"", Atom.to_string(atom), "\"", selected, ">", label, "</option>"]
46+
end),
47+
"</select></label>",
48+
"<label>chroma ceiling <input type=\"number\" name=\"chroma_ceiling\"",
49+
" min=\"0.1\" max=\"1.0\" step=\"0.05\" value=\"",
50+
format_ceiling(chroma_ceiling),
51+
"\"></label>",
52+
"<label><input type=\"checkbox\" name=\"hue_drift\" value=\"1\"",
53+
if(hue_drift?, do: " checked", else: ""),
54+
"> hue drift</label>"
55+
]
56+
end
57+
58+
defp format_ceiling(value) when is_float(value), do: :erlang.float_to_binary(value, decimals: 2)
59+
defp format_ceiling(value) when is_integer(value), do: Integer.to_string(value)
60+
defp format_ceiling(value), do: to_string(value)
61+
62+
defp build(seed, hue_drift?, gamut, chroma_ceiling, name) do
63+
options = [
64+
hue_drift: hue_drift?,
65+
gamut: gamut,
66+
chroma_ceiling: chroma_ceiling
67+
]
68+
3669
options = if name, do: [{:name, name} | options], else: options
3770
{:ok, Tonal.new(seed, options)}
3871
rescue

0 commit comments

Comments
 (0)