/** Shared colour helpers for movement streams (timeline flow, vertical flow, tree). */ /** True when `hex` is a usable #rgb / #rrggbb / #rrggbbaa-style value. */ export function isMovementHexColor(hex: string): boolean { const normalized = hex.replace('#', '').trim(); return /^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6,}$/.test(normalized); } /** * Parse a catalogue colour to RGB. * 3-digit shorthand expands; 6+ digits keep the first six (so `#rrggbbaa` → `#rrggbb`). * Throws on malformed input so callers can fall back. */ export function parseHexColor(hex: string): [number, number, number] { const normalized = hex.replace('#', '').trim(); if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6,}$/.test(normalized)) { throw new Error(`invalid hex colour: ${hex}`); } const value = normalized.length === 3 ? normalized .split('') .map((c) => c + c) .join('') : normalized.slice(0, 6); const n = parseInt(value, 16); if (!Number.isFinite(n)) throw new Error(`invalid hex colour: ${hex}`); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } export function rgbToHsl(r: number, g: number, b: number): [number, number, number] { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const l = (max + min) / 2; if (max === min) return [0, 0, l]; const d = max - min; const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); let h = 0; if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; else if (max === g) h = ((b - r) / d + 2) / 6; else h = ((r - g) / d + 4) / 6; return [h * 360, s, l]; } export function hslToHex(h: number, s: number, l: number): string { const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = l - c / 2; let r = 0; let g = 0; let b = 0; if (h < 60) [r, g, b] = [c, x, 0]; else if (h < 120) [r, g, b] = [x, c, 0]; else if (h < 180) [r, g, b] = [0, c, x]; else if (h < 240) [r, g, b] = [0, x, c]; else if (h < 300) [r, g, b] = [x, 0, c]; else [r, g, b] = [c, 0, x]; const toByte = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0'); return `#${toByte(r)}${toByte(g)}${toByte(b)}`; } /** Lift a muted catalogue colour into a saturated stream colour. */ export function vividMovementColor(hex: string): string { try { if (!isMovementHexColor(hex)) return hex; const [r, g, b] = parseHexColor(hex); const [h, s, l] = rgbToHsl(r, g, b); const s2 = s < 0.1 ? Math.min(0.55, s + 0.42) : Math.min(1, s * 1.65 + 0.08); const l2 = l < 0.22 ? 0.5 : l > 0.78 ? 0.62 : Math.min(0.68, Math.max(0.4, l * 0.75 + 0.28)); return hslToHex(h, s2, l2); } catch { return hex; } } /** Darker variant of a stream colour — used for the shaded side of a tree limb. */ export function shadeMovementColor(hex: string, amount = 0.34): string { try { if (!isMovementHexColor(hex)) return hex; const [r, g, b] = parseHexColor(hex); const [h, s, l] = rgbToHsl(r, g, b); return hslToHex(h, Math.min(1, s * 1.05), Math.max(0.08, l * (1 - amount))); } catch { return hex; } }