├── .gitignore
├── .husky
└── pre-commit
├── LICENSE.md
├── README.md
├── __mocks__
└── fileMock.js
├── docs
└── screenshot.png
├── index.html
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
├── src
├── components
│ ├── App.css
│ ├── App.test.jsx
│ ├── App.tsx
│ ├── Button.css
│ ├── Button.tsx
│ ├── Code.css
│ ├── Code.tsx
│ ├── Detail.css
│ ├── Detail.tsx
│ ├── Footer.css
│ ├── Footer.tsx
│ ├── Gradient.css
│ ├── Gradient.tsx
│ ├── History.css
│ ├── History.tsx
│ ├── Options.css
│ ├── Options.tsx
│ ├── Output.css
│ ├── Output.tsx
│ ├── Picker.css
│ ├── Picker.tsx
│ ├── Point.css
│ └── Point.tsx
├── index.css
├── lib
│ ├── bitDepth.test.ts
│ ├── bitDepth.ts
│ ├── colorSpace.test.ts
│ ├── colorSpace.ts
│ ├── gradient.test.ts
│ ├── gradient.ts
│ ├── hex.test.ts
│ ├── hex.ts
│ ├── output.test.ts
│ ├── output.ts
│ ├── targets.ts
│ ├── url.test.ts
│ ├── url.ts
│ ├── utils.test.ts
│ └── utils.ts
├── main.tsx
├── setupTests.js
├── store
│ ├── actions.ts
│ ├── index.ts
│ ├── options.ts
│ └── points.ts
├── types.ts
└── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.js
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npm run lint
5 | npm run tsc
6 | npm test
7 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2022 Graham Bates
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Gradient Blaster
2 |
3 | https://gradient-blaster.grahambates.com
4 |
5 |
6 | Gradient Blaster is a web-based tool to build gradient data for retro platforms using a visual editor. It supports multiple algorithms for colour blending and dithering. The gradient data can be exported in several formats for use with different languages and use cases.
7 |
8 | ## Interface
9 |
10 | 
11 |
12 | 1. [Options](#options)
13 | 2. Selected point detail - Editor for the current point
14 | 3. Points track - Draggable/selectable markers of the points defined on the gradient
15 | 4. Preview
16 | 5. [Output](#output) - Export gradient data in desired format
17 | 6. History - undo/redo changes or reset to default state
18 |
19 | ---
20 |
21 | ## Options
22 |
23 | ### Steps:
24 |
25 | The number of values in the gradient output i.e. the number of pixels it spans.
26 |
27 | ### Blend mode:
28 |
29 | The colour model / algorithm used to interpolate between points in the gradient.
30 |
31 | 1. **OKLAB:** Interpolates values using the [OKLAB](https://bottosson.github.io/posts/oklab/) colour space. This is a perceptual colour space designed for image processing tasks, including creating smooth and uniform looking transitions between colors.
32 | 2. **LAB:** Interpolates values using the standard [LAB](https://en.wikipedia.org/wiki/CIELAB_color_space) colour space.
33 | 3. **Gamma adjusted RGB:** Converts to linear colour space using the SRGB model prior to interpolation. Applies adjustments for percieved brightness. Based on ['Mark's method'](https://stackoverflow.com/questions/22607043/color-gradient-algorithm).
34 | 4. **Simple RGB:** Naive linear interpolation of the raw RGB values.
35 |
36 | ### Target:
37 |
38 | The platform and colour mode that the gradient is intended for:
39 |
40 | | Platform | Bit depth | Data format |
41 | |-|-|-|
42 | | [Amiga OCS](https://en.wikipedia.org/wiki/Original_Chip_Set)/[ECS](https://en.wikipedia.org/wiki/Amiga_Enhanced_Chip_Set) | 12 bit (4 per channel) | Single word: `R3 R2 R1 R0 G3 G2 G1 G0 B3 B2 B1 B0`
43 | | [Amiga OCS](https://en.wikipedia.org/wiki/Original_Chip_Set)/[ECS](https://en.wikipedia.org/wiki/Amiga_Enhanced_Chip_Set) Interlaced | 15 bit (effective) | As above, but two alternating frames to give the appearence of blended colours and provide an extra 'fake' bit per channel
44 | | [Amiga AGA](https://en.wikipedia.org/wiki/Amiga_Advanced_Graphics_Architecture) | 24 bit (8 per channel) | Two words: high/low nibbles A: `R7 R6 R5 R4 G7 G6 G5 G4 B7 B6 B5 B4` B: `R3 R2 R1 R0 G3 G2 G1 G0 B3 B2 B1 B0`
45 | | [Atari ST](https://en.wikipedia.org/wiki/Atari_ST) | 9 bit (3 per channel) | Single word: `__ R2 R1 R0 __ G2 G1 G0 __ B2 B1 B0`
46 | | [Atari STe](https://en.wikipedia.org/wiki/Atari_ST#STE_models)/[TT](https://en.wikipedia.org/wiki/Atari_TT030) | 12 bit (4 per channel) | Single word: LSB first `R0 R3 R2 R1 G0 G3 G2 G1 B0 B3 B2 B1` |
47 | | [Atari Falcon](https://en.wikipedia.org/wiki/Atari_Falcon) | 18 bit (6 per channel) | Single longword: 2 LSB per byte unused, 3rd byte blank `R5 R4 R3 R2 R1 R0 __ __ G5 G4 G3 G2 G1 G0 __ __` `__ __ __ __ __ __ __ __ B5 B4 B3 B2 B1 B0 __ __` |
48 | | [Atari Falcon](https://en.wikipedia.org/wiki/Atari_Falcon) true colour | 16 bit (5 red, 6 green, 5 blue) | Single word: `R4 R3 R2 R1 R0 G5 G4 G3 G2 G1 G0 B4 B3 B2 B1 B0` |
49 |
50 | ### Dither Modes:
51 |
52 | 1. **Off:** no dithering, just hard quantise to the desired bit depth.
53 | 2. **Shuffle:** Switches pairs of values at colour boundaries to lessen the appearance of banding.
54 | 3. **Error diffusion:** Applies [one-dimesionsal error diffusion](https://en.wikipedia.org/wiki/Error_diffusion#One-dimensional_error_diffusion) to values.
55 | 4. **Blue noise:** Adds [blue noise](https://en.wikipedia.org/wiki/Colors_of_noise#Blue_noise) to data to each channel before quantising.
56 | 5. **Blue noise mono:** As above, but applies the same noise values across all RGB channels, whereas normally we use a different starting offset for each channel. Less subtle but avoids colour variation in the dithering artifacts.
57 | 6. **Golden ratio:** Adds noise using an [algorithm based on the Golden ratio sequence](https://bartwronski.com/2016/10/30/dithering-part-two-golden-ratio-sequence-blue-noise-and-highpass-and-remap/). Similar to blue noise, this givens an even distribution of noise. Depending on the data either one of these may give better results.
58 | 7. **Golden ratio mono:** *See Blue noise mono*
59 | 8. **White noise:** Applies completely random noise. Generally looks pretty bad, but useful for comparison with other noise algorithms or to create a deliberately noise appearance.
60 | 9. **White noise mono:** *See Blue noise mono*
61 | 10. **Ordered:** Applies +/- offset to odd and even rows. This gives a consistent alternating pattern.
62 | 11. **Ordered mono:** Applies the same offset to all channels, whereas normally the green channel is flipped +/- for a smoother appearance.
63 |
64 | ### Dither amount:
65 |
66 | Multiplier for noise or adjustments applied by the current dithering algorithm.
67 |
68 | ### Shuffle count:
69 |
70 | Maximum numer of pairs to swap at each boundary when using the Shuffle dither mode.
71 |
72 | ---
73 |
74 | ## Editing
75 |
76 | The gradient is defined by a list of fixed points which have a colour value and position. These are then interpolated to provide the intermediate values.
77 |
78 | The left hand panel (2) shows the currently selected point and allows you to edit the colour and position. The center panel shows a preview of the gradient (4) and has markers for the points in the track down the left hand side (3).
79 |
80 | ### Adding a point:
81 | Click in the track (3) to add a new point at that position
82 |
83 | ### Selecting a point:
84 | - Click the marker in the track (3)
85 | - Navigate using the arrows at the top of the detail panel (2)
86 |
87 | ### Moving a point:
88 |
89 | To change to the position of a point you can either:
90 | - Drag the marker in the track (3)
91 | - Select the point and edit the 'Position' field (2)
92 |
93 | ### Removing a point:
94 |
95 | To remove a point for the gradient can either:
96 | - Select the point and click the 'Remove' button in the detail panel (2)
97 | - Drag the point outside of the track (3)
98 |
99 | ---
100 |
101 | ## Output
102 |
103 | The gradient can be exported in the following formats:
104 |
105 | ### Copper list:
106 |
107 | For Amiga targets, outputs data for the [Copper](https://en.wikipedia.org/wiki/Original_Chip_Set#Copper) to output the vertical gradient based on line wait commands.
108 |
109 | ### Table:
110 |
111 | Outputs the raw colour values for each step in the gradient. Supports code generation for several languages, as well as binary download in [Big Endian](https://en.wikipedia.org/wiki/Endianness) suitable for `INCBIN` into your code.
112 |
113 | ### PNG Image:
114 |
115 | Download a PNG of the gradient preview. This contains the vertical gradient as shown in the preview, but at native resolution and allows you to specify the width. This can then be shared with designers/graphicians.
116 |
117 | ---
118 |
119 |
120 | ## Links
121 |
122 | - [Leave a comment on Pouet](https://www.pouet.net/prod.php?which=92033)
123 | - [Discussion on English Amiga Board](https://eab.abime.net/showthread.php?p=1559925)
124 |
125 | ## References
126 |
127 | - https://bartwronski.com/2016/10/30/dithering-part-two-golden-ratio-sequence-blue-noise-and-highpass-and-remap/
128 | - https://bottosson.github.io/posts/oklab/
129 | - https://stackoverflow.com/questions/22607043/color-gradient-algorithm
130 |
131 | ## Thanks to…
132 |
133 | - Soundy/Deadliners: for creating the original [Gradient Master](http://deadliners.net/gradientmaster/index.html) that provided inspiration for this tool
134 | - Evil/DHS: For suggestion and technical support implementing for Atari target modes
135 | - Pink/Abyss: for suggesting the interlace mode
--------------------------------------------------------------------------------
/__mocks__/fileMock.js:
--------------------------------------------------------------------------------
1 | module.exports = "test-file-stub";
2 |
--------------------------------------------------------------------------------
/docs/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/grahambates/gradient-blaster/23cd580a7878d1a0a881814a86e6273c93330112/docs/screenshot.png
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | Gradient Blaster
12 |
13 |
14 | You need to enable JavaScript to run this app.
15 |
16 |
17 |
18 |
22 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "gradient-blaster",
3 | "description": "Web-based tool to build gradient data for retro platforms using a visual editor",
4 | "version": "0.1.0",
5 | "author": "Graham Bates",
6 | "license": "MIT",
7 | "homepage": "https://github.com/grahambates/gradient-blaster#readme",
8 | "keywords": [
9 | "demoscene",
10 | "demotool",
11 | "amiga",
12 | "atarist",
13 | "atari",
14 | "atari-st",
15 | "atari-falcon",
16 | "atari-ste"
17 | ],
18 | "repository": {
19 | "type": "git",
20 | "url": "https://github.com/grahambates/gradient-blaster"
21 | },
22 | "type": "module",
23 | "dependencies": {
24 | "@reduxjs/toolkit": "^1.8.3",
25 | "qs": "^6.11.0",
26 | "react": "^18.2.0",
27 | "react-dom": "^18.2.0",
28 | "react-icons": "^4.4.0",
29 | "react-redux": "^8.0.2",
30 | "redux-undo": "^1.0.1"
31 | },
32 | "scripts": {
33 | "dev": "vite",
34 | "build": "vite build",
35 | "preview": "vite preview",
36 | "lint": "eslint src",
37 | "test": "jest",
38 | "tsc": "tsc",
39 | "analyze": "source-map-explorer build/assets/index.*.js",
40 | "prepare": "husky install"
41 | },
42 | "eslintConfig": {
43 | "extends": [
44 | "eslint:recommended",
45 | "react-app",
46 | "plugin:prettier/recommended"
47 | ],
48 | "overrides": [
49 | {
50 | "files": [
51 | "src/**/*.test.ts*"
52 | ],
53 | "plugins": [
54 | "jest"
55 | ],
56 | "extends": [
57 | "plugin:jest/recommended"
58 | ]
59 | }
60 | ]
61 | },
62 | "jest": {
63 | "transform": {
64 | "^.+\\.jsx?$": [
65 | "esbuild-jest",
66 | {
67 | "sourcemap": true,
68 | "loaders": {
69 | ".test.jsx": "jsx"
70 | }
71 | }
72 | ],
73 | "^.+\\.tsx?$": [
74 | "esbuild-jest",
75 | {
76 | "sourcemap": true,
77 | "loaders": {
78 | ".test.tsx": "tsx"
79 | }
80 | }
81 | ]
82 | },
83 | "moduleNameMapper": {
84 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js",
85 | "\\.(css|less)$": "/__mocks__/fileMock.js"
86 | },
87 | "testEnvironment": "jsdom",
88 | "setupFilesAfterEnv": [
89 | "/src/setupTests.js"
90 | ]
91 | },
92 | "devDependencies": {
93 | "@testing-library/jest-dom": "^5.16.4",
94 | "@testing-library/react": "^13.3.0",
95 | "@testing-library/user-event": "^13.5.0",
96 | "@types/qs": "^6.9.7",
97 | "@types/react": "^18.0.17",
98 | "@types/react-dom": "^18.0.6",
99 | "@vitejs/plugin-react": "^2.0.1",
100 | "canvas": "^2.11.2",
101 | "esbuild-jest": "^0.5.0",
102 | "eslint": "^8.22.0",
103 | "eslint-config-prettier": "^8.10.0",
104 | "eslint-config-react-app": "^7.0.1",
105 | "eslint-plugin-jest": "^26.8.7",
106 | "eslint-plugin-prettier": "^5.2.1",
107 | "husky": "^8.0.0",
108 | "jest": "^28.1.3",
109 | "jest-environment-jsdom": "^28.1.3",
110 | "prettier": "3.3.3",
111 | "source-map-explorer": "^2.5.2",
112 | "typescript": "^4.7.4",
113 | "vite": "^3.0.9"
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/grahambates/gradient-blaster/23cd580a7878d1a0a881814a86e6273c93330112/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/grahambates/gradient-blaster/23cd580a7878d1a0a881814a86e6273c93330112/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/grahambates/gradient-blaster/23cd580a7878d1a0a881814a86e6273c93330112/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Gradient Blaster",
3 | "name": "Gradient Blaster",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/components/App.css:
--------------------------------------------------------------------------------
1 | /* App */
2 |
3 | .App__top {
4 | background: rgb(43, 43, 43);
5 | color: white;
6 | padding: 0.75rem 1.5rem 1.25rem 1.5rem;
7 |
8 | display: flex;
9 | justify-content: space-between;
10 | align-items: flex-end;
11 | }
12 |
13 | .App__main {
14 | padding: 1.5rem;
15 | display:grid;
16 | grid-template-columns: 292px minmax(256px, 512px) minmax(430px, 800px);
17 | column-gap: 3rem;
18 | }
19 |
20 | .App__bottom {
21 | padding: 1.25rem 1.5rem;
22 | border-top: 1px solid #eee;
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/App.test.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render, screen } from "@testing-library/react";
3 | import App from "./App";
4 | import store from "../store";
5 | import { Provider } from "react-redux";
6 |
7 | test("renders learn react link", () => {
8 | render(
9 |
10 |
11 |
12 | );
13 | const stepsLabel = screen.getByText(/Steps:/i);
14 | expect(stepsLabel).toBeInTheDocument();
15 | });
16 |
--------------------------------------------------------------------------------
/src/components/App.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import "./App.css";
4 | import Gradient from "./Gradient";
5 | import Options from "./Options";
6 | import Detail from "./Detail";
7 | import Output from "./Output";
8 | import History from "./History";
9 | import Footer from "./Footer";
10 |
11 | function App() {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | );
37 | }
38 |
39 | export default App;
40 |
--------------------------------------------------------------------------------
/src/components/Button.css:
--------------------------------------------------------------------------------
1 | .Button {
2 | background: #eee;
3 | border: 1px solid #ccc;
4 | color: #333;
5 | font-size: 1rem;
6 | line-height: 1;
7 | height: 36px;
8 | box-sizing: border-box;
9 | display: inline-block;
10 | padding: 0.5rem 0.75rem;
11 | cursor: pointer;
12 | text-decoration: none;
13 | vertical-align: middle;
14 | border-radius: 4px;
15 | white-space: nowrap;
16 | }
17 |
18 | .Button:hover {
19 | background: #ddd;
20 | border-color: #bbb;
21 | }
22 |
23 | .Button > * {
24 | vertical-align: middle;
25 | }
26 |
27 | .Button--iconLeft svg {
28 | margin-right: 0.3rem;
29 | }
30 |
31 | .Button--iconRight svg {
32 | margin-left: 0.3rem;
33 | }
34 |
35 | .Button--dark {
36 | background: #444;
37 | color: white;
38 | border-color: #777;
39 | }
40 |
41 | .Button--dark:hover {
42 | background: #555;
43 | border-color: #bbb;
44 | }
45 |
46 | .Button--minimal {
47 | height: auto;
48 | background: transparent;
49 | border: none;
50 | padding: 0;
51 | color: currentColor;
52 | font-size: 0.9em;
53 | }
54 |
55 | .Button--minimal:hover {
56 | background: transparent;
57 | border-color: none;
58 | opacity: 0.8;
59 | }
60 |
61 | .Button:disabled {
62 | opacity: 0.25;
63 | cursor: default;
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import "./Button.css";
3 |
4 | export type ButtonProps = {
5 | dark?: boolean;
6 | iconLeft?: React.ReactNode;
7 | iconRight?: React.ReactNode;
8 | minimal?: boolean;
9 | href?: string;
10 | } & (
11 | | React.ButtonHTMLAttributes
12 | | React.AnchorHTMLAttributes
13 | );
14 |
15 | function Button({
16 | dark,
17 | iconLeft,
18 | iconRight,
19 | minimal,
20 | children,
21 | href,
22 | ...rest
23 | }: ButtonProps) {
24 | const classes = ["Button"];
25 | if (dark) {
26 | classes.push("Button--dark");
27 | }
28 | if (iconLeft) {
29 | classes.push("Button--iconLeft");
30 | }
31 | if (iconRight) {
32 | classes.push("Button--iconRight");
33 | }
34 | if (minimal) {
35 | classes.push("Button--minimal");
36 | }
37 |
38 | if (href) {
39 | return (
40 | )}
44 | >
45 | {iconLeft}
46 | {children}
47 | {iconRight}
48 |
49 | );
50 | }
51 | return (
52 | )}
55 | >
56 | {iconLeft}
57 | {children}
58 | {iconRight}
59 |
60 | );
61 | }
62 |
63 | export default Button;
64 |
--------------------------------------------------------------------------------
/src/components/Code.css:
--------------------------------------------------------------------------------
1 | .Code {
2 | color: rgb(248, 248, 242);
3 | background: rgb(43, 43, 43) none repeat scroll 0% 0%;
4 | font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
5 | text-align: left;
6 | white-space: pre;
7 | word-spacing: normal;
8 | word-break: normal;
9 | overflow-wrap: normal;
10 | line-height: 1.5;
11 | tab-size: 4;
12 | hyphens: none;
13 | padding: 1em;
14 | margin: 0.5em 0px;
15 | overflow: auto;
16 | border-radius: 0.3em;
17 |
18 | clear: both;
19 | height: 400px;
20 | font-size: 0.9em;
21 | transition: 2s;
22 | box-shadow: 0 0 4px 4px transparent;
23 | }
24 |
25 | .Code__hex {
26 | color: rgb(0, 224, 224);
27 | }
28 |
29 | .Code__comment,
30 | .Code__comment * {
31 | color: rgb(212, 208, 171);
32 | }
33 |
34 | .Code__directive {
35 | color: rgb(255, 160, 122);
36 | }
37 |
38 | .Code__keyword {
39 | color: rgb(255, 160, 122);
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/Code.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useEffect } from "react";
2 | import "./Code.css";
3 |
4 | export interface CodeProps {
5 | code: string;
6 | }
7 |
8 | const Code = ({ code }: CodeProps) => {
9 | const preRef = useRef(null);
10 | useEffect(() => {
11 | const processed = code
12 | .replace(/((\$|0x)[0-9a-f]+)/gi, "$1 ")
13 | .replace(/(\b[0-9]+\b)/gi, "$1 ")
14 | .replace(/((\/\/|;|Rem).+)/gi, "")
15 | .replace(/(.+:\n)/gi, "$1 ")
16 | .replace(/(dc.(w|l)|Data)/gi, "$1 ")
17 | .replace(
18 | /(unsigned|short|long)/gi,
19 | "$1 ",
20 | );
21 |
22 | preRef.current!.innerHTML = processed;
23 | }, [code]);
24 | return ;
25 | };
26 |
27 | export default Code;
28 |
--------------------------------------------------------------------------------
/src/components/Detail.css:
--------------------------------------------------------------------------------
1 | .Detail__info {
2 | display: flex;
3 | margin-bottom: 1rem;
4 | justify-content: space-between;
5 | }
6 |
7 | .Detail__position input {
8 | width: 3rem;
9 | }
10 |
11 | .Detail__header {
12 | color: white;
13 | padding: 12px 18px;
14 | border-radius: 10px 10px 0 0;
15 | display: flex;
16 | justify-content: space-between;
17 | }
18 |
19 | .Detail__header--light {
20 | color: inherit;
21 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
22 | }
23 |
24 | .Detail__body {
25 | background: #eee;
26 | padding: 18px;
27 | border-radius: 0 0 10px 10px;
28 | }
29 |
30 | .Detail__color * {
31 | vertical-align: middle;
32 | }
33 | .Detail__hexInput {
34 | width: 3rem;
35 | }
36 |
37 | .Detail__nav svg {
38 | vertical-align: text-top;
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/Detail.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect, useState } from "react";
2 | import { useSelector, useDispatch } from "react-redux";
3 | import { FaTrash, FaChevronLeft, FaChevronRight } from "react-icons/fa";
4 |
5 | import "./Detail.css";
6 | import {
7 | removePoint,
8 | setPos,
9 | setColor,
10 | selectSelectedIndex,
11 | selectPoints,
12 | previousPoint,
13 | nextPoint,
14 | } from "../store/points";
15 | import { selectOptions, selectDepth } from "../store/options";
16 | import { clamp, rgbCssProp } from "../lib/utils";
17 | import Picker from "./Picker";
18 | import Button from "./Button";
19 | import { decodeHex3, decodeHex6, encodeHex3, encodeHex6 } from "../lib/hex";
20 | import { quantize, reduceBits, restoreBits } from "../lib/bitDepth";
21 | import { hsvToRgb, luminance, rgbToHsv } from "../lib/colorSpace";
22 | import { HSV } from "../types";
23 |
24 | function Detail() {
25 | const dispatch = useDispatch();
26 | const { steps } = useSelector(selectOptions);
27 | const depth = useSelector(selectDepth);
28 | const points = useSelector(selectPoints);
29 | const selectedIndex = useSelector(selectSelectedIndex);
30 |
31 | const selectedPoint = points[selectedIndex];
32 |
33 | const handleMove = (newY: number) => {
34 | const maxY = steps - 1;
35 | const pos = clamp(newY, 0, maxY) / maxY;
36 | dispatch(setPos(pos));
37 | };
38 |
39 | const [hex, setHex] = useState("");
40 |
41 | useEffect(() => {
42 | const rgb = reduceBits(hsvToRgb(selectedPoint.color), depth);
43 | if (depth <= 4) {
44 | setHex(encodeHex3(rgb));
45 | } else {
46 | setHex(encodeHex6(rgb));
47 | }
48 | }, [selectedPoint.color, depth]);
49 |
50 | const rgb = hsvToRgb(selectedPoint.color);
51 | const color = rgbCssProp(quantize(rgb, depth));
52 | const light = luminance(rgb) > 128;
53 |
54 | const classes = ["Detail__header"];
55 | if (light) {
56 | classes.push("Detail__header--light");
57 | }
58 |
59 | const handleChangeColor = useCallback(
60 | (color: HSV) => dispatch(setColor(color)),
61 | [dispatch],
62 | );
63 |
64 | let hexPattern;
65 | if (depth === 3) {
66 | hexPattern = "[0-7]{3}";
67 | } else if (depth === 4) {
68 | hexPattern = "[0-9a-f]{3}";
69 | } else {
70 | hexPattern = "[0-9a-f]{6}";
71 | }
72 |
73 | return (
74 |
150 | );
151 | }
152 |
153 | export default Detail;
154 |
--------------------------------------------------------------------------------
/src/components/Footer.css:
--------------------------------------------------------------------------------
1 | .Footer {
2 | display: flex;
3 | }
4 | .Footer > * {
5 | margin-right: 2rem;
6 | }
7 |
8 | .Footer svg {
9 | vertical-align: middle;
10 | margin-right: 0.3rem;
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import "./Footer.css";
3 |
4 | import { FaGithub } from "react-icons/fa";
5 | import { GiTrumpet } from "react-icons/gi";
6 | import { MdHelp } from "react-icons/md";
7 |
8 | function Footer() {
9 | return (
10 |
11 |
12 | Gradient Blaster © 2022 Graham Bates
13 |
14 |
20 |
26 |
30 |
31 | );
32 | }
33 |
34 | export default Footer;
35 |
--------------------------------------------------------------------------------
/src/components/Gradient.css:
--------------------------------------------------------------------------------
1 | .Gradient {
2 | display: flex;
3 | }
4 |
5 | .Gradient__track {
6 | background-color: #eee;
7 | flex: 0 18px;
8 | min-width: 18px;
9 | position: relative;
10 | cursor: copy;
11 | border: 1px solid transparent;
12 | }
13 |
14 | .Gradient__canvas {
15 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
16 | width: calc(100% - 18px);
17 | }
18 |
19 | .Gradient__options {
20 | margin-top: 1rem;
21 | display: flex;
22 | justify-content: space-between;
23 | }
24 |
25 | .Gradient__zoom input[type="number"] {
26 | width: 4rem;
27 | }
28 |
29 | .Gradient__canvas.hidden {
30 | display: none;
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/Gradient.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState } from "react";
2 | import "./Gradient.css";
3 | import Point from "./Point";
4 | import { useDispatch, useSelector } from "react-redux";
5 | import {
6 | addPoint,
7 | removePoint,
8 | clonePoint,
9 | setPos,
10 | selectIndex,
11 | selectPoints,
12 | selectSelectedIndex,
13 | } from "../store/points";
14 | import { selectOptions, selectTarget } from "../store/options";
15 | import { selectGradient } from "../store";
16 | import { interlaceGradient } from "../lib/gradient";
17 | import { rgbToHsv } from "../lib/colorSpace";
18 | import { quantize } from "../lib/bitDepth";
19 | import { clamp, rgbCssProp } from "../lib/utils";
20 | import { Bits, RGB } from "../types";
21 |
22 | function Gradient() {
23 | const dispatch = useDispatch();
24 | const { steps } = useSelector(selectOptions);
25 | const points = useSelector(selectPoints);
26 | const selected = useSelector(selectSelectedIndex);
27 | const gradient = useSelector(selectGradient);
28 | const { depth, interlaced } = useSelector(selectTarget);
29 |
30 | const [scale, setScale] = useState(1);
31 | const [autoScale, setAutoScale] = useState(true);
32 | const [previewLace, setPreviewLace] = useState(false);
33 |
34 | useEffect(() => {
35 | if (autoScale) {
36 | setScale(Math.floor(512 / steps) || 1);
37 | }
38 | }, [steps, autoScale]);
39 |
40 | const height = steps * scale;
41 |
42 | const [isDragging, setIsDragging] = useState(false);
43 |
44 | const handleAdd = (y: number) => {
45 | const scaledY = Math.round(y / scale);
46 | const sample = gradient[scaledY];
47 |
48 | // Start new point in dragging state as mouse is currently down
49 | setIsDragging(true);
50 | // Cancel drag on mouse up
51 | const stopDrag = () => {
52 | setIsDragging(false);
53 | window.removeEventListener("mouseup", stopDrag);
54 | };
55 | window.addEventListener("mouseup", stopDrag);
56 |
57 | dispatch(
58 | addPoint({
59 | pos: y / (height - 1),
60 | color: rgbToHsv(sample),
61 | }),
62 | );
63 | };
64 |
65 | const handleMove = (newY: number) => {
66 | const maxY = steps * scale - scale;
67 | const pos = clamp(newY, 0, maxY) / maxY;
68 | dispatch(setPos(pos));
69 | };
70 |
71 | return (
72 | <>
73 |
74 |
{
78 | e.preventDefault();
79 | handleAdd(e.pageY - e.currentTarget.offsetTop);
80 | }}
81 | >
82 | {points.map((p, i) => (
83 |
dispatch(clonePoint())}
92 | onSelect={() => dispatch(selectIndex(i))}
93 | onRemove={() => dispatch(removePoint())}
94 | />
95 | ))}
96 |
97 | {interlaced && previewLace ? (
98 |
99 | ) : (
100 |
101 | )}
102 |
103 |
104 |
139 | >
140 | );
141 | }
142 |
143 | interface CanvasProps {
144 | gradient: RGB[];
145 | scale: number;
146 | depth: Bits;
147 | }
148 |
149 | function Canvas({ gradient, scale, depth }: CanvasProps) {
150 | const width = 512;
151 | const canvasRef = useRef(null);
152 | const steps = gradient.length;
153 |
154 | useEffect(() => {
155 | const ctx = canvasRef.current?.getContext("2d");
156 | if (ctx) {
157 | for (let i = 0; i < steps; i++) {
158 | let col = gradient[i];
159 | col = quantize(col, depth);
160 | ctx.fillStyle = rgbCssProp(col);
161 | ctx.fillRect(0, i * scale, width, scale);
162 | }
163 | }
164 | }, [steps, scale, depth, gradient]);
165 |
166 | return (
167 |
173 | );
174 | }
175 |
176 | function CanvasLaced({ gradient, scale, depth }: CanvasProps) {
177 | const width = 512;
178 | const canvasRefA = useRef(null);
179 | const canvasRefB = useRef(null);
180 | const steps = gradient.length;
181 |
182 | useEffect(() => {
183 | const canvasA = canvasRefA.current;
184 | const canvasB = canvasRefB.current;
185 | const ctxA = canvasA?.getContext("2d");
186 | const ctxB = canvasB?.getContext("2d");
187 |
188 | if (!canvasA || !canvasB || !ctxA || !ctxB) {
189 | return;
190 | }
191 |
192 | const [odd, even] = interlaceGradient(gradient, depth);
193 |
194 | for (let i = 0; i < steps; i++) {
195 | ctxA.fillStyle = rgbCssProp(odd[i]);
196 | ctxA.fillRect(0, i * scale, width, scale);
197 | ctxB.fillStyle = rgbCssProp(even[i]);
198 | ctxB.fillRect(0, i * scale, width, scale);
199 | }
200 |
201 | let mounted = true;
202 |
203 | const toggle = () => {
204 | canvasA.classList.toggle("hidden");
205 | canvasB.classList.toggle("hidden");
206 | if (mounted) {
207 | window.requestAnimationFrame(toggle);
208 | }
209 | };
210 |
211 | window.requestAnimationFrame(toggle);
212 |
213 | return () => {
214 | mounted = false;
215 | };
216 | }, [steps, scale, depth, gradient]);
217 |
218 | return (
219 | <>
220 |
226 |
232 | >
233 | );
234 | }
235 |
236 | export default Gradient;
237 |
--------------------------------------------------------------------------------
/src/components/History.css:
--------------------------------------------------------------------------------
1 | .History {
2 | display: flex;
3 | }
4 | .History .Button {
5 | margin-left: 0.25rem;
6 | }
7 | .History__group {
8 | margin-left: 1rem;
9 | display: flex;
10 | }
11 |
--------------------------------------------------------------------------------
/src/components/History.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { ActionCreators } from "redux-undo";
3 | import { useDispatch, useSelector } from "react-redux";
4 | import { FaUndo, FaRedo } from "react-icons/fa";
5 | import { MdCreate } from "react-icons/md";
6 | import "./History.css";
7 |
8 | import Button from "./Button";
9 | import { reset } from "../store/actions";
10 | import { RootState } from "../store";
11 |
12 | function History() {
13 | const dispatch = useDispatch();
14 | const { past, future } = useSelector((state: RootState) => state.data);
15 |
16 | // Undo/redo keyboard shortcuts
17 | useEffect(() => {
18 | const handleKeyDown = function (e: KeyboardEvent) {
19 | e.stopImmediatePropagation();
20 | if (e.key === "z" && (e.ctrlKey || e.metaKey) && e.shiftKey) {
21 | dispatch(ActionCreators.redo());
22 | } else if (e.key === "z" && (e.ctrlKey || e.metaKey)) {
23 | dispatch(ActionCreators.undo());
24 | }
25 | };
26 | window.addEventListener("keydown", handleKeyDown);
27 | return () => window.removeEventListener("keydown", handleKeyDown);
28 | }, [dispatch]);
29 |
30 | return (
31 |
32 |
} onClick={() => dispatch(reset())}>
33 | Reset
34 |
35 |
36 | }
39 | disabled={!past.length}
40 | onClick={() => dispatch(ActionCreators.undo())}
41 | >
42 | Undo
43 |
44 | }
47 | disabled={!future.length}
48 | onClick={() => dispatch(ActionCreators.redo())}
49 | >
50 | Redo
51 |
52 |
53 |
54 | );
55 | }
56 |
57 | export default History;
58 |
--------------------------------------------------------------------------------
/src/components/Options.css:
--------------------------------------------------------------------------------
1 | .Options {
2 | display: flex;
3 | }
4 | .Options > div {
5 | margin-right: 1.5rem;
6 | }
7 | .Options label {
8 | color: #ccc;
9 | display: block;
10 | margin-bottom: 3px;
11 | white-space: nowrap;
12 | }
13 | .Options input[type="number"] {
14 | width: 4rem;
15 | }
16 | .Options__ditherAmount {
17 | white-space: nowrap;
18 | }
19 | input.Options__ditherAmountRange {
20 | width: 200px;
21 | vertical-align: middle;
22 | }
23 | input.Options__ditherAmountInput {
24 | width: 3rem;
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/Options.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useSelector, useDispatch } from "react-redux";
3 | import "./Options.css";
4 |
5 | import {
6 | setSteps,
7 | setBlendMode,
8 | setDitherMode,
9 | setDitherAmount,
10 | setShuffleCount,
11 | setTarget,
12 | selectOptions,
13 | } from "../store/options";
14 | import targets, { TargetKey } from "../lib/targets";
15 |
16 | function Options() {
17 | const dispatch = useDispatch();
18 | const options = useSelector(selectOptions);
19 | const { steps, blendMode, ditherMode, ditherAmount, shuffleCount, target } =
20 | options;
21 |
22 | return (
23 |
24 |
25 | Steps:
26 | dispatch(setSteps(parseInt(e.target.value)))}
33 | />
34 |
35 |
36 |
37 | Blend mode:
38 | dispatch(setBlendMode(e.target.value))}
42 | >
43 | OKLAB
44 | LAB
45 | Gamma adjusted RGB
46 | Simple RGB
47 |
48 |
49 |
50 |
51 | Target:
52 | dispatch(setTarget(e.target.value))}
56 | >
57 | {Object.keys(targets).map((key) => (
58 |
59 | {targets[key as TargetKey].label}
60 |
61 | ))}
62 |
63 |
64 |
65 |
66 | Dither mode:
67 | dispatch(setDitherMode(e.target.value))}
71 | >
72 | Off
73 | Shuffle
74 | Error diffusion
75 | Blue noise
76 | Blue noise mono
77 | Golden ratio
78 | Golden ratio mono
79 | White noise
80 | White noise mono
81 | Ordered
82 | Ordered mono
83 |
84 |
85 |
86 | {!["off", "shuffle"].includes(ditherMode) && (
87 |
88 | Dither amount:
89 |
96 | dispatch(setDitherAmount(parseInt(e.target.value)))
97 | }
98 | />
99 |
107 | dispatch(setDitherAmount(parseInt(e.target.value)))
108 | }
109 | />{" "}
110 | %
111 |
112 | )}
113 |
114 | {ditherMode === "shuffle" && (
115 |
116 | Shuffle count:
117 |
125 | dispatch(setShuffleCount(parseInt(e.target.value)))
126 | }
127 | />
128 |
129 | )}
130 |
131 | );
132 | }
133 |
134 | export default Options;
135 |
--------------------------------------------------------------------------------
/src/components/Output.css:
--------------------------------------------------------------------------------
1 | .Output__format {
2 | margin-bottom: 0.5rem;
3 | }
4 |
5 | .Output__actions {
6 | margin-bottom: 0.5rem;
7 | }
8 |
9 | @media screen and (min-width: 1280px) {
10 | .Output__format {
11 | margin-top: 8px;
12 | float: left;
13 | }
14 |
15 | .Output__actions {
16 | float: right;
17 | }
18 | }
19 |
20 | .Output__actions > * + * {
21 | margin-left: 0.5rem;
22 | }
23 |
24 | .codeCopied .Output pre {
25 | box-shadow: 0 0 4px 4px rgb(0, 244, 244);
26 | transition: 0s;
27 | }
28 |
29 | .Output code {
30 | word-break: break-word !important;
31 | }
32 | .Output input[type="number"] {
33 | width: 4rem;
34 | }
35 | .Output input[type="text"] {
36 | width: 8rem;
37 | }
38 |
39 | .Output__formatOptions {
40 | display: flex;
41 | flex-wrap: wrap;
42 | max-width: 500px;
43 | }
44 |
45 | .Output__formatOptions > div {
46 | width: 50%;
47 | margin-bottom: 0.5rem;
48 | }
49 |
50 | #Output-startLine {
51 | width: 3rem;
52 | }
53 |
54 | .Output__labels > div {
55 | display: flex;
56 | justify-content: space-between;
57 | margin-bottom: 0.5rem;
58 | }
59 |
--------------------------------------------------------------------------------
/src/components/Output.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState } from "react";
2 | import { useSelector } from "react-redux";
3 | import { FaCopy, FaDownload } from "react-icons/fa";
4 |
5 | import "./Output.css";
6 | import { selectGradient } from "../store";
7 | import { selectOptions, selectTarget } from "../store/options";
8 | import * as output from "../lib/output";
9 | import { encodeUrlQuery } from "../lib/url";
10 | import { interlaceGradient } from "../lib/gradient";
11 | import Button from "./Button";
12 | import Code from "./Code";
13 | import { selectPoints } from "../store/points";
14 | import { RGB } from "../types";
15 | import { Target } from "../lib/targets";
16 | import { quantize } from "../lib/bitDepth";
17 | import { rgbCssProp } from "../lib/utils";
18 |
19 | const DEBOUNCE_DELAY = 100;
20 |
21 | const baseUrl = window.location.href.split("?")[0];
22 |
23 | function Output() {
24 | const [outputFormat, setOutputFormat] =
25 | useState("copperList");
26 | const options = useSelector(selectOptions);
27 | const points = useSelector(selectPoints);
28 | const gradient = useSelector(selectGradient);
29 | const target = useSelector(selectTarget);
30 |
31 | // Delay update to output for performance i.e. dont generate 1000s of times while dragging
32 | const [debouncedGradient, setDebouncedGradient] = useState(gradient);
33 | const [debouncedQuery, setDebouncedQuery] = useState(
34 | encodeUrlQuery({ points, options }),
35 | );
36 | const timeout = useRef();
37 |
38 | useEffect(() => {
39 | if (timeout.current) clearTimeout(timeout.current);
40 | timeout.current = setTimeout(() => {
41 | const query = encodeUrlQuery({ points, options });
42 |
43 | setDebouncedQuery(query);
44 | setDebouncedGradient(gradient);
45 | // Update URL path
46 | window.history.replaceState({}, "", window.location.pathname + query);
47 | }, DEBOUNCE_DELAY);
48 | }, [points, options, gradient]);
49 |
50 | useEffect(() => {
51 | if (!target.outputs.includes(outputFormat)) {
52 | setOutputFormat(target.outputs[0]);
53 | }
54 | }, [target, outputFormat]);
55 |
56 | return (
57 |
58 |
59 | Output: {" "}
60 | setOutputFormat(e.target.value as output.FormatKey)}
64 | >
65 | {target.outputs.map((key) => (
66 |
67 | {output.formats[key].label}
68 |
69 | ))}
70 |
71 |
72 | {outputFormat === "copperList" && (
73 |
79 | )}
80 | {outputFormat === "copperListC" && (
81 |
87 | )}
88 | {outputFormat === "tableAsm" && (
89 |
95 | )}
96 | {outputFormat === "tableC" && (
97 |
103 | )}
104 | {(outputFormat === "tableAmos" || outputFormat === "tableStos") && (
105 |
111 | )}
112 | {outputFormat === "tableBin" && (
113 |
114 | )}
115 | {outputFormat === "imagePng" && (
116 |
117 | )}
118 |
119 | );
120 | }
121 |
122 | interface TableProps {
123 | gradient: RGB[];
124 | query: string;
125 | target: Target;
126 | lang: string;
127 | }
128 |
129 | const Table = React.memo(({ gradient, query, target, lang }: TableProps) => {
130 | let commentPrefix: string;
131 | let fn: (gradient: RGB[], opts: output.TableOptions) => string;
132 | let ext: string;
133 | switch (lang) {
134 | case "c":
135 | commentPrefix = "// ";
136 | fn = output.formatTableC;
137 | ext = "c";
138 | break;
139 | case "amos":
140 | commentPrefix = "Rem ";
141 | fn = (gradient, opts) =>
142 | output.formatTableAsm(gradient, opts).replace(/\tdc.w/g, "Data");
143 | ext = "txt";
144 | break;
145 | default:
146 | case "asm":
147 | commentPrefix = "; ";
148 | fn = output.formatTableAsm;
149 | ext = "s";
150 | break;
151 | }
152 |
153 | const defaultLength = target.id === "atariFalcon" ? 4 : 8;
154 | const [rowSize, setRowSize] = useState(defaultLength);
155 | const [varName, setVarName] = useState("Gradient");
156 | const [varNameA, setVarNameA] = useState("GradientOdd");
157 | const [varNameB, setVarNameB] = useState("GradientEven");
158 |
159 | useEffect(() => {
160 | setRowSize(defaultLength);
161 | }, [defaultLength, setRowSize]);
162 |
163 | let code = commentPrefix + baseUrl + query + "\n";
164 | if (target.interlaced) {
165 | const [odd, even] = interlaceGradient(gradient, target.depth);
166 | code += fn(odd, {
167 | rowSize,
168 | varName: varNameA,
169 | target,
170 | });
171 | code += "\n";
172 | code += fn(even, {
173 | rowSize,
174 | varName: varNameB,
175 | target,
176 | });
177 | } else {
178 | code += fn(gradient, {
179 | rowSize,
180 | varName,
181 | target,
182 | });
183 | }
184 |
185 | return (
186 | <>
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 | Values per row:
196 | setRowSize(parseInt(e.currentTarget.value))}
203 | />
204 |
205 | {target.interlaced ? (
206 |
226 | ) : (
227 |
228 | Label:
229 | setVarName(e.target.value)}
234 | />
235 |
236 | )}
237 |
238 | >
239 | );
240 | });
241 |
242 | interface TableBinProps {
243 | gradient: RGB[];
244 | target: Target;
245 | }
246 |
247 | const TableBin = React.memo(({ gradient, target }: TableBinProps) => {
248 | if (target.interlaced) {
249 | const [odd, even] = interlaceGradient(gradient, target.depth);
250 | const oddBytes = output.gradientToBytes(odd, target);
251 | const evenBytes = output.gradientToBytes(even, target);
252 | return (
253 |
254 |
260 |
266 |
267 | );
268 | } else {
269 | const bytes = output.gradientToBytes(gradient, target);
270 | return (
271 |
272 |
277 |
278 | );
279 | }
280 | });
281 |
282 | interface CopperListProps {
283 | gradient: RGB[];
284 | query: string;
285 | target: Target;
286 | lang: "c" | "asm";
287 | }
288 |
289 | const CopperList = React.memo(
290 | ({ gradient, query, target, lang }: CopperListProps) => {
291 | const [startLine, setStartLine] = useState(0x2b);
292 | const [colorIndex, setColorIndex] = useState(0);
293 | const [varName, setVarName] = useState("Gradient");
294 | const [varNameA, setVarNameA] = useState("GradientOdd");
295 | const [varNameB, setVarNameB] = useState("GradientEven");
296 | const [waitStart, setWaitStart] = useState(true);
297 | const [endList, setEndList] = useState(true);
298 |
299 | const commentPrefix = lang === "c" ? "// " : "; ";
300 | let code = commentPrefix + baseUrl + query + "\n";
301 | if (target.interlaced) {
302 | const [odd, even] = interlaceGradient(gradient, target.depth);
303 | code += output.buildCopperList(odd, {
304 | varName: varNameA,
305 | colorIndex,
306 | startLine,
307 | waitStart,
308 | endList,
309 | target,
310 | lang,
311 | });
312 | code += "\n";
313 | code += output.buildCopperList(even, {
314 | varName: varNameB,
315 | colorIndex,
316 | startLine,
317 | waitStart,
318 | endList,
319 | target,
320 | lang,
321 | });
322 | } else {
323 | code += output.buildCopperList(gradient, {
324 | varName,
325 | colorIndex,
326 | startLine,
327 | waitStart,
328 | endList,
329 | target,
330 | lang,
331 | });
332 | }
333 |
334 | return (
335 | <>
336 |
337 |
338 |
339 |
340 |
341 |
342 |
415 | >
416 | );
417 | },
418 | );
419 |
420 | interface ImagePngProps {
421 | gradient: RGB[];
422 | target: Target;
423 | }
424 |
425 | function ImagePng({ gradient, target }: ImagePngProps) {
426 | const [repeat, setRepeat] = useState(gradient.length);
427 | const [orientation, setOrientation] = useState("v");
428 | const canvasRef = useRef(null);
429 | const [data, setData] = useState("");
430 | const vertical = orientation === "v";
431 |
432 | useEffect(() => {
433 | const ctx = canvasRef.current?.getContext("2d");
434 | if (!canvasRef.current || !ctx) {
435 | return;
436 | }
437 | for (let i = 0; i < gradient.length; i++) {
438 | let color = quantize(gradient[i], target.depth);
439 | ctx.fillStyle = rgbCssProp(color);
440 | if (vertical) {
441 | ctx.fillRect(0, i, repeat, i);
442 | } else {
443 | ctx.fillRect(i, 0, i, repeat);
444 | }
445 | }
446 | setData(canvasRef.current.toDataURL());
447 | }, [gradient, vertical, repeat, target]);
448 |
449 | return (
450 | <>
451 |
452 |
458 |
459 | Dir:
460 | setOrientation(e.target.value)}
464 | >
465 | Vertical
466 | Horizontal
467 |
468 |
469 |
470 |
471 | {orientation === "v" ? "Width: " : "Height: "}
472 |
473 | setRepeat(parseInt(e.target.value))}
480 | />
481 |
482 | } href={data} download="gradient.png">
483 | Download
484 |
485 |
486 | >
487 | );
488 | }
489 |
490 | interface CopyLinkProps {
491 | code: string;
492 | }
493 |
494 | function CopyLink({ code }: CopyLinkProps) {
495 | return (
496 | }
498 | onClick={() => {
499 | navigator.clipboard.writeText(code);
500 | document.body.classList.add("codeCopied");
501 | setTimeout(() => {
502 | document.body.classList.remove("codeCopied");
503 | }, 1);
504 | }}
505 | >
506 | Copy to clipboard
507 |
508 | );
509 | }
510 |
511 | interface DownloadLinkProps {
512 | data: string;
513 | filename: string;
514 | mimetype?: string;
515 | label?: string;
516 | }
517 |
518 | function DownloadLink({
519 | data,
520 | filename,
521 | mimetype = "text/plain;charset=utf-8",
522 | label = "Download",
523 | }: DownloadLinkProps) {
524 | const codeHref = `data:${mimetype},` + encodeURIComponent(data);
525 | return (
526 | } href={codeHref} download={filename}>
527 | {label}
528 |
529 | );
530 | }
531 |
532 | export default Output;
533 |
--------------------------------------------------------------------------------
/src/components/Picker.css:
--------------------------------------------------------------------------------
1 | .Picker {
2 | width: 256px;
3 | }
4 |
5 | .Picker__swatches {
6 | margin-top: 1rem;
7 | }
8 | .Picker__swatchesRow {
9 | display: flex;
10 | justify-content: space-between;
11 | margin: 4px 0;
12 | }
13 | .Picker__swatch {
14 | width: 18px;
15 | height: 18px;
16 | cursor: pointer;
17 | border: 1px solid rgba(0, 0, 0, 0.2);
18 | }
19 |
20 | /* HueStrip */
21 |
22 | .HueStrip {
23 | position: relative;
24 | margin: 1.5rem 0;
25 | cursor: crosshair;
26 | }
27 |
28 | .HueStrip__selection {
29 | position: absolute;
30 | width: 0;
31 | top: 0;
32 | height: 15px;
33 | }
34 |
35 | .HueStrip__selection::before {
36 | content: "";
37 | width: 0;
38 | height: 0;
39 | top: -4px;
40 | margin-left: -6px;
41 | border-left: 6px solid transparent;
42 | border-right: 6px solid transparent;
43 | border-top: 6px solid rgba(0, 0, 0, 0.8);
44 | position: absolute;
45 | }
46 |
47 | .HueStrip__selection::after {
48 | content: "";
49 | width: 0;
50 | height: 0;
51 | bottom: -6px;
52 | margin-left: -6px;
53 | border-left: 6px solid transparent;
54 | border-right: 6px solid transparent;
55 | border-bottom: 6px solid rgba(0, 0, 0, 0.8);
56 | position: absolute;
57 | }
58 |
59 | /* PickerSquare */
60 |
61 | .PickerSquare {
62 | position: relative;
63 | cursor: crosshair;
64 | }
65 |
66 | .PickerSquare canvas {
67 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
68 | }
69 |
70 | .PickerSquare__selection {
71 | position: absolute;
72 | width: 10px;
73 | height: 10px;
74 | margin-top: -6px;
75 | margin-left: -6px;
76 | border: 1px solid white;
77 | border-radius: 6px;
78 | }
79 |
80 | .PickerSquare__selection.light {
81 | border-color: rgba(0, 0, 0, 0.8);
82 | }
83 |
--------------------------------------------------------------------------------
/src/components/Picker.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect, useRef } from "react";
2 | import "./Picker.css";
3 | import { decodeHex3 } from "../lib/hex";
4 | import { quantize, restoreBits } from "../lib/bitDepth";
5 | import { clamp, fToPercent, rgbCssProp } from "../lib/utils";
6 | import { hsvToRgb, luminance, rgbToHsv } from "../lib/colorSpace";
7 | import { Bits, HSV } from "../types";
8 |
9 | // Standard palette swatches, divided into rows
10 | const swatches = [
11 | [
12 | "444",
13 | "999",
14 | "fff",
15 | "f43",
16 | "f90",
17 | "fd0",
18 | "dd0",
19 | "ad0",
20 | "6cc",
21 | "7df",
22 | "aaf",
23 | "faf",
24 | ],
25 | [
26 | "333",
27 | "888",
28 | "ccc",
29 | "d31",
30 | "e70",
31 | "fc0",
32 | "bb0",
33 | "6b0",
34 | "1aa",
35 | "09e",
36 | "76f",
37 | "f2f",
38 | ],
39 | [
40 | "000",
41 | "666",
42 | "bbb",
43 | "900",
44 | "c50",
45 | "f90",
46 | "880",
47 | "143",
48 | "077",
49 | "06b",
50 | "639",
51 | "a19",
52 | ],
53 | ].map((row) => row.map(decodeHex3).map((c) => restoreBits(c, 4)));
54 |
55 | export interface PickerProps {
56 | hsv: HSV;
57 | depth: Bits;
58 | onChange: (c: HSV) => void;
59 | }
60 |
61 | const Picker = React.memo(({ hsv, depth, onChange }: PickerProps) => {
62 | return (
63 |
64 |
65 |
66 |
67 | {swatches.map((row, i) => (
68 |
69 | {row.map((rgb) => {
70 | return (
71 | onChange(rgbToHsv(rgb))}
76 | />
77 | );
78 | })}
79 |
80 | ))}
81 |
82 |
83 | );
84 | });
85 |
86 | const PickerSquare = ({ hsv, depth, onChange }: PickerProps) => {
87 | const width = 256;
88 | const height = 256;
89 | const canvasRef = useRef(null);
90 |
91 | const [h, s, v] = hsv;
92 |
93 | useEffect(() => {
94 | const ctx = canvasRef.current?.getContext("2d");
95 | if (!canvasRef.current || !ctx) {
96 | return;
97 | }
98 | const imageData = ctx.createImageData(width, height);
99 | let i = 0;
100 | for (let x = 0; x < width; x++) {
101 | for (let y = 0; y < height; y++) {
102 | const s1 = y / (height - 1);
103 | const v1 = 1 - x / (width - 1);
104 | let color = hsvToRgb([h, s1, v1]);
105 | color = quantize(color, depth);
106 | const [r, g, b] = color;
107 |
108 | imageData.data[i++] = r;
109 | imageData.data[i++] = g;
110 | imageData.data[i++] = b;
111 | imageData.data[i++] = 255;
112 | }
113 | }
114 |
115 | ctx.putImageData(imageData, 0, 0);
116 | }, [h, depth]);
117 |
118 | const handleMouseDown: React.MouseEventHandler =
119 | useCallback(
120 | (e) => {
121 | const maxX = width - 1;
122 | const maxY = height - 1;
123 |
124 | const dragMove = (e: MouseEvent) => {
125 | e.stopPropagation();
126 | const parent = canvasRef.current?.offsetParent as HTMLDivElement;
127 | const y = e.pageY - parent.offsetTop;
128 | const x = e.pageX - parent.offsetLeft;
129 | const s = clamp(x / maxX);
130 | const v = 1 - clamp(y / maxY);
131 | onChange([h, s, v]);
132 | };
133 |
134 | const dragStop = () => {
135 | document.removeEventListener("mousemove", dragMove);
136 | document.removeEventListener("mouseup", dragStop);
137 | };
138 |
139 | document.addEventListener("mousemove", dragMove);
140 | document.addEventListener("mouseup", dragStop);
141 |
142 | dragMove(e as any);
143 | },
144 | [h, onChange],
145 | );
146 |
147 | const classes = ["PickerSquare__selection"];
148 | if (luminance(hsvToRgb(hsv)) > 128) {
149 | classes.push("light");
150 | }
151 |
152 | return (
153 |
168 | );
169 | };
170 |
171 | interface HueStripProps {
172 | hsv: HSV;
173 | onChange: (c: HSV) => void;
174 | }
175 |
176 | const HueStrip = ({ hsv, onChange }: HueStripProps) => {
177 | const width = 256;
178 | const height = 14;
179 | const canvasRef = useRef(null);
180 |
181 | const [h, s, v] = hsv;
182 |
183 | useEffect(() => {
184 | const ctx = canvasRef.current?.getContext("2d");
185 | if (!ctx) {
186 | return;
187 | }
188 | for (let x = 0; x < width; x++) {
189 | const hue = x / (width - 1);
190 | const rgb = hsvToRgb([hue, 1, 1]);
191 | ctx.fillStyle = rgbCssProp(rgb);
192 | ctx.fillRect(x, 0, 1, height);
193 | }
194 | }, []);
195 |
196 | const handleMouseDown: React.MouseEventHandler =
197 | useCallback(
198 | (e) => {
199 | const maxX = width - 1;
200 |
201 | const dragMove = (e: MouseEvent) => {
202 | e.stopPropagation();
203 | const parent = canvasRef.current?.offsetParent as HTMLDivElement;
204 | const x = e.pageX - parent.offsetLeft;
205 | const h1 = clamp(x / maxX);
206 | onChange([h1, s, v]);
207 | };
208 |
209 | const dragStop = () => {
210 | document.removeEventListener("mousemove", dragMove);
211 | document.removeEventListener("mouseup", dragStop);
212 | };
213 |
214 | document.addEventListener("mousemove", dragMove);
215 | document.addEventListener("mouseup", dragStop);
216 |
217 | dragMove(e as any);
218 | },
219 | [s, v, onChange],
220 | );
221 |
222 | return (
223 |
232 | );
233 | };
234 |
235 | export default Picker;
236 |
--------------------------------------------------------------------------------
/src/components/Point.css:
--------------------------------------------------------------------------------
1 | .Point {
2 | width: 12px;
3 | height: 12px;
4 | position: absolute;
5 | left: -1px;
6 | margin-top: -7px;
7 | background-color: currentColor;
8 | cursor: grab;
9 | border: 1px solid rgba(0, 0, 0, 0.5);
10 | }
11 |
12 | .Point::after {
13 | content: "";
14 | width: 9px;
15 | height: 9px;
16 | position: absolute;
17 | top: 1px;
18 | right: -6px;
19 | transform: rotate(45deg);
20 | background: currentColor;
21 | border-top: 1px solid rgba(0, 0, 0, 0.5);
22 | border-right: 1px solid rgba(0, 0, 0, 0.5);
23 | }
24 |
25 | .Point.selected {
26 | z-index: 1;
27 | }
28 | .Point.removing {
29 | opacity: 0.25;
30 | }
31 |
32 | .Point.selected::before {
33 | content: "";
34 | background-color: white;
35 | width: 6px;
36 | height: 6px;
37 | position: absolute;
38 | top: 3px;
39 | left: 5px;
40 | border-radius: 3px;
41 | z-index: 1;
42 | }
43 |
44 | .Point.selected.light::before {
45 | background-color: black;
46 | }
47 |
48 | body.dragging * {
49 | cursor: grabbing;
50 | }
51 |
52 | body.removing * {
53 | cursor: no-drop !important;
54 | }
55 |
--------------------------------------------------------------------------------
/src/components/Point.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import { quantize } from "../lib/bitDepth";
3 | import { hsvToRgb, luminance } from "../lib/colorSpace";
4 | import { rgbCssProp } from "../lib/utils";
5 | import { Bits, HSV } from "../types";
6 | import "./Point.css";
7 |
8 | const REMOVE_THRESHOLD = 30;
9 |
10 | export interface PointProps {
11 | y: number;
12 | color: HSV;
13 | selected?: boolean;
14 | onMove: (y: number) => void;
15 | onClone: () => void;
16 | onSelect: () => void;
17 | onRemove: () => void;
18 | initialDrag?: boolean;
19 | depth: Bits;
20 | }
21 |
22 | function Point({
23 | y,
24 | color,
25 | selected,
26 | onMove,
27 | onClone,
28 | onSelect,
29 | onRemove,
30 | initialDrag,
31 | depth,
32 | }: PointProps) {
33 | const rgb = quantize(hsvToRgb(color), depth);
34 |
35 | const [isDragging, setIsDragging] = useState(initialDrag);
36 | const [isRemoving, setIsRemoving] = useState(false);
37 |
38 | const handleClick: React.MouseEventHandler = (e) => {
39 | e.stopPropagation();
40 | if (e.altKey) {
41 | onClone();
42 | }
43 | onSelect();
44 | setIsDragging(true);
45 | };
46 |
47 | const startDrag = () => {
48 | let offsetY: number;
49 | let offsetX: number;
50 | let isRemoving = false;
51 | document.body.classList.add("dragging");
52 |
53 | const dragMove = (e: MouseEvent) => {
54 | e.stopPropagation();
55 | if (offsetX === undefined) {
56 | offsetY = e.clientY - y;
57 | offsetX = e.clientX;
58 | }
59 |
60 | // Indicate that point will be removed on mouseUp if dragged far outside of track region
61 | isRemoving = Math.abs(e.clientX - offsetX) > REMOVE_THRESHOLD;
62 | setIsRemoving(isRemoving);
63 | if (isRemoving) {
64 | document.body.classList.add("removing");
65 | } else {
66 | document.body.classList.remove("removing");
67 | onMove(e.clientY - offsetY);
68 | }
69 | };
70 |
71 | const dragStop = () => {
72 | document.removeEventListener("mousemove", dragMove);
73 | document.removeEventListener("mouseup", dragStop);
74 | document.body.classList.remove("dragging");
75 | document.body.classList.remove("removing");
76 | if (isRemoving) {
77 | onRemove();
78 | }
79 | // Might not actually be removed if <= 2 points
80 | // need to reset state regardless
81 | setIsRemoving(false);
82 | setIsDragging(false);
83 | };
84 |
85 | document.addEventListener("mousemove", dragMove);
86 | document.addEventListener("mouseup", dragStop);
87 | };
88 |
89 | useEffect(() => {
90 | if (isDragging) startDrag();
91 | // eslint-disable-next-line react-hooks/exhaustive-deps
92 | }, [isDragging]);
93 |
94 | // Build class list:
95 | const classes = ["Point"];
96 | if (selected) {
97 | classes.push("selected");
98 | }
99 | if (isRemoving) {
100 | classes.push("removing");
101 | }
102 | if (luminance(rgb) > 128) {
103 | classes.push("light");
104 | }
105 |
106 | return (
107 |
115 | );
116 | }
117 |
118 | export default Point;
119 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
12 | monospace;
13 | }
14 |
15 | select,
16 | input[type="text"],
17 | input[type="number"] {
18 | background: white;
19 | border: 1px solid #ccc;
20 | padding: 0.2rem;
21 | }
22 |
23 | input:invalid {
24 | border-color: red;
25 | }
26 |
--------------------------------------------------------------------------------
/src/lib/bitDepth.test.ts:
--------------------------------------------------------------------------------
1 | import * as conv from "./bitDepth";
2 |
3 | describe("bitDepth", () => {
4 | describe("reduceBits()", () => {
5 | it("converts to 4 bit", () => {
6 | let result = conv.reduceBits([255, 255, 255], 4);
7 | expect(result).toEqual([15, 15, 15]);
8 |
9 | result = conv.reduceBits([128, 128, 128], 4);
10 | expect(result).toEqual([8, 8, 8]);
11 |
12 | result = conv.reduceBits([0, 0, 0], 4);
13 | expect(result).toEqual([0, 0, 0]);
14 |
15 | result = conv.reduceBits([255, 128, 0], 4);
16 | expect(result).toEqual([15, 8, 0]);
17 | });
18 |
19 | it("distributes values evenly within range", () => {
20 | const values = Array(16).fill(0);
21 | for (let i = 0; i < 256; i++) {
22 | let result = conv.reduceBits([i, i, i], 4);
23 | values[result[0]]++;
24 | }
25 | for (let group of values) {
26 | expect(group).toBe(16);
27 | }
28 | });
29 | });
30 |
31 | describe("restoreBits()", () => {
32 | it("converts 4 bit back to 8 bit", () => {
33 | let result = conv.restoreBits([15, 15, 15], 4);
34 | expect(result).toEqual([255, 255, 255]);
35 |
36 | result = conv.restoreBits([0, 0, 0], 4);
37 | expect(result).toEqual([0, 0, 0]);
38 | });
39 | });
40 |
41 | describe("quantize()", () => {
42 | it("rounds RGB values to 4 bit resolution", () => {
43 | let result = conv.quantize([254, 254, 254], 4);
44 | expect(result).toEqual([255, 255, 255]);
45 | });
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/src/lib/bitDepth.ts:
--------------------------------------------------------------------------------
1 | import { Bits, RGB } from "../types";
2 | import { clamp } from "./utils";
3 |
4 | export function reduceBits(rgb8: RGB, bits: Bits): RGB {
5 | const bitsArr = bitsToArray(bits);
6 | return rgb8.map((c, i) => {
7 | const x = 1 << bitsArr[i];
8 | const max = x - 1;
9 | const divisor = 256 / x;
10 | return clamp(Math.floor(c / divisor), 0, max);
11 | }) as RGB;
12 | }
13 |
14 | export function restoreBits(rgb: RGB, bits: Bits): RGB {
15 | const bitsArr = bitsToArray(bits);
16 | return rgb.map((c, i) => {
17 | const x = 1 << bitsArr[i];
18 | const max = x - 1;
19 | const multiplier = 256 / max;
20 | return clamp(c * multiplier, 0, 255);
21 | }) as RGB;
22 | }
23 |
24 | function bitsToArray(bits: Bits): [number, number, number] {
25 | return Array.isArray(bits) ? bits : [bits, bits, bits];
26 | }
27 |
28 | export function quantize(rgb8: RGB, bits: Bits): RGB {
29 | return restoreBits(reduceBits(rgb8, bits), bits);
30 | }
31 |
--------------------------------------------------------------------------------
/src/lib/colorSpace.test.ts:
--------------------------------------------------------------------------------
1 | import * as conv from "./colorSpace";
2 |
3 | describe("colorSpace", () => {
4 | // HSV:
5 |
6 | describe("rgbToHsv()", () => {
7 | it("converts RGB to HSV", () => {
8 | let result = conv.rgbToHsv([255, 127.5, 0]);
9 | expect(result).toEqual([0.08333333333333333, 1, 1]);
10 | });
11 | });
12 |
13 | describe("hsvToRgb()", () => {
14 | it("converts RGB to HSV", () => {
15 | let result = conv.hsvToRgb([0.08333333333333333, 1, 1]);
16 | expect(result).toEqual([255, 127.5, 0]);
17 | });
18 | });
19 |
20 | // LAB:
21 |
22 | describe("rgbToLab()", () => {
23 | it("converts RGB to LAB", () => {
24 | let result = conv.rgbToLab([255, 127, 0]);
25 | expect(result).toEqual([
26 | 66.853804382266, 43.32394349110946, 73.90977076096983,
27 | ]);
28 | });
29 | });
30 |
31 | describe("labToRgb()", () => {
32 | it("converts LAB to RGB", () => {
33 | let result = conv
34 | .labToRgb([66.853804382266, 43.32394349110946, 73.90977076096983])
35 | .map(Math.round);
36 | expect(result).toEqual([255, 127, 0]);
37 | });
38 | });
39 |
40 | // OKLAB
41 |
42 | describe("rgbToOklab()", () => {
43 | it("converts RGB to OKLAB", () => {
44 | let result = conv.rgbToOklab([255, 127, 0]);
45 | expect(result).toEqual([
46 | 58.09001263044044, 9.955384839640125, 11.763332051857013,
47 | ]);
48 | });
49 | });
50 |
51 | describe("oklabToRgb()", () => {
52 | it("converts LAB to RGB", () => {
53 | let result = conv
54 | .oklabToRgb([58.09001263044044, 9.955384839640125, 11.763332051857013])
55 | .map(Math.round)
56 | .map(Math.abs);
57 | expect(result).toEqual([255, 127, 0]);
58 | });
59 | });
60 |
61 | // Linear RGB:
62 |
63 | describe("rgbToLrgb()", () => {
64 | it("converts RGB to OKLAB", () => {
65 | let result = conv.rgbToLrgb([255, 127, 0]);
66 | expect(result).toEqual([524946.8293388885, 98576.71060759352, 0]);
67 | });
68 | });
69 |
70 | describe("lrgbToRgb()", () => {
71 | it("converts LAB to RGB", () => {
72 | let result = conv
73 | .lrgbToRgb([524946.8293388885, 98576.71060759352, 0])
74 | .map(Math.round);
75 | expect(result).toEqual([255, 127, 0]);
76 | });
77 | });
78 |
79 | // Linear sRGB components:
80 |
81 | describe("srgbToLinear()", () => {
82 | it("converts SRGB to linear", () => {
83 | let result = conv.srgbToLinear(127.5);
84 | expect(result).toEqual(0.21404114048223255);
85 | });
86 | });
87 |
88 | describe("linearToSrgb()", () => {
89 | it("converts linear to SRGB", () => {
90 | let result = conv.linearToSrgb(0.21404114048223255);
91 | expect(result).toEqual(127.5);
92 | });
93 | });
94 |
95 | // Luminance:
96 |
97 | describe("luminance()", () => {
98 | it("returns max value for white", () => {
99 | let result = conv.luminance([255, 255, 255]);
100 | expect(result).toBe(255);
101 | });
102 |
103 | it("returns min value for blue", () => {
104 | let result = conv.luminance([0, 0, 0]);
105 | expect(result).toBe(0);
106 | });
107 |
108 | it("considers red brighter than blue", () => {
109 | let red = conv.luminance([255, 0, 0]);
110 | let blue = conv.luminance([0, 0, 255]);
111 | expect(red).toBeGreaterThan(blue);
112 | });
113 | });
114 | });
115 |
--------------------------------------------------------------------------------
/src/lib/colorSpace.ts:
--------------------------------------------------------------------------------
1 | import { HSV, LAB, LRGB, OKLAB, RGB } from "../types";
2 |
3 | // HSV:
4 |
5 | export function rgbToHsv([r, g, b]: RGB): HSV {
6 | r /= 255;
7 | g /= 255;
8 | b /= 255;
9 |
10 | const max = Math.max(r, g, b);
11 | const min = Math.min(r, g, b);
12 | const v = max;
13 | const d = max - min;
14 | const s = max === 0 ? 0 : d / max;
15 | let h = 0;
16 |
17 | if (max === min) {
18 | h = 0; // achromatic
19 | } else {
20 | switch (max) {
21 | case r:
22 | h = (g - b) / d + (g < b ? 6 : 0);
23 | break;
24 | case g:
25 | h = (b - r) / d + 2;
26 | break;
27 | case b:
28 | h = (r - g) / d + 4;
29 | break;
30 | default:
31 | }
32 |
33 | h /= 6;
34 | }
35 |
36 | return [h, s, v];
37 | }
38 |
39 | export function hsvToRgb([h, s, v]: HSV): RGB {
40 | let r, g, b;
41 |
42 | const i = Math.floor(h * 6);
43 | const f = h * 6 - i;
44 | const p = v * (1 - s);
45 | const q = v * (1 - f * s);
46 | const t = v * (1 - (1 - f) * s);
47 |
48 | switch (i % 6) {
49 | case 0:
50 | r = v;
51 | g = t;
52 | b = p;
53 | break;
54 | case 1:
55 | r = q;
56 | g = v;
57 | b = p;
58 | break;
59 | case 2:
60 | r = p;
61 | g = v;
62 | b = t;
63 | break;
64 | case 3:
65 | r = p;
66 | g = q;
67 | b = v;
68 | break;
69 | case 4:
70 | r = t;
71 | g = p;
72 | b = v;
73 | break;
74 | default:
75 | case 5:
76 | r = v;
77 | g = p;
78 | b = q;
79 | break;
80 | }
81 |
82 | return [r * 255, g * 255, b * 255];
83 | }
84 |
85 | // LAB:
86 |
87 | export function rgbToLab(rgb: RGB): LAB {
88 | let r = rgb[0] / 255,
89 | g = rgb[1] / 255,
90 | b = rgb[2] / 255,
91 | x,
92 | y,
93 | z;
94 |
95 | r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92;
96 | g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92;
97 | b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92;
98 |
99 | x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047;
100 | y = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 1.0;
101 | z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883;
102 |
103 | x = x > 0.008856 ? Math.pow(x, 1 / 3) : 7.787 * x + 16 / 116;
104 | y = y > 0.008856 ? Math.pow(y, 1 / 3) : 7.787 * y + 16 / 116;
105 | z = z > 0.008856 ? Math.pow(z, 1 / 3) : 7.787 * z + 16 / 116;
106 |
107 | return [116 * y - 16, 500 * (x - y), 200 * (y - z)];
108 | }
109 |
110 | export function labToRgb(lab: LAB): RGB {
111 | let y = (lab[0] + 16) / 116,
112 | x = lab[1] / 500 + y,
113 | z = y - lab[2] / 200,
114 | r,
115 | g,
116 | b;
117 |
118 | x = 0.95047 * (x * x * x > 0.008856 ? x * x * x : (x - 16 / 116) / 7.787);
119 | y = 1.0 * (y * y * y > 0.008856 ? y * y * y : (y - 16 / 116) / 7.787);
120 | z = 1.08883 * (z * z * z > 0.008856 ? z * z * z : (z - 16 / 116) / 7.787);
121 |
122 | r = x * 3.2406 + y * -1.5372 + z * -0.4986;
123 | g = x * -0.9689 + y * 1.8758 + z * 0.0415;
124 | b = x * 0.0557 + y * -0.204 + z * 1.057;
125 |
126 | r = r > 0.0031308 ? 1.055 * Math.pow(r, 1 / 2.4) - 0.055 : 12.92 * r;
127 | g = g > 0.0031308 ? 1.055 * Math.pow(g, 1 / 2.4) - 0.055 : 12.92 * g;
128 | b = b > 0.0031308 ? 1.055 * Math.pow(b, 1 / 2.4) - 0.055 : 12.92 * b;
129 |
130 | return [
131 | Math.max(0, Math.min(1, r)) * 255,
132 | Math.max(0, Math.min(1, g)) * 255,
133 | Math.max(0, Math.min(1, b)) * 255,
134 | ];
135 | }
136 |
137 | // OKLAB:
138 |
139 | export function rgbToOklab(col: RGB): OKLAB {
140 | const [r, g, b] = rgbToLrgb(col);
141 |
142 | let L = Math.cbrt(
143 | 0.41222147079999993 * r + 0.5363325363 * g + 0.0514459929 * b,
144 | );
145 | let M = Math.cbrt(
146 | 0.2119034981999999 * r + 0.6806995450999999 * g + 0.1073969566 * b,
147 | );
148 | let S = Math.cbrt(
149 | 0.08830246189999998 * r + 0.2817188376 * g + 0.6299787005000002 * b,
150 | );
151 |
152 | return [
153 | 0.2104542553 * L + 0.793617785 * M - 0.0040720468 * S,
154 | 1.9779984951 * L - 2.428592205 * M + 0.4505937099 * S,
155 | 0.0259040371 * L + 0.7827717662 * M - 0.808675766 * S,
156 | ];
157 | }
158 |
159 | export function oklabToRgb([l, a, b]: OKLAB): RGB {
160 | let L = Math.pow(
161 | l * 0.9999999984505198 + 0.3963377921737679 * a + 0.2158037580607588 * b,
162 | 3,
163 | );
164 | let M = Math.pow(
165 | l * 1.000000008881761 - 0.105561342323656 * a - 0.0638541747717059 * b,
166 | 3,
167 | );
168 | let S = Math.pow(
169 | l * 1.000000054672411 - 0.0894841820949658 * a - 1.291485537864092 * b,
170 | 3,
171 | );
172 |
173 | return lrgbToRgb([
174 | +4.076741661347994 * L - 3.307711590408193 * M + 0.230969928729428 * S,
175 | -1.2684380040921763 * L + 2.6097574006633715 * M - 0.3413193963102197 * S,
176 | -0.004196086541837188 * L - 0.7034186144594493 * M + 1.7076147009309444 * S,
177 | ]);
178 | }
179 |
180 | // Linear RGB
181 |
182 | export const rgbToLrgb = (col: RGB): LRGB => {
183 | return col.map((c) => {
184 | const abs = Math.abs(c);
185 | if (abs < 0.04045) {
186 | return c / 12.92;
187 | }
188 | return (Math.sign(c) || 1) * Math.pow((abs + 0.055) / 1.055, 2.4);
189 | }) as LRGB;
190 | };
191 |
192 | export const lrgbToRgb = (col: LRGB): RGB => {
193 | return col.map((c) => {
194 | const abs = Math.abs(c);
195 | if (abs > 0.0031308) {
196 | return (Math.sign(c) || 1) * (1.055 * Math.pow(abs, 1 / 2.4) - 0.055);
197 | }
198 | return c * 12.92;
199 | }) as RGB;
200 | };
201 |
202 | // Linear sRGB components
203 |
204 | export function linearToSrgb(x: number): number {
205 | const y = x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055;
206 | return y * 255;
207 | }
208 |
209 | export function srgbToLinear(x: number): number {
210 | x /= 255.0;
211 | return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
212 | }
213 |
214 | // Luminance:
215 |
216 | export function luminance([r, g, b]: RGB): number {
217 | return 0.299 * r + 0.587 * g + 0.114 * b;
218 | }
219 |
--------------------------------------------------------------------------------
/src/lib/gradient.test.ts:
--------------------------------------------------------------------------------
1 | import { rgbToHsv } from "./colorSpace";
2 | import { buildGradient, interlaceGradient } from "./gradient";
3 |
4 | describe("gradient", () => {
5 | describe("buildGradient()", () => {
6 | const points = [
7 | { color: rgbToHsv([0, 0, 0]), pos: 0 },
8 | { color: rgbToHsv([255, 255, 255]), pos: 1 },
9 | ];
10 | const steps = 16;
11 |
12 | const result = buildGradient(points, {
13 | steps,
14 | blendMode: "oklab",
15 | ditherMode: "blueNoise",
16 | ditherAmount: 30,
17 | target: "amigaOcs",
18 | });
19 |
20 | it("contains the correct number of steps", () => {
21 | expect(result).toHaveLength(steps);
22 | });
23 |
24 | it("starts on the first point color", () => {
25 | expect(result[0]).toEqual([0, 0, 0]);
26 | });
27 |
28 | it("ends on the last point color", () => {
29 | expect(result[steps - 1]).toEqual([255, 255, 255]);
30 | });
31 | });
32 |
33 | describe("interlaceGradient()", () => {
34 | const points = [
35 | { color: rgbToHsv([0, 0, 0]), pos: 0 },
36 | { color: rgbToHsv([255, 255, 255]), pos: 1 },
37 | ];
38 | const steps = 16;
39 |
40 | const gradient = buildGradient(points, {
41 | steps,
42 | blendMode: "oklab",
43 | ditherMode: "blueNoise",
44 | ditherAmount: 30,
45 | target: "amigaOcs",
46 | });
47 |
48 | const result = interlaceGradient(gradient, 5);
49 |
50 | it("outputs two gradients", () => {
51 | expect(result).toHaveLength(2);
52 | });
53 |
54 | it("contains the correct number of steps in each", () => {
55 | expect(result[0]).toHaveLength(steps);
56 | expect(result[1]).toHaveLength(steps);
57 | });
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/src/lib/gradient.ts:
--------------------------------------------------------------------------------
1 | import { Bits, Color, Options, Point, RGB } from "../types";
2 | import { quantize } from "./bitDepth";
3 | import {
4 | hsvToRgb,
5 | labToRgb,
6 | linearToSrgb,
7 | oklabToRgb,
8 | rgbToLab,
9 | rgbToOklab,
10 | srgbToLinear,
11 | } from "./colorSpace";
12 | import targets from "./targets";
13 | import { normalizeRgb, sameColors } from "./utils";
14 |
15 | const GOLDEN_RATIO = 1.61803399;
16 |
17 | export function buildGradient(points: Point[], options: Options): RGB[] {
18 | const { steps, blendMode, ditherMode, target } = options;
19 | const depth = targets[target].depth;
20 | const mappedPoints = [...points].map((p) => {
21 | let color = hsvToRgb(p.color);
22 | color = quantize(color, depth);
23 | const y = Math.round(p.pos * (steps - 1));
24 | return { y, color };
25 | });
26 |
27 | let values: RGB[] = [];
28 | let pointIndex = 0;
29 |
30 | for (let i = 0; i < steps; i++) {
31 | const current = mappedPoints[pointIndex];
32 | let next = mappedPoints[pointIndex + 1];
33 |
34 | const firstPoint = current.y >= i;
35 | const lastPoint = !next;
36 |
37 | if (firstPoint || lastPoint) {
38 | // Use exact color value
39 | values.push(current.color);
40 | } else if (next.y === i) {
41 | const col = next.color;
42 | // Reached next point
43 | while (next && next.y === i) {
44 | pointIndex++;
45 | next = mappedPoints[pointIndex + 1];
46 | }
47 | values.push(col);
48 | } else {
49 | // Mix intermediate step:
50 | const pos = (i - current.y) / (next.y - current.y);
51 | const from = current.color;
52 | const to = next.color;
53 | let mixed: RGB;
54 | switch (blendMode) {
55 | case "lab":
56 | mixed = labToRgb(lerpColor(rgbToLab(from), rgbToLab(to), pos));
57 | break;
58 | // https://bottosson.github.io/posts/oklab/#blending-colors
59 | case "oklab":
60 | mixed = oklabToRgb(lerpColor(rgbToOklab(from), rgbToOklab(to), pos));
61 | break;
62 | case "perceptual":
63 | mixed = perceptualMix(from, to, pos);
64 | break;
65 | default:
66 | mixed = lerpColor(from, to, pos);
67 | }
68 | values.push(mixed);
69 | }
70 | }
71 |
72 | if (ditherMode !== "off") {
73 | values = dither(values, options);
74 | }
75 |
76 | return values.map(normalizeRgb);
77 | }
78 |
79 | // https://stackoverflow.com/questions/22607043/color-gradient-algorithm
80 | function perceptualMix(color1: RGB, color2: RGB, pos: number): RGB {
81 | const from = color1.map(srgbToLinear);
82 | const to = color2.map(srgbToLinear);
83 | const mixed = [
84 | from[0] + (to[0] - from[0]) * pos,
85 | from[1] + (to[1] - from[1]) * pos,
86 | from[2] + (to[2] - from[2]) * pos,
87 | ];
88 |
89 | // Compute a measure of brightness of the two colors using empirically determined gamma
90 | const gamma = 0.43;
91 | const fromBrightness = Math.pow(from[0] + from[1] + from[2], gamma);
92 | const toBrightness = Math.pow(to[0] + to[1] + to[2], gamma);
93 |
94 | // Interpolate a new brightness value, and convert back to linear light
95 | const brightness = fromBrightness + (toBrightness - fromBrightness) * pos;
96 | const intensity = Math.pow(brightness, 1 / gamma);
97 |
98 | // Apply adjustment factor to each rgb value based
99 | const sum = mixed[0] + mixed[1] + mixed[2];
100 | if (sum > 0) {
101 | const factor = intensity / sum;
102 | mixed[0] *= factor;
103 | mixed[1] *= factor;
104 | mixed[2] *= factor;
105 | }
106 |
107 | return mixed.map(linearToSrgb) as RGB;
108 | }
109 |
110 | function dither(
111 | values: RGB[],
112 | { ditherMode, ditherAmount = 0, shuffleCount = 1, target }: Options,
113 | ) {
114 | if (ditherMode === "off") {
115 | return values;
116 | }
117 |
118 | const depth = targets[target].depth;
119 |
120 | let amount = ditherAmount / 100;
121 |
122 | if (ditherMode === "errorDiffusion") {
123 | const labValues = values.map(rgbToLab);
124 | for (let i = 0; i < labValues.length; i++) {
125 | const col = labValues[i];
126 | const quantised = rgbToLab(quantize(labToRgb(col), depth));
127 | const errL = col[0] - quantised[0];
128 | const errA = col[1] - quantised[1];
129 | const errB = col[2] - quantised[2];
130 | if (labValues[i + 1]) {
131 | labValues[i + 1][0] += errL * amount;
132 | labValues[i + 1][1] += errA * amount;
133 | labValues[i + 1][2] += errB * amount;
134 | }
135 | }
136 | return labValues.map(labToRgb);
137 | }
138 |
139 | const depthInt = Array.isArray(depth) ? depth[0] : depth;
140 |
141 | // Scale noise functions to color depth
142 | amount *= 4 / depthInt;
143 |
144 | const sameOutput = (a: RGB, b: RGB) =>
145 | sameColors(quantize(a, depth), quantize(b, depth));
146 |
147 | for (let i = 0; i < values.length; i++) {
148 | switch (ditherMode) {
149 | case "shuffle": {
150 | if (i > 0) {
151 | const prev = values[i - 1];
152 | const current = values[i];
153 | if (!sameOutput(prev, current)) {
154 | // First shuffle
155 | values[i - 1] = values[i];
156 | values[i] = prev;
157 | i++;
158 |
159 | // Additional shuffles
160 | for (let j = 0; j < shuffleCount - 1; j++) {
161 | let n = (j + 1) * 4;
162 | if (
163 | values[i + 1] &&
164 | sameOutput(current, values[i + 1]) &&
165 | values[i - n] &&
166 | sameOutput(prev, values[i - n])
167 | ) {
168 | values[i - n] = current;
169 | values[i + 1] = prev;
170 | i += 2;
171 | }
172 | }
173 | }
174 | }
175 | break;
176 | }
177 | case "ordered":
178 | values[i][0] += (i % 2 ? 4 : -4) * amount;
179 | values[i][1] += (i % 2 ? -4 : 4) * amount;
180 | values[i][2] += (i % 2 ? 4 : -4) * amount;
181 | break;
182 | case "orderedMono": {
183 | const offset = (i % 2 ? 4 : -4) * amount;
184 | values[i][0] += offset;
185 | values[i][1] += offset;
186 | values[i][2] += offset;
187 | break;
188 | }
189 | case "blueNoise": {
190 | values[i][0] += blueNoise[i % 64] * 17 * amount;
191 | values[i][1] += blueNoise[(i + 16) % 64] * 17 * amount;
192 | values[i][2] += blueNoise[(i + 32) % 64] * 17 * amount;
193 | break;
194 | }
195 | case "blueNoiseMono": {
196 | const ofs = blueNoise[i % 64] * 17 * amount;
197 | values[i][0] += ofs;
198 | values[i][1] += ofs;
199 | values[i][2] += ofs;
200 | break;
201 | }
202 | case "whiteNoise":
203 | values[i][0] += (Math.random() * 17 - 8.5) * amount;
204 | values[i][1] += (Math.random() * 17 - 8.5) * amount;
205 | values[i][2] += (Math.random() * 17 - 8.5) * amount;
206 | break;
207 | case "whiteNoiseMono": {
208 | const ofs = (Math.random() - 0.5) * 17 * amount;
209 | values[i][0] += ofs;
210 | values[i][1] += ofs;
211 | values[i][2] += ofs;
212 | break;
213 | }
214 | // https://bartwronski.com/2016/10/30/dithering-part-two-golden-ratio-sequence-blue-noise-and-highpass-and-remap/comment-page-1/
215 | case "goldenRatio": {
216 | values[i][0] += (((i * GOLDEN_RATIO) % 1) - 0.5) * 17 * amount;
217 | values[i][1] += ((((i + 1) * GOLDEN_RATIO) % 1) - 0.5) * 17 * amount;
218 | values[i][2] += ((((i + 3) * GOLDEN_RATIO) % 1) - 0.5) * 17 * amount;
219 | break;
220 | }
221 | case "goldenRatioMono": {
222 | const ofs = (((i * GOLDEN_RATIO) % 1) - 0.5) * 17 * amount;
223 | values[i][0] += ofs;
224 | values[i][1] += ofs;
225 | values[i][2] += ofs;
226 | break;
227 | }
228 | default:
229 | }
230 | }
231 | return values;
232 | }
233 |
234 | function lerpColor(from: T, to: T, pos: number): T {
235 | return [
236 | Math.round(from[0] + (to[0] - from[0]) * pos),
237 | Math.round(from[1] + (to[1] - from[1]) * pos),
238 | Math.round(from[2] + (to[2] - from[2]) * pos),
239 | ] as T;
240 | }
241 |
242 | const blueNoise = [
243 | 18, 59, 10, 35, 49, 22, 6, 53, 27, 41, 13, 63, 20, 37, 1, 48, 25, 57, 9, 34,
244 | 44, 16, 51, 4, 31, 62, 19, 39, 11, 47, 23, 56, 0, 32, 45, 14, 60, 28, 7, 50,
245 | 38, 15, 29, 54, 2, 42, 24, 61, 12, 36, 21, 52, 5, 40, 26, 58, 8, 33, 46, 17,
246 | 55, 3, 30, 43,
247 | ].map((n) => n / 64 - 0.5);
248 |
249 | type GradientPair = [RGB[], RGB[]];
250 |
251 | export function interlaceGradient(gradient: RGB[], depth: Bits): GradientPair {
252 | const out: GradientPair = [[], []];
253 | const depthInt = Array.isArray(depth) ? depth[0] : depth;
254 |
255 | const x = 1 << depthInt;
256 | const divisor = 256 / x;
257 | const inc = divisor / 2;
258 |
259 | for (let col of gradient) {
260 | let odd = quantize(col, depthInt).map((c) => c - inc) as RGB;
261 | odd = quantize(odd, depthInt - 1);
262 | out[0].push(odd);
263 |
264 | let even = quantize(col, depthInt).map((c) => c + inc) as RGB;
265 | even = quantize(even, depthInt - 1);
266 | out[1].push(even);
267 | }
268 |
269 | return out;
270 | }
271 |
--------------------------------------------------------------------------------
/src/lib/hex.test.ts:
--------------------------------------------------------------------------------
1 | import * as hex from "./hex";
2 |
3 | describe("hex", () => {
4 | // Encode:
5 |
6 | describe("encodeHex3()", () => {
7 | it("encodes a 4 bit RGB value", () => {
8 | let result = hex.encodeHex3([0xf, 0xf, 0xf]);
9 | expect(result).toBe("fff");
10 |
11 | result = hex.encodeHex3([0x0, 0x0, 0x0]);
12 | expect(result).toBe("000");
13 |
14 | result = hex.encodeHex3([0xf, 0x8, 0x1]);
15 | expect(result).toBe("f81");
16 | });
17 | });
18 |
19 | describe("encodeHex6()", () => {
20 | it("encodes an 8 bit RGB value", () => {
21 | let result = hex.encodeHex6([0xff, 0xff, 0xff]);
22 | expect(result).toBe("ffffff");
23 |
24 | result = hex.encodeHex6([0x0, 0x0, 0x0]);
25 | expect(result).toBe("000000");
26 |
27 | result = hex.encodeHex6([0xf1, 0x82, 0x13]);
28 | expect(result).toBe("f18213");
29 | });
30 | });
31 |
32 | describe("encodeHexSte()", () => {
33 | it("encodes a 4 bit RGB value with LSB rotated", () => {
34 | let result = hex.encodeHexSte([0xf, 0xf, 0xf]);
35 | expect(result).toBe("fff");
36 |
37 | result = hex.encodeHexSte([0xe, 0xe, 0xe]);
38 | expect(result).toBe("777");
39 |
40 | result = hex.encodeHexSte([0x2, 0x2, 0x2]);
41 | expect(result).toBe("111");
42 |
43 | result = hex.encodeHexSte([0x1, 0x1, 0x1]);
44 | expect(result).toBe("888");
45 |
46 | result = hex.encodeHexSte([0x0, 0x0, 0x0]);
47 | expect(result).toBe("000");
48 |
49 | result = hex.encodeHexSte([0xf, 0xe, 0x1]);
50 | expect(result).toBe("f78");
51 | });
52 | });
53 |
54 | describe("encodeHexFalcon()", () => {
55 | it("encodes a 6 bit RGB value in upper bits of each byte", () => {
56 | let result = hex.encodeHexFalcon([0x3f, 0x3f, 0x3f]);
57 | expect(result).toBe("fcfc00fc");
58 |
59 | result = hex.encodeHexFalcon([0x1, 0x1, 0x1]);
60 | expect(result).toBe("04040004");
61 |
62 | result = hex.encodeHexFalcon([0x3f, 0x11, 0x1]);
63 | expect(result).toBe("fc440004");
64 | });
65 | });
66 |
67 | describe("encodeHexFalconTrue()", () => {
68 | it("encodes a 5/6/5 bit RGB value in a single word", () => {
69 | let result = hex.encodeHexFalconTrue([0x1f, 0x3f, 0x1f]);
70 | expect(result).toBe("ffff");
71 |
72 | result = hex.encodeHexFalconTrue([0x0, 0x0, 0x0]);
73 | expect(result).toBe("0000");
74 |
75 | result = hex.encodeHexFalconTrue([16, 8, 0]);
76 | expect(result).toBe("8100");
77 | });
78 | });
79 |
80 | describe("encodeHexPairAga()", () => {
81 | //
82 | it("encodes an 8 bit RGB value to a pair of words containing upper and lower nibbles", () => {
83 | let result = hex.encodeHexPairAga([0xf1, 0xe2, 0xd3]);
84 | expect(result).toEqual(["fed", "123"]);
85 | });
86 | });
87 |
88 | // Decode:
89 |
90 | describe("hexToRgb()", () => {
91 | it("decodes an 8bit hex value", () => {
92 | let result = hex.hexToRgb("ffeedd");
93 | expect(result).toEqual([255, 238, 221]);
94 | });
95 |
96 | it("decodes an 3bit hex value", () => {
97 | let result = hex.hexToRgb("777", 3);
98 | expect(result).toEqual([255, 255, 255]);
99 | });
100 |
101 | it("decodes an 4 bit hex value by default", () => {
102 | let result = hex.hexToRgb("fff");
103 | expect(result).toEqual([255, 255, 255]);
104 | });
105 | });
106 |
107 | describe("decodeHex3()", () => {
108 | it("decodes a 4 bit RGB value", () => {
109 | let result = hex.decodeHex3("fff");
110 | expect(result).toEqual([0xf, 0xf, 0xf]);
111 |
112 | result = hex.decodeHex3("000");
113 | expect(result).toEqual([0x0, 0x0, 0x0]);
114 |
115 | result = hex.decodeHex3("f81");
116 | expect(result).toEqual([0xf, 0x8, 0x1]);
117 | });
118 | });
119 |
120 | describe("decodeHex6()", () => {
121 | it("decodes an 8 bit RGB value", () => {
122 | let result = hex.decodeHex6("ffffff");
123 | expect(result).toEqual([0xff, 0xff, 0xff]);
124 |
125 | result = hex.decodeHex6("000000");
126 | expect(result).toEqual([0x0, 0x0, 0x0]);
127 |
128 | result = hex.decodeHex6("f18213");
129 | expect(result).toEqual([0xf1, 0x82, 0x13]);
130 | });
131 | });
132 | });
133 |
--------------------------------------------------------------------------------
/src/lib/hex.ts:
--------------------------------------------------------------------------------
1 | import { Bits, RGB } from "../types";
2 | import { restoreBits } from "./bitDepth";
3 | import { normalizeRgb } from "./utils";
4 |
5 | // Encode
6 |
7 | export function encodeHex3(rgb: RGB): string {
8 | return rgb.map((v) => v.toString(16)).join("");
9 | }
10 |
11 | export function encodeHex6(rgb: RGB): string {
12 | return normalizeRgb(rgb)
13 | .map((v) => v.toString(16).padStart(2, "0"))
14 | .join("");
15 | }
16 |
17 | /**
18 | * Hex value for Atari STe/TT
19 | * LSB is moved to MSB for each nibble
20 | */
21 | export function encodeHexSte(rgb: RGB): string {
22 | return rgb.map((n) => ((n >> 1) | ((n & 1) << 3)).toString(16)).join("");
23 | }
24 |
25 | export function encodeHexFalcon(rgb: RGB): string {
26 | const [r, g, b] = normalizeRgb(rgb).map((v) =>
27 | (v << 2).toString(16).padStart(2, "0"),
28 | );
29 | return r + g + "00" + b;
30 | }
31 |
32 | export function encodeHexFalcon24(rgb: RGB): string {
33 | const [r, g, b] = normalizeRgb(rgb).map((v) =>
34 | v.toString(16).padStart(2, "0"),
35 | );
36 | return r + g + "00" + b;
37 | }
38 |
39 | export function encodeHexFalconTrue([r, g, b]: RGB): string {
40 | const word = (r << 11) | (g << 5) | b;
41 | return word.toString(16).padStart(4, "0");
42 | }
43 |
44 | export function encodeNeoGeo([r, g, b]: RGB): string {
45 | const luma = Math.floor(54.213 * r + 182.376 * g + 18.411 * b) & 1;
46 | r = Math.floor(r / 8);
47 | g = Math.floor(g / 8);
48 | b = Math.floor(b / 8);
49 | const word =
50 | ((luma ^ 1) << 15) |
51 | ((r & 1) << 14) |
52 | ((g & 1) << 13) |
53 | ((b & 1) << 12) |
54 | ((r & 0x1e) << 7) |
55 | ((g & 0x1e) << 3) |
56 | (b >> 1);
57 | return word.toString(16).padStart(4, "0");
58 | }
59 |
60 | /**
61 | * Pair of hex values for Amiga AGA registers
62 | * Separate word for upper and lower nibbles
63 | */
64 | export function encodeHexPairAga(rgb: RGB): [string, string] {
65 | const hex = encodeHex6(rgb);
66 | return [hex[0] + hex[2] + hex[4], hex[1] + hex[3] + hex[5]];
67 | }
68 |
69 | // Decode:
70 |
71 | export function hexToRgb(hex: string, depth: Bits = 4): RGB {
72 | return hex.length === 3
73 | ? restoreBits(decodeHex3(hex), depth)
74 | : decodeHex6(hex);
75 | }
76 |
77 | export function decodeHex3(hex: string): RGB {
78 | return hex.split("").map((v) => parseInt(v, 16)) as RGB;
79 | }
80 |
81 | export function decodeHex6(hex: string): RGB {
82 | return [hex.substring(0, 2), hex.substring(2, 4), hex.substring(4, 6)].map(
83 | (v) => parseInt(v, 16),
84 | ) as RGB;
85 | }
86 |
--------------------------------------------------------------------------------
/src/lib/output.test.ts:
--------------------------------------------------------------------------------
1 | import { RGB } from "../types";
2 | import { restoreBits } from "./bitDepth";
3 | import * as output from "./output";
4 | import targets from "./targets";
5 |
6 | const values: RGB[] = [...Array(255)].map((_, i) => [i, i, i]);
7 |
8 | describe("output", () => {
9 | describe("buildCopperList()", () => {
10 | it("formats for amigaOcs", () => {
11 | let result = output.buildCopperList(values, {
12 | varName: "Gradient",
13 | target: targets.amigaOcs,
14 | lang: "asm",
15 | });
16 | expect(result).toBe(`Gradient:
17 | dc.w $2b07,$fffe
18 | dc.w $180,$000
19 | dc.w $3b07,$fffe
20 | dc.w $180,$111
21 | dc.w $4b07,$fffe
22 | dc.w $180,$222
23 | dc.w $5b07,$fffe
24 | dc.w $180,$333
25 | dc.w $6b07,$fffe
26 | dc.w $180,$444
27 | dc.w $7b07,$fffe
28 | dc.w $180,$555
29 | dc.w $8b07,$fffe
30 | dc.w $180,$666
31 | dc.w $9b07,$fffe
32 | dc.w $180,$777
33 | dc.w $ab07,$fffe
34 | dc.w $180,$888
35 | dc.w $bb07,$fffe
36 | dc.w $180,$999
37 | dc.w $cb07,$fffe
38 | dc.w $180,$aaa
39 | dc.w $db07,$fffe
40 | dc.w $180,$bbb
41 | dc.w $eb07,$fffe
42 | dc.w $180,$ccc
43 | dc.w $fb07,$fffe
44 | dc.w $180,$ddd
45 | dc.w $ffdf,$fffe ; PAL fix
46 | dc.w $b07,$fffe
47 | dc.w $180,$eee
48 | dc.w $1b07,$fffe
49 | dc.w $180,$fff
50 | dc.w $ffff,$fffe ; End copper list`);
51 | });
52 |
53 | it("formats for amigaOcs in C lang", () => {
54 | let result = output.buildCopperList(values, {
55 | varName: "Gradient",
56 | target: targets.amigaOcs,
57 | lang: "c",
58 | });
59 | expect(result).toBe(`unsigned short Gradient[] = {
60 | 0x2b07,0xfffe,
61 | 0x180,0x000,
62 | 0x3b07,0xfffe,
63 | 0x180,0x111,
64 | 0x4b07,0xfffe,
65 | 0x180,0x222,
66 | 0x5b07,0xfffe,
67 | 0x180,0x333,
68 | 0x6b07,0xfffe,
69 | 0x180,0x444,
70 | 0x7b07,0xfffe,
71 | 0x180,0x555,
72 | 0x8b07,0xfffe,
73 | 0x180,0x666,
74 | 0x9b07,0xfffe,
75 | 0x180,0x777,
76 | 0xab07,0xfffe,
77 | 0x180,0x888,
78 | 0xbb07,0xfffe,
79 | 0x180,0x999,
80 | 0xcb07,0xfffe,
81 | 0x180,0xaaa,
82 | 0xdb07,0xfffe,
83 | 0x180,0xbbb,
84 | 0xeb07,0xfffe,
85 | 0x180,0xccc,
86 | 0xfb07,0xfffe,
87 | 0x180,0xddd,
88 | 0xffdf,0xfffe, // PAL fix
89 | 0xb07,0xfffe,
90 | 0x180,0xeee,
91 | 0x1b07,0xfffe,
92 | 0x180,0xfff,
93 | 0xffff,0xfffe // End copper list
94 | };`);
95 | });
96 |
97 | it("formats for amigaAga", () => {
98 | let result = output.buildCopperList(values, {
99 | varName: "Gradient",
100 | target: targets.amigaAga,
101 | lang: "asm",
102 | });
103 | // Output too long to include in full
104 | expect(result).toContain(`Gradient:
105 | dc.w $2b07,$fffe
106 | dc.w $180,$000
107 | dc.w $106,$200
108 | dc.w $180,$000
109 | dc.w $106,$000
110 | dc.w $2c07,$fffe
111 | dc.w $180,$000
112 | dc.w $106,$200
113 | dc.w $180,$111
114 | dc.w $106,$000
115 | dc.w $2d07,$fffe
116 | dc.w $180,$000
117 | dc.w $106,$200
118 | dc.w $180,$222
119 | dc.w $106,$000`);
120 | expect(result).toContain("dc.w $ffdf,$fffe ; PAL fix");
121 | expect(result).toContain("dc.w $ffff,$fffe ; End copper list");
122 | });
123 |
124 | it("formats for amigaAga in C lang", () => {
125 | let result = output.buildCopperList(values, {
126 | varName: "Gradient",
127 | target: targets.amigaAga,
128 | lang: "c",
129 | });
130 | // Output too long to include in full
131 | expect(result).toContain(`unsigned short Gradient[] = {
132 | 0x2b07,0xfffe,
133 | 0x180,0x000,
134 | 0x106,0x200,
135 | 0x180,0x000,
136 | 0x106,0x000,
137 | 0x2c07,0xfffe,
138 | 0x180,0x000,
139 | 0x106,0x200,
140 | 0x180,0x111,
141 | 0x106,0x000,
142 | 0x2d07,0xfffe,
143 | 0x180,0x000,
144 | 0x106,0x200,
145 | 0x180,0x222,
146 | 0x106,0x000`);
147 | expect(result).toContain("0xffdf,0xfffe, // PAL fix");
148 | expect(result).toContain("0xffff,0xfffe // End copper list");
149 | });
150 |
151 | it("allows setting color index", () => {
152 | let result = output.buildCopperList(values, {
153 | varName: "Gradient",
154 | target: targets.amigaOcs,
155 | colorIndex: 1,
156 | lang: "asm",
157 | });
158 | expect(result).toContain(`dc.w $182,$000`);
159 | });
160 | });
161 |
162 | describe("formatTableAsm()", () => {
163 | it("formats for amigaOcs", () => {
164 | let result = output.formatTableAsm(values, {
165 | rowSize: 8,
166 | varName: "Gradient",
167 | target: targets.amigaOcs,
168 | });
169 | expect(result).toBe(`Gradient:
170 | dc.w $000,$000,$000,$000,$000,$000,$000,$000
171 | dc.w $000,$000,$000,$000,$000,$000,$000,$000
172 | dc.w $111,$111,$111,$111,$111,$111,$111,$111
173 | dc.w $111,$111,$111,$111,$111,$111,$111,$111
174 | dc.w $222,$222,$222,$222,$222,$222,$222,$222
175 | dc.w $222,$222,$222,$222,$222,$222,$222,$222
176 | dc.w $333,$333,$333,$333,$333,$333,$333,$333
177 | dc.w $333,$333,$333,$333,$333,$333,$333,$333
178 | dc.w $444,$444,$444,$444,$444,$444,$444,$444
179 | dc.w $444,$444,$444,$444,$444,$444,$444,$444
180 | dc.w $555,$555,$555,$555,$555,$555,$555,$555
181 | dc.w $555,$555,$555,$555,$555,$555,$555,$555
182 | dc.w $666,$666,$666,$666,$666,$666,$666,$666
183 | dc.w $666,$666,$666,$666,$666,$666,$666,$666
184 | dc.w $777,$777,$777,$777,$777,$777,$777,$777
185 | dc.w $777,$777,$777,$777,$777,$777,$777,$777
186 | dc.w $888,$888,$888,$888,$888,$888,$888,$888
187 | dc.w $888,$888,$888,$888,$888,$888,$888,$888
188 | dc.w $999,$999,$999,$999,$999,$999,$999,$999
189 | dc.w $999,$999,$999,$999,$999,$999,$999,$999
190 | dc.w $aaa,$aaa,$aaa,$aaa,$aaa,$aaa,$aaa,$aaa
191 | dc.w $aaa,$aaa,$aaa,$aaa,$aaa,$aaa,$aaa,$aaa
192 | dc.w $bbb,$bbb,$bbb,$bbb,$bbb,$bbb,$bbb,$bbb
193 | dc.w $bbb,$bbb,$bbb,$bbb,$bbb,$bbb,$bbb,$bbb
194 | dc.w $ccc,$ccc,$ccc,$ccc,$ccc,$ccc,$ccc,$ccc
195 | dc.w $ccc,$ccc,$ccc,$ccc,$ccc,$ccc,$ccc,$ccc
196 | dc.w $ddd,$ddd,$ddd,$ddd,$ddd,$ddd,$ddd,$ddd
197 | dc.w $ddd,$ddd,$ddd,$ddd,$ddd,$ddd,$ddd,$ddd
198 | dc.w $eee,$eee,$eee,$eee,$eee,$eee,$eee,$eee
199 | dc.w $eee,$eee,$eee,$eee,$eee,$eee,$eee,$eee
200 | dc.w $fff,$fff,$fff,$fff,$fff,$fff,$fff,$fff
201 | dc.w $fff,$fff,$fff,$fff,$fff,$fff,$fff`);
202 | });
203 |
204 | it("formats for amigaAga", () => {
205 | let result = output.formatTableAsm(values, {
206 | rowSize: 8,
207 | varName: "Gradient",
208 | target: targets.amigaAga,
209 | });
210 | expect(result).toBe(`Gradient:
211 | dc.w $000,$000,$000,$111,$000,$222,$000,$333
212 | dc.w $000,$444,$000,$555,$000,$666,$000,$777
213 | dc.w $000,$888,$000,$999,$000,$aaa,$000,$bbb
214 | dc.w $000,$ccc,$000,$ddd,$000,$eee,$000,$fff
215 | dc.w $111,$000,$111,$111,$111,$222,$111,$333
216 | dc.w $111,$444,$111,$555,$111,$666,$111,$777
217 | dc.w $111,$888,$111,$999,$111,$aaa,$111,$bbb
218 | dc.w $111,$ccc,$111,$ddd,$111,$eee,$111,$fff
219 | dc.w $222,$000,$222,$111,$222,$222,$222,$333
220 | dc.w $222,$444,$222,$555,$222,$666,$222,$777
221 | dc.w $222,$888,$222,$999,$222,$aaa,$222,$bbb
222 | dc.w $222,$ccc,$222,$ddd,$222,$eee,$222,$fff
223 | dc.w $333,$000,$333,$111,$333,$222,$333,$333
224 | dc.w $333,$444,$333,$555,$333,$666,$333,$777
225 | dc.w $333,$888,$333,$999,$333,$aaa,$333,$bbb
226 | dc.w $333,$ccc,$333,$ddd,$333,$eee,$333,$fff
227 | dc.w $444,$000,$444,$111,$444,$222,$444,$333
228 | dc.w $444,$444,$444,$555,$444,$666,$444,$777
229 | dc.w $444,$888,$444,$999,$444,$aaa,$444,$bbb
230 | dc.w $444,$ccc,$444,$ddd,$444,$eee,$444,$fff
231 | dc.w $555,$000,$555,$111,$555,$222,$555,$333
232 | dc.w $555,$444,$555,$555,$555,$666,$555,$777
233 | dc.w $555,$888,$555,$999,$555,$aaa,$555,$bbb
234 | dc.w $555,$ccc,$555,$ddd,$555,$eee,$555,$fff
235 | dc.w $666,$000,$666,$111,$666,$222,$666,$333
236 | dc.w $666,$444,$666,$555,$666,$666,$666,$777
237 | dc.w $666,$888,$666,$999,$666,$aaa,$666,$bbb
238 | dc.w $666,$ccc,$666,$ddd,$666,$eee,$666,$fff
239 | dc.w $777,$000,$777,$111,$777,$222,$777,$333
240 | dc.w $777,$444,$777,$555,$777,$666,$777,$777
241 | dc.w $777,$888,$777,$999,$777,$aaa,$777,$bbb
242 | dc.w $777,$ccc,$777,$ddd,$777,$eee,$777,$fff
243 | dc.w $888,$000,$888,$111,$888,$222,$888,$333
244 | dc.w $888,$444,$888,$555,$888,$666,$888,$777
245 | dc.w $888,$888,$888,$999,$888,$aaa,$888,$bbb
246 | dc.w $888,$ccc,$888,$ddd,$888,$eee,$888,$fff
247 | dc.w $999,$000,$999,$111,$999,$222,$999,$333
248 | dc.w $999,$444,$999,$555,$999,$666,$999,$777
249 | dc.w $999,$888,$999,$999,$999,$aaa,$999,$bbb
250 | dc.w $999,$ccc,$999,$ddd,$999,$eee,$999,$fff
251 | dc.w $aaa,$000,$aaa,$111,$aaa,$222,$aaa,$333
252 | dc.w $aaa,$444,$aaa,$555,$aaa,$666,$aaa,$777
253 | dc.w $aaa,$888,$aaa,$999,$aaa,$aaa,$aaa,$bbb
254 | dc.w $aaa,$ccc,$aaa,$ddd,$aaa,$eee,$aaa,$fff
255 | dc.w $bbb,$000,$bbb,$111,$bbb,$222,$bbb,$333
256 | dc.w $bbb,$444,$bbb,$555,$bbb,$666,$bbb,$777
257 | dc.w $bbb,$888,$bbb,$999,$bbb,$aaa,$bbb,$bbb
258 | dc.w $bbb,$ccc,$bbb,$ddd,$bbb,$eee,$bbb,$fff
259 | dc.w $ccc,$000,$ccc,$111,$ccc,$222,$ccc,$333
260 | dc.w $ccc,$444,$ccc,$555,$ccc,$666,$ccc,$777
261 | dc.w $ccc,$888,$ccc,$999,$ccc,$aaa,$ccc,$bbb
262 | dc.w $ccc,$ccc,$ccc,$ddd,$ccc,$eee,$ccc,$fff
263 | dc.w $ddd,$000,$ddd,$111,$ddd,$222,$ddd,$333
264 | dc.w $ddd,$444,$ddd,$555,$ddd,$666,$ddd,$777
265 | dc.w $ddd,$888,$ddd,$999,$ddd,$aaa,$ddd,$bbb
266 | dc.w $ddd,$ccc,$ddd,$ddd,$ddd,$eee,$ddd,$fff
267 | dc.w $eee,$000,$eee,$111,$eee,$222,$eee,$333
268 | dc.w $eee,$444,$eee,$555,$eee,$666,$eee,$777
269 | dc.w $eee,$888,$eee,$999,$eee,$aaa,$eee,$bbb
270 | dc.w $eee,$ccc,$eee,$ddd,$eee,$eee,$eee,$fff
271 | dc.w $fff,$000,$fff,$111,$fff,$222,$fff,$333
272 | dc.w $fff,$444,$fff,$555,$fff,$666,$fff,$777
273 | dc.w $fff,$888,$fff,$999,$fff,$aaa,$fff,$bbb
274 | dc.w $fff,$ccc,$fff,$ddd,$fff,$eee`);
275 | });
276 |
277 | it("formats for atariSt", () => {
278 | let result = output.formatTableAsm(values, {
279 | rowSize: 8,
280 | varName: "Gradient",
281 | target: targets.atariSt,
282 | });
283 | expect(result).toBe(`Gradient:
284 | dc.w $000,$000,$000,$000,$000,$000,$000,$000
285 | dc.w $000,$000,$000,$000,$000,$000,$000,$000
286 | dc.w $000,$000,$000,$000,$000,$000,$000,$000
287 | dc.w $000,$000,$000,$000,$000,$000,$000,$000
288 | dc.w $111,$111,$111,$111,$111,$111,$111,$111
289 | dc.w $111,$111,$111,$111,$111,$111,$111,$111
290 | dc.w $111,$111,$111,$111,$111,$111,$111,$111
291 | dc.w $111,$111,$111,$111,$111,$111,$111,$111
292 | dc.w $222,$222,$222,$222,$222,$222,$222,$222
293 | dc.w $222,$222,$222,$222,$222,$222,$222,$222
294 | dc.w $222,$222,$222,$222,$222,$222,$222,$222
295 | dc.w $222,$222,$222,$222,$222,$222,$222,$222
296 | dc.w $333,$333,$333,$333,$333,$333,$333,$333
297 | dc.w $333,$333,$333,$333,$333,$333,$333,$333
298 | dc.w $333,$333,$333,$333,$333,$333,$333,$333
299 | dc.w $333,$333,$333,$333,$333,$333,$333,$333
300 | dc.w $444,$444,$444,$444,$444,$444,$444,$444
301 | dc.w $444,$444,$444,$444,$444,$444,$444,$444
302 | dc.w $444,$444,$444,$444,$444,$444,$444,$444
303 | dc.w $444,$444,$444,$444,$444,$444,$444,$444
304 | dc.w $555,$555,$555,$555,$555,$555,$555,$555
305 | dc.w $555,$555,$555,$555,$555,$555,$555,$555
306 | dc.w $555,$555,$555,$555,$555,$555,$555,$555
307 | dc.w $555,$555,$555,$555,$555,$555,$555,$555
308 | dc.w $666,$666,$666,$666,$666,$666,$666,$666
309 | dc.w $666,$666,$666,$666,$666,$666,$666,$666
310 | dc.w $666,$666,$666,$666,$666,$666,$666,$666
311 | dc.w $666,$666,$666,$666,$666,$666,$666,$666
312 | dc.w $777,$777,$777,$777,$777,$777,$777,$777
313 | dc.w $777,$777,$777,$777,$777,$777,$777,$777
314 | dc.w $777,$777,$777,$777,$777,$777,$777,$777
315 | dc.w $777,$777,$777,$777,$777,$777,$777`);
316 | });
317 |
318 | it("formats for atariSte", () => {
319 | let result = output.formatTableAsm(values, {
320 | rowSize: 8,
321 | varName: "Gradient",
322 | target: targets.atariSte,
323 | });
324 | expect(result).toBe(`Gradient:
325 | dc.w $000,$000,$000,$000,$000,$000,$000,$000
326 | dc.w $000,$000,$000,$000,$000,$000,$000,$000
327 | dc.w $888,$888,$888,$888,$888,$888,$888,$888
328 | dc.w $888,$888,$888,$888,$888,$888,$888,$888
329 | dc.w $111,$111,$111,$111,$111,$111,$111,$111
330 | dc.w $111,$111,$111,$111,$111,$111,$111,$111
331 | dc.w $999,$999,$999,$999,$999,$999,$999,$999
332 | dc.w $999,$999,$999,$999,$999,$999,$999,$999
333 | dc.w $222,$222,$222,$222,$222,$222,$222,$222
334 | dc.w $222,$222,$222,$222,$222,$222,$222,$222
335 | dc.w $aaa,$aaa,$aaa,$aaa,$aaa,$aaa,$aaa,$aaa
336 | dc.w $aaa,$aaa,$aaa,$aaa,$aaa,$aaa,$aaa,$aaa
337 | dc.w $333,$333,$333,$333,$333,$333,$333,$333
338 | dc.w $333,$333,$333,$333,$333,$333,$333,$333
339 | dc.w $bbb,$bbb,$bbb,$bbb,$bbb,$bbb,$bbb,$bbb
340 | dc.w $bbb,$bbb,$bbb,$bbb,$bbb,$bbb,$bbb,$bbb
341 | dc.w $444,$444,$444,$444,$444,$444,$444,$444
342 | dc.w $444,$444,$444,$444,$444,$444,$444,$444
343 | dc.w $ccc,$ccc,$ccc,$ccc,$ccc,$ccc,$ccc,$ccc
344 | dc.w $ccc,$ccc,$ccc,$ccc,$ccc,$ccc,$ccc,$ccc
345 | dc.w $555,$555,$555,$555,$555,$555,$555,$555
346 | dc.w $555,$555,$555,$555,$555,$555,$555,$555
347 | dc.w $ddd,$ddd,$ddd,$ddd,$ddd,$ddd,$ddd,$ddd
348 | dc.w $ddd,$ddd,$ddd,$ddd,$ddd,$ddd,$ddd,$ddd
349 | dc.w $666,$666,$666,$666,$666,$666,$666,$666
350 | dc.w $666,$666,$666,$666,$666,$666,$666,$666
351 | dc.w $eee,$eee,$eee,$eee,$eee,$eee,$eee,$eee
352 | dc.w $eee,$eee,$eee,$eee,$eee,$eee,$eee,$eee
353 | dc.w $777,$777,$777,$777,$777,$777,$777,$777
354 | dc.w $777,$777,$777,$777,$777,$777,$777,$777
355 | dc.w $fff,$fff,$fff,$fff,$fff,$fff,$fff,$fff
356 | dc.w $fff,$fff,$fff,$fff,$fff,$fff,$fff`);
357 | });
358 |
359 | it("formats for atariFalcon", () => {
360 | let result = output.formatTableAsm(values, {
361 | rowSize: 8,
362 | varName: "Gradient",
363 | target: targets.atariFalcon,
364 | });
365 | expect(result).toBe(`Gradient:
366 | dc.l $00000000,$00000000,$00000000,$00000000,$04040004,$04040004,$04040004,$04040004
367 | dc.l $08080008,$08080008,$08080008,$08080008,$0c0c000c,$0c0c000c,$0c0c000c,$0c0c000c
368 | dc.l $10100010,$10100010,$10100010,$10100010,$14140014,$14140014,$14140014,$14140014
369 | dc.l $18180018,$18180018,$18180018,$18180018,$1c1c001c,$1c1c001c,$1c1c001c,$1c1c001c
370 | dc.l $20200020,$20200020,$20200020,$20200020,$24240024,$24240024,$24240024,$24240024
371 | dc.l $28280028,$28280028,$28280028,$28280028,$2c2c002c,$2c2c002c,$2c2c002c,$2c2c002c
372 | dc.l $30300030,$30300030,$30300030,$30300030,$34340034,$34340034,$34340034,$34340034
373 | dc.l $38380038,$38380038,$38380038,$38380038,$3c3c003c,$3c3c003c,$3c3c003c,$3c3c003c
374 | dc.l $40400040,$40400040,$40400040,$40400040,$44440044,$44440044,$44440044,$44440044
375 | dc.l $48480048,$48480048,$48480048,$48480048,$4c4c004c,$4c4c004c,$4c4c004c,$4c4c004c
376 | dc.l $50500050,$50500050,$50500050,$50500050,$54540054,$54540054,$54540054,$54540054
377 | dc.l $58580058,$58580058,$58580058,$58580058,$5c5c005c,$5c5c005c,$5c5c005c,$5c5c005c
378 | dc.l $60600060,$60600060,$60600060,$60600060,$64640064,$64640064,$64640064,$64640064
379 | dc.l $68680068,$68680068,$68680068,$68680068,$6c6c006c,$6c6c006c,$6c6c006c,$6c6c006c
380 | dc.l $70700070,$70700070,$70700070,$70700070,$74740074,$74740074,$74740074,$74740074
381 | dc.l $78780078,$78780078,$78780078,$78780078,$7c7c007c,$7c7c007c,$7c7c007c,$7c7c007c
382 | dc.l $80800080,$80800080,$80800080,$80800080,$84840084,$84840084,$84840084,$84840084
383 | dc.l $88880088,$88880088,$88880088,$88880088,$8c8c008c,$8c8c008c,$8c8c008c,$8c8c008c
384 | dc.l $90900090,$90900090,$90900090,$90900090,$94940094,$94940094,$94940094,$94940094
385 | dc.l $98980098,$98980098,$98980098,$98980098,$9c9c009c,$9c9c009c,$9c9c009c,$9c9c009c
386 | dc.l $a0a000a0,$a0a000a0,$a0a000a0,$a0a000a0,$a4a400a4,$a4a400a4,$a4a400a4,$a4a400a4
387 | dc.l $a8a800a8,$a8a800a8,$a8a800a8,$a8a800a8,$acac00ac,$acac00ac,$acac00ac,$acac00ac
388 | dc.l $b0b000b0,$b0b000b0,$b0b000b0,$b0b000b0,$b4b400b4,$b4b400b4,$b4b400b4,$b4b400b4
389 | dc.l $b8b800b8,$b8b800b8,$b8b800b8,$b8b800b8,$bcbc00bc,$bcbc00bc,$bcbc00bc,$bcbc00bc
390 | dc.l $c0c000c0,$c0c000c0,$c0c000c0,$c0c000c0,$c4c400c4,$c4c400c4,$c4c400c4,$c4c400c4
391 | dc.l $c8c800c8,$c8c800c8,$c8c800c8,$c8c800c8,$cccc00cc,$cccc00cc,$cccc00cc,$cccc00cc
392 | dc.l $d0d000d0,$d0d000d0,$d0d000d0,$d0d000d0,$d4d400d4,$d4d400d4,$d4d400d4,$d4d400d4
393 | dc.l $d8d800d8,$d8d800d8,$d8d800d8,$d8d800d8,$dcdc00dc,$dcdc00dc,$dcdc00dc,$dcdc00dc
394 | dc.l $e0e000e0,$e0e000e0,$e0e000e0,$e0e000e0,$e4e400e4,$e4e400e4,$e4e400e4,$e4e400e4
395 | dc.l $e8e800e8,$e8e800e8,$e8e800e8,$e8e800e8,$ecec00ec,$ecec00ec,$ecec00ec,$ecec00ec
396 | dc.l $f0f000f0,$f0f000f0,$f0f000f0,$f0f000f0,$f4f400f4,$f4f400f4,$f4f400f4,$f4f400f4
397 | dc.l $f8f800f8,$f8f800f8,$f8f800f8,$f8f800f8,$fcfc00fc,$fcfc00fc,$fcfc00fc`);
398 | });
399 |
400 | it("formats for atariFalconTruecolor", () => {
401 | let result = output.formatTableAsm(values, {
402 | rowSize: 8,
403 | varName: "Gradient",
404 | target: targets.atariFalconTruecolor,
405 | });
406 | expect(result).toBe(`Gradient:
407 | dc.w $0000,$0000,$0000,$0000,$0020,$0020,$0020,$0020
408 | dc.w $0841,$0841,$0841,$0841,$0861,$0861,$0861,$0861
409 | dc.w $1082,$1082,$1082,$1082,$10a2,$10a2,$10a2,$10a2
410 | dc.w $18c3,$18c3,$18c3,$18c3,$18e3,$18e3,$18e3,$18e3
411 | dc.w $2104,$2104,$2104,$2104,$2124,$2124,$2124,$2124
412 | dc.w $2945,$2945,$2945,$2945,$2965,$2965,$2965,$2965
413 | dc.w $3186,$3186,$3186,$3186,$31a6,$31a6,$31a6,$31a6
414 | dc.w $39c7,$39c7,$39c7,$39c7,$39e7,$39e7,$39e7,$39e7
415 | dc.w $4208,$4208,$4208,$4208,$4228,$4228,$4228,$4228
416 | dc.w $4a49,$4a49,$4a49,$4a49,$4a69,$4a69,$4a69,$4a69
417 | dc.w $528a,$528a,$528a,$528a,$52aa,$52aa,$52aa,$52aa
418 | dc.w $5acb,$5acb,$5acb,$5acb,$5aeb,$5aeb,$5aeb,$5aeb
419 | dc.w $630c,$630c,$630c,$630c,$632c,$632c,$632c,$632c
420 | dc.w $6b4d,$6b4d,$6b4d,$6b4d,$6b6d,$6b6d,$6b6d,$6b6d
421 | dc.w $738e,$738e,$738e,$738e,$73ae,$73ae,$73ae,$73ae
422 | dc.w $7bcf,$7bcf,$7bcf,$7bcf,$7bef,$7bef,$7bef,$7bef
423 | dc.w $8410,$8410,$8410,$8410,$8430,$8430,$8430,$8430
424 | dc.w $8c51,$8c51,$8c51,$8c51,$8c71,$8c71,$8c71,$8c71
425 | dc.w $9492,$9492,$9492,$9492,$94b2,$94b2,$94b2,$94b2
426 | dc.w $9cd3,$9cd3,$9cd3,$9cd3,$9cf3,$9cf3,$9cf3,$9cf3
427 | dc.w $a514,$a514,$a514,$a514,$a534,$a534,$a534,$a534
428 | dc.w $ad55,$ad55,$ad55,$ad55,$ad75,$ad75,$ad75,$ad75
429 | dc.w $b596,$b596,$b596,$b596,$b5b6,$b5b6,$b5b6,$b5b6
430 | dc.w $bdd7,$bdd7,$bdd7,$bdd7,$bdf7,$bdf7,$bdf7,$bdf7
431 | dc.w $c618,$c618,$c618,$c618,$c638,$c638,$c638,$c638
432 | dc.w $ce59,$ce59,$ce59,$ce59,$ce79,$ce79,$ce79,$ce79
433 | dc.w $d69a,$d69a,$d69a,$d69a,$d6ba,$d6ba,$d6ba,$d6ba
434 | dc.w $dedb,$dedb,$dedb,$dedb,$defb,$defb,$defb,$defb
435 | dc.w $e71c,$e71c,$e71c,$e71c,$e73c,$e73c,$e73c,$e73c
436 | dc.w $ef5d,$ef5d,$ef5d,$ef5d,$ef7d,$ef7d,$ef7d,$ef7d
437 | dc.w $f79e,$f79e,$f79e,$f79e,$f7be,$f7be,$f7be,$f7be
438 | dc.w $ffdf,$ffdf,$ffdf,$ffdf,$ffff,$ffff,$ffff`);
439 | });
440 |
441 | it("allows setting label", () => {
442 | let result = output.formatTableAsm(values, {
443 | rowSize: 8,
444 | varName: "Foo",
445 | target: targets.amigaOcs,
446 | });
447 | expect(result).toMatch(/^Foo:/);
448 | });
449 |
450 | it("allows setting row size", () => {
451 | let result = output.formatTableAsm(values, {
452 | rowSize: 16,
453 | varName: "Gradient",
454 | target: targets.amigaOcs,
455 | });
456 | expect(result).toContain(
457 | "\tdc.w $000,$000,$000,$000,$000,$000,$000,$000,$000,$000,$000,$000,$000,$000,$000,$000\n",
458 | );
459 | });
460 | });
461 |
462 | describe("formatTableC()", () => {
463 | it("formats for amigaOcs", () => {
464 | let result = output.formatTableC(values, {
465 | rowSize: 8,
466 | varName: "Gradient",
467 | target: targets.amigaOcs,
468 | });
469 | expect(result).toBe(`unsigned short Gradient[255] = {
470 | 0x000,0x000,0x000,0x000,0x000,0x000,0x000,0x000,
471 | 0x000,0x000,0x000,0x000,0x000,0x000,0x000,0x000,
472 | 0x111,0x111,0x111,0x111,0x111,0x111,0x111,0x111,
473 | 0x111,0x111,0x111,0x111,0x111,0x111,0x111,0x111,
474 | 0x222,0x222,0x222,0x222,0x222,0x222,0x222,0x222,
475 | 0x222,0x222,0x222,0x222,0x222,0x222,0x222,0x222,
476 | 0x333,0x333,0x333,0x333,0x333,0x333,0x333,0x333,
477 | 0x333,0x333,0x333,0x333,0x333,0x333,0x333,0x333,
478 | 0x444,0x444,0x444,0x444,0x444,0x444,0x444,0x444,
479 | 0x444,0x444,0x444,0x444,0x444,0x444,0x444,0x444,
480 | 0x555,0x555,0x555,0x555,0x555,0x555,0x555,0x555,
481 | 0x555,0x555,0x555,0x555,0x555,0x555,0x555,0x555,
482 | 0x666,0x666,0x666,0x666,0x666,0x666,0x666,0x666,
483 | 0x666,0x666,0x666,0x666,0x666,0x666,0x666,0x666,
484 | 0x777,0x777,0x777,0x777,0x777,0x777,0x777,0x777,
485 | 0x777,0x777,0x777,0x777,0x777,0x777,0x777,0x777,
486 | 0x888,0x888,0x888,0x888,0x888,0x888,0x888,0x888,
487 | 0x888,0x888,0x888,0x888,0x888,0x888,0x888,0x888,
488 | 0x999,0x999,0x999,0x999,0x999,0x999,0x999,0x999,
489 | 0x999,0x999,0x999,0x999,0x999,0x999,0x999,0x999,
490 | 0xaaa,0xaaa,0xaaa,0xaaa,0xaaa,0xaaa,0xaaa,0xaaa,
491 | 0xaaa,0xaaa,0xaaa,0xaaa,0xaaa,0xaaa,0xaaa,0xaaa,
492 | 0xbbb,0xbbb,0xbbb,0xbbb,0xbbb,0xbbb,0xbbb,0xbbb,
493 | 0xbbb,0xbbb,0xbbb,0xbbb,0xbbb,0xbbb,0xbbb,0xbbb,
494 | 0xccc,0xccc,0xccc,0xccc,0xccc,0xccc,0xccc,0xccc,
495 | 0xccc,0xccc,0xccc,0xccc,0xccc,0xccc,0xccc,0xccc,
496 | 0xddd,0xddd,0xddd,0xddd,0xddd,0xddd,0xddd,0xddd,
497 | 0xddd,0xddd,0xddd,0xddd,0xddd,0xddd,0xddd,0xddd,
498 | 0xeee,0xeee,0xeee,0xeee,0xeee,0xeee,0xeee,0xeee,
499 | 0xeee,0xeee,0xeee,0xeee,0xeee,0xeee,0xeee,0xeee,
500 | 0xfff,0xfff,0xfff,0xfff,0xfff,0xfff,0xfff,0xfff,
501 | 0xfff,0xfff,0xfff,0xfff,0xfff,0xfff,0xfff
502 | };`);
503 | });
504 |
505 | it("formats for amigaAga", () => {
506 | let result = output.formatTableC(values, {
507 | rowSize: 8,
508 | varName: "Gradient",
509 | target: targets.amigaAga,
510 | });
511 | expect(result).toBe(`unsigned short Gradient[510] = {
512 | 0x000,0x000,0x000,0x111,0x000,0x222,0x000,0x333,
513 | 0x000,0x444,0x000,0x555,0x000,0x666,0x000,0x777,
514 | 0x000,0x888,0x000,0x999,0x000,0xaaa,0x000,0xbbb,
515 | 0x000,0xccc,0x000,0xddd,0x000,0xeee,0x000,0xfff,
516 | 0x111,0x000,0x111,0x111,0x111,0x222,0x111,0x333,
517 | 0x111,0x444,0x111,0x555,0x111,0x666,0x111,0x777,
518 | 0x111,0x888,0x111,0x999,0x111,0xaaa,0x111,0xbbb,
519 | 0x111,0xccc,0x111,0xddd,0x111,0xeee,0x111,0xfff,
520 | 0x222,0x000,0x222,0x111,0x222,0x222,0x222,0x333,
521 | 0x222,0x444,0x222,0x555,0x222,0x666,0x222,0x777,
522 | 0x222,0x888,0x222,0x999,0x222,0xaaa,0x222,0xbbb,
523 | 0x222,0xccc,0x222,0xddd,0x222,0xeee,0x222,0xfff,
524 | 0x333,0x000,0x333,0x111,0x333,0x222,0x333,0x333,
525 | 0x333,0x444,0x333,0x555,0x333,0x666,0x333,0x777,
526 | 0x333,0x888,0x333,0x999,0x333,0xaaa,0x333,0xbbb,
527 | 0x333,0xccc,0x333,0xddd,0x333,0xeee,0x333,0xfff,
528 | 0x444,0x000,0x444,0x111,0x444,0x222,0x444,0x333,
529 | 0x444,0x444,0x444,0x555,0x444,0x666,0x444,0x777,
530 | 0x444,0x888,0x444,0x999,0x444,0xaaa,0x444,0xbbb,
531 | 0x444,0xccc,0x444,0xddd,0x444,0xeee,0x444,0xfff,
532 | 0x555,0x000,0x555,0x111,0x555,0x222,0x555,0x333,
533 | 0x555,0x444,0x555,0x555,0x555,0x666,0x555,0x777,
534 | 0x555,0x888,0x555,0x999,0x555,0xaaa,0x555,0xbbb,
535 | 0x555,0xccc,0x555,0xddd,0x555,0xeee,0x555,0xfff,
536 | 0x666,0x000,0x666,0x111,0x666,0x222,0x666,0x333,
537 | 0x666,0x444,0x666,0x555,0x666,0x666,0x666,0x777,
538 | 0x666,0x888,0x666,0x999,0x666,0xaaa,0x666,0xbbb,
539 | 0x666,0xccc,0x666,0xddd,0x666,0xeee,0x666,0xfff,
540 | 0x777,0x000,0x777,0x111,0x777,0x222,0x777,0x333,
541 | 0x777,0x444,0x777,0x555,0x777,0x666,0x777,0x777,
542 | 0x777,0x888,0x777,0x999,0x777,0xaaa,0x777,0xbbb,
543 | 0x777,0xccc,0x777,0xddd,0x777,0xeee,0x777,0xfff,
544 | 0x888,0x000,0x888,0x111,0x888,0x222,0x888,0x333,
545 | 0x888,0x444,0x888,0x555,0x888,0x666,0x888,0x777,
546 | 0x888,0x888,0x888,0x999,0x888,0xaaa,0x888,0xbbb,
547 | 0x888,0xccc,0x888,0xddd,0x888,0xeee,0x888,0xfff,
548 | 0x999,0x000,0x999,0x111,0x999,0x222,0x999,0x333,
549 | 0x999,0x444,0x999,0x555,0x999,0x666,0x999,0x777,
550 | 0x999,0x888,0x999,0x999,0x999,0xaaa,0x999,0xbbb,
551 | 0x999,0xccc,0x999,0xddd,0x999,0xeee,0x999,0xfff,
552 | 0xaaa,0x000,0xaaa,0x111,0xaaa,0x222,0xaaa,0x333,
553 | 0xaaa,0x444,0xaaa,0x555,0xaaa,0x666,0xaaa,0x777,
554 | 0xaaa,0x888,0xaaa,0x999,0xaaa,0xaaa,0xaaa,0xbbb,
555 | 0xaaa,0xccc,0xaaa,0xddd,0xaaa,0xeee,0xaaa,0xfff,
556 | 0xbbb,0x000,0xbbb,0x111,0xbbb,0x222,0xbbb,0x333,
557 | 0xbbb,0x444,0xbbb,0x555,0xbbb,0x666,0xbbb,0x777,
558 | 0xbbb,0x888,0xbbb,0x999,0xbbb,0xaaa,0xbbb,0xbbb,
559 | 0xbbb,0xccc,0xbbb,0xddd,0xbbb,0xeee,0xbbb,0xfff,
560 | 0xccc,0x000,0xccc,0x111,0xccc,0x222,0xccc,0x333,
561 | 0xccc,0x444,0xccc,0x555,0xccc,0x666,0xccc,0x777,
562 | 0xccc,0x888,0xccc,0x999,0xccc,0xaaa,0xccc,0xbbb,
563 | 0xccc,0xccc,0xccc,0xddd,0xccc,0xeee,0xccc,0xfff,
564 | 0xddd,0x000,0xddd,0x111,0xddd,0x222,0xddd,0x333,
565 | 0xddd,0x444,0xddd,0x555,0xddd,0x666,0xddd,0x777,
566 | 0xddd,0x888,0xddd,0x999,0xddd,0xaaa,0xddd,0xbbb,
567 | 0xddd,0xccc,0xddd,0xddd,0xddd,0xeee,0xddd,0xfff,
568 | 0xeee,0x000,0xeee,0x111,0xeee,0x222,0xeee,0x333,
569 | 0xeee,0x444,0xeee,0x555,0xeee,0x666,0xeee,0x777,
570 | 0xeee,0x888,0xeee,0x999,0xeee,0xaaa,0xeee,0xbbb,
571 | 0xeee,0xccc,0xeee,0xddd,0xeee,0xeee,0xeee,0xfff,
572 | 0xfff,0x000,0xfff,0x111,0xfff,0x222,0xfff,0x333,
573 | 0xfff,0x444,0xfff,0x555,0xfff,0x666,0xfff,0x777,
574 | 0xfff,0x888,0xfff,0x999,0xfff,0xaaa,0xfff,0xbbb,
575 | 0xfff,0xccc,0xfff,0xddd,0xfff,0xeee
576 | };`);
577 | });
578 |
579 | it("formats for atariSt", () => {
580 | let result = output.formatTableC(values, {
581 | rowSize: 8,
582 | varName: "Gradient",
583 | target: targets.atariSt,
584 | });
585 | expect(result).toBe(`unsigned short Gradient[255] = {
586 | 0x000,0x000,0x000,0x000,0x000,0x000,0x000,0x000,
587 | 0x000,0x000,0x000,0x000,0x000,0x000,0x000,0x000,
588 | 0x000,0x000,0x000,0x000,0x000,0x000,0x000,0x000,
589 | 0x000,0x000,0x000,0x000,0x000,0x000,0x000,0x000,
590 | 0x111,0x111,0x111,0x111,0x111,0x111,0x111,0x111,
591 | 0x111,0x111,0x111,0x111,0x111,0x111,0x111,0x111,
592 | 0x111,0x111,0x111,0x111,0x111,0x111,0x111,0x111,
593 | 0x111,0x111,0x111,0x111,0x111,0x111,0x111,0x111,
594 | 0x222,0x222,0x222,0x222,0x222,0x222,0x222,0x222,
595 | 0x222,0x222,0x222,0x222,0x222,0x222,0x222,0x222,
596 | 0x222,0x222,0x222,0x222,0x222,0x222,0x222,0x222,
597 | 0x222,0x222,0x222,0x222,0x222,0x222,0x222,0x222,
598 | 0x333,0x333,0x333,0x333,0x333,0x333,0x333,0x333,
599 | 0x333,0x333,0x333,0x333,0x333,0x333,0x333,0x333,
600 | 0x333,0x333,0x333,0x333,0x333,0x333,0x333,0x333,
601 | 0x333,0x333,0x333,0x333,0x333,0x333,0x333,0x333,
602 | 0x444,0x444,0x444,0x444,0x444,0x444,0x444,0x444,
603 | 0x444,0x444,0x444,0x444,0x444,0x444,0x444,0x444,
604 | 0x444,0x444,0x444,0x444,0x444,0x444,0x444,0x444,
605 | 0x444,0x444,0x444,0x444,0x444,0x444,0x444,0x444,
606 | 0x555,0x555,0x555,0x555,0x555,0x555,0x555,0x555,
607 | 0x555,0x555,0x555,0x555,0x555,0x555,0x555,0x555,
608 | 0x555,0x555,0x555,0x555,0x555,0x555,0x555,0x555,
609 | 0x555,0x555,0x555,0x555,0x555,0x555,0x555,0x555,
610 | 0x666,0x666,0x666,0x666,0x666,0x666,0x666,0x666,
611 | 0x666,0x666,0x666,0x666,0x666,0x666,0x666,0x666,
612 | 0x666,0x666,0x666,0x666,0x666,0x666,0x666,0x666,
613 | 0x666,0x666,0x666,0x666,0x666,0x666,0x666,0x666,
614 | 0x777,0x777,0x777,0x777,0x777,0x777,0x777,0x777,
615 | 0x777,0x777,0x777,0x777,0x777,0x777,0x777,0x777,
616 | 0x777,0x777,0x777,0x777,0x777,0x777,0x777,0x777,
617 | 0x777,0x777,0x777,0x777,0x777,0x777,0x777
618 | };`);
619 | });
620 |
621 | it("formats for atariSte", () => {
622 | let result = output.formatTableC(values, {
623 | rowSize: 8,
624 | varName: "Gradient",
625 | target: targets.atariSte,
626 | });
627 | expect(result).toBe(`unsigned short Gradient[255] = {
628 | 0x000,0x000,0x000,0x000,0x000,0x000,0x000,0x000,
629 | 0x000,0x000,0x000,0x000,0x000,0x000,0x000,0x000,
630 | 0x888,0x888,0x888,0x888,0x888,0x888,0x888,0x888,
631 | 0x888,0x888,0x888,0x888,0x888,0x888,0x888,0x888,
632 | 0x111,0x111,0x111,0x111,0x111,0x111,0x111,0x111,
633 | 0x111,0x111,0x111,0x111,0x111,0x111,0x111,0x111,
634 | 0x999,0x999,0x999,0x999,0x999,0x999,0x999,0x999,
635 | 0x999,0x999,0x999,0x999,0x999,0x999,0x999,0x999,
636 | 0x222,0x222,0x222,0x222,0x222,0x222,0x222,0x222,
637 | 0x222,0x222,0x222,0x222,0x222,0x222,0x222,0x222,
638 | 0xaaa,0xaaa,0xaaa,0xaaa,0xaaa,0xaaa,0xaaa,0xaaa,
639 | 0xaaa,0xaaa,0xaaa,0xaaa,0xaaa,0xaaa,0xaaa,0xaaa,
640 | 0x333,0x333,0x333,0x333,0x333,0x333,0x333,0x333,
641 | 0x333,0x333,0x333,0x333,0x333,0x333,0x333,0x333,
642 | 0xbbb,0xbbb,0xbbb,0xbbb,0xbbb,0xbbb,0xbbb,0xbbb,
643 | 0xbbb,0xbbb,0xbbb,0xbbb,0xbbb,0xbbb,0xbbb,0xbbb,
644 | 0x444,0x444,0x444,0x444,0x444,0x444,0x444,0x444,
645 | 0x444,0x444,0x444,0x444,0x444,0x444,0x444,0x444,
646 | 0xccc,0xccc,0xccc,0xccc,0xccc,0xccc,0xccc,0xccc,
647 | 0xccc,0xccc,0xccc,0xccc,0xccc,0xccc,0xccc,0xccc,
648 | 0x555,0x555,0x555,0x555,0x555,0x555,0x555,0x555,
649 | 0x555,0x555,0x555,0x555,0x555,0x555,0x555,0x555,
650 | 0xddd,0xddd,0xddd,0xddd,0xddd,0xddd,0xddd,0xddd,
651 | 0xddd,0xddd,0xddd,0xddd,0xddd,0xddd,0xddd,0xddd,
652 | 0x666,0x666,0x666,0x666,0x666,0x666,0x666,0x666,
653 | 0x666,0x666,0x666,0x666,0x666,0x666,0x666,0x666,
654 | 0xeee,0xeee,0xeee,0xeee,0xeee,0xeee,0xeee,0xeee,
655 | 0xeee,0xeee,0xeee,0xeee,0xeee,0xeee,0xeee,0xeee,
656 | 0x777,0x777,0x777,0x777,0x777,0x777,0x777,0x777,
657 | 0x777,0x777,0x777,0x777,0x777,0x777,0x777,0x777,
658 | 0xfff,0xfff,0xfff,0xfff,0xfff,0xfff,0xfff,0xfff,
659 | 0xfff,0xfff,0xfff,0xfff,0xfff,0xfff,0xfff
660 | };`);
661 | });
662 |
663 | it("formats for atariFalcon", () => {
664 | let result = output.formatTableC(values, {
665 | rowSize: 8,
666 | varName: "Gradient",
667 | target: targets.atariFalcon,
668 | });
669 | expect(result).toBe(`unsigned long Gradient[255] = {
670 | 0x00000000,0x00000000,0x00000000,0x00000000,0x04040004,0x04040004,0x04040004,0x04040004,
671 | 0x08080008,0x08080008,0x08080008,0x08080008,0x0c0c000c,0x0c0c000c,0x0c0c000c,0x0c0c000c,
672 | 0x10100010,0x10100010,0x10100010,0x10100010,0x14140014,0x14140014,0x14140014,0x14140014,
673 | 0x18180018,0x18180018,0x18180018,0x18180018,0x1c1c001c,0x1c1c001c,0x1c1c001c,0x1c1c001c,
674 | 0x20200020,0x20200020,0x20200020,0x20200020,0x24240024,0x24240024,0x24240024,0x24240024,
675 | 0x28280028,0x28280028,0x28280028,0x28280028,0x2c2c002c,0x2c2c002c,0x2c2c002c,0x2c2c002c,
676 | 0x30300030,0x30300030,0x30300030,0x30300030,0x34340034,0x34340034,0x34340034,0x34340034,
677 | 0x38380038,0x38380038,0x38380038,0x38380038,0x3c3c003c,0x3c3c003c,0x3c3c003c,0x3c3c003c,
678 | 0x40400040,0x40400040,0x40400040,0x40400040,0x44440044,0x44440044,0x44440044,0x44440044,
679 | 0x48480048,0x48480048,0x48480048,0x48480048,0x4c4c004c,0x4c4c004c,0x4c4c004c,0x4c4c004c,
680 | 0x50500050,0x50500050,0x50500050,0x50500050,0x54540054,0x54540054,0x54540054,0x54540054,
681 | 0x58580058,0x58580058,0x58580058,0x58580058,0x5c5c005c,0x5c5c005c,0x5c5c005c,0x5c5c005c,
682 | 0x60600060,0x60600060,0x60600060,0x60600060,0x64640064,0x64640064,0x64640064,0x64640064,
683 | 0x68680068,0x68680068,0x68680068,0x68680068,0x6c6c006c,0x6c6c006c,0x6c6c006c,0x6c6c006c,
684 | 0x70700070,0x70700070,0x70700070,0x70700070,0x74740074,0x74740074,0x74740074,0x74740074,
685 | 0x78780078,0x78780078,0x78780078,0x78780078,0x7c7c007c,0x7c7c007c,0x7c7c007c,0x7c7c007c,
686 | 0x80800080,0x80800080,0x80800080,0x80800080,0x84840084,0x84840084,0x84840084,0x84840084,
687 | 0x88880088,0x88880088,0x88880088,0x88880088,0x8c8c008c,0x8c8c008c,0x8c8c008c,0x8c8c008c,
688 | 0x90900090,0x90900090,0x90900090,0x90900090,0x94940094,0x94940094,0x94940094,0x94940094,
689 | 0x98980098,0x98980098,0x98980098,0x98980098,0x9c9c009c,0x9c9c009c,0x9c9c009c,0x9c9c009c,
690 | 0xa0a000a0,0xa0a000a0,0xa0a000a0,0xa0a000a0,0xa4a400a4,0xa4a400a4,0xa4a400a4,0xa4a400a4,
691 | 0xa8a800a8,0xa8a800a8,0xa8a800a8,0xa8a800a8,0xacac00ac,0xacac00ac,0xacac00ac,0xacac00ac,
692 | 0xb0b000b0,0xb0b000b0,0xb0b000b0,0xb0b000b0,0xb4b400b4,0xb4b400b4,0xb4b400b4,0xb4b400b4,
693 | 0xb8b800b8,0xb8b800b8,0xb8b800b8,0xb8b800b8,0xbcbc00bc,0xbcbc00bc,0xbcbc00bc,0xbcbc00bc,
694 | 0xc0c000c0,0xc0c000c0,0xc0c000c0,0xc0c000c0,0xc4c400c4,0xc4c400c4,0xc4c400c4,0xc4c400c4,
695 | 0xc8c800c8,0xc8c800c8,0xc8c800c8,0xc8c800c8,0xcccc00cc,0xcccc00cc,0xcccc00cc,0xcccc00cc,
696 | 0xd0d000d0,0xd0d000d0,0xd0d000d0,0xd0d000d0,0xd4d400d4,0xd4d400d4,0xd4d400d4,0xd4d400d4,
697 | 0xd8d800d8,0xd8d800d8,0xd8d800d8,0xd8d800d8,0xdcdc00dc,0xdcdc00dc,0xdcdc00dc,0xdcdc00dc,
698 | 0xe0e000e0,0xe0e000e0,0xe0e000e0,0xe0e000e0,0xe4e400e4,0xe4e400e4,0xe4e400e4,0xe4e400e4,
699 | 0xe8e800e8,0xe8e800e8,0xe8e800e8,0xe8e800e8,0xecec00ec,0xecec00ec,0xecec00ec,0xecec00ec,
700 | 0xf0f000f0,0xf0f000f0,0xf0f000f0,0xf0f000f0,0xf4f400f4,0xf4f400f4,0xf4f400f4,0xf4f400f4,
701 | 0xf8f800f8,0xf8f800f8,0xf8f800f8,0xf8f800f8,0xfcfc00fc,0xfcfc00fc,0xfcfc00fc
702 | };`);
703 | });
704 |
705 | it("formats for atariFalconTruecolor", () => {
706 | let result = output.formatTableC(values, {
707 | rowSize: 8,
708 | varName: "Gradient",
709 | target: targets.atariFalconTruecolor,
710 | });
711 | expect(result).toBe(`unsigned short Gradient[255] = {
712 | 0x0000,0x0000,0x0000,0x0000,0x0020,0x0020,0x0020,0x0020,
713 | 0x0841,0x0841,0x0841,0x0841,0x0861,0x0861,0x0861,0x0861,
714 | 0x1082,0x1082,0x1082,0x1082,0x10a2,0x10a2,0x10a2,0x10a2,
715 | 0x18c3,0x18c3,0x18c3,0x18c3,0x18e3,0x18e3,0x18e3,0x18e3,
716 | 0x2104,0x2104,0x2104,0x2104,0x2124,0x2124,0x2124,0x2124,
717 | 0x2945,0x2945,0x2945,0x2945,0x2965,0x2965,0x2965,0x2965,
718 | 0x3186,0x3186,0x3186,0x3186,0x31a6,0x31a6,0x31a6,0x31a6,
719 | 0x39c7,0x39c7,0x39c7,0x39c7,0x39e7,0x39e7,0x39e7,0x39e7,
720 | 0x4208,0x4208,0x4208,0x4208,0x4228,0x4228,0x4228,0x4228,
721 | 0x4a49,0x4a49,0x4a49,0x4a49,0x4a69,0x4a69,0x4a69,0x4a69,
722 | 0x528a,0x528a,0x528a,0x528a,0x52aa,0x52aa,0x52aa,0x52aa,
723 | 0x5acb,0x5acb,0x5acb,0x5acb,0x5aeb,0x5aeb,0x5aeb,0x5aeb,
724 | 0x630c,0x630c,0x630c,0x630c,0x632c,0x632c,0x632c,0x632c,
725 | 0x6b4d,0x6b4d,0x6b4d,0x6b4d,0x6b6d,0x6b6d,0x6b6d,0x6b6d,
726 | 0x738e,0x738e,0x738e,0x738e,0x73ae,0x73ae,0x73ae,0x73ae,
727 | 0x7bcf,0x7bcf,0x7bcf,0x7bcf,0x7bef,0x7bef,0x7bef,0x7bef,
728 | 0x8410,0x8410,0x8410,0x8410,0x8430,0x8430,0x8430,0x8430,
729 | 0x8c51,0x8c51,0x8c51,0x8c51,0x8c71,0x8c71,0x8c71,0x8c71,
730 | 0x9492,0x9492,0x9492,0x9492,0x94b2,0x94b2,0x94b2,0x94b2,
731 | 0x9cd3,0x9cd3,0x9cd3,0x9cd3,0x9cf3,0x9cf3,0x9cf3,0x9cf3,
732 | 0xa514,0xa514,0xa514,0xa514,0xa534,0xa534,0xa534,0xa534,
733 | 0xad55,0xad55,0xad55,0xad55,0xad75,0xad75,0xad75,0xad75,
734 | 0xb596,0xb596,0xb596,0xb596,0xb5b6,0xb5b6,0xb5b6,0xb5b6,
735 | 0xbdd7,0xbdd7,0xbdd7,0xbdd7,0xbdf7,0xbdf7,0xbdf7,0xbdf7,
736 | 0xc618,0xc618,0xc618,0xc618,0xc638,0xc638,0xc638,0xc638,
737 | 0xce59,0xce59,0xce59,0xce59,0xce79,0xce79,0xce79,0xce79,
738 | 0xd69a,0xd69a,0xd69a,0xd69a,0xd6ba,0xd6ba,0xd6ba,0xd6ba,
739 | 0xdedb,0xdedb,0xdedb,0xdedb,0xdefb,0xdefb,0xdefb,0xdefb,
740 | 0xe71c,0xe71c,0xe71c,0xe71c,0xe73c,0xe73c,0xe73c,0xe73c,
741 | 0xef5d,0xef5d,0xef5d,0xef5d,0xef7d,0xef7d,0xef7d,0xef7d,
742 | 0xf79e,0xf79e,0xf79e,0xf79e,0xf7be,0xf7be,0xf7be,0xf7be,
743 | 0xffdf,0xffdf,0xffdf,0xffdf,0xffff,0xffff,0xffff
744 | };`);
745 | });
746 |
747 | it("allows setting label", () => {
748 | let result = output.formatTableC(values, {
749 | rowSize: 8,
750 | varName: "Foo",
751 | target: targets.amigaOcs,
752 | });
753 | expect(result).toContain("unsigned short Foo[255]");
754 | });
755 |
756 | it("allows setting row size", () => {
757 | let result = output.formatTableC(values, {
758 | rowSize: 16,
759 | varName: "Gradient",
760 | target: targets.amigaOcs,
761 | });
762 | expect(result).toContain(
763 | "\t0x000,0x000,0x000,0x000,0x000,0x000,0x000,0x000,0x000,0x000,0x000,0x000,0x000,0x000,0x000,0x000,\n",
764 | );
765 | });
766 | });
767 |
768 | describe("gradientToBytes()", () => {
769 | it("formats for amigaOcs", () => {
770 | let result = output.gradientToBytes(
771 | [
772 | [0xa, 0xb, 0xc],
773 | [0x1, 0x2, 0x3],
774 | ].map((v) => restoreBits(v as RGB, 4)),
775 | targets.amigaOcs,
776 | );
777 | expect(result).toEqual(new Uint8Array([0xa, 0xbc, 0x1, 0x23]));
778 | });
779 |
780 | it("formats for amigaAga", () => {
781 | let result = output.gradientToBytes(
782 | [
783 | [0xa1, 0xb2, 0xc3],
784 | [0xd4, 0xe5, 0xf6],
785 | ],
786 | targets.amigaAga,
787 | );
788 | expect(result).toEqual(
789 | new Uint8Array([0xa, 0xbc, 0x1, 0x23, 0xd, 0xef, 0x4, 0x56]),
790 | );
791 | });
792 |
793 | it("formats for atariSt", () => {
794 | let result = output.gradientToBytes(
795 | [
796 | [0x1, 0x2, 0x3],
797 | [0x4, 0x5, 0x6],
798 | ].map((v) => restoreBits(v as RGB, 3)),
799 | targets.atariSt,
800 | );
801 | expect(result).toEqual(new Uint8Array([0x1, 0x23, 0x4, 0x56]));
802 | });
803 |
804 | it("formats for atariSte", () => {
805 | let result = output.gradientToBytes(
806 | [
807 | [0xa, 0xb, 0xc],
808 | [0x1, 0x2, 0x3],
809 | ].map((v) => restoreBits(v as RGB, 4)),
810 | targets.atariSte,
811 | );
812 | expect(result).toEqual(new Uint8Array([0x5, 0xd6, 0x8, 0x19]));
813 | });
814 |
815 | it("formats for atariFalcon", () => {
816 | let result = output.gradientToBytes(
817 | [
818 | [0x1f, 0x3f, 0x1f],
819 | [0x10, 0x08, 0x00],
820 | ].map((v) => restoreBits(v as RGB, 6)),
821 | targets.atariFalcon,
822 | );
823 | expect(result).toEqual(new Uint8Array([124, 252, 0, 124, 64, 32, 0, 0]));
824 | });
825 |
826 | it("formats for atariFalconTrue", () => {
827 | let result = output.gradientToBytes(
828 | [
829 | [0x1f, 0x3f, 0x1f],
830 | [0x10, 0x08, 0x00],
831 | ].map((v) => restoreBits(v as RGB, 6)),
832 | targets.atariFalconTruecolor,
833 | );
834 | expect(result).toEqual(new Uint8Array([127, 239, 65, 0]));
835 | });
836 | });
837 |
838 | describe("base64Encode", () => {
839 | it("encodes a byte array", () => {
840 | let result = output.base64Encode(
841 | new Uint8Array(["a", "b", "c", "d"].map((s) => s.charCodeAt(0))),
842 | );
843 | expect(result).toBe("YWJjZA==");
844 | });
845 | });
846 | });
847 |
--------------------------------------------------------------------------------
/src/lib/output.ts:
--------------------------------------------------------------------------------
1 | import {
2 | encodeHex3,
3 | encodeHexSte,
4 | encodeHexPairAga,
5 | encodeHexFalcon,
6 | encodeHexFalconTrue,
7 | decodeHex3,
8 | encodeHexFalcon24,
9 | encodeNeoGeo,
10 | } from "./hex";
11 | import { reduceBits } from "./bitDepth";
12 | import { sameColors } from "./utils";
13 | import { Target } from "./targets";
14 | import { RGB } from "../types";
15 |
16 | export type FormatKey =
17 | | "copperList"
18 | | "copperListC"
19 | | "tableAsm"
20 | | "tableC"
21 | | "tableAmos"
22 | | "tableStos"
23 | | "tableBin"
24 | | "imagePng";
25 |
26 | export interface Format {
27 | label: string;
28 | }
29 |
30 | export const formats: Record = {
31 | copperList: { label: "Copper list: asm" },
32 | copperListC: { label: "Copper list: C" },
33 | tableAsm: { label: "Table: asm" },
34 | tableC: { label: "Table: C" },
35 | tableAmos: { label: "Table: AMOS" },
36 | tableStos: { label: "Table: STOS" },
37 | tableBin: { label: "Table: binary" },
38 | imagePng: { label: "PNG Image" },
39 | };
40 |
41 | export interface CopperListOptions {
42 | startLine?: number;
43 | varName: string;
44 | colorIndex?: number;
45 | waitStart?: boolean;
46 | endList?: boolean;
47 | target: Target;
48 | lang: "c" | "asm";
49 | }
50 |
51 | export function buildCopperList(
52 | gradient: RGB[],
53 | {
54 | startLine = 0x2b,
55 | varName,
56 | colorIndex = 0,
57 | waitStart = true,
58 | endList = true,
59 | target,
60 | lang,
61 | }: CopperListOptions,
62 | ): string {
63 | const isC = lang === "c";
64 |
65 | const numberPrefix = isC ? "0x" : "$";
66 | const linePrefix = isC ? "" : "dc.w ";
67 | const commentPrefix = isC ? "//" : ";";
68 | const linePostfix = isC ? "," : "";
69 |
70 | let colorReg = numberPrefix + (0x180 + colorIndex * 2).toString(16);
71 | let output = [];
72 | if (varName) {
73 | if (isC) {
74 | output.push(`unsigned short ${varName}[] = {`);
75 | } else {
76 | output.push(varName + ":");
77 | }
78 | }
79 |
80 | let lastCol;
81 | let line = startLine;
82 | for (const col of gradient) {
83 | if (target.id === "amigaOcs" || target.id === "amigaOcsLace") {
84 | // OCS/ ECS
85 | const hex = encodeHex3(reduceBits(col, 4));
86 | if (lastCol !== hex) {
87 | const l = (line & 0xff).toString(16);
88 | if (line > startLine || waitStart) {
89 | output.push(
90 | `\t${linePrefix}${numberPrefix}${l}07,${numberPrefix}fffe${linePostfix}`,
91 | );
92 | }
93 | output.push(
94 | `\t${linePrefix}${colorReg},${numberPrefix}${hex}${linePostfix}`,
95 | );
96 | }
97 | lastCol = hex;
98 | } else {
99 | // AGA
100 | if (!sameColors(lastCol as RGB, col)) {
101 | const l = (line & 0xff).toString(16);
102 | if (line > startLine || waitStart) {
103 | output.push(
104 | `\t${linePrefix}${numberPrefix}${l}07,${numberPrefix}fffe${linePostfix}`,
105 | );
106 | }
107 | const [hex1, hex2] = encodeHexPairAga(col);
108 | output.push(
109 | `\t${linePrefix}${colorReg},${numberPrefix}${hex1}${linePostfix}`,
110 | );
111 | output.push(
112 | `\t${linePrefix}${numberPrefix}106,${numberPrefix}200${linePostfix}`,
113 | );
114 | output.push(
115 | `\t${linePrefix}${colorReg},${numberPrefix}${hex2}${linePostfix}`,
116 | );
117 | output.push(
118 | `\t${linePrefix}${numberPrefix}106,${numberPrefix}000${linePostfix}`,
119 | );
120 | }
121 | lastCol = col;
122 | }
123 | // PAL fix
124 | if (line === 0xff) {
125 | output.push(
126 | `\t${linePrefix}${numberPrefix}ffdf,${numberPrefix}fffe${linePostfix} ${commentPrefix} PAL fix`,
127 | );
128 | }
129 | line++;
130 | }
131 | if (endList) {
132 | output.push(
133 | `\t${linePrefix}${numberPrefix}ffff,${numberPrefix}fffe ${commentPrefix} End copper list`,
134 | );
135 | }
136 | if (isC) {
137 | output.push("};");
138 | }
139 | return output.join("\n");
140 | }
141 |
142 | export interface TableOptions {
143 | rowSize: number;
144 | varName: string;
145 | target: Target;
146 | }
147 |
148 | export const formatTableAsm = (
149 | values: RGB[],
150 | { rowSize, varName, target }: TableOptions,
151 | ) => {
152 | let output = varName ? varName + ":\n" : "";
153 | const items = tableHexItems(values, target);
154 | const size = items[0]?.length > 4 ? "l" : "w";
155 | output += groupRows(items, rowSize)
156 | .map((row) => `\tdc.${size} ` + row.map((v) => "$" + v).join(","))
157 | .join("\n");
158 | return output;
159 | };
160 |
161 | function tableHexItems(values: RGB[], target: Target) {
162 | console.log(target);
163 | const items = [];
164 | for (let col of values) {
165 | if (target.id === "atariSte") {
166 | const color = reduceBits(col, target.depth);
167 | items.push(encodeHexSte(color));
168 | } else if (target.id === "amigaAga") {
169 | const hex = encodeHexPairAga(col);
170 | items.push(hex[0], hex[1]);
171 | } else if (target.id === "atariFalcon") {
172 | const color = reduceBits(col, target.depth);
173 | items.push(encodeHexFalcon(color));
174 | } else if (target.id === "atariFalcon24") {
175 | items.push(encodeHexFalcon24(col));
176 | } else if (target.id === "atariFalconTrue") {
177 | const color = reduceBits(col, target.depth);
178 | items.push(encodeHexFalconTrue(color));
179 | } else if (target.id === "amigaOcsLace") {
180 | const color = reduceBits(col, 4);
181 | items.push(encodeHex3(color));
182 | } else if (target.id === "neoGeo") {
183 | items.push(encodeNeoGeo(col));
184 | } else {
185 | const color = reduceBits(col, target.depth);
186 | items.push(encodeHex3(color));
187 | }
188 | }
189 | return items;
190 | }
191 |
192 | function groupRows(items: T[], rowSize: number): T[][] {
193 | const out = [];
194 | let current = [];
195 | for (let i = 0; i < items.length; i++) {
196 | if (i % rowSize === 0) {
197 | if (current.length) {
198 | out.push(current);
199 | }
200 | current = [];
201 | }
202 | current.push(items[i]);
203 | }
204 | if (current.length) {
205 | out.push(current);
206 | }
207 | return out;
208 | }
209 |
210 | export const formatTableC = (
211 | values: RGB[],
212 | { rowSize = 16, varName, target }: TableOptions,
213 | ) => {
214 | const items = tableHexItems(values, target);
215 | const size = items[0]?.length > 4 ? "long" : "short";
216 | let output = `unsigned ${size} ${varName}[${items.length}] = {\n`;
217 | output += groupRows(items, rowSize)
218 | .map((row) => "\t" + row.map((v) => "0x" + v).join(","))
219 | .join(",\n");
220 | return output + "\n};";
221 | };
222 |
223 | export const gradientToBytes = (gradient: RGB[], target: Target) => {
224 | let bytes;
225 | let i = 0;
226 |
227 | if (target.id === "amigaAga") {
228 | bytes = new Uint8Array(gradient.length * 4);
229 | for (const rgb of gradient) {
230 | const rgbPair = encodeHexPairAga(rgb).map(decodeHex3);
231 | for (const [r, g, b] of rgbPair) {
232 | bytes[i++] = r;
233 | bytes[i++] = (g << 4) + b;
234 | }
235 | }
236 | } else if (target.id === "atariSte") {
237 | bytes = new Uint8Array(gradient.length * 2);
238 | for (const [r, g, b] of gradient.map((c) =>
239 | decodeHex3(encodeHexSte(reduceBits(c, target.depth))),
240 | )) {
241 | bytes[i++] = r;
242 | bytes[i++] = (g << 4) + b;
243 | }
244 | } else if (target.id === "atariFalcon") {
245 | bytes = new Uint8Array(gradient.length * 4);
246 | for (const [r, g, b] of gradient.map((c) =>
247 | reduceBits(c, target.depth).map((c) => c << 2),
248 | )) {
249 | bytes[i++] = r;
250 | bytes[i++] = g;
251 | bytes[i++] = 0;
252 | bytes[i++] = b;
253 | }
254 | } else if (target.id === "atariFalcon24") {
255 | bytes = new Uint8Array(gradient.length * 4);
256 | for (const [r, g, b] of gradient) {
257 | bytes[i++] = r;
258 | bytes[i++] = g;
259 | bytes[i++] = 0;
260 | bytes[i++] = b;
261 | }
262 | } else if (target.id === "atariFalconTrue") {
263 | bytes = new Uint8Array(gradient.length * 2);
264 | for (const [a, b, c, d] of gradient.map(
265 | (c) =>
266 | decodeHex3(
267 | encodeHexFalconTrue(reduceBits(c, target.depth)),
268 | ) as number[],
269 | )) {
270 | bytes[i++] = (a << 4) + b;
271 | bytes[i++] = (c << 4) + d;
272 | }
273 | } else if (target.id === "amigaOcsLace") {
274 | bytes = new Uint8Array(gradient.length * 2);
275 | for (const [r, g, b] of gradient.map((c) => reduceBits(c, 4))) {
276 | bytes[i++] = r;
277 | bytes[i++] = (g << 4) + b;
278 | }
279 | } else if (target.id === "neoGeo") {
280 | bytes = new Uint8Array(gradient.length * 2);
281 | for (const col of gradient) {
282 | const hex = encodeNeoGeo(col);
283 | bytes[i++] = Number(hex.substring(0, 2));
284 | bytes[i++] = Number(hex.substring(2));
285 | }
286 | } else {
287 | bytes = new Uint8Array(gradient.length * 2);
288 | for (const [r, g, b] of gradient.map((c) => reduceBits(c, target.depth))) {
289 | bytes[i++] = r;
290 | bytes[i++] = (g << 4) + b;
291 | }
292 | }
293 | return bytes;
294 | };
295 |
296 | export const base64Encode = (bytes: Uint8Array) =>
297 | window.btoa(
298 | bytes.reduce((data, byte) => data + String.fromCharCode(byte), ""),
299 | );
300 |
--------------------------------------------------------------------------------
/src/lib/targets.ts:
--------------------------------------------------------------------------------
1 | import { Bits } from "../types";
2 | import { FormatKey } from "./output";
3 |
4 | export interface Target {
5 | id: string;
6 | label: string;
7 | depth: Bits;
8 | interlaced?: boolean;
9 | outputs: FormatKey[];
10 | }
11 |
12 | export type TargetKey =
13 | | "amigaOcs"
14 | | "amigaAga"
15 | | "amigaOcsLace"
16 | | "atariSt"
17 | | "atariSte"
18 | | "atariFalcon"
19 | | "atariFalcon24"
20 | | "atariFalconTruecolor"
21 | | "neoGeo";
22 |
23 | const targets: Record = {
24 | amigaOcs: {
25 | id: "amigaOcs",
26 | label: "Amiga OCS/ECS",
27 | depth: 4,
28 | outputs: [
29 | "copperList",
30 | "copperListC",
31 | "tableAsm",
32 | "tableC",
33 | "tableAmos",
34 | "tableBin",
35 | "imagePng",
36 | ],
37 | },
38 | amigaOcsLace: {
39 | id: "amigaOcsLace",
40 | label: "Amiga OCS/ESC interlace",
41 | depth: 5,
42 | interlaced: true,
43 | outputs: [
44 | "copperList",
45 | "copperListC",
46 | "tableAsm",
47 | "tableC",
48 | "tableAmos",
49 | "tableBin",
50 | "imagePng",
51 | ],
52 | },
53 | amigaAga: {
54 | id: "amigaAga",
55 | label: "Amiga AGA",
56 | depth: 8,
57 | outputs: [
58 | "copperList",
59 | "copperListC",
60 | "tableAsm",
61 | "tableC",
62 | "tableAmos",
63 | "tableBin",
64 | "imagePng",
65 | ],
66 | },
67 | atariSt: {
68 | id: "atariSt",
69 | label: "Atari ST",
70 | depth: 3,
71 | outputs: ["tableAsm", "tableC", "tableStos", "tableBin", "imagePng"],
72 | },
73 | atariSte: {
74 | id: "atariSte",
75 | label: "Atari STe/TT",
76 | depth: 4,
77 | outputs: ["tableAsm", "tableC", "tableStos", "tableBin", "imagePng"],
78 | },
79 | atariFalcon: {
80 | id: "atariFalcon",
81 | label: "Atari Falcon",
82 | depth: 6,
83 | outputs: ["tableAsm", "tableC", "tableStos", "tableBin", "imagePng"],
84 | },
85 | atariFalcon24: {
86 | id: "atariFalcon24",
87 | label: "Atari Falcon 24bit",
88 | depth: 8,
89 | outputs: ["tableAsm", "tableC", "tableStos", "tableBin", "imagePng"],
90 | },
91 | atariFalconTruecolor: {
92 | id: "atariFalconTrue",
93 | label: "Atari Falcon Truecolor",
94 | depth: [5, 6, 5],
95 | outputs: ["tableAsm", "tableC", "tableStos", "tableBin", "imagePng"],
96 | },
97 | neoGeo: {
98 | id: "neoGeo",
99 | label: "NeoGeo",
100 | depth: [6, 6, 6],
101 | outputs: ["tableAsm", "tableC", "tableBin", "imagePng"],
102 | },
103 | };
104 |
105 | export default targets;
106 |
--------------------------------------------------------------------------------
/src/lib/url.test.ts:
--------------------------------------------------------------------------------
1 | import { Options } from "../types";
2 | import { rgbToHsv } from "./colorSpace";
3 | import * as url from "./url";
4 |
5 | const points = [
6 | { color: rgbToHsv([0, 0, 0]), pos: 0 },
7 | { color: rgbToHsv([255, 255, 255]), pos: 1 },
8 | ];
9 | const options: Options = {
10 | steps: 16,
11 | blendMode: "oklab",
12 | ditherMode: "blueNoise",
13 | ditherAmount: 30,
14 | target: "amigaOcs",
15 | };
16 | const agaOptions: Options = {
17 | ...options,
18 | target: "amigaAga",
19 | };
20 |
21 | describe("url", () => {
22 | describe("encodeUrlQuery", () => {
23 | it("encodes points and options for amigaOcs", () => {
24 | const result = url.encodeUrlQuery({ points, options });
25 | expect(result).toBe(
26 | "?points=000@0,fff@15&steps=16&blendMode=oklab&ditherMode=blueNoise&target=amigaOcs&ditherAmount=30",
27 | );
28 | });
29 |
30 | it("uses long hex values for > 4 bits", () => {
31 | const result = url.encodeUrlQuery({
32 | points,
33 | options: agaOptions,
34 | });
35 | expect(result).toBe(
36 | "?points=000000@0,ffffff@15&steps=16&blendMode=oklab&ditherMode=blueNoise&target=amigaAga&ditherAmount=30",
37 | );
38 | });
39 | });
40 |
41 | describe("decodeUrlQuery())", () => {
42 | it("decodes a query", () => {
43 | const result = url.decodeUrlQuery(
44 | "?points=000@0,fff@15&steps=16&blendMode=oklab&ditherMode=blueNoise&target=amigaOcs&ditherAmount=30",
45 | );
46 | expect(result.points).toEqual(points);
47 | expect(result.options).toEqual(options);
48 | });
49 |
50 | it("decodes a query with long hex values", () => {
51 | const result = url.decodeUrlQuery(
52 | "?points=000000@0,ffffff@15&steps=16&blendMode=oklab&ditherMode=blueNoise&target=amigaAga&ditherAmount=30",
53 | );
54 | expect(result.points).toEqual(points);
55 | expect(result.options).toEqual(agaOptions);
56 | });
57 | });
58 | });
59 |
--------------------------------------------------------------------------------
/src/lib/url.ts:
--------------------------------------------------------------------------------
1 | import qs from "qs";
2 | import { Bits, Options, Point } from "../types";
3 | import { reduceBits } from "./bitDepth";
4 | import { hsvToRgb, rgbToHsv } from "./colorSpace";
5 | import { encodeHex3, encodeHex6, hexToRgb } from "./hex";
6 | import targets, { TargetKey } from "./targets";
7 |
8 | export type UrlArgs = {
9 | points: Point[];
10 | options: Options;
11 | };
12 |
13 | export const encodeUrlQuery = ({ points, options }: UrlArgs): string => {
14 | const { steps, blendMode, ditherMode, ditherAmount, shuffleCount, target } =
15 | options;
16 | const opts: Options = { steps, blendMode, ditherMode, target };
17 | if (!["off", "shuffle"].includes(ditherMode)) {
18 | opts.ditherAmount = ditherAmount;
19 | }
20 | if (ditherMode === "shuffle") {
21 | opts.shuffleCount = shuffleCount;
22 | }
23 | const depth = targets[target].depth;
24 | return `?points=${encodePoints(points, steps, depth)}&${qs.stringify(opts)}`;
25 | };
26 |
27 | export const decodeUrlQuery = (query: string): Partial => {
28 | if (!query) return {};
29 | const {
30 | points,
31 | steps,
32 | blendMode,
33 | ditherMode,
34 | ditherAmount,
35 | shuffleCount,
36 | target,
37 | } = qs.parse(query.substring(1)) as Record;
38 | const options = {
39 | steps: intVal(steps),
40 | blendMode,
41 | ditherMode,
42 | ditherAmount: intVal(ditherAmount),
43 | shuffleCount: intVal(shuffleCount),
44 | target,
45 | } as Options;
46 | // Remove undefined
47 | Object.keys(options).forEach((key) => {
48 | if (options[key as keyof Options] === undefined) {
49 | delete options[key as keyof Options];
50 | }
51 | });
52 | const depth = targets[target as TargetKey].depth ?? 4;
53 | return {
54 | points: points && steps ? decodePoints(points, options.steps, depth) : [],
55 | options,
56 | };
57 | };
58 |
59 | const intVal = (str: string | undefined): number | undefined =>
60 | str ? parseInt(str) : undefined;
61 |
62 | const encodePoints = (points: Point[], steps: number, depth: Bits): string =>
63 | points
64 | .map((p) => {
65 | const col =
66 | depth <= 4
67 | ? encodeHex3(reduceBits(hsvToRgb(p.color), depth))
68 | : encodeHex6(hsvToRgb(p.color));
69 | return col + "@" + Math.round(p.pos * (steps - 1));
70 | })
71 | .join(",");
72 |
73 | const decodePoints = (encoded: string, steps: number, depth: Bits): Point[] =>
74 | encoded.split(",").map((n) => {
75 | const [hex, y] = n.split("@");
76 | const color = rgbToHsv(hexToRgb(hex, depth));
77 | const pos = parseInt(y) / (steps - 1);
78 | return { color, pos };
79 | });
80 |
--------------------------------------------------------------------------------
/src/lib/utils.test.ts:
--------------------------------------------------------------------------------
1 | import * as utils from "./utils";
2 |
3 | describe("utils", () => {
4 | describe("normalizeRgb()", () => {
5 | it("clamps and rounds components", () => {
6 | const result = utils.normalizeRgb([300, 1.5, -1]);
7 | expect(result).toEqual([255, 2, 0]);
8 | });
9 | });
10 |
11 | describe("rgbCssProp()", () => {
12 | it("converts to a valid CSS RGB property", () => {
13 | const result = utils.rgbCssProp([12, 34, 56]);
14 | expect(result).toBe("rgb(12, 34, 56)");
15 | });
16 | });
17 |
18 | describe("fToPercent()", () => {
19 | it("converts a flat to a percentage string", () => {
20 | let result = utils.fToPercent(1);
21 | expect(result).toBe("100%");
22 | result = utils.fToPercent(0.5);
23 | expect(result).toBe("50%");
24 |
25 | result = utils.fToPercent(0.25);
26 | expect(result).toBe("25%");
27 |
28 | result = utils.fToPercent(0);
29 | expect(result).toBe("0%");
30 | });
31 | });
32 |
33 | describe("clamp()", () => {
34 | it("limits max value", () => {
35 | let result = utils.clamp(100, 0, 50);
36 | expect(result).toBe(50);
37 | });
38 |
39 | it("limits min value", () => {
40 | let result = utils.clamp(10, 50, 200);
41 | expect(result).toBe(50);
42 | });
43 |
44 | it("defaults to 0-1 range", () => {
45 | let result = utils.clamp(1.5);
46 | expect(result).toBe(1);
47 |
48 | result = utils.clamp(-1);
49 | expect(result).toBe(0);
50 | });
51 | });
52 |
53 | describe("sameColors()", () => {
54 | it("returns true for identical RGB values", () => {
55 | let result = utils.sameColors([1, 2, 3], [1, 2, 3]);
56 | expect(result).toBe(true);
57 | });
58 |
59 | it("returns false for identical RGB values", () => {
60 | let result = utils.sameColors([1, 2, 3], [3, 2, 1]);
61 | expect(result).toBe(false);
62 | });
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { Color, RGB } from "../types";
2 |
3 | export function normalizeRgb(rgb: RGB): RGB {
4 | return rgb.map((c) => Math.round(clamp(c, 0, 255))) as RGB;
5 | }
6 |
7 | export function rgbCssProp([r, g, b]: RGB): string {
8 | return `rgb(${r}, ${g}, ${b})`;
9 | }
10 |
11 | export function fToPercent(val: number): string {
12 | return Math.round(val * 100) + "%";
13 | }
14 |
15 | export function clamp(value: number, min = 0, max = 1) {
16 | return Math.min(Math.max(value, min), max);
17 | }
18 |
19 | export function sameColors(a: Color, b: Color) {
20 | return a && b && a.join(",") === b.join(",");
21 | }
22 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import "./index.css";
4 | import App from "./components/App";
5 | import { Provider } from "react-redux";
6 | import store from "./store";
7 |
8 | const root = ReactDOM.createRoot(document.getElementById("root")!);
9 | root.render(
10 |
11 |
12 |
13 |
14 | ,
15 | );
16 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import "@testing-library/jest-dom";
6 |
--------------------------------------------------------------------------------
/src/store/actions.ts:
--------------------------------------------------------------------------------
1 | import { createAction } from "@reduxjs/toolkit";
2 |
3 | export const reset = createAction("reset");
4 |
--------------------------------------------------------------------------------
/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | combineReducers,
3 | configureStore,
4 | createSelector,
5 | } from "@reduxjs/toolkit";
6 | import points, { selectPoints } from "./points";
7 | import options, { selectOptions } from "./options";
8 | import { buildGradient } from "../lib/gradient";
9 | import undoable, { excludeAction } from "redux-undo";
10 |
11 | // Grouping logic for undo/redo:
12 |
13 | // Group actions of same type that occur within MAX_DELTA ms of each other
14 | // e.g. dragging or scroling
15 |
16 | let lastActionType: string;
17 | let lastActionTime = 0;
18 | const MAX_DELTA = 500; // Max time between grouped actions
19 | let groupNo = 0; // This will be incremented each time we choose not to group with th previous action
20 |
21 | const groupBy = (action: { type: string }) => {
22 | const now = Date.now();
23 | const delta = now - lastActionTime;
24 | if (lastActionType !== action.type || delta > MAX_DELTA) {
25 | groupNo++;
26 | }
27 | lastActionTime = now;
28 | lastActionType = action.type;
29 | return groupNo;
30 | };
31 |
32 | const data = undoable(
33 | combineReducers({
34 | points,
35 | options,
36 | }),
37 | {
38 | groupBy,
39 | filter: excludeAction([
40 | "points/selectIndex",
41 | "points/nextPoint",
42 | "points/previousPoint",
43 | "options/setScale",
44 | ]),
45 | },
46 | );
47 |
48 | const store = configureStore({ reducer: { data } });
49 |
50 | // Infer the `RootState` and `AppDispatch` types from the store itself
51 | export type RootState = ReturnType;
52 | // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
53 | export type AppDispatch = typeof store.dispatch;
54 |
55 | export const selectGradient = createSelector(
56 | selectPoints,
57 | selectOptions,
58 | (points, options) => {
59 | return buildGradient(points, options);
60 | },
61 | );
62 |
63 | export const selectPresentData = (state: RootState) => state.data.present;
64 |
65 | export default store;
66 |
--------------------------------------------------------------------------------
/src/store/options.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 | import { decodeUrlQuery } from "../lib/url";
3 | import { reset } from "./actions";
4 | import targets, { Target } from "../lib/targets";
5 | import { Bits, Options } from "../types";
6 | import { RootState } from ".";
7 |
8 | const urlState = decodeUrlQuery(window.location.search);
9 |
10 | const defaultState: Options = {
11 | steps: 256,
12 | blendMode: "oklab",
13 | ditherMode: "blueNoise",
14 | ditherAmount: 40,
15 | shuffleCount: 2,
16 | target: "amigaOcs",
17 | };
18 |
19 | const initialState: Options = {
20 | ...defaultState,
21 | ...urlState.options,
22 | };
23 |
24 | export const configSlice = createSlice({
25 | name: "options",
26 | initialState,
27 | reducers: {
28 | setSteps: (state, action) => {
29 | state.steps = action.payload;
30 | },
31 | setBlendMode: (state, action) => {
32 | state.blendMode = action.payload;
33 | },
34 | setDitherMode: (state, action) => {
35 | state.ditherMode = action.payload;
36 | },
37 | setDitherAmount: (state, action) => {
38 | state.ditherAmount = action.payload;
39 | },
40 | setShuffleCount: (state, action) => {
41 | state.shuffleCount = action.payload;
42 | },
43 | setTarget: (state, action) => {
44 | state.target = action.payload;
45 | },
46 | },
47 | extraReducers: {
48 | [reset as any]() {
49 | return defaultState;
50 | },
51 | },
52 | });
53 |
54 | export const {
55 | setSteps,
56 | setBlendMode,
57 | setDitherMode,
58 | setDitherAmount,
59 | setShuffleCount,
60 | setTarget,
61 | } = configSlice.actions;
62 |
63 | export const selectOptions = (state: RootState): Options =>
64 | state.data.present.options;
65 | export const selectTarget = (state: RootState): Target =>
66 | targets[state.data.present.options.target];
67 | export const selectDepth = (state: RootState): Bits =>
68 | selectTarget(state).depth;
69 |
70 | export default configSlice.reducer;
71 |
--------------------------------------------------------------------------------
/src/store/points.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 | import { RootState } from ".";
3 | import * as conv from "../lib/colorSpace";
4 | import { decodeUrlQuery } from "../lib/url";
5 | import { Point } from "../types";
6 | import { reset } from "./actions";
7 |
8 | const urlState = decodeUrlQuery(window.location.search);
9 |
10 | // Assign unique IDs to points
11 | let id = 0;
12 | const nextId = () => id++;
13 |
14 | const defaultPoints: Point[] = [
15 | { id: nextId(), pos: 0, color: conv.rgbToHsv([255, 255, 0]) },
16 | { id: nextId(), pos: 1, color: conv.rgbToHsv([0, 0, 255]) },
17 | ];
18 |
19 | const initialState = {
20 | selectedIndex: 0,
21 | items: urlState.points
22 | ? urlState.points.map((p) => ({ id: nextId(), ...p }))
23 | : defaultPoints,
24 | };
25 |
26 | export const pointsSlice = createSlice({
27 | name: "points",
28 | initialState,
29 | reducers: {
30 | addPoint: (state, action) => {
31 | const newPoint = {
32 | id: nextId(),
33 | ...action.payload,
34 | };
35 | state.items.push(newPoint);
36 | state.items.sort((a, b) => a.pos - b.pos);
37 | state.selectedIndex = state.items.findIndex((p) => p === newPoint);
38 | },
39 | removePoint: (state) => {
40 | if (state.items.length > 2) {
41 | state.items.splice(state.selectedIndex, 1);
42 | state.selectedIndex = Math.max(state.selectedIndex - 1, 0);
43 | }
44 | },
45 | clonePoint: (state) => {
46 | const selected = state.items[state.selectedIndex];
47 | const clonedPoint = {
48 | ...selected,
49 | id: nextId(),
50 | };
51 | state.items.splice(state.selectedIndex + 1, 0, clonedPoint);
52 | },
53 | setPos: (state, action) => {
54 | const selected = state.items[state.selectedIndex];
55 | selected.pos = action.payload;
56 | state.items.sort((a, b) => a.pos - b.pos);
57 | state.selectedIndex = state.items.findIndex((p) => p === selected);
58 | },
59 | setColor: (state, action) => {
60 | state.items[state.selectedIndex].color = action.payload;
61 | },
62 | selectIndex: (state, action) => {
63 | state.selectedIndex = action.payload;
64 | },
65 | previousPoint: (state) => {
66 | state.selectedIndex -= 1;
67 | },
68 | nextPoint: (state) => {
69 | state.selectedIndex += 1;
70 | },
71 | },
72 | extraReducers: {
73 | [reset as any]() {
74 | return {
75 | selectedIndex: 0,
76 | items: defaultPoints,
77 | };
78 | },
79 | },
80 | });
81 |
82 | export const {
83 | addPoint,
84 | removePoint,
85 | clonePoint,
86 | setPos,
87 | setColor,
88 | selectIndex,
89 | previousPoint,
90 | nextPoint,
91 | } = pointsSlice.actions;
92 |
93 | export const selectPoints = (state: RootState): Point[] =>
94 | state.data.present.points.items;
95 | export const selectSelectedIndex = (state: RootState): number =>
96 | state.data.present.points.selectedIndex;
97 |
98 | export default pointsSlice.reducer;
99 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import { TargetKey } from "./lib/targets";
2 |
3 | // Use enums for components to prevent mixing types:
4 | // https://stackoverflow.com/questions/43831683/can-i-declare-custom-number-types-in-typescript
5 | enum RGBComp {}
6 | enum LRGBComp {}
7 | enum HSVComp {}
8 | enum LABComp {}
9 | enum OKLABComp {}
10 |
11 | export type RGB = [RGBComp, RGBComp, RGBComp];
12 | export type LRGB = [LRGBComp, LRGBComp, LRGBComp];
13 | export type HSV = [HSVComp, HSVComp, HSVComp];
14 | export type LAB = [LABComp, LABComp, LABComp];
15 | export type OKLAB = [OKLABComp, OKLABComp, OKLABComp];
16 | export type Color = RGB | LRGB | HSV | LAB | OKLAB;
17 |
18 | export type Bits = number | [number, number, number];
19 |
20 | export interface Point {
21 | id?: number;
22 | color: HSV;
23 | pos: number;
24 | }
25 |
26 | export interface Options {
27 | steps: number;
28 | blendMode: string;
29 | ditherMode: string;
30 | ditherAmount?: number;
31 | shuffleCount?: number;
32 | target: TargetKey;
33 | }
34 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "references": [{ "path": "./tsconfig.node.json" }]
21 | }
22 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | build: {
8 | outDir: "build",
9 | sourcemap: true,
10 | },
11 | });
12 |
--------------------------------------------------------------------------------