├── .eslintrc.js ├── .github ├── FUNDING.yml └── workflows │ ├── npmRunDeploy.yml │ └── npmTest.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── CNAME ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── NOTICE ├── README.md ├── TODO.md ├── babel.config.json ├── config └── jest │ ├── cssTransform.js │ └── fileTransform.js ├── docker-compose.yml ├── favicon.ico ├── index.html ├── jest.config.js ├── package-lock.json ├── package.json ├── public └── fonts │ ├── BubblegumSans-Regular.ttf │ ├── EBGaramond-Regular.ttf │ ├── HoltwoodOneSC-Regular.ttf │ ├── LICENSE │ ├── Lobster-Regular.ttf │ ├── Montserrat-Bold.ttf │ ├── MountainsofChristmas-Regular.ttf │ ├── NotoEmoji-VariableFont_wght.ttf │ ├── OpenSans-Regular.ttf │ ├── Roboto-Black.ttf │ └── RougeScript-Regular.ttf ├── src ├── common │ ├── Graph.js │ ├── Model.js │ ├── PriorityQueue.js │ ├── colors.js │ ├── debugging.js │ ├── eulerian_trail │ │ ├── LICENSE │ │ └── eulerianTrail.js │ ├── geometry.js │ ├── hooks │ │ └── useKeyPress.js │ ├── lindenmayer.js │ ├── localStorage.js │ ├── mocks.js │ ├── noise.js │ ├── proximity.js │ ├── slice.js │ ├── util.js │ └── voronoi.js ├── components │ ├── CheckboxOption.js │ ├── DropdownOption.js │ ├── InputOption.js │ ├── ModelOption.js │ ├── QuadrantButtonsOption.js │ ├── SliderOption.js │ └── ToggleButtonOption.js ├── features │ ├── app │ │ ├── About.js │ │ ├── About.scss │ │ ├── App.js │ │ ├── App.scss │ │ ├── App.test.js │ │ ├── ErrorFallback.js │ │ ├── Header.js │ │ ├── Header.scss │ │ ├── LICENSE │ │ ├── Notice.js │ │ ├── Notice.scss │ │ ├── Sidebar.js │ │ ├── appSlice.js │ │ ├── bootstrap.scss │ │ ├── happy-holidays.svg │ │ ├── koch-cube-flowers.svg │ │ ├── logo.svg │ │ ├── perlin-rings.svg │ │ ├── reactGA.js │ │ ├── rootSlice.js │ │ └── store.js │ ├── effects │ │ ├── CopyEffect.js │ │ ├── Effect.js │ │ ├── EffectEditor.js │ │ ├── EffectLayer.js │ │ ├── EffectList.js │ │ ├── EffectManager.js │ │ ├── FineTuning.js │ │ ├── Fisheye.js │ │ ├── Loop.js │ │ ├── Mask.js │ │ ├── NewEffect.js │ │ ├── ProgramCode.js │ │ ├── Track.js │ │ ├── Transformer.js │ │ ├── Voronoi.js │ │ ├── Warp.js │ │ ├── effectFactory.js │ │ ├── effectsSlice.js │ │ ├── effectsSlice.spec.js │ │ └── noise │ │ │ ├── LICENSE │ │ │ └── Noise.js │ ├── export │ │ ├── ExportDownloader.js │ │ ├── Exporter.js │ │ ├── GCodeExporter.js │ │ ├── ScaraGCodeExporter.js │ │ ├── SvgExporter.js │ │ ├── ThetaRhoExporter.js │ │ ├── exporterSlice.js │ │ └── exporterSlice.spec.js │ ├── file │ │ ├── SandifyDownloader.js │ │ ├── SandifyExporter.js │ │ ├── SandifyImporter.js │ │ ├── SandifyUploader.js │ │ └── fileSlice.js │ ├── fonts │ │ └── fontsSlice.js │ ├── groups │ │ └── Group.js │ ├── images │ │ ├── imagesSlice.js │ │ └── imagesSlice.spec.js │ ├── import │ │ ├── GCodeImporter.js │ │ ├── ImageUploader.js │ │ ├── Importer.js │ │ ├── LayerUploader.js │ │ ├── ThetaRhoImporter.js │ │ └── testArcs.gcode │ ├── layers │ │ ├── CopyLayer.js │ │ ├── Layer.js │ │ ├── LayerEditor.js │ │ ├── LayerList.js │ │ ├── LayerManager.js │ │ ├── LayerManager.scss │ │ ├── NewLayer.js │ │ ├── layersSlice.js │ │ └── layersSlice.spec.js │ ├── machines │ │ ├── CopyMachine.js │ │ ├── Machine.js │ │ ├── MachineEditor.js │ │ ├── MachineList.js │ │ ├── MachineManager.js │ │ ├── NewMachine.js │ │ ├── PolarInvertedMachine.js │ │ ├── PolarMachine.js │ │ ├── RectInvertedMachine.js │ │ ├── RectMachine.js │ │ ├── machineFactory.js │ │ ├── machinesSlice.js │ │ ├── machinesSlice.spec.js │ │ ├── rectMachine.spec.js │ │ └── util.js │ ├── preview │ │ ├── ConnectorPreview.js │ │ ├── EffectPreview.js │ │ ├── PreviewHelper.js │ │ ├── PreviewManager.js │ │ ├── PreviewManager.scss │ │ ├── PreviewStats.js │ │ ├── PreviewWindow.js │ │ ├── ShapePreview.js │ │ ├── previewSlice.js │ │ └── previewSlice.spec.js │ └── shapes │ │ ├── Circle.js │ │ ├── Epicycloid.js │ │ ├── FancyText.js │ │ ├── Freeform.js │ │ ├── Heart.js │ │ ├── Hypocycloid.js │ │ ├── LayerImport.js │ │ ├── NoiseWave.js │ │ ├── Point.js │ │ ├── Polygon.js │ │ ├── Reuleaux.js │ │ ├── Rose.js │ │ ├── Shape.js │ │ ├── Star.js │ │ ├── Voronoi.js │ │ ├── Wiper.js │ │ ├── circle_packer │ │ ├── Circle.js │ │ └── CirclePacker.js │ │ ├── fractal_spirograph │ │ ├── FractalSpirograph.js │ │ └── Orbit.js │ │ ├── image_import │ │ ├── ImageImport.js │ │ ├── LICENSE │ │ ├── helpers.js │ │ ├── polyspiral.js │ │ ├── sawtooth.js │ │ ├── spiral.js │ │ ├── springs.js │ │ ├── squiggle.js │ │ ├── subtypes.js │ │ └── waves.js │ │ ├── input_text │ │ ├── Fonts.js │ │ ├── InputText.js │ │ ├── convert_letters.py │ │ ├── raysol_cursive.js │ │ ├── raysol_cursive.txt │ │ ├── raysol_sanserif.js │ │ └── raysol_sanserif.txt │ │ ├── lsystem │ │ ├── LSystem.js │ │ └── subtypes.js │ │ ├── shapeFactory.js │ │ ├── space_filler │ │ ├── SpaceFiller.js │ │ └── subtypes.js │ │ ├── tessellation_twist │ │ ├── LICENSE │ │ └── TessellationTwist.js │ │ └── v1_engineering │ │ ├── V1Engineering.js │ │ └── Vicious1Vertices.js ├── index.css ├── index.js └── setupTests.js └── vite.config.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: jeffeb3 # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: jeffeb3 # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/npmRunDeploy.yml: -------------------------------------------------------------------------------- 1 | # Deploy when there is a new release "Published". This will take each new release and deploy it to 2 | # gh-pages. 3 | # 4 | # Guide: 5 | # https://medium.com/@cmichel/how-to-deploy-a-create-react-app-with-github-actions-5e01f7a7b6b 6 | 7 | name: Deployment 8 | 9 | on: 10 | release: 11 | types: [published] 12 | 13 | jobs: 14 | deploy: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Use Node.js 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: '20.x' 24 | - name: Install Packages 25 | run: npm install 26 | - name: Build page 27 | run: npm run build --if-present 28 | - name: Deploy to gh-pages 29 | uses: peaceiris/actions-gh-pages@v3 30 | with: 31 | deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }} 32 | publish_dir: ./build 33 | cname: sandify.org 34 | -------------------------------------------------------------------------------- /.github/workflows/npmTest.yml: -------------------------------------------------------------------------------- 1 | # Run the npm tests on every push, which automatically tests every PR. 2 | # 3 | # If you want a feature to stay working, then make a test, and other coders will notice when it 4 | # breaks. 5 | # -- Sandify testing strategy. 6 | # 7 | # Guide for this action workflow: 8 | # https://help.github.com/en/actions/language-and-framework-guides/using-nodejs-with-github-actions 9 | name: npm test 10 | 11 | on: [push, pull_request] 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [20.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm install 28 | - run: npm run build --if-present 29 | - run: npm test 30 | - run: npm run lint-ci 31 | env: 32 | CI: true 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | tmp 4 | .eslintcache 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.6.1 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | tmp 4 | .eslintcache 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleAttributePerLine": true 4 | } 5 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | sandify.org 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## How to contribute 2 | 3 | Sandify grows from community contributions. We'd love to collaborate with you. Here are some ways we 4 | can improve together: 5 | 6 | ### Share Patterns 7 | 8 | Even though we've spent a lot of time developing sandify, the patterns it can create suprise even 9 | us. If you have used sandify to make a pattern, please share it, and share how it was done. We get 10 | motivated seeing the impacts. Other users get inspired. 11 | 12 | ### Report Issues 13 | 14 | Report bugs, feature requests, errors in the [issue 15 | tracker](https://github.com/jeffeb3/sandify/issues). We can't test every scenario in every release 16 | on every system. Many of the best ideas for features have come from issue requests. 17 | 18 | ### Contribute Code 19 | 20 | Sandify has never been a one person effort. @jeffeb3 owns the project and guides the effort, but 21 | most of the code has been written and rewritten by contributors. It is a pleasure to 22 | work on, and we welcome collaboration. 23 | 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile for development of sandify 2 | 3 | FROM node:20.18.1 4 | 5 | RUN npm install -g npm 6 | #RUN npm install -g 7 | 8 | RUN mkdir /srv/app && chown node:node /srv/app 9 | 10 | #COPY . /srv/app 11 | 12 | USER node 13 | 14 | WORKDIR /srv/app 15 | 16 | CMD [ "npm", "start" ] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Jeff Eberl 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 | 23 | --- 24 | 25 | Note: Logo files located in the "src/features/app" directory are not covered under 26 | this license. See LICENSE in that directory for details. 27 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | NOTICE 2 | 3 | Sandify incorporates code and font assets from various external sources, 4 | each of may be covered by its own license and copyright notice. Where applicable, 5 | libraries and their licenses are included in the "node_modules" 6 | directory as part of the standard npm installation process. Additional licensing 7 | information can be found, when applicable, in subdirectory LICENSE files and in source 8 | files. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Sandify 2 | 3 | Sandify turns your cold, empty-hearted, emotionless sand tables into cold, empty-hearted, emotionless sand table robots with enchanting patterns. 4 | 5 | [![Build Status](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fjeffeb3%2Fsandify%2Fbadge&style=for-the-badge)](https://actions-badge.atrox.dev/jeffeb3/sandify/goto) 6 | 7 | Sandify outputs code you can run on your sand table. The code produced is: 8 | - Continuous, since the ball can't lift to travel to another location. 9 | - Bounded to never exceed machine limits, as the firmware isn't typically smart enough to handle. 10 | 11 | Sandify supports a large number of shape types and effects, so be creative! Input is welcome from the community. 12 | 13 | ## Using Sandify 14 | 15 | - Head over to [sandify.org](https://sandify.org). 16 | - Adjust the machine limits to match your table. 17 | - Add shapes and effects until you create a pleasing pattern. 18 | - Export the code in a format supported by your table. 19 | 20 | ## More info 21 | 22 | - Check out the [wiki](https://github.com/jeffeb3/sandify/wiki) for lots of details on features. 23 | - Read about how Sandify [came into being](https://forum.v1engineering.com/t/does-this-count-as-a-build/6037?u=jeffeb3). 24 | 25 | ## Running from source locally 26 | 27 | - [Install npm and node.js](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm). 28 | - Clone the Sandify repository, and change to that directory. 29 | - `npm install` 30 | - `npm start` 31 | - View in your browser at http://127.0.0.1:3000 or http://localhost:3000. 32 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ### TODO FOR RELEASE 2 | 3 | - bug: wiper, 90 degrees with noise effect; change wiper size from 4 to 40, hangs browser 4 | - bug: edge-case optimization of pattern with inverted mask is adding a center point within the mask; workaround is to enable "minimize perimeter moves", but this isn't user-friendly obviously 5 | 6 | ### FUTURE CONSIDERATION 7 | 8 | - refactor slider so it's precise like fine tuning; support Shift key as well 9 | - use react-router-dom for routes so browser back button works with tabs 10 | - groups 11 | - selectLayersByGroupId - some kind of compound parent key "[a]-[b]" 12 | - big thunk which just changes layer dimensions 13 | - if it has effects, can render those via selector 14 | - store pattern name and other desired attribution (?) in exported file and display it somewhere (either stats tab or in the preview window) 15 | - show pattern start/end type (e.g., 1-0) if start/end if specified 16 | - new fine tuning setting: when backtracking at end, optionally ignore border if enabled 17 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"], 3 | "plugins": [ 4 | ["@babel/transform-runtime"] 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /config/jest/cssTransform.js: -------------------------------------------------------------------------------- 1 | // @remove-on-eject-begin 2 | /** 3 | * Copyright (c) 2014-present, Facebook, Inc. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | */ 8 | // @remove-on-eject-end 9 | 'use strict' 10 | 11 | // This is a custom Jest transformer turning style imports into empty objects. 12 | // http://facebook.github.io/jest/docs/en/webpack.html 13 | 14 | module.exports = { 15 | process() { 16 | return 'module.exports = {}' 17 | }, 18 | getCacheKey() { 19 | // The output is always the same. 20 | return 'cssTransform' 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /config/jest/fileTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const camelcase = require('camelcase') 5 | 6 | // This is a custom Jest transformer turning file imports into filenames. 7 | // http://facebook.github.io/jest/docs/en/webpack.html 8 | 9 | module.exports = { 10 | process(src, filename) { 11 | const assetFilename = JSON.stringify(path.basename(filename)) 12 | 13 | if (filename.match(/\.svg$/)) { 14 | // Based on how SVGR generates a component name: 15 | // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 16 | const pascalCaseFilename = camelcase(path.parse(filename).name, { 17 | pascalCase: true, 18 | }) 19 | const componentName = `Svg${pascalCaseFilename}` 20 | const code = `const React = require('react') 21 | module.exports = { 22 | __esModule: true, 23 | default: ${assetFilename}, 24 | ReactComponent: React.forwardRef(function ${componentName}(props, ref) { 25 | return { 26 | $$typeof: Symbol.for('react.element'), 27 | type: 'svg', 28 | ref: ref, 29 | key: null, 30 | props: Object.assign({}, props, { 31 | children: ${assetFilename} 32 | }) 33 | } 34 | }), 35 | }` 36 | return { 37 | code 38 | } 39 | } 40 | 41 | return `module.exports = ${assetFilename}` 42 | }, 43 | } 44 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | # build with: 3 | # docker-compose build sandify 4 | # 5 | # install/update with: 6 | # docker-compose run --entrypoint "npm install" sandify 7 | # 8 | # run with: 9 | # docker-compose run sandify 10 | # 11 | # test with: 12 | # docker-compose run --entrypoint "npm test" sandify 13 | # 14 | version: '3.7' 15 | 16 | services: 17 | sandify: 18 | image: sandify 19 | build: . 20 | container_name: sandify 21 | ports: 22 | - "3000:3000" 23 | volumes: 24 | - .:/srv/app 25 | network_mode: "host" 26 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/favicon.ico -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Sandify 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sandify", 3 | "homepage": "https://sandify.org", 4 | "version": "1.1.1", 5 | "private": true, 6 | "dependencies": { 7 | "@dnd-kit/core": "^6.1.0", 8 | "@dnd-kit/modifiers": "^7.0", 9 | "@dnd-kit/sortable": "^8.0", 10 | "@reduxjs/toolkit": "^2.2.4", 11 | "array-move": "^4.0.0", 12 | "bootstrap": "^5.3.3", 13 | "buffer": "^6.0.3", 14 | "canvas": "^2.11.2", 15 | "color": "^4.2.3", 16 | "convexhull-js": "^1.0.0", 17 | "core-js": "^3.37.0", 18 | "curve-matcher": "^1.1.1", 19 | "d3": "^7.9", 20 | "d3-delaunay": "^6.0.4", 21 | "d3-fisheye": "^2.0.1", 22 | "gcode-toolpath": "^2.2.0", 23 | "geokdbush": "^1.1.0", 24 | "javascript-algorithms": "0.0.5", 25 | "kdbush": "^4.0.2", 26 | "konva": "^9.2.0", 27 | "liang-barsky": "^1.0.4", 28 | "lodash": "^4.17.21", 29 | "lru-cache": "^10.2.2", 30 | "mathjs": "^12.4.2", 31 | "opentype.js": "^1.3.4", 32 | "point-in-polygon": "^1.1.0", 33 | "points-on-path": "^0.2.1", 34 | "poisson-disk-sampling": "^2.3.1", 35 | "rc-slider": "^10.6.2", 36 | "re-reselect": "^5.1.0", 37 | "react": "18.3", 38 | "react-bootstrap": "^2.10.2", 39 | "react-dom": "^18.3.1", 40 | "react-error-boundary": "^4.0.13", 41 | "react-ga4": "^2.1.0", 42 | "react-icons": "^5.2.1", 43 | "react-konva": "^18.2.10", 44 | "react-redux": "^9.1.2", 45 | "react-select": "^5.8.0", 46 | "react-switch": "^7.0.0", 47 | "react-toastify": "^10.0.5", 48 | "react-tooltip": "^5.26.4", 49 | "reduce-reducers": "^1.0.4", 50 | "redux": "^5.0.1", 51 | "redux-mock-store": "^1.5.4", 52 | "reselect": "^5.1.0", 53 | "sass": "^1.77.1", 54 | "seedrandom": "^3.0.5", 55 | "uuid": "^9.0.0", 56 | "victor": "^1.1.0" 57 | }, 58 | "devDependencies": { 59 | "@babel/eslint-parser": "^7.24.5", 60 | "@babel/plugin-transform-runtime": "^7.24.3", 61 | "@babel/preset-env": "^7.24.5", 62 | "@babel/preset-react": "^7.24.1", 63 | "@esbuild-plugins/node-globals-polyfill": "^0.2.3", 64 | "@typescript-eslint/eslint-plugin": "^7.8.0", 65 | "@typescript-eslint/parser": "^7.8.0", 66 | "@vitejs/plugin-react": "^4.2.1", 67 | "babel-jest": "^29.6.4", 68 | "eslint": "^8.57.0", 69 | "eslint-config-prettier": "^9.1.0", 70 | "eslint-plugin-jest": "^28.5.0", 71 | "eslint-plugin-prettier": "^5.1.3", 72 | "eslint-plugin-react": "^7.34.1", 73 | "eslint-plugin-react-redux": "^4.1.0", 74 | "gh-pages": "^6.1.0", 75 | "identity-obj-proxy": "^3.0.0", 76 | "jest": "^29.6.4", 77 | "jest-canvas-mock": "^2.3.1", 78 | "jest-environment-jsdom": "^29.6.4", 79 | "prettier": "3.2.5", 80 | "vite": "^4.5.3", 81 | "vite-plugin-node-polyfills": "^0.21.0" 82 | }, 83 | "scripts": { 84 | "start": "vite", 85 | "build": "vite build", 86 | "preview": "vite preview", 87 | "deploy": "gh-pages -d build", 88 | "test": "jest", 89 | "lint": "eslint --fix --ext .js --ignore-path .gitignore src", 90 | "lint-ci": "eslint --ext .js --ignore-path .gitignore src" 91 | }, 92 | "browserslist": [ 93 | ">0.2%", 94 | "not dead", 95 | "not ie <= 11", 96 | "not op_mini all" 97 | ] 98 | } 99 | -------------------------------------------------------------------------------- /public/fonts/BubblegumSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/BubblegumSans-Regular.ttf -------------------------------------------------------------------------------- /public/fonts/EBGaramond-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/EBGaramond-Regular.ttf -------------------------------------------------------------------------------- /public/fonts/HoltwoodOneSC-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/HoltwoodOneSC-Regular.ttf -------------------------------------------------------------------------------- /public/fonts/Lobster-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/Lobster-Regular.ttf -------------------------------------------------------------------------------- /public/fonts/Montserrat-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/Montserrat-Bold.ttf -------------------------------------------------------------------------------- /public/fonts/MountainsofChristmas-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/MountainsofChristmas-Regular.ttf -------------------------------------------------------------------------------- /public/fonts/NotoEmoji-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/NotoEmoji-VariableFont_wght.ttf -------------------------------------------------------------------------------- /public/fonts/OpenSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/OpenSans-Regular.ttf -------------------------------------------------------------------------------- /public/fonts/Roboto-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/Roboto-Black.ttf -------------------------------------------------------------------------------- /public/fonts/RougeScript-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/RougeScript-Regular.ttf -------------------------------------------------------------------------------- /src/common/Model.js: -------------------------------------------------------------------------------- 1 | import { functionValue } from "@/common/util" 2 | 3 | const options = [] 4 | 5 | export default class Model { 6 | constructor(type, state) { 7 | this.type = type 8 | this.state = state 9 | 10 | Object.assign(this, { 11 | usesMachine: false, 12 | usesFonts: false, 13 | dragging: false, 14 | stretch: false, 15 | randomizable: true, 16 | }) 17 | } 18 | 19 | // override as needed 20 | canChangeSize(state) { 21 | return true 22 | } 23 | 24 | // override as needed 25 | canChangeAspectRatio(state) { 26 | return this.canChangeSize(state) 27 | } 28 | 29 | // override as needed 30 | canRotate(state) { 31 | return true 32 | } 33 | 34 | // override as needed 35 | canMove(state) { 36 | return true 37 | } 38 | 39 | canTransform(state) { 40 | return ( 41 | this.canMove(state) || this.canRotate(state) || this.canChangeSize(state) 42 | ) 43 | } 44 | 45 | // override as needed; redux state of a newly created instance 46 | getInitialState() { 47 | return {} 48 | } 49 | 50 | getOptions() { 51 | return options 52 | } 53 | 54 | randomChanges(layer, exclude = []) { 55 | const changes = { id: layer.id } 56 | const options = this.getOptions() 57 | 58 | Object.keys(options).forEach((key) => { 59 | if (!exclude.includes(key)) { 60 | const change = this.randomChange(key, layer, options) 61 | 62 | if (change != null) { 63 | changes[key] = change 64 | } 65 | } 66 | }) 67 | 68 | return changes 69 | } 70 | 71 | // given an option key, make a random change based on model options 72 | randomChange(key, layer, options) { 73 | const settings = options[key] 74 | const random = settings.random == null ? 1 : settings.random 75 | const randomize = Math.random() <= random 76 | const type = settings.type 77 | 78 | if (type == "checkbox") { 79 | const defaults = this.getInitialState() 80 | let value = defaults[key] 81 | 82 | if (random && Math.random() <= 0.5) { 83 | value = !value 84 | } 85 | 86 | return value 87 | } else if (type == "togglebutton" || type == "dropdown") { 88 | let choices = functionValue(settings.choices, layer) 89 | const choice = Math.floor(Math.random() * choices.length) 90 | 91 | return choices[choice] 92 | } else if (type == "number" || type == null) { 93 | const defaults = this.getInitialState() 94 | let min = settings.randomMin 95 | if (min == null) { 96 | min = functionValue(settings.min, layer) || 0 97 | } 98 | let max = settings.randomMax || functionValue(settings.max, layer) 99 | if (max == null) { 100 | max = defaults[key] * 3 101 | } 102 | const step = settings.step || 1 103 | 104 | if (randomize) { 105 | const random = Math.random() * (max - min) + min 106 | let precision, value 107 | 108 | if (step >= 1) { 109 | value = Math.round(random) 110 | } else { 111 | precision = Math.round(1 / step) 112 | value = Math.round(random * precision) / precision 113 | } 114 | 115 | return value 116 | } else { 117 | return min 118 | } 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/common/PriorityQueue.js: -------------------------------------------------------------------------------- 1 | export class PriorityQueue { 2 | constructor() { 3 | this.collection = [] 4 | } 5 | 6 | enqueue(element) { 7 | if (this.isEmpty()) { 8 | this.collection.push(element) 9 | } else { 10 | let added = false 11 | for (let i = 1; i <= this.collection.length; i++) { 12 | if (element[1] < this.collection[i - 1][1]) { 13 | this.collection.splice(i - 1, 0, element) 14 | added = true 15 | break 16 | } 17 | } 18 | 19 | if (!added) { 20 | this.collection.push(element) 21 | } 22 | } 23 | } 24 | 25 | dequeue() { 26 | let value = this.collection.shift() 27 | return value 28 | } 29 | 30 | isEmpty() { 31 | return this.collection.length === 0 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/common/colors.js: -------------------------------------------------------------------------------- 1 | const colors = { 2 | selectedShapeColor: "rgba(255, 255, 0, 0.7)", // yellow 3 | slidingColor: "#2983BA", // blue 4 | activeEffectColor: "rgba(15, 128, 0, 0.8)", // green 5 | unselectedShapeColor: "rgba(195, 214, 230, 0.4)", // gray 6 | noSelectionColor: "rgba(255, 255, 0, 0.7)", // slightly muted yellow 7 | activeConnectorColor: "#2983BA", // blue 8 | endPointColor: "red", 9 | startPointColor: "rgb(15, 128, 0)", // green 10 | transformerBorderColor: "#fefefe", // almost white 11 | } 12 | 13 | export default colors 14 | -------------------------------------------------------------------------------- /src/common/debugging.js: -------------------------------------------------------------------------------- 1 | import { compact } from "lodash" 2 | 3 | // set to true to enable console logging 4 | const debug = false 5 | 6 | // set to true to clear console before logging each state change 7 | const debugConsoleClear = false 8 | 9 | // limit which keys are shown, e.g., const keys = ['selectLayerById'] 10 | const keys = null 11 | 12 | // keep count of log occurrences by key 13 | const logCounts = {} 14 | 15 | export const log = (key, id) => { 16 | if (debug) { 17 | if (!keys || keys.includes(key)) { 18 | const keyId = compact([id, key]).join("/") 19 | logCounts[keyId] ||= 0 20 | logCounts[keyId]++ 21 | 22 | const message = [logCounts[keyId], keyId].join(" - ") 23 | console.log(message) 24 | } 25 | } 26 | } 27 | 28 | export const resetLogCounts = () => { 29 | if (debug) { 30 | for (const key of Object.getOwnPropertyNames(logCounts)) { 31 | delete logCounts[key] 32 | } 33 | 34 | if (debugConsoleClear) { 35 | console.clear() 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/common/eulerian_trail/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Mauricio Poppe 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/common/eulerian_trail/eulerianTrail.js: -------------------------------------------------------------------------------- 1 | // adapted from https://github.com/mauriciopoppe/eulerian-trail/blob/master/lib/eulerian-trail.js 2 | // see LICENSE for license details 3 | // commented out thrown exceptions to return non-optimal eulerian paths 4 | export const eulerianTrail = (options) => { 5 | var g = [] 6 | var i 7 | var edgePointer = [] 8 | var edgeUsed = [] 9 | var trail = [] 10 | 11 | var id = {} 12 | var idReverse = [] 13 | var idCount = 0 14 | 15 | function getId(x) { 16 | if (!id[x]) { 17 | edgePointer[idCount] = 0 18 | idReverse[idCount] = x 19 | id[x] = idCount++ 20 | } 21 | return id[x] 22 | } 23 | 24 | function dfs(v) { 25 | for (; edgePointer[v] < g[v].length; edgePointer[v] += 1) { 26 | var edge = g[v][edgePointer[v]] 27 | var to = edge[0] 28 | var id = edge[1] 29 | if (!edgeUsed[id]) { 30 | edgeUsed[id] = true 31 | dfs(to) 32 | } 33 | } 34 | trail.push(v) 35 | } 36 | 37 | function pushEdge(u, v, id) { 38 | g[u] = g[u] || [] 39 | g[v] = g[v] || [] 40 | g[u].push([v, id]) 41 | } 42 | 43 | var deg = [] 44 | var inDeg = [], 45 | outDeg = [] 46 | 47 | for (i = 0; i < options.edges.length; i += 1) { 48 | var edge = options.edges[i] 49 | var u = getId(edge[0]) 50 | var v = getId(edge[1]) 51 | 52 | pushEdge(u, v, i) 53 | if (!options.directed) { 54 | pushEdge(v, u, i) 55 | } 56 | 57 | if (options.directed) { 58 | outDeg[u] = outDeg[u] || 0 59 | inDeg[u] = inDeg[u] || 0 60 | outDeg[v] = outDeg[v] || 0 61 | inDeg[v] = inDeg[v] || 0 62 | outDeg[u] += 1 63 | inDeg[v] += 1 64 | } else { 65 | deg[u] = deg[u] || 0 66 | deg[v] = deg[v] || 0 67 | deg[u] += 1 68 | deg[v] += 1 69 | } 70 | } 71 | 72 | function checkDirected() { 73 | var oddVertex = 0 74 | var start = 0 75 | 76 | for (i = 0; i < idCount; i += 1) { 77 | if (outDeg[i] - inDeg[i] !== 0) { 78 | if (outDeg[i] > inDeg[i]) { 79 | start = i 80 | } 81 | oddVertex += 1 82 | } 83 | } 84 | return { odd: oddVertex, start } 85 | } 86 | 87 | function checkUndirected() { 88 | var oddVertex = 0 89 | var start = 0 90 | 91 | for (i = 0; i < idCount; i += 1) { 92 | if (deg[i] % 2 !== 0) { 93 | start = i 94 | oddVertex += 1 95 | } 96 | } 97 | return { odd: oddVertex, start } 98 | } 99 | 100 | var check = options.directed ? checkDirected() : checkUndirected() 101 | if (check.odd % 2 !== 0 || check.odd > 2) { 102 | // throw Error('the graph does not have an eulerian trail') 103 | } 104 | dfs(check.start) 105 | 106 | if (trail.length !== options.edges.length + 1) { 107 | // throw Error('the graph does not have an eulerian trail') 108 | } 109 | 110 | trail.reverse() 111 | 112 | // id to input 113 | return trail.map(function (id) { 114 | return idReverse[id] 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /src/common/hooks/useKeyPress.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react" 2 | 3 | const useKeyPress = (targetKey, inputRef) => { 4 | const [keyPressed, setKeyPressed] = useState(false) 5 | 6 | const handleKeyDown = ({ key }) => { 7 | if (key === targetKey) { 8 | setKeyPressed(true) 9 | } 10 | } 11 | 12 | const handleKeyUp = ({ key }) => { 13 | if (key === targetKey) { 14 | setKeyPressed(false) 15 | } 16 | } 17 | 18 | useEffect(() => { 19 | if (inputRef.current) { 20 | window.addEventListener("keydown", handleKeyDown) 21 | window.addEventListener("keyup", handleKeyUp) 22 | 23 | // remove event listeners on cleanup 24 | return () => { 25 | window.removeEventListener("keydown", handleKeyDown) 26 | window.removeEventListener("keyup", handleKeyUp) 27 | } 28 | } 29 | }, [inputRef]) 30 | 31 | return keyPressed 32 | } 33 | 34 | export default useKeyPress 35 | -------------------------------------------------------------------------------- /src/common/localStorage.js: -------------------------------------------------------------------------------- 1 | // if you want to save a multiple temporary states, use these keys. The first time 2 | // you save a new state, change persistSaveKey. Make a change, then change 3 | // persistLoadKey to the same value. These keys are obsolete now 4 | // given that a user can save their pattern to a file. 5 | const persistLoadKey = "state" 6 | const persistSaveKey = "state" 7 | 8 | export const loadState = (key = persistLoadKey) => { 9 | try { 10 | const serializedState = localStorage.getItem(key) 11 | if (serializedState === null) { 12 | return undefined 13 | } 14 | return JSON.parse(serializedState) 15 | } catch (err) { 16 | return undefined 17 | } 18 | } 19 | 20 | export const saveState = (state, key = persistSaveKey) => { 21 | try { 22 | const serializedState = JSON.stringify(state) 23 | localStorage.setItem(key, serializedState) 24 | } catch (err) { 25 | // ignore write errors 26 | console.log(err) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/common/mocks.js: -------------------------------------------------------------------------------- 1 | let counter = 0 2 | 3 | const uniqueId = () => { 4 | counter++ 5 | return counter.toString() 6 | } 7 | 8 | export const resetUniqueId = (start = 0) => { 9 | counter = start 10 | } 11 | 12 | jest.mock("uuid", () => ({ v4: () => uniqueId() })) 13 | -------------------------------------------------------------------------------- /src/common/proximity.js: -------------------------------------------------------------------------------- 1 | import KDBush from "kdbush" 2 | 3 | // O(n); returns the point in arr that is farthest from a given point 4 | export const farthest = (arr, point) => { 5 | return arr.reduce( 6 | (max, x, i, arr) => (x.distance(point) > max.distance(point) ? x : max), 7 | arr[0], 8 | ) 9 | } 10 | 11 | // O(n); returns the point in arr that is closest to a given point 12 | export const closest = (arr, point) => { 13 | return arr.reduce( 14 | (max, x, i, arr) => (x.distance(point) < max.distance(point) ? x : max), 15 | arr[0], 16 | ) 17 | } 18 | 19 | // O(n log n); returns the point in arr1 that is closest to any point in arr2 20 | export const nearestNeighbor = (arr1, arr2, radius = 1) => { 21 | const index = new KDBush(arr2.length) 22 | 23 | arr2.forEach((point) => index.add(point.x, point.y)) 24 | index.finish() 25 | 26 | let minDistance = Infinity 27 | let closestPoint 28 | 29 | arr1.forEach((point) => { 30 | // find the nearest neighbor in arr2 to point 31 | const neighborIds = index.within(point.x, point.y, radius) 32 | const neighbor = arr2[neighborIds[0]] 33 | 34 | if (neighbor) { 35 | // calculate the Euclidean distance between the point and the nearest neighbor 36 | const distance = Math.sqrt( 37 | (point.x - neighbor.x) ** 2 + (point.y - neighbor.y) ** 2, 38 | ) 39 | 40 | if (distance < minDistance) { 41 | minDistance = distance 42 | closestPoint = point 43 | } 44 | } 45 | }) 46 | 47 | return closestPoint 48 | } 49 | -------------------------------------------------------------------------------- /src/common/slice.js: -------------------------------------------------------------------------------- 1 | // 2 | // Wrapper methods around slice and adapter actions consistent with how our our application 3 | // uses the Redux store. 4 | // 5 | import { v4 as uuidv4 } from "uuid" 6 | 7 | const selectedIndex = (state) => { 8 | const curr = state.entities[state.selected] 9 | return curr ? state.ids.findIndex((id) => id === curr.id) : -1 10 | } 11 | 12 | // Insert an entity at a specific index, which is not supported by addOne. 13 | export const insertOne = (state, action) => { 14 | const index = state.selected ? selectedIndex(state) + 1 : 0 15 | const entity = { ...action.payload } 16 | 17 | state.ids.splice(index, 0, entity.id) 18 | state.entities[entity.id] = entity 19 | state.current = entity.id 20 | 21 | return entity 22 | } 23 | 24 | export const prepareAfterAdd = (entity) => { 25 | const id = uuidv4() 26 | 27 | // return newly generated id so downstream actions can use it 28 | return { payload: { ...entity, id }, meta: { id } } 29 | } 30 | 31 | export const updateOne = (adapter, state, action) => { 32 | const entity = action.payload 33 | 34 | adapter.updateOne(state, { id: entity.id, changes: entity }) 35 | } 36 | -------------------------------------------------------------------------------- /src/common/util.js: -------------------------------------------------------------------------------- 1 | import { keyBy, compact } from "lodash" 2 | 3 | export const difference = (a, b) => { 4 | // eslint-disable-next-line no-undef 5 | return new Set([ 6 | ...[...a].filter((x) => !b.has(x)), 7 | ...[...b].filter((x) => !a.has(x)), 8 | ]) 9 | } 10 | 11 | // round a given number n to p number of digits 12 | export const roundP = (n, p) => { 13 | return Math.round((n + Number.EPSILON) * Math.pow(10, p)) / Math.pow(10, p) 14 | } 15 | 16 | // https://stackoverflow.com/a/4652513 17 | export const reduce = (numerator, denominator) => { 18 | let gcd = (a, b) => { 19 | return b ? gcd(b, a % b) : a 20 | } 21 | 22 | gcd = gcd(numerator, denominator) 23 | return [numerator / gcd, denominator / gcd] 24 | } 25 | 26 | // rotates an array count times 27 | // taken from https://stackoverflow.com/questions/1985260/rotate-the-elements-in-an-array-in-javascript#33451102 28 | export const arrayRotate = (arr, count) => { 29 | count -= arr.length * Math.floor(count / arr.length) 30 | arr.push.apply(arr, arr.splice(0, count)) 31 | return arr 32 | } 33 | 34 | // Helper function to take a string and make the user download a text file with that text as the 35 | // content. I don't really understand this, but I took it from here, and it seems to work: 36 | // https://stackoverflow.com/a/18197511 37 | export const downloadFile = ( 38 | fileName, 39 | text, 40 | fileType = "text/plain;charset=utf-8", 41 | ) => { 42 | let link = document.createElement("a") 43 | link.download = fileName 44 | 45 | let blob = new Blob([text], { type: fileType }) 46 | 47 | // Windows Edge fix 48 | if (window.navigator && window.navigator.msSaveOrOpenBlob) { 49 | window.navigator.msSaveOrOpenBlob(blob, fileName) 50 | } else { 51 | link.href = URL.createObjectURL(blob) 52 | if (document.createEvent) { 53 | var event = document.createEvent("MouseEvents") 54 | event.initEvent("click", true, true) 55 | link.dispatchEvent(event) 56 | } else { 57 | link.click() 58 | } 59 | URL.revokeObjectURL(link.href) 60 | } 61 | } 62 | 63 | // returns an ordered list of objects based on a given key 64 | export const orderByKey = (keys, objects, keyName = "id") => { 65 | const objectMap = keyBy(objects, keyName) 66 | return compact(keys.map((key) => objectMap[key])) 67 | } 68 | 69 | // given a delta values from a mouse wheel, returns the equivalent layer delta. 70 | // when shift key is pressed, this is apparently "horizontal" scrolling by the browser; 71 | // for us, it means we'll grow in increments of 1 72 | export const scaleByWheel = (size, deltaX, deltaY) => { 73 | const signX = Math.sign(deltaX) 74 | const signY = Math.sign(deltaY) 75 | 76 | if (deltaX) { 77 | return size + 1 * signX 78 | } else { 79 | const scale = 1 + (Math.log(Math.abs(deltaY)) / 30) * signY 80 | let newSize = Math.max(roundP(size * scale, 0), 1) 81 | 82 | if (newSize === size) { 83 | // if the log scaled value isn't big enough to move the scale 84 | newSize = Math.max(signY + size, 1) 85 | } 86 | 87 | return newSize 88 | } 89 | } 90 | 91 | // convenience method to handle invocation of a function when present 92 | export const functionValue = (val, arg1, arg2) => { 93 | return typeof val === "function" ? val(arg1, arg2) : val 94 | } 95 | 96 | // shared logic via mixins 97 | export const mixin = (targetClass, mixinClass) => { 98 | Object.getOwnPropertyNames(mixinClass.prototype).forEach((name) => { 99 | if (name !== "constructor") { 100 | targetClass.prototype[name] = mixinClass.prototype[name] 101 | } 102 | }) 103 | } 104 | -------------------------------------------------------------------------------- /src/components/CheckboxOption.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Col from "react-bootstrap/Col" 3 | import Row from "react-bootstrap/Row" 4 | import Form from "react-bootstrap/Form" 5 | import S from "react-switch" 6 | const Switch = S.default ? S.default : S // Fix: https://github.com/vitejs/vite/issues/2139 7 | 8 | const CheckboxOption = ({ 9 | options, 10 | optionKey, 11 | data, 12 | model, 13 | onChange, 14 | label = true, 15 | }) => { 16 | const option = options[optionKey] 17 | const visible = 18 | option.isVisible === undefined ? true : option.isVisible(model, data) 19 | 20 | const handleChange = (checked) => { 21 | let attrs = {} 22 | attrs[optionKey] = checked 23 | 24 | if (option.onChange !== undefined) { 25 | attrs = option.onChange(model, attrs, data) 26 | } 27 | 28 | onChange(attrs) 29 | } 30 | 31 | return ( 32 | 33 | 34 | {label && ( 35 | 39 | {option.title} 40 | 41 | )} 42 | 43 | 44 | 48 | 52 | 53 | 54 | ) 55 | } 56 | export default CheckboxOption 57 | -------------------------------------------------------------------------------- /src/components/DropdownOption.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Col from "react-bootstrap/Col" 3 | import Row from "react-bootstrap/Row" 4 | import Form from "react-bootstrap/Form" 5 | import Select from "react-select" 6 | 7 | const DropdownOption = ({ 8 | options, 9 | optionKey, 10 | data, 11 | model, 12 | onChange, 13 | index, 14 | }) => { 15 | const option = options[optionKey] 16 | const currentChoice = data[optionKey] 17 | 18 | let choices = option.choices 19 | if (typeof choices === "function") { 20 | choices = choices() 21 | } 22 | 23 | choices = Array.isArray(choices) 24 | ? choices.map((choice) => { 25 | return { value: choice, label: choice } 26 | }) 27 | : Object.keys(choices).map((key) => { 28 | return { value: key, label: choices[key] } 29 | }) 30 | const currentLabel = ( 31 | choices.find((choice) => choice.value == currentChoice) || choices[0] 32 | ).label 33 | const visible = 34 | option.isVisible === undefined ? true : option.isVisible(model, data) 35 | 36 | const handleChange = (choice) => { 37 | const value = choice.value 38 | let attrs = {} 39 | attrs[optionKey] = value 40 | 41 | if (option.onChange !== undefined) { 42 | attrs = option.onChange(model, attrs, data) 43 | } 44 | 45 | onChange(attrs) 46 | } 47 | 48 | return ( 49 | 53 | 54 | 58 | {option.title} 59 | 60 | 61 | 62 | 63 | ({ ...base, zIndex: 9999 }) }} 55 | /> 56 | 57 | 58 | ) 59 | 60 | // this should really be a component, but I could not figure out how to get it 61 | // to not re-render as the value changed; the fallout is that the editor re-renders 62 | // more than it should, but it's not noticeable 63 | const renderOption = ({ optionKey }) => { 64 | return ( 65 | 73 | ) 74 | } 75 | 76 | const renderedModelOptions = Object.keys(machineOptions) 77 | .filter( 78 | (optionKey) => optionKey !== "name" && optionKey !== "minimizeMoves", 79 | ) 80 | .map((optionKey) => renderOption({ options: machineOptions, optionKey })) 81 | 82 | return ( 83 |
84 | {renderOption({ optionKey: "name" })} 85 | {renderedMachineSelection} 86 | {renderedModelOptions} 87 | {renderOption({ optionKey: "minimizeMoves" })} 88 |
89 | ) 90 | } 91 | 92 | export default React.memo(MachineEditor) 93 | -------------------------------------------------------------------------------- /src/features/machines/MachineList.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { useDispatch, useSelector } from "react-redux" 3 | import ListGroup from "react-bootstrap/ListGroup" 4 | import { 5 | selectAllMachines, 6 | selectCurrentMachineId, 7 | setCurrentMachine, 8 | } from "./machinesSlice" 9 | import { getMachine } from "./machineFactory" 10 | 11 | const MachineRow = ({ current, numLayers, machine, handleMachineSelected }) => { 12 | const { name, id } = machine 13 | const activeClass = current ? "active" : "" 14 | const machineModel = getMachine(machine) 15 | 16 | return ( 17 | 22 |
26 |
27 |
{name}
28 | {machineModel.label} 29 |
30 |
31 |
32 | ) 33 | } 34 | 35 | const MachineList = () => { 36 | const dispatch = useDispatch() 37 | const machines = useSelector(selectAllMachines) 38 | const currentMachineId = useSelector(selectCurrentMachineId) 39 | 40 | const handleMachineSelected = (event) => { 41 | const id = event.target.closest(".list-group-item").id 42 | dispatch(setCurrentMachine(id)) 43 | } 44 | 45 | return ( 46 |
47 | 51 | {machines.map((machine, index) => ( 52 | 60 | ))} 61 | 62 |
63 | ) 64 | } 65 | 66 | export default React.memo(MachineList) 67 | -------------------------------------------------------------------------------- /src/features/machines/MachineManager.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react" 2 | import Button from "react-bootstrap/Button" 3 | import { Tooltip } from "react-tooltip" 4 | import { FaTrash, FaCopy, FaPlusSquare } from "react-icons/fa" 5 | import { useSelector, useDispatch } from "react-redux" 6 | import { 7 | selectNumMachines, 8 | selectCurrentMachineId, 9 | deleteMachine, 10 | } from "@/features/machines/machinesSlice" 11 | import MachineList from "./MachineList" 12 | import MachineEditor from "./MachineEditor" 13 | import CopyMachine from "./CopyMachine" 14 | import NewMachine from "./NewMachine" 15 | 16 | const MachineManager = () => { 17 | const dispatch = useDispatch() 18 | const numMachines = useSelector(selectNumMachines) 19 | const currentMachineId = useSelector(selectCurrentMachineId) 20 | 21 | const canRemove = numMachines > 1 22 | const [showNewMachine, setShowNewMachine] = useState(false) 23 | const [showCopyMachine, setShowCopyMachine] = useState(false) 24 | 25 | const toggleNewMachineModal = () => setShowNewMachine(!showNewMachine) 26 | const toggleCopyMachineModal = () => setShowCopyMachine(!showCopyMachine) 27 | const handleMachineRemoved = (id) => dispatch(deleteMachine(currentMachineId)) 28 | 29 | return ( 30 |
31 | 35 | 39 |
40 | 41 |
42 | 43 | 53 | {canRemove && } 54 | {canRemove && ( 55 | 64 | )} 65 | 66 | 75 |
76 |
77 | 78 |
79 | ) 80 | } 81 | 82 | export default React.memo(MachineManager) 83 | -------------------------------------------------------------------------------- /src/features/machines/NewMachine.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from "react" 2 | import { useDispatch } from "react-redux" 3 | import Select from "react-select" 4 | import Col from "react-bootstrap/Col" 5 | import Row from "react-bootstrap/Row" 6 | import Form from "react-bootstrap/Form" 7 | import Button from "react-bootstrap/Button" 8 | import Modal from "react-bootstrap/Modal" 9 | import { 10 | getDefaultMachine, 11 | getMachineSelectOptions, 12 | getMachine, 13 | } from "./machineFactory" 14 | import { addMachine } from "./machinesSlice" 15 | 16 | const defaultMachine = getDefaultMachine() 17 | const customStyles = { 18 | control: (base) => ({ 19 | ...base, 20 | height: 55, 21 | minHeight: 55, 22 | }), 23 | } 24 | 25 | const NewMachine = ({ toggleModal, showModal }) => { 26 | const dispatch = useDispatch() 27 | const selectRef = useRef() 28 | const selectOptions = getMachineSelectOptions() 29 | const [type, setType] = useState(defaultMachine.type) 30 | const [name, setName] = useState(defaultMachine.label) 31 | 32 | const selectedMachine = getMachine({ type }) 33 | const selectedOption = { 34 | value: selectedMachine.id, 35 | label: selectedMachine.label, 36 | } 37 | 38 | const handleNameFocus = (event) => { 39 | event.target.select() 40 | } 41 | 42 | const handleInitialFocus = () => { 43 | selectRef.current.focus() 44 | } 45 | 46 | const handleChangeNewType = (selected) => { 47 | const machine = getMachine(selected.value) 48 | 49 | setType(selected.value) 50 | setName(machine.label.toLowerCase()) 51 | } 52 | 53 | const handleChangeNewName = (event) => { 54 | setName(event.target.value) 55 | } 56 | 57 | const onMachineAdded = (event) => { 58 | const machine = getMachine(type) 59 | 60 | event.preventDefault() 61 | dispatch( 62 | addMachine({ 63 | ...machine.getInitialState(), 64 | name, 65 | }), 66 | ) 67 | toggleModal() 68 | } 69 | 70 | return ( 71 | 76 | 77 | Create new machine 78 | 79 | 80 |
81 | 82 | 83 | Type 84 | 85 |