fix a few things
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
<div class="case-search-widget">
|
||||
{% with input_id=input_id|default:'case-search-input' target_id=target_id|default:'case-search-results' %}
|
||||
<style>
|
||||
.case-search-loading {
|
||||
display: none;
|
||||
}
|
||||
.case-search-widget.is-loading .case-search-loading {
|
||||
display: inline-flex;
|
||||
}
|
||||
</style>
|
||||
<label for="{{ input_id }}" class="form-label">Search cases</label>
|
||||
{% if collection %}
|
||||
<input type="hidden" id="{{ input_id }}-collection" name="collection" value="{{ collection.pk }}">
|
||||
@@ -13,8 +21,14 @@
|
||||
hx-include="closest .case-search-widget"
|
||||
hx-target="#{{ target_id }}"
|
||||
hx-trigger="input delay:400ms"
|
||||
hx-indicator="#{{ input_id }}-loading"
|
||||
autocomplete="off">
|
||||
|
||||
<div id="{{ input_id }}-loading" class="case-search-loading align-items-center gap-2 mt-2 text-muted small">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>Searching cases...</span>
|
||||
</div>
|
||||
|
||||
<div id="{{ target_id }}" class="mt-2">
|
||||
{# Render initial results (search + recent) on initial GET; HTMX searches will replace this div with partial results #}
|
||||
{% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=collection show_select_button=show_select_button %}
|
||||
@@ -27,7 +41,14 @@
|
||||
try {
|
||||
var input = document.getElementById('{{ input_id|default:"case-search-input" }}');
|
||||
if (!input) return;
|
||||
var widget = input.closest('.case-search-widget');
|
||||
var timer = null;
|
||||
|
||||
function setLoading(on) {
|
||||
if (!widget) return;
|
||||
widget.classList.toggle('is-loading', !!on);
|
||||
}
|
||||
|
||||
input.addEventListener('input', function(){
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(function(){
|
||||
@@ -36,12 +57,13 @@
|
||||
var url = "{% url 'atlas:case_search' %}?q=" + q + ({% if collection %} "&collection={{ collection.pk }}" {% else %} '' {% endif %}) + ({% if show_select_button %} "&show_select=1" {% else %} '' {% endif %});
|
||||
var target = '#{{ target_id|default:"case-search-results" }}';
|
||||
if (window.htmx && typeof window.htmx.ajax === 'function') {
|
||||
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' });
|
||||
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML', indicator: '#{{ input_id|default:"case-search-input" }}-loading' });
|
||||
} else {
|
||||
setLoading(true);
|
||||
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
|
||||
var el = document.querySelector(target);
|
||||
if (el) el.innerHTML = html;
|
||||
}).catch(function(e){ console.error('case search fetch error', e); });
|
||||
}).catch(function(e){ console.error('case search fetch error', e); }).finally(function(){ setLoading(false); });
|
||||
}
|
||||
} catch(e) { console.error(e); }
|
||||
}, 400);
|
||||
@@ -61,6 +83,17 @@
|
||||
var targetEl = document.getElementById(targetId);
|
||||
if (!container || !targetEl) return;
|
||||
|
||||
document.body.addEventListener('htmx:beforeRequest', function (event) {
|
||||
if (event.target === inputEl) {
|
||||
container.classList.add('is-loading');
|
||||
}
|
||||
});
|
||||
document.body.addEventListener('htmx:afterRequest', function (event) {
|
||||
if (event.target === inputEl) {
|
||||
container.classList.remove('is-loading');
|
||||
}
|
||||
});
|
||||
|
||||
// Delegate clicks on result items and dispatch a scoped custom event
|
||||
targetEl.addEventListener('click', function(e){
|
||||
var item = e.target.closest('.list-group-item');
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
<div id="current-case-meta" data-current-case="{{ case.pk }}" data-current-case-title="{{ case.title|escape }}" style="display:none;"></div>
|
||||
{% endif %}
|
||||
{% with input_id=input_id|default:'displayset-search-input' target_id=target_id|default:'displayset-search-results' %}
|
||||
<style>
|
||||
.displayset-search-loading { display: none; }
|
||||
.displayset-search-widget.is-loading .displayset-search-loading { display: inline-flex; }
|
||||
</style>
|
||||
<label for="{{ input_id }}" class="form-label">Search display sets</label>
|
||||
<input id="{{ input_id }}" name="q" class="form-control" type="search"
|
||||
placeholder="Type to search display sets..."
|
||||
@@ -11,8 +15,14 @@
|
||||
{% if case %}hx-vals='{"case": "{{ case.pk }}"}'{% endif %}
|
||||
hx-target="#{{ target_id }}"
|
||||
hx-trigger="keyup changed delay:400ms"
|
||||
hx-indicator="#{{ input_id }}-loading"
|
||||
autocomplete="off">
|
||||
|
||||
<div id="{{ input_id }}-loading" class="displayset-search-loading align-items-center gap-2 mt-2 text-muted small">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>Searching display sets...</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-1">
|
||||
<span id="{{ input_id }}-case-badge" class="badge bg-secondary" style="display: {% if case %}inline-block{% else %}none{% endif %};">
|
||||
{% if case %}Filtering: {{ case.title|escape }}{% endif %}
|
||||
@@ -31,7 +41,14 @@
|
||||
try {
|
||||
var input = document.getElementById('{{ input_id|default:"displayset-search-input" }}');
|
||||
if (!input) return;
|
||||
var widget = input.closest('.displayset-search-widget');
|
||||
var timer = null;
|
||||
|
||||
function setLoading(on) {
|
||||
if (!widget) return;
|
||||
widget.classList.toggle('is-loading', !!on);
|
||||
}
|
||||
|
||||
input.addEventListener('input', function(){
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(function(){
|
||||
@@ -40,16 +57,28 @@
|
||||
var url = "{% url 'atlas:displayset_search' %}?q=" + q + ({% if case %} "&case={{ case.pk }}" {% else %} '' {% endif %});
|
||||
var target = '#{{ target_id|default:"displayset-search-results" }}';
|
||||
if (window.htmx && typeof window.htmx.ajax === 'function') {
|
||||
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' });
|
||||
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML', indicator: '#{{ input_id|default:"displayset-search-input" }}-loading' });
|
||||
} else {
|
||||
setLoading(true);
|
||||
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
|
||||
var el = document.querySelector(target);
|
||||
if (el) el.innerHTML = html;
|
||||
}).catch(function(e){ console.error('displayset search fetch error', e); });
|
||||
}).catch(function(e){ console.error('displayset search fetch error', e); }).finally(function(){ setLoading(false); });
|
||||
}
|
||||
} catch(e) { console.error(e); }
|
||||
}, 400);
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:beforeRequest', function (event) {
|
||||
if (event.target === input) {
|
||||
setLoading(true);
|
||||
}
|
||||
});
|
||||
document.body.addEventListener('htmx:afterRequest', function (event) {
|
||||
if (event.target === input) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
} catch(e) { console.error('displayset search widget init error', e); }
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<div class="finding-search-widget">
|
||||
{% with input_id=input_id|default:'finding-search-input' target_id=target_id|default:'finding-search-results' %}
|
||||
<style>
|
||||
.finding-search-loading { display: none; }
|
||||
.finding-search-widget.is-loading .finding-search-loading { display: inline-flex; }
|
||||
</style>
|
||||
<label for="{{ input_id }}" class="form-label">Search findings</label>
|
||||
<input id="{{ input_id }}" name="q" class="form-control" type="search"
|
||||
placeholder="Type to search findings..."
|
||||
@@ -8,8 +12,14 @@
|
||||
{% if case %}hx-vals='{"case": "{{ case.pk }}"}'{% endif %}
|
||||
hx-target="#{{ target_id }}"
|
||||
hx-trigger="keyup changed delay:400ms"
|
||||
hx-indicator="#{{ input_id }}-loading"
|
||||
autocomplete="off">
|
||||
|
||||
<div id="{{ input_id }}-loading" class="finding-search-loading align-items-center gap-2 mt-2 text-muted small">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>Searching findings...</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-1">
|
||||
<span id="{{ input_id }}-case-badge" class="badge bg-secondary" style="display: {% if case %}inline-block{% else %}none{% endif %};">
|
||||
{% if case %}Filtering: {{ case.title|escape }}{% endif %}
|
||||
@@ -28,8 +38,14 @@
|
||||
try {
|
||||
var input = document.getElementById('{{ input_id|default:"finding-search-input" }}');
|
||||
if (!input) return;
|
||||
var widget = input.closest('.finding-search-widget');
|
||||
var timer = null;
|
||||
|
||||
function setLoading(on) {
|
||||
if (!widget) return;
|
||||
widget.classList.toggle('is-loading', !!on);
|
||||
}
|
||||
|
||||
// If server provided a case via inclusion, ensure widget sets dataset and nearby textarea dataset
|
||||
var meta = document.getElementById('current-case-meta');
|
||||
var casePk = meta ? meta.getAttribute('data-current-case') : null;
|
||||
@@ -70,16 +86,28 @@
|
||||
var url = "{% url 'atlas:finding_search' %}?q=" + q + caseParam;
|
||||
var target = '#{{ target_id|default:"finding-search-results" }}';
|
||||
if (window.htmx && typeof window.htmx.ajax === 'function') {
|
||||
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' });
|
||||
window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML', indicator: '#{{ input_id|default:"finding-search-input" }}-loading' });
|
||||
} else {
|
||||
setLoading(true);
|
||||
fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){
|
||||
var el = document.querySelector(target);
|
||||
if (el) el.innerHTML = html;
|
||||
}).catch(function(e){ console.error('finding search fetch error', e); });
|
||||
}).catch(function(e){ console.error('finding search fetch error', e); }).finally(function(){ setLoading(false); });
|
||||
}
|
||||
} catch(e) { console.error(e); }
|
||||
}, 400);
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:beforeRequest', function (event) {
|
||||
if (event.target === input) {
|
||||
setLoading(true);
|
||||
}
|
||||
});
|
||||
document.body.addEventListener('htmx:afterRequest', function (event) {
|
||||
if (event.target === input) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
} catch(e) { console.error('finding search widget init error', e); }
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block css %}
|
||||
{{ block.super }}
|
||||
<style>
|
||||
.recon-advanced-viewer {
|
||||
width: 100%;
|
||||
min-height: 62vh;
|
||||
box-sizing: border-box;
|
||||
background: #111;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.recon-advanced-help {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">Advanced Series Reconstruction</h2>
|
||||
<p class="text-muted mb-0">Plan reconstruction from interactive DV3D MPR viewports with crosshair-linked navigation.</p>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{{ series.get_absolute_url }}">
|
||||
<i class="bi bi-arrow-left me-1"></i> Back to series
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info recon-advanced-help">
|
||||
<div><strong>Workflow:</strong> click <em>Apply MPR layout</em>, switch all panes to volume mode if needed, enable crosshairs in the DV3D toolbar, then position the crosshair planes as desired before generating reconstructions.</div>
|
||||
</div>
|
||||
|
||||
<div id="advanced_recon_root" class="dicom-viewer-root recon-advanced-viewer" data-images='{{ image_ids_json|safe }}'></div>
|
||||
|
||||
<div class="card border-0 shadow-sm mt-3">
|
||||
<div class="card-body">
|
||||
<form
|
||||
id="advanced-recon-form"
|
||||
class="row g-3"
|
||||
method="post"
|
||||
action="{% url 'atlas:series_optimize' series_id=series.pk %}"
|
||||
hx-post="{% url 'atlas:series_optimize' series_id=series.pk %}"
|
||||
hx-target="#advanced-recon-feedback"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#advanced-recon-indicator"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="operation" value="reconstruct">
|
||||
<input type="hidden" name="recon_viewerstate_json" id="id_recon_viewerstate_json">
|
||||
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="fw-semibold mb-2">Output planes</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="adv-recon-axial" name="recon_planes" value="axial" checked>
|
||||
<label class="form-check-label" for="adv-recon-axial">Axial</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="adv-recon-coronal" name="recon_planes" value="coronal" checked>
|
||||
<label class="form-check-label" for="adv-recon-coronal">Coronal</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="adv-recon-sagittal" name="recon_planes" value="sagittal" checked>
|
||||
<label class="form-check-label" for="adv-recon-sagittal">Sagittal</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-4">
|
||||
<label class="form-label" for="adv-recon-thickness">Slab thickness (mm)</label>
|
||||
<input id="adv-recon-thickness" type="number" step="0.1" min="0.1" name="recon_slice_thickness" class="form-control" placeholder="e.g. 2.5">
|
||||
|
||||
<label class="form-label mt-2" for="adv-recon-spacing">Slice spacing (mm)</label>
|
||||
<input id="adv-recon-spacing" type="number" step="0.1" min="0.1" name="recon_slice_spacing" class="form-control" placeholder="e.g. 1.0">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-4">
|
||||
<label class="form-label" for="adv-recon-mode">Projection mode</label>
|
||||
<select id="adv-recon-mode" name="recon_thickness_mode" class="form-select">
|
||||
<option value="mean" selected>Average intensity</option>
|
||||
<option value="max">Maximum intensity</option>
|
||||
<option value="min">Minimum intensity</option>
|
||||
</select>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mt-3">
|
||||
<button type="button" id="apply-mpr-layout" class="btn btn-outline-primary btn-sm">Apply MPR layout</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Generate recon series</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer d-flex align-items-center gap-2">
|
||||
<span id="advanced-recon-indicator" class="spinner-border spinner-border-sm text-primary htmx-indicator" role="status" aria-hidden="true"></span>
|
||||
<small class="text-muted">The current viewer state is captured at submit and attached to the reconstruction request.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="advanced-recon-feedback" class="small mt-3"></div>
|
||||
</div>
|
||||
|
||||
<script src="{% static 'dv3d/index.js' %}" type="module" defer="defer"></script>
|
||||
<script>
|
||||
(function () {
|
||||
function getViewerApi(name) {
|
||||
return window[name + '_advanced_recon_root'] || window[name + '_advanced_recon'] || window[name + '_root'] || window[name];
|
||||
}
|
||||
|
||||
function buildMprState() {
|
||||
return {
|
||||
grid: { rows: 1, cols: 3 },
|
||||
modes: ['volume', 'volume', 'volume'],
|
||||
orientations: ['axial', 'coronal', 'sagittal'],
|
||||
volumeSliceIndices: [null, null, null],
|
||||
};
|
||||
}
|
||||
|
||||
var applyBtn = document.getElementById('apply-mpr-layout');
|
||||
if (applyBtn) {
|
||||
applyBtn.addEventListener('click', function () {
|
||||
var importFn = getViewerApi('importViewerState');
|
||||
if (typeof importFn === 'function') {
|
||||
try {
|
||||
importFn(buildMprState());
|
||||
} catch (err) {
|
||||
console.warn('Failed to apply MPR layout', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var form = document.getElementById('advanced-recon-form');
|
||||
var hidden = document.getElementById('id_recon_viewerstate_json');
|
||||
if (form && hidden) {
|
||||
form.addEventListener('submit', function () {
|
||||
var exportFn = getViewerApi('exportViewerState');
|
||||
if (typeof exportFn !== 'function') {
|
||||
hidden.value = '';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var state = exportFn();
|
||||
hidden.value = typeof state === 'string' ? state : JSON.stringify(state || {});
|
||||
} catch (err) {
|
||||
hidden.value = '';
|
||||
console.warn('Failed to capture viewer state', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -416,6 +416,9 @@
|
||||
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-outline-primary" type="submit" name="operation" value="reconstruct">Generate recon series</button>
|
||||
<a class="btn btn-sm btn-outline-info ms-1" href="{% url 'atlas:series_reconstruct_advanced' series_id=series.pk %}" target="_blank" rel="noopener">
|
||||
Advanced reconstruction page
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user