diff --git a/cpp/palettes/tones.cc b/cpp/palettes/tones.cc index bfd4a142..cc5d1b93 100644 --- a/cpp/palettes/tones.cc +++ b/cpp/palettes/tones.cc @@ -27,7 +27,7 @@ TonalPalette::TonalPalette(Argb argb) : key_color_(0.0, 0.0, 0.0) { Cam cam = CamFromInt(argb); hue_ = cam.hue; chroma_ = cam.chroma; - key_color_ = createKeyColor(cam.hue, cam.chroma); + key_color_ = KeyColor(cam.hue, cam.chroma).create(); } TonalPalette::TonalPalette(Hct hct) @@ -40,7 +40,7 @@ TonalPalette::TonalPalette(double hue, double chroma) : key_color_(hue, chroma, 0.0) { hue_ = hue; chroma_ = chroma; - key_color_ = createKeyColor(hue, chroma); + key_color_ = KeyColor(hue, chroma).create(); } TonalPalette::TonalPalette(double hue, double chroma, Hct key_color) @@ -54,38 +54,63 @@ Argb TonalPalette::get(double tone) const { return IntFromHcl(hue_, chroma_, tone); } -Hct TonalPalette::createKeyColor(double hue, double chroma) { - double start_tone = 50.0; - Hct smallest_delta_hct(hue, chroma, start_tone); - double smallest_delta = abs(smallest_delta_hct.get_chroma() - chroma); - // Starting from T50, check T+/-delta to see if they match the requested - // chroma. - // - // Starts from T50 because T50 has the most chroma available, on - // average. Thus it is most likely to have a direct answer and minimize - // iteration. - for (double delta = 1.0; delta < 50.0; delta += 1.0) { - // Termination condition rounding instead of minimizing delta to avoid - // case where requested chroma is 16.51, and the closest chroma is 16.49. - // Error is minimized, but when rounded and displayed, requested chroma - // is 17, key color's chroma is 16. - if (round(chroma) == round(smallest_delta_hct.get_chroma())) { - return smallest_delta_hct; - } - Hct hct_add(hue, chroma, start_tone + delta); - double hct_add_delta = abs(hct_add.get_chroma() - chroma); - if (hct_add_delta < smallest_delta) { - smallest_delta = hct_add_delta; - smallest_delta_hct = hct_add; - } - Hct hct_subtract(hue, chroma, start_tone - delta); - double hct_subtract_delta = abs(hct_subtract.get_chroma() - chroma); - if (hct_subtract_delta < smallest_delta) { - smallest_delta = hct_subtract_delta; - smallest_delta_hct = hct_subtract; +KeyColor::KeyColor(double hue, double requested_chroma) + : hue_(hue), requested_chroma_(requested_chroma) {} + +Hct KeyColor::create() { + // Pivot around T50 because T50 has the most chroma available, on + // average. Thus it is most likely to have a direct answer. + const int pivot_tone = 50; + const int tone_step_size = 1; + // Epsilon to accept values slightly higher than the requested chroma. + const double epsilon = 0.01; + + // Binary search to find the tone that can provide a chroma that is closest + // to the requested chroma. + int lower_tone = 0; + int upper_tone = 100; + while (lower_tone < upper_tone) { + const int mid_tone = (lower_tone + upper_tone) / 2; + bool is_ascending = + max_chroma(mid_tone) < max_chroma(mid_tone + tone_step_size); + bool sufficient_chroma = + max_chroma(mid_tone) >= requested_chroma_ - epsilon; + + if (sufficient_chroma) { + // Either range [lower_tone, mid_tone] or [mid_tone, upper_tone] has + // the answer, so search in the range that is closer the pivot tone. + if (abs(lower_tone - pivot_tone) < abs(upper_tone - pivot_tone)) { + upper_tone = mid_tone; + } else { + if (lower_tone == mid_tone) { + return Hct(hue_, requested_chroma_, lower_tone); + } + lower_tone = mid_tone; + } + } else { + // As there's no sufficient chroma in the mid_tone, follow the direction + // to the chroma peak. + if (is_ascending) { + lower_tone = mid_tone + tone_step_size; + } else { + // Keep mid_tone for potential chroma peak. + upper_tone = mid_tone; + } } } - return smallest_delta_hct; + + return Hct(hue_, requested_chroma_, lower_tone); } +double KeyColor::max_chroma(double tone) { + auto it = chroma_cache_.find(tone); + if (it != chroma_cache_.end()) { + return it->second; + } + + double chroma = Hct(hue_, max_chroma_value_, tone).get_chroma(); + chroma_cache_[tone] = chroma; + return chroma; +}; + } // namespace material_color_utilities diff --git a/cpp/palettes/tones.h b/cpp/palettes/tones.h index 71448bf6..2c4e64d1 100644 --- a/cpp/palettes/tones.h +++ b/cpp/palettes/tones.h @@ -17,6 +17,8 @@ #ifndef CPP_PALETTES_TONES_H_ #define CPP_PALETTES_TONES_H_ +#include + #include "cpp/cam/hct.h" #include "cpp/utils/utils.h" @@ -45,8 +47,31 @@ class TonalPalette { double hue_; double chroma_; Hct key_color_; +}; + +/** + * Key color is a color that represents the hue and chroma of a tonal palette + */ +class KeyColor { + public: + KeyColor(double hue, double requested_chroma); + /** + * Creates a key color from a [hue] and a [chroma]. + * The key color is the first tone, starting from T50, matching the given hue + * and chroma. + * + * @return Key color in Hct. + */ + Hct create(); + + private: + const double max_chroma_value_ = 200.0; + double hue_; + double requested_chroma_; + // Cache that maps tone to max chroma to avoid duplicated HCT calculation. + std::unordered_map chroma_cache_; - Hct createKeyColor(double hue, double chroma); + double max_chroma(double tone); }; } // namespace material_color_utilities diff --git a/cpp/palettes/tones_test.cc b/cpp/palettes/tones_test.cc index bf419bab..8da3d11f 100644 --- a/cpp/palettes/tones_test.cc +++ b/cpp/palettes/tones_test.cc @@ -17,6 +17,7 @@ #include "cpp/palettes/tones.h" #include "testing/base/public/gunit.h" +#include "cpp/cam/hct.h" #include "cpp/utils/utils.h" namespace material_color_utilities { @@ -40,5 +41,45 @@ TEST(TonesTest, Blue) { EXPECT_EQ(HexFromArgb(tonal_palette.get(0)), "ff000000"); } +TEST(KeyColorTests, ExactChromaAvailable) { + // Requested chroma is exactly achievable at a certain tone. + TonalPalette palette = TonalPalette(50.0, 60.0); + Hct result = palette.get_key_color(); + + EXPECT_NEAR(result.get_hue(), 50.0, 10.0); + EXPECT_NEAR(result.get_chroma(), 60.0, 0.5); + // Tone might vary, but should be within the range from 0 to 100. + EXPECT_GT(result.get_tone(), 0); + EXPECT_LT(result.get_tone(), 100); +} + +TEST(KeyColorTests, UnusuallyHighChroma) { + // Requested chroma is above what is achievable. For Hue 149, chroma peak + // is 89.6 at Tone 87.9. The result key color's chroma should be close to the + // chroma peak. + TonalPalette palette = TonalPalette(149.0, 200.0); + Hct result = palette.get_key_color(); + + EXPECT_NEAR(result.get_hue(), 149.0, 10.0); + EXPECT_GT(result.get_chroma(), 89.0); + // Tone might vary, but should be within the range from 0 to 100. + EXPECT_GT(result.get_tone(), 0); + EXPECT_LT(result.get_tone(), 100); +} + +TEST(KeyColorTests, UnusuallyLowChroma) { + // By definition, the key color should be the first tone, starting from Tone + // 50, matching the given hue and chroma. When requesting a very low chroma, + // the result should be close to Tone 50, since most tones can produce a low + // chroma. + TonalPalette palette = TonalPalette(50.0, 3.0); + Hct result = palette.get_key_color(); + + // Higher error tolerance for hue when the requested chroma is unusually low. + EXPECT_NEAR(result.get_hue(), 50.0, 10.0); + EXPECT_NEAR(result.get_chroma(), 3.0, 0.5); + EXPECT_NEAR(result.get_tone(), 50.0, 0.5); +} + } // namespace } // namespace material_color_utilities diff --git a/dart/CHANGELOG.md b/dart/CHANGELOG.md index e452f9d0..991ad522 100644 --- a/dart/CHANGELOG.md +++ b/dart/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.11.2 - 2024-04-30 +### Changed +- Updated `TonalPalette` to use new key color algorithm. + ## 0.11.1 - 2024-03-11 ### Fixed - Fixed Apache license diff --git a/dart/lib/palettes/tonal_palette.dart b/dart/lib/palettes/tonal_palette.dart index 359f28b0..026d5e93 100644 --- a/dart/lib/palettes/tonal_palette.dart +++ b/dart/lib/palettes/tonal_palette.dart @@ -63,12 +63,12 @@ class TonalPalette { TonalPalette._fromHueAndChroma(this.hue, this.chroma) : _cache = {}, - keyColor = createKeyColor(hue, chroma), + keyColor = KeyColor(hue, chroma).create(), _isFromCache = false; TonalPalette._fromCache(Map cache, this.hue, this.chroma) : _cache = cache, - keyColor = createKeyColor(hue, chroma), + keyColor = KeyColor(hue, chroma).create(), _isFromCache = true; /// Create colors using [hue] and [chroma]. @@ -113,46 +113,6 @@ class TonalPalette { return TonalPalette._fromCache(cache, bestHue, bestChroma); } - /// Creates a key color from a [hue] and a [chroma]. - /// The key color is the first tone, starting from T50, matching the given hue and chroma. - /// Key color [Hct] - static Hct createKeyColor(double hue, double chroma) { - double startTone = 50.0; - Hct smallestDeltaHct = Hct.from(hue, chroma, startTone); - double smallestDelta = (smallestDeltaHct.chroma - chroma).abs(); - // Starting from T50, check T+/-delta to see if they match the requested - // chroma. - // - // Starts from T50 because T50 has the most chroma available, on - // average. Thus it is most likely to have a direct answer and minimize - // iteration. - for (double delta = 1.0; delta < 50.0; delta += 1.0) { - // Termination condition rounding instead of minimizing delta to avoid - // case where requested chroma is 16.51, and the closest chroma is 16.49. - // Error is minimized, but when rounded and displayed, requested chroma - // is 17, key color's chroma is 16. - if (chroma.round() == smallestDeltaHct.chroma.round()) { - return smallestDeltaHct; - } - - final Hct hctAdd = Hct.from(hue, chroma, startTone + delta); - final double hctAddDelta = (hctAdd.chroma - chroma).abs(); - if (hctAddDelta < smallestDelta) { - smallestDelta = hctAddDelta; - smallestDeltaHct = hctAdd; - } - - final Hct hctSubtract = Hct.from(hue, chroma, startTone - delta); - final double hctSubtractDelta = (hctSubtract.chroma - chroma).abs(); - if (hctSubtractDelta < smallestDelta) { - smallestDelta = hctSubtractDelta; - smallestDeltaHct = hctSubtract; - } - } - - return smallestDeltaHct; - } - /// Returns a fixed-size list of ARGB color ints for common tone values. /// /// Inverse of [fromList]. @@ -222,3 +182,73 @@ class TonalPalette { } } } + +/// Key color is a color that represents the hue and chroma of a tonal palette. +class KeyColor { + final double hue; + final double requestedChroma; + + /// Cache that maps (hue, tone) to max chroma to avoid duplicated HCT + /// calculation. + final Map _chromaCache = {}; + final double _maxChromaValue = 200.0; + + KeyColor(this.hue, this.requestedChroma); + + /// Creates a key color from a [hue] and a [chroma]. + /// The key color is the first tone, starting from T50, matching the given hue + /// and chroma. + /// + /// @return Key color [Hct] + Hct create() { + // Pivot around T50 because T50 has the most chroma available, on + // average. Thus it is most likely to have a direct answer. + const int pivotTone = 50; + const int toneStepSize = 1; + // Epsilon to accept values slightly higher than the requested chroma. + const double epsilon = 0.01; + + // Binary search to find the tone that can provide a chroma that is closest + // to the requested chroma. + int lowerTone = 0; + int upperTone = 100; + while (lowerTone < upperTone) { + final int midTone = (lowerTone + upperTone) ~/ 2; + final bool isAscending = + _maxChroma(midTone) < _maxChroma(midTone + toneStepSize); + final bool sufficientChroma = + _maxChroma(midTone) >= requestedChroma - epsilon; + + if (sufficientChroma) { + // Either range [lowerTone, midTone] or [midTone, upperTone] has + // the answer, so search in the range that is closer the pivot tone. + if ((lowerTone - pivotTone).abs() < (upperTone - pivotTone).abs()) { + upperTone = midTone; + } else { + if (lowerTone == midTone) { + return Hct.from(hue, requestedChroma, lowerTone.toDouble()); + } + lowerTone = midTone; + } + } else { + // As there is no sufficient chroma in the midTone, follow the direction + // to the chroma peak. + if (isAscending) { + lowerTone = midTone + toneStepSize; + } else { + // Keep midTone for potential chroma peak. + upperTone = midTone; + } + } + } + + return Hct.from(hue, requestedChroma, lowerTone.toDouble()); + } + + // Find the maximum chroma for a given tone + double _maxChroma(int tone) { + return _chromaCache.putIfAbsent(tone, () { + return Hct.from(hue, _maxChromaValue, tone.toDouble()).chroma; + }); + } +} diff --git a/dart/pubspec.yaml b/dart/pubspec.yaml index d1618834..d0b13d16 100644 --- a/dart/pubspec.yaml +++ b/dart/pubspec.yaml @@ -14,7 +14,7 @@ name: material_color_utilities description: Algorithms and utilities that power the Material Design 3 color system, including choosing theme colors from images and creating tones of colors; all in a new color space. -version: 0.11.1 +version: 0.11.2 repository: https://github.com/material-foundation/material-color-utilities/tree/main/dart issue_tracker: https://github.com/material-foundation/material-color-utilities/issues screenshots: diff --git a/dart/test/palettes_test.dart b/dart/test/palettes_test.dart index 360063c4..869d5ba0 100644 --- a/dart/test/palettes_test.dart +++ b/dart/test/palettes_test.dart @@ -316,4 +316,44 @@ void main() { expect(core.secondary.get(0), 0xff000000); }); }); + + group('KeyColor', () { + test('exact chroma is available', () { + // Requested chroma is exactly achievable at a certain tone. + final palette = TonalPalette.of(50.0, 60.0); + final result = palette.keyColor; + + expect(result.hue, closeTo(50.0, 10.0)); + expect(result.chroma, closeTo(60.0, 0.5)); + // Tone might vary, but should be within the range from 0 to 100. + expect(result.tone, greaterThan(0)); + expect(result.tone, lessThan(100)); + }); + + test('requesting unusually high chroma', () { + // Requested chroma is above what is achievable. For Hue 149, chroma peak is 89.6 at Tone 87.9. + // The result key color's chroma should be close to the chroma peak. + final palette = TonalPalette.of(149.0, 200.0); + final result = palette.keyColor; + + expect(result.hue, closeTo(149.0, 10.0)); + expect(result.chroma, greaterThan(89.0)); + // Tone might vary, but should be within the range from 0 to 100. + expect(result.tone, greaterThan(0)); + expect(result.tone, lessThan(100)); + }); + + test('requesting unusually low chroma', () { + // By definition, the key color should be the first tone, starting from Tone 50, matching the + // given hue and chroma. When requesting a very low chroma, the result should be close to Tone + // 50, since most tones can produce a low chroma. + final palette = TonalPalette.of(50.0, 3.0); + final result = palette.keyColor; + + // Higher error tolerance for hue when the requested chroma is unusually low. + expect(result.hue, closeTo(50.0, 10.0)); + expect(result.chroma, closeTo(3.0, 0.5)); + expect(result.tone, closeTo(50.0, 0.5)); + }); + }); } diff --git a/java/palettes/TonalPalette.java b/java/palettes/TonalPalette.java index 3347c78e..e3cfac0d 100644 --- a/java/palettes/TonalPalette.java +++ b/java/palettes/TonalPalette.java @@ -57,7 +57,8 @@ public static TonalPalette fromHct(Hct hct) { * @return Tones matching hue and chroma. */ public static TonalPalette fromHueAndChroma(double hue, double chroma) { - return new TonalPalette(hue, chroma, createKeyColor(hue, chroma)); + final Hct keyColor = new KeyColor(hue, chroma).create(); + return new TonalPalette(hue, chroma, keyColor); } private TonalPalette(double hue, double chroma, Hct keyColor) { @@ -67,44 +68,6 @@ private TonalPalette(double hue, double chroma, Hct keyColor) { this.keyColor = keyColor; } - /** The key color is the first tone, starting from T50, matching the given hue and chroma. */ - private static Hct createKeyColor(double hue, double chroma) { - double startTone = 50.0; - Hct smallestDeltaHct = Hct.from(hue, chroma, startTone); - double smallestDelta = Math.abs(smallestDeltaHct.getChroma() - chroma); - // Starting from T50, check T+/-delta to see if they match the requested - // chroma. - // - // Starts from T50 because T50 has the most chroma available, on - // average. Thus it is most likely to have a direct answer and minimize - // iteration. - for (double delta = 1.0; delta < 50.0; delta += 1.0) { - // Termination condition rounding instead of minimizing delta to avoid - // case where requested chroma is 16.51, and the closest chroma is 16.49. - // Error is minimized, but when rounded and displayed, requested chroma - // is 17, key color's chroma is 16. - if (Math.round(chroma) == Math.round(smallestDeltaHct.getChroma())) { - return smallestDeltaHct; - } - - final Hct hctAdd = Hct.from(hue, chroma, startTone + delta); - final double hctAddDelta = Math.abs(hctAdd.getChroma() - chroma); - if (hctAddDelta < smallestDelta) { - smallestDelta = hctAddDelta; - smallestDeltaHct = hctAdd; - } - - final Hct hctSubtract = Hct.from(hue, chroma, startTone - delta); - final double hctSubtractDelta = Math.abs(hctSubtract.getChroma() - chroma); - if (hctSubtractDelta < smallestDelta) { - smallestDelta = hctSubtractDelta; - smallestDeltaHct = hctSubtract; - } - } - - return smallestDeltaHct; - } - /** * Create an ARGB color with HCT hue and chroma of this Tones instance, and the provided HCT tone. * @@ -141,4 +104,75 @@ public double getHue() { public Hct getKeyColor() { return this.keyColor; } + + /** Key color is a color that represents the hue and chroma of a tonal palette. */ + private static final class KeyColor { + private final double hue; + private final double requestedChroma; + + // Cache that maps tone to max chroma to avoid duplicated HCT calculation. + private final Map chromaCache = new HashMap<>(); + private static final double MAX_CHROMA_VALUE = 200.0; + + /** Key color is a color that represents the hue and chroma of a tonal palette */ + public KeyColor(double hue, double requestedChroma) { + this.hue = hue; + this.requestedChroma = requestedChroma; + } + + /** + * Creates a key color from a [hue] and a [chroma]. The key color is the first tone, starting + * from T50, matching the given hue and chroma. + * + * @return Key color [Hct] + */ + public Hct create() { + // Pivot around T50 because T50 has the most chroma available, on + // average. Thus it is most likely to have a direct answer. + final int pivotTone = 50; + final int toneStepSize = 1; + // Epsilon to accept values slightly higher than the requested chroma. + final double epsilon = 0.01; + + // Binary search to find the tone that can provide a chroma that is closest + // to the requested chroma. + int lowerTone = 0; + int upperTone = 100; + while (lowerTone < upperTone) { + final int midTone = (lowerTone + upperTone) / 2; + boolean isAscending = maxChroma(midTone) < maxChroma(midTone + toneStepSize); + boolean sufficientChroma = maxChroma(midTone) >= requestedChroma - epsilon; + + if (sufficientChroma) { + // Either range [lowerTone, midTone] or [midTone, upperTone] has + // the answer, so search in the range that is closer the pivot tone. + if (Math.abs(lowerTone - pivotTone) < Math.abs(upperTone - pivotTone)) { + upperTone = midTone; + } else { + if (lowerTone == midTone) { + return Hct.from(this.hue, this.requestedChroma, lowerTone); + } + lowerTone = midTone; + } + } else { + // As there is no sufficient chroma in the midTone, follow the direction to the chroma + // peak. + if (isAscending) { + lowerTone = midTone + toneStepSize; + } else { + // Keep midTone for potential chroma peak. + upperTone = midTone; + } + } + } + + return Hct.from(this.hue, this.requestedChroma, lowerTone); + } + + // Find the maximum chroma for a given tone + private double maxChroma(int tone) { + return chromaCache.computeIfAbsent( + tone, (Integer key) -> Hct.from(hue, MAX_CHROMA_VALUE, key).getChroma()); + } + } } diff --git a/swift/Sources/MaterialColorUtilities/Palettes/TonalPalette.swift b/swift/Sources/MaterialColorUtilities/Palettes/TonalPalette.swift index 9e1a7608..da951263 100644 --- a/swift/Sources/MaterialColorUtilities/Palettes/TonalPalette.swift +++ b/swift/Sources/MaterialColorUtilities/Palettes/TonalPalette.swift @@ -80,7 +80,7 @@ public class TonalPalette: Equatable, Hashable { self.init( hue: hue, chroma: chroma, - keyColor: TonalPalette.createKeyColor(hue, chroma), + keyColor: KeyColor(hue: hue, requestedChroma: chroma).create(), cache: [:] ) } @@ -100,47 +100,7 @@ public class TonalPalette: Equatable, Hashable { return TonalPalette(hue: hue, chroma: chroma) } - /// Creates a key color from a [hue] and a [chroma]. - /// The key color is the first tone, starting from T50, matching the given hue and chroma. - /// Key color [Hct] - static func createKeyColor(_ hue: Double, _ chroma: Double) -> Hct { - let startTone: Double = 50.0 - var smallestDeltaHct = Hct.from(hue, chroma, startTone) - var smallestDelta: Double = abs(smallestDeltaHct.chroma - chroma) - // Starting from T50, check T+/-delta to see if they match the requested - // chroma. - // - // Starts from T50 because T50 has the most chroma available, on - // average. Thus it is most likely to have a direct answer and minimize - // iteration. - for delta in (1...49) { - // Termination condition rounding instead of minimizing delta to avoid - // case where requested chroma is 16.51, and the closest chroma is 16.49. - // Error is minimized, but when rounded and displayed, requested chroma - // is 17, key color's chroma is 16. - if round(chroma) == round(smallestDeltaHct.chroma) { - return smallestDeltaHct - } - - let hctAdd = Hct.from(hue, chroma, startTone + Double(delta)) - let hctAddDelta: Double = abs(hctAdd.chroma - chroma) - if hctAddDelta < smallestDelta { - smallestDelta = hctAddDelta - smallestDeltaHct = hctAdd - } - - let hctSubtract = Hct.from(hue, chroma, startTone - Double(delta)) - let hctSubtractDelta: Double = abs(hctSubtract.chroma - chroma) - if hctSubtractDelta < smallestDelta { - smallestDelta = hctSubtractDelta - smallestDeltaHct = hctSubtract - } - } - - return smallestDeltaHct - } - - /// Returns the ARGB representation of an HCT color. + /// Returns the ARGB representation of an Hct color. /// /// If the class was instantiated from [_hue] and [_chroma], will return the /// color with corresponding [tone]. @@ -174,3 +134,74 @@ public class TonalPalette: Equatable, Hashable { return "TonalPalette.of(\(hue), \(chroma))" } } + +/// Key color is a color that represents the hue and chroma of a tonal palette. +private class KeyColor { + let hue: Double + let requestedChroma: Double + + /// Cache that maps (hue, tone) to max chroma to avoid duplicated Hct calculation. + private var chromaCache: [Int: Double] = [:] + private let maxChromaValue = 200.0 + + init(hue: Double, requestedChroma: Double) { + self.hue = hue + self.requestedChroma = requestedChroma + } + + /// Creates a key color from a [hue] and a [chroma]. + /// The key color is the first tone, starting from T50, matching the given hue and chroma. + /// Key color [Hct] + func create() -> Hct { + /// Pivot around T50 because T50 has the most chroma available, on average. Thus it is most + /// likely to have a direct answer. + let pivotTone = 50 + let toneStepSize = 1 + /// Epsilon to accept values slightly higher than the requested chroma. + let epsilon = 0.01 + + /// Binary search to find the tone that can provide a chroma that is closest + /// to the requested chroma. + var lowerTone = 0 + var upperTone = 100 + + while lowerTone < upperTone { + let midTone = (lowerTone + upperTone) / 2 + let isAscending = + maxChroma(tone: midTone) < maxChroma(tone: midTone + toneStepSize) + let sufficientChroma = maxChroma(tone: midTone) >= requestedChroma - epsilon + + if sufficientChroma { + /// Either range [lowerTone, midTone] or [midTone, upperTone] has answer, so search in the + /// range that is closer the pivot tone. + if abs(lowerTone - pivotTone) < abs(upperTone - pivotTone) { + upperTone = midTone + } else { + if lowerTone == midTone { + return Hct.from(hue, requestedChroma, Double(lowerTone)) + } + lowerTone = midTone + } + } else if isAscending { + /// As there is no sufficient chroma in the midTone, follow the direction to the chroma + /// peak. + lowerTone = midTone + toneStepSize + } else { + /// Keep midTone for potential chroma peak. + upperTone = midTone + } + } + + return Hct.from(hue, requestedChroma, Double(lowerTone)) + } + + /// Find the maximum chroma for a given tone + private func maxChroma(tone: Int) -> Double { + return chromaCache[tone] + ?? { + let chroma = Hct.from(self.hue, self.maxChromaValue, Double(tone)).chroma + chromaCache[tone] = chroma + return chroma + }() + } +} diff --git a/swift/Tests/MaterialColorUtilitiesTests/PalettesTest/TonalPalettesTests.swift b/swift/Tests/MaterialColorUtilitiesTests/PalettesTest/TonalPalettesTests.swift index de028fd2..705b3ef5 100644 --- a/swift/Tests/MaterialColorUtilitiesTests/PalettesTest/TonalPalettesTests.swift +++ b/swift/Tests/MaterialColorUtilitiesTests/PalettesTest/TonalPalettesTests.swift @@ -39,6 +39,42 @@ final class TonalPalettesTests: XCTestCase { XCTAssertEqual(tones.tone(3), 0xff00_003c) } + func testKeyColor_exactChromaAvailable() { + // Requested chroma is exactly achievable at a certain tone. + let palette = TonalPalette(hue: 50.0, chroma: 60.0) + let result = palette.keyColor + + XCTAssertEqual(result.hue, 50.0, accuracy: 10.0) + XCTAssertEqual(result.chroma, 60.0, accuracy: 0.5) + // Tone might vary, but should be within the range from 0 to 100. + XCTAssertTrue(result.tone > 0 && result.tone < 100) + } + + func testKeyColor_unusuallyHighChroma() { + // Requested chroma is above what is achievable. For Hue 149, chroma peak is 89.6 at Tone 87.9. + // The result key color's chroma should be close to the chroma peak. + let palette = TonalPalette(hue: 149.0, chroma: 200.0) + let result = palette.keyColor + + XCTAssertEqual(result.hue, 149.0, accuracy: 10.0) + XCTAssertGreaterThan(result.chroma, 89.0) + // Tone might vary, but should be within the range from 0 to 100. + XCTAssertTrue(result.tone > 0 && result.tone < 100) + } + + func testKeyColor_unusuallyLowChroma() { + // By definition, the key color should be the first tone, starting from Tone 50, matching the + // given hue and chroma. When requesting a very low chroma, the result should be close to Tone + // 50, since most tones can produce a low chroma. + let palette = TonalPalette(hue: 50.0, chroma: 3.0) + let result = palette.keyColor + + // Higher error tolerance for hue when the requested chroma is unusually low. + XCTAssertEqual(result.hue, 50.0, accuracy: 10.0) + XCTAssertEqual(result.chroma, 3.0, accuracy: 0.5) + XCTAssertEqual(result.tone, 50.0, accuracy: 0.5) + } + func testOfOperatorAndHash() { let hctAB = Hct.fromInt(0xff00_00ff) let tonesA = TonalPalette.of(hctAB.hue, hctAB.chroma) diff --git a/typescript/palettes/palettes_test.ts b/typescript/palettes/palettes_test.ts index 5083fcb7..bdc1c828 100644 --- a/typescript/palettes/palettes_test.ts +++ b/typescript/palettes/palettes_test.ts @@ -17,6 +17,8 @@ import 'jasmine'; +import {Hct} from '../hct/hct.js'; + import {CorePalette} from './core_palette.js'; import {TonalPalette} from './tonal_palette.js'; @@ -100,3 +102,50 @@ describe('CorePalette', () => { expect(core.a2.tone(0)).toBe(0xff000000); }); }); + +describe('KeyColor', () => { + it('Key color with exact chroma', () => { + // Requested chroma is exactly achievable at a certain tone. + const palette = TonalPalette.fromHueAndChroma(50.0, 60.0); + const result = palette.keyColor; + + const hueDifference = Math.abs(result.hue - 50.0); + expect(hueDifference).toBeLessThan(10.0); + const chromaDifference = Math.abs(result.chroma - 60.0); + expect(chromaDifference).toBeLessThan(0.5); + // Tone might vary, but should be within the range from 0 to 100. + expect(result.tone).toBeGreaterThan(0); + expect(result.tone).toBeLessThan(100); + }); + + it('key color with unusually high chroma', () => { + // Requested chroma is above what is achievable. For Hue 149, chroma peak + // is 89.6 at Tone 87.9. The result key color's chroma should be close to + // the chroma peak. + const palette = TonalPalette.fromHueAndChroma(149.0, 200.0); + const result = palette.keyColor; + + const hueDifference = Math.abs(result.hue - 149.0); + expect(hueDifference).toBeLessThan(10.0); + expect(result.chroma).toBeGreaterThan(89.0); + // Tone might vary, but should be within the range from 0 to 100. + expect(result.tone).toBeGreaterThan(0); + expect(result.tone).toBeLessThan(100); + }); + + it('key color with unusually low chroma', () => { + // By definition, the key color should be the first tone, starting from + // Tone 50, matching the given hue and chroma. When requesting a very low + // chroma, the result should be close to Tone 50, since most tones can + // produce a low chroma. + const palette = TonalPalette.fromHueAndChroma(50.0, 3.0); + const result = palette.keyColor; + + const hueDifference = Math.abs(result.hue - 50.0); + expect(hueDifference).toBeLessThan(10.0); + const chromaDifference = Math.abs(result.chroma - 3.0); + expect(chromaDifference).toBeLessThan(0.5); + const toneDifference = Math.abs(result.tone - 50.0); + expect(toneDifference).toBeLessThan(0.5); + }); +}) \ No newline at end of file diff --git a/typescript/palettes/tonal_palette.ts b/typescript/palettes/tonal_palette.ts index aedc990e..5dc3be2a 100644 --- a/typescript/palettes/tonal_palette.ts +++ b/typescript/palettes/tonal_palette.ts @@ -47,48 +47,12 @@ export class TonalPalette { * @return Tones matching hue and chroma. */ static fromHueAndChroma(hue: number, chroma: number): TonalPalette { - return new TonalPalette(hue, chroma, TonalPalette.createKeyColor(hue, chroma)); + const keyColor = new KeyColor(hue, chroma).create(); + return new TonalPalette(hue, chroma, keyColor); } private constructor(readonly hue: number, readonly chroma: number, readonly keyColor: Hct) {} - private static createKeyColor(hue: number, chroma: number): Hct { - const startTone = 50.0; - let smallestDeltaHct = Hct.from(hue, chroma, startTone); - let smallestDelta = Math.abs(smallestDeltaHct.chroma - chroma); - // Starting from T50, check T+/-delta to see if they match the requested - // chroma. - // - // Starts from T50 because T50 has the most chroma available, on - // average. Thus it is most likely to have a direct answer and minimize - // iteration. - for (let delta = 1.0; delta < 50.0; delta += 1.0) { - // Termination condition rounding instead of minimizing delta to avoid - // case where requested chroma is 16.51, and the closest chroma is 16.49. - // Error is minimized, but when rounded and displayed, requested chroma - // is 17, key color's chroma is 16. - if (Math.round(chroma) === Math.round(smallestDeltaHct.chroma)) { - return smallestDeltaHct; - } - - const hctAdd = Hct.from(hue, chroma, startTone + delta); - const hctAddDelta = Math.abs(hctAdd.chroma - chroma); - if (hctAddDelta < smallestDelta) { - smallestDelta = hctAddDelta; - smallestDeltaHct = hctAdd; - } - - const hctSubtract = Hct.from(hue, chroma, startTone - delta); - const hctSubtractDelta = Math.abs(hctSubtract.chroma - chroma); - if (hctSubtractDelta < smallestDelta) { - smallestDelta = hctSubtractDelta; - smallestDeltaHct = hctSubtract; - } - } - - return smallestDeltaHct; - } - /** * @param tone HCT tone, measured from 0 to 100. * @return ARGB representation of a color with that tone. @@ -110,3 +74,76 @@ export class TonalPalette { return Hct.fromInt(this.tone(tone)); } } + +/** + * Key color is a color that represents the hue and chroma of a tonal palette + */ +class KeyColor { + // Cache that maps tone to max chroma to avoid duplicated HCT calculation. + private readonly chromaCache = new Map(); + private readonly maxChromaValue = 200.0; + + constructor(readonly hue: number, readonly requestedChroma: number) {} + + /** + * Creates a key color from a [hue] and a [chroma]. + * The key color is the first tone, starting from T50, matching the given hue + * and chroma. + * + * @return Key color [Hct] + */ + create(): Hct { + // Pivot around T50 because T50 has the most chroma available, on + // average. Thus it is most likely to have a direct answer. + const pivotTone = 50; + const toneStepSize = 1; + // Epsilon to accept values slightly higher than the requested chroma. + const epsilon = 0.01; + + // Binary search to find the tone that can provide a chroma that is closest + // to the requested chroma. + let lowerTone = 0; + let upperTone = 100; + while (lowerTone < upperTone) { + const midTone = Math.floor((lowerTone + upperTone) / 2); + const isAscending = + this.maxChroma(midTone) < this.maxChroma(midTone + toneStepSize); + const sufficientChroma = + this.maxChroma(midTone) >= this.requestedChroma - epsilon; + + if (sufficientChroma) { + // Either range [lowerTone, midTone] or [midTone, upperTone] has + // the answer, so search in the range that is closer the pivot tone. + if (Math.abs(lowerTone - pivotTone) < Math.abs(upperTone - pivotTone)) { + upperTone = midTone; + } else { + if (lowerTone === midTone) { + return Hct.from(this.hue, this.requestedChroma, lowerTone); + } + lowerTone = midTone; + } + } else { + // As there is no sufficient chroma in the midTone, follow the direction + // to the chroma peak. + if (isAscending) { + lowerTone = midTone + toneStepSize; + } else { + // Keep midTone for potential chroma peak. + upperTone = midTone; + } + } + } + + return Hct.from(this.hue, this.requestedChroma, lowerTone); + } + + // Find the maximum chroma for a given tone + private maxChroma(tone: number): number { + if (this.chromaCache.has(tone)) { + return this.chromaCache.get(tone)!; + } + const chroma = Hct.from(this.hue, this.maxChromaValue, tone).chroma; + this.chromaCache.set(tone, chroma); + return chroma; + } +}