diff --git a/package-lock.json b/package-lock.json index 500c90fe4079e3689f53f6ff0b836cd8e4847dc7..1fb0b91693fa9d26ef328518fef4f2249ffd670b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/vue-fontawesome": "^3.0.3", + "@svgdotjs/svg.js": "^3.2.0", "@vueuse/core": "^10.4.1", "axios": "^1.4.0", "lodash-es": "^4.17.21", @@ -927,6 +928,15 @@ "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", "dev": true }, + "node_modules/@svgdotjs/svg.js": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.0.tgz", + "integrity": "sha512-Tr8p+QVP7y+QT1GBlq1Tt57IvedVH8zCPoYxdHLX0Oof3a/PqnC/tXAkVufv1JQJfsDHlH/UrjcDfgxSofqSNA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Fuzzyma" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -1020,9 +1030,9 @@ "dev": true }, "node_modules/@types/web-bluetooth": { - "version": "0.0.17", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.17.tgz", - "integrity": "sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==" + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==" }, "node_modules/@types/yauzl": { "version": "2.10.0", @@ -1587,14 +1597,14 @@ } }, "node_modules/@vueuse/core": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.4.1.tgz", - "integrity": "sha512-DkHIfMIoSIBjMgRRvdIvxsyboRZQmImofLyOHADqiVbQVilP8VVHDhBX2ZqoItOgu7dWa8oXiNnScOdPLhdEXg==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.6.0.tgz", + "integrity": "sha512-+Yee+g9+9BEbvkyGdn4Bf4yZx9EfocAytpV2ZlrlP7xcz+qznLmZIDqDroTvc5vtMkWZicisgEv8dt3+jL+HQg==", "dependencies": { - "@types/web-bluetooth": "^0.0.17", - "@vueuse/metadata": "10.4.1", - "@vueuse/shared": "10.4.1", - "vue-demi": ">=0.14.5" + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.6.0", + "@vueuse/shared": "10.6.0", + "vue-demi": ">=0.14.6" }, "funding": { "url": "https://github.com/sponsors/antfu" @@ -1626,19 +1636,19 @@ } }, "node_modules/@vueuse/metadata": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.4.1.tgz", - "integrity": "sha512-2Sc8X+iVzeuMGHr6O2j4gv/zxvQGGOYETYXEc41h0iZXIRnRbJZGmY/QP8dvzqUelf8vg0p/yEA5VpCEu+WpZg==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.6.0.tgz", + "integrity": "sha512-mzKHkHoiK6xVz01VzQjM2l6ofUanEaofgEGPgDHcAzlvOTccPRTIdEuzneOUTYxgfm1vkDikS6rtrEw/NYlaTQ==", "funding": { "url": "https://github.com/sponsors/antfu" } }, "node_modules/@vueuse/shared": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.4.1.tgz", - "integrity": "sha512-vz5hbAM4qA0lDKmcr2y3pPdU+2EVw/yzfRsBdu+6+USGa4PxqSQRYIUC9/NcT06y+ZgaTsyURw2I9qOFaaXHAg==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.6.0.tgz", + "integrity": "sha512-0t4MVE18sO+/4Gh0jfeOXBTjKeV4606N9kIrDOLPjFl8Rwnlodn+QC5A4LfJuysK7aOsTMjF3KnzNeueaI0xlQ==", "dependencies": { - "vue-demi": ">=0.14.5" + "vue-demi": ">=0.14.6" }, "funding": { "url": "https://github.com/sponsors/antfu" diff --git a/package.json b/package.json index 7a616973b29c58ae17ef50588034709d497a0017..ed13830fb34eeae4c542f208b98c36b32a887d3f 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/vue-fontawesome": "^3.0.3", + "@svgdotjs/svg.js": "^3.2.0", "@vueuse/core": "^10.4.1", "axios": "^1.4.0", "lodash-es": "^4.17.21", diff --git a/src/components/BaseLinkListCard.vue b/src/components/BaseLinkListCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..619790104a3b47230f2410c62e822b34f4bdbd24 --- /dev/null +++ b/src/components/BaseLinkListCard.vue @@ -0,0 +1,147 @@ +<script setup lang="ts"> +/** + * Vue imports + */ +import { onMounted, ref } from 'vue' +/** + * Components imports + */ +import Card from 'primevue/card' +/** + * Other 3rd-party imports + */ +import { compact as _compact } from 'lodash-es' +/** + * Types imports + */ +import type { TailwindBaseColorModel } from '@/typings/styleTypes' +import type { RouteParamsRaw } from 'vue-router' + +export interface LinkListItemModel { + id: string + title: string + subtitle?: string + link: { + name: string + params?: RouteParamsRaw + } + details?: [string, string?, string?] +} + +interface PropsModel { + listTitle?: string + linkItems: LinkListItemModel[] + color?: TailwindBaseColorModel + disabled?: boolean + hoverEvent?: boolean +} + +const props = defineProps<PropsModel>() + +const emit = defineEmits<{ + enterItem: [id: string] + leaveItem: [id: string] +}>() + +const listContainer = ref<HTMLDivElement | null>(null) +// const listContainerScroll = useScroll(listContainer, { behavior: 'smooth' }) +const linkItemWrappers = ref<HTMLDivElement[] | null>(null) + +onMounted(() => { + if (props.hoverEvent) { + linkItemWrappers.value?.forEach((linkItemWrapper) => { + linkItemWrapper.addEventListener('mouseenter', () => { + emit('enterItem', linkItemWrapper.dataset['linkItemId'] || '') + }) + linkItemWrapper.addEventListener('mouseleave', () => { + emit('leaveItem', linkItemWrapper.dataset['linkItemId'] || '') + }) + }) + } +}) + +const scrollToListTop = () => { + // listContainerScroll.x.value = 0 + setTimeout(() => { + listContainer.value?.scrollTo({ top: 0, behavior: 'smooth' }) + }, 10) +} + +defineExpose({ scrollToListTop }) +</script> + +<template> + <Card + :class="['!border', `!border-${color || 'slate'}-600`]" + :pt="{ + body: { class: '!pr-0' }, + content: { + class: '!py-0' + } + }" + > + <template v-if="listTitle" #title> + <h3 class="mb-4 text-center">{{ listTitle }}</h3> + </template> + <template v-if="!disabled" #content> + <div + ref="listContainer" + class="!mr-2 flex max-h-[65vh] flex-col gap-4 overflow-auto !pr-3" + > + <RouterLink + v-for="(linkItem, linkItemIndex) in linkItems" + :key="linkItemIndex" + :class="[ + `hover:border-${color || 'slate'}-600`, + 'rounded-md border-2 hover:shadow-md' + ]" + :to="{ + name: linkItem.link.name, + params: linkItem.link.params + }" + > + <div + :ref="hoverEvent ? 'linkItemWrappers' : ''" + :data-link-item-id="linkItem.id" + :class="[ + 'grid p-2', + linkItem.details ? 'grid-cols-5' : 'grid-cols-1' + ]" + > + <div :class="['flex flex-col', linkItem.details && 'border-r-2']"> + <h4 class="px-2 text-center text-lg font-semibold"> + {{ linkItem.title }} + </h4> + <p + v-if="linkItem.subtitle" + class="text-center italic text-slate-400" + > + {{ linkItem.subtitle }} + </p> + </div> + <div + v-if="linkItem.details" + class="col-span-4 my-auto pl-4 text-xl text-slate-400" + > + {{ linkItem.details[0] }} + <template + v-for="(detail, detailIndex) in _compact( + linkItem.details.slice(1) + )" + :key="detailIndex" + > + <span class="mx-2">•</span> + {{ detail + ' ' }} + </template> + </div> + </div> + </RouterLink> + </div> + </template> + <template v-else #content> + <span class="m-auto italic text-slate-400"> + <slot name="disabled"> Link list is disabled. </slot> + </span> + </template> + </Card> +</template> diff --git a/src/components/BaseRadioFieldset.vue b/src/components/BaseRadioFieldset.vue index 0ffae1bd9f1e1fa0c6ad72390e194273d0c5c1a8..b6dd0661b431a6271fdb5c0171634d21489c739b 100644 --- a/src/components/BaseRadioFieldset.vue +++ b/src/components/BaseRadioFieldset.vue @@ -19,10 +19,6 @@ interface PropsModel { labelFieldPath?: string } -interface EmitModel { - change: [value: any] -} - interface SlotModel { /** Custom whole entry (input & label). */ default(props: { diff --git a/src/components/BaseTooltip.vue b/src/components/BaseTooltip.vue new file mode 100644 index 0000000000000000000000000000000000000000..a33f045505a57c88c8b6cd4568a9558179a863e9 --- /dev/null +++ b/src/components/BaseTooltip.vue @@ -0,0 +1,95 @@ +<script setup lang="ts"> +/** + * Vue imports + */ +import { computed } from 'vue' +/** + * Types imports + */ +import type { StyleValue } from 'vue' + +interface PropsModel { + /** On which side to display the 'arrow' of the tooltip. */ + side?: 'left' | 'right' | 'top' | 'bottom' + /** Whether to display or not the 'arrow' of the tooltip. */ + showArrow?: boolean +} + +const props = withDefaults(defineProps<PropsModel>(), { + side: 'bottom', + showArrow: true +}) + +const tooltipStyle = computed<StyleValue>(() => { + const translateX = + props.side === 'bottom' || props.side === 'top' + ? '-50%' + : props.side === 'left' + ? '0.75rem' + : 'calc(-100% - 0.75rem)' + const translateY = + props.side === 'left' || props.side === 'right' + ? '50%' + : props.side === 'bottom' + ? '-0.75rem' + : 'calc(100% + 0.75rem)' + return { + transform: `translateX(${translateX}) translateY(${translateY})` + } +}) + +const tooltipAfterStyle = computed(() => { + const top = + props.side === 'left' || props.side === 'right' + ? '50%' + : props.side === 'bottom' + ? '' + : '0' + const bottom = props.side === 'bottom' && '0' + const left = props.side === 'left' && '0' + const right = props.side === 'right' && '0' + const translateX = + props.side === 'left' ? '-50%' : props.side === 'right' ? '50%' : '0' + const translateY = props.side === 'bottom' ? '50%' : '-50%' + const rotate = + props.side === 'bottom' + ? '45deg' + : props.side === 'left' + ? '135deg' + : props.side === 'top' + ? '225deg' + : '315deg' + return { + top, + bottom, + left, + right, + transform: `translateX(${translateX}) translateY(${translateY}) rotate(${rotate})` + } +}) +</script> + +<template> + <div + :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' + ]" + :style="tooltipStyle" + > + <slot></slot> + </div> +</template> + +<style scoped lang="scss"> +.tooltip { + &::after { + top: v-bind('tooltipAfterStyle.top'); + bottom: v-bind('tooltipAfterStyle.bottom'); + left: v-bind('tooltipAfterStyle.left'); + right: v-bind('tooltipAfterStyle.right'); + transform: v-bind('tooltipAfterStyle.transform'); + } +} +</style> diff --git a/src/components/ChromosomeMagnify.vue b/src/components/ChromosomeMagnify.vue new file mode 100644 index 0000000000000000000000000000000000000000..aec0d7c92eb022ffc771d3bd0ff5d51b721590ec --- /dev/null +++ b/src/components/ChromosomeMagnify.vue @@ -0,0 +1,478 @@ +<script setup lang="ts"> +/** + * Vue imports + */ +import { computed, onMounted, ref } from 'vue' +/** + * Components imports + */ +import BaseTooltip from './BaseTooltip.vue' +/** + * Other 3rd-party imports + */ +import { + onClickOutside, + useMouseInElement +} from '@vueuse/core' +import { G, Rect, SVG, type Svg } from '@svgdotjs/svg.js' +/** + * Types imports + */ +import type { ChromosomeModel, GenomeObjectModel } from './KaryotypeBoard.vue' + +interface propsModel { + // The chromosome to display + chromosome: ChromosomeModel + // The DOM element corresponding to the chromosome + chromosomeElement: HTMLElement | SVGElement + // A list of objects to display on the chromosome + objects?: GenomeObjectModel[] + // The zoom factor to apply + zoomFactor?: number + // The orientation of the chromosome + orientation?: 'horizontal' | 'vertical' + // The radius of the 'magnifying glass' + radius?: number +} + +const props = withDefaults(defineProps<propsModel>(), { + objects: () => [], + zoomFactor: 10, + orientation: 'horizontal', + radius: 50 +}) + +/** + * Dimensions of the base chromosome element, position of the mouse relative to + * the top left of the client, and to the chromosome element + */ +const { chromosomeDimensions, mouseClient, mouseChromosome } = (() => { + const { elementHeight, elementWidth, elementX, elementY, isOutside, x, y } = + useMouseInElement(props.chromosomeElement, { + type: 'client' + }) + return { + chromosomeDimensions: { + height: elementHeight, + width: elementWidth + }, + mouseClient: { + x: x, + y: y + }, + mouseChromosome: { + x: elementX, + y: elementY, + isOutside + } + } +})() + +/** + * Ref to the div containing the chromosome SVG + */ +const magnifiedChromosome = ref<HTMLDivElement>() + +/** + * 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 + */ +const hoveredObjectsElements = ref<SVGElement[]>([]) + +/** + * DOM elements corresponding to objects to display in the tooltip when locked + */ +const lockedTooltipObjectsElements = ref<SVGElement[]>([]) + +/** + * DOM elements corresponding to objects to display in the tooltip + */ +const tooltipObjectsElements = computed(() => + lockedTooltipPosition.value + ? lockedTooltipObjectsElements.value + : hoveredObjectsElements.value +) + +/** + * Lock the tooltip in its place and add a scroll listener + */ +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) +} + +/** + * Unlock 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) +} + +/** + * 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) + */ +const mousePositionNormalised = computed(() => ({ + x: mouseChromosome.x.value / chromosomeDimensions.width.value, + y: mouseChromosome.y.value / chromosomeDimensions.height.value +})) + +/** + * 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 + */ +const magnifiedChromosomeDimensionsPX = computed(() => ({ + length: chromosomeDimensions.width.value * props.zoomFactor, + thickness: 1.5 * props.radius +})) + +/** + * Translation applied to the magnified chromosome + */ +const magnifiedChromosomeTranslate = computed(() => ({ + x: + -mousePositionNormalised.value.x * + magnifiedChromosomeDimensionsPX.value.length + + props.radius, + y: + (0.5 - mousePositionNormalised.value.y + 0.25) * + magnifiedChromosomeDimensionsPX.value.thickness +})) + +/** + * Ref to the SVG representation of the chromosome + */ +const draw = ref<Svg>() + +/** + * Creates the elements forming the chromosome SVG + */ +const createChromosomeSVG = () => { + if (!magnifiedChromosome.value) { + console.log('Element not present') + return + } + + draw.value = SVG().addTo(magnifiedChromosome.value) + + // Create the body of the chromosome, made of the 2 arms + const mainGroup = draw.value + .group() + .fill(props.chromosome.color || '#84b650') + .addClass('zoom-chr-svg-main') + mainGroup.rect().addClass('zoom-chr-svg-main-short-arm') + mainGroup.rect().addClass('zoom-chr-svg-main-long-arm') + + // Create and fill a group with the objects present on the chromosome + const objectsGroup = draw.value + .group() + .addClass('fill-[#abcf74]') + .addClass('zoom-chr-svg-objects') + props.objects.forEach((object) => { + const rect = objectsGroup.rect().data(object) + if (object.color) { + rect.fill(object.color) + } + rect.addClass('cursor-pointer') + }) + objectsGroup.mousemove((e: MouseEvent) => { + // Get current hovered object elements + const hoveredObjectsElementsNew = document + .elementsFromPoint(e.clientX, e.clientY) + .filter((objectElement) => + objectElement.parentElement?.classList.contains('zoom-chr-svg-objects') + ) as SVGElement[] + + // Remove highlight on no longer hovered elements + hoveredObjectsElements.value.forEach((objectElement) => { + if ( + !hoveredObjectsElementsNew.find( + (objectElementNew) => + objectElementNew.dataset.id === objectElement.dataset.id + ) + ) { + objectElement.setAttribute( + 'class', + (objectElement.getAttribute('class') || '').replace( + ' brightness-90', + '' + ) + ) + } + }) + // Add highlight on newly hovered elements + hoveredObjectsElementsNew.forEach((objectElementNew) => { + if ( + !hoveredObjectsElements.value.find( + (objectElement) => + objectElement.dataset.id === objectElementNew.dataset.id + ) + ) { + objectElementNew.setAttribute( + 'class', + `${objectElementNew.getAttribute('class') || ''} brightness-90` + ) + } + }) + hoveredObjectsElements.value = hoveredObjectsElementsNew + }) + objectsGroup.mouseleave(() => { + hoveredObjectsElements.value.forEach((objectElement) => { + objectElement.setAttribute( + 'class', + (objectElement.getAttribute('class') || '').replace( + ' brightness-90', + '' + ) + ) + }) + hoveredObjectsElements.value = [] + }) + + objectsGroup.click(lockTooltipIfUnlocked) + + // Create the clip of the object's group, by cloning the chromosome body, + // and apply the clip + const clipGroup = draw.value.clip() + for (const path of mainGroup.clone().children()) { + clipGroup.add(path) + } + objectsGroup.clipWith(clipGroup) +} + +/** + * Updates the size & position of the elements in the chromosome SVG + */ +const updateChromosomeSVG = () => { + if (!draw.value) { + return + } + // 1. Compute dimensions + const chromosomeDrawSize = { + height: magnifiedChromosomeDimensionsPX.value.length, + width: magnifiedChromosomeDimensionsPX.value.thickness + } + const ONE_BP_LENGTH_PX = chromosomeDrawSize.height / props.chromosome.length + // A tenth of the width of the chromosomes is added to arm's length to allow + // their superposition w/o losing the real length + const shortArmHeight = + ONE_BP_LENGTH_PX * (props.chromosome.centromereCenter || 0) + + chromosomeDrawSize.width / 10 + const longArmHeight = + ONE_BP_LENGTH_PX * + (props.chromosome.length - (props.chromosome.centromereCenter || 0)) + + chromosomeDrawSize.width / 10 + // If horizontal, chromosome will be rotated so the SVG height equals the + // chromosome width and vice-versa + const SVGSize = + props.orientation === 'vertical' + ? { + height: chromosomeDrawSize.height, + width: chromosomeDrawSize.width + } + : { + height: chromosomeDrawSize.width, + width: chromosomeDrawSize.height + } + // Resize the SVG + draw.value.size(SVGSize.width, SVGSize.height) + + // 2. Retrieve SVG objects + // Casting is needed for Typescript to work, so the notation is a bit verbose + const chrMainGroup = draw.value.findOne('g.zoom-chr-svg-main') as G + const shortArmRect = draw.value.findOne( + 'rect.zoom-chr-svg-main-short-arm' + ) as Rect + const longArmRect = draw.value.findOne( + 'rect.zoom-chr-svg-main-long-arm' + ) as Rect + const objectsGroup = draw.value.findOne('g.zoom-chr-svg-objects') as G + const objectsGroupClipperShortArmRect = objectsGroup + .clipper() + .findOne('rect.zoom-chr-svg-main-short-arm') as Rect + const objectsGroupClipperLongArmRect = objectsGroup + .clipper() + .findOne('rect.zoom-chr-svg-main-long-arm') as Rect + + // 3. Base operations (chromosome is vertical, centered, and text horizontal) + // Chromosome arms + shortArmRect + .size(chromosomeDrawSize.width, shortArmHeight) + .radius(chromosomeDrawSize.width / 2) + .cx(SVGSize.width / 2) + longArmRect + .size(chromosomeDrawSize.width, longArmHeight) + .radius(chromosomeDrawSize.width / 2) + .cx(SVGSize.width / 2) + .y(shortArmHeight - chromosomeDrawSize.width / 5) + // Chromosome objects + objectsGroup.children().forEach((objectRect) => { + const objectSide = objectRect.data('side') || 'left' + const objectDimensions = { + width: chromosomeDrawSize.width / 2, + height: Math.max( + (objectRect.data('end') - objectRect.data('start')) * ONE_BP_LENGTH_PX, + 4 * props.zoomFactor + ), + x: + objectSide === 'left' + ? SVGSize.width / 2 - + chromosomeDrawSize.width / 2 - + chromosomeDrawSize.width / 10 + : SVGSize.width / 2 + chromosomeDrawSize.width / 10, + y: objectRect.data('start') * ONE_BP_LENGTH_PX + } + ;(objectRect as Rect) + .move(objectDimensions.x, objectDimensions.y) + .size(objectDimensions.width, objectDimensions.height) + .radius(2) + }) + // Clip path + objectsGroupClipperShortArmRect + .size(chromosomeDrawSize.width, shortArmHeight) + .radius(chromosomeDrawSize.width / 2) + .cx(SVGSize.width / 2) + objectsGroupClipperLongArmRect + .size(chromosomeDrawSize.width, longArmHeight) + .radius(chromosomeDrawSize.width / 2) + .cx(SVGSize.width / 2) + .y(shortArmHeight - chromosomeDrawSize.width / 5) + + // 4. Transforms (orientation-specific) + const groupsTransforms = + props.orientation === 'vertical' + ? { + rotate: 0, + origin: { x: 0, y: 0 }, + translateX: 0 + } + : { + rotate: -90, + origin: { x: SVGSize.width / 2, y: SVGSize.height / 2 }, + translateX: -SVGSize.width / 2 + SVGSize.height / 2 + } + // Main group (chr arms) + chrMainGroup.transform(groupsTransforms) + // Objects group + objectsGroup.transform(groupsTransforms) +} + +onMounted(() => { + createChromosomeSVG() + updateChromosomeSVG() +}) + +// Add a listener to unlock the tooltip when clicking outside of it +onClickOutside(tooltipComponent, unlockTooltipIfLocked) +</script> + +<template> + <div + v-show="!mouseChromosome.isOutside.value" + class="fixed -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-full border bg-white shadow-lg" + :style="{ + height: `${radius * 2}px`, + width: `${radius * 2}px`, + top: `${magnifyElementPositions.top}px`, + left: `${magnifyElementPositions.left}px` + }" + > + <div + ref="magnifiedChromosome" + :style="{ + transform: `translateY(${magnifiedChromosomeTranslate.y}px) translateX(${magnifiedChromosomeTranslate.x}px)` + }" + ></div> + </div> + <BaseTooltip + v-show="hoveredObjectsElements.length || lockedTooltipPosition" + ref="tooltipComponent" + :style="{ + bottom: `${tooltipComponentPositions.bottom}px`, + left: `${tooltipComponentPositions.left}px` + }" + > + <div class="flex flex-col gap-2"> + <RouterLink + v-for="objectElement in tooltipObjectsElements" + :key="objectElement.dataset.id" + :to="{ + name: 'guideDetails', + params: { id: objectElement.dataset.id } + }" + class="font-bold underline hover:text-sky-600" + > + {{ objectElement.dataset.name }} + </RouterLink> + </div> + </BaseTooltip> +</template> diff --git a/src/components/KaryotypeBoard.vue b/src/components/KaryotypeBoard.vue new file mode 100644 index 0000000000000000000000000000000000000000..b63d4188d4eba7e8228315bb6303c2dddf37eea8 --- /dev/null +++ b/src/components/KaryotypeBoard.vue @@ -0,0 +1,499 @@ +<script setup lang="ts"> +/** + * Vue imports + */ +import { computed, onMounted, ref } from 'vue' +/** + * Components imports + */ +import Button from 'primevue/button' +import IconTablerCircleArrowLeft from '~icons/tabler/circle-arrow-left' +import IconTablerCircleArrowLeftFilled from '~icons/tabler/circle-arrow-left-filled' +/** + * Other 3rd-party imports + */ +import { SVG, Svg } from '@svgdotjs/svg.js' +import { find as _find } from 'lodash-es' +import { useElementBounding, useWindowScroll } from '@vueuse/core' +/** + * Types imports + */ +import type { G } from '@svgdotjs/svg.js' +import type { Rect } from '@svgdotjs/svg.js' +import type { Text } from '@svgdotjs/svg.js' +import type { HexColorCodeModel } from '@/typings/styleTypes' +import ChromosomeMagnify from './ChromosomeMagnify.vue' + +export interface ChromosomeModel { + name: string + id: string + length: number + centromereCenter: number + color?: HexColorCodeModel + textLeft?: string + textRight?: string +} + +export interface GenomeObjectModel { + name: string + id: string + chromosomeId: string + start: number + end: number + color?: HexColorCodeModel + highlightColor?: HexColorCodeModel + side?: 'left' | 'right' +} + +interface PropsModel { + /** Chromosomes to be displayed */ + chromosomes: ChromosomeModel[] + /** Objects to display on the chromosomes */ + objects?: GenomeObjectModel[] +} + +interface DrawSizesModel { + thickness: number + length: number +} + +const props = defineProps<PropsModel>() + +const windowScroll = useWindowScroll({ behavior: 'smooth' }) + +const boardContent = ref<HTMLDivElement>() +const chromosomeElements = ref<{ [k: string]: SVGGElement }>({}) +const boardContentBounding = useElementBounding(boardContent) +const remToPixelsRatio = computed(() => + parseInt(window.getComputedStyle(boardContent.value!).fontSize) +) + +const LONGEST_CHROMOSOME_LENGTH = 300 +const expandedChromosomeLength = computed(() => + Math.min(2 * LONGEST_CHROMOSOME_LENGTH, boardContentBounding.width.value) +) + +const longestChromosome = computed(() => + props.chromosomes.reduce( + (acc, curr) => (curr.length > acc.length ? curr : acc), + props.chromosomes[0] + ) +) +const objectsByChromosome = computed(() => + props.chromosomes.reduce( + (acc: { [k: string]: GenomeObjectModel[] }, curr) => { + acc[curr.id] = (props.objects || []).filter( + (object) => object.chromosomeId === curr.id + ) + return acc + }, + {} + ) +) + +// Currently expanded chromosome, undefined if the full karyotype is displayed +const expandedChromosome = ref<ChromosomeModel | undefined>(undefined) + +// Collection containing the Objects representing the chromosome's SVGs +const draw = ref<{ [k: string]: Svg }>({}) + +// Fills the `draw` ref by creating the SVG corresponding to every chromosome +const createChromosomeSVGs = () => { + props.chromosomes.forEach((chromosome) => { + // Create the SVG + draw.value[chromosome.id] = SVG().addTo(`#${chromosome.id}-svg`) + + // Create the body of the chromosome, made of the 2 arms + const mainGroup = draw.value[chromosome.id] + .group() + .fill(chromosome.color || '#84b650') + .addClass('chr-svg-main') + mainGroup.rect().addClass('chr-svg-main-short-arm') + mainGroup.rect().addClass('chr-svg-main-long-arm') + // Store DOM element corresponding to each group in a ref + chromosomeElements.value[chromosome.id] = mainGroup.node + + // Create and fill a group with the objects present on the chromosome + const objectsGroup = draw.value[chromosome.id] + .group() + .addClass('fill-[#abcf74]') + .addClass('chr-svg-objects') + for (const object of objectsByChromosome.value[chromosome.id]) { + const rect = objectsGroup.rect().data(object) + if (object.color) { + rect.fill(object.color) + } + } + + // Create the clip of the object's group, by cloning the chromosome body, + // and apply the clip + const clipGroup = draw.value[chromosome.id].clip() + for (const path of mainGroup.clone().children()) { + clipGroup.add(path) + } + objectsGroup.clipWith(clipGroup) + + // Create a group for the texts + const textGroup = draw.value[chromosome.id] + .group() + .font({ + style: 'italic' + }) + .addClass('fill-slate-700 chr-svg-text') + + // Create a group for side texts, and inserts those texts if provided + const sideTextGroup = textGroup + .group() + .font({ + size: '1rem', + weight: '500', + anchor: 'middle' + }) + .addClass('fill-slate-500 chr-svg-text-side') + if (chromosome.textLeft) { + sideTextGroup + .plain(chromosome.textLeft) + .addClass('chr-svg-text-side-left') + } + if (chromosome.textRight) { + sideTextGroup + .plain(chromosome.textRight) + .addClass('chr-svg-text-side-right') + } + }) +} + +// Update the SVG of a given chromosome +const updateChromosomeSVG = ( + chromosome: ChromosomeModel, + chromosomeDrawSize: DrawSizesModel, + orientation: 'vertical' | 'horizontal' = 'vertical' +) => { + // 1. Compute dimensions + const ONE_BP_LENGTH_PX = chromosomeDrawSize.length / chromosome.length + // A tenth of the width of the chromosomes is added to arm's length to allow + // their superposition w/o losing the real length + const armsOverlap = chromosomeDrawSize.thickness / 5 + const shortArmLength = + ONE_BP_LENGTH_PX * (chromosome.centromereCenter || 0) + armsOverlap / 2 + const longArmLength = + ONE_BP_LENGTH_PX * + (chromosome.length - (chromosome.centromereCenter || 0)) + + armsOverlap / 2 + const SVGSize = + orientation === 'vertical' + ? { + height: chromosomeDrawSize.length, + width: chromosomeDrawSize.thickness + 2 * remToPixelsRatio.value + 15 + } + : { + height: + chromosomeDrawSize.thickness + 2 * remToPixelsRatio.value + 15, + width: chromosomeDrawSize.length + } + // Resize the SVG + draw.value[chromosome.id].size(SVGSize.width, SVGSize.height) + + // 2. Retrieve SVG objects + // Casting is needed for Typescript to work, so the notation is a bit verbose + const chrMainGroup = draw.value[chromosome.id].findOne('g.chr-svg-main') as G + const shortArmRect = draw.value[chromosome.id].findOne( + 'rect.chr-svg-main-short-arm' + ) as Rect + const longArmRect = draw.value[chromosome.id].findOne( + 'rect.chr-svg-main-long-arm' + ) as Rect + const objectsGroup = draw.value[chromosome.id].findOne( + 'g.chr-svg-objects' + ) as G + const objectsGroupClipperShortArmRect = objectsGroup + .clipper() + .findOne('rect.chr-svg-main-short-arm') as Rect + const objectsGroupClipperLongArmRect = objectsGroup + .clipper() + .findOne('rect.chr-svg-main-long-arm') as Rect + const sideTextLeftText = draw.value[chromosome.id].findOne( + 'text.chr-svg-text-side-left' + ) as Text + const sideTextRightText = draw.value[chromosome.id].findOne( + 'text.chr-svg-text-side-right' + ) as Text + + // 3. Base operations (chromosome is vertical, centered, and text horizontal) + // Chromosome arms + shortArmRect + .size(chromosomeDrawSize.thickness, shortArmLength) + .radius(chromosomeDrawSize.thickness / 2) + .cx(SVGSize.width / 2) + longArmRect + .size(chromosomeDrawSize.thickness, longArmLength) + .radius(chromosomeDrawSize.thickness / 2) + .cx(SVGSize.width / 2) + .y(shortArmLength - armsOverlap) + // Chromosome objects + objectsGroup.children().forEach((objectRect) => { + const objectSide = objectRect.data('side') || 'left' + const objectDimensions = { + width: chromosomeDrawSize.thickness / 2, + height: Math.max( + (objectRect.data('end') - objectRect.data('start')) * ONE_BP_LENGTH_PX, + 4 + ), + x: + objectSide === 'left' + ? SVGSize.width / 2 - + chromosomeDrawSize.thickness / 2 - + chromosomeDrawSize.thickness / 10 + : SVGSize.width / 2 + chromosomeDrawSize.thickness / 10, + y: objectRect.data('start') * ONE_BP_LENGTH_PX + } + ;(objectRect as Rect) + .move(objectDimensions.x, objectDimensions.y) + .size(objectDimensions.width, objectDimensions.height) + .radius(2) + }) + // Clip path + objectsGroupClipperShortArmRect + .size(chromosomeDrawSize.thickness, shortArmLength) + .radius(chromosomeDrawSize.thickness / 2) + .cx(SVGSize.width / 2) + objectsGroupClipperLongArmRect + .size(chromosomeDrawSize.thickness, longArmLength) + .radius(chromosomeDrawSize.thickness / 2) + .cx(SVGSize.width / 2) + .y(shortArmLength - chromosomeDrawSize.thickness / 5) + // Texts + if (chromosome.textLeft) { + sideTextLeftText.center(SVGSize.width / 2, SVGSize.height / 2) + } + if (chromosome.textRight) { + sideTextRightText.center(SVGSize.width / 2, SVGSize.height / 2) + } + + // 4. Transforms (orientation-specific) + const groupsTransforms = + orientation === 'vertical' + ? { + rotate: 0, + origin: { x: 0, y: 0 }, + translateX: 0 + } + : { + rotate: -90, + origin: { x: SVGSize.width / 2, y: SVGSize.height / 2 }, + translateX: -SVGSize.width / 2 + SVGSize.height / 2 + } + const textsTransforms = + orientation === 'vertical' + ? { + left: { + rotate: -90, + translate: { x: -(chromosomeDrawSize.thickness / 2 + 15), y: 0 } + }, + right: { + rotate: 90, + translate: { x: chromosomeDrawSize.thickness / 2 + 15, y: 0 } + } + } + : { + left: { + rotate: 0, + translate: { x: 0, y: chromosomeDrawSize.thickness / 2 + 15 } + }, + right: { + rotate: 0, + translate: { x: 0, y: -(chromosomeDrawSize.thickness / 2 + 15) } + } + } + // Main group (chr arms) + chrMainGroup.transform(groupsTransforms) + // Objects group + objectsGroup.transform(groupsTransforms) + // Texts + if (chromosome.textLeft) { + sideTextLeftText.transform(textsTransforms.left) + } + if (chromosome.textRight) { + sideTextRightText.transform(textsTransforms.right) + } +} + +// Highlight a given object with its highlight color, is made available in slots +const highlightObjectOnChromosome = ( + chromosomeId: string, + objectId: string +) => { + const objectsGroup = draw.value[chromosomeId].findOne( + 'g.chr-svg-objects' + ) as G + const objectRect = Array.from(objectsGroup.children()).find( + (objectRect) => objectRect.node.dataset.id === objectId + ) + if (!objectRect) return + const highlightColor = objectsByChromosome.value[chromosomeId].find( + (object) => object.id === objectId + )?.highlightColor + if (highlightColor) { + objectRect.fill(highlightColor) + } + const objectWidth = parseInt(objectRect.width().toString()) + objectRect.width(objectWidth * 1.25).front() +} + +// Remove highlight on a given object, is made available in slots +const unhighlightObjectOnChromosome = ( + chromosomeId: string, + objectId: string +) => { + const objectsGroup = draw.value[chromosomeId].findOne( + 'g.chr-svg-objects' + ) as G + const objectRect = Array.from(objectsGroup.children()).find( + (objectRect) => objectRect.node.dataset.id === objectId + ) + if (!objectRect) return + const color = objectsByChromosome.value[chromosomeId].find( + (object) => object.id === objectId + )?.color + if (color) { + objectRect.fill(color) + } + const objectWidth = parseInt(objectRect.width().toString()) + objectRect.width(objectWidth * 0.8) +} + +// Callback to expand a given chromosome +const expandChromosomeIfReduced = (chromosomeId: string) => { + if (expandedChromosome.value) return + // Update state + expandedChromosome.value = _find(props.chromosomes, ['id', chromosomeId])! + + // Update UI + updateChromosomeSVG( + expandedChromosome.value, + { + length: expandedChromosomeLength.value, + thickness: 24 + }, + 'horizontal' + ) + windowScroll.y.value = boardContentBounding.top.value +} + +// Callback to go back to full karyotype +const reduceChromosome = () => { + // Use a size base shared between chromosomes for a representative relative sizing + const ONE_BP_LENGTH_PX = + LONGEST_CHROMOSOME_LENGTH / longestChromosome.value.length + + if (expandedChromosome.value) { + // Update UI + updateChromosomeSVG( + expandedChromosome.value, + { + length: ONE_BP_LENGTH_PX * expandedChromosome.value.length, + thickness: 24 + }, + 'vertical' + ) + } + // setTimeout(() => { + windowScroll.y.value = boardContentBounding.top.value + // }, 100) + + // Update state + expandedChromosome.value = undefined +} + +onMounted(() => { + // Use a size base shared between chromosomes for a representative relative sizing + const ONE_BP_LENGTH_PX = + LONGEST_CHROMOSOME_LENGTH / longestChromosome.value.length + + createChromosomeSVGs() + + props.chromosomes.forEach((chromosome) => { + updateChromosomeSVG(chromosome, { + length: ONE_BP_LENGTH_PX * chromosome.length, + thickness: 24 + }) + }) +}) +</script> + +<template> + <Button + v-if="!!expandedChromosome" + class="group absolute cursor-pointer text-3xl text-slate-400 outline-none transition-colors duration-200 hover:text-slate-600 focus:text-slate-600" + unstyled + > + <icon-tabler-circle-arrow-left + class="absolute transition-all duration-200 group-hover:opacity-0 group-focus:opacity-0" + /> + <icon-tabler-circle-arrow-left-filled + class="absolute opacity-0 transition-all duration-200 group-hover:opacity-100 group-focus:opacity-100" + @click="reduceChromosome()" + /> + </Button> + + <div + ref="boardContent" + class="flex flex-wrap justify-around gap-y-8 text-base" + > + <div + v-for="(chromosome, index) in chromosomes" + :key="index" + :class="[ + 'mx-auto flex flex-col justify-between', + expandedChromosome && + expandedChromosome.id !== chromosome.id && + 'hidden' + ]" + > + <div + :class="[ + 'relative mb-2 flex flex-col rounded-xl border-2 border-dashed border-transparent p-4 px-16 text-center outline-none', + !expandedChromosome && + 'transition-all duration-200 before:absolute before:inset-0 before:rounded-xl before:mix-blend-soft-light before:transition-all before:duration-200 hover:border-slate-300 hover:before:bg-white focus:border-slate-300 focus:before:bg-black', + expandedChromosome && 'cursor-default' + ]" + role="button" + :tabindex="expandedChromosome ? '' : '0'" + @click="expandChromosomeIfReduced(chromosome.id)" + > + <div :class="['mb-8 text-2xl italic', expandedChromosome && 'mb-12']"> + <h2 class="font-semibold"> + {{ + (expandedChromosome ? 'Chromosome ' : '') + chromosome.name || + `chr${index}` + }} + </h2> + <h3 v-if="expandedChromosome" class="text-lg text-slate-400"> + {{ chromosome.id }} + </h3> + </div> + <div :id="`${chromosome.id}-svg`" class="mx-auto"></div> + <ChromosomeMagnify + v-if="expandedChromosome" + :chromosome="chromosome" + :chromosome-element="chromosomeElements[chromosome.id]" + :objects="objectsByChromosome[chromosome.id]" + ></ChromosomeMagnify> + </div> + <slot + v-if="!expandedChromosome" + name="karyotypeChromosomeAppend" + :chromosome="chromosome" + :highlight-object="(objectId : string) => {highlightObjectOnChromosome(chromosome.id, objectId)}" + :unhighlight-object="(objectId : string) => {unhighlightObjectOnChromosome(chromosome.id, objectId)}" + ></slot> + <slot + v-else-if="expandedChromosome.id === chromosome.id" + name="expandedChromosomeAppend" + :chromosome="chromosome" + :highlight-object="(objectId : string) => {highlightObjectOnChromosome(chromosome.id, objectId)}" + :unhighlight-object="(objectId : string) => {unhighlightObjectOnChromosome(chromosome.id, objectId)}" + ></slot> + </div> + </div> +</template> diff --git a/src/stores/species.ts b/src/stores/species.ts index 3e656d2ddd95201e3b5ebb616202fe286ad4cee8..92373b4237e0dfde214a17c336f08a6167a5c914 100644 --- a/src/stores/species.ts +++ b/src/stores/species.ts @@ -18,6 +18,7 @@ import type { } from '@/typings/snoboardApi' interface SpeciesModel { + id: number table_entries: RemoteTableEntryModel[] coverage: { guide: number @@ -55,6 +56,7 @@ export const useSpeciesStore = defineStore('species', () => { ) species.value = { + id: speciesId, table_entries: tableEntriesRes.data, coverage: { guide: diff --git a/src/typings/styleTypes.ts b/src/typings/styleTypes.ts index ae25563e5e9654197ed79264aaa9b568886eb3fd..449623855de96389ae73eee88200150a6f8c19c8 100644 --- a/src/typings/styleTypes.ts +++ b/src/typings/styleTypes.ts @@ -20,7 +20,8 @@ const TailwindBaseColors = [ 'purple', 'fuschia', 'pink', - 'rose' + 'rose', + 'transparent' ] as const export type TailwindBaseColorModel = (typeof TailwindBaseColors)[number] diff --git a/src/views/StatisticsView.vue b/src/views/StatisticsView.vue index 083b68f583eaf224e1a60f06c926daf374415b84..3ab69b585e382ffb252580aaeecb4c535e5e029d 100644 --- a/src/views/StatisticsView.vue +++ b/src/views/StatisticsView.vue @@ -7,114 +7,249 @@ import { ref, computed, toRefs } from 'vue' * Components imports */ import MainLayout from '@/layouts/MainLayout.vue' -import Card from 'primevue/card' +import KaryotypeBoard from '@/components/KaryotypeBoard.vue' +import BaseLinkListCard from '@/components/BaseLinkListCard.vue' +import Panel from 'primevue/panel' import IconTablerSquareRoundedChevronRightFilled from '~icons/tabler/square-rounded-chevron-right-filled' import IconTablerSquareRoundedChevronRight from '~icons/tabler/square-rounded-chevron-right' +import IconFa6SolidDna from '~icons/fa6-solid/dna' /** * Other 3rd-party imports */ -import { uniqBy as _uniqBy, countBy as _countBy } from 'lodash-es' +import { + uniqBy as _uniqBy, + countBy as _countBy, + sortBy as _sortBy +} from 'lodash-es' /** * Stores imports */ import { useSpeciesStore } from '@/stores/species' +/** + * Types imports + */ +import type { LinkListItemModel } from '@/components/BaseLinkListCard.vue' +import type { GenomeObjectModel } from '@/components/KaryotypeBoard.vue' +import type { TailwindBaseColorModel } from '@/typings/styleTypes' /** * Utils imports */ import { formatSpeciesName } from '@/utils/format' -interface FieldDataModel { - name: string - id: string - type1: string - type2?: string - count: number -} - -type FieldNameModel = 'modifications' | 'guides' | 'targets' | '' +type FieldNameModel = 'modifications' | 'guides' | 'targets' | 'none' const { species } = toRefs(useSpeciesStore()) -const selectedField = ref<FieldNameModel>('') +const selectedField = ref<FieldNameModel>('none') + +const listCardComponent = ref() -const dataCardContent = ref() +/** + * Table entries, duplicated modifications removed + */ +const modificationsTableEntries = computed(() => + _uniqBy(species.value?.table_entries, 'modification.id') +) +/** + * Table entries, duplicated guides removed + */ +const guidesTableEntries = computed(() => + _uniqBy(species.value?.table_entries, 'guide.id') +) +/** + * Table entries, duplicated targets removed + */ +const targetsTableEntries = computed(() => + _uniqBy(species.value?.table_entries, 'target.id') +) +/** + * Select a field + * @param fieldName Name of the field to select + */ const selectField = (fieldName: FieldNameModel) => { selectedField.value = fieldName - dataCardContent.value?.scrollTo({ top: 0, behavior: 'smooth' }) + listCardComponent.value.scrollToListTop() } -const selectedFieldColor = computed(() => { - switch (selectedField.value) { - case 'modifications': - return 'sky' - case 'guides': - return 'lime' - case 'targets': - return 'amber' - default: - return 'transparent' - } -}) +/** + * The Tailwind color associated with a given field + */ +const colorByField: { [k: string]: TailwindBaseColorModel } = { + modifications: 'sky', + guides: 'lime', + targets: 'amber', + none: 'transparent' +} -const rowEntriesCountBy = computed(() => ({ +/** + * For each field, the number of entries associated with each ID of that field + */ +const rowEntriesCountByField = computed(() => ({ modifications: _countBy(species.value?.table_entries, 'modification.id'), guides: _countBy(species.value?.table_entries, 'guide.id'), - targets: _countBy( - _uniqBy(species.value?.table_entries, 'modification.id'), - 'target.id' - ) + targets: _countBy(modificationsTableEntries.value, 'target.id'), + none: {} })) -const selectedFieldData = computed<FieldDataModel[]>(() => { - switch (selectedField.value) { - case 'modifications': - return _uniqBy(species.value?.table_entries, 'modification.id').map( - (tableEntry) => ({ - name: tableEntry.modification.name, - id: tableEntry.modification.id, - type1: "2'-O-me", - type2: tableEntry.modification.after, - count: - rowEntriesCountBy.value.modifications[tableEntry.modification.id] - }) - ) - case 'guides': - return _uniqBy(species.value?.table_entries, 'guide.id').map( - (tableEntry) => ({ - name: tableEntry.guide.name, - id: tableEntry.guide.id, - type1: tableEntry.guide.family, - type2: tableEntry.guide.type, - count: rowEntriesCountBy.value.guides[tableEntry.guide.id] - }) - ) - case 'targets': - return _uniqBy(species.value?.table_entries, 'target.id').map( - (tableEntry) => ({ - name: tableEntry.target.name, - id: tableEntry.target.id, - type1: tableEntry.target.type, - count: rowEntriesCountBy.value.targets[tableEntry.target.id] - }) - ) - default: - return [] - } -}) +/** + * For each field, a `LinkListItemModel` of the uniq entries of that field + */ +const itemsByField = computed<{ [k: string]: LinkListItemModel[] }>(() => ({ + modifications: modificationsTableEntries.value.map((tableEntry) => ({ + id: tableEntry.modification.id, + title: tableEntry.modification.name, + subtitle: tableEntry.modification.id, + link: { + name: `modificationDetails`, + params: { id: tableEntry.modification.id } + }, + details: [ + "2'-O-me", + tableEntry.modification.after, + `${ + rowEntriesCountByField.value.modifications[tableEntry.modification.id] + } linked guide${ + rowEntriesCountByField.value.modifications[tableEntry.modification.id] > + 1 + ? 's' + : '' + }` + ] + })), + guides: guidesTableEntries.value.map((tableEntry) => ({ + id: tableEntry.guide.id, + title: tableEntry.guide.name, + subtitle: tableEntry.guide.id, + link: { + name: `guideDetails`, + params: { id: tableEntry.guide.id } + }, + details: [ + tableEntry.guide.family, + tableEntry.guide.type, + `${ + rowEntriesCountByField.value.guides[tableEntry.guide.id] + } guided modification${ + rowEntriesCountByField.value.guides[tableEntry.guide.id] > 1 ? 's' : '' + }` + ] + })), + targets: targetsTableEntries.value.map((tableEntry) => ({ + id: tableEntry.target.id, + title: tableEntry.target.name, + subtitle: tableEntry.target.id, + link: { + name: `${selectedField.value.slice(0, -1)}Details`, + params: { id: tableEntry.target.id } + }, + details: [ + tableEntry.target.type, + `${ + rowEntriesCountByField.value.targets[tableEntry.target.id] + } registered modification${ + rowEntriesCountByField.value.targets[tableEntry.target.id] > 1 + ? 's' + : '' + }` + ] + })), + none: [] +})) -const selectedFieldCountInfo = () => { - switch (selectedField.value) { - case 'modifications': - return 'linked guide' - case 'guides': - return 'guided modification' - case 'targets': - return 'registered modification' - default: - return '' - } -} +/** + * A list of the main info of each chromosome of the species to display on + * the karyotype (i.e. len > 1e6) + */ +const karyotypeChromosomes = computed( + () => + species.value?.chromosomes + .map((chr) => ({ + name: chr.name, + id: chr.id, + length: chr.length, + centromereCenter: Math.round(chr.length / 2), + + textLeft: 'C/D box', + textRight: 'H/ACA box' + })) + .filter((chr) => chr.length > 1e6) || [] +) + +/** + * A list of 'objects' to display on the karyotype, that is the species guides + */ +const karyotypeObjects = computed( + () => + guidesTableEntries.value + .filter((tableEntry) => tableEntry.guide.type === 'CD box') + .map((tableEntry) => ({ + name: tableEntry.guide.name, + id: tableEntry.guide.id, + start: tableEntry.guide.start, + end: tableEntry.guide.end, + chromosomeId: tableEntry.guide.chr_id, + side: 'left', + color: '#efcb58', + highlightColor: '#e87' + })) + .concat([ + { + name: 'Test guide', + id: 'GD_TEST', + start: 1124873, + end: 1124988, + chromosomeId: 'CHR_00000', + side: 'right', + color: '#c8baff', + highlightColor: '#8bf' + } + ]) as GenomeObjectModel[] +) + +/** + * The number of guide on each chromosome, as a {chrID: guideCount} map + */ +const guideCountByChromosome = computed(() => + _countBy(guidesTableEntries.value, 'guide.chromosome.id') +) + +/** + * For each chromosome, a `LinkListItemModel` of the guides present on that chromosome + */ +const guidesByKaryotypeChromosome = computed<{ + [k: string]: LinkListItemModel[] +}>(() => + karyotypeChromosomes.value.reduce( + ( + guidesByKaryotypeChromosome: { [k: string]: LinkListItemModel[] }, + currKaryotypeChromosome + ) => { + guidesByKaryotypeChromosome[currKaryotypeChromosome.id] = _sortBy( + guidesTableEntries.value.filter( + (tableEntry) => + tableEntry.guide.chromosome.id === currKaryotypeChromosome.id + ), + (el) => el.guide.start + el.guide.end + ).map((tableEntry) => ({ + id: tableEntry.guide.id, + title: tableEntry.guide.name, + subtitle: tableEntry.guide.id, + link: { + name: 'guideDetails', + params: { id: tableEntry.guide.id } + }, + details: [ + tableEntry.guide.type, + `Start: ${tableEntry.guide.start}`, + `End: ${tableEntry.guide.end}` + ] + })) + return guidesByKaryotypeChromosome + }, + {} + ) +) </script> <template> @@ -126,7 +261,7 @@ const selectedFieldCountInfo = () => { Species statistics </h2> - <div class="mx-8 grid grid-cols-3 gap-8"> + <div class="mx-8 mb-16 grid grid-cols-3 gap-8"> <div class="left flex flex-col gap-4"> <button :class="[ @@ -236,57 +371,69 @@ const selectedFieldCountInfo = () => { </div> </div> <div class="right col-span-2"> - <Card - :class="['!border', `!border-${selectedFieldColor}-600`]" - :pt="{ - body: { class: '!pr-0' }, - content: { - class: '!py-0' - } - }" + <BaseLinkListCard + ref="listCardComponent" + :color="colorByField[selectedField]" + :link-items="itemsByField[selectedField]" + :disabled="selectedField === 'none'" > - <template v-if="selectedField" #content> - <div - ref="dataCardContent" - class="!mr-2 flex max-h-[65vh] flex-col gap-4 overflow-auto !pr-3" - > - <RouterLink - v-for="data in selectedFieldData" - :key="data.id" - :class="[ - `hover:border-${selectedFieldColor}-600`, - 'grid grid-cols-5 rounded-md border-2 p-2 hover:shadow-md' - ]" - :to="{ - name: `${selectedField.slice(0, -1)}Details`, - params: { id: data.id } - }" - > - <div class="flex flex-col border-r-2"> - <h4 class="text-center text-lg font-semibold"> - {{ data.name }} - </h4> - <p class="text-center italic text-slate-400">{{ data.id }}</p> - </div> - <div class="col-span-4 my-auto pl-4 text-xl text-slate-400"> - {{ data.type1 }} - <span class="mx-2">•</span> - {{ data.type2 }} - <span v-if="data.type2" class="mx-2">•</span> - {{ data.count }} - {{ selectedFieldCountInfo() + (data.count > 1 ? 's' : '') }} - </div> - </RouterLink> - </div> + <template #disabled> + <strong>←</strong> + Chose a data type on the left to see its elements </template> - <template v-else #content> - <span class="m-auto italic text-slate-400"> - <strong>←</strong> - Chose a data type on the left to see its elements - </span></template - > - </Card> + </BaseLinkListCard> </div> </div> + + <Panel + toggleable + class="mx-auto max-w-7xl break-words 2xl:max-w-[100rem]" + :pt="{ + header: { class: '!bg-slate-50' }, + toggler: { class: 'hover:!bg-slate-100' } + }" + > + <template #header="scope"> + <icon-fa6-solid-dna :class="scope.class" /> + <span :id="scope.id" class="p-panel-title">Chromosomes</span> + </template> + + <KaryotypeBoard + v-if="species" + :chromosomes="karyotypeChromosomes" + :objects="karyotypeObjects" + > + <template #karyotypeChromosomeAppend="{ chromosome }"> + <p class="text-center text-xl"> + Guides: + <span class="font-bold italic"> + {{ guideCountByChromosome[chromosome.id] }} + </span> + </p> + </template> + <template + #expandedChromosomeAppend="{ + chromosome, + highlightObject, + unhighlightObject + }" + > + <div class="mb-8 text-center italic text-slate-400"> + Length : + <em class="text-lg font-bold not-italic text-slate-700">{{ + chromosome.length + }}</em> + </div> + <BaseLinkListCard + list-title="Guides list" + :link-items="guidesByKaryotypeChromosome[chromosome.id]" + color="sky" + :hover-event="true" + @enter-item="highlightObject" + @leave-item="unhighlightObject" + ></BaseLinkListCard> + </template> + </KaryotypeBoard> + </Panel> </MainLayout> </template> diff --git a/tailwind.config.js b/tailwind.config.js index 1b8dc0ad73334d62edc17bd6651f1678121b2708..d69f6465e0ea9f122df02cebdc28700955f00b14 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -36,9 +36,11 @@ export default { pattern: /^!?border-(lime|red|orange|yellow|violet|purple|slate)-600$/ }, { - pattern: /^border-(sky|lime|amber|transparent)-600$/, + pattern: /^border-(sky|lime|amber|transparent|slate)-600$/, variants: ['hover'] - } + }, + 'brightness-90', // Zoomed chromosome objects hover + 'cursor-pointer' // Zoomed chromosome objects // { // pattern: /^!border-(sky|lime|amber|transparent)-600$/ // }