mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Add locality & species combined export feature
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { Request, Router } from 'express'
|
||||
import { getAllLocalities, getLocalityDetails, validateEntireLocality } from '../services/locality'
|
||||
import {
|
||||
getAllLocalities,
|
||||
getLocalityDetails,
|
||||
getLocalitySpeciesList,
|
||||
validateEntireLocality,
|
||||
} from '../services/locality'
|
||||
import { fixBigInt } from '../utils/common'
|
||||
import { EditDataType, LocalityDetailsType } from '../../../frontend/src/backendTypes'
|
||||
import { write } from '../services/write/write'
|
||||
@@ -9,11 +14,18 @@ import { Role } from '../../../frontend/src/types'
|
||||
const router = Router()
|
||||
|
||||
router.get('/all', async (req, res) => {
|
||||
const showAll = req.user && [Role.Admin, Role.EditUnrestricted].includes(req.user.role)
|
||||
const localities = await getAllLocalities(showAll, req.user)
|
||||
const localities = await getAllLocalities(req.user)
|
||||
return res.status(200).send(fixBigInt(localities))
|
||||
})
|
||||
|
||||
router.post('/localitySpecies', async (req: Request<object, object, { lids: number[] }>, res) => {
|
||||
const user = req.user
|
||||
const lids = req.body.lids
|
||||
if (!lids) return res.status(400).send({ message: 'Missing lids' })
|
||||
const localitySpeciesList = await getLocalitySpeciesList(lids, user)
|
||||
return res.status(200).json(localitySpeciesList)
|
||||
})
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const id = parseInt(req.params.id)
|
||||
const locality = await getLocalityDetails(id, req.user)
|
||||
|
||||
@@ -4,9 +4,137 @@ import Prisma from '../../prisma/generated/now_test_client'
|
||||
import { validateLocality } from '../../../frontend/src/validators/locality'
|
||||
import { ValidationObject } from '../../../frontend/src/validators/validator'
|
||||
import { fixBigInt } from '../utils/common'
|
||||
import { Role } from '../../../frontend/src/types'
|
||||
import { Role, calculateMeanHypsodonty } from '../../../frontend/src/types'
|
||||
import { AccessError } from '../middlewares/authorizer'
|
||||
|
||||
export const getLocalitySpeciesList = async (lids: number[], user: User) => {
|
||||
// Get localities separately with getAllLocalities to know which localities user has access to.
|
||||
// Unoptimal, if it's slow try a different way.
|
||||
const localityList = await getAllLocalities(user)
|
||||
|
||||
const lidsSet = new Set(lids)
|
||||
const premittedLids = localityList.filter(loc => lidsSet.has(loc.lid)).map(loc => loc.lid)
|
||||
|
||||
const localitySpecies = await nowDb.now_loc.findMany({
|
||||
select: {
|
||||
lid: true,
|
||||
loc_name: true,
|
||||
dms_lat: true,
|
||||
dms_long: true,
|
||||
dec_lat: true,
|
||||
dec_long: true,
|
||||
altitude: true,
|
||||
max_age: true,
|
||||
bfa_max: true,
|
||||
bfa_max_abs: true,
|
||||
frac_max: true,
|
||||
min_age: true,
|
||||
bfa_min: true,
|
||||
bfa_min_abs: true,
|
||||
frac_min: true,
|
||||
chron: true,
|
||||
age_comm: true,
|
||||
basin: true,
|
||||
subbasin: true,
|
||||
country: true,
|
||||
state: true,
|
||||
county: true,
|
||||
appr_num_spm: true,
|
||||
gen_loc: true,
|
||||
now_syn_loc: true,
|
||||
estimate_precip: true,
|
||||
estimate_npp: true,
|
||||
pers_woody_cover: true,
|
||||
pers_pollen_nap: true,
|
||||
pers_pollen_other: true,
|
||||
now_ls: {
|
||||
include: { com_species: true },
|
||||
},
|
||||
},
|
||||
where: {
|
||||
lid: {
|
||||
in: premittedLids,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// TODO calc mean hypsodonty & add title
|
||||
|
||||
const localityTitles = [
|
||||
'lid',
|
||||
'loc_name',
|
||||
'dms_lat',
|
||||
'dms_long',
|
||||
'dec_lat',
|
||||
'dec_long',
|
||||
'altitude',
|
||||
'max_age',
|
||||
'bfa_max',
|
||||
'bfa_max_abs',
|
||||
'frac_max',
|
||||
'min_age',
|
||||
'bfa_min',
|
||||
'bfa_min_abs',
|
||||
'frac_min',
|
||||
'chron',
|
||||
'age_comm',
|
||||
'basin',
|
||||
'subbasin',
|
||||
'country',
|
||||
'state',
|
||||
'county',
|
||||
'appr_num_spm',
|
||||
'gen_loc',
|
||||
'now_syn_loc',
|
||||
'estimate_precip',
|
||||
'estimate_npp',
|
||||
'pers_woody_cover',
|
||||
'pers_pollen_nap',
|
||||
'pers_pollen_other',
|
||||
'mean hypsodonty',
|
||||
]
|
||||
|
||||
const formatValue = (value: string | number | boolean | null | object | bigint) => {
|
||||
if (Array.isArray(value)) return `"${value.map((value: { synonym: string }) => value.synonym).join(', ')}"`
|
||||
if (typeof value === 'object' && value !== null)
|
||||
throw new Error('Internal error: Unexpected non-array object in export data')
|
||||
if (typeof value === 'bigint') return Number(BigInt)
|
||||
return `"${value}"`
|
||||
}
|
||||
|
||||
const speciesTitles = Object.keys(nowDb.com_species.fields)
|
||||
const lsTitles = Object.keys(nowDb.now_ls.fields)
|
||||
|
||||
const titleRow = [...localityTitles, ...speciesTitles, ...lsTitles]
|
||||
|
||||
const data = localitySpecies.flatMap(localityWithSpecies => {
|
||||
const { now_ls, ...locality } = localityWithSpecies
|
||||
const meanHypsodonty = calculateMeanHypsodonty(localityWithSpecies as unknown as LocalityDetailsType)
|
||||
const speciesList =
|
||||
now_ls.length > 0
|
||||
? now_ls.map(ls => {
|
||||
const { com_species, ...nowLs } = ls
|
||||
return [
|
||||
...Object.values(locality).map(value => formatValue(value)),
|
||||
meanHypsodonty,
|
||||
...Object.values(com_species).map(value => formatValue(value)),
|
||||
...Object.values(nowLs).map(value => formatValue(value)),
|
||||
]
|
||||
})
|
||||
: [
|
||||
[
|
||||
...Object.values(locality).map(value => formatValue(value)),
|
||||
meanHypsodonty,
|
||||
...speciesTitles.map(() => ''),
|
||||
...lsTitles.map(() => ''),
|
||||
],
|
||||
]
|
||||
return speciesList
|
||||
})
|
||||
|
||||
return [titleRow, ...data]
|
||||
}
|
||||
|
||||
const getIdsOfUsersProjects = async (user: User) => {
|
||||
const usersProjects = await nowDb.now_proj_people.findMany({
|
||||
where: { initials: user.initials },
|
||||
@@ -16,10 +144,25 @@ const getIdsOfUsersProjects = async (user: User) => {
|
||||
return new Set(usersProjects.map(({ pid }) => pid))
|
||||
}
|
||||
|
||||
export const getAllLocalities = async (showAll: boolean, user?: User) => {
|
||||
const removeProjects: (item: { now_plr: unknown }) => unknown = item => {
|
||||
type LocalityListType = {
|
||||
lid: number
|
||||
bfa_max: string | null
|
||||
bfa_min: string | null
|
||||
loc_name: string
|
||||
max_age: number
|
||||
min_age: number
|
||||
country: string | null
|
||||
loc_status: boolean | null
|
||||
now_plr: {
|
||||
pid: number
|
||||
}[]
|
||||
}
|
||||
|
||||
export const getAllLocalities = async (user?: User) => {
|
||||
const showAll = user && [Role.Admin, Role.EditUnrestricted].includes(user.role)
|
||||
const removeProjects: (loc: LocalityListType) => Omit<LocalityListType, 'now_plr'> = loc => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { now_plr, ...rest } = item
|
||||
const { now_plr, ...rest } = loc
|
||||
return rest
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ import Box from '@mui/material/Box'
|
||||
|
||||
export const FrontPage = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ fontSize: 20 }}>
|
||||
<h1>NOW Database</h1>
|
||||
<p>Site under construction.</p>
|
||||
{ENV !== 'prod' && (
|
||||
<p>
|
||||
<b>This version is running on test data. May contain incorrect and outdated information.</b>
|
||||
@@ -16,9 +15,8 @@ export const FrontPage = () => {
|
||||
<a href="https://github.com/nowcommunity/nowdatabase">https://github.com/nowcommunity/nowdatabase</a>
|
||||
</p>
|
||||
<p>
|
||||
Have any questions or suggestions for improvement? Make an issue by logging into GitHub and clicking the
|
||||
"New Issue" -button on this page:{' '}
|
||||
<a href="https://github.com/nowcommunity/nowdatabase/issue">GitHub Issues</a>
|
||||
Have any questions or suggestions for improvement? Make an in GitHub by clicking "New Issue" (requires
|
||||
a GitHub user): <a href="https://github.com/nowcommunity/nowdatabase/issue">GitHub Issues</a>
|
||||
</p>
|
||||
<p>
|
||||
The old application: <a href="https://nowdatabase.luomus.fi/">https://nowdatabase.luomus.fi/</a>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useMemo } from 'react'
|
||||
import { type MRT_ColumnDef } from 'material-react-table'
|
||||
import { useGetAllLocalitiesQuery } from '../../redux/localityReducer'
|
||||
import { useGetAllLocalitiesQuery, useGetLocalitySpeciesListMutation } from '../../redux/localityReducer'
|
||||
import { Locality } from '@/backendTypes'
|
||||
import { TableView } from '../TableView/TableView'
|
||||
|
||||
export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Locality) => void }) => {
|
||||
const localitiesQuery = useGetAllLocalitiesQuery()
|
||||
const [getLocalitySpeciesList] = useGetLocalitySpeciesListMutation()
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<Locality>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -33,6 +35,18 @@ export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Localit
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const combinedExport = async (lids: number[]) => {
|
||||
const result = await getLocalitySpeciesList(lids).unwrap()
|
||||
const dataString = result.map(row => (row as unknown[]).join(',')).join('\n')
|
||||
const blob = new Blob([dataString], { type: 'text/csv' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'localitiesWithSpecies.csv'
|
||||
a.click()
|
||||
}
|
||||
|
||||
const checkRowRestriction = (row: Locality) => {
|
||||
return !!row.loc_status
|
||||
}
|
||||
@@ -46,6 +60,7 @@ export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Localit
|
||||
columns={columns}
|
||||
data={localitiesQuery.data}
|
||||
url="locality"
|
||||
combinedExport={combinedExport}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,46 +1,13 @@
|
||||
import { LocalityDetailsType } from '@/backendTypes'
|
||||
import { ArrayFrame } from '../../DetailView/common/tabLayoutHelpers'
|
||||
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
|
||||
import { calculateMeanHypsodonty } from '@/types'
|
||||
|
||||
export const EcometricsTab = () => {
|
||||
const { textField, data } = useDetailContext<LocalityDetailsType>()
|
||||
|
||||
// Mean hypsodonty calculation has been made based on html/include/database.php line 2567 onwards
|
||||
|
||||
const relevantOrderNames = [
|
||||
'Perissodactyla',
|
||||
'Artiodactyla',
|
||||
'Primates',
|
||||
'Proboscidea',
|
||||
'Hyracoidea',
|
||||
'Dinocerata',
|
||||
'Embrithopoda',
|
||||
'Notoungulata',
|
||||
'Astrapotheria',
|
||||
'Pyrotheria',
|
||||
'Litopterna',
|
||||
'Condylarthra',
|
||||
'Pantodonta',
|
||||
]
|
||||
|
||||
const thtToValue = {
|
||||
bra: 1.0,
|
||||
mes: 2.0,
|
||||
hyp: 3.0,
|
||||
hys: 3.0,
|
||||
none: 0.0,
|
||||
} as Record<string, number>
|
||||
|
||||
const species = data.now_ls
|
||||
.map(ls => ls.com_species)
|
||||
.filter(species => relevantOrderNames.includes(species.order_name))
|
||||
.map(species => thtToValue[species.tht ?? 'none'])
|
||||
const sum = species.reduce((sum, cur) => sum + cur, 0.0)
|
||||
const meanHypsodonty = species.length > 0 ? sum / (species.length * 1.0) : 0.0
|
||||
const roundedMean = parseFloat((Math.floor(meanHypsodonty * 100) / 100).toFixed(2))
|
||||
|
||||
const ecometrics = [
|
||||
['Mean hypsodonty', roundedMean],
|
||||
['Mean hypsodonty', calculateMeanHypsodonty(data)],
|
||||
['Estimate of annual precipitation (mm)', textField('estimate_precip', { type: 'number' })],
|
||||
['Estimate of mean annual temperature (°C)', textField('estimate_temp', { type: 'number' })],
|
||||
['Estimate of net primary productivity (g/m2/yr)', textField('estimate_npp', { type: 'number' })],
|
||||
|
||||
@@ -25,7 +25,7 @@ export const NavBar = () => {
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null)
|
||||
}
|
||||
|
||||
const linkFontSize = 18
|
||||
const pages: LinkDefinition[] = [
|
||||
{ title: 'Localities', url: '/locality' },
|
||||
{ title: 'Species', url: '/species' },
|
||||
@@ -73,7 +73,9 @@ export const NavBar = () => {
|
||||
onClick={handleClick}
|
||||
sx={{ color: 'white', paddingTop: '6px', paddingBottom: '6px', paddingLeft: '16px', paddingRight: '16px' }}
|
||||
>
|
||||
<Typography textAlign="center">{link.title}</Typography>
|
||||
<Typography textAlign="center" fontSize={linkFontSize}>
|
||||
{link.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Menu
|
||||
id={`${link.url}-menu`}
|
||||
@@ -103,7 +105,9 @@ export const NavBar = () => {
|
||||
}
|
||||
return (
|
||||
<MenuItem key={`${link.url}-menuitem`} component={Link} to={link.url} style={{ display: 'inline-block' }}>
|
||||
<Typography textAlign="center">{link.title}</Typography>
|
||||
<Typography textAlign="center" fontSize={linkFontSize}>
|
||||
{link.title}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export const TableView = <T extends MRT_RowData>({
|
||||
selectorFn,
|
||||
url,
|
||||
title,
|
||||
combinedExport,
|
||||
}: {
|
||||
title?: string
|
||||
data: T[] | undefined
|
||||
@@ -42,6 +43,7 @@ export const TableView = <T extends MRT_RowData>({
|
||||
checkRowRestriction?: (row: T) => boolean
|
||||
selectorFn?: (id: T) => void
|
||||
url?: string
|
||||
combinedExport?: (lids: number[]) => Promise<void>
|
||||
}) => {
|
||||
const location = useLocation()
|
||||
const { editRights } = usePageContext()
|
||||
@@ -147,6 +149,22 @@ export const TableView = <T extends MRT_RowData>({
|
||||
<Divider sx={{ marginTop: '1rem' }} />
|
||||
</>
|
||||
)}
|
||||
{combinedExport && (
|
||||
<Box sx={{ margin: '1em', maxWidth: '20em' }}>
|
||||
<p>
|
||||
Click here to export a list of the localities shown in the table combined with each species found in those
|
||||
localities as a comma separated csv-file. Notice, that the export may take a while. It is recommended to
|
||||
filter the table first. You can also export only the table data by clicking the download icon in the
|
||||
right-top corner of the table.
|
||||
</p>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => void combinedExport(table.getFilteredRowModel().rows.map(d => d.original.lid as number))}
|
||||
>
|
||||
Locality & Species export
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<MaterialReactTable table={table} />
|
||||
</Paper>
|
||||
)
|
||||
|
||||
@@ -15,6 +15,13 @@ const localitiesApi = api.injectEndpoints({
|
||||
}),
|
||||
providesTags: result => (result ? [{ type: 'locality', id: result.lid }] : []),
|
||||
}),
|
||||
getLocalitySpeciesList: builder.mutation<unknown[], number[]>({
|
||||
query: (lids: number[]) => ({
|
||||
url: `/locality/localitySpecies/`,
|
||||
body: { lids },
|
||||
method: 'POST',
|
||||
}),
|
||||
}),
|
||||
editLocality: builder.mutation<{ id: number }, EditDataType<LocalityDetailsType>>({
|
||||
query: locality => ({
|
||||
url: `/locality/`,
|
||||
@@ -26,4 +33,9 @@ const localitiesApi = api.injectEndpoints({
|
||||
}),
|
||||
})
|
||||
|
||||
export const { useGetAllLocalitiesQuery, useGetLocalityDetailsQuery, useEditLocalityMutation } = localitiesApi
|
||||
export const {
|
||||
useGetAllLocalitiesQuery,
|
||||
useGetLocalityDetailsQuery,
|
||||
useEditLocalityMutation,
|
||||
useGetLocalitySpeciesListMutation,
|
||||
} = localitiesApi
|
||||
|
||||
@@ -7,3 +7,40 @@ export enum Role {
|
||||
ProjectPrivate = 6,
|
||||
ReadOnly = 7,
|
||||
}
|
||||
|
||||
import { LocalityDetailsType } from '@/backendTypes'
|
||||
|
||||
export const calculateMeanHypsodonty = (locality: LocalityDetailsType) => {
|
||||
// Mean hypsodonty calculation has been made based on html/include/database.php line 2567 onwards
|
||||
const relevantOrderNames = [
|
||||
'Perissodactyla',
|
||||
'Artiodactyla',
|
||||
'Primates',
|
||||
'Proboscidea',
|
||||
'Hyracoidea',
|
||||
'Dinocerata',
|
||||
'Embrithopoda',
|
||||
'Notoungulata',
|
||||
'Astrapotheria',
|
||||
'Pyrotheria',
|
||||
'Litopterna',
|
||||
'Condylarthra',
|
||||
'Pantodonta',
|
||||
]
|
||||
|
||||
const thtToValue = {
|
||||
bra: 1.0,
|
||||
mes: 2.0,
|
||||
hyp: 3.0,
|
||||
hys: 3.0,
|
||||
none: 0.0,
|
||||
} as Record<string, number>
|
||||
|
||||
const species = locality.now_ls
|
||||
.map(ls => ls.com_species)
|
||||
.filter(species => relevantOrderNames.includes(species.order_name))
|
||||
.map(species => thtToValue[species.tht ?? 'none'])
|
||||
const sum = species.reduce((sum, cur) => sum + cur, 0.0)
|
||||
const meanHypsodonty = species.length > 0 ? sum / (species.length * 1.0) : 0.0
|
||||
return parseFloat((Math.floor(meanHypsodonty * 100) / 100).toFixed(2))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user