├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ ├── github-pages.yml │ └── semantic-release.yml ├── .gitignore ├── .gitlab-ci.yml ├── LICENSE ├── README.md ├── demo └── index.html ├── examples ├── example1.png ├── example2.png ├── example3.png └── example4.png ├── fonts ├── OpenSans-Regular.ttf └── arial.ttf ├── package.json ├── rollup.config.ts ├── src ├── constants.ts ├── plugin.ts ├── renderer │ ├── index.ts │ ├── renderer.ts │ ├── roughjs │ │ ├── defs.ts │ │ └── roughjs-renderer.ts │ └── svgjs │ │ └── svg-js-renderer.ts ├── svguitar.ts └── utils │ ├── is-node.ts │ └── range.ts ├── test ├── plugin.test.ts ├── svguitar.test.ts └── testutils.ts ├── tools ├── build-demo.ts └── gh-pages-publish.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | #root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | max_line_length = 100 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: [ 5 | '@typescript-eslint', 6 | ], 7 | extends: [ 8 | 'airbnb-typescript/base', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'prettier', 11 | 'prettier/@typescript-eslint', 12 | ], 13 | parserOptions: { 14 | project: './tsconfig.json', 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /.github/workflows/github-pages.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy Pages 2 | on: 3 | push: 4 | branches: 5 | - master 6 | permissions: 7 | contents: write 8 | jobs: 9 | build-and-deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 🛎️ 13 | uses: actions/checkout@v3 14 | 15 | - name: Install and Build 16 | run: | 17 | yarn install 18 | yarn build 19 | cp -r demo public 20 | cp -r docs public/docs 21 | 22 | - name: Deploy 🚀 23 | uses: JamesIves/github-pages-deploy-action@v4 24 | with: 25 | folder: public 26 | -------------------------------------------------------------------------------- /.github/workflows/semantic-release.yml: -------------------------------------------------------------------------------- 1 | name: Semantic Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - next 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | - run: yarn install 14 | - run: yarn build 15 | - name: Semantic Release 16 | uses: cycjimmy/semantic-release-action@v3 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | test-renders 4 | .nyc_output 5 | .DS_Store 6 | *.log 7 | .vscode 8 | .idea 9 | dist 10 | compiled 11 | .awcache 12 | .rpt2_cache 13 | docs 14 | 15 | # IDE 16 | *.iml 17 | 18 | # Demo 19 | demo/js 20 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: node:16 2 | 3 | before_script: 4 | - yarn install --cache-folder .yarn 5 | 6 | # Cache node modules in between jobs 7 | cache: 8 | key: ${CI_COMMIT_REF_SLUG} 9 | paths: 10 | - .yarn/ 11 | 12 | stages: 13 | - build 14 | - release 15 | - deploy 16 | 17 | build: 18 | stage: build 19 | script: 20 | - yarn install 21 | - yarn test 22 | - yarn build 23 | artifacts: 24 | paths: 25 | - dist 26 | - docs 27 | - demo 28 | expire_in: 1 week 29 | except: 30 | refs: 31 | - /^v\d+\.\d+\.\d+.*$/ 32 | 33 | release-develop: 34 | stage: release 35 | script: 36 | - npx semantic-release 37 | artifacts: 38 | paths: 39 | - CHANGELOG.md 40 | - dist 41 | - docs 42 | - demo 43 | expire_in: 1 week 44 | only: 45 | refs: 46 | - develop 47 | 48 | release-prod: 49 | stage: release 50 | script: 51 | - npx semantic-release 52 | artifacts: 53 | paths: 54 | - CHANGELOG.md 55 | - dist 56 | only: 57 | refs: 58 | - master 59 | 60 | pages: 61 | stage: deploy 62 | script: 63 | - yarn build 64 | - cp -r demo public 65 | - cp -r docs public/docs 66 | artifacts: 67 | paths: 68 | - public 69 | expire_in: 1 day 70 | only: 71 | - master 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Raphael Voellmy 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 | # SVGuitar - JavaScript Guitar Chord Renderer 2 | 3 | ![build](https://github.com/omnibrain/svguitar/actions/workflows/semantic-release.yml/badge.svg) 4 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 5 | [![Coveralls](https://img.shields.io/coveralls/omnibrain/svguitar.svg)](https://coveralls.io/github/omnibrain/svguitar) 6 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 7 | [![Known Vulnerabilities](https://snyk.io/test/github/omnibrain/svguitar/badge.svg)](https://snyk.io/test/github/omnibrain/svguitar) 8 | [![Code Style](https://badgen.net/badge/code%20style/airbnb/ff5a5f?icon=airbnb)](https://github.com/airbnb/javascript) 9 | 10 | JavaScript (TypeScript) library to create beautiful SVG guitar chord charts directly in the browser. 11 | 12 | To see this library in action check out [chordpic.com](https://chordpic.com), a free online chord diagram creator. 13 | 14 | **Demo**: https://omnibrain.github.io/svguitar/ [ [source](https://github.com/omnibrain/svguitar/blob/master/demo/index.html) ] 15 | 16 | **TypeScript API Documentation**: https://omnibrain.github.io/svguitar/docs/ 17 | 18 | Example chord charts: 19 | 20 | ![Example Chord Chart 1](https://raw.githubusercontent.com/omnibrain/svguitar/master/examples/example1.png) 21 | ![Example Chord Chart 2](https://raw.githubusercontent.com/omnibrain/svguitar/master/examples/example2.png) 22 | ![Example Chord Chart 3](https://raw.githubusercontent.com/omnibrain/svguitar/master/examples/example3.png) 23 | ![Example Chord Chart 4](https://raw.githubusercontent.com/omnibrain/svguitar/master/examples/example4.png) 24 | 25 | ### Getting Started 26 | 27 | ```html 28 | 29 |
30 | 31 | 32 | 33 | 34 | 48 | ``` 49 | 50 | Of course, you can also add SVGuitar as a dependency to your project: 51 | 52 | ```bash 53 | # Add the dependency to your project 54 | npm install --save svguitar 55 | 56 | # or 57 | yarn add svguitar 58 | 59 | # or 60 | pnpm add svguitar 61 | ``` 62 | 63 | And then import it in your project: 64 | 65 | ```javascript 66 | import { SVGuitarChord } from 'svguitar' 67 | 68 | const chart = new SVGuitarChord('#chart') 69 | 70 | // draw the chart 71 | chart 72 | .configure({ 73 | /* configuration */ 74 | }) 75 | .chord({ 76 | /* chord */ 77 | }) 78 | .draw() 79 | ``` 80 | 81 | ## Usage 82 | 83 | The SVG charts are highly customizable. 84 | For a full API documentation have a look at the [TypeScript documentation](https://omnibrain.github.io/svguitar/docs/). 85 | 86 | Chart configuration is completely optional, you don't have to pass any configuration or you can 87 | only override specific settings. 88 | 89 | Here's an example of a very customized chart: 90 | 91 | ```javascript 92 | new SVGuitarChord('#some-selector') 93 | .chord({ 94 | // array of [string, fret, text | options] 95 | fingers: [ 96 | // finger at string 2, fret 2, with text '2' 97 | [2, 2, '2'], 98 | 99 | // finger at string 3, fret 3, with text '4', colored red and has class 'red' 100 | [3, 3, { text: '4', color: '#F00', className: 'red' }], 101 | 102 | // finger at string 4, fret 3, with text '3', triangle shaped 103 | [4, 3, { text: '3', shape: 'triangle' }], 104 | 105 | // an 'x' above string 6 denotes it isn't played 106 | [6, 'x'], 107 | ], 108 | 109 | // optional: barres for barre chords 110 | barres: [ 111 | { 112 | fromString: 5, 113 | toString: 1, 114 | fret: 1, 115 | text: '1', 116 | color: '#0F0', 117 | textColor: '#F00', 118 | className: 'my-barre-chord', 119 | }, 120 | ], 121 | 122 | // title of the chart (optional) 123 | title: 'F# minor', 124 | 125 | // position (defaults to 1) 126 | position: 9, 127 | }) 128 | .configure({ 129 | // Customizations (all optional, defaults shown) 130 | 131 | /** 132 | * Orientation of the chord diagram. Chose between 'vertical' or 'horizontal' 133 | */ 134 | orientation: 'vertical', 135 | 136 | /** 137 | * Select between 'normal' and 'handdrawn' 138 | */ 139 | style: 'normal', 140 | 141 | /** 142 | * The number of strings 143 | */ 144 | strings: 6, 145 | 146 | /** 147 | * The number of frets 148 | */ 149 | frets: 4, 150 | 151 | /** 152 | * Default position if no positon is provided (first fret is 1) 153 | */ 154 | position: 1, 155 | 156 | /** 157 | * These are the labels under the strings. Can be any string. 158 | */ 159 | tuning: ['E', 'A', 'D', 'G', 'B', 'E'], 160 | 161 | /** 162 | * The position of the fret label (eg. "3fr") 163 | */ 164 | fretLabelPosition: 'right', 165 | 166 | /** 167 | * The font size of the fret label 168 | */ 169 | fretLabelFontSize: 38, 170 | 171 | /** 172 | * The font size of the string labels 173 | */ 174 | tuningsFontSize: 28, 175 | 176 | /** 177 | * Size of a finger or barre relative to the string spacing 178 | */ 179 | fingerSize: 0.65, 180 | 181 | /** 182 | * Color of a finger or barre 183 | */ 184 | fingerColor: '#000', 185 | 186 | /** 187 | * The color of text inside fingers and barres 188 | */ 189 | fingerTextColor: '#FFF', 190 | 191 | /** 192 | * The size of text inside fingers and barres 193 | */ 194 | fingerTextSize: 22, 195 | 196 | /** 197 | * stroke color of a finger or barre. Defaults to the finger color if not set 198 | */ 199 | fingerStrokeColor: '#000000', 200 | 201 | /** 202 | * stroke width of a finger or barre 203 | */ 204 | fingerStrokeWidth: 0, 205 | 206 | /** 207 | * stroke color of a barre chord. Defaults to the finger color if not set 208 | */ 209 | barreChordStrokeColor: '#000000', 210 | 211 | /** 212 | * stroke width of a barre chord 213 | */ 214 | barreChordStrokeWidth: 0, 215 | 216 | /** 217 | * Height of a fret, relative to the space between two strings 218 | */ 219 | fretSize: 1.5, 220 | 221 | /** 222 | * The minimum side padding (from the guitar to the edge of the SVG) relative to the whole width. 223 | * This is only applied if it's larger than the letters inside of the padding (eg the starting fret) 224 | */ 225 | sidePadding: 0.2, 226 | 227 | /** 228 | * The font family used for all letters and numbers 229 | */ 230 | fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif', 231 | 232 | /** 233 | * Default title of the chart if no title is provided 234 | */ 235 | title: 'F# minor', 236 | 237 | /** 238 | * Font size of the title. This is only the initial font size. If the title doesn't fit, the title 239 | * is automatically scaled so that it fits. 240 | */ 241 | titleFontSize: 48, 242 | 243 | /** 244 | * Space between the title and the chart 245 | */ 246 | titleBottomMargin: 0, 247 | 248 | /** 249 | * Global color of the whole chart. Can be overridden with more specifig color settings such as 250 | * @link titleColor or @link stringColor etc. 251 | */ 252 | color: '#000000', 253 | 254 | /** 255 | * The background color of the chord diagram. By default the background is transparent. To set the background to transparent either set this to 'none' or undefined 256 | */ 257 | backgroundColor: 'none', 258 | 259 | /** 260 | * Barre chord rectangle border radius relative to the fingerSize (eg. 1 means completely round endges, 0 means not rounded at all) 261 | */ 262 | barreChordRadius: 0.25, 263 | 264 | /** 265 | * Size of the Xs and Os above empty strings relative to the space between two strings 266 | */ 267 | emptyStringIndicatorSize: 0.6, 268 | 269 | /** 270 | * Global stroke width 271 | */ 272 | strokeWidth: 2, 273 | 274 | /** 275 | * The width of the nut (only used if position is 1) 276 | */ 277 | nutWidth: 10, 278 | 279 | /** 280 | * If this is set to `true`, the starting fret (eg. 3fr) will not be shown. If the position is 1 the 281 | * nut will have the same width as all other frets. 282 | */ 283 | noPosition: false, 284 | 285 | /** 286 | * The color of the title (overrides color) 287 | */ 288 | titleColor: '#000000', 289 | 290 | /** 291 | * The color of the strings (overrides color) 292 | */ 293 | stringColor: '#000000', 294 | 295 | /** 296 | * The color of the fret position (overrides color) 297 | */ 298 | fretLabelColor: '#000000', 299 | 300 | /** 301 | * The color of the tunings (overrides color) 302 | */ 303 | tuningsColor: '#000000', 304 | 305 | /** 306 | * The color of the frets (overrides color) 307 | */ 308 | fretColor: '#000000', 309 | 310 | /** 311 | * When set to true the distance between the chord diagram and the top of the SVG stayes the same, 312 | * no matter if a title is defined or not. 313 | */ 314 | fixedDiagramPosition: false, 315 | 316 | /** 317 | * Text of the watermark (text on the bottom of the chart) 318 | */ 319 | watermark: 'some watermark', 320 | 321 | /** 322 | * Font size of the watermark 323 | */ 324 | watermarkFontSize: 12, 325 | 326 | /** 327 | * Color of the watermark (overrides color) 328 | */ 329 | watermarkColor: '#000000', 330 | 331 | /** 332 | * Font-family of the watermark (overrides fontFamily) 333 | */ 334 | watermarkFontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif', 335 | 336 | /** 337 | * The title of the SVG. This is not visible in the SVG, but can be used for accessibility. 338 | */ 339 | svgTitle: 'Guitar chord diagram of F# minor', 340 | 341 | 342 | /** 343 | * The fret markers. See type docs for more options. 344 | */ 345 | fretMarkers: [ 346 | 2, 4, 6, 8, { 347 | fret: 11, 348 | double: true, 349 | }, 350 | ], 351 | 352 | /** 353 | * Flag to show or disable all fret markers globally. This is just for convenience. 354 | * The fret markers can also be removed by not setting the fretMarkers property. 355 | */ 356 | showFretMarkers: true, 357 | 358 | /** 359 | * The shape of the fret markets. Applies to all fret markets unless overridden 360 | * on specific fret markers. Defaults to circles. 361 | */ 362 | fretMarkerShape: 'circle', 363 | 364 | /** 365 | * The size of a fret marker. This is relative to the space between two strings, so 366 | * a value of 1 means a fret marker spans from one string to another. 367 | */ 368 | fretMarkerSize: 0.4, 369 | 370 | /** 371 | * The color of the fret markers. 372 | */ 373 | fretMarkerColor: 'rgba(0, 0, 0, 0.2)', 374 | 375 | /** 376 | * The stroke color of the fret markers. By default, the fret markets have no border. 377 | */ 378 | fretMarkerStrokeColor: '#000000', 379 | 380 | /** 381 | * The stroke width of the fret markers. By default, the fret markets have no border. 382 | */ 383 | fretMarkerStrokeWidth: 0, 384 | 385 | /** 386 | * The distance between the double fret markers, relative to the width 387 | * of the whole neck. E.g. 0.5 means the distance between the fret markers 388 | * is equivalent to 0.5 times the width of the whole neck. 389 | */ 390 | doubleFretMarkerDistance: 0.4 391 | 392 | }) 393 | .draw() 394 | ``` 395 | 396 | ## Contribute 397 | 398 | Pull Requests are very welcome! 399 | 400 | ## Projects using SVGuitar 401 | 402 | Here are some projects that use `svguitar`: 403 | 404 | - [ChordPic - Easily Create Guitar Chord Charts](https://chordpic.com) 405 | - [muted.io - Magical Music Theory Tools to Learn Music Online for Free](https://muted.io/) 406 | - [Chordpress Wordpress Plugin - ChordPro Text Formatter](https://wordpress.org/plugins/chordpress/) 407 | - [Harmonote - Find chords for your favorite songs](https://harmonote.com/) 408 | - [Guide Tones Practice - A technique to learn Jazz chords quickly](https://www.onlinemusictools.com/guide-tones/) 409 | 410 | Are you using SVGuitar? Create an issue to get your project listed here! Or simply create a pull request with your project added. 411 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 15 | 16 | SVGuitar Demo 17 | 18 | 19 |
20 |
21 |
22 |

SVGuitar Demo

23 | 24 |

Demo page for SVGuitar. Try to create some SVGs here.

25 | 26 |

27 | For a more elaborate chord editor that also uses SVGuitar check out 28 | ChordPic. 29 |

30 | 31 | TypeScript Documentation 32 |
33 |
34 |
35 |
36 |
37 |

Chord

38 |
39 | 40 | JSON of the chord. See documentation for more information. 41 | 42 |
43 | 44 |
45 |
46 |
47 |
48 |

Configuration

49 | 50 |
51 |
52 | 53 | 57 |
58 |
59 | 60 | 64 |
65 |
66 | 67 | 74 |
75 |
76 | 77 | 85 |
86 |
87 | 88 | 96 |
97 |
98 | 99 | 107 |
108 |
109 | 110 | 117 |
118 |
119 | 120 | 128 |
129 |
130 | 131 | 138 |
139 |
140 | 141 |
142 |
143 |
144 |
145 |
146 |

Result

147 | 148 |
149 |
150 |
151 |
152 | 156 | 157 | 158 | 159 | 164 | 169 | 174 | 175 | 176 | 252 | 253 | 254 | Fork me on GitHub 263 | 264 | 265 | 266 | -------------------------------------------------------------------------------- /examples/example1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/omnibrain/svguitar/8094eb090e3539c21104e606f9899106abcf8fef/examples/example1.png -------------------------------------------------------------------------------- /examples/example2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/omnibrain/svguitar/8094eb090e3539c21104e606f9899106abcf8fef/examples/example2.png -------------------------------------------------------------------------------- /examples/example3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/omnibrain/svguitar/8094eb090e3539c21104e606f9899106abcf8fef/examples/example3.png -------------------------------------------------------------------------------- /examples/example4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/omnibrain/svguitar/8094eb090e3539c21104e606f9899106abcf8fef/examples/example4.png -------------------------------------------------------------------------------- /fonts/OpenSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/omnibrain/svguitar/8094eb090e3539c21104e606f9899106abcf8fef/fonts/OpenSans-Regular.ttf -------------------------------------------------------------------------------- /fonts/arial.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/omnibrain/svguitar/8094eb090e3539c21104e606f9899106abcf8fef/fonts/arial.ttf -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svguitar", 3 | "version": "1.0.0-development", 4 | "description": "Library to create beautiful SVG guitar chord charts.", 5 | "keywords": [ 6 | "guitar", 7 | "guitar-chords", 8 | "svg", 9 | "chord", 10 | "diagram" 11 | ], 12 | "main": "dist/svguitar.umd.js", 13 | "module": "dist/svguitar.es5.js", 14 | "typings": "dist/types/svguitar.d.ts", 15 | "files": [ 16 | "dist" 17 | ], 18 | "author": "Raphael Voellmy ", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/omnibrain/svguitar.git" 22 | }, 23 | "license": "MIT", 24 | "engines": { 25 | "node": ">=10.0.0" 26 | }, 27 | "scripts": { 28 | "format": "prettier --write '{src,test}/**/*.ts'", 29 | "lint": "eslint -c .eslintrc.js --ext .ts src", 30 | "prebuild": "rimraf dist", 31 | "build": "run-s build:tsc build:rollup build:typedoc build:demo", 32 | "build:typedoc": "typedoc --out docs src/svguitar.ts", 33 | "build:tsc": "tsc --module commonjs", 34 | "build:rollup": "rollup -c rollup.config.ts", 35 | "build:demo": "ts-node tools/build-demo", 36 | "start": "rollup -c rollup.config.ts -w", 37 | "test": "jest --coverage", 38 | "test:watch": "jest --coverage --watch", 39 | "test:prod": "run-s lint test", 40 | "report-coverage": "run-s test publish-coverage", 41 | "publish-coverage": "cat ./coverage/lcov.info | coveralls", 42 | "commit": "git-cz", 43 | "semantic-release": "semantic-release", 44 | "precommit": "lint-staged" 45 | }, 46 | "lint-staged": { 47 | "{src,test}/**/*.ts": [ 48 | "prettier --write", 49 | "git add" 50 | ] 51 | }, 52 | "config": { 53 | "commitizen": { 54 | "path": "node_modules/cz-conventional-changelog" 55 | } 56 | }, 57 | "release": { 58 | "branches": [ 59 | "master", 60 | { 61 | "name": "next", 62 | "prerelease": true 63 | } 64 | ], 65 | "plugins": [ 66 | "@semantic-release/commit-analyzer", 67 | "@semantic-release/release-notes-generator", 68 | "@semantic-release/changelog", 69 | "@semantic-release/npm", 70 | [ 71 | "@semantic-release/github", 72 | { 73 | "assets": [ 74 | "dist/*.tgz", 75 | "CHANGELOG.md" 76 | ] 77 | } 78 | ] 79 | ] 80 | }, 81 | "jest": { 82 | "transform": { 83 | ".(ts|tsx)": "ts-jest" 84 | }, 85 | "testEnvironment": "node", 86 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", 87 | "moduleFileExtensions": [ 88 | "ts", 89 | "tsx", 90 | "js" 91 | ], 92 | "coveragePathIgnorePatterns": [ 93 | "/node_modules/", 94 | "/test/" 95 | ], 96 | "coverageThreshold": { 97 | "global": { 98 | "branches": 90, 99 | "functions": 95, 100 | "lines": 95, 101 | "statements": 95 102 | } 103 | }, 104 | "collectCoverageFrom": [ 105 | "src/*.{js,ts}" 106 | ] 107 | }, 108 | "prettier": { 109 | "semi": false, 110 | "singleQuote": true, 111 | "trailingComma": "all" 112 | }, 113 | "commitlint": { 114 | "extends": [ 115 | "@commitlint/config-conventional" 116 | ] 117 | }, 118 | "devDependencies": { 119 | "@commitlint/cli": "^11.0.0", 120 | "@commitlint/config-conventional": "^11.0.0", 121 | "@semantic-release/changelog": "^6.0.1", 122 | "@semantic-release/commit-analyzer": "^9.0.1", 123 | "@semantic-release/github": "^8.0.6", 124 | "@semantic-release/npm": "^9.0.1", 125 | "@semantic-release/release-notes-generator": "^10.0.3", 126 | "@types/jest": "^26.0.0", 127 | "@types/node": "^14.0.0", 128 | "@typescript-eslint/eslint-plugin": "^4.9.1", 129 | "@typescript-eslint/parser": "^4.9.1", 130 | "colors": "^1.3.2", 131 | "commitizen": "^4.0.3", 132 | "coveralls": "^3.0.2", 133 | "cross-env": "^7.0.0", 134 | "cz-conventional-changelog": "^3.0.2", 135 | "eslint": "^7.2.0", 136 | "eslint-config-airbnb-typescript": "^12.0.0", 137 | "eslint-config-prettier": "^7.0.0", 138 | "eslint-plugin-import": "^2.22.1", 139 | "husky": "^4.0.2", 140 | "jest": "^26.0.1", 141 | "jest-config": "^26.0.1", 142 | "jest-each": "^26.0.0", 143 | "lint-staged": "^10.0.3", 144 | "lodash.camelcase": "^4.3.0", 145 | "npm-run-all-v2": "^1.0.0", 146 | "prettier": "^2.0.0", 147 | "prompt": "^1.0.0", 148 | "replace-in-file": "^6.0.0", 149 | "rimraf": "^3.0.0", 150 | "rollup": "^2.0.0", 151 | "rollup-plugin-commonjs": "^10.1.0", 152 | "rollup-plugin-html": "^0.2.1", 153 | "rollup-plugin-json": "^4.0.0", 154 | "rollup-plugin-node-resolve": "^5.2.0", 155 | "rollup-plugin-sourcemaps": "^0.6.0", 156 | "rollup-plugin-typescript2": "^0.34.1", 157 | "semantic-release": "^19.0.5", 158 | "shelljs": "^0.8.3", 159 | "ts-jest": "^26.0.0", 160 | "ts-node": "^10.9.1", 161 | "typedoc": "^0.23.17", 162 | "typescript": "^4.8.4" 163 | }, 164 | "dependencies": { 165 | "@svgdotjs/svg.js": "3.0.16", 166 | "roughjs": "^4.5.2", 167 | "svgdom": "^0.1.14" 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve' 2 | import commonjs from 'rollup-plugin-commonjs' 3 | import sourceMaps from 'rollup-plugin-sourcemaps' 4 | import camelCase from 'lodash.camelcase' 5 | import typescript from 'rollup-plugin-typescript2' 6 | import json from 'rollup-plugin-json' 7 | import html from 'rollup-plugin-html'; 8 | 9 | const pkg = require('./package.json') 10 | 11 | const libraryName = 'svguitar' 12 | 13 | export default { 14 | input: `src/${libraryName}.ts`, 15 | output: [ 16 | { file: pkg.main, name: camelCase(libraryName), format: 'umd', sourcemap: true }, 17 | { file: pkg.module, format: 'es', sourcemap: true }, 18 | ], 19 | // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') 20 | external: [], 21 | watch: { 22 | include: 'src/**', 23 | }, 24 | plugins: [ 25 | // Allow json resolution 26 | json(), 27 | // Allow importing HTML 28 | html({ 29 | include: '**/*.html' 30 | }), 31 | // Compile TypeScript files 32 | typescript({ useTsconfigDeclarationDir: true }), 33 | // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) 34 | commonjs(), 35 | // Allow node_modules resolution, so you can use 'external' to control 36 | // which external modules to include in the bundle 37 | // https://github.com/rollup/rollup-plugin-node-resolve#usage 38 | resolve(), 39 | 40 | // Resolve source maps to the original source 41 | sourceMaps(), 42 | ], 43 | } 44 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | interface ChartConstants { 2 | width: number 3 | } 4 | 5 | export const constants: ChartConstants = { 6 | /** 7 | * The viewbox width of the svg 8 | */ 9 | width: 400, 10 | } 11 | 12 | export default constants 13 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type { SVGuitarChord } from './svguitar' 3 | 4 | export type ApiExtension = { [key: string]: any } 5 | 6 | export type Constructor = new (...args: any[]) => T 7 | 8 | export type AnyFunction = (...args: any) => any 9 | 10 | /** 11 | * @author https://stackoverflow.com/users/2887218/jcalz 12 | * @see https://stackoverflow.com/a/50375286/10325032 13 | */ 14 | export type UnionToIntersection = ( 15 | Union extends any ? (argument: Union) => void : never 16 | ) extends (argument: infer Intersection) => void // tslint:disable-line: no-unused 17 | ? Intersection 18 | : never 19 | 20 | export type ReturnTypeOf = T extends AnyFunction 21 | ? ReturnType 22 | : T extends AnyFunction[] 23 | ? UnionToIntersection> 24 | : never 25 | 26 | export type SVGuitarPlugin = (instance: SVGuitarChord) => ApiExtension | undefined 27 | -------------------------------------------------------------------------------- /src/renderer/index.ts: -------------------------------------------------------------------------------- 1 | export { RoughJsRenderer } from './roughjs/roughjs-renderer' 2 | export { SvgJsRenderer } from './svgjs/svg-js-renderer' 3 | export { Renderer, Alignment, GraphcisElement } from './renderer' 4 | -------------------------------------------------------------------------------- /src/renderer/renderer.ts: -------------------------------------------------------------------------------- 1 | import { QuerySelector } from '@svgdotjs/svg.js' 2 | 3 | export enum Alignment { 4 | LEFT = 'left', 5 | MIDDLE = 'middle', 6 | RIGHT = 'right', 7 | } 8 | 9 | export interface GraphcisElement { 10 | width: number 11 | height: number 12 | x: number 13 | y: number 14 | remove: () => void 15 | } 16 | 17 | export abstract class Renderer { 18 | protected constructor(protected container: QuerySelector | HTMLElement) {} 19 | 20 | abstract line( 21 | x1: number, 22 | y1: number, 23 | x2: number, 24 | y2: number, 25 | strokeWidth: number, 26 | color: string, 27 | classes?: string | string[], 28 | ): void 29 | 30 | abstract size(width: number, height: number): void 31 | 32 | abstract clear(): void 33 | 34 | abstract remove(): void 35 | 36 | abstract background(color: string): void 37 | 38 | abstract title(title: string): void 39 | 40 | abstract text( 41 | text: string, 42 | x: number, 43 | y: number, 44 | fontSize: number, 45 | color: string, 46 | fontFamily: string, 47 | alignment: Alignment, 48 | classes?: string | string[], 49 | plain?: boolean, 50 | ): GraphcisElement 51 | 52 | abstract circle( 53 | x: number, 54 | y: number, 55 | diameter: number, 56 | strokeWidth: number, 57 | strokeColor: string, 58 | fill?: string, 59 | classes?: string | string[], 60 | ): GraphcisElement 61 | 62 | abstract rect( 63 | x: number, 64 | y: number, 65 | width: number, 66 | height: number, 67 | strokeWidth: number, 68 | strokeColor: string, 69 | classes?: string | string[], 70 | fill?: string, 71 | radius?: number, 72 | ): GraphcisElement 73 | 74 | abstract triangle( 75 | x: number, 76 | y: number, 77 | size: number, 78 | strokeWidth: number, 79 | strokeColor: string, 80 | classes?: string | string[], 81 | fill?: string, 82 | ): GraphcisElement 83 | 84 | abstract pentagon( 85 | x: number, 86 | y: number, 87 | size: number, 88 | strokeWidth: number, 89 | strokeColor: string, 90 | fill?: string, 91 | classes?: string | string[], 92 | ): GraphcisElement 93 | 94 | protected static trianglePath(x: number, y: number, size: number): string { 95 | return `M${x + size / 2} ${y} L${x + size} ${y + size} L${x} ${y + size}` 96 | } 97 | 98 | protected static ngonPath(x: number, y: number, size: number, edges: number): string { 99 | let i: number 100 | let a: number 101 | const degrees = 360 / edges 102 | const radius = size / 2 103 | const points: [number, number][] = [] 104 | 105 | let curX = x 106 | let curY = y 107 | 108 | for (i = 0; i < edges; i += 1) { 109 | a = i * degrees - 90 110 | 111 | curX = radius + radius * Math.cos((a * Math.PI) / 180) 112 | curY = radius + radius * Math.sin((a * Math.PI) / 180) 113 | 114 | points.push([curX, curY]) 115 | } 116 | 117 | const lines = points.reduce((acc, [posX, posY]) => `${acc} L${posX} ${posY}`, '') 118 | 119 | return `M${curX} ${curY} ${lines}` 120 | } 121 | 122 | protected static toClassName(classes?: string | string[]): string { 123 | if (!classes) { 124 | return '' 125 | } 126 | 127 | return Array.isArray(classes) ? classes.join(' ') : classes 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/renderer/roughjs/defs.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | 3 | 13 | 14 | ` 15 | -------------------------------------------------------------------------------- /src/renderer/roughjs/roughjs-renderer.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* 3 | Unfortunately this roughjs implementation can't be tested with jsdom at the moment. The problem is 4 | that there is no SVG implementation for JSDOM. If that changes at some point this class can be 5 | tested just like the svg.js implementation 6 | */ 7 | 8 | import { QuerySelector } from '@svgdotjs/svg.js' 9 | import { RoughSVG } from 'roughjs/bin/svg' 10 | import rough from 'roughjs' 11 | import { Options } from 'roughjs/bin/core' 12 | import defs from './defs' 13 | import { Alignment, GraphcisElement, Renderer } from '../renderer' 14 | 15 | /** 16 | * Currently the font is hard-coded to 'Patrick Hand' when using the handdrawn chord diagram style. 17 | * The reason is that the font needs to be base64 encoded and embedded in the SVG. In theory a web-font 18 | * could be downloaded, base64 encoded and embedded in the SVG but that's too much of a hassle. But if the 19 | * need arises it should be possible. 20 | */ 21 | const FONT_FAMLILY = 'Patrick Hand' 22 | 23 | export class RoughJsRenderer extends Renderer { 24 | private rc: RoughSVG 25 | 26 | private containerNode: HTMLElement 27 | 28 | private svgNode: SVGSVGElement 29 | 30 | constructor(container: QuerySelector | HTMLElement) { 31 | super(container) 32 | 33 | // initialize the container 34 | if (container instanceof HTMLElement) { 35 | this.containerNode = container 36 | } else { 37 | this.containerNode = container as unknown as HTMLElement 38 | const node = document.querySelector(container) 39 | 40 | if (!node) { 41 | throw new Error(`No element found with selector "${container}"`) 42 | } 43 | 44 | this.containerNode = node 45 | } 46 | 47 | // create an empty SVG element 48 | this.svgNode = document.createElementNS('http://www.w3.org/2000/svg', 'svg') 49 | this.svgNode.setAttribute('xmlns', 'http://www.w3.org/2000/svg') 50 | this.svgNode.setAttribute('version', '1.1') 51 | this.svgNode.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink') 52 | this.svgNode.setAttribute('xmlns:svgjs', 'http://svgjs.com/svgjs') 53 | 54 | this.svgNode.setAttribute('preserveAspectRatio', 'xMidYMid meet') 55 | this.svgNode.setAttribute('viewBox', '0 0 400 400') 56 | 57 | this.embedDefs() 58 | 59 | this.containerNode.appendChild(this.svgNode) 60 | 61 | this.rc = rough.svg(this.svgNode) 62 | } 63 | 64 | /** 65 | * This will embed all defs defined in the defs.html file. Specifically this is used to embed the base64 66 | * encoded font into the SVG so that the font always looks correct. 67 | */ 68 | private embedDefs() { 69 | /* 70 | Embed the base64 encoded font. This is done in a timeout because roughjs also creates defs which will simply overwrite existing defs. 71 | By putting this in a timeout we make sure that the style tag is added after roughjs finished rendering. 72 | ATTENTION: This will only work as long as we're synchronously rendering the diagram! If we ever switch to asynchronous rendering a different 73 | solution must be found. 74 | */ 75 | setTimeout(() => { 76 | // check if defs were already added 77 | if (this.svgNode.querySelector('defs [data-svguitar-def]')) { 78 | return 79 | } 80 | 81 | let currentDefs = this.svgNode.querySelector('defs') 82 | 83 | if (!currentDefs) { 84 | currentDefs = document.createElementNS('http://www.w3.org/2000/svg', 'defs') 85 | this.svgNode.prepend(currentDefs) 86 | } 87 | 88 | // create dom nodes from HTML string 89 | const template = document.createElement('template') 90 | template.innerHTML = defs.trim() 91 | 92 | // typescript is complaining when I access content.firstChild.children, therefore this ugly workaround. 93 | const defsToAdd = template.content.firstChild?.firstChild?.parentElement?.children 94 | 95 | if (defsToAdd) { 96 | Array.from(defsToAdd).forEach((def) => { 97 | def.setAttribute('data-svguitar-def', 'true') 98 | currentDefs?.appendChild(def) 99 | }) 100 | } 101 | }) 102 | } 103 | 104 | title(title: string): void { 105 | const titleEl = document.createElement('title') 106 | titleEl.textContent = title 107 | this.svgNode.appendChild(titleEl) 108 | } 109 | 110 | circle( 111 | x: number, 112 | y: number, 113 | diameter: number, 114 | strokeWidth: number, 115 | strokeColor: string, 116 | fill?: string, 117 | classes?: string | string[], 118 | ): GraphcisElement { 119 | const options: Options = { 120 | fill: fill || 'none', 121 | fillWeight: 2.5, 122 | stroke: strokeColor || fill || 'none', 123 | roughness: 1.5, 124 | } 125 | 126 | if (strokeWidth > 0) { 127 | options.strokeWidth = strokeWidth 128 | } 129 | 130 | const circle = this.rc.circle(x + diameter / 2, y + diameter / 2, diameter, options) 131 | circle.classList.add(...RoughJsRenderer.toClassArray(classes)) 132 | 133 | this.svgNode.appendChild(circle) 134 | 135 | return RoughJsRenderer.boxToElement(circle.getBBox(), () => 136 | circle ? circle.remove() : undefined, 137 | ) 138 | } 139 | 140 | clear(): void { 141 | while (this.svgNode.firstChild) { 142 | this.svgNode.removeChild(this.svgNode.firstChild) 143 | } 144 | 145 | this.rc = rough.svg(this.svgNode) 146 | this.embedDefs() 147 | } 148 | 149 | remove(): void { 150 | this.svgNode.remove() 151 | } 152 | 153 | line( 154 | x1: number, 155 | y1: number, 156 | x2: number, 157 | y2: number, 158 | strokeWidth: number, 159 | color: string, 160 | classes?: string | string[], 161 | ): void { 162 | if (strokeWidth > 5 && (x1 - x2 === 0 || y1 - y2 === 0)) { 163 | if (Math.abs(x1 - x2) > Math.abs(y1 - y2)) { 164 | this.rect(x1, y1, x2 - x1, strokeWidth, 0, color, color) 165 | } else { 166 | this.rect(x1 - strokeWidth / 2, y1, strokeWidth, y2 - y1, 0, color, color) 167 | } 168 | } else { 169 | const line = this.rc.line(x1, y1, x2, y2, { 170 | strokeWidth, 171 | stroke: color, 172 | }) 173 | 174 | line.classList.add(...RoughJsRenderer.toClassArray(classes)) 175 | this.svgNode.appendChild(line) 176 | } 177 | } 178 | 179 | rect( 180 | x: number, 181 | y: number, 182 | width: number, 183 | height: number, 184 | strokeWidth: number, 185 | strokeColor: string, 186 | classes?: string | string[], 187 | fill?: string, 188 | radius?: number, 189 | ): GraphcisElement { 190 | const rect2 = this.rc.rectangle(x, y, width, height, { 191 | // fill: fill || 'none', 192 | fill: 'none', 193 | fillWeight: 2, 194 | strokeWidth, 195 | stroke: strokeColor, 196 | roughness: 2.8, 197 | fillStyle: 'cross-hatch', 198 | hachureAngle: 60, // angle of hachure, 199 | hachureGap: 4, 200 | }) 201 | 202 | const rectRadius = radius || 0 203 | const path = RoughJsRenderer.roundedRectData( 204 | width, 205 | height, 206 | rectRadius, 207 | rectRadius, 208 | rectRadius, 209 | rectRadius, 210 | ) 211 | const rect = this.rc.path(path, { 212 | fill: fill || 'none', 213 | fillWeight: 2.5, 214 | stroke: strokeColor || fill || 'none', 215 | roughness: 1.5, 216 | }) 217 | rect.setAttribute('transform', `translate(${x}, ${y})`) 218 | rect.classList.add(...RoughJsRenderer.toClassArray(classes)) 219 | rect2.classList.add(...RoughJsRenderer.toClassArray(classes)) 220 | this.svgNode.appendChild(rect) 221 | this.svgNode.appendChild(rect2) 222 | 223 | return RoughJsRenderer.boxToElement(rect.getBBox(), () => rect.remove()) 224 | } 225 | 226 | triangle( 227 | x: number, 228 | y: number, 229 | size: number, 230 | strokeWidth: number, 231 | strokeColor: string, 232 | classes?: string | string[], 233 | fill?: string | undefined, 234 | ): GraphcisElement { 235 | const triangle = this.rc.path(Renderer.trianglePath(0, 0, size), { 236 | fill: fill || 'none', 237 | fillWeight: 2.5, 238 | stroke: strokeColor || fill || 'none', 239 | roughness: 1.5, 240 | }) 241 | triangle.setAttribute('transform', `translate(${x}, ${y})`) 242 | triangle.classList.add(...RoughJsRenderer.toClassArray(classes)) 243 | this.svgNode.appendChild(triangle) 244 | 245 | return RoughJsRenderer.boxToElement(triangle.getBBox(), () => triangle.remove()) 246 | } 247 | 248 | pentagon( 249 | x: number, 250 | y: number, 251 | size: number, 252 | strokeWidth: number, 253 | strokeColor: string, 254 | fill?: string, 255 | classes?: string | string[], 256 | spikes = 5, 257 | ): GraphcisElement { 258 | const pentagon = this.rc.path(Renderer.ngonPath(0, 0, size, spikes), { 259 | fill: fill || 'none', 260 | fillWeight: 2.5, 261 | stroke: strokeColor || fill || 'none', 262 | roughness: 1.5, 263 | }) 264 | pentagon.setAttribute('transform', `translate(${x}, ${y})`) 265 | pentagon.classList.add(...RoughJsRenderer.toClassArray(classes)) 266 | this.svgNode.appendChild(pentagon) 267 | 268 | return RoughJsRenderer.boxToElement(pentagon.getBBox(), () => pentagon.remove()) 269 | } 270 | 271 | size(width: number, height: number): void { 272 | this.svgNode.setAttribute('viewBox', `0 0 ${Math.ceil(width)} ${Math.ceil(height)}`) 273 | } 274 | 275 | background(color: string): void { 276 | const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect') 277 | 278 | bg.setAttributeNS(null, 'width', '100%') 279 | bg.setAttributeNS(null, 'height', '100%') 280 | bg.setAttributeNS(null, 'fill', color) 281 | 282 | this.svgNode.insertBefore(bg, this.svgNode.firstChild) 283 | } 284 | 285 | text( 286 | text: string, 287 | x: number, 288 | y: number, 289 | fontSize: number, 290 | color: string, 291 | fontFamily: string, 292 | alignment: Alignment, 293 | classes?: string | string[], 294 | plain?: boolean, 295 | ): GraphcisElement { 296 | // Place the SVG namespace in a variable to easily reference it. 297 | const txtElem = document.createElementNS('http://www.w3.org/2000/svg', 'text') 298 | 299 | txtElem.setAttributeNS(null, 'x', String(x)) 300 | txtElem.setAttributeNS(null, 'y', String(y)) 301 | txtElem.setAttributeNS(null, 'font-size', String(fontSize)) 302 | txtElem.setAttributeNS(null, 'font-family', FONT_FAMLILY) 303 | txtElem.setAttributeNS(null, 'align', alignment) 304 | txtElem.setAttributeNS(null, 'fill', color) 305 | 306 | if (plain) { 307 | txtElem.setAttributeNS(null, 'dominant-baseline', 'central') 308 | } 309 | 310 | txtElem.appendChild(document.createTextNode(text)) 311 | 312 | this.svgNode.appendChild(txtElem) 313 | 314 | const bbox = txtElem.getBBox() 315 | 316 | let xOffset: number 317 | 318 | switch (alignment) { 319 | case Alignment.MIDDLE: 320 | xOffset = -(bbox.width / 2) 321 | break 322 | case Alignment.LEFT: 323 | xOffset = 0 324 | break 325 | case Alignment.RIGHT: 326 | xOffset = -bbox.width 327 | break 328 | default: 329 | throw new Error(`Invalid alignment ${alignment}`) 330 | } 331 | 332 | txtElem.classList.add(...RoughJsRenderer.toClassArray(classes)) 333 | txtElem.setAttributeNS(null, 'x', String(x + xOffset)) 334 | txtElem.setAttributeNS(null, 'y', String(y + (plain ? 0 : bbox.height / 2))) 335 | 336 | return RoughJsRenderer.boxToElement(txtElem.getBBox(), txtElem.remove.bind(txtElem)) 337 | } 338 | 339 | private static boxToElement(box: DOMRect, remove: () => void): GraphcisElement { 340 | return { 341 | width: box.width, 342 | height: box.height, 343 | x: box.x, 344 | y: box.y, 345 | remove, 346 | } 347 | } 348 | 349 | private static roundedRectData( 350 | w: number, 351 | h: number, 352 | tlr: number, 353 | trr: number, 354 | brr: number, 355 | blr: number, 356 | ): string { 357 | return ( 358 | `M 0 ${tlr} A ${tlr} ${tlr} 0 0 1 ${tlr} 0` + 359 | ` L ${w - trr} 0` + 360 | ` A ${trr} ${trr} 0 0 1 ${w} ${trr} L ${w} ${h - brr} A ${brr} ${brr} 0 0 1 ${ 361 | w - brr 362 | } ${h} L ${blr} ${h} A ${blr} ${blr} 0 0 1 0 ${h - blr} Z` 363 | ) 364 | } 365 | 366 | private static toClassArray(classes?: string | string[]): string[] { 367 | if (!classes) { 368 | return [] 369 | } 370 | 371 | return Renderer.toClassName(classes).split(' ') 372 | } 373 | } 374 | 375 | export default RoughJsRenderer 376 | -------------------------------------------------------------------------------- /src/renderer/svgjs/svg-js-renderer.ts: -------------------------------------------------------------------------------- 1 | import { Box, Container, QuerySelector, SVG } from '@svgdotjs/svg.js' 2 | import { Alignment, GraphcisElement, Renderer } from '../renderer' 3 | import { constants } from '../../constants' 4 | import { isNode } from '../../utils/is-node' 5 | 6 | export class SvgJsRenderer extends Renderer { 7 | private svg: Container 8 | 9 | constructor(container: QuerySelector | HTMLElement) { 10 | super(container) 11 | 12 | // initialize the SVG 13 | const { width } = constants 14 | const height = 0 15 | 16 | /* 17 | For some reason the container needs to be initiated differently with svgdom (node) and 18 | and in the browser. Might be a bug in either svg.js or svgdom. But this workaround works fine 19 | so I'm not going to care for now. 20 | */ 21 | /* istanbul ignore else */ 22 | if (isNode()) { 23 | // node (jest) 24 | this.svg = SVG(container) as Container 25 | } else { 26 | // browser 27 | this.svg = SVG().addTo(container) 28 | } 29 | 30 | this.svg.attr('preserveAspectRatio', 'xMidYMid meet').viewbox(0, 0, width, height) 31 | } 32 | 33 | title(title: string): void { 34 | this.svg.add(this.svg.element('title').words(title)) 35 | } 36 | 37 | line( 38 | fromX: number, 39 | fromY: number, 40 | toX: number, 41 | toY: number, 42 | strokeWidth: number, 43 | color: string, 44 | ): void { 45 | this.svg.line(fromX, fromY, toX, toY).stroke({ color, width: strokeWidth }) 46 | } 47 | 48 | size(width: number, height: number): void { 49 | this.svg.viewbox(0, 0, width, height) 50 | } 51 | 52 | clear(): void { 53 | this.svg.children().forEach((child) => child.remove()) 54 | } 55 | 56 | remove(): void { 57 | this.svg.remove() 58 | } 59 | 60 | background(color: string): void { 61 | this.svg.rect().size('100%', '100%').fill(color) 62 | } 63 | 64 | text( 65 | text: string, 66 | x: number, 67 | y: number, 68 | fontSize: number, 69 | color: string, 70 | fontFamily: string, 71 | alignment: Alignment, 72 | classes?: string | string[], 73 | plain?: boolean, 74 | ): GraphcisElement { 75 | let element 76 | 77 | if (plain) { 78 | // create a text element centered at x,y. No SVG.js magic. 79 | element = this.svg 80 | .plain(text) 81 | .attr({ 82 | x, 83 | y, 84 | }) 85 | .font({ 86 | family: fontFamily, 87 | size: fontSize, 88 | anchor: alignment, 89 | 'dominant-baseline': 'central', 90 | }) 91 | .fill(color) 92 | .addClass(Renderer.toClassName(classes)) 93 | } else { 94 | element = this.svg 95 | .text(text) 96 | .move(x, y) 97 | .font({ 98 | family: fontFamily, 99 | size: fontSize, 100 | anchor: alignment, 101 | }) 102 | .fill(color) 103 | .addClass(Renderer.toClassName(classes)) 104 | } 105 | 106 | return SvgJsRenderer.boxToElement(element.bbox(), element.remove.bind(element)) 107 | } 108 | 109 | circle( 110 | x: number, 111 | y: number, 112 | diameter: number, 113 | strokeWidth: number, 114 | strokeColor: string, 115 | fill?: string, 116 | classes?: string | string[], 117 | ): GraphcisElement { 118 | const element = this.svg 119 | .circle(diameter) 120 | .move(x, y) 121 | .fill(fill || 'none') 122 | .stroke({ 123 | color: strokeColor, 124 | width: strokeWidth, 125 | }) 126 | .addClass(Renderer.toClassName(classes)) 127 | 128 | return SvgJsRenderer.boxToElement(element.bbox(), element.remove.bind(element)) 129 | } 130 | 131 | rect( 132 | x: number, 133 | y: number, 134 | width: number, 135 | height: number, 136 | strokeWidth: number, 137 | strokeColor: string, 138 | classes?: string | string[], 139 | fill?: string, 140 | radius?: number, 141 | ): GraphcisElement { 142 | const element = this.svg 143 | .rect(width, height) 144 | .move(x, y) 145 | .fill(fill || 'none') 146 | .stroke({ 147 | width: strokeWidth, 148 | color: strokeColor, 149 | }) 150 | .radius(radius || 0) 151 | .addClass(Renderer.toClassName(classes)) 152 | 153 | return SvgJsRenderer.boxToElement(element.bbox(), element.remove.bind(element)) 154 | } 155 | 156 | triangle( 157 | x: number, 158 | y: number, 159 | size: number, 160 | strokeWidth: number, 161 | strokeColor: string, 162 | classes?: string | string[], 163 | fill?: string | undefined, 164 | ): GraphcisElement { 165 | const element = this.svg 166 | .path(Renderer.trianglePath(x, y, size)) 167 | .move(x, y) 168 | .fill(fill || 'none') 169 | .stroke({ 170 | width: strokeWidth, 171 | color: strokeColor, 172 | }) 173 | .addClass(Renderer.toClassName(classes)) 174 | 175 | return SvgJsRenderer.boxToElement(element.bbox(), element.remove.bind(element)) 176 | } 177 | 178 | pentagon( 179 | x: number, 180 | y: number, 181 | size: number, 182 | strokeWidth: number, 183 | strokeColor: string, 184 | fill: string, 185 | classes?: string | string[], 186 | ): GraphcisElement { 187 | return this.ngon(x, y, size, strokeWidth, strokeColor, fill, 5, classes) 188 | } 189 | 190 | private ngon( 191 | x: number, 192 | y: number, 193 | size: number, 194 | strokeWidth: number, 195 | strokeColor: string, 196 | fill: string, 197 | edges: number, 198 | classes?: string | string[], 199 | ) { 200 | const element = this.svg 201 | .path(Renderer.ngonPath(x, y, size, edges)) 202 | .move(x, y) 203 | .fill(fill || 'none') 204 | .stroke({ 205 | width: strokeWidth, 206 | color: strokeColor, 207 | }) 208 | .addClass(Renderer.toClassName(classes)) 209 | 210 | return SvgJsRenderer.boxToElement(element.bbox(), element.remove.bind(element)) 211 | } 212 | 213 | private static boxToElement(box: Box, remove: () => void): GraphcisElement { 214 | return { 215 | width: box.width, 216 | height: box.height, 217 | x: box.x, 218 | y: box.y, 219 | remove, 220 | } 221 | } 222 | } 223 | 224 | export default SvgJsRenderer 225 | -------------------------------------------------------------------------------- /src/svguitar.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | import { QuerySelector } from '@svgdotjs/svg.js' 3 | import { constants } from './constants' 4 | import { 5 | AnyFunction, 6 | ApiExtension, 7 | Constructor, 8 | ReturnTypeOf, 9 | SVGuitarPlugin, 10 | UnionToIntersection, 11 | } from './plugin' 12 | import { Alignment, GraphcisElement, Renderer, RoughJsRenderer, SvgJsRenderer } from './renderer' 13 | import { range } from './utils/range' 14 | 15 | // export types for Typedoc 16 | export type { 17 | Constructor, 18 | ReturnTypeOf, 19 | SVGuitarPlugin, 20 | Renderer, 21 | Alignment, 22 | GraphcisElement, 23 | RoughJsRenderer, 24 | SvgJsRenderer, 25 | ApiExtension, 26 | AnyFunction, 27 | UnionToIntersection, 28 | } 29 | 30 | // Chord diagram input types (compatible with Vexchords input, see https://github.com/0xfe/vexchords) 31 | export type SilentString = 'x' 32 | export type OpenString = 0 33 | export type Finger = [number, number | OpenString | SilentString, (string | FingerOptions)?] 34 | export type Barre = { 35 | fromString: number 36 | toString: number 37 | fret: number 38 | text?: string 39 | color?: string 40 | textColor?: string 41 | strokeWidth?: number 42 | strokeColor?: string 43 | className?: string 44 | } 45 | export type Chord = { 46 | /** 47 | * The fingers 48 | */ 49 | fingers: Finger[] 50 | 51 | /** 52 | * The barre chords 53 | */ 54 | barres: Barre[] 55 | /** 56 | * Position (defaults to 1). Can also be provided via {@link ChordSettings}. 57 | */ 58 | position?: number 59 | /** 60 | * Title of the chart. Can also be provided via {@link ChordSettings}. 61 | */ 62 | title?: string 63 | } 64 | 65 | export interface FingerOptions { 66 | text?: string 67 | color?: string 68 | textColor?: string 69 | shape?: Shape 70 | strokeColor?: string 71 | strokeWidth?: number 72 | className?: string 73 | } 74 | 75 | /** 76 | * Value for an open string (O) 77 | */ 78 | export const OPEN: OpenString = 0 79 | 80 | /** 81 | * Value for a silent string (X) 82 | */ 83 | export const SILENT: SilentString = 'x' 84 | 85 | /** 86 | * Possible positions of the fret label (eg. "3fr"). 87 | */ 88 | export enum FretLabelPosition { 89 | LEFT = 'left', 90 | RIGHT = 'right', 91 | } 92 | 93 | export type FretMarker = DoubleFretMarker | SingleFretMarker | number 94 | 95 | export interface SingleFretMarker { 96 | /** 97 | * Position of the fret marker. Starts at 0. 98 | * Position of 0 means between fret 0 and 1. 99 | */ 100 | fret: number, 101 | 102 | /** 103 | * The color of the fret marker. 104 | */ 105 | color?: string, 106 | 107 | /** 108 | * The stroke color of the fret marker. 109 | */ 110 | strokeColor?: string, 111 | /** 112 | * The stroke color of the fret marker. 113 | */ 114 | strokeWidth?: number, 115 | 116 | /** 117 | * Shape of the fret marker. Defaults to {@link Shape.CIRCLE} 118 | */ 119 | shape?: Shape 120 | 121 | /** 122 | * The relative size of a fret marker 123 | */ 124 | size?: number 125 | 126 | /** 127 | * Class name on the fret markers 128 | */ 129 | className?: string 130 | } 131 | export interface DoubleFretMarker extends SingleFretMarker { 132 | /** 133 | * Set to true if there should be two fret markers at this position 134 | */ 135 | double: true 136 | 137 | /** 138 | * Distance between double fret markers. 139 | * Overrides the default {@link ChordSettings#doubleFretMarkerDistance} 140 | */ 141 | distance?: number 142 | } 143 | 144 | export enum Shape { 145 | CIRCLE = 'circle', 146 | SQUARE = 'square', 147 | TRIANGLE = 'triangle', 148 | PENTAGON = 'pentagon', 149 | } 150 | 151 | export enum ChordStyle { 152 | normal = 'normal', 153 | handdrawn = 'handdrawn', 154 | } 155 | 156 | export enum Orientation { 157 | vertical = 'vertical', 158 | horizontal = 'horizontal', 159 | } 160 | 161 | export enum ElementType { 162 | FRET = 'fret', 163 | STRING = 'string', 164 | BARRE = 'barre', 165 | BARRE_TEXT = 'barre-text', 166 | FINGER = 'finger', 167 | TITLE = 'title', 168 | TUNING = 'tuning', 169 | FRET_POSITION = 'fret-position', 170 | FRET_MARKER = 'fret-marker', 171 | STRING_TEXT = 'string-text', 172 | SILENT_STRING = 'silent-string', 173 | OPEN_STRING = 'open-string', 174 | WATERMARK = 'watermark', 175 | } 176 | 177 | export interface ChordSettings { 178 | /** 179 | * Orientation of the chord diagram. Chose between "vertical" or "horizontal". Defaults to "vertical". 180 | */ 181 | orientation?: Orientation 182 | 183 | /** 184 | * Style of the chord diagram. Currently you can chose between "normal" and "handdrawn". 185 | */ 186 | style?: ChordStyle 187 | 188 | /** 189 | * The number of strings 190 | */ 191 | strings?: number 192 | 193 | /** 194 | * The number of frets 195 | */ 196 | frets?: number, 197 | 198 | /** 199 | * The fret markers 200 | */ 201 | fretMarkers?: FretMarker[] 202 | 203 | /** 204 | * Flag to show or disable all fret markers globally. This is just for convenience. 205 | * The fret markers can also be removed by not setting the {@link fretMarkers} property. 206 | */ 207 | showFretMarkers?: boolean, 208 | 209 | /** 210 | * The {@link Shape} of the fret markets. Applies to all fret markets unless overridden 211 | * on specific fret markers. 212 | */ 213 | fretMarkerShape?: Shape, 214 | 215 | /** 216 | * The size of a fret marker. This is relative to the space between two strings, so 217 | * a value of 1 means a fret marker spans from one string to another. 218 | */ 219 | fretMarkerSize?: number, 220 | 221 | /** 222 | * The color of the fret markers. 223 | */ 224 | fretMarkerColor?: string, 225 | 226 | /** 227 | * The stroke color of the fret markers. By default, the fret markets have no border. 228 | */ 229 | fretMarkerStrokeColor?: string, 230 | 231 | /** 232 | * The stroke width of the fret markers. By default, the fret markets have no border. 233 | */ 234 | fretMarkerStrokeWidth?: number, 235 | 236 | /** 237 | * The distance between the double fret markers, relative to the width 238 | * of the whole neck. E.g. 0.5 means the distance between the fret markers 239 | * is equivalent to 0.5 times the width of the whole neck. 240 | */ 241 | doubleFretMarkerDistance?: number 242 | 243 | /** 244 | * The starting fret (first fret is 1). The position can also be provided with the {@link Chord}. 245 | * If the position is provided via the chord, this value will be ignored. 246 | */ 247 | position?: number 248 | 249 | /** 250 | * These are the labels under the strings. Can be any string. 251 | */ 252 | tuning?: string[] 253 | 254 | /** 255 | * The position of the fret label (eg. "3fr") 256 | */ 257 | fretLabelPosition?: FretLabelPosition 258 | 259 | /** 260 | * The font size of the fret label 261 | */ 262 | fretLabelFontSize?: number 263 | 264 | /** 265 | * The font size of the string labels 266 | */ 267 | tuningsFontSize?: number 268 | 269 | /** 270 | * Size of a finger relative to the string spacing 271 | */ 272 | fingerSize?: number 273 | 274 | /** 275 | * Color of a finger 276 | */ 277 | fingerColor?: string 278 | 279 | /** 280 | * The color of text inside fingers or barres 281 | */ 282 | fingerTextColor?: string 283 | 284 | /** 285 | * The size of text inside fingers or barres 286 | */ 287 | fingerTextSize?: number 288 | 289 | /** 290 | * stroke color of a finger. Defaults to the finger color if not set 291 | */ 292 | fingerStrokeColor?: string 293 | 294 | /** 295 | * stroke width of a finger or barre 296 | */ 297 | fingerStrokeWidth?: number 298 | 299 | /** 300 | * stroke color of a barre chord. Defaults to the finger color if not set 301 | */ 302 | barreChordStrokeColor?: string 303 | 304 | /** 305 | * stroke width of a barre chord 306 | */ 307 | barreChordStrokeWidth?: number 308 | 309 | /** 310 | * Height of a fret, relative to the space between two strings 311 | */ 312 | fretSize?: number 313 | 314 | /** 315 | * The minimum side padding (from the guitar to the edge of the SVG) relative to the whole width. 316 | * This is only applied if it's larger than the letters inside of the padding (eg the starting 317 | * fret) 318 | */ 319 | sidePadding?: number 320 | 321 | /** 322 | * The font family used for all letters and numbers. Please not that when using the 'handdrawn' 323 | * chord diagram style setting the font family has no effect. 324 | */ 325 | fontFamily?: string 326 | 327 | /** 328 | * The title of the diagram. The title can also be provided with the {@link Chord}. 329 | * If the title is provided in the chord, this value will be ignored. 330 | */ 331 | title?: string 332 | 333 | /** 334 | * Font size of the title. This is only the initial font size. If the title doesn't fit, the title 335 | * is automatically scaled so that it fits. 336 | */ 337 | titleFontSize?: number 338 | 339 | /** 340 | * Space between the title and the chord diagram 341 | */ 342 | titleBottomMargin?: number 343 | 344 | /** 345 | * Global color of the whole diagram. Can be overridden with more specific color settings such as 346 | * @link titleColor or @link stringColor etc. 347 | */ 348 | color?: string 349 | 350 | /** 351 | * The background color of the chord diagram. By default the background is transparent. To set the 352 | * background to transparent either set this to 'none' or undefined 353 | */ 354 | backgroundColor?: string 355 | 356 | /** 357 | * The color of the title (overrides color) 358 | */ 359 | titleColor?: string 360 | 361 | /** 362 | * The color of the strings (overrides color) 363 | */ 364 | stringColor?: string 365 | 366 | /** 367 | * The color of the fret position (overrides color) 368 | */ 369 | fretLabelColor?: string 370 | 371 | /** 372 | * The color of the tunings (overrides color) 373 | */ 374 | tuningsColor?: string 375 | 376 | /** 377 | * The color of the frets (overrides color) 378 | */ 379 | fretColor?: string 380 | 381 | /** 382 | * Barre chord rectangle border radius relative to the fingerSize (eg. 1 means completely round 383 | * edges, 0 means not rounded at all) 384 | */ 385 | barreChordRadius?: number 386 | 387 | /** 388 | * Size of the Xs and Os above empty strings relative to the space between two strings 389 | */ 390 | emptyStringIndicatorSize?: number 391 | 392 | /** 393 | * Global stroke width 394 | */ 395 | strokeWidth?: number 396 | 397 | /** 398 | * The width of the nut (only used if position is 1) 399 | */ 400 | nutWidth?: number 401 | 402 | /** 403 | * The width of the nut (only used if position is 1). If `nutWidth` is set, this value will be ignored. 404 | * 405 | * @deprecated Use `nutWidth` instead. This will be removed in the next major version. 406 | */ 407 | topFretWidth?: number 408 | 409 | /** 410 | * If this is set to true, the fret (eg. 3fr) will not be shown. If the position is 1 the 411 | * nut will have the same width as all other frets. 412 | */ 413 | noPosition?: boolean 414 | 415 | /** 416 | * When set to true the distance between the chord diagram and the top of the SVG stayes the same, 417 | * no matter if a title is defined or not. 418 | */ 419 | fixedDiagramPosition?: boolean 420 | 421 | /** 422 | * Text of the watermark 423 | */ 424 | watermark?: string 425 | 426 | /** 427 | * Font size of the watermark 428 | */ 429 | watermarkFontSize?: number 430 | 431 | /** 432 | * Color of the watermark (overrides color) 433 | */ 434 | watermarkColor?: string 435 | 436 | /** 437 | * Font-family of the watermark (overrides fontFamily) 438 | */ 439 | watermarkFontFamily?: string 440 | 441 | /** 442 | * The title of the SVG. This is not visible in the SVG, but can be used for accessibility. 443 | */ 444 | svgTitle?: string 445 | } 446 | 447 | /** 448 | * All required chord settings. This interface is only used internally. From the outside, none of 449 | * the chord settings are required. 450 | */ 451 | interface RequiredChordSettings { 452 | style: ChordStyle 453 | strings: number 454 | frets: number 455 | position: number 456 | tuning: string[] 457 | tuningsFontSize: number 458 | fretLabelFontSize: number 459 | fretLabelPosition: FretLabelPosition 460 | fingerSize: number 461 | fingerTextColor: string 462 | fingerTextSize: number 463 | fingerStrokeWidth: number 464 | barreChordStrokeWidth: number 465 | sidePadding: number 466 | titleFontSize: number 467 | titleBottomMargin: number 468 | color: string 469 | emptyStringIndicatorSize: number 470 | strokeWidth: number 471 | nutWidth: number 472 | fretSize: number 473 | barreChordRadius: number 474 | fontFamily: string 475 | shape: Shape 476 | orientation: Orientation 477 | watermarkFontSize: number 478 | noPosition: boolean 479 | showFretMarkers: boolean, 480 | fretMarkerSize: number, 481 | fretMarkerShape: Shape, 482 | fretMarkerColor: string, 483 | doubleFretMarkerDistance: number, 484 | } 485 | 486 | const defaultSettings: RequiredChordSettings = { 487 | style: ChordStyle.normal, 488 | strings: 6, 489 | frets: 5, 490 | position: 1, 491 | tuning: [], 492 | tuningsFontSize: 28, 493 | fretLabelFontSize: 38, 494 | fretLabelPosition: FretLabelPosition.RIGHT, 495 | fingerSize: 0.65, 496 | fingerTextColor: '#FFF', 497 | fingerTextSize: 24, 498 | fingerStrokeWidth: 0, 499 | barreChordStrokeWidth: 0, 500 | sidePadding: 0.2, 501 | titleFontSize: 48, 502 | titleBottomMargin: 0, 503 | color: '#000', 504 | emptyStringIndicatorSize: 0.6, 505 | strokeWidth: 2, 506 | nutWidth: 10, 507 | fretSize: 1.5, 508 | barreChordRadius: 0.25, 509 | fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif', 510 | shape: Shape.CIRCLE, 511 | orientation: Orientation.vertical, 512 | watermarkFontSize: 12, 513 | noPosition: false, 514 | fretMarkerColor: 'rgba(0, 0, 0, 0.2)', 515 | fretMarkerSize: 0.4, 516 | doubleFretMarkerDistance: 0.4, 517 | fretMarkerShape: Shape.CIRCLE, 518 | showFretMarkers: true, 519 | } 520 | 521 | export class SVGuitarChord { 522 | static plugins: SVGuitarPlugin[] = [] 523 | 524 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 525 | static plugin< 526 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 527 | S extends Constructor & { plugins: any[] }, 528 | T extends SVGuitarPlugin | SVGuitarPlugin[], 529 | >(this: S, plugin: T) { 530 | const currentPlugins = this.plugins 531 | 532 | const BaseWithPlugins = class extends this { 533 | static plugins = currentPlugins.concat(plugin) 534 | } 535 | 536 | return BaseWithPlugins as typeof BaseWithPlugins & Constructor> 537 | } 538 | 539 | private rendererInternal?: Renderer 540 | 541 | private settings: ChordSettings = {} 542 | 543 | private chordInternal: Chord = { fingers: [], barres: [] } 544 | 545 | constructor(private container: QuerySelector | HTMLElement) { 546 | // apply plugins 547 | // https://stackoverflow.com/a/16345172 548 | const classConstructor = this.constructor as typeof SVGuitarChord 549 | classConstructor.plugins.forEach((plugin) => { 550 | Object.assign(this, plugin(this)) 551 | }) 552 | } 553 | 554 | private get renderer(): Renderer { 555 | if (!this.rendererInternal) { 556 | const style = this.settings.style ?? defaultSettings.style 557 | 558 | switch (style) { 559 | case ChordStyle.normal: 560 | this.rendererInternal = new SvgJsRenderer(this.container) 561 | break 562 | case ChordStyle.handdrawn: 563 | this.rendererInternal = new RoughJsRenderer(this.container) 564 | break 565 | default: 566 | throw new Error(`${style} is not a valid chord diagram style.`) 567 | } 568 | } 569 | 570 | return this.rendererInternal 571 | } 572 | 573 | configure(settings: ChordSettings): SVGuitarChord { 574 | SVGuitarChord.sanityCheckSettings(settings) 575 | 576 | // special case for style: remove current renderer instance if style changed. The new renderer 577 | // instance will be created lazily. 578 | if (settings.style !== this.settings.style) { 579 | this.renderer.remove() 580 | delete this.rendererInternal 581 | } 582 | 583 | this.settings = { ...this.settings, ...settings } 584 | 585 | return this 586 | } 587 | 588 | chord(chord: Chord): SVGuitarChord { 589 | this.chordInternal = chord 590 | 591 | return this 592 | } 593 | 594 | draw(): { width: number; height: number } { 595 | this.clear() 596 | this.drawBackground() 597 | 598 | if (this.settings.svgTitle) { 599 | this.renderer.title(this.settings.svgTitle) 600 | } 601 | 602 | let y 603 | 604 | y = this.drawTitle(this.settings.titleFontSize ?? defaultSettings.titleFontSize) 605 | y = this.drawEmptyStringIndicators(y) 606 | y = this.drawTopFret(y) 607 | this.drawPosition(y) 608 | y = this.drawGrid(y) 609 | y = this.drawTunings(y) 610 | y = this.drawWatermark(y) 611 | 612 | // now set the final height of the svg (and add some padding relative to the fret spacing) 613 | y += this.fretSpacing() / 10 614 | 615 | const width = this.width(constants.width, y) 616 | const height = this.height(y, constants.width) 617 | 618 | this.renderer.size(width, height) 619 | 620 | this.drawTopEdges(y) 621 | 622 | return { 623 | width: constants.width, 624 | height: y, 625 | } 626 | } 627 | 628 | static sanityCheckSettings(settings: Partial): void { 629 | if (typeof settings.strings !== 'undefined' && settings.strings <= 1) { 630 | throw new Error('Must have at least 2 strings') 631 | } 632 | 633 | if (typeof settings.frets !== 'undefined' && settings.frets < 0) { 634 | throw new Error('Cannot have less than 0 frets') 635 | } 636 | 637 | if (typeof settings.position !== 'undefined' && settings.position < 1) { 638 | throw new Error('Position cannot be less than 1') 639 | } 640 | 641 | if (typeof settings.fretSize !== 'undefined' && settings.fretSize < 0) { 642 | throw new Error('Fret size cannot be smaller than 0') 643 | } 644 | 645 | if (typeof settings.fingerSize !== 'undefined' && settings.fingerSize < 0) { 646 | throw new Error('Finger size cannot be smaller than 0') 647 | } 648 | 649 | if (typeof settings.strokeWidth !== 'undefined' && settings.strokeWidth < 0) { 650 | throw new Error('Stroke width cannot be smaller than 0') 651 | } 652 | 653 | if (typeof settings.doubleFretMarkerDistance !== 'undefined' 654 | && settings.doubleFretMarkerDistance < 0 655 | && settings.doubleFretMarkerDistance > 1 656 | ) { 657 | throw new Error('Double fret marker distance has to be a number between [0, 1]') 658 | } 659 | } 660 | 661 | private drawTunings(y: number) { 662 | // add some padding relative to the fret spacing 663 | const padding = this.fretSpacing() / 5 664 | const stringXPositions = this.stringXPos() 665 | const strings = this.numStrings() 666 | const color = this.settings.tuningsColor ?? this.settings.color ?? defaultSettings.color 667 | const tuning = this.settings.tuning ?? defaultSettings.tuning 668 | const fontFamily = this.settings.fontFamily ?? defaultSettings.fontFamily 669 | const tuningsFontSize = this.settings.tuningsFontSize ?? defaultSettings.tuningsFontSize 670 | 671 | let text: GraphcisElement | undefined 672 | 673 | tuning.forEach((tuning_, i): void => { 674 | if (i < strings) { 675 | const classNames = [ElementType.TUNING, `${ElementType.TUNING}-${i}`] 676 | 677 | const { x: textX, y: textY } = this.coordinates(stringXPositions[i], y + padding) 678 | 679 | const tuningText = this.renderer.text( 680 | tuning_, 681 | textX, 682 | textY, 683 | tuningsFontSize, 684 | color, 685 | fontFamily, 686 | Alignment.MIDDLE, 687 | classNames, 688 | true, 689 | ) 690 | 691 | if (tuning_) { 692 | text = tuningText 693 | } 694 | } 695 | }) 696 | 697 | if (text) { 698 | return y + this.height(text.height, text.width) 699 | } 700 | 701 | return y 702 | } 703 | 704 | private drawWatermark(y: number): number { 705 | if (!this.settings.watermark) { 706 | return y 707 | } 708 | 709 | const padding = this.fretSpacing() / 5 710 | const orientation = this.settings.orientation ?? defaultSettings.orientation 711 | const stringXPositions = this.stringXPos() 712 | const endX = stringXPositions[stringXPositions.length - 1] 713 | const startX = stringXPositions[0] 714 | 715 | const color = this.settings.watermarkColor ?? this.settings.color ?? defaultSettings.color 716 | const fontSize = this.settings.watermarkFontSize ?? defaultSettings.watermarkFontSize 717 | const fontFamily = 718 | this.settings.watermarkFontFamily ?? this.settings.fontFamily ?? defaultSettings.fontFamily 719 | 720 | let textX 721 | let textY 722 | 723 | if (orientation === Orientation.vertical) { 724 | textX = startX + (endX - startX) / 2 725 | textY = y + padding 726 | } else { 727 | const lastFret = y 728 | const firstFret = y - (this.numFrets()) * this.fretSpacing() 729 | textX = firstFret + (lastFret - firstFret) / 2 730 | textY = this.y(startX, 0) + padding 731 | } 732 | 733 | const { height } = this.renderer.text( 734 | this.settings.watermark, 735 | textX, 736 | textY, 737 | fontSize, 738 | color, 739 | fontFamily, 740 | Alignment.MIDDLE, 741 | ElementType.WATERMARK, 742 | ) 743 | 744 | return y + height * 2 745 | } 746 | 747 | private drawPosition(y: number): void { 748 | const position = 749 | this.chordInternal.position ?? this.settings.position ?? defaultSettings.position 750 | const noPosition = this.settings.noPosition ?? defaultSettings.noPosition 751 | 752 | if (position <= 1 || noPosition) { 753 | return 754 | } 755 | 756 | const stringXPositions = this.stringXPos() 757 | const endX = stringXPositions[stringXPositions.length - 1] 758 | const startX = stringXPositions[0] 759 | const text = `${position}fr` 760 | const size = this.settings.fretLabelFontSize ?? defaultSettings.fretLabelFontSize 761 | const color = this.settings.fretLabelColor ?? this.settings.color ?? defaultSettings.color 762 | const fingerSize = 763 | this.stringSpacing() * (this.settings.fingerSize ?? defaultSettings.fingerSize) 764 | const fontFamily = this.settings.fontFamily ?? defaultSettings.fontFamily 765 | const fretLabelPosition = this.settings.fretLabelPosition ?? defaultSettings.fretLabelPosition 766 | 767 | // add some padding relative to the string spacing. Also make sure the padding is at least 768 | // 1/2 fingerSize plus some padding to prevent the finger overlapping the position label. 769 | const padding = Math.max(this.stringSpacing() / 5, fingerSize / 2 + 5) 770 | const className = ElementType.FRET_POSITION 771 | 772 | if (this.orientation === Orientation.vertical) { 773 | const drawText = (sizeMultiplier = 1) => { 774 | if (sizeMultiplier < 0.01) { 775 | // text does not fit: don't render it at all. 776 | // eslint-disable-next-line no-console 777 | console.warn('Not enough space to draw the starting fret') 778 | return 779 | } 780 | 781 | if (fretLabelPosition === FretLabelPosition.RIGHT) { 782 | const svgText = this.renderer.text( 783 | text, 784 | endX + padding, 785 | y, 786 | size * sizeMultiplier, 787 | color, 788 | fontFamily, 789 | Alignment.LEFT, 790 | className, 791 | ) 792 | 793 | const { width, x } = svgText 794 | if (x + width > constants.width) { 795 | svgText.remove() 796 | drawText(sizeMultiplier * 0.9) 797 | } 798 | } else { 799 | const svgText = this.renderer.text( 800 | text, 801 | 1 / sizeMultiplier + startX - padding, 802 | y, 803 | size * sizeMultiplier, 804 | color, 805 | fontFamily, 806 | Alignment.RIGHT, 807 | className, 808 | ) 809 | 810 | const { x } = svgText 811 | if (x < 0) { 812 | svgText.remove() 813 | drawText(sizeMultiplier * 0.8) 814 | } 815 | } 816 | } 817 | 818 | drawText() 819 | 820 | return 821 | } 822 | 823 | // Horizontal orientation 824 | const { x: textX, y: textY } = 825 | fretLabelPosition === FretLabelPosition.RIGHT 826 | ? this.coordinates(endX + padding, y) 827 | : this.coordinates(startX - padding, y) 828 | this.renderer.text( 829 | text, 830 | textX, 831 | textY, 832 | size, 833 | color, 834 | fontFamily, 835 | Alignment.MIDDLE, 836 | className, 837 | true, 838 | ) 839 | } 840 | 841 | /** 842 | * Hack to prevent the empty space of the svg from being cut off without having to define a 843 | * fixed width 844 | */ 845 | private drawTopEdges(y: number) { 846 | const orientation = this.settings.orientation ?? defaultSettings.orientation 847 | 848 | const xTopRight = orientation === Orientation.vertical ? constants.width : y 849 | 850 | this.renderer.circle(0, 0, 0, 0, 'transparent', 'none', 'top-left') 851 | this.renderer.circle(xTopRight, 0, 0, 0, 'transparent', 'none', 'top-right') 852 | } 853 | 854 | private drawBackground() { 855 | if (this.settings.backgroundColor) { 856 | this.renderer.background(this.settings.backgroundColor) 857 | } 858 | } 859 | 860 | private drawTopFret(y: number): number { 861 | const stringXpositions = this.stringXPos() 862 | const strokeWidth = this.settings.strokeWidth ?? defaultSettings.strokeWidth 863 | const nutWidth = 864 | this.settings.topFretWidth ?? this.settings.nutWidth ?? defaultSettings.nutWidth 865 | const startX = stringXpositions[0] - strokeWidth / 2 866 | const endX = stringXpositions[stringXpositions.length - 1] + strokeWidth / 2 867 | const position = 868 | this.chordInternal.position ?? this.settings.position ?? defaultSettings.position 869 | const color = this.settings.fretColor ?? this.settings.color ?? defaultSettings.color 870 | const noPositon = this.settings.noPosition ?? defaultSettings.noPosition 871 | 872 | let fretSize: number 873 | if (position > 1 || noPositon) { 874 | fretSize = strokeWidth 875 | } else { 876 | fretSize = nutWidth 877 | } 878 | 879 | const { x: lineX1, y: lineY1 } = this.coordinates(startX, y + fretSize / 2) 880 | const { x: lineX2, y: lineY2 } = this.coordinates(endX, y + fretSize / 2) 881 | 882 | this.renderer.line(lineX1, lineY1, lineX2, lineY2, fretSize, color, ['top-fret', 'fret-0']) 883 | 884 | return y + fretSize 885 | } 886 | 887 | private stringXPos(): number[] { 888 | const strings = this.numStrings() 889 | const sidePadding = this.settings.sidePadding ?? defaultSettings.sidePadding 890 | const startX = constants.width * sidePadding 891 | const stringsSpacing = this.stringSpacing() 892 | 893 | return range(strings).map((i) => startX + stringsSpacing * i) 894 | } 895 | 896 | private numStrings() { 897 | return this.settings.strings ?? defaultSettings.strings 898 | } 899 | 900 | private numFrets() { 901 | return this.settings.frets ?? defaultSettings.frets 902 | } 903 | 904 | private stringSpacing(): number { 905 | const sidePadding = this.settings.sidePadding ?? defaultSettings.sidePadding 906 | const strings = this.numStrings() 907 | const startX = constants.width * sidePadding 908 | const endX = constants.width - startX 909 | const width = endX - startX 910 | 911 | return width / (strings - 1) 912 | } 913 | 914 | private fretSpacing(): number { 915 | const stringSpacing = this.stringSpacing() 916 | const fretSize = this.settings.fretSize ?? defaultSettings.fretSize 917 | 918 | return stringSpacing * fretSize 919 | } 920 | 921 | private fretLinesYPos(startY: number): number[] { 922 | const frets = this.numFrets() 923 | const fretSpacing = this.fretSpacing() 924 | 925 | return range(frets, 1).map((i) => startY + fretSpacing * i) 926 | } 927 | 928 | private toArrayIndex(stringIndex: number): number { 929 | const strings = this.numStrings() 930 | 931 | return Math.abs(stringIndex - strings) 932 | } 933 | 934 | private drawEmptyStringIndicators(y: number): number { 935 | const stringXPositions = this.stringXPos() 936 | const stringSpacing = this.stringSpacing() 937 | const emptyStringIndicatorSize = 938 | this.settings.emptyStringIndicatorSize ?? defaultSettings.emptyStringIndicatorSize 939 | const size = emptyStringIndicatorSize * stringSpacing 940 | // add some space above and below the indicator, relative to the indicator size 941 | const padding = size / 3 942 | const color = this.settings.color ?? defaultSettings.color 943 | const strokeWidth = this.settings.strokeWidth ?? defaultSettings.strokeWidth 944 | 945 | let hasEmpty = false 946 | 947 | this.chordInternal.fingers 948 | .filter(([, value]) => value === SILENT || value === OPEN) 949 | .map(([index, value, textOrOptions]) => [ 950 | this.toArrayIndex(index), 951 | value, 952 | textOrOptions, 953 | ]) 954 | .forEach(([stringIndex, value, textOrOptions]) => { 955 | hasEmpty = true 956 | 957 | const fingerOptions = SVGuitarChord.getFingerOptions(textOrOptions) 958 | const effectiveStrokeWidth = fingerOptions.strokeWidth ?? strokeWidth 959 | const effectiveStrokeColor = fingerOptions.strokeColor ?? color 960 | 961 | if (fingerOptions.text) { 962 | const textColor = fingerOptions.textColor ?? this.settings.color ?? defaultSettings.color 963 | const textSize = this.settings.fingerTextSize ?? defaultSettings.fingerTextSize 964 | const fontFamily = this.settings.fontFamily ?? defaultSettings.fontFamily 965 | const classNames = [ElementType.STRING_TEXT, `${ElementType.STRING_TEXT}-${stringIndex}`] 966 | 967 | const { x: textX, y: textY } = this.coordinates( 968 | stringXPositions[stringIndex], 969 | y + padding + size / 2, 970 | ) 971 | 972 | this.renderer.text( 973 | fingerOptions.text, 974 | textX, 975 | textY, 976 | textSize, 977 | textColor, 978 | fontFamily, 979 | Alignment.MIDDLE, 980 | classNames, 981 | true, 982 | ) 983 | } 984 | 985 | if (value === OPEN) { 986 | // draw an O 987 | const classNames = [ElementType.OPEN_STRING, `${ElementType.OPEN_STRING}-${stringIndex}`] 988 | 989 | const { x: lineX1, y: lineY1 } = this.rectCoordinates( 990 | stringXPositions[stringIndex] - size / 2, 991 | y + padding, 992 | size, 993 | size, 994 | ) 995 | 996 | this.renderer.circle( 997 | lineX1, 998 | lineY1, 999 | size, 1000 | effectiveStrokeWidth, 1001 | effectiveStrokeColor, 1002 | undefined, 1003 | classNames, 1004 | ) 1005 | } else { 1006 | // draw an X 1007 | const classNames = [ 1008 | ElementType.SILENT_STRING, 1009 | `${ElementType.SILENT_STRING}-${stringIndex}`, 1010 | ] 1011 | const startX = stringXPositions[stringIndex] - size / 2 1012 | const endX = startX + size 1013 | const startY = y + padding 1014 | const endY = startY + size 1015 | 1016 | const { x: line1X1, y: line1Y1 } = this.coordinates(startX, startY) 1017 | const { x: line1X2, y: line1Y2 } = this.coordinates(endX, endY) 1018 | 1019 | this.renderer.line( 1020 | line1X1, 1021 | line1Y1, 1022 | line1X2, 1023 | line1Y2, 1024 | effectiveStrokeWidth, 1025 | effectiveStrokeColor, 1026 | classNames, 1027 | ) 1028 | 1029 | const { x: line2X1, y: line2Y1 } = this.coordinates(startX, endY) 1030 | const { x: line2X2, y: line2Y2 } = this.coordinates(endX, startY) 1031 | 1032 | this.renderer.line( 1033 | line2X1, 1034 | line2Y1, 1035 | line2X2, 1036 | line2Y2, 1037 | effectiveStrokeWidth, 1038 | effectiveStrokeColor, 1039 | classNames, 1040 | ) 1041 | } 1042 | }) 1043 | 1044 | return hasEmpty || this.settings.fixedDiagramPosition ? y + size + 2 * padding : y + padding 1045 | } 1046 | 1047 | private drawGrid(y: number): number { 1048 | const frets = this.numFrets() 1049 | const fretSize = this.settings.fretSize ?? defaultSettings.fretSize 1050 | const relativeFingerSize = this.settings.fingerSize ?? defaultSettings.fingerSize 1051 | const stringXPositions = this.stringXPos() 1052 | const fretYPositions = this.fretLinesYPos(y) 1053 | const stringSpacing = this.stringSpacing() 1054 | const fretSpacing = stringSpacing * fretSize 1055 | const height = fretSpacing * frets 1056 | 1057 | const startX = stringXPositions[0] 1058 | const endX = stringXPositions[stringXPositions.length - 1] 1059 | 1060 | const fingerSize = relativeFingerSize * stringSpacing 1061 | const fingerColor = this.settings.fingerColor ?? this.settings.color ?? defaultSettings.color 1062 | const fretColor = this.settings.fretColor ?? this.settings.color ?? defaultSettings.color 1063 | const barreChordRadius = this.settings.barreChordRadius ?? defaultSettings.barreChordRadius 1064 | const strokeWidth = this.settings.strokeWidth ?? defaultSettings.strokeWidth 1065 | const fontFamily = this.settings.fontFamily ?? defaultSettings.fontFamily 1066 | const fingerTextColor = this.settings.fingerTextColor ?? defaultSettings.fingerTextColor 1067 | const fingerTextSize = this.settings.fingerTextSize ?? defaultSettings.fingerTextSize 1068 | 1069 | // draw frets 1070 | fretYPositions.forEach((fretY, i) => { 1071 | const classNames = [ElementType.FRET, `${ElementType.FRET}-${i}`] 1072 | const { x: lineX1, y: lineY1 } = this.coordinates(startX, fretY) 1073 | const { x: lineX2, y: lineY2 } = this.coordinates(endX, fretY) 1074 | 1075 | this.renderer.line(lineX1, lineY1, lineX2, lineY2, strokeWidth, fretColor, classNames) 1076 | }) 1077 | 1078 | // draw strings 1079 | stringXPositions.forEach((stringX, i) => { 1080 | const classNames = [ElementType.STRING, `${ElementType.STRING}-${i}`] 1081 | 1082 | const { x: lineX1, y: lineY1 } = this.coordinates(stringX, y) 1083 | const { x: lineX2, y: lineY2 } = this.coordinates(stringX, y + height + strokeWidth / 2) 1084 | 1085 | this.renderer.line(lineX1, lineY1, lineX2, lineY2, strokeWidth, fretColor, classNames) 1086 | }) 1087 | 1088 | // draw barre chords 1089 | this.chordInternal.barres.forEach( 1090 | ({ 1091 | fret, 1092 | fromString, 1093 | toString, 1094 | text, 1095 | color, 1096 | textColor, 1097 | strokeColor, 1098 | className, 1099 | strokeWidth: individualBarreChordStrokeWidth, 1100 | }) => { 1101 | const barreCenterY = fretYPositions[fret - 1] - strokeWidth / 4 - fretSpacing / 2 1102 | const fromStringX = stringXPositions[this.toArrayIndex(fromString)] 1103 | const distance = Math.abs(toString - fromString) * stringSpacing 1104 | 1105 | const barreChordStrokeColor = 1106 | strokeColor ?? 1107 | this.settings.barreChordStrokeColor ?? 1108 | this.settings.fingerColor ?? 1109 | this.settings.color ?? 1110 | defaultSettings.color 1111 | const barreChordStrokeWidth = 1112 | individualBarreChordStrokeWidth ?? 1113 | this.settings.barreChordStrokeWidth ?? 1114 | defaultSettings.barreChordStrokeWidth 1115 | 1116 | const classNames = [ 1117 | ElementType.BARRE, 1118 | `${ElementType.BARRE}-fret-${fret - 1}`, 1119 | ...(className ? [className] : []), 1120 | ] 1121 | 1122 | const barreWidth = distance + stringSpacing / 2 1123 | const barreHeight = fingerSize 1124 | 1125 | const { 1126 | x: rectX, 1127 | y: rectY, 1128 | height: rectHeight, 1129 | width: rectWidth, 1130 | } = this.rectCoordinates( 1131 | fromStringX - stringSpacing / 4, 1132 | barreCenterY - fingerSize / 2, 1133 | barreWidth, 1134 | barreHeight, 1135 | ) 1136 | 1137 | this.renderer.rect( 1138 | rectX, 1139 | rectY, 1140 | rectWidth, 1141 | rectHeight, 1142 | barreChordStrokeWidth, 1143 | barreChordStrokeColor, 1144 | classNames, 1145 | color ?? fingerColor, 1146 | fingerSize * barreChordRadius, 1147 | ) 1148 | 1149 | // draw text on the barre chord 1150 | if (text) { 1151 | const textClassNames = [ElementType.BARRE_TEXT, `${ElementType.BARRE_TEXT}-${fret}`] 1152 | 1153 | const { x: textX, y: textY } = this.coordinates(fromStringX + distance / 2, barreCenterY) 1154 | 1155 | this.renderer.text( 1156 | text, 1157 | textX, 1158 | textY, 1159 | fingerTextSize, 1160 | textColor ?? fingerTextColor, 1161 | fontFamily, 1162 | Alignment.MIDDLE, 1163 | textClassNames, 1164 | true, 1165 | ) 1166 | } 1167 | }, 1168 | ) 1169 | 1170 | // draw fingers 1171 | this.chordInternal.fingers 1172 | .filter(([, value]) => value !== SILENT && value !== OPEN) 1173 | .map<[number, number, string | FingerOptions | undefined]>( 1174 | ([stringIndex, fretIndex, text]) => [ 1175 | this.toArrayIndex(stringIndex), 1176 | fretIndex as number, 1177 | text, 1178 | ], 1179 | ) 1180 | .forEach(([stringIndex, fretIndex, textOrOptions]) => { 1181 | const fingerCenterX = startX + stringIndex * stringSpacing 1182 | const fingerCenterY = y + fretIndex * fretSpacing - fretSpacing / 2 1183 | const fingerOptions = SVGuitarChord.getFingerOptions(textOrOptions) 1184 | 1185 | const classNames = [ 1186 | ElementType.FINGER, 1187 | `${ElementType.FINGER}-string-${stringIndex}`, 1188 | `${ElementType.FINGER}-fret-${fretIndex - 1}`, 1189 | `${ElementType.FINGER}-string-${stringIndex}-fret-${fretIndex - 1}`, 1190 | ...(fingerOptions.className ? [fingerOptions.className] : []), 1191 | ] 1192 | 1193 | this.drawFinger( 1194 | fingerCenterX, 1195 | fingerCenterY, 1196 | fingerSize, 1197 | fingerColor, 1198 | fingerTextSize, 1199 | fontFamily, 1200 | fingerOptions, 1201 | classNames, 1202 | ) 1203 | }) 1204 | 1205 | if (this.settings.showFretMarkers ?? defaultSettings.showFretMarkers) { 1206 | this.settings.fretMarkers 1207 | ?.forEach((fretMarker) => { 1208 | const fretMarkerOptions = (typeof fretMarker == 'number' ? { 1209 | fret: fretMarker, 1210 | } : fretMarker) as DoubleFretMarker | SingleFretMarker 1211 | 1212 | if (fretMarkerOptions.fret >= this.numFrets()) { 1213 | // don't draw fret markers outside the chord diagram 1214 | return; 1215 | } 1216 | 1217 | const fretMarkerIndex = fretMarkerOptions.fret 1218 | const fretMarkerCenterX = constants.width / 2 1219 | const fretMarkerCenterY = y + (fretMarkerIndex + 1) * fretSpacing - fretSpacing / 2 1220 | const fretMarkerSize = this.settings.fretMarkerSize ?? defaultSettings.fretMarkerSize 1221 | const fretMarkerColor = this.settings.fretMarkerColor ?? defaultSettings.fretMarkerColor 1222 | 1223 | const classNames = [ 1224 | ElementType.FRET_MARKER, 1225 | `${ElementType.FRET_MARKER}-fret-${fretMarkerIndex}`, 1226 | ...(fretMarkerOptions.className ?? []), 1227 | ] 1228 | 1229 | if ('double' in fretMarkerOptions) { 1230 | this.stringSpacing() 1231 | const doubleFretMarkerDistance = fretMarkerOptions.distance ?? this.settings.doubleFretMarkerDistance ?? defaultSettings.doubleFretMarkerDistance 1232 | 1233 | const neckWidth = (this.numStrings() - 1) * this.stringSpacing(); 1234 | const fretMarkerDistanceFromCenter = neckWidth * doubleFretMarkerDistance / 2 1235 | 1236 | this.drawFretMarker( 1237 | fretMarkerCenterX - fretMarkerDistanceFromCenter, 1238 | fretMarkerCenterY, 1239 | fretMarkerSize, 1240 | fretMarkerColor, 1241 | fretMarker, 1242 | classNames 1243 | ) 1244 | this.drawFretMarker( 1245 | fretMarkerCenterX + fretMarkerDistanceFromCenter, 1246 | fretMarkerCenterY, 1247 | fretMarkerSize, 1248 | fretMarkerColor, 1249 | fretMarker, 1250 | classNames 1251 | ) 1252 | } else { 1253 | this.drawFretMarker( 1254 | fretMarkerCenterX, 1255 | fretMarkerCenterY, 1256 | fretMarkerSize, 1257 | fretMarkerColor, 1258 | fretMarker, 1259 | classNames 1260 | ) 1261 | } 1262 | }); 1263 | } 1264 | 1265 | return y + height 1266 | } 1267 | 1268 | private drawFretMarker( 1269 | x: number, 1270 | y: number, 1271 | size: number, 1272 | color: string, 1273 | fretMarketOptions: FretMarker, 1274 | classNames: string[], 1275 | ) { 1276 | const markerOptions = typeof fretMarketOptions === 'number' ? {fret: fretMarketOptions} : fretMarketOptions 1277 | 1278 | const shape = markerOptions.shape ?? defaultSettings.fretMarkerShape 1279 | const fretMarkerColor = markerOptions.color ?? this.settings.fretMarkerColor ?? defaultSettings.fretMarkerColor 1280 | const fretMarkerStrokeColor = markerOptions.strokeColor ?? this.settings.fretMarkerStrokeColor ?? color 1281 | const fretMarkerStrokeWidth = markerOptions.strokeWidth ?? this.settings.fretMarkerStrokeWidth ?? 0 1282 | 1283 | const fretMarkerSize = this.stringSpacing() * (markerOptions.size ?? size) 1284 | const startX = x - fretMarkerSize / 2 1285 | const startY = y - fretMarkerSize / 2 1286 | 1287 | const classNamesWithShape = [...classNames, `${ElementType.FRET_MARKER}-${shape}`] 1288 | 1289 | const { x: x0, y: y0 } = this.rectCoordinates(startX, startY, fretMarkerSize, fretMarkerSize) 1290 | 1291 | this.drawShape( 1292 | shape, 1293 | x0, 1294 | y0, 1295 | fretMarkerSize, 1296 | fretMarkerStrokeWidth, 1297 | fretMarkerStrokeColor, 1298 | fretMarkerColor ?? color, 1299 | classNamesWithShape 1300 | ) 1301 | 1302 | } 1303 | 1304 | private drawFinger( 1305 | x: number, 1306 | y: number, 1307 | size: number, 1308 | color: string, 1309 | textSize: number, 1310 | fontFamily: string, 1311 | fingerOptions: FingerOptions, 1312 | classNames: string[], 1313 | ) { 1314 | const shape = fingerOptions.shape ?? defaultSettings.shape 1315 | const fingerTextColor = 1316 | fingerOptions.textColor ?? this.settings.fingerTextColor ?? defaultSettings.fingerTextColor 1317 | const fingerStrokeColor = 1318 | fingerOptions.strokeColor ?? 1319 | this.settings.fingerStrokeColor ?? 1320 | this.settings.fingerColor ?? 1321 | this.settings.color ?? 1322 | defaultSettings.color 1323 | const fingerStrokeWidth = 1324 | fingerOptions.strokeWidth ?? 1325 | this.settings.fingerStrokeWidth ?? 1326 | defaultSettings.fingerStrokeWidth 1327 | const startX = x - size / 2 1328 | const startY = y - size / 2 1329 | 1330 | const classNamesWithShape = [...classNames, `${ElementType.FINGER}-${shape}`] 1331 | 1332 | const { x: x0, y: y0 } = this.rectCoordinates(startX, startY, size, size) 1333 | 1334 | this.drawShape( 1335 | shape, 1336 | x0, 1337 | y0, 1338 | size, 1339 | fingerStrokeWidth, 1340 | fingerStrokeColor, 1341 | fingerOptions.color ?? color, 1342 | classNamesWithShape 1343 | ) 1344 | 1345 | // draw text on the finger 1346 | const textClassNames = [...classNames, `${ElementType.FINGER}-text`] 1347 | if (fingerOptions.text) { 1348 | const { x: textX, y: textY } = this.coordinates(x, y) 1349 | 1350 | this.renderer.text( 1351 | fingerOptions.text, 1352 | textX, 1353 | textY, 1354 | textSize, 1355 | fingerOptions.textColor ?? fingerTextColor, 1356 | fontFamily, 1357 | Alignment.MIDDLE, 1358 | textClassNames, 1359 | true, 1360 | ) 1361 | } 1362 | } 1363 | 1364 | private drawShape( 1365 | shape: Shape, 1366 | x: number, 1367 | y: number, 1368 | size: number, 1369 | strokeWidth: number, 1370 | strokeColor: string, 1371 | fillColor: string, 1372 | classNames: string[] 1373 | ) { 1374 | switch (shape) { 1375 | case Shape.CIRCLE: 1376 | this.renderer.circle( 1377 | x, 1378 | y, 1379 | size, 1380 | strokeWidth, 1381 | strokeColor, 1382 | fillColor, 1383 | classNames, 1384 | ) 1385 | break 1386 | case Shape.SQUARE: 1387 | this.renderer.rect( 1388 | x, 1389 | y, 1390 | size, 1391 | size, 1392 | strokeWidth, 1393 | strokeColor, 1394 | classNames, 1395 | fillColor, 1396 | ) 1397 | break 1398 | case Shape.TRIANGLE: 1399 | this.renderer.triangle( 1400 | x, 1401 | y, 1402 | size, 1403 | strokeWidth, 1404 | strokeColor, 1405 | classNames, 1406 | fillColor, 1407 | ) 1408 | break 1409 | case Shape.PENTAGON: 1410 | this.renderer.pentagon( 1411 | x, 1412 | y, 1413 | size, 1414 | strokeWidth, 1415 | strokeColor, 1416 | fillColor, 1417 | classNames, 1418 | ) 1419 | break 1420 | default: 1421 | throw new Error( 1422 | `Invalid shape "${shape}". Valid shapes are: ${Object.values(Shape) 1423 | .map((val) => `"${val}"`) 1424 | .join(', ')}.`, 1425 | ) 1426 | } 1427 | } 1428 | 1429 | private drawTitle(size: number): number { 1430 | const color = this.settings.color ?? defaultSettings.color 1431 | const titleBottomMargin = this.settings.titleBottomMargin ?? defaultSettings.titleBottomMargin 1432 | const fontFamily = this.settings.fontFamily ?? defaultSettings.fontFamily 1433 | 1434 | // This is somewhat of a hack to get a steady diagram position: If no title is defined we initially 1435 | // render an 'X' and later remove it again. That way we get the same y as if there was a title. I tried 1436 | // just rendering a space but that doesn't work. 1437 | const title = 1438 | this.chordInternal.title ?? 1439 | this.settings.title ?? 1440 | (this.settings.fixedDiagramPosition ? 'X' : '') 1441 | 1442 | // draw the title 1443 | if (this.orientation === Orientation.vertical) { 1444 | const { x, y, width, height, remove } = this.renderer.text( 1445 | title, 1446 | constants.width / 2, 1447 | 5, 1448 | size, 1449 | color, 1450 | fontFamily, 1451 | Alignment.MIDDLE, 1452 | ElementType.TITLE, 1453 | ) 1454 | 1455 | // check if the title fits. If not, try with a smaller size 1456 | if (x < -0.0001) { 1457 | remove() 1458 | 1459 | // try again with smaller font 1460 | return this.drawTitle(size * (constants.width / width) * 0.97) 1461 | } 1462 | 1463 | if (!this.settings.title && this.settings.fixedDiagramPosition) { 1464 | remove() 1465 | } 1466 | 1467 | return y + height + titleBottomMargin 1468 | } 1469 | 1470 | // render temporary text to get the height of the title 1471 | const { remove: removeTempText, width } = this.renderer.text( 1472 | title, 1473 | 0, 1474 | 0, 1475 | size, 1476 | color, 1477 | fontFamily, 1478 | Alignment.LEFT, 1479 | ElementType.TITLE, 1480 | ) 1481 | removeTempText() 1482 | 1483 | const { x: textX, y: textY } = this.rectCoordinates(constants.width / 2, 5, 0, 0) 1484 | 1485 | const { remove } = this.renderer.text( 1486 | title, 1487 | textX, 1488 | textY, 1489 | size, 1490 | color, 1491 | fontFamily, 1492 | Alignment.LEFT, 1493 | ElementType.TITLE, 1494 | true, 1495 | ) 1496 | 1497 | if (!this.settings.title && this.settings.fixedDiagramPosition) { 1498 | remove() 1499 | } 1500 | 1501 | return width + titleBottomMargin 1502 | } 1503 | 1504 | clear(): void { 1505 | this.renderer.clear() 1506 | } 1507 | 1508 | /** 1509 | * Completely remove the diagram from the DOM 1510 | */ 1511 | remove(): void { 1512 | this.renderer.remove() 1513 | } 1514 | 1515 | /** 1516 | * Helper method to get an options object from the 3rd array value for a finger, that can either 1517 | * be undefined, a string or and options object. This method will return an options object in 1518 | * any case, so it's easier to work with this third value. 1519 | * 1520 | * @param textOrOptions 1521 | */ 1522 | private static getFingerOptions( 1523 | textOrOptions: string | FingerOptions | undefined, 1524 | ): FingerOptions { 1525 | if (!textOrOptions) { 1526 | return {} 1527 | } 1528 | 1529 | if (typeof textOrOptions === 'string') { 1530 | return { 1531 | text: textOrOptions, 1532 | } 1533 | } 1534 | 1535 | return textOrOptions 1536 | } 1537 | 1538 | /** 1539 | * rotates x value if orientation is horizontal 1540 | * 1541 | * @param x x in vertical orientation 1542 | * @param y y in vertical orientation 1543 | * @returns 1544 | */ 1545 | private x(x: number, y: number): number { 1546 | return this.orientation === Orientation.vertical ? x : y 1547 | } 1548 | 1549 | /** 1550 | * rotates y value if orientation is horizontal 1551 | * 1552 | * @param x x in vertical orientation 1553 | * @param y y in vertical orientation 1554 | * @returns 1555 | */ 1556 | private y(x: number, y: number): number { 1557 | return this.orientation === Orientation.vertical ? y : Math.abs(x - constants.width) 1558 | } 1559 | 1560 | /** 1561 | * rotates coordinates if orientation is horizontal 1562 | * 1563 | * @param x x in vertical orientation 1564 | * @param y y in vertical orientation 1565 | * @returns 1566 | */ 1567 | private coordinates(x: number, y: number): { x: number; y: number } { 1568 | return { 1569 | x: this.x(x, y), 1570 | y: this.y(x, y), 1571 | } 1572 | } 1573 | 1574 | /** 1575 | * rotates coordinates of a rectangle if orientation is horizontal 1576 | * 1577 | * @param x x in vertical orientation 1578 | * @param y y in vertical orientation 1579 | * @param width width in vertical orientation 1580 | * @param height height in vertical orientation 1581 | * @returns 1582 | */ 1583 | private rectCoordinates( 1584 | x: number, 1585 | y: number, 1586 | width: number, 1587 | height: number, 1588 | ): { x: number; y: number; width: number; height: number } { 1589 | if (this.orientation === Orientation.vertical) { 1590 | return { 1591 | x, 1592 | y, 1593 | width, 1594 | height, 1595 | } 1596 | } 1597 | 1598 | return { 1599 | x: this.x(x, y), 1600 | y: this.y(x, y) - width, 1601 | width: this.width(width, height), 1602 | height: this.height(height, width), 1603 | } 1604 | } 1605 | 1606 | /** 1607 | * rotates height if orientation is horizontal 1608 | * 1609 | * @param height_ height in vertical orientation 1610 | * @param width width in vertical orientation 1611 | * @returns 1612 | */ 1613 | private height(height_: number, width: number): number { 1614 | return this.orientation === Orientation.vertical ? height_ : width 1615 | } 1616 | 1617 | /** 1618 | * rotates width if orientation is horizontal 1619 | * 1620 | * @param width_ width in vertical orientation 1621 | * @param height height in vertical orientation 1622 | * @returns 1623 | */ 1624 | private width(width_: number, height: number): number { 1625 | return this.orientation === Orientation.horizontal ? height : width_ 1626 | } 1627 | 1628 | private get orientation(): Orientation { 1629 | return this.settings.orientation ?? defaultSettings.orientation 1630 | } 1631 | } 1632 | -------------------------------------------------------------------------------- /src/utils/is-node.ts: -------------------------------------------------------------------------------- 1 | export function isNode(): boolean { 2 | // tslint:disable-next-line:strict-type-predicates 3 | return typeof process !== 'undefined' && process.versions != null && process.versions.node != null 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/range.ts: -------------------------------------------------------------------------------- 1 | export function range(length: number, from = 0): number[] { 2 | return Array.from({ length }, (_, i) => i + from) 3 | } 4 | -------------------------------------------------------------------------------- /test/plugin.test.ts: -------------------------------------------------------------------------------- 1 | import { SVGuitarChord } from '../src/svguitar' 2 | import { setUpSvgDom } from './testutils' 3 | 4 | const document = setUpSvgDom() 5 | 6 | describe('SVGuitarChord Plugin', () => { 7 | let container: HTMLElement 8 | 9 | beforeEach(() => { 10 | container = document.documentElement 11 | }) 12 | 13 | test('should apply a basic plugin', () => { 14 | // given 15 | const spy = jest.fn() 16 | function myFooPlugin(/* instance: SVGuitarChord */): { foo: jest.Mock } { 17 | return { 18 | foo: spy, 19 | } 20 | } 21 | 22 | // when 23 | const PluginTest = SVGuitarChord.plugin(myFooPlugin) 24 | const withPlugin = new PluginTest(container) 25 | withPlugin.foo() 26 | 27 | // then 28 | expect(spy).toHaveBeenCalledWith() 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /test/svguitar.test.ts: -------------------------------------------------------------------------------- 1 | import { FretLabelPosition, Orientation, Shape, SVGuitarChord } from '../src/svguitar' 2 | import { saveSvg, setUpSvgDom } from './testutils' 3 | 4 | const document = setUpSvgDom() 5 | 6 | describe('SVGuitarChord', () => { 7 | let container: HTMLElement 8 | let svguitar: SVGuitarChord 9 | 10 | beforeEach(() => { 11 | container = document.documentElement 12 | svguitar = new SVGuitarChord(container) 13 | }) 14 | 15 | it('Should create an instance of the SVGuitarChord class', () => { 16 | expect(svguitar).toBeTruthy() 17 | }) 18 | it('Should completely remove the diagram from the DOM when removing', () => { 19 | // given 20 | svguitar.draw() 21 | 22 | // when 23 | svguitar.remove() 24 | 25 | // then 26 | expect(container.querySelector('svg')).toBeNull() 27 | }) 28 | 29 | it('Should render an svg of an arbitrary chord', () => { 30 | svguitar 31 | .chord({ 32 | fingers: [ 33 | [1, 2, '1'], 34 | [2, 1, '2'], 35 | [3, 2, '3'], 36 | [4, 0], // fret 0 = open string 37 | [5, 'x'], // fret x = muted string 38 | ], 39 | barres: [ 40 | { 41 | fret: 3, 42 | fromString: 4, 43 | toString: 1, 44 | text: 'B', 45 | }, 46 | ], 47 | }) 48 | .configure({ 49 | position: 5, 50 | tuning: ['1', '2', '3', '4', '5', '6'], 51 | strings: 5, 52 | frets: 6, 53 | title: 'Amaj7', 54 | }) 55 | .draw() 56 | 57 | saveSvg('arbitrary chord', container.outerHTML) 58 | }) 59 | 60 | it('Should render an svg of a horizontal chart', () => { 61 | svguitar 62 | .chord({ 63 | fingers: [ 64 | [1, 2, '1'], 65 | [2, 1, '2'], 66 | [3, 2, '3'], 67 | [4, 0], // fret 0 = open string 68 | [5, 'x'], // fret x = muted string 69 | ], 70 | barres: [ 71 | { 72 | fret: 3, 73 | fromString: 4, 74 | toString: 1, 75 | text: 'B', 76 | }, 77 | ], 78 | }) 79 | .configure({ 80 | position: 5, 81 | tuning: ['1', '2', '3', '4', '5', '6'], 82 | orientation: Orientation.horizontal, 83 | strings: 5, 84 | frets: 6, 85 | title: 'Amaj7', 86 | }) 87 | .draw() 88 | 89 | saveSvg('horizontal chord', container.outerHTML) 90 | }) 91 | 92 | it('Should render an svg of a horizontal chart', () => { 93 | svguitar 94 | .chord({ 95 | fingers: [], 96 | barres: [], 97 | }) 98 | .configure({ 99 | fixedDiagramPosition: true, 100 | orientation: Orientation.horizontal, 101 | strings: 5, 102 | frets: 6, 103 | }) 104 | .draw() 105 | 106 | saveSvg('horizontal fixed diagram position', container.outerHTML) 107 | }) 108 | 109 | it('Should render the fret position correctly on the bottom', () => { 110 | svguitar 111 | .chord({ 112 | fingers: [], 113 | barres: [], 114 | }) 115 | .configure({ 116 | fixedDiagramPosition: true, 117 | orientation: Orientation.horizontal, 118 | fretLabelPosition: FretLabelPosition.LEFT, 119 | strings: 5, 120 | frets: 6, 121 | position: 5, 122 | }) 123 | .draw() 124 | 125 | saveSvg('horizontal bottom fret position', container.outerHTML) 126 | }) 127 | 128 | it('Should render fingers over barre chords', () => { 129 | svguitar 130 | .chord({ 131 | fingers: [[2, 1, {color: 'green', text: '1'}]], 132 | barres: [ 133 | { 134 | fromString: 3, 135 | toString: 1, 136 | fret: 1, 137 | color: 'blue', 138 | }, 139 | ], 140 | }) 141 | .configure({ 142 | strings: 5, 143 | frets: 6, 144 | title: 'Finger over Barre Chord', 145 | }) 146 | .draw() 147 | 148 | saveSvg('finger over barre', container.outerHTML) 149 | }) 150 | 151 | it('Should render text on the fingers', () => { 152 | svguitar 153 | .chord({ 154 | fingers: [ 155 | [1, 2, 'A'], 156 | [2, 1, 'B'], 157 | [3, 2, 'C'], 158 | [4, 0], // fret 0 = open string 159 | [5, 'x'], // fret x = muted string 160 | ], 161 | barres: [], 162 | }) 163 | .configure({ 164 | strings: 5, 165 | frets: 6, 166 | title: 'Text on Fingers', 167 | fingerTextColor: 'tomato', 168 | }) 169 | .draw() 170 | 171 | saveSvg('text on fingers', container.outerHTML) 172 | }) 173 | 174 | it('Should set the stroke width on silent and open string indicators', () => { 175 | svguitar 176 | .chord({ 177 | fingers: [ 178 | [2, 0], 179 | [3, 'x'], 180 | [4, 0, {strokeWidth: 5}], 181 | [5, 'x', {strokeWidth: 5}], 182 | ], 183 | barres: [], 184 | }) 185 | .configure({ 186 | title: 'Open & Silent String Indicator Strokes', 187 | }) 188 | .draw() 189 | 190 | saveSvg('silent and open strokes', container.outerHTML) 191 | }) 192 | 193 | it('Should set the stroke colors on silent and open string indicators', () => { 194 | svguitar 195 | .chord({ 196 | fingers: [ 197 | [4, 0, {strokeColor: 'blue'}], 198 | [5, 'x', {strokeColor: 'green'}], 199 | ], 200 | barres: [], 201 | }) 202 | .configure({ 203 | title: 'Open & Silent String Indicator Colors', 204 | }) 205 | .draw() 206 | 207 | saveSvg('silent and open colored', container.outerHTML) 208 | }) 209 | 210 | it('Should render text on silent and open string indicators', () => { 211 | svguitar 212 | .chord({ 213 | fingers: [ 214 | [2, 0, 'A'], 215 | [3, 'x', 'B'], 216 | [4, 0, {text: 'C', textColor: 'green'}], 217 | [5, 'x', {text: 'D', textColor: 'blue'}], 218 | ], 219 | barres: [], 220 | }) 221 | .configure({ 222 | title: 'Text on Open & Silent Strings', 223 | }) 224 | .draw() 225 | 226 | saveSvg('silent and open colored', container.outerHTML) 227 | }) 228 | 229 | it('Should render text on silent and open string indicators vertically', () => { 230 | svguitar 231 | .chord({ 232 | fingers: [ 233 | [2, 0, 'A'], 234 | [3, 'x', 'B'], 235 | [4, 0, {text: 'C', textColor: 'green'}], 236 | [5, 'x', {text: 'D', textColor: 'blue'}], 237 | ], 238 | barres: [], 239 | }) 240 | .configure({ 241 | orientation: Orientation.horizontal, 242 | title: 'Text on Open & Silent Strings Horizontal', 243 | }) 244 | .draw() 245 | 246 | saveSvg('silent and open colored', container.outerHTML) 247 | }) 248 | 249 | it('Should render fingers with a different color', () => { 250 | svguitar 251 | .chord({ 252 | fingers: [ 253 | [1, 2, {color: 'green'}], 254 | [2, 1, {text: 'B', color: 'blue'}], 255 | ], 256 | barres: [], 257 | }) 258 | .configure({ 259 | strings: 5, 260 | frets: 6, 261 | title: 'Colored Fingers', 262 | }) 263 | .draw() 264 | 265 | saveSvg('colored fingers', container.outerHTML) 266 | }) 267 | 268 | it('Should render square fingers', () => { 269 | svguitar 270 | .chord({ 271 | fingers: [ 272 | [1, 2, {shape: Shape.SQUARE}], 273 | [2, 3, {shape: Shape.SQUARE, color: 'blue', text: 'X'}], 274 | ], 275 | barres: [], 276 | }) 277 | .configure({ 278 | strings: 5, 279 | frets: 6, 280 | title: 'Square Fingers', 281 | }) 282 | .draw() 283 | 284 | saveSvg('square fingers', container.outerHTML) 285 | }) 286 | 287 | it('Should render triangle fingers', () => { 288 | svguitar 289 | .chord({ 290 | fingers: [ 291 | [1, 2, {shape: Shape.TRIANGLE}], 292 | [2, 3, {shape: Shape.TRIANGLE, color: 'blue', text: 'X'}], 293 | ], 294 | barres: [], 295 | }) 296 | .configure({ 297 | strings: 5, 298 | frets: 6, 299 | title: 'Triangle Fingers', 300 | }) 301 | .draw() 302 | 303 | saveSvg('triangle fingers', container.outerHTML) 304 | }) 305 | 306 | it('Should render pentagon shaped fingers', () => { 307 | svguitar 308 | .chord({ 309 | fingers: [ 310 | [1, 2, {shape: Shape.PENTAGON}], 311 | [2, 3, {shape: Shape.PENTAGON, color: 'blue', text: 'X'}], 312 | ], 313 | barres: [], 314 | }) 315 | .configure({ 316 | strings: 5, 317 | frets: 6, 318 | title: 'Pentagon Fingers', 319 | }) 320 | .draw() 321 | 322 | saveSvg('pentagon fingers', container.outerHTML) 323 | }) 324 | 325 | it('Should render outline square fingers ', () => { 326 | svguitar 327 | .chord({ 328 | fingers: [ 329 | [ 330 | 2, 331 | 3, 332 | { 333 | shape: Shape.SQUARE, 334 | color: 'blue', 335 | text: 'X', 336 | strokeColor: 'red', 337 | strokeWidth: 3, 338 | }, 339 | ], 340 | ], 341 | barres: [], 342 | }) 343 | .configure({ 344 | title: 'Outline Square Fingers', 345 | }) 346 | .draw() 347 | 348 | saveSvg('outline square fingers', container.outerHTML) 349 | }) 350 | 351 | it('Should render outline triangle fingers', () => { 352 | svguitar 353 | .chord({ 354 | fingers: [ 355 | [ 356 | 2, 357 | 3, 358 | { 359 | shape: Shape.TRIANGLE, 360 | color: 'blue', 361 | text: 'X', 362 | strokeColor: 'red', 363 | strokeWidth: 3, 364 | }, 365 | ], 366 | ], 367 | barres: [], 368 | }) 369 | .configure({ 370 | title: 'Outline Triangle Fingers', 371 | }) 372 | .draw() 373 | 374 | saveSvg('outline triangle fingers', container.outerHTML) 375 | }) 376 | 377 | it('Should render pentagon shaped fingers', () => { 378 | svguitar 379 | .chord({ 380 | fingers: [ 381 | [ 382 | 2, 383 | 3, 384 | { 385 | shape: Shape.PENTAGON, 386 | color: 'blue', 387 | text: 'X', 388 | strokeColor: 'red', 389 | strokeWidth: 3, 390 | }, 391 | ], 392 | ], 393 | barres: [], 394 | }) 395 | .configure({ 396 | title: 'Outline Pentagon Fingers', 397 | }) 398 | .draw() 399 | 400 | saveSvg('outline pentagon fingers', container.outerHTML) 401 | }) 402 | 403 | it('Should render an outlined barre chord', () => { 404 | svguitar 405 | .chord({ 406 | fingers: [], 407 | barres: [ 408 | { 409 | fromString: 4, 410 | toString: 2, 411 | fret: 1, 412 | strokeWidth: 3, 413 | strokeColor: 'green', 414 | }, 415 | ], 416 | }) 417 | .configure({ 418 | title: 'Outlined Barre Chord', 419 | }) 420 | .draw() 421 | 422 | saveSvg('outline barre', container.outerHTML) 423 | }) 424 | 425 | it('Should render all fingers and barre chords with an outline', () => { 426 | svguitar 427 | .chord({ 428 | fingers: [ 429 | [2, 3, {shape: Shape.SQUARE}], 430 | [3, 4], 431 | ], 432 | barres: [ 433 | { 434 | fromString: 3, 435 | toString: 1, 436 | fret: 1, 437 | }, 438 | ], 439 | }) 440 | .configure({ 441 | fingerStrokeWidth: 3, 442 | barreChordStrokeWidth: 3, 443 | barreChordStrokeColor: 'green', 444 | fingerStrokeColor: 'red', 445 | title: 'Outlined', 446 | }) 447 | .draw() 448 | 449 | saveSvg('outline fingers', container.outerHTML) 450 | }) 451 | 452 | it('Should throw an error if an invliad shape is provided', () => { 453 | expect(() => { 454 | svguitar 455 | .chord({ 456 | fingers: [[1, 2, {shape: 'XXX' as Shape}]], 457 | barres: [], 458 | }) 459 | .draw() 460 | }).toThrowError(/XXX/) 461 | }) 462 | 463 | it('Should render text on fingers with a different color', () => { 464 | svguitar 465 | .chord({ 466 | fingers: [ 467 | [1, 2, {text: 'G', textColor: 'green'}], 468 | [2, 1, {text: 'B', textColor: 'blue'}], 469 | [3, 1, {textColor: 'green'}], // no effect 470 | ], 471 | barres: [], 472 | }) 473 | .configure({ 474 | strings: 5, 475 | frets: 6, 476 | title: 'Colored Text on Fingers', 477 | }) 478 | .draw() 479 | 480 | saveSvg('colored text on fingers', container.outerHTML) 481 | }) 482 | 483 | it('Should render barre chords with a different color', () => { 484 | svguitar 485 | .chord({ 486 | fingers: [], 487 | barres: [ 488 | { 489 | fret: 1, 490 | fromString: 4, 491 | toString: 1, 492 | color: 'blue', 493 | }, 494 | { 495 | fret: 3, 496 | fromString: 5, 497 | toString: 2, 498 | color: 'red', 499 | }, 500 | ], 501 | }) 502 | .configure({ 503 | strings: 5, 504 | frets: 6, 505 | title: 'Colored Barre Chords', 506 | }) 507 | .draw() 508 | 509 | saveSvg('colored barre chords', container.outerHTML) 510 | }) 511 | 512 | it('Should render text on barre chords with a different color', () => { 513 | svguitar 514 | .chord({ 515 | fingers: [], 516 | barres: [ 517 | { 518 | fret: 1, 519 | fromString: 4, 520 | toString: 1, 521 | text: 'Blue Text', 522 | textColor: 'blue', 523 | }, 524 | { 525 | fret: 3, 526 | fromString: 5, 527 | toString: 2, 528 | text: 'Red Text', 529 | textColor: 'red', 530 | }, 531 | { 532 | fret: 2, 533 | fromString: 3, 534 | toString: 2, 535 | textColor: 'red', 536 | }, 537 | ], 538 | }) 539 | .configure({ 540 | strings: 5, 541 | frets: 6, 542 | title: 'Colored Text on Barre Chords', 543 | }) 544 | .draw() 545 | 546 | saveSvg('colored text on barre chords', container.outerHTML) 547 | }) 548 | 549 | it('Should render text on the barre chords', () => { 550 | svguitar 551 | .chord({ 552 | fingers: [], 553 | barres: [ 554 | { 555 | fret: 1, 556 | fromString: 4, 557 | toString: 1, 558 | text: 'B', 559 | }, 560 | { 561 | fret: 3, 562 | fromString: 5, 563 | toString: 2, 564 | text: 'A', 565 | }, 566 | ], 567 | }) 568 | .configure({ 569 | strings: 5, 570 | frets: 5, 571 | title: 'Text on Barres', 572 | fingerTextColor: 'lightgreen', 573 | }) 574 | .draw() 575 | 576 | saveSvg('text on barre chords', container.outerHTML) 577 | }) 578 | 579 | it('Should render a title nicely', () => { 580 | svguitar 581 | .configure({ 582 | title: 'Test Title', 583 | }) 584 | .draw() 585 | 586 | saveSvg('with title', container.outerHTML) 587 | }) 588 | 589 | it('Should render a title provided as part of the chord', () => { 590 | svguitar 591 | .configure({ 592 | title: 'DO NOT RENDER THIS', 593 | }) 594 | .chord({ 595 | fingers: [], 596 | barres: [], 597 | title: 'title from chord', 598 | }) 599 | .draw() 600 | 601 | saveSvg('title from chord', container.outerHTML) 602 | }) 603 | 604 | it('Should render the position provided as part of the chord', () => { 605 | svguitar 606 | .configure({ 607 | position: 999, 608 | }) 609 | .chord({ 610 | fingers: [], 611 | barres: [], 612 | position: 3, 613 | }) 614 | .draw() 615 | 616 | saveSvg('position from chord', container.outerHTML) 617 | }) 618 | 619 | it('Should not render a nut if no position is true', () => { 620 | svguitar 621 | .configure({ 622 | title: 'No position', 623 | noPosition: true, 624 | }) 625 | .chord({ 626 | fingers: [], 627 | barres: [], 628 | position: 3, 629 | }) 630 | .draw() 631 | 632 | saveSvg('no position', container.outerHTML) 633 | }) 634 | 635 | it('Should not render a nut if no position is true and position is 1', () => { 636 | svguitar 637 | .configure({ 638 | title: 'No nut', 639 | noPosition: true, 640 | }) 641 | .chord({ 642 | fingers: [], 643 | barres: [], 644 | }) 645 | .draw() 646 | 647 | saveSvg('no position with position 1', container.outerHTML) 648 | }) 649 | 650 | it('Should render a large title', () => { 651 | svguitar 652 | .configure({ 653 | title: 'A', 654 | titleFontSize: 200, 655 | }) 656 | .draw() 657 | 658 | saveSvg('large title', container.outerHTML) 659 | }) 660 | 661 | it('Should render a very long title nicely', () => { 662 | svguitar 663 | .configure({ 664 | title: 'This is a very long title that does not fit easily', 665 | }) 666 | .draw() 667 | 668 | saveSvg('with long title', container.outerHTML) 669 | }) 670 | 671 | it('Should render 8 strings', () => { 672 | svguitar 673 | .configure({ 674 | title: '8 Strings', 675 | }) 676 | .configure({ 677 | strings: 8, 678 | }) 679 | .draw() 680 | 681 | saveSvg('8 strings', container.outerHTML) 682 | }) 683 | 684 | it('Should render 8 frets', () => { 685 | svguitar 686 | .configure({ 687 | title: '8 Frets', 688 | }) 689 | .configure({ 690 | frets: 8, 691 | }) 692 | .draw() 693 | 694 | saveSvg('8 frets', container.outerHTML) 695 | }) 696 | 697 | it('Should render from fret 2 with the fret label left', () => { 698 | svguitar 699 | .configure({ 700 | position: 2, 701 | fretLabelPosition: FretLabelPosition.LEFT, 702 | }) 703 | .draw() 704 | 705 | saveSvg('starting fret 2 left', container.outerHTML) 706 | }) 707 | 708 | it('Should render from fret 2 with the fret label right', () => { 709 | svguitar 710 | .configure({ 711 | position: 2, 712 | fretLabelPosition: FretLabelPosition.RIGHT, 713 | }) 714 | .draw() 715 | 716 | saveSvg('starting fret 2 right', container.outerHTML) 717 | }) 718 | 719 | it('Should render all tunings', () => { 720 | svguitar 721 | .configure({ 722 | strings: 5, 723 | tuning: ['1', '2', '3', '4', '5'], 724 | }) 725 | .draw() 726 | 727 | saveSvg('tunings', container.outerHTML) 728 | }) 729 | 730 | it('Should render not render all tunings if there are extranous tunings', () => { 731 | svguitar 732 | .configure({ 733 | strings: 5, 734 | tuning: ['1', '2', '3', '4', '5', '6'], 735 | }) 736 | .draw() 737 | 738 | saveSvg('too many tunings', container.outerHTML) 739 | }) 740 | 741 | it('Should render barre chords', () => { 742 | svguitar 743 | .configure({ 744 | strings: 5, 745 | frets: 5, 746 | }) 747 | .chord({ 748 | fingers: [], 749 | barres: [ 750 | { 751 | fret: 1, 752 | fromString: 4, 753 | toString: 1, 754 | }, 755 | { 756 | fret: 3, 757 | fromString: 5, 758 | toString: 2, 759 | }, 760 | ], 761 | }) 762 | .draw() 763 | 764 | saveSvg('barre chords', container.outerHTML) 765 | }) 766 | 767 | it('Should render everything in red', () => { 768 | svguitar 769 | .configure({ 770 | color: '#f00', 771 | tuning: ['1', '2', '3', '4', '5', '6'], 772 | title: 'Test', 773 | position: 3, 774 | }) 775 | .chord({ 776 | fingers: [ 777 | [1, 2], 778 | [2, 1], 779 | [3, 2], 780 | [4, 0], // fret 0 = open string 781 | [5, 'x'], // fret x = muted string 782 | ], 783 | barres: [], 784 | }) 785 | .draw() 786 | 787 | saveSvg('red', container.outerHTML) 788 | }) 789 | 790 | it('Should render correctly with all default settings overridden', () => { 791 | svguitar 792 | .configure({ 793 | strings: 6, 794 | frets: 5, 795 | position: 1, 796 | tuning: [], 797 | tuningsFontSize: 28, 798 | fretLabelFontSize: 38, 799 | fretLabelPosition: FretLabelPosition.RIGHT, 800 | fingerSize: 0.65, 801 | sidePadding: 0.2, 802 | titleFontSize: 48, 803 | titleBottomMargin: 0, 804 | color: '#000', 805 | emptyStringIndicatorSize: 0.6, 806 | strokeWidth: 2, 807 | topFretWidth: 10, 808 | fretSize: 1.5, 809 | barreChordRadius: 0.25, 810 | fontFamily: 'Arial, "Helvetica Neue", Helvetica, sans-serif', 811 | }) 812 | .chord({ 813 | fingers: [ 814 | [1, 2], 815 | [2, 1], 816 | [3, 2], 817 | [4, 0], // fret 0 = open string 818 | [5, 'x'], // fret x = muted string 819 | ], 820 | barres: [], 821 | }) 822 | .draw() 823 | 824 | saveSvg('settings overridden', container.outerHTML) 825 | }) 826 | 827 | it('Should render correctly without any configuration', () => { 828 | svguitar 829 | .chord({ 830 | fingers: [ 831 | [1, 2], 832 | [2, 1], 833 | [3, 2], 834 | [4, 0], // fret 0 = open string 835 | [5, 'x'], // fret x = muted string 836 | ], 837 | barres: [], 838 | }) 839 | .draw() 840 | 841 | saveSvg('settings overridden', container.outerHTML) 842 | }) 843 | 844 | it('Should render very fat strokes', () => { 845 | svguitar 846 | .configure({ 847 | title: 'Fat Strokes', 848 | strokeWidth: 10, 849 | topFretWidth: 30, 850 | }) 851 | .chord({ 852 | fingers: [ 853 | [1, 2], 854 | [2, 1], 855 | [3, 2], 856 | [4, 0], // fret 0 = open string 857 | [5, 'x'], // fret x = muted string 858 | ], 859 | barres: [], 860 | }) 861 | .draw() 862 | 863 | saveSvg('fat strokes', container.outerHTML) 864 | }) 865 | 866 | it('Should render a green background', () => { 867 | svguitar 868 | .configure({ 869 | title: 'With Background', 870 | backgroundColor: '#00FF00', 871 | }) 872 | .draw() 873 | 874 | saveSvg('with background', container.outerHTML) 875 | }) 876 | 877 | it('Should vertically center the barre correctly', () => { 878 | svguitar 879 | .chord({ 880 | fingers: [], 881 | barres: [ 882 | { 883 | fret: 1, 884 | fromString: 4, 885 | toString: 1, 886 | }, 887 | ], 888 | }) 889 | .configure({ 890 | title: 'Centered Barre', 891 | fretSize: 1, 892 | fingerSize: 1, 893 | strokeWidth: 5, 894 | fingerColor: 'tomato', 895 | barreChordRadius: 0, 896 | }) 897 | .draw() 898 | 899 | saveSvg('centered barre', container.outerHTML) 900 | }) 901 | 902 | it('Should render two diagrams in the same position, with and without title', () => { 903 | svguitar 904 | .configure({ 905 | title: 'With Title', 906 | fixedDiagramPosition: true, 907 | }) 908 | .draw() 909 | saveSvg('fixed diagram position 1', container.outerHTML) 910 | 911 | svguitar 912 | .configure({ 913 | title: undefined, 914 | fixedDiagramPosition: true, 915 | }) 916 | .draw() 917 | saveSvg('fixed diagram position 2', container.outerHTML) 918 | 919 | svguitar 920 | .configure({ 921 | fixedDiagramPosition: true, 922 | }) 923 | .chord({ 924 | fingers: [[5, 'x']], 925 | barres: [], 926 | }) 927 | .draw() 928 | saveSvg('fixed diagram position 3', container.outerHTML) 929 | }) 930 | 931 | it('Should add custom classes to the barrre chord', () => { 932 | svguitar 933 | .chord({ 934 | fingers: [], 935 | barres: [ 936 | { 937 | fret: 1, 938 | fromString: 4, 939 | toString: 1, 940 | className: 'custom-class-123', 941 | }, 942 | ], 943 | }) 944 | .configure({ 945 | title: 'Barre with Custom Class', 946 | }) 947 | .draw() 948 | 949 | saveSvg('barre with class', container.outerHTML) 950 | }) 951 | 952 | it('Should add custom classes to fingers', () => { 953 | svguitar 954 | .chord({ 955 | fingers: [ 956 | [1, 2, {text: 'a', className: 'custom-class-a'}], 957 | [2, 1, {text: 'b', className: 'custom-class-b'}], 958 | [3, 1, {text: 'c', className: 'custom-class-c'}], 959 | ], 960 | barres: [], 961 | }) 962 | .configure({ 963 | title: 'Fingers with Custom Class', 964 | }) 965 | .draw() 966 | 967 | saveSvg('fingers with class', container.outerHTML) 968 | }) 969 | 970 | it('Should add a watermark', () => { 971 | svguitar 972 | .chord({ 973 | fingers: [], 974 | barres: [], 975 | }) 976 | .configure({ 977 | tuning: ['1', '2', '3', '4', '5', '6'], 978 | title: 'With watermark', 979 | watermark: 'test watermark', 980 | watermarkFontSize: 20, 981 | watermarkColor: 'rgba(255, 0, 0, 0.5)', 982 | }) 983 | .draw() 984 | 985 | saveSvg('with watermark', container.outerHTML) 986 | }) 987 | 988 | it('Should add a watermark on a horizontal chart', () => { 989 | svguitar 990 | .chord({ 991 | fingers: [], 992 | barres: [], 993 | }) 994 | .configure({ 995 | orientation: Orientation.horizontal, 996 | tuning: ['1', '2', '3', '4', '5', '6'], 997 | title: 'Horizontal watermark', 998 | watermark: 'test watermark', 999 | watermarkFontSize: 20, 1000 | watermarkColor: 'rgba(255, 0, 0, 0.5)', 1001 | }) 1002 | .draw() 1003 | 1004 | saveSvg('with watermark horizontal', container.outerHTML) 1005 | }) 1006 | 1007 | it('Should render a chart with an SVG title', () => { 1008 | svguitar 1009 | .chord({ 1010 | fingers: [], 1011 | barres: [], 1012 | }) 1013 | .configure({ 1014 | title: 'SVG Title', 1015 | svgTitle: 'This is the SVG title', 1016 | }) 1017 | .draw() 1018 | 1019 | saveSvg('with svg title', container.outerHTML) 1020 | }) 1021 | 1022 | it('Should render a chart with fret markers', () => { 1023 | svguitar 1024 | .chord({ 1025 | fingers: [], 1026 | barres: [], 1027 | }) 1028 | .configure({ 1029 | fretMarkers: [ 1030 | 0, 1031 | { 1032 | fret: 1, 1033 | color: '#FF0000', 1034 | }, { 1035 | fret: 2, 1036 | shape: Shape.SQUARE, 1037 | }, { 1038 | fret: 3, 1039 | size: 1, 1040 | }, { 1041 | fret: 4, 1042 | strokeColor: '#0000FF', 1043 | strokeWidth: 3, 1044 | }], 1045 | }) 1046 | .draw() 1047 | 1048 | saveSvg('fret markers', container.outerHTML) 1049 | }) 1050 | 1051 | it('Should render a chart with double fret markers', () => { 1052 | svguitar 1053 | .chord({ 1054 | fingers: [], 1055 | barres: [], 1056 | }) 1057 | .configure({ 1058 | frets: 6, 1059 | fretMarkers: [{ 1060 | fret: 0, 1061 | double: true, 1062 | }, { 1063 | fret: 1, 1064 | color: '#FF0000', 1065 | double: true, 1066 | }, { 1067 | fret: 2, 1068 | shape: Shape.SQUARE, 1069 | double: true, 1070 | }, { 1071 | fret: 3, 1072 | double: true, 1073 | distance: 0.8, 1074 | }, { 1075 | fret: 4, 1076 | double: true, 1077 | distance: 1, 1078 | }, { 1079 | fret: 5, 1080 | double: true, 1081 | distance: 0, 1082 | }], 1083 | }) 1084 | .draw() 1085 | 1086 | saveSvg('double fret markers', container.outerHTML) 1087 | }) 1088 | 1089 | it('Should render a horizontal chart with double fret markers', () => { 1090 | svguitar 1091 | .chord({ 1092 | fingers: [ 1093 | [1, 2], 1094 | [2, 1], 1095 | [3, 2], 1096 | [4, 0], 1097 | [5, 'x'], 1098 | ], 1099 | barres: [ 1100 | { 1101 | fret: 3, 1102 | fromString: 4, 1103 | toString: 1, 1104 | }, 1105 | ], 1106 | }) 1107 | .configure({ 1108 | orientation: Orientation.horizontal, 1109 | frets: 14, 1110 | fretMarkers: [ 1111 | 2, 4, 6, 8, { 1112 | fret: 11, 1113 | double: true, 1114 | }, 1115 | ], 1116 | }) 1117 | .draw() 1118 | 1119 | saveSvg('fret markers horizontal', container.outerHTML) 1120 | }) 1121 | 1122 | it('Should remove all fret markers', () => { 1123 | svguitar 1124 | .chord({ 1125 | fingers: [ 1126 | ], 1127 | barres: [ 1128 | ], 1129 | }) 1130 | .configure({ 1131 | showFretMarkers: false, 1132 | fretMarkers: [ 1133 | 2, 4, 6, 1134 | ], 1135 | }) 1136 | .draw() 1137 | 1138 | saveSvg('hide fret markers', container.outerHTML) 1139 | }) 1140 | 1141 | test.each` 1142 | setting | value | valid 1143 | ${'strings'} | ${1} | ${false} 1144 | ${'strings'} | ${2} | ${true} 1145 | ${'frets'} | ${0} | ${true} 1146 | ${'frets'} | ${-1} | ${false} 1147 | ${'position'} | ${1} | ${true} 1148 | ${'position'} | ${0} | ${false} 1149 | ${'fretSize'} | ${-1} | ${false} 1150 | ${'fingerSize'} | ${-1} | ${false} 1151 | ${'strokeWidth'} | ${-1} | ${false} 1152 | `('Should correctly sanity check the settings', ({setting, value, valid}) => { 1153 | // console.log(`Should ${valid ? 'not' : ''} thrown if ${setting} is ${value}`) 1154 | if (valid) { 1155 | expect(() => svguitar.configure({[setting]: value})).not.toThrow() 1156 | } else { 1157 | expect(() => svguitar.configure({[setting]: value})).toThrow() 1158 | } 1159 | }) 1160 | }) 1161 | -------------------------------------------------------------------------------- /test/testutils.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, mkdirSync, writeFileSync } from 'fs' 2 | import { join } from 'path' 3 | 4 | // constants 5 | export const svgOutputDir = './test-renders' 6 | 7 | export function setUpSvgDom(): Document { 8 | const svgdom = require('svgdom') 9 | svgdom 10 | // your font directory 11 | .setFontDir('./fonts') 12 | // map the font-family to the file 13 | .setFontFamilyMappings({ Arial: './arial.ttf' }) 14 | // you can preload your fonts to avoid the loading delay 15 | // when the font is used the first time 16 | .preloadFonts() 17 | 18 | const window = svgdom.createSVGWindow() 19 | 20 | const { registerWindow } = require('@svgdotjs/svg.js') 21 | registerWindow(window, window.document) 22 | 23 | return window.document 24 | } 25 | 26 | export function saveSvg(name: string, svg: string) { 27 | if (!existsSync(svgOutputDir)) { 28 | mkdirSync(svgOutputDir) 29 | } 30 | 31 | writeFileSync(join(svgOutputDir, `${name}.svg`.replace(/\s+/g, '-')), svg) 32 | } 33 | -------------------------------------------------------------------------------- /tools/build-demo.ts: -------------------------------------------------------------------------------- 1 | const { cp } = require("shelljs") 2 | 3 | cp('-r', 'dist/', 'demo/js') 4 | -------------------------------------------------------------------------------- /tools/gh-pages-publish.ts: -------------------------------------------------------------------------------- 1 | const { cd, exec, echo, touch, cp } = require("shelljs") 2 | const { readFileSync } = require("fs") 3 | const url = require("url") 4 | 5 | let repoUrl 6 | let pkg = JSON.parse(readFileSync("package.json") as any) 7 | if (typeof pkg.repository === "object") { 8 | if (!pkg.repository.hasOwnProperty("url")) { 9 | throw new Error("URL does not exist in repository section") 10 | } 11 | repoUrl = pkg.repository.url 12 | } else { 13 | repoUrl = pkg.repository 14 | } 15 | 16 | let parsedUrl = url.parse(repoUrl) 17 | let repository = (parsedUrl.host || "") + (parsedUrl.path || "") 18 | let ghToken = process.env.GH_TOKEN 19 | 20 | echo("Deploying github pages...") 21 | cp('-r', 'demo/.', 'gh-pages') 22 | cp('-r', 'docs', 'gh-pages') 23 | cd("gh-pages") 24 | touch(".nojekyll") 25 | exec("git init") 26 | exec("git add .") 27 | exec('git config user.name "Raphael Voellmy"') 28 | exec('git config user.email "r.voellmy@gmail.com"') 29 | exec('git commit -m "docs(docs): update gh-pages"') 30 | exec( 31 | `git push --force --quiet "https://${ghToken}@${repository}" master:gh-pages` 32 | ) 33 | echo("Docs deployed!!") 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es5", 5 | "module":"es2015", 6 | "lib": ["es2015", "es2016", "es2017", "dom"], 7 | "strict": true, 8 | "sourceMap": true, 9 | "declaration": true, 10 | "allowSyntheticDefaultImports": true, 11 | "esModuleInterop": false, 12 | "experimentalDecorators": true, 13 | "emitDecoratorMetadata": true, 14 | "declarationDir": "dist/types", 15 | "outDir": "dist/lib", 16 | "typeRoots": [ 17 | "node_modules/@types" 18 | ], 19 | "downlevelIteration": true 20 | }, 21 | "include": [ 22 | "src" 23 | ] 24 | } 25 | --------------------------------------------------------------------------------