dcmjs-ecg
Version:
DICOM electrocardiography (ECG) rendering for Node.js and browser using dcmjs
466 lines (409 loc) • 12.2 kB
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>