From 33b8ae5a5f0d0fe2432f0588b35ebc42680f3df6 Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Thu, 13 Aug 2026 12:43:49 +0300 Subject: [PATCH] Extract shared movement colour helpers and harden hex parsing. Co-authored-by: Cursor --- client/src/components/MovementBands.tsx | 69 +------------- .../src/components/VerticalMovementBands.tsx | 61 +------------ client/src/utils/movementColor.ts | 90 +++++++++++++++++++ 3 files changed, 92 insertions(+), 128 deletions(-) create mode 100644 client/src/utils/movementColor.ts diff --git a/client/src/components/MovementBands.tsx b/client/src/components/MovementBands.tsx index d6b3de4..db6e456 100644 --- a/client/src/components/MovementBands.tsx +++ b/client/src/components/MovementBands.tsx @@ -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 { diff --git a/client/src/components/VerticalMovementBands.tsx b/client/src/components/VerticalMovementBands.tsx index d185ba6..6878148 100644 --- a/client/src/components/VerticalMovementBands.tsx +++ b/client/src/components/VerticalMovementBands.tsx @@ -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 diff --git a/client/src/utils/movementColor.ts b/client/src/utils/movementColor.ts new file mode 100644 index 0000000..5af4ac5 --- /dev/null +++ b/client/src/utils/movementColor.ts @@ -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; + } +}