Skip to content

Commit

Permalink
Merge pull request #1021 from ImageMarkup/diagnosis-question-interface
Browse files Browse the repository at this point in the history
  • Loading branch information
danlamanna authored Nov 22, 2024
2 parents 4debd0c + cdf4b6f commit 149488b
Show file tree
Hide file tree
Showing 9 changed files with 502 additions and 14 deletions.
258 changes: 258 additions & 0 deletions isic/core/templates/core/widgets/diagnosis_picker.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
<div id="diagnosis-picker-{{ widget.name }}" class="diagnosis-picker">
<input type="hidden" name="{{ widget.name }}" id="{{ widget.name }}" value="{{ value|default_if_none:'' }}">

{{ diagnosis_values|json_script:"diagnosis-values" }}

<script>
function frequentDiagnoses() {
const mostFrequentDiagnoses = JSON.parse(document.getElementById('most-frequent-diagnoses').textContent);

return {
selectedDiagnosis: null,
mostFrequentDiagnoses: mostFrequentDiagnoses.map(diagnosis => {
const sections = diagnosis.choice__text.split(':');
return {
id: diagnosis.choice,
value: sections[sections.length - 1],
fullValue: diagnosis.choice__text,
};
}),
selectDiagnosis(diagnosisId, diagnosisFullValue) {
this.selectedDiagnosis = diagnosisId;

// this logic is duplicated below by the search picker
document.getElementById('selectedDiagnosis').textContent = diagnosisFullValue;
document.getElementById('selection').classList.remove('hidden');
document.getElementById('{{ widget.name }}').value = diagnosisId;

// strip all selected values from the tree
document.querySelectorAll('.diagnosis').forEach(d => d.classList.remove('selected'));
},
}
}
</script>

<div class="quick-select" x-data="frequentDiagnoses()">
<h3>Recent Diagnoses</h3>
<div class="quick-select-list" id="quick-select-list">
<template x-for="diagnosis in mostFrequentDiagnoses" :key="diagnosis.id">
<div class="quick-select-item"
:class="{ 'quick-select-selected': diagnosis.id == selectedDiagnosis }"
x-text="diagnosis.value"
@click="selectDiagnosis(diagnosis.id, diagnosis.fullValue)"></div>
</template>
</div>
</div>

<div class="h-8 relative">
<hr class="absolute inset-x-0 top-1/2 z-10">
</div>

<div class="search-container">
<input type="text" id="search-input" placeholder="Search diagnoses..." autocomplete="off" />
</div>

<div class="tree" id="diagnosis-tree"></div>

<div id="selection" class="hidden">
<div class="font-bold">Selected:</div>
<div id="selectedDiagnosis"></span>
</div>

<script>
const diagnosisValues = JSON.parse(document.getElementById('diagnosis-values').textContent)

const containerId = "diagnosis-picker-{{ widget.name }}";
const containerEl = document.getElementById(containerId);

function stringsToTree(strings) {
const tree = {};
strings.forEach(path => {
const parts = path.split(':');
let current = tree;
parts.forEach(part => {
if (!current[part]) {
current[part] = {};
}
current = current[part];
});
});
return tree;
}

function getNodePath(element) {
const path = [];
while (element) {
const diagnosis = element.querySelector(':scope > .diagnosis');
if (diagnosis) {
path.unshift(diagnosis.textContent);
}
// Move up to parent li if it exists
element = element.parentElement?.closest('li');
}
return path;
}

function createTree(data, parent = null) {
const ul = document.createElement('ul');

Object.entries(data).forEach(([key, value]) => {
const li = document.createElement('li');
// set li data-diagnosis-string element to the value so it can be used for rendering
// the highlighted version.
li.setAttribute('data-diagnosis-string', key);

const hasChildren = Object.keys(value).length > 0;

const toggle = document.createElement('span');
toggle.className = 'tree-toggle';
toggle.textContent = hasChildren ? '▶' : ' ';

const diagnosis = document.createElement('span');
diagnosis.className = 'diagnosis';
diagnosis.textContent = key;

li.appendChild(toggle);
li.appendChild(diagnosis);

if (hasChildren) {
const childTree = createTree(value, li);
childTree.classList.add('hidden');
li.appendChild(childTree);

toggle.addEventListener('click', () => {
childTree.classList.toggle('hidden');
toggle.textContent = childTree.classList.contains('hidden') ? '▶' : '▼';
});
}

diagnosis.addEventListener('click', () => {
document.querySelectorAll('.diagnosis').forEach(d => d.classList.remove('selected'));
diagnosis.classList.add('selected');

const path = getNodePath(li);
// this logic is duplicated above by the recent diagnosis picker
document.getElementById('selectedDiagnosis').textContent = path.join(':');
document.getElementById('selection').classList.remove('hidden');
document.getElementById('{{ widget.name }}').value = diagnosisValues[path.join(':')];
});

ul.appendChild(li);
});

return ul;
}

function highlightTerms(text, terms) {
// sort terms by length (longest first) to handle overlapping matches correctly
const sortedMatches = [...terms].sort((a, b) => b.length - a.length);
const pattern = new RegExp(`(${sortedMatches.join('|')})`, 'gi');
return text.replace(pattern, `<span class="match">$1</span>`);
}

function hasMatchInChildren(li, terms) {
const allText = li.textContent.toLowerCase();
return terms.every(term => allText.includes(term));
}

function filterTree(searchText) {
const terms = searchText.toLowerCase().split(' ').filter(term => term.length > 0);

// remove the highlighted spans
containerEl.querySelectorAll('.diagnosis').forEach(d => {
d.innerHTML = d.textContent;
});

containerEl.querySelectorAll('li').forEach(li => {
li.style.display = '';
const ul = li.querySelector('ul');
if (ul) {
ul.classList.add('hidden');
}
const toggle = li.querySelector('.tree-toggle');
if (toggle && toggle.textContent !== ' ') {
toggle.textContent = '▶';
}
});

if (terms.length === 0) {
return;
}

// mark matching nodes and their ancestors
const matchingNodes = new Set();
const ancestorNodes = new Set();

containerEl.querySelectorAll('li').forEach(li => {
const diagnosisEl = li.querySelector('.diagnosis');
const diagnosisText = diagnosisEl.textContent.toLowerCase();

if (terms.every(term => diagnosisText.includes(term)) || hasMatchInChildren(li, terms)) {
matchingNodes.add(li);

let parent = li.parentElement;
while (parent && !parent.classList.contains('tree')) {
if (parent.tagName === 'LI') {
ancestorNodes.add(parent);
}
parent = parent.parentElement;
}
}
});

// hide non-matching nodes and show relevant paths
containerEl.querySelectorAll('li').forEach(li => {
if (!matchingNodes.has(li) && !ancestorNodes.has(li)) {
li.style.display = 'none';
} else {
li.style.display = '';

// highlight matching terms
if (matchingNodes.has(li)) {
const diagnosisEl = li.querySelector('.diagnosis');
const diagnosisText = diagnosisEl.parentElement.getAttribute('data-diagnosis-string');
diagnosisEl.innerHTML = highlightTerms(diagnosisText, terms);

}

// expand relevant nodes
if (matchingNodes.has(li) || ancestorNodes.has(li)) {
let parent = li.parentElement;
while (parent && !parent.classList.contains('tree')) {
if (parent.tagName === 'UL') {
parent.classList.remove('hidden');
const parentLi = parent.parentElement;
const toggle = parentLi.querySelector('.tree-toggle');
if (toggle) {
toggle.textContent = '▼';
}
}
parent = parent.parentElement;
}
}

// expand children nodes
if (hasMatchInChildren(li, terms)) {
const ul = li.querySelector('ul');
if (ul) {
ul.classList.remove('hidden');
const toggle = li.querySelector('.tree-toggle');
if (toggle) {
toggle.textContent = '▼';
}
}
}
}
});
}

const treeData = stringsToTree(Object.keys(diagnosisValues));
const treeContainer = document.getElementById('diagnosis-tree');
treeContainer.appendChild(createTree(treeData));

const searchInput = document.getElementById('search-input');
searchInput.addEventListener('input', (e) => {
filterTree(e.target.value);
});
</script>
</div>
39 changes: 38 additions & 1 deletion isic/studies/forms.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from django import forms
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db.models import Count
from django.db.models.query import QuerySet
from django.db.models.query_utils import Q

from isic.studies.models import Question, Study
from isic.studies.models import Question, Response, Study


class StudyTaskForm(forms.Form):
Expand All @@ -22,6 +23,42 @@ def __init__(self, *args, **kwargs):
# Note: questions must be annotated with a required attribute
questions: QuerySet[Question] = kwargs.pop("questions")
self.questions = {x.pk: x for x in questions}

num_diagnosis_questions = len(
[question for question in questions if question.type == Question.QuestionType.DIAGNOSIS]
)
if num_diagnosis_questions > 1:
# this is a hack because passing a per-question version of most frequent diagnoses is
# unreasonably difficult.
raise ValueError("Only one diagnosis question is allowed per study.")
elif num_diagnosis_questions == 1: # noqa: RET506
# the study and user are necessary for diagnosis questions in order to compute
# the most frequently used diagnosis.
self.study = kwargs.pop("study")
self.user = kwargs.pop("user")

self.most_frequent_diagnoses = list(
Response.objects.filter(
question=next(
question
for question in questions
if question.type == Question.QuestionType.DIAGNOSIS
),
annotation__study=self.study,
annotation__annotator=self.user,
)
.values("choice", "choice__text")
.alias(count=Count("choice"))
.order_by("-count")
)

# remove study/user from kwargs before passing to super
if "study" in kwargs:
del kwargs["study"]

if "user" in kwargs:
del kwargs["user"]

super().__init__(*args, **kwargs)
for question in questions:
# field names for django forms must be strings
Expand Down
25 changes: 25 additions & 0 deletions isic/studies/migrations/0002_alter_question_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 5.1.2 on 2024-11-13 17:33

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("studies", "0001_initial"),
]

operations = [
migrations.AlterField(
model_name="question",
name="type",
field=models.CharField(
choices=[
("select", "Select"),
("number", "Number"),
("diagnosis", "Diagnosis"),
],
default="select",
max_length=9,
),
),
]
17 changes: 17 additions & 0 deletions isic/studies/migrations/0003_alter_questionchoice_text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.1.3 on 2024-11-21 14:20

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("studies", "0002_alter_question_type"),
]

operations = [
migrations.AlterField(
model_name="questionchoice",
name="text",
field=models.CharField(max_length=255),
),
]
Loading

0 comments on commit 149488b

Please sign in to comment.