91 lines
3.1 KiB
TypeScript
91 lines
3.1 KiB
TypeScript
/** 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;
|
|
}
|
|
}
|