├── .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 | ![screenshot](docs/screenshot.png) 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 | 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 | 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, "$1") 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 |     
75 |
76 |
77 | Point:{" "} 78 | {" "} 85 | {selectedIndex + 1}/{points.length}{" "} 86 | 93 |
94 | {points.length > 2 && ( 95 | 102 | )} 103 |
104 |
105 |
106 |
107 | 108 | { 116 | const newHex = e.target.value; 117 | setHex(newHex); 118 | if (!newHex || e.target.validity.patternMismatch) { 119 | return; 120 | } 121 | if (depth <= 4) { 122 | const newRgb = decodeHex3(newHex); 123 | dispatch(setColor(rgbToHsv(restoreBits(newRgb, depth)))); 124 | } else { 125 | const newRgb = decodeHex6(newHex); 126 | dispatch(setColor(rgbToHsv(newRgb))); 127 | } 128 | }} 129 | /> 130 |
131 |
132 | 133 | handleMove(parseInt(e.target.value))} 140 | /> 141 |
142 |
143 | 148 |
149 |
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 |
27 | 28 | Pouet 29 |
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 |
105 |
106 | {interlaced && ( 107 | 115 | )} 116 |
117 | 118 |
119 | ×{" "} 120 | setScale(parseInt(e.target.value))} 128 | />{" "} 129 | 137 |
138 |
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 | 35 |
36 | 44 | 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 | 26 | dispatch(setSteps(parseInt(e.target.value)))} 33 | /> 34 |
35 | 36 |
37 | 38 | 48 |
49 | 50 |
51 | 52 | 63 |
64 | 65 |
66 | 67 | 84 |
85 | 86 | {!["off", "shuffle"].includes(ditherMode) && ( 87 |
88 | 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 | 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 | {" "} 60 | 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 | 196 | setRowSize(parseInt(e.currentTarget.value))} 203 | /> 204 |
205 | {target.interlaced ? ( 206 |
207 |
208 | 209 | setVarNameA(e.target.value)} 214 | /> 215 |
216 |
217 | 218 | setVarNameB(e.target.value)} 223 | /> 224 |
225 |
226 | ) : ( 227 |
228 | 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 |
343 |
344 | 345 | setStartLine(parseInt(e.target.value, 16))} 350 | /> 351 |
352 |
353 | 354 | setColorIndex(parseInt(e.target.value))} 361 | /> 362 |
363 |
364 | 372 |
373 | 381 |
382 | {target.interlaced ? ( 383 |
384 |
385 | 386 | setVarNameA(e.target.value)} 391 | /> 392 |
393 |
394 | 395 | setVarNameB(e.target.value)} 400 | /> 401 |
402 |
403 | ) : ( 404 |
405 | 406 | setVarName(e.target.value)} 411 | /> 412 |
413 | )} 414 |
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 | 460 | 468 | 469 | 470 | 473 | setRepeat(parseInt(e.target.value))} 480 | /> 481 | 482 | 485 |
486 | 487 | ); 488 | } 489 | 490 | interface CopyLinkProps { 491 | code: string; 492 | } 493 | 494 | function CopyLink({ code }: CopyLinkProps) { 495 | return ( 496 | 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 | 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 |
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 |
157 | 163 |
167 |
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 |
224 | 230 |
231 |
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 | --------------------------------------------------------------------------------