diff --git a/Eplant/views/eFP/EFPPreview.tsx b/Eplant/views/eFP/EFPPreview.tsx index 1bcb18e2..0ec2146c 100644 --- a/Eplant/views/eFP/EFPPreview.tsx +++ b/Eplant/views/eFP/EFPPreview.tsx @@ -26,6 +26,7 @@ export type EFPPreviewProps = { selected: boolean colorMode: 'absolute' | 'relative' data: EFPData + maskThreshold: number } & BoxProps export default function EFPPreview({ gene, @@ -33,6 +34,7 @@ export default function EFPPreview({ selected, colorMode, data, + maskThreshold, ...boxProps }: EFPPreviewProps) { const colorModeDeferred = React.useDeferredValue(colorMode) @@ -54,9 +56,10 @@ export default function EFPPreview({ state={{ renderAsThumbnail: true, colorMode: colorModeDeferred, + maskThreshold: maskThreshold }} geneticElement={gene} - dispatch={() => {}} + dispatch={() => { }} />
void + onSubmit: (threshhold: number) => void +} + +const MaskModal = ({ state, onClose, onSubmit }: MaskModalProps) => { + const [sliderValue, setSliderValue] = useState(state.maskThreshold); + const theme = useTheme() + const handleSliderChange = (event: Event, newValue: number | number[]) => { + setSliderValue(newValue as number); + }; + + const handleClose = () => { + setSliderValue(state.maskThreshold) + onClose(); + }; + + const handleSubmit = () => { + onSubmit(sliderValue); + onClose(); + }; + + return ( + +
+ + Mask samples if the expression level is within a given range of their standard deviation. + + `${value}%`} + min={0} + max={100} + /> +
+ + +
+
+
+ ); +}; + +export default MaskModal; diff --git a/Eplant/views/eFP/Viewer/index.tsx b/Eplant/views/eFP/Viewer/index.tsx index 53fc54c5..4a81982f 100644 --- a/Eplant/views/eFP/Viewer/index.tsx +++ b/Eplant/views/eFP/Viewer/index.tsx @@ -18,6 +18,7 @@ import Legend from './legend' import NotSupported from '@eplant/UI/Layout/ViewNotSupported' import Dropdown from '@eplant/UI/Dropdown' import GeneDistributionChart from './GeneDistributionChart' +import MaskModal from './MaskModal' type EFPListProps = { geneticElement: GeneticElement @@ -31,6 +32,7 @@ type EFPListProps = { >['dispatch'] height: number colorMode: 'absolute' | 'relative' + maskThreshold: number } const EFPListItem = React.memo( @@ -50,6 +52,7 @@ const EFPListItem = React.memo( gene={data.geneticElement} selected={data.views[i].id == data.activeView.id} view={data.views[i]} + maskThreshold={data.maskThreshold} onClick={() => { startTransition(() => { data.dispatch({ type: 'set-view', id: data.views[i].id }) @@ -116,6 +119,8 @@ export default class EFPViewer zoom: 1, }, sortBy: 'name', + maskThreshold: 100, + maskModalVisible: false, } } constructor( @@ -126,7 +131,7 @@ export default class EFPViewer public icon: () => JSX.Element, public description?: string, public thumbnail?: string, - ) {} + ) { } getInitialData = async ( gene: GeneticElement | null, loadEvent: (progress: number) => void, @@ -157,6 +162,7 @@ export default class EFPViewer viewData: viewData, efps: this.efps, colorMode: 'absolute' as const, + } } reducer = (state: EFPViewerState, action: EFPViewerAction) => { @@ -192,6 +198,16 @@ export default class EFPViewer ? ('relative' as const) : ('absolute' as const), } + case 'toggle-mask-modal': + return { + ...state, + maskModalVisible: state.maskModalVisible ? false : true + } + case 'set-mask-threshold': + return { + ...state, + maskThreshold: action.threshold + } default: return state } @@ -238,9 +254,10 @@ export default class EFPViewer state={{ colorMode: props.state.colorMode, renderAsThumbnail: false, + maskThreshold: props.state.maskThreshold }} geneticElement={props.geneticElement} - dispatch={() => {}} + dispatch={() => { }} /> ) }, [ @@ -249,6 +266,7 @@ export default class EFPViewer props.dispatch, sortedViewData[activeViewIndex], props.state.colorMode, + props.state.maskThreshold ]) const ref = React.useRef(null) const dimensions = useDimensions(ref) @@ -289,7 +307,7 @@ export default class EFPViewer }} > {/* Dropdown menus for selecting a view and sort options - + //TODO: Make the dropdown menus appear closer to the button, left aligned and with a max height */} )} + props.dispatch({ type: 'toggle-mask-modal' })} + onSubmit={(threshold) => props.dispatch({ type: 'set-mask-threshold', threshold: threshold })} + /> ({ position: 'absolute', @@ -378,6 +402,7 @@ export default class EFPViewer state={{ colorMode: props.state.colorMode, renderAsThumbnail: false, + maskThreshold: props.state.maskThreshold }} /> <>Toggle data mode: {props.state.colorMode}, }, + { + action: { type: 'toggle-mask-modal' }, + render: () => <>Mask data + } ] header: View['header'] = ( props, diff --git a/Eplant/views/eFP/Viewer/types.tsx b/Eplant/views/eFP/Viewer/types.tsx index 895503f8..8e2cb15b 100644 --- a/Eplant/views/eFP/Viewer/types.tsx +++ b/Eplant/views/eFP/Viewer/types.tsx @@ -18,6 +18,8 @@ export type EFPViewerState = { transform: Transform colorMode: ColorMode sortBy: EFPViewerSortTypes + maskModalVisible: boolean + maskThreshold: number } export type EFPViewerAction = @@ -26,3 +28,5 @@ export type EFPViewerAction = | { type: 'set-transform'; transform: Transform } | { type: 'toggle-color-mode' } | { type: 'sort-by'; by: EFPViewerSortTypes } + | { type: 'toggle-mask-modal' } + | { type: 'set-mask-threshold'; threshold: number } diff --git a/Eplant/views/eFP/index.tsx b/Eplant/views/eFP/index.tsx index a6f0c3e6..c7cc76c5 100644 --- a/Eplant/views/eFP/index.tsx +++ b/Eplant/views/eFP/index.tsx @@ -1,6 +1,5 @@ import GeneticElement from '@eplant/GeneticElement' import { CircularProgress, Typography } from '@mui/material' -import React from 'react' import { View, ViewProps } from '@eplant/View' import { useEFPSVG, useStyles } from './svg' import { @@ -15,11 +14,13 @@ import { import _ from 'lodash' import { ViewDataError } from '@eplant/View/viewData' import SVGTooltip from './Tooltips/EFPTooltip' +import React, { useState, useMemo, useEffect, useLayoutEffect } from 'react' export default class EFP implements View { getInitialState: () => EFPState = () => ({ colorMode: 'absolute', renderAsThumbnail: false, + maskThreshold: 100 }) tooltipComponent: (props: { el: SVGElement | null @@ -27,7 +28,7 @@ export default class EFP implements View { tissue: EFPTissue data: EFPData state: EFPState - }) => React.JSX.Element + }) => JSX.Element constructor( public name: string, public id: EFPId, @@ -51,9 +52,8 @@ export default class EFP implements View { const database = xml.getElementsByTagName('view')[0]?.getAttribute('db') let webservice = xml.getElementsByTagName('webservice')[0]?.textContent if (!webservice) - webservice = `https://bar.utoronto.ca/eplant/cgi-bin/plantefp.cgi?datasource=${ - database ?? 'atgenexp_plus' - }&` + webservice = `https://bar.utoronto.ca/eplant/cgi-bin/plantefp.cgi?datasource=${database ?? 'atgenexp_plus' + }&` // Get a list of groups and samples const sampleNames: string[] = [] @@ -95,9 +95,9 @@ export default class EFP implements View { chunks.map((names) => fetch( webservice + - `id=${gene.id}&samples=${encodeURIComponent( - JSON.stringify(names), - )}`, + `id=${gene.id}&samples=${encodeURIComponent( + JSON.stringify(names), + )}`, ) .then((res) => res.json()) .then( @@ -176,7 +176,6 @@ export default class EFP implements View { showText: !props.state.renderAsThumbnail, }, ) - const { svg } = view ?? {} const id = 'svg-container-' + @@ -184,9 +183,9 @@ export default class EFP implements View { '-' + (props.geneticElement?.id ?? 'no-gene') + '-' + - React.useMemo(() => Math.random().toString(16).slice(3), []) - const styles = useStyles(id, props.activeData, props.state.colorMode) - React.useEffect(() => { + useMemo(() => Math.random().toString(16).slice(3), []) + const styles = useStyles(id, props.activeData, props.state.colorMode, props.state.maskThreshold) + useEffect(() => { const el = document.createElement('style') el.innerHTML = styles document.head.appendChild(el) @@ -196,7 +195,7 @@ export default class EFP implements View { }, [props.activeData.groups, styles]) // Add tooltips to svg - const [svgElements, setSvgElements] = React.useState< + const [svgElements, setSvgElements] = useState< { el: SVGElement group: EFPGroup @@ -204,7 +203,7 @@ export default class EFP implements View { }[] >([]) - const svgDiv = React.useMemo(() => { + const svgDiv = useMemo(() => { return (
{ ) }, [svg, id]) - React.useLayoutEffect(() => { + useLayoutEffect(() => { const elements = Array.from( props.activeData.groups.flatMap((group) => group.tissues.map((t) => ({ diff --git a/Eplant/views/eFP/svg.tsx b/Eplant/views/eFP/svg.tsx index 4524bb17..437c8c6f 100644 --- a/Eplant/views/eFP/svg.tsx +++ b/Eplant/views/eFP/svg.tsx @@ -41,9 +41,9 @@ export const useEFPSVG = ( if (!cache[view.id]) return { view: null, loading: true } const parser = new DOMParser() const svg = parser.parseFromString(cache[view.id].svg, 'text/xml') - ;['width', 'height', 'x', 'y', 'id'].map((s) => - svg.documentElement.removeAttribute(s), - ) + ;['width', 'height', 'x', 'y', 'id'].map((s) => + svg.documentElement.removeAttribute(s), + ) svg.documentElement.setAttribute('class', 'eFP-svg') // Remove styling from all of the text tags for (const text of svg.querySelectorAll('text, tspan')) { @@ -84,14 +84,21 @@ export function getColor( control: number, theme: Theme, colorMode: ColorMode, + tissueStd?: number, + maskThreshold?: number ): string { const extremum = Math.max( Math.abs(Math.log2(group.min / control)), Math.log2(group.max / control), 1, ) + const masked = maskThreshold && tissueStd ? + isNaN(group.std) || (tissueStd >= value * (maskThreshold / 100)) : + false const norm = Math.log2(value / control) / extremum - if (colorMode === 'relative') + if (masked) { + return (theme.palette.secondary.dark) + } else if (colorMode === 'relative') return norm < 0 ? mix(theme.palette.neutral.main, theme.palette.cold.main, Math.abs(norm)) : mix(theme.palette.neutral.main, theme.palette.hot.main, Math.abs(norm)) @@ -107,22 +114,23 @@ export function useStyles( id: string, { groups, control }: EFPData, colorMode: ColorMode, + maskThreshold?: number, ) { const theme = useTheme() const samples = groups .flatMap((group) => group.tissues.map( - (tissue) => - ` - #${id} .efp-group-${tissue.id} *, #${id} .efp-group-${ - tissue.id + (tissue) => ` + #${id} .efp-group-${tissue.id} *, #${id} .efp-group-${tissue.id } { fill: ${getColor( tissue.mean, group, control ?? 1, theme, colorMode, - )} !important; }`, + tissue.std, + maskThreshold, + )} !important; }` ), ) .join('\n') diff --git a/Eplant/views/eFP/types.tsx b/Eplant/views/eFP/types.tsx index 076fbe04..ef9c926c 100644 --- a/Eplant/views/eFP/types.tsx +++ b/Eplant/views/eFP/types.tsx @@ -25,6 +25,7 @@ export type ColorMode = 'absolute' | 'relative' export type EFPState = { colorMode: 'absolute' | 'relative' renderAsThumbnail: boolean + maskThreshold: number } export type EFPSVG = { svg: string; xml: string; id: EFPId }