// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ // ┃ Copyright (c) 2017, the Perspective Authors. ┃ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ // ┃ This file is part of the Perspective library, distributed under the terms ┃ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { parseCSSColorToVec3 } from "../utils/css"; /** * A single stop on a parsed CSS gradient. `offset` ∈ [0, 1]. */ export interface GradientStop { offset: number; color: [number, number, number, number]; // RGBA, each ∈ [0, 1] } const DEFAULT_STOPS: GradientStop[] = [ { offset: 0, color: [0x03 / 255, 0x66 / 255, 0xd6 / 255, 1] }, { offset: 1, color: [0xff / 255, 0x7f / 255, 0x0e / 255, 1] }, ]; /** * Parse a `linear-gradient(...)` CSS expression into ordered stops. Tolerates * missing percentages (distributes linearly between known offsets, matching * the CSS standard) and leading direction tokens (`to right`, `90deg`, etc.) * which are simply skipped. * * Returns the default blue → orange two-stop on any parse failure so themes * that never set the gradient still produce sane output. */ export function parseCssGradient( src: string | null | undefined, ): GradientStop[] { if (!src) { return DEFAULT_STOPS.slice(); } const trimmed = src.trim(); if (!trimmed) { return DEFAULT_STOPS.slice(); } // Strip the `linear-gradient(` wrapper. Bail out if we don't find it. const openIdx = trimmed.indexOf("("); if (openIdx < 0) { return DEFAULT_STOPS.slice(); } if (!/^linear-gradient\s*\(/i.test(trimmed)) { return DEFAULT_STOPS.slice(); } const closeIdx = trimmed.lastIndexOf(")"); if (closeIdx <= openIdx) { return DEFAULT_STOPS.slice(); } const body = trimmed.substring(openIdx + 1, closeIdx); // Split on commas at depth 0 (respecting nested `rgb(...)` / `rgba(...)` / // `hsl(...)` parens which also contain commas). const parts: string[] = []; let depth = 0; let start = 0; for (let i = 0; i < body.length; i++) { const ch = body[i]; if (ch === "(") { depth++; } else if (ch === ")") { depth--; } else if (ch === "," && depth === 0) { parts.push(body.substring(start, i)); start = i + 1; } } parts.push(body.substring(start)); // First part may be a direction (`to right`, `90deg`, `to bottom right`) // rather than a color-stop. Detect by: no matching color syntax. const stops: Array<{ color: [number, number, number]; offset: number | null; }> = []; let startIdx = 0; if (parts.length > 0) { const firstNorm = parts[0].trim().toLowerCase(); if ( firstNorm.startsWith("to ") || /^[-\d.]+(deg|rad|grad|turn)/.test(firstNorm) ) { startIdx = 1; } } for (let i = startIdx; i < parts.length; i++) { const piece = parts[i].trim(); if (!piece) { continue; } // Peel off an optional trailing `%` or `px`. const pctMatch = piece.match(/\s([\-\d.]+)%\s*$/); const color = pctMatch ? piece.substring(0, pctMatch.index).trim() : piece; const offset = pctMatch ? parseFloat(pctMatch[1]) / 100 : null; try { const rgb = parseCSSColorToVec3(color); stops.push({ color: rgb, offset }); } catch { // skip unparseable stop } } if (stops.length === 0) { return DEFAULT_STOPS.slice(); } if (stops.length === 1) { // Single stop → solid color. Duplicate across [0, 1] so sampling works. const [r, g, b] = stops[0].color; return [ { offset: 0, color: [r, g, b, 1] }, { offset: 1, color: [r, g, b, 1] }, ]; } // Fill in missing offsets by linear interpolation of neighbours with // known positions (CSS implicit-position semantics). if (stops[0].offset === null) { stops[0].offset = 0; } if (stops[stops.length - 1].offset === null) { stops[stops.length - 1].offset = 1; } for (let i = 1; i < stops.length - 1; i++) { if (stops[i].offset !== null) { continue; } // Find next known offset. let j = i + 1; while (j < stops.length && stops[j].offset === null) { j++; } const before = stops[i - 1].offset!; const after = stops[j].offset!; const span = j - (i - 1); for (let k = i; k < j; k++) { stops[k].offset = before + ((k - (i - 1)) / span) * (after - before); } i = j - 1; } // Clamp offsets to [0, 1] and ensure non-decreasing order. let prev = 0; const result: GradientStop[] = stops.map((s) => { const off = Math.max(prev, Math.min(1, s.offset!)); prev = off; return { offset: off, color: [s.color[0], s.color[1], s.color[2], 1], }; }); return result; } /** * Piecewise-linear color sample at `t ∈ [0, 1]`. Returns RGBA in [0, 1]. * Clamps `t` to the gradient's first/last stop outside `[0, 1]`. */ export function sampleGradient( stops: GradientStop[], t: number, ): [number, number, number, number] { if (stops.length === 0) { return [0, 0, 0, 1]; } if (t <= stops[0].offset) { return stops[0].color.slice() as [number, number, number, number]; } const last = stops[stops.length - 1]; if (t >= last.offset) { return last.color.slice() as [number, number, number, number]; } // Bisect for the interval containing `t`. let lo = 0; let hi = stops.length - 1; while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (stops[mid].offset <= t) { lo = mid; } else { hi = mid; } } const a = stops[lo]; const b = stops[hi]; const span = b.offset - a.offset; const u = span > 0 ? (t - a.offset) / span : 0; return [ a.color[0] + (b.color[0] - a.color[0]) * u, a.color[1] + (b.color[1] - a.color[1]) * u, a.color[2] + (b.color[2] - a.color[2]) * u, a.color[3] + (b.color[3] - a.color[3]) * u, ]; } /** * Sign-aware normalization. Returns `t ∈ [0, 1]` where the 50% stop is * always the sign pivot: * - crosses zero → `[-maxAbs, maxAbs]` stretched symmetrically; 0 → 0.5. * - all-positive → `[0, colorMax]` occupies top half `[0.5, 1]`. * - all-negative → `[colorMin, 0]` occupies bottom half `[0, 0.5]`. * - degenerate → 0.5 (single colour at the midpoint). */ export function colorValueToT( value: number, colorMin: number, colorMax: number, ): number { if (!isFinite(value) || colorMin === colorMax) { return 0.5; } let denom: number; if (colorMin >= 0) { denom = colorMax; } else if (colorMax <= 0) { denom = -colorMin; } else { denom = Math.max(-colorMin, colorMax); } if (denom <= 0) { return 0.5; } const t = 0.5 + 0.5 * (value / denom); return t < 0 ? 0 : t > 1 ? 1 : t; } /** * Convert a discrete series palette (from `--psp-charts--series-N--color`) * into a `GradientStop[]` with stops at `i / (N - 1)`. The resulting * stops can feed `buildGradientLUT` / `ensureGradientTexture` / any * other code path that already accepts a gradient — so categorical * coloring and numeric gradients share one LUT pipeline. Integer idx * sampling via `t = idx / (N - 1)` lands exactly on a palette color; * the linear blend between stops is only hit by non-integer samples * (which categorical data doesn't produce). */ export function paletteToStops( palette: [number, number, number][], ): GradientStop[] { if (palette.length === 0) { return DEFAULT_STOPS.slice(); } if (palette.length === 1) { const [r, g, b] = palette[0]; return [ { offset: 0, color: [r, g, b, 1] }, { offset: 1, color: [r, g, b, 1] }, ]; } const denom = palette.length - 1; return palette.map(([r, g, b], i) => ({ offset: i / denom, color: [r, g, b, 1], })); } /** * Bake a sampled LUT for GPU upload as RGBA8 (`size × 1`). Default 256 * samples — visually indistinguishable from a denser sample at typical * viewport sizes and keeps the texture tiny (1 KB). */ export function buildGradientLUT( stops: GradientStop[], size: number = 256, ): Uint8Array { const out = new Uint8Array(size * 4); for (let i = 0; i < size; i++) { const t = size === 1 ? 0 : i / (size - 1); const c = sampleGradient(stops, t); out[i * 4] = Math.round(c[0] * 255); out[i * 4 + 1] = Math.round(c[1] * 255); out[i * 4 + 2] = Math.round(c[2] * 255); out[i * 4 + 3] = Math.round(c[3] * 255); } return out; }