diff --git a/package-lock.json b/package-lock.json index c057fa12..07d7f2f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "react-router-dom": "^5.2.0", "react-scripts": "^4.0.3", "react-select": "^3.1.0", + "react-tabs": "^4.2.1", "react-windowed-select": "^5.1.0" }, "devDependencies": { @@ -37,6 +38,7 @@ "@types/react-plotly.js": "^2.2.4", "@types/react-router-dom": "^5.1.7", "@types/react-select": "^4.0.16", + "@types/react-tabs": "^2.3.4", "@types/react-window": "^1.8.5", "@typescript-eslint/eslint-plugin": "^4.28.0", "@typescript-eslint/parser": "^4.28.0", @@ -4539,6 +4541,15 @@ "@types/react-transition-group": "*" } }, + "node_modules/@types/react-tabs": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@types/react-tabs/-/react-tabs-2.3.4.tgz", + "integrity": "sha512-HQzhKW+RF/7h14APw/2cu4Nnt+GmsTvfBKbFdn/NbYpb8Q+iB65wIkPHz4VRKDxWtOpNFpOUtzt5r0LRmQMfOA==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.5", "license": "MIT", @@ -18566,6 +18577,18 @@ "react": ">= 0.14.0" } }, + "node_modules/react-tabs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-tabs/-/react-tabs-4.2.1.tgz", + "integrity": "sha512-nQcEN3KrAsSry6f9Jz2oyMQsnh+sLEy31YjlskL/mnI3KU/c7BeyD1VzHZmmcJ15UEFu12pYOXYkdTzZ0uyIbw==", + "dependencies": { + "clsx": "^1.1.0", + "prop-types": "^15.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-0 || ^18.0.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "license": "BSD-3-Clause", @@ -27475,6 +27498,15 @@ "@types/react-transition-group": "*" } }, + "@types/react-tabs": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@types/react-tabs/-/react-tabs-2.3.4.tgz", + "integrity": "sha512-HQzhKW+RF/7h14APw/2cu4Nnt+GmsTvfBKbFdn/NbYpb8Q+iB65wIkPHz4VRKDxWtOpNFpOUtzt5r0LRmQMfOA==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-transition-group": { "version": "4.4.5", "requires": { @@ -37097,6 +37129,15 @@ "refractor": "^3.1.0" } }, + "react-tabs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-tabs/-/react-tabs-4.2.1.tgz", + "integrity": "sha512-nQcEN3KrAsSry6f9Jz2oyMQsnh+sLEy31YjlskL/mnI3KU/c7BeyD1VzHZmmcJ15UEFu12pYOXYkdTzZ0uyIbw==", + "requires": { + "clsx": "^1.1.0", + "prop-types": "^15.5.0" + } + }, "react-transition-group": { "version": "4.4.5", "requires": { diff --git a/package.json b/package.json index b0cfbc2e..a39d455f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "react-router-dom": "^5.2.0", "react-scripts": "^4.0.3", "react-select": "^3.1.0", + "react-tabs": "^4.2.1", "react-windowed-select": "^5.1.0" }, "scripts": { @@ -56,6 +57,7 @@ "@types/react-plotly.js": "^2.2.4", "@types/react-router-dom": "^5.1.7", "@types/react-select": "^4.0.16", + "@types/react-tabs": "^2.3.4", "@types/react-window": "^1.8.5", "@typescript-eslint/eslint-plugin": "^4.28.0", "@typescript-eslint/parser": "^4.28.0", diff --git a/src/components/Explorer/Base/Result/Result.tsx b/src/components/Explorer/Base/Result/Result.tsx index 3d87abec..b4f0a9c3 100644 --- a/src/components/Explorer/Base/Result/Result.tsx +++ b/src/components/Explorer/Base/Result/Result.tsx @@ -3,6 +3,7 @@ import { FamilyMatches } from './FamilyMatches' import { SequenceMatches } from './SequenceMatches' import { RunLookup } from './RunLookup' import { RangeFilter } from 'components/Explorer/types' +import { withTabs } from './withTabs' type Props = { searchLevel: string @@ -20,10 +21,20 @@ export const Result = ({ searchLevel, searchLevelValue, identityLims, scoreLims return } if (searchLevel === 'sequence') { - return + return withTabs({ + component: , + searchLevel, + searchLevelValue, + filters, + }) } if (searchLevel === 'family') { - return + return withTabs({ + component: , + searchLevel, + searchLevelValue, + filters, + }) } return null } diff --git a/src/components/Explorer/Base/Result/RunLookup/PalmPrintsTable.tsx b/src/components/Explorer/Base/Result/RunLookup/PalmPrintsTable.tsx index aba6cf85..b5249096 100644 --- a/src/components/Explorer/Base/Result/RunLookup/PalmPrintsTable.tsx +++ b/src/components/Explorer/Base/Result/RunLookup/PalmPrintsTable.tsx @@ -2,6 +2,7 @@ import React from 'react' import { NavLink } from 'react-router-dom' import { routes } from 'common/routes' import { filterObjects } from 'common/utils' +import { externalLinkIcon } from 'common' interface PalmPrintsTableProps { data: any[] | undefined @@ -68,7 +69,8 @@ export const PalmPrintsTable = ({ data, header }: PalmPrintsTableProps) => { - Analyse + Palmprint{' '} + {externalLinkIcon} diff --git a/src/components/Explorer/Base/Result/SerratusApiCalls.tsx b/src/components/Explorer/Base/Result/SerratusApiCalls.tsx index 4048afe7..b195d091 100644 --- a/src/components/Explorer/Base/Result/SerratusApiCalls.tsx +++ b/src/components/Explorer/Base/Result/SerratusApiCalls.tsx @@ -29,6 +29,27 @@ export const getMatchesDownloadUrl = ( return `${baseUrl}/matches/${searchType}/download?${urlParams}` } +export const fetchMatches = async ( + searchType: string, + searchLevel: string, + searchLevelValue: string, + filters: Filters, + columns: string[] +) => { + const [identityMin, identityMax] = filters.identityLims + const [scoreMin, scoreMax] = filters.scoreLims + const params = { + scoreMin: scoreMin.toString(), + scoreMax: scoreMax.toString(), + identityMin: identityMin.toString(), + identityMax: identityMax.toString(), + [searchLevel]: searchLevelValue, + columns: columns.join(','), + } + const response = await axios.get(`${baseUrl}/matches/${searchType}`, { params }) + return response.data +} + export const fetchPagedMatches = async ( searchType: string, searchLevel: string, @@ -95,10 +116,16 @@ export const fetchPagedRunMatches = async ( return response.data as ResultPagination } -export const fetchPagedGeoMatches = async (searchType: string, page: number, perPage: number) => { +export const fetchPagedGeoMatches = async ( + searchType: string, + page: number, + perPage: number, + runIds?: string[] +) => { const params: any = { page: page, perPage: perPage, + run: runIds?.length ? runIds.join(',') : '', } const response = await axios.get(`${baseUrl}/geo/${searchType}/paged`, { params: params, diff --git a/src/components/Explorer/Base/Result/withTabs.tsx b/src/components/Explorer/Base/Result/withTabs.tsx new file mode 100644 index 00000000..e2561105 --- /dev/null +++ b/src/components/Explorer/Base/Result/withTabs.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import { Geo } from 'components/Geo' +import { BaseContext } from 'components/Explorer/Base/BaseContext' +import { Tab, Tabs, TabList, TabPanel } from 'react-tabs' +import { Filters } from 'components/Explorer/types' +import { fetchMatches } from './SerratusApiCalls' +import 'react-tabs/style/react-tabs.css' + +type Props = { + component: React.ReactElement + searchLevel: string + searchLevelValue: string + filters: Filters +} + +export const withTabs = ({ component, searchLevel, searchLevelValue, filters }: Props) => { + const context = React.useContext(BaseContext) + const [runIds, setRunIds] = React.useState([]) + + const shouldRenderGeoTab = () => { + return context.searchType === 'rdrp' + } + + React.useEffect(() => { + async function onMount() { + const runIds = await fetchMatches( + context.searchType, + searchLevel, + searchLevelValue, + filters, + ['run_id'] + ) + setRunIds(runIds.map((row: any) => row.run_id)) + } + onMount() + }, []) + + const loading =
Loading... (this might take a while)
+ + return ( + + + Sequence + {shouldRenderGeoTab() ? Geo : null} + + + {component} + + {shouldRenderGeoTab() ? ( + {runIds.length ? : loading} + ) : null} + + ) +} diff --git a/src/components/Geo/Geo.tsx b/src/components/Geo/Geo.tsx index d6db36c1..fa04c088 100644 --- a/src/components/Geo/Geo.tsx +++ b/src/components/Geo/Geo.tsx @@ -17,7 +17,11 @@ import { import { SpeciesSelect } from './SpeciesSelect' import { TimePlot } from './TimePlot' -export const Geo = () => { +type Props = { + runIds?: string[] +} + +export const Geo = ({ runIds }: Props) => { const [isCollapsed, setIsCollapsed] = React.useState(false) const [isFetching, setIsFetching] = React.useState(true) const [paginatedRunData, setPaginatedRunData] = React.useState<{ @@ -26,6 +30,15 @@ export const Geo = () => { const [selectedPoints, setSelectedPoints] = React.useState([]) const [selectedSpecies, setSelectedSpecies] = React.useState([]) + const shouldFetchAll = () => !runIds || runIds?.length === 0 || runIds?.length > 100 + + React.useEffect(() => { + async function onMount() { + fetchRunData() + } + onMount() + }, []) + React.useEffect(() => { async function onMount() { fetchRunData() @@ -50,7 +63,8 @@ export const Geo = () => { let page = 1 const perPage = 20000 const searchType = 'rdrp' - const { result, total } = await fetchPagedGeoMatches(searchType, page, perPage) + const queryRunIds = shouldFetchAll() ? [] : runIds + const { result, total } = await fetchPagedGeoMatches(searchType, page, perPage, queryRunIds) storePaginatedRunData(result as RunData[], page) // Batch requests for remaining pages @@ -63,15 +77,23 @@ export const Geo = () => { } await Promise.allSettled( iterPages.map(async (page) => { - const { result } = await fetchPagedGeoMatches(searchType, page as number, perPage) + const { result } = await fetchPagedGeoMatches( + searchType, + page as number, + perPage, + queryRunIds + ) storePaginatedRunData(result as RunData[], page as number) }) ) setIsFetching(false) } + const runData = React.useMemo(() => { + return getRunDataFromPaginatedData(paginatedRunData, shouldFetchAll() ? runIds : []) + }, [isFetching]) + const speciesOptions = React.useMemo(() => { - const runData = getRunDataFromPaginatedData(paginatedRunData) const filteredBySelectedPoints = filterRunDataByGroup( runData, getBioIdsFromRunData(selectedPoints), @@ -81,10 +103,9 @@ export const Geo = () => { filteredBySelectedPoints.map((d) => d?.[RunDataKey.ScientificName]) ) return Array.from(speciesSet).sort() - }, [isFetching, selectedPoints, selectedSpecies]) + }, [runData, selectedPoints, selectedSpecies]) const filteredAndSelectedRows = React.useMemo(() => { - const runData = getRunDataFromPaginatedData(paginatedRunData) if (runData.length === 0) { return [] } @@ -98,10 +119,9 @@ export const Geo = () => { getBioIdsFromRunData(selectedPoints), RunDataKey.BiosampleId ) - }, [isFetching, selectedSpecies, selectedPoints]) + }, [runData, selectedSpecies, selectedPoints]) const mapData = React.useMemo(() => { - const runData = getRunDataFromPaginatedData(paginatedRunData) const filteredBySpecies = filterRunDataByGroup( runData, selectedSpecies, @@ -118,7 +138,7 @@ export const Geo = () => { selectedSpecies, getBioIdsFromRunData(selectedPoints) ) - }, [isFetching, selectedSpecies, selectedPoints]) + }, [runData, selectedSpecies, selectedPoints]) const timePlotData = React.useMemo(() => { const groupedCounter = countRunDataByDateAndKey( @@ -128,49 +148,73 @@ export const Geo = () => { return transformToTimePlotData(groupedCounter, selectedSpecies) }, [filteredAndSelectedRows]) + const loading =
Loading... (this might take a while)
+ return (
Serratus | Planetary RNA Virome
The Planetary RNA Virome
- -
-

- We searched 5.7 million public sequencing libraries for the RNA virus hallmark - gene, RNA-dependent RNA Polymerase (RdRP). -

- -

- This map shows the location of BioSamples from which an intact RdRP sequence - could be recovered and geographical meta-data was present. -

- -

A 100-meter randomization is applied to all points to prevent overplotting.

-
-
- -
-
- -
-
- Use `Shift` -click to select multiple points or the `Box Select` or{' '} - `Lasso Select` icons in the top-right. Double-click to deselect - points. -
- - + {!runData.length || isFetching ? ( + loading + ) : ( + <> +
+ +

+ {runData.length + ? `Geo data available for ${runData.length}/${ + !runIds || runIds?.length === 0 + ? runData.length + : runIds?.length + } runs` + : null} +

+
+
+

+ We searched 5.7 million public sequencing libraries for the RNA virus + hallmark gene, RNA-dependent RNA Polymerase (RdRP). +

+ +

+ This map shows the location of BioSamples from which an intact RdRP + sequence could be recovered and geographical meta-data was present. +

+ +

+ A 100-meter randomization is applied to all points to prevent + overplotting. +

+
+ +
+ +
+
+ +
+
+ `Shift`-click to select multiple points or use the{' '} + `Box Select` or `Lasso Select` icons in the top-right.{' '} + Double-click to deselect points. +
+ + + + )}
) } diff --git a/src/components/Geo/GeoHelpers.tsx b/src/components/Geo/GeoHelpers.tsx index 3df668b5..dc655383 100644 --- a/src/components/Geo/GeoHelpers.tsx +++ b/src/components/Geo/GeoHelpers.tsx @@ -19,8 +19,17 @@ export function getColorFromSelectedIndex( return selectedIndex >= 0 ? getColorFromIndex(selectedIndex) : defaultColor } -export function getRunDataFromPaginatedData(paginatedRunData: { [page: string]: RunData[] }) { - return Object.values(paginatedRunData).flat() +export function getRunDataFromPaginatedData( + paginatedRunData: { [page: string]: RunData[] }, + filterRunIds?: string[] +) { + const results = Object.values(paginatedRunData).flat() + if (!filterRunIds || filterRunIds?.length === 0) { + return results + } + const set = new Set(filterRunIds) + + return results.filter((row) => set.has(row.run_id)) } export function getBioIdsFromRunData(selectedPoints: RunData[]) { diff --git a/src/components/Geo/MapPlot.tsx b/src/components/Geo/MapPlot.tsx index 60a51c0d..f34e9067 100644 --- a/src/components/Geo/MapPlot.tsx +++ b/src/components/Geo/MapPlot.tsx @@ -23,7 +23,7 @@ export const MapPlot = ({ setSelectedPoints, plotData }: Props) => { }) React.useEffect(() => { - setConfig((prevConfig) => ({ ...prevConfig, data: plotData })) + setConfig((prevState) => ({ ...prevState, data: plotData })) }, [plotData]) function onSelected(selectedData: Readonly) { @@ -43,7 +43,12 @@ export const MapPlot = ({ setSelectedPoints, plotData }: Props) => { data: plotData, } as PlotParams) } - onUpdate={(figure) => setConfig(figure as PlotParams)} + onUpdate={(figure) => + setConfig({ + ...figure, + data: plotData, + } as PlotParams) + } useResizeHandler style={{ width: '100%', height: '100%', minHeight: '500px' }} onSelected={onSelected}