colors.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  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. // Names a plain hex lookup cannot reach, keyed "<material>|<hex>". A hex is not
  13. // one colour in Bambu's range -- #FFFFFF is Jade White in PLA Basic and Ivory
  14. // White in PLA Matte -- so a caller that knows the material (an AMS slot knows
  15. // it as tray_sub_brands) gets the right one instead of whichever row the
  16. // backend's collapse happened to keep (#2875).
  17. let runtimeMaterialCatalog: Record<string, string> = {};
  18. let catalogVersion = 0;
  19. const catalogListeners = new Set<() => void>();
  20. export function setColorCatalog(map: Record<string, string>, byMaterial?: Record<string, string>): void {
  21. // Normalize keys to lowercase 6-char hex (no '#'), defensively. Backend already
  22. // does this, but the frontend contract is explicit so callers from tests or
  23. // future integrations can't accidentally break lookups.
  24. const normalized: Record<string, string> = {};
  25. for (const [key, value] of Object.entries(map)) {
  26. if (!key || !value) continue;
  27. const hex = key.replace('#', '').toLowerCase().slice(0, 6);
  28. if (hex.length === 6) normalized[hex] = value;
  29. }
  30. const normalizedByMaterial: Record<string, string> = {};
  31. for (const [key, value] of Object.entries(byMaterial || {})) {
  32. if (!key || !value) continue;
  33. // Split on the LAST separator: a material is free text (users edit the
  34. // catalog) and may itself contain a '|'.
  35. const cut = key.lastIndexOf('|');
  36. if (cut <= 0) continue;
  37. const material = key.slice(0, cut).trim().toLowerCase();
  38. const cleanHex = key.slice(cut + 1).replace('#', '').toLowerCase().slice(0, 6);
  39. if (material && cleanHex.length === 6) normalizedByMaterial[`${material}|${cleanHex}`] = value;
  40. }
  41. runtimeColorCatalog = normalized;
  42. runtimeMaterialCatalog = normalizedByMaterial;
  43. catalogVersion += 1;
  44. // Snapshot listeners to avoid mutation-during-iteration if a listener unsubscribes.
  45. for (const listener of Array.from(catalogListeners)) {
  46. listener();
  47. }
  48. }
  49. export function subscribeColorCatalog(listener: () => void): () => void {
  50. catalogListeners.add(listener);
  51. return () => {
  52. catalogListeners.delete(listener);
  53. };
  54. }
  55. export function getColorCatalogVersion(): number {
  56. return catalogVersion;
  57. }
  58. /** Test-only hook: reset the catalog to empty so unit tests can exercise fallbacks. */
  59. export function __resetColorCatalogForTests(): void {
  60. runtimeColorCatalog = {};
  61. runtimeMaterialCatalog = {};
  62. catalogVersion = 0;
  63. catalogListeners.clear();
  64. }
  65. /**
  66. * Colour families, in the order the Inventory's Color column sorts them (#2729).
  67. *
  68. * Chromatic families run in rainbow order with Brown after them (it is a dark
  69. * orange by hue and would otherwise split the oranges in half), then the
  70. * neutrals light-to-dark, then Clear.
  71. */
  72. export const COLOR_FAMILY_ORDER = [
  73. 'Red',
  74. 'Orange',
  75. 'Yellow',
  76. 'Green',
  77. 'Cyan',
  78. 'Blue',
  79. 'Purple',
  80. 'Pink',
  81. 'Brown',
  82. 'White',
  83. 'Light Gray',
  84. 'Gray',
  85. 'Dark Gray',
  86. 'Black',
  87. 'Clear',
  88. ] as const;
  89. export type ColorFamily = (typeof COLOR_FAMILY_ORDER)[number];
  90. /** Families whose hue carries no meaning — see ``colorSortKey``. */
  91. const ACHROMATIC_FAMILIES = new Set<ColorFamily>([
  92. 'White',
  93. 'Light Gray',
  94. 'Gray',
  95. 'Dark Gray',
  96. 'Black',
  97. 'Clear',
  98. ]);
  99. interface Hsl {
  100. h: number;
  101. s: number;
  102. l: number;
  103. }
  104. /** Parse 6/8-char hex (with or without '#') to HSL, or null if unparseable. */
  105. function hexToHsl(hex: string | null | undefined): Hsl | null {
  106. if (!hex || hex.length < 6) return null;
  107. const cleanHex = hex.replace('#', '');
  108. const r = parseInt(cleanHex.substring(0, 2), 16);
  109. const g = parseInt(cleanHex.substring(2, 4), 16);
  110. const b = parseInt(cleanHex.substring(4, 6), 16);
  111. if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
  112. const max = Math.max(r, g, b) / 255;
  113. const min = Math.min(r, g, b) / 255;
  114. const l = (max + min) / 2;
  115. let h = 0;
  116. let s = 0;
  117. if (max !== min) {
  118. const d = max - min;
  119. s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
  120. const rNorm = r / 255, gNorm = g / 255, bNorm = b / 255;
  121. if (max === rNorm) h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
  122. else if (max === gNorm) h = ((bNorm - rNorm) / d + 2) / 6;
  123. else h = ((rNorm - gNorm) / d + 4) / 6;
  124. }
  125. return { h: h * 360, s, l };
  126. }
  127. /**
  128. * Classify a hex colour into one of ``COLOR_FAMILY_ORDER``, or null if the
  129. * value can't be parsed.
  130. *
  131. * This is the single source of truth for both the fallback colour *name* shown
  132. * when a hex isn't in the catalog and the *order* the Color column sorts in, so
  133. * the two cannot drift into disagreeing about what counts as brown or grey.
  134. */
  135. export function colorFamily(hex: string | null | undefined): ColorFamily | null {
  136. if (!hex || hex.length < 6) return null;
  137. const cleanHex = hex.replace('#', '');
  138. // Alpha=00 → fully transparent. Classify as 'Clear' before looking at RGB,
  139. // otherwise #00000000 (Bambu's transparent code) would come out 'Black' (#1545).
  140. if (cleanHex.length === 8 && cleanHex.substring(6, 8).toLowerCase() === '00') {
  141. return 'Clear';
  142. }
  143. const hsl = hexToHsl(cleanHex);
  144. if (!hsl) return null;
  145. const { h, s, l } = hsl;
  146. if (l < 0.15) return 'Black';
  147. if (l > 0.85) return 'White';
  148. if (s < 0.15) {
  149. if (l < 0.4) return 'Dark Gray';
  150. if (l > 0.6) return 'Light Gray';
  151. return 'Gray';
  152. }
  153. // Brown is orange/yellow hue with lower lightness
  154. if (h >= 15 && h < 45 && l < 0.45) return 'Brown';
  155. if (h >= 45 && h < 70 && l < 0.40) return 'Brown';
  156. if (h < 15 || h >= 345) return 'Red';
  157. if (h < 45) return 'Orange';
  158. if (h < 70) return 'Yellow';
  159. if (h < 150) return 'Green';
  160. if (h < 200) return 'Cyan';
  161. if (h < 260) return 'Blue';
  162. if (h < 290) return 'Purple';
  163. return 'Pink';
  164. }
  165. /**
  166. * Convert hex color to basic color name using HSL analysis.
  167. * Used as fallback when hex is not in the runtime catalog.
  168. */
  169. export function hexToColorName(hex: string | null | undefined): string {
  170. return colorFamily(hex) ?? 'Unknown';
  171. }
  172. /**
  173. * Sort key placing a spool colour in rainbow order (#2729, reporter @macwhiz).
  174. *
  175. * Returns a fixed-width string so it drops into the Inventory table's existing
  176. * ``string | number`` comparison with no extra plumbing, ascending = Red first.
  177. *
  178. * The issue asked for a straight hue → saturation → lightness sort, which does
  179. * not survive contact with a real inventory: a near-neutral still has a hue and
  180. * it can be anything. Measured against a 30-spool inventory, Titan Gray
  181. * (5F6367, hue 210°, saturation 0.04) landed between Sky Blue and Purple, and
  182. * 8B8889 (hue 340°, saturation 0.01) landed between Purple and Burgundy Red.
  183. * Black, white and silver happen to clump correctly — zero saturation sorts
  184. * first within hue 0 — but anything a shade off neutral flies into the colours.
  185. *
  186. * So the family from ``colorFamily`` leads, and the continuous sort the issue
  187. * asked for runs inside each family. Within a neutral family hue is discarded
  188. * rather than sorted on, for the same reason it is not trusted to pick the
  189. * family: ordering greys by 210° vs 340° is ordering them by noise. Neutrals go
  190. * light-to-dark instead, matching the order of the families themselves.
  191. *
  192. * Unparseable or missing colours sort last in ascending order, so a spool with
  193. * no colour recorded never leads the list.
  194. */
  195. export function colorSortKey(rgba: string | null | undefined): string {
  196. const family = colorFamily(rgba);
  197. if (!family) return '99|0000|0000|0000';
  198. const rank = String(COLOR_FAMILY_ORDER.indexOf(family)).padStart(2, '0');
  199. const hsl = hexToHsl(rgba) ?? { h: 0, s: 0, l: 0 };
  200. const pad4 = (n: number) => String(Math.round(n)).padStart(4, '0');
  201. if (ACHROMATIC_FAMILIES.has(family)) {
  202. // Lightness descending, so lighter shades lead within the family just as
  203. // White leads Black across families. Hue is deliberately zeroed.
  204. return `${rank}|0000|${pad4(1000 - hsl.l * 1000)}|${pad4(hsl.s * 1000)}`;
  205. }
  206. return `${rank}|${pad4(hsl.h * 10)}|${pad4(hsl.s * 1000)}|${pad4(hsl.l * 1000)}`;
  207. }
  208. /**
  209. * Get color name from hex color.
  210. * Looks up the runtime color catalog (backend-sourced), then falls back to HSL.
  211. *
  212. * Pass `material` whenever the caller knows which variant the colour belongs to
  213. * -- for an AMS slot that is the printer's own `tray_sub_brands` ("PLA Matte").
  214. * Without it a white Matte spool reads "Jade White", the PLA Basic name that
  215. * shares its hex, because the flat map can only keep one name per hex (#2875).
  216. * An unknown material falls through to the flat lookup, so passing one can only
  217. * ever improve the answer.
  218. */
  219. export function getColorName(hexColor: string, material?: string | null): string {
  220. if (!hexColor) return hexToColorName(hexColor);
  221. const clean = hexColor.replace('#', '').toLowerCase();
  222. if (clean.length === 8 && clean.substring(6, 8) === '00') return 'Clear';
  223. const hex = clean.substring(0, 6);
  224. if (material) {
  225. const qualified = runtimeMaterialCatalog[`${material.trim().toLowerCase()}|${hex}`];
  226. if (qualified) return qualified;
  227. }
  228. const mapped = runtimeColorCatalog[hex];
  229. if (mapped) return mapped;
  230. return hexToColorName(hexColor);
  231. }
  232. /**
  233. * Label two colours so a reader can tell which is which.
  234. *
  235. * `getColorName` resolves a hex against the catalogue and falls back to a
  236. * coarse family bucket, so a slicer profile's near-pure `#0028FF` and Bambu's
  237. * navy `#0A2989` are both called "Blue". A mismatch warning between those two
  238. * then reads as a contradiction of the two identical names printed either side
  239. * of it, which is the whole of #2941: the comparison was right, the labels gave
  240. * the user no way to see what it was comparing.
  241. *
  242. * When the names collide the hex is appended to both, because that is what
  243. * actually differs. Distinct names are returned untouched -- once the words
  244. * separate them the hex is noise. A side with no name falls back to its hex, and
  245. * a pair with no usable hex at all keeps the bare names rather than growing an
  246. * empty "()".
  247. */
  248. export function disambiguateColorNames(
  249. first: { name?: string | null; hex?: string | null },
  250. second: { name?: string | null; hex?: string | null },
  251. ): [string, string] {
  252. const hexLabel = (hex?: string | null): string => {
  253. const clean = (hex ?? '').replace('#', '').trim().slice(0, 6).toUpperCase();
  254. return /^[0-9A-F]{6}$/.test(clean) ? `#${clean}` : '';
  255. };
  256. const firstName = (first.name ?? '').trim();
  257. const secondName = (second.name ?? '').trim();
  258. const firstHex = hexLabel(first.hex);
  259. const secondHex = hexLabel(second.hex);
  260. if (!firstName || !secondName) return [firstName || firstHex, secondName || secondHex];
  261. if (firstName.toLowerCase() !== secondName.toLowerCase()) return [firstName, secondName];
  262. if (!firstHex && !secondHex) return [firstName, secondName];
  263. return [
  264. firstHex ? `${firstName} (${firstHex})` : firstName,
  265. secondHex ? `${secondName} (${secondHex})` : secondName,
  266. ];
  267. }
  268. /**
  269. * Resolve a spool's display color name.
  270. * Tries: stored color_name (if it's a readable name) → runtime catalog via rgba → null.
  271. * Detects Bambu internal codes (e.g. "A06-D0") and ignores them in favor of hex lookup
  272. * because the same code is not globally unique across material families (#857).
  273. */
  274. export function resolveSpoolColorName(colorName: string | null, rgba: string | null): string | null {
  275. // If color_name looks like a readable name (no pattern like "X00-Y0"), use it directly
  276. if (colorName && !/^[A-Z]\d+-[A-Z]\d+$/.test(colorName)) {
  277. return colorName;
  278. }
  279. if (rgba && rgba.length >= 6) {
  280. const clean = rgba.replace('#', '').toLowerCase();
  281. // Transparent rgba: don't fall through to RGB-based lookup that would
  282. // return 'Black' for #00000000 (#1545).
  283. if (clean.length === 8 && clean.substring(6, 8) === '00') return 'Clear';
  284. const hex = clean.substring(0, 6);
  285. const mapped = runtimeColorCatalog[hex];
  286. if (mapped) return mapped;
  287. }
  288. // Return null (displayed as "-") — better than showing a code
  289. return null;
  290. }
  291. /**
  292. * Build a hex string suitable for SVG `fill=` / props that take a single
  293. * colour value. Preserves the alpha byte when alpha < FF so a transparent
  294. * spool renders translucent in SVG / CSS rather than collapsing to solid
  295. * black (#1545). Null / malformed input falls back to `#808080`.
  296. *
  297. * Prefer `getSwatchStyle` for `style` objects that paint a div background —
  298. * that helper paints a visible checkerboard under transparent fills.
  299. */
  300. export function spoolColorString(rgba: string | null | undefined): string {
  301. if (!rgba) return '#808080';
  302. const clean = rgba.replace(/^#/, '');
  303. if (clean.length < 6) return '#808080';
  304. if (clean.length >= 8 && clean.substring(6, 8).toLowerCase() !== 'ff') {
  305. return `#${clean.substring(0, 8)}`;
  306. }
  307. return `#${clean.substring(0, 6)}`;
  308. }
  309. /**
  310. * Build an inline-style object for a simple filament swatch (a div / button
  311. * background) given a spool's rgba. Opaque colours return a plain
  312. * `backgroundColor`; transparent (alpha=00) returns a small checkerboard
  313. * pattern so the user can see the swatch instead of an invisible element
  314. * (#1545). Null / unparseable input falls back to the neutral `#808080` used
  315. * elsewhere in the codebase.
  316. *
  317. * Use this anywhere a quick swatch was previously painted via
  318. * `style={{ backgroundColor: '#' + rgba.slice(0, 6) }}` — alpha-stripping
  319. * silently turned `Clear` spools into solid black.
  320. *
  321. * NOTE: `FilamentSwatch` already paints a richer checkerboard underlay
  322. * automatically for translucent colours; prefer that for new code and use
  323. * this helper only when retro-fitting an existing simple swatch site.
  324. */
  325. export function getSwatchStyle(rgba: string | null | undefined): {
  326. backgroundColor?: string;
  327. backgroundImage?: string;
  328. backgroundSize?: string;
  329. } {
  330. if (!rgba) return { backgroundColor: '#808080' };
  331. const clean = rgba.replace(/^#/, '');
  332. if (clean.length < 6) return { backgroundColor: '#808080' };
  333. if (clean.length >= 8 && clean.substring(6, 8).toLowerCase() === '00') {
  334. return {
  335. backgroundImage: 'repeating-conic-gradient(#979797 0% 25%, #f5f5f5 0% 50%)',
  336. backgroundSize: '8px 8px',
  337. };
  338. }
  339. return { backgroundColor: `#${clean.substring(0, 6)}` };
  340. }
  341. /**
  342. * Parse an RGBA hex string (e.g., "FF0000FF") to a CSS rgba() color.
  343. * Returns null for empty, all-zero, or fully transparent colors.
  344. */
  345. export function parseFilamentColor(rgba: string): string | null {
  346. if (!rgba || rgba === '00000000' || rgba.length < 6) return null;
  347. const r = rgba.slice(0, 2);
  348. const g = rgba.slice(2, 4);
  349. const b = rgba.slice(4, 6);
  350. const a = rgba.length >= 8 ? parseInt(rgba.slice(6, 8), 16) / 255 : 1;
  351. if (a === 0) return null;
  352. return `rgba(${parseInt(r, 16)}, ${parseInt(g, 16)}, ${parseInt(b, 16)}, ${a})`;
  353. }
  354. /**
  355. * Check if a hex color is light (for choosing text contrast).
  356. * Uses luminance formula: 0.299*R + 0.587*G + 0.114*B.
  357. */
  358. export function isLightColor(hex: string | null): boolean {
  359. if (!hex || hex.length < 6) return false;
  360. const cleanHex = hex.replace('#', '');
  361. // Transparent swatches are painted over the light/mid-gray checkerboard
  362. // underlay, so treat them as light for text-contrast purposes (#1545).
  363. if (cleanHex.length === 8 && cleanHex.slice(6, 8).toLowerCase() === '00') return true;
  364. const r = parseInt(cleanHex.slice(0, 2), 16);
  365. const g = parseInt(cleanHex.slice(2, 4), 16);
  366. const b = parseInt(cleanHex.slice(4, 6), 16);
  367. return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
  368. }