colors.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. // Runtime color-name catalog, populated once at app startup by ColorCatalogProvider
  2. // from /api/inventory/colors/map. The backend color_catalog table is the single
  3. // source of truth — no hardcoded hex→name tables live on the frontend anymore.
  4. //
  5. // Keyed by lowercase 6-char hex (no leading '#'). Lookups before the provider has
  6. // fetched the catalog fall through to hexToColorName (HSL-based bucketing). A
  7. // subscribe/getSnapshot pair lets React components re-render via
  8. // useSyncExternalStore when the catalog loads, so pages that mount before the
  9. // fetch resolves (InventoryPage, PrintersPage) update to the catalog name once it
  10. // arrives instead of staying stuck on the HSL fallback.
  11. let runtimeColorCatalog: Record<string, string> = {};
  12. let catalogVersion = 0;
  13. const catalogListeners = new Set<() => void>();
  14. export function setColorCatalog(map: Record<string, string>): void {
  15. // Normalize keys to lowercase 6-char hex (no '#'), defensively. Backend already
  16. // does this, but the frontend contract is explicit so callers from tests or
  17. // future integrations can't accidentally break lookups.
  18. const normalized: Record<string, string> = {};
  19. for (const [key, value] of Object.entries(map)) {
  20. if (!key || !value) continue;
  21. const hex = key.replace('#', '').toLowerCase().slice(0, 6);
  22. if (hex.length === 6) normalized[hex] = value;
  23. }
  24. runtimeColorCatalog = normalized;
  25. catalogVersion += 1;
  26. // Snapshot listeners to avoid mutation-during-iteration if a listener unsubscribes.
  27. for (const listener of Array.from(catalogListeners)) {
  28. listener();
  29. }
  30. }
  31. export function subscribeColorCatalog(listener: () => void): () => void {
  32. catalogListeners.add(listener);
  33. return () => {
  34. catalogListeners.delete(listener);
  35. };
  36. }
  37. export function getColorCatalogVersion(): number {
  38. return catalogVersion;
  39. }
  40. /** Test-only hook: reset the catalog to empty so unit tests can exercise fallbacks. */
  41. export function __resetColorCatalogForTests(): void {
  42. runtimeColorCatalog = {};
  43. catalogVersion = 0;
  44. catalogListeners.clear();
  45. }
  46. /**
  47. * Convert hex color to basic color name using HSL analysis.
  48. * Used as fallback when hex is not in the runtime catalog.
  49. */
  50. export function hexToColorName(hex: string | null | undefined): string {
  51. if (!hex || hex.length < 6) return 'Unknown';
  52. const cleanHex = hex.replace('#', '');
  53. // Alpha=00 → fully transparent. Name it 'Clear' before falling through to
  54. // RGB-based naming, otherwise #00000000 (Bambu's transparent code) would
  55. // resolve to 'Black' via the HSL fallback (#1545).
  56. if (cleanHex.length === 8 && cleanHex.substring(6, 8).toLowerCase() === '00') {
  57. return 'Clear';
  58. }
  59. const r = parseInt(cleanHex.substring(0, 2), 16);
  60. const g = parseInt(cleanHex.substring(2, 4), 16);
  61. const b = parseInt(cleanHex.substring(4, 6), 16);
  62. const max = Math.max(r, g, b) / 255;
  63. const min = Math.min(r, g, b) / 255;
  64. const l = (max + min) / 2;
  65. let h = 0;
  66. let s = 0;
  67. if (max !== min) {
  68. const d = max - min;
  69. s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
  70. const rNorm = r / 255, gNorm = g / 255, bNorm = b / 255;
  71. if (max === rNorm) h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
  72. else if (max === gNorm) h = ((bNorm - rNorm) / d + 2) / 6;
  73. else h = ((rNorm - gNorm) / d + 4) / 6;
  74. }
  75. h = h * 360;
  76. if (l < 0.15) return 'Black';
  77. if (l > 0.85) return 'White';
  78. if (s < 0.15) {
  79. if (l < 0.4) return 'Dark Gray';
  80. if (l > 0.6) return 'Light Gray';
  81. return 'Gray';
  82. }
  83. // Brown is orange/yellow hue with lower lightness
  84. if (h >= 15 && h < 45 && l < 0.45) return 'Brown';
  85. if (h >= 45 && h < 70 && l < 0.40) return 'Brown';
  86. if (h < 15 || h >= 345) return 'Red';
  87. if (h < 45) return 'Orange';
  88. if (h < 70) return 'Yellow';
  89. if (h < 150) return 'Green';
  90. if (h < 200) return 'Cyan';
  91. if (h < 260) return 'Blue';
  92. if (h < 290) return 'Purple';
  93. return 'Pink';
  94. }
  95. /**
  96. * Get color name from hex color.
  97. * Looks up the runtime color catalog (backend-sourced), then falls back to HSL.
  98. */
  99. export function getColorName(hexColor: string): string {
  100. if (!hexColor) return hexToColorName(hexColor);
  101. const clean = hexColor.replace('#', '').toLowerCase();
  102. if (clean.length === 8 && clean.substring(6, 8) === '00') return 'Clear';
  103. const hex = clean.substring(0, 6);
  104. const mapped = runtimeColorCatalog[hex];
  105. if (mapped) return mapped;
  106. return hexToColorName(hexColor);
  107. }
  108. /**
  109. * Resolve a spool's display color name.
  110. * Tries: stored color_name (if it's a readable name) → runtime catalog via rgba → null.
  111. * Detects Bambu internal codes (e.g. "A06-D0") and ignores them in favor of hex lookup
  112. * because the same code is not globally unique across material families (#857).
  113. */
  114. export function resolveSpoolColorName(colorName: string | null, rgba: string | null): string | null {
  115. // If color_name looks like a readable name (no pattern like "X00-Y0"), use it directly
  116. if (colorName && !/^[A-Z]\d+-[A-Z]\d+$/.test(colorName)) {
  117. return colorName;
  118. }
  119. if (rgba && rgba.length >= 6) {
  120. const clean = rgba.replace('#', '').toLowerCase();
  121. // Transparent rgba: don't fall through to RGB-based lookup that would
  122. // return 'Black' for #00000000 (#1545).
  123. if (clean.length === 8 && clean.substring(6, 8) === '00') return 'Clear';
  124. const hex = clean.substring(0, 6);
  125. const mapped = runtimeColorCatalog[hex];
  126. if (mapped) return mapped;
  127. }
  128. // Return null (displayed as "-") — better than showing a code
  129. return null;
  130. }
  131. /**
  132. * Build a hex string suitable for SVG `fill=` / props that take a single
  133. * colour value. Preserves the alpha byte when alpha < FF so a transparent
  134. * spool renders translucent in SVG / CSS rather than collapsing to solid
  135. * black (#1545). Null / malformed input falls back to `#808080`.
  136. *
  137. * Prefer `getSwatchStyle` for `style` objects that paint a div background —
  138. * that helper paints a visible checkerboard under transparent fills.
  139. */
  140. export function spoolColorString(rgba: string | null | undefined): string {
  141. if (!rgba) return '#808080';
  142. const clean = rgba.replace(/^#/, '');
  143. if (clean.length < 6) return '#808080';
  144. if (clean.length >= 8 && clean.substring(6, 8).toLowerCase() !== 'ff') {
  145. return `#${clean.substring(0, 8)}`;
  146. }
  147. return `#${clean.substring(0, 6)}`;
  148. }
  149. /**
  150. * Build an inline-style object for a simple filament swatch (a div / button
  151. * background) given a spool's rgba. Opaque colours return a plain
  152. * `backgroundColor`; transparent (alpha=00) returns a small checkerboard
  153. * pattern so the user can see the swatch instead of an invisible element
  154. * (#1545). Null / unparseable input falls back to the neutral `#808080` used
  155. * elsewhere in the codebase.
  156. *
  157. * Use this anywhere a quick swatch was previously painted via
  158. * `style={{ backgroundColor: '#' + rgba.slice(0, 6) }}` — alpha-stripping
  159. * silently turned `Clear` spools into solid black.
  160. *
  161. * NOTE: `FilamentSwatch` already paints a richer checkerboard underlay
  162. * automatically for translucent colours; prefer that for new code and use
  163. * this helper only when retro-fitting an existing simple swatch site.
  164. */
  165. export function getSwatchStyle(rgba: string | null | undefined): {
  166. backgroundColor?: string;
  167. backgroundImage?: string;
  168. backgroundSize?: string;
  169. } {
  170. if (!rgba) return { backgroundColor: '#808080' };
  171. const clean = rgba.replace(/^#/, '');
  172. if (clean.length < 6) return { backgroundColor: '#808080' };
  173. if (clean.length >= 8 && clean.substring(6, 8).toLowerCase() === '00') {
  174. return {
  175. backgroundImage: 'repeating-conic-gradient(#979797 0% 25%, #f5f5f5 0% 50%)',
  176. backgroundSize: '8px 8px',
  177. };
  178. }
  179. return { backgroundColor: `#${clean.substring(0, 6)}` };
  180. }
  181. /**
  182. * Parse an RGBA hex string (e.g., "FF0000FF") to a CSS rgba() color.
  183. * Returns null for empty, all-zero, or fully transparent colors.
  184. */
  185. export function parseFilamentColor(rgba: string): string | null {
  186. if (!rgba || rgba === '00000000' || rgba.length < 6) return null;
  187. const r = rgba.slice(0, 2);
  188. const g = rgba.slice(2, 4);
  189. const b = rgba.slice(4, 6);
  190. const a = rgba.length >= 8 ? parseInt(rgba.slice(6, 8), 16) / 255 : 1;
  191. if (a === 0) return null;
  192. return `rgba(${parseInt(r, 16)}, ${parseInt(g, 16)}, ${parseInt(b, 16)}, ${a})`;
  193. }
  194. /**
  195. * Check if a hex color is light (for choosing text contrast).
  196. * Uses luminance formula: 0.299*R + 0.587*G + 0.114*B.
  197. */
  198. export function isLightColor(hex: string | null): boolean {
  199. if (!hex || hex.length < 6) return false;
  200. const cleanHex = hex.replace('#', '');
  201. // Transparent swatches are painted over the light/mid-gray checkerboard
  202. // underlay, so treat them as light for text-contrast purposes (#1545).
  203. if (cleanHex.length === 8 && cleanHex.slice(6, 8).toLowerCase() === '00') return true;
  204. const r = parseInt(cleanHex.slice(0, 2), 16);
  205. const g = parseInt(cleanHex.slice(2, 4), 16);
  206. const b = parseInt(cleanHex.slice(4, 6), 16);
  207. return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
  208. }