feat(tasks): Add seriesId to stacks in Case model and enhance series viewer with navigation buttons

This commit is contained in:
Ross
2026-05-18 08:33:46 +01:00
parent 1c76e0696c
commit 93e24bf855
5 changed files with 215 additions and 31 deletions
+199 -21
View File
@@ -18,10 +18,18 @@
</button>
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
<button type="button"
class="btn btn-outline-info btn-xs btn-sm series-open-viewer-btn"
title="Open series in viewer"
data-series-id="{{series.pk}}">
<i class="bi bi-eye"></i> View
class="btn btn-outline-info btn-xs btn-sm series-open-primary-btn"
title="Open series in primary viewport"
data-series-id="{{series.pk}}"
data-series-name="{{ series|escape }}">
<i class="bi bi-eye"></i> Open
</button>
<button type="button"
class="btn btn-outline-info btn-xs btn-sm series-open-new-viewport-btn"
title="Add a viewport and open series"
data-series-id="{{series.pk}}"
data-series-name="{{ series|escape }}">
<i class="bi bi-layout-split"></i> Open + viewport
</button>
<a href="{% url 'atlas:series_detail' pk=series.pk %}"
class="btn btn-outline-secondary btn-xs btn-sm"
@@ -1339,32 +1347,202 @@
}
})();
// Series viewer button handler: open series in viewer or navigate to series page
// Series viewer button handlers: open series in primary viewport or new viewport.
(function initSeriesViewerButtons() {
if (window.__seriesViewerButtonsBound) return;
window.__seriesViewerButtonsBound = true;
document.querySelectorAll('.series-open-viewer-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
function getViewerApi(baseName, fallbackName) {
const fn = window[`${baseName}_main_viewer`] || (fallbackName ? window[fallbackName] : null);
return typeof fn === 'function' ? fn : null;
}
function ensureViewerOpen() {
const viewerDetails = document.getElementById('dicom-viewer-details');
if (viewerDetails && !viewerDetails.hasAttribute('open')) {
viewerDetails.setAttribute('open', 'open');
viewerDetails.dispatchEvent(new Event('toggle', { bubbles: true }));
}
}
function flattenNamedStacks(raw) {
const out = [];
if (!Array.isArray(raw)) {
return out;
}
raw.forEach((item) => {
if (item && typeof item === 'object' && Array.isArray(item.stacks)) {
const caseId = item.caseId;
const studyId = item.studyId;
item.stacks.forEach((stack) => {
if (stack && Array.isArray(stack.imageIds)) {
out.push({
imageIds: stack.imageIds,
name: stack.name || '',
caseId: stack.caseId || caseId,
studyId: stack.studyId || studyId,
seriesId: stack.seriesId || null,
});
}
});
} else if (item && typeof item === 'object' && Array.isArray(item.imageIds)) {
out.push({
imageIds: item.imageIds,
name: item.name || '',
caseId: item.caseId || null,
studyId: item.studyId || null,
seriesId: item.seriesId || null,
});
}
});
return out;
}
function getNamedStacksFromMainViewer() {
const mainViewer = document.getElementById('main_viewer');
if (!mainViewer) {
return [];
}
const raw = mainViewer.getAttribute('data-named-stacks') || '[]';
try {
return flattenNamedStacks(JSON.parse(raw));
} catch (e) {
console.warn('Failed to parse data-named-stacks', e);
return [];
}
}
function findStackBySeries(stacks, seriesId, seriesName) {
const idNum = Number(seriesId);
let match = stacks.find((stack) => Number(stack.seriesId) === idNum);
if (!match && seriesName) {
match = stacks.find((stack) => String(stack.name || '').trim() === String(seriesName).trim());
}
return match || null;
}
async function openSeriesInPrimary(seriesId, seriesName) {
ensureViewerOpen();
const mainViewer = document.getElementById('main_viewer');
if (!mainViewer) {
return;
}
const stacks = getNamedStacksFromMainViewer();
const target = findStackBySeries(stacks, seriesId, seriesName);
if (!target) {
alert('Series stack not found in this case viewer.');
return;
}
const reordered = [target, ...stacks.filter((s) => s !== target)];
mainViewer.setAttribute('data-named-stacks', JSON.stringify(reordered));
try {
await mountDicomViewersSafely();
} catch (e) {
console.warn('Failed to remount viewer', e);
}
}
async function openSeriesInNewViewport(seriesId, seriesName) {
ensureViewerOpen();
const stacks = getNamedStacksFromMainViewer();
const target = findStackBySeries(stacks, seriesId, seriesName);
if (!target) {
alert('Series stack not found in this case viewer.');
return;
}
const exportStateFn = getViewerApi('exportViewerState');
const importStateFn = getViewerApi('importViewerState');
if (!exportStateFn || !importStateFn) {
// Fallback: at least add stack to viewer list and auto-fill an empty viewport if available.
const loadAdditionalStackFn = getViewerApi('loadAdditionalStack');
if (loadAdditionalStackFn) {
await loadAdditionalStackFn(target.imageIds, target.studyId);
}
return;
}
let state = {};
try {
state = JSON.parse(exportStateFn() || '{}');
} catch (e) {
state = {};
}
const currentStacks = Array.isArray(state.stacks) ? [...state.stacks] : [];
const toNorm = (ids) => (Array.isArray(ids) ? ids.map((id) => String(id).replace(/^wadouri:/, '')) : []);
const targetNorm = toNorm(target.imageIds);
let targetIdx = currentStacks.findIndex((stack) => {
const ids = Array.isArray(stack?.i) ? stack.i : [];
const norm = toNorm(ids);
return norm.length === targetNorm.length && norm.every((id, i) => id === targetNorm[i]);
});
if (targetIdx < 0) {
currentStacks.push({ i: target.imageIds, u: target.studyId || undefined });
targetIdx = currentStacks.length - 1;
}
const grid = state.grid && Number.isFinite(state.grid.rows) && Number.isFinite(state.grid.cols)
? { rows: Math.max(1, Number(state.grid.rows)), cols: Math.max(1, Number(state.grid.cols)) }
: { rows: 1, cols: 1 };
if ((grid.rows * grid.cols) < 2) {
grid.cols = 2;
}
const numActive = Math.max(2, grid.rows * grid.cols);
const stackIdx = Array.isArray(state.stackIdx) ? [...state.stackIdx] : [];
const modes = Array.isArray(state.modes) ? [...state.modes] : [];
while (stackIdx.length < numActive) {
stackIdx.push(0);
}
while (modes.length < numActive) {
modes.push('stack');
}
stackIdx[1] = targetIdx;
modes[0] = 'stack';
modes[1] = 'stack';
const nextState = {
...state,
grid,
stacks: currentStacks,
stackIdx,
modes,
};
importStateFn(JSON.stringify(nextState));
}
document.querySelectorAll('.series-open-primary-btn').forEach((btn) => {
btn.addEventListener('click', async function (e) {
e.preventDefault();
const seriesId = this.getAttribute('data-series-id');
const seriesName = this.getAttribute('data-series-name') || '';
if (!seriesId) {
alert('Series ID not found');
return;
}
// Focus the viewer details element to open it
const viewerDetails = document.getElementById('dicom-viewer-details');
if (viewerDetails && !viewerDetails.hasAttribute('open')) {
viewerDetails.setAttribute('open', 'open');
// Trigger the toggle event to ensure viewer initializes
viewerDetails.dispatchEvent(new Event('toggle', { bubbles: true }));
await openSeriesInPrimary(seriesId, seriesName);
});
});
document.querySelectorAll('.series-open-new-viewport-btn').forEach((btn) => {
btn.addEventListener('click', async function (e) {
e.preventDefault();
const seriesId = this.getAttribute('data-series-id');
const seriesName = this.getAttribute('data-series-name') || '';
if (!seriesId) {
return;
}
// Note: Direct series loading will be implemented via API in the future.
// For now, this just opens and focuses the viewer.
// When the series page viewer API is available, we can load the specific series here:
// window.loadSeriesInViewer(seriesId);
await openSeriesInNewViewport(seriesId, seriesName);
});
});
})();
+10 -8
View File
@@ -5,6 +5,16 @@
<div class="row">
<div class="col-12 mb-3">
<div class="card shadow-sm">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
<div class="small text-uppercase text-muted fw-semibold">Series Navigation</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-outline-secondary btn-sm" href="{% url 'atlas:series_detail' pk=series.pk %}">Series details</a>
{% if can_edit %}
<a class="btn btn-outline-secondary btn-sm" href="{% url 'atlas:task_overview' %}">Tasks</a>
{% endif %}
<button id="reset-viewport-button" class="btn btn-outline-secondary btn-sm">Reset viewport</button>
</div>
</div>
<div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-start gap-3">
<div>
<h4 class="card-title mb-1">Series: {{ series.modality }} - {{ series.examination }}</h4>
@@ -12,14 +22,6 @@
<div class="mt-2">Description: <span class="text-break">{{ series.description }}</span></div>
<div class="mt-2 text-muted">Author: {{ series.get_author_display }}</div>
</div>
<div class="ms-auto d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="{% url 'atlas:series_detail' pk=series.pk %}">View series</a>
{% if can_edit %}
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
{% else %}
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
{% endif %}
</div>
</div>
</div>
</div>
+1
View File
@@ -82,6 +82,7 @@ Atlas Task Overview
<option value="reset_selected">Reset selected to READY (skip RUNNING)</option>
<option value="delete_completed">Delete selected COMPLETED</option>
<option value="delete_failed">Delete selected FAILED</option>
<option value="delete_selected_all">Delete ALL selected (including RUNNING)</option>
</select>
<button type="submit" class="btn btn-sm btn-primary">Apply to selected</button>
<div class="form-check ms-2">