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 +
+ + +
+ + { + setThreshold(roundThreshold(Number(e.target.value))) + }} + style={{ + width: '100%', + accentColor: '#2563eb', + cursor: 'pointer', + }} + /> + +
+ ±0.1 + ±0.5 + ±0.9 +
+ +
+
+ + Análisis del nodo: + + + setTraversalMode(data.value as TraversalMode)} + /> +
+ +
+ + + + + + + +
+
+
+ +
+
+ + {tooltip.visible && ( +
+ {tooltip.content} +
+ )}
+ > +
+
+
Aristas seleccionadas
+ + +
+ + {selectedEdges.length === 0 + ? ( +
+ Hacé click en múltiples aristas para ver el valor de cada una. +
+ ) + : ( +
+ {selectedEdges.map((edge) => ( +
+
+
+ {edge.source} → {edge.target} +
+
Direction: {edge.direction}
+
Correlation: {edge.correlation}
+
+ +
+ ))} +
+ )} +
+ +
+
+ {traversalMode === 'outgoing' + ? 'Nivel de profundidad' + : traversalMode === 'incoming' + ? 'Nivel de regulación ascendente' + : 'Niveles de relación'} +
+ + {!selectedRootNode + ? ( +
+ Seleccioná un nodo para calcular niveles. +
+ ) + : ( +
+
+ Nodo raíz: {selectedRootNode} +
+ + {(traversalMode === 'outgoing' || traversalMode === 'both') && ( +
+
+ Regula +
+ + {depthSummary.length === 0 + ? ( +
+ No regula nodos visibles con el threshold actual. +
+ ) + : ( + depthSummary.map((item) => ( +
+
+ + Nivel {item.depth} +
+
{item.nodes.join(', ')}
+
+ )) + )} +
+ )} + + {(traversalMode === 'incoming' || traversalMode === 'both') && ( +
+
+ Es regulado por +
+ + {incomingSummary.length === 0 + ? ( +
+ No tiene reguladores visibles con el threshold actual. +
+ ) + : ( + incomingSummary.map((item) => ( +
+
+ + Nivel {item.depth} +
+
{item.nodes.join(', ')}
+
+ )) + )} +
+ )} +
+ )} +
+
) } +const LevelBadge = ({ depth, incoming = false }: { depth: number; incoming?: boolean }) => { + const color = + depth === 1 + ? LEVEL_COLORS.level1 + : depth === 2 + ? LEVEL_COLORS.level2 + : LEVEL_COLORS.level3 + + return ( + + ) +} + const LegendDot = ({ color, label }: { color: string; label: string }) => ( ( ) -const LegendLine = ({ color, label }: { color: string; label: string }) => ( +const LegendArrow = ({ color, label }: { color: string; label: string }) => ( + > + + {label} ) From 132fcf5cb5b3450d461b9dc41d221ce8e210a635 Mon Sep 17 00:00:00 2001 From: Juan Nicolas Herrera <54152074+juanNH@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:42:37 -0300 Subject: [PATCH 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 +
+ + +
+ + { + setMaxLevels(Number(e.target.value)) + }} + style={{ + width: '100%', + accentColor: '#16a34a', + cursor: 'pointer', + }} + /> + +
+ 1 + 5 + 10 +
+
+
setTraversalMode(data.value as TraversalMode)} /> + +
- + @@ -1028,6 +1073,10 @@ export const GeneExpressionRegulationNetworkPanel = ({ Nodo raíz: {selectedRootNode}
+
+ Límite de búsqueda: {maxLevels} nivel{maxLevels > 1 ? 'es' : ''} +
+ {(traversalMode === 'outgoing' || traversalMode === 'both') && (
@@ -1113,7 +1162,9 @@ const LevelBadge = ({ depth, incoming = false }: { depth: number; incoming?: boo ? LEVEL_COLORS.level1 : depth === 2 ? LEVEL_COLORS.level2 - : LEVEL_COLORS.level3 + : depth === 3 + ? LEVEL_COLORS.level3 + : '#94a3b8' return ( { + const [threshold, setThreshold] = useState(0.5) + const [traversalMode, setTraversalMode] = useState('outgoing') + const [maxLevels, setMaxLevels] = useState(3) + const [selectedEdges, setSelectedEdges] = useState([]) + + const queryParams = useMemo(() => ({ + rootNodeId: 'gene_braf', + threshold, + traversalMode, + maxLevels, + }), [threshold, traversalMode, maxLevels]) + + const { data, loading, error } = useGeneGraphQuery(queryParams) + + return ( +
+ { + setSelectedEdges([]) + setThreshold(value) + }} + maxLevels={maxLevels} + onMaxLevelsChange={(value) => { + setSelectedEdges([]) + setMaxLevels(value) + }} + traversalMode={traversalMode} + onTraversalModeChange={(value) => { + setSelectedEdges([]) + setTraversalMode(value) + }} + /> + + {loading && ( +
+ +
+ )} + + {error && ( + + Error fetching graph +

{error}

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