diff --git a/src/content/app/regulatory-activity-viewer/RegulatoryActivityViewer.tsx b/src/content/app/regulatory-activity-viewer/RegulatoryActivityViewer.tsx index 4b136ff1bc..64345c51a4 100644 --- a/src/content/app/regulatory-activity-viewer/RegulatoryActivityViewer.tsx +++ b/src/content/app/regulatory-activity-viewer/RegulatoryActivityViewer.tsx @@ -16,6 +16,10 @@ import noop from 'lodash/noop'; +import { useAppSelector } from 'src/store'; + +import { getActiveGenomeId } from 'src/content/app/regulatory-activity-viewer/state/general/generalSelectors'; + import { StandardAppLayout } from 'src/shared/components/layout'; import RegionOverview from './components/region-overview/RegionOverview'; import RegionActivitySection from './components/region-activity-section/RegionActivitySection'; @@ -36,12 +40,19 @@ const ActivityViewer = () => { }; const MainContent = () => { + const activeGenomeId = useAppSelector(getActiveGenomeId); + + if (!activeGenomeId) { + // this will be an interstitial in the future + return null; + } + return (
Hello activity viewer - +
- +
); }; diff --git a/src/content/app/regulatory-activity-viewer/components/activity-viewer-popup/AcrivityViewerPopup.module.css b/src/content/app/regulatory-activity-viewer/components/activity-viewer-popup/AcrivityViewerPopup.module.css new file mode 100644 index 0000000000..67ba77c375 --- /dev/null +++ b/src/content/app/regulatory-activity-viewer/components/activity-viewer-popup/AcrivityViewerPopup.module.css @@ -0,0 +1,15 @@ +.regularRow span + span { + margin-left: 1ch; +} + +.strand { + margin-right: 2.5ch; +} + +.light { + font-weight: var(--font-weight-light); +} + +.strong { + font-weight: var(--font-weight-bold); +} diff --git a/src/content/app/regulatory-activity-viewer/components/activity-viewer-popup/ActivityViewerPopup.tsx b/src/content/app/regulatory-activity-viewer/components/activity-viewer-popup/ActivityViewerPopup.tsx new file mode 100644 index 0000000000..35409e53b2 --- /dev/null +++ b/src/content/app/regulatory-activity-viewer/components/activity-viewer-popup/ActivityViewerPopup.tsx @@ -0,0 +1,75 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState, type ReactNode } from 'react'; +import classNames from 'classnames'; + +import PointerBox, { + Position +} from 'src/shared/components/pointer-box/PointerBox'; + +import pointerBoxStyles from 'src/shared/components/pointer-box/PointerBox.module.css'; +import toolboxStyles from 'src/shared/components/toolbox/Toolbox.module.css'; + +/** + * This component is similar to the "Zmenu" component of the genome browser. + * Since graphics in the regulatory activity viewer tend to be svgs, + * the component adds a tiny rect element as an anchor for the popup. + */ + +type Props = { + x: number; + y: number; + children: ReactNode; + onClose: () => void; +}; + +const ActivityViewerPopup = (props: Props) => { + const { x, y, children, onClose } = props; + const [anchorElement, setAnchorElement] = useState( + null + ); + + const pointerBoxClasses = classNames( + toolboxStyles.toolbox, + pointerBoxStyles.pointerBoxShadow + ); + + return ( + <> + + {anchorElement && ( + + {children} + + )} + + ); +}; + +export default ActivityViewerPopup; diff --git a/src/content/app/regulatory-activity-viewer/components/activity-viewer-popup/GenePopupContent.tsx b/src/content/app/regulatory-activity-viewer/components/activity-viewer-popup/GenePopupContent.tsx new file mode 100644 index 0000000000..7ea2df6f54 --- /dev/null +++ b/src/content/app/regulatory-activity-viewer/components/activity-viewer-popup/GenePopupContent.tsx @@ -0,0 +1,81 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getStrandDisplayName } from 'src/shared/helpers/formatters/strandFormatter'; +import { getFormattedLocation } from 'src/shared/helpers/formatters/regionFormatter'; + +import TextButton from 'src/shared/components/text-button/TextButton'; + +import type { GeneInRegionOverview } from 'src/content/app/regulatory-activity-viewer/types/regionOverview'; + +import styles from './AcrivityViewerPopup.module.css'; + +type GeneField = + | 'stable_id' + | 'symbol' + | 'unversioned_stable_id' + | 'biotype' + | 'strand' + | 'start' + | 'end'; + +type Props = { + gene: Pick & { region_name: string }; + onFocus: () => void; +}; + +const GenePopupContent = (props: Props) => { + const { gene } = props; + + const geneSymbolAndIdentifier = gene.symbol ? ( + <> + {gene.symbol} {gene.stable_id} + + ) : ( + {gene.stable_id} + ); + + return ( +
+
+ Gene + {geneSymbolAndIdentifier} +
+
+ Biotype + {gene.biotype} +
+
+ + {/* TODO: Change Strand enum into a union type */} + {getStrandDisplayName(gene.strand as any)}{' '} + + + {getFormattedLocation({ + chromosome: gene.region_name, + start: gene.start, + end: gene.end + })} + +
+
+ Make focus +
+
+ ); +}; + +export default GenePopupContent; diff --git a/src/content/app/regulatory-activity-viewer/components/region-activity-section/RegionActivitySection.tsx b/src/content/app/regulatory-activity-viewer/components/region-activity-section/RegionActivitySection.tsx index a133468e19..fb20c8b46f 100644 --- a/src/content/app/regulatory-activity-viewer/components/region-activity-section/RegionActivitySection.tsx +++ b/src/content/app/regulatory-activity-viewer/components/region-activity-section/RegionActivitySection.tsx @@ -17,8 +17,12 @@ import { useState, useEffect, useRef, useMemo } from 'react'; import classNames from 'classnames'; +import { useAppSelector } from 'src/store'; + import prepareFeatureTracks from 'src/content/app/regulatory-activity-viewer/helpers/prepare-feature-tracks/prepareFeatureTracks'; +import { getRegionDetailSelectedLocation } from 'src/content/app/regulatory-activity-viewer/state/region-detail/regionDetaillSelectors'; + import { useRegionOverviewQuery } from 'src/content/app/regulatory-activity-viewer/state/api/activityViewerApiSlice'; import RegionActivitySectionImage from './RegionActivitySectionImage'; @@ -37,9 +41,18 @@ import styles from './RegionActivitySection.module.css'; * or (more likely) from redux */ -const RegionActivitySection = () => { +type Props = { + activeGenomeId: string; +}; + +const RegionActivitySection = (props: Props) => { + const { activeGenomeId } = props; // TODO: think about how best to handle width changes; maybe they should come from the parent const [width, setWidth] = useState(0); + const regionDetailLocation = useAppSelector((state) => + getRegionDetailSelectedLocation(state, activeGenomeId) + ); + const { currentData } = useRegionOverviewQuery(); const imageContainerRef = useRef(null); @@ -64,21 +77,25 @@ const RegionActivitySection = () => { // TODO: below are hard-coded start and end of the selected segment of the region. // When the selection element is implemented, the selected start and end will come from user selection, probably via redux // REMEMBER to add selectionStart and selectionEnd to the list of dependencies of useMemo, when they start coming from user selection - const locationLength = location.end - location.start + 1; - const selectedStart = location.start + Math.round(0.2 * locationLength); - const selectedEnd = location.start + Math.round(0.4 * locationLength); + // const locationLength = location.end - location.start + 1; + // const selectedStart = location.start + Math.round(0.2 * locationLength); + // const selectedEnd = location.start + Math.round(0.4 * locationLength); + + const selectedStart = regionDetailLocation?.start ?? location.start; + const selectedEnd = regionDetailLocation?.end ?? location.end; const featureTracks = prepareFeatureTracks({ data: currentData, start: selectedStart, end: selectedEnd }); + return { featureTracks, start: selectedStart, end: selectedEnd }; - }, [currentData]); + }, [currentData, regionDetailLocation]); const componentClasses = classNames( styles.section, diff --git a/src/content/app/regulatory-activity-viewer/components/region-activity-section/RegionActivitySectionImage.tsx b/src/content/app/regulatory-activity-viewer/components/region-activity-section/RegionActivitySectionImage.tsx index 8805a36903..efb536ce15 100644 --- a/src/content/app/regulatory-activity-viewer/components/region-activity-section/RegionActivitySectionImage.tsx +++ b/src/content/app/regulatory-activity-viewer/components/region-activity-section/RegionActivitySectionImage.tsx @@ -18,6 +18,13 @@ import { scaleLinear } from 'd3'; import RegionDetailImage from './region-detail-image/RegionDetailImage'; +import { + GENE_TRACKS_TOP_OFFSET, + GENE_TRACK_HEIGHT, + REGULATORY_FEATURE_TRACKS_TOP_OFFSET, + REGULATORY_FEATURE_TRACK_HEIGHT +} from './region-detail-image/regionDetailConstants'; + import type { FeatureTracks } from 'src/content/app/regulatory-activity-viewer/helpers/prepare-feature-tracks/prepareFeatureTracks'; import type { OverviewRegion } from 'src/content/app/regulatory-activity-viewer/types/regionOverview'; @@ -43,8 +50,15 @@ const RegionActivitySectionImage = (props: Props) => { .domain([start, end]) .rangeRound([0, Math.floor(width)]); - // FIXME: height should be calculated from data (the number of tracks) - const height = 500; + const height = + GENE_TRACKS_TOP_OFFSET + + (featureTracks.geneTracks.forwardStrandTracks.length + + featureTracks.geneTracks.reverseStrandTracks.length) * + GENE_TRACK_HEIGHT + + REGULATORY_FEATURE_TRACKS_TOP_OFFSET + + featureTracks.regulatoryFeatureTracks.length * + REGULATORY_FEATURE_TRACK_HEIGHT + + 50; return ( { - const { scale, width, featureTracks } = props; + const { data, scale, width, featureTracks } = props; + const geneForwardStrandTracks = featureTracks.geneTracks.forwardStrandTracks; + const geneReverseStrandTracks = featureTracks.geneTracks.reverseStrandTracks; + + const regulatoryTracksOffsetTop = + GENE_TRACKS_TOP_OFFSET + + (geneForwardStrandTracks.length + geneReverseStrandTracks.length) * + GENE_TRACK_HEIGHT + + REGULATORY_FEATURE_TRACKS_TOP_OFFSET; return ( @@ -48,6 +62,12 @@ const RegionDetailImage = (props: Props) => { scale={scale} width={width} /> + ); }; @@ -128,4 +148,47 @@ const GeneTrack = (props: { return {geneElements}; }; +const RegulatoryFeatureTracks = (props: { + featureTracks: RegulatoryFeature[][]; + featureTypesMap: OverviewRegion['regulatory_features']['feature_types']; + offsetTop: number; + scale: ScaleLinear; +}) => { + const { featureTracks, featureTypesMap, offsetTop, scale } = props; + + const trackElements = featureTracks.map((track, index) => ( + + )); + + return {trackElements}; +}; + +const RegulatoryFeatureTrack = ({ + features, + featureTypesMap, + offsetTop, + scale +}: { + features: RegulatoryFeature[]; + featureTypesMap: OverviewRegion['regulatory_features']['feature_types']; + offsetTop: number; + scale: ScaleLinear; +}) => { + return features.map((feature) => ( + + )); +}; + export default RegionDetailImage; diff --git a/src/content/app/regulatory-activity-viewer/components/region-activity-section/region-detail-image/region-detail-regulatory-feature/RegionDetailRegulatoryFeature.tsx b/src/content/app/regulatory-activity-viewer/components/region-activity-section/region-detail-image/region-detail-regulatory-feature/RegionDetailRegulatoryFeature.tsx new file mode 100644 index 0000000000..ddf30c66cd --- /dev/null +++ b/src/content/app/regulatory-activity-viewer/components/region-activity-section/region-detail-image/region-detail-regulatory-feature/RegionDetailRegulatoryFeature.tsx @@ -0,0 +1,106 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ScaleLinear } from 'd3'; + +import { + REGULATORY_FEATURE_CORE_HEIGHT, + REGULATORY_FEATURE_EXTENT_HEIGHT +} from 'src/content/app/regulatory-activity-viewer/components/region-activity-section/region-detail-image/regionDetailConstants'; + +import type { + OverviewRegion, + RegulatoryFeature +} from 'src/content/app/regulatory-activity-viewer/types/regionOverview'; + +type Props = { + feature: RegulatoryFeature; + featureTypesMap: OverviewRegion['regulatory_features']['feature_types']; + offsetTop: number; + scale: ScaleLinear; +}; + +const RegionDetailRegulatoryFeature = (props: Props) => { + return ( + + + + + + ); +}; + +const CoreRegion = (props: Props) => { + const { scale, feature, featureTypesMap, offsetTop } = props; + + const x1 = scale(feature.start); + const x2 = scale(feature.end); + const width = x2 - x1; + const color = featureTypesMap[feature.feature_type].color; + + if (!width) { + return null; + } + + return ( + + ); +}; + +const BoundsRegion = (props: Props & { side: 'left' | 'right' }) => { + const { scale, feature, featureTypesMap, offsetTop, side } = props; + + const extentCoordinate = + side === 'left' ? feature.extended_start : feature.extended_end; + const isExtentSameAsCore = + side === 'left' + ? extentCoordinate === feature.start + : extentCoordinate === feature.end; + + if (!extentCoordinate || isExtentSameAsCore) { + return null; + } + + const extentX = scale(extentCoordinate); + const width = + side === 'left' + ? scale(feature.start) - extentX + : extentX - scale(feature.end); + const start = side === 'left' ? extentX : scale(feature.end); + const color = featureTypesMap[feature.feature_type].color; + + if (width <= 0) { + return null; + } + + return ( + + ); +}; + +export default RegionDetailRegulatoryFeature; diff --git a/src/content/app/regulatory-activity-viewer/components/region-activity-section/region-detail-image/regionDetailConstants.ts b/src/content/app/regulatory-activity-viewer/components/region-activity-section/region-detail-image/regionDetailConstants.ts index 5417ef05bb..95ec90aac6 100644 --- a/src/content/app/regulatory-activity-viewer/components/region-activity-section/region-detail-image/regionDetailConstants.ts +++ b/src/content/app/regulatory-activity-viewer/components/region-activity-section/region-detail-image/regionDetailConstants.ts @@ -17,3 +17,9 @@ export const GENE_TRACKS_TOP_OFFSET = 32; export const GENE_HEIGHT = 8; export const GENE_TRACK_HEIGHT = GENE_HEIGHT + 3; + +export const REGULATORY_FEATURE_TRACKS_TOP_OFFSET = 50; +export const REGULATORY_FEATURE_HEIGHT = 8; +export const REGULATORY_FEATURE_CORE_HEIGHT = 8; +export const REGULATORY_FEATURE_EXTENT_HEIGHT = 4; +export const REGULATORY_FEATURE_TRACK_HEIGHT = REGULATORY_FEATURE_HEIGHT + 10; diff --git a/src/content/app/regulatory-activity-viewer/components/region-overview/RegionOverview.tsx b/src/content/app/regulatory-activity-viewer/components/region-overview/RegionOverview.tsx index 99394e7a99..c7f7df5f0a 100644 --- a/src/content/app/regulatory-activity-viewer/components/region-overview/RegionOverview.tsx +++ b/src/content/app/regulatory-activity-viewer/components/region-overview/RegionOverview.tsx @@ -43,7 +43,12 @@ import styles from './RegionOverview.module.css'; * => This means that the toggling of the sidebar will result in re-rendering of everything */ -const RegionOverview = () => { +type Props = { + activeGenomeId: string; +}; + +const RegionOverview = (props: Props) => { + const { activeGenomeId } = props; const [width, setWidth] = useState(0); // FIXME: this is temporary; focus can also be a regulatory feature; should probably be reflected in url, and should be set via redux const [focusGeneId, setFocusGeneId] = useState(null); @@ -94,6 +99,7 @@ const RegionOverview = () => {
{currentData && featureTracks && width && ( { - const { width, featureTracks, data, focusGeneId } = props; + const { activeGenomeId, width, featureTracks, data, focusGeneId } = props; + const [imageRef, setImageRef] = useRefWithRerender(null); + const { imageHeight, regulatoryFeatureTracksTopOffset } = getImageHeightAndTopOffsets(featureTracks); @@ -74,6 +80,8 @@ const RegionOverviewImage = (props: Props) => { return ( { borderColor: 'var(--color-dark-grey)' }} > - - + > + + + ); }; const GeneTracks = (props: { + regionData: Props['data']; tracks: FeatureTracks['geneTracks']; scale: ScaleLinear; width: number; // full svg width @@ -107,14 +125,16 @@ const GeneTracks = (props: { onFocusGeneChange: Props['onFocusGeneChange']; }) => { const { forwardStrandTracks, reverseStrandTracks } = props.tracks; - let tempY = GENE_TRACKS_TOP_OFFSET; + let tempY = GENE_TRACKS_TOP_OFFSET; // keep track of the y coordinate for subsequent shapes to be drawn // calculate y-coordinates for gene tracks const forwardStrandTrackYs: number[] = []; const reverseStrandTrackYs: number[] = []; - for (let i = 0; i < forwardStrandTracks.length; i++) { - forwardStrandTrackYs.push(tempY); + // Andrea: forward strand genes above the central line should stack upwards + for (let i = forwardStrandTracks.length; i > 0; i--) { + const y = GENE_TRACKS_TOP_OFFSET + GENE_TRACK_HEIGHT * (i - 1); + forwardStrandTrackYs.push(y); tempY += GENE_TRACK_HEIGHT; } @@ -143,6 +163,7 @@ const GeneTracks = (props: { return tracks.map((track, index) => ( ; gene: GeneInTrack; + regionData: Pick; offsetTop: number; isFocused: boolean; onClick: (geneId: string) => void; @@ -47,11 +53,34 @@ type Intron = { end: number; }; +type PopupCoordinates = { + x: number; + y: number; +}; + const RegionOverviewGene = (props: Props) => { - const { gene, scale, offsetTop, isFocused } = props; + const { gene, regionData, scale, offsetTop, isFocused } = props; + const [popupCoordinates, setPopupCoordinates] = + useState(null); const transcript = gene.data.representative_transcript; const color = isFocused ? 'black' : '#0099ff'; // <-- This is our design system blue; see if it can be imported + const onClick = (event: MouseEvent) => { + // props.onClick(gene.data.stable_id); + const x = event.nativeEvent.offsetX; + const y = event.nativeEvent.offsetY; + setPopupCoordinates({ x, y }); + }; + + const closePopup = () => { + setPopupCoordinates(null); + }; + + const onGeneFocus = () => { + props.onClick(gene.data.stable_id); + closePopup(); + }; + const trackY = offsetTop; let transcriptStart: number | null = null; @@ -80,6 +109,13 @@ const RegionOverviewGene = (props: Props) => { return ( + { color={color} /> + + {popupCoordinates && ( + + + + )} ); }; @@ -195,22 +254,88 @@ const Introns = (props: { }); }; -const InteractiveArea = ( +const GeneExtent = ( props: Props & { - start: number; // <-- in genomic coordinates - end: number; // <-- in genomic coordinates + transcriptStart: number; + transcriptEnd: number; + color: string; + direction: 'left' | 'right'; } ) => { - const { gene, offsetTop, start, end, scale } = props; + const { + scale, + gene, + transcriptStart, + transcriptEnd, + direction, + offsetTop, + color + } = props; + + const width = + direction === 'left' + ? scale(transcriptStart) - scale(gene.data.start) + : scale(gene.data.end) - scale(transcriptEnd); + + if (width < 2) { + return null; + } + + const x1 = + direction === 'left' ? scale(gene.data.start) : scale(transcriptEnd); + const x2 = + direction === 'left' ? scale(transcriptStart) : scale(gene.data.end); + + const lineY = offsetTop + +GENE_HEIGHT / 2; + const endpointX = direction === 'left' ? x1 : x2; + + // horizontal line + const line = ( + + ); + + const endpoint = ( + + ); + + return ( + <> + {line} + {endpoint} + + ); +}; + +const InteractiveArea = (props: { + gene: Props['gene']; + offsetTop: Props['offsetTop']; + scale: Props['scale']; + start: number; + end: number; + onClick: (event: MouseEvent) => void; +}) => { + const { gene, offsetTop, scale } = props; + const { start, end } = gene.data; const x = scale(start); const width = scale(end) - scale(start); const y = offsetTop; const height = GENE_HEIGHT; - const onClick = () => { - props.onClick(gene.data.stable_id); - }; - return ( ); diff --git a/src/content/app/regulatory-activity-viewer/components/region-overview/region-overview-image/region-overview-location-selector/RegionOverviewLocationSelector.tsx b/src/content/app/regulatory-activity-viewer/components/region-overview/region-overview-image/region-overview-location-selector/RegionOverviewLocationSelector.tsx new file mode 100644 index 0000000000..78f10a6240 --- /dev/null +++ b/src/content/app/regulatory-activity-viewer/components/region-overview/region-overview-image/region-overview-location-selector/RegionOverviewLocationSelector.tsx @@ -0,0 +1,186 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { type MutableRefObject, type ReactNode } from 'react'; +import { type ScaleLinear } from 'd3'; + +import { useAppDispatch } from 'src/store'; + +import useLocationSelector from './useLocationSelector'; + +import { setRegionDetailLocation } from 'src/content/app/regulatory-activity-viewer/state/region-detail/regionDetailSlice'; + +/** + * RULES: + * - Do not let the selection continue outside of the svg + * - There should probably be a minimum possible selection + */ + +type Props = { + activeGenomeId: string; + imageRef: MutableRefObject; + height: number; + width: number; + scale: ScaleLinear; + children: ReactNode; +}; + +const RegionOverviewLocationSelector = (props: Props) => { + const { activeGenomeId, scale, imageRef, children } = props; + const dispatch = useAppDispatch(); + + const onSelectionCompleted = (params: { start: number; end: number }) => { + const { start, end } = params; + const genomicStart = Math.round(scale.invert(start)); + const genomicEnd = Math.round(scale.invert(end)); + + dispatch( + setRegionDetailLocation({ + genomeId: activeGenomeId, + location: { + start: genomicStart, + end: genomicEnd + } + }) + ); + }; + + const selectedLocation = useLocationSelector({ + ref: imageRef, + onSelectionCompleted + }); + + // TODO: get the red colour from the CSS variable + + const filterId = 'greyscale'; + + return ( + <> + {selectedLocation && ( + + )} + {children} + {selectedLocation && ( + <> + + + + )} + + ); +}; + +const Filter = ({ + id, + positionLeft, + positionRight, + height, + width +}: { + id: string; + height: number; // <-- total height of the image + width: number; // <-- total width of the image + positionLeft: number; // <-- left coordinate of the selection area + positionRight: number; // <-- right coordinate of the selection area +}) => { + const rightFilterWidth = width - positionRight; + + return ( + + + + + + + + + + + + ); +}; + +/** + * The purpose of this component is to act as a shield from user's clicks + * over the area outside the selection + */ +const InertAreas = ({ + height, + width, + positionLeft, + positionRight +}: { + height: number; // <-- total height of the image + width: number; // <-- total width of the image + positionLeft: number; // <-- left coordinate of the selection area + positionRight: number; // <-- right coordinate of the selection area +}) => { + return ( + + + + + ); +}; + +export default RegionOverviewLocationSelector; diff --git a/src/content/app/regulatory-activity-viewer/components/region-overview/region-overview-image/region-overview-location-selector/useLocationSelector.ts b/src/content/app/regulatory-activity-viewer/components/region-overview/region-overview-image/region-overview-location-selector/useLocationSelector.ts new file mode 100644 index 0000000000..177097b340 --- /dev/null +++ b/src/content/app/regulatory-activity-viewer/components/region-overview/region-overview-image/region-overview-location-selector/useLocationSelector.ts @@ -0,0 +1,194 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useReducer, useRef, type MutableRefObject } from 'react'; + +/** + * TODO: + * - consider how this selection will be reconciled with selection start and end + * that are passed from the parent (i.e. those would inevitably have to live in redux) + * - until selection has been committed, allow dismissing it upon pressing the escape key + */ + +/** + * Actions: + * - start selection + * - update selection + * - clear selection + */ + +type State = { + originX: number; + latestX: number; +} | null; + +type SelectionStartAction = { + type: 'selection-start'; + payload: { + originX: number; + latestX: number; + }; +}; + +type SelectionUpdateAction = { + type: 'selection-update'; + payload: { + x: number; + }; +}; + +type SelectionClearAction = { + type: 'selection-clear'; +}; + +type Action = + | SelectionStartAction + | SelectionUpdateAction + | SelectionClearAction; + +const selectionStateReducer = (state: State, action: Action): State => { + if (action.type === 'selection-start') { + return { + originX: action.payload.originX, + latestX: action.payload.latestX + }; + } else if (action.type === 'selection-update') { + if (!state) { + // this should not happen + return null; + } + + return { + ...state, + latestX: action.payload.x + }; + } else { + return null; + } +}; + +const useLocationSelector = ({ + ref, + onSelectionCompleted +}: { + ref: MutableRefObject; + onSelectionCompleted: (coords: { start: number; end: number }) => void; +}) => { + const [state, dispatch] = useReducer(selectionStateReducer, null); + const stateRef = useRef(state); + const selectionOriginXRef = useRef(null); + const containerBoundingClientRef = useRef(null); + + useEffect(() => { + if (ref.current) { + containerBoundingClientRef.current = ref.current.getBoundingClientRect(); + + ref.current.addEventListener('mousedown', onMouseDown as EventListener); + // ref.current.addEventListener('touchstart', onSelectionStart as EventListener); + } + + return () => { + if (!ref.current) { + return; + } + ref.current.removeEventListener( + 'mousedown', + onMouseDown as EventListener + ); + // ref.current.removeEventListener('touchstart', onSelectionStart as EventListener); + }; + }, [ref.current]); + + // NOTE: this might become unnecessary when the new useEffectEvent hook is released + useEffect(() => { + stateRef.current = state; + }, [state]); + + const onMouseDown = (event: MouseEvent) => { + const targetElement = event.target; + if (targetElement !== ref.current) { + return; + } + selectionOriginXRef.current = event.offsetX; + document.addEventListener('mousemove', detectSelectionStart); + }; + + const detectSelectionStart = (event: MouseEvent) => { + const currentX = event.offsetX; + const distance = Math.abs( + currentX - (selectionOriginXRef.current as number) + ); + + // start the selection after a minimum distance from the origin has been reached + if (distance > 5) { + onSelectionStart(event); + document.removeEventListener('mousemove', detectSelectionStart); + } + }; + + const onSelectionStart = (event: MouseEvent | TouchEvent) => { + if (event instanceof MouseEvent) { + const startPosition = event.offsetX; + dispatch({ + type: 'selection-start', + payload: { + originX: selectionOriginXRef.current as number, + latestX: startPosition + } + }); + + document.addEventListener('mousemove', onSelectionChange); + document.addEventListener('mouseup', onSelectionEnd); + } + }; + + const onSelectionChange = (event: MouseEvent | TouchEvent) => { + if (event instanceof MouseEvent) { + const newX = event.clientX - containerBoundingClientRef.current!.x; + if (newX < 0 || newX > containerBoundingClientRef.current!.width) { + return; + } + + dispatch({ + type: 'selection-update', + payload: { x: newX } + }); + } + }; + + const onSelectionEnd = () => { + if (stateRef.current) { + onSelectionCompleted(stateToRectCoordinates(stateRef.current)); + } + removeAllListeners(); + }; + + const removeAllListeners = () => { + document.removeEventListener('mousemove', onSelectionChange); + document.removeEventListener('mousemove', detectSelectionStart); + }; + + return state ? stateToRectCoordinates(state) : null; +}; + +const stateToRectCoordinates = (state: NonNullable) => { + return { + start: Math.min(state.originX, state.latestX), + end: Math.max(state.originX, state.latestX) + }; +}; + +export default useLocationSelector; diff --git a/src/content/app/regulatory-activity-viewer/components/region-overview/region-overview-image/transcription-start-site/TranscriptionStartSite.tsx b/src/content/app/regulatory-activity-viewer/components/region-overview/region-overview-image/transcription-start-site/TranscriptionStartSite.tsx index 3532a6145c..879e6acff4 100644 --- a/src/content/app/regulatory-activity-viewer/components/region-overview/region-overview-image/transcription-start-site/TranscriptionStartSite.tsx +++ b/src/content/app/regulatory-activity-viewer/components/region-overview/region-overview-image/transcription-start-site/TranscriptionStartSite.tsx @@ -43,10 +43,43 @@ type Props = { scale: ScaleLinear; }; -const TranscriptionStartSite = (props: Props) => { - const { yStart, yEnd, tss, strand, scale } = props; +/** + Design guidelines from Andrea: + + shorter arrow + height: 12 + horizontal: 6 + + arrowhead: + base 6 + height: 6.75 + + + + longer arrow + height: 19 + + + distance between: 1? - const stemX = scale(tss[0].position); + + distance from gene: 5 + */ + +const TranscriptionStartSites = (props: Props) => { + const { tss } = props; + + return tss.map((site) => ( + + )); +}; + +const TranscriptionStartSite = ( + props: Props & { site: GeneInRegionOverview['tss'][number] } +) => { + const { yStart, yEnd, strand, scale, site } = props; + + const stemX = scale(site.position); const armEndX = strand === 'forward' ? stemX + HORIZONTAL_ARM_LENGTH @@ -60,11 +93,6 @@ const TranscriptionStartSite = (props: Props) => { const arrowheadBaseTopCoords = `${armEndX}, ${yEnd + ARROWHEAD_BASE_LENGTH / 2}`; const arrowheadPointCoords = `${arrowheadPointX}, ${yEnd}`; - const labelX = - strand === 'forward' ? arrowheadPointX + 8 : arrowheadPointX - 8; - const labelY = yEnd; - const textAnchor = strand === 'forward' ? 'start' : 'end'; - return ( { - - Transcription start - ); }; -export default TranscriptionStartSite; +export default TranscriptionStartSites; diff --git a/src/content/app/regulatory-activity-viewer/helpers/prepare-feature-tracks/prepareFeatureTracks.ts b/src/content/app/regulatory-activity-viewer/helpers/prepare-feature-tracks/prepareFeatureTracks.ts index e16eea91c6..0a866d7017 100644 --- a/src/content/app/regulatory-activity-viewer/helpers/prepare-feature-tracks/prepareFeatureTracks.ts +++ b/src/content/app/regulatory-activity-viewer/helpers/prepare-feature-tracks/prepareFeatureTracks.ts @@ -195,11 +195,27 @@ const isFeatureInsideSelection = (params: { return true; } + const isExtendedStartInsideSelection = feature.extended_start + ? feature.extended_start > start && feature.extended_start < end + : false; + const isExtendedEndInsideSelection = feature.extended_end + ? feature.extended_end > start && feature.extended_end < end + : false; + const isFeatureStartInsideSelection = + feature.start > start && feature.start < end; + const isFeatureEndInsideSelection = feature.end > start && feature.end < end; + // for features that fill the viewport and have start/end hanging outside + const isOverhangingFeature = + ((feature.extended_start && feature.extended_start < start) || + feature.start < start) && + ((feature.extended_end && feature.extended_end > end) || feature.end > end); + return ( - (feature.extended_start && feature.extended_start < end) || - (feature.extended_end && feature.extended_end > start) || - feature.start < end || - feature.end > start + isExtendedStartInsideSelection || + isExtendedEndInsideSelection || + isFeatureStartInsideSelection || + isFeatureEndInsideSelection || + isOverhangingFeature ); }; diff --git a/src/content/app/regulatory-activity-viewer/state/general/generalSelectors.ts b/src/content/app/regulatory-activity-viewer/state/general/generalSelectors.ts new file mode 100644 index 0000000000..96e00397eb --- /dev/null +++ b/src/content/app/regulatory-activity-viewer/state/general/generalSelectors.ts @@ -0,0 +1,20 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { RootState } from 'src/store'; + +export const getActiveGenomeId = (state: RootState) => + state.regionActivityViewer.general.activeGenomeId; diff --git a/src/content/app/regulatory-activity-viewer/state/general/generalSlice.ts b/src/content/app/regulatory-activity-viewer/state/general/generalSlice.ts new file mode 100644 index 0000000000..feb1a24b54 --- /dev/null +++ b/src/content/app/regulatory-activity-viewer/state/general/generalSlice.ts @@ -0,0 +1,37 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +type State = { + activeGenomeId: string | null; +}; + +const initialState: State = { + activeGenomeId: 'test-id' +}; + +const generalSlice = createSlice({ + name: 'regulatory-activity-viewer-general', + initialState, + reducers: { + setActiveGenomeId(state, action: PayloadAction) { + state.activeGenomeId = action.payload; + } + } +}); + +export default generalSlice.reducer; diff --git a/src/content/app/regulatory-activity-viewer/state/region-detail/regionDetailSlice.ts b/src/content/app/regulatory-activity-viewer/state/region-detail/regionDetailSlice.ts new file mode 100644 index 0000000000..c54c2670a4 --- /dev/null +++ b/src/content/app/regulatory-activity-viewer/state/region-detail/regionDetailSlice.ts @@ -0,0 +1,59 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +type StatePerGenome = { + selectedLocation: { + start: number; + end: number; + } | null; +}; + +// states for different genomes are keyed by their genome id +type State = Record; + +const initialStatePerGenome: StatePerGenome = { + selectedLocation: null +}; + +const ensureStatePerGenome = (state: State, genomeId: string) => { + if (!state[genomeId]) { + state[genomeId] = structuredClone(initialStatePerGenome); + } +}; + +const regionDetailSlice = createSlice({ + name: 'regulatory-activity-viewer-region-in-detail', + initialState: {} as State, + reducers: { + setRegionDetailLocation( + state, + action: PayloadAction<{ + genomeId: string; + location: StatePerGenome['selectedLocation']; + }> + ) { + const { genomeId, location } = action.payload; + ensureStatePerGenome(state, genomeId); + state[genomeId].selectedLocation = location; + } + } +}); + +export const { setRegionDetailLocation } = regionDetailSlice.actions; + +export default regionDetailSlice.reducer; diff --git a/src/content/app/regulatory-activity-viewer/state/region-detail/regionDetaillSelectors.ts b/src/content/app/regulatory-activity-viewer/state/region-detail/regionDetaillSelectors.ts new file mode 100644 index 0000000000..9831643dd5 --- /dev/null +++ b/src/content/app/regulatory-activity-viewer/state/region-detail/regionDetaillSelectors.ts @@ -0,0 +1,23 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { RootState } from 'src/store'; + +export const getRegionDetailSelectedLocation = ( + state: RootState, + genomeId: string +) => + state.regionActivityViewer.regionDetail[genomeId]?.selectedLocation ?? null; diff --git a/src/content/app/regulatory-activity-viewer/state/regulatoryActivityViewerReducer.ts b/src/content/app/regulatory-activity-viewer/state/regulatoryActivityViewerReducer.ts new file mode 100644 index 0000000000..1d5782b23c --- /dev/null +++ b/src/content/app/regulatory-activity-viewer/state/regulatoryActivityViewerReducer.ts @@ -0,0 +1,25 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { combineReducers } from 'redux'; + +import generalSlice from './general/generalSlice'; +import regionDetailReducer from './region-detail/regionDetailSlice'; + +export default combineReducers({ + general: generalSlice, + regionDetail: regionDetailReducer +}); diff --git a/src/root/rootReducer.ts b/src/root/rootReducer.ts index decdcaf0d8..6e1153382d 100644 --- a/src/root/rootReducer.ts +++ b/src/root/rootReducer.ts @@ -24,6 +24,7 @@ import inAppSearch from 'src/shared/state/in-app-search/inAppSearchSlice'; import communication from 'src/shared/state/communication/communicationSlice'; import speciesSelector from 'src/content/app/species-selector/state/speciesSelectorReducer'; import entityViewer from 'src/content/app/entity-viewer/state/entityViewerReducer'; +import regionActivityViewer from 'src/content/app/regulatory-activity-viewer/state/regulatoryActivityViewerReducer'; import speciesPage from 'src/content/app/species/state/index'; import blast from 'src/content/app/tools/blast/state/blastReducer'; import vep from 'src/content/app/tools/vep/state/vepReducer'; @@ -42,6 +43,7 @@ const createRootReducer = () => speciesSelector, speciesPage, entityViewer, + regionActivityViewer, blast, vep, [graphqlApiSlice.reducerPath]: graphqlApiSlice.reducer, diff --git a/src/shared/components/pointer-box/PointerBox.tsx b/src/shared/components/pointer-box/PointerBox.tsx index f96dd9fb15..5e538b622e 100644 --- a/src/shared/components/pointer-box/PointerBox.tsx +++ b/src/shared/components/pointer-box/PointerBox.tsx @@ -52,7 +52,7 @@ type PointerProps = { export type PointerBoxProps = { position?: Position; - anchor: HTMLElement; + anchor: Element; container?: HTMLElement | null; // area within which the box should try to position itself; defaults to window if null autoAdjust?: boolean; // whether to adjust pointer box position so as not to extend beyond screen bounds renderInsideAnchor?: boolean; // whether to render PointerBox inside the anchor (which should have position: relative to display it properly); renders to body if false @@ -129,7 +129,10 @@ const PointerBox = (props: PointerBoxProps) => { } }; - const adjustPosition = (pointerBox: HTMLDivElement, anchor: HTMLElement) => { + const adjustPosition = ( + pointerBox: HTMLDivElement, + anchor: PointerBoxProps['anchor'] + ) => { const pointerBoxBoundingRect = pointerBox.getBoundingClientRect(); const rootBoundingRect = props.container ? props.container.getBoundingClientRect()