@xivdyetools/core 1.12.4 → 1.12.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -606,15 +606,36 @@ export class ColorConverter {
606
606
  }
607
607
  /**
608
608
  * Calculate DeltaE between two hex colors using specified formula
609
+ *
610
+ * Available formulas:
611
+ * - cie76: LAB Euclidean (fast, fair accuracy)
612
+ * - cie2000: CIEDE2000 (industry standard, accurate)
613
+ * - oklab: OKLAB Euclidean (modern, simpler than cie2000, CSS standard)
614
+ * - hyab: HyAB hybrid (best for large color differences/palette matching)
615
+ *
609
616
  * @param hex1 First hex color
610
617
  * @param hex2 Second hex color
611
- * @param formula DeltaE formula to use ('cie76' or 'cie2000')
612
- * @returns DeltaE value
618
+ * @param formula DeltaE formula to use (default: 'cie76')
619
+ * @returns DeltaE value (scale varies by formula)
613
620
  */
614
621
  getDeltaE(hex1, hex2, formula = 'cie76') {
615
- const lab1 = this.hexToLab(hex1);
616
- const lab2 = this.hexToLab(hex2);
617
- return formula === 'cie2000' ? this.getDeltaE2000(lab1, lab2) : this.getDeltaE76(lab1, lab2);
622
+ switch (formula) {
623
+ case 'cie2000': {
624
+ const lab1 = this.hexToLab(hex1);
625
+ const lab2 = this.hexToLab(hex2);
626
+ return this.getDeltaE2000(lab1, lab2);
627
+ }
628
+ case 'oklab':
629
+ return this.getDeltaE_Oklab(hex1, hex2);
630
+ case 'hyab':
631
+ return this.getDeltaE_HyAB(hex1, hex2);
632
+ case 'cie76':
633
+ default: {
634
+ const lab1 = this.hexToLab(hex1);
635
+ const lab2 = this.hexToLab(hex2);
636
+ return this.getDeltaE76(lab1, lab2);
637
+ }
638
+ }
618
639
  }
619
640
  /**
620
641
  * Static method: Calculate DeltaE using default instance
@@ -622,6 +643,569 @@ export class ColorConverter {
622
643
  static getDeltaE(hex1, hex2, formula = 'cie76') {
623
644
  return this.getDefault().getDeltaE(hex1, hex2, formula);
624
645
  }
646
+ // ============================================================================
647
+ // OKLAB-based Color Difference Calculations
648
+ // ============================================================================
649
+ /**
650
+ * Calculate color difference using OKLAB Euclidean distance.
651
+ *
652
+ * OKLAB provides better perceptual uniformity than LAB with simpler math
653
+ * than CIEDE2000. It fixes LAB's blue→purple hue shift issue.
654
+ *
655
+ * Adopted by Safari, Photoshop, and CSS Color Level 4.
656
+ *
657
+ * Reference: Björn Ottosson (2020) - "A perceptual color space for image processing"
658
+ *
659
+ * @param hex1 First color in hex format
660
+ * @param hex2 Second color in hex format
661
+ * @returns Distance value (0 = identical, scale ~0-0.5 for typical colors)
662
+ *
663
+ * @example getDeltaE_Oklab("#FF0000", "#00FF00") -> ~0.39
664
+ */
665
+ getDeltaE_Oklab(hex1, hex2) {
666
+ const lab1 = this.hexToOklab(hex1);
667
+ const lab2 = this.hexToOklab(hex2);
668
+ const dL = lab2.L - lab1.L;
669
+ const da = lab2.a - lab1.a;
670
+ const db = lab2.b - lab1.b;
671
+ return Math.sqrt(dL * dL + da * da + db * db);
672
+ }
673
+ /**
674
+ * Static method: Calculate OKLAB Euclidean distance using default instance
675
+ */
676
+ static getDeltaE_Oklab(hex1, hex2) {
677
+ return this.getDefault().getDeltaE_Oklab(hex1, hex2);
678
+ }
679
+ /**
680
+ * Calculate color difference using HyAB (Hybrid) algorithm.
681
+ *
682
+ * HyAB uses taxicab distance for lightness and Euclidean for chroma.
683
+ * Research shows it outperforms both Euclidean AND CIEDE2000 for large
684
+ * color differences (>10 units), making it ideal for palette matching.
685
+ *
686
+ * Formula: ΔE_HyAB = |L₂ - L₁| + √[(a₂-a₁)² + (b₂-b₁)²]
687
+ *
688
+ * Reference: Abasi, Tehran & Fairchild (2019) -
689
+ * "Distance metrics for very large color differences"
690
+ *
691
+ * @param hex1 First color in hex format
692
+ * @param hex2 Second color in hex format
693
+ * @param kL Lightness weight (default 1.0). Higher = prioritize lightness matching.
694
+ * Use kL > 1 for visibility-critical matching (armor, UI).
695
+ * Use kL < 1 to tolerate brightness differences (find vibrant alternatives).
696
+ * @returns Distance value (0 = identical, scale ~0-1.5 for typical colors)
697
+ *
698
+ * @example getDeltaE_HyAB("#FF0000", "#800000") -> ~0.32
699
+ * @example getDeltaE_HyAB("#FF0000", "#800000", 2.0) -> higher (emphasize brightness)
700
+ */
701
+ getDeltaE_HyAB(hex1, hex2, kL = 1.0) {
702
+ const lab1 = this.hexToOklab(hex1);
703
+ const lab2 = this.hexToOklab(hex2);
704
+ // Taxicab distance for lightness (weighted)
705
+ const dL = Math.abs(lab2.L - lab1.L) * kL;
706
+ // Euclidean distance for chroma plane
707
+ const da = lab2.a - lab1.a;
708
+ const db = lab2.b - lab1.b;
709
+ const dChroma = Math.sqrt(da * da + db * db);
710
+ return dL + dChroma;
711
+ }
712
+ /**
713
+ * Static method: Calculate HyAB distance using default instance
714
+ */
715
+ static getDeltaE_HyAB(hex1, hex2, kL = 1.0) {
716
+ return this.getDefault().getDeltaE_HyAB(hex1, hex2, kL);
717
+ }
718
+ /**
719
+ * Calculate color difference using OKLCH with customizable L/C/H weights.
720
+ *
721
+ * Allows users to prioritize different color attributes:
722
+ * - Lightness (L): Brightness/darkness
723
+ * - Chroma (C): Saturation/vividness
724
+ * - Hue (H): The actual color (red, blue, green, etc.)
725
+ *
726
+ * @param hex1 First color in hex format
727
+ * @param hex2 Second color in hex format
728
+ * @param weights Object with kL, kC, kH weights (default 1.0 each)
729
+ * @returns Distance value (0 = identical, higher = more different)
730
+ *
731
+ * @example getDeltaE_OklchWeighted("#FF0000", "#FF8000", { kH: 2.0 }) -> prioritizes hue match
732
+ */
733
+ getDeltaE_OklchWeighted(hex1, hex2, weights = {}) {
734
+ const { kL = 1.0, kC = 1.0, kH = 1.0 } = weights;
735
+ const lch1 = this.hexToOklch(hex1);
736
+ const lch2 = this.hexToOklch(hex2);
737
+ // Lightness difference
738
+ const dL = (lch2.L - lch1.L) * kL;
739
+ // Chroma difference
740
+ const dC = (lch2.C - lch1.C) * kC;
741
+ // Hue difference with wraparound (circular)
742
+ let dH = lch2.h - lch1.h;
743
+ if (dH > 180)
744
+ dH -= 360;
745
+ if (dH < -180)
746
+ dH += 360;
747
+ // Scale hue by average chroma for perceptual accuracy
748
+ // (hue differences matter less for desaturated colors)
749
+ const avgC = (lch1.C + lch2.C) / 2;
750
+ const dHScaled = (dH / 180) * avgC * kH;
751
+ return Math.sqrt(dL * dL + dC * dC + dHScaled * dHScaled);
752
+ }
753
+ /**
754
+ * Static method: Calculate OKLCH weighted distance using default instance
755
+ */
756
+ static getDeltaE_OklchWeighted(hex1, hex2, weights = {}) {
757
+ return this.getDefault().getDeltaE_OklchWeighted(hex1, hex2, weights);
758
+ }
759
+ // ============================================================================
760
+ // OKLAB/OKLCH Color Space Conversion (Björn Ottosson, 2020)
761
+ // ============================================================================
762
+ /**
763
+ * Convert sRGB component to linear RGB
764
+ * Applies inverse gamma companding
765
+ * @internal
766
+ */
767
+ srgbToLinear(c) {
768
+ const normalized = c / 255;
769
+ return normalized <= 0.04045 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4);
770
+ }
771
+ /**
772
+ * Convert linear RGB component to sRGB
773
+ * Applies gamma companding
774
+ * @internal
775
+ */
776
+ linearToSrgb(c) {
777
+ const clamped = Math.max(0, Math.min(1, c));
778
+ const srgb = clamped <= 0.0031308 ? clamped * 12.92 : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
779
+ return Math.round(srgb * 255);
780
+ }
781
+ /**
782
+ * Convert RGB to OKLAB color space
783
+ *
784
+ * OKLAB is a modern perceptually uniform color space that fixes issues
785
+ * with CIELAB, particularly for blue colors. Blue + Yellow = Green in OKLAB.
786
+ *
787
+ * @param r Red component (0-255)
788
+ * @param g Green component (0-255)
789
+ * @param b Blue component (0-255)
790
+ * @returns OKLAB color with L (0-1), a (~-0.4 to 0.4), b (~-0.4 to 0.4)
791
+ *
792
+ * @example rgbToOklab(255, 0, 0) -> { L: 0.628, a: 0.225, b: 0.126 }
793
+ */
794
+ rgbToOklab(r, g, b) {
795
+ if (!isValidRGB(r, g, b)) {
796
+ throw new AppError(ErrorCode.INVALID_RGB_VALUE, `Invalid RGB values: r=${r}, g=${g}, b=${b}. Values must be 0-255`, 'error');
797
+ }
798
+ // Convert sRGB to linear RGB
799
+ const rLin = this.srgbToLinear(r);
800
+ const gLin = this.srgbToLinear(g);
801
+ const bLin = this.srgbToLinear(b);
802
+ // Linear RGB to LMS (using Oklab's specific matrix)
803
+ const l = 0.4122214708 * rLin + 0.5363325363 * gLin + 0.0514459929 * bLin;
804
+ const m = 0.2119034982 * rLin + 0.6806995451 * gLin + 0.1073969566 * bLin;
805
+ const s = 0.0883024619 * rLin + 0.2817188376 * gLin + 0.6299787005 * bLin;
806
+ // Apply cube root
807
+ const lRoot = Math.cbrt(l);
808
+ const mRoot = Math.cbrt(m);
809
+ const sRoot = Math.cbrt(s);
810
+ // LMS to Oklab
811
+ return {
812
+ L: round(0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot, 6),
813
+ a: round(1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot, 6),
814
+ b: round(0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot, 6),
815
+ };
816
+ }
817
+ /**
818
+ * Static method: Convert RGB to OKLAB using default instance
819
+ */
820
+ static rgbToOklab(r, g, b) {
821
+ return this.getDefault().rgbToOklab(r, g, b);
822
+ }
823
+ /**
824
+ * Convert OKLAB to RGB color space
825
+ *
826
+ * @param L Lightness (0-1)
827
+ * @param a Green-Red axis (~-0.4 to 0.4)
828
+ * @param b Blue-Yellow axis (~-0.4 to 0.4)
829
+ * @returns RGB color with values 0-255
830
+ *
831
+ * @example oklabToRgb(0.628, 0.225, 0.126) -> { r: 255, g: 0, b: 0 }
832
+ */
833
+ oklabToRgb(L, a, b) {
834
+ // Oklab to LMS
835
+ const lRoot = L + 0.3963377774 * a + 0.2158037573 * b;
836
+ const mRoot = L - 0.1055613458 * a - 0.0638541728 * b;
837
+ const sRoot = L - 0.0894841775 * a - 1.291485548 * b;
838
+ // Cube the roots
839
+ const l = lRoot * lRoot * lRoot;
840
+ const m = mRoot * mRoot * mRoot;
841
+ const s = sRoot * sRoot * sRoot;
842
+ // LMS to linear RGB
843
+ const rLin = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
844
+ const gLin = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
845
+ const bLin = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
846
+ // Convert to sRGB
847
+ return {
848
+ r: clamp(this.linearToSrgb(rLin), RGB_MIN, RGB_MAX),
849
+ g: clamp(this.linearToSrgb(gLin), RGB_MIN, RGB_MAX),
850
+ b: clamp(this.linearToSrgb(bLin), RGB_MIN, RGB_MAX),
851
+ };
852
+ }
853
+ /**
854
+ * Static method: Convert OKLAB to RGB using default instance
855
+ */
856
+ static oklabToRgb(L, a, b) {
857
+ return this.getDefault().oklabToRgb(L, a, b);
858
+ }
859
+ /**
860
+ * Convert hex color to OKLAB
861
+ */
862
+ hexToOklab(hex) {
863
+ const rgb = this.hexToRgb(hex);
864
+ return this.rgbToOklab(rgb.r, rgb.g, rgb.b);
865
+ }
866
+ /**
867
+ * Static method: Convert hex to OKLAB using default instance
868
+ */
869
+ static hexToOklab(hex) {
870
+ return this.getDefault().hexToOklab(hex);
871
+ }
872
+ /**
873
+ * Convert OKLAB to hex color
874
+ */
875
+ oklabToHex(L, a, b) {
876
+ const rgb = this.oklabToRgb(L, a, b);
877
+ return this.rgbToHex(rgb.r, rgb.g, rgb.b);
878
+ }
879
+ /**
880
+ * Static method: Convert OKLAB to hex using default instance
881
+ */
882
+ static oklabToHex(L, a, b) {
883
+ return this.getDefault().oklabToHex(L, a, b);
884
+ }
885
+ /**
886
+ * Convert RGB to OKLCH (cylindrical OKLAB)
887
+ *
888
+ * OKLCH expresses OKLAB in cylindrical coordinates for intuitive
889
+ * hue manipulation. Ideal for gradient interpolation.
890
+ *
891
+ * @param r Red component (0-255)
892
+ * @param g Green component (0-255)
893
+ * @param b Blue component (0-255)
894
+ * @returns OKLCH color with L (0-1), C (chroma, 0 to ~0.4), h (hue, 0-360)
895
+ *
896
+ * @example rgbToOklch(255, 0, 0) -> { L: 0.628, C: 0.258, h: 29.23 }
897
+ */
898
+ rgbToOklch(r, g, b) {
899
+ const oklab = this.rgbToOklab(r, g, b);
900
+ // Convert to cylindrical coordinates
901
+ const C = Math.sqrt(oklab.a * oklab.a + oklab.b * oklab.b);
902
+ let h = Math.atan2(oklab.b, oklab.a) * (180 / Math.PI);
903
+ if (h < 0)
904
+ h += 360;
905
+ return {
906
+ L: oklab.L,
907
+ C: round(C, 6),
908
+ h: round(h, 4),
909
+ };
910
+ }
911
+ /**
912
+ * Static method: Convert RGB to OKLCH using default instance
913
+ */
914
+ static rgbToOklch(r, g, b) {
915
+ return this.getDefault().rgbToOklch(r, g, b);
916
+ }
917
+ /**
918
+ * Convert OKLCH to RGB
919
+ *
920
+ * @param L Lightness (0-1)
921
+ * @param C Chroma (0 to ~0.4)
922
+ * @param h Hue angle (0-360 degrees)
923
+ * @returns RGB color with values 0-255
924
+ */
925
+ oklchToRgb(L, C, h) {
926
+ // Convert from cylindrical to rectangular coordinates
927
+ const hRad = h * (Math.PI / 180);
928
+ const a = C * Math.cos(hRad);
929
+ const b = C * Math.sin(hRad);
930
+ return this.oklabToRgb(L, a, b);
931
+ }
932
+ /**
933
+ * Static method: Convert OKLCH to RGB using default instance
934
+ */
935
+ static oklchToRgb(L, C, h) {
936
+ return this.getDefault().oklchToRgb(L, C, h);
937
+ }
938
+ /**
939
+ * Convert hex color to OKLCH
940
+ */
941
+ hexToOklch(hex) {
942
+ const rgb = this.hexToRgb(hex);
943
+ return this.rgbToOklch(rgb.r, rgb.g, rgb.b);
944
+ }
945
+ /**
946
+ * Static method: Convert hex to OKLCH using default instance
947
+ */
948
+ static hexToOklch(hex) {
949
+ return this.getDefault().hexToOklch(hex);
950
+ }
951
+ /**
952
+ * Convert OKLCH to hex color
953
+ */
954
+ oklchToHex(L, C, h) {
955
+ const rgb = this.oklchToRgb(L, C, h);
956
+ return this.rgbToHex(rgb.r, rgb.g, rgb.b);
957
+ }
958
+ /**
959
+ * Static method: Convert OKLCH to hex using default instance
960
+ */
961
+ static oklchToHex(L, C, h) {
962
+ return this.getDefault().oklchToHex(L, C, h);
963
+ }
964
+ // ============================================================================
965
+ // LCH Color Space Conversion (Cylindrical CIE LAB)
966
+ // ============================================================================
967
+ /**
968
+ * Convert CIE LAB to LCH (cylindrical LAB)
969
+ *
970
+ * LCH expresses LAB in cylindrical coordinates for hue-based operations.
971
+ *
972
+ * @param L Lightness (0-100)
973
+ * @param a Green-Red axis (~-128 to 127)
974
+ * @param b Blue-Yellow axis (~-128 to 127)
975
+ * @returns LCH color with L (0-100), C (chroma), h (hue, 0-360)
976
+ */
977
+ labToLch(L, a, b) {
978
+ const C = Math.sqrt(a * a + b * b);
979
+ let h = Math.atan2(b, a) * (180 / Math.PI);
980
+ if (h < 0)
981
+ h += 360;
982
+ return {
983
+ L: round(L, 4),
984
+ C: round(C, 4),
985
+ h: round(h, 4),
986
+ };
987
+ }
988
+ /**
989
+ * Static method: Convert LAB to LCH using default instance
990
+ */
991
+ static labToLch(L, a, b) {
992
+ return this.getDefault().labToLch(L, a, b);
993
+ }
994
+ /**
995
+ * Convert LCH to CIE LAB
996
+ *
997
+ * @param L Lightness (0-100)
998
+ * @param C Chroma
999
+ * @param h Hue angle (0-360 degrees)
1000
+ * @returns LAB color
1001
+ */
1002
+ lchToLab(L, C, h) {
1003
+ const hRad = h * (Math.PI / 180);
1004
+ return {
1005
+ L: round(L, 4),
1006
+ a: round(C * Math.cos(hRad), 4),
1007
+ b: round(C * Math.sin(hRad), 4),
1008
+ };
1009
+ }
1010
+ /**
1011
+ * Static method: Convert LCH to LAB using default instance
1012
+ */
1013
+ static lchToLab(L, C, h) {
1014
+ return this.getDefault().lchToLab(L, C, h);
1015
+ }
1016
+ /**
1017
+ * Convert RGB to LCH
1018
+ */
1019
+ rgbToLch(r, g, b) {
1020
+ const lab = this.rgbToLab(r, g, b);
1021
+ return this.labToLch(lab.L, lab.a, lab.b);
1022
+ }
1023
+ /**
1024
+ * Static method: Convert RGB to LCH using default instance
1025
+ */
1026
+ static rgbToLch(r, g, b) {
1027
+ return this.getDefault().rgbToLch(r, g, b);
1028
+ }
1029
+ /**
1030
+ * Convert LCH to RGB
1031
+ */
1032
+ lchToRgb(L, C, h) {
1033
+ const lab = this.lchToLab(L, C, h);
1034
+ return this.labToRgb(lab.L, lab.a, lab.b);
1035
+ }
1036
+ /**
1037
+ * Static method: Convert LCH to RGB using default instance
1038
+ */
1039
+ static lchToRgb(L, C, h) {
1040
+ return this.getDefault().lchToRgb(L, C, h);
1041
+ }
1042
+ /**
1043
+ * Convert hex color to LCH
1044
+ */
1045
+ hexToLch(hex) {
1046
+ const rgb = this.hexToRgb(hex);
1047
+ return this.rgbToLch(rgb.r, rgb.g, rgb.b);
1048
+ }
1049
+ /**
1050
+ * Static method: Convert hex to LCH using default instance
1051
+ */
1052
+ static hexToLch(hex) {
1053
+ return this.getDefault().hexToLch(hex);
1054
+ }
1055
+ /**
1056
+ * Convert LCH to hex color
1057
+ */
1058
+ lchToHex(L, C, h) {
1059
+ const rgb = this.lchToRgb(L, C, h);
1060
+ return this.rgbToHex(rgb.r, rgb.g, rgb.b);
1061
+ }
1062
+ /**
1063
+ * Static method: Convert LCH to hex using default instance
1064
+ */
1065
+ static lchToHex(L, C, h) {
1066
+ return this.getDefault().lchToHex(L, C, h);
1067
+ }
1068
+ // ============================================================================
1069
+ // HSL Color Space Conversion
1070
+ // ============================================================================
1071
+ /**
1072
+ * Convert RGB to HSL (Hue, Saturation, Lightness)
1073
+ *
1074
+ * HSL is similar to HSV but uses Lightness instead of Value.
1075
+ * Common in design tools like Photoshop, Figma, and CSS.
1076
+ *
1077
+ * @param r Red component (0-255)
1078
+ * @param g Green component (0-255)
1079
+ * @param b Blue component (0-255)
1080
+ * @returns HSL color with h (0-360), s (0-100), l (0-100)
1081
+ *
1082
+ * @example rgbToHsl(255, 0, 0) -> { h: 0, s: 100, l: 50 }
1083
+ */
1084
+ rgbToHsl(r, g, b) {
1085
+ if (!isValidRGB(r, g, b)) {
1086
+ throw new AppError(ErrorCode.INVALID_RGB_VALUE, `Invalid RGB values: r=${r}, g=${g}, b=${b}. Values must be 0-255`, 'error');
1087
+ }
1088
+ // Normalize to 0-1
1089
+ const rNorm = r / 255;
1090
+ const gNorm = g / 255;
1091
+ const bNorm = b / 255;
1092
+ const max = Math.max(rNorm, gNorm, bNorm);
1093
+ const min = Math.min(rNorm, gNorm, bNorm);
1094
+ const delta = max - min;
1095
+ // Calculate Lightness
1096
+ const l = (max + min) / 2;
1097
+ // Calculate Saturation
1098
+ let s = 0;
1099
+ if (delta !== 0) {
1100
+ s = delta / (1 - Math.abs(2 * l - 1));
1101
+ }
1102
+ // Calculate Hue (same as HSV)
1103
+ let h = 0;
1104
+ if (delta !== 0) {
1105
+ if (max === rNorm) {
1106
+ h = ((gNorm - bNorm) / delta + (gNorm < bNorm ? 6 : 0)) * 60;
1107
+ }
1108
+ else if (max === gNorm) {
1109
+ h = ((bNorm - rNorm) / delta + 2) * 60;
1110
+ }
1111
+ else {
1112
+ h = ((rNorm - gNorm) / delta + 4) * 60;
1113
+ }
1114
+ }
1115
+ return {
1116
+ h: round(h, 2),
1117
+ s: round(s * 100, 2),
1118
+ l: round(l * 100, 2),
1119
+ };
1120
+ }
1121
+ /**
1122
+ * Static method: Convert RGB to HSL using default instance
1123
+ */
1124
+ static rgbToHsl(r, g, b) {
1125
+ return this.getDefault().rgbToHsl(r, g, b);
1126
+ }
1127
+ /**
1128
+ * Convert HSL to RGB
1129
+ *
1130
+ * @param h Hue (0-360 degrees)
1131
+ * @param s Saturation (0-100 percent)
1132
+ * @param l Lightness (0-100 percent)
1133
+ * @returns RGB color with values 0-255
1134
+ *
1135
+ * @example hslToRgb(0, 100, 50) -> { r: 255, g: 0, b: 0 }
1136
+ */
1137
+ hslToRgb(h, s, l) {
1138
+ // Normalize
1139
+ const hNorm = this.normalizeHue(h) / 360;
1140
+ const sNorm = clamp(s, 0, 100) / 100;
1141
+ const lNorm = clamp(l, 0, 100) / 100;
1142
+ let r, g, b;
1143
+ if (sNorm === 0) {
1144
+ // Achromatic (gray)
1145
+ r = g = b = lNorm;
1146
+ }
1147
+ else {
1148
+ const q = lNorm < 0.5 ? lNorm * (1 + sNorm) : lNorm + sNorm - lNorm * sNorm;
1149
+ const p = 2 * lNorm - q;
1150
+ r = this.hueToRgbComponent(p, q, hNorm + 1 / 3);
1151
+ g = this.hueToRgbComponent(p, q, hNorm);
1152
+ b = this.hueToRgbComponent(p, q, hNorm - 1 / 3);
1153
+ }
1154
+ return {
1155
+ r: clamp(Math.round(r * 255), RGB_MIN, RGB_MAX),
1156
+ g: clamp(Math.round(g * 255), RGB_MIN, RGB_MAX),
1157
+ b: clamp(Math.round(b * 255), RGB_MIN, RGB_MAX),
1158
+ };
1159
+ }
1160
+ /**
1161
+ * Helper for HSL to RGB conversion
1162
+ * @internal
1163
+ */
1164
+ hueToRgbComponent(p, q, t) {
1165
+ if (t < 0)
1166
+ t += 1;
1167
+ if (t > 1)
1168
+ t -= 1;
1169
+ if (t < 1 / 6)
1170
+ return p + (q - p) * 6 * t;
1171
+ if (t < 1 / 2)
1172
+ return q;
1173
+ if (t < 2 / 3)
1174
+ return p + (q - p) * (2 / 3 - t) * 6;
1175
+ return p;
1176
+ }
1177
+ /**
1178
+ * Static method: Convert HSL to RGB using default instance
1179
+ */
1180
+ static hslToRgb(h, s, l) {
1181
+ return this.getDefault().hslToRgb(h, s, l);
1182
+ }
1183
+ /**
1184
+ * Convert hex color to HSL
1185
+ */
1186
+ hexToHsl(hex) {
1187
+ const rgb = this.hexToRgb(hex);
1188
+ return this.rgbToHsl(rgb.r, rgb.g, rgb.b);
1189
+ }
1190
+ /**
1191
+ * Static method: Convert hex to HSL using default instance
1192
+ */
1193
+ static hexToHsl(hex) {
1194
+ return this.getDefault().hexToHsl(hex);
1195
+ }
1196
+ /**
1197
+ * Convert HSL to hex color
1198
+ */
1199
+ hslToHex(h, s, l) {
1200
+ const rgb = this.hslToRgb(h, s, l);
1201
+ return this.rgbToHex(rgb.r, rgb.g, rgb.b);
1202
+ }
1203
+ /**
1204
+ * Static method: Convert HSL to hex using default instance
1205
+ */
1206
+ static hslToHex(h, s, l) {
1207
+ return this.getDefault().hslToHex(h, s, l);
1208
+ }
625
1209
  }
626
1210
  // Default singleton instance for static API compatibility
627
1211
  // Per Issue #6: Eager initialization to avoid race conditions in concurrent scenarios