├── .browserslistrc ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── stale.yml └── workflows │ ├── build-and-deploy.yml │ └── test-and-build.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── angular.json ├── commitlint.config.js ├── package-lock.json ├── package.json ├── projects ├── angular-google-charts │ ├── README.md │ ├── jest.config.js │ ├── ng-package.json │ ├── ng-package.prod.json │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── lib │ │ │ ├── components │ │ │ ├── chart-base │ │ │ │ └── chart-base.component.ts │ │ │ ├── chart-editor │ │ │ │ ├── chart-editor-ref.spec.ts │ │ │ │ ├── chart-editor-ref.ts │ │ │ │ ├── chart-editor.component.spec.ts │ │ │ │ ├── chart-editor.component.ts │ │ │ │ └── chart-editor.ts │ │ │ ├── chart-wrapper │ │ │ │ ├── chart-wrapper.component.spec.ts │ │ │ │ └── chart-wrapper.component.ts │ │ │ ├── control-wrapper │ │ │ │ ├── control-wrapper.component.spec.ts │ │ │ │ └── control-wrapper.component.ts │ │ │ ├── dashboard │ │ │ │ ├── dashboard.component.spec.ts │ │ │ │ └── dashboard.component.ts │ │ │ └── google-chart │ │ │ │ ├── google-chart.component.spec.ts │ │ │ │ └── google-chart.component.ts │ │ │ ├── google-charts.module.spec.ts │ │ │ ├── google-charts.module.ts │ │ │ ├── helpers │ │ │ ├── chart.helper.ts │ │ │ └── id.helper.ts │ │ │ ├── services │ │ │ ├── data-table.service.ts │ │ │ ├── script-loader.service.spec.ts │ │ │ └── script-loader.service.ts │ │ │ └── types │ │ │ ├── chart-type.ts │ │ │ ├── control-type.ts │ │ │ ├── events.ts │ │ │ ├── formatter.ts │ │ │ └── google-charts-config.ts │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ ├── tsconfig.spec.json │ └── tslint.json └── playground │ ├── .browserslistrc │ ├── src │ ├── app │ │ ├── app-routing.module.ts │ │ ├── app.component.ts │ │ ├── app.module.ts │ │ ├── main │ │ │ ├── main.component.html │ │ │ └── main.component.ts │ │ └── test │ │ │ ├── test.component.html │ │ │ └── test.component.ts │ ├── assets │ │ └── .gitkeep │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── favicon.ico │ ├── index.html │ ├── main.ts │ ├── polyfills.ts │ └── styles.scss │ ├── tsconfig.app.json │ └── tslint.json ├── tsconfig.json └── tslint.json /.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is currently used by autoprefixer to adjust CSS to support the below specified browsers 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | # For IE 9-11 support, please uncomment the last line of the file and adjust as needed 5 | > 0.5% 6 | last 2 versions 7 | Firefox ESR 8 | not dead 9 | # IE 9-11 -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug in angular-google-charts 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | # Bug Report 10 | 11 | ## Prerequisites 12 | 13 | Please answer the following questions for yourself before submitting an issue. 14 | **YOU MAY DELETE THE PREREQUISITES SECTION.** 15 | 16 | - [ ] I am running the latest version 17 | - [ ] I checked the Readme and found no answer 18 | - [ ] I checked to make sure that this issue has not already been filed 19 | - [ ] This is related to angular-google-charts and not to Google Charts directly (if it's a Google Charts issue, [their forum](https://groups.google.com/forum/#!forum/google-visualization-api) may help) 20 | 21 | ## Description 22 | 23 | A clear and concise description of what the bug is. 24 | 25 | ## To reproduce 26 | 27 | Steps to reproduce the behaviour: 28 | 29 | 1. Go to '...' 30 | 2. Click on '....' 31 | 3. Scroll down to '....' 32 | 4. See error 33 | 34 | ## Expected behaviour 35 | 36 | A clear and concise description of what you expected to happen. 37 | 38 | ## Exception or Error 39 | 40 |

41 | 
42 | 
43 | 
44 | 
45 | 46 | ## Your environment 47 | 48 |

49 | 
50 | 
51 | 52 | ## Anything else? 53 | 54 | Add any other context about the problem here. 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a new feature for this project 4 | title: '' 5 | labels: feature 6 | assignees: '' 7 | --- 8 | 9 | # Feature request 10 | 11 | ## Describe the solution you'd like 12 | 13 | A clear and concise description of what you want to happen. If this is related to a feature in the Google Charts library, please include links to the documentation. 14 | 15 | ## Describe alternatives you've considered 16 | 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | ## Additional context 20 | 21 | Add any other context or screenshots about the feature request here. 22 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 7 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: stale 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/build-and-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and deploy 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | build-and-deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: '18.10' 16 | registry-url: 'https://registry.npmjs.org' 17 | cache: 'npm' 18 | - run: npm ci 19 | - run: npm run build 20 | - run: npm publish ./dist/angular-google-charts 21 | env: 22 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/test-and-build.yml: -------------------------------------------------------------------------------- 1 | name: Test and build 2 | 3 | on: 4 | push: 5 | branches: ['master'] 6 | pull_request: 7 | branches: ['master'] 8 | 9 | jobs: 10 | test-and-build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: '18.10' 17 | cache: 'npm' 18 | - run: npm ci 19 | - run: npm run build 20 | - name: Run tests 21 | run: npm run test -- --ci --coverage --reporters default --reporters jest-junit 22 | - name: Upload test results 23 | uses: actions/upload-artifact@v4 24 | with: 25 | name: test-results 26 | path: test-results 27 | - name: Upload coverage report 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: coverage 31 | path: coverage 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # test results 28 | /coverage 29 | /test-results 30 | 31 | # misc 32 | /.angular/cache 33 | /.sass-cache 34 | /connect.lock 35 | /libpeerconnection.log 36 | npm-debug.log 37 | yarn-error.log 38 | testem.log 39 | /typings 40 | 41 | # System Files 42 | .DS_Store 43 | Thumbs.db 44 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | commitlint --edit $1 -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": true, 4 | "singleQuote": true, 5 | "printWidth": 120, 6 | "quoteProps": "as-needed", 7 | "trailingComma": "none", 8 | "arrowParens": "avoid" 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | // List of extensions which should be recommended for users of this workspace. 5 | "recommendations": [ 6 | "esbenp.prettier-vscode", 7 | "ms-vscode.vscode-typescript-tslint-plugin", 8 | "msjsdiag.debugger-for-chrome", 9 | "angular.ng-template", 10 | "orta.vscode-jest" 11 | ], 12 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 13 | "unwantedRecommendations": [] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "attach", 10 | "name": "Attach to Chrome", 11 | "port": 9222, 12 | "webRoot": "${workspaceFolder}" 13 | }, 14 | { 15 | "type": "node", 16 | "request": "launch", 17 | "name": "Debug Current Test File", 18 | "program": "${workspaceFolder}/node_modules/@angular/cli/bin/ng", 19 | "args": [ 20 | "test", 21 | "angular-google-charts", 22 | "--runInBand=true", 23 | "--coverage=false", 24 | "--testPathPattern=${fileBasename}" 25 | ], 26 | "cwd": "${workspaceFolder}", 27 | "console": "integratedTerminal", 28 | "trace": "all" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[typescript]": { 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.tslint": "explicit" 6 | } 7 | }, 8 | "git.rebaseWhenSync": true 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [16.0.2](https://github.com/FERNman/angular-google-charts/compare/v16.0.1...v16.0.2) (2025-01-17) 6 | 7 | ### [16.0.1](https://github.com/FERNman/angular-google-charts/compare/v16.0.0...v16.0.1) (2024-09-12) 8 | 9 | ### Bug Fixes 10 | 11 | - include typings in production build ([39cf41b](https://github.com/FERNman/angular-google-charts/commit/39cf41bb5ed4b0c57bbbc77d315fee8ddf8338b2)) 12 | 13 | ## [16.0.0](https://github.com/FERNman/angular-google-charts/compare/v12.0.0...v16.0.0) (2024-09-12) 14 | 15 | ### ⚠ BREAKING CHANGES 16 | 17 | - Angular 16 (#321) 18 | 19 | - Angular 16 ([#321](https://github.com/FERNman/angular-google-charts/issues/321)) ([d236480](https://github.com/FERNman/angular-google-charts/commit/d236480e92f1436b5539a4e8b67a86f2d1f183d3)) 20 | 21 | ## [12.0.0](https://github.com/FERNman/angular-google-charts/compare/v2.2.4...v12.0.0) (2024-09-12) 22 | 23 | ### Features 24 | 25 | - expand `row` type ([#313](https://github.com/FERNman/angular-google-charts/issues/313)) ([ea6456b](https://github.com/FERNman/angular-google-charts/commit/ea6456b0747a3e17e364faa04a06492af0a1ce59)) 26 | 27 | ### [2.2.4](https://github.com/FERNman/angular-google-charts/compare/v2.2.3...v2.2.4) (2024-09-02) 28 | 29 | ### Bug Fixes 30 | 31 | - [#248](https://github.com/FERNman/angular-google-charts/issues/248) fixed WordTree in chart-type.ts, added sample WordTree ([#277](https://github.com/FERNman/angular-google-charts/issues/277)) ([34575a1](https://github.com/FERNman/angular-google-charts/commit/34575a1f757cc451a0f055280172c5686610c816)) 32 | 33 | ### [2.2.3](https://github.com/FERNman/angular-google-charts/compare/v2.2.2...v2.2.3) (2022-07-18) 34 | 35 | ### [2.2.2](https://github.com/FERNman/angular-google-charts/compare/v2.2.1...v2.2.2) (2021-06-12) 36 | 37 | ### [2.2.1](https://github.com/FERNman/angular-google-charts/compare/v2.2.0...v2.2.1) (2021-05-03) 38 | 39 | ### Bug Fixes 40 | 41 | - resize subscription memory leak ([#226](https://github.com/FERNman/angular-google-charts/issues/226)) ([c1f49a0](https://github.com/FERNman/angular-google-charts/commit/c1f49a05a6351ba9f5ef949baf47fa6cad191be4)), closes [#225](https://github.com/FERNman/angular-google-charts/issues/225) 42 | 43 | ## [2.2.0](https://github.com/FERNman/angular-google-charts/compare/v2.1.1...v2.2.0) (2021-04-19) 44 | 45 | ### Features 46 | 47 | - add lazy loading for config ([#221](https://github.com/FERNman/angular-google-charts/issues/221)) ([f04d689](https://github.com/FERNman/angular-google-charts/commit/f04d689ac2a496b85ae51fa982f070edcb3dbe0c)) 48 | 49 | ### [2.1.1](https://github.com/FERNman/angular-google-charts/compare/v2.1.0...v2.1.1) (2021-04-15) 50 | 51 | ### Bug Fixes 52 | 53 | - loading correct package based on chart type ([#219](https://github.com/FERNman/angular-google-charts/issues/219)) ([d6e6922](https://github.com/FERNman/angular-google-charts/commit/d6e69226f80af3f7cd78cd925c562a99120f4ca5)) 54 | 55 | ## [2.1.0](https://github.com/FERNman/angular-google-charts/compare/v2.0.1...v2.1.0) (2021-04-10) 56 | 57 | ### Features 58 | 59 | - added support for add/remove custom event listeners ([65c6836](https://github.com/FERNman/angular-google-charts/commit/65c6836ce85f8db9be1f213491ea3a1c646bcaf9)) 60 | - add dashboard formatters ([9fbfefd](https://github.com/FERNman/angular-google-charts/commit/9fbfefd870f3e4b685433034e8ceed4f6ef9a3be)) 61 | 62 | ### Bug Fixes 63 | 64 | - get/set chartWrapper ([bf3a194](https://github.com/FERNman/angular-google-charts/commit/bf3a194e49a55fbbda2bef188e5bc02df44e89eb)) 65 | 66 | ### [2.0.1](https://github.com/FERNman/angular-google-charts/compare/v2.0.0...v2.0.1) (2021-01-27) 67 | 68 | ## [2.0.0](https://github.com/FERNman/angular-google-charts/compare/v1.1.6...v2.0.0) (2021-01-16) 69 | 70 | ### ⚠ BREAKING CHANGES 71 | 72 | - compile in strict mode 73 | 74 | ### Features 75 | 76 | - **error-dash-events:** add error and ready listeners ([853dac4](https://github.com/FERNman/angular-google-charts/commit/853dac4c81408bcdb46819b5522196cf36f09755)) 77 | 78 | ### Bug Fixes 79 | 80 | - dataTable contains stale values ([4c9f7c6](https://github.com/FERNman/angular-google-charts/commit/4c9f7c69e7f4c7fd829b54687590d7858eb913d0)) 81 | - provide script-loader service in module ([a9dd652](https://github.com/FERNman/angular-google-charts/commit/a9dd65215b1f3e0eb2cf4d10350747cd84412a40)) 82 | 83 | - compile in strict mode ([d387928](https://github.com/FERNman/angular-google-charts/commit/d38792859231cabf4cf16d62da669fea5dbe7e32)) 84 | 85 | ### [1.1.6](https://github.com/FERNman/angular-google-charts/compare/v1.1.5...v1.1.6) (2020-09-12) 86 | 87 | ### Bug Fixes 88 | 89 | - compilation errors because of types ([24a5d6c](https://github.com/FERNman/angular-google-charts/commit/24a5d6c0b8c8e6e403e2ac4b9e0ab39196e76641)), closes [#167](https://github.com/FERNman/angular-google-charts/issues/167) 90 | 91 | ### [1.1.5](https://github.com/FERNman/angular-google-charts/compare/v1.1.4...v1.1.5) (2020-09-07) 92 | 93 | ### Bug Fixes 94 | 95 | - incorrect documentation links ([eaba673](https://github.com/FERNman/angular-google-charts/commit/eaba67300582e8a49a03fcf698f5233d18271891)) 96 | 97 | ### [1.1.4](https://github.com/FERNman/angular-google-charts/compare/v1.1.3...v1.1.4) (2020-04-26) 98 | 99 | ### Bug Fixes 100 | 101 | - compile error when using typescript < 3.7 ([7e9ff39](https://github.com/FERNman/angular-google-charts/commit/7e9ff396ce7a92e4d23d6737f43f6fc050b07cd5)), closes [microsoft/typescript#33939](https://github.com/microsoft/typescript/issues/33939) [#140](https://github.com/FERNman/angular-google-charts/issues/140) 102 | 103 | ### [1.1.3](https://github.com/FERNman/angular-google-charts/compare/v1.1.2...v1.1.3) (2020-04-23) 104 | 105 | ### Bug Fixes 106 | 107 | - no selector for directive error ([95e594b](https://github.com/FERNman/angular-google-charts/commit/95e594b38256ff88dd5d18313d3f478f4afdb8a5)) 108 | 109 | ### [1.1.2](https://github.com/FERNman/angular-google-charts/compare/v1.1.1...v1.1.2) (2020-04-22) 110 | 111 | ### Bug Fixes 112 | 113 | - select event not firing ([624b080](https://github.com/FERNman/angular-google-charts/commit/624b080d443e696b38c222b07f540bc52b8993bb)) 114 | 115 | ### [1.1.1](https://github.com/FERNman/angular-google-charts/compare/v1.1.0...v1.1.1) (2020-04-22) 116 | 117 | ## [1.1.0](https://github.com/FERNman/angular-google-charts/compare/v0.1.6...v1.1.0) (2020-04-19) 118 | 119 | ### ⚠ BREAKING CHANGES 120 | 121 | - always load the `google.visualization` namespace 122 | - rename raw chart to chart wrapper 123 | - raw chart component 124 | - google chart component 125 | - script loader service public interface 126 | 127 | ### Features 128 | 129 | - add safe mode to config ([e11974c](https://github.com/FERNman/angular-google-charts/commit/e11974c9ae8a851329d99b00251051cb3f29059b)) 130 | - always load the `google.visualization` namespace ([1a9d892](https://github.com/FERNman/angular-google-charts/commit/1a9d892ff721693d6636b24670f325b91a533c05)) 131 | - controls and dashboards ([3c7c497](https://github.com/FERNman/angular-google-charts/commit/3c7c497edcfd9d11db61eafd1ed251349b6fa55f)) 132 | - editing charts ([c6eda2d](https://github.com/FERNman/angular-google-charts/commit/c6eda2db8b270f7289c911a789ba65aac1cb0d4e)) 133 | - provide config as object ([2d5953f](https://github.com/FERNman/angular-google-charts/commit/2d5953fb62401890e81d6d6cc170eb05ac797597)) 134 | 135 | ### Bug Fixes 136 | 137 | - mouse events emitting multiple times if chart is redrawn ([20e6ad1](https://github.com/FERNman/angular-google-charts/commit/20e6ad1e27018ad5c300b23c4a374c2d43b02466)), closes [#83](https://github.com/FERNman/angular-google-charts/issues/83) 138 | - run callbacks after loading scripts in angular zone ([b567030](https://github.com/FERNman/angular-google-charts/commit/b567030fa7821549eef4ecde135c5431755a5271)) 139 | 140 | ### improvement 141 | 142 | - script loader service public interface ([8f1f36b](https://github.com/FERNman/angular-google-charts/commit/8f1f36b0254d6444cf5bc9da556176bac85713f3)) 143 | 144 | * google chart component ([013f978](https://github.com/FERNman/angular-google-charts/commit/013f978dae88cceb963983ae353574344c41726d)) 145 | * raw chart component ([ed88549](https://github.com/FERNman/angular-google-charts/commit/ed885493882d9c7266c28a44416cb406eccdafed)) 146 | * rename raw chart to chart wrapper ([875e71e](https://github.com/FERNman/angular-google-charts/commit/875e71e6eacaf119314d2b3e4d32d64cca35665d)) 147 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | MIT License 4 | 5 | Copyright (c) 2024 Gabriel Sperrer 6 | 7 | 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: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | 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. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Angular-Google-Charts 2 | 3 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/FERNman/angular-google-charts/test-and-build.yml) ![npm](https://img.shields.io/npm/dm/angular-google-charts) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) 4 | 5 | > A wrapper for the [Google Charts library](https://developers.google.com/chart/) written in Angular. 6 | 7 | ## Setup 8 | 9 | ### Installation 10 | 11 | To use Angular-Google-Charts in your project, install the package with npm by calling 12 | 13 | ```bash 14 | npm install angular-google-charts 15 | ``` 16 | 17 | This will add the package to your package.json and install the required dependencies. 18 | 19 | ### Importing 20 | 21 | Import the `GoogleChartsModule` in your `app.module.ts`: 22 | 23 | ```typescript 24 | import { GoogleChartsModule } from 'angular-google-charts'; 25 | 26 | @NgModule({ 27 | ... 28 | imports: [ 29 | ... 30 | GoogleChartsModule, 31 | ... 32 | ], 33 | ... 34 | }) 35 | export class AppModule {} 36 | ``` 37 | 38 | This will allow you to use all of the features provided by this library. 39 | 40 | #### Configuring 41 | 42 | For some use cases, it might be necessary to use some different config options than the default values. 43 | 44 | All config options for Angular Google Charts are provided through a config object, which 45 | can be passed to the library by importing the `GoogleChartsModule` using its `forRoot` method 46 | or by providing the `GOOGLE_CHARTS_LAZY_CONFIG` injection token with an `Observable` value. 47 | 48 | ##### Using forRoot 49 | 50 | Here you will pass the options that are passed to the `google.charts.load` method in the normal JavaScript library. 51 | For instance, to change the [version](https://developers.google.com/chart/interactive/docs/basic_load_libs#load-version-name-or-number) 52 | 53 | ```typescript 54 | GoogleChartsModule.forRoot({ version: 'chart-version' }), 55 | ``` 56 | 57 | Another example, to specify the Google Maps API key, or any other [Settings](https://developers.google.com/chart/interactive/docs/basic_load_libs#load-settings): 58 | 59 | ```typescript 60 | GoogleChartsModule.forRoot({ mapsApiKey: '' }), 61 | ``` 62 | 63 | ##### Using lazy loading 64 | 65 | ###### Option #1 66 | 67 | ```typescript 68 | // Provide an observable through a service that fetches your chart configurations 69 | 70 | @Injectable() 71 | export class GoogleChartsConfigService { 72 | private configSubject = new ReplaySubject(1); 73 | readonly config$ = this.configSubject.asObservable(); 74 | 75 | constructor(private http: HttpClient) {} 76 | 77 | loadLazyConfigValues(): void { 78 | this.http.post('https://special.config.api.com/getchartsconfig', {}) 79 | .pipe(take(1)) 80 | .subscribe(config => this.configSubject.next(config)); 81 | } 82 | } 83 | 84 | // Factory function that provides the config$ observable from your GoogleChartsConfigService 85 | export function googleChartsConfigFactory(configService: GoogleChartsConfigService): Observable { 86 | return configService.config$; 87 | } 88 | 89 | @NgModule({ 90 | ... 91 | providers: [ 92 | GoogleChartsConfigService, 93 | {provide: GOOGLE_CHARTS_LAZY_CONFIG, useFactory: googleChartsConfigFactory, deps: [GoogleChartsConfigService]} 94 | ] 95 | }) 96 | export class AppModule {} 97 | 98 | ``` 99 | 100 | ###### Option #2 101 | 102 | ```typescript 103 | // Use a global subject (whether this violates best practices in your case is up to you). 104 | // This is just to point out a more simple way of achieving a lazy-loaded config. 105 | export const googleChartsConfigSubject = new ReplaySubject(1); 106 | 107 | // Call this from anywhere you want 108 | googleChartsConfigSubject.next(config); 109 | 110 | // Your app.module 111 | @NgModule({ 112 | ... 113 | providers: [ 114 | {provide: GOOGLE_CHARTS_LAZY_CONFIG, useValue: googleChartsConfigSubject.asObservable()} 115 | ] 116 | }) 117 | export class AppModule {} 118 | ``` 119 | 120 | #### NOTE 121 | 122 | - You can provide options through the `forRoot` function **OR** the `GOOGLE_CHARTS_LAZY_CONFIG` token. You cannot use them interchangeably. 123 | - If you provide a lazy-loaded config object then the charts will not render until the observable has a value for the subscriber. 124 | 125 | ## Charts 126 | 127 | The easiest way to create a chart is using the `GoogleChartComponent`. 128 | 129 | ```html 130 | 131 | ``` 132 | 133 | Using the component, it is possible to create every chart in the Google Charts library. 134 | It has a few important input properties, which are explained below. 135 | 136 | ### Type 137 | 138 | ```html 139 | 140 | ``` 141 | 142 | The type of chart you want to create. Must be of type `ChartType`. Check [this file](https://github.com/FERNman/angular-google-charts/blob/master/projects/angular-google-charts/src/lib/types/chart-type.ts) for a list of the supported types 143 | 144 | To see examples for all chart types and more information, visit the [google chart gallery](https://developers.google.com/chart/interactive/docs/gallery). 145 | 146 | ### Data 147 | 148 | ```html 149 | 150 | ``` 151 | 152 | The data property expects an array of a certain shape, which depends on the chart type. Some chart types even support different data formats depending on the mode. 153 | 154 | Example with a chart that expects two-dimensional arrays: 155 | 156 | ```typescript 157 | myData = [ 158 | ['London', 8136000], 159 | ['New York', 8538000], 160 | ['Paris', 2244000], 161 | ['Berlin', 3470000], 162 | ['Kairo', 19500000], 163 | ... 164 | ]; 165 | ``` 166 | 167 | The data object can also include formatters for the given data. To use these, pass an object of type `{ v: any, f: string }` as the data values in the inner array. The property `v` should contain the real value, and the property `f` the formatted value. 168 | 169 | Formatters can also be passed as a separate input property, see [Formatters](#formatters); 170 | 171 | ```typescript 172 | myData = [ 173 | ['London', {v: 8136000, f: '8,1360'}], 174 | ['New York', {v: 8538000, f: '8,530'}], 175 | ... 176 | ]; 177 | ``` 178 | 179 | For further information, please see the official [documentation](https://developers.google.com/chart/interactive/docs/reference#arraytodatatable) on `ArrayToDataTable`, which is the function used internally. 180 | 181 | ### Columns 182 | 183 | ```html 184 | 185 | ``` 186 | 187 | The `columns` property expects an array describing the columns chart data array. The number of entries must match the length of the inner array passed in the `data` property. 188 | Some charts don't require columns. Whether your chart requires it can be check in the official documentation. 189 | 190 | Continuing with the simple two-dimensional example: 191 | 192 | ```typescript 193 | chartColumns = ['City', 'Inhabitants']; 194 | ``` 195 | 196 | For more complex formats an array of objects can be passed. For instance, the GeoChart in markers mode expects 4 columns of type number: 197 | 198 | ```typescript 199 | chartColumns = [ 200 | { type: 'number', role: 'latitude' }, 201 | { type: 'number', role: 'longitude' }, 202 | { type: 'number', role: 'markerColor' }, 203 | { type: 'number', role: 'markerSize' } 204 | ]; 205 | ``` 206 | 207 | ### Title 208 | 209 | ```html 210 | 211 | ``` 212 | 213 | The `title` property is optional and provided for convenience. It can also be included in the `options` property. 214 | 215 | ### Width 216 | 217 | ```html 218 | 219 | ``` 220 | 221 | The `width` property is optional and allows to set the width of the chart. The number provided will be converted to a pixel value. The default is `undefined`, which makes the chart figure out its width by itself. 222 | You can also set the width using CSS, which has the advantage of allowing `%` values instead of only pixels. For more information on that, see [dynamic resize](#dynamic-resize). 223 | 224 | ### Height 225 | 226 | ```html 227 | 228 | ``` 229 | 230 | The `height` property is optional and allows to set the height of the chart. The number provided will be converted to a pixel value. The default is `undefined`, which makes the chart figure out its height by itself. 231 | You can also set the height using CSS, which has the advantage of allowing `%` values instead of only pixels. For more information on that, see [dynamic resize](#dynamic-resize). 232 | 233 | ### Options 234 | 235 | ```html 236 | 237 | ``` 238 | 239 | The `options` property is optional and allows to customize the chart to a great extent. How and what you can customize depends on the type of chart. For more information, please see the [google documentation](https://developers.google.com/chart/interactive/docs/customizing_charts). 240 | 241 | ```typescript 242 | // example 243 | myOptions = { 244 | colors: ['#e0440e', '#e6693e', '#ec8f6e', '#f3b49f', '#f6c7b6'], 245 | is3D: true 246 | }; 247 | ``` 248 | 249 | ### Formatters 250 | 251 | ```html 252 | 253 | ``` 254 | 255 | The `formatter` property is optional and allows to format the chart data. It requires an array of objects containing a formatter and an index. 256 | 257 | For more information and all formatter types, please refer to the [documentation](https://developers.google.com/chart/interactive/docs/reference#formatters). 258 | 259 | ```typescript 260 | // Formats the column with the index 1 and 3 to Date(long) 261 | myFormatters = [ 262 | { 263 | formatter: new google.visualization.DateFormat({ formatType: 'long' }), 264 | colIndex: 1 265 | }, 266 | { 267 | formatter: new google.visualization.DateFormat({ formatType: 'long' }), 268 | colIndex: 3 269 | } 270 | ]; 271 | ``` 272 | 273 | _Note: When you get the error "google is not defined" whilst using the formatter in your component, you probably didn't load the google charts script. Please read the chapter on using the [ScriptLoaderService](#using-the-scriptloaderservice)._ 274 | 275 | ### Dynamic Resize 276 | 277 | ```html 278 | 279 | ``` 280 | 281 | The `dynamicResize` property is optional and makes your chart redraw every time the window is resized. 282 | Defaults to `false` and should only be used when setting the width or height of the chart to a percentage value. 283 | Otherwise, the chart gets redrawn unnecessary and therefore slows down the site. 284 | 285 | ### Styling 286 | 287 | ```html 288 | 289 | ``` 290 | 291 | Most CSS properties should work exactly as you would expect them to. 292 | If you want to have the chart full-width for example, set the width to `100%`. 293 | 294 | ## Events 295 | 296 | The `GoogleChartComponent` provides bindings for the most common Google Chart events. 297 | 298 | ### Ready 299 | 300 | The [`ready` event](https://developers.google.com/chart/interactive/docs/events#the-ready-event) is emitted as soon as the chart got drawn and after every subsequent redraw. 301 | 302 | ```html 303 | 304 | ``` 305 | 306 | The event is of type `ChartReadyEvent`. 307 | 308 | ### Error 309 | 310 | The [`error` event](https://developers.google.com/chart/interactive/docs/events#the-error-event) is emitted when an internal error occurs. However, since the newer versions of google-charts, most errors are displayed in the chart HTML as well. It can be bound to like this: 311 | 312 | ```html 313 | 314 | ``` 315 | 316 | The event is of type `ChartErrorEvent`. 317 | 318 | ### Select 319 | 320 | The [`select` event](https://developers.google.com/chart/interactive/docs/events#the-select-event) is emitted when an element in the chart gets selected. 321 | 322 | ```html 323 | 324 | ``` 325 | 326 | The event of type `ChartSelectionChangedEvent` containing an array of selected values. 327 | 328 | ### Mouseover 329 | 330 | The `mouseover` event fires when the mouse hovers over one of the charts elements (i. e. a bar in a bar chart or a segment in a pie chart). 331 | 332 | ```html 333 | 334 | ``` 335 | 336 | The event is of type `ChartMouseOverEvent`, where `column` is the index of the hovered column and `row` is the index of the hovered row. 337 | 338 | ### Mouseleave 339 | 340 | The `mouseleave` event fires when the mouse stops hovering one of the charts elements (i. e. a bar in a bar chart or a segment in a pie chart). 341 | 342 | ```html 343 | 344 | ``` 345 | 346 | The event is of type `ChartMouseLeaveEvent`, where `column` is the index of the no-longer hovered column and `row` is the index of the no-longer hovered row. 347 | 348 | ## Controls and Dashboards 349 | 350 | Google Charts supports combining multiple charts into dashboards and giving users controls to manipulate what data they show, see [their documentation](https://developers.google.com/chart/interactive/docs/gallery/controls). Using this library, dashboards can be created easily. 351 | 352 | A dashboard component can be instantiated, which can contain child controls and charts. Every control must specify one or more charts they are controlling via their `for` property. It accepts a single chart as well as an array of charts, and one chart can be controlled by multiple controls. 353 | 354 | ```html 355 | 356 | 357 | 358 | 359 | ``` 360 | 361 | When creating dashboards, the charts themselves are not responsible for drawing, which means their `columns`, `data`, and (optional) `formatter` properties are unused. Instead, the dashboard is responsible for drawing. It therefore accepts data in the same format as charts do through the `columns`, `data`, and `formatter` properties. 362 | 363 | Note that charts in a dashboard will not be visible if they are not referenced in at least one control. 364 | 365 | ## Editing Charts 366 | 367 | Google Charts comes with a full-fledged [chart editor](https://developers.google.com/chart/interactive/docs/reference#google_visualization_charteditor), 368 | allowing users to configure charts the way they want. 369 | 370 | Angular-Google-Charts includes a component wrapping the native `ChartEditor`, the `ChartEditorComponent`. 371 | It has to be instantiated in HTML and can be used to edit charts by calling its `editChart` method. 372 | 373 | ```html 374 | 375 | 376 | 377 | 378 | 379 | ``` 380 | 381 | ```typescript 382 | // my.component.ts 383 | class MyComp { 384 | @ViewChild(ChartEditorComponent) 385 | public readonly editor: ChartEditorComponent; 386 | 387 | public editChart(chart: ChartBase) { 388 | this.editor 389 | .editChart(chart) 390 | .afterClosed() 391 | .subscribe(result => { 392 | if (result) { 393 | // Saved 394 | } else { 395 | // Cancelled 396 | } 397 | }); 398 | } 399 | } 400 | ``` 401 | 402 | `editChart` returns a handle to the open dialog which can be used to close the edit dialog. 403 | 404 | Note that only one chart can be edited by a chart editor at a time. 405 | 406 | ## Advanced 407 | 408 | ### Accessing the chart wrapper directly 409 | 410 | I case you don't need any of the special features the `GoogleChartsComponent` provides, the `ChartWrapperComponent` can be used. 411 | It is a direct wrapper of the [`ChartWrapper`](https://developers.google.com/chart/interactive/docs/reference#chartwrapper-class).. 412 | 413 | ```html 414 | 415 | ``` 416 | 417 | The `ChartWrapperComponent` should be used if you need fine-grained control over the data you are providing or you want to use e.g. 418 | the query feature that Google Charts provides, which is not supported using the `GoogleChartComponent`. 419 | 420 | ### Using the `ScriptLoaderService` 421 | 422 | If a specific chart is created a lot in your application, you may want to create custom components. 423 | 424 | When doing so, you need to load the chart packages by yourself. 425 | The `ScriptLoaderService` provides a few methods helping with this. 426 | 427 | ```typescript 428 | class MyComponent { 429 | private readonly chartPackage = getPackageForChart(ChartType.BarChart); 430 | 431 | @ViewChild('container', { read: ElementRef }) 432 | private containerEl: ElementRef; 433 | 434 | constructor(private loaderService: ScriptLoaderService) {} 435 | 436 | ngOnInit() { 437 | this.loaderService.loadChartPackages(this.chartPackage).subscribe(() => { 438 | // Start creating your chart now 439 | const char = new google.visualization.BarChart(this.containerEl.nativeElement); 440 | }); 441 | } 442 | } 443 | ``` 444 | 445 | The `loadChartPackages` method can also be called without any parameters. This way, only the default 446 | google charts packages will be loaded. These include the namespaces `google.charts` and `google.visualization`, but no charts. 447 | 448 | ### Preloading the Google Charts script 449 | 450 | If the existence of charts is crucial to your application, you may want to decrease the time it takes until the first chart becomes visible. 451 | This can be achieved by loading the Google Charts script concurrently with the rest of the application. 452 | In the playground application, this reduces the time until the first chart appears by roughly 20%, which means for 453 | example about 4 seconds when using the "Slow 3G" profile in Chrome DevTools. 454 | 455 | To achieve this, two scripts have to be added to the `index.html` file in your apps' root folder. 456 | The first one loads the generic Google Charts script, the second one the version-specific parts of the library needed to load charts. 457 | 458 | In the code below, `` has to be replaced with the **exact** of the Google Charts library that you want to use and must match the version you use when importing the `GoogleChartsModule`. 459 | 460 | The only exception to this is version `46`. All minor versions of Google Charts v46 require the loader to be of version `46.2`. 461 | 462 | ```html 463 | 464 | 465 | ``` 466 | 467 | Please note that this can increase the time it takes until Angular is fully loaded. 468 | I suggest doing some benchmarks with your specific application before deploying this to production. 469 | 470 | ## License 471 | 472 | This project is provided under the [MIT license](https://github.com/FERNman/angular-google-charts/blob/master/LICENSE.md). 473 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "angular-google-charts": { 7 | "root": "projects/angular-google-charts", 8 | "sourceRoot": "projects/angular-google-charts/src", 9 | "projectType": "library", 10 | "prefix": "lib", 11 | "architect": { 12 | "build": { 13 | "builder": "@angular-devkit/build-angular:ng-packagr", 14 | "options": { 15 | "tsConfig": "projects/angular-google-charts/tsconfig.lib.json", 16 | "project": "projects/angular-google-charts/ng-package.json" 17 | }, 18 | "configurations": { 19 | "production": { 20 | "project": "projects/angular-google-charts/ng-package.prod.json", 21 | "tsConfig": "projects/angular-google-charts/tsconfig.lib.prod.json" 22 | } 23 | } 24 | }, 25 | "test": { 26 | "builder": "@angular-builders/jest:run", 27 | "options": { 28 | "configPath": "jest.config.js", 29 | "projects": ["projects/angular-google-charts"], 30 | "coverageDirectory": "../../coverage" 31 | } 32 | } 33 | } 34 | }, 35 | "playground": { 36 | "projectType": "application", 37 | "schematics": {}, 38 | "root": "projects/playground", 39 | "sourceRoot": "projects/playground/src", 40 | "prefix": "app", 41 | "architect": { 42 | "build": { 43 | "builder": "@angular-devkit/build-angular:browser", 44 | "options": { 45 | "aot": true, 46 | "outputPath": "dist/playground", 47 | "index": "projects/playground/src/index.html", 48 | "main": "projects/playground/src/main.ts", 49 | "polyfills": "projects/playground/src/polyfills.ts", 50 | "tsConfig": "projects/playground/tsconfig.app.json", 51 | "assets": ["projects/playground/src/favicon.ico", "projects/playground/src/assets"], 52 | "styles": ["projects/playground/src/styles.scss"], 53 | "scripts": [] 54 | }, 55 | "configurations": { 56 | "production": { 57 | "fileReplacements": [ 58 | { 59 | "replace": "projects/playground/src/environments/environment.ts", 60 | "with": "projects/playground/src/environments/environment.prod.ts" 61 | } 62 | ], 63 | "optimization": true, 64 | "outputHashing": "all", 65 | "sourceMap": false, 66 | "namedChunks": false, 67 | "aot": true, 68 | "extractLicenses": true, 69 | "vendorChunk": false, 70 | "buildOptimizer": true, 71 | "budgets": [ 72 | { 73 | "type": "initial", 74 | "maximumWarning": "2mb", 75 | "maximumError": "5mb" 76 | }, 77 | { 78 | "type": "anyComponentStyle", 79 | "maximumWarning": "6kb" 80 | } 81 | ] 82 | } 83 | } 84 | }, 85 | "serve": { 86 | "builder": "@angular-devkit/build-angular:dev-server", 87 | "options": { 88 | "browserTarget": "playground:build" 89 | }, 90 | "configurations": { 91 | "production": { 92 | "browserTarget": "playground:build:production" 93 | } 94 | } 95 | } 96 | } 97 | } 98 | }, 99 | "cli": { 100 | "analytics": "d81f7890-056e-4fbd-90be-bc8fb5b6dfaf" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-google-charts", 3 | "description": "A wrapper for the Google Charts library written in Angular", 4 | "version": "0.0.0", 5 | "private": false, 6 | "scripts": { 7 | "ng": "ng", 8 | "start": "ng serve playground -o", 9 | "build": "ng build angular-google-charts --configuration production", 10 | "test": "ng test angular-google-charts --coverage", 11 | "lint": "ng lint playground", 12 | "release": "standard-version", 13 | "prepare": "husky" 14 | }, 15 | "lint-staged": { 16 | "*.{json,html,ts,js,scss,css,yml,md}": "prettier --write" 17 | }, 18 | "jest-junit": { 19 | "outputDirectory": "test-results" 20 | }, 21 | "standard-version": { 22 | "bumpFiles": "libs/angular-google-charts/package.json", 23 | "packageFiles": "libs/angular-google-charts/package.json" 24 | }, 25 | "engines": { 26 | "npm": ">=8.0.0" 27 | }, 28 | "dependencies": { 29 | "@angular/common": "^16.2.12", 30 | "@angular/compiler": "^16.2.12", 31 | "@angular/core": "^16.2.12", 32 | "@angular/platform-browser": "^16.2.12", 33 | "@angular/platform-browser-dynamic": "^16.2.12", 34 | "@angular/router": "^16.2.12", 35 | "core-js": "^3.10.1", 36 | "rxjs": "^7.8.1", 37 | "tslib": "^2.2.0", 38 | "zone.js": "^0.13.3" 39 | }, 40 | "devDependencies": { 41 | "@angular-builders/jest": "^16.0.1", 42 | "@angular-devkit/build-angular": "^16.2.15", 43 | "@angular/cli": "^16.2.15", 44 | "@angular/compiler-cli": "^16.2.12", 45 | "@angular/language-service": "^16.2.12", 46 | "@commitlint/cli": "^19.4.1", 47 | "@commitlint/config-conventional": "^19.4.1", 48 | "@types/google.visualization": "0.0.74", 49 | "@types/jest": "^29.5.12", 50 | "@types/node": "^16.18.106", 51 | "husky": "^9.1.5", 52 | "jest": "^29.7.0", 53 | "jest-junit": "^16.0.0", 54 | "lint-staged": "^15.2.10", 55 | "ng-packagr": "^16.2.3", 56 | "prettier": "^3.3.3", 57 | "standard-version": "^9.5.0", 58 | "ts-jest": "^29.2.5", 59 | "typescript": "^5.1.6" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /projects/angular-google-charts/README.md: -------------------------------------------------------------------------------- 1 | # angular-google-charts 2 | 3 | > A wrapper for the [Google Charts library](https://google-developers.appspot.com/chart/) written in Angular. 4 | 5 | ## Install 6 | 7 | With [npm](https://npmjs.org/) installed, run 8 | 9 | ```bash 10 | npm install angular-google-charts 11 | ``` 12 | 13 | ## Usage 14 | 15 | Import the `GoogleChartsModule` in your `app.module.ts`: 16 | 17 | ```typescript 18 | import { GoogleChartsModule } from 'angular-google-charts'; 19 | 20 | @NgModule({ 21 | ... 22 | imports: [ 23 | ... 24 | GoogleChartsModule, 25 | ... 26 | ], 27 | ... 28 | }) 29 | export class AppModule {} 30 | ``` 31 | 32 | And create a `google-chart` component somewhere in your application: 33 | 34 | ```html 35 | 42 | 43 | ``` 44 | 45 | ## Detailed Instructions 46 | 47 | Find the full readme at [GitHub](https://github.com/FERNman/angular-google-charts). 48 | 49 | ## License 50 | 51 | MIT 52 | -------------------------------------------------------------------------------- /projects/angular-google-charts/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | resetMocks: true, 4 | transform: { 5 | '^.+\\.ts?$': ['ts-jest', { diagnostics: { ignoreCodes: ['TS151001'] } }] 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /projects/angular-google-charts/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/angular-google-charts", 4 | "deleteDestPath": false, 5 | "lib": { 6 | "entryFile": "src/index.ts" 7 | }, 8 | "allowedNonPeerDependencies": ["."] 9 | } 10 | -------------------------------------------------------------------------------- /projects/angular-google-charts/ng-package.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/angular-google-charts", 4 | "lib": { 5 | "entryFile": "src/index.ts" 6 | }, 7 | "allowedNonPeerDependencies": ["."] 8 | } 9 | -------------------------------------------------------------------------------- /projects/angular-google-charts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-google-charts", 3 | "version": "16.0.2", 4 | "description": "A wrapper for the Google Charts library written with Angular", 5 | "keywords": [ 6 | "Angular", 7 | "Angular 2", 8 | "Charts", 9 | "Google", 10 | "Chart" 11 | ], 12 | "homepage": "https://github.com/FERNman/angular-google-charts", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/FERNman/angular-google-charts" 16 | }, 17 | "bugs": { 18 | "url": "https://github.com/FERNman/angular-google-charts", 19 | "email": "gabriel.sperrer@gmail.com" 20 | }, 21 | "license": "MIT", 22 | "author": { 23 | "name": "Gabriel Sperrer", 24 | "email": "gabriel.sperrer@gmail.com" 25 | }, 26 | "peerDependencies": { 27 | "@angular/common": ">=16.0.0", 28 | "@angular/core": ">=16.0.0" 29 | }, 30 | "dependencies": { 31 | "@types/google.visualization": "0.0.74", 32 | "tslib": "^2.2.0" 33 | }, 34 | "main": "src/index.ts" 35 | } 36 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of angular-google-charts 3 | */ 4 | 5 | export * from './lib/components/chart-editor/chart-editor-ref'; 6 | export * from './lib/components/chart-editor/chart-editor.component'; 7 | export * from './lib/components/google-chart/google-chart.component'; 8 | export * from './lib/components/chart-wrapper/chart-wrapper.component'; 9 | export * from './lib/components/dashboard/dashboard.component'; 10 | export * from './lib/components/control-wrapper/control-wrapper.component'; 11 | export * from './lib/components/chart-base/chart-base.component'; 12 | 13 | export * from './lib/helpers/chart.helper'; 14 | 15 | export * from './lib/types/chart-type'; 16 | export * from './lib/types/control-type'; 17 | export * from './lib/types/events'; 18 | export * from './lib/types/formatter'; 19 | export * from './lib/types/google-charts-config'; 20 | 21 | export * from './lib/services/script-loader.service'; 22 | 23 | export * from './lib/google-charts.module'; 24 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/components/chart-base/chart-base.component.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { ChartErrorEvent, ChartReadyEvent, ChartSelectionChangedEvent } from '../../types/events'; 5 | 6 | export type Column = string | google.visualization.ColumnSpec; 7 | export type Row = (string | number | Date | boolean | google.visualization.DataObjectCell | null)[]; 8 | 9 | export interface ChartBase { 10 | /** 11 | * The chart is ready for external method calls. 12 | * 13 | * Emits *after* the chart was drawn for the first time every time the chart gets redrawn. 14 | */ 15 | ready: EventEmitter; 16 | 17 | /** 18 | * Emits when an error occurs when attempting to render the chart. 19 | */ 20 | error: EventEmitter; 21 | 22 | /** 23 | * Emits when the user clicks a bar or legend. 24 | * 25 | * When a chart element is selected, the corresponding cell 26 | * in the data table is selected; when a legend is selected, 27 | * the corresponding column in the data table is selected. 28 | */ 29 | select: EventEmitter; 30 | 31 | /** 32 | * The drawn chart or `null`. 33 | */ 34 | chart: google.visualization.ChartBase | null; 35 | 36 | /** 37 | * The underlying chart wrapper. 38 | * 39 | * This will throw an exception when trying to access the chart wrapper before `wrapperReady$` emits. 40 | */ 41 | chartWrapper: google.visualization.ChartWrapper; 42 | 43 | /** 44 | * Emits after the `ChartWrapper` is created, but before the chart is drawn for the first time. 45 | */ 46 | wrapperReady$: Observable; 47 | } 48 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/components/chart-editor/chart-editor-ref.spec.ts: -------------------------------------------------------------------------------- 1 | import { ChartEditorRef } from './chart-editor-ref'; 2 | 3 | const visualizationMock = { 4 | events: { 5 | addOneTimeListener: jest.fn(), 6 | removeAllListeners: jest.fn() 7 | } 8 | }; 9 | 10 | const editorMock = { 11 | openDialog: jest.fn(), 12 | closeDialog: jest.fn(), 13 | setChartWrapper: jest.fn(), 14 | getChartWrapper: jest.fn() 15 | } as jest.Mocked; 16 | 17 | describe('ChartEditorRef', () => { 18 | let editor: ChartEditorRef; 19 | 20 | beforeEach(() => { 21 | globalThis.google = { visualization: visualizationMock } as any; 22 | editor = new ChartEditorRef(editorMock); 23 | }); 24 | 25 | it('should create', () => { 26 | expect(editor).toBeTruthy(); 27 | }); 28 | 29 | it('should register event listeners on create', () => { 30 | expect(visualizationMock.events.addOneTimeListener).toHaveBeenCalledWith(editorMock, 'ok', expect.any(Function)); 31 | expect(visualizationMock.events.addOneTimeListener).toHaveBeenCalledWith( 32 | editorMock, 33 | 'cancel', 34 | expect.any(Function) 35 | ); 36 | }); 37 | 38 | describe('afterClosed', () => { 39 | it('should emit update wrapper if dialog was saved', () => { 40 | const okCallback = visualizationMock.events.addOneTimeListener.mock.calls[0][2]; 41 | 42 | const editResult = { draw: jest.fn() }; 43 | editorMock.getChartWrapper.mockReturnValueOnce(editResult as any); 44 | 45 | const closedSpy = jest.fn(); 46 | editor.afterClosed().subscribe(result => closedSpy(result)); 47 | 48 | okCallback(); 49 | 50 | expect(google.visualization.events.removeAllListeners).toHaveBeenCalled(); 51 | expect(closedSpy).toHaveBeenCalledWith(editResult); 52 | }); 53 | 54 | it('should emit `null` if dialog was cancelled', () => { 55 | const cancelCallback = visualizationMock.events.addOneTimeListener.mock.calls[1][2]; 56 | 57 | const closedSpy = jest.fn(); 58 | editor.afterClosed().subscribe(result => closedSpy(result)); 59 | 60 | cancelCallback(); 61 | 62 | expect(google.visualization.events.removeAllListeners).toHaveBeenCalled(); 63 | expect(closedSpy).toHaveBeenCalledWith(null); 64 | }); 65 | }); 66 | 67 | describe('cancel', () => { 68 | it('should close the dialog', () => { 69 | editor.cancel(); 70 | 71 | expect(editorMock.closeDialog).toHaveBeenCalled(); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/components/chart-editor/chart-editor-ref.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { Observable, Subject } from 'rxjs'; 4 | 5 | export type EditChartResult = google.visualization.ChartWrapper | null; 6 | 7 | export class ChartEditorRef { 8 | private readonly doneSubject = new Subject(); 9 | 10 | constructor(private readonly editor: google.visualization.ChartEditor) { 11 | this.addEventListeners(); 12 | } 13 | 14 | /** 15 | * Gets an observable that is notified when the dialog is saved. 16 | * Emits either the result if the dialog was saved or `null` if editing was cancelled. 17 | */ 18 | public afterClosed(): Observable { 19 | return this.doneSubject.asObservable(); 20 | } 21 | 22 | /** 23 | * Stops editing the chart and closes the dialog. 24 | */ 25 | public cancel() { 26 | this.editor.closeDialog(); 27 | } 28 | 29 | private addEventListeners() { 30 | google.visualization.events.addOneTimeListener(this.editor, 'ok', () => { 31 | google.visualization.events.removeAllListeners(this.editor); 32 | 33 | const updatedChartWrapper = this.editor.getChartWrapper(); 34 | 35 | this.doneSubject.next(updatedChartWrapper); 36 | this.doneSubject.complete(); 37 | }); 38 | 39 | google.visualization.events.addOneTimeListener(this.editor, 'cancel', () => { 40 | google.visualization.events.removeAllListeners(this.editor); 41 | 42 | this.doneSubject.next(null); 43 | this.doneSubject.complete(); 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/components/chart-editor/chart-editor.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { EMPTY, of } from 'rxjs'; 3 | 4 | import { ScriptLoaderService } from '../../services/script-loader.service'; 5 | import { ChartBase } from '../chart-base/chart-base.component'; 6 | 7 | import { ChartEditorRef } from './chart-editor-ref'; 8 | import { ChartEditorComponent } from './chart-editor.component'; 9 | 10 | jest.mock('../../services/script-loader.service'); 11 | jest.mock('./chart-editor-ref'); 12 | 13 | const editorRefMock = { 14 | afterClosed: jest.fn() 15 | }; 16 | 17 | const visualizationMock = { 18 | ChartEditor: jest.fn() 19 | }; 20 | 21 | const editorMock = { 22 | openDialog: jest.fn(), 23 | closeDialog: jest.fn(), 24 | setChartWrapper: jest.fn(), 25 | getChartWrapper: jest.fn() 26 | } as jest.Mocked; 27 | 28 | describe('ChartEditorComponent', () => { 29 | let component: ChartEditorComponent; 30 | let fixture: ComponentFixture; 31 | 32 | beforeEach(() => { 33 | visualizationMock.ChartEditor.mockReturnValue(editorMock); 34 | globalThis.google = { visualization: visualizationMock } as any; 35 | }); 36 | 37 | beforeEach(async () => { 38 | await TestBed.configureTestingModule({ 39 | declarations: [ChartEditorComponent], 40 | providers: [ScriptLoaderService] 41 | }).compileComponents(); 42 | }); 43 | 44 | beforeEach(() => { 45 | fixture = TestBed.createComponent(ChartEditorComponent); 46 | component = fixture.componentInstance; 47 | }); 48 | 49 | it('should create', () => { 50 | expect(component).toBeTruthy(); 51 | }); 52 | 53 | describe('ngOnInit', () => { 54 | it('should load editor package', () => { 55 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 56 | scriptLoaderService.loadChartPackages.mockReturnValueOnce(EMPTY); 57 | 58 | component.ngOnInit(); 59 | 60 | expect(scriptLoaderService.loadChartPackages).toHaveBeenCalledWith('charteditor'); 61 | }); 62 | 63 | it('should create chart editor', () => { 64 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 65 | scriptLoaderService.loadChartPackages.mockReturnValueOnce(of(null)); 66 | 67 | component.ngOnInit(); 68 | 69 | expect(visualizationMock.ChartEditor).toHaveBeenCalled(); 70 | }); 71 | 72 | it('should emit initialized event', () => { 73 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 74 | scriptLoaderService.loadChartPackages.mockReturnValueOnce(of(null)); 75 | 76 | const initializedSpy = jest.fn(); 77 | component.initialized$.subscribe(event => initializedSpy(event)); 78 | 79 | component.ngOnInit(); 80 | 81 | expect(initializedSpy).toHaveBeenCalledWith(editorMock); 82 | }); 83 | }); 84 | 85 | describe('editChart', () => { 86 | const chartWrapper = { draw: jest.fn() } as any; 87 | 88 | beforeEach(() => { 89 | component['editor'] = editorMock; 90 | (ChartEditorRef as any as jest.SpyInstance).mockReturnValue(editorRefMock); 91 | editorRefMock.afterClosed.mockReturnValue(EMPTY); 92 | }); 93 | 94 | it('should open the edit dialog', () => { 95 | const chartComponent = { chartWrapper } as ChartBase; 96 | 97 | component.editChart(chartComponent); 98 | 99 | expect(editorMock.openDialog).toHaveBeenCalledWith(chartComponent.chartWrapper, {}); 100 | }); 101 | 102 | it('should pass the provided options', () => { 103 | const chartComponent = { chartWrapper } as ChartBase; 104 | 105 | const options = { 106 | dataSourceInput: 'urlbox' 107 | } as google.visualization.ChartEditorOptions; 108 | 109 | component.editChart(chartComponent, options); 110 | 111 | expect(editorMock.openDialog).toHaveBeenCalledWith(chartComponent.chartWrapper, options); 112 | }); 113 | 114 | it('should create an editor ref and return it', () => { 115 | const chartComponent = { chartWrapper } as ChartBase; 116 | const handle = component.editChart(chartComponent); 117 | 118 | expect(ChartEditorRef).toHaveBeenCalledWith(editorMock); 119 | expect(handle).toBe(editorRefMock); 120 | }); 121 | 122 | it('should update the components chart wrapper with the edit result', async () => { 123 | const chartComponent = { chartWrapper } as ChartBase; 124 | const updatedWrapper = { draw: jest.fn() }; 125 | 126 | editorRefMock.afterClosed.mockReturnValue(of(updatedWrapper)); 127 | 128 | component.editChart(chartComponent); 129 | 130 | expect(chartComponent.chartWrapper).toBe(updatedWrapper); 131 | }); 132 | 133 | it('should not update the components wrapper if editing was cancelled', () => { 134 | const chartComponent = { chartWrapper } as ChartBase; 135 | 136 | editorRefMock.afterClosed.mockReturnValue(of(null)); 137 | 138 | component.editChart(chartComponent); 139 | 140 | expect(chartComponent.chartWrapper).toBe(chartWrapper); 141 | }); 142 | 143 | it("should throw if the component' chart wrapper is undefined", () => { 144 | const chartComponent = {} as ChartBase; 145 | expect(() => component.editChart(chartComponent)).toThrow(); 146 | }); 147 | 148 | it("should throw if the component' editor is undefined", () => { 149 | const chartComponent = { chartWrapper } as ChartBase; 150 | 151 | component['editor'] = undefined; 152 | 153 | expect(() => component.editChart(chartComponent)).toThrow(); 154 | }); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/components/chart-editor/chart-editor.component.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; 4 | import { Subject } from 'rxjs'; 5 | 6 | import { ScriptLoaderService } from '../../services/script-loader.service'; 7 | import { ChartBase } from '../chart-base/chart-base.component'; 8 | 9 | import { ChartEditorRef } from './chart-editor-ref'; 10 | 11 | @Component({ 12 | selector: 'chart-editor', 13 | template: ``, 14 | host: { class: 'chart-editor' }, 15 | changeDetection: ChangeDetectionStrategy.OnPush 16 | }) 17 | export class ChartEditorComponent implements OnInit { 18 | private editor: google.visualization.ChartEditor | undefined; 19 | private initializedSubject = new Subject(); 20 | 21 | constructor(private scriptLoaderService: ScriptLoaderService) {} 22 | 23 | /** 24 | * Emits as soon as the chart editor is fully initialized. 25 | */ 26 | public get initialized$() { 27 | return this.initializedSubject.asObservable(); 28 | } 29 | 30 | public ngOnInit() { 31 | this.scriptLoaderService.loadChartPackages('charteditor').subscribe(() => { 32 | this.editor = new google.visualization.ChartEditor(); 33 | this.initializedSubject.next(this.editor); 34 | this.initializedSubject.complete(); 35 | }); 36 | } 37 | 38 | /** 39 | * Opens the chart editor as an embedded dialog box on the page. 40 | * If the editor gets saved, the components' chart will be updated with the result. 41 | * 42 | * @param component The chart to be edited. 43 | * @returns A reference to the open editor. 44 | */ 45 | public editChart(component: ChartBase): ChartEditorRef; 46 | public editChart(component: ChartBase, options: google.visualization.ChartEditorOptions): ChartEditorRef; 47 | public editChart(component: ChartBase, options?: google.visualization.ChartEditorOptions) { 48 | if (!component.chartWrapper) { 49 | throw new Error( 50 | 'Chart wrapper is `undefined`. Please wait for the `initialized$` observable before trying to edit a chart.' 51 | ); 52 | } 53 | if (!this.editor) { 54 | throw new Error( 55 | 'Chart editor is `undefined`. Please wait for the `initialized$` observable before trying to edit a chart.' 56 | ); 57 | } 58 | 59 | const handle = new ChartEditorRef(this.editor); 60 | this.editor.openDialog(component.chartWrapper, options || {}); 61 | 62 | handle.afterClosed().subscribe(result => { 63 | if (result) { 64 | component.chartWrapper = result; 65 | } 66 | }); 67 | 68 | return handle; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/components/chart-editor/chart-editor.ts: -------------------------------------------------------------------------------- 1 | declare namespace google { 2 | namespace visualization { 3 | export interface ChartEditorOptions { 4 | dataSourceInput?: HTMLElement | 'urlbox'; 5 | } 6 | 7 | export class ChartEditor { 8 | public openDialog(chartWrapper: google.visualization.ChartWrapper, options: ChartEditorOptions): void; 9 | public getChartWrapper(): google.visualization.ChartWrapper; 10 | public setChartWrapper(chartWrapper: google.visualization.ChartWrapper): void; 11 | public closeDialog(): void; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/components/chart-wrapper/chart-wrapper.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { SimpleChange } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { EMPTY, of } from 'rxjs'; 4 | 5 | import { ScriptLoaderService } from '../../services/script-loader.service'; 6 | import { ChartType } from '../../types/chart-type'; 7 | import { ChartErrorEvent, ChartReadyEvent, ChartSelectionChangedEvent } from '../../types/events'; 8 | 9 | import { ChartWrapperComponent } from './chart-wrapper.component'; 10 | 11 | jest.mock('../../services/script-loader.service'); 12 | 13 | const chartWrapperMock = { 14 | setChartType: jest.fn(), 15 | setDataTable: jest.fn(), 16 | setOptions: jest.fn(), 17 | setContainerId: jest.fn(), 18 | setDataSourceUrl: jest.fn(), 19 | setQuery: jest.fn(), 20 | setRefreshInterval: jest.fn(), 21 | setView: jest.fn(), 22 | draw: jest.fn(), 23 | getChart: jest.fn() 24 | }; 25 | 26 | const visualizationMock = { 27 | ChartWrapper: jest.fn(), 28 | arrayToDataTable: jest.fn(), 29 | events: { 30 | addListener: jest.fn(), 31 | removeAllListeners: jest.fn() 32 | } 33 | }; 34 | 35 | describe('ChartWrapperComponent', () => { 36 | let component: ChartWrapperComponent; 37 | let fixture: ComponentFixture; 38 | 39 | beforeEach(() => { 40 | visualizationMock.ChartWrapper.mockReturnValue(chartWrapperMock); 41 | globalThis.google = { visualization: visualizationMock } as any; 42 | }); 43 | 44 | beforeEach(async () => { 45 | await TestBed.configureTestingModule({ 46 | declarations: [ChartWrapperComponent], 47 | providers: [ScriptLoaderService] 48 | }).compileComponents(); 49 | }); 50 | 51 | beforeEach(() => { 52 | fixture = TestBed.createComponent(ChartWrapperComponent); 53 | component = fixture.componentInstance; 54 | // No change detection here, we want to invoke the 55 | // lifecycle methods in the unit tests 56 | }); 57 | 58 | it('should be created', () => { 59 | expect(component).toBeTruthy(); 60 | }); 61 | 62 | describe('ngOnInit', () => { 63 | it('should load the google charts package', () => { 64 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 65 | scriptLoaderService.loadChartPackages.mockReturnValue(EMPTY); 66 | 67 | component.ngOnInit(); 68 | 69 | expect(scriptLoaderService.loadChartPackages).toHaveBeenCalled(); 70 | }); 71 | 72 | it('should create the chart wrapper using the provided specs', () => { 73 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 74 | scriptLoaderService.loadChartPackages.mockReturnValue(of(null)); 75 | 76 | const specs = { chartType: ChartType.AreaChart, dataTable: [] }; 77 | component.specs = specs; 78 | component.ngOnInit(); 79 | 80 | expect(visualizationMock.ChartWrapper).toHaveBeenCalledWith(expect.objectContaining(specs)); 81 | }); 82 | 83 | it('should not throw if the specs are `null`', () => { 84 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 85 | scriptLoaderService.loadChartPackages.mockReturnValue(of(null)); 86 | component.specs = undefined; 87 | 88 | expect(() => component.ngOnInit()).not.toThrow(); 89 | }); 90 | 91 | it('should not use container or containerId if present in the chart specs', () => { 92 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 93 | scriptLoaderService.loadChartPackages.mockReturnValue(of(null)); 94 | 95 | const specs = { 96 | chartType: ChartType.AreaChart, 97 | container: { innerHTML: '' } as HTMLElement, 98 | containerId: 'test' 99 | }; 100 | component.specs = specs; 101 | component.ngOnInit(); 102 | 103 | expect(visualizationMock.ChartWrapper).not.toHaveBeenCalledWith( 104 | expect.objectContaining({ container: specs.container }) 105 | ); 106 | expect(visualizationMock.ChartWrapper).not.toHaveBeenCalledWith( 107 | expect.objectContaining({ containerId: specs.containerId }) 108 | ); 109 | }); 110 | 111 | it('should register chart wrapper event handlers', () => { 112 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 113 | scriptLoaderService.loadChartPackages.mockReturnValue(of(null)); 114 | 115 | component.specs = { chartType: ChartType.AreaChart }; 116 | 117 | component.ngOnInit(); 118 | 119 | expect(visualizationMock.events.removeAllListeners).toHaveBeenCalled(); 120 | expect(visualizationMock.events.addListener).toHaveBeenCalledWith( 121 | chartWrapperMock, 122 | 'ready', 123 | expect.any(Function) 124 | ); 125 | expect(visualizationMock.events.addListener).toHaveBeenCalledWith( 126 | chartWrapperMock, 127 | 'error', 128 | expect.any(Function) 129 | ); 130 | expect(visualizationMock.events.addListener).toHaveBeenCalledWith( 131 | chartWrapperMock, 132 | 'select', 133 | expect.any(Function) 134 | ); 135 | }); 136 | 137 | it('should emit ready event', () => { 138 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 139 | scriptLoaderService.loadChartPackages.mockReturnValue(of(null)); 140 | 141 | component.specs = { chartType: ChartType.AreaChart }; 142 | 143 | const readySpy = jest.fn(); 144 | component.wrapperReady$.subscribe(event => readySpy(event)); 145 | 146 | component.ngOnInit(); 147 | 148 | expect(readySpy).toHaveBeenCalledWith(chartWrapperMock); 149 | }); 150 | 151 | it('should draw the chart', () => { 152 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 153 | scriptLoaderService.loadChartPackages.mockReturnValue(of(null)); 154 | 155 | component.specs = { chartType: ChartType.AreaChart }; 156 | 157 | component.ngOnInit(); 158 | 159 | expect(chartWrapperMock.draw).toHaveBeenCalled(); 160 | }); 161 | }); 162 | 163 | describe('ngOnChanges', () => { 164 | beforeEach(() => { 165 | component['wrapper'] = chartWrapperMock as any; 166 | component['initialized'] = true; 167 | }); 168 | 169 | it('should not throw if the wrapper is not yet initialized', () => { 170 | component['wrapper'] = undefined; 171 | component['initialized'] = false; 172 | 173 | const specs = { 174 | chartType: ChartType.AreaChart 175 | }; 176 | 177 | expect(() => changeSpecs(specs)).not.toThrow(); 178 | }); 179 | 180 | it('should update the chart wrapper with the provided specs', () => { 181 | const specs = { 182 | chartType: ChartType.AreaChart, 183 | dataSourceUrl: 'www.data.de', 184 | dataTable: [], 185 | options: { test: 'any' }, 186 | query: 'query', 187 | refreshInterval: 100, 188 | view: 'testview' 189 | } as google.visualization.ChartSpecs; 190 | 191 | changeSpecs(specs); 192 | 193 | expect(chartWrapperMock.setChartType).toHaveBeenCalledWith(specs.chartType); 194 | expect(chartWrapperMock.setDataSourceUrl).toHaveBeenCalledWith(specs.dataSourceUrl); 195 | expect(chartWrapperMock.setDataTable).toHaveBeenCalledWith(specs.dataTable); 196 | expect(chartWrapperMock.setOptions).toHaveBeenCalledWith(specs.options); 197 | expect(chartWrapperMock.setQuery).toHaveBeenCalledWith(specs.query); 198 | expect(chartWrapperMock.setRefreshInterval).toHaveBeenCalledWith(specs.refreshInterval); 199 | expect(chartWrapperMock.setView).toHaveBeenCalledWith(specs.view); 200 | }); 201 | 202 | it('should ignore `container` and `containerId` if given', () => { 203 | const specs = { containerId: 'test', container: {} } as google.visualization.ChartSpecs; 204 | component.specs = specs; 205 | 206 | expect(chartWrapperMock.setContainerId).not.toHaveBeenCalled(); 207 | }); 208 | 209 | it('should not throw if the specs are `undefined`', () => { 210 | expect(() => changeSpecs(undefined)).not.toThrow(); 211 | 212 | expect(chartWrapperMock.draw).toHaveBeenCalled(); 213 | }); 214 | 215 | it('should redraw the chart if the specs change', () => { 216 | const specs = { chartType: ChartType.AreaChart } as google.visualization.ChartSpecs; 217 | component.specs = specs; 218 | 219 | const newSpecs = { ...specs, chartType: ChartType.GeoChart }; 220 | changeSpecs(newSpecs); 221 | 222 | expect(chartWrapperMock.draw).toHaveBeenCalled(); 223 | }); 224 | 225 | it("should not redraw the chart if the specs didn't change", () => { 226 | const specs = { chartType: ChartType.AreaChart } as google.visualization.ChartSpecs; 227 | component.specs = specs; 228 | 229 | component.ngOnChanges({}); 230 | 231 | expect(chartWrapperMock.draw).not.toHaveBeenCalled(); 232 | }); 233 | }); 234 | 235 | describe('chart', () => { 236 | it('should not throw when trying to access chart if its not yet drawn', () => { 237 | component['wrapper'] = chartWrapperMock as any; 238 | expect(() => component.chart).not.toThrow(); 239 | }); 240 | }); 241 | 242 | describe('chartWrapper', () => { 243 | it('should throw if the chart wrapper is `undefined`', () => { 244 | expect(() => component.chartWrapper).toThrow(); 245 | }); 246 | 247 | it('should return the chart wrapper', () => { 248 | component['wrapper'] = chartWrapperMock as any; 249 | 250 | const wrapper = component.chartWrapper; 251 | expect(wrapper).toBe(chartWrapperMock); 252 | }); 253 | 254 | it('should redraw if changed', () => { 255 | component.chartWrapper = chartWrapperMock as any; 256 | 257 | expect(chartWrapperMock.draw).toHaveBeenCalled(); 258 | }); 259 | }); 260 | 261 | describe('events', () => { 262 | beforeEach(() => { 263 | const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; 264 | service.loadChartPackages.mockReturnValueOnce(of(null)); 265 | }); 266 | 267 | it('should emit ready event after the chart is ready', () => { 268 | const chartMock = { draw: jest.fn() }; 269 | chartWrapperMock.getChart.mockReturnValue(chartMock); 270 | 271 | component.ngOnInit(); 272 | 273 | const readySpy = jest.fn(); 274 | component.ready.subscribe((event: ChartReadyEvent) => readySpy(event)); 275 | 276 | const readyCallback = visualizationMock.events.addListener.mock.calls[0][2]; 277 | readyCallback(); 278 | 279 | expect(readySpy).toHaveBeenCalledWith({ chart: chartMock }); 280 | }); 281 | 282 | it('should emit error event if the chart caused an error', () => { 283 | component.ngOnInit(); 284 | 285 | const errorSpy = jest.fn(); 286 | component.error.subscribe((event: ChartErrorEvent) => errorSpy(event)); 287 | 288 | const errorCallback = visualizationMock.events.addListener.mock.calls[1][2]; 289 | 290 | const error = 'someerror'; 291 | errorCallback(error); 292 | 293 | expect(errorSpy).toHaveBeenCalledWith(error); 294 | }); 295 | 296 | it('should emit select event if a value was selected', () => { 297 | const selection = [{ column: 1, row: 2 }] as google.visualization.ChartSelection[]; 298 | 299 | const chartMock = { getSelection: jest.fn(() => selection) }; 300 | chartWrapperMock.getChart.mockReturnValue(chartMock); 301 | 302 | const selectSpy = jest.fn(); 303 | component.select.subscribe((event: ChartSelectionChangedEvent) => selectSpy(event)); 304 | 305 | component.ngOnInit(); 306 | 307 | expect(selectSpy).not.toHaveBeenCalled(); 308 | 309 | const selectCallback = visualizationMock.events.addListener.mock.calls[2][2]; 310 | 311 | selectCallback(); 312 | 313 | expect(selectSpy).toHaveBeenCalledWith({ selection }); 314 | }); 315 | }); 316 | 317 | function changeSpecs(newValue?: google.visualization.ChartSpecs) { 318 | const oldValue = component.specs; 319 | component.specs = newValue; 320 | component.ngOnChanges({ specs: new SimpleChange(oldValue, newValue, oldValue == null) }); 321 | } 322 | }); 323 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/components/chart-wrapper/chart-wrapper.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | ElementRef, 5 | EventEmitter, 6 | Input, 7 | OnChanges, 8 | OnInit, 9 | Output, 10 | SimpleChanges 11 | } from '@angular/core'; 12 | import { ReplaySubject } from 'rxjs'; 13 | 14 | import { ScriptLoaderService } from '../../services/script-loader.service'; 15 | import { ChartErrorEvent, ChartReadyEvent, ChartSelectionChangedEvent } from '../../types/events'; 16 | import { ChartBase } from '../chart-base/chart-base.component'; 17 | 18 | @Component({ 19 | selector: 'chart-wrapper', 20 | template: '', 21 | styles: [':host { width: fit-content; display: block; }'], 22 | host: { class: 'chart-wrapper' }, 23 | exportAs: 'chartWrapper', 24 | changeDetection: ChangeDetectionStrategy.OnPush 25 | }) 26 | export class ChartWrapperComponent implements ChartBase, OnChanges, OnInit { 27 | /** 28 | * Either a JSON object defining the chart, or a serialized string version of that object. 29 | * The format of this object is shown in the 30 | * {@link https://developers.google.com/chart/interactive/docs/reference#google.visualization.drawchart `drawChart()`} documentation. 31 | * 32 | * The `container` and `containerId` will be overwritten by this component to allow 33 | * rendering the chart into the components' template. 34 | */ 35 | @Input() 36 | public specs?: google.visualization.ChartSpecs; 37 | 38 | @Output() 39 | public error = new EventEmitter(); 40 | 41 | @Output() 42 | public ready = new EventEmitter(); 43 | 44 | @Output() 45 | public select = new EventEmitter(); 46 | 47 | private wrapper: google.visualization.ChartWrapper | undefined; 48 | private wrapperReadySubject = new ReplaySubject(1); 49 | private initialized = false; 50 | 51 | constructor(private element: ElementRef, private scriptLoaderService: ScriptLoaderService) {} 52 | 53 | public get chart(): google.visualization.ChartBase | null { 54 | return this.chartWrapper.getChart(); 55 | } 56 | 57 | public get wrapperReady$() { 58 | return this.wrapperReadySubject.asObservable(); 59 | } 60 | 61 | public get chartWrapper(): google.visualization.ChartWrapper { 62 | if (!this.wrapper) { 63 | throw new Error('Cannot access the chart wrapper before initialization.'); 64 | } 65 | 66 | return this.wrapper; 67 | } 68 | 69 | public set chartWrapper(wrapper: google.visualization.ChartWrapper) { 70 | this.wrapper = wrapper; 71 | this.drawChart(); 72 | } 73 | 74 | public ngOnInit() { 75 | // We don't need to load any chart packages, the chart wrapper will handle this else for us 76 | this.scriptLoaderService.loadChartPackages().subscribe(() => { 77 | if (!this.specs) { 78 | this.specs = {} as google.visualization.ChartSpecs; 79 | } 80 | 81 | const { containerId, container, ...specs } = this.specs; 82 | 83 | // Only ever create the wrapper once to allow animations to happen if something changes. 84 | this.wrapper = new google.visualization.ChartWrapper({ 85 | ...specs, 86 | container: this.element.nativeElement 87 | }); 88 | this.registerChartEvents(); 89 | 90 | this.wrapperReadySubject.next(this.wrapper); 91 | 92 | this.drawChart(); 93 | this.initialized = true; 94 | }); 95 | } 96 | 97 | public ngOnChanges(changes: SimpleChanges) { 98 | if (!this.initialized) { 99 | return; 100 | } 101 | 102 | if (changes.specs) { 103 | this.updateChart(); 104 | this.drawChart(); 105 | } 106 | } 107 | 108 | private updateChart() { 109 | if (!this.specs) { 110 | // When creating the wrapper with empty specs, the google charts library will show an error 111 | // If we don't do this, a javascript error will be thrown, which is not as visible to the user 112 | this.specs = {} as google.visualization.ChartSpecs; 113 | } 114 | 115 | // The typing here are not correct. These methods accept `undefined` as well. 116 | // That's why we have to cast to `any` 117 | 118 | this.wrapper!.setChartType(this.specs.chartType); 119 | this.wrapper!.setDataTable(this.specs.dataTable as any); 120 | this.wrapper!.setDataSourceUrl(this.specs.dataSourceUrl as any); 121 | this.wrapper!.setDataSourceUrl(this.specs.dataSourceUrl as any); 122 | this.wrapper!.setQuery(this.specs.query as any); 123 | this.wrapper!.setOptions(this.specs.options as any); 124 | this.wrapper!.setRefreshInterval(this.specs.refreshInterval as any); 125 | this.wrapper!.setView(this.specs.view); 126 | } 127 | 128 | private drawChart() { 129 | if (this.wrapper) { 130 | this.wrapper.draw(); 131 | } 132 | } 133 | 134 | private registerChartEvents() { 135 | google.visualization.events.removeAllListeners(this.wrapper); 136 | 137 | const registerChartEvent = (object: any, eventName: string, callback: Function) => { 138 | google.visualization.events.addListener(object, eventName, callback); 139 | }; 140 | 141 | registerChartEvent(this.wrapper, 'ready', () => this.ready.emit({ chart: this.chart! })); 142 | registerChartEvent(this.wrapper, 'error', (error: ChartErrorEvent) => this.error.emit(error)); 143 | registerChartEvent(this.wrapper, 'select', () => { 144 | const selection = this.chart!.getSelection(); 145 | this.select.emit({ selection }); 146 | }); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/components/control-wrapper/control-wrapper.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { SimpleChange } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { EMPTY, of } from 'rxjs'; 4 | 5 | import { ScriptLoaderService } from '../../services/script-loader.service'; 6 | import { FilterType } from '../../types/control-type'; 7 | import { ChartErrorEvent, ChartReadyEvent } from '../../types/events'; 8 | 9 | import { ControlWrapperComponent } from './control-wrapper.component'; 10 | 11 | jest.mock('../../services/script-loader.service'); 12 | 13 | const visualizationMock = { 14 | ControlWrapper: jest.fn(), 15 | events: { 16 | addListener: jest.fn(), 17 | removeAllListeners: jest.fn() 18 | } 19 | }; 20 | 21 | describe('ControlWrapperComponent', () => { 22 | let component: ControlWrapperComponent; 23 | let fixture: ComponentFixture; 24 | 25 | beforeEach(async () => { 26 | await TestBed.configureTestingModule({ 27 | declarations: [ControlWrapperComponent], 28 | providers: [ScriptLoaderService] 29 | }).compileComponents(); 30 | }); 31 | 32 | beforeEach(() => { 33 | fixture = TestBed.createComponent(ControlWrapperComponent); 34 | component = fixture.componentInstance; 35 | // No change detection here, we want to invoke the 36 | // lifecycle methods in the unit tests 37 | }); 38 | 39 | it('should create', () => { 40 | expect(component).toBeTruthy(); 41 | }); 42 | 43 | describe('ngOnInit', () => { 44 | it('should load the `controls` package', () => { 45 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 46 | scriptLoaderService.loadChartPackages.mockReturnValueOnce(EMPTY); 47 | 48 | component.ngOnInit(); 49 | 50 | expect(scriptLoaderService.loadChartPackages).toHaveBeenCalledWith('controls'); 51 | }); 52 | 53 | it('should create the control wrapper after the packages loaded', () => { 54 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 55 | scriptLoaderService.loadChartPackages.mockReturnValueOnce(of(null)); 56 | 57 | globalThis.google = { visualization: visualizationMock } as any; 58 | 59 | const options = { 60 | containerId: 'someid', 61 | controlType: FilterType.ChartRange, 62 | state: { test: 1 }, 63 | options: { key: 'value' } 64 | }; 65 | 66 | // @ts-ignore 67 | component.id = options.containerId; 68 | component.type = options.controlType; 69 | component.state = options.state; 70 | component.options = options.options; 71 | 72 | component.ngOnInit(); 73 | 74 | expect(visualizationMock.ControlWrapper).toHaveBeenCalledWith(options); 75 | expect(component.controlWrapper).toBeTruthy(); 76 | }); 77 | 78 | it('should throw if the control wrapper is accessed before being initialized', () => { 79 | expect(() => component.controlWrapper).toThrow(); 80 | }); 81 | 82 | it('should add event listeners', () => { 83 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 84 | scriptLoaderService.loadChartPackages.mockReturnValueOnce(of(null)); 85 | 86 | const controlWrapperMock = { setControlType: jest.fn() }; 87 | visualizationMock.ControlWrapper.mockReturnValue(controlWrapperMock); 88 | 89 | globalThis.google = { visualization: visualizationMock } as any; 90 | 91 | component.ngOnInit(); 92 | 93 | expect(visualizationMock.events.removeAllListeners).toHaveBeenCalledWith(controlWrapperMock); 94 | expect(visualizationMock.events.addListener).toHaveBeenCalledWith( 95 | controlWrapperMock, 96 | 'ready', 97 | expect.any(Function) 98 | ); 99 | expect(visualizationMock.events.addListener).toHaveBeenCalledWith( 100 | controlWrapperMock, 101 | 'error', 102 | expect.any(Function) 103 | ); 104 | expect(visualizationMock.events.addListener).toHaveBeenCalledWith( 105 | controlWrapperMock, 106 | 'statechange', 107 | expect.any(Function) 108 | ); 109 | }); 110 | 111 | it('should emit wrapper ready event', () => { 112 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 113 | scriptLoaderService.loadChartPackages.mockReturnValueOnce(of(null)); 114 | 115 | const controlWrapperMock = { setControlType: jest.fn() }; 116 | visualizationMock.ControlWrapper.mockReturnValue(controlWrapperMock); 117 | 118 | globalThis.google = { visualization: visualizationMock } as any; 119 | 120 | const wrapperReadySpy = jest.fn(); 121 | component.wrapperReady$.subscribe(event => wrapperReadySpy(event)); 122 | 123 | component.ngOnInit(); 124 | 125 | expect(wrapperReadySpy).toHaveBeenCalledTimes(1); 126 | expect(wrapperReadySpy).toHaveBeenCalledWith(controlWrapperMock); 127 | }); 128 | }); 129 | 130 | describe('ngOnChanges', () => { 131 | function changeInput(property: K, newValue: ControlWrapperComponent[K]) { 132 | const oldValue = component[property]; 133 | component[property] = newValue; 134 | component.ngOnChanges({ [property]: new SimpleChange(oldValue, newValue, oldValue == null) }); 135 | } 136 | 137 | it('should not throw if component is not yet initialized', () => { 138 | expect(() => component.ngOnChanges({ type: new SimpleChange(null, null, true) })).not.toThrow(); 139 | }); 140 | 141 | it('should update the control type if it changed', () => { 142 | const controlWrapperMock = { setControlType: jest.fn() }; 143 | component['_controlWrapper'] = controlWrapperMock as any; 144 | 145 | const type = FilterType.Category; 146 | changeInput('type', type); 147 | 148 | expect(controlWrapperMock.setControlType).toHaveBeenCalledWith(type); 149 | }); 150 | 151 | it('should update the options if they changed', () => { 152 | const controlWrapperMock = { setOptions: jest.fn() }; 153 | component['_controlWrapper'] = controlWrapperMock as any; 154 | 155 | const options = { key: 'value' }; 156 | changeInput('options', options); 157 | 158 | expect(controlWrapperMock.setOptions).toHaveBeenCalledWith(options); 159 | }); 160 | 161 | it('should update the state if it changed', () => { 162 | const controlWrapperMock = { setState: jest.fn() }; 163 | component['_controlWrapper'] = controlWrapperMock as any; 164 | 165 | const state = { from: 'to' }; 166 | changeInput('state', state); 167 | 168 | expect(controlWrapperMock.setState).toHaveBeenCalledWith(state); 169 | }); 170 | }); 171 | 172 | describe('events', () => { 173 | beforeEach(() => { 174 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 175 | scriptLoaderService.loadChartPackages.mockReturnValueOnce(of(null)); 176 | 177 | const controlWrapperMock = { setControlType: jest.fn() }; 178 | visualizationMock.ControlWrapper.mockReturnValue(controlWrapperMock); 179 | 180 | globalThis.google = { visualization: visualizationMock } as any; 181 | }); 182 | 183 | it('should emit ready event', () => { 184 | const readySpy = jest.fn(); 185 | component.ready.subscribe((event: ChartReadyEvent) => readySpy(event)); 186 | 187 | // This leads to the component subscribing to all events 188 | component.ngOnInit(); 189 | 190 | const readyCallback: Function = visualizationMock.events.addListener.mock.calls[0][2]; 191 | 192 | const eventMock = 'event'; 193 | readyCallback(eventMock); 194 | 195 | expect(readySpy).toHaveBeenCalledWith(eventMock); 196 | }); 197 | 198 | it('should emit error event', () => { 199 | const errorSpy = jest.fn(); 200 | component.error.subscribe((event: ChartErrorEvent) => errorSpy(event)); 201 | 202 | // This leads to the component subscribing to all events 203 | component.ngOnInit(); 204 | 205 | const errorCallback: Function = visualizationMock.events.addListener.mock.calls[1][2]; 206 | 207 | const eventMock = 'event'; 208 | errorCallback(eventMock); 209 | 210 | expect(errorSpy).toHaveBeenCalledWith(eventMock); 211 | }); 212 | 213 | it('should emit statechange event', () => { 214 | const stateChangeSpy = jest.fn(); 215 | component.stateChange.subscribe((event: unknown) => stateChangeSpy(event)); 216 | 217 | // This leads to the component subscribing to all events 218 | component.ngOnInit(); 219 | 220 | const stateChangeCallback: Function = visualizationMock.events.addListener.mock.calls[2][2]; 221 | 222 | const eventMock = 'event'; 223 | stateChangeCallback(eventMock); 224 | 225 | expect(stateChangeSpy).toHaveBeenCalledWith(eventMock); 226 | }); 227 | }); 228 | }); 229 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/components/control-wrapper/control-wrapper.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | EventEmitter, 5 | HostBinding, 6 | Input, 7 | OnChanges, 8 | OnInit, 9 | Output, 10 | SimpleChanges 11 | } from '@angular/core'; 12 | import { ReplaySubject } from 'rxjs'; 13 | 14 | import { generateRandomId } from '../../helpers/id.helper'; 15 | import { ScriptLoaderService } from '../../services/script-loader.service'; 16 | import { FilterType } from '../../types/control-type'; 17 | import { ChartErrorEvent, ChartReadyEvent } from '../../types/events'; 18 | import { ChartBase } from '../chart-base/chart-base.component'; 19 | 20 | @Component({ 21 | selector: 'control-wrapper', 22 | template: '', 23 | host: { class: 'control-wrapper' }, 24 | exportAs: 'controlWrapper', 25 | changeDetection: ChangeDetectionStrategy.OnPush 26 | }) 27 | export class ControlWrapperComponent implements OnInit, OnChanges { 28 | /** 29 | * Charts controlled by this control wrapper. Can be a single chart or an array of charts. 30 | */ 31 | @Input() 32 | public for!: ChartBase | ChartBase[]; 33 | 34 | /** 35 | * The class name of the control. 36 | * The `google.visualization` package name can be omitted for Google controls. 37 | * 38 | * @example 39 | * 40 | * ```html 41 | * 42 | * ``` 43 | */ 44 | @Input() 45 | public type!: FilterType; 46 | 47 | /** 48 | * An object describing the options for the control. 49 | * You can use either JavaScript literal notation, or provide a handle to the object. 50 | * 51 | * @example 52 | * 53 | * ```html 54 | * 55 | * ``` 56 | */ 57 | @Input() 58 | public options?: object; 59 | 60 | /** 61 | * An object describing the state of the control. 62 | * The state collects all the variables that the user operating the control can affect. 63 | * 64 | * For example, a range slider state can be described in term of the positions that the low and high thumb 65 | * of the slider occupy. 66 | * You can use either Javascript literal notation, or provide a handle to the object. 67 | * 68 | * @example 69 | * 70 | * ```html 71 | * 72 | * ``` 73 | */ 74 | @Input() 75 | public state?: object; 76 | 77 | /** 78 | * Emits when an error occurs when attempting to render the control. 79 | */ 80 | @Output() 81 | public error = new EventEmitter(); 82 | 83 | /** 84 | * The control is ready to accept user interaction and for external method calls. 85 | * 86 | * Alternatively, you can listen for a ready event on the dashboard holding the control 87 | * and call control methods only after the event was fired. 88 | */ 89 | @Output() 90 | public ready = new EventEmitter(); 91 | 92 | /** 93 | * Emits when the user interacts with the control, affecting its state. 94 | * For example, a `stateChange` event will be emitted whenever you move the thumbs of a range slider control. 95 | * 96 | * To retrieve an updated control state after the event fired, call `ControlWrapper.getState()`. 97 | */ 98 | @Output() 99 | public stateChange = new EventEmitter(); 100 | 101 | /** 102 | * A generated id assigned to this components DOM element. 103 | */ 104 | @HostBinding('id') 105 | public readonly id = generateRandomId(); 106 | 107 | private _controlWrapper?: google.visualization.ControlWrapper; 108 | private wrapperReadySubject = new ReplaySubject(1); 109 | 110 | constructor(private loaderService: ScriptLoaderService) {} 111 | 112 | /** 113 | * Emits after the `ControlWrapper` was created. 114 | */ 115 | public get wrapperReady$() { 116 | return this.wrapperReadySubject.asObservable(); 117 | } 118 | 119 | public get controlWrapper(): google.visualization.ControlWrapper { 120 | if (!this._controlWrapper) { 121 | throw new Error(`Cannot access the control wrapper before it being initialized.`); 122 | } 123 | 124 | return this._controlWrapper; 125 | } 126 | 127 | public ngOnInit() { 128 | this.loaderService.loadChartPackages('controls').subscribe(() => { 129 | this.createControlWrapper(); 130 | }); 131 | } 132 | 133 | public ngOnChanges(changes: SimpleChanges): void { 134 | if (!this._controlWrapper) { 135 | return; 136 | } 137 | 138 | if (changes.type) { 139 | this._controlWrapper.setControlType(this.type); 140 | } 141 | 142 | if (changes.options) { 143 | this._controlWrapper.setOptions(this.options || {}); 144 | } 145 | 146 | if (changes.state) { 147 | this._controlWrapper.setState(this.state || {}); 148 | } 149 | } 150 | 151 | private createControlWrapper() { 152 | this._controlWrapper = new google.visualization.ControlWrapper({ 153 | containerId: this.id, 154 | controlType: this.type, 155 | state: this.state, 156 | options: this.options 157 | }); 158 | 159 | this.addEventListeners(); 160 | this.wrapperReadySubject.next(this._controlWrapper); 161 | } 162 | 163 | private addEventListeners() { 164 | google.visualization.events.removeAllListeners(this._controlWrapper); 165 | 166 | google.visualization.events.addListener(this._controlWrapper, 'ready', (event: ChartReadyEvent) => 167 | this.ready.emit(event) 168 | ); 169 | google.visualization.events.addListener(this._controlWrapper, 'error', (event: ChartErrorEvent) => 170 | this.error.emit(event) 171 | ); 172 | google.visualization.events.addListener(this._controlWrapper, 'statechange', (event: unknown) => 173 | this.stateChange.emit(event) 174 | ); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/components/dashboard/dashboard.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { SimpleChange } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { EMPTY, of, Subject } from 'rxjs'; 4 | 5 | import { DataTableService } from '../../services/data-table.service'; 6 | import { ScriptLoaderService } from '../../services/script-loader.service'; 7 | 8 | import { DashboardComponent } from './dashboard.component'; 9 | 10 | jest.mock('../../services/script-loader.service'); 11 | 12 | const visualizationMock = { 13 | Dashboard: jest.fn(), 14 | arrayToDataTable: jest.fn(), 15 | events: { 16 | addListener: jest.fn(), 17 | removeAllListeners: jest.fn() 18 | } 19 | }; 20 | 21 | describe('DashboardComponent', () => { 22 | let component: DashboardComponent; 23 | let fixture: ComponentFixture; 24 | 25 | beforeEach(async () => { 26 | await TestBed.configureTestingModule({ 27 | declarations: [DashboardComponent], 28 | providers: [ScriptLoaderService, DataTableService] 29 | }).compileComponents(); 30 | }); 31 | 32 | beforeEach(() => { 33 | fixture = TestBed.createComponent(DashboardComponent); 34 | component = fixture.componentInstance; 35 | // No change detection here, we want to invoke the 36 | // lifecycle methods in the unit tests 37 | }); 38 | 39 | it('should create', () => { 40 | expect(component).toBeTruthy(); 41 | }); 42 | 43 | describe('ngOnInit', () => { 44 | it('should load the controls package', () => { 45 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 46 | scriptLoaderService.loadChartPackages.mockReturnValueOnce(EMPTY); 47 | 48 | component.ngOnInit(); 49 | 50 | expect(scriptLoaderService.loadChartPackages).toHaveBeenCalledWith('controls'); 51 | }); 52 | 53 | it('should create the data table', () => { 54 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 55 | scriptLoaderService.loadChartPackages.mockReturnValueOnce(of(null)); 56 | 57 | globalThis.google = { visualization: visualizationMock } as any; 58 | 59 | const columns = ['test', 'test2']; 60 | component.columns = columns; 61 | 62 | const data = [ 63 | ['row 1', 10], 64 | ['row 2', 12] 65 | ]; 66 | component.data = data; 67 | 68 | component['controlWrappers'] = [] as any; 69 | 70 | component.ngOnInit(); 71 | 72 | expect(visualizationMock.arrayToDataTable).toHaveBeenCalledWith([columns, ...data], false); 73 | }); 74 | 75 | it('should create the data table without columns if none are provided', () => { 76 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 77 | scriptLoaderService.loadChartPackages.mockReturnValueOnce(of(null)); 78 | 79 | globalThis.google = { visualization: visualizationMock } as any; 80 | const data = [ 81 | ['row 1', 10], 82 | ['row 2', 12] 83 | ]; 84 | component.data = data; 85 | 86 | component['controlWrappers'] = [] as any; 87 | 88 | component.ngOnInit(); 89 | 90 | expect(visualizationMock.arrayToDataTable).toHaveBeenCalledWith(data, true); 91 | }); 92 | 93 | it('should wait for all controls and their charts until creating the dashboard', () => { 94 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 95 | scriptLoaderService.loadChartPackages.mockReturnValueOnce(of(null)); 96 | 97 | const dashboardMock = { bind: jest.fn(), draw: jest.fn() }; 98 | visualizationMock.Dashboard.mockReturnValueOnce(dashboardMock); 99 | 100 | globalThis.google = { visualization: visualizationMock } as any; 101 | 102 | const chartOne = { wrapperReady$: new Subject() }; 103 | const chartTwo = { wrapperReady$: new Subject() }; 104 | const controlOne = { wrapperReady$: new Subject(), for: chartOne }; 105 | const controlTwo = { wrapperReady$: new Subject(), for: [chartOne, chartTwo] }; 106 | 107 | component['controlWrappers'] = [controlOne, controlTwo] as any; 108 | 109 | component.ngOnInit(); 110 | 111 | expect(visualizationMock.Dashboard).not.toHaveBeenCalled(); 112 | 113 | controlOne.wrapperReady$.next(); 114 | controlTwo.wrapperReady$.next(); 115 | expect(visualizationMock.Dashboard).not.toHaveBeenCalled(); 116 | 117 | chartOne.wrapperReady$.next(); 118 | expect(visualizationMock.Dashboard).not.toHaveBeenCalled(); 119 | chartTwo.wrapperReady$.next(); 120 | 121 | expect(visualizationMock.Dashboard).toHaveBeenCalled(); 122 | }); 123 | 124 | it('should bind all controls and dashboards', () => { 125 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 126 | scriptLoaderService.loadChartPackages.mockReturnValueOnce(of(null)); 127 | 128 | const dashboardMock = { bind: jest.fn(), draw: jest.fn() }; 129 | visualizationMock.Dashboard.mockReturnValueOnce(dashboardMock); 130 | 131 | globalThis.google = { visualization: visualizationMock } as any; 132 | 133 | const chartOne = { wrapperReady$: of(null), chartWrapper: {} }; 134 | const chartTwo = { wrapperReady$: of(null), chartWrapper: {} }; 135 | const controlOne = { wrapperReady$: of(null), for: chartOne, controlWrapper: {} }; 136 | const controlTwo = { wrapperReady$: of(null), for: [chartOne, chartTwo], controlWrapper: {} }; 137 | 138 | component['controlWrappers'] = [controlOne, controlTwo] as any; 139 | 140 | component.ngOnInit(); 141 | 142 | expect(dashboardMock.bind).toHaveBeenCalledWith(controlOne.controlWrapper, chartOne.chartWrapper); 143 | expect(dashboardMock.bind).toHaveBeenCalledWith(controlTwo.controlWrapper, [ 144 | chartOne.chartWrapper, 145 | chartTwo.chartWrapper 146 | ]); 147 | }); 148 | 149 | it('should register dashboard wrapper event handlers', () => { 150 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 151 | scriptLoaderService.loadChartPackages.mockReturnValueOnce(of(null)); 152 | 153 | const dashboardMock = { bind: jest.fn(), draw: jest.fn() }; 154 | visualizationMock.Dashboard.mockReturnValueOnce(dashboardMock); 155 | 156 | const dataTableMock = {}; 157 | visualizationMock.arrayToDataTable.mockReturnValueOnce(dataTableMock); 158 | 159 | globalThis.google = { visualization: visualizationMock } as any; 160 | 161 | // At least one control wrapper is needed to start the drawing 162 | const chart = { wrapperReady$: of(null), chartWrapper: {} }; 163 | const control = { wrapperReady$: of(null), for: chart, controlWrapper: {} }; 164 | component['controlWrappers'] = [control] as any; 165 | 166 | component.data = []; 167 | 168 | component.ngOnInit(); 169 | 170 | expect(visualizationMock.events.removeAllListeners).toHaveBeenCalled(); 171 | expect(visualizationMock.events.addListener).toHaveBeenCalledWith(dashboardMock, 'ready', expect.any(Function)); 172 | expect(visualizationMock.events.addListener).toHaveBeenCalledWith(dashboardMock, 'error', expect.any(Function)); 173 | }); 174 | 175 | it('should apply the provided formatters', () => { 176 | const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; 177 | service.loadChartPackages.mockReturnValueOnce(of(null)); 178 | 179 | const formatter = { formatter: { format: jest.fn() }, colIndex: 1 }; 180 | component.formatters = [formatter]; 181 | component.data = []; 182 | 183 | const dataTableMock = {}; 184 | visualizationMock.arrayToDataTable.mockReturnValueOnce(dataTableMock); 185 | 186 | component.ngOnInit(); 187 | 188 | expect(formatter.formatter.format).toHaveBeenCalledWith(dataTableMock, formatter.colIndex); 189 | }); 190 | 191 | it('should draw the dashboard using the provided data', () => { 192 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 193 | scriptLoaderService.loadChartPackages.mockReturnValueOnce(of(null)); 194 | 195 | const dashboardMock = { bind: jest.fn(), draw: jest.fn() }; 196 | visualizationMock.Dashboard.mockReturnValueOnce(dashboardMock); 197 | 198 | const dataTableMock = {}; 199 | visualizationMock.arrayToDataTable.mockReturnValueOnce(dataTableMock); 200 | 201 | globalThis.google = { visualization: visualizationMock } as any; 202 | 203 | // At least one control wrapper is needed to start the drawing 204 | const chart = { wrapperReady$: of(null), chartWrapper: {} }; 205 | const control = { wrapperReady$: of(null), for: chart, controlWrapper: {} }; 206 | component['controlWrappers'] = [control] as any; 207 | 208 | component.data = []; 209 | 210 | component.ngOnInit(); 211 | 212 | expect(dashboardMock.draw).toHaveBeenCalledWith(dataTableMock); 213 | }); 214 | }); 215 | 216 | describe('ngOnChanges', () => { 217 | function changeInput(property: K, newValue: DashboardComponent[K]) { 218 | const oldValue = component[property]; 219 | component[property] = newValue; 220 | component.ngOnChanges({ [property]: new SimpleChange(oldValue, newValue, oldValue == null) }); 221 | } 222 | 223 | it('should not throw if called before initialization', () => { 224 | expect(() => component.ngOnChanges({})).not.toThrow(); 225 | }); 226 | 227 | it('should redraw the chart if the data changed', () => { 228 | const dashboardMock = { draw: jest.fn() }; 229 | component['dashboard'] = dashboardMock as any; 230 | component['initialized'] = true; 231 | 232 | globalThis.google = { visualization: visualizationMock } as any; 233 | 234 | const data = [['row 1', 12]]; 235 | changeInput('data', data); 236 | 237 | expect(visualizationMock.arrayToDataTable).toHaveBeenCalled(); 238 | expect(dashboardMock.draw).toHaveBeenCalled(); 239 | }); 240 | 241 | it('should redraw the chart if the columns changed', () => { 242 | const dashboardMock = { draw: jest.fn() }; 243 | component['dashboard'] = dashboardMock as any; 244 | component['initialized'] = true; 245 | 246 | globalThis.google = { visualization: visualizationMock } as any; 247 | 248 | component.data = []; 249 | 250 | const columns = ['test']; 251 | changeInput('columns', columns); 252 | 253 | expect(visualizationMock.arrayToDataTable).toHaveBeenCalled(); 254 | expect(dashboardMock.draw).toHaveBeenCalled(); 255 | }); 256 | 257 | it('should redraw the chart if `formatters` changed', () => { 258 | const dashboardMock = { draw: jest.fn() }; 259 | component['dashboard'] = dashboardMock as any; 260 | component['initialized'] = true; 261 | 262 | globalThis.google = { visualization: visualizationMock } as any; 263 | 264 | const data = [ 265 | ['First Row', 10], 266 | ['Second Row', 11] 267 | ]; 268 | component.data = data; 269 | 270 | const columns = ['Some label', 'Some values']; 271 | component.columns = columns; 272 | 273 | const dataTableMock = {}; 274 | visualizationMock.arrayToDataTable.mockReturnValueOnce(dataTableMock); 275 | 276 | const formatter = { formatter: { format: jest.fn() }, colIndex: 1 }; 277 | changeInput('formatters', [formatter]); 278 | 279 | expect(formatter.formatter.format).toHaveBeenCalled(); 280 | expect(visualizationMock.arrayToDataTable).toHaveBeenCalled(); 281 | expect(dashboardMock.draw).toHaveBeenCalled(); 282 | }); 283 | 284 | it('should not redraw if nothing changed', () => { 285 | const dashboardMock = { draw: jest.fn() }; 286 | component['dashboard'] = dashboardMock as any; 287 | component['initialized'] = true; 288 | 289 | globalThis.google = { visualization: visualizationMock } as any; 290 | 291 | component.data = [['row 1', 12]]; 292 | component.columns = ['test']; 293 | 294 | component.ngOnChanges({}); 295 | 296 | expect(visualizationMock.arrayToDataTable).not.toHaveBeenCalled(); 297 | expect(dashboardMock.draw).not.toHaveBeenCalled(); 298 | }); 299 | }); 300 | }); 301 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/components/dashboard/dashboard.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | ContentChildren, 5 | ElementRef, 6 | EventEmitter, 7 | Input, 8 | OnChanges, 9 | OnInit, 10 | Output, 11 | QueryList, 12 | SimpleChanges 13 | } from '@angular/core'; 14 | import { combineLatest } from 'rxjs'; 15 | 16 | import { DataTableService } from '../../services/data-table.service'; 17 | import { ScriptLoaderService } from '../../services/script-loader.service'; 18 | import { ChartErrorEvent } from '../../types/events'; 19 | import { Formatter } from '../../types/formatter'; 20 | import { Column, Row } from '../chart-base/chart-base.component'; 21 | import { ControlWrapperComponent } from '../control-wrapper/control-wrapper.component'; 22 | 23 | @Component({ 24 | selector: 'dashboard', 25 | template: '', 26 | changeDetection: ChangeDetectionStrategy.OnPush, 27 | exportAs: 'dashboard', 28 | host: { class: 'dashboard' } 29 | }) 30 | export class DashboardComponent implements OnInit, OnChanges { 31 | /** 32 | * Data used to initialize the table. 33 | * 34 | * This must also contain all roles that are set in the `columns` property. 35 | */ 36 | @Input() 37 | public data!: Row[]; 38 | 39 | /** 40 | * The columns the `data` consists of. 41 | * The length of this array must match the length of each row in the `data` object. 42 | * 43 | * If {@link https://developers.google.com/chart/interactive/docs/roles roles} should be applied, they must be included in this array as well. 44 | */ 45 | @Input() 46 | public columns?: Column[]; 47 | 48 | /** 49 | * Used to change the displayed value of the specified column in all rows. 50 | * 51 | * Each array element must consist of an instance of a [`formatter`](https://developers.google.com/chart/interactive/docs/reference#formatters) 52 | * and the index of the column you want the formatter to get applied to. 53 | */ 54 | @Input() 55 | public formatters?: Formatter[]; 56 | 57 | /** 58 | * The dashboard has completed drawing and is ready to accept changes. 59 | * 60 | * The ready event will also fire: 61 | * - after the completion of a dashboard refresh triggered by a user or programmatic interaction with one of the controls, 62 | * - after redrawing any chart on the dashboard. 63 | */ 64 | @Output() 65 | public ready = new EventEmitter(); 66 | 67 | /** 68 | * Emits when an error occurs when attempting to render the dashboard. 69 | * One or more of the controls and charts that are part of the dashboard may have failed rendering. 70 | */ 71 | @Output() 72 | public error = new EventEmitter(); 73 | 74 | @ContentChildren(ControlWrapperComponent) 75 | private controlWrappers!: QueryList; 76 | 77 | private dashboard?: google.visualization.Dashboard; 78 | private dataTable?: google.visualization.DataTable; 79 | private initialized = false; 80 | 81 | constructor( 82 | private element: ElementRef, 83 | private loaderService: ScriptLoaderService, 84 | private dataTableService: DataTableService 85 | ) {} 86 | 87 | public ngOnInit() { 88 | this.loaderService.loadChartPackages('controls').subscribe(() => { 89 | this.dataTable = this.dataTableService.create(this.data, this.columns, this.formatters); 90 | this.createDashboard(); 91 | this.initialized = true; 92 | }); 93 | } 94 | 95 | public ngOnChanges(changes: SimpleChanges): void { 96 | if (!this.initialized) { 97 | return; 98 | } 99 | 100 | if (changes.data || changes.columns || changes.formatters) { 101 | this.dataTable = this.dataTableService.create(this.data, this.columns, this.formatters); 102 | this.dashboard!.draw(this.dataTable!); 103 | } 104 | } 105 | 106 | private createDashboard(): void { 107 | // TODO: This should happen in the control wrapper 108 | // However, I don't yet know how to do this because then `bind()` would get called multiple times 109 | // for the same control if something changes. This is not supported by google charts as far as I can tell 110 | // from their source code. 111 | const controlWrappersReady$ = this.controlWrappers.map(control => control.wrapperReady$); 112 | const chartsReady$ = this.controlWrappers 113 | .map(control => control.for) 114 | .map(charts => { 115 | if (Array.isArray(charts)) { 116 | // CombineLatest waits for all observables 117 | return combineLatest(charts.map(chart => chart.wrapperReady$)); 118 | } else { 119 | return charts.wrapperReady$; 120 | } 121 | }); 122 | 123 | // We have to wait for all chart wrappers and control wrappers to be initialized 124 | // before we can compose them together to create the dashboard 125 | combineLatest([...controlWrappersReady$, ...chartsReady$]).subscribe(() => { 126 | this.dashboard = new google.visualization.Dashboard(this.element.nativeElement); 127 | this.initializeBindings(); 128 | this.registerEvents(); 129 | this.dashboard.draw(this.dataTable!); 130 | }); 131 | } 132 | 133 | private registerEvents(): void { 134 | google.visualization.events.removeAllListeners(this.dashboard); 135 | 136 | const registerDashEvent = (object: any, eventName: string, callback: Function) => { 137 | google.visualization.events.addListener(object, eventName, callback); 138 | }; 139 | 140 | registerDashEvent(this.dashboard, 'ready', () => this.ready.emit()); 141 | registerDashEvent(this.dashboard, 'error', (error: ChartErrorEvent) => this.error.emit(error)); 142 | } 143 | 144 | private initializeBindings(): void { 145 | this.controlWrappers.forEach(control => { 146 | if (Array.isArray(control.for)) { 147 | const chartWrappers = control.for.map(chart => chart.chartWrapper); 148 | this.dashboard!.bind(control.controlWrapper, chartWrappers); 149 | } else { 150 | this.dashboard!.bind(control.controlWrapper, control.for.chartWrapper); 151 | } 152 | }); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/components/google-chart/google-chart.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { SimpleChange } from '@angular/core'; 2 | import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; 3 | import { EMPTY, of } from 'rxjs'; 4 | 5 | import { ScriptLoaderService } from '../../services/script-loader.service'; 6 | import { ChartType } from '../../types/chart-type'; 7 | import { ChartReadyEvent } from '../../types/events'; 8 | 9 | import { GoogleChartComponent } from './google-chart.component'; 10 | 11 | jest.mock('../../services/script-loader.service'); 12 | 13 | const chartWrapperMock = { 14 | setChartType: jest.fn(), 15 | setDataTable: jest.fn(), 16 | setOptions: jest.fn(), 17 | draw: jest.fn(), 18 | getChart: jest.fn() 19 | }; 20 | 21 | const visualizationMock = { 22 | ChartWrapper: jest.fn(), 23 | arrayToDataTable: jest.fn(), 24 | events: { 25 | addListener: jest.fn(), 26 | removeListener: jest.fn(), 27 | removeAllListeners: jest.fn() 28 | } 29 | }; 30 | 31 | describe('GoogleChartComponent', () => { 32 | let component: GoogleChartComponent; 33 | let fixture: ComponentFixture; 34 | 35 | beforeEach(() => { 36 | visualizationMock.ChartWrapper.mockReturnValue(chartWrapperMock); 37 | globalThis.google = { visualization: visualizationMock } as any; 38 | }); 39 | 40 | beforeEach(async () => { 41 | await TestBed.configureTestingModule({ 42 | declarations: [GoogleChartComponent], 43 | providers: [ScriptLoaderService] 44 | }).compileComponents(); 45 | }); 46 | 47 | beforeEach(() => { 48 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 49 | scriptLoaderService.loadChartPackages.mockReturnValue(EMPTY); 50 | }); 51 | 52 | beforeEach(() => { 53 | fixture = TestBed.createComponent(GoogleChartComponent); 54 | component = fixture.componentInstance; 55 | // No change detection here, we want to invoke the 56 | // lifecycle methods in the unit tests 57 | }); 58 | 59 | it('should be created', () => { 60 | expect(component).toBeTruthy(); 61 | }); 62 | 63 | it('should not throw when trying to access chart if its not yet drawn', () => { 64 | component['wrapper'] = chartWrapperMock as any; 65 | expect(() => component.chart).not.toThrow(); 66 | }); 67 | 68 | describe('chartWrapper', () => { 69 | it('should throw if the chart wrapper is `undefined`', () => { 70 | expect(() => component.chartWrapper).toThrow(); 71 | }); 72 | 73 | it('should return the chart wrapper', () => { 74 | component['wrapper'] = chartWrapperMock as any; 75 | 76 | const wrapper = component.chartWrapper; 77 | expect(wrapper).toBe(chartWrapperMock); 78 | }); 79 | 80 | it('should redraw if changed', () => { 81 | component.chartWrapper = chartWrapperMock as any; 82 | 83 | expect(chartWrapperMock.draw).toHaveBeenCalled(); 84 | }); 85 | }); 86 | 87 | describe('ngOnInit', () => { 88 | it('should load the google chart library', () => { 89 | const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; 90 | service.loadChartPackages.mockReturnValueOnce(EMPTY); 91 | 92 | component.ngOnInit(); 93 | 94 | expect(service.loadChartPackages).toHaveBeenCalled(); 95 | }); 96 | 97 | it('should not throw if only the type, but no data is provided', waitForAsync(() => { 98 | const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; 99 | service.loadChartPackages.mockReturnValueOnce(of(null)); 100 | 101 | component.ngOnInit(); 102 | 103 | expect(component['wrapper']).toBeDefined(); 104 | expect(chartWrapperMock.draw).toHaveBeenCalledTimes(1); 105 | })); 106 | 107 | it('should create the data table', () => { 108 | const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; 109 | service.loadChartPackages.mockReturnValueOnce(of(null)); 110 | 111 | const data = [ 112 | ['First Row', 10], 113 | ['Second Row', 11] 114 | ]; 115 | component.data = data; 116 | 117 | const columns = ['Some data', 'Some values']; 118 | component.columns = columns; 119 | 120 | component.ngOnInit(); 121 | 122 | expect(visualizationMock.arrayToDataTable).toHaveBeenCalledWith([columns, ...data], false); 123 | }); 124 | 125 | it('should assume the first row is data if no columns are provided', () => { 126 | const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; 127 | service.loadChartPackages.mockReturnValueOnce(of(null)); 128 | 129 | const data = [ 130 | ['First Row', 10], 131 | ['Second Row', 11] 132 | ]; 133 | component.data = data; 134 | 135 | component.ngOnInit(); 136 | 137 | expect(visualizationMock.arrayToDataTable).toHaveBeenCalledWith(data, true); 138 | }); 139 | 140 | it('should create the chart', () => { 141 | const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; 142 | service.loadChartPackages.mockReturnValueOnce(of(null)); 143 | 144 | const dataTableMock = { data: [] }; 145 | visualizationMock.arrayToDataTable.mockReturnValueOnce(dataTableMock); 146 | 147 | const data = [ 148 | ['First Row', 10], 149 | ['Second Row', 11] 150 | ]; 151 | component.data = data; 152 | 153 | const columns = ['Some data', 'Some values']; 154 | component.columns = columns; 155 | 156 | const options = { test: 'test' }; 157 | component.options = options; 158 | 159 | const chartType = ChartType.BarChart; 160 | component.type = chartType; 161 | 162 | component.ngOnInit(); 163 | 164 | expect(visualizationMock.ChartWrapper).toHaveBeenCalledWith({ 165 | container: expect.any(Object), 166 | dataTable: dataTableMock, 167 | chartType, 168 | options 169 | }); 170 | }); 171 | 172 | it('should set the title, width and height', () => { 173 | const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; 174 | service.loadChartPackages.mockReturnValueOnce(of(null)); 175 | 176 | const dataTableMock = { data: [] }; 177 | visualizationMock.arrayToDataTable.mockReturnValueOnce(dataTableMock); 178 | 179 | const data = [ 180 | ['First Row', 10], 181 | ['Second Row', 11] 182 | ]; 183 | component.data = data; 184 | 185 | const columns = ['Some data', 'Some values']; 186 | component.columns = columns; 187 | 188 | const options = { test: 'test' }; 189 | component.options = options; 190 | 191 | const title = 'chart'; 192 | component.title = title; 193 | 194 | const width = 120; 195 | component.width = width; 196 | 197 | const height = 150; 198 | component.height = height; 199 | 200 | const chartType = ChartType.BarChart; 201 | component.type = chartType; 202 | 203 | component.ngOnInit(); 204 | 205 | expect(visualizationMock.ChartWrapper).toHaveBeenCalledWith({ 206 | container: expect.any(Object), 207 | dataTable: dataTableMock, 208 | options: { ...options, width, height, title }, 209 | chartType 210 | }); 211 | }); 212 | 213 | it('should apply the provided formatters', () => { 214 | const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; 215 | service.loadChartPackages.mockReturnValueOnce(of(null)); 216 | 217 | const formatter = { formatter: { format: jest.fn() }, colIndex: 1 }; 218 | component.formatters = [formatter]; 219 | component.data = []; 220 | 221 | const dataTableMock = {}; 222 | visualizationMock.arrayToDataTable.mockReturnValueOnce(dataTableMock); 223 | 224 | component.ngOnInit(); 225 | 226 | expect(formatter.formatter.format).toHaveBeenCalledWith(dataTableMock, formatter.colIndex); 227 | }); 228 | 229 | it('should draw the chart', () => { 230 | const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; 231 | service.loadChartPackages.mockReturnValueOnce(of(null)); 232 | 233 | component.ngOnInit(); 234 | 235 | expect(chartWrapperMock.draw).toHaveBeenCalled(); 236 | }); 237 | 238 | it('should not draw the chart if the chart is part of a dashboard', () => { 239 | const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; 240 | service.loadChartPackages.mockReturnValueOnce(of(null)); 241 | 242 | component['dashboard'] = {} as any; 243 | 244 | component.ngOnInit(); 245 | 246 | expect(chartWrapperMock.draw).not.toHaveBeenCalled(); 247 | }); 248 | 249 | it('should emit wrapper ready event', () => { 250 | const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; 251 | service.loadChartPackages.mockReturnValueOnce(of(null)); 252 | 253 | const readySpy = jest.fn(); 254 | component.wrapperReady$.subscribe(event => readySpy(event)); 255 | 256 | component.ngOnInit(); 257 | 258 | expect(readySpy).toHaveBeenCalledWith(chartWrapperMock); 259 | }); 260 | 261 | it('should register chart wrapper event handlers', () => { 262 | const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; 263 | service.loadChartPackages.mockReturnValueOnce(of(null)); 264 | 265 | component.ngOnInit(); 266 | 267 | expect(visualizationMock.events.removeAllListeners).toHaveBeenCalled(); 268 | 269 | expect(visualizationMock.events.addListener).toHaveBeenCalledWith( 270 | chartWrapperMock, 271 | 'ready', 272 | expect.any(Function) 273 | ); 274 | expect(visualizationMock.events.addListener).toHaveBeenCalledWith( 275 | chartWrapperMock, 276 | 'error', 277 | expect.any(Function) 278 | ); 279 | }); 280 | 281 | it('should create the chart with correct package', () => { 282 | const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; 283 | service.loadChartPackages.mockReturnValueOnce(of(null)); 284 | 285 | const chartType = ChartType.Map; 286 | component.type = chartType; 287 | 288 | component.ngOnInit(); 289 | 290 | expect(service.loadChartPackages).toHaveBeenCalledWith('map'); 291 | }); 292 | }); 293 | 294 | describe('ngOnChanges', () => { 295 | beforeEach(() => { 296 | component['wrapper'] = chartWrapperMock as any; 297 | component['initialized'] = true; 298 | }); 299 | 300 | it('should not throw if anything changed but the chart wrapper was not yet initialized', () => { 301 | component['wrapper'] = undefined; 302 | component['initialized'] = false; 303 | 304 | expect(() => { 305 | component.ngOnChanges({ 306 | data: new SimpleChange(null, [], true), 307 | columns: new SimpleChange(null, [], true) 308 | }); 309 | }).not.toThrow(); 310 | }); 311 | 312 | it('should draw the chart only once if `type`, `data` and `columns` changed all at once', () => { 313 | const data = [ 314 | ['First Row', 10], 315 | ['Second Row', 11] 316 | ]; 317 | component.data = data; 318 | 319 | const columns = ['Some data', 'Some values']; 320 | component.columns = columns; 321 | 322 | const chartType = ChartType.BarChart; 323 | component.type = chartType; 324 | component.ngOnChanges({ 325 | type: new SimpleChange(null, chartType, true), 326 | data: new SimpleChange(null, data, true), 327 | columns: new SimpleChange(null, columns, true) 328 | }); 329 | 330 | expect(chartWrapperMock.draw).toHaveBeenCalledTimes(1); 331 | }); 332 | 333 | it('should not redraw the chart if nothing changed', () => { 334 | component.ngOnChanges({}); 335 | 336 | expect(chartWrapperMock.draw).not.toBeCalled(); 337 | }); 338 | 339 | it('should redraw the chart if `data` changed', () => { 340 | const data = [ 341 | ['First Row', 10], 342 | ['Second Row', 11] 343 | ]; 344 | component.data = data; 345 | 346 | const columns = ['Some label', 'Some values']; 347 | component.columns = columns; 348 | 349 | const dataTableMock = {}; 350 | visualizationMock.arrayToDataTable.mockReturnValueOnce(dataTableMock); 351 | 352 | const newData = [...data, ['Third Row', 12]]; 353 | changeInput('data', newData); 354 | 355 | expect(visualizationMock.arrayToDataTable).toHaveBeenCalledWith([columns, ...newData], false); 356 | expect(chartWrapperMock.setDataTable).toHaveBeenCalledWith(dataTableMock); 357 | expect(chartWrapperMock.draw).toHaveBeenCalled(); 358 | }); 359 | 360 | it('should redraw the chart if `columns` changed', () => { 361 | const data = [ 362 | ['First Row', 10], 363 | ['Second Row', 11] 364 | ]; 365 | component.data = data; 366 | 367 | const columns = ['Some label', 'Some values']; 368 | component.columns = columns; 369 | 370 | const dataTableMock = {}; 371 | visualizationMock.arrayToDataTable.mockReturnValueOnce(dataTableMock); 372 | 373 | const newColumns = ['New label', 'Some values']; 374 | changeInput('columns', newColumns); 375 | 376 | expect(visualizationMock.arrayToDataTable).toHaveBeenCalledWith([newColumns, ...data], false); 377 | expect(chartWrapperMock.setDataTable).toHaveBeenCalledWith(dataTableMock); 378 | expect(chartWrapperMock.draw).toHaveBeenCalled(); 379 | }); 380 | 381 | it('should redraw the chart if `formatters` changed', () => { 382 | const data = [ 383 | ['First Row', 10], 384 | ['Second Row', 11] 385 | ]; 386 | component.data = data; 387 | 388 | const columns = ['Some label', 'Some values']; 389 | component.columns = columns; 390 | 391 | const dataTableMock = {}; 392 | visualizationMock.arrayToDataTable.mockReturnValueOnce(dataTableMock); 393 | 394 | const formatter = { formatter: { format: jest.fn() }, colIndex: 1 }; 395 | changeInput('formatters', [formatter]); 396 | 397 | expect(formatter.formatter.format).toHaveBeenCalled(); 398 | expect(chartWrapperMock.setDataTable).toHaveBeenCalledWith(dataTableMock); 399 | expect(chartWrapperMock.draw).toHaveBeenCalled(); 400 | }); 401 | 402 | it('should redraw the chart if `options` changed', () => { 403 | const options = { test: 'test' }; 404 | changeInput('options', options); 405 | 406 | expect(chartWrapperMock.setOptions).toHaveBeenCalledWith(options); 407 | expect(chartWrapperMock.draw).toHaveBeenCalled(); 408 | }); 409 | 410 | it('should redraw the chart if `title` changed', () => { 411 | const title = 'some title'; 412 | changeInput('title', title); 413 | 414 | expect(chartWrapperMock.setOptions).toHaveBeenCalledWith({ title }); 415 | expect(chartWrapperMock.draw).toHaveBeenCalled(); 416 | }); 417 | 418 | it('should redraw the chart if `width` changed', () => { 419 | const width = 100; 420 | changeInput('width', width); 421 | 422 | expect(chartWrapperMock.setOptions).toHaveBeenCalledWith({ width }); 423 | expect(chartWrapperMock.draw).toHaveBeenCalled(); 424 | }); 425 | 426 | it('should redraw the chart if `height` changed', () => { 427 | const height = 100; 428 | changeInput('height', height); 429 | 430 | expect(chartWrapperMock.setOptions).toHaveBeenCalledWith({ height }); 431 | expect(chartWrapperMock.draw).toHaveBeenCalled(); 432 | }); 433 | }); 434 | 435 | describe('dynamicResize', () => { 436 | it('should subscribe to window resize event if set to true', () => { 437 | changeInput('dynamicResize', true); 438 | 439 | expect(component['resizeSubscription']).toBeTruthy(); 440 | }); 441 | 442 | it('should unsubscribe existing subscription if changed', () => { 443 | const subscriptionMock = { unsubscribe: jest.fn() }; 444 | component['resizeSubscription'] = subscriptionMock as any; 445 | 446 | changeInput('dynamicResize', false); 447 | 448 | expect(subscriptionMock.unsubscribe).toHaveBeenCalled(); 449 | expect(component['resizeSubscription']).toBeUndefined(); 450 | }); 451 | 452 | it('should do nothing if the window was resized, but the chart is not yet initialized', fakeAsync(() => { 453 | changeInput('dynamicResize', true); 454 | 455 | expect(() => { 456 | // This would cause an error if the wrapper would somehow be called 457 | window.dispatchEvent(new Event('resize')); 458 | tick(100); 459 | }).not.toThrow(); 460 | })); 461 | 462 | it('should redraw the chart if the window was resized', fakeAsync(() => { 463 | const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; 464 | scriptLoaderService.loadChartPackages.mockReturnValue(of(null)); 465 | 466 | component.ngOnInit(); 467 | 468 | changeInput('dynamicResize', true); 469 | 470 | window.dispatchEvent(new Event('resize')); 471 | tick(100); 472 | 473 | expect(chartWrapperMock.draw).toHaveBeenCalled(); 474 | })); 475 | }); 476 | 477 | describe('events', () => { 478 | beforeEach(() => { 479 | const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; 480 | service.loadChartPackages.mockReturnValueOnce(of(null)); 481 | }); 482 | 483 | it('should register chart event handlers after the chart got drawn', () => { 484 | const chartMock = { draw: jest.fn() }; 485 | chartWrapperMock.getChart.mockReturnValue(chartMock); 486 | 487 | component.ngOnInit(); 488 | 489 | expect(visualizationMock.events.addListener).not.toHaveBeenCalledWith( 490 | chartWrapperMock, 491 | 'onmouseover', 492 | expect.any(Function) 493 | ); 494 | expect(visualizationMock.events.addListener).not.toHaveBeenCalledWith( 495 | chartWrapperMock, 496 | 'onmouseout', 497 | expect.any(Function) 498 | ); 499 | expect(visualizationMock.events.addListener).not.toHaveBeenCalledWith( 500 | chartWrapperMock, 501 | 'select', 502 | expect.any(Function) 503 | ); 504 | 505 | const readyCallback = visualizationMock.events.addListener.mock.calls[0][2]; 506 | readyCallback(); 507 | 508 | expect(visualizationMock.events.addListener).toHaveBeenCalledWith(chartMock, 'onmouseover', expect.any(Function)); 509 | expect(visualizationMock.events.addListener).toHaveBeenCalledWith(chartMock, 'onmouseout', expect.any(Function)); 510 | expect(visualizationMock.events.addListener).toHaveBeenCalledWith(chartMock, 'select', expect.any(Function)); 511 | }); 512 | 513 | it('should remove all listeners from the chart before subscribing again', () => { 514 | const chartMock = { draw: jest.fn() }; 515 | chartWrapperMock.getChart.mockReturnValue(chartMock); 516 | 517 | component.ngOnInit(); 518 | 519 | expect(visualizationMock.events.removeAllListeners).toHaveBeenCalledWith(chartWrapperMock); 520 | 521 | const readyCallback = visualizationMock.events.addListener.mock.calls[0][2]; 522 | readyCallback(); 523 | 524 | expect(visualizationMock.events.removeAllListeners).toHaveBeenCalledWith(chartMock); 525 | }); 526 | 527 | it('should emit ready event after the chart is ready', () => { 528 | const chartMock = { draw: jest.fn() }; 529 | chartWrapperMock.getChart.mockReturnValue(chartMock); 530 | 531 | component.ngOnInit(); 532 | 533 | const readySpy = jest.fn(); 534 | component.ready.subscribe((event: ChartReadyEvent) => readySpy(event)); 535 | 536 | const readyCallback = visualizationMock.events.addListener.mock.calls[0][2]; 537 | readyCallback(); 538 | 539 | expect(readySpy).toHaveBeenCalledWith({ chart: chartMock }); 540 | }); 541 | 542 | it('should emit error event if the chart caused an error', () => { 543 | component.ngOnInit(); 544 | 545 | const errorSpy = jest.fn(); 546 | component.error.subscribe((event: ChartReadyEvent) => errorSpy(event)); 547 | 548 | const errorCallback = visualizationMock.events.addListener.mock.calls[1][2]; 549 | 550 | const error = 'someerror'; 551 | errorCallback(error); 552 | 553 | expect(errorSpy).toHaveBeenCalledWith(error); 554 | }); 555 | 556 | it('should emit select event if a value was selected', () => { 557 | const selection = [{ column: 1, row: 2 }] as google.visualization.ChartSelection[]; 558 | 559 | const chartMock = { getSelection: jest.fn(() => selection) }; 560 | chartWrapperMock.getChart.mockReturnValue(chartMock); 561 | 562 | const selectSpy = jest.fn(); 563 | component.select.subscribe((event: ChartReadyEvent) => selectSpy(event)); 564 | 565 | component.ngOnInit(); 566 | 567 | const readyCallback = visualizationMock.events.addListener.mock.calls[0][2]; 568 | readyCallback(); 569 | 570 | expect(selectSpy).not.toHaveBeenCalled(); 571 | 572 | const selectCallback = visualizationMock.events.addListener.mock.calls[4][2]; 573 | selectCallback(); 574 | 575 | expect(selectSpy).toHaveBeenCalledWith({ selection }); 576 | }); 577 | 578 | it('should add and remove custom event listeners', () => { 579 | const chartMock = { draw: jest.fn() }; 580 | chartWrapperMock.getChart.mockReturnValue(chartMock); 581 | 582 | component.ngOnInit(); 583 | 584 | visualizationMock.events.addListener.mockReturnValue('handle1'); 585 | const rollupCallback = () => {}; 586 | let handle = component.addEventListener('rollup', rollupCallback); 587 | expect(handle).toBe('handle1'); 588 | expect(visualizationMock.events.addListener).lastCalledWith(chartMock, 'rollup', rollupCallback); 589 | 590 | component.removeEventListener(handle); 591 | expect(visualizationMock.events.removeListener).lastCalledWith(handle); 592 | 593 | handle = component.addEventListener('rollup', rollupCallback); 594 | visualizationMock.events.addListener.mockReturnValue('handle2'); 595 | const readyCallback = visualizationMock.events.addListener.mock.calls[0][2]; 596 | readyCallback(); 597 | 598 | component.removeEventListener(handle); 599 | expect(visualizationMock.events.removeListener).not.lastCalledWith(handle); 600 | expect(visualizationMock.events.removeListener).lastCalledWith('handle2'); 601 | }); 602 | }); 603 | 604 | function changeInput(property: K, newValue: GoogleChartComponent[K]) { 605 | const oldValue = component[property]; 606 | component[property] = newValue; 607 | component.ngOnChanges({ [property]: new SimpleChange(oldValue, newValue, oldValue == null) }); 608 | } 609 | }); 610 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/components/google-chart/google-chart.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | ElementRef, 5 | EventEmitter, 6 | Input, 7 | OnChanges, 8 | OnDestroy, 9 | OnInit, 10 | Optional, 11 | Output, 12 | SimpleChanges 13 | } from '@angular/core'; 14 | import { fromEvent, Observable, ReplaySubject, Subscription } from 'rxjs'; 15 | import { debounceTime } from 'rxjs/operators'; 16 | 17 | import { getPackageForChart } from '../../helpers/chart.helper'; 18 | import { DataTableService } from '../../services/data-table.service'; 19 | import { ScriptLoaderService } from '../../services/script-loader.service'; 20 | import { ChartType } from '../../types/chart-type'; 21 | import { 22 | ChartErrorEvent, 23 | ChartMouseLeaveEvent, 24 | ChartMouseOverEvent, 25 | ChartReadyEvent, 26 | ChartSelectionChangedEvent 27 | } from '../../types/events'; 28 | import { Formatter } from '../../types/formatter'; 29 | import { ChartBase, Column, Row } from '../chart-base/chart-base.component'; 30 | import { DashboardComponent } from '../dashboard/dashboard.component'; 31 | 32 | @Component({ 33 | selector: 'google-chart', 34 | template: '', 35 | styles: [':host { width: fit-content; display: block; }'], 36 | host: { class: 'google-chart' }, 37 | exportAs: 'googleChart', 38 | changeDetection: ChangeDetectionStrategy.OnPush 39 | }) 40 | export class GoogleChartComponent implements ChartBase, OnInit, OnChanges, OnDestroy { 41 | /** 42 | * The type of the chart to create. 43 | */ 44 | @Input() 45 | public type!: ChartType; 46 | 47 | /** 48 | * Data used to initialize the table. 49 | * 50 | * This must also contain all roles that are set in the `columns` property. 51 | */ 52 | @Input() 53 | public data!: Row[]; 54 | 55 | /** 56 | * The columns the `data` consists of. 57 | * The length of this array must match the length of each row in the `data` object. 58 | * 59 | * If {@link https://developers.google.com/chart/interactive/docs/roles roles} should be applied, they must be included in this array as well. 60 | */ 61 | @Input() 62 | public columns?: Column[]; 63 | 64 | /** 65 | * A convenience property used to set the title of the chart. 66 | * 67 | * This can also be set using `options.title`, which, if existant, will overwrite this value. 68 | */ 69 | @Input() 70 | public title?: string; 71 | 72 | /** 73 | * A convenience property used to set the width of the chart in pixels. 74 | * 75 | * This can also be set using `options.width`, which, if existant, will overwrite this value. 76 | */ 77 | @Input() 78 | public width?: number; 79 | 80 | /** 81 | * A convenience property used to set the height of the chart in pixels. 82 | * 83 | * This can also be set using `options.height`, which, if existant, will overwrite this value. 84 | */ 85 | @Input() 86 | public height?: number; 87 | 88 | /** 89 | * The chart-specific options. All options listen in the Google Charts documentation applying 90 | * to the chart type specified can be used here. 91 | */ 92 | @Input() 93 | public options: object = {}; 94 | 95 | /** 96 | * Used to change the displayed value of the specified column in all rows. 97 | * 98 | * Each array element must consist of an instance of a [`formatter`](https://developers.google.com/chart/interactive/docs/reference#formatters) 99 | * and the index of the column you want the formatter to get applied to. 100 | */ 101 | @Input() 102 | public formatters?: Formatter[]; 103 | 104 | /** 105 | * If this is set to `true`, the chart will be redrawn if the browser window is resized. 106 | * Defaults to `false` and should only be used when specifying the width or height of the chart 107 | * in percent. 108 | * 109 | * Note that this can impact performance. 110 | */ 111 | @Input() 112 | public dynamicResize = false; 113 | 114 | @Output() 115 | public ready = new EventEmitter(); 116 | 117 | @Output() 118 | public error = new EventEmitter(); 119 | 120 | @Output() 121 | public select = new EventEmitter(); 122 | 123 | @Output() 124 | public mouseover = new EventEmitter(); 125 | 126 | @Output() 127 | public mouseleave = new EventEmitter(); 128 | 129 | private resizeSubscription?: Subscription; 130 | 131 | private dataTable: google.visualization.DataTable | undefined; 132 | private wrapper: google.visualization.ChartWrapper | undefined; 133 | private wrapperReadySubject = new ReplaySubject(1); 134 | private initialized = false; 135 | private eventListeners = new Map(); 136 | 137 | constructor( 138 | private element: ElementRef, 139 | private scriptLoaderService: ScriptLoaderService, 140 | private dataTableService: DataTableService, 141 | @Optional() private dashboard?: DashboardComponent 142 | ) {} 143 | 144 | public get chart(): google.visualization.ChartBase | null { 145 | return this.chartWrapper.getChart(); 146 | } 147 | 148 | public get wrapperReady$(): Observable { 149 | return this.wrapperReadySubject.asObservable(); 150 | } 151 | 152 | public get chartWrapper(): google.visualization.ChartWrapper { 153 | if (!this.wrapper) { 154 | throw new Error('Trying to access the chart wrapper before it was fully initialized'); 155 | } 156 | 157 | return this.wrapper; 158 | } 159 | 160 | public set chartWrapper(wrapper: google.visualization.ChartWrapper) { 161 | this.wrapper = wrapper; 162 | this.drawChart(); 163 | } 164 | 165 | public ngOnInit() { 166 | // We don't need to load any chart packages, the chart wrapper will handle this for us 167 | this.scriptLoaderService.loadChartPackages(getPackageForChart(this.type)).subscribe(() => { 168 | this.dataTable = this.dataTableService.create(this.data, this.columns, this.formatters); 169 | 170 | // Only ever create the wrapper once to allow animations to happen when something changes. 171 | this.wrapper = new google.visualization.ChartWrapper({ 172 | container: this.element.nativeElement, 173 | chartType: this.type, 174 | dataTable: this.dataTable, 175 | options: this.mergeOptions() 176 | }); 177 | 178 | this.registerChartEvents(); 179 | 180 | this.wrapperReadySubject.next(this.wrapper); 181 | this.initialized = true; 182 | 183 | this.drawChart(); 184 | }); 185 | } 186 | 187 | public ngOnChanges(changes: SimpleChanges) { 188 | if (changes.dynamicResize) { 189 | this.updateResizeListener(); 190 | } 191 | 192 | if (this.initialized) { 193 | let shouldRedraw = false; 194 | if (changes.data || changes.columns || changes.formatters) { 195 | this.dataTable = this.dataTableService.create(this.data, this.columns, this.formatters); 196 | this.wrapper!.setDataTable(this.dataTable!); 197 | shouldRedraw = true; 198 | } 199 | 200 | if (changes.type) { 201 | this.wrapper!.setChartType(this.type); 202 | shouldRedraw = true; 203 | } 204 | 205 | if (changes.options || changes.width || changes.height || changes.title) { 206 | this.wrapper!.setOptions(this.mergeOptions()); 207 | shouldRedraw = true; 208 | } 209 | 210 | if (shouldRedraw) { 211 | this.drawChart(); 212 | } 213 | } 214 | } 215 | 216 | public ngOnDestroy(): void { 217 | this.unsubscribeToResizeIfSubscribed(); 218 | } 219 | 220 | /** 221 | * For listening to events other than the most common ones (available via Output properties). 222 | * 223 | * Can be called after the chart emits that it's "ready". 224 | * 225 | * Returns a handle that can be used for `removeEventListener`. 226 | */ 227 | public addEventListener(eventName: string, callback: Function): any { 228 | const handle = this.registerChartEvent(this.chart, eventName, callback); 229 | this.eventListeners.set(handle, { eventName, callback, handle }); 230 | return handle; 231 | } 232 | 233 | public removeEventListener(handle: any): void { 234 | const entry = this.eventListeners.get(handle); 235 | if (entry) { 236 | google.visualization.events.removeListener(entry.handle); 237 | this.eventListeners.delete(handle); 238 | } 239 | } 240 | 241 | private updateResizeListener() { 242 | this.unsubscribeToResizeIfSubscribed(); 243 | 244 | if (this.dynamicResize) { 245 | this.resizeSubscription = fromEvent(window, 'resize', { passive: true }) 246 | .pipe(debounceTime(100)) 247 | .subscribe(() => { 248 | if (this.initialized) { 249 | this.drawChart(); 250 | } 251 | }); 252 | } 253 | } 254 | 255 | private unsubscribeToResizeIfSubscribed() { 256 | if (this.resizeSubscription != null) { 257 | this.resizeSubscription.unsubscribe(); 258 | this.resizeSubscription = undefined; 259 | } 260 | } 261 | 262 | private mergeOptions(): object { 263 | return { 264 | title: this.title, 265 | width: this.width, 266 | height: this.height, 267 | ...this.options 268 | }; 269 | } 270 | 271 | private registerChartEvents() { 272 | google.visualization.events.removeAllListeners(this.wrapper); 273 | 274 | this.registerChartEvent(this.wrapper, 'ready', () => { 275 | // This could also be done by checking if we already subscribed to the events 276 | google.visualization.events.removeAllListeners(this.chart); 277 | this.registerChartEvent(this.chart, 'onmouseover', (event: ChartMouseOverEvent) => this.mouseover.emit(event)); 278 | this.registerChartEvent(this.chart, 'onmouseout', (event: ChartMouseLeaveEvent) => this.mouseleave.emit(event)); 279 | this.registerChartEvent(this.chart, 'select', () => { 280 | const selection = this.chart!.getSelection(); 281 | this.select.emit({ selection }); 282 | }); 283 | this.eventListeners.forEach(x => (x.handle = this.registerChartEvent(this.chart, x.eventName, x.callback))); 284 | 285 | this.ready.emit({ chart: this.chart! }); 286 | }); 287 | 288 | this.registerChartEvent(this.wrapper, 'error', (error: ChartErrorEvent) => this.error.emit(error)); 289 | } 290 | 291 | private registerChartEvent(object: any, eventName: string, callback: Function): any { 292 | return google.visualization.events.addListener(object, eventName, callback); 293 | } 294 | 295 | private drawChart() { 296 | if (this.dashboard != null) { 297 | // If this chart is part of a dashboard, the dashboard takes care of drawing 298 | return; 299 | } 300 | 301 | this.wrapper!.draw(); 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/google-charts.module.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { GoogleChartsModule } from './google-charts.module'; 4 | import { ScriptLoaderService } from './services/script-loader.service'; 5 | import { GoogleChartsConfig, GOOGLE_CHARTS_CONFIG } from './types/google-charts-config'; 6 | 7 | describe('GoogleChartsModule', () => { 8 | let service: ScriptLoaderService; 9 | 10 | describe('direct import', () => { 11 | beforeEach(() => { 12 | TestBed.configureTestingModule({ 13 | imports: [GoogleChartsModule] 14 | }); 15 | }); 16 | 17 | it('should allow instantiation of `ScriptLoaderService`', () => { 18 | service = TestBed.inject(ScriptLoaderService); 19 | expect(service).toBeTruthy(); 20 | }); 21 | }); 22 | 23 | describe('config via forRoot', () => { 24 | const mapsApiKey = 'myMapsApiKey'; 25 | const version = '13.5'; 26 | const safeMode = false; 27 | 28 | it('should provide the given config values', () => { 29 | const config: GoogleChartsConfig = { mapsApiKey, version, safeMode }; 30 | 31 | TestBed.configureTestingModule({ 32 | imports: [GoogleChartsModule.forRoot(config)] 33 | }); 34 | 35 | expect(TestBed.inject(GOOGLE_CHARTS_CONFIG)).toEqual(config); 36 | }); 37 | 38 | it('should accept empty config', () => { 39 | TestBed.configureTestingModule({ 40 | imports: [GoogleChartsModule.forRoot()] 41 | }); 42 | 43 | expect(TestBed.inject(GOOGLE_CHARTS_CONFIG)).toEqual({}); 44 | }); 45 | 46 | it('should accept a partial config', () => { 47 | TestBed.configureTestingModule({ 48 | imports: [GoogleChartsModule.forRoot({ mapsApiKey })] 49 | }); 50 | 51 | expect(TestBed.inject(GOOGLE_CHARTS_CONFIG)).toMatchObject({ mapsApiKey }); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/google-charts.module.ts: -------------------------------------------------------------------------------- 1 | import { ModuleWithProviders, NgModule } from '@angular/core'; 2 | 3 | import { ChartEditorComponent } from './components/chart-editor/chart-editor.component'; 4 | import { ChartWrapperComponent } from './components/chart-wrapper/chart-wrapper.component'; 5 | import { ControlWrapperComponent } from './components/control-wrapper/control-wrapper.component'; 6 | import { DashboardComponent } from './components/dashboard/dashboard.component'; 7 | import { GoogleChartComponent } from './components/google-chart/google-chart.component'; 8 | import { ScriptLoaderService } from './services/script-loader.service'; 9 | import { GoogleChartsConfig, GOOGLE_CHARTS_CONFIG } from './types/google-charts-config'; 10 | 11 | @NgModule({ 12 | declarations: [ 13 | GoogleChartComponent, 14 | ChartWrapperComponent, 15 | DashboardComponent, 16 | ControlWrapperComponent, 17 | ChartEditorComponent 18 | ], 19 | providers: [ScriptLoaderService], 20 | exports: [ 21 | GoogleChartComponent, 22 | ChartWrapperComponent, 23 | DashboardComponent, 24 | ControlWrapperComponent, 25 | ChartEditorComponent 26 | ] 27 | }) 28 | export class GoogleChartsModule { 29 | public static forRoot(config: GoogleChartsConfig = {}): ModuleWithProviders { 30 | return { 31 | ngModule: GoogleChartsModule, 32 | providers: [{ provide: GOOGLE_CHARTS_CONFIG, useValue: config }] 33 | }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/helpers/chart.helper.ts: -------------------------------------------------------------------------------- 1 | import { ChartType } from '../types/chart-type'; 2 | import { GoogleChartsConfig } from '../types/google-charts-config'; 3 | 4 | const ChartTypesToPackages = { 5 | [ChartType.AnnotationChart]: 'annotationchart', 6 | [ChartType.AreaChart]: 'corechart', 7 | [ChartType.Bar]: 'bar', 8 | [ChartType.BarChart]: 'corechart', 9 | [ChartType.BubbleChart]: 'corechart', 10 | [ChartType.Calendar]: 'calendar', 11 | [ChartType.CandlestickChart]: 'corechart', 12 | [ChartType.ColumnChart]: 'corechart', 13 | [ChartType.ComboChart]: 'corechart', 14 | [ChartType.PieChart]: 'corechart', 15 | [ChartType.Gantt]: 'gantt', 16 | [ChartType.Gauge]: 'gauge', 17 | [ChartType.GeoChart]: 'geochart', 18 | [ChartType.Histogram]: 'corechart', 19 | [ChartType.Line]: 'line', 20 | [ChartType.LineChart]: 'corechart', 21 | [ChartType.Map]: 'map', 22 | [ChartType.OrgChart]: 'orgchart', 23 | [ChartType.Sankey]: 'sankey', 24 | [ChartType.Scatter]: 'scatter', 25 | [ChartType.ScatterChart]: 'corechart', 26 | [ChartType.SteppedAreaChart]: 'corechart', 27 | [ChartType.Table]: 'table', 28 | [ChartType.Timeline]: 'timeline', 29 | [ChartType.TreeMap]: 'treemap', 30 | [ChartType.WordTree]: 'wordtree' 31 | }; 32 | 33 | export function getPackageForChart(type: ChartType): string { 34 | return ChartTypesToPackages[type]; 35 | } 36 | 37 | export function getDefaultConfig(): GoogleChartsConfig { 38 | return { 39 | version: 'current', 40 | safeMode: false 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/helpers/id.helper.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates a random ID which can be used to uniquely identify an element. 3 | */ 4 | export function generateRandomId() { 5 | // Math.random should be unique because of its seeding algorithm. 6 | // Convert it to base 36 (numbers + letters), and grab the first 9 characters 7 | // after the decimal. 8 | return '_' + Math.random().toString(36).substr(2, 9); 9 | } 10 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/services/data-table.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { Column, Row } from '../components/chart-base/chart-base.component'; 4 | import { Formatter } from '../types/formatter'; 5 | 6 | @Injectable({ providedIn: 'root' }) 7 | export class DataTableService { 8 | public create( 9 | data: Row[] | undefined, 10 | columns?: Column[], 11 | formatters?: Formatter[] 12 | ): google.visualization.DataTable | undefined { 13 | if (data == null) { 14 | return undefined; 15 | } 16 | 17 | let firstRowIsData = true; 18 | if (columns != null) { 19 | firstRowIsData = false; 20 | } 21 | 22 | const dataTable = google.visualization.arrayToDataTable(this.getDataAsTable(data, columns), firstRowIsData); 23 | if (formatters) { 24 | this.applyFormatters(dataTable, formatters); 25 | } 26 | 27 | return dataTable; 28 | } 29 | 30 | private getDataAsTable(data: Row[], columns: Column[] | undefined): (Row | Column[])[] { 31 | if (columns) { 32 | return [columns, ...data]; 33 | } else { 34 | return data; 35 | } 36 | } 37 | 38 | private applyFormatters(dataTable: google.visualization.DataTable, formatters: Formatter[]): void { 39 | for (const val of formatters) { 40 | val.formatter.format(dataTable, val.colIndex); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/services/script-loader.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { LOCALE_ID } from '@angular/core'; 2 | import { TestBed } from '@angular/core/testing'; 3 | import { Subject, throwError } from 'rxjs'; 4 | import { catchError } from 'rxjs/operators'; 5 | 6 | import { getDefaultConfig } from '../helpers/chart.helper'; 7 | import { GoogleChartsConfig, GOOGLE_CHARTS_CONFIG, GOOGLE_CHARTS_LAZY_CONFIG } from '../types/google-charts-config'; 8 | 9 | import { ScriptLoaderService } from './script-loader.service'; 10 | 11 | describe('ScriptLoaderService', () => { 12 | let service: ScriptLoaderService; 13 | 14 | beforeEach(() => { 15 | TestBed.configureTestingModule({ 16 | providers: [ScriptLoaderService] 17 | }); 18 | }); 19 | 20 | beforeEach(() => { 21 | service = TestBed.inject(ScriptLoaderService); 22 | (globalThis as any).google = undefined; 23 | }); 24 | 25 | describe('isGoogleChartsAvailable', () => { 26 | it('should be false if `google` is not available', () => { 27 | expect(service.isGoogleChartsAvailable()).toBeFalsy(); 28 | }); 29 | 30 | it('should be false if another google package is loaded, but `google.charts` is not available', () => { 31 | globalThis.google = { someOtherPackage: {} } as any; 32 | 33 | expect(service.isGoogleChartsAvailable()).toBeFalsy(); 34 | }); 35 | 36 | it('should be true if `google.charts` and `google.visulization` is available', () => { 37 | globalThis.google = { 38 | charts: { load: () => {} }, 39 | visulization: { arrayToDataTable: () => {} } 40 | } as any; 41 | 42 | expect(service.isGoogleChartsAvailable()).toBeTruthy(); 43 | }); 44 | }); 45 | 46 | describe('loadChartPackages', () => { 47 | it('should load the google charts script before trying to load packages', () => { 48 | const createElementSpy = jest.spyOn(document, 'createElement').mockReturnValue({} as any); 49 | 50 | const headMock = { appendChild: jest.fn() }; 51 | jest 52 | .spyOn(document, 'getElementsByTagName') 53 | .mockReturnValueOnce([] as any) 54 | .mockReturnValueOnce([headMock] as any); 55 | 56 | service.loadChartPackages().subscribe(); 57 | 58 | expect(createElementSpy).toHaveBeenCalledWith('script'); 59 | expect(headMock.appendChild).toHaveBeenCalledWith( 60 | expect.objectContaining({ 61 | type: 'text/javascript', 62 | src: service['scriptSource'], 63 | async: true 64 | }) 65 | ); 66 | }); 67 | 68 | describe('loading the charts script', () => { 69 | let createElementSpy: jest.SpyInstance; 70 | let getElementsByTagNameSpy: jest.SpyInstance; 71 | 72 | beforeEach(() => { 73 | createElementSpy = jest.spyOn(document, 'createElement').mockImplementation(); 74 | getElementsByTagNameSpy = jest.spyOn(document, 'getElementsByTagName').mockImplementation(); 75 | }); 76 | 77 | it('should not load the script if it is already loaded', () => { 78 | globalThis.google = { 79 | charts: { load: () => {} } 80 | } as any; 81 | 82 | service.loadChartPackages().subscribe(); 83 | 84 | expect(createElementSpy).not.toHaveBeenCalled(); 85 | }); 86 | 87 | it('should not load the script if it is currently being loaded', () => { 88 | getElementsByTagNameSpy.mockReturnValue([{ src: service['scriptSource'] }]); 89 | 90 | service.loadChartPackages().subscribe(); 91 | 92 | expect(createElementSpy).not.toHaveBeenCalled(); 93 | }); 94 | 95 | it('should load the google charts script', () => { 96 | createElementSpy.mockReturnValue({}); 97 | 98 | const headMock = { appendChild: jest.fn() }; 99 | getElementsByTagNameSpy.mockReturnValueOnce([]).mockReturnValueOnce([headMock]); 100 | 101 | service.loadChartPackages().subscribe(); 102 | 103 | expect(createElementSpy).toHaveBeenCalledWith('script'); 104 | expect(headMock.appendChild).toHaveBeenCalledWith( 105 | expect.objectContaining({ 106 | type: 'text/javascript', 107 | src: service['scriptSource'], 108 | async: true 109 | }) 110 | ); 111 | }); 112 | 113 | it('should emit an error if the script fails to load', () => { 114 | const scriptMock = { onerror: () => void 0 }; 115 | createElementSpy.mockReturnValue(scriptMock); 116 | 117 | const headMock = { appendChild: jest.fn() }; 118 | getElementsByTagNameSpy.mockReturnValueOnce([]).mockReturnValueOnce([headMock]); 119 | 120 | const errorSpy = jest.fn(); 121 | service 122 | .loadChartPackages() 123 | .pipe( 124 | catchError(error => { 125 | errorSpy(); 126 | return throwError(error); 127 | }) 128 | ) 129 | .subscribe(); 130 | 131 | expect(errorSpy).not.toHaveBeenCalled(); 132 | 133 | const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); 134 | scriptMock.onerror(); 135 | consoleSpy.mockReset(); 136 | 137 | expect(errorSpy).toHaveBeenCalled(); 138 | }); 139 | }); 140 | 141 | describe('loading chart packages', () => { 142 | const chartsMock = { 143 | load: jest.fn(), 144 | setOnLoadCallback: jest.fn() 145 | }; 146 | 147 | it('should load the chart packages after loading the google charts script', () => { 148 | const scriptMock = { onload: () => {} }; 149 | 150 | jest.spyOn(document, 'createElement').mockReturnValue(scriptMock as any); 151 | 152 | const headMock = { appendChild: jest.fn() }; 153 | jest 154 | .spyOn(document, 'getElementsByTagName') 155 | .mockReturnValueOnce([] as any) 156 | .mockReturnValueOnce([headMock] as any); 157 | 158 | service.loadChartPackages().subscribe(); 159 | 160 | globalThis.google = { charts: chartsMock } as any; 161 | scriptMock.onload(); 162 | 163 | expect(chartsMock.load).toHaveBeenCalledWith('current', { 164 | packages: [], 165 | language: 'en-US', 166 | mapsApiKey: undefined, 167 | safeMode: false 168 | }); 169 | }); 170 | 171 | it('should immediately load the chart packages if the google charts script is already loaded', () => { 172 | globalThis.google = { charts: chartsMock } as any; 173 | 174 | const chart = 'corechart'; 175 | 176 | service.loadChartPackages(chart).subscribe(); 177 | 178 | expect(chartsMock.load).toHaveBeenCalledWith('current', { 179 | packages: [chart], 180 | language: 'en-US', 181 | mapsApiKey: undefined, 182 | safeMode: false 183 | }); 184 | }); 185 | 186 | it('should emit after loading the charts', () => { 187 | globalThis.google = { charts: chartsMock } as any; 188 | 189 | const chart = 'corechart'; 190 | let loadCallback: Function; 191 | 192 | chartsMock.setOnLoadCallback.mockImplementation(callback => (loadCallback = callback)); 193 | 194 | const loadedSpy = jest.fn(); 195 | service.loadChartPackages(chart).subscribe(() => loadedSpy()); 196 | 197 | expect(loadedSpy).not.toHaveBeenCalled(); 198 | 199 | expect(loadCallback!).toBeTruthy(); 200 | loadCallback!(); 201 | expect(loadedSpy).toHaveBeenCalled(); 202 | }); 203 | 204 | it('should use injected config values', () => { 205 | globalThis.google = { charts: chartsMock } as any; 206 | 207 | TestBed.resetTestingModule(); 208 | 209 | const version = 'current'; 210 | const mapsApiKey = 'mapsApiKey'; 211 | const safeMode = true; 212 | const locale = 'de-DE'; 213 | 214 | TestBed.configureTestingModule({ 215 | providers: [ 216 | ScriptLoaderService, 217 | { provide: LOCALE_ID, useValue: locale }, 218 | { provide: GOOGLE_CHARTS_CONFIG, useValue: { version, mapsApiKey, safeMode } } 219 | ] 220 | }); 221 | service = TestBed.inject(ScriptLoaderService); 222 | 223 | const chart = 'corechart'; 224 | 225 | service.loadChartPackages(chart).subscribe(); 226 | 227 | expect(chartsMock.load).toHaveBeenCalledWith(version, { 228 | packages: [chart], 229 | language: locale, 230 | mapsApiKey, 231 | safeMode 232 | }); 233 | }); 234 | 235 | it('should use injected lazy config values', () => { 236 | globalThis.google = { charts: chartsMock } as any; 237 | 238 | TestBed.resetTestingModule(); 239 | 240 | const mapsApiKey = 'mapsApiKey'; 241 | const safeMode = false; 242 | const locale = 'en-US'; 243 | const lazyConfigSubject = new Subject(); 244 | 245 | TestBed.configureTestingModule({ 246 | providers: [ 247 | ScriptLoaderService, 248 | { provide: LOCALE_ID, useValue: locale }, 249 | { provide: GOOGLE_CHARTS_LAZY_CONFIG, useValue: lazyConfigSubject.asObservable() } 250 | ] 251 | }); 252 | service = TestBed.inject(ScriptLoaderService); 253 | 254 | const chart = 'corechart'; 255 | 256 | service.loadChartPackages(chart).subscribe(); 257 | 258 | expect(chartsMock.load).not.toHaveBeenCalled(); 259 | 260 | lazyConfigSubject.next({ mapsApiKey, safeMode }); 261 | 262 | expect(chartsMock.load).toHaveBeenCalledWith(getDefaultConfig().version, { 263 | packages: [chart], 264 | language: locale, 265 | mapsApiKey, 266 | safeMode 267 | }); 268 | }); 269 | }); 270 | }); 271 | }); 272 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/services/script-loader.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, LOCALE_ID, NgZone } from '@angular/core'; 2 | import { Observable, of, Subject } from 'rxjs'; 3 | import { map, mergeMap, switchMap } from 'rxjs/operators'; 4 | 5 | import { getDefaultConfig } from '../helpers/chart.helper'; 6 | import { GoogleChartsConfig, GOOGLE_CHARTS_LAZY_CONFIG } from '../types/google-charts-config'; 7 | 8 | @Injectable() 9 | export class ScriptLoaderService { 10 | private readonly scriptSource = 'https://www.gstatic.com/charts/loader.js'; 11 | private readonly scriptLoadSubject = new Subject(); 12 | 13 | constructor( 14 | private zone: NgZone, 15 | @Inject(LOCALE_ID) private localeId: string, 16 | @Inject(GOOGLE_CHARTS_LAZY_CONFIG) private readonly config$: Observable 17 | ) {} 18 | 19 | /** 20 | * Checks whether `google.charts` is available. 21 | * 22 | * If not, it can be loaded by calling `loadChartPackages`. 23 | * 24 | * @returns `true` if `google.charts` is available, `false` otherwise. 25 | */ 26 | public isGoogleChartsAvailable(): boolean { 27 | if (typeof google === 'undefined' || typeof google.charts === 'undefined') { 28 | return false; 29 | } 30 | 31 | return true; 32 | } 33 | 34 | /** 35 | * Loads the Google Chart script and the provided chart packages. 36 | * Can be called multiple times to load more packages. 37 | * 38 | * When called without any arguments, this will just load the default package 39 | * containing the namespaces `google.charts` and `google.visualization` without any charts. 40 | * 41 | * @param packages The packages to load. 42 | * @returns A stream emitting as soon as the chart packages are loaded. 43 | */ 44 | public loadChartPackages(...packages: string[]): Observable { 45 | return this.loadGoogleCharts().pipe( 46 | mergeMap(() => this.config$), 47 | map(config => { 48 | return { ...getDefaultConfig(), ...(config || {}) }; 49 | }), 50 | switchMap((googleChartsConfig: GoogleChartsConfig) => { 51 | return new Observable(observer => { 52 | const config = { 53 | packages, 54 | language: this.localeId, 55 | mapsApiKey: googleChartsConfig.mapsApiKey, 56 | safeMode: googleChartsConfig.safeMode 57 | }; 58 | 59 | google.charts.load(googleChartsConfig.version!, config); 60 | google.charts.setOnLoadCallback(() => { 61 | this.zone.run(() => { 62 | observer.next(); 63 | observer.complete(); 64 | }); 65 | }); 66 | }); 67 | }) 68 | ); 69 | } 70 | 71 | /** 72 | * Loads the Google Charts script. After the script is loaded, `google.charts` is defined. 73 | * 74 | * @returns A stream emitting as soon as loading has completed. 75 | * If the google charts script is already loaded, the stream emits immediately. 76 | */ 77 | private loadGoogleCharts(): Observable { 78 | if (this.isGoogleChartsAvailable()) { 79 | return of(undefined); 80 | } else if (!this.isLoadingGoogleCharts()) { 81 | const script = this.createGoogleChartsScript(); 82 | script.onload = () => { 83 | this.zone.run(() => { 84 | this.scriptLoadSubject.next(); 85 | this.scriptLoadSubject.complete(); 86 | }); 87 | }; 88 | 89 | script.onerror = () => { 90 | this.zone.run(() => { 91 | console.error('Failed to load the google charts script!'); 92 | this.scriptLoadSubject.error(new Error('Failed to load the google charts script!')); 93 | }); 94 | }; 95 | } 96 | 97 | return this.scriptLoadSubject.asObservable(); 98 | } 99 | 100 | private isLoadingGoogleCharts() { 101 | return this.getGoogleChartsScript() != null; 102 | } 103 | 104 | private getGoogleChartsScript(): HTMLScriptElement | undefined { 105 | const pageScripts = Array.from(document.getElementsByTagName('script')); 106 | return pageScripts.find(script => script.src === this.scriptSource); 107 | } 108 | 109 | private createGoogleChartsScript(): HTMLScriptElement { 110 | const script = document.createElement('script'); 111 | script.type = 'text/javascript'; 112 | script.src = this.scriptSource; 113 | script.async = true; 114 | document.getElementsByTagName('head')[0].appendChild(script); 115 | return script; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/types/chart-type.ts: -------------------------------------------------------------------------------- 1 | export enum ChartType { 2 | AnnotationChart = 'AnnotationChart', 3 | AreaChart = 'AreaChart', 4 | Bar = 'Bar', 5 | BarChart = 'BarChart', 6 | BubbleChart = 'BubbleChart', 7 | Calendar = 'Calendar', 8 | CandlestickChart = 'CandlestickChart', 9 | ColumnChart = 'ColumnChart', 10 | ComboChart = 'ComboChart', 11 | PieChart = 'PieChart', 12 | Gantt = 'Gantt', 13 | Gauge = 'Gauge', 14 | GeoChart = 'GeoChart', 15 | Histogram = 'Histogram', 16 | Line = 'Line', 17 | LineChart = 'LineChart', 18 | Map = 'Map', 19 | OrgChart = 'OrgChart', 20 | Sankey = 'Sankey', 21 | Scatter = 'Scatter', 22 | ScatterChart = 'ScatterChart', 23 | SteppedAreaChart = 'SteppedAreaChart', 24 | Table = 'Table', 25 | Timeline = 'Timeline', 26 | TreeMap = 'TreeMap', 27 | WordTree = 'WordTree' 28 | } 29 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/types/control-type.ts: -------------------------------------------------------------------------------- 1 | export enum FilterType { 2 | Category = 'CategoryFilter', 3 | ChartRange = 'ChartRangeFilter', 4 | DateRange = 'DateRangeFilter', 5 | NumberRange = 'NumberRangeFilter', 6 | String = 'StringFilter' 7 | } 8 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/types/events.ts: -------------------------------------------------------------------------------- 1 | export interface ChartReadyEvent { 2 | /** 3 | * The newly instantiated chart. 4 | */ 5 | chart: T; 6 | } 7 | 8 | export interface ChartSelectionChangedEvent { 9 | /** 10 | * An array of objects describing the selected data elements. 11 | * 12 | * These objects have the properties `row` and `column`. 13 | * `row` and `column` are the row and column indexes of the selected item in the charts' data. 14 | * 15 | * If both `row` and `column` are specified, the selected element is a cell. 16 | * If only `row` is specified, the selected element is a row. 17 | * If only `column` is specified, the selected element is a column. 18 | */ 19 | selection: google.visualization.ChartSelection[]; 20 | } 21 | 22 | export interface ChartMouseOverEvent { 23 | /** 24 | * The index of the column of the hovered item in the chart data. 25 | */ 26 | column: number; 27 | 28 | /** 29 | * The index of the row of the hovered item in the chart data. 30 | */ 31 | row: number; 32 | } 33 | 34 | export interface ChartMouseLeaveEvent { 35 | column: number; 36 | row: number; 37 | } 38 | 39 | export interface ChartErrorEvent { 40 | /** 41 | * The ID of the DOM element containing the chart, or 42 | * an error message displayed instead of the chart if it cannot be rendered. 43 | */ 44 | id: string; 45 | 46 | /** 47 | * A short message string describing the error. 48 | */ 49 | message: string; 50 | 51 | /** 52 | * A detailed explanation of the error. 53 | */ 54 | detailedMessage?: string; 55 | 56 | /** 57 | * An object containing custom parameters appropriate to this error and chart type. 58 | */ 59 | options?: object; 60 | } 61 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/types/formatter.ts: -------------------------------------------------------------------------------- 1 | export interface Formatter { 2 | formatter: google.visualization.DefaultFormatter; 3 | colIndex: number; 4 | } 5 | -------------------------------------------------------------------------------- /projects/angular-google-charts/src/lib/types/google-charts-config.ts: -------------------------------------------------------------------------------- 1 | import { inject, InjectFlags, InjectionToken } from '@angular/core'; 2 | import { Observable, of } from 'rxjs'; 3 | 4 | import { getDefaultConfig } from '../helpers/chart.helper'; 5 | 6 | export interface GoogleChartsConfig { 7 | /** 8 | * This setting lets you specify a key that you may use with Geochart and Map Chart. 9 | * You may want to do this rather than using the default behavior which may result in 10 | * occasional throttling of service for your users. 11 | * 12 | * Only available when using Google Charts 45 or higher. 13 | * 14 | * {@link https://developers.google.com/chart/interactive/docs/basic_load_libs#load-settings Parameter documentation } 15 | * {@link https://developers.google.com/chart/interactive/docs/gallery/geochart GeoChart Documentation} 16 | */ 17 | mapsApiKey?: string; 18 | 19 | /** 20 | * Which version of Google Charts to use. 21 | * 22 | * Please note that this library does only work with Google Charts 45 or higher. 23 | * 24 | * @description 25 | * Can be either a number specifying a 26 | * {@link https://developers.google.com/chart/interactive/docs/release_notes#current:-january-6,-2020 frozen version } of Google Charts 27 | * or one of the special versions `current` and `upcoming`. 28 | * 29 | * Defaults to `current`. 30 | * 31 | * {@link https://developers.google.com/chart/interactive/docs/basic_load_libs#basic-library-loading Offical Documentation} 32 | */ 33 | version?: string; 34 | 35 | /** 36 | * When set to true, all charts and tooltips that generate HTML from user-supplied data will sanitize it 37 | * by stripping out unsafe elements and attributes. 38 | * 39 | * Only available when using GoogleCharts 47 or higher. 40 | * 41 | * {@link https://developers.google.com/chart/interactive/docs/basic_load_libs#load-settings Parameter documentation } 42 | */ 43 | safeMode?: boolean; 44 | } 45 | 46 | export const GOOGLE_CHARTS_CONFIG = new InjectionToken>('GOOGLE_CHARTS_CONFIG'); 47 | export const GOOGLE_CHARTS_LAZY_CONFIG = new InjectionToken>( 48 | 'GOOGLE_CHARTS_LAZY_CONFIG', 49 | { 50 | providedIn: 'root', 51 | factory: () => { 52 | const configFromModule = inject(GOOGLE_CHARTS_CONFIG, InjectFlags.Optional); 53 | return of({ ...getDefaultConfig(), ...(configFromModule || {}) }); 54 | } 55 | } 56 | ); 57 | -------------------------------------------------------------------------------- /projects/angular-google-charts/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/lib", 5 | "declarationMap": true, 6 | "module": "es2015", 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "sourceMap": true, 10 | "inlineSources": true, 11 | "emitDecoratorMetadata": true, 12 | "experimentalDecorators": true, 13 | "importHelpers": true, 14 | "types": ["google.visualization"], 15 | "lib": ["dom", "es2015"] 16 | }, 17 | "angularCompilerOptions": { 18 | "skipTemplateCodegen": true, 19 | "strictMetadataEmit": true, 20 | "fullTemplateTypeCheck": true, 21 | "strictInjectionParameters": true, 22 | "flatModuleId": "AUTOGENERATED", 23 | "flatModuleOutFile": "AUTOGENERATED" 24 | }, 25 | "exclude": ["src/setup-tests.ts", "**/*.spec.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /projects/angular-google-charts/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "compilationMode": "partial" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /projects/angular-google-charts/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/spec", 5 | "types": ["jest", "node", "google.visualization"] 6 | }, 7 | "include": ["**/*.spec.ts", "**/*.d.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /projects/angular-google-charts/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [true, "attribute", "camelCase"], 5 | "component-selector": [true, "element", "kebab-case"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /projects/playground/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https:// github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | > 0.5% 9 | last 2 versions 10 | Firefox ESR 11 | not dead 12 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /projects/playground/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | import { MainComponent } from './main/main.component'; 5 | import { TestComponent } from './test/test.component'; 6 | 7 | const routes: Routes = [ 8 | { path: 'app', component: MainComponent }, 9 | { path: 'test', component: TestComponent }, 10 | { path: '', redirectTo: '/app', pathMatch: 'full' } 11 | ]; 12 | 13 | @NgModule({ 14 | imports: [RouterModule.forRoot(routes)], 15 | exports: [RouterModule] 16 | }) 17 | export class AppRoutingModule {} 18 | -------------------------------------------------------------------------------- /projects/playground/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | template: '' 6 | }) 7 | export class AppComponent {} 8 | -------------------------------------------------------------------------------- /projects/playground/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { GoogleChartsModule } from 'angular-google-charts'; 4 | 5 | import { AppRoutingModule } from './app-routing.module'; 6 | import { AppComponent } from './app.component'; 7 | import { MainComponent } from './main/main.component'; 8 | import { TestComponent } from './test/test.component'; 9 | 10 | @NgModule({ 11 | declarations: [AppComponent, TestComponent, MainComponent], 12 | imports: [ 13 | BrowserModule, 14 | AppRoutingModule, 15 | GoogleChartsModule.forRoot({ mapsApiKey: 'AIzaSyD-9tSrke72PouQMnMX-a7eZSW0jkFMBWY' }) 16 | ], 17 | bootstrap: [AppComponent] 18 | }) 19 | export class AppModule {} 20 | -------------------------------------------------------------------------------- /projects/playground/src/app/main/main.component.html: -------------------------------------------------------------------------------- 1 |

Angular Google-Charts Demo

2 |
3 | 16 | 17 |
18 | 19 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /projects/playground/src/app/main/main.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewChild } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | import { 4 | ChartErrorEvent, 5 | ChartMouseLeaveEvent, 6 | ChartMouseOverEvent, 7 | ChartSelectionChangedEvent, 8 | ChartType, 9 | Column, 10 | GoogleChartComponent 11 | } from 'angular-google-charts'; 12 | 13 | @Component({ 14 | selector: 'app-main', 15 | templateUrl: './main.component.html', 16 | styles: [':host > *:not(h1) { display: inline-block !important; }'] 17 | }) 18 | export class MainComponent implements OnInit { 19 | public charts: { 20 | title: string; 21 | type: ChartType; 22 | data: any[][]; 23 | columns?: Column[]; 24 | options?: {}; 25 | }[] = []; 26 | 27 | public changingChart = { 28 | title: 'Changing Chart', 29 | type: ChartType.BarChart, 30 | data: [ 31 | ['Copper', 8.94], 32 | ['Silver', 10.49], 33 | ['Gold', 19.3], 34 | ['Platinum', 21.45] 35 | ], 36 | columns: ['Element', 'Density'], 37 | options: { 38 | animation: { 39 | duration: 250, 40 | easing: 'ease-in-out', 41 | startup: true 42 | } 43 | } 44 | }; 45 | 46 | @ViewChild('chart', { static: true }) 47 | public chart!: GoogleChartComponent; 48 | 49 | constructor(private router: Router) { 50 | this.charts.push({ 51 | title: 'Pie Chart', 52 | type: ChartType.PieChart, 53 | columns: ['Task', 'Hours per Day'], 54 | data: [ 55 | ['Work', 11], 56 | ['Eat', 2], 57 | ['Commute', 2], 58 | ['Watch TV', 2], 59 | ['Sleep', 7] 60 | ] 61 | }); 62 | 63 | this.charts.push({ 64 | title: 'Bar Chart', 65 | type: ChartType.BarChart, 66 | columns: ['Element', 'Density', { role: 'style', type: 'string' }], 67 | data: [ 68 | ['Copper', 8.94, '#b87333'], 69 | ['Silver', 10.49, 'silver'], 70 | ['Gold', 19.3, 'gold'], 71 | ['Platinum', 21.45, 'color: #e5e4e2'] 72 | ] 73 | }); 74 | 75 | this.charts.push({ 76 | title: 'Bar Chart', 77 | type: ChartType.BarChart, 78 | columns: [ 79 | 'City', 80 | '2010 Population', 81 | { role: 'annotation', type: 'string' }, 82 | '2000 Population', 83 | { role: 'annotation', type: 'string' } 84 | ], 85 | data: [ 86 | ['New York City, NY', 8175000, '8.1M', 8008000, '8M'], 87 | ['Los Angeles, CA', 3792000, '3.8M', 3694000, '3.7M'], 88 | ['Chicago, IL', 2695000, '2.7M', 2896000, '2.9M'], 89 | ['Houston, TX', 2099000, '2.1M', 1953000, '2.0M'], 90 | ['Philadelphia, PA', 1526000, '1.5M', 1517000, '1.5M'] 91 | ], 92 | options: { 93 | annotations: { 94 | alwaysOutside: true, 95 | textStyle: { 96 | fontSize: 12, 97 | auraColor: 'none', 98 | color: '#555' 99 | }, 100 | boxStyle: { 101 | stroke: '#ccc', 102 | strokeWidth: 1, 103 | gradient: { 104 | color1: '#f3e5f5', 105 | color2: '#f3e5f5', 106 | x1: '0%', 107 | y1: '0%', 108 | x2: '100%', 109 | y2: '100%' 110 | } 111 | } 112 | }, 113 | hAxis: { 114 | title: 'Total Population', 115 | minValue: 0 116 | }, 117 | vAxis: { 118 | title: 'City' 119 | } 120 | } 121 | }); 122 | 123 | this.charts.push({ 124 | title: 'Styled Line Chart', 125 | type: ChartType.LineChart, 126 | columns: [ 127 | 'Element', 128 | 'Density', 129 | { type: 'number', role: 'interval' }, 130 | { type: 'number', role: 'interval' }, 131 | { type: 'string', role: 'annotation' }, 132 | { type: 'string', role: 'annotationText' }, 133 | { type: 'boolean', role: 'certainty' } 134 | ], 135 | data: [ 136 | ['April', 1000, 900, 1100, 'A', 'Stolen data', true], 137 | ['May', 1170, 1000, 1200, 'B', 'Coffee spill', true], 138 | ['June', 660, 550, 800, 'C', 'Wumpus attack', true], 139 | ['July', 1030, null, null, null, null, false] 140 | ] 141 | }); 142 | 143 | this.charts.push({ 144 | title: 'Material Bar Chart', 145 | type: ChartType.Bar, 146 | columns: ['Year', 'Sales', 'Expenses', 'Profit'], 147 | data: [ 148 | ['2014', 1000, 400, 200], 149 | ['2015', 1170, 460, 250], 150 | ['2016', 660, 1120, 300], 151 | ['2017', 1030, 540, 350] 152 | ], 153 | options: { 154 | chart: { 155 | title: 'Material Bar Chart', 156 | subtitle: 'Sales, Expenses, and Profit: 2014-2017' 157 | }, 158 | bars: 'horizontal' // Required for Material Bar Charts. 159 | } 160 | }); 161 | 162 | this.charts.push({ 163 | title: 'Area Chart', 164 | type: ChartType.AreaChart, 165 | columns: ['Year', 'Sales', 'Expenses'], 166 | data: [ 167 | ['2013', 1000, 400], 168 | ['2014', 1170, 460], 169 | ['2015', 660, 1120], 170 | ['2016', 1030, 540] 171 | ] 172 | }); 173 | 174 | this.charts.push({ 175 | title: 'Bubble Chart', 176 | type: ChartType.BubbleChart, 177 | columns: ['ID', 'X', 'Y'], 178 | data: [ 179 | ['Hallo', 80, 167], 180 | ['', 79, 136], 181 | ['', 78, 184], 182 | ['', 72, 278], 183 | ['', 81, 200], 184 | ['', 72, 170], 185 | ['', 68, 477] 186 | ] 187 | }); 188 | 189 | this.charts.push({ 190 | title: 'Candlestick Chart', 191 | type: ChartType.CandlestickChart, 192 | columns: undefined, 193 | data: [ 194 | ['Mon', 20, 28, 38, 45], 195 | ['Tue', 31, 38, 55, 66], 196 | ['Wed', 50, 55, 77, 80], 197 | ['Thu', 77, 77, 66, 50], 198 | ['Fri', 68, 66, 22, 15] 199 | ] 200 | }); 201 | 202 | this.charts.push({ 203 | title: 'Combo Chart', 204 | type: ChartType.ComboChart, 205 | columns: ['Month', 'Bolivia', 'Ecuador', 'Madagascar', 'Papua New Guinea', 'Rwanda', 'Average'], 206 | data: [ 207 | ['2004/05', 165, 938, 522, 998, 450, 614.6], 208 | ['2005/06', 135, 1120, 599, 1268, 288, 682], 209 | ['2006/07', 157, 1167, 587, 807, 397, 623], 210 | ['2007/08', 139, 1110, 615, 968, 215, 609.4], 211 | ['2008/09', 136, 691, 629, 1026, 366, 569.6] 212 | ], 213 | options: { 214 | vAxis: { title: 'Cups' }, 215 | hAxis: { title: 'Month' }, 216 | seriesType: 'bars', 217 | series: { 5: { type: 'line' } } 218 | } 219 | }); 220 | 221 | this.charts.push({ 222 | title: 'Histogram', 223 | type: ChartType.Histogram, 224 | columns: ['Dinosaur', 'Length'], 225 | data: [ 226 | ['Acrocanthosaurus (top-spined lizard)', 12.2], 227 | ['Albertosaurus (Alberta lizard)', 9.1], 228 | ['Allosaurus (other lizard)', 12.2], 229 | ['Apatosaurus (deceptive lizard)', 22.9], 230 | ['Archaeopteryx (ancient wing)', 0.9], 231 | ['Argentinosaurus (Argentina lizard)', 36.6], 232 | ['Baryonyx (heavy claws)', 9.1], 233 | ['Brachiosaurus (arm lizard)', 30.5], 234 | ['Ceratosaurus (horned lizard)', 6.1], 235 | ['Coelophysis (hollow form)', 2.7], 236 | ['Compsognathus (elegant jaw)', 0.9], 237 | ['Deinonychus (terrible claw)', 2.7], 238 | ['Diplodocus (double beam)', 27.1], 239 | ['Dromicelomimus (emu mimic)', 3.4], 240 | ['Gallimimus (fowl mimic)', 5.5], 241 | ['Mamenchisaurus (Mamenchi lizard)', 21.0], 242 | ['Megalosaurus (big lizard)', 7.9], 243 | ['Microvenator (small hunter)', 1.2], 244 | ['Ornithomimus (bird mimic)', 4.6], 245 | ['Oviraptor (egg robber)', 1.5], 246 | ['Plateosaurus (flat lizard)', 7.9], 247 | ['Sauronithoides (narrow-clawed lizard)', 2.0], 248 | ['Seismosaurus (tremor lizard)', 45.7], 249 | ['Spinosaurus (spiny lizard)', 12.2], 250 | ['Supersaurus (super lizard)', 30.5], 251 | ['Tyrannosaurus (tyrant lizard)', 15.2], 252 | ['Ultrasaurus (ultra lizard)', 30.5], 253 | ['Velociraptor (swift robber)', 1.8] 254 | ] 255 | }); 256 | 257 | this.charts.push({ 258 | title: 'Scatter Chart', 259 | type: ChartType.ScatterChart, 260 | columns: ['Age', 'Weight'], 261 | data: [ 262 | [8, 12], 263 | [4, 5.5], 264 | [11, 14], 265 | [4, 5], 266 | [3, 3.5], 267 | [6.5, 7] 268 | ], 269 | options: { 270 | explorer: { 271 | actions: ['dragToZoom', 'rightClickToReset'], 272 | keepInBounds: true, 273 | maxZoomIn: 4, 274 | zoomDelta: 1 275 | } 276 | } 277 | }); 278 | 279 | this.charts.push({ 280 | title: 'WordTree', 281 | type: ChartType.WordTree, 282 | // type: 'WordTree' as ChartType, 283 | columns: ['Phrases'], 284 | data: [['This is a test'], ['This is not a test'], ['This is an actual test']], 285 | options: { 286 | wordtree: { 287 | format: 'implicit', 288 | type: 'double', 289 | word: 'test' 290 | } 291 | } 292 | }); 293 | } 294 | 295 | public onReady() { 296 | console.log('Chart ready'); 297 | } 298 | 299 | public onError(error: ChartErrorEvent) { 300 | console.error('Error: ' + error.message.toString()); 301 | } 302 | 303 | public onSelect(event: ChartSelectionChangedEvent) { 304 | console.log('Selected: ' + event.toString()); 305 | } 306 | 307 | public onMouseEnter(event: ChartMouseOverEvent) { 308 | console.log('Hovering ' + event.toString()); 309 | } 310 | 311 | public onMouseLeave(event: ChartMouseLeaveEvent) { 312 | console.log('No longer hovering ' + event.toString()); 313 | } 314 | 315 | public ngOnInit() { 316 | console.log(this.chart); 317 | } 318 | 319 | public changeChart() { 320 | this.changingChart.data = [ 321 | ['Copper', Math.random() * 20.0], 322 | ['Silver', Math.random() * 20.0], 323 | ['Gold', Math.random() * 20.0], 324 | ['Platinum', Math.random() * 20.0] 325 | ]; 326 | } 327 | 328 | public navigateToTest() { 329 | this.router.navigateByUrl('/test'); 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /projects/playground/src/app/test/test.component.html: -------------------------------------------------------------------------------- 1 |

Test Component

2 | 3 | 4 |
5 | 6 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 |
26 |
27 | 28 | 37 | 38 | -------------------------------------------------------------------------------- /projects/playground/src/app/test/test.component.ts: -------------------------------------------------------------------------------- 1 | import { Location } from '@angular/common'; 2 | import { Component, ViewChild } from '@angular/core'; 3 | import { 4 | ChartBase, 5 | ChartEditorComponent, 6 | ChartType, 7 | FilterType, 8 | Formatter, 9 | ScriptLoaderService 10 | } from 'angular-google-charts'; 11 | import { Observable } from 'rxjs'; 12 | import { map, share } from 'rxjs/operators'; 13 | 14 | @Component({ 15 | selector: 'app-test', 16 | templateUrl: './test.component.html', 17 | styles: ['.inline > * { display: inline-block; vertical-align: top; }'] 18 | }) 19 | export class TestComponent { 20 | public chart = { 21 | title: 'Test Chart', 22 | type: ChartType.BarChart, 23 | data: [ 24 | ['Copper', 8.94], 25 | ['Silver', 10.49], 26 | ['Gold', 19.3], 27 | ['Platinum', 21.45] 28 | ], 29 | columnNames: ['Element', 'Density'], 30 | options: { 31 | animation: { 32 | duration: 250, 33 | easing: 'ease-in-out', 34 | startup: true 35 | } 36 | } 37 | }; 38 | 39 | public dashboardData = [ 40 | ['Michael', 5], 41 | ['Elisa', 7], 42 | ['Robert', 3], 43 | ['John', 2], 44 | ['Jessica', 6], 45 | ['Aaron', 1], 46 | ['Margareth', 8] 47 | ]; 48 | public filterType = FilterType.NumberRange; 49 | 50 | public chartWrapperSpecs: google.visualization.ChartSpecs = { 51 | chartType: ChartType.AreaChart, 52 | dataTable: [ 53 | ['SMR CV', 'US Cents/KG'], 54 | [new Date(1990, 1, 1), 10], 55 | [new Date(1991, 1, 1), 20], 56 | [new Date(1992, 1, 1), 40], 57 | [new Date(1993, 1, 1), 80], 58 | [new Date(1994, 1, 1), 160], 59 | [new Date(1995, 1, 1), 320], 60 | [new Date(1996, 1, 1), 640], 61 | [new Date(1997, 1, 1), 1280] 62 | ] 63 | }; 64 | 65 | public readonly formatters$: Observable = this.scriptLoaderService.loadChartPackages().pipe( 66 | share(), 67 | map(() => [ 68 | { colIndex: 1, formatter: new google.visualization.NumberFormat({ fractionDigits: 0, prefix: '$', suffix: '‰' }) } 69 | ]) 70 | ); 71 | 72 | @ViewChild(ChartEditorComponent) 73 | public readonly editor!: ChartEditorComponent; 74 | 75 | constructor( 76 | private location: Location, 77 | private scriptLoaderService: ScriptLoaderService 78 | ) {} 79 | 80 | public edit(chart: ChartBase) { 81 | this.editor 82 | .editChart(chart) 83 | .afterClosed() 84 | .subscribe(result => { 85 | if (result) { 86 | console.log(result); 87 | } else { 88 | console.log('Editing was cancelled'); 89 | } 90 | }); 91 | } 92 | 93 | public goBack() { 94 | this.location.back(); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /projects/playground/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FERNman/angular-google-charts/eebda5494c9f0c6088f5fb8975e367d6b0b90ab0/projects/playground/src/assets/.gitkeep -------------------------------------------------------------------------------- /projects/playground/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /projects/playground/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /projects/playground/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FERNman/angular-google-charts/eebda5494c9f0c6088f5fb8975e367d6b0b90ab0/projects/playground/src/favicon.ico -------------------------------------------------------------------------------- /projects/playground/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Playground 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /projects/playground/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic() 12 | .bootstrapModule(AppModule) 13 | .catch(err => console.error(err)); 14 | -------------------------------------------------------------------------------- /projects/playground/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** 22 | * By default, zone.js will patch all possible macroTask and DomEvents 23 | * user can disable parts of macroTask/DomEvents patch by setting following flags 24 | * because those flags need to be set before `zone.js` being loaded, and webpack 25 | * will put import in the top of bundle, so user need to create a separate file 26 | * in this directory (for example: zone-flags.ts), and put the following flags 27 | * into that file, and then add the following code before importing zone.js. 28 | * import './zone-flags.ts'; 29 | * 30 | * The flags allowed in zone-flags.ts are listed here. 31 | * 32 | * The following flags will work for all browsers. 33 | * 34 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 35 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 36 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 37 | * 38 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 39 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 40 | * 41 | * (window as any).__Zone_enable_cross_context_check = true; 42 | * 43 | */ 44 | 45 | /*************************************************************************************************** 46 | * Zone JS is required by default for Angular itself. 47 | */ 48 | import 'zone.js/dist/zone'; // Included with Angular CLI. 49 | 50 | /*************************************************************************************************** 51 | * APPLICATION IMPORTS 52 | */ 53 | -------------------------------------------------------------------------------- /projects/playground/src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | -------------------------------------------------------------------------------- /projects/playground/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/app", 5 | "types": ["google.visualization"] 6 | }, 7 | "files": ["src/main.ts", "src/polyfills.ts"], 8 | "include": ["src/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /projects/playground/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [true, "attribute", "app", "camelCase"], 5 | "component-selector": [true, "element", "app", "kebab-case"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "importHelpers": true, 6 | "module": "es2020", 7 | "outDir": "./dist/out-tsc", 8 | "sourceMap": true, 9 | "declaration": false, 10 | "moduleResolution": "node", 11 | "emitDecoratorMetadata": true, 12 | "experimentalDecorators": true, 13 | "strict": true, 14 | "target": "ES2022", 15 | "typeRoots": ["node_modules/@types"], 16 | "lib": ["es2017", "dom"], 17 | "noUnusedLocals": true, 18 | "paths": { 19 | "angular-google-charts": ["projects/angular-google-charts/src/index.ts"] 20 | }, 21 | "useDefineForClassFields": false 22 | }, 23 | "angularCompilerOptions": { 24 | "skipTemplateCodegen": true, 25 | "fullTemplateTypeCheck": true, 26 | "strictInjectionParameters": true 27 | }, 28 | "exclude": ["dist/**/*"] 29 | } 30 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-angular", "tslint-config-prettier"], 3 | "rulesDirectory": ["node_modules/codelyzer"], 4 | "rules": { 5 | "directive-selector": [true, "attribute", "camelCase"], 6 | "component-selector": [true, "element", "kebab-case"], 7 | "deprecation": { 8 | "severity": "warning" 9 | }, 10 | "ordered-imports": [true, { "grouped-imports": true }], 11 | "member-ordering": [true, { "order": "fields-first" }], 12 | "member-access": true, 13 | "array-type": [true, "array"], 14 | "no-host-metadata-property": false, 15 | "unified-signatures": false, 16 | "no-non-null-assertion": false 17 | } 18 | } 19 | --------------------------------------------------------------------------------