const MISSING_LABEL_SENTINEL = "undefined"; export const MISSING_LABEL_COLOR = "#39d3cc"; // matches --accent-cyan export const FALLBACK_LABEL_COLOR = "#8b949e"; // matches --muted-foreground // Rerun-style auto colors: distribute hues by golden ratio conjugate. // See: context/repos/rerun/crates/viewer/re_viewer_context/src/utils/color.rs const GOLDEN_RATIO_CONJUGATE = (Math.sqrt(5) - 1) / 2; // ~0.61803398875 function fnv1a32(input: string): number { // 32-bit FNV-1a hash let hash = 0x811c9dc5; for (let i = 0; i < input.length; i++) { hash ^= input.charCodeAt(i); hash = Math.imul(hash, 0x01000193); } return hash >>> 0; } function clamp01(v: number): number { if (v < 0) return 0; if (v > 1) return 1; return v; } function hsvToHex(h: number, s: number, v: number): string { // h/s/v in [0, 1] const hh = ((h % 1) + 1) % 1; const ss = clamp01(s); const vv = clamp01(v); const x = hh * 6; const i = Math.floor(x); const f = x - i; const p = vv * (1 - ss); const q = vv * (1 - ss * f); const t = vv * (1 - ss * (1 - f)); let r = 0; let g = 0; let b = 0; switch (i % 6) { case 0: r = vv; g = t; b = p; break; case 1: r = q; g = vv; b = p; break; case 2: r = p; g = vv; b = t; break; case 3: r = p; g = q; b = vv; break; case 4: r = t; g = p; b = vv; break; case 5: r = vv; g = p; b = q; break; } const toHex = (x: number) => { const n = Math.round(clamp01(x) * 255); return n.toString(16).padStart(2, "0"); }; return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } export function labelToColor(label: string): string { if (!label) return FALLBACK_LABEL_COLOR; if (label === MISSING_LABEL_SENTINEL) return MISSING_LABEL_COLOR; return colorForKey(label); } function colorForKey(key: string): string { const seed = fnv1a32(key); const val = seed & 0xffff; // map to u16 const h = (val * GOLDEN_RATIO_CONJUGATE) % 1; // Match Rerun's defaults: saturation 0.85, value 0.5 return hsvToHex(h, 0.85, 0.5); } function stableLabelSort(a: string, b: string): number { if (a === MISSING_LABEL_SENTINEL && b !== MISSING_LABEL_SENTINEL) return 1; if (b === MISSING_LABEL_SENTINEL && a !== MISSING_LABEL_SENTINEL) return -1; return a.localeCompare(b); } /** * Builds a deterministic, collision-free label → color mapping for the given * label universe. * * Notes: * - No modulo/cycling: if there are N labels, we produce N colors. * - Deterministic: same input set yields same mapping. * - Collision-free within the provided set via deterministic rehashing. */ export function createLabelColorMap(labels: string[]): Record { const unique = Array.from(new Set(labels.map((l) => normalizeLabel(l)))).sort(stableLabelSort); const colors: Record = {}; const used = new Set(); for (const label of unique) { if (label === MISSING_LABEL_SENTINEL) { colors[label] = MISSING_LABEL_COLOR; used.add(MISSING_LABEL_COLOR.toLowerCase()); continue; } let attempt = 0; // Deterministic collision resolution (should be extremely rare). while (attempt < 32) { const candidate = colorForKey(attempt === 0 ? label : `${label}#${attempt}`); const normalized = candidate.toLowerCase(); if (!used.has(normalized)) { colors[label] = candidate; used.add(normalized); break; } attempt++; } if (!colors[label]) { // Should never happen, but keep UI resilient. colors[label] = FALLBACK_LABEL_COLOR; } } return colors; } export function normalizeLabel(label: string | null | undefined): string { return label && label.length > 0 ? label : MISSING_LABEL_SENTINEL; }