diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx index 1f173352..63f697d8 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/CurrentMoleculeDetails.tsx @@ -7,11 +7,11 @@ import { MoleculeGeneralInformation } from './MoleculeGeneralInformation' import { PathwaysInformation } from './genes/PathwaysInformation' import { MirnaInteractionsPanel } from './MirnaInteractionsPanel' import { GeneOntologyPanel } from './gene-ontology/GeneOntologyPanel' -import { GeneAssociationsNetworkPanel } from './genes/GeneAssociationsNetworkPanel' import { MiRNADrugsPanel } from '../../../pipeline/experiment-result/gene-gem-details/MiRNADrugsPanel' import { MiRNADiseasesPanel } from '../../../pipeline/experiment-result/gene-gem-details/MiRNADiseasesPanel' import { ActionableCancerGenesPanel } from './genes/ActionableCancerGenesPanel' -import { GeneExpressionRegulationNetworkPanel } from './genes/GeneAssociationsNetwork' +/* import { 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 @@ -53,8 +53,9 @@ export const CurrentMoleculeDetails = (props: CurrentMoleculeDetailsProps) => { return case ActiveBiomarkerMoleculeItemMenu.GENE_ONTOLOGY: return - case ActiveBiomarkerMoleculeItemMenu.GENE_REGULATION_ASSOCIATIONS: - return + /* Todo: implement wEhen experiment regulation associations are available + case ActiveBiomarkerMolecuWleItemMenu.GENE_REGULATION_ASSOCIATIONS: + return */ case ActiveBiomarkerMoleculeItemMenu.DISEASES: return case ActiveBiomarkerMoleculeItemMenu.DRUGS: diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/MoleculesDetailsMenu.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/MoleculesDetailsMenu.tsx index ec0b914c..e0318e2e 100644 --- a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/MoleculesDetailsMenu.tsx +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/MoleculesDetailsMenu.tsx @@ -63,13 +63,14 @@ export const MoleculesDetailsMenu = (props: MoleculesDetailsMenuProps) => { popupInfo: 'Gene Ontology (GO) is a powerful tool for understanding the biological processes, molecular functions, and cellular components associated with a gene', isVisible: isGene }, + /* TODO: implement when experiment regulation associations are available { name: 'Gene regulation associations', onClick: () => props.setActiveItem(ActiveBiomarkerMoleculeItemMenu.GENE_REGULATION_ASSOCIATIONS), isActive: props.activeItem === ActiveBiomarkerMoleculeItemMenu.GENE_REGULATION_ASSOCIATIONS, popupInfo: 'Gene regulation associations provide insights into the regulatory relationships between genes, helping to unravel the complex mechanisms that control gene expression and cellular function', isVisible: isGene - }, + }, */ // TODO: implement // { // name: 'Actionable/Cancer genes', diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/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..a7a61c13 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationAssociationsPanel.tsx @@ -0,0 +1,203 @@ +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. + * @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..cfb8d778 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationFiltersPanel.tsx @@ -0,0 +1,237 @@ +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. + * @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..1c9b4c76 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/GeneRegulationGraph.tsx @@ -0,0 +1,690 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import cytoscape, { Core, ElementDefinition } from 'cytoscape' +import { NODE_COLORS, REGULATION_COLORS } from './graphStyle' +import { LegendArrow, LegendDot } from './legend' +import { FetchGeneRegulationGraphResponse, GraphQueryFilter, SelectedEdgeInfo, TraversalMode } from './types' + +const MIN_ZOOM = 0.4 +const MAX_ZOOM = 2 +const ZOOM_STEP = 1.2 + +const traversalLabels: Record = { + outgoing: 'Regulates', + incoming: 'Regulated by', + both: 'Both', +} + +/** + * Resolves the edge color according to the correlation sign. + * @param correlation Correlation value stored in the graph edge. + * @returns The color used to render the edge. + */ +const getEdgeColor = (correlation: number) => { + if (correlation < 0) { return REGULATION_COLORS.down } + + return REGULATION_COLORS.up +} + +/** + * Maps the edge correlation strength into the rendered edge opacity. + * @param correlation Correlation value stored in the graph edge. + * @returns The opacity used to render the edge. + */ +const getEdgeOpacity = (correlation: number) => { + const abs = Math.abs(correlation) + + if (abs >= 0.8) { return 0.95 } + + if (abs >= 0.6) { return 0.8 } + + return 0.7 +} + +/** + * Maps the edge correlation strength into the rendered edge width. + * @param correlation Correlation value stored in the graph edge. + * @returns The width used to render the edge. + */ +const getEdgeWidth = (correlation: number) => { + const abs = Math.abs(correlation) + + if (abs >= 0.9) { return 6 } + + if (abs >= 0.8) { return 5 } + + if (abs >= 0.7) { return 4 } + + return 3 +} + +/** + * Converts the edge correlation sign into the label shown by the UI. + * @param correlation Correlation value stored in the graph edge. + * @returns The semantic direction label shown in tooltips and panels. + */ +const getDirectionLabel = (correlation: number): SelectedEdgeInfo['direction'] => { + if (correlation < 0) { return 'Down-regulation' } + + return 'Up-regulation' +} + +/** Tooltip state used while hovering graph edges. */ +type TooltipState = { + /** Whether the tooltip is currently visible. */ + visible: boolean; + /** Horizontal position relative to the graph container. */ + x: number; + /** Vertical position relative to the graph container. */ + y: number; + /** Text shown inside the tooltip. */ + content: string; +} + +/** Context menu state used when expanding a node from the graph. */ +type ContextMenuState = { + /** Whether the expansion menu is currently visible. */ + visible: boolean; + /** Horizontal position relative to the graph container. */ + x: number; + /** Vertical position relative to the graph container. */ + y: number; + /** Node identifier being expanded. */ + nodeId: string; + /** Visible label of the node being expanded. */ + nodeLabel: string; + /** Threshold draft shown in the expansion form. */ + threshold: number; + /** Traversal mode draft shown in the expansion form. */ + traversalMode: TraversalMode; + /** Max depth draft shown in the expansion form. */ + maxLevels: number; +} + +/** Props accepted by the Cytoscape-based regulation graph. */ +interface GeneRegulationGraphProps { + /** Graph payload currently rendered in Cytoscape. */ + data: FetchGeneRegulationGraphResponse | null; + /** Height assigned to the graph area. */ + height?: number | string; + /** Width assigned to the graph area. */ + width?: number | string; + /** Edges currently selected by the user. */ + selectedEdges: SelectedEdgeInfo[]; + /** Callback used to sync edge selections with the side panel. */ + onSelectedEdgesChange: (edges: SelectedEdgeInfo[]) => void; + /** Node identifiers already expanded into the current request. */ + expandedNodeIds: string[]; + /** Default expansion values reused when opening the node context menu. */ + defaultExpansionFilter: Omit; + /** Callback used to append a new node expansion to the query. */ + onExpandNode: (filter: GraphQueryFilter) => void; +} + +/** + * Renders the Cytoscape graph along with local zoom and expansion controls. + * @param props Component props. + * @returns The rendered graph with overlays for legend, zoom and node expansion. + */ +export const GeneRegulationGraph = (props: GeneRegulationGraphProps): JSX.Element => { + const { + data, + height = 650, + width = '100%', + selectedEdges, + onSelectedEdgesChange, + expandedNodeIds, + defaultExpansionFilter, + onExpandNode, + } = props + const containerRef = useRef(null) + const cyRef = useRef(null) + const defaultExpansionFilterRef = useRef(defaultExpansionFilter) + + const [tooltip, setTooltip] = useState({ + visible: false, + x: 0, + y: 0, + content: '', + }) + const [contextMenu, setContextMenu] = useState({ + visible: false, + x: 0, + y: 0, + nodeId: '', + nodeLabel: '', + threshold: defaultExpansionFilter.threshold, + traversalMode: defaultExpansionFilter.traversalMode, + maxLevels: defaultExpansionFilter.maxLevels, + }) + + useEffect(() => { + defaultExpansionFilterRef.current = defaultExpansionFilter + }, [defaultExpansionFilter]) + + const elements = useMemo(() => { + if (!data) { return [] } + + const nodes: ElementDefinition[] = data.nodes.map((node) => ({ + data: { + ...node, + }, + })) + + const edges: ElementDefinition[] = data.edges.map((edge) => ({ + data: { + ...edge, + edgeColor: getEdgeColor(edge.correlation), + edgeOpacity: getEdgeOpacity(edge.correlation), + edgeWidth: getEdgeWidth(edge.correlation), + directionLabel: getDirectionLabel(edge.correlation), + }, + })) + + return [...nodes, ...edges] + }, [data]) + + useEffect(() => { + if (!containerRef.current || !data) { return } + + if (cyRef.current) { + cyRef.current.destroy() + cyRef.current = null + } + + const cy = cytoscape({ + container: containerRef.current, + elements, + wheelSensitivity: 0.18, + minZoom: 0.4, + maxZoom: 2, + style: [ + { + selector: 'node', + style: { + label: 'data(label)', + width: 'data(size)', + height: 'data(size)', + shape: 'ellipse', + 'background-color': '#64748b', + color: '#ffffff', + 'font-size': 12, + 'font-weight': 600, + 'text-valign': 'center', + 'text-halign': 'center', + 'text-wrap': 'wrap', + 'text-max-width': 90 as any, + 'text-outline-color': '#475569', + 'text-outline-width': 3, + 'border-width': 2, + 'border-color': '#ffffff', + }, + }, + { + selector: 'node[type = "Gene"]', + style: { + 'background-color': NODE_COLORS.Gene, + 'text-outline-color': NODE_COLORS.Gene, + }, + }, + { + selector: 'node[type = "miRNA"]', + style: { + 'background-color': NODE_COLORS.miRNA, + 'text-outline-color': NODE_COLORS.miRNA, + }, + }, + { + selector: 'node[type = "CNA"]', + style: { + 'background-color': NODE_COLORS.CNA, + 'text-outline-color': NODE_COLORS.CNA, + }, + }, + { + selector: 'node[type = "Methylation"]', + style: { + 'background-color': NODE_COLORS.Methylation, + 'text-outline-color': NODE_COLORS.Methylation, + }, + }, + { + selector: 'node[type = "Drug"]', + style: { + 'background-color': NODE_COLORS.Drug, + 'text-outline-color': NODE_COLORS.Drug, + }, + }, + { + selector: 'edge', + style: { + width: 'data(edgeWidth)', + 'line-color': 'data(edgeColor)', + opacity: 'data(edgeOpacity)' as any, + 'curve-style': 'bezier', + 'target-arrow-shape': 'triangle', + 'target-arrow-color': 'data(edgeColor)', + 'arrow-scale': 1.15, + }, + }, + { + selector: 'edge.edge-picked', + style: { + opacity: 0.82, + 'underlay-color': '#64748b', + 'underlay-opacity': 0.22, + 'underlay-padding': 5, + width: 'mapData(edgeWidth, 3, 6, 4, 6)', + 'z-index': 999, + }, + }, + { + selector: 'node.root-node', + style: { + 'border-color': '#f59e0b', + 'border-width': 6, + }, + }, + ], + layout: { + name: 'cose', + animate: true, + randomize: true, + fit: true, + padding: 40, + gravity: 1, + nodeRepulsion: 9000, + idealEdgeLength: 90, + componentSpacing: 80, + }, + }) + + cyRef.current = cy + + for (const filter of data.filters) { + const expandedRootNode = cy.getElementById(filter.rootNodeId) + + if (expandedRootNode.nonempty()) { + expandedRootNode.addClass('root-node') + } + } + + const rootNode = cy.getElementById(data.rootNodeId) + + if (rootNode.nonempty()) { + cy.center(rootNode) + } + + /** Synchronizes the selected edge list shown in the side panel. */ + const syncSelectedEdges = () => { + const picked = cy + .edges('.edge-picked') + .toArray() + .map((edge: any) => ({ + id: edge.id(), + source: edge.source().data('label') || edge.data('source'), + target: edge.target().data('label') || edge.data('target'), + correlation: edge.data('correlation'), + direction: edge.data('directionLabel'), + })) + + onSelectedEdgesChange(picked) + } + + cy.on('tap', 'edge', (evt) => { + const edge = evt.target + + setContextMenu((prev) => ({ + ...prev, + visible: false, + })) + + if (edge.hasClass('edge-picked')) { + edge.removeClass('edge-picked') + } else { + edge.addClass('edge-picked') + } + + syncSelectedEdges() + }) + + cy.on('tap', 'node', () => { + setContextMenu((prev) => ({ + ...prev, + visible: false, + })) + }) + + cy.on('cxttap', 'node', (evt) => { + evt.originalEvent?.preventDefault() + + const node = evt.target + + setTooltip((prev) => ({ + ...prev, + visible: false, + })) + + setContextMenu({ + visible: true, + x: evt.renderedPosition?.x ?? 0, + y: evt.renderedPosition?.y ?? 0, + nodeId: node.id(), + nodeLabel: node.data('label') || node.id(), + threshold: defaultExpansionFilterRef.current.threshold, + traversalMode: defaultExpansionFilterRef.current.traversalMode, + maxLevels: defaultExpansionFilterRef.current.maxLevels, + }) + }) + + cy.on('mouseover', 'edge', (evt) => { + const edge = evt.target + const correlation = edge.data('correlation') + const direction = edge.data('directionLabel') + + setTooltip({ + visible: true, + x: evt.renderedPosition?.x ?? 0, + y: evt.renderedPosition?.y ?? 0, + content: `${direction} | correlation: ${correlation}`, + }) + }) + + cy.on('mousemove', 'edge', (evt) => { + setTooltip((prev) => ({ + ...prev, + x: evt.renderedPosition?.x ?? prev.x, + y: evt.renderedPosition?.y ?? prev.y, + visible: true, + })) + }) + + cy.on('mouseout', 'edge', () => { + setTooltip((prev) => ({ + ...prev, + visible: false, + })) + }) + + return () => { + cy.destroy() + cyRef.current = null + } + }, [data, elements, onSelectedEdgesChange]) + + useEffect(() => { + const cy = cyRef.current + + if (!cy) { return } + + cy.edges().removeClass('edge-picked') + + for (const edge of selectedEdges) { + const cyEdge = cy.getElementById(edge.id) + + if (cyEdge.nonempty()) { + cyEdge.addClass('edge-picked') + } + } + }, [selectedEdges]) + + /** + * Applies a centered zoom step from the overlay controls. + * @param direction Zoom direction requested by the user. + */ + const handleGraphZoom = (direction: 'in' | 'out') => { + const cy = cyRef.current + + if (!cy) { return } + + const nextZoom = direction === 'in' + ? Math.min(MAX_ZOOM, cy.zoom() * ZOOM_STEP) + : Math.max(MIN_ZOOM, cy.zoom() / ZOOM_STEP) + + cy.zoom({ + level: nextZoom, + renderedPosition: { + x: cy.width() / 2, + y: cy.height() / 2, + }, + }) + } + + return ( +
event.preventDefault()} + > +
+ +
+ + + +
+ +
+
+ Nodes: + + + + + +
+ +
+ Edges: + + +
+
+ + {tooltip.visible && ( +
+ {tooltip.content} +
+ )} + + {contextMenu.visible && ( +
+
+ {contextMenu.nodeLabel} +
+ +
+ Configure this expansion before adding it to the graph request. +
+ +
+
+
+ Threshold + {contextMenu.threshold.toFixed(1)} +
+ setContextMenu((prev) => ({ + ...prev, + threshold: Number(Number(event.target.value).toFixed(1)), + }))} + /> +
+ +
+
+ Depth + {contextMenu.maxLevels} +
+ setContextMenu((prev) => ({ + ...prev, + maxLevels: Number(event.target.value), + }))} + /> +
+ +
+ Regulation mode + +
+
+ + +
+ )} +
+ ) +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/LevelBadge.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/LevelBadge.tsx new file mode 100644 index 00000000..89861d57 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/LevelBadge.tsx @@ -0,0 +1,51 @@ +import React from 'react' + +const LEVEL_COLORS = { + level1: '#0ea5e9', + level2: '#22c55e', + level3: '#a855f7', + other: '#94a3b8', +} + +/** LevelBadge props. */ +interface LevelBadgeProps { + /** Depth level represented by the badge. */ + depth: number; + /** Whether the badge should use the incoming traversal style. */ + incoming?: boolean; +} + +/** + * Displays the visual badge used to identify a traversal depth level. + * @param props Component props. + * @returns The colored level badge rendered inline. + */ +export const LevelBadge = (props: LevelBadgeProps): JSX.Element => { + const { + depth, + incoming = false, + } = props + const color = + depth === 1 + ? LEVEL_COLORS.level1 + : depth === 2 + ? LEVEL_COLORS.level2 + : depth === 3 + ? LEVEL_COLORS.level3 + : LEVEL_COLORS.other + + return ( + + ) +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/SelectedEdgesPanel.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/SelectedEdgesPanel.tsx new file mode 100644 index 00000000..652c51e6 --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/SelectedEdgesPanel.tsx @@ -0,0 +1,107 @@ +import React from 'react' +import { Button, Icon } from 'semantic-ui-react' +import { SelectedEdgeInfo } from './types' + +/** SelectedEdgesPanel props. */ +interface SelectedEdgesPanelProps { + /** Edges currently selected in the graph. */ + selectedEdges: SelectedEdgeInfo[]; + /** Callback used to deselect a single edge. */ + onRemoveEdge: (edgeId: string) => void; + /** Callback used to clear the entire edge selection. */ + onClearAll: () => void; +} + +/** + * Lists the edges selected by the user and exposes quick cleanup actions. + * @param props Component props. + * @returns The selected edges panel rendered below the graph. + */ +export const SelectedEdgesPanel = (props: SelectedEdgesPanelProps): JSX.Element => { + const { + selectedEdges, + onRemoveEdge, + onClearAll, + } = props + return ( +
+
+
Selected edges
+ + +
+ + {selectedEdges.length === 0 + ? ( +
+ Click multiple edges to inspect each correlation value. +
+ ) + : ( +
+ {selectedEdges.map((edge) => ( +
+
+
+ {edge.source} -> {edge.target} +
+
Direction: {edge.direction}
+
Correlation: {edge.correlation}
+
+ +
+ ))} +
+ )} +
+ ) +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/TraversalSummaryPanel.tsx b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/TraversalSummaryPanel.tsx new file mode 100644 index 00000000..3e99704e --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/TraversalSummaryPanel.tsx @@ -0,0 +1,140 @@ +import React from 'react' +import { LevelBadge } from './LevelBadge' +import { DepthSummaryItem, TraversalMode } from './types' + +/** TraversalSummaryPanel props. */ +interface TraversalSummaryPanelProps { + /** Active traversal mode applied to the root filter. */ + traversalMode: TraversalMode; + /** Visible label of the current root node. */ + selectedRootNode: string | null; + /** Maximum depth configured for the root filter. */ + maxLevels: number; + /** Outgoing traversal summary grouped by level. */ + depthSummary: DepthSummaryItem[]; + /** Incoming traversal summary grouped by level. */ + incomingSummary: DepthSummaryItem[]; +} + +/** + * Summarizes the traversal results for the active root graph filter. + * @param props Component props. + * @returns The traversal summary panel rendered below the graph. + */ +export const TraversalSummaryPanel = (props: TraversalSummaryPanelProps): JSX.Element => { + const { + traversalMode, + selectedRootNode, + maxLevels, + depthSummary, + incomingSummary, + } = props + return ( +
+
+ {traversalMode === 'outgoing' + ? 'Depth levels' + : traversalMode === 'incoming' + ? 'Upstream regulation levels' + : 'Relationship levels'} +
+ + {!selectedRootNode + ? ( +
+ Select a node to calculate levels. +
+ ) + : ( +
+
+ Root node: {selectedRootNode} +
+ +
+ Search limit: {maxLevels} level{maxLevels > 1 ? 's' : ''} +
+ + {(traversalMode === 'outgoing' || traversalMode === 'both') && ( +
+
+ Regulates +
+ + {depthSummary.length === 0 + ? ( +
+ No visible regulated nodes match the current filter. +
+ ) + : ( + depthSummary.map((item) => ( +
+
+ + Level {item.depth} +
+
{item.nodes.join(', ')}
+
+ )) + )} +
+ )} + + {(traversalMode === 'incoming' || traversalMode === 'both') && ( +
+
+ Regulated by +
+ + {incomingSummary.length === 0 + ? ( +
+ No visible regulators match the current filter. +
+ ) + : ( + incomingSummary.map((item) => ( +
+
+ + Level {item.depth} +
+
{item.nodes.join(', ')}
+
+ )) + )} +
+ )} +
+ )} +
+ ) +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphApi.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/graphApi.ts 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..de4a61de --- /dev/null +++ b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/legend.tsx @@ -0,0 +1,70 @@ +import React from 'react' + +/** LegendDotProps props. */ +type LegendDotProps = { + /** Color shown in the legend swatch. */ + color: string; + /** Visible legend label. */ + label: string; +} + +/** + * Renders a colored dot item used by the graph legend. + * @param props Component props. + * @returns The node legend item. + */ +export const LegendDot = (props: LegendDotProps): JSX.Element => { + const { color, label } = props + + return ( + + + {label} + + ) +} + +/** + * Renders an arrow item used by the graph edge legend. + * @param props Component props. + * @returns The edge legend item. + */ +export const LegendArrow = (props: LegendDotProps): JSX.Element => { + const { color, label } = props + + return ( + + + + + {label} + + ) +} diff --git a/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/mockNetworkData.ts b/src/frontend/static/frontend/src/components/biomarkers/biomarker-details-modal/molecules/genes/gene-association-network/mockNetworkData.ts 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/pipeline/experiment-result/gene-gem-details/stats/DensityChart.tsx b/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/stats/DensityChart.tsx index 9b73a71e..c45145b8 100644 --- a/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/stats/DensityChart.tsx +++ b/src/frontend/static/frontend/src/components/pipeline/experiment-result/gene-gem-details/stats/DensityChart.tsx @@ -12,10 +12,9 @@ import { BarChart } from 'recharts' -/** - * Component's props - */ +/** DensityChart props. */ interface DensityChartProps { + /** Data series used to build the density chart. */ dataObjects: StatChartData[], /** True to show bars in density chart */ showBars: boolean, @@ -55,11 +54,26 @@ type ChartData = { /** Data for a Bar component. */ type BarData = { + /** Stroke color used to render the chart outline. */ strokeColor: string | undefined, + /** Fill color used to render the chart bars. */ fillColor: string | undefined, + /** Bars rendered for the current chart. */ data: ChartData[] } +/** CustomTooltip props. */ +interface CustomTooltipProps { + /** True when the tooltip is active. */ + active?: boolean, + /** Tooltip payload provided by Recharts. */ + payload?: any[], + /** Primary color used for the tooltip title. */ + color: string | undefined, + /** Secondary color used when rendering the comparison series. */ + color2: string | null | undefined +} + /** * Renders a Density chart * @param props Component's props @@ -109,14 +123,12 @@ export const DensityChart = (props: DensityChartProps) => { /** * Renders a custom tooltip for Density chart - * @param props Props of tooltip - * @param props.active if component is active - * @param props.payload data for tooltip - * @param props.color color for tooltip title - * @param props.color2 color for tooltip title + * @param props Component props. * @returns Component */ -const CustomTooltip = ({ active, payload, color, color2 }: any) => { +const CustomTooltip = (props: CustomTooltipProps) => { + const { active, payload, color, color2 } = props + if (active && payload && payload.length) { return (
@@ -142,10 +154,9 @@ const CustomTooltip = ({ active, payload, color, color2 }: any) => { } } -/** - * Component's props - */ +/** DensityChartMix props. */ interface DensityChartMixProps { + /** Data series used to build the mixed density chart. */ dataObjects: StatChartData[], /** True to show bars in density chart */ showBars: boolean,