├── api ├── api.js └── index.tpl.html ├── _headers ├── netlify.toml ├── apps ├── index.md ├── convert │ ├── index.html │ ├── style.css │ └── convert.js └── gradients │ ├── style.css │ └── index.html ├── README.md ├── tests ├── style.css ├── style.src.css ├── index.js ├── hooks.html ├── index.html ├── contrast.html ├── construct.html ├── modifications.html ├── multiply-matrices.html ├── gamut.html └── parse.html ├── .gitignore ├── .editorconfig ├── templates ├── _header.html ├── _head.html ├── _nav.html ├── _docs-nav.html └── _footer.html ├── color.js ├── src ├── spaces │ ├── srgb-linear.js │ ├── p3.js │ ├── absxyzd65.js │ ├── a98rgb.js │ ├── hsv.js │ ├── prophoto.js │ ├── jzczhz.js │ ├── rec2020.js │ ├── oklch.js │ ├── lch.js │ ├── rec2100.js │ ├── oklab.js │ ├── hsl.js │ ├── lab.js │ ├── acescc.js │ ├── hwb.js │ ├── srgb.js │ ├── jzazbz.js │ └── ictcp.js ├── deltaE │ ├── deltaEOK.js │ ├── deltaEITP.js │ ├── deltaEJz.js │ ├── deltaECMC.js │ └── deltaE2000.js ├── hooks.js ├── multiply-matrices.js ├── angles.js ├── main.js ├── util.js ├── CATs.js ├── interpolation.js └── keywords.js ├── assets ├── js │ ├── showdown-extensions.mjs │ ├── index.js │ └── docs.js └── css │ ├── home.src.css │ ├── home.css │ ├── docs.src.css │ ├── docs.css │ └── prism.css ├── .eslintrc.json ├── notebook ├── index.tpl.html ├── repl.js ├── color-notebook.src.css └── color-notebook.css ├── docs ├── manipulation.md ├── index.tpl.html ├── spaces.tpl.html ├── gamut-mapping.md ├── the-color-object.md ├── interpolation.md ├── output.md └── color-difference.md ├── get ├── style.src.css ├── style.css ├── index.js └── index.tpl.html ├── LICENSE ├── package.json ├── CONTRIBUTING.md ├── scripts └── RGB_matrix_maker.py ├── logo.svg ├── gulpfile.js └── index.tpl.html /api/api.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_headers: -------------------------------------------------------------------------------- 1 | /* 2 | Access-Control-Allow-Origin: * 3 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build.environment] 2 | NODE_VERSION = "14.3" 3 | -------------------------------------------------------------------------------- /apps/index.md: -------------------------------------------------------------------------------- 1 | # Demos 2 | 3 | - [Convert](convert) 4 | - [Gradients](gradients) 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Color.js 2 | 3 | - [Docs](https://colorjs.io) 4 | - [Contributing](CONTRIBUTING.md) 5 | -------------------------------------------------------------------------------- /tests/style.css: -------------------------------------------------------------------------------- 1 | td[style*="--color:"] { 2 | background: var(--color, inherit); 3 | } 4 | 5 | td.dark { 6 | color: white; 7 | } 8 | -------------------------------------------------------------------------------- /tests/style.src.css: -------------------------------------------------------------------------------- 1 | td[style*="--color:"] { 2 | background: var(--color, inherit); 3 | } 4 | 5 | td.dark { 6 | color: white; 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | 4 | # Ignore generated HTML files 5 | *.html 6 | !*.tpl.html 7 | !tests/*.html 8 | !templates/*.html 9 | !apps/*/*.html 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf -------------------------------------------------------------------------------- /templates/_header.html: -------------------------------------------------------------------------------- 1 |
2 | 6 |

@@title

7 |
8 | -------------------------------------------------------------------------------- /templates/_head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /color.js: -------------------------------------------------------------------------------- 1 | // Import all modules of Color.js and assign Color to a global, 2 | // for easier testing and experimentation without building 3 | import Color, {util} from "./src/main.js"; 4 | 5 | window.Color = window.Color || Color; 6 | 7 | // Re-export 8 | export default Color; 9 | export {util}; 10 | -------------------------------------------------------------------------------- /src/spaces/srgb-linear.js: -------------------------------------------------------------------------------- 1 | import Color from "./srgb.js"; 2 | 3 | // This is the linear-light version of sRGB 4 | // as used for example in SVG filters 5 | // or in Canvas 6 | 7 | Color.defineSpace({ 8 | inherits: "srgb", 9 | id: "srgb-linear", 10 | name: "sRGB-linear", 11 | toLinear(RGB) { 12 | return RGB; 13 | }, 14 | toGamma(RGB) { 15 | return RGB; 16 | }, 17 | }); 18 | 19 | export default Color; 20 | 21 | -------------------------------------------------------------------------------- /templates/_nav.html: -------------------------------------------------------------------------------- 1 | Get Color.js 2 | Docs 3 | API 4 | Play! 5 | Demos 6 | Tests 7 | GitHub 8 | File bug 9 | -------------------------------------------------------------------------------- /templates/_docs-nav.html: -------------------------------------------------------------------------------- 1 |
  • The Color object
  • 2 |
  • Supported color spaces
  • 3 |
  • Color difference
  • 4 |
  • Color manipulation
  • 5 |
  • Gamut Mapping
  • 6 |
  • Interpolation
  • 7 |
  • Chromatic Adaptation
  • 8 |
  • Output
  • 9 | -------------------------------------------------------------------------------- /templates/_footer.html: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /assets/js/showdown-extensions.mjs: -------------------------------------------------------------------------------- 1 | const extensions = { 2 | "apiLinks": { // TODO read api/api.json to see what to linkify 3 | type: "lang", 4 | regex: /`([Cc]olor).(\w+)\(\)`/g, 5 | replace: ($0, className, funcName) => { 6 | return `${$0}`; 7 | } 8 | }, 9 | "callouts": { 10 | type: "lang", 11 | regex: /^\s*(Tip|Warning|Note):\s+/gm, 12 | replace: ($0, className, funcName) => { 13 | return `

    `; 14 | } 15 | } 16 | }; 17 | 18 | export default extensions; 19 | -------------------------------------------------------------------------------- /tests/index.js: -------------------------------------------------------------------------------- 1 | RefTest.hooks.add("reftest-testrow", function (env) { 2 | let table = this.table; 3 | 4 | if (!table.dataset.colors) { 5 | return; 6 | } 7 | 8 | let colorCols = new Set(table.dataset.colors.split(/\s*,\s*/).map(i => i - 1)); 9 | 10 | for (let i = 0; i < env.cells.length; i++) { 11 | if (!colorCols.has(i)) { 12 | continue; 13 | } 14 | 15 | let cell = env.cells[i]; 16 | let color = new Color(cell.textContent); 17 | cell.style.setProperty("--color", color.toString({fallback: true})); 18 | cell.classList.add(color.luminance > .5 || color.alpha < .5? "light" : "dark"); 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /src/deltaE/deltaEOK.js: -------------------------------------------------------------------------------- 1 | import Color from "../spaces/oklab.js"; 2 | // More accurate color-difference formulae 3 | // than the simple 1976 Euclidean distance in CIE Lab 4 | 5 | 6 | Color.prototype.deltaEOK = function (sample, deltas = {}) { 7 | let color = this; 8 | sample = Color.get(sample); 9 | 10 | // Given this color as the reference 11 | // and a sample, 12 | // calculate deltaEOK, term by term as root sum of squares 13 | let [L1, a1, b1] = color.oklab; 14 | let [L2, a2, b2] = sample.oklab; 15 | let ΔL = L1 - L2; 16 | let Δa = a1 - a2; 17 | let Δb = b1 - b2; 18 | return Math.sqrt(ΔL ** 2 + Δa ** 2 + Δb ** 2); 19 | }; 20 | 21 | Color.statify(["deltaEOK"]); 22 | -------------------------------------------------------------------------------- /src/hooks.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module version of Bliss.Hooks. 3 | * @author Lea Verou 4 | */ 5 | export default class Hooks { 6 | add (name, callback, first) { 7 | if (typeof arguments[0] != "string") { 8 | // Multiple hooks 9 | for (var name in arguments[0]) { 10 | this.add(name, arguments[0][name], arguments[1]); 11 | } 12 | 13 | return; 14 | } 15 | 16 | (Array.isArray(name)? name : [name]).forEach(function(name) { 17 | this[name] = this[name] || []; 18 | 19 | if (callback) { 20 | this[name][first? "unshift" : "push"](callback); 21 | } 22 | }, this); 23 | } 24 | 25 | run (name, env) { 26 | this[name] = this[name] || []; 27 | this[name].forEach(function(callback) { 28 | callback.call(env && env.context? env.context : env, env); 29 | }); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "parserOptions": { 4 | "ecmaVersion": 6, 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "impliedStrict": true 8 | } 9 | }, 10 | "env": { 11 | "browser": true, 12 | "node": true 13 | }, 14 | "rules": { 15 | "semi": 1, 16 | "no-dupe-args": 1, 17 | "no-dupe-keys": 1, 18 | "no-unreachable": 1, 19 | "valid-typeof": 1, 20 | "curly": 1, 21 | "no-useless-call": 1, 22 | "brace-style": [1, "stroustrup"], 23 | "no-mixed-spaces-and-tabs": [1, "smart-tabs"], 24 | "quotes": [1, "double", "avoid-escape"], 25 | "spaced-comment": [1, "always", { 26 | "block": { 27 | "exceptions": ["*"] 28 | } 29 | }], 30 | "arrow-spacing": 1, 31 | "comma-spacing": 1, 32 | "keyword-spacing": 1 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/deltaE/deltaEITP.js: -------------------------------------------------------------------------------- 1 | import Color from "../spaces/ictcp.js"; 2 | 3 | // Delta E in ICtCp space, 4 | // which the ITU calls Delta E ITP, which is shorter 5 | // formulae from ITU Rec. ITU-R BT.2124-0 6 | 7 | Color.prototype.deltaEITP = function (sample) { 8 | let color = this; 9 | sample = Color.get(sample); 10 | 11 | // Given this color as the reference 12 | // and a sample, 13 | // calculate deltaE in ICtCp 14 | // which is simply the Euclidean distance 15 | 16 | let [ I1, T1, P1 ] = color.ictcp; 17 | let [ I2, T2, P2 ] = sample.ictcp; 18 | 19 | // the 0.25 factor is to undo the encoding scaling in Ct 20 | // the 720 is so that 1 deltaE = 1 JND 21 | // per ITU-R BT.2124-0 p.3 22 | 23 | return 720 * Math.sqrt((I1 - I2) ** 2 + (0.25 * (T1 -T2) ** 2) + (P1 - P2) ** 2); 24 | } 25 | 26 | Color.statify(["deltaEITP"]); 27 | -------------------------------------------------------------------------------- /notebook/index.tpl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Color Notebook 7 | 8 | @@include('_head.html') 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | @@include('_header.html', { 17 | "title": "Color Notebook" 18 | }) 19 |

    20 | 21 |
    27 | 28 |
    29 | 30 |
    31 | 32 | @@include('_footer.html') 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /docs/manipulation.md: -------------------------------------------------------------------------------- 1 | # Color manipulation 2 | 3 | ## By color coordinates 4 | 5 | We've seen in the [previous section](the-color-object) how we can manipulate a color 6 | by directly manipulating coordinates in any of the color spaces supported. 7 | LCH coordinates are particularly useful for this so they are available directly on color objects: 8 | 9 | ```js 10 | let color = new Color("rebeccapurple"); 11 | color.lightness *= 1.2; 12 | color.chroma = 40; 13 | color.hue += 30; 14 | ``` 15 | 16 | You can also use `color.set()` to set multiple coordinates at once. 17 | In addition, it returns the color itself, so further methods can be called on it: 18 | 19 | ```js 20 | let color = new Color("lch(50% 50 10)"); 21 | color = color.set({ 22 | hue: h => h + 180, // relative modification! 23 | chroma: 60, 24 | "hwb.whiteness": w => w * 1.2 25 | }).lighten(); 26 | ``` 27 | -------------------------------------------------------------------------------- /apps/convert/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Convert 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
    15 | 18 | 19 | 22 |
    23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
    IdCoordscolor.toString()Color.prototype.toString.call(color)
    37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/spaces/p3.js: -------------------------------------------------------------------------------- 1 | import Color from "./srgb.js"; 2 | 3 | Color.defineSpace({ 4 | inherits: "srgb", 5 | id: "p3", 6 | name: "P3", 7 | cssId: "display-p3", 8 | // Gamma correction is the same as sRGB 9 | // convert an array of display-p3 values to CIE XYZ 10 | // using D65 (no chromatic adaptation) 11 | // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html 12 | // Functions are the same as sRGB, just with different matrices 13 | toXYZ_M: [ 14 | [0.4865709486482162, 0.26566769316909306, 0.1982172852343625], 15 | [0.2289745640697488, 0.6917385218365064, 0.079286914093745], 16 | [0.0000000000000000, 0.04511338185890264, 1.043944368900976] 17 | ], 18 | fromXYZ_M: [ 19 | [ 2.493496911941425, -0.9313836179191239, -0.40271078445071684], 20 | [-0.8294889695615747, 1.7626640603183463, 0.023624685841943577], 21 | [ 0.03584583024378447, -0.07617238926804182, 0.9568845240076872] 22 | ] 23 | }); 24 | -------------------------------------------------------------------------------- /src/multiply-matrices.js: -------------------------------------------------------------------------------- 1 | // A is m x n. B is n x p. product is m x p. 2 | export default function multiplyMatrices(A, B) { 3 | let m = A.length; 4 | 5 | if (!Array.isArray(A[0])) { 6 | // A is vector, convert to [[a, b, c, ...]] 7 | A = [A]; 8 | } 9 | 10 | if (!Array.isArray(B[0])) { 11 | // B is vector, convert to [[a], [b], [c], ...]] 12 | B = B.map(x => [x]); 13 | } 14 | 15 | let p = B[0].length; 16 | let B_cols = B[0].map((_, i) => B.map(x => x[i])); // transpose B 17 | let product = A.map(row => B_cols.map(col => { 18 | if (!Array.isArray(row)) { 19 | return col.reduce((a, c) => a + c * row, 0); 20 | } 21 | 22 | return row.reduce((a, c, i) => a + c * (col[i] || 0), 0); 23 | })); 24 | 25 | if (m === 1) { 26 | product = product[0]; // Avoid [[a, b, c, ...]] 27 | } 28 | 29 | if (p === 1) { 30 | return product.map(x => x[0]); // Avoid [[a], [b], [c], ...]] 31 | } 32 | 33 | return product; 34 | } 35 | -------------------------------------------------------------------------------- /src/angles.js: -------------------------------------------------------------------------------- 1 | export const range = [0, 360]; 2 | range.isAngle = true; 3 | 4 | export function constrain (angle) { 5 | return ((angle % 360) + 360) % 360; 6 | } 7 | 8 | export function adjust (arc, angles) { 9 | if (arc === "raw") { 10 | return angles; 11 | } 12 | 13 | let [a1, a2] = angles.map(constrain); 14 | 15 | let angleDiff = a2 - a1; 16 | 17 | if (arc === "increasing") { 18 | if (angleDiff < 0) { 19 | a2 += 360; 20 | } 21 | } 22 | else if (arc === "decreasing") { 23 | if (angleDiff > 0) { 24 | a1 += 360; 25 | } 26 | } 27 | else if (arc === "longer") { 28 | if (-180 < angleDiff && angleDiff < 180) { 29 | if (angleDiff > 0) { 30 | a2 += 360; 31 | } 32 | else { 33 | a1 += 360; 34 | } 35 | } 36 | } 37 | else if (arc === "shorter") { 38 | if (angleDiff > 180) { 39 | a1 += 360; 40 | } 41 | else if (angleDiff < -180) { 42 | a2 += 360; 43 | } 44 | } 45 | 46 | return [a1, a2]; 47 | } 48 | -------------------------------------------------------------------------------- /docs/index.tpl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Color.js Docs 7 | 8 | @@include('_head.html') 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | @@include('_header.html', { 17 | "title": "Color.js Docs" 18 | }) 19 |
    20 | 21 | 26 | 27 | 37 | 38 |
    39 | 40 | @@include('_footer.html') 41 | 42 | 43 | -------------------------------------------------------------------------------- /get/style.src.css: -------------------------------------------------------------------------------- 1 | [mv-app="colorBundler"] { 2 | display: grid; 3 | grid-template-columns: 1fr 1fr; 4 | grid-gap: .6em 1em; 5 | 6 | & #core, 7 | & #spaces, 8 | & #optional, 9 | & > pre, 10 | & > button, 11 | & a[download] { 12 | grid-column-end: span 2; 13 | } 14 | } 15 | 16 | fieldset { 17 | & ul { 18 | list-style: none; 19 | 20 | & li { 21 | position: relative; 22 | break-inside: avoid; 23 | margin-bottom: .5em; 24 | 25 | & input[type=checkbox], 26 | & input[type=radio] { 27 | position: absolute; 28 | right: 100%; 29 | margin: .8em; 30 | } 31 | } 32 | } 33 | 34 | & .description { 35 | margin: 0; 36 | font-size: 75%; 37 | } 38 | } 39 | 40 | #spaces ul { 41 | columns: 20em 2; 42 | } 43 | 44 | a[download] { 45 | display: block; 46 | padding: .5em; 47 | border-radius: .2em; 48 | color: white; 49 | text-align: center; 50 | font-weight: 900; 51 | background: var(--rainbow); 52 | animation: var(--rainbow-scroll); 53 | 54 | &:hover { 55 | text-decoration: none; 56 | background: var(--color-green); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /apps/convert/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font: 120%/1.5 Helvetica Neue, Helvetica, Segoe UI, sans-serif; 3 | width: 90vw; 4 | max-width: fit-content; 5 | margin: 1em auto; 6 | padding: 0 2em; 7 | } 8 | 9 | input { 10 | font: inherit; 11 | font-size: 150%; 12 | width: 100%; 13 | padding: 0 .2em; 14 | box-sizing: border-box; 15 | } 16 | 17 | .inputs { 18 | display: grid; 19 | grid-template-columns: 1fr auto; 20 | grid-gap: 1em; 21 | margin: 3em 0; 22 | } 23 | 24 | td { 25 | padding: .3em .5em; 26 | font-family: Consolas, Monaco, monospace; 27 | } 28 | 29 | td:nth-child(3):hover, 30 | td:nth-child(4):hover { 31 | cursor: pointer; 32 | text-decoration: underline; 33 | } 34 | 35 | tr:nth-child(even) { 36 | background: rgba(0,0,0,.05); 37 | } 38 | 39 | #colorOutput { 40 | position: absolute; 41 | top: 0; left: 0; right: 0; 42 | width: 100%; 43 | height: 2em; 44 | --red-stripe-stops: transparent calc(50% - .05em), red 0 calc(50% + .05em), transparent 0; 45 | --error-background: linear-gradient(to bottom right, var(--red-stripe-stops)), linear-gradient(to top right, var(--red-stripe-stops)) gray; 46 | } 47 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // Import all modules of Color.js 2 | import Color, {util} from "./color.js"; 3 | 4 | // Import color spaces 5 | import "./spaces/lab.js"; 6 | import "./spaces/lch.js"; 7 | import "./spaces/srgb.js"; 8 | import "./spaces/srgb-linear.js"; 9 | import "./spaces/hsl.js"; 10 | import "./spaces/hwb.js"; 11 | import "./spaces/hsv.js"; 12 | import "./spaces/p3.js"; 13 | import "./spaces/a98rgb.js"; 14 | import "./spaces/prophoto.js"; 15 | import "./spaces/rec2020.js"; 16 | import "./spaces/absxyzd65.js"; 17 | import "./spaces/jzazbz.js"; 18 | import "./spaces/jzczhz.js"; 19 | import "./spaces/ictcp.js"; 20 | import "./spaces/rec2100.js"; 21 | import "./spaces/oklab.js"; 22 | import "./spaces/oklch.js"; 23 | import "./spaces/acescc.js"; 24 | 25 | 26 | // Import optional modules 27 | import "./interpolation.js"; 28 | import "./deltaE/deltaECMC.js"; 29 | import "./deltaE/deltaE2000.js"; 30 | import "./deltaE/deltaEJz.js"; 31 | import "./deltaE/deltaEITP.js"; 32 | import "./deltaE/deltaEOK.js"; 33 | import "./CATs.js"; 34 | import "./keywords.js"; 35 | 36 | // Re-export everything 37 | export default Color; 38 | export {util}; 39 | -------------------------------------------------------------------------------- /src/spaces/absxyzd65.js: -------------------------------------------------------------------------------- 1 | import Color, {util} from "./../color.js"; 2 | 3 | Color.defineSpace({ 4 | // Absolute CIE XYZ, with a D65 whitepoint, 5 | // as used in most HDR colorspaces as a starting point. 6 | // SDR spaces are converted per BT.2048 7 | // so that diffuse, media white is 203 cd/m² 8 | id: "absxyzd65", 9 | name: "Absolute XYZ D65", 10 | coords: { 11 | Xa: [0, 9504.7], 12 | Ya: [0, 10000], 13 | Za: [0, 10888.3] 14 | }, 15 | white: Color.whites.D65, 16 | Yw: 203, // absolute luminance of media white 17 | inGamut: coords => true, 18 | fromXYZ (XYZ) { 19 | 20 | const {Yw} = this; 21 | 22 | // Make XYZ absolute, not relative to media white 23 | // Maximum luminance in PQ is 10,000 cd/m² 24 | // Relative XYZ has Y=1 for media white 25 | 26 | return XYZ.map (function (val) { 27 | return Math.max(val * Yw, 0); 28 | }); 29 | }, 30 | toXYZ (AbsXYZ) { 31 | 32 | // Convert to media-white relative XYZ 33 | 34 | const {Yw} = this; 35 | 36 | let XYZ = AbsXYZ.map (function (val) { 37 | return Math.max(val / Yw, 0); 38 | }); 39 | 40 | return XYZ; 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Lea Verou, Chris Lilley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /get/style.css: -------------------------------------------------------------------------------- 1 | [mv-app="colorBundler"] { 2 | display: grid; 3 | grid-template-columns: 1fr 1fr; 4 | grid-gap: .6em 1em 5 | } 6 | 7 | [mv-app="colorBundler"] #core, 8 | [mv-app="colorBundler"] #spaces, 9 | [mv-app="colorBundler"] #optional, 10 | [mv-app="colorBundler"] > pre, 11 | [mv-app="colorBundler"] > button, 12 | [mv-app="colorBundler"] a[download] { 13 | grid-column-end: span 2; 14 | } 15 | 16 | fieldset ul { 17 | list-style: none 18 | } 19 | 20 | fieldset ul li { 21 | position: relative; 22 | break-inside: avoid; 23 | margin-bottom: .5em 24 | } 25 | 26 | fieldset ul li input[type=checkbox], 27 | fieldset ul li input[type=radio] { 28 | position: absolute; 29 | right: 100%; 30 | margin: .8em; 31 | } 32 | 33 | fieldset .description { 34 | margin: 0; 35 | font-size: 75%; 36 | } 37 | 38 | #spaces ul { 39 | columns: 20em 2; 40 | } 41 | 42 | a[download] { 43 | display: block; 44 | padding: .5em; 45 | border-radius: .2em; 46 | color: white; 47 | text-align: center; 48 | font-weight: 900; 49 | background: var(--rainbow); 50 | animation: var(--rainbow-scroll) 51 | } 52 | 53 | a[download]:hover { 54 | text-decoration: none; 55 | background: var(--color-green); 56 | } 57 | -------------------------------------------------------------------------------- /tests/hooks.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Color modification tests 7 | 8 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 19 | 20 |

    Color modification Tests

    21 |

    These tests modify one or more coordinates and check the result.

    22 | 23 |
    24 |

    sRGB to LCH

    25 | 26 | 27 | 40 | 41 | 42 |
    28 | 39 | { "spaceId": "srgb", "coords": [ 1, 0.5, 0.5 ], "alpha": 1 }
    43 |
    44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/spaces/a98rgb.js: -------------------------------------------------------------------------------- 1 | import Color from "./srgb.js"; 2 | 3 | Color.defineSpace({ 4 | inherits: "srgb", 5 | id: "a98rgb", 6 | name: "Adobe 98 RGB compatible", 7 | cssId: "a98-rgb", 8 | toLinear(RGB) { 9 | return RGB.map(val => Math.pow(Math.abs(val), 563/256)*Math.sign(val)); 10 | }, 11 | toGamma(RGB) { 12 | return RGB.map(val => Math.pow(Math.abs(val), 256/563)*Math.sign(val)); 13 | }, 14 | // convert an array of linear-light a98-rgb values to CIE XYZ 15 | // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html 16 | // has greater numerical precision than section 4.3.5.3 of 17 | // https://www.adobe.com/digitalimag/pdfs/AdobeRGB1998.pdf 18 | // but the values below were calculated from first principles 19 | // from the chromaticity coordinates of R G B W 20 | toXYZ_M: [ 21 | [ 0.5766690429101305, 0.1855582379065463, 0.1882286462349947 ], 22 | [ 0.29734497525053605, 0.6273635662554661, 0.07529145849399788 ], 23 | [ 0.02703136138641234, 0.07068885253582723, 0.9913375368376388 ] 24 | ], 25 | fromXYZ_M: [ 26 | [ 2.0415879038107465, -0.5650069742788596, -0.34473135077832956 ], 27 | [ -0.9692436362808795, 1.8759675015077202, 0.04155505740717557 ], 28 | [ 0.013444280632031142, -0.11836239223101838, 1.0151749943912054 ] 29 | ] 30 | }); 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "colorjs.io", 3 | "version": "0.0.5", 4 | "description": "Color space agnostic color manipulation library", 5 | "files": [ 6 | "dist/color.cjs.js", 7 | "dist/color.cjs.js.map", 8 | "dist/color.esm.js", 9 | "dist/color.esm.js.map" 10 | ], 11 | "exports": { 12 | "import": "./dist/color.esm.js", 13 | "require": "./dist/color.cjs.js" 14 | }, 15 | "main": "./dist/color.cjs.js", 16 | "module": "./dist/color.esm.js", 17 | "directories": { 18 | "test": "tests" 19 | }, 20 | "scripts": { 21 | "test": "open tests/", 22 | "build": "gulp" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/LeaVerou/color.js.git" 27 | }, 28 | "keywords": [ 29 | "color" 30 | ], 31 | "author": "Lea Verou, Chris Lilley", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/LeaVerou/color.js/issues" 35 | }, 36 | "homepage": "https://colorjs.io", 37 | "devDependencies": { 38 | "babel-eslint": "^10.1.0", 39 | "eslint": "^7.2.0", 40 | "gulp": "^4.0.2", 41 | "gulp-file-include": "^2.2.2", 42 | "gulp-postcss": "^8.0.0", 43 | "gulp-rename": "^2.0.0", 44 | "postcss-nesting": "^7.0.1", 45 | "rollup": "^2.10.5", 46 | "rollup-plugin-terser": "^5.3.0", 47 | "showdown": "^1.9.1" 48 | }, 49 | "dependencies": { 50 | "acorn": "^7.3.1" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/deltaE/deltaEJz.js: -------------------------------------------------------------------------------- 1 | import Color from "../spaces/jzazbz.js"; 2 | import "../spaces/jzczhz.js"; 3 | 4 | // More accurate color-difference formulae 5 | // than the simple 1976 Euclidean distance in Lab 6 | 7 | // Uses JzCzHz, which has improved perceptual uniformity 8 | // and thus a simple Euclidean root-sum of ΔL² ΔC² ΔH² 9 | // gives good results. 10 | 11 | Color.prototype.deltaEJz = function (sample) { 12 | let color = this; 13 | sample = Color.get(sample); 14 | 15 | // Given this color as the reference 16 | // and a sample, 17 | // calculate deltaE in JzCzHz. 18 | 19 | let [Jz1, Cz1, Hz1] = color.jzczhz; 20 | let [Jz2, Cz2, Hz2] = sample.jzczhz; 21 | 22 | // Lightness and Chroma differences 23 | // sign does not matter as they are squared. 24 | let ΔJ = Jz1 - Jz2; 25 | let ΔC = Cz1 - Cz2; 26 | 27 | // length of chord for ΔH 28 | if ((Number.isNaN(Hz1)) && (Number.isNaN(Hz2))) { 29 | // both undefined hues 30 | Hz1 = 0; 31 | Hz2 = 0; 32 | } else 33 | if (Number.isNaN(Hz1)) { 34 | // one undefined, set to the defined hue 35 | Hz1 = Hz2; 36 | } else 37 | if (Number.isNaN(Hz2)) { 38 | Hz2 = Hz1; 39 | } 40 | 41 | let Δh = Hz1 - Hz2; 42 | let ΔH = 2 * Math.sqrt(Cz1 * Cz2) * Math.sin((Δh / 2) * (Math.PI / 180)); 43 | 44 | return Math.sqrt(ΔJ ** 2 + ΔC ** 2 + ΔH ** 2); 45 | }; 46 | 47 | Color.statify(["deltaEJz"]); 48 | -------------------------------------------------------------------------------- /src/spaces/hsv.js: -------------------------------------------------------------------------------- 1 | import Color, {angles} from "./hsl.js"; 2 | 3 | // The Hue, Whiteness Blackness (HWB) colorspace 4 | // See https://drafts.csswg.org/css-color-4/#the-hwb-notation 5 | // Note that, like HSL, calculations are done directly on 6 | // gamma-corrected sRGB values rather than linearising them first. 7 | 8 | Color.defineSpace({ 9 | id: "hsv", 10 | name: "HSV", 11 | coords: { 12 | hue: angles.range, 13 | saturation: [0, 100], 14 | value: [0, 100] 15 | }, 16 | inGamut (coords, epsilon) { 17 | let hsl = this.to.hsl(coords); 18 | return Color.spaces.hsl.inGamut(hsl, {epsilon: epsilon}); 19 | }, 20 | white: Color.whites.D65, 21 | 22 | from: { 23 | // https://en.wikipedia.org/wiki/HSL_and_HSV#Interconversion 24 | hsl (hsl) { 25 | let [h, s, l] = hsl; 26 | s /= 100; 27 | l /= 100; 28 | 29 | let v = l + s * Math.min(l, 1 - l); 30 | 31 | return [ 32 | h, // h is the same 33 | v === 0? 0 : 200 * (1 - l / v), // s 34 | 100 * v 35 | ]; 36 | }, 37 | }, 38 | 39 | to: { 40 | // https://en.wikipedia.org/wiki/HSL_and_HSV#Interconversion 41 | hsl (hsv) { 42 | let [h, s, v] = hsv; 43 | 44 | s /= 100; 45 | v /= 100; 46 | 47 | let l = v * (1 - s/2); 48 | 49 | return [ 50 | h, // h is the same 51 | (l === 0 || l === 1)? 0 : ((v - l) / Math.min(l, 1 - l)) * 100, 52 | l * 100 53 | ]; 54 | } 55 | } 56 | }); 57 | -------------------------------------------------------------------------------- /src/spaces/prophoto.js: -------------------------------------------------------------------------------- 1 | import Color from "./srgb.js"; 2 | 3 | Color.defineSpace({ 4 | inherits: "srgb", 5 | id: "prophoto", 6 | name: "ProPhoto", 7 | cssId: "prophoto-rgb", 8 | white: Color.whites.D50, 9 | toLinear(RGB) { 10 | // Transfer curve is gamma 1.8 with a small linear portion 11 | const Et2 = 16/512; 12 | return RGB.map(function (val) { 13 | if (val < Et2) { 14 | return val / 16; 15 | } 16 | 17 | return Math.pow(val, 1.8); 18 | }); 19 | }, 20 | toGamma(RGB) { 21 | const Et = 1/512; 22 | return RGB.map(function (val) { 23 | if (val >= Et) { 24 | return Math.pow(val, 1/1.8); 25 | } 26 | 27 | return 16 * val; 28 | }); 29 | }, 30 | // convert an array of prophoto-rgb values to CIE XYZ 31 | // using D50 (so no chromatic adaptation needed afterwards) 32 | // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html 33 | toXYZ_M: [ 34 | [ 0.7977604896723027, 0.13518583717574031, 0.0313493495815248 ], 35 | [ 0.2880711282292934, 0.7118432178101014, 0.00008565396060525902 ], 36 | [ 0.0, 0.0, 0.8251046025104601 ] 37 | ], 38 | fromXYZ_M: [ 39 | [ 1.3457989731028281, -0.25558010007997534, -0.05110628506753401 ], 40 | [ -0.5446224939028347, 1.5082327413132781, 0.02053603239147973 ], 41 | [ 0.0, 0.0, 1.2119675456389454 ] 42 | ] 43 | }); 44 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Color.js Tests 7 | 8 | 9 | 10 | 11 | 12 | 13 |
    14 |
    15 |

    Color.js Tests

    16 | 17 |
    18 | 26 | 27 |
    28 |
    29 |
    30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /assets/css/home.src.css: -------------------------------------------------------------------------------- 1 | body > header { 2 | padding-bottom: 1rem; 3 | } 4 | 5 | .logo { 6 | margin: 1em auto; 7 | width: fit-content; 8 | display: grid; 9 | grid-gap: .3em 0; 10 | grid-template-columns: minmax(1em, auto) 1fr; 11 | font-size: 200%; 12 | 13 | & h1 { 14 | margin: 0; 15 | color: hsl(var(--gray) 48%); 16 | font-family: var(--font-sans); 17 | font-weight: 700; 18 | letter-spacing: -.03em; 19 | } 20 | 21 | & h2 { 22 | grid-row: 2; 23 | margin: 0; 24 | font-style: italic; 25 | font-size: 52%; 26 | font-weight: 800; 27 | color: hsl(220, 10%, 30%); 28 | } 29 | 30 | & img { 31 | grid-row: 1 / span 2; 32 | height: 3em; 33 | } 34 | } 35 | 36 | #features { 37 | display: grid; 38 | grid-template-columns: 1fr; 39 | grid-gap: 1.5rem 0; 40 | align-items: start; 41 | } 42 | 43 | @media (min-width: 800px) { 44 | #features { 45 | grid-template-columns: 1fr 1fr; 46 | } 47 | } 48 | 49 | @media (max-width: 800px) { 50 | #features { 51 | padding: 1rem; 52 | } 53 | } 54 | 55 | #features article { 56 | display: grid; 57 | grid-template-columns: auto 1fr; 58 | grid-gap: .1em .5em; 59 | } 60 | 61 | #features article::before { 62 | content: "✓"; 63 | color: var(--color-green); 64 | font-size: 300%; 65 | line-height: .8; 66 | grid-row: 1 / span 2; 67 | } 68 | 69 | #features h4 { 70 | margin: 0; 71 | } 72 | 73 | #features p { 74 | margin: 0; 75 | font-size: 75%; 76 | } 77 | -------------------------------------------------------------------------------- /assets/css/home.css: -------------------------------------------------------------------------------- 1 | body > header { 2 | padding-bottom: 1rem; 3 | } 4 | 5 | .logo { 6 | margin: 1em auto; 7 | width: fit-content; 8 | display: grid; 9 | grid-gap: .3em 0; 10 | grid-template-columns: minmax(1em, auto) 1fr; 11 | font-size: 200% 12 | } 13 | 14 | .logo h1 { 15 | margin: 0; 16 | color: hsl(var(--gray) 48%); 17 | font-family: var(--font-sans); 18 | font-weight: 700; 19 | letter-spacing: -.03em; 20 | } 21 | 22 | .logo h2 { 23 | grid-row: 2; 24 | margin: 0; 25 | font-style: italic; 26 | font-size: 52%; 27 | font-weight: 800; 28 | color: hsl(220, 10%, 30%); 29 | } 30 | 31 | .logo img { 32 | grid-row: 1 / span 2; 33 | height: 3em; 34 | } 35 | 36 | #features { 37 | display: grid; 38 | grid-template-columns: 1fr; 39 | grid-gap: 1.5rem 0; 40 | align-items: start; 41 | } 42 | 43 | @media (min-width: 800px) { 44 | #features { 45 | grid-template-columns: 1fr 1fr; 46 | } 47 | } 48 | 49 | @media (max-width: 800px) { 50 | #features { 51 | padding: 1rem; 52 | } 53 | } 54 | 55 | #features article { 56 | display: grid; 57 | grid-template-columns: auto 1fr; 58 | grid-gap: .1em .5em; 59 | } 60 | 61 | #features article::before { 62 | content: "✓"; 63 | color: var(--color-green); 64 | font-size: 300%; 65 | line-height: .8; 66 | grid-row: 1 / span 2; 67 | } 68 | 69 | #features h4 { 70 | margin: 0; 71 | } 72 | 73 | #features p { 74 | margin: 0; 75 | font-size: 75%; 76 | } 77 | -------------------------------------------------------------------------------- /src/spaces/jzczhz.js: -------------------------------------------------------------------------------- 1 | import Color from "./../color.js"; 2 | import "./jzazbz.js"; 3 | import * as angles from "../angles.js"; 4 | 5 | Color.defineSpace({ 6 | id: "jzczhz", 7 | name: "JzCzHz", 8 | coords: { 9 | Jz: [0, 1], 10 | chroma: [0, 1], 11 | hue: angles.range, 12 | }, 13 | inGamut: coords => true, 14 | white: Color.whites.D65, 15 | from: { 16 | jzazbz (jzazbz) { 17 | // Convert to polar form 18 | let [Jz, az, bz] = jzazbz; 19 | let hue; 20 | const ε = 0.0002; // chromatic components much smaller than a,b 21 | 22 | if (Math.abs(az) < ε && Math.abs(bz) < ε) { 23 | hue = NaN; 24 | } 25 | else { 26 | hue = Math.atan2(bz, az) * 180 / Math.PI; 27 | } 28 | 29 | return [ 30 | Jz, // Jz is still Jz 31 | Math.sqrt(az ** 2 + bz ** 2), // Chroma 32 | angles.constrain(hue) // Hue, in degrees [0 to 360) 33 | ]; 34 | } 35 | }, 36 | to: { 37 | jzazbz (jzczhz) { 38 | // Convert from polar form 39 | // debugger; 40 | return [ 41 | jzczhz[0], // Jz is still Jz 42 | jzczhz[1] * Math.cos(jzczhz[2] * Math.PI / 180), // az 43 | jzczhz[1] * Math.sin(jzczhz[2] * Math.PI / 180) // bz 44 | ]; 45 | } 46 | }, 47 | parse (str, parsed = Color.parseFunction(str)) { 48 | if (parsed && parsed.name === "jzczhz") { 49 | let Jz = parsed.args[0]; 50 | 51 | return { 52 | spaceId: "jzczhz", 53 | coords: parsed.args.slice(0, 3), 54 | alpha: parsed.args.slice(3)[0] 55 | }; 56 | } 57 | }, 58 | 59 | }); 60 | 61 | export default Color; 62 | export {angles}; 63 | -------------------------------------------------------------------------------- /src/spaces/rec2020.js: -------------------------------------------------------------------------------- 1 | import Color, {util} from "./srgb.js"; 2 | 3 | Color.defineSpace({ 4 | inherits: "srgb", 5 | id: "rec2020", 6 | name: "REC.2020", 7 | α: 1.09929682680944, 8 | β: 0.018053968510807, 9 | // Non-linear transfer function from Rec. ITU-R BT.2020-2 table 4 10 | toLinear(RGB) { 11 | const {α, β} = this; 12 | 13 | return RGB.map(function (val) { 14 | if (val < β * 4.5 ) { 15 | return val / 4.5; 16 | } 17 | 18 | return Math.pow((val + α -1 ) / α, 1/0.45); 19 | }); 20 | }, 21 | toGamma(RGB) { 22 | const {α, β} = this; 23 | 24 | return RGB.map(function (val) { 25 | if (val >= β ) { 26 | return α * Math.pow(val, 0.45) - (α - 1); 27 | } 28 | 29 | return 4.5 * val; 30 | }); 31 | }, 32 | // convert an array of linear-light rec2020 values to CIE XYZ 33 | // using D65 (no chromatic adaptation) 34 | // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html 35 | // 0 is actually calculated as 4.994106574466076e-17 36 | toXYZ_M: [ 37 | [ 0.6369580483012914, 0.14461690358620832, 0.1688809751641721 ], 38 | [ 0.2627002120112671, 0.6779980715188708, 0.05930171646986196 ], 39 | [ 0.000000000000000, 0.028072693049087428, 1.060985057710791 ] 40 | ], 41 | // from ITU-R BT.2124-0 Annex 2 p.3 42 | fromXYZ_M: [ 43 | [ 1.716651187971268, -0.355670783776392, -0.253366281373660 ], 44 | [ -0.666684351832489, 1.616481236634939, 0.0157685458139111 ], 45 | [ 0.017639857445311, -0.042770613257809, 0.942103121235474 ] 46 | ] 47 | }); 48 | 49 | export default Color; 50 | export {util}; 51 | -------------------------------------------------------------------------------- /src/spaces/oklch.js: -------------------------------------------------------------------------------- 1 | import Color, {util} from "./../color.js"; 2 | import "./oklab.js"; 3 | import * as angles from "../angles.js"; 4 | 5 | Color.defineSpace({ 6 | id: "oklch", 7 | name: "OKLCh", 8 | coords: { 9 | lightness: [0, 1], 10 | chroma: [0, 1], 11 | hue: angles.range, 12 | }, 13 | inGamut: coords => true, 14 | white: Color.whites.D65, 15 | from: { 16 | oklab (oklab) { 17 | // Convert to polar form 18 | let [L, a, b] = oklab; 19 | let h; 20 | const ε = 0.0002; // chromatic components much smaller than a,b 21 | 22 | if (Math.abs(a) < ε && Math.abs(b) < ε) { 23 | h = NaN; 24 | } 25 | else { 26 | h = Math.atan2(b, a) * 180 / Math.PI; 27 | } 28 | 29 | return [ 30 | L, // OKLab L is still L 31 | Math.sqrt(a ** 2 + b ** 2), // Chroma 32 | angles.constrain(h) // Hue, in degrees [0 to 360) 33 | ]; 34 | } 35 | }, 36 | to: { 37 | // Convert from polar form 38 | oklab (oklch) { 39 | let [L, C, h] = oklch; 40 | let a, b; 41 | 42 | // check for NaN hue 43 | if (isNaN(h)) { 44 | a = 0; 45 | b = 0; 46 | } 47 | else { 48 | a = C * Math.cos(h * Math.PI / 180); 49 | b = C * Math.sin(h * Math.PI / 180); 50 | } 51 | 52 | return [ L, a, b ]; 53 | } 54 | }, 55 | parse (str, parsed = Color.parseFunction(str)) { 56 | if (parsed && parsed.name === "oklch") { 57 | let L = parsed.args[0]; 58 | 59 | return { 60 | spaceId: "oklch", 61 | coords: parsed.args.slice(0, 3), 62 | alpha: parsed.args.slice(3)[0] 63 | }; 64 | } 65 | }, 66 | 67 | }); 68 | 69 | export default Color; 70 | export {angles}; 71 | -------------------------------------------------------------------------------- /apps/gradients/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: hsl(152.32 0% 46.63%); /* lab(50% 0 0) */ 3 | font: 120%/1.5 Helvetica Neue, Helvetica, Segoe UI, sans-serif; 4 | width: fit-content; 5 | width: 50em; 6 | max-width: 90vw; 7 | margin: 1em auto; 8 | color: white; 9 | } 10 | 11 | input { 12 | font: inherit; 13 | box-sizing: border-box; 14 | } 15 | 16 | .inputs input[type="text"] { 17 | width: 100%; 18 | border: 5px solid var(--color); 19 | border-right-width: 2em; 20 | background: hsla(0 0% 100% / .5); 21 | color: black; 22 | font-size: 150%; 23 | padding: 0 .2em; 24 | } 25 | 26 | .inputs label { 27 | display: block; 28 | } 29 | 30 | #output { 31 | margin-top: 1em; 32 | } 33 | 34 | [property="gradient"] { 35 | background: var(--gradient); 36 | padding: 2em; 37 | color: black; 38 | } 39 | 40 | [property="gradient"] details { 41 | padding: .5em; 42 | background: hsl(0 0% 100% / .5); 43 | } 44 | 45 | [property="gradient"] details input, 46 | [property="gradient"] details textarea { 47 | background: hsl(0 0% 100% / .5); 48 | border: 1px solid white; 49 | } 50 | 51 | input[type=number] { 52 | width: 4em; 53 | padding: 0 .2em; 54 | font-weight: bold; 55 | } 56 | 57 | textarea { 58 | display: block; 59 | margin-top: .5em; 60 | width: 100%; 61 | font: 80%/1.4 Consolas, Monaco, monospace; 62 | height: 7em; 63 | } 64 | 65 | [property="step"] { 66 | padding: .4em; 67 | background: var(--color); 68 | font-weight: bold; 69 | font-size: 90%; 70 | } 71 | 72 | [property="step"] code { 73 | font-family: Consolas, Monaco, monospace; 74 | } 75 | 76 | meta[property][mv-mode="edit"] { 77 | display: none; 78 | } 79 | -------------------------------------------------------------------------------- /notebook/repl.js: -------------------------------------------------------------------------------- 1 | import Notebook, {initAll} from "./color-notebook.js"; 2 | import extensions from "../assets/js/showdown-extensions.mjs"; 3 | 4 | let container = $("[property=content]"); 5 | 6 | document.addEventListener("mv-markdown-render", function(evt) { 7 | container.dirty = false; 8 | 9 | requestAnimationFrame(() => { 10 | initAll(evt.target); 11 | }); 12 | }); 13 | 14 | function updateMarkdown() { 15 | // Update code snippets with actual contents 16 | let node = Mavo.all.colorNotebook.root.children.content; 17 | let value = node.value; 18 | // This approach will fail when a) we have duplicate code in multiple snippets 19 | // b) when we have empty code areas 20 | 21 | for (let notebook of Notebook.all) { 22 | if (notebook?.edited) { 23 | value = value.replace("```js\n" + notebook.initialCode + "\n```", "```js\n" + notebook.code + "\n```"); 24 | } 25 | 26 | notebook.destroy(); 27 | } 28 | 29 | if (node.value !== value 30 | && confirm("You have edited the code snippets, do you want to transfer these changes to your Markdown?")) { 31 | node.value = value; 32 | } 33 | } 34 | 35 | Mavo.hooks.add("save-start", function() { 36 | if (this.id === "colorNotebook") { 37 | updateMarkdown(); 38 | } 39 | }); 40 | 41 | 42 | let editObserver = new Mavo.Observer(container, "mv-mode", () => { 43 | if (container.getAttribute("mv-mode") === "edit") { 44 | updateMarkdown(); 45 | } 46 | }); 47 | 48 | (async () => { 49 | 50 | await Mavo.ready; 51 | 52 | for (let id in extensions) { 53 | showdown.extension(id, () => [ 54 | extensions[id] 55 | ]); 56 | } 57 | 58 | let defaultOptions = Mavo.Plugins.loaded.markdown.defaultOptions; 59 | 60 | defaultOptions.extensions = defaultOptions.extensions || []; 61 | defaultOptions.extensions.push("apiLinks", "callouts"); 62 | 63 | })(); 64 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | ## Getting up and running with build tools 4 | 5 | We use gulp for processing CSS and HTML for the website, and Rollup.js for bundling Color.js. 6 | 7 | 1. [Install npm](https://www.npmjs.com/get-npm) if you don't already have it 8 | 2. `cd` to the project directory and run `npm install` to install local modules 9 | 3. Done! Now run `npx gulp` to build. 10 | 11 | Run `npx gulp watch` before you start working on the website to have gulp run automatically as you edit files. 12 | 13 | Or, for individual tasks: 14 | 15 | - `gulp md` to convert Markdown files to HTML pages 16 | - `gulp css` to process PostCSS files (`*.src.css` in our repo) 17 | - `gulp bundle` to create Color.js bundles in `dist/` 18 | 19 | ## Commit messages 20 | 21 | - If working on a color space, please prefix your commit with `[spaces/SPACE_ID]` 22 | - If working on a demo app, please prefix your commit with `[apps/APP_ID]` 23 | - If working on a module other than color.js, please prefix your commit with `[modulename]` e.g. `[interpolation]` 24 | 25 | ## Code style 26 | 27 | Please install an ESLint plugin for your editor. There is an `.eslintrc.json` file in the repo which encodes most of the coding style of the project. 28 | 29 | Here are a few other guideliines that cannot be enforced via ESLint: 30 | 31 | - When you define a function, use a space between the opening paren and its name. That way we can search for "functionName (" and find its definition immediately, instead of having to wade through calls to the function. 32 | - Prefer single-word names over multi-word names. 3+ word names are especially frowned upon. 33 | - camelCase over underscore_case, with few exceptions. 34 | - Don't be afraid of unicode characters when appropriate, except on user-facing names. E.g. use ε over epsilon internally, but not ΔΕ over deltaE in the public-facing method name. 35 | -------------------------------------------------------------------------------- /src/spaces/lch.js: -------------------------------------------------------------------------------- 1 | import Color from "./../color.js"; 2 | import "./lab.js"; 3 | import * as angles from "../angles.js"; 4 | 5 | Color.defineSpace({ 6 | id: "lch", 7 | name: "LCH", 8 | coords: { 9 | lightness: [0, 100], 10 | chroma: [0, 150], 11 | hue: angles.range, 12 | }, 13 | inGamut: coords => true, 14 | white: Color.whites.D50, 15 | from: { 16 | lab (Lab) { 17 | // Convert to polar form 18 | let [L, a, b] = Lab; 19 | let hue; 20 | const ε = 0.02; 21 | 22 | if (Math.abs(a) < ε && Math.abs(b) < ε) { 23 | hue = NaN; 24 | } 25 | else { 26 | hue = Math.atan2(b, a) * 180 / Math.PI; 27 | } 28 | 29 | return [ 30 | L, // L is still L 31 | Math.sqrt(a ** 2 + b ** 2), // Chroma 32 | angles.constrain(hue) // Hue, in degrees [0 to 360) 33 | ]; 34 | } 35 | }, 36 | to: { 37 | lab (LCH) { 38 | // Convert from polar form 39 | let [Lightness, Chroma, Hue] = LCH; 40 | // Clamp any negative Chroma 41 | if (Chroma < 0) { 42 | Chroma = 0; 43 | }; 44 | // Deal with NaN Hue 45 | if (isNaN(Hue)) { 46 | Hue = 0; 47 | } 48 | return [ 49 | Lightness, // L is still L 50 | Chroma * Math.cos(Hue * Math.PI / 180), // a 51 | Chroma * Math.sin(Hue * Math.PI / 180) // b 52 | ]; 53 | } 54 | }, 55 | parse (str, parsed = Color.parseFunction(str)) { 56 | if (parsed && parsed.name === "lch") { 57 | let L = parsed.args[0]; 58 | 59 | // Percentages in lch() don't translate to a 0-1 range, but a 0-100 range 60 | if (L.percentage) { 61 | parsed.args[0] = L * 100; 62 | } 63 | 64 | return { 65 | spaceId: "lch", 66 | coords: parsed.args.slice(0, 3), 67 | alpha: parsed.args.slice(3)[0] 68 | }; 69 | } 70 | }, 71 | instance: { 72 | toString ({format, ...rest} = {}) { 73 | if (!format) { 74 | format = (c, i) => i === 0? c + "%" : c; 75 | } 76 | 77 | return Color.prototype.toString.call(this, {name: "lch", format, ...rest}); 78 | } 79 | } 80 | }); 81 | -------------------------------------------------------------------------------- /assets/css/docs.src.css: -------------------------------------------------------------------------------- 1 | main { 2 | position: relative; 3 | } 4 | 5 | #toc { 6 | font-size: 75%; 7 | 8 | & > ul { 9 | padding: 0; 10 | list-style: none; 11 | } 12 | 13 | @media (min-width: 1480px) { 14 | position: fixed; 15 | top: 11rem; 16 | right: calc(var(--page-width) + var(--page-margin) + 1em); 17 | width: fit-content; 18 | max-width: calc(var(--page-margin) - 1em); 19 | margin-left: 1em; 20 | 21 | @supports (top: max(1em, 1px)) { 22 | top: max(0em, 11rem - var(--scrolltop) * 1px); 23 | } 24 | } 25 | 26 | 27 | @media (max-width: 1480px), (max-height: 30rem) { 28 | /* Hide all but next, current, prev */ 29 | & > ul { 30 | & > li:not(.next):not(.current):not(.previous) { 31 | display: none; 32 | } 33 | } 34 | } 35 | 36 | & ul ul { 37 | list-style: square 38 | } 39 | 40 | & .previous, 41 | & .current, 42 | & .next { 43 | &[aria-label]::before { 44 | content: attr(aria-label) ": "; 45 | font-size: 85%; 46 | font-weight: 600; 47 | opacity: .5; 48 | text-transform: uppercase; 49 | } 50 | } 51 | } 52 | 53 | [mv-app="colorSpaces"] { 54 | & [property="space"] { 55 | display: grid; 56 | grid-template-columns: 1fr auto; 57 | grid-gap: 0 1em; 58 | margin: 1em 0; 59 | 60 | & > * { 61 | grid-column: 1; 62 | } 63 | 64 | & > header { 65 | grid-column: 1 / span 2; 66 | display: flex; 67 | align-items: center; 68 | 69 | & h2 { 70 | margin: 0 auto 0 0; 71 | } 72 | 73 | & .file { 74 | font-style: italic; 75 | opacity: .6; 76 | } 77 | } 78 | 79 | & [property="description"] { 80 | margin: .5em 0; 81 | } 82 | 83 | & dl { 84 | min-width: 10em; 85 | margin: 0; 86 | grid-row: 2 / span 3; 87 | grid-column: 2; 88 | background: hsl(var(--gray) 95%); 89 | border-radius: .2em; 90 | padding: 1em; 91 | 92 | & dt { 93 | margin-top: .5em; 94 | font-size: 80%; 95 | } 96 | 97 | & dd { 98 | grid-column: 1; 99 | } 100 | } 101 | } 102 | 103 | 104 | } 105 | -------------------------------------------------------------------------------- /src/spaces/rec2100.js: -------------------------------------------------------------------------------- 1 | import Color from "./rec2020.js"; 2 | 3 | Color.defineSpace({ 4 | inherits: "rec2020", 5 | id: "rec2100pq", 6 | cssid: "rec2100-pq", 7 | name: "REC.2100-PQ", 8 | Yw: 203, // absolute luminance of media white, cd/m² 9 | n: 2610 / (2 ** 14), 10 | ninv: (2 ** 14) / 2610, 11 | m: 2523 / (2 ** 5), 12 | minv: (2 ** 5) / 2523, 13 | c1: 3424 / (2 ** 12), 14 | c2: 2413 / (2 ** 7), 15 | c3: 2392 / (2 ** 7), 16 | toLinear(RGB) { 17 | // given PQ encoded component in range [0, 1] 18 | // return media-white relative linear-light 19 | 20 | const {Yw, ninv, minv, c1, c2, c3} = this; 21 | 22 | return RGB.map(function (val) { 23 | let x = ((Math.max(((val ** minv) - c1), 0) / (c2 - (c3 * (val ** minv)))) ** ninv); 24 | return (x * 10000 / Yw); // luminance relative to diffuse white, [0, 70 or so]. 25 | }); 26 | }, 27 | toGamma(RGB) { 28 | // given media-white relative linear-light 29 | // returnPQ encoded component in range [0, 1] 30 | 31 | const {Yw, n, m, c1, c2, c3} = this; 32 | 33 | return RGB.map(function (val) { 34 | let x = Math.max(val * Yw / 10000, 0); // absolute luminance of peak white is 10,000 cd/m². 35 | let num = (c1 + (c2 * (x ** n))); 36 | let denom = (1 + (c3 * (x ** n))); 37 | // console.log({x, num, denom}); 38 | return ((num / denom) ** m); 39 | }); 40 | } 41 | // , 42 | // // convert an array of linear-light rec2120 values to CIE XYZ 43 | // // using D65 (no chromatic adaptation) 44 | // // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html 45 | // // 0 is actually calculated as 4.994106574466076e-17 46 | // toXYZ_M: [ 47 | // [0.6369580483012914, 0.14461690358620832, 0.1688809751641721], 48 | // [0.2627002120112671, 0.6779980715188708, 0.05930171646986196], 49 | // [0.000000000000000, 0.028072693049087428, 1.060985057710791] 50 | // ], 51 | // fromXYZ_M: [ 52 | // [1.7166511879712674, -0.35567078377639233, -0.25336628137365974], 53 | // [-0.6666843518324892, 1.6164812366349395, 0.01576854581391113], 54 | // [0.017639857445310783, -0.042770613257808524, 0.9421031212354738] 55 | // ] 56 | }); 57 | -------------------------------------------------------------------------------- /docs/spaces.tpl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Supported color spaces • Color.js 7 | 8 | @@include('_head.html') 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | @@include('_header.html', { 17 | "title": "Supported color spaces" 18 | }) 19 |
    20 | 21 | 26 | 27 |
    28 | 29 |
    30 |
    31 |

    32 | 33 |
    src/spaces/.js
    34 |
    35 | 36 |
    37 | 38 |
    39 |
    White point:
    40 |
    41 | 42 |
    Coordinates:
    43 |
    44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 61 | 62 | 63 | 64 |
    NameRef. range
    55 | 56 | – 57 | 58 | 59 | 60 |
    65 |
    66 |
    67 | 68 |
    let color = new Color("{{ space.id }}", [{{randomCoord}}]);
    69 | {{ join(codeExample, "\n") }}
    70 | color.toString();
    71 | 72 | Learn more about [name] 73 |
    74 | 75 |
    76 | 77 |
    78 | 79 | @@include('_footer.html') 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /assets/js/index.js: -------------------------------------------------------------------------------- 1 | let $ = Bliss; 2 | let $$ = $.$; 3 | 4 | import {} from "../../notebook/color-notebook.js"; 5 | 6 | if (location.pathname.indexOf("/docs/") > -1 && window.toc) { 7 | import("./docs.js"); 8 | } 9 | 10 | let root = document.documentElement; 11 | let colors = { 12 | red: new Color("--color-red"), 13 | green: new Color("--color-green"), 14 | blue: new Color("--color-blue") 15 | }; 16 | 17 | let supportsP3 = window.CSS && CSS.supports("color", "color(display-p3 0 1 0)"); 18 | let interpolationOptions = {steps: 5, space: "lch", outputSpace: supportsP3? "p3" : "hsl"}; 19 | 20 | if (!Color.DEBUGGING) { 21 | let redGreen = colors.red.range(colors.green, interpolationOptions); 22 | let greenBlue = colors.green.range(colors.blue, interpolationOptions); 23 | let blueRed = colors.blue.range(colors.red, interpolationOptions); 24 | 25 | let vars = { 26 | "gradient-steps": [ 27 | ...Color.steps(redGreen, interpolationOptions), 28 | ...Color.steps(greenBlue, interpolationOptions), 29 | ...Color.steps(blueRed, interpolationOptions) 30 | ], 31 | "color-red-light": colors.red.clone().set({lightness: 80}), 32 | "color-green-light": colors.green.clone().set({lightness: 80}), 33 | "color-blue-light": colors.blue.clone().set({lightness: 80}), 34 | 35 | "color-red-lighter": colors.red.clone().set({lightness: 94}), 36 | "color-green-lighter": colors.green.clone().set({lightness: 95}), 37 | "color-blue-lighter": colors.blue.clone().set({lightness: 94}), 38 | 39 | "color-red-green": redGreen(.5), 40 | "color-green-blue": greenBlue(.5), 41 | "color-blue-red": blueRed(.5), 42 | 43 | "color-red-green-light": redGreen(.5).set({lightness: 94}), 44 | "color-green-blue-light": greenBlue(.5).set({lightness: 94}), 45 | "color-blue-red-light": blueRed(.5).set({lightness: 94}), 46 | }; 47 | 48 | $.create("style", { 49 | inside: document.head, 50 | textContent: `:root { 51 | ${Object.entries(vars).map(pair => `--${pair[0]}: ${pair[1]}`).join(";\n")}; 52 | --scrolltop: ${root.scrollTop}; 53 | }` 54 | }); 55 | } 56 | 57 | document.addEventListener("scroll", evt => { 58 | root.style.setProperty("--scrolltop", root.scrollTop); 59 | }, {passive: true}); 60 | -------------------------------------------------------------------------------- /src/spaces/oklab.js: -------------------------------------------------------------------------------- 1 | import Color, {util} from "./../color.js"; 2 | 3 | Color.defineSpace({ 4 | id: "oklab", 5 | cssid: "oklab", 6 | name: "OKLab", 7 | coords: { 8 | L: [ 0, 1], 9 | a: [-0.5, 0.5], 10 | b: [-0.5, 0.5] 11 | }, 12 | inGamut: coords => true, 13 | // Note that XYZ is relative to D65 14 | white: Color.whites.D65, 15 | // Recalculated for consistent reference white 16 | // see https://github.com/w3c/csswg-drafts/issues/6642#issuecomment-943521484 17 | XYZtoLMS_M: [ 18 | [ 0.8190224432164319, 0.3619062562801221, -0.12887378261216414 ], 19 | [ 0.0329836671980271, 0.9292868468965546, 0.03614466816999844 ], 20 | [ 0.048177199566046255, 0.26423952494422764, 0.6335478258136937 ] 21 | ], 22 | // inverse of XYZtoLMS_M 23 | LMStoXYZ_M: [ 24 | [ 1.2268798733741557, -0.5578149965554813, 0.28139105017721583], 25 | [ -0.04057576262431372, 1.1122868293970594, -0.07171106666151701], 26 | [ -0.07637294974672142, -0.4214933239627914, 1.5869240244272418 ] 27 | ], 28 | LMStoLab_M: [ 29 | [ 0.2104542553, 0.7936177850, -0.0040720468 ], 30 | [ 1.9779984951, -2.4285922050, 0.4505937099 ], 31 | [ 0.0259040371, 0.7827717662, -0.8086757660 ] 32 | ], 33 | // LMStoIab_M inverted 34 | LabtoLMS_M: [ 35 | [ 0.99999999845051981432, 0.39633779217376785678, 0.21580375806075880339 ], 36 | [ 1.0000000088817607767, -0.1055613423236563494, -0.063854174771705903402 ], 37 | [ 1.0000000546724109177, -0.089484182094965759684, -1.2914855378640917399 ] 38 | ], 39 | fromXYZ (XYZ) { 40 | const {XYZtoLMS_M, LMStoLab_M} = this; 41 | 42 | // move to LMS cone domain 43 | let LMS = util.multiplyMatrices(XYZtoLMS_M, XYZ); 44 | 45 | // non-linearity 46 | let LMSg = LMS.map (val => Math.cbrt(val)); 47 | 48 | return (util.multiplyMatrices(LMStoLab_M, LMSg)); 49 | 50 | }, 51 | toXYZ (OKLab) { 52 | 53 | const {LMStoXYZ_M, LabtoLMS_M} = this; 54 | 55 | // move to LMS cone domain 56 | let LMSg = util.multiplyMatrices(LabtoLMS_M, OKLab); 57 | 58 | // restore linearity 59 | let LMS = LMSg.map (val => val ** 3); 60 | 61 | return (util.multiplyMatrices(LMStoXYZ_M, LMS)); 62 | } 63 | }); 64 | 65 | 66 | export default Color; 67 | export {util}; 68 | -------------------------------------------------------------------------------- /assets/css/docs.css: -------------------------------------------------------------------------------- 1 | main { 2 | position: relative; 3 | } 4 | 5 | #toc { 6 | font-size: 75% 7 | } 8 | 9 | #toc > ul { 10 | padding: 0; 11 | list-style: none; 12 | } 13 | 14 | @media (min-width: 1480px) { 15 | 16 | #toc { 17 | position: fixed; 18 | top: 11rem; 19 | right: calc(var(--page-width) + var(--page-margin) + 1em); 20 | width: fit-content; 21 | max-width: calc(var(--page-margin) - 1em); 22 | margin-left: 1em 23 | } 24 | 25 | @supports (top: max(1em, 1px)) { 26 | 27 | #toc { 28 | top: max(0em, 11rem - var(--scrolltop) * 1px) 29 | } 30 | } 31 | } 32 | 33 | @media (max-width: 1480px), (max-height: 30rem) { 34 | 35 | #toc { 36 | /* Hide all but next, current, prev */ 37 | } 38 | #toc > ul > li:not(.next):not(.current):not(.previous) { 39 | display: none; 40 | } 41 | } 42 | 43 | #toc ul ul { 44 | list-style: square 45 | } 46 | 47 | #toc .previous[aria-label]::before, #toc .current[aria-label]::before, #toc .next[aria-label]::before { 48 | content: attr(aria-label) ": "; 49 | font-size: 85%; 50 | font-weight: 600; 51 | opacity: .5; 52 | text-transform: uppercase; 53 | } 54 | 55 | [mv-app="colorSpaces"] [property="space"] { 56 | display: grid; 57 | grid-template-columns: 1fr auto; 58 | grid-gap: 0 1em; 59 | margin: 1em 0 60 | } 61 | 62 | [mv-app="colorSpaces"] [property="space"] > * { 63 | grid-column: 1; 64 | } 65 | 66 | [mv-app="colorSpaces"] [property="space"] > header { 67 | grid-column: 1 / span 2; 68 | display: flex; 69 | align-items: center 70 | } 71 | 72 | [mv-app="colorSpaces"] [property="space"] > header h2 { 73 | margin: 0 auto 0 0; 74 | } 75 | 76 | [mv-app="colorSpaces"] [property="space"] > header .file { 77 | font-style: italic; 78 | opacity: .6; 79 | } 80 | 81 | [mv-app="colorSpaces"] [property="space"] [property="description"] { 82 | margin: .5em 0; 83 | } 84 | 85 | [mv-app="colorSpaces"] [property="space"] dl { 86 | min-width: 10em; 87 | margin: 0; 88 | grid-row: 2 / span 3; 89 | grid-column: 2; 90 | background: hsl(var(--gray) 95%); 91 | border-radius: .2em; 92 | padding: 1em 93 | } 94 | 95 | [mv-app="colorSpaces"] [property="space"] dl dt { 96 | margin-top: .5em; 97 | font-size: 80%; 98 | } 99 | 100 | [mv-app="colorSpaces"] [property="space"] dl dd { 101 | grid-column: 1; 102 | } 103 | -------------------------------------------------------------------------------- /src/spaces/hsl.js: -------------------------------------------------------------------------------- 1 | import Color, {util} from "./srgb.js"; 2 | import * as angles from "../angles.js"; 3 | 4 | Color.defineSpace({ 5 | id: "hsl", 6 | name: "HSL", 7 | coords: { 8 | hue: angles.range, 9 | saturation: [0, 100], 10 | lightness: [0, 100] 11 | }, 12 | inGamut (coords, epsilon) { 13 | let rgb = this.to.srgb(coords); 14 | return Color.inGamut("srgb", rgb, {epsilon: epsilon}); 15 | }, 16 | white: Color.whites.D65, 17 | 18 | // Adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB 19 | from: { 20 | srgb (rgb) { 21 | let max = Math.max(...rgb); 22 | let min = Math.min(...rgb); 23 | let [r, g, b] = rgb; 24 | let [h, s, l] = [NaN, 0, (min + max)/2]; 25 | let d = max - min; 26 | 27 | if (d !== 0) { 28 | s = (l === 0 || l === 1) ? 0 : (max - l) / Math.min(l, 1 - l); 29 | 30 | switch (max) { 31 | case r: h = (g - b) / d + (g < b ? 6 : 0); break; 32 | case g: h = (b - r) / d + 2; break; 33 | case b: h = (r - g) / d + 4; 34 | } 35 | 36 | h = h * 60; 37 | } 38 | 39 | return [h, s * 100, l * 100]; 40 | } 41 | }, 42 | // Adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative 43 | to: { 44 | srgb (hsl) { 45 | let [h, s, l] = hsl; 46 | h = h % 360; 47 | 48 | if (h < 0) { 49 | h += 360; 50 | } 51 | 52 | s /= 100; 53 | l /= 100; 54 | 55 | function f(n) { 56 | let k = (n + h/30) % 12; 57 | let a = s * Math.min(l, 1 - l); 58 | return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); 59 | } 60 | 61 | return [f(0), f(8), f(4)]; 62 | } 63 | }, 64 | 65 | parse (str, parsed = Color.parseFunction(str)) { 66 | if (parsed && /^hsla?$/.test(parsed.name)) { 67 | let hsl = parsed.args; 68 | 69 | // percentages are converted to [0, 1] by parseFunction 70 | hsl[1] *= 100; 71 | hsl[2] *= 100; 72 | 73 | return { 74 | spaceId: "hsl", 75 | coords: hsl.slice(0, 3), 76 | alpha: hsl[3] 77 | }; 78 | } 79 | }, 80 | 81 | instance: { 82 | toString ({precision, commas, format, inGamut, ...rest} = {}) { 83 | if (!format) { 84 | format = (c, i) => i > 0? c + "%" : c; 85 | } 86 | 87 | return Color.prototype.toString.call(this, { 88 | inGamut: true, // hsl() out of gamut makes no sense 89 | commas, format, 90 | name: "hsl" + (commas && this.alpha < 1? "a" : ""), 91 | ...rest 92 | }); 93 | } 94 | } 95 | }); 96 | 97 | export default Color; 98 | export {util, angles}; 99 | -------------------------------------------------------------------------------- /src/spaces/lab.js: -------------------------------------------------------------------------------- 1 | import Color from "./../color.js"; 2 | 3 | Color.defineSpace({ 4 | id: "lab", 5 | name: "Lab", 6 | coords: { 7 | L: [0, 100], 8 | a: [-100, 100], 9 | b: [-100, 100] 10 | }, 11 | inGamut: coords => true, 12 | // Assuming XYZ is relative to D50, convert to CIE Lab 13 | // from CIE standard, which now defines these as a rational fraction 14 | white: Color.whites.D50, 15 | ε: 216/24389, // 6^3/29^3 == (24/116)^3 16 | ε3: 24/116, 17 | κ: 24389/27, // 29^3/3^3 18 | // κ * ε = 2^3 = 8 19 | fromXYZ(XYZ) { 20 | // Convert D50-adapted XYX to Lab 21 | // CIE 15.3:2004 section 8.2.1.1 22 | const {κ, ε, white} = this; 23 | 24 | // compute xyz, which is XYZ scaled relative to reference white 25 | let xyz = XYZ.map((value, i) => value / white[i]); 26 | 27 | // now compute f 28 | let f = xyz.map(value => value > ε ? Math.cbrt(value) : (κ * value + 16)/116); 29 | 30 | return [ 31 | (116 * f[1]) - 16, // L 32 | 500 * (f[0] - f[1]), // a 33 | 200 * (f[1] - f[2]) // b 34 | ]; 35 | }, 36 | toXYZ(Lab) { 37 | // Convert Lab to D50-adapted XYZ 38 | // Same result as CIE 15.3:2004 Appendix D although the derivation is different 39 | // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html 40 | const {κ, ε3, white} = this; 41 | 42 | // compute f, starting with the luminance-related term 43 | let f = []; 44 | f[1] = (Lab[0] + 16)/116; 45 | f[0] = Lab[1]/500 + f[1]; 46 | f[2] = f[1] - Lab[2]/200; 47 | 48 | // compute xyz 49 | var xyz = [ 50 | f[0] > ε3 ? Math.pow(f[0], 3) : (116*f[0]-16)/κ, 51 | Lab[0] > 8 ? Math.pow((Lab[0]+16)/116, 3) : Lab[0]/κ, 52 | f[2] > ε3 ? Math.pow(f[2], 3) : (116*f[2]-16)/κ 53 | ]; 54 | 55 | // Compute XYZ by scaling xyz by reference white 56 | return xyz.map((value, i) => value * white[i]); 57 | }, 58 | parse (str, parsed = Color.parseFunction(str)) { 59 | if (parsed && parsed.name === "lab") { 60 | let L = parsed.args[0]; 61 | 62 | // Percentages in lab() don't translate to a 0-1 range, but a 0-100 range 63 | if (L.percentage) { 64 | parsed.args[0] = L * 100; 65 | } 66 | 67 | return { 68 | spaceId: "lab", 69 | coords: parsed.args.slice(0, 3), 70 | alpha: parsed.args.slice(3)[0] 71 | }; 72 | } 73 | }, 74 | instance: { 75 | toString ({format, ...rest} = {}) { 76 | if (!format) { 77 | format = (c, i) => i === 0? c + "%" : c; 78 | } 79 | 80 | return Color.prototype.toString.call(this, {name: "lab", format, ...rest}); 81 | } 82 | } 83 | }); 84 | -------------------------------------------------------------------------------- /apps/convert/convert.js: -------------------------------------------------------------------------------- 1 | const favicon = document.querySelector('link[rel="shortcut icon"]'); 2 | const supportsP3 = window.CSS && CSS.supports("color", "color(display-p3 0 1 0)"); 3 | 4 | function getURLParams() { 5 | return Object.fromEntries(new URL(location).searchParams); 6 | } 7 | 8 | function update() { 9 | try { 10 | var color = new Color(colorInput.value); 11 | colorInput.setCustomValidity(""); 12 | 13 | let oldParams = getURLParams(); 14 | let newParams = [ 15 | ["color", colorInput.value], 16 | ["precision", precisionInput.value || "0"] 17 | ]; 18 | 19 | let changed = ![...new URL(location).searchParams].every((pair, i) => { 20 | let [key, value] = pair; 21 | let [newKey, newValue] = newParams[i]; 22 | 23 | return newValue && newValue.indexOf(value) === 0; 24 | }); 25 | 26 | let title = newParams[0][1] + " convert"; 27 | let query = newParams.map(pair => `${pair[0]}=${encodeURIComponent(pair[1])}`).join("&"); 28 | history[(changed? "push" : "replace") + "State"](null, title, "?" + query); 29 | document.title = title; 30 | } 31 | catch (e) { 32 | if (e.message.indexOf("Cannot parse") > -1) { 33 | colorInput.setCustomValidity(e); 34 | colorOutput.style.background = "var(--error-background)"; 35 | return; 36 | } 37 | else { 38 | throw e; 39 | } 40 | } 41 | 42 | if (color) { 43 | output.tBodies[0].textContent = ""; 44 | let ret = ""; 45 | 46 | for (let id in Color.spaces) { 47 | let converted = color.to(id); 48 | let space = Color.spaces[id]; 49 | 50 | if (id === "srgb" || (id === "p3") && supportsP3) { 51 | colorOutput.style.background = converted; 52 | favicon.href = `data:image/svg+xml,` 53 | } 54 | 55 | let precision = precisionInput.value; 56 | 57 | ret += ` 58 | ${space.name} 59 | ${converted.coords.join(", ")} 60 | ${converted.toString({precision})} 61 | ${Color.prototype.toString.call(converted, {precision})} 62 | `; 63 | } 64 | 65 | output.tBodies[0].innerHTML = ret; 66 | } 67 | }; 68 | 69 | let urlParams = getURLParams(); 70 | 71 | colorInput.addEventListener("input", update); 72 | precisionInput.addEventListener("input", update); 73 | 74 | function updateFromURL() { 75 | colorInput.value = urlParams.color || colorInput.value; 76 | precisionInput.value = urlParams.precision || precisionInput.value; 77 | update(); 78 | } 79 | 80 | updateFromURL(); 81 | 82 | addEventListener("popstate", updateFromURL); 83 | 84 | document.body.addEventListener("click", evt => { 85 | if (evt.target.matches("td:nth-child(3), td:nth-child(4)")) { 86 | // Color cell 87 | colorInput.value = evt.target.textContent; 88 | update(); 89 | } 90 | }) 91 | -------------------------------------------------------------------------------- /notebook/color-notebook.src.css: -------------------------------------------------------------------------------- 1 | .cn-wrapper { 2 | position: relative; 3 | display: flex; 4 | --transparency: transparent; 5 | 6 | @supports (background: conic-gradient(red, white)) { 7 | --transparency: repeating-conic-gradient(transparent 0 25%, rgb(0 0 0 / .05) 0 50%) 0 0 / 1.5em 1.5em content-box border-box; 8 | } 9 | 10 | & > pre, 11 | & > .prism-live { 12 | flex: 1; 13 | width: 0; 14 | } 15 | 16 | & .cn-results:empty { 17 | display: none; 18 | } 19 | 20 | & .cn-results:not(:empty) { 21 | display: flex; 22 | flex-flow: column; 23 | margin-left: .5em; 24 | padding-top: .5em; 25 | font: bold 65%/1 var(--font-monospace, monospace); 26 | 27 | & > * { 28 | margin-bottom: .5em; 29 | min-width: 10rem; 30 | max-width: 15rem; 31 | } 32 | } 33 | 34 | @media (max-width: 900px) { 35 | & .cn-results:not(:empty), 36 | & > pre, 37 | & > .prism-live { 38 | transition: .4s width; 39 | } 40 | 41 | & .cn-results:not(:empty) { 42 | width: 2em; 43 | overflow: hidden; 44 | 45 | &:hover, 46 | &:focus, 47 | &:focus-within, 48 | &:active { 49 | width: auto; 50 | overflow: visible; 51 | } 52 | } 53 | } 54 | 55 | & .cn-value { 56 | cursor: pointer; 57 | } 58 | 59 | & .cn-string, 60 | & .cn-number, 61 | & .cn-boolean, 62 | & .cn-error { 63 | padding: .3em 0; 64 | line-height: 1.2; 65 | } 66 | 67 | & .cn-error { 68 | color: var(--color-red); 69 | cursor: help; 70 | 71 | &::before { 72 | content: "⚠️"; 73 | margin-right: .1em; 74 | filter: invert() brightness(2) saturate(.7) hue-rotate(130deg); 75 | } 76 | } 77 | 78 | & .cn-color, 79 | & .cn-range { 80 | padding: .4em; 81 | border-radius: .2em; 82 | transition: .3s .15s transform; 83 | transform-origin: right; 84 | 85 | &:hover { 86 | z-index: 1; 87 | transform: scale(2); 88 | } 89 | } 90 | 91 | & .cn-range { 92 | height: 1em; 93 | background: linear-gradient(to right, var(--stops, transparent, transparent)), var(--transparency); 94 | } 95 | 96 | & .cn-color { 97 | position: relative; 98 | background: linear-gradient(var(--color), var(--color)), var(--transparency); 99 | white-space: nowrap; 100 | 101 | &.dark { 102 | color: white; 103 | } 104 | 105 | &.out-of-gamut { 106 | box-shadow: 0 0 .1em .02em var(--out-of-gamut-color, red); 107 | 108 | &.light { 109 | --out-of-gamut-color: #b00; 110 | } 111 | } 112 | } 113 | 114 | & .cn-array { 115 | & > * { 116 | display: inline-block; 117 | } 118 | 119 | & > .cn-color:hover { 120 | transform: scale(4); 121 | } 122 | } 123 | 124 | } 125 | 126 | [data-varname] { 127 | background: var(--color); 128 | box-shadow: 0 0 0 .1em var(--color); 129 | border-radius: .01em; 130 | } 131 | 132 | [data-varname].dark { 133 | color: white; 134 | text-shadow: none; 135 | } 136 | -------------------------------------------------------------------------------- /src/spaces/acescc.js: -------------------------------------------------------------------------------- 1 | import Color from "../color.js"; 2 | import "../CATs.js"; 3 | // because of the funky whitepoint 4 | 5 | Color.defineSpace({ 6 | id: "acescc", 7 | name: "ACEScc", 8 | inherits: "srgb", 9 | 10 | // see S-2014-003 ACEScc – A Logarithmic Encoding of ACES Data 11 | // uses the AP1 primaries, see section 4.3.1 Color primaries 12 | coords: { 13 | red: [-0.3014, 1.468], 14 | green: [-0.3014, 1.468], 15 | blue: [-0.3014, 1.468] 16 | }, 17 | // Appendix A: "Very small ACES scene referred values below 7 1/4 stops 18 | // below 18% middle gray are encoded as negative ACEScc values. 19 | // These values should be preserved per the encoding in Section 4.4 20 | // so that all positive ACES values are maintained." 21 | 22 | // The ACES whitepoint 23 | // see TB-2018-001 Derivation of the ACES White Point CIE Chromaticity Coordinates 24 | // also https://github.com/ampas/aces-dev/blob/master/documents/python/TB-2018-001/aces_wp.py 25 | white: Color.whites.ACES = [0.32168/0.33767, 1.00000, (1.00000 - 0.32168 - 0.33767)/0.33767], 26 | // Similar to D60 27 | 28 | // from section 4.4.2 Decoding Function 29 | toLinear(RGB) { 30 | 31 | const low = (9.72 - 15) / 17.52; // -0.3014 32 | const high = (Math.log2(65504) + 9.72) / 17.52; // 1.468 33 | const ε = 2 ** -16; 34 | 35 | return RGB.map(function (val) { 36 | if (val <= low) { 37 | return (2 ** ((val * 17.52) - 9.72) - ε) * 2; // 0 for low or below 38 | } 39 | else if (val < high) { 40 | return 2 ** ((val * 17.52) - 9.72); 41 | } 42 | else { // val >= high 43 | return 65504; 44 | } 45 | }); 46 | }, 47 | 48 | // Non-linear encoding function from S-2014-003, section 4.4.1 Encoding Function 49 | toGamma(RGB) { 50 | 51 | const ε = 2 ** -16; 52 | 53 | return RGB.map(function (val) { 54 | if (val <= 0) { 55 | return (Math.log2(ε) + 9.72) / 17.52; // -0.3584 56 | } 57 | else if (val < ε) { 58 | return (Math.log2(ε + val * 0.5) + 9.72) / 17.52; 59 | } 60 | else { // val >= ε 61 | return (Math.log2(val) + 9.72) / 17.52; 62 | } 63 | }); 64 | }, 65 | // encoded media white (rgb 1,1,1) => linear [ 222.861, 222.861, 222.861 ] 66 | // encoded media black (rgb 0,0,0) => linear [ 0.0011857, 0.0011857, 0.0011857] 67 | 68 | // convert an array of linear-light ACEScc values to CIE XYZ 69 | toXYZ_M: [ 70 | [ 0.6624541811085053, 0.13400420645643313, 0.1561876870049078 ], 71 | [ 0.27222871678091454, 0.6740817658111484, 0.05368951740793705 ], 72 | [ -0.005574649490394108, 0.004060733528982826, 1.0103391003129971 ] 73 | ], 74 | // 75 | fromXYZ_M: [ 76 | [ 1.6410233796943257, -0.32480329418479, -0.23642469523761225 ], 77 | [ -0.6636628587229829, 1.6153315916573379, 0.016756347685530137 ], 78 | [ 0.011721894328375376, -0.008284441996237409, 0.9883948585390215 ] 79 | ] 80 | }); 81 | 82 | // export default Color; 83 | -------------------------------------------------------------------------------- /assets/css/prism.css: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.20.0 2 | https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript&plugins=keep-markup+normalize-whitespace */ 3 | /** 4 | * prism.js default theme for JavaScript, CSS and HTML 5 | * Based on dabblet (http://dabblet.com) 6 | * @author Lea Verou 7 | */ 8 | 9 | code[class*="language-"], 10 | pre[class*="language-"] { 11 | color: black; 12 | background: none; 13 | text-shadow: 0 1px white; 14 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 15 | font-size: 1em; 16 | text-align: left; 17 | white-space: pre; 18 | word-spacing: normal; 19 | word-break: normal; 20 | word-wrap: normal; 21 | line-height: 1.5; 22 | 23 | -moz-tab-size: 4; 24 | -o-tab-size: 4; 25 | tab-size: 4; 26 | 27 | -webkit-hyphens: none; 28 | -moz-hyphens: none; 29 | -ms-hyphens: none; 30 | hyphens: none; 31 | } 32 | 33 | pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection, 34 | code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection { 35 | text-shadow: none; 36 | background: #b3d4fc; 37 | } 38 | 39 | pre[class*="language-"]::selection, pre[class*="language-"] ::selection, 40 | code[class*="language-"]::selection, code[class*="language-"] ::selection { 41 | text-shadow: none; 42 | background: #b3d4fc; 43 | } 44 | 45 | @media print { 46 | code[class*="language-"], 47 | pre[class*="language-"] { 48 | text-shadow: none; 49 | } 50 | } 51 | 52 | /* Code blocks */ 53 | pre[class*="language-"] { 54 | padding: 1em; 55 | margin: .5em 0; 56 | overflow: auto; 57 | } 58 | 59 | :not(pre) > code[class*="language-"], 60 | pre[class*="language-"] { 61 | background: #f5f2f0; 62 | } 63 | 64 | /* Inline code */ 65 | :not(pre) > code[class*="language-"] { 66 | padding: .1em; 67 | border-radius: .3em; 68 | white-space: normal; 69 | } 70 | 71 | .token.comment, 72 | .token.prolog, 73 | .token.doctype, 74 | .token.cdata { 75 | color: slategray; 76 | } 77 | 78 | .token.punctuation { 79 | color: #999; 80 | } 81 | 82 | .token.namespace { 83 | opacity: .7; 84 | } 85 | 86 | .token.property, 87 | .token.tag, 88 | .token.boolean, 89 | .token.number, 90 | .token.constant, 91 | .token.symbol, 92 | .token.deleted { 93 | color: var(--color-red); 94 | } 95 | 96 | .token.selector, 97 | .token.attr-name, 98 | .token.string, 99 | .token.char, 100 | .token.builtin, 101 | .token.inserted { 102 | color: var(--color-green); 103 | } 104 | 105 | .token.operator, 106 | .token.entity, 107 | .token.url, 108 | .language-css .token.string, 109 | .style .token.string { 110 | color: #9a6e3a; 111 | background: hsla(0, 0%, 100%, .5); 112 | } 113 | 114 | .token.atrule, 115 | .token.attr-value, 116 | .token.keyword { 117 | color: var(--color-blue); 118 | } 119 | 120 | .token.function, 121 | .token.class-name { 122 | color: var(--color-blue-red, var(--color-red)); 123 | } 124 | 125 | .token.regex, 126 | .token.important, 127 | .token.variable { 128 | color: #e90; 129 | } 130 | 131 | .token.important, 132 | .token.bold { 133 | font-weight: bold; 134 | } 135 | .token.italic { 136 | font-style: italic; 137 | } 138 | 139 | .token.entity { 140 | cursor: help; 141 | } 142 | -------------------------------------------------------------------------------- /src/spaces/hwb.js: -------------------------------------------------------------------------------- 1 | import Color, {angles} from "./hsl.js"; 2 | 3 | // The Hue, Whiteness Blackness (HWB) colorspace 4 | // See https://drafts.csswg.org/css-color-4/#the-hwb-notation 5 | // Note that, like HSL, calculations are done directly on 6 | // gamma-corrected sRGB values rather than linearising them first. 7 | 8 | Color.defineSpace({ 9 | id: "hwb", 10 | name: "HWB", 11 | coords: { 12 | hue: angles.range, 13 | whiteness: [0, 100], 14 | blackness: [0, 100] 15 | }, 16 | inGamut (coords, epsilon) { 17 | let rgb = this.to.srgb(coords); 18 | return Color.inGamut("srgb", rgb, {epsilon: epsilon}); 19 | }, 20 | white: Color.whites.D65, 21 | 22 | from: { 23 | srgb (rgb) { 24 | let hsl = Color.spaces.hsl.from.srgb(rgb); 25 | let h = hsl[0]; 26 | // calculate white and black 27 | let w = Math.min(...rgb); 28 | let b = 1 - Math.max(...rgb); 29 | w *= 100; 30 | b *= 100; 31 | return [h, w, b]; 32 | }, 33 | 34 | hsv (hsv) { 35 | let [h, s, v] = hsv; 36 | 37 | return [h, v * (100 - s) / 100, 100 - v]; 38 | }, 39 | 40 | hsl (hsl) { 41 | let hsv = Color.spaces.hsv.from.hsl(hsl); 42 | return this.hsv(hsv); 43 | } 44 | }, 45 | 46 | to: { 47 | srgb (hwb) { 48 | let [h, w, b] = hwb; 49 | 50 | // Now convert percentages to [0..1] 51 | w /= 100; 52 | b /= 100; 53 | 54 | // Achromatic check (white plus black >= 1) 55 | let sum = w + b; 56 | if (sum >= 1) { 57 | let gray = w / sum; 58 | return [gray, gray, gray]; 59 | } 60 | 61 | // From https://drafts.csswg.org/css-color-4/#hwb-to-rgb 62 | let rgb = Color.spaces.hsl.to.srgb([h, 100, 50]); 63 | for (var i = 0; i < 3; i++) { 64 | rgb[i] *= (1 - w - b); 65 | rgb[i] += w; 66 | } 67 | return rgb; 68 | }, 69 | 70 | hsv (hwb) { 71 | let [h, w, b] = hwb; 72 | 73 | // Now convert percentages to [0..1] 74 | w /= 100; 75 | b /= 100; 76 | 77 | // Achromatic check (white plus black >= 1) 78 | let sum = w + b; 79 | if (sum >= 1) { 80 | let gray = w / sum; 81 | return [h, 0, gray * 100]; 82 | } 83 | 84 | let v = (1 - b); 85 | let s = (v === 0) ? 0 : 1 - w / v; 86 | return [h, s * 100, v * 100]; 87 | }, 88 | 89 | hsl (hwb) { 90 | let hsv = Color.spaces.hwb.to.hsv(hwb); 91 | return (Color.spaces.hsv.to.hsl(hsv)); 92 | } 93 | }, 94 | 95 | parse (str, parsed = Color.parseFunction(str)) { 96 | if (parsed && /^hwba?$/.test(parsed.name)) { 97 | let hwb = parsed.args; 98 | 99 | // white and black percentages are converted to [0, 1] by parseFunction 100 | hwb[1] *= 100; 101 | hwb[2] *= 100; 102 | 103 | return { 104 | spaceId: "hwb", 105 | coords: hwb.slice(0, 3), 106 | alpha: hwb[3] 107 | }; 108 | } 109 | }, 110 | 111 | instance: { 112 | toString ({format, commas, inGamut, ...rest} = {}) { 113 | if (!format) { 114 | format = (c, i) => i > 0? c + "%" : c; 115 | } 116 | 117 | return Color.prototype.toString.call(this, { 118 | inGamut: true, // hwb() out of gamut makes no sense 119 | commas: false, // never commas 120 | format, 121 | name: "hwb", 122 | ...rest 123 | }); 124 | } 125 | } 126 | }); 127 | -------------------------------------------------------------------------------- /tests/contrast.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Contrast tests 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 28 | 29 | 30 | 31 | 32 |

    Contrast Tests

    33 | 34 |
    35 |

    sRGB

    36 | 37 | 38 | 39 | 40 | 43 | 44 | 45 | 46 | 47 | 48 | 51 | 52 | 53 | 54 | 55 | 56 | 59 | 60 | 61 | 62 | 63 | 64 | 67 | 68 | 69 | 70 | 71 | 72 | 75 | 76 | 77 | 78 | 79 | 80 | 83 | 84 | 85 |
    whiteblack 41 | 42 | 21
    whitewhite 49 | 50 | 1
    #ffewhite 57 | 58 | 1.010070
    #afbaaewhite 65 | 66 | 2.008125
    #8b9986white 73 | 74 | 3.000644
    #765white 81 | 82 | 5.502984
    86 |
    87 | 88 |
    89 |

    Display P3

    90 | 91 | 92 | 93 | 94 | 95 | 98 | 99 | 100 |
    color(display-p3 0.555354 0.5982801 0.5316741)white 96 | 97 | 3.000644
    101 |
    102 | 103 |
    104 |

    LCH

    105 | 106 | 107 | 108 | 109 | 112 | 113 | 114 |
    lch(50% 0 0)white 110 | 111 | 4.4836
    115 |
    116 | 117 |
    118 |

    With NaN

    119 | 120 | 121 | 122 | 123 | 126 | 127 | 128 |
    lch(50% 0 NaN)white 124 | 125 | 4.4836
    129 |
    130 | 131 | 132 | 133 | 134 | -------------------------------------------------------------------------------- /tests/construct.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Constructor tests 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |

    Constructor Tests

    18 |

    These tests check that the various constructor signatures work.

    19 | 20 |
    21 |

    Basic constructors

    22 | 23 | 24 | 32 | 35 | 36 | 37 | 45 | 48 | 49 | 50 | 58 | 61 | 62 | 63 | 71 | 74 | 75 | 76 | 84 | 91 | 92 | 93 | 101 | 104 | 105 | 106 | 114 | 117 | 118 |
    25 | 31 | 33 | { "spaceId": "p3", "coords": [ 0, 1, 0 ], "alpha": 1 } 34 |
    38 | 44 | 46 | { "spaceId": "srgb", "coords": [ 0, 1, 0 ], "alpha": 1 } 47 |
    51 | 57 | 59 | { "spaceId": "srgb", "coords": [ 1, 0, 0 ], "alpha": 1 } 60 |
    64 | 70 | 72 | { "spaceId": "srgb", "coords": [ 0, 1, 0 ], "alpha": 1 } 73 |
    77 | 83 | 85 | 90 |
    94 | 100 | 102 | { "spaceId": "p3", "coords": [ 0, 1, 0 ], "alpha": 1 } 103 |
    107 | 113 | 115 | { "spaceId": "rec2100pq", "coords": [ 0.34, 0.34, 0.34 ], "alpha": 1 } 116 |
    119 | 120 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /notebook/color-notebook.css: -------------------------------------------------------------------------------- 1 | .cn-wrapper { 2 | position: relative; 3 | display: flex; 4 | --transparency: transparent 5 | 6 | } 7 | 8 | @supports (background: conic-gradient(red, white)) { 9 | 10 | .cn-wrapper { 11 | --transparency: repeating-conic-gradient(transparent 0 25%, rgb(0 0 0 / .05) 0 50%) 0 0 / 1.5em 1.5em content-box border-box 12 | 13 | } 14 | } 15 | 16 | .cn-wrapper > pre, 17 | .cn-wrapper > .prism-live { 18 | flex: 1; 19 | width: 0; 20 | } 21 | 22 | .cn-wrapper .cn-results:empty { 23 | display: none; 24 | } 25 | 26 | .cn-wrapper .cn-results:not(:empty) { 27 | display: flex; 28 | flex-flow: column; 29 | margin-left: .5em; 30 | padding-top: .5em; 31 | font: bold 65%/1 var(--font-monospace, monospace) 32 | } 33 | 34 | .cn-wrapper .cn-results:not(:empty) > * { 35 | margin-bottom: .5em; 36 | min-width: 10rem; 37 | max-width: 15rem; 38 | } 39 | 40 | @media (max-width: 900px) { 41 | .cn-wrapper .cn-results:not(:empty), 42 | .cn-wrapper > pre, 43 | .cn-wrapper > .prism-live { 44 | transition: .4s width; 45 | } 46 | 47 | .cn-wrapper .cn-results:not(:empty) { 48 | width: 2em; 49 | overflow: hidden 50 | } 51 | 52 | .cn-wrapper .cn-results:not(:empty):hover, 53 | .cn-wrapper .cn-results:not(:empty):focus, 54 | .cn-wrapper .cn-results:not(:empty):focus-within, 55 | .cn-wrapper .cn-results:not(:empty):active { 56 | width: auto; 57 | overflow: visible; 58 | } 59 | } 60 | 61 | .cn-wrapper .cn-value { 62 | cursor: pointer; 63 | } 64 | 65 | .cn-wrapper .cn-string, 66 | .cn-wrapper .cn-number, 67 | .cn-wrapper .cn-boolean, 68 | .cn-wrapper .cn-error { 69 | padding: .3em 0; 70 | line-height: 1.2; 71 | } 72 | 73 | .cn-wrapper .cn-error { 74 | color: var(--color-red); 75 | cursor: help 76 | } 77 | 78 | .cn-wrapper .cn-error::before { 79 | content: "⚠️"; 80 | margin-right: .1em; 81 | filter: invert() brightness(2) saturate(.7) hue-rotate(130deg); 82 | } 83 | 84 | .cn-wrapper .cn-color, 85 | .cn-wrapper .cn-range { 86 | padding: .4em; 87 | border-radius: .2em; 88 | transition: .3s .15s transform; 89 | transform-origin: right 90 | } 91 | 92 | .cn-wrapper .cn-color:hover, .cn-wrapper .cn-range:hover { 93 | z-index: 1; 94 | transform: scale(2); 95 | } 96 | 97 | .cn-wrapper .cn-range { 98 | height: 1em; 99 | background: linear-gradient(to right, var(--stops, transparent, transparent)), var(--transparency); 100 | } 101 | 102 | .cn-wrapper .cn-color { 103 | position: relative; 104 | background: linear-gradient(var(--color), var(--color)), var(--transparency); 105 | white-space: nowrap 106 | } 107 | 108 | .cn-wrapper .cn-color.dark { 109 | color: white; 110 | } 111 | 112 | .cn-wrapper .cn-color.out-of-gamut { 113 | box-shadow: 0 0 .1em .02em var(--out-of-gamut-color, red) 114 | } 115 | 116 | .cn-wrapper .cn-color.out-of-gamut.light { 117 | --out-of-gamut-color: #b00; 118 | } 119 | 120 | .cn-wrapper .cn-array > * { 121 | display: inline-block; 122 | } 123 | 124 | .cn-wrapper .cn-array > .cn-color:hover { 125 | transform: scale(4); 126 | } 127 | 128 | [data-varname] { 129 | background: var(--color); 130 | box-shadow: 0 0 0 .1em var(--color); 131 | border-radius: .01em; 132 | } 133 | 134 | [data-varname].dark { 135 | color: white; 136 | text-shadow: none; 137 | } 138 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | import multiplyMatrices from "./multiply-matrices.js"; 2 | 3 | /** 4 | * Check if a value is a string (including a String object) 5 | * @param {*} str - Value to check 6 | * @returns {boolean} 7 | */ 8 | export function isString (str) { 9 | return type(str) === "string"; 10 | } 11 | 12 | /** 13 | * Determine the internal JavaScript [[Class]] of an object. 14 | * @param {*} o - Value to check 15 | * @returns {string} 16 | */ 17 | export function type (o) { 18 | let str = Object.prototype.toString.call(o); 19 | 20 | return (str.match(/^\[object\s+(.*?)\]$/)[1] || "").toLowerCase(); 21 | } 22 | 23 | /** 24 | * Like Object.assign() but copies property descriptors (including symbols) 25 | * @param {Object} target - Object to copy to 26 | * @param {...Object} sources - Objects to copy from 27 | * @returns {Object} target 28 | */ 29 | export function extend (target, ...sources) { 30 | for (let source of sources) { 31 | if (source) { 32 | let descriptors = Object.getOwnPropertyDescriptors(source); 33 | Object.defineProperties(target, descriptors); 34 | } 35 | } 36 | 37 | return target; 38 | } 39 | 40 | /** 41 | * Copy a descriptor from one object to another 42 | * @param {Object} target - Object to copy to 43 | * @param {Object} source - Object to copy from 44 | * @param {string} prop - Name of property 45 | */ 46 | export function copyDescriptor (target, source, prop) { 47 | let descriptor = Object.getOwnPropertyDescriptor(source, prop); 48 | Object.defineProperty(target, prop, descriptor); 49 | } 50 | 51 | /** 52 | * Uppercase the first letter of a string 53 | * @param {string} str - String to capitalize 54 | * @returns Capitalized string 55 | */ 56 | export function capitalize(str) { 57 | if (!str) { 58 | return str; 59 | } 60 | 61 | return str[0].toUpperCase() + str.slice(1); 62 | } 63 | 64 | /** 65 | * Round a number to a certain number of significant digits based on a range 66 | * @param {number} n - The number to round 67 | * @param {number} precision - Number of significant digits 68 | */ 69 | export function toPrecision(n, precision) { 70 | precision = +precision; 71 | let integerLength = (Math.floor(n) + "").length; 72 | 73 | if (precision > integerLength) { 74 | return +n.toFixed(precision - integerLength); 75 | } 76 | else { 77 | let p10 = 10 ** (integerLength - precision); 78 | return Math.round(n / p10) * p10; 79 | } 80 | } 81 | 82 | export function parseCoord(coord) { 83 | if (coord.indexOf(".") > 0) { 84 | // Reduce a coordinate of a certain color space until the color is in gamut 85 | let [spaceId, coordName] = coord.split("."); 86 | let space = Color.space(spaceId); 87 | 88 | if (!(coordName in space.coords)) { 89 | throw new ReferenceError(`Color space "${space.name}" has no "${coordName}" coordinate.`); 90 | } 91 | 92 | return [space, coordName]; 93 | } 94 | } 95 | 96 | export function value(obj, prop, value) { 97 | let props = prop.split("."); 98 | let lastProp = props.pop(); 99 | 100 | obj = props.reduceRight((acc, cur) => { 101 | return acc && acc[cur]; 102 | }, obj); 103 | 104 | if (obj) { 105 | if (value === undefined) { 106 | // Get 107 | return obj[lastProp]; 108 | } 109 | else { 110 | // Set 111 | return obj[lastProp] = value; 112 | } 113 | } 114 | } 115 | 116 | export {multiplyMatrices}; 117 | -------------------------------------------------------------------------------- /get/index.js: -------------------------------------------------------------------------------- 1 | let code = $("#bundle > code"); 2 | code.classList.remove("language-none"); 3 | 4 | document.addEventListener("mv-change", evt => { 5 | if (code.children === 0) { 6 | Prism.highlightElement(code); 7 | } 8 | }); 9 | 10 | $("a[download]").addEventListener("click", evt => { 11 | evt.target.href = createURL(code.textContent); 12 | }); 13 | 14 | function createURL(code, type = "text/javascript") { 15 | var blob = new Blob([code], {type}); 16 | 17 | return URL.createObjectURL(blob); 18 | } 19 | 20 | // import {rollup} from "https://unpkg.com/rollup@2.10.9/dist/es/rollup.browser.js?module"; 21 | // 22 | // async function build() { 23 | // // create a bundle 24 | // const bundle = await rollup({ 25 | // input: "../src/main.js", 26 | // plugins: [] 27 | // }); 28 | // 29 | // console.log(bundle.watchFiles); // an array of file names this bundle depends on 30 | // 31 | // // generate output specific code in-memory 32 | // // you can call this function multiple times on the same bundle object 33 | // const { output } = await bundle.generate({ 34 | // file: `dist/color.js`, 35 | // name: "Color", 36 | // format: "iife", 37 | // sourcemap: true, 38 | // exports: "named", /** Disable warning for default imports */ 39 | // // plugins: [ 40 | // // minify? terser({ 41 | // // compress: true, 42 | // // mangle: true 43 | // // }) : undefined 44 | // // ] 45 | // }); 46 | // 47 | // for (const chunkOrAsset of output) { 48 | // if (chunkOrAsset.type === 'asset') { 49 | // // For assets, this contains 50 | // // { 51 | // // fileName: string, // the asset file name 52 | // // source: string | Uint8Array // the asset source 53 | // // type: 'asset' // signifies that this is an asset 54 | // // } 55 | // console.log('Asset', chunkOrAsset); 56 | // } else { 57 | // // For chunks, this contains 58 | // // { 59 | // // code: string, // the generated JS code 60 | // // dynamicImports: string[], // external modules imported dynamically by the chunk 61 | // // exports: string[], // exported variable names 62 | // // facadeModuleId: string | null, // the id of a module that this chunk corresponds to 63 | // // fileName: string, // the chunk file name 64 | // // imports: string[], // external modules imported statically by the chunk 65 | // // isDynamicEntry: boolean, // is this chunk a dynamic entry point 66 | // // isEntry: boolean, // is this chunk a static entry point 67 | // // map: string | null, // sourcemaps if present 68 | // // modules: { // information about the modules in this chunk 69 | // // [id: string]: { 70 | // // renderedExports: string[]; // exported variable names that were included 71 | // // removedExports: string[]; // exported variable names that were removed 72 | // // renderedLength: number; // the length of the remaining code in this module 73 | // // originalLength: number; // the original length of the code in this module 74 | // // }; 75 | // // }, 76 | // // name: string // the name of this chunk as used in naming patterns 77 | // // type: 'chunk', // signifies that this is a chunk 78 | // // } 79 | // console.log('Chunk', chunkOrAsset.modules); 80 | // } 81 | // } 82 | // 83 | // // or write the bundle to disk 84 | // // await bundle.write(outputOptions); 85 | // } 86 | // 87 | // build(); 88 | -------------------------------------------------------------------------------- /assets/js/docs.js: -------------------------------------------------------------------------------- 1 | import Notebook from "../../notebook/color-notebook.js"; 2 | 3 | let $ = Bliss; 4 | let $$ = $.$; 5 | 6 | // Wrap toc links in a list 7 | let ul = $("#toc > ul"); 8 | let current = $$("#toc > ul > li > a").find(a => { 9 | return a.pathname.replace(/\.html|$/i, "") === location.pathname.replace(/\.html|$/i, ""); 10 | }); 11 | 12 | if (current) { 13 | current = current.parentNode; //
  • 14 | current.classList.add("current"); 15 | current.setAttribute("aria-label", "This page"); 16 | 17 | let pageToc = document.createElement("ul"); 18 | 19 | makePageToc(pageToc); 20 | 21 | current.append(pageToc); 22 | 23 | document.addEventListener("mv-load", evt => { 24 | makePageToc(pageToc); 25 | }); 26 | 27 | // Find next and previous 28 | let previous = current.previousElementSibling; 29 | 30 | if (previous) { 31 | previous.classList.add("previous"); 32 | previous.setAttribute("aria-label", "Previous"); 33 | } 34 | 35 | let next = current.nextElementSibling; 36 | 37 | if (next) { 38 | next.classList.add("next"); 39 | next.setAttribute("aria-label", "Next"); 40 | } 41 | } 42 | 43 | function idify(str) { 44 | // from Mavo.Functions.idify() 45 | return str 46 | .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Convert accented letters to ASCII 47 | .replace(/[^\w\s-]/g, "") // Remove remaining non-ASCII characters 48 | .trim().replace(/\s+/g, "-") // Convert whitespace to hyphens 49 | .toLowerCase(); 50 | } 51 | 52 | function makePageToc(pageToc) { 53 | pageToc.textContent = ""; 54 | 55 | // Make toc for current page 56 | $$("main h2:not(.no-toc)").map(h2 => { 57 | let text = h2.textContent; 58 | 59 | if (!h2.id) { 60 | h2.id = idify(text); 61 | } 62 | 63 | let a = $.create("a", { 64 | textContent: text, 65 | href: "#" + h2.id 66 | }); 67 | 68 | // Linkify heading 69 | if (!$("a", h2)) { 70 | h2.textContent = ""; 71 | h2.appendChild(a.cloneNode(true)); 72 | } 73 | 74 | $.create("li", { 75 | contents: a, 76 | inside: pageToc 77 | }); 78 | }); 79 | } 80 | 81 | if (location.pathname.indexOf("/spaces") > -1) { 82 | // FIXME race condition: data may have already rendered 83 | Mavo.hooks.add("render-start", function(env) { 84 | if (this.id !== "colorSpaces") { 85 | return; 86 | } 87 | 88 | for (let space of env.data.space) { 89 | let spaceMeta = Color.spaces[space.id]; 90 | 91 | if (!spaceMeta) { 92 | continue; 93 | } 94 | 95 | space.coord = Object.entries(spaceMeta.coords).map(entry => { 96 | return { 97 | name: entry[0], 98 | min: entry[1][0], 99 | max: entry[1][1] 100 | }; 101 | }); 102 | 103 | space.whitePoint = spaceMeta.white === Color.whites.D50? "D50" : "D65"; 104 | space.cssId = spaceMeta.cssId || spaceMeta.id; 105 | } 106 | }); 107 | 108 | Mavo.hooks.add("getdata-end", function(env) { 109 | if (this.id !== "colorSpaces") { 110 | return; 111 | } 112 | 113 | for (let space of env.data.space) { 114 | delete space.coord; 115 | delete space.whitePoint; 116 | } 117 | }); 118 | 119 | Mavo.all.colorSpaces.dataLoaded.then(() => { 120 | return Mavo.defer(500); 121 | }).then(() => { 122 | $$("pre:not([class])").forEach(pre => { 123 | // Add class now to avoid race conditions where Prism highlights before expressions resolve 124 | pre.classList.add("language-javascript"); 125 | Prism.highlightElement(pre); 126 | 127 | Notebook.create(pre); 128 | }); 129 | }); 130 | 131 | if (Mavo.all.colorSpaces && Mavo.all.colorSpaces.root.children.space.children.length > 1) { 132 | // Data has already rendered, re-render 133 | Mavo.all.colorSpaces.render(Mavo.all.colorSpaces.getData()); 134 | 135 | } 136 | 137 | 138 | } 139 | -------------------------------------------------------------------------------- /src/deltaE/deltaECMC.js: -------------------------------------------------------------------------------- 1 | import Color from "../color.js"; 2 | // More accurate color-difference formulae 3 | // than the simple 1976 Euclidean distance in Lab 4 | 5 | // CMC by the Color Measurement Committee of the 6 | // Bradford Society of Dyeists and Colorsts, 1994. 7 | // Uses LCH rather than Lab, 8 | // with different weights for L, C and H differences 9 | // A nice increase in accuracy for modest increase in complexity 10 | 11 | Color.prototype.deltaECMC = function (sample, {l = 2, c = 1} = {}) { 12 | let color = this; 13 | sample = Color.get(sample); 14 | 15 | // Given this color as the reference 16 | // and a sample, 17 | // calculate deltaE CMC. 18 | 19 | // This implementation assumes the parametric 20 | // weighting factors l:c are 2:1 21 | // which is typical for non-textile uses. 22 | 23 | let [L1, a1, b1] = color.lab; 24 | let C1 = color.chroma; 25 | let H1 = color.hue; 26 | let [L2, a2, b2] = sample.lab; 27 | let C2 = sample.chroma; 28 | 29 | // Check for negative Chroma, 30 | // which might happen through 31 | // direct user input of LCH values 32 | 33 | if (C1 < 0) { 34 | C1 = 0; 35 | } 36 | if (C2 < 0) { 37 | C2 = 0; 38 | } 39 | 40 | // we don't need H2 as ΔH is calculated from Δa, Δb and ΔC 41 | // console.log({L1, a1, b1}); 42 | // console.log({L2, a2, b2}); 43 | 44 | // Lightness and Chroma differences 45 | // These are (color - sample), unlike deltaE2000 46 | let ΔL = L1 - L2; 47 | let ΔC = C1 - C2; 48 | // console.log({ΔL}); 49 | // console.log({ΔC}); 50 | 51 | let Δa = a1 - a2; 52 | let Δb = b1 - b2; 53 | // console.log({Δa}); 54 | // console.log({Δb}); 55 | 56 | // weighted Hue difference, less for larger Chroma difference 57 | const π = Math.PI; 58 | const d2r = π / 180; 59 | let H2 = (Δa ** 2) + (Δb ** 2) - (ΔC ** 2); 60 | // due to roundoff error it is possible that, for zero a and b, 61 | // ΔC > Δa + Δb is 0, resulting in attempting 62 | // to take the square root of a negative number 63 | 64 | // trying instead the equation from Industrial Color Physics 65 | // By Georg A. Klein 66 | 67 | // let ΔH = ((a1 * b2) - (a2 * b1)) / Math.sqrt(0.5 * ((C2 * C1) + (a2 * a1) + (b2 * b1))); 68 | // console.log({ΔH}); 69 | // This gives the same result to 12 decimal places 70 | // except it sometimes NaNs when trying to root a negative number 71 | 72 | // let ΔH = Math.sqrt(H2); we never actually use the root, it gets squared again!! 73 | 74 | // positional corrections to the lack of uniformity of CIELAB 75 | // These are all trying to make JND ellipsoids more like spheres 76 | 77 | // SL Lightness crispening factor, depends entirely on L1 not L2 78 | let SL = 0.511; // linear portion of the Y to L transfer function 79 | if (L1 >= 16) { // cubic portion 80 | SL = (0.040975 * L1) / (1 + 0.01765 * L1); 81 | } 82 | // console.log({SL}); 83 | 84 | // SC Chroma factor 85 | let SC = ((0.0638 * C1) / (1 + 0.0131 * C1)) + 0.638; 86 | // console.log({SC}); 87 | 88 | // Cross term T for blue non-linearity 89 | let T; 90 | if ( Number.isNaN(H1)) { 91 | H1 = 0; 92 | } 93 | 94 | if (H1 >= 164 && H1 <= 345) { 95 | T = 0.56 + Math.abs(0.2 * Math.cos((H1 + 168) * d2r)); 96 | } 97 | else { 98 | T = 0.36 + Math.abs(0.4 * Math.cos((H1 + 35) * d2r)); 99 | } 100 | // console.log({T}); 101 | 102 | // SH Hue factor also depends on C1, 103 | let C4 = Math.pow(C1, 4); 104 | let F = Math.sqrt(C4 / (C4 + 1900)); 105 | let SH = SC * ((F * T) + 1 - F); 106 | // console.log({SH}); 107 | 108 | // Finally calculate the deltaE, term by term as root sume of squares 109 | let dE = (ΔL / (l * SL)) ** 2; 110 | dE += (ΔC / (c * SC)) ** 2; 111 | dE += (H2 / (SH ** 2)); 112 | // dE += (ΔH / SH) ** 2; 113 | return Math.sqrt(dE); 114 | // Yay!!! 115 | }; 116 | 117 | Color.statify(["deltaECMC"]); 118 | -------------------------------------------------------------------------------- /scripts/RGB_matrix_maker.py: -------------------------------------------------------------------------------- 1 | """Calculate XYZ conversion matrices.""" 2 | """Derived from https://gist.githubusercontent.com/facelessuser/7d3707734fa9bcf208ab4dabea830cdb/raw/52a6effaec9b8b3662d28cd1378799639d1b4ef7/matrix_calc.py """ 3 | import numpy as np 4 | 5 | np.set_printoptions(precision=17, suppress='true', sign='-', floatmode='fixed') 6 | 7 | """From ASTM E308-01""" 8 | """ white_d65 = [0.95047, 1.00000, 1.08883]""" 9 | 10 | """ white_d50 = [0.96422, 1.00000, 0.82521]""" 11 | 12 | """ From CIE 15:2004 table T.3 chromaticities""" 13 | 14 | """white_d65 = [0.31272 / 0.32903, 1.00000, (1.0 - 0.31272 - 0.32903) / 0.32903]""" 15 | 16 | """white_d50 = [0.34567 / 0.35851, 1.00000, (1.0 - 0.34567 - 0.35851) / 0.35851]""" 17 | 18 | """ From CIE 15:2004 table T.3 XYZ""" 19 | 20 | """white_d65 = [0.9504, 1.0000, 1.0888]""" 21 | 22 | """white_d50 = [0.9642, 1.0000, 0.8251]""" 23 | 24 | """The four-digit chromaticity ones that everyone uses, including the sRGB standard, for compatibility""" 25 | 26 | white_d65 = [0.3127 / 0.3290, 1.00000, (1.0 - 0.3127 - 0.3290) / 0.3290] 27 | white_d50 = [0.3457 / 0.3585, 1.00000, (1.0 - 0.3457 - 0.3585) / 0.3585] 28 | 29 | 30 | def get_matrix(wp, space): 31 | """Get the matrices for the specified space.""" 32 | 33 | if space == 'srgb': 34 | xr = 0.64 35 | yr = 0.33 36 | xg = 0.30 37 | yg = 0.60 38 | xb = 0.15 39 | yb = 0.06 40 | elif space == 'display-p3': 41 | xr = 0.68 42 | yr = 0.32 43 | xg = 0.265 44 | yg = 0.69 45 | xb = 0.150 46 | yb = 0.060 47 | elif space == 'rec2020': 48 | xr = 0.708 49 | yr = 0.292 50 | xg = 0.17 51 | yg = 0.797 52 | xb = 0.131 53 | yb = 0.046 54 | elif space == 'a98-rgb': 55 | xr = 0.64 56 | yr = 0.33 57 | xg = 0.21 58 | yg = 0.71 59 | xb = 0.15 60 | yb = 0.06 61 | elif space == 'prophoto-rgb': 62 | xr = 0.734699 63 | yr = 0.265301 64 | xg = 0.159597 65 | yg = 0.840403 66 | xb = 0.036598 67 | yb = 0.000105 68 | else: 69 | raise ValueError 70 | 71 | m = [ 72 | [xr / yr, 1.0, (1.0 - xr - yr) / yr], 73 | [xg / yg, 1.0, (1.0 - xg - yg) / yg], 74 | [xb / yb, 1.0, (1.0 - xb - yb) / yb] 75 | ] 76 | mi = np.linalg.inv(m) 77 | 78 | r, g, b = np.dot(wp, mi) 79 | rgb = [ 80 | [r], 81 | [g], 82 | [b] 83 | ] 84 | rgb2xyz = np.multiply(rgb, m).transpose() 85 | xyz2rgb = np.linalg.inv(rgb2xyz) 86 | 87 | return rgb2xyz, xyz2rgb 88 | 89 | 90 | if __name__ == "__main__": 91 | print('===== sRGB =====') 92 | to_xyz, from_xyz = get_matrix(white_d65, 'srgb') 93 | print('--- rgb -> xyz ---') 94 | print(to_xyz) 95 | print('--- xyz -> rgb ---') 96 | print(from_xyz) 97 | 98 | print('===== Display P3 =====') 99 | to_xyz, from_xyz = get_matrix(white_d65, 'display-p3') 100 | print('--- rgb -> xyz ---') 101 | print(to_xyz) 102 | print('--- xyz -> rgb ---') 103 | print(from_xyz) 104 | 105 | print('===== Adobe 98 =====') 106 | to_xyz, from_xyz = get_matrix(white_d65, 'a98-rgb') 107 | print('--- rgb -> xyz ---') 108 | print(to_xyz) 109 | print('--- xyz -> rgb ---') 110 | print(from_xyz) 111 | 112 | print('===== Rec.2020 =====') 113 | to_xyz, from_xyz = get_matrix(white_d65, 'rec2020') 114 | print('--- rgb -> xyz ---') 115 | print(to_xyz) 116 | print('--- xyz -> rgb ---') 117 | print(from_xyz) 118 | 119 | print('===== ProPhoto =====') 120 | to_xyz, from_xyz = get_matrix(white_d50, 'prophoto-rgb') 121 | print('--- rgb -> xyz ---') 122 | print(to_xyz) 123 | print('--- xyz -> rgb ---') 124 | print(from_xyz) 125 | -------------------------------------------------------------------------------- /apps/gradients/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Interpolation 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
    17 | 23 | 24 | 30 | 31 | 35 | 36 | Output space: 37 | 38 | 42 | 43 | Permalink 44 | 45 | 46 | 47 |
    48 | 49 |
    50 |
    51 |
    52 | 53 | interpolation, 54 | 55 | max ΔE 56 | ([count(step)] resulting steps) 57 | 58 | 59 |
    60 | Colors: 61 |
    62 | [color & ""][outputColor & ""] 63 | 64 | 65 | 66 | ΔE [color.deltaE2000($previous.color)] 67 | 68 |
    69 |
    70 | 71 | 75 |
    76 |
    77 |
    78 | 79 | 84 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /tests/modifications.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Color modification tests 7 | 8 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 19 | 20 |

    Color modification Tests

    21 |

    These tests modify one or more coordinates and check the result.

    22 | 23 |
    24 |

    sRGB to LCH

    25 | 26 | 27 | 36 | 37 | 38 | 39 | 48 | 49 | 50 | 51 | 60 | 61 | 62 | 63 | 72 | 73 | 74 | 75 | 84 | 85 | 86 | 87 | 96 | 97 | 98 | 99 | 108 | 109 | 110 | 111 | 120 | 121 | 122 | 123 | 133 | 134 | 135 | 136 | 146 | 147 | 148 | 149 | 158 | 159 | 160 |
    28 | 35 | 13
    40 | 47 | 13
    52 | 59 | 13
    64 | 71 | 13
    76 | 83 | 13 40
    88 | 95 | 1
    100 | 107 | 13
    112 | 119 | 13
    124 | 132 | 13.480970445148008
    137 | 145 | 13.480970445148008
    150 | 157 | 52.69726799102946 14.04267594002497 253.0004426214531
    161 |
    162 | 163 | 164 | 165 | -------------------------------------------------------------------------------- /docs/gamut-mapping.md: -------------------------------------------------------------------------------- 1 | # Gamut mapping 2 | 3 | ## What is color gamut? 4 | 5 | [Color Gamut](https://en.wikipedia.org/wiki/Gamut) is the range of colors a given color space can produce. 6 | Some color spaces (e.g. Lab, LCH, Jazbz, CAM16) are mathematical models that encompass all visible color 7 | and thus, do not have a fixed gamut. 8 | Others however cannot produce all visible color without *values out of range*. 9 | For example. all the RGB spaces (sRGB, P3, Adobe® RGB, ProPhoto, REC.2020) have a gamut that is smaller than all visible color. 10 | Therefore, there are visible colors that cannot be represented by certain color spaces. 11 | For example, the P3 lime (`color(display-p3 0 1 0)`) is outside of the gamut of sRGB. 12 | In addition, colors that are **not visible to humans** can sometimes be represented by some color spaces! 13 | Most notably, two of ProPhoto's three primaries (pure green, pure blue) are **outside the gamut of human vision**! 14 | 15 | The process of transforming a color outside of a given gamut to a color that is as close as possible but is *inside gamut* is called *gamut mapping* and is the subject of [entire books](https://www.google.com/books/edition/Color_Gamut_Mapping/Yy0uK3pvfRMC?hl=en&gbpv=1&printsec=frontcover). 16 | 17 | ## So how does Color.js handle all this? 18 | 19 | **Color.js does not do gamut mapping by default**, as this is lossy: If you convert from a larger color space to a smaller one and then back, you need to be able to get your original color (possibly with some roundoff error due to the calculations). 20 | 21 | You can call `color.inGamut()` to check if the current color is in gamut of its own color space, or you can pass a different color space to check against: 22 | 23 | ```js 24 | let lime = new Color("p3", [0, 1, 0]); 25 | lime.inGamut(); 26 | lime.inGamut("srgb"); 27 | let sRGB_lime = lime.to("srgb"); 28 | sRGB_lime.inGamut(); 29 | ``` 30 | 31 | Note that while the coordinates remain unchanged, the string representation of a Color object is, by default, _after_ gamut mapping, unless you explicitly turn that off: 32 | 33 | ```js 34 | let lime = new Color("p3", [0, 1, 0]).to("srgb"); 35 | lime.coords; 36 | lime.toString(); 37 | lime.toString({inGamut: false}); 38 | ``` 39 | 40 | 41 | If you want gamut mapped coordinates, you can use `color.toGamut()`, which by default returns a new color that is within gamut (if you want to mutate your own color instead you can use `{inPlace: true}`). 42 | You can also pass a different color space whose gamut you are mapping to via the `space` parameter. 43 | 44 | ```js 45 | let lime = new Color("p3", [0, 1, 0]); 46 | let sRGB_lime = lime.to("srgb"); 47 | lime.toGamut({space: "srgb"}); 48 | sRGB_lime.toGamut(); 49 | ``` 50 | 51 | Perhaps most important is the `method` parameter, which controls the algorithm used for gamut mapping. 52 | You can pass `"clip"` to use simple clipping (not recommended), or any coordinate of any imported color space, which will make Color.js reduce that coordinate until the color is in gamut. 53 | 54 | The default method is `"lch.chroma"` which means LCH hue and lightness remain constant while chroma is reduced until the color fits in gamut. 55 | Simply reducing chroma tends to produce good results for most colors, but most notably fails on yellows: 56 | 57 | ![chroma-reduction](../images/p3-yellow-lab.svg) 58 | 59 | Here is P3 yellow, with LCH Chroma reduced to the neutral axis. The RGB values are linear-light P3. The color wedge shows sRGB values, if in gamut; salmon, if outside sRGB and red if outside P3. Notice the red curve goes up (so, out of gamut) before finally dropping again. The chroma of P3 yellow is 123, while the chroma of the gamut-mapped result is far to low, only 25! 60 | 61 | Instead, the default algorithm reduces chroma (by binary search) and also, at each stage, calculates the deltaE2000 between the current estimate and a channel-clipped version of that color. If the deltaE is less than 2, the clipped color is displayed. Notice the red curve hugs the top edge now because clipping to sRGB also means it is inside P3 gamut. Notice how we get an in-gamut color much earlier. This method produces an in-gamut color with chroma 103. 62 | 63 | ![chroma-reduction-clip](../images/p3-yellow-lab-clip.svg) 64 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | Based on u*,v* UCS diagram 3 | 27 | 28 | 122 | 123 | 124 | 125 | 126 | 140 | 141 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | -------------------------------------------------------------------------------- /src/spaces/srgb.js: -------------------------------------------------------------------------------- 1 | import Color, {util} from "./../color.js"; 2 | 3 | Color.defineSpace({ 4 | id: "srgb", 5 | name: "sRGB", 6 | coords: { 7 | red: [0, 1], 8 | green: [0, 1], 9 | blue: [0, 1] 10 | }, 11 | white: Color.whites.D65, 12 | 13 | // convert an array of sRGB values in the range 0.0 - 1.0 14 | // to linear light (un-companded) form. 15 | // https://en.wikipedia.org/wiki/SRGB 16 | toLinear(RGB) { 17 | return RGB.map(function (val) { 18 | let sign = val < 0? -1 : 1; 19 | let abs = Math.abs(val); 20 | 21 | if (abs < 0.04045) { 22 | return val / 12.92; 23 | } 24 | 25 | return sign * Math.pow((abs + 0.055) / 1.055, 2.4); 26 | }); 27 | }, 28 | // convert an array of linear-light sRGB values in the range 0.0-1.0 29 | // to gamma corrected form 30 | // https://en.wikipedia.org/wiki/SRGB 31 | toGamma(RGB) { 32 | return RGB.map(function (val) { 33 | let sign = val < 0? -1 : 1; 34 | let abs = Math.abs(val); 35 | 36 | if (abs > 0.0031308) { 37 | return sign * (1.055 * Math.pow(abs, 1/2.4) - 0.055); 38 | } 39 | 40 | return 12.92 * val; 41 | }); 42 | }, 43 | 44 | // This matrix was calculated directly from the RGB and white chromaticities 45 | // when rounded to 8 decimal places, it agrees completely with the official matrix 46 | // see https://github.com/w3c/csswg-drafts/issues/5922 47 | toXYZ_M: [ 48 | [ 0.41239079926595934, 0.357584339383878, 0.1804807884018343 ], 49 | [ 0.21263900587151027, 0.715168678767756, 0.07219231536073371 ], 50 | [ 0.01933081871559182, 0.11919477979462598, 0.9505321522496607 ] 51 | ], 52 | 53 | // This matrix is the inverse of the above; 54 | // again it agrees with the official definiton when rounded to 8 decimal places 55 | fromXYZ_M: [ 56 | [ 3.2409699419045226, -1.537383177570094, -0.4986107602930034 ], 57 | [ -0.9692436362808796, 1.8759675015077202, 0.04155505740717559 ], 58 | [ 0.05563007969699366, -0.20397695888897652, 1.0569715142428786 ] 59 | ], 60 | 61 | // convert an array of sRGB values to CIE XYZ 62 | // using sRGB's own white, D65 (no chromatic adaptation) 63 | toXYZ(rgb) { 64 | rgb = this.toLinear(rgb); 65 | 66 | return util.multiplyMatrices(this.toXYZ_M, rgb); 67 | }, 68 | fromXYZ(XYZ) { 69 | return this.toGamma(util.multiplyMatrices(this.fromXYZ_M, XYZ)); 70 | }, 71 | // Properties added to Color.prototype 72 | properties: { 73 | toHex({ 74 | alpha = true, // include alpha in hex? 75 | collapse = true // collapse to 3-4 digit hex when possible? 76 | } = {}) { 77 | let coords = this.to("srgb", {inGamut: true}).coords; 78 | 79 | if (this.alpha < 1 && alpha) { 80 | coords.push(this.alpha); 81 | } 82 | 83 | coords = coords.map(c => Math.round(c * 255)); 84 | 85 | let collapsible = collapse && coords.every(c => c % 17 === 0); 86 | 87 | let hex = coords.map(c => { 88 | if (collapsible) { 89 | return (c/17).toString(16); 90 | } 91 | 92 | return c.toString(16).padStart(2, "0"); 93 | }).join(""); 94 | 95 | return "#" + hex; 96 | }, 97 | 98 | get hex() { 99 | return this.toHex(); 100 | } 101 | }, 102 | // Properties present only on sRGB colors 103 | instance: { 104 | toString ({inGamut = true, commas, format = "%", ...rest} = {}) { 105 | if (format === 255) { 106 | format = c => c * 255; 107 | } 108 | else if (format === "hex") { 109 | return this.toHex(arguments[0]); 110 | } 111 | 112 | return Color.prototype.toString.call(this, { 113 | inGamut, commas, format, 114 | name: "rgb" + (commas && this.alpha < 1? "a" : ""), 115 | ...rest 116 | }); 117 | } 118 | }, 119 | 120 | parseHex (str) { 121 | if (str.length <= 5) { 122 | // #rgb or #rgba, duplicate digits 123 | str = str.replace(/[a-f0-9]/gi, "$&$&"); 124 | } 125 | 126 | let rgba = []; 127 | str.replace(/[a-f0-9]{2}/gi, component => { 128 | rgba.push(parseInt(component, 16) / 255); 129 | }); 130 | 131 | return { 132 | spaceId: "srgb", 133 | coords: rgba.slice(0, 3), 134 | alpha: rgba.slice(3)[0] 135 | }; 136 | } 137 | }); 138 | 139 | Color.hooks.add("parse-start", env => { 140 | let str = env.str; 141 | 142 | if (/^#([a-f0-9]{3,4}){1,2}$/i.test(str)) { 143 | env.color = Color.spaces.srgb.parseHex(str); 144 | } 145 | }); 146 | 147 | export default Color; 148 | export {util}; 149 | -------------------------------------------------------------------------------- /docs/the-color-object.md: -------------------------------------------------------------------------------- 1 | # The Color Object 2 | 3 | The first part to many Color.js operations is creating a Color object, 4 | which represents a specific color, 5 | in a particular colorspace, 6 | and has methods to convert the color to other spaces 7 | or to manipulate it.. 8 | There are many ways to do so. 9 | 10 | ## Passing a CSS color string 11 | 12 | ```js 13 | let color = new Color("slategray"); 14 | let color2 = new Color("hwb(60 30% 40% / .1)"); 15 | let color3 = new Color("color(display-p3 0 1 0)"); 16 | let color4 = new Color("lch(50% 80 30)"); 17 | ``` 18 | 19 | You can even use CSS variables, optionally with a DOM element against which they will be resolved (defaults to document root): 20 | 21 | ```js 22 | new Color("--color-blue"); 23 | new Color("--color-green", document.querySelector("h1")); 24 | ``` 25 | 26 | ## Color space and coordinates 27 | 28 | Internally, every Color object is stored this way, so this is the most low level way to create a Color object. 29 | 30 | ```js 31 | let lime = new Color("sRGB", [0, 1, 0], .5); // optional alpha 32 | let yellow = new Color("P3", [1, 1, 0]); 33 | new Color("lch", [50, 30, 180]); 34 | 35 | // Capitalization doesn't matter 36 | new Color("LCH", [50, 30, 180]); 37 | 38 | // Color space objects work too 39 | new Color(Color.spaces.lch, [50, 30, 180]); 40 | ``` 41 | 42 | The exact ranges for these coordinates are up to the 43 | [color space](spaces.html) definition. 44 | 45 | You can also pass another color, or an object literal with `spaceId`/`space`, `coords`, and optionally `alpha` properties: 46 | 47 | ```js 48 | let red1 = new Color({space: "lab", coords: [50, 50, 50]}); 49 | let red2 = new Color({spaceId: "lab", coords: [50, 50, 50]}); 50 | let redClone = new Color(red1); 51 | ``` 52 | 53 | ## Color object properties 54 | 55 | The three basic properties of a color object are its color space, its coordinates, and its alpha: 56 | 57 | ```js 58 | let color = new Color("deeppink"); 59 | color.space; // Color space object 60 | color.space === Color.spaces.srgb; 61 | color.spaceId; // same as color.space.id 62 | color.coords; 63 | color.alpha; 64 | ``` 65 | 66 | However, you can also use color space ids to get the color's coordinates in any other color space: 67 | 68 | 69 | ```js 70 | let color = new Color("deeppink"); 71 | color.srgb; 72 | color.p3; 73 | color.lch; 74 | color.lab; 75 | color.prophoto; 76 | ``` 77 | 78 | In fact, you can even manipulate the color this way! 79 | 80 | 81 | ```js 82 | let color = new Color("deeppink"); 83 | color.lch[0] = 90; 84 | color; 85 | ``` 86 | 87 | Named coordinates are also available: 88 | 89 | ```js 90 | let color = new Color("deeppink"); 91 | color.srgb.green; 92 | color.srgb.green = .5; 93 | color; 94 | ``` 95 | 96 | Note that unless we explicitly change a color's color space, it remains in the same color space it was when it was created. 97 | Manipulating coordinates of other color spaces do not change a color's space, it is just internally converted to another space and then back to its own. 98 | To convert a color to a different color space, you need to change its `space` or `spaceId` properties. 99 | Both accept either a color space object, or an id: 100 | 101 | 102 | ```js 103 | let color = new Color("srgb", [0, 1, 0]); 104 | color.space = "p3"; 105 | color; 106 | color.space = Color.spaces.prophoto; 107 | color; 108 | ``` 109 | 110 | Often, we want to keep our color intact, 111 | but also convert it to another color space, 112 | creating a new Color object. 113 | This is exactly what `color.to()` is for: 114 | 115 | ```js 116 | let color = new Color("srgb", [0, 1, 0]); 117 | let colorP3 = color.to("p3"); 118 | color; 119 | colorP3; 120 | ``` 121 | 122 | Sometimes, when converting to a color space with a smaller gamut, the resulting coordinates may be out of gamut. 123 | You can test for that with `color.inGamut()` and get gamut mapped coordinates with `color.toGamut()`: 124 | 125 | 126 | ```js 127 | let funkyLime = new Color("p3", [0, 1, 0]); 128 | let boringLime = funkyLime.to("srgb"); 129 | boringLime.coords; 130 | boringLime.inGamut(); 131 | boringLime.toGamut(); 132 | ``` 133 | 134 | Note that `color.toString()` returns gamut mapped coordinates by default. 135 | You can turn this off, via the `{inGamut: false}` option. 136 | You can read more about gamut mapping in the [Gamut mapping](manipulation.html#gamut-mapping) section. 137 | -------------------------------------------------------------------------------- /src/CATs.js: -------------------------------------------------------------------------------- 1 | import Color, {util} from "./color.js"; 2 | 3 | Color.CATs = {}; 4 | 5 | Color.hooks.add("chromatic-adaptation-start", env => { 6 | if (env.options.method) { 7 | env.M = Color.adapt(env.W1, env.W2, env.options.method); 8 | } 9 | }); 10 | 11 | Color.hooks.add("chromatic-adaptation-end", env => { 12 | if (!env.M) { 13 | env.M = Color.adapt(env.W1, env.W2, env.options.method); 14 | } 15 | }); 16 | 17 | Color.defineCAT = function ({id, toCone_M, fromCone_M}) { 18 | // Use id, toCone_M, fromCone_M like variables 19 | Color.CATs[id] = arguments[0]; 20 | }; 21 | 22 | Color.adapt = function (W1, W2, id = "Bradford") { 23 | // adapt from a source whitepoint or illuminant W1 24 | // to a destination whitepoint or illuminant W2, 25 | // using the given chromatic adaptation transform (CAT) 26 | // debugger; 27 | let method = Color.CATs[id]; 28 | 29 | let [ρs, γs, βs] = util.multiplyMatrices(method.toCone_M, W1); 30 | let [ρd, γd, βd] = util.multiplyMatrices(method.toCone_M, W2); 31 | 32 | // all practical illuminants have non-zero XYZ so no division by zero can occur below 33 | let scale = [ 34 | [ρd/ρs, 0, 0 ], 35 | [0, γd/γs, 0 ], 36 | [0, 0, βd/βs ] 37 | ]; 38 | // console.log({scale}); 39 | 40 | let scaled_cone_M = util.multiplyMatrices(scale, method.toCone_M); 41 | let adapt_M = util.multiplyMatrices(method.fromCone_M, scaled_cone_M); 42 | // console.log({scaled_cone_M, adapt_M}); 43 | return adapt_M; 44 | }; 45 | 46 | Color.defineCAT({ 47 | id: "von Kries", 48 | toCone_M: [ 49 | [ 0.4002400, 0.7076000, -0.0808100 ], 50 | [ -0.2263000, 1.1653200, 0.0457000 ], 51 | [ 0.0000000, 0.0000000, 0.9182200 ] 52 | ], 53 | fromCone_M: [ 54 | [ 1.8599364, -1.1293816, 0.2198974 ], 55 | [ 0.3611914, 0.6388125, -0.0000064 ], 56 | [ 0.0000000, 0.0000000, 1.0890636 ] 57 | ] 58 | }); 59 | Color.defineCAT({ 60 | id: "Bradford", 61 | // Convert an array of XYZ values in the range 0.0 - 1.0 62 | // to cone fundamentals 63 | toCone_M: [ 64 | [ 0.8951000, 0.2664000, -0.1614000 ], 65 | [ -0.7502000, 1.7135000, 0.0367000 ], 66 | [ 0.0389000, -0.0685000, 1.0296000 ] 67 | ], 68 | // and back 69 | fromCone_M: [ 70 | [ 0.9869929, -0.1470543, 0.1599627 ], 71 | [ 0.4323053, 0.5183603, 0.0492912 ], 72 | [ -0.0085287, 0.0400428, 0.9684867 ] 73 | ] 74 | }); 75 | 76 | Color.defineCAT({ 77 | id: "CAT02", 78 | // with complete chromatic adaptation to W2, so D = 1.0 79 | toCone_M: [ 80 | [ 0.7328000, 0.4296000, -0.1624000 ], 81 | [ -0.7036000, 1.6975000, 0.0061000 ], 82 | [ 0.0030000, 0.0136000, 0.9834000 ] 83 | ], 84 | fromCone_M: [ 85 | [ 1.0961238, -0.2788690, 0.1827452 ], 86 | [ 0.4543690, 0.4735332, 0.0720978 ], 87 | [ -0.0096276, -0.0056980, 1.0153256 ] 88 | ] 89 | }); 90 | 91 | Color.defineCAT({ 92 | id: "CAT16", 93 | toCone_M: [ 94 | [ 0.401288, 0.650173, -0.051461 ], 95 | [ -0.250268, 1.204414, 0.045854 ], 96 | [ -0.002079, 0.048952, 0.953127 ] 97 | ], 98 | // the extra precision is needed to avoid roundtripping errors 99 | fromCone_M: [ 100 | [ 1.862067855087233e+0, -1.011254630531685e+0, 1.491867754444518e-1 ], 101 | [ 3.875265432361372e-1, 6.214474419314753e-1, -8.973985167612518e-3 ], 102 | [ -1.584149884933386e-2, -3.412293802851557e-2, 1.049964436877850e+0 ] 103 | ] 104 | }); 105 | 106 | Object.assign(Color.whites, { 107 | // whitepoint values from ASTM E308-01 with 10nm spacing, 1931 2 degree observer 108 | // all normalized to Y (luminance) = 1.00000 109 | // Illuminant A is a tungsten electric light, giving a very warm, orange light. 110 | A: [1.09850, 1.00000, 0.35585], 111 | 112 | // Illuminant C was an early approximation to daylight: illuminant A with a blue filter. 113 | C: [0.98074, 1.000000, 1.18232], 114 | 115 | // The daylight series of illuminants simulate natural daylight. 116 | // The color temperature (in degrees Kelvin/100) ranges from 117 | // cool, overcast daylight (D50) to bright, direct sunlight (D65). 118 | D55: [0.95682, 1.00000, 0.92149], 119 | D75: [0.94972, 1.00000, 1.22638], 120 | 121 | // Equal-energy illuminant, used in two-stage CAT16 122 | E: [1.00000, 1.00000, 1.00000], 123 | 124 | // The F series of illuminants represent flourescent lights 125 | F2: [0.99186, 1.00000, 0.67393], 126 | F7: [0.95041, 1.00000, 1.08747], 127 | F11: [1.00962, 1.00000, 0.64350], 128 | }); 129 | -------------------------------------------------------------------------------- /tests/multiply-matrices.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Matrix multiplication Tests 7 | 8 | 9 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 25 | 86 | 87 | 88 | 89 | 90 |

    Matrix multiplication Tests

    91 | 92 |
    93 |

    Basic 3x3 and vectors

    94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 108 | 109 | 110 | 113 | 114 | 115 | 118 | 119 | 120 | 123 | 124 | 125 | 128 | 129 |
    math.jsmultiplyMatrices()
    130 |
    131 | 132 |
    133 |

    Incorrect data

    134 | 135 |

    These are expected to fail, as multiplyMatrices does not dimension checking.The point of them is to see how it fails.

    136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 148 | 149 | 150 | 153 | 154 | 155 | 158 | 159 | 160 | 163 | 164 |
    math.jsmultiplyMatrices()
    165 |
    166 | 167 | 168 | 169 | -------------------------------------------------------------------------------- /src/spaces/jzazbz.js: -------------------------------------------------------------------------------- 1 | import Color, {util} from "./../color.js"; 2 | 3 | Color.defineSpace({ 4 | id: "jzazbz", 5 | cssid: "Jzazbz", 6 | name: "Jzazbz", 7 | coords: { 8 | Jz: [0, 1], 9 | az: [-0.5, 0.5], 10 | bz: [-0.5, 0.5] 11 | }, 12 | inGamut: coords => true, 13 | // Note that XYZ is relative to D65 14 | white: Color.whites.D65, 15 | b: 1.15, 16 | g: 0.66, 17 | n:2610 / (2 ** 14), 18 | ninv: (2 ** 14) / 2610, 19 | c1: 3424 / (2 ** 12), 20 | c2: 2413 / (2 ** 7), 21 | c3: 2392 / (2 ** 7), 22 | p: 1.7 * 2523 / (2 ** 5), 23 | pinv: (2 ** 5) / (1.7 * 2523), 24 | d: -0.56, 25 | d0: 1.6295499532821566E-11, 26 | XYZtoCone_M: [ 27 | [ 0.41478972, 0.579999, 0.0146480 ], 28 | [ -0.2015100, 1.120649, 0.0531008 ], 29 | [ -0.0166008, 0.264800, 0.6684799 ] 30 | ], 31 | // XYZtoCone_M inverted 32 | ConetoXYZ_M: [ 33 | [ 1.9242264357876067, -1.0047923125953657, 0.037651404030618 ], 34 | [ 0.35031676209499907, 0.7264811939316552, -0.06538442294808501 ], 35 | [ -0.09098281098284752, -0.3127282905230739, 1.5227665613052603 ] 36 | ], 37 | ConetoIab_M: [ 38 | [ 0.5, 0.5, 0 ], 39 | [ 3.524000, -4.066708, 0.542708 ], 40 | [ 0.199076, 1.096799, -1.295875 ] 41 | ], 42 | // ConetoIab_M inverted 43 | IabtoCone_M: [ 44 | [ 1, 0.1386050432715393, 0.05804731615611886 ], 45 | [ 0.9999999999999999, -0.1386050432715393, -0.05804731615611886 ], 46 | [ 0.9999999999999998, -0.09601924202631895, -0.8118918960560388 ] 47 | ], 48 | fromXYZ (XYZ) { 49 | 50 | const {b, g, n, p, c1, c2, c3, d, d0, XYZtoCone_M, ConetoIab_M} = this; 51 | 52 | // First make XYZ absolute, not relative to media white 53 | // Maximum luminance in PQ is 10,000 cd/m² 54 | // Relative XYZ has Y=1 for media white 55 | // BT.2048 says media white Y=203 at PQ 58 56 | 57 | // console.log({XYZ}); 58 | 59 | let [ Xa, Ya, Za ] = Color.spaces.absxyzd65.fromXYZ(XYZ); 60 | // console.log({Xa, Ya, Za}); 61 | 62 | 63 | // modify X and Y 64 | let Xm = (b * Xa) - ((b - 1) * Za); 65 | let Ym = (g * Ya) - ((g - 1) * Xa); 66 | // console.log({Xm, Ym, Za}); 67 | 68 | // move to LMS cone domain 69 | let LMS = util.multiplyMatrices(XYZtoCone_M, [ Xm, Ym, Za ]); 70 | // console.log({LMS}); 71 | 72 | // PQ-encode LMS 73 | let PQLMS = LMS.map (function (val) { 74 | let num = c1 + (c2 * ((val / 10000) ** n)); 75 | let denom = 1 + (c3 * ((val / 10000) ** n)); 76 | // console.log({val, num, denom}); 77 | return (num / denom) ** p; 78 | }); 79 | // console.log({PQLMS}); 80 | 81 | // almost there, calculate Iz az bz 82 | let [ Iz, az, bz] = util.multiplyMatrices(ConetoIab_M, PQLMS); 83 | // console.log({Iz, az, bz}); 84 | 85 | let Jz = ((1 + d) * Iz) / (1 + (d * Iz)) - d0; 86 | return [Jz, az, bz]; 87 | 88 | }, 89 | toXYZ(Jzazbz) { 90 | 91 | const {b, g, ninv, pinv, c1, c2, c3, d, d0, ConetoXYZ_M, IabtoCone_M} = this; 92 | 93 | let [Jz, az, bz] = Jzazbz; 94 | let Iz = (Jz + d0) / (1 + d - d * (Jz + d0)); 95 | // console.log({Iz}); 96 | 97 | // bring into LMS cone domain 98 | let PQLMS = util.multiplyMatrices(IabtoCone_M, [ Iz, az, bz ]); 99 | // console.log({PQLMS}); 100 | 101 | // convert from PQ-coded to linear-light 102 | let LMS = PQLMS.map(function (val){ 103 | let num = (c1 - (val ** pinv)); 104 | let denom = (c3 * (val ** pinv)) - c2; 105 | let x = 10000 * ((num / denom) ** ninv); 106 | // console.log({x, num, denom}) 107 | return (x); // luminance relative to diffuse white, [0, 70 or so]. 108 | }); 109 | // console.log({LMS}); 110 | 111 | // modified abs XYZ 112 | let [ Xm, Ym, Za ] = util.multiplyMatrices(ConetoXYZ_M, LMS); 113 | // console.log({sXm, Ym, Za}); 114 | 115 | // restore standard D50 relative XYZ, relative to media white 116 | let Xa = (Xm + ((b -1) * Za)) / b; 117 | let Ya = (Ym + ((g -1) * Xa)) / g; 118 | return Color.spaces.absxyzd65.toXYZ([ Xa, Ya, Za ]); 119 | }, 120 | parse (str, parsed = Color.parseFunction(str)) { 121 | if (parsed && parsed.name === "jzabz") { 122 | return { 123 | spaceId: "jzazbz", 124 | coords: parsed.args.slice(0, 3), 125 | alpha: parsed.args.slice(3)[0] 126 | }; 127 | } 128 | }, 129 | instance: { 130 | toString ({format, ...rest} = {}) { 131 | return Color.prototype.toString.call(this, {name: "jzazbz", format, ...rest}); 132 | } 133 | } 134 | }); 135 | 136 | export default Color; 137 | export {util}; 138 | -------------------------------------------------------------------------------- /docs/interpolation.md: -------------------------------------------------------------------------------- 1 | # Interpolation 2 | 3 | ## Ranges 4 | 5 | `color.range()` and `Color.range()` are at the core of Color.js’ interpolation engine. 6 | They give you a function that accepts a number and returns a color. 7 | For numbers in the range 0 to 1, the function _interpolates_; for numbers outside that range, the function _extrapolates_ (and thus, may not return the results you expect): 8 | 9 | ```js 10 | let color = new Color("p3", [0, 1, 0]); 11 | let redgreen = color.range("red", { 12 | space: "lch", // interpolation space 13 | outputSpace: "srgb" 14 | }); 15 | redgreen(.5); // midpoint 16 | ``` 17 | 18 | Percentages (0 to 100%) should be converted to numbers (0 to 1). 19 | 20 | The `space` parameter controls the [color space](spaces.html) 21 | interpolation occurs in, and defaults to Lab. 22 | Colors do not need to be in that space, they will be converted for interpolation. 23 | The interpolation space can make a big difference in the result: 24 | 25 | ```js 26 | let c1 = new Color("rebeccapurple"); 27 | let c2 = new Color("lch", [85, 100, 85]); 28 | c1.range(c2); // lab 29 | c1.range(c2, {space: "lch"}); 30 | c1.range(c2, {space: "srgb"}); // gamma encoded sRGB 31 | c1.range(c2, {space: "srgb-linear"}); //linear-light sRGB 32 | c1.range(c2, {space: "xyz"}); // XYZ, same result as linear RGB 33 | c1.range(c2, {space: "hsl"}); 34 | c1.range(c2, {space: "hwb"}); 35 | ``` 36 | 37 | Note that for color spaces with a hue angle there are multiple ways to interpolate, which can produce drastically different results. 38 | The `hue` argument is inspired by [the hue-adjuster in CSS Color 5](https://drafts.csswg.org/css-color-5/#hue-adjuster). 39 | 40 | ```js 41 | let c1 = new Color("rebeccapurple"); 42 | c1.lch; 43 | let c2 = new Color("lch", [85, 85, 85 + 720]); 44 | c1.range(c2, {space: "lch", hue: "longer"}); 45 | c1.range(c2, {space: "lch", hue: "shorter"}); 46 | c1.range(c2, {space: "lch", hue: "increasing"}); 47 | c1.range(c2, {space: "lch", hue: "decreasing"}); 48 | c1.range(c2, {space: "lch", hue: "raw"}); 49 | c1.range(c2, {space: "lch"}); // default is "shorter" 50 | ``` 51 | 52 | Range interpolates between colors as they were at the time of its creation. 53 | If you change the colors afterwards, the range will not be affected: 54 | 55 | ```js 56 | let color = new Color("red"); 57 | let color2 = new Color("black"); 58 | let gradient = color.range(color2); 59 | color.coords[1] = 1; 60 | color2.coords[2] = 1; 61 | gradient(.5); 62 | gradient = color.range(color2); 63 | gradient(.5); 64 | ``` 65 | 66 | Interpolating between a coordinate and `NaN` keeps that coordinate constant. 67 | This is useful for achromatic transitions: 68 | 69 | ```js 70 | let lime = new Color("p3", [0, 1, 0]); 71 | let white = new Color("lch", [100, 0, 0]); 72 | let white2 = new Color("lch", [100, 0, NaN]); 73 | let limewhite = lime.range(white, {space: "lch"}); 74 | let limewhite2 = lime.range(white2, {space: "lch"}); 75 | 76 | // Two kinds of fade out to transparent 77 | lime.range(new Color("transparent")); 78 | lime.range(new Color(lime.space, [NaN, NaN, NaN], 0), {space: lime.space}); 79 | ``` 80 | 81 | You can use the `progression` parameter to customize the progression and make it non-linear: 82 | ```js 83 | let r = new Color("lch(50 50 0)").range("lch(90 50 20)"); 84 | Color.range(r, {progression: p => p ** 3}); 85 | ``` 86 | 87 | Note that you can use `Color.range(rangeFunction)` to modify a range after it has been created, as you can see in the example above. 88 | This produces a new range, and leaves the old one unaffected. 89 | 90 | ## Interpolation by discrete steps 91 | 92 | `color.steps()` and `Color.steps()` give you an array of discrete steps. 93 | 94 | ```js 95 | let color = new Color("p3", [0, 1, 0]); 96 | color.steps("red", { 97 | space: "lch", 98 | outputSpace: "srgb", 99 | maxDeltaE: 3, // max deltaE between consecutive steps (optional) 100 | steps: 10 // min number of steps 101 | }); 102 | ``` 103 | 104 | By default, the deltaE76 function is used. 105 | 106 | ## Mixing colors 107 | 108 | Interpolation can be used to create color mixtures, 109 | in any desired proportion, 110 | between two colors. 111 | 112 | Shortcut for specific points in the range: 113 | ```js 114 | let color = new Color("p3", [0, 1, 0]); 115 | let redgreen = color.mix("red", .5, {space: "lch", outputSpace: "srgb"}); 116 | let reddishGreen = color.mix("red", .25, {space: "lch", outputSpace: "srgb"}); 117 | ``` 118 | 119 | Static syntax, for one-off mixing: 120 | ```js 121 | Color.mix("color(display-p3 0 1 0)", "red", .5); 122 | ``` 123 | -------------------------------------------------------------------------------- /api/index.tpl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | API Documentation 7 | 8 | 9 | @@include('_head.html') 10 | 11 | 12 | 13 | 14 | 15 | 19 | 20 | @@include('_header.html', { 21 | "title": "API Documentation" 22 | }) 23 |
    24 | 25 |
    26 |
    27 | 28 |
    29 | Collapse all: 30 | 34 | 38 | 42 |
    43 | 44 |
    45 | 46 |

    Mavo

    47 |

    48 | Inherits from 49 |

    50 |

    Defined in mavo.js

    51 |
    52 | 53 |

    `Mavo` is the main class. Every Mavo app is an instance of `Mavo` and `mavo.js` is the entry point of Mavo itself.

    54 | 55 |

    Members

    56 | 57 |
    58 | 59 | 60 | 61 |

    [if(role = 'constructor', 'new ' & className.name, if (static, className.name, lowercase(className.name)) & ".")]

    62 | 63 | [returnType] 64 | Static 65 | 66 |
    67 | 68 |

    69 | 70 |
    71 | Arguments ([count(argument.name)]) & Return Value 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 |
    NameTypeDescription
    82 | 83 | Optional 84 | 85 | 90 |
    Returns
    103 |
    104 | 105 |
    106 |
    107 | 108 | 115 |
    116 | 117 | 118 | 126 | 127 | @@include('_footer.html') 128 | 129 | 130 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require("gulp"); 2 | const rename = require("gulp-rename"); 3 | const postcss = require("gulp-postcss"); 4 | const rollup = require("rollup"); 5 | const { terser: terser } = require("rollup-plugin-terser"); 6 | const fileinclude = require("gulp-file-include"); 7 | const showdown = require("showdown"); 8 | 9 | const globs = { 10 | css: ["**/*.src.css", "!node_modules/**"], 11 | md: ["**/*.md", "!node_modules/**", "!README.md", "!CONTRIBUTING.md"], 12 | html: ["**/*.tpl.html"] 13 | }; 14 | 15 | gulp.task("css", function () { 16 | return gulp.src(globs.css) 17 | .pipe(postcss([ 18 | require("postcss-nesting")(), 19 | // require("postcss-selector-matches")({ 20 | // lineBreak: true 21 | // }), 22 | // require("autoprefixer")({ 23 | // browsers: ["last 2 versions"] 24 | // }) 25 | ])) 26 | .pipe(rename({ extname: "" })) 27 | .pipe(rename({ extname: ".css" })) 28 | .pipe(gulp.dest(".")); 29 | }); 30 | 31 | // https://metafizzy.co/blog/transfob-replaces-through2-gulp/ 32 | const Transform = require("stream").Transform; 33 | 34 | function transfob( _transform ) { 35 | var transform = new Transform({ 36 | objectMode: true 37 | }); 38 | transform._transform = _transform; 39 | return transform; 40 | }; 41 | 42 | // Loosely inspired from https://github.com/xieranmaya/gulp-showdown (unmaintained) 43 | function gulpShowdown(options = {}) { 44 | let defaultOptions = { 45 | extensions: ["apiLinks", "callouts"] 46 | // headerLevelStart: 2 47 | }; 48 | 49 | let converter = new showdown.Converter(Object.assign({}, defaultOptions, options)); 50 | converter.setFlavor("github"); 51 | converter.setOption("simpleLineBreaks", false); 52 | 53 | return transfob(function (file, encoding, callback) { 54 | let text = file.contents.toString(); 55 | 56 | // Move first h1 inside header 57 | let title; 58 | text = text.replace(/^#\s+(.+)$/m, (_, $1) => { 59 | title = $1; 60 | return ""; 61 | }); 62 | 63 | let html = converter.makeHtml(text); 64 | let isDocs = file.path.indexOf("/docs/") > -1; 65 | 66 | let relativePath = file.path.replace(__dirname, ""); 67 | 68 | html = ` 69 | 70 | 71 | ${title} • Color.js 72 | @@include('_head.html') 73 | ${isDocs? '' : ""} 74 | 75 | 76 | @@include('_header.html', { 77 | "title": "${title}" 78 | }) 79 |
    80 | ${isDocs? `` : ""} 85 | ${html} 86 | 87 | 92 |
    93 | 94 | @@include('_footer.html') 95 | 96 | `; 97 | file.contents = Buffer.from(html); 98 | 99 | callback(null, file); 100 | }); 101 | } 102 | 103 | gulp.task("md", async function() { 104 | const {default: extensions} = await import("./assets/js/showdown-extensions.mjs"); 105 | 106 | for (let id in extensions) { 107 | showdown.extension(id, () => [ 108 | extensions[id] 109 | ]); 110 | } 111 | 112 | return gulp.src(globs.md) 113 | .pipe(gulpShowdown()) 114 | .pipe(fileinclude({ 115 | basepath: "templates/" 116 | }).on("error", function(error) { 117 | console.error(error); 118 | })) 119 | .pipe(rename({ extname: ".html" })) 120 | .pipe(gulp.dest(".")); 121 | }); 122 | 123 | gulp.task("html", function() { 124 | return gulp.src(globs.html) 125 | .pipe(fileinclude({ 126 | basepath: "templates/" 127 | }).on("error", function(error) { 128 | console.error(error); 129 | })) 130 | .pipe(rename({ extname: "" })) 131 | .pipe(rename({ extname: ".html" })) 132 | .pipe(gulp.dest(".")); 133 | }); 134 | 135 | gulp.task("watch", function() { 136 | gulp.watch(globs.css, gulp.series("css")); 137 | gulp.watch(["./templates/*.html", ...globs.html], gulp.series("html")); 138 | gulp.watch(["./templates/*.html", ...globs.md], gulp.series("md")); 139 | }); 140 | 141 | function bundle(format, {minify} = {}) { 142 | let filename = "color"; 143 | 144 | if (format !== "iife") { 145 | filename += "." + format; 146 | } 147 | 148 | if (minify) { 149 | filename += ".min"; 150 | } 151 | 152 | return { 153 | file: `dist/${filename}.js`, 154 | name: "Color", 155 | format: format, 156 | sourcemap: true, 157 | exports: "named", /** Disable warning for default imports */ 158 | plugins: [ 159 | minify? terser({ 160 | compress: true, 161 | mangle: true 162 | }) : undefined 163 | ] 164 | }; 165 | } 166 | 167 | // Same as a rollup.config.js 168 | let rollupConfig = { 169 | input: "src/main.js", 170 | output: [ 171 | bundle("iife"), 172 | bundle("iife", {minify: true}), 173 | bundle("esm"), 174 | bundle("esm", {minify: true}), 175 | bundle("cjs"), 176 | bundle("cjs", {minify: true}) 177 | ], 178 | }; 179 | 180 | gulp.task("bundle", async function () { 181 | for (const bundle of rollupConfig.output) { 182 | let b = await rollup.rollup({ 183 | input: rollupConfig.input, 184 | plugins: bundle.plugins 185 | }); 186 | 187 | await b.write(bundle); 188 | } 189 | }); 190 | 191 | gulp.task("default", gulp.parallel("css", "bundle", "html", "md")); 192 | -------------------------------------------------------------------------------- /src/deltaE/deltaE2000.js: -------------------------------------------------------------------------------- 1 | import Color from "../color.js"; 2 | 3 | // deltaE2000 is a statistically significant improvement 4 | // and is recommended by the CIE and Idealliance 5 | // especially for color differences less than 10 deltaE76 6 | // but is wicked complicated 7 | // and many implementations have small errors! 8 | // DeltaE2000 is also discontinuous; in case this 9 | // matters to you, use deltaECMC instead. 10 | 11 | Color.prototype.deltaE2000 = function (sample, {kL = 1, kC = 1, kH = 1} = {}) { 12 | let color = this; 13 | sample = Color.get(sample); 14 | 15 | // Given this color as the reference 16 | // and the function parameter as the sample, 17 | // calculate deltaE 2000. 18 | 19 | // This implementation assumes the parametric 20 | // weighting factors kL, kC and kH 21 | // for the influence of viewing conditions 22 | // are all 1, as sadly seems typical. 23 | // kL should be increased for lightness texture or noise 24 | // and kC increased for chroma noise 25 | 26 | let [L1, a1, b1] = color.lab; 27 | let C1 = color.chroma; 28 | let [L2, a2, b2] = sample.lab; 29 | let C2 = sample.chroma; 30 | 31 | // Check for negative Chroma, 32 | // which might happen through 33 | // direct user input of LCH values 34 | 35 | if (C1 < 0) { 36 | C1 = 0; 37 | } 38 | if (C2 < 0) { 39 | C2 = 0; 40 | } 41 | 42 | let Cbar = (C1 + C2)/2; // mean Chroma 43 | 44 | // calculate a-axis asymmetry factor from mean Chroma 45 | // this turns JND ellipses for near-neutral colors back into circles 46 | let C7 = Math.pow(Cbar, 7); 47 | const Gfactor = Math.pow(25, 7); 48 | let G = 0.5 * (1 - Math.sqrt(C7/(C7+Gfactor))); 49 | 50 | // scale a axes by asymmetry factor 51 | // this by the way is why there is no Lab2000 colorspace 52 | let adash1 = (1 + G) * a1; 53 | let adash2 = (1 + G) * a2; 54 | 55 | // calculate new Chroma from scaled a and original b axes 56 | let Cdash1 = Math.sqrt(adash1 ** 2 + b1 ** 2); 57 | let Cdash2 = Math.sqrt(adash2 ** 2 + b2 ** 2); 58 | 59 | // calculate new hues, with zero hue for true neutrals 60 | // and in degrees, not radians 61 | const π = Math.PI; 62 | const r2d = 180 / π; 63 | const d2r = π / 180; 64 | let h1 = (adash1 === 0 && b1 === 0)? 0: Math.atan2(b1, adash1); 65 | let h2 = (adash2 === 0 && b2 === 0)? 0: Math.atan2(b2, adash2); 66 | 67 | if (h1 < 0) { 68 | h1 += 2 * π; 69 | } 70 | if (h2 < 0) { 71 | h2 += 2 * π; 72 | } 73 | 74 | h1 *= r2d; 75 | h2 *= r2d; 76 | 77 | // Lightness and Chroma differences; sign matters 78 | let ΔL = L2 - L1; 79 | let ΔC = Cdash2 - Cdash1; 80 | 81 | // Hue difference, getting the sign correct 82 | let hdiff = h2 - h1; 83 | let hsum = h1 + h2; 84 | let habs = Math.abs(hdiff); 85 | let Δh; 86 | 87 | if (Cdash1 * Cdash2 === 0) { 88 | Δh = 0; 89 | } 90 | else if (habs <= 180) { 91 | Δh = hdiff; 92 | } 93 | else if (hdiff > 180) { 94 | Δh = hdiff - 360; 95 | } 96 | else if (hdiff < -180) { 97 | Δh = hdiff + 360; 98 | } 99 | else { 100 | console.log("the unthinkable has happened"); 101 | } 102 | 103 | // weighted Hue difference, more for larger Chroma 104 | let ΔH = 2 * Math.sqrt(Cdash2 * Cdash1) * Math.sin(Δh * d2r / 2); 105 | 106 | // calculate mean Lightness and Chroma 107 | let Ldash = (L1 + L2)/2; 108 | let Cdash = (Cdash1 + Cdash2)/2; 109 | let Cdash7 = Math.pow(Cdash, 7); 110 | 111 | // Compensate for non-linearity in the blue region of Lab. 112 | // Four possibilities for hue weighting factor, 113 | // depending on the angles, to get the correct sign 114 | let hdash; 115 | if (Cdash1 * Cdash2 === 0) { 116 | hdash = hsum; // which should be zero 117 | } 118 | else if (habs <= 180) { 119 | hdash = hsum / 2; 120 | } 121 | else if (hsum < 360) { 122 | hdash = (hsum + 360) / 2; 123 | } 124 | else { 125 | hdash = (hsum - 360) / 2; 126 | } 127 | 128 | // positional corrections to the lack of uniformity of CIELAB 129 | // These are all trying to make JND ellipsoids more like spheres 130 | 131 | // SL Lightness crispening factor 132 | // a background with L=50 is assumed 133 | let lsq = (Ldash - 50) ** 2; 134 | let SL = 1 + ((0.015 * lsq) / Math.sqrt(20 + lsq)); 135 | 136 | // SC Chroma factor, similar to those in CMC and deltaE 94 formulae 137 | let SC = 1 + 0.045 * Cdash; 138 | 139 | // Cross term T for blue non-linearity 140 | let T = 1; 141 | T -= (0.17 * Math.cos(( hdash - 30) * d2r)); 142 | T += (0.24 * Math.cos( 2 * hdash * d2r)); 143 | T += (0.32 * Math.cos(((3 * hdash) + 6) * d2r)); 144 | T -= (0.20 * Math.cos(((4 * hdash) - 63) * d2r)); 145 | 146 | // SH Hue factor depends on Chroma, 147 | // as well as adjusted hue angle like deltaE94. 148 | let SH = 1 + 0.015 * Cdash * T; 149 | 150 | // RT Hue rotation term compensates for rotation of JND ellipses 151 | // and Munsell constant hue lines 152 | // in the medium-high Chroma blue region 153 | // (Hue 225 to 315) 154 | let Δθ = 30 * Math.exp(-1 * (((hdash - 275)/25) ** 2)); 155 | let RC = 2 * Math.sqrt(Cdash7/(Cdash7 + Gfactor)); 156 | let RT = -1 * Math.sin(2 * Δθ * d2r) * RC; 157 | 158 | // Finally calculate the deltaE, term by term as root sume of squares 159 | let dE = (ΔL / (kL * SL)) ** 2; 160 | dE += (ΔC / (kC * SC)) ** 2; 161 | dE += (ΔH / (kH * SH)) ** 2; 162 | dE += RT * (ΔC / (kC * SC)) * (ΔH / (kH * SH)); 163 | return Math.sqrt(dE); 164 | // Yay!!! 165 | }; 166 | 167 | Color.statify(["deltaE2000"]); 168 | -------------------------------------------------------------------------------- /tests/gamut.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Gamut mapping tests 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 25 | 26 | 27 | 28 | 29 |

    Gamut mapping Tests

    30 |

    These tests check how coords are shrunk to smaller gamuts.

    31 | 32 |
    33 |

    P3 primaries to sRGB

    34 | 35 | 36 | 37 | 40 | 43 | 44 | 45 | 46 | 49 | 52 | 53 | 54 | 55 | 58 | 59 | 62 | 63 | 64 | 65 | 68 | 71 | 72 | 73 | 74 | 77 | 80 | 81 | 82 | 83 | 86 | 87 | 90 | 91 | 92 | 93 | 96 | 99 | 100 | 101 | 102 | 105 | 108 | 109 | 110 | 111 | 114 | 117 | 118 | 119 | 120 | 123 | 126 | 127 | 128 | 129 | 132 | 135 | 136 | 137 | 138 | 141 | 144 | 145 | 146 | 147 | 150 | 153 | 154 | 155 | 156 | 159 | 162 | 163 |
    color(display-p3 1 0 0) 38 | 39 | 41 | rgb(98.20411139286732% 21.834053137266363% 0%) 42 |
    color(display-p3 1 0 0) 47 | 48 | 50 | rgb(100% 0% 0%) 51 |
    color(display-p3 1 0 0) 56 | 57 | 60 | rgb(100% 0% 0%) 61 |
    color(display-p3 0 1 0) 66 | 67 | 69 | rgb(0% 99.7921930734509% 0%) 70 |
    color(display-p3 0 1 0) 75 | 76 | 78 | rgb(0% 100% 0%) 79 |
    color(display-p3 0 1 0) 84 | 85 | 88 | rgb(0% 100% 0%) 89 |
    color(display-p3 0 0 1) 94 | 95 | 97 | rgb(0% 0% 100%) 98 |
    color(display-p3 0 0 1) 103 | 104 | 106 | rgb(0% 0% 100%) 107 |
    color(display-p3 1 1 0) 112 | 113 | 115 | rgb(100% 99.45446271521069% 0%) 116 |
    color(display-p3 1 1 0) 121 | 122 | 124 | rgb(100% 100% 0%) 125 |
    color(display-p3 0 1 1) 130 | 131 | 133 | rgb(0% 100% 98.93709142382755%) 134 |
    color(display-p3 0 1 1) 139 | 140 | 142 | rgb(0% 100% 100%) 143 |
    color(display-p3 1 0 1) 148 | 149 | 151 | rgb(100% 8.637212218104592% 98.22133121285436%) 152 |
    color(display-p3 1 0 1) 157 | 158 | 160 | rgb(100% 0% 100%) 161 |
    164 |
    165 | 166 | 167 | 195 | 196 | 197 | 198 | 199 | -------------------------------------------------------------------------------- /tests/parse.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Color parse tests 7 | 8 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 19 | 20 |

    Color parse Tests

    21 |

    These tests parse different color formats and compare the result as JSON.

    22 | 23 |
    24 |

    sRGB colors

    25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 |
    blue{"spaceId":"srgb","coords":[0,0,1],"alpha":1}
    transparent{"spaceId":"srgb","coords":[0,0,0],"alpha":0}
    #ff0066{"spaceId":"srgb","coords":[1,0,0.4],"alpha":1}
    #f06{"spaceId":"srgb","coords":[1,0,0.4],"alpha":1}
    #ff006688{"spaceId":"srgb","coords":[1,0,0.4],"alpha":0.5333333333333333}
    #f068{"spaceId":"srgb","coords":[1,0,0.4],"alpha":0.5333333333333333}
    rgba(0% 50% 200% / 0.5){"spaceId":"srgb","coords":[0,0.5,2],"alpha":0.5}
    rgba(0, 127.5, 300, 0.5){"spaceId":"srgb","coords":[0,0.5,1.1764705882352942],"alpha":0.5}
    59 |
    60 | 61 |
    62 |

    Lab and LCH colors

    63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 |
    lab(100% 0 0){"spaceId":"lab","coords":[100,0,0],"alpha":1}
    Lab(100% 0 0){"spaceId":"lab","coords":[100,0,0],"alpha":1}
    lab(100 -50 50){"spaceId":"lab","coords":[100,-50,50],"alpha":1}
    lch(100% 0 0){"spaceId":"lch","coords":[100,0,0],"alpha":1}
    lch(100 50 50){"spaceId":"lch","coords":[100,50,50],"alpha":1}
    lch(100 50 450){"spaceId":"lch","coords":[100,50,450],"alpha":1}
    90 |
    91 | 92 |
    93 |

    color()

    94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 |
    color(display-p3 0 .5 1){"spaceId":"p3","coords":[0,0.5,1],"alpha":1}
    color(a98-rgb 0 .5 1){"spaceId":"a98rgb","coords":[0,0.5,1],"alpha":1}
    color(display-p3 0 1 0 / .5){"spaceId":"p3","coords":[0,1,0],"alpha":0.5}
    color(display-p3){"spaceId":"p3","coords":[0,0,0],"alpha":1}
    color(display-p3 / .5){"spaceId":"p3","coords":[0,0,0],"alpha":0.5}
    color(display-p3 1){"spaceId":"p3","coords":[1,0,0],"alpha":1}
    color(display-p3 1 / .5){"spaceId":"p3","coords":[1,0,0],"alpha":0.5}
    128 |
    129 | 130 |
    131 |

    hsl()

    132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 |
    hsl(180, 50%, 50%){"spaceId":"hsl","coords":[180,50,50],"alpha":1}
    hsl(-180, 50%, 50%){"spaceId":"hsl","coords":[-180,50,50],"alpha":1}
    hsl(900, 50%, 50%){"spaceId":"hsl","coords":[900,50,50],"alpha":1}
    hsl(0deg 0% 0% / .5){"spaceId":"hsl","coords":[0,0,0],"alpha":0.5}
    151 |
    152 | 153 | 173 | 174 | 175 | 176 | 177 | -------------------------------------------------------------------------------- /get/index.tpl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Get Color.js 7 | 8 | @@include('_head.html') 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | @@include('_header.html', { 18 | "title": "Get Color.js" 19 | }) 20 |
    21 | 22 |

    Quick use

    23 | 24 |

    If you just want to play around ASAP, the quickest way is to import the entire library, either as a module in your JS:

    25 | 26 |
    import Color from "https://colorjs.io/dist/color.esm.js";
    27 | 28 |

    To be able to use import statements in your JS, your <script> element needs type="module"

    29 | 30 | Or, if you'd rather just have Color as a global variable, the classic way, just include the following script in your HTML: 31 | 32 |
    <script src="https://colorjs.io/dist/color.js"></script>
    33 | 34 |

    You can also add .min right before the .js extension to get a minified file. 35 | 36 |

    Via npm

    37 | 38 |

    Run:

    39 | 40 |
    npm install colorjs.io
    41 | 42 |

    Custom bundle

    43 | 44 |

    Or, create a custom bundle that is tailored to your needs.

    45 | 46 |

    This is a work in progress and doesn't fully work yet. 47 | Right now it can generate import statements, but not an actual bundle.

    48 | 49 |
    50 | 51 |
    52 | Core modules 53 | 54 |
      55 |
    • 56 | 62 |
    • 63 |
    64 |
    65 | 66 |
    67 | Color spaces 68 | 69 |
      70 |
    • 71 | 77 |
      78 |
    • 79 |
    80 |
    81 | 82 |
    83 | Optional modules 84 | 85 |
      86 |
    • 87 | 94 |
      95 |
    • 96 |
    97 |
    98 | 99 |
    100 | Global or ES6 module? 101 | 102 |
      103 |
    • 104 | 108 |
    • 109 |
    • 110 | 114 |
    • 115 |
    116 |
    117 | 118 | 136 | 137 |
    
    138 | [join(importStatement where include, '\n')]
    139 | [if(format = "iife", 'window.Color = window.Color || Color;', '')]
    140 | // Re-export
    141 | export default Color;
    142 | 
    143 | 144 | Download 145 | 146 |
    147 | 148 |
    149 |

    How to use your custom bundle

    150 | 151 |

    The following assume your downloaded file is in the same directory as the JS/HTML file using it. 152 | If not, adjust the path accordingly.

    153 | 154 |

    If using client-side, include in your page via:

    155 | 156 |
    
    157 | 		<script src="color.js" type="module"></script>
    158 | 	
    159 | 160 |

    Or, include in your JS via:

    161 | 162 |
    import Color from "./color.js"
    163 | 164 |

    To be able to use import statements in your JS, your <script> element needs type="module"

    165 |
    166 | 167 |
    168 | 169 | @@include('_footer.html') 170 | 171 | 172 | 173 | -------------------------------------------------------------------------------- /docs/output.md: -------------------------------------------------------------------------------- 1 | # Output 2 | 3 | Eventually, no matter what your calculations are, you will want to display some kind of output, most likely on a web page, 4 | frequently in CSS. This page is all about that. 5 | 6 | ## Getting a string representation of a color 7 | 8 | `color.toString()` is a swiss army knife for many of your serialization needs. 9 | In fact, you may have used it without knowing, since Javascript calls it implicitly with no params when you coerce an object to a string: 10 | 11 | ```js 12 | let magenta = new Color("srgb", [1, 0, .4]); 13 | "I love " + magenta; 14 | ``` 15 | 16 | While this may suffice for some uses, in many cases you will want to provide parameters to customize the result. 17 | Here are a few examples. 18 | 19 | ### Disable gamut mapping 20 | 21 | ```js 22 | let funkyMagenta = new Color("p3", [1, 0, .4]); 23 | funkyMagenta = funkyMagenta.to("srgb"); 24 | funkyMagenta.toString(); // gamut mapping by default 25 | funkyMagenta.toString({inGamut: false}); // disable gamut mapping 26 | ``` 27 | 28 | Note that you cannot disable gamut mapping in `certain color spaces whose conversion math doesn't make sense for out of gamut values. 29 | These are typically the polar forms of gamma-corrected sRGB (HSL, HWB, HSV etc). 30 | 31 | ### Change precision 32 | 33 | By default, values are rounded to 5 significant digits. 34 | You can change that with the `precision` parameter: 35 | 36 | ```js 37 | let pink = new Color("lch", [70, 50, 350]); 38 | pink = pink.to("srgb"); 39 | pink.toString(); 40 | pink.toString({precision: 1}); 41 | pink.toString({precision: 2}); 42 | pink.toString({precision: 3}); 43 | pink.toString({precision: 21}); 44 | ``` 45 | 46 | Tip: Building an app that needs high precision? You can change the default precision of 5 globally by setting `Color.defaults.precision`! 47 | 48 | ### Get a CSS color value that is actually supported by the current browser 49 | 50 | When using sRGB or HSL, you can just use the output of `color.toString()` directly in CSS. 51 | However, with many color spaces, and most browsers, this is not the case yet. 52 | 53 | One way to go about with this is to check if the value is supported and convert it if not: 54 | 55 | ```js 56 | let green = new Color("lch", [80, 80, 120]); 57 | let cssColor = green.toString(); 58 | if (!CSS.supports("color", cssColor)) 59 | cssColor = green.to("srgb").toString(); 60 | ``` 61 | 62 | This works fairly well, but the browser may support a wider gamut than sRGB, and it forces everything into sRGB. 63 | An iterative approach may be better: 64 | 65 | ```js 66 | let green = new Color("lch", [80, 80, 120]); 67 | let cssColor = green.toString(); 68 | if (!CSS.supports("color", cssColor)) 69 | cssColor = green.to("p3").toString(); 70 | if (!CSS.supports("color", cssColor)) 71 | cssColor = green.to("srgb").toString(); 72 | cssColor; 73 | ``` 74 | As of June 2020, `cssColor` will be sRGB in Chrome and Firefox, and P3 in Safari, providing access to 50% more colors than sRGB! 75 | 76 | So, this works, but the process is a little tedious. Thankfully, Color.js has got your back! 77 | Simply use the `fallback` parameter. 78 | If set to `true` it will use the default set of fallbacks (P3, then sRGB), but you can also provide your own. 79 | Let's rewrite the example above using the `fallback` parameter! 80 | 81 | ```js 82 | let green = new Color("lch", [80, 80, 120]); 83 | let cssColor = green.toString({fallback: true}); 84 | let cssColor2 = green.toString({fallback: ["p3", "hsl"]}); 85 | ``` 86 | 87 | Tip: You can change the default set of fallbacks by setting `Color.defaults.fallbackSpaces`. 88 | 89 | What if you want access to the converted color? For example, you may want to indicate whether it was in gamut or not. 90 | You can access the `color` property on the returned value: 91 | 92 | ```js 93 | let green = new Color("lch", [80, 90, 120]); 94 | let cssColor = green.toString({fallback: true}); 95 | cssColor.color.inGamut(); 96 | ``` 97 | 98 | Note: While `color.toString()` returns a primitive string in most cases, when `fallback` is used it returns a `String` object 99 | so that it can have a property (primitives in Javascript cannot have properties). 100 | 101 | ## Creating a CSS gradient from a range 102 | 103 | When working with [ranges](interpolation), you may often need to display the range as a CSS gradient. 104 | The trick here is to grab as many steps as you need via `color.steps()`, then use them as gradient color stops. 105 | If you don't know how many steps you need, this is what the `maxDeltaE` parameter is for, as it lets you specify the maximum allowed deltaE between consecutive colors. 106 | 107 |
    108 | 109 | ```js 110 | let r = Color.range("hsl(330 90% 50%)", "hotpink"); 111 | let stops = Color.steps(r, {steps: 5, maxDeltaE: 3}); 112 | let element = document.querySelector("#test"); 113 | element.style.background = `linear-gradient(to right, ${ 114 | stops.join(", ") 115 | })`; 116 | ``` 117 | 118 | Play with the parameters above to see what gradient is produced, or use the [gradients demo app](/apps/gradients)! 119 | 120 | Note that in the example above, `color.toString()` is called implicitly with no params due to `array.join()`. 121 | You can also map the colors to strings yourself: 122 | 123 |
    124 | 125 | ```js 126 | let r = Color.range("rebeccapurple", "gold"); 127 | let stops = Color.steps(r, {steps: 10}); 128 | let element = document.querySelector("#test2"); 129 | element.style.background = `linear-gradient(to right, ${ 130 | stops.map(c => c.toString({precision: 2})).join(", ") 131 | })`; 132 | ``` 133 | -------------------------------------------------------------------------------- /src/interpolation.js: -------------------------------------------------------------------------------- 1 | import Color, {util} from "./color.js"; 2 | import * as angles from "./angles.js"; 3 | 4 | let methods = { 5 | range (...args) { 6 | return Color.range(this, ...args); 7 | }, 8 | 9 | /** 10 | * Return an intermediate color between two colors 11 | * Signatures: color.mix(color, p, options) 12 | * color.mix(color, options) 13 | * color.mix(color) 14 | */ 15 | mix (color, p = .5, o = {}) { 16 | if (util.type(p) === "object") { 17 | [p, o] = [.5, p]; 18 | } 19 | 20 | let {space, outputSpace} = o; 21 | 22 | color = Color.get(color); 23 | let range = this.range(color, {space, outputSpace}); 24 | return range(p); 25 | }, 26 | 27 | /** 28 | * Interpolate to color2 and return an array of colors 29 | * @returns {Array[Color]} 30 | */ 31 | steps (...args) { 32 | return Color.steps(this, ...args); 33 | } 34 | }; 35 | 36 | Color.steps = function(color1, color2, options = {}) { 37 | let range; 38 | 39 | if (isRange(color1)) { 40 | // Tweaking existing range 41 | [range, options] = [color1, color2]; 42 | [color1, color2] = range.rangeArgs.colors; 43 | } 44 | 45 | let { 46 | maxDeltaE, deltaEMethod, 47 | steps = 2, maxSteps = 1000, 48 | ...rangeOptions 49 | } = options; 50 | 51 | if (!range) { 52 | color1 = Color.get(color1); 53 | color2 = Color.get(color2); 54 | range = Color.range(color1, color2, rangeOptions); 55 | } 56 | 57 | let totalDelta = this.deltaE(color2); 58 | let actualSteps = maxDeltaE > 0? Math.max(steps, Math.ceil(totalDelta / maxDeltaE) + 1) : steps; 59 | let ret = []; 60 | 61 | if (maxSteps !== undefined) { 62 | actualSteps = Math.min(actualSteps, maxSteps); 63 | } 64 | 65 | if (actualSteps === 1) { 66 | ret = [{p: .5, color: range(.5)}]; 67 | } 68 | else { 69 | let step = 1 / (actualSteps - 1); 70 | ret = Array.from({length: actualSteps}, (_, i) => { 71 | let p = i * step; 72 | return {p, color: range(p)}; 73 | }); 74 | } 75 | 76 | if (maxDeltaE > 0) { 77 | // Iterate over all stops and find max deltaE 78 | let maxDelta = ret.reduce((acc, cur, i) => { 79 | if (i === 0) { 80 | return 0; 81 | } 82 | 83 | let deltaE = cur.color.deltaE(ret[i - 1].color, deltaEMethod); 84 | return Math.max(acc, deltaE); 85 | }, 0); 86 | 87 | while (maxDelta > maxDeltaE) { 88 | // Insert intermediate stops and measure maxDelta again 89 | // We need to do this for all pairs, otherwise the midpoint shifts 90 | maxDelta = 0; 91 | 92 | for (let i = 1; (i < ret.length) && (ret.length < maxSteps); i++) { 93 | let prev = ret[i - 1]; 94 | let cur = ret[i]; 95 | 96 | let p = (cur.p + prev.p) / 2; 97 | let color = range(p); 98 | maxDelta = Math.max(maxDelta, color.deltaE(prev.color), color.deltaE(cur.color)); 99 | ret.splice(i, 0, {p, color: range(p)}); 100 | i++; 101 | } 102 | } 103 | } 104 | 105 | ret = ret.map(a => a.color); 106 | 107 | return ret; 108 | }; 109 | 110 | /** 111 | * Interpolate to color2 and return a function that takes a 0-1 percentage 112 | * @returns {Function} 113 | */ 114 | Color.range = function(color1, color2, options = {}) { 115 | if (isRange(color1)) { 116 | // Tweaking existing range 117 | let [range, options] = [color1, color2]; 118 | return Color.range(...range.rangeArgs.colors, {...range.rangeArgs.options, ...options}); 119 | } 120 | 121 | let {space, outputSpace, progression, premultiplied} = options; 122 | 123 | // Make sure we're working on copies of these colors 124 | color1 = new Color(color1); 125 | color2 = new Color(color2); 126 | 127 | 128 | let rangeArgs = {colors: [color1, color2], options}; 129 | 130 | if (space) { 131 | space = Color.space(space); 132 | } 133 | else { 134 | space = Color.spaces[Color.defaults.interpolationSpace] || color1.space; 135 | } 136 | 137 | outputSpace = outputSpace? Color.space(outputSpace) : (color1.space || space); 138 | 139 | color1 = color1.to(space).toGamut(); 140 | color2 = color2.to(space).toGamut(); 141 | 142 | // Handle hue interpolation 143 | // See https://github.com/w3c/csswg-drafts/issues/4735#issuecomment-635741840 144 | if (space.coords.hue && space.coords.hue.isAngle) { 145 | let arc = options.hue = options.hue || "shorter"; 146 | 147 | [color1[space.id].hue, color2[space.id].hue] = angles.adjust(arc, [color1[space.id].hue, color2[space.id].hue]); 148 | } 149 | 150 | if (premultiplied) { 151 | // not coping with polar spaces yet 152 | color1.coords = color1.coords.map (c => c * color1.alpha); 153 | color2.coords = color2.coords.map (c => c * color2.alpha); 154 | } 155 | 156 | return Object.assign(p => { 157 | p = progression? progression(p) : p; 158 | let coords = color1.coords.map((start, i) => { 159 | let end = color2.coords[i]; 160 | return interpolate(start, end, p); 161 | }); 162 | let alpha = interpolate(color1.alpha, color2.alpha, p); 163 | let ret = new Color(space, coords, alpha); 164 | 165 | if (premultiplied) { 166 | // undo premultiplication 167 | ret.coords = ret.coords.map(c => c / alpha); 168 | } 169 | 170 | if (outputSpace !== space) { 171 | ret = ret.to(outputSpace); 172 | } 173 | 174 | return ret; 175 | }, { 176 | rangeArgs 177 | }); 178 | }; 179 | 180 | export function isRange (val) { 181 | return util.type(val) === "function" && val.rangeArgs; 182 | }; 183 | 184 | // Helper 185 | function interpolate(start, end, p) { 186 | if (isNaN(start)) { 187 | return end; 188 | } 189 | 190 | if (isNaN(end)) { 191 | return start; 192 | } 193 | 194 | return start + (end - start) * p; 195 | } 196 | 197 | Object.assign(Color.defaults, { 198 | interpolationSpace: "lab" 199 | }); 200 | 201 | Object.assign(Color.prototype, methods); 202 | Color.statify(Object.keys(methods)); 203 | 204 | export default Color; 205 | -------------------------------------------------------------------------------- /src/spaces/ictcp.js: -------------------------------------------------------------------------------- 1 | import Color, {util} from "./rec2020.js"; 2 | 3 | const rec2020 = Color.spaces.rec2020; 4 | 5 | Color.defineSpace({ 6 | // Only the PQ form of ICtCp is implemented here. There is also an HLG form. 7 | // from Dolby, "WHAT IS ICTCP?" 8 | // https://professional.dolby.com/siteassets/pdfs/ictcp_dolbywhitepaper_v071.pdf 9 | // and 10 | // Dolby, "Perceptual Color Volume 11 | // Measuring the Distinguishable Colors of HDR and WCG Displays" 12 | // https://professional.dolby.com/siteassets/pdfs/dolby-vision-measuring-perceptual-color-volume-v7.1.pdf 13 | id: "ictcp", 14 | name: "ICTCP", 15 | // From BT.2100-2 page 7: 16 | // During production, signal values are expected to exceed the 17 | // range E′ = [0.0 : 1.0]. This provides processing headroom and avoids 18 | // signal degradation during cascaded processing. Such values of E′, 19 | // below 0.0 or exceeding 1.0, should not be clipped during production 20 | // and exchange. 21 | // Values below 0.0 should not be clipped in reference displays (even 22 | // though they represent “negative” light) to allow the black level of 23 | // the signal (LB) to be properly set using test signals known as “PLUGE” 24 | coords: { 25 | I: [0, 1], // Constant luminance 26 | CT: [-0.5, 0.5], // Full BT.2020 gamut in range [-0.5, 0.5] 27 | CP: [-0.5, 0.5] 28 | }, 29 | inGamut: coords => true, 30 | // Note that XYZ is relative to D65 31 | white: Color.whites.D65, 32 | c1: 3424 / 4096, 33 | c2: 2413 / 128, 34 | c3: 2392 / 128, 35 | m1: 2610 / 16384, 36 | m2: 2523 / 32, 37 | im1: 16384 / 2610, 38 | im2: 32 / 2523, 39 | // The matrix below includes the 4% crosstalk components 40 | // and is from the Dolby "What is ICtCp" paper" 41 | XYZtoLMS_M: [ 42 | [ 0.3592, 0.6976, -0.0358], 43 | [-0.1922, 1.1004, 0.0755], 44 | [ 0.0070, 0.0749, 0.8434] 45 | ], 46 | // linear-light Rec.2020 to LMS, again with crosstalk 47 | // rational terms from Jan Fröhlich, 48 | // Encoding High Dynamic Range andWide Color Gamut Imagery, p.97 49 | // and ITU-R BT.2124-0 p.2 50 | Rec2020toLMS_M: [ 51 | [ 1688 / 4096, 2146 / 4096, 262 / 4096 ], 52 | [ 683 / 4096, 2951 / 4096, 462 / 4096 ], 53 | [ 99 / 4096, 309 / 4096, 3688 / 4096 ] 54 | ], 55 | // this includes the Ebner LMS coefficients, 56 | // the rotation, and the scaling to [-0.5,0.5] range 57 | // rational terms from Fröhlich p.97 58 | // and ITU-R BT.2124-0 pp.2-3 59 | LMStoIPT_M: [ 60 | [ 2048 / 4096, 2048 / 4096, 0 ], 61 | [ 6610 / 4096, -13613 / 4096, 7003 / 4096 ], 62 | [ 17933 / 4096, -17390 / 4096, -543 / 4096 ] 63 | ], 64 | // inverted matrices, calculated from the above 65 | IPTtoLMS_M: [ 66 | [0.99998889656284013833, 0.00860505014728705821, 0.1110343715986164786 ], 67 | [1.0000111034371598616, -0.00860505014728705821, -0.1110343715986164786 ], 68 | [1.000032063391005412, 0.56004913547279000113, -0.32063391005412026469], 69 | ], 70 | LMStoRec2020_M: [ 71 | [ 3.4375568932814012112, -2.5072112125095058195, 0.069654319228104608382], 72 | [-0.79142868665644156125, 1.9838372198740089874, -0.19240853321756742626 ], 73 | [-0.025646662911506476363, -0.099240248643945566751, 1.1248869115554520431 ] 74 | ], 75 | LMStoXYZ_M: [ 76 | [ 2.0701800566956135096, -1.3264568761030210255, 0.20661600684785517081 ], 77 | [ 0.36498825003265747974, 0.68046736285223514102, -0.045421753075853231409], 78 | [-0.049595542238932107896, -0.049421161186757487412, 1.1879959417328034394 ] 79 | ], 80 | fromXYZ (XYZ) { 81 | 82 | const {XYZtoLMS_M} = this; 83 | // console.log ({c1, c2, c3, m1, m2}); 84 | 85 | // Make XYZ absolute, not relative to media white 86 | // Maximum luminance in PQ is 10,000 cd/m² 87 | // Relative XYZ has Y=1 for media white 88 | // BT.2048 says media white Y=203 at PQ 58 89 | // This also does the D50 to D65 adaptation 90 | 91 | let [ Xa, Ya, Za ] = Color.spaces.absxyzd65.fromXYZ(XYZ); 92 | // console.log({Xa, Ya, Za}); 93 | 94 | // move to LMS cone domain 95 | let LMS = util.multiplyMatrices(XYZtoLMS_M, [ Xa, Ya, Za ]); 96 | // console.log({LMS}); 97 | 98 | return this.LMStoICtCp(LMS); 99 | }, 100 | toXYZ (ICtCp) { 101 | 102 | const {LMStoXYZ_M} = this; 103 | 104 | let LMS = this.ICtCptoLMS(ICtCp); 105 | 106 | let XYZa = util.multiplyMatrices(LMStoXYZ_M, LMS); 107 | 108 | // convert from Absolute, D65 XYZ to media white relative, D50 XYZ 109 | return Color.spaces.absxyzd65.toXYZ(XYZa); 110 | 111 | }, 112 | LMStoICtCp (LMS) { 113 | 114 | const {LMStoIPT_M, c1, c2, c3, m1, m2} = this; 115 | // console.log ({c1, c2, c3, m1, m2}); 116 | 117 | // apply the PQ EOTF 118 | // we can't ever be dividing by zero because of the "1 +" in the denominator 119 | let PQLMS = LMS.map (function (val) { 120 | let num = c1 + (c2 * ((val / 10000) ** m1)); 121 | let denom = 1 + (c3 * ((val / 10000) ** m1)); 122 | // console.log({val, num, denom}); 123 | return (num / denom) ** m2; 124 | }); 125 | // console.log({PQLMS}); 126 | 127 | // LMS to IPT, with rotation for Y'C'bC'r compatibility 128 | return util.multiplyMatrices(LMStoIPT_M, PQLMS); 129 | }, 130 | ICtCptoLMS (ICtCp) { 131 | 132 | const {IPTtoLMS_M, c1, c2, c3, im1, im2} = this; 133 | 134 | let PQLMS = util.multiplyMatrices(IPTtoLMS_M, ICtCp); 135 | 136 | // From BT.2124-0 Annex 2 Conversion 3 137 | let LMS = PQLMS.map (function (val) { 138 | let num = Math.max((val ** im2) - c1, 0); 139 | let denom = (c2 - (c3 * (val ** im2))); 140 | return 10000 * ((num / denom) ** im1); 141 | }); 142 | 143 | return LMS; 144 | } 145 | // }, 146 | // from: { 147 | // rec2020: function() { 148 | 149 | // } 150 | // }, 151 | // to: { 152 | // rec2020: function() { 153 | 154 | // } 155 | // } 156 | }); 157 | 158 | export default Color; 159 | -------------------------------------------------------------------------------- /docs/color-difference.md: -------------------------------------------------------------------------------- 1 | # Color differences 2 | 3 | ## Euclidean distance 4 | 5 | We often need to determine the distance between two colors, for a variety of use cases. 6 | Before most people dive into color science, when they are only familiar with sRGB colors, 7 | their first attempt to do so usually is the Euclidean distance of colors in sRGB, 8 | like so: `sqrt((r₁ - r₂)² + (g₁ - g₂)² + (b₁ - b₂)²)`. 9 | However, since sRGB is not [perceptually uniform](https://programmingdesignsystems.com/color/perceptually-uniform-color-spaces/), 10 | pairs of colors with the same Euclidean distance can have hugely different perceptual differences: 11 | 12 |
    13 |
    14 |
    15 |
    16 | 17 | ```js 18 | let color1 = new Color("hsl(30, 100%, 50%)"); 19 | let color2 = new Color("hsl(50, 100%, 50%)"); 20 | let color3 = new Color("hsl(230, 100%, 50%)"); 21 | let color4 = new Color("hsl(260, 100%, 50%)"); 22 | color1.distance(color2, "srgb"); 23 | color3.distance(color4, "srgb"); 24 | ``` 25 | 26 | Notice that even though `color3` and `color4` are far closer than `color1` and `color2`, their sRGB Euclidean distance is slightly larger! 27 | 28 | Euclidean distance *can* be very useful in calculating color difference, as long as the measurement is done in a perceptually uniform color space, such as Lab, ICtCp or Jzazbz: 29 | 30 | ```js 31 | let color1 = new Color("hsl(30, 100%, 50%)"); 32 | let color2 = new Color("hsl(50, 100%, 50%)"); 33 | let color3 = new Color("hsl(230, 100%, 50%)"); 34 | let color4 = new Color("hsl(260, 100%, 50%)"); 35 | color1.distance(color2, "lab"); 36 | color3.distance(color4, "lab"); 37 | 38 | color1.distance(color2, "jzazbz"); 39 | color3.distance(color4, "jzazbz"); 40 | ``` 41 | 42 | ## Delta E (ΔE) 43 | 44 | DeltaE (ΔE) is a family of algorithms specifically for calculating the difference (delta) between two colors. 45 | The very first version of DeltaE, [DeltaE 1976](https://en.wikipedia.org/wiki/Color_difference#CIE76) was simply the Euclidean distance of the colors in Lab: 46 | 47 | ```js 48 | let color1 = new Color("hsl(30, 100%, 50%)"); 49 | let color2 = new Color("hsl(50, 100%, 50%)"); 50 | let color3 = new Color("hsl(230, 100%, 50%)"); 51 | let color4 = new Color("hsl(260, 100%, 50%)"); 52 | 53 | color1.deltaE76(color2); 54 | color3.deltaE76(color4); 55 | ``` 56 | 57 | However, because Lab turned out to not be as perceptually uniform as it was once thought, the algorithm was revised in 1984 (CMC), 1994, and lastly, 2000, with the most accurate and most complicated Lab-based DeltaE algorithm to date. 58 | 59 | Instead of handling the remaining perceptual non-uniformities of Lab in the color difference equation, 60 | another option is to use a better color model and perform a simpler color difference calculation in that space. 61 | 62 | Examples include DeltaEJz (which uses JzCzhz) and deltaEITP (which uses ICtCp). An additional benefit of these two color difference formulae is that, unlike Lab which is mostly tested with medium to low chroma, reflective surface colors, JzCzhz and ICtCp are designed to be used with light-emitting devices (screens), high chroma colors often found in Wide Gamut content, and a much larger range of luminances as found in High Dynamic Range content. 63 | 64 | Color.js supports all the DeltaE algorithms mentioned above except DeltaE 94. Each DeltaE algorithm comes with its own method (e.g. `color1.deltaECMC(color2)`), 65 | as well as a parameterized syntax (e.g. `color1.deltaE(color2, "CMC")`) which falls back to DeltaE 76 when the requested algorithm is not available, or the second argument is missing. 66 | 67 | Note: If you are not using the Color.js bundle that includes everything, you will need to import the modules for DeltaE CMC, DeltaE 2000, DeltaEJz and DeltaEITP manually. DeltaE 76 is supported in the core Color.js. 68 | 69 | ```js 70 | // These are not needed if you're just using the bundle 71 | // and will be omitted in other code examples 72 | import "https://colorjs.io/src/deltaE/deltaECMC.js"; 73 | import "https://colorjs.io/src/deltaE/deltaE2000.js"; 74 | import "https://colorjs.io/src/deltaE/deltaEITP.js"; 75 | 76 | let color1 = new Color("blue"); 77 | let color2 = new Color("lab", [30, 30, 30]); 78 | let color3 = new Color("lch", [40, 50, 60]); 79 | 80 | color1.deltaE(color2, "76"); 81 | color2.deltaE(color3, "76"); 82 | 83 | color1.deltaE(color2, "CMC"); 84 | color2.deltaE(color3, "CMC"); 85 | 86 | color1.deltaE(color2, "2000"); 87 | color2.deltaE(color3, "2000"); 88 | 89 | color1.deltaE(color2, "ITP"); 90 | color2.deltaE(color3, "ITP"); 91 | ``` 92 | 93 | For most DeltaE algorithms, 2.3 is considered the "Just Noticeable Difference" (JND). 94 | Can you notice a difference in the two colors below? 95 | 96 | ```js 97 | let color1 = new Color("lch", [40, 50, 60]); 98 | let color2 = new Color("lch", [40, 50, 60]); 99 | 100 | color1.deltaE(color2, "76"); 101 | color1.deltaE(color2, "CMC"); 102 | color1.deltaE(color2, "2000"); 103 | ``` 104 | 105 | ## Setting the default DeltaE algorithm 106 | 107 | Notice that even if you include better DeltaE algorithms such as ΔΕ2000, 108 | the default DeltaE algorithm used in every function that accepts a deltaE argument (e.g `Color#steps()`) or `color.deltaE()` with no method parameter will remain DeltaE 1976. 109 | This is because Color.js doesn't necessarily know which DeltaE method is better for your use case. 110 | E.g. for high performance code, you may prefer the speed over accuracy tradeoff of DeltaE 1976. 111 | You can however change this default: 112 | 113 | ```js 114 | let color1 = new Color("blue"); 115 | let color2 = new Color("lch", [20, 50, 230]); 116 | color1.deltaE(color2); 117 | Color.defaults.deltaE = "2000"; 118 | color1.deltaE(color2); 119 | ``` 120 | -------------------------------------------------------------------------------- /index.tpl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Color.js 6 | @@include('_head.html') 7 | 8 | 9 | 10 | 11 | 12 | 13 |
    14 | 17 | 18 | 23 | 24 |
    25 |
    26 |

    Fully color space aware

    27 |

    Each color belongs to a color space; operations are color space agnostic. 28 | Modules for a wide variety of color spaces, including Lab/LCH, sRGB and friends (HSL/HSV/HWB), Display P3, Jzazbz, REC.2100 and more.

    29 |
    30 |
    31 |

    Doesn't gloss over color science

    32 |

    Actual gamut mapping instead of naïve clipping, 33 | multiple DeltaE methods (76, CMC, 2000, Jz), 34 | multiple chromatic adaptation methods (von Kries, Bradford, CAT02, CAT16), 35 | all with sensible defaults 36 |

    37 |
    38 |
    39 |

    Up to date with CSS Color 4

    40 |

    Every CSS Color 4 format & color space supported for both input and output, whether your browser supports it or not.

    41 |
    42 |
    43 |

    Readable, object-oriented API

    44 |

    Color objects for multiple operations on the same color, and static Color.something() functions for one-off calculations

    45 |
    46 |
    47 |

    Modular & Extensible

    48 |

    Use only what you need, or a bundle. Client-side or Node. Deep extensibility with hooks.

    49 |
    50 |
    51 |
    52 | 53 |
    54 |

    55 | Color.js is currently an unreleased work in progress. Here be dragons. 56 | If you found this website somehow, feel free to try it out and give us feedback, but sshhhh! 🤫 57 | There are more bugs in the live code snippets ("Color Notebook") in the docs than the actual Color.js library, so before reporting a bug, please try to reproduce it outside Color Notebook. 58 |

    59 | 60 |
    61 |

    Reading colors

    62 | 63 |

    Any color from CSS Color Level 4 should work:

    64 |
    
     65 | 			let color = new Color("slategray");
     66 | 			let color2 = new Color("hwb(60 30% 40%)");
     67 | 			let color3 = new Color("color(display-p3 0 1 0)");
     68 | 			let color4 = new Color("lch(50% 80 30)");
     69 | 
     70 | 			// CSS variables work too!
     71 | 			let colorjsBlue = new Color("--color-blue");
     72 | 		
    73 | 74 |

    Read more about color objects 75 |

    76 | 77 |
    78 |

    Manipulating colors

    79 | 80 |

    You can use properties to modify coordinates 81 | of any color space and convert back 82 |

    
     83 | 			let color = new Color("slategray");
     84 | 			color.lightness = 80; // LCH coords available directly on colors
     85 | 			color.chroma *= 1.2; // saturate 20%
     86 | 			color.hwb.whiteness += 10; // any other color space also available
     87 | 		
    88 | 89 |

    Chaining-style modifications are also supported:

    90 |
    
     91 | 			let color = new Color("lch(50% 50 10)");
     92 | 			color = color.set({
     93 | 				hue: h => h + 180,
     94 | 				chroma: 60
     95 | 			}).lighten();
     96 | 		
    97 | 98 |

    Read more about color manipulation 99 |

    100 | 101 |
    102 |

    Converting between color spaces & stringifying

    103 | 104 |

    Output in any color space

    105 |
    
    106 | 			let color = new Color("slategray");
    107 | 			color + ""; // default stringification
    108 | 			color.to("p3").toString({precision: 3});
    109 | 		
    110 | 111 |

    Clip to gamut or don't

    112 |
    
    113 | 			let color = new Color("p3", [0, 1, 0]);
    114 | 			color.to("srgb").toString({inGamut: false})
    115 | 		
    116 | 117 |

    Change color space:

    118 |
    
    119 | 			let color = new Color("slategray");
    120 | 			color.space = "srgb"; // Convert to sRGB
    121 | 			color.spaceId = "srgb"; // Same
    122 | 			color.space = "sRGB"; // Capitalization doesn't matter
    123 | 			color.space = Color.spaces.srgb; // Same
    124 | 			color.spaceId = Color.spaces.srgb; // Same
    125 | 		
    126 |
    127 | 128 |
    129 |

    Interpolation

    130 | 131 |

    Get a function that accepts a percentage:

    132 |
    
    133 | 			let color = new Color("p3", [0, 1, 0]);
    134 | 			let redgreen = color.range("red", {
    135 | 				space: "lch", // interpolation space
    136 | 				outputSpace: "srgb"
    137 | 			});
    138 | 			redgreen(.5); // midpoint
    139 | 		
    140 | 141 |

    Interpolation by discrete steps:

    142 |
    
    143 | 			let color = new Color("p3", [0, 1, 0]);
    144 | 			color.steps("red", {
    145 | 				space: "lch",
    146 | 				outputSpace: "srgb",
    147 | 				maxDeltaE: 3, // max deltaE between consecutive steps
    148 | 				steps: 10 // min number of steps
    149 | 			});
    150 | 		
    151 | 152 |

    Shortcut for specific points in the range:

    153 |
    
    154 | 			let color = new Color("p3", [0, 1, 0]);
    155 | 			let redgreen = color.mix("red", .5, {space: "lch", outputSpace: "srgb"});
    156 | 			let reddishGreen = color.mix("red", .25, {space: "lch", outputSpace: "srgb"});
    157 | 		
    158 | 159 |

    Static syntax (every color method has a static one too):

    160 |
    
    161 | 			Color.mix("color(display-p3 0 1 0)", "red", .5);
    162 | 		
    163 | 164 |

    Read more about interpolation 165 |

    166 |
    167 | 168 | @@include('_footer.html') 169 | 170 | 171 | 172 | -------------------------------------------------------------------------------- /src/keywords.js: -------------------------------------------------------------------------------- 1 | /* Parse color keywords without the browser DOM 2 | * This is only needed to parse Color keywords in Node, 3 | * and to improve performance when parsing color keywords in the browser 4 | * To take advantage of this, just import the module. 5 | * You can also take advantage of its default exports, if you need a data structure of named colors 6 | * Note that this does not handle currentColor 7 | */ 8 | import Color, {util} from "./color.js"; 9 | 10 | // To produce: Visit https://www.w3.org/TR/css-color-4/#named-colors 11 | // and run in the console: 12 | // copy($$("tr", $(".named-color-table tbody")).map(tr => `"${tr.cells[2].textContent.trim()}": [${tr.cells[4].textContent.trim().split(/\s+/).map(c => c === "0"? "0" : c === "255"? "1" : c + " / 255").join(", ")}]`).join(",\n")) 13 | const KEYWORDS = { 14 | "aliceblue": [240 / 255, 248 / 255, 1], 15 | "antiquewhite": [250 / 255, 235 / 255, 215 / 255], 16 | "aqua": [0, 1, 1], 17 | "aquamarine": [127 / 255, 1, 212 / 255], 18 | "azure": [240 / 255, 1, 1], 19 | "beige": [245 / 255, 245 / 255, 220 / 255], 20 | "bisque": [1, 228 / 255, 196 / 255], 21 | "black": [0, 0, 0], 22 | "blanchedalmond": [1, 235 / 255, 205 / 255], 23 | "blue": [0, 0, 1], 24 | "blueviolet": [138 / 255, 43 / 255, 226 / 255], 25 | "brown": [165 / 255, 42 / 255, 42 / 255], 26 | "burlywood": [222 / 255, 184 / 255, 135 / 255], 27 | "cadetblue": [95 / 255, 158 / 255, 160 / 255], 28 | "chartreuse": [127 / 255, 1, 0], 29 | "chocolate": [210 / 255, 105 / 255, 30 / 255], 30 | "coral": [1, 127 / 255, 80 / 255], 31 | "cornflowerblue": [100 / 255, 149 / 255, 237 / 255], 32 | "cornsilk": [1, 248 / 255, 220 / 255], 33 | "crimson": [220 / 255, 20 / 255, 60 / 255], 34 | "cyan": [0, 1, 1], 35 | "darkblue": [0, 0, 139 / 255], 36 | "darkcyan": [0, 139 / 255, 139 / 255], 37 | "darkgoldenrod": [184 / 255, 134 / 255, 11 / 255], 38 | "darkgray": [169 / 255, 169 / 255, 169 / 255], 39 | "darkgreen": [0, 100 / 255, 0], 40 | "darkgrey": [169 / 255, 169 / 255, 169 / 255], 41 | "darkkhaki": [189 / 255, 183 / 255, 107 / 255], 42 | "darkmagenta": [139 / 255, 0, 139 / 255], 43 | "darkolivegreen": [85 / 255, 107 / 255, 47 / 255], 44 | "darkorange": [1, 140 / 255, 0], 45 | "darkorchid": [153 / 255, 50 / 255, 204 / 255], 46 | "darkred": [139 / 255, 0, 0], 47 | "darksalmon": [233 / 255, 150 / 255, 122 / 255], 48 | "darkseagreen": [143 / 255, 188 / 255, 143 / 255], 49 | "darkslateblue": [72 / 255, 61 / 255, 139 / 255], 50 | "darkslategray": [47 / 255, 79 / 255, 79 / 255], 51 | "darkslategrey": [47 / 255, 79 / 255, 79 / 255], 52 | "darkturquoise": [0, 206 / 255, 209 / 255], 53 | "darkviolet": [148 / 255, 0, 211 / 255], 54 | "deeppink": [1, 20 / 255, 147 / 255], 55 | "deepskyblue": [0, 191 / 255, 1], 56 | "dimgray": [105 / 255, 105 / 255, 105 / 255], 57 | "dimgrey": [105 / 255, 105 / 255, 105 / 255], 58 | "dodgerblue": [30 / 255, 144 / 255, 1], 59 | "firebrick": [178 / 255, 34 / 255, 34 / 255], 60 | "floralwhite": [1, 250 / 255, 240 / 255], 61 | "forestgreen": [34 / 255, 139 / 255, 34 / 255], 62 | "fuchsia": [1, 0, 1], 63 | "gainsboro": [220 / 255, 220 / 255, 220 / 255], 64 | "ghostwhite": [248 / 255, 248 / 255, 1], 65 | "gold": [1, 215 / 255, 0], 66 | "goldenrod": [218 / 255, 165 / 255, 32 / 255], 67 | "gray": [128 / 255, 128 / 255, 128 / 255], 68 | "green": [0, 128 / 255, 0], 69 | "greenyellow": [173 / 255, 1, 47 / 255], 70 | "grey": [128 / 255, 128 / 255, 128 / 255], 71 | "honeydew": [240 / 255, 1, 240 / 255], 72 | "hotpink": [1, 105 / 255, 180 / 255], 73 | "indianred": [205 / 255, 92 / 255, 92 / 255], 74 | "indigo": [75 / 255, 0, 130 / 255], 75 | "ivory": [1, 1, 240 / 255], 76 | "khaki": [240 / 255, 230 / 255, 140 / 255], 77 | "lavender": [230 / 255, 230 / 255, 250 / 255], 78 | "lavenderblush": [1, 240 / 255, 245 / 255], 79 | "lawngreen": [124 / 255, 252 / 255, 0], 80 | "lemonchiffon": [1, 250 / 255, 205 / 255], 81 | "lightblue": [173 / 255, 216 / 255, 230 / 255], 82 | "lightcoral": [240 / 255, 128 / 255, 128 / 255], 83 | "lightcyan": [224 / 255, 1, 1], 84 | "lightgoldenrodyellow": [250 / 255, 250 / 255, 210 / 255], 85 | "lightgray": [211 / 255, 211 / 255, 211 / 255], 86 | "lightgreen": [144 / 255, 238 / 255, 144 / 255], 87 | "lightgrey": [211 / 255, 211 / 255, 211 / 255], 88 | "lightpink": [1, 182 / 255, 193 / 255], 89 | "lightsalmon": [1, 160 / 255, 122 / 255], 90 | "lightseagreen": [32 / 255, 178 / 255, 170 / 255], 91 | "lightskyblue": [135 / 255, 206 / 255, 250 / 255], 92 | "lightslategray": [119 / 255, 136 / 255, 153 / 255], 93 | "lightslategrey": [119 / 255, 136 / 255, 153 / 255], 94 | "lightsteelblue": [176 / 255, 196 / 255, 222 / 255], 95 | "lightyellow": [1, 1, 224 / 255], 96 | "lime": [0, 1, 0], 97 | "limegreen": [50 / 255, 205 / 255, 50 / 255], 98 | "linen": [250 / 255, 240 / 255, 230 / 255], 99 | "magenta": [1, 0, 1], 100 | "maroon": [128 / 255, 0, 0], 101 | "mediumaquamarine": [102 / 255, 205 / 255, 170 / 255], 102 | "mediumblue": [0, 0, 205 / 255], 103 | "mediumorchid": [186 / 255, 85 / 255, 211 / 255], 104 | "mediumpurple": [147 / 255, 112 / 255, 219 / 255], 105 | "mediumseagreen": [60 / 255, 179 / 255, 113 / 255], 106 | "mediumslateblue": [123 / 255, 104 / 255, 238 / 255], 107 | "mediumspringgreen": [0, 250 / 255, 154 / 255], 108 | "mediumturquoise": [72 / 255, 209 / 255, 204 / 255], 109 | "mediumvioletred": [199 / 255, 21 / 255, 133 / 255], 110 | "midnightblue": [25 / 255, 25 / 255, 112 / 255], 111 | "mintcream": [245 / 255, 1, 250 / 255], 112 | "mistyrose": [1, 228 / 255, 225 / 255], 113 | "moccasin": [1, 228 / 255, 181 / 255], 114 | "navajowhite": [1, 222 / 255, 173 / 255], 115 | "navy": [0, 0, 128 / 255], 116 | "oldlace": [253 / 255, 245 / 255, 230 / 255], 117 | "olive": [128 / 255, 128 / 255, 0], 118 | "olivedrab": [107 / 255, 142 / 255, 35 / 255], 119 | "orange": [1, 165 / 255, 0], 120 | "orangered": [1, 69 / 255, 0], 121 | "orchid": [218 / 255, 112 / 255, 214 / 255], 122 | "palegoldenrod": [238 / 255, 232 / 255, 170 / 255], 123 | "palegreen": [152 / 255, 251 / 255, 152 / 255], 124 | "paleturquoise": [175 / 255, 238 / 255, 238 / 255], 125 | "palevioletred": [219 / 255, 112 / 255, 147 / 255], 126 | "papayawhip": [1, 239 / 255, 213 / 255], 127 | "peachpuff": [1, 218 / 255, 185 / 255], 128 | "peru": [205 / 255, 133 / 255, 63 / 255], 129 | "pink": [1, 192 / 255, 203 / 255], 130 | "plum": [221 / 255, 160 / 255, 221 / 255], 131 | "powderblue": [176 / 255, 224 / 255, 230 / 255], 132 | "purple": [128 / 255, 0, 128 / 255], 133 | "rebeccapurple": [102 / 255, 51 / 255, 153 / 255], 134 | "red": [1, 0, 0], 135 | "rosybrown": [188 / 255, 143 / 255, 143 / 255], 136 | "royalblue": [65 / 255, 105 / 255, 225 / 255], 137 | "saddlebrown": [139 / 255, 69 / 255, 19 / 255], 138 | "salmon": [250 / 255, 128 / 255, 114 / 255], 139 | "sandybrown": [244 / 255, 164 / 255, 96 / 255], 140 | "seagreen": [46 / 255, 139 / 255, 87 / 255], 141 | "seashell": [1, 245 / 255, 238 / 255], 142 | "sienna": [160 / 255, 82 / 255, 45 / 255], 143 | "silver": [192 / 255, 192 / 255, 192 / 255], 144 | "skyblue": [135 / 255, 206 / 255, 235 / 255], 145 | "slateblue": [106 / 255, 90 / 255, 205 / 255], 146 | "slategray": [112 / 255, 128 / 255, 144 / 255], 147 | "slategrey": [112 / 255, 128 / 255, 144 / 255], 148 | "snow": [1, 250 / 255, 250 / 255], 149 | "springgreen": [0, 1, 127 / 255], 150 | "steelblue": [70 / 255, 130 / 255, 180 / 255], 151 | "tan": [210 / 255, 180 / 255, 140 / 255], 152 | "teal": [0, 128 / 255, 128 / 255], 153 | "thistle": [216 / 255, 191 / 255, 216 / 255], 154 | "tomato": [1, 99 / 255, 71 / 255], 155 | "turquoise": [64 / 255, 224 / 255, 208 / 255], 156 | "violet": [238 / 255, 130 / 255, 238 / 255], 157 | "wheat": [245 / 255, 222 / 255, 179 / 255], 158 | "white": [1, 1, 1], 159 | "whitesmoke": [245 / 255, 245 / 255, 245 / 255], 160 | "yellow": [1, 1, 0], 161 | "yellowgreen": [154 / 255, 205 / 255, 50 / 255] 162 | }; 163 | 164 | Color.hooks.add("parse-start", env => { 165 | let str = env.str.toLowerCase(); 166 | let ret = {spaceId: "srgb", coords: null, alpha: 1}; 167 | 168 | if (str === "transparent") { 169 | ret.coords = KEYWORDS.black; 170 | ret.alpha = 0; 171 | } 172 | else { 173 | ret.coords = KEYWORDS[str]; 174 | } 175 | 176 | if (ret.coords) { 177 | env.color = ret; 178 | } 179 | }); 180 | 181 | export default KEYWORDS; 182 | --------------------------------------------------------------------------------