diff --git a/src/components/BaseLegendButtonOverlay.vue b/src/components/BaseLegendButtonOverlay.vue index 346c550e0a26386b5172338516203a52de655fa8..5eef2a54e3ed5dfd3f3d878ddc84c2c9be016fc0 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"> diff --git a/src/components/BaseLockableTooltip.vue b/src/components/BaseLockableTooltip.vue new file mode 100644 index 0000000000000000000000000000000000000000..52fb6f14caa4c73905e6fe597988f8af31dfc19a --- /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> diff --git a/src/components/BaseTooltip.vue b/src/components/BaseTooltip.vue index 1da727a1621eb5091aaf572d8b597e4ae1b31259..b3cdb7864563bd1624078e3447e397d840d66f68 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" > diff --git a/src/components/ChromosomeMagnify.vue b/src/components/ChromosomeMagnify.vue index eeac915beeb931ee19e64894be42483cd38e995c..0c339e8b813b2ae600440398a5738972220fb3d9 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 @@ -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,88 +75,51 @@ 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, 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 + * 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[]>([]) +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 - ? 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) + * Normalised position of the mouse on the chromosome (between 0 & 1). */ const mousePositionNormalised = computed(() => ({ x: mouseChromosome.x.value / chromosomeDimensions.width.value, @@ -164,37 +127,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 +135,7 @@ const magnifiedChromosomeDimensionsPX = computed(() => ({ })) /** - * Translation applied to the magnified chromosome + * Translation applied to the magnified chromosome. */ const magnifiedChromosomeTranslate = computed(() => ({ x: @@ -215,12 +148,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 +224,8 @@ const createChromosomeSVG = () => { }) hoveredObjectsElements.value = hoveredObjectsElementsNew }) + + // Clear style of no-longer-hovered elements objectsGroup.mouseleave(() => { hoveredObjectsElements.value.forEach((objectElement) => { objectElement.setAttribute( @@ -304,7 +239,8 @@ const createChromosomeSVG = () => { hoveredObjectsElements.value = [] }) - objectsGroup.click(lockTooltipIfUnlocked) + // Lock the tooltip when clicking on any chromosome object + objectsGroup.click(lockTooltip) // Create the clip of the object's group, by cloning the chromosome body, // and apply the clip @@ -316,7 +252,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,13 +375,13 @@ const updateChromosomeSVG = () => { objectsGroup.transform(groupsTransforms) } +/** + * Creates and updates the SVG elements of the chromosome on mount. + */ onMounted(() => { createChromosomeSVG() updateChromosomeSVG() }) - -// Add a listener to unlock the tooltip when clicking outside of it -onClickOutside(tooltipComponent, unlockTooltipIfLocked) </script> <template> @@ -466,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 @@ -487,5 +420,5 @@ onClickOutside(tooltipComponent, unlockTooltipIfLocked) {{ objectElement.dataset.name }} </RouterLink> </div> - </BaseTooltip> + </BaseLockableTooltip> </template> diff --git a/src/components/SequenceBoard.vue b/src/components/SequenceBoard.vue index 5583f97493f574feaf8e9d8c1d699acd5c3065e1..d7dbfe78719629b136db2f56fb21d48e6b37ce25 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" >→ 3'</span diff --git a/src/typings/typeUtils.ts b/src/typings/typeUtils.ts index 3829cb31292e5ce03623a12cd5f4c731654ce811..717ecb389c8e692a4544720206545afa575ade90 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) + } +} diff --git a/src/views/GuideView.vue b/src/views/GuideView.vue index 4cab84fdf17021b60334be295ddeaca42a08fcdd..75ad379c3f0307a3c8ecdcc3dba353182d61f9f0 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> diff --git a/src/views/TargetView.vue b/src/views/TargetView.vue index b93eadb0db41161c53b20b7a755f683aeff10ae4..d027676c8a6f9247122faf8f61d54c0184dcbc9f 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"