Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/conceptmap #20

Merged
merged 13 commits into from
Mar 4, 2022
4 changes: 3 additions & 1 deletion .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ on:
push:
branches: [ develop, main ]
pull_request:
branches: [ develop, main ]
branches: [ main ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:

jobs:
build-ubuntu:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/stopship.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ jobs:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2
- name: Test for STOPSHIP comments
run: grep -rnliq src/main/{kotlin,java,antlr} -e 'stopshipasdf' && echo exit 1 || echo exit 0
run: grep -rnliq src/main/{kotlin,java,antlr} -e 'stopship' && exit 1 || exit 0
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ as the computed difference graph. To illustrate, consider these two CodeSystems:
|-----------------------------------------------|-------------------------------------------------|
| ![Left CS for diff graph](images/left-cs.png) | ![Right CS for diff graph](images/right-cs.png) |


Going from "left" to "right", the concept `C` was removed, leading to changes in the edge going from `D` to `A`. Also, a
new type of edge, `related-to` was introduced.

Expand Down Expand Up @@ -156,7 +155,9 @@ We utilize the following libraries alongside *Compose*:
- [Apache Commons Lang](https://commons.apache.org/proper/commons-lang/)
- [FlatLaf](https://www.formdev.com/flatlaf/) for dark window chrome on Windows
- [ktor](https://ktor.io) for coroutine-based HTTP
- [JavaWuzzy](https://github.com/xdrop/fuzzywuzzy) for fuzzy string matching, a port of [FuzzyWuzzy](https://pypi.org/project/fuzzywuzzy/) in Python
- [JavaWuzzy](https://github.com/xdrop/fuzzywuzzy) for fuzzy string matching, a port
of [FuzzyWuzzy](https://pypi.org/project/fuzzywuzzy/) in Python
- [RSyntaxTextArea](https://github.com/bobbylight/RSyntaxTextArea) for the JSON editor in the `ConceptMap` panel

### Localization

Expand All @@ -178,7 +179,8 @@ implementation takes in a list of column specifications that render the provided
The table supports merging adjacent columns (if a predicate returns `true`), tooltips (e.g. on the lower table, when
English is not selected as the language, the default name of the FHIR attribute is shown in the tooltip of the left-hand
column), and zebra striping. In the top table, the table data is also pre-filtered using a generic set of filter
buttons.
buttons. Column specs can declare that they are searchable, which yields a search button next to the column name. Search
parameters can be combined at will.

### Graph window

Expand Down Expand Up @@ -211,7 +213,7 @@ We are looking at implementing the following features:
- [x] support for the `vread` mechanism to compare across instance versions on FHIR Terminology servers
- [ ] a visualization of the neighborhood of any concept in the graph, to view the connections a concept has across the
network of concepts
- integrating this feature into the difference graph, so that layers of context can be added iteratively
- integrating this feature into the difference graph, so that layers of context can be added iteratively
- support for other types of resources in FHIR, especially `ValueSet` and `ConceptMap`, likely with TS support.

## How do I cite this?
Expand Down
5 changes: 3 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ repositories {
}

val hapiVersion = "5.6.2"
val slf4jVersion = "1.7.35"
val slf4jVersion = "1.7.36"
val graphStreamVersion = "2.0"
val jGraphTVersion = "1.5.1"
val material3DesktopVersion = "1.0.0"
Expand Down Expand Up @@ -51,6 +51,7 @@ dependencies {
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-cio:$ktorVersion")
implementation("me.xdrop:fuzzywuzzy:1.4.0")
implementation("com.fifesoft:rsyntaxtextarea:3.1.6")
}

tasks.test {
Expand Down Expand Up @@ -87,7 +88,7 @@ compose.desktop {
packageName = "TerminoDiff"
packageVersion = composeBuildVersion
description = "Visually compare HL7 FHIR Terminology"
vendor = "IT Center for Clinical Reserach, University of Lübeck"
vendor = "IT Center for Clinical Research, University of Lübeck"
copyright = "Joshua Wiedekopf / IT Center for Clinical Research, 2022-"

when (composeBuildOs) {
Expand Down
19 changes: 4 additions & 15 deletions src/main/kotlin/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.compose.ui.window.ApplicationScope
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import ca.uhn.fhir.context.FhirContext
import ca.uhn.fhir.narrative.DefaultThymeleafNarrativeGenerator
import com.formdev.flatlaf.FlatDarkLaf
import org.apache.commons.lang3.SystemUtils
import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi
Expand Down Expand Up @@ -61,13 +62,7 @@ fun AppWindow(
useDarkTheme: Boolean,
onChangeDarkTheme: () -> Unit,
) {

when (SystemUtils.IS_OS_WINDOWS) {
//when (useDarkTheme && SystemUtils.IS_OS_WINDOWS) {
//setting this does not make sense if not on Windows
true -> FlatDarkLaf.setup()
//else -> FlatLightLaf.setup()
}
FlatDarkLaf.setup()
var locale by remember { mutableStateOf(SupportedLocale.valueOf(AppPreferences.language)) }
val localizedStrings by derivedStateOf { getStrings(locale) }
val scrollState = rememberScrollState()
Expand All @@ -83,19 +78,13 @@ fun AppWindow(
this.window.iconImage = ImageIO.read(it.resolve("[email protected]"))
}
UIManager.setLookAndFeel(FlatDarkLaf())
when (SystemUtils.IS_OS_WINDOWS) {
//when (useDarkTheme && SystemUtils.IS_OS_WINDOWS) {
/*false -> UIManager.setLookAndFeel(FlatLightLaf())
else -> UIManager.setLookAndFeel(FlatDarkLaf())*/
true -> UIManager.setLookAndFeel(FlatDarkLaf())
}

if (!hasResizedWindow) {
// app crashes if we use state for the window, when the locale is changed, with the error
// that the window is already on screen.
// this is because everything is recomposed when the locale changes, and that breaks AWT.
// using the mutable state, we change the window size exactly once, during the first (re-) composition,
// so that the user can then change the res as they require.
// using the mutable state, we programatically change the window size exactly once,
// during the first (re-) composition, so that the user can then change the res as they require.
// A resolution of 1280x960 is 4:3.
this.window.size = Dimension(1280, 960)
hasResizedWindow = true
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package libraries.pager_indicators
package libraries.accompanist.pager_indicators

import libraries.accompanist.pager.PagerState
import androidx.compose.foundation.background
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package libraries.pager_indicators
package libraries.accompanist.pager_indicators

import libraries.accompanist.pager.PagerState
import androidx.compose.foundation.layout.fillMaxWidth
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package terminodiff.ui.util
package libraries.sahruday.carousel

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.MutatePriority
Expand Down
239 changes: 239 additions & 0 deletions src/main/kotlin/terminodiff/engine/conceptmap/ConceptMapState.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
package terminodiff.terminodiff.engine.conceptmap

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Verified
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.vector.ImageVector
import org.hl7.fhir.r4.model.ConceptMap
import org.hl7.fhir.r4.model.ConceptMap.*
import org.hl7.fhir.r4.model.DateTimeType
import org.hl7.fhir.r4.model.Enumerations
import org.hl7.fhir.r4.model.Enumerations.ConceptMapEquivalence
import org.jgrapht.GraphPath
import org.jgrapht.alg.shortestpath.AllDirectedPaths
import terminodiff.engine.resources.DiffDataContainer
import terminodiff.i18n.LocalizedStrings
import terminodiff.terminodiff.engine.graph.CombinedEdge
import terminodiff.terminodiff.engine.graph.CombinedVertex
import terminodiff.terminodiff.engine.graph.GraphSide
import terminodiff.terminodiff.ui.panes.diff.NeighborhoodDisplay

class ConceptMapState(
diffDataContainer: DiffDataContainer,
) {
fun acceptAll() = conceptMap.group.elements.forEach { element ->
element.targets.forEach { target ->
target.isAutomaticallySet = false
}
}

val conceptMap by mutableStateOf(TerminodiffConceptMap(diffDataContainer))
}

class TerminodiffConceptMap(diffDataContainer: DiffDataContainer) {

val id: MutableState<String?> = mutableStateOf(null)
val canonicalUrl: MutableState<String?> = mutableStateOf(null)
val version: MutableState<String?> = mutableStateOf(null)
val name: MutableState<String?> =
mutableStateOf(null)
val title: MutableState<String?> =
mutableStateOf(null)
val sourceValueSet: MutableState<String?> =
mutableStateOf(null)
val targetValueSet: MutableState<String?> =
mutableStateOf(null)
var group by mutableStateOf(ConceptMapGroup(diffDataContainer))


val toFhir by derivedStateOf {
ConceptMap().apply {
this.id = [email protected]
this.url = [email protected]
this.version = [email protected]
this.name = [email protected]
this.title = [email protected]
this.dateElement = DateTimeType.now()
this.group.add([email protected])
}
}

override fun toString(): String {
return "TerminodiffConceptMap(id=${id.value}, canonicalUrl=${canonicalUrl.value}, version=${version.value}, name=${name.value}, title=${title.value}, sourceValueSet=${sourceValueSet.value}, targetValueSet=${targetValueSet.value})"
}
}

class ConceptMapGroup(diffDataContainer: DiffDataContainer) {
val sourceUri = mutableStateOf(diffDataContainer.leftCodeSystem?.url)
val sourceVersion = mutableStateOf(diffDataContainer.leftCodeSystem?.version)
val targetUri = mutableStateOf(diffDataContainer.rightCodeSystem?.url)
val targetVersion = mutableStateOf(diffDataContainer.rightCodeSystem?.version)
val elements = mutableStateListOf<ConceptMapElement>()

init {
populateElements(diffDataContainer)
}

private fun populateElements(diff: DiffDataContainer) {
diff.codeSystemDiff!!.combinedGraph!!.affectedVertices.forEach { vertex ->
elements.add(ConceptMapElement(diff, vertex.code, vertex.getTooltip()))
}
}

override fun toString(): String {
return "ConceptMapGroup(sourceUri=${sourceUri.value}, sourceVersion=${sourceVersion.value}, targetUri=${targetUri.value}, targetVersion=${targetVersion.value})"
}

val toFhir: ConceptMapGroupComponent by derivedStateOf {
ConceptMapGroupComponent().apply {
this.source = [email protected]
this.sourceVersion = [email protected]
this.target = [email protected]
this.targetVersion = [email protected]
this.element.addAll([email protected] { it.toFhir }
.filter(SourceElementComponent::hasTarget))
}
}
}

class ConceptMapElement(private val diffDataContainer: DiffDataContainer, code: String, display: String?) {
val code: MutableState<String> = mutableStateOf(code)
val display: MutableState<String?> = mutableStateOf(display)

val neighborhood by derivedStateOf {
NeighborhoodDisplay(this.code.value, diffDataContainer.codeSystemDiff!!)
}

private val neighborhoodGraph by derivedStateOf {
neighborhood.getNeighborhoodGraph()
}

private val suitableTargets by derivedStateOf {
// the list of targets is calculated from the neighborhood graph of the current vertex
neighborhoodGraph.vertexSet().filter { it.code != code } // the node itself can't be mapped to
.filter { it.side == GraphSide.BOTH } // we can only map to nodes that are shared across versions
.filter { v ->
// consider nodes that are reachable from the source vertex
val paths = getPaths(getVertexByCode(code), getVertexByCode(v.code))
paths.any { p ->
p.edgeList.any { e -> e.side != GraphSide.BOTH }
//disregard those paths that are entirely following nodes in both CS versions
}
}
}

val targets = mutableStateListOf<ConceptMapTarget>().apply {
suitableTargets.forEach { t ->
this.add(ConceptMapTarget(diffDataContainer).apply {
this.code.value = t.code
this.equivalence.value = inferEquivalence([email protected], t.code)
})
}
}

val toFhir: SourceElementComponent by derivedStateOf {
SourceElementComponent().apply {
this.code = [email protected]
this.display = [email protected]
this.target.addAll([email protected] { it.state == ConceptMapTarget.MappingState.VALID }
.map { it.toFhir })
}
}

private fun inferEquivalence(sourceCode: String, targetCode: String): ConceptMapEquivalence? {
val sourceVertex = getVertexByCode(sourceCode) ?: return null
val targetVertex = getVertexByCode(targetCode) ?: return null
val (allPaths, originalOrder) = getPaths(sourceVertex, targetVertex).let { walk ->
when (walk.isEmpty()) {
true -> getPaths(targetVertex, sourceVertex) to false // flip the edge order
else -> walk to true
}
}
return when {
allPaths.isEmpty() -> null
allPaths.size == 1 -> inferEquivalenceFromPath(allPaths.first(), originalOrder)
else -> allPaths.maxByOrNull { it.length }?.let { shortestPath ->
inferEquivalenceFromPath(shortestPath, originalOrder)
}
}
}

private fun inferEquivalenceFromPath(
path: GraphPath<CombinedVertex, CombinedEdge>,
originalOrder: Boolean,
): ConceptMapEquivalence? {
return when {
path.edgeList.all { it.side == GraphSide.LEFT } -> if (originalOrder) ConceptMapEquivalence.WIDER else ConceptMapEquivalence.NARROWER
path.edgeList.all { it.side == GraphSide.RIGHT } -> if (originalOrder) ConceptMapEquivalence.NARROWER else ConceptMapEquivalence.WIDER
else -> null
}
}

private fun getVertexByCode(searchCode: String) = neighborhoodGraph.vertexSet().find { it.code == searchCode }

private fun getPaths(
sourceVertex: CombinedVertex?,
targetVertex: CombinedVertex?,
): List<GraphPath<CombinedVertex, CombinedEdge>> = when {
sourceVertex == null || targetVertex == null -> listOf()
else -> AllDirectedPaths(neighborhoodGraph).getAllPaths(sourceVertex,
targetVertex,
true,
neighborhoodGraph.edgeSet().size) // a path can't be longer than one using all edges
}


override fun toString(): String {
return "ConceptMapElement(code=${code.value}, display=${display.value})"
}
}

class ConceptMapTarget(diffDataContainer: DiffDataContainer) {
val code: MutableState<String?> = mutableStateOf(null)
val display: String? by derivedStateOf {
code.value?.let { c -> diffDataContainer.rightGraphBuilder?.nodeTree?.get(c)?.display }
}
val equivalence: MutableState<ConceptMapEquivalence?> = mutableStateOf(null)
val comment: MutableState<String?> = mutableStateOf(null)

var isAutomaticallySet by mutableStateOf(true)
private val valid by derivedStateOf {
when {
code.value == null -> false
code.value !in diffDataContainer.allCodes -> false
isAutomaticallySet -> equivalence.value != null
equivalence.value == null -> false
else -> true
}
}

val state by derivedStateOf {
when {
!valid -> MappingState.INVALID
valid && isAutomaticallySet -> MappingState.AUTO
else -> MappingState.VALID
}
}


val toFhir: TargetElementComponent by derivedStateOf {
TargetElementComponent().apply {
this.code = [email protected]
this.display = [email protected]
this.comment = [email protected]
this.equivalence = [email protected]
}
}

enum class MappingState(val image: ImageVector, val description: LocalizedStrings.() -> String) {
AUTO(Icons.Default.AutoAwesome, { automatic }), VALID(Icons.Default.Verified,
{ ok }),
INVALID(Icons.Default.Error, { invalid })
}

override fun toString(): String {
return "ConceptMapTarget(code=${code.value}, display=${display}, equivalence=${equivalence.value}, comment=${comment.value}, state=${state})"
}
}
Loading