├── .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 | 
91 | 
92 | 
93 | 
94 | 
95 | 
96 | 
97 | 
98 | 
99 | 
100 | 
101 | 
102 | 
103 | 
104 | 
105 | 
106 | 
107 | 
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 ``;
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 |
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 |
--------------------------------------------------------------------------------