@zakkster/lite-gradient-studio 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +105 -0
- package/LICENSE +21 -0
- package/README.md +307 -0
- package/llms.txt +173 -0
- package/package.json +81 -0
- package/src/bake.js +141 -0
- package/src/color-convert.js +233 -0
- package/src/css-emitters.js +86 -0
- package/src/exporters.js +310 -0
- package/src/gradient-parse.js +447 -0
- package/src/index.d.ts +380 -0
- package/src/index.js +38 -0
- package/src/mesh-css.js +113 -0
- package/src/mesh.js +673 -0
- package/src/palette-extract.js +216 -0
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// @zakkster/lite-gradient-studio v1.0.0 -- type definitions.
|
|
2
|
+
//
|
|
3
|
+
// (c) Zahary Shinikchiev. MIT licensed.
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Re-exports from @zakkster/lite-gradient
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export { Gradient } from '@zakkster/lite-gradient';
|
|
10
|
+
export type {
|
|
11
|
+
GradientStop,
|
|
12
|
+
GradientConstructorStop,
|
|
13
|
+
GradientOptions,
|
|
14
|
+
} from '@zakkster/lite-gradient';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Common color shapes
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** Plain OKLCH color object. Alpha is optional and defaults to 1. */
|
|
21
|
+
export interface OklchColor {
|
|
22
|
+
l: number;
|
|
23
|
+
c: number;
|
|
24
|
+
h: number;
|
|
25
|
+
a?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** OKLCH color with alpha always present (after construction-time defaulting). */
|
|
29
|
+
export interface OklchColorA {
|
|
30
|
+
l: number;
|
|
31
|
+
c: number;
|
|
32
|
+
h: number;
|
|
33
|
+
a: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Mesh stop with optional deformed-grid position. */
|
|
37
|
+
export interface MeshStop extends OklchColor {
|
|
38
|
+
x?: number;
|
|
39
|
+
y?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Mesh stop fully populated (post-construction): always has x, y, a. */
|
|
43
|
+
export interface MeshStopFull {
|
|
44
|
+
l: number;
|
|
45
|
+
c: number;
|
|
46
|
+
h: number;
|
|
47
|
+
a: number;
|
|
48
|
+
x: number;
|
|
49
|
+
y: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 2D position out-parameter shape. */
|
|
53
|
+
export interface XYOut {
|
|
54
|
+
x: number;
|
|
55
|
+
y: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// MeshGradient
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
export type InterpolationMode = 'bilinear' | 'smooth' | 'cubic';
|
|
63
|
+
|
|
64
|
+
export interface MeshRasterizeOptions {
|
|
65
|
+
/** Default 'bilinear'. */
|
|
66
|
+
interpolation?: InterpolationMode;
|
|
67
|
+
/** Legacy boolean: `true` is equivalent to `interpolation: 'smooth'`. */
|
|
68
|
+
smooth?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Procedural default color for a control point at (col, row) of a cols x rows
|
|
73
|
+
* mesh. Pure function. Used internally by MeshGradient when no `stops` array
|
|
74
|
+
* is supplied; re-exported for callers who want to inspect or override the
|
|
75
|
+
* default field.
|
|
76
|
+
*/
|
|
77
|
+
export function defaultMeshColor(
|
|
78
|
+
col: number,
|
|
79
|
+
row: number,
|
|
80
|
+
cols: number,
|
|
81
|
+
rows: number,
|
|
82
|
+
): OklchColor;
|
|
83
|
+
|
|
84
|
+
export class MeshGradient {
|
|
85
|
+
constructor(cols: number, rows: number, stops?: ReadonlyArray<MeshStop>);
|
|
86
|
+
|
|
87
|
+
readonly cols: number;
|
|
88
|
+
readonly rows: number;
|
|
89
|
+
readonly stops: MeshStopFull[];
|
|
90
|
+
|
|
91
|
+
/** Read the (col, row) color into a caller-owned out. Zero-GC. */
|
|
92
|
+
getPoint<T extends Partial<OklchColor>>(col: number, row: number, out: T): T;
|
|
93
|
+
/** Mutate the (col, row) color in place. */
|
|
94
|
+
setPoint(col: number, row: number, l: number, c: number, h: number): void;
|
|
95
|
+
|
|
96
|
+
/** Set the (x, y) deformed-grid position of a control point. */
|
|
97
|
+
setPointPosition(col: number, row: number, x: number, y: number): void;
|
|
98
|
+
/** Read the (x, y) position into a caller-owned `{ x, y }` out. Zero-GC. */
|
|
99
|
+
getPointPosition<T extends XYOut>(col: number, row: number, out: T): T;
|
|
100
|
+
/** Restore all control-point positions to the regular grid. */
|
|
101
|
+
resetPositions(): void;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Sample at parametric (u, v) in [0, 1]^2 into `out`. Zero-GC.
|
|
105
|
+
*
|
|
106
|
+
* `modeOrSmooth`:
|
|
107
|
+
* - false / undefined -> 'bilinear'
|
|
108
|
+
* - true -> 'smooth' (smoothstep)
|
|
109
|
+
* - 'cubic' -> Catmull-Rom 2D
|
|
110
|
+
* - any of the strings above explicitly
|
|
111
|
+
*/
|
|
112
|
+
sampleAt<T extends Partial<OklchColorA>>(
|
|
113
|
+
u: number,
|
|
114
|
+
v: number,
|
|
115
|
+
out: T,
|
|
116
|
+
modeOrSmooth?: boolean | InterpolationMode,
|
|
117
|
+
): T;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Rasterize on the REGULAR grid into a packed-RGBA Uint32Array (little-
|
|
121
|
+
* endian byte order; aliasable as Uint8ClampedArray for ImageData).
|
|
122
|
+
*/
|
|
123
|
+
rasterizeTo(
|
|
124
|
+
out: Uint32Array,
|
|
125
|
+
width: number,
|
|
126
|
+
height: number,
|
|
127
|
+
opts?: MeshRasterizeOptions,
|
|
128
|
+
): Uint32Array;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Rasterize honoring control-point positions (deformable mesh). Pixels
|
|
132
|
+
* outside any quad are LEFT UNTOUCHED -- callers that want a clean
|
|
133
|
+
* canvas must fill or zero `out` first.
|
|
134
|
+
*/
|
|
135
|
+
rasterizeDeformedTo(
|
|
136
|
+
out: Uint32Array,
|
|
137
|
+
width: number,
|
|
138
|
+
height: number,
|
|
139
|
+
opts?: MeshRasterizeOptions,
|
|
140
|
+
): Uint32Array;
|
|
141
|
+
|
|
142
|
+
/** Release internal references for GC. */
|
|
143
|
+
destroy(): void;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// CSS emitters
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
/** Gradient shape accepted by the CSS emitters (a `stops` array of OKLCH+pos). */
|
|
151
|
+
export interface GradientLike {
|
|
152
|
+
stops: ReadonlyArray<OklchColor & { pos: number }>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface FormatCssLinearOptions {
|
|
156
|
+
/** Default 90 (left -> right). */
|
|
157
|
+
angle?: number;
|
|
158
|
+
/** Default true. Emits `in oklch` so browsers interpolate perceptually. */
|
|
159
|
+
oklchInterp?: boolean;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface FormatCssRadialOptions {
|
|
163
|
+
/** Default 'circle'. */
|
|
164
|
+
shape?: 'circle' | 'ellipse';
|
|
165
|
+
/** Default 'farthest-corner'. */
|
|
166
|
+
size?: string;
|
|
167
|
+
/** Default 'center'. Accepts CSS position syntax. */
|
|
168
|
+
position?: string;
|
|
169
|
+
/** Default true. */
|
|
170
|
+
oklchInterp?: boolean;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface FormatCssConicOptions {
|
|
174
|
+
/** Default 0. Starting angle in degrees. */
|
|
175
|
+
from?: number;
|
|
176
|
+
/** Default 'center'. */
|
|
177
|
+
position?: string;
|
|
178
|
+
/** Default true. */
|
|
179
|
+
oklchInterp?: boolean;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function formatCssLinear(gradient: GradientLike, opts?: FormatCssLinearOptions): string;
|
|
183
|
+
export function formatCssRadial(gradient: GradientLike, opts?: FormatCssRadialOptions): string;
|
|
184
|
+
export function formatCssConic(gradient: GradientLike, opts?: FormatCssConicOptions): string;
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Mesh CSS approximation
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
export interface FormatCssMeshOptions {
|
|
191
|
+
/** Layer radius as % of farthest corner. Auto-sized by mesh dims if absent. */
|
|
192
|
+
radiusPct?: number;
|
|
193
|
+
/** Default 'circle'. */
|
|
194
|
+
shape?: 'circle' | 'ellipse';
|
|
195
|
+
/** Default true. */
|
|
196
|
+
oklchInterp?: boolean;
|
|
197
|
+
/** Default true. Append an average-color base layer to fill gaps. */
|
|
198
|
+
includeBase?: boolean;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function formatCssMesh(mesh: MeshGradient, opts?: FormatCssMeshOptions): string;
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Color conversion
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* OKLCH -> linear sRGB triplet. sRGB-gamut-mapped via 10-iteration binary
|
|
209
|
+
* search on chroma when the requested color is out of gamut.
|
|
210
|
+
*
|
|
211
|
+
* @param out Optional 3-element `[r, g, b]` array. If supplied, written in
|
|
212
|
+
* place and returned (zero allocation). Otherwise a fresh array
|
|
213
|
+
* is allocated.
|
|
214
|
+
*/
|
|
215
|
+
export function oklchToLinearSrgb(
|
|
216
|
+
L: number,
|
|
217
|
+
C: number,
|
|
218
|
+
H: number,
|
|
219
|
+
out?: number[],
|
|
220
|
+
): number[];
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Linear sRGB -> OKLCH.
|
|
224
|
+
* @param out Optional `{l, c, h}` object. If supplied, written in place and
|
|
225
|
+
* returned (zero allocation). The `a` field is left untouched.
|
|
226
|
+
*/
|
|
227
|
+
export function linearSrgbToOklch<T extends Partial<OklchColor>>(
|
|
228
|
+
r: number,
|
|
229
|
+
g: number,
|
|
230
|
+
b: number,
|
|
231
|
+
out?: T,
|
|
232
|
+
): T;
|
|
233
|
+
|
|
234
|
+
/** sRGB transfer (linear -> gamma-encoded). IEC 61966-2-1. */
|
|
235
|
+
export function srgbGamma(x: number): number;
|
|
236
|
+
/** sRGB inverse transfer (gamma-encoded -> linear). */
|
|
237
|
+
export function srgbInverseGamma(x: number): number;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* OKLCH -> hex. Emits '#rrggbb' for opaque colors and '#rrggbbaa' otherwise.
|
|
241
|
+
* Output always lowercase.
|
|
242
|
+
*/
|
|
243
|
+
export function toHex(color: OklchColor): string;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Hex -> OKLCH. Accepts 3, 4, 6, or 8-char hex; '#' optional; case-insensitive.
|
|
247
|
+
* @throws on malformed input.
|
|
248
|
+
*/
|
|
249
|
+
export function fromHex(hex: string): OklchColorA;
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Bake / pixel helpers
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
/** Flatten `gradient.stops` into a Float32Array of [L, C, H, L, C, H, ...]. */
|
|
256
|
+
export function flattenStopsToBuffer(
|
|
257
|
+
gradient: { stops: ReadonlyArray<OklchColor> },
|
|
258
|
+
out?: Float32Array,
|
|
259
|
+
): Float32Array;
|
|
260
|
+
|
|
261
|
+
/** Bake a Gradient into a fixed-resolution Uint32Array LUT of packed RGBA. */
|
|
262
|
+
export function bakeGradientToLut(
|
|
263
|
+
gradient: { stops: ReadonlyArray<OklchColor & { pos: number }>; sampleArray(out: Float32Array, n: number): void },
|
|
264
|
+
resolution?: number,
|
|
265
|
+
opts?: {
|
|
266
|
+
easeFn?: (t: number) => number;
|
|
267
|
+
packer?: Function;
|
|
268
|
+
},
|
|
269
|
+
): Uint32Array;
|
|
270
|
+
|
|
271
|
+
/** Sample a single packed color from a baked LUT at parametric t in [0, 1]. */
|
|
272
|
+
export function sampleLut(lut: Uint32Array, t: number): number;
|
|
273
|
+
|
|
274
|
+
/** Pack a single OKLCH color directly to a 32-bit RGBA value. */
|
|
275
|
+
export function packOklchSingle(l: number, c: number, h: number, alpha?: number): number;
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// Palette extraction
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Extract a designer-friendly palette from raw RGBA pixels. Chroma-weighted
|
|
283
|
+
* hue-bucketing with >= 50 deg hue separation between picks. Returns up to
|
|
284
|
+
* `count` colors sorted by L ascending; may return fewer on low-diversity
|
|
285
|
+
* images (e.g. monochrome).
|
|
286
|
+
*/
|
|
287
|
+
export function extractPalette(
|
|
288
|
+
pixels: Uint8ClampedArray,
|
|
289
|
+
count?: number,
|
|
290
|
+
): OklchColor[];
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// CSS parser
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
export interface ParsedLinearGradient {
|
|
297
|
+
mode: 'linear';
|
|
298
|
+
angle: number;
|
|
299
|
+
stops: Array<OklchColorA & { stop: number }>;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export interface ParsedRadialGradient {
|
|
303
|
+
mode: 'radial';
|
|
304
|
+
radShape: 'circle' | 'ellipse';
|
|
305
|
+
radCx: number;
|
|
306
|
+
radCy: number;
|
|
307
|
+
stops: Array<OklchColorA & { stop: number }>;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export interface ParsedConicGradient {
|
|
311
|
+
mode: 'conic';
|
|
312
|
+
conFrom: number;
|
|
313
|
+
conCx: number;
|
|
314
|
+
conCy: number;
|
|
315
|
+
stops: Array<OklchColorA & { stop: number }>;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export type ParsedGradient =
|
|
319
|
+
| ParsedLinearGradient
|
|
320
|
+
| ParsedRadialGradient
|
|
321
|
+
| ParsedConicGradient;
|
|
322
|
+
|
|
323
|
+
/** Parse a CSS `linear-gradient`/`radial-gradient`/`conic-gradient` string. */
|
|
324
|
+
export function parseGradientCss(input: string): ParsedGradient;
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// Multi-format exporters
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
export type Export1dFormat = 'css' | 'css-var' | 'scss' | 'tailwind' | 'json' | 'svg';
|
|
331
|
+
export type ExportMeshFormat = 'css' | 'css-var' | 'json';
|
|
332
|
+
|
|
333
|
+
export const EXPORT_FORMATS_1D: ReadonlyArray<Export1dFormat>;
|
|
334
|
+
export const EXPORT_FORMATS_MESH: ReadonlyArray<ExportMeshFormat>;
|
|
335
|
+
|
|
336
|
+
export interface FormatMetaEntry {
|
|
337
|
+
label: string;
|
|
338
|
+
hint: string;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export const FORMAT_META: Readonly<Record<Export1dFormat, FormatMetaEntry>>;
|
|
342
|
+
|
|
343
|
+
/** 1D state shape consumed by the exporters (matches the editor's serialize/load). */
|
|
344
|
+
export interface State1d {
|
|
345
|
+
mode: 'linear' | 'radial' | 'conic';
|
|
346
|
+
stops: Array<{
|
|
347
|
+
l: number;
|
|
348
|
+
c: number;
|
|
349
|
+
h: number;
|
|
350
|
+
a?: number;
|
|
351
|
+
stop: number;
|
|
352
|
+
}>;
|
|
353
|
+
angle?: number; // linear
|
|
354
|
+
radShape?: 'circle' | 'ellipse';
|
|
355
|
+
radCx?: number; radCy?: number;
|
|
356
|
+
radPos?: string; // legacy keyword form
|
|
357
|
+
conFrom?: number;
|
|
358
|
+
conCx?: number; conCy?: number;
|
|
359
|
+
conPos?: string; // legacy keyword form
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export interface ExportOptions {
|
|
363
|
+
name?: string;
|
|
364
|
+
width?: number; // svg only
|
|
365
|
+
height?: number; // svg only
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function toTokens1d(state: State1d, format: Export1dFormat, opts?: ExportOptions): string;
|
|
369
|
+
export function toTokensMesh(mesh: MeshGradient, format: ExportMeshFormat, opts?: ExportOptions): string;
|
|
370
|
+
|
|
371
|
+
export function toCss1d(state: State1d, opts?: ExportOptions): string;
|
|
372
|
+
export function toCssVar1d(state: State1d, opts?: ExportOptions): string;
|
|
373
|
+
export function toScss1d(state: State1d, opts?: ExportOptions): string;
|
|
374
|
+
export function toTailwind1d(state: State1d, opts?: ExportOptions): string;
|
|
375
|
+
export function toJson1d(state: State1d, opts?: ExportOptions): string;
|
|
376
|
+
export function toSvg1d(state: State1d, opts?: ExportOptions): string;
|
|
377
|
+
|
|
378
|
+
export function toCssMesh(mesh: MeshGradient, opts?: ExportOptions): string;
|
|
379
|
+
export function toCssVarMesh(mesh: MeshGradient, opts?: ExportOptions): string;
|
|
380
|
+
export function toJsonMesh(mesh: MeshGradient, opts?: ExportOptions): string;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-gradient-studio
|
|
3
|
+
*
|
|
4
|
+
* Authoring layer on top of @zakkster/lite-gradient:
|
|
5
|
+
* - High-fidelity CSS emitters (preserve authored stop positions, optional `in oklch` hint)
|
|
6
|
+
* - Conic CSS emitter (missing from lite-gradient)
|
|
7
|
+
* - Mesh gradient kernel — bilinear OKLCH over deformable control grid
|
|
8
|
+
* - Multi-radial mesh → CSS approximation
|
|
9
|
+
* - Multi-format exporters (CSS, SCSS, Tailwind, JSON, SVG)
|
|
10
|
+
* - OKLCH ↔ hex conversion with sRGB-gamut mapping
|
|
11
|
+
* - Pixel-buffer rasterizer for previews + PNG export
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Gradient } from '@zakkster/lite-gradient';
|
|
15
|
+
|
|
16
|
+
export { Gradient };
|
|
17
|
+
export * from '@zakkster/lite-gradient';
|
|
18
|
+
export {
|
|
19
|
+
bakeGradientToLut,
|
|
20
|
+
flattenStopsToBuffer,
|
|
21
|
+
sampleLut,
|
|
22
|
+
packOklchSingle,
|
|
23
|
+
} from './bake.js';
|
|
24
|
+
export { MeshGradient, defaultMeshColor } from './mesh.js';
|
|
25
|
+
export { formatCssLinear, formatCssRadial, formatCssConic } from './css-emitters.js';
|
|
26
|
+
export { formatCssMesh } from './mesh-css.js';
|
|
27
|
+
export {
|
|
28
|
+
toHex, fromHex, oklchToLinearSrgb, srgbGamma, srgbInverseGamma,
|
|
29
|
+
linearSrgbToOklch,
|
|
30
|
+
} from './color-convert.js';
|
|
31
|
+
export { extractPalette } from './palette-extract.js';
|
|
32
|
+
export { parseGradientCss } from './gradient-parse.js';
|
|
33
|
+
export {
|
|
34
|
+
EXPORT_FORMATS_1D, EXPORT_FORMATS_MESH, FORMAT_META,
|
|
35
|
+
toTokens1d, toTokensMesh,
|
|
36
|
+
toCss1d, toCssVar1d, toScss1d, toTailwind1d, toJson1d, toSvg1d,
|
|
37
|
+
toCssMesh, toCssVarMesh, toJsonMesh,
|
|
38
|
+
} from './exporters.js';
|
package/src/mesh-css.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mesh → CSS approximation.
|
|
3
|
+
*
|
|
4
|
+
* Real mesh gradients don't have a CSS primitive — `mesh-gradient()`
|
|
5
|
+
* isn't standardized. The conventional workaround (Mesher.app, Stripe-
|
|
6
|
+
* style aurora backgrounds, etc.) is to stack one `radial-gradient()`
|
|
7
|
+
* per control point, each fading from the point's color at center to
|
|
8
|
+
* transparent at an outer radius. With sensible overlap the alpha
|
|
9
|
+
* composites approximate the true bilinear field.
|
|
10
|
+
*
|
|
11
|
+
* This is genuinely an approximation, not a faithful reproduction —
|
|
12
|
+
* adjacent control points blend through the alpha layer, not through
|
|
13
|
+
* OKLCH interpolation. The visible result is close enough that most
|
|
14
|
+
* users prefer it to a giant PNG. For pixel-perfect output, use the
|
|
15
|
+
* PNG export path which renders our deformable bilinear kernel direct
|
|
16
|
+
* to a buffer.
|
|
17
|
+
*
|
|
18
|
+
* Output uses the `in oklch` interpolation hint on each radial so the
|
|
19
|
+
* color → transparent fade respects perceptual uniformity in browsers
|
|
20
|
+
* that support it (Chrome 111+, Firefox 113+, Safari 16.4+).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { toCssOklch } from '@zakkster/lite-color';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} mesh A MeshGradient instance (uses .stops, .cols, .rows)
|
|
27
|
+
* @param {object} [opts]
|
|
28
|
+
* @param {number} [opts.radiusPct] Layer radius as % of farthest-corner.
|
|
29
|
+
* Defaults to a formula scaled by mesh size.
|
|
30
|
+
* @param {string} [opts.shape='circle'] 'circle' or 'ellipse'
|
|
31
|
+
* @param {boolean} [opts.oklchInterp=true] Emit `in oklch` per layer.
|
|
32
|
+
* @param {boolean} [opts.includeBase=true] Append a base color layer at end so
|
|
33
|
+
* gaps between radials are filled.
|
|
34
|
+
* @returns {string} A CSS `background:` value (no `background:` keyword, no
|
|
35
|
+
* trailing semicolon — caller decides how to wrap it).
|
|
36
|
+
*/
|
|
37
|
+
export function formatCssMesh(mesh, opts = {}) {
|
|
38
|
+
const {
|
|
39
|
+
radiusPct = autoRadius(mesh),
|
|
40
|
+
shape = 'circle',
|
|
41
|
+
oklchInterp = true,
|
|
42
|
+
includeBase = true,
|
|
43
|
+
} = opts;
|
|
44
|
+
|
|
45
|
+
const scratch = { l: 0, c: 0, h: 0, a: 1 };
|
|
46
|
+
const layers = [];
|
|
47
|
+
const interp = oklchInterp ? ' in oklch' : '';
|
|
48
|
+
|
|
49
|
+
for (const s of mesh.stops) {
|
|
50
|
+
scratch.l = s.l; scratch.c = s.c; scratch.h = s.h;
|
|
51
|
+
scratch.a = s.a === undefined ? 1 : s.a;
|
|
52
|
+
const color = toCssOklch(scratch);
|
|
53
|
+
const x = (s.x * 100).toFixed(1);
|
|
54
|
+
const y = (s.y * 100).toFixed(1);
|
|
55
|
+
// `transparent` resolves to oklch(0 0 0 / 0) — same hue interpolation.
|
|
56
|
+
layers.push(
|
|
57
|
+
`radial-gradient(${shape} at ${x}% ${y}%${interp}, ` +
|
|
58
|
+
`${color}, transparent ${radiusPct.toFixed(0)}%)`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (includeBase) {
|
|
63
|
+
// Base layer = average of all stops. Picks a sensible "background"
|
|
64
|
+
// tone so gaps between radials don't show as transparent.
|
|
65
|
+
const avg = averageColor(mesh.stops);
|
|
66
|
+
scratch.l = avg.l; scratch.c = avg.c; scratch.h = avg.h;
|
|
67
|
+
scratch.a = avg.a;
|
|
68
|
+
layers.push(toCssOklch(scratch));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return layers.join(',\n ');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Heuristic radius. Larger meshes → smaller per-layer radii (each
|
|
76
|
+
* layer covers less canvas, so seams between adjacent points stay
|
|
77
|
+
* tight; otherwise the alpha stack reduces to a muddy average).
|
|
78
|
+
*
|
|
79
|
+
* Tuned against 2×2 → 5×5: the multiplier (1.2×) gives ~20% overlap
|
|
80
|
+
* between neighbouring layers — enough that they blend, not enough
|
|
81
|
+
* that the field collapses toward the average. The base layer beneath
|
|
82
|
+
* fills any remaining gaps.
|
|
83
|
+
*/
|
|
84
|
+
function autoRadius(mesh) {
|
|
85
|
+
const n = Math.max(mesh.cols, mesh.rows);
|
|
86
|
+
if (n <= 2) return 70;
|
|
87
|
+
return Math.round(100 / (n - 1) * 1.2);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Average L/C and circular-mean H of the stops. Used as the base layer
|
|
92
|
+
* so the visible field has a sensible color where layers don't reach.
|
|
93
|
+
*
|
|
94
|
+
* Hue is averaged via vector mean (sum unit vectors at each hue, take
|
|
95
|
+
* angle of the resultant) so that a 350°/10° pair averages to ~0°/360°,
|
|
96
|
+
* not 180°. Standard circular-statistics trick.
|
|
97
|
+
*/
|
|
98
|
+
function averageColor(stops) {
|
|
99
|
+
let lSum = 0, cSum = 0, aSum = 0;
|
|
100
|
+
let hxSum = 0, hySum = 0;
|
|
101
|
+
const n = stops.length;
|
|
102
|
+
for (const s of stops) {
|
|
103
|
+
lSum += s.l;
|
|
104
|
+
cSum += s.c;
|
|
105
|
+
aSum += s.a === undefined ? 1 : s.a;
|
|
106
|
+
const rad = s.h * Math.PI / 180;
|
|
107
|
+
hxSum += Math.cos(rad);
|
|
108
|
+
hySum += Math.sin(rad);
|
|
109
|
+
}
|
|
110
|
+
let hAvg = Math.atan2(hySum / n, hxSum / n) * 180 / Math.PI;
|
|
111
|
+
if (hAvg < 0) hAvg += 360;
|
|
112
|
+
return { l: lSum / n, c: cSum / n, h: hAvg, a: aSum / n };
|
|
113
|
+
}
|