import { Vec4 } from '../src/index'; // Colour conversion helpers function srgbToLinear(c: number): number { return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; } function linearToSrgb(c: number): number { return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055; } function clamp01(v: number): number { return Math.max(0, Math.min(1, v)); } // sRGB Vec4 -> [L, a, b] in Oklab function vec4ToOklab(v: Vec4): [number, number, number] { const r = srgbToLinear(v.x); const g = srgbToLinear(v.y); const b = srgbToLinear(v.z); const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; const l_ = Math.cbrt(l); const m_ = Math.cbrt(m); const s_ = Math.cbrt(s); return [ 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_, ]; } // [L, a, b] Oklab -> sRGB Vec4 function oklabToVec4(L: number, a: number, b: number, alpha: number): Vec4 { const l_ = L + 0.3963377774 * a + 0.2158037573 * b; const m_ = L - 0.1055613458 * a - 0.0638541728 * b; const s_ = L - 0.0894841775 * a - 1.2914855480 * b; const l = l_ * l_ * l_; const m = m_ * m_ * m_; const s = s_ * s_ * s_; const linR = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; const linG = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; const linB = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s; return new Vec4( clamp01(linearToSrgb(Math.max(0, linR))), clamp01(linearToSrgb(Math.max(0, linG))), clamp01(linearToSrgb(Math.max(0, linB))), alpha, ); } // sRGB Vec4 -> [L, C, H] in Oklch function vec4ToOklch(v: Vec4): [number, number, number] { const [L, a, b] = vec4ToOklab(v); return [L, Math.sqrt(a * a + b * b), Math.atan2(b, a)]; } // Lerp hue angle via shortest arc function lerpHue(a: number, b: number, t: number): number { let diff = b - a; if (diff > Math.PI) diff -= 2 * Math.PI; if (diff < -Math.PI) diff += 2 * Math.PI; return a + diff * t; } // Interpolation functions function lerpSrgb(a: Vec4, b: Vec4, t: number): Vec4 { return Vec4.lerp(a, b, t) as Vec4; } function lerpLinearLight(a: Vec4, b: Vec4, t: number): Vec4 { const aL = new Vec4(srgbToLinear(a.x), srgbToLinear(a.y), srgbToLinear(a.z), a.w); const bL = new Vec4(srgbToLinear(b.x), srgbToLinear(b.y), srgbToLinear(b.z), b.w); const mixed = Vec4.lerp(aL, bL, t) as Vec4; return new Vec4( clamp01(linearToSrgb(mixed.x)), clamp01(linearToSrgb(mixed.y)), clamp01(linearToSrgb(mixed.z)), mixed.w, ); } function lerpOklab(a: Vec4, b: Vec4, t: number): Vec4 { const [La, aa, ba] = vec4ToOklab(a); const [Lb, ab, bb] = vec4ToOklab(b); return oklabToVec4( La + (Lb - La) * t, aa + (ab - aa) * t, ba + (bb - ba) * t, a.w + (b.w - a.w) * t, ); } function lerpOklch(a: Vec4, b: Vec4, t: number): Vec4 { const [La, Ca, Ha] = vec4ToOklch(a); const [Lb, Cb, Hb] = vec4ToOklch(b); return oklabToVec4( La + (Lb - La) * t, (Ca + (Cb - Ca) * t) * Math.cos(lerpHue(Ha, Hb, t)), (Ca + (Cb - Ca) * t) * Math.sin(lerpHue(Ha, Hb, t)), a.w + (b.w - a.w) * t, ); } // Mode definitions interface Mode { name: string; formula: string; interpolate: (a: Vec4, b: Vec4, t: number) => Vec4; } const EASING_MODES: Mode[] = [ { name: 'Linear', formula: 'Vec4.lerp(a, b, t)', interpolate: (a, b, t) => lerpSrgb(a, b, t), }, { name: 'Smoothstep', formula: 'Vec4.lerp(a, b, t²(3−2t))', interpolate: (a, b, t) => lerpSrgb(a, b, t * t * (3 - 2 * t)), }, { name: 'Ease In — cubic', formula: 'Vec4.lerp(a, b, t³)', interpolate: (a, b, t) => lerpSrgb(a, b, t * t * t), }, { name: 'Ease Out — cubic', formula: 'Vec4.lerp(a, b, 1−(1−t)³)', interpolate: (a, b, t) => lerpSrgb(a, b, 1 - (1 - t) ** 3), }, { name: 'Ease In-Out — sine', formula: 'Vec4.lerp(a, b, −(cos(πt)−1)/2)', interpolate: (a, b, t) => lerpSrgb(a, b, -(Math.cos(Math.PI * t) - 1) / 2), }, ]; const SPACE_MODES: Mode[] = [ { name: 'Gamma-correct (linear light)', formula: 'linearise -> lerp -> re-linearise', interpolate: lerpLinearLight, }, { name: 'Oklab', formula: 'sRGB -> Oklab -> lerp L,a,b -> sRGB', interpolate: lerpOklab, }, { name: 'Oklch — hue arc', formula: 'sRGB -> Oklch -> lerp L,C,H -> sRGB', interpolate: lerpOklch, }, ]; // State function hexToVec4(hex: string): Vec4 { const r = parseInt(hex.slice(1, 3), 16) / 255; const g = parseInt(hex.slice(3, 5), 16) / 255; const b = parseInt(hex.slice(5, 7), 16) / 255; return new Vec4(r, g, b, 1); } function vec4ToCss(v: Vec4): string { return `rgb(${Math.round(v.x * 255)}, ${Math.round(v.y * 255)}, ${Math.round(v.z * 255)})`; } function fmtVec4(v: Vec4): string { return `Vec4(${v.x.toFixed(2)}, ${v.y.toFixed(2)}, ${v.z.toFixed(2)}, ${v.w.toFixed(2)})`; } let colA = hexToVec4('#e63b5e'); let colB = hexToVec4('#22b0f0'); let t = 0.5; // DOM const colorAInput = document.getElementById('colorA') as HTMLInputElement; const colorBInput = document.getElementById('colorB') as HTMLInputElement; const tSlider = document.getElementById('tSlider') as HTMLInputElement; const tNum = document.getElementById('tNum') as HTMLInputElement; const vecAEl = document.getElementById('vecA')!; const vecBEl = document.getElementById('vecB')!; const resultSwatch = document.getElementById('result-swatch')!; const resultVec = document.getElementById('result-vec')!; const gradPanel = document.getElementById('grad-panel')!; // Build gradient strip elements interface Strip { canvas: HTMLCanvasElement; dot: HTMLDivElement; val: HTMLSpanElement; mode: Mode; } function buildStrips(modes: Mode[], sectionTitle: string): Strip[] { const sectionEl = document.createElement('div'); sectionEl.className = 'grad-section-header'; sectionEl.textContent = sectionTitle; gradPanel.appendChild(sectionEl); return modes.map(mode => { const container = document.createElement('div'); container.className = 'grad-strip'; const header = document.createElement('div'); header.className = 'grad-header'; header.innerHTML = `${mode.name}${mode.formula}`; const row = document.createElement('div'); row.className = 'grad-row'; const cvs = document.createElement('canvas'); cvs.className = 'grad-canvas'; const dot = document.createElement('div'); dot.className = 'grad-dot'; const val = document.createElement('span'); val.className = 'grad-val'; row.appendChild(cvs); row.appendChild(dot); container.appendChild(header); container.appendChild(row); container.appendChild(val); gradPanel.appendChild(container); return { canvas: cvs, dot, val, mode }; }); } const easingStrips = buildStrips(EASING_MODES, 'Easing — in sRGB space'); const spaceStrips = buildStrips(SPACE_MODES, 'Colour-space interpolation'); const allStrips = [...easingStrips, ...spaceStrips]; // Render function renderStrip(strip: Strip) { const cvs = strip.canvas; const W = cvs.clientWidth; const H = cvs.clientHeight; if (W === 0) return; const dpr = window.devicePixelRatio || 1; cvs.width = W * dpr; cvs.height = H * dpr; const ctx = cvs.getContext('2d')!; ctx.scale(dpr, dpr); for (let px = 0; px < W; px++) { const pxT = px / (W - 1); const col = strip.mode.interpolate(colA, colB, pxT); ctx.fillStyle = vec4ToCss(col); ctx.fillRect(px, 0, 1, H); } // t marker line const markerX = t * (W - 1); ctx.strokeStyle = 'rgba(255,255,255,0.85)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(markerX, 0); ctx.lineTo(markerX, H); ctx.stroke(); } function renderAll() { vecAEl.textContent = fmtVec4(colA); vecBEl.textContent = fmtVec4(colB); const linearCol = Vec4.lerp(colA, colB, t) as Vec4; resultSwatch.style.background = vec4ToCss(linearCol); resultVec.textContent = fmtVec4(linearCol); for (const strip of allStrips) { renderStrip(strip); const col = strip.mode.interpolate(colA, colB, t); strip.dot.style.background = vec4ToCss(col); strip.val.textContent = fmtVec4(col); } } // Controls wiring colorAInput.addEventListener('input', () => { colA = hexToVec4(colorAInput.value); renderAll(); }); colorBInput.addEventListener('input', () => { colB = hexToVec4(colorBInput.value); renderAll(); }); tSlider.addEventListener('input', () => { t = parseFloat(tSlider.value); tNum.value = String(t); renderAll(); }); tNum.addEventListener('change', () => { t = Math.max(0, Math.min(1, parseFloat(tNum.value))); tSlider.value = String(t); renderAll(); }); window.addEventListener('resize', renderAll); renderAll();