├── .env
├── .github
└── workflows
│ └── build-and-deploy-to-github-pages.yml
├── .gitignore
├── .husky
└── pre-commit
├── .lintstagedrc
├── .nvmrc
├── .prettierrc
├── README.md
├── components.json
├── docs
├── axidaw-web-arch.png
├── axidraw-pen-holder.svg
└── screenshot-setup.png
├── eslint.config.js
├── index.html
├── package.json
├── patches
└── @serialport__bindings-interface@1.2.2.patch
├── pnpm-lock.yaml
├── postcss.config.js
├── scripts
└── create-cert.sh
├── server
├── cert
│ └── .gitkeep
├── consts.ts
├── index.ts
├── package.json
├── serial-port.ts
├── utils.ts
└── ws.ts
├── src
├── analystic.ts
├── assets
│ └── svg
│ │ ├── axidraw-first.svg
│ │ ├── pen-holder.svg
│ │ ├── test-arc.svg
│ │ ├── test-bezier.svg
│ │ ├── test-lines.svg
│ │ ├── test-move.svg
│ │ ├── test-shape.svg
│ │ └── test-simple-path.svg
├── communication
│ ├── device
│ │ ├── consts.ts
│ │ ├── device.ts
│ │ ├── index.ts
│ │ ├── usb.ts
│ │ ├── utils.ts
│ │ ├── virtual.ts
│ │ ├── webscoket-type.ts
│ │ └── websocket.ts
│ └── ebb
│ │ ├── command.ts
│ │ ├── commands
│ │ ├── a.ts
│ │ ├── ac.ts
│ │ ├── bl.ts
│ │ ├── c.ts
│ │ ├── ck.ts
│ │ ├── cs.ts
│ │ ├── cu.ts
│ │ ├── em.ts
│ │ ├── es.ts
│ │ ├── hm.ts
│ │ ├── i.ts
│ │ ├── lm.ts
│ │ ├── lt.ts
│ │ ├── mr.ts
│ │ ├── mw.ts
│ │ ├── nd.ts
│ │ ├── ni.ts
│ │ ├── o.ts
│ │ ├── pc.ts
│ │ ├── pd.ts
│ │ ├── pg.ts
│ │ ├── pi.ts
│ │ ├── po.ts
│ │ ├── qb.ts
│ │ ├── qc.ts
│ │ ├── qe.ts
│ │ ├── qg.ts
│ │ ├── ql.ts
│ │ ├── qm.ts
│ │ ├── qn.ts
│ │ ├── qp.ts
│ │ ├── qr.ts
│ │ ├── qs.ts
│ │ ├── qt.ts
│ │ ├── r.ts
│ │ ├── rb.ts
│ │ ├── s2.ts
│ │ ├── sc.ts
│ │ ├── se.ts
│ │ ├── sl.ts
│ │ ├── sm.ts
│ │ ├── sn.ts
│ │ ├── sp.ts
│ │ ├── sr.ts
│ │ ├── st.ts
│ │ ├── t.ts
│ │ ├── tp.ts
│ │ ├── v.ts
│ │ └── xm.ts
│ │ ├── constants.ts
│ │ ├── index.ts
│ │ ├── messages
│ │ ├── ebb.ts
│ │ ├── error.ts
│ │ └── ok.ts
│ │ └── utils.ts
├── components
│ ├── device-connector
│ │ └── device-connector.tsx
│ ├── footer
│ │ ├── footer.module.css
│ │ └── footer.tsx
│ ├── loading
│ │ ├── loading.module.css
│ │ └── loading.tsx
│ └── ui
│ │ ├── alert.tsx
│ │ ├── button.tsx
│ │ ├── form.module.css
│ │ ├── sheet.module.css
│ │ ├── toast.tsx
│ │ └── toaster.tsx
├── containers
│ ├── app
│ │ └── app.tsx
│ ├── composer
│ │ ├── components
│ │ │ └── midi-commander.tsx
│ │ ├── composer.tsx
│ │ ├── songs
│ │ │ ├── index.ts
│ │ │ ├── the-wheels-on-the-bus.ts
│ │ │ └── twinkle-twinkle-little-star.ts
│ │ └── utils.ts
│ ├── debugger
│ │ ├── components
│ │ │ ├── batch-commander.tsx
│ │ │ ├── fav-commander.tsx
│ │ │ └── simple-commander.tsx
│ │ ├── debugger.tsx
│ │ └── utils.ts
│ ├── plotter
│ │ ├── components
│ │ │ ├── panels
│ │ │ │ ├── esimating.tsx
│ │ │ │ ├── panel.module.css
│ │ │ │ ├── panel.tsx
│ │ │ │ ├── planning.module.css
│ │ │ │ ├── planning.tsx
│ │ │ │ ├── plotting.module.css
│ │ │ │ ├── plotting.tsx
│ │ │ │ ├── setup.module.css
│ │ │ │ ├── setup.tsx
│ │ │ │ └── simple-debugger.tsx
│ │ │ └── workspace
│ │ │ │ ├── debug.module.css
│ │ │ │ ├── debug.tsx
│ │ │ │ ├── gizmo.module.css
│ │ │ │ ├── gizmo.tsx
│ │ │ │ ├── page.module.css
│ │ │ │ ├── page.tsx
│ │ │ │ ├── planning.module.css
│ │ │ │ ├── planning.tsx
│ │ │ │ ├── setup.module.css
│ │ │ │ ├── setup.tsx
│ │ │ │ ├── shadow-def.tsx
│ │ │ │ ├── workspace.module.css
│ │ │ │ └── workspace.tsx
│ │ ├── context.ts
│ │ ├── plotter.module.css
│ │ ├── plotter.tsx
│ │ ├── presenters
│ │ │ ├── page.ts
│ │ │ ├── planning.ts
│ │ │ └── work.ts
│ │ └── utils.ts
│ └── virtual
│ │ ├── components
│ │ ├── canvas.module.css
│ │ ├── canvas.tsx
│ │ ├── pen-holder.module.css
│ │ └── pen-holder.tsx
│ │ ├── plotter
│ │ ├── command.ts
│ │ ├── commands
│ │ │ ├── em.ts
│ │ │ ├── hm.ts
│ │ │ ├── lm.ts
│ │ │ ├── qb.ts
│ │ │ ├── qs.ts
│ │ │ ├── r.ts
│ │ │ ├── sc.ts
│ │ │ ├── sm.ts
│ │ │ ├── sp.ts
│ │ │ ├── sr.ts
│ │ │ ├── tp.ts
│ │ │ └── v.ts
│ │ ├── index.ts
│ │ └── utils.ts
│ │ ├── utils.ts
│ │ ├── virtual.module.css
│ │ └── virtual.tsx
├── css
│ ├── base.css
│ ├── fonts.css
│ ├── preflight.css
│ └── typograph.css
├── hooks
│ ├── device.ts
│ └── use-toast.ts
├── index.css
├── lib
│ └── utils.ts
├── main.tsx
├── math
│ ├── ebb.ts
│ ├── geom.ts
│ └── svg.ts
├── plotter
│ ├── cleaner.ts
│ ├── consts.ts
│ ├── estimator.ts
│ ├── motion
│ │ ├── const-acceleration.ts
│ │ └── const-velocity.ts
│ ├── planner.ts
│ ├── plotter.ts
│ ├── rtree
│ │ ├── index.ts
│ │ ├── tests
│ │ │ └── rtree.test.ts
│ │ └── utils.ts
│ ├── svg
│ │ ├── arc-to-lines.ts
│ │ ├── bezier-to-lines.ts
│ │ ├── element-to-path.ts
│ │ ├── math.ts
│ │ ├── path-to-lines.ts
│ │ ├── path
│ │ │ ├── cmd-a.ts
│ │ │ ├── cmd-c.ts
│ │ │ ├── cmd-h.ts
│ │ │ ├── cmd-l.ts
│ │ │ ├── cmd-m.ts
│ │ │ ├── cmd-q.ts
│ │ │ ├── cmd-s.ts
│ │ │ ├── cmd-t.ts
│ │ │ ├── cmd-v.ts
│ │ │ ├── cmd-z.ts
│ │ │ ├── index.ts
│ │ │ ├── parser
│ │ │ │ ├── index.ts
│ │ │ │ ├── tests
│ │ │ │ │ └── parser.test.ts
│ │ │ │ └── utils.ts
│ │ │ ├── steppers.ts
│ │ │ ├── transformers.ts
│ │ │ └── utils.ts
│ │ ├── points-to-lines.ts
│ │ ├── presentation.ts
│ │ ├── svg-to-lines.ts
│ │ └── utils.ts
│ └── utils.ts
├── utils
│ ├── dom-event.ts
│ ├── file.ts
│ ├── logger.ts
│ ├── style.ts
│ └── time.ts
├── vite-env.d.ts
└── web-usb.d.ts
├── tailwind.config.js
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.env:
--------------------------------------------------------------------------------
1 | VITE_GA=
2 |
--------------------------------------------------------------------------------
/.github/workflows/build-and-deploy-to-github-pages.yml:
--------------------------------------------------------------------------------
1 | name: Axidraw Web Deployment
2 | run-name: ${{ github.actor }} is deploying Axidraw Web to GitHub Pages
3 | on:
4 | workflow_dispatch:
5 |
6 | jobs:
7 | Build:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Checkout
11 | uses: actions/checkout@v4
12 | - name: Setup Node.js
13 | uses: actions/setup-node@v4
14 | with:
15 | node-version-file: .nvmrc
16 | - name: Setup pnpm
17 | uses: pnpm/action-setup@v4
18 | with:
19 | run_install: true
20 | - name: Build
21 | run: pnpm build
22 | - name: Upload static files as artifact
23 | uses: actions/upload-pages-artifact@v3
24 | with:
25 | path: dist/
26 | Deploy:
27 | runs-on: ubuntu-latest
28 | needs: Build
29 | permissions:
30 | pages: write
31 | id-token: write
32 | environment:
33 | name: github-pages
34 | url: ${{ steps.deployment.outputs.page_url }}
35 | steps:
36 | - name: Deploy to GitHub Pages
37 | id: deployment
38 | uses: actions/deploy-pages@v4
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | # Caches
27 | .eslintcache
28 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | pnpm lint-staged
2 |
--------------------------------------------------------------------------------
/.lintstagedrc:
--------------------------------------------------------------------------------
1 | {
2 | "*.{ts,tsx}": "eslint --cache --fix"
3 | }
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 23.5.0
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "all",
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": true,
6 | "singleQuote": true,
7 | "endOfLine": "lf",
8 | "printWidth": 80
9 | }
10 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/index.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/docs/axidaw-web-arch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mutoo/axidraw-web/fc50eb3fd597059f18dec7406ed8175b4c59a340/docs/axidaw-web-arch.png
--------------------------------------------------------------------------------
/docs/screenshot-setup.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mutoo/axidraw-web/fc50eb3fd597059f18dec7406ed8175b4c59a340/docs/screenshot-setup.png
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js';
2 | import globals from 'globals';
3 | import react from 'eslint-plugin-react';
4 | import reactHooks from 'eslint-plugin-react-hooks';
5 | import reactRefresh from 'eslint-plugin-react-refresh';
6 | import tseslint from 'typescript-eslint';
7 | import prettier from 'eslint-config-prettier';
8 | import importPlugin from 'eslint-plugin-import';
9 |
10 | export default tseslint.config(
11 | { ignores: ['dist'] },
12 | {
13 | extends: [
14 | js.configs.recommended,
15 | ...tseslint.configs.strictTypeChecked,
16 | importPlugin.flatConfigs.recommended,
17 | importPlugin.flatConfigs.typescript,
18 | prettier,
19 | ],
20 | files: ['**/*.{ts,tsx}'],
21 | languageOptions: {
22 | ecmaVersion: 2020,
23 | globals: globals.browser,
24 | parserOptions: {
25 | project: ['./tsconfig.node.json', './tsconfig.app.json'],
26 | tsconfigRootDir: import.meta.dirname,
27 | },
28 | },
29 | settings: {
30 | react: {
31 | version: '18.3',
32 | },
33 | 'import/resolver': {
34 | typescript: true,
35 | node: true,
36 | },
37 | },
38 | plugins: {
39 | react,
40 | 'react-hooks': reactHooks,
41 | 'react-refresh': reactRefresh,
42 | },
43 | rules: {
44 | ...react.configs.recommended.rules,
45 | ...react.configs['jsx-runtime'].rules,
46 | ...reactHooks.configs.recommended.rules,
47 | 'react-refresh/only-export-components': [
48 | 'warn',
49 | { allowConstantExport: true },
50 | ],
51 | 'import/order': [
52 | 'error',
53 | {
54 | pathGroups: [
55 | {
56 | pattern: '@/**',
57 | group: 'external',
58 | position: 'after',
59 | },
60 | ],
61 | pathGroupsExcludedImportTypes: ['builtin'],
62 | alphabetize: {
63 | order: 'asc',
64 | caseInsensitive: true,
65 | },
66 | },
67 | ],
68 | '@typescript-eslint/no-unused-vars': [
69 | 'error',
70 | {
71 | args: 'all',
72 | argsIgnorePattern: '^_',
73 | caughtErrors: 'all',
74 | caughtErrorsIgnorePattern: '^_',
75 | destructuredArrayIgnorePattern: '^_',
76 | varsIgnorePattern: '^_',
77 | ignoreRestSiblings: true,
78 | },
79 | ],
80 | '@typescript-eslint/restrict-template-expressions': [
81 | 'error',
82 | {
83 | allowNumber: true,
84 | },
85 | ],
86 | '@typescript-eslint/no-non-null-assertion': 'off',
87 | },
88 | },
89 | );
90 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Axidraw Web
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "axidraw-web",
3 | "private": true,
4 | "version": "0.2.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "test": "vitest",
9 | "build": "tsc -b && vite build",
10 | "lint": "eslint .",
11 | "server": "ts-node server",
12 | "preview": "vite preview",
13 | "prepare": "husky"
14 | },
15 | "dependencies": {
16 | "@radix-ui/react-slot": "^1.1.1",
17 | "@radix-ui/react-toast": "^1.2.4",
18 | "@tailwindcss/forms": "^0.5.10",
19 | "class-variance-authority": "^0.7.1",
20 | "clsx": "^2.1.1",
21 | "events": "^3.3.0",
22 | "express": "5",
23 | "js-logger": "^1.6.1",
24 | "lucide-react": "^0.469.0",
25 | "mobx": "^6.13.5",
26 | "mobx-react-lite": "^4.1.0",
27 | "query-string": "^9.1.1",
28 | "react": "^18.3.1",
29 | "react-dom": "^18.3.1",
30 | "react-ga4": "^2.1.0",
31 | "react-router": "^7.1.1",
32 | "serialport": "^13.0.0",
33 | "tailwind-merge": "^2.6.0",
34 | "tailwindcss-animate": "^1.0.7",
35 | "ws": "^8.18.0"
36 | },
37 | "devDependencies": {
38 | "@eslint/js": "^9.17.0",
39 | "@serialport/bindings-interface": "^1.2.2",
40 | "@types/express": "^5.0.0",
41 | "@types/node": "^22.10.5",
42 | "@types/react": "^18.3.18",
43 | "@types/react-dom": "^18.3.5",
44 | "@types/w3c-web-usb": "^1.0.10",
45 | "@types/ws": "^8.5.13",
46 | "@vitejs/plugin-react": "^4.3.4",
47 | "autoprefixer": "^10.4.20",
48 | "eslint": "^9.17.0",
49 | "eslint-config-prettier": "^9.1.0",
50 | "eslint-import-resolver-typescript": "^3.7.0",
51 | "eslint-plugin-import": "^2.31.0",
52 | "eslint-plugin-react": "^7.37.3",
53 | "eslint-plugin-react-hooks": "^5.0.0",
54 | "eslint-plugin-react-refresh": "^0.4.16",
55 | "globals": "^15.14.0",
56 | "husky": "^9.1.7",
57 | "lint-staged": "^15.3.0",
58 | "postcss": "^8.4.49",
59 | "postcss-import": "^16.1.0",
60 | "prettier": "^3.4.2",
61 | "tailwindcss": "^3.4.17",
62 | "ts-node": "^10.9.2",
63 | "typescript": "~5.6.2",
64 | "typescript-eslint": "^8.18.2",
65 | "vite": "^6.0.5",
66 | "vitest": "^2.1.8"
67 | },
68 | "packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a",
69 | "pnpm": {
70 | "patchedDependencies": {
71 | "@serialport/bindings-interface@1.2.2": "patches/@serialport__bindings-interface@1.2.2.patch"
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/patches/@serialport__bindings-interface@1.2.2.patch:
--------------------------------------------------------------------------------
1 | diff --git a/package.json b/package.json
2 | index 1f46c413862b1b4df9bc152a21f1c7405923074e..7694e1aed63b9f59ab7da4015e9eff44da17db04 100644
3 | --- a/package.json
4 | +++ b/package.json
5 | @@ -6,6 +6,7 @@
6 | "main": "./dist/index.js",
7 | "exports": {
8 | "require": "./dist/index.js",
9 | + "types": "./dist/index.d.ts",
10 | "default": "./dist/index-esm.mjs"
11 | },
12 | "publishConfig": {
13 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | 'postcss-import': {},
4 | tailwindcss: {},
5 | autoprefixer: {},
6 | },
7 | }
8 |
--------------------------------------------------------------------------------
/scripts/create-cert.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | CERT_DIR=`dirname $0`/../server/cert
4 | FILENAME_CA_CNF=ca.cnf
5 | FILENAME_CA_KEY=ca.key
6 | FILENAME_CA_CERT=ca.pem
7 | FILENAME_CERT_CNF=localhost.cnf
8 | FILENAME_CERT_KEY=localhost.key
9 | FILENAME_CERT_CSR=localhost.csr
10 | FILENAME_CERT_EXT=localhost.ext
11 | FILENAME_CERT_CERT=localhost.crt
12 |
13 | # create cert folder
14 | mkdir -p $CERT_DIR
15 | cd $CERT_DIR
16 |
17 | cat > $FILENAME_CA_CNF < $FILENAME_CERT_CNF < $FILENAME_CERT_EXT < {
32 | const addr = server.address();
33 | if (!addr) {
34 | console.error('Server address is not available');
35 | return;
36 | }
37 | if (typeof addr === 'string') {
38 | console.log(`Listening on ${addr}`);
39 | } else {
40 | console.log(`Listening on https://${addr.address}:${addr.port}`);
41 | }
42 | });
43 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "axidraw-web-server",
3 | "private": true,
4 | "version": "0.2.0",
5 | "type": "commonjs"
6 | }
7 |
--------------------------------------------------------------------------------
/server/serial-port.ts:
--------------------------------------------------------------------------------
1 | import { PortInfo } from '@serialport/bindings-interface';
2 | import { SerialPort } from 'serialport';
3 | import { delay } from './utils';
4 |
5 | export const AXIDRAW_VENDOR_ID = '04d8';
6 | export const AXIDRAW_PRODUCT_ID = 'fd92';
7 |
8 | export const listDevices = async () => {
9 | const ports: PortInfo[] = await SerialPort.list();
10 | return ports.filter(
11 | (port) =>
12 | port.vendorId?.toLowerCase() === AXIDRAW_VENDOR_ID &&
13 | port.productId?.toLowerCase() === AXIDRAW_PRODUCT_ID,
14 | );
15 | };
16 |
17 | export const waitForEBB = async (deviceId: string, retry = 10) => {
18 | let retried = 0;
19 | while (retried < retry) {
20 | const EBBs = await listDevices();
21 | const device = EBBs.find((ebb) => ebb.path === deviceId);
22 | if (device) {
23 | return deviceId;
24 | }
25 |
26 | console.log('EBB not found, will retry in 3s...');
27 |
28 | await delay(3000);
29 | retried += 1;
30 | }
31 | throw new Error('Device not available right now.');
32 | };
33 |
34 | export const connectToDevice = async (
35 | deviceId: string,
36 | dataHandler: (resp: Buffer) => void,
37 | ) => {
38 | const path = await waitForEBB(deviceId, 10);
39 | return new Promise((resolve, reject) => {
40 | const port = new SerialPort({ path, baudRate: 9600 });
41 | port.on('open', () => {
42 | console.log(`Connected to port: ${path}`);
43 | resolve(port);
44 | });
45 | port.on('error', (err) => {
46 | console.log(`Can not connect to port: ${err}`);
47 | reject(err);
48 | });
49 | port.on('data', dataHandler);
50 | });
51 | };
52 |
--------------------------------------------------------------------------------
/server/utils.ts:
--------------------------------------------------------------------------------
1 | export const delay = (ms: number) => {
2 | return new Promise((resolve) => {
3 | setTimeout(resolve, ms);
4 | });
5 | };
6 |
--------------------------------------------------------------------------------
/server/ws.ts:
--------------------------------------------------------------------------------
1 | import { Server } from 'https';
2 | import { Express } from 'express';
3 | import { SerialPort } from 'serialport';
4 | import { WebSocket, WebSocketServer } from 'ws';
5 | import {
6 | ServerMessage,
7 | ClientMessage,
8 | } from '../src/communication/device/webscoket-type';
9 | import {
10 | WEBSOCKET_STATUS_AUTHORIZED,
11 | WEBSOCKET_STATUS_CONNECTED,
12 | WEBSOCKET_STATUS_DISCONNECTED,
13 | WEBSOCKET_STATUS_STANDBY,
14 | } from './consts';
15 | import { connectToDevice, listDevices } from './serial-port';
16 |
17 | const authCode = process.env.AXIDRAW_AUTH || 'axidraw-web';
18 |
19 | export default function setupWebSocket(_app: Express, server: Server) {
20 | const wss = new WebSocketServer({ server, path: '/axidraw' });
21 |
22 | wss.on('connection', (ws: WebSocket) => {
23 | let wsStatus = WEBSOCKET_STATUS_CONNECTED;
24 | let port: SerialPort | undefined;
25 | const wsSend = (data: ServerMessage) => {
26 | if (ws.readyState !== WebSocket.OPEN) {
27 | console.log('WebSocket is not open');
28 | return;
29 | }
30 | ws.send(JSON.stringify({ status: wsStatus, ...data }));
31 | };
32 | ws.on('message', (message: string) => {
33 | const data = JSON.parse(message) as ClientMessage;
34 | switch (data.type) {
35 | case 'auth':
36 | if (data.code !== authCode) {
37 | ws.close(3000, 'Forbidden');
38 | } else {
39 | wsStatus = WEBSOCKET_STATUS_AUTHORIZED;
40 | void listDevices().then((EBBs) => {
41 | wsSend({ type: 'devices', devices: EBBs });
42 | });
43 | }
44 | break;
45 | case 'device_id':
46 | void (async () => {
47 | try {
48 | const { device } = data;
49 | port = await connectToDevice(device, (response: Buffer) => {
50 | console.log(`EBB: ${Array.from(response).join(' ')}`);
51 | wsSend({ type: 'ebb', response: Array.from(response) });
52 | });
53 | if (wsStatus === WEBSOCKET_STATUS_DISCONNECTED) {
54 | // websocket may disconnect during connecting to device
55 | return;
56 | }
57 | wsStatus = WEBSOCKET_STATUS_STANDBY;
58 | wsSend({ type: 'ready' });
59 | } catch (e) {
60 | console.log('Failed to connect EBB');
61 | ws.close(3001, String(e));
62 | }
63 | })();
64 | break;
65 | case 'command':
66 | if (port) {
67 | console.log(`Client: ${data.command}`);
68 | port.write(data.command);
69 | } else {
70 | ws.close(3002, 'Device is not connected');
71 | }
72 | break;
73 | default:
74 | ws.close(3003, 'Unknown message.');
75 | }
76 | });
77 | ws.on('error', (e) => {
78 | console.error(e.toString());
79 | ws.close(3005, `Internal Error: ${e.toString()}`);
80 | });
81 | ws.on('close', () => {
82 | if (port) {
83 | console.log('Disconnect from client, close EBB');
84 | port.write('R\r');
85 | port.close();
86 | }
87 | wsStatus = WEBSOCKET_STATUS_DISCONNECTED;
88 | });
89 | });
90 | }
91 |
--------------------------------------------------------------------------------
/src/analystic.ts:
--------------------------------------------------------------------------------
1 | import ReactGA from 'react-ga4';
2 |
3 | if (import.meta.env.VITE_GA) {
4 | ReactGA.initialize(import.meta.env.VITE_GA, {
5 | testMode: import.meta.env.MODE === 'development',
6 | });
7 | }
8 |
9 | export const trackCategoryEvent =
10 | (category: string) => (action: string, label?: string) => {
11 | if (import.meta.env.VITE_GA) {
12 | ReactGA.event({
13 | category,
14 | action,
15 | label,
16 | });
17 | } else {
18 | console.log(`track event: ${category} ${action} ${label}`);
19 | }
20 | };
21 |
--------------------------------------------------------------------------------
/src/assets/svg/test-arc.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/svg/test-bezier.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/svg/test-lines.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
30 |
--------------------------------------------------------------------------------
/src/assets/svg/test-move.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/svg/test-shape.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/svg/test-simple-path.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/communication/device/consts.ts:
--------------------------------------------------------------------------------
1 | export const DEVICE_TYPE_USB = 'axidraw-web-device-type-usb';
2 | export const DEVICE_TYPE_WEBSOCKET = 'axidraw-web-device-type-ws';
3 | export const DEVICE_TYPE_VIRTUAL = 'axidraw-web-device-type-virtual';
4 |
5 | export const DEVICE_EVENT_CONNECTED = 'axidraw-web-device-event-connected';
6 | export const DEVICE_EVENT_DISCONNECTED =
7 | 'axidraw-web-device-event-disconnected';
8 |
9 | export const WEBSOCKET_STATUS_DISCONNECTED =
10 | 'axidraw-web-ws-status-disconnected';
11 | export const WEBSOCKET_STATUS_CONNECTED = 'axidraw-web-ws-status-connected';
12 | export const WEBSOCKET_STATUS_AUTHORIZED = 'axidraw-web-ws-status-authorized';
13 | export const WEBSOCKET_STATUS_STANDBY = 'axidraw-web-ws-status-standby';
14 |
15 | export const WEBSOCKET_EVENT_CONNECTED = 'axidraw-web-ws-event-connected';
16 | export const WEBSOCKET_EVENT_DISCONNECTED = 'axidraw-web-ws-event-disconnected';
17 | export const WEBSOCKET_EVENT_MESSAGE = 'axidraw-web-ws-event-message';
18 |
19 | export const VIRTUAL_STATUS_DISCONNECTED =
20 | 'axidraw-web-virtual-status-disconnected';
21 | export const VIRTUAL_STATUS_CONNECTED = 'axidraw-web-virtual-status-connected';
22 |
23 | export const VIRTUAL_EVENT_STARTED = 'axidraw-web-virtual-event-started';
24 | export const VIRTUAL_EVENT_CONNECTED = 'axidraw-web-virtual-event-connected';
25 | export const VIRTUAL_EVENT_DISCONNECTED =
26 | 'axidraw-web-virtual-event-disconnected';
27 | export const VIRTUAL_EVENT_MESSAGE = 'axidraw-web-virtual-event-message';
28 |
--------------------------------------------------------------------------------
/src/communication/device/device.ts:
--------------------------------------------------------------------------------
1 | import { Command } from '../ebb/command';
2 |
3 | export interface IDevice {
4 | /**
5 | * Check if the device is ready to send messages.
6 | */
7 | isReady: boolean;
8 | /**
9 | * Check the status of the device. Throw an error if the device is not ready.
10 | */
11 | checkStatus(): void;
12 | /**
13 | * Send a message to the device.
14 | */
15 | send(message: string): Promise;
16 | /**
17 | * Disconnect the device.
18 | */
19 | disconnect(): Promise;
20 | /**
21 | * Listen for the device disconnected event.
22 | */
23 | onDisconnected(listener: (reason: string) => void): void;
24 | }
25 |
26 | export type DevicePicker = (devices: T[]) => Promise;
27 |
28 | export interface IDeviceConnector {
29 | connectDevice: (config: C) => Promise;
30 | disconnectDevice: () => Promise;
31 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
32 | executeCommand: (
33 | cmd: Command,
34 | ...params: T
35 | ) => Promise;
36 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
37 | on(event: string, listener: (...args: any[]) => void): void;
38 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
39 | off(event: string, listener: (...args: any[]) => void): void;
40 | type: string;
41 | version: string;
42 | isConnected: boolean;
43 | }
44 |
--------------------------------------------------------------------------------
/src/communication/device/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DEVICE_TYPE_USB,
3 | DEVICE_TYPE_VIRTUAL,
4 | DEVICE_TYPE_WEBSOCKET,
5 | } from './consts';
6 | import { DevicePicker } from './device';
7 | import createUSBDevice from './usb';
8 | import createVirtualDevice from './virtual';
9 | import { WSDevice } from './webscoket-type';
10 | import createWSDevice from './websocket';
11 |
12 | export default function createDevice(
13 | type: string,
14 | devicePicker: DevicePicker,
15 | ) {
16 | switch (type) {
17 | default:
18 | case DEVICE_TYPE_USB:
19 | return createUSBDevice({
20 | devicePicker: devicePicker as DevicePicker,
21 | });
22 | case DEVICE_TYPE_WEBSOCKET:
23 | return createWSDevice({
24 | devicePicker: devicePicker as DevicePicker,
25 | });
26 | case DEVICE_TYPE_VIRTUAL:
27 | return createVirtualDevice();
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/communication/device/webscoket-type.ts:
--------------------------------------------------------------------------------
1 | export type WSDevice = { path: string };
2 |
3 | export type ServerMessage =
4 | | { type: 'ready' }
5 | | {
6 | type: 'devices';
7 | devices: WSDevice[];
8 | }
9 | | { type: 'ebb'; response: ArrayLike };
10 |
11 | export type ClientMessage =
12 | | { type: 'auth'; code: string }
13 | | { type: 'device_id'; device: string }
14 | | { type: 'command'; command: string };
15 |
--------------------------------------------------------------------------------
/src/communication/ebb/command.ts:
--------------------------------------------------------------------------------
1 | export type UnfinishedMessage = {
2 | consumed: number;
3 | };
4 |
5 | export type FinishedMessage = {
6 | consumed: number;
7 | remain: string;
8 | result: T;
9 | };
10 |
11 | export type CommandGenerator = Generator<
12 | string | UnfinishedMessage,
13 | FinishedMessage,
14 | number[]
15 | >;
16 |
17 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
18 | export type Command = {
19 | cmd: string;
20 | title: string;
21 | create: (...params: T) => CommandGenerator;
22 | parseParams: (params: string) => T;
23 | version?: string;
24 | execution?: number;
25 | };
26 |
27 | export type ExtractCommandParams = T extends {
28 | create: (...params: infer P) => CommandGenerator;
29 | }
30 | ? P
31 | : never;
32 |
33 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
34 | export type CommandWithParams> = {
35 | cmd: T;
36 | title?: string;
37 | params: ExtractCommandParams;
38 | };
39 |
40 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
41 | export const createCommand = (
42 | cmd: string,
43 | title: string,
44 | create: (...params: T) => CommandGenerator,
45 | parseParams: (params: string) => T,
46 | options: { version?: string; execution?: number } = {},
47 | ): Command => {
48 | return {
49 | cmd,
50 | title,
51 | create,
52 | parseParams,
53 | ...options,
54 | };
55 | };
56 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/a.ts:
--------------------------------------------------------------------------------
1 | import { CommandGenerator, createCommand } from '../command';
2 | import { ENDING_CR_NL } from '../constants';
3 | import { noParameters, readUntil, toInt, transformResult } from '../utils';
4 |
5 | export const cmd = 'A';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Get analog values',
10 | function* (): CommandGenerator<{ [port: number]: number }> {
11 | const dataIn = yield `${cmd}\r`;
12 | // example response: "A,00:0713,02:0241,05:0089:09:1004\r\n"
13 | const parsed = yield* readUntil(ENDING_CR_NL, dataIn);
14 | return transformResult(parsed, (result) => {
15 | const pairs = result.trim().substring(2).split(',');
16 | return pairs.reduce((memo, pair) => {
17 | const [port, value] = pair.split(':');
18 | return { ...memo, [toInt(port)]: toInt(value) };
19 | }, {});
20 | });
21 | },
22 | noParameters,
23 | {
24 | version: '2.2.3',
25 | },
26 | );
27 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/ac.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 |
4 | export const cmd = 'AC';
5 |
6 | export default createCommand(
7 | cmd,
8 | 'Analog configure',
9 | function* (channel: number, enable: number) {
10 | const dataIn = yield `${cmd},${[channel, enable].join(',')}\r`;
11 | return yield* handleOKMessage(dataIn);
12 | },
13 | (params: string): [number, number] => {
14 | const [channel, enable] = params.split(',').map((p) => parseInt(p, 10));
15 | return [channel, enable];
16 | },
17 | {
18 | version: '2.2.3',
19 | },
20 | );
21 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/bl.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 | import { noParameters } from '../utils';
4 |
5 | export const cmd = 'BL';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Enter bootloader',
10 | function* () {
11 | const dataIn = yield `${cmd}\r`;
12 | return yield* handleOKMessage(dataIn);
13 | },
14 | noParameters,
15 | {
16 | version: '1.9.5',
17 | },
18 | );
19 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/c.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 |
4 | export const cmd = 'C';
5 |
6 | export default createCommand(
7 | cmd,
8 | 'Configure pin direction',
9 | function* (
10 | portA: number,
11 | portB: number,
12 | portC: number,
13 | portD: number,
14 | portE: number,
15 | ) {
16 | const dataIn = yield `${cmd},${[portA, portB, portC, portD, portE].join(
17 | ',',
18 | )}\r`;
19 | return yield* handleOKMessage(dataIn);
20 | },
21 | (params: string): [number, number, number, number, number] => {
22 | const [portA, portB, portC, portD, portE] = params
23 | .split(',')
24 | .map((p) => parseInt(p, 10));
25 | return [portA, portB, portC, portD, portE];
26 | },
27 | );
28 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/ck.ts:
--------------------------------------------------------------------------------
1 | import { CommandGenerator, createCommand } from '../command';
2 | import { ENDING_OK_CR_NL } from '../constants';
3 | import { readUntil, toInt, transformResult } from '../utils';
4 |
5 | export const cmd = 'CK';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Check input',
10 | function* (
11 | v1: number,
12 | v2: number,
13 | v3: number,
14 | v4: number,
15 | v5: number,
16 | v6: number,
17 | v7: string,
18 | v8: string,
19 | ): CommandGenerator<(number | string)[]> {
20 | const dataIn = yield `${cmd},${[v1, v2, v3, v4, v5, v6, v7, v8].join(
21 | ',',
22 | )}\r`;
23 | // example response: "Param1=0\r\nParam2=0\r\nParam3=0\r\nParam4=0\r\nParam5=0\r\nParam6=0\r\nParam7=a\r\nParam8=A\r\nOK\r\n"
24 | const parsed = yield* readUntil(ENDING_OK_CR_NL, dataIn);
25 | return transformResult(parsed, (result) => {
26 | return result
27 | .substring(0, result.length - 6) // discard \r\nOK\r\n
28 | .split(/\s+/)
29 | .map((pair, idx) => {
30 | const value = pair.split('=')[1];
31 | return idx < 6 ? toInt(value) : value;
32 | });
33 | });
34 | },
35 | (
36 | params: string,
37 | ): [number, number, number, number, number, number, string, string] => {
38 | const [v1, v2, v3, v4, v5, v6, v7, v8] = params.split(',');
39 | const [n1, n2, n3, n4, n5, n6] = [v1, v2, v3, v4, v5, v6].map((p) =>
40 | parseInt(p, 10),
41 | );
42 | return [n1, n2, n3, n4, n5, n6, v7, v8];
43 | },
44 | );
45 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/cs.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 | import { noParameters } from '../utils';
4 |
5 | export const cmd = 'CS';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Clear step position',
10 | function* () {
11 | const dataIn = yield `${cmd}\r`;
12 | return yield* handleOKMessage(dataIn);
13 | },
14 | noParameters,
15 | {
16 | version: '2.4.3',
17 | },
18 | );
19 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/cu.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 |
4 | export const cmd = 'CU';
5 |
6 | export default createCommand(
7 | cmd,
8 | 'Configure user options',
9 | function* (paramNumber: number, paramValue: number) {
10 | const dataIn = yield `${cmd},${[paramNumber, paramValue].join(',')}\r`;
11 | return yield* handleOKMessage(dataIn);
12 | },
13 | (params: string): [number, number] => {
14 | const [paramNumber, paramValue] = params
15 | .split(',')
16 | .map((p) => parseInt(p, 10));
17 | return [paramNumber, paramValue];
18 | },
19 | );
20 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/em.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { EXECUTION_FIFO } from '../constants';
3 | import handleOKMessage from '../messages/ok';
4 |
5 | export const cmd = 'EM';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Enable Motors',
10 | function* (motor1: number, motor2: number) {
11 | const dataIn = yield `${cmd},${[motor1, motor2].join(',')}\r`;
12 | return yield* handleOKMessage(dataIn);
13 | },
14 | (params: string): [number, number] => {
15 | const [motor1, motor2] = params.split(',').map((p) => parseInt(p, 10));
16 | return [motor1, motor2];
17 | },
18 | {
19 | execution: EXECUTION_FIFO,
20 | },
21 | );
22 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/es.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { ENDING_OK_CR_NL } from '../constants';
3 | import {
4 | cmdWithOptionalParams,
5 | readUntil,
6 | toInt,
7 | transformResult,
8 | } from '../utils';
9 |
10 | export const cmd = 'ES';
11 |
12 | export default createCommand(
13 | cmd,
14 | 'E stop',
15 | function* (disableMotors?: number) {
16 | const dataIn = yield cmdWithOptionalParams(cmd, disableMotors);
17 | // example response: "0,0,0,0,0\n\rOK\r\n"
18 | const parsed = yield* readUntil(ENDING_OK_CR_NL, dataIn);
19 | return transformResult(parsed, (result) => {
20 | const values = result
21 | .substring(0, result.length - 6)
22 | .split(',')
23 | .map(toInt);
24 | return {
25 | interrupted: values[0],
26 | axis1: {
27 | steps: values[1],
28 | remain: values[3],
29 | },
30 | axis2: {
31 | steps: values[2],
32 | remain: values[4],
33 | },
34 | };
35 | });
36 | },
37 | (params: string): [number | undefined] => {
38 | const [n] = params.split(',').map(toInt);
39 | return [n];
40 | },
41 | {
42 | version: '2.2.7',
43 | },
44 | );
45 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/hm.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { EXECUTION_FIFO } from '../constants';
3 | import handleOKMessage from '../messages/ok';
4 | import { cmdWithOptionalParams } from '../utils';
5 |
6 | export const cmd = 'HM';
7 |
8 | export default createCommand(
9 | cmd,
10 | 'Home or absolute move',
11 | function* (stepFrequency: number, axis1?: number, axis2?: number) {
12 | const dataIn = yield cmdWithOptionalParams(
13 | `${cmd},${stepFrequency}`,
14 | axis1,
15 | axis2,
16 | );
17 | return yield* handleOKMessage(dataIn);
18 | },
19 | (params: string): [number, number | undefined, number | undefined] => {
20 | const [stepFrequency, axis1, axis2] = params
21 | .split(',')
22 | .map((p) => parseInt(p, 10));
23 | return [stepFrequency, axis1, axis2];
24 | },
25 | {
26 | execution: EXECUTION_FIFO,
27 | version: '2.6.2',
28 | },
29 | );
30 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/i.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { ENDING_CR_NL } from '../constants';
3 | import { noParameters, readUntil, toInt, transformResult } from '../utils';
4 |
5 | export const cmd = 'I';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Input (digital)',
10 | function* () {
11 | const dataIn = yield `${cmd}\r`;
12 | // example response: "I,PortA,PortB,PortC,PortD,PortE\r\n"
13 | const parsed = yield* readUntil(ENDING_CR_NL, dataIn);
14 | return transformResult(parsed, (result) =>
15 | result.trim().substring(2).split(',').map(toInt),
16 | );
17 | },
18 | noParameters,
19 | );
20 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/lm.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { EXECUTION_FIFO } from '../constants';
3 | import handleOKMessage from '../messages/ok';
4 | import { cmdWithOptionalParams } from '../utils';
5 |
6 | export const cmd = 'LM';
7 |
8 | export default createCommand(
9 | cmd,
10 | 'Low-level move, step-limited',
11 | function* (
12 | rate1: number,
13 | steps1: number,
14 | accel1: number,
15 | rate2: number,
16 | steps2: number,
17 | accel2: number,
18 | clear?: number,
19 | ) {
20 | const dataIn = yield cmdWithOptionalParams(
21 | `${cmd},${[rate1, steps1, accel1, rate2, steps2, accel2].join(',')}`,
22 | clear,
23 | );
24 | return yield* handleOKMessage(dataIn);
25 | },
26 | (
27 | params: string,
28 | ): [number, number, number, number, number, number, number] => {
29 | const [rate1, steps1, accel1, rate2, steps2, accel2, clear] = params
30 | .split(',')
31 | .map((p) => parseInt(p, 10));
32 | return [rate1, steps1, accel1, rate2, steps2, accel2, clear];
33 | },
34 | {
35 | execution: EXECUTION_FIFO,
36 | version: '2.7.0',
37 | },
38 | );
39 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/lt.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { EXECUTION_FIFO } from '../constants';
3 | import handleOKMessage from '../messages/ok';
4 | import { cmdWithOptionalParams } from '../utils';
5 |
6 | export const cmd = 'LT';
7 |
8 | export default createCommand(
9 | cmd,
10 | 'Low-level move, time-limited',
11 | function* (
12 | intervals: number,
13 | rate1: number,
14 | accel1: number,
15 | rate2: number,
16 | accel2: number,
17 | clear?: number,
18 | ) {
19 | const dataIn = yield cmdWithOptionalParams(
20 | `${cmd},${[intervals, rate1, accel1, rate2, accel2].join(',')}`,
21 | clear,
22 | );
23 | return yield* handleOKMessage(dataIn);
24 | },
25 | (params: string): [number, number, number, number, number, number] => {
26 | const [intervals, rate1, accel1, rate2, accel2, clear] = params
27 | .split(',')
28 | .map((p) => parseInt(p, 10));
29 | return [intervals, rate1, accel1, rate2, accel2, clear];
30 | },
31 | {
32 | execution: EXECUTION_FIFO,
33 | version: '2.7.0',
34 | },
35 | );
36 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/mr.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { ENDING_CR_NL } from '../constants';
3 | import { readUntil, toInt, transformResult } from '../utils';
4 |
5 | export const cmd = 'MR';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Memory Read',
10 | function* (address: number) {
11 | const dataIn = yield `${cmd},${address.toFixed(0)}\r`;
12 | // example response: "MR,071\r\n"
13 | const parsed = yield* readUntil(ENDING_CR_NL, dataIn);
14 | return transformResult(parsed, (result) => toInt(result.substring(3)));
15 | },
16 | (params: string): [number] => {
17 | return [toInt(params)];
18 | },
19 | );
20 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/mw.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 |
4 | export const cmd = 'MW';
5 |
6 | export default createCommand(
7 | cmd,
8 | 'Memory Write',
9 | function* (address: number, data: number) {
10 | const dataIn = yield `${cmd},${address.toFixed(0)},${data.toFixed(0)}\r`;
11 | return yield* handleOKMessage(dataIn);
12 | },
13 | (params: string): [number, number] => {
14 | const [address, data] = params.split(',').map((p) => parseInt(p, 10));
15 | return [address, data];
16 | },
17 | );
18 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/nd.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 | import { noParameters } from '../utils';
4 |
5 | export const cmd = 'ND';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Node count decrement',
10 | function* () {
11 | const dataIn = yield `ND\r`;
12 | return yield* handleOKMessage(dataIn);
13 | },
14 | noParameters,
15 | {
16 | version: '1.9.5',
17 | },
18 | );
19 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/ni.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 | import { noParameters } from '../utils';
4 |
5 | export const cmd = 'NI';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Node count increment',
10 | function* () {
11 | const dataIn = yield `NI\r`;
12 | return yield* handleOKMessage(dataIn);
13 | },
14 | noParameters,
15 | {
16 | version: '1.9.5',
17 | },
18 | );
19 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/o.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 | import { cmdWithOptionalParams } from '../utils';
4 |
5 | export const cmd = 'O';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Output (digital)',
10 | function* (
11 | portA: number,
12 | portB: number,
13 | portC: number,
14 | portD: number,
15 | portE: number,
16 | ) {
17 | const dataIn = yield cmdWithOptionalParams(
18 | `${cmd},${portA.toFixed(0)}`,
19 | portB,
20 | portC,
21 | portD,
22 | portE,
23 | );
24 | return yield* handleOKMessage(dataIn);
25 | },
26 | (params: string): [number, number, number, number, number] => {
27 | const [portA, portB, portC, portD, portE] = params
28 | .split(',')
29 | .map((p) => parseInt(p, 10));
30 | return [portA, portB, portC, portD, portE];
31 | },
32 | );
33 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/pc.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 | import { cmdWithOptionalParams } from '../utils';
4 |
5 | export const cmd = 'PC';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Pulse configure',
10 | function* (
11 | len0: number,
12 | period0: number,
13 | len1?: number,
14 | period1?: number,
15 | len2?: number,
16 | period2?: number,
17 | len3?: number,
18 | period3?: number,
19 | ) {
20 | const dataIn = yield cmdWithOptionalParams(
21 | `${cmd},${len0.toFixed(0)},${period0.toFixed(0)}`,
22 | len1,
23 | period1,
24 | len2,
25 | period2,
26 | len3,
27 | period3,
28 | );
29 | return yield* handleOKMessage(dataIn);
30 | },
31 | (
32 | params: string,
33 | ): [number, number, number, number, number, number, number, number] => {
34 | const [len0, period0, len1, period1, len2, period2, len3, period3] = params
35 | .split(',')
36 | .map((p) => parseInt(p, 10));
37 | return [len0, period0, len1, period1, len2, period2, len3, period3];
38 | },
39 | );
40 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/pd.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 |
4 | export const cmd = 'PD';
5 |
6 | export default createCommand(
7 | cmd,
8 | 'Pin direction',
9 | function* (port: string, pin: number, direction: number) {
10 | const dataIn =
11 | yield `${cmd},${port},${pin.toFixed(0)},${direction.toFixed(0)}\r`;
12 | return yield* handleOKMessage(dataIn);
13 | },
14 | (params: string): [string, number, number] => {
15 | const [port, pin, direction] = params.split(',');
16 | return [port, parseInt(pin, 10), parseInt(direction, 10)];
17 | },
18 | );
19 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/pg.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 |
4 | export const cmd = 'PG';
5 |
6 | export default createCommand(
7 | cmd,
8 | 'Pulse go',
9 | function* (enable: number) {
10 | const dataIn = yield `${cmd},${enable.toFixed(0)}\r`;
11 | return yield* handleOKMessage(dataIn);
12 | },
13 | (params: string): [number] => {
14 | const [enable] = params.split(',').map((p) => parseInt(p, 10));
15 | return [enable];
16 | },
17 | );
18 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/pi.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { ENDING_CR_NL } from '../constants';
3 | import { readUntil, toInt, transformResult } from '../utils';
4 |
5 | export const cmd = 'PI';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Pin input',
10 | function* (port: string, pin: number) {
11 | const dataIn = yield `${cmd},${port},${pin.toFixed(0)}\r`;
12 | // example response: "PI,1\r\n"
13 | const parsed = yield* readUntil(ENDING_CR_NL, dataIn);
14 | return transformResult(parsed, (result) => toInt(result.substring(3)));
15 | },
16 | (params: string): [string, number] => {
17 | const [port, pin] = params.split(',');
18 | return [port, toInt(pin)];
19 | },
20 | );
21 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/po.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 |
4 | export const cmd = 'PO';
5 |
6 | export default createCommand(
7 | cmd,
8 | 'Pin output',
9 | function* (port: string, pin: number, value: number) {
10 | const dataIn = yield `${cmd},${port},${pin},${value}\r`;
11 | return yield* handleOKMessage(dataIn);
12 | },
13 | (params: string): [string, number, number] => {
14 | const [port, pinStr, valueStr] = params.split(',');
15 | const pin = parseInt(pinStr, 10);
16 | const value = parseInt(valueStr, 10);
17 | return [port, pin, value];
18 | },
19 | );
20 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/qb.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { ENDING_OK_CR_NL } from '../constants';
3 | import { noParameters, readUntil, toInt, transformResult } from '../utils';
4 |
5 | export const cmd = 'QB';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Query button',
10 | function* () {
11 | const dataIn = yield `${cmd}\r`;
12 | // example response: "0\r\nOK\r\n"
13 | const parsed = yield* readUntil(ENDING_OK_CR_NL, dataIn);
14 | return transformResult(parsed, (result) => toInt(result));
15 | },
16 | noParameters,
17 | {
18 | version: '1.9.2',
19 | },
20 | );
21 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/qc.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { ENDING_OK_CR_NL } from '../constants';
3 | import { noParameters, readUntil, toInt, transformResult } from '../utils';
4 |
5 | export const cmd = 'QC';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Query current',
10 | function* () {
11 | const dataIn = yield `${cmd}\r`;
12 | // example response: "0394,0300\r\nOK\r\n"
13 | const parsed = yield* readUntil(ENDING_OK_CR_NL, dataIn);
14 | return transformResult(parsed, (result) => {
15 | const values = result
16 | .substring(0, result.length - 6) // discard \r\nOK\r\n
17 | .split(',')
18 | .map((v) => ((toInt(v) / 1023) * 3.3).toFixed(2));
19 | return {
20 | ra0: {
21 | voltage: values[0],
22 | maxCurrent: (toInt(values[0]) / 1.76).toFixed(2),
23 | },
24 | vPlus: {
25 | voltage: (toInt(values[1]) * 9.2 + 0.3).toFixed(2),
26 | },
27 | };
28 | });
29 | },
30 | noParameters,
31 | {
32 | version: '2.2.3',
33 | },
34 | );
35 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/qe.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { ENDING_OK_CR_NL } from '../constants';
3 | import { noParameters, readUntil, toInt, transformResult } from '../utils';
4 |
5 | export const cmd = 'QE';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Query motor enable',
10 | function* () {
11 | const dataIn = yield `QE\r`;
12 | // example response: "0,4\r\nOK\r\n"
13 | const parsed = yield* readUntil(ENDING_OK_CR_NL, dataIn);
14 | return transformResult(parsed, (result) => {
15 | return result
16 | .substring(0, result.length - 6)
17 | .split(',')
18 | .map(toInt);
19 | });
20 | },
21 | noParameters,
22 | {
23 | version: '2.8.0',
24 | },
25 | );
26 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/qg.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { ENDING_CR_NL } from '../constants';
3 | import { noParameters, readUntil, toInt, transformResult } from '../utils';
4 |
5 | export const cmd = 'QG';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Query general',
10 | function* () {
11 | const dataIn = yield `QG\r`;
12 | // example response: "3E\r\n"
13 | const parsed = yield* readUntil(ENDING_CR_NL, dataIn);
14 | return transformResult(parsed, (result) => {
15 | const results = toInt(result);
16 | return {
17 | fifo: (results & 1) > 0,
18 | mtr2: (results & 2) > 0,
19 | mtr1: (results & 4) > 0,
20 | cmd: (results & 8) > 0,
21 | pen: (results & 16) > 0,
22 | prg: (results & 32) > 0,
23 | rb2: (results & 64) > 0,
24 | rb5: (results & 128) > 0,
25 | };
26 | });
27 | },
28 | noParameters,
29 | {
30 | version: '2.6.2',
31 | },
32 | );
33 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/ql.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { ENDING_OK_CR_NL } from '../constants';
3 | import { noParameters, readUntil, toInt, transformResult } from '../utils';
4 |
5 | export const cmd = 'QL';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Query layer',
10 | function* () {
11 | const dataIn = yield `${cmd}\r`;
12 | // example response: "4\r\nOK\r\n"
13 | const parsed = yield* readUntil(ENDING_OK_CR_NL, dataIn);
14 | return transformResult(parsed, (result) => toInt(result));
15 | },
16 | noParameters,
17 | {
18 | version: '1.9.2',
19 | },
20 | );
21 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/qm.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { ENDING_NL_CR } from '../constants';
3 | import { noParameters, readUntil, toInt, transformResult } from '../utils';
4 |
5 | export const cmd = 'QM';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Query motors',
10 | function* () {
11 | const dataIn = yield `${cmd}\r`;
12 | // example response: "QM,0,0,0,0\n\r"
13 | // N.B. QM is the only command returning \n\r
14 | // https://github.com/evil-mad/EggBot/issues/159
15 | const parsed = yield* readUntil(ENDING_NL_CR, dataIn);
16 | return transformResult(parsed, (result) => {
17 | const results = result.trim().substring(3).split(',').map(toInt);
18 | return {
19 | command: results[0],
20 | motor1: results[1],
21 | motor2: results[2],
22 | fifo: results[3],
23 | };
24 | });
25 | },
26 | noParameters,
27 | {
28 | version: '2.4.4',
29 | },
30 | );
31 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/qn.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { ENDING_OK_CR_NL } from '../constants';
3 | import { noParameters, readUntil, toInt, transformResult } from '../utils';
4 |
5 | export const cmd = 'QN';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Query node count',
10 | function* () {
11 | const dataIn = yield `${cmd}\r`;
12 | // example response: "256\r\nOK\r\n"
13 | const parsed = yield* readUntil(ENDING_OK_CR_NL, dataIn);
14 | return transformResult(parsed, (result) => toInt(result));
15 | },
16 | noParameters,
17 | {
18 | version: '1.9.2',
19 | },
20 | );
21 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/qp.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { ENDING_OK_CR_NL } from '../constants';
3 | import { noParameters, readUntil, toInt, transformResult } from '../utils';
4 |
5 | export const cmd = 'QP';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Query pen',
10 | function* () {
11 | const dataIn = yield `${cmd}\r`;
12 | // example response: "1\n\rOK\r\n"
13 | const parsed = yield* readUntil(ENDING_OK_CR_NL, dataIn);
14 | return transformResult(parsed, (result) => toInt(result));
15 | },
16 | noParameters,
17 | {
18 | version: '1.9.0',
19 | },
20 | );
21 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/qr.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { ENDING_OK_CR_NL } from '../constants';
3 | import { noParameters, readUntil, toInt, transformResult } from '../utils';
4 |
5 | export const cmd = 'QR';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Query RC Servo power state',
10 | function* () {
11 | const dataIn = yield `${cmd}\r`;
12 | // example response: "1\n\rOK\r\n"
13 | const parsed = yield* readUntil(ENDING_OK_CR_NL, dataIn);
14 | return transformResult(parsed, (result) => toInt(result));
15 | },
16 | noParameters,
17 | {
18 | version: '2.6.0',
19 | },
20 | );
21 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/qs.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { ENDING_OK_CR_NL } from '../constants';
3 | import { noParameters, readUntil, toInt, transformResult } from '../utils';
4 |
5 | export const cmd = 'QS';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Query step position',
10 | function* () {
11 | const dataIn = yield `${cmd}\r`;
12 | // example response: "1024,-512\n\rOK\r\n"
13 | const parsed = yield* readUntil(ENDING_OK_CR_NL, dataIn);
14 | return transformResult(parsed, (result) => {
15 | const positions = result
16 | .substring(0, result.length - 6)
17 | .split(',')
18 | .map(toInt);
19 | return {
20 | a1: positions[0],
21 | a2: positions[1],
22 | };
23 | });
24 | },
25 | noParameters,
26 | {
27 | version: '2.4.3',
28 | },
29 | );
30 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/qt.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { ENDING_OK_CR_NL } from '../constants';
3 | import { noParameters, readUntil, transformResult } from '../utils';
4 |
5 | export const cmd = 'QT';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Query EBB nickname tag',
10 | function* () {
11 | const dataIn = yield `${cmd}\r`;
12 | // example response: "East EBB\r\nOK\r\n"
13 | const parsed = yield* readUntil(ENDING_OK_CR_NL, dataIn);
14 | return transformResult(parsed, (result) =>
15 | result.substring(0, result.length - 6),
16 | );
17 | },
18 | noParameters,
19 | {
20 | version: '2.5.4',
21 | },
22 | );
23 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/r.ts:
--------------------------------------------------------------------------------
1 | import { CommandGenerator, createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 | import { noParameters } from '../utils';
4 |
5 | export const cmd = 'R';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Reset',
10 | function* (): CommandGenerator {
11 | let dataIn = yield `${cmd}\r`;
12 | const parsed = yield* handleOKMessage(dataIn);
13 | // the R command would receive OK twice
14 | dataIn = yield parsed;
15 | return yield* handleOKMessage(dataIn);
16 | },
17 | noParameters,
18 | );
19 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/rb.ts:
--------------------------------------------------------------------------------
1 | import { CommandGenerator, createCommand } from '../command';
2 | import { decode, noParameters } from '../utils';
3 |
4 | export const cmd = 'RB';
5 |
6 | export default createCommand(
7 | cmd,
8 | 'Reboot',
9 | function* (): CommandGenerator {
10 | const dataIn = yield 'RB\r';
11 | // no data returned
12 | return {
13 | consumed: 0,
14 | remain: decode(dataIn),
15 | result: null,
16 | };
17 | },
18 | noParameters,
19 | {
20 | version: '2.5.4',
21 | },
22 | );
23 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/s2.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { EXECUTION_FIFO } from '../constants';
3 | import handleOKMessage from '../messages/ok';
4 | import { cmdWithOptionalParams } from '../utils';
5 |
6 | export const cmd = 'S2';
7 |
8 | export default createCommand(
9 | cmd,
10 | 'General RC Servo output',
11 | function* (
12 | position: number,
13 | outputPin: number,
14 | rate?: number,
15 | delay?: number,
16 | ) {
17 | const dataIn = yield cmdWithOptionalParams(
18 | `${cmd},${position.toFixed(0)},${outputPin.toFixed(0)}`,
19 | rate,
20 | delay,
21 | );
22 | return yield* handleOKMessage(dataIn);
23 | },
24 | (
25 | params: string,
26 | ): [number, number, number | undefined, number | undefined] => {
27 | const [position, outputPin, rate, delay] = params.split(',').map(Number);
28 | return [position, outputPin, rate, delay];
29 | },
30 | {
31 | execution: EXECUTION_FIFO,
32 | version: '2.2.0',
33 | },
34 | );
35 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/sc.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 |
4 | export const cmd = 'SC';
5 |
6 | export default createCommand(
7 | cmd,
8 | 'Stepper and Servo mode configure',
9 | function* (value1: number, value2: number) {
10 | const dataIn = yield `${cmd},${value1.toFixed(0)},${value2.toFixed(0)}\r`;
11 | return yield* handleOKMessage(dataIn);
12 | },
13 | (params: string): [number, number] => {
14 | const [value1, value2] = params.split(',').map((p) => parseInt(p, 10));
15 | return [value1, value2];
16 | },
17 | );
18 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/se.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { EXECUTION_FIFO } from '../constants';
3 | import handleOKMessage from '../messages/ok';
4 | import { cmdWithOptionalParams } from '../utils';
5 |
6 | export const cmd = 'SE';
7 |
8 | export default createCommand(
9 | cmd,
10 | 'Set engraver',
11 | function* (state: number, power?: number, useMotionQueue?: number) {
12 | const dataIn = yield cmdWithOptionalParams(
13 | `${cmd},${state.toFixed(0)}`,
14 | power,
15 | useMotionQueue,
16 | );
17 | return yield* handleOKMessage(dataIn);
18 | },
19 | (params: string): [number, number | undefined, number | undefined] => {
20 | const [state, power, useMotionQueue] = params.split(',').map(Number);
21 | return [state, power, useMotionQueue];
22 | },
23 | {
24 | // FIXME: this command only use FIFO queue when useMotionQueue is set to 1
25 | execution: EXECUTION_FIFO,
26 | version: '2.1.0',
27 | },
28 | );
29 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/sl.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 |
4 | export const cmd = 'SL';
5 |
6 | export default createCommand(
7 | cmd,
8 | 'Set layer',
9 | function* (layer: number) {
10 | const dataIn = yield `${cmd},${layer.toFixed(0)}\r`;
11 | return yield* handleOKMessage(dataIn);
12 | },
13 | (params: string): [number] => {
14 | return [parseInt(params, 10)];
15 | },
16 | {
17 | version: '1.9.2',
18 | },
19 | );
20 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/sm.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { EXECUTION_FIFO } from '../constants';
3 | import handleOKMessage from '../messages/ok';
4 | import { cmdWithOptionalParams } from '../utils';
5 |
6 | export const cmd = 'SM';
7 |
8 | export default createCommand(
9 | cmd,
10 | 'Stepper move',
11 | function* (duration: number, axis1: number, axis2?: number) {
12 | const dataIn = yield cmdWithOptionalParams(
13 | `${cmd},${duration.toFixed(0)},${axis1.toFixed(0)}`,
14 | axis2,
15 | );
16 | return yield* handleOKMessage(dataIn);
17 | },
18 | (params: string): [number, number, number | undefined] => {
19 | const [duration, axis1, axis2] = params.split(',').map(Number);
20 | return [duration, axis1, axis2];
21 | },
22 | {
23 | execution: EXECUTION_FIFO,
24 | },
25 | );
26 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/sn.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 |
4 | export const cmd = 'SN';
5 |
6 | export default createCommand(
7 | cmd,
8 | 'Set node count',
9 | function* (count: number) {
10 | const dataIn = yield `${cmd},${count.toFixed(0)}\r`;
11 | return yield* handleOKMessage(dataIn);
12 | },
13 | (params: string): [number] => {
14 | return [parseInt(params, 10)];
15 | },
16 | {
17 | version: '1.9.5',
18 | },
19 | );
20 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/sp.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { EXECUTION_FIFO } from '../constants';
3 | import handleOKMessage from '../messages/ok';
4 | import { cmdWithOptionalParams } from '../utils';
5 |
6 | export const cmd = 'SP';
7 |
8 | export default createCommand(
9 | cmd,
10 | 'Set pen state',
11 | function* (value: number, duration?: number, portBPin?: number) {
12 | const dataIn = yield cmdWithOptionalParams(
13 | `${cmd},${value.toFixed(0)}`,
14 | duration,
15 | portBPin,
16 | );
17 | return yield* handleOKMessage(dataIn);
18 | },
19 | (params: string): [number, number | undefined, number | undefined] => {
20 | const [value, duration, portBPin] = params.split(',').map(Number);
21 | return [value, duration, portBPin];
22 | },
23 | {
24 | execution: EXECUTION_FIFO,
25 | },
26 | );
27 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/sr.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 | import { cmdWithOptionalParams } from '../utils';
4 |
5 | export const cmd = 'SR';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Set RC Servo power timeout',
10 | function* (timeout: number, state?: number) {
11 | const dataIn = yield cmdWithOptionalParams(
12 | `${cmd},${timeout.toFixed(0)}`,
13 | state,
14 | );
15 | return yield* handleOKMessage(dataIn);
16 | },
17 | (params: string): [number, number | undefined] => {
18 | const [timeout, state] = params.split(',').map(Number);
19 | return [timeout, state];
20 | },
21 | {
22 | version: '2.6.0',
23 | },
24 | );
25 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/st.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 |
4 | export const cmd = 'ST';
5 |
6 | export default createCommand(
7 | cmd,
8 | 'Set EBB nickname tag',
9 | function* (tag: string) {
10 | const dataIn = yield `${cmd},${tag}\r`;
11 | return yield* handleOKMessage(dataIn);
12 | },
13 | (params: string): [string] => {
14 | return [params];
15 | },
16 | {
17 | version: '2.5.5',
18 | },
19 | );
20 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/t.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 |
4 | export const cmd = 'T';
5 |
6 | export default createCommand(
7 | cmd,
8 | 'Timed analog/digital read',
9 | function* (duration: number, mode: number) {
10 | const dataIn = yield `${cmd},${duration.toFixed(0)},${mode.toFixed(0)}\r`;
11 | return yield* handleOKMessage(dataIn);
12 | },
13 | (params: string): [number, number] => {
14 | const [duration, mode] = params.split(',').map(Number);
15 | return [duration, mode];
16 | },
17 | );
18 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/tp.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import handleOKMessage from '../messages/ok';
3 | import { cmdWithOptionalParams } from '../utils';
4 |
5 | export const cmd = 'TP';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Toggle pen',
10 | function* (duration?: number) {
11 | const dataIn = yield cmdWithOptionalParams(cmd, duration);
12 | return yield* handleOKMessage(dataIn);
13 | },
14 | (params: string): [number | undefined] => {
15 | const [duration] = params.split(',').map(Number);
16 | return [duration];
17 | },
18 | {
19 | version: '1.9.0',
20 | },
21 | );
22 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/v.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { ENDING_CR_NL } from '../constants';
3 | import { noParameters, readUntil, transformResult } from '../utils';
4 |
5 | export const cmd = 'V';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Version',
10 | function* () {
11 | const dataIn = yield `${cmd}\r`;
12 | // example response: "EBBv13_and_above EB Firmware Version 2.7.0\r\n"
13 | const parsed = yield* readUntil(ENDING_CR_NL, dataIn);
14 | return transformResult(parsed, (result) => result.trim());
15 | },
16 | noParameters,
17 | );
18 |
--------------------------------------------------------------------------------
/src/communication/ebb/commands/xm.ts:
--------------------------------------------------------------------------------
1 | import { createCommand } from '../command';
2 | import { EXECUTION_FIFO } from '../constants';
3 | import handleOKMessage from '../messages/ok';
4 |
5 | export const cmd = 'XM';
6 |
7 | export default createCommand(
8 | cmd,
9 | 'Stepper move, mixed-axis geometries',
10 | function* (duration: number, xSteps: number, ySteps: number) {
11 | const dataIn =
12 | yield `${cmd},${duration.toFixed(0)},${xSteps.toFixed(0)},${ySteps.toFixed(0)}\r`;
13 | return yield* handleOKMessage(dataIn);
14 | },
15 | (params: string): [number, number, number] => {
16 | const [duration, xSteps, ySteps] = params.split(',').map(Number);
17 | return [duration, xSteps, ySteps];
18 | },
19 | {
20 | execution: EXECUTION_FIFO,
21 | version: '2.3.0',
22 | },
23 | );
24 |
--------------------------------------------------------------------------------
/src/communication/ebb/constants.ts:
--------------------------------------------------------------------------------
1 | export const ENDING_CR = '\r';
2 | export const ENDING_CR_NL = '\r\n';
3 | export const ENDING_NL_CR = '\n\r';
4 | export const ENDING_OK_CR_NL = 'OK\r\n';
5 |
6 | export const EXECUTION_IMMEDIATE = 0;
7 | export const EXECUTION_FIFO = 1;
8 |
9 | export const HIGH_DPI_AA = 2870;
10 | export const HIGH_DPI_XY = 2029; // HIGH_DPI_AA / Sqrt(2)
11 |
--------------------------------------------------------------------------------
/src/communication/ebb/index.ts:
--------------------------------------------------------------------------------
1 | export { default as a } from './commands/a';
2 | export { default as ac } from './commands/ac';
3 | export { default as bl } from './commands/bl';
4 | export { default as c } from './commands/c';
5 | export { default as ck } from './commands/ck';
6 | export { default as cs } from './commands/cs';
7 | export { default as cu } from './commands/cu';
8 | export { default as em } from './commands/em';
9 | export { default as es } from './commands/es';
10 | export { default as hm } from './commands/hm';
11 | export { default as i } from './commands/i';
12 | export { default as lm } from './commands/lm';
13 | export { default as lt } from './commands/lt';
14 | export { default as mr } from './commands/mr';
15 | export { default as mw } from './commands/mw';
16 | export { default as nd } from './commands/nd';
17 | export { default as ni } from './commands/ni';
18 | export { default as o } from './commands/o';
19 | export { default as pc } from './commands/pc';
20 | export { default as pd } from './commands/pd';
21 | export { default as pg } from './commands/pg';
22 | export { default as pi } from './commands/pi';
23 | export { default as po } from './commands/po';
24 | export { default as qb } from './commands/qb';
25 | export { default as qc } from './commands/qc';
26 | export { default as qe } from './commands/qe';
27 | export { default as qg } from './commands/qg';
28 | export { default as ql } from './commands/ql';
29 | export { default as qm } from './commands/qm';
30 | export { default as qn } from './commands/qn';
31 | export { default as qp } from './commands/qp';
32 | export { default as qr } from './commands/qr';
33 | export { default as qs } from './commands/qs';
34 | export { default as qt } from './commands/qt';
35 | export { default as r } from './commands/r';
36 | export { default as rb } from './commands/rb';
37 | export { default as s2 } from './commands/s2';
38 | export { default as sc } from './commands/sc';
39 | export { default as se } from './commands/se';
40 | export { default as sl } from './commands/sl';
41 | export { default as sm } from './commands/sm';
42 | export { default as sn } from './commands/sn';
43 | export { default as sp } from './commands/sp';
44 | export { default as sr } from './commands/sr';
45 | export { default as st } from './commands/st';
46 | export { default as t } from './commands/t';
47 | export { default as tp } from './commands/tp';
48 | export { default as v } from './commands/v';
49 | export { default as xm } from './commands/xm';
50 |
--------------------------------------------------------------------------------
/src/communication/ebb/messages/ebb.ts:
--------------------------------------------------------------------------------
1 | import { PendingCommand } from '@/communication/device/utils';
2 | import { decode, logger } from '../utils';
3 | import handleErrorMessage from './error';
4 |
5 | export default function* handleEBBMessages(
6 | commandQueue: PendingCommand[],
7 | ): Generator {
8 | let buffer: number[] = [];
9 | let errorHandler = null;
10 | for (;;) {
11 | const dataIn = yield;
12 | buffer.push(...new Uint8Array(dataIn));
13 | // data arrived
14 | while (buffer.length) {
15 | if (commandQueue.length) {
16 | if (buffer[0] === '!'.charCodeAt(0)) {
17 | if (!errorHandler) {
18 | errorHandler = handleErrorMessage();
19 | errorHandler.next(); // set ready
20 | }
21 | }
22 | const cmd = commandQueue[0];
23 | const handler = (errorHandler || cmd.parser) as unknown as Generator<
24 | { consumed: number },
25 | { result: unknown; consumed: number },
26 | number[]
27 | >;
28 | // pass the current buffer to cmd,
29 | // and let it consume what it needs.
30 | const cmdStatus = handler.next(buffer);
31 | const { consumed } = cmdStatus.value;
32 | if (consumed) {
33 | buffer = buffer.slice(consumed);
34 | }
35 | if (cmdStatus.done) {
36 | const { result } = cmdStatus.value;
37 | if (handler === errorHandler) {
38 | errorHandler = null;
39 | cmd.reject(result as string);
40 | } else {
41 | logger.debug(`Received message: ${JSON.stringify(result)}`);
42 | cmd.resolve(result);
43 | }
44 | commandQueue.shift();
45 | }
46 | } else {
47 | const garbage = decode(buffer);
48 | logger.debug(`Discard garbage message: ${garbage}`);
49 | buffer.length = 0;
50 | }
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/communication/ebb/messages/error.ts:
--------------------------------------------------------------------------------
1 | import { CommandGenerator } from '../command';
2 | import { ENDING_CR } from '../constants';
3 | import { readUntil } from '../utils';
4 |
5 | export default function* handleErrorMessage(): CommandGenerator {
6 | let parsed: { consumed: number; remain: string; result?: string } = {
7 | consumed: 0,
8 | remain: '',
9 | };
10 | let result = '';
11 | do {
12 | const dataIn: number[] = yield parsed;
13 | // The error message may ends with \r\n or \n\r
14 | parsed = yield* readUntil(ENDING_CR, dataIn);
15 | result += (parsed as { result: string }).result;
16 | } while ((parsed as { remain: string }).remain[0] === '!');
17 | return {
18 | ...(parsed as { consumed: number; remain: string }),
19 | result,
20 | };
21 | }
22 |
--------------------------------------------------------------------------------
/src/communication/ebb/messages/ok.ts:
--------------------------------------------------------------------------------
1 | import { CommandGenerator } from '../command';
2 | import { ENDING_OK_CR_NL } from '../constants';
3 | import { readUntil, transformResult } from '../utils';
4 |
5 | export default function* handleOKMessage(
6 | dataIn: number[],
7 | ): CommandGenerator {
8 | const parsed = yield* readUntil(ENDING_OK_CR_NL, dataIn);
9 | return transformResult(parsed, (result) => result.trim());
10 | }
11 |
--------------------------------------------------------------------------------
/src/communication/ebb/utils.ts:
--------------------------------------------------------------------------------
1 | import Logger from 'js-logger';
2 |
3 | export const logger = Logger.get('ebb');
4 |
5 | export const toInt = (i: string) => parseInt(i, 10);
6 |
7 | const decoder = new TextDecoder();
8 | export const decode = (buffer: number[]) =>
9 | decoder.decode(Uint8Array.from(buffer));
10 |
11 | const encoder = new TextEncoder();
12 | export const encode = (msg: string) => encoder.encode(msg);
13 |
14 | export const cmdWithOptionalParams = (
15 | cmd: string,
16 | ...optional: (string | number | undefined)[]
17 | ) => {
18 | let cmdWithParams = cmd;
19 | for (const param of optional) {
20 | // ignore all following commands if it's undefined
21 | if (param === undefined) break;
22 | cmdWithParams += `,${param}`;
23 | }
24 | return `${cmdWithParams}\r`;
25 | };
26 |
27 | export const readUntil = function* (
28 | ending: string,
29 | dataIn: number[],
30 | ): Generator<
31 | { consumed: number },
32 | { result: string; consumed: number; remain: string },
33 | number[]
34 | > {
35 | let buffer = '';
36 | let foundEnding = -1;
37 | let consumed = 0;
38 | let nextDataIn = dataIn;
39 | do {
40 | const fragment = decode(nextDataIn);
41 | buffer += fragment;
42 | foundEnding = buffer.indexOf(ending);
43 | if (foundEnding !== -1) {
44 | foundEnding += ending.length;
45 | break;
46 | }
47 | nextDataIn = yield {
48 | consumed: fragment.length,
49 | };
50 | consumed += fragment.length;
51 |
52 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
53 | } while (foundEnding === -1);
54 | return {
55 | // discard any space at the beginning
56 | result: buffer.substring(0, foundEnding).trimStart(),
57 | consumed: foundEnding - consumed,
58 | remain: buffer.substring(foundEnding),
59 | };
60 | };
61 |
62 | export const transformResult = (
63 | parsed: { result: string; consumed: number; remain: string },
64 | fn: (result: string) => T,
65 | ): { result: T; consumed: number; remain: string } => {
66 | return {
67 | ...parsed,
68 | result: fn(parsed.result),
69 | };
70 | };
71 |
72 | export const checkVersion = (
73 | deviceVersion: string,
74 | cmdVersion: string,
75 | ): boolean => {
76 | const [dMajor, dMinor, dPatch] = deviceVersion.split('.');
77 | const [cMajor, cMinor, cPatch] = cmdVersion.split('.');
78 | if (cMajor < dMajor) return true;
79 | if (dMajor < cMajor) return false;
80 | // cMajor === dMajor
81 | if (cMinor < dMinor) return true;
82 | if (dMinor < cMinor) return false;
83 | // dMinor === dMinor
84 | return cPatch <= dPatch;
85 | };
86 |
87 | export const noParameters = (_: string): [] => [];
88 |
--------------------------------------------------------------------------------
/src/components/footer/footer.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | @apply text-sm text-center mb-8;
3 |
4 | color: hsl(0 0% 63.9%);
5 |
6 | p {
7 | @apply leading-6;
8 | }
9 |
10 | a {
11 | @apply underline;
12 | }
13 |
14 | svg {
15 | @apply fill-current;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/footer/footer.tsx:
--------------------------------------------------------------------------------
1 | import { version } from '../../../package.json';
2 | import styles from './footer.module.css';
3 |
4 | const Footer = () => {
5 | return (
6 |
32 | );
33 | };
34 |
35 | export default Footer;
36 |
--------------------------------------------------------------------------------
/src/components/loading/loading.module.css:
--------------------------------------------------------------------------------
1 | .loading {
2 | @apply block p-3;
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/loading/loading.tsx:
--------------------------------------------------------------------------------
1 | import styles from './loading.module.css';
2 |
3 | const Loading = () => {
4 | return Loading
;
5 | };
6 |
7 | export default Loading;
8 |
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import { cva, type VariantProps } from "class-variance-authority"
2 | import * as React from "react"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border px-4 py-3 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from "@radix-ui/react-slot"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 | import * as React from "react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/src/components/ui/form.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | @apply grid grid-cols-1 gap-4 lg:gap-6;
3 | }
4 |
5 | .checkboxLabel,
6 | .radioLabel {
7 | @apply inline-flex items-center;
8 |
9 | span {
10 | @apply ml-2;
11 | }
12 |
13 | input[disabled] {
14 | background-color: var(--muted);
15 | }
16 | }
17 |
18 | .inputLabel {
19 | @apply inline-flex flex-col items-stretch;
20 |
21 | span {
22 | @apply mb-2;
23 | }
24 |
25 | input[disabled],
26 | select[disabled],
27 | textarea[disabled] {
28 | background-color: var(--muted);
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/ui/sheet.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | @apply p-8 mx-auto mb-8 bg-white shadow-xl md:mt-8;
3 | @apply grid grid-cols-1 gap-6;
4 |
5 | @screen md {
6 | max-width: 600px;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Toast,
3 | ToastClose,
4 | ToastDescription,
5 | ToastProvider,
6 | ToastTitle,
7 | ToastViewport,
8 | } from "@/components/ui/toast"
9 | import { useToast } from "@/hooks/use-toast"
10 |
11 | export function Toaster() {
12 | const { toasts } = useToast()
13 |
14 | return (
15 |
16 | {toasts.map(function ({ id, title, description, action, ...props }) {
17 | return (
18 |
19 |
20 | {title && {title}}
21 | {description && (
22 | {description}
23 | )}
24 |
25 | {action}
26 |
27 |
28 | )
29 | })}
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/containers/app/app.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense, lazy } from 'react';
2 | import { Route, Routes } from 'react-router';
3 | import Loading from '@/components/loading/loading';
4 | import { Toaster } from '@/components/ui/toaster';
5 |
6 | const Plotter = lazy(() => import('../plotter/plotter'));
7 | const VirtualPlotter = lazy(() => import('../virtual/virtual'));
8 | const Debugger = lazy(() => import('../debugger/debugger'));
9 | const Composer = lazy(() => import('../composer/composer'));
10 |
11 | const App = () => {
12 | return (
13 | <>
14 | }>
15 |
16 | } />
17 | } />
18 | } />
19 | } />
20 |
21 |
22 |
23 | >
24 | );
25 | };
26 |
27 | export default App;
28 |
--------------------------------------------------------------------------------
/src/containers/composer/composer.tsx:
--------------------------------------------------------------------------------
1 | import classnames from 'clsx';
2 | import { useState } from 'react';
3 | import { IDeviceConnector } from '@/communication/device/device';
4 | import DeviceConnector from '@/components/device-connector/device-connector';
5 | import Footer from '@/components/footer/footer';
6 | import formStyles from '@/components/ui/form.module.css';
7 | import sheetsStyles from '@/components/ui/sheet.module.css';
8 | import MidiCommander from './components/midi-commander';
9 |
10 | const Composer = () => {
11 | const [device, setDevice] = useState | null>(null);
12 |
13 | return (
14 | <>
15 |
16 | { setDevice(null); }}
19 | />
20 | {device && }
21 |
22 |
23 | >
24 | );
25 | };
26 |
27 | export default Composer;
28 |
--------------------------------------------------------------------------------
/src/containers/composer/songs/index.ts:
--------------------------------------------------------------------------------
1 | export { default as twinkleTwinkleLittleStar } from './twinkle-twinkle-little-star';
2 | export { default as theWheelOnTheBus } from './the-wheels-on-the-bus';
3 |
--------------------------------------------------------------------------------
/src/containers/composer/songs/the-wheels-on-the-bus.ts:
--------------------------------------------------------------------------------
1 | import { createSong, flat } from '../utils';
2 |
3 | export default createSong(
4 | 'The wheels on the bus',
5 | flat([
6 | ['eG4'],
7 | ['eC5', 'sC5', 'sC5', 'eC5', 'eE5', 'eG5', 'eE5', 'qC5'],
8 | ['eD5', 'eD5', 'qD5', 'eB4', 'eA4', 'esG4', 'sG4'],
9 | ['eC5', 'sC5', 'sC5', 'eC5', 'eE5', 'eG5', 'eE5', 'qC5'],
10 | ['qD5', 'esG4', 'sG4', 'qeC5'],
11 | ]),
12 | [],
13 | );
14 |
--------------------------------------------------------------------------------
/src/containers/composer/songs/twinkle-twinkle-little-star.ts:
--------------------------------------------------------------------------------
1 | import { createSong, flat } from '../utils'
2 |
3 | export default createSong(
4 | 'Twinkle twinkle little star',
5 | flat([
6 | ['qC5', 'qC5', 'qG5', 'qG5', 'qA5', 'qA5', 'hG5'],
7 | ['qF5', 'qF5', 'qE5', 'qE5', 'qD5', 'qD5', 'hC5'],
8 | ['qG5', 'qG5', 'qF5', 'qF5', 'qE5', 'qE5', 'hD5'],
9 | ['qG5', 'qG5', 'qF5', 'qF5', 'qE5', 'qE5', 'hD5'],
10 | ['qC5', 'qC5', 'qG5', 'qG5', 'qA5', 'qA5', 'hG5'],
11 | ['qF5', 'qF5', 'qE5', 'qE5', 'qD5', 'qD5', 'hC5'],
12 | ]),
13 | flat([
14 | ['eC4', 'eG4', 'eE4', 'eG4', 'eC4', 'eG4', 'eE4', 'eG4', 'eC4', 'eA4', 'eD4', 'eA4', 'eC4', 'eG4', 'eE4', 'eG4',],
15 | ['eC4', 'eA4', 'eD4', 'eA4', 'eC4', 'eG4', 'eE4', 'eG4', 'eA3', 'eG4', 'eD4', 'eG4', 'eC4', 'eG4', 'eE4', 'eG4',],
16 | ['eC4', 'eG4', 'eE4', 'eG4', 'eC4', 'eA4', 'eF4', 'eA4', 'eC3', 'eG4', 'eE4', 'eG4', 'eA3', 'eG4', 'eD4', 'eG4',],
17 | ['eC4', 'eG4', 'eE4', 'eG4', 'eC4', 'eA4', 'eF4', 'eA4', 'eC3', 'eG4', 'eE4', 'eG4', 'eA3', 'eG4', 'eD4', 'eG4',],
18 | ['eC4', 'eG4', 'eE4', 'eG4', 'eC4', 'eG4', 'eE4', 'eG4', 'eC4', 'eA4', 'eD4', 'eA4', 'eC4', 'eG4', 'eE4', 'eG4',],
19 | ['eC4', 'eA4', 'eD4', 'eA4', 'eC4', 'eG4', 'eE4', 'eG4', 'eA3', 'eG4', 'eD4', 'eG4', 'eC4', 'eG4', 'eE4', 'eG4',],
20 | ]),
21 | );
22 |
--------------------------------------------------------------------------------
/src/containers/debugger/components/batch-commander.tsx:
--------------------------------------------------------------------------------
1 | import { FormEvent, useCallback, useState } from 'react';
2 | import { IDeviceConnector } from '@/communication/device/device';
3 | import * as commands from '@/communication/ebb';
4 | import { Command } from '@/communication/ebb/command';
5 | import { Button } from '@/components/ui/button';
6 | import formStyles from '@/components/ui/form.module.css';
7 | import { trackEvent } from '../utils';
8 |
9 | const BatchCommander = ({ device }: { device: IDeviceConnector }) => {
10 | const [batch, setBatch] = useState('');
11 | const [results, setResults] = useState('');
12 | const sendCommands = useCallback(
13 | (e: FormEvent) => {
14 | e.preventDefault();
15 | void (async () => {
16 | try {
17 | trackEvent('batch');
18 | const CMDs = batch.trim().split(/\s+/);
19 | let resultStr = '';
20 | for (const cmdWithParams of CMDs) {
21 | // skip comments
22 | if (cmdWithParams.startsWith('#')) continue;
23 | const parts = cmdWithParams.split(',');
24 | const maybeCmd = parts.shift();
25 | if (!maybeCmd) continue;
26 | const cmdId = maybeCmd as keyof typeof commands;
27 | const params = parts.join(',');
28 | // eslint-disable-next-line import/namespace
29 | const command = commands[cmdId] as Command;
30 | // there commands are not concurrent, their are executed one-by-one.
31 | const result = await device.executeCommand(
32 | command,
33 | command.parseParams(params),
34 | );
35 | resultStr += JSON.stringify(result);
36 | resultStr += '\n';
37 | }
38 | setResults(resultStr);
39 | } catch (err) {
40 | setResults(String(err));
41 | }
42 | });
43 | },
44 | [device, batch],
45 | );
46 | return (
47 |
66 | );
67 | };
68 |
69 | export default BatchCommander;
70 |
--------------------------------------------------------------------------------
/src/containers/debugger/components/fav-commander.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from 'react';
2 | import { IDeviceConnector } from '@/communication/device/device';
3 | import * as commands from '@/communication/ebb';
4 | import { Command, CommandWithParams } from '@/communication/ebb/command';
5 | import { Button } from '@/components/ui/button';
6 | import formStyles from '@/components/ui/form.module.css';
7 | import { trackEvent } from '../utils';
8 |
9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
10 | const frequentlyCommands: CommandWithParams>[] = [
11 | {
12 | cmd: commands.r,
13 | title: 'Reset',
14 | params: [],
15 | },
16 | {
17 | cmd: commands.v,
18 | title: 'Get version',
19 | params: [],
20 | },
21 | {
22 | cmd: commands.qb,
23 | title: 'Query button',
24 | params: [],
25 | },
26 | {
27 | cmd: commands.tp,
28 | title: 'Toggle pen',
29 | params: [],
30 | },
31 | {
32 | cmd: commands.sp,
33 | title: 'Pen up',
34 | params: [1],
35 | },
36 | {
37 | cmd: commands.sp,
38 | title: 'Pen down',
39 | params: [0],
40 | },
41 | {
42 | cmd: commands.em,
43 | title: 'Enable motor',
44 | params: [1, 1],
45 | },
46 | {
47 | cmd: commands.em,
48 | title: 'Disable motor',
49 | params: [0, 0],
50 | },
51 | ];
52 |
53 | const FavCommander = ({ device }: { device: IDeviceConnector }) => {
54 | const [result, setResult] = useState('');
55 | const sendCommand = useCallback(
56 | async (cmd: Command, params: T) => {
57 | trackEvent('fav', cmd.title);
58 | try {
59 | const cmdResult = await device.executeCommand(cmd, ...params);
60 | setResult(JSON.stringify(cmdResult));
61 | } catch (err) {
62 | setResult(String(err));
63 | }
64 | },
65 | [device],
66 | );
67 | return (
68 |
91 | );
92 | };
93 |
94 | export default FavCommander;
95 |
--------------------------------------------------------------------------------
/src/containers/debugger/components/simple-commander.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ChangeEvent,
3 | FormEvent,
4 | useCallback,
5 | useEffect,
6 | useState,
7 | } from 'react';
8 | import { IDeviceConnector } from '@/communication/device/device';
9 | import * as commands from '@/communication/ebb';
10 | import { Command } from '@/communication/ebb/command';
11 | import { Button } from '@/components/ui/button';
12 | import formStyles from '@/components/ui/form.module.css';
13 | import { trackEvent } from '../utils';
14 |
15 | type CommandsType = typeof commands;
16 | type CommandId = keyof CommandsType;
17 | const commandList: CommandId[] = Object.keys(commands) as CommandId[];
18 |
19 | const SimpleCommander = ({ device }: { device: IDeviceConnector }) => {
20 | const [cmd, setCmd] = useState('r');
21 | const [params, setParams] = useState('');
22 | const [paramsHistory, setParamsHistory] = useState>(
23 | {},
24 | );
25 | const [result, setResult] = useState('');
26 | useEffect(() => {
27 | setParams(paramsHistory[cmd] ?? '');
28 | }, [cmd, paramsHistory]);
29 | const sendCommand = useCallback(
30 | (e: FormEvent) => {
31 | e.preventDefault();
32 | void (async () => {
33 | trackEvent('simple', cmd);
34 | try {
35 | const paramsStr = params.trim();
36 | setParamsHistory({ ...paramsHistory, [cmd]: paramsStr });
37 | // eslint-disable-next-line import/namespace
38 | const command = commands[cmd] as Command;
39 | const cmdResult = await device.executeCommand(
40 | command,
41 | command.parseParams(paramsStr),
42 | );
43 | setResult(JSON.stringify(cmdResult));
44 | } catch (err) {
45 | setResult(String(err));
46 | }
47 | })();
48 | },
49 | [device, cmd, params, paramsHistory],
50 | );
51 | return (
52 |
100 | );
101 | };
102 |
103 | export default SimpleCommander;
104 |
--------------------------------------------------------------------------------
/src/containers/debugger/debugger.tsx:
--------------------------------------------------------------------------------
1 | import classnames from 'clsx';
2 | import { useState } from 'react';
3 | import { IDeviceConnector } from '@/communication/device/device';
4 | import DeviceConnector from '@/components/device-connector/device-connector';
5 | import formStyles from '@/components/ui/form.module.css';
6 | import sheetsStyles from '@/components/ui/sheet.module.css';
7 | import Footer from '../../components/footer/footer';
8 | import BatchCommander from './components/batch-commander';
9 | import FavCommander from './components/fav-commander';
10 | import SimpleCommander from './components/simple-commander';
11 |
12 | const Debugger = () => {
13 | const [device, setDevice] = useState | null>(null);
14 | return (
15 | <>
16 |
17 | {
20 | setDevice(null);
21 | }}
22 | />
23 | {device && }
24 | {device && }
25 | {device && }
26 |
27 |
28 | >
29 | );
30 | };
31 |
32 | export default Debugger;
33 |
--------------------------------------------------------------------------------
/src/containers/debugger/utils.ts:
--------------------------------------------------------------------------------
1 | import { trackCategoryEvent } from '@/analystic';
2 |
3 | export const trackEvent = trackCategoryEvent('debugger');
4 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/panels/esimating.tsx:
--------------------------------------------------------------------------------
1 | import { Timer } from 'lucide-react';
2 | import { observer } from 'mobx-react-lite';
3 | import { useContext, useEffect, useRef, useState } from 'react';
4 | import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
5 | import { formatTime } from '@/utils/time';
6 | import { PlotterContext } from '../../context';
7 |
8 | const Estimating = observer(() => {
9 | const { work, planning } = useContext(PlotterContext);
10 | const [time, setTime] = useState(
11 | planning.motions ? work.estimate({ motions: planning.motions }) : 0,
12 | );
13 | const timer = useRef();
14 | useEffect(() => {
15 | clearTimeout(timer.current);
16 | timer.current = setTimeout(() => {
17 | setTime(
18 | planning.motions ? work.estimate({ motions: planning.motions }) : 0,
19 | );
20 | }, 100);
21 | return () => {
22 | clearTimeout(timer.current);
23 | };
24 | });
25 | if (!time) return null;
26 | return (
27 |
28 |
29 |
30 | Time
31 |
32 | Estimated plotting time: {formatTime(time / 1000)}
33 |
34 |
35 |
36 | );
37 | });
38 |
39 | export default Estimating;
40 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/panels/panel.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | @apply absolute inset-0 px-8 space-y-8 overflow-y-scroll;
3 | @apply hidden;
4 |
5 | &::before,
6 | &::after {
7 | @apply block h-8;
8 |
9 | content: '';
10 | }
11 | }
12 |
13 | .active {
14 | @apply block;
15 | }
16 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/panels/panel.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'clsx';
2 | import styles from './panel.module.css';
3 |
4 | const Panel = ({
5 | active,
6 | children,
7 | ...props
8 | }: {
9 | active: boolean;
10 | children: React.ReactNode;
11 | }) => {
12 | return (
13 |
17 | {children}
18 |
19 | );
20 | };
21 |
22 | export default Panel;
23 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/panels/planning.module.css:
--------------------------------------------------------------------------------
1 | .form {
2 | @apply grid gap-4 items-center mb-4;
3 |
4 | grid-template-columns: max-content 1fr;
5 | }
6 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/panels/plotting.module.css:
--------------------------------------------------------------------------------
1 | .form {
2 | @apply grid gap-4 items-center mb-4;
3 |
4 | grid-template-columns: max-content 1fr;
5 | }
6 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/panels/setup.module.css:
--------------------------------------------------------------------------------
1 | .inputs {
2 | @apply grid gap-4 items-center mb-4;
3 |
4 | grid-template-columns: max-content 1fr;
5 | }
6 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/panels/simple-debugger.tsx:
--------------------------------------------------------------------------------
1 | import { Bug } from 'lucide-react';
2 | import { reaction } from 'mobx';
3 | import { useCallback, useContext, useEffect, useState } from 'react';
4 | import { IDeviceConnector } from '@/communication/device/device';
5 | import * as commands from '@/communication/ebb';
6 | import { Command, CommandWithParams } from '@/communication/ebb/command';
7 | import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
8 | import { Button } from '@/components/ui/button';
9 | import formStyles from '@/components/ui/form.module.css';
10 | import { PlotterContext } from '../../context';
11 | import { trackEvent } from '../../utils';
12 |
13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
14 | const frequentlyCommands: CommandWithParams>[] = [
15 | {
16 | cmd: commands.r,
17 | title: 'Reset',
18 | params: [],
19 | },
20 | {
21 | cmd: commands.em,
22 | title: 'Disable Motor',
23 | params: [0, 0],
24 | },
25 | {
26 | cmd: commands.tp,
27 | title: 'Toggle Pen',
28 | params: [],
29 | },
30 | {
31 | cmd: commands.sp,
32 | title: 'Pen Up',
33 | params: [1],
34 | },
35 | {
36 | cmd: commands.sp,
37 | title: 'Pen Down',
38 | params: [0],
39 | },
40 | ];
41 |
42 | const SimpleDebugger = ({ device }: { device: IDeviceConnector }) => {
43 | const { work } = useContext(PlotterContext);
44 | const [result, setResult] = useState('');
45 | const sendCommand = useCallback(
46 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
47 | async (cmd: Command, params: T) => {
48 | try {
49 | const cmdResult = await device.executeCommand(cmd, ...params);
50 | if (typeof cmdResult === 'object') {
51 | setResult(JSON.stringify(cmdResult));
52 | } else {
53 | setResult(String(cmdResult));
54 | }
55 | } catch (err) {
56 | setResult(String(err));
57 | }
58 | },
59 | [device],
60 | );
61 | useEffect(() => {
62 | const dispose = reaction(
63 | () => ({
64 | servoMin: work.servoMin.get(),
65 | servoMax: work.servoMax.get(),
66 | servoRate: work.servoRate.get(),
67 | }),
68 | ({ servoMin, servoMax, servoRate }) => {
69 | void (async () => {
70 | if (device.isConnected) {
71 | await device.executeCommand(commands.sc, 4, servoMin);
72 | await device.executeCommand(commands.sc, 5, servoMax);
73 | await device.executeCommand(commands.sc, 10, servoRate);
74 | }
75 | })();
76 | },
77 | );
78 | return () => {
79 | dispose();
80 | };
81 | }, [device, work.servoMax, work.servoMin, work.servoRate]);
82 |
83 | return (
84 |
116 | );
117 | };
118 |
119 | export default SimpleDebugger;
120 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/workspace/debug.module.css:
--------------------------------------------------------------------------------
1 | .internalNode {
2 | outline: 1px solid red;
3 | }
4 |
5 | .leafNode {
6 | outline: 1px solid green;
7 | }
8 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/workspace/debug.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from 'mobx-react-lite';
2 | import { mm2px } from '@/math/svg';
3 | import {
4 | DataNode,
5 | InternalEntry,
6 | InternalNode,
7 | LeafNode,
8 | } from '@/plotter/rtree';
9 | import styles from './debug.module.css';
10 |
11 | function RTLeafNode({ node }: { node: LeafNode }) {
12 | return (
13 |
14 | {node.entries.map((entry) => (
15 |
22 | ))}
23 |
24 | );
25 | }
26 |
27 | function RTInternalNode({
28 | node,
29 | }: {
30 | node: InternalNode;
31 | }) {
32 | return (
33 |
34 | {node.entries.map((subNode) => (
35 |
36 | ))}
37 |
38 | );
39 | }
40 |
41 | function RTNode({ node }: { node: InternalEntry }) {
42 | switch (node.type) {
43 | case 'rtree-type-node-internal':
44 | return ;
45 | case 'rtree-type-node-leaf':
46 | return ;
47 | default:
48 | return null;
49 | }
50 | }
51 |
52 | const DebugRtree = observer(function ({
53 | root,
54 | ...props
55 | }: {
56 | root: InternalEntry | null;
57 | [key: string]: unknown;
58 | }) {
59 | return {root && };
60 | });
61 |
62 | export default DebugRtree;
63 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/workspace/gizmo.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | fill: none;
3 | }
4 |
5 | .axis {
6 | stroke-width: 1mm;
7 | stroke-linejoin: round;
8 | stroke-linecap: round;
9 | }
10 |
11 | .x {
12 | stroke: #f00;
13 | }
14 |
15 | .y {
16 | stroke: #0f0;
17 | }
18 |
19 | .z {
20 | fill: #00f;
21 | }
22 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/workspace/gizmo.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'clsx';
2 | import { observer } from 'mobx-react-lite';
3 | import { useContext } from 'react';
4 | import { mm2px } from '@/math/svg';
5 | import { PlotterContext } from '../../context';
6 | import { PAGE_ORIENTATION_PORTRAIT } from '../../presenters/page';
7 | import styles from './gizmo.module.css';
8 |
9 | const Gizmo = observer(({ ...props }) => {
10 | const { page } = useContext(PlotterContext);
11 | return (
12 |
21 |
25 |
29 |
30 |
31 | );
32 | });
33 |
34 | export default Gizmo;
35 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/workspace/page.module.css:
--------------------------------------------------------------------------------
1 | .shadow {
2 | fill: rgba(0, 0, 0, 0.25);
3 | }
4 |
5 | .paper {
6 | fill: white;
7 |
8 | &.dropping {
9 | fill: #eef;
10 | }
11 | }
12 |
13 | .printable {
14 | fill: none;
15 | stroke: #b8b8b8;
16 | stroke-width: 0.4mm;
17 | stroke-dasharray: 5 5;
18 | }
19 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/workspace/page.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'clsx';
2 | import { observer } from 'mobx-react-lite';
3 | import { useContext, useState } from 'react';
4 | import { mm2px } from '@/math/svg';
5 | import { preventDefault } from '@/utils/dom-event';
6 | import { PlotterContext } from '../../context';
7 | import { PLANNING_PHASE } from '../../presenters/planning';
8 | import styles from './page.module.css';
9 |
10 | const Page = observer(({ ...props }) => {
11 | const { planning, page } = useContext(PlotterContext);
12 | const widthPx = mm2px(page.width);
13 | const heightPx = mm2px(page.height);
14 | const paddingPx = mm2px(page.padding);
15 | const [dropping, setDropping] = useState(false);
16 | return (
17 | {
20 | setDropping(planning.phase === PLANNING_PHASE.SETUP);
21 | })}
22 | onDragLeave={preventDefault(() => {
23 | setDropping(false);
24 | })}
25 | onDrop={preventDefault((e: DragEvent) => {
26 | if (planning.phase !== PLANNING_PHASE.SETUP) {
27 | return;
28 | }
29 | setDropping(false);
30 | if (!e.dataTransfer?.files.length) {
31 | return;
32 | }
33 | const svgFile = Array.from(e.dataTransfer.files).find(
34 | (file) => file.type === 'image/svg+xml',
35 | );
36 | if (!svgFile) {
37 | alert('Only SVG files are supported.');
38 | return;
39 | }
40 | planning.loadFromFile(svgFile);
41 | })}
42 | {...props}
43 | >
44 |
50 |
55 |
62 |
63 | );
64 | });
65 |
66 | Page.propTypes = {};
67 |
68 | export default Page;
69 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/workspace/planning.module.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --planning-stroke-width: 1;
3 | }
4 |
5 | .root {
6 | stroke-width: var(--planning-stroke-width);
7 | stroke-linecap: round;
8 | stroke-linejoin: round;
9 | fill: none;
10 | }
11 |
12 | .motionPenUp {
13 | stroke: #e2e2e2;
14 | }
15 |
16 | .motionPenDown {
17 | stroke: #333;
18 | }
19 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/workspace/planning.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'clsx';
2 | import { observer } from 'mobx-react-lite';
3 | import { useContext } from 'react';
4 | import { px2mm } from '@/math/svg';
5 | import { PlotterContext } from '../../context';
6 | import { PLANNING_PHASE } from '../../presenters/planning';
7 | import styles from './planning.module.css';
8 |
9 | const Planning = observer(
10 | ({ strokeWidth, ...props }: { strokeWidth: number }) => {
11 | const { planning, page } = useContext(PlotterContext);
12 | const scale = px2mm(1);
13 | return (
14 |
30 |
34 |
35 |
36 | );
37 | },
38 | );
39 |
40 | export default Planning;
41 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/workspace/setup.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | pointer-events: none;
3 |
4 | * {
5 | stroke: #333 !important;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/workspace/setup.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'clsx';
2 | import { observer } from 'mobx-react-lite';
3 | import { useContext, useLayoutEffect, useRef } from 'react';
4 | import { mm2px } from '@/math/svg';
5 | import clean from '@/plotter/cleaner';
6 | import svgToLines, { SVGContainer } from '@/plotter/svg/svg-to-lines';
7 | import { PlotterContext } from '../../context';
8 | import { PLANNING_PHASE } from '../../presenters/planning';
9 | import styles from './setup.module.css';
10 |
11 | const Setup = observer(({ ...props }) => {
12 | const { planning, page } = useContext(PlotterContext);
13 | const setupContainerRef = useRef(null);
14 | const paddingPx = mm2px(page.padding);
15 | const widthPx = mm2px(page.width);
16 | const heightPx = mm2px(page.height);
17 | const { contentFitPage, contentPreserveAspectRatio } = page;
18 | useLayoutEffect(() => {
19 | const container = setupContainerRef.current;
20 | const imported = container?.children[0] as SVGGraphicsElement | null;
21 | if (!imported) {
22 | return;
23 | }
24 | /* remove unsupported elements */
25 | if (!imported.hasAttribute('data-cleaned')) {
26 | const response = clean(imported);
27 | planning.updateFileInfo(response.counts);
28 | imported.setAttribute('data-cleaned', ''); // set to true with empty string
29 | }
30 | /* adjust svg dimension as per page setup */
31 | if (!imported.hasAttribute('data-original-viewBox')) {
32 | const originalViewBox = imported.getAttribute('viewBox');
33 | if (originalViewBox) {
34 | imported.setAttribute('data-original-viewBox', originalViewBox);
35 | }
36 | }
37 | imported.setAttribute('width', String(widthPx - paddingPx * 2));
38 | imported.setAttribute('height', String(heightPx - paddingPx * 2));
39 | imported.setAttribute('x', String(paddingPx));
40 | imported.setAttribute('y', String(paddingPx));
41 | imported.setAttribute('preserveAspectRatio', contentPreserveAspectRatio);
42 | const originalViewBox = imported.getAttribute('data-original-viewBox');
43 | const bbox = imported.getBBox();
44 | imported.setAttribute(
45 | 'viewBox',
46 | contentFitPage
47 | ? `${bbox.x} ${bbox.y} ${bbox.width} ${bbox.height}`
48 | : (originalViewBox ?? ''),
49 | );
50 | }, [
51 | page.size,
52 | page.padding,
53 | page.orientation,
54 | page.alignment.vertical,
55 | page.alignment.horizontal,
56 | page.contentFitPage,
57 | planning.svgContent,
58 | widthPx,
59 | paddingPx,
60 | heightPx,
61 | contentPreserveAspectRatio,
62 | contentFitPage,
63 | planning,
64 | ]);
65 | useLayoutEffect(() => {
66 | // when switch to planning phase, extract svg to lines.
67 | if (planning.phase === PLANNING_PHASE.PLANNING) {
68 | const container = setupContainerRef.current;
69 | const imported = container?.children[0] as SVGContainer | null;
70 | if (!imported) {
71 | return;
72 | }
73 | planning.updateLines(svgToLines(imported));
74 | }
75 | }, [planning, planning.phase]);
76 | return (
77 |
86 | );
87 | });
88 |
89 | export default Setup;
90 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/workspace/shadow-def.tsx:
--------------------------------------------------------------------------------
1 | import { mm2px } from '@/math/svg';
2 |
3 | const ShadowDef = ({ margin }: { margin: number }) => {
4 | const marginPx = mm2px(margin);
5 | return (
6 |
7 |
14 |
20 |
26 |
27 | );
28 | };
29 |
30 | export default ShadowDef;
31 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/workspace/workspace.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | @apply w-full;
3 |
4 | height: calc(100% - 70px);
5 | }
6 |
--------------------------------------------------------------------------------
/src/containers/plotter/components/workspace/workspace.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from 'mobx-react-lite';
2 | import { useContext, useLayoutEffect, useRef, useState } from 'react';
3 | import { mm2px, normalizedDiagonalLength } from '@/math/svg';
4 | import { PlotterContext } from '../../context';
5 | import Gizmo from './gizmo';
6 | import Page from './page';
7 | import Planning from './planning';
8 | import Setup from './setup';
9 | import ShadowDef from './shadow-def';
10 | import styles from './workspace.module.css';
11 |
12 | const Workspace = observer(({ margin = 20 }: { margin?: number }) => {
13 | const { page, planning } = useContext(PlotterContext);
14 | const marginPx = mm2px(margin);
15 | const widthPx = mm2px(page.width);
16 | const heightPx = mm2px(page.height);
17 | const viewBox = `${-marginPx} ${-marginPx} ${widthPx + marginPx * 2} ${
18 | heightPx + marginPx * 2
19 | }`;
20 | const svgRef = useRef(null);
21 | const [strokeWidth, setStrokeWidth] = useState(1);
22 | useLayoutEffect(() => {
23 | if (!svgRef.current) return;
24 | setStrokeWidth(
25 | (mm2px(planning.previewStrokeWidth) /
26 | normalizedDiagonalLength(svgRef.current.viewBox)) *
27 | 100,
28 | );
29 | }, [viewBox, planning.previewStrokeWidth]);
30 | return (
31 |
48 | );
49 | });
50 |
51 | export default Workspace;
52 |
--------------------------------------------------------------------------------
/src/containers/plotter/context.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from 'react';
2 | import svgPlaceholder from '@/assets/svg/axidraw-first.svg';
3 | import createPageSetup from './presenters/page';
4 | import createPlanning from './presenters/planning';
5 | import createWork from './presenters/work';
6 |
7 | export const page = createPageSetup();
8 | export const planning = createPlanning();
9 | export const work = createWork();
10 |
11 | if (import.meta.env.MODE === 'development') {
12 | setTimeout(() => {
13 | planning.loadFromUrl(svgPlaceholder);
14 | });
15 | }
16 |
17 | export const PlotterContext = createContext({ page, planning, work });
18 | PlotterContext.displayName = 'Plotter Context';
19 |
--------------------------------------------------------------------------------
/src/containers/plotter/plotter.module.css:
--------------------------------------------------------------------------------
1 | .workspace {
2 | @apply fixed inset-0;
3 |
4 | right: 450px;
5 | }
6 |
7 | .panel {
8 | @apply fixed inset-0 text-base;
9 |
10 | left: auto;
11 | width: 450px;
12 | background-color: #efefef;
13 | }
14 |
--------------------------------------------------------------------------------
/src/containers/plotter/plotter.tsx:
--------------------------------------------------------------------------------
1 | import Footer from '@/components/footer/footer';
2 | import Planning from './components/panels/planning';
3 | import Plotting from './components/panels/plotting';
4 | import Setup from './components/panels/setup';
5 | import Workspace from './components/workspace/workspace';
6 | import styles from './plotter.module.css';
7 |
8 | const Plotter = () => {
9 | return (
10 | <>
11 |
12 |
13 |
14 |
15 |
20 | >
21 | );
22 | };
23 |
24 | export default Plotter;
25 |
--------------------------------------------------------------------------------
/src/containers/plotter/presenters/planning.ts:
--------------------------------------------------------------------------------
1 | import { makeAutoObservable, runInAction } from 'mobx';
2 | import { Line2D } from '@/math/geom';
3 | import plan, { Motion, PlanOptions } from '@/plotter/planner';
4 | import { toSvgPathDef } from '@/plotter/svg/presentation';
5 |
6 | export enum PLANNING_PHASE {
7 | SETUP,
8 | PLANNING,
9 | PLOTTING,
10 | }
11 |
12 | const createPlanning = () =>
13 | makeAutoObservable(
14 | {
15 | // store loaded raw svg content
16 | svgContent: null as string | null,
17 | // store filename
18 | filename: null as string | null,
19 | // store detail of loaded svg
20 | fileInfo: null as Record | null,
21 | // store planned lines
22 | lines: null as Line2D[] | null,
23 | // store lines with pen status
24 | motions: null as Motion[] | null,
25 | previewStrokeWidth: 0.4, // mm
26 | setPreviewStrokeWidth(width: number) {
27 | this.previewStrokeWidth = width;
28 | },
29 | phase: PLANNING_PHASE.SETUP,
30 | setPhase(phase: PLANNING_PHASE) {
31 | this.phase = phase;
32 | },
33 | loadFromUrl(url: string) {
34 | void fetch(url)
35 | .then((resp) => resp.text())
36 | .then((svg: string) => {
37 | runInAction(() => {
38 | const filename = url.split('/').pop();
39 | this.loadSVGContent(filename!.split(/\.svg/i)[0], svg);
40 | });
41 | });
42 | },
43 | loadFromFile(file: File) {
44 | const reader = new FileReader();
45 | reader.onload = (ev: ProgressEvent) => {
46 | runInAction(() => {
47 | if (!ev.target) {
48 | throw new Error('FileReader target is null');
49 | }
50 | this.loadSVGContent(file.name, ev.target.result as string);
51 | });
52 | };
53 | reader.readAsText(file);
54 | },
55 | loadSVGContent(filename: string, content: string) {
56 | this.filename = filename.split(/\.svg/i)[0];
57 | this.svgContent = content;
58 | this.lines = null;
59 | this.motions = null;
60 | },
61 | updateFileInfo(fileInfo: Record) {
62 | this.fileInfo = fileInfo;
63 | },
64 | updateLines(lines: Line2D[]) {
65 | this.lines = lines;
66 | this.motions = null;
67 | },
68 | planMotion(
69 | options: Partial & Pick,
70 | ) {
71 | if (!this.lines?.length) {
72 | throw new Error('Lines are not ready');
73 | }
74 | this.motions = plan(this.lines, options);
75 | },
76 | get linesInfo(): { lines: number } {
77 | return { lines: this.lines?.length ?? 0 };
78 | },
79 | get motionsInfo(): { penDown: number; penUp: number } {
80 | const init = { penDown: 0, penUp: 0 };
81 | return (
82 | this.motions?.reduce((info, motion) => {
83 | info[motion.pen === 0 ? 'penDown' : 'penUp'] += 1;
84 | return info;
85 | }, init) ?? init
86 | );
87 | },
88 | get linesAsPathDef() {
89 | if (!this.motions?.length) return '';
90 | const penDownMotions = this.motions.filter(
91 | ({ pen }) => pen === 0,
92 | ) as (Motion & { pen: 0 })[];
93 | return toSvgPathDef(penDownMotions.map(({ line }) => line));
94 | },
95 | get connections() {
96 | if (!this.motions) return [];
97 | const penUpMotions = this.motions.filter(
98 | ({ pen }) => pen === 1,
99 | ) as (Motion & { pen: 1 })[];
100 | return penUpMotions.map((motion) => motion.line);
101 | },
102 | get connectionsAsPathDef() {
103 | return toSvgPathDef(this.connections);
104 | },
105 | },
106 | undefined,
107 | {
108 | name: 'axidraw-web-work',
109 | deep: false,
110 | },
111 | );
112 |
113 | export default createPlanning;
114 |
--------------------------------------------------------------------------------
/src/containers/plotter/utils.ts:
--------------------------------------------------------------------------------
1 | import { trackCategoryEvent } from '@/analystic';
2 |
3 | export const trackEvent = trackCategoryEvent('plotter');
4 |
--------------------------------------------------------------------------------
/src/containers/virtual/components/canvas.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | position: relative;
3 | width: 100%;
4 | height: 100%;
5 | }
6 |
7 | .canvas {
8 | position: absolute;
9 | width: 100%;
10 | height: 100%;
11 | object-fit: contain;
12 | }
13 |
--------------------------------------------------------------------------------
/src/containers/virtual/components/canvas.tsx:
--------------------------------------------------------------------------------
1 | import { reaction } from 'mobx';
2 | import { useEffect, useRef } from 'react';
3 | import { aaSteps2xyDist } from '@/math/ebb';
4 | import { IVirtualPlotter } from '../plotter';
5 | import styles from './canvas.module.css';
6 |
7 | const Canvas = ({
8 | vm,
9 | width,
10 | height,
11 | }: {
12 | vm: IVirtualPlotter;
13 | width: number;
14 | height: number;
15 | }) => {
16 | const canvasRef = useRef(null);
17 |
18 | useEffect(() => {
19 | if (!canvasRef.current) return;
20 | const vmCtx = vm.context;
21 | const canvasCtx = canvasRef.current.getContext('2d');
22 | if (!canvasCtx) {
23 | alert('Canvas not supported');
24 | return;
25 | }
26 | let prevX = 0;
27 | let prevY = 0;
28 |
29 | return reaction(
30 | () => [vmCtx.motor.a1, vmCtx.motor.a2],
31 | ([a1, a2]) => {
32 | const { x, y } = aaSteps2xyDist({ a1, a2 }, vmCtx.motor.m1);
33 | canvasCtx.beginPath();
34 | if (vmCtx.pen === 0) {
35 | canvasCtx.lineWidth = 7;
36 | canvasCtx.lineCap = 'round';
37 | canvasCtx.moveTo(prevX, prevY);
38 | canvasCtx.lineTo(x * 10, y * 10);
39 | canvasCtx.stroke();
40 | }
41 | prevX = x * 10;
42 | prevY = y * 10;
43 | },
44 | );
45 | }, [vm]);
46 | return (
47 |
56 | );
57 | };
58 |
59 | export default Canvas;
60 |
--------------------------------------------------------------------------------
/src/containers/virtual/components/pen-holder.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | position: absolute;
3 | top: 0;
4 | left: 0;
5 | }
6 |
7 | .dot {
8 | display: block;
9 | border-radius: 50%;
10 | background-color: red;
11 | width: 25px;
12 | height: 25px;
13 | transform: translate(-50%, -50%);
14 | transition: opacity ease-out 0.3s;
15 | }
16 |
17 | .dotUp {
18 | opacity: 0;
19 | }
20 |
21 | .carriage {
22 | position: absolute;
23 | top: 0;
24 | left: 0;
25 | width: 712px;
26 | height: 3322px;
27 | max-width: none;
28 | transform: translate(0, -100%);
29 | transition: opacity ease-out 0.5s;
30 | }
31 |
32 | .carriageHidden {
33 | opacity: 0;
34 | }
35 |
--------------------------------------------------------------------------------
/src/containers/virtual/components/pen-holder.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'clsx';
2 | import { reaction } from 'mobx';
3 | import { useEffect, useState } from 'react';
4 | import Carriage from '@/assets/svg/pen-holder.svg';
5 | import { aaSteps2xyDist } from '../../../math/ebb';
6 | import { IVirtualPlotter } from '../plotter';
7 | import styles from './pen-holder.module.css';
8 |
9 | const PenHolder = ({ vm }: { vm: IVirtualPlotter }) => {
10 | const [pos, setPos] = useState({ x: 0, y: 0 });
11 | const [pen, setPen] = useState(1);
12 | useEffect(() => {
13 | const vmCtx = vm.context;
14 | const posDisposer = reaction(
15 | () => [vmCtx.motor.a1, vmCtx.motor.a2],
16 | ([a1, a2]) => {
17 | const { x, y } = aaSteps2xyDist({ a1, a2 }, vmCtx.motor.m1);
18 | setPos({ x: x * 10, y: y * 10 });
19 | },
20 | );
21 | const penDisposer = reaction(
22 | () => vmCtx.pen,
23 | (penState) => {
24 | setPen(penState);
25 | },
26 | );
27 | return () => {
28 | posDisposer();
29 | penDisposer();
30 | };
31 | }, [vm]);
32 | return (
33 |
37 |
38 |

45 |
46 | );
47 | };
48 |
49 | export default PenHolder;
50 |
--------------------------------------------------------------------------------
/src/containers/virtual/plotter/command.ts:
--------------------------------------------------------------------------------
1 | import { VirtualPlotterContext } from '.';
2 |
3 | export type CommandGenerator = AsyncGenerator;
4 |
5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
6 | export type Command = {
7 | cmd: string;
8 | title: string;
9 | create: (context: VirtualPlotterContext, ...args: T) => CommandGenerator;
10 | };
11 |
12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
13 | export const CreateCommand = (
14 | cmd: string,
15 | title: string,
16 | create: (context: VirtualPlotterContext, ...args: T) => CommandGenerator,
17 | ): Command => {
18 | return {
19 | cmd,
20 | title,
21 | create,
22 | };
23 | };
24 |
--------------------------------------------------------------------------------
/src/containers/virtual/plotter/commands/em.ts:
--------------------------------------------------------------------------------
1 | import { runInAction } from 'mobx';
2 | import { ENDING_OK_CR_NL } from '@/communication/ebb/constants';
3 | import { VirtualPlotterContext } from '..';
4 | import { CommandGenerator, CreateCommand } from '../command';
5 |
6 | export default CreateCommand(
7 | 'EM',
8 | 'Enable motors',
9 | // eslint-disable-next-line @typescript-eslint/require-await
10 | async function* (
11 | context: VirtualPlotterContext,
12 | m1: number,
13 | m2: number,
14 | ): CommandGenerator {
15 | runInAction(() => {
16 | if (m1 === 0) {
17 | context.motor.m1 = 0;
18 | } else {
19 | context.motor.m1 = m1;
20 | context.motor.m2 = m1; // m2 will be set to whatever m1 set
21 | }
22 | if (m2 === 0) {
23 | context.motor.m2 = 0;
24 | }
25 | });
26 | yield ENDING_OK_CR_NL;
27 | return;
28 | },
29 | );
30 |
--------------------------------------------------------------------------------
/src/containers/virtual/plotter/commands/hm.ts:
--------------------------------------------------------------------------------
1 | import { runInAction } from 'mobx';
2 | import { ENDING_OK_CR_NL } from '@/communication/ebb/constants';
3 | import { VirtualPlotterContext } from '..';
4 | import { CreateCommand } from '../command';
5 | import { linearMotion } from '../utils';
6 |
7 | export default CreateCommand(
8 | 'HM',
9 | 'Home or absolute move',
10 | async function* (
11 | context: VirtualPlotterContext,
12 | stepFrequency: number,
13 | p1: number = 0,
14 | p2: number = 0,
15 | ) {
16 | const a1start = context.motor.a1;
17 | const a2start = context.motor.a2;
18 | const a1end = p1;
19 | const a2end = p2;
20 | yield ENDING_OK_CR_NL;
21 |
22 | if (context.mode === 'fast') {
23 | runInAction(() => {
24 | context.motor.a1 = a1end;
25 | context.motor.a2 = a2end;
26 | });
27 | return;
28 | }
29 |
30 | const dist = Math.hypot(a1end - a1start, a2end - a2start);
31 | const duration = (dist / stepFrequency) * 1000;
32 | await linearMotion(context, [a1end, a2end], duration);
33 | },
34 | );
35 |
--------------------------------------------------------------------------------
/src/containers/virtual/plotter/commands/lm.ts:
--------------------------------------------------------------------------------
1 | import { runInAction } from 'mobx';
2 | import { ENDING_OK_CR_NL } from '@/communication/ebb/constants';
3 | import { rate2s, rsa2t } from '@/math/ebb';
4 | import { VirtualPlotterContext } from '..';
5 | import { CreateCommand } from '../command';
6 | import { accelMotion } from '../utils';
7 |
8 | export default CreateCommand(
9 | 'LM',
10 | 'Low-level move, step-limited',
11 | async function* (
12 | context: VirtualPlotterContext,
13 | rate1: number = 0,
14 | step1: number = 0,
15 | accel1: number = 0,
16 | rate2: number = 0,
17 | step2: number = 0,
18 | accel2: number = 0,
19 | _clear: number = 0,
20 | ) {
21 | const d1 = rsa2t({ rate: rate1, step: Math.abs(step1), acc: accel1 }) || 0;
22 | const d2 = rsa2t({ rate: rate2, step: Math.abs(step2), acc: accel2 }) || 0;
23 | const duration = Math.max(d1, d2) * 1000;
24 | yield ENDING_OK_CR_NL;
25 | if (context.mode === 'fast') {
26 | runInAction(() => {
27 | context.motor.a1 += step1;
28 | context.motor.a2 += step2;
29 | });
30 | return;
31 | }
32 | await accelMotion(
33 | context,
34 | [step1, step2],
35 | [rate2s(rate1), rate2s(rate2)],
36 | [rate2s(accel1 * 25000), rate2s(accel2 * 25000)],
37 | duration,
38 | );
39 | },
40 | );
41 |
--------------------------------------------------------------------------------
/src/containers/virtual/plotter/commands/qb.ts:
--------------------------------------------------------------------------------
1 | import { ENDING_OK_CR_NL } from '@/communication/ebb/constants';
2 | import { VirtualPlotterContext } from '..';
3 | import { CreateCommand } from '../command';
4 |
5 | export const cmd = 'QB';
6 |
7 | export default CreateCommand(
8 | 'QB',
9 | 'Query button',
10 | // eslint-disable-next-line @typescript-eslint/require-await
11 | async function* (context: VirtualPlotterContext) {
12 | const previousPRG = context.PRG;
13 | context.PRG = 0;
14 | yield `${previousPRG}\r\n${ENDING_OK_CR_NL}`;
15 | return;
16 | },
17 | );
18 |
--------------------------------------------------------------------------------
/src/containers/virtual/plotter/commands/qs.ts:
--------------------------------------------------------------------------------
1 | import { ENDING_OK_CR_NL } from '@/communication/ebb/constants';
2 | import { CreateCommand } from '../command';
3 |
4 | export default CreateCommand(
5 | 'QS',
6 | 'Query step position',
7 | // eslint-disable-next-line @typescript-eslint/require-await
8 | async function* (context) {
9 | const { a1, a2 } = context.motor;
10 | yield `${a1},${a2}\n\r${ENDING_OK_CR_NL}`;
11 | return;
12 | },
13 | );
14 |
--------------------------------------------------------------------------------
/src/containers/virtual/plotter/commands/r.ts:
--------------------------------------------------------------------------------
1 | import { ENDING_OK_CR_NL } from '@/communication/ebb/constants';
2 | import { CreateCommand } from '../command';
3 |
4 | export default CreateCommand(
5 | 'R',
6 | 'Reset',
7 | // eslint-disable-next-line @typescript-eslint/require-await
8 | async function* () {
9 | yield ENDING_OK_CR_NL + ENDING_OK_CR_NL;
10 | return;
11 | },
12 | );
13 |
--------------------------------------------------------------------------------
/src/containers/virtual/plotter/commands/sc.ts:
--------------------------------------------------------------------------------
1 | import { runInAction } from 'mobx';
2 | import { ENDING_OK_CR_NL } from '@/communication/ebb/constants';
3 | import { VirtualPlotterContext } from '..';
4 | import { CreateCommand } from '../command';
5 |
6 | export default CreateCommand(
7 | 'SC',
8 | 'Stepper and Servo mode configure',
9 | // eslint-disable-next-line @typescript-eslint/require-await
10 | async function* (context: VirtualPlotterContext, key: number, value: number) {
11 | runInAction(() => {
12 | switch (key) {
13 | case 4:
14 | context.servo.min = value;
15 | break;
16 | case 5:
17 | context.servo.max = value;
18 | break;
19 | case 10:
20 | context.servo.rate = value;
21 | break;
22 | default:
23 | }
24 | });
25 | yield ENDING_OK_CR_NL;
26 | return;
27 | },
28 | );
29 |
--------------------------------------------------------------------------------
/src/containers/virtual/plotter/commands/sm.ts:
--------------------------------------------------------------------------------
1 | import { runInAction } from 'mobx';
2 | import { ENDING_OK_CR_NL } from '@/communication/ebb/constants';
3 | import { delay } from '@/utils/time';
4 | import { VirtualPlotterContext } from '..';
5 | import { CreateCommand } from '../command';
6 | import { linearMotion } from '../utils';
7 |
8 | export default CreateCommand(
9 | 'SM',
10 | 'Stepper move',
11 | async function* (
12 | context: VirtualPlotterContext,
13 | duration: number,
14 | delta1: number = 0,
15 | delta2: number = 0,
16 | ) {
17 | const a1start = context.motor.a1;
18 | const a2start = context.motor.a2;
19 | yield ENDING_OK_CR_NL;
20 |
21 | if (context.mode === 'fast') {
22 | runInAction(() => {
23 | context.motor.a1 += delta1;
24 | context.motor.a2 += delta2;
25 | });
26 | }
27 |
28 | if (delta1 === 0 && delta2 === 0) {
29 | await delay(duration / 1000);
30 | } else {
31 | await linearMotion(
32 | context,
33 | [a1start + delta1, a2start + delta2],
34 | duration,
35 | );
36 | }
37 | },
38 | );
39 |
--------------------------------------------------------------------------------
/src/containers/virtual/plotter/commands/sp.ts:
--------------------------------------------------------------------------------
1 | import { runInAction } from 'mobx';
2 | import { ENDING_OK_CR_NL } from '@/communication/ebb/constants';
3 | import { delay } from '@/utils/time';
4 | import { VirtualPlotterContext } from '..';
5 | import { CreateCommand } from '../command';
6 |
7 | export default CreateCommand(
8 | 'SP',
9 | 'Set pen state',
10 | async function* (
11 | context: VirtualPlotterContext,
12 | value: number,
13 | duration: number,
14 | ) {
15 | runInAction(() => {
16 | context.pen = value;
17 | });
18 | if (context.mode !== 'fast' && duration) {
19 | await delay(duration);
20 | }
21 | yield ENDING_OK_CR_NL;
22 | return;
23 | },
24 | );
25 |
--------------------------------------------------------------------------------
/src/containers/virtual/plotter/commands/sr.ts:
--------------------------------------------------------------------------------
1 | import { ENDING_OK_CR_NL } from '@/communication/ebb/constants';
2 | import { CreateCommand } from '../command';
3 |
4 | export default CreateCommand(
5 | 'SR',
6 | 'Set RC Servo power timeout',
7 | // eslint-disable-next-line @typescript-eslint/require-await
8 | async function* () {
9 | yield ENDING_OK_CR_NL;
10 | return;
11 | },
12 | );
13 |
--------------------------------------------------------------------------------
/src/containers/virtual/plotter/commands/tp.ts:
--------------------------------------------------------------------------------
1 | import { runInAction } from 'mobx';
2 | import { ENDING_OK_CR_NL } from '@/communication/ebb/constants';
3 | import { delay } from '@/utils/time';
4 | import { VirtualPlotterContext } from '..';
5 | import { CreateCommand } from '../command';
6 |
7 | export const cmd = 'TP';
8 |
9 | export default CreateCommand(
10 | 'TP',
11 | 'Toggle pen',
12 | async function* (context: VirtualPlotterContext, duration: number) {
13 | runInAction(() => {
14 | context.pen = 1 - context.pen;
15 | });
16 | if (context.mode !== 'fast' && duration) {
17 | await delay(duration);
18 | }
19 | yield ENDING_OK_CR_NL;
20 | return;
21 | },
22 | );
23 |
--------------------------------------------------------------------------------
/src/containers/virtual/plotter/commands/v.ts:
--------------------------------------------------------------------------------
1 | import { CreateCommand } from '../command';
2 |
3 | export default CreateCommand(
4 | 'V',
5 | 'Version',
6 | // eslint-disable-next-line @typescript-eslint/require-await
7 | async function* (context) {
8 | yield `EBBv13_and_above EB Firmware Version ${context.version}\r\n`;
9 | return;
10 | },
11 | );
12 |
--------------------------------------------------------------------------------
/src/containers/virtual/plotter/utils.ts:
--------------------------------------------------------------------------------
1 | import { runInAction } from 'mobx';
2 | import { Point2D, Vector2D } from '@/math/geom';
3 | import { delay } from '@/utils/time';
4 | import { VirtualPlotterContext } from '.';
5 |
6 | const interval = 16; // FPS = 60
7 |
8 | export async function linearMotion(
9 | context: VirtualPlotterContext,
10 | destination: Point2D,
11 | duration: number,
12 | ) {
13 | const a1start = context.motor.a1;
14 | const a2start = context.motor.a2;
15 | const [a1end, a2end] = destination;
16 | runInAction(() => {
17 | context.motor.f1 = (Math.abs(a1end - a1start) * 1000) / duration;
18 | context.motor.f2 = (Math.abs(a2end - a2start) * 1000) / duration;
19 | });
20 | let elapsed = 0;
21 | const steps = Math.ceil(duration / interval);
22 | for (let step = 0; step < steps; step += 1) {
23 | const s = Math.min((elapsed + interval) / duration, 1);
24 | const a1 = Math.round(a1start + s * (a1end - a1start));
25 | const a2 = Math.round(a2start + s * (a2end - a2start));
26 | runInAction(() => {
27 | context.motor.a1 = a1;
28 | context.motor.a2 = a2;
29 | });
30 | await delay(Math.min(duration - elapsed, interval));
31 | elapsed += interval;
32 | }
33 | runInAction(() => {
34 | context.motor.f1 = 0;
35 | context.motor.f2 = 0;
36 | });
37 | }
38 |
39 | export async function accelMotion(
40 | context: VirtualPlotterContext,
41 | dir: Vector2D,
42 | vel: Vector2D,
43 | accel: Vector2D,
44 | duration: number,
45 | ) {
46 | const a1start = context.motor.a1;
47 | const a2start = context.motor.a2;
48 | const [s1, s2] = dir;
49 | const [v1, v2] = vel;
50 | const [accel1, accel2] = accel;
51 | let elapsed = 0;
52 | const steps = Math.ceil(duration / interval);
53 | for (let step = 0; step < steps; step += 1) {
54 | const s = Math.min((elapsed + interval) / duration, 1);
55 | const t = (s * duration) / 1000;
56 | const d1 = Math.sign(s1) * Math.round(v1 * t + (accel1 * t * t) / 2);
57 | const d2 = Math.sign(s2) * Math.round(v2 * t + (accel2 * t * t) / 2);
58 | runInAction(() => {
59 | const { a1: a1o, a2: a2o } = context.motor;
60 | context.motor.a1 = a1start + d1;
61 | context.motor.a2 = a2start + d2;
62 | context.motor.f1 = (Math.abs(a1start + d1 - a1o) * 1000) / interval;
63 | context.motor.f2 = (Math.abs(a2start + d2 - a2o) * 1000) / interval;
64 | });
65 | await delay(Math.min(duration - elapsed, interval));
66 | elapsed += interval;
67 | }
68 | runInAction(() => {
69 | context.motor.a1 = a1start + s1;
70 | context.motor.a2 = a2start + s2;
71 | context.motor.f1 = 0;
72 | context.motor.f2 = 0;
73 | });
74 | }
75 |
--------------------------------------------------------------------------------
/src/containers/virtual/utils.ts:
--------------------------------------------------------------------------------
1 | import Logger from 'js-logger';
2 |
3 | export const logger = Logger.get('virtual');
4 |
--------------------------------------------------------------------------------
/src/containers/virtual/virtual.module.css:
--------------------------------------------------------------------------------
1 | .stage {
2 | position: fixed;
3 | top: 50px;
4 | bottom: 100px;
5 | width: 100%;
6 | }
7 |
8 | .frame {
9 | position: absolute;
10 | top: 0;
11 | left: 0;
12 | }
13 |
14 | .board {
15 | position: absolute;
16 | top: 0;
17 | left: 0;
18 | background: white;
19 | box-shadow: 0 0 50px 0 rgba(0, 0, 0, 0.25);
20 | }
21 |
22 | .plotter {
23 | position: absolute;
24 | top: 0;
25 | left: 0;
26 | }
27 |
28 | .footer {
29 | position: fixed;
30 | width: 100%;
31 | height: 100px;
32 | padding-top: 30px;
33 | bottom: 0;
34 | }
35 |
--------------------------------------------------------------------------------
/src/css/base.css:
--------------------------------------------------------------------------------
1 |
2 | @layer base {
3 | :root {
4 | --background: 0 0% 100%;
5 | --foreground: 222.2 84% 4.9%;
6 | --card: 0 0% 100%;
7 | --card-foreground: 222.2 84% 4.9%;
8 | --popover: 0 0% 100%;
9 | --popover-foreground: 222.2 84% 4.9%;
10 | --primary: 221.2 83.2% 53.3%;
11 | --primary-foreground: 210 40% 98%;
12 | --secondary: 210 40% 96.1%;
13 | --secondary-foreground: 222.2 47.4% 11.2%;
14 | --muted: 210 40% 96.1%;
15 | --muted-foreground: 215.4 16.3% 46.9%;
16 | --accent: 210 40% 96.1%;
17 | --accent-foreground: 222.2 47.4% 11.2%;
18 | --destructive: 0 84.2% 60.2%;
19 | --destructive-foreground: 210 40% 98%;
20 | --border: 214.3 31.8% 91.4%;
21 | --input: 214.3 31.8% 91.4%;
22 | --ring: 221.2 83.2% 53.3%;
23 | --radius: 0.5rem;
24 | --chart-1: 12 76% 61%;
25 | --chart-2: 173 58% 39%;
26 | --chart-3: 197 37% 24%;
27 | --chart-4: 43 74% 66%;
28 | --chart-5: 27 87% 67%;
29 | }
30 |
31 | .dark {
32 | --background: 222.2 84% 4.9%;
33 | --foreground: 210 40% 98%;
34 | --card: 222.2 84% 4.9%;
35 | --card-foreground: 210 40% 98%;
36 | --popover: 222.2 84% 4.9%;
37 | --popover-foreground: 210 40% 98%;
38 | --primary: 217.2 91.2% 59.8%;
39 | --primary-foreground: 222.2 47.4% 11.2%;
40 | --secondary: 217.2 32.6% 17.5%;
41 | --secondary-foreground: 210 40% 98%;
42 | --muted: 217.2 32.6% 17.5%;
43 | --muted-foreground: 215 20.2% 65.1%;
44 | --accent: 217.2 32.6% 17.5%;
45 | --accent-foreground: 210 40% 98%;
46 | --destructive: 0 62.8% 30.6%;
47 | --destructive-foreground: 210 40% 98%;
48 | --border: 217.2 32.6% 17.5%;
49 | --input: 217.2 32.6% 17.5%;
50 | --ring: 224.3 76.3% 48%;
51 | --chart-1: 220 70% 50%;
52 | --chart-2: 160 60% 45%;
53 | --chart-3: 30 80% 55%;
54 | --chart-4: 280 65% 60%;
55 | --chart-5: 340 75% 55%;
56 | }
57 | }
58 |
59 | @layer base {
60 | * {
61 | @apply border-border;
62 | }
63 | body {
64 | @apply overscroll-none min-h-screen;
65 | @apply font-roboto;
66 |
67 | background: linear-gradient(to left, #fff, #ece9e6);
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/css/fonts.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@700&family=Roboto:wght@400;700&display=swap');
2 |
--------------------------------------------------------------------------------
/src/css/preflight.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
--------------------------------------------------------------------------------
/src/css/typograph.css:
--------------------------------------------------------------------------------
1 | @layer base {
2 | h1 {
3 | font-size: 2em;
4 | }
5 |
6 | h2 {
7 | font-size: 1.5em;
8 | }
9 |
10 | h3 {
11 | font-size: 1.25em;
12 | }
13 |
14 | h4,
15 | h5,
16 | h6 {
17 | font-size: 1em;
18 | }
19 |
20 | h5,
21 | h6 {
22 | @apply font-normal;
23 | }
24 |
25 | h6 {
26 | @apply italic;
27 | }
28 |
29 | h1,
30 | h2,
31 | h3,
32 | h4,
33 | h5,
34 | h6 {
35 | @apply font-bold uppercase;
36 | @apply font-condensed
37 | }
38 |
39 | p {
40 | @apply leading-5;
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/hooks/device.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from 'react';
2 | import createDevice from '@/communication/device';
3 | import {
4 | DEVICE_EVENT_CONNECTED,
5 | DEVICE_EVENT_DISCONNECTED,
6 | DEVICE_TYPE_USB,
7 | DEVICE_TYPE_VIRTUAL,
8 | } from '@/communication/device/consts';
9 | import { IDeviceConnector } from '@/communication/device/device';
10 | import { selectFirstDevice } from '@/communication/device/utils';
11 | import * as commands from '@/communication/ebb';
12 |
13 | export const DEVICE_STATUS_CONNECTED = 'axidraw_web_device_status_connected';
14 | export const DEVICE_STATUS_DISCONNECTED =
15 | 'axidraw_web_device_status_disconnected';
16 |
17 | const supportedWebUSB = !!navigator.usb;
18 |
19 | export const useDeviceConnector = () => {
20 | const [deviceStatus, setDeviceStatus] = useState(DEVICE_STATUS_DISCONNECTED);
21 | const [deviceType, setDeviceType] = useState(
22 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
23 | supportedWebUSB ? DEVICE_TYPE_USB : DEVICE_TYPE_VIRTUAL,
24 | );
25 | const [deviceVersion, setDeviceVersion] = useState(null);
26 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
27 | const [device, setDevice] = useState | null>(null);
28 | const [connectionError, setConnectionError] = useState(null);
29 |
30 | // switch device as per device type
31 | useEffect(() => {
32 | // TODO: use a modal to select device from list
33 | if (deviceStatus !== DEVICE_STATUS_CONNECTED) {
34 | setDevice(createDevice(deviceType, selectFirstDevice));
35 | }
36 | }, [deviceType, deviceStatus]);
37 |
38 | // clear connection error
39 | useEffect(() => {
40 | setConnectionError(null);
41 | }, [device, deviceType, setDeviceStatus]);
42 |
43 | // auto disconnect previous device
44 | useEffect(
45 | () => () => {
46 | void device?.disconnectDevice();
47 | },
48 | [device],
49 | );
50 |
51 | // listen to connection event
52 | useEffect(() => {
53 | const onConnect = () => {
54 | setDeviceStatus(DEVICE_STATUS_CONNECTED);
55 | setDeviceVersion(device!.version);
56 | };
57 | const onDisconnect = (e: string) => {
58 | setDeviceStatus(DEVICE_STATUS_DISCONNECTED);
59 | setDeviceVersion('');
60 | if (e) {
61 | setConnectionError(e);
62 | }
63 | };
64 | device?.on(DEVICE_EVENT_CONNECTED, onConnect);
65 | device?.on(DEVICE_EVENT_DISCONNECTED, onDisconnect);
66 | return () => {
67 | device?.off(DEVICE_EVENT_CONNECTED, onConnect);
68 | device?.off(DEVICE_EVENT_DISCONNECTED, onDisconnect);
69 | };
70 | }, [device]);
71 |
72 | // connect device handle
73 | const connectDevice = useCallback(
74 | async (config: unknown) => {
75 | if (device && deviceStatus === DEVICE_STATUS_DISCONNECTED) {
76 | try {
77 | await device.connectDevice(config);
78 | } catch (e) {
79 | if (e instanceof Error) {
80 | setConnectionError(e.message);
81 | } else {
82 | setConnectionError(String(e));
83 | }
84 | await device.disconnectDevice();
85 | }
86 | } else if (!device) {
87 | setConnectionError('Device is not created yet.');
88 | } else {
89 | setConnectionError('Device is already connected yet.');
90 | }
91 | },
92 | [device, deviceStatus],
93 | );
94 |
95 | // disconnect device handle
96 | const disconnectDevice = useCallback(async () => {
97 | if (device && deviceStatus === DEVICE_STATUS_CONNECTED) {
98 | try {
99 | await device.executeCommand(commands.r);
100 | await device.disconnectDevice();
101 | } catch (e) {
102 | setConnectionError(String(e));
103 | }
104 | } else {
105 | setConnectionError('Device is not connected yet.');
106 | }
107 | }, [device, deviceStatus]);
108 |
109 | return {
110 | device,
111 | deviceType,
112 | setDeviceType,
113 | deviceStatus,
114 | deviceVersion,
115 | connectionError,
116 | connectDevice,
117 | disconnectDevice,
118 | };
119 | };
120 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import './css/preflight.css';
2 | @import './css/base.css';
3 | @import './css/fonts.css';
4 | @import './css/typograph.css';
5 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | // please keep this file for shadcn ui
2 | export { cn } from '@/utils/style';
3 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import { HashRouter } from 'react-router';
4 | import App from './containers/app/app';
5 | import '@/utils/logger';
6 | import './index.css';
7 |
8 | createRoot(document.getElementById('root')!).render(
9 |
10 |
11 |
12 |
13 | ,
14 | );
15 |
--------------------------------------------------------------------------------
/src/math/ebb.ts:
--------------------------------------------------------------------------------
1 | import { HIGH_DPI_XY } from '@/communication/ebb/constants';
2 |
3 | /**
4 | * This function transform x,y coordination to two-axis coordination with matrix
5 | * | 1, 1 |
6 | * | 1, -1 |
7 | * @param x
8 | * @param y
9 | * @returns {{a1: number, a2: number}}
10 | */
11 | export const xy2aa = ({ x, y }: { x: number; y: number }) => ({
12 | a1: x + y,
13 | a2: x - y,
14 | });
15 |
16 | /**
17 | * This function transform two-axis coordination to xy coordination with reverse matrix of
18 | * | 1, 1 |
19 | * | 1, -1 |
20 | * reverse:
21 | * | 1/2, 1/2 |
22 | * | 1/2, -1/2 |
23 | * @param a1
24 | * @param a2
25 | * @returns {{x: number, y: number}}
26 | */
27 | export const aa2xy = ({ a1, a2 }: { a1: number; a2: number }) => ({
28 | x: (a1 + a2) / 2,
29 | y: (a1 - a2) / 2,
30 | });
31 |
32 | // step to rate, accumulator: 2**31 rate = 1 step
33 | export const s2rate = (hz: number) => ((2 ** 31 / 25000) * Math.abs(hz)) | 0;
34 |
35 | // rate to step, accumulator: 2**31 rate = 1 step
36 | export const rate2s = (rate: number) => ((rate / 2 ** 31) * 25000) | 0;
37 |
38 | // time to intervals: 25000 intervals per second
39 | export const t2interval = (t: number) => t * 25000;
40 |
41 | // intervals to time: 25000 intervals per second
42 | export const interval2t = (interval: number) => interval / 25000;
43 |
44 | /**
45 | * Calculate time to move from rate, step and acc for LM command
46 | * by solving s = vt + 1/2 * at^2 for t
47 | * @param rate added to accumulator each 40 μs, 2**31 rate = 1 step
48 | * @param step steps to move on axis
49 | * @param acc the acceleration for rate per 40 μs
50 | * @returns {null|number}
51 | */
52 | export const rsa2t = ({
53 | rate,
54 | step,
55 | acc,
56 | }: {
57 | rate: number;
58 | step: number;
59 | acc: number;
60 | }) => {
61 | const S = step;
62 | const V = rate2s(rate);
63 | const A = rate2s(acc * 25000);
64 | if (A === 0) {
65 | if (V === 0) return null;
66 | return S / V;
67 | }
68 | const delta = 2 * A * S + V * V;
69 | if (delta < 0) return null;
70 | const t0 = (Math.sqrt(delta) - V) / A;
71 | if (Math.abs(delta) < 0.001) return t0;
72 | const t1 = -(Math.sqrt(delta) + V) / A;
73 | const [s1, s2] = t0 < t1 ? [t0, t1] : [t1, t0];
74 | return s1 < 0 ? s2 : s1;
75 | };
76 |
77 | /**
78 | * Calculator steps to move from interval, rate and acc for LT command
79 | * by solving s = vt + 1/2 * at^2
80 | * @param interval the amount of 40 μs
81 | * @param rate added to accumulator each 40 μs, 2**31 rate = 1 step
82 | * @param acc the acceleration for rate per 40 μs
83 | */
84 | export const ira2s = ({
85 | interval,
86 | rate,
87 | acc,
88 | }: {
89 | interval: number;
90 | rate: number;
91 | acc: number;
92 | }) => {
93 | const T = interval2t(interval);
94 | const V = rate2s(rate);
95 | const A = rate2s(acc * 25000);
96 | return (V * T + (A * T * T) / 2) | 0;
97 | };
98 |
99 | export const mm2steps = (mm: number, mode: number = 1) => {
100 | const stepsPerMm = HIGH_DPI_XY / 2 ** (mode - 1) / 25.4;
101 | return (stepsPerMm * mm) | 0;
102 | };
103 |
104 | export const steps2mm = (steps: number, mode: number) => {
105 | const stepsPerMm = HIGH_DPI_XY / 2 ** (mode - 1) / 25.4;
106 | return steps / stepsPerMm;
107 | };
108 |
109 | /**
110 | * calculator axis steps {a1, a2} from {x, y} coordinate
111 | * @param x x component in mm
112 | * @param y y component in mm
113 | * @param mode the motor mode
114 | * @returns {{a1: number, a2: number}}
115 | */
116 | export const xyDist2aaSteps = (
117 | { x, y }: { x: number; y: number },
118 | mode: number = 1,
119 | ) => {
120 | const xSteps = mm2steps(x, mode);
121 | const ySteps = mm2steps(y, mode);
122 | return xy2aa({ x: xSteps, y: ySteps });
123 | };
124 |
125 | export const aaSteps2xyDist = (
126 | { a1, a2 }: { a1: number; a2: number },
127 | mode: number = 1,
128 | ) => {
129 | const a1mm = steps2mm(a1, mode);
130 | const a2mm = steps2mm(a2, mode);
131 | return aa2xy({ a1: a1mm, a2: a2mm });
132 | };
133 |
134 | export const aaStepsToLMParams = (
135 | { a1, a2 }: { a1: number; a2: number },
136 | t: number,
137 | clear: number = 3,
138 | ) => {
139 | return [s2rate(a1 / t), a1, 0, s2rate(a2 / t), a2, clear];
140 | };
141 |
142 | // servo reaction time in ms
143 | export const servoTime = (min: number, max: number, rate: number) =>
144 | ((Math.abs(min - max) * 0.024) / rate) * 1000;
145 |
--------------------------------------------------------------------------------
/src/math/geom.ts:
--------------------------------------------------------------------------------
1 | export type Point2D = [number, number];
2 | export type Vector2D = Point2D;
3 | export type Line2D = [Point2D, Point2D];
4 |
5 | export const dist = ([x0, y0]: Point2D, [x1, y1]: Point2D) =>
6 | Math.sqrt((x0 - x1) ** 2 + (y0 - y1) ** 2);
7 |
8 | export const distSq = ([x0, y0]: Point2D, [x1, y1]: Point2D) =>
9 | (x0 - x1) ** 2 + (y0 - y1) ** 2;
10 |
11 | export const isSamePoint = (
12 | [x0, y0]: Point2D,
13 | [x1, y1]: Point2D,
14 | epsilon = 0.0001,
15 | ) => Math.abs(x0 - x1) < epsilon && Math.abs(y0 - y1) < epsilon;
16 |
17 | export const pointToLineDist = (
18 | [px, py]: Point2D,
19 | [x0, y0]: Point2D,
20 | [x1, y1]: Point2D,
21 | ) =>
22 | Math.abs(px * (y1 - y0) - py * (x1 - x0) + x1 * y0 - x0 * y1) /
23 | Math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2);
24 |
--------------------------------------------------------------------------------
/src/math/svg.ts:
--------------------------------------------------------------------------------
1 | export const PPI = 96;
2 |
3 | export const mm2px = (mm: number) => (PPI / 25.4) * mm;
4 | export const px2mm = (px: number) => (px / PPI) * 25.4;
5 |
6 | export const normalizedDiagonalLength = (viewBox: {
7 | baseVal: { width: number; height: number };
8 | }) => {
9 | const { width, height } = viewBox.baseVal;
10 | return Math.sqrt(width ** 2 + height ** 2) / Math.sqrt(2);
11 | };
12 |
--------------------------------------------------------------------------------
/src/plotter/cleaner.ts:
--------------------------------------------------------------------------------
1 | import { logger } from './utils';
2 |
3 | export function* walkSvg(
4 | svg: SVGElement,
5 | ): Generator<{ action: 'count' | 'discard'; el: SVGElement; type: string }> {
6 | for (const child of svg.children) {
7 | const svgEl = child as SVGElement;
8 | const type = svgEl.nodeName;
9 | switch (type) {
10 | case 'svg':
11 | case 'g':
12 | case 'a':
13 | yield* walkSvg(svgEl);
14 | break;
15 | case 'rect':
16 | case 'circle':
17 | case 'ellipse':
18 | case 'line':
19 | case 'polyline':
20 | case 'polygon':
21 | case 'path':
22 | // keep them;
23 | yield { action: 'count', el: svgEl, type };
24 | break;
25 | default:
26 | yield { action: 'discard', el: svgEl, type };
27 | }
28 | }
29 | }
30 |
31 | export default function clean(svg: SVGElement) {
32 | const counts: Record = {};
33 | const toDiscard: SVGElement[] = [];
34 | for (const node of walkSvg(svg)) {
35 | switch (node.action) {
36 | case 'count':
37 | counts[node.type] = (counts[node.type] || 0) + 1;
38 | break;
39 | case 'discard':
40 | default:
41 | counts[node.action] = (counts[node.action] || 0) + 1;
42 | logger.debug(`Unsupported element type: ${node.type}`);
43 | toDiscard.push(node.el);
44 | }
45 | }
46 | for (const el of toDiscard) {
47 | el.remove();
48 | }
49 | return { counts };
50 | }
51 |
--------------------------------------------------------------------------------
/src/plotter/consts.ts:
--------------------------------------------------------------------------------
1 | export const PLOTTER_STATUS_STANDBY = 'axidraw-web-plotter-status-standby';
2 | export const PLOTTER_STATUS_PAUSED = 'axidraw-web-plotter-status-paused';
3 | export const PLOTTER_STATUS_PLOTTING = 'axidraw-web-plotter-status-plotting';
4 |
5 | export enum PLOTTER_ACTION {
6 | PAUSE,
7 | RESUME,
8 | STOP,
9 | }
10 |
11 | export enum PLOTTER_SPEED_MODE {
12 | CONSTANT,
13 | ACCELERATING,
14 | }
15 |
16 | export const MOTION_PEN_UP = 1;
17 | export const MOTION_PEN_DOWN = 0;
18 |
--------------------------------------------------------------------------------
/src/plotter/motion/const-velocity.ts:
--------------------------------------------------------------------------------
1 | export function* slopeSegments({
2 | t,
3 | stepLong,
4 | stepShort,
5 | }: {
6 | t: number;
7 | stepLong: number;
8 | stepShort: number;
9 | }) {
10 | const absStepShort = Math.abs(stepShort);
11 | const maxTimeShort = absStepShort * 1310;
12 | const stepShortDir = Math.sign(stepShort);
13 | const flatT = Math.floor((t - maxTimeShort) / (absStepShort + 1));
14 | const stepRateLong = stepLong / t;
15 | const flatStepLong = Math.floor(stepRateLong * flatT);
16 | const slopeStopLong = Math.floor(stepRateLong * 1310);
17 | let remainingStepLong = stepLong;
18 | /**
19 | * | ____
20 | * | ___/
21 | * |___/_________
22 | */
23 | for (let i = 0, segments = 2 * absStepShort + 1; i < segments; i += 1) {
24 | if (i % 2 === 0) {
25 | // flat segment
26 | if (flatT > 0) {
27 | remainingStepLong -= flatStepLong;
28 | yield {
29 | time: flatT,
30 | longStep: flatStepLong,
31 | shortStep: 0,
32 | remaining: remainingStepLong,
33 | };
34 | }
35 | } else {
36 | // slope segment
37 | remainingStepLong -= slopeStopLong;
38 | yield {
39 | time: 1310,
40 | longStep: slopeStopLong,
41 | shortStep: stepShortDir,
42 | remaining: remainingStepLong,
43 | };
44 | }
45 | }
46 | // there might be remain step due to Math.floor
47 | }
48 |
--------------------------------------------------------------------------------
/src/plotter/rtree/utils.ts:
--------------------------------------------------------------------------------
1 | import { Point2D } from '@/math/geom';
2 | import { DataNode, InternalEntry } from '.';
3 |
4 | export type MBR = { p0: Point2D; p1: Point2D };
5 |
6 | export function pointAsMbr(p: Point2D): MBR {
7 | return { p0: [p[0], p[1]], p1: [p[0], p[1]] };
8 | }
9 |
10 | export function formatMbr({ p0: [x0, y0], p1: [x1, y1] }: MBR): string {
11 | return `(${x0}, ${y0}), (${x1}, ${y1})`;
12 | }
13 |
14 | export function extendMbr(mbr0: MBR, mbr1: MBR): MBR {
15 | const {
16 | p0: [np0x, np0y],
17 | p1: [np1x, np1y],
18 | } = mbr0;
19 | const {
20 | p0: [ep0x, ep0y],
21 | p1: [ep1x, ep1y],
22 | } = mbr1;
23 | const p0x = Math.min(np0x, ep0x);
24 | const p0y = Math.min(np0y, ep0y);
25 | const p1x = Math.max(np1x, ep1x);
26 | const p1y = Math.max(np1y, ep1y);
27 | return {
28 | p0: [p0x, p0y],
29 | p1: [p1x, p1y],
30 | };
31 | }
32 |
33 | export function extendMbrPlan(
34 | node: InternalEntry,
35 | entryMbr: MBR,
36 | ) {
37 | const extendedMbr = extendMbr(node.mbr, entryMbr);
38 | const {
39 | p0: [np0x, np0y],
40 | p1: [np1x, np1y],
41 | } = node.mbr;
42 | const {
43 | p0: [p0x, p0y],
44 | p1: [p1x, p1y],
45 | } = extendedMbr;
46 | const originalArea = (np1x - np0x) * (np1y - np0y);
47 | const extendedArea = (p1x - p0x) * (p1y - p0y);
48 | const cost = extendedArea - originalArea;
49 | return {
50 | node,
51 | extendedMbr,
52 | extendedArea,
53 | originalArea,
54 | cost,
55 | };
56 | }
57 |
58 | export function canCoverMbr(nodeMbr: MBR, entryMbr: MBR) {
59 | const {
60 | p0: [np0x, np0y],
61 | p1: [np1x, np1y],
62 | } = nodeMbr;
63 | const {
64 | p0: [ep0x, ep0y],
65 | p1: [ep1x, ep1y],
66 | } = entryMbr;
67 | return (
68 | np0x <= ep0x &&
69 | ep0x <= np1x &&
70 | np0x <= ep1x &&
71 | ep1x <= np1x &&
72 | np0y <= ep0y &&
73 | ep0y <= np1y &&
74 | np0y <= ep1y &&
75 | ep1y <= np1y
76 | );
77 | }
78 |
79 | export function mergeMbrs(mbrs: MBR[]): MBR | null {
80 | if (!mbrs.length) return null;
81 | return mbrs.reduce(extendMbr);
82 | }
83 |
84 | export function batchAddToNode(
85 | addToNode: InternalEntry,
86 | entries: (InternalEntry | T)[],
87 | startIdx: number,
88 | endIdx: number,
89 | ) {
90 | for (let i = startIdx; i < endIdx; i += 1) {
91 | const entryToAdd = entries[i];
92 | if (addToNode.type === 'rtree-type-node-internal') {
93 | addToNode.entries.push(entryToAdd as InternalEntry);
94 | } else {
95 | addToNode.entries.push(entryToAdd as T);
96 | }
97 | entryToAdd.parent = addToNode;
98 | const extended = extendMbrPlan(addToNode, entryToAdd.mbr);
99 | addToNode.mbr = extended.extendedMbr;
100 | }
101 | }
102 |
103 | export function minDist(
104 | [px, py]: Point2D,
105 | { p0: [sx, sy], p1: [tx, ty] }: MBR,
106 | ) {
107 | let rx = px;
108 | if (rx < sx) rx = sx;
109 | else if (rx > tx) rx = tx;
110 | let ry = py;
111 | if (ry < sy) ry = sy;
112 | else if (ry > ty) ry = ty;
113 | return (px - rx) ** 2 + (py - ry) ** 2;
114 | }
115 |
116 | export function minMaxDist(
117 | [px, py]: Point2D,
118 | { p0: [sx, sy], p1: [tx, ty] }: MBR,
119 | ) {
120 | const mx = (sx + tx) / 2;
121 | const rmx = px <= mx ? sx : tx;
122 | const rMx = px >= mx ? sx : tx;
123 | const my = (sy + ty) / 2;
124 | const rmy = py <= my ? sy : ty;
125 | const rMy = py >= my ? sy : ty;
126 | const dx = (px - rmx) ** 2 + (py - rMy) ** 2;
127 | const dy = (py - rmy) ** 2 + (px - rMx) ** 2;
128 | return Math.min(dx, dy);
129 | }
130 |
--------------------------------------------------------------------------------
/src/plotter/svg/arc-to-lines.ts:
--------------------------------------------------------------------------------
1 | import { Point2D, pointToLineDist } from '@/math/geom';
2 | import { arcAngleFn, transformLine, transformPoint } from './math';
3 | import { EllipticalArc } from './path/parser';
4 | import { SvgToLinesOptions } from './svg-to-lines';
5 | import { attachIds, Line2DWithId } from './utils';
6 |
7 | export default function* arcToLines(
8 | arc: EllipticalArc[1],
9 | startPos: Point2D,
10 | ctm: DOMMatrix,
11 | opt: SvgToLinesOptions,
12 | ): Generator {
13 | // B.2.4. Conversion from endpoint to center parameterization
14 | // @link: https://www.w3.org/TR/SVG/implnote.html#ArcConversionEndpointToCenter
15 | const { cos, sin, PI } = Math;
16 | const [x1, y1] = startPos;
17 | const [rx, ry, deg, fa, fs, [x2, y2]] = arc;
18 | // B.2.5 step 1: ensure radii are non-zero
19 | if (rx === 0 || ry === 0) {
20 | const currPos = [x2, y2] as Point2D;
21 | yield attachIds(transformLine(startPos, currPos, ctm), opt);
22 | return currPos;
23 | }
24 | // B.2.5 step 2: ensure radii are positive
25 | let Rx = Math.abs(rx);
26 | let Ry = Math.abs(ry);
27 | // B.2.4 step 1: compute (x1t, y1t)
28 | const angle = (deg * Math.PI) / 180;
29 | const cosAngle = cos(angle);
30 | const sinAngle = sin(angle);
31 | const mat1 = new DOMMatrix([cosAngle, -sinAngle, sinAngle, cosAngle, 0, 0]);
32 | const [x1t, y1t] = transformPoint([(x1 - x2) / 2, (y1 - y2) / 2], mat1);
33 | // B.2.4 step 2: compute (cxt, cyt)
34 | let rx2 = Rx * Rx;
35 | let ry2 = Ry * Ry;
36 | const x1t2 = x1t * x1t;
37 | const y1t2 = y1t * y1t;
38 | // B.2.5 step 3: ensure radii are large enough
39 | const lambda = x1t2 / rx2 + y1t2 / ry2;
40 | if (lambda > 1) {
41 | const lambdaSqrt = Math.sqrt(lambda);
42 | Rx *= lambdaSqrt;
43 | Ry *= lambdaSqrt;
44 | rx2 = Rx * Rx;
45 | ry2 = Ry * Ry;
46 | }
47 | let factor =
48 | (rx2 * ry2 - rx2 * y1t2 - ry2 * x1t2) / (rx2 * y1t2 + ry2 * x1t2);
49 | if (factor < 0) {
50 | factor = 0;
51 | }
52 | const sign = fa === fs ? -1 : 1;
53 | const root = Math.sqrt(factor);
54 | const cxt = (sign * root * Rx * y1t) / Ry;
55 | const cyt = (-sign * root * Ry * x1t) / Rx;
56 | // B.2.4 step 3: compute (cx, cy) from (cxt, cyt)
57 | const mat3 = new DOMMatrix([cosAngle, sinAngle, -sinAngle, cosAngle, 0, 0]);
58 | const [pcx, pcy] = transformPoint([cxt, cyt], mat3);
59 | const cx = pcx + (x1 + x2) / 2;
60 | const cy = pcy + (y1 + y2) / 2;
61 | // B.2.4 step 4: compute theta1 and delta
62 | const vx1 = (x1t - cxt) / Rx;
63 | const vy1 = (y1t - cyt) / Ry;
64 | const vx2 = (-x1t - cxt) / Rx;
65 | const vy2 = (-y1t - cyt) / Ry;
66 | const theta1 = arcAngleFn(1, 0, vx1, vy1);
67 | const TWO_PI = PI * 2;
68 | const tDelta = arcAngleFn(vx1, vy1, vx2, vy2) % TWO_PI;
69 | let delta = tDelta;
70 | if (fs === 0 && tDelta > 0) {
71 | delta -= TWO_PI;
72 | }
73 | if (fs === 1 && tDelta < 0) {
74 | delta += TWO_PI;
75 | }
76 | // linear approximation
77 | // https://www.spaceroots.org/documents/ellipse/elliptical-arc.pdf
78 | const { maxError } = opt;
79 | const matE = new DOMMatrix([ctm.a, ctm.b, ctm.c, ctm.d, 0, 0]);
80 |
81 | function* arcLinearApproximation(
82 | t0: number,
83 | t1: number,
84 | p0: Point2D,
85 | p1: Point2D,
86 | ): Generator {
87 | const tm = (t0 + t1) / 2;
88 | const [cpx, cpy] = transformPoint([Rx * cos(tm), Ry * sin(tm)], mat3);
89 | const pm = [cx + cpx, cy + cpy] as Point2D;
90 | const error = pointToLineDist(pm, p0, p1);
91 | // transform error to paper space and test with maxError
92 | const [ex, ey] = transformPoint([error, 0], matE);
93 | const errSq = ex * ex + ey * ey;
94 | if (errSq <= maxError * maxError) {
95 | yield attachIds(transformLine(p0, p1, ctm), opt);
96 | } else {
97 | // exceed the max error, splits the arc into two segments
98 | yield* arcLinearApproximation(t0, tm, p0, pm);
99 | yield* arcLinearApproximation(tm, t1, pm, p1);
100 | }
101 | }
102 |
103 | yield* arcLinearApproximation(theta1, theta1 + delta, [x1, y1], [x2, y2]);
104 | return [x2, y2];
105 | }
106 |
--------------------------------------------------------------------------------
/src/plotter/svg/bezier-to-lines.ts:
--------------------------------------------------------------------------------
1 | import { Point2D } from '@/math/geom';
2 | import {
3 | getMidPoint,
4 | isSufficientlyFlat,
5 | transformLine,
6 | transformPoint,
7 | } from './math';
8 | import { CurveTo } from './path/parser';
9 | import { SvgToLinesOptions } from './svg-to-lines';
10 | import { attachIds, Line2DWithId } from './utils';
11 |
12 | export default function* bezierToLines(
13 | bezier: CurveTo[1],
14 | startPt: Point2D,
15 | ctm: DOMMatrix,
16 | opt: SvgToLinesOptions,
17 | ): Generator {
18 | const [ctrlPt1, ctrlPt2, endPt] = bezier;
19 | // A Primer on Bezier Curves
20 | // @link: https://pomax.github.io/bezierinfo/
21 | //
22 | // Piecewise Linear Approximation of Bezier Curves
23 | // @link: http://hcklbrrfnn.files.wordpress.com/2012/08/bez.pdf
24 | const { maxError } = opt;
25 | // translate maxError into path's local space
26 | const ctmInv = ctm.inverse();
27 | const ctmInvForVec = new DOMMatrix([
28 | ctmInv.a,
29 | ctmInv.b,
30 | ctmInv.c,
31 | ctmInv.d,
32 | 0,
33 | 0,
34 | ]);
35 | const [epx, epy] = transformPoint([maxError, 0], ctmInvForVec);
36 | const errSq = epx * epx + epy * epy;
37 |
38 | function* bezierLinearApproximation(
39 | p0: Point2D,
40 | p1: Point2D,
41 | p2: Point2D,
42 | p3: Point2D,
43 | ): Generator {
44 | if (isSufficientlyFlat(p0, p1, p2, p3, errSq)) {
45 | yield attachIds(transformLine(p0, p3, ctm), opt);
46 | } else {
47 | // if it's not flat enough, then splits the bezier into two segments
48 | const lp1 = getMidPoint(p0, p1);
49 | const mp = getMidPoint(p1, p2);
50 | const lp2 = getMidPoint(lp1, mp);
51 | const rp2 = getMidPoint(p2, p3);
52 | const rp1 = getMidPoint(mp, rp2);
53 | const lp3 = getMidPoint(lp2, rp1);
54 | const rp0 = lp3;
55 | yield* bezierLinearApproximation(p0, lp1, lp2, lp3);
56 | yield* bezierLinearApproximation(rp0, rp1, rp2, p3);
57 | }
58 | }
59 |
60 | yield* bezierLinearApproximation(startPt, ctrlPt1, ctrlPt2, endPt);
61 | return endPt;
62 | }
63 |
--------------------------------------------------------------------------------
/src/plotter/svg/element-to-path.ts:
--------------------------------------------------------------------------------
1 | import { Path } from './path/parser';
2 | import { getAttrVal } from './utils';
3 |
4 | export default function elementToPath(
5 | svgEl: SVGRectElement | SVGCircleElement | SVGEllipseElement,
6 | ): Path {
7 | // we parse this manually to save time
8 | const parsedPath: Path = [];
9 | switch (svgEl.nodeName) {
10 | case 'rect':
11 | {
12 | const rectEl = svgEl as SVGRectElement;
13 | let w = getAttrVal(rectEl, 'width');
14 | let h = getAttrVal(rectEl, 'height');
15 | const x = getAttrVal(rectEl, 'x');
16 | const y = getAttrVal(rectEl, 'y');
17 | let rx = getAttrVal(rectEl, 'rx');
18 | let ry = getAttrVal(rectEl, 'ry');
19 | rx = Math.min(rx, w / 2);
20 | ry = Math.min(ry, h / 2);
21 | if (rx === 0 || ry === 0) {
22 | /* it's a normal rectangle */
23 | // we goes the rect counter-clock-wise
24 | // 1-(-w)-4
25 | // | |
26 | // h -h
27 | // | |
28 | // 2-( w)-3
29 | parsedPath.push(
30 | ['M', [x, y]],
31 | ['v', h],
32 | ['h', w],
33 | ['v', -h],
34 | ['h', -w],
35 | );
36 | } else {
37 | /* it's a rounded rectangle */
38 | w -= 2 * rx;
39 | h -= 2 * ry;
40 | // rotation = 0
41 | // large-arc = 0
42 | // sweep = 0
43 | parsedPath.push(
44 | // start point
45 | ['M', [x, y + ry]],
46 | // left border
47 | ['v', h],
48 | // bottom left radius
49 | ['a', [rx, ry, 0, 0, 0, [rx, ry]]],
50 | // bottom border
51 | ['h', w],
52 | // bottom right radius
53 | ['a', [rx, ry, 0, 0, 0, [rx, -ry]]],
54 | // right border
55 | ['v', -h],
56 | // top right radius
57 | ['a', [rx, ry, 0, 0, 0, [-rx, -ry]]],
58 | // top border
59 | ['h', -w],
60 | // top left radius
61 | ['a', [rx, ry, 0, 0, 0, [-rx, ry]]],
62 | );
63 | }
64 | }
65 | break;
66 | case 'circle':
67 | case 'ellipse':
68 | {
69 | const roundEl = svgEl as SVGCircleElement | SVGEllipseElement;
70 | const cx = getAttrVal(roundEl, 'cx');
71 | const cy = getAttrVal(roundEl, 'cy');
72 | let rx: number, ry: number;
73 | if (svgEl.nodeName === 'circle') {
74 | const r = getAttrVal(roundEl as SVGCircleElement, 'r');
75 | rx = ry = r;
76 | } else {
77 | rx = getAttrVal(roundEl as SVGEllipseElement, 'rx');
78 | ry = getAttrVal(roundEl as SVGEllipseElement, 'ry');
79 | }
80 | if (rx === 0 || ry === 0) {
81 | // it won't be drawn at all if circle or ellipse has no radius
82 | return null;
83 | }
84 | parsedPath.push(
85 | // start point
86 | ['M', [cx, cy - ry]],
87 | // rotation = 0
88 | // large-arc = 1
89 | // sweep = 0
90 | ['a', [rx, ry, 0, 0, 0, [0, ry * 2]]],
91 | // we split a circle/ellipse into two arc
92 | // to ensure the linear approximation is working correctly
93 | ['a', [rx, ry, 0, 0, 0, [0, -ry * 2]]],
94 | );
95 | }
96 | break;
97 | default:
98 | throw new Error(`Can't generate path definition from ${svgEl.nodeName}`);
99 | }
100 | return parsedPath;
101 | }
102 |
--------------------------------------------------------------------------------
/src/plotter/svg/math.ts:
--------------------------------------------------------------------------------
1 | import { Line2D, Point2D } from '@/math/geom';
2 |
3 | /**
4 | * Fast transform point with DOMMatrix:
5 | *
6 | * The original DOMPoint.transformMatrix(DOMMatrix) is very slow.
7 | *
8 | * here is the mapping of DOMMatrix that multiplies a 2D Point
9 | *
10 | * | m11 m21 m31 m41 | <-> | a c m31 e | * | x | = | x`|
11 | * | m12 m22 m32 m42 | | b d m32 f | | y | | y`|
12 | * | m13 m23 m33 m43 | | m13 m23 m33 m43 | | 0 | | 0 |
13 | * | m14 m24 m34 m44 | | m14 m24 m34 m44 | | 1 | | 1 |
14 | */
15 | export function transformPoint(
16 | [x, y]: Point2D,
17 | { a, b, c, d, e, f }: DOMMatrix,
18 | ): Point2D {
19 | return [a * x + c * y + e, b * x + d * y + f];
20 | }
21 |
22 | // transform lines from local space to world space with CTM
23 | export function transformLine(
24 | p0: Point2D,
25 | p1: Point2D,
26 | ctm: DOMMatrix,
27 | ): Line2D {
28 | return [transformPoint(p0, ctm), transformPoint(p1, ctm)];
29 | }
30 |
31 | export const arcAngleFn = (ux: number, uy: number, vx: number, vy: number) => {
32 | return Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
33 | };
34 |
35 | export function isSufficientlyFlat(
36 | [x0, y0]: Point2D,
37 | [x1, y1]: Point2D,
38 | [x2, y2]: Point2D,
39 | [x3, y3]: Point2D,
40 | errSq: number,
41 | ) {
42 | let ux = (3 * x1 - 2 * x0 - x3) ** 2;
43 | let uy = (3 * y1 - 2 * y0 - y3) ** 2;
44 | const vx = (3 * x2 - 2 * x3 - x0) ** 2;
45 | const vy = (3 * y2 - 2 * y3 - y0) ** 2;
46 | if (ux < vx) ux = vx;
47 | if (uy < vy) uy = vy;
48 | return ux + uy <= 16 * errSq;
49 | }
50 |
51 | export const getMidPoint = ([x0, y0]: Point2D, [x1, y1]: Point2D): Point2D => {
52 | return [(x0 + x1) / 2, (y0 + y1) / 2];
53 | };
54 |
55 | // convert quadratic to cubic bezier
56 | // https://codepen.io/enxaneta/post/quadratic-to-cubic-b-zier-in-svg
57 | export function quadToCubicBezierControlPoints(
58 | [x0, y0]: Point2D,
59 | [x1, y1]: Point2D,
60 | [x2, y2]: Point2D,
61 | ): [Point2D, Point2D] {
62 | const cx1 = x0 + ((x1 - x0) * 2) / 3;
63 | const cy1 = y0 + ((y1 - y0) * 2) / 3;
64 | const cx2 = x2 + ((x1 - x2) * 2) / 3;
65 | const cy2 = y2 + ((y1 - y2) * 2) / 3;
66 | return [
67 | [cx1, cy1],
68 | [cx2, cy2],
69 | ];
70 | }
71 |
--------------------------------------------------------------------------------
/src/plotter/svg/path/cmd-a.ts:
--------------------------------------------------------------------------------
1 | import { EllipticalArc } from './parser';
2 | import { stepXY } from './steppers';
3 | import { transformerXY } from './transformers';
4 | import { Context, normalize, SingleCommand } from './utils';
5 |
6 | export default function* (command: EllipticalArc, context: Context) {
7 | const [cmd, ...params] = command;
8 | for (const param of params) {
9 | yield normalize>(
10 | cmd,
11 | param,
12 | context,
13 | stepXY,
14 | transformerXY,
15 | );
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/plotter/svg/path/cmd-c.ts:
--------------------------------------------------------------------------------
1 | import { CurveTo } from './parser';
2 | import { stepXY } from './steppers';
3 | import { transformerXYPairs } from './transformers';
4 | import { Context, normalize, SingleCommand } from './utils';
5 |
6 | export default function* (command: CurveTo, context: Context) {
7 | const [cmd, ...params] = command;
8 | for (const param of params) {
9 | yield normalize>(
10 | cmd,
11 | param,
12 | context,
13 | stepXY,
14 | transformerXYPairs,
15 | );
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/plotter/svg/path/cmd-h.ts:
--------------------------------------------------------------------------------
1 | import { HorizontalLineTo } from './parser';
2 | import { stepX } from './steppers';
3 | import { transformerX } from './transformers';
4 | import { Context, normalize, SingleCommand } from './utils';
5 |
6 | export default function* (command: HorizontalLineTo, context: Context) {
7 | const [cmd, ...coordinates] = command;
8 | for (const coordinate of coordinates) {
9 | yield normalize>(
10 | cmd,
11 | coordinate,
12 | context,
13 | stepX,
14 | transformerX,
15 | );
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/plotter/svg/path/cmd-l.ts:
--------------------------------------------------------------------------------
1 | import { LineTo } from './parser';
2 | import { stepXY } from './steppers';
3 | import { transformerXY } from './transformers';
4 | import { Context, normalize, SingleCommand } from './utils';
5 |
6 | export default function* (command: LineTo, context: Context) {
7 | const [cmd, ...coordinatePairs] = command;
8 | for (const coordinatePair of coordinatePairs) {
9 | yield normalize>(
10 | cmd,
11 | coordinatePair,
12 | context,
13 | stepXY,
14 | transformerXY,
15 | );
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/plotter/svg/path/cmd-m.ts:
--------------------------------------------------------------------------------
1 | import L from './cmd-l';
2 | import { MoveTo } from './parser';
3 | import { StepAndStoreXY } from './steppers';
4 | import { transformerXY } from './transformers';
5 | import { Context, normalize, SingleCommand } from './utils';
6 |
7 | export default function* (command: MoveTo, context: Context) {
8 | const [cmd, coordinatePair, ...coordinatePairs] = command;
9 | yield normalize>(
10 | cmd,
11 | coordinatePair,
12 | context,
13 | StepAndStoreXY,
14 | transformerXY,
15 | );
16 | // the following coordinate pairs are for line commands
17 | if (coordinatePairs.length) {
18 | yield* L(
19 | [{ M: 'L' as const, m: 'l' as const }[cmd], ...coordinatePairs],
20 | context,
21 | );
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/plotter/svg/path/cmd-q.ts:
--------------------------------------------------------------------------------
1 | import { QuadBezierCurveTo } from './parser';
2 | import { stepXY } from './steppers';
3 | import { transformerXYPairs } from './transformers';
4 | import { Context, normalize, SingleCommand } from './utils';
5 |
6 | export default function* (command: QuadBezierCurveTo, context: Context) {
7 | const [cmd, ...params] = command;
8 | for (const param of params) {
9 | yield normalize>(
10 | cmd,
11 | param,
12 | context,
13 | stepXY,
14 | transformerXYPairs,
15 | );
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/plotter/svg/path/cmd-s.ts:
--------------------------------------------------------------------------------
1 | import { SmoothCurveTo } from './parser';
2 | import { stepXY } from './steppers';
3 | import { transformerXYPairs } from './transformers';
4 | import { Context, normalize, SingleCommand } from './utils';
5 |
6 | export default function* (command: SmoothCurveTo, context: Context) {
7 | const [cmd, ...params] = command;
8 | for (const param of params) {
9 | yield normalize>(
10 | cmd,
11 | param,
12 | context,
13 | stepXY,
14 | transformerXYPairs,
15 | );
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/plotter/svg/path/cmd-t.ts:
--------------------------------------------------------------------------------
1 | import { SmoothQuadBezierCurveTo } from './parser';
2 | import { stepXY } from './steppers';
3 | import { transformerXY } from './transformers';
4 | import { Context, normalize, SingleCommand } from './utils';
5 |
6 | export default function* (command: SmoothQuadBezierCurveTo, context: Context) {
7 | const [cmd, ...coordinatePairs] = command;
8 | for (const coordinatePair of coordinatePairs) {
9 | yield normalize>(
10 | cmd,
11 | coordinatePair,
12 | context,
13 | stepXY,
14 | transformerXY,
15 | );
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/plotter/svg/path/cmd-v.ts:
--------------------------------------------------------------------------------
1 | import { VerticalLineTo } from './parser';
2 | import { stepY } from './steppers';
3 | import { transformerY } from './transformers';
4 | import { Context, normalize, SingleCommand } from './utils';
5 |
6 | export default function* (command: VerticalLineTo, context: Context) {
7 | const [cmd, ...coordinates] = command;
8 | for (const coordinate of coordinates) {
9 | yield normalize>(
10 | cmd,
11 | coordinate,
12 | context,
13 | stepY,
14 | transformerY,
15 | );
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/plotter/svg/path/cmd-z.ts:
--------------------------------------------------------------------------------
1 | import { ClosePath } from './parser';
2 | import { resetXY } from './steppers';
3 | import { Context, SingleCommand } from './utils';
4 |
5 | export default function* (command: ClosePath, context: Context) {
6 | resetXY(null, context);
7 | yield [
8 | context.toAbsolute ? ('Z' as const) : command[0],
9 | undefined,
10 | ] as SingleCommand;
11 | }
12 |
--------------------------------------------------------------------------------
/src/plotter/svg/path/index.ts:
--------------------------------------------------------------------------------
1 | import A from './cmd-a';
2 | import C from './cmd-c';
3 | import H from './cmd-h';
4 | import L from './cmd-l';
5 | import M from './cmd-m';
6 | import Q from './cmd-q';
7 | import S from './cmd-s';
8 | import T from './cmd-t';
9 | import V from './cmd-v';
10 | import Z from './cmd-z';
11 | import {
12 | ClosePath,
13 | Command,
14 | CurveTo,
15 | EllipticalArc,
16 | HorizontalLineTo,
17 | LineTo,
18 | MoveTo,
19 | parsePath,
20 | Path,
21 | QuadBezierCurveTo,
22 | SmoothCurveTo,
23 | SmoothQuadBezierCurveTo,
24 | VerticalLineTo,
25 | } from './parser';
26 | import { Context, SingleCommand } from './utils';
27 |
28 | export function svgPathParser(pathDef = 'M 0 0') {
29 | return parsePath(pathDef);
30 | }
31 |
32 | export default function* svgPathNormalizer(
33 | parsedPath: Path,
34 | toAbsolute = true,
35 | ): Generator, void, void> {
36 | if (!parsedPath) {
37 | return;
38 | }
39 |
40 | // create a context to walk through the path
41 | const context: Context = { x: 0, y: 0, startX: 0, startY: 0, toAbsolute };
42 | for (const command of parsedPath) {
43 | const commandId = (command[0] as string).toUpperCase();
44 | switch (commandId) {
45 | case 'M':
46 | yield* M(command as MoveTo, context);
47 | break;
48 | case 'L':
49 | yield* L(command as LineTo, context);
50 | break;
51 | case 'H':
52 | yield* H(command as HorizontalLineTo, context);
53 | break;
54 | case 'V':
55 | yield* V(command as VerticalLineTo, context);
56 | break;
57 | case 'C':
58 | yield* C(command as CurveTo, context);
59 | break;
60 | case 'S':
61 | yield* S(command as SmoothCurveTo, context);
62 | break;
63 | case 'Q':
64 | yield* Q(command as QuadBezierCurveTo, context);
65 | break;
66 | case 'T':
67 | yield* T(command as SmoothQuadBezierCurveTo, context);
68 | break;
69 | case 'A':
70 | yield* A(command as EllipticalArc, context);
71 | break;
72 | case 'Z':
73 | yield* Z(command as ClosePath, context);
74 | break;
75 | default:
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/plotter/svg/path/parser/utils.ts:
--------------------------------------------------------------------------------
1 | export class NotMatchError extends Error {
2 | constructor() {
3 | super('Not match');
4 | }
5 | }
6 |
7 | /* regex utils */
8 |
9 | export const composeRe = (...res: RegExp[]) => {
10 | const source = res.reduce((src, re) => src + re.source, '');
11 | return new RegExp(source);
12 | };
13 |
14 | export const branchesRe = (...res: RegExp[]) => {
15 | const source = res.map((re) => re.source).join('|');
16 | return new RegExp(`(?:${source})`);
17 | };
18 |
19 | export const optionalRe = (re: RegExp) => new RegExp(`(?:${re.source})?`);
20 |
21 | export const atLeastRe = (re: RegExp) => new RegExp(`(?:${re.source})+`);
22 |
23 | export const manyRe = (re: RegExp) => new RegExp(`(?:${re.source})*`);
24 |
25 | /* parse utils */
26 |
27 | export type Consumer = (
28 | s: string,
29 | idx?: number,
30 | ) => { idx: number; value: T };
31 |
32 | export type ExtractConsumedType = T extends Consumer ? R : never;
33 |
34 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
35 | export type Transformer = (...args: any[]) => T;
36 |
37 | // match a branches of consumer
38 | export const fastBranches = (
39 | consumerDict: Record>,
40 | ): Consumer =>
41 | function FastBranches(s: string, idx = 0) {
42 | const peek = s.at(idx)?.toUpperCase();
43 | const consumer = peek && consumerDict[peek];
44 | if (!consumer) {
45 | throw new NotMatchError();
46 | }
47 | return consumer(s, idx);
48 | };
49 |
50 | // match a group of consumers
51 | export const rule = (
52 | transform: Transformer,
53 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
54 | ...consumers: Consumer[]
55 | ): Consumer =>
56 | function Rule(s, idx = 0) {
57 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
58 | const results = consumers.reduce<{ idx: number; values: any[] }>(
59 | function RuleReducer(context, consumer) {
60 | const ret = consumer(s, context.idx);
61 | context.idx = ret.idx;
62 | context.values.push(ret.value);
63 | return context;
64 | },
65 | { idx, values: [] },
66 | );
67 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
68 | return { idx: results.idx, value: transform(...results.values) };
69 | };
70 |
71 | // match regex, return null or single string
72 | export const consume = (regex: RegExp): Consumer => {
73 | const normalizedRe = new RegExp(regex.source, 'y');
74 | return function Consume(s, idx = 0) {
75 | normalizedRe.lastIndex = idx;
76 | const ret = normalizedRe.exec(s);
77 | if (!ret) throw new NotMatchError();
78 | const value = ret[0] as T;
79 | return { idx: normalizedRe.lastIndex, value };
80 | };
81 | };
82 |
83 | // match zero or one, return defaultValue or matched single value
84 | export const optional = (
85 | consumer: Consumer,
86 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
87 | defaultValue: any = null,
88 | ): Consumer =>
89 | function Optional(s, idx = 0) {
90 | try {
91 | return consumer(s, idx);
92 | } catch (e) {
93 | if (e instanceof NotMatchError) {
94 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
95 | return { idx, value: defaultValue };
96 | }
97 | throw e;
98 | }
99 | };
100 |
101 | // match zero or more, return in array
102 | export const many = (consumer: Consumer): Consumer =>
103 | function Many(s, idx = 0) {
104 | const values: T[] = [];
105 | let lastIdx = idx;
106 | for (;;) {
107 | try {
108 | const ret = consumer(s, lastIdx);
109 | values.push(ret.value);
110 | lastIdx = ret.idx;
111 | } catch (e) {
112 | if (e instanceof NotMatchError) {
113 | break;
114 | } else {
115 | throw e;
116 | }
117 | }
118 | }
119 | return {
120 | idx: lastIdx,
121 | value: values,
122 | };
123 | };
124 |
--------------------------------------------------------------------------------
/src/plotter/svg/path/steppers.ts:
--------------------------------------------------------------------------------
1 | import { ClosePath, Command, HorizontalLineTo, VerticalLineTo } from './parser';
2 | import { Context, SingleCommand } from './utils';
3 |
4 | export const stepX = (
5 | param: HorizontalLineTo[1],
6 | context: Context,
7 | isAbsoluteCmd: boolean,
8 | ) => {
9 | if (isAbsoluteCmd) {
10 | context.x = param;
11 | } else {
12 | context.x += param;
13 | }
14 | };
15 |
16 | export const stepY = (
17 | param: VerticalLineTo[1],
18 | context: Context,
19 | isAbsoluteCmd: boolean,
20 | ) => {
21 | if (isAbsoluteCmd) {
22 | context.y = param;
23 | } else {
24 | context.y += param;
25 | }
26 | };
27 |
28 | export const stepXY = (
29 | params: SingleCommand<
30 | Exclude
31 | >[1],
32 | context: Context,
33 | isAbsoluteCmd: boolean,
34 | ) => {
35 | // the params may be a single pair of coordinates or multiple pairs
36 | const lastParam = params[params.length - 1];
37 | const coordinatePair = (
38 | typeof lastParam === 'number' ? params : lastParam
39 | ) as [number, number];
40 |
41 | if (isAbsoluteCmd) {
42 | context.x = coordinatePair[0];
43 | context.y = coordinatePair[1];
44 | } else {
45 | context.x += coordinatePair[0];
46 | context.y += coordinatePair[1];
47 | }
48 | };
49 |
50 | export const storeXY = (_: unknown, context: Context) => {
51 | context.startX = context.x;
52 | context.startY = context.y;
53 | };
54 |
55 | export const StepAndStoreXY = (
56 | params: SingleCommand<
57 | Exclude
58 | >[1],
59 | context: Context,
60 | isAbsoluteCmd: boolean,
61 | ) => {
62 | stepXY(params, context, isAbsoluteCmd);
63 | storeXY(null, context);
64 | };
65 |
66 | export const resetXY = (_: unknown, context: Context) => {
67 | context.x = context.startX;
68 | context.y = context.startY;
69 | };
70 |
--------------------------------------------------------------------------------
/src/plotter/svg/path/transformers.ts:
--------------------------------------------------------------------------------
1 | import { Point2D } from '@/math/geom';
2 | import {
3 | CurveTo,
4 | EllipticalArc,
5 | HorizontalLineTo,
6 | LineTo,
7 | MoveTo,
8 | numberPair,
9 | QuadBezierCurveTo,
10 | SmoothCurveTo,
11 | SmoothQuadBezierCurveTo,
12 | VerticalLineTo,
13 | } from './parser';
14 | import { Context, SingleCommand } from './utils';
15 |
16 | export const transformerXY = <
17 | T extends SingleCommand<
18 | EllipticalArc | LineTo | MoveTo | SmoothQuadBezierCurveTo
19 | >[1],
20 | >(
21 | params: T,
22 | context: Context,
23 | ) => {
24 | // the params may be a single pair of coordinates or multiple pairs
25 | const lastParam = params[params.length - 1];
26 | if (typeof lastParam === 'number') {
27 | const coordinatePair = params as numberPair;
28 | return [coordinatePair[0] + context.x, coordinatePair[1] + context.y] as T;
29 | }
30 |
31 | const transformed = [...params];
32 | // most commands store the coordinate-pair at last param
33 | const [x, y] = transformed[transformed.length - 1] as Point2D;
34 | transformed[transformed.length - 1] = [x + context.x, y + context.y];
35 | return transformed as T;
36 | };
37 |
38 | export const transformerX = (
39 | x: T,
40 | context: Context,
41 | ) => {
42 | return (x + context.x) as T;
43 | };
44 |
45 | export const transformerY = (
46 | y: T,
47 | context: Context,
48 | ) => {
49 | return (y + context.y) as T;
50 | };
51 |
52 | export const transformerXYPairs = <
53 | T extends SingleCommand[1],
54 | >(
55 | params: T,
56 | context: Context,
57 | ) => {
58 | return (params as numberPair[]).map(([x, y]) => [
59 | x + context.x,
60 | y + context.y,
61 | ]) as T;
62 | };
63 |
--------------------------------------------------------------------------------
/src/plotter/svg/path/utils.ts:
--------------------------------------------------------------------------------
1 | import { Command } from './parser';
2 |
3 | export type Context = {
4 | x: number;
5 | y: number;
6 | startX: number;
7 | startY: number;
8 | toAbsolute: boolean;
9 | };
10 |
11 | export type SingleCommand = [C[0], C[1]];
12 |
13 | /**
14 | * Walking through the command, and transform from relative to absolute format if needed.
15 | * Also update the context to record the current position.
16 | *
17 | * @param command
18 | * @param params
19 | * @param context
20 | * @param stepper
21 | * @param transformer
22 | * @returns {*[]}
23 | */
24 | export const normalize = >(
25 | command: T[0],
26 | params: T[1],
27 | context: Context,
28 | stepper: (params: T[1], context: Context, isAbsoluteCmd: boolean) => void,
29 | transformer: (params: T[1], context: Context) => T[1],
30 | ): T => {
31 | const absoluteCmd = command.toUpperCase();
32 | const isAbsoluteCmd = command === absoluteCmd;
33 | // if the segment is a absolute cmd or don't need to transform
34 | // update the current position and return segment without change
35 | if (isAbsoluteCmd || !context.toAbsolute) {
36 | stepper(params, context, isAbsoluteCmd);
37 | return [command, params] as T;
38 | }
39 |
40 | // else the segment is a relative cmd and need to be transformed
41 | const transformed = transformer(params, context);
42 | stepper(transformed, context, true);
43 | return [absoluteCmd as T[0], transformed] as T;
44 | };
45 |
--------------------------------------------------------------------------------
/src/plotter/svg/points-to-lines.ts:
--------------------------------------------------------------------------------
1 | import { Point2D } from '@/math/geom';
2 | import { transformLine } from './math';
3 | import { SvgToLinesOptions } from './svg-to-lines';
4 | import { attachIds, getAttrVal } from './utils';
5 |
6 | export default function* pointsToLines(
7 | svgEl: SVGLineElement | SVGPolylineElement | SVGPolygonElement,
8 | opt: SvgToLinesOptions,
9 | ) {
10 | const ctm = svgEl.getCTM();
11 | if (!ctm) {
12 | throw new Error('CTM is null');
13 | }
14 | switch (svgEl.nodeName) {
15 | case 'line': {
16 | const svgLineEl = svgEl as SVGLineElement;
17 | const p0 = [
18 | getAttrVal(svgLineEl, 'x1'),
19 | getAttrVal(svgLineEl, 'y1'),
20 | ] as Point2D;
21 | const p1 = [
22 | getAttrVal(svgLineEl, 'x2'),
23 | getAttrVal(svgLineEl, 'y2'),
24 | ] as Point2D;
25 | yield attachIds(transformLine(p0, p1, ctm), opt);
26 | return;
27 | }
28 | case 'polyline':
29 | case 'polygon':
30 | {
31 | const { points } = svgEl as SVGPolylineElement | SVGPolygonElement;
32 | const len = points.length;
33 | for (let i = 0; i < len - 1; i += 1) {
34 | const p0 = points[i];
35 | const p1 = points[i + 1];
36 | yield attachIds(transformLine([p0.x, p0.y], [p1.x, p1.y], ctm), opt);
37 | }
38 | if (svgEl.nodeName === 'polygon') {
39 | const p0 = points[len - 1];
40 | const p1 = points[0];
41 | yield attachIds(transformLine([p0.x, p0.y], [p1.x, p1.y], ctm), opt);
42 | }
43 | }
44 | break;
45 | default:
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/plotter/svg/presentation.ts:
--------------------------------------------------------------------------------
1 | import { isSamePoint, Line2D, Point2D } from '../../math/geom';
2 |
3 | export const toSvgLines = (lines: Line2D[]) =>
4 | lines
5 | .map(
6 | ([[x1, y1], [x2, y2]]) =>
7 | ``,
8 | )
9 | .join('');
10 |
11 | export const toSvgPathDef = (lines: Line2D[]) => {
12 | let currentPos: Point2D = [0, 0];
13 | return lines
14 | .reduce(
15 | (defs, [[x0, y0], [x1, y1]]) => {
16 | if (isSamePoint([x0, y0], currentPos)) {
17 | defs.push(`${x1} ${y1}`);
18 | } else {
19 | defs.push(`M ${x0} ${y0} ${x1} ${y1}`);
20 | }
21 | currentPos = [x1, y1];
22 | return defs;
23 | },
24 | ['M 0 0'],
25 | )
26 | .join(' ');
27 | };
28 |
29 | export const toSvgPath = (lines: Line2D[]) =>
30 | ``;
31 |
--------------------------------------------------------------------------------
/src/plotter/svg/utils.ts:
--------------------------------------------------------------------------------
1 | import { Line2D } from '@/math/geom';
2 | import { SvgToLinesOptions } from './svg-to-lines';
3 |
4 | export function createSVGElement(type: string) {
5 | return document.createElementNS('http://www.w3.org/2000/svg', type);
6 | }
7 |
8 | export function getAttrVal(
9 | svgEl: T,
10 | attr: keyof T,
11 | ): number {
12 | return (svgEl[attr] as SVGAnimatedLength).baseVal.value;
13 | }
14 |
15 | export type Line2DWithId = Line2D & {
16 | groupId: string;
17 | elementId: number;
18 | };
19 |
20 | export function attachIds(
21 | line: Line2D,
22 | { groups, elementId }: SvgToLinesOptions,
23 | ): Line2DWithId {
24 | const line2DWithId = [...line] as Line2DWithId;
25 | line2DWithId.groupId = groups.join('-');
26 | line2DWithId.elementId = elementId;
27 | return line2DWithId;
28 | }
29 |
--------------------------------------------------------------------------------
/src/plotter/utils.ts:
--------------------------------------------------------------------------------
1 | import Logger, { ILogger } from 'js-logger';
2 |
3 | export const logger: ILogger = Logger.get('plotter');
4 |
--------------------------------------------------------------------------------
/src/utils/dom-event.ts:
--------------------------------------------------------------------------------
1 | export const preventDefault =
2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
3 | (fn?: (...params: any[]) => void) =>
4 | (e: { preventDefault: () => void }) => {
5 | e.preventDefault();
6 | fn?.(e);
7 | };
8 |
--------------------------------------------------------------------------------
/src/utils/file.ts:
--------------------------------------------------------------------------------
1 | export function saveFile(
2 | content: BlobPart,
3 | fileName: string,
4 | mimeType: string = 'text/plain',
5 | ) {
6 | const blob = new Blob([content], { type: mimeType });
7 | const url = URL.createObjectURL(blob);
8 |
9 | const a = document.createElement('a');
10 | a.href = url;
11 | a.download = fileName;
12 |
13 | document.body.appendChild(a);
14 | a.click();
15 | document.body.removeChild(a);
16 |
17 | URL.revokeObjectURL(url);
18 | }
19 |
--------------------------------------------------------------------------------
/src/utils/logger.ts:
--------------------------------------------------------------------------------
1 | import Logger from 'js-logger';
2 |
3 | const defaultLogLevel =
4 | import.meta.env.MODE === 'production' ? Logger.OFF : Logger.INFO;
5 |
6 | // This is not a react hook.
7 | // eslint-disable-next-line react-hooks/rules-of-hooks
8 | Logger.useDefaults({
9 | defaultLevel: defaultLogLevel,
10 | formatter(messages, context) {
11 | if (context.name) {
12 | messages.unshift(`[${context.name}]`);
13 | }
14 | messages.unshift(new Date().toISOString());
15 | },
16 | });
17 |
18 | if (import.meta.env.MODE === 'development') {
19 | Logger.get('device').setLevel(Logger.DEBUG);
20 | Logger.get('ebb').setLevel(Logger.DEBUG);
21 | Logger.get('plotter').setLevel(Logger.INFO);
22 | Logger.get('virtual').setLevel(Logger.DEBUG);
23 | }
24 |
--------------------------------------------------------------------------------
/src/utils/style.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/utils/time.ts:
--------------------------------------------------------------------------------
1 | export const delay = (ms: number) => {
2 | return new Promise((resolve) => {
3 | setTimeout(resolve, ms);
4 | });
5 | };
6 |
7 | export const timeout = (ms: number, message: string) => {
8 | return new Promise((_, reject) => {
9 | setTimeout(() => {
10 | reject(new Error(message));
11 | }, ms);
12 | });
13 | };
14 |
15 | export const formatTime = (second: number): string => {
16 | if (!second) return '0m0s';
17 | let rest = second | 0;
18 | const sec = rest % 60;
19 | rest = (second / 60) | 0;
20 | const min = rest % 60;
21 | rest = (min / 60) | 0;
22 | const hour = rest % 60;
23 | rest = (hour / 60) | 0;
24 | if (rest) {
25 | return 'more than 1 hour';
26 | }
27 | if (hour) return `${hour}h${min}m${sec}s`;
28 | return `${min}m${sec}s`;
29 | };
30 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | interface ImportMetaEnv {
4 | readonly VITE_GA: string
5 | // more env variables...
6 | }
7 |
8 | interface ImportMeta {
9 | readonly env: ImportMetaEnv
10 | }
--------------------------------------------------------------------------------
/src/web-usb.d.ts:
--------------------------------------------------------------------------------
1 | ///
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | darkMode: ['class'],
4 | content: ['./src/**/*.{ts,tsx}'],
5 | theme: {
6 | extend: {
7 | fontFamily: {
8 | roboto: ['Roboto', 'sans-serif'],
9 | condensed: ['Roboto Condensed', 'sans-serif'],
10 | },
11 | borderRadius: {
12 | lg: 'var(--radius)',
13 | md: 'calc(var(--radius) - 2px)',
14 | sm: 'calc(var(--radius) - 4px)',
15 | },
16 | colors: {
17 | background: 'hsl(var(--background))',
18 | foreground: 'hsl(var(--foreground))',
19 | card: {
20 | DEFAULT: 'hsl(var(--card))',
21 | foreground: 'hsl(var(--card-foreground))',
22 | },
23 | popover: {
24 | DEFAULT: 'hsl(var(--popover))',
25 | foreground: 'hsl(var(--popover-foreground))',
26 | },
27 | primary: {
28 | DEFAULT: 'hsl(var(--primary))',
29 | foreground: 'hsl(var(--primary-foreground))',
30 | },
31 | secondary: {
32 | DEFAULT: 'hsl(var(--secondary))',
33 | foreground: 'hsl(var(--secondary-foreground))',
34 | },
35 | muted: {
36 | DEFAULT: 'hsl(var(--muted))',
37 | foreground: 'hsl(var(--muted-foreground))',
38 | },
39 | accent: {
40 | DEFAULT: 'hsl(var(--accent))',
41 | foreground: 'hsl(var(--accent-foreground))',
42 | },
43 | destructive: {
44 | DEFAULT: 'hsl(var(--destructive))',
45 | foreground: 'hsl(var(--destructive-foreground))',
46 | },
47 | border: 'hsl(var(--border))',
48 | input: 'hsl(var(--input))',
49 | ring: 'hsl(var(--ring))',
50 | chart: {
51 | 1: 'hsl(var(--chart-1))',
52 | 2: 'hsl(var(--chart-2))',
53 | 3: 'hsl(var(--chart-3))',
54 | 4: 'hsl(var(--chart-4))',
55 | 5: 'hsl(var(--chart-5))',
56 | },
57 | },
58 | },
59 | },
60 | plugins: [require('tailwindcss-animate'), require('@tailwindcss/forms')],
61 | };
62 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Imports */
11 | "baseUrl": ".",
12 | "paths": {
13 | "@/*": ["./src/*"]
14 | },
15 |
16 | /* Bundler mode */
17 | "moduleResolution": "bundler",
18 | "allowImportingTsExtensions": true,
19 | "isolatedModules": true,
20 | "moduleDetection": "force",
21 | "noEmit": true,
22 | "jsx": "react-jsx",
23 |
24 | /* Linting */
25 | "strict": true,
26 | "noUnusedLocals": true,
27 | "noUnusedParameters": true,
28 | "noFallthroughCasesInSwitch": true,
29 | "noUncheckedSideEffectImports": true
30 | },
31 | "include": ["src"]
32 | }
33 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ],
7 | "compilerOptions": {
8 | "baseUrl": ".",
9 | "paths": {
10 | "@/*": ["./src/*"]
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Imports */
10 | "baseUrl": ".",
11 | "paths": {
12 | "@/*": ["./src/*"]
13 | },
14 |
15 | /* Bundler mode */
16 | "moduleResolution": "bundler",
17 | "allowImportingTsExtensions": true,
18 | "isolatedModules": true,
19 | "moduleDetection": "force",
20 | "noEmit": true,
21 |
22 | /* Linting */
23 | "strict": true,
24 | "noUnusedLocals": true,
25 | "noUnusedParameters": true,
26 | "noFallthroughCasesInSwitch": true,
27 | "noUncheckedSideEffectImports": true
28 | },
29 | "include": ["vite.config.ts", "server"]
30 | }
31 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'node:path';
2 | import react from '@vitejs/plugin-react';
3 | import { defineConfig } from 'vite';
4 |
5 | // https://vite.dev/config/
6 | export default defineConfig({
7 | plugins: [react()],
8 | resolve: {
9 | alias: {
10 | '@': path.resolve(__dirname, './src'),
11 | },
12 | },
13 | });
14 |
--------------------------------------------------------------------------------