inou/static/viewer_js

1647 lines
58 KiB
Plaintext

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: '<div style="font-size:48px;text-align:center;margin:15px 0;color:#B45309;font-family:Noto Sans JP,sans-serif">&#x7570;&#x80FD;</div><div style="text-align:center;color:#888;margin-bottom:15px">"extraordinary ability"</div>Explore medical imaging with AI assistance.<br><br>Currently supports <b>MRI</b>, <b>CT</b>, and <b>X-Ray</b>.<br><span style="color:#888">Need other modalities? requests@inou.com</span>',
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 = '<h3>' + step.title + '</h3><p>' + step.text + '</p>' +
'<div class="tour-nav">' +
(isLastStep ? '<span></span>' : '<button class="tour-skip" onclick="endTour()">Skip</button>') +
'<span class="tour-step">' + (tourIndex + 1) + ' / ' + tourSteps.length + '</span>' +
'<button onclick="nextTourStep()">' + (isLastStep ? 'Done' : 'Next') + '</button>' +
'</div>';
// 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 = '<span class="series-name">' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')</span>';
} else {
headerContent = '<select onchange="loadSeries(' + idx + ', this.value)">' +
'<option value="">Select series...</option>' +
seriesList.map(s => '<option value="' + s.id + '">' + s.series_desc + ' (' + s.slice_count + ')</option>').join('') +
'</select>';
}
div.innerHTML =
'<div class="panel-header">' + headerContent + '</div>' +
'<div class="panel-content"><div class="img-wrapper"><img><div class="rect-overlay" style="display:none"></div></div><div class="image-overlay"><div class="overlay-top-left"><span class="overlay-patient"></span><span class="overlay-accession"></span><span class="overlay-study-desc"></span><span class="overlay-series"></span><div class="overlay-slice-box"><span class="overlay-slice-num"></span><span class="overlay-slice-total"></span></div></div><div class="overlay-top-right"><span class="overlay-datetime"></span><span class="overlay-institution"></span><span class="overlay-pos"></span><span class="overlay-thickness"></span><div class="overlay-wl"><div class="overlay-wl-item"><span class="overlay-wl-label">C</span><span class="overlay-wl-value overlay-wc"></span></div><div class="overlay-wl-divider"></div><div class="overlay-wl-item"><span class="overlay-wl-label">W</span><span class="overlay-wl-value overlay-ww"></span></div></div><span class="overlay-zoom"></span></div><span class="overlay-orient overlay-orient-left"></span><span class="overlay-orient overlay-orient-right"></span><span class="overlay-orient overlay-orient-top"></span><span class="overlay-orient overlay-orient-bottom"></span></div></div>' +
'<div class="thumbnails"></div>';
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 =
'<div class="panel-header">' +
'<span style="color:#B45309;margin-right:10px;font-weight:bold">' + orientation + '</span>' +
'<select onchange="loadSeries(' + idx + ', this.value)">' +
'<option value="">Select ' + orientation + ' series...</option>' +
(seriesOptions || []).map(s => '<option value="' + s.id + '">' + s.series_desc + ' (' + s.slice_count + ')</option>').join('') +
'</select></div>' +
'<div class="panel-content"><div class="img-wrapper">' +
'<img>' +
'<div class="crosshair-h" style="display:none"></div>' +
'<div class="crosshair-v" style="display:none"></div>' +
'<div class="rect-overlay" style="display:none"></div>' +
'</div><div class="image-overlay"><div class="overlay-top-left"><span class="overlay-patient"></span><span class="overlay-accession"></span><span class="overlay-study-desc"></span><span class="overlay-series"></span><div class="overlay-slice-box"><span class="overlay-slice-num"></span><span class="overlay-slice-total"></span></div></div><div class="overlay-top-right"><span class="overlay-datetime"></span><span class="overlay-institution"></span><span class="overlay-pos"></span><span class="overlay-thickness"></span><div class="overlay-wl"><div class="overlay-wl-item"><span class="overlay-wl-label">C</span><span class="overlay-wl-value overlay-wc"></span></div><div class="overlay-wl-divider"></div><div class="overlay-wl-item"><span class="overlay-wl-label">W</span><span class="overlay-wl-value overlay-ww"></span></div></div><span class="overlay-zoom"></span></div><span class="overlay-orient overlay-orient-left"></span><span class="overlay-orient overlay-orient-right"></span><span class="overlay-orient overlay-orient-top"></span><span class="overlay-orient overlay-orient-bottom"></span></div></div>' +
'<div class="thumbnails"></div>';
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) => {
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 = '<span class="series-name">' + seriesList[0].series_desc + ' (' + seriesList[0].slice_count + ')</span>';
} else {
headerContent = '<select onchange="loadSeries(' + idx + ', this.value)">' +
'<option value="">Select series...</option>' +
seriesList.map(s => '<option value="' + s.id + '">' + s.series_desc + ' (' + s.slice_count + ')</option>').join('') +
'</select>';
}
div.innerHTML =
'<div class="panel-header">' + headerContent + '</div>' +
'<div class="panel-content"><div class="img-wrapper"><img><div class="rect-overlay" style="display:none"></div></div><div class="image-overlay"><div class="overlay-top-left"><span class="overlay-patient"></span><span class="overlay-accession"></span><span class="overlay-study-desc"></span><span class="overlay-series"></span><div class="overlay-slice-box"><span class="overlay-slice-num"></span><span class="overlay-slice-total"></span></div></div><div class="overlay-top-right"><span class="overlay-datetime"></span><span class="overlay-institution"></span><span class="overlay-pos"></span><span class="overlay-thickness"></span><div class="overlay-wl"><div class="overlay-wl-item"><span class="overlay-wl-label">C</span><span class="overlay-wl-value overlay-wc"></span></div><div class="overlay-wl-divider"></div><div class="overlay-wl-item"><span class="overlay-wl-label">W</span><span class="overlay-wl-value overlay-ww"></span></div></div><span class="overlay-zoom"></span></div><span class="overlay-orient overlay-orient-left"></span><span class="overlay-orient overlay-orient-right"></span><span class="overlay-orient overlay-orient-top"></span><span class="overlay-orient overlay-orient-bottom"></span></div></div>' +
'<div class="thumbnails"></div>';
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 + '&thumbs=false'));
const data = await res.json();
const slices = data.slices || data;
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) =>
'<div class="wl-preset' + (i === 0 ? ' active' : '') + '" data-panel="' + panelIdx + '" data-wc="' + p.wc + '" data-ww="' + p.ww + '" onclick="applyWLPreset(this)">' +
'<img src="' + getImageUrlWithWL(midSliceId, seriesId, p.wc, p.ww) + '">' +
'<span>' + p.name + '</span></div>'
).join('');
const scrubberHtml =
'<div class="slice-scrubber" data-panel="' + panelIdx + '">' +
'<div class="scrubber-label">Slice <span class="scrubber-current">1</span> / ' + slices.length + '</div>' +
'<div class="scrubber-track">' +
'<div class="scrubber-fill"></div>' +
'<div class="scrubber-handle"></div>' +
'</div>' +
'<div class="scrubber-ticks"><span>1</span><span>' + slices.length + '</span></div>' +
'</div>';
thumbs.innerHTML = '<div class="wl-presets">' + presetsHtml + '</div>' + 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,
center_x: centerX,
center_y: centerY,
center_z: centerZ,
rows: s.rows,
cols: s.cols,
psRow: psRow,
psCol: psCol,
rowVec: rowVec,
colVec: colVec
};
};
const dot = (a, b) => a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
const sagPanel = panels.find(p => p.orientation === 'SAG');
const axPanel = panels.find(p => p.orientation === 'AX');
const corPanel = panels.find(p => p.orientation === 'COR');
const sagData = getData(sagPanel);
const axData = getData(axPanel);
const corData = getData(corPanel);
panels.forEach((p, idx) => {
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');
if (!img.naturalWidth) {
hLine.style.display = 'none';
vLine.style.display = 'none';
return;
}
const myData = getData(p);
const rect = img.getBoundingClientRect();
// Divide out zoom since crosshairs are inside the transformed wrapper
const zoom = zoomLevels[zoomState[p.orientation].level];
const scaleX = rect.width / img.naturalWidth / zoom;
const scaleY = rect.height / img.naturalHeight / zoom;
// Build target point from CENTER of other slices
// SAG through-plane = X, AX through-plane = Z, COR through-plane = Y
let targetX = myData.center_x, targetY = myData.center_y, targetZ = myData.center_z;
if (sagData && p.orientation !== 'SAG') targetX = sagData.center_x;
if (axData && p.orientation !== 'AX') targetZ = axData.center_z;
if (corData && p.orientation !== 'COR') targetY = corData.center_y;
// Offset from corner to target
const offset = [targetX - myData.pos_x, targetY - myData.pos_y, targetZ - myData.pos_z];
// Project onto row/col directions
const vPixel = dot(offset, myData.rowVec) / myData.psCol;
const hPixel = dot(offset, myData.colVec) / myData.psRow;
if (hPixel >= 0 && hPixel <= myData.rows) {
hLine.style.top = (hPixel * scaleY) + 'px';
hLine.style.display = 'block';
} else {
hLine.style.display = 'none';
}
if (vPixel >= 0 && vPixel <= myData.cols) {
vLine.style.left = (vPixel * scaleX) + 'px';
vLine.style.display = 'block';
} else {
vLine.style.display = 'none';
}
});
}
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) => {
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) => {
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) => {
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) => {
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) => {
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) => {
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) => {
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();