From a3cc8303ca6e2e2dc10779244021bcfee0c78850 Mon Sep 17 00:00:00 2001 From: Juan Nicolas Herrera <54152074+juanNH@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:02:28 -0300 Subject: [PATCH 01/17] Save 20.3 cytoscape graph --- .../static/frontend/package-lock.json | 10 +- src/frontend/static/frontend/package.json | 2 +- .../molecules/CurrentMoleculeDetails.tsx | 3 + .../molecules/MoleculesDetailsMenu.tsx | 7 + .../GeneOntologyCytoscapeChart.tsx | 2 +- .../genes/GeneAssociationsNetwork.tsx | 341 +++++++++++++ .../genes/GeneAssociationsNetworkPanel.tsx | 479 +++++++++++------- .../src/components/biomarkers/types.ts | 1 + 8 files changed, 645 insertions(+), 200 deletions(-) create mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx diff --git a/src/frontend/static/frontend/package-lock.json b/src/frontend/static/frontend/package-lock.json index b6e3a75c..bbca754f 100644 --- a/src/frontend/static/frontend/package-lock.json +++ b/src/frontend/static/frontend/package-lock.json @@ -16,7 +16,7 @@ "@visx/tooltip": "^3.3.0", "apexcharts": "^3.52.0", "axios": "^1.8.4", - "cytoscape": "^3.30.2", + "cytoscape": "^3.33.1", "d3": "^7.9.0", "dayjs": "^1.11.13", "fomantic-ui-css": "^2.9.4", @@ -10122,9 +10122,10 @@ "integrity": "sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A==" }, "node_modules/cytoscape": { - "version": "3.30.2", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.30.2.tgz", - "integrity": "sha512-oICxQsjW8uSaRmn4UK/jkczKOqTrVqt5/1WL0POiJUT2EKNc9STM4hYFHv917yu55aTBMFNRzymlJhVAiWPCxw==", + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", + "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", + "license": "MIT", "engines": { "node": ">=0.10" } @@ -23101,7 +23102,6 @@ "resolved": "https://registry.npmjs.org/plotly.js/-/plotly.js-3.3.0.tgz", "integrity": "sha512-3PT9dW7IbIfN7JWGr4YxxFQnbN5MRaB36qIKF/eF0iC9l0/MuGSlMlgRgI7Uu8vYuGxX6AjLwsBBRYTPG7NFSA==", "license": "MIT", - "peer": true, "dependencies": { "@plotly/d3": "3.8.2", "@plotly/d3-sankey": "0.7.2", diff --git a/src/frontend/static/frontend/package.json b/src/frontend/static/frontend/package.json index fd73478c..76dd01fa 100644 --- a/src/frontend/static/frontend/package.json +++ b/src/frontend/static/frontend/package.json @@ -59,7 +59,7 @@ "@visx/tooltip": "^3.3.0", "apexcharts": "^3.52.0", "axios": "^1.8.4", - "cytoscape": "^3.30.2", + "cytoscape": "^3.33.1", "d3": "^7.9.0", "dayjs": "^1.11.13", "fomantic-ui-css": "^2.9.4", diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx index 4f463eeb..1f173352 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx @@ -11,6 +11,7 @@ import { GeneAssociationsNetworkPanel } from './genes/GeneAssociationsNetworkPan import { MiRNADrugsPanel } from '../../../pipeline/experiment-result/gene-gem-details/MiRNADrugsPanel' import { MiRNADiseasesPanel } from '../../../pipeline/experiment-result/gene-gem-details/MiRNADiseasesPanel' import { ActionableCancerGenesPanel } from './genes/ActionableCancerGenesPanel' +import { GeneExpressionRegulationNetworkPanel } from './genes/GeneAssociationsNetwork' // const MENU_DEFAULT: ActiveBiomarkerMoleculeItemMenu = ActiveBiomarkerMoleculeItemMenu.DETAILS // TODO: use this const MENU_DEFAULT: ActiveBiomarkerMoleculeItemMenu = ActiveBiomarkerMoleculeItemMenu.DETAILS @@ -52,6 +53,8 @@ export const CurrentMoleculeDetails = (props: CurrentMoleculeDetailsProps) => { return case ActiveBiomarkerMoleculeItemMenu.GENE_ONTOLOGY: return + case ActiveBiomarkerMoleculeItemMenu.GENE_REGULATION_ASSOCIATIONS: + return case ActiveBiomarkerMoleculeItemMenu.DISEASES: return case ActiveBiomarkerMoleculeItemMenu.DRUGS: diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/MoleculesDetailsMenu.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/MoleculesDetailsMenu.tsx index 5a98008b..ec0b914c 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/MoleculesDetailsMenu.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/MoleculesDetailsMenu.tsx @@ -63,6 +63,13 @@ export const MoleculesDetailsMenu = (props: MoleculesDetailsMenuProps) => { popupInfo: 'Gene Ontology (GO) is a powerful tool for understanding the biological processes, molecular functions, and cellular components associated with a gene', isVisible: isGene }, + { + name: 'Gene regulation associations', + onClick: () => props.setActiveItem(ActiveBiomarkerMoleculeItemMenu.GENE_REGULATION_ASSOCIATIONS), + isActive: props.activeItem === ActiveBiomarkerMoleculeItemMenu.GENE_REGULATION_ASSOCIATIONS, + popupInfo: 'Gene regulation associations provide insights into the regulatory relationships between genes, helping to unravel the complex mechanisms that control gene expression and cellular function', + isVisible: isGene + }, // TODO: implement // { // name: 'Actionable/Cancer genes', diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/gene-ontology/GeneOntologyCytoscapeChart.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/gene-ontology/GeneOntologyCytoscapeChart.tsx index 9b277bba..af7df28e 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/gene-ontology/GeneOntologyCytoscapeChart.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/gene-ontology/GeneOntologyCytoscapeChart.tsx @@ -78,7 +78,7 @@ export const GeneOntologyCytoscapeChart = (props: GeneOntologyCytoscapeChartProp style: { 'curve-style': 'bezier', 'target-arrow-shape': 'triangle', - 'line-color': function (edge) { + 'line-color': function (edge): any { // Sets the color of the edge depending on the relation_type attribute const relationType: OntologyRelationTermToTermFilter = edge.data('relation_type') diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx new file mode 100644 index 00000000..ad7f0e0d --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx @@ -0,0 +1,341 @@ +import React, { useEffect, useMemo, useRef } from 'react' +import cytoscape, { Core, ElementDefinition } from 'cytoscape' + +type NodeKind = 'mRNA' | 'miRNA' | 'CNA' | 'Methylation' | 'Drug' + +type Props = { + height?: number | string; + width?: number | string; +} + +const NODE_COLORS: Record = { + mRNA: '#4f46e5', + miRNA: '#db2777', + CNA: '#f59e0b', + Methylation: '#10b981', + Drug: '#64748b', +} + +const getEdgeColor = (correlation: number) => { + if (correlation <= -0.5) { return '#dc2626' } + + if (correlation >= 0.5) { return '#2563eb' } + + return '#cbd5e1' +} + +const getEdgeOpacity = (correlation: number) => { + const abs = Math.abs(correlation) + + if (abs >= 0.8) { return 0.95 } + if (abs >= 0.6) { return 0.8 } + if (abs >= 0.5) { return 0.65 } + + return 0.22 +} + +const getEdgeWidth = (correlation: number) => { + const abs = Math.abs(correlation) + + if (abs >= 0.9) { return 6 } + if (abs >= 0.8) { return 5 } + if (abs >= 0.7) { return 4 } + if (abs >= 0.5) { return 3 } + + return 1.5 +} + +export const GeneExpressionRegulationNetworkPanel = ({ + height = 650, + width = '100%', +}: Props) => { + const containerRef = useRef(null) + const cyRef = useRef(null) + + const elements = useMemo(() => { + const nodes: ElementDefinition[] = [ + { data: { id: 'mrna_pcna', label: 'PCNA', type: 'mRNA', size: 58 } }, + { data: { id: 'mrna_fen1', label: 'FEN1', type: 'mRNA', size: 54 } }, + { data: { id: 'mrna_rad51', label: 'RAD51', type: 'mRNA', size: 36 } }, + { data: { id: 'mrna_pold1', label: 'POLD1', type: 'mRNA', size: 32 } }, + { data: { id: 'mrna_lig1', label: 'LIG1', type: 'mRNA', size: 30 } }, + { data: { id: 'mir_21', label: 'miR-21', type: 'miRNA', size: 28 } }, + { data: { id: 'mir_34a', label: 'miR-34a', type: 'miRNA', size: 28 } }, + { data: { id: 'mir_155', label: 'miR-155', type: 'miRNA', size: 28 } }, + { data: { id: 'mir_200c', label: 'miR-200c', type: 'miRNA', size: 28 } }, + { data: { id: 'cna_8q24', label: 'CNA 8q24', type: 'CNA', size: 34 } }, + { data: { id: 'cna_17p_loss', label: '17p loss', type: 'CNA', size: 34 } }, + { data: { id: 'cna_1q_gain', label: '1q gain', type: 'CNA', size: 34 } }, + { data: { id: 'meth_mlh1', label: 'MLH1 meth', type: 'Methylation', size: 32 } }, + { data: { id: 'meth_mgmt', label: 'MGMT meth', type: 'Methylation', size: 32 } }, + { data: { id: 'drug_olaparib', label: 'Olaparib', type: 'Drug', size: 34 } }, + { data: { id: 'drug_cisplatin', label: 'Cisplatin', type: 'Drug', size: 34 } }, + { data: { id: 'drug_temozolomide', label: 'Temozolomide', type: 'Drug', size: 34 } }, + { data: { id: 'mrna_apex2', label: 'APEX2', type: 'mRNA', size: 32 } }, + ] + + const rawEdges = [ + ['mrna_pcna', 'mrna_fen1', 0.92], + ['mrna_pcna', 'mrna_rad51', 0.87], + ['mrna_pcna', 'mrna_pold1', 0.83], + ['mrna_pcna', 'mrna_lig1', 0.79], + ['mrna_pcna', 'mrna_apex2', 0.72], + ['mrna_fen1', 'mrna_rad51', 0.76], + ['mrna_fen1', 'mrna_pold1', 0.81], + ['mrna_fen1', 'mrna_apex2', 0.69], + ['mrna_rad51', 'mrna_apex2', 0.64], + ['mrna_pold1', 'mrna_lig1', 0.71], + ['mrna_lig1', 'mrna_apex2', 0.58], + + ['mir_21', 'mrna_pcna', -0.74], + ['mir_21', 'mrna_fen1', -0.68], + ['mir_21', 'mrna_rad51', -0.61], + ['mir_34a', 'mrna_pcna', -0.71], + ['mir_34a', 'mrna_pold1', -0.66], + ['mir_155', 'mrna_rad51', -0.78], + ['mir_155', 'mrna_apex2', -0.57], + ['mir_200c', 'mrna_lig1', -0.63], + ['mir_200c', 'mrna_fen1', -0.55], + + ['cna_8q24', 'mrna_pcna', 0.67], + ['cna_8q24', 'mrna_fen1', 0.54], + ['cna_17p_loss', 'mrna_rad51', -0.58], + ['cna_17p_loss', 'mrna_apex2', -0.52], + ['cna_1q_gain', 'mrna_pold1', 0.62], + ['cna_1q_gain', 'mrna_lig1', 0.57], + ['meth_mlh1', 'mrna_pcna', -0.53], + ['meth_mlh1', 'mrna_apex2', -0.64], + ['meth_mgmt', 'mrna_rad51', -0.59], + ['meth_mgmt', 'mrna_fen1', -0.51], + + ['drug_olaparib', 'mrna_rad51', -0.69], + ['drug_olaparib', 'mrna_fen1', -0.56], + ['drug_cisplatin', 'mrna_pcna', -0.52], + ['drug_cisplatin', 'mrna_lig1', -0.55], + ['drug_temozolomide', 'meth_mgmt', 0.73], + ['drug_temozolomide', 'mrna_apex2', -0.51], + + ['mir_21', 'cna_8q24', 0.22], + ['mir_34a', 'meth_mlh1', -0.18], + ['drug_olaparib', 'drug_cisplatin', 0.31], + ['mrna_pcna', 'drug_temozolomide', -0.27], + ['cna_1q_gain', 'mrna_apex2', 0.33], + ] as const + + const edges: ElementDefinition[] = rawEdges.map(([source, target, correlation], index) => ({ + data: { + id: `e_${index + 1}`, + source, + target, + correlation, + edgeColor: getEdgeColor(correlation), + edgeOpacity: getEdgeOpacity(correlation), + edgeWidth: getEdgeWidth(correlation), + }, + classes: Math.abs(correlation) < 0.5 ? 'weak-edge' : '', + })) + + return [...nodes, ...edges] + }, []) + + useEffect(() => { + if (!containerRef.current) { return } + + const cy = cytoscape({ + container: containerRef.current, + elements, + wheelSensitivity: 0.18, + minZoom: 0.4, + maxZoom: 2, + style: [ + { + selector: 'node', + style: { + label: 'data(label)', + width: 'data(size)', + height: 'data(size)', + shape: 'ellipse', + 'background-color': '#64748b', + color: '#ffffff', + 'font-size': 12, + 'font-weight': 600, + 'text-valign': 'center', + 'text-halign': 'center', + 'text-wrap': 'wrap', + 'text-max-width': 90 as any, + 'text-outline-color': '#475569', + 'text-outline-width': 3, + 'border-width': 2, + 'border-color': '#ffffff', + }, + }, + { + selector: 'node[type = "mRNA"]', + style: { + 'background-color': NODE_COLORS.mRNA, + 'text-outline-color': NODE_COLORS.mRNA, + }, + }, + { + selector: 'node[type = "miRNA"]', + style: { + 'background-color': NODE_COLORS.miRNA, + 'text-outline-color': NODE_COLORS.miRNA, + }, + }, + { + selector: 'node[type = "CNA"]', + style: { + 'background-color': NODE_COLORS.CNA, + 'text-outline-color': NODE_COLORS.CNA, + }, + }, + { + selector: 'node[type = "Methylation"]', + style: { + 'background-color': NODE_COLORS.Methylation, + 'text-outline-color': NODE_COLORS.Methylation, + }, + }, + { + selector: 'node[type = "Drug"]', + style: { + 'background-color': NODE_COLORS.Drug, + 'text-outline-color': NODE_COLORS.Drug, + }, + }, + { + selector: 'edge', + style: { + width: 'data(edgeWidth)', + 'line-color': 'data(edgeColor)', + opacity: 'data(edgeOpacity)' as any, + 'curve-style': 'bezier', + }, + }, + { + selector: 'edge.weak-edge', + style: { + 'line-style': 'dashed', + }, + }, + { + selector: ':selected', + style: { + 'border-color': '#f8fafc', + 'border-width': 5, + 'line-color': '#0f172a', + 'target-arrow-color': '#0f172a', + 'source-arrow-color': '#0f172a', + }, + }, + { + selector: '.faded', + style: { + opacity: 0.12, + }, + }, + ], + layout: { + name: 'cose', + animate: true, + randomize: true, + fit: true, + padding: 40, + gravity: 1, + nodeRepulsion: 9000, + idealEdgeLength: 90, + componentSpacing: 80, + }, + }) + + cyRef.current = cy + + cy.on('tap', 'node', (evt) => { + const node = evt.target + const neighborhood = node.closedNeighborhood() + + cy.elements().addClass('faded') + neighborhood.removeClass('faded') + }) + + cy.on('tap', (evt) => { + if (evt.target === cy) { + cy.elements().removeClass('faded') + cy.elements().unselect() + } + }) + + return () => { + cy.destroy() + cyRef.current = null + } + }, [elements]) + + return ( +
+
+
+ + + + + + + +
+
+ +
+
+ ) +} + +const LegendDot = ({ color, label }: { color: string; label: string }) => ( + + + {label} + +) + +const LegendLine = ({ color, label }: { color: string; label: string }) => ( + + + {label} + +) diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetworkPanel.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetworkPanel.tsx index a7d5db85..4a4e00ab 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetworkPanel.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetworkPanel.tsx @@ -1,6 +1,11 @@ -import React, { useEffect, useRef } from 'react' +import React, { useEffect, useRef, useState } from 'react' import ky from 'ky' -import cytoscape from 'cytoscape' +import cytoscape, { + Core, + ElementDefinition, + EventObjectNode, + NodeSingular +} from 'cytoscape' import { BiomarkerMolecule } from '../../../types' import { Form, Grid, Input } from 'semantic-ui-react' import { alertGeneralError } from '../../../../../utils/util_functions' @@ -11,240 +16,314 @@ import '../../../../../css/cytoscape.css' // Defined in biomarkers.html declare const urlGeneAssociationsNetwork: string -/** Colors for the String standard: https://string-db.org/cgi/help ("Network" section). */ const COLORS_BY_STRING_RELATION = { fusion: ['Fusion', '#d90429'], coOccurrence: ['Co-occurrence', '#3a86ff'], experimental: ['Experimental', '#7209b7'], textMining: ['Text mining', '#d5ae5d'], database: ['Database', '#4cc9f0'], - coExpression: ['Co-expression', '#10002b'] // In the FAQ says 'white', but we have a white background, so it's not visible -} + coExpression: ['Co-expression', '#10002b'] +} as const -/** - * Renders the legends for the Cytoscape instance. - * @returns Component. - */ const CytoscapeLegends = () => (
Relations
    {Object.entries(COLORS_BY_STRING_RELATION).map(([_, [relationDescription, color]]) => ( -
  • {relationDescription}
  • +
  • + + {relationDescription} +
  • ))}
) -/** GeneAssociationsNetworkPanel props. */ interface GeneAssociationsNetworkPanelProps { - /** Selected BiomarkerMolecule instance to show the options. */ - selectedGene: BiomarkerMolecule, + selectedGene: BiomarkerMolecule } -export const GeneAssociationsNetworkPanel = (props: GeneAssociationsNetworkPanelProps) => { - const abortControllerTerm = useRef(new AbortController()) - const [minCombinedScore, setMinCombinedScore] = React.useState(950) +type CytoscapeResponseData = + | ElementDefinition[] + | { + nodes?: ElementDefinition[] + edges?: ElementDefinition[] + } + +export const GeneAssociationsNetworkPanel = ({ selectedGene }: GeneAssociationsNetworkPanelProps) => { + const cyContainerRef = useRef(null) + const cyRef = useRef(null) + const abortControllerRef = useRef(null) + const positionsRef = useRef>({}) + const [minCombinedScore, setMinCombinedScore] = useState(950) + + const cytoscapeStyles = [ + { + selector: 'core', + style: { + 'selection-box-color': '#AAD8FF', + 'selection-box-border-color': '#8BB0D0', + 'selection-box-opacity': 0.5 + } as any + }, + { + selector: 'node', + style: { + width: 'mapData(score, 0, 0.006769776522008331, 20, 60)', + height: 'mapData(score, 0, 0.006769776522008331, 20, 60)', + content: 'data(name)', + 'font-size': '12px', + 'text-valign': 'center', + 'text-halign': 'center', + 'background-color': '#555', + 'text-outline-color': '#555', + 'text-outline-width': '2px', + color: '#fff', + 'overlay-padding': '6px', + 'z-index': 10 + } + }, + { + selector: 'node[?attr]', + style: { + shape: 'rectangle', + 'background-color': '#aaa', + 'text-outline-color': '#aaa', + width: '16px', + height: '16px', + 'font-size': '6px', + 'z-index': 1 + } + }, + { + selector: 'node[?query]', + style: { + 'background-clip': 'none', + 'background-fit': 'contain' + } + }, + { + selector: 'node:selected', + style: { + 'border-width': '6px', + 'border-color': '#AAD8FF', + 'border-opacity': 0.5, + 'background-color': '#77828C', + 'text-outline-color': '#77828C' + } + }, + { + selector: 'edge', + style: { + 'curve-style': 'haystack', + 'haystack-radius': 0.5, + opacity: 0.4, + 'line-color': '#bbb', + width: 'mapData(weight, 0, 1, 1, 8)', + 'overlay-padding': '3px' + } + }, + { + selector: 'node.unhighlighted', + style: { + opacity: 0.2 + } + }, + { + selector: 'edge.unhighlighted', + style: { + opacity: 0.05 + } + }, + { + selector: '.highlighted', + style: { + 'z-index': 999999 + } + }, + { + selector: 'node.highlighted', + style: { + 'border-width': '6px', + 'border-color': '#AAD8FF', + 'border-opacity': 0.5, + 'background-color': '#394855', + 'text-outline-color': '#394855' + } + }, + { + selector: 'edge.filtered', + style: { + opacity: 0 + } + }, + { + selector: 'edge[group="fusion"]', + style: { + 'line-color': COLORS_BY_STRING_RELATION.fusion[1] + } + }, + { + selector: 'edge[group="coOccurrence"]', + style: { + 'line-color': COLORS_BY_STRING_RELATION.coOccurrence[1] + } + }, + { + selector: 'edge[group="experimental"]', + style: { + 'line-color': COLORS_BY_STRING_RELATION.experimental[1] + } + }, + { + selector: 'edge[group="textMining"]', + style: { + 'line-color': COLORS_BY_STRING_RELATION.textMining[1] + } + }, + { + selector: 'edge[group="database"]', + style: { + 'line-color': COLORS_BY_STRING_RELATION.database[1] + } + }, + { + selector: 'edge[group="coExpression"]', + style: { + 'line-color': COLORS_BY_STRING_RELATION.coExpression[1] + } + } + ] + + const normalizeElements = (elements: CytoscapeResponseData): ElementDefinition[] => { + if (Array.isArray(elements)) { + return elements + } + + return [...(elements.nodes || []), ...(elements.edges || [])] + } + + const createCy = () => { + if (cyRef.current || !cyContainerRef.current) { return } - /** - * Initializes the Cytoscape instance with the given elements. - * @param elements Cytoscape elements to initialize the instance. - */ - const initCytoscape = (elements: any /* TODO: type */) => { const cy = cytoscape({ - container: document.getElementById('cy'), + container: cyContainerRef.current, minZoom: 0.5, maxZoom: 1.5, - randomize: true, - animate: true, - nodeSpacing: 15, - edgeLengthVal: 45, - style: [ - { - selector: 'core', - style: { - 'selection-box-color': '#AAD8FF', - 'selection-box-border-color': '#8BB0D0', - 'selection-box-opacity': '0.5' - } - }, - { - selector: 'node', - style: { - width: 'mapData(score, 0, 0.006769776522008331, 20, 60)', - height: 'mapData(score, 0, 0.006769776522008331, 20, 60)', - content: 'data(name)', - 'font-size': '12px', - 'text-valign': 'center', - 'text-halign': 'center', - 'background-color': '#555', - 'text-outline-color': '#555', - 'text-outline-width': '2px', - color: '#fff', - 'overlay-padding': '6px', - 'z-index': '10' - } - }, - { - selector: 'node[?attr]', - style: { - shape: 'rectangle', - 'background-color': '#aaa', - 'text-outline-color': '#aaa', - width: '16px', - height: '16px', - 'font-size': '6px', - 'z-index': '1' - } - }, - { - selector: 'node[?query]', - style: { - 'background-clip': 'none', - 'background-fit': 'contain' - } - }, - { - selector: 'node:selected', - style: { - 'border-width': '6px', - 'border-color': '#AAD8FF', - 'border-opacity': '0.5', - 'background-color': '#77828C', - 'text-outline-color': '#77828C' - } - }, - { - selector: 'edge', - style: { - 'curve-style': 'haystack', - 'haystack-radius': '0.5', - opacity: '0.4', - 'line-color': '#bbb', - width: 'mapData(weight, 0, 1, 1, 8)', - 'overlay-padding': '3px' - } - }, - { - selector: 'node.unhighlighted', - style: { - opacity: '0.2' - } - }, - { - selector: 'edge.unhighlighted', - style: { - opacity: '0.05' - } - }, - { - selector: '.highlighted', - style: { - 'z-index': '999999' - } - }, - { - selector: 'node.highlighted', - style: { - 'border-width': '6px', - 'border-color': '#AAD8FF', - 'border-opacity': '0.5', - 'background-color': '#394855', - 'text-outline-color': '#394855' - } - }, - { - selector: 'edge.filtered', - style: { - opacity: '0' - } - }, - // Types of relations - { - selector: 'edge[group="fusion"]', - style: { - 'line-color': COLORS_BY_STRING_RELATION.fusion[1] - } - }, - { - selector: 'edge[group="coOccurrence"]', - style: { - 'line-color': COLORS_BY_STRING_RELATION.coOccurrence[1] - } - }, - { - selector: 'edge[group="experimental"]', - style: { - 'line-color': COLORS_BY_STRING_RELATION.experimental[1] - } - }, - { - selector: 'edge[group="textMining"]', - style: { - 'line-color': COLORS_BY_STRING_RELATION.textMining[1] - } - }, - { - selector: 'edge[group="database"]', - style: { - 'line-color': COLORS_BY_STRING_RELATION.database[1] - } - }, - { - selector: 'edge[group="coExpression"]', - style: { - 'line-color': COLORS_BY_STRING_RELATION.coExpression[1] - } + elements: [], + style: cytoscapeStyles, + layout: { + name: 'preset' + } + }) + + cy.on('dragfreeon', 'node', (evt: EventObjectNode) => { + const node = evt.target + positionsRef.current[node.id()] = node.position() + }) + + cyRef.current = cy + } + + const applyElements = (rawElements: CytoscapeResponseData) => { + const cy = cyRef.current + + if (!cy) { return } + + const incomingElements = normalizeElements(rawElements) + + cy.nodes().forEach((node: NodeSingular) => { + positionsRef.current[node.id()] = node.position() + }) + + const mergedElements = incomingElements.map((element) => { + const elementId = element?.data?.id as string | undefined + const savedPosition = elementId ? positionsRef.current[elementId] : undefined + const isNode = !('source' in (element.data || {})) && !('target' in (element.data || {})) + + if (isNode && savedPosition) { + return { + ...element, + position: savedPosition } - ], - elements + } + + return element }) - const layout = cy.elements().layout({ - name: 'random' + cy.batch(() => { + cy.elements().remove() + cy.add(mergedElements) }) - layout.run() + const hasSavedPositions = cy + .nodes() + .toArray() + .some((node: NodeSingular) => Boolean(positionsRef.current[node.id()])) + + if (hasSavedPositions) { + cy.layout({ + name: 'preset', + fit: true, + padding: 30, + animate: false + }).run() + } else { + cy.layout({ + name: 'cose', + fit: true, + padding: 30, + animate: false, + randomize: true + }).run() + } + + cy.resize() + cy.fit(undefined, 30) } - /** - * Gets all the related genes to the given gene. - * @param selectedGene Gene object. - */ - const getRelatedGenes = (selectedGene: BiomarkerMolecule) => { - const searchParams = { gene_id: selectedGene.identifier, min_combined_score: minCombinedScore } - - ky.get(urlGeneAssociationsNetwork, { searchParams: searchParams as any, signal: abortControllerTerm.current.signal }).then((response) => { - response.json().then((data: { data: any /* TODO: type */ }) => { - // The response is a CytoscapeElements object already - initCytoscape(data.data) - }).catch((err) => { - alertGeneralError() - console.log('Error parsing JSON ->', err) + const getRelatedGenes = async (gene: BiomarkerMolecule, score: number) => { + abortControllerRef.current?.abort() + abortControllerRef.current = new AbortController() + + try { + const response = await ky.get(urlGeneAssociationsNetwork, { + searchParams: { + gene_id: gene.identifier, + min_combined_score: score + } as any, + signal: abortControllerRef.current.signal }) - }).catch((err) => { - if (!abortControllerTerm.current.signal.aborted) { + + const data = await response.json<{ data: CytoscapeResponseData }>() + applyElements(data.data) + } catch (err) { + if (!abortControllerRef.current?.signal.aborted) { alertGeneralError() } console.log('Error getting experiment', err) - }) + } } useEffect(() => { - getRelatedGenes(props.selectedGene) - }, [props.selectedGene]) + createCy() - /** On unmount, cancels the request */ - useEffect(() => { return () => { - // Cleanup: cancel the ongoing request when component unmounts - abortControllerTerm.current.abort() + abortControllerRef.current?.abort() + cyRef.current?.destroy() + cyRef.current = null } }, []) + useEffect(() => { + if (!cyRef.current) { return } + + getRelatedGenes(selectedGene, minCombinedScore) + }, [selectedGene, minCombinedScore]) + return ( @@ -257,27 +336,41 @@ export const GeneAssociationsNetworkPanel = (props: GeneAssociationsNetworkPanel value={minCombinedScore} min={900} max={1000} - // TODO: implement with debounce onChange={(_e, { value }) => setMinCombinedScore(Number(value))} /> + - The combined score is computed by combining the probabilities from the different evidence channels and corrected for the probability of randomly observing an interaction. For a more detailed description please see von Mering, et al. Nucleic Acids Res. 2005 + The combined score is computed by combining the probabilities from the different evidence channels and corrected for the probability of randomly observing an interaction. For a more detailed description please see{' '} + + von Mering, et al. Nucleic Acids Res. 2005 +
)} onTop={false} onEvent='click' /> + -
+
diff --git a/src/frontend/static/frontend/src/components/biomarkers/types.ts b/src/frontend/static/frontend/src/components/biomarkers/types.ts index 131ae12d..98d2b66f 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/types.ts +++ b/src/frontend/static/frontend/src/components/biomarkers/types.ts @@ -389,6 +389,7 @@ enum ActiveBiomarkerMoleculeItemMenu { PATHWAYS, GENE_ASSOCIATIONS_NETWORK, GENE_ONTOLOGY, + GENE_REGULATION_ASSOCIATIONS, INFERENCE, DISEASES, DRUGS, From 3271a108f19733f186d2970491c6332e5ba06eaa Mon Sep 17 00:00:00 2001 From: Juan Nicolas Herrera <54152074+juanNH@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:22:51 -0300 Subject: [PATCH 02/17] Update GeneAssociationsNetwork.tsx --- .../genes/GeneAssociationsNetwork.tsx | 950 ++++++++++++++++-- 1 file changed, 892 insertions(+), 58 deletions(-) diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx index ad7f0e0d..04e4ff86 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx @@ -1,5 +1,6 @@ -import React, { useEffect, useMemo, useRef } from 'react' -import cytoscape, { Core, ElementDefinition } from 'cytoscape' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import cytoscape, { Core, ElementDefinition, NodeSingular } from 'cytoscape' +import { Button, Dropdown, Icon, Label } from 'semantic-ui-react' type NodeKind = 'mRNA' | 'miRNA' | 'CNA' | 'Methylation' | 'Drug' @@ -8,6 +9,28 @@ type Props = { width?: number | string; } +type TooltipState = { + visible: boolean; + x: number; + y: number; + content: string; +} + +type SelectedEdgeInfo = { + id: string; + source: string; + target: string; + correlation: number; + direction: 'Down regulate' | 'Up regulate'; +} + +type DepthSummaryItem = { + depth: number; + nodes: string[]; +} + +type TraversalMode = 'outgoing' | 'incoming' | 'both' + const NODE_COLORS: Record = { mRNA: '#4f46e5', miRNA: '#db2777', @@ -16,35 +39,64 @@ const NODE_COLORS: Record = { Drug: '#64748b', } -const getEdgeColor = (correlation: number) => { - if (correlation <= -0.5) { return '#dc2626' } +const LEVEL_COLORS = { + root: '#f59e0b', + level1: '#0ea5e9', + level2: '#22c55e', + level3: '#a855f7', +} + +const roundThreshold = (value: number) => Number(value.toFixed(1)) - if (correlation >= 0.5) { return '#2563eb' } +const getEdgeColor = (correlation: number, threshold: number) => { + if (correlation <= -threshold) { return '#dc2626' } - return '#cbd5e1' + if (correlation >= threshold) { return '#2563eb' } + + return 'transparent' } -const getEdgeOpacity = (correlation: number) => { +const getEdgeOpacity = (correlation: number, threshold: number) => { const abs = Math.abs(correlation) + if (abs < threshold) { return 0 } + if (abs >= 0.8) { return 0.95 } + if (abs >= 0.6) { return 0.8 } - if (abs >= 0.5) { return 0.65 } - return 0.22 + return 0.7 } -const getEdgeWidth = (correlation: number) => { +const getEdgeWidth = (correlation: number, threshold: number) => { const abs = Math.abs(correlation) + if (abs < threshold) { return 0 } + if (abs >= 0.9) { return 6 } + if (abs >= 0.8) { return 5 } + if (abs >= 0.7) { return 4 } - if (abs >= 0.5) { return 3 } - return 1.5 + return 3 +} + +const getDirectionLabel = ( + correlation: number, + threshold: number +): SelectedEdgeInfo['direction'] => { + if (correlation <= -threshold) { return 'Down regulate' } + + return 'Up regulate' } +const traversalOptions = [ + { key: 'outgoing', value: 'outgoing', text: 'Regula' }, + { key: 'incoming', value: 'incoming', text: 'Es regulado por' }, + { key: 'both', value: 'both', text: 'Ambos' }, +] + export const GeneExpressionRegulationNetworkPanel = ({ height = 650, width = '100%', @@ -52,6 +104,21 @@ export const GeneExpressionRegulationNetworkPanel = ({ const containerRef = useRef(null) const cyRef = useRef(null) + const [threshold, setThreshold] = useState(0.5) + const [traversalMode, setTraversalMode] = useState('outgoing') + + const [tooltip, setTooltip] = useState({ + visible: false, + x: 0, + y: 0, + content: '', + }) + + const [selectedEdges, setSelectedEdges] = useState([]) + const [selectedRootNode, setSelectedRootNode] = useState(null) + const [depthSummary, setDepthSummary] = useState([]) + const [incomingSummary, setIncomingSummary] = useState([]) + const elements = useMemo(() => { const nodes: ElementDefinition[] = [ { data: { id: 'mrna_pcna', label: 'PCNA', type: 'mRNA', size: 58 } }, @@ -122,25 +189,43 @@ export const GeneExpressionRegulationNetworkPanel = ({ ['cna_1q_gain', 'mrna_apex2', 0.33], ] as const - const edges: ElementDefinition[] = rawEdges.map(([source, target, correlation], index) => ({ - data: { - id: `e_${index + 1}`, - source, - target, - correlation, - edgeColor: getEdgeColor(correlation), - edgeOpacity: getEdgeOpacity(correlation), - edgeWidth: getEdgeWidth(correlation), - }, - classes: Math.abs(correlation) < 0.5 ? 'weak-edge' : '', - })) + const edges: ElementDefinition[] = rawEdges + .filter(([, , correlation]) => Math.abs(correlation) >= threshold) + .map(([source, target, correlation], index) => ({ + data: { + id: `e_${index + 1}`, + source, + target, + correlation, + edgeColor: getEdgeColor(correlation, threshold), + edgeOpacity: getEdgeOpacity(correlation, threshold), + edgeWidth: getEdgeWidth(correlation, threshold), + directionLabel: getDirectionLabel(correlation, threshold), + }, + })) return [...nodes, ...edges] - }, []) + }, [threshold]) useEffect(() => { if (!containerRef.current) { return } + if (cyRef.current) { + cyRef.current.destroy() + cyRef.current = null + } + + setSelectedEdges([]) + setSelectedRootNode(null) + setDepthSummary([]) + setIncomingSummary([]) + setTooltip({ + visible: false, + x: 0, + y: 0, + content: '', + }) + const cy = cytoscape({ container: containerRef.current, elements, @@ -167,6 +252,7 @@ export const GeneExpressionRegulationNetworkPanel = ({ 'text-outline-width': 3, 'border-width': 2, 'border-color': '#ffffff', + opacity: 1, }, }, { @@ -211,30 +297,126 @@ export const GeneExpressionRegulationNetworkPanel = ({ 'line-color': 'data(edgeColor)', opacity: 'data(edgeOpacity)' as any, 'curve-style': 'bezier', + 'target-arrow-shape': 'triangle', + 'target-arrow-color': 'data(edgeColor)', + 'arrow-scale': 1.15, + }, + }, + { + selector: 'edge.edge-picked', + style: { + opacity: 1, + 'underlay-color': '#000000', + 'underlay-opacity': 1, + 'underlay-padding': 9, + width: 'mapData(edgeWidth, 3, 6, 5, 8)', + 'z-index': 999, + }, + }, + { + selector: 'edge.depth-path-outgoing', + style: { + width: 7, + }, + }, + { + selector: 'edge.depth-path-incoming', + style: { + width: 7, + 'line-style': 'dotted', + 'target-arrow-shape': 'triangle', + }, + }, + { + selector: 'node.depth-root', + style: { + 'border-color': LEVEL_COLORS.root, + 'border-width': 6, + opacity: 1, + }, + }, + { + selector: 'node.depth-level-1', + style: { + 'border-color': LEVEL_COLORS.level1, + 'border-width': 5, + }, + }, + { + selector: 'node.depth-level-2', + style: { + 'border-color': LEVEL_COLORS.level2, + 'border-width': 5, }, }, { - selector: 'edge.weak-edge', + selector: 'node.depth-level-3', style: { - 'line-style': 'dashed', + 'border-color': LEVEL_COLORS.level3, + 'border-width': 5, + }, + }, + { + selector: 'node.incoming-level-1', + style: { + 'border-style': 'double', + 'border-color': LEVEL_COLORS.level1, + 'border-width': 5, + }, + }, + { + selector: 'node.incoming-level-2', + style: { + 'border-style': 'double', + 'border-color': LEVEL_COLORS.level2, + 'border-width': 5, + }, + }, + { + selector: 'node.incoming-level-3', + style: { + 'border-style': 'double', + 'border-color': LEVEL_COLORS.level3, + 'border-width': 5, }, }, { - selector: ':selected', + selector: 'node:selected', style: { 'border-color': '#f8fafc', 'border-width': 5, - 'line-color': '#0f172a', - 'target-arrow-color': '#0f172a', - 'source-arrow-color': '#0f172a', }, }, { - selector: '.faded', + selector: '.fade-unrelated', style: { opacity: 0.12, }, }, + { + selector: '.fade-level-1', + style: { + opacity: 1, + }, + }, + { + selector: '.fade-level-2', + style: { + opacity: 0.72, + }, + }, + { + selector: '.fade-level-3', + style: { + opacity: 0.45, + }, + }, + { + selector: '.fade-level-4plus', + style: { + opacity: 0.25, + }, + }, ], layout: { name: 'cose', @@ -251,18 +433,322 @@ export const GeneExpressionRegulationNetworkPanel = ({ cyRef.current = cy + const syncSelectedEdges = () => { + const picked = cy + .edges('.edge-picked') + .toArray() + .map((edge: any) => { + const sourceNode = edge.source() + const targetNode = edge.target() + + return { + id: edge.id(), + source: sourceNode.data('label') || edge.data('source'), + target: targetNode.data('label') || edge.data('target'), + correlation: edge.data('correlation'), + direction: edge.data('directionLabel'), + } + }) + + setSelectedEdges(picked) + } + + const clearTraversal = () => { + cy.batch(() => { + cy.nodes().forEach((node: any) => { + node.removeClass('depth-root') + node.removeClass('depth-level-1') + node.removeClass('depth-level-2') + node.removeClass('depth-level-3') + node.removeClass('incoming-level-1') + node.removeClass('incoming-level-2') + node.removeClass('incoming-level-3') + node.removeClass('fade-unrelated') + node.removeClass('fade-level-1') + node.removeClass('fade-level-2') + node.removeClass('fade-level-3') + node.removeClass('fade-level-4plus') + }) + + cy.edges().forEach((edge: any) => { + edge.removeClass('depth-path-outgoing') + edge.removeClass('depth-path-incoming') + edge.removeClass('fade-unrelated') + edge.removeClass('fade-level-1') + edge.removeClass('fade-level-2') + edge.removeClass('fade-level-3') + edge.removeClass('fade-level-4plus') + }) + }) + + setSelectedRootNode(null) + setDepthSummary([]) + setIncomingSummary([]) + } + + const assignFadeClass = (ele: any, level: number) => { + ele.removeClass('fade-unrelated') + ele.removeClass('fade-level-1') + ele.removeClass('fade-level-2') + ele.removeClass('fade-level-3') + ele.removeClass('fade-level-4plus') + + if (level <= 1) { + ele.addClass('fade-level-1') + return + } + + if (level === 2) { + ele.addClass('fade-level-2') + return + } + + if (level === 3) { + ele.addClass('fade-level-3') + return + } + + ele.addClass('fade-level-4plus') + } + + const applyProgressiveFading = ( + root: NodeSingular, + outgoingVisited: Map, + incomingVisited: Map + ) => { + cy.batch(() => { + cy.nodes().forEach((node: any) => { + if (node.id() === root.id()) { + assignFadeClass(node, 1) + return + } + + const outDepth = outgoingVisited.get(node.id()) + const inDepth = incomingVisited.get(node.id()) + + if (outDepth == null && inDepth == null) { + node.addClass('fade-unrelated') + return + } + + const bestDepth = Math.min( + outDepth ?? Number.POSITIVE_INFINITY, + inDepth ?? Number.POSITIVE_INFINITY + ) + + assignFadeClass(node, bestDepth) + }) + + cy.edges().forEach((edge: any) => { + const sourceId = edge.data('source') + const targetId = edge.data('target') + + const outSource = outgoingVisited.get(sourceId) + const outTarget = outgoingVisited.get(targetId) + const inSource = incomingVisited.get(sourceId) + const inTarget = incomingVisited.get(targetId) + + const outgoingRelated = outSource != null && outTarget != null + const incomingRelated = inSource != null && inTarget != null + + if (!outgoingRelated && !incomingRelated) { + edge.addClass('fade-unrelated') + return + } + + const candidateDepths: number[] = [] + + if (outgoingRelated) { + candidateDepths.push(Math.max(outSource!, outTarget!)) + } + + if (incomingRelated) { + candidateDepths.push(Math.max(inSource!, inTarget!)) + } + + const bestDepth = Math.min(...candidateDepths) + assignFadeClass(edge, bestDepth) + }) + }) + } + + const walkOutgoing = (root: NodeSingular) => { + const visitedNodes = new Map() + const summaryMap = new Map() + + const queue: Array<{ node: NodeSingular; depth: number }> = [{ node: root, depth: 0 }] + visitedNodes.set(root.id(), 0) + + while (queue.length > 0) { + const current = queue.shift() + + if (!current) { continue } + + const { node, depth } = current + const outgoers = node.outgoers('edge') + + outgoers.forEach((edge: any) => { + const target = edge.target() + const nextDepth = depth + 1 + + edge.addClass('depth-path-outgoing') + + if (!visitedNodes.has(target.id()) || nextDepth < visitedNodes.get(target.id())!) { + visitedNodes.set(target.id(), nextDepth) + + if (nextDepth === 1) { + target.addClass('depth-level-1') + } else if (nextDepth === 2) { + target.addClass('depth-level-2') + } else { + target.addClass('depth-level-3') + } + + const arr = summaryMap.get(nextDepth) || [] + const label = target.data('label') + + if (!arr.includes(label)) { + arr.push(label) + summaryMap.set(nextDepth, arr) + } + + queue.push({ node: target, depth: nextDepth }) + } + }) + } + + const summary = Array.from(summaryMap.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([depth, nodes]) => ({ depth, nodes })) + + return { visitedNodes, summary } + } + + const walkIncoming = (root: NodeSingular) => { + const visitedNodes = new Map() + const summaryMap = new Map() + + const queue: Array<{ node: NodeSingular; depth: number }> = [{ node: root, depth: 0 }] + visitedNodes.set(root.id(), 0) + + while (queue.length > 0) { + const current = queue.shift() + + if (!current) { continue } + + const { node, depth } = current + const incomers = node.incomers('edge') + + incomers.forEach((edge: any) => { + const source = edge.source() + const nextDepth = depth + 1 + + edge.addClass('depth-path-incoming') + + if (!visitedNodes.has(source.id()) || nextDepth < visitedNodes.get(source.id())!) { + visitedNodes.set(source.id(), nextDepth) + + if (nextDepth === 1) { + source.addClass('incoming-level-1') + } else if (nextDepth === 2) { + source.addClass('incoming-level-2') + } else { + source.addClass('incoming-level-3') + } + + const arr = summaryMap.get(nextDepth) || [] + const label = source.data('label') + + if (!arr.includes(label)) { + arr.push(label) + summaryMap.set(nextDepth, arr) + } + + queue.push({ node: source, depth: nextDepth }) + } + }) + } + + const summary = Array.from(summaryMap.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([depth, nodes]) => ({ depth, nodes })) + + return { visitedNodes, summary } + } + + const applyTraversal = (root: NodeSingular, mode: TraversalMode) => { + clearTraversal() + + cy.batch(() => { + root.addClass('depth-root') + }) + + const outgoing = mode === 'outgoing' || mode === 'both' + ? walkOutgoing(root) + : { visitedNodes: new Map(), summary: [] } + + const incoming = mode === 'incoming' || mode === 'both' + ? walkIncoming(root) + : { visitedNodes: new Map(), summary: [] } + + applyProgressiveFading(root, outgoing.visitedNodes, incoming.visitedNodes) + + setSelectedRootNode(root.data('label')) + setDepthSummary(outgoing.summary) + setIncomingSummary(incoming.summary) + } + cy.on('tap', 'node', (evt) => { const node = evt.target - const neighborhood = node.closedNeighborhood() + applyTraversal(node, traversalMode) + }) + + cy.on('tap', 'edge', (evt) => { + const edge = evt.target + + if (edge.hasClass('edge-picked')) { + edge.removeClass('edge-picked') + } else { + edge.addClass('edge-picked') + } + + syncSelectedEdges() + }) + + cy.on('mouseover', 'edge', (evt) => { + const edge = evt.target + const correlation = edge.data('correlation') + const direction = edge.data('directionLabel') + + setTooltip({ + visible: true, + x: evt.renderedPosition?.x ?? 0, + y: evt.renderedPosition?.y ?? 0, + content: `${direction} | correlation: ${correlation}`, + }) + }) + + cy.on('mousemove', 'edge', (evt) => { + setTooltip((prev) => ({ + ...prev, + x: evt.renderedPosition?.x ?? prev.x, + y: evt.renderedPosition?.y ?? prev.y, + visible: true, + })) + }) - cy.elements().addClass('faded') - neighborhood.removeClass('faded') + cy.on('mouseout', 'edge', () => { + setTooltip((prev) => ({ + ...prev, + visible: false, + })) }) cy.on('tap', (evt) => { if (evt.target === cy) { - cy.elements().removeClass('faded') cy.elements().unselect() + setTooltip((prev) => ({ ...prev, visible: false })) + clearTraversal() } }) @@ -270,47 +756,381 @@ export const GeneExpressionRegulationNetworkPanel = ({ cy.destroy() cyRef.current = null } - }, [elements]) + }, [elements, traversalMode]) + + const removeSelectedEdgeFromList = (edgeId: string) => { + const cy = cyRef.current + + if (!cy) { return } + + const edge = cy.getElementById(edgeId) + + if (edge && edge.nonempty() && edge.hasClass('edge-picked')) { + edge.removeClass('edge-picked') + } + + setSelectedEdges((prev) => prev.filter((item) => item.id !== edgeId)) + } + + const clearAllSelectedEdges = () => { + const cy = cyRef.current + + if (!cy) { + setSelectedEdges([]) + return + } + + cy.edges('.edge-picked').removeClass('edge-picked') + setSelectedEdges([]) + } return (
-
- - - - - - - +
+
+
+ Correlation threshold +
+ + +
+ + { + setThreshold(roundThreshold(Number(e.target.value))) + }} + style={{ + width: '100%', + accentColor: '#2563eb', + cursor: 'pointer', + }} + /> + +
+ ±0.1 + ±0.5 + ±0.9 +
+ +
+
+ + Análisis del nodo: + + + setTraversalMode(data.value as TraversalMode)} + /> +
+ +
+ + + + + + + +
+
+
+ +
+
+ + {tooltip.visible && ( +
+ {tooltip.content} +
+ )}
+ > +
+
+
Aristas seleccionadas
+ + +
+ + {selectedEdges.length === 0 + ? ( +
+ Hacé click en múltiples aristas para ver el valor de cada una. +
+ ) + : ( +
+ {selectedEdges.map((edge) => ( +
+
+
+ {edge.source} → {edge.target} +
+
Direction: {edge.direction}
+
Correlation: {edge.correlation}
+
+ +
+ ))} +
+ )} +
+ +
+
+ {traversalMode === 'outgoing' + ? 'Nivel de profundidad' + : traversalMode === 'incoming' + ? 'Nivel de regulación ascendente' + : 'Niveles de relación'} +
+ + {!selectedRootNode + ? ( +
+ Seleccioná un nodo para calcular niveles. +
+ ) + : ( +
+
+ Nodo raíz: {selectedRootNode} +
+ + {(traversalMode === 'outgoing' || traversalMode === 'both') && ( +
+
+ Regula +
+ + {depthSummary.length === 0 + ? ( +
+ No regula nodos visibles con el threshold actual. +
+ ) + : ( + depthSummary.map((item) => ( +
+
+ + Nivel {item.depth} +
+
{item.nodes.join(', ')}
+
+ )) + )} +
+ )} + + {(traversalMode === 'incoming' || traversalMode === 'both') && ( +
+
+ Es regulado por +
+ + {incomingSummary.length === 0 + ? ( +
+ No tiene reguladores visibles con el threshold actual. +
+ ) + : ( + incomingSummary.map((item) => ( +
+
+ + Nivel {item.depth} +
+
{item.nodes.join(', ')}
+
+ )) + )} +
+ )} +
+ )} +
+
) } +const LevelBadge = ({ depth, incoming = false }: { depth: number; incoming?: boolean }) => { + const color = + depth === 1 + ? LEVEL_COLORS.level1 + : depth === 2 + ? LEVEL_COLORS.level2 + : LEVEL_COLORS.level3 + + return ( + + ) +} + const LegendDot = ({ color, label }: { color: string; label: string }) => ( ( ) -const LegendLine = ({ color, label }: { color: string; label: string }) => ( +const LegendArrow = ({ color, label }: { color: string; label: string }) => ( + > + + {label} ) From 132fcf5cb5b3450d461b9dc41d221ce8e210a635 Mon Sep 17 00:00:00 2001 From: Juan Nicolas Herrera <54152074+juanNH@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:42:37 -0300 Subject: [PATCH 03/17] Gene regulation Association base --- .../molecules/CurrentMoleculeDetails.tsx | 6 +- .../genes/GeneAssociationsNetwork.tsx | 279 +++++++++------ .../GeneExpressionRegulationNetworkPanel.tsx | 108 ++++++ .../GeneNetworkGraph.tsx | 331 ++++++++++++++++++ .../GraphControls.tsx | 180 ++++++++++ .../gene-association-network/LevelBadge.tsx | 40 +++ .../SelectedEdgesPanel.tsx | 97 +++++ .../TraversalSummaryPanel.tsx | 128 +++++++ .../gene-association-network/graphApi.ts | 202 +++++++++++ .../gene-association-network/graphStyle.ts | 14 + .../genes/gene-association-network/legend.tsx | 44 +++ .../mockNetworkData.ts | 52 +++ .../genes/gene-association-network/types.ts | 45 +++ .../useGeneGraphQuery.ts | 58 +++ 14 files changed, 1467 insertions(+), 117 deletions(-) create mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneExpressionRegulationNetworkPanel.tsx create mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneNetworkGraph.tsx create mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GraphControls.tsx create mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/LevelBadge.tsx create mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/SelectedEdgesPanel.tsx create mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/TraversalSummaryPanel.tsx create mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphApi.ts create mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphStyle.ts create mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/legend.tsx create mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/mockNetworkData.ts create mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/types.ts create mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/useGeneGraphQuery.ts diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx index 1f173352..cc17d21f 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx @@ -7,11 +7,11 @@ import { MoleculeGeneralInformation } from './MoleculeGeneralInformation' import { PathwaysInformation } from './genes/PathwaysInformation' import { MirnaInteractionsPanel } from './MirnaInteractionsPanel' import { GeneOntologyPanel } from './gene-ontology/GeneOntologyPanel' -import { GeneAssociationsNetworkPanel } from './genes/GeneAssociationsNetworkPanel' import { MiRNADrugsPanel } from '../../../pipeline/experiment-result/gene-gem-details/MiRNADrugsPanel' import { MiRNADiseasesPanel } from '../../../pipeline/experiment-result/gene-gem-details/MiRNADiseasesPanel' import { ActionableCancerGenesPanel } from './genes/ActionableCancerGenesPanel' -import { GeneExpressionRegulationNetworkPanel } from './genes/GeneAssociationsNetwork' +import { GeneExpressionRegulationAssociationNetworkPanel } from './genes/gene-association-network/GeneExpressionRegulationNetworkPanel' +import { GeneAssociationsNetworkPanel } from './genes/GeneAssociationsNetworkPanel' // const MENU_DEFAULT: ActiveBiomarkerMoleculeItemMenu = ActiveBiomarkerMoleculeItemMenu.DETAILS // TODO: use this const MENU_DEFAULT: ActiveBiomarkerMoleculeItemMenu = ActiveBiomarkerMoleculeItemMenu.DETAILS @@ -54,7 +54,7 @@ export const CurrentMoleculeDetails = (props: CurrentMoleculeDetailsProps) => { case ActiveBiomarkerMoleculeItemMenu.GENE_ONTOLOGY: return case ActiveBiomarkerMoleculeItemMenu.GENE_REGULATION_ASSOCIATIONS: - return + return case ActiveBiomarkerMoleculeItemMenu.DISEASES: return case ActiveBiomarkerMoleculeItemMenu.DRUGS: diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx index 04e4ff86..85a7b886 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import cytoscape, { Core, ElementDefinition, NodeSingular } from 'cytoscape' import { Button, Dropdown, Icon, Label } from 'semantic-ui-react' -type NodeKind = 'mRNA' | 'miRNA' | 'CNA' | 'Methylation' | 'Drug' +type NodeKind = 'Gene' | 'miRNA' | 'CNA' | 'Methylation' | 'Drug' type Props = { height?: number | string; @@ -32,7 +32,7 @@ type DepthSummaryItem = { type TraversalMode = 'outgoing' | 'incoming' | 'both' const NODE_COLORS: Record = { - mRNA: '#4f46e5', + Gene: '#4f46e5', miRNA: '#db2777', CNA: '#f59e0b', Methylation: '#10b981', @@ -50,9 +50,7 @@ const roundThreshold = (value: number) => Number(value.toFixed(1)) const getEdgeColor = (correlation: number, threshold: number) => { if (correlation <= -threshold) { return '#dc2626' } - if (correlation >= threshold) { return '#2563eb' } - return 'transparent' } @@ -60,9 +58,7 @@ const getEdgeOpacity = (correlation: number, threshold: number) => { const abs = Math.abs(correlation) if (abs < threshold) { return 0 } - if (abs >= 0.8) { return 0.95 } - if (abs >= 0.6) { return 0.8 } return 0.7 @@ -72,11 +68,8 @@ const getEdgeWidth = (correlation: number, threshold: number) => { const abs = Math.abs(correlation) if (abs < threshold) { return 0 } - if (abs >= 0.9) { return 6 } - if (abs >= 0.8) { return 5 } - if (abs >= 0.7) { return 4 } return 3 @@ -87,7 +80,6 @@ const getDirectionLabel = ( threshold: number ): SelectedEdgeInfo['direction'] => { if (correlation <= -threshold) { return 'Down regulate' } - return 'Up regulate' } @@ -106,6 +98,7 @@ export const GeneExpressionRegulationNetworkPanel = ({ const [threshold, setThreshold] = useState(0.5) const [traversalMode, setTraversalMode] = useState('outgoing') + const [maxLevels, setMaxLevels] = useState(3) const [tooltip, setTooltip] = useState({ visible: false, @@ -115,78 +108,58 @@ export const GeneExpressionRegulationNetworkPanel = ({ }) const [selectedEdges, setSelectedEdges] = useState([]) - const [selectedRootNode, setSelectedRootNode] = useState(null) + const [selectedRootNode, setSelectedRootNode] = useState('BRAF') const [depthSummary, setDepthSummary] = useState([]) const [incomingSummary, setIncomingSummary] = useState([]) const elements = useMemo(() => { const nodes: ElementDefinition[] = [ - { data: { id: 'mrna_pcna', label: 'PCNA', type: 'mRNA', size: 58 } }, - { data: { id: 'mrna_fen1', label: 'FEN1', type: 'mRNA', size: 54 } }, - { data: { id: 'mrna_rad51', label: 'RAD51', type: 'mRNA', size: 36 } }, - { data: { id: 'mrna_pold1', label: 'POLD1', type: 'mRNA', size: 32 } }, - { data: { id: 'mrna_lig1', label: 'LIG1', type: 'mRNA', size: 30 } }, + { data: { id: 'gene_braf', label: 'BRAF', type: 'Gene', size: 64, isRoot: true } }, + { data: { id: 'gene_mek1', label: 'MEK1', type: 'Gene', size: 44 } }, + { data: { id: 'gene_erk1', label: 'ERK1', type: 'Gene', size: 40 } }, + { data: { id: 'gene_myc', label: 'MYC', type: 'Gene', size: 38 } }, + { data: { id: 'gene_ccnd1', label: 'CCND1', type: 'Gene', size: 38 } }, + { data: { id: 'gene_dusp6', label: 'DUSP6', type: 'Gene', size: 34 } }, + { data: { id: 'gene_fos', label: 'FOS', type: 'Gene', size: 34 } }, + { data: { id: 'gene_elk1', label: 'ELK1', type: 'Gene', size: 32 } }, + { data: { id: 'gene_map3k8', label: 'MAP3K8', type: 'Gene', size: 30 } }, + { data: { id: 'gene_spry2', label: 'SPRY2', type: 'Gene', size: 30 } }, + { data: { id: 'mir_17', label: 'miR-17', type: 'miRNA', size: 28 } }, { data: { id: 'mir_21', label: 'miR-21', type: 'miRNA', size: 28 } }, - { data: { id: 'mir_34a', label: 'miR-34a', type: 'miRNA', size: 28 } }, - { data: { id: 'mir_155', label: 'miR-155', type: 'miRNA', size: 28 } }, - { data: { id: 'mir_200c', label: 'miR-200c', type: 'miRNA', size: 28 } }, - { data: { id: 'cna_8q24', label: 'CNA 8q24', type: 'CNA', size: 34 } }, - { data: { id: 'cna_17p_loss', label: '17p loss', type: 'CNA', size: 34 } }, - { data: { id: 'cna_1q_gain', label: '1q gain', type: 'CNA', size: 34 } }, - { data: { id: 'meth_mlh1', label: 'MLH1 meth', type: 'Methylation', size: 32 } }, - { data: { id: 'meth_mgmt', label: 'MGMT meth', type: 'Methylation', size: 32 } }, - { data: { id: 'drug_olaparib', label: 'Olaparib', type: 'Drug', size: 34 } }, - { data: { id: 'drug_cisplatin', label: 'Cisplatin', type: 'Drug', size: 34 } }, - { data: { id: 'drug_temozolomide', label: 'Temozolomide', type: 'Drug', size: 34 } }, - { data: { id: 'mrna_apex2', label: 'APEX2', type: 'mRNA', size: 32 } }, + { data: { id: 'cna_7q34', label: '7q34 gain', type: 'CNA', size: 34 } }, + { data: { id: 'meth_rassf1', label: 'RASSF1 meth', type: 'Methylation', size: 34 } }, + { data: { id: 'drug_vemurafenib', label: 'Vemurafenib', type: 'Drug', size: 34 } }, ] const rawEdges = [ - ['mrna_pcna', 'mrna_fen1', 0.92], - ['mrna_pcna', 'mrna_rad51', 0.87], - ['mrna_pcna', 'mrna_pold1', 0.83], - ['mrna_pcna', 'mrna_lig1', 0.79], - ['mrna_pcna', 'mrna_apex2', 0.72], - ['mrna_fen1', 'mrna_rad51', 0.76], - ['mrna_fen1', 'mrna_pold1', 0.81], - ['mrna_fen1', 'mrna_apex2', 0.69], - ['mrna_rad51', 'mrna_apex2', 0.64], - ['mrna_pold1', 'mrna_lig1', 0.71], - ['mrna_lig1', 'mrna_apex2', 0.58], - - ['mir_21', 'mrna_pcna', -0.74], - ['mir_21', 'mrna_fen1', -0.68], - ['mir_21', 'mrna_rad51', -0.61], - ['mir_34a', 'mrna_pcna', -0.71], - ['mir_34a', 'mrna_pold1', -0.66], - ['mir_155', 'mrna_rad51', -0.78], - ['mir_155', 'mrna_apex2', -0.57], - ['mir_200c', 'mrna_lig1', -0.63], - ['mir_200c', 'mrna_fen1', -0.55], - - ['cna_8q24', 'mrna_pcna', 0.67], - ['cna_8q24', 'mrna_fen1', 0.54], - ['cna_17p_loss', 'mrna_rad51', -0.58], - ['cna_17p_loss', 'mrna_apex2', -0.52], - ['cna_1q_gain', 'mrna_pold1', 0.62], - ['cna_1q_gain', 'mrna_lig1', 0.57], - ['meth_mlh1', 'mrna_pcna', -0.53], - ['meth_mlh1', 'mrna_apex2', -0.64], - ['meth_mgmt', 'mrna_rad51', -0.59], - ['meth_mgmt', 'mrna_fen1', -0.51], - - ['drug_olaparib', 'mrna_rad51', -0.69], - ['drug_olaparib', 'mrna_fen1', -0.56], - ['drug_cisplatin', 'mrna_pcna', -0.52], - ['drug_cisplatin', 'mrna_lig1', -0.55], - ['drug_temozolomide', 'meth_mgmt', 0.73], - ['drug_temozolomide', 'mrna_apex2', -0.51], - - ['mir_21', 'cna_8q24', 0.22], - ['mir_34a', 'meth_mlh1', -0.18], - ['drug_olaparib', 'drug_cisplatin', 0.31], - ['mrna_pcna', 'drug_temozolomide', -0.27], - ['cna_1q_gain', 'mrna_apex2', 0.33], + ['gene_braf', 'gene_mek1', 0.92], + ['gene_braf', 'gene_erk1', 0.84], + ['gene_braf', 'gene_dusp6', 0.72], + ['gene_braf', 'gene_fos', 0.69], + + ['gene_mek1', 'gene_erk1', 0.88], + ['gene_mek1', 'gene_elk1', 0.76], + ['gene_mek1', 'gene_ccnd1', 0.67], + + ['gene_erk1', 'gene_myc', 0.81], + ['gene_erk1', 'gene_fos', 0.79], + ['gene_erk1', 'gene_spry2', 0.62], + + ['gene_fos', 'gene_ccnd1', 0.58], + ['gene_elk1', 'gene_myc', 0.61], + + ['mir_17', 'gene_braf', -0.66], + ['mir_21', 'gene_mek1', -0.57], + + ['cna_7q34', 'gene_braf', 0.73], + ['meth_rassf1', 'gene_braf', -0.54], + ['drug_vemurafenib', 'gene_braf', -0.89], + + ['gene_map3k8', 'gene_mek1', 0.55], + ['gene_braf', 'gene_map3k8', 0.52], + + ['gene_spry2', 'gene_braf', -0.32], + ['drug_vemurafenib', 'gene_mek1', -0.41], ] as const const edges: ElementDefinition[] = rawEdges @@ -216,7 +189,6 @@ export const GeneExpressionRegulationNetworkPanel = ({ } setSelectedEdges([]) - setSelectedRootNode(null) setDepthSummary([]) setIncomingSummary([]) setTooltip({ @@ -256,10 +228,10 @@ export const GeneExpressionRegulationNetworkPanel = ({ }, }, { - selector: 'node[type = "mRNA"]', + selector: 'node[type = "Gene"]', style: { - 'background-color': NODE_COLORS.mRNA, - 'text-outline-color': NODE_COLORS.mRNA, + 'background-color': NODE_COLORS.Gene, + 'text-outline-color': NODE_COLORS.Gene, }, }, { @@ -356,6 +328,13 @@ export const GeneExpressionRegulationNetworkPanel = ({ 'border-width': 5, }, }, + { + selector: 'node.depth-level-4, node.depth-level-5, node.depth-level-6, node.depth-level-7, node.depth-level-8, node.depth-level-9, node.depth-level-10', + style: { + 'border-color': '#94a3b8', + 'border-width': 5, + }, + }, { selector: 'node.incoming-level-1', style: { @@ -380,6 +359,14 @@ export const GeneExpressionRegulationNetworkPanel = ({ 'border-width': 5, }, }, + { + selector: 'node.incoming-level-4, node.incoming-level-5, node.incoming-level-6, node.incoming-level-7, node.incoming-level-8, node.incoming-level-9, node.incoming-level-10', + style: { + 'border-style': 'double', + 'border-color': '#94a3b8', + 'border-width': 5, + }, + }, { selector: 'node:selected', style: { @@ -456,18 +443,21 @@ export const GeneExpressionRegulationNetworkPanel = ({ const clearTraversal = () => { cy.batch(() => { cy.nodes().forEach((node: any) => { - node.removeClass('depth-root') - node.removeClass('depth-level-1') - node.removeClass('depth-level-2') - node.removeClass('depth-level-3') - node.removeClass('incoming-level-1') - node.removeClass('incoming-level-2') - node.removeClass('incoming-level-3') - node.removeClass('fade-unrelated') - node.removeClass('fade-level-1') - node.removeClass('fade-level-2') - node.removeClass('fade-level-3') - node.removeClass('fade-level-4plus') + const classesToRemove = [ + 'depth-root', + 'fade-unrelated', + 'fade-level-1', + 'fade-level-2', + 'fade-level-3', + 'fade-level-4plus', + ] + + classesToRemove.forEach((cls) => node.removeClass(cls)) + + for (let i = 1; i <= 10; i += 1) { + node.removeClass(`depth-level-${i}`) + node.removeClass(`incoming-level-${i}`) + } }) cy.edges().forEach((edge: any) => { @@ -572,7 +562,7 @@ export const GeneExpressionRegulationNetworkPanel = ({ }) } - const walkOutgoing = (root: NodeSingular) => { + const walkOutgoing = (root: NodeSingular, maxDepth: number) => { const visitedNodes = new Map() const summaryMap = new Map() @@ -585,24 +575,22 @@ export const GeneExpressionRegulationNetworkPanel = ({ if (!current) { continue } const { node, depth } = current + + if (depth >= maxDepth) { continue } + const outgoers = node.outgoers('edge') outgoers.forEach((edge: any) => { const target = edge.target() const nextDepth = depth + 1 + if (nextDepth > maxDepth) { return } + edge.addClass('depth-path-outgoing') if (!visitedNodes.has(target.id()) || nextDepth < visitedNodes.get(target.id())!) { visitedNodes.set(target.id(), nextDepth) - - if (nextDepth === 1) { - target.addClass('depth-level-1') - } else if (nextDepth === 2) { - target.addClass('depth-level-2') - } else { - target.addClass('depth-level-3') - } + target.addClass(`depth-level-${nextDepth}`) const arr = summaryMap.get(nextDepth) || [] const label = target.data('label') @@ -624,7 +612,7 @@ export const GeneExpressionRegulationNetworkPanel = ({ return { visitedNodes, summary } } - const walkIncoming = (root: NodeSingular) => { + const walkIncoming = (root: NodeSingular, maxDepth: number) => { const visitedNodes = new Map() const summaryMap = new Map() @@ -637,24 +625,22 @@ export const GeneExpressionRegulationNetworkPanel = ({ if (!current) { continue } const { node, depth } = current + + if (depth >= maxDepth) { continue } + const incomers = node.incomers('edge') incomers.forEach((edge: any) => { const source = edge.source() const nextDepth = depth + 1 + if (nextDepth > maxDepth) { return } + edge.addClass('depth-path-incoming') if (!visitedNodes.has(source.id()) || nextDepth < visitedNodes.get(source.id())!) { visitedNodes.set(source.id(), nextDepth) - - if (nextDepth === 1) { - source.addClass('incoming-level-1') - } else if (nextDepth === 2) { - source.addClass('incoming-level-2') - } else { - source.addClass('incoming-level-3') - } + source.addClass(`incoming-level-${nextDepth}`) const arr = summaryMap.get(nextDepth) || [] const label = source.data('label') @@ -676,7 +662,7 @@ export const GeneExpressionRegulationNetworkPanel = ({ return { visitedNodes, summary } } - const applyTraversal = (root: NodeSingular, mode: TraversalMode) => { + const applyTraversal = (root: NodeSingular, mode: TraversalMode, levels: number) => { clearTraversal() cy.batch(() => { @@ -684,11 +670,11 @@ export const GeneExpressionRegulationNetworkPanel = ({ }) const outgoing = mode === 'outgoing' || mode === 'both' - ? walkOutgoing(root) + ? walkOutgoing(root, levels) : { visitedNodes: new Map(), summary: [] } const incoming = mode === 'incoming' || mode === 'both' - ? walkIncoming(root) + ? walkIncoming(root, levels) : { visitedNodes: new Map(), summary: [] } applyProgressiveFading(root, outgoing.visitedNodes, incoming.visitedNodes) @@ -700,7 +686,7 @@ export const GeneExpressionRegulationNetworkPanel = ({ cy.on('tap', 'node', (evt) => { const node = evt.target - applyTraversal(node, traversalMode) + applyTraversal(node, traversalMode, maxLevels) }) cy.on('tap', 'edge', (evt) => { @@ -752,11 +738,17 @@ export const GeneExpressionRegulationNetworkPanel = ({ } }) + const defaultRoot = cy.getElementById('gene_braf') + if (defaultRoot && defaultRoot.nonempty()) { + applyTraversal(defaultRoot, traversalMode, maxLevels) + cy.center(defaultRoot) + } + return () => { cy.destroy() cyRef.current = null } - }, [elements, traversalMode]) + }, [elements, traversalMode, maxLevels]) const removeSelectedEdgeFromList = (edgeId: string) => { const cy = cyRef.current @@ -845,6 +837,55 @@ export const GeneExpressionRegulationNetworkPanel = ({
+
+
+
+ Cantidad máxima de niveles +
+ + +
+ + { + setMaxLevels(Number(e.target.value)) + }} + style={{ + width: '100%', + accentColor: '#16a34a', + cursor: 'pointer', + }} + /> + +
+ 1 + 5 + 10 +
+
+
setTraversalMode(data.value as TraversalMode)} /> + +
- + @@ -1028,6 +1073,10 @@ export const GeneExpressionRegulationNetworkPanel = ({ Nodo raíz: {selectedRootNode}
+
+ Límite de búsqueda: {maxLevels} nivel{maxLevels > 1 ? 'es' : ''} +
+ {(traversalMode === 'outgoing' || traversalMode === 'both') && (
@@ -1113,7 +1162,9 @@ const LevelBadge = ({ depth, incoming = false }: { depth: number; incoming?: boo ? LEVEL_COLORS.level1 : depth === 2 ? LEVEL_COLORS.level2 - : LEVEL_COLORS.level3 + : depth === 3 + ? LEVEL_COLORS.level3 + : '#94a3b8' return ( { + const [threshold, setThreshold] = useState(0.5) + const [traversalMode, setTraversalMode] = useState('outgoing') + const [maxLevels, setMaxLevels] = useState(3) + const [selectedEdges, setSelectedEdges] = useState([]) + + const queryParams = useMemo(() => ({ + rootNodeId: 'gene_braf', + threshold, + traversalMode, + maxLevels, + }), [threshold, traversalMode, maxLevels]) + + const { data, loading, error } = useGeneGraphQuery(queryParams) + + return ( +
+ { + setSelectedEdges([]) + setThreshold(value) + }} + maxLevels={maxLevels} + onMaxLevelsChange={(value) => { + setSelectedEdges([]) + setMaxLevels(value) + }} + traversalMode={traversalMode} + onTraversalModeChange={(value) => { + setSelectedEdges([]) + setTraversalMode(value) + }} + /> + + {loading && ( +
+ +
+ )} + + {error && ( + + Error fetching graph +

{error}

+
+ )} + + {!loading && !error && data && ( + + )} + + {!loading && !error && data && ( +
+ setSelectedEdges((prev) => prev.filter((edge) => edge.id !== edgeId))} + onClearAll={() => setSelectedEdges([])} + /> + + node.id === data.rootNodeId)?.label ?? null} + maxLevels={maxLevels} + depthSummary={data.outgoingSummary} + incomingSummary={data.incomingSummary} + /> +
+ )} +
+ ) +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneNetworkGraph.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneNetworkGraph.tsx new file mode 100644 index 00000000..5c59dcd9 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneNetworkGraph.tsx @@ -0,0 +1,331 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import cytoscape, { Core, ElementDefinition } from 'cytoscape' +import { NODE_COLORS, REGULATION_COLORS } from './graphStyle' +import { FetchGeneGraphResponse, SelectedEdgeInfo } from './types' + +const getEdgeColor = (correlation: number) => { + if (correlation < 0) { return REGULATION_COLORS.down } + + return REGULATION_COLORS.up +} + +const getEdgeOpacity = (correlation: number) => { + const abs = Math.abs(correlation) + + if (abs >= 0.8) { return 0.95 } + + if (abs >= 0.6) { return 0.8 } + + return 0.7 +} + +const getEdgeWidth = (correlation: number) => { + const abs = Math.abs(correlation) + + if (abs >= 0.9) { return 6 } + + if (abs >= 0.8) { return 5 } + + if (abs >= 0.7) { return 4 } + + return 3 +} + +const getDirectionLabel = (correlation: number): SelectedEdgeInfo['direction'] => { + if (correlation < 0) { return 'Down-regulation' } + + return 'Up-regulation' +} + +type TooltipState = { + visible: boolean; + x: number; + y: number; + content: string; +} + +type Props = { + data: FetchGeneGraphResponse | null; + height?: number | string; + width?: number | string; + selectedEdges: SelectedEdgeInfo[]; + onSelectedEdgesChange: (edges: SelectedEdgeInfo[]) => void; +} + +export const GeneNetworkGraph = ({ + data, + height = 650, + width = '100%', + selectedEdges, + onSelectedEdgesChange, +}: Props): JSX.Element => { + const containerRef = useRef(null) + const cyRef = useRef(null) + + const [tooltip, setTooltip] = useState({ + visible: false, + x: 0, + y: 0, + content: '', + }) + + const elements = useMemo(() => { + if (!data) { return [] } + + const nodes: ElementDefinition[] = data.nodes.map((node) => ({ + data: { + ...node, + }, + })) + + const edges: ElementDefinition[] = data.edges.map((edge) => ({ + data: { + ...edge, + edgeColor: getEdgeColor(edge.correlation), + edgeOpacity: getEdgeOpacity(edge.correlation), + edgeWidth: getEdgeWidth(edge.correlation), + directionLabel: getDirectionLabel(edge.correlation), + }, + })) + + return [...nodes, ...edges] + }, [data]) + + useEffect(() => { + if (!containerRef.current || !data) { return } + + if (cyRef.current) { + cyRef.current.destroy() + cyRef.current = null + } + + const cy = cytoscape({ + container: containerRef.current, + elements, + wheelSensitivity: 0.18, + minZoom: 0.4, + maxZoom: 2, + style: [ + { + selector: 'node', + style: { + label: 'data(label)', + width: 'data(size)', + height: 'data(size)', + shape: 'ellipse', + 'background-color': '#64748b', + color: '#ffffff', + 'font-size': 12, + 'font-weight': 600, + 'text-valign': 'center', + 'text-halign': 'center', + 'text-wrap': 'wrap', + 'text-max-width': 90 as any, + 'text-outline-color': '#475569', + 'text-outline-width': 3, + 'border-width': 2, + 'border-color': '#ffffff', + }, + }, + { + selector: 'node[type = "Gene"]', + style: { + 'background-color': NODE_COLORS.Gene, + 'text-outline-color': NODE_COLORS.Gene, + }, + }, + { + selector: 'node[type = "miRNA"]', + style: { + 'background-color': NODE_COLORS.miRNA, + 'text-outline-color': NODE_COLORS.miRNA, + }, + }, + { + selector: 'node[type = "CNA"]', + style: { + 'background-color': NODE_COLORS.CNA, + 'text-outline-color': NODE_COLORS.CNA, + }, + }, + { + selector: 'node[type = "Methylation"]', + style: { + 'background-color': NODE_COLORS.Methylation, + 'text-outline-color': NODE_COLORS.Methylation, + }, + }, + { + selector: 'node[type = "Drug"]', + style: { + 'background-color': NODE_COLORS.Drug, + 'text-outline-color': NODE_COLORS.Drug, + }, + }, + { + selector: 'edge', + style: { + width: 'data(edgeWidth)', + 'line-color': 'data(edgeColor)', + opacity: 'data(edgeOpacity)' as any, + 'curve-style': 'bezier', + 'target-arrow-shape': 'triangle', + 'target-arrow-color': 'data(edgeColor)', + 'arrow-scale': 1.15, + }, + }, + { + selector: 'edge.edge-picked', + style: { + opacity: 1, + 'underlay-color': '#000000', + 'underlay-opacity': 1, + 'underlay-padding': 9, + width: 'mapData(edgeWidth, 3, 6, 5, 8)', + 'z-index': 999, + }, + }, + { + selector: 'node.root-node', + style: { + 'border-color': '#f59e0b', + 'border-width': 6, + }, + }, + ], + layout: { + name: 'cose', + animate: true, + randomize: true, + fit: true, + padding: 40, + gravity: 1, + nodeRepulsion: 9000, + idealEdgeLength: 90, + componentSpacing: 80, + }, + }) + + cyRef.current = cy + + const rootNode = cy.getElementById(data.rootNodeId) + + if (rootNode.nonempty()) { + rootNode.addClass('root-node') + cy.center(rootNode) + } + + const syncSelectedEdges = () => { + const picked = cy + .edges('.edge-picked') + .toArray() + .map((edge: any) => ({ + id: edge.id(), + source: edge.source().data('label') || edge.data('source'), + target: edge.target().data('label') || edge.data('target'), + correlation: edge.data('correlation'), + direction: edge.data('directionLabel'), + })) + + onSelectedEdgesChange(picked) + } + + cy.on('tap', 'edge', (evt) => { + const edge = evt.target + + if (edge.hasClass('edge-picked')) { + edge.removeClass('edge-picked') + } else { + edge.addClass('edge-picked') + } + + syncSelectedEdges() + }) + + cy.on('mouseover', 'edge', (evt) => { + const edge = evt.target + const correlation = edge.data('correlation') + const direction = edge.data('directionLabel') + + setTooltip({ + visible: true, + x: evt.renderedPosition?.x ?? 0, + y: evt.renderedPosition?.y ?? 0, + content: `${direction} | correlation: ${correlation}`, + }) + }) + + cy.on('mousemove', 'edge', (evt) => { + setTooltip((prev) => ({ + ...prev, + x: evt.renderedPosition?.x ?? prev.x, + y: evt.renderedPosition?.y ?? prev.y, + visible: true, + })) + }) + + cy.on('mouseout', 'edge', () => { + setTooltip((prev) => ({ + ...prev, + visible: false, + })) + }) + + return () => { + cy.destroy() + cyRef.current = null + } + }, [data, elements, onSelectedEdgesChange]) + + useEffect(() => { + const cy = cyRef.current + + if (!cy) { return } + + cy.edges().removeClass('edge-picked') + + for (const edge of selectedEdges) { + const cyEdge = cy.getElementById(edge.id) + + if (cyEdge.nonempty()) { + cyEdge.addClass('edge-picked') + } + } + }, [selectedEdges]) + + return ( +
+
+ + {tooltip.visible && ( +
+ {tooltip.content} +
+ )} +
+ ) +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GraphControls.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GraphControls.tsx new file mode 100644 index 00000000..4800444c --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GraphControls.tsx @@ -0,0 +1,180 @@ +import React from 'react' +import { Dropdown, Label } from 'semantic-ui-react' +import { NODE_COLORS, REGULATION_COLORS } from './graphStyle' +import { LegendArrow, LegendDot } from './legend' +import { TraversalMode } from './types' + +const traversalOptions = [ + { key: 'outgoing', value: 'outgoing', text: 'Regulates' }, + { key: 'incoming', value: 'incoming', text: 'Regulated by' }, + { key: 'both', value: 'both', text: 'Both' }, +] + +type Props = { + threshold: number; + onThresholdChange: (value: number) => void; + maxLevels: number; + onMaxLevelsChange: (value: number) => void; + traversalMode: TraversalMode; + onTraversalModeChange: (value: TraversalMode) => void; +} + +const roundThreshold = (value: number) => Number(value.toFixed(1)) + +export const GraphControls = ({ + threshold, + onThresholdChange, + maxLevels, + onMaxLevelsChange, + traversalMode, + onTraversalModeChange, +}: Props) => { + return ( +
+
+
+
+ Correlation threshold +
+ + +
+ + onThresholdChange(roundThreshold(Number(e.target.value)))} + style={{ + width: '100%', + accentColor: '#2563eb', + cursor: 'pointer', + }} + /> + +
+ +/-0.1 + +/-0.5 + +/-0.9 +
+
+ +
+
+
+ Maximum levels +
+ + +
+ + onMaxLevelsChange(Number(e.target.value))} + style={{ + width: '100%', + accentColor: '#16a34a', + cursor: 'pointer', + }} + /> + +
+ 1 + 5 + 10 +
+
+ +
+
+ + Node analysis: + + + onTraversalModeChange(data.value as TraversalMode)} + /> + + +
+ +
+
+ Nodes: + + + + + +
+ +
+ Edges: + + +
+
+
+
+ ) +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/LevelBadge.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/LevelBadge.tsx new file mode 100644 index 00000000..6d691918 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/LevelBadge.tsx @@ -0,0 +1,40 @@ +import React from 'react' + +const LEVEL_COLORS = { + level1: '#0ea5e9', + level2: '#22c55e', + level3: '#a855f7', + other: '#94a3b8', +} + +export const LevelBadge = ({ + depth, + incoming = false, +}: { + depth: number; + incoming?: boolean; +}) => { + const color = + depth === 1 + ? LEVEL_COLORS.level1 + : depth === 2 + ? LEVEL_COLORS.level2 + : depth === 3 + ? LEVEL_COLORS.level3 + : LEVEL_COLORS.other + + return ( + + ) +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/SelectedEdgesPanel.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/SelectedEdgesPanel.tsx new file mode 100644 index 00000000..d1191914 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/SelectedEdgesPanel.tsx @@ -0,0 +1,97 @@ +import React from 'react' +import { Button, Icon } from 'semantic-ui-react' +import { SelectedEdgeInfo } from './types' + +type Props = { + selectedEdges: SelectedEdgeInfo[]; + onRemoveEdge: (edgeId: string) => void; + onClearAll: () => void; +} + +export const SelectedEdgesPanel = ({ + selectedEdges, + onRemoveEdge, + onClearAll, +}: Props) => { + return ( +
+
+
Selected edges
+ + +
+ + {selectedEdges.length === 0 + ? ( +
+ Click multiple edges to inspect each correlation value. +
+ ) + : ( +
+ {selectedEdges.map((edge) => ( +
+
+
+ {edge.source} -> {edge.target} +
+
Direction: {edge.direction}
+
Correlation: {edge.correlation}
+
+ +
+ ))} +
+ )} +
+ ) +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/TraversalSummaryPanel.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/TraversalSummaryPanel.tsx new file mode 100644 index 00000000..67e7ed85 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/TraversalSummaryPanel.tsx @@ -0,0 +1,128 @@ +import React from 'react' +import { LevelBadge } from './LevelBadge' +import { DepthSummaryItem, TraversalMode } from './types' + +type Props = { + traversalMode: TraversalMode; + selectedRootNode: string | null; + maxLevels: number; + depthSummary: DepthSummaryItem[]; + incomingSummary: DepthSummaryItem[]; +} + +export const TraversalSummaryPanel = ({ + traversalMode, + selectedRootNode, + maxLevels, + depthSummary, + incomingSummary, +}: Props) => { + return ( +
+
+ {traversalMode === 'outgoing' + ? 'Depth levels' + : traversalMode === 'incoming' + ? 'Upstream regulation levels' + : 'Relationship levels'} +
+ + {!selectedRootNode + ? ( +
+ Select a node to calculate levels. +
+ ) + : ( +
+
+ Root node: {selectedRootNode} +
+ +
+ Search limit: {maxLevels} level{maxLevels > 1 ? 's' : ''} +
+ + {(traversalMode === 'outgoing' || traversalMode === 'both') && ( +
+
+ Regulates +
+ + {depthSummary.length === 0 + ? ( +
+ No visible regulated nodes match the current filter. +
+ ) + : ( + depthSummary.map((item) => ( +
+
+ + Level {item.depth} +
+
{item.nodes.join(', ')}
+
+ )) + )} +
+ )} + + {(traversalMode === 'incoming' || traversalMode === 'both') && ( +
+
+ Regulated by +
+ + {incomingSummary.length === 0 + ? ( +
+ No visible regulators match the current filter. +
+ ) + : ( + incomingSummary.map((item) => ( +
+
+ + Level {item.depth} +
+
{item.nodes.join(', ')}
+
+ )) + )} +
+ )} +
+ )} +
+ ) +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphApi.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphApi.ts new file mode 100644 index 00000000..2a8f1ec1 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphApi.ts @@ -0,0 +1,202 @@ +import { MOCK_EDGES, MOCK_NODES } from './mockNetworkData' +import { + DepthSummaryItem, + FetchGeneGraphParams, + FetchGeneGraphResponse, + GraphEdge, + TraversalMode, +} from './types' + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +const passesThreshold = (correlation: number, threshold: number) => + Math.abs(correlation) >= threshold + +const buildSummary = (map: Map) => + Array.from(map.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([depth, nodes]): DepthSummaryItem => ({ + depth, + nodes: [...nodes].sort((a, b) => a.localeCompare(b)), + })) + +const walkOutgoing = (rootNodeId: string, edges: GraphEdge[], maxLevels: number) => { + const visitedNodes = new Map() + const includedEdgeIds = new Set() + const summaryMap = new Map() + + const queue: Array<{ nodeId: string; depth: number }> = [{ nodeId: rootNodeId, depth: 0 }] + visitedNodes.set(rootNodeId, 0) + + while (queue.length > 0) { + const current = queue.shift() + + if (!current) { continue } + + const { nodeId, depth } = current + + if (depth >= maxLevels) { continue } + + const outgoingEdges = edges.filter((edge) => edge.source === nodeId) + + for (const edge of outgoingEdges) { + const nextDepth = depth + 1 + + if (nextDepth > maxLevels) { continue } + + includedEdgeIds.add(edge.id) + + if (!visitedNodes.has(edge.target) || nextDepth < visitedNodes.get(edge.target)!) { + visitedNodes.set(edge.target, nextDepth) + + const targetLabel = MOCK_NODES.find((node) => node.id === edge.target)?.label ?? edge.target + const arr = summaryMap.get(nextDepth) || [] + + if (!arr.includes(targetLabel)) { + arr.push(targetLabel) + summaryMap.set(nextDepth, arr) + } + + queue.push({ nodeId: edge.target, depth: nextDepth }) + } + } + } + + return { + visitedNodes, + includedEdgeIds, + summary: buildSummary(summaryMap), + } +} + +const walkIncoming = (rootNodeId: string, edges: GraphEdge[], maxLevels: number) => { + const visitedNodes = new Map() + const includedEdgeIds = new Set() + const summaryMap = new Map() + + const queue: Array<{ nodeId: string; depth: number }> = [{ nodeId: rootNodeId, depth: 0 }] + visitedNodes.set(rootNodeId, 0) + + while (queue.length > 0) { + const current = queue.shift() + + if (!current) { continue } + + const { nodeId, depth } = current + + if (depth >= maxLevels) { continue } + + const incomingEdges = edges.filter((edge) => edge.target === nodeId) + + for (const edge of incomingEdges) { + const nextDepth = depth + 1 + + if (nextDepth > maxLevels) { continue } + + includedEdgeIds.add(edge.id) + + if (!visitedNodes.has(edge.source) || nextDepth < visitedNodes.get(edge.source)!) { + visitedNodes.set(edge.source, nextDepth) + + const sourceLabel = MOCK_NODES.find((node) => node.id === edge.source)?.label ?? edge.source + const arr = summaryMap.get(nextDepth) || [] + + if (!arr.includes(sourceLabel)) { + arr.push(sourceLabel) + summaryMap.set(nextDepth, arr) + } + + queue.push({ nodeId: edge.source, depth: nextDepth }) + } + } + } + + return { + visitedNodes, + includedEdgeIds, + summary: buildSummary(summaryMap), + } +} + +const collectResponse = ( + rootNodeId: string, + traversalMode: TraversalMode, + threshold: number, + maxLevels: number +): FetchGeneGraphResponse => { + const thresholdEdges = MOCK_EDGES.filter((edge) => + passesThreshold(edge.correlation, threshold) + ) + + const outgoing = traversalMode === 'outgoing' || traversalMode === 'both' + ? walkOutgoing(rootNodeId, thresholdEdges, maxLevels) + : { + visitedNodes: new Map(), + includedEdgeIds: new Set(), + summary: [] as DepthSummaryItem[], + } + + const incoming = traversalMode === 'incoming' || traversalMode === 'both' + ? walkIncoming(rootNodeId, thresholdEdges, maxLevels) + : { + visitedNodes: new Map(), + includedEdgeIds: new Set(), + summary: [] as DepthSummaryItem[], + } + + const includedEdgeIds = new Set([ + ...Array.from(outgoing.includedEdgeIds), + ...Array.from(incoming.includedEdgeIds), + ]) + + const edges = thresholdEdges.filter((edge) => includedEdgeIds.has(edge.id)) + + const includedNodeIdsFromEdges = new Set() + + for (const edge of edges) { + includedNodeIdsFromEdges.add(edge.source) + includedNodeIdsFromEdges.add(edge.target) + } + + // Keep the root visible even when no edges match the current filters. + includedNodeIdsFromEdges.add(rootNodeId) + + const nodes = MOCK_NODES.filter((node) => includedNodeIdsFromEdges.has(node.id)) + + const validLabels = new Set(nodes.map((node) => node.label)) + + const outgoingSummary = outgoing.summary + .map((item) => ({ + depth: item.depth, + nodes: item.nodes.filter((label) => validLabels.has(label)), + })) + .filter((item) => item.nodes.length > 0) + + const incomingSummary = incoming.summary + .map((item) => ({ + depth: item.depth, + nodes: item.nodes.filter((label) => validLabels.has(label)), + })) + .filter((item) => item.nodes.length > 0) + + return { + rootNodeId, + nodes, + edges, + outgoingSummary, + incomingSummary, + } +} + +export const fetchGeneGraph = async ( + params: FetchGeneGraphParams +): Promise => { + await sleep(350) + + return collectResponse( + params.rootNodeId, + params.traversalMode, + params.threshold, + params.maxLevels + ) +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphStyle.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphStyle.ts new file mode 100644 index 00000000..f599bf9b --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphStyle.ts @@ -0,0 +1,14 @@ +import { NodeKind } from './types' + +export const NODE_COLORS: Record = { + Gene: '#4f46e5', + miRNA: '#db2777', + CNA: '#f59e0b', + Methylation: '#10b981', + Drug: '#64748b', +} + +export const REGULATION_COLORS = { + down: '#dc2626', + up: '#2563eb', +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/legend.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/legend.tsx new file mode 100644 index 00000000..b77ce364 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/legend.tsx @@ -0,0 +1,44 @@ +import React from 'react' + +export const LegendDot = ({ color, label }: { color: string; label: string }) => ( + + + {label} + +) + +export const LegendArrow = ({ color, label }: { color: string; label: string }) => ( + + + + + {label} + +) diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/mockNetworkData.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/mockNetworkData.ts new file mode 100644 index 00000000..b39c6147 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/mockNetworkData.ts @@ -0,0 +1,52 @@ +import { GraphEdge, GraphNode } from './types' + +export const MOCK_NODES: GraphNode[] = [ + { id: 'gene_braf', label: 'BRAF', type: 'Gene', size: 66 }, + { id: 'gene_mek1', label: 'MEK1', type: 'Gene', size: 46 }, + { id: 'gene_mek2', label: 'MEK2', type: 'Gene', size: 44 }, + { id: 'gene_erk1', label: 'ERK1', type: 'Gene', size: 42 }, + { id: 'gene_erk2', label: 'ERK2', type: 'Gene', size: 42 }, + { id: 'gene_myc', label: 'MYC', type: 'Gene', size: 38 }, + { id: 'gene_fos', label: 'FOS', type: 'Gene', size: 36 }, + { id: 'gene_ccnd1', label: 'CCND1', type: 'Gene', size: 36 }, + { id: 'gene_elk1', label: 'ELK1', type: 'Gene', size: 34 }, + { id: 'gene_dusp6', label: 'DUSP6', type: 'Gene', size: 34 }, + { id: 'gene_spry2', label: 'SPRY2', type: 'Gene', size: 32 }, + { id: 'gene_map3k8', label: 'MAP3K8', type: 'Gene', size: 32 }, + { id: 'mir_17', label: 'miR-17', type: 'miRNA', size: 28 }, + { id: 'mir_21', label: 'miR-21', type: 'miRNA', size: 28 }, + { id: 'cna_7q34', label: '7q34 gain', type: 'CNA', size: 34 }, + { id: 'meth_rassf1', label: 'RASSF1 meth', type: 'Methylation', size: 34 }, + { id: 'drug_vemurafenib', label: 'Vemurafenib', type: 'Drug', size: 36 }, +] + +export const MOCK_EDGES: GraphEdge[] = [ + { id: 'e_1', source: 'gene_braf', target: 'gene_mek1', correlation: 0.92 }, + { id: 'e_2', source: 'gene_braf', target: 'gene_mek2', correlation: 0.83 }, + { id: 'e_3', source: 'gene_braf', target: 'gene_erk1', correlation: 0.78 }, + { id: 'e_4', source: 'gene_braf', target: 'gene_dusp6', correlation: 0.72 }, + { id: 'e_5', source: 'gene_braf', target: 'gene_fos', correlation: 0.67 }, + + { id: 'e_6', source: 'gene_mek1', target: 'gene_erk1', correlation: 0.88 }, + { id: 'e_7', source: 'gene_mek1', target: 'gene_erk2', correlation: 0.79 }, + { id: 'e_8', source: 'gene_mek1', target: 'gene_elk1', correlation: 0.76 }, + { id: 'e_9', source: 'gene_mek2', target: 'gene_erk2', correlation: 0.81 }, + + { id: 'e_10', source: 'gene_erk1', target: 'gene_myc', correlation: 0.81 }, + { id: 'e_11', source: 'gene_erk1', target: 'gene_fos', correlation: 0.79 }, + { id: 'e_12', source: 'gene_erk2', target: 'gene_ccnd1', correlation: 0.68 }, + { id: 'e_13', source: 'gene_elk1', target: 'gene_myc', correlation: 0.61 }, + { id: 'e_14', source: 'gene_fos', target: 'gene_ccnd1', correlation: 0.58 }, + + { id: 'e_15', source: 'mir_17', target: 'gene_braf', correlation: -0.66 }, + { id: 'e_16', source: 'mir_21', target: 'gene_mek1', correlation: -0.57 }, + { id: 'e_17', source: 'cna_7q34', target: 'gene_braf', correlation: 0.73 }, + { id: 'e_18', source: 'meth_rassf1', target: 'gene_braf', correlation: -0.54 }, + { id: 'e_19', source: 'drug_vemurafenib', target: 'gene_braf', correlation: -0.89 }, + + { id: 'e_20', source: 'gene_map3k8', target: 'gene_mek1', correlation: 0.55 }, + { id: 'e_21', source: 'gene_braf', target: 'gene_map3k8', correlation: 0.52 }, + + { id: 'e_22', source: 'gene_spry2', target: 'gene_braf', correlation: -0.32 }, + { id: 'e_23', source: 'drug_vemurafenib', target: 'gene_mek1', correlation: -0.41 }, +] diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/types.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/types.ts new file mode 100644 index 00000000..2034a57c --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/types.ts @@ -0,0 +1,45 @@ +export type NodeKind = 'Gene' | 'miRNA' | 'CNA' | 'Methylation' | 'Drug' + +export type TraversalMode = 'outgoing' | 'incoming' | 'both' + +export type GraphNode = { + id: string; + label: string; + type: NodeKind; + size: number; +} + +export type GraphEdge = { + id: string; + source: string; + target: string; + correlation: number; +} + +export type SelectedEdgeInfo = { + id: string; + source: string; + target: string; + correlation: number; + direction: 'Down-regulation' | 'Up-regulation'; +} + +export type DepthSummaryItem = { + depth: number; + nodes: string[]; +} + +export type FetchGeneGraphParams = { + rootNodeId: string; + threshold: number; + traversalMode: TraversalMode; + maxLevels: number; +} + +export type FetchGeneGraphResponse = { + rootNodeId: string; + nodes: GraphNode[]; + edges: GraphEdge[]; + outgoingSummary: DepthSummaryItem[]; + incomingSummary: DepthSummaryItem[]; +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/useGeneGraphQuery.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/useGeneGraphQuery.ts new file mode 100644 index 00000000..e97402e6 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/useGeneGraphQuery.ts @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react' +import { fetchGeneGraph } from './graphApi' +import { FetchGeneGraphParams, FetchGeneGraphResponse } from './types' + +type QueryState = { + data: FetchGeneGraphResponse | null; + loading: boolean; + error: string | null; +} + +export const useGeneGraphQuery = (params: FetchGeneGraphParams) => { + const [state, setState] = useState({ + data: null, + loading: true, + error: null, + }) + + useEffect(() => { + let cancelled = false + + setState({ + data: null, + loading: true, + error: null, + }) + + fetchGeneGraph(params) + .then((data) => { + if (cancelled) { return } + + setState({ + data, + loading: false, + error: null, + }) + }) + .catch((error) => { + if (cancelled) { return } + + setState({ + data: null, + loading: false, + error: error instanceof Error ? error.message : 'Unknown error', + }) + }) + + return () => { + cancelled = true + } + }, [ + params.rootNodeId, + params.threshold, + params.traversalMode, + params.maxLevels, + ]) + + return state +} From e67c109708da3b4bbf93a9caac943a3b9a1b054e Mon Sep 17 00:00:00 2001 From: Juan Nicolas Herrera <54152074+juanNH@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:36:56 -0300 Subject: [PATCH 04/17] feat: add editable graph expansions and filter apply flow --- .../ActiveGraphFiltersPanel.tsx | 221 ++++++++++++ .../GeneExpressionRegulationNetworkPanel.tsx | 130 +++++-- .../GeneNetworkGraph.tsx | 321 +++++++++++++++++- .../GraphControls.tsx | 4 + .../gene-association-network/graphApi.ts | 108 ++++-- .../mockNetworkData.ts | 125 +++++++ .../genes/gene-association-network/types.ts | 13 +- .../useGeneGraphQuery.ts | 7 +- 8 files changed, 854 insertions(+), 75 deletions(-) create mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/ActiveGraphFiltersPanel.tsx diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/ActiveGraphFiltersPanel.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/ActiveGraphFiltersPanel.tsx new file mode 100644 index 00000000..56fc2e3f --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/ActiveGraphFiltersPanel.tsx @@ -0,0 +1,221 @@ +import React from 'react' +import { Button, Dropdown, Icon, Label } from 'semantic-ui-react' +import { GraphQueryFilter, TraversalMode } from './types' + +const traversalLabels: Record = { + outgoing: 'Regulates', + incoming: 'Regulated by', + both: 'Both', +} + +const traversalOptions = [ + { key: 'outgoing', value: 'outgoing', text: traversalLabels.outgoing }, + { key: 'incoming', value: 'incoming', text: traversalLabels.incoming }, + { key: 'both', value: 'both', text: traversalLabels.both }, +] + +type Props = { + filters: GraphQueryFilter[]; + hasPendingChanges: boolean; + getNodeLabel: (nodeId: string) => string; + onUpdateFilter: (rootNodeId: string, partialFilter: Partial) => void; + onClearExpansions: () => void; + onRemoveFilter: (rootNodeId: string) => void; + onResetFilters: () => void; + onApplyFilters: () => void; +} + +export const ActiveGraphFiltersPanel = ({ + filters, + hasPendingChanges, + getNodeLabel, + onUpdateFilter, + onClearExpansions, + onRemoveFilter, + onResetFilters, + onApplyFilters, +}: Props) => { + return ( +
+
+
+
Active graph filters
+
+ Edit filters here. Changes stay local until you click Filter. Reset restores the last applied state. +
+
+
+ +
+ {filters.map((filter, index) => ( +
+
+
+ {getNodeLabel(filter.rootNodeId)} + +
+ + {index !== 0 && ( +
+ +
+
+
+ Threshold + {filter.threshold.toFixed(1)} +
+ onUpdateFilter(filter.rootNodeId, { + threshold: Number(Number(event.target.value).toFixed(1)), + })} + /> +
+ +
+
+ Depth + {filter.maxLevels} +
+ onUpdateFilter(filter.rootNodeId, { + maxLevels: Number(event.target.value), + })} + /> +
+ +
+ Regulation mode + onUpdateFilter(filter.rootNodeId, { + traversalMode: data.value as TraversalMode, + })} + /> +
+
+
+ ))} +
+ +
+
+ +
+ +
+ + + +
+
+
+ ) +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneExpressionRegulationNetworkPanel.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneExpressionRegulationNetworkPanel.tsx index 99d11000..6ae451f9 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneExpressionRegulationNetworkPanel.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneExpressionRegulationNetworkPanel.tsx @@ -1,55 +1,109 @@ import React, { useMemo, useState } from 'react' import { Loader, Message } from 'semantic-ui-react' -import { GraphControls } from './GraphControls' +import { ActiveGraphFiltersPanel } from './ActiveGraphFiltersPanel' import { GeneNetworkGraph } from './GeneNetworkGraph' import { SelectedEdgesPanel } from './SelectedEdgesPanel' import { TraversalSummaryPanel } from './TraversalSummaryPanel' import { useGeneGraphQuery } from './useGeneGraphQuery' -import { SelectedEdgeInfo, TraversalMode } from './types' +import { GraphQueryFilter, SelectedEdgeInfo } from './types' type Props = { height?: number | string; width?: number | string; } +const DEFAULT_ROOT_FILTER: GraphQueryFilter = { + rootNodeId: 'gene_braf', + threshold: 0.5, + traversalMode: 'both', + maxLevels: 3, +} + export const GeneExpressionRegulationAssociationNetworkPanel = ({ height = 650, width = '100%', }: Props) => { - const [threshold, setThreshold] = useState(0.5) - const [traversalMode, setTraversalMode] = useState('outgoing') - const [maxLevels, setMaxLevels] = useState(3) const [selectedEdges, setSelectedEdges] = useState([]) + const [filters, setFilters] = useState([DEFAULT_ROOT_FILTER]) + const [draftFilters, setDraftFilters] = useState([DEFAULT_ROOT_FILTER]) + + const defaultExpansionFilter = useMemo(() => ({ + threshold: draftFilters[0]?.threshold ?? DEFAULT_ROOT_FILTER.threshold, + traversalMode: draftFilters[0]?.traversalMode ?? DEFAULT_ROOT_FILTER.traversalMode, + maxLevels: draftFilters[0]?.maxLevels ?? DEFAULT_ROOT_FILTER.maxLevels, + }), [draftFilters]) const queryParams = useMemo(() => ({ - rootNodeId: 'gene_braf', - threshold, - traversalMode, - maxLevels, - }), [threshold, traversalMode, maxLevels]) + filters, + }), [filters]) const { data, loading, error } = useGeneGraphQuery(queryParams) + const hasPendingFilterChanges = filters.length !== draftFilters.length || filters.some((filter, index) => { + const draftFilter = draftFilters[index] + + if (!draftFilter) { return true } + + return filter.rootNodeId !== draftFilter.rootNodeId || + filter.threshold !== draftFilter.threshold || + filter.traversalMode !== draftFilter.traversalMode || + filter.maxLevels !== draftFilter.maxLevels + }) + + const handleExpandNode = (filter: GraphQueryFilter) => { + setSelectedEdges([]) + setDraftFilters((prev) => { + if (prev.some((item) => item.rootNodeId === filter.rootNodeId)) { + setFilters(prev) + return prev + } + + const nextFilters = [ + ...prev, + filter, + ] + + setFilters(nextFilters) + + return nextFilters + }) + } + + const handleUpdateFilter = (rootNodeId: string, partialFilter: Partial) => { + setDraftFilters((prev) => prev.map((filter) => { + if (filter.rootNodeId !== rootNodeId) { return filter } + + return { + ...filter, + ...partialFilter, + } + })) + } + + const handleRemoveFilter = (rootNodeId: string) => { + setDraftFilters((prev) => prev.filter((filter, index) => index === 0 || filter.rootNodeId !== rootNodeId)) + } + + const handleClearExpansions = () => { + setDraftFilters((prev) => { + if (prev.length === 0) { return [DEFAULT_ROOT_FILTER] } + + return [prev[0]] + }) + } + + const handleResetFilters = () => { + setDraftFilters(filters.map((filter) => ({ + ...filter, + }))) + } + + const handleApplyFilters = () => { + setSelectedEdges([]) + setFilters(draftFilters) + } return (
- { - setSelectedEdges([]) - setThreshold(value) - }} - maxLevels={maxLevels} - onMaxLevelsChange={(value) => { - setSelectedEdges([]) - setMaxLevels(value) - }} - traversalMode={traversalMode} - onTraversalModeChange={(value) => { - setSelectedEdges([]) - setTraversalMode(value) - }} - /> - {loading && (
filter.rootNodeId)} + defaultExpansionFilter={defaultExpansionFilter} + onExpandNode={handleExpandNode} + /> + )} + + {!loading && !error && data && ( + data.nodes.find((node) => node.id === nodeId)?.label ?? nodeId} + onUpdateFilter={handleUpdateFilter} + onClearExpansions={handleClearExpansions} + onRemoveFilter={handleRemoveFilter} + onResetFilters={handleResetFilters} + onApplyFilters={handleApplyFilters} /> )} @@ -95,9 +165,9 @@ export const GeneExpressionRegulationAssociationNetworkPanel = ({ /> node.id === data.rootNodeId)?.label ?? null} - maxLevels={maxLevels} + maxLevels={filters[0]?.maxLevels ?? DEFAULT_ROOT_FILTER.maxLevels} depthSummary={data.outgoingSummary} incomingSummary={data.incomingSummary} /> diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneNetworkGraph.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneNetworkGraph.tsx index 5c59dcd9..d4ad4e48 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneNetworkGraph.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneNetworkGraph.tsx @@ -1,7 +1,18 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import cytoscape, { Core, ElementDefinition } from 'cytoscape' import { NODE_COLORS, REGULATION_COLORS } from './graphStyle' -import { FetchGeneGraphResponse, SelectedEdgeInfo } from './types' +import { LegendArrow, LegendDot } from './legend' +import { FetchGeneGraphResponse, GraphQueryFilter, SelectedEdgeInfo, TraversalMode } from './types' + +const MIN_ZOOM = 0.4 +const MAX_ZOOM = 2 +const ZOOM_STEP = 1.2 + +const traversalLabels: Record = { + outgoing: 'Regulates', + incoming: 'Regulated by', + both: 'Both', +} const getEdgeColor = (correlation: number) => { if (correlation < 0) { return REGULATION_COLORS.down } @@ -44,12 +55,26 @@ type TooltipState = { content: string; } +type ContextMenuState = { + visible: boolean; + x: number; + y: number; + nodeId: string; + nodeLabel: string; + threshold: number; + traversalMode: TraversalMode; + maxLevels: number; +} + type Props = { data: FetchGeneGraphResponse | null; height?: number | string; width?: number | string; selectedEdges: SelectedEdgeInfo[]; onSelectedEdgesChange: (edges: SelectedEdgeInfo[]) => void; + expandedNodeIds: string[]; + defaultExpansionFilter: Omit; + onExpandNode: (filter: GraphQueryFilter) => void; } export const GeneNetworkGraph = ({ @@ -58,9 +83,13 @@ export const GeneNetworkGraph = ({ width = '100%', selectedEdges, onSelectedEdgesChange, + expandedNodeIds, + defaultExpansionFilter, + onExpandNode, }: Props): JSX.Element => { const containerRef = useRef(null) const cyRef = useRef(null) + const defaultExpansionFilterRef = useRef(defaultExpansionFilter) const [tooltip, setTooltip] = useState({ visible: false, @@ -68,6 +97,20 @@ export const GeneNetworkGraph = ({ y: 0, content: '', }) + const [contextMenu, setContextMenu] = useState({ + visible: false, + x: 0, + y: 0, + nodeId: '', + nodeLabel: '', + threshold: defaultExpansionFilter.threshold, + traversalMode: defaultExpansionFilter.traversalMode, + maxLevels: defaultExpansionFilter.maxLevels, + }) + + useEffect(() => { + defaultExpansionFilterRef.current = defaultExpansionFilter + }, [defaultExpansionFilter]) const elements = useMemo(() => { if (!data) { return [] } @@ -177,11 +220,11 @@ export const GeneNetworkGraph = ({ { selector: 'edge.edge-picked', style: { - opacity: 1, - 'underlay-color': '#000000', - 'underlay-opacity': 1, - 'underlay-padding': 9, - width: 'mapData(edgeWidth, 3, 6, 5, 8)', + opacity: 0.82, + 'underlay-color': '#64748b', + 'underlay-opacity': 0.22, + 'underlay-padding': 5, + width: 'mapData(edgeWidth, 3, 6, 4, 6)', 'z-index': 999, }, }, @@ -208,10 +251,17 @@ export const GeneNetworkGraph = ({ cyRef.current = cy + for (const filter of data.filters) { + const expandedRootNode = cy.getElementById(filter.rootNodeId) + + if (expandedRootNode.nonempty()) { + expandedRootNode.addClass('root-node') + } + } + const rootNode = cy.getElementById(data.rootNodeId) if (rootNode.nonempty()) { - rootNode.addClass('root-node') cy.center(rootNode) } @@ -233,6 +283,11 @@ export const GeneNetworkGraph = ({ cy.on('tap', 'edge', (evt) => { const edge = evt.target + setContextMenu((prev) => ({ + ...prev, + visible: false, + })) + if (edge.hasClass('edge-picked')) { edge.removeClass('edge-picked') } else { @@ -242,6 +297,35 @@ export const GeneNetworkGraph = ({ syncSelectedEdges() }) + cy.on('tap', 'node', () => { + setContextMenu((prev) => ({ + ...prev, + visible: false, + })) + }) + + cy.on('cxttap', 'node', (evt) => { + evt.originalEvent?.preventDefault() + + const node = evt.target + + setTooltip((prev) => ({ + ...prev, + visible: false, + })) + + setContextMenu({ + visible: true, + x: evt.renderedPosition?.x ?? 0, + y: evt.renderedPosition?.y ?? 0, + nodeId: node.id(), + nodeLabel: node.data('label') || node.id(), + threshold: defaultExpansionFilterRef.current.threshold, + traversalMode: defaultExpansionFilterRef.current.traversalMode, + maxLevels: defaultExpansionFilterRef.current.maxLevels, + }) + }) + cy.on('mouseover', 'edge', (evt) => { const edge = evt.target const correlation = edge.data('correlation') @@ -293,8 +377,29 @@ export const GeneNetworkGraph = ({ } }, [selectedEdges]) + const zoomGraph = (direction: 'in' | 'out') => { + const cy = cyRef.current + + if (!cy) { return } + + const nextZoom = direction === 'in' + ? Math.min(MAX_ZOOM, cy.zoom() * ZOOM_STEP) + : Math.max(MIN_ZOOM, cy.zoom() / ZOOM_STEP) + + cy.zoom({ + level: nextZoom, + renderedPosition: { + x: cy.width() / 2, + y: cy.height() / 2, + }, + }) + } + return ( -
+
event.preventDefault()} + >
+
+ + + +
+ +
+
+ Nodes: + + + + + +
+ +
+ Edges: + + +
+
+ {tooltip.visible && (
)} + + {contextMenu.visible && ( +
+
+ {contextMenu.nodeLabel} +
+ +
+ Configure this expansion before adding it to the graph request. +
+ +
+
+
+ Threshold + {contextMenu.threshold.toFixed(1)} +
+ setContextMenu((prev) => ({ + ...prev, + threshold: Number(Number(event.target.value).toFixed(1)), + }))} + /> +
+ +
+
+ Depth + {contextMenu.maxLevels} +
+ setContextMenu((prev) => ({ + ...prev, + maxLevels: Number(event.target.value), + }))} + /> +
+ +
+ Regulation mode + +
+
+ + +
+ )}
) } diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GraphControls.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GraphControls.tsx index 4800444c..a1036ed8 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GraphControls.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GraphControls.tsx @@ -41,6 +41,10 @@ export const GraphControls = ({ }} >
+
+ These values update the BRAF root while it is the only active filter. After expanding nodes, they are used for the next right-click expansion. +
+
new Promise((resolve) => setTimeout(resolve, ms)) const passesThreshold = (correlation: number, threshold: number) => Math.abs(correlation) >= threshold +const getNodeLabel = (nodeId: string) => + MOCK_NODES.find((node) => node.id === nodeId)?.label ?? nodeId + +const getEdgesForFilter = (rootNodeId: string) => [ + ...MOCK_EDGES, + ...(MOCK_EXPANSION_EDGES_BY_ROOT[rootNodeId] ?? []), +] + const buildSummary = (map: Map) => Array.from(map.entries()) .sort((a, b) => a[0] - b[0]) @@ -20,12 +35,36 @@ const buildSummary = (map: Map) => nodes: [...nodes].sort((a, b) => a.localeCompare(b)), })) +const mergeSummaries = (summaries: DepthSummaryItem[][]) => { + const summaryMap = new Map>() + + for (const summary of summaries) { + for (const item of summary) { + const nodes = summaryMap.get(item.depth) ?? new Set() + + for (const node of item.nodes) { + nodes.add(node) + } + + summaryMap.set(item.depth, nodes) + } + } + + return Array.from(summaryMap.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([depth, nodes]): DepthSummaryItem => ({ + depth, + nodes: Array.from(nodes).sort((a, b) => a.localeCompare(b)), + })) +} + const walkOutgoing = (rootNodeId: string, edges: GraphEdge[], maxLevels: number) => { const visitedNodes = new Map() const includedEdgeIds = new Set() const summaryMap = new Map() const queue: Array<{ nodeId: string; depth: number }> = [{ nodeId: rootNodeId, depth: 0 }] + visitedNodes.set(rootNodeId, 0) while (queue.length > 0) { @@ -49,7 +88,7 @@ const walkOutgoing = (rootNodeId: string, edges: GraphEdge[], maxLevels: number) if (!visitedNodes.has(edge.target) || nextDepth < visitedNodes.get(edge.target)!) { visitedNodes.set(edge.target, nextDepth) - const targetLabel = MOCK_NODES.find((node) => node.id === edge.target)?.label ?? edge.target + const targetLabel = getNodeLabel(edge.target) const arr = summaryMap.get(nextDepth) || [] if (!arr.includes(targetLabel)) { @@ -75,6 +114,7 @@ const walkIncoming = (rootNodeId: string, edges: GraphEdge[], maxLevels: number) const summaryMap = new Map() const queue: Array<{ nodeId: string; depth: number }> = [{ nodeId: rootNodeId, depth: 0 }] + visitedNodes.set(rootNodeId, 0) while (queue.length > 0) { @@ -98,7 +138,7 @@ const walkIncoming = (rootNodeId: string, edges: GraphEdge[], maxLevels: number) if (!visitedNodes.has(edge.source) || nextDepth < visitedNodes.get(edge.source)!) { visitedNodes.set(edge.source, nextDepth) - const sourceLabel = MOCK_NODES.find((node) => node.id === edge.source)?.label ?? edge.source + const sourceLabel = getNodeLabel(edge.source) const arr = summaryMap.get(nextDepth) || [] if (!arr.includes(sourceLabel)) { @@ -118,26 +158,21 @@ const walkIncoming = (rootNodeId: string, edges: GraphEdge[], maxLevels: number) } } -const collectResponse = ( - rootNodeId: string, - traversalMode: TraversalMode, - threshold: number, - maxLevels: number -): FetchGeneGraphResponse => { - const thresholdEdges = MOCK_EDGES.filter((edge) => - passesThreshold(edge.correlation, threshold) +const collectFilterResponse = (filter: GraphQueryFilter): FetchGeneGraphResponse => { + const thresholdEdges = getEdgesForFilter(filter.rootNodeId).filter((edge) => + passesThreshold(edge.correlation, filter.threshold) ) - const outgoing = traversalMode === 'outgoing' || traversalMode === 'both' - ? walkOutgoing(rootNodeId, thresholdEdges, maxLevels) + const outgoing = filter.traversalMode === 'outgoing' || filter.traversalMode === 'both' + ? walkOutgoing(filter.rootNodeId, thresholdEdges, filter.maxLevels) : { visitedNodes: new Map(), includedEdgeIds: new Set(), summary: [] as DepthSummaryItem[], } - const incoming = traversalMode === 'incoming' || traversalMode === 'both' - ? walkIncoming(rootNodeId, thresholdEdges, maxLevels) + const incoming = filter.traversalMode === 'incoming' || filter.traversalMode === 'both' + ? walkIncoming(filter.rootNodeId, thresholdEdges, filter.maxLevels) : { visitedNodes: new Map(), includedEdgeIds: new Set(), @@ -150,7 +185,6 @@ const collectResponse = ( ]) const edges = thresholdEdges.filter((edge) => includedEdgeIds.has(edge.id)) - const includedNodeIdsFromEdges = new Set() for (const edge of edges) { @@ -158,11 +192,9 @@ const collectResponse = ( includedNodeIdsFromEdges.add(edge.target) } - // Keep the root visible even when no edges match the current filters. - includedNodeIdsFromEdges.add(rootNodeId) + includedNodeIdsFromEdges.add(filter.rootNodeId) const nodes = MOCK_NODES.filter((node) => includedNodeIdsFromEdges.has(node.id)) - const validLabels = new Set(nodes.map((node) => node.label)) const outgoingSummary = outgoing.summary @@ -180,11 +212,38 @@ const collectResponse = ( .filter((item) => item.nodes.length > 0) return { - rootNodeId, + rootNodeId: filter.rootNodeId, nodes, edges, outgoingSummary, incomingSummary, + filters: [filter], + } +} + +const collectResponse = (filters: GraphQueryFilter[]): FetchGeneGraphResponse => { + const safeFilters = filters.length > 0 ? filters : [FALLBACK_FILTER] + const responses = safeFilters.map(collectFilterResponse) + const nodeIds = new Set() + const edgesById = new Map() + + for (const response of responses) { + for (const node of response.nodes) { + nodeIds.add(node.id) + } + + for (const edge of response.edges) { + edgesById.set(edge.id, edge) + } + } + + return { + rootNodeId: safeFilters[0].rootNodeId, + nodes: MOCK_NODES.filter((node) => nodeIds.has(node.id)), + edges: Array.from(edgesById.values()), + outgoingSummary: mergeSummaries(responses.map((response) => response.outgoingSummary)), + incomingSummary: mergeSummaries(responses.map((response) => response.incomingSummary)), + filters: safeFilters, } } @@ -193,10 +252,5 @@ export const fetchGeneGraph = async ( ): Promise => { await sleep(350) - return collectResponse( - params.rootNodeId, - params.traversalMode, - params.threshold, - params.maxLevels - ) + return collectResponse(params.filters) } diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/mockNetworkData.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/mockNetworkData.ts index b39c6147..39080864 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/mockNetworkData.ts +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/mockNetworkData.ts @@ -18,6 +18,48 @@ export const MOCK_NODES: GraphNode[] = [ { id: 'cna_7q34', label: '7q34 gain', type: 'CNA', size: 34 }, { id: 'meth_rassf1', label: 'RASSF1 meth', type: 'Methylation', size: 34 }, { id: 'drug_vemurafenib', label: 'Vemurafenib', type: 'Drug', size: 36 }, + { id: 'gene_akt1', label: 'AKT1', type: 'Gene', size: 32 }, + { id: 'gene_grb2', label: 'GRB2', type: 'Gene', size: 30 }, + { id: 'gene_raf1', label: 'RAF1', type: 'Gene', size: 32 }, + { id: 'drug_trametinib', label: 'Trametinib', type: 'Drug', size: 34 }, + { id: 'gene_mapkapk2', label: 'MAPKAPK2', type: 'Gene', size: 30 }, + { id: 'gene_rps6ka1', label: 'RPS6KA1', type: 'Gene', size: 30 }, + { id: 'gene_dusp4', label: 'DUSP4', type: 'Gene', size: 30 }, + { id: 'gene_jun', label: 'JUN', type: 'Gene', size: 32 }, + { id: 'gene_etv4', label: 'ETV4', type: 'Gene', size: 30 }, + { id: 'gene_dusp5', label: 'DUSP5', type: 'Gene', size: 30 }, + { id: 'gene_rps6ka3', label: 'RPS6KA3', type: 'Gene', size: 30 }, + { id: 'gene_elk4', label: 'ELK4', type: 'Gene', size: 30 }, + { id: 'gene_ets1', label: 'ETS1', type: 'Gene', size: 30 }, + { id: 'gene_cdk4', label: 'CDK4', type: 'Gene', size: 30 }, + { id: 'gene_tert', label: 'TERT', type: 'Gene', size: 30 }, + { id: 'gene_max', label: 'MAX', type: 'Gene', size: 30 }, + { id: 'mir_34a', label: 'miR-34a', type: 'miRNA', size: 28 }, + { id: 'gene_junb', label: 'JUNB', type: 'Gene', size: 30 }, + { id: 'gene_atf3', label: 'ATF3', type: 'Gene', size: 30 }, + { id: 'gene_mmp9', label: 'MMP9', type: 'Gene', size: 30 }, + { id: 'gene_cdk6', label: 'CDK6', type: 'Gene', size: 30 }, + { id: 'gene_rb1', label: 'RB1', type: 'Gene', size: 30 }, + { id: 'drug_palbociclib', label: 'Palbociclib', type: 'Drug', size: 34 }, + { id: 'gene_egr1', label: 'EGR1', type: 'Gene', size: 30 }, + { id: 'gene_srf', label: 'SRF', type: 'Gene', size: 30 }, + { id: 'gene_fgfr1', label: 'FGFR1', type: 'Gene', size: 30 }, + { id: 'gene_spry4', label: 'SPRY4', type: 'Gene', size: 30 }, + { id: 'gene_egfr', label: 'EGFR', type: 'Gene', size: 32 }, + { id: 'gene_fgfr2', label: 'FGFR2', type: 'Gene', size: 30 }, + { id: 'gene_nfkb1', label: 'NFKB1', type: 'Gene', size: 30 }, + { id: 'gene_rela', label: 'RELA', type: 'Gene', size: 30 }, + { id: 'gene_pten', label: 'PTEN', type: 'Gene', size: 32 }, + { id: 'gene_e2f1', label: 'E2F1', type: 'Gene', size: 30 }, + { id: 'gene_bcl2l11', label: 'BCL2L11', type: 'Gene', size: 30 }, + { id: 'gene_pdcd4', label: 'PDCD4', type: 'Gene', size: 30 }, + { id: 'gene_tpm1', label: 'TPM1', type: 'Gene', size: 30 }, + { id: 'gene_braf_fusion', label: 'BRAF fusion', type: 'Gene', size: 32 }, + { id: 'gene_kiaa1549', label: 'KIAA1549', type: 'Gene', size: 30 }, + { id: 'gene_rassf1', label: 'RASSF1', type: 'Gene', size: 30 }, + { id: 'gene_cdkn2a', label: 'CDKN2A', type: 'Gene', size: 30 }, + { id: 'gene_axl', label: 'AXL', type: 'Gene', size: 30 }, + { id: 'gene_dusp1', label: 'DUSP1', type: 'Gene', size: 30 }, ] export const MOCK_EDGES: GraphEdge[] = [ @@ -50,3 +92,86 @@ export const MOCK_EDGES: GraphEdge[] = [ { id: 'e_22', source: 'gene_spry2', target: 'gene_braf', correlation: -0.32 }, { id: 'e_23', source: 'drug_vemurafenib', target: 'gene_mek1', correlation: -0.41 }, ] + +export const MOCK_EXPANSION_EDGES_BY_ROOT: Record = { + gene_mek1: [ + { id: 'e_exp_mek1_1', source: 'gene_raf1', target: 'gene_mek1', correlation: 0.86 }, + { id: 'e_exp_mek1_2', source: 'gene_grb2', target: 'gene_mek1', correlation: 0.62 }, + { id: 'e_exp_mek1_3', source: 'gene_mek1', target: 'gene_akt1', correlation: 0.74 }, + { id: 'e_exp_mek1_4', source: 'drug_trametinib', target: 'gene_mek1', correlation: -0.91 }, + { id: 'e_exp_mek1_5', source: 'gene_akt1', target: 'gene_myc', correlation: 0.58 }, + ], + gene_mek2: [ + { id: 'e_exp_mek2_1', source: 'gene_mek2', target: 'gene_mapkapk2', correlation: 0.73 }, + { id: 'e_exp_mek2_2', source: 'gene_mek2', target: 'gene_rps6ka1', correlation: 0.69 }, + { id: 'e_exp_mek2_3', source: 'gene_dusp4', target: 'gene_mek2', correlation: -0.61 }, + ], + gene_erk1: [ + { id: 'e_exp_erk1_1', source: 'gene_erk1', target: 'gene_jun', correlation: 0.84 }, + { id: 'e_exp_erk1_2', source: 'gene_erk1', target: 'gene_etv4', correlation: 0.78 }, + { id: 'e_exp_erk1_3', source: 'gene_dusp5', target: 'gene_erk1', correlation: -0.71 }, + { id: 'e_exp_erk1_4', source: 'gene_jun', target: 'gene_fos', correlation: 0.65 }, + ], + gene_erk2: [ + { id: 'e_exp_erk2_1', source: 'gene_erk2', target: 'gene_rps6ka3', correlation: 0.82 }, + { id: 'e_exp_erk2_2', source: 'gene_erk2', target: 'gene_elk4', correlation: 0.73 }, + { id: 'e_exp_erk2_3', source: 'gene_ets1', target: 'gene_erk2', correlation: 0.59 }, + ], + gene_myc: [ + { id: 'e_exp_myc_1', source: 'gene_myc', target: 'gene_cdk4', correlation: 0.82 }, + { id: 'e_exp_myc_2', source: 'gene_myc', target: 'gene_tert', correlation: 0.76 }, + { id: 'e_exp_myc_3', source: 'gene_max', target: 'gene_myc', correlation: 0.88 }, + { id: 'e_exp_myc_4', source: 'mir_34a', target: 'gene_myc', correlation: -0.79 }, + ], + gene_fos: [ + { id: 'e_exp_fos_1', source: 'gene_fos', target: 'gene_junb', correlation: 0.84 }, + { id: 'e_exp_fos_2', source: 'gene_fos', target: 'gene_atf3', correlation: 0.76 }, + { id: 'e_exp_fos_3', source: 'gene_junb', target: 'gene_mmp9', correlation: 0.71 }, + { id: 'e_exp_fos_4', source: 'mir_21', target: 'gene_fos', correlation: -0.62 }, + ], + gene_ccnd1: [ + { id: 'e_exp_ccnd1_1', source: 'gene_ccnd1', target: 'gene_cdk6', correlation: 0.81 }, + { id: 'e_exp_ccnd1_2', source: 'gene_ccnd1', target: 'gene_rb1', correlation: -0.64 }, + { id: 'e_exp_ccnd1_3', source: 'drug_palbociclib', target: 'gene_ccnd1', correlation: -0.88 }, + ], + gene_elk1: [ + { id: 'e_exp_elk1_1', source: 'gene_elk1', target: 'gene_egr1', correlation: 0.79 }, + { id: 'e_exp_elk1_2', source: 'gene_srf', target: 'gene_elk1', correlation: 0.61 }, + ], + gene_dusp6: [ + { id: 'e_exp_dusp6_1', source: 'gene_dusp6', target: 'gene_fgfr1', correlation: -0.75 }, + { id: 'e_exp_dusp6_2', source: 'gene_dusp6', target: 'gene_spry4', correlation: 0.66 }, + ], + gene_spry2: [ + { id: 'e_exp_spry2_1', source: 'gene_spry2', target: 'gene_egfr', correlation: -0.72 }, + { id: 'e_exp_spry2_2', source: 'gene_fgfr2', target: 'gene_spry2', correlation: 0.69 }, + ], + gene_map3k8: [ + { id: 'e_exp_map3k8_1', source: 'gene_map3k8', target: 'gene_nfkb1', correlation: 0.8 }, + { id: 'e_exp_map3k8_2', source: 'gene_map3k8', target: 'gene_rela', correlation: 0.73 }, + ], + mir_17: [ + { id: 'e_exp_mir17_1', source: 'mir_17', target: 'gene_pten', correlation: -0.82 }, + { id: 'e_exp_mir17_2', source: 'mir_17', target: 'gene_e2f1', correlation: -0.69 }, + { id: 'e_exp_mir17_3', source: 'gene_e2f1', target: 'gene_bcl2l11', correlation: 0.64 }, + ], + mir_21: [ + { id: 'e_exp_mir21_1', source: 'mir_21', target: 'gene_pdcd4', correlation: -0.86 }, + { id: 'e_exp_mir21_2', source: 'mir_21', target: 'gene_pten', correlation: -0.72 }, + { id: 'e_exp_mir21_3', source: 'mir_21', target: 'gene_tpm1', correlation: -0.61 }, + ], + cna_7q34: [ + { id: 'e_exp_cna7q34_1', source: 'cna_7q34', target: 'gene_braf_fusion', correlation: 0.93 }, + { id: 'e_exp_cna7q34_2', source: 'gene_kiaa1549', target: 'gene_braf_fusion', correlation: 0.77 }, + { id: 'e_exp_cna7q34_3', source: 'gene_braf_fusion', target: 'gene_braf', correlation: 0.84 }, + ], + meth_rassf1: [ + { id: 'e_exp_meth_rassf1_1', source: 'meth_rassf1', target: 'gene_rassf1', correlation: -0.88 }, + { id: 'e_exp_meth_rassf1_2', source: 'gene_rassf1', target: 'gene_cdkn2a', correlation: 0.62 }, + ], + drug_vemurafenib: [ + { id: 'e_exp_vemurafenib_1', source: 'drug_vemurafenib', target: 'gene_axl', correlation: 0.72 }, + { id: 'e_exp_vemurafenib_2', source: 'drug_vemurafenib', target: 'gene_egfr', correlation: 0.68 }, + { id: 'e_exp_vemurafenib_3', source: 'gene_dusp1', target: 'gene_braf', correlation: -0.55 }, + ], +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/types.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/types.ts index 2034a57c..3075e70e 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/types.ts +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/types.ts @@ -16,6 +16,13 @@ export type GraphEdge = { correlation: number; } +export type GraphQueryFilter = { + rootNodeId: string; + threshold: number; + traversalMode: TraversalMode; + maxLevels: number; +} + export type SelectedEdgeInfo = { id: string; source: string; @@ -30,10 +37,7 @@ export type DepthSummaryItem = { } export type FetchGeneGraphParams = { - rootNodeId: string; - threshold: number; - traversalMode: TraversalMode; - maxLevels: number; + filters: GraphQueryFilter[]; } export type FetchGeneGraphResponse = { @@ -42,4 +46,5 @@ export type FetchGeneGraphResponse = { edges: GraphEdge[]; outgoingSummary: DepthSummaryItem[]; incomingSummary: DepthSummaryItem[]; + filters: GraphQueryFilter[]; } diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/useGeneGraphQuery.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/useGeneGraphQuery.ts index e97402e6..9dbc5872 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/useGeneGraphQuery.ts +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/useGeneGraphQuery.ts @@ -47,12 +47,7 @@ export const useGeneGraphQuery = (params: FetchGeneGraphParams) => { return () => { cancelled = true } - }, [ - params.rootNodeId, - params.threshold, - params.traversalMode, - params.maxLevels, - ]) + }, [params]) return state } From 5e70b1c3cf09efb22a17744a4f7853867f629571 Mon Sep 17 00:00:00 2001 From: Juan Nicolas Herrera <54152074+juanNH@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:47:36 -0300 Subject: [PATCH 05/17] Comment access to tab to implement backend in future --- .../molecules/CurrentMoleculeDetails.tsx | 7 ++++--- .../molecules/MoleculesDetailsMenu.tsx | 3 ++- .../molecules/genes/GeneAssociationsNetwork.tsx | 9 +++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx index cc17d21f..d4235c5b 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx @@ -10,7 +10,7 @@ import { GeneOntologyPanel } from './gene-ontology/GeneOntologyPanel' import { MiRNADrugsPanel } from '../../../pipeline/experiment-result/gene-gem-details/MiRNADrugsPanel' import { MiRNADiseasesPanel } from '../../../pipeline/experiment-result/gene-gem-details/MiRNADiseasesPanel' import { ActionableCancerGenesPanel } from './genes/ActionableCancerGenesPanel' -import { GeneExpressionRegulationAssociationNetworkPanel } from './genes/gene-association-network/GeneExpressionRegulationNetworkPanel' +/* import { GeneExpressionRegulationAssociationNetworkPanel } from './genes/gene-association-network/GeneExpressionRegulationNetworkPanel' */ import { GeneAssociationsNetworkPanel } from './genes/GeneAssociationsNetworkPanel' // const MENU_DEFAULT: ActiveBiomarkerMoleculeItemMenu = ActiveBiomarkerMoleculeItemMenu.DETAILS // TODO: use this @@ -53,8 +53,9 @@ export const CurrentMoleculeDetails = (props: CurrentMoleculeDetailsProps) => { return case ActiveBiomarkerMoleculeItemMenu.GENE_ONTOLOGY: return - case ActiveBiomarkerMoleculeItemMenu.GENE_REGULATION_ASSOCIATIONS: - return + /* Todo: implement when experiment regulation associations are available + case ActiveBiomarkerMoleculeItemMenu.GENE_REGULATION_ASSOCIATIONS: + return */ case ActiveBiomarkerMoleculeItemMenu.DISEASES: return case ActiveBiomarkerMoleculeItemMenu.DRUGS: diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/MoleculesDetailsMenu.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/MoleculesDetailsMenu.tsx index ec0b914c..e0318e2e 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/MoleculesDetailsMenu.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/MoleculesDetailsMenu.tsx @@ -63,13 +63,14 @@ export const MoleculesDetailsMenu = (props: MoleculesDetailsMenuProps) => { popupInfo: 'Gene Ontology (GO) is a powerful tool for understanding the biological processes, molecular functions, and cellular components associated with a gene', isVisible: isGene }, + /* TODO: implement when experiment regulation associations are available { name: 'Gene regulation associations', onClick: () => props.setActiveItem(ActiveBiomarkerMoleculeItemMenu.GENE_REGULATION_ASSOCIATIONS), isActive: props.activeItem === ActiveBiomarkerMoleculeItemMenu.GENE_REGULATION_ASSOCIATIONS, popupInfo: 'Gene regulation associations provide insights into the regulatory relationships between genes, helping to unravel the complex mechanisms that control gene expression and cellular function', isVisible: isGene - }, + }, */ // TODO: implement // { // name: 'Actionable/Cancer genes', diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx index 85a7b886..f87b7b60 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx @@ -50,7 +50,9 @@ const roundThreshold = (value: number) => Number(value.toFixed(1)) const getEdgeColor = (correlation: number, threshold: number) => { if (correlation <= -threshold) { return '#dc2626' } + if (correlation >= threshold) { return '#2563eb' } + return 'transparent' } @@ -58,7 +60,9 @@ const getEdgeOpacity = (correlation: number, threshold: number) => { const abs = Math.abs(correlation) if (abs < threshold) { return 0 } + if (abs >= 0.8) { return 0.95 } + if (abs >= 0.6) { return 0.8 } return 0.7 @@ -68,8 +72,11 @@ const getEdgeWidth = (correlation: number, threshold: number) => { const abs = Math.abs(correlation) if (abs < threshold) { return 0 } + if (abs >= 0.9) { return 6 } + if (abs >= 0.8) { return 5 } + if (abs >= 0.7) { return 4 } return 3 @@ -80,6 +87,7 @@ const getDirectionLabel = ( threshold: number ): SelectedEdgeInfo['direction'] => { if (correlation <= -threshold) { return 'Down regulate' } + return 'Up regulate' } @@ -739,6 +747,7 @@ export const GeneExpressionRegulationNetworkPanel = ({ }) const defaultRoot = cy.getElementById('gene_braf') + if (defaultRoot && defaultRoot.nonempty()) { applyTraversal(defaultRoot, traversalMode, maxLevels) cy.center(defaultRoot) From 1ea5264f82410060b1d36400fed62ffbb969325a Mon Sep 17 00:00:00 2001 From: Juan Nicolas Herrera <54152074+juanNH@users.noreply.github.com> Date: Tue, 5 May 2026 09:24:33 -0300 Subject: [PATCH 06/17] Fixs comments, params, interfaces. Remove unused code --- .../molecules/CurrentMoleculeDetails.tsx | 4 +- .../GeneExpressionRegulationNetworkPanel.tsx | 178 --------------- .../GeneRegulationAssociationsPanel.tsx | 205 ++++++++++++++++++ ...nel.tsx => GeneRegulationFiltersPanel.tsx} | 52 +++-- ...tworkGraph.tsx => GeneRegulationGraph.tsx} | 94 ++++++-- .../GraphControls.tsx | 184 ---------------- .../gene-association-network/LevelBadge.tsx | 20 +- .../SelectedEdgesPanel.tsx | 21 +- .../TraversalSummaryPanel.tsx | 27 ++- .../gene-association-network/graphApi.ts | 89 ++++++-- .../gene-association-network/graphStyle.ts | 2 + .../genes/gene-association-network/legend.tsx | 99 ++++++--- .../mockNetworkData.ts | 3 + .../genes/gene-association-network/types.ts | 39 +++- ...uery.ts => useGeneRegulationGraphQuery.ts} | 25 ++- 15 files changed, 570 insertions(+), 472 deletions(-) delete mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneExpressionRegulationNetworkPanel.tsx create mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationAssociationsPanel.tsx rename src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/{ActiveGraphFiltersPanel.tsx => GeneRegulationFiltersPanel.tsx} (80%) rename src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/{GeneNetworkGraph.tsx => GeneRegulationGraph.tsx} (85%) delete mode 100644 src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GraphControls.tsx rename src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/{useGeneGraphQuery.ts => useGeneRegulationGraphQuery.ts} (51%) diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx index d4235c5b..07fd58eb 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx @@ -10,7 +10,7 @@ import { GeneOntologyPanel } from './gene-ontology/GeneOntologyPanel' import { MiRNADrugsPanel } from '../../../pipeline/experiment-result/gene-gem-details/MiRNADrugsPanel' import { MiRNADiseasesPanel } from '../../../pipeline/experiment-result/gene-gem-details/MiRNADiseasesPanel' import { ActionableCancerGenesPanel } from './genes/ActionableCancerGenesPanel' -/* import { GeneExpressionRegulationAssociationNetworkPanel } from './genes/gene-association-network/GeneExpressionRegulationNetworkPanel' */ +/* import { GeneRegulationAssociationsPanel } from './genes/gene-association-network/GeneRegulationAssociationsPanel' */ import { GeneAssociationsNetworkPanel } from './genes/GeneAssociationsNetworkPanel' // const MENU_DEFAULT: ActiveBiomarkerMoleculeItemMenu = ActiveBiomarkerMoleculeItemMenu.DETAILS // TODO: use this @@ -55,7 +55,7 @@ export const CurrentMoleculeDetails = (props: CurrentMoleculeDetailsProps) => { return /* Todo: implement when experiment regulation associations are available case ActiveBiomarkerMoleculeItemMenu.GENE_REGULATION_ASSOCIATIONS: - return */ + return */ case ActiveBiomarkerMoleculeItemMenu.DISEASES: return case ActiveBiomarkerMoleculeItemMenu.DRUGS: diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneExpressionRegulationNetworkPanel.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneExpressionRegulationNetworkPanel.tsx deleted file mode 100644 index 6ae451f9..00000000 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneExpressionRegulationNetworkPanel.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import React, { useMemo, useState } from 'react' -import { Loader, Message } from 'semantic-ui-react' -import { ActiveGraphFiltersPanel } from './ActiveGraphFiltersPanel' -import { GeneNetworkGraph } from './GeneNetworkGraph' -import { SelectedEdgesPanel } from './SelectedEdgesPanel' -import { TraversalSummaryPanel } from './TraversalSummaryPanel' -import { useGeneGraphQuery } from './useGeneGraphQuery' -import { GraphQueryFilter, SelectedEdgeInfo } from './types' - -type Props = { - height?: number | string; - width?: number | string; -} - -const DEFAULT_ROOT_FILTER: GraphQueryFilter = { - rootNodeId: 'gene_braf', - threshold: 0.5, - traversalMode: 'both', - maxLevels: 3, -} - -export const GeneExpressionRegulationAssociationNetworkPanel = ({ - height = 650, - width = '100%', -}: Props) => { - const [selectedEdges, setSelectedEdges] = useState([]) - const [filters, setFilters] = useState([DEFAULT_ROOT_FILTER]) - const [draftFilters, setDraftFilters] = useState([DEFAULT_ROOT_FILTER]) - - const defaultExpansionFilter = useMemo(() => ({ - threshold: draftFilters[0]?.threshold ?? DEFAULT_ROOT_FILTER.threshold, - traversalMode: draftFilters[0]?.traversalMode ?? DEFAULT_ROOT_FILTER.traversalMode, - maxLevels: draftFilters[0]?.maxLevels ?? DEFAULT_ROOT_FILTER.maxLevels, - }), [draftFilters]) - - const queryParams = useMemo(() => ({ - filters, - }), [filters]) - - const { data, loading, error } = useGeneGraphQuery(queryParams) - const hasPendingFilterChanges = filters.length !== draftFilters.length || filters.some((filter, index) => { - const draftFilter = draftFilters[index] - - if (!draftFilter) { return true } - - return filter.rootNodeId !== draftFilter.rootNodeId || - filter.threshold !== draftFilter.threshold || - filter.traversalMode !== draftFilter.traversalMode || - filter.maxLevels !== draftFilter.maxLevels - }) - - const handleExpandNode = (filter: GraphQueryFilter) => { - setSelectedEdges([]) - setDraftFilters((prev) => { - if (prev.some((item) => item.rootNodeId === filter.rootNodeId)) { - setFilters(prev) - return prev - } - - const nextFilters = [ - ...prev, - filter, - ] - - setFilters(nextFilters) - - return nextFilters - }) - } - - const handleUpdateFilter = (rootNodeId: string, partialFilter: Partial) => { - setDraftFilters((prev) => prev.map((filter) => { - if (filter.rootNodeId !== rootNodeId) { return filter } - - return { - ...filter, - ...partialFilter, - } - })) - } - - const handleRemoveFilter = (rootNodeId: string) => { - setDraftFilters((prev) => prev.filter((filter, index) => index === 0 || filter.rootNodeId !== rootNodeId)) - } - - const handleClearExpansions = () => { - setDraftFilters((prev) => { - if (prev.length === 0) { return [DEFAULT_ROOT_FILTER] } - - return [prev[0]] - }) - } - - const handleResetFilters = () => { - setDraftFilters(filters.map((filter) => ({ - ...filter, - }))) - } - - const handleApplyFilters = () => { - setSelectedEdges([]) - setFilters(draftFilters) - } - - return ( -
- {loading && ( -
- -
- )} - - {error && ( - - Error fetching graph -

{error}

-
- )} - - {!loading && !error && data && ( - filter.rootNodeId)} - defaultExpansionFilter={defaultExpansionFilter} - onExpandNode={handleExpandNode} - /> - )} - - {!loading && !error && data && ( - data.nodes.find((node) => node.id === nodeId)?.label ?? nodeId} - onUpdateFilter={handleUpdateFilter} - onClearExpansions={handleClearExpansions} - onRemoveFilter={handleRemoveFilter} - onResetFilters={handleResetFilters} - onApplyFilters={handleApplyFilters} - /> - )} - - {!loading && !error && data && ( -
- setSelectedEdges((prev) => prev.filter((edge) => edge.id !== edgeId))} - onClearAll={() => setSelectedEdges([])} - /> - - node.id === data.rootNodeId)?.label ?? null} - maxLevels={filters[0]?.maxLevels ?? DEFAULT_ROOT_FILTER.maxLevels} - depthSummary={data.outgoingSummary} - incomingSummary={data.incomingSummary} - /> -
- )} -
- ) -} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationAssociationsPanel.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationAssociationsPanel.tsx new file mode 100644 index 00000000..c893c493 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationAssociationsPanel.tsx @@ -0,0 +1,205 @@ +import React, { useMemo, useState } from 'react' +import { Loader, Message } from 'semantic-ui-react' +import { GeneRegulationFiltersPanel } from './GeneRegulationFiltersPanel' +import { GeneRegulationGraph } from './GeneRegulationGraph' +import { SelectedEdgesPanel } from './SelectedEdgesPanel' +import { TraversalSummaryPanel } from './TraversalSummaryPanel' +import { useGeneRegulationGraphQuery } from './useGeneRegulationGraphQuery' +import { GraphQueryFilter, SelectedEdgeInfo } from './types' + +/** Props accepted by the gene regulation associations tab. */ +interface GeneRegulationAssociationsPanelProps { + /** Height assigned to the graph area. */ + height?: number | string; + /** Width assigned to the graph area. */ + width?: number | string; +} + +const DEFAULT_BRAF_GRAPH_FILTER: GraphQueryFilter = { + rootNodeId: 'gene_braf', + threshold: 0.5, + traversalMode: 'both', + maxLevels: 3, +} + +/** + * Renders the mock gene regulation associations experience for the selected biomarker molecule. + * @param props Component props. + * @param props.height Graph container height. + * @param props.width Graph container width. + * @returns The complete gene regulation associations tab. + */ +export const GeneRegulationAssociationsPanel = (props: GeneRegulationAssociationsPanelProps): JSX.Element => { + const { + height = 650, + width = '100%', + } = props + const [selectedEdges, setSelectedEdges] = useState([]) + const [appliedFilters, setAppliedFilters] = useState([DEFAULT_BRAF_GRAPH_FILTER]) + const [editableFilters, setEditableFilters] = useState([DEFAULT_BRAF_GRAPH_FILTER]) + + const nextExpansionFilter = useMemo(() => ({ + threshold: editableFilters[0]?.threshold ?? DEFAULT_BRAF_GRAPH_FILTER.threshold, + traversalMode: editableFilters[0]?.traversalMode ?? DEFAULT_BRAF_GRAPH_FILTER.traversalMode, + maxLevels: editableFilters[0]?.maxLevels ?? DEFAULT_BRAF_GRAPH_FILTER.maxLevels, + }), [editableFilters]) + + const graphQueryParams = useMemo(() => ({ + filters: appliedFilters, + }), [appliedFilters]) + + const { data, loading, error } = useGeneRegulationGraphQuery(graphQueryParams) + const hasPendingEditableFilterChanges = appliedFilters.length !== editableFilters.length || appliedFilters.some((filter, index) => { + const editableFilter = editableFilters[index] + + if (!editableFilter) { return true } + + return filter.rootNodeId !== editableFilter.rootNodeId || + filter.threshold !== editableFilter.threshold || + filter.traversalMode !== editableFilter.traversalMode || + filter.maxLevels !== editableFilter.maxLevels + }) + + /** + * Applies a node expansion immediately and keeps the editable state in sync. + * @param filter Expansion filter created from the graph context menu. + */ + const handleExpandGraphFromNode = (filter: GraphQueryFilter) => { + setSelectedEdges([]) + setEditableFilters((prev) => { + if (prev.some((item) => item.rootNodeId === filter.rootNodeId)) { + setAppliedFilters(prev) + return prev + } + + const nextEditableFilters = [ + ...prev, + filter, + ] + + setAppliedFilters(nextEditableFilters) + + return nextEditableFilters + }) + } + + /** + * Stages the edition of a filter without applying the request yet. + * @param rootNodeId Root node associated with the filter being edited. + * @param partialFilter Partial values that should overwrite the current draft filter. + */ + const handleEditableFilterUpdate = (rootNodeId: string, partialFilter: Partial) => { + setEditableFilters((prev) => prev.map((filter) => { + if (filter.rootNodeId !== rootNodeId) { return filter } + + return { + ...filter, + ...partialFilter, + } + })) + } + + /** + * Removes an expansion from the editable request payload. + * @param rootNodeId Root node identifier of the expansion to remove. + */ + const handleEditableExpansionRemoval = (rootNodeId: string) => { + setEditableFilters((prev) => prev.filter((filter, index) => index === 0 || filter.rootNodeId !== rootNodeId)) + } + + /** Keeps only the root filter in the editable request payload. */ + const handleEditableExpansionClear = () => { + setEditableFilters((prev) => { + if (prev.length === 0) { return [DEFAULT_BRAF_GRAPH_FILTER] } + + return [prev[0]] + }) + } + + /** Restores the editable filters to the last filters applied to the graph. */ + const handleEditableFiltersReset = () => { + setEditableFilters(appliedFilters.map((filter) => ({ + ...filter, + }))) + } + + /** Applies the staged filters and triggers a new graph query. */ + const handleEditableFiltersApply = () => { + setSelectedEdges([]) + setAppliedFilters(editableFilters) + } + + return ( +
+ {loading && ( +
+ +
+ )} + + {error && ( + + Error fetching graph +

{error}

+
+ )} + + {!loading && !error && data && ( + filter.rootNodeId)} + defaultExpansionFilter={nextExpansionFilter} + onExpandNode={handleExpandGraphFromNode} + /> + )} + + {!loading && !error && data && ( + data.nodes.find((node) => node.id === nodeId)?.label ?? nodeId} + onUpdateFilter={handleEditableFilterUpdate} + onClearExpansions={handleEditableExpansionClear} + onRemoveFilter={handleEditableExpansionRemoval} + onResetFilters={handleEditableFiltersReset} + onApplyFilters={handleEditableFiltersApply} + /> + )} + + {!loading && !error && data && ( +
+ setSelectedEdges((prev) => prev.filter((edge) => edge.id !== edgeId))} + onClearAll={() => setSelectedEdges([])} + /> + + node.id === data.rootNodeId)?.label ?? null} + maxLevels={appliedFilters[0]?.maxLevels ?? DEFAULT_BRAF_GRAPH_FILTER.maxLevels} + depthSummary={data.outgoingSummary} + incomingSummary={data.incomingSummary} + /> +
+ )} +
+ ) +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/ActiveGraphFiltersPanel.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationFiltersPanel.tsx similarity index 80% rename from src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/ActiveGraphFiltersPanel.tsx rename to src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationFiltersPanel.tsx index 56fc2e3f..f95f2883 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/ActiveGraphFiltersPanel.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationFiltersPanel.tsx @@ -14,27 +14,51 @@ const traversalOptions = [ { key: 'both', value: 'both', text: traversalLabels.both }, ] -type Props = { - filters: GraphQueryFilter[]; +/** Props accepted by the editable graph filters panel. */ +interface GeneRegulationFiltersPanelProps { + /** Draft filters currently being edited by the user. */ + editableFilters: GraphQueryFilter[]; + /** Indicates whether the draft differs from the last applied filters. */ hasPendingChanges: boolean; + /** Resolves the visible label for a node identifier. */ getNodeLabel: (nodeId: string) => string; + /** Updates one draft filter without querying the graph yet. */ onUpdateFilter: (rootNodeId: string, partialFilter: Partial) => void; + /** Clears every expansion while keeping the root filter. */ onClearExpansions: () => void; + /** Removes a specific expansion from the draft filter list. */ onRemoveFilter: (rootNodeId: string) => void; + /** Restores the draft state from the last applied filters. */ onResetFilters: () => void; + /** Applies the draft filter list to the graph query. */ onApplyFilters: () => void; } -export const ActiveGraphFiltersPanel = ({ - filters, - hasPendingChanges, - getNodeLabel, - onUpdateFilter, - onClearExpansions, - onRemoveFilter, - onResetFilters, - onApplyFilters, -}: Props) => { +/** + * Renders the editable list of filters used to build the graph request payload. + * @param props Component props. + * @param props.editableFilters Draft filters currently being edited in the UI. + * @param props.hasPendingChanges Indicates whether the draft differs from the applied graph filters. + * @param props.getNodeLabel Resolves the visible label for a graph node identifier. + * @param props.onUpdateFilter Updates a single draft filter without querying the graph yet. + * @param props.onClearExpansions Removes every draft expansion except for the root filter. + * @param props.onRemoveFilter Removes a specific expansion from the draft request payload. + * @param props.onResetFilters Restores the draft state from the last applied filters. + * @param props.onApplyFilters Applies the current draft filters to the graph query. + * @returns The editable filters panel rendered below the graph. + */ +export const GeneRegulationFiltersPanel = (props: GeneRegulationFiltersPanelProps): JSX.Element => { + const { + editableFilters, + hasPendingChanges, + getNodeLabel, + onUpdateFilter, + onClearExpansions, + onRemoveFilter, + onResetFilters, + onApplyFilters, + } = props + return (
- {filters.map((filter, index) => ( + {editableFilters.map((filter, index) => (
Clear expansions diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneNetworkGraph.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationGraph.tsx similarity index 85% rename from src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneNetworkGraph.tsx rename to src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationGraph.tsx index d4ad4e48..949a3d1f 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneNetworkGraph.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationGraph.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import cytoscape, { Core, ElementDefinition } from 'cytoscape' import { NODE_COLORS, REGULATION_COLORS } from './graphStyle' import { LegendArrow, LegendDot } from './legend' -import { FetchGeneGraphResponse, GraphQueryFilter, SelectedEdgeInfo, TraversalMode } from './types' +import { FetchGeneRegulationGraphResponse, GraphQueryFilter, SelectedEdgeInfo, TraversalMode } from './types' const MIN_ZOOM = 0.4 const MAX_ZOOM = 2 @@ -14,12 +14,22 @@ const traversalLabels: Record = { both: 'Both', } +/** + * Resolves the edge color according to the correlation sign. + * @param correlation Correlation value stored in the graph edge. + * @returns The color used to render the edge. + */ const getEdgeColor = (correlation: number) => { if (correlation < 0) { return REGULATION_COLORS.down } return REGULATION_COLORS.up } +/** + * Maps the edge correlation strength into the rendered edge opacity. + * @param correlation Correlation value stored in the graph edge. + * @returns The opacity used to render the edge. + */ const getEdgeOpacity = (correlation: number) => { const abs = Math.abs(correlation) @@ -30,6 +40,11 @@ const getEdgeOpacity = (correlation: number) => { return 0.7 } +/** + * Maps the edge correlation strength into the rendered edge width. + * @param correlation Correlation value stored in the graph edge. + * @returns The width used to render the edge. + */ const getEdgeWidth = (correlation: number) => { const abs = Math.abs(correlation) @@ -42,51 +57,93 @@ const getEdgeWidth = (correlation: number) => { return 3 } +/** + * Converts the edge correlation sign into the label shown by the UI. + * @param correlation Correlation value stored in the graph edge. + * @returns The semantic direction label shown in tooltips and panels. + */ const getDirectionLabel = (correlation: number): SelectedEdgeInfo['direction'] => { if (correlation < 0) { return 'Down-regulation' } return 'Up-regulation' } +/** Tooltip state used while hovering graph edges. */ type TooltipState = { + /** Whether the tooltip is currently visible. */ visible: boolean; + /** Horizontal position relative to the graph container. */ x: number; + /** Vertical position relative to the graph container. */ y: number; + /** Text shown inside the tooltip. */ content: string; } +/** Context menu state used when expanding a node from the graph. */ type ContextMenuState = { + /** Whether the expansion menu is currently visible. */ visible: boolean; + /** Horizontal position relative to the graph container. */ x: number; + /** Vertical position relative to the graph container. */ y: number; + /** Node identifier being expanded. */ nodeId: string; + /** Visible label of the node being expanded. */ nodeLabel: string; + /** Threshold draft shown in the expansion form. */ threshold: number; + /** Traversal mode draft shown in the expansion form. */ traversalMode: TraversalMode; + /** Max depth draft shown in the expansion form. */ maxLevels: number; } -type Props = { - data: FetchGeneGraphResponse | null; +/** Props accepted by the Cytoscape-based regulation graph. */ +interface GeneRegulationGraphProps { + /** Graph payload currently rendered in Cytoscape. */ + data: FetchGeneRegulationGraphResponse | null; + /** Height assigned to the graph area. */ height?: number | string; + /** Width assigned to the graph area. */ width?: number | string; + /** Edges currently selected by the user. */ selectedEdges: SelectedEdgeInfo[]; + /** Callback used to sync edge selections with the side panel. */ onSelectedEdgesChange: (edges: SelectedEdgeInfo[]) => void; + /** Node identifiers already expanded into the current request. */ expandedNodeIds: string[]; + /** Default expansion values reused when opening the node context menu. */ defaultExpansionFilter: Omit; + /** Callback used to append a new node expansion to the query. */ onExpandNode: (filter: GraphQueryFilter) => void; } -export const GeneNetworkGraph = ({ - data, - height = 650, - width = '100%', - selectedEdges, - onSelectedEdgesChange, - expandedNodeIds, - defaultExpansionFilter, - onExpandNode, -}: Props): JSX.Element => { +/** + * Renders the Cytoscape graph along with local zoom and expansion controls. + * @param props Component props. + * @param props.data Graph payload rendered in Cytoscape. + * @param props.height Graph container height. + * @param props.width Graph container width. + * @param props.selectedEdges Edges currently selected by the user. + * @param props.onSelectedEdgesChange Callback used to sync the selected edge panel. + * @param props.expandedNodeIds Node identifiers already expanded into the request payload. + * @param props.defaultExpansionFilter Filter values used to initialize the context-menu expansion form. + * @param props.onExpandNode Callback used to add a new expansion from the graph context menu. + * @returns The rendered graph with overlays for legend, zoom and node expansion. + */ +export const GeneRegulationGraph = (props: GeneRegulationGraphProps): JSX.Element => { + const { + data, + height = 650, + width = '100%', + selectedEdges, + onSelectedEdgesChange, + expandedNodeIds, + defaultExpansionFilter, + onExpandNode, + } = props const containerRef = useRef(null) const cyRef = useRef(null) const defaultExpansionFilterRef = useRef(defaultExpansionFilter) @@ -265,6 +322,7 @@ export const GeneNetworkGraph = ({ cy.center(rootNode) } + /** Synchronizes the selected edge list shown in the side panel. */ const syncSelectedEdges = () => { const picked = cy .edges('.edge-picked') @@ -377,7 +435,11 @@ export const GeneNetworkGraph = ({ } }, [selectedEdges]) - const zoomGraph = (direction: 'in' | 'out') => { + /** + * Applies a centered zoom step from the overlay controls. + * @param direction Zoom direction requested by the user. + */ + const handleGraphZoom = (direction: 'in' | 'out') => { const cy = cyRef.current if (!cy) { return } @@ -423,7 +485,7 @@ export const GeneNetworkGraph = ({ >
- -
-
-
- Threshold - {filter.threshold.toFixed(1)} -
- onUpdateFilter(filter.rootNodeId, { - threshold: Number(Number(event.target.value).toFixed(1)), - })} - /> -
- -
-
- Depth - {filter.maxLevels} -
- onUpdateFilter(filter.rootNodeId, { - maxLevels: Number(event.target.value), - })} - /> -
- -
- Regulation mode - onUpdateFilter(filter.rootNodeId, { - traversalMode: data.value as TraversalMode, - })} - /> -
-
-
- ))} -
- -
-
- -
- -
- - - -
-
-
- ) -} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationGraph.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationGraph.tsx deleted file mode 100644 index 1c9b4c76..00000000 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationGraph.tsx +++ /dev/null @@ -1,690 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react' -import cytoscape, { Core, ElementDefinition } from 'cytoscape' -import { NODE_COLORS, REGULATION_COLORS } from './graphStyle' -import { LegendArrow, LegendDot } from './legend' -import { FetchGeneRegulationGraphResponse, GraphQueryFilter, SelectedEdgeInfo, TraversalMode } from './types' - -const MIN_ZOOM = 0.4 -const MAX_ZOOM = 2 -const ZOOM_STEP = 1.2 - -const traversalLabels: Record = { - outgoing: 'Regulates', - incoming: 'Regulated by', - both: 'Both', -} - -/** - * Resolves the edge color according to the correlation sign. - * @param correlation Correlation value stored in the graph edge. - * @returns The color used to render the edge. - */ -const getEdgeColor = (correlation: number) => { - if (correlation < 0) { return REGULATION_COLORS.down } - - return REGULATION_COLORS.up -} - -/** - * Maps the edge correlation strength into the rendered edge opacity. - * @param correlation Correlation value stored in the graph edge. - * @returns The opacity used to render the edge. - */ -const getEdgeOpacity = (correlation: number) => { - const abs = Math.abs(correlation) - - if (abs >= 0.8) { return 0.95 } - - if (abs >= 0.6) { return 0.8 } - - return 0.7 -} - -/** - * Maps the edge correlation strength into the rendered edge width. - * @param correlation Correlation value stored in the graph edge. - * @returns The width used to render the edge. - */ -const getEdgeWidth = (correlation: number) => { - const abs = Math.abs(correlation) - - if (abs >= 0.9) { return 6 } - - if (abs >= 0.8) { return 5 } - - if (abs >= 0.7) { return 4 } - - return 3 -} - -/** - * Converts the edge correlation sign into the label shown by the UI. - * @param correlation Correlation value stored in the graph edge. - * @returns The semantic direction label shown in tooltips and panels. - */ -const getDirectionLabel = (correlation: number): SelectedEdgeInfo['direction'] => { - if (correlation < 0) { return 'Down-regulation' } - - return 'Up-regulation' -} - -/** Tooltip state used while hovering graph edges. */ -type TooltipState = { - /** Whether the tooltip is currently visible. */ - visible: boolean; - /** Horizontal position relative to the graph container. */ - x: number; - /** Vertical position relative to the graph container. */ - y: number; - /** Text shown inside the tooltip. */ - content: string; -} - -/** Context menu state used when expanding a node from the graph. */ -type ContextMenuState = { - /** Whether the expansion menu is currently visible. */ - visible: boolean; - /** Horizontal position relative to the graph container. */ - x: number; - /** Vertical position relative to the graph container. */ - y: number; - /** Node identifier being expanded. */ - nodeId: string; - /** Visible label of the node being expanded. */ - nodeLabel: string; - /** Threshold draft shown in the expansion form. */ - threshold: number; - /** Traversal mode draft shown in the expansion form. */ - traversalMode: TraversalMode; - /** Max depth draft shown in the expansion form. */ - maxLevels: number; -} - -/** Props accepted by the Cytoscape-based regulation graph. */ -interface GeneRegulationGraphProps { - /** Graph payload currently rendered in Cytoscape. */ - data: FetchGeneRegulationGraphResponse | null; - /** Height assigned to the graph area. */ - height?: number | string; - /** Width assigned to the graph area. */ - width?: number | string; - /** Edges currently selected by the user. */ - selectedEdges: SelectedEdgeInfo[]; - /** Callback used to sync edge selections with the side panel. */ - onSelectedEdgesChange: (edges: SelectedEdgeInfo[]) => void; - /** Node identifiers already expanded into the current request. */ - expandedNodeIds: string[]; - /** Default expansion values reused when opening the node context menu. */ - defaultExpansionFilter: Omit; - /** Callback used to append a new node expansion to the query. */ - onExpandNode: (filter: GraphQueryFilter) => void; -} - -/** - * Renders the Cytoscape graph along with local zoom and expansion controls. - * @param props Component props. - * @returns The rendered graph with overlays for legend, zoom and node expansion. - */ -export const GeneRegulationGraph = (props: GeneRegulationGraphProps): JSX.Element => { - const { - data, - height = 650, - width = '100%', - selectedEdges, - onSelectedEdgesChange, - expandedNodeIds, - defaultExpansionFilter, - onExpandNode, - } = props - const containerRef = useRef(null) - const cyRef = useRef(null) - const defaultExpansionFilterRef = useRef(defaultExpansionFilter) - - const [tooltip, setTooltip] = useState({ - visible: false, - x: 0, - y: 0, - content: '', - }) - const [contextMenu, setContextMenu] = useState({ - visible: false, - x: 0, - y: 0, - nodeId: '', - nodeLabel: '', - threshold: defaultExpansionFilter.threshold, - traversalMode: defaultExpansionFilter.traversalMode, - maxLevels: defaultExpansionFilter.maxLevels, - }) - - useEffect(() => { - defaultExpansionFilterRef.current = defaultExpansionFilter - }, [defaultExpansionFilter]) - - const elements = useMemo(() => { - if (!data) { return [] } - - const nodes: ElementDefinition[] = data.nodes.map((node) => ({ - data: { - ...node, - }, - })) - - const edges: ElementDefinition[] = data.edges.map((edge) => ({ - data: { - ...edge, - edgeColor: getEdgeColor(edge.correlation), - edgeOpacity: getEdgeOpacity(edge.correlation), - edgeWidth: getEdgeWidth(edge.correlation), - directionLabel: getDirectionLabel(edge.correlation), - }, - })) - - return [...nodes, ...edges] - }, [data]) - - useEffect(() => { - if (!containerRef.current || !data) { return } - - if (cyRef.current) { - cyRef.current.destroy() - cyRef.current = null - } - - const cy = cytoscape({ - container: containerRef.current, - elements, - wheelSensitivity: 0.18, - minZoom: 0.4, - maxZoom: 2, - style: [ - { - selector: 'node', - style: { - label: 'data(label)', - width: 'data(size)', - height: 'data(size)', - shape: 'ellipse', - 'background-color': '#64748b', - color: '#ffffff', - 'font-size': 12, - 'font-weight': 600, - 'text-valign': 'center', - 'text-halign': 'center', - 'text-wrap': 'wrap', - 'text-max-width': 90 as any, - 'text-outline-color': '#475569', - 'text-outline-width': 3, - 'border-width': 2, - 'border-color': '#ffffff', - }, - }, - { - selector: 'node[type = "Gene"]', - style: { - 'background-color': NODE_COLORS.Gene, - 'text-outline-color': NODE_COLORS.Gene, - }, - }, - { - selector: 'node[type = "miRNA"]', - style: { - 'background-color': NODE_COLORS.miRNA, - 'text-outline-color': NODE_COLORS.miRNA, - }, - }, - { - selector: 'node[type = "CNA"]', - style: { - 'background-color': NODE_COLORS.CNA, - 'text-outline-color': NODE_COLORS.CNA, - }, - }, - { - selector: 'node[type = "Methylation"]', - style: { - 'background-color': NODE_COLORS.Methylation, - 'text-outline-color': NODE_COLORS.Methylation, - }, - }, - { - selector: 'node[type = "Drug"]', - style: { - 'background-color': NODE_COLORS.Drug, - 'text-outline-color': NODE_COLORS.Drug, - }, - }, - { - selector: 'edge', - style: { - width: 'data(edgeWidth)', - 'line-color': 'data(edgeColor)', - opacity: 'data(edgeOpacity)' as any, - 'curve-style': 'bezier', - 'target-arrow-shape': 'triangle', - 'target-arrow-color': 'data(edgeColor)', - 'arrow-scale': 1.15, - }, - }, - { - selector: 'edge.edge-picked', - style: { - opacity: 0.82, - 'underlay-color': '#64748b', - 'underlay-opacity': 0.22, - 'underlay-padding': 5, - width: 'mapData(edgeWidth, 3, 6, 4, 6)', - 'z-index': 999, - }, - }, - { - selector: 'node.root-node', - style: { - 'border-color': '#f59e0b', - 'border-width': 6, - }, - }, - ], - layout: { - name: 'cose', - animate: true, - randomize: true, - fit: true, - padding: 40, - gravity: 1, - nodeRepulsion: 9000, - idealEdgeLength: 90, - componentSpacing: 80, - }, - }) - - cyRef.current = cy - - for (const filter of data.filters) { - const expandedRootNode = cy.getElementById(filter.rootNodeId) - - if (expandedRootNode.nonempty()) { - expandedRootNode.addClass('root-node') - } - } - - const rootNode = cy.getElementById(data.rootNodeId) - - if (rootNode.nonempty()) { - cy.center(rootNode) - } - - /** Synchronizes the selected edge list shown in the side panel. */ - const syncSelectedEdges = () => { - const picked = cy - .edges('.edge-picked') - .toArray() - .map((edge: any) => ({ - id: edge.id(), - source: edge.source().data('label') || edge.data('source'), - target: edge.target().data('label') || edge.data('target'), - correlation: edge.data('correlation'), - direction: edge.data('directionLabel'), - })) - - onSelectedEdgesChange(picked) - } - - cy.on('tap', 'edge', (evt) => { - const edge = evt.target - - setContextMenu((prev) => ({ - ...prev, - visible: false, - })) - - if (edge.hasClass('edge-picked')) { - edge.removeClass('edge-picked') - } else { - edge.addClass('edge-picked') - } - - syncSelectedEdges() - }) - - cy.on('tap', 'node', () => { - setContextMenu((prev) => ({ - ...prev, - visible: false, - })) - }) - - cy.on('cxttap', 'node', (evt) => { - evt.originalEvent?.preventDefault() - - const node = evt.target - - setTooltip((prev) => ({ - ...prev, - visible: false, - })) - - setContextMenu({ - visible: true, - x: evt.renderedPosition?.x ?? 0, - y: evt.renderedPosition?.y ?? 0, - nodeId: node.id(), - nodeLabel: node.data('label') || node.id(), - threshold: defaultExpansionFilterRef.current.threshold, - traversalMode: defaultExpansionFilterRef.current.traversalMode, - maxLevels: defaultExpansionFilterRef.current.maxLevels, - }) - }) - - cy.on('mouseover', 'edge', (evt) => { - const edge = evt.target - const correlation = edge.data('correlation') - const direction = edge.data('directionLabel') - - setTooltip({ - visible: true, - x: evt.renderedPosition?.x ?? 0, - y: evt.renderedPosition?.y ?? 0, - content: `${direction} | correlation: ${correlation}`, - }) - }) - - cy.on('mousemove', 'edge', (evt) => { - setTooltip((prev) => ({ - ...prev, - x: evt.renderedPosition?.x ?? prev.x, - y: evt.renderedPosition?.y ?? prev.y, - visible: true, - })) - }) - - cy.on('mouseout', 'edge', () => { - setTooltip((prev) => ({ - ...prev, - visible: false, - })) - }) - - return () => { - cy.destroy() - cyRef.current = null - } - }, [data, elements, onSelectedEdgesChange]) - - useEffect(() => { - const cy = cyRef.current - - if (!cy) { return } - - cy.edges().removeClass('edge-picked') - - for (const edge of selectedEdges) { - const cyEdge = cy.getElementById(edge.id) - - if (cyEdge.nonempty()) { - cyEdge.addClass('edge-picked') - } - } - }, [selectedEdges]) - - /** - * Applies a centered zoom step from the overlay controls. - * @param direction Zoom direction requested by the user. - */ - const handleGraphZoom = (direction: 'in' | 'out') => { - const cy = cyRef.current - - if (!cy) { return } - - const nextZoom = direction === 'in' - ? Math.min(MAX_ZOOM, cy.zoom() * ZOOM_STEP) - : Math.max(MIN_ZOOM, cy.zoom() / ZOOM_STEP) - - cy.zoom({ - level: nextZoom, - renderedPosition: { - x: cy.width() / 2, - y: cy.height() / 2, - }, - }) - } - - return ( -
event.preventDefault()} - > -
- -
- - - -
- -
-
- Nodes: - - - - - -
- -
- Edges: - - -
-
- - {tooltip.visible && ( -
- {tooltip.content} -
- )} - - {contextMenu.visible && ( -
-
- {contextMenu.nodeLabel} -
- -
- Configure this expansion before adding it to the graph request. -
- -
-
-
- Threshold - {contextMenu.threshold.toFixed(1)} -
- setContextMenu((prev) => ({ - ...prev, - threshold: Number(Number(event.target.value).toFixed(1)), - }))} - /> -
- -
-
- Depth - {contextMenu.maxLevels} -
- setContextMenu((prev) => ({ - ...prev, - maxLevels: Number(event.target.value), - }))} - /> -
- -
- Regulation mode - -
-
- - -
- )} -
- ) -} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/LevelBadge.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/LevelBadge.tsx deleted file mode 100644 index 89861d57..00000000 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/LevelBadge.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react' - -const LEVEL_COLORS = { - level1: '#0ea5e9', - level2: '#22c55e', - level3: '#a855f7', - other: '#94a3b8', -} - -/** LevelBadge props. */ -interface LevelBadgeProps { - /** Depth level represented by the badge. */ - depth: number; - /** Whether the badge should use the incoming traversal style. */ - incoming?: boolean; -} - -/** - * Displays the visual badge used to identify a traversal depth level. - * @param props Component props. - * @returns The colored level badge rendered inline. - */ -export const LevelBadge = (props: LevelBadgeProps): JSX.Element => { - const { - depth, - incoming = false, - } = props - const color = - depth === 1 - ? LEVEL_COLORS.level1 - : depth === 2 - ? LEVEL_COLORS.level2 - : depth === 3 - ? LEVEL_COLORS.level3 - : LEVEL_COLORS.other - - return ( - - ) -} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/SelectedEdgesPanel.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/SelectedEdgesPanel.tsx deleted file mode 100644 index 652c51e6..00000000 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/SelectedEdgesPanel.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React from 'react' -import { Button, Icon } from 'semantic-ui-react' -import { SelectedEdgeInfo } from './types' - -/** SelectedEdgesPanel props. */ -interface SelectedEdgesPanelProps { - /** Edges currently selected in the graph. */ - selectedEdges: SelectedEdgeInfo[]; - /** Callback used to deselect a single edge. */ - onRemoveEdge: (edgeId: string) => void; - /** Callback used to clear the entire edge selection. */ - onClearAll: () => void; -} - -/** - * Lists the edges selected by the user and exposes quick cleanup actions. - * @param props Component props. - * @returns The selected edges panel rendered below the graph. - */ -export const SelectedEdgesPanel = (props: SelectedEdgesPanelProps): JSX.Element => { - const { - selectedEdges, - onRemoveEdge, - onClearAll, - } = props - return ( -
-
-
Selected edges
- - -
- - {selectedEdges.length === 0 - ? ( -
- Click multiple edges to inspect each correlation value. -
- ) - : ( -
- {selectedEdges.map((edge) => ( -
-
-
- {edge.source} -> {edge.target} -
-
Direction: {edge.direction}
-
Correlation: {edge.correlation}
-
- -
- ))} -
- )} -
- ) -} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/TraversalSummaryPanel.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/TraversalSummaryPanel.tsx deleted file mode 100644 index 3e99704e..00000000 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/TraversalSummaryPanel.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React from 'react' -import { LevelBadge } from './LevelBadge' -import { DepthSummaryItem, TraversalMode } from './types' - -/** TraversalSummaryPanel props. */ -interface TraversalSummaryPanelProps { - /** Active traversal mode applied to the root filter. */ - traversalMode: TraversalMode; - /** Visible label of the current root node. */ - selectedRootNode: string | null; - /** Maximum depth configured for the root filter. */ - maxLevels: number; - /** Outgoing traversal summary grouped by level. */ - depthSummary: DepthSummaryItem[]; - /** Incoming traversal summary grouped by level. */ - incomingSummary: DepthSummaryItem[]; -} - -/** - * Summarizes the traversal results for the active root graph filter. - * @param props Component props. - * @returns The traversal summary panel rendered below the graph. - */ -export const TraversalSummaryPanel = (props: TraversalSummaryPanelProps): JSX.Element => { - const { - traversalMode, - selectedRootNode, - maxLevels, - depthSummary, - incomingSummary, - } = props - return ( -
-
- {traversalMode === 'outgoing' - ? 'Depth levels' - : traversalMode === 'incoming' - ? 'Upstream regulation levels' - : 'Relationship levels'} -
- - {!selectedRootNode - ? ( -
- Select a node to calculate levels. -
- ) - : ( -
-
- Root node: {selectedRootNode} -
- -
- Search limit: {maxLevels} level{maxLevels > 1 ? 's' : ''} -
- - {(traversalMode === 'outgoing' || traversalMode === 'both') && ( -
-
- Regulates -
- - {depthSummary.length === 0 - ? ( -
- No visible regulated nodes match the current filter. -
- ) - : ( - depthSummary.map((item) => ( -
-
- - Level {item.depth} -
-
{item.nodes.join(', ')}
-
- )) - )} -
- )} - - {(traversalMode === 'incoming' || traversalMode === 'both') && ( -
-
- Regulated by -
- - {incomingSummary.length === 0 - ? ( -
- No visible regulators match the current filter. -
- ) - : ( - incomingSummary.map((item) => ( -
-
- - Level {item.depth} -
-
{item.nodes.join(', ')}
-
- )) - )} -
- )} -
- )} -
- ) -} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphApi.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphApi.ts deleted file mode 100644 index 94bcbae5..00000000 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphApi.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { MOCK_EDGES, MOCK_EXPANSION_EDGES_BY_ROOT, MOCK_NODES } from './mockNetworkData' -import { - DepthSummaryItem, - FetchGeneRegulationGraphParams, - FetchGeneRegulationGraphResponse, - GraphEdge, - GraphQueryFilter, -} from './types' - -const FALLBACK_FILTER: GraphQueryFilter = { - rootNodeId: 'gene_braf', - threshold: 0.5, - traversalMode: 'both', - maxLevels: 3, -} - -type TraversalWalkResult = { - visitedNodes: Map; - includedEdgeIds: Set; - summary: DepthSummaryItem[]; -} - -/** - * Simulates the latency of the future backend integration. - * @param ms Milliseconds to wait before resolving the mock request. - * @returns A promise resolved after the requested delay. - */ -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -/** - * Validates whether an edge should remain visible for the current threshold. - * @param correlation Correlation value stored in the graph edge. - * @param threshold Threshold required for the edge to stay visible. - * @returns Whether the edge passes the active threshold. - */ -const passesThreshold = (correlation: number, threshold: number) => - Math.abs(correlation) >= threshold - -/** - * Resolves the label to display for a node identifier. - * @param nodeId Graph node identifier. - * @returns The visible label associated with the node. - */ -const getNodeLabel = (nodeId: string) => - MOCK_NODES.find((node) => node.id === nodeId)?.label ?? nodeId - -/** - * Returns the base graph plus the mock branch associated with a root node expansion. - * @param rootNodeId Root node identifier used for the expansion. - * @returns The list of edges available for that root node. - */ -const getEdgesForFilter = (rootNodeId: string) => [ - ...MOCK_EDGES, - ...(MOCK_EXPANSION_EDGES_BY_ROOT[rootNodeId] ?? []), -] - -/** - * Converts the raw traversal map into a sorted depth summary structure. - * @param map Traversal map keyed by depth. - * @returns The normalized list of depth summary items. - */ -const buildSummary = (map: Map) => - Array.from(map.entries()) - .sort((a, b) => a[0] - b[0]) - .map(([depth, nodes]): DepthSummaryItem => ({ - depth, - nodes: [...nodes].sort((a, b) => a.localeCompare(b)), - })) - -/** - * Merges summaries coming from all active root filters. - * @param summaries Summary lists produced for each active root filter. - * @returns A merged summary grouped by depth. - */ -const mergeSummaries = (summaries: DepthSummaryItem[][]) => { - const summaryMap = new Map>() - - for (const summary of summaries) { - for (const item of summary) { - const nodes = summaryMap.get(item.depth) ?? new Set() - - for (const node of item.nodes) { - nodes.add(node) - } - - summaryMap.set(item.depth, nodes) - } - } - - return Array.from(summaryMap.entries()) - .sort((a, b) => a[0] - b[0]) - .map(([depth, nodes]): DepthSummaryItem => ({ - depth, - nodes: Array.from(nodes).sort((a, b) => a.localeCompare(b)), - })) -} - -/** - * Traverses outward edges from a root node respecting the selected depth limit. - * @param rootNodeId Root node identifier used as traversal origin. - * @param edges Edges currently visible for the filter. - * @param maxLevels Maximum number of depth levels to walk. - * @returns The visited nodes, kept edges and outgoing level summary. - */ -const walkOutgoing = (rootNodeId: string, edges: GraphEdge[], maxLevels: number): TraversalWalkResult => { - const visitedNodes = new Map() - const includedEdgeIds = new Set() - const summaryMap = new Map() - - const queue: Array<{ nodeId: string; depth: number }> = [{ nodeId: rootNodeId, depth: 0 }] - - visitedNodes.set(rootNodeId, 0) - - while (queue.length > 0) { - const current = queue.shift() - - if (!current) { continue } - - const { nodeId, depth } = current - - if (depth >= maxLevels) { continue } - - const outgoingEdges = edges.filter((edge) => edge.source === nodeId) - - for (const edge of outgoingEdges) { - const nextDepth = depth + 1 - - if (nextDepth > maxLevels) { continue } - - includedEdgeIds.add(edge.id) - - if (!visitedNodes.has(edge.target) || nextDepth < visitedNodes.get(edge.target)!) { - visitedNodes.set(edge.target, nextDepth) - - const targetLabel = getNodeLabel(edge.target) - const arr = summaryMap.get(nextDepth) || [] - - if (!arr.includes(targetLabel)) { - arr.push(targetLabel) - summaryMap.set(nextDepth, arr) - } - - queue.push({ nodeId: edge.target, depth: nextDepth }) - } - } - } - - return { - visitedNodes, - includedEdgeIds, - summary: buildSummary(summaryMap), - } -} - -/** - * Traverses inward edges from a root node respecting the selected depth limit. - * @param rootNodeId Root node identifier used as traversal origin. - * @param edges Edges currently visible for the filter. - * @param maxLevels Maximum number of depth levels to walk. - * @returns The visited nodes, kept edges and incoming level summary. - */ -const walkIncoming = (rootNodeId: string, edges: GraphEdge[], maxLevels: number): TraversalWalkResult => { - const visitedNodes = new Map() - const includedEdgeIds = new Set() - const summaryMap = new Map() - - const queue: Array<{ nodeId: string; depth: number }> = [{ nodeId: rootNodeId, depth: 0 }] - - visitedNodes.set(rootNodeId, 0) - - while (queue.length > 0) { - const current = queue.shift() - - if (!current) { continue } - - const { nodeId, depth } = current - - if (depth >= maxLevels) { continue } - - const incomingEdges = edges.filter((edge) => edge.target === nodeId) - - for (const edge of incomingEdges) { - const nextDepth = depth + 1 - - if (nextDepth > maxLevels) { continue } - - includedEdgeIds.add(edge.id) - - if (!visitedNodes.has(edge.source) || nextDepth < visitedNodes.get(edge.source)!) { - visitedNodes.set(edge.source, nextDepth) - - const sourceLabel = getNodeLabel(edge.source) - const arr = summaryMap.get(nextDepth) || [] - - if (!arr.includes(sourceLabel)) { - arr.push(sourceLabel) - summaryMap.set(nextDepth, arr) - } - - queue.push({ nodeId: edge.source, depth: nextDepth }) - } - } - } - - return { - visitedNodes, - includedEdgeIds, - summary: buildSummary(summaryMap), - } -} - -/** - * Builds the partial graph response for a single root filter. - * @param filter Active filter to resolve for one root node. - * @returns The partial graph payload generated for that filter. - */ -const collectFilterResponse = (filter: GraphQueryFilter): FetchGeneRegulationGraphResponse => { - const thresholdEdges = getEdgesForFilter(filter.rootNodeId).filter((edge) => - passesThreshold(edge.correlation, filter.threshold) - ) - - const outgoing = filter.traversalMode === 'outgoing' || filter.traversalMode === 'both' - ? walkOutgoing(filter.rootNodeId, thresholdEdges, filter.maxLevels) - : { - visitedNodes: new Map(), - includedEdgeIds: new Set(), - summary: [] as DepthSummaryItem[], - } - - const incoming = filter.traversalMode === 'incoming' || filter.traversalMode === 'both' - ? walkIncoming(filter.rootNodeId, thresholdEdges, filter.maxLevels) - : { - visitedNodes: new Map(), - includedEdgeIds: new Set(), - summary: [] as DepthSummaryItem[], - } - - const includedEdgeIds = new Set([ - ...Array.from(outgoing.includedEdgeIds), - ...Array.from(incoming.includedEdgeIds), - ]) - - const edges = thresholdEdges.filter((edge) => includedEdgeIds.has(edge.id)) - const includedNodeIdsFromEdges = new Set() - - for (const edge of edges) { - includedNodeIdsFromEdges.add(edge.source) - includedNodeIdsFromEdges.add(edge.target) - } - - includedNodeIdsFromEdges.add(filter.rootNodeId) - - const nodes = MOCK_NODES.filter((node) => includedNodeIdsFromEdges.has(node.id)) - const validLabels = new Set(nodes.map((node) => node.label)) - - const outgoingSummary = outgoing.summary - .map((item) => ({ - depth: item.depth, - nodes: item.nodes.filter((label) => validLabels.has(label)), - })) - .filter((item) => item.nodes.length > 0) - - const incomingSummary = incoming.summary - .map((item) => ({ - depth: item.depth, - nodes: item.nodes.filter((label) => validLabels.has(label)), - })) - .filter((item) => item.nodes.length > 0) - - return { - rootNodeId: filter.rootNodeId, - nodes, - edges, - outgoingSummary, - incomingSummary, - filters: [filter], - } -} - -/** - * Combines all active filter responses into a single graph payload for the UI. - * @param filters Active filters currently applied to the graph. - * @returns The merged graph payload rendered by the panel. - */ -const collectResponse = (filters: GraphQueryFilter[]): FetchGeneRegulationGraphResponse => { - const safeFilters = filters.length > 0 ? filters : [FALLBACK_FILTER] - const responses = safeFilters.map(collectFilterResponse) - const nodeIds = new Set() - const edgesById = new Map() - - for (const response of responses) { - for (const node of response.nodes) { - nodeIds.add(node.id) - } - - for (const edge of response.edges) { - edgesById.set(edge.id, edge) - } - } - - return { - rootNodeId: safeFilters[0].rootNodeId, - nodes: MOCK_NODES.filter((node) => nodeIds.has(node.id)), - edges: Array.from(edgesById.values()), - outgoingSummary: mergeSummaries(responses.map((response) => response.outgoingSummary)), - incomingSummary: mergeSummaries(responses.map((response) => response.incomingSummary)), - filters: safeFilters, - } -} - -/** - * Resolves the mock graph request used by the gene regulation associations tab. - * @param params Active graph filters that would later be forwarded to the backend. - * @returns A promise with the merged graph payload for all active filters. - */ -export const fetchGeneRegulationGraph = ( - params: FetchGeneRegulationGraphParams -): Promise => - sleep(350).then(() => collectResponse(params.filters)) diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphStyle.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphStyle.ts deleted file mode 100644 index 49794222..00000000 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphStyle.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NodeKind } from './types' - -/** Shared node colors used by the graph and legend. */ -export const NODE_COLORS: Record = { - Gene: '#4f46e5', - miRNA: '#db2777', - CNA: '#f59e0b', - Methylation: '#10b981', - Drug: '#64748b', -} - -/** Shared edge colors used to indicate regulation direction. */ -export const REGULATION_COLORS = { - down: '#dc2626', - up: '#2563eb', -} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/legend.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/legend.tsx deleted file mode 100644 index de4a61de..00000000 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/legend.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react' - -/** LegendDotProps props. */ -type LegendDotProps = { - /** Color shown in the legend swatch. */ - color: string; - /** Visible legend label. */ - label: string; -} - -/** - * Renders a colored dot item used by the graph legend. - * @param props Component props. - * @returns The node legend item. - */ -export const LegendDot = (props: LegendDotProps): JSX.Element => { - const { color, label } = props - - return ( - - - {label} - - ) -} - -/** - * Renders an arrow item used by the graph edge legend. - * @param props Component props. - * @returns The edge legend item. - */ -export const LegendArrow = (props: LegendDotProps): JSX.Element => { - const { color, label } = props - - return ( - - - - - {label} - - ) -} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/mockNetworkData.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/mockNetworkData.ts deleted file mode 100644 index fb4f50ae..00000000 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/mockNetworkData.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { GraphEdge, GraphNode } from './types' - -/** Base nodes and expansion-only nodes used by the mock graph API. */ -export const MOCK_NODES: GraphNode[] = [ - { id: 'gene_braf', label: 'BRAF', type: 'Gene', size: 66 }, - { id: 'gene_mek1', label: 'MEK1', type: 'Gene', size: 46 }, - { id: 'gene_mek2', label: 'MEK2', type: 'Gene', size: 44 }, - { id: 'gene_erk1', label: 'ERK1', type: 'Gene', size: 42 }, - { id: 'gene_erk2', label: 'ERK2', type: 'Gene', size: 42 }, - { id: 'gene_myc', label: 'MYC', type: 'Gene', size: 38 }, - { id: 'gene_fos', label: 'FOS', type: 'Gene', size: 36 }, - { id: 'gene_ccnd1', label: 'CCND1', type: 'Gene', size: 36 }, - { id: 'gene_elk1', label: 'ELK1', type: 'Gene', size: 34 }, - { id: 'gene_dusp6', label: 'DUSP6', type: 'Gene', size: 34 }, - { id: 'gene_spry2', label: 'SPRY2', type: 'Gene', size: 32 }, - { id: 'gene_map3k8', label: 'MAP3K8', type: 'Gene', size: 32 }, - { id: 'mir_17', label: 'miR-17', type: 'miRNA', size: 28 }, - { id: 'mir_21', label: 'miR-21', type: 'miRNA', size: 28 }, - { id: 'cna_7q34', label: '7q34 gain', type: 'CNA', size: 34 }, - { id: 'meth_rassf1', label: 'RASSF1 meth', type: 'Methylation', size: 34 }, - { id: 'drug_vemurafenib', label: 'Vemurafenib', type: 'Drug', size: 36 }, - { id: 'gene_akt1', label: 'AKT1', type: 'Gene', size: 32 }, - { id: 'gene_grb2', label: 'GRB2', type: 'Gene', size: 30 }, - { id: 'gene_raf1', label: 'RAF1', type: 'Gene', size: 32 }, - { id: 'drug_trametinib', label: 'Trametinib', type: 'Drug', size: 34 }, - { id: 'gene_mapkapk2', label: 'MAPKAPK2', type: 'Gene', size: 30 }, - { id: 'gene_rps6ka1', label: 'RPS6KA1', type: 'Gene', size: 30 }, - { id: 'gene_dusp4', label: 'DUSP4', type: 'Gene', size: 30 }, - { id: 'gene_jun', label: 'JUN', type: 'Gene', size: 32 }, - { id: 'gene_etv4', label: 'ETV4', type: 'Gene', size: 30 }, - { id: 'gene_dusp5', label: 'DUSP5', type: 'Gene', size: 30 }, - { id: 'gene_rps6ka3', label: 'RPS6KA3', type: 'Gene', size: 30 }, - { id: 'gene_elk4', label: 'ELK4', type: 'Gene', size: 30 }, - { id: 'gene_ets1', label: 'ETS1', type: 'Gene', size: 30 }, - { id: 'gene_cdk4', label: 'CDK4', type: 'Gene', size: 30 }, - { id: 'gene_tert', label: 'TERT', type: 'Gene', size: 30 }, - { id: 'gene_max', label: 'MAX', type: 'Gene', size: 30 }, - { id: 'mir_34a', label: 'miR-34a', type: 'miRNA', size: 28 }, - { id: 'gene_junb', label: 'JUNB', type: 'Gene', size: 30 }, - { id: 'gene_atf3', label: 'ATF3', type: 'Gene', size: 30 }, - { id: 'gene_mmp9', label: 'MMP9', type: 'Gene', size: 30 }, - { id: 'gene_cdk6', label: 'CDK6', type: 'Gene', size: 30 }, - { id: 'gene_rb1', label: 'RB1', type: 'Gene', size: 30 }, - { id: 'drug_palbociclib', label: 'Palbociclib', type: 'Drug', size: 34 }, - { id: 'gene_egr1', label: 'EGR1', type: 'Gene', size: 30 }, - { id: 'gene_srf', label: 'SRF', type: 'Gene', size: 30 }, - { id: 'gene_fgfr1', label: 'FGFR1', type: 'Gene', size: 30 }, - { id: 'gene_spry4', label: 'SPRY4', type: 'Gene', size: 30 }, - { id: 'gene_egfr', label: 'EGFR', type: 'Gene', size: 32 }, - { id: 'gene_fgfr2', label: 'FGFR2', type: 'Gene', size: 30 }, - { id: 'gene_nfkb1', label: 'NFKB1', type: 'Gene', size: 30 }, - { id: 'gene_rela', label: 'RELA', type: 'Gene', size: 30 }, - { id: 'gene_pten', label: 'PTEN', type: 'Gene', size: 32 }, - { id: 'gene_e2f1', label: 'E2F1', type: 'Gene', size: 30 }, - { id: 'gene_bcl2l11', label: 'BCL2L11', type: 'Gene', size: 30 }, - { id: 'gene_pdcd4', label: 'PDCD4', type: 'Gene', size: 30 }, - { id: 'gene_tpm1', label: 'TPM1', type: 'Gene', size: 30 }, - { id: 'gene_braf_fusion', label: 'BRAF fusion', type: 'Gene', size: 32 }, - { id: 'gene_kiaa1549', label: 'KIAA1549', type: 'Gene', size: 30 }, - { id: 'gene_rassf1', label: 'RASSF1', type: 'Gene', size: 30 }, - { id: 'gene_cdkn2a', label: 'CDKN2A', type: 'Gene', size: 30 }, - { id: 'gene_axl', label: 'AXL', type: 'Gene', size: 30 }, - { id: 'gene_dusp1', label: 'DUSP1', type: 'Gene', size: 30 }, -] - -/** Base relationships rendered by the initial BRAF graph query. */ -export const MOCK_EDGES: GraphEdge[] = [ - { id: 'e_1', source: 'gene_braf', target: 'gene_mek1', correlation: 0.92 }, - { id: 'e_2', source: 'gene_braf', target: 'gene_mek2', correlation: 0.83 }, - { id: 'e_3', source: 'gene_braf', target: 'gene_erk1', correlation: 0.78 }, - { id: 'e_4', source: 'gene_braf', target: 'gene_dusp6', correlation: 0.72 }, - { id: 'e_5', source: 'gene_braf', target: 'gene_fos', correlation: 0.67 }, - - { id: 'e_6', source: 'gene_mek1', target: 'gene_erk1', correlation: 0.88 }, - { id: 'e_7', source: 'gene_mek1', target: 'gene_erk2', correlation: 0.79 }, - { id: 'e_8', source: 'gene_mek1', target: 'gene_elk1', correlation: 0.76 }, - { id: 'e_9', source: 'gene_mek2', target: 'gene_erk2', correlation: 0.81 }, - - { id: 'e_10', source: 'gene_erk1', target: 'gene_myc', correlation: 0.81 }, - { id: 'e_11', source: 'gene_erk1', target: 'gene_fos', correlation: 0.79 }, - { id: 'e_12', source: 'gene_erk2', target: 'gene_ccnd1', correlation: 0.68 }, - { id: 'e_13', source: 'gene_elk1', target: 'gene_myc', correlation: 0.61 }, - { id: 'e_14', source: 'gene_fos', target: 'gene_ccnd1', correlation: 0.58 }, - - { id: 'e_15', source: 'mir_17', target: 'gene_braf', correlation: -0.66 }, - { id: 'e_16', source: 'mir_21', target: 'gene_mek1', correlation: -0.57 }, - { id: 'e_17', source: 'cna_7q34', target: 'gene_braf', correlation: 0.73 }, - { id: 'e_18', source: 'meth_rassf1', target: 'gene_braf', correlation: -0.54 }, - { id: 'e_19', source: 'drug_vemurafenib', target: 'gene_braf', correlation: -0.89 }, - - { id: 'e_20', source: 'gene_map3k8', target: 'gene_mek1', correlation: 0.55 }, - { id: 'e_21', source: 'gene_braf', target: 'gene_map3k8', correlation: 0.52 }, - - { id: 'e_22', source: 'gene_spry2', target: 'gene_braf', correlation: -0.32 }, - { id: 'e_23', source: 'drug_vemurafenib', target: 'gene_mek1', correlation: -0.41 }, -] - -/** Expansion branches available when the user right-clicks a visible root node. */ -export const MOCK_EXPANSION_EDGES_BY_ROOT: Record = { - gene_mek1: [ - { id: 'e_exp_mek1_1', source: 'gene_raf1', target: 'gene_mek1', correlation: 0.86 }, - { id: 'e_exp_mek1_2', source: 'gene_grb2', target: 'gene_mek1', correlation: 0.62 }, - { id: 'e_exp_mek1_3', source: 'gene_mek1', target: 'gene_akt1', correlation: 0.74 }, - { id: 'e_exp_mek1_4', source: 'drug_trametinib', target: 'gene_mek1', correlation: -0.91 }, - { id: 'e_exp_mek1_5', source: 'gene_akt1', target: 'gene_myc', correlation: 0.58 }, - ], - gene_mek2: [ - { id: 'e_exp_mek2_1', source: 'gene_mek2', target: 'gene_mapkapk2', correlation: 0.73 }, - { id: 'e_exp_mek2_2', source: 'gene_mek2', target: 'gene_rps6ka1', correlation: 0.69 }, - { id: 'e_exp_mek2_3', source: 'gene_dusp4', target: 'gene_mek2', correlation: -0.61 }, - ], - gene_erk1: [ - { id: 'e_exp_erk1_1', source: 'gene_erk1', target: 'gene_jun', correlation: 0.84 }, - { id: 'e_exp_erk1_2', source: 'gene_erk1', target: 'gene_etv4', correlation: 0.78 }, - { id: 'e_exp_erk1_3', source: 'gene_dusp5', target: 'gene_erk1', correlation: -0.71 }, - { id: 'e_exp_erk1_4', source: 'gene_jun', target: 'gene_fos', correlation: 0.65 }, - ], - gene_erk2: [ - { id: 'e_exp_erk2_1', source: 'gene_erk2', target: 'gene_rps6ka3', correlation: 0.82 }, - { id: 'e_exp_erk2_2', source: 'gene_erk2', target: 'gene_elk4', correlation: 0.73 }, - { id: 'e_exp_erk2_3', source: 'gene_ets1', target: 'gene_erk2', correlation: 0.59 }, - ], - gene_myc: [ - { id: 'e_exp_myc_1', source: 'gene_myc', target: 'gene_cdk4', correlation: 0.82 }, - { id: 'e_exp_myc_2', source: 'gene_myc', target: 'gene_tert', correlation: 0.76 }, - { id: 'e_exp_myc_3', source: 'gene_max', target: 'gene_myc', correlation: 0.88 }, - { id: 'e_exp_myc_4', source: 'mir_34a', target: 'gene_myc', correlation: -0.79 }, - ], - gene_fos: [ - { id: 'e_exp_fos_1', source: 'gene_fos', target: 'gene_junb', correlation: 0.84 }, - { id: 'e_exp_fos_2', source: 'gene_fos', target: 'gene_atf3', correlation: 0.76 }, - { id: 'e_exp_fos_3', source: 'gene_junb', target: 'gene_mmp9', correlation: 0.71 }, - { id: 'e_exp_fos_4', source: 'mir_21', target: 'gene_fos', correlation: -0.62 }, - ], - gene_ccnd1: [ - { id: 'e_exp_ccnd1_1', source: 'gene_ccnd1', target: 'gene_cdk6', correlation: 0.81 }, - { id: 'e_exp_ccnd1_2', source: 'gene_ccnd1', target: 'gene_rb1', correlation: -0.64 }, - { id: 'e_exp_ccnd1_3', source: 'drug_palbociclib', target: 'gene_ccnd1', correlation: -0.88 }, - ], - gene_elk1: [ - { id: 'e_exp_elk1_1', source: 'gene_elk1', target: 'gene_egr1', correlation: 0.79 }, - { id: 'e_exp_elk1_2', source: 'gene_srf', target: 'gene_elk1', correlation: 0.61 }, - ], - gene_dusp6: [ - { id: 'e_exp_dusp6_1', source: 'gene_dusp6', target: 'gene_fgfr1', correlation: -0.75 }, - { id: 'e_exp_dusp6_2', source: 'gene_dusp6', target: 'gene_spry4', correlation: 0.66 }, - ], - gene_spry2: [ - { id: 'e_exp_spry2_1', source: 'gene_spry2', target: 'gene_egfr', correlation: -0.72 }, - { id: 'e_exp_spry2_2', source: 'gene_fgfr2', target: 'gene_spry2', correlation: 0.69 }, - ], - gene_map3k8: [ - { id: 'e_exp_map3k8_1', source: 'gene_map3k8', target: 'gene_nfkb1', correlation: 0.8 }, - { id: 'e_exp_map3k8_2', source: 'gene_map3k8', target: 'gene_rela', correlation: 0.73 }, - ], - mir_17: [ - { id: 'e_exp_mir17_1', source: 'mir_17', target: 'gene_pten', correlation: -0.82 }, - { id: 'e_exp_mir17_2', source: 'mir_17', target: 'gene_e2f1', correlation: -0.69 }, - { id: 'e_exp_mir17_3', source: 'gene_e2f1', target: 'gene_bcl2l11', correlation: 0.64 }, - ], - mir_21: [ - { id: 'e_exp_mir21_1', source: 'mir_21', target: 'gene_pdcd4', correlation: -0.86 }, - { id: 'e_exp_mir21_2', source: 'mir_21', target: 'gene_pten', correlation: -0.72 }, - { id: 'e_exp_mir21_3', source: 'mir_21', target: 'gene_tpm1', correlation: -0.61 }, - ], - cna_7q34: [ - { id: 'e_exp_cna7q34_1', source: 'cna_7q34', target: 'gene_braf_fusion', correlation: 0.93 }, - { id: 'e_exp_cna7q34_2', source: 'gene_kiaa1549', target: 'gene_braf_fusion', correlation: 0.77 }, - { id: 'e_exp_cna7q34_3', source: 'gene_braf_fusion', target: 'gene_braf', correlation: 0.84 }, - ], - meth_rassf1: [ - { id: 'e_exp_meth_rassf1_1', source: 'meth_rassf1', target: 'gene_rassf1', correlation: -0.88 }, - { id: 'e_exp_meth_rassf1_2', source: 'gene_rassf1', target: 'gene_cdkn2a', correlation: 0.62 }, - ], - drug_vemurafenib: [ - { id: 'e_exp_vemurafenib_1', source: 'drug_vemurafenib', target: 'gene_axl', correlation: 0.72 }, - { id: 'e_exp_vemurafenib_2', source: 'drug_vemurafenib', target: 'gene_egfr', correlation: 0.68 }, - { id: 'e_exp_vemurafenib_3', source: 'gene_dusp1', target: 'gene_braf', correlation: -0.55 }, - ], -} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/types.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/types.ts deleted file mode 100644 index 2340bf0f..00000000 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/types.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** Supported node categories shown in the regulation graph. */ -export type NodeKind = 'Gene' | 'miRNA' | 'CNA' | 'Methylation' | 'Drug' - -/** Traversal direction used when exploring associations from a root node. */ -export type TraversalMode = 'outgoing' | 'incoming' | 'both' - -/** Graph node rendered by Cytoscape. */ -export type GraphNode = { - /** Stable node identifier used by Cytoscape and the mock API. */ - id: string; - /** Human-readable node label shown in the graph and side panels. */ - label: string; - /** Biological or domain category used to color the node. */ - type: NodeKind; - /** Visual size used by the graph layout. */ - size: number; -} - -/** Graph edge rendered by Cytoscape. */ -export type GraphEdge = { - /** Stable edge identifier used to keep selections in sync. */ - id: string; - /** Source node identifier. */ - source: string; - /** Target node identifier. */ - target: string; - /** Correlation score used to infer direction color and visibility. */ - correlation: number; -} - -/** Filter payload used to query one graph root and its expansion settings. */ -export type GraphQueryFilter = { - /** Root node from which the traversal starts. */ - rootNodeId: string; - /** Minimum absolute correlation required for edges to stay visible. */ - threshold: number; - /** Which direction should be traversed from the root node. */ - traversalMode: TraversalMode; - /** Maximum number of levels to traverse from the root node. */ - maxLevels: number; -} - -/** Edge selection details rendered in the side panel after user interaction. */ -export type SelectedEdgeInfo = { - /** Stable edge identifier. */ - id: string; - /** Visible label of the source node. */ - source: string; - /** Visible label of the target node. */ - target: string; - /** Correlation associated with the selected edge. */ - correlation: number; - /** Regulation label derived from the correlation sign. */ - direction: 'Down-regulation' | 'Up-regulation'; -} - -/** Group of nodes found at the same traversal level. */ -export type DepthSummaryItem = { - /** Traversal level relative to the selected root. */ - depth: number; - /** Visible labels of the nodes found at that level. */ - nodes: string[]; -} - -/** Request payload sent by the panel when asking for graph data. */ -export type FetchGeneRegulationGraphParams = { - /** Active root filters, including user-created expansions. */ - filters: GraphQueryFilter[]; -} - -/** Response payload consumed by the regulation graph UI. */ -export type FetchGeneRegulationGraphResponse = { - /** Root node of the primary filter used to center the graph. */ - rootNodeId: string; - /** Nodes included after applying the active filters. */ - nodes: GraphNode[]; - /** Edges included after applying the active filters. */ - edges: GraphEdge[]; - /** Outgoing traversal summary for the current filter set. */ - outgoingSummary: DepthSummaryItem[]; - /** Incoming traversal summary for the current filter set. */ - incomingSummary: DepthSummaryItem[]; - /** Filters effectively used to generate the response. */ - filters: GraphQueryFilter[]; -} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/useGeneRegulationGraphQuery.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/useGeneRegulationGraphQuery.ts deleted file mode 100644 index 3678492d..00000000 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/useGeneRegulationGraphQuery.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useEffect, useState } from 'react' -import { fetchGeneRegulationGraph } from './graphApi' -import { FetchGeneRegulationGraphParams, FetchGeneRegulationGraphResponse } from './types' - -/** State handled by the gene regulation graph query hook. */ -type GeneRegulationGraphQueryState = { - /** Resolved graph payload returned by the mock API. */ - data: FetchGeneRegulationGraphResponse | null; - /** Indicates whether the query is currently in flight. */ - loading: boolean; - /** Error message displayed by the panel when the query fails. */ - error: string | null; -} - -/** - * Fetches graph data for the currently applied list of filters. - * @param params Request payload with all active graph filters. - * @returns Loading, error, and graph data state for the panel. - */ -export const useGeneRegulationGraphQuery = (params: FetchGeneRegulationGraphParams) => { - const [state, setState] = useState({ - data: null, - loading: true, - error: null, - }) - - useEffect(() => { - let cancelled = false - - setState({ - data: null, - loading: true, - error: null, - }) - - fetchGeneRegulationGraph(params) - .then((data) => { - if (cancelled) { return } - - setState({ - data, - loading: false, - error: null, - }) - }) - .catch((error) => { - if (cancelled) { return } - - setState({ - data: null, - loading: false, - error: error instanceof Error ? error.message : 'Unknown error', - }) - }) - - return () => { - cancelled = true - } - }, [params]) - - return state -} diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/stats/DensityChart.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/stats/DensityChart.tsx index a9bfab68..bafd1c91 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/stats/DensityChart.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/stats/DensityChart.tsx @@ -13,9 +13,10 @@ import { BarChart } from 'recharts' -/** DensityChart props. */ +/** + * Component's props + */ interface DensityChartProps { - /** Data series used to build the density chart. */ dataObjects: StatChartData[], /** True to show bars in density chart */ showBars: boolean, @@ -55,26 +56,11 @@ type ChartData = { /** Data for a Bar component. */ type BarData = { - /** Stroke color used to render the chart outline. */ strokeColor: string | undefined, - /** Fill color used to render the chart bars. */ fillColor: string | undefined, - /** Bars rendered for the current chart. */ data: ChartData[] } -/** CustomTooltip props. */ -interface CustomTooltipProps { - /** True when the tooltip is active. */ - active?: boolean, - /** Tooltip payload provided by Recharts. */ - payload?: any[], - /** Primary color used for the tooltip title. */ - color: string | undefined, - /** Secondary color used when rendering the comparison series. */ - color2: string | null | undefined -} - /** * Renders a Density chart * @param props Component's props @@ -125,14 +111,14 @@ export const DensityChart = (props: DensityChartProps) => { /** * Renders a custom tooltip for Density chart - * @param props Props of tooltip. - * @param props.active True when the tooltip is active. - * @param props.payload Tooltip payload provided by Recharts. - * @param props.color Primary color used for the tooltip title. - * @param props.color2 Secondary color used when rendering the comparison series. + * @param props Props of tooltip + * @param props.active if component is active + * @param props.payload data for tooltip + * @param props.color color for tooltip title + * @param props.color2 color for tooltip title * @returns Component */ -const CustomTooltip = ({ active, payload, color, color2 }: CustomTooltipProps) => { +const CustomTooltip = ({ active, payload, color, color2 }: any) => { const intl = useIntl() if (active && payload && payload.length) { @@ -160,9 +146,10 @@ const CustomTooltip = ({ active, payload, color, color2 }: CustomTooltipProps) = } } -/** DensityChartMix props. */ +/** + * Component's props + */ interface DensityChartMixProps { - /** Data series used to build the mixed density chart. */ dataObjects: StatChartData[], /** True to show bars in density chart */ showBars: boolean, From fd241693d43d0b464614cd3d15f282ec64ca0e9c Mon Sep 17 00:00:00 2001 From: Juan Nicolas Herrera <54152074+juanNH@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:35:30 -0300 Subject: [PATCH 16/17] feat: add miRTarBase validation panel --- .../gene-gem-details/GeneGemDetailsModal.tsx | 4 + .../gene-gem-details/GeneGemModalMenu.tsx | 17 +++++ .../MiRNATargetValidationPanel.tsx | 73 +++++++++++++++++++ .../static/frontend/src/locales/en.ts | 8 ++ .../static/frontend/src/locales/es.ts | 8 ++ 5 files changed, 110 insertions(+) create mode 100644 src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/MiRNATargetValidationPanel.tsx diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/GeneGemDetailsModal.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/GeneGemDetailsModal.tsx index 0cb9b90f..5bec065d 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/GeneGemDetailsModal.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/GeneGemDetailsModal.tsx @@ -16,6 +16,7 @@ import { GeneGemModalMenu } from './GeneGemModalMenu' import { NoClinicalData } from './survival-analysis/NoClinicalData' import { KaplanMeierChart } from './survival-analysis/KaplanMeier' import { MiRNATargetInteractionPanel } from './MiRNATargetInteractionPanel' +import { MiRNATargetValidationPanel } from './MiRNATargetValidationPanel' import { TryAgainSegment } from '../../../common/TryAgainSegment' import { COLOR_YELLOW_FILL, COLOR_YELLOW_STROKE } from '../../../../utils/constants' import { useIntl, IntlShape } from 'react-intl' @@ -38,6 +39,7 @@ enum ActiveItemMenu { STATISTICAL_PROPERTIES, ASSUMPTIONS, MIRNA_TARGET_INTERACTION, + MIRNA_TARGET_VALIDATION, MIRNA_INTERACTION, DISEASES_ASSOCIATION, DRUGS_ASSOCIATION, @@ -245,6 +247,8 @@ class GeneGemDetailsModal extends React.Component ) + case ActiveItemMenu.MIRNA_TARGET_VALIDATION: + return case ActiveItemMenu.MIRNA_INTERACTION: // NOTE: it's inside a React.Fragment to force componentDidMount execution // in PaginatedTable component. Otherwise, React will see miRNA interaction diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/GeneGemModalMenu.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/GeneGemModalMenu.tsx index 9b94c6f6..61cc8b87 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/GeneGemModalMenu.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/GeneGemModalMenu.tsx @@ -91,6 +91,23 @@ export const GeneGemModalMenu = (props: GeneGemModalMenuProps) => { /> + props.setActiveItem(ActiveItemMenu.MIRNA_TARGET_VALIDATION)} + > + {intl.formatMessage({ id: 'geneGemModalMenu.mirnaTargetValidation' })} + + + + props.setActiveItem(ActiveItemMenu.MIRNA_INTERACTION)} diff --git a/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/MiRNATargetValidationPanel.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/MiRNATargetValidationPanel.tsx new file mode 100644 index 00000000..c52a88e9 --- /dev/null +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/MiRNATargetValidationPanel.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import { Table } from 'semantic-ui-react' +import { RowHeader } from '../../../../utils/django_interfaces' +import { PaginatedTable } from '../../../common/PaginatedTable' +import { useIntl } from 'react-intl' +import { PubmedButton } from './PubmedButton' + +declare const urlMiRNATargetValidation: string + +/** One experimentally validated miRNA-target record returned by Modulector. */ +interface MiRNATargetValidation { + id: number, + mirtarbase_id: string, + mirna: string, + gene: string, + target_gene_entrez_id: string | number, + experiments: string[], + support_type: string, + pmid: string | number | null +} + +interface MiRNATargetValidationPanelProps { + /** Gene selected in the current experiment result row. */ + gene: string, + /** miRNA selected in the current experiment result row. */ + miRNA: string +} + +/** + * Shows miRTarBase experimental evidence for the selected miRNA-gene pair. + * @param props Selected miRNA and gene identifiers. + * @returns A paginated table of validation records. + */ +export const MiRNATargetValidationPanel = (props: MiRNATargetValidationPanelProps) => { + const intl = useIntl() + const headers: RowHeader[] = [ + { name: intl.formatMessage({ id: 'miRNATargetValidationPanel.mirtarbaseId' }) }, + { name: intl.formatMessage({ id: 'miRNATargetValidationPanel.supportType' }) }, + { name: intl.formatMessage({ id: 'miRNATargetValidationPanel.experiments' }) }, + { name: intl.formatMessage({ id: 'miRNATargetValidationPanel.targetGeneEntrezId' }) }, + { name: intl.formatMessage({ id: 'miRNATargetValidationPanel.pubmed' }), textAlign: 'center' } + ] + + return ( + + headerTitle={intl.formatMessage( + { id: 'miRNATargetValidationPanel.headerTitle' }, + { miRNA: props.miRNA, gene: props.gene } + )} + headers={headers} + urlToRetrieveData={urlMiRNATargetValidation} + queryParams={{ mirna: props.miRNA, target: props.gene }} + defaultSortProp={{ sortField: 'gene', sortOrderAscendant: true }} + mapFunction={(validation) => { + const pubmedUrl = validation.pmid + ? `https://pubmed.ncbi.nlm.nih.gov/${encodeURIComponent(String(validation.pmid))}/` + : '' + + return ( + + {validation.mirtarbase_id || '-'} + {validation.support_type || '-'} + {validation.experiments?.join(', ') || '-'} + {validation.target_gene_entrez_id || '-'} + + {pubmedUrl ? : '-'} + + + ) + }} + /> + ) +} diff --git a/src/frontend/static/frontend/src/locales/en.ts b/src/frontend/static/frontend/src/locales/en.ts index fdab788b..cb559d7f 100644 --- a/src/frontend/static/frontend/src/locales/en.ts +++ b/src/frontend/static/frontend/src/locales/en.ts @@ -1070,6 +1070,8 @@ export default { 'geneGemModalMenu.interaction': '{gem}-{gene} Interaction', 'geneGemModalMenu.interaction.info': 'Scores, given by external databases, that measures the association between {gene} and {gem}', + 'geneGemModalMenu.mirnaTargetValidation': 'miRTarBase validation', + 'geneGemModalMenu.mirnaTargetValidation.info': 'Experimental evidence for the interaction between {gem} and {gene} from miRTarBase', 'geneGemModalMenu.mirnaInteraction': '{gem} Interaction', 'geneGemModalMenu.mirnaInteraction.info': 'Find target interactions with {gem} in miRNA external databases', @@ -1149,6 +1151,12 @@ export default { 'miRNAInteractionPanel.includePubmeds': 'Include pubmeds', 'miRNAInteractionPanel.seeMorePapers': 'See more papers', + 'miRNATargetValidationPanel.headerTitle': 'Experimental validations for {miRNA} and {gene}', + 'miRNATargetValidationPanel.mirtarbaseId': 'miRTarBase ID', + 'miRNATargetValidationPanel.supportType': 'Support type', + 'miRNATargetValidationPanel.experiments': 'Experiments', + 'miRNATargetValidationPanel.targetGeneEntrezId': 'Target Entrez ID', + 'miRNATargetValidationPanel.pubmed': 'PubMed', // 122.MiRNATargetInteractionPanel 'miRNATargetInteractionPanel.noData': 'No data found for this miRNA and gene', diff --git a/src/frontend/static/frontend/src/locales/es.ts b/src/frontend/static/frontend/src/locales/es.ts index 388b0bfc..c83df6b7 100644 --- a/src/frontend/static/frontend/src/locales/es.ts +++ b/src/frontend/static/frontend/src/locales/es.ts @@ -1123,6 +1123,8 @@ export default { 'geneGemModalMenu.interaction': 'Interacción {gem}-{gene}', 'geneGemModalMenu.interaction.info': 'Puntuaciones proporcionadas por bases de datos externas que miden la asociación entre {gene} y {gem}', + 'geneGemModalMenu.mirnaTargetValidation': 'Validación miRTarBase', + 'geneGemModalMenu.mirnaTargetValidation.info': 'Evidencia experimental de la interacción entre {gem} y {gene} en miRTarBase', 'geneGemModalMenu.mirnaInteraction': 'Interacción de {gem}', 'geneGemModalMenu.mirnaInteraction.info': 'Encuentra interacciones objetivo con {gem} en bases de datos externas de miRNA', @@ -1202,6 +1204,12 @@ export default { 'miRNAInteractionPanel.includePubmeds': 'Incluir PubMed', 'miRNAInteractionPanel.seeMorePapers': 'Ver más artículos', + 'miRNATargetValidationPanel.headerTitle': 'Validaciones experimentales para {miRNA} y {gene}', + 'miRNATargetValidationPanel.mirtarbaseId': 'ID de miRTarBase', + 'miRNATargetValidationPanel.supportType': 'Tipo de soporte', + 'miRNATargetValidationPanel.experiments': 'Experimentos', + 'miRNATargetValidationPanel.targetGeneEntrezId': 'ID Entrez del gen objetivo', + 'miRNATargetValidationPanel.pubmed': 'PubMed', // 122.MiRNATargetInteractionPanel 'miRNATargetInteractionPanel.noData': 'No se encontraron datos para este miRNA y gen', From 01118c7390576bdf6e952ba009c57ee2cbe5aebb Mon Sep 17 00:00:00 2001 From: Juan Nicolas Herrera <54152074+juanNH@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:24:23 -0300 Subject: [PATCH 17/17] fix: address miRTarBase PR review feedback --- src/api_service/serializers.py | 37 +++------- .../tests/test_mirna_target_validation.py | 73 +++++++++++++------ src/api_service/views.py | 26 ++++--- 3 files changed, 77 insertions(+), 59 deletions(-) diff --git a/src/api_service/serializers.py b/src/api_service/serializers.py index 07577909..50ddd1eb 100644 --- a/src/api_service/serializers.py +++ b/src/api_service/serializers.py @@ -9,33 +9,16 @@ from django.contrib.auth import get_user_model -class MirnaTargetValidationQuerySerializer(serializers.Serializer): - """Filters accepted by the miRTarBase validation endpoint.""" - mirna = serializers.CharField(required=False) - target = serializers.CharField(required=False) - support_type = serializers.ChoiceField( - choices=[ - 'Functional MTI', - 'Functional MTI (Weak)', - 'Non-Functional MTI', - 'Non-Functional MTI (Weak)', - ], required=False - ) - experiment = serializers.CharField(required=False) - ordering = serializers.CharField(required=False) - page = serializers.IntegerField(min_value=1, required=False) - page_size = serializers.IntegerField(min_value=1, max_value=1000, required=False) - - def validate_ordering(self, value): - fields = [field.strip() for field in value.split(',')] - if not all(field in ('mirna', '-mirna', 'gene', '-gene') for field in fields): - raise serializers.ValidationError('ordering must contain only mirna or gene fields') - return ','.join(fields) - - def validate(self, attrs): - if not attrs.get('mirna') and not attrs.get('target'): - raise serializers.ValidationError('mirna or target is required') - return attrs +class MirnaTargetValidationSerializer(serializers.Serializer): + """Read-only representation of one miRTarBase validation record.""" + id = serializers.IntegerField(read_only=True) + mirtarbase_id = serializers.CharField(read_only=True) + mirna = serializers.CharField(read_only=True) + gene = serializers.CharField(read_only=True) + target_gene_entrez_id = serializers.JSONField(read_only=True, allow_null=True) + experiments = serializers.ListField(child=serializers.CharField(), read_only=True) + support_type = serializers.CharField(read_only=True) + pmid = serializers.JSONField(read_only=True, allow_null=True) class GeneGEMCombinationSerializer(serializers.ModelSerializer): diff --git a/src/api_service/tests/test_mirna_target_validation.py b/src/api_service/tests/test_mirna_target_validation.py index 4d44f06a..17319edb 100644 --- a/src/api_service/tests/test_mirna_target_validation.py +++ b/src/api_service/tests/test_mirna_target_validation.py @@ -7,6 +7,7 @@ from django.urls import reverse from api_service.mrna_service import MRNAService +from api_service.serializers import MirnaTargetValidationSerializer from api_service.views import get_mirna_target_validation_action @@ -20,22 +21,36 @@ def request(self, params): return request def test_requires_mirna_or_target(self): + """The endpoint rejects requests that cannot identify a miRNA-target pair.""" response = get_mirna_target_validation_action(self.request({})) self.assertEqual(response.status_code, 400) def test_only_accepts_get(self): + """The endpoint only permits GET requests.""" request = self.factory.post(reverse('mirna_target_validation')) request.user = SimpleNamespace(is_authenticated=True) response = get_mirna_target_validation_action(request) self.assertEqual(response.status_code, 405) - def test_forwards_validated_filters_and_returns_paginated_data(self): - page = {'count': 1, 'next': '', 'previous': '', 'results': [{'gene': 'SLC7A1'}]} + def test_forwards_query_parameters_and_serializes_paginated_results(self): + """The view forwards query parameters and returns read-only serialized records.""" + result = { + 'id': 1, + 'mirtarbase_id': 'MIRT000001', + 'mirna': 'hsa-miR-122-5p', + 'gene': 'SLC7A1', + 'target_gene_entrez_id': '10558', + 'experiments': ['Western blot'], + 'support_type': 'Functional MTI', + 'pmid': '12345678', + } + page = {'count': 1, 'next': '', 'previous': '', 'results': [result]} with patch('api_service.views.global_mrna_service.get_mirna_target_validations', return_value=page) as get_data: response = get_mirna_target_validation_action(self.request({ 'mirna': 'hsa-miR-122-5p', 'target': 'SLC7A1', 'support_type': 'Functional MTI', + 'experiment': 'Western blot', 'page': '2', 'page_size': '20', 'ordering': '-gene', @@ -45,24 +60,24 @@ def test_forwards_validated_filters_and_returns_paginated_data(self): self.assertEqual(json.loads(response.content), page) get_data.assert_called_once_with( mirna='hsa-miR-122-5p', target='SLC7A1', - support_type='Functional MTI', page=2, page_size=20, ordering='-gene', + support_type='Functional MTI', experiment='Western blot', + page='2', page_size='20', ordering='-gene', ) - def test_rejects_invalid_support_type(self): - response = get_mirna_target_validation_action(self.request({ - 'mirna': 'hsa-miR-122-5p', 'support_type': 'unknown' - })) - self.assertEqual(response.status_code, 400) - def test_accepts_target_without_mirna(self): + """A target-only query is forwarded to Modulector and returns its empty page.""" page = {'count': 0, 'next': '', 'previous': '', 'results': []} with patch('api_service.views.global_mrna_service.get_mirna_target_validations', return_value=page) as get_data: response = get_mirna_target_validation_action(self.request({'target': 'SLC7A1'})) self.assertEqual(response.status_code, 200) - get_data.assert_called_once_with(target='SLC7A1') + get_data.assert_called_once_with( + mirna=None, target='SLC7A1', support_type=None, experiment=None, + ordering=None, page=None, page_size=None, + ) - def test_rewrites_upstream_pagination_links_to_local_route(self): + def test_preserves_upstream_pagination_links(self): + """Pagination metadata from Modulector is returned without rewriting its links.""" page = { 'count': 11, 'next': 'https://web/mirna-target-validation/?target=SLC7A1&page=2', @@ -73,27 +88,23 @@ def test_rewrites_upstream_pagination_links_to_local_route(self): response = get_mirna_target_validation_action(self.request({'target': 'SLC7A1'})) self.assertEqual(response.status_code, 200) - self.assertEqual( - json.loads(response.content)['next'], - f"{reverse('mirna_target_validation')}?target=SLC7A1&page=2", - ) - - def test_rejects_page_size_above_sdk_limit(self): - response = get_mirna_target_validation_action(self.request({ - 'target': 'SLC7A1', 'page_size': '1001' - })) - self.assertEqual(response.status_code, 400) + self.assertEqual(json.loads(response.content), page) def test_accepts_multi_field_ordering(self): + """The view forwards a multi-field ordering value to the SDK service.""" page = {'count': 0, 'next': '', 'previous': '', 'results': []} with patch('api_service.views.global_mrna_service.get_mirna_target_validations', return_value=page) as get_data: response = get_mirna_target_validation_action(self.request({ 'target': 'SLC7A1', 'ordering': 'mirna, -gene' })) self.assertEqual(response.status_code, 200) - get_data.assert_called_once_with(target='SLC7A1', ordering='mirna,-gene') + get_data.assert_called_once_with( + mirna=None, target='SLC7A1', support_type=None, experiment=None, + ordering='mirna, -gene', page=None, page_size=None, + ) def test_upstream_failure_is_not_reported_as_no_data(self): + """An upstream service failure is reported as a gateway error, not an empty result.""" with patch('api_service.views.global_mrna_service.get_mirna_target_validations', side_effect=requests.Timeout), \ patch('api_service.views.logging.exception'): @@ -102,6 +113,7 @@ def test_upstream_failure_is_not_reported_as_no_data(self): @patch('api_service.mrna_service.modulector.get_mirna_target_validations') def test_service_calls_sdk(self, get_validations): + """The service forwards all supported miRTarBase filters and pagination to the SDK.""" get_validations.return_value = SimpleNamespace( count=1, next=None, previous=None, results=[{'gene': 'SLC7A1'}] ) @@ -118,3 +130,20 @@ def test_service_calls_sdk(self, get_validations): self.assertEqual(get_validations.call_args.kwargs['experiment'], 'Western blot') self.assertEqual(get_validations.call_args.kwargs['ordering'], '-gene') self.assertEqual(get_validations.call_args.kwargs['page'], 1) + + def test_result_serializer_is_read_only(self): + """The result serializer represents Modulector records without accepting writes.""" + record = { + 'id': 1, + 'mirtarbase_id': 'MIRT000001', + 'mirna': 'hsa-miR-122-5p', + 'gene': 'SLC7A1', + 'target_gene_entrez_id': None, + 'experiments': ['Western blot'], + 'support_type': 'Functional MTI', + 'pmid': None, + } + serializer = MirnaTargetValidationSerializer(data=record) + + self.assertTrue(serializer.is_valid()) + self.assertEqual(serializer.validated_data, {}) diff --git a/src/api_service/views.py b/src/api_service/views.py index 10bb43d3..119de807 100644 --- a/src/api_service/views.py +++ b/src/api_service/views.py @@ -1,6 +1,5 @@ import json import logging -from urllib.parse import urlsplit import requests from typing import Optional, Dict, Tuple, List, Type, OrderedDict, Union, cast, Any @@ -15,7 +14,6 @@ from django.db.models import Q from django.http import HttpResponse, JsonResponse, Http404 from django.shortcuts import get_object_or_404 -from django.urls import reverse from django.views.decorators.http import require_http_methods from django_filters.rest_framework import DjangoFilterBackend from rest_framework import generics, permissions, filters, status @@ -56,7 +54,7 @@ from .permissions import ExperimentIsNotRunning from .serializers import ExperimentSerializer, ExperimentSerializerDetail, \ GeneMiRNACombinationSerializer, GeneCNACombinationSerializer, GeneMethylationCombinationSerializer, \ - ExperimentClinicalSourceSerializer, LimitedUserSerializer, MirnaTargetValidationQuerySerializer + ExperimentClinicalSourceSerializer, LimitedUserSerializer, MirnaTargetValidationSerializer from .tasks import eval_mrna_gem_experiment from .utils import get_experiment_source, file_type_to_experiment_type, get_cgds_dataset from institutions.models import Institution @@ -962,19 +960,27 @@ def get_mirna_target_interaction_action(request): @require_http_methods(['GET']) def get_mirna_target_validation_action(request): """Return experimentally validated miRNA-target interactions from miRTarBase.""" - serializer = MirnaTargetValidationQuerySerializer(data=request.GET) - if not serializer.is_valid(): - return JsonResponse(serializer.errors, status=400) + mirna = request.GET.get('mirna') + target = request.GET.get('target') + + if not mirna and not target: + return JsonResponse(data={"error": "Param 'mirna' or 'target' are mandatory"}, status=400) try: - data = global_mrna_service.get_mirna_target_validations(**serializer.validated_data) + data = global_mrna_service.get_mirna_target_validations( + mirna=mirna, + target=target, + support_type=request.GET.get('support_type'), + experiment=request.GET.get('experiment'), + ordering=request.GET.get('ordering'), + page=request.GET.get('page'), + page_size=request.GET.get('page_size'), + ) except (requests.RequestException, ValueError): logging.exception('Modulector miRNA-target validation request failed') return JsonResponse({'error': 'Modulector validation service is unavailable'}, status=502) - for key in ('next', 'previous'): - if data.get(key): - data[key] = f"{reverse('mirna_target_validation')}?{urlsplit(data[key]).query}" + data['results'] = MirnaTargetValidationSerializer(instance=data['results'], many=True).data return JsonResponse(data)