/** * Shared color scales and color math for the MINT SDK. * * ## Why the exported values are hex, not `var(--…)` * * Every scale below is mirrored by a CSS custom property in * `styles/variables.css` (`--mint-sample-1…9`, `--mint-slot-r/g/b/y`), so * stylesheets and `:style` bindings can stay theme-aware by referencing the * token. The JavaScript constants deliberately carry the plain hex **fallback** * for the same slot, because these values are also * * - persisted into experiment design data (`SampleType.color`), where a * `var(--…)` string would be meaningless outside the browser, and * - handed to canvas / SVG / export code paths, which cannot resolve CSS * custom properties. * * Keep the two in sync: a change here must be mirrored in `variables.css`. */ import type { SlotPosition } from '../types/componentLabTypes' /** * Canonical categorical scale for sample types, plate maps, dose-response * series and legends. Mirrors `--mint-sample-1` … `--mint-sample-9`. */ export const SAMPLE_COLOR_SCALE: readonly string[] = [ '#3B82F6', // blue '#10B981', // green '#EF4444', // red '#F59E0B', // amber '#8B5CF6', // purple '#F97316', // orange '#06B6D4', // cyan '#14B8A6', // teal '#6B7280', // gray ] /** * Group palette as CSS tokens (3a): groups always take `--mint-sample-1` … * `--mint-sample-8`; the 9th (gray) stays out of the group cycle. */ export const SAMPLE_GROUP_TOKENS: readonly string[] = [ 'var(--mint-sample-1)', 'var(--mint-sample-2)', 'var(--mint-sample-3)', 'var(--mint-sample-4)', 'var(--mint-sample-5)', 'var(--mint-sample-6)', 'var(--mint-sample-7)', 'var(--mint-sample-8)', ] /** * Slot-position colors (R/G/B/Y rack positions). * Mirrors `--mint-slot-r`, `--mint-slot-g`, `--mint-slot-b`, `--mint-slot-y`. */ export const SLOT_COLORS: Record = { R: '#ef4444', // red G: '#22c55e', // green B: '#3b82f6', // blue Y: '#eab308', // yellow } /** Slot positions in their canonical display order. */ export const SLOT_ORDER: readonly SlotPosition[] = ['R', 'G', 'B', 'Y'] /** * Color utilities for deriving same-hue-family shades. * * Used by SampleSelector and other group/legend UIs where a parent color * needs to seed a gradient of related shades for its children. */ export interface Hsl { h: number // 0–360 s: number // 0–100 l: number // 0–100 } const HEX_RE = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i function clamp(n: number, min: number, max: number): number { return Math.min(max, Math.max(min, n)) } export function hexToHsl(hex: string): Hsl { const match = HEX_RE.exec(hex.trim()) if (!match) return { h: 0, s: 0, l: 50 } let raw = match[1] if (raw.length === 3) raw = raw.split('').map(c => c + c).join('') const r = parseInt(raw.slice(0, 2), 16) / 255 const g = parseInt(raw.slice(2, 4), 16) / 255 const b = parseInt(raw.slice(4, 6), 16) / 255 const max = Math.max(r, g, b) const min = Math.min(r, g, b) const delta = max - min let h = 0 if (delta !== 0) { if (max === r) h = ((g - b) / delta) % 6 else if (max === g) h = (b - r) / delta + 2 else h = (r - g) / delta + 4 } h = (h * 60 + 360) % 360 const l = (max + min) / 2 const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)) return { h, s: s * 100, l: l * 100 } } export function hslToHex(h: number, s: number, l: number): string { const sNorm = clamp(s, 0, 100) / 100 const lNorm = clamp(l, 0, 100) / 100 const hNorm = ((h % 360) + 360) % 360 const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm const x = c * (1 - Math.abs(((hNorm / 60) % 2) - 1)) const m = lNorm - c / 2 let r = 0, g = 0, b = 0 if (hNorm < 60) [r, g, b] = [c, x, 0] else if (hNorm < 120) [r, g, b] = [x, c, 0] else if (hNorm < 180) [r, g, b] = [0, c, x] else if (hNorm < 240) [r, g, b] = [0, x, c] else if (hNorm < 300) [r, g, b] = [x, 0, c] else [r, g, b] = [c, 0, x] const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0') return `#${toHex(r)}${toHex(g)}${toHex(b)}` } /** * Derive a same-hue-family shade of `parentHex` for child at `index` of `total`. * * Lightness is distributed evenly within [35, 75] so all children remain * readable on light/dark backgrounds. Hue and saturation are inherited from * the parent. With `total <= 1`, returns the parent unchanged. * * Children are ordered light → dark by index, so index 0 is the brightest * sibling and index `total - 1` is the darkest. */ export function deriveShade(parentHex: string, index: number, total: number): string { if (total <= 1) return parentHex const { h, s, l } = hexToHsl(parentHex) const L_MIN = 35 const L_MAX = 75 const spread = Math.min(L_MAX - L_MIN, 40) const center = clamp(l, L_MIN + spread / 2, L_MAX - spread / 2) const top = center + spread / 2 const bot = center - spread / 2 const t = total === 1 ? 0.5 : index / (total - 1) const childL = top - t * (top - bot) const childS = clamp(s, 35, 100) return hslToHex(h, childS, childL) }