let studies = []; let currentStudy = null; let studyInfo = {}; let seriesList = []; let panels = []; let panelCount = 0; let is3DMode = false; let seriesListByOrientation = { SAG: [], AX: [], COR: [] }; let tokenParam = ''; // Will be set from URL if present // W/L presets for common viewing windows const wlPresets = [ { name: 'Default', wc: null, ww: null }, { name: 'Brain', wc: 40, ww: 80 }, { name: 'Subdural', wc: 80, ww: 200 }, { name: 'Bone', wc: 500, ww: 2000 }, { name: 'Stroke', wc: 40, ww: 40 }, { name: 'Soft', wc: 50, ww: 400 } ]; // Detect if image background is light (for overlay color adjustment) function detectImageBrightness(img, panelIdx) { const div = document.getElementById('panel-' + panelIdx); if (!div || !img.complete || !img.naturalWidth) return; const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const sampleSize = 50; canvas.width = sampleSize; canvas.height = sampleSize; // Sample top-left corner (where overlay text appears) ctx.drawImage(img, 0, 0, sampleSize, sampleSize, 0, 0, sampleSize, sampleSize); const data = ctx.getImageData(0, 0, sampleSize, sampleSize).data; let total = 0; for (let i = 0; i < data.length; i += 4) { total += (data[i] + data[i+1] + data[i+2]) / 3; } const avgBrightness = total / (data.length / 4); // Toggle light-bg class based on brightness threshold div.classList.toggle('light-bg', avgBrightness > 160); } function addToken(url) { if (!tokenParam) return url; return url + (url.includes('?') ? '&' : '?') + 'token=' + tokenParam; } // Rectangle drawing state let isDrawing = false; let startX = 0, startY = 0; let currentRect = null; let activePanel = null; // Window/Level adjustment state let wlState = {}; // seriesId -> { wc, ww, originalWc, originalWw } let isAdjustingWL = false; let wlStartX = 0, wlStartY = 0; let wlStartWc = 0, wlStartWw = 0; let wlDebounceTimer = null; let wlPanel = -1; function getImageUrl(sliceId, seriesId) { let url = "/image/" + sliceId; const params = []; if (tokenParam) params.push("token=" + tokenParam); if (seriesId && wlState[seriesId] && wlState[seriesId].adjusted) { params.push("wc=" + Math.round(wlState[seriesId].wc)); params.push("ww=" + Math.round(wlState[seriesId].ww)); } if (params.length) url += "?" + params.join("&"); return url; } function getImageUrlWithWL(sliceId, seriesId, wc, ww) { let url = "/image/" + sliceId; const params = []; if (tokenParam) params.push("token=" + tokenParam); if (wc !== null && ww !== null) { params.push("wc=" + Math.round(wc)); params.push("ww=" + Math.round(ww)); } if (params.length) url += "?" + params.join("&"); return url; } function initWLState(seriesId, slices) { if (!wlState[seriesId] && slices.length > 0) { const s = slices[0]; wlState[seriesId] = { adjusted: false, wc: s.window_center || 128, ww: s.window_width || 256, originalWc: s.window_center || 128, originalWw: s.window_width || 256 }; } } function resetWL(panelIdx) { const panel = panels[panelIdx]; if (!panel || !panel.seriesId) return; const state = wlState[panel.seriesId]; if (state) { state.wc = state.originalWc; state.ww = state.originalWw; state.adjusted = false; reloadPanelImages(panelIdx); } } function reloadPanelImages(panelIdx) { const panel = panels[panelIdx]; if (!panel || !panel.slices.length) return; const div = document.getElementById("panel-" + panelIdx); const img = div.querySelector(".panel-content img"); img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId); // Reload thumbnails too const thumbs = div.querySelectorAll(".thumb"); thumbs.forEach((t, i) => { t.src = getImageUrl(panel.slices[i].id, panel.seriesId); }); updateOverlay(panelIdx); } function updateOverlay(panelIdx) { const panel = panels[panelIdx]; if (!panel) return; const div = document.getElementById("panel-" + panelIdx); if (!div) return; // Get series info const series = seriesList.find(s => s.id === panel.seriesId) || (is3DMode && panel.orientation ? seriesListByOrientation[panel.orientation].find(s => s.id === panel.seriesId) : null); const seriesName = series ? series.series_desc : ""; // Get slice info const slice = panel.slices[panel.currentSlice]; // Get W/L info let wc = "", ww = ""; let adjusted = false; if (panel.seriesId && wlState[panel.seriesId]) { const state = wlState[panel.seriesId]; if (state.adjusted) { wc = Math.round(state.wc); ww = Math.round(state.ww); adjusted = true; } else if (slice) { wc = Math.round(slice.window_center || 0); ww = Math.round(slice.window_width || 0); } } else if (slice) { wc = Math.round(slice.window_center || 0); ww = Math.round(slice.window_width || 0); } // Get zoom level const orientation = panel.orientation || "AX"; const zoom = zoomState[orientation] ? Math.round(zoomLevels[zoomState[orientation].level] * 100) : 100; // Update all overlay elements const q = s => div.querySelector(s); // Top left - patient/study info if (q(".overlay-patient")) q(".overlay-patient").textContent = (studyInfo.patient_name || "").replace(/\^/g, " "); if (q(".overlay-accession")) q(".overlay-accession").textContent = studyInfo.accession ? "Acc: " + studyInfo.accession : ""; if (q(".overlay-study-desc")) q(".overlay-study-desc").textContent = studyInfo.study_desc || ""; if (q(".overlay-series")) q(".overlay-series").textContent = seriesName; if (q(".overlay-slice-num")) q(".overlay-slice-num").textContent = slice ? (panel.currentSlice + 1) : ""; if (q(".overlay-slice-total")) q(".overlay-slice-total").textContent = panel.slices.length ? " / " + panel.slices.length : ""; // Top right - technical info if (q(".overlay-datetime")) { let dt = ""; if (studyInfo.study_date) { dt = studyInfo.study_date; if (studyInfo.study_time) dt += " " + studyInfo.study_time.substring(0,2) + ":" + studyInfo.study_time.substring(2,4); } q(".overlay-datetime").textContent = dt; } if (q(".overlay-institution")) q(".overlay-institution").textContent = studyInfo.institution || ""; if (q(".overlay-pos")) q(".overlay-pos").textContent = slice && slice.slice_location != null ? "Pos: " + slice.slice_location.toFixed(1) + " mm" : ""; if (q(".overlay-thickness")) q(".overlay-thickness").textContent = slice && slice.slice_thickness ? "ST: " + slice.slice_thickness.toFixed(2) + " mm" : ""; const wcEl = q(".overlay-wc"); const wwEl = q(".overlay-ww"); if (wcEl) { wcEl.textContent = wc; wcEl.classList.toggle("wl-adjusted", adjusted); } if (wwEl) { wwEl.textContent = ww; wwEl.classList.toggle("wl-adjusted", adjusted); } if (q(".overlay-zoom")) q(".overlay-zoom").textContent = zoom !== 100 ? "Zoom: " + zoom + "%" : ""; // Orientation markers based on image_orientation updateOrientationMarkers(div, slice, orientation); } function updateOrientationMarkers(div, slice, orientationType) { const left = div.querySelector(".overlay-orient-left"); const right = div.querySelector(".overlay-orient-right"); const top = div.querySelector(".overlay-orient-top"); const bottom = div.querySelector(".overlay-orient-bottom"); // Default markers based on orientation type let markers = { left: "", right: "", top: "", bottom: "" }; if (orientationType === "AX") { markers = { left: "R", right: "L", top: "A", bottom: "P" }; } else if (orientationType === "SAG") { markers = { left: "A", right: "P", top: "S", bottom: "I" }; } else if (orientationType === "COR") { markers = { left: "R", right: "L", top: "S", bottom: "I" }; } // TODO: Parse image_orientation DICOM tag for exact orientation if needed if (left) left.textContent = markers.left; if (right) right.textContent = markers.right; if (top) top.textContent = markers.top; if (bottom) bottom.textContent = markers.bottom; } // Zoom state - shared by orientation type const zoomLevels = [1, 1.5, 2, 3, 4]; let zoomState = { AX: { level: 0, panX: 0, panY: 0 }, SAG: { level: 0, panX: 0, panY: 0 }, COR: { level: 0, panX: 0, panY: 0 } }; let hoveredPanel = 0; let cursorX = 0, cursorY = 0; // cursor position relative to hovered wrapper let scrollAccumulator = 0; // for slower slice scrolling function toggleHelp() { document.getElementById('helpModal').classList.toggle('show'); } // Tour functionality const tourSteps = [ { target: () => document.getElementById('header'), title: 'Welcome to Inou', text: '
異能
"extraordinary ability"
Explore medical imaging with AI assistance.

Currently supports MRI, CT, and X-Ray.
Need other modalities? requests@inou.com', pos: 'bottom' }, { target: () => document.querySelector('button[onclick="setPanels(1)"]'), title: 'Panel Layout', text: 'Switch between 1, 2, or 3 panels to compare different series side by side.', pos: 'bottom' }, { target: () => document.getElementById('btn3d'), title: '3D Crosshair Mode', text: 'For MRI studies with SAG, AX, and COR series: synchronized crosshair navigation across all three planes.', pos: 'bottom' }, { target: () => document.getElementById('helpBtn'), title: 'Keyboard Shortcuts', text: 'Click here for a quick reference of all keyboard and mouse controls.', pos: 'bottom' }, { target: () => document.querySelector('.panel-content img'), title: 'Select a Region', text: 'Click and drag on any image to draw a rectangle. This creates a reference you can share with AI.', pos: 'bottom' }, { target: () => document.getElementById('rectInfo'), title: 'AI Communication', text: 'Copy a rectangle reference to share with Claude, or paste one Claude gives you to jump to a specific location.', pos: 'bottom' } ]; let tourIndex = 0; function startTour() { tourIndex = 0; document.getElementById('tourOverlay').classList.add('show'); showTourStep(); } function endTour() { document.getElementById('tourOverlay').classList.remove('show'); localStorage.setItem('tourSeen', 'true'); } function showTourStep() { const step = tourSteps[tourIndex]; const target = step.target(); if (!target) { nextTourStep(); return; } const rect = target.getBoundingClientRect(); const spotlight = document.getElementById('tourSpotlight'); const tooltip = document.getElementById('tourTooltip'); // Position spotlight const pad = 8; spotlight.style.left = (rect.left - pad) + 'px'; spotlight.style.top = (rect.top - pad) + 'px'; spotlight.style.width = (rect.width + pad * 2) + 'px'; spotlight.style.height = (rect.height + pad * 2) + 'px'; // Build tooltip const isLastStep = tourIndex >= tourSteps.length - 1; tooltip.innerHTML = '

' + step.title + '

' + step.text + '

' + '
' + (isLastStep ? '' : '') + '' + (tourIndex + 1) + ' / ' + tourSteps.length + '' + '' + '
'; // Position tooltip const ttWidth = tourIndex === 0 ? 420 : 300; const ttHeight = tourIndex === 0 ? 280 : 150; let ttLeft = rect.left + rect.width / 2 - ttWidth / 2; ttLeft = Math.max(10, Math.min(window.innerWidth - ttWidth - 10, ttLeft)); tooltip.style.left = ttLeft + 'px'; tooltip.style.width = ttWidth + 'px'; // Welcome screen: center vertically if (tourIndex === 0) { tooltip.style.top = '50%'; tooltip.style.transform = 'translateY(-50%)'; tooltip.style.left = '50%'; tooltip.style.marginLeft = (-ttWidth / 2) + 'px'; return; } tooltip.style.transform = 'none'; tooltip.style.marginLeft = '0'; // Determine best vertical position const spaceBelow = window.innerHeight - rect.bottom - 20; const spaceAbove = rect.top - 20; const placeBelow = step.pos === 'bottom' || spaceBelow >= ttHeight || spaceBelow > spaceAbove; tooltip.style.bottom = 'auto'; tooltip.style.top = 'auto'; if (placeBelow) { let ttTop = rect.bottom + 15; ttTop = Math.min(ttTop, window.innerHeight - ttHeight - 20); tooltip.style.top = ttTop + 'px'; } else { let ttTop = rect.top - ttHeight - 15; ttTop = Math.max(10, ttTop); tooltip.style.top = ttTop + 'px'; } } function nextTourStep() { tourIndex++; if (tourIndex >= tourSteps.length) { endTour(); } else { showTourStep(); } } // Pan state let isPanning = false; let panStartMouseX = 0, panStartMouseY = 0; let panStartPanX = 0, panStartPanY = 0; let panOrientation = null; function getCurrentImageRef(panelIdx) { const panel = panels[panelIdx]; if (!panel || !panel.slices.length) return ''; const slice = panel.slices[panel.currentSlice]; const seriesDesc = seriesList.find(s => s.id == panel.seriesId)?.series_desc || 'Unknown'; const study = studies.find(s => s.id == document.getElementById('studySelect').value); const studyRef = study ? study.study_date + ' ' + study.study_desc : 'Unknown'; return studyRef + ' | ' + seriesDesc + ' slice ' + slice.instance_number; } async function init() { // Create W/L hint element const wlHint = document.createElement('div'); wlHint.id = 'wlHint'; wlHint.textContent = 'Image updates after 0.3s'; document.body.appendChild(wlHint); // Extract token from URL for subsequent API calls const params = new URLSearchParams(window.location.search); tokenParam = params.get('token') || ''; const res = await fetch(addToken('/api/studies')); studies = await res.json(); const sel = document.getElementById('studySelect'); studies.forEach(s => { const opt = document.createElement('option'); opt.value = s.id; opt.textContent = s.study_date + ' - ' + s.study_desc; sel.appendChild(opt); }); if (studies.length > 0) sel.selectedIndex = 0; // Deep link by study/series GUID const urlStudy = params.get('study'); const urlSeries = params.get('series'); if (urlStudy) { const idx = studies.findIndex(s => s.id === urlStudy); if (idx >= 0) sel.selectedIndex = idx; } if (studies.length > 0) { await loadStudy(urlSeries ? 1 : 2); // 1 panel for deep link, 2 otherwise if (urlSeries && seriesList.length > 0) { const idx = seriesList.findIndex(s => s.id === urlSeries); if (idx >= 0 && panels[0]) { const panel = document.getElementById('panel-0'); const select = panel.querySelector('select'); if (select) select.selectedIndex = idx + 1; // +1 for "Select series..." option await loadSeries(0, seriesList[idx].id); } } } // Auto-start tour for first-time users if (!localStorage.getItem('tourSeen')) { setTimeout(startTour, 800); } } async function addPanelEmpty() { const idx = panelCount++; const panel = { idx, seriesId: null, slices: [], currentSlice: 0 }; panels.push(panel); const div = document.createElement('div'); div.className = 'panel'; div.id = 'panel-' + idx; // Series header: show dropdown only if multiple series let headerContent; if (seriesList.length === 1) { headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')'; } else { headerContent = ''; } div.innerHTML = '
' + headerContent + '
' + '
C
W
' + '
'; document.getElementById('panels').appendChild(div); const img = div.querySelector('.panel-content img'); const rectOverlay = div.querySelector('.rect-overlay'); // Mouse move - show coordinates img.addEventListener('mousemove', e => { const { x, y } = getImageCoords(e, img); document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; if (isDrawing && activePanel === idx) { const { rect, scaleX, scaleY } = getImageCoords(e, img); const curX = (e.clientX - rect.left); const curY = (e.clientY - rect.top); const sx = Math.min(startX, curX); const sy = Math.min(startY, curY); const w = Math.abs(curX - startX); const h = Math.abs(curY - startY); rectOverlay.style.left = sx + 'px'; rectOverlay.style.top = sy + 'px'; rectOverlay.style.width = w + 'px'; rectOverlay.style.height = h + 'px'; rectOverlay.style.display = 'block'; const x1 = Math.round(Math.min(startX, curX) * scaleX); const y1 = Math.round(Math.min(startY, curY) * scaleY); const x2 = Math.round(Math.max(startX, curX) * scaleX); const y2 = Math.round(Math.max(startY, curY) * scaleY); const imgRef = getCurrentImageRef(activePanel); document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')'; document.getElementById('rectInfo').style.display = 'block'; } }); img.addEventListener('mouseleave', () => { document.getElementById('coordDisplay').textContent = 'x: -, y: -'; }); img.addEventListener('mousedown', e => { if (e.button !== 0) return; // Only left-click for drawing if (e.shiftKey) return; // Shift+drag is for panning e.preventDefault(); isDrawing = true; activePanel = idx; const rect = img.getBoundingClientRect(); startX = e.clientX - rect.left; startY = e.clientY - rect.top; rectOverlay.style.display = 'none'; }); img.addEventListener('mouseup', e => { if (isDrawing && activePanel === idx) { isDrawing = false; const { rect, scaleX, scaleY } = getImageCoords(e, img); const curX = e.clientX - rect.left; const curY = e.clientY - rect.top; if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { currentRect = { panelIdx: idx, x1: Math.round(Math.min(startX, curX) * scaleX), y1: Math.round(Math.min(startY, curY) * scaleY), x2: Math.round(Math.max(startX, curX) * scaleX), y2: Math.round(Math.max(startY, curY) * scaleY) }; const imgRef = getCurrentImageRef(idx); document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')'; document.getElementById('rectInfo').style.display = 'block'; } else { rectOverlay.style.display = 'none'; } } }); } async function loadStudy(numPanels = 2) { const studyId = document.getElementById('studySelect').value; const res = await fetch(addToken('/api/series?study=' + studyId)); seriesList = await res.json(); // Fetch study info for overlay const infoRes = await fetch(addToken("/api/studies?study=" + studyId)); studyInfo = await infoRes.json(); is3DMode = false; // Smart UI: show/hide elements based on series count const seriesCount = seriesList.length; const btn2panels = document.getElementById('btn2panels'); const btn3panels = document.getElementById('btn3panels'); const btn3d = document.getElementById('btn3d'); const syncLabel = document.getElementById('syncLabel'); if (seriesCount === 1) { // Single series: hide multi-panel options, 3D, sync btn2panels.style.display = 'none'; btn3panels.style.display = 'none'; btn3d.style.display = 'none'; syncLabel.style.display = 'none'; numPanels = 1; // Force single panel } else if (seriesCount === 2) { // Two series: hide 3-panel, 3D, sync btn2panels.style.display = ''; btn3panels.style.display = 'none'; btn3d.style.display = 'none'; syncLabel.style.display = 'none'; if (numPanels > 2) numPanels = 2; } else { // 3+ series: show all, check 3D availability btn2panels.style.display = ''; btn3panels.style.display = ''; btn3d.style.display = ''; syncLabel.style.display = ''; // Check if 3D mode is available (has SAG, AX, and COR) const hasSag = seriesList.some(s => s.series_desc.toUpperCase().includes('SAG')); const hasAx = seriesList.some(s => s.series_desc.toUpperCase().includes('AX')); const hasCor = seriesList.some(s => s.series_desc.toUpperCase().includes('COR')); btn3d.disabled = !(hasSag && hasAx && hasCor); btn3d.title = btn3d.disabled ? 'Requires SAG, AX, and COR series' : '3D crosshair mode'; } setPanels(numPanels); } async function set3DMode() { const studyId = document.getElementById('studySelect').value; if (!studyId) return; is3DMode = true; document.getElementById('syncScroll').checked = false; // Fetch series for each orientation const [sagRes, axRes, corRes] = await Promise.all([ fetch(addToken('/api/series?study=' + studyId + '&orientation=SAG')), fetch(addToken('/api/series?study=' + studyId + '&orientation=AX')), fetch(addToken('/api/series?study=' + studyId + '&orientation=COR')) ]); seriesListByOrientation.SAG = await sagRes.json() || []; seriesListByOrientation.AX = await axRes.json() || []; seriesListByOrientation.COR = await corRes.json() || []; // Clear and create 3 panels document.getElementById('panels').innerHTML = ''; panels = []; panelCount = 0; await add3DPanel(0, 'SAG', seriesListByOrientation.SAG); await add3DPanel(1, 'AX', seriesListByOrientation.AX); await add3DPanel(2, 'COR', seriesListByOrientation.COR); } function pickBestSeries(seriesOptions) { if (!seriesOptions || !seriesOptions.length) return null; // Prefer T1 with contrast (T1+, T1+C, T1 POST, T1 C+, etc) let t1plus = seriesOptions.find(s => /T1.*(\+|POST|C\+|CONTRAST)/i.test(s.series_desc)); if (t1plus) return t1plus.id; // Then T2 let t2 = seriesOptions.find(s => /\bT2\b/i.test(s.series_desc)); if (t2) return t2.id; // Then T1 (without contrast) let t1 = seriesOptions.find(s => /\bT1\b/i.test(s.series_desc)); if (t1) return t1.id; // Fallback to first return seriesOptions[0].id; } async function add3DPanel(idx, orientation, seriesOptions) { panelCount++; const panel = { idx, seriesId: null, slices: [], currentSlice: 0, orientation }; panels.push(panel); const div = document.createElement('div'); div.className = 'panel'; div.id = 'panel-' + idx; div.innerHTML = '
' + '' + orientation + '' + '
' + '
' + '' + '' + '' + '' + '
C
W
' + '
'; document.getElementById('panels').appendChild(div); const img = div.querySelector('.panel-content img'); const rectOverlay = div.querySelector('.rect-overlay'); img.addEventListener('mousemove', e => { const { x, y } = getImageCoords(e, img); document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; if (isDrawing && activePanel === idx) { const { rect, scaleX, scaleY } = getImageCoords(e, img); const curX = (e.clientX - rect.left); const curY = (e.clientY - rect.top); const sx = Math.min(startX, curX); const sy = Math.min(startY, curY); const w = Math.abs(curX - startX); const h = Math.abs(curY - startY); rectOverlay.style.left = sx + 'px'; rectOverlay.style.top = sy + 'px'; rectOverlay.style.width = w + 'px'; rectOverlay.style.height = h + 'px'; rectOverlay.style.display = 'block'; } }); img.addEventListener('mouseleave', () => { document.getElementById('coordDisplay').textContent = 'x: -, y: -'; }); img.addEventListener('mousedown', e => { if (e.button !== 0) return; // Only left-click for drawing if (e.shiftKey) return; // Shift+drag is for panning e.preventDefault(); isDrawing = true; activePanel = idx; const rect = img.getBoundingClientRect(); startX = e.clientX - rect.left; startY = e.clientY - rect.top; rectOverlay.style.display = 'none'; }); img.addEventListener('mouseup', e => { if (isDrawing && activePanel === idx) { isDrawing = false; const { rect, scaleX, scaleY } = getImageCoords(e, img); const curX = e.clientX - rect.left; const curY = e.clientY - rect.top; if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { currentRect = { panelIdx: idx, x1: Math.round(Math.min(startX, curX) * scaleX), y1: Math.round(Math.min(startY, curY) * scaleY), x2: Math.round(Math.max(startX, curX) * scaleX), y2: Math.round(Math.max(startY, curY) * scaleY) }; } else { rectOverlay.style.display = 'none'; } } }); // Auto-select best series (T1+ > T2 > T1 > first) const bestSeriesId = pickBestSeries(seriesOptions); if (bestSeriesId) { div.querySelector('select').value = bestSeriesId; await loadSeries(idx, bestSeriesId); } } function setPanels(count) { is3DMode = false; document.getElementById('panels').innerHTML = ''; panels = []; panelCount = 0; for (let i = 0; i < count; i++) { addPanel(); } } function getImageCoords(e, img) { const rect = img.getBoundingClientRect(); const scaleX = img.naturalWidth / rect.width; const scaleY = img.naturalHeight / rect.height; const x = Math.round((e.clientX - rect.left) * scaleX); const y = Math.round((e.clientY - rect.top) * scaleY); return { x, y, rect, scaleX, scaleY }; } function getPanelOrientation(panelIdx) { const panel = panels[panelIdx]; if (!panel || !panel.slices.length) return 'AX'; // In 3D mode, orientation is stored on panel if (panel.orientation) return panel.orientation; // Otherwise, derive from series description const series = seriesList.find(s => s.id == panel.seriesId); if (series) { const desc = series.series_desc.toUpperCase(); if (desc.includes('SAG')) return 'SAG'; if (desc.includes('COR')) return 'COR'; } return 'AX'; // default } function applyZoom(orientation) { const state = zoomState[orientation]; const zoom = zoomLevels[state.level]; panels.forEach((p, idx) => { console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); if (getPanelOrientation(idx) === orientation) { const div = document.getElementById('panel-' + idx); if (!div) return; const wrapper = div.querySelector('.img-wrapper'); const content = div.querySelector('.panel-content'); wrapper.style.transform = 'scale(' + zoom + ') translate(' + state.panX + 'px, ' + state.panY + 'px)'; content.classList.toggle('zoomed', state.level > 0); } }); } function zoomIn(panelIdx) { const orientation = getPanelOrientation(panelIdx); const state = zoomState[orientation]; if (state.level < zoomLevels.length - 1) { state.level++; // Keep pan at 0 to center the image state.panX = 0; state.panY = 0; applyZoom(orientation); } } function zoomOut(panelIdx) { const orientation = getPanelOrientation(panelIdx); const state = zoomState[orientation]; if (state.level > 0) { state.level--; // Keep pan at 0 to center the image state.panX = 0; state.panY = 0; applyZoom(orientation); } } function resetZoom(orientation) { const state = zoomState[orientation]; state.level = 0; state.panX = 0; state.panY = 0; applyZoom(orientation); } async function addPanel() { const idx = panelCount++; const panel = { idx, seriesId: null, slices: [], currentSlice: 0 }; panels.push(panel); const div = document.createElement('div'); div.className = 'panel'; div.id = 'panel-' + idx; // Series header: show dropdown only if multiple series let headerContent; if (seriesList.length === 1) { headerContent = '' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')'; } else { headerContent = ''; } div.innerHTML = '
' + headerContent + '
' + '
C
W
' + '
'; document.getElementById('panels').appendChild(div); const img = div.querySelector('.panel-content img'); const wrapper = div.querySelector('.img-wrapper'); const rectOverlay = div.querySelector('.rect-overlay'); // Mouse move - show coordinates img.addEventListener('mousemove', e => { const { x, y } = getImageCoords(e, img); document.getElementById('coordDisplay').textContent = 'x: ' + x + ', y: ' + y; if (isDrawing && activePanel === idx) { const { rect, scaleX, scaleY } = getImageCoords(e, img); const curX = (e.clientX - rect.left); const curY = (e.clientY - rect.top); const sx = Math.min(startX, curX); const sy = Math.min(startY, curY); const w = Math.abs(curX - startX); const h = Math.abs(curY - startY); rectOverlay.style.left = sx + 'px'; rectOverlay.style.top = sy + 'px'; rectOverlay.style.width = w + 'px'; rectOverlay.style.height = h + 'px'; rectOverlay.style.display = 'block'; // Update coords in image space const x1 = Math.round(Math.min(startX, curX) * scaleX); const y1 = Math.round(Math.min(startY, curY) * scaleY); const x2 = Math.round(Math.max(startX, curX) * scaleX); const y2 = Math.round(Math.max(startY, curY) * scaleY); const imgRef = getCurrentImageRef(activePanel); document.getElementById('rectCoords').value = imgRef + ': (' + x1 + ',' + y1 + ')-(' + x2 + ',' + y2 + ')'; document.getElementById('rectInfo').style.display = 'block'; } }); img.addEventListener('mouseleave', () => { document.getElementById('coordDisplay').textContent = 'x: -, y: -'; }); // Mouse down - start drawing img.addEventListener('mousedown', e => { if (e.button !== 0) return; // Only left-click for drawing if (e.shiftKey) return; // Shift+drag is for panning e.preventDefault(); isDrawing = true; activePanel = idx; const rect = img.getBoundingClientRect(); startX = e.clientX - rect.left; startY = e.clientY - rect.top; rectOverlay.style.display = 'none'; }); // Mouse up - finish drawing img.addEventListener('mouseup', e => { if (isDrawing && activePanel === idx) { isDrawing = false; const { rect, scaleX, scaleY } = getImageCoords(e, img); const curX = e.clientX - rect.left; const curY = e.clientY - rect.top; if (Math.abs(curX - startX) > 5 && Math.abs(curY - startY) > 5) { currentRect = { panelIdx: idx, x1: Math.round(Math.min(startX, curX) * scaleX), y1: Math.round(Math.min(startY, curY) * scaleY), x2: Math.round(Math.max(startX, curX) * scaleX), y2: Math.round(Math.max(startY, curY) * scaleY) }; const imgRef = getCurrentImageRef(idx); document.getElementById('rectCoords').value = imgRef + ': (' + currentRect.x1 + ',' + currentRect.y1 + ')-(' + currentRect.x2 + ',' + currentRect.y2 + ')'; document.getElementById('rectInfo').style.display = 'block'; } else { rectOverlay.style.display = 'none'; } } }); if (idx < seriesList.length) { // Smart default: prefer T1/T2/FLAIR, avoid DWI/DTI/Diffusion/high slice counts const preferred = pickDefaultSeries(seriesList, idx); if (preferred) { const select = div.querySelector('select'); if (select) select.value = preferred.id; loadSeries(idx, preferred.id); } } } function pickDefaultSeries(series, panelIdx) { // Score each series - lower is better const scored = series.map(s => { const desc = s.series_desc.toUpperCase(); let score = 100; // Strongly prefer structural sequences if (desc.includes('T2') && !desc.includes('DWI') && !desc.includes('DIFFUSION')) score -= 50; if (desc.includes('T1')) score -= 40; if (desc.includes('FLAIR')) score -= 35; // Prefer axial for comparison if (desc.includes('AX')) score -= 20; // Avoid diffusion/DWI/DTI if (desc.includes('DWI') || desc.includes('DTI') || desc.includes('DIFFUSION') || desc.includes('TENSOR')) score += 100; if (desc.includes('B 1000') || desc.includes('B1000') || desc.includes('B0')) score += 80; if (desc.includes('ADC') || desc.includes('TRACE')) score += 60; // Prefer moderate slice counts (20-50 is typical for structural) if (s.slice_count > 200) score += 50; if (s.slice_count > 500) score += 50; return { ...s, score }; }); // Sort by score scored.sort((a, b) => a.score - b.score); // For panel 0, pick best. For panel 1+, pick next best with SAME orientation if (panelIdx === 0) { return scored[0]; } else { // Get orientation of first panel's pick const firstPick = scored[0]; const firstDesc = firstPick.series_desc.toUpperCase(); let firstOrientation = 'AX'; if (firstDesc.includes('SAG')) firstOrientation = 'SAG'; else if (firstDesc.includes('COR')) firstOrientation = 'COR'; // Find next best with same orientation (excluding first pick) const sameOrientation = scored.filter(s => { if (s.id === firstPick.id) return false; const desc = s.series_desc.toUpperCase(); if (firstOrientation === 'SAG') return desc.includes('SAG'); if (firstOrientation === 'COR') return desc.includes('COR'); return desc.includes('AX') || (!desc.includes('SAG') && !desc.includes('COR')); }); return sameOrientation[panelIdx - 1] || sameOrientation[0] || scored[panelIdx] || scored[0]; } } function showRectFromInput() { const input = document.getElementById('rectCoords').value; const debug = document.getElementById('debugInfo'); debug.textContent = 'Parsing: ' + input; // Try to parse full reference: "STUDYDATE STUDYDESC / SERIESDESC slice N: (x1,y1)-(x2,y2)" const fullMatch = input.match(/^(\d{8})\s+(.+?)\s+\|\s+(.+?)\s+slice\s+(\d+):\s*\((\d+),(\d+)\)-\((\d+),(\d+)\)$/); if (fullMatch) { const [_, studyDate, studyDesc, seriesDesc, sliceNum, x1, y1, x2, y2] = fullMatch; debug.textContent = 'Match! date=' + studyDate + ' study=' + studyDesc + ' series=' + seriesDesc + ' slice=' + sliceNum; // Find matching study const study = studies.find(s => s.study_date === studyDate && s.study_desc.includes(studyDesc.trim())); if (!study) { debug.textContent = 'Study not found. Looking for date=' + studyDate + ', desc contains: ' + studyDesc.trim(); debug.textContent += ' | Available: ' + studies.map(s => s.study_date + '/' + s.study_desc).join(', '); return; } debug.textContent = 'Found study id=' + study.id; document.getElementById('studySelect').value = study.id; fetch(addToken('/api/series?study=' + study.id)) .then(res => res.json()) .then(series => { seriesList = series; debug.textContent = 'Series loaded: ' + series.map(s => s.series_desc).join(', '); const targetSeries = series.find(s => s.series_desc.trim() === seriesDesc.trim()); if (!targetSeries) { debug.textContent = 'Series not found: "' + seriesDesc.trim() + '"'; return; } debug.textContent = 'Found series id=' + targetSeries.id; setPanels(1); setTimeout(() => { const panel = document.getElementById('panel-0'); const select = panel.querySelector('select'); if (select) select.value = targetSeries.id; loadSeries(0, targetSeries.id).then(() => { const sliceIdx = panels[0].slices.findIndex(s => s.instance_number == parseInt(sliceNum)); debug.textContent = 'Slice idx=' + sliceIdx + ' for instance=' + sliceNum; if (sliceIdx >= 0) { goToSlice(0, sliceIdx); setTimeout(() => drawRect(0, parseInt(x1), parseInt(y1), parseInt(x2), parseInt(y2)), 100); } }); }, 50); }); return; } debug.textContent = 'No full match, trying coords only...'; // Fallback: just coordinates const match = input.match(/\((\d+),(\d+)\)-\((\d+),(\d+)\)/); if (!match) { debug.textContent = 'No coord match either'; return; } const x1 = parseInt(match[1]), y1 = parseInt(match[2]); const x2 = parseInt(match[3]), y2 = parseInt(match[4]); debug.textContent = 'Drawing rect: ' + x1 + ',' + y1 + ' to ' + x2 + ',' + y2; const panelIdx = activePanel !== null ? activePanel : 0; drawRect(panelIdx, x1, y1, x2, y2); } function drawRect(panelIdx, x1, y1, x2, y2) { const panel = document.getElementById('panel-' + panelIdx); if (!panel) return; const img = panel.querySelector('.panel-content img'); const rectOverlay = panel.querySelector('.rect-overlay'); const rect = img.getBoundingClientRect(); // Divide out zoom since rect overlay is inside the transformed wrapper const orientation = getPanelOrientation(panelIdx); const zoom = zoomLevels[zoomState[orientation].level]; const scaleX = rect.width / img.naturalWidth / zoom; const scaleY = rect.height / img.naturalHeight / zoom; rectOverlay.style.left = (x1 * scaleX) + 'px'; rectOverlay.style.top = (y1 * scaleY) + 'px'; rectOverlay.style.width = ((x2 - x1) * scaleX) + 'px'; rectOverlay.style.height = ((y2 - y1) * scaleY) + 'px'; rectOverlay.style.display = 'block'; currentRect = { panelIdx, x1, y1, x2, y2 }; activePanel = panelIdx; } function copyCoords() { const input = document.getElementById('rectCoords'); input.select(); document.execCommand('copy'); } function clearRect() { document.querySelectorAll('.rect-overlay').forEach(r => r.style.display = 'none'); document.getElementById('rectCoords').value = ''; currentRect = null; } async function loadSeries(panelIdx, seriesId) { if (!seriesId) return; const res = await fetch(addToken('/api/slices?series=' + seriesId)); const slices = await res.json(); panels[panelIdx].seriesId = seriesId; panels[panelIdx].slices = slices; panels[panelIdx].currentSlice = 0; initWLState(seriesId, slices); const panel = document.getElementById('panel-' + panelIdx); const thumbs = panel.querySelector('.thumbnails'); // Create W/L presets + scrubber const midSliceId = slices[Math.floor(slices.length / 2)]?.id; const presetsHtml = wlPresets.map((p, i) => '
' + '' + '' + p.name + '
' ).join(''); const scrubberHtml = '
' + '
Slice 1 / ' + slices.length + '
' + '
' + '
' + '
' + '
' + '
1' + slices.length + '
' + '
'; thumbs.innerHTML = '
' + presetsHtml + '
' + scrubberHtml; // Setup scrubber interaction setupScrubber(panelIdx); // Preload all slice images for smooth scrolling slices.forEach(s => { const img = new Image(); img.src = getImageUrlWithWL(s.id, seriesId, null, null); }); // Start at middle slice const midSlice = Math.floor(slices.length / 2); goToSlice(panelIdx, midSlice); } function update3DCrosshairs() { if (!is3DMode) return; const getData = (p) => { if (!p || !p.slices.length) return null; const s = p.slices[p.currentSlice]; // Parse orientation: "Rx\Ry\Rz\Cx\Cy\Cz" let rowVec = [1,0,0], colVec = [0,1,0]; if (s.image_orientation) { const parts = s.image_orientation.split('\\').map(Number); if (parts.length === 6) { rowVec = [parts[0], parts[1], parts[2]]; colVec = [parts[3], parts[4], parts[5]]; } } // Compute CENTER of slice (not corner) const psRow = s.pixel_spacing_row || 0.5; const psCol = s.pixel_spacing_col || 0.5; const halfWidth = (s.cols / 2) * psCol; const halfHeight = (s.rows / 2) * psRow; const centerX = s.pos_x + halfWidth * rowVec[0] + halfHeight * colVec[0]; const centerY = s.pos_y + halfWidth * rowVec[1] + halfHeight * colVec[1]; const centerZ = s.pos_z + halfWidth * rowVec[2] + halfHeight * colVec[2]; return { pos_x: s.pos_x, pos_y: s.pos_y, pos_z: s.pos_z, slice_loc: s.slice_location, center_x: centerX, center_y: centerY, center_z: centerZ, rows: s.rows, cols: s.cols, psRow: psRow, psCol: psCol, rowVec: rowVec, colVec: colVec }; }; // Get reference data from FIRST slice of each series (fixed reference frame) const getRefData = (panel) => { if (!panel || !panel.slices || !panel.slices.length) return null; const s = panel.slices[0]; // Use first slice as reference return { pos_x: s.pos_x, pos_y: s.pos_y, pos_z: s.pos_z, rows: s.rows, cols: s.cols, psRow: s.pixel_spacing_row, psCol: s.pixel_spacing_col }; }; const sagPanel = panels.find(p => p.orientation === 'SAG'); const axPanel = panels.find(p => p.orientation === 'AX'); const corPanel = panels.find(p => p.orientation === 'COR'); // Current slice_location from each panel (through-plane position) const sagLoc = sagPanel?.slices[sagPanel.currentSlice]?.slice_location || 0; // X const axLoc = axPanel?.slices[axPanel.currentSlice]?.slice_location || 0; // Z const corLoc = corPanel?.slices[corPanel.currentSlice]?.slice_location || 0; // Y // Reference frames (first slice of each series) const sagRef = getRefData(sagPanel); const axRef = getRefData(axPanel); const corRef = getRefData(corPanel); panels.forEach((p, idx) => { console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); if (!p.slices.length || !p.orientation) return; const div = document.getElementById('panel-' + idx); const img = div.querySelector('.panel-content img'); const hLine = div.querySelector('.crosshair-h'); const vLine = div.querySelector('.crosshair-v'); console.log("Panel " + idx + ": naturalWidth=" + img.naturalWidth); if (!img.naturalWidth) { hLine.style.display = 'none'; vLine.style.display = 'none'; return; } // DEBUG: Fixed position at top-left corner hLine.style.top = '0px'; hLine.style.display = 'block'; vLine.style.left = '0px'; vLine.style.display = 'block'; }); } function goToSlice(panelIdx, sliceIdx) { const panel = panels[panelIdx]; if (!panel.slices.length) return; panel.currentSlice = sliceIdx; const div = document.getElementById('panel-' + panelIdx); const img = div.querySelector('.panel-content img'); img.onload = () => detectImageBrightness(img, panelIdx); img.src = getImageUrl(panel.slices[sliceIdx].id, panel.seriesId); // Clear rectangle when changing slice div.querySelector('.rect-overlay').style.display = 'none'; div.querySelectorAll('.thumb').forEach((t, i) => t.classList.toggle('active', i === sliceIdx)); // Update scrubber position updateScrubber(panelIdx, sliceIdx); updateOverlay(panelIdx); // Update crosshairs in 3D mode if (is3DMode) { setTimeout(update3DCrosshairs, 50); } if (document.getElementById('syncScroll').checked && !is3DMode) { const loc = panel.slices[sliceIdx].slice_location; panels.forEach((p, i) => { if (i !== panelIdx && p.slices.length) { const closest = p.slices.reduce((prev, curr, idx) => Math.abs(curr.slice_location - loc) < Math.abs(p.slices[prev].slice_location - loc) ? idx : prev, 0); if (p.currentSlice !== closest) { p.currentSlice = closest; const pDiv = document.getElementById('panel-' + i); pDiv.querySelector('.panel-content img').src = getImageUrl(p.slices[closest].id, p.seriesId); pDiv.querySelectorAll('.thumb').forEach((t, j) => t.classList.toggle('active', j === closest)); updateOverlay(i); } } }); } } // Track hovered panel for keyboard zoom document.addEventListener('mousemove', (e) => { panels.forEach((p, idx) => { console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); const div = document.getElementById('panel-' + idx); if (div) { const rect = div.getBoundingClientRect(); if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) { hoveredPanel = idx; } } }); }, { passive: true }); document.addEventListener('wheel', e => { if (!panels.length) return; // Find which panel the mouse is over let targetPanel = -1; panels.forEach((p, idx) => { console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); const div = document.getElementById('panel-' + idx); if (div) { const rect = div.getBoundingClientRect(); if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) { targetPanel = idx; } } }); if (targetPanel < 0) return; hoveredPanel = targetPanel; // Track cursor relative to wrapper (for zoom-to-cursor) // Account for current zoom since getBoundingClientRect returns transformed bounds const div = document.getElementById('panel-' + targetPanel); const wrapper = div.querySelector('.img-wrapper'); const wrapperRect = wrapper.getBoundingClientRect(); const orientation = getPanelOrientation(targetPanel); const currentZoom = zoomLevels[zoomState[orientation].level]; cursorX = (e.clientX - wrapperRect.left) / currentZoom; cursorY = (e.clientY - wrapperRect.top) / currentZoom; // Shift+wheel = zoom if (e.shiftKey) { e.preventDefault(); const delta = e.shiftKey && e.deltaY === 0 ? e.deltaX : e.deltaY; if (delta < 0) { zoomIn(targetPanel); } else if (delta > 0) { zoomOut(targetPanel); } return; } // Regular wheel = scroll slices const delta = e.deltaY > 0 ? 1 : -1; const p = panels[targetPanel]; if (!p.slices.length) return; const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta)); if (newIdx !== p.currentSlice) goToSlice(targetPanel, newIdx); }, { passive: false }); document.addEventListener('keydown', e => { if (e.key === 'Escape') { if (document.getElementById('tourOverlay').classList.contains('show')) { endTour(); } else if (document.getElementById('helpModal').classList.contains('show')) { toggleHelp(); } else { clearRect(); } return; } if (!panels.length) return; // +/- for zoom (affects hovered panel's orientation group) if (e.key === '+' || e.key === '=') { e.preventDefault(); zoomIn(hoveredPanel); return; } if (e.key === '-' || e.key === '_') { e.preventDefault(); zoomOut(hoveredPanel); return; } // Arrow keys for slice navigation let delta = 0; if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') delta = -1; if (e.key === 'ArrowDown' || e.key === 'ArrowRight') delta = 1; if (delta === 0) return; e.preventDefault(); const p = panels[0]; const newIdx = Math.max(0, Math.min(p.slices.length - 1, p.currentSlice + delta)); if (newIdx !== p.currentSlice) goToSlice(0, newIdx); }); // Cancel drawing if mouse leaves window document.addEventListener('mouseup', (e) => { isDrawing = false; if (isPanning) { isPanning = false; // Restore transition document.querySelectorAll('.img-wrapper').forEach(w => w.style.transition = ''); document.querySelectorAll('.panel-content').forEach(c => c.classList.remove('panning')); } }); // Shift+click pan document.addEventListener('mousedown', (e) => { if (e.button !== 0 || !e.shiftKey) return; e.preventDefault(); // Find hovered panel let targetPanel = -1; panels.forEach((p, idx) => { console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); const div = document.getElementById('panel-' + idx); if (div) { const rect = div.getBoundingClientRect(); if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) { targetPanel = idx; } } }); if (targetPanel < 0) return; const orientation = getPanelOrientation(targetPanel); const state = zoomState[orientation]; if (state.level === 0) return; // no pan at 1x zoom isPanning = true; panOrientation = orientation; panStartMouseX = e.clientX; panStartMouseY = e.clientY; panStartPanX = state.panX; panStartPanY = state.panY; // Disable transition during pan for smooth movement panels.forEach((p, idx) => { console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); if (getPanelOrientation(idx) === orientation) { const div = document.getElementById('panel-' + idx); if (div) div.querySelector('.img-wrapper').style.transition = 'none'; } }); document.querySelectorAll('.panel-content').forEach(c => c.classList.add('panning')); }); document.addEventListener('mousemove', (e) => { if (!isPanning || !panOrientation) return; const state = zoomState[panOrientation]; const zoom = zoomLevels[state.level]; // With transform: scale(zoom) translate(panX, panY), translate values are scaled // Divide by zoom for 1:1 screen-to-image movement const dx = (e.clientX - panStartMouseX) / zoom; const dy = (e.clientY - panStartMouseY) / zoom; state.panX = panStartPanX + dx; state.panY = panStartPanY + dy; applyZoom(panOrientation); }); // Double-click to reset zoom document.addEventListener('dblclick', (e) => { // Find hovered panel let targetPanel = -1; panels.forEach((p, idx) => { console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); const div = document.getElementById('panel-' + idx); if (div) { const rect = div.getBoundingClientRect(); if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) { targetPanel = idx; } } }); if (targetPanel < 0) return; const orientation = getPanelOrientation(targetPanel); resetZoom(orientation); }); // Ctrl+click for Window/Level adjustment document.addEventListener("mousedown", (e) => { if (e.button !== 2 || e.shiftKey) return; // right-click only, not with shift e.preventDefault(); // Find hovered panel let targetPanel = -1; panels.forEach((p, idx) => { console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); const div = document.getElementById("panel-" + idx); if (div) { const rect = div.getBoundingClientRect(); if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) { targetPanel = idx; } } }); if (targetPanel < 0) return; const panel = panels[targetPanel]; if (!panel || !panel.seriesId || !wlState[panel.seriesId]) return; isAdjustingWL = true; isDrawing = false; // Prevent rect drawing document.querySelectorAll(".rect-overlay").forEach(r => r.style.display = "none"); wlPanel = targetPanel; wlStartX = e.clientX; wlStartY = e.clientY; wlStartWc = wlState[panel.seriesId].wc; wlStartWw = wlState[panel.seriesId].ww; document.body.style.cursor = "crosshair"; // Show hint const hint = document.getElementById('wlHint'); hint.style.left = (e.clientX + 15) + 'px'; hint.style.top = (e.clientY - 10) + 'px'; hint.classList.add('show'); }); document.addEventListener("mousemove", (e) => { if (!isAdjustingWL || wlPanel < 0) return; const panel = panels[wlPanel]; if (!panel || !panel.seriesId) return; const state = wlState[panel.seriesId]; if (!state) return; // Horizontal = width, Vertical = center const dx = e.clientX - wlStartX; const dy = e.clientY - wlStartY; state.ww = Math.max(1, wlStartWw + dx * 2); state.wc = wlStartWc - dy * 2; // invert: drag up = brighter state.adjusted = true; // Update overlay C/W values in real-time const div = document.getElementById("panel-" + wlPanel); const wcEl = div.querySelector(".overlay-wc"); const wwEl = div.querySelector(".overlay-ww"); if (wcEl) wcEl.textContent = Math.round(state.wc); if (wwEl) wwEl.textContent = Math.round(state.ww); // Debounce image reload if (wlDebounceTimer) clearTimeout(wlDebounceTimer); wlDebounceTimer = setTimeout(() => { const img = div.querySelector(".panel-content img"); img.src = getImageUrl(panel.slices[panel.currentSlice].id, panel.seriesId); }, 150); }); document.addEventListener("mouseup", (e) => { if (isAdjustingWL) { isAdjustingWL = false; document.body.style.cursor = ""; document.getElementById('wlHint').classList.remove('show'); if (wlDebounceTimer) clearTimeout(wlDebounceTimer); if (wlPanel >= 0) { reloadPanelImages(wlPanel); } wlPanel = -1; } }); // Track right-click for double-click detection let lastRightClickTime = 0; let lastRightClickPanel = -1; // Double right-click to reset Window/Level document.addEventListener("mousedown", (e) => { if (e.button !== 2) return; let targetPanel = -1; panels.forEach((p, idx) => { console.log("Panel " + idx + ": orientation=" + p.orientation + " slices=" + (p.slices ? p.slices.length : 0)); const div = document.getElementById("panel-" + idx); if (div) { const rect = div.getBoundingClientRect(); if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) { targetPanel = idx; } } }); if (targetPanel < 0) return; const now = Date.now(); if (targetPanel === lastRightClickPanel && now - lastRightClickTime < 400) { // Double right-click detected - reset W/L resetWL(targetPanel); lastRightClickTime = 0; lastRightClickPanel = -1; e.preventDefault(); return; } lastRightClickTime = now; lastRightClickPanel = targetPanel; }); // Update crosshairs on window resize // Prevent context menu on panels for right-click W/L adjustment document.addEventListener("contextmenu", (e) => { if (!e.target.closest("#panels")) return; e.preventDefault(); }); window.addEventListener('resize', () => { if (is3DMode) update3DCrosshairs(); }); // W/L Preset functions function applyWLPreset(el) { const panelIdx = parseInt(el.dataset.panel); const wcAttr = el.dataset.wc; const wwAttr = el.dataset.ww; const panel = panels[panelIdx]; if (!panel || !panel.seriesId) return; // Update wlState - null means reset to original if (wcAttr === 'null' || wwAttr === 'null') { wlState[panel.seriesId].wc = wlState[panel.seriesId].originalWc; wlState[panel.seriesId].ww = wlState[panel.seriesId].originalWw; wlState[panel.seriesId].adjusted = false; } else { wlState[panel.seriesId].wc = parseInt(wcAttr); wlState[panel.seriesId].ww = parseInt(wwAttr); wlState[panel.seriesId].adjusted = true; } // Update active preset const container = el.closest('.thumbnails'); container.querySelectorAll('.wl-preset').forEach(p => p.classList.remove('active')); el.classList.add('active'); // Reload image reloadPanelImages(panelIdx); } function setupScrubber(panelIdx) { const panel = document.getElementById('panel-' + panelIdx); const scrubber = panel.querySelector('.slice-scrubber'); if (!scrubber) return; const track = scrubber.querySelector('.scrubber-track'); let isDragging = false; const updateFromPosition = (e) => { const rect = track.getBoundingClientRect(); const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); const pct = x / rect.width; const sliceCount = panels[panelIdx].slices.length; const sliceIdx = Math.round(pct * (sliceCount - 1)); goToSlice(panelIdx, sliceIdx); }; track.addEventListener('mousedown', (e) => { isDragging = true; updateFromPosition(e); e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (isDragging) updateFromPosition(e); }); document.addEventListener('mouseup', () => { isDragging = false; }); } function updateScrubber(panelIdx, sliceIdx) { const panel = document.getElementById('panel-' + panelIdx); if (!panel) return; const scrubber = panel.querySelector('.slice-scrubber'); if (!scrubber) return; const sliceCount = panels[panelIdx].slices.length; const pct = sliceCount > 1 ? (sliceIdx / (sliceCount - 1)) * 100 : 0; const fill = scrubber.querySelector('.scrubber-fill'); const handle = scrubber.querySelector('.scrubber-handle'); const current = scrubber.querySelector('.scrubber-current'); if (fill) fill.style.width = pct + '%'; if (handle) handle.style.left = pct + '%'; if (current) current.textContent = sliceIdx + 1; } init();