# `Color.Palette.Sort`

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{}`.

# `sort`

```elixir
@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"]

---

*Consult [api-reference.md](api-reference.md) for complete listing*
