Extract shared movement colour helpers and harden hex parsing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-08-13 12:43:49 +03:00
co-authored by Cursor
parent 971c1e8dd8
commit 33b8ae5a5f
3 changed files with 92 additions and 128 deletions
+1 -68
View File
@@ -4,6 +4,7 @@ import { portraitThumbUrl } from '../api/client';
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc'; import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
import { MOVEMENT_LINEAGE } from '../data/movement-lineage'; import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
import { panTimelineView, zoomTimelineView } from '../utils/timelineView'; import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
import { parseHexColor, rgbToHsl, hslToHex, vividMovementColor } from '../utils/movementColor';
import './MovementBands.css'; import './MovementBands.css';
interface Props { interface Props {
@@ -244,74 +245,6 @@ function buildArtistPlacements(
return placements; return placements;
} }
function parseHexColor(hex: string): [number, number, number] {
const normalized = hex.replace('#', '');
const value =
normalized.length === 3
? normalized
.split('')
.map((c) => c + c)
.join('')
: normalized.padStart(6, '0').slice(0, 6);
return [
parseInt(value.slice(0, 2), 16),
parseInt(value.slice(2, 4), 16),
parseInt(value.slice(4, 6), 16),
];
}
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
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 === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
else h = ((rn - gn) / d + 4) / 6;
return [h * 360, s, l];
}
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)}`;
}
/** Boost saturation / mid lightness so streams read vividly on the dark flow canvas. */
function vividMovementColor(hex: string): string {
try {
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;
}
}
function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: number): string { function artistLifespanColor(baseColor: string, laneIndex: number, laneCount: number): string {
if (laneCount <= 1) return baseColor; if (laneCount <= 1) return baseColor;
try { try {
@@ -2,6 +2,7 @@ import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } fr
import type { ArtMovement } from '../types'; import type { ArtMovement } from '../types';
import { MOVEMENT_LINEAGE } from '../data/movement-lineage'; import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
import { panTimelineView, zoomTimelineView } from '../utils/timelineView'; import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
import { vividMovementColor } from '../utils/movementColor';
import './VerticalMovementBands.css'; import './VerticalMovementBands.css';
interface Props { interface Props {
@@ -51,66 +52,6 @@ function yearToBottomPercent(year: number, start: number, end: number): number {
return ((year - start) / (end - start)) * 100; return ((year - start) / (end - start)) * 100;
} }
function parseHexColor(hex: string): [number, number, number] {
const normalized = hex.replace('#', '');
const value =
normalized.length === 3
? normalized
.split('')
.map((c) => c + c)
.join('')
: normalized;
const n = parseInt(value, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
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];
}
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)}`;
}
function vividMovementColor(hex: string): string {
try {
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;
}
}
function buildLineageParentMap( function buildLineageParentMap(
visible: ArtMovement[], visible: ArtMovement[],
nameToId: Map<string, number> nameToId: Map<string, number>
+90
View File
@@ -0,0 +1,90 @@
/** 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;
}
}