@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.
- package/README.md +3 -2
- package/dist/data/locales/de.json +1 -1
- package/dist/data/locales/en.json +1 -1
- package/dist/data/locales/fr.json +1 -1
- package/dist/data/locales/ja.json +1 -1
- package/dist/data/locales/ko.json +1 -1
- package/dist/data/locales/zh.json +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/services/ColorService.d.ts +210 -1
- package/dist/services/ColorService.d.ts.map +1 -1
- package/dist/services/ColorService.js +330 -1
- package/dist/services/ColorService.js.map +1 -1
- package/dist/services/color/ColorConverter.d.ts +311 -4
- package/dist/services/color/ColorConverter.d.ts.map +1 -1
- package/dist/services/color/ColorConverter.js +589 -5
- package/dist/services/color/ColorConverter.js.map +1 -1
- package/dist/services/color/SpectralMixer.d.ts +77 -0
- package/dist/services/color/SpectralMixer.d.ts.map +1 -0
- package/dist/services/color/SpectralMixer.js +133 -0
- package/dist/services/color/SpectralMixer.js.map +1 -0
- package/dist/services/dye/DyeSearch.d.ts +53 -4
- package/dist/services/dye/DyeSearch.d.ts.map +1 -1
- package/dist/services/dye/DyeSearch.js +105 -13
- package/dist/services/dye/DyeSearch.js.map +1 -1
- package/dist/types/index.d.ts +62 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +13 -0
- package/dist/types/index.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -3
|
@@ -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'
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|