start new viva view and add window/level sensitivy to dv3d
This commit is contained in:
@@ -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}}
|
||||
<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>
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user