1647 lines
58 KiB
Plaintext
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">異能</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();
|