diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000000000000000000000000000000000..98d4832a4762bd4cbe321f8cbb413a7003886c13 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +src/gql/codegen +src/assets/theme.css \ No newline at end of file diff --git a/README.md b/README.md index aaa2e843d4c5dbcb69e8d2d55fb7fb473cc78aaf..41f7346a9ea86b53206f8b84365ddb6979fa8f71 100644 --- a/README.md +++ b/README.md @@ -86,10 +86,15 @@ import ... from 'componentLib/some3PComponent' import ... from '@/components/someLocalComponent' import ... from '@/layout/someLayoutComponent' import ... from '@/views/someViewComponent' +/** + * Composables imports + */ +import ... from '@/composables/useSomeComposable' +import { ... } from '@vueuse/core' /** * Other 3rd-party imports */ -import { ... } from '@vueuse/core' // For VueUse e.g. +import { ... } from 'lodash-es' /** * Stores imports */ @@ -97,7 +102,7 @@ import { ... } from '@/stores/someStore' /** * Types imports */ -import { type ... } from 'lib/some3PTypes' +import { type ... } from 'lib/someTypes' import { type ... } from '@/typings/someTypes' /** * Utils imports diff --git a/package-lock.json b/package-lock.json index 88dcb77748fafe58b29b7dfb73a5c04843770dcf..dcd740875af49390748081ee0c4cc92990e25eb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5046,16 +5046,18 @@ "dev": true }, "node_modules/@types/lodash": { - "version": "4.14.196", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.196.tgz", - "integrity": "sha512-22y3o88f4a94mKljsZcanlNWPzO0uBsBdzLAngf2tp533LzZcQzb6+eZPJ+vCTt+bqF2XnvT9gejTLsAcJAJyQ==", - "dev": true + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "dev": true, + "license": "MIT" }, "node_modules/@types/lodash-es": { "version": "4.17.8", "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.8.tgz", "integrity": "sha512-euY3XQcZmIzSy7YH5+Unb3b2X12Wtk54YWINBvvGQ5SmMvwb11JQskGsfkH/5HXK77Kr8GF0wkVDIxzAisWtog==", "dev": true, + "license": "MIT", "dependencies": { "@types/lodash": "*" } diff --git a/src/assets/icons/alignment.svg b/src/assets/icons/alignment.svg new file mode 100644 index 0000000000000000000000000000000000000000..653e40e9ed5a0a74d99c446ae1c323676645f848 --- /dev/null +++ b/src/assets/icons/alignment.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"> + <path + d="M0 96c0-17.7 14.3-32 32-32h448c17.7 0 32 14.3 32 32s-14.3 32-32 32H32c-17.7 0-32-14.3-32-32Zm224 320c0-17.7 14.3-32 32-32h224c17.7 0 32 14.3 32 32s-14.3 32-32 32H256c-17.7 0-32-14.3-32-32zM0 416c0-17.7 14.3-32 32-32h98c17.7 0 32 14.3 32 32s-14.3 32-32 32H32c-17.7 0-32-14.3-32-32Zm288-160c0 17.7-14.3 32-32 32H32c-17.7 0-32-14.3-32-32s14.3-32 32-32h224c17.7 0 32 14.3 32 32zm224 0c0 17.7-14.3 32-32 32h-98c-17.7 0-32-14.3-32-32s14.3-32 32-32h98c17.7 0 32 14.3 32 32z" + fill="currentColor" /> +</svg> \ No newline at end of file diff --git a/src/assets/icons/modification.svg b/src/assets/icons/modification.svg index cb196bffed9d9cbdc722e0dc2f892a1cd555e7c6..fd6ff998f1c7b35cf55e14ed161762c07907fc01 100644 --- a/src/assets/icons/modification.svg +++ b/src/assets/icons/modification.svg @@ -1,5 +1,4 @@ <svg width="1em" height="1em" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"> - <path style="fill:currentColor" d="M56.8 267.5q-17.2-.7-27-8.2-9.9-7.6-13-22.3l-5-24.2 5.9 2H.5l-1.3-6q8.4-6.2 17.5-6.2 6.6 0 10.6 3.7 4.1 3.5 5.4 10.2l2.8 15.4q1.6 9.3 4.4 15 2.8 5.4 7.2 8 4.3 2.5 10.6 3.1h11q4-.3 7-1 3-.8 5.7-2.5 1.4-1.4 3-4.8 1.5-3.4 3-8.2 1.3-4.8 2.3-9.6l3-15.4q1.2-6.7 5.1-10.2 4-3.7 10-3.7 9.2 0 17.5 6.2l-1.2 6h-17.2l5.9-2-5 24q-3.2 14.8-13.2 22.3-10 7.6-27.3 8.4zm-16.3 28.2-.9-4.4q2.8-1.7 6-3.2 3.3-1.7 7.9-3.4l.1 11zm25.7 0 .4-9.9 19.3 2.8-.1 7.1zm-14.5 0v-92h21.2v92zm8.5-82-19.3-2.8.1-7.1h19.6zm10.8 1.1-.2-11h12.7l.9 4.4-6.7 3.5q-3 1.6-6.7 3.1zm60 100.9-5.4-6.5q4.2-3.7 6.7-7.7 2.7-4 2.8-7.7l-7.4-6q.9-5.2 3.8-8.7 3-3.6 7.3-5.9 6.3 2.3 9.3 6.6 3.1 4.2 3.1 9.2 0 5-2.7 10.1-2.6 5.2-7.2 9.5t-10.4 7.1z" diff --git a/src/assets/style.css b/src/assets/style.css index b5c61c956711f981a41e95f7fcf0038436cfbb22..45d56fcc15fde275e16056f29d0eda606d18248e 100644 --- a/src/assets/style.css +++ b/src/assets/style.css @@ -1,3 +1,9 @@ @tailwind base; @tailwind components; @tailwind utilities; + +@layer utilities { + .small-caps { + font-variant: small-caps; + } +} diff --git a/src/components/AlignmentForm.vue b/src/components/AlignmentForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..5aa7d1f32c754d229c22787c476732568aec5dcc --- /dev/null +++ b/src/components/AlignmentForm.vue @@ -0,0 +1,120 @@ +<script setup lang="ts"> +/** + * Vue imports + */ +import { computed, ref } from 'vue' +/** + * Components imports + */ +import AlignmentFormGuide from './AlignmentFormGuide.vue' +import AlignmentFormTarget from './AlignmentFormTarget.vue' +import TabView from 'primevue/tabview' +import TabPanel from 'primevue/tabpanel' +/** + * Types imports + */ +import type { AlignmentModesType } from '@/views/AlignmentView.vue' +import { ALIGNMENT_MODES } from '@/utils/constant' +/** + * Utils imports + */ +import { getColorWithOptionalShade } from '@/utils/colors' + +/** + * Available tabs for type of data to align. + */ +enum TabEnum { + Guide = 0, + Target +} + +/** + * Component props + */ +const props = defineProps<{ + /** The current mode. */ + currentModeModel: AlignmentModesType + /** The selected IDs. */ + selectedIdsModel?: string[] +}>() + +/** + * Component events. + */ +const emit = defineEmits<{ + /** Event used to update the `v-model:currentModeModel` value. */ + 'update:currentModeModel': [currentModeModel: AlignmentModesType] + /** Event used to update the `v-model:selectedIdsModel` value. */ + 'update:selectedIdsModel': [selectedIdsModel?: string[]] +}>() + +/** + * The index of the tab currently selected, reactively updated when changed. + */ +const activeTabIndex = computed<TabEnum>({ + get: () => + ALIGNMENT_MODES.findIndex((mode) => mode === props.currentModeModel), + set: (newTabIndex) => + emit('update:currentModeModel', ALIGNMENT_MODES[newTabIndex]) +}) + +/** + * The IDs of the sequences selected for alignment. + */ +const selectedIds = ref<{ + /** The IDs of the guides selected for alignment. */ + guide: string[] + /** The IDs of the targets selected for alignment. */ + target: string[] +}>({ + guide: [], + target: [] +}) +</script> + +<template> + <TabView + v-model:active-index="activeTabIndex" + :pt="{ + nav: { + style: { + justifyContent: 'center', + marginBottom: '2rem', + fontSize: '1.25em', + background: `linear-gradient(white 0 0) padding-box, + linear-gradient(90deg, + ${getColorWithOptionalShade('slate', '300')}00 10%, + ${getColorWithOptionalShade('slate', '300')} 15%, + ${getColorWithOptionalShade('slate', '300')} 85%, + ${getColorWithOptionalShade('slate', '300')}00 90%) + border-box`, + borderStyle: 'dashed', + borderColor: 'white' + } + } + }" + @tab-change="$emit('update:selectedIdsModel', [])" + > + <TabPanel header="Guide"> + <AlignmentFormGuide + v-model:selected-guide-ids-model="selectedIds.guide" + class="mx-auto rounded-xl border border-slate-300 bg-slate-50 p-8" + @update:selected-guide-ids-model=" + (newSelectedGuideIds) => + $emit('update:selectedIdsModel', newSelectedGuideIds) + " + /> + </TabPanel> + + <TabPanel header="Target"> + <AlignmentFormTarget + v-model:selected-target-ids-model="selectedIds.target" + class="mx-auto rounded-xl border border-slate-300 bg-slate-50 p-8" + @update:selected-target-ids-model=" + (newSelectedTargetIds) => + $emit('update:selectedIdsModel', newSelectedTargetIds) + " + /> + </TabPanel> + </TabView> +</template> diff --git a/src/components/AlignmentFormGuide.vue b/src/components/AlignmentFormGuide.vue new file mode 100644 index 0000000000000000000000000000000000000000..9e01148e1332057e879bf830b72b10ed068b40da --- /dev/null +++ b/src/components/AlignmentFormGuide.vue @@ -0,0 +1,364 @@ +<script setup lang="ts"> +/** + * Vue imports + */ +import { computed, ref, toRef } from 'vue' +/** + * Components imports + */ +import BaseRenderedMarkdown from '@/components/BaseRenderedMarkdown.vue' +import SelectButton from 'primevue/selectbutton' +import Chip from 'primevue/chip' +import MultiSelect from 'primevue/multiselect' +import Dropdown from 'primevue/dropdown' +/** + * Composables imports + */ +import { useQuery } from '@urql/vue' +/** + * Other 3rd-party imports + */ +import { + uniqWith as _uniqWith, + isEqual as _isEqual, + groupBy as _groupBy, + mapValues as _mapValues +} from 'lodash-es' +/** + * Types imports + */ +import type { GuideClass } from '@/gql/codegen/graphql' +/** + * Utils imports + */ +import { guideAlignmentQuery } from '@/gql/queries' + +interface GuideAlignmentSelectionModel { + /** Currently selected guide subclasses. */ + guideSubclasses: GuideClass[] + /** Currently selected guide name. */ + guideName: string | undefined + /** Currently selected organism IDs.*/ + organismIds: number[] + /** Currently selected guide IDs for alignment. */ + guideIds: string[] +} + +/** + * Component props + */ +defineProps<{ + /** The currently selected guide IDs for alignment. */ + selectedGuideIdsModel?: string[] +}>() + +/** + * Component events. + */ +const emit = defineEmits<{ + /** Event used to update the `v-model:selectedGuideIdsModel` value. */ + 'update:selectedGuideIdsModel': [selectedGuideIdsModel?: string[]] +}>() + +/** + * Current selection of parameters/filters for the guides to pick. + */ +const selection = ref<GuideAlignmentSelectionModel>({ + guideSubclasses: [], + guideName: undefined, + organismIds: [], + guideIds: [] +}) + +/** + * Reactive urql GraphQL query object, updated with query state & response. + */ +const gqlQuery = useQuery({ + query: guideAlignmentQuery, + variables: toRef(() => ({ + guideSubclasses: selection.value.guideSubclasses.length + ? selection.value.guideSubclasses + : undefined, + guideName: selection.value.guideName || '', + organismIds: selection.value.organismIds.length + ? selection.value.organismIds + : undefined + })) +}) + +/** + * Available options for guide subclass selection, independently of other fields' + * selection. + */ +const guideSubclassOptions = computed(() => + _uniqWith( + gqlQuery.data.value?.guideBase, + (guideA, guideB) => guideA.subclass === guideB.subclass + ).map((guide) => ({ + label: guide.subclass_label, + value: guide.subclass + })) +) + +/** + * Available options for guide name selection, independently of other fields' + * selection. + */ +const guideNameOptionsBase = computed(() => + _uniqWith( + gqlQuery.data.value?.guideBase, + (guideA, guideB) => guideA.name === guideB.name + ).map((guide) => ({ + label: guide.name, + value: guide.name + })) +) + +/** + * Available guide names, filtered based on other fields' selection (only guide + * names which exists with selected options are present). + */ +const guideNamesFiltered = computed(() => + _uniqWith( + gqlQuery.data.value?.guideNamesFilteredBySubclass, + (guideA, guideB) => guideA.name === guideB.name + ).map((guide) => guide.name) +) + +/** + * Available options for guide name selection, with a `isDisabled` field on + * absence in `guideNamesFiltered`. + */ +const guideNameOptionsWithDisabling = computed(() => + guideNameOptionsBase.value.map((guideNameOption) => ({ + ...guideNameOption, + isDisabled: !guideNamesFiltered.value.some( + (guideNameFiltered) => guideNameFiltered === guideNameOption.value + ) + })) +) + +/** + * Available options for organism ID selection, independently of other fields' + * selection. + */ +const organismIdOptionsBase = computed(() => + _uniqWith(gqlQuery.data.value?.organismsBase, _isEqual).map((organism) => ({ + label: organism.label, + value: organism.id + })) +) + +/** + * For each guide name, an array of the IDs of the organisms which have at + * least a guide of this name registered. + */ +const organismIdsByGuideNameBase = computed(() => + _mapValues( + _groupBy(_uniqWith(gqlQuery.data.value?.guideBase, _isEqual), 'name'), + (guides) => guides.map((guide) => guide.genome?.organism?.id) + ) +) + +/** + * Available organism IDs, filtered based on other fields' selection (only + * organism IDs which exists with selected options are present). + */ +const organismIdsFiltered = computed( + () => + (selection.value.guideName && + organismIdsByGuideNameBase.value[selection.value.guideName]) || + [] +) + +/** + * Available options for organism ID selection, with a `isDisabled` field on + * absence in `organismIdsFiltered`. + */ +const organismIdOptionsWithDisabling = computed(() => + organismIdOptionsBase.value.map((organismIdOption) => ({ + ...organismIdOption, + isDisabled: !organismIdsFiltered.value.some( + (organismIdFiltered) => organismIdFiltered === organismIdOption.value + ) + })) +) + +/** + * Available options for guide ID selection for alignment given the other + * fields selection. + */ +const guideIdOptions = computed(() => + gqlQuery.data.value?.selectableGuides.map((guide) => ({ + label: guide.id, + optionLabel: `\`${guide.id}\` • ${guide.genome?.organism?.shortlabel}`, + value: guide.id + })) +) + +/** + * Clear the selected guide IDs. + */ +const clearGuideIds = () => { + emit('update:selectedGuideIdsModel', (selection.value.guideIds = [])) +} + +/** + * Remove an organism ID from the selected ones & clear the selected guide IDs. + */ +const removeOrganismAndClearGuideIds = (organismId: number) => { + const organismIdIndex = selection.value.organismIds.findIndex( + (currOrganismId) => currOrganismId === organismId + ) + organismIdIndex !== -1 && + selection.value.organismIds.splice(organismIdIndex, 1) + clearGuideIds() +} + +/** + * Clear the selected organisms & guide IDs. + */ +const clearOrganismsAndGuideIds = () => { + selection.value.organismIds = selection.value.organismIds.filter( + (organismId) => + selection.value.guideName && + organismIdsByGuideNameBase.value[selection.value.guideName]?.includes( + organismId + ) + ) + clearGuideIds() +} + +/** + * Clear the selection except the selected guide subclass. + */ +const clearSelectionExceptSubclass = () => { + selection.value.guideName = undefined + clearOrganismsAndGuideIds() +} +</script> + +<template> + <div class="flex max-w-max flex-wrap gap-8"> + <div class="flex flex-col gap-2"> + <h3 class="text-lg font-bold text-slate-700">Guide subclasses</h3> + <SelectButton + v-model="selection.guideSubclasses" + :options="guideSubclassOptions" + option-label="label" + option-value="value" + option-disabled="isDisabled" + multiple + @change="clearSelectionExceptSubclass" + > + <template #option="{ option }"> + <BaseRenderedMarkdown + :stringified-markdown="option.label" + class="font-medium" + /> + </template> + </SelectButton> + </div> + + <div class="flex max-w-min flex-col gap-2"> + <h3 class="text-lg font-bold text-slate-700">Guides</h3> + <Dropdown + v-model="selection.guideName" + :options="guideNameOptionsWithDisabling" + option-label="label" + option-value="value" + option-disabled="isDisabled" + placeholder="Select a guide..." + filter + @change="clearOrganismsAndGuideIds" + /> + </div> + + <div + v-tooltip.bottom=" + !selection.guideName && { + value: 'First select a guide to list its organisms.', + autoHide: false, + pt: { text: { style: { textAlign: 'center' } } } + } + " + class="flex max-w-min flex-col gap-2" + > + <h3 class="text-lg font-bold text-slate-700">Organisms</h3> + <MultiSelect + v-model="selection.organismIds" + :options="organismIdOptionsWithDisabling" + option-label="label" + option-value="value" + option-disabled="isDisabled" + multiple + placeholder="Select organisms..." + :max-selected-labels="3" + display="chip" + filter + :disabled="!selection.guideName" + @change="clearGuideIds" + > + <template #value> + <Chip + v-for="organismId in selection.organismIds.slice(0, 3)" + :key="organismId" + class="mr-1" + removable + @remove.stop="removeOrganismAndClearGuideIds(organismId)" + > + <BaseRenderedMarkdown + :stringified-markdown=" + organismIdOptionsBase.find( + (organismIdOption) => organismIdOption.value === organismId + )?.label || '' + " + class="my-1.5" + /> + </Chip> + <template v-if="selection.organismIds.length > 3"> + +{{ selection.organismIds.length - 3 }} others... + </template> + </template> + <template #option="{ option }"> + <BaseRenderedMarkdown :stringified-markdown="option.label" /> + </template> + </MultiSelect> + </div> + + <div + v-tooltip.bottom=" + !selection.guideName && { + value: 'First select a guide to list its sequences.', + autoHide: false, + pt: { text: { style: { textAlign: 'center' } } } + } + " + class="flex max-w-min flex-col gap-2" + > + <h3 class="text-lg font-bold text-slate-700">Sequences</h3> + <MultiSelect + v-model="selection.guideIds" + :options="guideIdOptions" + option-label="label" + option-value="value" + multiple + placeholder="Select sequences..." + :max-selected-labels="3" + filter + :disabled="!selection.guideName" + empty-message="First select a guide to list its sequences" + @update:model-value="(selectedGuideIdsModel: string[]) => $emit('update:selectedGuideIdsModel', selectedGuideIdsModel)" + > + <template #option="{ option }"> + <BaseRenderedMarkdown :stringified-markdown="option.optionLabel" /> + </template> + <template #value="{ value }"> + <span v-if="value.length" class="font-mono"> + {{ value.join(', ') }} + </span> + </template> + </MultiSelect> + </div> + </div> +</template> diff --git a/src/components/AlignmentFormTarget.vue b/src/components/AlignmentFormTarget.vue new file mode 100644 index 0000000000000000000000000000000000000000..8f5b96271cfb6262aa2df21b842838114fd96f3d --- /dev/null +++ b/src/components/AlignmentFormTarget.vue @@ -0,0 +1,301 @@ +<script setup lang="ts"> +/** + * Vue imports + */ +import { computed, ref, toRef } from 'vue' +/** + * Components imports + */ +import BaseRenderedMarkdown from '@/components/BaseRenderedMarkdown.vue' +import Chip from 'primevue/chip' +import MultiSelect from 'primevue/multiselect' +import Dropdown from 'primevue/dropdown' +/** + * Composables imports + */ +import { useQuery } from '@urql/vue' +/** + * Other 3rd-party imports + */ +import { + uniqWith as _uniqWith, + isEqual as _isEqual, + groupBy as _groupBy, + mapValues as _mapValues +} from 'lodash-es' +/** + * Utils imports + */ +import { targetAlignmentQuery } from '@/gql/queries' + +interface TargetAlignmentSelectionModel { + /** Currently selected target name. */ + targetName: string | undefined + /** Currently selected organism IDs.*/ + organismIds: number[] + /** Currently selected target IDs for alignment. */ + targetIds: string[] +} + +/** + * Component props + */ +defineProps<{ + /** The currently selected target IDs for alignment. */ + selectedTargetIdsModel?: string[] +}>() + +/** + * Component events. + */ +const emit = defineEmits<{ + /** Event used to update the `v-model:selectedTargetIdsModel` value. */ + 'update:selectedTargetIdsModel': [selectedTargetIdsModel?: string[]] +}>() + +/** + * Current selection of parameters/filters for the target sequences to pick. + */ +const selection = ref<TargetAlignmentSelectionModel>({ + targetName: undefined, + organismIds: [], + targetIds: [] +}) + +/** + * Reactive urql GraphQL query object, updated with query state & response. + */ +const gqlQuery = useQuery({ + query: targetAlignmentQuery, + variables: toRef(() => ({ + targetName: selection.value.targetName || '', + organismIds: selection.value.organismIds.length + ? selection.value.organismIds + : undefined + })) +}) + +/** + * Available options for target name selection, independently of other fields' + * selection. + */ +const targetNameOptionsGroups = computed(() => + Object.entries( + _groupBy( + _uniqWith( + gqlQuery.data.value?.targetBase, + (targetA, targetB) => targetA.name === targetB.name + ).map((target) => ({ + label: target.name, + value: target.name, + groupLabel: target.unit || 'Other' + })), + 'groupLabel' + ) + ).map(([targetUnit, targets]) => ({ label: targetUnit, children: targets })) +) + +/** + * Available options for organism ID selection, independently of other fields' + * selection. + */ +const organismIdOptionsBase = computed(() => + _uniqWith(gqlQuery.data.value?.organismsBase, _isEqual).map((organism) => ({ + label: organism.label, + value: organism.id + })) +) + +/** + * For each target name, an array of the IDs of the organisms which have at + * least a target of this name registered. + */ +const organismIdsByTargetNameBase = computed(() => + _mapValues( + _groupBy(_uniqWith(gqlQuery.data.value?.targetBase, _isEqual), 'name'), + (targets) => targets.map((target) => target.genome?.organism?.id) + ) +) + +/** + * Available organism IDs, filtered based on other fields' selection (only + * organism IDs which exists with selected options are present). + */ +const organismIdsFiltered = computed( + () => + (selection.value.targetName && + organismIdsByTargetNameBase.value[selection.value.targetName]) || + [] +) + +/** + * Available options for organism ID selection, with a `isDisabled` field on + * absence in `organismIdsFiltered`. + */ +const organismIdOptionsWithDisabling = computed(() => + organismIdOptionsBase.value.map((organismIdOption) => ({ + ...organismIdOption, + isDisabled: !organismIdsFiltered.value.some( + (organismIdFiltered) => organismIdFiltered === organismIdOption.value + ) + })) +) + +/** + * Available options for target ID selection for alignment given the other + * fields selection. + */ +const targetIdOptions = computed(() => + gqlQuery.data.value?.selectableTargets.map((target) => ({ + label: target.id, + optionLabel: `\`${target.id}\` • ${target.genome?.organism?.shortlabel}`, + value: target.id + })) +) + +/** + * Clear the selected target IDs. + */ +const clearTargetIds = () => { + emit('update:selectedTargetIdsModel', (selection.value.targetIds = [])) +} + +/** + * Remove an organism ID from the selected ones & clear the selected target IDs. + */ +const removeOrganismAndClearTargetIds = (organismId: number) => { + const organismIdIndex = selection.value.organismIds.findIndex( + (currOrganismId) => currOrganismId === organismId + ) + organismIdIndex !== -1 && + selection.value.organismIds.splice(organismIdIndex, 1) + clearTargetIds() +} + +/** + * Clear the selected organisms & target IDs. + */ +const clearOrganismsAndTargetIds = () => { + selection.value.organismIds = selection.value.organismIds.filter( + (organismId) => + selection.value.targetName && + organismIdsByTargetNameBase.value[selection.value.targetName]?.includes( + organismId + ) + ) + clearTargetIds() +} +</script> + +<template> + <div class="flex max-w-max flex-wrap gap-8"> + <div class="flex max-w-min flex-col gap-2"> + <h3 class="text-lg font-bold text-slate-700">Targets</h3> + <Dropdown + v-model="selection.targetName" + :options="targetNameOptionsGroups" + option-label="label" + option-value="value" + option-group-label="label" + option-group-children="children" + placeholder="Select a target..." + filter + :pt="{ + item: { + style: { + marginLeft: '1rem' + } + } + }" + @change="clearOrganismsAndTargetIds" + /> + </div> + + <div + v-tooltip.bottom=" + !selection.targetName && { + value: 'First select a target to list its organisms.', + autoHide: false, + pt: { text: { style: { textAlign: 'center' } } } + } + " + class="flex max-w-min flex-col gap-2" + > + <h3 class="text-lg font-bold text-slate-700">Organisms</h3> + <MultiSelect + v-model="selection.organismIds" + :options="organismIdOptionsWithDisabling" + option-label="label" + option-value="value" + option-disabled="isDisabled" + multiple + placeholder="Select organisms..." + :max-selected-labels="3" + display="chip" + filter + :disabled="!selection.targetName" + @change="clearTargetIds" + > + <template #value> + <Chip + v-for="organismId in selection.organismIds.slice(0, 3)" + :key="organismId" + class="mr-1" + removable + @remove.stop="removeOrganismAndClearTargetIds(organismId)" + > + <BaseRenderedMarkdown + :stringified-markdown=" + organismIdOptionsBase.find( + (organismIdOption) => organismIdOption.value === organismId + )?.label || '' + " + class="my-1.5" + /> + </Chip> + <template v-if="selection.organismIds.length > 3"> + +{{ selection.organismIds.length - 3 }} others... + </template> + </template> + <template #option="{ option }"> + <BaseRenderedMarkdown :stringified-markdown="option.label" /> + </template> + </MultiSelect> + </div> + + <div + v-tooltip.bottom=" + !selection.targetName && { + value: 'First select a target to list its sequences.', + autoHide: false, + pt: { text: { style: { textAlign: 'center' } } } + } + " + class="flex max-w-min flex-col gap-2" + > + <h3 class="text-lg font-bold text-slate-700">Sequences</h3> + <MultiSelect + v-model="selection.targetIds" + :options="targetIdOptions" + option-label="label" + option-value="value" + multiple + placeholder="Select sequences..." + :max-selected-labels="3" + filter + :disabled="!selection.targetName" + empty-message="First select a target to list its sequences" + @update:model-value="(selectedTargetIds: string[]) => $emit('update:selectedTargetIdsModel', selectedTargetIds)" + > + <template #option="{ option }"> + <BaseRenderedMarkdown :stringified-markdown="option.optionLabel" /> + </template> + <template #value="{ value }"> + <span v-if="value.length" class="font-mono"> + {{ value.join(', ') }} + </span> + </template> + </MultiSelect> + </div> + </div> +</template> diff --git a/src/components/BaseLegendButtonOverlay.vue b/src/components/BaseLegendButtonOverlay.vue index 68f0f79a1bf4dd8120e1d958e81e2e92e7a8bb27..058299341d2aa98d4408c29cfebbd0bcbb2e6ead 100644 --- a/src/components/BaseLegendButtonOverlay.vue +++ b/src/components/BaseLegendButtonOverlay.vue @@ -4,7 +4,7 @@ */ import { ref } from 'vue' /** - * Component imports + * Components imports */ import Button from 'primevue/button' import OverlayPanel from 'primevue/overlaypanel' diff --git a/src/components/BaseLockableTooltip.vue b/src/components/BaseLockableTooltip.vue index 52fb6f14caa4c73905e6fe597988f8af31dfc19a..d7d5c0f88dba2883f89e81efcdd53b35be37be01 100644 --- a/src/components/BaseLockableTooltip.vue +++ b/src/components/BaseLockableTooltip.vue @@ -8,7 +8,7 @@ import { computed, ref } from 'vue' */ import BaseTooltip from '@/components/BaseTooltip.vue' /** - * Other 3rd-party imports + * Composables imports */ import { useMouse, onClickOutside } from '@vueuse/core' diff --git a/src/components/ChromosomeMagnify.vue b/src/components/ChromosomeMagnify.vue index a9538657e3e4c51d3cc51b007343c09362b96ea8..300add17373f65a9f317061af39b9ef823b49248 100644 --- a/src/components/ChromosomeMagnify.vue +++ b/src/components/ChromosomeMagnify.vue @@ -8,9 +8,12 @@ import { computed, onMounted, ref } from 'vue' */ import BaseLockableTooltip from '@/components/BaseLockableTooltip.vue' /** - * Other 3rd-party imports + * Composables imports */ import { useMouseInElement } from '@vueuse/core' +/** + * Other 3rd-party imports + */ import { G, Rect, SVG, type Svg } from '@svgdotjs/svg.js' /** * Types imports diff --git a/src/components/HelpDialog.vue b/src/components/HelpDialog.vue index 225a44c8416be166b50d6dc634cf6e0b090eb63d..1254b4f7bee309022f1392500f5f77d166578343 100644 --- a/src/components/HelpDialog.vue +++ b/src/components/HelpDialog.vue @@ -4,7 +4,7 @@ */ import { ref, watchEffect } from 'vue' /** - * Component imports + * Components imports */ import Dialog from 'primevue/dialog' import Carousel from 'primevue/carousel' diff --git a/src/components/InteractionCard.vue b/src/components/InteractionCard.vue index 17dd546847cd57db689774a1d3493862c769437c..757548bb4608cf8731d7cb2df0255ccc018d3f0f 100644 --- a/src/components/InteractionCard.vue +++ b/src/components/InteractionCard.vue @@ -142,9 +142,9 @@ const HACAFormattedInteraction = computed<InteractionHACAModel | undefined>( () => { if (props.interaction?.modification.type === ModifType.Psi) { const duplexes = { - before: props.interaction.duplexes.find((duplex) => duplex.index === 1), - middle: props.interaction.duplexes.find((duplex) => duplex.index === 2), - after: props.interaction.duplexes.find((duplex) => duplex.index === 3) + before: props.interaction.duplexes.find((duplex) => duplex.index === 0), + middle: props.interaction.duplexes.find((duplex) => duplex.index === 1), + after: props.interaction.duplexes.find((duplex) => duplex.index === 2) } if ( diff --git a/src/components/InteractionCardCD.vue b/src/components/InteractionCardCD.vue index 01f46763033bfa378ed1f787f227ebedaa6478fc..3d445222a561829828fbe9e2d591233ba25a62ad 100644 --- a/src/components/InteractionCardCD.vue +++ b/src/components/InteractionCardCD.vue @@ -223,7 +223,9 @@ const bondsText = computed(() => const basePair = nucleotide + paddedFragments.value.target[nucleotidePositionInDuplex] || '' - return /GC|CG|AU|UA/.test(basePair) + return nucleotidePositionInDuplex === modificationPositionInDuplex.value + ? ' ' + : /GC|CG|AU|UA/.test(basePair) ? '|' : /GU|UG/.test(basePair) && nucleotidePositionInDuplex > firstBondPositionInDuplex.value && diff --git a/src/components/InteractionCardHACA.vue b/src/components/InteractionCardHACA.vue index 9d5046ba9309b3fe8221ec21955eb04c73b05612..35b93e0fd30d5e32577ab53d3929812c5f1fe4ca 100644 --- a/src/components/InteractionCardHACA.vue +++ b/src/components/InteractionCardHACA.vue @@ -9,7 +9,8 @@ import { ref, computed } from 'vue' import { isEqual as _isEqual, reverse as _reverse, - findLastIndex as _findLastIndex + findLastIndex as _findLastIndex, + mapValues as _mapValues } from 'lodash-es' /** * Types imports @@ -349,45 +350,42 @@ const lastBondIndex = computed(() => * Texts representing the bonds w/ their nature */ const bondsTexts = computed(() => - Object.entries({ - before: [ - splitPaddedFragments.value.guide.first.before, - splitPaddedFragments.value.target.before - ], - middle: [ - splitPaddedFragments.value.guide.first.middle, - _reverse(splitPaddedFragments.value.guide.second.middle.split('')).join( - '' - ) - ], - after: [ - splitPaddedFragments.value.guide.second.after, - splitPaddedFragments.value.target.after - ] - }).reduce<{ [duplexId: string]: string }>( - (bondsTexts, [duplexId, fragmentPair]) => ({ - ...bondsTexts, - [duplexId]: - fragmentPair[0] - ?.split('') - .map((nucleotide, inDuplexNucleotideIndex) => { - const basePair = - nucleotide + fragmentPair[1]?.[inDuplexNucleotideIndex] || '' - return /GC|CG|AU|UA/.test(basePair) - ? '|' - : /GU|UG/.test(basePair) && - inDuplexNucleotideIndex > firstBondIndex.value && - inDuplexNucleotideIndex < lastBondIndex.value - ? '∙' - : /GA|AG/.test(basePair) && - inDuplexNucleotideIndex > firstBondIndex.value && - inDuplexNucleotideIndex < lastBondIndex.value - ? '○' - : ' ' - }) - .join('') || '' - }), - {} + _mapValues( + { + before: [ + splitPaddedFragments.value.guide.first.before, + splitPaddedFragments.value.target.before + ], + middle: [ + splitPaddedFragments.value.guide.first.middle, + _reverse(splitPaddedFragments.value.guide.second.middle.split('')).join( + '' + ) + ], + after: [ + splitPaddedFragments.value.guide.second.after, + splitPaddedFragments.value.target.after + ] + }, + (fragmentPair) => + fragmentPair[0] + ?.split('') + .map((nucleotide, inDuplexNucleotideIndex) => { + const basePair = + nucleotide + fragmentPair[1]?.[inDuplexNucleotideIndex] || '' + return /GC|CG|AU|UA/.test(basePair) + ? '|' + : /GU|UG/.test(basePair) && + inDuplexNucleotideIndex > firstBondIndex.value && + inDuplexNucleotideIndex < lastBondIndex.value + ? '∙' + : /GA|AG/.test(basePair) && + inDuplexNucleotideIndex > firstBondIndex.value && + inDuplexNucleotideIndex < lastBondIndex.value + ? '○' + : ' ' + }) + .join('') || '' ) ) diff --git a/src/components/KaryotypeBoard.vue b/src/components/KaryotypeBoard.vue index 3eecb1199eb97ba399de2406a2c23ef82cac0ded..e7aa613efa7ce8a31c48147adc3e3517ea2d2019 100644 --- a/src/components/KaryotypeBoard.vue +++ b/src/components/KaryotypeBoard.vue @@ -9,12 +9,15 @@ import { computed, onMounted, ref } from 'vue' import Button from 'primevue/button' import IconTablerCircleArrowLeft from '~icons/tabler/circle-arrow-left' import IconTablerCircleArrowLeftFilled from '~icons/tabler/circle-arrow-left-filled' +/** + * Composables imports + */ +import { useElementBounding, useWindowScroll } from '@vueuse/core' /** * 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 */ @@ -117,7 +120,10 @@ const expandedChromosomeLength = computed(() => */ const longestChromosome = computed(() => props.chromosomes.reduce( - (longestChromosome, currChromosome) => (longestChromosome && currChromosome.length > longestChromosome.length ? currChromosome : longestChromosome), + (longestChromosome, currChromosome) => + longestChromosome && currChromosome.length > longestChromosome.length + ? currChromosome + : longestChromosome, props.chromosomes[0] ) ) @@ -129,7 +135,7 @@ const objectsByChromosome = computed(() => props.chromosomes.reduce<{ [k: string]: GenomeObjectModel[] }>( (objectsByChromosome, currChromosome) => ({ ...objectsByChromosome, - [currChromosome.id] : (props.objects || []).filter( + [currChromosome.id]: (props.objects || []).filter( (object) => object.chromosomeId === currChromosome.id ) }), diff --git a/src/components/SecondaryStructure.vue b/src/components/SecondaryStructure.vue index d6f1490415c5d671a391e34c6778def16e827055..e3076650e1a336ea033d7a8e8d4581df16813064 100644 --- a/src/components/SecondaryStructure.vue +++ b/src/components/SecondaryStructure.vue @@ -19,10 +19,13 @@ import IconFa6SolidCropSimple from '~icons/fa6-solid/crop-simple' import IconFa6SolidExpand from '~icons/fa6-solid/expand' import IconFa6SolidDownload from '~icons/fa6-solid/download' /** - * Other3rd-party imports + * Composables imports */ -import G6 from '@antv/g6' import { useElementSize, useResizeObserver } from '@vueuse/core' +/** + * Other 3rd-party imports + */ +import G6 from '@antv/g6' import { find as _find } from 'lodash-es' import mime from 'mime' import FileSaver from 'file-saver' diff --git a/src/components/SelectionForm.vue b/src/components/SelectionForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..4738b92058be52565b532f8ec6716d5994a7f871 --- /dev/null +++ b/src/components/SelectionForm.vue @@ -0,0 +1,166 @@ +<script setup lang="ts"> +/** + * Vue imports + */ +import { computed, ref, watch } from 'vue' +/** + * Components imports + */ +import SelectionFormGuide from './SelectionFormGuide.vue' +import SelectionFormTarget from './SelectionFormTarget.vue' +import SelectionFormModification from './SelectionFormModification.vue' +import TabView from 'primevue/tabview' +import TabPanel from 'primevue/tabpanel' +/** + * Types imports + */ +import type { SelectionModesType } from '@/views/SelectionView.vue' +import type { GuideSelectionModel } from './SelectionFormGuide.vue' +import type { TargetSelectionModel } from './SelectionFormTarget.vue' +import type { ModificationSelectionModel } from './SelectionFormModification.vue' +import { SELECTION_MODES } from '@/utils/constant' +/** + * Utils imports + */ +import { getColorWithOptionalShade } from '@/utils/colors' + +/** + * A selection for the different modes. + */ +export interface SelectionModel { + /** The selection of the modification. */ + modification?: ModificationSelectionModel + /** The selection of the guide. */ + guide?: GuideSelectionModel + /** The selection of the target. */ + target?: TargetSelectionModel +} + +/** + * Available tabs for type of data to select. + */ +enum TabEnum { + Modification = 0, + Guide, + Target +} + +/** + * Component props + */ +const props = defineProps<{ + /** The current mode. */ + currentModeModel: SelectionModesType + /** The current selection in the different modes (v-model value). */ + selectionModel: SelectionModel + /** The ID of the target which matches the selection if it is unique (v-model + * value). */ + onlyTargetIdByModeModel: { modification?: string; target?: string } +}>() + +/** + * Component events. + */ +const emit = defineEmits<{ + /** Event used to update the `v-model:currentModeModel` value. */ + 'update:currentModeModel': [currentModeModel: SelectionModesType] + /** Event used to update the `v-model:selectionModel` value. */ + 'update:selectionModel': [selectionModel: SelectionModel] + /** Event used to update the `v-model:onlyTargetIdByModeModel` value. */ + 'update:onlyTargetIdByModeModel': [ + onlyTargetIdByModeModel: { modification?: string; target?: string } + ] +}>() + +/** + * The index of the tab currently selected, reactively updated when changed. + */ +const activeTabIndex = computed<TabEnum>({ + get: () => + SELECTION_MODES.findIndex((mode) => mode === props.currentModeModel), + set: (newTabIndex) => + emit('update:currentModeModel', SELECTION_MODES[newTabIndex]) +}) + +/** + * The current selection in the different modes (local value). + */ +const selection = ref<SelectionModel>({ + modification: undefined, + guide: undefined, + target: undefined +}) + +/** + * The ID of the target which matches the selection if it is unique (i.e. a + * single target name & a single species is selected), `undefined` otherwise. + * One target ID is used for each mode able to provide one (local value). + */ +const onlyTargetIdByMode = ref<{ modification?: string; target?: string }>({ + modification: undefined, + target: undefined +}) + +/** + * Watchers to update the `v-model` value when modified locally. + */ +watch( + selection, + () => { + emit('update:selectionModel', selection.value) + }, + { deep: true } +) +watch( + onlyTargetIdByMode, + () => { + emit('update:onlyTargetIdByModeModel', onlyTargetIdByMode.value) + }, + { deep: true } +) +</script> + +<template> + <TabView + v-model:active-index="activeTabIndex" + :pt="{ + nav: { + style: { + justifyContent: 'center', + marginBottom: '2rem', + fontSize: '1.25em', + background: `linear-gradient(white 0 0) padding-box, + linear-gradient(90deg, + ${getColorWithOptionalShade('slate', '300')}00 10%, + ${getColorWithOptionalShade('slate', '300')} 15%, + ${getColorWithOptionalShade('slate', '300')} 85%, + ${getColorWithOptionalShade('slate', '300')}00 90%) + border-box`, + borderStyle: 'dashed', + borderColor: 'white' + } + } + }" + > + <TabPanel header="Modification"> + <SelectionFormModification + v-model:selection-model="selection.modification" + v-model:only-target-id="onlyTargetIdByMode.modification" + class="mx-auto rounded-xl border border-slate-300 bg-slate-50 p-8" + /> + </TabPanel> + <TabPanel header="Guide"> + <SelectionFormGuide + v-model:selection-model="selection.guide" + class="mx-auto rounded-xl border border-slate-300 bg-slate-50 p-8" + /> + </TabPanel> + <TabPanel header="Target"> + <SelectionFormTarget + v-model:selection-model="selection.target" + v-model:only-target-id="onlyTargetIdByMode.target" + class="mx-auto rounded-xl border border-slate-300 bg-slate-50 p-8" + /> + </TabPanel> + </TabView> +</template> diff --git a/src/components/GuideSelectionForm.vue b/src/components/SelectionFormGuide.vue similarity index 89% rename from src/components/GuideSelectionForm.vue rename to src/components/SelectionFormGuide.vue index 061f1795a9ac70d694ccc01e3a1299c92eadfcbb..91b30aeb8410f37965de545885db9e1cdca524df 100644 --- a/src/components/GuideSelectionForm.vue +++ b/src/components/SelectionFormGuide.vue @@ -4,7 +4,7 @@ */ import { computed, ref, toRef } from 'vue' /** - * Component imports + * Components imports */ import BaseRenderedMarkdown from './BaseRenderedMarkdown.vue' import Chip from 'primevue/chip' @@ -12,14 +12,18 @@ import SelectButton from 'primevue/selectbutton' import MultiSelect from 'primevue/multiselect' import { GuideClass, ModifType } from '@/gql/codegen/graphql' /** - * Other 3rd-party imports + * Composables imports */ import { useQuery } from '@urql/vue' +/** + * Other 3rd-party imports + */ import { uniqWith as _uniqWith, isEqual as _isEqual } from 'lodash-es' /** * Utils imports */ import { guideSelectionQuery } from '@/gql/queries' +import { isNonNullish } from '@/typings/typeUtils' /** * A guide selection. @@ -27,6 +31,8 @@ import { guideSelectionQuery } from '@/gql/queries' export interface GuideSelectionModel { /** Currently selected guide subclasses. */ guideSubclasses: GuideClass[] + /** Currently selected guide subclass labels. */ + guideSubclassLabels: string[] /** Currently selected target names. */ targetNames: string[] /** Currently selected modification types. */ @@ -56,6 +62,7 @@ defineEmits<{ */ const selection = ref<GuideSelectionModel>({ guideSubclasses: [], + guideSubclassLabels: [], targetNames: [], modificationTypes: [], organismIds: [] @@ -67,7 +74,7 @@ const selection = ref<GuideSelectionModel>({ const gqlQuery = useQuery({ query: guideSelectionQuery, variables: toRef(() => ({ - guideSubclasses: selection.value.guideSubclasses?.length + guideSubclass: selection.value.guideSubclasses?.length ? selection.value.guideSubclasses : undefined, targetNames: selection.value.targetNames?.length @@ -93,6 +100,21 @@ const guideSubclassOptionsBase = computed(() => })) ) +/** + * The guide subclasses, associated to their labels. + */ +const guideSubclassLabelBySubclass = computed<{ + [guideSubclass: string]: string +}>(() => + guideSubclassOptionsBase.value.reduce( + (guideSubclassLabelBySubclass, guideSubclassOption) => ({ + ...guideSubclassLabelBySubclass, + [guideSubclassOption.value]: guideSubclassOption.label + }), + {} + ) +) + /** * Available guide subclasses, filtered based on other fields' selection (only guide * subclasses which exists with selected options are present). @@ -269,7 +291,7 @@ const organismIdOptionsWithDisabling = computed(() => <template> <div - class="grid grid-cols-[max-content_1fr] grid-rows-4 items-center gap-x-16 gap-y-8" + class="grid max-w-max grid-cols-[max-content_1fr] grid-rows-4 items-center gap-x-16 gap-y-8" > <h3 class="text-lg font-bold">Guide subclasses</h3> <SelectButton @@ -280,7 +302,20 @@ const organismIdOptionsWithDisabling = computed(() => option-disabled="isDisabled" multiple class="mr-auto" - @update:model-value="(selectedGuideSubclasses: GuideClass[]) => $emit('update:selectionModel', {...selection, guideSubclasses:selectedGuideSubclasses })" + @update:model-value="(selectedGuideSubclasses: GuideClass[]) => + { + selection.guideSubclassLabels = selectedGuideSubclasses + .map((guideSubclass) => + guideSubclassLabelBySubclass[guideSubclass] + ) + .filter(isNonNullish) + $emit('update:selectionModel', { + ...selection, + guideSubclasses: selectedGuideSubclasses, + guideSubclassLabels: selection.guideSubclassLabels + }) + } + " > <template #option="{ option }"> <BaseRenderedMarkdown @@ -305,7 +340,12 @@ const organismIdOptionsWithDisabling = computed(() => :max-selected-labels="3" display="chip" filter - @update:model-value="(selectedTargetNames: string[]) => $emit('update:selectionModel', {...selection, targetNames:selectedTargetNames })" + @update:model-value="(selectedTargetNames: string[]) => + $emit('update:selectionModel', { + ...selection, + targetNames: selectedTargetNames + }) + " > <template #value> <Chip diff --git a/src/components/ModificationSelectionForm.vue b/src/components/SelectionFormModification.vue similarity index 98% rename from src/components/ModificationSelectionForm.vue rename to src/components/SelectionFormModification.vue index 786ea2137026736a93b67adbbf33e5e51a100566..c0a2344c95fee0c7565ec5148dd1a7947a401618 100644 --- a/src/components/ModificationSelectionForm.vue +++ b/src/components/SelectionFormModification.vue @@ -4,7 +4,7 @@ */ import { computed, ref, toRef, watchEffect } from 'vue' /** - * Component imports + * Components imports */ import BaseRenderedMarkdown from './BaseRenderedMarkdown.vue' import Chip from 'primevue/chip' @@ -12,9 +12,12 @@ import SelectButton from 'primevue/selectbutton' import MultiSelect from 'primevue/multiselect' import { ModifType } from '@/gql/codegen/graphql' /** - * Other 3rd-party imports + * Composables imports */ import { useQuery } from '@urql/vue' +/** + * Other 3rd-party imports + */ import { uniqWith as _uniqWith, isEqual as _isEqual } from 'lodash-es' /** * Utils imports @@ -253,7 +256,7 @@ watchEffect(() => { <template> <div - class="grid grid-cols-[max-content_1fr] grid-rows-4 items-center gap-x-16 gap-y-8" + class="grid max-w-max grid-cols-[max-content_1fr] grid-rows-3 items-center gap-x-16 gap-y-8" > <h3 class="text-lg font-bold">Modification types</h3> <SelectButton diff --git a/src/components/TargetSelectionForm.vue b/src/components/SelectionFormTarget.vue similarity index 98% rename from src/components/TargetSelectionForm.vue rename to src/components/SelectionFormTarget.vue index 8a7a8cbbded74ba2f63261465bd5fea98482e4d6..d22e087d57762f3e5635cbb4ef9cabbb1f4174a3 100644 --- a/src/components/TargetSelectionForm.vue +++ b/src/components/SelectionFormTarget.vue @@ -4,7 +4,7 @@ */ import { computed, ref, toRef, watchEffect } from 'vue' /** - * Component imports + * Components imports */ import BaseRenderedMarkdown from './BaseRenderedMarkdown.vue' import Chip from 'primevue/chip' @@ -12,9 +12,12 @@ import SelectButton from 'primevue/selectbutton' import MultiSelect from 'primevue/multiselect' import { ModifType } from '@/gql/codegen/graphql' /** - * Other 3rd-party imports + * Composables imports */ import { useQuery } from '@urql/vue' +/** + * Other 3rd-party imports + */ import { uniqWith as _uniqWith, isEqual as _isEqual, @@ -257,7 +260,7 @@ watchEffect(() => { <template> <div - class="grid grid-cols-[max-content_1fr] grid-rows-4 items-center gap-x-16 gap-y-8" + class="grid max-w-max grid-cols-[max-content_1fr] grid-rows-3 items-center gap-x-16 gap-y-8" > <h3 class="text-lg font-bold">Targets</h3> <MultiSelect diff --git a/src/components/SequenceAlignment.vue b/src/components/SequenceAlignment.vue new file mode 100644 index 0000000000000000000000000000000000000000..3ca91569f080b2ceab57e47c77bec44a36e15cb0 --- /dev/null +++ b/src/components/SequenceAlignment.vue @@ -0,0 +1,986 @@ +<script setup lang="ts"> +/** + * Vue imports + */ +import { computed, onMounted, ref, toRef, watch } from 'vue' +/** + * Components imports + */ +import SequenceAlignmentToolbar from './SequenceAlignmentToolbar.vue' +import SequenceAlignmentTrackNames from './SequenceAlignmentTrackNames.vue' +/** + * Composables imports + */ +import { useConservationAnalysis } from '@/composables/useConservationAnalysis' +import { useElementBounding, useResizeObserver } from '@vueuse/core' +/** + * Other 3rd-party imports + */ +import { + inRange as _inRange, + uniqBy as _uniqBy, + mapValues as _mapValues +} from 'lodash-es' +/** + * Types imports + */ +import type { + HexColorCodeModel, + TailwindDefaultColorNameModel +} from '@/typings/styleTypes' +import type { RouteLocationRaw } from 'vue-router' +import type { StyleValue } from 'vue' +import type { BasesConservationConfigModel } from './SequenceAlignmentToolbar.vue' +import type { ConservationAnalysisConfigModel } from '@/composables/useConservationAnalysis' +/** + * Utils imports + */ +import { composeConservationRate } from '@/utils/sequences' +import { isDefined } from '@/typings/typeUtils' +import { range } from '@/utils/numbers' + +/** + * A legend item. + */ +export interface LegendItemModel { + /** Item id. */ + id: string + /** Item title. */ + title: string + /** Item description. */ + description?: string + /** Tailwind color name of the color to legend. */ + color?: TailwindDefaultColorNameModel +} + +/** + * An object on a track in an alignment. + */ +export interface AlignmentObjectModel { + /** The name of the object. */ + name?: string + /** The type of the object. */ + type?: string + /** The start position of the object (in the alignment reference). */ + start: number + /** The end position of the object (in the alignment reference). */ + end: number + /** The color in which to represent the object. */ + color?: TailwindDefaultColorNameModel + /** Link to go to when clicking on the object. */ + link?: RouteLocationRaw +} + +/** + * An object on a track in an alignment, with the ID of the track it is on. + */ +export interface AlignmentObjectWithTrackIdModel extends AlignmentObjectModel { + /** The ID of track the object is present on. */ + trackId: string +} + +/** + * A track in an alignment. + */ +export interface AlignmentTrackModel { + /** The ID of the track. */ + id: string + /** The name of the track. */ + name?: string + /** The short name of the track. */ + shortname?: string + /** The sequence of the track. */ + sequence: string + /** Wether the track is a reference sequence or not. */ + isReference?: boolean + /** A list of object present on the track to display. */ + objects?: { [objectId: string]: AlignmentObjectModel } + /** Wether the track is an information track. */ + isInformation?: boolean +} + +/** + * An alignment. + */ +export interface AlignmentModel { + /** The tracks composing the alignment. */ + tracks: AlignmentTrackModel[] +} + +/** + * The colours for base conservation colouring. + */ +const BASE_CONSERVATION_COLOURS = { + low: '#F87171' as HexColorCodeModel, + middle: '#FCD34D' as HexColorCodeModel, + high: '#D9EDC1' as HexColorCodeModel +} + +/** + * Component props. + */ +const props = defineProps<{ + /** The representation of the alignment. */ + alignment: AlignmentModel + /** The items of the legend to display for the highlighted groups. */ + legendItems?: LegendItemModel[] +}>() + +/** + * Size (in term of nucl.) of the groups into which the sequences are divided. + */ +const nucleotideGroupsSize = ref(10) + +/** + * Number of groups in which the sequences are divided. + */ +const nucleotideGroupsCount = computed(() => + Math.ceil( + (props.alignment.tracks[0]?.sequence.length || 1) / + nucleotideGroupsSize.value + ) +) + +/** + * Number of nucleotides in the last group. + */ +const lastNucleotideGroupSize = computed( + () => + (props.alignment.tracks[0]?.sequence.length || 1) % + nucleotideGroupsSize.value +) + +/** + * The tracks, with an additional track for the conservation analysis added + * at the end. + */ +const tracksWithConservationTrack = computed(() => [ + ...props.alignment.tracks, + conservationAnalysis.track.value +]) + +/** + * The sequences of the alignment, split and rearranged in the following nested + * arrays structure: + * ```ts + * [ + * [ // Group 0 + * [ // Nucleotide 0 + * sequence1[0], + * sequence2[0], + * ..., + * sequence<#ofTracks>[0] + * ], + * [ // Nucleotide 1 + * sequence1[1], + * sequence2[1], + * ..., + * sequence<#ofTracks>[1] + * ], + * ..., + * [ // Last nucleotide of group 0 + * sequence1[nucleotideGroupsSize - 1], + * sequence2[nucleotideGroupsSize - 1], + * ..., + * sequence<#ofTracks>[nucleotideGroupsSize - 1] + * ], + * ], + * [ // Group 1 + * [ // First nucleotide of group 1 + * sequence1[nucleotideGroupsSize], + * sequence2[nucleotideGroupsSize], + * ..., + * sequence<#ofTracks>[nucleotideGroupsSize] + * ], + * [ + * sequence1[nucleotideGroupsSize + 1], + * sequence2[nucleotideGroupsSize + 1], + * ..., + * sequence<#ofTracks>[nucleotideGroupsSize + 1] + * ], + * ..., + * [ // Last nucleotide of group 1 + * sequence1[2 * nucleotideGroupsSize - 1], + * sequence2[2 * nucleotideGroupsSize - 1], + * ..., + * sequence<#ofTracks>[2 * nucleotideGroupsSize - 1] + * ], + * ], + * ... + * ] + * ``` + */ +const splitAlignmentSequences = computed(() => + // Top-level array, size = group count, one cell = one group + Array.from({ length: nucleotideGroupsCount.value }, (_, groupIndex) => + // Second-level array, size = group size (if last group, special size), one + // cell = one position in alignment + Array.from( + { + length: + groupIndex === nucleotideGroupsCount.value - 1 + ? lastNucleotideGroupSize.value + : nucleotideGroupsSize.value + }, + (_, nucleotideIndexInGroup) => { + // Compute the position of the nucleotide + const nucleotideIndex = + groupIndex * nucleotideGroupsSize.value + nucleotideIndexInGroup + // Third-level array, size = # of sequences, one cell = the nucleotide + // at current position in the corresponding sequence + return Array.from( + { length: tracksWithConservationTrack.value.length }, + (_, sequenceIndex) => { + const nucleotide = + tracksWithConservationTrack.value[sequenceIndex]?.sequence[ + nucleotideIndex + ] + return nucleotide ? nucleotide : '-' + } + ) + } + ) + ) +) + +/** + * Wether a nucleotide is in a given object of a given track or not. + * @param trackIndex The index of the track in the alignment. + * @param position The position of the nucleotide to check. + * @param objectId The id of the object to check for. + * @returns `true` if position if in the given object, `false` otherwise. + */ +const isInObject = ( + trackIndex: number, + position: number, + objectId: string +): boolean => { + const object = + tracksWithConservationTrack.value[trackIndex]?.objects?.[objectId] + return !!object && _inRange(position, object.start, object.end + 1) +} + +/** + * Gets the IDs of the objects of a given track containing a nucleotide. + * @param trackIndex The index of the track in the alignment. + * @param position The position of the nucleotide for which to get object IDs. + * @returns A list of the IDs of the objects containing the nucleotide. + */ +const objectIdsContaining = ( + trackIndex: number, + position: number +): string[] => { + const trackObjects = tracksWithConservationTrack.value[trackIndex]?.objects + return trackObjects + ? Object.keys(trackObjects).filter((objectId) => + isInObject(trackIndex, position, objectId) + ) + : [] +} + +/** + * Whether a nucleotide is in any object of a given track. + * @param trackIndex The index of the track in the alignment. + * @param position The position of the nucleotide to check. + * @returns `true` if the nucleotide is in any object, `false` otherwise. + */ +const isInAnyObject = (trackIndex: number, position: number): boolean => + !!objectIdsContaining(trackIndex, position).length + +/** + * For each track, a `Set` of the boundary positions (start & end) of all + * objects. It is in the form of an array, indexed in the same order as the + * tracks in the alignment. + */ +const objectsBoundaryPositions = computed(() => + tracksWithConservationTrack.value.map((track) => { + // Objects present on the current track + const trackObjects = + track.isInformation && track.objects + ? Object.values(track.objects) + : referenceTrackModifications.value.filter( + (modification) => modification.trackId === track.id + ) + return trackObjects.reduce( + (objectsBoundaryPositions, object) => + object.start && object.end + ? objectsBoundaryPositions.add(object.start).add(object.end) + : objectsBoundaryPositions, + new Set<number>() + ) + }) +) + +/** + * Whether a nucleotide is a boundary position (start or end) of an object. + * @param position The position of the nucleotide to check. + * @returns `true` if the nucleotide is the start of an object, `false` otherwise. + */ +const isObjectBoundary = (trackIndex: number, position: number): boolean => + !!objectsBoundaryPositions.value[trackIndex]?.has(position) + +/** + * Composes the Tailwind color name in which to paint a nucleotide based on its + * position. + * @param trackIndex The index of the track in the alignment. + * @param position The position for which to compose the color. + * @returns `slate` if there is no or several objects at the given position, the + * Tailwind color name of the object if there is only one. + */ +const composeInObjectPositionColor = ( + trackIndex: number, + position: number +): TailwindDefaultColorNameModel => { + const objectIds = objectIdsContaining(trackIndex, position) + // If only one group, use its color, otherwise, use 'slate' + return objectIds.length === 1 && objectIds[0] + ? tracksWithConservationTrack.value[trackIndex]?.objects?.[objectIds[0]] + ?.color || 'slate' + : 'slate' +} + +/** + * Sequence container DOM `HTMLElement`. + */ +const sequenceContainerElement = ref<HTMLElement>() +/** + * Split sequences groups DOM `HTMLElement` array. + */ +const sequenceGroupElements = ref<HTMLElement[]>([]) +/** + * First & last nucleotides of each box DOM `HTMLElement` array. + */ +const boxBoundaryElements = ref<HTMLElement[]>([]) + +/** + * Bounding box of the first split sequences group DOM `HTMLElement`. + */ +const firstSequenceGroupElementBounding = useElementBounding( + toRef(() => sequenceGroupElements.value[0]) +) + +/** + * Bounding box of the split sequences groups DOM `HTMLElements`. + */ +const sequenceContainerElementBounding = useElementBounding( + sequenceContainerElement +) + +/** + * Number of sequence lines. + */ +const lineCount = computed(() => { + const firstSequenceGroupElement = sequenceGroupElements.value[0] + return ( + (firstSequenceGroupElement && + Math.round( + sequenceContainerElementBounding.height.value / + (parseInt( + window.getComputedStyle(firstSequenceGroupElement).marginTop + ) + + firstSequenceGroupElementBounding.height.value) + )) || + 1 + ) +}) + +/** + * Height of a sequence line. + */ +const lineHeight = computed( + () => + firstSequenceGroupElementBounding.bottom.value - + sequenceContainerElementBounding.top.value +) + +/** + * Dictionary of the number of line on which each object spreads, + * by group ID. + */ +const tracksObjectsLineCount = ref< + ({ [groupId: string]: number | undefined } | undefined)[] +>([]) + +/** + * Updates the number of line on which each object spreads. + * @returns The updated number of line on which each object spreads. + * @description Retrieves the line count by getting the difference between the + * top of the last nucleotide of the box and the top of the first one, and + * dividing by the line height. + */ +const updateObjectsLineCount = () => + (tracksObjectsLineCount.value = tracksWithConservationTrack.value.map( + (track, trackIndex) => + _mapValues(track.objects, (trackObject) => { + const objectStartNucleotideElementParent = + boxBoundaryElements.value.find( + (element) => + element.dataset.trackAndPosition === + `${trackIndex}:${trackObject.start}` + )?.offsetParent + + const objectEndNucleotideElementParent = boxBoundaryElements.value.find( + (element) => + element.dataset.trackAndPosition === + `${trackIndex}:${trackObject.end}` + )?.offsetParent + + if ( + !( + objectStartNucleotideElementParent instanceof HTMLElement && + objectEndNucleotideElementParent instanceof HTMLElement && + objectStartNucleotideElementParent.offsetParent instanceof + HTMLElement && + objectEndNucleotideElementParent.offsetParent instanceof HTMLElement + ) + ) { + return undefined + } + + return ( + lineHeight.value && + Math.round( + (objectEndNucleotideElementParent.offsetParent.offsetTop - + objectStartNucleotideElementParent.offsetParent.offsetTop) / + lineHeight.value + ) + 1 + ) + }) + )) + +/** + * Width of the sequence container. + */ +const sequenceContainerContentWidth = ref<number>() + +/** + * Updates the width of the sequence container. + */ +const updateSequenceContainerContentWidth = + (): typeof sequenceContainerContentWidth.value => { + // First sequence group DOM element + const firstSequenceGroupElement = sequenceGroupElements.value[0] + if (!firstSequenceGroupElement) return + + // Size of the right margin of a sequence group DOM element + const sequenceGroupElementRightMargin = parseInt( + window.getComputedStyle(firstSequenceGroupElement).marginRight + ) + if (!sequenceGroupElementRightMargin) return + + // Width of a sequence group DOM element + const sequenceGroupElementWidth = + firstSequenceGroupElement.offsetWidth + sequenceGroupElementRightMargin + if (!sequenceGroupElementWidth) return + + // Width of the sequences container DOM element + const sequenceContainerElementWidth = + sequenceContainerElement.value?.offsetWidth + if (!sequenceContainerElementWidth) return + + // Sequence content width = container width - (space not actually occupied by + // sequence + 1 * right margin of a sequence group) + return (sequenceContainerContentWidth.value = + sequenceContainerElementWidth - + ((sequenceContainerElementWidth % sequenceGroupElementWidth) + + sequenceGroupElementRightMargin)) + } + +/** + * Updates all non-reactive values. + */ +const update = (): void => { + updateObjectsLineCount() + updateSequenceContainerContentWidth() +} + +/** + * Sets hook to update non-reactive values on component mount. + */ +onMounted(update) + +/** + * Sets an observer to update on container resize. + */ +useResizeObserver(sequenceContainerElement, update) + +/** + * Reactive array, for each track, the style to apply to each fragment of each + * of its objects. + */ +const boxStyles = computed(() => + // Map tracks + tracksWithConservationTrack.value.map((track, trackIndex) => { + // No object on current track + if (!track.objects) { + return {} + } + + // Map track objects to the style of each of their fragment + return _mapValues(track.objects, (trackObject, trackObjectId) => { + // DOM element of the first nucleotide of the box + const objectStartNucleotideElementParent = boxBoundaryElements.value.find( + (element) => + element.dataset.trackAndPosition === + `${trackIndex}:${trackObject.start}` + )?.offsetParent + + // DOM element of the last nucleotide of the box + const objectEndNucleotideElementParent = boxBoundaryElements.value.find( + (element) => + element.dataset.trackAndPosition === + `${trackIndex}:${trackObject.end}` + )?.offsetParent + + if ( + !( + objectStartNucleotideElementParent instanceof HTMLElement && + objectEndNucleotideElementParent instanceof HTMLElement && + objectStartNucleotideElementParent.offsetParent instanceof + HTMLElement && + objectEndNucleotideElementParent.offsetParent instanceof HTMLElement + ) + ) { + return + } + + // Absolute positions of the first and last nucleotide of the object DOM + // elements + const objectStartNucleotideElementTop = + objectStartNucleotideElementParent.offsetParent.offsetTop + + (objectStartNucleotideElementParent?.offsetTop || 0) + const objectStartNucleotideElementLeft = + objectStartNucleotideElementParent.offsetParent.offsetLeft + + (objectStartNucleotideElementParent?.offsetLeft || 0) + const objectEndNucleotideElementLeft = + objectEndNucleotideElementParent.offsetParent.offsetLeft + + (objectEndNucleotideElementParent?.offsetLeft || 0) + const objectEndNucleotideElementRight = + objectEndNucleotideElementLeft + + (objectEndNucleotideElementParent?.offsetWidth || 0) + + // Number of fragment in which the current object is divided + const boxFragmentCount = + tracksObjectsLineCount.value?.[trackIndex]?.[trackObjectId] || 0 + + // Array of length equals # of fragments, containing style for each of them + return Array.from( + { length: boxFragmentCount }, + (_, fragmentIndex): StyleValue => ({ + top: `${ + objectStartNucleotideElementTop + + fragmentIndex * (lineHeight.value || 0) + }px`, + left: + fragmentIndex === 0 + ? `calc(${objectStartNucleotideElementLeft}px - 0.125rem)` + : '0', + right: `calc(${ + (sequenceContainerElementBounding.width.value || 0) - + (fragmentIndex === boxFragmentCount - 1 + ? objectEndNucleotideElementRight + : sequenceContainerContentWidth.value || 0) + }px - 0.125rem)` + }) + ) + }) + }) +) + +/** + * Compute the position of a nucleotide in the reference of a sequence from the + * position on the same sequence gapped with `-` for alignment. + * @param alignedSequence The sequence, gapped with `-` for alignment with other + * sequences. + * @param inAlignmentPosition The position in the reference of the alignment to + * convert to the source sequence reference (starts at 1). + */ +const composePositionOnSource = ( + alignedSequence: string, + inAlignmentPosition: number +): number | undefined => + alignedSequence[inAlignmentPosition - 1] === '-' + ? undefined + : inAlignmentPosition - + (alignedSequence.slice(0, inAlignmentPosition).split('-').length - 1) + +/** + * Compute the position of a nucleotide on a sequence gapped with `-` for + * alignment from the position in the source reference of the same sequence. + * @param alignedSequence The sequence, gapped with `-` for alignment with other + * sequences. + * @param inSourcePosition The position in the source sequence reference to + * convert to the reference of the alignment (starts at 1). + * @returns The position of the nucleotide on the aligned sequence, -1 if the + * requested position exceeded the actual length of the source sequence. + */ +const composePositionOnAlignment = ( + alignedSequence: string, + inSourcePosition: number +): number => { + const firstDashPositionOnAlignment = alignedSequence.indexOf('-') + 1 + + // If the aligned sequence is shorter than the requested position, it means + // that the latter doesn't exists. + if (alignedSequence.length < inSourcePosition) { + return -1 + } + + // If the requested position is closer to the start than the first dash (or if + // there is no dash in it), then the position on the alignment is the same + // than on the source sequence. + if ( + inSourcePosition < firstDashPositionOnAlignment || + firstDashPositionOnAlignment === 0 + ) { + return inSourcePosition + } + + // Otherwise, remove the part to the first dash and compute the position on + // the remaining aligned sequence (& handle case were the position doesn't + // exists). + const positionOnRemainingAlignment = composePositionOnAlignment( + alignedSequence.slice(firstDashPositionOnAlignment), + inSourcePosition - firstDashPositionOnAlignment - 1 + ) + return positionOnRemainingAlignment === -1 + ? -1 + : positionOnRemainingAlignment + firstDashPositionOnAlignment +} + +/** + * Maximum width of the names of the tracks DOM `HTMLElements`. + */ +const trackNamesWidth = ref<number>(0) + +/** + * Width of the container element of the track names, express as a ratio to + * apply on the width of the sequence group elements (e.g., if it is 3, this + * means that the container element of the track names should be 3 times wider + * than a sequence group element). + */ +const trackNamesContainerWidthRatio = computed(() => + Math.ceil( + trackNamesWidth.value / (firstSequenceGroupElementBounding.width.value || 1) + ) +) + +/** + * Configuration for the bases conservation colouring. + */ +const basesConservationConfig = ref<BasesConservationConfigModel>({ + isEnabled: false, + thresholds: { low: 0.25, high: 0.75 } +}) + +/** + * Computes the conservation rate of the reference nucleotide in the provided + * nucl. list, and derivates from it the colour to use for the background. + * @param nucleotides The list of nucleotides on which to compute the + * conservation colour. + * @param referenceNucleotide The nucleotide for which to compute the + * conservation colour. + * @returns The colour in which to set the background according to the + * computed conservation rate. + */ +const composeConservationColour = ( + nucleotides: string[], + referenceNucleotide: string +): string => { + const conservationRate = composeConservationRate( + nucleotides, + referenceNucleotide + ) + if (conservationRate > basesConservationConfig.value.thresholds.high) { + return BASE_CONSERVATION_COLOURS.high + } + if (conservationRate > basesConservationConfig.value.thresholds.low) { + return BASE_CONSERVATION_COLOURS.middle + } + return BASE_CONSERVATION_COLOURS.low +} + +/** + * All the modifications present on the reference track sequences. + */ +const referenceTrackModifications = computed<AlignmentObjectWithTrackIdModel[]>( + () => + props.alignment.tracks + .filter((track) => track.isReference) + .map( + (track) => + track.objects && + Object.values(track.objects).map((object) => ({ + ...object, + trackId: track.id + })) + ) + .flat() + .filter(isDefined) +) + +/** + * Available options for track selection for conservation analysis. + */ +const conservationAnalysisTrackOptions = computed(() => + props.alignment.tracks + .filter((track) => track.isReference && !track.isInformation) + .map((track) => ({ + label: track.name || track.id, + id: track.id + })) +) + +/** + * A list of the types of the objects present on the sequences of the alignment. + */ +const conservationAnalysisObjectTypeOptions = computed(() => + _uniqBy( + Array.from( + props.alignment.tracks + .filter((track) => + conservationAnalysisConfig.value.trackIds.includes(track.id) + ) + .map( + (track) => + track.objects && + Object.values(track.objects).map((object) => + object.type === undefined + ? undefined + : { label: object.type, type: object.type } + ) + ) + .flat() + .filter(isDefined) + ), + 'type' + ) +) + +/** + * Configuration for the conservation analysis. + */ +const conservationAnalysisConfig = ref<ConservationAnalysisConfigModel>({ + trackIds: [], + objectTypes: [], + analysisType: null +}) + +/** + * Update non-reactive values when config changes. + */ +watch(conservationAnalysisConfig, () => { + setTimeout(update, 100) +}) + +/** + * Conservation analysis, reactively updated when analysis parameters change. + */ +const conservationAnalysis = useConservationAnalysis( + props.alignment.tracks, + conservationAnalysisConfig +) + +/** + * Wether a conservation analysis is displayed or not. + */ +const isConservationAnalysisDisplayed = computed( + () => conservationAnalysisConfig.value.analysisType !== null +) + +/** + * The value to apply to the conservation analysis track `display` CSS property. + */ +const conservationAnalysisTrackDisplay = computed(() => ({ + trackLabel: isConservationAnalysisDisplayed.value ? 'flex' : 'none', + trackSequence: isConservationAnalysisDisplayed.value ? undefined : 'none' +})) +</script> + +<template> + <SequenceAlignmentToolbar + v-model:nucleotide-groups-size-model="nucleotideGroupsSize" + v-model:bases-conservation-config-model="basesConservationConfig" + v-model:conservation-analysis-config-model="conservationAnalysisConfig" + :bases-conservation-colours="BASE_CONSERVATION_COLOURS" + :conservation-analysis-track-options="conservationAnalysisTrackOptions" + :conservation-analysis-object-type-options=" + conservationAnalysisObjectTypeOptions + " + /> + + <div + class="mt-8 grid gap-4 text-2xl leading-[normal]" + :style="{ + gridTemplateColumns: `${ + trackNamesContainerWidthRatio * nucleotideGroupsSize * 1.5 + }rem 1fr` + }" + > + <!-- Track names --> + <div> + <SequenceAlignmentTrackNames + v-model:track-names-width-model="trackNamesWidth" + :tracks="tracksWithConservationTrack" + /> + <SequenceAlignmentTrackNames + v-for="line in range(lineCount - 1)" + :key="line" + :tracks="tracksWithConservationTrack" + /> + </div> + + <div ref="sequenceContainerElement" class="relative"> + <!-- Track content --> + <span + v-for="(sequenceGroup, groupIndex) in splitAlignmentSequences" + :key="groupIndex" + ref="sequenceGroupElements" + :data-group-index="groupIndex" + class="relative z-10 mr-4 mt-8 inline-block select-none whitespace-nowrap font-mono" + :style="{ width: `${nucleotideGroupsSize * 1.5}rem` }" + > + <span class="absolute -top-4 left-0 select-none text-sm text-slate-400"> + {{ groupIndex * nucleotideGroupsSize + 1 }} + </span> + <span + v-for="(nucleotide, nucleotideIndexInGroup) in sequenceGroup" + :key="groupIndex * nucleotideGroupsSize + nucleotideIndexInGroup" + :data-nucleotide-index=" + groupIndex * nucleotideGroupsSize + nucleotideIndexInGroup + " + class="inline-flex flex-col" + > + <span + v-for="(trackNucleotide, trackIndex) in nucleotide" + :key="`${trackIndex}:${ + groupIndex * nucleotideGroupsSize + nucleotideIndexInGroup + }`" + v-tooltip.top="{ + value: + composePositionOnSource( + alignment.tracks[trackIndex]?.sequence || '', + groupIndex * nucleotideGroupsSize + nucleotideIndexInGroup + 1 + )?.toString() || '<em>N/A</em>', + pt: { + text: { + style: { textAlign: 'center', fontWeight: '600' } + } + }, + escape: true, + autoHide: false + }" + :class="[ + 'relative mx-[.0625rem] cursor-help border-2 border-transparent px-0.5 pt-0.5', + { + 'rounded-t-md': trackIndex === 0, + 'rounded-b-md': + trackIndex === props.alignment.tracks.length - 1, + 'conservation-track-sequence': + tracksWithConservationTrack[trackIndex]?.isInformation + }, + isInAnyObject( + trackIndex, + groupIndex * nucleotideGroupsSize + nucleotideIndexInGroup + 1 + ) && [ + `text-${composeInObjectPositionColor( + trackIndex, + groupIndex * nucleotideGroupsSize + nucleotideIndexInGroup + 1 + )}-600`, + 'modification font-semibold' + ] + ]" + :style=" + (basesConservationConfig.isEnabled && + trackNucleotide !== '-' && + !tracksWithConservationTrack[trackIndex]?.isInformation && { + backgroundColor: composeConservationColour( + nucleotide, + trackNucleotide + ) + }) || + undefined + " + > + <span + v-if=" + isObjectBoundary( + trackIndex, + groupIndex * nucleotideGroupsSize + nucleotideIndexInGroup + 1 + ) + " + ref="boxBoundaryElements" + :data-track-and-position="`${trackIndex}:${ + groupIndex * nucleotideGroupsSize + nucleotideIndexInGroup + 1 + }`" + > + {{ trackNucleotide }} + </span> + <span v-else> + {{ trackNucleotide }} + </span> + </span> + </span> + </span> + + <!-- Object boxes --> + <span + v-for="(track, trackIndex) in tracksWithConservationTrack" + :key="track.id" + > + <span + v-for="[objectId, object] in Object.entries(track.objects || {})" + :key="objectId" + class="object" + > + <span + v-for="fragmentIndex in range( + tracksObjectsLineCount?.[trackIndex]?.[objectId] || 0 + )" + :key="fragmentIndex" + :style="boxStyles?.[trackIndex]?.[objectId]?.[fragmentIndex]" + :class="[ + 'object-fragment absolute z-10 h-8 border-y-2 bg-opacity-50 px-0.5 text-2xl mix-blend-multiply', + [`!border-${object.color}-600`, `bg-${object.color}-100`], + { + 'rounded-l-xl border-l-2': fragmentIndex === 0, + 'rounded-r-xl border-r-2': + tracksObjectsLineCount && + fragmentIndex + 1 === + tracksObjectsLineCount[trackIndex]?.[objectId] + } + ]" + /> + </span> + </span> + </div> + </div> +</template> + +<style lang="scss"> +.conservation-track-label { + display: v-bind('conservationAnalysisTrackDisplay.trackLabel'); +} + +.conservation-track-sequence { + display: v-bind('conservationAnalysisTrackDisplay.trackSequence'); +} + +.track-name-arrow { + &::before { + // Custom dashed border + border-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAACCAYAAABllJ3tAAAATUlEQVQIHQFCAL3/AZSjuP8AAAAAAAAAAAAAAABsXUgBAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA7aYEBI+Q3/YAAAAASUVORK5CYII=') + 2 0 0 / 1 1 0 repeat; + } + &::after { + background: rgb(255, 255, 255); + background: linear-gradient( + 45deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0) 50%, + rgba(255, 255, 255, 1) 50%, + rgba(255, 255, 255, 1) 100% + ); + } +} +</style> diff --git a/src/components/SequenceAlignmentToolbar.vue b/src/components/SequenceAlignmentToolbar.vue new file mode 100644 index 0000000000000000000000000000000000000000..25228b1219f853b06cb879481caf75dad8520934 --- /dev/null +++ b/src/components/SequenceAlignmentToolbar.vue @@ -0,0 +1,586 @@ +<script setup lang="ts"> +/** + * Vue imports + */ +import { ref, watch } from 'vue' +/** + * Component imports + */ +import BaseRenderedMarkdown from './BaseRenderedMarkdown.vue' +import Toolbar from 'primevue/toolbar' +import Dropdown from 'primevue/dropdown' +import TabView from 'primevue/tabview' +import TabPanel from 'primevue/tabpanel' +import Slider, { type SliderSlideEndEvent } from 'primevue/slider' +import MultiSelect from 'primevue/multiselect' +import SelectButton from 'primevue/selectbutton' +import ToggleButton from 'primevue/togglebutton' +import IconFa6RegularCircleQuestion from '~icons/fa6-regular/circle-question' + +/** + * Types imports. + */ +import type { HexColorCodeModel } from '@/typings/styleTypes' +import { + ConservationAnalysisTypesEnum, + type ConservationAnalysisConfigModel +} from '@/composables/useConservationAnalysis' + +/** + * Configuration for the bases conservation colouring. + */ +export interface BasesConservationConfigModel { + /** Wether the bases conservation colouring is enabled. */ + isEnabled: boolean + /** Conservation rate thresholds for bases conservation colouring. */ + thresholds: { + low: number + high: number + } +} + +/** + * Available options for conservation analysis type. + */ +const CONSERVATION_ANALYSIS_TYPES_OPTIONS = [ + { + label: 'Specific', + analysisType: ConservationAnalysisTypesEnum.Specific + }, + { + label: 'Common', + analysisType: ConservationAnalysisTypesEnum.Common + } +] + +/** + * Component props. + */ +const props = withDefaults( + defineProps<{ + /** The selected nucleotide group size. */ + nucleotideGroupsSizeModel: number + /** The config for the bases conservation colouring. */ + basesConservationConfigModel: BasesConservationConfigModel + /** Colours to use for bases conservation colouring */ + basesConservationColours?: { + low: HexColorCodeModel + middle: HexColorCodeModel + high: HexColorCodeModel + } + /** The config for the conservation analysis. */ + conservationAnalysisConfigModel: ConservationAnalysisConfigModel + /** Available options for track selection for conservation analysis. */ + conservationAnalysisTrackOptions: { label: string; id: string }[] + /** Available options for selection of the types of the objects present on + * the sequences for conservation analysis. */ + conservationAnalysisObjectTypeOptions: { label?: string; type: string }[] + }>(), + { + basesConservationColours: () => ({ + low: '#F87171' as HexColorCodeModel, + middle: '#FCD34D' as HexColorCodeModel, + high: '#D9EDC1' as HexColorCodeModel + }) + } +) + +const emit = defineEmits<{ + /** Event used to update the `v-model:nucleotideGroupsSizeModel` value. */ + 'update:nucleotideGroupsSizeModel': [nucleotideGroupsSizeModel: number] + /** Event used to update the `v-model:basesConservationConfigModel` value. */ + 'update:basesConservationConfigModel': [ + basesConservationConfigModel: BasesConservationConfigModel + ] + /** Event used to update the `v-model:conservationAnalysisConfigModel` value. */ + 'update:conservationAnalysisConfigModel': [ + conservationAnalysisConfigModel: ConservationAnalysisConfigModel + ] +}>() + +/** + * The currently active tab. + */ +const activeTabIndex = ref(0) + +/** + * Clears the conservation analysis type if no track or modification type is + * selected anymore for conservation analysis. + */ +const clearAnalysisTypeIfNeeded = () => { + if ( + props.conservationAnalysisConfigModel.objectTypes.length === 0 || + props.conservationAnalysisConfigModel.trackIds.length === 0 + ) { + emit('update:conservationAnalysisConfigModel', { + ...props.conservationAnalysisConfigModel, + analysisType: null + }) + } +} + +/** + * Callbacks to use for events in template. + */ +const eventHandlers = { + /** Callbacks for values related to bases conservation colouring. */ + basesConservation: { + /** Callback for the toggling of bases conservation colouring. */ + isEnabled: (newValue: boolean) => + emit('update:basesConservationConfigModel', { + ...props.basesConservationConfigModel, + isEnabled: newValue + }), + /** Callback for bases conservation colouring low threshold update by slider. */ + lowThreshold: (slideEndEvent: SliderSlideEndEvent) => { + emit('update:basesConservationConfigModel', { + ...props.basesConservationConfigModel, + thresholds: { + ...props.basesConservationConfigModel.thresholds, + low: slideEndEvent.value + } + }) + }, + /** Callback for bases conservation colouring high threshold update by slider. */ + highThreshold: (slideEndEvent: SliderSlideEndEvent) => { + emit('update:basesConservationConfigModel', { + ...props.basesConservationConfigModel, + thresholds: { + ...props.basesConservationConfigModel.thresholds, + high: slideEndEvent.value + } + }) + } + }, + /** Callbacks for values related to conservation analysis. */ + conservationAnalysis: { + /** Callback for track IDs selection update in Multiselect. */ + trackIds: (newValue: string[]) => { + clearAnalysisTypeIfNeeded() + emit('update:conservationAnalysisConfigModel', { + ...props.conservationAnalysisConfigModel, + trackIds: newValue + }) + }, + /** Callback for object types selection update in Multiselect. */ + objectTypes: (newValue: string[]) => { + clearAnalysisTypeIfNeeded() + emit('update:conservationAnalysisConfigModel', { + ...props.conservationAnalysisConfigModel, + objectTypes: newValue + }) + }, + /** Callback for object types selection update in Multiselect. */ + analysisType: (newValue: ConservationAnalysisTypesEnum) => { + emit('update:conservationAnalysisConfigModel', { + ...props.conservationAnalysisConfigModel, + analysisType: newValue + }) + } + } +} + +/** + * Local values of the thresholds for bases conservation colouring, to use as + * `v-model` for the sliders. + * + * @description We use this instead of + * {@link props.basesConservationConfigModel.thresholds} directly because + * sliders update their value immediately, but we only wan't to update the + * actual thresholds when mouse is released (on `slideend` event). + * Thus, those intermediary values allow displaying the new threshold in real + * time, but only updating the actual value when the mouse is released. + */ +const localBasesConservationThresholds = ref({ + ...props.basesConservationConfigModel.thresholds +}) + +/** + * Watcher to update the local value of the thresholds for bases conservation + * colouring when actual thresholds are updated using `v-model`. + */ +watch( + () => props.basesConservationConfigModel.thresholds, + () => + (localBasesConservationThresholds.value = { + ...props.basesConservationConfigModel.thresholds + }) +) +</script> + +<template> + <Toolbar + :pt="{ + start: { + style: { + gap: '2rem', + alignItems: 'stretch' + } + } + }" + class="mx-auto w-5/6" + > + <template #start> + <label class="flex flex-col gap-2 text-sm"> + Group length + <div class="flex grow flex-col justify-center"> + <Dropdown + :model-value="nucleotideGroupsSizeModel" + :options="[5, 10, 20]" + class="w-52" + @update:model-value=" + (newValue: number) => $emit('update:nucleotideGroupsSizeModel', newValue) + " + > + <template #option="{ option }">{{ option }} nucleotides</template> + <template #value="{ value }">{{ value }} nucleotides</template> + </Dropdown> + </div> + </label> + + <!-- <div class="self-stretch rounded border border-slate-200" /> --> + </template> + + <template #center> + <TabView + v-model:active-index="activeTabIndex" + :pt="{ + nav: { + style: { + background: 'none', + justifyContent: 'center' + } + }, + panelContainer: { + style: { + background: 'none' + } + } + }" + > + <TabPanel + header="Bases conservation" + :pt="{ + headerAction: { + style: { + background: 'none' + } + } + }" + > + <div class="flex flex-wrap gap-8"> + <div class="flex flex-col gap-2"> + <label + for="sequence-conservation-colouring-switch" + class="text-sm" + > + Conservation colouring + </label> + <div class="flex grow flex-col justify-center"> + <ToggleButton + :model-value="basesConservationConfigModel.isEnabled" + on-label="On" + off-label="Off" + class="w-full" + input-id="sequence-conservation-colouring-switch" + @update:model-value=" + eventHandlers.basesConservation.isEnabled + " + /> + </div> + </div> + + <label class="flex flex-col gap-2 text-sm"> + Conservation thresholds + <div class="flex grow flex-col justify-center"> + <div + v-tooltip.bottom=" + !basesConservationConfigModel.isEnabled && { + value: + 'To set the thresholds, first activate the colouring.', + autoHide: false, + pt: { text: 'text-xs text-center' } + } + " + class="flex gap-4 rounded-md border border-zinc-300 bg-white p-2 shadow-sm" + > + <div + :class="{ + 'text-slate-400': !basesConservationConfigModel.isEnabled + }" + > + <label + id="low-conservation-threshold-label" + class="font-thin italic" + > + Little conserved + <span + v-tooltip.bottom=" + basesConservationConfigModel.isEnabled && { + value: + 'The conservation rate under which to color the nucleotides with the \'little conserved\' color.', + autoHide: false, + pt: { text: 'text-xs text-center' } + } + " + > + <icon-fa6-regular-circle-question + class="mb-0.5 ml-1 inline" + /> + </span> + </label> + <Slider + v-model="localBasesConservationThresholds.low" + :pt="{ + root: { + style: { + backgroundColor: basesConservationColours.middle + } + }, + range: { + style: { + backgroundColor: basesConservationColours.low + } + } + }" + :disabled="!basesConservationConfigModel.isEnabled" + class="my-2 w-full" + :step="0.05" + :min="0" + :max="0.5" + aria-labelledby="low-conservation-threshold-label" + @slideend="eventHandlers.basesConservation.lowThreshold" + /> + <div class="w-full text-center font-mono"> + {{ + Math.round(localBasesConservationThresholds.low * 100) + }}% + </div> + </div> + <div + :class="{ + 'text-slate-400': !basesConservationConfigModel.isEnabled + }" + > + <label + id="high-conservation-threshold-label" + class="font-thin italic" + > + Highly conserved + <span + v-tooltip.bottom=" + basesConservationConfigModel.isEnabled && { + value: + 'The conservation rate above which to color the nucleotides with the \'highly conserved\' color.', + autoHide: false, + pt: { text: 'text-xs text-center' } + } + " + > + <icon-fa6-regular-circle-question + class="mb-0.5 ml-1 inline" + /> + </span> + </label> + <Slider + v-model="localBasesConservationThresholds.high" + :pt="{ + root: { + style: { + backgroundColor: basesConservationColours.high + } + }, + range: { + style: { + backgroundColor: basesConservationColours.middle + } + } + }" + :disabled="!basesConservationConfigModel.isEnabled" + class="my-2 w-full" + :step="0.05" + :min="0.5" + :max="1" + aria-labelledby="high-conservation-threshold-label" + @slideend="eventHandlers.basesConservation.highThreshold" + /> + <div class="w-full text-center font-mono"> + {{ + Math.round(localBasesConservationThresholds.high * 100) + }}% + </div> + </div> + </div> + </div> + </label> + </div> + </TabPanel> + + <TabPanel + header="Modifications conservation" + :pt="{ + headerAction: { + style: { + background: 'none' + } + } + }" + > + <div class="flex flex-wrap gap-8"> + <div class="flex flex-col gap-2"> + <label for="track-selection" class="text-sm"> Tracks </label> + <div class="flex grow flex-col justify-center"> + <MultiSelect + :model-value="conservationAnalysisConfigModel.trackIds" + :options="conservationAnalysisTrackOptions" + option-label="label" + option-value="id" + multiple + placeholder="Select tracks..." + input-id="track-selection" + @update:model-value=" + eventHandlers.conservationAnalysis.trackIds + " + /> + </div> + </div> + <div class="flex flex-col gap-2"> + <label for="object-types-selection" class="text-sm"> + Modification type + </label> + <div class="flex grow flex-col justify-center"> + <MultiSelect + :model-value="conservationAnalysisConfigModel.objectTypes" + :options="conservationAnalysisObjectTypeOptions" + option-label="label" + option-value="type" + multiple + placeholder="Select object types..." + input-id="object-types-selection" + @update:model-value=" + eventHandlers.conservationAnalysis.objectTypes + " + > + <template #option="{ option }"> + <BaseRenderedMarkdown + :stringified-markdown="option.label" + inline-content + /> + </template> + <template #value="{ value }"> + <BaseRenderedMarkdown + v-if="value && value.length" + :stringified-markdown="value.join(', ')" + inline-content + /> + </template> + </MultiSelect> + </div> + </div> + + <div class="flex flex-col gap-2"> + <label for="analysis-type-selection" class="text-sm"> + Analysis type + <span + v-tooltip.bottom="{ + value: `• Specific: highlights positions modified on the selected sequences only (all of them). + + • Common: highlights positions modified on the selected sequences at least (all of them).`, + autoHide: false, + pt: { text: 'text-xs w-[40ch]' } + }" + > + <icon-fa6-regular-circle-question + class="mb-0.5 ml-1 inline" + /> + </span> + </label> + <div + v-tooltip.bottom=" + !( + conservationAnalysisConfigModel.objectTypes.length && + props.conservationAnalysisConfigModel.objectTypes.length + ) && { + value: + 'First select some sequences and modification types to chose a conservation analysis type.', + autoHide: false, + pt: { text: 'text-xs' } + } + " + class="flex grow flex-col justify-center" + > + <SelectButton + :model-value="conservationAnalysisConfigModel.analysisType" + :options="CONSERVATION_ANALYSIS_TYPES_OPTIONS" + option-label="label" + option-value="analysisType" + :disabled=" + !( + conservationAnalysisConfigModel.objectTypes.length && + props.conservationAnalysisConfigModel.objectTypes.length + ) + " + input-id="analysis-type-selection" + @update:model-value=" + eventHandlers.conservationAnalysis.analysisType + " + /> + </div> + </div> + </div> + </TabPanel> + </TabView> + </template> + + <!-- <template v-if="legendItems" #center> + <BaseLegendButtonOverlay button-text="Legend" :items="legendItems"> + <template #item="{ item }"> + <slot name="legend-item" :item="item" /> + </template> + <template #item-icon="{ item }"> + <slot name="legend-item-icon" :item="item" /> + </template> + <template #item-title="{ item }"> + <slot name="legend-item-title" :item="item" /> + </template> + <template #item-description="{ item }"> + <slot name="legend-item-description" :item="item" /> + </template> + </BaseLegendButtonOverlay> + </template> --> + + <!-- <template #end> + <Button + outlined + severity="secondary" + :class="[ + clipboardState === ClipboardStateModel.PostCopy && + '!bg-lime-100 !text-lime-700', + 'flex min-w-[18ch] justify-center' + ]" + :loading="clipboardState === ClipboardStateModel.Busy" + @click="sequenceToClipboard" + > + <span + v-if="clipboardState === ClipboardStateModel.Busy" + class="mr-2 h-5 overflow-hidden text-xl" + > + <icon-fa6-solid-dna class="animate-[dnaSpin_.5s_linear_infinite]" /> + <icon-fa6-solid-dna class="animate-[dnaSpin_.5s_linear_infinite]" /> + </span> + <icon-fa6-solid-circle-check + v-else-if="clipboardState === ClipboardStateModel.PostCopy" + class="mr-2 text-xl" + /> + <icon-fa6-regular-copy v-else class="mr-2 text-xl" /> + {{ + clipboardState === ClipboardStateModel.Busy + ? 'Copying...' + : clipboardState === ClipboardStateModel.PostCopy + ? 'Copied !' + : 'Copy sequence' + }} + </Button> + </template> --> + </Toolbar> +</template> diff --git a/src/components/SequenceAlignmentTrackNames.vue b/src/components/SequenceAlignmentTrackNames.vue new file mode 100644 index 0000000000000000000000000000000000000000..e093b10f650a7e6ac1c661d8f9c25fc2fc31ec8e --- /dev/null +++ b/src/components/SequenceAlignmentTrackNames.vue @@ -0,0 +1,123 @@ +<script setup lang="ts"> +/** + * Vue imports + */ +import { computed, ref, watchEffect } from 'vue' +/** + * Composables imports. + */ +import { useElementSize } from '@vueuse/core' +/** + * Types imports. + */ +import type { AlignmentTrackModel } from './SequenceAlignment.vue' + +const props = defineProps<{ + /** The tracks of which to display the names. */ + tracks: AlignmentTrackModel[] + /** The width of the track names, based on the longest name width. */ + trackNamesWidthModel?: number +}>() + +const emit = defineEmits<{ + /** Event used to update the `v-model:trackNamesWidthModel` value. */ + 'update:trackNamesWidthModel': [trackNamesWidthModel: number] +}>() + +/** + * Names of the tracks DOM `HTMLElement` array. + */ +const trackNameElements = ref<HTMLElement[]>([]) + +/** + * Sizes of the names of the tracks DOM `HTMLElements`. + */ +const trackNameElementSizes = computed(() => + trackNameElements.value.map((trackNameElement) => { + return useElementSize(trackNameElement) + }) +) + +/** + * Watcher to compute & rise the width of the track names to parent. + */ +watchEffect(() => { + emit( + 'update:trackNamesWidthModel', + trackNameElementSizes.value.reduce( + // Get the maximum amongst the track name element's widths. + (maxWidth, trackNameElementSize, trackIndex) => { + const trackNameElementWidth = + trackNameElementSize.width.value + + (props.tracks[trackIndex]?.isReference ? 50 : 0) + + 15 + return trackNameElementWidth > maxWidth + ? trackNameElementWidth + : maxWidth + }, + 0 + ) + ) +}) +</script> + +<template> + <div + class="mt-8 inline-flex w-full flex-col whitespace-nowrap transition-transform duration-500" + > + <div + v-for="track in tracks" + :key="track.id" + :class="[ + 'flex min-h-[2.125rem] border-2 border-transparent italic text-slate-400', + { + 'conservation-track-label justify-end': track.isInformation + } + ]" + > + <component + :is="track.isReference ? 'RouterLink' : 'span'" + ref="trackNameElements" + v-tooltip="{ + value: track.name, + pt: { + root: 'ml-2', + text: { + style: { textAlign: 'center', fontWeight: '600' } + } + } + }" + :to=" + track.isReference && { + name: 'targetDetails', + query: { id: track.id } + } + " + :class="{ + 'rounded-full border border-slate-400 px-2 text-center font-mono text-xl not-italic': + track.isInformation, + 'font-bold underline decoration-transparent decoration-dashed transition-all duration-200 hover:decoration-inherit': + track.isReference + }" + > + {{ track.shortname || track.id }} + </component> + + <span + v-if="track.isReference" + v-tooltip="{ + value: 'This sequence is a reference sequence.', + pt: { text: 'text-xs' } + }" + class="small-caps my-auto ml-3 rounded-lg border-2 border-solid border-slate-400 bg-slate-100 px-1.5 text-sm font-bold text-slate-400" + > + Ref + </span> + + <div + v-if="!track.isInformation" + class="track-name-arrow relative grow before:absolute before:left-2 before:right-0 before:top-1/2 before:-translate-y-[1px] before:border-t-2 after:absolute after:right-0 after:top-1/2 after:h-2 after:w-2 after:-translate-y-1/2 after:rotate-45 after:border-r-2 after:border-t-2 after:border-slate-400" + /> + </div> + </div> +</template> diff --git a/src/components/SequenceBoard.vue b/src/components/SequenceBoard.vue index 3c012016b7eebb36029ad42baf89822feb93a54a..df6849ba99e62b4483eefad43b90731968f006c8 100644 --- a/src/components/SequenceBoard.vue +++ b/src/components/SequenceBoard.vue @@ -16,7 +16,7 @@ import IconFa6SolidCircleCheck from '~icons/fa6-solid/circle-check' import IconFa6RegularCopy from '~icons/fa6-regular/copy' import IconFa6SolidCircleInfo from '~icons/fa6-solid/circle-info' /** - * Other 3rd-party imports + * Composables imports */ import { useElementBounding, @@ -24,13 +24,17 @@ import { useMutationObserver, useResizeObserver } from '@vueuse/core' -import { inRange as _inRange } from 'lodash-es' +/** + * Other 3rd-party imports + */ +import { inRange as _inRange, mapValues as _mapValues } from 'lodash-es' /** * Types imports */ import type { TailwindDefaultColorNameModel } from '@/typings/styleTypes' import type { LegendItemModel } from '@/components/BaseLegendButtonOverlay.vue' import type { RouteLocationRaw } from 'vue-router' +import type { StyleValue } from 'vue' /** * Utils imports */ @@ -38,22 +42,22 @@ import { promisedWait } from '@/utils/promise' import { range } from '@/utils/numbers' /** - * A group of nucleotides to highlight on the sequence. + * An object to highlight on the sequence. */ -export interface highlightGroupModel { - /** First position to highlight (starts at 1). */ +export interface objectModel { + /** Start position of the object (starts at 1). */ start: number - /** Last position to highlight (included). */ + /** End position of the object (included). */ end: number - /** Color(s) to use to highlight the group. */ + /** Color(s) to use to highlight the object. */ color: TailwindDefaultColorNameModel - /** Group name. */ + /** Object name. */ name?: string - /** Group type. */ + /** Object type. */ type?: string - /** Link to go to when clicking on the group. */ + /** Link to go to when clicking on the object. */ link?: RouteLocationRaw - /** Wether to show a tooltip on hover on this group. */ + /** Wether to show a tooltip on hover on this object. */ shouldTooltip?: boolean } @@ -77,9 +81,9 @@ const props = defineProps<{ sequenceId: string /** The sequence to represent. */ sequence: string - /** Groups to highlight on the sequence. */ - highlightedGroups?: { [groupId: string]: highlightGroupModel } - /** The items of the legend to display for the highlighted groups. */ + /** Objects to highlight on the sequence. */ + objects?: { [objectId: string]: objectModel } + /** The items of the legend to display for the objects. */ legendItems?: LegendItemModel[] }>() @@ -105,12 +109,12 @@ defineSlots<{ 'legend-item-description': (props: { /** The item of which to customise the description. */ item: LegendItemModel }) => any - /** Custom tooltip group items template */ + /** Custom tooltip object 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 + /** The object of the item to customise */ + object: objectModel + /** The ID of the object of the item to customise */ + objectId: string }) => any }>() @@ -161,93 +165,92 @@ const splitSequence = computed(() => ) /** - * Wether a nucleotide is in a given highlighted group or not. + * Wether a nucleotide is in a given object or not. * @param position The position of the nucleotide to check. - * @param groupId The id of the group to check for. - * @returns `true` if position if in the given group, `false` otherwise. + * @param objectId The id of the object to check for. + * @returns `true` if position if in the given object, `false` otherwise. */ -const isInHighlightedGroup = (position: number, groupId: string): boolean => { - const highlightedGroup = props.highlightedGroups?.[groupId] +const isInObject = (position: number, objectId: string): boolean => { + const object = props.objects?.[objectId] return ( - !!highlightedGroup && - _inRange(position, highlightedGroup.start, highlightedGroup.end + 1) + !!object && + _inRange(position, object.start, object.end + 1) ) } /** - * Gets the IDs of the highlighted groups containing a nucleotide. - * @param position The position of the nucleotide for which to get group IDs. - * @returns A list of the IDs of the highlighted groups containing the nucleotide. + * Gets the IDs of the objects containing a nucleotide. + * @param position The position of the nucleotide for which to get object IDs. + * @returns A list of the IDs of the objects containing the nucleotide. */ -const highlightedGroupIdsContaining = (position: number): string[] => - props.highlightedGroups - ? Object.keys(props.highlightedGroups).filter((groupId) => - isInHighlightedGroup(position, groupId) +const objectIdsContaining = (position: number): string[] => + props.objects + ? Object.keys(props.objects).filter((objectId) => + isInObject(position, objectId) ) : [] /** - * Gets the highlighted groups containing a nucleotide. - * @param position The position of the nucleotide for which to get group. - * @returns A list of tuples, `[group ID, group]` for each of the highlighted - * groups containing the nucleotide. + * Gets the objects containing a nucleotide. + * @param position The position of the nucleotide for which to get objects. + * @returns A list of tuples, `[object ID, object]` for each of the objects + * containing the nucleotide. */ -const highlightedGroupsContaining = ( +const objectsContaining = ( position: number -): [string, highlightGroupModel][] => - props.highlightedGroups - ? Object.entries(props.highlightedGroups).filter(([groupId]) => - isInHighlightedGroup(position, groupId) +): [string, objectModel][] => + props.objects + ? Object.entries(props.objects).filter(([objectId]) => + isInObject(position, objectId) ) : [] /** - * Whether a nucleotide is in any highlighted group. + * Whether a nucleotide is in any object. * @param position The position of the nucleotide to check. - * @returns `true` if the nucleotide is in any highlighted group, `false` otherwise. + * @returns `true` if the nucleotide is in any object, `false` otherwise. */ -const isInAnyHighlightedGroup = (position: number): boolean => - !!highlightedGroupIdsContaining(position).length +const isInAnyObject = (position: number): boolean => + !!objectIdsContaining(position).length /** - * Set of all the boundary positions (start & end) of all highlighted groups on + * Set of all the boundary positions (start & end) of all objects on * the sequence. */ -const highlightedGroupsBoundaryPositions = computed( +const objectsBoundaryPositions = computed( () => - props.highlightedGroups && - Object.values(props.highlightedGroups).reduce( - (highlightedGroupsBoundaryPositions, highlightedGroup) => - highlightedGroupsBoundaryPositions - .add(highlightedGroup.start) - .add(highlightedGroup.end), + props.objects && + Object.values(props.objects).reduce( + (objectsBoundaryPositions, object) => + objectsBoundaryPositions + .add(object.start) + .add(object.end), new Set<number>() ) ) /** - * Whether a nucleotide is a boundary position (start or end) of an highlighted - * group. + * Whether a nucleotide is a boundary position (start or end) of an object. * @param position The position of the nucleotide to check. - * @returns `true` if the nucleotide is the start of an highlighted group, `false` otherwise. + * @returns `true` if the nucleotide is the start of an object, `false` otherwise. */ -const isHighlightedGroupBoundary = (position: number): boolean => - !!highlightedGroupsBoundaryPositions.value?.has(position) +const isObjectBoundary = (position: number): boolean => + !!objectsBoundaryPositions.value?.has(position) /** * Composes the Tailwind color name in which to paint a nucleotide based on its * position. * @param position The position for which to compose the color. - * @returns `slate` if there is no or several boxes at the given position, the - * Tailwind color name of the box if there is only one. + * @returns `slate` if there is no or several object at the given position, the + * Tailwind color name of the object if there is only one. */ -const composeHighlightedPositionColor = ( +const composeInObjectPositionColor = ( position: number ): TailwindDefaultColorNameModel => { - const highlightedGroupIds = highlightedGroupIdsContaining(position) - // If only one group, use its color, otherwise, use 'slate' - return highlightedGroupIds.length === 1 && highlightedGroupIds[0] - ? props.highlightedGroups?.[highlightedGroupIds[0]]?.color || 'slate' + const objectIds = objectIdsContaining(position) + // If only one object, use its color, otherwise, use 'slate' + return objectIds.length === 1 && objectIds[0] + ? props.objects?.[objectIds[0]]?.color || 'slate' : 'slate' } @@ -264,18 +267,9 @@ const sequenceContainerElement = ref<HTMLElement>() */ const sequenceGroupElements = ref<HTMLElement[]>([]) /** - * Labels of the positions of the first nucleotide of each group DOM `HTMLElement`. - * array - */ -const groupsPositionsElements = ref<HTMLElement[]>([]) -/** - * First & last nucleotides of each box DOM `HTMLElement` array. + * First & last nucleotides of each object DOM `HTMLElement` array. */ -const boxBoundaryElements = ref<HTMLElement[]>([]) -/** - * Boxes DOM `HTMLElement` array. - */ -const boxElements = ref<HTMLElement[]>([]) +const objectBoundaryElements = ref<HTMLElement[]>([]) /** * Last split sequence group DOM `HTMLElement`. @@ -342,44 +336,52 @@ const lineHeight = computed( ) /** - * Dictionary of the number of line on which each highlighted group spreads, - * by group ID. + * Dictionary of the number of line on which each object spreads, by object ID. */ -const highlightedGroupsLineCount = ref<{ [groupId: string]: number }>() +const objectsLineCount = ref<{ + [objectId: string]: number | undefined +}>() /** - * Updates the number of line on which each highlighted group spreads. - * @returns The updated number of line on which each highlighted group spreads. + * Updates the number of line on which each object spreads. + * @returns The updated number of line on which each object spreads. * @description Retrieves the line count by getting the difference between the - * top of the last nucleotide of the box and the top of the first one, and + * top of the last nucleotide of the object and the top of the first one, and * dividing by the line height. */ -const updateHighlightedGroupsLineCount = - (): typeof highlightedGroupsLineCount.value => - (highlightedGroupsLineCount.value = - props.highlightedGroups && - Object.entries(props.highlightedGroups).reduce( - (highlightedGroupsLineCount, [groupId, group]) => ({ - ...highlightedGroupsLineCount, - [groupId]: - lineHeight.value && - Math.round( - (( - boxBoundaryElements.value.find( - (element) => element.dataset.position === group.end.toString() - )?.offsetParent as HTMLElement - )?.offsetTop - - ( - boxBoundaryElements.value.find( - (element) => - element.dataset.position === group.start.toString() - )?.offsetParent as HTMLElement - )?.offsetTop) / - lineHeight.value - ) + 1 - }), - {} - )) +const updateObjectsLineCount = () => + (objectsLineCount.value = _mapValues( + props.objects, + (object) => { + // Parent DOM elements of the first and last nucleotide of the object + const objectStartNucleotideElementParent = objectBoundaryElements.value.find( + (element) => + element.dataset.position === object.start.toString() + )?.offsetParent + const objectEndNucleotideElementParent = objectBoundaryElements.value.find( + (element) => + element.dataset.position === object.end.toString() + )?.offsetParent + + if ( + !( + objectStartNucleotideElementParent instanceof HTMLElement && + objectEndNucleotideElementParent instanceof HTMLElement + ) + ) { + return undefined + } + + return ( + lineHeight.value && + Math.round( + (objectEndNucleotideElementParent.offsetTop - + objectStartNucleotideElementParent.offsetTop) / + lineHeight.value + ) + 1 + ) + } + )) /** * Width of the sequence container. @@ -426,120 +428,17 @@ const updateSequenceContainerContentWidth = } /** - * Updates all the DOM positions & sizes. + * Updates all non-reactive values. */ const update = (): void => { - updateHighlightedGroupsLineCount() + updateObjectsLineCount() updateSequenceContainerContentWidth() } /** - * Redraws the nucleotide positions of the start of each split sequence groups. - */ -const redrawGroupNumbers = (): void => { - groupsPositionsElements.value.forEach((groupNumberElement) => { - const matchingSequenceGroupElement = sequenceGroupElements.value.find( - (element) => { - return ( - element.dataset.groupIndex === groupNumberElement.dataset.groupIndex - ) - } - ) - - groupNumberElement.style.left = `${ - matchingSequenceGroupElement?.offsetLeft || 0 - }px` - groupNumberElement.style.top = `calc(${ - matchingSequenceGroupElement?.offsetTop || 0 - }px - 1.25rem)` - }) -} - -/** - * Redraws the boxes of highlighted groups. - */ -const redrawBoxes = (): void => { - boxElements.value.forEach((boxElement) => { - const matchingBoxFirstNucleotideElement = boxBoundaryElements.value.find( - (element) => element.dataset.position === boxElement.dataset.start - ) - const matchingBoxLastNucleotideElement = boxBoundaryElements.value.find( - (element) => element.dataset.position === boxElement.dataset.end - ) - - if ( - !matchingBoxFirstNucleotideElement?.offsetParent || - !matchingBoxLastNucleotideElement?.offsetParent - ) { - return - } - - const matchingBoxFirstNucleotideElementTop = - (matchingBoxFirstNucleotideElement?.offsetParent as HTMLElement) - .offsetTop + (matchingBoxFirstNucleotideElement?.offsetTop || 0) - const matchingBoxFirstNucleotideElementLeft = - (matchingBoxFirstNucleotideElement?.offsetParent as HTMLElement) - .offsetLeft + (matchingBoxFirstNucleotideElement?.offsetLeft || 0) - const matchingBoxLastNucleotideElementLeft = - (matchingBoxLastNucleotideElement?.offsetParent as HTMLElement) - .offsetLeft + (matchingBoxLastNucleotideElement?.offsetLeft || 0) - const matchingBoxLastNucleotideElementRight = - matchingBoxLastNucleotideElementLeft + - (matchingBoxLastNucleotideElement?.offsetWidth || 0) - - Array.from(boxElement.children as HTMLCollectionOf<HTMLElement>).forEach( - (boxFragmentElement, boxFragmentIndex) => { - boxFragmentElement.style.top = `${ - matchingBoxFirstNucleotideElementTop + - boxFragmentIndex * (lineHeight.value || 0) - }px` - - const boxFragmentElementLeft = - boxFragmentIndex === 0 ? matchingBoxFirstNucleotideElementLeft : 0 - - boxFragmentElement.style.left = - boxFragmentIndex === 0 - ? `calc(${boxFragmentElementLeft}px - 0.125rem)` - : '0' - - boxFragmentElement.style.width = `calc(${ - boxFragmentIndex === boxElement.children.length - 1 - ? boxFragmentIndex === 0 - ? `${ - matchingBoxLastNucleotideElementRight - - matchingBoxFirstNucleotideElementLeft - }px` - : `${matchingBoxLastNucleotideElementRight}px` - : `${ - (sequenceContainerContentWidth.value || 0) - - boxFragmentElementLeft - }px` - } + 0.25rem)` - } - ) - }) -} - -/** - * Redraws every absolutely positioned objects. - */ -const redraw = (): void => { - redrawGroupNumbers() - redrawBoxes() -} - -/** - * Updates values then redraws every absolutely positioned objects. - */ -const updateAndRedraw = (): void => { - update() - redraw() -} - -/** - * Exposes methods to manually trigger update & redraw of boxes. + * Exposes methods to manually trigger updates. */ -defineExpose({ update, redraw, updateAndRedraw }) +defineExpose({ update }) /** * Whether the sequence is visible or not. @@ -549,49 +448,156 @@ const isSequenceContainerElementVisible = useElementVisibility( ) /** - * Sets hook to update and (re)draw on component mount. + * Sets hook to update on component mount. */ -onMounted(updateAndRedraw) +onMounted(update) /** - * Sets an observer to update and redraw on container resize. + * Sets an observer to update on container resize. */ useResizeObserver(sequenceContainerElement, () => { - updateAndRedraw() + update() setTimeout(() => { - updateAndRedraw() + update() }, 100) }) /** - * Sets a watcher to update and redraw when resuming sequence visibility. + * Sets a watcher to update when resuming sequence visibility. */ watch( [isSequenceContainerElementVisible, () => props.sequenceId], ([isSequenceContainerElementVisible, newSequenceId], [, oldSequenceId]) => { if (isSequenceContainerElementVisible || newSequenceId !== oldSequenceId) { setTimeout(() => { - updateAndRedraw() + update() }, 100) } } ) /** - * The highlighted boxes currently being hovered. + * Bounding box of sequence container DOM `HTMLElement`. */ -const hoveredHighlightedGroups = ref(new Set<[string, highlightGroupModel]>()) +const sequenceContainerElementBounding = useElementBounding( + sequenceContainerElement +) /** - * The highlighted boxes currently being hovered. + * For each sequence group first position label, the bounding box of its + * reference sequence group DOM element. */ -const lockedTooltipHighlightedGroups = ref<Set<[string, highlightGroupModel]>>() +const groupNumberReferenceSequenceGroupElementsBounding = computed(() => + Array.from({ length: nucleotideGroupsCount.value }, (_, groupNumberIndex) => + useElementBounding( + sequenceGroupElements.value.find( + (sequenceGroupElement) => + sequenceGroupElement.dataset.groupIndex === + groupNumberIndex.toString() + ) + ) + ) +) + +/** + * The style to apply to the sequence group first position label DOM element. + */ +const groupNumberStyles = computed(() => + groupNumberReferenceSequenceGroupElementsBounding.value.map<StyleValue>( + (groupNumberReferenceSequenceGroupElementBounding) => ({ + left: `${ + groupNumberReferenceSequenceGroupElementBounding.left.value - + sequenceContainerElementBounding.left.value + }px`, + top: `calc(${ + groupNumberReferenceSequenceGroupElementBounding.top.value - + sequenceContainerElementBounding.top.value + }px - 1.25rem)` + }) + ) +) + +/** + * The style to apply to each fragment of each of the object boxes. + */ +const objectBoxStyles = computed(() => + // Map objects to the styles of each of their fragment + _mapValues(props.objects, (object, objectId) => { + // DOM element of the first and last nucleotides of the object + const objectStartNucleotideElement = objectBoundaryElements.value.find( + (element) => + element.dataset.position === object.start.toString() + ) + const objectEndNucleotideElement = objectBoundaryElements.value.find( + (element) => element.dataset.position === object.end.toString() + ) + + if ( + !( + objectStartNucleotideElement?.offsetParent instanceof HTMLElement && + objectEndNucleotideElement?.offsetParent instanceof HTMLElement + ) + ) { + return + } + + // Absolute positions of the DOM elements of the first and last nucleotide + // of the object + const objectStartNucleotideElementTop = + objectStartNucleotideElement.offsetParent.offsetTop + + (objectStartNucleotideElement?.offsetTop || 0) + const objectStartNucleotideElementLeft = + objectStartNucleotideElement.offsetParent.offsetLeft + + (objectStartNucleotideElement?.offsetLeft || 0) + const objectEndNucleotideElementLeft = + objectEndNucleotideElement.offsetParent.offsetLeft + + (objectEndNucleotideElement.offsetLeft || 0) + const objectEndNucleotideElementRight = + objectEndNucleotideElementLeft + + (objectEndNucleotideElement.offsetWidth || 0) + + // Number of fragment in which the current object box is divided + const objectBoxFragmentsCount = + objectsLineCount.value?.[objectId] || 0 + + // Array of length equals # of fragments, containing style for each of them + return Array.from( + { length: objectBoxFragmentsCount }, + (_, fragmentIndex): StyleValue => ({ + top: `${ + objectStartNucleotideElementTop + + fragmentIndex * (lineHeight.value || 0) + }px`, + left: + fragmentIndex === 0 + ? `calc(${objectStartNucleotideElementLeft}px - 0.125rem)` + : '0', + right: `calc(${ + (sequenceContainerElementBounding.width.value || 0) - + (fragmentIndex === objectBoxFragmentsCount - 1 + ? objectEndNucleotideElementRight + : sequenceContainerContentWidth.value || 0) + }px - 0.125rem)` + }) + ) + }) +) + +/** + * The objects currently being hovered. + */ +const hoveredObjects = ref(new Set<[string, objectModel]>()) + +/** + * The objects currently being hovered. + */ +const lockedTooltipObjects = ref<Set<[string, objectModel]>>() /** * DOM elements of objects to display in the tooltip. */ -const tooltipHighlightedGroups = computed( - () => lockedTooltipHighlightedGroups.value || hoveredHighlightedGroups.value +const tooltipObjects = computed( + () => lockedTooltipObjects.value || hoveredObjects.value ) /** @@ -603,7 +609,7 @@ const tooltipComponent = ref<InstanceType<typeof BaseLockableTooltip>>() * Locks the tooltip and the values to display in it. */ const lockTooltip = () => { - lockedTooltipHighlightedGroups.value = new Set(hoveredHighlightedGroups.value) + lockedTooltipObjects.value = new Set(hoveredObjects.value) tooltipComponent.value?.lockTooltipIfUnlocked() } </script> @@ -617,7 +623,7 @@ const lockTooltip = () => { v-model="nucleotideGroupsSize" :options="[5, 10, 20, 50, 100]" class="mt-2 w-52" - @hide="updateAndRedraw" + @hide="update" > <template #option="{ option }">{{ option }} nucleotides</template> <template #value="{ value }">{{ value }} nucleotides</template> @@ -711,59 +717,57 @@ const lockTooltip = () => { > <span v-if=" - isHighlightedGroupBoundary( + isObjectBoundary( nucleotideIndex + groupIndex * nucleotideGroupsSize + 1 ) " - ref="boxBoundaryElements" + ref="objectBoundaryElements" :data-position=" nucleotideIndex + groupIndex * nucleotideGroupsSize + 1 " :class="[ 'relative mx-[.0625rem] border-2 border-transparent px-0.5 pt-0.5', - isInAnyHighlightedGroup( - nucleotideIndex + groupIndex * nucleotideGroupsSize + 1 - ) && [ - `text-${composeHighlightedPositionColor( + [ + `text-${composeInObjectPositionColor( nucleotideIndex + groupIndex * nucleotideGroupsSize + 1 )}-600`, - 'modification font-semibold' + 'font-semibold' ] ]" > <span v-if=" - highlightedGroupsContaining( + objectsContaining( nucleotideIndex + groupIndex * nucleotideGroupsSize + 1 ).find( - ([_highlightedGroupId, highlightedGroup]) => - highlightedGroup.shouldTooltip + ([, object]) => + object.shouldTooltip ) " :class="[ 'absolute -bottom-0.5 -left-[0.1875rem] -right-[0.1875rem] -top-0.5', - highlightedGroupsContaining( + objectsContaining( nucleotideIndex + groupIndex * nucleotideGroupsSize + 1 ).find( - ([_highlightedGroupId, highlightedGroup]) => - highlightedGroup.link + ([, object]) => + object.link ) ? 'cursor-pointer' : 'cursor-help' ]" @mouseenter=" - highlightedGroupsContaining( + objectsContaining( nucleotideIndex + groupIndex * nucleotideGroupsSize + 1 ).forEach( - ([highlightedGroupId, highlightedGroup]) => - highlightedGroup.shouldTooltip && - hoveredHighlightedGroups.add([ - highlightedGroupId, - highlightedGroup + ([objectId, object]) => + object.shouldTooltip && + hoveredObjects.add([ + objectId, + object ]) ) " - @mouseleave="hoveredHighlightedGroups.clear()" + @mouseleave="hoveredObjects.clear()" @click="lockTooltip" />{{ nucleotide }} </span> @@ -771,49 +775,49 @@ const lockTooltip = () => { v-else :class="[ 'relative mx-[.0625rem] border-2 border-transparent px-0.5 pt-0.5', - isInAnyHighlightedGroup( + isInAnyObject( nucleotideIndex + groupIndex * nucleotideGroupsSize + 1 ) && [ - `text-${composeHighlightedPositionColor( + `text-${composeInObjectPositionColor( nucleotideIndex + groupIndex * nucleotideGroupsSize + 1 )}-600`, - 'modification font-semibold' + 'font-semibold' ] ]" > <span v-if=" - highlightedGroupsContaining( + objectsContaining( nucleotideIndex + groupIndex * nucleotideGroupsSize + 1 ).find( - ([_highlightedGroupId, highlightedGroup]) => - highlightedGroup.shouldTooltip + ([, object]) => + object.shouldTooltip ) " :class="[ 'absolute -bottom-0.5 -left-[0.1875rem] -right-[0.1875rem] -top-0.5', - highlightedGroupsContaining( + objectsContaining( nucleotideIndex + groupIndex * nucleotideGroupsSize + 1 ).find( - ([_highlightedGroupId, highlightedGroup]) => - highlightedGroup.link + ([, object]) => + object.link ) ? 'cursor-pointer' : 'cursor-help' ]" @mouseenter=" - highlightedGroupsContaining( + objectsContaining( nucleotideIndex + groupIndex * nucleotideGroupsSize + 1 ).forEach( - ([highlightedGroupId, highlightedGroup]) => - highlightedGroup.shouldTooltip && - hoveredHighlightedGroups.add([ - highlightedGroupId, - highlightedGroup + ([objectId, object]) => + object.shouldTooltip && + hoveredObjects.add([ + objectId, + object ]) ) " - @mouseleave="hoveredHighlightedGroups.clear()" + @mouseleave="hoveredObjects.clear()" @click="lockTooltip" />{{ nucleotide }} </span> @@ -823,86 +827,83 @@ const lockTooltip = () => { <span v-for="(_sequenceGroup, groupIndex) in splitSequence" :key="groupIndex" - ref="groupsPositionsElements" class="absolute select-none text-sm text-slate-400" - :data-group-index="groupIndex" + :style="groupNumberStyles[groupIndex]" > {{ groupIndex * nucleotideGroupsSize + 1 }} </span> <span - v-for="[highlightedGroupId, highlightedGroup] in Object.entries( - highlightedGroups || {} + v-for="[objectId, object] in Object.entries( + objects || {} )" - :key="highlightedGroupId" - ref="boxElements" - :data-start="highlightedGroup.start" - :data-end="highlightedGroup.end" - class="highlight-group" + :key="objectId" + class="object" > <span - v-for="highlightedGroupLine in range( - highlightedGroupsLineCount?.[highlightedGroupId] || 0 + v-for="fragmentIndex in range( + objectsLineCount?.[objectId] || 0 )" - :key="highlightedGroupLine" + :key="fragmentIndex" + :style="objectBoxStyles?.[objectId]?.[fragmentIndex]" :class="[ - 'highlight-group-fragment absolute h-8 border-y-2 bg-opacity-50 px-0.5 text-2xl mix-blend-multiply', + 'object-fragment absolute h-8 border-y-2 bg-opacity-50 px-0.5 text-2xl mix-blend-multiply', [ - `!border-${highlightedGroup.color}-600`, - `bg-${highlightedGroup.color}-100` + `!border-${object.color}-600`, + `bg-${object.color}-100` ], { - 'rounded-l-xl border-l-2': highlightedGroupLine === 0, + 'rounded-l-xl border-l-2': fragmentIndex === 0, 'rounded-r-xl border-r-2': - highlightedGroupsLineCount && - highlightedGroupLine + 1 === - highlightedGroupsLineCount[highlightedGroupId] + objectsLineCount && + fragmentIndex + 1 === + objectsLineCount[objectId] }, - highlightedGroup.shouldTooltip && - (highlightedGroup.link ? 'cursor-pointer' : 'cursor-help') + object.shouldTooltip && + (object.link ? 'cursor-pointer' : 'cursor-help') ]" /> </span> <BaseLockableTooltip ref="tooltipComponent" - :show="!!hoveredHighlightedGroups.size" + :show="!!hoveredObjects.size" class="z-20 max-w-min font-sans text-base shadow-xl" - @unlock="lockedTooltipHighlightedGroups = undefined" + @unlock="lockedTooltipObjects = undefined" > <ul class="flex flex-col gap-1"> <li - v-for="(highlightedGroup, index) in tooltipHighlightedGroups" + v-for="(object, index) in tooltipObjects" :key="index" > <RouterLink - v-if="highlightedGroup[1].link" - :to="highlightedGroup[1].link" + v-if="object[1].link" + :to="object[1].link" :class="[ - `text-${highlightedGroup[1].color}-600`, + `text-${object[1].color}-600`, 'whitespace-nowrap' ]" > <slot name="tooltip-item" - :group="highlightedGroup[1]" - :group-id="highlightedGroup[0]" + :object="object[1]" + :object-id="object[0]" > - {{ highlightedGroup[1].name || highlightedGroup[1].link }} + {{ object[1].name || object[1].link }} {{ - highlightedGroup[1].type && ` - ${highlightedGroup[1].type}` + object[1].type && ` - ${object[1].type}` }} </slot> </RouterLink> <span v-else> <slot name="tooltip-item" - :group="highlightedGroup[1]" - :group-id="highlightedGroup[0]" + :object="object[1]" + :object-id="object[0]" > - {{ highlightedGroup[1].name || highlightedGroup[1].link }} + {{ object[1].name || object[1].link }} {{ - highlightedGroup[1].type && ` - ${highlightedGroup[1].type}` + object[1].type && ` - ${object[1].type}` }} </slot> </span> @@ -917,10 +918,10 @@ const lockTooltip = () => { </template> <style lang="scss"> -// .sequence-parent:has(.highlight-group:hover) { +// .sequence-parent:has(.object:hover) { // // color: var(--gray-300); // -// .highlight-group-fragment { +// .object-fragment { // border-color: var(--gray-300); // background-color: var(--gray-100); // } diff --git a/src/composables/useConservationAnalysis.ts b/src/composables/useConservationAnalysis.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d64a7f38dbfdecbf097f7a004bfd5dc2febc59a --- /dev/null +++ b/src/composables/useConservationAnalysis.ts @@ -0,0 +1,226 @@ +/** + * Vue imports + */ +import { computed, toRef, type MaybeRef, type ComputedRef } from 'vue' +/** + * Other 3rd-party imports + */ +import { groupBy as _groupBy, pick as _pick } from 'lodash-es' +/** + * Types imports + */ +import type { + AlignmentObjectWithTrackIdModel, + AlignmentTrackModel +} from '@/components/SequenceAlignment.vue' +/** + * Utils imports + */ +import { isDefined, isNotEmpty } from '@/typings/typeUtils' + +/** + * A conservation analysis type. + */ +export enum ConservationAnalysisTypesEnum { + /** + * Visualise modifications present **only** on **all** the selected + * sequences + */ + Specific = 0, + /** + * Visualise modifications present **at least** on **all** the selected + * sequences + */ + Common +} + +/** + * Configuration for the conservation analysis. + */ +export interface ConservationAnalysisConfigModel { + /** The IDs of the tracks selected for conservation analysis. */ + trackIds: string[] + /** The object types selected for conservation analysis. */ + objectTypes: string[] + /** The type of conservation analysis to perform + * See {@link ConservationAnalysisTypesEnum} for the description of each mode. */ + analysisType: ConservationAnalysisTypesEnum | null +} + +/** + * Reactive conservation analysis between sequences (mainly track) + * @param tracks The tracks of the alignment on which to perform the + * conservation analysis (contains selected AND unselected tracks, to be able to + * perform the *Specific* analysis) + * @param selectedAnalysisType + */ +export const useConservationAnalysis = ( + tracks: MaybeRef<AlignmentTrackModel[]>, + config: MaybeRef<ConservationAnalysisConfigModel | null> +): { + /** The track representing the conservation analysis in the alignment. */ + track: ComputedRef<AlignmentTrackModel> +} => { + /** + * All the modifications present on the reference track sequences. + */ + const referenceTrackModifications = computed< + AlignmentObjectWithTrackIdModel[] + >(() => + toRef(tracks) + .value.filter((track) => track.isReference) + .map( + (track) => + track.objects && + Object.values(track.objects).map((object) => ({ + ...object, + trackId: track.id + })) + ) + .flat() + .filter(isDefined) + ) + + /** + * Modifications present on selected track sequences. + */ + const selectedModifications = computed(() => + referenceTrackModifications.value.filter((modification) => + toRef(config).value?.trackIds.includes(modification.trackId) + ) + ) + + /** + * Modifications present on selected track sequences, filtered according to the + * currently selected mode (`selectedConservationAnalysisType`) & modification + * type(`selectedConservationAnalysisObjectTypes`). + * + * See {@link ConservationAnalysisTypesEnum} for the description of each mode. + */ + const filteredSelectedModifications = computed(() => { + if (toRef(config).value?.analysisType == null) { + return [] + } + return ( + // Group modifications by position & type + Object.values( + _groupBy( + selectedModifications.value, + (selectedModification) => + `${selectedModification.start}-${selectedModification.end}:${selectedModification.type}` + ) + ) + // Keep only modifications when present on every track selected for + // analysis (i.e. the number of modifications on a position is the same + // as the number of selected tracks) + .filter(isNotEmpty) + .filter( + (selectedModificationsByPosition) => + selectedModificationsByPosition.length === + toRef(config).value?.trackIds.length + ) + // Keep only the modification informations common to all selected tracks + // for each modification (reduce modification arrays to a single one for + // each) + .map((selectedModificationsByPosition) => { + return _pick< + (typeof selectedModificationsByPosition)[0], + 'start' | 'end' | 'color' | 'type' | 'trackId' + >(selectedModificationsByPosition[0], [ + 'start', + 'end', + 'color', + 'type', + 'trackId' + ]) + }) + // Keep only modifications of the selected types + .filter( + (selectedModification) => + selectedModification.type && + toRef(config).value?.objectTypes.includes(selectedModification.type) + ) + // If in "specific" analysis mode, keep only modification if absent on + // every non-selected track (i.e. there is no modification with same + // positions on a non-selected track) + .filter((selectedModification) => + toRef(config).value?.analysisType === + ConservationAnalysisTypesEnum.Specific + ? !toRef(referenceTrackModifications).value.find( + (referenceTrackModification) => + // Using a cross-organism ID here would avoid matching 2 different + // modifications taking place at the same position + referenceTrackModification.start === + selectedModification?.start && + referenceTrackModification.end === + selectedModification?.end && + !toRef(config).value?.trackIds.includes( + referenceTrackModification.trackId + ) + ) + : selectedModification + ) + ) + }) + + /** + * Selected tracks for conservation analysis. + */ + const selectedTracks = computed<AlignmentTrackModel[]>( + () => + toRef(config) + .value?.trackIds.map((selectedTrackId) => + toRef(tracks).value.find((track) => track.id === selectedTrackId) + ) + .filter(isDefined) || [] + ) + + /** + * If a conservation analysis is selected, the track to display to represent the + * analysis. + */ + const conservationAnalysisTrack = computed<AlignmentTrackModel>(() => { + const objects: { + [objectId: string]: (typeof filteredSelectedModifications.value)[number] + } = {} + + const sequence = Array.from( + { + length: selectedTracks.value?.[0]?.sequence.length || 0 + }, + (_, index) => { + const matchingModification = filteredSelectedModifications.value.find( + (modification) => modification?.start === index + 1 + ) + + // No conserved modification at current position + if (!matchingModification) { + return '-' + } + + // Add modification to object list of conservation analysis track + objects[`CONS_OBJ_${index}`] = { + ...matchingModification, + trackId: 'CONSERVATION_TRACK' + } + + const modificationNucleotide = toRef(tracks).value.find( + (track) => track.id === matchingModification.trackId + )?.sequence[index] + + return modificationNucleotide || '↯' + } + ).join('') + + return { + id: 'CONSERVATION_TRACK', + name: 'Conservation analysis', + shortname: 'Conservation', + sequence, + isInformation: true, + objects + } + }) + + return { track: conservationAnalysisTrack } +} diff --git a/src/gql/codegen/gql.ts b/src/gql/codegen/gql.ts index 1cdd8fd0b5c5297451891af1c63484e3dfa739b3..6e7d7be3ef22375a79e768db653a8cd7c8a8bc56 100644 --- a/src/gql/codegen/gql.ts +++ b/src/gql/codegen/gql.ts @@ -22,6 +22,8 @@ const documents = { "\n query guideByIdQuery($id: ID) {\n guides(where: { id: $id }) {\n id\n name\n altnames\n description\n length\n class\n subclass_label\n chromosomeConnection: parentConnection(\n where: { node: { graphql_type: Chromosome } }\n ) {\n edges {\n properties {\n start\n end\n strand\n }\n node {\n id\n name\n length\n graphql_type\n }\n }\n }\n host_genes\n seq\n genome {\n organism {\n id\n label\n }\n }\n boxConnections: featuresConnection(\n where: { NOT: { node: { class: DuplexFragment } } }\n ) {\n edges {\n properties {\n ... on HasFeature {\n start\n end\n }\n }\n node {\n id\n annotation\n }\n }\n }\n modifications(options: { sort: [{ position: ASC }] }) {\n id\n name\n type\n }\n modificationsAggregate {\n count\n }\n interactions {\n duplexes {\n primaryStrandsConnection: strandsConnection(\n where: { edge: { primary: true } }\n ) {\n edges {\n properties {\n start\n end\n primary\n }\n node {\n seq\n parentConnection {\n edges {\n properties {\n start\n end\n }\n }\n }\n }\n }\n }\n secondaryStrandsConnection: strandsConnection(\n where: { edge: { primary: false } }\n ) {\n edges {\n properties {\n start\n end\n primary\n }\n node {\n seq\n parentConnection {\n edges {\n properties {\n start\n end\n }\n }\n }\n }\n }\n }\n index\n }\n modification {\n id\n name\n position\n symbol\n symbol_label\n type\n type_short_label\n }\n target {\n id\n name\n class\n unit\n }\n }\n cluster {\n id\n }\n clusterAggregate {\n count\n }\n isoform {\n guides {\n id\n name\n }\n guidesAggregate {\n count\n }\n }\n isoformAggregate {\n count\n }\n chebi_id\n so_id\n }\n\n targets(\n where: { modifications_SOME: { guides_SOME: { id: $id } } }\n options: { sort: [{ name: ASC }] }\n ) {\n id\n name\n class\n unit\n }\n }\n": types.GuideByIdQueryDocument, "\n query targetByIdQuery($id: ID) {\n targets(where: { id: $id }) {\n id\n name\n altnames\n description\n length\n class\n unit\n chromosomeConnection: parentConnection(\n where: { node: { graphql_type: Chromosome } }\n ) {\n edges {\n properties {\n start\n end\n strand\n }\n node {\n name\n graphql_type\n }\n }\n }\n seq\n genome {\n organism {\n id\n label\n }\n }\n modifications(options: { sort: [{ position: ASC }] }) {\n id\n name\n position\n type\n symbol\n }\n modificationsAggregate {\n count\n }\n interactions {\n duplexes {\n primaryStrandsConnection: strandsConnection(\n where: { edge: { primary: true } }\n ) {\n edges {\n properties {\n start\n end\n primary\n }\n node {\n seq\n parentConnection {\n edges {\n properties {\n start\n end\n }\n }\n }\n }\n }\n }\n secondaryStrandsConnection: strandsConnection(\n where: { edge: { primary: false } }\n ) {\n edges {\n properties {\n start\n end\n primary\n }\n node {\n seq\n parentConnection {\n edges {\n properties {\n start\n end\n }\n }\n }\n }\n }\n }\n index\n }\n modification {\n id\n name\n position\n symbol\n symbol_label\n type\n type_short_label\n }\n guide {\n id\n name\n subclass_label\n class\n }\n }\n chebi_id\n so_id\n url\n secondary_struct_file\n }\n\n guides(\n where: { modifications_SOME: { target: { id: $id } } }\n options: { sort: [{ id: ASC }] }\n ) {\n id\n name\n class\n chromosome: parent(where: { graphql_type: Chromosome }) {\n name\n }\n }\n }\n": types.TargetByIdQueryDocument, "\n query clusterByIdQuery($id: ID) {\n clusters(where: { id: $id }) {\n id\n guides(options: { sort: [{ id: ASC }] }) {\n id\n name\n class\n subclass_label\n modificationsAggregate {\n count\n }\n chromosomeConnection: parentConnection(\n where: { node: { graphql_type: Chromosome } }\n ) {\n edges {\n properties {\n start\n end\n }\n }\n }\n }\n guidesAggregate {\n count\n }\n referenceGuide: guides(options: { limit: 1 }) {\n chromosome: parent(where: { graphql_type: Chromosome }) {\n id\n name\n }\n genome {\n organism {\n label\n id\n }\n }\n }\n }\n }\n": types.ClusterByIdQueryDocument, + "\n query targetAlignmentQuery($targetName: String, $organismIds: [Int!]) {\n targetBase: targets {\n name\n unit\n genome {\n organism {\n id\n label\n }\n }\n }\n\n organismsBase: organisms {\n label\n id\n }\n\n organisms(\n where: {\n tableEntries_SOME: { modification: { target: { name: $targetName } } }\n }\n ) {\n id\n }\n\n selectableTargets: targets(\n where: {\n name: $targetName\n genome: { organism: { id_IN: $organismIds } }\n }\n ) {\n id\n genome {\n organism {\n shortlabel\n }\n }\n }\n }\n": types.TargetAlignmentQueryDocument, + "\n query guideAlignmentQuery(\n $guideSubclasses: [GuideClass!]\n $guideName: String\n $organismIds: [Int!]\n ) {\n guideBase: guides {\n name\n subclass\n subclass_label\n genome {\n organism {\n id\n }\n }\n }\n\n guideNamesFilteredBySubclass: guides(\n where: { subclass_IN: $guideSubclasses }\n ) {\n name\n genome {\n organism {\n id\n }\n }\n }\n\n organismsBase: organisms {\n label\n id\n }\n\n organisms(where: { tableEntries_SOME: { guide: { name: $guideName } } }) {\n id\n }\n\n selectableGuides: guides(\n where: { name: $guideName, genome: { organism: { id_IN: $organismIds } } }\n ) {\n id\n genome {\n organism {\n shortlabel\n }\n }\n }\n }\n": types.GuideAlignmentQueryDocument, "\n query databaseStatisticsQuery {\n organismsAggregate {\n count\n }\n\n allModifications: modifications {\n type\n }\n\n allModificationsCount: modificationsAggregate {\n count\n }\n\n nonOrphanModificationsCount: modificationsAggregate(\n where: { guidesAggregate: { count_GT: 0 } }\n ) {\n count\n }\n\n allGuides: guides {\n subclass\n }\n\n allGuidesCount: guidesAggregate {\n count\n }\n\n nonOrphanGuidesCount: guidesAggregate(\n where: { modificationsAggregate: { count_GT: 0 } }\n ) {\n count\n }\n }\n": types.DatabaseStatisticsQueryDocument, "\n query legalDocumentListQuery {\n documents(where: { types_INCLUDES: \"legal\" }) {\n id\n menu_label\n }\n }\n": types.LegalDocumentListQueryDocument, "\n query documentByIdQuery($id: ID) {\n documents(where: { id: $id }) {\n id\n menu_label\n content\n }\n }\n": types.DocumentByIdQueryDocument, @@ -77,6 +79,14 @@ export function graphql(source: "\n query targetByIdQuery($id: ID) {\n targe * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n query clusterByIdQuery($id: ID) {\n clusters(where: { id: $id }) {\n id\n guides(options: { sort: [{ id: ASC }] }) {\n id\n name\n class\n subclass_label\n modificationsAggregate {\n count\n }\n chromosomeConnection: parentConnection(\n where: { node: { graphql_type: Chromosome } }\n ) {\n edges {\n properties {\n start\n end\n }\n }\n }\n }\n guidesAggregate {\n count\n }\n referenceGuide: guides(options: { limit: 1 }) {\n chromosome: parent(where: { graphql_type: Chromosome }) {\n id\n name\n }\n genome {\n organism {\n label\n id\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query clusterByIdQuery($id: ID) {\n clusters(where: { id: $id }) {\n id\n guides(options: { sort: [{ id: ASC }] }) {\n id\n name\n class\n subclass_label\n modificationsAggregate {\n count\n }\n chromosomeConnection: parentConnection(\n where: { node: { graphql_type: Chromosome } }\n ) {\n edges {\n properties {\n start\n end\n }\n }\n }\n }\n guidesAggregate {\n count\n }\n referenceGuide: guides(options: { limit: 1 }) {\n chromosome: parent(where: { graphql_type: Chromosome }) {\n id\n name\n }\n genome {\n organism {\n label\n id\n }\n }\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query targetAlignmentQuery($targetName: String, $organismIds: [Int!]) {\n targetBase: targets {\n name\n unit\n genome {\n organism {\n id\n label\n }\n }\n }\n\n organismsBase: organisms {\n label\n id\n }\n\n organisms(\n where: {\n tableEntries_SOME: { modification: { target: { name: $targetName } } }\n }\n ) {\n id\n }\n\n selectableTargets: targets(\n where: {\n name: $targetName\n genome: { organism: { id_IN: $organismIds } }\n }\n ) {\n id\n genome {\n organism {\n shortlabel\n }\n }\n }\n }\n"): (typeof documents)["\n query targetAlignmentQuery($targetName: String, $organismIds: [Int!]) {\n targetBase: targets {\n name\n unit\n genome {\n organism {\n id\n label\n }\n }\n }\n\n organismsBase: organisms {\n label\n id\n }\n\n organisms(\n where: {\n tableEntries_SOME: { modification: { target: { name: $targetName } } }\n }\n ) {\n id\n }\n\n selectableTargets: targets(\n where: {\n name: $targetName\n genome: { organism: { id_IN: $organismIds } }\n }\n ) {\n id\n genome {\n organism {\n shortlabel\n }\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query guideAlignmentQuery(\n $guideSubclasses: [GuideClass!]\n $guideName: String\n $organismIds: [Int!]\n ) {\n guideBase: guides {\n name\n subclass\n subclass_label\n genome {\n organism {\n id\n }\n }\n }\n\n guideNamesFilteredBySubclass: guides(\n where: { subclass_IN: $guideSubclasses }\n ) {\n name\n genome {\n organism {\n id\n }\n }\n }\n\n organismsBase: organisms {\n label\n id\n }\n\n organisms(where: { tableEntries_SOME: { guide: { name: $guideName } } }) {\n id\n }\n\n selectableGuides: guides(\n where: { name: $guideName, genome: { organism: { id_IN: $organismIds } } }\n ) {\n id\n genome {\n organism {\n shortlabel\n }\n }\n }\n }\n"): (typeof documents)["\n query guideAlignmentQuery(\n $guideSubclasses: [GuideClass!]\n $guideName: String\n $organismIds: [Int!]\n ) {\n guideBase: guides {\n name\n subclass\n subclass_label\n genome {\n organism {\n id\n }\n }\n }\n\n guideNamesFilteredBySubclass: guides(\n where: { subclass_IN: $guideSubclasses }\n ) {\n name\n genome {\n organism {\n id\n }\n }\n }\n\n organismsBase: organisms {\n label\n id\n }\n\n organisms(where: { tableEntries_SOME: { guide: { name: $guideName } } }) {\n id\n }\n\n selectableGuides: guides(\n where: { name: $guideName, genome: { organism: { id_IN: $organismIds } } }\n ) {\n id\n genome {\n organism {\n shortlabel\n }\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/src/gql/codegen/graphql.ts b/src/gql/codegen/graphql.ts index f6673f5d2b9fc6cc764985ac33b49d0af780dee3..29a5f235a76b7ff9f6cc47b14a4f9332b33c0a2f 100644 --- a/src/gql/codegen/graphql.ts +++ b/src/gql/codegen/graphql.ts @@ -9829,6 +9829,23 @@ export type ClusterByIdQueryQueryVariables = Exact<{ export type ClusterByIdQueryQuery = { __typename?: 'Query', clusters: Array<{ __typename?: 'Cluster', id: string, guides: Array<{ __typename?: 'Guide', id: string, name?: string | null, class: SequenceClass, subclass_label: string, modificationsAggregate?: { __typename?: 'GuideModificationModificationsAggregationSelection', count: number } | null, chromosomeConnection: { __typename?: 'GuideParentConnection', edges: Array<{ __typename?: 'GuideParentRelationship', properties: { __typename?: 'HasFeature', start: number, end: number } }> } }>, guidesAggregate?: { __typename?: 'ClusterGuideGuidesAggregationSelection', count: number } | null, referenceGuide: Array<{ __typename?: 'Guide', chromosome?: { __typename?: 'Chromosome', id: string, name?: string | null } | { __typename?: 'GenericSequence', id: string, name?: string | null } | { __typename?: 'Guide', id: string, name?: string | null } | { __typename?: 'Target', id: string, name?: string | null } | null, genome?: { __typename?: 'Genome', organism?: { __typename?: 'Organism', label: string, id: number } | null } | null }> }> }; +export type TargetAlignmentQueryQueryVariables = Exact<{ + targetName?: InputMaybe<Scalars['String']['input']>; + organismIds?: InputMaybe<Array<Scalars['Int']['input']> | Scalars['Int']['input']>; +}>; + + +export type TargetAlignmentQueryQuery = { __typename?: 'Query', targetBase: Array<{ __typename?: 'Target', name?: string | null, unit?: string | null, genome?: { __typename?: 'Genome', organism?: { __typename?: 'Organism', id: number, label: string } | null } | null }>, organismsBase: Array<{ __typename?: 'Organism', label: string, id: number }>, organisms: Array<{ __typename?: 'Organism', id: number }>, selectableTargets: Array<{ __typename?: 'Target', id: string, genome?: { __typename?: 'Genome', organism?: { __typename?: 'Organism', shortlabel: string } | null } | null }> }; + +export type GuideAlignmentQueryQueryVariables = Exact<{ + guideSubclasses?: InputMaybe<Array<GuideClass> | GuideClass>; + guideName?: InputMaybe<Scalars['String']['input']>; + organismIds?: InputMaybe<Array<Scalars['Int']['input']> | Scalars['Int']['input']>; +}>; + + +export type GuideAlignmentQueryQuery = { __typename?: 'Query', guideBase: Array<{ __typename?: 'Guide', name?: string | null, subclass: GuideClass, subclass_label: string, genome?: { __typename?: 'Genome', organism?: { __typename?: 'Organism', id: number } | null } | null }>, guideNamesFilteredBySubclass: Array<{ __typename?: 'Guide', name?: string | null, genome?: { __typename?: 'Genome', organism?: { __typename?: 'Organism', id: number } | null } | null }>, organismsBase: Array<{ __typename?: 'Organism', label: string, id: number }>, organisms: Array<{ __typename?: 'Organism', id: number }>, selectableGuides: Array<{ __typename?: 'Guide', id: string, genome?: { __typename?: 'Genome', organism?: { __typename?: 'Organism', shortlabel: string } | null } | null }> }; + export type DatabaseStatisticsQueryQueryVariables = Exact<{ [key: string]: never; }>; @@ -9856,6 +9873,8 @@ export const ModificationByIdQueryDocument = {"kind":"Document","definitions":[{ export const GuideByIdQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"guideByIdQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"guides"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"altnames"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"length"}},{"kind":"Field","name":{"kind":"Name","value":"class"}},{"kind":"Field","name":{"kind":"Name","value":"subclass_label"}},{"kind":"Field","alias":{"kind":"Name","value":"chromosomeConnection"},"name":{"kind":"Name","value":"parentConnection"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"node"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"graphql_type"},"value":{"kind":"EnumValue","value":"Chromosome"}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start"}},{"kind":"Field","name":{"kind":"Name","value":"end"}},{"kind":"Field","name":{"kind":"Name","value":"strand"}}]}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"length"}},{"kind":"Field","name":{"kind":"Name","value":"graphql_type"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"host_genes"}},{"kind":"Field","name":{"kind":"Name","value":"seq"}},{"kind":"Field","name":{"kind":"Name","value":"genome"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organism"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"boxConnections"},"name":{"kind":"Name","value":"featuresConnection"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"NOT"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"node"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"class"},"value":{"kind":"EnumValue","value":"DuplexFragment"}}]}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"HasFeature"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start"}},{"kind":"Field","name":{"kind":"Name","value":"end"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"annotation"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"modifications"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"options"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"sort"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"position"},"value":{"kind":"EnumValue","value":"ASC"}}]}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}},{"kind":"Field","name":{"kind":"Name","value":"modificationsAggregate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"interactions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"duplexes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"primaryStrandsConnection"},"name":{"kind":"Name","value":"strandsConnection"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"edge"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"primary"},"value":{"kind":"BooleanValue","value":true}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start"}},{"kind":"Field","name":{"kind":"Name","value":"end"}},{"kind":"Field","name":{"kind":"Name","value":"primary"}}]}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"seq"}},{"kind":"Field","name":{"kind":"Name","value":"parentConnection"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start"}},{"kind":"Field","name":{"kind":"Name","value":"end"}}]}}]}}]}}]}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"secondaryStrandsConnection"},"name":{"kind":"Name","value":"strandsConnection"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"edge"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"primary"},"value":{"kind":"BooleanValue","value":false}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start"}},{"kind":"Field","name":{"kind":"Name","value":"end"}},{"kind":"Field","name":{"kind":"Name","value":"primary"}}]}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"seq"}},{"kind":"Field","name":{"kind":"Name","value":"parentConnection"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start"}},{"kind":"Field","name":{"kind":"Name","value":"end"}}]}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"index"}}]}},{"kind":"Field","name":{"kind":"Name","value":"modification"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"symbol"}},{"kind":"Field","name":{"kind":"Name","value":"symbol_label"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"type_short_label"}}]}},{"kind":"Field","name":{"kind":"Name","value":"target"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"class"}},{"kind":"Field","name":{"kind":"Name","value":"unit"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"cluster"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"clusterAggregate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isoform"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"guides"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"guidesAggregate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"isoformAggregate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"chebi_id"}},{"kind":"Field","name":{"kind":"Name","value":"so_id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"targets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"modifications_SOME"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"guides_SOME"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"options"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"sort"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"name"},"value":{"kind":"EnumValue","value":"ASC"}}]}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"class"}},{"kind":"Field","name":{"kind":"Name","value":"unit"}}]}}]}}]} as unknown as DocumentNode<GuideByIdQueryQuery, GuideByIdQueryQueryVariables>; export const TargetByIdQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"targetByIdQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"targets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"altnames"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"length"}},{"kind":"Field","name":{"kind":"Name","value":"class"}},{"kind":"Field","name":{"kind":"Name","value":"unit"}},{"kind":"Field","alias":{"kind":"Name","value":"chromosomeConnection"},"name":{"kind":"Name","value":"parentConnection"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"node"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"graphql_type"},"value":{"kind":"EnumValue","value":"Chromosome"}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start"}},{"kind":"Field","name":{"kind":"Name","value":"end"}},{"kind":"Field","name":{"kind":"Name","value":"strand"}}]}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"graphql_type"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"seq"}},{"kind":"Field","name":{"kind":"Name","value":"genome"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organism"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"modifications"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"options"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"sort"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"position"},"value":{"kind":"EnumValue","value":"ASC"}}]}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"symbol"}}]}},{"kind":"Field","name":{"kind":"Name","value":"modificationsAggregate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"interactions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"duplexes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"primaryStrandsConnection"},"name":{"kind":"Name","value":"strandsConnection"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"edge"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"primary"},"value":{"kind":"BooleanValue","value":true}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start"}},{"kind":"Field","name":{"kind":"Name","value":"end"}},{"kind":"Field","name":{"kind":"Name","value":"primary"}}]}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"seq"}},{"kind":"Field","name":{"kind":"Name","value":"parentConnection"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start"}},{"kind":"Field","name":{"kind":"Name","value":"end"}}]}}]}}]}}]}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"secondaryStrandsConnection"},"name":{"kind":"Name","value":"strandsConnection"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"edge"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"primary"},"value":{"kind":"BooleanValue","value":false}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start"}},{"kind":"Field","name":{"kind":"Name","value":"end"}},{"kind":"Field","name":{"kind":"Name","value":"primary"}}]}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"seq"}},{"kind":"Field","name":{"kind":"Name","value":"parentConnection"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start"}},{"kind":"Field","name":{"kind":"Name","value":"end"}}]}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"index"}}]}},{"kind":"Field","name":{"kind":"Name","value":"modification"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"symbol"}},{"kind":"Field","name":{"kind":"Name","value":"symbol_label"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"type_short_label"}}]}},{"kind":"Field","name":{"kind":"Name","value":"guide"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"subclass_label"}},{"kind":"Field","name":{"kind":"Name","value":"class"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"chebi_id"}},{"kind":"Field","name":{"kind":"Name","value":"so_id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"secondary_struct_file"}}]}},{"kind":"Field","name":{"kind":"Name","value":"guides"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"modifications_SOME"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"target"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"options"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"sort"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"EnumValue","value":"ASC"}}]}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"class"}},{"kind":"Field","alias":{"kind":"Name","value":"chromosome"},"name":{"kind":"Name","value":"parent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"graphql_type"},"value":{"kind":"EnumValue","value":"Chromosome"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<TargetByIdQueryQuery, TargetByIdQueryQueryVariables>; export const ClusterByIdQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"clusterByIdQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"clusters"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"guides"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"options"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"sort"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"EnumValue","value":"ASC"}}]}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"class"}},{"kind":"Field","name":{"kind":"Name","value":"subclass_label"}},{"kind":"Field","name":{"kind":"Name","value":"modificationsAggregate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"chromosomeConnection"},"name":{"kind":"Name","value":"parentConnection"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"node"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"graphql_type"},"value":{"kind":"EnumValue","value":"Chromosome"}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"start"}},{"kind":"Field","name":{"kind":"Name","value":"end"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"guidesAggregate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"referenceGuide"},"name":{"kind":"Name","value":"guides"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"options"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"1"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"chromosome"},"name":{"kind":"Name","value":"parent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"graphql_type"},"value":{"kind":"EnumValue","value":"Chromosome"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"genome"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organism"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode<ClusterByIdQueryQuery, ClusterByIdQueryQueryVariables>; +export const TargetAlignmentQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"targetAlignmentQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"targetName"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organismIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"targetBase"},"name":{"kind":"Name","value":"targets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"unit"}},{"kind":"Field","name":{"kind":"Name","value":"genome"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organism"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}}]}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"organismsBase"},"name":{"kind":"Name","value":"organisms"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"organisms"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"tableEntries_SOME"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"modification"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"target"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"targetName"}}}]}}]}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"selectableTargets"},"name":{"kind":"Name","value":"targets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"targetName"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"genome"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"organism"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id_IN"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organismIds"}}}]}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"genome"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organism"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"shortlabel"}}]}}]}}]}}]}}]} as unknown as DocumentNode<TargetAlignmentQueryQuery, TargetAlignmentQueryQueryVariables>; +export const GuideAlignmentQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"guideAlignmentQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"guideSubclasses"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"GuideClass"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"guideName"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organismIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"guideBase"},"name":{"kind":"Name","value":"guides"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"subclass"}},{"kind":"Field","name":{"kind":"Name","value":"subclass_label"}},{"kind":"Field","name":{"kind":"Name","value":"genome"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organism"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"guideNamesFilteredBySubclass"},"name":{"kind":"Name","value":"guides"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"subclass_IN"},"value":{"kind":"Variable","name":{"kind":"Name","value":"guideSubclasses"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"genome"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organism"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"organismsBase"},"name":{"kind":"Name","value":"organisms"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"organisms"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"tableEntries_SOME"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"guide"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"guideName"}}}]}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"selectableGuides"},"name":{"kind":"Name","value":"guides"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"guideName"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"genome"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"organism"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id_IN"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organismIds"}}}]}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"genome"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organism"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"shortlabel"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GuideAlignmentQueryQuery, GuideAlignmentQueryQueryVariables>; export const DatabaseStatisticsQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"databaseStatisticsQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organismsAggregate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"allModifications"},"name":{"kind":"Name","value":"modifications"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"allModificationsCount"},"name":{"kind":"Name","value":"modificationsAggregate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"nonOrphanModificationsCount"},"name":{"kind":"Name","value":"modificationsAggregate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"guidesAggregate"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"count_GT"},"value":{"kind":"IntValue","value":"0"}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"allGuides"},"name":{"kind":"Name","value":"guides"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subclass"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"allGuidesCount"},"name":{"kind":"Name","value":"guidesAggregate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"nonOrphanGuidesCount"},"name":{"kind":"Name","value":"guidesAggregate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"modificationsAggregate"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"count_GT"},"value":{"kind":"IntValue","value":"0"}}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}}]} as unknown as DocumentNode<DatabaseStatisticsQueryQuery, DatabaseStatisticsQueryQueryVariables>; export const LegalDocumentListQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"legalDocumentListQuery"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documents"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"types_INCLUDES"},"value":{"kind":"StringValue","value":"legal","block":false}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"menu_label"}}]}}]}}]} as unknown as DocumentNode<LegalDocumentListQueryQuery, LegalDocumentListQueryQueryVariables>; export const DocumentByIdQueryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"documentByIdQuery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documents"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"menu_label"}},{"kind":"Field","name":{"kind":"Name","value":"content"}}]}}]}}]} as unknown as DocumentNode<DocumentByIdQueryQuery, DocumentByIdQueryQueryVariables>; \ No newline at end of file diff --git a/src/gql/queries.ts b/src/gql/queries.ts index 8c4c412ba4937932b616a9469b7de77ebeac4e49..ba8c44dad3a7f2cb480e63e087c7f808893ee456 100644 --- a/src/gql/queries.ts +++ b/src/gql/queries.ts @@ -820,6 +820,104 @@ export const clusterByIdQuery = graphql(/* GraphQL */ ` } `) +/** + * Get necessary data to select sequences for alignment. + */ +export const targetAlignmentQuery = graphql(/* GraphQL */ ` + query targetAlignmentQuery($targetName: String, $organismIds: [Int!]) { + targetBase: targets { + name + unit + genome { + organism { + id + label + } + } + } + + organismsBase: organisms { + label + id + } + + organisms( + where: { + tableEntries_SOME: { modification: { target: { name: $targetName } } } + } + ) { + id + } + + selectableTargets: targets( + where: { + name: $targetName + genome: { organism: { id_IN: $organismIds } } + } + ) { + id + genome { + organism { + shortlabel + } + } + } + } +`) + +/** + * Get necessary data to select sequences for alignment. + */ +export const guideAlignmentQuery = graphql(/* GraphQL */ ` + query guideAlignmentQuery( + $guideSubclasses: [GuideClass!] + $guideName: String + $organismIds: [Int!] + ) { + guideBase: guides { + name + subclass + subclass_label + genome { + organism { + id + } + } + } + + guideNamesFilteredBySubclass: guides( + where: { subclass_IN: $guideSubclasses } + ) { + name + genome { + organism { + id + } + } + } + + organismsBase: organisms { + label + id + } + + organisms(where: { tableEntries_SOME: { guide: { name: $guideName } } }) { + id + } + + selectableGuides: guides( + where: { name: $guideName, genome: { organism: { id_IN: $organismIds } } } + ) { + id + genome { + organism { + shortlabel + } + } + } + } +`) + /** * Get statistics about the database. */ diff --git a/src/layouts/FooterLayout.vue b/src/layouts/FooterLayout.vue index b8b9b65f243c0bbdb24db3f827b1ef4222b926e5..40c65ef5e1201d99a8ca122d0149b7d9fad79625 100644 --- a/src/layouts/FooterLayout.vue +++ b/src/layouts/FooterLayout.vue @@ -11,7 +11,7 @@ import Button from 'primevue/button' import IconFa6SolidAddressCard from '~icons/fa6-solid/address-card' import IconFa6SolidChevronUp from '~icons/fa6-solid/chevron-up' /** - * Other 3rd-party imports + * Composables imports */ import { useQuery } from '@urql/vue' /** diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index b7e4fc416d89a519a23fbb24fb6a69bac533dcfc..1be2df3923fde964a1d8950fb9e7157883a2e3ae 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -3,7 +3,6 @@ * Vue imports */ import { ref } from 'vue' -import { useRouter } from 'vue-router' /** * Components imports */ @@ -15,6 +14,10 @@ import Menubar from 'primevue/menubar' import IconFa6SolidMagnifyingGlass from '~icons/fa6-solid/magnifying-glass' import IconFa6SolidCircleXmark from '~icons/fa6-solid/circle-xmark' import IconFa6SolidArrowRight from '~icons/fa6-solid/arrow-right' +/** + * Composables imports + */ +import { useRouter } from 'vue-router' /** * Utils imports */ diff --git a/src/router/index.ts b/src/router/index.ts index f29dd5bb5bb141619f0c57f083dbdc477b08ec3f..b12de9de6a7de5ad7905aa931aee98a2d5b3491e 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -7,18 +7,22 @@ import { createRouter, createWebHistory } from 'vue-router' */ import HomeView from '@/views/HomeView.vue' /** - * Other 3rd-party imports + * Composables imports */ import { useTitle } from '@vueuse/core' +/** + * Other 3rd-party imports + */ import { isNonNullish } from '@/typings/typeUtils' /** * Types imports */ -import type { SelectionFormModesType } from '@/views/SelectionView.vue' +import type { SelectionModesType } from '@/views/SelectionView.vue' +// import type { AlignmentModesType } from '@/views/AlignmentView.vue' /** * Utils imports */ -import { SELECTION_FORM_MODES } from '@/utils/constant' +import { /* ALIGNMENT_MODES, */ SELECTION_MODES } from '@/utils/constant' declare module 'vue-router' { interface RouteMeta { @@ -42,20 +46,18 @@ const router = createRouter({ name: 'selection', component: () => import('@/views/SelectionView.vue'), props: (to) => ({ - mode: to.query.mode + initialMode: to.query.mode }), beforeEnter: (to) => { if ( typeof to.query.mode !== 'string' || - !SELECTION_FORM_MODES.includes( - to.query.mode as SelectionFormModesType - ) + !SELECTION_MODES.includes(to.query.mode as SelectionModesType) ) { - router.replace({ name: 'lost' }) + router.replace({ name: 'selection', query: { mode: 'guide' } }) } }, meta: { - title: 'Advanced selection' + managedTitle: true } }, { @@ -109,6 +111,9 @@ const router = createRouter({ ) { router.replace({ name: 'notFound' }) } + }, + meta: { + managedTitle: true } }, { @@ -132,6 +137,9 @@ const router = createRouter({ if (typeof to.query.id !== 'string') { router.replace({ name: 'notFound' }) } + }, + meta: { + managedTitle: true } }, { @@ -146,6 +154,9 @@ const router = createRouter({ if (typeof to.query.id !== 'string') { router.replace({ name: 'notFound' }) } + }, + meta: { + managedTitle: true } }, { @@ -160,6 +171,9 @@ const router = createRouter({ if (typeof to.query.id !== 'string') { router.replace({ name: 'notFound' }) } + }, + meta: { + managedTitle: true } }, { @@ -171,10 +185,32 @@ const router = createRouter({ if (typeof to.query.id !== 'string') { router.replace({ name: 'notFound' }) } + }, + meta: { + managedTitle: true } } ] }, + // { + // path: '/alignment', + // name: 'alignment', + // component: () => import('@/views/AlignmentView.vue'), + // props: (to) => ({ + // initialMode: to.query.mode + // }), + // beforeEnter: (to) => { + // if ( + // typeof to.query.mode !== 'string' || + // !ALIGNMENT_MODES.includes(to.query.mode as AlignmentModesType) + // ) { + // router.replace({ name: 'alignment', query: { mode: 'guide' } }) + // } + // }, + // meta: { + // managedTitle: true + // } + // }, { path: '/statistics', name: 'statistics', @@ -263,8 +299,10 @@ const router = createRouter({ }) router.afterEach((to) => { - useTitle((to.meta.title ? `${to.meta.title} | ` : '') + 'SnoBoard') - console.info('Title changed.') + if (!to.meta.managedTitle) { + useTitle((to.meta.title ? `${to.meta.title} | ` : '') + 'SnoBoard') + console.info('Title changed.') + } }) export default router diff --git a/src/typings/typeUtils.ts b/src/typings/typeUtils.ts index d44a1907db913128da2440626b8e95b317986e1f..d23a9f3ca3a829b9da9dbd532febf9e152bc4c57 100644 --- a/src/typings/typeUtils.ts +++ b/src/typings/typeUtils.ts @@ -110,3 +110,11 @@ export type SubType<BaseType, ConditionType> = Pick< [Key in keyof BaseType]: BaseType[Key] extends ConditionType ? Key : never }[keyof BaseType] > + +/** + * Checks if an array contains at least one element, narrowing its type. + * @param array The array for which to check the length. + */ +export function isNotEmpty<T>(array: T[]): array is [T,...T[]] { + return array.length >= 1 +} \ No newline at end of file diff --git a/src/utils/constant.ts b/src/utils/constant.ts index 0b0b8efa33038c101af9aa6c12c025a1443015f9..6aaf040b1448c319ec0d35540a6bf70a091772f1 100644 --- a/src/utils/constant.ts +++ b/src/utils/constant.ts @@ -1,5 +1,5 @@ /** - * Component imports + * Components imports */ import IconFa6SolidTable from '~icons/fa6-solid/table' import IconFa6SolidDatabase from '~icons/fa6-solid/database' @@ -36,10 +36,14 @@ interface HelpTourItem { } /** - * The different modes available for advanced selection form on the selection - * page. + * The different modes available for advanced selection. */ -export const SELECTION_FORM_MODES = ['modification', 'guide', 'target'] as const +export const SELECTION_MODES = ['modification', 'guide', 'target'] as const + +/** + * The different modes available for alignment. + */ +export const ALIGNMENT_MODES = ['guide', 'target'] as const /** * The items to display in the main navigation menu. diff --git a/src/utils/graphFormat.ts b/src/utils/graphFormat.ts index 70e62750ecb3af9d425591e48452df58b99a7fe8..aedd4cfdfb1bb1706ca2ad142db1593d667dfe40 100644 --- a/src/utils/graphFormat.ts +++ b/src/utils/graphFormat.ts @@ -1,5 +1,9 @@ -import type { C4GGraphModel, C4GNodeModel } from '@/typings/Codev4GraphFormat' +/** + * Types imports + */ +import type { C4GGraphModel } from '@/typings/Codev4GraphFormat' import type { JGFGraphModel } from '@/typings/JSONGraphFormat' +import { mapValues as _mapValues } from 'lodash-es' /** * "Fix" a graph by transforming it to proper C4G format @@ -22,30 +26,22 @@ export const fixGraphFormat = (JGFGraph: JGFGraphModel): C4GGraphModel => { }) ) }, - nodes: Object.entries(JGFGraph.graph.nodes || {}).reduce<{ - [nodeId: string]: C4GNodeModel - }>( - (nodes, [nodeId, node]) => ({ - ...nodes, - [nodeId]: { - label: node.label, - metadata: { - position: { - x: node.metadata?.x, - y: node.metadata?.y - }, - data: [ - { - label: 'nucleotidePosition', - type: 'number', - value: node.metadata?.position - } - ] + nodes: _mapValues(JGFGraph.graph.nodes, (node) => ({ + label: node.label, + metadata: { + position: { + x: node.metadata?.x, + y: node.metadata?.y + }, + data: [ + { + label: 'nucleotidePosition', + type: 'number', + value: node.metadata?.position } - } - }), - {} - ), + ] + } + })), edges: JGFGraph.graph.edges?.map((edge) => ({ ...edge, metadata: { diff --git a/src/utils/normalise.ts b/src/utils/normalise.ts index b6f9a32aef922969c7bc17dab3f93db4ddd0b08c..18f945dd9bdee923f680aff2eec33968642ed30d 100644 --- a/src/utils/normalise.ts +++ b/src/utils/normalise.ts @@ -1,7 +1,7 @@ /** * Other 3rd-party imports */ -import { findIndex as _findIndex } from 'lodash-es' +import { findIndex as _findIndex, mapValues as _mapValues } from 'lodash-es' /** * Types imports */ @@ -96,9 +96,7 @@ export const composeNormalisedGraph = ( normalisedEdgeLength / (referenceEdgeLength || normalisedEdgeLength) // Apply normalisation to the nodes - const normalisedNodes = Object.entries(graph.graph.nodes).reduce<{ - [nodeId: string]: C4GNodeModel - }>((nodes, [nodeId, node]) => { + const normalisedNodes = _mapValues(graph.graph.nodes, (node) => { // Normalise position if present const normalisedNode = node.metadata?.position ? { @@ -116,10 +114,7 @@ export const composeNormalisedGraph = ( // Check if additional metadata is present, if not return & continue // normalisation on next node if (!normalisedNode.metadata?.data) { - return { - ...nodes, - [`${nodeId}`]: normalisedNode - } + return normalisedNode } // Get indexes of additional metadata to normalise (-1 if not present) @@ -166,11 +161,8 @@ export const composeNormalisedGraph = ( nucleotidePositionLineDataValue.y2 *= normalisationRatio } - return { - ...nodes, - [`${nodeId}`]: normalisedNode - } - }, {}) + return normalisedNode + }) return { graph: { diff --git a/src/utils/sequences.ts b/src/utils/sequences.ts new file mode 100644 index 0000000000000000000000000000000000000000..24412249a8fb4411ce855f717d3f6bb9ea440b48 --- /dev/null +++ b/src/utils/sequences.ts @@ -0,0 +1,15 @@ +/** + * In an array of sequences (e.g. for an alignment), computes the conservation + * rate of a reference seq., i.e. the proportion of seq. in the array which are + * identical to it. + * @param sequences The sequences among which to compute the conservation. + * @param referenceSequence The sequence for which to compute the conservation. + * @returns The proportion of the sequences in the array which are identical to + * the reference one. + */ +export const composeConservationRate = ( + sequences: string[], + referenceSequence: string +): number => + sequences.filter((sequence) => sequence && sequence === referenceSequence) + .length / (sequences.length || 1) diff --git a/src/views/APIView.vue b/src/views/APIView.vue index 73bdbca3897e35d921c08f8c947aed8adb05c0a8..b98200b0ce02a7297f4c3dd6a3879d23fc259844 100644 --- a/src/views/APIView.vue +++ b/src/views/APIView.vue @@ -12,11 +12,14 @@ import Dialog from 'primevue/dialog' import Checkbox from 'primevue/checkbox' import IconDeviconNeo4j from '~icons/devicon/neo4j' import IconLogosGraphql from '~icons/logos/graphql' +/** + * Composables imports + */ +import { useStorage } from '@vueuse/core' /** * Other 3rd-party imports */ import { ApolloSandbox } from '@apollo/sandbox' -import { useStorage } from '@vueuse/core' /** * Reactive local storage entry to show or not the info dialog at landing. diff --git a/src/views/AlignmentView.vue b/src/views/AlignmentView.vue new file mode 100644 index 0000000000000000000000000000000000000000..f9dad3e1bc59d45d4f3895b687ea04721f7721c4 --- /dev/null +++ b/src/views/AlignmentView.vue @@ -0,0 +1,179 @@ +<script setup lang="ts"> +/** + * Vue imports + */ +import { computed, ref, onMounted } from 'vue' +/** + * Components imports + */ +import MainLayout from '@/layouts/MainLayout.vue' +import SequenceAlignment from '@/components/SequenceAlignment.vue' +import AlignmentForm from '@/components/AlignmentForm.vue' +import IconSnoboardAlignment from '~icons/snoboard/alignment' +/** + * Composables imports + */ +import { useRouter } from 'vue-router' +import { useTitle } from '@vueuse/core' +/** + * Other 3rd-party import + */ +import { capitalize as _capitalize } from 'lodash-es' +/** + * Type imports + */ +import type { AlignmentModel } from '@/components/SequenceAlignment.vue' +/** + * Utils imports + */ +import { ALIGNMENT_MODES } from '@/utils/constant' + +/** + * Type of sequence to align. + */ +export type AlignmentModesType = (typeof ALIGNMENT_MODES)[number] + +const ALIGNMENT: AlignmentModel = { + tracks: [ + { + id: '18S_Arabidopsis_Iour', + name: 'A.thaliana - Iouri', + shortname: 'Ath-I', + sequence: + 'UACCUGGUUGAUCCUGCCAGUAGUCAUAUGCUUGUCUCAAAGAUUAAGCCAUGCAUGUGUAAGUAUGAACGAAUUCAGACUGUGAAACUGCGAAUGGCUCAUUAAAUCAGUUAUAGUUUGUUUGAUGGUAA---CUACUACUC---GGAUAACCGUAGUAAUUCUAGAGCUAAUACGUGCAAC---------AAACCCCGACU-UAUGGAAGGGACGCAUUUAUUAGAUAAAAG----------------------------GUCGACGCGGGCUCUGCCCGUUGCUCUGAUGAUUCAUGAUAACUC--GACGGAUCGCAUGGCCUCUGUGCUGGCGACGCAUCAUUCAAAUUUCUGCCCUAUCAACUUUCGAUGGUAGGAUAGUGGCCUACCAUGGUGGUAACGGGUGACGGAGAAUUAGGGUUCGAUUCCGGAGAGGGAGCCUGAGAAACGGCUACCACAUCCAAGGAAGGCAGCAGGCGCGCAAAUUACCCAAUCCUGACACGGGGAGGUAGUGACAAUAAAUAACAAUACCGGGCUCUUUCGAGUCU-GGUAAUUGGAAUGAGUACAAUCUAAAUCCCUUAACGAGGAUCCAUUGGAGGGCAAGUCUGGUGCCAGCAGCCGCGGUAAUUCCAGCUCCAAUAGCGUAUAUUUAAGUUGUUGCAGUUAAAAAGCUCGUAGUUGAACCUUGGGAUGGGUCGGCCGGUCCGCCUUUGGUGUGCAUUGGUC--------GGCUUGUCCCUUCGGUCGGCGAUACGCUCCUGGUCUUAAUUGGCCGGGUCGUGCCUCCGGCGCUGUUACUUUGAAGAAAUUAGAGUGCUCAAAGCAAGCCUACGCU--CUGGAUACAUUAGCAUGGGAUAACAUCAUAGGAU-UUCGAUCCUAUUGUGUUGGCCUUCGGGAUCGGAGUAAUGAUUAACAGGGACAGUCGGGGGCAUUCGUAUUUCAUAGUCAGAGGUGAAAUUCUUGGAUUUAUGAAAGACGAACAACUGCGAAAGCAUUUGCCAAGGAUGUUUUCAUUAAUCAAGAACGAAAGUUGGGGGCUCGAAGACGAUCAGAUACCGUCCUAGUCUCAACCAUAAACGAUGCCGACCAGGGAUCAGCGGAUGUUGCUUAUAGGACUCCGCUGGCACCUUAUGAGAAAUCAAAGUUUUUGGGUUCCGGGGGGAGUAUGGUCGCAAGGCUGAAACUUAAAGGAAUUGACGGAAGGGCACCACCAGGAGUGGAGCCUGCGGCUUAAUUUGACUCAACACGGGGAAACUUACCAGGUCCAGACAUAGUAAGGAUUGACAGACUGAGAGCUCUUUCUUGAUUCUAUGGGUGGUGGUGCAUGGCCGUUCUUAGUUGGUGGAGCGAUUUGUCUGGUUAAUUCCGUUAACGAACGAGACCUCAGCCUGCUAACUAGCUACGUG-----GAGGCAUCCCUUCACGGCCGGCUUCUUAGAGGGACUAUGGCCGUUUAGGCCAAGGAAGUUUGAGGCAAUAACAGGUCUGUGAUGCCCUUAGAUGUUCUGGGCCGCACGCGCGCUACACUGAUGUAUUCAACGAGUUCACACCUUG-GCCGACAGGCCCGGGUAAUCUU-UGAAAUUUCAUCGUGAUGGGGAUAGAUCAUUGCAAUUGUUGGUCUUCAACGAGGAAUUCCU--AGUAAGCGCGAGUCAUCAGCUCGCGUUGACUACGUCCCUGCCCUUUGUACACACCGCCCGUCGCUCCUACCGAUUGAAUGAUCCGGUGAAGUGUUCGGAUCGCGGCGACGUGGGUGGUUCGCCGCCC---GCGACGUCGCGAGAAGUCCACUAAACCUUAUCAUUUAGAGGAAGGAGAAGUCGUAACAAGGUUUCCGUAGGUGAACCUGCGGAAGGAUCAUUG', + isReference: true, + objects: { + MOD_00030: { + name: 'Am28_18S', + start: 28, + end: 28, + type: "2'-*O*-Me", + color: 'sky', + link: { name: 'modificationDetails', query: { id: 'MOD_00030' } } + }, + MOD_00187: { + name: 'Psi35_18S', + start: 35, + end: 35, + type: 'Pseudouridylation', + color: 'violet', + link: { name: 'modificationDetails', query: { id: 'MOD_00187' } } + }, + MOD_00012: { + name: 'Am162_18S', + start: 168, + end: 168, + type: "2'-*O*-Me", + color: 'sky', + link: { name: 'modificationDetails', query: { id: 'MOD_00012' } } + } + } + }, + { + id: '18S_Arabidopsis_thal', + name: 'A.thaliana - ref', + shortname: 'Ath-Ref', + sequence: + 'UACCUGGUUGAUCCUGCCAGUAGUCAUAUGCUUGUCUCAAAGAUUAAGCCAUGCAUGUGUAAGUAUGAACGAAUUCAGACUGUGAAACUGCGAAUGGCUCAUUAAAUCAGUUAUAGUUUGUUUGAUGGUAA---CUACUACUC---GGAUAACCGUAGUAAUUCUAGAGCUAAUACGUGCAAC---------AAACCCCGACU-UAUGGAAGGGACGCAUUUAUUAGAUAAAAG----------------------------GUCGACGCGGGCUCUGGC--UUGCUCUGAUGAUUCAUGAUAACUC--GACGGAUCGCAUGGCCUCUGUGCUGGCGACGCAUCAUUCAAAUUUCUGCCCUAUCAACUUUCGAUGGUAGGAUAGUGGCCUACCAUGGUGGUAACGGGUGACGGAGAAUUAGGGUUCGAUUCCGGAGAGGGAGCCUGAGAAACGGCUACCACAUCCAAGGAAGGCAGCAGGCGCGCAAAUUACCCAAUCCUGACACGGGGAGGUAGUGACAAUAAAUAACAAUACCGGGCUCUUUCGAGUCU-GGUAAUUGGAAUGAGUACAAUCUAAAUCCCUUAACGAGGAUCCAUUGGAGGGCAAGUCUGGUGCCAGCAGCCGCGGUAAUUCCAGCUCCAAUAGCGUAUAUUUAAGUUGUUGCAGUUAAAAAGCUCGUAGUUGAACCUUGGGAUGGGUCGGCCGGUCCGCCUUUGGUGUGCAUUGGUC--------GGCUUGUCCCUUCGGUCGGCGAUACGCUCCUGGUCUUAAUUGGCCGGGUCGUGCCUCCGGCGCUGUUACUUUGAAGAAAUUAGAGUGCUCAAAGCAAGCCUACGCU--CUGGAUACAUUAGCAUGGGAUAACAUCAUAGGAU-UUCGAUCCUAUUGUGUUGGC-UUCGGGAUCGGAGUAAUGAUUAACAGGGACAGUCGGGGGCAUUCGUAUUUCAUAGUCAGAGGUGAAAUUCUUGGAUUUAUGAAAGACGAACAACUGCGAAAGCAUUUGCCAAGGAUGUUUUCAUUAAUCAAGAACGAAAGUUGGGGGCUCGAAGACGAUCAGAUACCGUCCUAGUCUCAACCAUAAACGAUGCCGACCAGGGAUCAGCGGAUGUUGCUUAUAGGACUCCGCUGGCACCUUAUGAGAAAUCAAAGUUUUUGGGUUCCGGGGGGAGUAUGGUCGCAAGGCUGAAACUUAAAGGAAUUGACGGAAGGGCACCACCAGGAGUGGAGCCUGCGGCUUAAUUUGACUCAACACGGGGAAACUUACCAGGUCCAGACAUAGUAAGGAUUGACAGACUGAGAGCUCUUUCUUGAUUCUAUGGGUGGUGGUGCAUGGCCGUUCUUAGUUGGUGGAGCGAUUUGUCUGGUUAAUUCCGUUAACGAACGAGACCUCAGCCUGCUAACUAGCUACGUG-----GAGGCAUCCCUUCACGGCCGGCUUCUUAGAGGGACUAUGGCCGUUUAGGCCAAGGAAGUUUGAGGCAAUAACAGGUCUGUGAUGCCCUUAGAUGUUCUGGGCCGCACGCGCGCUACACUGAUGUAUUCAACGAGUUCACACCUUG--CCGACAGGCCCGGGUAAUCUU-UGAAAUUUCAUCGUGAUGGGGAUAGAUCAUUGCAAUUGUUGGUCUUCAACGAGGAAUUCCU--AGUAAGCGCGAGUCAUCAGCUCGCGUUGACUACGUCCCUGCCCUUUGUACACACCGCCCGUCGCUCCUACCGAUUGAAUGAUCCGGUGAAGUGUUCGGAUCGCGGCGACGUGGGUGGUUCGCCGCCC---GCGACGUCGCGAGAAGUCCACUAAACCUUAUCAUUUAGAGGAAGGAGAAGUCGUAACAAGGUUUCCGUAGGUGAACCUGCGGAAGGAUCAUUG' + }, + { + id: '18S_S.cerevisiae', + name: 'S.cerevisiae - ref', + shortname: 'Sc-Ref', + sequence: + 'UAUCUGGUUGAUCCUGCCAGUAGUCAUAUGCUUGUCUCAAAGAUUAAGCCAUGCAUGUCUAAGUAUAAGC-AAUUUAUACAGUGAAACUGCGAAUGGCUCAUUAAAUCAGUUAUCGUUUAUUUGAUAGUUC---CUUUACUACAUGGUAUAACUGUGGUAAUUCUAGAGCUAAUACAUGCUUA---------AAAUCUCGACCCUUUGGAAGAGAUGUAUUUAUUAGAUAAAAA----------------------------AUCAAUGUCUUCG------GACUCUUUGAUGAUUCAUAAUAACUU--UUCGAAUCGCAUGGCCU-UGUGCUGGCGAUGGUUCAUUCAAAUUUCUGCCCUAUCAACUUUCGAUGGUAGGAUAGUGGCCUACCAUGGUUUCAACGGGUAACGGGGAAUAAGGGUUCGAUUCCGGAGAGGGAGCCUGAGAAACGGCUACCACAUCCAAGGAAGGCAGCAGGCGCGCAAAUUACCCAAUCCUAAUUCAGGGAGGUAGUGACAAUAAAUAACGAUACAGGGCCCAUUCGGGUCU-UGUAAUUGGAAUGAGUACAAUGUAAAUACCUUAACGAGGAACAAUUGGAGGGCAAGUCUGGUGCCAGCAGCCGCGGUAAUUCCAGCUCCAAUAGCGUAUAUUAAAGUUGUUGCAGUUAAAAAGCUCGUAGUUGAACUUUGGGCCCGGUUGGCCGGUCCGAUUUUUUCGUGUACUGGAUUUCCAACGGGGCCUUUCCUUCUGGCUAACCUUGAGUCCUUGUGGCUCUUGGCGAA---------CCAGGACUUUUACUUUGAAAAAAUUAGAGUGUUCAAAGCAGGCGUAUUGC--UCGAAUAUAUUAGCAUGGAAUAAUAGAAUAGGACGUUUGGUUCUAUUUUGUUGGUUUCUAGGACCAUCGUAAUGAUUAAUAGGGACGGUCGGGGGCAUCAGUAUUCAAUUGUCAGAGGUGAAAUUCUUGGAUUUAUUGAAGACUAACUACUGCGAAAGCAUUUGCCAAGGACGUUUUCAUUAAUCAAGAACGAAAGUUAGGGGAUCGAAGAUGAUCAGAUACCGUCGUAGUCUUAACCAUAAACUAUGCCGACUAGGGAUCGGGUGGUGUUUUUUUAAUGACCCACUCGGCACCUUACGAGAAAUCAAAGUCUUUGGGUUCUGGGGGGAGUAUGGUCGCAAGGCUGAAACUUAAAGGAAUUGACGGAAGGGCACCACCAGGAGUGGAGCCUGCGGCUUAAUUUGACUCAACACGGGGAAACUCACCAGGUCCAGACACAAUAAGGAUUGACAGAUUGAGAGCUCUUUCUUGAUUUUGUGGGUGGUGGUGCAUGGCCGUUCUUAGUUGGUGGAGUGAUUUGUCUGCUUAAUUGCGAUAACGAACGAGACCUUAACCUACUAAAUAGUG--GUG-----CUAGCAUUUGCUGGUUAUCCACUUCUUAGAGGGACUAUCGGUUUCAAGCCGAUGGAAGUUUGAGGCAAUAACAGGUCUGUGAUGCCCUUAGACGUUCUGGGCCGCACGCGCGCUACACUGACGGAGCCAGCGAGUCUA-ACCUUG-GCCGAGAGGUCUUGGUAAUCUUGUGAAACUCCGUCGUGCUGGGGAUAGAGCAUUGUAAUUAUUGCUCUUCAACGAGGAAUUCCU--AGUAAGCGCAAGUCAUCAGCUUGCGUUGAUUACGUCCCUGCCCUUUGUACACACCGCCCGUCGCUAGUACCGAUUGAAUGGCUUAGUGAGGCCUCAGGAUCUGCUUAGAGAAGGGGGG-CAACUCCA---UCUCAGAGCGGAGAAUUUGGACAAACUUGGUCAUUUAGAGGAACUAAAAGUCGUAACAAGGUUUCCGUAGGUGAACCUGCGGAAGGAUCAUUA', + isReference: true, + objects: { + MOD_TEST: { + name: 'Am164_18S_Sc', + start: 168, + end: 168, + type: "2'-*O*-Me", + color: 'sky', + link: { name: 'modificationDetails', query: { id: 'MOD_TEST' } } + } + } + }, + { + id: '18S_hs_Iouri_NR_0462', + name: 'H.sapiens - Iouri - NR_0462', + shortname: 'Hs-NR_0462', + sequence: + 'UACCUGGUUGAUCCUGCCAGUAG-CAUAUGCUUGUCUCAAAGAUUAAGCCAUGCAUGUCUGAGUACGCACGGCCGG-UACAGUGAAACUGCGAAUGGCUCAUUAAAUCAGUUAUGGUUCCUUUGGUCGCUCGCUCCUCUCCUACUUGGAUAACUGUGGUAAUUCUAGAGCUAAUACAUGCCGACGGGCGCUGACCCCCUUCGCGGGGGGGAUGCGUGCAUUUAUCAGAUCAAAACCAACCCGGUCAGCCCCUCUCCGGCCCCGGCCGGGGGGCGGGCGCCGGCGGCUUUGGUGACUCUAGAUAACCUCGGGCCGAUCGCACGCCCCCCGUGGCGGCGACGACCCAUUCGAACGUCUGCCCUAUCAACUUUCGAUGGUAGUCGCCGUGCCUACCAUGGUGACCACGGGUGACGGGGAAUCAGGGUUCGAUUCCGGAGAGGGAGCCUGAGAAACGGCUACCACAUCCAAGGAAGGCAGCAGGCGCGCAAAUUACCCACUCCCGACCCGGGGAGGUAGUGACGAAAAAUAACAAUACAGGACUCUUUCGAGGCCCUGUAAUUGGAAUGAGUCCACUUUAAAUCCUUUAACGAGGAUCCAUUGGAGGGCAAGUCUGGUGCCAGCAGCCGCGGUAAUUCCAGCUCCAAUAGCGUAUAUUAAAGUUGCUGCAGUUAAAAAGCUCGUAGUUGGAUCUUGGGAGCGGGCGGGCGGUCCGCCGCGAGGCGAGCCACCGCCCGUCCCCGCCCCUUGCCUCUCGGCGCCCCCUCGAUGCUCUUAGCUGAGUGUCCCGCGGGGC--CCGAAGCGUUUACUUUGAAAAAAUUAGAGUGUUCAAAGCAGGCCCGAGCCGCCUGGAUACCGCAGCUAGGAAUAAUGGAAUAGGACCG-CGGUUCUAUUUUGUUGGUUUUCGGAACUGAGGCCAUGAUUAAGAGGGACGGCCGGGGGCAUUCGUAUUGCGCCGCUAGAGGUGAAAUUCUUGGACCGGCGCAAGACGGACCAGAGCGAAAGCAUUUGCCAAGAAUGUUUUCAUUAAUCAAGAACGAAAGUCGGAGGUUCGAAGACGAUCAGAUACCGUCGUAGUUCCGACCAUAAACGAUGCCGACCGGCGAUGCGGCGGCGUUAUUCCCAUGACCCGCCGGGCAGCUUCCGGGAAACCAAAGUCUUUGGGUUCCGGGGGGAGUAUGGUUGCAAAGCUGAAACUUAAAGGAAUUGACGGAAGGGCACCACCAGGAGUGGAGCCUGCGGCUUAAUUUGACUCAACACGGGAAACCUCACCCGGCCCGGACACGGACAGGAUUGACAGAUUGAUAGCUCUUUCUCGAUUCCGUGGGUGGUGGUGCAUGGCCGUUCUUAGUUGGUGGAGCGAUUUGUCUGGUUAAUUCCGAUAACGAACGAGACUCUGGCAUGCUAACUAGUUACGCGACCCCCGAGCGGUCGGCGUCCCCCAACUUCUUAGAGGGACAAGUGGCGUUCAGCCACCCGAGAUUGA--GCAAUAACAGGUCUGUGAUGCCCUUAGAUGUCCGGGGCUGCACGCGCGCUACACUGACUGGCUCAGCGUGUGCCUACCCUACGCCGGCAGGCGCGGGUAACCCGUUGAACCCCAUUCGUGAUGGGGAUCGGGGAUUGCAAUUAUUCCCCAUGAACGAGGAAUUCCC--AGUAAGUGCGGGUCAUAAGCUUGCGUUGAUUAAGUCCCUGCCCUUUGUACACACCGCCCGUCGCUACUACCGAUUGGAUGGUUUAGUGAGGCCCUCGGAUCGGCCCCGCCGGGGUCGGCCCACGGCCCUGGCGGAGCGCUGAGAAGACGGUCGAACUUGACUAUCUAGAGGAAGUAAAAGUCGUAACAAGGUUUCCGUAGGUGAACCUGCGGAAGGAUCAUUA' + }, + { + id: '18S_Human_reference', + name: 'H.sapiens - ref', + shortname: 'Hs-Ref', + sequence: + 'UACCUGGUUGAUCCUGCCAGUAG-CAUAUGCUUGUCUCAAAGAUUAAGCCAUGCAUGUCUAAGUACGCACGGCCGG-UACAGUGAAACUGCGAAUGGCUCAUUAAAUCAGUUAUGGUUCCUUUGGUCGCUCGCUCCUCUCCUACUUGGAUAACUGUGGUAAUUCUAGAGCUAAUACAUGCCGACGGGCGCUGACCCCCUUCGCGGGGGGGAUGCGUGCAUUUAUCAGAUCAAAACCAACCCGGUCAGCCCCUCUCCGGCCCCGGCCGGGGGGCGGGCGCCGGCGGCUUUGGUGACUCUAGAUAACCUCGGGCCGAUCGCACGCCCCCCGUGGCGGCGACGACCCAUUCGAACGUCUGCCCUAUCAACUUUCGAUGGUAGUCGCCGUGCCUACCAUGGUGACCACGGGUGACGGGGAAUCAGGGUUCGAUUCCGGAGAGGGAGCCUGAGAAACGGCUACCACAUCCAAGGAAGGCAGCAGGCGCGCAAAUUACCCACUCCCGACCCGGGGAGGUAGUGACGAAAAAUAACAAUACAGGACUCUUUCGAGGCCCUGUAAUUGGAAUGAGUCCACUUUAAAUCCUUUAACGAGGAUCCAUUGGAGGGCAAGUCUGGUGCCAGCAGCCGCGGUAAUUCCAGCUCCAAUAGCGUAUAUUAAAGUUGCUGCAGUUAAAAAGCUCGUAGUUGGAUCUUGGGAGCGGGCGGGCGGUCCGCCGCGAGGCGAGCCACCGCCCGUCCCCGCCCCUUGCCUCUCGGCGCCCCCUCGAUGCUCUUAGCUGAGUGUCCCGCGGGGC--CCGAAGCGUUUACUUUGAAAAAAUUAGAGUGUUCAAAGCAGGCCCGAGCCGCCUGGAUACCGCAGCUAGGAAUAAUGGAAUAGGACCG-CGGUUCUAUUUUGUUGGUUUUCGGAACUGAGGCCAUGAUUAAGAGGGACGGCCGGGGGCAUUCGUAUUGCGCCGCUAGAGGUGAAAUUCUUGGACCGGCGCAAGACGGACCAGAGCGAAAGCAUUUGCCAAGAAUGUUUUCAUUAAUCAAGAACGAAAGUCGGAGGUUCGAAGACGAUCAGAUACCGUCGUAGUUCCGACCAUAAACGAUGCCGACCGGCGAUGCGGCGGCGUUAUUCCCAUGACCCGCCGGGCAGCUUCCGGGAAACCAAAGUCUUUGGGUUCCGGGGGGAGUAUGGUUGCAAAGCUGAAACUUAAAGGAAUUGACGGAAGGGCACCACCAGGAGUGGAGCCUGCGGCUUAAUUUGACUCAACACGGGAAACCUCACCCGGCCCGGACACGGACAGGAUUGACAGAUUGAUAGCUCUUUCUCGAUUCCGUGGGUGGUGGUGCAUGGCCGUUCUUAGUUGGUGGAGCGAUUUGUCUGGUUAAUUCCGAUAACGAACGAGACUCUGGCAUGCUAACUAGUUACGCGACCCCCGAGCGGUCGGCGUCCCCCAACUUCUUAGAGGGACAAGUGGCGUUCAGCCACCCGAGAUUGA--GCAAUAACAGGUCUGUGAUGCCCUUAGAUGUCCGGGGCUGCACGCGCGCUACACUGACUGGCUCAGCGUGUGCCUACCCUACGCCGGCAGGCGCGGGUAACCCGUUGAACCCCAUUCGUGAUGGGGAUCGGGGAUUGCAAUUAUUCCCCAUGAACGAGGGAAUUCCCGAGUAAGUGCGGGUCAUAAGCUUGCGUUGAUUAAGUCCCUGCCCUUUGUACACACCGCCCGUCGCUACUACCGAUUGGAUGGUUUAGUGAGGCCCUCGGAUCGGCCCCGCCGGGGUCGGCCCACGGCCCUGGCGGAGCGCUGAGAAGACGGUCGAACUUGACUAUCUAGAGGAAGUAAAAGUCGUAACAAGGUUUCCGUAGGUGAACCUGCGGAAGGAUCAUUA' + } + ] +} + +/** + * Component props. + */ +const props = defineProps<{ + /** The mode in which the alignment form is at landing (usually passed + * from URL). */ + initialMode: AlignmentModesType +}>() + +/** + * Vue Router instance reactive object. + */ +const router = useRouter() + +/** + * The currently selected mode. + */ +const activeMode = ref(props.initialMode) + +/** + * Title of the page, reactively updated when data is fetched. + */ +const pageTitle = computed( + () => `${_capitalize(activeMode.value)} • Alignment | SnoBoard` +) +// Bind actual page title to computed one +onMounted(() => useTitle(pageTitle)) + +/** + * Callback to change the URL when switching tab. + * @param e The event emitted when changing tab. + */ +const updateUrlQuery = (newMode: AlignmentModesType) => { + if (newMode) { + router.replace({ + name: 'alignment', + query: { mode: newMode } + }) + } +} + +/** + * The IDs of currently selected sequences. + */ +const selectedSequenceIds = ref<string[]>([]) +</script> + +<template> + <MainLayout padded> + <h1 class="mb-8 text-center text-3xl font-semibold text-slate-700"> + <icon-snoboard-alignment class="mb-1 mr-2 inline" /> + Sequences alignment + </h1> + + <AlignmentForm + v-model:current-mode-model="activeMode" + v-model:selected-ids-model="selectedSequenceIds" + class="mb-8" + @update:current-mode-model="updateUrlQuery" + /> + + <div class="my-8"> + <SequenceAlignment :alignment="ALIGNMENT" /> + </div> + </MainLayout> +</template> diff --git a/src/views/ClusterView.vue b/src/views/ClusterView.vue index 769549f121408f4d998e8c48c0bcd030c1d7dce8..a0bee4e2b1f2df59ad2b7b7e823a0d8a8518a097 100644 --- a/src/views/ClusterView.vue +++ b/src/views/ClusterView.vue @@ -16,11 +16,14 @@ import IconFa6SolidArrowUpRightFromSquare from '~icons/fa6-solid/arrow-up-right- import IconFa6SolidList from '~icons/fa6-solid/list' import IconEmojioneThinkingFace from '~icons/emojione/thinking-face' /** - * Other 3rd-party imports + * Composables imports */ -import { omit as _omit, minBy as _minBy, maxBy as _maxBy } from 'lodash-es' import { useQuery } from '@urql/vue' import { useTitle } from '@vueuse/core' +/** + * Other 3rd-party imports + */ +import { omit as _omit, minBy as _minBy, maxBy as _maxBy } from 'lodash-es' /** * Types imports */ diff --git a/src/views/DataTableView.vue b/src/views/DataTableView.vue index 235f9d03773e359d834159ddb0e4a8ff858aa7f7..acbaaac7cde290d3459849b59e20217a422ad269 100644 --- a/src/views/DataTableView.vue +++ b/src/views/DataTableView.vue @@ -25,6 +25,10 @@ import IconFa6SolidCircleQuestion from '~icons/fa6-solid/circle-question' import IconFa6SolidCircleXmark from '~icons/fa6-solid/circle-xmark' import IconFa6SolidFilter from '~icons/fa6-solid/filter' import IconSnoboardFilterWarning from '~icons/snoboard/filter-warning' +/** + * Composables imports + */ +import { useQuery } from '@urql/vue' /** * Other 3rd-party imports */ @@ -36,7 +40,6 @@ import { findLast as _findLast } from 'lodash-es' import { FilterMatchMode as primeVueFilterMatchMode } from 'primevue/api' -import { useQuery } from '@urql/vue' /** * Types imports */ @@ -265,46 +268,36 @@ const targetNameOptionsBase = computed(() => /** * Available options for target name selection, grouped. */ -const targetNameOptionsWithDisablingGroups = computed(() => +const targetNameOptionGroups = computed(() => targetNameOptionsBase.value.reduce< { label: string; children: { label: string; value: any }[] }[] - >((targetNameOptionsWithDisablingGroups, targetNameOption) => { + >((targetNameOptionGroups, targetNameOption) => { // Get index of the group of the current option - const targetNameOptionGroupIndex = - targetNameOptionsWithDisablingGroups.findIndex( - (group) => group.label === targetNameOption.groupLabel - ) + const targetNameOptionGroupIndex = targetNameOptionGroups.findIndex( + (group) => group.label === targetNameOption.groupLabel + ) // Insert option in existing group if present // Using non-null type assertion for - // `targetNameOptionsWithDisablingGroups[targetNameOptionGroupIndex]` + // `targetNameOptionGroups[targetNameOptionGroupIndex]` // because it is checked before return targetNameOptionGroupIndex !== -1 ? [ - ...targetNameOptionsWithDisablingGroups.slice( - 0, - targetNameOptionGroupIndex - ), + ...targetNameOptionGroups.slice(0, targetNameOptionGroupIndex), { - label: - targetNameOptionsWithDisablingGroups[targetNameOptionGroupIndex]! - .label, + label: targetNameOptionGroups[targetNameOptionGroupIndex]!.label, children: [ - ...targetNameOptionsWithDisablingGroups[ - targetNameOptionGroupIndex - ]!.children, + ...targetNameOptionGroups[targetNameOptionGroupIndex]!.children, { label: targetNameOption.label || '', value: targetNameOption.value } ] }, - ...targetNameOptionsWithDisablingGroups.slice( - targetNameOptionGroupIndex + 1 - ) + ...targetNameOptionGroups.slice(targetNameOptionGroupIndex + 1) ] : // Otherwise add the group with the option [ - ...targetNameOptionsWithDisablingGroups, + ...targetNameOptionGroups, { label: targetNameOption.groupLabel, children: [ @@ -733,7 +726,7 @@ const specificFilterConfigs = computed<{ columns.find((column) => column.id == 'targetName')?.field || '', filter: { matchMode: primeVueFilterMatchMode.IN, - options: targetNameOptionsWithDisablingGroups.value + options: targetNameOptionGroups.value }, grouped: true }, diff --git a/src/views/GuideView.vue b/src/views/GuideView.vue index 004dc3bc91150dd534becb3b66af2fceef5536f5..d518217bdfabf68a820f773b15c85e08684e8556 100644 --- a/src/views/GuideView.vue +++ b/src/views/GuideView.vue @@ -26,17 +26,20 @@ import IconFa6SolidCirclePlus from '~icons/fa6-solid/circle-plus' import IconFa6SolidCircleQuestion from '~icons/fa6-solid/circle-question' import IconFa6SolidCircleXmark from '~icons/fa6-solid/circle-xmark' /** - * Other 3rd-party imports + * Composables imports */ -import { uniq as _uniq } from 'lodash-es' import { useQuery } from '@urql/vue' import { useTitle } from '@vueuse/core' +/** + * Other 3rd-party imports + */ +import { uniq as _uniq } from 'lodash-es' /** * Types import */ import type { TailwindDefaultColorNameModel } from '@/typings/styleTypes' import type { InteractionCardModel } from '@/components/InteractionCard.vue' -import type { highlightGroupModel } from '@/components/SequenceBoard.vue' +import type { objectModel } from '@/components/SequenceBoard.vue' import { GraphQlType, ModifType, @@ -269,7 +272,7 @@ const filteredFacingModifications = computed(() => */ const sequenceChunks = computed(() => ({ ...guide.value?.boxConnections.edges.reduce<{ - [groupId: string]: highlightGroupModel + [groupId: string]: objectModel }>((boxes, boxConnection) => { return boxConnection.node.annotation ? { @@ -288,7 +291,7 @@ const sequenceChunks = computed(() => ({ : boxes }, {}), ...filteredFacingModifications.value?.reduce<{ - [groupId: string]: highlightGroupModel + [groupId: string]: objectModel }>( (sequenceChunks, facingModification) => ({ ...sequenceChunks, @@ -789,7 +792,7 @@ const selectedGraphicsTab = ref(GraphicsTabEnum[props.initialGraphicsPanelTab]) v-if="guide?.seq" :sequence="guide.seq.replace(/T/g, 'U')" :sequence-id="guide.id" - :highlighted-groups="sequenceChunks" + :objects="sequenceChunks" :legend-items="GUIDE_LEGEND_ITEMS" > <template #legend-item-title="{ item }"> @@ -802,7 +805,7 @@ const selectedGraphicsTab = ref(GraphicsTabEnum[props.initialGraphicsPanelTab]) </span> </template> - <template #tooltip-item="{ group }"> + <template #tooltip-item="{ object: group }"> <Chip v-if="group.type && isInEnum(group.type, ModifType)" :class="[ diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 4097e8afbf6145dfaa55ed1a0350fe6933ea8b45..65e5b081470b069e103e86ee35f6f9042a54b507 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -3,7 +3,6 @@ * Vue imports */ import { computed, ref } from 'vue' -import { useRouter } from 'vue-router' /** * Components imports */ @@ -28,9 +27,13 @@ import IconTablerCircleArrowDownFilled from '~icons/tabler/circle-arrow-down-fil import IconSnoboardGuide from '~icons/snoboard/guide' import IconSnoboardModification from '~icons/snoboard/modification' /** - * Other 3rd-party imports + * Composables imports */ import { useQuery } from '@urql/vue' +import { useRouter } from 'vue-router' +/** + * Other 3rd-party imports + */ import { uniqWith as _uniqWith, isEqual as _isEqual } from 'lodash-es' /** * Types imports. @@ -302,39 +305,39 @@ const menuItems = computed<MenuItem[]>(() => [ key: 'conservation', label: 'Conservation', items: [ - { - key: 'conservationModifications', - label: 'Modifications', - route: { - // name: 'conservation', - // query: { - // type: 'modification' - // } - }, - iconComponent: IconSnoboardModification, - disabled: true - }, + // { + // key: 'conservationModifications', + // label: 'Modifications', + // route: { + // name: 'alignment' + // // query: { + // // type: 'modification' + // // } + // }, + // iconComponent: IconSnoboardModification, + // disabled: true + // }, { key: 'conservationGuides', label: 'Guides', - route: { - // name: 'conservation', - // query: { - // type: 'modification' - // } - }, + // route: { + // name: 'alignment', + // query: { + // mode: 'guide' + // } + // }, iconComponent: IconSnoboardGuide, disabled: true }, { key: 'conservationTargets', label: 'Targets', - route: { - // name: 'conservation', - // query: { - // type: 'modification' - // } - }, + // route: { + // name: 'alignment', + // query: { + // mode: 'target' + // } + // }, iconComponent: IconFa6SolidBullseye, disabled: true } diff --git a/src/views/LegalView.vue b/src/views/LegalView.vue index 03dc9222ea51160ba7ae273db20b5f656b0a6def..b0b28e258d3759391f5c12c4dd29c81027473359 100644 --- a/src/views/LegalView.vue +++ b/src/views/LegalView.vue @@ -9,7 +9,7 @@ import { computed, toRef } from 'vue' import MainLayout from '@/layouts/MainLayout.vue' import BaseRenderedMarkdown from '@/components/BaseRenderedMarkdown.vue' /** - * Other 3rd-party imports + * Composables imports */ import { useQuery } from '@urql/vue' /** diff --git a/src/views/ModificationView.vue b/src/views/ModificationView.vue index db242e8a601882df0f82378480acb12048df1862..ff226c126b3c5ad16787c5af3f68c7eb58d93237 100644 --- a/src/views/ModificationView.vue +++ b/src/views/ModificationView.vue @@ -18,10 +18,13 @@ import Tag from 'primevue/tag' import IconFa6SolidCircleInfo from '~icons/fa6-solid/circle-info' import IconFa6SolidDrawPolygon from '~icons/fa6-solid/draw-polygon' /** - * Other 3rd-party imports + * Composables imports */ import { useQuery } from '@urql/vue' import { useTitle } from '@vueuse/core' +/** + * Other 3rd-party imports + */ import { sortBy as _sortBy } from 'lodash-es' /** * Types imports diff --git a/src/views/OrganismView.vue b/src/views/OrganismView.vue index 19b000064e708a9068ecdd61b5bb30adb513137f..3379ae7e0bb87928172583f08f2ad8bf12a74306 100644 --- a/src/views/OrganismView.vue +++ b/src/views/OrganismView.vue @@ -25,10 +25,13 @@ import IconFa6SolidFileLines from '~icons/fa6-solid/file-lines' import IconCarbonAccessibilityColor from '~icons/carbon/accessibility-color' import IconCarbonAccessibilityColorFilled from '~icons/carbon/accessibility-color-filled' /** - * Other 3rd-party imports + * Composables imports */ import { useQuery } from '@urql/vue' import { useTitle } from '@vueuse/core' +/** + * Other 3rd-party imports + */ import { sortBy as _sortBy, capitalize as _capitalize, diff --git a/src/views/SelectionView.vue b/src/views/SelectionView.vue index b6b100ac642f78a99f519fbfcd93d8f3485add9e..cf4fd8e6607ebbc3c2cf00cd2fccb18370d86d7f 100644 --- a/src/views/SelectionView.vue +++ b/src/views/SelectionView.vue @@ -3,48 +3,39 @@ * Vue imports */ import { computed, onMounted, ref } from 'vue' -import { useRouter } from 'vue-router' /** * Components imports */ import MainLayout from '@/layouts/MainLayout.vue' -import ModificationSelectionForm, { - type ModificationSelectionModel -} from '@/components/ModificationSelectionForm.vue' -import GuideSelectionForm, { - type GuideSelectionModel -} from '@/components/GuideSelectionForm.vue' -import TargetSelectionForm, { - type TargetSelectionModel -} from '@/components/TargetSelectionForm.vue' -import TabView, { type TabViewChangeEvent } from 'primevue/tabview' -import TabPanel from 'primevue/tabpanel' -import Card from 'primevue/card' +import SelectionForm from '@/components/SelectionForm.vue' import Button from 'primevue/button' +import IconFa6SolidSliders from '~icons/fa6-solid/sliders' import IconFa6SolidTable from '~icons/fa6-solid/table' import IconFa6SolidBullseye from '~icons/fa6-solid/bullseye' import IconFa6SolidCircleNodes from '~icons/fa6-solid/circle-nodes' import IconSnoboardSequence from '~icons/snoboard/sequence' /** - * Other 3rd-party imports + * Composables imports */ +import { useRouter } from 'vue-router' import { useTitle } from '@vueuse/core' +/** + * Other 3rd-party imports + */ import { capitalize as _capitalize } from 'lodash-es' +/** + * Types imports + */ +import type { SelectionModel } from '@/components/SelectionForm.vue' /** * Utils imports */ -import { SELECTION_FORM_MODES } from '@/utils/constant' -import { getColorWithOptionalShade } from '@/utils/colors' +import { SELECTION_MODES } from '@/utils/constant' /** * Type of data to select. */ -export type SelectionFormModesType = (typeof SELECTION_FORM_MODES)[number] -enum TabEnum { - Modification = 0, - Guide, - Target -} +export type SelectionModesType = (typeof SELECTION_MODES)[number] /** * A selection option. @@ -99,7 +90,9 @@ const TABLE_COLUMNS_BY_MODE = { * Component props. */ const props = defineProps<{ - mode: SelectionFormModesType + /** The mode in which the selection form is at landing (usually passed + * from URL). */ + initialMode: SelectionModesType }>() /** @@ -108,16 +101,9 @@ const props = defineProps<{ const router = useRouter() /** - * The index of the tab currently selected, reactively updated when changed. - */ -const activeTabIndex = ref<TabEnum>( - SELECTION_FORM_MODES.findIndex((mode) => mode === props.mode) -) - -/** - * The mode corresponding to the tab currently selected. + * The currently selected mode. */ -const activeMode = computed(() => SELECTION_FORM_MODES[activeTabIndex.value]) +const activeMode = ref(props.initialMode) /** * Title of the page, reactively updated when data is fetched. @@ -132,21 +118,19 @@ onMounted(() => useTitle(pageTitle)) * Callback to change the URL when switching tab. * @param e The event emitted when changing tab. */ -const updateUrlQuery = (e: TabViewChangeEvent) => { - const newMode = SELECTION_FORM_MODES[e.index] +const updateUrlQuery = (newMode: SelectionModesType) => { if (newMode) { - router.replace({ name: 'selection', query: { mode: newMode } }) + router.replace({ + name: 'selection', + query: { mode: newMode } + }) } } /** * The current selection. */ -const selection = ref<{ - modification?: ModificationSelectionModel - guide?: GuideSelectionModel - target?: TargetSelectionModel -}>({ +const selection = ref<SelectionModel>({ modification: undefined, guide: undefined, target: undefined @@ -159,7 +143,7 @@ const selection = ref<{ const tableFilters = computed(() => ({ guideSubclass: activeMode.value === 'guide' - ? selection.value[activeMode.value]?.guideSubclasses + ? selection.value.guide?.guideSubclassLabels : undefined, targetName: selection.value[activeMode.value]?.targetNames, modificationType: selection.value[activeMode.value]?.modificationTypes, @@ -191,152 +175,105 @@ const onlyTargetIdActiveMode = computed(() => <template> <MainLayout padded> - <h1 class="text-center text-3xl font-semibold text-slate-700"> + <h1 class="mb-8 text-center text-3xl font-semibold text-slate-700"> + <icon-fa6-solid-sliders class="mb-1 mr-2 inline" /> Advanced selection </h1> - <Card - class="mx-16 !rounded-2xl border-none !shadow-none" - :pt="{ - footer: { - style: { - display: 'flex', - justifyContent: 'center' + <SelectionForm + v-model:current-mode-model="activeMode" + v-model:selection-model="selection" + v-model:only-target-id-by-mode-model="onlyTargetIdByMode" + @update:current-mode-model="updateUrlQuery" + /> + + <div class="mt-8 flex justify-center gap-4"> + <RouterLink + :to="{ + name: 'table', + query: { + columns: TABLE_COLUMNS_BY_MODE[activeMode], + ...tableFilters } - } - }" - > - <template #content> - <TabView - v-model:active-index="activeTabIndex" - :pt="{ - nav: { - style: { - justifyContent: 'center', - marginBottom: '2rem', - fontSize: '1.25em', - background: `linear-gradient(white 0 0) padding-box, - linear-gradient(90deg, - ${getColorWithOptionalShade('slate', '300')}00 10%, - ${getColorWithOptionalShade('slate', '300')} 15%, - ${getColorWithOptionalShade('slate', '300')} 85%, - ${getColorWithOptionalShade('slate', '300')}00 90%) - border-box`, - borderStyle: 'dashed', - borderColor: 'white' + }" + > + <Button> + <icon-fa6-solid-table /> + <span class="ml-2">View in table</span> + </Button> + </RouterLink> + + <div + v-if="activeMode === 'target'" + v-tooltip.top=" + !onlyTargetIdByMode.target && { + value: 'Select a unique target/species pair', + pt: { + text: { + style: { + textAlign: 'center', + fontStyle: 'italic' + } } } + } + " + > + <RouterLink + :to="{ + name: 'targetDetails', + query: { + id: onlyTargetIdByMode.target + }, + hash: '#sequence-panel' }" - @tab-change="updateUrlQuery" + :class="{ 'pointer-events-none': !onlyTargetIdByMode.target }" > - <TabPanel header="Modification"> - <ModificationSelectionForm - v-model:selection-model="selection.modification" - v-model:only-target-id="onlyTargetIdByMode.modification" - /> - </TabPanel> - <TabPanel header="Guide"> - <GuideSelectionForm v-model:selection-model="selection.guide" /> - </TabPanel> - <TabPanel header="Target"> - <TargetSelectionForm - v-model:selection-model="selection.target" - v-model:only-target-id="onlyTargetIdByMode.target" - /> - </TabPanel> - </TabView> - </template> + <Button :disabled="!onlyTargetIdByMode.target"> + <icon-snoboard-sequence /> + <span class="ml-2">View sequence</span> + </Button> + </RouterLink> + </div> - <template #footer> - <div class="flex gap-4"> - <RouterLink - :to="{ - name: 'table', - query: { - columns: TABLE_COLUMNS_BY_MODE[activeMode], - ...tableFilters - } - }" - > - <Button> - <icon-fa6-solid-table /> - <span class="ml-2">View in table</span> - </Button> - </RouterLink> - - <div - v-if="activeMode === 'target'" - v-tooltip.top=" - !onlyTargetIdByMode.target && { - value: 'Select a unique target/species pair', - pt: { - text: { - style: { - textAlign: 'center', - fontStyle: 'italic' - } - } + <div + v-if="activeMode === 'target' || activeMode === 'modification'" + v-tooltip.top=" + !onlyTargetIdActiveMode && { + value: 'Select a unique target/species pair', + pt: { + text: { + style: { + textAlign: 'center', + fontStyle: 'italic' } } - " - > - <RouterLink - :to="{ - name: 'targetDetails', - query: { - id: onlyTargetIdByMode.target - }, - hash: '#sequence-panel' - }" - :class="{ 'pointer-events-none': !onlyTargetIdByMode.target }" - > - <Button :disabled="!onlyTargetIdByMode.target"> - <icon-snoboard-sequence /> - <span class="ml-2">View sequence</span> - </Button> - </RouterLink> - </div> - - <div - v-if="activeMode === 'target' || activeMode === 'modification'" - v-tooltip.top=" - !onlyTargetIdActiveMode && { - value: 'Select a unique target/species pair', - pt: { - text: { - style: { - textAlign: 'center', - fontStyle: 'italic' - } - } - } - } - " - > - <RouterLink - :to="{ - name: 'targetDetails', - query: { - id: onlyTargetIdActiveMode, - graphicsTab: '2d-struct' - }, - hash: '#graphics-panel' - }" - :class="{ 'pointer-events-none': !onlyTargetIdActiveMode }" - > - <Button :disabled="!onlyTargetIdActiveMode"> - <icon-fa6-solid-bullseye v-if="activeMode === 'modification'" /> - <icon-fa6-solid-circle-nodes v-else /> - <span class="ml-2"> - View - {{ activeMode === 'modification' ? 'on target' : '' }} - structure - </span> - </Button> - </RouterLink> - </div> - </div> - </template> - </Card> + } + } + " + > + <RouterLink + :to="{ + name: 'targetDetails', + query: { + id: onlyTargetIdActiveMode, + graphicsTab: '2d-struct' + }, + hash: '#graphics-panel' + }" + :class="{ 'pointer-events-none': !onlyTargetIdActiveMode }" + > + <Button :disabled="!onlyTargetIdActiveMode"> + <icon-fa6-solid-bullseye v-if="activeMode === 'modification'" /> + <icon-fa6-solid-circle-nodes v-else /> + <span class="ml-2"> + View + {{ activeMode === 'modification' ? 'on target' : '' }} + structure + </span> + </Button> + </RouterLink> + </div> + </div> </MainLayout> </template> diff --git a/src/views/StatisticsView.vue b/src/views/StatisticsView.vue index 9729c7818eccb53e49cf6d35c2c67d0ad6b89293..e2459d6fcece7382dc3341ccc00eb976c8b938b9 100644 --- a/src/views/StatisticsView.vue +++ b/src/views/StatisticsView.vue @@ -17,9 +17,12 @@ import IconCarbonAccessibilityColorFilled from '~icons/carbon/accessibility-colo import IconSnoboardModification from '~icons/snoboard/modification' import IconSnoboardGuide from '~icons/snoboard/guide' /** - * Other 3rd-party imports + * Composables imports */ import { useQuery } from '@urql/vue' +/** + * Other 3rd-party imports + */ import { countBy as _countBy } from 'lodash-es' import pattern from 'patternomaly' /** diff --git a/src/views/TargetView.vue b/src/views/TargetView.vue index b9bf64cd3581accc925443b57b8c9bd5a2c86933..6c48a2fe5f3a559ffe67b8f0cc6531ebc9c2504f 100644 --- a/src/views/TargetView.vue +++ b/src/views/TargetView.vue @@ -28,18 +28,25 @@ import IconFa6SolidCirclePlus from '~icons/fa6-solid/circle-plus' import IconFa6SolidCircleQuestion from '~icons/fa6-solid/circle-question' import IconFa6SolidCircleXmark from '~icons/fa6-solid/circle-xmark' /** - * Other 3rd-party imports + * Composables imports */ -import { find as _find, sortBy as _sortBy } from 'lodash-es' import { useQuery } from '@urql/vue' import { useFetch, useTitle } from '@vueuse/core' +/** + * Other 3rd-party imports + */ +import { + find as _find, + sortBy as _sortBy, + mapValues as _mapValues +} from 'lodash-es' /** * Types import */ import type { TailwindDefaultColorNameModel } from '@/typings/styleTypes' import type { InteractionCardModel } from '@/components/InteractionCard.vue' -import type { C4GGraphModel, C4GNodeModel } from '@/typings/Codev4GraphFormat' -import type { highlightGroupModel } from '@/components/SequenceBoard.vue' +import type { C4GGraphModel } from '@/typings/Codev4GraphFormat' +import type { objectModel } from '@/components/SequenceBoard.vue' import { GraphQlType, ModifType, @@ -173,37 +180,28 @@ const secondaryStructureWithModificationsMetadata = secondaryStructure.value && { graph: { ...secondaryStructure.value?.graph, - nodes: Object.entries( - secondaryStructure.value?.graph.nodes || {} - ).reduce<{ [nodeId: string]: C4GNodeModel }>( - (nodes, [nodeId, node]) => { - const nodeModification = _find(target.value?.modifications, [ - 'position', - _find(node.metadata?.data, ['label', 'nucleotidePosition']) - ?.value - ]) - return nodeModification - ? { - ...nodes, - [`${nodeId}`]: { - ...node, - metadata: { - ...node.metadata, - data: [ - ...(node.metadata?.data || []), - { - label: 'modification', - type: 'number', - value: nodeModification - } - ] + nodes: _mapValues(secondaryStructure.value?.graph.nodes, (node) => { + const nodeModification = _find(target.value?.modifications, [ + 'position', + _find(node.metadata?.data, ['label', 'nucleotidePosition'])?.value + ]) + return nodeModification + ? { + ...node, + metadata: { + ...node.metadata, + data: [ + ...(node.metadata?.data || []), + { + label: 'modification', + type: 'number', + value: nodeModification } - } + ] } - : { ...nodes, [`${nodeId}`]: node } - }, - {} - ) + } + : node + }) } } ) @@ -313,7 +311,7 @@ const filteredModifications = computed( */ const sequenceChunks = computed(() => filteredModifications.value.reduce<{ - [groupId: string]: highlightGroupModel + [groupId: string]: objectModel }>( (sequenceChunks, modification) => ({ ...sequenceChunks, @@ -698,7 +696,7 @@ const selectedGraphicsTab = ref(GraphicsTabEnum[props.initialGraphicsPanelTab]) v-if="target?.seq" :sequence="target.seq.replace(/T/g, 'U')" :sequence-id="target.id" - :highlighted-groups="sequenceChunks" + :objects="sequenceChunks" :legend-items="TARGET_LEGEND_ITEMS" > <template #legend-item-title="{ item }"> @@ -710,7 +708,7 @@ const selectedGraphicsTab = ref(GraphicsTabEnum[props.initialGraphicsPanelTab]) /> </template> - <template #tooltip-item="{ group }"> + <template #tooltip-item="{ object: group }"> <Chip v-if="group.type && isInEnum(group.type, ModifType)" :class="[ diff --git a/tailwind.config.js b/tailwind.config.js index 0b7312ea8f7f42178c407698151f0f38fc73ec3a..80e2887f6afbd23c8c3a1c4c5e31e0870390afa0 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -53,6 +53,8 @@ export default { pattern: /^stroke-[a-z]+-600$/ }, 'brightness-90', // ChromosomeMagnify - 'cursor-pointer' // ChromosomeMagnify + 'cursor-pointer', // ChromosomeMagnify + 'text-xs', // SecondaryStructure, SequenceAlignment (tooltip) + 'text-center' // SecondaryStructure, SequenceAlignment (tooltip) ] }