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 1/6] 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 2/6] 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 + + + + Visible when correlation ≤ -{threshold.toFixed(1)} or ≥ {threshold.toFixed(1)} + + + + { + 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 + + + + Clear + + + + {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} + + + removeSelectedEdgeFromList(edge.id)} + title='Deseleccionar' + /> + + ))} + + )} + + + + + {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 3/6] 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 + + + + {maxLevels} nivel{maxLevels > 1 ? 'es' : ''} + + + + { + setMaxLevels(Number(e.target.value)) + }} + style={{ + width: '100%', + accentColor: '#16a34a', + cursor: 'pointer', + }} + /> + + + 1 + 5 + 10 + + + setTraversalMode(data.value as TraversalMode)} /> + + + Nodo inicial: BRAF + - + @@ -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 + + + + Visible when correlation <= -{threshold.toFixed(1)} or >= {threshold.toFixed(1)} + + + + onThresholdChange(roundThreshold(Number(e.target.value)))} + style={{ + width: '100%', + accentColor: '#2563eb', + cursor: 'pointer', + }} + /> + + + +/-0.1 + +/-0.5 + +/-0.9 + + + + + + + Maximum levels + + + + {maxLevels} level{maxLevels > 1 ? 's' : ''} + + + + onMaxLevelsChange(Number(e.target.value))} + style={{ + width: '100%', + accentColor: '#16a34a', + cursor: 'pointer', + }} + /> + + + 1 + 5 + 10 + + + + + + + Node analysis: + + + onTraversalModeChange(data.value as TraversalMode)} + /> + + Root node: BRAF + + + + + 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 + + + + Clear + + + + {selectedEdges.length === 0 + ? ( + + Click multiple edges to inspect each correlation value. + + ) + : ( + + {selectedEdges.map((edge) => ( + + + + {edge.source} -> {edge.target} + + Direction: {edge.direction} + Correlation: {edge.correlation} + + + onRemoveEdge(edge.id)} + title='Deselect' + /> + + ))} + + )} + + ) +} 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 4/6] 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 ? 'Initial root' : 'Expansion'} + + + + {index !== 0 && ( + onRemoveFilter(filter.rootNodeId)} + title='Remove expansion' + /> + )} + + + + + + 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, + })} + /> + + + + ))} + + + + + + + Clear expansions + + + + + + + Reset to applied + + + + + Filter + + + + + ) +} 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()} + > + + zoomGraph('in')} + title='Zoom in' + style={{ + width: 34, + height: 34, + border: '1px solid #cbd5e1', + borderRadius: 8, + background: '#ffffff', + color: '#0f172a', + cursor: 'pointer', + fontSize: 18, + fontWeight: 700, + boxShadow: '0 4px 12px rgba(15,23,42,0.12)', + }} + > + + + + + zoomGraph('out')} + title='Zoom out' + style={{ + width: 34, + height: 34, + border: '1px solid #cbd5e1', + borderRadius: 8, + background: '#ffffff', + color: '#0f172a', + cursor: 'pointer', + fontSize: 20, + fontWeight: 700, + lineHeight: 1, + boxShadow: '0 4px 12px rgba(15,23,42,0.12)', + }} + > + - + + + + + + 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 + setContextMenu((prev) => ({ + ...prev, + traversalMode: event.target.value as TraversalMode, + }))} + style={{ + border: '1px solid #cbd5e1', + borderRadius: 8, + padding: '7px 8px', + background: '#ffffff', + color: '#0f172a', + }} + > + {traversalLabels.outgoing} + {traversalLabels.incoming} + {traversalLabels.both} + + + + + { + onExpandNode({ + rootNodeId: contextMenu.nodeId, + threshold: contextMenu.threshold, + traversalMode: contextMenu.traversalMode, + maxLevels: contextMenu.maxLevels, + }) + setContextMenu((prev) => ({ + ...prev, + visible: false, + })) + }} + style={{ + width: '100%', + border: '1px solid #2563eb', + borderRadius: 8, + padding: '7px 10px', + background: expandedNodeIds.includes(contextMenu.nodeId) ? '#e2e8f0' : '#2563eb', + color: expandedNodeIds.includes(contextMenu.nodeId) ? '#64748b' : '#ffffff', + cursor: expandedNodeIds.includes(contextMenu.nodeId) ? 'not-allowed' : 'pointer', + fontWeight: 700, + }} + > + {expandedNodeIds.includes(contextMenu.nodeId) ? 'Already expanded' : 'Expand graph'} + + + )} ) } 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 5/6] 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 6/6] 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 = ({ > zoomGraph('in')} + onClick={() => handleGraphZoom('in')} title='Zoom in' style={{ width: 34, @@ -443,7 +505,7 @@ export const GeneNetworkGraph = ({ zoomGraph('out')} + onClick={() => handleGraphZoom('out')} title='Zoom out' style={{ width: 34, 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 deleted file mode 100644 index a1036ed8..00000000 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GraphControls.tsx +++ /dev/null @@ -1,184 +0,0 @@ -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 ( - - - - 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. - - - - - Correlation threshold - - - - Visible when correlation <= -{threshold.toFixed(1)} or >= {threshold.toFixed(1)} - - - - onThresholdChange(roundThreshold(Number(e.target.value)))} - style={{ - width: '100%', - accentColor: '#2563eb', - cursor: 'pointer', - }} - /> - - - +/-0.1 - +/-0.5 - +/-0.9 - - - - - - - Maximum levels - - - - {maxLevels} level{maxLevels > 1 ? 's' : ''} - - - - onMaxLevelsChange(Number(e.target.value))} - style={{ - width: '100%', - accentColor: '#16a34a', - cursor: 'pointer', - }} - /> - - - 1 - 5 - 10 - - - - - - - Node analysis: - - - onTraversalModeChange(data.value as TraversalMode)} - /> - - Root node: BRAF - - - - - 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 index 6d691918..dd1652be 100644 --- 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 @@ -7,13 +7,23 @@ const LEVEL_COLORS = { other: '#94a3b8', } -export const LevelBadge = ({ - depth, - incoming = false, -}: { +interface LevelBadgeProps { depth: number; incoming?: boolean; -}) => { +} + +/** + * Displays the visual badge used to identify a traversal depth level. + * @param props Component props. + * @param props.depth Depth level represented by the badge. + * @param props.incoming Whether the badge should use the incoming traversal style. + * @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 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 index d1191914..97e75bfc 100644 --- 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 @@ -2,17 +2,26 @@ import React from 'react' import { Button, Icon } from 'semantic-ui-react' import { SelectedEdgeInfo } from './types' -type Props = { +interface SelectedEdgesPanelProps { selectedEdges: SelectedEdgeInfo[]; onRemoveEdge: (edgeId: string) => void; onClearAll: () => void; } -export const SelectedEdgesPanel = ({ - selectedEdges, - onRemoveEdge, - onClearAll, -}: Props) => { +/** + * Lists the edges selected by the user and exposes quick cleanup actions. + * @param props Component props. + * @param props.selectedEdges Edges currently selected in the graph. + * @param props.onRemoveEdge Callback used to deselect a single edge. + * @param props.onClearAll Callback used to clear the entire edge selection. + * @returns The selected edges panel rendered below the graph. + */ +export const SelectedEdgesPanel = (props: SelectedEdgesPanelProps): JSX.Element => { + const { + selectedEdges, + onRemoveEdge, + onClearAll, + } = props return ( { +/** + * Summarizes the traversal results for the active root graph filter. + * @param props Component props. + * @param props.traversalMode Active traversal mode applied to the root filter. + * @param props.selectedRootNode Visible label of the current root node. + * @param props.maxLevels Maximum depth configured for the root filter. + * @param props.depthSummary Outgoing traversal summary grouped by level. + * @param props.incomingSummary Incoming traversal summary grouped by level. + * @returns The traversal summary panel rendered below the graph. + */ +export const TraversalSummaryPanel = (props: TraversalSummaryPanelProps): JSX.Element => { + const { + traversalMode, + selectedRootNode, + maxLevels, + depthSummary, + incomingSummary, + } = props return ( ; + 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]) @@ -35,6 +67,11 @@ const buildSummary = (map: Map) => 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>() @@ -58,7 +95,14 @@ const mergeSummaries = (summaries: DepthSummaryItem[][]) => { })) } -const walkOutgoing = (rootNodeId: string, edges: GraphEdge[], maxLevels: number) => { +/** + * 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() @@ -108,7 +152,14 @@ const walkOutgoing = (rootNodeId: string, edges: GraphEdge[], maxLevels: number) } } -const walkIncoming = (rootNodeId: string, edges: GraphEdge[], maxLevels: number) => { +/** + * 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() @@ -158,7 +209,12 @@ const walkIncoming = (rootNodeId: string, edges: GraphEdge[], maxLevels: number) } } -const collectFilterResponse = (filter: GraphQueryFilter): FetchGeneGraphResponse => { +/** + * 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) ) @@ -221,7 +277,12 @@ const collectFilterResponse = (filter: GraphQueryFilter): FetchGeneGraphResponse } } -const collectResponse = (filters: GraphQueryFilter[]): FetchGeneGraphResponse => { +/** + * 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() @@ -247,10 +308,12 @@ const collectResponse = (filters: GraphQueryFilter[]): FetchGeneGraphResponse => } } -export const fetchGeneGraph = async ( - params: FetchGeneGraphParams -): Promise => { - await sleep(350) - - return collectResponse(params.filters) -} +/** + * 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 index f599bf9b..49794222 100644 --- 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 @@ -1,5 +1,6 @@ import { NodeKind } from './types' +/** Shared node colors used by the graph and legend. */ export const NODE_COLORS: Record = { Gene: '#4f46e5', miRNA: '#db2777', @@ -8,6 +9,7 @@ export const NODE_COLORS: Record = { 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 index b77ce364..c55e7e42 100644 --- 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 @@ -1,44 +1,71 @@ import React from 'react' -export const LegendDot = ({ color, label }: { color: string; label: string }) => ( - - - {label} - -) +type LegendItemProps = { + color: string; + label: string; +} -export const LegendArrow = ({ color, label }: { color: string; label: string }) => ( - - +/** + * Renders a colored dot item used by the graph legend. + * @param props Component props. + * @param props.color Color shown in the legend swatch. + * @param props.label Visible legend label. + * @returns The node legend item. + */ +export const LegendDot = (props: LegendItemProps): JSX.Element => { + const { color, label } = props + + return ( + + {label} + + ) +} + +/** + * Renders an arrow item used by the graph edge legend. + * @param props Component props. + * @param props.color Color shown in the legend arrow. + * @param props.label Visible legend label. + * @returns The edge legend item. + */ +export const LegendArrow = (props: LegendItemProps): JSX.Element => { + const { color, label } = props + + return ( + + + + + {label} - {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 index 39080864..fb4f50ae 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 @@ -1,5 +1,6 @@ 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 }, @@ -62,6 +63,7 @@ export const MOCK_NODES: GraphNode[] = [ { 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 }, @@ -93,6 +95,7 @@ export const MOCK_EDGES: GraphEdge[] = [ { 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 }, 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 3075e70e..2340bf0f 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 @@ -1,50 +1,85 @@ +/** 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[]; } -export type FetchGeneGraphParams = { +/** Request payload sent by the panel when asking for graph data. */ +export type FetchGeneRegulationGraphParams = { + /** Active root filters, including user-created expansions. */ filters: GraphQueryFilter[]; } -export type FetchGeneGraphResponse = { +/** 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/useGeneGraphQuery.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/useGeneRegulationGraphQuery.ts similarity index 51% rename from src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/useGeneGraphQuery.ts rename to src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/useGeneRegulationGraphQuery.ts index 9dbc5872..3678492d 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/useGeneRegulationGraphQuery.ts @@ -1,15 +1,24 @@ import { useEffect, useState } from 'react' -import { fetchGeneGraph } from './graphApi' -import { FetchGeneGraphParams, FetchGeneGraphResponse } from './types' - -type QueryState = { - data: FetchGeneGraphResponse | null; +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; } -export const useGeneGraphQuery = (params: FetchGeneGraphParams) => { - const [state, setState] = useState({ +/** + * 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, @@ -24,7 +33,7 @@ export const useGeneGraphQuery = (params: FetchGeneGraphParams) => { error: null, }) - fetchGeneGraph(params) + fetchGeneRegulationGraph(params) .then((data) => { if (cancelled) { return }
{error}