rampensau
Version:
Color ramp generator using curves within the HSL color model
266 lines (228 loc) • 8.99 kB
text/typescript
/**
* returns a new shuffled array
* @param {Array} array - The array to shuffle.
* @param {function} rndFn - The random function to use.
* @returns {Array} - The shuffled array.
*/
export function shuffleArray<T>(array: readonly T[], rndFn = Math.random): T[] {
// Create a copy of the input array
const copy = [...array];
let currentIndex = copy.length,
randomIndex;
while (currentIndex != 0) {
randomIndex = Math.floor(rndFn() * currentIndex);
currentIndex--;
[copy[currentIndex], copy[randomIndex]] = [
copy[randomIndex] as T,
copy[currentIndex] as T,
];
}
return copy;
}
type FillFunction<T> = T extends number
? (amt: number, from: T, to: T) => T
: (amt: number, from: T | null, to: T | null) => T;
/**
* Linearly interpolates between two values.
*
* @param {number} amt - The interpolation amount (usually between 0 and 1).
* @param {number} from - The starting value.
* @param {number} to - The ending value.
* @returns {number} - The interpolated value.
*/
export const lerp: FillFunction<number> = (amt, from, to) =>
from + amt * (to - from);
/**
* Scales and spreads an array to the target size using interpolation, with optional padding.
*
* This function takes an initial array of values, a target size, an optional padding value,
* and an interpolation function (defaults to `lerp`). It returns a scaled and spread
* version of the initial array to the target size using the specified interpolation function.
*
* The padding parameter (between 0 and 1) compresses the normalized domain from both ends,
* matching the behavior of chroma.js's scale() function. This is particularly useful for
* color scales to prevent the endpoints from being too extreme.
*
* When padding is 0 (default), the original algorithm is used where values are distributed
* and interpolated across segments.
*
* When padding > 0, the normalized domain (0-1) is compressed to [padding, 1-padding],
* allowing for more graceful handling of extreme values.
*
* @param {Array<T>} valuesToFill - The initial array of values.
* @param {number} targetSize - The desired size of the resulting array.
* @param {number} padding - Optional padding value between 0 and 1 (default: 0).
* @param {FillFunction<T>} fillFunction - The interpolation function (default is lerp).
* @returns {Array<T>} The scaled and spread array.
* @throws {Error} If the initial array is invalid or target size is invalid.
*/
export const scaleSpreadArray = <T>(
valuesToFill: T[],
targetSize: number,
padding = 0,
fillFunction: FillFunction<T> = lerp as unknown as FillFunction<T>
): T[] => {
// Validation checks
if (!valuesToFill || valuesToFill.length < 2) {
throw new Error("valuesToFill array must have at least two values.");
}
if (targetSize < 1 && padding > 0) {
throw new Error("Target size must be at least 1");
}
if (targetSize < valuesToFill.length && padding === 0) {
throw new Error(
"Target size must be greater than or equal to the valuesToFill array length."
);
}
// For case without padding, use the original algorithm
if (padding <= 0) {
// Create a copy of the valuesToFill array and add null values to it if necessary
const valuesToAdd = targetSize - valuesToFill.length;
const chunkArray: T[][] = valuesToFill.map((value): T[] => [value]);
for (let i = 0; i < valuesToAdd; i++) {
const idx = i % (valuesToFill.length - 1);
if (idx >= 0 && idx < chunkArray.length) {
const chunk = chunkArray[idx];
if (chunk) {
chunk.push(null as unknown as T);
}
}
}
// Fill each chunk with interpolated values using the specified interpolation function
for (let i = 0; i < chunkArray.length - 1; i++) {
const currentChunk = chunkArray[i];
const nextChunk = chunkArray[i + 1];
if (!currentChunk || !nextChunk) {
continue;
}
const currentValue = currentChunk[0];
const nextValue = nextChunk[0];
if (currentValue === undefined || nextValue === undefined) {
continue;
}
for (let j = 1; j < currentChunk.length; j++) {
const percent = j / currentChunk.length;
currentChunk[j] = fillFunction(percent, currentValue, nextValue);
}
}
return chunkArray.flat() as T[];
}
// Implement chroma.js style padding
const result: T[] = [];
// The padding essentially shifts the start and end of the normalized range
const domainStart = padding;
const domainEnd = 1 - padding;
// Generate evenly spaced positions in the target array
for (let i = 0; i < targetSize; i++) {
// Generate normalized position (0-1)
const t = targetSize === 1 ? 0.5 : i / (targetSize - 1);
// Apply padding by adjusting t
const adjustedT = domainStart + t * (domainEnd - domainStart);
// Find the right segment for this position
let segmentIndex = 0;
const normalizedPositions: number[] = valuesToFill.map(
(_, i) => i / (valuesToFill.length - 1)
);
for (let j = 1; j < normalizedPositions.length; j++) {
const position = normalizedPositions[j];
if (position !== undefined && adjustedT <= position) {
segmentIndex = j - 1;
break;
}
if (j === normalizedPositions.length - 1) {
segmentIndex = j - 1;
}
}
// Ensure segment index is valid
segmentIndex = Math.min(Math.max(0, segmentIndex), valuesToFill.length - 2);
// Get the segment boundaries in normalized space
const segmentStart = normalizedPositions[segmentIndex] || 0;
const segmentEnd = normalizedPositions[segmentIndex + 1] || 1;
// Calculate relative position within segment (0-1)
let segmentT = 0;
if (segmentEnd > segmentStart) {
segmentT = (adjustedT - segmentStart) / (segmentEnd - segmentStart);
}
// Get the values from the segments, with null checks
const fromValue = valuesToFill[segmentIndex];
const toValue = valuesToFill[segmentIndex + 1];
if (fromValue === undefined || toValue === undefined) {
throw new Error(`Invalid segment values at index ${segmentIndex}`);
}
// Get the interpolated value from the correct segment
const value = fillFunction(segmentT, fromValue, toValue);
result.push(value);
}
return result;
};
export type CurveMethod =
| "lamé"
| "arc"
| "pow"
| "powY"
| "powX"
| ((i: number, curveAccent: number) => [number, number]);
/**
* function pointOnCurve
* @param curveMethod {String|Function} Defines how the curve is drawn
* @param curveAccent {Number} Defines the accent of the curve
* @returns {Function} A function that takes a number between 0 and 1 and returns the x and y coordinates of the curve at that point
* @throws {Error} If the curveMethod is not a valid type
*/
export const pointOnCurve = (curveMethod: CurveMethod, curveAccent: number) => {
return (t: number): { x: number; y: number } => {
const limit = Math.PI / 2;
const slice = limit / 1;
const percentile = t;
let x = 0,
y = 0;
if (curveMethod === "lamé") {
const t = percentile * limit;
const exp = 2 / (2 + 20 * curveAccent);
const cosT = Math.cos(t);
const sinT = Math.sin(t);
x = Math.sign(cosT) * Math.abs(cosT) ** exp;
y = Math.sign(sinT) * Math.abs(sinT) ** exp;
} else if (curveMethod === "arc") {
y = Math.cos(-Math.PI / 2 + t * slice + curveAccent);
x = Math.sin(Math.PI / 2 + t * slice - curveAccent);
} else if (curveMethod === "pow") {
x = Math.pow(1 - percentile, 1 - curveAccent);
y = Math.pow(percentile, 1 - curveAccent);
} else if (curveMethod === "powY") {
x = Math.pow(1 - percentile, curveAccent);
y = Math.pow(percentile, 1 - curveAccent);
} else if (curveMethod === "powX") {
x = Math.pow(percentile, curveAccent);
y = Math.pow(percentile, 1 - curveAccent);
} else if (typeof curveMethod === "function") {
const [xFunc, yFunc] = curveMethod(t, curveAccent) as [number, number];
x = xFunc;
y = yFunc;
} else {
throw new Error(
`pointOnCurve() curveAccent parameter is expected to be "lamé" | "arc" | "pow" | "powY" | "powX" or a function but \`${curveMethod}\` given.`
);
}
return { x, y };
};
};
/**
* makeCurveEasings generates two easing functions based on a curve method and accent.
* @param {CurveMethod} curveMethod - The method used to generate the curve.
* @param {number} curveAccent - The accent of the curve.
* @returns {Object} An object containing two easing functions: sEasing and lEasing.
*/
export const makeCurveEasings = (
curveMethod: CurveMethod,
curveAccent: number
): {
sEasing: (t: number) => number;
lEasing: (t: number) => number;
} => {
const point = pointOnCurve(curveMethod, curveAccent);
return {
sEasing: (t: number) => point(t).x,
lEasing: (t: number) => point(t).y,
};
};