├── .babelrc ├── jest.setup.js ├── __mocks__ ├── fileMock.js └── styleMock.js ├── next.config.js ├── components ├── Loading.js ├── App.module.css ├── CopyToClipboard.js ├── Notification.module.css ├── Presets.module.css ├── Bitmap.module.css ├── Icon.test.js ├── Icon.js ├── Notification.js ├── Presets.js ├── App.js └── Bitmap.js ├── .husky ├── pre-push └── pre-commit ├── public └── robot.png ├── .prettierrc.json ├── jsconfig.json ├── src ├── useForceUpdate.js ├── share-url-encoder.test.js ├── formatters.js ├── reducer.js ├── transforms.js └── share-url-encoder.js ├── .prettierignore ├── pages ├── index.js ├── share │ └── [...bitmapdata].js └── _app.js ├── .eslintrc.json ├── .gitignore ├── scripts └── generate-share-url-encoder-v2-data.js ├── README.md ├── jest.config.js ├── styles └── globals.css ├── package.json └── __fixtures__ └── share-url-encoder-v0.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"] 3 | } 4 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub' 2 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reactStrictMode: true, 3 | } 4 | -------------------------------------------------------------------------------- /components/Loading.js: -------------------------------------------------------------------------------- 1 | export const Loading = () =>

Loading...

2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn test 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /public/robot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cowboy/bitmap-code-generator/main/public/robot.png -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "arrowParens": "always", 5 | "proseWrap": "always" 6 | } 7 | -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | // Mock CSS files. 2 | // If you're using CSS modules, this file can be deleted. 3 | 4 | module.exports = {} 5 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "components/*": ["components/*"], 6 | "src/*": ["src/*"] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/useForceUpdate.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | // I'm so lazy 4 | export const useForceUpdate = () => { 5 | const [value, setValue] = React.useState(0) 6 | return () => setValue((value) => value + 1) 7 | } 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # next.js 10 | /.next/ 11 | /out/ 12 | 13 | # production 14 | /build 15 | 16 | # vercel 17 | .vercel 18 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import { App } from 'components/App' 2 | 3 | export async function getStaticProps() { 4 | return { 5 | props: { 6 | commitSha: process.env.VERCEL_GIT_COMMIT_SHA || false, 7 | }, 8 | } 9 | } 10 | 11 | export default App 12 | -------------------------------------------------------------------------------- /components/App.module.css: -------------------------------------------------------------------------------- 1 | .code { 2 | font-family: Consolas, monaco, monospace; 3 | } 4 | 5 | .url { 6 | width: 100%; 7 | } 8 | 9 | .horizontalRow { 10 | display: flex; 11 | align-items: center; 12 | } 13 | 14 | .horizontalRow > * { 15 | margin-bottom: 0.5em; 16 | } 17 | -------------------------------------------------------------------------------- /components/CopyToClipboard.js: -------------------------------------------------------------------------------- 1 | import { CopyToClipboard as CopyToClipboardLib } from 'react-copy-to-clipboard' 2 | import { Icon } from './Icon' 3 | 4 | export const CopyToClipboard = ({ text, titleText, onCopy }) => ( 5 | 6 | 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["next/core-web-vitals", "prettier"], 4 | "plugins": ["testing-library"], 5 | "overrides": [ 6 | // Only uses Testing Library lint rules in test files 7 | { 8 | "files": [ 9 | "**/__tests__/**/*.[jt]s?(x)", 10 | "**/?(*.)+(spec|test).[jt]s?(x)" 11 | ], 12 | "extends": ["plugin:testing-library/react"] 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /pages/share/[...bitmapdata].js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useRouter } from 'next/router' 3 | import { Loading } from 'components/Loading' 4 | 5 | export default function Share({ dispatch }) { 6 | const router = useRouter() 7 | const { bitmapdata } = router.query 8 | 9 | React.useEffect(() => { 10 | if (bitmapdata) { 11 | dispatch({ type: 'initialLoad', payload: bitmapdata.join('/') }) 12 | router.replace('/') 13 | } 14 | }, [dispatch, bitmapdata, router]) 15 | return 16 | } 17 | -------------------------------------------------------------------------------- /.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /components/Notification.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | transition: all ease-out 0.3s; 3 | position: fixed; 4 | display: flex; 5 | align-items: center; 6 | padding: 0 1em; 7 | top: 1em; 8 | right: 1em; 9 | border: 1px solid #000; 10 | border-radius: 5px; 11 | box-shadow: 3px 5px 5px #0004; 12 | cursor: pointer; 13 | min-height: 2.5em; 14 | } 15 | 16 | .root:empty { 17 | opacity: 0; 18 | right: -3em; 19 | transition: all 0s; 20 | } 21 | 22 | .icon { 23 | margin-left: -0.25em; 24 | margin-right: 0.5em; 25 | } 26 | 27 | .info { 28 | background: rgb(194, 242, 255); 29 | } 30 | 31 | .error { 32 | background: #faa; 33 | } 34 | -------------------------------------------------------------------------------- /components/Presets.module.css: -------------------------------------------------------------------------------- 1 | .bitmapButton { 2 | display: inline-flex; 3 | padding: 0; 4 | line-height: 1.6em; 5 | } 6 | 7 | .bitmapButton > * { 8 | transition: all 0.2s; 9 | color: #000; 10 | background: #fff; 11 | } 12 | 13 | .deleteText { 14 | padding: 0 0.5em; 15 | border-top-left-radius: 3px; 16 | border-bottom-left-radius: 3px; 17 | } 18 | 19 | .deleteText:hover { 20 | color: #fff; 21 | background: var(--button-hover-color); 22 | } 23 | 24 | .deleteIcon { 25 | font-size: 0.9em; 26 | padding: 0 0.3em; 27 | border-top-right-radius: 3px; 28 | border-bottom-right-radius: 3px; 29 | } 30 | 31 | .deleteIcon:hover { 32 | color: #fff; 33 | background: #f00; 34 | } 35 | -------------------------------------------------------------------------------- /components/Bitmap.module.css: -------------------------------------------------------------------------------- 1 | .table { 2 | border-collapse: collapse; 3 | border-spacing: 0; 4 | background: #000; 5 | } 6 | 7 | .row .cell { 8 | height: var(--cell-size); 9 | } 10 | 11 | .cell { 12 | width: var(--cell-size); 13 | min-width: var(--cell-size); 14 | background: #222; 15 | border: 1px solid #000; 16 | border-radius: calc(var(--cell-size) / 3); 17 | } 18 | 19 | .cell.on { 20 | background: #f00; 21 | } 22 | 23 | .buttonGroup { 24 | display: inline-block; 25 | margin-right: 1em; 26 | } 27 | 28 | .buttonGroup:last-child, 29 | .buttonGroup > *:last-child { 30 | margin-right: 0; 31 | } 32 | 33 | .buttonHeader { 34 | font-weight: 700; 35 | margin-right: 0.5em; 36 | } 37 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Head from 'next/head' 3 | import NoSSR from 'react-no-ssr' 4 | import { reducer } from 'src/reducer' 5 | import { Notification } from 'components/Notification' 6 | 7 | import '../styles/globals.css' 8 | 9 | function MyApp({ Component, pageProps }) { 10 | const [state, dispatch] = React.useReducer(reducer, {}) 11 | return ( 12 | 13 | 14 | Bitmap ⇔ Code Generator 15 | 16 | 17 |

Bitmap ⇔ Code Generator

18 | 19 | 20 |
21 | ) 22 | } 23 | 24 | export default MyApp 25 | -------------------------------------------------------------------------------- /scripts/generate-share-url-encoder-v2-data.js: -------------------------------------------------------------------------------- 1 | // node scripts/generate-share-url-encoder-v2-data.js > __fixtures__/share-url-encoder-v2.json 2 | 3 | const getRandomBitmapArray = (width, height) => 4 | Array.from({ length: height }, () => 5 | Array.from({ length: width }, () => Math.random() > 0.5) 6 | ) 7 | 8 | const bitmapArrayToBitmapStringArray = (arr) => 9 | arr.map((row) => row.map((s) => (s ? 'x' : ' ')).join('')) 10 | 11 | const size = 48 12 | const items = [] 13 | for (let height = 1; height <= size; height++) { 14 | for (let width = 1; width <= size; width++) { 15 | const bitmapArray = getRandomBitmapArray(width, height) 16 | const bitmapStringArray = bitmapArrayToBitmapStringArray(bitmapArray) 17 | items.push({ width, height, bitmapStringArray }) 18 | } 19 | } 20 | 21 | console.log(JSON.stringify(items, null, 2)) 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bitmap ⇔ Code Generator 2 | 3 | [![github pages](https://github.com/cowboy/bitmap-code-generator/actions/workflows/main.yml/badge.svg)](https://github.com/cowboy/bitmap-code-generator/actions/workflows/main.yml) 4 | 5 | Convert between bitmaps and code, with a GUI bitmap editor 6 | 7 | Suitable for use with [GyverMAX7219](https://github.com/GyverLibs/GyverMAX7219) 8 | and possibly other code or hardware that uses 8x8 bitmaps or LED matrixes 9 | 10 | ## Online Editor 11 | 12 | https://bitmap-code-generator.benalman.com/ 13 | 14 | ## Example 15 | 16 | A bitmap like this: 17 | 18 | ![](https://github.com/cowboy/bitmap-code-generator/blob/main/public/robot.png?raw=true) 19 | 20 | Becomes this C++ code: 21 | 22 | ```cpp 23 | const uint8_t robot[] PROGMEM = {0x42, 0x7e, 0x81, 0xa5, 0x81, 0x7e, 0x3c, 0xff}; 24 | ``` 25 | 26 | Or this JavaScript code: 27 | 28 | ```js 29 | const robot = [0x42, 0x7e, 0x81, 0xa5, 0x81, 0x7e, 0x3c, 0xff] 30 | ``` 31 | 32 | And vice-versa! 33 | -------------------------------------------------------------------------------- /components/Icon.test.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { render } from '@testing-library/react' 3 | import { stripIndents } from 'common-tags' 4 | 5 | import { Icon } from './Icon' 6 | 7 | const iconNames = stripIndents` 8 | fas:arrow-down 9 | fas:arrow-left 10 | fas:arrow-right 11 | fas:arrow-up 12 | far:clipboard 13 | fas:compress-arrows-alt 14 | fas:exclamation-triangle 15 | fas:expand-arrows-alt 16 | fas:info-circle 17 | far:save 18 | far:trash-alt 19 | `.split('\n') 20 | 21 | iconNames.forEach((iconName) => { 22 | const [prefix, icon] = iconName.split(':') 23 | it(`should render icon ${iconName}`, () => { 24 | /* eslint-disable testing-library/no-container, testing-library/no-node-access */ 25 | const { container } = render() 26 | const svg = container.querySelector('svg') 27 | expect(svg).toHaveAttribute('data-prefix', prefix) 28 | expect(svg).toHaveAttribute('data-icon', icon) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /components/Icon.js: -------------------------------------------------------------------------------- 1 | import { library } from '@fortawesome/fontawesome-svg-core' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | // import { fab } from '@fortawesome/free-brands-svg-icons' 4 | import { 5 | faArrowDown, 6 | faArrowLeft, 7 | faArrowRight, 8 | faArrowUp, 9 | faCompressArrowsAlt, 10 | faExclamationTriangle, 11 | faExpandArrowsAlt, 12 | faInfoCircle, 13 | } from '@fortawesome/free-solid-svg-icons' 14 | import { 15 | faClipboard, 16 | faSave, 17 | faTrashAlt, 18 | } from '@fortawesome/free-regular-svg-icons' 19 | 20 | library.add( 21 | faArrowDown, 22 | faArrowLeft, 23 | faArrowRight, 24 | faArrowUp, 25 | faClipboard, 26 | faCompressArrowsAlt, 27 | faExclamationTriangle, 28 | faExpandArrowsAlt, 29 | faInfoCircle, 30 | faSave, 31 | faTrashAlt 32 | ) 33 | 34 | export const Icon = ({ icon, ...props }) => { 35 | if (typeof icon === 'string') { 36 | icon = icon.split(':') 37 | } 38 | return 39 | } 40 | -------------------------------------------------------------------------------- /src/share-url-encoder.test.js: -------------------------------------------------------------------------------- 1 | import { getShareUrlData, getStateFromBitmapData } from './share-url-encoder' 2 | 3 | import v2fixtures from '../__fixtures__/share-url-encoder-v2.json' 4 | import v0fixtures from '../__fixtures__/share-url-encoder-v0' 5 | 6 | const bitmapStringArrayToBitmapArray = (arr) => 7 | arr.map((row) => row.split('').map((c) => c === 'x')) 8 | 9 | describe('v2', () => { 10 | v2fixtures.forEach(({ width, height, bitmapStringArray }) => { 11 | it(`should encode and decode a ${width}x${height} bitmapArray`, () => { 12 | const name = 'example_name' 13 | const bitmapArray = bitmapStringArrayToBitmapArray(bitmapStringArray) 14 | const encoded = getShareUrlData({ name, width, height, bitmapArray }) 15 | const decoded = getStateFromBitmapData(encoded) 16 | expect(decoded).toEqual({ name, width, height, bitmapArray }) 17 | }) 18 | }) 19 | }) 20 | 21 | describe('v0', () => { 22 | v0fixtures.forEach(({ name, bitmapStringArray, shareUrl }) => { 23 | it(`should decode the shareUrl for ${name} into the proper data`, () => { 24 | const decoded = getStateFromBitmapData(shareUrl) 25 | expect(decoded.name).toEqual(name) 26 | expect(decoded.bitmapArray).toEqual( 27 | bitmapStringArrayToBitmapArray(bitmapStringArray) 28 | ) 29 | }) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverageFrom: [ 3 | '**/*.{js,jsx,ts,tsx}', 4 | '!**/*.d.ts', 5 | '!**/node_modules/**', 6 | ], 7 | moduleNameMapper: { 8 | // Handle CSS imports (with CSS modules) 9 | // https://jestjs.io/docs/webpack#mocking-css-modules 10 | '^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy', 11 | 12 | // Handle CSS imports (without CSS modules) 13 | '^.+\\.(css|sass|scss)$': '/__mocks__/styleMock.js', 14 | 15 | // Handle image imports 16 | // https://jestjs.io/docs/webpack#handling-static-assets 17 | '^.+\\.(jpg|jpeg|png|gif|webp|avif|svg)$': `/__mocks__/fileMock.js`, 18 | 19 | // Handle module aliases 20 | '^components/(.*)$': '/components/$1', 21 | '^src/(.*)$': '/src/$1', 22 | }, 23 | setupFilesAfterEnv: ['/jest.setup.js'], 24 | testEnvironment: 'jsdom', 25 | testPathIgnorePatterns: ['/node_modules/', '/.next/'], 26 | transform: { 27 | // Use babel-jest to transpile tests with the next/babel preset 28 | // https://jestjs.io/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object 29 | '^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['next/babel'] }], 30 | }, 31 | transformIgnorePatterns: [ 32 | '/node_modules/', 33 | '^.+\\.module\\.(css|sass|scss)$', 34 | ], 35 | } 36 | -------------------------------------------------------------------------------- /components/Notification.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import cx from 'classnames' 3 | 4 | import styles from './Notification.module.css' 5 | import { Icon } from './Icon' 6 | 7 | export const Notification = ({ state, dispatch }) => { 8 | const [notificationChanged, setNotificationChanged] = React.useState(false) 9 | const clearNotification = React.useCallback( 10 | () => dispatch({ type: 'clearNotification' }), 11 | [dispatch] 12 | ) 13 | 14 | React.useEffect(() => { 15 | let id = setTimeout(clearNotification, 10000) 16 | setNotificationChanged(true) 17 | return () => { 18 | clearTimeout(id) 19 | } 20 | }, [dispatch, state.notification, clearNotification]) 21 | 22 | React.useEffect(() => { 23 | if (notificationChanged) { 24 | setNotificationChanged(false) 25 | } 26 | }, [notificationChanged, setNotificationChanged]) 27 | 28 | const { type = 'info', message } = 29 | (!notificationChanged && state.notification) || {} 30 | 31 | const icons = { 32 | info: 'fas:info-circle', 33 | error: 'fas:exclamation-triangle', 34 | } 35 | 36 | return ( 37 |
38 | {message && ( 39 | 40 | 41 | 42 | )} 43 | {message} 44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --button-color: #fff; 3 | --button-hover-color: rgb(25, 105, 209); 4 | } 5 | 6 | html, 7 | body { 8 | padding: 0; 9 | margin: 0; 10 | background: #eee; 11 | } 12 | 13 | html, 14 | body, 15 | button { 16 | font-family: 'Helvetica Neue', 'Arial Nova', Helvetica, Arial, sans-serif; 17 | } 18 | 19 | * { 20 | box-sizing: border-box; 21 | } 22 | 23 | body { 24 | padding: 1em; 25 | } 26 | 27 | a { 28 | display: inline-block; 29 | position: relative; 30 | left: 0; 31 | padding: 2px 4px; 32 | margin: -2px -4px; 33 | transition: all 0.2s; 34 | color: var(--button-hover-color); 35 | border-radius: 3px; 36 | } 37 | 38 | a:hover { 39 | color: #fff; 40 | background-color: var(--button-hover-color); 41 | } 42 | 43 | h1 { 44 | margin-top: 0; 45 | } 46 | 47 | h3 { 48 | margin: 0.5em 0 0.2em; 49 | font-size: 1.2em; 50 | } 51 | 52 | input { 53 | margin-right: 0.5em; 54 | height: 1.8em; 55 | } 56 | 57 | textarea { 58 | width: 100%; 59 | height: 150px; 60 | } 61 | 62 | button { 63 | transition: all 0.1s; 64 | margin: 0 0.5em 0.5em 0; 65 | border: 1px solid #777; 66 | border-radius: 3px; 67 | background: var(--button-color); 68 | padding: 0.1em 0.5em; 69 | line-height: 1.4em; 70 | /* min-width: 2em; */ 71 | white-space: nowrap; 72 | } 73 | 74 | button:hover { 75 | color: #fff; 76 | background: var(--button-hover-color); 77 | } 78 | 79 | button svg { 80 | position: relative; 81 | top: 1px; 82 | } 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bitmap-code-generator", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint && prettier --check .", 10 | "test": "yarn lint && yarn test:unit", 11 | "test:unit": "jest", 12 | "test:watch": "jest --watchAll", 13 | "prepare": "husky install" 14 | }, 15 | "dependencies": { 16 | "@fortawesome/fontawesome-svg-core": "^1.2.36", 17 | "@fortawesome/free-brands-svg-icons": "^5.15.4", 18 | "@fortawesome/free-regular-svg-icons": "^5.15.4", 19 | "@fortawesome/free-solid-svg-icons": "^5.15.4", 20 | "@fortawesome/react-fontawesome": "^0.1.15", 21 | "classnames": "^2.3.1", 22 | "common-tags": "^1.8.0", 23 | "js-base64": "^3.7.2", 24 | "next": "11.1.2", 25 | "react": "17.0.2", 26 | "react-copy-to-clipboard": "^5.0.4", 27 | "react-dom": "17.0.2", 28 | "react-favicon": "^0.0.23", 29 | "react-no-ssr": "^1.1.0", 30 | "store2": "^2.12.0" 31 | }, 32 | "devDependencies": { 33 | "@testing-library/jest-dom": "^5.14.1", 34 | "@testing-library/react": "^12.1.2", 35 | "@testing-library/user-event": "^13.4.1", 36 | "babel-jest": "^27.2.5", 37 | "eslint": "7.32.0", 38 | "eslint-config-next": "11.1.2", 39 | "eslint-config-prettier": "^8.3.0", 40 | "eslint-plugin-testing-library": "^4.12.4", 41 | "husky": "^7.0.0", 42 | "identity-obj-proxy": "^3.0.0", 43 | "jest": "^27.2.5", 44 | "lint-staged": "^11.2.3", 45 | "prettier": "2.4.1", 46 | "react-test-renderer": "^17.0.2", 47 | "webpack": "5" 48 | }, 49 | "lint-staged": { 50 | "**/*.js": [ 51 | "eslint --fix" 52 | ], 53 | "**/*": [ 54 | "prettier --write --ignore-unknown" 55 | ] 56 | }, 57 | "volta": { 58 | "node": "14.18.0", 59 | "yarn": "1.22.15" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/formatters.js: -------------------------------------------------------------------------------- 1 | import { roundUpToBlockSize } from './transforms' 2 | 3 | const bitmapArrayRowToNumber = (row) => 4 | row.reduce( 5 | (sum, pixel, i) => sum + (pixel ? Math.pow(2, row.length - i - 1) : 0), 6 | 0 7 | ) 8 | 9 | const getHexArray = (bitmapArray) => { 10 | const width = roundUpToBlockSize(getMaxRowSize(bitmapArray)) 11 | const hexDigits = (Math.pow(2, width) - 1).toString(16).length 12 | const hexNumber = (row) => { 13 | const n = bitmapArrayRowToNumber(row) 14 | const hexStr = `${'0'.repeat(hexDigits)}${n.toString(16)}`.slice(-hexDigits) 15 | return `0x${hexStr}` 16 | } 17 | return bitmapArray.map(hexNumber).join(', ') 18 | } 19 | 20 | // const getStrArray = (bitmapArray) => { 21 | // const binaryString = (row) => { 22 | // return `"${row.map((pixel) => (pixel ? '1' : '0')).join('')}"` 23 | // } 24 | // return `\n ${bitmapArray.map(binaryString).join(',\n ')}\n` 25 | // } 26 | 27 | // const cleanupCode = (code) => 28 | // code.replace(/\/\*[\s\S]*?\*\/|\/\/.*\n/g, '').replace(/[\s\n]+/g, ' ') 29 | 30 | const getMaxRowSize = (bitmapArray) => 31 | Math.max(...bitmapArray.map((row) => row.length)) 32 | 33 | export const defaultFormat = 'JavaScript' 34 | export const formatters = { 35 | 'C++ (AVR PROGMEM)': { 36 | fromCode: (code) => { 37 | const { groups } = 38 | /.*?(?[\w]+)\s*\[\][^=]*=\s*\{(?[^}]+)?\}?.*$/.exec( 39 | code 40 | ) || {} 41 | return groups 42 | }, 43 | toCode: ({ name, bitmapArray }) => { 44 | const size = getMaxRowSize(bitmapArray) 45 | const bitSize = [8, 16, 32, 64].find((n) => size <= n) 46 | if (!bitSize) { 47 | return `/* Unable to generate array because row data exceeds uint64_t size. */` 48 | } 49 | const arrayStr = getHexArray(bitmapArray) 50 | return `const uint${bitSize}_t ${name}[] PROGMEM = {${arrayStr}};` 51 | }, 52 | }, 53 | JavaScript: { 54 | fromCode: (code) => { 55 | const { groups } = 56 | /.*?(?[\w]+)\s*=\s*\[(?[^\]]+)?\]?.*$/.exec(code) || {} 57 | return groups 58 | }, 59 | toCode: ({ name, bitmapArray }) => { 60 | if (getMaxRowSize(bitmapArray) > 52) { 61 | return `/* Unable to generate array because row data exceeds Number.MAX_SAFE_INTEGER size. */` 62 | } 63 | const arrayStr = getHexArray(bitmapArray) 64 | return `const ${name} = [${arrayStr}]` 65 | }, 66 | }, 67 | } 68 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import { getDefaultPreset } from 'components/Presets' 2 | import { getStateFromBitmapData } from 'src/share-url-encoder' 3 | import { 4 | transform, 5 | codeToBitmapArray, 6 | getCode, 7 | getDimensions, 8 | getShareUrl, 9 | validateName, 10 | validateFormat, 11 | validateScale, 12 | printBitmapArray, 13 | printState, 14 | } from 'src/transforms' 15 | 16 | const getInitialData = (bitmapData) => { 17 | let notification 18 | if (bitmapData !== undefined) { 19 | const { error, name, bitmapArray } = getStateFromBitmapData(bitmapData) 20 | if (error) { 21 | notification = { type: 'error', message: error } 22 | } else { 23 | return { name, bitmapArray, loaded: true } 24 | } 25 | } 26 | return { ...getDefaultPreset(), notification, loaded: true } 27 | } 28 | 29 | const debugMode = 0 30 | const debugStateBitmap = debugMode 31 | ? transform(printState, printBitmapArray) 32 | : transform() 33 | const debugState = debugMode ? transform(printState) : transform() 34 | 35 | const actionHandlers = { 36 | notify(state, notification) { 37 | return { ...state, notification } 38 | }, 39 | clearNotification({ notification, ...state }) { 40 | return state 41 | }, 42 | scale(state, scale) { 43 | return transform(validateScale, debugState)({ ...state, scale }) 44 | }, 45 | bitmapArray(state, bitmapArray) { 46 | return transform( 47 | getDimensions, 48 | getCode, 49 | getShareUrl, 50 | debugStateBitmap 51 | )({ ...state, bitmapArray }) 52 | }, 53 | code(state, code) { 54 | return transform( 55 | codeToBitmapArray, 56 | getDimensions, 57 | getShareUrl, 58 | debugStateBitmap 59 | )({ ...state, code }) 60 | }, 61 | formatCode(state, code) { 62 | return transform(getCode, debugState)({ ...state, code }) 63 | }, 64 | name(state, name) { 65 | return transform( 66 | validateName, 67 | getCode, 68 | getShareUrl, 69 | debugState 70 | )({ ...state, name }) 71 | }, 72 | format(state, format) { 73 | return transform(validateFormat, getCode, debugState)({ ...state, format }) 74 | }, 75 | preset(state, { name, bitmapArray }) { 76 | return transform( 77 | getDimensions, 78 | getCode, 79 | getShareUrl, 80 | debugStateBitmap 81 | )({ ...state, name, bitmapArray }) 82 | }, 83 | initialLoad(state, bitmapData) { 84 | return transform( 85 | validateFormat, 86 | validateScale, 87 | getDimensions, 88 | getCode, 89 | getShareUrl, 90 | debugStateBitmap 91 | )(getInitialData(bitmapData)) 92 | }, 93 | } 94 | 95 | export const reducer = (state, { type, payload }) => { 96 | if (actionHandlers[type]) { 97 | return actionHandlers[type](state, payload) 98 | } else { 99 | console.error(`Unknown action type "${type}" with payload:`, payload) 100 | } 101 | return state 102 | } 103 | -------------------------------------------------------------------------------- /src/transforms.js: -------------------------------------------------------------------------------- 1 | import store from 'store2' 2 | import { defaultFormat, formatters } from './formatters' 3 | import { stringToBitmapArray } from 'components/Presets' 4 | import { getShareUrlData } from './share-url-encoder' 5 | 6 | const transformStore = store.namespace('transform') 7 | 8 | export const transform = 9 | (...transformations) => 10 | (state) => 11 | transformations.reduce( 12 | (acc, transformation) => ({ 13 | ...acc, 14 | ...transformation(acc), 15 | }), 16 | state 17 | ) 18 | 19 | export const blockSize = 8 20 | export const roundUpToBlockSize = (arg) => { 21 | const maxSize = Array.isArray(arg) ? Math.max(...arg) : arg 22 | return Math.ceil(maxSize / blockSize) * blockSize 23 | } 24 | export const roundDownToBlockSize = (arg) => { 25 | const maxSize = Array.isArray(arg) ? Math.max(...arg) : arg 26 | return Math.max(1, Math.floor(maxSize / blockSize)) * blockSize 27 | } 28 | 29 | export const getWidthFromBitmap = (bitmap) => { 30 | return roundUpToBlockSize(bitmap.split('\n').map((s) => s.length)) 31 | } 32 | 33 | export const getHeightFromBitmap = (bitmap) => { 34 | return roundUpToBlockSize(bitmap.split('\n').length) 35 | } 36 | 37 | export const getWidthFromArray = (array) => { 38 | return roundUpToBlockSize(array.map((n) => n.toString(2).length)) 39 | } 40 | 41 | export const getHeightFromArray = (array) => { 42 | return roundUpToBlockSize(array.length) 43 | } 44 | 45 | // State transformers 46 | 47 | export const codeToBitmapArray = ({ code, format }) => { 48 | const { 49 | name = '', 50 | arrayStr = '', 51 | bitmapArray = /\S/.test(arrayStr) ? stringToBitmapArray(arrayStr) : [], 52 | } = formatters[format].fromCode(code.trim()) || {} 53 | return { name, bitmapArray } 54 | } 55 | 56 | export const getCode = ({ name, bitmapArray, format }) => { 57 | const code = formatters[format].toCode({ name, bitmapArray }) 58 | return { code } 59 | } 60 | 61 | export const getDimensions = ({ bitmapArray }) => { 62 | const width = Math.max(...bitmapArray.map((row) => row.length)) 63 | const height = bitmapArray.length 64 | return { width, height } 65 | } 66 | 67 | export const getShareUrl = ({ name, width, height, bitmapArray }) => { 68 | const href = location.href.replace(/\/share\/.*/, '').replace(/\/$/, '') 69 | const data = getShareUrlData({ name, width, height, bitmapArray }) 70 | const shareUrl = `${href}/share/${data}` 71 | return { shareUrl } 72 | } 73 | 74 | // Property validators 75 | 76 | export const validateName = ({ name }) => { 77 | name = name.replace(/\W/g, '_') 78 | return { name } 79 | } 80 | 81 | export const validateFormat = ({ format = transformStore('format') }) => { 82 | if (!formatters[format]) { 83 | format = defaultFormat 84 | } 85 | transformStore('format', format) 86 | return { format } 87 | } 88 | 89 | const defaultScale = 1 90 | export const validateScale = ({ 91 | scale = transformStore('scale') || defaultScale, 92 | }) => { 93 | scale = Math.max(0.25, Math.min(scale, 2)) 94 | transformStore('scale', scale) 95 | return { scale } 96 | } 97 | 98 | // Debugging 99 | 100 | export const printBitmapArray = ({ bitmapArray }) => { 101 | const { length } = bitmapArray[0] 102 | const digit = (n) => n % 10 103 | console.log( 104 | [ 105 | ` ${Array.from({ length }, (_, i) => digit(i)).join('')}`, 106 | ...bitmapArray.map( 107 | (row, i) => 108 | `${digit(i)}|${row.map((pixel) => (pixel ? 'x' : ' ')).join('')}|` 109 | ), 110 | ].join('\n') 111 | ) 112 | } 113 | 114 | export const printState = (state) => { 115 | const replacer = (key, value) => { 116 | if (key === 'bitmapArray') { 117 | return '(omitted)' 118 | } 119 | return value 120 | } 121 | console.log(JSON.stringify(state, replacer, 2)) 122 | } 123 | -------------------------------------------------------------------------------- /components/Presets.js: -------------------------------------------------------------------------------- 1 | import store from 'store2' 2 | import { getShareUrlData, getStateFromBitmapData } from 'src/share-url-encoder' 3 | import { getWidthFromArray } from 'src/transforms' 4 | import { useForceUpdate } from 'src/useForceUpdate' 5 | 6 | import styles from './Presets.module.css' 7 | import { Icon } from './Icon' 8 | 9 | const bitmapStore = store.namespace('preset') 10 | 11 | const defaultPreset = 'robot' 12 | const presets = { 13 | robot: [0x42, 0x7e, 0x81, 0xa5, 0x81, 0x7e, 0x3c, 0xff], 14 | play: [0xff, 0x81, 0xb1, 0xbd, 0xbd, 0xb1, 0x81, 0xff], 15 | stop: [0xff, 0x81, 0xbd, 0xbd, 0xbd, 0xbd, 0x81, 0xff], 16 | ok: [0x65, 0x95, 0x95, 0x96, 0x96, 0x95, 0x95, 0x65], 17 | err: [0x3d, 0x42, 0x85, 0x89, 0x91, 0xa1, 0x42, 0xbc], 18 | number1: [0x06, 0x0e, 0x16, 0x06, 0x06, 0x06, 0x06, 0x1f], 19 | number2: [0x0e, 0x1b, 0x03, 0x03, 0x06, 0x0c, 0x18, 0x1f], 20 | number3: [0x1e, 0x03, 0x03, 0x0e, 0x03, 0x03, 0x03, 0x1e], 21 | number4: [0x03, 0x07, 0x0b, 0x1b, 0x1f, 0x03, 0x03, 0x03], 22 | } 23 | 24 | export const numberArrayToBitmapArray = (numberArray) => { 25 | const width = getWidthFromArray(numberArray) 26 | const bitmapArray = numberArray.map((n) => { 27 | const arr = n 28 | .toString(2) 29 | .split('') 30 | .map((char) => char === '1') 31 | const length = width - arr.length 32 | return [...Array.from({ length }, () => false), ...arr] 33 | }) 34 | return bitmapArray 35 | } 36 | 37 | export const stringToBitmapArray = (arrayStr) => { 38 | return numberArrayToBitmapArray( 39 | arrayStr 40 | .split(/\s*,\s*/) 41 | .filter((s, i, { length }) => !(s === '' && i === length - 1)) 42 | .map((s) => (s === '' ? 0 : parseInt(s))) 43 | .map((n) => (isNaN(n) ? 0 : n)) 44 | ) 45 | } 46 | 47 | export const getPreset = (name) => { 48 | const bitmapArray = numberArrayToBitmapArray(presets[name]) 49 | return { name, bitmapArray } 50 | } 51 | 52 | export const getDefaultPreset = () => getPreset(defaultPreset) 53 | 54 | export const getSavedBitmap = (name) => { 55 | const data = bitmapStore(name) 56 | // v0 57 | if (Array.isArray(data)) { 58 | const bitmapArray = numberArrayToBitmapArray(data) 59 | return { name, bitmapArray } 60 | } 61 | const { bitmapArray } = getStateFromBitmapData(data) 62 | return { name, bitmapArray } 63 | } 64 | 65 | export const saveBitmap = ({ name, width, height, bitmapArray }) => { 66 | if (bitmapStore.has(name)) { 67 | if (!confirm(`Replace local bitmap "${name}"?`)) { 68 | return false 69 | } 70 | } 71 | bitmapStore(name, getShareUrlData({ width, height, bitmapArray })) 72 | return true 73 | } 74 | 75 | export const deletePreset = (name) => bitmapStore.remove(name) 76 | 77 | export const Presets = ({ onClick }) => { 78 | const update = useForceUpdate() 79 | 80 | const deleteBitmap = (name) => (event) => { 81 | event.stopPropagation() 82 | if (confirm(`Delete local bitmap "${name}"?`)) { 83 | deletePreset(name) 84 | update() 85 | } 86 | } 87 | const loadBitmap = (name) => () => { 88 | onClick(getSavedBitmap(name)) 89 | } 90 | const loadPreset = (name) => () => { 91 | onClick(getPreset(name)) 92 | } 93 | 94 | return ( 95 |
96 | {bitmapStore.size() > 0 && ( 97 | <> 98 |

Your saved bitmaps

99 | {Object.keys(bitmapStore()) 100 | .sort() 101 | .map((name) => ( 102 | 115 | ))} 116 | 117 | )} 118 |

Example bitmaps

119 | {Object.keys(presets).map((name) => ( 120 | 123 | ))} 124 |
125 | ) 126 | } 127 | -------------------------------------------------------------------------------- /components/App.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { useForceUpdate } from 'src/useForceUpdate' 4 | import { formatters } from 'src/formatters' 5 | import { Presets, saveBitmap } from 'components/Presets' 6 | import { Loading } from 'components/Loading' 7 | import { Bitmap } from 'components/Bitmap' 8 | import { CopyToClipboard } from 'components/CopyToClipboard' 9 | import { Icon } from 'components/Icon' 10 | 11 | import styles from './App.module.css' 12 | 13 | const GITHUB_URL = 'https://github.com/cowboy/bitmap-code-generator' 14 | 15 | export const App = ({ state, dispatch, commitSha }) => { 16 | const update = useForceUpdate() 17 | 18 | React.useEffect(() => { 19 | if (!state.loaded) { 20 | dispatch({ type: 'initialLoad' }) 21 | } 22 | }, [dispatch, state.loaded]) 23 | 24 | if (!state.loaded) { 25 | return 26 | } 27 | 28 | const handleEventChange = (type) => (event) => { 29 | const payload = event.target.value 30 | dispatch({ type, payload }) 31 | } 32 | 33 | const handleChange = (type) => (payload) => { 34 | dispatch({ type, payload }) 35 | } 36 | 37 | const save = () => { 38 | if (saveBitmap(state)) { 39 | update() 40 | } 41 | } 42 | 43 | const notifyClipboard = (msg) => () => { 44 | dispatch({ 45 | type: 'notify', 46 | payload: { message: `${msg} copied to clipboard` }, 47 | }) 48 | } 49 | 50 | return ( 51 | <> 52 |
53 |

54 | 55 |

56 | 62 | 65 |
66 |

Bitmap

67 | 72 |
73 |

74 | 75 |

76 |
77 | 83 | 88 |
89 |
90 |
91 |

92 | 93 |

94 | 105 |
106 |
107 |

108 | {' '} 109 | 114 |

115 |