UNPKG

scrawl-canvas

Version:

Responsive, interactive and more accessible HTML5 canvas elements. Scrawl-canvas is a JavaScript library designed to make using the HTML5 canvas element easier, and more fun

712 lines (509 loc) 24.4 kB
// # Color factory // // **NOTE – Why Scrawl-canvas HSL/HWB gradients don’t match CSS** // // Browsers treat hsl() and hwb() as *alternate notations of sRGB*. For gradients, CSS first resolves all stops to sRGB and then interpolates channels in RGB (premultiplied alpha). Result: CSS gradients for RGB/HSL/HWB look the same. // // Scrawl-canvas intentionally interpolates *in the declared internal space*. For HSL and HWB the code blends the cylindrical components (with shortest-arc hue wrapping), which produces more saturated, “truer HSL/HWB” midpoints than RGB-space blends. That’s why gradients built in the HSL/HWB color space tend to be more colorful, and differ from equivalent CSS gradients. // // **NOTE – Why Scrawl-canvas LCH/OKLCH gradients don’t match CSS** // // Most browsers currently render lch() and oklch() gradient stops by converting them to the *Cartesian* forms (Lab / Oklab) and then interpolating linearly in that space (premultiplied-alpha, then mapped to sRGB). Hue is only used to form a/b (or A/B), it is not interpolated as an angle; achromatic stops effectively collapse to Lab/Oklab vectors. In practice this makes CSS LCH ≈ CSS LAB and CSS OKLCH ≈ CSS OKLAB. // // Scrawl-canvas intentionally interpolates in the *cylindrical* spaces themselves: L linearly; H with shortest-arc wrapping; C linearly, with chroma gently clipped/fit to the output gamut. That preserves hue continuity and tends to produce more saturated “LCH-like / OKLCH-like” midpoints (e.g. a magenta ridge between red→blue), hence the visual difference from equivalent CSS gradients. // #### Imports import { constructors, entity } from '../core/library.js'; import { clamp, correctAngle, doCreate, easeEngines, interpolate, isa_fn, isa_obj, mergeOver, pushUnique, Ωempty } from '../helper/utilities.js'; import { colorEngine } from '../helper/color-engine.js'; import baseMix from '../mixin/base.js'; // Shared constants import { _isArray, _isFinite, _keys, _random, _values, BLACK, BLANK, INT_COLOR_SPACES, LINEAR, NAME, RANDOM, RGB, STYLES, T_COLOR, UNDEF, WHITE } from '../helper/shared-vars.js'; // Local constants const RET_COLOR_SPACES = ['rgb', 'hsl', 'hwb', 'lab', 'lch', 'oklab', 'oklch'], HSL = 'hsl', HWB = 'hwb', LAB = 'lab', LCH = 'lch', OKLAB = 'oklab', OKLCH = 'oklch', XYZ = 'xyz', MAX = 'max', MIN = 'min', EPS = 0.0001; // Local helper functions // // `interpolateShortestHue` - Interpolate angles in degrees along the shortest arc, result in [0,360) const interpolateShortestHue = (t, h1, h2) => { const d = ((h2 - h1 + 540) % 360) - 180; return correctAngle(h1 + t * d); }; // #### Color constructor const Color = function (items = Ωempty) { this.makeName(items.name); this.register(); this.color = null; this.currentColorData = []; this.currentColorInternalData = []; this.currentColorReturnData = []; this.currentColorString = ''; this.maximumColor = null; this.currentMaximumColorData = []; this.currentMaximumColorInternalData = []; this.currentMaximumColorReturnData = []; this.currentMaximumColorString = ''; this.minimumColor = null; this.currentMinimumColorData = []; this.currentMinimumColorInternalData = []; this.currentMinimumColorReturnData = []; this.currentMinimumColorString = ''; this.set(this.defs); this.easing = LINEAR; this.currentRangePoint = -1; this.set(items); return this; }; // #### Color prototype const P = Color.prototype = doCreate(); P.type = T_COLOR; P.lib = STYLES; P.isArtefact = false; P.isAsset = false; // #### Mixins baseMix(P); // #### Color attributes const defaultAttributes = { // __colorSpace__ - String value defining the color space to be used by the Color object for its internal calculations. // + Accepted values from: `'rgb', 'hsl', 'hwb', 'xyz', 'lab', 'lch', 'oklab', 'oklch'` (mixed capitals acceptable eg 'RGB' as strings will be lowercased) colorSpace: RGB, // __returnColorAs__ - String value defining the type of color String the Color object will return. // + This is a shorter list than the internal colorSpace attribute as we only return values for CSS specified color spaces. // + Accepted values from: `'rgb', 'hsl', 'hwb', 'lab', 'lch', 'oklab', 'oklch'` (mixed capitals acceptable eg 'RGB' as strings will be lowercased) returnColorAs: RGB, // __color__ - CSS (Color level 4) String value defining the object's main color. color: BLANK, // __maxColor__ - CSS (Color level 4) String value defining the object's maximum color. Used with both range color and random color functionality. maximumColor: WHITE, // __minColor__ - CSS (Color level 4) String value defining the object's maximum color. Used with both range color and random color functionality. minimumColor: BLACK, // The __easing__ attribute affects the `getRangeColor` function, applying an easing function to those requests. Value may be a predefined easing String name, or a function accepting a Number value and returning a Number value, both values to be positive floats in the range 0-1 easing: LINEAR, }; P.defs = mergeOver(P.defs, defaultAttributes); // #### Packet management P.packetFunctions = pushUnique(P.packetFunctions, ['easingFunction']); // #### Clone management // No additional clone functionality required // #### Kill management // Overwrites ./mixin/base.js P.kill = function () { const myname = this.name; // Remove style from all entity state objects _values(entity).forEach(ent => { const state = ent.state; if (state) { const fill = state.fillStyle, stroke = state.strokeStyle, shadow = state.shadowColor; if (isa_obj(fill) && fill.name === myname) state.fillStyle = state.defs.fillStyle; if (isa_obj(stroke) && stroke.name === myname) state.strokeStyle = state.defs.strokeStyle; if (isa_obj(shadow) && shadow.name === myname) state.shadowColor = state.defs.shadowColor; } }); // Remove style from the Scrawl-canvas library this.deregister(); return this; }; // #### Get, Set, deltaSet // `get` - overrides function in mixin/base.js - this function has several overrides: // + `get()` - return the object's main color string. // + `get(Number)` - returns a range color string, between the minimum and maximum colors as modified by the current easing engine function. Argument must be a positive number in the range 0.0 - 1.0. // + `get('min')` - return the object's minimum color string. // + `get('max')` - return the object's maximum color string. // + `get('random')` - return a random color string, between the minimum and maximum colors; this value also becomes the object's new main color. P.get = function (item) { if (item.toFixed) { return this.getRangeColor(item); } else if (item === MIN) { return this.getMinimumColorString(); } else if (item === MAX) { return this.getMaximumColorString(); } else if (item === RANDOM) { return this.generateRandomColor(); } else { return this.getMainColorString(); } }; // `set` - overrides function in mixin/base.js. P.set = function (items = Ωempty) { const keys = _keys(items), keysLen = keys.length; if (keysLen) { const setters = this.setters, defs = this.defs; let predefined, i, key, value; for (i = 0; i < keysLen; i++) { key = keys[i]; value = items[key]; if (key && key !== NAME && value != null) { predefined = setters[key]; if (predefined) predefined.call(this, value); else if (typeof defs[key] !== UNDEF) this[key] = value; } } // If users invoke `color.set({ random: true, key: value, ... })` the object will generate a new, random main color. if (items.random) this.generateRandomColor(); } return this; }; const S = P.setters; // The `color` function takes a CSS (Color level 4) String argument and performs immidiate work to calculate the related `current` attributes. S.color = function (item) { this.setMainColor(item); }; // The `minimumColor` function takes a CSS (Color level 4) String argument and performs immidiate work to calculate the related `current` attributes. S.minimumColor = function (item) { this.setMinimumColor(item); }; // The `maximumColor` function takes a CSS (Color level 4) String argument and performs immidiate work to calculate the related `current` attributes. S.maximumColor = function (item) { this.setMaximumColor(item); }; // `easing` - we can apply easing functions to colors, for instance when invoking the Color object's `getRangeColor()` function to return the most appropriate color between the Color object's minimum and maximum color values. // + Can accept a String value identifying an SC pre-defined easing function (default: `linear`). // + Can also accept a function which takes a single Number argument (between 0.0 and 1.0) and returns an eased Number (again, between 0-1). // + For legacy reasons, this function also maps to `easingFunction`. S.easing = function (item) { this.setEasingHelper(item); }; S.easingFunction = S.easing; S.colorSpace = function (item) { this.setColorSpaceHelper(item); }; S.returnColorAs = function (item) { this.setReturnColorAsHelper(item); }; // #### Prototype functions // The `setColor` function takes a CSS (Color level 4) String argument and performs immidiate work to calculate the related `current` attributes. // + `this.currentRangePoint` - must be set to `-1` as part of this work P.setMainColor = function (item) { if (item.substring) { this.color = item; const col = this.currentColorData = colorEngine.getColorValuesFromString(item); const int = this.currentColorInternalData = colorEngine.convertColorData(col, this.colorSpace); const ret = this.currentColorReturnData = colorEngine.convertColorData(int, this.returnColorAs); this.currentColorString = colorEngine.buildColorStringFromData(ret); this.currentRangePoint = -1; } }; // The `setMinimumColor` function takes a CSS (Color level 4) String argument and performs immidiate work to calculate the related `current` attributes. // + `this.currentRangePoint` - must be set to `-1` as part of this work. P.setMinimumColor = function (item) { if (item.substring) { this.minimumColor = item; const col = this.currentMinimumColorData = colorEngine.getColorValuesFromString(item); const int = this.currentMinimumColorInternalData = colorEngine.convertColorData(col, this.colorSpace); const ret = this.currentMinimumColorReturnData = colorEngine.convertColorData(int, this.returnColorAs); this.currentMinimumColorString = colorEngine.buildColorStringFromData(ret); this.currentRangePoint = -1; } }; // The `setMaximumColor` function takes a CSS (Color level 4) String argument and performs immidiate work to calculate the related `current` attributes. // + `this.currentRangePoint` - must be set to `-1` as part of this work. P.setMaximumColor = function (item) { if (item.substring) { this.maximumColor = item; const col = this.currentMaximumColorData = colorEngine.getColorValuesFromString(item); const int = this.currentMaximumColorInternalData = colorEngine.convertColorData(col, this.colorSpace); const ret = this.currentMaximumColorReturnData = colorEngine.convertColorData(int, this.returnColorAs); this.currentMaximumColorString = colorEngine.buildColorStringFromData(ret); this.currentRangePoint = -1; } }; // The `setEasingHelper` function accepts the following arguments: // + Can accept a String value identifying an SC pre-defined easing function (default: `linear`). // + Can also accept a function which takes a single Number argument (between 0.0 and 1.0) and returns an eased Number (again, between 0-1). // + `this.currentRangePoint` - must be set to `-1` as part of this work. P.setEasingHelper = function (item) { if (isa_fn(item) || (item.substring && easeEngines[item])) { this.easing = item; } else { this.easing = LINEAR; } this.currentRangePoint = -1; }; // The `setColorSpaceHelper` function takes a CSS (Color level 4) String argument and performs immidiate work to calculate the related `current` attributes. // + Permitted strings: 'RGB', 'HSL', 'HWB', 'XYZ', 'LAB', 'LCH', 'OKLAB', 'OKLCH' (lowercase and mixed case alternatives permitted). P.setColorSpaceHelper = function (item) { if (item != null && item.toLowerCase) { item = item.toLowerCase(); if (INT_COLOR_SPACES.includes(item)) { if (this.colorSpace == null) this.colorSpace = item; else { if (item !== this.colorSpace) { this.colorSpace = item; // Max const maxInt = colorEngine.convertColorData(this.currentMaximumColorData, this.colorSpace); this.currentMaximumColorInternalData = maxInt; const maxRet = colorEngine.convertColorData(maxInt, this.returnColorAs); this.currentMaximumColorReturnData = maxRet; this.currentMaximumColorString = colorEngine.buildColorStringFromData(maxRet); // Min const minInt = colorEngine.convertColorData(this.currentMinimumColorData, item); this.currentMinimumColorInternalData = minInt; const minRet = colorEngine.convertColorData(minInt, this.returnColorAs); this.currentMinimumColorReturnData = minRet; this.currentMinimumColorString = colorEngine.buildColorStringFromData(minRet); // Main const mainInt = colorEngine.convertColorData(this.currentColorData, item); this.currentColorInternalData = mainInt; const mainRet = colorEngine.convertColorData(mainInt, this.returnColorAs); this.currentColorReturnData = mainRet; this.currentColorString = colorEngine.buildColorStringFromData(mainRet); this.currentRangePoint = -1; } } } } }; // The `setReturnColorAsHelper` function takes a colorSpace String argument and performs immidiate work to calculate the related `current` attributes. // + Permitted strings: 'RGB', 'HSL', 'HWB', 'LAB', 'LCH', 'OKLAB', 'OKLCH' (lowercase and mixed case alternatives permitted). P.setReturnColorAsHelper = function (item) { if (item != null && item.toLowerCase) { item = item.toLowerCase(); if (RET_COLOR_SPACES.includes(item)) { if (this.returnColorAs == null) this.returnColorAs = item; else { if (item !== this.returnColorAs) { this.returnColorAs = item; // Max const maxRet = colorEngine.convertColorData(this.currentMaximumColorInternalData, this.returnColorAs); this.currentMaximumColorReturnData = maxRet; this.currentMaximumColorString = colorEngine.buildColorStringFromData(maxRet); // Min const minRet = colorEngine.convertColorData(this.currentMinimumColorInternalData, this.returnColorAs); this.currentMinimumColorReturnData = minRet; this.currentMinimumColorString = colorEngine.buildColorStringFromData(minRet); // Main const mainRet = colorEngine.convertColorData(this.currentColorInternalData, this.returnColorAs); this.currentColorReturnData = mainRet; this.currentColorString = colorEngine.buildColorStringFromData(mainRet); this.currentRangePoint = -1; } } } } }; P.getMinimumColorString = function () { return this.currentMinimumColorString; }; P.getMaximumColorString = function () { return this.currentMaximumColorString; }; P.getMainColorString = function () { return this.currentColorString; }; P.getRangeColor = function (item) { if (!_isFinite(item)) item = 0; const { currentMinimumColorInternalData: min, currentMaximumColorInternalData: max, colorSpace, returnColorAs, easing, currentRangePoint, } = this; const val = isa_fn(easing) ? easing(item) : easeEngines[easing](item); if (val !== currentRangePoint) { let c1 = interpolate(val, min[1], max[1]), c2 = interpolate(val, min[2], max[2]), c3 = interpolate(val, min[3], max[3]), alpha = interpolate(val, min[4], max[4]), cMin, cMax; // We need to clamp color channels to meet colorSpace requirements switch (colorSpace) { case RGB : c1 = clamp(c1, 0, 255); c2 = clamp(c2, 0, 255); c3 = clamp(c3, 0, 255); break; case OKLCH : c1 = clamp(c1, 0, 1); c2 = clamp(c2, 0, 0.4); cMin = min[2]; cMax = max[2]; if (cMin <= EPS && cMax <= EPS) c3 = correctAngle(max[3]); else if (cMin <= EPS) c3 = correctAngle(max[3]); else if (cMax <= EPS) c3 = correctAngle(min[3]); else c3 = interpolateShortestHue(val, min[3], max[3]); break; case OKLAB : c1 = clamp(c1, 0, 1); c2 = clamp(c2, -0.4, 0.4); c3 = clamp(c3, -0.4, 0.4); break; case LCH : c1 = clamp(c1, 0, 100); c2 = clamp(c2, 0, 230); cMin = min[2]; cMax = max[2]; if (cMin <= EPS && cMax <= EPS) c3 = correctAngle(max[3]); else if (cMin <= EPS) c3 = correctAngle(max[3]); else if (cMax <= EPS) c3 = correctAngle(min[3]); else c3 = interpolateShortestHue(val, min[3], max[3]); break; case LAB : c1 = clamp(c1, 0, 100); c2 = clamp(c2, -125, 125); c3 = clamp(c3, -125, 125); break; case HSL : c1 = interpolateShortestHue(val, min[1], max[1]); c2 = clamp(c2, 0, 100); c3 = clamp(c3, 0, 100); break; case HWB : c1 = interpolateShortestHue(val, min[1], max[1]); c2 = clamp(c2, 0, 100); c3 = clamp(c3, 0, 100); break; case XYZ : c1 = clamp(c1, -0.001, 1.2); c2 = clamp(c2, -0.001, 1.2); c3 = clamp(c3, -0.001, 1.2); break; } alpha = clamp(alpha, 0, 1); const data = [colorSpace, c1, c2, c3, alpha]; this.currentColorInternalData = data; const res = colorEngine.convertColorData(data, returnColorAs); this.currentColorReturnData = res; this.currentColorString = colorEngine.buildColorStringFromData(res); this.currentRangePoint = val; } // } return this.currentColorString; }; P.generateRandomColor = function () { const { currentMinimumColorInternalData: min, currentMaximumColorInternalData: max, colorSpace, returnColorAs, } = this; if (_isArray(min) && _isArray(max)) { let c1 = interpolate(_random(), min[1], max[1]), c2 = interpolate(_random(), min[2], max[2]), c3 = interpolate(_random(), min[3], max[3]), alpha = interpolate(_random(), min[4], max[4]); let cMin, cMax; // We need to clamp color channels to meet colorSpace requirements switch (colorSpace) { case RGB : c1 = clamp(c1, 0, 255); c2 = clamp(c2, 0, 255); c3 = clamp(c3, 0, 255); break; case OKLCH : c1 = clamp(c1, 0, 1); c2 = clamp(c2, 0, 0.4); cMin = min[2]; cMax = max[2]; if (cMin <= EPS && cMax <= EPS) c3 = correctAngle(max[3]); else if (cMin <= EPS) c3 = correctAngle(max[3]); else if (cMax <= EPS) c3 = correctAngle(min[3]); else c3 = interpolateShortestHue(_random(), min[3], max[3]); break; case OKLAB : c1 = clamp(c1, 0, 1); c2 = clamp(c2, -0.4, 0.4); c3 = clamp(c3, -0.4, 0.4); break; case LCH : c1 = clamp(c1, 0, 100); c2 = clamp(c2, 0, 230); cMin = min[2]; cMax = max[2]; if (cMin <= EPS && cMax <= EPS) c3 = correctAngle(max[3]); else if (cMin <= EPS) c3 = correctAngle(max[3]); else if (cMax <= EPS) c3 = correctAngle(min[3]); else c3 = interpolateShortestHue(_random(), min[3], max[3]); break; case LAB : c1 = clamp(c1, 0, 100); c2 = clamp(c2, -125, 125); c3 = clamp(c3, -125, 125); break; case HSL : c1 = interpolateShortestHue(_random(), min[1], max[1]); c2 = clamp(c2, 0, 100); c3 = clamp(c3, 0, 100); break; case HWB : c1 = interpolateShortestHue(_random(), min[1], max[1]); c2 = clamp(c2, 0, 100); c3 = clamp(c3, 0, 100); break; case XYZ : c1 = clamp(c1, -0.001, 1.2); c2 = clamp(c2, -0.001, 1.2); c3 = clamp(c3, -0.001, 1.2); break; } alpha = clamp(alpha, 0, 1); const data = [colorSpace, c1, c2, c3, alpha]; this.currentColorInternalData = data; const res = colorEngine.convertColorData(data, returnColorAs); this.currentColorReturnData = res; this.currentColorString = colorEngine.buildColorStringFromData(res); this.currentRangePoint = -1; } return this.currentColorString; }; // `getData` function called by Cell objects when calculating required updates to its CanvasRenderingContext2D engine, specifically for an entity's __fillStyle__, __strokeStyle__ and __shadowColor__ attributes. P.getData = function () { return this.currentColorString; }; // #### Passing functions from the color engine through to the factory instance's API // // `buildColorStringFromData` // + Returns appropriate color strings from input data in the form `[space, c1, c2, c3, a]` // + space ∈ {'rgb','hsl','hwb','lab','lch','oklab','oklch'} P.buildColorStringFromData = colorEngine.buildColorStringFromData; // `extractRGBfromColorString` - returns the R, G and B channel values (0 to 255) from a valid CSS Colors level 4 string P.extractRGBfromColorString = colorEngine.extractRGBfromColorString // `convertRGBtoHex` P.convertRGBtoHex = colorEngine.convertRGBtoHex; // #### Factory // ``` // const mycol = scrawl.makeColor({ // // name: 'myColorObject', // // minimumColor: 'red', // maximumColor: 'green', // }); // // scrawl.makeBlock({ // // name: 'block-tester', // // width: 120, // height: 40, // // startX: 60, // startY: 60, // // fillStyle: mycol.getRangeColor(Math.random()), // method: 'fill', // }); // ``` export const makeColor = function (items) { if (!items) return false; return new Color(items); }; constructors.Color = Color;