├── .github ├── CODEOWNERS ├── FUNDING.yml └── workflows │ ├── commitlint.yml │ ├── release-please.yml │ └── run-tests.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bun.lockb ├── bunfig.toml ├── images └── logo.png ├── package.json ├── src ├── components │ ├── Angle.vue │ ├── Arc.vue │ ├── Circle.vue │ ├── Ellipse.vue │ ├── FunctionPlot.vue │ ├── Graph.vue │ ├── Group.vue │ ├── Label.vue │ ├── Line.vue │ ├── Point.vue │ ├── PolyLine.vue │ ├── Polygon.vue │ ├── Sector.vue │ └── Vector.vue ├── composables │ ├── useColors.ts │ ├── useGraphContext.ts │ ├── useMatrices.ts │ └── usePointerIntersection.ts ├── index.ts ├── types.ts ├── utils │ ├── Matrix2D.ts │ ├── Vector2.ts │ ├── configureGraphs.ts │ ├── constants.ts │ └── geometry.ts └── vite-env.d.ts ├── tests ├── math │ ├── Matrix2D.test.ts │ └── Vector2.test.ts └── setup.ts ├── tsconfig.json └── vite.config.ts /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @ksassnowski -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ksassnowski 4 | -------------------------------------------------------------------------------- /.github/workflows/commitlint.yml: -------------------------------------------------------------------------------- 1 | name: Lint Commit Messages 2 | on: [pull_request] 3 | 4 | jobs: 5 | commitlint: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | with: 10 | fetch-depth: 0 11 | - uses: wagoid/commitlint-github-action@v5 12 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | name: release-please 11 | 12 | jobs: 13 | release-please: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: google-github-actions/release-please-action@v4 17 | id: release 18 | with: 19 | release-type: node 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | - uses: oven-sh/setup-bun@v1 22 | - uses: actions/checkout@v4 23 | - run: bun install 24 | - run: bun run build 25 | - run: npm publish 26 | env: 27 | NPM_TOKEN: ${{secrets.NPM_TOKEN}} 28 | if: ${{ steps.release.outputs.release_created }} 29 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | with: 11 | fetch-depth: 0 12 | - uses: oven-sh/setup-bun@v1 13 | - name: Install dependencies 14 | run: bun install 15 | - name: Run tests 16 | run: bun test 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | src/ 3 | tests/ 4 | images/ 5 | .github/ -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NPM_TOKEN} -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.2.0](https://github.com/ksassnowski/vueclid/compare/v1.1.1...v1.2.0) (2024-02-28) 4 | 5 | 6 | ### Features 7 | 8 | * add option to draw labels for angles and arcs ([3c88067](https://github.com/ksassnowski/vueclid/commit/3c8806705089b60ad451de2e6249453c089efef9)) 9 | * add option to draw labels without border ([323baaa](https://github.com/ksassnowski/vueclid/commit/323baaa42301ef7529333a5e467e2bbbae1d4e83)) 10 | * allow labels to be rotated ([9dd32f4](https://github.com/ksassnowski/vueclid/commit/9dd32f4b2fb99480ce8c1abeade64be3736f02e2)) 11 | 12 | ## [1.1.1](https://github.com/ksassnowski/vueclid/compare/v1.1.0...v1.1.1) (2024-02-22) 13 | 14 | ### Bug Fixes 15 | 16 | - remove small gaps in function plot ([a960411](https://github.com/ksassnowski/vueclid/commit/a9604111d64c306f840769b8dc18617274796566)) 17 | - scale arrow correctly for short vectors ([635e936](https://github.com/ksassnowski/vueclid/commit/635e936ac8c83869ee502d9b97a777846bb72a96)) 18 | 19 | ## [1.1.0](https://github.com/ksassnowski/vueclid/compare/v1.0.2...v1.1.0) (2024-01-30) 20 | 21 | ### Features 22 | 23 | - add rotation parameter to ellipse ([fa1bb9d](https://github.com/ksassnowski/vueclid/commit/fa1bb9ddba99b01991708d564052fb3872ed98a3)) 24 | 25 | ## [1.0.2](https://github.com/ksassnowski/vueclid/compare/v1.0.1...v1.0.2) (2024-01-30) 26 | 27 | ### Bug Fixes 28 | 29 | - fix typescript type declarations (maybe) ([11eeec4](https://github.com/ksassnowski/vueclid/commit/11eeec4badf01098ff2326eb4f6c1334805980f9)) 30 | 31 | ## [1.0.1](https://github.com/ksassnowski/vueclid/compare/v1.0.0...v1.0.1) (2024-01-30) 32 | 33 | ### Bug Fixes 34 | 35 | - point typescript to correct declaration file ([641877e](https://github.com/ksassnowski/vueclid/commit/641877e6aa351bb6b5fe99d2ae8b955e1f0212a3)) 36 | 37 | ## [1.0.0](https://github.com/ksassnowski/vueclid/compare/v0.1.0...v1.0.0) (2024-01-30) 38 | 39 | ### Features 40 | 41 | - allow vector to be constructed from a single value ([f07be5f](https://github.com/ksassnowski/vueclid/commit/f07be5f1a05aa8fcf4980e5310a7d44adfd06510)) 42 | 43 | ### Miscellaneous Chores 44 | 45 | - release 1.0.0 ([e0f73bf](https://github.com/ksassnowski/vueclid/commit/e0f73bfc2243ac8f5b889a2d7ba3f3eff12575c2)) 46 | 47 | ## [0.1.0](https://github.com/ksassnowski/vueclid/compare/v0.0.7...v0.1.0) (2024-01-30) 48 | 49 | ### Features 50 | 51 | - add configuration function ([#21](https://github.com/ksassnowski/vueclid/issues/21)) ([aa5d554](https://github.com/ksassnowski/vueclid/commit/aa5d5546e91867d4a86ce40d4723a9b89aa3b02b)) 52 | - make diagrams responsive ([#20](https://github.com/ksassnowski/vueclid/issues/20)) ([2b96c7c](https://github.com/ksassnowski/vueclid/commit/2b96c7c2d0e7149547de8c87ab4c6dfaec0ac331)) 53 | - make setColors reactive ([9a0275e](https://github.com/ksassnowski/vueclid/commit/9a0275e0cd54aeff541c972f0914e57cd87b8855)) 54 | 55 | ## [0.0.7](https://github.com/ksassnowski/vueclid/compare/v0.0.6...v0.0.7) (2024-01-29) 56 | 57 | ### Bug Fixes 58 | 59 | - remove path alias ([acea770](https://github.com/ksassnowski/vueclid/commit/acea770ea5a54772299f4a8e0aee885bbe1574eb)) 60 | 61 | ## [0.0.6](https://github.com/ksassnowski/vueclid/compare/v0.0.5...v0.0.6) (2024-01-29) 62 | 63 | ### Bug Fixes 64 | 65 | - export component types properly ([07deefe](https://github.com/ksassnowski/vueclid/commit/07deefe8f0c648a230ad3a1a0d7a6625e4e73b64)) 66 | 67 | ## [0.0.5](https://github.com/ksassnowski/vueclid/compare/v0.0.4...v0.0.5) (2024-01-29) 68 | 69 | ### Bug Fixes 70 | 71 | - use correct name for type declarations file ([d688f8a](https://github.com/ksassnowski/vueclid/commit/d688f8af70a0f6677b3bdfcb2158956c822138b3)) 72 | 73 | ## [0.0.4](https://github.com/ksassnowski/vueclid/compare/v0.0.3...v0.0.4) (2024-01-29) 74 | 75 | ### Bug Fixes 76 | 77 | - generate type declaration file in correct location ([f7fb5b2](https://github.com/ksassnowski/vueclid/commit/f7fb5b2b81962a4e92e16759dc76598329accf02)) 78 | 79 | ### Miscellaneous Chores 80 | 81 | - release 0.0.3 ([e2733bb](https://github.com/ksassnowski/vueclid/commit/e2733bbf7c66d3b0288b1f2ce039369d94aa7da6)) 82 | - release 0.0.4 ([0c4cc7a](https://github.com/ksassnowski/vueclid/commit/0c4cc7a74482e675bc2d590edbe99023db6ea72e)) 83 | 84 | ## Changelog 85 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright <2024> 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |

6 | Vueclid 7 |

8 | 9 |

10 | 11 |

12 | 13 |

14 | Pixel-perfect math diagrams for Vue.js. 15 |

16 | 17 |

18 | Documentation 19 |

20 | 21 | ## Installation 22 | 23 | ```bash 24 | npm install @ksassnowski/vueclid 25 | ``` 26 | 27 | ## Credits 28 | 29 | - [Kai Sassnowski](https://github.com/ksassnowski) 30 | - [All contributors](https://github.com/ksassnowski/vueclid/contributors) 31 | 32 | ## License 33 | 34 | MIT 35 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ksassnowski/vueclid/0111623bead813d9c24bf3110cbd298da24a0c41/bun.lockb -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | preload = "./tests/setup.ts" -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ksassnowski/vueclid/0111623bead813d9c24bf3110cbd298da24a0c41/images/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ksassnowski/vueclid", 3 | "version": "1.2.0", 4 | "type": "module", 5 | "main": "./dist/vueclid.umd.js", 6 | "module": "./dist/vueclid.js", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "files": [ 11 | "dist/**/*" 12 | ], 13 | "license": "MIT", 14 | "exports": { 15 | ".": { 16 | "import": "./dist/vueclid.js", 17 | "require": "./dist/vueclid.umd.cjs", 18 | "types": "./dist/index.d.ts" 19 | } 20 | }, 21 | "types": "./dist/index.d.ts", 22 | "scripts": { 23 | "build": "vite build && vue-tsc --emitDeclarationOnly" 24 | }, 25 | "devDependencies": { 26 | "@happy-dom/global-registrator": "^13.4.1", 27 | "@types/node": "^20.11.5", 28 | "@vitejs/plugin-vue": "^4.5.2", 29 | "prettier": "^3.2.4", 30 | "typescript": "^5.2.2", 31 | "vite": "^5.0.8", 32 | "vue": "^3.3.11", 33 | "vue-tsc": "^1.8.25" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/Angle.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 116 | -------------------------------------------------------------------------------- /src/components/Arc.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 82 | -------------------------------------------------------------------------------- /src/components/Circle.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 64 | -------------------------------------------------------------------------------- /src/components/Ellipse.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 77 | -------------------------------------------------------------------------------- /src/components/FunctionPlot.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 119 | -------------------------------------------------------------------------------- /src/components/Graph.vue: -------------------------------------------------------------------------------- 1 | 117 | 118 | 240 | -------------------------------------------------------------------------------- /src/components/Group.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /src/components/Label.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 102 | -------------------------------------------------------------------------------- /src/components/Line.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 127 | -------------------------------------------------------------------------------- /src/components/Point.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 97 | -------------------------------------------------------------------------------- /src/components/PolyLine.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 65 | -------------------------------------------------------------------------------- /src/components/Polygon.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 88 | -------------------------------------------------------------------------------- /src/components/Sector.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 97 | -------------------------------------------------------------------------------- /src/components/Vector.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 116 | -------------------------------------------------------------------------------- /src/composables/useColors.ts: -------------------------------------------------------------------------------- 1 | import { computed, reactive, readonly, Ref, ref, unref } from "vue"; 2 | import { Color } from "../types.ts"; 3 | 4 | export interface ColorScheme { 5 | grid: string; 6 | units: string; 7 | axis: string; 8 | stroke: string; 9 | text: string; 10 | labelBackground: string; 11 | points: string; 12 | } 13 | type Colors = { light: ColorScheme; dark: ColorScheme }; 14 | 15 | export const colorScheme: Colors = reactive({ 16 | light: { 17 | grid: "#ccc", 18 | units: "#aaa", 19 | axis: "#ccc", 20 | stroke: "#000", 21 | text: "#000", 22 | labelBackground: "#ffffffcc", 23 | points: "#000", 24 | }, 25 | dark: { 26 | grid: "#646262", 27 | units: "#727171", 28 | axis: "#6f6f6f", 29 | stroke: "#f1f1f1", 30 | text: "#f1f1f1", 31 | labelBackground: "#222222cc", 32 | points: "#f1f1f1", 33 | }, 34 | }); 35 | export const darkMode = ref(false); 36 | const colors = computed(() => 37 | darkMode.value ? colorScheme.dark : colorScheme.light, 38 | ); 39 | 40 | function parseColor( 41 | color: Color | Ref | undefined, 42 | fallback?: keyof ColorScheme, 43 | ) { 44 | return computed(() => { 45 | const value = unref(color); 46 | 47 | if (typeof value === "string") { 48 | return value; 49 | } 50 | 51 | if (value === undefined) { 52 | if (fallback === undefined) { 53 | return "none"; 54 | } 55 | return colors.value[fallback]; 56 | } 57 | 58 | return darkMode.value ? value.dark : value.light; 59 | }); 60 | } 61 | 62 | export function useColors() { 63 | return { 64 | parseColor, 65 | colors: readonly(colors), 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /src/composables/useGraphContext.ts: -------------------------------------------------------------------------------- 1 | import { inject } from "vue"; 2 | import { graphContext } from "../types.ts"; 3 | 4 | export function useGraphContext() { 5 | const context = inject(graphContext); 6 | 7 | if (!context) { 8 | throw new Error("Component is missing a parent component."); 9 | } 10 | 11 | return context; 12 | } 13 | -------------------------------------------------------------------------------- /src/composables/useMatrices.ts: -------------------------------------------------------------------------------- 1 | import { PossibleVector2, Vector2 } from "../utils/Vector2.ts"; 2 | import { Ref, computed, inject, provide, unref } from "vue"; 3 | import { 4 | cameraMatrixKey, 5 | parentToWorld as parentToWorldKey, 6 | } from "../types.ts"; 7 | import { Matrix2D } from "../utils/Matrix2D.ts"; 8 | 9 | export function useMatrices( 10 | localPosition: PossibleVector2 | Ref = new Vector2(), 11 | ) { 12 | const parentToWorld = inject( 13 | parentToWorldKey, 14 | computed(() => new Matrix2D()), 15 | ); 16 | const parentToCamera = inject( 17 | cameraMatrixKey, 18 | computed(() => new Matrix2D()), 19 | ); 20 | 21 | const cameraMatrix = computed(() => { 22 | const transform = new Matrix2D().translate( 23 | Vector2.wrap(unref(localPosition)), 24 | ); 25 | return parentToCamera.value.multiply(transform); 26 | }); 27 | 28 | const cameraPosition = computed(() => 29 | Vector2.wrap(unref(localPosition)).transform(parentToCamera.value), 30 | ); 31 | 32 | const localToWorld = computed(() => { 33 | const position = Vector2.wrap(unref(localPosition)); 34 | const transform = new Matrix2D().translate([position.x, position.y]); 35 | return parentToWorld.value.multiply(transform); 36 | }); 37 | 38 | provide(parentToWorldKey, localToWorld); 39 | provide(cameraMatrixKey, cameraMatrix); 40 | 41 | return { parentToWorld, cameraMatrix, cameraPosition }; 42 | } 43 | -------------------------------------------------------------------------------- /src/composables/usePointerIntersection.ts: -------------------------------------------------------------------------------- 1 | import { Vector2 } from "src"; 2 | import { useGraphContext } from "./useGraphContext"; 3 | import { Ref, watch } from "vue"; 4 | 5 | export type IntersectionFn = (pointer: Vector2) => boolean; 6 | 7 | export function usePointerIntersection( 8 | ref: Ref, 9 | intersectionFn: IntersectionFn, 10 | ) { 11 | const { cursor } = useGraphContext(); 12 | 13 | watch(cursor, (position: Vector2 | null) => { 14 | if (!position) { 15 | ref.value = false; 16 | return; 17 | } 18 | ref.value = intersectionFn(position); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Angle } from "./components/Angle.vue"; 2 | export { default as Arc } from "./components/Arc.vue"; 3 | export { default as Circle } from "./components/Circle.vue"; 4 | export { default as Ellipse } from "./components/Ellipse.vue"; 5 | export { default as FunctionPlot } from "./components/FunctionPlot.vue"; 6 | export { default as Graph } from "./components/Graph.vue"; 7 | export { default as Group } from "./components/Group.vue"; 8 | export { default as Label } from "./components/Label.vue"; 9 | export { default as Line } from "./components/Line.vue"; 10 | export { default as Point } from "./components/Point.vue"; 11 | export { default as Polygon } from "./components/Polygon.vue"; 12 | export { default as PolyLine } from "./components/PolyLine.vue"; 13 | export { default as Sector } from "./components/Sector.vue"; 14 | export { default as Vector } from "./components/Vector.vue"; 15 | export { Vector2 } from "./utils/Vector2"; 16 | export { Matrix2D } from "./utils/Matrix2D"; 17 | export { useColors } from "./composables/useColors"; 18 | export { configureGraphs } from "./utils/configureGraphs"; 19 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { ComputedRef, InjectionKey } from "vue"; 2 | import { Vector2 } from "./utils/Vector2.ts"; 3 | import { Matrix2D } from "./utils/Matrix2D.ts"; 4 | 5 | export type Color = string | { light: string; dark: string }; 6 | 7 | export const graphContext = Symbol() as InjectionKey<{ 8 | size: ComputedRef; 9 | scale: ComputedRef; 10 | invScale: ComputedRef; 11 | origin: ComputedRef; 12 | cursor: ComputedRef; 13 | offset: ComputedRef; 14 | domain: ComputedRef<{ x: Vector2; y: Vector2 }>; 15 | matrix: ComputedRef; 16 | }>; 17 | 18 | export const parentToWorld = Symbol() as InjectionKey>; 19 | export const cameraMatrixKey = Symbol() as InjectionKey>; 20 | -------------------------------------------------------------------------------- /src/utils/Matrix2D.ts: -------------------------------------------------------------------------------- 1 | import { PossibleVector2, Vector2 } from "./Vector2.ts"; 2 | 3 | export type PossibleMatrix2D = 4 | | number 5 | | Matrix2D 6 | | [number, number, number, number, number, number] 7 | | { a: number; b: number; c: number; d: number; tx: number; ty: number } 8 | | DOMMatrix; 9 | 10 | /** 11 | * An optimized 2x3 matrix for 2D transformations. 12 | */ 13 | export class Matrix2D { 14 | private _a: number = 1; 15 | private _b: number = 0; 16 | private _c: number = 0; 17 | private _d: number = 1; 18 | private _tx: number = 0; 19 | private _ty: number = 0; 20 | 21 | public static get identity() { 22 | return new Matrix2D(); 23 | } 24 | 25 | public static get zero() { 26 | return new Matrix2D(0, 0, 0, 0, 0, 0); 27 | } 28 | 29 | public get isIdentity() { 30 | return ( 31 | this._a === 1 && 32 | this._b === 0 && 33 | this._c === 0 && 34 | this._d === 1 && 35 | this._tx === 0 && 36 | this._ty === 0 37 | ); 38 | } 39 | 40 | public get a() { 41 | return this._a; 42 | } 43 | 44 | public get b() { 45 | return this._b; 46 | } 47 | 48 | public get c() { 49 | return this._c; 50 | } 51 | 52 | public get d() { 53 | return this._d; 54 | } 55 | 56 | public get tx() { 57 | return this._tx; 58 | } 59 | 60 | public get ty() { 61 | return this._ty; 62 | } 63 | 64 | public get inverse(): Matrix2D { 65 | const { _a, _b, _c, _d, _tx, _ty } = this; 66 | const det = this.determinant; 67 | if (det === 0) { 68 | return new Matrix2D(); 69 | } 70 | return new Matrix2D( 71 | _d / det, 72 | -_b / det, 73 | -_c / det, 74 | _a / det, 75 | (_c * _ty - _d * _tx) / det, 76 | (_b * _tx - _a * _ty) / det, 77 | ); 78 | } 79 | 80 | public get determinant(): number { 81 | return this._a * this._d - this._b * this._c; 82 | } 83 | 84 | public constructor(); 85 | public constructor( 86 | a: number, 87 | b: number, 88 | c: number, 89 | d: number, 90 | tx: number, 91 | ty: number, 92 | ); 93 | public constructor(value?: PossibleMatrix2D); 94 | public constructor(a?: any, b?: any, c?: any, d?: any, tx?: any, ty?: any) { 95 | if (a === undefined) { 96 | return; 97 | } 98 | 99 | if (Array.isArray(a)) { 100 | this._a = a[0]; 101 | this._b = a[1]; 102 | this._c = a[2]; 103 | this._d = a[3]; 104 | this._tx = a[4]; 105 | this._ty = a[5]; 106 | return; 107 | } 108 | 109 | if (typeof a === "number") { 110 | if (typeof b === "number") { 111 | this._a = a; 112 | this._b = b; 113 | this._c = c; 114 | this._d = d; 115 | this._tx = tx; 116 | this._ty = ty; 117 | } else { 118 | this._a = a; 119 | this._b = a; 120 | this._c = a; 121 | this._d = a; 122 | this._tx = a; 123 | this._ty = a; 124 | } 125 | return; 126 | } 127 | 128 | if (a instanceof DOMMatrix) { 129 | this._a = a.a; 130 | this._b = a.b; 131 | this._c = a.c; 132 | this._d = a.d; 133 | this._tx = a.e; 134 | this._ty = a.f; 135 | return; 136 | } 137 | 138 | if (typeof a === "object") { 139 | this._a = a.a; 140 | this._b = a.b; 141 | this._c = a.c; 142 | this._d = a.d; 143 | this._tx = a.tx; 144 | this._ty = a.ty; 145 | return; 146 | } 147 | } 148 | 149 | public translate(by: PossibleVector2) { 150 | const v = Vector2.wrap(by); 151 | return new Matrix2D( 152 | this._a, 153 | this._b, 154 | this._c, 155 | this._d, 156 | this._tx + v.x, 157 | this._ty + v.y, 158 | ); 159 | } 160 | 161 | public scale(by: PossibleVector2) { 162 | const v = Vector2.wrap(by); 163 | return new Matrix2D( 164 | this._a * v.x, 165 | this._b * v.x, 166 | this._c * v.y, 167 | this._d * v.y, 168 | this._tx, 169 | this._ty, 170 | ); 171 | } 172 | 173 | public rotate(angle: number) { 174 | const cos = Math.cos(angle); 175 | const sin = Math.sin(angle); 176 | return new Matrix2D( 177 | this._a * cos - this._b * sin, 178 | this._a * sin + this._b * cos, 179 | this._c * cos - this._d * sin, 180 | this._c * sin + this._d * cos, 181 | this._tx, 182 | this._ty, 183 | ); 184 | } 185 | 186 | public multiply(other: Matrix2D) { 187 | const { _a, _b, _c, _d, _tx, _ty } = this; 188 | 189 | return new Matrix2D( 190 | _a * other._a + _c * other._b, 191 | _b * other._a + _d * other._b, 192 | _a * other._c + _c * other._d, 193 | _b * other._c + _d * other._d, 194 | _a * other._tx + _c * other._ty + _tx, 195 | _b * other._tx + _d * other._ty + _ty, 196 | ); 197 | } 198 | 199 | public equals(other: Matrix2D, threshold: number = 0.000001) { 200 | return ( 201 | Math.abs(this._a - other._a) <= threshold + Number.EPSILON && 202 | Math.abs(this._b - other._b) <= threshold + Number.EPSILON && 203 | Math.abs(this._c - other._c) <= threshold + Number.EPSILON && 204 | Math.abs(this._d - other._d) <= threshold + Number.EPSILON && 205 | Math.abs(this._tx - other._tx) <= threshold + Number.EPSILON && 206 | Math.abs(this._ty - other._ty) <= threshold + Number.EPSILON 207 | ); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/utils/Vector2.ts: -------------------------------------------------------------------------------- 1 | import { Matrix2D } from "./Matrix2D"; 2 | import { TAU } from "./constants"; 3 | 4 | export type PossibleVector2 = 5 | | number 6 | | Vector2 7 | | [number, number] 8 | | { width: number; height: number } 9 | | { x: number; y: number }; 10 | 11 | const RAD2DEG = 180 / Math.PI; 12 | 13 | /** 14 | * A 2D vector. 15 | */ 16 | export class Vector2 { 17 | /** 18 | * The x component of the vector. 19 | */ 20 | public x: number; 21 | 22 | /** 23 | * The y component of the vector. 24 | */ 25 | public y: number; 26 | 27 | /** 28 | * Wraps a value in a vector. 29 | * 30 | * If the value is already a vector, it is returned unchanged. 31 | * 32 | * @param value The value to wrap. 33 | */ 34 | public static wrap(value: PossibleVector2): Vector2 { 35 | if (value instanceof Vector2) { 36 | return value; 37 | } 38 | return new Vector2(value); 39 | } 40 | 41 | /** 42 | * Creates a vector with length 1 from an angle with the x-axis. 43 | * 44 | * @param angle The angle of the vector with the x-axis in radians. 45 | */ 46 | public static fromAngle(angle: number) { 47 | return new Vector2(Math.cos(angle), Math.sin(angle)); 48 | } 49 | 50 | /** 51 | * Creates a vector from polar coordinates. 52 | * 53 | * @param angle The angle of the vector with the x-axis in radians. 54 | * @param r The magnitude of the vector. 55 | */ 56 | public static fromPolar(angle: number, r: number = 1) { 57 | return new Vector2(r * Math.cos(angle), r * Math.sin(angle)); 58 | } 59 | 60 | /** 61 | * An alias for the x component of the vector. 62 | */ 63 | public get width() { 64 | return this.x; 65 | } 66 | 67 | public set width(value: number) { 68 | this.x = value; 69 | } 70 | 71 | /** 72 | * An alias for the y component of the vector. 73 | */ 74 | public get height() { 75 | return this.y; 76 | } 77 | 78 | public set height(value: number) { 79 | this.y = value; 80 | } 81 | 82 | /** 83 | * Returns the angle of the vector with the x-axis in radians. 84 | */ 85 | public get angle() { 86 | return Math.atan2(this.y, this.x); 87 | } 88 | 89 | /** 90 | * Returns the angle of the vector with the x-axis in degrees. 91 | */ 92 | public get angleDegrees() { 93 | return this.angle * RAD2DEG; 94 | } 95 | 96 | /** 97 | * Returns the slope of the vector. 98 | */ 99 | public get slope() { 100 | return this.y / this.x; 101 | } 102 | 103 | constructor(); 104 | constructor(xy: number); 105 | constructor(x: number, y: number); 106 | constructor(possibleVector: PossibleVector2); 107 | constructor(x?: any, y?: any) { 108 | if (typeof x === "number") { 109 | this.x = x; 110 | this.y = x; 111 | if (typeof y === "number") { 112 | this.y = y; 113 | } 114 | return; 115 | } 116 | 117 | if (Array.isArray(x)) { 118 | this.x = x[0]; 119 | this.y = x[1]; 120 | return; 121 | } 122 | 123 | if (typeof x === "object") { 124 | this.x = x.x ?? x.width; 125 | this.y = x.y ?? x.height; 126 | return; 127 | } 128 | 129 | this.x = 0; 130 | this.y = 0; 131 | } 132 | 133 | /** 134 | * Returns the sum of this vector and the other. 135 | * 136 | * @param vector - The other vector. 137 | */ 138 | public add(vector: PossibleVector2) { 139 | const other = Vector2.wrap(vector); 140 | return new Vector2(this.x + other.x, this.y + other.y); 141 | } 142 | 143 | /** 144 | * Returns the difference between this vector and the other. 145 | * 146 | * @param vector - The other vector. 147 | */ 148 | public sub(vector: PossibleVector2) { 149 | const other = Vector2.wrap(vector); 150 | return new Vector2(this.x - other.x, this.y - other.y); 151 | } 152 | 153 | /** 154 | * Multiplies each component of the vector by the corresponding component of 155 | * the other vector. 156 | * 157 | * @param vector - The other vector. 158 | */ 159 | public mul(vector: PossibleVector2) { 160 | const other = Vector2.wrap(vector); 161 | return new Vector2(this.x * other.x, this.y * other.y); 162 | } 163 | 164 | /** 165 | * Divides each component of the vector by the corresponding component of the 166 | * other vector. 167 | * 168 | * @param vector - The other vector. 169 | */ 170 | public div(vector: PossibleVector2) { 171 | const other = new Vector2(vector); 172 | return new Vector2(this.x / other.x, this.y / other.y); 173 | } 174 | 175 | /** 176 | * Returns the dot product of this vector and another. 177 | * 178 | * @param vector - The other vector. 179 | */ 180 | public dot(vector: PossibleVector2) { 181 | const other = new Vector2(vector); 182 | return this.x * other.x + this.y * other.y; 183 | } 184 | 185 | /** 186 | * Scales both components of the vector by the given scalar. 187 | * 188 | * @param scalar The scalar to scale by. 189 | */ 190 | public scale(scalar: number) { 191 | return new Vector2(this.x * scalar, this.y * scalar); 192 | } 193 | 194 | /** 195 | * Returns the magnitude of the vector. 196 | */ 197 | public length() { 198 | return Math.sqrt(this.x * this.x + this.y * this.y); 199 | } 200 | 201 | public squaredLength() { 202 | return this.x * this.x + this.y * this.y; 203 | } 204 | 205 | /** 206 | * Returns a vector with the same direction but a magnitude of 1. 207 | */ 208 | public normalized() { 209 | return new Vector2(this.x / this.length(), this.y / this.length()); 210 | } 211 | 212 | /** 213 | * Returns a vector with the same magnitude but opposite direction. 214 | */ 215 | public negate() { 216 | return new Vector2(-this.x, -this.y); 217 | } 218 | 219 | /** 220 | * Returns a vector perpendicular to this one. 221 | */ 222 | public perpendicular() { 223 | return new Vector2(this.y, -this.x); 224 | } 225 | 226 | /** 227 | * Rotates the vector by the given angle around the origin. 228 | * 229 | * @param angle - The angle to rotate by in radians. 230 | */ 231 | public rotate(angle: number) { 232 | const cos = Math.cos(angle); 233 | const sin = Math.sin(angle); 234 | return new Vector2( 235 | this.x * cos - this.y * sin, 236 | this.x * sin + this.y * cos, 237 | ); 238 | } 239 | 240 | /** 241 | * Sets the angle of the vector with the x-axis. 242 | * 243 | * This method mutates the vector. 244 | * 245 | * @param angle - The angle between the vector and the x-axis in radians. 246 | */ 247 | public setAngle(angle: number) { 248 | const length = this.length(); 249 | this.x = length * Math.cos(angle); 250 | this.y = length * Math.sin(angle); 251 | } 252 | 253 | /** 254 | * Compute the angle between this vector and the given vector. 255 | * The returned angle will be in the range [-Math.PI, Math.PI]. 256 | * 257 | * @param vector - The other vector. 258 | */ 259 | public angleBetween(vector: PossibleVector2) { 260 | return Math.acos( 261 | this.dot(vector) / (this.length() * Vector2.wrap(vector).length()), 262 | ); 263 | } 264 | 265 | /** 266 | * Compute the clockwise angle from this vector to a given other 267 | * vector. The angle will be in the range [0, Math.PI * 2[. 268 | * 269 | * @param vector - The target vector. 270 | */ 271 | public clockwiseAngleTo(vector: PossibleVector2) { 272 | const target = Vector2.wrap(vector); 273 | const dot = this.dot(target); 274 | const determinant = this.x * target.y - this.y * target.x; 275 | return (Math.atan2(determinant, dot) + TAU) % TAU; 276 | } 277 | 278 | /** 279 | * Calculate the distance between this vector and another. 280 | * 281 | * @param vector - The other vector. 282 | */ 283 | public distanceTo(vector: PossibleVector2) { 284 | return this.sub(Vector2.wrap(vector)).length(); 285 | } 286 | 287 | /** 288 | * Calculate the squared distance between this vector and another. 289 | * 290 | * @param vector - The other vector. 291 | */ 292 | public squaredDistanceTo(vector: PossibleVector2) { 293 | return this.sub(Vector2.wrap(vector)).squaredLength(); 294 | } 295 | 296 | /** 297 | * Calculates the 2D vector cross product between this vector and 298 | * the provided vector. 299 | * 300 | * @remarks 301 | * The 2D cross product is defined as a.y * b.x - a.x * b.y 302 | * 303 | * @param vector - The other vector. 304 | */ 305 | public cross(vector: PossibleVector2) { 306 | const other = Vector2.wrap(vector); 307 | return this.y * other.x - this.x * other.y; 308 | } 309 | 310 | /** 311 | * Apply the provided matrix to this vector. 312 | * 313 | * @param matrix - The transformation matrix. 314 | */ 315 | public transform(matrix: Matrix2D): Vector2 { 316 | return new Vector2( 317 | this.x * matrix.a + this.y * matrix.c + matrix.tx, 318 | this.x * matrix.b + this.y * matrix.d + matrix.ty, 319 | ); 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /src/utils/configureGraphs.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ColorScheme, 3 | colorScheme, 4 | darkMode, 5 | } from "../composables/useColors"; 6 | 7 | interface GraphOptions { 8 | darkMode?: boolean; 9 | colors?: { 10 | light?: Partial; 11 | dark?: Partial; 12 | }; 13 | } 14 | 15 | export function configureGraphs(options: GraphOptions) { 16 | if (options.colors) { 17 | colorScheme.light = { ...colorScheme.light, ...options.colors.light }; 18 | colorScheme.dark = { ...colorScheme.dark, ...options.colors.dark }; 19 | } 20 | 21 | if (options.darkMode !== undefined) { 22 | darkMode.value = options.darkMode; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const TAU = 2 * Math.PI; 2 | export const HALF_PI = Math.PI / 2; 3 | export const RAD2DEG = 180 / Math.PI; 4 | export const DEG2RAD = Math.PI / 180; 5 | -------------------------------------------------------------------------------- /src/utils/geometry.ts: -------------------------------------------------------------------------------- 1 | import { Vector2 } from "./Vector2"; 2 | import { TAU } from "./constants"; 3 | 4 | function isAngleBetweenAngles( 5 | angle: number, 6 | theta1: number, 7 | theta2: number, 8 | ): boolean { 9 | theta1 = (theta1 + TAU) % TAU; 10 | theta2 = (theta2 + TAU) % TAU; 11 | angle = (angle + TAU) % TAU; 12 | 13 | if (theta2 === 0) { 14 | theta2 = TAU; 15 | } 16 | 17 | if (theta1 < theta2) { 18 | return theta1 <= angle && angle < theta2; 19 | } 20 | 21 | return angle >= theta1 || angle < theta2; 22 | } 23 | 24 | function circularDifference(alpha: number, beta: number): number { 25 | alpha = (alpha + TAU) % TAU; 26 | beta = (beta + TAU) % TAU; 27 | return Math.min((beta - alpha + TAU) % TAU, (alpha - beta + TAU) % TAU); 28 | } 29 | 30 | function findClosestAngleToAngle( 31 | angle: number, 32 | alpha: number, 33 | beta: number, 34 | ): number { 35 | const diff1 = circularDifference(angle, alpha); 36 | const diff2 = circularDifference(angle, beta); 37 | return diff1 < diff2 ? alpha : beta; 38 | } 39 | 40 | /** 41 | * Checks if the given point is inside the circle with 42 | * the given center and radius. 43 | * 44 | * @param center - The center of the circle. 45 | * @param radius - The radius of the circle. 46 | * @param point - The point to check. 47 | */ 48 | export function pointInsideCircle( 49 | center: Vector2, 50 | radius: number, 51 | point: Vector2, 52 | ): boolean { 53 | return center.distanceTo(point) <= radius; 54 | } 55 | 56 | /** 57 | * Checks if the given point is inside the ellipse at 58 | * the given center with the given major and minor radii. 59 | * 60 | * @param center - The center of the ellipse. 61 | * @param radius - The major and minor radii of the ellipse. 62 | * @param point - The point to check. 63 | */ 64 | export function pointInsideEllipse( 65 | center: Vector2, 66 | radius: Vector2, 67 | point: Vector2, 68 | ): boolean { 69 | return ( 70 | (point.x - center.x) ** 2 / radius.x ** 2 + 71 | (point.y - center.y) ** 2 / radius.y ** 2 <= 72 | 1 73 | ); 74 | } 75 | 76 | /** 77 | * Check if the given point is inside the sector defined 78 | * by points a, b, and c with the given radius. 79 | * 80 | * @param a - The first vertex of the sector. 81 | * @param b - The center vertex of the sector. 82 | * @param c - The second vertex of the sector. 83 | * @param radius - The radius of the sector. 84 | * @param point - The point to check. 85 | */ 86 | export function pointInsideSector( 87 | a: Vector2, 88 | b: Vector2, 89 | c: Vector2, 90 | radius: number, 91 | point: Vector2, 92 | ): boolean { 93 | const d = point.distanceTo(b); 94 | 95 | if (d >= radius) { 96 | return false; 97 | } 98 | 99 | const bToA = a.sub(b); 100 | const bToC = c.sub(b); 101 | const bToPoint = point.sub(b); 102 | const totalAngle = bToA.clockwiseAngleTo(bToC); 103 | const angleToPoint = bToA.clockwiseAngleTo(bToPoint); 104 | 105 | return angleToPoint >= 0 && angleToPoint <= totalAngle; 106 | } 107 | 108 | export function pointInsideRectangle( 109 | center: Vector2, 110 | size: Vector2, 111 | point: Vector2, 112 | ): boolean { 113 | const minX = center.x - size.width / 2; 114 | const maxX = center.x + size.width / 2; 115 | const minY = center.y - size.height / 2; 116 | const maxY = center.y + size.height / 2; 117 | return ( 118 | point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY 119 | ); 120 | } 121 | 122 | /** 123 | * Checks if a point is inside a polygon defined by a list 124 | * of vertices. 125 | * 126 | * @remarks 127 | * This function uses raycasting to determine if the given 128 | * point is inside the polygon or not. It counts how many 129 | * edges a ray cast from the point crosses. If the number 130 | * is even, the point is outside the polygon. If it is odd, 131 | * the point is inside the polygon. 132 | * 133 | * @param vertices - The vertices of the polygon. 134 | * @param point - The point to check. 135 | */ 136 | export function pointInsidePolygon( 137 | vertices: Vector2[], 138 | point: Vector2, 139 | ): boolean { 140 | const p = point; 141 | let isInside = false; 142 | 143 | for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i++) { 144 | const a = vertices[i]; 145 | const b = vertices[j]; 146 | if ( 147 | a.y > p.y !== b.y > p.y && 148 | p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x 149 | ) { 150 | isInside = !isInside; 151 | } 152 | } 153 | 154 | return isInside; 155 | } 156 | 157 | /** 158 | * Calculates the shortest distance from a given point 159 | * to any point on a line segment. 160 | * 161 | * @param a - The first point of the line segment. 162 | * @param b - The second point of the line segment. 163 | * @param point - The point to check. 164 | */ 165 | export function distanceToLineSegment( 166 | a: Vector2, 167 | b: Vector2, 168 | point: Vector2, 169 | ): number { 170 | const lineSegment = b.sub(a); 171 | const squaredLength = lineSegment.squaredLength(); 172 | 173 | // Special case where a == b. In this case, simply return 174 | // the distance from the point to point a. 175 | if (squaredLength === 0) { 176 | return point.distanceTo(a); 177 | } 178 | 179 | let t = point.sub(a).dot(lineSegment) / squaredLength; 180 | t = Math.max(0, Math.min(1, t)); 181 | const projection = a.add(lineSegment.scale(t)); 182 | 183 | return point.distanceTo(projection); 184 | } 185 | 186 | export function distanceToArc( 187 | center: Vector2, 188 | fromAngle: number, 189 | toAngle: number, 190 | radius: number, 191 | point: Vector2, 192 | ): number { 193 | const centerToPoint = point.sub(center); 194 | let angleToPoint = centerToPoint.angle; 195 | 196 | let angle: number; 197 | if (isAngleBetweenAngles(angleToPoint, fromAngle, toAngle)) { 198 | angle = angleToPoint; 199 | } else { 200 | angle = findClosestAngleToAngle(angleToPoint, fromAngle, toAngle); 201 | } 202 | 203 | const pointOnArc = center.add(Vector2.fromPolar(angle, radius)); 204 | return point.distanceTo(pointOnArc); 205 | } 206 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tests/math/Matrix2D.test.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { describe, expect, test } from "bun:test"; 3 | import { Matrix2D, PossibleMatrix2D } from "../../src/utils/Matrix2D"; 4 | 5 | test.each([ 6 | [[1, 0, 0, 1, 0, 0], true], 7 | [[2, 0, 0, 2, 0, 0], false], 8 | ])( 9 | "isIdentity: values = %p -> %p", 10 | (values: PossibleMatrix2D, expected: boolean) => { 11 | const m = new Matrix2D(values); 12 | expect(m.isIdentity).toBe(expected); 13 | }, 14 | ); 15 | 16 | describe("equality", () => { 17 | test.each([ 18 | [[1, 0, 0, 1, 0, 0], [1, 0, 0, 1, 0, 0], true], 19 | [[2, 2, 0, 1, 0, 0], [2, 2, 0, 1, 0, 0], true], 20 | [[1, 0, 0, 1, 0, 0], [2, 0, 0, 2, 0, 0], false], 21 | [[1, 0, 0, 1, 0, 0], [1, 0, 0, 1, 0, 1], false], 22 | ])( 23 | "exactly equal: %p === %p -> %p", 24 | (a: PossibleMatrix2D, b: PossibleMatrix2D, expected: number) => { 25 | const m1 = new Matrix2D(a); 26 | const m2 = new Matrix2D(b); 27 | expect(m1.equals(m2)).toBe(expected); 28 | }, 29 | ); 30 | 31 | test.each([ 32 | [[1.1, 0, 0, 1.1, 0, 0], [1.05, 0, 0, 1.05, 0, 0], 0.05, true], 33 | [[1.1, 0, 0, 1.1, 0, 0], [1.05, 0, 0, 1.05, 0, 0], 0.04, false], 34 | [[1.1, 0, 0, 1.1, 0, 0], [1.099999, 0, 0, 1.099999, 0, 0], undefined, true], 35 | ])("threshold equal: %p === %p -> %p", (a, b, threshold, expected) => { 36 | const m1 = new Matrix2D(a); 37 | const m2 = new Matrix2D(b); 38 | expect(m1.equals(m2, threshold)).toBe(expected); 39 | }); 40 | }); 41 | 42 | describe("constructor", () => { 43 | test("no arguments -> identity", () => { 44 | const m = new Matrix2D(); 45 | expect(m.isIdentity).toBe(true); 46 | }); 47 | 48 | test("static identity constructor", () => { 49 | const m = Matrix2D.identity; 50 | expect(m.isIdentity).toBe(true); 51 | }); 52 | 53 | test("static zero constructor", () => { 54 | const m = Matrix2D.zero; 55 | expect(m.a).toBe(0); 56 | expect(m.b).toBe(0); 57 | expect(m.c).toBe(0); 58 | expect(m.d).toBe(0); 59 | expect(m.tx).toBe(0); 60 | expect(m.ty).toBe(0); 61 | }); 62 | 63 | test("array of values", () => { 64 | const m = new Matrix2D([1, 2, 3, 4, 5, 6]); 65 | expect(m.a).toBe(1); 66 | expect(m.b).toBe(2); 67 | expect(m.c).toBe(3); 68 | expect(m.d).toBe(4); 69 | expect(m.tx).toBe(5); 70 | expect(m.ty).toBe(6); 71 | }); 72 | 73 | test("individual values", () => { 74 | const m = new Matrix2D(1, 2, 3, 4, 5, 6); 75 | expect(m.a).toBe(1); 76 | expect(m.b).toBe(2); 77 | expect(m.c).toBe(3); 78 | expect(m.d).toBe(4); 79 | expect(m.tx).toBe(5); 80 | expect(m.ty).toBe(6); 81 | }); 82 | 83 | test("single value fills all", () => { 84 | const m = new Matrix2D(2); 85 | expect(m.a).toBe(2); 86 | expect(m.b).toBe(2); 87 | expect(m.c).toBe(2); 88 | expect(m.d).toBe(2); 89 | expect(m.tx).toBe(2); 90 | expect(m.ty).toBe(2); 91 | }); 92 | 93 | test("Matrix2D object", () => { 94 | const other = new Matrix2D(1, 2, 3, 1, 2, 3); 95 | const m = new Matrix2D(other); 96 | expect(m.equals(other)).toBe(true); 97 | }); 98 | 99 | test("{ a, b, c, d, tx, ty }", () => { 100 | const m = new Matrix2D({ a: 1, b: 2, c: 3, d: 4, tx: 5, ty: 6 }); 101 | expect(m.a).toBe(1); 102 | expect(m.b).toBe(2); 103 | expect(m.c).toBe(3); 104 | expect(m.d).toBe(4); 105 | expect(m.tx).toBe(5); 106 | expect(m.ty).toBe(6); 107 | }); 108 | 109 | test("DOMMatrix", () => { 110 | const domMatrix = new DOMMatrix([1, 2, 3, 4, 5, 6]); 111 | const m = new Matrix2D(domMatrix); 112 | expect(m.a).toBe(1); 113 | expect(m.b).toBe(2); 114 | expect(m.c).toBe(3); 115 | expect(m.d).toBe(4); 116 | expect(m.tx).toBe(5); 117 | expect(m.ty).toBe(6); 118 | }); 119 | }); 120 | 121 | test("translate", () => { 122 | const m = new Matrix2D().translate([5, 2]); 123 | expect(m.equals(new Matrix2D([1, 0, 0, 1, 5, 2]))).toBe(true); 124 | }); 125 | 126 | describe("scale", () => { 127 | test("single value scales both axes", () => { 128 | const m = new Matrix2D(1, 2, 3, 4, 5, 6).scale(2); 129 | expect(m.equals(new Matrix2D(2, 4, 6, 8, 5, 6))).toBe(true); 130 | }); 131 | 132 | test("scale axes individually", () => { 133 | const m = new Matrix2D(1, 2, 3, 4, 5, 6).scale([2, 3]); 134 | expect(m.equals(new Matrix2D(2, 4, 9, 12, 5, 6))).toBe(true); 135 | }); 136 | }); 137 | 138 | describe("rotate", () => { 139 | test.each([[Math.PI / 2, [1, 0, 0, 1, 0, 0], [0, 1, -1, 0, 0, 0]]])( 140 | "angle = %p, input = %p -> %p", 141 | (angle: number, matrix: PossibleMatrix2D, expected: number) => { 142 | const m = new Matrix2D(matrix).rotate(angle); 143 | expect(m.equals(new Matrix2D(expected))).toBe(true); 144 | }, 145 | ); 146 | }); 147 | 148 | describe("determinant", () => { 149 | test("identity matrix has determinant 1", () => { 150 | const matrix = Matrix2D.identity; 151 | expect(matrix.determinant).toBe(1); 152 | }); 153 | 154 | test("matrix with identical columns has determinant 0", () => { 155 | const matrix = new Matrix2D(5, 2, 5, 2, 0, 0); 156 | expect(matrix.determinant).toBe(0); 157 | }); 158 | 159 | test.each([ 160 | [[3, 2, 4, 9, 0, 0], 19], 161 | [[1, 2, 3, 4, 0, 0], -2], 162 | [[6, 6, 7, 7, 0, 0], 0], 163 | ])("det(%p) = %p", (matrix: PossibleMatrix2D, expected: number) => { 164 | const m = new Matrix2D(matrix); 165 | expect(m.determinant).toBe(expected); 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /tests/math/Vector2.test.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { describe, expect, test } from "bun:test"; 3 | import { Vector2 } from "../../src"; 4 | 5 | describe("constructor", () => { 6 | test("no arguments -> (0, 0)", () => { 7 | const v = new Vector2(); 8 | expect(v.x).toBe(0); 9 | expect(v.y).toBe(0); 10 | }); 11 | 12 | test("x, y -> (x, y)", () => { 13 | const v = new Vector2(1, 2); 14 | expect(v.x).toBe(1); 15 | expect(v.y).toBe(2); 16 | }); 17 | 18 | test("[x, y] -> (x, y)", () => { 19 | const v = new Vector2([1, 2]); 20 | expect(v.x).toBe(1); 21 | expect(v.y).toBe(2); 22 | }); 23 | 24 | test("{x, y} -> (x, y)", () => { 25 | const v = new Vector2({ x: 1, y: 2 }); 26 | expect(v.x).toBe(1); 27 | expect(v.y).toBe(2); 28 | }); 29 | 30 | test("x -> (x, x)", () => { 31 | const v = new Vector2(new Vector2(1)); 32 | expect(v.x).toBe(1); 33 | expect(v.y).toBe(1); 34 | }); 35 | 36 | test("{width, height} -> (width, height)", () => { 37 | const v = new Vector2({ width: 1, height: 2 }); 38 | expect(v.x).toBe(1); 39 | expect(v.y).toBe(2); 40 | }); 41 | }); 42 | 43 | describe("fromPolar", () => { 44 | test.each([ 45 | [1, 0, [1, 0]], 46 | [2, Math.PI / 2, [0, 2]], 47 | [0.5, Math.PI, [-0.5, 0]], 48 | [1, (Math.PI * 3) / 2, [0, -1]], 49 | [4, Math.PI / 4, [2.83, 2.83]], 50 | ])( 51 | "r = %p, angle = %p -> %p", 52 | (r: number, angle: number, expected: [number, number]) => { 53 | const v = Vector2.fromPolar(angle, r); 54 | expect(v.x).toBeCloseTo(expected[0]); 55 | expect(v.y).toBeCloseTo(expected[1]); 56 | }, 57 | ); 58 | 59 | test("default r", () => { 60 | const v = Vector2.fromPolar(Math.PI / 2); 61 | expect(v.x).toBeCloseTo(0); 62 | expect(v.y).toBeCloseTo(1); 63 | }); 64 | }); 65 | 66 | describe("negate vector", () => { 67 | test.each([ 68 | [ 69 | [1, 2], 70 | [-1, -2], 71 | ], 72 | [ 73 | [4, 5], 74 | [-4, -5], 75 | ], 76 | [ 77 | [-2.5, 4.8], 78 | [2.5, -4.8], 79 | ], 80 | ])("%p -> %p", (vector: [number, number], expected: [number, number]) => { 81 | const a = new Vector2(vector[0], vector[1]); 82 | const b = new Vector2(expected[0], expected[1]); 83 | expect(a.negate()).toEqual(b); 84 | }); 85 | }); 86 | 87 | describe("angleBetween", () => { 88 | test("90deg", () => { 89 | const a = new Vector2(1, 0); 90 | const b = new Vector2(0, 1); 91 | 92 | expect(a.angleBetween(b)).toBeCloseTo(Math.PI / 2); 93 | expect(b.angleBetween(a)).toBeCloseTo(Math.PI / 2); 94 | }); 95 | 96 | test("same vector", () => { 97 | const a = new Vector2(1, 0); 98 | expect(a.angleBetween(a)).toBe(0); 99 | }); 100 | 101 | test("opposite vector", () => { 102 | const a = new Vector2(1, 0); 103 | const b = new Vector2(-1, 0); 104 | expect(a.angleBetween(b)).toBeCloseTo(Math.PI); 105 | expect(b.angleBetween(a)).toBeCloseTo(Math.PI); 106 | }); 107 | 108 | test("45deg", () => { 109 | const a = new Vector2(1, 0); 110 | const b = new Vector2(1, 1); 111 | expect(a.angleBetween(b)).toBeCloseTo(Math.PI / 4); 112 | expect(b.angleBetween(a)).toBeCloseTo(Math.PI / 4); 113 | }); 114 | }); 115 | 116 | describe("distanceTo", () => { 117 | test("same vector", () => { 118 | const a = new Vector2(1, 0); 119 | expect(a.distanceTo(a)).toBe(0); 120 | }); 121 | 122 | test("opposite vector", () => { 123 | const a = new Vector2(1, 0); 124 | const b = new Vector2(-1, 0); 125 | expect(a.distanceTo(b)).toBe(2); 126 | expect(b.distanceTo(a)).toBe(2); 127 | }); 128 | 129 | test("90 deg", () => { 130 | const a = new Vector2(1, 0); 131 | const b = new Vector2(0, 1); 132 | expect(a.distanceTo(b)).toBeCloseTo(Math.sqrt(2)); 133 | expect(b.distanceTo(a)).toBeCloseTo(Math.sqrt(2)); 134 | }); 135 | 136 | test("45 deg", () => { 137 | const a = new Vector2(1, 0); 138 | const b = new Vector2(1, 1); 139 | expect(a.distanceTo(b)).toBe(1); 140 | expect(b.distanceTo(a)).toBe(1); 141 | }); 142 | }); 143 | 144 | describe("normalized", () => { 145 | test.each([ 146 | [ 147 | [5, 0], 148 | [1, 0], 149 | ], 150 | [ 151 | [1, 1], 152 | [1 / Math.sqrt(2), 1 / Math.sqrt(2)], 153 | ], 154 | [ 155 | [-4, 0], 156 | [-1, 0], 157 | ], 158 | [ 159 | [-1, -1], 160 | [-1 / Math.sqrt(2), -1 / Math.sqrt(2)], 161 | ], 162 | ])("%p -> %p", (vector: [number, number], expected: [number, number]) => { 163 | const a = new Vector2(vector[0], vector[1]); 164 | const b = new Vector2(expected[0], expected[1]); 165 | expect(a.normalized()).toEqual(b); 166 | }); 167 | }); 168 | 169 | describe("perpendicular", () => { 170 | test.each([ 171 | [ 172 | [5, 0], 173 | [0, -5], 174 | ], 175 | [ 176 | [1, 1], 177 | [1, -1], 178 | ], 179 | [ 180 | [-4, 0], 181 | [0, 4], 182 | ], 183 | [ 184 | [-1, -1], 185 | [-1, 1], 186 | ], 187 | ])("%p -> %p", (vector: [number, number], expected: [number, number]) => { 188 | const a = new Vector2(vector[0], vector[1]); 189 | const b = new Vector2(expected[0], expected[1]); 190 | expect(a.perpendicular()).toEqual(b); 191 | }); 192 | }); 193 | 194 | describe("scale", () => { 195 | test.each([ 196 | [[1, 2], 3, [3, 6]], 197 | [[4, 5], 6, [24, 30]], 198 | [[-2.5, 4.8], 1.5, [-3.75, 7.2]], 199 | [[3.8, 2.7], 0, [0, 0]], 200 | ])( 201 | "%p * %p -> %p", 202 | (vector: [number, number], scale: number, expected: [number, number]) => { 203 | const a = new Vector2(vector[0], vector[1]).scale(scale); 204 | expect(a.x).toBeCloseTo(expected[0]); 205 | expect(a.y).toBeCloseTo(expected[1]); 206 | }, 207 | ); 208 | }); 209 | 210 | describe("angle", () => { 211 | test.each([ 212 | [[1, 0], 0], 213 | [[0, 1], Math.PI / 2], 214 | [[-1, 0], Math.PI], 215 | [[0, -1], -Math.PI / 2], 216 | [[1, 1], Math.PI / 4], 217 | [[-1, 1], (Math.PI * 3) / 4], 218 | [[-1, -1], (-Math.PI * 3) / 4], 219 | [[1, -1], -Math.PI / 4], 220 | ])("%p -> %p", (vector: [number, number], expected: number) => { 221 | const a = new Vector2(vector[0], vector[1]); 222 | expect(a.angle).toBeCloseTo(expected); 223 | }); 224 | }); 225 | 226 | describe("angleDegrees", () => { 227 | test.each([ 228 | [[1, 0], 0], 229 | [[0, 1], 90], 230 | [[-1, 0], 180], 231 | [[0, -1], -90], 232 | [[1, 1], 45], 233 | [[-1, 1], 135], 234 | [[-1, -1], -135], 235 | [[1, -1], -45], 236 | ])("%p -> %p", (vector: [number, number], expected: number) => { 237 | const a = new Vector2(vector[0], vector[1]); 238 | expect(a.angleDegrees).toBeCloseTo(expected); 239 | }); 240 | }); 241 | 242 | describe("dot", () => { 243 | test.each([ 244 | [[1, 0], [0, 1], 0], 245 | [[3, -2], [4, -1], 14], 246 | [[-5, 2], [3, 4], -7], 247 | [[-2, -1], [-3, -4], 10], 248 | ])( 249 | "%p * %p -> %p", 250 | (a: [number, number], b: [number, number], expected: number) => { 251 | const v1 = new Vector2(a[0], a[1]); 252 | const v2 = new Vector2(b[0], b[1]); 253 | expect(v1.dot(v2)).toBe(expected); 254 | }, 255 | ); 256 | }); 257 | 258 | describe("slope", () => { 259 | test.each([ 260 | [[1, 1], 1], 261 | [[-1, 1], -1], 262 | [[-1, -1], 1], 263 | [[1, -1], -1], 264 | [[3, 2], 2 / 3], 265 | [[-3, 2], -2 / 3], 266 | ])("%p -> %p", (vector: [number, number], expected: number) => { 267 | const a = new Vector2(vector[0], vector[1]); 268 | expect(a.slope).toBeCloseTo(expected); 269 | }); 270 | }); 271 | 272 | describe("aliases", () => { 273 | test("width is an alias for x", () => { 274 | const v = new Vector2(1, 2); 275 | expect(v.width).toBe(1); 276 | 277 | v.x = 2; 278 | expect(v.width).toBe(2); 279 | 280 | v.width = 4; 281 | expect(v.x).toBe(4); 282 | }); 283 | 284 | test("height is an alias for y", () => { 285 | const v = new Vector2(1, 2); 286 | expect(v.height).toBe(2); 287 | 288 | v.y = 3; 289 | expect(v.height).toBe(3); 290 | 291 | v.height = 5; 292 | expect(v.y).toBe(5); 293 | }); 294 | }); 295 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | import { GlobalRegistrator } from "@happy-dom/global-registrator"; 2 | 3 | GlobalRegistrator.register(); 4 | 5 | // @ts-ignore 6 | window.DOMMatrix = class { 7 | public readonly a: number = 1; 8 | public readonly b: number = 1; 9 | public readonly c: number = 1; 10 | public readonly d: number = 1; 11 | public readonly e: number = 1; 12 | public readonly f: number = 1; 13 | 14 | public readonly is2D: boolean = true; 15 | 16 | public get m11() { 17 | return this.a; 18 | } 19 | 20 | public get m12() { 21 | return this.b; 22 | } 23 | 24 | public get m13() { 25 | return 0; 26 | } 27 | 28 | public get m14() { 29 | return 0; 30 | } 31 | 32 | public get m21() { 33 | return this.c; 34 | } 35 | 36 | public get m22() { 37 | return this.d; 38 | } 39 | 40 | public get m23() { 41 | return 0; 42 | } 43 | 44 | public get m24() { 45 | return 0; 46 | } 47 | 48 | public get m31() { 49 | return 0; 50 | } 51 | 52 | public get m32() { 53 | return 0; 54 | } 55 | 56 | public get m33() { 57 | return 1; 58 | } 59 | 60 | public get m41() { 61 | return this.e; 62 | } 63 | 64 | public get m42() { 65 | return this.f; 66 | } 67 | 68 | public get m43() { 69 | return 0; 70 | } 71 | 72 | public get m44() { 73 | return 1; 74 | } 75 | 76 | constructor(values: [number, number, number, number, number, number]) { 77 | this.a = values[0]; 78 | this.b = values[1]; 79 | this.c = values[2]; 80 | this.d = values[3]; 81 | this.e = values[4]; 82 | this.f = values[5]; 83 | } 84 | }; 85 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | "outDir": "dist", 9 | "declaration": true, 10 | "noEmit": false, 11 | 12 | /* Bundler mode */ 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | 25 | "baseUrl": ".", 26 | }, 27 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], 28 | "exclude": ["node_modules", "dist"], 29 | } 30 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { defineConfig } from "vite"; 3 | import vue from "@vitejs/plugin-vue"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | build: { 8 | lib: { 9 | entry: resolve(__dirname, "src/index.ts"), 10 | name: "vueclid", 11 | fileName: "vueclid", 12 | }, 13 | rollupOptions: { 14 | external: ["vue"], 15 | output: { 16 | globals: { 17 | vue: "Vue", 18 | }, 19 | }, 20 | }, 21 | }, 22 | plugins: [vue()], 23 | }); 24 | --------------------------------------------------------------------------------