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..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 @@ -7,10 +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 { GeneRegulationAssociationsPanel } from './genes/gene-association-network/GeneRegulationAssociationsPanel' */ +import { GeneAssociationsNetworkPanel } from './genes/GeneAssociationsNetworkPanel' // const MENU_DEFAULT: ActiveBiomarkerMoleculeItemMenu = ActiveBiomarkerMoleculeItemMenu.DETAILS // TODO: use this const MENU_DEFAULT: ActiveBiomarkerMoleculeItemMenu = ActiveBiomarkerMoleculeItemMenu.DETAILS @@ -52,6 +53,9 @@ export const CurrentMoleculeDetails = (props: CurrentMoleculeDetailsProps) => { return case ActiveBiomarkerMoleculeItemMenu.GENE_ONTOLOGY: 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 5a98008b..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,6 +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/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..f87b7b60 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/GeneAssociationsNetwork.tsx @@ -0,0 +1,1235 @@ +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 = 'Gene' | 'miRNA' | 'CNA' | 'Methylation' | 'Drug' + +type Props = { + height?: number | string; + 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 = { + Gene: '#4f46e5', + miRNA: '#db2777', + CNA: '#f59e0b', + Methylation: '#10b981', + Drug: '#64748b', +} + +const LEVEL_COLORS = { + root: '#f59e0b', + level1: '#0ea5e9', + level2: '#22c55e', + level3: '#a855f7', +} + +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' +} + +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 +} + +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 +} + +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%', +}: Props) => { + const containerRef = useRef(null) + const cyRef = useRef(null) + + const [threshold, setThreshold] = useState(0.5) + const [traversalMode, setTraversalMode] = useState('outgoing') + const [maxLevels, setMaxLevels] = useState(3) + + const [tooltip, setTooltip] = useState({ + visible: false, + x: 0, + y: 0, + content: '', + }) + + const [selectedEdges, setSelectedEdges] = useState([]) + const [selectedRootNode, setSelectedRootNode] = useState('BRAF') + const [depthSummary, setDepthSummary] = useState([]) + const [incomingSummary, setIncomingSummary] = useState([]) + + const elements = useMemo(() => { + const nodes: ElementDefinition[] = [ + { 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: '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 = [ + ['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 + .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([]) + setDepthSummary([]) + setIncomingSummary([]) + setTooltip({ + visible: false, + x: 0, + y: 0, + content: '', + }) + + 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', + opacity: 1, + }, + }, + { + 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: '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: 'node.depth-level-3', + style: { + 'border-color': LEVEL_COLORS.level3, + '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: { + '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: '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: { + 'border-color': '#f8fafc', + 'border-width': 5, + }, + }, + { + 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', + animate: true, + randomize: true, + fit: true, + padding: 40, + gravity: 1, + nodeRepulsion: 9000, + idealEdgeLength: 90, + componentSpacing: 80, + }, + }) + + 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) => { + 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) => { + 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, maxDepth: number) => { + 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 + + 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) + target.addClass(`depth-level-${nextDepth}`) + + 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, maxDepth: number) => { + 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 + + 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) + source.addClass(`incoming-level-${nextDepth}`) + + 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, levels: number) => { + clearTraversal() + + cy.batch(() => { + root.addClass('depth-root') + }) + + const outgoing = mode === 'outgoing' || mode === 'both' + ? walkOutgoing(root, levels) + : { visitedNodes: new Map(), summary: [] } + + const incoming = mode === 'incoming' || mode === 'both' + ? walkIncoming(root, levels) + : { 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 + applyTraversal(node, traversalMode, maxLevels) + }) + + 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, + })) + }) + + cy.on('tap', (evt) => { + if (evt.target === cy) { + cy.elements().unselect() + setTooltip((prev) => ({ ...prev, visible: false })) + clearTraversal() + } + }) + + 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, maxLevels]) + + 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 +
+
+ +
+
+
+ Cantidad máxima de niveles +
+ + +
+ + { + setMaxLevels(Number(e.target.value)) + }} + style={{ + width: '100%', + accentColor: '#16a34a', + cursor: 'pointer', + }} + /> + +
+ 1 + 5 + 10 +
+
+ +
+
+ + 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} +
+ +
+ Límite de búsqueda: {maxLevels} nivel{maxLevels > 1 ? 'es' : ''} +
+ + {(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 + : depth === 3 + ? LEVEL_COLORS.level3 + : '#94a3b8' + + return ( + + ) +} + +const LegendDot = ({ color, label }: { color: string; label: string }) => ( + + + {label} + +) + +const LegendArrow = ({ 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/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/GeneRegulationFiltersPanel.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationFiltersPanel.tsx new file mode 100644 index 00000000..f95f2883 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationFiltersPanel.tsx @@ -0,0 +1,245 @@ +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 }, +] + +/** 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; +} + +/** + * 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 ( +
+
+
+
Active graph filters
+
+ Edit filters here. Changes stay local until you click Filter. Reset restores the last applied state. +
+
+
+ +
+ {editableFilters.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/GeneRegulationGraph.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationGraph.tsx new file mode 100644 index 00000000..949a3d1f --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationGraph.tsx @@ -0,0 +1,698 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import cytoscape, { Core, ElementDefinition } from 'cytoscape' +import { NODE_COLORS, REGULATION_COLORS } from './graphStyle' +import { LegendArrow, LegendDot } from './legend' +import { FetchGeneRegulationGraphResponse, GraphQueryFilter, SelectedEdgeInfo, TraversalMode } from './types' + +const MIN_ZOOM = 0.4 +const MAX_ZOOM = 2 +const ZOOM_STEP = 1.2 + +const traversalLabels: Record = { + outgoing: 'Regulates', + incoming: 'Regulated by', + both: 'Both', +} + +/** + * Resolves the edge color according to the correlation sign. + * @param correlation Correlation value stored in the graph edge. + * @returns The color used to render the edge. + */ +const getEdgeColor = (correlation: number) => { + if (correlation < 0) { return REGULATION_COLORS.down } + + return REGULATION_COLORS.up +} + +/** + * Maps the edge correlation strength into the rendered edge opacity. + * @param correlation Correlation value stored in the graph edge. + * @returns The opacity used to render the edge. + */ +const getEdgeOpacity = (correlation: number) => { + const abs = Math.abs(correlation) + + if (abs >= 0.8) { return 0.95 } + + if (abs >= 0.6) { return 0.8 } + + return 0.7 +} + +/** + * Maps the edge correlation strength into the rendered edge width. + * @param correlation Correlation value stored in the graph edge. + * @returns The width used to render the edge. + */ +const getEdgeWidth = (correlation: number) => { + const abs = Math.abs(correlation) + + if (abs >= 0.9) { return 6 } + + if (abs >= 0.8) { return 5 } + + if (abs >= 0.7) { return 4 } + + return 3 +} + +/** + * Converts the edge correlation sign into the label shown by the UI. + * @param correlation Correlation value stored in the graph edge. + * @returns The semantic direction label shown in tooltips and panels. + */ +const getDirectionLabel = (correlation: number): SelectedEdgeInfo['direction'] => { + if (correlation < 0) { return 'Down-regulation' } + + return 'Up-regulation' +} + +/** Tooltip state used while hovering graph edges. */ +type TooltipState = { + /** Whether the tooltip is currently visible. */ + visible: boolean; + /** Horizontal position relative to the graph container. */ + x: number; + /** Vertical position relative to the graph container. */ + y: number; + /** Text shown inside the tooltip. */ + content: string; +} + +/** Context menu state used when expanding a node from the graph. */ +type ContextMenuState = { + /** Whether the expansion menu is currently visible. */ + visible: boolean; + /** Horizontal position relative to the graph container. */ + x: number; + /** Vertical position relative to the graph container. */ + y: number; + /** Node identifier being expanded. */ + nodeId: string; + /** Visible label of the node being expanded. */ + nodeLabel: string; + /** Threshold draft shown in the expansion form. */ + threshold: number; + /** Traversal mode draft shown in the expansion form. */ + traversalMode: TraversalMode; + /** Max depth draft shown in the expansion form. */ + maxLevels: number; +} + +/** Props accepted by the Cytoscape-based regulation graph. */ +interface GeneRegulationGraphProps { + /** Graph payload currently rendered in Cytoscape. */ + data: FetchGeneRegulationGraphResponse | null; + /** Height assigned to the graph area. */ + height?: number | string; + /** Width assigned to the graph area. */ + width?: number | string; + /** Edges currently selected by the user. */ + selectedEdges: SelectedEdgeInfo[]; + /** Callback used to sync edge selections with the side panel. */ + onSelectedEdgesChange: (edges: SelectedEdgeInfo[]) => void; + /** Node identifiers already expanded into the current request. */ + expandedNodeIds: string[]; + /** Default expansion values reused when opening the node context menu. */ + defaultExpansionFilter: Omit; + /** Callback used to append a new node expansion to the query. */ + onExpandNode: (filter: GraphQueryFilter) => void; +} + +/** + * Renders the Cytoscape graph along with local zoom and expansion controls. + * @param props Component props. + * @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) + + const [tooltip, setTooltip] = useState({ + visible: false, + x: 0, + y: 0, + content: '', + }) + const [contextMenu, setContextMenu] = useState({ + visible: false, + x: 0, + y: 0, + nodeId: '', + nodeLabel: '', + threshold: defaultExpansionFilter.threshold, + traversalMode: defaultExpansionFilter.traversalMode, + maxLevels: defaultExpansionFilter.maxLevels, + }) + + useEffect(() => { + defaultExpansionFilterRef.current = defaultExpansionFilter + }, [defaultExpansionFilter]) + + const elements = useMemo(() => { + if (!data) { return [] } + + const nodes: ElementDefinition[] = data.nodes.map((node) => ({ + data: { + ...node, + }, + })) + + const edges: ElementDefinition[] = data.edges.map((edge) => ({ + data: { + ...edge, + edgeColor: getEdgeColor(edge.correlation), + edgeOpacity: getEdgeOpacity(edge.correlation), + edgeWidth: getEdgeWidth(edge.correlation), + directionLabel: getDirectionLabel(edge.correlation), + }, + })) + + return [...nodes, ...edges] + }, [data]) + + useEffect(() => { + if (!containerRef.current || !data) { return } + + if (cyRef.current) { + cyRef.current.destroy() + cyRef.current = null + } + + const cy = cytoscape({ + container: containerRef.current, + elements, + wheelSensitivity: 0.18, + minZoom: 0.4, + maxZoom: 2, + style: [ + { + selector: 'node', + style: { + label: 'data(label)', + width: 'data(size)', + height: 'data(size)', + shape: 'ellipse', + 'background-color': '#64748b', + color: '#ffffff', + 'font-size': 12, + 'font-weight': 600, + 'text-valign': 'center', + 'text-halign': 'center', + 'text-wrap': 'wrap', + 'text-max-width': 90 as any, + 'text-outline-color': '#475569', + 'text-outline-width': 3, + 'border-width': 2, + 'border-color': '#ffffff', + }, + }, + { + selector: 'node[type = "Gene"]', + style: { + 'background-color': NODE_COLORS.Gene, + 'text-outline-color': NODE_COLORS.Gene, + }, + }, + { + selector: 'node[type = "miRNA"]', + style: { + 'background-color': NODE_COLORS.miRNA, + 'text-outline-color': NODE_COLORS.miRNA, + }, + }, + { + selector: 'node[type = "CNA"]', + style: { + 'background-color': NODE_COLORS.CNA, + 'text-outline-color': NODE_COLORS.CNA, + }, + }, + { + selector: 'node[type = "Methylation"]', + style: { + 'background-color': NODE_COLORS.Methylation, + 'text-outline-color': NODE_COLORS.Methylation, + }, + }, + { + selector: 'node[type = "Drug"]', + style: { + 'background-color': NODE_COLORS.Drug, + 'text-outline-color': NODE_COLORS.Drug, + }, + }, + { + selector: 'edge', + style: { + width: 'data(edgeWidth)', + 'line-color': 'data(edgeColor)', + opacity: 'data(edgeOpacity)' as any, + 'curve-style': 'bezier', + 'target-arrow-shape': 'triangle', + 'target-arrow-color': 'data(edgeColor)', + 'arrow-scale': 1.15, + }, + }, + { + selector: 'edge.edge-picked', + style: { + opacity: 0.82, + 'underlay-color': '#64748b', + 'underlay-opacity': 0.22, + 'underlay-padding': 5, + width: 'mapData(edgeWidth, 3, 6, 4, 6)', + 'z-index': 999, + }, + }, + { + selector: 'node.root-node', + style: { + 'border-color': '#f59e0b', + 'border-width': 6, + }, + }, + ], + layout: { + name: 'cose', + animate: true, + randomize: true, + fit: true, + padding: 40, + gravity: 1, + nodeRepulsion: 9000, + idealEdgeLength: 90, + componentSpacing: 80, + }, + }) + + cyRef.current = cy + + for (const filter of data.filters) { + const expandedRootNode = cy.getElementById(filter.rootNodeId) + + if (expandedRootNode.nonempty()) { + expandedRootNode.addClass('root-node') + } + } + + const rootNode = cy.getElementById(data.rootNodeId) + + if (rootNode.nonempty()) { + cy.center(rootNode) + } + + /** Synchronizes the selected edge list shown in the side panel. */ + const syncSelectedEdges = () => { + const picked = cy + .edges('.edge-picked') + .toArray() + .map((edge: any) => ({ + id: edge.id(), + source: edge.source().data('label') || edge.data('source'), + target: edge.target().data('label') || edge.data('target'), + correlation: edge.data('correlation'), + direction: edge.data('directionLabel'), + })) + + onSelectedEdgesChange(picked) + } + + cy.on('tap', 'edge', (evt) => { + const edge = evt.target + + setContextMenu((prev) => ({ + ...prev, + visible: false, + })) + + if (edge.hasClass('edge-picked')) { + edge.removeClass('edge-picked') + } else { + edge.addClass('edge-picked') + } + + syncSelectedEdges() + }) + + cy.on('tap', 'node', () => { + setContextMenu((prev) => ({ + ...prev, + visible: false, + })) + }) + + cy.on('cxttap', 'node', (evt) => { + evt.originalEvent?.preventDefault() + + const node = evt.target + + setTooltip((prev) => ({ + ...prev, + visible: false, + })) + + setContextMenu({ + visible: true, + x: evt.renderedPosition?.x ?? 0, + y: evt.renderedPosition?.y ?? 0, + nodeId: node.id(), + nodeLabel: node.data('label') || node.id(), + threshold: defaultExpansionFilterRef.current.threshold, + traversalMode: defaultExpansionFilterRef.current.traversalMode, + maxLevels: defaultExpansionFilterRef.current.maxLevels, + }) + }) + + cy.on('mouseover', 'edge', (evt) => { + const edge = evt.target + const correlation = edge.data('correlation') + const direction = edge.data('directionLabel') + + setTooltip({ + visible: true, + x: evt.renderedPosition?.x ?? 0, + y: evt.renderedPosition?.y ?? 0, + content: `${direction} | correlation: ${correlation}`, + }) + }) + + cy.on('mousemove', 'edge', (evt) => { + setTooltip((prev) => ({ + ...prev, + x: evt.renderedPosition?.x ?? prev.x, + y: evt.renderedPosition?.y ?? prev.y, + visible: true, + })) + }) + + cy.on('mouseout', 'edge', () => { + setTooltip((prev) => ({ + ...prev, + visible: false, + })) + }) + + return () => { + cy.destroy() + cyRef.current = null + } + }, [data, elements, onSelectedEdgesChange]) + + useEffect(() => { + const cy = cyRef.current + + if (!cy) { return } + + cy.edges().removeClass('edge-picked') + + for (const edge of selectedEdges) { + const cyEdge = cy.getElementById(edge.id) + + if (cyEdge.nonempty()) { + cyEdge.addClass('edge-picked') + } + } + }, [selectedEdges]) + + /** + * Applies a centered zoom step from the overlay controls. + * @param direction Zoom direction requested by the user. + */ + const handleGraphZoom = (direction: 'in' | 'out') => { + const cy = cyRef.current + + if (!cy) { return } + + const nextZoom = direction === 'in' + ? Math.min(MAX_ZOOM, cy.zoom() * ZOOM_STEP) + : Math.max(MIN_ZOOM, cy.zoom() / ZOOM_STEP) + + cy.zoom({ + level: nextZoom, + renderedPosition: { + x: cy.width() / 2, + y: cy.height() / 2, + }, + }) + } + + return ( +
event.preventDefault()} + > +
+ +
+ + + +
+ +
+
+ Nodes: + + + + + +
+ +
+ Edges: + + +
+
+ + {tooltip.visible && ( +
+ {tooltip.content} +
+ )} + + {contextMenu.visible && ( +
+
+ {contextMenu.nodeLabel} +
+ +
+ Configure this expansion before adding it to the graph request. +
+ +
+
+
+ Threshold + {contextMenu.threshold.toFixed(1)} +
+ setContextMenu((prev) => ({ + ...prev, + threshold: Number(Number(event.target.value).toFixed(1)), + }))} + /> +
+ +
+
+ Depth + {contextMenu.maxLevels} +
+ setContextMenu((prev) => ({ + ...prev, + maxLevels: Number(event.target.value), + }))} + /> +
+ +
+ Regulation mode + +
+
+ + +
+ )} +
+ ) +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/LevelBadge.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/LevelBadge.tsx new file mode 100644 index 00000000..dd1652be --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/LevelBadge.tsx @@ -0,0 +1,50 @@ +import React from 'react' + +const LEVEL_COLORS = { + level1: '#0ea5e9', + level2: '#22c55e', + level3: '#a855f7', + other: '#94a3b8', +} + +interface LevelBadgeProps { + depth: number; + incoming?: boolean; +} + +/** + * Displays the visual badge used to identify a traversal depth level. + * @param props Component props. + * @param props.depth Depth level represented by the badge. + * @param props.incoming Whether the badge should use the incoming traversal style. + * @returns The colored level badge rendered inline. + */ +export const LevelBadge = (props: LevelBadgeProps): JSX.Element => { + const { + depth, + incoming = false, + } = props + const color = + depth === 1 + ? LEVEL_COLORS.level1 + : 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..97e75bfc --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/SelectedEdgesPanel.tsx @@ -0,0 +1,106 @@ +import React from 'react' +import { Button, Icon } from 'semantic-ui-react' +import { SelectedEdgeInfo } from './types' + +interface SelectedEdgesPanelProps { + selectedEdges: SelectedEdgeInfo[]; + onRemoveEdge: (edgeId: string) => void; + onClearAll: () => void; +} + +/** + * Lists the edges selected by the user and exposes quick cleanup actions. + * @param props Component props. + * @param props.selectedEdges Edges currently selected in the graph. + * @param props.onRemoveEdge Callback used to deselect a single edge. + * @param props.onClearAll Callback used to clear the entire edge selection. + * @returns The selected edges panel rendered below the graph. + */ +export const SelectedEdgesPanel = (props: SelectedEdgesPanelProps): JSX.Element => { + const { + selectedEdges, + onRemoveEdge, + onClearAll, + } = props + return ( +
+
+
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..9777b084 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/TraversalSummaryPanel.tsx @@ -0,0 +1,139 @@ +import React from 'react' +import { LevelBadge } from './LevelBadge' +import { DepthSummaryItem, TraversalMode } from './types' + +interface TraversalSummaryPanelProps { + traversalMode: TraversalMode; + selectedRootNode: string | null; + maxLevels: number; + depthSummary: DepthSummaryItem[]; + incomingSummary: DepthSummaryItem[]; +} + +/** + * Summarizes the traversal results for the active root graph filter. + * @param props Component props. + * @param props.traversalMode Active traversal mode applied to the root filter. + * @param props.selectedRootNode Visible label of the current root node. + * @param props.maxLevels Maximum depth configured for the root filter. + * @param props.depthSummary Outgoing traversal summary grouped by level. + * @param props.incomingSummary Incoming traversal summary grouped by level. + * @returns The traversal summary panel rendered below the graph. + */ +export const TraversalSummaryPanel = (props: TraversalSummaryPanelProps): JSX.Element => { + const { + traversalMode, + selectedRootNode, + maxLevels, + depthSummary, + incomingSummary, + } = props + return ( +
+
+ {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..94bcbae5 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphApi.ts @@ -0,0 +1,319 @@ +import { MOCK_EDGES, MOCK_EXPANSION_EDGES_BY_ROOT, MOCK_NODES } from './mockNetworkData' +import { + DepthSummaryItem, + FetchGeneRegulationGraphParams, + FetchGeneRegulationGraphResponse, + GraphEdge, + GraphQueryFilter, +} from './types' + +const FALLBACK_FILTER: GraphQueryFilter = { + rootNodeId: 'gene_braf', + threshold: 0.5, + traversalMode: 'both', + maxLevels: 3, +} + +type TraversalWalkResult = { + visitedNodes: Map; + includedEdgeIds: Set; + summary: DepthSummaryItem[]; +} + +/** + * Simulates the latency of the future backend integration. + * @param ms Milliseconds to wait before resolving the mock request. + * @returns A promise resolved after the requested delay. + */ +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * Validates whether an edge should remain visible for the current threshold. + * @param correlation Correlation value stored in the graph edge. + * @param threshold Threshold required for the edge to stay visible. + * @returns Whether the edge passes the active threshold. + */ +const passesThreshold = (correlation: number, threshold: number) => + Math.abs(correlation) >= threshold + +/** + * Resolves the label to display for a node identifier. + * @param nodeId Graph node identifier. + * @returns The visible label associated with the node. + */ +const getNodeLabel = (nodeId: string) => + MOCK_NODES.find((node) => node.id === nodeId)?.label ?? nodeId + +/** + * Returns the base graph plus the mock branch associated with a root node expansion. + * @param rootNodeId Root node identifier used for the expansion. + * @returns The list of edges available for that root node. + */ +const getEdgesForFilter = (rootNodeId: string) => [ + ...MOCK_EDGES, + ...(MOCK_EXPANSION_EDGES_BY_ROOT[rootNodeId] ?? []), +] + +/** + * Converts the raw traversal map into a sorted depth summary structure. + * @param map Traversal map keyed by depth. + * @returns The normalized list of depth summary items. + */ +const buildSummary = (map: Map) => + Array.from(map.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([depth, nodes]): DepthSummaryItem => ({ + depth, + nodes: [...nodes].sort((a, b) => a.localeCompare(b)), + })) + +/** + * Merges summaries coming from all active root filters. + * @param summaries Summary lists produced for each active root filter. + * @returns A merged summary grouped by depth. + */ +const mergeSummaries = (summaries: DepthSummaryItem[][]) => { + const summaryMap = new Map>() + + for (const summary of summaries) { + for (const item of summary) { + const nodes = summaryMap.get(item.depth) ?? new Set() + + for (const node of item.nodes) { + nodes.add(node) + } + + summaryMap.set(item.depth, nodes) + } + } + + return Array.from(summaryMap.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([depth, nodes]): DepthSummaryItem => ({ + depth, + nodes: Array.from(nodes).sort((a, b) => a.localeCompare(b)), + })) +} + +/** + * Traverses outward edges from a root node respecting the selected depth limit. + * @param rootNodeId Root node identifier used as traversal origin. + * @param edges Edges currently visible for the filter. + * @param maxLevels Maximum number of depth levels to walk. + * @returns The visited nodes, kept edges and outgoing level summary. + */ +const walkOutgoing = (rootNodeId: string, edges: GraphEdge[], maxLevels: number): TraversalWalkResult => { + const visitedNodes = new Map() + const includedEdgeIds = new Set() + const summaryMap = new Map() + + const queue: Array<{ nodeId: string; depth: number }> = [{ nodeId: rootNodeId, depth: 0 }] + + visitedNodes.set(rootNodeId, 0) + + while (queue.length > 0) { + const current = queue.shift() + + if (!current) { continue } + + const { nodeId, depth } = current + + if (depth >= maxLevels) { continue } + + const outgoingEdges = edges.filter((edge) => edge.source === nodeId) + + for (const edge of outgoingEdges) { + const nextDepth = depth + 1 + + if (nextDepth > maxLevels) { continue } + + includedEdgeIds.add(edge.id) + + if (!visitedNodes.has(edge.target) || nextDepth < visitedNodes.get(edge.target)!) { + visitedNodes.set(edge.target, nextDepth) + + const targetLabel = getNodeLabel(edge.target) + const arr = summaryMap.get(nextDepth) || [] + + if (!arr.includes(targetLabel)) { + arr.push(targetLabel) + summaryMap.set(nextDepth, arr) + } + + queue.push({ nodeId: edge.target, depth: nextDepth }) + } + } + } + + return { + visitedNodes, + includedEdgeIds, + summary: buildSummary(summaryMap), + } +} + +/** + * Traverses inward edges from a root node respecting the selected depth limit. + * @param rootNodeId Root node identifier used as traversal origin. + * @param edges Edges currently visible for the filter. + * @param maxLevels Maximum number of depth levels to walk. + * @returns The visited nodes, kept edges and incoming level summary. + */ +const walkIncoming = (rootNodeId: string, edges: GraphEdge[], maxLevels: number): TraversalWalkResult => { + const visitedNodes = new Map() + const includedEdgeIds = new Set() + const summaryMap = new Map() + + const queue: Array<{ nodeId: string; depth: number }> = [{ nodeId: rootNodeId, depth: 0 }] + + visitedNodes.set(rootNodeId, 0) + + while (queue.length > 0) { + const current = queue.shift() + + if (!current) { continue } + + const { nodeId, depth } = current + + if (depth >= maxLevels) { continue } + + const incomingEdges = edges.filter((edge) => edge.target === nodeId) + + for (const edge of incomingEdges) { + const nextDepth = depth + 1 + + if (nextDepth > maxLevels) { continue } + + includedEdgeIds.add(edge.id) + + if (!visitedNodes.has(edge.source) || nextDepth < visitedNodes.get(edge.source)!) { + visitedNodes.set(edge.source, nextDepth) + + const sourceLabel = getNodeLabel(edge.source) + const arr = summaryMap.get(nextDepth) || [] + + if (!arr.includes(sourceLabel)) { + arr.push(sourceLabel) + summaryMap.set(nextDepth, arr) + } + + queue.push({ nodeId: edge.source, depth: nextDepth }) + } + } + } + + return { + visitedNodes, + includedEdgeIds, + summary: buildSummary(summaryMap), + } +} + +/** + * Builds the partial graph response for a single root filter. + * @param filter Active filter to resolve for one root node. + * @returns The partial graph payload generated for that filter. + */ +const collectFilterResponse = (filter: GraphQueryFilter): FetchGeneRegulationGraphResponse => { + const thresholdEdges = getEdgesForFilter(filter.rootNodeId).filter((edge) => + passesThreshold(edge.correlation, filter.threshold) + ) + + const outgoing = filter.traversalMode === 'outgoing' || filter.traversalMode === 'both' + ? walkOutgoing(filter.rootNodeId, thresholdEdges, filter.maxLevels) + : { + visitedNodes: new Map(), + includedEdgeIds: new Set(), + summary: [] as DepthSummaryItem[], + } + + const incoming = filter.traversalMode === 'incoming' || filter.traversalMode === 'both' + ? walkIncoming(filter.rootNodeId, thresholdEdges, filter.maxLevels) + : { + visitedNodes: new Map(), + includedEdgeIds: new Set(), + summary: [] as DepthSummaryItem[], + } + + const includedEdgeIds = new Set([ + ...Array.from(outgoing.includedEdgeIds), + ...Array.from(incoming.includedEdgeIds), + ]) + + const edges = thresholdEdges.filter((edge) => includedEdgeIds.has(edge.id)) + const includedNodeIdsFromEdges = new Set() + + for (const edge of edges) { + includedNodeIdsFromEdges.add(edge.source) + includedNodeIdsFromEdges.add(edge.target) + } + + includedNodeIdsFromEdges.add(filter.rootNodeId) + + const nodes = MOCK_NODES.filter((node) => includedNodeIdsFromEdges.has(node.id)) + const validLabels = new Set(nodes.map((node) => node.label)) + + const outgoingSummary = outgoing.summary + .map((item) => ({ + depth: item.depth, + nodes: item.nodes.filter((label) => validLabels.has(label)), + })) + .filter((item) => item.nodes.length > 0) + + const incomingSummary = incoming.summary + .map((item) => ({ + depth: item.depth, + nodes: item.nodes.filter((label) => validLabels.has(label)), + })) + .filter((item) => item.nodes.length > 0) + + return { + rootNodeId: filter.rootNodeId, + nodes, + edges, + outgoingSummary, + incomingSummary, + filters: [filter], + } +} + +/** + * Combines all active filter responses into a single graph payload for the UI. + * @param filters Active filters currently applied to the graph. + * @returns The merged graph payload rendered by the panel. + */ +const collectResponse = (filters: GraphQueryFilter[]): FetchGeneRegulationGraphResponse => { + const safeFilters = filters.length > 0 ? filters : [FALLBACK_FILTER] + const responses = safeFilters.map(collectFilterResponse) + const nodeIds = new Set() + const edgesById = new Map() + + for (const response of responses) { + for (const node of response.nodes) { + nodeIds.add(node.id) + } + + for (const edge of response.edges) { + edgesById.set(edge.id, edge) + } + } + + return { + rootNodeId: safeFilters[0].rootNodeId, + nodes: MOCK_NODES.filter((node) => nodeIds.has(node.id)), + edges: Array.from(edgesById.values()), + outgoingSummary: mergeSummaries(responses.map((response) => response.outgoingSummary)), + incomingSummary: mergeSummaries(responses.map((response) => response.incomingSummary)), + filters: safeFilters, + } +} + +/** + * Resolves the mock graph request used by the gene regulation associations tab. + * @param params Active graph filters that would later be forwarded to the backend. + * @returns A promise with the merged graph payload for all active filters. + */ +export const fetchGeneRegulationGraph = ( + params: FetchGeneRegulationGraphParams +): Promise => + sleep(350).then(() => collectResponse(params.filters)) diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphStyle.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphStyle.ts new file mode 100644 index 00000000..49794222 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphStyle.ts @@ -0,0 +1,16 @@ +import { NodeKind } from './types' + +/** Shared node colors used by the graph and legend. */ +export const NODE_COLORS: Record = { + Gene: '#4f46e5', + miRNA: '#db2777', + CNA: '#f59e0b', + Methylation: '#10b981', + Drug: '#64748b', +} + +/** Shared edge colors used to indicate regulation direction. */ +export const REGULATION_COLORS = { + down: '#dc2626', + up: '#2563eb', +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/legend.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/legend.tsx new file mode 100644 index 00000000..c55e7e42 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/legend.tsx @@ -0,0 +1,71 @@ +import React from 'react' + +type LegendItemProps = { + color: string; + label: string; +} + +/** + * Renders a colored dot item used by the graph legend. + * @param props Component props. + * @param props.color Color shown in the legend swatch. + * @param props.label Visible legend label. + * @returns The node legend item. + */ +export const LegendDot = (props: LegendItemProps): JSX.Element => { + const { color, label } = props + + return ( + + + {label} + + ) +} + +/** + * Renders an arrow item used by the graph edge legend. + * @param props Component props. + * @param props.color Color shown in the legend arrow. + * @param props.label Visible legend label. + * @returns The edge legend item. + */ +export const LegendArrow = (props: LegendItemProps): JSX.Element => { + const { color, label } = props + + return ( + + + + + {label} + + ) +} 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..fb4f50ae --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/mockNetworkData.ts @@ -0,0 +1,180 @@ +import { GraphEdge, GraphNode } from './types' + +/** Base nodes and expansion-only nodes used by the mock graph API. */ +export const MOCK_NODES: GraphNode[] = [ + { id: 'gene_braf', label: 'BRAF', type: 'Gene', size: 66 }, + { id: 'gene_mek1', label: 'MEK1', type: 'Gene', size: 46 }, + { id: 'gene_mek2', label: 'MEK2', type: 'Gene', size: 44 }, + { id: 'gene_erk1', label: 'ERK1', type: 'Gene', size: 42 }, + { id: 'gene_erk2', label: 'ERK2', type: 'Gene', size: 42 }, + { id: 'gene_myc', label: 'MYC', type: 'Gene', size: 38 }, + { id: 'gene_fos', label: 'FOS', type: 'Gene', size: 36 }, + { id: 'gene_ccnd1', label: 'CCND1', type: 'Gene', size: 36 }, + { id: 'gene_elk1', label: 'ELK1', type: 'Gene', size: 34 }, + { id: 'gene_dusp6', label: 'DUSP6', type: 'Gene', size: 34 }, + { id: 'gene_spry2', label: 'SPRY2', type: 'Gene', size: 32 }, + { id: 'gene_map3k8', label: 'MAP3K8', type: 'Gene', size: 32 }, + { id: 'mir_17', label: 'miR-17', type: 'miRNA', size: 28 }, + { id: 'mir_21', label: 'miR-21', type: 'miRNA', size: 28 }, + { id: 'cna_7q34', label: '7q34 gain', type: 'CNA', size: 34 }, + { id: 'meth_rassf1', label: 'RASSF1 meth', type: 'Methylation', size: 34 }, + { id: 'drug_vemurafenib', label: 'Vemurafenib', type: 'Drug', size: 36 }, + { id: 'gene_akt1', label: 'AKT1', type: 'Gene', size: 32 }, + { id: 'gene_grb2', label: 'GRB2', type: 'Gene', size: 30 }, + { id: 'gene_raf1', label: 'RAF1', type: 'Gene', size: 32 }, + { id: 'drug_trametinib', label: 'Trametinib', type: 'Drug', size: 34 }, + { id: 'gene_mapkapk2', label: 'MAPKAPK2', type: 'Gene', size: 30 }, + { id: 'gene_rps6ka1', label: 'RPS6KA1', type: 'Gene', size: 30 }, + { id: 'gene_dusp4', label: 'DUSP4', type: 'Gene', size: 30 }, + { id: 'gene_jun', label: 'JUN', type: 'Gene', size: 32 }, + { id: 'gene_etv4', label: 'ETV4', type: 'Gene', size: 30 }, + { id: 'gene_dusp5', label: 'DUSP5', type: 'Gene', size: 30 }, + { id: 'gene_rps6ka3', label: 'RPS6KA3', type: 'Gene', size: 30 }, + { id: 'gene_elk4', label: 'ELK4', type: 'Gene', size: 30 }, + { id: 'gene_ets1', label: 'ETS1', type: 'Gene', size: 30 }, + { id: 'gene_cdk4', label: 'CDK4', type: 'Gene', size: 30 }, + { id: 'gene_tert', label: 'TERT', type: 'Gene', size: 30 }, + { id: 'gene_max', label: 'MAX', type: 'Gene', size: 30 }, + { id: 'mir_34a', label: 'miR-34a', type: 'miRNA', size: 28 }, + { id: 'gene_junb', label: 'JUNB', type: 'Gene', size: 30 }, + { id: 'gene_atf3', label: 'ATF3', type: 'Gene', size: 30 }, + { id: 'gene_mmp9', label: 'MMP9', type: 'Gene', size: 30 }, + { id: 'gene_cdk6', label: 'CDK6', type: 'Gene', size: 30 }, + { id: 'gene_rb1', label: 'RB1', type: 'Gene', size: 30 }, + { id: 'drug_palbociclib', label: 'Palbociclib', type: 'Drug', size: 34 }, + { id: 'gene_egr1', label: 'EGR1', type: 'Gene', size: 30 }, + { id: 'gene_srf', label: 'SRF', type: 'Gene', size: 30 }, + { id: 'gene_fgfr1', label: 'FGFR1', type: 'Gene', size: 30 }, + { id: 'gene_spry4', label: 'SPRY4', type: 'Gene', size: 30 }, + { id: 'gene_egfr', label: 'EGFR', type: 'Gene', size: 32 }, + { id: 'gene_fgfr2', label: 'FGFR2', type: 'Gene', size: 30 }, + { id: 'gene_nfkb1', label: 'NFKB1', type: 'Gene', size: 30 }, + { id: 'gene_rela', label: 'RELA', type: 'Gene', size: 30 }, + { id: 'gene_pten', label: 'PTEN', type: 'Gene', size: 32 }, + { id: 'gene_e2f1', label: 'E2F1', type: 'Gene', size: 30 }, + { id: 'gene_bcl2l11', label: 'BCL2L11', type: 'Gene', size: 30 }, + { id: 'gene_pdcd4', label: 'PDCD4', type: 'Gene', size: 30 }, + { id: 'gene_tpm1', label: 'TPM1', type: 'Gene', size: 30 }, + { id: 'gene_braf_fusion', label: 'BRAF fusion', type: 'Gene', size: 32 }, + { id: 'gene_kiaa1549', label: 'KIAA1549', type: 'Gene', size: 30 }, + { id: 'gene_rassf1', label: 'RASSF1', type: 'Gene', size: 30 }, + { id: 'gene_cdkn2a', label: 'CDKN2A', type: 'Gene', size: 30 }, + { id: 'gene_axl', label: 'AXL', type: 'Gene', size: 30 }, + { id: 'gene_dusp1', label: 'DUSP1', type: 'Gene', size: 30 }, +] + +/** Base relationships rendered by the initial BRAF graph query. */ +export const MOCK_EDGES: GraphEdge[] = [ + { id: 'e_1', source: 'gene_braf', target: 'gene_mek1', correlation: 0.92 }, + { id: 'e_2', source: 'gene_braf', target: 'gene_mek2', correlation: 0.83 }, + { id: 'e_3', source: 'gene_braf', target: 'gene_erk1', correlation: 0.78 }, + { id: 'e_4', source: 'gene_braf', target: 'gene_dusp6', correlation: 0.72 }, + { id: 'e_5', source: 'gene_braf', target: 'gene_fos', correlation: 0.67 }, + + { id: 'e_6', source: 'gene_mek1', target: 'gene_erk1', correlation: 0.88 }, + { id: 'e_7', source: 'gene_mek1', target: 'gene_erk2', correlation: 0.79 }, + { id: 'e_8', source: 'gene_mek1', target: 'gene_elk1', correlation: 0.76 }, + { id: 'e_9', source: 'gene_mek2', target: 'gene_erk2', correlation: 0.81 }, + + { id: 'e_10', source: 'gene_erk1', target: 'gene_myc', correlation: 0.81 }, + { id: 'e_11', source: 'gene_erk1', target: 'gene_fos', correlation: 0.79 }, + { id: 'e_12', source: 'gene_erk2', target: 'gene_ccnd1', correlation: 0.68 }, + { id: 'e_13', source: 'gene_elk1', target: 'gene_myc', correlation: 0.61 }, + { id: 'e_14', source: 'gene_fos', target: 'gene_ccnd1', correlation: 0.58 }, + + { id: 'e_15', source: 'mir_17', target: 'gene_braf', correlation: -0.66 }, + { id: 'e_16', source: 'mir_21', target: 'gene_mek1', correlation: -0.57 }, + { id: 'e_17', source: 'cna_7q34', target: 'gene_braf', correlation: 0.73 }, + { id: 'e_18', source: 'meth_rassf1', target: 'gene_braf', correlation: -0.54 }, + { id: 'e_19', source: 'drug_vemurafenib', target: 'gene_braf', correlation: -0.89 }, + + { id: 'e_20', source: 'gene_map3k8', target: 'gene_mek1', correlation: 0.55 }, + { id: 'e_21', source: 'gene_braf', target: 'gene_map3k8', correlation: 0.52 }, + + { id: 'e_22', source: 'gene_spry2', target: 'gene_braf', correlation: -0.32 }, + { id: 'e_23', source: 'drug_vemurafenib', target: 'gene_mek1', correlation: -0.41 }, +] + +/** Expansion branches available when the user right-clicks a visible root node. */ +export const MOCK_EXPANSION_EDGES_BY_ROOT: Record = { + gene_mek1: [ + { id: 'e_exp_mek1_1', source: 'gene_raf1', target: 'gene_mek1', correlation: 0.86 }, + { id: 'e_exp_mek1_2', source: 'gene_grb2', target: 'gene_mek1', correlation: 0.62 }, + { id: 'e_exp_mek1_3', source: 'gene_mek1', target: 'gene_akt1', correlation: 0.74 }, + { id: 'e_exp_mek1_4', source: 'drug_trametinib', target: 'gene_mek1', correlation: -0.91 }, + { id: 'e_exp_mek1_5', source: 'gene_akt1', target: 'gene_myc', correlation: 0.58 }, + ], + gene_mek2: [ + { id: 'e_exp_mek2_1', source: 'gene_mek2', target: 'gene_mapkapk2', correlation: 0.73 }, + { id: 'e_exp_mek2_2', source: 'gene_mek2', target: 'gene_rps6ka1', correlation: 0.69 }, + { id: 'e_exp_mek2_3', source: 'gene_dusp4', target: 'gene_mek2', correlation: -0.61 }, + ], + gene_erk1: [ + { id: 'e_exp_erk1_1', source: 'gene_erk1', target: 'gene_jun', correlation: 0.84 }, + { id: 'e_exp_erk1_2', source: 'gene_erk1', target: 'gene_etv4', correlation: 0.78 }, + { id: 'e_exp_erk1_3', source: 'gene_dusp5', target: 'gene_erk1', correlation: -0.71 }, + { id: 'e_exp_erk1_4', source: 'gene_jun', target: 'gene_fos', correlation: 0.65 }, + ], + gene_erk2: [ + { id: 'e_exp_erk2_1', source: 'gene_erk2', target: 'gene_rps6ka3', correlation: 0.82 }, + { id: 'e_exp_erk2_2', source: 'gene_erk2', target: 'gene_elk4', correlation: 0.73 }, + { id: 'e_exp_erk2_3', source: 'gene_ets1', target: 'gene_erk2', correlation: 0.59 }, + ], + gene_myc: [ + { id: 'e_exp_myc_1', source: 'gene_myc', target: 'gene_cdk4', correlation: 0.82 }, + { id: 'e_exp_myc_2', source: 'gene_myc', target: 'gene_tert', correlation: 0.76 }, + { id: 'e_exp_myc_3', source: 'gene_max', target: 'gene_myc', correlation: 0.88 }, + { id: 'e_exp_myc_4', source: 'mir_34a', target: 'gene_myc', correlation: -0.79 }, + ], + gene_fos: [ + { id: 'e_exp_fos_1', source: 'gene_fos', target: 'gene_junb', correlation: 0.84 }, + { id: 'e_exp_fos_2', source: 'gene_fos', target: 'gene_atf3', correlation: 0.76 }, + { id: 'e_exp_fos_3', source: 'gene_junb', target: 'gene_mmp9', correlation: 0.71 }, + { id: 'e_exp_fos_4', source: 'mir_21', target: 'gene_fos', correlation: -0.62 }, + ], + gene_ccnd1: [ + { id: 'e_exp_ccnd1_1', source: 'gene_ccnd1', target: 'gene_cdk6', correlation: 0.81 }, + { id: 'e_exp_ccnd1_2', source: 'gene_ccnd1', target: 'gene_rb1', correlation: -0.64 }, + { id: 'e_exp_ccnd1_3', source: 'drug_palbociclib', target: 'gene_ccnd1', correlation: -0.88 }, + ], + gene_elk1: [ + { id: 'e_exp_elk1_1', source: 'gene_elk1', target: 'gene_egr1', correlation: 0.79 }, + { id: 'e_exp_elk1_2', source: 'gene_srf', target: 'gene_elk1', correlation: 0.61 }, + ], + gene_dusp6: [ + { id: 'e_exp_dusp6_1', source: 'gene_dusp6', target: 'gene_fgfr1', correlation: -0.75 }, + { id: 'e_exp_dusp6_2', source: 'gene_dusp6', target: 'gene_spry4', correlation: 0.66 }, + ], + gene_spry2: [ + { id: 'e_exp_spry2_1', source: 'gene_spry2', target: 'gene_egfr', correlation: -0.72 }, + { id: 'e_exp_spry2_2', source: 'gene_fgfr2', target: 'gene_spry2', correlation: 0.69 }, + ], + gene_map3k8: [ + { id: 'e_exp_map3k8_1', source: 'gene_map3k8', target: 'gene_nfkb1', correlation: 0.8 }, + { id: 'e_exp_map3k8_2', source: 'gene_map3k8', target: 'gene_rela', correlation: 0.73 }, + ], + mir_17: [ + { id: 'e_exp_mir17_1', source: 'mir_17', target: 'gene_pten', correlation: -0.82 }, + { id: 'e_exp_mir17_2', source: 'mir_17', target: 'gene_e2f1', correlation: -0.69 }, + { id: 'e_exp_mir17_3', source: 'gene_e2f1', target: 'gene_bcl2l11', correlation: 0.64 }, + ], + mir_21: [ + { id: 'e_exp_mir21_1', source: 'mir_21', target: 'gene_pdcd4', correlation: -0.86 }, + { id: 'e_exp_mir21_2', source: 'mir_21', target: 'gene_pten', correlation: -0.72 }, + { id: 'e_exp_mir21_3', source: 'mir_21', target: 'gene_tpm1', correlation: -0.61 }, + ], + cna_7q34: [ + { id: 'e_exp_cna7q34_1', source: 'cna_7q34', target: 'gene_braf_fusion', correlation: 0.93 }, + { id: 'e_exp_cna7q34_2', source: 'gene_kiaa1549', target: 'gene_braf_fusion', correlation: 0.77 }, + { id: 'e_exp_cna7q34_3', source: 'gene_braf_fusion', target: 'gene_braf', correlation: 0.84 }, + ], + meth_rassf1: [ + { id: 'e_exp_meth_rassf1_1', source: 'meth_rassf1', target: 'gene_rassf1', correlation: -0.88 }, + { id: 'e_exp_meth_rassf1_2', source: 'gene_rassf1', target: 'gene_cdkn2a', correlation: 0.62 }, + ], + drug_vemurafenib: [ + { id: 'e_exp_vemurafenib_1', source: 'drug_vemurafenib', target: 'gene_axl', correlation: 0.72 }, + { id: 'e_exp_vemurafenib_2', source: 'drug_vemurafenib', target: 'gene_egfr', correlation: 0.68 }, + { id: 'e_exp_vemurafenib_3', source: 'gene_dusp1', target: 'gene_braf', correlation: -0.55 }, + ], +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/types.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/types.ts new file mode 100644 index 00000000..2340bf0f --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/types.ts @@ -0,0 +1,85 @@ +/** Supported node categories shown in the regulation graph. */ +export type NodeKind = 'Gene' | 'miRNA' | 'CNA' | 'Methylation' | 'Drug' + +/** Traversal direction used when exploring associations from a root node. */ +export type TraversalMode = 'outgoing' | 'incoming' | 'both' + +/** Graph node rendered by Cytoscape. */ +export type GraphNode = { + /** Stable node identifier used by Cytoscape and the mock API. */ + id: string; + /** Human-readable node label shown in the graph and side panels. */ + label: string; + /** Biological or domain category used to color the node. */ + type: NodeKind; + /** Visual size used by the graph layout. */ + size: number; +} + +/** Graph edge rendered by Cytoscape. */ +export type GraphEdge = { + /** Stable edge identifier used to keep selections in sync. */ + id: string; + /** Source node identifier. */ + source: string; + /** Target node identifier. */ + target: string; + /** Correlation score used to infer direction color and visibility. */ + correlation: number; +} + +/** Filter payload used to query one graph root and its expansion settings. */ +export type GraphQueryFilter = { + /** Root node from which the traversal starts. */ + rootNodeId: string; + /** Minimum absolute correlation required for edges to stay visible. */ + threshold: number; + /** Which direction should be traversed from the root node. */ + traversalMode: TraversalMode; + /** Maximum number of levels to traverse from the root node. */ + maxLevels: number; +} + +/** Edge selection details rendered in the side panel after user interaction. */ +export type SelectedEdgeInfo = { + /** Stable edge identifier. */ + id: string; + /** Visible label of the source node. */ + source: string; + /** Visible label of the target node. */ + target: string; + /** Correlation associated with the selected edge. */ + correlation: number; + /** Regulation label derived from the correlation sign. */ + direction: 'Down-regulation' | 'Up-regulation'; +} + +/** Group of nodes found at the same traversal level. */ +export type DepthSummaryItem = { + /** Traversal level relative to the selected root. */ + depth: number; + /** Visible labels of the nodes found at that level. */ + nodes: string[]; +} + +/** Request payload sent by the panel when asking for graph data. */ +export type FetchGeneRegulationGraphParams = { + /** Active root filters, including user-created expansions. */ + filters: GraphQueryFilter[]; +} + +/** Response payload consumed by the regulation graph UI. */ +export type FetchGeneRegulationGraphResponse = { + /** Root node of the primary filter used to center the graph. */ + rootNodeId: string; + /** Nodes included after applying the active filters. */ + nodes: GraphNode[]; + /** Edges included after applying the active filters. */ + edges: GraphEdge[]; + /** Outgoing traversal summary for the current filter set. */ + outgoingSummary: DepthSummaryItem[]; + /** Incoming traversal summary for the current filter set. */ + incomingSummary: DepthSummaryItem[]; + /** Filters effectively used to generate the response. */ + filters: GraphQueryFilter[]; +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/useGeneRegulationGraphQuery.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/useGeneRegulationGraphQuery.ts new file mode 100644 index 00000000..3678492d --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/useGeneRegulationGraphQuery.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react' +import { fetchGeneRegulationGraph } from './graphApi' +import { FetchGeneRegulationGraphParams, FetchGeneRegulationGraphResponse } from './types' + +/** State handled by the gene regulation graph query hook. */ +type GeneRegulationGraphQueryState = { + /** Resolved graph payload returned by the mock API. */ + data: FetchGeneRegulationGraphResponse | null; + /** Indicates whether the query is currently in flight. */ + loading: boolean; + /** Error message displayed by the panel when the query fails. */ + error: string | null; +} + +/** + * Fetches graph data for the currently applied list of filters. + * @param params Request payload with all active graph filters. + * @returns Loading, error, and graph data state for the panel. + */ +export const useGeneRegulationGraphQuery = (params: FetchGeneRegulationGraphParams) => { + const [state, setState] = useState({ + data: null, + loading: true, + error: null, + }) + + useEffect(() => { + let cancelled = false + + setState({ + data: null, + loading: true, + error: null, + }) + + fetchGeneRegulationGraph(params) + .then((data) => { + if (cancelled) { return } + + setState({ + data, + loading: false, + error: null, + }) + }) + .catch((error) => { + if (cancelled) { return } + + setState({ + data: null, + loading: false, + error: error instanceof Error ? error.message : 'Unknown error', + }) + }) + + return () => { + cancelled = true + } + }, [params]) + + return state +} diff --git a/src/frontend/static/frontend/src/components/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,