Last active
April 4, 2026 22:58
-
-
Save schuhwerk/619bc69ef6309c00b4d7273eadbc3ee1 to your computer and use it in GitHub Desktop.
Single page HTML DICOM-viewer.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>DICOM Viewer</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { background: #1a1a2e; color: #e0e0e0; font-family: 'Segoe UI', system-ui, sans-serif; height: 100vh; overflow: hidden; } | |
| #dropzone { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; gap: 20px; } | |
| #dropzone h1 { font-size: 28px; color: #ccc; font-weight: 300; } | |
| #dropzone p { color: #888; font-size: 14px; } | |
| #dropzone button { padding: 14px 36px; font-size: 18px; border: none; border-radius: 8px; background: #4a6fa5; color: #fff; cursor: pointer; } | |
| #dropzone button:hover { background: #5a8fd5; } | |
| #loading { display: none; color: #aaa; font-size: 16px; } | |
| body.dragover #dropzone { outline: 3px dashed #4a6fa5; outline-offset: -20px; } | |
| #viewer { display: none; height: 100vh; flex-direction: column; } | |
| #toolbar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: #16213e; border-bottom: 1px solid #333; flex-shrink: 0; flex-wrap: wrap; } | |
| #toolbar button { padding: 5px 12px; background: #2a3a5e; color: #ccc; border: 1px solid #444; border-radius: 4px; cursor: pointer; font-size: 13px; } | |
| #toolbar button.active { background: #4a6fa5; color: #fff; border-color: #5a8fd5; } | |
| #toolbar button:hover { background: #3a5a8e; } | |
| #toolbar select { padding: 5px 8px; background: #1a1a2e; color: #e0e0e0; border: 1px solid #444; border-radius: 4px; font-size: 13px; } | |
| #toolbar .sep { width: 1px; height: 24px; background: #444; } | |
| #toolbar .info { font-size: 13px; color: #aaa; margin-left: auto; } | |
| #canvas-area { flex: 1; position: relative; overflow: hidden; background: #000; } | |
| #layerGroup0, #layerGroup1 { width: 100%; height: 100%; position: absolute; top: 0; left: 0; } | |
| #layerGroup0 { z-index: 2; } #layerGroup1 { z-index: 1; } | |
| #overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; z-index: 10; } | |
| .ov { position: absolute; font: 12px/1.4 monospace; color: rgba(255,255,100,0.85); padding: 8px; } | |
| .ov-tl { top: 0; left: 0; } .ov-tr { top: 0; right: 0; text-align: right; } .ov-bl { bottom: 0; left: 0; } | |
| #help-text { position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%); font-size: 11px; color: #555; pointer-events: none; z-index: 10; white-space: nowrap; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="dropzone"> | |
| <h1>DICOM Viewer</h1> | |
| <button id="openBtn" onclick="pickFolder()">Open DICOM Folder</button> | |
| <p>Or drag & drop a DICOM folder here</p> | |
| <div id="loading">Loading...</div> | |
| </div> | |
| <div id="viewer"> | |
| <div id="toolbar"> | |
| <button id="toolScroll" onclick="setTool('Scroll')">Scroll</button> | |
| <button id="toolWL" onclick="setTool('WindowLevel')">W/L</button> | |
| <button id="toolZoom" onclick="setTool('ZoomAndPan')">Zoom</button> | |
| <span class="sep"></span> | |
| <button onclick="resetView()">Reset</button> | |
| <span class="sep"></span> | |
| <button onclick="prevSeries()"><</button> | |
| <select id="seriesSelect" onchange="switchSeries(this.value)"></select> | |
| <button onclick="nextSeries()">></button> | |
| <span class="sep"></span> | |
| <span id="sliceInfo" style="font-size:13px;color:#ccc;"></span> | |
| <span class="info" id="patientInfo"></span> | |
| </div> | |
| <div id="canvas-area"> | |
| <div id="layerGroup0"></div> | |
| <div id="layerGroup1"></div> | |
| <div id="overlay"> | |
| <div class="ov ov-tl" id="ov-tl"></div> | |
| <div class="ov ov-tr" id="ov-tr"></div> | |
| <div class="ov ov-bl" id="ov-bl"></div> | |
| </div> | |
| <div id="help-text">Up/Down: slices | Left/Right: series | 1: scroll | 2: W/L | 3: zoom | R: reset</div> | |
| </div> | |
| </div> | |
| <script src="https://github.com/ivmartel/dwv/releases/download/v0.30.4/dwv-0.30.4.min.js"></script> | |
| <script> | |
| 'use strict'; | |
| // --- Decoder workers (inlined as blobs for cross-origin compat) --- | |
| const DB = 'https://cdn.jsdelivr.net/npm/dwv@0.30.4/decoders'; | |
| (async function() { | |
| const defs = { | |
| 'jpeg2000': [DB+'/pdfjs/jpx.js', DB+'/pdfjs/util.js', DB+'/pdfjs/arithmetic_decoder.js', DB+'/pdfjs/decode-jpeg2000.js'], | |
| 'jpeg-baseline': [DB+'/pdfjs/jpg.js', DB+'/pdfjs/decode-jpegbaseline.js'], | |
| 'jpeg-lossless': [DB+'/rii-mango/lossless-min.js', DB+'/rii-mango/decode-jpegloss.js'], | |
| 'rle': [DB+'/dwv/rle.js', DB+'/dwv/decode-rle.js'] | |
| }; | |
| const scripts = {}; | |
| for (const [key, urls] of Object.entries(defs)) { | |
| try { | |
| const parts = []; | |
| for (const url of urls) { | |
| let code = await (await fetch(url)).text(); | |
| parts.push(code.replace(/importScripts\([^)]*\);?/g, '')); | |
| } | |
| scripts[key] = URL.createObjectURL(new Blob(parts, { type: 'application/javascript' })); | |
| } catch(e) {} | |
| } | |
| dwv.image.decoderScripts = scripts; | |
| })(); | |
| // --- State --- | |
| let app = null, allSeries = [], currentIdx = 0; | |
| let manualMode = false, manualSlice = 0, activeLayer = 0, manualLoading = false; | |
| // --- DICOM tag parser --- | |
| function parseDicomTag(bytes, group, elem) { | |
| const g0 = group & 0xFF, g1 = (group >> 8) & 0xFF; | |
| const e0 = elem & 0xFF, e1 = (elem >> 8) & 0xFF; | |
| for (let p = 132; p < bytes.length - 8; p++) { | |
| if (bytes[p] !== g0 || bytes[p+1] !== g1 || bytes[p+2] !== e0 || bytes[p+3] !== e1) continue; | |
| const vr = String.fromCharCode(bytes[p+4], bytes[p+5]); | |
| let valOff, valLen; | |
| if (/^[A-Z]{2}$/.test(vr)) { | |
| if ('OB|OW|OF|SQ|UC|UN|UR|UT'.includes(vr)) { | |
| valLen = bytes[p+8] | (bytes[p+9]<<8) | (bytes[p+10]<<16) | (bytes[p+11]<<24); valOff = p + 12; | |
| } else { | |
| valLen = bytes[p+6] | (bytes[p+7]<<8); valOff = p + 8; | |
| } | |
| } else { | |
| valLen = bytes[p+4] | (bytes[p+5]<<8) | (bytes[p+6]<<16) | (bytes[p+7]<<24); valOff = p + 8; | |
| } | |
| if (valLen > 0 && valLen < 200 && valOff + valLen <= bytes.length) | |
| return new TextDecoder().decode(bytes.slice(valOff, valOff + valLen)).replace(/\0/g, '').trim(); | |
| } | |
| return null; | |
| } | |
| // Helper: consume file arrayBuffer and return new File (since arrayBuffer can only be read once) | |
| function cloneFile(ab, f) { | |
| const nf = new File([ab], f.name, { type: f.type }); | |
| nf._parentDir = f._parentDir; | |
| Object.defineProperty(nf, 'webkitRelativePath', { value: f.webkitRelativePath || '' }); | |
| return nf; | |
| } | |
| // --- File input --- | |
| function pickFolder() { | |
| const input = document.createElement('input'); | |
| input.type = 'file'; input.webkitdirectory = true; input.multiple = true; | |
| input.onchange = (e) => handleFiles(Array.from(e.target.files)); | |
| input.click(); | |
| } | |
| // --- Drag and drop --- | |
| document.addEventListener('dragenter', (e) => { e.preventDefault(); document.body.classList.add('dragover'); }); | |
| document.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }); | |
| document.addEventListener('dragleave', (e) => { if (!e.relatedTarget) document.body.classList.remove('dragover'); }); | |
| document.addEventListener('drop', async (e) => { | |
| e.preventDefault(); document.body.classList.remove('dragover'); | |
| const files = await collectDroppedFiles(e.dataTransfer); | |
| if (files.length) handleFiles(files); | |
| }); | |
| async function collectDroppedFiles(dt) { | |
| if (dt.items && dt.items[0] && dt.items[0].webkitGetAsEntry) { | |
| const entries = []; | |
| for (let i = 0; i < dt.items.length; i++) { | |
| const e = dt.items[i].webkitGetAsEntry(); | |
| if (e) entries.push(e); | |
| } | |
| return await readEntries(entries); | |
| } | |
| return Array.from(dt.files); | |
| } | |
| async function readEntries(entries) { | |
| const files = []; | |
| async function traverse(entry, parentDir) { | |
| if (entry.isFile) { | |
| const file = await new Promise((r, e) => entry.file(r, e)); | |
| file._parentDir = parentDir; | |
| files.push(file); | |
| } else if (entry.isDirectory) { | |
| const reader = entry.createReader(); | |
| let batch; | |
| do { | |
| batch = await new Promise((r, e) => reader.readEntries(r, e)); | |
| for (const c of batch) await traverse(c, entry.name); | |
| } while (batch.length); | |
| } | |
| } | |
| for (const e of entries) await traverse(e, e.name); | |
| return files; | |
| } | |
| // --- Group files into series --- | |
| async function handleFiles(files) { | |
| const dcmFiles = files.filter(f => !/\.(jpg|png|html|json|xml|txt|js|css)$/i.test(f.name)); | |
| if (!dcmFiles.length) { alert('No DICOM files found.'); return; } | |
| document.getElementById('loading').style.display = 'block'; | |
| document.getElementById('loading').textContent = 'Scanning files...'; | |
| document.getElementById('openBtn').style.display = 'none'; | |
| // Group by parent directory | |
| const dirGroups = {}; | |
| for (const f of dcmFiles) { | |
| let dir = f._parentDir || 'default'; | |
| if (f.webkitRelativePath) { | |
| const parts = f.webkitRelativePath.split('/'); | |
| dir = parts.length >= 2 ? parts[parts.length - 2] : 'default'; | |
| } | |
| (dirGroups[dir] = dirGroups[dir] || []).push(f); | |
| } | |
| // Single directory: sub-group by SeriesUID, skip non-image modalities | |
| const groups = {}; | |
| const nonImageMods = ['SR','PR','KO','SEG','DOC','REG']; | |
| for (const [dir, dirFiles] of Object.entries(dirGroups)) { | |
| if (Object.keys(dirGroups).length > 1) { | |
| groups[dir] = dirFiles; | |
| } else { | |
| for (const f of dirFiles) { | |
| try { | |
| const ab = await f.arrayBuffer(); | |
| const bytes = new Uint8Array(ab.slice(0, 6144)); | |
| const mod = parseDicomTag(bytes, 0x0008, 0x0060); | |
| if (mod && nonImageMods.includes(mod.toUpperCase())) continue; | |
| const key = parseDicomTag(bytes, 0x0020, 0x000E) || dir; | |
| (groups[key] = groups[key] || []).push(cloneFile(ab, f)); | |
| } catch(e) { | |
| (groups[dir] = groups[dir] || []).push(f); | |
| } | |
| } | |
| } | |
| } | |
| allSeries = Object.keys(groups).sort().map(k => ({ name: k, files: groups[k] })); | |
| // Get series description labels | |
| for (const s of allSeries) { | |
| try { | |
| const ab = await s.files[0].arrayBuffer(); | |
| const desc = parseDicomTag(new Uint8Array(ab.slice(0, 6144)), 0x0008, 0x103E); | |
| if (desc) s.label = desc.trim(); | |
| s.files[0] = cloneFile(ab, s.files[0]); | |
| } catch(e) {} | |
| } | |
| const sel = document.getElementById('seriesSelect'); | |
| sel.innerHTML = allSeries.map((s, i) => | |
| `<option value="${i}">${s.label || s.name} (${s.files.length})</option>` | |
| ).join(''); | |
| loadSeries(0); | |
| } | |
| // --- Load a series --- | |
| async function loadSeries(idx) { | |
| currentIdx = idx; manualMode = false; manualSlice = 0; | |
| document.getElementById('seriesSelect').value = idx; | |
| document.getElementById('loading').style.display = 'block'; | |
| document.getElementById('loading').textContent = `Loading ${allSeries[idx].label || allSeries[idx].name}...`; | |
| document.getElementById('openBtn').style.display = 'none'; | |
| // Sort by InstanceNumber once | |
| if (!allSeries[idx]._sorted) { | |
| const items = []; | |
| for (const f of allSeries[idx].files) { | |
| try { | |
| const ab = await f.arrayBuffer(); | |
| const inst = parseInt(parseDicomTag(new Uint8Array(ab.slice(0, 6144)), 0x0020, 0x0013)) || 0; | |
| items.push({ file: cloneFile(ab, f), inst }); | |
| } catch(e) { items.push({ file: f, inst: 0 }); } | |
| } | |
| items.sort((a, b) => a.inst - b.inst); | |
| allSeries[idx].files = items.map(x => x.file); | |
| allSeries[idx]._sorted = true; | |
| } | |
| const seriesFiles = allSeries[idx].files; | |
| activeLayer = 0; | |
| for (let i = 0; i < 2; i++) { | |
| document.getElementById('layerGroup' + i).innerHTML = ''; | |
| document.getElementById('layerGroup' + i).style.zIndex = i === 0 ? 2 : 1; | |
| } | |
| app = new dwv.App(); | |
| app.init({ dataViewConfigs: {'*': [{divId: 'layerGroup0'}]}, tools: { Scroll: {}, WindowLevel: {}, ZoomAndPan: {} } }); | |
| app.addEventListener('loadend', () => { | |
| document.getElementById('dropzone').style.display = 'none'; | |
| document.getElementById('loading').style.display = 'none'; | |
| document.getElementById('viewer').style.display = 'flex'; | |
| setTimeout(() => { | |
| try { app.onResize(); } catch(e) {} | |
| let canScroll = false; | |
| try { const vc = app.getViewController('0'); canScroll = vc && vc.canScroll(); } catch(e) {} | |
| if (!canScroll && seriesFiles.length > 1) { manualMode = true; manualSlice = 0; } | |
| setTool(manualMode ? 'WindowLevel' : 'Scroll'); | |
| showMeta(); updateOverlay(); | |
| }, 150); | |
| }); | |
| app.addEventListener('positionchange', updateOverlay); | |
| app.loadFiles(seriesFiles); | |
| } | |
| // --- Manual slice loading (double-buffered) --- | |
| function loadManualSlice(sliceIdx) { | |
| if (!manualMode || manualLoading) return; | |
| const files = allSeries[currentIdx].files; | |
| if (sliceIdx < 0 || sliceIdx >= files.length) return; | |
| manualSlice = sliceIdx; manualLoading = true; updateOverlay(); | |
| const backId = 1 - activeLayer; | |
| const backDiv = document.getElementById('layerGroup' + backId); | |
| backDiv.innerHTML = ''; | |
| const backApp = new dwv.App(); | |
| backApp.init({ dataViewConfigs: {'*': [{divId: 'layerGroup' + backId}]}, tools: { WindowLevel: {}, ZoomAndPan: {} } }); | |
| backApp.addEventListener('loadend', () => { | |
| setTimeout(() => { | |
| try { backApp.onResize(); } catch(e) {} | |
| document.getElementById('layerGroup' + backId).style.zIndex = 2; | |
| document.getElementById('layerGroup' + activeLayer).style.zIndex = 1; | |
| app = backApp; activeLayer = backId; manualLoading = false; | |
| setTool('WindowLevel'); updateOverlay(); | |
| }, 20); | |
| }); | |
| backApp.loadFiles([files[sliceIdx]]); | |
| } | |
| // --- UI helpers --- | |
| function showMeta() { | |
| try { | |
| const meta = app.getMetaData('0'); | |
| if (!meta) return; | |
| const pn = meta['00100010'] ? meta['00100010'].value[0] : ''; | |
| document.getElementById('patientInfo').textContent = (typeof pn === 'string' ? pn : (pn.Alphabetic || '')).replace(/\^/g, ', '); | |
| document.getElementById('ov-tl').textContent = meta['0008103E'] ? meta['0008103E'].value[0] : ''; | |
| } catch(e) {} | |
| } | |
| function updateOverlay() { | |
| if (!app) return; | |
| if (manualMode) { | |
| const total = allSeries[currentIdx].files.length; | |
| document.getElementById('sliceInfo').textContent = document.getElementById('ov-bl').textContent = `Slice ${manualSlice + 1} / ${total}`; | |
| try { const wl = app.getViewController('0').getWindowLevel(); if (wl) document.getElementById('ov-tr').textContent = `WC: ${Math.round(wl.center)} / WW: ${Math.round(wl.width)}`; } catch(e) {} | |
| return; | |
| } | |
| try { | |
| const vc = app.getViewController('0'); if (!vc) return; | |
| const k = vc.getCurrentScrollIndexValue ? vc.getCurrentScrollIndexValue() : 0; | |
| const si = vc.getScrollIndex ? vc.getScrollIndex() : 2; | |
| let total = '?'; try { total = app.getImage(0).getGeometry().getSize().get(si); } catch(e) {} | |
| document.getElementById('sliceInfo').textContent = document.getElementById('ov-bl').textContent = `Slice ${k + 1} / ${total}`; | |
| const wl = vc.getWindowLevel(); | |
| if (wl) document.getElementById('ov-tr').textContent = `WC: ${Math.round(wl.center)} / WW: ${Math.round(wl.width)}`; | |
| } catch(e) {} | |
| } | |
| function setTool(name) { | |
| if (!app) return; | |
| try { app.setTool(name); } catch(e) {} | |
| document.querySelectorAll('#toolbar button[id^="tool"]').forEach(b => | |
| b.classList.toggle('active', b.id === 'tool' + name.replace('AndPan','').replace('indowLevel','L'))); | |
| } | |
| function resetView() { | |
| if (!app) return; | |
| try { app.getViewController('0').setWindowLevelPreset('Default'); app.resetZoom(); } catch(e) {} | |
| updateOverlay(); | |
| } | |
| function switchSeries(idx) { loadSeries(parseInt(idx)); } | |
| function prevSeries() { if (currentIdx > 0) switchSeries(currentIdx - 1); } | |
| function nextSeries() { if (currentIdx < allSeries.length - 1) switchSeries(currentIdx + 1); } | |
| function scrollSlice(delta) { | |
| if (!app) return; | |
| if (manualMode) { | |
| const n = allSeries[currentIdx].files.length; | |
| const s = Math.max(0, Math.min(n - 1, manualSlice + delta)); | |
| if (s !== manualSlice) loadManualSlice(s); | |
| return; | |
| } | |
| try { | |
| const vc = app.getViewController('0'); | |
| if (!vc || !vc.canScroll()) return; | |
| for (let i = 0; i < Math.abs(delta); i++) delta > 0 ? vc.incrementScrollIndex() : vc.decrementScrollIndex(); | |
| updateOverlay(); | |
| } catch(e) {} | |
| } | |
| // --- Events --- | |
| let wheelTimer = null; | |
| document.getElementById('canvas-area').addEventListener('wheel', (e) => { | |
| e.preventDefault(); | |
| if (wheelTimer) return; | |
| wheelTimer = setTimeout(() => wheelTimer = null, 80); | |
| scrollSlice(e.deltaY > 0 ? 1 : -1); | |
| }, { passive: false }); | |
| document.addEventListener('keydown', (e) => { | |
| if (!app) return; | |
| switch (e.key) { | |
| case 'ArrowUp': e.preventDefault(); scrollSlice(-1); break; | |
| case 'ArrowDown': e.preventDefault(); scrollSlice(1); break; | |
| case 'ArrowLeft': e.preventDefault(); prevSeries(); break; | |
| case 'ArrowRight': e.preventDefault(); nextSeries(); break; | |
| case 'Home': e.preventDefault(); scrollSlice(-9999); break; | |
| case 'End': e.preventDefault(); scrollSlice(9999); break; | |
| case '1': setTool('Scroll'); break; | |
| case '2': setTool('WindowLevel'); break; | |
| case '3': setTool('ZoomAndPan'); break; | |
| case 'r': case 'R': resetView(); break; | |
| } | |
| }); | |
| window.addEventListener('resize', () => { if (app) try { app.onResize(); } catch(e) {} }); | |
| setInterval(updateOverlay, 500); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment