start new viva view and add window/level sensitivy to dv3d

This commit is contained in:
Ross
2026-06-01 11:11:02 +01:00
parent 1d85666127
commit f2a52bed23
10 changed files with 778 additions and 14 deletions
@@ -61,6 +61,7 @@
<div class="mb-2 w-100 text-md-end">
{% if collection.collection_type == "VIV" %}
<a href="{% url 'atlas:collection_viva' collection.pk %}" class="btn btn-primary mb-2">Start Viva</a>
<a href="{% url 'atlas:collection_viva_local' collection.pk %}" class="btn btn-outline-primary mb-2">Start Viva (Local)</a>
{% else %}
<div class="d-inline-block">
<a class="btn btn-outline-secondary btn-sm" href="{{ collection.get_take_url }}" id="take-url-link">Open</a>
@@ -30,6 +30,7 @@
<li><a class="dropdown-item text-danger" href="{% url 'atlas:exam_deleted' collection.id %}">Delete</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'atlas:collection_viva' collection.pk %}">View as viva</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:collection_viva_local' collection.pk %}">View as viva (local)</a></li>
{% if request.user.is_superuser %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'admin:atlas_casecollection_change' collection.id %}">Admin Edit</a></li>
@@ -0,0 +1,348 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>This set up is designed to allow you to project/share cases in a seperate window whilst controlling/tracking their display from this page.</p>
<p>First you will need to open the linked viewer window by clicking the 'Open Viewer Local' button. This will open a new window/tab with the viewer in it. You will probably need to detach this from your current window.</p>
<p>If you are sharing the window you can maximise the screen area by hiding the tab/bookmark bar and the address bar. In Microsoft Edge this can be done by installing as an app, in Chrome you can use an extension such as <a href='https://chromewebstore.google.com/detail/ncppfjladdkdaemaghochfikpmghbcpc'>Open-as-Popup</a>.</p>
<p>Click on the 'Load' button to open the case in a the linked window. Loading subsequent cases/series will replace the first case in the window.</p>
<p>It is also possible to open a single series by clicking on the 'Load' button next to the individual series.</p>
<p>Once opened the case details will be show on the right hand side of the page.</p>
<div class="mb-2">
<button id="toggle-edit-mode" class="btn btn-warning ms-2">Enable Edit Mode</button>
</div>
</details>
<div class="container">
<div class="row">
<div class="col-sm">
<h3>Cases</h3>
<button id="open-viewer-local" >Open Viewer Local</button>
<button id="view-series" class="text-dark border-dark" >Toggle series</button>
<ol id="" class="sortable">
{% for casedetail in casedetails %}
{% with case=casedetail.case %}
<li data-question_pk={{case.pk}}>
<div class="case-item" data-title="{{case.title}}" data-case={{case.pk}} data-casejson='{{case.get_viva_details_json}}' data-images='{{case.get_series_images_nested}}' data-viewerstate={{casedetail.default_viewerstate_string}}>
Case {{forloop.counter}}: {{case.title}} &nbsp;
<button class="open-case-local no-padding" data-target=case.pk data-type="case">Load</button>
<!-- Edit mode link for case -->
<button class="view-link-case btn btn-outline-primary btn-sm ms-2" style="display:none;">
<a href="{% url 'atlas:case_detail' case.pk %}" style="color: inherit; text-decoration: none;">View Case</a>
</button>
<button class="edit-link-case btn btn-outline-warning btn-sm ms-2" style="display:none;">
<a href="{% url 'atlas:case_update' case.pk %}" style="color: inherit; text-decoration: none;">Edit Case</a>
</button>
<br />
<div class="pre-whitespace multi-image-block">
<details class="series-detail"><summary>Series:</summary>
{% for series in case.series.all %}
<span class="series-block">
<span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
test
{{series.get_block}}
<br />
<button class="open-series-local no-padding text-secondary border-secondary"
data-type="series"
data-series="{{ forloop.counter }}"
data-case="{{ case.pk }}"
data-target="{{ series.pk }}">
Load
</button>
<!-- Edit mode link for series -->
<button class="edit-link-series btn btn-outline-warning btn-sm ms-2" style="display:none;">
<a href="{% url 'atlas:series_update' series.pk %}" style="color: inherit; text-decoration: none;">Edit Series</a>
</button>
<br>
</span>
</span>
{% endfor %}
</details>
</div>
{# Hidden per-case question snippet to inject when case is loaded #}
<div class="question-block-snippet" style="display:none;">
{% include 'atlas/partials/collection_question_block.html' with casedetail=casedetail %}
</div>
{# Attached resources for this case: allow loading into the shared viewer #}
{% if case.caseresource_set.exists %}
<div class="mt-2">
<strong>Resources:</strong>
<div class="d-flex flex-column gap-1">
{% for cr in case.caseresource_set.all %}
{% with res=cr.resource %}
<div>
<span class="me-2">{{ res.name }}</span>
<button class="open-resource btn btn-sm btn-outline-primary ms-1" data-src="{% if res.file %}{{ res.file.url }}{% else %}{{ res.url }}{% endif %}" data-title="{{ res.name|escapejs }}">Load</button>
</div>
{% endwith %}
{% endfor %}
</div>
</div>
{% endif %}
{% if case.display_sets.all %}
<details class="displayset-detail"><summary>Display Sets:</summary>
{% for ds in case.display_sets.all %}
<div class="displayset-block">
<span>
<b>{{ ds.name }}</b>
{% if ds.description %}
<span class="text-muted">({{ ds.description }})</span>
{% endif %}
<button class="open-displayset no-padding text-secondary border-secondary"
data-type="displayset"
data-displayset="{{ ds.pk }}"
data-case="{{ case.pk }}"
data-title="{{ ds.name }}"
data-casejson='{{case.get_viva_details_json}}'
data-images='{{case.get_series_images_nested}}'
data-viewerstate={{ds.viewerstate_string}}
data-annotations='{{ds.annotations_string}}'
>
Load Display Set
</button>
<!-- Edit mode link for display set -->
<button class="edit-link-displayset btn btn-outline-warning btn-sm ms-2" style="display:none;">
<a href="{% url 'atlas:case_displaysets' case.pk %}?displayset={{ ds.pk }}&edit=true" style="color: inherit; text-decoration: none;">Edit Display Set</a>
</button>
</span>
</div>
{% empty %}
<span class="text-muted">No display sets for this case.</span>
{% endfor %}
</details>
{% endif %}
</div>
</li>
{% endwith %}
{% endfor %}
</ol>
</div>
<div class="col-sm">
<div class="sticky-top">
<h3>Current Case</h3>
<div id="case-details">
<div id="loading-case">Waiting for case to load in linked window.<br/> If you are seeing this message you probably need to open the viewer window and reload the case.</div>
<div id="current-case-title">None</div>
<div id="current-case-series"></div>
<div id="current-case-history"></div>
<div id="current-case-discussion"></div>
<div id="current-case-report"></div>
<div id="current-case-questions" class="mt-3"></div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block js %}
<script type="text/javascript">
var win2 = false;
let editMode = false;
$(document).ready(function() {
// Edit mode toggle
$('#toggle-edit-mode').click(function() {
editMode = !editMode;
if (editMode) {
$('.edit-link-case').show();
$('.view-link-case').show();
$('.edit-link-series').show();
$('.edit-link-displayset').show();
$(this).text('Disable Edit Mode').removeClass('btn-warning').addClass('btn-danger');
} else {
$('.edit-link-case').hide();
$('.view-link-case').hide();
$('.edit-link-series').hide();
$('.edit-link-displayset').hide();
$(this).text('Enable Edit Mode').removeClass('btn-danger').addClass('btn-warning');
}
});
const bc = new BroadcastChannel("viva_load");
bc.onmessage = (event) => {
console.log("message received");
console.log(event);
if (event.data.type == "open-complete-local") {
$("#loading-case").hide();
$("#open-viewer-local").removeClass("flash-button");
//bc.postMessage({"type": "open-complete-local"});
}
};
console.log('ready')
$('.show-collection, .show-case, .show-series').click(function() {
var url = $(this).data('target');
console.log('clicked', url)
$('#case-title').text($(this).data('title'));
$('#viewer').attr('src', url)
});
$('.open-case-local').click(function() {
let c = this.closest(".case-item");
console.log('clicked local', c)
console.log('open local', c.dataset.case)
casedetails = JSON.parse(c.dataset.casejson);
let viewerstate = JSON.parse(c.dataset.viewerstate) || {};
$('#loading-case').show()
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
$("#current-case-history").html("<span class='title'>History:</span> "+casedetails['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
bc.postMessage({"type": "open",
"case" : c.dataset.case, "images": c.dataset.images,
"viewerstate": viewerstate
});
// Inject the per-case question snippet into the current case panel
try {
var snippet = $(c).find('.question-block-snippet').html();
$('#current-case-questions').html(snippet || '');
} catch (e) {
console.warn('Unable to inject question snippet', e);
}
});
$('.open-displayset').click(function() {
let c = this;
console.log('clicked local', c)
console.log('open local', c.dataset.case)
casedetails = JSON.parse(c.dataset.casejson);
let viewerstate = JSON.parse(c.dataset.viewerstate) || {};
let annotations = JSON.parse(c.dataset.annotations) || {};
$('#loading-case').show()
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
$("#current-case-history").html("<span class='title'>History:</span> "+casedetails['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+casedetails['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+casedetails['report']);
bc.postMessage({"type": "open",
"case" : c.dataset.case, "images": c.dataset.images,
"viewerstate": viewerstate,
"annotations": annotations
});
// Inject the per-case question snippet
try {
var caseItem = $(this).closest('.case-item');
var snippet = caseItem.find('.question-block-snippet').html();
$('#current-case-questions').html(snippet || '');
} catch (e) {
console.warn('Unable to inject question snippet for displayset', e);
}
});
$('.open-series-local').click(function() {
let c = $(this).closest(".case-item")[0];
let seriesNumber = $(this).data('series');
let seriesPk = $(this).data('target');
let caseDetails = JSON.parse(c.dataset.casejson);
let images = JSON.parse(c.dataset.images)[seriesNumber - 1] || [];
$('#loading-case').show();
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> " + c.dataset.title);
$('#current-case-series').html("<span class='title'>Series:</span> " + seriesNumber);
$("#current-case-history").html("<span class='title'>History:</span> " + caseDetails['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> " + caseDetails['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> " + caseDetails['report']);
// Send only the images for this series
bc.postMessage({
"type": "open",
"case": c.dataset.case,
"series": seriesPk,
"images": JSON.stringify(images),
});
// Inject the per-case question snippet
try {
var snippet = $(c).find('.question-block-snippet').html();
$('#current-case-questions').html(snippet || '');
} catch (e) {
console.warn('Unable to inject question snippet for series', e);
}
});
// Load resource into linked viewer
$('.open-resource').click(function() {
var src = $(this).data('src');
var title = $(this).data('title') || 'Resource';
$('#loading-case').show();
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Resource:</span> "+title);
$('#current-case-history').html('');
$('#current-case-discussion').html('');
$('#current-case-report').html('');
bc.postMessage({"type": "open", "url": src});
});
$('.send-message').click(function() {
var url = $(this).data('target');
bc.postMessage(url);
})
$('#open-viewer-local').click(function() {
$("#open-viewer-local").removeClass("flash-button");
openSecondaryWindow("{% url 'atlas:collection_viewer_local_progressive' %}");
//bc.postMessage(url);
})
$('#view-series').click(function() {
$(".series-detail").attr("open", (_, attr) => !attr);
//document.getElementsByClassName("series-detail").toggleAttribute("open");
//bc.postMessage(url);
})
});
function openSecondaryWindow(url) {
//return win2 = window.open(url,'secondary','width=300,height=100');
return win2 = window.open(
url,
'secondary',
'toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=1200,height=800'
);
}
</script>
{{ form.media }}
{% endblock %}
{% block css %}
<style type="text/css">
#loading-case {
display: none;
color: red;
}
@keyframes glowing {
0% {
box-shadow: 0 0 20px #a600ff;
}
50% {
box-shadow: 0 0 40px #52057b;
}
100% {
box-shadow: 0 0 20px #a600ff;
}
}
.flash-button {
animation: glowing 1300ms infinite;
}
#case-details {
padding-bottom: 20px;
}
.title {
font-weight: bold;
}
.edit-link-case, .edit-link-series, .edit-link-displayset {
vertical-align: middle;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script>
<script src="/static/django_jsonforms/jsoneditor_init.js"></script>
{% endblock css %}
@@ -22,10 +22,16 @@
{% if show_case_editor %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' current_case.pk %}">Hide case series editor</a>
{% endif %}
{% if can_edit_chain and not show_study_dates_editor %}
<a class="btn btn-outline-primary" href="{% url 'atlas:linked_cases_overview' current_case.pk %}?show_dates=1">Open study dates editor</a>
{% endif %}
{% if show_study_dates_editor %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' current_case.pk %}">Hide study dates editor</a>
{% endif %}
</div>
</div>
{% if can_edit_chain %}
{% if can_edit_chain and show_study_dates_editor %}
<div class="card border-secondary-subtle shadow-sm mb-4">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
@@ -68,14 +74,8 @@
>
</td>
<td class="text-nowrap">
{% if row.relative_days is not None %}
{% if row.relative_days == 0 %}
Day 0
{% elif row.relative_days > 0 %}
+{{ row.relative_days }} day{{ row.relative_days|pluralize }}
{% else %}
{{ row.relative_days }} day{{ row.relative_days|pluralize }}
{% endif %}
{% if row.relative_label %}
{{ row.relative_label.text }}
{% else %}
<span class="text-muted">No anchor</span>
{% endif %}
@@ -244,7 +244,7 @@
{% if previous_cases %}
<div class="d-flex flex-column gap-3">
{% for linked_case in previous_cases %}
{% include "atlas/partials/_linked_case_card.html" with relation_label="Earlier in the linked chain" is_current=False %}
{% include "atlas/partials/_linked_case_card.html" with relation_label="Earlier in the linked chain" relative_label=linked_case.relative_label is_current=False %}
{% endfor %}
</div>
{% else %}
@@ -262,7 +262,7 @@
{% if next_cases %}
<div class="d-flex flex-column gap-3">
{% for linked_case in next_cases %}
{% include "atlas/partials/_linked_case_card.html" with relation_label="Later in the linked chain" is_current=False %}
{% include "atlas/partials/_linked_case_card.html" with relation_label="Later in the linked chain" relative_label=linked_case.relative_label is_current=False %}
{% endfor %}
</div>
{% else %}
@@ -29,7 +29,9 @@
{% endif %}
</div>
<div class="text-sm-end small text-muted">
{% if linked_case.get_effective_study_date %}
{% if relative_label %}
<div>Timeline {{ relative_label.text }}</div>
{% elif linked_case.get_effective_study_date %}
<div>Study date {{ linked_case.get_effective_study_date|date:"Y-m-d" }}</div>
{% elif linked_case.published_date %}
<div>Published {{ linked_case.published_date|date:"Y-m-d" }}</div>
@@ -0,0 +1,274 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>Viewer</title>
<style type="text/css">
body, html {
margin: 0; padding: 0; height: 100%; overflow: hidden;
}
#viewer {
position:absolute; left: 0; right: 0; bottom: 0; top: 0px;
}
/* Modal styles */
.center-modal {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
width: 100vw; height: 100vh;
background: rgba(20,20,30,0.96);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.center-modal-content {
background: #23232b;
color: #eee;
padding: 2em 2.5em;
border-radius: 12px;
box-shadow: 0 4px 32px rgba(0,0,0,0.4);
max-width: 90vw;
max-height: 80vh;
text-align: center;
font-size: 1.2em;
}
.highlight-action {
display: inline-block;
background: #0d6efd;
color: #fff;
font-weight: bold;
padding: 0.4em 1em;
border-radius: 6px;
margin: 0.7em 0 1.2em 0;
font-size: 1.3em;
box-shadow: 0 2px 8px rgba(13,110,253,0.15);
letter-spacing: 0.03em;
}
.center-modal-content ul {
color: #bdbdbd;
}
.center-modal-content h2 {
color: #fff;
}
</style>
<script src="{% static 'dv3d/index.js' %}" type="module" defer="defer"></script>
</head>
<body>
<!-- Centered modal for instructions -->
<div id="awaiting-modal" class="center-modal">
<div class="center-modal-content">
<span class="highlight-action">Share this screen</span>
<h2>Awaiting Load Request</h2>
<p>This page will display a DICOM series when loaded from another page.</p>
<ul style="text-align:left; margin: 1em auto; max-width: 30em;">
<li>Return to the previous page and select a series to view.</li>
<li>This viewer will update automatically when a series is loaded.</li>
<li>Keep this tab open to receive data.</li>
</ul>
</div>
</div>
<div id="root" class="dicom-viewer-root"
style="box-sizing: border-box; background: #222; position: absolute; top: 0; left: 0; right: 0; bottom: 0; width: 100vw; height: 100vh;"
data-auto-cache-stack="false"
></div>
<!-- Overlay for non-DICOM resources (images/videos/embeds) -->
<div id="resourceDisplay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.96); z-index:100000; align-items:center; justify-content:center;">
<div id="resourceInner" style="max-width:95vw; max-height:95vh; display:flex; align-items:center; justify-content:center;"></div>
<button id="resourceClose" style="position:fixed; top:12px; right:12px; z-index:2001;" class="btn btn-sm btn-light">Close</button>
</div>
<script type="text/javascript">
var win2 = false;
document.addEventListener("DOMContentLoaded", function() {
document.title = "Case: {{current_case.title}}";
console.log('ready');
const bc = new BroadcastChannel("viva_load");
const POPOUT_STATE_KEY = 'rad_local_viewer_popout_state';
// Centralized hide helper and delegated close handling for local viewer
function hideResourceLocal(){ const disp=document.getElementById('resourceDisplay'); const inner=document.getElementById('resourceInner'); if(disp) disp.style.display='none'; if(inner) inner.innerHTML=''; }
(function(){ const disp=document.getElementById('resourceDisplay'); if(!disp) return; disp.addEventListener('click', function(e){ const close = e.target.closest && e.target.closest('#resourceClose'); if (close) hideResourceLocal(); }); })();
// Helper to safely mount the DICOM viewers (waits for bundle and layout)
function waitForMountFn(timeout = 5000) {
if (window.mountDicomViewers) return Promise.resolve(window.mountDicomViewers);
return new Promise((resolve, reject) => {
const start = Date.now();
function poll() {
if (window.mountDicomViewers) return resolve(window.mountDicomViewers);
if (Date.now() - start > timeout) return reject(new Error('mountDicomViewers not available'));
requestAnimationFrame(poll);
}
poll();
});
}
async function mountDicomViewersSafely(timeout = 5000) {
try {
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
const mountFn = await waitForMountFn(timeout);
try { mountFn(); } catch (e) { console.warn('mountDicomViewers failed', e); }
requestAnimationFrame(() => { try { window.dispatchEvent(new Event('resize')); } catch (e) {} });
} catch (err) { console.warn('Viewer initialization deferred/fallback:', err); }
}
function applyViewerPayload(payload) {
if (!payload || payload.type !== 'open') {
return;
}
var root = document.getElementById('root');
var awaiting = document.getElementById('awaiting-modal');
if (!root) {
return;
}
if (awaiting) {
awaiting.style.display = 'none';
}
if (payload.namedStacks) {
root.dataset.namedStacks = payload.namedStacks;
} else {
delete root.dataset.namedStacks;
}
if (payload.images) {
root.dataset.images = payload.images;
} else {
delete root.dataset.images;
}
if (payload.viewerstate && Object.keys(payload.viewerstate).length > 0) {
root.dataset.viewerstate = payload.viewerstate;
} else {
delete root.dataset.viewerstate;
}
if (payload.annotations && Object.keys(payload.annotations).length > 0) {
root.dataset.annotationjson = payload.annotations;
} else {
delete root.dataset.annotationjson;
}
mountDicomViewersSafely();
}
function persistViewerPayload(payload) {
try {
localStorage.setItem(POPOUT_STATE_KEY, JSON.stringify(payload));
} catch (e) {
console.warn('Could not persist viewer payload', e);
}
}
function readPersistedViewerPayload() {
try {
var saved = localStorage.getItem(POPOUT_STATE_KEY);
return saved ? JSON.parse(saved) : null;
} catch (e) {
console.warn('Could not read persisted viewer payload', e);
return null;
}
}
var savedPayload = readPersistedViewerPayload();
if (savedPayload) {
applyViewerPayload(savedPayload);
}
window.addEventListener('storage', function (event) {
if (event.key !== POPOUT_STATE_KEY || !event.newValue) {
return;
}
try {
applyViewerPayload(JSON.parse(event.newValue));
} catch (e) {
console.warn('Could not parse updated viewer payload', e);
}
});
bc.onmessage = (event) => {
// Ensure any existing overlay is closed on new load
try { hideResourceLocal(); } catch(_) {}
console.log("message received");
console.log(event);
if (event.data.type == "open") {
// If a resource URL is provided (image/video/embed), show it in overlay
function isMediaUrl(url) {
if (!url) return false;
const lower = url.toLowerCase();
if (lower.match(/\.(jpe?g|png|gif|webp|svg)(?:$|\?|#)/)) return 'image';
if (lower.match(/\.(mp4|webm|ogg)(?:$|\?|#)/)) return 'video';
if (lower.indexOf('youtube.com') !== -1 || lower.indexOf('youtu.be') !== -1) return 'embed';
if (lower.indexOf('vimeo.com') !== -1) return 'embed';
return false;
}
function showResource(url) {
const type = isMediaUrl(url);
const disp = document.getElementById('resourceDisplay');
const inner = document.getElementById('resourceInner');
inner.innerHTML = '';
if (!type) return false;
const transformWrap = document.createElement('div');
transformWrap.style.display = 'flex'; transformWrap.style.alignItems = 'center'; transformWrap.style.justifyContent = 'center';
transformWrap.style.width = '100%'; transformWrap.style.height = '100%'; transformWrap.style.overflow = 'hidden'; transformWrap.style.touchAction = 'none';
if (type === 'image') {
const img = document.createElement('img'); img.src = url; img.style.maxWidth = '95%'; img.style.maxHeight = '95%'; img.style.objectFit = 'contain'; img.style.cursor='grab'; img.draggable=false; transformWrap.appendChild(img);
} else if (type === 'video') {
const v = document.createElement('video'); v.controls=true; v.autoplay=false; v.style.maxWidth='95%'; v.style.maxHeight='95%'; v.style.objectFit='contain'; const s=document.createElement('source'); s.src=url; v.appendChild(s); transformWrap.appendChild(v);
} else if (type === 'embed') {
let embed = url; try{ const m=url.match(/(?:v=|v\/|embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/); if (m&&m[1]) embed='https://www.youtube.com/embed/'+m[1]; else if (url.indexOf('vimeo.com')!==-1){ const m2=url.match(/vimeo\.com\/(\d+)/); if(m2&&m2[1]) embed='https://player.vimeo.com/video/'+m2[1]; } }catch(e){}
const iframe=document.createElement('iframe'); iframe.src=embed; iframe.setAttribute('frameborder','0'); iframe.setAttribute('allowfullscreen',''); iframe.style.width='95%'; iframe.style.height='85%'; transformWrap.appendChild(iframe);
}
inner.appendChild(transformWrap);
// pan/zoom
(function enablePanZoom(target){
let scale=1, minScale=0.5, maxScale=6, startX=0, startY=0, translateX=0, translateY=0, dragging=false;
const content=target.firstElementChild;
function update(){ if(content) content.style.transform=`translate(${translateX}px, ${translateY}px) scale(${scale})`; }
target.addEventListener('wheel', function(e){
const dispEl = document.getElementById('resourceDisplay');
if (!dispEl || dispEl.style.display === 'none') return;
e.preventDefault();
const delta=-e.deltaY||e.wheelDelta; const zoom=delta>0?1.12:0.9; const newScale=Math.min(maxScale, Math.max(minScale, scale*zoom)); const rect=content.getBoundingClientRect(); const mx=e.clientX-rect.left, my=e.clientY-rect.top; const ratio=newScale/scale-1; translateX -= (mx-rect.width/2)*ratio; translateY -= (my-rect.height/2)*ratio; scale=newScale; update();
}, {passive:false});
target.addEventListener('pointerdown', function(e){ dragging=true; startX=e.clientX; startY=e.clientY; target.setPointerCapture(e.pointerId); if(content) content.style.cursor='grabbing'; });
target.addEventListener('pointermove', function(e){ if(!dragging) return; e.preventDefault(); const dx=e.clientX-startX, dy=e.clientY-startY; startX=e.clientX; startY=e.clientY; translateX+=dx; translateY+=dy; update(); });
target.addEventListener('pointerup', function(e){ dragging=false; try{target.releasePointerCapture(e.pointerId)}catch(_){} if(content) content.style.cursor='grab'; });
target.addEventListener('dblclick', function(){ scale=1; translateX=0; translateY=0; update(); });
document.addEventListener('keydown', function(ev){ if(ev.key==='Escape'){ document.getElementById('resourceDisplay').style.display='none'; document.getElementById('resourceInner').innerHTML=''; } });
})(transformWrap);
disp.style.display='flex'; return true;
}
if (event.data.url && isMediaUrl(event.data.url)) {
// show resource overlay and do not attempt to mount DICOM viewer
if (showResource(event.data.url)) {
persistViewerPayload(event.data);
bc.postMessage({"type": "open-complete-local"});
return;
}
}
persistViewerPayload(event.data);
applyViewerPayload(event.data);
bc.postMessage({"type": "open-complete-local"});
}
};
});
</script>
</body>
</html>
+50
View File
@@ -233,3 +233,53 @@ def test_linked_cases_editor_is_hidden_until_requested(client, create_user, make
)
assert response_with_editor.status_code == 200
assertContains(response_with_editor, "Case Series Editor")
@pytest.mark.django_db
def test_linked_cases_study_dates_editor_is_hidden_until_requested(client, create_user, make_case):
user = create_user
case1 = make_case(title="Case 1")
case2 = make_case(title="Case 2")
case2.previous_case = case1
case2.save(update_fields=["previous_case"])
case1.author.add(user)
case2.author.add(user)
client.force_login(user)
response_default = client.get(
reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk})
)
assert response_default.status_code == 200
assertContains(response_default, "Open study dates editor")
assert "Study Dates" not in response_default.content.decode("utf-8")
response_with_editor = client.get(
reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk}) + "?show_dates=1"
)
assert response_with_editor.status_code == 200
assertContains(response_with_editor, "Study Dates")
@pytest.mark.django_db
def test_linked_case_card_shows_scaled_relative_timeline_label(client, create_user, make_case):
user = create_user
case1 = make_case(title="Case 1")
case2 = make_case(title="Case 2")
case2.previous_case = case1
case2.study_date = datetime.date(2028, 1, 1)
case2.save(update_fields=["previous_case", "study_date"])
case1.study_date = datetime.date(2026, 1, 1)
case1.save(update_fields=["study_date"])
case1.author.add(user)
case2.author.add(user)
client.force_login(user)
response = client.get(
reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk})
)
assert response.status_code == 200
assertContains(response, "Timeline +2 years")
+2
View File
@@ -97,8 +97,10 @@ urlpatterns = [
),
path("collection/viewer", TemplateView.as_view(template_name="atlas/viewer.html"), name="collection_viewer"),
path("collection/viewer_local", TemplateView.as_view(template_name="atlas/viewer_local.html"), name="collection_viewer_local"),
path("collection/viewer_local_progressive", TemplateView.as_view(template_name="atlas/viewer_local_progressive.html"), name="collection_viewer_local_progressive"),
path("collection/<int:pk>", views.collection_detail, name="collection_detail"),
path("collection/<int:pk>/viva", views.collection_viva, name="collection_viva"),
path("collection/<int:pk>/viva-local", views.collection_viva_local, name="collection_viva_local"),
path("collection/<int:pk>/viva/<int:case_number>", views.collection_viva_case, name="collection_viva_case"),
path("collection/<int:pk>/viva/<int:series_id>/series", views.collection_viva_series, name="collection_viva_series"),
path(
+87 -1
View File
@@ -195,6 +195,37 @@ def _extract_study_date_from_dicoms(dicoms):
return study_date
return None
def _format_relative_date_offset(days_delta):
if days_delta is None:
return None
if days_delta == 0:
return {"text": "Day 0", "unit": "day", "value": 0}
sign_prefix = "+" if days_delta > 0 else "-"
absolute_days = abs(days_delta)
if absolute_days >= 365:
value = round(absolute_days / 365.25, 1)
unit = "year"
elif absolute_days >= 30:
value = round(absolute_days / 30.4375, 1)
unit = "month"
else:
value = absolute_days
unit = "day"
if isinstance(value, float) and value.is_integer():
value = int(value)
suffix = "s" if value != 1 else ""
return {
"text": f"{sign_prefix}{value} {unit}{suffix}",
"unit": unit,
"value": value,
}
from .tasks import push_case_to_cimar_task, series_downsample_task, series_reconstruct_task
try:
from django_tasks import TaskResultStatus
@@ -5724,6 +5755,49 @@ def collection_viva(request, pk):
)
@user_is_collection_author_or_atlas_editor
def collection_viva_local(request, pk):
"""
Local-only viva controller view.
Scope:
- Mirrors `collection_viva` functionality for case/series/displayset loading.
- Uses only the embedded local DV3D viewer workflow.
- Keeps the original viva route intact for side-by-side rollout.
"""
collection = get_object_or_404(CaseCollection, pk=pk)
from django.db.models import Prefetch as _Prefetch
series_images_qs = SeriesImage.objects.filter(removed=False).order_by("position")
series_qs = Series.objects.order_by("seriesdetail__sort_order").prefetch_related(
_Prefetch("images", queryset=series_images_qs)
)
casedetails = (
CaseDetail.objects
.filter(collection=collection)
.select_related("case")
.order_by("sort_order")
.prefetch_related(
_Prefetch("case__series", queryset=series_qs),
_Prefetch("case__series__modality"),
_Prefetch("case__series__examination"),
_Prefetch("case__series__plane"),
_Prefetch("case__series__contrast"),
_Prefetch("case__caseresource_set"),
_Prefetch("case__display_sets"),
)
.annotate(series_count=Count("case__seriesdetail"))
)
return render(
request,
"atlas/collection_viva_local.html",
{"collection": collection, "casedetails": casedetails, "can_edit": True},
)
@user_is_collection_author_or_atlas_editor
def collection_viva_case(request, pk, case_number):
collection = get_object_or_404(CaseCollection, pk=pk)
@@ -9584,6 +9658,7 @@ def linked_cases_overview(request, case_id):
direct_next = next_cases[0] if next_cases else None
case_editor_prefix = "case_series_editor"
show_study_dates_editor = request.GET.get("show_dates") == "1"
show_case_editor = request.GET.get("show_editor") == "1"
case_editor_selected_field = ""
case_editor_selected_source = ""
@@ -9692,6 +9767,8 @@ def linked_cases_overview(request, case_id):
if not case.check_user_can_edit(request.user):
raise PermissionDenied("You do not have permission to edit this case chain.")
show_study_dates_editor = True
apply_to_all_from = request.POST.get("apply_to_all_from")
updated_cases = 0
@@ -9722,12 +9799,13 @@ def linked_cases_overview(request, case_id):
updated_cases += 1
messages.success(request, f"Updated {updated_cases} linked case date(s).")
return redirect("atlas:linked_cases_overview", case_id=case.pk)
return redirect(f"{reverse('atlas:linked_cases_overview', kwargs={'case_id': case.pk})}?show_dates=1")
if show_case_editor and editable_chain_case_ids:
case_editor_formset, case_editor_rows = build_case_editor_formset()
linked_case_rows = []
case_relative_labels = {}
anchor_date = next(
(linked_case.get_effective_study_date() for linked_case in chain_cases if linked_case.get_effective_study_date()),
None,
@@ -9735,13 +9813,19 @@ def linked_cases_overview(request, case_id):
for linked_case in chain_cases:
study_date = linked_case.get_effective_study_date()
relative_days = None
relative_label = None
if anchor_date and study_date:
relative_days = (study_date - anchor_date).days
relative_label = _format_relative_date_offset(relative_days)
case_relative_labels[linked_case.pk] = relative_label
linked_case.relative_label = relative_label
linked_case_rows.append(
{
"case": linked_case,
"study_date": study_date,
"relative_days": relative_days,
"relative_label": relative_label,
"can_edit": linked_case.check_user_can_edit(request.user),
"is_current": linked_case.pk == case.pk,
}
@@ -9762,6 +9846,7 @@ def linked_cases_overview(request, case_id):
"current_index": len(previous_cases) + 1,
"direct_previous": direct_previous,
"direct_next": direct_next,
"show_study_dates_editor": show_study_dates_editor,
"show_case_editor": show_case_editor,
"case_editor_formset": case_editor_formset,
"case_editor_rows": case_editor_rows,
@@ -9771,6 +9856,7 @@ def linked_cases_overview(request, case_id):
"case_editor_prefix": case_editor_prefix,
"editable_case_count": len(editable_chain_case_ids),
"locked_case_count": len(chain_case_ids) - len(editable_chain_case_ids),
"case_relative_labels": case_relative_labels,
},
)
File diff suppressed because one or more lines are too long