├── .gitignore
├── example
├── test_ecg.dcm
├── index.css
├── index.umd.js.LICENSE.txt
└── index.html
├── .npmignore
├── .github
├── ISSUE_TEMPLATE
│ ├── custom.md
│ ├── feature_request.md
│ └── bug_report.md
└── workflows
│ └── codeql-analysis.yml
├── .prettierrc
├── src
├── index.ts
├── constants
│ └── Constants.ts
├── viewer
│ ├── Style.css
│ └── DicomECGViewer.ts
├── draw
│ ├── GenericCanvas.ts
│ └── DrawECGCanvas.ts
└── utils
│ └── ReadECG.ts
├── tsconfig.json
├── CONTRIBUTING.md
├── LICENSE
├── .webpack
├── webpack.config.js
└── webpack.dev.js
├── package.json
├── README.md
└── CHANGELOG.md
/.gitignore:
--------------------------------------------------------------------------------
1 | ## Directories
2 | node_modules
3 |
4 | ## Output
5 | dist/
--------------------------------------------------------------------------------
/example/test_ecg.dcm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ArturRod/ecg-dicom-web-viewer/HEAD/example/test_ecg.dcm
--------------------------------------------------------------------------------
/example/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | width: 100%;
4 | height: 100%;
5 | margin: 0px;
6 | padding: 0px;
7 | background: white;
8 | }
9 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | ## All Non-Dist Firectories
2 | src/
3 | example/
4 | .webpack/
5 | .github/
6 |
7 | ## Root Files
8 | .gitignore
9 | tsconfig.json
10 | CONTRIBUTING.md
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/custom.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Custom issue template
3 | about: Describe this issue template's purpose here.
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 |
11 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "trailingComma": "none",
4 | "singleQuote": true,
5 | "printWidth": 150,
6 | "tabWidth": 2,
7 | "useTabs": false,
8 | "bracketSpacing": true,
9 | "arrowParens": "avoid"
10 | }
11 |
--------------------------------------------------------------------------------
/example/index.umd.js.LICENSE.txt:
--------------------------------------------------------------------------------
1 | /*! @license DOMPurify 3.3.0 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.3.0/LICENSE */
2 |
3 | /*! pako 2.1.0 https://github.com/nodeca/pako @license (MIT AND Zlib) */
4 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import ReadECG from "./utils/ReadECG"; //Development
2 | import { SOP_CLASS_UIDS, WAVE_FORM_BITS_STORED, KEY_UNIT_INFO, SPLINE } from "./constants/Constants";
3 | import DicomECGViewer from "./viewer/DicomECGViewer";
4 |
5 | export { ReadECG, DicomECGViewer, SOP_CLASS_UIDS, WAVE_FORM_BITS_STORED, KEY_UNIT_INFO, SPLINE };
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "./dist",
4 | "allowJs": true,
5 | "target": "es5",
6 | "module": "commonjs",
7 | "sourceMap": true,
8 | "jsx": "react",
9 | "allowSyntheticDefaultImports": true
10 | },
11 | "exclude": ["node_modules"],
12 | "include": ["./src/**/*"]
13 | }
14 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Thanks for being willing to contribute!
4 |
5 | **Working on your first Pull Request?**
6 | [https://docs.github.com/es/communities/setting-up-your-project-for-healthy-contributions/setting-guidelines-for-repository-contributors]
7 |
8 | ## Project setup
9 | 1. Fork and clone the repo.
10 | 2. Create a new branch of the project.
11 | 3. Make the appropriate changes in a new branch of the project.
12 | 4. Push your changes.
13 | 5. Request a merge.
14 | 6. It will be verified that the changes made do not affect the structure and work correctly.
15 | 7. If everything is fine, the merge will be applied.
16 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Arturo Rodrigo
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.webpack/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 |
3 | var dist = {
4 | entry: path.resolve(__dirname, "../src/index.ts"),
5 | output: {
6 | path: path.resolve(__dirname, "../dist"),
7 | filename: "index.umd.js",
8 | library: "$",
9 | libraryTarget: "umd",
10 | },
11 | resolve: {
12 | extensions: ["", ".webpack.js", ".web.js", ".ts", ".tsx", ".js"],
13 | },
14 | module: {
15 | rules: [
16 | { test: /\.tsx?$/, loader: "ts-loader" },
17 | { test: /\.js$/, loader: "source-map-loader" },
18 | { test: /\.css$/i, use: ["style-loader", "css-loader"] },
19 | ],
20 | },
21 | mode: "production",
22 | };
23 |
24 | var example = {
25 | entry: path.resolve(__dirname, "../src/index.ts"),
26 | output: {
27 | path: path.resolve(__dirname, "../example"),
28 | filename: "index.umd.js",
29 | library: "$",
30 | libraryTarget: "umd",
31 | },
32 | resolve: {
33 | extensions: ["", ".webpack.js", ".web.js", ".ts", ".tsx", ".js"],
34 | },
35 | module: {
36 | rules: [
37 | { test: /\.tsx?$/, loader: "ts-loader" },
38 | { test: /\.js$/, loader: "source-map-loader" },
39 | { test: /\.css$/i, use: ["style-loader", "css-loader"] },
40 | ],
41 | },
42 | mode: "production",
43 | };
44 |
45 | // Return Array of Configurations
46 | module.exports = [dist, example];
47 |
--------------------------------------------------------------------------------
/.webpack/webpack.dev.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 |
3 | var dist = {
4 | entry: path.resolve(__dirname, "../src/index.ts"),
5 | output: {
6 | path: path.resolve(__dirname, "../dist"),
7 | filename: "index.umd.js",
8 | library: "$",
9 | libraryTarget: "umd",
10 | },
11 | resolve: {
12 | extensions: ["", ".webpack.js", ".web.js", ".ts", ".tsx", ".js"],
13 | },
14 | module: {
15 | rules: [
16 | { test: /\.tsx?$/, loader: "ts-loader" },
17 | { test: /\.js$/, loader: "source-map-loader" },
18 | { test: /\.css$/i, use: ["style-loader", "css-loader"] },
19 | ],
20 | },
21 | mode: "development",
22 | };
23 |
24 | var example = {
25 | entry: path.resolve(__dirname, "../src/index.ts"),
26 | output: {
27 | path: path.resolve(__dirname, "../example"),
28 | filename: "index.umd.js",
29 | library: "$",
30 | libraryTarget: "umd",
31 | },
32 | resolve: {
33 | extensions: ["", ".webpack.js", ".web.js", ".ts", ".tsx", ".js"],
34 | },
35 | module: {
36 | rules: [
37 | { test: /\.tsx?$/, loader: "ts-loader" },
38 | { test: /\.js$/, loader: "source-map-loader" },
39 | { test: /\.css$/i, use: ["style-loader", "css-loader"] },
40 | ],
41 | },
42 | mode: "development",
43 | };
44 |
45 | // Return Array of Configurations
46 | module.exports = [dist, example];
47 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ecg-dicom-web-viewer",
3 | "version": "2.1.4",
4 | "author": "Arturo Rodrigo (https://github.com/ArturRod)",
5 | "license": "MIT",
6 | "description": "Together with the cornerstone library, this project allows reading and drawing ECGs from a dcm in web version.",
7 | "main": "dist/index.umd.js",
8 | "module": "src/index.ts",
9 | "engines": {
10 | "node": ">=10",
11 | "npm": ">=6",
12 | "yarn": ">=1.16.0"
13 | },
14 | "scripts": {
15 | "build": "webpack --config ./.webpack/webpack.config.js",
16 | "dev": "webpack --config ./.webpack/webpack.dev.js"
17 | },
18 | "repository": {
19 | "type": "git",
20 | "url": "git+https://github.com/ArturRod/ecg-dicom-web-viewer.git"
21 | },
22 | "keywords": [
23 | "DICOM",
24 | "ECG",
25 | "MEDICAL",
26 | "DCMJS"
27 | ],
28 | "bugs": {
29 | "url": "https://github.com/ArturRod/ecg-dicom-web-viewer/issues"
30 | },
31 | "homepage": "https://github.com/ArturRod/ecg-dicom-web-viewer#readme",
32 | "dependencies": {
33 | "dcmjs": "^0.37.0",
34 | "dompurify": "^3.3.0"
35 | },
36 | "devDependencies": {
37 | "css-loader": "^7.1.2",
38 | "source-map-loader": "^5.0.0",
39 | "style-loader": "^4.0.0",
40 | "ts-loader": "^9.5.4",
41 | "typescript": "^5.9.3",
42 | "webpack": "^5.102.1",
43 | "webpack-cli": "^6.0.1"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
52 |
53 |
--------------------------------------------------------------------------------
/src/constants/Constants.ts:
--------------------------------------------------------------------------------
1 | //SOP:
2 | const SOP_CLASS_UIDS = [
3 | '1.2.840.10008.5.1.4.1.1.9.1.1', //Sop12LeadECGWaveformStorage
4 | '1.2.840.10008.5.1.4.1.1.9.1.2', //GeneralECGWaveformStorage
5 | '1.2.840.10008.5.1.4.1.1.9.1.3', //AmbulatoryECGWaveformStorage
6 | '1.2.840.10008.5.1.4.1.1.9.2.1', //HemodynamicWaveformStorage
7 | '1.2.840.10008.5.1.4.1.1.9.2.1', //CardiacElectrophysiologyWaveformStorage
8 | ];
9 |
10 | //Bits ECG (Accuracy and quality of the recorded signal):
11 | const WAVE_FORM_BITS_STORED = [
12 | 8, //Low Resolution, 256 different voltage levels.
13 | 12, //Medium resolution, 4096 different voltage levels. (Clinical Applications)
14 | 16, //High resolution, 65536 different voltage levels. (Investigations and Diagnoses)
15 | 24, //Very high resolution, 16777216 different voltage levels. (Advanced Applications)
16 | ];
17 |
18 | //Data to look for in the ECG, capital letters:
19 | const KEY_UNIT_INFO = [
20 | { key: 'HEART RATE', unit: 'BPM' },
21 | { key: 'HR', unit: 'BPM' },
22 | { key: 'P DURATION', unit: 'ms' },
23 | { key: 'QT INTERVAL', unit: 'ms' },
24 | { key: 'QTC INTERVAL', unit: 'ms' },
25 | { key: 'RR INTERVAL', unit: 'ms' },
26 | { key: 'VRATE', unit: 'BPM' },
27 | { key: 'QRS DURATION', unit: 'ms' },
28 | { key: 'QRS AXIS', unit: '°' },
29 | { key: 'T AXIS', unit: '°' },
30 | { key: 'P AXIS', unit: '°' },
31 | { key: 'PR INTERVAL', unit: 'ms' },
32 | { key: 'ANNOTATION', unit: '' }, //Always ?
33 | { key: 'SAMPLING FREQUENCY', unit: 'Hz' }, //Always.
34 | { key: 'DURATION', unit: 'sec' }, //Always.
35 | { key: 'SPEED', unit: 'mm/sec' }, //Always.
36 | { key: 'AMPLITUDE', unit: 'mm/mV' }, //Always.
37 | ];
38 |
39 | //Spline draw ECG:
40 | const SPLINE = {
41 | enable: true, //True o false.
42 | //A tension value of 0.5 is a good middle ground that provides a smooth curve without being too stiff or too loose.
43 | tension: 0.5, //Tension default 0.5
44 | //The number of segments determines how many waypoints are calculated between each pair of control points. A value of 16 is a good balance between curve accuracy and performance.
45 | //Fewer Segments (low value): The curve will be less precise and more angular, but the calculation will be faster.
46 | //More Segments (high value): The curve will be more precise and smooth, but the calculation will be slower.
47 | numOfSegments: 16, //Default 16.
48 | };
49 |
50 | export { SOP_CLASS_UIDS, WAVE_FORM_BITS_STORED, KEY_UNIT_INFO, SPLINE};
51 |
--------------------------------------------------------------------------------
/src/viewer/Style.css:
--------------------------------------------------------------------------------
1 | /*Table Data Study*/
2 | #infoECG {
3 | height: auto;
4 | }
5 | .divTableRow {
6 | display: table-row;
7 | }
8 | .divTableHeading {
9 | background-color: #eee;
10 | display: table-header-group;
11 | }
12 | .divTableCell,
13 | .divTableHead {
14 | display: table-cell;
15 | font-size: 13px;
16 | font-weight: 700;
17 | color: #000000;
18 | }
19 | .divTableCell i {
20 | font-weight: normal;
21 | }
22 | .divTableHeading {
23 | background-color: #eee;
24 | display: table-header-group;
25 | font-weight: bold;
26 | }
27 | .divTableFoot {
28 | background-color: #eee;
29 | display: table-footer-group;
30 | font-weight: bold;
31 | }
32 | #divTableBody {
33 | height: auto;
34 | background: #f9f8f2;
35 | width: 100%;
36 | display: table;
37 | padding: 10px;
38 | }
39 |
40 | /** Buttons **/
41 | #toolsECG {
42 | background: #f9f8f2;
43 | width: 100%;
44 | display: flow-root;
45 | border-top: 2px solid rgb(0, 0, 0);
46 | }
47 | .divTools {
48 | float: right;
49 | font-size: 13px;
50 | font-weight: 700;
51 | margin-left: 1.5rem;
52 | }
53 | .divTools i {
54 | font-weight: normal;
55 | }
56 | .divTools button {
57 | background-color: #dadada;
58 | color: black;
59 | padding: 5px 15px;
60 | margin: 4px 2px;
61 | border-radius: 10%;
62 | }
63 |
64 | /*Zoom buttons*/
65 | #zoomButons {
66 | position: absolute;
67 | width: 30px;
68 | top: 125px;
69 | right: 2px;
70 | }
71 |
72 | #zoomButons button {
73 | background-color: #dadada;
74 | color: black;
75 | padding: 5px;
76 | width: 30px;
77 | height: 30px;
78 | margin: 0px 0px 2px 0px;
79 | border-radius: 10%;
80 | }
81 |
82 | /* New wrapper to position zoom buttons relative to canvas */
83 | .ecgCanvasWrapper {
84 | position: relative;
85 | width: 100%;
86 | }
87 | .ecgCanvasWrapper #zoomButons {
88 | top: 10px !important; /* place at top inside wrapper */
89 | right: 10px;
90 | bottom: auto; /* unset bottom positioning */
91 | }
92 | .ecgCanvas {
93 | display: block;
94 | width: 100%;
95 | }
96 |
97 | /* Custom Context Menu */
98 | .context-menu {
99 | position: absolute;
100 | background-color: white;
101 | border: 1px solid #ccc;
102 | border-radius: 4px;
103 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
104 | padding: 4px 0;
105 | z-index: 1000;
106 | display: none;
107 | min-width: 120px;
108 | }
109 |
110 | .context-menu-item {
111 | padding: 8px 16px;
112 | cursor: pointer;
113 | font-size: 14px;
114 | color: #333;
115 | border: none;
116 | background: none;
117 | width: 100%;
118 | text-align: left;
119 | }
120 |
121 | .context-menu-item:hover {
122 | background-color: #f0f0f0;
123 | }
124 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ "main" ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ "main" ]
20 | schedule:
21 | - cron: '45 23 * * 3'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 | permissions:
28 | actions: read
29 | contents: read
30 | security-events: write
31 |
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: [ 'javascript' ]
36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
38 |
39 | steps:
40 | - name: Checkout repository
41 | uses: actions/checkout@v3
42 |
43 | # Initializes the CodeQL tools for scanning.
44 | - name: Initialize CodeQL
45 | uses: github/codeql-action/init@v2
46 | with:
47 | languages: ${{ matrix.language }}
48 | # If you wish to specify custom queries, you can do so here or in a config file.
49 | # By default, queries listed here will override any specified in a config file.
50 | # Prefix the list here with "+" to use these queries and those in the config file.
51 |
52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
53 | # queries: security-extended,security-and-quality
54 |
55 |
56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
57 | # If this step fails, then you should remove it and run the build manually (see below)
58 | - name: Autobuild
59 | uses: github/codeql-action/autobuild@v2
60 |
61 | # ℹ️ Command-line programs to run using the OS shell.
62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
63 |
64 | # If the Autobuild fails above, remove it and uncomment the following three lines.
65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
66 |
67 | # - run: |
68 | # echo "Run, Build Application using script"
69 | # ./location_of_script_within_repo/buildscript.sh
70 |
71 | - name: Perform CodeQL Analysis
72 | uses: github/codeql-action/analyze@v2
73 | with:
74 | category: "/language:${{matrix.language}}"
75 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ecg-dicom-web-viewer
2 |
3 | This library allows viewing an ECG file in DICOM format in web view.
4 |
5 | - NPM: https://www.npmjs.com/package/ecg-dicom-web-viewer
6 |
7 | ## Installation
8 |
9 | This module is distributed via [npm][npm-url] which is bundled with [node][node] and
10 | should be installed as one of your project's `dependencies`:
11 |
12 | ```js
13 | // To install the newest version
14 | npm install --save ecg-dicom-web-viewer
15 | ```
16 |
17 | ## Example
18 |
19 | 1. Once installed import the project.
20 |
21 | ```js
22 | // Import
23 | import {
24 | ReadECG, //Optional.
25 | SOP_CLASS_UIDS, //Optional.
26 | WAVE_FORM_BITS_STORED, //Optional.
27 | KEY_UNIT_INFO, //Optional.
28 | SPLINE, //Optional.
29 | DicomECGViewer, //Principal.
30 | } from "ecg-dicom-web-viewer";
31 | ```
32 |
33 | 2. Instantiate the new class with the necessary data and create the view.
34 |
35 | ```js
36 | //Load view:
37 | let viewer = new DicomECGViewer(
38 | byteArray, //Data array ECG (XMLHttpRequest response array or...local open data)
39 | divView, //Div where to draw the view
40 | viewportIndex //View number, since you can have several views.
41 | );
42 | viewer.loadCanvas(); // Load canvas view.
43 | ```
44 |
45 | ## Result
46 |
47 |
48 |
49 | ## Documentation
50 |
51 | Currently it works:
52 |
53 |
54 | - Sop12LeadECGWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.1.1', --> YES
55 | - GeneralECGWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.1.2', --> YES
56 | - AmbulatoryECGWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.1.3', --> YES
57 | - HemodynamicWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.2.1', --> YES
58 | - CardiacElectrophysiologyWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.2.1', --> YES
59 |
60 | The next available classes are as follows:
61 | Class DicomECGViewer
62 | - constructor(dataDICOMarrayBuffer, idView, nameView)
63 |
dataDICOMarrayBuffer DICOM DCM ECG Array Buffer.
64 | idView Draw ID View. Recomended a div.
65 | nameView Identifier of the view you want to put, in case you have several views, default 0.
66 | - loadCanvas()
67 |
Main method, draws the canvas and its entire view.
68 | Class ReadECG
69 | - ReadECG(this.dataDICOMarrayBuffer, '', opts)
70 |
Receives a dataSet data structure and returns a readable array.
71 | optsspeed: 25, amplitude: 10, applyLowPassFilter: true
72 | - getWaveform()
73 |
Read the arraydicombuffer and return legible data.
74 | - getInfo()
75 |
Read the arraydicombuffer and return information data, example: BPM, Name, Duration ECG...
76 | Static Constants
77 | SOP_CLASS_UIDS - SOP UID of ECG types and graph measurements.
78 | WAVE_FORM_BITS_STORED - Accuracy and quality of the recorded signal.
79 | KEY_UNIT_INFO - These are the data to be displayed/read from the ECG. Example: QTC INTERVAL, QRS AXIS, P DURATION etc...
80 | SPLINE - Generates interpolation in the ECG view with a spline, enabled by default, may affect performance.
81 | Class GenericCanvas
82 | It is the generic class for the canvas, it contains the values of the number of views, canvas size, rows, columns, grid size...
83 | Class DrawECGCanvas extends GenericCanvas
84 | This class renders the data, both the grid and the view, it also contains the button events.
85 |
86 | ## Features
87 |
88 |
89 | - Improve canvas scrolling performance.
90 |
91 |
--------------------------------------------------------------------------------
/src/draw/GenericCanvas.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Generic class canvas.
3 | */
4 | class GenericCanvas {
5 | public id_canvas: string;
6 | public dataMg: any;
7 | //Canvas ECG:
8 | public canvas: HTMLCanvasElement;
9 | public ctx: CanvasRenderingContext2D;
10 | public positionsDraw: Array;
11 |
12 | //Configuration:
13 | public configuration = {
14 | //GRID:
15 | CELL_WIDTH: 1, // The stroke width of cell lines, expressed in millimeters (≈0.26 px on a 96 DPI display)
16 | CELL_SIZE: 6, // The cell size in pixels (≈1 mm visually)
17 | BLOCK_WIDTH: 2, // The stroke width of block lines, expressed in millimeters (≈0.53 px on a 96 DPI display)
18 | BLOCK_SIZE: 0, //CELL_SIZE * 5, //The block size, each block includes 5*5 cells
19 | //ROWS and COLUMNS canvas separation I, II, III, aVR, aVL, aVF, V1, V2, V3, V4, V5, V6
20 | ROWS: 6,
21 | COLUMNS: 2,
22 | columnsText: [
23 | ["I", "II", "III", "aVR", "aVL", "aVF"], //Colum 1
24 | ["V1", "V2", "V3", "V4", "V5", "V6"], //Colum 2
25 | ],
26 | //LINE ECG:
27 | CURVE_WIDTH: 1.5, //The stroke width of curve
28 | SAMPLING_RATE: 125, //The number of samples per second (1/0.008)
29 | FREQUENCY: 250, //The frequency to update the curve 25mm = 1000ms = 1s
30 | TIME: 0.25, //Default <- 25mm/s -> Each square is 1 mm
31 | AMPLITUDE: 0.10, //Default 10mm/mV Each square is 1 mm
32 | //DESING:
33 | GRID_COLOR: "#F08080",
34 | LINE_COLOR: "#000033",
35 | BACKGROUND_COLOR: "#F9F8F2",
36 | HEIGHT_USER_INFO: 100, //Start grid draw.
37 | };
38 |
39 | /**
40 | * Constructor.
41 | * @param id_canvas ID Canvas view.
42 | * @param dataMg data Dicom.
43 | */
44 | constructor(id_canvas: string, dataMg: any) {
45 | this.dataMg = dataMg;
46 |
47 | //Canvas ECG:
48 | this.canvas = document.getElementById(id_canvas);
49 | this.ctx = this.canvas.getContext("2d");
50 |
51 | //Adjusts line thickness based on screen resolution:
52 | const dpr = window.devicePixelRatio || 1;
53 | const pxPerMm = 3.78 * dpr; //On a normal monitor (96 DPI), 1 mm ≈ 3.78 px
54 |
55 | // Each cell measures 1 mm; the actual 1 mm stroke would be:
56 | this.configuration.CELL_WIDTH /= pxPerMm; // ≈ 0.26 px a DPR = 1
57 | this.configuration.BLOCK_WIDTH /= pxPerMm; // ≈ 0.53 px a DPR = 1
58 |
59 | //Canvas resize:
60 | let restHeight = document.getElementById('divTableBody').clientHeight + document.getElementById('toolsECG').clientHeight;
61 | this.canvas.width = window.innerWidth;
62 | this.canvas.height = window.innerHeight - restHeight; //Rest height information and buttons.
63 | this.canvas.style.width = "100%";
64 | this.canvas.style.height = "85%"; //Aprox infoECG 15%
65 |
66 | //Color canvas:
67 | this.canvas.style.backgroundColor = this.configuration.BACKGROUND_COLOR;
68 |
69 | //Block size:
70 | this.configuration.BLOCK_SIZE = this.configuration.CELL_SIZE * 5;
71 |
72 | //Frequency data dcm:
73 | this.configuration.FREQUENCY = dataMg.samplingFrequency;
74 | }
75 |
76 | /**
77 | * Draw a line from point (x1, y1) to point (x2, y2)
78 | * @param x1 moveTo(x1).
79 | * @param y1 moveTo(y1).
80 | * @param x2 lineTo(x2).
81 | * @param y2 lineTo(y2).
82 | */
83 | public drawLine(x1: number, y1: number, x2: number, y2:number) {
84 | this.ctx.moveTo(x1, y1);
85 | this.ctx.lineTo(x2, y2);
86 | }
87 |
88 | //GET Methods:
89 | /**
90 | * Returns the cell size
91 | * @return the cell size
92 | */
93 | public get cellSize() {
94 | return this.configuration.CELL_SIZE;
95 | }
96 |
97 | /**
98 | * Returns the block size
99 | * @return the block size
100 | */
101 | public get blockSize() {
102 | return 5 * this.configuration.CELL_SIZE;
103 | }
104 |
105 | /**
106 | * Returns the number of cells per period
107 | * @return the number of cells per period
108 | */
109 | public get cellsPerPeriod() {
110 | return Math.floor(this.width / this.cellSize);
111 | }
112 |
113 | /**
114 | * Returns the number of samples per cell
115 | * @return the number of samples per cell
116 | */
117 | public get samplesPerCell() {
118 | return 0.04 * this.samplingRate;
119 | }
120 |
121 | /**
122 | * Returns the number of samples per second
123 | * @return the number of samples per second
124 | */
125 | public get samplingRate() {
126 | return this.configuration.SAMPLING_RATE;
127 | }
128 |
129 | /**
130 | * Returns the number of samples per period
131 | * @return the number of samples per period
132 | */
133 | public get samplesPerPeriod() {
134 | return Math.floor(
135 | 0.04 * this.samplingRate * (this.width / this.cellSize)
136 | );
137 | }
138 |
139 | /**
140 | * Returns the width of this electrocardiogram
141 | * @return the width of this electrocardiogram
142 | */
143 | public get width() {
144 | return this.ctx.canvas.width;
145 | }
146 |
147 | /**
148 | * Returns the height of this electrocardiogram
149 | * @return the height of this electrocardiogram
150 | */
151 | public get height() {
152 | return this.ctx.canvas.height;
153 | }
154 |
155 | /**
156 | * Returns the period (seconds) of this electrocardiogram
157 | * @return the period of this electrocardiogram
158 | */
159 | public get period() {
160 | return (0.04 * this.width) / this.cellSize;
161 | }
162 |
163 | /**
164 | * Change time.
165 | */
166 | public set time(time: number){
167 | this.configuration.TIME = time;
168 | }
169 |
170 | /**
171 | * Change amplitude.
172 | */
173 | public set amplitude(ampli: number){
174 | this.configuration.AMPLITUDE = ampli;
175 | }
176 | }
177 | export default GenericCanvas;
178 |
--------------------------------------------------------------------------------
/src/viewer/DicomECGViewer.ts:
--------------------------------------------------------------------------------
1 | import * as DOMPurify from 'dompurify';
2 | import { KEY_UNIT_INFO, SOP_CLASS_UIDS } from '../constants/Constants';
3 | import DrawECGCanvas from '../draw/DrawECGCanvas';
4 | import ReadECG from '../utils/ReadECG'; //Development
5 | import './Style.css';
6 |
7 | /**
8 | * Principal Class to render ECG viewer.
9 | */
10 | class DicomECGViewer {
11 | private dataDICOMarrayBuffer: ArrayBuffer;
12 | private idView: string;
13 | private nameView: string;
14 | /**
15 | * Create Viewer
16 | * @param {*} dataDICOMarrayBuffer DICOM DCM ECG Array Buffer.
17 | * @param {*} idView Draw ID View.
18 | * @param {*} nameView Identifier of the view you want to put, in case you have several views, default 0.
19 | */
20 | constructor(dataDICOMarrayBuffer: ArrayBuffer, idView: string, nameView: '0') {
21 | this.dataDICOMarrayBuffer = dataDICOMarrayBuffer;
22 | //Sanitize string for posivility attacks XSS:
23 | this.idView = DOMPurify.sanitize(idView);
24 | this.nameView = DOMPurify.sanitize(nameView);
25 | }
26 |
27 | /**
28 | * Development new read and render.
29 | */
30 | public loadCanvas() {
31 | //Optiones default:
32 | let opts = {
33 | speed: 25, //Default
34 | amplitude: 10, //Defualt
35 | applyLowPassFilter: true
36 | };
37 | let readECG = new ReadECG(this.dataDICOMarrayBuffer, '', opts);
38 | //Correct data:
39 | if (readECG != null) {
40 | let waveform = readECG.getWaveform();
41 | let waveinformation = readECG.getInfo();
42 | //Load canvas structure:
43 | if (waveform != null && waveinformation != null) {
44 | //Read wave information disponibility:
45 | //Read information Patient:
46 | let informationPatient = {
47 | //Study Information:
48 | Name: readECG.elements.PatientName || '',
49 | Sex: readECG.elements.Sex || '',
50 | Size: readECG.elements.PatientSize || '',
51 | Id: readECG.elements.PatientID || '',
52 | Age: readECG.elements.PatientAge || '',
53 | Weight: readECG.elements.PatientWeight || '',
54 | Date: readECG.elements.StudyDate || '',
55 | Birth: readECG.elements.PatientBirthDate || ''
56 | };
57 |
58 | //Read information Wave:
59 | let informationWave = {};
60 | waveinformation.forEach(item => {
61 | informationWave[item.key] = `${item.value || ''} ${item.unit}`;
62 | });
63 |
64 | //Load information:
65 | this.loadCanvasDOM(informationPatient, informationWave);
66 |
67 | //Draw ECG:
68 | let ecgCanvas = new DrawECGCanvas(this.idView + this.nameView, waveform);
69 | //SOP CLASS UID COMPATIBLE:
70 | if (SOP_CLASS_UIDS.includes(readECG.elements.SOPClassUID)) {
71 | ecgCanvas.draw();
72 | } else {
73 | ecgCanvas.drawNoCompatible();
74 | }
75 | }
76 | } else {
77 | alert('ECG NO COMPATIBLE');
78 | }
79 | }
80 |
81 | //Load the data according to the view of the elements to be displayed configured in KEY_UNIT_INFO, return string html:
82 | //It will only show the information that contains data, if it is empty it will not be shown KEY: VALUE
83 | private loadKey_Unit(informationWave) {
84 | //First new row:
85 | let html = "";
86 | let count = 0;
87 | for (let i = 0; i < KEY_UNIT_INFO.length; i++) {
88 | const key = KEY_UNIT_INFO[i].key;
89 | const value = informationWave[key];
90 | if (value !== undefined) {
91 | html += `
${key}: ${value}
`;
92 | count += 1;
93 | //If it has 8 elements we create a new divTableRow
94 | if (count == 8) {
95 | html += "
";
96 | count = 0;
97 | }
98 | }
99 | }
100 | //Close divTableRow:
101 | html += '
';
102 | return html;
103 | }
104 |
105 | /**
106 | * Create struct of view.
107 | */
108 | private loadCanvasDOM(informationPatient, informationWave) {
109 | //Load the data KEY_UNIT_INFO:
110 | let htmlUnit = this.loadKey_Unit(informationWave);
111 | let view = '';
112 | document.getElementById(this.idView).innerHTML = view;
113 | view = DOMPurify.sanitize(
114 | '' +
115 | '
' +
116 | '
' +
117 | '
NAME: ' +
118 | informationPatient.Name +
119 | '
' +
120 | '
SEX: ' +
121 | informationPatient.Sex +
122 | '
' +
123 | '
PATIENT SIZE: ' +
124 | informationPatient.Size +
125 | '
' +
126 | '
PATIENT AGE: ' +
127 | informationPatient.Age +
128 | '
' +
129 | '
PATIENT WEIGHT: ' +
130 | informationPatient.Weight +
131 | '
' +
132 | '
DATE: ' +
133 | informationPatient.Date +
134 | '
' +
135 | '
BIRTH: ' +
136 | informationPatient.Birth +
137 | '
' +
138 | '
PATIENT ID: ' +
139 | informationPatient.Id +
140 | '
' +
141 | '
' +
142 | htmlUnit +
143 | '
' +
144 | '
' +
160 | '
' +
161 | '' +
162 | '
' +
166 | '
' +
167 | '' +
168 | '' +
169 | '
' +
170 | '' +
174 | '
'
175 | );
176 |
177 | document.getElementById(this.idView).innerHTML = view;
178 | }
179 | }
180 | export default DicomECGViewer;
181 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | All notable changes to this project will be documented in this file.
4 | See [Conventional Commits](https://conventionalcommits.org) for commit guideline
5 | s.
6 | ## [2.1.4](https://github.com/ArturRod/ecg-dicom-web-viewer) (2025-11-06)
7 |
8 | **Note:** Issues identified:
9 | The lines were not visible at startup because the thicknesses (0.1, 0.2) were less than 1 physical pixel → they were either anti-aliased or disappeared.
10 | When zooming, the browser increased the device Pixel Ratio (DPR) → the subpixel lines then occupied actual pixels and became visible.
11 | The line thicknesses (CELL_WIDTH, BLOCK_WIDTH) were defined in canvas pixels, not in actual millimeters, so there was no consistency across screens.
12 |
13 | Solution: The ECG grid appears complete from the first render, without waiting for zooming.
14 | The actual 1 mm / 5 mm cell/block ratio is maintained.
15 | The thin/thick thicknesses are visibly distinct and consistent.
16 | The grid looks the same on standard displays, Retina displays, and at different zoom levels.
17 |
18 | Other improvements:
19 | Commit 740a9ff (https://github.com/ArturRod/ecg-dicom-web-viewer/commit/740a9ff7aff77589bd6a605aba9260a4f72ed8b0)
20 | Add .prettierrc for consistent formatting
21 | Add reset and center buttons
22 | Add context menu for canvas
23 | Package update
24 | Dcmjs needs updating from ^0.37.0 to ^0.45.0, as it's causing errors with the new version and needs fixing. (Future update)
25 |
26 | ## [2.1.3](https://github.com/ArturRod/ecg-dicom-web-viewer) (2025-10-04)
27 |
28 | **Note:** Improve waveform parsing and silent failures (#19) (https://github.com/ArturRod/ecg-dicom-web-viewer/pull/19 by @MomenAbdelwadoud)
29 | Improve part 10 parsing and waveform sequence handling
30 | Fix overlapping for zoom controls for big headers
31 | Fix unit detection for ECG signal conversion
32 | Updated the handling of units to correctly interpret "mv" as microvolt based on sensitivity values.
33 | Added checks for channel sensitivity to determine if "mv" should be treated as microvolt or millivolt.
34 | Ensured proper conversion from microvolt to millivolt when necessary.
35 |
36 | ## [2.1.2](https://github.com/ArturRod/ecg-dicom-web-viewer) (2025-01-08)
37 |
38 | **Note:** Added in constants SPLINE - Generates interpolation in the ECG view with a spline, enabled by default, may affect performance.
39 | Advantages of Using Splines in an ECG:
40 | Smoothness: Splines can make ECG lines look smoother and more continuous, which can be more pleasing to the eye and easier to interpret.
41 | Interpolation: If the sample data has large intervals, splines can interpolate the points in between, providing a more continuous representation of the signal.
42 | Zoom: When zooming, splines can help maintain the continuity of the lines, preventing individual points from being seen.
43 | Disadvantages of Using Splines in an ECG
44 | Accuracy: In some cases, splines can over-smooth the signal, obscuring important details that could be clinically relevant.
45 | Complexity: Implementing splines adds complexity to the code and can require more computational resources.
46 |
47 | Tension (tension = 0.5)
48 | The tension in a cardinal spline controls the stiffness of the curve. A tension value of 0.5 is a good middle ground that provides a smooth curve without being too stiff or too loose.
49 | Low Tension (close to 0): The curve will be looser and smoother, but may deviate more from the control points.
50 | High Tension (close to 1): The curve will be stiffer and will fit closer to the control points, but may appear more angular.
51 | A value of 0.5 is chosen as a compromise between smoothness and accuracy, providing a curve that is visually pleasing and follows the control points reasonably well.
52 | Number of Segments (numOfSegments = 16)
53 | The number of segments determines how many intermediate points are calculated between each pair of control points. A value of 16 is a good balance between curve accuracy and performance.
54 | Fewer Segments (low value): The curve will be less accurate and more angular, but the calculation will be faster.
55 | More Segments (high value): The curve will be more accurate and smoother, but the calculation will be slower.
56 | A value of 16 provides a smooth and accurate curve without requiring too many computational resources, which is important to maintain good performance, especially in real-time applications such as ECG visualization.
57 |
58 | ## [2.1.1](https://github.com/ArturRod/ecg-dicom-web-viewer) (2025-01-02)
59 |
60 | **Note:** Added in constants KEY_UNIT_INFO - These are the data to be displayed/read from the ECG. Only the data that has values will be shown, otherwise nothing will appear. They must always be in capital letters so that they are detected well. It can be customized according to the needs to be displayed in the ECG view.
61 |
62 | ## [2.1.0](https://github.com/ArturRod/ecg-dicom-web-viewer) (2025-01-02)
63 |
64 | **Note:** Added in constants WAVE_FORM_BITS_STORED - ECG bits (Accuracy and quality of the recorded signal), can be 8 (missing test), 12 (missing test), 16 (OK) and 24 (missing test). This is done to support different ECG qualities. Update packages.
65 |
66 | ## [2.0.9](https://github.com/ArturRod/ecg-dicom-web-viewer) (2024-11-19)
67 |
68 | **Note:** Update packages and correct small error when analyzing millivolts.
69 |
70 | ## [2.0.8](https://github.com/ArturRod/ecg-dicom-web-viewer) (2023-06-30)
71 |
72 | **Note:** Resolve fix: https://github.com/ArturRod/ecg-dicom-web-viewer/issues/9, If the data information arrives in the appropriate format, it is controlled, that is: I, II... without spaces without text Lead... If the dicom format does not arrive correctly for any reason, an alert will appear indicating this. This for the data format I, II, aVR...
73 |
74 | ## [2.0.7](https://github.com/ArturRod/ecg-dicom-web-viewer) (2023-06-21)
75 |
76 | **Note:** New information is displayed, PR Interval, QRS Duration, QT/QTC...
77 |
78 | ## [2.0.6](https://github.com/ArturRod/ecg-dicom-web-viewer) (2023-06-21)
79 |
80 | **Note:** Resolve fix: https://github.com/ArturRod/ecg-dicom-web-viewer/issues/8#issue-1765695154, remove white spaces and special characters: u\0000, \x00..., and update packages.
81 |
82 | ## [2.0.5](https://github.com/ArturRod/ecg-dicom-web-viewer) (2023-06-20)
83 |
84 | **Note:** The DOMPurify library is implemented to prevent XSS attacks on information div.
85 |
86 | ## [2.0.4](https://github.com/ArturRod/ecg-dicom-web-viewer) (2023-06-19)
87 |
88 | **Note:** It corrects errors, a new way of displaying the data is generated based on dcmjs instead of dicom-parse. More data is shown and legibility is more complete.
89 | Part of the project code is implemented: (https://github.com/PantelisGeorgiadis/dcmjs-ecg) whose author is: (PantelisGeorgiadis)
90 |
91 | ## [2.0.3](https://github.com/ArturRod/ecg-dicom-web-viewer) (2023-05-29)
92 |
93 | **Note:** Update packages.
94 |
95 | ## [2.0.2](https://github.com/ArturRod/ecg-dicom-web-viewer) (2022-10-06)
96 |
97 | **Note:** The DOMPurify library is implemented to prevent XSS attacks on data entry idView and nameView.
98 |
99 | ## [2.0.1](https://github.com/ArturRod/ecg-dicom-web-viewer) (2022-08-24)
100 |
101 | **Note:** Repair bug -> https://github.com/ArturRod/ecg-dicom-web-viewer/issues/2
102 |
103 | ## [2.0.0](https://github.com/ArturRod/ecg-dicom-web-viewer) (2022-08-24)
104 |
105 | **Note:** The view and rendering with canvas is implemented. This allows to change the amplitude and the time (mm/mV, mm/s) It is also allowed to pan, and zoom.
106 | Rendering is faster and allows more options for the future. The project is passed to Typescript.
107 |
108 | ## [1.0.6](https://github.com/ArturRod/ecg-dicom-web-viewer) (2022-08-09)
109 |
110 | **Note:** It is no longer necessary to pass the user data, these will be read from the arraybyte file itself.
111 |
112 | ## [1.0.5](https://github.com/ArturRod/ecg-dicom-web-viewer) (2022-08-04)
113 |
114 | **Note:** Add Documentation.
115 |
116 | ## [1.0.4](https://github.com/ArturRod/ecg-dicom-web-viewer) (2022-08-04)
117 |
118 | **Note:** Bug Fix Render View.
119 |
120 | ## [1.0.3](https://github.com/ArturRod/ecg-dicom-web-viewer) (2022-08-03)
121 |
122 | **Note:** Bug Fix Render View.
123 |
124 | ## [1.0.2](https://github.com/ArturRod/ecg-dicom-web-viewer) (2022-08-03)
125 |
126 | **Note:** Bug Fix Render View.
127 |
128 | ## [1.0.1](https://github.com/ArturRod/ecg-dicom-web-viewer) (2022-08-03)
129 |
130 | **Note:** Bug Fix.
131 |
132 | ## [1.0.0](https://github.com/ArturRod/ecg-dicom-web-viewer) (2022-08-03)
133 |
134 | **Note:** Create proyect.
135 |
--------------------------------------------------------------------------------
/src/draw/DrawECGCanvas.ts:
--------------------------------------------------------------------------------
1 | import { SPLINE } from '../constants/Constants';
2 | import GenericCanvas from './GenericCanvas';
3 |
4 | //Global variable for pan and drag:
5 | //The global variable is necessary since we have to remove an event listener that we call from 2 places in the same class.
6 | var pan = {
7 | start: {
8 | x: null,
9 | y: null
10 | },
11 | offset: {
12 | x: 0,
13 | y: 0
14 | },
15 | globaloffset: {
16 | x: 0,
17 | y: 0
18 | }
19 | };
20 |
21 | /**
22 | * Draw grid canvas template.
23 | */
24 | class DrawECGCanvas extends GenericCanvas {
25 | private margin = 20; //Margin to draw elements.
26 | private changeValues = 0.05; //Value of change up down, left o right graph.
27 | private scale = 1;
28 | private scaleFactor = 0.8;
29 |
30 | constructor(id_canvas: string, dataMg: any) {
31 | super(id_canvas, dataMg);
32 | //Reset values pan:
33 | pan.start.x = null;
34 | pan.start.y = null;
35 | pan.offset.x = 0;
36 | pan.offset.y = 0;
37 | pan.globaloffset.x = 0;
38 | pan.globaloffset.y = 0;
39 | //Event buttons:
40 | this.buttonsEvents();
41 | }
42 |
43 | //--------------------------------------------------------
44 | //------------------ DRAW AND EVENTS ---------------------
45 | //--------------------------------------------------------
46 | //#region DRAW AND EVENTS:
47 |
48 | /**
49 | * Draw grid and views.
50 | * We don't put requestAnimationFrame since it only renders when we zoom, redraw or move, that's why I don't put the method to be loading all the time without need.
51 | */
52 | public draw() {
53 | this.ctx.setTransform(1, 0, 0, 1, 0, 0);
54 | //Zoom:
55 | this.ctx.scale(this.scale, this.scale);
56 | this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
57 | //Pan:
58 | this.ctx.translate(pan.offset.x, pan.offset.y);
59 | //Draw:
60 | this.drawGrid();
61 | if (!this.drawECG()) {
62 | alert('AN ERROR HAS OCCURRED, THE DICOM INFORMATION IS NOT CORRECT.');
63 | }
64 | }
65 |
66 | /**
67 | * Load view no compatible:
68 | */
69 | public drawNoCompatible() {
70 | this.ctx.font = '3rem Arial';
71 | this.ctx.fillText('ECG NO COMPATIBLE', this.canvas.width / 2, this.canvas.height / 2);
72 | }
73 |
74 | /**
75 | * Events buttons.
76 | */
77 | private buttonsEvents() {
78 | //AMPLITUDE mm/mV:
79 | let buttonAmUp = document.getElementById('amplitudeUp');
80 | buttonAmUp.addEventListener('click', () => this.changeAmplitude(true));
81 | let buttonAmDown = document.getElementById('amplitudeDown');
82 | buttonAmDown.addEventListener('click', () => this.changeAmplitude(false));
83 |
84 | //TEMPO mm/s
85 | let buttonTempoUp = document.getElementById('timeLeft');
86 | buttonTempoUp.addEventListener('click', () => this.changeTempo(true));
87 | let buttonTempoDown = document.getElementById('timeRight');
88 | buttonTempoDown.addEventListener('click', () => this.changeTempo(false));
89 |
90 | //Zoom:
91 | let buttonZoomMax = document.getElementById('plus');
92 | buttonZoomMax.addEventListener('click', () => this.changeZoom(false));
93 | let buttonZoomMin = document.getElementById('minus');
94 | buttonZoomMin.addEventListener('click', () => this.changeZoom(true));
95 |
96 | //Reset:
97 | let buttonReset = document.getElementById('resetButton');
98 | buttonReset.addEventListener('click', () => this.resetView());
99 |
100 | //Center:
101 | let buttonCenter = document.getElementById('centerButton');
102 | buttonCenter.addEventListener('click', () => this.centerView());
103 |
104 | //Drag/Pan:
105 | this.canvas.addEventListener('mousedown', e => this.startPan(e));
106 | this.canvas.addEventListener('mouseleave', () => this.endPan());
107 | this.canvas.addEventListener('mouseup', () => this.endPan());
108 |
109 | //Context Menu:
110 | this.canvas.addEventListener('contextmenu', e => this.showContextMenu(e));
111 |
112 | //Context Menu Items:
113 | let contextCenter = document.getElementById('contextCenter');
114 | contextCenter.addEventListener('click', () => {
115 | this.centerView();
116 | this.hideContextMenu();
117 | });
118 |
119 | let contextReset = document.getElementById('contextReset');
120 | contextReset.addEventListener('click', () => {
121 | this.resetView();
122 | this.hideContextMenu();
123 | });
124 |
125 | //Hide context menu on click elsewhere:
126 | document.addEventListener('click', () => this.hideContextMenu());
127 | }
128 |
129 | /**
130 | * Start pan.
131 | * @param e event.
132 | */
133 | private startPan(e) {
134 | this.canvas.addEventListener('mousemove', this.trackMouse);
135 | this.canvas.addEventListener('mousemove', () => this.draw());
136 | pan.start.x = e.clientX;
137 | pan.start.y = e.clientY;
138 | }
139 |
140 | /**
141 | * End pan.
142 | */
143 | private endPan() {
144 | this.canvas.removeEventListener('mousemove', this.trackMouse);
145 | pan.start.x = null;
146 | pan.start.y = null;
147 | pan.globaloffset.x = pan.offset.x;
148 | pan.globaloffset.y = pan.offset.y;
149 | }
150 |
151 | /**
152 | * Track mose x & y.
153 | * @param e event.
154 | */
155 | private trackMouse(e) {
156 | var offsetX = e.clientX - pan.start.x;
157 | var offsetY = e.clientY - pan.start.y;
158 | pan.offset.x = pan.globaloffset.x + offsetX;
159 | pan.offset.y = pan.globaloffset.y + offsetY;
160 | }
161 |
162 | /**
163 | * Change zoom, scale canvas, and adjust line stroke.
164 | * @param min minimize or maximize
165 | */
166 | private changeZoom(min: boolean) {
167 | //Zoom:
168 | if (min) {
169 | this.scale *= this.scaleFactor;
170 | if (this.configuration.CURVE_WIDTH < 1.5 && this.scale < 3.8) {
171 | this.configuration.CURVE_WIDTH += 0.2;
172 | }
173 | } else {
174 | this.scale /= this.scaleFactor;
175 | if (this.configuration.CURVE_WIDTH > 0.5) {
176 | this.configuration.CURVE_WIDTH -= 0.2;
177 | }
178 | }
179 | //Max undefinded and min zoom = zoom base:
180 | if (this.scale <= 1) {
181 | this.scale = 1;
182 | } else {
183 | this.draw();
184 | }
185 | }
186 |
187 | /**
188 | * Change amplitude
189 | * @param up up or down.
190 | */
191 | private changeAmplitude(up: boolean) {
192 | let ampli;
193 | if (up) {
194 | ampli = this.configuration.AMPLITUDE + this.changeValues;
195 | } else {
196 | ampli = this.configuration.AMPLITUDE - this.changeValues;
197 | }
198 | ampli = Math.round(ampli * 100) / 100;
199 | //Max 1.0 = 100mm/mV | min 0.05 = 5mm/mV
200 | if (ampli <= 1.0 && ampli >= this.changeValues) {
201 | //Change amplitude:
202 | this.amplitude = ampli;
203 | this.draw();
204 |
205 | //Update text:
206 | let text = document.getElementById('textAmplitude');
207 | text.innerText = ' ' + Math.round(ampli * 100) + 'mm/mV ';
208 | }
209 | }
210 |
211 | /**
212 | * Change tempo.
213 | * @param left left or right
214 | */
215 | private changeTempo(left: boolean) {
216 | let time;
217 | if (left) {
218 | time = this.configuration.TIME - this.changeValues;
219 | } else {
220 | time = this.configuration.TIME + this.changeValues;
221 | }
222 | time = Math.round(time * 100) / 100;
223 | //Max 1.0 = 100mm/ss | min 0.5 = 5mm/ss
224 | if (time <= 1.0 && time >= this.changeValues) {
225 | //Change amplitude:
226 | this.time = time;
227 | this.draw();
228 |
229 | //Update text:
230 | let text = document.getElementById('textTime');
231 | text.innerText = ' ' + Math.round(time * 100) + 'mm/s ';
232 | }
233 | }
234 |
235 | /**
236 | * Reset view to default values (zoom, pan, amplitude, time, etc.)
237 | */
238 | private resetView() {
239 | // Reset scale (zoom) to default value
240 | this.scale = 1;
241 |
242 | // Reset pan/offset to center
243 | pan.start.x = null;
244 | pan.start.y = null;
245 | pan.offset.x = 0;
246 | pan.offset.y = 0;
247 | pan.globaloffset.x = 0;
248 | pan.globaloffset.y = 0;
249 |
250 | // Reset amplitude to default (10mm/mV)
251 | this.amplitude = 0.1;
252 |
253 | // Reset time/speed to default (25mm/s)
254 | this.time = 0.25;
255 |
256 | // Reset curve width to default
257 | this.configuration.CURVE_WIDTH = 1.5;
258 |
259 | // Update UI text displays
260 | let textAmplitude = document.getElementById('textAmplitude');
261 | textAmplitude.innerText = ' ' + Math.round(0.1 * 100) + 'mm/mV ';
262 |
263 | let textTime = document.getElementById('textTime');
264 | textTime.innerText = ' ' + Math.round(0.25 * 100) + 'mm/s ';
265 |
266 | // Redraw the canvas
267 | this.draw();
268 | }
269 |
270 | /**
271 | * Center the view by resetting only pan/offset values
272 | */
273 | private centerView() {
274 | // Reset pan/offset to center the view
275 | pan.start.x = null;
276 | pan.start.y = null;
277 | pan.offset.x = 0;
278 | pan.offset.y = 0;
279 | pan.globaloffset.x = 0;
280 | pan.globaloffset.y = 0;
281 |
282 | // Redraw the canvas
283 | this.draw();
284 | }
285 |
286 | /**
287 | * Show context menu at cursor position
288 | */
289 | private showContextMenu(e) {
290 | e.preventDefault();
291 |
292 | const contextMenu = document.getElementById('contextMenu');
293 | const rect = this.canvas.getBoundingClientRect();
294 |
295 | // Position the context menu at the mouse position
296 | contextMenu.style.left = e.clientX - rect.left + 'px';
297 | contextMenu.style.top = e.clientY - rect.top + 'px';
298 | contextMenu.style.display = 'block';
299 | }
300 |
301 | /**
302 | * Hide context menu
303 | */
304 | private hideContextMenu() {
305 | const contextMenu = document.getElementById('contextMenu');
306 | if (contextMenu) {
307 | contextMenu.style.display = 'none';
308 | }
309 | }
310 |
311 | //#endregion
312 |
313 | //--------------------------------------------------------
314 | //---------------- DRAW GRID -------------------------
315 | //--------------------------------------------------------
316 | //#region DRAW GRID
317 | /**
318 | * Draw the grid
319 | */
320 | private drawGrid() {
321 | let w = this.width - 1;
322 | let h = this.height - 1;
323 | let bs = this.blockSize;
324 | let cs = this.cellSize;
325 | let lw = this.ctx.lineWidth;
326 | let ss = this.ctx.strokeStyle;
327 |
328 | // Clear:
329 | this.ctx.clearRect(0, 0, w, h);
330 |
331 | this.ctx.strokeStyle = this.configuration.GRID_COLOR;
332 |
333 | //Horizontal:
334 | for (var y = h; y >= 0; y -= cs) {
335 | this.ctx.beginPath();
336 | this.ctx.lineWidth = (h - y) % bs ? this.configuration.CELL_WIDTH : this.configuration.BLOCK_WIDTH;
337 | this.drawLine(0, y, w, y);
338 | this.ctx.closePath();
339 | this.ctx.stroke();
340 | }
341 |
342 | //Vertical:
343 | for (var x = 0; x <= w; x += cs) {
344 | this.ctx.beginPath();
345 | this.ctx.lineWidth = x % bs ? this.configuration.CELL_WIDTH : this.configuration.BLOCK_WIDTH;
346 | this.drawLine(x, 0, x, h);
347 | this.ctx.closePath();
348 | this.ctx.stroke();
349 | }
350 |
351 | this.ctx.lineWidth = lw;
352 | this.ctx.strokeStyle = ss;
353 | //Draw indicators:
354 | this.drawECGIndicators();
355 | }
356 |
357 | //Draw I, II, III, aVR, aVL, aVF, V1, V2, V3, V4, V5, V6
358 | private drawECGIndicators() {
359 | let gridWidth = this.canvas.width / this.configuration.COLUMNS;
360 | let gridHeight = this.canvas.height / this.configuration.ROWS;
361 | let marginWidth = 10;
362 | this.ctx.font = 'small-caps 800 25px Times New Roman';
363 | //Inicialize array:
364 | this.positionsDraw = new Array();
365 | //COLUMNS:
366 | for (let e = 0; e < this.configuration.COLUMNS; e++) {
367 | let middleHeight = gridHeight / 2;
368 | //ROWS:
369 | for (let i = 0; i < this.configuration.ROWS; i++) {
370 | this.ctx.fillText(this.configuration.columnsText[e][i], marginWidth, middleHeight - this.margin);
371 | //Save positions for drawECG:
372 | let position = {
373 | name: this.configuration.columnsText[e][i],
374 | width: marginWidth,
375 | height: middleHeight
376 | };
377 | this.positionsDraw.push(position);
378 | middleHeight += gridHeight;
379 | }
380 | marginWidth += gridWidth;
381 | }
382 | }
383 | //#endregion
384 |
385 | //--------------------------------------------------------
386 | //---------------- DRAW ECG -------------------------
387 | //--------------------------------------------------------
388 | //#region DRAW ECG
389 |
390 | /**
391 | * Draw lines.
392 | */
393 | private drawECG() {
394 | //CHANNELS:
395 | for (let ileads = 0; ileads < this.dataMg.leads.length; ileads++) {
396 | //Read position to start draw:
397 | let objPosition: any;
398 | objPosition = this.ReadObjPosition(ileads);
399 | //There is no correct information.
400 | if (objPosition == null) {
401 | return false; //No draw, show error mensaje.
402 | }
403 | //Variables:
404 | let data = [];
405 | let points = [];
406 | let time = 0;
407 | let i = 0;
408 | //Reference to draw:
409 | let startY = objPosition.height;
410 | let startX = objPosition.width + this.margin; //Margin left and right to draw:
411 | let latestPosition = startY;
412 |
413 | //Load data:
414 | this.dataMg.leads[ileads].signal.forEach(element => {
415 | data.push(element);
416 | });
417 |
418 | //Colum calculator margins left and right:
419 | let space = this.canvas.width / 2 - (startX + this.margin);
420 | if (startX != 10 + this.margin) {
421 | space = this.canvas.width / 2 - (this.margin + 10);
422 | }
423 |
424 | //Line options:
425 | this.ctx.lineWidth = this.configuration.CURVE_WIDTH;
426 | this.ctx.lineCap = 'round';
427 |
428 | //Draw line:
429 | while (i < data.length && time < space) {
430 | //10mV/s:
431 | let point = data[i] * objPosition.height * this.configuration.AMPLITUDE; //Rescalate. 10mV/s Each square is 1 mm.
432 | //Use spline true o false:
433 | if (SPLINE.enable) {
434 | //Add points:
435 | points.push(startX + time, startY - point);
436 | } else {
437 | //Draw line:
438 | this.ctx.beginPath();
439 | this.drawLine(startX + time, latestPosition, startX + time, startY - point);
440 | this.ctx.stroke();
441 |
442 | //Positions:
443 | latestPosition = startY - point;
444 | }
445 |
446 | //Duration / time / 100
447 | //25mm/s Each square is 1 mm
448 | time += this.dataMg.duration / this.configuration.TIME / 100;
449 | i++;
450 | }
451 | //Sline true:
452 | if (SPLINE.enable) {
453 | //Draw Sline:
454 | this.DrawSpline(points);
455 | }
456 | }
457 | //Clear data:
458 | this.positionsDraw = null;
459 | return true; //Draw nice.
460 | }
461 |
462 | //Draws smooth, continuous curve used in mathematics and computer graphics to interpolate or smooth data.
463 | //Draw the spline on the canvas.
464 | private DrawSpline(points) {
465 | //Calculate the points of the cardinal spline:
466 | let splinePoints = this.GetCurvePoints(points);
467 | //Draw:
468 | this.ctx.beginPath();
469 | this.ctx.moveTo(splinePoints[0], splinePoints[1]);
470 | for (let i = 2; i < splinePoints.length - 1; i += 2) {
471 | this.ctx.lineTo(splinePoints[i], splinePoints[i + 1]);
472 | }
473 | this.ctx.stroke();
474 | }
475 |
476 | //Calculate the points of the cardinal spline, tension of spline. Number of segments between each pair of points (16 by default).
477 | private GetCurvePoints(points) {
478 | let result = [];
479 |
480 | //If there are less than 4 points (8 coordinates), return the original points.
481 | if (points.length < 8) {
482 | return points;
483 | }
484 |
485 | //Add duplicate checkpoints to the beginning and end:
486 | points = [points[0], points[1], ...points, points[points.length - 2], points[points.length - 1]];
487 |
488 | //Add duplicate control points to the beginning and end of the array to handle the edges of the spline:
489 | for (let i = 2; i < points.length - 4; i += 2) {
490 | //Divides the segment between two points into number segments.
491 | for (let t = 0; t <= SPLINE.numOfSegments; t++) {
492 | //Tensors in the x and y direction:
493 | let t1x = (points[i + 2] - points[i - 2]) * SPLINE.tension;
494 | let t2x = (points[i + 4] - points[i]) * SPLINE.tension;
495 | let t1y = (points[i + 3] - points[i - 1]) * SPLINE.tension;
496 | let t2y = (points[i + 5] - points[i + 1]) * SPLINE.tension;
497 | //Powers of st:
498 | let st = t / SPLINE.numOfSegments;
499 | let pow2 = Math.pow(st, 2);
500 | let pow3 = Math.pow(st, 3);
501 | //Coefficients for cubic interpolation:
502 | let c1 = 2 * pow3 - 3 * pow2 + 1;
503 | let c2 = -(2 * pow3) + 3 * pow2;
504 | let c3 = pow3 - 2 * pow2 + st;
505 | let c4 = pow3 - pow2;
506 |
507 | //Calculate the new coordinates (x, y) for each segment using the coefficients and tensors:
508 | result.push(c1 * points[i] + c2 * points[i + 2] + c3 * t1x + c4 * t2x, c1 * points[i + 1] + c2 * points[i + 3] + c3 * t1y + c4 * t2y);
509 | }
510 | }
511 | return result;
512 | }
513 |
514 | //Read object position to start draw:
515 | private ReadObjPosition(position) {
516 | let code = this.dataMg.channelDefinitionSequence[position].ChannelLabel; //Comes in correct format split === 1
517 | //No exist ChannelLabel code:
518 | if (code == undefined) {
519 | let codeMeaning = this.dataMg.channelDefinitionSequence[position].ChannelSourceSequence[0].CodeMeaning;
520 | if (codeMeaning != undefined) {
521 | if (codeMeaning.split(' ').length === 1) {
522 | //Comes in correct format
523 | code = codeMeaning;
524 | } else if (codeMeaning.split(' ').length === 2 || codeMeaning.split(' ').length === 3) {
525 | //3 text + Bethoven:
526 | code = codeMeaning.split(' ')[1];
527 | }
528 | } else {
529 | return null; //There is no correct information.
530 | }
531 | } else {
532 | if (code.split(' ').length === 2 || code.split(' ').length === 3) {
533 | //3 text + Bethoven:
534 | code = code.split(' ')[1];
535 | } else if (code.split('_').length === 2 || code.split('_').length === 3) {
536 | //3 text + Bethoven or _ separator:
537 | code = code.split('_')[1];
538 | }
539 | }
540 |
541 | //Remove spaces: \u0000, \x00...
542 | code = code.replace(/ /g, '');
543 | code = code.replace('\0', '');
544 | code = code.replace(/\0/g, '');
545 |
546 | //Search position to draw:
547 | let objPosition = this.positionsDraw.find(obj => {
548 | return obj.name.toUpperCase() === code.toUpperCase(); //All mayus compare.
549 | });
550 |
551 | return objPosition;
552 | }
553 |
554 | //#endregion
555 | }
556 |
557 | export default DrawECGCanvas;
558 |
--------------------------------------------------------------------------------
/src/utils/ReadECG.ts:
--------------------------------------------------------------------------------
1 | import { KEY_UNIT_INFO, WAVE_FORM_BITS_STORED } from '../constants/Constants';
2 |
3 | const dcmjs = require('dcmjs');
4 | const { DicomMetaDictionary, DicomMessage, ReadBufferStream, WriteBufferStream } = dcmjs.data;
5 |
6 | const RenderingDefaults = {
7 | DefaultSpeed: 25.0, //25mm/s
8 | DefaultAmplitude: 10.0 //10mm/mV
9 | };
10 |
11 | /**
12 | * ReadECG.
13 | * --> https://dicom.nema.org/medical/dicom/current/output/html/part16.html
14 | * --> https://dicom.nema.org/medical/dicom/2017e/output/chtml/part06/chapter_6.html
15 | * Thanks to the author PantelisGeorgiadis in his project https://github.com/PantelisGeorgiadis/dcmjs-ecg since it is an adaptation of what has been done.
16 | */
17 | class DicomEcg {
18 | public transferSyntaxUid: any;
19 | public elements: any;
20 | public opts: {
21 | speed: any;
22 | amplitude: any;
23 | applyLowPassFilter: any;
24 | };
25 |
26 | /**
27 | * Creates an instance of DicomEcg.
28 | * @constructor
29 | * @param {Object|ArrayBuffer} [elementsOrBuffer] - Dataset elements as object or encoded as a DICOM dataset buffer.
30 | * @param {string} [transferSyntaxUid] - Dataset transfer syntax.
31 | * @param {Object} [opts] - Rendering options.
32 | */
33 | constructor(elementsOrBuffer, transferSyntaxUid, opts) {
34 | //Load options:
35 | this._setOpts(opts);
36 | this.transferSyntaxUid = transferSyntaxUid || '1.2.840.10008.1.2';
37 | if (elementsOrBuffer instanceof ArrayBuffer) {
38 | if (transferSyntaxUid) {
39 | this.elements = this._fromElementsBuffer(elementsOrBuffer, transferSyntaxUid);
40 | } else {
41 | const ret = this._fromP10Buffer(elementsOrBuffer);
42 | this.elements = ret.elements;
43 | this.transferSyntaxUid = ret.transferSyntaxUid;
44 | }
45 | this._postProcessElements();
46 | return;
47 | }
48 |
49 | this.elements = elementsOrBuffer || {};
50 | this._postProcessElements();
51 | }
52 |
53 | /**
54 | * Gets element value.
55 | * @method
56 | * @param {string} tag - Element tag.
57 | * @returns {string|undefined} Element value or undefined if element doesn't exist.
58 | */
59 | getElement(tag) {
60 | // Fallback mapping for key lookups (handle numeric tag references)
61 | if (tag === 'WaveformSequence' && !this.elements.WaveformSequence) {
62 | const alt = this.elements['54000100'] || this.elements['5400,0100'] || this.elements['(5400,0100)'];
63 | if (alt) {
64 | this.elements.WaveformSequence = this._coerceSequence(alt);
65 | }
66 | }
67 | return this.elements[tag];
68 | }
69 |
70 | /**
71 | * Sets element value.
72 | * @method
73 | * @param {string} tag - Element tag.
74 | * @param {string} value - Element value.
75 | */
76 | setElement(tag, value) {
77 | this.elements[tag] = value;
78 | }
79 |
80 | /**
81 | * Gets all elements.
82 | * @method
83 | * @returns {Object} Elements.
84 | */
85 | getElements() {
86 | return this.elements;
87 | }
88 |
89 | /**
90 | * Gets DICOM transfer syntax UID.
91 | * @method
92 | * @returns {string} Transfer syntax UID.
93 | */
94 | getTransferSyntaxUid() {
95 | return this.transferSyntaxUid;
96 | }
97 |
98 | /**
99 | * Sets DICOM transfer syntax UID.
100 | * @method
101 | * @param {string} transferSyntaxUid - Transfer Syntax UID.
102 | */
103 | setTransferSyntaxUid(transferSyntaxUid) {
104 | this.transferSyntaxUid = transferSyntaxUid;
105 | }
106 |
107 | /**
108 | * Gets elements encoded in a DICOM dataset buffer.
109 | * @method
110 | * @returns {ArrayBuffer} DICOM dataset.
111 | */
112 | getDenaturalizedDataset() {
113 | const denaturalizedDataset = DicomMetaDictionary.denaturalizeDataset(this.getElements());
114 | const stream = new WriteBufferStream();
115 | DicomMessage.write(denaturalizedDataset, stream, this.transferSyntaxUid, {});
116 |
117 | return stream.getBuffer();
118 | }
119 |
120 | /**
121 | * Load options.
122 | * @method
123 | * @private
124 | */
125 | _setOpts(opts) {
126 | this.opts = opts || {};
127 | this.opts.speed = opts.speed || RenderingDefaults.DefaultSpeed;
128 | this.opts.amplitude = opts.amplitude || RenderingDefaults.DefaultAmplitude;
129 | this.opts.applyLowPassFilter = opts.applyLowPassFilter || false;
130 |
131 | if (opts.millimeterPerSecond) {
132 | this.opts.speed = opts.millimeterPerSecond;
133 | }
134 | if (opts.millimeterPerMillivolt) {
135 | this.opts.amplitude = opts.millimeterPerMillivolt;
136 | }
137 | }
138 |
139 | /**
140 | * Gets Waveform DICOM dataset buffer.
141 | * @method
142 | * @returns {Waveform} DICOM Waveform.
143 | */
144 | getWaveform() {
145 | // Extract waveform
146 | const waveform = this._extractWaveform();
147 | return waveform;
148 | }
149 |
150 | /**
151 | * Gets Waveform Information DICOM dataset buffer.
152 | * @method
153 | * @returns {WaveformInfo} DICOM Waveform.
154 | */
155 | getInfo() {
156 | const waveform = this.getWaveform();
157 | // Extract waveform info
158 | const info = this._extractInformation(waveform);
159 |
160 | // Extract annotation
161 | const annotation = this._extractAnnotation();
162 | if (annotation.length > 0) {
163 | info.push({ key: 'ANNOTATION', value: annotation });
164 | }
165 |
166 | // Additional info
167 | info.push({
168 | key: 'SAMPLING FREQUENCY',
169 | value: waveform.samplingFrequency,
170 | unit: 'Hz'
171 | });
172 | info.push({
173 | key: 'DURATION',
174 | value: waveform.samples / waveform.samplingFrequency,
175 | unit: 'sec'
176 | });
177 | info.push({ key: 'SPEED', value: this.opts.speed, unit: 'mm/sec' });
178 | info.push({ key: 'AMPLITUDE', value: this.opts.amplitude, unit: 'mm/mV' });
179 | return info;
180 | }
181 |
182 | /**
183 | * Gets the ECG description.
184 | * @method
185 | * @returns {string} DICOM ECG description.
186 | */
187 | toString() {
188 | const str = [];
189 | str.push('DICOM ECG:');
190 | str.push('='.repeat(50));
191 | str.push(JSON.stringify(this.getElements()));
192 |
193 | return str.join('\n');
194 | }
195 |
196 | /**
197 | * Loads a dataset from p10 buffer.
198 | * @method
199 | * @private
200 | * @param {ArrayBuffer} arrayBuffer - DICOM P10 array buffer.
201 | * @returns {Object} Dataset elements and transfer syntax UID.
202 | */
203 | _fromP10Buffer(arrayBuffer) {
204 | // Original attempt with standard Part 10 reader
205 | try {
206 | const dicomDict = DicomMessage.readFile(arrayBuffer, { ignoreErrors: true });
207 | const meta = DicomMetaDictionary.naturalizeDataset(dicomDict.meta || {});
208 | const transferSyntaxUid = meta.TransferSyntaxUID || '1.2.840.10008.1.2';
209 | const elements = DicomMetaDictionary.naturalizeDataset(dicomDict.dict || {});
210 | return { elements, transferSyntaxUid };
211 | } catch (e) {
212 | const errMsg = (e && e.message) || '';
213 | if (!/expected header is missing/i.test(errMsg)) {
214 | throw e; // Different error; rethrow
215 | }
216 | console.warn('No DICM header detected. Falling back to raw dataset parsing.');
217 |
218 | // Helper to try raw decoding with a given buffer & syntax
219 | const tryRaw = (buffer, syntax) => {
220 | try {
221 | const stream = new ReadBufferStream(buffer);
222 | const denat = DicomMessage._read(stream, syntax, {
223 | ignoreErrors: true
224 | });
225 | if (denat && Object.keys(denat).length > 0) {
226 | return DicomMetaDictionary.naturalizeDataset(denat);
227 | }
228 | } catch (err) {
229 | if (err && err.message) {
230 | console.debug('tryRaw fail', syntax, err.message);
231 | }
232 | }
233 | return undefined;
234 | };
235 |
236 | // Candidate syntaxes (add big endian + experimental implicit big endian)
237 | const syntaxCandidates = [
238 | '1.2.840.10008.1.2', // Implicit VR Little Endian
239 | '1.2.840.10008.1.2.1', // Explicit VR Little Endian
240 | '1.2.840.10008.1.2.2' // Explicit VR Big Endian
241 | ];
242 | for (const ts of syntaxCandidates) {
243 | const elems = tryRaw(arrayBuffer, ts);
244 | if (elems) {
245 | return { elements: elems, transferSyntaxUid: ts };
246 | }
247 | }
248 |
249 | // Heuristic: scan for WaveformSequence tag (5400,0100) bytes in little endian: 00 54 00 01
250 | const u8 = new Uint8Array(arrayBuffer);
251 | let waveSeqOffset = -1;
252 | for (let i = 0; i < Math.min(u8.length - 4, 1024 * 128); i++) {
253 | if (u8[i] === 0x00 && u8[i + 1] === 0x54 && u8[i + 2] === 0x00 && u8[i + 3] === 0x01) {
254 | waveSeqOffset = i;
255 | break;
256 | }
257 | // Also check big-endian order (54 00 01 00)
258 | if (u8[i] === 0x54 && u8[i + 1] === 0x00 && u8[i + 2] === 0x01 && u8[i + 3] === 0x00) {
259 | waveSeqOffset = i;
260 | break;
261 | }
262 | }
263 | if (waveSeqOffset > 0) {
264 | // Try slicing starting a bit before presumed tag (align to even 2-byte boundary)
265 | const start = waveSeqOffset > 512 ? waveSeqOffset - 512 : 0;
266 | const sliced = arrayBuffer.slice(start);
267 | for (const ts of syntaxCandidates) {
268 | const elems = tryRaw(sliced, ts);
269 | if (elems && (elems.WaveformSequence || elems['54000100'])) {
270 | console.warn('Recovered dataset via heuristic WaveformSequence scan at offset', waveSeqOffset, 'syntax', ts);
271 | return { elements: elems, transferSyntaxUid: ts };
272 | }
273 | }
274 | }
275 |
276 | // 2. Search for a late 'DICM' magic inside first 1KB (some files embed offset)
277 | for (let offset = 4; offset < Math.min(u8.length - 4, 1024); offset++) {
278 | if (
279 | u8[offset] === 0x44 && // D
280 | u8[offset + 1] === 0x49 && // I
281 | u8[offset + 2] === 0x43 && // C
282 | u8[offset + 3] === 0x4d // M
283 | ) {
284 | const sliced = arrayBuffer.slice(offset - 128 > 0 ? offset - 128 : offset);
285 | for (const ts of syntaxCandidates) {
286 | const elems = tryRaw(sliced, ts);
287 | if (elems) {
288 | console.warn('Recovered dataset after locating delayed DICM magic at offset', offset);
289 | return { elements: elems, transferSyntaxUid: ts };
290 | }
291 | }
292 | break; // stop after first candidate
293 | }
294 | }
295 |
296 | // 3. As a last resort, prepend a synthetic 128-byte preamble + 'DICM' and retry
297 | try {
298 | const synthetic = new Uint8Array(132 + u8.length);
299 | synthetic.fill(0, 0, 128);
300 | synthetic[128] = 0x44; // D
301 | synthetic[129] = 0x49; // I
302 | synthetic[130] = 0x43; // C
303 | synthetic[131] = 0x4d; // M
304 | synthetic.set(u8, 132);
305 | const dicomDict2 = DicomMessage.readFile(synthetic.buffer, {
306 | ignoreErrors: true
307 | });
308 | const meta2 = DicomMetaDictionary.naturalizeDataset(dicomDict2.meta || {});
309 | const ts2 = meta2.TransferSyntaxUID || '1.2.840.10008.1.2';
310 | const elements2 = DicomMetaDictionary.naturalizeDataset(dicomDict2.dict || {});
311 | console.warn('Parsed dataset using synthetic preamble.');
312 | return { elements: elements2, transferSyntaxUid: ts2 };
313 | } catch (e2) {
314 | console.error('Synthetic preamble parsing failed', e2);
315 | }
316 |
317 | // Debug: dump first few potential tags for user insight
318 | this._debugDumpFirstTags(new Uint8Array(arrayBuffer));
319 |
320 | throw e; // Give up, propagate original error
321 | }
322 | }
323 |
324 | /**
325 | * Loads a dataset from elements only buffer.
326 | * @method
327 | * @private
328 | * @param {ArrayBuffer} arrayBuffer - Elements array buffer.
329 | * @param {string} transferSyntaxUid - Transfer Syntax UID.
330 | * @returns {Object} Dataset elements.
331 | */
332 | _fromElementsBuffer(arrayBuffer, transferSyntaxUid) {
333 | const stream = new ReadBufferStream(arrayBuffer);
334 | // Use the proper syntax length (based on transfer syntax UID)
335 | // since dcmjs doesn't do that internally.
336 | let syntaxLengthTypeToDecode = transferSyntaxUid === '1.2.840.10008.1.2' ? '1.2.840.10008.1.2' : '1.2.840.10008.1.2.1';
337 | const denaturalizedDataset = DicomMessage._read(stream, syntaxLengthTypeToDecode, {
338 | ignoreErrors: true
339 | });
340 |
341 | return DicomMetaDictionary.naturalizeDataset(denaturalizedDataset);
342 | }
343 |
344 | /**
345 | * Post processing: key logging, waveform sequence recovery heuristics.
346 | */
347 | _postProcessElements() {
348 | if (!this.elements) return;
349 | try {
350 | console.debug('Parsed top-level keys:', Object.keys(this.elements));
351 | } catch {}
352 |
353 | // If WaveformSequence already present & array, nothing to do
354 | if (Array.isArray(this.elements.WaveformSequence)) return;
355 |
356 | // Direct numeric tag forms
357 | const direct = this.elements['54000100'] || this.elements['5400,0100'] || this.elements['(5400,0100)'] || this.elements['5400 0100'];
358 | if (direct && !this.elements.WaveformSequence) {
359 | this.elements.WaveformSequence = this._coerceSequence(direct);
360 | console.warn('WaveformSequence recovered via direct numeric tag 54000100 (top-level).');
361 | return;
362 | }
363 |
364 | // Deep search for nested numeric tag
365 | const deep = this._findSequenceByNumericTag(this.elements, new Set());
366 | if (deep && !this.elements.WaveformSequence) {
367 | this.elements.WaveformSequence = deep;
368 | console.warn('WaveformSequence recovered via deep recursive search for tag 54000100.');
369 | }
370 | }
371 |
372 | _coerceSequence(raw: any): any[] {
373 | if (!raw) return [];
374 | if (Array.isArray(raw)) return raw;
375 | // dcmjs sometimes encodes as { Value: [ ... ] }
376 | if (raw.Value && Array.isArray(raw.Value)) return raw.Value;
377 | if (raw.Items && Array.isArray(raw.Items)) return raw.Items; // generic
378 | return [raw];
379 | }
380 |
381 | _findSequenceByNumericTag(obj: any, seen: Set): any[] | undefined {
382 | if (!obj || typeof obj !== 'object') return undefined;
383 | if (seen.has(obj)) return undefined;
384 | seen.add(obj);
385 | for (const k of Object.keys(obj)) {
386 | if (k === '54000100' || k === '5400,0100' || k === '(5400,0100)' || k === '5400 0100') {
387 | return this._coerceSequence(obj[k]);
388 | }
389 | const v = obj[k];
390 | if (v && typeof v === 'object') {
391 | const found = this._findSequenceByNumericTag(v, seen);
392 | if (found) return found;
393 | }
394 | }
395 | return undefined;
396 | }
397 |
398 | /**
399 | * Extracts waveform.
400 | * @method
401 | * @private
402 | * @returns {Object} Waveform.
403 | * @throws Error if WaveformSequence is empty and sample interpretation
404 | * or bits allocated values are not supported.
405 | */
406 | _extractWaveform() {
407 | const waveformSequence = this.getElement('WaveformSequence');
408 | if (waveformSequence === undefined || !Array.isArray(waveformSequence) || waveformSequence.length === 0) {
409 | throw new Error('WaveformSequence is empty');
410 | }
411 | // Pick the BEST item: SS interpretation, 16 bits allocated, max samples
412 | const candidates = waveformSequence.filter(item => item && item.WaveformSampleInterpretation === 'SS' && item.WaveformBitsAllocated === 16);
413 | let waveformSequenceItem = candidates.sort((a, b) => (b.NumberOfWaveformSamples || 0) - (a.NumberOfWaveformSamples || 0))[0];
414 | if (!waveformSequenceItem) {
415 | // Fallback to first available
416 | waveformSequenceItem = waveformSequence.find(o => o);
417 | }
418 | if (!waveformSequenceItem) {
419 | throw new Error('No valid waveform sequence item found');
420 | }
421 | if (waveformSequenceItem.WaveformSampleInterpretation !== 'SS') {
422 | throw new Error(`Waveform sample interpretation is not supported [${waveformSequenceItem.WaveformSampleInterpretation}]`);
423 | }
424 | if (waveformSequenceItem.WaveformBitsAllocated !== 16) {
425 | throw new Error(`Waveform bits allocated is not supported [${waveformSequenceItem.WaveformBitsAllocated}]`);
426 | }
427 | const waveform = {
428 | leads: '',
429 | minMax: 0,
430 | channelDefinitionSequence: waveformSequenceItem.ChannelDefinitionSequence,
431 | waveformData: waveformSequenceItem.WaveformData,
432 | channels: waveformSequenceItem.NumberOfWaveformChannels,
433 | samples: waveformSequenceItem.NumberOfWaveformSamples,
434 | samplingFrequency: waveformSequenceItem.SamplingFrequency,
435 | duration: waveformSequenceItem.NumberOfWaveformSamples / waveformSequenceItem.SamplingFrequency
436 | };
437 | this._calculateLeads(waveform);
438 | this._sortLeads(waveform);
439 |
440 | return waveform;
441 | }
442 |
443 | /**
444 | * Calculates waveform leads.
445 | * @method
446 | * @private
447 | * @param {Object} waveform - Waveform.
448 | * @throws Error if waveform bits stored definition value is not supported.
449 | */
450 | _calculateLeads(waveform) {
451 | const channelDefinitionSequence = waveform.channelDefinitionSequence;
452 | if (channelDefinitionSequence === undefined || !Array.isArray(channelDefinitionSequence) || channelDefinitionSequence.length === 0) {
453 | throw new Error('ChannelDefinitionSequence is empty');
454 | }
455 |
456 | if (waveform.channels !== channelDefinitionSequence.length) {
457 | }
458 |
459 | const channels = channelDefinitionSequence.length;
460 | const factor = new Array(channels).fill(1.0);
461 | const baseline = new Array(channels).fill(0.0);
462 |
463 | const units = [];
464 | const sources = [];
465 | channelDefinitionSequence.forEach((channelDefinitionSequenceItem, i) => {
466 | if (channelDefinitionSequenceItem !== undefined) {
467 | //Bits:
468 | if (!WAVE_FORM_BITS_STORED.includes(channelDefinitionSequenceItem.WaveformBitsStored)) {
469 | throw new Error(`Waveform bits stored definition is not supported [${channelDefinitionSequenceItem.WaveformBitsStored}]`);
470 | }
471 |
472 | if (
473 | channelDefinitionSequenceItem.ChannelSensitivity !== undefined &&
474 | channelDefinitionSequenceItem.ChannelSensitivityCorrectionFactor !== undefined
475 | ) {
476 | factor[i] = channelDefinitionSequenceItem.ChannelSensitivity * channelDefinitionSequenceItem.ChannelSensitivityCorrectionFactor;
477 | }
478 | if (channelDefinitionSequenceItem.ChannelBaseline !== undefined) {
479 | baseline[i] = channelDefinitionSequenceItem.ChannelBaseline;
480 | }
481 |
482 | const channelSensitivityUnitsSequence = channelDefinitionSequenceItem.ChannelSensitivityUnitsSequence;
483 | if (
484 | channelSensitivityUnitsSequence !== undefined &&
485 | Array.isArray(channelSensitivityUnitsSequence) &&
486 | channelSensitivityUnitsSequence.length > 0
487 | ) {
488 | const channelSensitivityUnitsSequenceFirstItem = channelSensitivityUnitsSequence[0];
489 | if (channelSensitivityUnitsSequenceFirstItem.CodeValue !== undefined) {
490 | units.push(channelSensitivityUnitsSequenceFirstItem.CodeValue);
491 | }
492 | }
493 |
494 | const channelSourceSequence = channelDefinitionSequenceItem.ChannelSourceSequence;
495 | if (channelSourceSequence !== undefined && Array.isArray(channelSourceSequence) && channelSourceSequence.length !== 0) {
496 | channelSourceSequence.forEach(channelSourceSequenceItem => {
497 | let title = channelSourceSequenceItem.CodeMeaning !== undefined ? channelSourceSequenceItem.CodeMeaning : '';
498 | let codeValue = channelSourceSequenceItem.CodeValue;
499 | const schemeDesignator = channelSourceSequenceItem.CodingSchemeDesignator;
500 | if (!title && codeValue !== undefined && schemeDesignator !== undefined) {
501 | codeValue = codeValue.replace(/[^0-9-:.]/g, '');
502 | const mdcScpEcgCodeTitles = [
503 | { mdcCode: '2:1', scpEcgCode: '5.6.3-9-1', title: 'I' },
504 | { mdcCode: '2:2', scpEcgCode: '5.6.3-9-2', title: 'II' },
505 | { mdcCode: '2:61', scpEcgCode: '5.6.3-9-61', title: 'III' },
506 | { mdcCode: '2:62', scpEcgCode: '5.6.3-9-62', title: 'aVR' },
507 | { mdcCode: '2:63', scpEcgCode: '5.6.3-9-63', title: 'aVL' },
508 | { mdcCode: '2:64', scpEcgCode: '5.6.3-9-64', title: 'aVF' },
509 | { mdcCode: '2:3', scpEcgCode: '5.6.3-9-3', title: 'V1' },
510 | { mdcCode: '2:4', scpEcgCode: '5.6.3-9-4', title: 'V2' },
511 | { mdcCode: '2:5', scpEcgCode: '5.6.3-9-5', title: 'V3' },
512 | { mdcCode: '2:6', scpEcgCode: '5.6.3-9-6', title: 'V4' },
513 | { mdcCode: '2:7', scpEcgCode: '5.6.3-9-7', title: 'V5' },
514 | { mdcCode: '2:8', scpEcgCode: '5.6.3-9-8', title: 'V6' }
515 | ];
516 | if (schemeDesignator === 'MDC') {
517 | const mdcCodeTitle = mdcScpEcgCodeTitles.find(i => i.mdcCode === codeValue);
518 | if (mdcCodeTitle !== undefined) {
519 | title = mdcCodeTitle.title;
520 | }
521 | } else if (schemeDesignator === 'SCPECG') {
522 | const scpEcgCodeTitle = mdcScpEcgCodeTitles.find(i => i.scpEcgCode === codeValue);
523 | if (scpEcgCodeTitle !== undefined) {
524 | title = scpEcgCodeTitle.title;
525 | }
526 | }
527 | }
528 |
529 | title = title ? title.replace(/[^a-zA-Z0-9_ ]/g, '') : '';
530 | sources.push(
531 | title
532 | .replace(/lead/i, '')
533 | .replace(/\(?einthoven\)?/i, '')
534 | .trim()
535 | );
536 | });
537 | }
538 | }
539 | });
540 |
541 | waveform.leads = [];
542 | // Robust extraction of raw waveform data (may be Array, ArrayBuffer, or typed array)
543 | let rawWave = waveform.waveformData;
544 | if (Array.isArray(rawWave)) rawWave = rawWave.find(o => o);
545 | let waveformDataBuffer: Uint8Array;
546 | if (rawWave instanceof Uint8Array) {
547 | waveformDataBuffer = rawWave;
548 | } else if (rawWave instanceof ArrayBuffer) {
549 | waveformDataBuffer = new Uint8Array(rawWave);
550 | } else {
551 | // Attempt to handle object with buffer property
552 | if (rawWave && rawWave.buffer) {
553 | try {
554 | waveformDataBuffer = new Uint8Array(rawWave.buffer);
555 | } catch {
556 | throw new Error('Unsupported WaveformData format');
557 | }
558 | } else {
559 | throw new Error('Unsupported WaveformData format');
560 | }
561 | }
562 | const waveformData = new Int16Array(
563 | new Uint16Array(waveformDataBuffer.buffer, waveformDataBuffer.byteOffset, waveformDataBuffer.byteLength / Uint16Array.BYTES_PER_ELEMENT)
564 | );
565 |
566 | // Split to channels
567 | let signals = waveformData.reduce(
568 | (rows, key, index) => (index % channels === 0 ? rows.push([key]) : rows[rows.length - 1].push(key)) && rows,
569 | []
570 | );
571 |
572 | // Transpose
573 | signals = signals[0].map((x, i) => signals.map(x => x[i]));
574 |
575 | // Apply baseline and factor
576 | for (let i = 0; i < channels; i++) {
577 | for (let j = 0; j < signals[i].length; j++) {
578 | signals[i][j] = (signals[i][j] + baseline[i]) * factor[i];
579 | }
580 | }
581 |
582 | // Filter 40hz:
583 | if (this.opts.applyLowPassFilter === true) {
584 | const cutoffFrequency = 40.0;
585 | for (let i = 0; i < channels; i++) {
586 | this._lowPassFilter(signals[i], cutoffFrequency, waveform.samplingFrequency);
587 | }
588 | }
589 |
590 | // Convert to millivolts (case-insensitive)
591 | if (units.length === channels) {
592 | for (let i = 0; i < channels; i++) {
593 | const unitKey = (units[i] || '').toString();
594 | const unitLower = unitKey.toLowerCase();
595 | let divisor = 1.0;
596 |
597 | // Fix the unit detection: "mv" in DICOM often means microvolt, not millivolt
598 | // Based on the sensitivity values (2.513826), these are microvolts that need conversion
599 | if (unitLower === 'uv' || unitLower === 'μv') {
600 | divisor = 1000.0; // microvolt to millivolt
601 | } else if (unitLower === 'mv') {
602 | // Check if this is actually microvolt based on sensitivity magnitude
603 | // Typical ECG sensitivities in microvolts are in the range of 1-10
604 | const sensitivity = channelDefinitionSequence[i]?.ChannelSensitivity || 1;
605 | if (sensitivity > 1 && sensitivity < 10) {
606 | divisor = 1000.0; // this is likely microvolt, convert to millivolt
607 | } else {
608 | divisor = 1.0; // already millivolt
609 | }
610 | } else if (unitLower === 'mmhg') {
611 | divisor = 200.0; // heuristic from original code
612 | }
613 |
614 | for (let j = 0; j < signals[i].length; j++) {
615 | signals[i][j] = signals[i][j] / divisor;
616 | }
617 | }
618 | }
619 |
620 | // Find min/max and assign signal and source
621 | for (let i = 0; i < channels; i++) {
622 | waveform.leads.push({
623 | min: Math.min(...signals[i]),
624 | max: Math.max(...signals[i]),
625 | signal: signals[i],
626 | source: sources[i]
627 | });
628 | }
629 | waveform.min = Math.min(...waveform.leads.map(lead => lead.min));
630 | waveform.max = Math.max(...waveform.leads.map(lead => lead.max));
631 | waveform.minMax = Math.max(
632 | Math.abs(Math.min(...waveform.leads.map(lead => lead.min))),
633 | Math.abs(Math.max(...waveform.leads.map(lead => lead.max)))
634 | );
635 | }
636 |
637 | /**
638 | * Sorts waveform leads based on source.
639 | * @method
640 | * @private
641 | * @param {Object} waveform - Waveform.
642 | */
643 | _sortLeads(waveform) {
644 | const order = ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6'];
645 | waveform.leads.sort((a, b) => {
646 | const index1 = order.indexOf(a.source);
647 | const index2 = order.indexOf(b.source);
648 | return (index1 > -1 ? index1 : Infinity) - (index2 > -1 ? index2 : Infinity);
649 | });
650 | }
651 |
652 | /**
653 | * Applies a low-pass filter to sample data.
654 | * @method
655 | * @private
656 | * @param {Array} samples - The sample data to filter.
657 | * @param {number} cutoff - Cut off frequency.
658 | * @param {number} sampleRate - Samples rate.
659 | */
660 | _lowPassFilter(samples, cutoff, sampleRate) {
661 | const rc = 1.0 / (cutoff * 2.0 * Math.PI);
662 | const dt = 1.0 / sampleRate;
663 | const alpha = dt / (rc + dt);
664 | let lastValue = samples[0];
665 |
666 | for (let i = 0; i < samples.length; i++) {
667 | lastValue = lastValue + alpha * (samples[i] - lastValue);
668 | samples[i] = lastValue;
669 | }
670 | }
671 |
672 | /**
673 | * Extracts waveform information.
674 | * @method
675 | * @param {Object} waveform - Waveform.
676 | * @private
677 | * @returns {Array