├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── nitpicking-report.md └── workflows │ └── deploy.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .vscode └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── PRIVACY.md ├── README.md ├── SECURITY.md ├── assets ├── editor-demo1.gif ├── editor-demo2.gif ├── editor-demo3.gif └── readme-banner.png ├── bun.lock ├── craco-copy-webpack-plugin.js ├── craco-fallback-util-plugin.js ├── craco-service-worker-dev-plugin.js ├── craco.config.js ├── docker-compose.yml ├── package-lock.json ├── package.json ├── prettier.config.js ├── public ├── api │ └── version ├── index.html ├── manifest.json ├── precache │ ├── V5RC-FieldPerimeter-12ft12ft-TopDown-TileColor66_71@1.0.png │ ├── V5RC-PushBack-H2H-TopDownHighlighted-TileColor66_71@0.1+2000px.png │ └── favicon.ico ├── robots.txt └── static │ ├── V5RC-HighStakes-H2H-TopDownHighlighted-TileColor66_71@4.0+2000px.png │ ├── V5RC-HighStakes-Skills-TopDownHighlighted-TileColor66_71@4.0+2000px.png │ ├── V5RC-OverUnder-H2H-TopDown-TileColor66_71@4.0+2000px.png │ ├── V5RC-OverUnder-Skills-TopDown-TileColor66_71@4.0+2000px.png │ ├── V5RC-PushBack-Skills-TopDownHighlighted-TileColor66_71@0.1+2000px.png │ ├── VIQRC-FieldPerimeter-8ft6ft-TopDown-Original+2000px.png │ ├── VIQRC-FullVolume-All-TopDown-Original+2000px.png │ ├── VIQRC-MixAndMatch-H2H-TopDown-Original@0.1+2000px.png │ ├── VIQRC-MixAndMatch-Skills-TopDown-Original@0.1+2000px.png │ ├── VIQRC-RapidRelay-All-TopDown-Original+2000px.png │ ├── VURC-HighStakes-H2H-TopDownHighlighted-TileColor66_71@4.0+2000px.png │ ├── VURC-HighStakes-Skills-TopDownHighlighted-TileColor66_71@4.0+2000px.png │ ├── VURC-OverUnder-H2H-TopDown-TileColor66_71@4.0+2000px.png │ ├── VURC-PushBack-H2H-TopDownHighlighted-TileColor66_71@0.1+2000px.png │ ├── coordinate-system-preview-cartesian-plane.png │ ├── coordinate-system-preview-path-relative-strict-mode.png │ ├── coordinate-system-preview-path-relative.png │ ├── coordinate-system-preview-vex-gps.png │ ├── instruction-to-brave-browser-1.png │ ├── instruction-to-brave-browser-2.png │ ├── logo192.png │ ├── logo464.svg │ ├── logo512-apple.png │ ├── logo512-safe.png │ ├── logo512.png │ └── readme-banner-0.5x.png ├── src ├── App.test.tsx ├── Root.scss ├── Root.tsx ├── Version.tsx ├── app │ ├── Layouts.tsx │ ├── MarkdownSupport.tsx │ ├── MarkdownTest.mdx │ ├── Notice.tsx │ ├── Theme.tsx │ ├── classic.blocks │ │ ├── MousePositionPresentation.scss │ │ ├── _index.scss │ │ ├── _index.tsx │ │ ├── panel │ │ │ ├── ControlConfigPanel.scss │ │ │ ├── MenuPanel.scss │ │ │ └── PathTreePanel.scss │ │ └── speed-canvas │ │ │ └── SpeedCanvasElement.scss │ ├── common.blocks │ │ ├── DragDropBackdrop.tsx │ │ ├── MousePositionPresentation.tsx │ │ ├── _index.scss │ │ ├── _index.tsx │ │ ├── field-canvas │ │ │ ├── AreaSelectionElement.tsx │ │ │ ├── ControlElement.tsx │ │ │ ├── FieldCanvasElement.tsx │ │ │ ├── RobotElement.tsx │ │ │ ├── SegmentControlVisualLineElement.tsx │ │ │ ├── SegmentElement.tsx │ │ │ └── SegmentPointsHitBoxElement.tsx │ │ ├── modal │ │ │ ├── AboutModal.scss │ │ │ ├── AboutModal.tsx │ │ │ ├── AssetManagerModal.scss │ │ │ ├── AssetManagerModal.tsx │ │ │ ├── ConfirmationModal.scss │ │ │ ├── ConfirmationModal.tsx │ │ │ ├── CoordinateSystemModal.scss │ │ │ ├── CoordinateSystemModal.tsx │ │ │ ├── Modal.scss │ │ │ ├── Modal.tsx │ │ │ ├── PreferencesModal.scss │ │ │ ├── PreferencesModal.tsx │ │ │ ├── RequireLocalFieldImageModal.scss │ │ │ ├── RequireLocalFieldImageModal.tsx │ │ │ ├── WelcomeForBrave.mdx │ │ │ ├── WelcomeForOthers.mdx │ │ │ ├── WelcomeModal.scss │ │ │ └── WelcomeModal.tsx │ │ ├── panel │ │ │ ├── ControlConfigPanel.scss │ │ │ ├── ControlConfigPanel.tsx │ │ │ ├── GeneralConfigPanel.tsx │ │ │ ├── MenuPanel.scss │ │ │ ├── MenuPanel.tsx │ │ │ ├── Panel.scss │ │ │ ├── Panel.tsx │ │ │ ├── PathTreePanel.scss │ │ │ └── PathTreePanel.tsx │ │ └── speed-canvas │ │ │ ├── SpeedCanvasElement.scss │ │ │ └── SpeedCanvasElement.tsx │ ├── component.blocks │ │ ├── CanvasTooltip.scss │ │ ├── CanvasTooltip.tsx │ │ ├── FormButton.scss │ │ ├── FormButton.tsx │ │ ├── FormCheckbox.tsx │ │ ├── FormEnumSelect.tsx │ │ ├── FormInputField.tsx │ │ ├── FormItemSelect.tsx │ │ ├── OpenModalButton.tsx │ │ ├── PanelBox.tsx │ │ └── RangeSlider.tsx │ ├── exclusive.blocks │ │ ├── _index.scss │ │ ├── _index.tsx │ │ ├── panel │ │ │ └── PathTreePanel.scss │ │ └── speed-canvas │ │ │ └── SpeedCanvasElement.scss │ └── mobile.blocks │ │ ├── _index.scss │ │ ├── _index.tsx │ │ ├── modal │ │ ├── AssetManagerModal.scss │ │ ├── CoordinateSystemModal.scss │ │ └── WelcomeModal.scss │ │ └── panel │ │ └── PathTreePanel.scss ├── core │ ├── Asset.test.ts │ ├── Asset.ts │ ├── Calculation.test.ts │ ├── Calculation.ts │ ├── Canvas.ts │ ├── Clipboard.ts │ ├── Command.test.ts │ ├── Command.ts │ ├── Coordinate.test.ts │ ├── Coordinate.ts │ ├── CoordinateSystem.test.ts │ ├── CoordinateSystem.ts │ ├── FieldEditor.ts │ ├── FieldImagePrompt.tsx │ ├── GoogleAnalytics.ts │ ├── Hook.ts │ ├── InputOutput.ts │ ├── Layout.ts │ ├── Logger.ts │ ├── LoggerImpl.ts │ ├── Magnet.ts │ ├── MainApp.ts │ ├── Path.test.ts │ ├── Path.ts │ ├── Preferences.ts │ ├── ServiceWorkerMessages.ts │ ├── ServiceWorkerRegistration.ts │ ├── SpeedEditor.ts │ ├── TouchEventListener.ts │ ├── Unit.ts │ ├── Util.test.ts │ ├── Util.ts │ └── Versioning.tsx ├── format │ ├── Config.test.ts │ ├── Config.tsx │ ├── Format.test.ts │ ├── Format.tsx │ ├── LemLibFormatV0_4 │ │ ├── GeneralConfig.tsx │ │ ├── PathConfig.tsx │ │ └── index.tsx │ ├── LemLibFormatV1_0 │ │ ├── GeneralConfig.tsx │ │ ├── PathConfig.tsx │ │ ├── Serialization.ts │ │ ├── index.test.tsx │ │ └── index.tsx │ ├── LemLibOdomGeneratorFormatV0_4 │ │ ├── GeneralConfig.tsx │ │ ├── PathConfig.tsx │ │ └── index.tsx │ ├── LemLibTarballFormatV0_5 │ │ ├── GeneralConfig.tsx │ │ ├── PathConfig.tsx │ │ └── index.tsx │ ├── MoveToPointCodeGenFormatV0_1 │ │ ├── GeneralConfig.tsx │ │ ├── PathConfig.tsx │ │ └── index.tsx │ ├── PathDotJerryioFormatV0_1 │ │ ├── GeneralConfig.tsx │ │ ├── PathConfig.tsx │ │ └── index.tsx │ └── RigidCodeGenFormatV0_1 │ │ ├── GeneralConfig.tsx │ │ ├── PathConfig.tsx │ │ └── index.tsx ├── index.tsx ├── react-app-env.d.ts ├── service-worker.ts ├── setupTests.ts └── token │ ├── Tokens.test.ts │ └── Tokens.ts ├── tsconfig.json └── tsconfig.paths.json /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: ":bug: " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - Browser [e.g. chrome, safari] 28 | - Version [e.g. 0.1.0] 29 | 30 | **Other Devices (please complete the following information):** 31 | - Device: [e.g. iPad mini] 32 | - Browser [e.g. stock browser, safari] 33 | - Version [e.g. 0.1.0] 34 | 35 | **Additional context** 36 | Add any other context about the problem here. 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: ":sparkles: " 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/nitpicking-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Nitpicking report 3 | about: Tell us anything you hate about UI/UX design 4 | title: ":art: " 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the issue** 11 | A clear and concise description of what is problem. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - Browser [e.g. chrome, safari] 28 | - Version [e.g. 0.1.0] 29 | 30 | **Other Devices (please complete the following information):** 31 | - Device: [e.g. iPad mini] 32 | - Browser [e.g. stock browser, safari] 33 | - Version [e.g. 0.1.0] 34 | 35 | **Additional context** 36 | Add any other context about the problem here. 37 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Tagged Deploy GitHub Pages 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | # Build job 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: 18 17 | - run: npm ci 18 | - run: npm test 19 | - run: npm run build 20 | env: 21 | CI: false 22 | - uses: actions/upload-pages-artifact@v3 23 | with: 24 | path: build 25 | 26 | # Deploy job 27 | deploy: 28 | needs: build 29 | 30 | # Grant GITHUB_TOKEN the permissions required to make a Pages deployment 31 | permissions: 32 | pages: write # to deploy to Pages 33 | id-token: write # to verify the deployment originates from an appropriate source 34 | 35 | # Deploy to the github-pages environment 36 | environment: 37 | name: github-pages 38 | url: ${{ steps.deployment.outputs.page_url }} 39 | 40 | # Specify runner + deployment step 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Deploy to GitHub Pages 44 | id: deployment 45 | uses: actions/deploy-pages@v4 # or the latest "vX.X.X" version tag for this action -------------------------------------------------------------------------------- /.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 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | if npm run check-format 5 | then 6 | : 7 | else 8 | echo 'Please run `npm run format` and commit again' 9 | echo 'If this problem persists, try running `npm run check-format` to gain more information' 10 | exit 1 11 | fi 12 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /.github/ 2 | /.vscode/settings.json 3 | /build/ 4 | /CODE_OF_CONDUCT.md 5 | /coverage/ 6 | /*.js 7 | /public/ 8 | /package-lock.json 9 | /package.json 10 | /src/app/MarkdownSupport.tsx 11 | /src/app/MarkdownTest.mdx 12 | /src/app/common.blocks/modal/WelcomeForBrave.mdx 13 | /src/app/common.blocks/modal/WelcomeForOthers.mdx 14 | /src/react-app-env.d.ts 15 | /src/setupTests.ts 16 | /src/token/Tokens.test.ts 17 | /tsconfig.json 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "jest.runMode": "on-demand", 4 | "[scss]": { 5 | "editor.defaultFormatter": "esbenp.prettier-vscode" 6 | } 7 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Coding Guidelines 2 | 3 | ### 2 Spaces for Indentation Rather Than Tabs 4 | 5 | Remember to change your settings in your editor to use 2 spaces when you create a new file. 6 | 7 | ### Add UX and ALGO Comments 8 | 9 | Add comments to your code to explain the code related to user experience and algorithms. 10 | 11 | ### Format Your Code 12 | 13 | Before pushing a commit, run `npm run format`, or else your commit will fail. 14 | 15 | ### Follow Naming Conventions 16 | 17 | Self explanatory. Follow the conventions of the language you are using. For example, in TypeScript, use camelCase for variables and functions, and PascalCase for classes. 18 | 19 | Use [BEM Approach](https://en.bem.info/methodology/quick-start/) for CSS class names and file structure. 20 | 21 | ## Workflow 22 | 23 | ### Use Issue Templates 24 | 25 | Self explanatory. Use the issue templates when creating an issue. 26 | 27 | ### Commit Messages 28 | 29 | > [!WARNING] 30 | > Hey! before you push a commit, run `npm run format`, to make sure it passes the precommit hook 31 | > 32 | > (If you want to ignore the pre-commit check, run the commit command with `--no-verify`) 33 | 34 | You must follow the [gitmoji](https://gitmoji.dev/) convention for commit messages. 35 | 36 | The emoji should be the first thing in the message, followed by a verb in the imperative mood, and the rest of the message should be in the present tense. 37 | 38 | The emoji is in text form. You should take the emoji from the [gitmoji](https://gitmoji.dev/) website, do not use your own emoji. The first letter of the message should be capitalized. No period at the end of the message. 39 | 40 | here is a valid example --> `:sparkles: Add new feature` 41 | 42 | Exceptions to this rule are: 43 | 44 | - `:truck:` for merge branches instead of `:twisted_rightwards_arrows:` 45 | 46 | ### Branch Naming 47 | 48 | Examples: `feature/`, `bugfix/`, `hotfix/` 49 | 50 | ### Never Push Directly to Main, Always Create a Branch With Pull Request 51 | 52 | Do not push directly to main. You should create a branch with a pull request. Then, you can continue to work on your branch and push your commits to the branch. 53 | 54 | ### Don't be Afraid to Push Your Code 55 | 56 | Do not keep a ton of commits locally, squash them into one commit and push it when you are done. You should push your commits to the branch often, so that you can get feedback on your code as soon as possible. 57 | 58 | ### Continuous Integration 59 | 60 | See: https://en.wikipedia.org/wiki/Continuous_integration 61 | 62 | > The longer development continues on a branch without merging back to the mainline, the greater the risk of multiple integration conflicts and failures when the developer branch is eventually merged back. 63 | 64 | Although we might not be able to follow continuous integration practice perfectly, we should try our best to follow some of the principles. 65 | 66 | To reduce merge conflicts with main and other branches, Jerry will review your code and merge even if it is not finished. It may be merged into main multiple times before it is finished. You should also backmerge main into your branch often to keep your branch up to date with others. 67 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM oven/bun:alpine AS builder 3 | 4 | WORKDIR /app 5 | 6 | COPY ./package.json ./bun.lock ./ 7 | 8 | RUN bun install 9 | 10 | COPY . . 11 | 12 | RUN bun run build 13 | 14 | # Runner stage 15 | FROM oven/bun:alpine AS runner 16 | 17 | WORKDIR /app 18 | 19 | COPY --from=builder /app/build . 20 | 21 | # Install http-server globally 22 | RUN bun install -g http-server 23 | 24 | # Expose port 8080 for the http-server 25 | EXPOSE 8080 26 | 27 | # Start the http-server 28 | CMD ["bunx", "http-server", "."] 29 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | ## Information We Collect 4 | 5 | We do not collect or transmit any user's personal information, except for usage data such as the number of times the application is launched or the duration of a user's session. This data is collected anonymously using Google Analytics services and is used to improve the performance and functionality of the application. Users can opt out of Google Analytics services by disabling it on the Preferences page. 6 | 7 | We treat all user content including path data as sensitive information. It is never collected or transmitted by us. All user content is saved on the user's device and never leaves the user's device. 8 | 9 | ## How We Protect Your Information 10 | 11 | We take the security of your information seriously. Please pay a visit to our [Security Policy](SECURITY.md) to learn more about the security measures we take to protect your information. 12 | 13 | ## Contact Us 14 | 15 | If you have any questions or concerns about this Privacy Policy, please contact us at [me@jerryio.com](mailto:me@jerryio.com). 16 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | If you discover a security vulnerability in this project, please report it to us by sending an email to [me@jerryio.com](mailto:me@jerryio.com) or by messaging me on discord `jerrylum` (User ID: 298638092196249600). Please do not create public GitHub issues for security vulnerabilities. 6 | 7 | We will acknowledge your report within 24 hours and provide an estimated timeline for a fix. We may also ask for additional information to help us reproduce and address the issue. 8 | 9 | ## Supported Versions 10 | 11 | We only support the latest version of the project. We encourage all users to use the latest version of the project by using the web app at [path.jerryio.com](https://path.jerryio.com), as it contains the latest security fixes and improvements. 12 | 13 | ## Security Updates 14 | 15 | After identifying and addressing a security vulnerability, we will release security updates for this project and provide information about the security vulnerability and how to address it as soon as possible. 16 | 17 | ## Security Measures 18 | 19 | This project takes the following security measures to ensure the safety of its users: 20 | 21 | - We use HTTPS to encrypt all traffic to and from the web app. 22 | - We host the web app on GitHub Pages, which provides additional security features such as HTTPS by default and DDoS protection. 23 | - We use input validation and sanitization to prevent common web application security vulnerabilities. Specifically, user input, including file content, is sanitized to prevent cross-site scripting (XSS) attacks by parsing malicious path files. 24 | - We do not store path data in the web app or on the server. All user content is saved on the user's device and never leaves the user's device. 25 | 26 | ## Responsible Disclosure Policy 27 | 28 | We believe in responsible disclosure of security vulnerabilities, and we encourage all security researchers to follow our responsible disclosure policy: 29 | 30 | 1. Do not attempt to disrupt the normal operation of the application or server. 31 | 2. Do not publicly disclose a vulnerability until we have had an opportunity to address it. 32 | 3. Provide us with a reasonable amount of time to address the vulnerability before publicly disclosing it. 33 | 34 | We appreciate the efforts of security researchers to improve the security of our project, and we will acknowledge their contributions in our release notes. 35 | 36 | ## Contact 37 | 38 | If you have any questions or concerns about this security policy, please contact us at [me@jerryio.com](mailto:me@jerryio.com). 39 | -------------------------------------------------------------------------------- /assets/editor-demo1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/assets/editor-demo1.gif -------------------------------------------------------------------------------- /assets/editor-demo2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/assets/editor-demo2.gif -------------------------------------------------------------------------------- /assets/editor-demo3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/assets/editor-demo3.gif -------------------------------------------------------------------------------- /assets/readme-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/assets/readme-banner.png -------------------------------------------------------------------------------- /craco-copy-webpack-plugin.js: -------------------------------------------------------------------------------- 1 | // See: https://stackoverflow.com/questions/53955660/workbox-webpack-4-plugin-unable-to-precache-non-webpack-assets 2 | 3 | const CopyPlugin = require("copy-webpack-plugin"); 4 | 5 | module.exports = { 6 | overrideWebpackConfig: ({ webpackConfig, cracoConfig, pluginOptions, context: { env, paths } }) => { 7 | /** @type {import('webpack/types'.Configuration)} */ 8 | const newConfig = { ...webpackConfig }; 9 | 10 | // The "precache" directory is where we require the service worker to precache files. 11 | // Files in other directories like "static" doesn't mean they are NOT precached. 12 | newConfig.plugins.push( 13 | new CopyPlugin({ 14 | patterns: [ 15 | { from: "precache/*.*", context: "public", noErrorOnMissing: true } 16 | ] 17 | }) 18 | ); 19 | return newConfig; 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /craco-fallback-util-plugin.js: -------------------------------------------------------------------------------- 1 | // See: https://stackoverflow.com/questions/74738438/add-polyfill-to-craco-issue-add-a-fallback-resolve-fallback 2 | 3 | module.exports = { 4 | overrideWebpackConfig: ({ webpackConfig, cracoConfig, pluginOptions, context: { env, paths } }) => { 5 | /** @type {import('webpack/types'.Configuration)} */ 6 | const newConfig = { ...webpackConfig }; 7 | 8 | newConfig.resolve.fallback = webpackConfig.resolve.fallback || {}; 9 | newConfig.resolve.fallback.util = require.resolve("util"); 10 | 11 | return newConfig; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /craco-service-worker-dev-plugin.js: -------------------------------------------------------------------------------- 1 | // See: https://stackoverflow.com/questions/65063966/how-to-use-the-service-worker-in-dev-mode-with-create-react-app 2 | // See: https://developer.chrome.com/docs/workbox/reference/workbox-webpack-plugin/#type-InjectManifest 3 | // See: https://github.com/facebook/create-react-app/issues/11060 4 | 5 | const WorkboxWebpackPlugin = require("workbox-webpack-plugin"); 6 | const path = require("path"); 7 | 8 | module.exports = { 9 | overrideWebpackConfig: ({ webpackConfig, cracoConfig, pluginOptions, context: { env, paths } }) => { 10 | /** @type {import('webpack/types'.Configuration)} */ 11 | const newConfig = { ...webpackConfig }; 12 | 13 | if (env === "development") { 14 | newConfig.plugins.push( 15 | new WorkboxWebpackPlugin.InjectManifest({ 16 | swSrc: path.resolve(__dirname, "src/service-worker.ts"), 17 | dontCacheBustURLsMatching: /\.[0-9a-f]{8}\./, 18 | exclude: [/\.map$/, /asset-manifest\.json$/, /LICENSE/], 19 | maximumFileSizeToCacheInBytes: 20 * 1024 * 1024 20 | }) 21 | ); 22 | } 23 | return newConfig; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /craco.config.js: -------------------------------------------------------------------------------- 1 | // The reason we need to use CRACO is because MDX doesn't work with Create React App out of the box. 2 | // https://github.com/orgs/mdx-js/discussions/1870 3 | // https://github.com/orgs/mdx-js/discussions/2218 4 | 5 | const { addAfterLoader, loaderByName } = require("@craco/craco"); 6 | 7 | module.exports = async env => { 8 | const remarkGfm = (await import("remark-gfm")).default; 9 | 10 | return { 11 | webpack: { 12 | configure: webpackConfig => { 13 | addAfterLoader(webpackConfig, loaderByName("babel-loader"), { 14 | test: /\.(md|mdx)$/, 15 | loader: require.resolve("@mdx-js/loader"), 16 | /** @type {import('@mdx-js/loader').Options} */ 17 | options: { 18 | remarkPlugins: [remarkGfm] 19 | } 20 | }); 21 | return webpackConfig; 22 | } 23 | }, 24 | plugins: [ 25 | { plugin: require("./craco-copy-webpack-plugin.js") }, 26 | { plugin: require("./craco-fallback-util-plugin.js") }, 27 | { plugin: require("./craco-service-worker-dev-plugin.js") }, 28 | { plugin: require('react-app-alias').CracoAliasPlugin } 29 | ] 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | web: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | ports: 7 | - "8080:8080" 8 | restart: always 9 | healthcheck: 10 | test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080"] 11 | interval: 30s 12 | timeout: 10s 13 | retries: 3 14 | start_period: 20s 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "path.jerryio", 3 | "version": "0.10.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.11.0", 7 | "@emotion/styled": "^11.11.0", 8 | "@fontsource/roboto": "^4.5.8", 9 | "@mdx-js/mdx": "^2.3.0", 10 | "@mui/icons-material": "^5.11.16", 11 | "@mui/lab": "^5.0.0-alpha.130", 12 | "@mui/material": "^5.13.1", 13 | "buffer": "^6.0.3", 14 | "class-transformer": "^0.5.1", 15 | "class-validator": "^0.14.0", 16 | "classnames": "^2.3.2", 17 | "dompurify": "^3.2.5", 18 | "fast-sha256": "^1.3.0", 19 | "localforage": "^1.10.0", 20 | "mobx-react-lite": "^3.4.3", 21 | "mui-file-input": "^3.0.1", 22 | "notistack": "^3.0.1", 23 | "react": "^18.2.0", 24 | "react-contenteditable": "^3.3.7", 25 | "react-dom": "^18.2.0", 26 | "react-hotkeys-hook": "^4.4.0", 27 | "react-konva": "^18.2.8", 28 | "react-konva-utils": "^1.0.5", 29 | "reflect-metadata": "^0.1.13", 30 | "sass": "^1.63.3", 31 | "semver": "^7.5.2", 32 | "smart-buffer": "^4.2.0", 33 | "use-image": "^1.1.0", 34 | "util": "^0.12.5", 35 | "web-vitals": "^2.1.4", 36 | "workbox-background-sync": "^6.5.4", 37 | "workbox-broadcast-update": "^6.5.4", 38 | "workbox-cacheable-response": "^6.5.4", 39 | "workbox-core": "^6.5.4", 40 | "workbox-expiration": "^6.5.4", 41 | "workbox-google-analytics": "^6.5.4", 42 | "workbox-navigation-preload": "^6.5.4", 43 | "workbox-precaching": "^6.5.4", 44 | "workbox-range-requests": "^6.5.4", 45 | "workbox-routing": "^6.5.4", 46 | "workbox-strategies": "^6.5.4", 47 | "workbox-streams": "^6.5.4", 48 | "workbox-window": "^7.0.0" 49 | }, 50 | "devDependencies": { 51 | "@babel/plugin-transform-private-property-in-object": "^7.23.3", 52 | "@craco/craco": "^7.1.0", 53 | "@mdx-js/loader": "^3.0.1", 54 | "@testing-library/jest-dom": "^5.16.5", 55 | "@testing-library/react": "^13.4.0", 56 | "@testing-library/user-event": "^13.5.0", 57 | "@types/dompurify": "^3.0.2", 58 | "@types/jest": "^27.5.2", 59 | "@types/node": "^17.0.45", 60 | "@types/react": "^18.2.6", 61 | "@types/react-dom": "^18.2.4", 62 | "@types/semver": "^7.5.0", 63 | "@types/wicg-file-system-access": "^2020.9.6", 64 | "copy-webpack-plugin": "^11.0.0", 65 | "husky": "^8.0.3", 66 | "prettier": "2.8.8", 67 | "react-app-alias": "^2.2.2", 68 | "react-scripts": "5.0.1", 69 | "remark-gfm": "^4.0.0", 70 | "typescript": "^5.7.2" 71 | }, 72 | "overrides": { 73 | "typescript": "^5.7.2" 74 | }, 75 | "scripts": { 76 | "start": "craco start", 77 | "build": "craco build", 78 | "test": "craco test", 79 | "test-coverage": "craco test --collectCoverage --watchAll", 80 | "test-coverage-ci": "craco test --collectCoverage --watchAll=false", 81 | "eject": "react-scripts eject", 82 | "prepare": "husky install", 83 | "format": "prettier . --write --cache", 84 | "check-format": "prettier . -c --cache" 85 | }, 86 | "eslintConfig": { 87 | "extends": [ 88 | "react-app", 89 | "react-app/jest" 90 | ] 91 | }, 92 | "browserslist": { 93 | "production": [ 94 | ">0.2%", 95 | "not dead", 96 | "not op_mini all" 97 | ], 98 | "development": [ 99 | "last 1 chrome version", 100 | "last 1 firefox version", 101 | "last 1 safari version" 102 | ] 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // See: https://prettier.io/docs/en/options.html 2 | 3 | module.exports = { 4 | printWidth: 120, 5 | 6 | tabWidth: 2, 7 | useTabs: false, 8 | 9 | semi: true, 10 | 11 | singleQuote: false, 12 | jsxSingleQuote: false, 13 | 14 | quoteProps: "as-needed", 15 | 16 | trailingComma: "none", 17 | 18 | bracketSpacing: true, 19 | bracketSameLine: true, 20 | 21 | arrowParens: "avoid", 22 | 23 | requirePragma: false, 24 | insertPragma: false, 25 | 26 | proseWrap: "preserve", 27 | 28 | htmlWhitespaceSensitivity: "css", 29 | 30 | vueIndentScriptAndStyle: false, 31 | 32 | endOfLine: "lf", 33 | 34 | embeddedLanguageFormatting: "auto", 35 | 36 | singleAttributePerLine: false 37 | }; 38 | -------------------------------------------------------------------------------- /public/api/version: -------------------------------------------------------------------------------- 1 | 0.10.0 -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | PATH.JERRYIO 12 | 13 | 14 | 15 |
16 | 17 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "PATH.JERRYIO", 3 | "name": "PATH.JERRYIO", 4 | "description": "The best path editor in the VEX Robotics Competition for designing skills routes and generating path files.", 5 | "icons": [ 6 | { 7 | "src": "precache/favicon.ico", 8 | "sizes": "48x48", 9 | "type": "image/x-icon" 10 | }, 11 | { 12 | "src": "static/logo192.png", 13 | "type": "image/png", 14 | "sizes": "192x192", 15 | "purpose": "any" 16 | }, 17 | { 18 | "src": "static/logo512.png", 19 | "type": "image/png", 20 | "sizes": "512x512", 21 | "purpose": "any" 22 | }, 23 | { 24 | "src": "static/logo512-apple.png", 25 | "type": "image/png", 26 | "sizes": "512x512", 27 | "purpose": "maskable" 28 | } 29 | ], 30 | "start_url": ".", 31 | "display": "standalone", 32 | "theme_color": "#5C469C", 33 | "background_color": "#FFFFFF", 34 | "display_override": [ 35 | "standalone", 36 | "browser", 37 | "minimal-ui", 38 | "window-controls-overlay" 39 | ], 40 | "orientation": "landscape", 41 | "dir": "ltr", 42 | "lang": "en" 43 | } 44 | -------------------------------------------------------------------------------- /public/precache/V5RC-FieldPerimeter-12ft12ft-TopDown-TileColor66_71@1.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/precache/V5RC-FieldPerimeter-12ft12ft-TopDown-TileColor66_71@1.0.png -------------------------------------------------------------------------------- /public/precache/V5RC-PushBack-H2H-TopDownHighlighted-TileColor66_71@0.1+2000px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/precache/V5RC-PushBack-H2H-TopDownHighlighted-TileColor66_71@0.1+2000px.png -------------------------------------------------------------------------------- /public/precache/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/precache/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/static/V5RC-HighStakes-H2H-TopDownHighlighted-TileColor66_71@4.0+2000px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/V5RC-HighStakes-H2H-TopDownHighlighted-TileColor66_71@4.0+2000px.png -------------------------------------------------------------------------------- /public/static/V5RC-HighStakes-Skills-TopDownHighlighted-TileColor66_71@4.0+2000px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/V5RC-HighStakes-Skills-TopDownHighlighted-TileColor66_71@4.0+2000px.png -------------------------------------------------------------------------------- /public/static/V5RC-OverUnder-H2H-TopDown-TileColor66_71@4.0+2000px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/V5RC-OverUnder-H2H-TopDown-TileColor66_71@4.0+2000px.png -------------------------------------------------------------------------------- /public/static/V5RC-OverUnder-Skills-TopDown-TileColor66_71@4.0+2000px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/V5RC-OverUnder-Skills-TopDown-TileColor66_71@4.0+2000px.png -------------------------------------------------------------------------------- /public/static/V5RC-PushBack-Skills-TopDownHighlighted-TileColor66_71@0.1+2000px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/V5RC-PushBack-Skills-TopDownHighlighted-TileColor66_71@0.1+2000px.png -------------------------------------------------------------------------------- /public/static/VIQRC-FieldPerimeter-8ft6ft-TopDown-Original+2000px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/VIQRC-FieldPerimeter-8ft6ft-TopDown-Original+2000px.png -------------------------------------------------------------------------------- /public/static/VIQRC-FullVolume-All-TopDown-Original+2000px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/VIQRC-FullVolume-All-TopDown-Original+2000px.png -------------------------------------------------------------------------------- /public/static/VIQRC-MixAndMatch-H2H-TopDown-Original@0.1+2000px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/VIQRC-MixAndMatch-H2H-TopDown-Original@0.1+2000px.png -------------------------------------------------------------------------------- /public/static/VIQRC-MixAndMatch-Skills-TopDown-Original@0.1+2000px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/VIQRC-MixAndMatch-Skills-TopDown-Original@0.1+2000px.png -------------------------------------------------------------------------------- /public/static/VIQRC-RapidRelay-All-TopDown-Original+2000px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/VIQRC-RapidRelay-All-TopDown-Original+2000px.png -------------------------------------------------------------------------------- /public/static/VURC-HighStakes-H2H-TopDownHighlighted-TileColor66_71@4.0+2000px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/VURC-HighStakes-H2H-TopDownHighlighted-TileColor66_71@4.0+2000px.png -------------------------------------------------------------------------------- /public/static/VURC-HighStakes-Skills-TopDownHighlighted-TileColor66_71@4.0+2000px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/VURC-HighStakes-Skills-TopDownHighlighted-TileColor66_71@4.0+2000px.png -------------------------------------------------------------------------------- /public/static/VURC-OverUnder-H2H-TopDown-TileColor66_71@4.0+2000px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/VURC-OverUnder-H2H-TopDown-TileColor66_71@4.0+2000px.png -------------------------------------------------------------------------------- /public/static/VURC-PushBack-H2H-TopDownHighlighted-TileColor66_71@0.1+2000px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/VURC-PushBack-H2H-TopDownHighlighted-TileColor66_71@0.1+2000px.png -------------------------------------------------------------------------------- /public/static/coordinate-system-preview-cartesian-plane.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/coordinate-system-preview-cartesian-plane.png -------------------------------------------------------------------------------- /public/static/coordinate-system-preview-path-relative-strict-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/coordinate-system-preview-path-relative-strict-mode.png -------------------------------------------------------------------------------- /public/static/coordinate-system-preview-path-relative.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/coordinate-system-preview-path-relative.png -------------------------------------------------------------------------------- /public/static/coordinate-system-preview-vex-gps.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/coordinate-system-preview-vex-gps.png -------------------------------------------------------------------------------- /public/static/instruction-to-brave-browser-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/instruction-to-brave-browser-1.png -------------------------------------------------------------------------------- /public/static/instruction-to-brave-browser-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/instruction-to-brave-browser-2.png -------------------------------------------------------------------------------- /public/static/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/logo192.png -------------------------------------------------------------------------------- /public/static/logo512-apple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/logo512-apple.png -------------------------------------------------------------------------------- /public/static/logo512-safe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/logo512-safe.png -------------------------------------------------------------------------------- /public/static/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/logo512.png -------------------------------------------------------------------------------- /public/static/readme-banner-0.5x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/public/static/readme-banner-0.5x.png -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | test("Dummy", () => { 4 | // render(); 5 | // const linkElement = screen.getByText(/learn react/i); 6 | // expect(linkElement).toBeInTheDocument(); 7 | }); 8 | -------------------------------------------------------------------------------- /src/Root.scss: -------------------------------------------------------------------------------- 1 | @import "app/common.blocks/index"; 2 | 3 | #root { 4 | overflow: hidden; // UX: Fix potential issue on device emulator 5 | 6 | > #Root-Container { 7 | display: flex; 8 | width: 100vw; 9 | width: 100svw; 10 | height: 100vh; 11 | height: 100svh; 12 | box-sizing: border-box; 13 | 14 | &[data-layout="classic"] { 15 | @import "app/classic.blocks/index"; 16 | } 17 | 18 | &[data-layout="exclusive"] { 19 | @import "app/exclusive.blocks/index"; 20 | } 21 | 22 | &[data-layout="mobile"] { 23 | @import "app/mobile.blocks/index"; 24 | } 25 | 26 | &[data-theme="light"] { 27 | --bg-body-color: #ffffff; 28 | --bg-default-color: #ffffff; 29 | --bg-paper-color: #f3f3f3; 30 | --text-primary-color: rgba(0, 0, 0, 0.87); 31 | --text-disabled-color: rgba(0, 0, 0, 0.6); 32 | --link-color: rgba(0, 0, 0, 0.87); 33 | --hover-color: rgba(0, 0, 0, 0.08); 34 | --focused-color: rgba(0, 0, 0, 0.12); 35 | --primary-main-color: #5c469c; 36 | background-color: var(--bg-body-color); 37 | 38 | code { 39 | background-color: #e3e3e3; 40 | } 41 | 42 | td, 43 | th { 44 | border: 1px solid #e3e3e3; 45 | } 46 | 47 | th { 48 | background-color: #f6f6f6; 49 | } 50 | } 51 | 52 | &[data-theme="dark"] { 53 | --bg-body-color: #292929; 54 | --bg-default-color: #1e1e1e; 55 | --bg-paper-color: #353535; 56 | --text-primary-color: rgba(255, 255, 255, 1); 57 | --text-disabled-color: rgba(255, 255, 255, 0.5); 58 | --link-color: rgba(255, 255, 255, 0.87); 59 | --hover-color: rgba(255, 255, 255, 0.04); 60 | --focused-color: rgba(255, 255, 255, 0.12); 61 | --primary-main-color: #7f47b3; 62 | background-color: var(--bg-body-color); 63 | 64 | code { 65 | background-color: var(--bg-default-color); 66 | } 67 | 68 | td, 69 | th { 70 | border: 1px solid #444444; 71 | } 72 | 73 | th { 74 | background-color: #3c3c3c; 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Version.tsx: -------------------------------------------------------------------------------- 1 | export const APP_VERSION_STRING = "0.10.0"; 2 | -------------------------------------------------------------------------------- /src/app/Layouts.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { LayoutProvider, LayoutType } from "@core/Layout"; 3 | import { observer } from "mobx-react-lite"; 4 | import { getAppStores } from "@core/MainApp"; 5 | import { ClassisLayout } from "./classic.blocks/_index"; 6 | import { ExclusiveLayout } from "./exclusive.blocks/_index"; 7 | import { MobileLayout } from "./mobile.blocks/_index"; 8 | 9 | /** 10 | * The Layout component renders the corresponding layout based on the given layout type. 11 | * All overlays will also be pre-rendered. 12 | * @param props.targetLayout - The layout type to render. 13 | * @returns The rendered layout component. 14 | */ 15 | export const Layout = observer((props: { targetLayout: LayoutType }) => { 16 | const { targetLayout } = props; 17 | const { ui } = getAppStores(); 18 | 19 | return ( 20 | 21 | {targetLayout === LayoutType.Classic && } 22 | {targetLayout === LayoutType.Exclusive && } 23 | {targetLayout === LayoutType.Mobile && } 24 | {ui.getAllOverlays().map(obj => ( 25 | {obj.builder()} 26 | ))} 27 | 28 | ); 29 | }); 30 | -------------------------------------------------------------------------------- /src/app/MarkdownSupport.tsx: -------------------------------------------------------------------------------- 1 | // no prettier 2 | 3 | import { Divider, Typography } from "@mui/material"; 4 | import { MDXComponents } from "mdx/types"; 5 | 6 | const MarkdownOverwrittenComponents: MDXComponents = { 7 | /* 8 | Read: https://mdxjs.com/table-of-components/ 9 | The components you can overwrite use their standard HTML names. 10 | Normally, in markdown, those names are: a, blockquote, br, code, em, h1, h2, h3, h4, h5, h6, hr, img, li, ol, p, pre, strong, and ul. 11 | you can also use: del, input, section, sup, table, tbody, td, th, thead, and tr. 12 | */ 13 | 14 | a: (props) => , // eslint-disable-line jsx-a11y/anchor-has-content 15 | // keep blockquote, but modify it in CSS 16 | // keep br 17 | code: (props) => {props.children}, 18 | // keep em 19 | h1: (props) => {props.children}, 20 | h2: (props) => {props.children}, 21 | h3: (props) => {props.children}, 22 | h4: (props) => {props.children}, 23 | h5: (props) => {props.children}, 24 | h6: (props) => {props.children}, 25 | hr: (props) => , 26 | img: (props) => , // eslint-disable-line jsx-a11y/alt-text 27 | li: (props) => {props.children}, 28 | ol: (props) => {props.children}, 29 | p: (props) => {props.children}, 30 | // keep pre 31 | // keep strong 32 | ul: (props) => {props.children}, 33 | // keep del 34 | // keep input 35 | // keep section 36 | // keep sup 37 | // keep table 38 | // keep tbody 39 | td: (props) => {props.children}, 40 | th: (props) => {props.children}, 41 | // keep thead 42 | // keep tr 43 | }; 44 | 45 | export { MarkdownOverwrittenComponents }; 46 | -------------------------------------------------------------------------------- /src/app/MarkdownTest.mdx: -------------------------------------------------------------------------------- 1 | # h1 Heading 2 | ## h2 Heading 3 | ### h3 Heading 4 | #### h4 Heading 5 | ##### h5 Heading 6 | ###### h6 Heading 7 | 8 | ## Horizontal Rules 9 | 10 | ___ 11 | 12 | --- 13 | 14 | *** 15 | 16 | 17 | ## Typographic replacements 18 | 19 | test.. test... test..... test?..... test!.... 20 | 21 | !!!!!! ???? ,, -- --- 22 | 23 | "Smartypants, double quotes" and 'single quotes' 24 | 25 | 26 | ## Emphasis 27 | 28 | **This is bold text** 29 | 30 | __This is bold text__ 31 | 32 | *This is italic text* 33 | 34 | _This is italic text_ 35 | 36 | ~~Strikethrough~~ 37 | 38 | 39 | ## Blockquotes 40 | 41 | > Blockquotes can also be nested... 42 | >> ...by using additional greater-than signs right next to each other... 43 | > > > ...or with spaces between arrows. 44 | 45 | 46 | ## Lists 47 | 48 | Unordered 49 | 50 | + Create a list by starting a line with `+`, `-`, or `*` 51 | + Sub-lists are made by indenting 2 spaces: 52 | - Marker character change forces new list start: 53 | * Ac tristique libero volutpat at 54 | + Facilisis in pretium nisl aliquet 55 | - Nulla volutpat aliquam velit 56 | + Very easy! 57 | 58 | Ordered 59 | 60 | 1. Lorem ipsum dolor sit amet 61 | 2. Consectetur adipiscing elit 62 | 3. Integer molestie lorem at massa 63 | 64 | 65 | 1. You can use sequential numbers... 66 | 1. ...or keep all the numbers as `1.` 67 | 68 | Start numbering with offset: 69 | 70 | 57. foo 71 | 1. bar 72 | 73 | 74 | ## Code 75 | 76 | Inline `code` 77 | 78 | Block code "fences" 79 | 80 | ``` 81 | Sample text here... 82 | ``` 83 | 84 | Syntax highlighting 85 | 86 | ``` js 87 | var foo = function (bar) { 88 | return bar++; 89 | }; 90 | 91 | console.log(foo(5)); 92 | ``` 93 | 94 | 95 | ## Tables 96 | 97 | | Option | Description | 98 | | ------ | ----------- | 99 | | data | path to data files to supply the data that will be passed into templates. | 100 | | engine | engine to be used for processing templates. Handlebars is the default. | 101 | | ext | extension to be used for dest files. | 102 | 103 | Right aligned columns 104 | 105 | | Option | Description | 106 | | ------:| -----------:| 107 | | data | path to data files to supply the data that will be passed into templates. | 108 | | engine | engine to be used for processing templates. Handlebars is the default. | 109 | | ext | extension to be used for dest files. | 110 | 111 | 112 | ## Links 113 | 114 | [link text](http://dev.nodeca.com) 115 | 116 | [link with title](http://nodeca.github.io/pica/demo/ "title text!") 117 | 118 | Autoconverted link https://github.com/nodeca/pica 119 | 120 | 121 | ## Images 122 | 123 | ![Minion](https://octodex.github.com/images/minion.png) 124 | ![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat") 125 | 126 | Like links, Images also have a footnote style syntax 127 | 128 | ![Alt text][id] 129 | 130 | With a reference later in the document defining the URL location: 131 | 132 | [id]: https://octodex.github.com/images/dojocat.jpg "The Dojocat" 133 | 134 | 135 | ## Autolink literals 136 | 137 | www.example.com, https://example.com, and contact@example.com. 138 | 139 | ## Footnote 140 | 141 | A note[^1] 142 | 143 | [^1]: Big note. 144 | 145 | ## Strikethrough 146 | 147 | ~one~ or ~~two~~ tildes. 148 | 149 | ## Table 150 | 151 | | a | b | c | d | 152 | | - | :- | -: | :-: | 153 | 154 | ## Tasklist 155 | 156 | * [ ] to do 157 | - [ ] to do 158 | * [x] done 159 | -------------------------------------------------------------------------------- /src/app/Notice.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@mui/material"; 2 | import { MaterialDesignContent, SnackbarProvider, enqueueSnackbar } from "notistack"; 3 | import { Logger } from "@core/Logger"; 4 | 5 | const StyledMaterialDesignContent = styled(MaterialDesignContent)(() => ({ 6 | "&.notistack-MuiContent-success": { 7 | maxWidth: "16rem" 8 | }, 9 | "&.notistack-MuiContent-error": { 10 | maxWidth: "16rem" 11 | } 12 | })); 13 | 14 | export function enqueueSuccessSnackbar(logger: Logger, message: string, autoHideDuration: number | null = 2000) { 15 | logger.log(message); 16 | return enqueueSnackbar(message, { variant: "success", autoHideDuration }); 17 | } 18 | 19 | export function enqueueInfoSnackbar(logger: Logger, message: string, autoHideDuration: number | null = 2000) { 20 | logger.log(message); 21 | return enqueueSnackbar(message, { variant: "info", autoHideDuration }); 22 | } 23 | 24 | export function enqueueErrorSnackbar(logger: Logger, err: unknown, autoHideDuration: number | null = 2000) { 25 | const errMsg = err instanceof Error ? err.message : err + ""; 26 | logger.error(errMsg); 27 | return enqueueSnackbar(errMsg, { variant: "error", autoHideDuration }); 28 | } 29 | 30 | export function NoticeProvider() { 31 | return ( 32 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/app/Theme.tsx: -------------------------------------------------------------------------------- 1 | import { Components, Theme, ThemeOptions, createTheme } from "@mui/material"; 2 | import { getAppStores } from "@core/MainApp"; 3 | 4 | export enum AppThemeType { 5 | Light = "light", 6 | Dark = "dark" // UX: Default theme 7 | } 8 | 9 | export interface AppThemeInfo { 10 | name: string; 11 | foregroundColor: string; // UX: Default card background color 12 | backgroundColor: string; 13 | styleName: string; 14 | theme: Theme; 15 | } 16 | 17 | const componentsStyleOverrides: Components> = {}; 18 | 19 | function createMuiTheme(options?: ThemeOptions | undefined, ...args: object[]): Theme { 20 | const theme = createTheme(options, ...args); 21 | 22 | // Override default typography 23 | const t = theme.typography; 24 | t.h1.fontSize = "1.75rem"; 25 | t.h2.fontSize = "1.5rem"; 26 | t.h3.fontSize = "1.25rem"; 27 | t.h4.fontSize = "1rem"; 28 | t.h5.fontSize = "1rem"; 29 | t.h6.fontSize = "1rem"; 30 | 31 | // Also read: https://www.joshwcomeau.com/css/rules-of-margin-collapse/ 32 | t.h1.marginTop = "1em"; 33 | t.h2.marginTop = "1em"; 34 | t.h3.marginTop = "1em"; 35 | t.h4.marginTop = "1em"; 36 | t.h5.marginTop = "1em"; 37 | t.h6.marginTop = "1em"; 38 | 39 | return theme; 40 | } 41 | 42 | export const themes = { 43 | [AppThemeType.Light]: { 44 | name: "Light", 45 | foregroundColor: "grey", 46 | backgroundColor: "#ffffff", 47 | styleName: "light", 48 | theme: createMuiTheme({ 49 | palette: { 50 | mode: "light", 51 | primary: { 52 | main: "#5C469C" 53 | } 54 | }, 55 | components: componentsStyleOverrides 56 | }) 57 | }, 58 | [AppThemeType.Dark]: { 59 | name: "Dark", 60 | foregroundColor: "#a4a4a4", 61 | backgroundColor: "#353535", 62 | styleName: "dark", 63 | theme: createMuiTheme({ 64 | palette: { 65 | mode: "dark", 66 | primary: { 67 | light: "#5C469C", 68 | main: "#7F47B3", 69 | dark: "#474AB3", 70 | contrastText: "#FFF" 71 | }, 72 | background: { 73 | default: "#1E1E1E", 74 | paper: "#353535" 75 | } 76 | }, 77 | components: componentsStyleOverrides 78 | }) 79 | } 80 | } as const satisfies { [key in AppThemeType]: Readonly }; 81 | 82 | export function getAppThemeInfo() { 83 | return themes[getAppStores().appPreferences.themeType] ?? themes[AppThemeType.Dark]; 84 | } 85 | -------------------------------------------------------------------------------- /src/app/classic.blocks/MousePositionPresentation.scss: -------------------------------------------------------------------------------- 1 | #MousePositionPresentation { 2 | margin-top: 16px !important; 3 | user-select: none; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/classic.blocks/_index.scss: -------------------------------------------------------------------------------- 1 | $pg: 16px; // panel gap 2 | $hpg: 8px; // half panel gap 3 | $qpg: 4px; // quarter panel gap 4 | $speed-canvas-height-vh: 12vh; 5 | $speed-canvas-height-svh: 12svh; 6 | $middle-section-width-vh: calc(100vh - $pg - $pg - $hpg - $speed-canvas-height-vh - $hpg - $pg); 7 | $middle-section-width-svh: calc(100svh - $pg - $pg - $hpg - $speed-canvas-height-svh - $hpg - $pg); 8 | $middle-section-height-vh: calc(100vh - $pg - $pg); 9 | $middle-section-height-svh: calc(100svh - $pg - $pg); 10 | 11 | & { 12 | padding: 8px; 13 | } 14 | 15 | #LeftSection, 16 | #MiddleSection, 17 | #RightSection { 18 | box-sizing: border-box; 19 | margin: $hpg; 20 | } 21 | 22 | #LeftSection { 23 | float: left; 24 | flex-grow: 1; 25 | min-width: 288px; 26 | max-width: 288px; 27 | margin: calc($hpg - $qpg) !important; 28 | padding-top: $qpg; 29 | overflow-x: hidden; 30 | height: calc(100% - $hpg - $hpg + $hpg); 31 | border-radius: 4px; 32 | } 33 | 34 | #MiddleSection { 35 | display: flex; 36 | width: auto; 37 | height: auto; 38 | flex-direction: column; 39 | 40 | #FieldCanvas-Container { 41 | width: $middle-section-width-vh; 42 | width: $middle-section-width-svh; 43 | height: auto; 44 | position: relative; 45 | display: inline-flex; 46 | 47 | > svg { 48 | height: 100%; 49 | width: 100%; 50 | } 51 | 52 | > div { 53 | position: absolute; 54 | top: 0; 55 | left: 0; 56 | bottom: 0; 57 | right: 0; 58 | padding: $hpg; 59 | overflow: hidden; 60 | } 61 | } 62 | 63 | &.full-height { 64 | #FieldCanvas-Container { 65 | width: $middle-section-height-vh; 66 | width: $middle-section-height-svh; 67 | height: $middle-section-height-vh; 68 | height: $middle-section-height-svh; 69 | } 70 | } 71 | } 72 | 73 | #RightSection { 74 | float: left; 75 | flex-grow: 1; 76 | min-width: 352px; 77 | max-width: 352px; 78 | margin: calc($hpg - $qpg); 79 | margin-bottom: 0; 80 | border-top: $qpg solid var(--bg-body-color); 81 | border-bottom: $qpg solid var(--bg-body-color); 82 | overflow-x: hidden; 83 | overflow-y: auto; 84 | height: calc(100% - $hpg - $hpg + $hpg); 85 | position: relative; 86 | } 87 | 88 | #LeftSection::-webkit-scrollbar, 89 | #RightSection::-webkit-scrollbar { 90 | width: 0; 91 | } 92 | 93 | #LeftSection > *, 94 | #RightSection > * { 95 | margin: 0 $qpg !important; 96 | } 97 | 98 | #LeftSection h3, 99 | #RightSection h3 { 100 | margin: 0; 101 | font-weight: 100; 102 | } 103 | 104 | #LeftSection > *::before, 105 | #RightSection > *::before { 106 | background-color: transparent !important; 107 | } 108 | 109 | // Import all blocks scss file that without tsx implementation 110 | 111 | @import "./panel/ControlConfigPanel"; 112 | @import "./panel/MenuPanel"; 113 | @import "./panel/PathTreePanel"; 114 | @import "./speed-canvas/SpeedCanvasElement"; 115 | @import "./MousePositionPresentation"; 116 | -------------------------------------------------------------------------------- /src/app/classic.blocks/_index.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Card } from "@mui/material"; 2 | import classNames from "classnames"; 3 | import { observer } from "mobx-react-lite"; 4 | import { LayoutType } from "@core/Layout"; 5 | import { getAppStores } from "@core/MainApp"; 6 | import { FieldCanvasElement } from "../common.blocks/field-canvas/FieldCanvasElement"; 7 | import { MenuPanel } from "../common.blocks/panel/MenuPanel"; 8 | import { PanelStaticInstance, PanelAccordionInstance } from "../common.blocks/panel/Panel"; 9 | import { PathTreePanel } from "../common.blocks/panel/PathTreePanel"; 10 | import { SpeedCanvasElement } from "../common.blocks/speed-canvas/SpeedCanvasElement"; 11 | import { MousePositionPresentation } from "../common.blocks/MousePositionPresentation"; 12 | 13 | export const ClassisLayout = observer(() => { 14 | const { appPreferences, ui } = getAppStores(); 15 | 16 | const panelProps = ui.getAllPanels().map(obj => obj.builder({})); 17 | 18 | return ( 19 | <> 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {appPreferences.isSpeedCanvasVisible && ( 31 | 32 | 33 | 34 | )} 35 | 36 | {appPreferences.isRightSectionVisible && ( 37 | 38 | {panelProps.map(panelProp => ( 39 | 40 | ))} 41 | 42 | 43 | )} 44 | 45 | ); 46 | }); 47 | -------------------------------------------------------------------------------- /src/app/classic.blocks/panel/ControlConfigPanel.scss: -------------------------------------------------------------------------------- 1 | #ControlConfigPanel { 2 | text-align: left; 3 | position: sticky; 4 | top: -4px; 5 | z-index: 100; 6 | padding-top: $hpg; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/classic.blocks/panel/MenuPanel.scss: -------------------------------------------------------------------------------- 1 | #MenuPanel { 2 | margin-bottom: $pg !important; 3 | padding: $hpg; 4 | height: calc(10px + 13px); 5 | display: flex; 6 | flex-wrap: wrap; 7 | flex-direction: row; 8 | align-items: center; 9 | 10 | > * { 11 | text-transform: none; 12 | min-width: 32px; 13 | font-weight: 400; 14 | padding: 3.2px 6.4px; 15 | line-height: 1; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/classic.blocks/panel/PathTreePanel.scss: -------------------------------------------------------------------------------- 1 | #PathTreePanel-Container { 2 | height: calc(100% - $qpg - $hpg - (10px + 13px) - $hpg - $pg); 3 | position: relative; 4 | 5 | .MuiAccordionSummary-root { 6 | cursor: unset; 7 | } 8 | 9 | .MuiAccordionSummary-content { 10 | justify-content: space-between; 11 | } 12 | 13 | .MuiAccordionDetails-root { 14 | overflow-y: auto; 15 | position: absolute; 16 | top: 48px; 17 | bottom: 1.6px; 18 | right: 1.6px; 19 | left: 0; 20 | scrollbar-gutter: stable; 21 | padding-right: calc($pg - 6.4px); 22 | } 23 | } 24 | 25 | .PathTreePanel-FunctionButton { 26 | cursor: pointer; 27 | padding: 0; 28 | margin-left: 3.2px; 29 | } 30 | -------------------------------------------------------------------------------- /src/app/classic.blocks/speed-canvas/SpeedCanvasElement.scss: -------------------------------------------------------------------------------- 1 | #SpeedCanvas-Container { 2 | width: auto; 3 | height: $speed-canvas-height-vh; 4 | height: $speed-canvas-height-svh; 5 | padding: $hpg; 6 | overflow: hidden; 7 | margin-top: $pg; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/common.blocks/DragDropBackdrop.tsx: -------------------------------------------------------------------------------- 1 | import { Backdrop, BackdropTypeMap, Typography } from "@mui/material"; 2 | import { DefaultComponentProps } from "@mui/material/OverridableComponent"; 3 | import { observer } from "mobx-react-lite"; 4 | 5 | const DragDropBackdrop = observer((props: Omit, "open">) => { 6 | return ( 7 | theme.zIndex.drawer + 1 }} 10 | open={true} 11 | tabIndex={-1} 12 | {...props}> 13 | 14 | Drop Here 15 | 16 | 17 | ); 18 | }); 19 | 20 | export { DragDropBackdrop }; 21 | -------------------------------------------------------------------------------- /src/app/common.blocks/MousePositionPresentation.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | import { getAppStores } from "@core/MainApp"; 3 | import { Box, BoxProps, Typography } from "@mui/material"; 4 | import { CoordinateSystemTransformation } from "@src/core/CoordinateSystem"; 5 | 6 | const MousePosition = observer(() => { 7 | const { app } = getAppStores(); 8 | 9 | const coord = app.fieldEditor.mousePosInUOL; 10 | if (coord === undefined) return undefined; 11 | 12 | const referencedPath = app.interestedPath(); 13 | if (referencedPath === undefined) return undefined; 14 | 15 | const cs = app.coordinateSystem; 16 | if (cs === undefined) return undefined; 17 | 18 | const fieldDimension = app.fieldDimension; 19 | 20 | const firstControl = referencedPath.segments[0]?.controls[0]; 21 | if (firstControl === undefined) return undefined; 22 | 23 | const cst = new CoordinateSystemTransformation(cs, fieldDimension, firstControl); 24 | const coordInFCS = cst.transform(coord); 25 | 26 | return ( 27 | 28 | X: {coordInFCS.x.toUser()}, Y: {coordInFCS.y.toUser()} 29 | 30 | ); 31 | }); 32 | 33 | const TravelDistance = observer(() => { 34 | const { app } = getAppStores(); 35 | 36 | const interestedPath = app.interestedPath(); 37 | 38 | if (app.robot.position.visible && interestedPath !== undefined) { 39 | const find = app.robot.position.toVector(); 40 | const points = interestedPath.cachedResult.points; 41 | 42 | let distance = 0; 43 | for (let i = 0; i < points.length - 1; i++) { 44 | const p = points[i]; 45 | if (p.x === find.x && p.y === find.y) break; 46 | distance += p.distance(points[i + 1]); 47 | } 48 | 49 | const arcLength = interestedPath.cachedResult.arcLength; 50 | 51 | const traveled = { 52 | distance: distance, 53 | percentage: arcLength === 0 ? 100 : (distance / arcLength) * 100 54 | }; 55 | 56 | return ( 57 | 58 | Traveled: {traveled.distance.toUser()} ({traveled.percentage.toFixed(2)}%) 59 | 60 | ); 61 | } else { 62 | return null; 63 | } 64 | }); 65 | 66 | export const MousePositionPresentation = observer((props: BoxProps) => { 67 | return ( 68 | 69 | 70 | 71 | 72 | ); 73 | }); 74 | -------------------------------------------------------------------------------- /src/app/common.blocks/_index.scss: -------------------------------------------------------------------------------- 1 | *::-webkit-scrollbar { 2 | width: 6.4px; 3 | } 4 | 5 | *::-webkit-scrollbar-track { 6 | -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0); 7 | } 8 | 9 | *:hover::-webkit-scrollbar-thumb { 10 | background-color: rgba(0, 0, 0, 0.2); 11 | border-radius: 3.2px; 12 | } 13 | 14 | body { 15 | margin: 0; 16 | overflow: hidden; 17 | touch-action: none; 18 | } 19 | 20 | h1:first-child, 21 | h2:first-child, 22 | h3:first-child, 23 | h4:first-child, 24 | h5:first-child, 25 | h6:first-child { 26 | margin-top: 0 !important; 27 | } 28 | 29 | a { 30 | color: var(--link-color) !important; 31 | text-decoration: underline !important; 32 | } 33 | 34 | code { 35 | display: inline-block; 36 | padding: 0px 6.4px; 37 | border-radius: 3.2px; 38 | font-family: "Roboto Mono", monospace !important; 39 | font-size: 14.4px !important; 40 | } 41 | 42 | blockquote { 43 | background-color: var(--bg-paper-color); 44 | border-left: 3.2px solid var(--bg-default-color); 45 | padding: 3.2px 6.4px; 46 | margin: 3.2px 0; 47 | font-style: italic; 48 | } 49 | 50 | img.markdown-style { 51 | max-width: 100%; 52 | } 53 | 54 | ol.markdown-style, 55 | ul.markdown-style { 56 | -webkit-padding-start: calc(1em + 1ex); 57 | padding-inline-start: calc(1em + 1ex); 58 | 59 | li { 60 | word-wrap: break-all; 61 | margin-block: calc(0.25em + 0.25ex); 62 | } 63 | 64 | li:has(> input:first-child) { 65 | // Hide dot in checkbox 66 | list-style-type: none; 67 | position: relative; 68 | 69 | input { 70 | position: absolute; 71 | left: -1.75em; 72 | top: 0.25em; 73 | } 74 | } 75 | } 76 | 77 | table { 78 | border-spacing: 0; 79 | border-collapse: collapse; 80 | } 81 | 82 | td, 83 | th { 84 | padding-block: 0.6ex; 85 | padding-inline: 1ex; 86 | } 87 | 88 | // For menu list 89 | .MuiPaper-root { 90 | transition: none !important; 91 | } 92 | 93 | // For accordion 94 | .MuiPaper-root { 95 | background-image: none !important; // remove tiny dot pattern in accordion 96 | } 97 | 98 | .notistack-SnackbarContainer { 99 | // cspell:disable-line 100 | font-family: "Roboto", "Helvetica", "Arial", sans-serif; 101 | } 102 | 103 | // See: https://stackoverflow.com/questions/23885255/how-to-remove-ignore-hover-css-style-on-touch-devices 104 | @mixin on-hover { 105 | @media (hover: hover) and (pointer: fine) { 106 | &:hover { 107 | @content; 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/app/common.blocks/_index.tsx: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /src/app/common.blocks/field-canvas/AreaSelectionElement.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | import { Vector } from "@core/Path"; 3 | import Konva from "konva"; 4 | import { Rect } from "react-konva"; 5 | import React from "react"; 6 | 7 | export interface AreaSelectionElementProps extends Konva.RectConfig { 8 | from?: Vector; 9 | to?: Vector; 10 | animation: boolean; 11 | } 12 | 13 | const AreaSelectionElement = observer((props: AreaSelectionElementProps) => { 14 | const { from, to, animation, ...rest } = props; 15 | const visible = from !== undefined && to !== undefined; 16 | 17 | const ref = React.useRef(null); 18 | const [endAnimation, setEndAnimation] = React.useState(0); 19 | 20 | const playAnimation = visible && animation; 21 | const playingAnimation = playAnimation && Date.now() < endAnimation; 22 | 23 | React.useEffect(() => { 24 | if (visible && animation && playingAnimation === false && ref.current) { 25 | setEndAnimation(Date.now() + 300); 26 | const node = ref.current; 27 | node.x(node.x() - 48); 28 | node.y(node.y() - 48); 29 | node.width(96); 30 | node.height(96); 31 | node.to({ x: to.x - 10, y: to.y - 10, width: 20, height: 20, duration: 0.3 }); 32 | } 33 | }, [visible && animation]); // eslint-disable-line react-hooks/exhaustive-deps 34 | 35 | if (!visible) return null; 36 | 37 | const fixedFrom = new Vector(Math.min(from.x, to.x), Math.min(from.y, to.y)); 38 | const fixedTo = new Vector(Math.max(from.x, to.x), Math.max(from.y, to.y)); 39 | 40 | return ( 41 | 51 | ); 52 | }); 53 | 54 | export { AreaSelectionElement }; 55 | -------------------------------------------------------------------------------- /src/app/common.blocks/field-canvas/RobotElement.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | import { EndControl, Vector } from "@core/Path"; 3 | import { FieldCanvasConverter } from "@core/Canvas"; 4 | import { Group, Line, Rect } from "react-konva"; 5 | 6 | const RobotElement = observer( 7 | (props: { fcc: FieldCanvasConverter; pos: EndControl; width: number; height: number }) => { 8 | const widthInPx = props.width * props.fcc.uol2pixel; 9 | const heightInPx = props.height * props.fcc.uol2pixel; 10 | const startInUOL = props.pos.toVector(); 11 | const startInPx = props.fcc.toPx(startInUOL); 12 | const centerInPx = new Vector(widthInPx / 2, heightInPx / 2); 13 | const frontInPx = centerInPx.add(new Vector(0, -heightInPx / 2)); 14 | 15 | const lineWidth = props.fcc.heightInPx / 600; 16 | 17 | return ( 18 | 25 | 34 | 35 | 36 | ); 37 | } 38 | ); 39 | 40 | export { RobotElement }; 41 | -------------------------------------------------------------------------------- /src/app/common.blocks/field-canvas/SegmentControlVisualLineElement.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | import { Vector } from "@core/Path"; 3 | import { FieldCanvasConverter } from "@core/Canvas"; 4 | import { Line } from "react-konva"; 5 | 6 | const SegmentControlVisualLineElement = observer((props: { start: Vector; end: Vector; fcc: FieldCanvasConverter }) => { 7 | const startInPx = props.fcc.toPx(props.start); 8 | const endInPx = props.fcc.toPx(props.end); 9 | 10 | const lineWidth = props.fcc.heightInPx / 600; 11 | 12 | return ( 13 | 19 | ); 20 | }); 21 | 22 | export { SegmentControlVisualLineElement }; 23 | -------------------------------------------------------------------------------- /src/app/common.blocks/field-canvas/SegmentElement.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from "mobx-react-lite"; 2 | import { Path, Segment } from "@core/Path"; 3 | import { SegmentControlVisualLineElement } from "./SegmentControlVisualLineElement"; 4 | import { SegmentPointsHitBoxElement } from "./SegmentPointsHitBoxElement"; 5 | import { FieldCanvasConverter } from "@core/Canvas"; 6 | 7 | export interface SegmentElementProps { 8 | segment: Segment; 9 | path: Path; 10 | fcc: FieldCanvasConverter; 11 | } 12 | 13 | const SegmentElement = observer((props: SegmentElementProps) => { 14 | return ( 15 | <> 16 | {/* ALGO: Do not calculate points here */} 17 | {props.segment.controls.length === 4 ? ( 18 | <> 19 | {props.segment.controls[1].visible && ( 20 | 25 | )} 26 | {props.segment.controls[2].visible && ( 27 | 32 | )} 33 | 34 | ) : null} 35 | 36 | {/* UX: Do not render control point here due to z-index */} 37 | 38 | ); 39 | }); 40 | 41 | export { SegmentElement }; 42 | -------------------------------------------------------------------------------- /src/app/common.blocks/field-canvas/SegmentPointsHitBoxElement.tsx: -------------------------------------------------------------------------------- 1 | import { action } from "mobx"; 2 | import { observer } from "mobx-react-lite"; 3 | import Konva from "konva"; 4 | import { Line } from "react-konva"; 5 | import { EndControl } from "@core/Path"; 6 | import { SegmentElementProps } from "./SegmentElement"; 7 | import { ConvertSegment, SplitSegment } from "@core/Command"; 8 | import { getAppStores } from "@core/MainApp"; 9 | 10 | const SegmentPointsHitBoxElement = observer((props: SegmentElementProps) => { 11 | const { app } = getAppStores(); 12 | 13 | function onTouchStart(event: Konva.KonvaEventObject) { 14 | event.evt.preventDefault(); 15 | 16 | app.fieldEditor.interactWithEntity(props.segment, "touch"); 17 | } 18 | 19 | function onTouchMove(event: Konva.KonvaEventObject) { 20 | event.evt.preventDefault(); 21 | } 22 | 23 | function onLineClick(event: Konva.KonvaEventObject) { 24 | const evt = event.evt; 25 | 26 | // UX: Do not interact with segment if any of its control points or the path is locked 27 | const isLocked = props.segment.isLocked() || props.path.lock; 28 | if (isLocked) { 29 | evt.preventDefault(); 30 | return; 31 | } 32 | 33 | const posInPx = props.fcc.getUnboundedPxFromEvent(event); 34 | if (posInPx === undefined) return; 35 | 36 | const cpInUOL = props.fcc.toUOL(new EndControl(posInPx.x, posInPx.y, 0)); 37 | 38 | if (evt.button === 2) { 39 | // right click 40 | // UX: Split segment if: right click 41 | app.history.execute( 42 | `Split segment ${props.segment.uid} with control ${cpInUOL.uid}`, 43 | new SplitSegment(props.path, props.segment, cpInUOL) 44 | ); 45 | } else if (evt.button === 0) { 46 | // UX: Convert segment if: left click 47 | app.history.execute(`Convert segment ${props.segment.uid}`, new ConvertSegment(props.path, props.segment)); 48 | } 49 | } 50 | 51 | const pointWidth = (props.fcc.heightInPx / 320) * 8; 52 | 53 | return ( 54 | { 56 | const cpInPx = props.fcc.toPx(cp.toVector()); 57 | return [cpInPx.x, cpInPx.y]; 58 | })} 59 | strokeWidth={pointWidth} 60 | stroke={"red"} 61 | opacity={0} 62 | bezier={props.segment.controls.length > 2} 63 | onClick={action(onLineClick)} 64 | onTouchStart={action(onTouchStart)} 65 | onTouchEnd={action(onTouchMove)} 66 | /> 67 | ); 68 | }); 69 | 70 | export { SegmentPointsHitBoxElement }; 71 | -------------------------------------------------------------------------------- /src/app/common.blocks/modal/AboutModal.scss: -------------------------------------------------------------------------------- 1 | #AboutModal { 2 | padding: 32px; 3 | width: 512px; 4 | max-width: 80%; 5 | min-height: 96px; 6 | outline: none !important; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/common.blocks/modal/AboutModal.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Card, Typography } from "@mui/material"; 2 | import { observer } from "mobx-react-lite"; 3 | import { Modal } from "./Modal"; 4 | import { APP_VERSION } from "@core/MainApp"; 5 | 6 | import "./AboutModal.scss"; 7 | 8 | export const AboutModalSymbol = Symbol("AboutModal"); 9 | 10 | export const AboutModal = observer(() => { 11 | const InlineLink = (props: { href: string; children: React.ReactNode }) => ( 12 | 21 | ); 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | PATH.JERRYIO Version {APP_VERSION.version} 29 | 30 | 31 | Made by Jerry Lum 32 | 33 | 34 | This is a free software licensed under GPL-3.0 35 | 36 | 37 | Source Code 38 | License 39 | Privacy Terms 40 | About Free Software 41 | Join Our Discord Server 42 | 43 | 44 | 45 | ); 46 | }); 47 | -------------------------------------------------------------------------------- /src/app/common.blocks/modal/AssetManagerModal.scss: -------------------------------------------------------------------------------- 1 | #AssetManagerModal { 2 | padding: 16px; 3 | width: 768px; 4 | max-width: 80%; 5 | min-height: 96px; 6 | max-height: 80%; 7 | outline: none !important; 8 | overflow-y: auto; 9 | padding-right: calc(16px - 6.4px); 10 | display: flex; 11 | flex-direction: column; 12 | scrollbar-gutter: stable; 13 | 14 | .FieldImageAssets-Title { 15 | .FieldImageAssets-FunctionButton { 16 | cursor: pointer; 17 | padding: 0; 18 | margin-left: 8px; 19 | } 20 | } 21 | 22 | #FieldImageAssets-Body { 23 | display: flex; 24 | width: 100%; 25 | gap: 16px; 26 | flex-wrap: wrap; 27 | 28 | #FieldImageAssets-LeftSide { 29 | flex-grow: 1000; 30 | max-width: 100%; 31 | width: 360px; 32 | 33 | #FieldImageAssetsList { 34 | flex-grow: 1; 35 | overflow-x: hidden; 36 | overflow-y: auto; 37 | padding-bottom: 8px; // maybe 16px 38 | max-height: 400px; 39 | 40 | .FieldImageAssetsList-Item { 41 | .FieldImageAssetsList-ItemApplyButton { 42 | visibility: hidden; 43 | } 44 | } 45 | 46 | .FieldImageAssetsList-Item:hover { 47 | .FieldImageAssetsList-ItemApplyButton { 48 | visibility: inherit; 49 | } 50 | } 51 | } 52 | } 53 | 54 | #FieldImageAssets-PreviewSection { 55 | width: 40%; 56 | min-width: 256px; 57 | flex-grow: 1; 58 | display: flex; 59 | flex-direction: column; 60 | align-items: center; 61 | 62 | #FieldImageAssets-AssetImagePreview { 63 | width: 100%; 64 | line-height: 0; 65 | border: 1px solid var(--text-primary-color); 66 | position: relative; 67 | 68 | > #FieldImageAssets-FailedMessage { 69 | position: absolute; 70 | top: 0; 71 | left: 0; 72 | right: 0; 73 | bottom: 0; 74 | padding: 16px; 75 | text-align: center; 76 | display: flex; 77 | align-content: center; 78 | flex-wrap: wrap; 79 | user-select: none; 80 | pointer-events: none; 81 | } 82 | 83 | > img { 84 | position: absolute; 85 | top: 0; 86 | left: 0; 87 | width: 100%; 88 | height: 100%; 89 | object-fit: contain; 90 | user-select: none; 91 | cursor: pointer; 92 | } 93 | 94 | > #FieldImageAssets-ReloadButton { 95 | position: absolute; 96 | top: 0; 97 | left: 0; 98 | right: 0; 99 | bottom: 0; 100 | text-align: center; 101 | display: flex; 102 | align-content: center; 103 | flex-wrap: wrap; 104 | background-color: rgba(255, 255, 255, 0.2); 105 | opacity: 0; 106 | transition: opacity 0.2s ease-in-out; 107 | user-select: none; 108 | cursor: pointer; 109 | 110 | > * { 111 | width: 100%; 112 | } 113 | 114 | &:hover { 115 | opacity: 1; 116 | 117 | & + #FieldImageAssets-FailedMessage { 118 | display: none; 119 | } 120 | } 121 | } 122 | } 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/app/common.blocks/modal/ConfirmationModal.scss: -------------------------------------------------------------------------------- 1 | #ConfirmationModal { 2 | padding: 16px; 3 | min-width: 320px; 4 | max-width: 80%; 5 | min-height: 96px; 6 | outline: none !important; 7 | } 8 | 9 | .ConfirmationModal-DescriptionBox { 10 | display: flex; 11 | 12 | > * { 13 | flex-grow: 1; 14 | width: 0; 15 | } 16 | } 17 | 18 | .ConfirmationModal-InputBox { 19 | width: 100%; 20 | 21 | > * { 22 | width: 100%; 23 | } 24 | } 25 | 26 | .ConfirmationModal-ButtonBox { 27 | display: flex; 28 | margin-top: 16px; 29 | justify-content: flex-end; 30 | align-items: flex-end; 31 | gap: 8px; 32 | 33 | *:focus { 34 | background-color: rgba(255, 255, 255, 0.1); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/common.blocks/modal/CoordinateSystemModal.scss: -------------------------------------------------------------------------------- 1 | #CoordinateSystemModal { 2 | padding: 16px; 3 | width: 768px; 4 | max-width: 80%; 5 | min-height: 96px; 6 | max-height: 80%; 7 | outline: none !important; 8 | overflow-y: auto; 9 | padding-right: calc(16px - 6.4px); 10 | display: flex; 11 | flex-direction: column; 12 | scrollbar-gutter: stable; 13 | 14 | #CoordinateSystem-Body { 15 | display: flex; 16 | width: 100%; 17 | gap: 16px; 18 | flex-wrap: wrap; 19 | 20 | #CoordinateSystems-LeftSide { 21 | flex-grow: 1000; 22 | max-width: 100%; 23 | width: 360px; 24 | 25 | #CoordinateSystemsList { 26 | flex-grow: 1; 27 | overflow-x: hidden; 28 | overflow-y: auto; 29 | padding-bottom: 8px; // maybe 16px 30 | max-height: 400px; 31 | 32 | .CoordinateSystemsList-Item { 33 | .CoordinateSystemsList-ItemApplyButton { 34 | visibility: hidden; 35 | } 36 | } 37 | 38 | .CoordinateSystemsList-Item:hover { 39 | .CoordinateSystemsList-ItemApplyButton { 40 | visibility: inherit; 41 | } 42 | } 43 | } 44 | } 45 | 46 | #CoordinateSystems-PreviewSection { 47 | width: 40%; 48 | min-width: 256px; 49 | flex-grow: 1; 50 | display: flex; 51 | flex-direction: column; 52 | align-items: center; 53 | 54 | #CoordinateSystems-ImagePreview { 55 | width: 100%; 56 | line-height: 0; 57 | border: 1px solid var(--text-primary-color); 58 | position: relative; 59 | 60 | > img { 61 | position: absolute; 62 | top: 0; 63 | left: 0; 64 | width: 100%; 65 | height: 100%; 66 | object-fit: contain; 67 | user-select: none; 68 | cursor: pointer; 69 | } 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/app/common.blocks/modal/Modal.scss: -------------------------------------------------------------------------------- 1 | .Modal-Backdrop { 2 | margin: 0; 3 | background-color: transparent !important; 4 | backdrop-filter: blur(12px); 5 | outline: none !important; 6 | } 7 | 8 | .Modal-Container { 9 | position: absolute; 10 | top: 50%; 11 | left: 50%; 12 | transform: translate(-50%, -50%); 13 | color: var(--text-primary-color); 14 | } 15 | -------------------------------------------------------------------------------- /src/app/common.blocks/modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import Modal from "@mui/material/Modal"; 2 | 3 | import { reaction } from "mobx"; 4 | import { observer } from "mobx-react-lite"; 5 | import React from "react"; 6 | import { getAppStores } from "@core/MainApp"; 7 | 8 | import "./Modal.scss"; 9 | 10 | export const CustomModal = observer( 11 | (props: { 12 | symbol: Symbol; // 13 | children: React.ReactElement; 14 | onOpen?: () => void; 15 | onClose?: () => void; 16 | }) => { 17 | const { ui } = getAppStores(); 18 | 19 | React.useEffect(() => { 20 | const disposer = reaction( 21 | () => ui.openingModal, 22 | (curr: Symbol | null, prev: Symbol | null) => { 23 | if (prev === props.symbol && curr === null) props.onClose?.(); 24 | if (prev === null && curr === props.symbol) props.onOpen?.(); 25 | } 26 | ); 27 | 28 | return () => { 29 | disposer(); 30 | }; 31 | }, []); // eslint-disable-line react-hooks/exhaustive-deps 32 | 33 | return ( 34 | ui.closeModal(props.symbol)}> 39 | {props.children} 40 | 41 | ); 42 | } 43 | ); 44 | 45 | export { CustomModal as Modal }; 46 | -------------------------------------------------------------------------------- /src/app/common.blocks/modal/PreferencesModal.scss: -------------------------------------------------------------------------------- 1 | #PreferencesModal { 2 | padding: 16px; 3 | width: 512px; 4 | max-width: 80%; 5 | min-height: 96px; 6 | outline: none !important; 7 | 8 | hr { 9 | margin: 16px 0; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/app/common.blocks/modal/PreferencesModal.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Divider, Typography } from "@mui/material"; 2 | import { observer } from "mobx-react-lite"; 3 | import { getAppStores } from "@core/MainApp"; 4 | import { AppThemeType } from "@app/Theme"; 5 | import { clamp } from "@core/Util"; 6 | import { FormEnumSelect } from "@app/component.blocks/FormEnumSelect"; 7 | import { FormCheckbox } from "@app/component.blocks/FormCheckbox"; 8 | import { FormInputField } from "@app/component.blocks/FormInputField"; 9 | import { Modal } from "./Modal"; 10 | import { enqueueInfoSnackbar } from "@app/Notice"; 11 | import { Logger } from "@core/Logger"; 12 | 13 | import "./PreferencesModal.scss"; 14 | 15 | export const PreferencesModalSymbol = Symbol("PreferencesModalSymbol"); 16 | 17 | export const PreferencesModal = observer(() => { 18 | const logger = Logger("Preferences"); 19 | const { appPreferences } = getAppStores(); 20 | 21 | return ( 22 | 23 | 24 | 25 | General 26 | 27 | appPreferences.maxHistory.toString()} 31 | setValue={v => (appPreferences.maxHistory = clamp(parseInt(v), 10, 1000))} 32 | isValidIntermediate={v => v === "" || new RegExp("^[1-9][0-9]*$").test(v)} 33 | isValidValue={v => new RegExp("^[1-9][0-9]*$").test(v)} 34 | numeric 35 | /> 36 | 37 | 38 | 39 | Appearance 40 | (appPreferences.themeType = v)} 45 | enumType={AppThemeType} 46 | /> 47 | 48 | 49 | 50 | Other 51 | (appPreferences.isGoogleAnalyticsEnabled = v)} 55 | /> 56 | { 60 | appPreferences.isExperimentalFeaturesEnabled = v; 61 | enqueueInfoSnackbar(logger, "Please refresh the page to apply this change.", 5000); 62 | }} 63 | /> 64 | 65 | 66 | ); 67 | }); 68 | -------------------------------------------------------------------------------- /src/app/common.blocks/modal/RequireLocalFieldImageModal.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/src/app/common.blocks/modal/RequireLocalFieldImageModal.scss -------------------------------------------------------------------------------- /src/app/common.blocks/modal/RequireLocalFieldImageModal.tsx: -------------------------------------------------------------------------------- 1 | import { getAppStores } from "@core/MainApp"; 2 | import { createLocalFieldImage } from "@core/Asset"; 3 | import { observer } from "mobx-react-lite"; 4 | import { action } from "mobx"; 5 | import { Card, Typography, Box, Button } from "@mui/material"; 6 | import { MuiFileInput } from "mui-file-input"; 7 | import { Modal } from "./Modal"; 8 | import React from "react"; 9 | import { RequireLocalFieldImageModalSymbol } from "@core/FieldImagePrompt"; 10 | import "./RequireLocalFieldImageModal.scss"; 11 | 12 | export const RequireLocalFieldImageModal = observer(() => { 13 | const { assetManager, ui } = getAppStores(); 14 | 15 | const [errorMessage, setErrorMessage] = React.useState(undefined); 16 | 17 | const requirement = assetManager.requiringLocalFieldImage; 18 | if (requirement === null) return <>; 19 | 20 | const signAndOrigin = requirement.requireSignAndOrigin; 21 | 22 | return ( 23 | ui.closeModal())}> 24 | 25 | 26 | Upload Missing Field Image 27 | 28 | {/* https://stackoverflow.com/questions/9769587/set-div-to-have-its-siblings-width */} 29 | 30 | 31 | This path file recommends the use of a custom field image and it is missing from your local storage.
32 |
33 | You can upload and install the image file or click "No" to use the default field image instead.
34 |
35 | Name: {signAndOrigin.displayName}
36 | Height {signAndOrigin.origin.heightInMM}mm 37 | {errorMessage !== undefined && ( 38 | <> 39 |
40 |
41 | {errorMessage} 42 | 43 | )} 44 |
45 |
46 | { 50 | if (file === null) return; 51 | 52 | const asset = await createLocalFieldImage( 53 | signAndOrigin.displayName, 54 | signAndOrigin.origin.heightInMM, 55 | file.slice() 56 | ); 57 | if (asset === undefined) { 58 | setErrorMessage("*The field image is invalid."); 59 | return; 60 | } 61 | if (asset.signature !== signAndOrigin.signature) { 62 | setErrorMessage( 63 | "*The signature of the field image does not match the recommended field image. Try again." 64 | ); 65 | asset.removeFromStorage(); 66 | return; 67 | } 68 | 69 | requirement.answer = asset; 70 | 71 | ui.closeModal(); 72 | })} 73 | size="small" 74 | /> 75 | 76 | 86 | 87 |
88 |
89 | ); 90 | }); 91 | -------------------------------------------------------------------------------- /src/app/common.blocks/modal/WelcomeForBrave.mdx: -------------------------------------------------------------------------------- 1 | app logo 2 | 3 |
4 | # Welcome to PATH.JERRYIO 5 | Before you start, please take a few minutes to read our [tutorial](https://docs.path.jerryio.com/docs/getting-started). 6 |
7 | 8 |
9 | 10 | ## Hey Brave User! 11 | 12 | We respect your privacy! We are glad that you are using a privacy-focused browser and we have disabled Google Analytics for you. 13 | Please read our [Privacy Policy](https://github.com/Jerrylum/path.jerryio/blob/main/PRIVACY.md) for more information. 14 | 15 | This app is using some canvas features that are disabled by default to protect your privacy on Brave Browser. 16 | In order for this app to work properly, please disable the protection by clicking on the Brave Logo on the right of the address bar and select `Allow fingerprinting` in the drop-down menu. 17 | 18 | ![An instruction showing how to disable fingerprinting protection on Brave Browser](static/instruction-to-brave-browser-1.png) 19 | 20 | Also, please enable File System Access API by going to `brave://flags/#file-system-access-api` and selecting `Enabled` in the drop-down menu. 21 | This API is used to save your work to your computer. 22 | Websites only have access to the file you select and cannot access any other files on your computer. 23 | It is safe and is enabled by default on other browsers. 24 | 25 | ![An instruction showing how to enable File System Access API on Brave Browser](static/instruction-to-brave-browser-2.png) 26 | 27 | ## Introduction 28 | 29 | PATH.JERRYIO is a multi-purpose path editor/planner which can be used to design routes for one-minute driver skill and generate path files. It is a Progressive Web App (PWA) that is installable and can be used on any device without an internet connection. 30 | 31 | ![A banner showing PATH.JERRYIO running on the iMac, MacBook, iPad, and iPhone](static/readme-banner-0.5x.png) 32 | 33 | More information about this editor can be found on the [GitHub Page](https://github.com/Jerrylum/path.jerryio). 34 | 35 | ## Support 36 | 37 | If you have any questions or suggestions, please join our [Discord Server](https://discord.gg/4uVSVXXBBa). 38 | You can also report bugs or request features on the [GitHub Page](https://github.com/Jerrylum/path.jerryio) too. 39 | -------------------------------------------------------------------------------- /src/app/common.blocks/modal/WelcomeForOthers.mdx: -------------------------------------------------------------------------------- 1 | import { FormCheckbox } from "@app/component.blocks/FormCheckbox"; 2 | 3 | app logo 4 | 5 |
6 | # Welcome to PATH.JERRYIO 7 | Before you start, please take a few minutes to read our [tutorial](https://docs.path.jerryio.com/docs/getting-started). 8 |
9 | 10 |
11 | 12 | PATH.JERRYIO is a multi-purpose path editor/planner which can be used to design routes for one-minute driver skill and generate path files. It is a Progressive Web App (PWA) that is installable and can be used on any device without an internet connection. 13 | 14 | ![A banner showing PATH.JERRYIO running on the iMac, MacBook, iPad, and iPhone](static/readme-banner-0.5x.png) 15 | 16 | More information about this editor can be found on the [GitHub Page](https://github.com/Jerrylum/path.jerryio). 17 | 18 | ## We Respect Your Privacy 19 | 20 | This app uses Google Analytics to collect anonymous usage data after leaving this page. 21 | You can disable it now or at any time on the Preference Page. 22 | Please read our [Privacy Policy](https://github.com/Jerrylum/path.jerryio/blob/main/PRIVACY.md) for more information. 23 | 24 | 25 | 26 | ## Support 27 | 28 | If you have any questions or suggestions, please join our [Discord Server](https://discord.gg/4uVSVXXBBa). 29 | You can also report bugs or request features on the [GitHub Page](https://github.com/Jerrylum/path.jerryio) too. 30 | -------------------------------------------------------------------------------- /src/app/common.blocks/modal/WelcomeModal.scss: -------------------------------------------------------------------------------- 1 | #WelcomeModal { 2 | padding: 16px; 3 | width: 768px; 4 | max-width: 80%; 5 | min-height: 96px; 6 | outline: none !important; 7 | height: 80%; 8 | overflow-y: auto; 9 | } 10 | 11 | .WelcomeModal-Logo { 12 | margin: auto; 13 | width: 128px; 14 | display: block; 15 | } 16 | 17 | @supports not (-moz-appearance: none) { 18 | #WelcomeModal { 19 | padding-right: 10px; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app/common.blocks/modal/WelcomeModal.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card } from "@mui/material"; 2 | import { Box } from "@mui/system"; 3 | import { observer } from "mobx-react-lite"; 4 | import { Modal } from "./Modal"; 5 | import { MarkdownOverwrittenComponents } from "@app/MarkdownSupport"; 6 | import { action } from "mobx"; 7 | import React from "react"; 8 | import { LayoutContext, LayoutType } from "@core/Layout"; 9 | import { getAppStores } from "@core/MainApp"; 10 | import WelcomeMDX from "./WelcomeForOthers.mdx"; 11 | import WelcomeForBraveMDX from "./WelcomeForBrave.mdx"; 12 | import { isBraveBrowser } from "@core/Util"; 13 | 14 | import "./WelcomeModal.scss"; 15 | 16 | export const WelcomeModalSymbol = Symbol("WelcomeModalSymbol"); 17 | 18 | export const WelcomeModal = observer(() => { 19 | const { appPreferences, ui } = getAppStores(); 20 | 21 | const rawGAEnabled = localStorage.getItem("googleAnalyticsEnabled"); 22 | const [isGAEnabled, setIsGAEnabled] = React.useState(rawGAEnabled !== "false"); // UX: Default to true 23 | 24 | const isBrave = isBraveBrowser(); 25 | 26 | React.useEffect(() => { 27 | setIsGAEnabled(rawGAEnabled !== "false"); 28 | if (rawGAEnabled === null) ui.openModal(WelcomeModalSymbol); // UX: Show welcome page if user is new 29 | }, [ui, rawGAEnabled]); 30 | 31 | // UX: Save user preference when user closes the modal 32 | const onClose = () => { 33 | // Get the latest value of isGAEnabled and save it to localStorage 34 | setIsGAEnabled(action((curr: boolean) => (appPreferences.isGoogleAnalyticsEnabled = curr && isBrave === false))); 35 | ui.closeModal(WelcomeModalSymbol); 36 | }; 37 | 38 | const isMobileLayout = React.useContext(LayoutContext) === LayoutType.Mobile; 39 | 40 | return ( 41 | 42 | 43 | {isMobileLayout && ( 44 | 45 | 46 | 47 | )} 48 | {isBrave ? ( 49 | 50 | ) : ( 51 | 52 | )} 53 | {isMobileLayout && ( 54 | 55 | 56 | 57 | )} 58 | 59 | 60 | ); 61 | }); 62 | -------------------------------------------------------------------------------- /src/app/common.blocks/panel/ControlConfigPanel.scss: -------------------------------------------------------------------------------- 1 | .ControlConfigPanel-ActionButton { 2 | border-radius: 0.25rem !important; 3 | 4 | .MuiTouchRipple-root .MuiTouchRipple-child { 5 | border-radius: 0.25rem !important; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/app/common.blocks/panel/MenuPanel.scss: -------------------------------------------------------------------------------- 1 | .Menu-Item.MuiMenuItem-root { 2 | padding-left: 4px !important; 3 | 4 | &.Mui-disabled { 5 | pointer-events: auto !important; 6 | 7 | &:hover { 8 | // UX: don't change background color on hover 9 | background-color: transparent !important; 10 | } 11 | 12 | &:active { 13 | // https://moshfeu.github.io/show-tooltip-on-pointer-events-none-element/ 14 | pointer-events: none !important; 15 | } 16 | } 17 | 18 | svg.Menu-ItemDoneIcon { 19 | height: 16px; 20 | margin-right: 4px; 21 | position: relative; 22 | top: -1px; 23 | } 24 | 25 | svg.Menu-ItemNextIcon { 26 | height: 24px; 27 | position: relative; 28 | top: -1px; 29 | right: -8px; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/common.blocks/panel/Panel.scss: -------------------------------------------------------------------------------- 1 | .Panel-Header { 2 | margin: 0; 3 | font-family: "Roboto", "Helvetica", "Arial", sans-serif; 4 | font-weight: 400; 5 | font-size: 1rem; 6 | line-height: 1.5; 7 | letter-spacing: 0.00938em; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/common.blocks/panel/Panel.tsx: -------------------------------------------------------------------------------- 1 | import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; 2 | import { AccordionSummary, Box, AccordionDetails, Card, Accordion } from "@mui/material"; 3 | import { PanelInstanceProps } from "@core/Layout"; 4 | import { observer } from "mobx-react-lite"; 5 | import "./Panel.scss"; 6 | 7 | export interface PanelStaticInstanceProps extends PanelInstanceProps { 8 | containerProps?: React.ComponentProps; 9 | headerProps?: React.ComponentProps; 10 | bodyProps?: React.ComponentProps; 11 | } 12 | 13 | export const PanelStaticInstance = observer((props: PanelStaticInstanceProps) => { 14 | return ( 15 | 16 | 17 | {props.header} 18 | 19 | {props.children} 20 | 21 | ); 22 | }); 23 | 24 | export interface PanelAccordionInstanceProps extends PanelInstanceProps { 25 | containerProps?: React.ComponentProps; 26 | headerProps?: React.ComponentProps; 27 | bodyProps?: React.ComponentProps; 28 | } 29 | 30 | export const PanelAccordionInstance = observer((props: PanelAccordionInstanceProps) => { 31 | return ( 32 | 33 | } className="Panel-Header" {...props.headerProps}> 34 | {props.header} 35 | 36 | {props.children} 37 | 38 | ); 39 | }); 40 | 41 | export interface PanelFloatingInstanceProps extends PanelInstanceProps { 42 | containerProps?: React.ComponentProps; 43 | headerProps?: React.ComponentProps; 44 | bodyProps?: React.ComponentProps; 45 | } 46 | 47 | export const PanelFloatingInstance = observer((props: PanelFloatingInstanceProps) => { 48 | return ( 49 | 50 | 51 | {props.header} 52 | 53 | {props.children} 54 | 55 | ); 56 | }); 57 | -------------------------------------------------------------------------------- /src/app/common.blocks/panel/PathTreePanel.scss: -------------------------------------------------------------------------------- 1 | .PathTreePanel-TreeView { 2 | padding: 0; 3 | list-style: none; 4 | outline: 0; 5 | flex-grow: 1; 6 | max-width: 100%; 7 | overflow-x: hidden; 8 | overflow-y: auto; 9 | margin: 8px 0 0; 10 | min-height: calc(100% - 8px); 11 | 12 | .PathTreePanel-TreeItem { 13 | list-style: none; 14 | margin: 0; 15 | padding: 0; 16 | outline: 0; 17 | user-select: none; 18 | position: relative; 19 | 20 | .PathTreePanel-TreeItemContent { 21 | padding: 0 0 0 8px; 22 | width: auto; 23 | display: flex; 24 | align-items: center; 25 | cursor: pointer; 26 | -webkit-tap-highlight-color: transparent; 27 | position: relative; 28 | 29 | .PathTreePanel-TreeItemIconContainer { 30 | margin-right: 4px; 31 | width: 15px; 32 | display: flex; 33 | -webkit-flex-shrink: 0; 34 | -ms-flex-negative: 0; 35 | flex-shrink: 0; 36 | justify-content: center; 37 | 38 | svg { 39 | font-size: 18px; 40 | } 41 | } 42 | 43 | .PathTreePanel-TreeItemLabel { 44 | width: 100%; 45 | min-width: 0; 46 | padding-left: 4px; 47 | position: relative; 48 | font-family: "Roboto", "Helvetica", "Arial", sans-serif; 49 | font-weight: 400; 50 | font-size: 16px; 51 | line-height: 1.5; 52 | height: 1.5em; 53 | letter-spacing: 0.00938em; 54 | display: flex; 55 | align-items: center; 56 | 57 | .PathTreePanel-TreeItemName { 58 | flex-grow: 1; 59 | overflow: hidden; 60 | height: calc(1.5em - 2px); 61 | margin-right: 1px; 62 | } 63 | 64 | .PathTreePanel-TreeItemName_edit { 65 | outline: 1px #7f47b3 solid; 66 | } 67 | 68 | .PathTreePanel-TreeItemName_preview { 69 | white-space: nowrap; 70 | text-overflow: ellipsis; 71 | } 72 | 73 | .PathTreePanel-TreeFuncIcon { 74 | height: calc(1em * 0.7) !important; 75 | vertical-align: middle; 76 | visibility: hidden; 77 | } 78 | 79 | .PathTreePanel-TreeFuncIcon_show { 80 | visibility: visible; 81 | } 82 | } 83 | 84 | .PathTreePanel-TreeItemLabel:hover > .PathTreePanel-TreeFuncIcon { 85 | visibility: visible; 86 | } 87 | } 88 | 89 | .PathTreePanel-TreeItemContent:hover { 90 | background-color: var(--hover-color); 91 | } 92 | 93 | .PathTreePanel-TreeItemContent.focused { 94 | background-color: var(--focused-color); 95 | } 96 | 97 | .PathTreePanel-TreeItemContent.selected { 98 | background-color: rgba(127, 71, 179, 0.16); 99 | } 100 | 101 | .PathTreePanel-TreeItemContent.selected:hover { 102 | background-color: rgba(127, 71, 179, 0.24); 103 | } 104 | 105 | .PathTreePanel-TreeItemContent.selected.focused { 106 | background-color: rgba(127, 71, 179, 0.28); 107 | } 108 | 109 | .PathTreePanel-TreeItemContent.deny-drop { 110 | opacity: 0.5; 111 | } 112 | 113 | .PathTreePanel-TreeItemChildrenGroup { 114 | height: auto; 115 | overflow: visible; 116 | margin: 0; 117 | padding: 0; 118 | // margin-left: 17px; 119 | min-height: 0px; 120 | transition-duration: 300ms; 121 | transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; 122 | } 123 | } 124 | 125 | .PathTreePanel-DraggingDividerTop::after { 126 | content: ""; 127 | border-top: 1px solid #7f47b3; 128 | width: 100%; 129 | display: block; 130 | position: absolute; 131 | left: 0; 132 | top: 0; 133 | } 134 | 135 | .PathTreePanel-DraggingDividerBottom::after { 136 | content: ""; 137 | border-top: 1px solid #7f47b3; 138 | width: 100%; 139 | display: block; 140 | position: absolute; 141 | left: 0; 142 | bottom: 0; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/app/common.blocks/speed-canvas/SpeedCanvasElement.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jerrylum/path.jerryio/222297f6269e06da250e159c127c4c3350e815f0/src/app/common.blocks/speed-canvas/SpeedCanvasElement.scss -------------------------------------------------------------------------------- /src/app/component.blocks/CanvasTooltip.scss: -------------------------------------------------------------------------------- 1 | .CanvasTooltip { 2 | user-select: none; 3 | pointer-events: none !important; 4 | } 5 | 6 | .CanvasTooltip-Label { 7 | padding: 8px 12px; 8 | display: inline-block; 9 | cursor: pointer; 10 | user-select: none; 11 | pointer-events: auto; 12 | } 13 | -------------------------------------------------------------------------------- /src/app/component.blocks/CanvasTooltip.tsx: -------------------------------------------------------------------------------- 1 | import { action } from "mobx"; 2 | import { Tooltip, TooltipProps, Typography, styled, tooltipClasses } from "@mui/material"; 3 | import { getAppStores } from "@core/MainApp"; 4 | import classNames from "classnames"; 5 | import "./CanvasTooltip.scss"; 6 | 7 | export const Padding0Tooltip = styled(({ className, ...props }: TooltipProps) => ( 8 | 9 | ))(({ theme }) => ({ 10 | [`& .${tooltipClasses.tooltip}`]: { 11 | padding: "0", 12 | marginBottom: "8px !important" 13 | } 14 | })); 15 | 16 | export const CanvasTooltip = function (props: { text: string; onClick: () => void }) { 17 | const { app } = getAppStores(); 18 | 19 | return ( 20 | { 25 | props.onClick(); 26 | app.fieldEditor.tooltipPosition = undefined; // UX: Hide tooltip 27 | app.speedEditor.tooltipPosition = undefined; // UX: Hide tooltip 28 | })}> 29 | {props.text} 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/app/component.blocks/FormButton.scss: -------------------------------------------------------------------------------- 1 | .FormButton-Button { 2 | all: unset; 3 | margin: 0; 4 | border-radius: 4px; 5 | border-style: solid; 6 | border-width: 1px; 7 | text-overflow: ellipsis; 8 | white-space: nowrap; 9 | overflow: hidden; 10 | min-width: 0%; 11 | border-color: rgba(255, 255, 255, 0.23); 12 | line-height: 1.4375em; 13 | letter-spacing: 0.00938em; 14 | align-items: center; 15 | padding: 8.5px 14px; 16 | // box-sizing: content-box; 17 | box-sizing: border-box; 18 | cursor: pointer; 19 | display: flex; 20 | gap: 8px; 21 | 22 | &:hover { 23 | border-color: var(--text-primary-color); 24 | } 25 | 26 | &:focus { 27 | border-color: var(--primary-main-color); 28 | border-width: 2px; 29 | padding: 7.5px 13px; 30 | } 31 | 32 | > svg { 33 | font-size: 1em; 34 | width: 1em; 35 | height: 1em; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/component.blocks/FormButton.tsx: -------------------------------------------------------------------------------- 1 | import { Typography, TypographyProps } from "@mui/material"; 2 | import { observer } from "mobx-react-lite"; 3 | import "./FormButton.scss"; 4 | 5 | export const FormButton = observer((props: {} & TypographyProps) => { 6 | const { ...rest } = props; 7 | 8 | return ; 9 | }); 10 | -------------------------------------------------------------------------------- /src/app/component.blocks/FormCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import { action } from "mobx"; 2 | import { observer } from "mobx-react-lite"; 3 | import { Checkbox, FormControlLabel, FormControlLabelProps } from "@mui/material"; 4 | 5 | const FormCheckbox = observer( 6 | ( 7 | props: Omit & { 8 | label: string; 9 | checked: boolean; 10 | onCheckedChange: (value: boolean) => void; 11 | } 12 | ) => { 13 | const { label, checked, onCheckedChange, ...rest } = props; 14 | 15 | return ( 16 | onCheckedChange(c))} />} 18 | label={label} 19 | sx={{ whiteSpace: "nowrap" }} 20 | {...rest} 21 | /> 22 | ); 23 | } 24 | ); 25 | 26 | export { FormCheckbox }; 27 | -------------------------------------------------------------------------------- /src/app/component.blocks/FormEnumSelect.tsx: -------------------------------------------------------------------------------- 1 | import { action } from "mobx"; 2 | import { observer } from "mobx-react-lite"; 3 | import { FormControlProps, FormControl, InputLabel, Select, SelectChangeEvent, MenuItem } from "@mui/material"; 4 | import React from "react"; 5 | import { makeId } from "@core/Util"; 6 | 7 | const FormEnumSelect = observer( 8 | ( 9 | props: FormControlProps & { 10 | label: string; 11 | enumValue: T; 12 | onEnumChange: (value: T) => void; 13 | enumType: any; 14 | } 15 | ) => { 16 | const { label, enumValue, onEnumChange, enumType, ...rest } = props; 17 | 18 | const uid = React.useRef(makeId(10)).current; 19 | 20 | return ( 21 | 22 | {label} 23 | 37 | 38 | ); 39 | } 40 | ); 41 | 42 | export { FormEnumSelect }; 43 | -------------------------------------------------------------------------------- /src/app/component.blocks/FormInputField.tsx: -------------------------------------------------------------------------------- 1 | import { TextField, TextFieldProps } from "@mui/material"; 2 | import { action } from "mobx"; 3 | import { observer } from "mobx-react-lite"; 4 | import { Quantity, UnitConverter, UnitOfLength } from "@core/Unit"; 5 | import { clamp } from "@core/Util"; 6 | import React, { forwardRef } from "react"; 7 | 8 | export function clampQuantity( 9 | value: number, 10 | uol: UnitOfLength, 11 | min = new Quantity(-Infinity, UnitOfLength.Centimeter), 12 | max = new Quantity(+Infinity, UnitOfLength.Centimeter) 13 | ): number { 14 | const minInUOL = new UnitConverter(min.unit, uol).fromAtoB(min.value); 15 | const maxInUOL = new UnitConverter(max.unit, uol).fromAtoB(max.value); 16 | 17 | return clamp(value, minInUOL, maxInUOL).toUser(); 18 | } 19 | 20 | export type FormInputFieldProps = TextFieldProps & { 21 | getValue: () => string; 22 | setValue: (value: string, payload: any) => void; 23 | isValidIntermediate: (candidate: string) => boolean; 24 | isValidValue: (candidate: string) => boolean | [boolean, any]; 25 | numeric?: boolean; // default false 26 | }; 27 | 28 | const FormInputField = observer( 29 | forwardRef((props: FormInputFieldProps, ref) => { 30 | // rest is used to send props to TextField without custom attributes 31 | const { getValue, setValue, isValidIntermediate, isValidValue, numeric: isNumeric, ...rest } = props; 32 | 33 | const initialValue = React.useState(() => getValue())[0]; 34 | const inputRef = React.useRef(null); 35 | const lastValidValue = React.useRef(initialValue); 36 | const lastValidIntermediate = React.useRef(initialValue); 37 | 38 | React.useImperativeHandle(ref, () => inputRef.current!); 39 | 40 | function onChange(event: React.ChangeEvent) { 41 | const element = event.nativeEvent.target as HTMLInputElement; 42 | const candidate = element.value; 43 | 44 | if (!isValidIntermediate(candidate)) { 45 | event.preventDefault(); 46 | 47 | element.value = lastValidIntermediate.current; 48 | } else { 49 | lastValidIntermediate.current = candidate; 50 | } 51 | } 52 | 53 | function onKeyDown(event: React.KeyboardEvent) { 54 | const element = event.nativeEvent.target as HTMLInputElement; 55 | 56 | if (event.code === "Enter" || event.code === "NumpadEnter") { 57 | event.preventDefault(); 58 | element.blur(); 59 | } else if (isNumeric && event.code === "ArrowDown") { 60 | onConfirm(event); 61 | element.value = parseFloat(getValue()) - 1 + ""; 62 | onConfirm(event); 63 | } else if (isNumeric && event.code === "ArrowUp") { 64 | onConfirm(event); 65 | element.value = parseFloat(getValue()) + 1 + ""; 66 | onConfirm(event); 67 | } else if (event.code === "Escape") { 68 | element.value = ""; 69 | element.blur(); 70 | } 71 | 72 | rest.onKeyDown?.(event); 73 | } 74 | 75 | function onBlur(event: React.FocusEvent) { 76 | onConfirm(event); 77 | 78 | rest.onBlur?.(event); 79 | } 80 | 81 | function onConfirm(event: React.SyntheticEvent) { 82 | const element = event.nativeEvent.target as HTMLInputElement; 83 | const candidate = element.value; 84 | let rtn: string; 85 | 86 | const result = isValidValue(candidate); 87 | const isValid = Array.isArray(result) ? result[0] : result; 88 | const payload = Array.isArray(result) ? result[1] : undefined; 89 | if (isValid === false) { 90 | element.value = rtn = lastValidValue.current; 91 | } else { 92 | rtn = candidate; 93 | } 94 | 95 | setValue(rtn, payload); 96 | inputRef.current && 97 | (inputRef.current.value = lastValidValue.current = lastValidIntermediate.current = getValue()); 98 | } 99 | 100 | const value = getValue(); 101 | 102 | React.useEffect(() => { 103 | const value = getValue(); 104 | if (value !== lastValidValue.current) { 105 | lastValidValue.current = value; 106 | lastValidIntermediate.current = value; 107 | inputRef.current!.value = value; 108 | } 109 | }, [value, getValue]); 110 | 111 | return ( 112 | 122 | ); 123 | }) 124 | ); 125 | 126 | export { FormInputField }; 127 | -------------------------------------------------------------------------------- /src/app/component.blocks/FormItemSelect.tsx: -------------------------------------------------------------------------------- 1 | import { action } from "mobx"; 2 | import { observer } from "mobx-react-lite"; 3 | import { FormControlProps, FormControl, InputLabel, Select, SelectChangeEvent, MenuItem } from "@mui/material"; 4 | import React from "react"; 5 | import { makeId } from "@core/Util"; 6 | 7 | export type Item = { 8 | key: string | number; 9 | value: TValue; 10 | label: string; 11 | }; 12 | 13 | const FormItemSelect = observer( 14 | , TItems extends TItem[]>( 15 | props: FormControlProps & { 16 | label: string; 17 | selected: TItems[number]["key"]; 18 | items: TItems; 19 | onSelectItem: (item: TItems[number]["value"] | undefined) => void; 20 | } 21 | ) => { 22 | const { label, selected, items, onSelectItem, ...rest } = props; 23 | 24 | const uid = React.useRef(makeId(10)).current; 25 | 26 | return ( 27 | 28 | {label} 29 | 41 | 42 | ); 43 | } 44 | ); 45 | 46 | export { FormItemSelect }; 47 | -------------------------------------------------------------------------------- /src/app/component.blocks/OpenModalButton.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@mui/material"; 2 | import { observer } from "mobx-react-lite"; 3 | import { FormButton } from "./FormButton"; 4 | import MenuIcon from "@mui/icons-material/Menu"; 5 | 6 | export const OpenModalButton = observer((props: React.ComponentProps) => { 7 | const { children, ...rest } = props; 8 | 9 | return ( 10 | 11 | 12 | 13 | {children} 14 | 15 | 16 | 17 | 18 | ); 19 | }); 20 | -------------------------------------------------------------------------------- /src/app/component.blocks/PanelBox.tsx: -------------------------------------------------------------------------------- 1 | import { BoxProps, Box } from "@mui/material"; 2 | import { observer } from "mobx-react-lite"; 3 | 4 | export const PanelBox = observer((props: {} & BoxProps) => { 5 | const { ...rest } = props; 6 | 7 | return ( 8 | 18 | ); 19 | }); 20 | -------------------------------------------------------------------------------- /src/app/component.blocks/RangeSlider.tsx: -------------------------------------------------------------------------------- 1 | import { Slider } from "@mui/material"; 2 | import { action } from "mobx"; 3 | import { observer } from "mobx-react-lite"; 4 | import { EditableNumberRange } from "@core/Util"; 5 | 6 | const RangeSlider = observer( 7 | (props: { range: EditableNumberRange; inverted?: boolean; onChange: (from: number, to: number) => void }) => { 8 | const range = props.range; 9 | 10 | return ( 11 | { 19 | if (!Array.isArray(value)) return; 20 | 21 | if (value[0] > value[1]) value[0] = value[1]; 22 | 23 | props.onChange(value[0], value[1]); 24 | })} 25 | {...(props.inverted ? { track: "inverted" } : {})} 26 | /> 27 | ); 28 | } 29 | ); 30 | 31 | export { RangeSlider }; 32 | -------------------------------------------------------------------------------- /src/app/exclusive.blocks/_index.scss: -------------------------------------------------------------------------------- 1 | #LeftSection { 2 | position: fixed; 3 | top: 8px; 4 | left: calc(8px + 48px + 8px); 5 | width: calc(288px); 6 | overflow-x: hidden; 7 | overflow-y: auto; 8 | max-height: calc(100% - 16px); 9 | 10 | .FloatingPanel { 11 | width: 288px; 12 | } 13 | } 14 | 15 | #RightSection { 16 | position: fixed; 17 | top: 8px; 18 | right: calc(8px - 8px + 48px + 8px); 19 | width: calc(352px + 8px); 20 | overflow-x: hidden; 21 | overflow-y: auto; 22 | max-height: calc(100% - 16px); 23 | 24 | .FloatingPanel { 25 | margin-bottom: 8px; 26 | width: 352px; 27 | } 28 | 29 | .FloatingPanel:last-child { 30 | margin-bottom: 0; 31 | } 32 | } 33 | 34 | #FieldCanvas-Container { 35 | width: 100%; 36 | height: 100%; 37 | margin: 0; 38 | } 39 | 40 | .FloatingPanel { 41 | background-color: var(--bg-paper-color); 42 | padding: 16px; 43 | border-radius: 4px; 44 | color: var(--text-primary-color); 45 | box-sizing: border-box; 46 | border: 0.5px solid var(--bg-default-color); 47 | 48 | .FloatingPanel-Header { 49 | font-size: 18px; 50 | margin-bottom: 16px; 51 | } 52 | } 53 | 54 | .PanelIcon-Box { 55 | position: fixed; 56 | margin: 0; 57 | } 58 | 59 | .PanelIcon { 60 | width: 48px; 61 | height: 48px; 62 | background-color: var(--bg-paper-color); 63 | color: var(--text-primary-color); 64 | padding: 0; 65 | margin: 0; 66 | cursor: pointer; 67 | border-radius: 4px; 68 | margin-bottom: 8px; 69 | position: relative; 70 | 71 | &.disabled { 72 | cursor: auto; 73 | color: var(--text-disabled-color); 74 | 75 | @include on-hover { 76 | background-color: var(--bg-paper-color) !important; 77 | } 78 | } 79 | 80 | > svg { 81 | font-size: 36px; 82 | // UX: Using vertical-align: middle with line-height can not center the icon with 1 px offset 83 | position: absolute; 84 | left: 50%; 85 | top: 50%; 86 | transform: translate(-50%, -50%); 87 | } 88 | 89 | @include on-hover { 90 | background-color: var(--bg-default-color); 91 | } 92 | } 93 | 94 | #MousePositionPresentation { 95 | position: fixed; 96 | color: var(--text-primary-color); 97 | user-select: none; 98 | } 99 | 100 | @import "./panel/PathTreePanel.scss"; 101 | @import "./speed-canvas/SpeedCanvasElement"; 102 | -------------------------------------------------------------------------------- /src/app/exclusive.blocks/panel/PathTreePanel.scss: -------------------------------------------------------------------------------- 1 | .PathTreePanel-Header { 2 | position: relative; 3 | 4 | > div { 5 | position: absolute; 6 | right: -8px; 7 | top: -8px; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/exclusive.blocks/speed-canvas/SpeedCanvasElement.scss: -------------------------------------------------------------------------------- 1 | #SpeedCanvas-Container { 2 | position: fixed; 3 | bottom: 8px; 4 | left: 50%; 5 | transform: translate(-50%, 0); 6 | background-color: var(--bg-paper-color); 7 | padding: 16px; 8 | border-radius: 4px; 9 | color: var(--text-primary-color); 10 | box-sizing: border-box; 11 | border: 0.5px solid var(--bg-default-color); 12 | 13 | &.extended { 14 | bottom: 0; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app/mobile.blocks/_index.scss: -------------------------------------------------------------------------------- 1 | #BottomNav { 2 | background-color: var(--bg-paper-color); 3 | position: fixed; 4 | bottom: 0; 5 | left: 0; 6 | right: 0; 7 | height: 42px; 8 | display: flex; 9 | justify-content: space-around; 10 | } 11 | 12 | #BottomPanel { 13 | background-color: var(--bg-paper-color); 14 | color: var(--text-primary-color); 15 | position: fixed; 16 | bottom: 0; 17 | left: 0; 18 | right: 0; 19 | min-height: 10%; 20 | max-height: 60%; 21 | overflow-y: auto; 22 | 23 | .FloatingPanel { 24 | color: var(--text-primary-color); 25 | box-sizing: border-box; 26 | margin: 16px; 27 | 28 | .FloatingPanel-Header { 29 | font-size: 18px; 30 | margin-bottom: 16px; 31 | } 32 | } 33 | 34 | #SpeedCanvas-Container { 35 | margin: 8px; 36 | } 37 | } 38 | 39 | #TopNav { 40 | background-color: var(--bg-paper-color); 41 | position: fixed; 42 | top: 0; 43 | left: 0; 44 | right: 0; 45 | height: 42px; 46 | padding-left: 16px; 47 | display: flex; 48 | 49 | #TopNav-LeftSection { 50 | display: flex; 51 | } 52 | 53 | #TopNav-UndoRedoSection { 54 | display: flex; 55 | flex-grow: 1; 56 | padding: 0 16px; 57 | } 58 | 59 | #TopNav-RightSection { 60 | display: flex; 61 | 62 | #TopNav-RightSectionDoneButton { 63 | height: 42px; 64 | line-height: 42px; 65 | padding: 0 16px; 66 | font-size: 18px; 67 | float: right; 68 | cursor: pointer; 69 | user-select: none; 70 | color: var(--text-primary-color); 71 | } 72 | } 73 | } 74 | 75 | .PanelIcon { 76 | width: 42px; 77 | height: 42px; 78 | background-color: transparent; 79 | color: var(--text-primary-color); 80 | padding: 0; 81 | margin: 0; 82 | cursor: pointer; 83 | position: relative; 84 | 85 | &.disabled { 86 | cursor: auto; 87 | color: var(--text-disabled-color); 88 | } 89 | 90 | > svg { 91 | font-size: 24px; 92 | // UX: Using vertical-align: middle with line-height can not center the icon with 1 px offset 93 | position: absolute; 94 | left: 50%; 95 | top: 50%; 96 | transform: translate(-50%, -50%); 97 | } 98 | } 99 | 100 | @import "./panel/PathTreePanel"; 101 | @import "./modal/AssetManagerModal"; 102 | @import "./modal/CoordinateSystemModal"; 103 | @import "./modal/WelcomeModal"; 104 | -------------------------------------------------------------------------------- /src/app/mobile.blocks/_index.tsx: -------------------------------------------------------------------------------- 1 | import { action, makeAutoObservable } from "mobx"; 2 | import MenuIcon from "@mui/icons-material/Menu"; 3 | import UndoIcon from "@mui/icons-material/Undo"; 4 | import RedoIcon from "@mui/icons-material/Redo"; 5 | import TimelineIcon from "@mui/icons-material/Timeline"; 6 | import { Box, Typography } from "@mui/material"; 7 | import classNames from "classnames"; 8 | import { observer } from "mobx-react-lite"; 9 | import React from "react"; 10 | import { LayoutType } from "@core/Layout"; 11 | import { getAppStores } from "@core/MainApp"; 12 | import { FieldCanvasElement } from "../common.blocks/field-canvas/FieldCanvasElement"; 13 | import { MenuMainDropdown } from "../common.blocks/panel/MenuPanel"; 14 | import { PanelFloatingInstance, PanelStaticInstance } from "../common.blocks/panel/Panel"; 15 | import { PathTreePanel } from "../common.blocks/panel/PathTreePanel"; 16 | import { SpeedCanvasElement } from "../common.blocks/speed-canvas/SpeedCanvasElement"; 17 | 18 | class MobileLayoutVariables { 19 | public currentPanel: string | null = null; 20 | public isMenuOpen: boolean = false; 21 | 22 | isOpenPanel(panel: string): boolean { 23 | return this.currentPanel === panel; 24 | } 25 | 26 | openPanel(panel: string) { 27 | this.currentPanel = panel; 28 | } 29 | 30 | constructor() { 31 | makeAutoObservable(this); 32 | } 33 | } 34 | 35 | export const MobileLayout = observer(() => { 36 | const { app, ui } = getAppStores(); 37 | 38 | const [variables] = React.useState(() => new MobileLayoutVariables()); 39 | 40 | const panelProps = ui.getAllPanels().map(obj => obj.builder({})); 41 | 42 | const pathTreeAccordion = PathTreePanel({ layout: LayoutType.Mobile }); 43 | 44 | return ( 45 | <> 46 | 47 | 48 | 49 | 50 | 51 | (variables.isMenuOpen = true))}> 52 | 53 | 54 | 55 | 56 | app.history.undo()}> 59 | 60 | 61 | app.history.redo()}> 64 | 65 | 66 | (variables.isMenuOpen = false))} 70 | /> 71 | 72 | 73 | {variables.currentPanel !== null && ( 74 | (variables.currentPanel = null))}> 75 | Done 76 | 77 | )} 78 | 79 | 80 | {variables.currentPanel !== null && ( 81 | 82 | {variables.isOpenPanel(pathTreeAccordion.id) && } 83 | {panelProps 84 | .filter(panelProp => variables.isOpenPanel(panelProp.id)) 85 | .filter(panelProp => panelProp.id !== "speed-graph") 86 | .map(panelProp => ( 87 | 88 | ))} 89 | {variables.isOpenPanel("speed-graph") && ( 90 | 91 | {app.interestedPath() ? ( 92 | 93 | ) : ( 94 | (No path to display) 95 | )} 96 | 97 | )} 98 | 99 | )} 100 | {variables.currentPanel === null && ( 101 | 102 | variables.openPanel(pathTreeAccordion.id)}> 103 | {pathTreeAccordion.icon} 104 | 105 | {panelProps.map(panelProp => ( 106 | variables.openPanel(panelProp.id)}> 107 | {panelProp.icon} 108 | 109 | ))} 110 | variables.openPanel("speed-graph")}> 111 | 112 | 113 | 114 | )} 115 | 116 | ); 117 | }); 118 | -------------------------------------------------------------------------------- /src/app/mobile.blocks/modal/AssetManagerModal.scss: -------------------------------------------------------------------------------- 1 | #AssetManagerModal { 2 | width: 100% !important; 3 | height: 100% !important; 4 | max-width: 100% !important; 5 | max-height: 100% !important; 6 | padding: 8px; 7 | box-sizing: border-box; 8 | 9 | .FieldImageAssets-Title { 10 | padding: 8px; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/mobile.blocks/modal/CoordinateSystemModal.scss: -------------------------------------------------------------------------------- 1 | #CoordinateSystemModal { 2 | width: 100% !important; 3 | height: 100% !important; 4 | max-width: 100% !important; 5 | max-height: 100% !important; 6 | padding: 8px; 7 | box-sizing: border-box; 8 | 9 | .CoordinateSystem-Title { 10 | padding: 8px; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/mobile.blocks/modal/WelcomeModal.scss: -------------------------------------------------------------------------------- 1 | #WelcomeModal { 2 | width: 100% !important; 3 | height: 100% !important; 4 | max-width: 100% !important; 5 | padding: 8px; 6 | box-sizing: border-box; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/mobile.blocks/panel/PathTreePanel.scss: -------------------------------------------------------------------------------- 1 | .PathTreePanel-Header { 2 | position: relative; 3 | 4 | > div { 5 | position: absolute; 6 | right: -8px; 7 | top: -8px; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/core/Asset.test.ts: -------------------------------------------------------------------------------- 1 | import { validate } from "class-validator"; 2 | import { 3 | FieldImageBuiltInOrigin, 4 | FieldImageLocalAsset, 5 | FieldImageLocalOrigin, 6 | FieldImageOriginType, 7 | FieldImageSignatureAndOrigin, 8 | createExternalFieldImage, 9 | createLocalFieldImage 10 | } from "./Asset"; 11 | 12 | test("Asset validation test", async () => { 13 | const signAndOrigin = new FieldImageSignatureAndOrigin("name123", "name123", new FieldImageBuiltInOrigin()); 14 | 15 | expect(await validate(signAndOrigin)).toHaveLength(0); 16 | 17 | signAndOrigin.signature = "anything else"; 18 | 19 | expect(await validate(signAndOrigin)).toHaveLength(0); 20 | 21 | const signAndOrigin2 = ( 22 | await createExternalFieldImage("name123", 100, "http://example.com/any.png") 23 | )?.getSignatureAndOrigin()!; 24 | 25 | expect(await validate(signAndOrigin2)).toHaveLength(0); 26 | 27 | signAndOrigin2.displayName = ""; 28 | 29 | expect(await validate(signAndOrigin2)).toHaveLength(1); 30 | 31 | signAndOrigin2.signature = "anything else"; 32 | 33 | expect(await validate(signAndOrigin2)).toHaveLength(2); 34 | 35 | signAndOrigin2.origin.heightInMM = 99; 36 | 37 | expect(await validate(signAndOrigin2)).toHaveLength(3); 38 | 39 | signAndOrigin2.origin.heightInMM = 100; 40 | signAndOrigin2.origin.location = "anywhere"; 41 | 42 | expect(await validate(signAndOrigin2)).toHaveLength(3); 43 | 44 | signAndOrigin2.origin.location = "http://example.com/any.pn"; 45 | 46 | expect(await validate(signAndOrigin2)).toHaveLength(3); 47 | 48 | signAndOrigin2.origin.location = "httpp://example.com/any.png"; 49 | 50 | expect(await validate(signAndOrigin2)).toHaveLength(3); 51 | 52 | const signAndOrigin3 = new FieldImageSignatureAndOrigin( 53 | "name123", 54 | "sign", 55 | new FieldImageLocalOrigin(100) 56 | ) as FieldImageSignatureAndOrigin; 57 | 58 | expect(await validate(signAndOrigin3)).toHaveLength(0); 59 | 60 | signAndOrigin3.displayName = ""; 61 | 62 | expect(await validate(signAndOrigin3)).toHaveLength(1); 63 | 64 | signAndOrigin3.signature = ""; 65 | 66 | expect(await validate(signAndOrigin3)).toHaveLength(2); 67 | 68 | signAndOrigin3.origin.heightInMM = 99; 69 | 70 | expect(await validate(signAndOrigin3)).toHaveLength(3); 71 | }); 72 | -------------------------------------------------------------------------------- /src/core/Coordinate.ts: -------------------------------------------------------------------------------- 1 | import { boundHeading, fromHeadingInDegreeToAngleInRadian } from "./Calculation"; 2 | 3 | export interface Coordinate { 4 | x: number; 5 | y: number; 6 | } 7 | 8 | export interface CoordinateWithHeading extends Coordinate { 9 | heading: number; // Degree [0, 360) 10 | } 11 | 12 | export function isCoordinate(target: any): target is Coordinate { 13 | return typeof target.x === "number" && typeof target.y === "number"; 14 | } 15 | 16 | export function isCoordinateWithHeading(target: any): target is CoordinateWithHeading { 17 | return typeof target.heading === "number" && isCoordinate(target); 18 | } 19 | 20 | export class EuclideanTransformation { 21 | private theta: number; 22 | private sin: number; 23 | private cos: number; 24 | 25 | constructor(readonly betaOrigin: CoordinateWithHeading) { 26 | this.theta = fromHeadingInDegreeToAngleInRadian(boundHeading(-betaOrigin.heading + 90)); 27 | this.sin = Math.sin(this.theta); 28 | this.cos = Math.cos(this.theta); 29 | } 30 | 31 | transform(alpha: Coordinate): Coordinate; 32 | transform(alpha: CoordinateWithHeading): CoordinateWithHeading; 33 | 34 | transform(alpha: Coordinate | CoordinateWithHeading): Coordinate | CoordinateWithHeading { 35 | const rtn: any = { 36 | y: (alpha.x - this.betaOrigin.x) * this.sin + (alpha.y - this.betaOrigin.y) * this.cos, 37 | x: (alpha.x - this.betaOrigin.x) * this.cos - (alpha.y - this.betaOrigin.y) * this.sin 38 | }; 39 | 40 | if (isCoordinateWithHeading(alpha)) { 41 | rtn.heading = boundHeading(alpha.heading - this.betaOrigin.heading); 42 | } 43 | 44 | return rtn; 45 | } 46 | 47 | inverseTransform(beta: Coordinate): Coordinate; 48 | inverseTransform(beta: CoordinateWithHeading): CoordinateWithHeading; 49 | 50 | inverseTransform(beta: Coordinate | CoordinateWithHeading): Coordinate | CoordinateWithHeading { 51 | const rtn: any = { 52 | y: -beta.x * this.sin + beta.y * this.cos + this.betaOrigin.y, 53 | x: beta.x * this.cos + beta.y * this.sin + this.betaOrigin.x 54 | }; 55 | 56 | if (isCoordinateWithHeading(beta)) { 57 | rtn.heading = boundHeading(beta.heading + this.betaOrigin.heading); 58 | } 59 | 60 | return rtn; 61 | } 62 | } 63 | 64 | export function euclideanRotation(theta: number, target: Coordinate) { 65 | const sin = Math.sin(theta); 66 | const cos = Math.cos(theta); 67 | 68 | return { 69 | y: target.x * sin + target.y * cos, 70 | x: target.x * cos - target.y * sin 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /src/core/FieldImagePrompt.tsx: -------------------------------------------------------------------------------- 1 | import { when } from "mobx"; 2 | import { 3 | FieldImageSignatureAndOrigin, 4 | FieldImageOriginType, 5 | FieldImageBuiltInOrigin, 6 | FieldImageExternalOrigin, 7 | createExternalFieldImage, 8 | FieldImageLocalOrigin 9 | } from "./Asset"; 10 | import { getAppStores } from "./MainApp"; 11 | import { runInActionAsync } from "./Util"; 12 | 13 | export async function promptFieldImage( 14 | signAndOrigin: FieldImageSignatureAndOrigin 15 | ): Promise { 16 | const { assetManager, confirmation, ui } = getAppStores(); 17 | 18 | if (assetManager.getAssetBySignature(signAndOrigin.signature)) return true; 19 | 20 | if (signAndOrigin.origin instanceof FieldImageBuiltInOrigin) { 21 | throw new Error("Built-in field image not found."); 22 | } else if (signAndOrigin.origin instanceof FieldImageExternalOrigin) { 23 | const url = new URL(signAndOrigin.origin.location); 24 | 25 | const answer = await new Promise(resolve => { 26 | confirmation.prompt({ 27 | title: "Download External Field Image", 28 | description: ( 29 | <> 30 | This path file recommends the use of a custom field image. Would you like to download and install it? 31 |
32 |
33 | Click "Yes" to download and install the image from
{url.origin} only if you 34 | trust the source. 35 |
36 | Click "No" to use the default field image instead. 37 |
38 |
39 | Name: {signAndOrigin.displayName} 40 | 41 | ), 42 | buttons: [ 43 | { label: "Yes", onClick: () => resolve(true) }, 44 | { label: "No", onClick: () => resolve(false) } 45 | ] 46 | }); 47 | }); 48 | 49 | if (answer === false) return false; 50 | 51 | const asset = await createExternalFieldImage( 52 | signAndOrigin.displayName, 53 | signAndOrigin.origin.heightInMM, 54 | signAndOrigin.origin.location 55 | ); 56 | if (asset === undefined) throw new Error("Unable to create the field image."); 57 | 58 | assetManager.addAsset(asset); 59 | 60 | return true; 61 | } else if (signAndOrigin.origin instanceof FieldImageLocalOrigin) { 62 | assetManager.requiringLocalFieldImage = { 63 | requireSignAndOrigin: signAndOrigin, 64 | answer: undefined 65 | }; 66 | 67 | ui.openModal(RequireLocalFieldImageModalSymbol); 68 | await when(() => ui.openingModal !== RequireLocalFieldImageModalSymbol); 69 | 70 | const answer = assetManager.requiringLocalFieldImage.answer; 71 | 72 | if (answer === undefined) throw new Error("The operation is cancelled by the user."); 73 | 74 | await runInActionAsync(() => { 75 | assetManager.requiringLocalFieldImage = null; 76 | if (answer !== null) assetManager.addAsset(answer); 77 | }); 78 | 79 | return answer !== null; 80 | } else { 81 | throw new Error("Unknown field image origin type."); 82 | } 83 | } 84 | 85 | export const RequireLocalFieldImageModalSymbol = Symbol("RequireLocalFieldImageModal"); 86 | -------------------------------------------------------------------------------- /src/core/GoogleAnalytics.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, reaction } from "mobx"; 2 | import { getAppStores } from "./MainApp"; 3 | 4 | // observable class 5 | export class GoogleAnalytics { 6 | readonly GTAG = "G-LLQRVD0VMQ"; // cspell:disable-line 7 | 8 | private loaded: boolean = false; 9 | 10 | private loadGA() { 11 | if (this.loaded) return; 12 | this.loaded = true; 13 | 14 | if (window.dataLayer !== undefined) return; 15 | window.dataLayer = window.dataLayer || []; 16 | 17 | // load GA script 18 | const script = document.createElement("script"); 19 | script.src = `https://www.googletagmanager.com/gtag/js?id=${this.GTAG}`; 20 | script.async = true; 21 | document.body.appendChild(script); 22 | 23 | this.gtag("js", new Date()); 24 | this.gtag("config", this.GTAG); 25 | } 26 | 27 | private init() { 28 | const { appPreferences } = getAppStores(); 29 | 30 | reaction( 31 | () => appPreferences.isGoogleAnalyticsEnabled, 32 | (newVal: boolean) => { 33 | if (newVal) this.loadGA(); 34 | // @ts-ignore 35 | window["ga-disable-" + this.GTAG] = !newVal; 36 | }, 37 | { fireImmediately: true } 38 | ); 39 | } 40 | 41 | public gtag(...args: any[]) { 42 | if (!this.loaded) return; 43 | 44 | window.dataLayer?.push(arguments); 45 | } 46 | 47 | constructor() { 48 | makeAutoObservable(this); 49 | 50 | // ALGO: Do not use init() here, because it will be called before appStores is initialized 51 | setTimeout(this.init.bind(this), 0); 52 | } 53 | } 54 | 55 | declare global { 56 | interface Window { 57 | dataLayer: any[] | undefined; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/core/Logger.ts: -------------------------------------------------------------------------------- 1 | import LoggerImpl from "./LoggerImpl"; 2 | 3 | export { LoggerImpl }; 4 | 5 | export type Method = "debug" | "log" | "warn" | "error" | "groupCollapsed" | "groupEnd"; 6 | 7 | export interface Logger { 8 | name: string; 9 | debug(...args: any[]): void; 10 | log(...args: any[]): void; 11 | warn(...args: any[]): void; 12 | error(...args: any[]): void; 13 | groupCollapsed(...args: any[]): void; 14 | groupEnd(): void; 15 | print(method: Method, ...args: any[]): void; 16 | } 17 | 18 | // eslint-disable-next-line @typescript-eslint/no-redeclare -- intentionally naming the function the same as the type 19 | export const Logger = function (name: string): Logger { 20 | return new LoggerImpl(name); 21 | }; 22 | -------------------------------------------------------------------------------- /src/core/LoggerImpl.ts: -------------------------------------------------------------------------------- 1 | type Method = "debug" | "log" | "warn" | "error" | "groupCollapsed" | "groupEnd"; 2 | 3 | export default class LoggerImpl { 4 | private name_: string = "Global"; 5 | private inGroup: boolean = false; 6 | 7 | constructor(name_: string) { 8 | if (!(this instanceof LoggerImpl)) { 9 | return new LoggerImpl(name_); 10 | } 11 | 12 | this.name_ = name_; 13 | } 14 | 15 | get name(): string { 16 | return this.name_; 17 | } 18 | 19 | debug(...args: any[]): void { 20 | this.print("debug", ...args); 21 | } 22 | 23 | log(...args: any[]): void { 24 | this.print("log", ...args); 25 | } 26 | 27 | warn(...args: any[]): void { 28 | this.print("warn", ...args); 29 | } 30 | 31 | error(...args: any[]): void { 32 | this.print("error", ...args); 33 | } 34 | 35 | groupCollapsed(...args: any[]): void { 36 | this.print("groupCollapsed", ...args); 37 | } 38 | 39 | groupEnd(): void { 40 | this.print("groupEnd"); 41 | } 42 | 43 | print(method: Method, ...args: any[]): void { 44 | // ALGO: This implementation is adopted from https://github.com/GoogleChrome/workbox under the MIT license. 45 | 46 | if (method === "groupCollapsed") { 47 | // Safari doesn't print all console.groupCollapsed() arguments: 48 | // https://bugs.webkit.org/show_bug.cgi?id=182754 49 | if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) { 50 | console[method](...args); 51 | return; 52 | } 53 | } 54 | 55 | const styles = [ 56 | `background: ${ 57 | { 58 | debug: `#a4a4a4`, 59 | log: `#7F47B3`, 60 | warn: `#ED6C02`, 61 | error: `#D32F2F`, 62 | groupCollapsed: `#5C469C`, 63 | groupEnd: null // No colored prefix on groupEnd 64 | }[method] 65 | }`, 66 | `border-radius: 0.5em`, 67 | `color: white`, 68 | `font-weight: bold`, 69 | `padding: 2px 0.5em` 70 | ]; 71 | 72 | // When in a group, the logger prefix is not displayed. 73 | const logPrefix = this.inGroup ? [] : [`%c${this.name}`, styles.join(";")]; 74 | console[method](...logPrefix, ...args); 75 | 76 | if (method === "groupCollapsed") this.inGroup = true; 77 | else if (method === "groupEnd") this.inGroup = false; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/core/Magnet.ts: -------------------------------------------------------------------------------- 1 | import { findClosestPointOnLine, findLinesIntersection } from "./Calculation"; 2 | import { Vector } from "./Path"; 3 | 4 | function findClosetReference(target: Vector, refs: MagnetReference[]): [Vector, MagnetReference | undefined] { 5 | let closetPos: Vector | undefined; 6 | let closetDistance: number = Infinity; 7 | let closetRef: MagnetReference | undefined; 8 | 9 | for (const ref of refs) { 10 | const result = findClosestPointOnLine(ref.source, ref.heading, target); 11 | const distance = target.distance(result); 12 | if (distance < closetDistance) { 13 | closetPos = result; 14 | closetDistance = distance; 15 | closetRef = ref; 16 | } 17 | } 18 | 19 | return [closetPos ?? target, closetRef]; 20 | } 21 | 22 | export function magnet(target: Vector, refs: MagnetReference[], threshold: number): [Vector, MagnetReference[]] { 23 | const [result1, result1Ref] = findClosetReference(target, refs); 24 | 25 | if (result1Ref === undefined || result1.distance(target) > threshold) { 26 | return [target, []]; 27 | } 28 | 29 | const [, result2Ref] = findClosetReference( 30 | result1, 31 | refs.filter(ref => ref.heading % 180 !== result1Ref.heading % 180) 32 | ); 33 | 34 | if (result2Ref !== undefined) { 35 | const result3 = findLinesIntersection(result1Ref.source, result1Ref.heading, result2Ref.source, result2Ref.heading); 36 | 37 | if (result3 !== undefined && result3.distance(target) < threshold) { 38 | return [result3, [result1Ref, result2Ref]]; 39 | } 40 | } 41 | 42 | return [result1, [result1Ref]]; 43 | } 44 | 45 | export interface MagnetReference { 46 | source: Vector; 47 | heading: number; 48 | } 49 | -------------------------------------------------------------------------------- /src/core/Preferences.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, intercept } from "mobx"; 2 | import { AppThemeType } from "@app/Theme"; 3 | import { LayoutType } from "./Layout"; 4 | 5 | export class Preferences { 6 | private disposers: (() => void)[] = []; // intercept() disposer 7 | 8 | // Local storage 9 | public maxHistory: number = 50; 10 | public isGoogleAnalyticsEnabled: boolean = false; 11 | public isExperimentalFeaturesEnabled: boolean = false; 12 | public themeType: AppThemeType = AppThemeType.Dark; 13 | public layoutType: LayoutType = LayoutType.Classic; 14 | public lastSelectedFormat: string = "path.jerryio v0.1.x (cm, rpm)"; 15 | 16 | // Not in local storage 17 | public isSpeedCanvasVisible: boolean = true; // In classic layout only 18 | public isRightSectionVisible: boolean = true; // In classic layout only 19 | 20 | constructor() { 21 | makeAutoObservable(this); 22 | 23 | this.linkLocalStorage(); 24 | window.addEventListener("storage", () => this.linkLocalStorage()); 25 | } 26 | 27 | private link(key: keyof this, storageKey: string) { 28 | const item = localStorage.getItem(storageKey); 29 | if (item !== null) { 30 | try { 31 | this[key] = JSON.parse(item); 32 | } catch (e) { 33 | this[key] = item as any; // ALGO: Legacy string support 34 | } 35 | } 36 | 37 | // ALGO: intercept() invokes the callback even if the value is the same 38 | return intercept(this, key, change => { 39 | localStorage.setItem(storageKey, JSON.stringify(change.newValue)); 40 | return change; 41 | }); 42 | } 43 | 44 | private linkLocalStorage() { 45 | this.disposers.forEach(disposer => disposer()); 46 | this.disposers = [ 47 | this.link("maxHistory", "maxHistory"), 48 | this.link("isGoogleAnalyticsEnabled", "googleAnalyticsEnabled"), 49 | this.link("isExperimentalFeaturesEnabled", "experimentalFeaturesEnabled"), 50 | this.link("themeType", "theme"), 51 | this.link("layoutType", "layout"), 52 | this.link("lastSelectedFormat", "lastSelectedFormat") 53 | ]; 54 | } 55 | } 56 | 57 | // ALGO: This methods is used to get preference from localStorage before Preferences is initialized 58 | export function getPreference(key: string, def: T): T { 59 | const item = localStorage.getItem(key); 60 | if (item !== null) { 61 | try { 62 | return JSON.parse(item); 63 | } catch (e) { 64 | return def; // ALGO: No legacy string support 65 | } 66 | } 67 | return def; 68 | } 69 | 70 | const localIsExperimentalFeaturesEnabled: boolean = getPreference("experimentalFeaturesEnabled", false); 71 | 72 | export function isExperimentalFeaturesEnabled(): boolean { 73 | return localIsExperimentalFeaturesEnabled; 74 | } 75 | -------------------------------------------------------------------------------- /src/core/ServiceWorkerMessages.ts: -------------------------------------------------------------------------------- 1 | export type MessageAction = "GET_VERSION" | "GET_CLIENTS_COUNT" | "SKIP_WAITING"; 2 | 3 | export type SkipWaitingResponse = undefined; 4 | export type ClientsCountResponse = number; 5 | export type VersionResponse = string; 6 | 7 | export interface Message { 8 | type: MessageAction; 9 | } 10 | 11 | export interface GetVersionMessage extends Message { 12 | type: "GET_VERSION"; 13 | } 14 | 15 | export interface GetClientsCountMessage extends Message { 16 | type: "GET_CLIENTS_COUNT"; 17 | } 18 | 19 | export interface SkipWaitingMessage extends Message { 20 | type: "SKIP_WAITING"; 21 | } 22 | 23 | export function isMessage(data: any): data is Message { 24 | return typeof data === "object" && data !== null && typeof data.type === "string"; 25 | } 26 | -------------------------------------------------------------------------------- /src/core/SpeedEditor.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, action } from "mobx"; 2 | import { GraphCanvasConverter } from "./Canvas"; 3 | import { SpeedKeyframe, KeyframePos, Path } from "./Path"; 4 | import { clamp } from "./Util"; 5 | 6 | export type KeyframeInteraction = 7 | | { 8 | keyframe: SpeedKeyframe; 9 | type: "touch" | "drag/hover"; 10 | } 11 | | { 12 | keyframe: null; 13 | type: "panning"; 14 | }; 15 | 16 | export class SpeedEditor { 17 | private _offset: number = 0; 18 | private _interaction: KeyframeInteraction | undefined = undefined; 19 | private _lastInteraction: KeyframeInteraction | undefined = undefined; 20 | isAddingKeyframe: boolean = false; 21 | tooltipPosition: KeyframePos | undefined = undefined; 22 | 23 | path: Path | undefined = undefined; 24 | gcc!: GraphCanvasConverter; // XXX 25 | 26 | constructor() { 27 | makeAutoObservable(this, { path: false, gcc: false }); 28 | 29 | // UX: Hide tooltip when the window size changes 30 | window.addEventListener( 31 | "resize", 32 | action(() => (this.tooltipPosition = undefined)) 33 | ); 34 | // UX: Hide tooltip when the user clicks outside of the tooltip 35 | document.addEventListener("touchstart", event => this.onTouchStartOrMouseDown(event)); 36 | document.addEventListener("mousedown", event => this.onTouchStartOrMouseDown(event)); 37 | } 38 | 39 | private onTouchStartOrMouseDown(event: TouchEvent | MouseEvent) { 40 | if (this.gcc === undefined) return; 41 | const fieldParent = this.gcc.container?.parentElement; 42 | const tooltips = [...(fieldParent?.querySelectorAll("*[role='tooltip']") ?? [])]; 43 | 44 | const isUsingTooltip = tooltips.some(tooltip => tooltip.contains(event.target as Node)); 45 | if (isUsingTooltip === false) this.tooltipPosition = undefined; 46 | } 47 | 48 | panning(vec: number) { 49 | if (this.path === undefined) { 50 | this.offset = 0; 51 | } else { 52 | const maxScrollPos = this.gcc.pointWidth * (this.path.cachedResult.points.length - 2); 53 | this.offset = clamp(this.offset - vec, 0, maxScrollPos); 54 | } 55 | // UX: This interaction is prioritized 56 | this.interaction = { keyframe: null, type: "panning" }; 57 | // UX: Remove tooltip when panning 58 | this.tooltipPosition = undefined; 59 | } 60 | 61 | interact(keyframe: SpeedKeyframe, type: "touch" | "drag/hover") { 62 | if (this._interaction !== undefined && this._interaction.keyframe !== keyframe) return false; 63 | this.interaction = { keyframe, type }; 64 | return true; 65 | } 66 | 67 | endInteraction() { 68 | this.interaction = undefined; 69 | } 70 | 71 | reset() { 72 | this._lastInteraction = undefined; 73 | this._interaction = undefined; 74 | this.isAddingKeyframe = false; 75 | this.tooltipPosition = undefined; 76 | this.offset = 0; 77 | } 78 | 79 | get offset() { 80 | return this._offset; 81 | } 82 | 83 | set offset(offset: number) { 84 | this._offset = offset; 85 | } 86 | 87 | get interaction() { 88 | return this._interaction; 89 | } 90 | 91 | get lastInteraction() { 92 | return this._lastInteraction; 93 | } 94 | 95 | private set interaction(newIt: KeyframeInteraction | undefined) { 96 | const oldIt = this._interaction; 97 | if ((oldIt === undefined) !== (newIt === undefined)) { 98 | this._lastInteraction = oldIt; 99 | this._interaction = newIt; 100 | } else if ( 101 | oldIt !== undefined && 102 | newIt !== undefined && 103 | (oldIt.keyframe !== newIt.keyframe || oldIt.type !== newIt.type) 104 | ) { 105 | this._lastInteraction = oldIt; 106 | this._interaction = newIt; 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/core/TouchEventListener.ts: -------------------------------------------------------------------------------- 1 | import { Vector } from "./Path"; 2 | import { makeObservable, computed, observable } from "mobx"; 3 | 4 | export class TouchEventListener { 5 | touches: { [identifier: number]: { lastPosition: Vector; vector: Vector } } = {}; 6 | 7 | constructor() { 8 | makeObservable(this, { 9 | touches: observable, 10 | keys: computed 11 | }); 12 | } 13 | 14 | protected toVector(t: Touch) { 15 | return new Vector(t.clientX, t.clientY); 16 | } 17 | 18 | onTouchStart(evt: TouchEvent) { 19 | [...evt.changedTouches].forEach(t => { 20 | const pos = this.toVector(t); 21 | const lastPos = this.touches[t.identifier]?.lastPosition ?? pos; 22 | this.touches[t.identifier] = { lastPosition: pos, vector: pos.subtract(lastPos) }; 23 | }); 24 | 25 | evt.preventDefault(); // ALGO: Prevent mouse click event from firing 26 | } 27 | 28 | onTouchMove(evt: TouchEvent) { 29 | this.keys.forEach(k => { 30 | const t = [...evt.touches].find(t => t.identifier === k); 31 | if (t) { 32 | const pos = this.toVector(t); 33 | const lastPos = this.touches[t.identifier]?.lastPosition ?? pos; 34 | this.touches[t.identifier] = { lastPosition: pos, vector: pos.subtract(lastPos) }; 35 | } 36 | }); 37 | 38 | evt.preventDefault(); // ALGO: Prevent mouse click event from firing 39 | } 40 | 41 | onTouchEnd(evt: TouchEvent) { 42 | [...evt.changedTouches].forEach(t => { 43 | delete this.touches[t.identifier]; 44 | }); 45 | 46 | // ALGO: Just in case any touchend event is not fired 47 | if (evt.targetTouches.length === 0) { 48 | this.touches = {}; 49 | } 50 | } 51 | 52 | /** 53 | * Get the identifiers of all touches started on the target element 54 | */ 55 | get keys() { 56 | return Object.keys(this.touches).map(k => parseInt(k)); 57 | } 58 | 59 | /** 60 | * Get the last position of the touch with the given identifier 61 | * @param key The identifier of the touch 62 | * @returns The last position of the touch 63 | */ 64 | pos(key: number) { 65 | return this.touches[key].lastPosition; 66 | } 67 | 68 | /** 69 | * Get the vector of the touch movement since the last frame 70 | * @param key The identifier of the touch 71 | * @returns The vector of the touch movement since the last frame 72 | */ 73 | vec(key: number) { 74 | return this.touches[key].vector; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/core/Unit.ts: -------------------------------------------------------------------------------- 1 | // Enum UnitOfLength values in editor version 0.1.0 2 | // 1 = Millimeter, 3 | // 2 = Centimeter, // default 4 | // 3 = Meter, 5 | // 4 = Inch, 6 | // 5 = Foot, 7 | 8 | export enum UnitOfLength { 9 | Centimeter = 1, // default 10 | Millimeter = Centimeter / 10, 11 | Meter = 100 * Centimeter, // SI base unit 12 | Inch = 2.54 * Centimeter, 13 | Foot = 12 * Inch, 14 | Tile = 24 * Inch 15 | } 16 | 17 | // Degree and Radian are "units" 18 | // Heading and Angle are context-dependent 19 | // It is possible to have a heading in degree or in radian 20 | // Heading starts from north (y+axis) and increases clockwise in [0, 360) 21 | // Angle starts from east (x+axis) and increases counter-clockwise in (-180, 180] 22 | 23 | export enum UnitOfAngle { 24 | Degree = 1, // default 25 | Radian = 180 / Math.PI 26 | } 27 | 28 | export type Unit = UnitOfLength | UnitOfAngle; 29 | 30 | /** 31 | * Quantity class represents a value with a unit 32 | * @param value is a number, represents the value of the quantity 33 | * @param unit is a Unit, can be UnitOfLength or UnitOfAngle 34 | */ 35 | export class Quantity { 36 | constructor(public value: number, public unit: T) {} 37 | 38 | /** 39 | * Converts the quantity to a new unit 40 | * @param unit, the new unit to convert the quantity value to 41 | * @returns the converted value in the new unit 42 | */ 43 | to(unit: T): number { 44 | return new UnitConverter(this.unit, unit).fromAtoB(this.value); 45 | } 46 | } 47 | 48 | /** 49 | * UnitConverter class converts a value from one unit to another 50 | * @param alpha is a Unit, the unit of the value to be converted 51 | * @param beta is a Unit, the unit to convert the value to 52 | */ 53 | export class UnitConverter { 54 | constructor(public alpha: T, public beta: T) {} 55 | 56 | /** 57 | * Converts a value from unit alpha to unit beta 58 | * @param a, the value to be converted in unit alpha 59 | * @returns the converted value in unit beta 60 | */ 61 | fromAtoB(a: number): number { 62 | return (a * this.alpha) / this.beta; 63 | } 64 | 65 | /** 66 | * Converts a value from unit beta to unit alpha 67 | * @param b, the value to be converted in unit beta 68 | * @returns the converted value in unit alpha 69 | */ 70 | fromBtoA(b: number): number { 71 | return (b * this.beta) / this.alpha; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/core/Util.test.ts: -------------------------------------------------------------------------------- 1 | import { validate } from "class-validator"; 2 | import { EditableNumberRange, ValidateEditableNumberRange, ValidateNumber } from "./Util"; 3 | import { Expose } from "class-transformer"; 4 | 5 | class TestClass { 6 | @ValidateNumber(num => num >= 5 && num <= 10) 7 | attr1; 8 | 9 | constructor(attr1: number) { 10 | this.attr1 = attr1; 11 | } 12 | } 13 | 14 | test("ValidateNumber", async () => { 15 | expect(await validate(new TestClass(4))).toHaveLength(1); 16 | expect(await validate(new TestClass(5))).toHaveLength(0); 17 | expect(await validate(new TestClass(10))).toHaveLength(0); 18 | expect(await validate(new TestClass(11))).toHaveLength(1); 19 | }); 20 | 21 | class TestClass2 { 22 | @ValidateEditableNumberRange(-10, 10) 23 | @Expose() 24 | attr1: EditableNumberRange = { 25 | minLimit: { value: -9, label: "0" }, 26 | maxLimit: { value: 9, label: "5" }, 27 | step: 1, 28 | from: 1, 29 | to: 2 30 | }; 31 | } 32 | 33 | test("ValidateEditableNumberRange", async () => { 34 | const test = new TestClass2(); 35 | 36 | expect(await validate(test)).toHaveLength(0); 37 | 38 | test.attr1.from = -10; 39 | expect(await validate(test)).toHaveLength(1); // Less than minLimit 40 | 41 | test.attr1.from = 10; 42 | expect(await validate(test)).toHaveLength(1); // Greater than maxLimit, also greater than TO 43 | 44 | test.attr1.from = 3; 45 | expect(await validate(test)).toHaveLength(1); // Greater than TO 46 | 47 | test.attr1.from = 1; 48 | expect(await validate(test)).toHaveLength(0); // Okay 49 | 50 | test.attr1.to = -10; 51 | expect(await validate(test)).toHaveLength(1); // Less than minLimit, also less than FROM 52 | 53 | test.attr1.to = 10; 54 | expect(await validate(test)).toHaveLength(1); // Greater than maxLimit 55 | 56 | test.attr1.to = 1; 57 | expect(await validate(test)).toHaveLength(0); // Okay, equal to FROM 58 | 59 | test.attr1.step = 0; 60 | expect(await validate(test)).toHaveLength(1); // Step is 0, which is not positive 61 | 62 | test.attr1.step = 1000; 63 | expect(await validate(test)).toHaveLength(0); // Step is 1000, which is positive 64 | 65 | test.attr1.minLimit.value = -11; 66 | expect(await validate(test)).toHaveLength(1); // minLimit is less than -10 67 | 68 | test.attr1.minLimit.value = 0; 69 | expect(await validate(test)).toHaveLength(0); // Okay 70 | 71 | test.attr1.maxLimit.value = 11; 72 | expect(await validate(test)).toHaveLength(1); // maxLimit is greater than 10 73 | 74 | test.attr1.maxLimit.value = 10; 75 | expect(await validate(test)).toHaveLength(0); // Okay 76 | }); 77 | -------------------------------------------------------------------------------- /src/format/Config.test.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from "mobx"; 2 | 3 | import { Expose, Exclude, plainToClassFromExist, Type } from "class-transformer"; 4 | import { IsBoolean, IsIn, IsObject, IsPositive, ValidateNested, validate } from "class-validator"; 5 | import { BentRateApplicationDirection, Path } from "@core/Path"; 6 | import { UnitOfLength } from "@core/Unit"; 7 | import { GeneralConfig, PathConfig } from "./Config"; 8 | import { Format } from "./Format"; 9 | import { EditableNumberRange, ValidateEditableNumberRange, ValidateNumber } from "@core/Util"; 10 | import { CustomFormat } from "./Format.test"; 11 | import { FieldImageOriginType, FieldImageSignatureAndOrigin, getDefaultBuiltInFieldImage } from "@core/Asset"; 12 | import { getNamedCoordinateSystems } from "@src/core/CoordinateSystem"; 13 | 14 | export class CustomGeneralConfig implements GeneralConfig { 15 | public custom: string = "custom"; 16 | 17 | @IsPositive() 18 | @Expose() 19 | robotWidth: number = 12; 20 | @IsPositive() 21 | @Expose() 22 | robotHeight: number = 12; 23 | @IsBoolean() 24 | @Expose() 25 | robotIsHolonomic: boolean = false; 26 | @IsBoolean() 27 | @Expose() 28 | showRobot: boolean = true; 29 | @ValidateNumber(num => num > 0 && num <= 1000) // Don't use IsEnum 30 | @Expose() 31 | uol: UnitOfLength = UnitOfLength.Inch; 32 | @IsPositive() 33 | @Expose() 34 | pointDensity: number = 2; // inches 35 | @IsPositive() 36 | @Expose() 37 | controlMagnetDistance: number = 5 / 2.54; 38 | @Type(() => FieldImageSignatureAndOrigin) 39 | @ValidateNested() 40 | @IsObject() 41 | @Expose() 42 | fieldImage: FieldImageSignatureAndOrigin = 43 | getDefaultBuiltInFieldImage().getSignatureAndOrigin(); 44 | @IsIn(getNamedCoordinateSystems().map(s => s.name)) 45 | @Expose() 46 | coordinateSystem: string = "VEX Gaming Positioning System"; 47 | 48 | constructor() { 49 | makeAutoObservable(this); 50 | } 51 | 52 | get format(): Format { 53 | throw new Error("Method not implemented."); 54 | } 55 | 56 | getAdditionalConfigUI(): JSX.Element { 57 | throw new Error("Method not implemented."); 58 | } 59 | } 60 | 61 | export class CustomPathConfig implements PathConfig { 62 | @Expose() 63 | public custom: string = "custom"; 64 | 65 | @Exclude() 66 | public path!: Path; 67 | 68 | @ValidateEditableNumberRange(-Infinity, Infinity) 69 | @Expose() 70 | speedLimit: EditableNumberRange = { 71 | minLimit: { value: 0, label: "0" }, 72 | maxLimit: { value: 127, label: "127" }, 73 | step: 1, 74 | from: 20, 75 | to: 100 76 | }; 77 | 78 | @ValidateEditableNumberRange(-Infinity, Infinity) 79 | @Expose() 80 | bentRateApplicableRange: EditableNumberRange = { 81 | minLimit: { value: 0, label: "0" }, 82 | maxLimit: { value: 1, label: "1" }, 83 | step: 0.001, 84 | from: 0, 85 | to: 0.1 86 | }; 87 | 88 | @Exclude() 89 | bentRateApplicationDirection = BentRateApplicationDirection.HighToLow; 90 | 91 | constructor() { 92 | makeAutoObservable(this); 93 | } 94 | 95 | get format(): Format { 96 | throw new Error("Method not implemented."); 97 | } 98 | 99 | getConfigPanel(): JSX.Element { 100 | throw new Error("Method not implemented."); 101 | } 102 | } 103 | 104 | test("Class transform path config", async () => { 105 | const f = new CustomFormat(); 106 | const path = f.createPath(); 107 | 108 | const pathRaw = {}; 109 | const pathPC = path.pc; 110 | plainToClassFromExist(path, pathRaw, { excludeExtraneousValues: true, exposeDefaultValues: true }); 111 | 112 | expect( 113 | plainToClassFromExist(pathPC, null, { 114 | excludeExtraneousValues: true, 115 | exposeDefaultValues: true 116 | }) 117 | ).toBe(null); 118 | 119 | expect( 120 | plainToClassFromExist(pathPC, undefined, { 121 | excludeExtraneousValues: true, 122 | exposeDefaultValues: true 123 | }) 124 | ).toBe(undefined); 125 | 126 | expect( 127 | plainToClassFromExist(pathPC, 123, { 128 | excludeExtraneousValues: true, 129 | exposeDefaultValues: true 130 | }) 131 | ).toBe(123); 132 | 133 | expect( 134 | plainToClassFromExist(pathPC, NaN, { 135 | excludeExtraneousValues: true, 136 | exposeDefaultValues: true 137 | }) 138 | ).toBe(NaN); 139 | 140 | expect( 141 | plainToClassFromExist(pathPC, [NaN], { 142 | excludeExtraneousValues: true, 143 | exposeDefaultValues: true 144 | }) 145 | ).toStrictEqual([NaN]); 146 | 147 | expect( 148 | plainToClassFromExist(pathPC, "", { 149 | excludeExtraneousValues: true, 150 | exposeDefaultValues: true 151 | }) 152 | ).toStrictEqual(""); 153 | 154 | path.pc = plainToClassFromExist(pathPC, new CustomPathConfig(), { 155 | excludeExtraneousValues: true, 156 | exposeDefaultValues: true 157 | }); 158 | expect(await validate(path.pc)).toHaveLength(0); 159 | }); 160 | -------------------------------------------------------------------------------- /src/format/Config.tsx: -------------------------------------------------------------------------------- 1 | import { reaction, action, intercept } from "mobx"; 2 | import { UnitConverter, UnitOfLength } from "@core/Unit"; 3 | import { Format } from "./Format"; 4 | import { BentRateApplicationDirection, Path } from "@core/Path"; 5 | import { FieldImageOriginType, FieldImageSignatureAndOrigin, getDefaultBuiltInFieldImage } from "@core/Asset"; 6 | import { EditableNumberRange, NumberRange } from "@core/Util"; 7 | import { getAppStores } from "@core/MainApp"; 8 | 9 | export function convertGeneralConfigUOL(gc: GeneralConfig, fromUOL: UnitOfLength) { 10 | const toUOL = gc.uol; 11 | const uc = new UnitConverter(fromUOL, toUOL); 12 | 13 | gc.robotWidth = uc.fromAtoB(gc.robotWidth); 14 | gc.robotHeight = uc.fromAtoB(gc.robotHeight); 15 | gc.pointDensity = uc.fromAtoB(gc.pointDensity); 16 | gc.controlMagnetDistance = uc.fromAtoB(gc.controlMagnetDistance); 17 | } 18 | 19 | export function convertFormat(newFormat: Format, oldFormat: Format, oldPaths: Path[]): Path[] { 20 | const oldGC = oldFormat.getGeneralConfig(); 21 | const newGC = newFormat.getGeneralConfig(); // == this.gc 22 | 23 | const keepPointDensity = newGC.pointDensity; 24 | 25 | newGC.robotWidth = oldGC.robotWidth; 26 | newGC.robotHeight = oldGC.robotHeight; 27 | convertGeneralConfigUOL(newGC, oldGC.uol); 28 | newGC.pointDensity = keepPointDensity; // UX: Use new format point density 29 | newGC.fieldImage = oldGC.fieldImage; 30 | 31 | const newPaths: Path[] = []; 32 | for (const oldPath of oldPaths) { 33 | const newPath = newFormat.createPath(...oldPath.segments); 34 | const newPC = newPath.pc; 35 | 36 | newPath.name = oldPath.name; 37 | newPath.visible = oldPath.visible; 38 | newPath.lock = oldPath.lock; 39 | 40 | if ( 41 | newPC.speedLimit.minLimit === oldPath.pc.speedLimit.minLimit && 42 | newPC.speedLimit.maxLimit === oldPath.pc.speedLimit.maxLimit 43 | ) { 44 | newPC.speedLimit = oldPath.pc.speedLimit; // UX: Keep speed limit if the new format has the same speed limit range as the old one 45 | } 46 | newPC.bentRateApplicableRange = oldPath.pc.bentRateApplicableRange; // UX: Keep application range 47 | 48 | newPaths.push(newPath); 49 | } 50 | 51 | return newPaths; 52 | } 53 | 54 | export function initGeneralConfig(gc: GeneralConfig) { 55 | reaction( 56 | () => gc.uol, 57 | action((newUOL: UnitOfLength, oldUOL: UnitOfLength) => { 58 | convertGeneralConfigUOL(gc, oldUOL); 59 | }) 60 | ); 61 | 62 | intercept(gc, "fieldImage", change => { 63 | const { app, assetManager } = getAppStores(); 64 | 65 | if (app.gc === gc && assetManager.getAssetBySignature(change.newValue.signature) === undefined) { 66 | change.newValue = getDefaultBuiltInFieldImage().getSignatureAndOrigin(); 67 | } 68 | 69 | return change; 70 | }); 71 | } 72 | 73 | export interface ConfigSection { 74 | get format(): Format; 75 | } 76 | 77 | /** 78 | * Common configuration params for all formats 79 | * @param robotWidth Width of the robot in the unit of length 80 | * @param robotHeight Height of the robot in the unit of length 81 | * @param robotIsHolonomic Whether the robot is holonomic or not, or force the robot to be static 82 | * Force Static - The robot's heading aligns with the first end control of the current segment where the robot is located 83 | * Holonomic robot - The robot that can move in any direction without turning 84 | * @param showRobot Whether to show the robot on the field 85 | * @param uol Unit of length 86 | * @param pointDensity The spacing between two waypoints on the path 87 | * @param controlMagnetDistance The minimal distance for the dragging control to get magnetized to the Magnet Reference Line 88 | * @param fieldImage The field image using for the format 89 | * @param coordinateSystem The coordinate system used for the format 90 | */ 91 | export interface GeneralConfig extends ConfigSection { 92 | robotWidth: number; 93 | robotHeight: number; 94 | robotIsHolonomic: boolean | "force-static" | "force-holonomic"; 95 | showRobot: boolean; 96 | uol: UnitOfLength; 97 | pointDensity: number; 98 | controlMagnetDistance: number; 99 | fieldImage: FieldImageSignatureAndOrigin; 100 | coordinateSystem: string; 101 | /** 102 | * Get the react components as customized additional configuration UI for the format 103 | * The customized additional configuration UI will be render at the end of GeneralConfigPanelBody 104 | * @returns The customized additional configuration UI as react components 105 | */ 106 | getAdditionalConfigUI(): React.ReactNode; 107 | } 108 | 109 | /** Common Path Configuration params for all formats 110 | * @param path The path to configure 111 | * @param lookaheadLimit The lookahead limit of the path, used for determining the lookahead of each points 112 | * @param speedLimit The configurable range of speed of the path 113 | * @param bentRateApplicableRange The configurable range of bent rate of the path 114 | * @param bentRateApplicationDirection The direction of the bent rate range on the speed canvas 115 | */ 116 | export interface PathConfig extends ConfigSection { 117 | path: Path; 118 | lookaheadLimit?: NumberRange; 119 | speedLimit: EditableNumberRange; 120 | bentRateApplicableRange: EditableNumberRange; 121 | bentRateApplicationDirection: BentRateApplicationDirection; 122 | } 123 | -------------------------------------------------------------------------------- /src/format/LemLibFormatV0_4/GeneralConfig.tsx: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from "mobx"; 2 | import { FieldImageSignatureAndOrigin, FieldImageOriginType, getDefaultBuiltInFieldImage } from "@core/Asset"; 3 | import { UnitOfLength } from "@core/Unit"; 4 | import { ValidateNumber } from "@core/Util"; 5 | import { Expose, Type, Exclude } from "class-transformer"; 6 | import { IsPositive, IsBoolean, ValidateNested, IsObject, IsIn } from "class-validator"; 7 | import { GeneralConfig, initGeneralConfig } from "../Config"; 8 | import { Format } from "../Format"; 9 | import { getNamedCoordinateSystems } from "@src/core/CoordinateSystem"; 10 | 11 | // observable class 12 | export class GeneralConfigImpl implements GeneralConfig { 13 | @IsPositive() 14 | @Expose() 15 | robotWidth: number = 12; 16 | @IsPositive() 17 | @Expose() 18 | robotHeight: number = 12; 19 | @IsBoolean() 20 | @Expose() 21 | robotIsHolonomic: boolean = false; 22 | @IsBoolean() 23 | @Expose() 24 | showRobot: boolean = false; 25 | @ValidateNumber(num => num > 0 && num <= 1000) // Don't use IsEnum 26 | @Expose() 27 | uol: UnitOfLength = UnitOfLength.Inch; 28 | @IsPositive() 29 | @Expose() 30 | pointDensity: number = 2; // inches 31 | @IsPositive() 32 | @Expose() 33 | controlMagnetDistance: number = 5 / 2.54; 34 | @Type(() => FieldImageSignatureAndOrigin) 35 | @ValidateNested() 36 | @IsObject() 37 | @Expose() 38 | fieldImage: FieldImageSignatureAndOrigin = 39 | getDefaultBuiltInFieldImage().getSignatureAndOrigin(); 40 | @IsIn(getNamedCoordinateSystems().map(s => s.name)) 41 | @Expose() 42 | coordinateSystem: string = "VEX Gaming Positioning System"; 43 | @Exclude() 44 | private format_: Format; 45 | 46 | constructor(format: Format) { 47 | this.format_ = format; 48 | makeAutoObservable(this); 49 | 50 | initGeneralConfig(this); 51 | } 52 | 53 | get format() { 54 | return this.format_; 55 | } 56 | 57 | getAdditionalConfigUI() { 58 | return <>; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/format/LemLibFormatV0_4/PathConfig.tsx: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, action } from "mobx"; 2 | import { Typography, Slider } from "@mui/material"; 3 | import { RangeSlider } from "@src/app/component.blocks/RangeSlider"; 4 | import { UpdateProperties } from "@core/Command"; 5 | import { LayoutContext, LayoutType, PanelBuilderProps, PanelInstanceProps } from "@core/Layout"; 6 | import { getAppStores } from "@core/MainApp"; 7 | import { BentRateApplicationDirection, Path } from "@core/Path"; 8 | import { ValidateEditableNumberRange, EditableNumberRange, ValidateNumber } from "@core/Util"; 9 | import { Expose, Exclude } from "class-transformer"; 10 | import { observer } from "mobx-react-lite"; 11 | import React from "react"; 12 | import { PathConfig } from "../Config"; 13 | import { Format } from "../Format"; 14 | import LinearScaleIcon from "@mui/icons-material/LinearScale"; 15 | import { PanelBox } from "@src/app/component.blocks/PanelBox"; 16 | 17 | // observable class 18 | export class PathConfigImpl implements PathConfig { 19 | @ValidateEditableNumberRange(-Infinity, Infinity) 20 | @Expose() 21 | speedLimit: EditableNumberRange = { 22 | minLimit: { value: 0, label: "0" }, 23 | maxLimit: { value: 127, label: "127" }, 24 | step: 1, 25 | from: 20, 26 | to: 100 27 | }; 28 | @ValidateEditableNumberRange(-Infinity, Infinity) 29 | @Expose() 30 | bentRateApplicableRange: EditableNumberRange = { 31 | minLimit: { value: 0, label: "0" }, 32 | maxLimit: { value: 1, label: "1" }, 33 | step: 0.001, 34 | from: 0, 35 | to: 0.1 36 | }; 37 | @Exclude() 38 | bentRateApplicationDirection = BentRateApplicationDirection.HighToLow; 39 | @ValidateNumber(num => num >= 0.1 && num <= 255) 40 | @Expose() 41 | maxDecelerationRate: number = 127; 42 | 43 | @Exclude() 44 | readonly format: Format; 45 | 46 | @Exclude() 47 | public path!: Path; 48 | 49 | constructor(format: Format) { 50 | this.format = format; 51 | makeAutoObservable(this); 52 | } 53 | } 54 | 55 | const PathConfigPanelBody = observer((props: {}) => { 56 | const { app } = getAppStores(); 57 | 58 | const pc = app.selectedPath?.pc as PathConfigImpl | undefined; 59 | 60 | const isClassic = React.useContext(LayoutContext) === LayoutType.Classic; 61 | 62 | if (pc === undefined) { 63 | return isClassic ? undefined : (No selected path); 64 | } 65 | 66 | return ( 67 | <> 68 | Min/Max Speed 69 | 70 | 73 | app.history.execute( 74 | `Change path ${pc.path.uid} min/max speed`, 75 | new UpdateProperties(pc.speedLimit, { from, to }) 76 | ) 77 | } 78 | /> 79 | 80 | Bent Rate Applicable Range 81 | 82 | 85 | app.history.execute( 86 | `Change path ${pc.path.uid} bent rate applicable range`, 87 | new UpdateProperties(pc.bentRateApplicableRange, { from, to }) 88 | ) 89 | } 90 | /> 91 | 92 | Max Deceleration Rate 93 | 94 | { 101 | if (Array.isArray(value)) value = value[0]; 102 | app.history.execute( 103 | `Change path ${pc.path.uid} max deceleration rate`, 104 | new UpdateProperties(pc, { maxDecelerationRate: value }) 105 | ); 106 | })} 107 | /> 108 | 109 | 110 | ); 111 | }); 112 | 113 | export const PathConfigPanel = (props: PanelBuilderProps): PanelInstanceProps => { 114 | return { 115 | id: "PathConfigAccordion", 116 | header: "Path", 117 | children: , 118 | icon: 119 | }; 120 | }; 121 | -------------------------------------------------------------------------------- /src/format/LemLibFormatV1_0/GeneralConfig.tsx: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from "mobx"; 2 | import { FieldImageSignatureAndOrigin, FieldImageOriginType, getDefaultBuiltInFieldImage } from "@core/Asset"; 3 | import { UnitOfLength } from "@core/Unit"; 4 | import { ValidateNumber } from "@core/Util"; 5 | import { Expose, Type, Exclude } from "class-transformer"; 6 | import { IsPositive, IsBoolean, ValidateNested, IsObject, IsIn } from "class-validator"; 7 | import { GeneralConfig, initGeneralConfig } from "../Config"; 8 | import { Format } from "../Format"; 9 | import { getNamedCoordinateSystems } from "@src/core/CoordinateSystem"; 10 | 11 | // observable class 12 | export class GeneralConfigImpl implements GeneralConfig { 13 | @IsPositive() 14 | @Expose() 15 | robotWidth: number = 300; 16 | @IsPositive() 17 | @Expose() 18 | robotHeight: number = 300; 19 | @IsBoolean() 20 | @Expose() 21 | robotIsHolonomic: boolean = false; 22 | @IsBoolean() 23 | @Expose() 24 | showRobot: boolean = false; 25 | @ValidateNumber(num => num > 0 && num <= 1000) // Don't use IsEnum 26 | @Expose() 27 | uol: UnitOfLength = UnitOfLength.Millimeter; 28 | @IsPositive() 29 | @Expose() 30 | pointDensity: number = 20; // mm 31 | @IsPositive() 32 | @Expose() 33 | controlMagnetDistance: number = 50; 34 | @Type(() => FieldImageSignatureAndOrigin) 35 | @ValidateNested() 36 | @IsObject() 37 | @Expose() 38 | fieldImage: FieldImageSignatureAndOrigin = 39 | getDefaultBuiltInFieldImage().getSignatureAndOrigin(); 40 | @IsIn(getNamedCoordinateSystems().map(s => s.name)) 41 | @Expose() 42 | coordinateSystem: string = "VEX Gaming Positioning System"; 43 | @Exclude() 44 | private format_: Format; 45 | 46 | constructor(format: Format) { 47 | this.format_ = format; 48 | makeAutoObservable(this); 49 | 50 | initGeneralConfig(this); 51 | } 52 | 53 | get format() { 54 | return this.format_; 55 | } 56 | 57 | getAdditionalConfigUI() { 58 | return <>; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/format/LemLibFormatV1_0/PathConfig.tsx: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, action } from "mobx"; 2 | import { Typography, Slider } from "@mui/material"; 3 | import { RangeSlider } from "@src/app/component.blocks/RangeSlider"; 4 | import { UpdateProperties } from "@core/Command"; 5 | import { LayoutContext, LayoutType, PanelBuilderProps, PanelInstanceProps } from "@core/Layout"; 6 | import { getAppStores } from "@core/MainApp"; 7 | import { BentRateApplicationDirection, Path } from "@core/Path"; 8 | import { NumberRange, ValidateEditableNumberRange, EditableNumberRange, ValidateNumber } from "@core/Util"; 9 | import { Exclude, Expose } from "class-transformer"; 10 | import { observer } from "mobx-react-lite"; 11 | import React from "react"; 12 | import { PathConfig } from "../Config"; 13 | import { Format } from "../Format"; 14 | import LinearScaleIcon from "@mui/icons-material/LinearScale"; 15 | import { PanelBox } from "@src/app/component.blocks/PanelBox"; 16 | 17 | export interface LemLibPathConfig extends PathConfig {} 18 | 19 | // observable class 20 | export class PathConfigImpl implements LemLibPathConfig { 21 | @Exclude() 22 | readonly lookaheadLimit: NumberRange = { 23 | from: 10, 24 | to: 1000 25 | }; 26 | 27 | @ValidateEditableNumberRange(-Infinity, Infinity) 28 | @Expose() 29 | speedLimit: EditableNumberRange = { 30 | minLimit: { value: 0, label: "0" }, 31 | // maxLimit: { value: 32.767, label: "32.767" }, 32 | maxLimit: { value: 10, label: "10" }, 33 | step: 0.05, 34 | from: 0.5, 35 | to: 1.0 36 | }; 37 | 38 | @ValidateEditableNumberRange(-Infinity, Infinity) 39 | @Expose() 40 | bentRateApplicableRange: EditableNumberRange = { 41 | minLimit: { value: 0, label: "0" }, 42 | maxLimit: { value: 1, label: "1" }, 43 | step: 0.001, 44 | from: 0, 45 | to: 0.1 46 | }; 47 | 48 | @Exclude() 49 | bentRateApplicationDirection = BentRateApplicationDirection.HighToLow; 50 | 51 | @ValidateNumber(num => num >= 0.05 && num <= 10) 52 | @Expose() 53 | maxDecelerationRate: number = 1; 54 | 55 | @Exclude() 56 | readonly format: Format; 57 | 58 | @Exclude() 59 | public path!: Path; 60 | 61 | constructor(format: Format) { 62 | this.format = format; 63 | makeAutoObservable(this); 64 | } 65 | } 66 | 67 | const PathConfigPanelBody = observer((props: {}) => { 68 | const { app } = getAppStores(); 69 | 70 | const pc = app.selectedPath?.pc as PathConfigImpl | undefined; 71 | 72 | const isClassic = React.useContext(LayoutContext) === LayoutType.Classic; 73 | 74 | if (pc === undefined) { 75 | return isClassic ? undefined : (No selected path); 76 | } 77 | 78 | return ( 79 | <> 80 | Min/Max Speed 81 | 82 | 85 | app.history.execute( 86 | `Change path ${pc.path.uid} min/max speed`, 87 | new UpdateProperties(pc.speedLimit, { from, to }) 88 | ) 89 | } 90 | /> 91 | 92 | Bent Rate Applicable Range 93 | 94 | 97 | app.history.execute( 98 | `Change path ${pc.path.uid} bent rate applicable range`, 99 | new UpdateProperties(pc.bentRateApplicableRange, { from, to }) 100 | ) 101 | } 102 | /> 103 | 104 | Max Deceleration Rate 105 | 106 | { 113 | if (Array.isArray(value)) value = value[0]; 114 | app.history.execute( 115 | `Change path ${pc.path.uid} max deceleration rate`, 116 | new UpdateProperties(pc, { maxDecelerationRate: value }) 117 | ); 118 | })} 119 | /> 120 | 121 | {/* TODO show button to show Lookahead Graph */} 122 | 123 | ); 124 | }); 125 | 126 | export const PathConfigPanel = (props: PanelBuilderProps): PanelInstanceProps => { 127 | return { 128 | id: "PathConfigAccordion", 129 | header: "Path", 130 | children: , 131 | icon: 132 | }; 133 | }; 134 | -------------------------------------------------------------------------------- /src/format/LemLibFormatV1_0/Serialization.ts: -------------------------------------------------------------------------------- 1 | import { fromDegreeToRadian, fromRadiansToDegree } from "@core/Calculation"; 2 | import { Path } from "@core/Path"; 3 | import { SmartBuffer } from "smart-buffer"; 4 | 5 | export namespace LemLibV1_0 { 6 | export interface LemLibWaypoint { 7 | x: number; // mm 8 | y: number; // mm 9 | speed: number; // m/s 10 | heading?: number; 11 | lookahead?: number; // mm 12 | } 13 | 14 | export interface LemLibPathData { 15 | name: string; 16 | waypoints: LemLibWaypoint[]; 17 | } 18 | 19 | export function writeWaypoint(buffer: SmartBuffer, waypoint: LemLibWaypoint) { 20 | let flag = 0; 21 | if (waypoint.heading !== undefined) flag |= 0x01; 22 | if (waypoint.lookahead !== undefined) flag |= 0x02; 23 | buffer.writeInt8(flag); 24 | buffer.writeInt16LE(Math.round(waypoint.x * 2)); 25 | buffer.writeInt16LE(Math.round(waypoint.y * 2)); 26 | buffer.writeInt16LE(Math.round(waypoint.speed * 1000)); 27 | if (waypoint.heading !== undefined) { 28 | // From [0, 360) to [0~6.2832] 29 | const rad = fromDegreeToRadian(waypoint.heading); 30 | const rad2 = Math.max(0, Math.min(rad, 6.2832)); 31 | buffer.writeUInt16LE(Math.round(rad2 * 10000)); 32 | } 33 | if (waypoint.lookahead !== undefined) { 34 | buffer.writeInt16LE(Math.round(waypoint.lookahead * 2)); 35 | } 36 | } 37 | 38 | export function readWaypoint(buffer: SmartBuffer): LemLibWaypoint { 39 | const flag = buffer.readInt8(); 40 | const waypoint: LemLibWaypoint = { 41 | x: buffer.readInt16LE() / 2, 42 | y: buffer.readInt16LE() / 2, 43 | speed: buffer.readInt16LE() / 1000 44 | }; 45 | if (flag & 0x01) { 46 | // From [0~6.2832] to [0, 360) 47 | const rad = buffer.readUInt16LE() / 10000; 48 | const deg = fromRadiansToDegree(rad); 49 | waypoint.heading = deg; 50 | } 51 | if (flag & 0x02) { 52 | waypoint.lookahead = buffer.readInt16LE() / 2; 53 | } 54 | if (flag & 0x04) buffer.readInt16LE(); // Reserved 55 | if (flag & 0x08) buffer.readInt16LE(); // Reserved 56 | if (flag & 0x10) buffer.readInt16LE(); // Reserved 57 | if (flag & 0x20) buffer.readInt16LE(); // Reserved 58 | if (flag & 0x40) buffer.readInt16LE(); // Reserved 59 | if (flag & 0x80) buffer.readInt16LE(); // Reserved 60 | 61 | return waypoint; 62 | } 63 | 64 | export function writePath(buffer: SmartBuffer, path: Path) { 65 | buffer.writeStringNT(path.name); 66 | buffer.writeInt8(0); 67 | // No metadata 68 | const result = path.pc.format.getPathPoints(path); 69 | const points = result.points; 70 | buffer.writeUInt16LE(points.length); 71 | points.forEach(point => { 72 | writeWaypoint(buffer, point); 73 | }); 74 | } 75 | 76 | export function readPath(buffer: SmartBuffer): LemLibPathData { 77 | const name = buffer.readStringNT(); 78 | const metadataSize = buffer.readInt8(); 79 | if (metadataSize > 0) { 80 | buffer.readBuffer(metadataSize); 81 | } 82 | const numPoints = buffer.readUInt16LE(); 83 | const waypoints: LemLibWaypoint[] = []; 84 | for (let i = 0; i < numPoints; i++) { 85 | waypoints.push(readWaypoint(buffer)); 86 | } 87 | return { name, waypoints }; 88 | } 89 | 90 | export function writePathFile(buffer: SmartBuffer, paths: Path[], pathFileData: Record) { 91 | const bodyBeginIdx = buffer.writeOffset; 92 | buffer.writeUInt8(4); // Metadata size 93 | const metadataStartIdx = buffer.writeOffset; 94 | buffer.writeUInt32LE(0); // Placeholder 95 | 96 | buffer.writeUInt16LE(paths.length); 97 | paths.forEach(path => { 98 | writePath(buffer, path); 99 | }); 100 | 101 | // The first 4 bytes of metadata is the pointer to the end of the body 102 | // The reader will use this pointer to skip the body and read the PATH.JERRYIO-DATA metadata 103 | const sizeOfBody = buffer.writeOffset - bodyBeginIdx; 104 | buffer.writeUInt32LE(sizeOfBody, metadataStartIdx); 105 | buffer.writeStringNT("#PATH.JERRYIO-DATA"); 106 | buffer.writeString(JSON.stringify(pathFileData)); 107 | } 108 | 109 | export function readPathFile( 110 | buffer: SmartBuffer 111 | ): { paths: LemLibPathData[]; pathFileData: Record } | undefined { 112 | const bodyBeginIdx = buffer.readOffset; 113 | const metadataSize = buffer.readUInt8(); 114 | const metadataStartIdx = buffer.readOffset; 115 | const metadataEndIdx = metadataStartIdx + metadataSize; 116 | const sizeOfBody = buffer.readUInt32LE(); 117 | buffer.readOffset = metadataEndIdx; 118 | 119 | const paths: LemLibPathData[] = []; 120 | const numPaths = buffer.readUInt16LE(); 121 | for (let i = 0; i < numPaths; i++) { 122 | paths.push(readPath(buffer)); 123 | } 124 | 125 | buffer.readOffset = bodyBeginIdx + sizeOfBody; 126 | const signature = buffer.readStringNT(); 127 | if (signature !== "#PATH.JERRYIO-DATA") return undefined; 128 | 129 | try { 130 | const pathFileData = JSON.parse(buffer.readString()); 131 | return { paths, pathFileData }; 132 | } catch (e) { 133 | return undefined; 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/format/LemLibFormatV1_0/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { SmartBuffer } from "smart-buffer"; 2 | import { MainApp, getAppStores } from "@core/MainApp"; 3 | import { LemLibFormatV1_0 } from "."; 4 | import { EndControl, Segment } from "@core/Path"; 5 | import { LemLibV1_0 } from "./Serialization"; 6 | 7 | test("dummy", () => { 8 | const { app } = getAppStores(); // suppress constructor error 9 | }); 10 | 11 | test("read write waypoint", () => { 12 | const buffer1 = SmartBuffer.fromSize(16); 13 | 14 | const point1: LemLibV1_0.LemLibWaypoint = { 15 | x: 1.5, // .5 precision 16 | y: 5.5, // .5 precision 17 | speed: 4.567, 18 | heading: 270.123, 19 | lookahead: 16 // .5 precision 20 | }; 21 | 22 | LemLibV1_0.writeWaypoint(buffer1, point1); 23 | 24 | const point2 = LemLibV1_0.readWaypoint(buffer1); 25 | 26 | expect(point2.x).toBe(point1.x); 27 | expect(point2.y).toBe(point1.y); 28 | expect(point2.speed).toBe(point1.speed); 29 | expect(point2.heading).toBeCloseTo(point1.heading ?? 0); 30 | expect(point2.lookahead).toBe(point1.lookahead ?? 0); 31 | }); 32 | 33 | test("read write path", () => { 34 | const format = new LemLibFormatV1_0(); 35 | 36 | const path = format.createPath(); 37 | 38 | path.segments.push(new Segment(new EndControl(60, 60, 0), new EndControl(62, 60, 90))); 39 | path.segments.push(new Segment(path.segments[path.segments.length - 1].last, new EndControl(63, 60, 180))); 40 | path.segments.push(new Segment(path.segments[path.segments.length - 1].last, new EndControl(64, 60, 270))); 41 | 42 | const buffer1 = SmartBuffer.fromSize(1024); // auto resize 43 | 44 | LemLibV1_0.writePath(buffer1, path); 45 | 46 | const result = LemLibV1_0.readPath(buffer1); 47 | 48 | expect(result.name).toBe(path.name); 49 | 50 | const points = path.cachedResult.points; 51 | 52 | expect(result.waypoints.length).toBe(points.length); 53 | 54 | for (let i = 0; i < points.length; i++) { 55 | const point1 = points[i]; 56 | const point2 = result.waypoints[i]; 57 | 58 | expect(point2.x).toBe(point1.x); 59 | expect(point2.y).toBe(point1.y); 60 | expect(point2.speed).toBeCloseTo(point1.speed); 61 | expect(point2.heading ?? 0).toBeCloseTo(point1.heading ?? 0); 62 | expect(point2.lookahead).toBe(point1.lookahead); 63 | } 64 | }); 65 | 66 | test("read write path file", () => { 67 | const app = new MainApp(); 68 | const format = new LemLibFormatV1_0(); 69 | app.format = format; 70 | 71 | const path = format.createPath(); 72 | app.paths.push(path); 73 | 74 | path.segments.push(new Segment(new EndControl(60, 60, 0), new EndControl(62, 60, 90))); 75 | path.segments.push(new Segment(path.segments[path.segments.length - 1].last, new EndControl(63, 60, 180))); 76 | path.segments.push(new Segment(path.segments[path.segments.length - 1].last, new EndControl(64, 60, 270))); 77 | 78 | const buffer1 = SmartBuffer.fromSize(1024); // auto resize 79 | 80 | const pathFileData = app.exportPDJData(); 81 | LemLibV1_0.writePathFile(buffer1, [path], pathFileData); 82 | 83 | const result = LemLibV1_0.readPathFile(buffer1); 84 | expect(result?.pathFileData).toStrictEqual(pathFileData); 85 | expect(result?.paths.length).toBe(1); 86 | 87 | const resultPoints = result?.paths[0].waypoints!; 88 | const points = path.cachedResult.points; 89 | 90 | expect(resultPoints.length).toBe(points.length); 91 | 92 | for (let i = 0; i < points.length; i++) { 93 | const point1 = points[i]; 94 | const point2 = resultPoints[i]; 95 | 96 | expect(point2.x).toBe(point1.x); 97 | expect(point2.y).toBe(point1.y); 98 | expect(point2.speed).toBeCloseTo(point1.speed); 99 | expect(point2.heading ?? 0).toBeCloseTo(point1.heading ?? 0); 100 | expect(point2.lookahead).toBe(point1.lookahead); 101 | } 102 | }); 103 | -------------------------------------------------------------------------------- /src/format/LemLibOdomGeneratorFormatV0_4/PathConfig.tsx: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from "mobx"; 2 | import { BentRateApplicationDirection, Path } from "@core/Path"; 3 | import { EditableNumberRange } from "@core/Util"; 4 | import { Exclude } from "class-transformer"; 5 | import { Format } from "../Format"; 6 | import { PathConfig } from "../Config"; 7 | 8 | // observable class 9 | export class PathConfigImpl implements PathConfig { 10 | @Exclude() 11 | speedLimit: EditableNumberRange = { 12 | minLimit: { value: 0, label: "" }, 13 | maxLimit: { value: 0, label: "" }, 14 | step: 0, 15 | from: 0, 16 | to: 0 17 | }; 18 | @Exclude() 19 | bentRateApplicableRange: EditableNumberRange = { 20 | minLimit: { value: 0, label: "" }, 21 | maxLimit: { value: 0, label: "" }, 22 | step: 0, 23 | from: 0, 24 | to: 0 25 | }; 26 | @Exclude() 27 | bentRateApplicationDirection = BentRateApplicationDirection.HighToLow; 28 | 29 | @Exclude() 30 | readonly format: Format; 31 | 32 | @Exclude() 33 | public path!: Path; 34 | 35 | constructor(format: Format) { 36 | this.format = format; 37 | makeAutoObservable(this); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/format/LemLibOdomGeneratorFormatV0_4/index.tsx: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from "mobx"; 2 | import { MainApp, getAppStores } from "@core/MainApp"; 3 | import { makeId } from "@core/Util"; 4 | import { Path, Segment, Vector } from "@core/Path"; 5 | import { UnitOfLength, UnitConverter, Quantity } from "@core/Unit"; 6 | import { GeneralConfig, convertFormat } from "../Config"; 7 | import { Format, importPDJDataFromTextFile } from "../Format"; 8 | import { PointCalculationResult, getPathPoints, getDiscretePoints, fromDegreeToRadian } from "@core/Calculation"; 9 | import { euclideanRotation } from "@core/Coordinate"; 10 | import { UserInterface } from "@core/Layout"; 11 | import { GeneralConfigImpl } from "./GeneralConfig"; 12 | import { PathConfigImpl } from "./PathConfig"; 13 | 14 | // observable class 15 | export class LemLibOdomGeneratorFormatV0_4 implements Format { 16 | isInit: boolean = false; 17 | uid: string; 18 | 19 | private gc = new GeneralConfigImpl(this); 20 | 21 | constructor() { 22 | this.uid = makeId(10); 23 | makeAutoObservable(this); 24 | } 25 | 26 | createNewInstance(): Format { 27 | return new LemLibOdomGeneratorFormatV0_4(); 28 | } 29 | 30 | getName(): string { 31 | return "LemLib Odom Code Gen v0.4"; 32 | } 33 | 34 | getDescription(): string { 35 | return "Generates a sequence of LemLib .moveTo function calls."; 36 | } 37 | 38 | register(app: MainApp, ui: UserInterface): void { 39 | if (this.isInit) return; 40 | this.isInit = true; 41 | } 42 | 43 | unregister(): void {} 44 | 45 | getGeneralConfig(): GeneralConfig { 46 | return this.gc; 47 | } 48 | 49 | createPath(...segments: Segment[]): Path { 50 | return new Path(new PathConfigImpl(this), ...segments); 51 | } 52 | 53 | getPathPoints(path: Path): PointCalculationResult { 54 | const result = getPathPoints(path, new Quantity(this.gc.pointDensity, this.gc.uol)); 55 | return result; 56 | } 57 | 58 | convertFromFormat(oldFormat: Format, oldPaths: Path[]): Path[] { 59 | return convertFormat(this, oldFormat, oldPaths); 60 | } 61 | 62 | importPathsFromFile(buffer: ArrayBuffer): Path[] { 63 | throw new Error("Unable to import paths from this format, try other formats?"); 64 | } 65 | 66 | exportCode(): string { 67 | const { app } = getAppStores(); 68 | 69 | let rtn = ""; 70 | const gc = app.gc as GeneralConfigImpl; 71 | 72 | const path = app.interestedPath(); 73 | if (path === undefined) throw new Error("No path to export"); 74 | if (path.segments.length === 0) throw new Error("No segment to export"); 75 | 76 | const uc = new UnitConverter(this.gc.uol, UnitOfLength.Inch); 77 | const points = getDiscretePoints(path); 78 | 79 | if (points.length > 0) { 80 | const start = points[0]; 81 | let heading = 0; 82 | 83 | if (start.heading !== undefined && gc.relativeCoords) { 84 | heading = fromDegreeToRadian(start.heading); 85 | } 86 | 87 | // ALGO: Offsets to convert the absolute coordinates to the relative coordinates LemLib uses 88 | const offsets = gc.relativeCoords ? new Vector(start.x, start.y) : new Vector(0, 0); 89 | for (const point of points) { 90 | // ALGO: Only coordinate points are supported in LemLibOdom format 91 | const relative = euclideanRotation(heading, point.subtract(offsets)); 92 | rtn += `${gc.chassisName}.moveTo(${uc.fromAtoB(relative.x).toUser()}, ${uc.fromAtoB(relative.y).toUser()}, ${ 93 | gc.movementTimeout 94 | });\n`; 95 | } 96 | } 97 | 98 | return rtn; 99 | } 100 | 101 | importPDJDataFromFile(buffer: ArrayBuffer): Record | undefined { 102 | return importPDJDataFromTextFile(buffer); 103 | } 104 | 105 | exportFile(): ArrayBufferView { 106 | const { app } = getAppStores(); 107 | 108 | let fileContent = this.exportCode(); 109 | 110 | fileContent += "\n"; 111 | 112 | fileContent += "#PATH.JERRYIO-DATA " + JSON.stringify(app.exportPDJData()); 113 | 114 | return new TextEncoder().encode(fileContent); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/format/LemLibTarballFormatV0_5/GeneralConfig.tsx: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from "mobx"; 2 | import { FieldImageSignatureAndOrigin, FieldImageOriginType, getDefaultBuiltInFieldImage } from "@core/Asset"; 3 | import { UnitOfLength } from "@core/Unit"; 4 | import { ValidateNumber } from "@core/Util"; 5 | import { Expose, Type, Exclude } from "class-transformer"; 6 | import { IsPositive, IsBoolean, ValidateNested, IsObject, IsIn } from "class-validator"; 7 | import { GeneralConfig, initGeneralConfig } from "../Config"; 8 | import { Format } from "../Format"; 9 | import { getNamedCoordinateSystems } from "@src/core/CoordinateSystem"; 10 | 11 | // observable class 12 | export class GeneralConfigImpl implements GeneralConfig { 13 | @IsPositive() 14 | @Expose() 15 | robotWidth: number = 12; 16 | @IsPositive() 17 | @Expose() 18 | robotHeight: number = 12; 19 | @IsBoolean() 20 | @Expose() 21 | robotIsHolonomic: boolean = false; 22 | @IsBoolean() 23 | @Expose() 24 | showRobot: boolean = false; 25 | @ValidateNumber(num => num > 0 && num <= 1000) // Don't use IsEnum 26 | @Expose() 27 | uol: UnitOfLength = UnitOfLength.Inch; 28 | @IsPositive() 29 | @Expose() 30 | pointDensity: number = 2; // inches 31 | @IsPositive() 32 | @Expose() 33 | controlMagnetDistance: number = 5 / 2.54; 34 | @Type(() => FieldImageSignatureAndOrigin) 35 | @ValidateNested() 36 | @IsObject() 37 | @Expose() 38 | fieldImage: FieldImageSignatureAndOrigin = 39 | getDefaultBuiltInFieldImage().getSignatureAndOrigin(); 40 | @IsIn(getNamedCoordinateSystems().map(s => s.name)) 41 | @Expose() 42 | coordinateSystem: string = "VEX Gaming Positioning System"; 43 | @Exclude() 44 | private format_: Format; 45 | 46 | constructor(format: Format) { 47 | this.format_ = format; 48 | makeAutoObservable(this); 49 | 50 | initGeneralConfig(this); 51 | } 52 | 53 | get format() { 54 | return this.format_; 55 | } 56 | 57 | getAdditionalConfigUI() { 58 | return <>; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/format/LemLibTarballFormatV0_5/PathConfig.tsx: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, action } from "mobx"; 2 | import { Typography, Slider } from "@mui/material"; 3 | import { RangeSlider } from "@src/app/component.blocks/RangeSlider"; 4 | import { UpdateProperties } from "@core/Command"; 5 | import { LayoutContext, LayoutType, PanelBuilderProps, PanelInstanceProps } from "@core/Layout"; 6 | import { getAppStores } from "@core/MainApp"; 7 | import { BentRateApplicationDirection, Path } from "@core/Path"; 8 | import { ValidateEditableNumberRange, EditableNumberRange, ValidateNumber } from "@core/Util"; 9 | import { Expose, Exclude } from "class-transformer"; 10 | import { observer } from "mobx-react-lite"; 11 | import React from "react"; 12 | import { PathConfig } from "../Config"; 13 | import { Format } from "../Format"; 14 | import LinearScaleIcon from "@mui/icons-material/LinearScale"; 15 | import { PanelBox } from "@src/app/component.blocks/PanelBox"; 16 | 17 | // observable class 18 | export class PathConfigImpl implements PathConfig { 19 | @ValidateEditableNumberRange(-Infinity, Infinity) 20 | @Expose() 21 | speedLimit: EditableNumberRange = { 22 | minLimit: { value: 0, label: "0" }, 23 | maxLimit: { value: 127, label: "127" }, 24 | step: 1, 25 | from: 20, 26 | to: 100 27 | }; 28 | @ValidateEditableNumberRange(-Infinity, Infinity) 29 | @Expose() 30 | bentRateApplicableRange: EditableNumberRange = { 31 | minLimit: { value: 0, label: "0" }, 32 | maxLimit: { value: 1, label: "1" }, 33 | step: 0.001, 34 | from: 0, 35 | to: 0.1 36 | }; 37 | @Exclude() 38 | bentRateApplicationDirection = BentRateApplicationDirection.HighToLow; 39 | @ValidateNumber(num => num >= 0.1 && num <= 255) 40 | @Expose() 41 | maxDecelerationRate: number = 127; 42 | 43 | @Exclude() 44 | readonly format: Format; 45 | 46 | @Exclude() 47 | public path!: Path; 48 | 49 | constructor(format: Format) { 50 | this.format = format; 51 | makeAutoObservable(this); 52 | } 53 | } 54 | 55 | const PathConfigPanelBody = observer((props: {}) => { 56 | const { app } = getAppStores(); 57 | 58 | const pc = app.selectedPath?.pc as PathConfigImpl | undefined; 59 | 60 | const isClassic = React.useContext(LayoutContext) === LayoutType.Classic; 61 | 62 | if (pc === undefined) { 63 | return isClassic ? undefined : (No selected path); 64 | } 65 | 66 | return ( 67 | <> 68 | Min/Max Speed 69 | 70 | 73 | app.history.execute( 74 | `Change path ${pc.path.uid} min/max speed`, 75 | new UpdateProperties(pc.speedLimit, { from, to }) 76 | ) 77 | } 78 | /> 79 | 80 | Bent Rate Applicable Range 81 | 82 | 85 | app.history.execute( 86 | `Change path ${pc.path.uid} bent rate applicable range`, 87 | new UpdateProperties(pc.bentRateApplicableRange, { from, to }) 88 | ) 89 | } 90 | /> 91 | 92 | Max Deceleration Rate 93 | 94 | { 101 | if (Array.isArray(value)) value = value[0]; 102 | app.history.execute( 103 | `Change path ${pc.path.uid} max deceleration rate`, 104 | new UpdateProperties(pc, { maxDecelerationRate: value }) 105 | ); 106 | })} 107 | /> 108 | 109 | 110 | ); 111 | }); 112 | 113 | export const PathConfigPanel = (props: PanelBuilderProps): PanelInstanceProps => { 114 | return { 115 | id: "PathConfigAccordion", 116 | header: "Path", 117 | children: , 118 | icon: 119 | }; 120 | }; 121 | -------------------------------------------------------------------------------- /src/format/MoveToPointCodeGenFormatV0_1/PathConfig.tsx: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from "mobx"; 2 | import { Typography } from "@mui/material"; 3 | import { FormInputField } from "@src/app/component.blocks/FormInputField"; 4 | import { LayoutContext, LayoutType, PanelBuilderProps, PanelInstanceProps } from "@core/Layout"; 5 | import { getAppStores } from "@core/MainApp"; 6 | import { BentRateApplicationDirection, Path } from "@core/Path"; 7 | import { EditableNumberRange } from "@core/Util"; 8 | import { NumberT, CodePointBuffer } from "@src/token/Tokens"; 9 | import { Exclude, Expose } from "class-transformer"; 10 | import { IsNumber } from "class-validator"; 11 | import { observer } from "mobx-react-lite"; 12 | import React from "react"; 13 | import { PathConfig } from "../Config"; 14 | import { Format } from "../Format"; 15 | import LinearScaleIcon from "@mui/icons-material/LinearScale"; 16 | import { PanelBox } from "@src/app/component.blocks/PanelBox"; 17 | 18 | // observable class 19 | export class PathConfigImpl implements PathConfig { 20 | @Exclude() 21 | speedLimit: EditableNumberRange = { 22 | minLimit: { value: 0, label: "0" }, 23 | maxLimit: { value: 1, label: "1" }, 24 | step: 1, 25 | from: 0, 26 | to: 1 27 | }; 28 | @Exclude() 29 | bentRateApplicableRange: EditableNumberRange = { 30 | minLimit: { value: 0, label: "0" }, 31 | maxLimit: { value: 1, label: "1" }, 32 | step: 0.001, 33 | from: 0, 34 | to: 1 35 | }; 36 | @Exclude() 37 | bentRateApplicationDirection = BentRateApplicationDirection.LowToHigh; 38 | @IsNumber() 39 | @Expose() 40 | speed: number = 30; 41 | @Exclude() 42 | readonly format: Format; 43 | 44 | @Exclude() 45 | public path!: Path; 46 | 47 | constructor(format: Format) { 48 | this.format = format; 49 | makeAutoObservable(this); 50 | } 51 | } 52 | 53 | const PathConfigPanelBody = observer((props: {}) => { 54 | const { app } = getAppStores(); 55 | 56 | const pc = app.selectedPath?.pc as PathConfigImpl | undefined; 57 | 58 | const isClassic = React.useContext(LayoutContext) === LayoutType.Classic; 59 | 60 | if (pc === undefined) { 61 | return isClassic ? undefined : (No selected path); 62 | } 63 | 64 | return ( 65 | <> 66 | 67 | pc.speed.toUser() + ""} 71 | setValue={(value: string) => { 72 | pc.speed = parseFloat(value); 73 | }} 74 | isValidIntermediate={() => true} 75 | isValidValue={(candidate: string) => NumberT.parse(new CodePointBuffer(candidate)) !== null} 76 | numeric 77 | /> 78 | 79 | 80 | ); 81 | }); 82 | 83 | export const PathConfigPanel = (props: PanelBuilderProps): PanelInstanceProps => { 84 | return { 85 | id: "PathConfigAccordion", 86 | header: "Path", 87 | children: , 88 | icon: 89 | }; 90 | }; 91 | -------------------------------------------------------------------------------- /src/format/PathDotJerryioFormatV0_1/GeneralConfig.tsx: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from "mobx"; 2 | import { FieldImageSignatureAndOrigin, FieldImageOriginType, getDefaultBuiltInFieldImage } from "@core/Asset"; 3 | import { UnitOfLength } from "@core/Unit"; 4 | import { ValidateNumber } from "@core/Util"; 5 | import { Expose, Type, Exclude } from "class-transformer"; 6 | import { IsPositive, IsBoolean, ValidateNested, IsObject, IsIn } from "class-validator"; 7 | import { GeneralConfig, initGeneralConfig } from "../Config"; 8 | import { Format } from "../Format"; 9 | import { getNamedCoordinateSystems } from "@src/core/CoordinateSystem"; 10 | 11 | // observable class 12 | export class GeneralConfigImpl implements GeneralConfig { 13 | @IsPositive() 14 | @Expose() 15 | robotWidth: number = 30; 16 | @IsPositive() 17 | @Expose() 18 | robotHeight: number = 30; 19 | @IsBoolean() 20 | @Expose() 21 | robotIsHolonomic: boolean = false; 22 | @IsBoolean() 23 | @Expose() 24 | showRobot: boolean = false; 25 | @ValidateNumber(num => num > 0 && num <= 1000) // Don't use IsEnum 26 | @Expose() 27 | uol: UnitOfLength = UnitOfLength.Centimeter; 28 | @IsPositive() 29 | @Expose() 30 | pointDensity: number = 2; 31 | @IsPositive() 32 | @Expose() 33 | controlMagnetDistance: number = 5; 34 | @Type(() => FieldImageSignatureAndOrigin) 35 | @ValidateNested() 36 | @IsObject() 37 | @Expose() 38 | fieldImage: FieldImageSignatureAndOrigin = 39 | getDefaultBuiltInFieldImage().getSignatureAndOrigin(); 40 | @IsIn(getNamedCoordinateSystems().map(s => s.name)) 41 | @Expose() 42 | coordinateSystem: string = "VEX Gaming Positioning System"; 43 | @Exclude() 44 | private format_: Format; 45 | 46 | constructor(format: Format) { 47 | this.format_ = format; 48 | makeAutoObservable(this); 49 | 50 | initGeneralConfig(this); 51 | } 52 | 53 | get format() { 54 | return this.format_; 55 | } 56 | 57 | getAdditionalConfigUI() { 58 | return <>; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/format/PathDotJerryioFormatV0_1/PathConfig.tsx: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from "mobx"; 2 | import { Typography } from "@mui/material"; 3 | import { RangeSlider } from "@src/app/component.blocks/RangeSlider"; 4 | import { UpdateProperties } from "@core/Command"; 5 | import { LayoutContext, LayoutType, PanelBuilderProps, PanelInstanceProps } from "@core/Layout"; 6 | import { getAppStores } from "@core/MainApp"; 7 | import { BentRateApplicationDirection, Path } from "@core/Path"; 8 | import { ValidateEditableNumberRange, EditableNumberRange } from "@core/Util"; 9 | import { Expose, Exclude } from "class-transformer"; 10 | import { observer } from "mobx-react-lite"; 11 | import React from "react"; 12 | import { PathConfig } from "../Config"; 13 | import { Format } from "../Format"; 14 | import LinearScaleIcon from "@mui/icons-material/LinearScale"; 15 | import { PanelBox } from "@src/app/component.blocks/PanelBox"; 16 | 17 | // observable class 18 | export class PathConfigImpl implements PathConfig { 19 | @ValidateEditableNumberRange(-Infinity, Infinity) 20 | @Expose() 21 | speedLimit: EditableNumberRange = { 22 | minLimit: { value: 0, label: "0" }, 23 | maxLimit: { value: 600, label: "600" }, 24 | step: 1, 25 | from: 40, 26 | to: 120 27 | }; 28 | @ValidateEditableNumberRange(-Infinity, Infinity) 29 | @Expose() 30 | bentRateApplicableRange: EditableNumberRange = { 31 | minLimit: { value: 0, label: "0" }, 32 | maxLimit: { value: 1, label: "1" }, 33 | step: 0.001, 34 | from: 0, 35 | to: 0.1 36 | }; 37 | @Exclude() 38 | bentRateApplicationDirection = BentRateApplicationDirection.HighToLow; 39 | @Exclude() 40 | readonly format: Format; 41 | 42 | @Exclude() 43 | public path!: Path; 44 | 45 | constructor(format: Format) { 46 | this.format = format; 47 | makeAutoObservable(this); 48 | } 49 | } 50 | 51 | const PathConfigPanelBody = observer((props: {}) => { 52 | const { app } = getAppStores(); 53 | 54 | const pc = app.selectedPath?.pc as PathConfigImpl | undefined; 55 | 56 | const isClassic = React.useContext(LayoutContext) === LayoutType.Classic; 57 | 58 | if (pc === undefined) { 59 | return isClassic ? undefined : (No selected path); 60 | } 61 | 62 | return ( 63 | <> 64 | Min/Max Speed 65 | 66 | 69 | app.history.execute( 70 | `Change path ${pc.path.uid} min/max speed`, 71 | new UpdateProperties(pc.speedLimit, { from, to }) 72 | ) 73 | } 74 | /> 75 | 76 | Bent Rate Applicable Range 77 | 78 | 81 | app.history.execute( 82 | `Change path ${pc.path.uid} bent rate applicable range`, 83 | new UpdateProperties(pc.bentRateApplicableRange, { from, to }) 84 | ) 85 | } 86 | /> 87 | 88 | 89 | ); 90 | }); 91 | 92 | export const PathConfigPanel = (props: PanelBuilderProps): PanelInstanceProps => { 93 | return { 94 | id: "PathConfigAccordion", 95 | header: "Path", 96 | children: , 97 | icon: 98 | }; 99 | }; 100 | -------------------------------------------------------------------------------- /src/format/PathDotJerryioFormatV0_1/index.tsx: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from "mobx"; 2 | import { MainApp, getAppStores } from "@core/MainApp"; 3 | import { makeId } from "@core/Util"; 4 | import { Quantity, UnitConverter, UnitOfLength } from "@core/Unit"; 5 | import { GeneralConfig, convertFormat } from "../Config"; 6 | import { Format, importPDJDataFromTextFile } from "../Format"; 7 | import { PointCalculationResult, getPathPoints } from "@core/Calculation"; 8 | import { Path, Segment } from "@core/Path"; 9 | import { isCoordinateWithHeading } from "@core/Coordinate"; 10 | import { GeneralConfigImpl } from "./GeneralConfig"; 11 | import { PathConfigImpl, PathConfigPanel } from "./PathConfig"; 12 | import { UserInterface } from "@core/Layout"; 13 | 14 | // observable class 15 | export class PathDotJerryioFormatV0_1 implements Format { 16 | isInit: boolean = false; 17 | uid: string; 18 | 19 | private gc = new GeneralConfigImpl(this); 20 | 21 | private readonly disposers: (() => void)[] = []; 22 | 23 | constructor() { 24 | this.uid = makeId(10); 25 | makeAutoObservable(this); 26 | } 27 | 28 | createNewInstance(): Format { 29 | return new PathDotJerryioFormatV0_1(); 30 | } 31 | 32 | getName(): string { 33 | return "path.jerryio v0.1"; 34 | } 35 | 36 | getDescription(): string { 37 | return "The default and official format for path planning purposes and custom library. Output is in cm, rpm."; 38 | } 39 | 40 | register(app: MainApp, ui: UserInterface): void { 41 | if (this.isInit) return; 42 | this.isInit = true; 43 | 44 | this.disposers.push(ui.registerPanel(PathConfigPanel).disposer); 45 | } 46 | 47 | unregister(): void { 48 | this.disposers.forEach(disposer => disposer()); 49 | } 50 | 51 | getGeneralConfig(): GeneralConfig { 52 | return this.gc; 53 | } 54 | 55 | createPath(...segments: Segment[]): Path { 56 | return new Path(new PathConfigImpl(this), ...segments); 57 | } 58 | 59 | getPathPoints(path: Path): PointCalculationResult { 60 | return getPathPoints(path, new Quantity(this.gc.pointDensity, this.gc.uol)); 61 | } 62 | 63 | convertFromFormat(oldFormat: Format, oldPaths: Path[]): Path[] { 64 | return convertFormat(this, oldFormat, oldPaths); 65 | } 66 | 67 | importPathsFromFile(buffer: ArrayBuffer): Path[] { 68 | throw new Error("Unable to import paths from this format, try other formats?"); 69 | } 70 | 71 | importPDJDataFromFile(buffer: ArrayBuffer): Record | undefined { 72 | return importPDJDataFromTextFile(buffer); 73 | } 74 | 75 | exportFile(): ArrayBufferView { 76 | const { app } = getAppStores(); 77 | 78 | let fileContent = ""; 79 | 80 | const uc = new UnitConverter(app.gc.uol, UnitOfLength.Centimeter); 81 | const density = new Quantity(app.gc.pointDensity, app.gc.uol); 82 | 83 | for (const path of app.paths) { 84 | fileContent += `#PATH-POINTS-START ${path.name}\n`; 85 | 86 | const points = getPathPoints(path, density).points; 87 | 88 | for (const point of points) { 89 | const x = uc.fromAtoB(point.x).toUser(); 90 | const y = uc.fromAtoB(point.y).toUser(); 91 | if (isCoordinateWithHeading(point)) fileContent += `${x},${y},${point.speed.toUser()},${point.heading}\n`; 92 | else fileContent += `${x},${y},${point.speed.toUser()}\n`; 93 | } 94 | } 95 | 96 | fileContent += "#PATH.JERRYIO-DATA " + JSON.stringify(app.exportPDJData()); 97 | 98 | return new TextEncoder().encode(fileContent); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/format/RigidCodeGenFormatV0_1/PathConfig.tsx: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from "mobx"; 2 | import { Typography } from "@mui/material"; 3 | import { FormInputField } from "@src/app/component.blocks/FormInputField"; 4 | import { BentRateApplicationDirection, Path } from "@core/Path"; 5 | import { EditableNumberRange } from "@core/Util"; 6 | import { NumberT, CodePointBuffer } from "@src/token/Tokens"; 7 | import { Exclude, Expose } from "class-transformer"; 8 | import { IsNumber } from "class-validator"; 9 | import { PathConfig } from "../Config"; 10 | import { Format } from "../Format"; 11 | import { LayoutContext, LayoutType, PanelBuilderProps, PanelInstanceProps } from "@core/Layout"; 12 | import { getAppStores } from "@core/MainApp"; 13 | import { observer } from "mobx-react-lite"; 14 | import React from "react"; 15 | import LinearScaleIcon from "@mui/icons-material/LinearScale"; 16 | import { PanelBox } from "@src/app/component.blocks/PanelBox"; 17 | 18 | // observable class 19 | export class PathConfigImpl implements PathConfig { 20 | @Exclude() 21 | speedLimit: EditableNumberRange = { 22 | minLimit: { value: 0, label: "0" }, 23 | maxLimit: { value: 1, label: "1" }, 24 | step: 1, 25 | from: 0, 26 | to: 1 27 | }; 28 | @Exclude() 29 | bentRateApplicableRange: EditableNumberRange = { 30 | minLimit: { value: 0, label: "0" }, 31 | maxLimit: { value: 1, label: "1" }, 32 | step: 0.001, 33 | from: 0, 34 | to: 1 35 | }; 36 | @Exclude() 37 | bentRateApplicationDirection = BentRateApplicationDirection.LowToHigh; 38 | @IsNumber() 39 | @Expose() 40 | speed: number = 30; 41 | @Exclude() 42 | readonly format: Format; 43 | 44 | @Exclude() 45 | public path!: Path; 46 | 47 | constructor(format: Format) { 48 | this.format = format; 49 | makeAutoObservable(this); 50 | } 51 | } 52 | 53 | const PathConfigPanelBody = observer((props: {}) => { 54 | const { app } = getAppStores(); 55 | 56 | const pc = app.selectedPath?.pc as PathConfigImpl | undefined; 57 | 58 | const isClassic = React.useContext(LayoutContext) === LayoutType.Classic; 59 | 60 | if (pc === undefined) { 61 | return isClassic ? undefined : (No selected path); 62 | } 63 | 64 | return ( 65 | <> 66 | 67 | pc.speed.toUser() + ""} 71 | setValue={(value: string) => { 72 | pc.speed = parseFloat(value); 73 | }} 74 | isValidIntermediate={() => true} 75 | isValidValue={(candidate: string) => NumberT.parse(new CodePointBuffer(candidate)) !== null} 76 | numeric 77 | /> 78 | 79 | 80 | ); 81 | }); 82 | 83 | export const PathConfigPanel = (props: PanelBuilderProps): PanelInstanceProps => { 84 | return { 85 | id: "PathConfigAccordion", 86 | header: "Path", 87 | children: , 88 | icon: 89 | }; 90 | }; 91 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import Root from "./Root"; 4 | import * as SWR from "./core/ServiceWorkerRegistration"; 5 | import { Buffer } from "buffer"; 6 | globalThis.Buffer = Buffer; 7 | 8 | const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); 9 | root.render( 10 | 11 | 12 | 13 | ); 14 | 15 | // See: https://deanhume.com/displaying-a-new-version-available-progressive-web-app/ 16 | // See: https://web.dev/service-worker-lifecycle/ 17 | // See: https://web.dev/service-worker-caching-and-http-caching/ 18 | // See: https://web.dev/progressive-web-apps/ 19 | SWR.register(); 20 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/service-worker.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /* eslint-disable no-restricted-globals */ 3 | 4 | // See: https://developers.google.com/web/tools/workbox/modules 5 | import { clientsClaim } from "workbox-core"; 6 | import { ExpirationPlugin } from "workbox-expiration"; 7 | import { precacheAndRoute } from "workbox-precaching"; 8 | import { registerRoute } from "workbox-routing"; 9 | import { StaleWhileRevalidate } from "workbox-strategies"; 10 | import LoggerImpl from "./core/LoggerImpl"; 11 | import { APP_VERSION_STRING } from "./Version"; 12 | import { 13 | ClientsCountResponse, 14 | Message, 15 | SkipWaitingResponse, 16 | VersionResponse, 17 | isMessage 18 | } from "./core/ServiceWorkerMessages"; 19 | 20 | declare const self: ServiceWorkerGlobalScope; 21 | 22 | /* 23 | XXX: 24 | For some reason, "./Logger" cannot be imported here. It exports a function and causes the following error: 25 | TypeError: Cannot read properties of undefined (reading 'register') 26 | 27 | Instead, we use "./types/LoggerImpl" which exports a class only. We use it directly to create a logger instance. 28 | This is also the reason why we separate the Logger interface and its implementation in two files. 29 | */ 30 | const logger = new LoggerImpl("Service Worker"); 31 | 32 | clientsClaim(); 33 | 34 | // See: https://developer.chrome.com/docs/workbox/modules/workbox-precaching/ 35 | const MANIFEST = self.__WB_MANIFEST; 36 | 37 | precacheAndRoute(MANIFEST); 38 | 39 | // Runtime caching route for requests that aren't handled by the precache 40 | registerRoute( 41 | ({ url }) => (url.origin === self.location.origin && url.pathname.startsWith("/api/")) === false, 42 | new StaleWhileRevalidate({ 43 | cacheName: "non-precache", 44 | plugins: [new ExpirationPlugin({ maxEntries: 50 })] 45 | }) 46 | ); 47 | 48 | self.addEventListener("message", event => { 49 | if (isMessage(event.data) === false) return; 50 | const msg = event.data as Message; 51 | 52 | if (msg.type === "GET_VERSION") { 53 | event.ports[0].postMessage(APP_VERSION_STRING as VersionResponse); 54 | } else if (msg.type === "GET_CLIENTS_COUNT") { 55 | self.clients.matchAll({ includeUncontrolled: false }).then(clients => { 56 | event.ports[0].postMessage(clients.length as ClientsCountResponse); 57 | }); 58 | } else if (msg.type === "SKIP_WAITING") { 59 | self.skipWaiting(); 60 | event.ports[0].postMessage(undefined as SkipWaitingResponse); 61 | } 62 | }); 63 | 64 | logger.log("Precache", MANIFEST); 65 | logger.log("Version", APP_VERSION_STRING); // IMPORTANT: Include the version string in service worker 66 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 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 | 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.paths.json", 3 | "compilerOptions": { 4 | "target": "es2015", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "module": "esnext", 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "noEmit": true, 22 | "jsx": "react-jsx", 23 | "experimentalDecorators": true 24 | }, 25 | "include": [ 26 | "src" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.paths.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@src/*": ["./src/*"], 6 | "@app/*": ["./src/app/*"], 7 | "@core/*": ["./src/core/*"], 8 | "@format/*": ["./src/format/*"], 9 | "@token/*": ["./src/token/*"] 10 | } 11 | } 12 | } 13 | --------------------------------------------------------------------------------