Extract shared movement colour helpers and harden hex parsing.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
971c1e8dd8
commit
33b8ae5a5f
@@ -4,6 +4,7 @@ import { portraitThumbUrl } from '../api/client';
|
||||
import { useQueuedImageSrc } from '../hooks/useQueuedImageSrc';
|
||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||
import { parseHexColor, rgbToHsl, hslToHex, vividMovementColor } from '../utils/movementColor';
|
||||
import './MovementBands.css';
|
||||
|
||||
interface Props {
|
||||
@@ -244,74 +245,6 @@ function buildArtistPlacements(
|
||||
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 {
|
||||
if (laneCount <= 1) return baseColor;
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useState } fr
|
||||
import type { ArtMovement } from '../types';
|
||||
import { MOVEMENT_LINEAGE } from '../data/movement-lineage';
|
||||
import { panTimelineView, zoomTimelineView } from '../utils/timelineView';
|
||||
import { vividMovementColor } from '../utils/movementColor';
|
||||
import './VerticalMovementBands.css';
|
||||
|
||||
interface Props {
|
||||
@@ -51,66 +52,6 @@ function yearToBottomPercent(year: number, start: number, end: number): number {
|
||||
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(
|
||||
visible: ArtMovement[],
|
||||
nameToId: Map<string, number>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user