diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7769df441a6ad701ee8d885ef6ea3bf107877962..07f7e28258ed40bb22033a0be5cda763a140f986 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -42,6 +42,7 @@ variables: CI_DEBUG_TRACE: "false" TEAM_ID: "df" DF_API_PREFIX: "dfapi" + DISPLAY_MESSAGE: "false" script: - > helm upgrade --install $CI_PROJECT_NAME-$CI_ENVIRONMENT_NAME ./deploy/ --namespace=${KUBE_NAMESPACE} @@ -69,6 +70,7 @@ variables: --set nuxt.image.repository="$CI_REGISTRY_IMAGE/$NUXT_IMG_NAME" --set nuxt.image.tag="$CI_COMMIT_SHORT_SHA" --set nuxt.dfApiPrefix="/${DF_API_PREFIX}" + --set nuxt.displayMessage="${DISPLAY_MESSAGE}" --set env="$ENV" --values deploy/values.yaml --values deploy/values.${ENV:-development}.yaml @@ -141,6 +143,7 @@ deploy:dev: KUBE_NAMESPACE: "defense-finder-dev" PUBLIC_URL: "defense-finder.dev.pasteur.cloud" ENV: "development" + DISPLAY_MESSAGE: "true" CI_DEBUG_TRACE: "true" environment: name: k8sdev-01 @@ -160,6 +163,8 @@ deploy:prod: PUBLIC_URL: "defensefinder.mdmlab.fr" ENV: "production" CI_DEBUG_TRACE: "false" + DISPLAY_MESSAGE: "true" + environment: name: k8sprod-02 url: "https://defense-finder.pasteur.cloud" diff --git a/backend/analysis/migrations/0009_analysis_from_nt.py b/backend/analysis/migrations/0009_analysis_from_nt.py new file mode 100644 index 0000000000000000000000000000000000000000..dad4e252d57dfa9cbbe6c303670bdc322e51e3a2 --- /dev/null +++ b/backend/analysis/migrations/0009_analysis_from_nt.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.3 on 2024-06-28 08:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('analysis', '0008_analysis_stderr'), + ] + + operations = [ + migrations.AddField( + model_name='analysis', + name='from_nt', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/analysis/migrations/0010_analysis_input_name_alter_analysis_name.py b/backend/analysis/migrations/0010_analysis_input_name_alter_analysis_name.py new file mode 100644 index 0000000000000000000000000000000000000000..a765467b77f9abbd4b1a8ab8027f963a60d9c653 --- /dev/null +++ b/backend/analysis/migrations/0010_analysis_input_name_alter_analysis_name.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.3 on 2024-07-01 13:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('analysis', '0009_analysis_from_nt'), + ] + + operations = [ + migrations.AddField( + model_name='analysis', + name='input_name', + field=models.CharField(default='to_change', max_length=256), + preserve_default=False, + ), + migrations.AlterField( + model_name='analysis', + name='name', + field=models.CharField(max_length=256), + ), + ] diff --git a/backend/analysis/migrations/0011_set_input_name.py b/backend/analysis/migrations/0011_set_input_name.py new file mode 100644 index 0000000000000000000000000000000000000000..8b0bed73ef2d468baa9a3811375b328df7c2249a --- /dev/null +++ b/backend/analysis/migrations/0011_set_input_name.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.3 on 2024-07-01 13:37 + +from django.db import migrations, models + + +def copy_field(apps, schema): + analysis = apps.get_model("analysis", "analysis") + for analys in analysis.objects.all(): + analys.input_name = analys.name + analys.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("analysis", "0010_analysis_input_name_alter_analysis_name"), + ] + + operations = [ + migrations.RunPython(copy_field), + ] diff --git a/backend/analysis/models.py b/backend/analysis/models.py index 3793948d213a390958c31a64f926e33ea4aaf1c2..a3c831b6a38b304c271aa0b13d56fccbdca04692 100644 --- a/backend/analysis/models.py +++ b/backend/analysis/models.py @@ -82,6 +82,7 @@ class AnalysisWorkflow(Workflow): try: analysis = Analysis( name=history_name, + input_name=history_name, galaxy_id=galaxy_inv.id, galaxy_state=galaxy_inv.state, analysis_workflow=self, @@ -113,21 +114,25 @@ class AnalysisWorkflow(Workflow): datamap = dict() for i, file_path in enumerate(input_files): - upload_response = history.upload_file(file_path) + upload_response = history.upload_file(file_path, file_name=history_name) datamap[i] = {"id": upload_response.id, "src": "hda"} self.analysis_owner.obj_gi.gi.histories.update_dataset( - history.galaxy_id, upload_response.id, tags=["sequences"] + history.galaxy_id, + upload_response.id, + tags=["sequences"], ) return history, datamap, params class Analysis(Invocation): - name = models.CharField(max_length=50) + name = models.CharField(max_length=256) + input_name = models.CharField(max_length=256) analysis_workflow = models.ForeignKey(AnalysisWorkflow, on_delete=models.CASCADE) analysis_history = models.ForeignKey(AnalysisHistory, on_delete=models.CASCADE) params = models.JSONField() datamap = models.JSONField() stderr = models.TextField(blank=True) + from_nt = models.BooleanField(default=False) class Meta: ordering = ["-create_time"] @@ -281,7 +286,8 @@ class Analysis(Invocation): self.save() def read_fasta_file(self, file_path, isFromNt=False): - + self.from_nt = isFromNt + self.save() # if is from Nt, need to sum prot length. # In order to get proteins that belongs to same contig # just remove (_\d+) to the id @@ -289,24 +295,24 @@ class Analysis(Invocation): sequences = [] if file_path is not None: with open(file_path) as handle: - current_contig = None - offset = 0 - last_prot_end = 0 + # current_contig = None + # offset = 0 + # last_prot_end = 0 for record in SeqIO.parse(handle, "fasta"): prot = {"id": record.id, "length": len(record), "strand": None} # print(len(record)) - if isFromNt: - contig = "-".join(prot["id"].split("_")[0:-1]) - if current_contig is None or contig != current_contig: - # print(contig) - - current_contig = contig - if current_contig is not None: - new_offset = int(last_prot_end) - offset = new_offset - if last_prot_end > 99999999: - return sequences + # if isFromNt: + # contig = "-".join(prot["id"].split("_")[0:-1]) + # if current_contig is None or contig != current_contig: + # # print(contig) + + # current_contig = contig + # if current_contig is not None: + # # new_offset = int(last_prot_end) + # # offset = new_offset + # if last_prot_end > 99999999: + # return sequences description_list = record.description.split(" # ") @@ -319,14 +325,14 @@ class Analysis(Invocation): prot["strand"] = strand else: strand = None - if isFromNt: - prot["start"] = offset + start - prot["end"] = offset + end - last_prot_end = int(prot["end"]) - - else: - prot["start"] = start - prot["end"] = end + # if isFromNt: + # prot["start"] = offset + start + # prot["end"] = offset + end + # last_prot_end = int(prot["end"]) + + # else: + prot["start"] = start + prot["end"] = end sequences.append(prot) return sequences diff --git a/deploy/charts/nuxt/templates/deployment.yaml b/deploy/charts/nuxt/templates/deployment.yaml index 38819ab1b156572aa4d92a0df66981b5c272dbc1..c94c04465144711c2dc5c766deb5090ef001b2a3 100644 --- a/deploy/charts/nuxt/templates/deployment.yaml +++ b/deploy/charts/nuxt/templates/deployment.yaml @@ -46,6 +46,8 @@ spec: value: {{ .Values.dfApiPrefix }} - name: NUXT_PUBLIC_DF_API_PREFIX value: {{ .Values.dfApiPrefix }} + - name: NUXT_PUBLIC_DISPLAY_MESSAGE + value: {{ .Values.displayMessage | quote}} livenessProbe: httpGet: path: /api/probe diff --git a/deploy/charts/nuxt/values.yaml b/deploy/charts/nuxt/values.yaml index c9726c2525d41957764dd8beb7d65328077d4d14..9e24642211e4661ce365212a7c779f5b5cda4673 100644 --- a/deploy/charts/nuxt/values.yaml +++ b/deploy/charts/nuxt/values.yaml @@ -85,4 +85,5 @@ tolerations: [] affinity: {} dfApiPrefix: "/dfapi" -backendHostFromServer: "" \ No newline at end of file +backendHostFromServer: "" +displayMessage: "false" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index a977aef72a61581f246027a8774be1d77f769da6..c9a566ba609b327c282146071d1b3471d30fb6f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,6 +51,7 @@ services: NUXT_SERVER_SIDE_API_BASE_URL: "http://defense-finder-api:8000/" NUXT_DF_API_PREFIX: "/dfapi" NUXT_PUBLIC_DF_API_PREFIX: "/dfapi" + NUXT_PUBLIC_DISPLAY_MESSAGE: "true" labels: - "traefik.enable=true" diff --git a/frontend/components/AnalysisList.vue b/frontend/components/AnalysisList.vue index ed529ac364a00dd182ad07fdfd36f7af078475b4..3149eb6da998362193fe386bfdb3606173f7daab 100644 --- a/frontend/components/AnalysisList.vue +++ b/frontend/components/AnalysisList.vue @@ -68,7 +68,7 @@ async function goToAnalysis(analysisId: number) { const analysisPendingsVal = toValue(analysisPendings) try { analysisPendingsVal[analysisId] = true - await navigateTo(`/analyses/${analysisId}/systems`) + await navigateTo(`/analyses/${analysisId}`) } catch (error) { throw createError("Unable to get analysis data") } diff --git a/frontend/components/AnalysisResultDataTable.vue b/frontend/components/AnalysisResultDataTable.vue index ca92d327e4cccfc7f2d88c841390c4043d9ef745..423921ae0602bd1dd304fd077ffec303b3277dc2 100644 --- a/frontend/components/AnalysisResultDataTable.vue +++ b/frontend/components/AnalysisResultDataTable.vue @@ -5,6 +5,9 @@ import CollapsibleChips from './CollapsibleChips.vue'; interface Props { items: any[] headers: Object[] + contig: string + groupBy?: { key: string, order: string }[] | boolean + } const props = defineProps<Props>() @@ -31,31 +34,76 @@ function namesToCollapsibleChips(names: string[]) { } +const dataTableProps = computed(() => { + let tableProps: { + headers: Object[] + items: any[] + 'item-value': string + search: string + 'group-by'?: { key: string, order: string }[] | boolean + } = { + headers: toValue(props.headers), + items: toValue(computedItems), + "item-value": "name", + search: toValue(search) + } + if (props.groupBy && typeof props.groupBy !== 'boolean') { + tableProps = { ...tableProps, 'group-by': props.groupBy } + } + return tableProps + +}) + + </script> <template> - <v-card v-if="items" class="mt-5" flat> + <v-card v-if="items" flat color="transparent"> <v-card-title> <v-text-field v-model="search" append-icon="mdi-magnify" label="Search" single-line hide-details></v-text-field></v-card-title> <v-card-text> - <v-data-table v-model:items-per-page="itemsPerPage" :headers="headers" :items="computedItems" item-value="name" - :search="search"> + <v-data-table v-model:items-per-page="itemsPerPage" v-bind="dataTableProps"> <template #[`item.type`]="{ item }"> - <v-chip :href="item?.href ? item.href : undefined" target="_blank" color="info"> + <v-chip :href="item?.href ? item.href : undefined" target="_blank"> {{ item.type }} </v-chip> </template> <template #[`item.hit_id`]="{ item }"> - <v-chip color="info" @click="setSelectedProtein(item.hit_id)"> + <v-chip color="info" @click="setSelectedProtein(item.hit_id, item.replicon)"> {{ item.hit_id }} </v-chip> </template> - + <template #[`item.sys_id`]="{ item }"> + <v-chip color="info" @click="setSelectedProtein(item.protein_in_syst[0], item.replicon)"> + {{ item.sys_id }} + </v-chip> + </template> <template #[`item.protein_in_syst`]="{ item }"> - <CollapsibleChips :items="namesToCollapsibleChips(item.protein_in_syst)"> + <CollapsibleChips :contig="item.replicon" :items="namesToCollapsibleChips(item.protein_in_syst)"> </CollapsibleChips> </template> + <template #header.data-table-group> + <div>Replicon/contig</div> + </template> + + + <!-- workaround to make it expanded default (NOT WORKING) --> + <!-- <template #group-header="{ item, columns, toggleGroup, isGroupOpen }"> + <template :ref="(el) => { + if (!isGroupOpen(item)) + toggleGroup(item); + }"></template> + <tr> + <td :colspan="columns.length"> + <v-btn :icon="isGroupOpen(item) ? '$expand' : '$next'" size="small" variant="text" + @click="toggleGroup(item)" /> + {{ item.value}} ({{ + item.items.length }}) + </td> + </tr> + </template> --> + </v-data-table> diff --git a/frontend/components/CollapsibleChips.vue b/frontend/components/CollapsibleChips.vue index bc2c95c55320d73c1cc7bc07808506b926562900..9f4053b9c710a66f35954becbfb66cbaa9915cad 100644 --- a/frontend/components/CollapsibleChips.vue +++ b/frontend/components/CollapsibleChips.vue @@ -4,16 +4,20 @@ interface item { title: string; href?: string | undefined + } export interface Props { items: item[]; itemsToDisplay?: number; + contig?: string | undefined } + const props = withDefaults(defineProps<Props>(), { items: () => [], itemsToDisplay: 1, + contig: undefined }); @@ -29,17 +33,20 @@ const show = ref(false); <span v-if="show" class="d-flex flex-wrap align-center justify-start"> <template v-if="items.length > itemsToDisplay"> <template v-for="item in items" :key="item.title"> - <v-chip color="info" class="mr-1 my-1 align-self-center" size="small" @click="setSelectedProtein(item.title)"> + <v-chip color="info" class="mr-1 my-1 align-self-center" size="small" + @click="setSelectedProtein(item.title, props.contig)"> {{ item.title }} </v-chip> </template> </template> - <v-btn v-if="itemsToDisplay < items.length" variant="text" :icon="'mdi-chevron-up'" @click="show = !show"></v-btn> + <v-btn v-if="itemsToDisplay < items.length" variant="text" :icon="'mdi-chevron-up'" + @click="show = !show"></v-btn> </span> <span v-else class="d-flex flex-wrap align-center justify-start"> <template v-for="(item, index) in items" :key="item.title"> - <v-chip v-if="index < itemsToDisplay || itemsToDisplay < 0 || items.length - itemsToDisplay === 1" color="info" - class="mr-1 my-1 align-self-center" size="small" @click="setSelectedProtein(item.title)"> + <v-chip v-if="index < itemsToDisplay || itemsToDisplay < 0 || items.length - itemsToDisplay === 1" + color="info" class="mr-1 my-1 align-self-center" size="small" + @click="console.log(props.contig); setSelectedProtein(item.title, props.contig)"> {{ item.title }} </v-chip> <template v-if="index === itemsToDisplay && items.length - itemsToDisplay > 1"> diff --git a/frontend/components/Main.vue b/frontend/components/Main.vue index 7664303fed1bccbe510be6b3844aa28feefb1c4e..439555df1dcbd58ae1e58d0579ac6ffb3f5d7470 100644 --- a/frontend/components/Main.vue +++ b/frontend/components/Main.vue @@ -42,6 +42,10 @@ function onScroll() { <v-col cols="auto"> <v-card flat color="transparent" :min-width="computedMinWidth" :max-width="props.fluid ? undefined : maxWidth"> + <v-alert v-if="runtimeConfig.public.displayMessage" type="warning" variant="tonal" border="start" prominent> + Following an update on July 3, 2024, some analyses conducted before this date may display unusual + results. If this occurs, simply rerun the analysis to resolve the issue + </v-alert> <slot /> </v-card> </v-col> diff --git a/frontend/components/SequenceBrowser.vue b/frontend/components/SequenceBrowser.vue new file mode 100644 index 0000000000000000000000000000000000000000..bf85dc98915ab8eb68c2ca4a0d7d066f25f1f61b --- /dev/null +++ b/frontend/components/SequenceBrowser.vue @@ -0,0 +1,372 @@ +<script setup lang="ts"> +import * as d3 from "d3"; + +import { useElementSize, useThrottleFn } from '@vueuse/core' +import type { Protein } from "~/types"; + +interface Props { + sequences: { name: string, defenseSystemHits: string, hmmerHits: string, proteins: Protein[] }, + fromNt: boolean + analysisName: string + canClose: boolean +} +const props = defineProps<Props>() + +const height = ref(300) +const margin = ref({ + marginLeft: 10, + marginRight: 10, + marginBottom: 10, + marginTop: 0, +}) + +const { selectedProtein } = useSelectedProtein() +const { serialize } = useSerialize() +const { rasterize } = useRasterize() +const { download } = useDownloadBlob() +const proteinIndex = computed(() => { + return new Map(props.sequences.proteins.map((d, i, arr) => { + return [d.id, { ...d }] + })) + +}) + + + +const emit = defineEmits(['close']) +// const tab = ref("systems") +const gbContainer = ref<ComponentPublicInstance | null>(null) +const svgRef = ref<ComponentPublicInstance | null>(null) +const figureRef = ref<ComponentPublicInstance | null>(null) + + +const gbContainerWidth = ref(useElementSize(gbContainer, { width: 500, height: 0 }, { box: 'border-box' }).width) +type ColorScale = (value: string | number) => string +const color = computed<ColorScale>(() => { + return d3.scaleOrdinal(["default", undefined, "df", "hmmer"], d3.schemeTableau10) +}) +const computedWidth = computed(() => { + return gbContainerWidth.value +}) +const displayHmmerHits = ref<boolean>(true) + + +const innerWidth = computed(() => { + const { marginLeft, marginRight } = toValue(margin) + return computedWidth.value - marginLeft - marginRight +}) +const innerHeigth = computed(() => { + const { marginBottom } = toValue(margin) + + return height.value - marginBottom +}) + + + +const minRange = ref(0) +const maxRange = ref(innerWidth.value) +const domain = ref([0, 10000]) +// const range = ref() +const xScale = ref(d3.scaleLinear() + .domain(domain.value) + .range([minRange.value, maxRange.value]) +); +const yScale = ref(d3.scaleLinear() + .domain([-1, 1]) + .range([0, innerHeigth.value])); + + +const computedProteins = computed(() => { + const { marginLeft, marginBottom } = toValue(margin) + + const newData = props.sequences.proteins.filter(gene => { + const { start, end } = gene + const [scaleStart, scaleEnd] = xScale.value.domain() + + return start <= scaleEnd && end >= scaleStart + + }).map(gene => { + const width = xScale.value(gene.end) - xScale.value(gene.start) + const height = yScale.value(-0.75) + const x = xScale.value(gene.start) + marginLeft + const y = yScale.value(-0.75) - marginBottom + + return { + ...gene, + width, + height, + x, + y + } + }) + return newData +}) + +const computedXUnit = computed(() => { + if (props.sequences.proteins) { + if (props.sequences.proteins[0].start === null && props.sequences.proteins[0].end === null) { + return "aa" + } + else { return "bp" } + } +}) + +function drawGene({ width, height, strand }: { width: number, height: number, strand: number }) { + const context = d3.path() + const halfHeight = height / 2 + const isWidthLonger = halfHeight < width + if (strand < 0) { + context.moveTo(0, halfHeight) + if (isWidthLonger) context.lineTo(halfHeight, 0) + context.lineTo(width, 0) + context.lineTo(width, height) + if (isWidthLonger) context.lineTo(halfHeight, height) + context.closePath() + } else if (strand > 0) { + context.moveTo(0, 0) + if (isWidthLonger) context.lineTo(width - halfHeight, 0) + context.lineTo(width, halfHeight) + if (isWidthLonger) context.lineTo(width - halfHeight, height) + context.lineTo(0, height) + context.closePath() + } + else { + context.moveTo(0, 0) + context.lineTo(width, 0) + context.lineTo(width, height) + context.lineTo(0, height) + context.closePath() + } + return context +} +function positionText(selection) { + selection.each(function (d) { + const textWidth = this.clientWidth + if (d.width < 10) { + d3.select(this) + .text('') + } + // else { + + const halfW = d.width / 2 + const halfTw = textWidth / 2 + const k = d.height / 8 + const x = d.strand > 0 ? halfW - halfTw - k : halfW - halfTw + k + d3.select(this) + .attr("transform", `translate(${halfW},45) rotate(45)`) + // } + }) +} +function proteinText(item) { + if (item.isHmmerHit && displayHmmerHits.value || item.isDefenseSystem) { + return `${item.gene_name} / ${item.name}` + } + +} + +function proteinTitle(item) { + let title = `name=${item.name} | length=${item.length}` + + if (item.isHmmerHit && !item.isDefenseSystem) { + title = `gene name=${item.gene_name} | ${title}` + } + if (item.isDefenseSystem) { + title = `system=${item.defenseSystem} | ${title}` + } + + return title +} + +const maxLabelSize = ref(38) + +function proteinTextTrunc(item) { + const text = proteinText(item) + return (text && text.length > maxLabelSize.value) ? text.slice(0, maxLabelSize.value - 1) + '...' : text; +} + +function drawGenes(genesSelection) { + genesSelection + .selectAll("g.gene") // get all "existing" lines in svg + .data(toValue(computedProteins)) // sync them with our data + .join( + enter => { + const g = enter.append("g") + .classed("gene", true); + g.append("path").classed("gene", true) + g.append("text") + // .attr("fill", "white") + .classed("gene-label", true) + .attr("fill", "currentColor") + .attr("dominant-baseline", "middle") + g.append("title") + }, + update => { + update + .attr("transform", d => `translate(${d.x},${d.y})`) + update.select("path.gene") + .attr("d", d => drawGene(d).toString()) + .attr("fill", d => { + + if (d.isDefenseSystem) return toValue(color)("df") + if (displayHmmerHits.value && d.isHmmerHit) return toValue(color)("hmmer") + return toValue(color)("default") + }) + update.select("text.gene-label").text(proteinTextTrunc).call(positionText) + update.select("title").text(proteinTitle) + }, + exit => exit.remove() + ) + +} +function draw() { + const { marginLeft } = toValue(margin) + + const throttledZoomed = useThrottleFn((event) => { + zoomed(event) + }, 50) + + + function zoomed(event) { + const { transform } = event + const zx = transform.rescaleX(xScale.value); + domain.value = zx.domain() + minRange.value = zx.range()[0] + maxRange.value = zx.range()[1] + } + + + const svg = d3.select(svgRef.value); + const zoom = d3.zoom() + .scaleExtent([0.5, 32]) + .on("zoom", throttledZoomed); + const xAxis = d3.axisBottom(xScale.value) + let gx = svg.select("g.xaxis") + if (gx.empty()) { + gx = svg.append("g").classed("xaxis", true) + + } + gx + .attr("transform", `translate(${marginLeft},${height.value - 18})`) + .call(xAxis) + + let gxTitle = gx.select("text.x-axis-title") + if (gxTitle.empty()) { + gxTitle = gx.append("text") + .classed("x-axis-title", true) + .attr("text-anchor", "end") + .attr("fill", "currentColor") + .html(() => `${computedXUnit.value} →`) + } + gxTitle + .attr("x", innerWidth.value) + .attr("y", - 10) + + let gGenes = svg.select("g.genes") + if (gGenes.empty()) { + gGenes = svg.append("g").classed("genes", true) + } + gGenes.call(drawGenes, xScale, yScale) + svg.call(zoom).call(zoom.transform, d3.zoomIdentity); + + + +} + +function downloadSvg(component: ComponentPublicInstance | null, filename: string) { + + const componentVal = toValue(component) + if (componentVal) { + const blob = serialize(componentVal) + if (blob !== undefined) { + download(blob, filename) + } + } +} + +async function downloadPng(component: ComponentPublicInstance | null, filename: string) { + const blob = await rasterize(toValue(component), filename)?.then((blob) => { + download(blob, filename) + }) +} + +onMounted(() => { + draw() + const selectedProt = toValue(selectedProtein) + const toValProtIndex = toValue(proteinIndex) + const contig = props.sequences.name + if (selectedProt?.name && toValProtIndex.size > 0 && toValProtIndex.has(selectedProt.name) && selectedProt?.contig && selectedProt.contig === contig) { + const prot = toValProtIndex.get(selectedProt.name) + + domain.value = [prot?.start ?? 0, prot?.end ?? 400] + } +}) +watchEffect(() => { + xScale.value = d3.scaleLinear() + .domain(domain.value) // input values... + .range([minRange.value, innerWidth.value]) + draw() +}) + + +watch(selectedProtein, () => { + + + const selectedProt = toValue(selectedProtein) + const toValProtIndex = toValue(proteinIndex) + const contig = props.sequences.name + if (selectedProt?.name && toValProtIndex.size > 0 && toValProtIndex.has(selectedProt.name) && selectedProt?.contig && selectedProt.contig === contig) { + const prot = toValProtIndex.get(selectedProt.name) + + domain.value = [prot?.start ?? 0, prot?.end ?? 400] + } +}) + +</script> + + +<template> + <div> + <v-card flat color="transparent" ref="gbContainer"> + <v-toolbar flat> + + <v-toolbar-title> {{ props.sequences.name }}</v-toolbar-title> + <v-switch v-model="displayHmmerHits" color="primary" label="Display HMM-only hits" hide-details="auto" + class="mr-2"></v-switch> + <v-menu> + <template v-slot:activator="{ props }"> + <v-btn color="primary" prepend-icon="md:download" class="mr-2" v-bind="props"> + Export + </v-btn> + </template> + <v-list> + <v-list-subheader>Images</v-list-subheader> + <v-list-item prepend-icon="mdi-svg" value="svg" + @click="downloadSvg(figureRef, `df-results-${props.analysisName}-${props.sequences.name}.svg`)"> + <v-list-item-title>to + svg</v-list-item-title> + + </v-list-item> + <v-list-item prepend-icon="mdi-image" value="png" + @click="downloadPng(figureRef, `df-results-${props.analysisName}-${props.sequences.name}.png`)"> + + <v-list-item-title>to + png</v-list-item-title> + </v-list-item> + </v-list> + </v-menu> + <v-btn v-if="props.canClose" icon="mdi-close" + @click="emit('close', { contig: props.sequences.name })"></v-btn> + </v-toolbar> + + <v-card-subtitle> + Defense system hits: {{ props.sequences.defenseSystemHits }} | Hmmer hits: {{ props.sequences.hmmerHits + }} + </v-card-subtitle> + <div ref="figureRef"> + <svg ref="svgRef" :width="computedWidth" :height="height"> + <g class="x-axis" /> + </svg> + </div> + </v-card> + </div> +</template> \ No newline at end of file diff --git a/frontend/components/tableResults/GenesTable.vue b/frontend/components/tableResults/GenesTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..4c3ea83a5522bbc1b24e4f8069e5b83308876d01 --- /dev/null +++ b/frontend/components/tableResults/GenesTable.vue @@ -0,0 +1,76 @@ +<script setup lang="ts"> +import type { GenesOut } from "~/types"; + + +interface Props { + contig: string, + fromNt: boolean +} +const props = defineProps<Props>() + +const route = useRoute(); +const analysisId = computed(() => { + if (Array.isArray(route.params.analysisId)) return null + return parseInt(route.params.analysisId) +}) + +const { data: genes, error } = await useAPI<GenesOut>( + `/analysis/${toValue(analysisId)}/genes` +); + +if (error.value) { + throw createError({ message: `Error while getting the list of genes for analysis ${toValue(analysisId)}` }) +} + + +const headers = ref([ + { title: "Hit id", align: "end", key: "hit_id", fixed: true }, + { title: "Replicon", align: "end", key: "replicon" }, + { title: "Position", align: "end", key: "hit_pos" }, + { title: "Model fqn", align: "end", key: "model_fqn" }, + { title: "System id", align: "end", key: "sys_id" }, + { title: "System loci", align: "end", key: "sys_loci" }, + { title: "locus_num", align: "end", key: "locus_num" }, + // { title: "sys_wholeness", align: "end", key: "sys_wholeness" }, + { title: "Score", align: "end", key: "sys_score" }, + { title: "sys_occ", align: "end", key: "sys_occ" }, + { title: "Gene ref", align: "end", key: "hit_gene_ref" }, + { title: "Status", align: "end", key: "hit_status" }, + { title: "Seq length", align: "end", key: "hit_seq_len" }, + { title: "i_eval", align: "end", key: "hit_i_eval" }, + { title: "Score", align: "end", key: "hit_score" }, + { title: "Profile cov", align: "end", key: "hit_profile_cov" }, + { title: "Seq cov", align: "end", key: "hit_seq_cov" }, +]); + +const sanitizedGenes = computed(() => { + console.log("dans sanitized gene gene tabl") + if (genes.value?.genes) { + return genes.value.genes.map(gene => { + return { ...gene, model_fqn: gene.model_fqn.split("/").slice(-2).join(" - "), sys_id: gene.sys_id.split(gene.replicon)[1].slice(1) } + }) + // .filter(gene => { + // return gene.replicon === props.contig + // }) + } + else { return [] } +}) +const groupBy = ref([ + { + key: 'replicon', + order: 'asc', + }, +]) + +</script> +<template> + <AnalysisResultDataTable v-if="sanitizedGenes.length > 0" :items="sanitizedGenes" :headers="headers" + :group-by="props.fromNt ? groupBy : false" :contig="props.contig" /> + <v-card v-else flat color="transparent"> + <v-card-text> + <v-alert type="info" variant="tonal"> + No gene found + </v-alert> + </v-card-text> + </v-card> +</template> \ No newline at end of file diff --git a/frontend/pages/analyses/[analysisId]/hmmer.vue b/frontend/components/tableResults/HmmersTable.vue similarity index 66% rename from frontend/pages/analyses/[analysisId]/hmmer.vue rename to frontend/components/tableResults/HmmersTable.vue index b4eeb965a917dc1a9678cbbff952ec0d3f32898f..25d84b92de64f3d26a360d384d85949b9ec3036c 100644 --- a/frontend/pages/analyses/[analysisId]/hmmer.vue +++ b/frontend/components/tableResults/HmmersTable.vue @@ -1,22 +1,39 @@ <script setup lang="ts"> -import { useFetch, useRoute } from "#imports"; +import { useRoute } from "#imports"; import type { HmmersOut } from "~/types"; +interface Props { + contig: string, + fromNt: boolean +} +const props = defineProps<Props>() const route = useRoute(); +const analysisId = computed(() => { + if (Array.isArray(route.params.analysisId)) return null + return parseInt(route.params.analysisId) +}) const { data: hmmers, error } = await useAPI<HmmersOut>( - `/analysis/${route.params.analysisId}/hmmers` + `/analysis/${toValue(analysisId)}/hmmers` ); if (error.value) { - throw createError({ message: `Error while getting the list of hmmer for analysis ${route.params.analysisId}` }) + throw createError({ message: `Error while getting the list of hmmer for analysis ${toValue(analysisId)}` }) } + +const groupBy = ref([ + { + key: 'replicon', + order: 'asc', + }, +]) + const headers = ref([ { title: "Hit identifier", align: "end", key: "hit_id", fixed: true }, - // { title: "Replicon", align: "end", key: "replicon" }, + { title: "Replicon", align: "end", key: "replicon" }, { title: "Position", align: "end", key: "hit_pos" }, { title: "Sequence length", align: "end", key: "hit_sequence_length" }, { title: "Gene name", align: "end", key: "gene_name" }, @@ -34,11 +51,14 @@ const sanitizedHmmer = computed(() => { return hmmers.value.hmmers } else { return [] } }) + + </script> <template> - <AnalysisResultDataTable v-if="sanitizedHmmer.length > 0" :items="sanitizedHmmer" :headers="headers" /> + <AnalysisResultDataTable v-if="sanitizedHmmer.length > 0" :items="sanitizedHmmer" :headers="headers" + :group-by="props.fromNt ? groupBy : false" :contig="props.contig" /> <v-card v-else flat color="transparent"> - <v-card-text> + <v-card-text>s <v-alert type="info" variant="tonal"> No hmmer hit </v-alert> diff --git a/frontend/components/tableResults/SystemsTable.vue b/frontend/components/tableResults/SystemsTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..1a8ed3dfe7bd63dfd7bb32d731011496c44d6e56 --- /dev/null +++ b/frontend/components/tableResults/SystemsTable.vue @@ -0,0 +1,69 @@ +<script setup lang="ts"> +import { useRoute } from "#imports"; +import type { SystemsOut } from "~/types"; + +interface Props { + contig: string, + fromNt: boolean +} +const props = defineProps<Props>() +const route = useRoute(); + +const analysisId = computed(() => { + if (Array.isArray(route.params.analysisId)) return null + return parseInt(route.params.analysisId) +}) + + +const { data: systems, error } = await useAPI<SystemsOut>( + `/analysis/${toValue(analysisId)}/systems` +); + +if (error.value) { + throw createError({ message: `Error while getting the list systems for analysis ${toValue(analysisId)}` }) +} + +const groupBy = ref([ + { + key: 'replicon', + order: 'asc', + }, +]) +const headers = ref([ + { title: "System id", align: "end", key: "sys_id", fixed: true, }, + { title: "type", align: "end", key: "type", }, + { title: "subtype", align: "end", key: "subtype" }, + { title: "sys_beg", align: "end", key: "sys_beg" }, + { title: "sys_end", align: "end", key: "sys_end" }, + { title: "protein_in_syst", align: "end", key: "protein_in_syst" }, +]); + +const sanitizedSystems = computed(() => { + if (systems.value?.systems) { + let data = systems.value.systems.map((system) => { + let newSystem = { + ...system, + protein_in_syst: system.protein_in_syst.split(','), + replicon: props.fromNt ? system.sys_beg.replace(/_\d+$/, "") : props.contig.replace(/\.[^\.]*?$/, "") + } + if (system.type === 'CasFinder') return { ...newSystem, type: 'Cas' } + return newSystem + }) + return data + + } else { return [] } +}) + +</script> +<template> + <AnalysisResultDataTable v-if="sanitizedSystems.length > 0" :items="sanitizedSystems" :headers="headers" + :group-by="props.fromNt ? groupBy : false" :contig="props.contig.replace(/\.[^\.]*?$/, '')" /> + <v-card v-else flat color="transparent"> + <v-card-text> + <v-alert type="info" variant="tonal"> + No system found + </v-alert> + </v-card-text> + </v-card> + +</template> diff --git a/frontend/composables/useCssSelector.ts b/frontend/composables/useCssSelector.ts new file mode 100644 index 0000000000000000000000000000000000000000..639edc7abc631fbc30f2ecf824466d168ea4e24c --- /dev/null +++ b/frontend/composables/useCssSelector.ts @@ -0,0 +1,14 @@ + + +export function useCssSelector(selector: MaybeRef<string>) { + const sanitizedSelector = ref<string | undefined>(undefined) + function sanitized(selector: MaybeRef<string>) { + return toValue(selector).replace(/[^\w\d_-]/g, "-") + } + watchEffect(() => { + sanitizedSelector.value = sanitized(selector) + }) + + return { sanitizedSelector } + +} \ No newline at end of file diff --git a/frontend/composables/useSelectedProtein.ts b/frontend/composables/useSelectedProtein.ts index d212fc35f116852b690e1d7e746aac038d63d5eb..cb180dc8d17e436b204ef60f97ba47b71aa8cba3 100644 --- a/frontend/composables/useSelectedProtein.ts +++ b/frontend/composables/useSelectedProtein.ts @@ -1,14 +1,12 @@ -const selectedProtein = ref<string | undefined>(undefined) +const selectedProtein = ref<{ name: string | undefined, contig: string | undefined } | undefined>(undefined) export function useSelectedProtein() { + function setSelectedProtein(hit_id: string | undefined, contig: string | undefined) { + selectedProtein.value = { + name: hit_id, contig + } - - - - function setSelectedProtein(hit_id: string | undefined) { - selectedProtein.value = hit_id } - return { selectedProtein, setSelectedProtein } } \ No newline at end of file diff --git a/frontend/composables/useSerialize.ts b/frontend/composables/useSerialize.ts index 47bcbc451f39d721fb4e6527bb2f77303c66bbad..6ee6f1f44db3501d97931aeee614f263dcf48f26 100644 --- a/frontend/composables/useSerialize.ts +++ b/frontend/composables/useSerialize.ts @@ -9,7 +9,9 @@ export function useSerialize() { function serialize(compo: MaybeRef<ComponentPublicInstance | null>) { const toValueCompo = toValue(compo) + if (toValueCompo !== null) { + console.log(toValueCompo) const { svg } = useSvgPlot(toValueCompo) const toValueSvg = toValue(svg) if (toValueSvg !== null) { diff --git a/frontend/composables/useSvgPlot.ts b/frontend/composables/useSvgPlot.ts index b2375d3a89bacfd8b7cc66451c2653fbdee38a91..5836801475aacbdf930a4220c088503824020f1f 100644 --- a/frontend/composables/useSvgPlot.ts +++ b/frontend/composables/useSvgPlot.ts @@ -2,8 +2,9 @@ export function useSvgPlot(component: MaybeRef<ComponentPublicInstance>) { const svg = ref<SVGElement | null>(null) const toValueCompo = toValue(component) - const rootElem = toValueCompo.$el + console.log(toValueCompo) + const rootElem = toValueCompo + console.log(rootElem) svg.value = rootElem.querySelector("svg") - console.log(svg.value) return { svg } } \ No newline at end of file diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 46bcdc729132107c84a60172f4db4c6bea74c71b..e1cd81ed3b0ed5b6e53c66ff9cfeacea9fd5075c 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -15,7 +15,8 @@ export default defineNuxtConfig({ public: { wikiUrl: '/wiki', dfApiPrefix: "/dfapi", - version: pkg.version + version: pkg.version, + displayMessage: false } }, modules: [ diff --git a/frontend/pages/analyses/[analysisId].vue b/frontend/pages/analyses/[analysisId].vue index 43bcdb5225dbd3ed494924104ebeda752a04ddae..4b9ae99e2b4f8c297d6282d0ecd040933c72173b 100644 --- a/frontend/pages/analyses/[analysisId].vue +++ b/frontend/pages/analyses/[analysisId].vue @@ -1,31 +1,28 @@ <script setup lang="ts"> -import { useThrottleFn } from '@vueuse/core' -import type { Analysis, Gene, GenesOut, Hmmer, HmmersOut, ProteinsOut } from '@/types' -// import { useFetchAnalysis } from "../../composables/useFetchAnalysis"; -import { useSelectedProtein } from '@/composables/useSelectedProtein' +import type { Analysis, Gene, GenesOut, Hmmer, HmmersOut, Protein, ProteinsOut } from '@/types' import { useRoute, computed } from "#imports"; import * as d3 from "d3"; -import { useElementSize } from '@vueuse/core' import { joinURL } from "ufo"; import type { ComponentPublicInstance } from 'vue' -import { useSerialize } from '~/composables/useSerialize'; -import { useRasterize } from '~/composables/useRasterize'; -import { useDownloadBlob } from '~/composables/useDownloadBlob'; +import { useGoTo } from 'vuetify' +import { useCssSelector } from "~/composables/useCssSelector" + +import SystemsTable from "~/components/tableResults/SystemsTable.vue"; +import GenesTable from "~/components/tableResults/GenesTable.vue"; +import HmmersTable from "~/components/tableResults/HmmersTable.vue"; const route = useRoute(); const { selectedProtein } = useSelectedProtein() - const runtimeConfig = useRuntimeConfig() -const { serialize } = useSerialize() -const { rasterize } = useRasterize() -const { download } = useDownloadBlob() - +const itemsPerPage = ref(-1) +const itemsPerPageOptions = ref<{ title: string, value: number }[]>([{ title: "2", value: 2 }, { title: "5", value: 5 }, { title: "10", value: 10 }, { title: "All", value: -1 }]) +const onlyWithDefenseSystem = ref(true) +const goTo = useGoTo() const analysisId = computed(() => { if (Array.isArray(route.params.analysisId)) return null return parseInt(route.params.analysisId) }) - const breadcrumbItems = computed(() => { return [ { @@ -50,7 +47,6 @@ const breadcrumbItems = computed(() => { }) -const height = ref(300) const genesMap = computed(() => { const mapGenes = new Map<string, Gene>() @@ -91,14 +87,7 @@ const { data: genes } = await useAPI<GenesOut>( const { data: rawHmmer } = await useAPI<HmmersOut>( `/analysis/${route.params.analysisId}/hmmers` ); -const computedXUnit = computed(() => { - if (rawProteins.value?.proteins) { - if (rawProteins.value.proteins[0].start === null && rawProteins.value.proteins[0].end === null) { - return "aa" - } - else { return "bp" } - } -}) + const sanitizedData = computed(() => { if (rawProteins.value?.proteins) { @@ -135,320 +124,163 @@ const sanitizedData = computed(() => { else { return [] } }) -const proteinIndex = computed(() => { - return new Map(sanitizedData.value.map((d, i, arr) => { - let previousStart = 0 - if (i > 0) { - previousStart = arr[i - 1].start - 10 - } - let nextEnd = previousStart + 400 - if (i < arr.length - 1) { - nextEnd = arr[i + 1].end + 10 - } - return [d.id, { ...d, previousStart, nextEnd }] - })) - -}) - - +const page = ref(1) +const tab = ref("systems") +const figureRef = ref<ComponentPublicInstance[] | null>(null) -const displayHmmerHits = ref<boolean>(true) -type ColorScale = (value: string | number) => string -const color = computed<ColorScale>(() => { - - return d3.scaleOrdinal(["default", undefined, "df", "hmmer"], d3.schemeTableau10) -}) -const gbContainer = ref(null) -const gbContainerWidth = ref(useElementSize(gbContainer, { width: 500, height: 0 }, { box: 'border-box' }).width) +const groupedPerContigData = computed(() => { -const computedWidth = computed(() => { - return gbContainerWidth.value + const computedDataVal = toValue(sanitizedData) + const analysisVal = toValue(analysis) + if (computedDataVal && analysisVal && analysisVal.from_nt) { + return d3.groups(computedDataVal, (d) => { + return d.name.replace(/_\d+?$/i, "") + }).map(([name, proteins]: [string, Array<Protein>]) => { + return { + name, + proteins, + defenseSystemHits: proteins.reduce((acc, curr) => { + if (curr.isDefenseSystem) return acc + 1 + return acc + }, 0), + hmmerHits: proteins.reduce((acc, curr) => { + if (curr.isHmmerHit) return acc + 1 + return acc + }, 0), + } + }) + } + else { + return [{ + name: toValue(analysis)?.input_name.replace(/\.[^\.]*?$/, ""), + proteins: computedDataVal, + defenseSystemHits: computedDataVal.reduce((acc, curr) => { + if (curr.isDefenseSystem) return acc + 1 + return acc + }, 0), + hmmerHits: computedDataVal.reduce((acc, curr) => { + if (curr.isHmmerHit) return acc + 1 + return acc + }, 0), + }] + } }) +const userSelectedContigs = ref<Set<string> | undefined>(undefined) - -function downloadSvg(component: ComponentPublicInstance | null, filename: string) { - const blob = toValue(serialize(toValue(component))) - if (blob !== undefined) { - download(blob, filename) +const maxContigCount = ref<null | number>(10) +const contigDomain = computed(() => { + const groupedPerContigDataVal = toValue(groupedPerContigData) + if (groupedPerContigDataVal.length >= 1) { + return [1, groupedPerContigDataVal.length] + } else { + return null } -} -async function downloadPng(component: ComponentPublicInstance | null, filename: string) { - const blob = await rasterize(toValue(component), filename)?.then((blob) => { - download(blob, filename) - }) -} - -watch(selectedProtein, () => { - const toValHitId = toValue(selectedProtein) - const toValProtIndex = toValue(proteinIndex) - if (toValHitId && toValProtIndex.size > 0 && toValProtIndex.has(toValHitId)) { - const prot = toValProtIndex.get(toValHitId) - domain.value = [prot?.previousStart ?? 0, prot?.nextEnd ?? 400] - } }) +const filteredGroupedPerContigData = computed(() => { + let data = toValue(groupedPerContigData) + const maxContigCountVal = toValue(maxContigCount) + data = data + .sort((a, b) => b.defenseSystemHits - a.defenseSystemHits) -const marginLeftGb = ref(10) -const marginRightGb = ref(10) -const marginBottomGb = ref(10) - -const innerWidth = computed(() => { - return computedWidth.value - marginLeftGb.value - marginRightGb.value -}) -const innerHeigth = computed(() => { - return height.value - marginBottomGb.value + return data }) -const minRange = ref(0) -const maxRange = ref(innerWidth.value) - - -const svgRef = ref<ComponentPublicInstance | null>(null) -const figureRef = ref<ComponentPublicInstance | null>(null) - -const domain = ref([0, 10000]) -// const range = ref() -const xScale = ref(d3.scaleLinear() - .domain(domain.value) - .range([minRange.value, maxRange.value]) -); -const yScale = ref(d3.scaleLinear() - .domain([-1, 1]) - .range([0, innerHeigth.value])); - - -const computedData = computed(() => { - const newData = sanitizedData.value.filter(gene => { - const { start, end } = gene - const [scaleStart, scaleEnd] = xScale.value.domain() - return start <= scaleEnd && end >= scaleStart - - }).map(gene => { - const width = xScale.value(gene.end) - xScale.value(gene.start) - const height = yScale.value(-0.75) - const x = xScale.value(gene.start) + marginLeftGb.value - const y = yScale.value(-0.75) - marginBottomGb.value - - return { - ...gene, - width, - height, - x, - y - } - }) - return newData -}) - -function drawGene({ width, height, strand }) { - const context = d3.path() - const halfHeight = height / 2 - const isWidthLonger = halfHeight < width - if (strand < 0) { - context.moveTo(0, halfHeight) - if (isWidthLonger) context.lineTo(halfHeight, 0) - context.lineTo(width, 0) - context.lineTo(width, height) - if (isWidthLonger) context.lineTo(halfHeight, height) - context.closePath() - } else if (strand > 0) { - context.moveTo(0, 0) - if (isWidthLonger) context.lineTo(width - halfHeight, 0) - context.lineTo(width, halfHeight) - if (isWidthLonger) context.lineTo(width - halfHeight, height) - context.lineTo(0, height) - context.closePath() - } - else { - context.moveTo(0, 0) - context.lineTo(width, 0) - context.lineTo(width, height) - context.lineTo(0, height) - context.closePath() - } - return context -} - -function positionText(selection) { - selection.each(function (d) { - const textWidth = this.clientWidth - if (d.width < 10) { - d3.select(this) - .text('') - } - // else { - - const halfW = d.width / 2 - const halfTw = textWidth / 2 - const k = d.height / 8 - const x = d.strand > 0 ? halfW - halfTw - k : halfW - halfTw + k - d3.select(this) - .attr("transform", `translate(${halfW},45) rotate(45)`) - // } - }) +function getResultArchiveUrl(analysisId: number) { + const url = joinURL(runtimeConfig.public.dfApiPrefix, `/analysis/${analysisId}/results-archive`) + window.location.href = url; } -function proteinText(item) { - if (item.isHmmerHit && displayHmmerHits.value || item.isDefenseSystem) { - return `${item.gene_name} / ${item.name}` +function handleCloseContig(payload) { + const { contig } = payload + const userSelectedContigsVal = toValue(userSelectedContigs) + if (userSelectedContigsVal?.has(contig)) { + userSelectedContigsVal.delete(contig) } - } - -function proteinTitle(item) { - let title = `name=${item.name} | length=${item.length}` - - if (item.isHmmerHit && !item.isDefenseSystem) { - title = `gene name=${item.gene_name} | ${title}` - } - if (item.isDefenseSystem) { - title = `system=${item.defenseSystem} | ${title}` +const computedSelectedContigs = computed(() => { + const userSelectedContigsVal = toValue(userSelectedContigs) + const filteredGroupedPerContigDataVal = toValue(filteredGroupedPerContigData) + if (userSelectedContigsVal === undefined) { + return filteredGroupedPerContigDataVal.slice(0, 1) } - - return title -} - -const maxLabelSize = ref(38) - -function proteinTextTrunc(item) { - const text = proteinText(item) - return (text && text.length > maxLabelSize.value) ? text.slice(0, maxLabelSize.value - 1) + '...' : text; -} - - -function drawGenes(genesSelection) { - genesSelection - .selectAll("g.gene") // get all "existing" lines in svg - .data(computedData.value) // sync them with our data - .join( - enter => { - const g = enter.append("g") - .classed("gene", true); - g.append("path").classed("gene", true) - g.append("text") - // .attr("fill", "white") - .classed("gene-label", true) - .attr("fill", "currentColor") - .attr("dominant-baseline", "middle") - g.append("title") - }, - update => { - update - .attr("transform", d => `translate(${d.x},${d.y})`) - update.select("path.gene") - .attr("d", d => drawGene(d).toString()) - .attr("fill", d => { - - if (d.isDefenseSystem) return toValue(color)("df") - if (displayHmmerHits.value && d.isHmmerHit) return toValue(color)("hmmer") - return toValue(color)("default") - }) - update.select("text.gene-label").text(proteinTextTrunc).call(positionText) - update.select("title").text(proteinTitle) - }, - exit => exit.remove() - ) - -} - -function draw() { - - const throttledZoomed = useThrottleFn((event) => { - zoomed(event) - }, 50) - - - function zoomed(event) { - const { transform } = event - const zx = transform.rescaleX(xScale.value); - domain.value = zx.domain() - minRange.value = zx.range()[0] - maxRange.value = zx.range()[1] - } - - - const svg = d3.select(svgRef.value); - const zoom = d3.zoom() - .scaleExtent([0.5, 32]) - .on("zoom", throttledZoomed); - const xAxis = d3.axisBottom(xScale.value) - let gx = svg.select("g.xaxis") - if (gx.empty()) { - gx = svg.append("g").classed("xaxis", true) - - } - gx - .attr("transform", `translate(${marginLeftGb.value},${height.value - 18})`) - .call(xAxis) - - let gxTitle = gx.select("text.x-axis-title") - if (gxTitle.empty()) { - gxTitle = gx.append("text") - .classed("x-axis-title", true) - .attr("text-anchor", "end") - .attr("fill", "currentColor") - .html(() => `${computedXUnit.value} →`) - } - gxTitle - .attr("x", innerWidth.value) - .attr("y", - 10) - - - - - let gGenes = svg.select("g.genes") - if (gGenes.empty()) { - gGenes = svg.append("g").classed("genes", true) + else { + return filteredGroupedPerContigDataVal.filter(g => userSelectedContigsVal.has(g.name)) } - gGenes.call(drawGenes, xScale, yScale) - svg.call(zoom).call(zoom.transform, d3.zoomIdentity); - +}) +const { data: analysis, refresh, pending, error } = await useAPI<Analysis>(`/analysis/${route.params.analysisId}`, { + key: `analysis-${route.params.analysisId}`, +}); -} -function getResultArchiveUrl(analysisId: number) { - return joinURL(runtimeConfig.public.dfApiPrefix, `/analysis/${analysisId}/results-archive`) +if (error.value) { + throw createError({ statusCode: 404, statusMessage: 'No analysis found' }) } -onMounted(() => { - draw() +useHead({ + title: analysis.value?.name ?? 'analysis', }) +const proteinIndex = computed(() => { + return new Map(props.sequences.proteins.map((d, i, arr) => { + return [d.id, { ...d }] + })) -watchEffect(() => { - xScale.value = d3.scaleLinear() - .domain(domain.value) // input values... - .range([minRange.value, innerWidth.value]) - draw() }) +const isUserSelectedContigsDefined = computed(() => { + return toValue(userSelectedContigs) !== undefined -const selectedResult = ref(null); - +}) +watch(selectedProtein, async () => { + const selectedProt = toValue(selectedProtein) + let userSelectedContigsVal = toValue(userSelectedContigs) + const itemsPerPageVal = toValue(itemsPerPage) + if (selectedProt !== undefined) { + const { contig } = selectedProt + if (contig !== undefined) { + if (userSelectedContigsVal === undefined) { + userSelectedContigs.value = new Set([contig]) + } else { + userSelectedContigsVal.add(contig) + } + const index = toValue(computedSelectedContigs).findIndex(contigObj => { + return contigObj.name === contig + }) + if (index > -1) { + if (itemsPerPageVal === -1) { + page.value = 1 + } else { + const position = index + 1 + if (position % itemsPerPageVal === 0) { + page.value = position / itemsPerPageVal + } + else { + page.value = Math.trunc(position / itemsPerPageVal) + 1 + } + } + await nextTick() + goTo(`#${toValue(useCssSelector(`contig-${contig}`).sanitizedSelector)}`, { offset: -100 }) + } + } + } +}, { deep: true }) -const { data: analysis, refresh, pending, error } = await useAPI<Analysis>(`/analysis/${analysisId.value}`, { - key: `analysis-${analysisId.value}`, -}); -if (error.value) { - throw createError({ statusCode: 404, statusMessage: 'No analysis found' }) +function getSelector(selector: MaybeRef<string>) { + const { sanitizedSelector } = useCssSelector(selector) + return sanitizedSelector } - -useHead({ - title: analysis.value?.name ?? 'analysis', -}) - - </script> <template> @@ -459,7 +291,7 @@ useHead({ </v-app-bar> <v-card> - <v-toolbar density="compact" class="pr-2"> + <v-toolbar color="transparent" flat> <v-toolbar-title>{{ analysis.name }}</v-toolbar-title> <v-menu> <template v-slot:activator="{ props }"> @@ -469,21 +301,9 @@ useHead({ </template> <v-list> <v-list-item v-if="analysis !== null" prepend-icon="mdi-archive" value="archive"> - <v-list-item-title @click="getResultArchiveUrl(analysis.id)">Download all results + <v-list-item-title @click="getResultArchiveUrl(analysisId)">Download all results </v-list-item-title> </v-list-item> - <v-divider></v-divider> - <v-list-subheader>Images</v-list-subheader> - <v-list-item prepend-icon="mdi-svg" value="svg"> - <v-list-item-title @click="downloadSvg(figureRef, `df-results-${analysis.name}.svg`)">to - svg</v-list-item-title> - - </v-list-item> - <v-list-item prepend-icon="mdi-image" value="png"> - - <v-list-item-title @click="downloadPng(figureRef, `df-results-${analysis.name}.png`)">to - png</v-list-item-title> - </v-list-item> </v-list> </v-menu> <v-chip color="primary" rounded>{{ new Date(analysis.create_time).toLocaleString() }}</v-chip> @@ -511,34 +331,65 @@ useHead({ <template v-else> <v-card-text> - <div ref="gbContainer"> - <v-card ref="figureRef" flat color="transparent"> - <v-toolbar variant="flat" density="compact" color="transparent"> - <v-spacer></v-spacer><v-toolbar-items> - <v-switch v-model="displayHmmerHits" color="primary" label="Display HMM-only hits - " class="mr-2"></v-switch> - </v-toolbar-items></v-toolbar> - - <svg ref="svgRef" :width="computedWidth" :height="height"> - <g class="x-axis" /> - </svg> - </v-card> - </div> + <v-data-iterator v-if="computedSelectedContigs.length > 0" :items="computedSelectedContigs" :page="page" + :items-per-page="itemsPerPage"> + + <template v-slot:default="{ items }"> + <v-card v-for="contig in items" :key="contig.raw.name" :flat="false" class="my-2" + :id="toValue(useCssSelector(`contig-${contig.raw.name}`).sanitizedSelector)"> + <v-card ref="figureRef" flat color="transparent" class="mb-1"> + <SequenceBrowser :sequences="contig.raw" :from-nt="analysis.from_nt" :analysis-name="analysis.name" + :can-close="isUserSelectedContigsDefined" @close="handleCloseContig"> + </SequenceBrowser> + </v-card> + </v-card> + </template> + <template v-slot:footer="{ page, pageCount, prevPage, nextPage }"> + <div class="d-flex align-center justify-center pa-4"> + <div class="d-flex text-caption align-center"> + <span class="mr-1">items per page</span> + <v-select v-model="itemsPerPage" :items="itemsPerPageOptions" hide-details="auto" density="compact" + variant="outlined" class="mr-2"> + </v-select> + </div> + <v-btn :disabled="page === 1" density="comfortable" icon="mdi-arrow-left" variant="tonal" rounded + @click="prevPage"></v-btn> + + <div class="mx-2 text-caption"> + Page {{ page }} of {{ pageCount }} + </div> + + <v-btn :disabled="page >= pageCount" density="comfortable" icon="mdi-arrow-right" variant="tonal" + rounded @click="nextPage"></v-btn> + + </div> + </template> + </v-data-iterator> + + <v-card v-else> + <v-alert type="info" variant="tonal">No defense finder hits</v-alert> + </v-card> + <v-tabs v-model="tab" align-tabs="start" color="primary"> + <v-tab value="systems">Systems</v-tab> + <v-tab value="genes">Genes</v-tab> + <v-tab value="hmmers">Hmmers</v-tab> + </v-tabs> + <v-card flat color="transparent"> + <v-window v-model="tab"> + <v-window-item value="systems"> + <SystemsTable :contig="analysis.input_name" :from-nt="analysis.from_nt"></SystemsTable> + </v-window-item> + <v-window-item value="genes"> + <GenesTable :contig="analysis.input_name" :from-nt="analysis.from_nt"></GenesTable> + </v-window-item> + <v-window-item value="hmmers"> + <HmmersTable :contig="analysis.input_name" :from-nt="analysis.from_nt"> </HmmersTable> + </v-window-item> + </v-window> + </v-card> </v-card-text> - <v-card-text> - <v-btn-toggle v-model="selectedResult" rounded="0" color="primary" group> - <v-btn value="systems" :to="`/analyses/${analysis.id}/systems`"> - Systems - </v-btn> - <v-btn value="genes" exact :to="`/analyses/${analysis.id}/genes`"> - Genes - </v-btn> - <v-btn value="hmmer" :to="`/analyses/${analysis.id}/hmmer`"> - Hmmer - </v-btn> - </v-btn-toggle> - </v-card-text> + </template> <NuxtPage /> </v-card> diff --git a/frontend/pages/analyses/[analysisId]/genes.vue b/frontend/pages/analyses/[analysisId]/genes.vue deleted file mode 100644 index 49f4567e3472fb718090380532a768616e2fd9a2..0000000000000000000000000000000000000000 --- a/frontend/pages/analyses/[analysisId]/genes.vue +++ /dev/null @@ -1,60 +0,0 @@ -<script setup lang="ts"> -import { useFetch, useRoute, computed } from "#imports"; -import type { GenesOut } from "~/types"; -const route = useRoute(); - - - -const { data: genes, error } = await useAPI<GenesOut>( - `/analysis/${route.params.analysisId}/genes` -); - - -if (error.value) { - throw createError({ message: `Error while getting the list of genes for analysis ${route.params.analysisId}` }) -} - - -const search = ref('') -const groupBy = ref([{ key: 'model_fqn', order: 'asc' }]) -const headers = ref([ - { title: "Hit id", align: "end", key: "hit_id", fixed: true }, - // { title: "Replicon", align: "end", key: "replicon" }, - { title: "Position", align: "end", key: "hit_pos" }, - { title: "Model fqn", align: "end", key: "model_fqn" }, - { title: "System id", align: "end", key: "sys_id" }, - { title: "System loci", align: "end", key: "sys_loci" }, - { title: "locus_num", align: "end", key: "locus_num" }, - // { title: "sys_wholeness", align: "end", key: "sys_wholeness" }, - { title: "Score", align: "end", key: "sys_score" }, - { title: "sys_occ", align: "end", key: "sys_occ" }, - { title: "Gene ref", align: "end", key: "hit_gene_ref" }, - { title: "Status", align: "end", key: "hit_status" }, - { title: "Seq length", align: "end", key: "hit_seq_len" }, - { title: "i_eval", align: "end", key: "hit_i_eval" }, - { title: "Score", align: "end", key: "hit_score" }, - { title: "Profile cov", align: "end", key: "hit_profile_cov" }, - { title: "Seq cov", align: "end", key: "hit_seq_cov" }, -]); - - -const sanitizedGenes = computed(() => { - if (genes.value?.genes) { - return genes.value.genes.map(gene => { - return { ...gene, model_fqn: gene.model_fqn.split("/").slice(-2).join(" - "), sys_id: gene.sys_id.split(gene.replicon)[1].slice(1) } - }) - } - else { return [] } -}) - -</script> -<template> - <AnalysisResultDataTable v-if="sanitizedGenes.length > 0" :items="sanitizedGenes" :headers="headers" /> - <v-card v-else flat color="transparent"> - <v-card-text> - <v-alert type="info" variant="tonal"> - No gene found - </v-alert> - </v-card-text> - </v-card> -</template> diff --git a/frontend/pages/analyses/[analysisId]/systems.vue b/frontend/pages/analyses/[analysisId]/systems.vue deleted file mode 100644 index 8f3d8fc018f199d6a06e637dc4a897558dc8dc69..0000000000000000000000000000000000000000 --- a/frontend/pages/analyses/[analysisId]/systems.vue +++ /dev/null @@ -1,45 +0,0 @@ -<script setup lang="ts"> -import { useRoute } from "#imports"; -import type { SystemsOut } from "~/types"; - - -const route = useRoute(); -const { data: systems, error } = await useAPI<SystemsOut>( - `/analysis/${route.params.analysisId}/systems` -); - -if (error.value) { - throw createError({ message: `Error while getting the list systems for analysis ${route.params.analysisId}` }) -} - -const headers = ref([ - { title: "type", align: "end", key: "type", fixed: true, }, - { title: "System id", align: "end", key: "sys_id" }, - { title: "subtype", align: "end", key: "subtype" }, - { title: "sys_beg", align: "end", key: "sys_beg" }, - { title: "sys_end", align: "end", key: "sys_end" }, - { title: "protein_in_syst", align: "end", key: "protein_in_syst" }, -]); - -const sanitizedSystems = computed(() => { - if (systems.value?.systems) { - return systems.value.systems.map((system) => { - let newSystem = { ...system, protein_in_syst: system.protein_in_syst.split(',') } - if (system.type === 'CasFinder') return { ...newSystem, type: 'Cas' } - return newSystem - }) - } else { return [] } -}) - -</script> -<template> - <AnalysisResultDataTable v-if="sanitizedSystems.length > 0" :items="sanitizedSystems" :headers="headers" /> - <v-card v-else flat color="transparent"> - <v-card-text> - <v-alert type="info" variant="tonal"> - No system found - </v-alert> - </v-card-text> - </v-card> - -</template> diff --git a/frontend/types.ts b/frontend/types.ts index d556ab7274d3371360569ebef10832ffe7af1664..41d7882078c04d2cac65520fcc1f19b888830eeb 100644 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -17,9 +17,11 @@ export type JobState = NonTerminalState | TerminalJobState export interface Analysis { id: number; name: string; + input_name: string; status: JobState; percentage_done: number; create_time: string + from_nt: boolean }