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 { 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>
|
||||||
|
|||||||
@@ -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