Skip to content

Commit eacc2b9

Browse files
tomolopolisTom Searle
andauthored
Allow overlapping annos in Trainer (#245)
* CU-869bacykn: enable overlapping span rendering on annotions in prep for medcatv2 models that support this * CU-869bacykn: frontend tests for clinical text component --------- Co-authored-by: Tom Searle <tom@cogstack.org>
1 parent cb9e985 commit eacc2b9

File tree

4 files changed

+612
-49
lines changed

4 files changed

+612
-49
lines changed

medcat-trainer/webapp/api/api/utils.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,6 @@ def get_create_cdb_infos(cdb, concept, cui, cui_info_prop, code_prop, desc_prop,
155155
return model_clazz.objects.filter(code__in=codes)
156156

157157

158-
def _remove_overlap(project, document, start, end):
159-
anns = AnnotatedEntity.objects.filter(project=project, document=document)
160-
161-
for ann in anns:
162-
if (start <= ann.start_ind <= end) or (start <= ann.end_ind <= end):
163-
logger.debug("Removed %s ", str(ann))
164-
ann.delete()
165-
166158

167159
def create_annotation(source_val: str, selection_occurrence_index: int, cui: str, user: User,
168160
project: ProjectAnnotateEntities, document, cat: CAT):
@@ -180,9 +172,8 @@ def create_annotation(source_val: str, selection_occurrence_index: int, cui: str
180172
start = all_occurrences_start_idxs[selection_occurrence_index]
181173

182174
if start is not None and len(source_val) > 0 and len(cui) > 0:
183-
# Remove overlaps
175+
# Allow overlapping annotations - removed overlap constraint
184176
end = start + len(source_val)
185-
_remove_overlap(project, document, start, end)
186177

187178
cnt = Entity.objects.filter(label=cui).count()
188179
if cnt == 0:

medcat-trainer/webapp/frontend/src/components/common/ClinicalText.vue

Lines changed: 180 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -78,48 +78,152 @@ export default {
7878
return this.addAnnos ? `<div @contextmenu.prevent.stop="showCtxMenu($event)">${text}</div>` : `<div>${text}</div>`
7979
}
8080
81+
// Sort entities by start_ind, then by end_ind (longer first for same start)
82+
// Preserve original index for click handlers
83+
const sortedEnts = this.ents.map((ent, origIdx) => ({ ent, origIdx })).sort((a, b) => {
84+
if (a.ent.start_ind !== b.ent.start_ind) {
85+
return a.ent.start_ind - b.ent.start_ind
86+
}
87+
return b.ent.end_ind - a.ent.end_ind // Longer spans first when same start
88+
})
89+
8190
const taskHighlightDefault = 'highlight-task-default'
82-
let formattedText = ''
83-
let start = 0
8491
let timeout = 0
85-
for (let i = 0; i < this.ents.length; i++) {
86-
// highlight the span with default
87-
let highlightText = this.text.slice(this.ents[i].start_ind, this.ents[i].end_ind)
8892
93+
// Create events for start and end of each annotation
94+
const events = []
95+
sortedEnts.forEach((entData, i) => {
96+
const ent = entData.ent
97+
const origIdx = entData.origIdx
98+
events.push({ pos: ent.start_ind, type: 'start', entIndex: i, origIndex: origIdx, ent: ent })
99+
events.push({ pos: ent.end_ind, type: 'end', entIndex: i, origIndex: origIdx, ent: ent })
100+
})
101+
events.sort((a, b) => {
102+
if (a.pos !== b.pos) {
103+
return a.pos - b.pos
104+
}
105+
// At same position, process starts before ends
106+
if (a.type !== b.type) {
107+
return a.type === 'start' ? -1 : 1
108+
}
109+
return 0
110+
})
111+
112+
let formattedText = ''
113+
let currentPos = 0
114+
const activeEnts = [] // Stack of active entities (ordered by when they were opened)
115+
116+
// Helper function to get style class for an entity
117+
const getStyleClass = (ent, origIndex) => {
89118
let styleClass = taskHighlightDefault
90-
if (this.ents[i].assignedValues[this.taskName] !== null) {
91-
let btnIndex = this.taskValues.indexOf(this.ents[i].assignedValues[this.taskName])
119+
if (ent.assignedValues[this.taskName] !== null) {
120+
let btnIndex = this.taskValues.indexOf(ent.assignedValues[this.taskName])
92121
styleClass = `highlight-task-${btnIndex}`
93122
}
94123
95-
if (this.ents[i].id === this.currentRelStartEnt.id) {
124+
if (ent.id === this.currentRelStartEnt.id) {
96125
styleClass += ' current-rel-start'
97-
} else if (this.ents[i].id === this.currentRelEndEnt.id) {
126+
} else if (ent.id === this.currentRelEndEnt.id) {
98127
styleClass += ' current-rel-end'
99128
}
100129
101-
styleClass = this.ents[i] === this.currentEnt ? `${styleClass} highlight-task-selected` : styleClass
102-
timeout = this.ents[i] === this.currentEnt && i === 0 ? 500 : timeout
130+
if (ent === this.currentEnt) {
131+
styleClass += ' highlight-task-selected'
132+
timeout = origIndex === 0 ? 500 : timeout
133+
}
134+
return styleClass
135+
}
136+
137+
// Helper function to build opening span tag
138+
const buildOpenSpan = (ent, origIndex) => {
139+
const styleClass = getStyleClass(ent, origIndex)
140+
return `<span @click.stop="selectEnt(${origIndex})" class="${styleClass}">`
141+
}
103142
143+
// Helper function to build closing span tag with optional remove button
144+
const buildCloseSpan = (ent, origIndex, isInnermost) => {
104145
let removeButtonEl = ''
105-
if (this.ents[i].manually_created) {
106-
removeButtonEl = `<font-awesome-icon icon="times" class="remove-new-anno" @click="removeNewAnno(${i})"></font-awesome-icon>`
146+
if (isInnermost && ent.manually_created) {
147+
removeButtonEl = `<font-awesome-icon icon="times" class="remove-new-anno" @click.stop="removeNewAnno(${origIndex})"></font-awesome-icon>`
107148
}
108-
let spanText = `<span @click="selectEnt(${i})" class="${styleClass}">${_.escape(highlightText)}${removeButtonEl}</span>`
109-
110-
let precedingText = _.escape(this.text.slice(start, this.ents[i].start_ind))
111-
precedingText = precedingText.length !== 0 ? precedingText : ' '
112-
start = this.ents[i].end_ind
113-
formattedText += precedingText + spanText
114-
if (i === this.ents.length - 1) {
115-
formattedText += this.text.slice(start, this.text.length)
149+
return `${removeButtonEl}</span>`
150+
}
151+
152+
for (const event of events) {
153+
// Handle start events first (before adding text)
154+
if (event.type === 'start') {
155+
// Add any text up to this point
156+
if (event.pos > currentPos) {
157+
const textSegment = this.text.slice(currentPos, event.pos)
158+
if (textSegment.length > 0) {
159+
formattedText += _.escape(textSegment)
160+
}
161+
currentPos = event.pos
162+
}
163+
// Open the span for this annotation
164+
formattedText += buildOpenSpan(event.ent, event.origIndex)
165+
activeEnts.push({ entIndex: event.entIndex, origIndex: event.origIndex, ent: event.ent })
166+
} else if (event.type === 'end') {
167+
// Close the span (in reverse order to maintain nesting)
168+
const index = activeEnts.findIndex(ae => ae.entIndex === event.entIndex)
169+
if (index !== -1) {
170+
// If this is not the innermost span, we need to handle overlapping text
171+
if (index < activeEnts.length - 1) {
172+
// Add text up to the end position while all spans are still active
173+
// This text is inside all active spans including this one
174+
if (event.pos > currentPos) {
175+
const textSegment = this.text.slice(currentPos, event.pos)
176+
if (textSegment.length > 0) {
177+
formattedText += _.escape(textSegment)
178+
}
179+
currentPos = event.pos
180+
}
181+
// Close all inner spans (from innermost to the one after this)
182+
// Don't add remove buttons here - these are temporary closes for nesting
183+
// We'll add remove buttons only when we reach the actual end position
184+
for (let j = activeEnts.length - 1; j > index; j--) {
185+
const innerData = activeEnts[j]
186+
// Always pass false here - these are temporary closes, not final ends
187+
formattedText += buildCloseSpan(innerData.ent, innerData.origIndex, false)
188+
}
189+
// Close this span (temporary close, no remove button)
190+
formattedText += buildCloseSpan(event.ent, event.origIndex, false)
191+
// Reopen inner spans (in the same order) so text after this position is inside them
192+
for (let j = index + 1; j < activeEnts.length; j++) {
193+
const innerData = activeEnts[j]
194+
formattedText += buildOpenSpan(innerData.ent, innerData.origIndex)
195+
}
196+
} else {
197+
// This is the innermost span at its final end position
198+
// Add text then close it with remove button if needed
199+
if (event.pos > currentPos) {
200+
const textSegment = this.text.slice(currentPos, event.pos)
201+
if (textSegment.length > 0) {
202+
formattedText += _.escape(textSegment)
203+
}
204+
currentPos = event.pos
205+
}
206+
// Only add remove button when closing at the actual end position
207+
formattedText += buildCloseSpan(event.ent, event.origIndex, true)
208+
}
209+
activeEnts.splice(index, 1)
210+
}
116211
}
117212
}
118213
119-
// escape '<' '>' that may be interpreted as start/end tags, escape inserted span tags.
120-
// formattedText = formattedText
121-
// .replace(/<(?!\/?span|font-awesome-icon)/g, '&lt')
122-
// .replace(/(?<!<span @click="selectEnt\(\d\d?\d?\d?\)".*"|\/span)>/g, '&gt')
214+
// Add remaining text after all events
215+
if (currentPos < this.text.length) {
216+
const textSegment = this.text.slice(currentPos)
217+
if (textSegment.length > 0) {
218+
formattedText += _.escape(textSegment)
219+
}
220+
// Close any remaining active spans (in reverse order)
221+
for (let j = activeEnts.length - 1; j >= 0; j--) {
222+
const activeData = activeEnts[j]
223+
const isInnermost = j === activeEnts.length - 1
224+
formattedText += buildCloseSpan(activeData.ent, activeData.origIndex, isInnermost)
225+
}
226+
}
123227
124228
formattedText = this.addAnnos ? `<div @contextmenu.prevent.stop="showCtxMenu($event)">${formattedText}</div>` : `<div>${formattedText}</div>`
125229
this.scrollIntoView(timeout)
@@ -238,18 +342,64 @@ export default {
238342
box-shadow: 0px -2px 3px 2px rgba(0, 0, 0, 0.2);
239343
padding: 25px;
240344
white-space: pre-wrap;
345+
line-height: 1.6; // Base line height for normal text
346+
347+
// Increase line height when there are 3 or more nested underlines
348+
// to prevent underlines from overlapping with next line
349+
[class^="highlight-task-"] [class^="highlight-task-"] [class^="highlight-task-"] {
350+
line-height: 2.2; // Increased line height for 3+ levels of nesting
351+
padding-bottom: 4px; // Extra padding to push next line down
352+
display: inline-block; // Ensure padding applies
353+
}
354+
355+
// Also handle when default is deeply nested
356+
.highlight-task-default [class^="highlight-task-"] [class^="highlight-task-"] {
357+
line-height: 2.2;
358+
padding-bottom: 4px;
359+
display: inline-block;
360+
}
241361
}
242362
243363
.highlight-task-default {
244-
background: lightgrey;
245-
border: 1px solid lightgrey;
246-
border-radius: 3px;
364+
text-decoration: underline;
365+
text-decoration-color: lightgrey;
366+
text-decoration-thickness: 3px;
367+
text-underline-offset: 3px; // Moved down 1px to avoid descender breaks
247368
cursor: pointer;
369+
370+
// Stack underlines when nested - each nested level gets a larger offset with clear spacing
371+
[class^="highlight-task-"] {
372+
text-underline-offset: 7px; // Second level underline (4px spacing from first, moved down 1px)
373+
}
374+
375+
[class^="highlight-task-"] [class^="highlight-task-"] {
376+
text-underline-offset: 11px; // Third level underline (4px spacing from second, moved down 1px)
377+
// Increase line height for 3+ levels to prevent overlap with next line
378+
line-height: 2.2;
379+
padding-bottom: 4px;
380+
display: inline-block;
381+
}
382+
383+
[class^="highlight-task-"] [class^="highlight-task-"] [class^="highlight-task-"] {
384+
text-underline-offset: 15px; // Fourth level underline (4px spacing from third, moved down 1px)
385+
// Further increase line height for 4+ levels
386+
line-height: 2.4;
387+
padding-bottom: 6px;
388+
display: inline-block;
389+
}
248390
}
249391
250392
.highlight-task-selected {
251-
font-weight: bold;
252-
font-size: 1.15rem;
393+
// Background highlight is applied via the specific highlight-task-{i} class
394+
// This ensures the background color matches the state color
395+
text-decoration-thickness: 4px;
396+
}
397+
398+
// Selected state for default (unvalidated) annotations
399+
.highlight-task-default.highlight-task-selected {
400+
background-color: rgba(211, 211, 211, 0.3); // Light grey background for selected default
401+
padding: 1px 2px;
402+
border-radius: 2px;
253403
}
254404
255405
.current-rel-start {

medcat-trainer/webapp/frontend/src/styles/_common.scss

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,31 +46,62 @@ $blur-radius: 10px;
4646
.title {
4747
padding: 5px 15px;
4848
font-size: 14pt;
49-
box-shadow: 0 5px 5px -5px rgba(0,0,0,0.2);
49+
box-shadow: 0 5px 5px -5px rgba(0, 0, 0, 0.2);
5050
color: black;
5151
height: $title-height;
5252
}
5353

5454
@each $i, $col in $task-colors {
5555
.highlight-task-#{$i} {
56-
background-color: $col;
57-
border-radius: 3px;
56+
text-decoration: underline;
57+
text-decoration-color: $col;
58+
text-decoration-thickness: 3px;
59+
text-underline-offset: 3px; // Moved down 1px to avoid descender breaks
5860
cursor: pointer;
59-
border: 1px solid $col;
60-
color: white;
61+
color: inherit; // Keep original text color
62+
63+
// When selected, add background highlight with state color
64+
&.highlight-task-selected {
65+
background-color: $col;
66+
padding: 1px 2px;
67+
border-radius: 2px;
68+
color: white;
69+
}
70+
71+
// Stack underlines when nested - each nested level gets a larger offset with clear spacing
72+
[class^="highlight-task-"] {
73+
text-underline-offset: 7px; // Second level underline (4px spacing from first, moved down 1px)
74+
}
75+
76+
[class^="highlight-task-"] [class^="highlight-task-"] {
77+
text-underline-offset: 11px; // Third level underline (4px spacing from second, moved down 1px)
78+
// Increase line height for 3+ levels to prevent overlap with next line
79+
line-height: 2.2;
80+
padding-bottom: 4px;
81+
display: inline-block;
82+
}
83+
84+
[class^="highlight-task-"] [class^="highlight-task-"] [class^="highlight-task-"] {
85+
text-underline-offset: 15px; // Fourth level underline (4px spacing from third, moved down 1px)
86+
// Further increase line height for 4+ levels
87+
line-height: 2.4;
88+
padding-bottom: 6px;
89+
display: inline-block;
90+
}
6191
}
6292
}
6393

64-
.alert-enter-active, .alert-leave-active {
94+
.alert-enter-active,
95+
.alert-leave-active {
6596
transition: opacity .5s;
6697
}
6798

68-
.alert-enter, .alert-leave-to {
99+
.alert-enter,
100+
.alert-leave-to {
69101
opacity: 0;
70102
}
71103

72104
.overlay-message {
73105
padding-left: 10px;
74106
opacity: 0.5;
75-
}
76-
107+
}

0 commit comments

Comments
 (0)