Color.Palette.Sort (Color v0.12.1)

Copy Markdown

Sort a list of colours into a perceptually-ordered sequence.

Useful when you have a heterogeneous bag of colours — brand swatches, Material role tokens, extracted image palette, etc. — and want to lay them out as a linear strip or grid in an order a human will find natural.

Four strategies are available, each with a different contract:

  • :hue_lightness (default) — "rainbow" order: hue first, then lightness. Matches the usual user expectation of a ROYGBIV ramp when the input spans multiple hues. See sort/2 for the design decisions.

  • :stepped_hue — Alan Zucconi's bucketed algorithm. Coarse rainbow order at the bucket level, with alternating-direction lightness ramps within each bucket. Produces visually continuous swatch grids at the cost of local non-monotonicity.

  • :lightness — dark → light, hue ignored. The right choice for sequential legends or contrast demos.

  • :material_pbr — splits %Color.Material{} inputs into dielectric and metallic buckets first, then applies hue + lightness ordering within each bucket, with roughness as a final tiebreaker. Plain colour inputs are treated as dielectrics with default roughness. The right choice for palettes that mix plastic and metal finishes.

All strategies operate in Oklch internally (perceptually uniform hue and lightness axes). %Color.Material{} inputs are returned as %Color.Material{} structs; other inputs are returned as %Color.SRGB{}.

Summary

Functions

Sorts a list of colours into a perceptually-ordered sequence.

Functions

sort(colors, options \\ [])

@spec sort(
  [Color.input()],
  keyword()
) :: [Color.SRGB.t()]

Sorts a list of colours into a perceptually-ordered sequence.

Arguments

  • colors is a list of values accepted by Color.new/1 — hex strings, CSS named colours, %Color.SRGB{} structs, Oklch structs, etc. Mixed types are allowed.

Options

  • :strategy is the sort algorithm. One of :hue_lightness (default), :stepped_hue, or :lightness. See the moduledoc for a description of each.

  • :chroma_threshold is the Oklch chroma below which a colour is treated as an achromatic gray rather than a chromatic hue. Grays are grouped into a separate bucket (see :grays) because their hue angle is numerically unstable and would otherwise scatter them randomly through the rainbow. Default 0.02.

  • :hue_origin is the Oklch hue angle (in degrees, 0–360) where the rainbow "starts" — that is, where the sort cuts the hue circle. Default 0.0 (red/orange region). Set to 270.0 to start the rainbow at blue/purple, for example. Applies to :hue_lightness and :stepped_hue only.

  • :grays controls where achromatic colours land in the output. :before (default) places them at the start of the list, before the chromatic rainbow. :after places them at the end. :exclude drops them entirely. Applies to :hue_lightness and :stepped_hue only.

  • :buckets is the number of hue buckets for :stepped_hue. Default 8. Only meaningful for that strategy.

  • :metallic_threshold is the cutoff between dielectric and metallic materials in (0.0, 1.0]. Default 0.5. A material with metallic >= threshold sorts into the metals bucket; below goes into dielectrics. Applies to :material_pbr only.

  • :metals controls whether the metallic bucket comes :before or :after the dielectric bucket. Default :after, matching typical PBR asset-browser layouts (plastics first, metals second). Applies to :material_pbr only.

  • :roughness_order is the tiebreaker direction when two otherwise-equal materials differ only in roughness. :glossy_first (default) puts low-roughness materials first; :matte_first reverses. Applies to :material_pbr only.

Returns

  • A list of %Color.SRGB{} structs in sorted order.

Material-aware order (:material_pbr)

When the input mixes %Color.Material{} structs (plastic, metal, varnish, ceramic), a flat colour sort puts a red plastic next to a red-anodized metal — visually the same hue but categorically different finishes. :material_pbr respects the finish cliff by sorting as a tuple:

  1. Dielectric vs metallic — split by metallic >= metallic_threshold. Dielectrics go before metals by default (:metals option to flip).

  2. Hue, then lightness — within each bucket, apply the :hue_lightness logic on the material's base colour. Near-gray materials form their own sub-bucket within each metallic group.

  3. Roughness tiebreaker — when materials tie on hue and lightness (e.g., gloss-red vs matte-red), order by roughness. :glossy_first (default) puts mirrors before matte; :matte_first reverses.

Plain colour inputs (hex strings, %Color.SRGB{} structs, etc.) are treated as implicit dielectrics with roughness = 0.5. A mixed list of materials and plain colours sorts them together into the dielectric bucket.

Rainbow order (:hue_lightness)

The default strategy produces a linear rainbow and is worth describing precisely because several small decisions affect what a user sees.

  1. Convert to Oklch. Hue angles come from Oklch, not HSL, because Oklch's hue axis is perceptually uniform. In HSL, yellow occupies a tiny sliver and green dominates; in Oklch the visual spacing of a sorted rainbow matches the numerical spacing of the sort.

  2. Partition by chroma. Colours with C < chroma_threshold are considered achromatic. Near-grays have mathematically unstable hue (a 0.001 change in chroma can flip the angle wildly), so sorting them with the chromatic colours scatters them across the rainbow in visually random places. The gray bucket is sorted independently by lightness and placed at the start (or end, via :grays).

  3. Sort the chromatic bucket. Primary key is (H - hue_origin) mod 360, ascending. Secondary key is lightness L, ascending — so within a single hue, the darkest shade comes first. This produces ROYGBIV → wrap when hue_origin = 0.

  4. The wraparound caveat. A hue circle has no start or end; the sort has to cut it somewhere. The first and last colours in the output are therefore neighbours on the hue wheel but visually far apart in a linear strip. This is intrinsic to projecting a circle onto a line — pick hue_origin to put the cut somewhere your input doesn't cross, if you can.

  5. Known surprises. Brown sits near orange and yellow in any hue-based sort (brown is dark orange/yellow); users who mentally file brown as its own category will find its placement unexpected. Magenta is a non-spectral colour and lands adjacent to blue and red in hue-angle space, which is topologically correct but may surprise users who expect a strict wavelength ordering.

Examples

iex> hexes = ["#808080", "#ff0000", "#00ff00", "#0000ff", "#ffff00"]
iex> hexes |> Color.Palette.Sort.sort() |> Enum.map(&Color.to_hex/1)
["#808080", "#ff0000", "#ffff00", "#00ff00", "#0000ff"]

iex> hexes = ["#ff0000", "#ffff00", "#00ff00"]
iex> hexes |> Color.Palette.Sort.sort(strategy: :lightness) |> Enum.map(&Color.to_hex/1)
["#ff0000", "#00ff00", "#ffff00"]

iex> hexes = ["#ff0000", "#0000ff"]
iex> hexes |> Color.Palette.Sort.sort(grays: :exclude) |> Enum.map(&Color.to_hex/1)
["#ff0000", "#0000ff"]

iex> items = [
...>   Color.Material.new("#ffd700", metallic: 1.0, roughness: 0.05, name: "gold"),
...>   Color.Material.new("#ff0000", metallic: 0.0, roughness: 0.6, name: "red plastic")
...> ]
iex> items
...> |> Color.Palette.Sort.sort(strategy: :material_pbr)
...> |> Enum.map(& &1.name)
["red plastic", "gold"]