diff --git a/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx b/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx index 2d60852eae90f9bb1e2912beca4ba75806913b93..6f3af87274b155a787252d1b46c6501c8f16ed52 100644 --- a/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx +++ b/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx @@ -4,6 +4,7 @@ import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { DrawerHeading } from '@/shared/DrawerHeading'; import { AnnotationItem } from './AnnotationItem'; import { AssociatedSubmap } from './AssociatedSubmap'; +import { OverlayData } from './OverlayData'; export const BioEntityDrawer = (): React.ReactNode => { const bioEntityData = useAppSelector(searchedFromMapBioEntityElement); @@ -15,7 +16,7 @@ export const BioEntityDrawer = (): React.ReactNode => { const isReferenceAvailable = bioEntityData.references.length > ZERO; return ( - <div className="h-full max-h-full" data-testid="bioentity-drawer"> + <div className="h-calc-drawer" data-testid="bioentity-drawer"> <DrawerHeading title={ <> @@ -24,7 +25,7 @@ export const BioEntityDrawer = (): React.ReactNode => { </> } /> - <div className="flex flex-col gap-6 p-6"> + <div className="flex max-h-full flex-col gap-6 overflow-y-auto p-6"> <div className="text-sm font-normal"> Compartment: <b className="font-semibold">{bioEntityData.compartmentName}</b> </div> @@ -47,6 +48,7 @@ export const BioEntityDrawer = (): React.ReactNode => { /> ))} <AssociatedSubmap /> + <OverlayData /> </div> </div> ); diff --git a/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/GeneVariantsTable/GeneVariantsTable.component.test.tsx b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/GeneVariantsTable/GeneVariantsTable.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8b31bae3b2194b80e86bfd8e688506b9df9bfaac --- /dev/null +++ b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/GeneVariantsTable/GeneVariantsTable.component.test.tsx @@ -0,0 +1,37 @@ +import { GENE_VARIANTS_MOCK } from '@/models/mocks/geneVariantsMock'; +import { GeneVariant } from '@/types/models'; +import { render, screen } from '@testing-library/react'; +import { GeneVariantsTable } from './GeneVariantsTable.component'; + +const renderComponent = (data: GeneVariant[]): void => { + render(<GeneVariantsTable data={data} />); +}; + +describe('GeneVariantsTable - component', () => { + beforeEach(() => { + renderComponent(GENE_VARIANTS_MOCK); + }); + + it('should render header', () => { + expect(screen.getByText('Contig')).toBeInTheDocument(); + expect(screen.getByText('Position')).toBeInTheDocument(); + expect(screen.getByText('From')).toBeInTheDocument(); + expect(screen.getByText('To')).toBeInTheDocument(); + expect(screen.getByText('rsID')).toBeInTheDocument(); + }); + + it.each(GENE_VARIANTS_MOCK)( + 'should render row', + ({ contig, position, originalDna, modifiedDna, variantIdentifier }) => { + const elements = [ + screen.getAllByText(contig), + screen.getAllByText(position), + screen.getAllByText(originalDna), + screen.getAllByText(modifiedDna), + screen.getAllByText(variantIdentifier), + ].flat(); + + elements.forEach(element => expect(element).toBeInTheDocument()); + }, + ); +}); diff --git a/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/GeneVariantsTable/GeneVariantsTable.component.tsx b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/GeneVariantsTable/GeneVariantsTable.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..32eca4e581bd42c39657c1c267f60cd8659a6ef7 --- /dev/null +++ b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/GeneVariantsTable/GeneVariantsTable.component.tsx @@ -0,0 +1,35 @@ +import { TWO } from '@/constants/common'; +import { GeneVariant } from '@/types/models'; +import { twMerge } from 'tailwind-merge'; + +interface Props { + data: GeneVariant[]; +} + +export const GeneVariantsTable = ({ data }: Props): JSX.Element => { + return ( + <table className="rounded-lg text-xs shadow-tableBorderDivide"> + <tr className="border-b border-divide text-left text-[#6A6977]"> + <th className="py-4 pl-4 pt-5 font-light ">Contig</th> + <th className="py-4 pt-5 font-light">Position</th> + <th className="py-4 pt-5 font-light">From</th> + <th className="py-4 pt-5 font-light">To</th> + <th className="py-4 pr-4 pt-5 font-light">rsID</th> + </tr> + {data.map((variant, index) => { + const isOdd = index % TWO; + const isEven = !isOdd; + + return ( + <tr key={variant.position} className={twMerge('font-semibold', isEven && 'bg-[#F3F3F3]')}> + <td className="py-4 pl-4">{variant.contig}</td> + <td className="py-4">{variant.position}</td> + <td className="py-4">{variant.originalDna}</td> + <td className="py-4">{variant.modifiedDna}</td> + <td className="py-4 pr-4">{variant.variantIdentifier}</td> + </tr> + ); + })} + </table> + ); +}; diff --git a/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/GeneVariantsTable/index.ts b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/GeneVariantsTable/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..810de4490a88f99a80c901720ae07aee11a21967 --- /dev/null +++ b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/GeneVariantsTable/index.ts @@ -0,0 +1 @@ +export { GeneVariantsTable } from './GeneVariantsTable.component'; diff --git a/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/OverlayAxis.component.test.tsx b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/OverlayAxis.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aedf8fe0e5e7805c5c583c08163582ce90cd70f1 --- /dev/null +++ b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/OverlayAxis.component.test.tsx @@ -0,0 +1,180 @@ +/* eslint-disable no-magic-numbers */ +import { ZERO } from '@/constants/common'; +import { GENE_VARIANTS_MOCK } from '@/models/mocks/geneVariantsMock'; +import { act, render, screen } from '@testing-library/react'; +import { OverlayDataAxis } from '../OverlayData.types'; +import { OverlayAxis } from './OverlayAxis.component'; + +const renderComponent = (axis: OverlayDataAxis): void => { + render(<OverlayAxis axis={axis} />); +}; + +const BASE_AXIS: OverlayDataAxis = { + id: 123, + title: 'axis title', + value: 2137, + color: '#FFFFFF', + geneVariants: undefined, +}; + +describe('OverlayAxis - component', () => { + describe('when always', () => { + beforeEach(() => { + renderComponent(BASE_AXIS); + }); + + it('renders title', () => { + expect(screen.getByText('axis title')).toBeInTheDocument(); + }); + + it('renders background with valid color', () => { + expect(screen.getByTestId('overlay-axis-bg')).toBeInTheDocument(); + expect(screen.getByTestId('overlay-axis-bg').getAttribute('style')).toContain( + 'background: rgba(255, 255, 255, 0.102);', + ); + }); + + it('renders valid value title (when no gene variants)', () => { + expect(screen.getByTestId('overlay-axis-value')).toBeInTheDocument(); + expect(screen.getByTestId('overlay-axis-value').innerHTML).toContain('2137'); + }); + }); + + describe('when value is positive', () => { + beforeEach(() => { + renderComponent({ + ...BASE_AXIS, + value: 0.564234344, + color: '#FF0000', + }); + }); + + it('renders bar with valid color and width', () => { + const axis = screen.getByTestId('overlay-axis-bg'); + const bar = axis.childNodes[0] as HTMLElement; + + expect(bar).toBeInTheDocument(); + expect(bar?.getAttribute('class')).toContain('rounded-r'); + expect(bar?.getAttribute('class')).toContain('left-1/2'); + expect(bar?.getAttribute('style')).toContain('width: 0.028211717200000003%;'); + expect(bar?.getAttribute('style')).toContain('background: rgb(255, 0, 0);'); + }); + + it('renders valid value title (when no gene variants)', () => { + expect(screen.getByTestId('overlay-axis-value')).toBeInTheDocument(); + expect(screen.getByTestId('overlay-axis-value').innerHTML).toContain('0.56'); + }); + }); + + describe('when value is negative', () => { + beforeEach(() => { + renderComponent({ + ...BASE_AXIS, + value: -0.3255323223, + color: '#00FF00', + }); + }); + + it('renders bar with valid color and width', () => { + const axis = screen.getByTestId('overlay-axis-bg'); + const bar = axis.childNodes[0] as HTMLElement; + + expect(bar).toBeInTheDocument(); + expect(bar?.getAttribute('class')).toContain('rounded-l'); + expect(bar?.getAttribute('class')).toContain('right-1/2'); + expect(bar?.getAttribute('style')).toContain('width: 0.016276616115%;'); + expect(bar?.getAttribute('style')).toContain('background: rgb(0, 255, 0);'); + }); + + it('renders valid value title (when no gene variants)', () => { + expect(screen.getByTestId('overlay-axis-value')).toBeInTheDocument(); + expect(screen.getByTestId('overlay-axis-value').innerHTML).toContain('-0.33'); + }); + }); + + describe('when value is zero', () => { + beforeEach(() => { + renderComponent({ + ...BASE_AXIS, + value: 0, + color: '#0000FF', + }); + }); + + it('renders bar with valid color and width', () => { + const axis = screen.getByTestId('overlay-axis-bg'); + const bar = axis.childNodes[0] as HTMLElement; + + expect(bar).toBeInTheDocument(); + expect(bar?.getAttribute('class')).not.toContain('right-1/2'); + expect(bar?.getAttribute('class')).not.toContain('rounded-l'); + expect(bar?.getAttribute('class')).not.toContain('right-1/2'); + expect(bar?.getAttribute('style')).toContain('width: 0%;'); + }); + + it('renders valid value title (when no gene variants)', () => { + expect(screen.getByTestId('overlay-axis-value')).toBeInTheDocument(); + expect(screen.getByTestId('overlay-axis-value').innerHTML).toContain('-'); + }); + }); + + describe('when value is undefined', () => { + beforeEach(() => { + renderComponent({ + ...BASE_AXIS, + value: undefined, + }); + }); + + it('renders bar with valid color and width', () => { + const axis = screen.getByTestId('overlay-axis-bg'); + const bar = axis.childNodes[0] as HTMLElement; + + expect(bar).toBeInTheDocument(); + expect(bar?.getAttribute('class')).toContain('w-full'); + expect(bar?.getAttribute('style')).toContain('width: 100%;'); + expect(bar?.getAttribute('style')).toContain('background: rgb(255, 255, 255);'); + }); + + it('renders valid value title (when no gene variants)', () => { + expect(screen.getByTestId('overlay-axis-value')).toBeInTheDocument(); + expect(screen.getByTestId('overlay-axis-value').innerHTML).toContain('-'); + }); + }); + + describe('when there is gene variants', () => { + beforeEach(() => { + renderComponent({ + ...BASE_AXIS, + geneVariants: GENE_VARIANTS_MOCK, + }); + }); + + it('renders gene variants icon button', () => { + expect(screen.getByTestId('overlay-axis-icon')).toBeInTheDocument(); + }); + + it('renders gene variants info icon', () => { + const infoIconWrapper = screen.getByTitle( + 'Number of variants mapped to this gene. See their details in the tabular view below.', + ); + const icon = infoIconWrapper.childNodes[1] as HTMLElement; + + expect(infoIconWrapper).toBeInTheDocument(); + expect(icon).toBeInTheDocument(); + }); + + it('shows gene variants table on gene variants icon button click', () => { + const geneVariantsTable = screen.queryAllByText('Contig'); + expect(geneVariantsTable.length).toEqual(ZERO); + + const iconButton = screen.getByTestId('overlay-axis-icon'); + act(() => { + iconButton.click(); + }); + + const geneVariantsTableVisible = screen.getByText('Contig'); + expect(geneVariantsTableVisible).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/OverlayAxis.component.tsx b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/OverlayAxis.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7253c4d19f61646328575b14a85021e2513fa2a6 --- /dev/null +++ b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/OverlayAxis.component.tsx @@ -0,0 +1,70 @@ +import { Icon } from '@/shared/Icon'; +import { IconButton } from '@/shared/IconButton'; +import { useState } from 'react'; +import { twMerge } from 'tailwind-merge'; +import { OverlayDataAxis } from '../OverlayData.types'; +import { GeneVariantsTable } from './GeneVariantsTable'; +import { getOverlayAxisData } from './utils/getOverlayAxisProps'; + +interface Props { + axis: OverlayDataAxis; +} + +export const OverlayAxis = ({ axis }: Props): JSX.Element => { + const [showGeneVariants, setShowGeneVariants] = useState<boolean>(false); + const { title, background, bar, value } = getOverlayAxisData(axis); + + const toggleShowGeneVariants = (): void => { + setShowGeneVariants(v => !v); + }; + + return ( + <> + <div className="flex items-center gap-2 text-xs"> + <div className="flex w-48 items-center justify-between gap-2 font-semibold"> + <div>{title}</div> + {axis.geneVariants && ( + <IconButton + icon={showGeneVariants ? 'chevron-up' : 'chevron-down'} + data-testid="overlay-axis-icon" + className="h-6 w-6 flex-shrink-0 bg-transparent p-0" + onClick={toggleShowGeneVariants} + /> + )} + </div> + <div + className="relative h-6 w-full overflow-hidden rounded" + style={{ background: background.color }} + data-testid="overlay-axis-bg" + > + <div + className={twMerge( + 'absolute h-full', + value.isPositive && 'left-1/2 rounded-r', + value.isNegative && 'right-1/2 rounded-l', + value.isUndefined && 'w-full', + )} + style={{ background: bar.color, width: `${bar.percentage}%` }} + /> + </div> + <div + className="flex h-6 w-12 flex-shrink-0 items-center justify-center rounded border border-divide p-1 text-center font-semibold" + title={ + axis.geneVariants + ? 'Number of variants mapped to this gene. See their details in the tabular view below.' + : undefined + } + data-testid="overlay-axis-value" + > + {value.title} + {axis.geneVariants && ( + <span className="ml-[2px] flex"> + <Icon name="info" className="h-3 w-3 fill-black" /> + </span> + )} + </div> + </div> + {axis.geneVariants && showGeneVariants && <GeneVariantsTable data={axis.geneVariants} />} + </> + ); +}; diff --git a/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/index.ts b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce74e82b2073179d8811d55546f9acdbcc3f23e8 --- /dev/null +++ b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/index.ts @@ -0,0 +1 @@ +export { OverlayAxis } from './OverlayAxis.component'; diff --git a/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/utils/getOverlayAxisProps.ts b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/utils/getOverlayAxisProps.ts new file mode 100644 index 0000000000000000000000000000000000000000..20c00c194dfb97705137222012b427cfe2e0f17e --- /dev/null +++ b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayAxis/utils/getOverlayAxisProps.ts @@ -0,0 +1,61 @@ +import { ONE_DECIMAL, ONE_HUNDRED, TWO, ZERO } from '@/constants/common'; +import { HALF } from '@/constants/dividers'; +import { addAlphaToHexString } from '@/utils/convert/addAlphaToHexString'; +import { OverlayDataAxis } from '../../OverlayData.types'; + +interface OverlayAxisProps { + title: string; + background: { + color: string; + }; + bar: { + color: string; + percentage: number; + }; + value: { + title: string; + isPositive: boolean; + isNegative: boolean; + isUndefined: boolean; + }; +} + +const FULL_WIDTH = 100; +const DEFAULT_VALUE_TITLE = '-'; + +const getBarPercentage = (value?: number): number => { + const valueNormalized = value || ZERO; + const isValueUndefined = typeof value === 'undefined'; + + const valuePositivePercentage = Math.abs(valueNormalized) * ONE_HUNDRED; + const valuePositivePercentageHalf = valuePositivePercentage / HALF; // 100% = full width of posivie OR negative chart SIDE + + return isValueUndefined ? FULL_WIDTH : valuePositivePercentageHalf; // axis without value = 100% both sides width +}; + +const getValueTitle = (axis: OverlayDataAxis): string => { + if (axis?.geneVariants) { + return axis.geneVariants.length.toString(); + } + + return axis.value ? axis.value.toFixed(TWO) : DEFAULT_VALUE_TITLE; +}; + +export const getOverlayAxisData = (axis: OverlayDataAxis): OverlayAxisProps => { + return { + title: axis.title, + background: { + color: addAlphaToHexString(axis.color, ONE_DECIMAL), + }, + bar: { + color: axis.color, + percentage: getBarPercentage(axis.value), + }, + value: { + title: getValueTitle(axis), + isUndefined: typeof axis.value === 'undefined', + isPositive: axis.value ? axis.value > ZERO : false, + isNegative: axis.value ? axis.value < ZERO : false, + }, + }; +}; diff --git a/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayData.component.test.tsx b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayData.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ac239c2fc2e98eeb77c5ff51d07d4b95c07725d9 --- /dev/null +++ b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayData.component.test.tsx @@ -0,0 +1,116 @@ +/* eslint-disable no-magic-numbers */ +import { ZERO } from '@/constants/common'; +import { bioEntitiesContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { overlayFixture } from '@/models/fixtures/overlaysFixture'; +import { GENE_VARIANTS_MOCK } from '@/models/mocks/geneVariantsMock'; +import { CORE_PD_MODEL_MOCK } from '@/models/mocks/modelsMock'; +import { initialMapStateFixture } from '@/redux/map/map.fixtures'; +import { MODELS_INITIAL_STATE_MOCK } from '@/redux/models/models.mock'; +import { + MOCKED_OVERLAY_BIO_ENTITY_RENDER, + OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, +} from '@/redux/overlayBioEntity/overlayBioEntity.mock'; +import { INITIAL_STORE_STATE_MOCK } from '@/redux/root/root.fixtures'; +import { StoreType } from '@/redux/store'; +import { InitialStoreState } from '@/utils/testing/getReduxStoreActionsListener'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen } from '@testing-library/react'; +import { OverlayData } from './OverlayData.component'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <OverlayData /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('OverlayData - component', () => { + describe('when axes list is empty', () => { + beforeEach(() => { + renderComponent({}); + }); + + it('should not render component', () => { + expect(screen.queryAllByText('Overlay data:').length).toEqual(ZERO); + }); + }); + + describe('when axes list is present', () => { + beforeEach(() => { + const OVERLAY_ID = overlayFixture.idObject; + const BIO_ENTITY = MOCKED_OVERLAY_BIO_ENTITY_RENDER[0]; + + renderComponent({ + ...INITIAL_STORE_STATE_MOCK, + map: { + ...initialMapStateFixture, + data: { ...initialMapStateFixture.data, modelId: CORE_PD_MODEL_MOCK.idObject }, + }, + overlays: { + ...INITIAL_STORE_STATE_MOCK.overlays, + data: [{ ...overlayFixture, name: 'axis name' }], + }, + bioEntity: { + data: [ + { + searchQueryElement: '', + loading: 'pending', + error: { name: '', message: '' }, + data: [ + { + ...bioEntitiesContentFixture[0], + bioEntity: { + ...bioEntitiesContentFixture[0].bioEntity, + id: BIO_ENTITY.id, + }, + }, + ], + }, + ], + loading: 'pending', + error: { name: '', message: '' }, + }, + overlayBioEntity: { + ...OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, + overlaysId: [OVERLAY_ID], + data: { + [OVERLAY_ID]: { + [CORE_PD_MODEL_MOCK.idObject]: [ + { + ...BIO_ENTITY, + geneVariants: GENE_VARIANTS_MOCK, + modelId: CORE_PD_MODEL_MOCK.idObject, + overlayId: OVERLAY_ID, + }, + ], + }, + }, + }, + models: { ...MODELS_INITIAL_STATE_MOCK, data: [CORE_PD_MODEL_MOCK] }, + drawer: { + ...INITIAL_STORE_STATE_MOCK.drawer, + bioEntityDrawerState: { + ...INITIAL_STORE_STATE_MOCK.drawer.bioEntityDrawerState, + bioentityId: BIO_ENTITY.id, + }, + }, + }); + }); + + it('should render title', () => { + expect(screen.getByText('Overlay data:')).toBeInTheDocument(); + }); + + it('should render axis title', () => { + expect(screen.getByText('axis name')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayData.component.tsx b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayData.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b4c193f2b23e57a1087b983d53ff00cc99736b4f --- /dev/null +++ b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayData.component.tsx @@ -0,0 +1,22 @@ +import { ZERO } from '@/constants/common'; +import { OverlayAxis } from './OverlayAxis'; +import { useOverlaysAxes } from './utils/useOverlaysAxes'; + +export const OverlayData = (): JSX.Element | null => { + const axes = useOverlaysAxes(); + + if (axes.length === ZERO) { + return null; + } + + return ( + <div> + <h3 className="mb-2 font-semibold">Overlay data:</h3> + <div className="flex flex-col gap-2 rounded-lg border border-divide p-4"> + {axes.map(axis => ( + <OverlayAxis key={axis.title} axis={axis} /> + ))} + </div> + </div> + ); +}; diff --git a/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayData.types.ts b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayData.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f78ef6121607c3a6fd06c7dc17303f8652075d0 --- /dev/null +++ b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/OverlayData.types.ts @@ -0,0 +1,9 @@ +import { GeneVariant } from '@/types/models'; + +export interface OverlayDataAxis { + id: number; + title: string; + value?: number; + color: string; + geneVariants?: GeneVariant[] | null; +} diff --git a/src/components/Map/Drawer/BioEntityDrawer/OverlayData/index.ts b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b003968ce0f0849e234c5db6d47301cfa412cd4 --- /dev/null +++ b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/index.ts @@ -0,0 +1 @@ +export { OverlayData } from './OverlayData.component'; diff --git a/src/components/Map/Drawer/BioEntityDrawer/OverlayData/utils/useOverlaysAxes.ts b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/utils/useOverlaysAxes.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7735979501adc9e868b6195bf97a2b856cd2b34 --- /dev/null +++ b/src/components/Map/Drawer/BioEntityDrawer/OverlayData/utils/useOverlaysAxes.ts @@ -0,0 +1,32 @@ +import { useGetOverlayColor } from '@/components/Map/MapViewer/utils/config/overlaysLayer/useGetOverlayColor'; +import { ONE } from '@/constants/common'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { + overlaysBioEntityForCurrentBioEntityAndCurrentModelSelector, + overlaysOpenedSelector, +} from '@/redux/overlayBioEntity/overlayBioEntity.selector'; +import { useSelector } from 'react-redux'; +import { OverlayDataAxis } from '../OverlayData.types'; + +export const useOverlaysAxes = (): OverlayDataAxis[] => { + const openedOverlays = useSelector(overlaysOpenedSelector); + const currentBioEntityOverlaysForCurrentBioEntity = useAppSelector( + overlaysBioEntityForCurrentBioEntityAndCurrentModelSelector, + ); + + const { getOverlayBioEntityColorByAvailableProperties } = useGetOverlayColor({ + forceOpacityValue: ONE, + }); + + return currentBioEntityOverlaysForCurrentBioEntity.map((overlayBioEntity): OverlayDataAxis => { + const overlay = openedOverlays.find(o => o.idObject === overlayBioEntity.overlayId); + + return { + id: overlayBioEntity.id, + title: overlay?.name || '', + value: overlayBioEntity.value || undefined, + color: getOverlayBioEntityColorByAvailableProperties(overlayBioEntity), + geneVariants: overlayBioEntity?.geneVariants, + }; + }); +}; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/useGetOverlayColor.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/useGetOverlayColor.ts index 64518c5e005318aca9f21c9635478e735043330b..46d7190721fbb14929d055cdeead41208bde90d3 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/useGetOverlayColor.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/useGetOverlayColor.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { ONE, ZERO } from '@/constants/common'; import { WHITE_HEX_OPACITY_0 } from '@/constants/hexColors'; import { maxColorValSelector, @@ -8,11 +8,11 @@ import { simpleColorValSelector, } from '@/redux/configuration/configuration.selectors'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; -import { getHexTricolorGradientColorWithAlpha } from '@/utils/convert/getHexTricolorGradientColorWithAlpha'; -import { ONE, ZERO } from '@/constants/common'; import { OverlayBioEntityRender } from '@/types/OLrendering'; import { addAlphaToHexString } from '@/utils/convert/addAlphaToHexString'; import { getHexStringColorFromRGBIntWithAlpha } from '@/utils/convert/getHexStringColorFromRGBIntWithAlpha'; +import { getHexTricolorGradientColorWithAlpha } from '@/utils/convert/getHexTricolorGradientColorWithAlpha'; +import { useCallback, useMemo } from 'react'; type GetOverlayBioEntityColorByAvailableProperties = (entity: OverlayBioEntityRender) => string; @@ -20,12 +20,17 @@ type UseTriColorLerpReturn = { getOverlayBioEntityColorByAvailableProperties: GetOverlayBioEntityColorByAvailableProperties; }; -export const useGetOverlayColor = (): UseTriColorLerpReturn => { +interface Props { + forceOpacityValue?: number; +} + +export const useGetOverlayColor = ({ forceOpacityValue }: Props = {}): UseTriColorLerpReturn => { const minColorValHexString = useAppSelector(minColorValSelector) || ''; const maxColorValHexString = useAppSelector(maxColorValSelector) || ''; const neutralColorValHexString = useAppSelector(neutralColorValSelector) || ''; - const overlayOpacityValue = useAppSelector(overlayOpacitySelector) || ONE; const simpleColorValue = useAppSelector(simpleColorValSelector) || WHITE_HEX_OPACITY_0; + const overlayOpacityDefaultValue = useAppSelector(overlayOpacitySelector); + const overlayOpacityValue = forceOpacityValue || overlayOpacityDefaultValue || ONE; const getHex3ColorGradientColorWithAlpha = useCallback( (position: number) => diff --git a/src/constants/common.ts b/src/constants/common.ts index 00220963e428965dbee6767b1715359ac15bbd02..cc1c5e08d0bda289dc7d1ba800b62cdeba4c2e28 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -8,6 +8,11 @@ export const FIRST_ARRAY_ELEMENT = 0; export const ONE = 1; export const SECOND_ARRAY_ELEMENT = 1; +export const TWO = 2; + export const THIRD_ARRAY_ELEMENT = 2; export const NOOP = (): void => {}; + +export const ONE_DECIMAL = 0.1; +export const ONE_HUNDRED = 0.1; diff --git a/src/models/geneVariant.ts b/src/models/geneVariant.ts new file mode 100644 index 0000000000000000000000000000000000000000..3df425e504d0bd6e92f597358d00072ce309d2cd --- /dev/null +++ b/src/models/geneVariant.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const geneVariant = z.object({ + position: z.number(), + originalDna: z.string(), + modifiedDna: z.string(), + contig: z.string(), + allelFrequency: z.number(), + aminoAcidChange: z.string(), + variantIdentifier: z.string(), +}); diff --git a/src/models/mocks/geneVariantsMock.ts b/src/models/mocks/geneVariantsMock.ts new file mode 100644 index 0000000000000000000000000000000000000000..0002828e1c5c0aae1081da489d47e21e9d37acad --- /dev/null +++ b/src/models/mocks/geneVariantsMock.ts @@ -0,0 +1,130 @@ +import { GeneVariant } from '@/types/models'; + +export const GENE_VARIANTS_MOCK: GeneVariant[] = [ + { + position: 162394349, + originalDna: 'G', + modifiedDna: 'A', + contig: 'chr6', + allelFrequency: 0.8, + aminoAcidChange: 'PRKN:NC_000006.11:g.162394349G>A:p.T240M', + variantIdentifier: 'rs137853054', + }, + { + position: 161771219, + originalDna: 'G', + modifiedDna: 'A', + contig: 'chr6', + allelFrequency: 0.8, + aminoAcidChange: 'PRKN:NC_000006.11:g.161771219G>A:p.P437L', + variantIdentifier: 'rs149953814', + }, + { + position: 162206852, + originalDna: 'G', + modifiedDna: 'A', + contig: 'chr6', + allelFrequency: 0.8, + aminoAcidChange: 'PRKN:NC_000006.11:g.162206852G>A:p.R275W', + variantIdentifier: 'rs34424986', + }, + { + position: 162394349, + originalDna: 'G', + modifiedDna: 'C', + contig: 'chr6', + allelFrequency: 0.8, + aminoAcidChange: 'PRKN:NC_000006.11:g.162394349G>C:p.T240R', + variantIdentifier: 'rs137853054', + }, + { + position: 161771171, + originalDna: 'C', + modifiedDna: 'T', + contig: 'chr6', + allelFrequency: 0.8, + aminoAcidChange: 'PRKN:NC_000006.11:g.161771171C>T:p.W453*', + variantIdentifier: 'rs137853056', + }, + { + position: 162683694, + originalDna: 'G', + modifiedDna: 'A', + contig: 'chr6', + allelFrequency: 0.8, + aminoAcidChange: 'PRKN:NC_000006.11:g.162683694G>A:p.A92V', + variantIdentifier: 'rs566229879', + }, + { + position: 162206914, + originalDna: 'T', + modifiedDna: 'C', + contig: 'chr6', + allelFrequency: 0.8, + aminoAcidChange: 'PRKN:NC_000006.11:g.162206914T>C:p.N254S', + variantIdentifier: 'rs139600787', + }, + { + position: 162394349, + originalDna: 'G', + modifiedDna: 'C', + contig: 'chr6', + allelFrequency: 0.8, + aminoAcidChange: 'PRKN:NC_000006.11:g.162394349G>C:p.T240R', + variantIdentifier: 'rs137853054', + }, + { + position: 162206825, + originalDna: 'C', + modifiedDna: 'G', + contig: 'chr6', + allelFrequency: 0.8, + aminoAcidChange: 'PRKN:NC_000006.11:g.162206825C>G:p.G284R', + variantIdentifier: 'rs751037529', + }, + { + position: 162394338, + originalDna: 'C', + modifiedDna: 'T', + contig: 'chr6', + allelFrequency: 0.8, + aminoAcidChange: 'PRKN:NC_000006.11:g.162394338C>T:p.V244I', + variantIdentifier: 'rs771259513', + }, + { + position: 161781201, + originalDna: 'G', + modifiedDna: 'A', + contig: 'chr6', + allelFrequency: 0.8, + aminoAcidChange: 'PRKN:NC_000006.11:g.161781201G>A:p.R402C', + variantIdentifier: 'rs55830907', + }, + { + position: 162622239, + originalDna: 'G', + modifiedDna: 'C', + contig: 'chr6', + allelFrequency: 0.8, + aminoAcidChange: 'PRKN:NC_000006.11:g.162622239G>C:p.P153R', + variantIdentifier: 'rs55654276', + }, + { + position: 161807855, + originalDna: 'C', + modifiedDna: 'G', + contig: 'chr6', + allelFrequency: 0.8, + aminoAcidChange: 'PRKN:NC_000006.11:g.161807855C>G:p.V380L', + variantIdentifier: 'rs1801582', + }, + { + position: 161771237, + originalDna: 'C', + modifiedDna: 'A', + contig: 'chr6', + allelFrequency: 0.8, + aminoAcidChange: 'PRKN:NC_000006.11:g.161771237C>A:p.C431F', + variantIdentifier: 'rs397514694', + }, +]; diff --git a/src/models/overlayRightBioEntitySchema.ts b/src/models/overlayRightBioEntitySchema.ts index 970271c56a9942521cb00987932a4d2bdd9857dd..20b03f8fb19bc6f0ed481a746fce6d6308150604 100644 --- a/src/models/overlayRightBioEntitySchema.ts +++ b/src/models/overlayRightBioEntitySchema.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { colorSchema } from './colorSchema'; +import { geneVariant } from './geneVariant'; export const overlayRightBioEntitySchema = z.object({ id: z.number(), @@ -11,4 +12,5 @@ export const overlayRightBioEntitySchema = z.object({ value: z.number().nullable(), color: colorSchema.nullable(), description: z.string().nullable(), + geneVariants: z.array(geneVariant).optional().nullable(), }); diff --git a/src/redux/overlayBioEntity/overlayBioEntity.selector.ts b/src/redux/overlayBioEntity/overlayBioEntity.selector.ts index 94f18b3a0b177154aae455a7d905055c72411ec4..e42ee1cba731c1c9fe3edc05dcf225e3ceeeeb9f 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.selector.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.selector.ts @@ -1,5 +1,6 @@ import { OverlayBioEntityRender } from '@/types/OLrendering'; import { createSelector } from '@reduxjs/toolkit'; +import { currentSearchedBioEntityId } from '../drawer/drawer.selectors'; import { currentModelIdSelector } from '../models/models.selectors'; import { overlaysDataSelector, overlaysIdsAndOrderSelector } from '../overlays/overlays.selectors'; import { rootSelector } from '../root/root.selectors'; @@ -66,3 +67,20 @@ export const getOverlayOrderSelector = createSelector( return calculateOvarlaysOrder(activeOverlaysIdsAndOrder); }, ); + +export const overlaysOpenedIdsSelector = createSelector( + rootSelector, + state => state.overlayBioEntity.overlaysId, +); + +export const overlaysOpenedSelector = createSelector( + overlaysDataSelector, + overlaysOpenedIdsSelector, + (data, ids) => data.filter(entity => ids.includes(entity.idObject)), +); + +export const overlaysBioEntityForCurrentBioEntityAndCurrentModelSelector = createSelector( + overlayBioEntitiesForCurrentModelSelector, + currentSearchedBioEntityId, + (data, currentBioEntityId) => data.filter(entity => entity.id === currentBioEntityId), +); diff --git a/src/redux/overlayBioEntity/overlayBioEntity.utils.ts b/src/redux/overlayBioEntity/overlayBioEntity.utils.ts index 6656d725badcafef167e19d63226804077ca4a21..b765ff8de4471783e7eca9ffa73ec405b9829118 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.utils.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.utils.ts @@ -32,6 +32,7 @@ export const parseOverlayBioEntityToOlRenderingFormat = ( value: entity.right.value, overlayId, color: entity.right.color, + geneVariants: entity.right.geneVariants, }); } diff --git a/src/shared/Icon/Icons/ChevronDownIcon.tsx b/src/shared/Icon/Icons/ChevronDownIcon.tsx index 5b9d6d3b570653d3bcc32deb51168d34337a2926..5b0ae0ecb5470ce1ac6b669011a558ef5887ad6f 100644 --- a/src/shared/Icon/Icons/ChevronDownIcon.tsx +++ b/src/shared/Icon/Icons/ChevronDownIcon.tsx @@ -3,14 +3,7 @@ interface ChevronDownIconProps { } export const ChevronDownIcon = ({ className }: ChevronDownIconProps): JSX.Element => ( - <svg - width="14" - height="14" - viewBox="0 0 14 14" - fill="none" - className={className} - xmlns="http://www.w3.org/2000/svg" - > + <svg viewBox="0 0 14 14" fill="none" className={className} xmlns="http://www.w3.org/2000/svg"> <g clipPath="url(#clip0_2005_6461)"> <path d="M6.99958 7.68437L9.88708 4.79687L10.7119 5.62171L6.99958 9.33404L3.28725 5.62171L4.11208 4.79687L6.99958 7.68437Z" /> </g> diff --git a/src/shared/Icon/Icons/ChevronUpIcon.tsx b/src/shared/Icon/Icons/ChevronUpIcon.tsx index d0c049778125ffacae3d23f135e1731b60931185..20b46496e9419bd2dd5125d8b3d2afdae8ef80a8 100644 --- a/src/shared/Icon/Icons/ChevronUpIcon.tsx +++ b/src/shared/Icon/Icons/ChevronUpIcon.tsx @@ -3,14 +3,7 @@ interface ChevronUpIconProps { } export const ChevronUpIcon = ({ className }: ChevronUpIconProps): JSX.Element => ( - <svg - width="14" - height="14" - viewBox="0 0 14 14" - fill="none" - className={className} - xmlns="http://www.w3.org/2000/svg" - > + <svg viewBox="0 0 14 14" fill="none" className={className} xmlns="http://www.w3.org/2000/svg"> <g clipPath="url(#clip0_2005_6483)"> <path d="M7.00042 6.31563L4.11292 9.20312L3.28809 8.37829L7.00042 4.66596L10.7128 8.37829L9.88792 9.20312L7.00042 6.31563Z" /> </g> diff --git a/src/types/OLrendering.ts b/src/types/OLrendering.ts index 6aee24d703ddc7ab5c39bf21644ad91895886891..18075b437e4f2f7e91f7de2cbe1955f8851feaf4 100644 --- a/src/types/OLrendering.ts +++ b/src/types/OLrendering.ts @@ -1,4 +1,4 @@ -import { Color } from './models'; +import { Color, GeneVariant } from './models'; export type OverlayBioEntityRenderType = 'line' | 'rectangle'; @@ -19,6 +19,7 @@ export type OverlayBioEntityRender = { overlayId: number; color: Color | null; type: OverlayBioEntityRenderType; + geneVariants?: GeneVariant[] | null; }; export interface OverlayReactionCoords { diff --git a/src/types/models.ts b/src/types/models.ts index 3f9efc0e75c9c1260b4572e642240f24c7b471a2..fbc40b495a27ae375e1242592391e07fa6d12f22 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -1,4 +1,3 @@ -import { z } from 'zod'; import { bioEntityContentSchema } from '@/models/bioEntityContentSchema'; import { bioEntityResponseSchema } from '@/models/bioEntityResponseSchema'; import { bioEntitySchema } from '@/models/bioEntitySchema'; @@ -14,6 +13,7 @@ import { disease } from '@/models/disease'; import { drugSchema } from '@/models/drugSchema'; import { elementSearchResult, elementSearchResultType } from '@/models/elementSearchResult'; import { exportElementsSchema, exportNetworkchema } from '@/models/exportSchema'; +import { geneVariant } from '@/models/geneVariant'; import { lineSchema } from '@/models/lineSchema'; import { loginSchema } from '@/models/loginSchema'; import { mapBackground } from '@/models/mapBackground'; @@ -40,6 +40,7 @@ import { import { overviewImageView } from '@/models/overviewImageView'; import { pluginSchema } from '@/models/pluginSchema'; import { projectSchema } from '@/models/projectSchema'; +import { publicationsResponseSchema } from '@/models/publicationsResponseSchema'; import { publicationSchema } from '@/models/publicationsSchema'; import { reactionSchema } from '@/models/reaction'; import { reactionLineSchema } from '@/models/reactionLineSchema'; @@ -47,7 +48,7 @@ import { referenceSchema } from '@/models/referenceSchema'; import { sessionSchemaValid } from '@/models/sessionValidSchema'; import { statisticsSchema } from '@/models/statisticsSchema'; import { targetSchema } from '@/models/targetSchema'; -import { publicationsResponseSchema } from '@/models/publicationsResponseSchema'; +import { z } from 'zod'; export type Project = z.infer<typeof projectSchema>; export type OverviewImageView = z.infer<typeof overviewImageView>; @@ -94,3 +95,4 @@ export type Publication = z.infer<typeof publicationSchema>; export type ExportNetwork = z.infer<typeof exportNetworkchema>; export type ExportElements = z.infer<typeof exportElementsSchema>; export type MinervaPlugin = z.infer<typeof pluginSchema>; // Plugin type interfers with global Plugin type +export type GeneVariant = z.infer<typeof geneVariant>; diff --git a/src/utils/convert/addAlphaToHexString.ts b/src/utils/convert/addAlphaToHexString.ts index ca474de5d2ef44f8dd293ddc56cb9880c3eceaeb..530d4a5d028ff39382e56cd0246da60e48c83487 100644 --- a/src/utils/convert/addAlphaToHexString.ts +++ b/src/utils/convert/addAlphaToHexString.ts @@ -1,12 +1,16 @@ +import { ZERO } from '@/constants/common'; import { expandHexToFullFormatIfItsShorthanded } from './hexToRgb'; const HEX_RADIX = 16; const EXPECTED_HEX_LENGTH = 2; const MAX_RGB_VALUE = 255; const DEFAULT_ALPHA = 1; +const NORMALIZED_HEX_LENGTH = 6; // example: FFFFFF export const addAlphaToHexString = (hexString: string, alpha: number = DEFAULT_ALPHA): string => { - const fullHexString = expandHexToFullFormatIfItsShorthanded(hexString); + const hexStringWithoutHash = hexString.replace('#', ''); + const hexStringNormalized = hexStringWithoutHash.slice(ZERO, NORMALIZED_HEX_LENGTH); + const fullHexString = expandHexToFullFormatIfItsShorthanded(hexStringNormalized); const alphaHex = Math.round(alpha * MAX_RGB_VALUE) .toString(HEX_RADIX) diff --git a/tailwind.config.ts b/tailwind.config.ts index 0f75c8f70c01a9fc34cd4d0a7b4e4c4605fb3c36..e2c026d909241ac536fae67aa80e253c274c5280 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -37,6 +37,7 @@ const config: Config = { }, boxShadow: { primary: '4px 8px 32px 0px rgba(0, 0, 0, 0.12)', + tableBorderDivide: '0 0 0 1px #e1e0e6', }, dropShadow: { primary: '0px 4px 24px rgba(0, 0, 0, 0.08)',