UNPKG

dcmjs-ecg

Version:

DICOM electrocardiography (ECG) rendering for Node.js and browser using dcmjs

466 lines (409 loc) 12.2 kB
<!doctype html> <html lang="en"> <head> <title>dcmjs-ecg rendering example</title> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <script src="https://unpkg.com/vue@3.5.13/dist/vue.global.prod.js"></script> <style> *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html, body { min-height: 100%; width: 100%; } body { min-height: 100vh; background: #0d1117; color: #c9d1d9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; display: flex; flex-direction: column; align-items: center; padding: 32px 16px; } .page-title { font-size: 24px; font-weight: 600; color: #f0f6fc; letter-spacing: -0.3px; margin-bottom: 16px; } .card { width: 100%; max-width: 1200px; background: #161b22; border: 1px solid #30363d; border-radius: 12px; overflow: hidden; } .drop-zone { padding: 30px 20px; text-align: center; border-bottom: 1px solid #30363d; transition: background 0.15s, box-shadow 0.15s; } .drop-zone.dragging { background: rgba(88, 166, 255, 0.05); box-shadow: inset 0 0 0 2px #58a6ff; } .drop-title { font-size: 13px; color: #8b949e; margin-bottom: 14px; } .btn-row { display: flex; gap: 8px; justify-content: center; } .btn { display: inline-flex; align-items: center; padding: 6px 16px; border-radius: 6px; border: 1px solid; font-size: 13px; font-weight: 500; cursor: pointer; transition: background 0.12s, opacity 0.12s; } .btn:disabled { opacity: 0.45; cursor: not-allowed; } .btn-primary { color: #58a6ff; border-color: rgba(56, 139, 253, 0.4); background: transparent; } .btn-primary:not(:disabled):hover { background: rgba(88, 166, 255, 0.1); } .controls-bar { padding: 10px 14px; border-bottom: 1px solid #30363d; display: flex; flex-wrap: wrap; gap: 16px; align-items: center; } .ctrl-label { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #8b949e; user-select: none; white-space: nowrap; } .ctrl-label span { display: inline-block; min-width: 72px; color: #c9d1d9; } .ctrl-label input[type='range'] { width: 110px; accent-color: #58a6ff; cursor: pointer; } .ctrl-label input[type='range']:disabled { opacity: 0.45; cursor: not-allowed; } .ctrl-check { cursor: pointer; gap: 6px; } .ctrl-check input[type='checkbox'] { accent-color: #58a6ff; width: 14px; height: 14px; cursor: pointer; } .status-bar { padding: 8px 14px; border-bottom: 1px solid #30363d; display: flex; align-items: center; min-height: 40px; } .status-text { flex: 1; font-size: 12px; color: #8b949e; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .status-text.error { color: #f85149; } .ecg-wrap { background: #0d1117; padding: 16px; text-align: center; min-height: 260px; display: flex; align-items: center; justify-content: center; overflow: auto; } .ecg-wrap img { max-width: 100%; display: block; border-radius: 4px; } .footer-note { margin-top: 14px; font-size: 12px; color: #484f58; text-align: center; } @media (max-width: 640px) { .btn-row { flex-direction: column; } .btn { justify-content: center; } } </style> </head> <body> <h1 class="page-title">dcmjs-ecg rendering example</h1> <div id="app"> <div class="card" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop.prevent="onDrop" > <div class="drop-zone" :class="{ dragging: isDragging }"> <p class="drop-title">Open or drag and drop a DICOM ECG Part 10 file to render it!</p> <div class="btn-row"> <button class="btn btn-primary" :disabled="isRendering" @click="openFile"> Open DICOM file </button> </div> </div> <div class="controls-bar"> <label class="ctrl-label"> Speed: <span>{{ speed }} mm/s</span> <input type="range" min="10" max="50" step="5" v-model.number="speed" :disabled="isRendering" /> </label> <label class="ctrl-label"> Amplitude: <span>{{ amplitude }} mm/mV</span> <input type="range" min="5" max="20" step="5" v-model.number="amplitude" :disabled="isRendering" /> </label> <label class="ctrl-label ctrl-check"> <input type="checkbox" v-model="applyLowPassFilter" :disabled="isRendering" /> Low-pass filter (40 Hz) </label> </div> <div class="status-bar"> <span class="status-text" :class="{ error: hasError }">{{ statusText }}</span> </div> <div class="ecg-wrap"> <img ref="imageRef" /> </div> </div> <p class="footer-note">Nothing is uploaded anywhere.</p> </div> <script type="text/javascript" src="https://unpkg.com/dcmjs"></script> <script type="text/javascript" src="dcmjs-ecg.min.js"></script> <script> const { createApp, ref, watch, onMounted } = Vue; const { DicomEcg } = window.dcmjsEcg; createApp({ setup() { const imageRef = ref(null); const isDragging = ref(false); const isRendering = ref(false); const hasError = ref(false); const statusText = ref(''); const speed = ref(25); const amplitude = ref(10); const applyLowPassFilter = ref(true); const currentBuffer = ref(null); function clearCurrentImage() { const imageElement = imageRef.value; if (!imageElement) { return; } imageElement.width = 0; imageElement.height = 0; imageElement.src = ''; } function renderArrayBuffer(arrayBuffer) { clearCurrentImage(); currentBuffer.value = arrayBuffer; const t0 = performance.now(); const ecg = new DicomEcg(arrayBuffer); const t1 = performance.now(); console.log('Parsing time: ' + (t1 - t0) + ' ms'); console.log('Transfer syntax UID: ' + ecg.getTransferSyntaxUid()); const renderingResult = ecg.render({ speed: speed.value, amplitude: amplitude.value, applyLowPassFilter: applyLowPassFilter.value, }); const t2 = performance.now(); const imageElement = imageRef.value; const svgDoc = new DOMParser().parseFromString(renderingResult.svg, 'application/xml'); const viewBox = svgDoc.documentElement.viewBox.baseVal; imageElement.width = viewBox.width; imageElement.height = viewBox.height; imageElement.src = 'data:image/svg+xml;base64,' + btoa(renderingResult.svg); const t3 = performance.now(); const info = renderingResult.info .map( (i) => i.key + ': ' + (Array.isArray(i.value) ? i.value.join(', ') : i.value) + (i.unit !== undefined ? ' ' + i.unit : '') ) .join(' | '); statusText.value = info + ' | Render ' + (t2 - t1).toFixed(2) + ' ms | Draw ' + (t3 - t2).toFixed(2) + ' ms'; console.log( 'ECG info:\n' + renderingResult.info .map( (i) => i.key + ': ' + (Array.isArray(i.value) ? i.value.join(', ') : i.value) + (i.unit !== undefined ? ' ' + i.unit : '') ) .join('\n') ); console.log('Rendering time: ' + (t2 - t1) + ' ms'); console.log('Drawing time: ' + (t3 - t2) + ' ms'); } function renderFile(file) { if (!file) { return; } const reader = new FileReader(); isRendering.value = true; hasError.value = false; statusText.value = 'Loading file...'; reader.onload = () => { try { renderArrayBuffer(reader.result); } catch (err) { clearCurrentImage(); hasError.value = true; statusText.value = 'Error: ' + err.message; console.error(err); } finally { isRendering.value = false; } }; reader.onerror = () => { clearCurrentImage(); isRendering.value = false; hasError.value = true; statusText.value = 'Error reading file'; }; reader.readAsArrayBuffer(file); } function onDragOver(event) { isDragging.value = true; if (event.dataTransfer) { event.dataTransfer.dropEffect = 'copy'; } } function onDragLeave() { isDragging.value = false; } function onDrop(event) { isDragging.value = false; const file = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files[0] : undefined; renderFile(file); } function openFile() { const input = document.createElement('input'); input.type = 'file'; input.onchange = (event) => { const file = event.target.files[0]; renderFile(file); }; input.click(); } watch([speed, amplitude, applyLowPassFilter], () => { if (currentBuffer.value) { try { renderArrayBuffer(currentBuffer.value); hasError.value = false; } catch (err) { clearCurrentImage(); hasError.value = true; statusText.value = 'Error: ' + err.message; } } }); onMounted(() => { window.addEventListener('dragover', (event) => { event.preventDefault(); }); window.addEventListener('drop', (event) => { event.preventDefault(); }); }); return { imageRef, isDragging, isRendering, hasError, statusText, speed, amplitude, applyLowPassFilter, onDragOver, onDragLeave, onDrop, openFile, }; }, }).mount('#app'); </script> </body> </html>