├── .babelrc ├── .editorconfig ├── .gitignore ├── .htmlnanorc ├── .posthtmlrc ├── .stylelintrc.json ├── LICENSE.md ├── README.md ├── color-locator-worker.js ├── favicon.ico ├── index.pug ├── lib ├── chroma-extensions.js ├── export-utils.js ├── generate-random-colors.js ├── image-palette.js ├── okhsv-conversions.js ├── palette-extractor.js ├── rgb-cymk.js ├── share-strings.js └── visualize-color-positions.js ├── main.js ├── main.scss ├── package-lock.json ├── package.json ├── pig.mjs ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── background.js ├── browserconfig.xml ├── farbicon.png ├── farbvelo.png ├── farbvelo.svg ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── maifest.json ├── mstile-150x150.png ├── safari-pinned-tab.svg ├── samples │ ├── engine-color-bingo-01.png │ ├── engine-color-bingo-02.png │ ├── engine-color-bingo-03.png │ ├── engine-color-bingo-04.png │ ├── engine-color-bingo-05.png │ ├── engine-color-bingo-06.png │ ├── engine-legacy-01.png │ ├── engine-legacy-02.png │ ├── engine-legacy-03.png │ ├── engine-legacy-04.png │ ├── engine-legacy-05.png │ ├── engine-legacy-06.png │ ├── engine-legacy-07.png │ ├── engine-legacy-08.png │ ├── engine-legacy-09.png │ ├── engine-legacy-10.png │ ├── engine-legacy-11.png │ └── engine-legacy-12.png └── site.webmanifest ├── seeds.js ├── utils.js └── worker.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"] 3 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /.htmlnanorc: -------------------------------------------------------------------------------- 1 | { 2 | "minifySvg": false 3 | } -------------------------------------------------------------------------------- /.posthtmlrc: -------------------------------------------------------------------------------- 1 | { "recognizeSelfClosing": true, "lowerCaseAttributeNames": false } -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "plugins": [ 4 | "stylelint-scss" 5 | ], 6 | "rules": { 7 | "declaration-colon-space-after": "always", 8 | "at-rule-no-unknown": null, 9 | "scss/at-rule-no-unknown": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ## creative commons 2 | 3 | # Attribution-ShareAlike 4.0 International 4 | 5 | Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. 6 | 7 | ### Using Creative Commons Public Licenses 8 | 9 | Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. 10 | 11 | * __Considerations for licensors:__ Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. [More considerations for licensors](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensors). 12 | 13 | * __Considerations for the public:__ By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. [More considerations for the public](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensees). 14 | 15 | ## Creative Commons Attribution-ShareAlike 4.0 International Public License 16 | 17 | By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. 18 | 19 | ### Section 1 – Definitions. 20 | 21 | a. __Adapted Material__ means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. 22 | 23 | b. __Adapter's License__ means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. 24 | 25 | c. __BY-SA Compatible License__ means a license listed at [creativecommons.org/compatiblelicenses](http://creativecommons.org/compatiblelicenses), approved by Creative Commons as essentially the equivalent of this Public License. 26 | 27 | d. __Copyright and Similar Rights__ means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. 28 | 29 | e. __Effective Technological Measures__ means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. 30 | 31 | f. __Exceptions and Limitations__ means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. 32 | 33 | g. __License Elements__ means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike. 34 | 35 | h. __Licensed Material__ means the artistic or literary work, database, or other material to which the Licensor applied this Public License. 36 | 37 | i. __Licensed Rights__ means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. 38 | 39 | j. __Licensor__ means the individual(s) or entity(ies) granting rights under this Public License. 40 | 41 | k. __Share__ means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. 42 | 43 | l. __Sui Generis Database Rights__ means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. 44 | 45 | m. __You__ means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. 46 | 47 | ### Section 2 – Scope. 48 | 49 | a. ___License grant.___ 50 | 51 | 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: 52 | 53 | A. reproduce and Share the Licensed Material, in whole or in part; and 54 | 55 | B. produce, reproduce, and Share Adapted Material. 56 | 57 | 2. __Exceptions and Limitations.__ For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. 58 | 59 | 3. __Term.__ The term of this Public License is specified in Section 6(a). 60 | 61 | 4. __Media and formats; technical modifications allowed.__ The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. 62 | 63 | 5. __Downstream recipients.__ 64 | 65 | A. __Offer from the Licensor – Licensed Material.__ Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. 66 | 67 | B. __Additional offer from the Licensor – Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter’s License You apply. 68 | 69 | C. __No downstream restrictions.__ You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. 70 | 71 | 6. __No endorsement.__ Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). 72 | 73 | b. ___Other rights.___ 74 | 75 | 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. 76 | 77 | 2. Patent and trademark rights are not licensed under this Public License. 78 | 79 | 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties. 80 | 81 | ### Section 3 – License Conditions. 82 | 83 | Your exercise of the Licensed Rights is expressly made subject to the following conditions. 84 | 85 | a. ___Attribution.___ 86 | 87 | 1. If You Share the Licensed Material (including in modified form), You must: 88 | 89 | A. retain the following if it is supplied by the Licensor with the Licensed Material: 90 | 91 | i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); 92 | 93 | ii. a copyright notice; 94 | 95 | iii. a notice that refers to this Public License; 96 | 97 | iv. a notice that refers to the disclaimer of warranties; 98 | 99 | v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; 100 | 101 | B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and 102 | 103 | C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. 104 | 105 | 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. 106 | 107 | 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. 108 | 109 | b. ___ShareAlike.___ 110 | 111 | In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply. 112 | 113 | 1. The Adapter’s License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License. 114 | 115 | 2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material. 116 | 117 | 3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply. 118 | 119 | ### Section 4 – Sui Generis Database Rights. 120 | 121 | Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: 122 | 123 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database; 124 | 125 | b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and 126 | 127 | c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. 128 | 129 | For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. 130 | 131 | ### Section 5 – Disclaimer of Warranties and Limitation of Liability. 132 | 133 | a. __Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.__ 134 | 135 | b. __To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.__ 136 | 137 | c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. 138 | 139 | ### Section 6 – Term and Termination. 140 | 141 | a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. 142 | 143 | b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: 144 | 145 | 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or 146 | 147 | 2. upon express reinstatement by the Licensor. 148 | 149 | For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. 150 | 151 | c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. 152 | 153 | d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. 154 | 155 | ### Section 7 – Other Terms and Conditions. 156 | 157 | a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. 158 | 159 | b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.t stated herein are separate from and independent of the terms and conditions of this Public License. 160 | 161 | ### Section 8 – Interpretation. 162 | 163 | a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. 164 | 165 | b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. 166 | 167 | c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. 168 | 169 | d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. 170 | 171 | ``` 172 | Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at [creativecommons.org/policies](http://creativecommons.org/policies), Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses. 173 | 174 | Creative Commons may be contacted at creativecommons.org 175 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FarbVelo 2 | "Random" color palette generator. 3 | ## Farbvelo 4 | 5 | FarbVelo (Swiss-German for color bicycle) is a playful color picking tool. It uses simple rules and lots of random numbers to help you come up with pleasing color combinations or just chill while cycling through color harmonies (I almost find it a bit psychedelic while listening to [custom made white noise](https://mynoise.net/NoiseMachines/tropicalRainNoiseGenerator.php)). 6 | 7 | ## About 8 | 9 | 1. Picking ℕ0 hue's (color stops) using [HSLuv](https://www.hsluv.org/) at a user defined minimum angle ∠. 10 | 2. Interpolating between color stops in CIE L*a*b* by default, using [chroma.js](https://gka.github.io/chroma.js/). 11 | 3. Finding pleasing [color names](https://github.com/meodai/color-names) using the color name [API](https://github.com/meodai/color-names#api-) 12 | 4. Icons made by [Ravindra Kalkani](https://thenounproject.com/search/?q=reload&i=1973430). 13 | 5. Originally released as a [Codepen](https://codepen.io/meodai/pen/RerqjG). 14 | 6. Source is on [github](https://github.com/meodai/farbvelo) and licensed under a [Creative Commons Attribution Share Alike 4.0](https://github.com/meodai/farbvelo/blob/main/LICENSE.md) license. 15 | 16 | ## Engine 17 | 18 | If you are anything like me, you are probably here to find out how the color picking works. Since this code is based on an old project and the code is very 19 | messy, let me help you: 20 | 21 | ```js 22 | // minHueDiffAngle = 60 23 | 24 | // create an array of hues to pick from. 25 | const baseHue = random(0, 360); 26 | const hues = new Array(Math.round( 360 / minHueDiffAngle) ).fill('').map((offset, i) => { 27 | return (baseHue + i * minHueDiffAngle) % 360; 28 | }); 29 | 30 | // low saturation color 31 | const baseSaturation = random(5, 40); 32 | const baseLightness = random(0, 20); 33 | const rangeLightness = 90 - baseLightness; 34 | 35 | colors.push( 36 | hsluvToHex([ 37 | hues[0], 38 | baseSaturation, 39 | baseLightness * random(0.25, 0.75), 40 | ]) 41 | ); 42 | 43 | // random shades 44 | const minSat = random(50, 70); 45 | const maxSat = minSat + 30; 46 | const minLight = random(35, 70); 47 | const maxLight = Math.min(minLight + random(20, 40), 95); 48 | // const lightDiff = maxLight - minLight; 49 | 50 | const remainingHues = [...hues]; 51 | 52 | for (let i = 0; i < parts - 2; i++) { 53 | const hue = remainingHues.splice(random(0, remainingHues.length - 1),1)[0]; 54 | const saturation = random(minSat, maxSat); 55 | const light = baseLightness + random(0,10) + ((rangeLightness/(parts - 1)) * i); 56 | 57 | colors.push( 58 | hsluvToHex([ 59 | hue, 60 | saturation, 61 | random(light, maxLight), 62 | ]) 63 | ) 64 | } 65 | 66 | colors.push( 67 | hsluvToHex([ 68 | remainingHues[0], 69 | baseSaturation, 70 | rangeLightness + 10, 71 | ]) 72 | ); 73 | 74 | chroma.scale(colors) 75 | .padding(.175) 76 | .mode('lab') 77 | .colors(6); 78 | ``` 79 | 80 | ## Techstack & Credits 81 | 82 | - Icons: [iconoir](https://iconoir.com/) 83 | - Vue 84 | - Chroma.js 85 | - Inter Font 86 | - Space Mono Font 87 | 88 | ## Samples 89 | 90 | ![sample screenshot of color bingo engine](public/samples/engine-color-bingo-01.png) 91 | ![sample screenshot of color bingo engine](public/samples/engine-color-bingo-02.png) 92 | ![sample screenshot of color bingo engine](public/samples/engine-color-bingo-03.png) 93 | ![sample screenshot of color bingo engine](public/samples/engine-color-bingo-04.png) 94 | ![sample screenshot of color bingo engine](public/samples/engine-color-bingo-05.png) 95 | ![sample screenshot of color bingo engine](public/samples/engine-color-bingo-06.png) 96 | ![sample screenshot of legacy engine](public/samples/engine-legacy-01.png) 97 | ![sample screenshot of legacy engine](public/samples/engine-legacy-02.png) 98 | ![sample screenshot of legacy engine](public/samples/engine-legacy-03.png) 99 | ![sample screenshot of legacy engine](public/samples/engine-legacy-04.png) 100 | ![sample screenshot of legacy engine](public/samples/engine-legacy-05.png) 101 | ![sample screenshot of legacy engine](public/samples/engine-legacy-06.png) 102 | ![sample screenshot of legacy engine](public/samples/engine-legacy-07.png) 103 | ![sample screenshot of legacy engine](public/samples/engine-legacy-08.png) 104 | ![sample screenshot of legacy engine](public/samples/engine-legacy-09.png) 105 | ![sample screenshot of legacy engine](public/samples/engine-legacy-10.png) 106 | ![sample screenshot of legacy engine](public/samples/engine-legacy-11.png) 107 | ![sample screenshot of legacy engine](public/samples/engine-legacy-12.png) 108 | -------------------------------------------------------------------------------- /color-locator-worker.js: -------------------------------------------------------------------------------- 1 | // color-locator-worker.js 2 | // This worker finds where specified RGB colors appear in image data, 3 | // searching from the center outwards. 4 | 5 | /** 6 | * Converts an RGB color object to a string key. 7 | * @param {{r: number, g: number, b: number}} rgb - The RGB color object. 8 | * @returns {string} A string representation (e.g., "255-0-0"). 9 | */ 10 | function rgbToKey(rgb) { 11 | return `${rgb.r}-${rgb.g}-${rgb.b}`; 12 | } 13 | 14 | /** 15 | * Calculates the squared Euclidean distance between two RGB colors. 16 | * This is faster than calculating the actual distance as it avoids Math.sqrt(). 17 | * @param {{r: number, g: number, b: number}} color1 18 | * @param {{r: number, g: number, b: number}} color2 19 | * @returns {number} The squared distance between the colors. 20 | */ 21 | function colorDistanceSq(color1, color2) { 22 | const dr = color1.r - color2.r; 23 | const dg = color1.g - color2.g; 24 | const db = color1.b - color2.b; 25 | return dr * dr + dg * dg + db * db; 26 | } 27 | 28 | /** 29 | * Finds locations of target RGB colors within image data. 30 | * @param {ImageData} imageData - Object with width, height, and data (Uint8ClampedArray). 31 | * @param {Array<{r: number, g: number, b: number}>} targetRgbColors - Array of RGB colors to find. 32 | * @param {Object} [options={}] - Optional configuration. 33 | * @param {number} [options.maxPositionsPerColor=30] - Max positions to find per color. 34 | * @param {number} [options.distanceThresholdSq=900] - Squared color distance threshold for a match (e.g., 30*30). 35 | * @returns {Object} An object mapping color keys to arrays of found positions. 36 | */ 37 | function findColorLocations(imageData, targetRgbColors, options = {}) { 38 | const { 39 | maxPositionsPerColor = 30, 40 | distanceThresholdSq = 900 // Default allows for some tolerance 41 | } = options; 42 | 43 | const { width, height, data } = imageData; 44 | 45 | const foundLocations = {}; 46 | targetRgbColors.forEach(rgb => { 47 | foundLocations[rgbToKey(rgb)] = []; 48 | }); 49 | 50 | const centerX = Math.floor(width / 2); 51 | const centerY = Math.floor(height / 2); 52 | const maxSearchRadius = Math.sqrt(centerX * centerX + centerY * centerY); // Max distance from center to a corner 53 | 54 | let colorsStillSearching = targetRgbColors.length; 55 | const radiusIncrementBase = Math.max(1, Math.floor(Math.min(width, height) / 200)); 56 | 57 | for (let radius = 0; radius <= maxSearchRadius && colorsStillSearching > 0; /* radius incremented below */) { 58 | let angleStep; 59 | if (radius < 1) { // Center pixel 60 | angleStep = 2 * Math.PI; // Will only run once for angle = 0 61 | } else if (radius < 20) { 62 | angleStep = Math.PI / 8; 63 | } else if (radius < 50) { 64 | angleStep = Math.PI / 6; 65 | } else if (radius < 100) { 66 | angleStep = Math.PI / 4; 67 | } else { 68 | angleStep = Math.PI / 3; 69 | } 70 | 71 | for (let angle = 0; angle < 2 * Math.PI; angle += angleStep) { 72 | const x = (radius === 0) ? centerX : Math.round(centerX + radius * Math.cos(angle)); 73 | const y = (radius === 0) ? centerY : Math.round(centerY + radius * Math.sin(angle)); 74 | 75 | if (x < 0 || x >= width || y < 0 || y >= height) { 76 | continue; // Pixel is outside image bounds 77 | } 78 | 79 | const pixelIndex = (y * width + x) * 4; 80 | const r = data[pixelIndex]; 81 | const g = data[pixelIndex + 1]; 82 | const b = data[pixelIndex + 2]; 83 | const a = data[pixelIndex + 3]; 84 | 85 | if (a < 128) { // Skip significantly transparent pixels 86 | continue; 87 | } 88 | 89 | const currentPixelRgb = { r, g, b }; 90 | 91 | for (const targetRgb of targetRgbColors) { 92 | const targetKey = rgbToKey(targetRgb); 93 | if (foundLocations[targetKey].length >= maxPositionsPerColor) { 94 | continue; // Already found enough positions for this color 95 | } 96 | 97 | const distSq = colorDistanceSq(currentPixelRgb, targetRgb); 98 | 99 | if (distSq < distanceThresholdSq) { 100 | foundLocations[targetKey].push({ 101 | x: x / width, // Normalized x-coordinate 102 | y: y / height, // Normalized y-coordinate 103 | distance: maxSearchRadius > 0 ? radius / maxSearchRadius : 0 // Normalized distance from center 104 | }); 105 | 106 | if (foundLocations[targetKey].length === maxPositionsPerColor) { 107 | colorsStillSearching--; 108 | if (colorsStillSearching === 0) break; // All colors found max positions 109 | } 110 | break; // Pixel matched one target color, move to next pixel on spiral 111 | } 112 | } 113 | if (colorsStillSearching === 0) break; // All colors found max positions 114 | if (radius === 0) break; // Center pixel processed, move to next radius 115 | } 116 | 117 | // Increment radius for next iteration of the spiral 118 | let currentIncrement = radiusIncrementBase; 119 | if (radius === 0) { // Ensure progression from center 120 | currentIncrement = Math.max(1, radiusIncrementBase); 121 | } else { 122 | if (radius > 100) currentIncrement += radiusIncrementBase; // Speed up for larger radii 123 | if (radius > 200) currentIncrement += radiusIncrementBase * 2; 124 | } 125 | radius += currentIncrement; 126 | } 127 | 128 | // For any colors where no positions were found, add a default center position 129 | targetRgbColors.forEach(rgb => { 130 | const key = rgbToKey(rgb); 131 | if (foundLocations[key].length === 0) { 132 | foundLocations[key].push({ 133 | x: 0.5, 134 | y: 0.5, 135 | distance: 0, 136 | isDefault: true 137 | }); 138 | } 139 | }); 140 | 141 | return foundLocations; 142 | } 143 | 144 | // Worker message handler 145 | self.onmessage = function(event) { 146 | try { 147 | const eventData = event.data || {}; 148 | const { imageData, targetRgbColors, options } = eventData; 149 | 150 | // Validate inputs 151 | if (!imageData || !imageData.data || typeof imageData.width !== 'number' || typeof imageData.height !== 'number') { 152 | throw new Error('Invalid or missing imageData. Expected {data, width, height}.'); 153 | } 154 | if (!targetRgbColors || !Array.isArray(targetRgbColors) || targetRgbColors.length === 0) { 155 | throw new Error('Invalid or missing targetRgbColors. Expected an array of {r,g,b} objects.'); 156 | } 157 | for (const color of targetRgbColors) { 158 | if (typeof color.r !== 'number' || typeof color.g !== 'number' || typeof color.b !== 'number') { 159 | throw new Error('Invalid RGB color format in targetRgbColors. Each color must be an object with r, g, b properties as numbers.'); 160 | } 161 | } 162 | 163 | const locations = findColorLocations(imageData, targetRgbColors, options || {}); 164 | 165 | // Ensure the result is structured-clone compliant 166 | const result = { 167 | status: 'SUCCESS', 168 | locations: {} 169 | }; 170 | Object.keys(locations).forEach(key => { 171 | result.locations[key] = locations[key].map(pos => ({ 172 | x: pos.x, 173 | y: pos.y, 174 | distance: pos.distance, 175 | isDefault: !!pos.isDefault 176 | })); 177 | }); 178 | self.postMessage(result); 179 | 180 | } catch (error) { 181 | self.postMessage({ 182 | status: 'ERROR', 183 | message: error.message, 184 | stack: error.stack // Stack might not always be available or cloneable 185 | }); 186 | } 187 | }; 188 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/favicon.ico -------------------------------------------------------------------------------- /index.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | 3 | html(lang="en-US") 4 | head 5 | meta(charset='UTF-8') 6 | 7 | title FarbVélo —— Random Color Cycler 8 | meta(name='title', content='FarbVélo —— Random Color Cycler') 9 | meta(name='description', content='Generative color harmonies. The random color expolorer') 10 | 11 | link(rel="apple-touch-icon" sizes="180x180" href="./public//apple-touch-icon.png") 12 | 13 | link(rel="icon", href="./public/favicon.ico", type="image/x-icon") 14 | link(rel="icon" type="image/png" sizes="32x32" href="./public/favicon-32x32.png") 15 | link(rel="icon" type="image/png" sizes="16x16" href="./public/favicon-16x16.png") 16 | 17 | link(rel="manifest" href="./public/site.webmanifest") 18 | link(rel="mask-icon" href="./public/safari-pinned-tab.svg" color="#5bbad5") 19 | 20 | meta(name="msapplication-TileColor" content="#ffffff") 21 | meta(name="theme-color" content="#ffffff") 22 | 23 | // Open Graph / Facebook 24 | meta(property='og:type', content='website') 25 | meta(property='og:url', content='https://farbVelo.elastiq.ch/') 26 | meta(property='og:title', content='FarbVélo —— Random Color Cycler') 27 | meta(property='og:description', content='Generative color harmonies. The random color expolorer') 28 | meta(property='og:image', content='https://farbvelo.elastiq.ch/farbvelo.png') 29 | 30 | // Twitter 31 | meta(property='twitter:card', content='summary_large_image') 32 | meta(property='twitter:url', content='https://farbVelo.elastiq.ch/') 33 | meta(property='twitter:title', content='FarbVélo —— Random Color Cycler') 34 | meta(property='twitter:description', content='Generative color harmonies. The random color expolorer') 35 | meta(property='twitter:image', content='https://farbvelo.elastiq.ch/farbvelo.png') 36 | 37 | meta(name="viewport", content="width=device-width, initial-scale=1") 38 | meta(name="apple-mobile-web-app-capable", content="yes") 39 | meta(name="apple-mobile-web-app-status-bar-style" content="black-translucent") 40 | meta(name="theme-color" content="#212121") 41 | 42 | link(rel="preconnect", href="https://fonts.googleapis.com") 43 | link(rel="preconnect", href="https://fonts.gstatic.com", crossorigin) 44 | link(href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", rel="stylesheet") 45 | 46 | link(rel="preconnect", href="https://fonts.gstatic.com") 47 | link(href="https://fonts.googleapis.com/css2?family=Space+Mono&display=swap", rel="stylesheet") 48 | 49 | link(rel='stylesheet', media='all', href='main.scss') 50 | 51 | body.is-loading 52 | #app.wrap( 53 | ref="app", 54 | :style="appStyles" 55 | :class="appClasses") 56 | .bg-wrap 57 | .bg 58 | .panel(ref="panel" v-on:pointerdown="cancelSwipe") 59 | .panel__title 60 | h4.panel__settingtitle Palette Name 61 | h1.title 62 | |{{paletteTitle}} 63 | 64 | label.panel__setting 65 | h4.panel__settingtitle Generation Method 66 | .panel__inputs.panel__inputs--select 67 | svg 68 | use(xlink:href="#icon-dropdown") 69 | select(v-model.number="generatorFunction") 70 | option(v-for="functionName in generatorFunctionList", :value=functionName). 71 | {{ functionName }} 72 | 73 | .panel__setting(v-if="generatorFunction === 'ImageExtract'") 74 | div.panel__img 75 | label 76 | img(v-bind:src="imgURL" alt="Image the colors are extracted from") 77 | svg.icon.icon--up 78 | use(xlink:href="#icon-upload") 79 | input(type="file" accept="image/*" ref="file" v-on:change="handlefile") 80 | .panel__imgpositions 81 | span.panel__imgposition(v-for="(pos, i) in colorPositions", :key="i", :style="{'--x': pos.position.x, '--y': pos.position.y, '--c': pos.color}"). 82 | 83 | svg.icon.icon--re(v-on:click="newColors(true)") 84 | use(xlink:href="#icon-refresh") 85 | 86 | label.panel__setting(v-if="generatorFunction === 'ImageExtract'") 87 | h4.panel__settingtitle Quantization Method 88 | .panel__inputs.panel__inputs--select 89 | svg 90 | use(xlink:href="#icon-dropdown") 91 | select(v-model="quantizationMethod") 92 | option(v-for="method in quantizationMethods", :value=method). 93 | {{ method }} 94 | 95 | label.panel__setting 96 | h4.panel__settingtitle 97 | span Colors 98 | input(type="number", v-model.number="amount", min="3", max="10") 99 | 100 | .panel__inputs 101 | input(type="range", v-model.number="amount", , min="3", max="10") 102 | 103 | label.panel__setting(title="Or use arrow keys ↔") 104 | h4.panel__settingtitle 105 | span Mix Padding 106 | input(type="number", v-model.number="padding", min="0", max="1", step=".001") 107 | 108 | .panel__inputs 109 | input(type="range", v-model.number="padding", min="0", max="1", step=".001") 110 | 111 | label.panel__setting(v-if="generatorFunction !== 'RandomColor.js' && generatorFunction !== 'ImageExtract'") 112 | h4.panel__settingtitle Color Mode 113 | .panel__inputs.panel__inputs--select 114 | svg 115 | use(xlink:href="#icon-dropdown") 116 | select(v-model="colorMode") 117 | option(v-for="mode in colorModeList", :value=mode). 118 | {{ mode }} 119 | 120 | label.panel__setting 121 | h4.panel__settingtitle Interpolation Model 122 | .panel__inputs.panel__inputs--select 123 | svg 124 | use(xlink:href="#icon-dropdown") 125 | select(v-model="interpolationColorModel") 126 | option(v-for="mode in interpolationColorModels" v-bind:value='mode') {{ mode }} 127 | 128 | label.panel__setting 129 | h4.panel__settingtitle 130 | span Color Stops 131 | input(type="number", v-model.number="colorsInGradient", min="2", :max="amount") 132 | .panel__inputs 133 | input(type="range", v-model.number="colorsInGradient", min="2", :max="amount") 134 | 135 | label.panel__setting(v-if="generatorFunction !== 'Full Random' && generatorFunction !== 'RandomColor.js' && generatorFunction !== 'ImageExtract'") 136 | h4.panel__settingtitle 137 | span Min. Hue angle difference 138 | input(type="number", v-model.number="minHueDistance", min="1", :max="360/colorsInGradient", step="1") 139 | .panel__inputs 140 | input(type="range", v-model.number="minHueDistance", min="1", :max="360/colorsInGradient", step="1") 141 | 142 | //label.panel__setting(v-if="generatorFunction !== 'ImageExtract'") 143 | h4.panel__settingtitle 144 | span Seed 145 | input(type="text", v-model="currentSeed" v-on:input="newColors(false)") 146 | 147 | label.panel__setting.panel__setting--checkbox.panel__setting--inline(title="WCAG 2.0 AA Contrasts") 148 | input(type="checkbox", v-model="showContrast") 149 | i.panel__checkbox 150 | svg 151 | use(xlink:href="#icon-check") 152 | 153 | strong.panel__settingtitle Show Contrasting Colors 154 | 155 | label.panel__setting.panel__setting--bnw.panel__setting--checkbox.panel__setting--inline(title="Toggles Black/White contrst to contrasting colors") 156 | input(type="checkbox", v-model="addBWContrast") 157 | i.panel__checkbox 158 | svg 159 | use(xlink:href="#icon-check") 160 | 161 | strong.panel__settingtitle Add Black & White 162 | 163 | label.panel__setting.panel__setting--checkbox.panel__setting--inline 164 | input(type="checkbox", v-model="hasBackground") 165 | i.panel__checkbox 166 | svg 167 | use(xlink:href="#icon-check") 168 | 169 | strong.panel__settingtitle Show Background 170 | 171 | label.panel__setting.panel__setting--checkbox.panel__setting--inline 172 | input(type="checkbox", v-model="hasGradients") 173 | i.panel__checkbox 174 | svg 175 | use(xlink:href="#icon-check") 176 | 177 | strong.panel__settingtitle Show Glow 178 | 179 | label.panel__setting.panel__setting--checkbox.panel__setting--inline 180 | input(type="checkbox", v-model="hasBleed") 181 | i.panel__checkbox 182 | svg 183 | use(xlink:href="#icon-check") 184 | 185 | strong.panel__settingtitle Color Bleed 186 | 187 | label.panel__setting.panel__setting--checkbox.panel__setting--inline 188 | input(type="checkbox", v-model="hideText") 189 | i.panel__checkbox 190 | svg 191 | use(xlink:href="#icon-check") 192 | 193 | strong.panel__settingtitle Hide Text 194 | 195 | label.panel__setting.panel__setting--checkbox.panel__setting--inline 196 | input(type="checkbox", v-model="hasOutlines") 197 | i.panel__checkbox 198 | svg 199 | use(xlink:href="#icon-check") 200 | 201 | strong.panel__settingtitle Visually Separate Things 202 | 203 | label.panel__setting.panel__setting--checkbox.panel__setting--inline.panel__setting--contrast 204 | input(type="checkbox", v-model="highContrast") 205 | i.panel__checkbox 206 | svg 207 | use(xlink:href="#icon-check") 208 | 209 | strong.panel__settingtitle High Contrast 210 | 211 | label.panel__setting.panel__setting--checkbox.panel__setting--inline.panel__setting--contrast 212 | input(type="checkbox", v-model="expandUI") 213 | i.panel__checkbox 214 | svg 215 | use(xlink:href="#icon-check") 216 | strong.panel__settingtitle Expand UI 217 | 218 | label.panel__setting.panel__setting--checkbox.panel__setting--inline.panel__setting--contrast 219 | input(type="checkbox", v-model="sameHeightColors") 220 | i.panel__checkbox 221 | svg 222 | use(xlink:href="#icon-check") 223 | strong.panel__settingtitle Same Height Colors 224 | 225 | label.panel__setting.panel__setting--checkbox.panel__setting--inline.panel__setting--contrast 226 | input(type="checkbox", v-model="lightmode") 227 | i.panel__checkbox 228 | svg 229 | use(xlink:href="#icon-check") 230 | strong.panel__settingtitle Lightmode 231 | 232 | label.panel__setting.panel__setting--checkbox.panel__setting--inline.panel__setting--contrast 233 | input(type="checkbox", v-model="autoHideUI") 234 | i.panel__checkbox 235 | svg 236 | use(xlink:href="#icon-check") 237 | strong.panel__settingtitle Autohide UI 238 | 239 | label.panel__setting.panel__setting--checkbox.panel__setting--inline.panel__setting--contrast 240 | input(type="checkbox", v-model="trackSettingsInURL") 241 | i.panel__checkbox 242 | svg 243 | use(xlink:href="#icon-check") 244 | strong.panel__settingtitle Track settings in URL 245 | 246 | label.panel__setting 247 | strong.panel__settingtitle Color Values 248 | .panel__inputs.panel__inputs--select 249 | svg 250 | use(xlink:href="#icon-dropdown") 251 | select(v-model="colorValueType") 252 | option(v-for="valueType in colorValueTypes", :value="valueType"). 253 | {{ valueType }} 254 | 255 | label.panel__setting 256 | strong.panel__settingtitle Color Name List 257 | .panel__inputs.panel__inputs--select 258 | svg 259 | use(xlink:href="#icon-dropdown") 260 | select(v-model="nameList") 261 | option(v-for="(list, key) in nameLists", :value="key" :key=key). 262 | {{ list.title }} 263 | p.panel__text 264 | | {{currentListData.description}} — {{currentListData.colorCount}} names 265 | 266 | button.panel__button(v-on:click="resetSettings") 267 | span Reset Settings 268 | 269 | 270 | .footer 271 | article.footer__about 272 | h2.title--main FarbVélo 273 | p. 274 | FarbVélo (Swiss-German for color bicycle) is a playful color picking tool. It follows simple rules and uses lots of random numbers to help you come up with pleasing color combinations or just chill while cycling through color harmonies (I almost a bit psychedelic while listening to custom made white noise). 275 | 276 | aside 277 | h2 Usage Tipps 278 | ol 279 | li Clicking and draging or swiping on the backgroud will change the palette padding. 280 | li So will the left and right arrow keys. 281 | li Pressing 'Space' generates a new platte using your current settings. 282 | li On phones and tablets you can add this page to your home screen for a nicer user experience. 283 | li Dragging an image into FarbVélo will genrate a palette from it. 284 | 285 | aside 286 | h2 About 287 | ol 288 | li Picking ℕ0 hue's (color stops) using HSLuv at a user defined minimum angle ∠. 289 | li Interpolating between color stops in CIE L*a*b* by default, using chroma.js. 290 | li Spectral interpolation using spectral.js. 291 | li Finding pleasing color names using the color name API 292 | li Originally released as a Codepen. 293 | li Source is on github and licensed under a Creative Commons Attribution Share Alike 4.0 license. 294 | 295 | footer 296 | a(href="https://www.elastiq.ch/" hreflang="en").ellogo 297 | svg(xmlns='http://www.w3.org/2000/svg' viewbox='0 0 352 185') 298 | g(fill='none' fill-rule='evenodd' transform='translate(0 6)') 299 | path(:fill='colors[0]' fill-rule='nonzero' d='M179.54 71.84a9 9 0 00-1.91.21 7.74 7.74 0 00-1.83.64 4 4 0 00-1.4 1.15 2.81 2.81 0 001.49 4.38 29.19 29.19 0 007 1.45 17.65 17.65 0 018.93 3.36 9.22 9.22 0 013.4 7.7c0 4.993-1.743 8.907-5.23 11.74s-8.323 4.25-14.51 4.25a21.41 21.41 0 01-8-1.36 17.6 17.6 0 01-5.53-3.4 14.1 14.1 0 01-3.28-4.51 12.64 12.64 0 01-1.19-4.68l10.55-2.55a7.32 7.32 0 002.38 4.81c1.42 1.333 3.577 2 6.47 2a13.24 13.24 0 005.19-.94 3.34 3.34 0 002.21-3.32 3.24 3.24 0 00-1.7-2.85c-1.133-.707-3.233-1.203-6.3-1.49a17.19 17.19 0 01-9.74-3.49 9.73 9.73 0 01-3.62-7.91 13.4 13.4 0 011.49-6.38 14 14 0 014-4.68 17.87 17.87 0 015.74-2.85 23.84 23.84 0 016.85-1 20.07 20.07 0 017.4 1.19 15 15 0 014.85 3 11.8 11.8 0 012.76 3.87 15.47 15.47 0 011.15 3.87l-10.45 2.72a5.27 5.27 0 00-2.13-3.62 8.32 8.32 0 00-5.04-1.31zm39.25 1.68h-11.91V63.33H221l3.91-18.55h10.72l-3.92 18.55h14.63v10.19h-16.83l-4.8 21.89.85.6 11.4-7.83 5.36 8-12.3 8.34a12 12 0 01-6.89 2.21 10.08 10.08 0 01-3.57-.64 8.74 8.74 0 01-5-4.72 9.22 9.22 0 01-.77-3.83 8 8 0 01.08-1.23c.053-.367.137-.863.25-1.49l4.67-21.3zm63.58 31a11.67 11.67 0 01-3.49 1.7 12.69 12.69 0 01-3.49.51 10.08 10.08 0 01-3.57-.64 9.25 9.25 0 01-3-1.79 8 8 0 01-2-2.81 9.16 9.16 0 01-.72-3.7 12.25 12.25 0 01.34-3l5.1-21.36-.85-.6-11.4 7.83-5.36-8 12.34-8.34a11.68 11.68 0 013.49-1.7 12.68 12.68 0 013.49-.51 10.11 10.11 0 013.57.64 9.28 9.28 0 013 1.79 8 8 0 012 2.81 9.18 9.18 0 01.72 3.7 12.32 12.32 0 01-.34 3l-5.11 21.36.85.6 11.4-7.83 5.36 8-12.33 8.34zm7.4-53.69a8 8 0 01-.64 3.19 7.68 7.68 0 01-1.74 2.55 8.57 8.57 0 01-2.59 1.7 7.82 7.82 0 01-3.11.64 7.72 7.72 0 01-3.15-.64 8.69 8.69 0 01-2.55-1.7 7.67 7.67 0 01-1.74-2.55 8.29 8.29 0 010-6.38 7.71 7.71 0 011.74-2.55 8.73 8.73 0 012.55-1.7 7.7 7.7 0 013.15-.64 7.8 7.8 0 013.11.64 8.61 8.61 0 012.59 1.7 7.72 7.72 0 011.74 2.55 8 8 0 01.64 3.18v.01zm42.8 48.58h-1.53a21.9 21.9 0 01-2.13 2.77 13 13 0 01-2.85 2.34 14.44 14.44 0 01-4 1.62 21.53 21.53 0 01-5.36.6 14 14 0 01-10.17-4.25 14.71 14.71 0 01-3.15-4.94 17.39 17.39 0 01-1.15-6.47 37.71 37.71 0 011.57-10.93 28.53 28.53 0 014.64-9.23 23.16 23.16 0 017.49-6.38 21 21 0 0110.12-2.38c3.46 0 6.057.71 7.79 2.13a10.62 10.62 0 013.53 5.19h1.53l1.28-6.13h10.72l-10.47 48.92.85.6 4.76-3.23 5.36 8-5.7 3.74a11.59 11.59 0 01-3.53 1.7 13.14 13.14 0 01-3.46.44 9.35 9.35 0 01-6.51-2.42 8.55 8.55 0 01-2.68-6.68c.017-.946.13-1.887.34-2.81l2.71-12.2zm-10.38-2.89a12.38 12.38 0 005.62-1.28 14.13 14.13 0 004.42-3.45 15.84 15.84 0 002.89-5 17 17 0 001-5.87 8.39 8.39 0 00-2.34-6.34 9 9 0 00-6.51-2.25 12.31 12.31 0 00-5.66 1.32 14 14 0 00-4.42 3.49 16.25 16.25 0 00-2.85 5 17 17 0 00-1 5.87c0 2.78.78 4.893 2.34 6.34a9.21 9.21 0 006.51 2.17z') 300 | path(:stroke='colors[colors.length - 1]' stroke-linecap='round' stroke-linejoin='round' stroke-width='11.38' d='M96.45 70.41c25.89-7.67 59 2.55 80.49-31.66 25.23-40.1 60.94-44.68 85.27-31.62 34.2 18.36 31.67 68.27 7.58 90.7-27.42 25.53-29.58 52.91-13.68 67.24 22.95 20.68 47.1-2.67 35.85-19.93-8.94-13.73-31.93-25.89-98.1 6.9-65.32 32.36-129.62 19-133.91-32.42-1.03-12.53 2.88-39.25 36.5-49.21h0z') 301 | path(:fill='colors[0]' fill-rule='nonzero' d='M.3 104.52l12.41-58.55h35.31v10.72H21.65l-2.94 13.62h23.06v10.72H16.46l-2.89 13.78h25.14v10.71H.3v-1zm77.08 1.69a12.69 12.69 0 01-3.49.51 10.08 10.08 0 01-3.57-.64 9.25 9.25 0 01-3-1.79 8 8 0 01-2-2.81 9.16 9.16 0 01-.72-3.7 12.93 12.93 0 01.34-3l9.27-38.71-.85-.6-11.4 7.83-5.36-8 12.34-8.34a11.68 11.68 0 013.49-1.7 12.67 12.67 0 013.49-.51 10.09 10.09 0 013.57.64 9.26 9.26 0 013 1.79 8.05 8.05 0 012 2.81 9.17 9.17 0 01.72 3.7 14.62 14.62 0 01-.34 3L75.6 95.4l.85.6 11.4-7.83 5.36 8-12.34 8.35a11.68 11.68 0 01-3.49 1.69zm62.88-10.8l.85.6 4.08-2.89 5.36 8-5 3.4a12.39 12.39 0 01-7 2.21 9.85 9.85 0 01-5.79-1.79 7.85 7.85 0 01-3.23-5H128a15.69 15.69 0 01-1.79 2.68 9.7 9.7 0 01-2.5 2.1 14.05 14.05 0 01-3.49 1.45 17.83 17.83 0 01-4.72.55 14.23 14.23 0 01-6.13-1.32 15.05 15.05 0 01-4.89-3.66 17.4 17.4 0 01-3.28-5.49 19.3 19.3 0 01-1.19-6.89 35.69 35.69 0 011.53-10.63 26.73 26.73 0 014.42-8.64 20.46 20.46 0 0116.59-8 12.9 12.9 0 017.4 1.87 9.08 9.08 0 013.66 4.94h1.53l1.19-5.62h10.72l-6.79 32.13zm-20.55 1.11a11.54 11.54 0 009.4-4.51 15.06 15.06 0 002.42-4.81c.574-1.89.86-3.855.85-5.83a9.27 9.27 0 00-2.3-6.51 7.91 7.91 0 00-6.13-2.51 11.68 11.68 0 00-5.45 1.23 12.16 12.16 0 00-4 3.28 14.54 14.54 0 00-2.47 4.81 19.7 19.7 0 00-.85 5.83 10.06 10.06 0 002.08 6.3c1.38 1.813 3.53 2.72 6.45 2.72z') 302 | 303 | .panel-share(ref="panel--share" v-on:pointerdown="cancelSwipe") 304 | div.panel-share__inner(v-if="shareVisible") 305 | .panel__title 306 | h4.panel__settingtitle Save & Share Palette 307 | h1.title 308 | |{{paletteTitle}} 309 | 310 | label.panel__setting 311 | h4.panel__settingtitle 312 | |Link to palette 313 | .panel__inputs 314 | input(type="url" :value="currentURL").input--url 315 | 316 | 317 | label.panel__setting 318 | h4.panel__settingtitle Export as 319 | .panel__inputs.panel__inputs--select 320 | svg 321 | use(xlink:href="#icon-dropdown") 322 | select(v-model="exportAs") 323 | each exp in [{n: 'Simple List', v:'list'}, {n: 'CSV List', v:'csvList'}, {n: 'JS Array', v:'jsArray'}, {n: 'JS Object', v:'jsObject'}, {n: 'CSS Custom Properies', v:'css'}, {n: 'CSS Gradient', v:'cssGradient'}, {n: 'Image', v:'image'}, {n: 'SVG Gradient', v:'SVG'}] 324 | option(value=exp.v)=exp.n 325 | 326 | div.panel__export 327 | button.minibutton(:class="{'minibutton--copy': isCopiying}" aria-label="copy to clipboard" v-on:click="copyExport") 328 | span.minibutton__label copy 329 | svg.minibutton__icon 330 | use(xlink:href="#icon-copy") 331 | svg.minibutton__icon.minibutton__icon--check 332 | use(xlink:href="#icon-check") 333 | 334 | pre.panel__code(v-if="exportAs !== 'image' && exportAs !== 'SVG'") 335 | code {{colorList}} 336 | div.panel__img.panel__img--export(v-if="exportAs === 'image'") 337 | img(:src="buildImage(600, 0, true).toDataURL('image/png')" style="max-width: 100%;") 338 | div.panel__img.panel__img--export(v-if="exportAs === 'SVG'", v-html="buildSVG(600, 0, true)" style="max-width: 100%;") 339 | label.panel__setting 340 | strong.panel__settingtitle Color Values 341 | .panel__inputs.panel__inputs--select 342 | svg 343 | use(xlink:href="#icon-dropdown") 344 | select(v-model="colorValueType") 345 | option(v-for="valueType in colorValueTypes", :value=valueType). 346 | {{ valueType }} 347 | 348 | div.panel__setting.panel__share 349 | h4.panel__settingtitle Share This Palette 350 | each provider in ['facebook', 'twitter', 'telegram', 'pocket', 'reddit', 'evernote', 'linkedin', 'pinterest', 'whatsapp', 'email'] 351 | a(:href="getShareLink('" + provider + "')" rel="noopener" target="_blank")= provider 352 | 353 | .footer 354 | article.footer__about 355 | h2 Support FarbVélo 356 | p. 357 | FarbVélo does not track you and has no ads. 358 | But hosting and maintaining this project is not free. 359 | Please consider a one time 360 | or recurring donation 361 | to help me maintain FarbVélo and build more color tools. 362 | 363 | //aside.footer__about 364 | h2 Sponsors 365 | ol 366 | li: a(href="https://neverything.me/") Silvan Hagen (25USD/month) 367 | li: a(href="https://dy.github.io/") Dmitry Iv. (10USD/month) 368 | 369 | article.colors(v-on:pointerdown="cancelSwipe") 370 | h2.colors__title Generated Color Palette 371 | color(v-for="(c, i) in colors", :nextcolorhex="colors[i+1] || colors[colors.length - 2]" :name="names.length ? names[i] : 'rainbow'", :colorvaluetype="colorValueType", :colorhex="c", :contrastcolor="getContrastColor(c)" :contrastcolors="wcagContrastColors[i]") 372 | 373 | .buttons 374 | button.button.settings(v-on:click="toggleSettings", aria-label="show settngs panel") 375 | svg 376 | use(xlink:href="#icon-cog") 377 | button.button.share(v-on:click="toggleShare", aria-label="share or save palette") 378 | svg 379 | use(xlink:href="#icon-share") 380 | button.button.refresh(title="Or hit the Space bar", v-on:click="newColors(true)", aria-label="generate new palette") 381 | svg 382 | use(xlink:href="#icon-refresh") 383 | 384 | .icons. 385 | 424 | script(src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.min.js") 425 | script(src="main.js") 426 | 427 | -------------------------------------------------------------------------------- /lib/chroma-extensions.js: -------------------------------------------------------------------------------- 1 | import chroma from 'chroma-js'; 2 | import { 3 | okhsvToOklab, 4 | oklabToOkhsv, 5 | okhslToOklab, 6 | oklabToOkhsl, 7 | } from "./okhsv-conversions.js"; 8 | 9 | // Add Okhsv support to Chroma.js 10 | 11 | // Helper to extract arguments for okhsv/okhsl constructors 12 | function parseOkColorArgs(args, keys) { 13 | let vals = keys.map(() => undefined); 14 | let alpha = 1; 15 | if (args.length === 1 && typeof args[0] === 'object' && args[0] !== null) { 16 | if (Array.isArray(args[0])) { 17 | vals = args[0].slice(0, keys.length); 18 | alpha = args[0][keys.length] === undefined ? 1 : args[0][keys.length]; 19 | } else { 20 | vals = keys.map(k => args[0][k]); 21 | alpha = args[0].alpha === undefined ? 1 : args[0].alpha; 22 | } 23 | } else if (args.length >= keys.length) { 24 | vals = args.slice(0, keys.length); 25 | alpha = args[keys.length] === undefined ? 1 : args[keys.length]; 26 | } else { 27 | throw new Error(`Invalid arguments for chroma.ok${keys.join('')}. Expected (${keys.join(',')},[alpha]), or ([${keys.join(',')},alpha]), or ({${keys.join(',')},alpha}).`); 28 | } 29 | if (vals.some(v => typeof v === 'undefined')) { 30 | throw new Error(`Invalid Ok${keys.join('')} components: ${keys.join(', ')} must be provided.`); 31 | } 32 | return [...vals, alpha]; 33 | } 34 | 35 | // Constructor: chroma.okhsv(h, s, v, alpha) or chroma.okhsv([h,s,v,a]) or chroma.okhsv({h,s,v,a}) 36 | // h in [0, 360], s in [0, 1], v in [0, 1], alpha in [0,1] 37 | chroma.okhsv = function(...args) { 38 | const [h, s, v, alpha] = parseOkColorArgs(args, ['h', 's', 'v']); 39 | const oklabColor = okhsvToOklab(h, s, v); 40 | return chroma.oklab(oklabColor.L, oklabColor.a, oklabColor.b).alpha(alpha); 41 | }; 42 | 43 | // Method: color.okhsv() -> [h, s, v, alpha] 44 | // Returns h in [0, 360], s in [0, 1], v in [0, 1], alpha in [0,1] 45 | chroma.Color.prototype.okhsv = function() { 46 | const [L, a, b] = this.oklab(); // Gets L,a,b from the current color 47 | const okhsvColor = oklabToOkhsv(L, a, b); 48 | return [okhsvColor.h, okhsvColor.s, okhsvColor.v, this.alpha()]; 49 | }; 50 | 51 | // Add Okhsl support to Chroma.js 52 | 53 | // Constructor: chroma.okhsl(h, s, l, alpha) or chroma.okhsl([h,s,l,a]) or chroma.okhsl({h,s,l,a}) 54 | // h in [0, 360], s in [0, 1], l in [0, 1], alpha in [0,1] 55 | chroma.okhsl = function(...args) { 56 | const [h, s, l, alpha] = parseOkColorArgs(args, ['h', 's', 'l']); 57 | const oklabColor = okhslToOklab(h, s, l); 58 | return chroma.oklab(oklabColor.L, oklabColor.a, oklabColor.b).alpha(alpha); 59 | }; 60 | 61 | // Method: color.okhsl() -> [h, s, l, alpha] 62 | // Returns h in [0, 360], s in [0, 1], l in [0, 1], alpha in [0,1] 63 | chroma.Color.prototype.okhsl = function() { 64 | const [L, a, b] = this.oklab(); // Gets L,a,b from the current color 65 | const okhslColor = oklabToOkhsl(L, a, b); 66 | return [okhslColor.h, okhslColor.s, okhslColor.l, this.alpha()]; 67 | }; 68 | 69 | export default chroma; 70 | -------------------------------------------------------------------------------- /lib/export-utils.js: -------------------------------------------------------------------------------- 1 | // Clipboard and export utility functions for Farbvelo 2 | 3 | export function buildImage(colors, lightmode, size = 100, padding = 0.1, hardStops = false) { 4 | const canvas = document.createElement("canvas"); 5 | canvas.width = size; 6 | canvas.height = size; 7 | const innerSize = size * (1 - padding * 2); 8 | const ctx = canvas.getContext("2d"); 9 | const gradient = ctx.createLinearGradient(0, 0, 0, size); 10 | ctx.fillStyle = lightmode ? "#fff" : "#000"; 11 | ctx.fillRect(0, 0, size, size); 12 | colors.forEach((color, i) => { 13 | if (hardStops) { 14 | ctx.fillStyle = color; 15 | ctx.fillRect( 16 | size * padding, 17 | size * padding + (i / colors.length) * innerSize - 1, 18 | innerSize, 19 | innerSize / colors.length + 1 20 | ); 21 | } else { 22 | gradient.addColorStop(Math.min(1, i / colors.length), color); 23 | } 24 | }); 25 | if (!hardStops) { 26 | ctx.fillStyle = gradient; 27 | ctx.fillRect( 28 | size * padding, 29 | size * padding, 30 | size * (1 - padding * 2), 31 | size * (1 - padding * 2) 32 | ); 33 | } 34 | return canvas; 35 | } 36 | 37 | export function buildSVG(colors, size = 100, padding = 0.1, hardStops = false) { 38 | return ` 39 | 40 | 41 | ${colors 42 | .map((color, i) => { 43 | return ``; 44 | }) 45 | .join("")} 46 | 47 | 48 | 49 | `; 50 | } 51 | 52 | export function copyExport({ 53 | exportAs, 54 | colorList, 55 | colors, 56 | lightmode, 57 | buildImageFn, 58 | buildSVGFn, 59 | setCopying 60 | }) { 61 | clearTimeout(copyExport.copyTimer); 62 | setCopying(true); 63 | copyExport.copyTimer = setTimeout(() => setCopying(false), 1000); 64 | if (exportAs === "image") { 65 | buildImageFn(colors, lightmode, 1000, 0.1, true).toBlob((blob) => { 66 | const item = new ClipboardItem({ 67 | "image/png": blob, 68 | }); 69 | navigator.clipboard.write([item]); 70 | }); 71 | } else if (exportAs === "SVG" || exportAs === "svg") { 72 | const svg = buildSVGFn(colors, 1000, 0.1, true); 73 | navigator.clipboard.writeText(svg); 74 | } else { 75 | navigator.clipboard.writeText(colorList); 76 | } 77 | } 78 | 79 | export function shareURL(url) { 80 | navigator.clipboard.writeText(url); 81 | } 82 | -------------------------------------------------------------------------------- /lib/generate-random-colors.js: -------------------------------------------------------------------------------- 1 | import SimplexNoise from 'simplex-noise'; 2 | import randomColor from 'randomcolor'; 3 | import { shuffleArray, coordsToHex } from '../utils.js'; 4 | 5 | export default function generateRandomColors({ 6 | generatorFunction, 7 | random, 8 | currentSeed, 9 | colorMode, 10 | amount = 6, 11 | parts = 4, 12 | randomOrder = false, 13 | minHueDiffAngle = 60 14 | }) { 15 | let colors = []; 16 | minHueDiffAngle = parseInt(minHueDiffAngle); 17 | amount = parseInt(amount); 18 | parts = parseInt(parts); 19 | minHueDiffAngle = Math.min(minHueDiffAngle, 360 / parts); 20 | 21 | if (generatorFunction === 'Hue Bingo') { 22 | const baseHue = random(0, 360); 23 | const hues = new Array(Math.round(360 / minHueDiffAngle)).fill('').map((_, i) => (baseHue + i * minHueDiffAngle) % 360); 24 | const baseSaturation = random(5, 40); 25 | const baseLightness = random(0, 20); 26 | const rangeLightness = 90 - baseLightness; 27 | colors.push(coordsToHex(hues[0], baseSaturation, baseLightness * random(0.25, 0.75), colorMode)); 28 | const minSat = random(50, 70); 29 | const maxSat = minSat + 30; 30 | const minLight = random(35, 70); 31 | const maxLight = Math.min(minLight + random(20, 40), 95); 32 | const remainingHues = [...hues]; 33 | for (let i = 0; i < parts - 2; i++) { 34 | const hue = remainingHues.splice(random(0, remainingHues.length - 1), 1)[0]; 35 | const saturation = random(minSat, maxSat); 36 | const light = baseLightness + random(0, 10) + ((rangeLightness / (parts - 1)) * i); 37 | colors.push(coordsToHex(hue, saturation, random(light, maxLight), colorMode)); 38 | } 39 | colors.push(coordsToHex(remainingHues[0], baseSaturation, rangeLightness + 10, colorMode)); 40 | } else if (generatorFunction === 'Legacy') { 41 | const part = Math.floor(amount / parts); 42 | const reminder = amount % parts; 43 | const baseHue = random(0, 360); 44 | const hues = new Array(Math.round(360 / minHueDiffAngle)).fill('').map((_, i) => (baseHue + i * minHueDiffAngle) % 360); 45 | const baseSaturation = random(5, 40); 46 | const baseLightness = random(0, 20); 47 | const rangeLightness = 90 - baseLightness; 48 | colors.push(coordsToHex(hues[0], baseSaturation, baseLightness * random(0.25, 0.75), colorMode)); 49 | for (let i = 0; i < (part - 1); i++) { 50 | colors.push(coordsToHex(hues[0], baseSaturation, baseLightness + (rangeLightness * Math.pow(i / (part - 1), 1.5)), colorMode)); 51 | } 52 | const minSat = random(50, 70); 53 | const maxSat = minSat + 30; 54 | const minLight = random(45, 80); 55 | const maxLight = Math.min(minLight + 40, 95); 56 | for (let i = 0; i < (part + reminder - 1); i++) { 57 | colors.push(coordsToHex(hues[random(0, hues.length - 1)], random(minSat, maxSat), random(minLight, maxLight), colorMode)); 58 | } 59 | colors.push(coordsToHex(hues[0], baseSaturation, rangeLightness, colorMode)); 60 | } else if (generatorFunction === 'Full Random') { 61 | for (let i = 0; i < parts; i++) { 62 | colors.push(coordsToHex(random(0, 360), random(0, 100), random(0, 100), colorMode)); 63 | } 64 | } else if (generatorFunction === 'Simplex Noise') { 65 | const simplex = new SimplexNoise(currentSeed); 66 | const minLight = random(50, 80); 67 | const maxLight = Math.min(minLight + 40, 95); 68 | const minSat = random(20, 80); 69 | const maxSat = random(80, 100); 70 | const satRamp = maxSat - minSat; 71 | for (let i = 0; i < parts + 1; i++) { 72 | colors.push(coordsToHex(simplex.noise2D(.5, (i / parts) * (3 * (minHueDiffAngle / 360))) * 360, minSat + (i / parts) * satRamp, i ? 55 + i / parts * (maxLight - minLight) : random(10, 40), colorMode)); 73 | } 74 | } else if (generatorFunction === 'RandomColor.js') { 75 | colors = [ 76 | randomColor({ luminosity: 'dark', seed: currentSeed }), 77 | ...randomColor({ seed: currentSeed + 50, count: parts - 2 }), 78 | randomColor({ luminosity: 'light', seed: currentSeed + 100 }) 79 | ]; 80 | } 81 | if (randomOrder) { 82 | colors = shuffleArray(colors); 83 | } 84 | return colors; 85 | } 86 | -------------------------------------------------------------------------------- /lib/image-palette.js: -------------------------------------------------------------------------------- 1 | // Handles image loading and palette extraction logic 2 | import chroma from "chroma-js"; 3 | import { quantize as quantizeGifenc } from 'gifenc'; 4 | 5 | const CANVAS_SCALE = 0.4; 6 | const workers = []; 7 | 8 | export function startColorLocatorWorker(imageDataObject, targetRgbColors, onResultCallback, onErrorCallback) { 9 | if (!imageDataObject || !imageDataObject.data || typeof imageDataObject.width !== 'number' || typeof imageDataObject.height !== 'number') { 10 | if (onErrorCallback) onErrorCallback({ message: "Valid imageData object (with data, width, height) is required." }); 11 | return null; 12 | } 13 | if (!targetRgbColors || !Array.isArray(targetRgbColors) || targetRgbColors.some(c => typeof c.r !== 'number' || typeof c.g !== 'number' || typeof c.b !== 'number')) { 14 | if (onErrorCallback) onErrorCallback({ message: "targetRgbColors must be an array of {r,g,b} objects." }); 15 | return null; 16 | } 17 | 18 | const worker = new Worker('../color-locator-worker.js'); // Path relative to lib/image-palette.js 19 | 20 | // Create a mapping from rgbKey to hex color to help with logging 21 | const rgbToHexMap = {}; 22 | targetRgbColors.forEach((rgb, index) => { 23 | const key = `${rgb.r}-${rgb.g}-${rgb.b}`; 24 | rgbToHexMap[key] = chroma(rgb.r, rgb.g, rgb.b).hex(); 25 | }); 26 | 27 | worker.onmessage = (event) => { 28 | if (event.data.status === 'SUCCESS') { 29 | if (onResultCallback) onResultCallback(event.data.locations); 30 | } else if (event.data.status === 'ERROR') { 31 | console.error('Color locator worker error:', event.data.message); 32 | if (onErrorCallback) onErrorCallback({ message: event.data.message, stack: event.data.stack }); 33 | } 34 | }; 35 | 36 | worker.onerror = (error) => { 37 | console.error('Worker error event:', error); 38 | if (onErrorCallback) onErrorCallback({ message: `Worker error: ${error.message}`, errorObject: error }); 39 | }; 40 | 41 | const messagePayload = { 42 | imageData: imageDataObject, // Expected to be { width, height, data: Uint8ClampedArray } 43 | targetRgbColors: targetRgbColors 44 | }; 45 | 46 | worker.postMessage(messagePayload); 47 | return worker; 48 | } 49 | 50 | export function startWorker(colors, imageUrl, imageData, width, filterOptions, colorsLength) { 51 | // Fix worker path to point to the root-level worker.js 52 | const worker = new Worker('../worker.js'); 53 | workers.push(worker); 54 | 55 | worker.addEventListener( 56 | 'message', 57 | (e) => { 58 | switch (e.data.type) { 59 | case 'GENERATE_COLORS_ARRAY': { 60 | const pixels = e.data.colors; 61 | worker.postMessage({ 62 | type: 'GENERATE_CLUSTERS', 63 | pixels, 64 | k: colorsLength, 65 | filterOptions, 66 | }); 67 | break; 68 | } 69 | case 'GENERATE_CLUSTERS': { 70 | const clusters = e.data.colors; 71 | colors.colorsValues = clusters.sort((c1, c2) => 72 | chroma(c1).lch()[0] - chroma(c2).lch()[0] 73 | ); 74 | document.documentElement.classList.remove('is-imagefetching'); 75 | break; 76 | } 77 | } 78 | }, 79 | false 80 | ); 81 | 82 | worker.postMessage({ 83 | type: 'GENERATE_COLORS_ARRAY', 84 | imageData, 85 | width, 86 | k: colorsLength, 87 | }); 88 | } 89 | 90 | export function imageLoadCallback(colors, image, canvas, ctx, colorsLength, quantizationMethod) { 91 | const width = Math.floor(image.naturalWidth * CANVAS_SCALE); 92 | const height = Math.floor(image.naturalHeight * CANVAS_SCALE); 93 | canvas.width = width; 94 | canvas.height = height; 95 | ctx.drawImage(image, 0, 0, width, height); 96 | const imageData = ctx.getImageData(0, 0, width, height); 97 | 98 | // Store a clone of necessary imageData parts on the 'colors' object (Vue instance from main.js) 99 | if (colors && typeof colors === 'object') { 100 | colors.currentImageData = { 101 | width: imageData.width, 102 | height: imageData.height, 103 | // Create a copy of the data buffer to ensure it's safe and available for later use 104 | data: new Uint8ClampedArray(imageData.data) 105 | }; 106 | } 107 | 108 | const filterOptions = { saturation: 0, lightness: 0 }; 109 | if (quantizationMethod === 'gifenc') { 110 | const rgbColors = quantizeGifenc(imageData.data, colorsLength); 111 | const hexColors = rgbColors.map(rgb => chroma(rgb[0], rgb[1], rgb[2]).hex()); 112 | colors.colorsValues = hexColors; 113 | document.documentElement.classList.remove('is-imagefetching'); 114 | } else { 115 | startWorker(colors, image.src, imageData, width, filterOptions, colorsLength); 116 | } 117 | } 118 | 119 | export function loadImage(colors, canvas, ctx, source, colorsLength, quantizationMethod) { 120 | workers.forEach(w => w.terminate()); 121 | const image = new Image(); 122 | image.crossOrigin = 'Anonymous'; 123 | image.src = source; 124 | image.onload = imageLoadCallback.bind(null, colors, image, canvas, ctx, colorsLength, quantizationMethod); 125 | } 126 | -------------------------------------------------------------------------------- /lib/okhsv-conversions.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Farbvelo Okhsv <-> Oklab Conversion Utilities 3 | * Based on Oklab color space by Björn Ottosson 4 | * Okhsv and Okhsl definitions also by Björn Ottosson 5 | * 6 | * Adapted from code by Björn Ottosson, 7 | * released under the MIT license: 8 | * 9 | * Copyright (c) 2021 Björn Ottosson 10 | * 11 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 12 | * this software and associated documentation files (the "Software"), to deal in 13 | * the Software without restriction, including without limitation the rights to 14 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 15 | * of the Software, and to permit persons to whom the Software is furnished to do 16 | * so, subject to the following conditions: 17 | * 18 | * The above copyright notice and this permission notice shall be included in all 19 | * copies or substantial portions of the Software. 20 | * 21 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 27 | * SOFTWARE. 28 | */ 29 | 30 | // Non-linearity functions for Oklab cone responses (effectively cbrt and cube) 31 | function toe(x) { 32 | return Math.cbrt(x); 33 | } 34 | 35 | function toe_inv(x_prime) { 36 | return x_prime * x_prime * x_prime; 37 | } 38 | 39 | // Matrix to convert LMS (cone responses) to XYZ. (M1_inv from Ottosson's post) 40 | const LMS_TO_XYZ_MATRIX = [ 41 | [1.22701385, -0.55779998, 0.28125615], 42 | [-0.04058017, 1.11225686, -0.07167669], 43 | [-0.07638128, -0.42148198, 1.58616322] 44 | ]; 45 | 46 | // Coefficients to convert Oklab's L,a,b to l',m',s' (primed cone responses). 47 | // Derived from M2_inv matrix from Ottosson's post. 48 | const OKLAB_TO_LMS_PRIME_COEFFS = { 49 | a_coeffs: [0.3963377774, -0.1055613458, -0.0894841775], // Coefficients for a_ok 50 | b_coeffs: [0.2158037573, -0.0638541728, -1.2914855480] // Coefficients for b_ok 51 | }; 52 | 53 | // Matrix to convert XYZ to Linear sRGB (D65 illuminant). 54 | const XYZ_TO_SRGB_MATRIX = [ 55 | [3.2404542, -1.5371385, -0.4985314], 56 | [-0.9692660, 1.8760108, 0.0415560], 57 | [0.0556434, -0.2040259, 1.0572252] 58 | ]; 59 | 60 | // Helper function: Convert Oklab to Linear sRGB 61 | function oklab_to_linear_srgb(L_ok, a_ok, b_ok) { 62 | const l_p = L_ok + OKLAB_TO_LMS_PRIME_COEFFS.a_coeffs[0] * a_ok + OKLAB_TO_LMS_PRIME_COEFFS.b_coeffs[0] * b_ok; 63 | const m_p = L_ok + OKLAB_TO_LMS_PRIME_COEFFS.a_coeffs[1] * a_ok + OKLAB_TO_LMS_PRIME_COEFFS.b_coeffs[1] * b_ok; 64 | const s_p = L_ok + OKLAB_TO_LMS_PRIME_COEFFS.a_coeffs[2] * a_ok + OKLAB_TO_LMS_PRIME_COEFFS.b_coeffs[2] * b_ok; 65 | 66 | const L_cone = toe_inv(l_p); 67 | const M_cone = toe_inv(m_p); 68 | const S_cone = toe_inv(s_p); 69 | 70 | const X = LMS_TO_XYZ_MATRIX[0][0] * L_cone + LMS_TO_XYZ_MATRIX[0][1] * M_cone + LMS_TO_XYZ_MATRIX[0][2] * S_cone; 71 | const Y = LMS_TO_XYZ_MATRIX[1][0] * L_cone + LMS_TO_XYZ_MATRIX[1][1] * M_cone + LMS_TO_XYZ_MATRIX[1][2] * S_cone; 72 | const Z = LMS_TO_XYZ_MATRIX[2][0] * L_cone + LMS_TO_XYZ_MATRIX[2][1] * M_cone + LMS_TO_XYZ_MATRIX[2][2] * S_cone; 73 | 74 | const r_lin = XYZ_TO_SRGB_MATRIX[0][0] * X + XYZ_TO_SRGB_MATRIX[0][1] * Y + XYZ_TO_SRGB_MATRIX[0][2] * Z; 75 | const g_lin = XYZ_TO_SRGB_MATRIX[1][0] * X + XYZ_TO_SRGB_MATRIX[1][1] * Y + XYZ_TO_SRGB_MATRIX[1][2] * Z; 76 | const b_lin = XYZ_TO_SRGB_MATRIX[2][0] * X + XYZ_TO_SRGB_MATRIX[2][1] * Y + XYZ_TO_SRGB_MATRIX[2][2] * Z; 77 | 78 | return { r: r_lin, g: g_lin, b: b_lin }; 79 | } 80 | 81 | // Helper: Finds the maximum chroma C for a given Oklab L and hue h_rad within sRGB gamut 82 | function find_gamut_intersection_chroma(h_rad, L_target) { 83 | if (L_target < -0.001 || L_target > 1.001) return 0; // L must be ~[0, 1] 84 | 85 | let low_C = 0; 86 | let high_C = 0.5; // Max possible chroma in Oklab for sRGB is ~0.3-0.4 87 | const N_ITERATIONS = 15; 88 | let C_max_in_gamut = 0; 89 | 90 | for (let i = 0; i < N_ITERATIONS; i++) { 91 | const mid_C = (low_C + high_C) / 2; 92 | if (mid_C < 1e-5) { 93 | C_max_in_gamut = Math.max(C_max_in_gamut, mid_C); 94 | low_C = mid_C; 95 | continue; 96 | } 97 | const a = mid_C * Math.cos(h_rad); 98 | const b = mid_C * Math.sin(h_rad); 99 | const rgb = oklab_to_linear_srgb(L_target, a, b); 100 | const tolerance = 1e-4; 101 | if (rgb.r >= -tolerance && rgb.r <= 1 + tolerance && 102 | rgb.g >= -tolerance && rgb.g <= 1 + tolerance && 103 | rgb.b >= -tolerance && rgb.b <= 1 + tolerance) { 104 | C_max_in_gamut = mid_C; 105 | low_C = mid_C; 106 | } else { 107 | high_C = mid_C; 108 | } 109 | } 110 | return C_max_in_gamut; 111 | } 112 | 113 | // Helper: Approximates the L_cusp and C_cusp for a given hue. 114 | // NOTE: This is a computationally intensive approximation. 115 | function get_cusp_approx(h_rad) { 116 | let max_C_found = 0; 117 | let L_at_max_C = 0.5; 118 | const L_STEPS = 50; // Performance vs. accuracy trade-off 119 | 120 | for (let i = 0; i <= L_STEPS; i++) { 121 | const l_test = i / L_STEPS; 122 | const c_at_l_test = find_gamut_intersection_chroma(h_rad, l_test); 123 | if (c_at_l_test > max_C_found) { 124 | max_C_found = c_at_l_test; 125 | L_at_max_C = l_test; 126 | } 127 | } 128 | if (max_C_found < 1e-5) { // Achromatic or near-achromatic 129 | L_at_max_C = 0.5; // Default L_cusp for C_cusp = 0 130 | } 131 | return { L_cusp: L_at_max_C, C_cusp: max_C_found }; 132 | } 133 | 134 | /** 135 | * Converts Okhsv color coordinates to Oklab. 136 | * @param {number} h_okhsv_deg Hue in degrees [0, 360). 137 | * @param {number} s_okhsv Saturation [0, 1]. 138 | * @param {number} v_okhsv Value [0, 1]. 139 | * @returns {{L: number, a: number, b: number}} Oklab color. 140 | */ 141 | function okhsvToOklab(h_okhsv_deg, s_okhsv, v_okhsv) { 142 | // Handle achromatic case: if saturation is near zero, L is v, a and b are 0. 143 | if (s_okhsv < 1e-5) { 144 | return { L: Math.max(0.0, Math.min(1.0, v_okhsv)), a: 0, b: 0 }; 145 | } 146 | 147 | const h_rad = (h_okhsv_deg % 360) * Math.PI / 180.0; 148 | const { L_cusp } = get_cusp_approx(h_rad); 149 | // Note: C_cusp from get_cusp_approx is not directly used here, but L_cusp is vital. 150 | 151 | // V_okhsv maps L_ok from 0 up to L_cusp. 152 | // So, L_ok = v_okhsv * L_cusp. When v_okhsv is 1, L_ok should be L_cusp. 153 | let L_ok = v_okhsv * L_cusp; 154 | L_ok = Math.max(0.0, Math.min(1.0, L_ok)); // Clamp L_ok for stability 155 | 156 | // S_okhsv maps C_ok from 0 up to the max chroma possible for this L_ok and h_rad. 157 | const C_ok_max_at_L = find_gamut_intersection_chroma(h_rad, L_ok); 158 | const C_ok = s_okhsv * C_ok_max_at_L; 159 | 160 | const a_ok = C_ok * Math.cos(h_rad); 161 | const b_ok = C_ok * Math.sin(h_rad); 162 | return { L: L_ok, a: a_ok, b: b_ok }; 163 | } 164 | 165 | /** 166 | * Converts Oklab color coordinates to Okhsv. 167 | * @param {number} L_ok Lightness [0, 1]. 168 | * @param {number} a_ok Green-red axis. 169 | * @param {number} b_ok Blue-yellow axis. 170 | * @returns {{h: number, s: number, v: number}} Okhsv color (h in degrees [0, 360)). 171 | */ 172 | function oklabToOkhsv(L_ok, a_ok, b_ok) { 173 | const C_ok = Math.sqrt(a_ok * a_ok + b_ok * b_ok); 174 | 175 | // Handle achromatic case: if chroma is near zero, s is 0, v is L_ok. Hue is arbitrary (e.g., 0). 176 | if (C_ok < 1e-5) { 177 | return { h: 0, s: 0, v: Math.max(0.0, Math.min(1.0, L_ok)) }; 178 | } 179 | 180 | let h_rad = Math.atan2(b_ok, a_ok); 181 | 182 | const L_ok_clamped = Math.max(0.0, Math.min(1.0, L_ok)); 183 | const { L_cusp } = get_cusp_approx(h_rad); // C_cusp is also returned but not directly used here. 184 | 185 | let v_okhsv; 186 | if (L_cusp < 1e-5) { 187 | // If L_cusp is effectively zero (e.g. for an achromatic hue, or error in cusp calculation), 188 | // L_ok_clamped / L_cusp is ill-defined or very large. 189 | // Set v_okhsv to 1 if L_ok is not black, otherwise 0. 190 | v_okhsv = (L_ok_clamped > 1e-5) ? 1.0 : 0.0; 191 | } else { 192 | // V_OKhsv = L_ok / L_cusp (simplified, assumes L_ok <= L_cusp) 193 | v_okhsv = L_ok_clamped / L_cusp; 194 | } 195 | v_okhsv = Math.max(0, Math.min(1, v_okhsv)); // Clamp v_okhsv to [0, 1] 196 | 197 | const C_ok_max_at_L = find_gamut_intersection_chroma(h_rad, L_ok_clamped); 198 | let s_okhsv = (C_ok_max_at_L < 1e-5) ? 0 : C_ok / C_ok_max_at_L; 199 | s_okhsv = Math.max(0, Math.min(1, s_okhsv)); // Clamp s_okhsv to [0, 1] 200 | 201 | let h_okhsv_deg = h_rad * 180.0 / Math.PI; 202 | h_okhsv_deg = (h_okhsv_deg % 360 + 360) % 360; // Normalize hue to [0, 360) 203 | 204 | return { h: h_okhsv_deg, s: s_okhsv, v: v_okhsv }; 205 | } 206 | 207 | // Constants for Okhsl's L_r lightness estimate 208 | const K1_LR = 0.206; 209 | const K2_LR = 0.03; 210 | const K3_LR = (1.0 + K1_LR) / (1.0 + K2_LR); 211 | 212 | // Toe function for Okhsl's L_r from Oklab's L 213 | function toe_Lr(L_ok) { 214 | return 0.5 * (K3_LR * L_ok - K1_LR + Math.sqrt((K3_LR * L_ok - K1_LR) * (K3_LR * L_ok - K1_LR) + 4 * K2_LR * K3_LR * L_ok)); 215 | } 216 | 217 | // Inverse toe function for Oklab's L from Okhsl's l (L_r) 218 | function toe_inv_Lr(l_okhsl) { 219 | return (l_okhsl * l_okhsl + K1_LR * l_okhsl) / (K3_LR * (l_okhsl + K2_LR)); 220 | } 221 | 222 | // Helper for Okhsl: Smooth approximation of the cusp location for C_mid 223 | // Polynomial coefficients from Ottosson's C++ code (get_ST_mid) 224 | function get_ST_mid(a_, b_) { 225 | const S = 0.11516993 + 1.0 / ( 226 | +7.44778970 + 227 | +4.15901240 * b_ + 228 | a_ * (-2.19557347 + 229 | +1.75198401 * b_ + 230 | a_ * (-2.13704948 + 231 | -10.02301043 * b_ + 232 | a_ * (-4.24894561 + 233 | +5.38770819 * b_ + 4.69891013 * a_ 234 | ) 235 | ) 236 | ) 237 | ); 238 | const T = 0.11239642 + 1.0 / ( 239 | +1.61320320 + 240 | -0.68124379 * b_ + 241 | a_ * (+0.40370612 + 242 | +0.90148123 * b_ + 243 | a_ * (-0.27087943 + 244 | +0.61223990 * b_ + 245 | a_ * (+0.00299215 + 246 | -0.45399568 * b_ - 0.14661872 * a_ 247 | ) 248 | ) 249 | ) 250 | ); 251 | return { S_mid: S, T_mid: T }; 252 | } 253 | 254 | // Helper for Okhsl: Calculate C_0, C_mid, C_max for a given L_oklab and hue (a_, b_) 255 | function get_Cs(L_oklab, a_, b_, h_rad) { 256 | const cusp = get_cusp_approx(h_rad); // { L_cusp, C_cusp } 257 | 258 | // C_max: Max chroma in sRGB gamut for this L_oklab and hue 259 | const C_max = find_gamut_intersection_chroma(h_rad, L_oklab); 260 | 261 | // Scale factor k to compensate for the curved part of gamut shape 262 | // cusp.L_cusp can be 0 for black, cusp.C_cusp can be 0. 263 | // Avoid division by zero if L_cusp or (1-L_cusp) is zero. 264 | let S_cusp = 0; 265 | let T_cusp = 0; 266 | if (cusp.L_cusp > 1e-7) S_cusp = cusp.C_cusp / cusp.L_cusp; 267 | if ((1.0 - cusp.L_cusp) > 1e-7) T_cusp = cusp.C_cusp / (1.0 - cusp.L_cusp); 268 | 269 | const C_max_triangle_component = Math.min(L_oklab * S_cusp, (1.0 - L_oklab) * T_cusp); 270 | const k = (C_max_triangle_component > 1e-7) ? C_max / C_max_triangle_component : 0; 271 | 272 | // C_mid: Smoothed chroma estimate 273 | const { S_mid, T_mid } = get_ST_mid(a_, b_); 274 | const C_a_mid = L_oklab * S_mid; 275 | const C_b_mid = (1.0 - L_oklab) * T_mid; 276 | // Soft minimum: ( (C_a_mid^-n + C_b_mid^-n) / 2 ) ^ (-1/n) 277 | // Using n=4 as in Ottosson's code (sqrt(sqrt(1/(1/C_a^4 + 1/C_b^4)))) 278 | // Simplified to avoid issues with C_a_mid or C_b_mid being zero or very small. 279 | let C_mid_val; 280 | if (C_a_mid < 1e-7 || C_b_mid < 1e-7) { 281 | C_mid_val = 0; 282 | } else { 283 | C_mid_val = 0.9 * k * Math.pow(1.0 / (Math.pow(C_a_mid, -4) + Math.pow(C_b_mid, -4)), 0.25); 284 | } 285 | 286 | 287 | // C_0: Chroma for near-achromatic colors, hue-independent shape 288 | // Values for S_0 and T_0 are constants (0.4 and 0.8) from Ottosson's code 289 | const C_a_0 = L_oklab * 0.4; 290 | const C_b_0 = (1.0 - L_oklab) * 0.8; 291 | // Soft minimum with n=2 292 | let C_0_val; 293 | if (C_a_0 < 1e-7 || C_b_0 < 1e-7) { 294 | C_0_val = 0; 295 | } else { 296 | C_0_val = Math.sqrt(1.0 / (1.0 / (C_a_0 * C_a_0) + 1.0 / (C_b_0 * C_b_0))); 297 | } 298 | 299 | 300 | return { C_0: C_0_val, C_mid: C_mid_val, C_max: C_max }; 301 | } 302 | 303 | 304 | /** 305 | * Converts Okhsl color coordinates to Oklab. 306 | * @param {number} h_okhsl_deg Hue in degrees [0, 360). 307 | * @param {number} s_okhsl Saturation [0, 1]. 308 | * @param {number} l_okhsl Lightness [0, 1]. 309 | * @returns {{L: number, a: number, b: number}} Oklab color. 310 | */ 311 | function okhslToOklab(h_okhsl_deg, s_okhsl, l_okhsl) { 312 | if (l_okhsl >= 0.99999) return { L: 1.0, a: 0, b: 0 }; // White 313 | if (l_okhsl <= 0.00001) return { L: 0.0, a: 0, b: 0 }; // Black 314 | 315 | const L_ok = toe_inv_Lr(l_okhsl); // Oklab L from Okhsl l 316 | 317 | const h_rad = (h_okhsl_deg % 360) * Math.PI / 180.0; 318 | const a_ = Math.cos(h_rad); 319 | const b_ = Math.sin(h_rad); 320 | 321 | const { C_0, C_mid, C_max } = get_Cs(L_ok, a_, b_, h_rad); 322 | 323 | let C_ok; 324 | const mid_s = 0.8; // Saturation threshold for interpolation change 325 | const mid_s_inv = 1.0 / mid_s; 326 | 327 | if (s_okhsl < mid_s) { 328 | const t = mid_s_inv * s_okhsl; 329 | const k1 = mid_s * C_0; 330 | const k2 = (C_mid > 1e-7) ? (1.0 - k1 / C_mid) : 1.0; // Avoid division by zero if C_mid is zero 331 | C_ok = (k1 > 1e-7 && (1.0 - k2 * t) > 1e-7) ? (t * k1 / (1.0 - k2 * t)) : 0.0; 332 | } else { 333 | const t = (s_okhsl - mid_s) / (1.0 - mid_s); 334 | const k0 = C_mid; 335 | const k1 = (C_0 > 1e-7) ? ((1.0 - mid_s) * C_mid * C_mid * mid_s_inv * mid_s_inv / C_0) : 0.0; 336 | const C_delta = C_max - C_mid; 337 | const k2 = (C_delta > 1e-7 || C_delta < -1e-7) ? (1.0 - k1 / C_delta) : 1.0; // Avoid division by zero 338 | C_ok = (k1 > 1e-7 && (1.0 - k2 * t) > 1e-7) ? (k0 + t * k1 / (1.0 - k2 * t)) : k0; 339 | } 340 | C_ok = Math.max(0, C_ok); 341 | 342 | 343 | return { L: L_ok, a: C_ok * a_, b: C_ok * b_ }; 344 | } 345 | 346 | /** 347 | * Converts Oklab color coordinates to Okhsl. 348 | * @param {number} L_ok Lightness [0, 1]. 349 | * @param {number} a_ok Green-red axis. 350 | * @param {number} b_ok Blue-yellow axis. 351 | * @returns {{h: number, s: number, l: number}} Okhsl color (h in degrees [0, 360)). 352 | */ 353 | function oklabToOkhsl(L_ok, a_ok, b_ok) { 354 | const l_okhsl = toe_Lr(L_ok); // Okhsl l from Oklab L 355 | 356 | if (l_okhsl >= 0.99999) return { h: 0, s: 0, l: 1.0 }; // White 357 | if (l_okhsl <= 0.00001) return { h: 0, s: 0, l: 0.0 }; // Black 358 | 359 | const C_ok = Math.sqrt(a_ok * a_ok + b_ok * b_ok); 360 | 361 | if (C_ok < 1e-5) { // Achromatic 362 | return { h: 0, s: 0, l: l_okhsl }; 363 | } 364 | 365 | let h_rad = Math.atan2(b_ok, a_ok); 366 | const a_ = Math.cos(h_rad); // or a_ok / C_ok 367 | const b_ = Math.sin(h_rad); // or b_ok / C_ok 368 | 369 | const { C_0, C_mid, C_max } = get_Cs(L_ok, a_, b_, h_rad); 370 | 371 | let s_okhsl; 372 | 373 | if (C_ok < C_mid) { 374 | const k_0 = 0; 375 | const k_1 = 0.8 * C_0; 376 | const k_2 = (C_mid > 1e-7) ? (1 - k_1 / C_mid) : 1.0; 377 | const divisor = k_1 + k_2 * (C_ok - k_0); 378 | const t = (divisor > 1e-7) ? (C_ok - k_0) / divisor : 0.0; 379 | s_okhsl = t * 0.8; 380 | } else { 381 | const k_0 = C_mid; 382 | const k_1 = (C_0 > 1e-7) ? (0.2 * C_mid * C_mid * 1.25 * 1.25) / C_0 : 0.0; 383 | const C_delta = C_max - C_mid; 384 | const k_2 = (Math.abs(C_delta) > 1e-7) ? (1 - k_1 / C_delta) : 1.0; 385 | const divisor = k_1 + k_2 * (C_ok - k_0); 386 | const t = (divisor > 1e-7) ? (C_ok - k_0) / divisor : 0.0; 387 | s_okhsl = 0.8 + 0.2 * t; 388 | } 389 | 390 | // Handle edge cases and ensure s_okhsl is in valid range 391 | s_okhsl = Math.max(0, Math.min(1, s_okhsl)); 392 | 393 | 394 | let h_okhsl_deg = h_rad * 180.0 / Math.PI; 395 | h_okhsl_deg = (h_okhsl_deg % 360 + 360) % 360; 396 | 397 | return { h: h_okhsl_deg, s: s_okhsl, l: l_okhsl }; 398 | } 399 | 400 | 401 | // ES6 Exports 402 | export { 403 | okhsvToOklab, 404 | oklabToOkhsv, 405 | okhslToOklab, 406 | oklabToOkhsl, 407 | // The internal functions can also be exported if needed for testing/debugging elsewhere 408 | // For now, only exporting the main conversion functions. 409 | // toe, 410 | // toe_inv, 411 | // oklab_to_linear_srgb, 412 | // find_gamut_intersection_chroma, 413 | // get_cusp_approx 414 | }; 415 | -------------------------------------------------------------------------------- /lib/palette-extractor.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 Google Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | https://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | /** 18 | * @fileoverview A small collection of vec3 utilities. 19 | */ 20 | 21 | const vec3Utils = {}; 22 | 23 | /** 24 | * Performs a component-wise addition of vec0 and vec1 together storing the 25 | * result into resultVec. 26 | * @param {!Float32Array|!Float64Array|!Array} vec0 The first addend. 27 | * @param {!Float32Array|!Float64Array|!Array} vec1 The second addend. 28 | * @param {!Float32Array|!Float64Array|!Array} resultVec The vector to 29 | * receive the result. May be vec0 or vec1. 30 | * @return {!Float32Array|!Float64Array|!Array} Return resultVec 31 | * so that operations can be chained together. 32 | */ 33 | vec3Utils.add = function (vec0, vec1, resultVec) { 34 | resultVec[0] = vec0[0] + vec1[0]; 35 | resultVec[1] = vec0[1] + vec1[1]; 36 | resultVec[2] = vec0[2] + vec1[2]; 37 | return resultVec; 38 | }; 39 | 40 | /** 41 | * Multiplies each component of vec0 with scalar storing the product into 42 | * resultVec. 43 | * @param {!Float32Array|!Float64Array|!Array} vec0 The source vector. 44 | * @param {number} scalar The value to multiply with each component of vec0. 45 | * @param {!Float32Array|!Float64Array|!Array} resultVec The vector to 46 | * receive the result. May be vec0. 47 | * @return {!Float32Array|!Float64Array|!Array} Return resultVec 48 | * so that operations can be chained together. 49 | */ 50 | vec3Utils.scale = function (vec0, scalar, resultVec) { 51 | resultVec[0] = vec0[0] * scalar; 52 | resultVec[1] = vec0[1] * scalar; 53 | resultVec[2] = vec0[2] * scalar; 54 | return resultVec; 55 | }; 56 | 57 | /** 58 | * Returns the squared distance between two points. 59 | * @param {!Float32Array|!Float64Array|!Array} vec0 First point. 60 | * @param {!Float32Array|!Float64Array|!Array} vec1 Second point. 61 | * @return {number} The squared distance between the points. 62 | */ 63 | vec3Utils.distanceSquared = function (vec0, vec1) { 64 | const x = vec0[0] - vec1[0]; 65 | const y = vec0[1] - vec1[1]; 66 | const z = vec0[2] - vec1[2]; 67 | return x * x + y * y + z * z; 68 | }; 69 | 70 | /** 71 | * Initializes the vector with the given array of values. 72 | * @param {!Float32Array|!Float64Array|!Array} vec The vector 73 | * to receive the values. 74 | * @param {!Float32Array|!Float64Array|!Array} values The array 75 | * of values. 76 | * @return {!Float32Array|!Float64Array|!Array} Return vec 77 | * so that operations can be chained together. 78 | */ 79 | vec3Utils.setFromArray = function (vec, values) { 80 | vec[0] = values[0]; 81 | vec[1] = values[1]; 82 | vec[2] = values[2]; 83 | return vec; 84 | }; 85 | 86 | /** 87 | * Creates a new 3 element Float32 vector initialized with the value from the 88 | * given array. 89 | * @param {!Float32Array|!Float64Array|!Array} vec The source 3 element 90 | * array. 91 | * @return {!Float32Array} The new 3 element array. 92 | */ 93 | vec3Utils.createFloat32FromArray = function (vec) { 94 | const newVec = new Float32Array(3); 95 | vec3Utils.setFromArray(newVec, vec); 96 | return newVec; 97 | }; 98 | 99 | /** 100 | * Creates a clone of the given 3 element Float32 vector. 101 | * @param {!Float32Array} vec The source 3 element vector. 102 | * @return {!Float32Array} The new cloned vector. 103 | */ 104 | vec3Utils.cloneFloat32 = vec3Utils.createFloat32FromArray; 105 | 106 | /** 107 | * @fileoverview A small collection of array utilities. 108 | */ 109 | 110 | const arrayUtils = {}; 111 | 112 | /** 113 | * Does a shallow copy of an array. 114 | * @param {IArrayLike|string} arr Array or array-like object to clone. 115 | * @return {!Array} Clone of the input array. 116 | */ 117 | arrayUtils.clone = function (arr) { 118 | const length = arr.length; 119 | if (length > 0) { 120 | const rv = new Array(length); 121 | for (let i = 0; i < length; i++) { 122 | rv[i] = arr[i]; 123 | } 124 | return rv; 125 | } 126 | return []; 127 | }; 128 | 129 | /** 130 | * Returns an array consisting of the given value repeated N times. 131 | * @param {VALUE} value The value to repeat. 132 | * @param {number} n The repeat count. 133 | * @return {!Array} An array with the repeated value. 134 | */ 135 | arrayUtils.repeat = function (value, n) { 136 | const array = []; 137 | for (let i = 0; i < n; i++) { 138 | array[i] = value; 139 | } 140 | return array; 141 | }; 142 | 143 | /* 144 | Copyright 2018 Google Inc. 145 | 146 | Licensed under the Apache License, Version 2.0 (the "License"); 147 | you may not use this file except in compliance with the License. 148 | You may obtain a copy of the License at 149 | 150 | https://www.apache.org/licenses/LICENSE-2.0 151 | 152 | Unless required by applicable law or agreed to in writing, software 153 | distributed under the License is distributed on an "AS IS" BASIS, 154 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 155 | See the License for the specific language governing permissions and 156 | limitations under the License. 157 | */ 158 | 159 | /** 160 | * @fileoverview Provides an api to get a palette from image pixel data 161 | * The implementation is largely based on Palette-Based Photo Recoloring [Chang 162 | * et al. 2015], section 3.2. 163 | * 164 | * Here are the main steps to generate a palette from RGB data: 165 | * 1. Accumulate RGB data (from an image for instance) in a 16x16x16 RGB 166 | * histogram. 167 | * 2. Keep track of the average Lab color of every histogram bin. 168 | * 3. Run K-means on these 4096 Lab colors to create clusters. 169 | * 4. Create a color palette using the mean colors defined by each cluster. 170 | */ 171 | 172 | class PaletteExtractor { 173 | constructor() { 174 | /** 175 | * Histogram bins containing lab values. 176 | * @private {!Array} 177 | */ 178 | this.labs_ = []; 179 | 180 | /** 181 | * Weights values for each lab. 182 | * @private {!Array} 183 | */ 184 | this.weights_ = []; 185 | 186 | /** 187 | * Seeds selected from the histogram. 188 | * @private {!Array} 189 | */ 190 | this.seeds_ = []; 191 | 192 | /** 193 | * Weights values for each seed. 194 | * @private {!Array} 195 | */ 196 | this.seedWeights_ = []; 197 | } 198 | 199 | /** 200 | * Starts the process to generate a palette from RGB data. 201 | * @param {!Array} data pixels colors - extracted from images using 202 | * canvasContext.getImageData 203 | * @param {number=} paletteSize (Optional) max number of color to get in 204 | * palette (5 if not set) 205 | * @return {!Array} hexadecimal colors 206 | * @package 207 | */ 208 | processImageData(data, paletteSize = 5) { 209 | this.computeHistogramFromImageData_(data); 210 | this.selectSeeds_(paletteSize); 211 | this.clusterColors_(); 212 | return this.exportPalette_(); 213 | } 214 | 215 | /** 216 | * Computes histogram from image data (step 1). 217 | * @param {!Array} data 218 | * @private 219 | */ 220 | computeHistogramFromImageData_(data) { 221 | const l = data.length; 222 | // reset histogram 223 | this.labs_ = []; 224 | this.weights_ = arrayUtils.repeat(0, PaletteExtractor.HISTOGRAM_SIZE_); 225 | 226 | for (let i = 0; i < l; i += 4) { 227 | const r = data[i]; 228 | const g = data[i + 1]; 229 | const b = data[i + 2]; 230 | // Convert RGB color to Lab. 231 | const lab = vec3Utils.createFloat32FromArray(this.rgbToLab(r, g, b)); 232 | // Get the index of the corresponding histogram bin. 233 | const index = (Math.floor(r / 16) * 16 + Math.floor(g / 16)) * 16 + 234 | Math.floor(b / 16); 235 | // Add the Lab color to the associated bin. 236 | if (!(index in this.labs_)) { 237 | this.labs_[index] = lab; 238 | } else { 239 | vec3Utils.add(this.labs_[index], lab, this.labs_[index]); 240 | } 241 | // Keep track of the number of colors added to every bin. 242 | this.weights_[index]++; 243 | } 244 | } 245 | 246 | /** 247 | * Selects seeds in current histogram (step 2). 248 | * @param {number} nbSeeds 249 | * @private 250 | */ 251 | selectSeeds_(nbSeeds) { 252 | // Reset histogram 253 | this.seeds_ = []; 254 | // Local copy of the weight bins to edit during seed selection. 255 | const mutableWeights = arrayUtils.clone(this.weights_); 256 | 257 | // Iteratively selects seeds as the heaviest bins in mutableWeights. 258 | // After selecting a seed, attenuates neighboring bin weights to increase 259 | // color variance. 260 | let seedColor; 261 | let maxIndex = 0; 262 | for (let i = 0; i < nbSeeds; ++i) { 263 | // Get the index of the heaviest bin. 264 | maxIndex = this.getHeaviestIndex_(mutableWeights); 265 | 266 | // Check that the selected bin is not empty. 267 | // Otherwise, it means that the previous seeds already cover all 268 | // non-empty bins. 269 | if (mutableWeights[maxIndex] == 0) { 270 | break; 271 | } 272 | 273 | // Set the new seed as the heaviest bin. 274 | seedColor = this.addSeedByIndex_(maxIndex); 275 | 276 | // Force the next seed to be different (unless all bin weights are 0). 277 | mutableWeights[maxIndex] = 0; 278 | 279 | // Attenuate weights close to the seed to maximize distance between seeds. 280 | this.attenuateWeightsAroundSeed_(mutableWeights, seedColor); 281 | } 282 | } 283 | 284 | /** 285 | * Runs K-means on histogram from seeds to create clusters (step 3). 286 | * @private 287 | */ 288 | clusterColors_() { 289 | if (!this.seeds_.length) { 290 | throw Error("Please select seeds before clustering"); 291 | } 292 | 293 | const clusterIndices = arrayUtils.repeat(0, PaletteExtractor.HISTOGRAM_SIZE_); 294 | let newSeeds = []; 295 | this.seedWeights_ = []; 296 | let optimumReached = false; 297 | let i = 0; 298 | while (!optimumReached) { 299 | optimumReached = true; 300 | newSeeds = []; 301 | this.seedWeights_ = arrayUtils.repeat(0, this.seeds_.length); 302 | // Assign every bin of the color histogram to the closest seed. 303 | for (i = 0; i < PaletteExtractor.HISTOGRAM_SIZE_; i++) { 304 | if (this.weights_[i] == 0) continue; 305 | 306 | // Compute the index of the seed that is closest to the bin's color. 307 | const clusterIndex = this.getClosestSeedIndex_(i); 308 | // Optimum is reached when no cluster assignment changes. 309 | if (optimumReached && clusterIndex != clusterIndices[i]) { 310 | optimumReached = false; 311 | } 312 | // Assign bin to closest seed. 313 | clusterIndices[i] = clusterIndex; 314 | // Accumulate colors and weights per cluster. 315 | this.addColorToSeed_(newSeeds, clusterIndex, i); 316 | } 317 | // Average accumulated colors to get new seeds. 318 | this.updateSeedsWithNewSeeds_(newSeeds); 319 | } 320 | } 321 | 322 | /** 323 | * Exports palette as hexadecimal colors array from current seeds list (step 324 | * 4). 325 | * @return {!Array} hexadecimal colors 326 | * @private 327 | */ 328 | exportPalette_() { 329 | if (!this.seeds_.length) { 330 | throw Error( 331 | "Please select seeds and get clusters " + 332 | "before exporting a new palette" 333 | ); 334 | } 335 | 336 | const results = []; 337 | 338 | for (let i = 0; i < this.seeds_.length; i++) { 339 | const rgb = this.labToRgb( 340 | this.seeds_[i][0], this.seeds_[i][1], this.seeds_[i][2] 341 | ); 342 | results.push(this.rgbToHex(rgb[0], rgb[1], rgb[2])); 343 | } 344 | 345 | return results; 346 | } 347 | 348 | /** 349 | * Attenuates weights close to the seed to maximize distance between seeds. 350 | * @param {!Array} mutableWeights 351 | * @param {!Float32Array} seedColor 352 | * @private 353 | */ 354 | attenuateWeightsAroundSeed_(mutableWeights, seedColor) { 355 | // For photos, we can use a higher coefficient, from 900 to 6400 356 | const squaredSeparationCoefficient = 3650; 357 | 358 | for (let i = 0; i < PaletteExtractor.HISTOGRAM_SIZE_; i++) { 359 | if (this.weights_[i] > 0) { 360 | const targetColor = vec3Utils.createFloat32FromArray([0, 0, 0]); 361 | vec3Utils.scale(this.labs_[i], 1 / this.weights_[i], targetColor); 362 | mutableWeights[i] *= 1 - 363 | Math.exp( 364 | -vec3Utils.distanceSquared(seedColor, targetColor) / 365 | squaredSeparationCoefficient 366 | ); 367 | } 368 | } 369 | } 370 | 371 | /** 372 | * Pushes a color from the histogram to the seeds list. 373 | * @param {number} index 374 | * @return {!Float32Array} seedColor 375 | * @private 376 | */ 377 | addSeedByIndex_(index) { 378 | const seedColor = vec3Utils.createFloat32FromArray([0, 0, 0]); 379 | vec3Utils.scale(this.labs_[index], 1 / this.weights_[index], seedColor); 380 | this.seeds_.push(seedColor); 381 | return seedColor; 382 | } 383 | 384 | /** 385 | * Gets heaviest index. 386 | * @param {!Array} weights 387 | * @return {number} index 388 | * @private 389 | */ 390 | getHeaviestIndex_(weights) { 391 | let heaviest = 0; 392 | let index = 0; 393 | for (let m = 0; m < PaletteExtractor.HISTOGRAM_SIZE_; m++) { 394 | if (weights[m] > heaviest) { 395 | heaviest = weights[m]; 396 | index = m; 397 | } 398 | } 399 | return index; 400 | } 401 | 402 | /** 403 | * Accumulates colors and weights per cluster. 404 | * @param {!Array} seeds 405 | * @param {number} clusterIndex 406 | * @param {number} histogramIndex 407 | * @private 408 | */ 409 | addColorToSeed_(seeds, clusterIndex, histogramIndex) { 410 | if (!(clusterIndex in seeds)) { 411 | seeds[clusterIndex] = vec3Utils.createFloat32FromArray([0, 0, 0]); 412 | } 413 | vec3Utils.add( 414 | seeds[clusterIndex], this.labs_[histogramIndex], seeds[clusterIndex] 415 | ); 416 | this.seedWeights_[clusterIndex] += this.weights_[histogramIndex]; 417 | } 418 | 419 | /** 420 | * Updates seeds with average colors using new seed values and seed weights. 421 | * @param {!Array} newSeeds 422 | * @private 423 | */ 424 | updateSeedsWithNewSeeds_(newSeeds) { 425 | for (let i = 0; i < this.seeds_.length; i++) { 426 | if (!(i in newSeeds)) { 427 | newSeeds[i] = vec3Utils.createFloat32FromArray([0, 0, 0]); 428 | } 429 | 430 | if (this.seedWeights_[i] == 0) { 431 | newSeeds[i] = vec3Utils.createFloat32FromArray([0, 0, 0]); 432 | } else { 433 | vec3Utils.scale(newSeeds[i], 1 / this.seedWeights_[i], newSeeds[i]); 434 | } 435 | 436 | // Update seeds. 437 | this.seeds_[i] = vec3Utils.cloneFloat32(newSeeds[i]); 438 | } 439 | } 440 | 441 | /** 442 | * Gets the closest seed index for a color in the histogram. 443 | * @param {number} index in histogram 444 | * @return {number} seed index 445 | * @private 446 | */ 447 | getClosestSeedIndex_(index) { 448 | const color = vec3Utils.cloneFloat32(this.labs_[index]); 449 | vec3Utils.scale(color, 1 / this.weights_[index], color); 450 | let seedDistMin = Number.MAX_SAFE_INTEGER; 451 | let seedIndex = 0; 452 | for (let i = 0; i < this.seeds_.length; i++) { 453 | const dist = vec3Utils.distanceSquared(this.seeds_[i], color); 454 | if (dist < seedDistMin) { 455 | seedDistMin = dist; 456 | seedIndex = i; 457 | } 458 | } 459 | return seedIndex; 460 | } 461 | 462 | /** 463 | * Converts color component to hexaminal part. 464 | * @param {number} c Component color value (r,g or b) 465 | * @return {string} The hexadecimal converted part 466 | * @package 467 | */ 468 | componentToHex(c) { 469 | const hex = c.toString(16); 470 | return hex.length == 1 ? "0" + hex : hex; 471 | } 472 | 473 | /** 474 | * Converts a color from RGB to hex representation. 475 | * @param {number} r Amount of red, int between 0 and 255. 476 | * @param {number} g Amount of green, int between 0 and 255. 477 | * @param {number} b Amount of blue, int between 0 and 255. 478 | * @return {string} hex representation of the color. 479 | */ 480 | rgbToHex(r, g, b) { 481 | r = Number(r); 482 | g = Number(g); 483 | b = Number(b); 484 | if (r != (r & 255) || g != (g & 255) || b != (b & 255)) { 485 | throw Error("\"(" + r + "," + g + "," + b + "\") is not a valid RGB color"); 486 | } 487 | const hexR = this.componentToHex(r); 488 | const hexG = this.componentToHex(g); 489 | const hexB = this.componentToHex(b); 490 | return "#" + hexR + hexG + hexB; 491 | } 492 | 493 | /** 494 | * Converts RGB color values to LAB. 495 | * @param {!number} r The R value of the color. 496 | * @param {!number} g The G value of the color. 497 | * @param {!number} b The B value of the color. 498 | * @return {!Array} An array of LAB values in that order. 499 | */ 500 | rgbToLab(r, g, b) { 501 | r = r / 255.0; 502 | g = g / 255.0; 503 | b = b / 255.0; 504 | const xyz = this.rgbToXyz(r, g, b); 505 | return this.xyzToLab(xyz[0], xyz[1], xyz[2]); 506 | } 507 | 508 | /** 509 | * Converts LAB color values to RGB. 510 | * @param {!number} lValue The L value of the color. 511 | * @param {!number} a The A value of the color. 512 | * @param {!number} b The B value of the color. 513 | * @return {!Array} An array of RGB values in that order. 514 | */ 515 | labToRgb(lValue, a, b) { 516 | const xyz = this.labToXyz(lValue, a, b); 517 | const rgb = this.xyzToRgb(xyz[0], xyz[1], xyz[2]); 518 | return [ 519 | Math.min(255, Math.max(0, Math.round(rgb[0] * 255))), 520 | Math.min(255, Math.max(0, Math.round(rgb[1] * 255))), 521 | Math.min(255, Math.max(0, Math.round(rgb[2] * 255))), 522 | ]; 523 | } 524 | 525 | /** 526 | * Converts XYZ color values to RGB. 527 | * Formula for conversion found at: 528 | * http://www.easyrgb.com/index.php?X=MATH&H=01#text1 529 | * @param {!number} x The X value of the color. 530 | * @param {!number} y The Y value of the color. 531 | * @param {!number} z The Z value of the color. 532 | * @return {!Array} An array of RGB values in that order. 533 | */ 534 | xyzToRgb(x, y, z) { 535 | x = x / 100.0; 536 | y = y / 100.0; 537 | z = z / 100.0; 538 | let r = x * 3.2406 + y * -1.5372 + z * -0.4986; 539 | let g = x * -0.9689 + y * 1.8758 + z * 0.0415; 540 | let b = x * 0.0557 + y * -0.2040 + z * 1.0570; 541 | if (r > 0.0031308) { 542 | r = 1.055 * Math.pow(r, 1 / 2.4) - .055; 543 | } else { 544 | r = 12.92 * r; 545 | } 546 | if (g > 0.0031308) { 547 | g = 1.055 * Math.pow(g, 1 / 2.4) - .055; 548 | } else { 549 | g = 12.92 * g; 550 | } 551 | if (b > 0.0031308) { 552 | b = 1.055 * Math.pow(b, 1 / 2.4) - .055; 553 | } else { 554 | b = 12.92 * b; 555 | } 556 | return [r, g, b]; 557 | } 558 | 559 | /** 560 | * Converts LAB color values to XYZ. 561 | * Formula for conversion found at: 562 | * http://www.easyrgb.com/index.php?X=MATH&H=08#text8 563 | * @param {!number} lValue The L value of the color. 564 | * @param {!number} a The A value of the color. 565 | * @param {!number} b The B value of the color. 566 | * @return {!Array} An array of XYZ values in that order. 567 | */ 568 | labToXyz(lValue, a, b) { 569 | const p = (lValue + 16) / 116; 570 | return [ 571 | PaletteExtractor.REF_X * Math.pow(p + a / 500, 3), 572 | PaletteExtractor.REF_Y * Math.pow(p, 3), 573 | PaletteExtractor.REF_Z * Math.pow(p - b / 200, 3), 574 | ]; 575 | } 576 | 577 | /** 578 | * Converts XYZ color values to LAB. 579 | * Formula for conversion found at: 580 | * http://www.easyrgb.com/index.php?X=MATH&H=07#text7 581 | * @param {!number} x The X value of the color. 582 | * @param {!number} y The Y value of the color. 583 | * @param {!number} z The Z value of the color. 584 | * @return {!Array} An array of LAB values in that order. 585 | */ 586 | xyzToLab(x, y, z) { 587 | const xRatio = x / PaletteExtractor.REF_X; 588 | const yRatio = y / PaletteExtractor.REF_Y; 589 | const zRatio = z / PaletteExtractor.REF_Z; 590 | return [ 591 | yRatio > 0.008856 ? 116 * Math.pow(yRatio, 1.0 / 3) - 16 : 903.3 * yRatio, 592 | 500 * (this.transformation(xRatio) - this.transformation(yRatio)), 593 | 200 * (this.transformation(yRatio) - this.transformation(zRatio)), 594 | ]; 595 | } 596 | 597 | /** 598 | * Converts RGB color values to XYZ. 599 | * Formula for conversion found at: 600 | * http://www.easyrgb.com/index.php?X=MATH&H=02#text2 601 | * @param {!number} r The R value of the color. 602 | * @param {!number} g The G value of the color. 603 | * @param {!number} b The B value of the color. 604 | * @return {!Array} An array of XYZ values in that order. 605 | */ 606 | rgbToXyz(r, g, b) { 607 | if (r > 0.04045) { 608 | r = Math.pow((r + .055) / 1.055, 2.4); 609 | } else { 610 | r = r / 12.92; 611 | } 612 | if (g > 0.04045) { 613 | g = Math.pow((g + .055) / 1.055, 2.4); 614 | } else { 615 | g = g / 12.92; 616 | } 617 | if (b > 0.04045) { 618 | b = Math.pow((b + .055) / 1.055, 2.4); 619 | } else { 620 | b = b / 12.92; 621 | } 622 | r = r * 100; 623 | g = g * 100; 624 | b = b * 100; 625 | return [ 626 | r * 0.4124 + g * 0.3576 + b * 0.1805, 627 | r * 0.2126 + g * 0.7152 + b * 0.0722, r * 0.0193 + g * 0.1192 + b * 0.9505, 628 | ]; 629 | } 630 | 631 | /** 632 | * Transformation function for CIELAB-CIEXYZ conversion. For more info, please 633 | * see http://en.wikipedia.org/wiki/Lab_color_space#CIELAB-CIEXYZ_conversions 634 | * @param {!number} t An input to the transformation function. 635 | * @return {!number} The transformed value. 636 | */ 637 | transformation(t) { 638 | if (t > 0.008856) { 639 | return Math.pow(t, 1.0 / 3); 640 | } 641 | return 7.787 * t + 16.0 / 116; 642 | } 643 | } 644 | 645 | /** 646 | * Total number of cells in the histogram. 647 | * @private @const @type {number} 648 | */ 649 | PaletteExtractor.HISTOGRAM_SIZE_ = 4096; 650 | 651 | /** 652 | * Reference values for Illuminant D65, a reference for color conversion. 653 | * @const @type {!number} 654 | */ 655 | PaletteExtractor.REF_X = 95.047; 656 | 657 | /** 658 | * @const @type {!number} 659 | */ 660 | PaletteExtractor.REF_Y = 100; 661 | 662 | /** 663 | * @const @type {!number} 664 | */ 665 | PaletteExtractor.REF_Z = 108.883; 666 | 667 | module.exports = PaletteExtractor; 668 | -------------------------------------------------------------------------------- /lib/rgb-cymk.js: -------------------------------------------------------------------------------- 1 | export default (rgb) => { 2 | let red = rgb[0], 3 | green = rgb[1], 4 | blue = rgb[2]; 5 | 6 | red = parseFloat(red); 7 | green = parseFloat(green); 8 | blue = parseFloat(blue); 9 | 10 | if (red > 255) { 11 | red = 255; 12 | } 13 | if (green > 255) { 14 | green = 255; 15 | } 16 | if (blue > 255) { 17 | blue = 255; 18 | } 19 | 20 | if (red < 0) { 21 | red = 0; 22 | } 23 | if (green < 0) { 24 | green = 0; 25 | } 26 | if (blue < 0) { 27 | blue = 0; 28 | } 29 | 30 | let redDash = 255, 31 | greenDash = 255, 32 | blueDash = 255; 33 | 34 | redDash = red / 255; 35 | greenDash = green / 255; 36 | blueDash = blue / 255 ; 37 | 38 | let cyan = 0, 39 | magenta = 0, 40 | yellow = 0, 41 | black = 0; 42 | 43 | black = (1 - Math.max(redDash, greenDash, blueDash)); 44 | let blackFactor = (1 - black); 45 | 46 | if (blackFactor === 0) { 47 | blackFactor = 1; 48 | } 49 | 50 | cyan = (1 - redDash - black) / blackFactor; 51 | magenta = (1 - greenDash - black) / blackFactor; 52 | yellow = (1 - blueDash - black) / blackFactor; 53 | 54 | return [parseFloat(cyan).toFixed(3), parseFloat(magenta).toFixed(3), parseFloat(yellow).toFixed(3), parseFloat(black).toFixed(3)]; 55 | }; -------------------------------------------------------------------------------- /lib/share-strings.js: -------------------------------------------------------------------------------- 1 | export default (provider, url, text) => { 2 | const localText = encodeURIComponent(text); 3 | const localUrl = encodeURIComponent(url); 4 | 5 | const links = { 6 | facebook: `https://www.facebook.com/sharer/sharer.php?u=${url}"e=${text}`, 7 | twitter: `https://twitter.com/intent/tweet?url=${url}&text=${text}`, 8 | telegram: `https://t.me/share/url?url=${url}&text=${text}`, 9 | pocket: `https://getpocket.com/edit?url=${url}&title=${text}`, 10 | reddit: `https://reddit.com/submit?url=${url}&title=${text}`, 11 | evernote: `https://www.evernote.com/clip.action?url=${url}&t=${text}`, 12 | linkedin: `https://www.linkedin.com/shareArticle?mini=true&url=${url}&title=${text}&source=${url}`, 13 | pinterest: `https://pinterest.com/pin/create/button/?url=${url}&media=${url}&description=${text}`, 14 | whatsapp: `https://wa.me/?text=${url}%20${text}`, 15 | email: `mailto:?subject=${url}&body=${text}`, 16 | }; 17 | 18 | return links[provider]; 19 | }; 20 | -------------------------------------------------------------------------------- /lib/visualize-color-positions.js: -------------------------------------------------------------------------------- 1 | // Functions for visualizing color positions found in images 2 | 3 | /** 4 | * Creates an overlay element showing the positions of colors in an image 5 | * @param {Object} colorLocations - Object mapping color keys to arrays of positions 6 | * @param {Object} rgbToHexMap - Object mapping RGB keys to hex colors 7 | * @param {HTMLElement} container - Container element to add the overlay to 8 | * @param {Object} imageData - Object with width and height properties 9 | * @returns {HTMLElement} The created overlay element 10 | */ 11 | export function createColorPositionsOverlay(colorLocations, rgbToHexMap, container, imageData) { 12 | // Remove any existing overlay 13 | const existingOverlay = container.querySelector('.color-positions-overlay'); 14 | if (existingOverlay) { 15 | existingOverlay.remove(); 16 | } 17 | 18 | // Create overlay container with the same dimensions as the image 19 | const overlay = document.createElement('div'); 20 | overlay.className = 'color-positions-overlay'; 21 | overlay.style.position = 'absolute'; 22 | overlay.style.top = '0'; 23 | overlay.style.left = '0'; 24 | overlay.style.width = '100%'; 25 | overlay.style.height = '100%'; 26 | overlay.style.pointerEvents = 'none'; 27 | overlay.style.zIndex = '10'; 28 | 29 | // Create markers for each color position 30 | Object.entries(colorLocations).forEach(([colorKey, positions]) => { 31 | const hexColor = rgbToHexMap ? rgbToHexMap[colorKey] : '#ffffff'; 32 | 33 | positions.forEach(pos => { 34 | // Skip default positions (they're just placeholders when a color wasn't found) 35 | if (pos.isDefault) return; 36 | 37 | const marker = document.createElement('div'); 38 | marker.className = 'color-position-marker'; 39 | marker.style.position = 'absolute'; 40 | marker.style.left = `${pos.x * 100}%`; 41 | marker.style.top = `${pos.y * 100}%`; 42 | marker.style.width = '8px'; 43 | marker.style.height = '8px'; 44 | marker.style.borderRadius = '50%'; 45 | marker.style.backgroundColor = hexColor; 46 | marker.style.transform = 'translate(-50%, -50%)'; 47 | marker.style.boxShadow = '0 0 0 2px rgba(255,255,255,0.8), 0 0 0 3px rgba(0,0,0,0.3)'; 48 | marker.style.opacity = (1 - pos.distance * 0.5).toString(); // Closer colors are more opaque 49 | 50 | // Add a pulsing animation for better visibility 51 | marker.style.animation = 'pulse 2s infinite'; 52 | 53 | overlay.appendChild(marker); 54 | }); 55 | }); 56 | 57 | // Add the overlay to the container 58 | container.appendChild(overlay); 59 | 60 | // Add CSS animation to the document if not already present 61 | if (!document.getElementById('color-position-styles')) { 62 | const styleEl = document.createElement('style'); 63 | styleEl.id = 'color-position-styles'; 64 | styleEl.textContent = ` 65 | @keyframes pulse { 66 | 0% { transform: translate(-50%, -50%) scale(1); } 67 | 50% { transform: translate(-50%, -50%) scale(1.5); } 68 | 100% { transform: translate(-50%, -50%) scale(1); } 69 | } 70 | `; 71 | document.head.appendChild(styleEl); 72 | } 73 | 74 | return overlay; 75 | } 76 | 77 | /** 78 | * Visualizes the color positions in an image 79 | * @param {Object} colorLocations - Object mapping color keys to arrays of positions 80 | * @param {Array} hexColors - Array of hex colors 81 | * @param {HTMLElement} imageContainer - Container element holding the image 82 | * @param {Object} imageData - Object with width and height properties 83 | */ 84 | export function visualizeColorPositions(colorLocations, hexColors, imageContainer, imageData) { 85 | if (!colorLocations || !hexColors || !imageContainer) { 86 | console.error('Missing required parameters for visualizeColorPositions'); 87 | return; 88 | } 89 | 90 | // Create mapping from RGB keys to hex colors 91 | const rgbToHexMap = {}; 92 | hexColors.forEach(hex => { 93 | const rgb = chroma(hex).rgb(); 94 | const key = `${rgb[0]}-${rgb[1]}-${rgb[2]}`; 95 | rgbToHexMap[key] = hex; 96 | }); 97 | 98 | createColorPositionsOverlay(colorLocations, rgbToHexMap, imageContainer, imageData); 99 | } 100 | -------------------------------------------------------------------------------- /main.scss: -------------------------------------------------------------------------------- 1 | $noise: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEEAAABBCAMAAAC5KTl3AAAAgVBMVEUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABtFS1lAAAAK3RSTlMWi3QSa1uQOKBWCTwcb6V4gWInTWYOqQSGfa6XLyszmyABlFFJXySxQ0BGn2PQBgAAC4NJREFUWMMV1kWO5UAQRdFk5kwzs/33v8Cunr7ZUehKAdaRUAse99ozDjF5BqswrPKm7btzJ2tRziN3rMYXC236humIV5Our7nHWnVdFOBojW2XVnkeu1IZHNJH5OPHj9TjgVxBGBwAAmp60WoA1gBBvg3XMFhxUQ4KuLqx0CritYZPPXinsOqB7I76+OHaZlPzLEcftrqOlOwjeXvuEuH6t6emkaofgVUDIb4fEZB6CmRAeFCTq11lxbAgUyx4rXkqlH9I4bTUDRRVD1xjbqb9HyUBn7rhtr1x+x9Y0e3BdX31/loYvZaLxqnjbRuokz+pPG7WebnSNKE3yE6Tka4aDEDMVYr6Neq126c+ZR2nzzm3yyiC7PGWG/1uueqZudrVGYNdsgOMDvt1cI8CXu63QIcPvYNY8z870WwYazTS7DqpDEknZqS0AFXObWUxTaw0q5pnHlq4oQImakpLfJkmErdvAfhsc7lod0DVT4tuob25C0tQjzdiFObCz7U7eaKGP3s6yQVgQ/y+q+nY6K5dfV75iXzcNlGIP38aj22sVwtWWKMRb7B5HoHPaBvI1Ve5TSXATi66vV6utxsV+aZNFu+93VvlrG/oj8Wp67YT8l+Oq6PjwdGatFm7SEAP13kE0y9CEcf9qhtEWCMIq5AGq71moEAI9vrmFcmO8+7ZyDnmRN/VUaFkM2ce8KuBGFzDMmY6myLfQGra2ofgHhbJRXuRDZ4H+HmliWBHXQ0ysLGfv6FetbxtxzRgIZWjIsGVFl5imPXeyvVyayNek+dSWzjXd4t310YBdaF8sXeKs481PjsXbAtIru2+wHbv3GVh3sQY6Dnu6pF3pZ714VYdDi9A5GkXR/6xgaZN/tpQ8wVV3zeBuB+njoBNE4wjc+uA523ysXGd/P2sntmOb3OdHNWP5OVrxD3eJHdtH8QVkEIAqCor3hReR96yqt6PkTQfenllooQ447h6tOrnnuzwA8fMpq+jqg1oW8fTYYIncAYpVeTvkEFr/khQSbjoE8ykx9049OkE5MQEO9lC24tT7DwThQgf4Fhf8nGgAo3GYaON3crODpOr2pu5dBABz69t7F5yJBBo+r6QJdeLDWEoO7r1tceR3haA7gc7eZrCvpxSXXeKpo4P+hRixo9DeOFbqQVjKyWfBg9pnrEZKzK7R437YTTwhfoySG/YOCt3fs4aXlU3FjKortqQ6XyXaD0+Y/8VoqpyU9TRW45eN4oBxAH8Y/jLnNXfELJW+/p/MgO9Z+mBli2qqAP7dV/Arc2+YZRZwtBW8/p32y5ZsEuCS4O5AAgfR7Dde7zhiGfgvurQkfAXIrUG61rmxc2EZo18ph4vaWZI+QM0JdsbNlBJlPlwf9uguujQJy0j7TgTHdtRnjybTg55Hkk9S6l2rpYahumSewKHVosa1bh2Y6r9JGkdKvIDN/eeAwScrfjoLkCxWJuFZQ53FNP5w9XbQd1HhgHcVB/0fATG3sUUid1RTfc2+7pZVKldFSsaEK0v4k90tapQOk2HIbMhaJQtrUEL5+3sDanh8sOpbYRoQoqXWu6SQcUTQL9jzOrXNPWCJwXge4U7tlU1hkF012cAmvp8llQxf1IEMcw14pURxVOWATz4ITnYQjuF+vDXg5hgoiqXzO6mS91FQUBheURHIJxUeU1i3P0WOMpsm7vFYk0JJi/Ev+X3FwYD69cARPuP5GIc0PxoAFjcLRbNur0iMTrQmBBNYJ2ngU4x7SWfdTRl52Bqv7LmYW3C1CyTCPTHeWWIAM/Whm32COHsaj+2UQ739XB9t6NV0o9E9b7CW3XNiXzi9e0KiE+3rntukdIDBWrU2jsfQWuyFJRANxq8StHVv1JPy2C3Byco7qdNbASrnNXZ8G0L/Wp/pif4Ai9aEZ9Bb+TRx+REBdGlkF/s0dUdMSMr+6YCbuGxqPWdzcdqutvqkBzCksFcwAtjf55TeuH79M6AQa7r5PLeXxMFIlQKrXP9VJ275WGX+ptpf+tvTDBsecPnYQAlAWrVbRVJ7K2pRHwIjtSpbX96Y/lbKk6ZWXlBmh15r8yAWQsYxXgBOXYMAfHnUXF+rDqnB8bXDRtAn7bCziIqetSboK3NexMePvsCRLvmsoREA+kH8j4HWFpnNEaWgOmR7xyXHfTaz3slHc/YA6H6tl/L8d5tPcIwwD0tjvRaq3Y5BmYBSDClpv0VIX4s8D0XK3sPdpAb94HjPLkgboEz9EdZATW6ZdcmQvtKUwoWw+nAVKA7IcdY1UHnvNnIBplKci+knzewLz5/GGnzkGuuGky+0LTjtGBGR85EQICDqKChnm5pH3Z44nnWAk1YRdyu3g7QoFZ0h8jkr2ffjKmi+Qvsp+9GvNGZHmgW+YQAGUw7PPt8IPKbdy432vhKtRJjKWcSqq7helj81o3nfmaxVZ7Sqie8OOBk9WsyTD/ab7fQ5aWwQeJvnH6+ayo4IdIkOSBJjzXkgr+1TPhAx1AXDsxtCCj3TzQTLA1p782f7a8vdgPfwwrXmZxxbqo2h+6Zlo6mcMY4V7cFBOLm17VCvx9Qa2tAnkxEB+KYyQgbgAAnmNDOdOO6y2Cb+lke1MWQc9o+EMdQf7ubIG3Ek8GZ4k1PtGjbhwgOMPp5Em59JMVk/jU8/aF73Xcrd3UBNZyueQu0/xz2aGtZT8CRziOax2BWFXaeDzgZNV7oRtUzFoijoETf3xkAFFk3OMb7SgPh5wxU1+MygDIp9gZChH2qEcpgLh8pBIK90PXT1ZSU+ZExFK4Vm4GL/J7+K13lS5dQkW4HQwl6GX4yLqu8GhGWS2k75yel5IZIfFNdAL0NpKr2N5dQesBnxa42DLgJd6agS1jJsp1mO1dip7PU4P6diLLoTsZ4m3Q0QweiqeFfIGPLgF6v6mSVv6xe85VBD/1Mpe3AurRbcJ9SEo8NszNVy8rOCEexyIFcJRvYAlI/wk2I7r3p60FFLQXoH2q9xri/m41svRPbW0/EnPn2DWsmk0IiPpB60aa3+hiFfWuC8ZvWKEd9LxAk3HcOof6d77RewPaPsGw5lQAHcZN2vx1448u9pLfMLGQ3BSRRjBzRhKt7HcCw/7aqjtCDs5q76b4ZGphxN2th1WeXYlfnozX3ebKtX4Te11hf1tZP1diiGjIDAB1cR4Sb9rcFPC/nBARjlgDxd+tCBb1t91j71xJcgGjT3g/dUFnXXNiDrxkyoHANPk58ACPUa42hj8tgGrhiXOCmygxFZBiT2wyAJTDJ4wJEPmp6JIrDaSWYNqv4xH2wwdSTGYb3E0pXnS39nmLUsqoVZxzSoegqzd0o06wdbTXsaHGL+IF4JtIcXddTcD/dCd8hVf+fWPSV553kjMmMEULLS8HcgmptDO955dLGX78PjiDA6IsTHPm5IA6bc5ha0gaGkoEttXuxU11B2dOJ65/Q08tEF1+Y9cr2Nh/VECfQ33GyvR/gsdN1LuIeLpKMCAF2yRr769g9/4aJLZNRI71m2S91+Kp+Q0zubTcxoG2/6gm1Q79wkMj2XNO2ui7nWw8ULtu27CCvqTGX2PffD+xcwgh/TrOKvGZMM5jRFGDTn4NO/lwnDR/GY/waDZtkWDUPI0O8ztcFVqp6r2ZW+2bvkJ3raptYagFqu95VdIaml2CIp6CKets34x+fH2C+zH4cVFO7vj+6k2FU39PtRhWluYeZ3gDz1TLB9K2v7SD9gJU1qDxoRDrAWcrFGLyndhdtd0505+gEP79adK8fmFCWNYC+ahzVNcRH79E8dA1iqX/N0qq22xcOc20ALxLDspEj4QCFBQMgaIwoKbxr0Bd7Sbws6GiRK6tqoPfpiCle23axejRLyO1I+ahsEpWrzT5ZsCyS5RcY9jMfENFxSnhKsrfW8JHH6/rdQUMfmQPT3Uz9gY0C/pu1yuCnrPUvio0a1qMEosA/EwIzzid7cqsAAAAASUVORK5CYII='); 2 | 3 | :root { 4 | --color-bg: #202126; 5 | --color-inverted: #fff; 6 | --size-gutter: 1rem; 7 | 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | } 11 | 12 | * { 13 | margin: 0; 14 | padding: 0; 15 | } 16 | 17 | body, html { 18 | height: -webkit-fill-available; 19 | min-height: 100vh; 20 | min-height: -webkit-fill-available; 21 | 22 | @media (max-width: 850px) { 23 | overflow-x: hidden; 24 | width: 100vw; 25 | } 26 | } 27 | 28 | html { 29 | font-size: calc(.75rem + 1.15vh); 30 | font-family: "Inter", sans-serif; 31 | font-optical-sizing: auto; 32 | } 33 | 34 | body { 35 | background: var(--color-bg); 36 | overflow: hidden; 37 | 38 | &.lightmode { 39 | --color-bg: #fff; 40 | --color-inverted: #000; 41 | } 42 | } 43 | 44 | a { 45 | color: inherit; 46 | } 47 | 48 | .icons { 49 | display: none; 50 | svg path { 51 | stroke-width: 2px; 52 | } 53 | } 54 | 55 | button { 56 | border: none; 57 | width: auto; 58 | overflow: visible; 59 | touch-action: manipulation; 60 | background: transparent; 61 | 62 | /* inherit font & color from ancestor */ 63 | color: inherit; 64 | font: inherit; 65 | 66 | /* Normalize `line-height`. Cannot be changed from `normal` in Firefox 4+. */ 67 | line-height: normal; 68 | 69 | /* Corrects font smoothing for webkit */ 70 | -webkit-font-smoothing: inherit; 71 | -moz-osx-font-smoothing: inherit; 72 | 73 | /* Corrects inability to style clickable `input` types in iOS */ 74 | -webkit-appearance: none; 75 | } 76 | 77 | .wrap__hasGradients { 78 | .color { 79 | &::after { 80 | opacity: .5; 81 | content: ''; 82 | position: absolute; 83 | inset: 0; 84 | box-shadow: 0 0 10rem var(--color-text); 85 | } 86 | } 87 | } 88 | 89 | .wrap__hasBleed { 90 | .color { 91 | &::before { 92 | position: absolute; 93 | inset: 0; 94 | content: ''; 95 | background-image: linear-gradient(to top, var(--color-next) -50%, var(--color) 80%); 96 | } 97 | } 98 | } 99 | 100 | .wrap__highContrast { 101 | .color { 102 | color: var(--color-best-contrast); 103 | } 104 | } 105 | 106 | @media (prefers-contrast: more) { 107 | .color { 108 | color: var(--color-best-contrast); 109 | } 110 | 111 | .panel__setting--contrast { 112 | display: none; 113 | } 114 | } 115 | 116 | .wrap__hasDithering { 117 | .color { 118 | &::before { 119 | mask: $noise, linear-gradient(black -100%, transparent); 120 | -webkit-mask: $noise, linear-gradient(black -100%, transparent); 121 | } 122 | } 123 | } 124 | 125 | .wrap__hasOutlines { 126 | .panel, 127 | .panel-share { 128 | border: 2px solid var(--color-bg); 129 | 130 | @media (max-width: 850px) { 131 | z-index: 11; 132 | border-width: 2px 4px; 133 | } 134 | } 135 | .refresh, 136 | .settings, 137 | .share { 138 | box-shadow: 0 0 0 4px var(--color-gb); 139 | } 140 | .colors { 141 | .color { 142 | &::before { 143 | z-index: 10; 144 | content: ''; 145 | position: absolute; 146 | top: -2px; 147 | left: 0; 148 | bottom: -2px; 149 | right: 0; 150 | border: 4px solid var(--color-bg); 151 | } 152 | } 153 | } 154 | .buttons { 155 | .button { 156 | box-shadow: 0 0 0 3px var(--color-bg); 157 | } 158 | } 159 | } 160 | 161 | 162 | .wrap, 163 | .bg, 164 | .panel, 165 | .panel-share, 166 | .bg-wrap { 167 | position: absolute; 168 | inset: 0; 169 | } 170 | 171 | .wrap { 172 | overflow: hidden; 173 | } 174 | 175 | .bg { 176 | opacity: 0; 177 | transform: scale(.7); 178 | transition: 300ms opacity linear, 220ms transform ease-out 50ms; 179 | background-image: linear-gradient(to bottom, var(--gradientBG)); 180 | /*mask-image: radial-gradient(circle at 50%, #000 10%, transparent 80%);*/ 181 | } 182 | 183 | .bg-wrap { 184 | overflow: hidden; 185 | height: -webkit-fill-available; 186 | min-height: 100vh; 187 | min-height: -webkit-fill-available; 188 | } 189 | 190 | .wrap__hasBackground { 191 | .bg { 192 | opacity: 1; 193 | transform: scale(1.3); 194 | } 195 | } 196 | 197 | .colors, 198 | .panel, 199 | .panel-share { 200 | position: absolute; 201 | left: 50%; 202 | bottom: 10vmin; 203 | top: 10vmin; 204 | display: flex; 205 | flex-direction: column; 206 | 207 | transform: translateX(-50%); 208 | width: 52%; 209 | 210 | @media (max-width: 850px) { 211 | width: auto; 212 | left: 10vmin; 213 | right: 10vmin; 214 | bottom: 12vmin; 215 | transform: none; 216 | } 217 | } 218 | 219 | .colors__title { 220 | display: none; 221 | } 222 | 223 | .color { 224 | position: relative; 225 | display: flex; 226 | background-color: var(--color); 227 | color: var(--color-text); 228 | 229 | //background-image: linear-gradient(to top, var(--color-text) -10rem, var(--color) 50%); 230 | //mask: $noise; 231 | 232 | align-items: center; 233 | //padding: 0 var(--size-gutter); 234 | padding: 0 max(var(--size-gutter), calc((10 - var(--colors)) * 1vmin)); 235 | flex-grow: 1; 236 | 237 | transition: 0ms color linear, 238 | 0ms background-color linear, 239 | 250ms padding-bottom cubic-bezier(.7, .3, .3, 1) 100ms; 240 | 241 | min-height: 0.1vmax; 242 | 243 | @media (max-width: 850px) { 244 | flex-direction: column; 245 | align-items: flex-start; 246 | justify-content: center; 247 | min-height: 0; 248 | } 249 | 250 | &:last-child, 251 | &:hover { 252 | padding-bottom: #{1.61803 * 4rem}; 253 | } 254 | 255 | .wrap__sameHeightColors &:last-child { 256 | padding-bottom: 0; 257 | } 258 | 259 | ::selection { 260 | color: var(--color); 261 | background-color: var(--color-text); 262 | } 263 | } 264 | 265 | .colors:hover .color { 266 | &:last-child { 267 | padding-bottom: .7rem; 268 | } 269 | 270 | &:hover { 271 | padding-bottom: #{1.61803 * 4rem}; 272 | } 273 | } 274 | 275 | .color__info { 276 | display: none; 277 | opacity: 1; 278 | position: absolute; 279 | top: 5.5rem; 280 | font-size: 0.75em; 281 | 282 | ul, ol { 283 | list-style: none; 284 | font-size: 0.7em; 285 | } 286 | 287 | li { 288 | margin-top: .5em; 289 | } 290 | } 291 | 292 | .color:hover .color__info { 293 | opacity: 1; 294 | transition: 300ms opacity; 295 | } 296 | 297 | .color__values { 298 | flex-grow: 1; 299 | padding-right: var(--size-gutter); 300 | font-size: 0.75em; 301 | font-family: 'Space Mono', monospace; 302 | display: flex; 303 | 304 | @media (max-width: 850px) { 305 | font-size: .6em; 306 | flex-grow: 0; 307 | } 308 | } 309 | 310 | .color__contrasts { 311 | opacity: 0; 312 | transition: 200ms opacity; 313 | position: absolute; 314 | left: 0; 315 | top: 50%; 316 | 317 | 318 | transform: translateY(-50%); 319 | height: 10px; 320 | transform: 200ms opacity linear; 321 | 322 | .wrap__showcontrast & { 323 | opacity: 1; 324 | transition-delay: .3s; 325 | 326 | } 327 | 328 | @media (max-width: 850px) { 329 | left: auto; 330 | right: var(--size-gutter); 331 | 332 | .wrap__hidetext & { 333 | top: 1.1rem; 334 | } 335 | } 336 | } 337 | 338 | .wrap__showcontrast .color:hover .color__contrasts { 339 | opacity: 0; 340 | 341 | @media (max-width: 850px) { 342 | opacity: 1; 343 | } 344 | } 345 | 346 | .color__contrasts ol { 347 | display: flex; 348 | height: 100%; 349 | list-style-type: none; 350 | } 351 | 352 | .color__contrasts ol li { 353 | position: relative; 354 | height: 8px; 355 | width: 8px; 356 | border-radius: 5px; 357 | background: var(--paircolor); 358 | 359 | .wrap__hasBleed & { 360 | box-shadow: 0 0 5px var(--paircolor); 361 | } 362 | } 363 | 364 | .color__contrasts ol li + li { 365 | margin-left: 5px; 366 | } 367 | 368 | .color__contrasts var { 369 | position: absolute; 370 | display: none; 371 | } 372 | 373 | .color__value { 374 | font-style: normal; 375 | font-family: 'Inter UI', sans-serif; 376 | sup, span { 377 | font-family: 'Space Mono', monospace; 378 | } 379 | } 380 | 381 | .color__name { 382 | font-size: 1em; 383 | text-align: right; 384 | font-weight: 300; 385 | letter-spacing: -0.02em; 386 | 387 | @media (max-width: 850px) { 388 | text-align: left; 389 | font-weight: 400; 390 | overflow: hidden; 391 | } 392 | } 393 | 394 | .color__values, 395 | .color__name { 396 | position: relative; 397 | z-index: 1; 398 | opacity: 1; 399 | transition: 100ms opacity linear; 400 | 401 | @media (max-width: 850px) { 402 | display: block; 403 | width: 100%; 404 | text-overflow: ellipsis; 405 | white-space: nowrap; 406 | } 407 | } 408 | 409 | .color { 410 | @for $i from 1 through 11 { 411 | &:nth-child(#{$i}) { 412 | .color__value, 413 | .color__name { 414 | transition-delay: #{$i * 50}ms; 415 | } 416 | } 417 | } 418 | } 419 | 420 | .wrap__hidetext { 421 | .color__value, 422 | .color__name { 423 | opacity: 0; 424 | } 425 | } 426 | 427 | .wrap__showcontrast .color__value { 428 | opacity: 0; 429 | 430 | @media (max-width: 850px) { 431 | opacity: 1; 432 | } 433 | } 434 | 435 | .wrap__showcontrast.wrap__hidetext .color__value { 436 | @media (max-width: 850px) { 437 | opacity: 0; 438 | } 439 | } 440 | 441 | .wrap__showcontrast .color:hover .color__value { 442 | opacity: 1; 443 | transition-delay: 350ms; 444 | } 445 | 446 | .wrap__showcontrast.wrap__hidetext .color:hover .color__value { 447 | opacity: 0; 448 | transition-delay: 350ms; 449 | } 450 | 451 | .buttons { 452 | position: absolute; 453 | 454 | right: 24%; 455 | //right: 50%; 456 | bottom: calc(10vmin - 0.1rem); 457 | display: flex; 458 | z-index: 20; 459 | transform: translate(1.1em, 1.1em); 460 | transition: 500ms transform ease-in 0ms, 461 | 500ms right cubic-bezier(0.3, 0.7, 0, 1) 50ms, 462 | 500ms bottom cubic-bezier(0.3, 0.7, 0, 1) 50ms; 463 | //transform: translateX(50%) translate(0, 1.1em); 464 | /* 465 | .wrap__hasGradients & { 466 | right: 24%; 467 | }*/ 468 | 469 | @media (max-width: 850px) { 470 | right: 10vmin; 471 | bottom: 12vmin; 472 | } 473 | 474 | .wrap__sameHeightColors & { 475 | right: 50%; 476 | transform: translate(calc(50%), 1.1em); 477 | @media (max-width: 850px) { 478 | right: 10vmin; 479 | transform: translate(1.1em, 1.1em); 480 | } 481 | } 482 | } 483 | 484 | .button { 485 | transform: translateX(0); 486 | transition: 500ms transform cubic-bezier(0.3, 0.7, 0, 1) 100ms, 200ms opacity 200ms; 487 | 488 | @for $i from 1 through 5 { 489 | &:nth-child(#{$i}) { 490 | transition-delay: #{50 + $i * 50}ms; 491 | } 492 | } 493 | } 494 | 495 | .wrap__hideUI .button { 496 | opacity: 0; 497 | transform: translateY(10vh); 498 | transition: 300ms transform cubic-bezier(0.3, 0.7, 0, 1), 200ms opacity; 499 | 500 | @for $i from 1 through 5 { 501 | &:nth-child(#{$i}) { 502 | transition-delay: #{50 + (5 - $i) * 50}ms; 503 | } 504 | } 505 | } 506 | 507 | .wrap__showSettings .button { 508 | transform: translateX(13vw); 509 | 510 | @for $i from 1 through 5 { 511 | &:nth-child(#{5 - $i}) { 512 | transition-delay: #{50 + $i * 50}ms; 513 | } 514 | } 515 | @media (max-width: 850px) { 516 | transform: translateX(0); 517 | } 518 | } 519 | 520 | .wrap__showShare .button { 521 | transform: translateX(-13vw); 522 | 523 | @for $i from 1 through 5 { 524 | &:nth-child(#{$i}) { 525 | transition-delay: #{50 + $i * 50}ms; 526 | } 527 | } 528 | 529 | @media (max-width: 850px) { 530 | transform: translateX(0); 531 | } 532 | } 533 | 534 | .button { 535 | position: relative; 536 | margin: 0; 537 | padding: 0; 538 | font-size: 2em; 539 | width: 1.2em; 540 | height: 1.2em; 541 | background: var(--color-last, #202126); 542 | color: var(--color-last-contrast, var(--color-inverted)); 543 | 544 | border-radius: 50%; 545 | outline: 0; 546 | 547 | box-shadow: 0 0 0 1px rgba(#fff, 0); 548 | 549 | /* 550 | .wrap__hasGradients & { 551 | &::before { 552 | z-index: -1; 553 | content: ''; 554 | box-shadow: 0 0 2rem rgba(#202126, .2); 555 | position: absolute; 556 | top: 0; 557 | right: 0; 558 | bottom: 0; 559 | left: 0; 560 | border-radius: 50%; 561 | } 562 | }*/ 563 | 564 | & + & { 565 | margin-left: calc(var(--size-gutter) * .6); 566 | } 567 | 568 | svg { 569 | position: absolute; 570 | top: 50%; 571 | left: 50%; 572 | transform: translate(-50%, -50%); 573 | opacity: .8; 574 | 575 | width: .6em; 576 | height: .6em; 577 | animation-name: rotate; 578 | animation-duration: 3s; 579 | animation-iteration-count: infinite; 580 | animation-timing-function: linear; 581 | animation-play-state: paused; 582 | line-height: 1; 583 | stroke: var(--color-last-contrast, var(--color-inverted)); 584 | color: var(--color-last-contrast, var(--color-inverted)); 585 | 586 | //shape-rendering: geometricPrecision; 587 | } 588 | 589 | &.refresh svg { 590 | width: .55em; 591 | height: .55em; 592 | } 593 | 594 | &:hover { 595 | svg { 596 | animation-play-state: running; 597 | } 598 | } 599 | } 600 | 601 | .settings, 602 | .share { 603 | &::after { 604 | content: '×'; 605 | position: absolute; 606 | top: 50%; 607 | left: 50%; 608 | margin-top: -.05em; 609 | transform: translate(-50%, -50%) scale(.2); 610 | opacity: 0; 611 | font-weight: 300; 612 | } 613 | } 614 | 615 | .wrap__showSettings .settings, 616 | .wrap__showShare .share { 617 | &::after { 618 | opacity: 1; 619 | transition: 100ms opacity linear 50ms, 620 | 300ms transform cubic-bezier(.3, 0, .7, 1.8); 621 | transform: translate(-50%, -50%) scale(.75); 622 | } 623 | 624 | svg { 625 | transition: 100ms opacity linear; 626 | opacity: 0; 627 | } 628 | } 629 | 630 | .share { 631 | svg { 632 | animation: none; 633 | stroke-dasharray: 20 20; 634 | stroke-dashoffset: 0; 635 | width: .55em; 636 | height: .55em; 637 | stroke: var(--color-last-contrast, var(--color-inverted)); 638 | stroke-width: 1px; 639 | margin-top: .01em; 640 | margin-left: -0.03em; 641 | } 642 | &:hover { 643 | svg { 644 | animation: 1s outline cubic-bezier(.7,.3,0,1); 645 | } 646 | } 647 | } 648 | 649 | .panel, 650 | .panel-share { 651 | opacity: 0; 652 | pointer-events: none; 653 | box-sizing: border-box; 654 | padding: calc(var(--size-gutter) * 2); 655 | padding-right: calc(25% + var(--size-gutter) * 2); 656 | background: var(--color-bg); 657 | //backdrop-filter: blur(5px); 658 | 659 | display: block; 660 | cursor: default; 661 | 662 | color: var(--color-inverted); 663 | 664 | ::selection { 665 | color: var(--color-first-contrast); 666 | background: var(--color-first); 667 | } 668 | 669 | transform: translateX(-50%) scale(.95); 670 | 671 | @media (max-width: 850px) { 672 | padding: var(--size-gutter); 673 | background: rgba(#202126, 0.8); 674 | 675 | .lightmode & { 676 | background: rgba(#fff, 0.8); 677 | } 678 | 679 | @supports ((-webkit-backdrop-filter: blur(10px)) or (backdrop-filter: blur(10px))) { 680 | backdrop-filter: blur(10px); 681 | -webkit-backdrop-filter: blur(10px); 682 | background: rgba(#202126, .1); 683 | .lightmode & { 684 | background: rgba(#fff, 0.8); 685 | } 686 | } 687 | } 688 | 689 | input[type=checkbox] { 690 | display: none; 691 | } 692 | 693 | input[type=checkbox]:checked + &__checkbox { 694 | svg { 695 | opacity: 1; 696 | transform: translate(-50%, -50%) scale(1); 697 | } 698 | } 699 | 700 | &__checkbox { 701 | display: block; 702 | position: relative; 703 | flex: 0 0 0.8rem; 704 | width: .8em; 705 | height: .8em; 706 | border: 1px solid var(--color-inverted); 707 | margin-top: -0.3em; 708 | transform: scale(.95); 709 | transition: 100ms transform cubic-bezier(0.3, 0.7, 0, 1); 710 | 711 | svg { 712 | opacity: 0; 713 | transform: translate(-50%, -50%) scale(.1); 714 | transition: 60ms opacity linear, 715 | 120ms transform cubic-bezier(0.7, 0.3, .8, 2); 716 | position: absolute; 717 | top: 50%; 718 | left: 50%; 719 | width: 100%; 720 | height: 100%; 721 | } 722 | } 723 | 724 | &__section, 725 | &__setting { 726 | display: block; 727 | touch-action: manipulation; 728 | 729 | & + & { 730 | margin-top: calc(var(--size-gutter) * 2); 731 | 732 | } 733 | 734 | &--checkbox + &--checkbox { 735 | margin-top: var(--size-gutter); 736 | } 737 | 738 | &--inline { 739 | display: flex; 740 | align-items: center; 741 | 742 | .panel__settingtitle { 743 | margin-left: calc(var(--size-gutter) * .5); 744 | } 745 | } 746 | } 747 | 748 | &__desc { 749 | margin: 1em 0 3em; 750 | font-size: .6em; 751 | } 752 | 753 | select { 754 | font-size: .8em; 755 | border-radius: 2rem; 756 | padding: .2rem; 757 | } 758 | 759 | input, select { 760 | display: block; 761 | box-sizing: border-box; 762 | touch-action: manipulation; 763 | font-family: 'Space Mono', monospace; 764 | border: none; 765 | width: auto; 766 | 767 | &[type=number], 768 | &[type=text] { 769 | color: var(--color-inverted); 770 | background: none; 771 | border: none; 772 | text-align: right; 773 | font-size: .8em; 774 | } 775 | 776 | &[type=number] { 777 | flex: 0 0 3rem; 778 | width: 3rem; 779 | } 780 | } 781 | 782 | input[type=url] { 783 | text-decoration: underline; 784 | } 785 | 786 | input { 787 | background-color: transparent; 788 | } 789 | 790 | /* Chrome, Safari, Edge, Opera */ 791 | input::-webkit-outer-spin-button, 792 | input::-webkit-inner-spin-button { 793 | -webkit-appearance: none; 794 | margin: 0; 795 | } 796 | 797 | /* Firefox */ 798 | input[type=number] { 799 | -moz-appearance: textfield; 800 | } 801 | 802 | input[type=range], 803 | input[type=color] { 804 | -webkit-appearance: none; 805 | } 806 | 807 | // range sliders 808 | input[type=range] { 809 | margin: 0; 810 | z-index: 1; 811 | padding-top: 0.7em; 812 | margin-top: -0.7em; 813 | } 814 | 815 | input[type=range]:focus { 816 | outline: none; 817 | 818 | &::-webkit-slider-thumb { 819 | //height: .65rem; 820 | background-color: var(--color-inverted); 821 | clip-path: polygon(100% 0%, 0% 0%, 50% 100%, 50% 100%); 822 | //clip-path: polygon(50% 0%, 50% 0%, 0% 100%, 100% 100%); 823 | } 824 | } 825 | 826 | @mixin slider-track { 827 | width: 100%; 828 | height: 1rem; 829 | animate: 0.2s; 830 | background: transparent; 831 | color: var(--c-black); 832 | border-radius: 0; 833 | border: solid var(--color-inverted); 834 | border-width: 0 0 1px; 835 | } 836 | 837 | @mixin slider-thumb { 838 | position: relative; 839 | border: 2px solid transparent; 840 | height: .75rem; 841 | width: .5rem; 842 | border-radius: 0; 843 | background: var(--color-inverted); 844 | -webkit-appearance: none; 845 | margin-top: 0.25rem; 846 | transition: 150ms background-color, 200ms clip-path, 200ms -webkit-clip-path; 847 | clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%); 848 | } 849 | 850 | input[type=range]::-webkit-slider-runnable-track { 851 | @include slider-track; 852 | } 853 | 854 | input[type=range]::-webkit-slider-thumb { 855 | @include slider-thumb; 856 | } 857 | 858 | input[type=range]:focus::-webkit-slider-runnable-track { 859 | //background: $c-black; 860 | } 861 | 862 | input[type=range]::-moz-range-track { 863 | @include slider-track; 864 | } 865 | 866 | input[type=range]::-moz-range-thumb { 867 | @include slider-thumb; 868 | } 869 | 870 | input[type=range]::-ms-track { 871 | @include slider-track; 872 | } 873 | 874 | input[type=range]::-ms-fill-lower { 875 | background: var(--color-inverted); 876 | border: none; 877 | border-radius: 100%; 878 | } 879 | 880 | input[type=range]::-ms-fill-upper { 881 | background: var(--color-inverted); 882 | border-radius: 100%; 883 | box-shadow: none; 884 | } 885 | 886 | input[type=range]::-ms-thumb { 887 | @include slider-thumb; 888 | } 889 | 890 | input[type=range]:focus::-ms-fill-lower { 891 | //background: $c-black; 892 | } 893 | 894 | input[type=range]:focus::-ms-fill-upper { 895 | //background: $c-black; 896 | } 897 | 898 | select { 899 | color: var(--color-inverted); 900 | width: 100%; 901 | box-sizing: border-box; 902 | -webkit-appearance: none; 903 | border: 0; 904 | box-shadow: 0 1px 0 0 var(--color-inverted); 905 | border-radius: 0; 906 | padding: 0.25rem 1rem 0.25rem 0rem; 907 | background-color: transparent; 908 | 909 | option { 910 | color: var(--color-bg); 911 | } 912 | 913 | &:focus { 914 | outline: none; 915 | background-color: transparent; 916 | } 917 | } 918 | } 919 | 920 | .panel__section { 921 | font-size: .7em; 922 | } 923 | 924 | .panel__url { 925 | display: block; 926 | overflow: hidden; 927 | text-overflow: ellipsis; 928 | white-space: nowrap; 929 | } 930 | 931 | .panel__share { 932 | font-size: .6em; 933 | h4 { 934 | margin-bottom: 1em; 935 | } 936 | a { 937 | display: inline-block; 938 | margin-right: .3em; 939 | text-transform: capitalize; 940 | } 941 | 942 | a:hover { 943 | text-decoration: none; 944 | } 945 | } 946 | 947 | 948 | .panel-share { 949 | padding: calc(var(--size-gutter) * 2); 950 | padding-left: calc(25% + var(--size-gutter) * 2); 951 | 952 | @media (max-width: 850px) { 953 | padding: var(--size-gutter); 954 | } 955 | } 956 | 957 | .panel__setting--bnw { 958 | max-height: 0; 959 | overflow: hidden; 960 | transition: 400ms max-height cubic-bezier(0.3, 0.7, 0, 1), 961 | 400ms margin-bottom cubic-bezier(0.3, 0.7, 0, 1), 962 | 400ms margin-top cubic-bezier(0.3, 0.7, 0, 1), 963 | 200ms opacity; 964 | opacity: 0; 965 | margin-bottom: calc(var(--size-gutter) * -1); 966 | transition-delay: 100ms, 0, 0, 0; 967 | margin-top: .5em; 968 | } 969 | 970 | .panel__img { 971 | position: relative; 972 | border: 1px solid var(--color-inverted); 973 | padding: 4px; 974 | min-height: 2rem; 975 | 976 | &::before { 977 | position: absolute; 978 | content: ''; 979 | top: 0; 980 | right: 0; 981 | background: var(--color-bg); 982 | width: 3.5rem; 983 | height: 1.9rem; 984 | } 985 | 986 | &--export { 987 | margin-top: 1rem; 988 | &::before { 989 | display: none; 990 | } 991 | } 992 | 993 | & > svg { 994 | display: block; 995 | } 996 | 997 | @media (max-width: 850px) { 998 | width: fit-content; 999 | } 1000 | 1001 | .icon { 1002 | width: 1rem; 1003 | height: 1rem; 1004 | content: "▲"; 1005 | position: absolute; 1006 | top: .5rem; 1007 | right: .5rem; 1008 | } 1009 | 1010 | input { 1011 | position: absolute; 1012 | inset: 0; 1013 | opacity: 0; 1014 | } 1015 | 1016 | .icon--up { 1017 | stroke-dasharray: 100 30; 1018 | stroke-dashoffset: 0; 1019 | transition: 3s stroke-dashoffset linear; 1020 | } 1021 | 1022 | .icon--re:hover { 1023 | animation-play-state: running; 1024 | } 1025 | 1026 | .icon--re { 1027 | right: 1.5rem; 1028 | top: 1rem; 1029 | animation-name: rotate; 1030 | animation-duration: 3s; 1031 | animation-iteration-count: infinite; 1032 | animation-timing-function: linear; 1033 | animation-play-state: paused; 1034 | } 1035 | 1036 | &:hover { 1037 | .icon--up { 1038 | stroke-dashoffset: -260; 1039 | } 1040 | } 1041 | } 1042 | .panel__img img { 1043 | display: block; 1044 | width: 100%; 1045 | 1046 | @media (max-width: 850px) { 1047 | width: auto; 1048 | max-width: 100%; 1049 | } 1050 | } 1051 | 1052 | .panel__imgpositions { 1053 | position: absolute; 1054 | inset: 0; 1055 | pointer-events: none; 1056 | overflow: hidden; 1057 | } 1058 | 1059 | .panel__imgposition { 1060 | position: absolute; 1061 | top: calc(var(--y) * 100%); 1062 | left: calc(var(--x) * 100%); 1063 | width: .4rem; 1064 | height: .4rem; 1065 | border-radius: 50%; 1066 | transform: translate(-50%, -50%); 1067 | box-shadow: 0 0 0 1px var(--color-inverted), 1068 | inset 0 0 0 1px var(--color-bg), 1069 | 0 0 0 2px var(--c); 1070 | } 1071 | 1072 | .panel__code { 1073 | margin-top: 1rem; 1074 | font-size: 0.6em; 1075 | font-family: 'Space Mono', monospace; 1076 | background: var(--color-inverted); 1077 | color: var(--color-bg); 1078 | padding: 1rem; 1079 | overflow-x: hidden; 1080 | text-overflow: ellipsis; 1081 | } 1082 | 1083 | .panel__export { 1084 | position: relative; 1085 | margin-bottom: 1rem; 1086 | 1087 | .minibutton { 1088 | z-index: 2; 1089 | position: absolute; 1090 | bottom: 2px; 1091 | right: 2px; 1092 | background: var(--color-bg); 1093 | color: var(--color-inverted); 1094 | line-height: 1; 1095 | font-size: .6rem; 1096 | padding: .4em .6em .5em .8em; 1097 | 1098 | .minibutton__icon--check { 1099 | display: none; 1100 | } 1101 | 1102 | &--copy { 1103 | .minibutton__label { 1104 | display: none; 1105 | } 1106 | &::before { 1107 | content: 'jeah!'; 1108 | } 1109 | .minibutton__icon { 1110 | display: none; 1111 | } 1112 | .minibutton__icon--check { 1113 | display: inline-block; 1114 | } 1115 | } 1116 | 1117 | 1118 | 1119 | svg { 1120 | width: 1em; 1121 | height: 1em; 1122 | margin-left: .5em; 1123 | position: relative; 1124 | top: .12em; 1125 | } 1126 | } 1127 | } 1128 | 1129 | .wrap__showcontrast .panel__setting--bnw { 1130 | opacity: 1; 1131 | max-height: 2em; 1132 | padding-top: .1em; 1133 | margin-bottom: 0; 1134 | transition-delay: 0, 0, 0, 100ms; 1135 | margin-top: .5em; 1136 | } 1137 | 1138 | .colors, 1139 | .panel, 1140 | .panel-share { 1141 | transition: 400ms transform cubic-bezier(0.3, 0.7, 0, 1); 1142 | 1143 | @media (min-width: 850px) { 1144 | transition: 400ms transform cubic-bezier(0.3, 0.7, 0, 1), 1145 | 1ms opacity linear 400ms; 1146 | } 1147 | } 1148 | 1149 | @media (min-width: 850px) { 1150 | .wrap__showSettings .panel { 1151 | transition-delay: 0ms, 0ms; 1152 | } 1153 | 1154 | .wrap__showShare .panel-share, 1155 | .wrap__showShare .panel { 1156 | transition-delay: 0ms, 0ms; 1157 | } 1158 | 1159 | .wrap__hasBackground .panel, 1160 | .wrap__hasBackground .panel-share { 1161 | box-shadow: none; 1162 | } 1163 | } 1164 | 1165 | .panel, 1166 | .panel-share { 1167 | //--color-bg: #fff; 1168 | //--color-inverted: #202126; 1169 | perspective: 400px; 1170 | box-shadow: inset 0 0 0 1px var(--color-inverted); 1171 | overflow: auto; 1172 | 1173 | margin-top: 1px; 1174 | margin-bottom: 1px; 1175 | 1176 | /*scrollbar-width: thin;*/ 1177 | 1178 | scrollbar-color: var(--color-bg) var(--color-inverted); 1179 | 1180 | 1181 | @media (max-width: 850px) { 1182 | opacity: 0; 1183 | z-index: 5; 1184 | transform: translateX(0%) scale(.8); 1185 | margin-top: 0; 1186 | margin-bottom: 0; 1187 | scrollbar-width: thin; 1188 | } 1189 | } 1190 | 1191 | .panel { 1192 | z-index: 2; 1193 | @media (max-width: 850px) { 1194 | z-index: 5; 1195 | } 1196 | } 1197 | 1198 | .panel-share { 1199 | direction: rtl; 1200 | z-index: 1; 1201 | & > * { 1202 | direction: ltr; 1203 | } 1204 | @media (max-width: 850px) { 1205 | z-index: 4; 1206 | direction: ltr; 1207 | } 1208 | } 1209 | 1210 | .colors { 1211 | z-index: 3; 1212 | } 1213 | 1214 | .panel__button { 1215 | position: relative; 1216 | display: block; 1217 | text-align: center; 1218 | width: 100%; 1219 | border: none; 1220 | margin-top: calc(var(--size-gutter) * 2);; 1221 | 1222 | background: var(--color-inverted); 1223 | color: var(--color-bg); 1224 | 1225 | padding: 0.8em calc(var(--size-gutter) * 2); 1226 | 1227 | font-size: 0.7rem; 1228 | font-weight: normal; 1229 | line-height: 1; 1230 | 1231 | overflow: hidden; 1232 | transition: 100ms color linear 200ms; 1233 | 1234 | &::before { 1235 | content: ''; 1236 | position: absolute; 1237 | bottom: 0; 1238 | left: 0; 1239 | right: 0; 1240 | height: calc(100% * var(--colors)); 1241 | background: linear-gradient(to bottom, var(--gradient-hard)); 1242 | transform: translateY(100%); 1243 | transition: 1000ms transform cubic-bezier(0.3, 0.7, 0, 1); 1244 | //--gradient-hard 1245 | z-index: 0; 1246 | } 1247 | 1248 | span { 1249 | position: relative; 1250 | z-index: 2; 1251 | } 1252 | 1253 | &:hover { 1254 | color: var(--color-last-contrast, var(--color-inverted)); 1255 | &::before { 1256 | transform: translateY(0%); 1257 | } 1258 | } 1259 | } 1260 | 1261 | .panel__setting { 1262 | opacity: 0; 1263 | transform: translateX(2rem) scale(1) rotateY(-5deg); 1264 | transform-origin: 150% 50%; 1265 | user-select: none; 1266 | 1267 | @media (max-width: 850px) { 1268 | transform: translateY(120%) scaleY(.5) rotateX(30deg); 1269 | } 1270 | } 1271 | 1272 | .panel-share .panel__setting { 1273 | transform: translateX(-2rem) scale(1) rotateY(-5deg); 1274 | transform-origin: -50% 50%; 1275 | 1276 | @media (max-width: 850px) { 1277 | transform: translateY(120%) scaleY(.5) rotateX(30deg); 1278 | } 1279 | } 1280 | 1281 | .wrap__showSettings .panel .panel__setting, 1282 | .wrap__showShare .panel-share .panel__setting { 1283 | opacity: 1; 1284 | transform: translateX(0%) scale(1) rotateY(0deg); 1285 | transition: 550ms transform cubic-bezier(0.3, 0.7, 0, 1) 50ms, 1286 | 475ms opacity linear 50ms; 1287 | 1288 | @for $i from 1 through 30 { 1289 | &:nth-child(#{$i}) { 1290 | transition-delay: #{0 + $i * 30}ms, 1291 | #{0 + $i * 35}ms; 1292 | } 1293 | } 1294 | } 1295 | 1296 | .share-url { 1297 | font-size: .7em; 1298 | } 1299 | 1300 | .wrap__showSettings .panel { 1301 | opacity: 1; 1302 | pointer-events: all; 1303 | transform: translateX(-75%); 1304 | 1305 | @media (max-width: 850px) { 1306 | transform: none; 1307 | opacity: 1; 1308 | } 1309 | } 1310 | 1311 | .wrap__showSettings .panel-share { 1312 | opacity: 0; 1313 | } 1314 | 1315 | .wrap__showShare .panel-share { 1316 | opacity: 1; 1317 | pointer-events: all; 1318 | transform: translateX(-25%); 1319 | 1320 | @media (max-width: 850px) { 1321 | transform: none; 1322 | opacity: 1; 1323 | } 1324 | } 1325 | 1326 | .wrap__showShare .panel { 1327 | opacity: 0; 1328 | } 1329 | 1330 | .wrap__showSettings .colors { 1331 | transform: translateX(-25%); 1332 | 1333 | @media (max-width: 850px) { 1334 | transform: none; 1335 | } 1336 | } 1337 | 1338 | .wrap__showShare .colors { 1339 | transform: translateX(-75%); 1340 | 1341 | @media (max-width: 850px) { 1342 | transform: none; 1343 | } 1344 | } 1345 | 1346 | .panel__settingtitle { 1347 | font-size: 0.7rem; 1348 | font-weight: normal; 1349 | line-height: 1; 1350 | margin-bottom: .4em; 1351 | 1352 | i { 1353 | display: inline-block; 1354 | font-size: .7em; 1355 | margin-left: 1em; 1356 | } 1357 | } 1358 | 1359 | .panel__text { 1360 | font-size: .7em; 1361 | margin-top: 1em; 1362 | line-height: 1.4; 1363 | } 1364 | 1365 | .panel__inputs, 1366 | .panel__settingtitle { 1367 | display: flex; 1368 | 1369 | > *:first-child { 1370 | flex: 1 0 auto; 1371 | } 1372 | } 1373 | 1374 | .panel__inputs { 1375 | position: relative; 1376 | z-index: 1; 1377 | } 1378 | 1379 | .panel__inputs--select { 1380 | position: relative; 1381 | svg { 1382 | width: .8em; 1383 | height: .8em; 1384 | position: absolute; 1385 | right: -.25em; 1386 | bottom: .35em; 1387 | } 1388 | } 1389 | 1390 | .wrap__expandUI { 1391 | .panel, 1392 | .panel-share, 1393 | .colors { 1394 | inset: 0; 1395 | } 1396 | 1397 | .panel, 1398 | .panel-share { 1399 | background: rgba(#202126,.25); 1400 | box-shadow: none; 1401 | 1402 | .lightmode & { 1403 | background: rgba(#fff, .5); 1404 | } 1405 | } 1406 | 1407 | .colors { 1408 | width: 100%; 1409 | transform: translateX(0%); 1410 | } 1411 | 1412 | & .buttons { 1413 | bottom: calc(var(--size-gutter) * 2.5); 1414 | right: calc(var(--size-gutter) * 2); 1415 | transform: translate(1.1em, 1.1em); 1416 | } 1417 | 1418 | &.wrap__sameHeightColors .buttons { 1419 | right: 50%; 1420 | transform: translate(50%, 75%); 1421 | 1422 | @media (max-width: 850px) { 1423 | bottom: calc(var(--size-gutter) * 2.5); 1424 | right: calc(var(--size-gutter) * 2); 1425 | transform: translate(1.1em, 1.9em); 1426 | } 1427 | } 1428 | 1429 | /*.button { 1430 | background: var(--color-first); 1431 | color: var(--color-first-contrast); 1432 | }*/ 1433 | 1434 | &.wrap__showSettings { 1435 | .colors { 1436 | transform: translateX(calc(25% + var(--size-gutter))); 1437 | @media (max-width: 850px) { 1438 | transform: translateX(0%); 1439 | } 1440 | } 1441 | 1442 | .panel { 1443 | transform: translateX(0%); 1444 | transition-delay: 50ms; 1445 | } 1446 | 1447 | .button { 1448 | transform: translateX(0); 1449 | @media (max-width: 850px) { 1450 | transform: translateX(5rem); 1451 | } 1452 | 1453 | &.share { 1454 | @media (max-width: 850px) { 1455 | transform: translateX(7rem); 1456 | } 1457 | } 1458 | &.refresh { 1459 | @media (max-width: 850px) { 1460 | transform: translateX(9rem); 1461 | } 1462 | } 1463 | } 1464 | } 1465 | 1466 | .panel-share { 1467 | left: auto; 1468 | right: 0; 1469 | transform: translateX(100%); 1470 | } 1471 | 1472 | &.wrap__showShare { 1473 | .colors { 1474 | transform: translateX(calc(-25% - var(--size-gutter))); 1475 | 1476 | @media (max-width: 850px) { 1477 | transform: translateX(0%); 1478 | } 1479 | } 1480 | 1481 | .panel-share { 1482 | transform: translateX(0); 1483 | transition-delay: 50ms; 1484 | } 1485 | 1486 | .button { 1487 | transform: translateX(0); 1488 | 1489 | @media (max-width: 850px) { 1490 | transform: translateX(2rem); 1491 | } 1492 | 1493 | &.share { 1494 | @media (max-width: 850px) { 1495 | transform: translateX(2rem); 1496 | } 1497 | } 1498 | 1499 | &.refresh { 1500 | @media (max-width: 850px) { 1501 | transform: translateX(9rem); 1502 | } 1503 | } 1504 | } 1505 | } 1506 | } 1507 | 1508 | @keyframes rotate { 1509 | from { 1510 | transform: translate(-50%, -50%) rotate(0deg); 1511 | } 1512 | 1513 | to { 1514 | transform: translate(-50%, -50%) rotate(360deg); 1515 | } 1516 | } 1517 | 1518 | .ellogo { 1519 | margin: calc(var(--size-gutter) * 2) auto var(--size-gutter); 1520 | display: block; 1521 | position: relative; 1522 | width: 40%; 1523 | box-sizing: border-box; 1524 | 1525 | svg { 1526 | display: block; 1527 | height: 100%; 1528 | width: 100%; 1529 | overflow: visible; 1530 | 1531 | path:nth-child(2) { 1532 | fill: none; 1533 | } 1534 | } 1535 | 1536 | &:hover { 1537 | path:nth-child(2) { 1538 | stroke: var(--color-inverted); 1539 | } 1540 | } 1541 | } 1542 | 1543 | .footer { 1544 | h2 { 1545 | font-weight: 900; 1546 | font-size: 2rem; 1547 | } 1548 | 1549 | font-size: .7em; 1550 | margin-top: calc(var(--size-gutter) * 2); 1551 | padding-top: var(--size-gutter); 1552 | line-height: 1.4; 1553 | 1554 | p { 1555 | margin-top: 1em; 1556 | margin-bottom: 2em; 1557 | } 1558 | 1559 | aside, 1560 | article { 1561 | h2 { font-size: 1.2rem; } 1562 | .title--main { 1563 | font-size: max(4vw, 1.2rem); 1564 | font-weight: 900; 1565 | } 1566 | } 1567 | 1568 | aside { 1569 | margin: 2em 0; 1570 | } 1571 | 1572 | ol, ul, li { 1573 | list-style: none; 1574 | } 1575 | 1576 | li + li{ 1577 | margin-top: 1em; 1578 | } 1579 | 1580 | ul, ol { 1581 | margin-top: 1em; 1582 | } 1583 | 1584 | a { 1585 | color: var(--color-inverted); 1586 | } 1587 | } 1588 | 1589 | .title { 1590 | font-weight: 900; 1591 | color: var(--color-inverted); 1592 | font-size: 1rem; 1593 | margin-bottom: 1.75em; 1594 | } 1595 | 1596 | // fetching 1597 | .is-imagefetching { 1598 | .button.refresh { 1599 | animation: pulsate 1s infinite alternate; 1600 | cursor: wait; 1601 | } 1602 | .colors .color { 1603 | .color__values, 1604 | .color__name { 1605 | animation: pulsate .5s infinite alternate; 1606 | } 1607 | 1608 | @for $i from 1 through 11 { 1609 | &:nth-child(#{$i}) .color__values, 1610 | &:nth-child(#{$i}) .color__name { 1611 | animation-delay: ($i/10) * 1s; 1612 | } 1613 | } 1614 | } 1615 | } 1616 | 1617 | // intro animation 1618 | 1619 | .is-loading { 1620 | .bg { 1621 | opacity: 0; 1622 | } 1623 | .panel, 1624 | .panel-share { 1625 | opacity: 0; 1626 | display: none; 1627 | } 1628 | } 1629 | 1630 | .is-loading.is-animating { 1631 | pointer-events: none; 1632 | 1633 | .bg { 1634 | opacity: 0; 1635 | } 1636 | 1637 | .colors { 1638 | perspective: 600px; 1639 | } 1640 | 1641 | .color { 1642 | opacity: 0; 1643 | transform: translateY(0%) scaleY(.5) rotate3d(.1, 0, 0, 80deg); 1644 | transform-origin: 50% 150%; 1645 | 1646 | &::after { 1647 | opacity: 0; 1648 | } 1649 | 1650 | .color__value, 1651 | .color__name { 1652 | opacity: 0; 1653 | visibility: hidden; 1654 | } 1655 | } 1656 | 1657 | .color:last-child { 1658 | padding-bottom: 0rem; 1659 | } 1660 | 1661 | .panel, 1662 | .panel-share { 1663 | display: none 1664 | } 1665 | 1666 | .button { 1667 | opacity: 0; 1668 | } 1669 | } 1670 | 1671 | .is-animating { 1672 | pointer-events: none; 1673 | 1674 | .panel, 1675 | .panel-share { 1676 | display: none 1677 | } 1678 | 1679 | .button { 1680 | opacity: 0; 1681 | } 1682 | 1683 | .colors { 1684 | perspective: 600px; 1685 | } 1686 | 1687 | .color { 1688 | opacity: 1; 1689 | transform: translateY(0) scaleY(1); 1690 | transform-origin: 50% 0%; 1691 | transition: 250ms opacity linear, 1692 | 200ms transform cubic-bezier(.445, .05, .55, .95); 1693 | 1694 | &::after { 1695 | visibility: hidden; 1696 | opacity: 0; 1697 | animation: 500ms reveal linear; 1698 | animation-fill-mode: forwards; 1699 | } 1700 | 1701 | .color__value, 1702 | .color__name { 1703 | transition: 100ms opacity linear; 1704 | } 1705 | 1706 | @for $i from 1 through 11 { 1707 | &:nth-child(#{$i}) { 1708 | transition-delay: #{130 + $i * 60}ms, #{50 + $i * 60}ms; 1709 | transition-duration: #{250ms + $i * $i * 5ms}, #{200ms + $i * $i * 5ms}; 1710 | 1711 | &::after { 1712 | animation-delay: #{800 + $i * 50}ms; 1713 | } 1714 | 1715 | .color__value, 1716 | .color__name { 1717 | transition-delay: #{450 + $i * 70}ms, #{450 + $i * 70}ms; 1718 | } 1719 | } 1720 | } 1721 | } 1722 | 1723 | .color:last-child { 1724 | padding-bottom: 0rem; 1725 | } 1726 | 1727 | .bg { 1728 | opacity: 0; 1729 | } 1730 | } 1731 | 1732 | @keyframes reveal { 1733 | 0% { 1734 | visibility: hidden; 1735 | opacity: 0; 1736 | } 1737 | 1% { 1738 | visibility: visible; 1739 | opacity: 0; 1740 | } 1741 | 100% { 1742 | visibility: visible; 1743 | opacity: 0.5; 1744 | } 1745 | } 1746 | 1747 | @keyframes pulsate { 1748 | 100% { 1749 | opacity: 0.25; 1750 | } 1751 | } 1752 | 1753 | @keyframes outline { 1754 | 100% { 1755 | stroke-dashoffset: 40; 1756 | } 1757 | } 1758 | 1759 | @media (prefers-reduced-motion) { 1760 | * { 1761 | animation-duration: 0.001ms !important; 1762 | animation-delay: 0s !important; 1763 | transition-delay: 0s !important; 1764 | transition-duration: 0.001ms !important; 1765 | } 1766 | } 1767 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "farbvelo", 3 | "version": "1.0.0", 4 | "description": "color harmonie cycler", 5 | "main": "index.pug", 6 | "scripts": { 7 | "start": "parcel index.pug", 8 | "build": "parcel build *.pug", 9 | "test": "npm run build" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/meodai/farbvelo.git" 14 | }, 15 | "author": "meodai", 16 | "license": "CC-BY-SA-4.0", 17 | "bugs": { 18 | "url": "https://github.com/meodai/farbvelo/issues" 19 | }, 20 | "homepage": "https://github.com/meodai/farbvelo#readme", 21 | "devDependencies": { 22 | "babel-core": "^6.26.3", 23 | "babel-preset-env": "^1.7.0", 24 | "parcel-bundler": "^1.12.4", 25 | "parcel-plugin-static-files-copy": "^2.5.0", 26 | "pug": "^3.0.0", 27 | "sass": "^1.29.0" 28 | }, 29 | "dependencies": { 30 | "chroma-js": "^2.4.2", 31 | "gifenc": "^1.0.3", 32 | "hsluv": "^0.1.0", 33 | "randomcolor": "^0.6.2", 34 | "seedrandom": "^3.0.5", 35 | "simplex-noise": "^3.0.1", 36 | "spectral.js": "^2.0.0", 37 | "vue": "^2.6.12" 38 | }, 39 | "staticFiles": { 40 | "staticPath": "public" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /pig.mjs: -------------------------------------------------------------------------------- 1 | import init, { 2 | pigments 3 | } from 'pigmnts'; 4 | 5 | 6 | export async function runPigment(canvas, colorsLength, colors) { 7 | await init('node_modules/pigmnts/pigmnts_bg.wasm'); 8 | const palette = pigments(canvas, colorsLength); 9 | 10 | console.log(palette) 11 | 12 | colors.colorsValues = palette.map(c => c.hex); 13 | }; 14 | 15 | window.runPigment = runPigment; 16 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/background.js: -------------------------------------------------------------------------------- 1 | chrome.app.runtime.onLaunched.addListener(function() { 2 | chrome.app.window.create('index.html', { 3 | 'outerBounds': { 4 | 'width': 400, 5 | 'height': 500 6 | } 7 | }); 8 | }); -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #ffffff 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/farbicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/farbicon.png -------------------------------------------------------------------------------- /public/farbvelo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/farbvelo.png -------------------------------------------------------------------------------- /public/farbvelo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/favicon.ico -------------------------------------------------------------------------------- /public/maifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "FarbVelo — Random Color Cycler", 3 | "description": "Generative color harmonies. The random color expolorer", 4 | "version": "1", 5 | "manifest_version": 2, 6 | "app": { 7 | "background": { 8 | "scripts": ["background.js"] 9 | } 10 | }, 11 | "icons": { 12 | "16": "farbvelo.png", 13 | "128": "farbvelo.png" 14 | } 15 | } -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/samples/engine-color-bingo-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-color-bingo-01.png -------------------------------------------------------------------------------- /public/samples/engine-color-bingo-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-color-bingo-02.png -------------------------------------------------------------------------------- /public/samples/engine-color-bingo-03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-color-bingo-03.png -------------------------------------------------------------------------------- /public/samples/engine-color-bingo-04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-color-bingo-04.png -------------------------------------------------------------------------------- /public/samples/engine-color-bingo-05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-color-bingo-05.png -------------------------------------------------------------------------------- /public/samples/engine-color-bingo-06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-color-bingo-06.png -------------------------------------------------------------------------------- /public/samples/engine-legacy-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-legacy-01.png -------------------------------------------------------------------------------- /public/samples/engine-legacy-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-legacy-02.png -------------------------------------------------------------------------------- /public/samples/engine-legacy-03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-legacy-03.png -------------------------------------------------------------------------------- /public/samples/engine-legacy-04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-legacy-04.png -------------------------------------------------------------------------------- /public/samples/engine-legacy-05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-legacy-05.png -------------------------------------------------------------------------------- /public/samples/engine-legacy-06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-legacy-06.png -------------------------------------------------------------------------------- /public/samples/engine-legacy-07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-legacy-07.png -------------------------------------------------------------------------------- /public/samples/engine-legacy-08.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-legacy-08.png -------------------------------------------------------------------------------- /public/samples/engine-legacy-09.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-legacy-09.png -------------------------------------------------------------------------------- /public/samples/engine-legacy-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-legacy-10.png -------------------------------------------------------------------------------- /public/samples/engine-legacy-11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-legacy-11.png -------------------------------------------------------------------------------- /public/samples/engine-legacy-12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meodai/farbvelo/d48cfcbabec921d102e3a879719ef1fdaa9d3a93/public/samples/engine-legacy-12.png -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "FarbVelo", 3 | "short_name": "FarbVelo", 4 | "icons": [ 5 | { 6 | "src": "android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /seeds.js: -------------------------------------------------------------------------------- 1 | export const solidFirstImpressionSeeds = [ 2 | "c28e0e0e26704", 3 | "6ce472e9c1349", 4 | "01db6e8a5e2648", 5 | "e3f479d20de55", 6 | "meodai", 7 | "6cce2b733fdc3", 8 | "9e4698bc52389", 9 | "14dd7afb54fdd", 10 | "b053abde9eaf8", 11 | "5096c7b088a5c", 12 | "c6596cd5f46848", 13 | "58a040c4afa3", 14 | "a7d2714cde7898", 15 | "756821670cb7b8", 16 | "c0c7aac92360b", 17 | "097d5168d7ca88", 18 | "00374a873b72e", 19 | "1721ba59bf2608", 20 | "ec33cea2246658", 21 | "9d2027bde5baa", 22 | "e111c49a556918", 23 | "7b81f30a07f018", 24 | "278ac9d860f1a8", 25 | "af95d1f4d64ab", 26 | "f0568004a4f7e", 27 | "f70e431bfd0738", 28 | "d6434a878ad51", 29 | "56d7b530c57d78", 30 | "85a878e32ca7e8", 31 | "d6dbffd0f34bd", 32 | "d88a8a08ea343", 33 | "6042908d1c6d08", 34 | "a83b53640f0bd8", 35 | "b36e1da764d1b", 36 | "c1d7fa9e19a61", 37 | "24b227a18532f8", 38 | "a36f0a1096b93", 39 | "ea4452a0f1761", 40 | "9291f114836f7", 41 | "ec3d7b0914f48", 42 | "036e13d96bd1a", 43 | "a09baa833cbce", 44 | "29e802eb06f1b", 45 | "diluninjli", 46 | "farbvélö", 47 | "senip", 48 | "8bbbc5797bf1a", 49 | "017d5635756358", 50 | "51b66c3bbb609", 51 | "1026c360fd6fd", 52 | "9bb28f79c95ce", 53 | "05f0517151ca1", 54 | "dc9d95a7a0f0c", 55 | "3ce8b71676d93", 56 | "HunterFizz", 57 | "EtherealCandy", 58 | "7f6adb34163f1", 59 | "1", 60 | "1313103'49249345830984530958938530853853", 61 | "75622075ac8cd8", 62 | "dismami", 63 | "roland+", 64 | "1313231", 65 | "07d74849910c", 66 | "deffac874e2d48", 67 | "36870044d0557", 68 | "f56f2927f2f7c", 69 | "3161e5613b8318", 70 | "fc8ea1e1829f1", 71 | "286b06f236ee6", 72 | "a4fbea8ddbac68", 73 | "3ab4647489f17", 74 | "13f66c90d54a88", 75 | "294107bfbb76e", 76 | "4dc2d846c001a8", 77 | "e4efe9a7da77f8", 78 | "60835842ea7728", 79 | "130572c4a1fb98", 80 | "d6fb5395ebfb78", 81 | "fde9283a27dc2", 82 | "276a94c40895", 83 | "2100af814e502", 84 | "eb27223cb254a8", 85 | "b7766f752bf818", 86 | "28f13718295cc8", 87 | "b49592f3c985e8", 88 | "19db034a105d68", 89 | "d3988215f11fb8", 90 | "b5f68e6c699878", 91 | "d80f07ec11a978", 92 | "3f772e335902d", 93 | "260964ee2bcc68", 94 | "58d60d76b2bf48", 95 | "localhorst", 96 | "44056a4f7c4da8", 97 | "d2bd865f949ac8", 98 | "06b5c4551c29a8", 99 | "7856ed3d908178", 100 | "66c1054f10dbc", 101 | "466d46f8fe2b48", 102 | "1e8af306e5f008", 103 | "b4082459b70c78", 104 | "52b813013460a8", 105 | "0e17a65ef25388", 106 | "4d77a2c7c04f5", 107 | "fa00ac03364db", 108 | "98dac556119a38", 109 | "bcdae2ecb6b31", 110 | "cb4c6e56f61db8", 111 | "49b8b366e202d8", 112 | "c024e1c6aa7d08", 113 | "43a8102b49c", 114 | "32d286eaef15c8", 115 | "0346a357deb288", 116 | "ae25fd21c2e668", 117 | "14369509b627b", 118 | "ee94985327df", 119 | "c1017d5e0357d8", 120 | "decf4f57dd2538", 121 | "55a909aa56a708", 122 | "fe41e95b34a2a8", 123 | "a8732e3a4d26b8", 124 | "b6c435ba4281a8", 125 | "7c8f6a8f34688", 126 | "72d4d59c22b758", 127 | "9f73c85552bd6", 128 | "cdb7e6ef5f5298", 129 | ]; 130 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | // Utility functions for Farbvelo 2 | 3 | // Log an array of colors to the console with color backgrounds 4 | export const logColors = (colors) => { 5 | let o = "", s = []; 6 | for (const c of colors) { 7 | o += `%c ${c} `; 8 | s.push(`background:${c}; color:${c}`); 9 | } 10 | console.log(o, ...s); 11 | }; 12 | 13 | // Shuffle an array randomly 14 | export const shuffleArray = (arr) => 15 | arr.map(a => [Math.random(), a]) 16 | .sort((a, b) => a[0] - b[0]) 17 | .map(a => a[1]); 18 | 19 | // Generate a random string of given length 20 | export const randomStr = (length = 14) => 21 | Math.random().toString(16).substr(2, length); 22 | 23 | // Extract Unsplash photo ID from URL 24 | export const unsplashURLtoID = url => { 25 | const match = url.match(/https:\/\/images\.unsplash\.com\/photo-([\da-f]+-[\da-f]+)/); 26 | return match ? match[1] : null; 27 | }; 28 | 29 | // Convert color coordinates to hex string based on mode 30 | import { hsluvToHex, hpluvToHex } from 'hsluv'; 31 | import chroma from './lib/chroma-extensions.js'; 32 | 33 | export function coordsToHex(angle, val1, val2, mode = 'hsluv') { 34 | if (mode === 'hsluv') { 35 | return hsluvToHex([angle, val1, val2]); 36 | } else if (mode === 'hpluv') { 37 | return hpluvToHex([angle, val1, val2]); 38 | } else if (mode === 'hcl') { 39 | return chroma(angle, val1, val2, 'hcl').hex(); 40 | } else if (mode === 'lch') { 41 | return chroma(val2, val1, angle, 'lch').hex(); 42 | } else if (mode === 'oklch') { 43 | return chroma(val2 / 100 * 0.999, val1 / 100 * 0.322, angle, 'oklch').hex(); 44 | } else if (mode === 'okhsv') { 45 | // Okhsv inputs: angle (hue 0-360), val1 (saturation 0-100), val2 (value 0-100) 46 | // chroma.okhsv expects: h_deg (0-360), s_norm (0-1), v_norm (0-1) 47 | let s_norm = val1 / 100; // Adjusted for chroma.okhsv 48 | let v_norm = Math.pow(val2 / 100, .9) * 1.1 + 0.15; // Adjusted for chroma.okhsv 49 | // max them out to 1 50 | s_norm = Math.min(s_norm, 1); 51 | v_norm = Math.min(v_norm, 1); 52 | return chroma.okhsv(angle, s_norm, v_norm).hex(); 53 | } else if (mode === 'okhsl') { 54 | const s_norm = val1 / 100; // Adjusted for chroma.okhsl 55 | const l_norm = val2 / 100; 56 | return chroma.okhsl(angle, s_norm, l_norm).hex(); 57 | } else if (['hsl', 'hsv', 'hcg'].includes(mode)) { 58 | return chroma(angle, val1 / 100, val2 / 100, mode).hex(); 59 | } 60 | // Fallback for unknown modes, or if a mode wasn't handled (should not happen with proper checks) 61 | console.warn(`Unknown color mode: ${mode} in coordsToHex. Falling back to black.`); 62 | return '#000000'; 63 | } 64 | -------------------------------------------------------------------------------- /worker.js: -------------------------------------------------------------------------------- 1 | import PaletteExtractor from './lib/palette-extractor'; 2 | 3 | let paletteExtractor = new PaletteExtractor(); 4 | let colors; 5 | 6 | self.addEventListener('message', e => { 7 | switch (e.data.type) { 8 | case 'GENERATE_COLORS_ARRAY': 9 | //pixels = calculateColorsArray(e.data.imageData.data, e.data.width); 10 | paletteExtractor = new PaletteExtractor(); 11 | colors = paletteExtractor.processImageData(e.data.imageData.data, e.data.k); 12 | self.postMessage({ 13 | type: 'GENERATE_COLORS_ARRAY', 14 | // pixels 15 | }); 16 | break; 17 | case 'GENERATE_CLUSTERS': 18 | self.postMessage({ 19 | type: 'GENERATE_CLUSTERS', 20 | colors 21 | }); 22 | break; 23 | } 24 | }, false); 25 | --------------------------------------------------------------------------------