From 9fbc5cf6791ac2485d5bbe815e98ccc45ec87e1b Mon Sep 17 00:00:00 2001
From: Julien Touchais <5978-julien.touchais@users.noreply.forgemia.inra.fr>
Date: Thu, 25 Apr 2024 09:33:17 +0200
Subject: [PATCH 1/9] feat(base-components): :sparkles: add a lockable (in
 place) tooltip component

---
 src/components/BaseLockableTooltip.vue | 115 +++++++++++++++++++++++++
 1 file changed, 115 insertions(+)
 create mode 100644 src/components/BaseLockableTooltip.vue

diff --git a/src/components/BaseLockableTooltip.vue b/src/components/BaseLockableTooltip.vue
new file mode 100644
index 0000000..52fb6f1
--- /dev/null
+++ b/src/components/BaseLockableTooltip.vue
@@ -0,0 +1,115 @@
+<script setup lang="ts">
+/**
+ * Vue imports
+ */
+import { computed, ref } from 'vue'
+/**
+ * Components imports
+ */
+import BaseTooltip from '@/components/BaseTooltip.vue'
+/**
+ * Other 3rd-party imports
+ */
+import { useMouse, onClickOutside } from '@vueuse/core'
+
+/**
+ * Component props.
+ */
+defineProps<{
+  /** Show the tooltip (even if not locked). */
+  show?: boolean
+}>()
+
+/**
+ * Component slots.
+ */
+defineSlots<{
+  /** Custom tooltip template. */
+  default: (props: {}) => any
+}>()
+
+/**
+ * Component events.
+ */
+const emit = defineEmits<{
+  /** Emitted when locking the tooltip. */
+  lock: []
+  /** Emitted when unlocking the tooltip. */
+  unlock: []
+}>()
+
+/**
+ * Reactive position of the mouse in the document.
+ */
+const mouse = useMouse({ type: 'client' })
+
+/**
+ * Location of the tooltip when locked in place, undefined if not locked.
+ */
+const lockedTooltipPosition = ref<{ bottom: number; left: number }>()
+
+/**
+ * Locks the tooltip in its place and add a scroll listener.
+ */
+const lockTooltipIfUnlocked = () => {
+  if (lockedTooltipPosition.value) return
+  // Set locked position
+  lockedTooltipPosition.value = {
+    bottom: tooltipComponentPositions.value.bottom,
+    left: tooltipComponentPositions.value.left
+  }
+  // Add scroll event listener to unlock if scroll
+  document.addEventListener('scroll', unlockTooltipIfLocked)
+  emit('lock')
+}
+
+/**
+ * Unlocks the tooltip when clicking out of it.
+ */
+const unlockTooltipIfLocked = () => {
+  if (!lockedTooltipPosition.value) return
+  // Reset position
+  lockedTooltipPosition.value = undefined
+  // Remove scroll event listener
+  document.removeEventListener('scroll', unlockTooltipIfLocked)
+  emit('unlock')
+}
+
+/**
+ * Ref to the tooltip component.
+ */
+const tooltipComponent = ref<InstanceType<typeof BaseTooltip>>()
+
+/**
+ * Position of the tooltip component relative to the viewport.
+ */
+const tooltipComponentPositions = computed(() => ({
+  bottom:
+    lockedTooltipPosition.value?.bottom ||
+    window.innerHeight - mouse.y.value + 10,
+  left: lockedTooltipPosition.value?.left || mouse.x.value
+}))
+
+/**
+ *  Adds a listener to unlock the tooltip when clicking outside of it.
+ */
+onClickOutside(tooltipComponent, unlockTooltipIfLocked)
+
+/**
+ * Exposes methods to un/lock the tooltip.
+ */
+defineExpose({ unlockTooltipIfLocked, lockTooltipIfUnlocked })
+</script>
+
+<template>
+  <BaseTooltip
+    v-show="show || lockedTooltipPosition"
+    ref="tooltipComponent"
+    :style="{
+      bottom: `${tooltipComponentPositions.bottom}px`,
+      left: `${tooltipComponentPositions.left}px`
+    }"
+  >
+    <slot />
+  </BaseTooltip>
+</template>
-- 
GitLab


From c00c26c0ab7434edcf937e763c394d97bcbdf69b Mon Sep 17 00:00:00 2001
From: Julien Touchais <5978-julien.touchais@users.noreply.forgemia.inra.fr>
Date: Thu, 25 Apr 2024 09:51:53 +0200
Subject: [PATCH 2/9] refactor(karyotype): :coffin: remove unused code & update
 comments

---
 src/components/ChromosomeMagnify.vue | 65 +++++++++-------------------
 1 file changed, 21 insertions(+), 44 deletions(-)

diff --git a/src/components/ChromosomeMagnify.vue b/src/components/ChromosomeMagnify.vue
index eeac915..78a8152 100644
--- a/src/components/ChromosomeMagnify.vue
+++ b/src/components/ChromosomeMagnify.vue
@@ -18,12 +18,12 @@ import { G, Rect, SVG, type Svg } from '@svgdotjs/svg.js'
 import type { ChromosomeModel, GenomeObjectModel } from './KaryotypeBoard.vue'
 
 /**
- * In pixels, the minimal unzoomed size at which to draw karyotype objects
+ * In pixels, the minimal unzoomed size at which to draw karyotype objects.
  */
 const MIN_BASE_OBJECT_LENGTH_PX = 4
 
 /**
- * Component props
+ * Component props.
  */
 const props = withDefaults(
   defineProps<{
@@ -50,7 +50,7 @@ const props = withDefaults(
 
 /**
  * Dimensions of the base chromosome element, position of the mouse relative to
- * the top left of the client, and to the chromosome element
+ * the top left of the client, and to the chromosome element.
  */
 const { chromosomeDimensions, mouseClient, mouseChromosome } = (() => {
   const { elementHeight, elementWidth, elementX, elementY, isOutside, x, y } =
@@ -75,12 +75,12 @@ const { chromosomeDimensions, mouseClient, mouseChromosome } = (() => {
 })()
 
 /**
- * Ref to the div containing the chromosome SVG
+ * Ref to the div containing the chromosome SVG.
  */
 const magnifiedChromosome = ref<HTMLDivElement>()
 
 /**
- * Position of the magnify div relative to the viewport
+ * Position of the magnify div relative to the viewport.
  */
 const magnifyElementPositions = computed(() => ({
   top: mouseClient.y.value,
@@ -93,17 +93,18 @@ const lockedTooltipPosition = ref<{ bottom: number; left: number }>()
 const lockOffset = ref(0)
 
 /**
- * DOM elements of objects currently hovered
+ * DOM elements of objects currently hovered, to display in the tooltip when
+ * not locked.
  */
 const hoveredObjectsElements = ref<SVGElement[]>([])
 
 /**
- * DOM elements corresponding to objects to display in the tooltip when locked
+ * DOM elements of objects to display in the tooltip when locked.
  */
 const lockedTooltipObjectsElements = ref<SVGElement[]>([])
 
 /**
- * DOM elements corresponding to objects to display in the tooltip
+ * DOM elements of objects to display in the tooltip.
  */
 const tooltipObjectsElements = computed(() =>
   lockedTooltipPosition.value
@@ -156,7 +157,7 @@ const tooltipComponentPositions = computed(() => ({
 }))
 
 /**
- * Normalised position of the mouse on the chromosome (between 0 & 1)
+ * Normalised position of the mouse on the chromosome (between 0 & 1).
  */
 const mousePositionNormalised = computed(() => ({
   x: mouseChromosome.x.value / chromosomeDimensions.width.value,
@@ -164,37 +165,7 @@ const mousePositionNormalised = computed(() => ({
 }))
 
 /**
- * Position of the mouse in terms of chromosome coordinate
- */
-const mousePositionChromosomeCoordinate = computed(() =>
-  Math.round(mousePositionNormalised.value.x * props.chromosome.length)
-)
-
-/**
- * Length (in bp) of the magnified portion, w/ respect to the zoom factor
- */
-const magnifiedPortionLengthBP = computed(() =>
-  Math.round(
-    ((props.radius / chromosomeDimensions.width.value) *
-      props.chromosome.length) /
-      props.zoomFactor
-  )
-)
-
-/**
- * Coordinates of the magnified chromosome portion
- */
-const magnifiedPortionCoordinates = computed(() => ({
-  start:
-    mousePositionChromosomeCoordinate.value -
-    Math.floor(magnifiedPortionLengthBP.value / 2),
-  end:
-    mousePositionChromosomeCoordinate.value +
-    Math.floor(magnifiedPortionLengthBP.value / 2)
-}))
-
-/**
- * Dimensions (in px) of the entire magnified chromosome
+ * Dimensions (in px) of the entire magnified chromosome.
  */
 const magnifiedChromosomeDimensionsPX = computed(() => ({
   length: chromosomeDimensions.width.value * props.zoomFactor,
@@ -202,7 +173,7 @@ const magnifiedChromosomeDimensionsPX = computed(() => ({
 }))
 
 /**
- * Translation applied to the magnified chromosome
+ * Translation applied to the magnified chromosome.
  */
 const magnifiedChromosomeTranslate = computed(() => ({
   x:
@@ -215,12 +186,12 @@ const magnifiedChromosomeTranslate = computed(() => ({
 }))
 
 /**
- * Ref to the SVG representation of the chromosome
+ * Ref to the SVG representation of the chromosome.
  */
 const draw = ref<Svg>()
 
 /**
- * Creates the elements forming the chromosome SVG
+ * Creates the elements forming the chromosome SVG.
  */
 const createChromosomeSVG = () => {
   if (!magnifiedChromosome.value) {
@@ -291,6 +262,8 @@ const createChromosomeSVG = () => {
     })
     hoveredObjectsElements.value = hoveredObjectsElementsNew
   })
+
+  // Clear style of no-longer-hovered elements
   objectsGroup.mouseleave(() => {
     hoveredObjectsElements.value.forEach((objectElement) => {
       objectElement.setAttribute(
@@ -304,6 +277,7 @@ const createChromosomeSVG = () => {
     hoveredObjectsElements.value = []
   })
 
+  // Lock the tooltip when clicking on any chromosome object
   objectsGroup.click(lockTooltipIfUnlocked)
 
   // Create the clip of the object's group, by cloning the chromosome body,
@@ -316,7 +290,7 @@ const createChromosomeSVG = () => {
 }
 
 /**
- * Updates the size & position of the elements in the chromosome SVG
+ * Updates the size & position of the elements in the chromosome SVG.
  */
 const updateChromosomeSVG = () => {
   if (!draw.value) {
@@ -439,6 +413,9 @@ const updateChromosomeSVG = () => {
   objectsGroup.transform(groupsTransforms)
 }
 
+/**
+ * Creates and updates the SVG elements of the chromosome on mount.
+ */
 onMounted(() => {
   createChromosomeSVG()
   updateChromosomeSVG()
-- 
GitLab


From 02db6cd5d75c48570dd4cf915c1f7028f04028dd Mon Sep 17 00:00:00 2001
From: Julien Touchais <5978-julien.touchais@users.noreply.forgemia.inra.fr>
Date: Thu, 25 Apr 2024 09:53:29 +0200
Subject: [PATCH 3/9] refactor(karyotype): :recycle: use lockable tooltip
 component

---
 src/components/ChromosomeMagnify.vue | 76 ++++++----------------------
 1 file changed, 16 insertions(+), 60 deletions(-)

diff --git a/src/components/ChromosomeMagnify.vue b/src/components/ChromosomeMagnify.vue
index 78a8152..0c339e8 100644
--- a/src/components/ChromosomeMagnify.vue
+++ b/src/components/ChromosomeMagnify.vue
@@ -6,11 +6,11 @@ import { computed, onMounted, ref } from 'vue'
 /**
  * Components imports
  */
-import BaseTooltip from './BaseTooltip.vue'
+import BaseLockableTooltip from '@/components/BaseLockableTooltip.vue'
 /**
  * Other 3rd-party imports
  */
-import { onClickOutside, useMouseInElement } from '@vueuse/core'
+import { useMouseInElement } from '@vueuse/core'
 import { G, Rect, SVG, type Svg } from '@svgdotjs/svg.js'
 /**
  * Types imports
@@ -86,11 +86,6 @@ const magnifyElementPositions = computed(() => ({
   top: mouseClient.y.value,
   left: mouseClient.x.value
 }))
-/**
- * Location of the tooltip when locked in place, undefined if not locked
- */
-const lockedTooltipPosition = ref<{ bottom: number; left: number }>()
-const lockOffset = ref(0)
 
 /**
  * DOM elements of objects currently hovered, to display in the tooltip when
@@ -101,61 +96,28 @@ const hoveredObjectsElements = ref<SVGElement[]>([])
 /**
  * DOM elements of objects to display in the tooltip when locked.
  */
-const lockedTooltipObjectsElements = ref<SVGElement[]>([])
+const lockedTooltipObjectsElements = ref<SVGElement[]>()
 
 /**
  * DOM elements of objects to display in the tooltip.
  */
-const tooltipObjectsElements = computed(() =>
-  lockedTooltipPosition.value
-    ? lockedTooltipObjectsElements.value
-    : hoveredObjectsElements.value
+const tooltipObjectsElements = computed(
+  () => lockedTooltipObjectsElements.value || hoveredObjectsElements.value
 )
 
 /**
- * Lock the tooltip in its place and add a scroll listener
+ * Ref to the tooltip component.
  */
-const lockTooltipIfUnlocked = () => {
-  if (lockedTooltipPosition.value) return
-  lockedTooltipObjectsElements.value = hoveredObjectsElements.value
-  // Set locked position
-  lockedTooltipPosition.value = {
-    bottom: tooltipComponentPositions.value.bottom,
-    left: tooltipComponentPositions.value.left
-  }
-  // Save lock offset
-  lockOffset.value = magnifiedChromosomeTranslate.value.x
-  // Add scroll event listener to unlock if scroll
-  document.addEventListener('scroll', unlockTooltipIfLocked)
-}
+const tooltipComponent = ref<InstanceType<typeof BaseLockableTooltip>>()
 
 /**
- * Unlock the tooltip when clicking out of it
+ * Locks the tooltip and the values to display in it.
  */
-const unlockTooltipIfLocked = () => {
-  if (!lockedTooltipPosition.value) return
-  // Reset position
-  lockedTooltipPosition.value = undefined
-  // Remove scroll event listener
-  document.removeEventListener('scroll', unlockTooltipIfLocked)
+const lockTooltip = () => {
+  lockedTooltipObjectsElements.value = hoveredObjectsElements.value
+  tooltipComponent.value?.lockTooltipIfUnlocked()
 }
 
-/**
- * Ref to the tooltip component
- */
-const tooltipComponent = ref()
-
-/**
- * Position of the tooltip component relative to the viewport
- */
-const tooltipComponentPositions = computed(() => ({
-  bottom:
-    lockedTooltipPosition.value?.bottom ||
-    window.innerHeight - mouseClient.y.value + 10,
-  // bottom: objectsGroupTop.value.value - mouseClient.y.value,
-  left: lockedTooltipPosition.value?.left || mouseClient.x.value
-}))
-
 /**
  * Normalised position of the mouse on the chromosome (between 0 & 1).
  */
@@ -278,7 +240,7 @@ const createChromosomeSVG = () => {
   })
 
   // Lock the tooltip when clicking on any chromosome object
-  objectsGroup.click(lockTooltipIfUnlocked)
+  objectsGroup.click(lockTooltip)
 
   // Create the clip of the object's group, by cloning the chromosome body,
   // and apply the clip
@@ -420,9 +382,6 @@ onMounted(() => {
   createChromosomeSVG()
   updateChromosomeSVG()
 })
-
-// Add a listener to unlock the tooltip when clicking outside of it
-onClickOutside(tooltipComponent, unlockTooltipIfLocked)
 </script>
 
 <template>
@@ -443,13 +402,10 @@ onClickOutside(tooltipComponent, unlockTooltipIfLocked)
       }"
     ></div>
   </div>
-  <BaseTooltip
-    v-show="hoveredObjectsElements.length || lockedTooltipPosition"
+  <BaseLockableTooltip
     ref="tooltipComponent"
-    :style="{
-      bottom: `${tooltipComponentPositions.bottom}px`,
-      left: `${tooltipComponentPositions.left}px`
-    }"
+    :show="!!hoveredObjectsElements.length"
+    @unlock="lockedTooltipObjectsElements = undefined"
   >
     <div class="flex flex-col gap-2">
       <RouterLink
@@ -464,5 +420,5 @@ onClickOutside(tooltipComponent, unlockTooltipIfLocked)
         {{ objectElement.dataset.name }}
       </RouterLink>
     </div>
-  </BaseTooltip>
+  </BaseLockableTooltip>
 </template>
-- 
GitLab


From 661ffe884f4c8187d89ad865d782bee3f6907893 Mon Sep 17 00:00:00 2001
From: Julien Touchais <5978-julien.touchais@users.noreply.forgemia.inra.fr>
Date: Thu, 25 Apr 2024 17:11:25 +0200
Subject: [PATCH 4/9] feat(base-components): :lipstick: add a border to legend
 component to be consistent with tooltips

---
 src/components/BaseLegendButtonOverlay.vue | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/components/BaseLegendButtonOverlay.vue b/src/components/BaseLegendButtonOverlay.vue
index 346c550..5eef2a5 100644
--- a/src/components/BaseLegendButtonOverlay.vue
+++ b/src/components/BaseLegendButtonOverlay.vue
@@ -101,6 +101,7 @@ const toggleLegend = (event: Event) => {
       :style="{
         opacity: opacity && 0 <= opacity && opacity <= 1 ? opacity : 1
       }"
+      class="!border !border-solid !border-slate-200"
     >
       <slot name="legend">
         <ul class="flex max-w-lg flex-col gap-3">
-- 
GitLab


From dd8b7841dee6a552cd24127331082a34bf9adddd Mon Sep 17 00:00:00 2001
From: Julien Touchais <5978-julien.touchais@users.noreply.forgemia.inra.fr>
Date: Thu, 25 Apr 2024 17:12:22 +0200
Subject: [PATCH 5/9] fix(base-components): :adhesive_bandage: tooltip arrow
 was above the tooltip content

---
 src/components/BaseTooltip.vue | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/components/BaseTooltip.vue b/src/components/BaseTooltip.vue
index 1da727a..b3cdb78 100644
--- a/src/components/BaseTooltip.vue
+++ b/src/components/BaseTooltip.vue
@@ -78,7 +78,7 @@ const tooltipAfterStyle = computed(() => {
     :class="[
       'tooltip fixed flex flex-col items-center rounded border bg-white p-2 transition-all duration-500',
       showArrow &&
-        'after:absolute after:h-4 after:w-4 after:rounded-sm after:border-b after:border-r after:bg-white'
+        'after:absolute after:-z-10 after:h-4 after:w-4 after:rounded-sm after:border-b after:border-r after:bg-white'
     ]"
     :style="tooltipStyle"
   >
-- 
GitLab


From d54cf3ab7843b2ad06ccf52391182093a57fced3 Mon Sep 17 00:00:00 2001
From: Julien Touchais <5978-julien.touchais@users.noreply.forgemia.inra.fr>
Date: Thu, 25 Apr 2024 17:29:18 +0200
Subject: [PATCH 6/9] feat(linear-sequence): :sparkles: add a tooltip to show
 info on hover highlighted groups

---
 src/components/SequenceBoard.vue | 115 ++++++++++++++++++++++++++++++-
 1 file changed, 113 insertions(+), 2 deletions(-)

diff --git a/src/components/SequenceBoard.vue b/src/components/SequenceBoard.vue
index 5583f97..d7dbfe7 100644
--- a/src/components/SequenceBoard.vue
+++ b/src/components/SequenceBoard.vue
@@ -7,6 +7,7 @@ import { ref, computed, onMounted, watch } from 'vue'
  * Components imports
  */
 import BaseLegendButtonOverlay from '@/components/BaseLegendButtonOverlay.vue'
+import BaseLockableTooltip from '@/components/BaseLockableTooltip.vue'
 import Dropdown from 'primevue/dropdown'
 import Button from 'primevue/button'
 import Toolbar from 'primevue/toolbar'
@@ -28,6 +29,7 @@ import { inRange as _inRange } from 'lodash-es'
  */
 import type { TailwindDefaultColorNameModel } from '@/typings/styleTypes'
 import type { LegendItemModel } from '@/components/BaseLegendButtonOverlay.vue'
+import type { RouteLocationRaw } from 'vue-router'
 /**
  * Utils imports
  */
@@ -48,6 +50,10 @@ export interface highlightGroupModel {
   name?: string
   /** Group type. */
   type?: string
+  /** Link to go to when clicking on the group. */
+  link?: RouteLocationRaw
+  /** Wether to show a tooltip on hover on this group. */
+  shouldTooltip?: boolean
 }
 
 /**
@@ -96,6 +102,13 @@ defineSlots<{
   'legend-item-description': (props: {
     /** The item of which to customise the description. */ item: LegendItemModel
   }) => any
+  /** Custom tooltip group items template */
+  'tooltip-item': (props: {
+    /** The group of the item to customise */
+    group: highlightGroupModel
+    /** The ID of the group of the item to customise */
+    groupId: string
+  }) => any
 }>()
 
 /**
@@ -561,6 +574,47 @@ watch(isSequenceContainerElementVisible, (visibility) => {
     updateAndRedraw()
   }
 })
+
+/**
+ * The highlighted boxes currently being hovered.
+ */
+const hoveredHighlightedGroups = ref(new Set<[string, highlightGroupModel]>())
+
+/**
+ * The highlighted boxes currently being hovered.
+ */
+const lockedTooltipHighlightedGroups = ref<Set<[string, highlightGroupModel]>>()
+
+/**
+ * DOM elements of objects to display in the tooltip.
+ */
+const tooltipHighlightedGroups = computed(
+  () => lockedTooltipHighlightedGroups.value || hoveredHighlightedGroups.value
+)
+
+/**
+ * Lockable tooltip component.
+ */
+const tooltipComponent = ref<InstanceType<typeof BaseLockableTooltip>>()
+
+/**
+ * Locks the tooltip and the values to display in it.
+ */
+const lockTooltip = () => {
+  lockedTooltipHighlightedGroups.value = new Set(hoveredHighlightedGroups.value)
+  tooltipComponent.value?.lockTooltipIfUnlocked()
+}
+
+const deleteHoveredHighlightedGroup = (
+  highlightedGroupTuple: [string, highlightGroupModel]
+) => {
+  const hoveredHighlightedGroupTuple = [...hoveredHighlightedGroups.value].find(
+    ([hoveredHighlightedGroupId]) =>
+      hoveredHighlightedGroupId === highlightedGroupTuple[0]
+  )
+  hoveredHighlightedGroupTuple &&
+    hoveredHighlightedGroups.value.delete(hoveredHighlightedGroupTuple)
+}
 </script>
 
 <template>
@@ -674,6 +728,7 @@ watch(isSequenceContainerElementVisible, (visibility) => {
           {{ nucleotide }}
         </span>
       </span>
+
       <span
         v-for="(_sequenceGroup, groupIndex) in splitSequence"
         :key="groupIndex"
@@ -683,6 +738,7 @@ watch(isSequenceContainerElementVisible, (visibility) => {
       >
         {{ groupIndex * nucleotideGroupsSize + 1 }}
       </span>
+
       <span
         v-for="[highlightedGroupId, highlightedGroup] in Object.entries(
           highlightedGroups || {}
@@ -692,6 +748,14 @@ watch(isSequenceContainerElementVisible, (visibility) => {
         :data-start="highlightedGroup.start"
         :data-end="highlightedGroup.end"
         class="highlight-group"
+        @mouseenter="
+          highlightedGroup.shouldTooltip &&
+            hoveredHighlightedGroups.add([highlightedGroupId, highlightedGroup])
+        "
+        @mouseleave="
+          deleteHoveredHighlightedGroup([highlightedGroupId, highlightedGroup])
+        "
+        @click="lockTooltip"
       >
         <span
           v-for="highlightedGroupLine in range(
@@ -709,11 +773,58 @@ watch(isSequenceContainerElementVisible, (visibility) => {
               'rounded-r-xl border-r-2':
                 highlightedGroupsLineCount &&
                 highlightedGroupLine + 1 ===
-                  highlightedGroupsLineCount[highlightedGroupId]
+                  highlightedGroupsLineCount[highlightedGroupId],
+              'z-10  hover:cursor-help': highlightedGroup.shouldTooltip
             }
           ]"
-        ></span>
+        />
       </span>
+
+      <BaseLockableTooltip
+        ref="tooltipComponent"
+        :show="!!hoveredHighlightedGroups.size"
+        class="z-20 max-w-min font-sans text-base shadow-xl"
+        @unlock="lockedTooltipHighlightedGroups = undefined"
+      >
+        <ul>
+          <li
+            v-for="(highlightedGroup, index) in tooltipHighlightedGroups"
+            :key="index"
+          >
+            <RouterLink
+              v-if="highlightedGroup[1].link"
+              :to="highlightedGroup[1].link"
+              :class="[
+                `text-${highlightedGroup[1].color}-600`,
+                'whitespace-nowrap'
+              ]"
+            >
+              <slot
+                name="tooltip-item"
+                :group="highlightedGroup[1]"
+                :group-id="highlightedGroup[0]"
+              >
+                {{ highlightedGroup[1].name || highlightedGroup[1].link }}
+                {{
+                  highlightedGroup[1].type && ` - ${highlightedGroup[1].type}`
+                }}
+              </slot>
+            </RouterLink>
+            <span v-else>
+              <slot
+                name="tooltip-item"
+                :group="highlightedGroup[1]"
+                :group-id="highlightedGroup[0]"
+              >
+                {{ highlightedGroup[1].name || highlightedGroup[1].link }}
+                {{
+                  highlightedGroup[1].type && ` - ${highlightedGroup[1].type}`
+                }}
+              </slot>
+            </span>
+          </li>
+        </ul>
+      </BaseLockableTooltip>
     </div>
     <span class="absolute italic text-slate-400" :style="threePrimeLabelStyle"
       >&rarr; 3'</span
-- 
GitLab


From 3888f35b45045ad2eeb212da9cadd8e0cf69d116 Mon Sep 17 00:00:00 2001
From: Julien Touchais <5978-julien.touchais@users.noreply.forgemia.inra.fr>
Date: Fri, 26 Apr 2024 09:12:11 +0200
Subject: [PATCH 7/9] feat(utils): :sparkles: add util to check if value is in
 an enum (& narrow)

---
 src/typings/typeUtils.ts | 43 ++++++++++++++++++++++++++++++++++++++++
 1 file changed, 43 insertions(+)

diff --git a/src/typings/typeUtils.ts b/src/typings/typeUtils.ts
index 3829cb3..717ecb3 100644
--- a/src/typings/typeUtils.ts
+++ b/src/typings/typeUtils.ts
@@ -33,10 +33,53 @@ export type DeepDefined<T> = T extends object
     }>
   : T
 
+/**
+ * Checks if a value is defined, narrowing by excluding `undefined` if yes.
+ * @param value The value to check.
+ */
 export const isDefined = <T>(value: T): value is Exclude<T, undefined> =>
   typeof value !== 'undefined'
 
+/**
+ * Checks if a value is a key of an object, narrowing its type to object
+ * properties' one if yes.
+ * @param value The value to check if it is a key of the object.
+ * @param object The object in which to check properties.
+ */
 export const isIn = <T extends object>(
   value: any,
   object: T
 ): value is keyof T => value in object
+
+/**
+ * Checks if a value is present in a string enum, narrowing its type to enum's
+ * one if yes.
+ * @param value The value to check if it is present in enum.
+ * @param enumType The enum to check into.
+ */
+export function isInEnum<T extends Record<string, string>>(
+  value: string,
+  enumType: T
+): value is T[keyof T]
+/**
+ * Checks if a value is present in a numeric enum, narrowing its type to enum's
+ * one if yes.
+ * @param value The value to check if it is present in enum.
+ * @param enumType The enum to check into.
+ */
+export function isInEnum<T extends Record<string, string | number>>(
+  value: string | number,
+  enumType: T
+): value is T[keyof T]
+export function isInEnum<T extends Record<string, string | number>>(
+  value: string | number,
+  enumType: T
+): value is T[keyof T] {
+  if (typeof value === 'string') {
+    return Object.values(enumType).includes(value)
+  } else {
+    return Object.values(enumType)
+      .filter((v) => typeof v === 'number')
+      .includes(value)
+  }
+}
-- 
GitLab


From 2df7e1bca78d1ff168ab068ee0db348d5dc26017 Mon Sep 17 00:00:00 2001
From: Julien Touchais <5978-julien.touchais@users.noreply.forgemia.inra.fr>
Date: Fri, 26 Apr 2024 09:17:55 +0200
Subject: [PATCH 8/9] feat(target-view): :sparkles: add tooltip & links on
 modifs in sequence

---
 src/views/TargetView.vue | 41 ++++++++++++++++++++++++++++++++--------
 1 file changed, 33 insertions(+), 8 deletions(-)

diff --git a/src/views/TargetView.vue b/src/views/TargetView.vue
index b93eadb..d027676 100644
--- a/src/views/TargetView.vue
+++ b/src/views/TargetView.vue
@@ -28,7 +28,7 @@ import IconFa6SolidCircleXmark from '~icons/fa6-solid/circle-xmark'
 /**
  * Other 3rd-party imports
  */
-import { uniq as _uniq, omit as _omit, find as _find } from 'lodash-es'
+import { omit as _omit, find as _find } from 'lodash-es'
 import { useQuery } from '@urql/vue'
 import { useFetch, useTitle } from '@vueuse/core'
 /**
@@ -44,7 +44,7 @@ import { FeatureType, ModifType, Strand } from '@/gql/codegen/graphql'
  */
 import { formatSpeciesName, separateThousands } from '@/utils/textFormatting'
 import { targetByIdQuery } from '@/gql/queries'
-import { isDefined } from '@/typings/typeUtils'
+import { isDefined, isInEnum } from '@/typings/typeUtils'
 import { getModificationColor } from '@/utils/colors'
 import type { LegendItemModel } from '@/components/BaseLegendButtonOverlay.vue'
 
@@ -256,12 +256,11 @@ const interactionList = computed<InteractionCardModel[] | undefined>(() => {
 /**
  * Target modifications, filtered by keeping only ones with positive position
  */
-const filteredModifications = computed(() =>
-  _uniq(
+const filteredModifications = computed(
+  () =>
     target.value?.modifications.filter(
       (modification) => modification.position >= 0
-    )
-  )
+    ) || []
 )
 
 /**
@@ -281,7 +280,14 @@ const sequenceChunks = computed(() =>
           ? getModificationColor(modification.type)
           : ('slate' as TailwindDefaultColorNameModel),
         name: modification.name,
-        type: modification.type || undefined
+        type: modification.type || undefined,
+        link: {
+          name: 'modificationDetails',
+          params: {
+            id: modification.id
+          }
+        },
+        shouldTooltip: true
       }
     }),
     {}
@@ -604,6 +610,22 @@ const ontologyLinks = computed(() => ({
             long-format
           />
         </template>
+
+        <template #tooltip-item="{ group }">
+          <Chip
+            v-if="group.type && isInEnum(group.type, ModifType)"
+            :class="[
+              'border-2 !font-semibold',
+              `!border-${getModificationColor(
+                group.type
+              )}-600 !bg-${getModificationColor(
+                group.type
+              )}-100 !text-${getModificationColor(group.type)}-600`
+            ]"
+          >
+            {{ group.name }}
+          </Chip>
+        </template>
       </SequenceBoard>
     </Panel>
 
@@ -633,7 +655,10 @@ const ontologyLinks = computed(() => ({
           >
             <template #item-label-1="{ currentValue }">
               <strong>{{ currentValue.modification.name }}</strong>
-              <em v-if="currentValue.modification.type" class="italic text-slate-400">
+              <em
+                v-if="currentValue.modification.type"
+                class="italic text-slate-400"
+              >
                 {{ ' - ' }}
                 <FormattedModificationType
                   :type-code="currentValue.modification.type"
-- 
GitLab


From 30a5d5010d0afc44afbbd2da9c94a11070f6d582 Mon Sep 17 00:00:00 2001
From: Julien Touchais <5978-julien.touchais@users.noreply.forgemia.inra.fr>
Date: Fri, 26 Apr 2024 09:18:26 +0200
Subject: [PATCH 9/9] feat(guide-view): :sparkles: add tooltip & link on
 modifications & boxes on sequence

---
 src/views/GuideView.vue | 142 +++++++++++++++++++++++++++-------------
 1 file changed, 96 insertions(+), 46 deletions(-)

diff --git a/src/views/GuideView.vue b/src/views/GuideView.vue
index 4cab84f..75ad379 100644
--- a/src/views/GuideView.vue
+++ b/src/views/GuideView.vue
@@ -27,7 +27,7 @@ import IconFa6SolidCircleXmark from '~icons/fa6-solid/circle-xmark'
 /**
  * Other 3rd-party imports
  */
-import { omit as _omit } from 'lodash-es'
+import { omit as _omit, uniq as _uniq } from 'lodash-es'
 import { useQuery } from '@urql/vue'
 import { useTitle } from '@vueuse/core'
 /**
@@ -40,6 +40,7 @@ import {
   FeatureType,
   GuideType,
   SequenceType,
+  ModifType,
   Strand
 } from '@/gql/codegen/graphql'
 import type { LegendItemModel } from '@/components/BaseLegendButtonOverlay.vue'
@@ -52,8 +53,8 @@ import {
   separateThousands
 } from '@/utils/textFormatting'
 import { guideByIdQuery } from '@/gql/queries'
-import { isDefined } from '@/typings/typeUtils'
-import { getBoxColor } from '@/utils/colors'
+import { isDefined, isInEnum } from '@/typings/typeUtils'
+import { getBoxColor, getModificationColor } from '@/utils/colors'
 
 /**
  * Utility constant to get the icon component corresponding to each strand value.
@@ -192,35 +193,41 @@ const interactionList = computed<InteractionCardModel[] | undefined>(() => {
 })
 
 /**
- * Positions of the modifications, relative to the guide sequence.
+ * Guide modifications, filtered by keeping only ones with positive position, relative to the guide sequence.
  */
-const onGuideModificationPositions = computed(() =>
-  interactionList.value?.reduce(
-    (
-      onGuideModificationPositions: { [modificationId: string]: number },
-      interaction
-    ) => {
-      const position =
-        interaction.duplexes[0] &&
-        interaction.duplexes[0].primaryFragment.onParentPosition?.start &&
-        interaction.duplexes[0].secondaryFragment.onParentPosition?.end
-          ? interaction.duplexes[0].primaryFragment.onParentPosition.start +
-            (interaction.duplexes[0].secondaryFragment.start -
-              interaction.duplexes[0].primaryFragment.start) +
-            (interaction.duplexes[0].secondaryFragment.onParentPosition.end -
-              interaction.modification.position) +
-            1
+const filteredFacingModifications = computed(() =>
+  _uniq(
+    interactionList.value
+      ?.map((interaction) => {
+        // Start coordinate of the guide fragment on the guide
+        const guideFragmentStart =
+          interaction.duplexes[0]?.primaryFragment.onParentPosition?.start ||
+          NaN
+        // End coordinate of the target fragment on the target
+        const targetFragmentEnd =
+          interaction.duplexes[0]?.secondaryFragment.onParentPosition?.end ||
+          NaN
+        // Start coordinate of the binding on the guide fragment
+        const bindingGuideStart =
+          interaction.duplexes[0]?.primaryFragment.start || NaN
+        // End coordinate of the binding on the target fragment
+        const bindingTargetEnd =
+          interaction.duplexes[0]?.secondaryFragment.end || NaN
+        // Length of the target fragment
+        const targetFragmentLength =
+          interaction.duplexes[0]?.secondaryFragment.seq.length || NaN
+
+        const position =
+          guideFragmentStart +
+          (bindingGuideStart - 1) +
+          (targetFragmentEnd -
+            (targetFragmentLength - bindingTargetEnd) -
+            interaction.modification.position)
+        return position && position >= 0
+          ? { ...interaction.modification, facingPosition: position }
           : undefined
-      // Only keep modification position if positive (! on the guide, this is
-      // why we can't filter before computing it)
-      return position && position >= 0
-        ? {
-            ...onGuideModificationPositions,
-            [interaction.modification.id]: position
-          }
-        : onGuideModificationPositions
-    },
-    {}
+      })
+      .filter(isDefined)
   )
 )
 
@@ -236,27 +243,36 @@ const sequenceChunks = computed(() => ({
       [featureEdge.node.id]: {
         start: Math.max(featureEdge.start, 1),
         end: Math.min(featureEdge.end, guide.value?.length || featureEdge.end),
-        color: getBoxColor(featureEdge.node.type)
+        color: getBoxColor(featureEdge.node.type),
+        type: featureEdge.node.type,
+        shouldTooltip: true
       }
     }),
     {}
   ),
-  ...(onGuideModificationPositions.value
-    ? Object.entries(onGuideModificationPositions.value).reduce(
-        (
-          sequenceChunks: { [groupId: string]: highlightGroupModel },
-          [modificationId, modificationPosition]
-        ) => ({
-          ...sequenceChunks,
-          [modificationId]: {
-            start: modificationPosition,
-            end: modificationPosition,
-            color: 'slate' as TailwindDefaultColorNameModel
+  ...filteredFacingModifications.value?.reduce(
+    (
+      sequenceChunks: { [groupId: string]: highlightGroupModel },
+      facingModification
+    ) => ({
+      ...sequenceChunks,
+      [facingModification.id]: {
+        start: facingModification.facingPosition,
+        end: facingModification.facingPosition,
+        color: 'slate' as TailwindDefaultColorNameModel,
+        name: facingModification.name,
+        type: facingModification.type,
+        link: {
+          name: 'modificationDetails',
+          params: {
+            id: facingModification.id
           }
-        }),
-        {}
-      )
-    : undefined)
+        },
+        shouldTooltip: true
+      }
+    }),
+    {}
+  )
 }))
 
 /**
@@ -669,6 +685,40 @@ const ontologyLinks = computed(() => ({
             >{{ item.title.slice(1) }}
           </span>
         </template>
+
+        <template #tooltip-item="{ group }">
+          <Chip
+            v-if="group.type && isInEnum(group.type, ModifType)"
+            :class="[
+              'border-2 !font-semibold',
+              `!border-${getModificationColor(
+                group.type
+              )}-600 !bg-${getModificationColor(
+                group.type
+              )}-100 !text-${getModificationColor(group.type)}-600`
+            ]"
+          >
+            {{ group.name }}
+          </Chip>
+          <span
+            v-else-if="group.type === SequenceType.CBox"
+            :class="[
+              'whitespace-nowrap font-bold not-italic',
+              `text-${getBoxColor(group.type)}-600`
+            ]"
+          >
+            <em>C</em> box
+          </span>
+          <span
+            v-else-if="group.type === SequenceType.DBox"
+            :class="[
+              'whitespace-nowrap font-bold not-italic',
+              `text-${getBoxColor(group.type)}-600`
+            ]"
+          >
+            <em>D</em> box
+          </span>
+        </template>
       </SequenceBoard>
     </Panel>
 
-- 
GitLab