poline
Version:
color palette generator mico-lib
861 lines (731 loc) • 24.3 kB
text/typescript
/* eslint-disable @typescript-eslint/ban-ts-comment */
export type FuncNumberReturn = (arg0: number) => Vector2;
export type Vector2 = [number, number];
export type Vector3 = [number, ...Vector2];
export type PartialVector3 = [number | null, number | null, number | null];
type CSSColorMethods = {
hsl: (p: ColorPoint) => string;
oklch: (p: ColorPoint) => string;
lch: (p: ColorPoint) => string;
};
/**
* Converts the given (x, y, z) coordinate to an HSL color
* The (x, y) values are used to calculate the hue, while the z value is used as the saturation
* The lightness value is calculated based on the distance of (x, y) from the center (0.5, 0.5)
* Returns an array [hue, saturation, lightness]
* @param xyz:Vector3 [x, y, z] coordinate array in (x, y, z) format (0-1, 0-1, 0-1)
* @returns [hue, saturation, lightness]: Vector3 color array in HSL format (0-360, 0-1, 0-1)
* @example
* pointToHSL([0.5, 0.5, 1]) // [0, 1, 0.5]
* pointToHSL([0.5, 0.5, 0]) // [0, 1, 0]
**/
export const pointToHSL = (
xyz: Vector3,
invertedLightness: boolean
): Vector3 => {
const [x, y, z] = xyz;
// cy and cx are the center (y and x) values
const cx = 0.5;
const cy = 0.5;
// Calculate the angle between the point (x, y) and the center (cx, cy)
const radians = Math.atan2(y - cy, x - cx);
// Convert the angle to degrees and shift it so that it goes from 0 to 360
let deg = radians * (180 / Math.PI);
deg = (360 + deg) % 360;
// The saturation value is taken from the z coordinate
const s = z;
// Calculate the lightness value based on the distance from the center
const dist = Math.sqrt(Math.pow(y - cy, 2) + Math.pow(x - cx, 2));
const l = dist / cx;
// Return the HSL color as an array [hue, saturation, lightness]
return [deg, s, invertedLightness ? 1 - l : l];
};
/**
* Converts the given HSL color to an (x, y, z) coordinate
* The hue value is used to calculate the (x, y) position, while the saturation value is used as the z coordinate
* The lightness value is used to calculate the distance from the center (0.5, 0.5)
* Returns an array [x, y, z]
* @param hsl:Vector3 [hue, saturation, lightness] color array in HSL format (0-360, 0-1, 0-1)
* @returns [x, y, z]:Vector3 coordinate array in (x, y, z) format (0-1, 0-1, 0-1)
* @example
* hslToPoint([0, 1, 0.5]) // [0.5, 0.5, 1]
* hslToPoint([0, 1, 0]) // [0.5, 0.5, 1]
* hslToPoint([0, 1, 1]) // [0.5, 0.5, 1]
* hslToPoint([0, 0, 0.5]) // [0.5, 0.5, 0]
**/
export const hslToPoint = (
hsl: Vector3,
invertedLightness: boolean
): Vector3 => {
// Destructure the input array into separate hue, saturation, and lightness values
const [h, s, l] = hsl;
// cx and cy are the center (x and y) values
const cx = 0.5;
const cy = 0.5;
// Calculate the angle in radians based on the hue value
const radians = h / (180 / Math.PI);
// Calculate the distance from the center based on the lightness value
const dist = (invertedLightness ? 1 - l : l) * cx;
// Calculate the x and y coordinates based on the distance and angle
const x = cx + dist * Math.cos(radians);
const y = cy + dist * Math.sin(radians);
// The z coordinate is equal to the saturation value
const z = s;
// Return the (x, y, z) coordinate as an array [x, y, z]
return [x, y, z];
};
export const randomHSLPair = (
startHue: number = Math.random() * 360,
saturations: Vector2 = [Math.random(), Math.random()],
lightnesses: Vector2 = [0.75 + Math.random() * 0.2, 0.3 + Math.random() * 0.2]
): [Vector3, Vector3] => [
[startHue, saturations[0], lightnesses[0]],
[(startHue + 60 + Math.random() * 180) % 360, saturations[1], lightnesses[1]],
];
export const randomHSLTriple = (
startHue: number = Math.random() * 360,
saturations: Vector3 = [Math.random(), Math.random(), Math.random()],
lightnesses: Vector3 = [
0.75 + Math.random() * 0.2,
Math.random() * 0.2,
0.75 + Math.random() * 0.2,
]
): [Vector3, Vector3, Vector3] => [
[startHue, saturations[0], lightnesses[0]],
[(startHue + 60 + Math.random() * 180) % 360, saturations[1], lightnesses[1]],
[(startHue + 60 + Math.random() * 180) % 360, saturations[2], lightnesses[2]],
];
const vectorOnLine = (
t: number,
p1: Vector3,
p2: Vector3,
invert = false,
fx = (t: number, invert: boolean): number => (invert ? 1 - t : t),
fy = (t: number, invert: boolean): number => (invert ? 1 - t : t),
fz = (t: number, invert: boolean): number => (invert ? 1 - t : t)
): Vector3 => {
const tModifiedX = fx(t, invert);
const tModifiedY = fy(t, invert);
const tModifiedZ = fz(t, invert);
const x = (1 - tModifiedX) * p1[0] + tModifiedX * p2[0];
const y = (1 - tModifiedY) * p1[1] + tModifiedY * p2[1];
const z = (1 - tModifiedZ) * p1[2] + tModifiedZ * p2[2];
return [x, y, z];
};
const vectorsOnLine = (
p1: Vector3,
p2: Vector3,
numPoints = 4,
invert = false,
fx = (t: number, invert: boolean): number => (invert ? 1 - t : t),
fy = (t: number, invert: boolean): number => (invert ? 1 - t : t),
fz = (t: number, invert: boolean): number => (invert ? 1 - t : t)
): Vector3[] => {
const points: Vector3[] = [];
for (let i = 0; i < numPoints; i++) {
const [x, y, z] = vectorOnLine(
i / (numPoints - 1),
p1,
p2,
invert,
fx,
fy,
fz
);
points.push([x, y, z]);
}
return points;
};
export type PositionFunction = (t: number, reverse?: boolean) => number;
const linearPosition: PositionFunction = (t: number) => {
return t;
};
const exponentialPosition: PositionFunction = (t: number, reverse = false) => {
if (reverse) {
return 1 - (1 - t) ** 2;
}
return t ** 2;
};
const quadraticPosition: PositionFunction = (t: number, reverse = false) => {
if (reverse) {
return 1 - (1 - t) ** 3;
}
return t ** 3;
};
const cubicPosition: PositionFunction = (t: number, reverse = false) => {
if (reverse) {
return 1 - (1 - t) ** 4;
}
return t ** 4;
};
const quarticPosition: PositionFunction = (t: number, reverse = false) => {
if (reverse) {
return 1 - (1 - t) ** 5;
}
return t ** 5;
};
const sinusoidalPosition: PositionFunction = (t: number, reverse = false) => {
if (reverse) {
return 1 - Math.sin(((1 - t) * Math.PI) / 2);
}
return Math.sin((t * Math.PI) / 2);
};
const asinusoidalPosition: PositionFunction = (t: number, reverse = false) => {
if (reverse) {
return 1 - Math.asin(1 - t) / (Math.PI / 2);
}
return Math.asin(t) / (Math.PI / 2);
};
const arcPosition: PositionFunction = (t: number, reverse = false) => {
if (reverse) {
return 1 - Math.sqrt(1 - t ** 2);
}
return 1 - Math.sqrt(1 - t);
};
const smoothStepPosition: PositionFunction = (t: number) => {
return t ** 2 * (3 - 2 * t);
};
export const positionFunctions = {
linearPosition,
exponentialPosition,
quadraticPosition,
cubicPosition,
quarticPosition,
sinusoidalPosition,
asinusoidalPosition,
arcPosition,
smoothStepPosition,
};
/**
* Calculates the distance between two points
* @param p1 The first point
* @param p2 The second point
* @param hueMode Whether to use the hue distance function
* @returns The distance between the two points
* @example
* const p1 = [0, 0, 0];
* const p2 = [1, 1, 1];
* const dist = distance(p1, p2);
* console.log(dist); // 1.7320508075688772
**/
const distance = (
p1: PartialVector3,
p2: PartialVector3,
hueMode = false
): number => {
const a1 = p1[0];
const a2 = p2[0];
let diffA = 0;
if (hueMode && a1 !== null && a2 !== null) {
diffA = Math.min(Math.abs(a1 - a2), 360 - Math.abs(a1 - a2));
diffA = diffA / 360;
} else {
diffA = a1 === null || a2 === null ? 0 : a1 - a2;
}
const a = diffA;
const b = p1[1] === null || p2[1] === null ? 0 : p2[1] - p1[1];
const c = p1[2] === null || p2[2] === null ? 0 : p2[2] - p1[2];
return Math.sqrt(a * a + b * b + c * c);
};
export type ColorPointCollection = {
xyz?: Vector3;
color?: Vector3;
invertedLightness?: boolean;
};
export class ColorPoint {
public x = 0;
public y = 0;
public z = 0;
public color: Vector3 = [0, 0, 0];
private _invertedLightness = false;
constructor({
xyz,
color,
invertedLightness = false,
}: ColorPointCollection = {}) {
this._invertedLightness = invertedLightness;
this.positionOrColor({ xyz, color, invertedLightness });
}
positionOrColor({
xyz,
color,
invertedLightness = false,
}: ColorPointCollection) {
if ((xyz && color) || (!xyz && !color)) {
throw new Error("Point must be initialized with either x,y,z or hsl");
} else if (xyz) {
this.x = xyz[0];
this.y = xyz[1];
this.z = xyz[2];
this.color = pointToHSL([this.x, this.y, this.z], invertedLightness);
} else if (color) {
this.color = color;
[this.x, this.y, this.z] = hslToPoint(color, invertedLightness);
}
}
set position([x, y, z]: Vector3) {
this.x = x;
this.y = y;
this.z = z;
this.color = pointToHSL(
[this.x, this.y, this.z] as Vector3,
this._invertedLightness
);
}
get position(): Vector3 {
return [this.x, this.y, this.z];
}
set hsl([h, s, l]: Vector3) {
this.color = [h, s, l];
[this.x, this.y, this.z] = hslToPoint(
this.color as Vector3,
this._invertedLightness
);
}
get hsl(): Vector3 {
return this.color;
}
get hslCSS(): string {
const [h, s, l] = this.color;
return `hsl(${h.toFixed(2)}, ${(s * 100).toFixed(2)}%, ${(l * 100).toFixed(
2
)}%)`;
}
get oklchCSS(): string {
const [h, s, l] = this.color;
return `oklch(${(l * 100).toFixed(2)}% ${(s * 0.4).toFixed(3)} ${h.toFixed(
2
)})`;
}
get lchCSS(): string {
const [h, s, l] = this.color;
return `lch(${(l * 100).toFixed(2)}% ${(s * 150).toFixed(2)} ${h.toFixed(
2
)})`;
}
shiftHue(angle: number): void {
this.color[0] = (360 + (this.color[0] + angle)) % 360;
[this.x, this.y, this.z] = hslToPoint(
this.color as Vector3,
this._invertedLightness
);
}
}
export type PolineOptions = {
anchorColors: Vector3[];
numPoints: number;
positionFunction?: (t: number, invert?: boolean) => number;
positionFunctionX?: (t: number, invert?: boolean) => number;
positionFunctionY?: (t: number, invert?: boolean) => number;
positionFunctionZ?: (t: number, invert?: boolean) => number;
invertedLightness?: boolean;
closedLoop?: boolean;
};
export class Poline {
private _needsUpdate = true;
private _anchorPoints: ColorPoint[];
private _numPoints: number;
private points: ColorPoint[][];
private _positionFunctionX = sinusoidalPosition;
private _positionFunctionY = sinusoidalPosition;
private _positionFunctionZ = sinusoidalPosition;
private _anchorPairs: ColorPoint[][];
private connectLastAndFirstAnchor = false;
private _animationFrame: null | number = null;
private _invertedLightness = false;
constructor(
{
anchorColors = randomHSLPair(),
numPoints = 4,
positionFunction = sinusoidalPosition,
positionFunctionX,
positionFunctionY,
positionFunctionZ,
closedLoop,
invertedLightness,
}: PolineOptions = {
anchorColors: randomHSLPair(),
numPoints: 4,
positionFunction: sinusoidalPosition,
closedLoop: false,
}
) {
if (!anchorColors || anchorColors.length < 2) {
throw new Error("Must have at least two anchor colors");
}
this._anchorPoints = anchorColors.map(
(point) => new ColorPoint({ color: point, invertedLightness })
);
this._numPoints = numPoints + 2; // add two for the anchor points
this._positionFunctionX =
positionFunctionX || positionFunction || sinusoidalPosition;
this._positionFunctionY =
positionFunctionY || positionFunction || sinusoidalPosition;
this._positionFunctionZ =
positionFunctionZ || positionFunction || sinusoidalPosition;
this.connectLastAndFirstAnchor = closedLoop || false;
this._invertedLightness = invertedLightness || false;
this.updateAnchorPairs();
}
public get numPoints(): number {
return this._numPoints - 2;
}
public set numPoints(numPoints: number) {
if (numPoints < 1) {
throw new Error("Must have at least one point");
}
this._numPoints = numPoints + 2; // add two for the anchor points
this.updateAnchorPairs();
}
public set positionFunction(
positionFunction: PositionFunction | PositionFunction[]
) {
if (Array.isArray(positionFunction)) {
if (positionFunction.length !== 3) {
throw new Error("Position function array must have 3 elements");
}
if (
typeof positionFunction[0] !== "function" ||
typeof positionFunction[1] !== "function" ||
typeof positionFunction[2] !== "function"
) {
throw new Error("Position function array must have 3 functions");
}
this._positionFunctionX = positionFunction[0];
this._positionFunctionY = positionFunction[1];
this._positionFunctionZ = positionFunction[2];
} else {
this._positionFunctionX = positionFunction;
this._positionFunctionY = positionFunction;
this._positionFunctionZ = positionFunction;
}
this.updateAnchorPairs();
}
public get positionFunction(): PositionFunction | PositionFunction[] {
// not to sure what to do here, because the position function is a combination of the three
if (
this._positionFunctionX === this._positionFunctionY &&
this._positionFunctionX === this._positionFunctionZ
) {
return this._positionFunctionX;
}
return [
this._positionFunctionX,
this._positionFunctionY,
this._positionFunctionZ,
];
}
public set positionFunctionX(positionFunctionX: PositionFunction) {
this._positionFunctionX = positionFunctionX;
this.updateAnchorPairs();
}
public get positionFunctionX(): PositionFunction {
return this._positionFunctionX;
}
public set positionFunctionY(positionFunctionY: PositionFunction) {
this._positionFunctionY = positionFunctionY;
this.updateAnchorPairs();
}
public get positionFunctionY(): PositionFunction {
return this._positionFunctionY;
}
public set positionFunctionZ(positionFunctionZ: PositionFunction) {
this._positionFunctionZ = positionFunctionZ;
this.updateAnchorPairs();
}
public get positionFunctionZ(): PositionFunction {
return this._positionFunctionZ;
}
public get anchorPoints(): ColorPoint[] {
return this._anchorPoints;
}
public set anchorPoints(anchorPoints: ColorPoint[]) {
this._anchorPoints = anchorPoints;
this.updateAnchorPairs();
}
public updateAnchorPairs(): void {
this._anchorPairs = [] as ColorPoint[][];
const anchorPointsLength = this.connectLastAndFirstAnchor
? this.anchorPoints.length
: this.anchorPoints.length - 1;
for (let i = 0; i < anchorPointsLength; i++) {
const pair = [
this.anchorPoints[i],
this.anchorPoints[(i + 1) % this.anchorPoints.length],
] as ColorPoint[];
this._anchorPairs.push(pair);
}
this.points = this._anchorPairs.map((pair, i) => {
const p1position = pair[0] ? pair[0].position : ([0, 0, 0] as Vector3);
const p2position = pair[1] ? pair[1].position : ([0, 0, 0] as Vector3);
// Special handling for closed loop with exactly 2 anchors
// we want to invert the ease for the first segment
const shouldInvertEase = this.shouldInvertEaseForSegment(i);
return vectorsOnLine(
p1position,
p2position,
this._numPoints,
shouldInvertEase ? true : false,
this.positionFunctionX,
this.positionFunctionY,
this.positionFunctionZ
).map(
(p) =>
new ColorPoint({ xyz: p, invertedLightness: this._invertedLightness })
);
});
}
public addAnchorPoint({
xyz,
color,
insertAtIndex,
}: ColorPointCollection & { insertAtIndex?: number }): ColorPoint {
const newAnchor = new ColorPoint({
xyz,
color,
invertedLightness: this._invertedLightness,
});
if (insertAtIndex !== undefined) {
this.anchorPoints.splice(insertAtIndex, 0, newAnchor);
} else {
this.anchorPoints.push(newAnchor);
}
this.updateAnchorPairs();
return newAnchor;
}
public removeAnchorPoint({
point,
index,
}: {
point?: ColorPoint;
index?: number;
}): void {
if (!point && index === undefined) {
throw new Error("Must provide a point or index");
}
if (this.anchorPoints.length < 3) {
throw new Error("Must have at least two anchor points");
}
let apid;
if (index !== undefined) {
apid = index;
} else if (point) {
apid = this.anchorPoints.indexOf(point);
}
if (apid > -1 && apid < this.anchorPoints.length) {
this.anchorPoints.splice(apid, 1);
this.updateAnchorPairs();
} else {
throw new Error("Point not found");
}
}
public updateAnchorPoint({
point,
pointIndex,
xyz,
color,
}: {
point?: ColorPoint;
pointIndex?: number;
} & ColorPointCollection): ColorPoint {
if (pointIndex !== undefined) {
point = this.anchorPoints[pointIndex];
}
if (!point) {
throw new Error("Must provide a point or pointIndex");
}
if (!xyz && !color) {
throw new Error("Must provide a new xyz position or color");
}
if (xyz) point.position = xyz;
if (color) point.hsl = color;
this.updateAnchorPairs();
return point;
}
public getClosestAnchorPoint({
xyz,
hsl,
maxDistance = 1,
}: {
xyz?: PartialVector3;
hsl?: PartialVector3;
maxDistance?: number;
}): ColorPoint | null {
if (!xyz && !hsl) {
throw new Error("Must provide a xyz or hsl");
}
let distances;
if (xyz) {
distances = this.anchorPoints.map((anchor) =>
distance(anchor.position, xyz)
);
} else if (hsl) {
distances = this.anchorPoints.map((anchor) =>
distance(anchor.hsl, hsl, true)
);
}
const minDistance = Math.min(...distances);
if (minDistance > maxDistance) {
return null;
}
const closestAnchorIndex = distances.indexOf(minDistance);
return this.anchorPoints[closestAnchorIndex] || null;
}
public set closedLoop(newStatus: boolean) {
this.connectLastAndFirstAnchor = newStatus;
this.updateAnchorPairs();
}
public get closedLoop(): boolean {
return this.connectLastAndFirstAnchor;
}
public set invertedLightness(newStatus: boolean) {
this._invertedLightness = newStatus;
this.updateAnchorPairs();
}
public get invertedLightness(): boolean {
return this._invertedLightness;
}
/**
* Returns a flattened array of all points across all segments,
* removing duplicated anchor points at segment boundaries.
*
* Since anchor points exist at both the end of one segment and
* the beginning of the next, this method keeps only one instance of each.
* The filter logic keeps the first point (index 0) and then filters out
* points whose indices are multiples of the segment size (_numPoints),
* which are the anchor points at the start of each segment (except the first).
*
* This approach ensures we get all unique points in the correct order
* while avoiding duplicated anchor points.
*
* @returns {ColorPoint[]} A flat array of unique ColorPoint instances
*/
public get flattenedPoints() {
return this.points
.flat()
.filter((p, i) => (i != 0 ? i % this._numPoints : true));
}
public get colors() {
const colors = this.flattenedPoints.map((p) => p.color);
if (this.connectLastAndFirstAnchor && this._anchorPoints.length !== 2) {
colors.pop();
}
return colors;
}
public cssColors(mode: "hsl" | "oklch" | "lch" = "hsl"): string[] {
const methods: CSSColorMethods = {
hsl: (p: ColorPoint): string => p.hslCSS,
oklch: (p: ColorPoint): string => p.oklchCSS,
lch: (p: ColorPoint): string => p.lchCSS,
};
const cssColors = this.flattenedPoints.map(methods[mode]);
if (this.connectLastAndFirstAnchor) {
cssColors.pop();
}
return cssColors;
}
public get colorsCSS() {
return this.cssColors("hsl");
}
public get colorsCSSlch() {
return this.cssColors("lch");
}
public get colorsCSSoklch() {
return this.cssColors("oklch");
}
public shiftHue(hShift = 20): void {
this.anchorPoints.forEach((p) => p.shiftHue(hShift));
this.updateAnchorPairs();
}
/**
* Returns a color at a specific position along the entire color line (0-1)
* Treats all segments as one continuous path, respecting easing functions
* @param t Position along the line (0-1), where 0 is start and 1 is end
* @returns ColorPoint at the specified position
* @example
* getColorAt(0) // Returns color at the very beginning
* getColorAt(0.5) // Returns color at the middle of the entire journey
* getColorAt(1) // Returns color at the very end
*/
public getColorAt(t: number): ColorPoint {
if (t < 0 || t > 1) {
throw new Error("Position must be between 0 and 1");
}
if (this.anchorPoints.length === 0) {
throw new Error("No anchor points available");
}
// For closed loops, we need to handle the full circle
const totalSegments = this.connectLastAndFirstAnchor
? this.anchorPoints.length
: this.anchorPoints.length - 1;
// Special case: if we only have 2 anchors in a closed loop,
// we actually have 2 segments going different ways
const effectiveSegments =
this.connectLastAndFirstAnchor && this.anchorPoints.length === 2
? 2
: totalSegments;
// Calculate which segment we're in and the position within that segment
const segmentPosition = t * effectiveSegments;
const segmentIndex = Math.floor(segmentPosition);
const localT = segmentPosition - segmentIndex;
// Handle edge case where t = 1 (end of line)
const actualSegmentIndex =
segmentIndex >= effectiveSegments ? effectiveSegments - 1 : segmentIndex;
const actualLocalT = segmentIndex >= effectiveSegments ? 1 : localT;
// Get the anchor pair for this segment
const pair = this._anchorPairs[actualSegmentIndex];
if (!pair || pair.length < 2 || !pair[0] || !pair[1]) {
// Fallback to first anchor if something goes wrong
return new ColorPoint({
color: this.anchorPoints[0]?.color || [0, 0, 0],
invertedLightness: this._invertedLightness,
});
}
const p1position = pair[0].position;
const p2position = pair[1].position;
// Apply the same easing logic as in updateAnchorPairs
const shouldInvertEase =
this.shouldInvertEaseForSegment(actualSegmentIndex);
// Use the existing vectorOnLine function for consistent interpolation
const xyz = vectorOnLine(
actualLocalT,
p1position,
p2position,
shouldInvertEase,
this._positionFunctionX,
this._positionFunctionY,
this._positionFunctionZ
);
return new ColorPoint({
xyz,
invertedLightness: this._invertedLightness,
});
}
/**
* Determines whether easing should be inverted for a given segment
* @param segmentIndex The index of the segment
* @returns Whether easing should be inverted
*/
private shouldInvertEaseForSegment(segmentIndex: number): boolean {
return !!(
segmentIndex % 2 ||
(this.connectLastAndFirstAnchor &&
this.anchorPoints.length === 2 &&
segmentIndex === 0)
);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { p5 } = globalThis as any;
if (p5 && p5.VERSION && p5.VERSION.startsWith("1.")) {
console.info("p5 < 1.x detected, adding poline to p5 prototype");
const poline = new Poline();
p5.prototype.poline = poline;
const polineColors = () =>
poline.colors.map(
(c) => `hsl(${Math.round(c[0])},${c[1] * 100}%,${c[2] * 100}%)`
);
p5.prototype.registerMethod("polineColors", polineColors);
globalThis.poline = poline;
globalThis.polineColors = polineColors;
}