├── .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 | 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 | 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 | '
    ' + 145 | '
    ' + 146 | 'TIME: 25mm/s ' + 147 | '' + 148 | '' + 149 | '
    ' + 150 | '
    ' + 151 | 'AMPLITUDE: 10mm/mV ' + 152 | '' + 153 | '' + 154 | '
    ' + 155 | '
    ' + 156 | '' + 157 | '' + 158 | '
    ' + 159 | '
    ' + 160 | '
    ' + 161 | '
    ' + 162 | '' + 166 | '
    ' + 167 | '' + 168 | '' + 169 | '
    ' + 170 | '
    ' + 171 | '' + 172 | '' + 173 | '
    ' + 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} Array of waveform information. 678 | */ 679 | _extractInformation(waveform) { 680 | const waveformAnnotationSequence = this.getElement('WaveformAnnotationSequence'); 681 | if (waveformAnnotationSequence === undefined || !Array.isArray(waveformAnnotationSequence) || waveformAnnotationSequence.length === 0) { 682 | return []; 683 | } 684 | const info = []; 685 | waveformAnnotationSequence.forEach(waveformAnnotationSequenceItem => { 686 | const conceptNameCodeSequence = waveformAnnotationSequenceItem.ConceptNameCodeSequence; 687 | if (conceptNameCodeSequence !== undefined && Array.isArray(conceptNameCodeSequence) && conceptNameCodeSequence.length !== 0) { 688 | conceptNameCodeSequence.forEach(conceptNameCodeSequenceItem => { 689 | //Delete null character CodeMeaning, transform to Uppercase & normalize 690 | let cleanedCodeMeaning = conceptNameCodeSequenceItem.CodeMeaning.replace(/\u0000/g, ''); 691 | cleanedCodeMeaning = this._normalizeCodeMeaning(cleanedCodeMeaning); 692 | //Search constant data: 693 | const keyUnitInfo = KEY_UNIT_INFO.find(i => i.key === cleanedCodeMeaning); 694 | //Load data: 695 | if ( 696 | waveformAnnotationSequenceItem.NumericValue !== undefined && 697 | waveformAnnotationSequenceItem.NumericValue !== '' && 698 | keyUnitInfo !== undefined 699 | ) { 700 | info.push({ 701 | key: cleanedCodeMeaning, 702 | value: waveformAnnotationSequenceItem.NumericValue, 703 | unit: keyUnitInfo.unit 704 | }); 705 | } 706 | }); 707 | } 708 | }); 709 | //RR INTEVAL TO VRATE - BPM: 710 | const rrInterval = info.find(i => i.key === 'RR INTERVAL'); 711 | if (!info.find(i => i.key === 'VRATE') && rrInterval) { 712 | info.push({ 713 | key: 'VRATE', 714 | value: Math.trunc(((60.0 / waveform.duration) * waveform.samples) / rrInterval.value), 715 | unit: 'BPM' 716 | }); 717 | } 718 | 719 | return info; 720 | } 721 | 722 | /** 723 | * Extracts waveform annotation. 724 | * @method 725 | * @private 726 | * @returns {Array} Array of waveform annotation. 727 | */ 728 | _extractAnnotation() { 729 | const waveformAnnotationSequence = this.getElement('WaveformAnnotationSequence'); 730 | if (waveformAnnotationSequence === undefined || !Array.isArray(waveformAnnotationSequence) || waveformAnnotationSequence.length === 0) { 731 | return []; 732 | } 733 | const annotations = []; 734 | waveformAnnotationSequence.forEach(waveformAnnotationSequenceItem => { 735 | if (waveformAnnotationSequenceItem.UnformattedTextValue !== undefined) { 736 | annotations.push(waveformAnnotationSequenceItem.UnformattedTextValue); 737 | } 738 | }); 739 | 740 | return annotations; 741 | } 742 | 743 | // Helper: normalize CodeMeaning synonyms to internal KEY_UNIT_INFO keys 744 | private _normalizeCodeMeaning(raw: string): string { 745 | if (!raw) return ''; 746 | let v = raw.toUpperCase().trim(); 747 | // Unify QTc variations 748 | v = v.replace(/QTC\s+/g, 'QTC '); 749 | // Remove double spaces 750 | v = v.replace(/\s{2,}/g, ' '); 751 | // Map specific phrases 752 | const directMap: Record = { 753 | 'VENTRICULAR HEART RATE': 'VRATE', 754 | 'QTC GLOBAL USING FREDERICIA FORMULA': 'QTC INTERVAL', 755 | 'QTC INTERVAL GLOBAL': 'QTC INTERVAL', 756 | 'QTC INTERVAL GLOBAL': 'QTC INTERVAL', 757 | 'QT INTERVAL GLOBAL': 'QT INTERVAL', 758 | 'RR INTERVAL GLOBAL': 'RR INTERVAL', 759 | 'PR INTERVAL GLOBAL': 'PR INTERVAL', 760 | 'P DURATION GLOBAL': 'P DURATION', 761 | 'QRS DURATION GLOBAL': 'QRS DURATION' 762 | }; 763 | if (directMap[v]) return directMap[v]; 764 | // Generic trimming of trailing GLOBAL words 765 | v = v.replace(/ (INTERVAL )?GLOBAL$/, ''); 766 | return v; 767 | } 768 | 769 | _debugDumpFirstTags(u8: Uint8Array, count: number = 12) { 770 | try { 771 | const tags = []; 772 | for (let i = 0; i < u8.length - 8 && tags.length < count; ) { 773 | const g = u8[i] | (u8[i + 1] << 8); 774 | const e = u8[i + 2] | (u8[i + 3] << 8); 775 | // Basic sanity: groups usually non-zero and element not huge 776 | if (g === 0x0000 && e === 0x0000) { 777 | i += 2; 778 | continue; 779 | } 780 | const tagStr = `(${g.toString(16).padStart(4, '0')},${e.toString(16).padStart(4, '0')})`; 781 | // Advance heuristically: skip 8 bytes (tag + VR/length) — this is rough, just for debugging 782 | tags.push(tagStr); 783 | i += 8; 784 | } 785 | console.debug('DEBUG DICOM TAGS (heuristic)', tags); 786 | } catch (err) { 787 | console.debug('DEBUG TAG DUMP FAILED', err); 788 | } 789 | } 790 | } 791 | 792 | export default DicomEcg; 793 | --------------------------------------------------------------------------------