Merge branch 'main' into copy-species-taxonomy

This commit is contained in:
juhanikat
2025-06-30 15:39:57 +03:00
committed by GitHub
72 changed files with 2240 additions and 324 deletions
+2
View File
@@ -2,6 +2,7 @@ import 'express-async-errors'
import express from 'express'
import cors from 'cors'
import crossSearchRouter from './routes/crossSearch'
import statisticsRouter from './routes/statistics'
import localityRouter from './routes/locality'
import museumRouter from './routes/museum'
import referenceRouter from './routes/reference'
@@ -52,6 +53,7 @@ app.use('/crosssearch', crossSearchRouter)
app.use('/locality', localityRouter)
app.use('/locality-species', localitySpeciesRouter)
app.use('/species', speciesRouter)
app.use('/statistics', statisticsRouter)
app.use('/reference', referenceRouter)
app.use('/time-unit', timeUnitRouter)
app.use('/time-bound', requireOneOf([Role.Admin, Role.EditUnrestricted]), timeBoundRouter)
+10 -5
View File
@@ -1,9 +1,16 @@
import { Router, Request } from 'express'
import { getAllPersons, getPersonDetails, validateEntirePerson } from '../services/person'
import { Role, PersonDetailsType, EditDataType, EditMetaData } from '../../../frontend/src/shared/types'
import {
Role,
PersonDetailsType,
EditDataType,
EditMetaData,
userGroups,
UserGroup,
} from '../../../frontend/src/shared/types'
import { requireOneOf } from '../middlewares/authorizer'
import { writePerson } from '../services/write/person'
import { UserGroup, validUserGroups, writeUserGroup } from '../services/write/user'
import { writeUserGroup } from '../services/write/user'
const router = Router()
@@ -34,11 +41,9 @@ router.put(
'/',
async (req: Request<object, object, { person: EditDataType<PersonDetailsType> & EditMetaData }>, res) => {
const { ...editedPerson } = req.body.person
if (!editedPerson.initials) {
return res.status(403).send({ error: 'Missing initials, creating new persons is not yet implemented' })
}
/* Access checking happens differently for this route, since we want to allow users to modify their own data */
if (!req.user)
return res.status(401).send({
@@ -60,7 +65,7 @@ router.put(
req.user.role === Role.Admin &&
editedPerson.user_id &&
editedPerson.now_user_group &&
validUserGroups.includes(editedPerson.now_user_group)
userGroups.includes(editedPerson.now_user_group)
) {
await writeUserGroup(editedPerson.user_id, editedPerson.now_user_group as UserGroup)
}
+22
View File
@@ -0,0 +1,22 @@
import { Router } from 'express'
import { getLocalityStatistics, getSpeciesStatistics, getStatistics } from '../services/statistics'
import { fixBigInt } from '../utils/common'
const router = Router()
router.get('/', async (_req, res) => {
const statistics = await getStatistics()
return res.status(200).send(fixBigInt(statistics))
})
router.get('/locality', async (_req, res) => {
const statistics = await getLocalityStatistics()
return res.status(200).send(fixBigInt(statistics))
})
router.get('/species', async (_req, res) => {
const statistics = await getSpeciesStatistics()
return res.status(200).send(fixBigInt(statistics))
})
export default router
+34 -8
View File
@@ -1,14 +1,17 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { Router } from 'express'
import { Request, Router } from 'express'
import { sign } from 'jsonwebtoken'
import { SECRET, LOGIN_VALID_SECONDS } from '../utils/config'
import * as bcrypt from 'bcrypt'
import { nowDb } from '../utils/db'
import { getRole } from '../middlewares/authenticator'
import { AccessError } from '../middlewares/authorizer'
import { Role } from '../../../frontend/src/shared/types'
import { Role, UserDetailsType } from '../../../frontend/src/shared/types'
import md5 from 'md5'
import { validatePassword } from '../utils/validatePassword'
import { createPasswordHash } from '../utils/createPasswordHash'
import { writeUser } from '../services/write/user'
import { validateUser } from '../utils/validateUser'
const router = Router()
@@ -75,11 +78,7 @@ router.post('/login', async (req, res) => {
})
})
const createPasswordHash = async (password: string) => {
const saltRounds = 10
return await bcrypt.hash(password, saltRounds)
}
// TODO: Remove if not needed for anything
router.post('/create', async (req, res) => {
const { username, password, initials, role, secret } = req.body
@@ -137,6 +136,33 @@ router.put('/password', async (req, res) => {
return res.status(200).send()
})
router.post('/create')
router.post('/new', async (req: Request<UserDetailsType>, res) => {
const user: UserDetailsType = req.body
if (!user) return res.status(400).send({ message: 'Missing user' })
if (!user.initials) {
return res.status(403).send({ message: 'Missing initials' })
}
if (!req.user || req.user.role !== Role.Admin)
return res.status(401).send({
message: 'User not authorized for the requested resource or action.',
})
const error = await validateUser(user)
if (error)
return res.status(400).send({
message: error,
})
// All good, write user.
try {
await writeUser(user)
} catch (e) {
return res.status(500).send({ message: 'Unknown server error occurred' })
}
return res.status(200).send({ message: '' })
})
export default router
+12 -1
View File
@@ -59,7 +59,7 @@ export const getAllLocalities = async (user?: User) => {
return rest
}
const result = (await nowDb.now_loc.findMany({
const localityResult = (await nowDb.now_loc.findMany({
select: {
lid: true,
loc_name: true,
@@ -117,6 +117,17 @@ export const getAllLocalities = async (user?: User) => {
},
})) as LocalityPreFilter[]
const synonyms: { lid: number }[] = await nowDb.now_syn_loc.findMany({
select: { lid: true },
distinct: ['lid'],
})
const synonymIdSet = new Set(synonyms.map(s => s.lid))
const result = localityResult.map(loc => ({
...loc,
has_synonym: synonymIdSet.has(loc.lid),
}))
if (showAll) return result.map(removeProjects)
if (!user) return result.filter(loc => loc.loc_status === false).map(removeProjects)
+29 -3
View File
@@ -7,7 +7,7 @@ import { logDb, nowDb } from '../utils/db'
import { getReferenceDetails } from './reference'
export const getAllSpecies = async () => {
const result = await nowDb.com_species.findMany({
const speciesResult = await nowDb.com_species.findMany({
select: {
species_id: true,
order_name: true,
@@ -54,6 +54,31 @@ export const getAllSpecies = async () => {
},
})
const speciesWithLocality: { species_id: number }[] = await nowDb.now_ls.findMany({
where: {
now_loc: {
NOT: undefined,
},
},
select: {
species_id: true,
},
distinct: ['species_id'],
})
const synonyms: { species_id: number }[] = await nowDb.com_taxa_synonym.findMany({
select: { species_id: true },
distinct: ['species_id'],
})
const speciesWithLocalitySet = new Set(speciesWithLocality.map(s => s.species_id))
const synonymIdSet = new Set(synonyms.map(s => s.species_id))
const result = speciesResult.map(sp => ({
...sp,
has_synonym: synonymIdSet.has(sp.species_id),
has_no_locality: !speciesWithLocalitySet.has(sp.species_id),
}))
return result
}
@@ -69,13 +94,14 @@ export const getSpeciesDetails = async (id: number) => {
now_sau: {
include: {
now_sr: {
include: {
select: {
ref_ref: {
include: {
select: {
ref_authors: true,
ref_journal: true,
},
},
rid: true,
},
},
},
+48
View File
@@ -0,0 +1,48 @@
import { ActivityStatistic, Statistics } from '../../../frontend/src/shared/types'
import { Prisma } from '../../prisma/generated/now_test_client'
import { nowDb } from '../utils/db'
export const getStatistics = async (): Promise<Statistics> => {
const localityAggregate = await nowDb.now_loc.aggregate({
_count: {
lid: true,
},
})
const localityCount = localityAggregate._count.lid
const speciesAggregate = await nowDb.com_species.aggregate({
_count: {
species_id: true,
},
})
const speciesCount = speciesAggregate._count.species_id
const localitySpeciesQuery = Prisma.sql`
SELECT COUNT(now_ls.species_id) as count
FROM now_loc
LEFT JOIN now_ls ON now_ls.lid = now_loc.lid
`
const localitySpeciesAggregate: { count: number }[] = await nowDb.$queryRaw(localitySpeciesQuery)
let localitySpeciesCount = 0
if (localitySpeciesAggregate.length > 0) {
localitySpeciesCount = localitySpeciesAggregate[0].count
}
return {
localityCount,
speciesCount,
localitySpeciesCount,
}
}
export const getLocalityStatistics = async (): Promise<ActivityStatistic[]> => {
const query = Prisma.sql`SELECT * FROM now_v_locality_statistics LIMIT 10`
return await nowDb.$queryRaw(query)
}
export const getSpeciesStatistics = async (): Promise<ActivityStatistic[]> => {
const query = Prisma.sql`SELECT * FROM now_v_species_statistics LIMIT 10`
return await nowDb.$queryRaw(query)
}
+16 -3
View File
@@ -1,6 +1,7 @@
import { EditDataType, PersonDetailsType } from '../../../../frontend/src/shared/types'
import Prisma from '../../../prisma/generated/now_test_client'
import { nowDb, getFieldsOfTables } from '../../utils/db'
import { getPersonDetails } from '../person'
import { filterAllowedKeys } from './writeOperations/utils'
export const writePerson = async (person: EditDataType<PersonDetailsType>) => {
@@ -8,12 +9,24 @@ export const writePerson = async (person: EditDataType<PersonDetailsType>) => {
const filteredPerson = filterAllowedKeys(person, allowedColumns) as Prisma.com_people
let personId: string
if (!filteredPerson.initials) {
throw new Error('Missing initials, creating new persons is not yet implemented')
const isPerson = await getPersonDetails(filteredPerson.initials)
if (!isPerson) {
const newPerson = await nowDb.com_people.create({
data: { ...filteredPerson, full_name: `${filteredPerson.first_name} ${filteredPerson.surname}` },
})
personId = newPerson.initials
} else {
await nowDb.com_people.update({
where: { initials: person.initials },
data: { ...filteredPerson, full_name: `${filteredPerson.first_name} ${filteredPerson.surname}` },
data: {
first_name: filteredPerson.first_name,
surname: filteredPerson.surname,
email: filteredPerson.email,
organization: filteredPerson.organization,
country: filteredPerson.country,
full_name: `${filteredPerson.first_name} ${filteredPerson.surname}`,
},
})
personId = filteredPerson.initials
}
+23 -4
View File
@@ -1,11 +1,30 @@
import { UserDetailsType, UserGroup } from '../../../../frontend/src/shared/types'
import { createPasswordHash } from '../../utils/createPasswordHash'
import { nowDb } from '../../utils/db'
export const validUserGroups = ['su', 'eu', 'er', 'pl', 'plp', 'no', 'ro']
export type UserGroup = 'su' | 'eu' | 'er' | 'pl' | 'plp' | 'no' | 'ro'
export const writeUserGroup = async (user_id: number, group: UserGroup) => {
await nowDb.com_users.update({
where: { user_id: user_id },
data: { now_user_group: group },
data: { now_user_group: group.split(' ')[0] },
})
}
export const writeUser = async (user: UserDetailsType) => {
const passwordHash = await createPasswordHash(user.password!)
const createdUser = await nowDb.com_users.create({
data: {
user_name: user.user_name,
newpassword: passwordHash,
now_user_group: user.now_user_group?.split(' ')[0],
},
})
if (!createdUser || !createdUser.user_id) throw Error('Could not create user.')
const createdPerson = await nowDb.com_people.update({
where: { initials: user.initials },
data: { user_id: createdUser.user_id },
})
if (!createdPerson) throw Error("Could not update person's user ID.")
}
+6
View File
@@ -0,0 +1,6 @@
import * as bcrypt from 'bcrypt'
export const createPasswordHash = async (password: string) => {
const saltRounds = 10
return await bcrypt.hash(password, saltRounds)
}
+36
View File
@@ -0,0 +1,36 @@
import { UserDetailsType } from '../../../frontend/src/shared/types'
import { nowDb } from './db'
import { validatePassword } from './validatePassword'
const isUsernameTaken = async (username: string): Promise<boolean> =>
!!(await nowDb.com_users.findFirst({ where: { user_name: username } }))
const personWithoutUserExists = async (initials: string): Promise<boolean> => {
const person = await nowDb.com_people.findFirst({ where: { initials: initials } })
if (!person) return false
if (person.user_id) {
const user = await nowDb.com_users.findFirst({ where: { user_id: person.user_id } })
if (user) return false
}
return true
}
export const validateUser = async (user: UserDetailsType): Promise<string | null> => {
const usernameMinLength = 3
const usernameMaxLength = 100
if (!user.user_name) return 'Username is missing.'
if (!user.password) return 'Password is missing.'
if (!user.now_user_group) return 'User group is missing.'
if (user.user_name?.length < usernameMinLength || user.user_name?.length > usernameMaxLength)
return `Username must be between ${usernameMinLength} and ${usernameMaxLength} characters.`
const res = validatePassword(user.password)
if (!res.isValid) return res.error ?? 'Password is invalid.'
if (await isUsernameTaken(user.user_name)) return 'Username is already in use.'
if (!(await personWithoutUserExists(user.initials))) return "Person doesn't exist or person already has a user."
return null
}
+2
View File
@@ -295,6 +295,8 @@ describe("Locality's coordinate selection map works", () => {
it('Map view and location search work', () => {
cy.visit(`/locality/20920?tab=1`)
cy.get('[id=edit-button]').click()
cy.contains('Choose a country').parent().type('Finland')
cy.contains('Finland').click()
cy.contains('Get Coordinates').click()
cy.contains('OpenStreetMap')
cy.contains('Leaflet')
+3 -3
View File
@@ -35,11 +35,11 @@ describe('Editing a person', () => {
cy.get('[id=edit-button]').click()
cy.contains('Choose user group').parent().type('n')
cy.contains('no').click()
cy.contains('Choose user group').parent().type('e')
cy.contains('er (edit restricted)').click()
cy.get('[id=write-button]').click()
cy.contains('no')
cy.contains('er')
})
it('with any missing fields does not work', () => {
+2 -2
View File
@@ -14,7 +14,7 @@ describe('Button Tests', () => {
cy.contains('References').should('be.visible')
cy.contains('Time Units').should('be.visible')
cy.contains('Cross-Search').should('be.visible')
cy.contains('Locality-Species').should('be.visible')
cy.contains('Localities').should('be.visible')
cy.contains('Species').should('be.visible')
})
@@ -49,7 +49,7 @@ describe('Button Tests', () => {
it('Cross Search button works', () => {
cy.visit('/')
cy.contains('Cross-Search').click()
cy.contains('Locality-Species').click()
cy.url().should('include', '/crosssearch')
cy.contains('Locality name').should('be.visible')
cy.contains('Country').should('be.visible')
+5
View File
@@ -0,0 +1,5 @@
# Architecture picture, stack
To do any updates to this picture, update documentation/images/architecture.drawio.svg in drawio/diagrams.net
![Architecture Diagram](./images/architecture.drawio.svg)
@@ -25,8 +25,9 @@ A normal street map as well as a simplified map with only country borders visibl
## Country border view
The simplified country border map view is achieved through a hardcoded array of latitude-longitude polygons that represent country borders, imported from [`country_borders_WGS84.ts`](../../../../frontend/src/components/Map/country_borders_WGS84.ts).
The simplified country border map view is achieved through an array of latitude-longitude polygons that represent country borders, imported from [`countryPolygons.ts`](../../../../frontend/src/country_data/countryPolygons.ts).
This means that the country borders are fixed in time to the moment of when that file was last updated.
Detailed instructions on where to get the data and how to convert it to this desired format can be found at (TBD).
Detailed instructions on where to get the data and how to convert it to this desired format can be found [here](../../../../frontend/src/country_data/README.md).
@@ -1,6 +1,6 @@
# Map features
See component-specific docs at [CoordinateSelectionMap](./CoordinateSelectionMap.md) and [LocalityMap](./LocalityMap.md)
See component-specific docs at [CoordinateSelectionMap](./CoordinateSelectionMap.md) and [LocalitiesMap](./LocalitiesMap.md)
The map features use the React Leaflet package and OpenStreetMap.
@@ -0,0 +1,5 @@
# Single locality map
A component for visualizing the location of a single latitude and longitude coordinate, used for the locality detail page as well as in a country bounding box check warning modal to illustrate the location with the box it missed.
The component takes in decimal-form latitude and longitude coordinates, along with an optional array of bounding boxes to display on the map.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 119 KiB

+62
View File
@@ -0,0 +1,62 @@
## User Testing Instructions
To participate in user testing, please follow the steps below:
1. Use the latest version of the application:
[Test Application](https://nowdb-frontend-nowdatabase.ext.ocp-test-0.k8s.it.helsinki.fi/)
2. Perform the listed tasks under typical usage conditions.
3. Note any bugs, usability issues, or unexpected behavior.
4. Submit your feedback via the provided feedback form or issue tracker.
Your feedback is essential to improving the user experience—thank you for participating!
---
## User Testing Tasks
### Cold Tests (No Instructions for Testing)
| Test Case ID | Description | Preconditions | Steps to Execute | Expected Result |
|--------------|-------------|---------------|------------------|------------------|
| TC01 | Find all localities for species Primus | User is not logged in | No instructions (tests intuitivity) | See a list of Primus with their localities (no map) |
| TC02 | Find all genus that includes "Alopex" | User is not logged in | No instructions | See 9 rows with "Alopex" |
| TC03 | See localities on a map filtered by country | User is not logged in | No instructions | See filtered localities on a map |
| TC04 | Take KML export of localities from map | User is not logged in | No instructions | Get KML file downloaded |
| TC05 | View base map with country boundaries | User is not logged in | No instructions | View simple colored boundary map |
| TC06 | Info for locality on map | User is not logged in | No instructions | Click marker to view info |
| TC07 | SVG export of map localities | User is not logged in | No instructions | Download SVG file (opens in browser) |
---
### Instructed Tests
| Test Case ID | Description | Preconditions | Steps to Execute | Expected Result |
|--------------|-------------|---------------|------------------|------------------|
| TC08 | View localities for species "lynx" | User is not logged in | 1. Go to Cross-search 2. Select species: lynx | See list of lynx localities (2 pages) |
| TC09 | Full lynx localities listed | User is not logged in | Check that list matches expected | All localities shown accurately |
| TC10 | View single locality on map via alternative path | User is not logged in | Go to localities page → choose → tab → map | See map with one locality |
| TC11 | Find localities for Cormohipparion | User is not logged in | Cross-search → filter Genus: "Cormohipparion" | See 3 pages of results |
---
## Additional Testing Comments (test results can be added here)
| Test Case ID | Pass/Fail if one fails | Comments (1st) 10.6.2025 | Comments (2nd) 10.6.2025 |
|--------------|------------------|----------------------|------------------|
| TC01 | Fail | Meni lokaliteetteihin, ei löydä, yrittää etsiä | Ei avaa Cross-search-hakua |
| TC02 | Pass | | |
| TC03 | Fail | Ei löydä karttaa heti, ei tarpeeksi näkyvästi karttapampula | Ei ollut helposti kartta löydettävissä |
| TC04 | Pass | | |
| TC05 | Pass | Etsi controlleria, löytää lopulta | |
| TC06 | Fail | not ready for testing | |
| TC07 | Pass | Yritti tehdä exportin kartalla, ei mennyt ilman apua | Helppo toiselle |
| TC08 | Pass | | |
| TC09 | — | | |
| TC10 | Fail | Ei tajua painaa search-merkkiä, kesti liian kauan | Symbolin painallus ei riittänyt |
| TC11 | Fail | Haku tyhjensi vastaukset, vaikka käyttö oikein | |
---
+1 -1
View File
@@ -31,7 +31,7 @@ export default [
'**/jest.config.ts',
'**/coverage',
'src/components/Map/*.js',
'src/components/Map/country_borders_WGS84.ts',
'src/country_data/**',
],
},
...fixupConfigRules(
+28 -24
View File
@@ -1,10 +1,10 @@
import { Container, Grid } from '@mui/material'
import { FrontPage } from './components/FrontPage'
import { NavBar } from './components/NavBar'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { Login } from './components/Login'
import {
crossSearchPage,
frontPage,
localityPage,
personPage,
projectPage,
@@ -16,36 +16,40 @@ import {
} from './components/pages'
import { Notification, NotificationContextProvider } from './components/Notification'
import { EmailPage } from './components/EmailPage'
import { Footer } from './Footer'
const App = () => {
return (
<BrowserRouter>
<NotificationContextProvider>
<Notification />
<Grid container>
<Grid item xs={12}>
<NavBar />
</Grid>
<Container maxWidth="xl" fixed style={{ marginTop: '2em', marginBottom: '2em' }}>
<Grid item>
<Routes>
<Route element={crossSearchPage} path="/crosssearch/:id?" />
<Route element={localityPage} path="/locality/:id?" />
<Route element={speciesPage} path="/species/:id?" />
<Route element={referencePage} path="/reference/:id?" />
<Route element={timeUnitPage} path="/time-unit/:id?" />
<Route element={timeBoundPage} path="/time-bound/:id?" />
<Route element={regionPage} path="/region/:id?" />
<Route element={personPage} path="/person/:id?" />
<Route element={projectPage} path="/project/:id?" />
<Route element={<EmailPage />} path="/email/" />
<Route element={<Login />} path="/login" />
<Route element={<FrontPage />} path="/" />
<Route element={<div>Page not found.</div>} path="*" />
</Routes>
<div id="wrapper">
<Grid container>
<Grid item xs={12}>
<NavBar />
</Grid>
</Container>
</Grid>
<Container maxWidth="xl" fixed style={{ marginTop: '2em', marginBottom: '2em' }}>
<Grid item>
<Routes>
<Route element={crossSearchPage} path="/crosssearch/:id?" />
<Route element={localityPage} path="/locality/:id?" />
<Route element={speciesPage} path="/species/:id?" />
<Route element={referencePage} path="/reference/:id?" />
<Route element={timeUnitPage} path="/time-unit/:id?" />
<Route element={timeBoundPage} path="/time-bound/:id?" />
<Route element={regionPage} path="/region/:id?" />
<Route element={personPage} path="/person/:id?" />
<Route element={projectPage} path="/project/:id?" />
<Route element={<EmailPage />} path="/email/" />
<Route element={<Login />} path="/login" />
<Route element={frontPage} path="/" />
<Route element={<div>Page not found.</div>} path="*" />
</Routes>
</Grid>
</Container>
</Grid>
<Footer />
</div>
</NotificationContextProvider>
</BrowserRouter>
)
+5
View File
@@ -0,0 +1,5 @@
import './styles/Footer.css'
export const Footer = () => {
return <footer>© The NOW Database {new Date().getFullYear()}</footer>
}
@@ -15,7 +15,7 @@ export const DetailBrowser = <T extends object>() => {
const nextIndex = currentIndex + 1
const previousIndex = currentIndex - 1
const style = {
width: '50em',
width: '100%',
marginLeft: '0em',
overflow: 'hidden',
whiteSpace: 'pre-wrap',
@@ -0,0 +1,37 @@
import { Button, Modal } from '@mui/material'
import { SingleLocalityMap } from '../Map/SingleLocalityMap'
import { CountryBoundingBox } from '@/country_data/countryBoundingBoxes'
export interface OutOfBoundsWarningModalState {
decLong: number
decLat: number
boxes?: CountryBoundingBox[]
}
interface Props {
onAnswer: (answer: boolean) => void
state: OutOfBoundsWarningModalState
isOpen: boolean
}
export const OutOfBoundsWarningModal = ({ isOpen, state, onAnswer }: Props) => {
return (
<Modal open={isOpen} className={`modal ${isOpen ? 'open' : ''}`} onClick={() => onAnswer(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<h3>Coordinate warning</h3>
<p style={{ marginBottom: '2em' }}>
The coordinate selected is outside of the locality country. Do you still want to proceed?
</p>
<SingleLocalityMap decLat={state.decLat} decLong={state.decLong} boxes={state.boxes} />
<div style={{ marginTop: '1em' }}>
<Button variant="contained" onClick={() => onAnswer(false)}>
Cancel
</Button>
<Button sx={{ marginLeft: '0.6em' }} variant="contained" onClick={() => onAnswer(true)}>
Proceed
</Button>
</div>
</div>
</Modal>
)
}
@@ -1,3 +1,5 @@
// NOTE: Contact button is disabled until we get the email server access working
import { Box, Button, Modal } from '@mui/material'
import { ReactNode, useState } from 'react'
import { modalStyle } from './misc'
@@ -20,6 +22,7 @@ export const ContactModal = ({
setOpen(false)
}
return <></>
return (
<Box>
<Button onClick={() => setOpen(true)} variant="outlined" startIcon={<EmailIcon />} className="button">
@@ -5,7 +5,9 @@ import {
SpeciesDetailsType,
TimeBoundDetailsType,
TimeUnitDetailsType,
PersonDetailsType,
} from '@/shared/types'
import { MRT_PaginationState } from 'material-react-table'
export const emptyLocality = {
@@ -246,5 +248,14 @@ export const emptyRegion = {
now_reg_coord_country: [],
} as unknown as RegionDetails
export const emptyPerson = {
initials: '',
first_name: '',
surname: '',
email: '',
organization: '',
country: '',
} as unknown as PersonDetailsType
export const defaultPagination: MRT_PaginationState = { pageIndex: 0, pageSize: 15 }
export const defaultPaginationSmall: MRT_PaginationState = { pageIndex: 0, pageSize: 10 }
@@ -11,6 +11,9 @@ import { referenceValidator } from '@/shared/validators/validator'
import { checkTaxonomy, convertTaxonomyFields } from '../../util/taxonomyFunctions'
import { useLazyGetAllSpeciesQuery, useLazyGetAllSynonymsQuery } from '@/redux/speciesReducer'
import { useNotify } from '@/hooks/notification'
import { countryBoundingBoxes } from '@/country_data/countryBoundingBoxes'
import { boundingBoxSplit, isPointInBoxes } from '@/util/isPointInBox'
import { OutOfBoundsWarningModal, OutOfBoundsWarningModalState } from './OutOfBoundsWarningModal'
export const WriteButton = <T,>({
onWrite,
@@ -27,11 +30,26 @@ export const WriteButton = <T,>({
const notify = useNotify()
const [getSpeciesData] = useLazyGetAllSpeciesQuery()
const [getSynonyms] = useLazyGetAllSynonymsQuery()
// For coordinate out of bounds warning
const [warningModalState, setWarningModalState] = useState<OutOfBoundsWarningModalState>({
decLong: 0,
decLat: 0,
boxes: undefined,
})
const [warningModalOpen, setWarningModalOpen] = useState(false)
const getButtonText = () => {
if (!mode.staging) return hasStagingMode ? 'Finalize entry' : 'Save changes'
return 'Complete and save'
}
// Checks if given coordinate is outside of country bounding box
const isCoordinateOutOfBounds = (dec_lat: number, dec_long: number, country: string): boolean => {
if (!(country in countryBoundingBoxes)) return false
return !isPointInBoxes(dec_lat, dec_long, boundingBoxSplit(countryBoundingBoxes[country]))
}
/* validates all fields when entering new/editing mode
to disable write button when unvisited tabs have
validation errors (e.g. missing required fields) */
@@ -147,20 +165,56 @@ export const WriteButton = <T,>({
}
return (
<Button
disabled={Object.keys(fieldsWithErrors).length > 0}
id="write-button"
sx={{ width: '20em' }}
onClick={() => void handleWriteButtonClick()}
variant="contained"
>
{loading ? (
<CircularProgress size="1.2em" sx={{ color: 'white', marginRight: '1em' }} />
) : (
<SaveIcon style={{ marginRight: '0.5em' }} />
)}
{getButtonText()}
</Button>
<>
<OutOfBoundsWarningModal
isOpen={warningModalOpen}
onAnswer={isOkay => {
setWarningModalOpen(false)
if (isOkay) save()
}}
state={warningModalState}
/>
<Button
disabled={Object.keys(fieldsWithErrors).length > 0}
id="write-button"
sx={{ width: '20em' }}
onClick={() => {
// Check for out-of-boundness before saving
if (
!('dec_lat' in (editData as object)) ||
!('dec_long' in (editData as object)) ||
!('country' in (editData as object))
) {
void handleWriteButtonClick()
return
}
const localityObject = editData as unknown as { dec_lat: number; dec_long: number; country: string }
if (!isCoordinateOutOfBounds(localityObject.dec_lat, localityObject.dec_long, localityObject.country)) {
void handleWriteButtonClick()
return
}
setWarningModalOpen(true)
setWarningModalState({
decLat: localityObject.dec_lat,
decLong: localityObject.dec_long,
// Guaranteed to exist by the isCoordinateOutOfBounds check above,
// as it will return false and return on no key found
boxes: boundingBoxSplit(countryBoundingBoxes[localityObject.country]),
})
}}
variant="contained"
>
{loading ? (
<CircularProgress size="1.2em" sx={{ color: 'white', marginRight: '1em' }} />
) : (
<SaveIcon style={{ marginRight: '0.5em' }} />
)}
{getButtonText()}
</Button>
</>
)
}
+136 -64
View File
@@ -1,30 +1,35 @@
import {
useGetLocalityStatisticsQuery,
useGetSpeciesStatisticsQuery,
useGetStatisticsQuery,
} from '../redux/statisticsReducer'
import { ENV } from '@/util/config'
import { Divider } from '@mui/material'
import Box from '@mui/material/Box'
import { useState, useEffect } from 'react'
import { useEffect, useRef } from 'react'
const dateWhenStarted = new Date(Date.now())
import mapSvg from '../resource/map.svg'
import logo from '../resource/nowlogo.jpg'
import '../styles/FrontPage.css'
export const FrontPage = () => {
const [dateString, setDateString] = useState('')
const mapContainerRef = useRef<HTMLDivElement | null>(null)
const { data: statisticsQueryData } = useGetStatisticsQuery()
const { data: speciesStatisticsQueryData, isFetching: speciesStatisticsQueryIsFetching } =
useGetSpeciesStatisticsQuery()
const { data: localityStatisticsQueryData, isFetching: localityStatisticsQueryIsFetching } =
useGetLocalityStatisticsQuery()
// Map hover effect
useEffect(() => {
const currentLocalStorage = localStorage.getItem('dateWhenStarted')
if (!currentLocalStorage) {
localStorage.setItem('dateWhenStarted', dateWhenStarted.toString())
return
}
if (!mapContainerRef.current) return
mapContainerRef.current.onmousemove = (e: MouseEvent) => {
if (!mapContainerRef.current) return
const rect = mapContainerRef.current.getBoundingClientRect()
const height = rect.bottom - rect.top
const width = rect.right - rect.left
const currentLocalStorageDate = new Date(currentLocalStorage)
const optionsForDateFormatting: Intl.DateTimeFormatOptions = {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
year: 'numeric',
month: 'long',
day: 'numeric',
mapContainerRef.current?.style.setProperty('--x', `${((e.clientX - rect.left) / width) * 100}%`)
mapContainerRef.current?.style.setProperty('--y', `${((e.clientY - rect.top) / height) * 100}%`)
}
setDateString(currentLocalStorageDate.toLocaleDateString('fi-FI', optionsForDateFormatting))
}, [])
if (ENV === 'dev') {
@@ -32,51 +37,118 @@ export const FrontPage = () => {
} else {
document.title = 'NOW Database'
}
return (
<Box sx={{ fontSize: 20, maxWidth: '40em' }}>
<h1>Welcome</h1>
{ENV !== 'prod' && (
<p>
<b style={{ backgroundColor: 'orange' }}>
This version is running on test data. May contain incorrect and outdated information.
</b>
</p>
)}
<p>
This is a new version of the NOW (New And Old Worlds) Fossil Mammal Database. It displays tables and details of
localities, species, time units and references.
</p>
<p>
<b>To navigate this application, use the bar at the top of the website.</b> Click on the links there to view
different data tables, such as localities or species. Then, to view details of an individual item, click the
magnifying glass icon on the left side of the row.
</p>
<p>
To log in, click the Login-link at the top-right corner of the screen. A logged in user will see more links in
the navigation bar, depending on their user rights, as well as the User-link that will take the user to their
own user-page, where you can view your own information and change your password.
</p>
<p style={{ fontSize: 26 }}>
<b>
<a href="https://forms.gle/Aj392QWov5QXBJfo7">Click here to provide feedback!</a>
</b>
</p>
<Divider />
<h2>External links</h2>
<ul>
<li>
Information about the database and NOW community:{' '}
<a href="https://nowdatabase.org/">https://nowdatabase.org/</a>
</li>
<li>
The old application: <a href="https://nowdatabase.luomus.fi/">https://nowdatabase.luomus.fi/</a>
</li>
<li>
Code repository of the project:{' '}
<a href="https://github.com/nowcommunity/nowdatabase">https://github.com/nowcommunity/nowdatabase</a>
</li>
</ul>
{ENV !== 'prod' ? <p style={{ opacity: 0.25 }}>{dateString}</p> : ''}
</Box>
<main id="front-page">
<header className="top-info">
<div className="left">
<h1>Welcome to NOW database</h1>
<p>
The NOW (New and Old Worlds) fossil mammal database contains information about Cenozoic land mammal taxa and
localities. The emphasis of the database has been on the European Miocene and Pliocene but North American
localities, covering the whole Cenozoic, were added to the public database in 2016. African localities are
currently being added and updated, and the temporal scale is also becoming wider for both Eurasia and
Africa, with Pleistocene and Paleogene localities being added into the database. The NOW database is
maintained and coordinated by <a href="mailto:indre-dot-zliobaite-at-helsinki-dot-fi">Indrė Žliobaitė</a> in
collaboration with associate coordinators, a steering group and an international{' '}
<a href="https://nowdatabase.org/now/board/">advisory board</a>.
</p>
<p>
<a href="https://nowdatabase.org/" target="_blank" rel="noreferrer">
See more information at nowdatabase.org
</a>
</p>
</div>
<div className="image-container">
<img src={logo} alt="NOW database logo" />
<span className="copyright">© Noira Martiskainen</span>
</div>
</header>
<div className="stats">
<span>
<b>Localities</b> {statisticsQueryData && statisticsQueryData.localityCount}
</span>
<span>
<b>Species</b> {statisticsQueryData && statisticsQueryData.speciesCount}
</span>
<span>
<b>Locality-species</b> {statisticsQueryData && statisticsQueryData.localitySpeciesCount}
</span>
</div>
<div className="map-container" ref={mapContainerRef}>
<div className="map">
<img src={mapSvg} />
</div>
</div>
<section className="additional-info">
<div>
<h2>Statistics</h2>
<div className="activity-stats">
<div className="stat-table">
<h3>Most localities</h3>
<table>
<thead>
<tr>
<th>Year</th>
<th>Month</th>
<th>Surname</th>
</tr>
</thead>
<tbody>
{!localityStatisticsQueryIsFetching &&
localityStatisticsQueryData &&
localityStatisticsQueryData.map((stat, i) => (
<tr key={i}>
<td>{stat.year}</td>
<td>{stat.month}</td>
<td>{stat.surname}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="stat-table">
<h3>Most species</h3>
<table>
<thead>
<tr>
<th>Year</th>
<th>Month</th>
<th>Surname</th>
</tr>
</thead>
<tbody>
{!speciesStatisticsQueryIsFetching &&
speciesStatisticsQueryData &&
speciesStatisticsQueryData.map((stat, i) => (
<tr key={i}>
<td>{stat.year}</td>
<td>{stat.month}</td>
<td>{stat.surname}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<div>
<h2>Citation</h2>
<h3>Minimum required attribution</h3>
<p>
Data (<a href="https://nowdatabase.org/now/database/">https://nowdatabase.org/now/database/</a>) by The NOW
Community / CC BY 4.0.
</p>
<h3>Suggested citation or attribution</h3>
<p>
The NOW Community [year]. New and Old Worlds Database of Fossil Mammals (NOW). Licensed under CC BY 4.0.
Retrieved [download date] from{' '}
<a href="https://nowdatabase.org/now/database/">https://nowdatabase.org/now/database/</a>.
</p>
<h3>DOI</h3>
<a href="http://doi.org/10.5281/zenodo.4268068">doi:10.5281/zenodo.4268068</a>.
</div>
</section>
</main>
)
}
@@ -0,0 +1,61 @@
import { Box, Modal, Alert, Typography, IconButton, Table, TableBody, TableRow, TableCell } from '@mui/material'
import { useGetLocalityDetailsQuery } from '../../redux/localityReducer'
import { skipToken } from '@reduxjs/toolkit/query/react'
import CloseIcon from '@mui/icons-material/Close'
import '../../styles/species/SynonymsModal.css'
export const LocalitySynonymsModal = ({
open,
onClose,
selectedLocality,
}: {
open: boolean
onClose: () => void
selectedLocality: string | undefined
}) => {
const { isError, isFetching, data } = useGetLocalityDetailsQuery(selectedLocality ? selectedLocality : skipToken)
if (isFetching) {
return (
<Modal open={open} onClose={onClose}>
<Box className="modal-content"></Box>
</Modal>
)
}
return (
<Modal open={open} onClose={onClose} className="synonyms-modal modal">
<Box className="modal-content">
<Box className="modal-header">
<Typography variant="h6" component="h2">
{`Synonyms ${isError ? '' : `- ${data?.loc_name}`}`}
</Typography>
<IconButton sx={{ color: 'white' }} onClick={onClose} aria-label="close" className="close-button">
<CloseIcon />
</IconButton>
</Box>
{isError ? (
<Alert severity="error" sx={{ m: 2 }}>
Failed to load locality details. Please try again later.
</Alert>
) : (
<Table className="modal-table">
<TableBody>
<TableRow>
<TableCell>
<strong>Locality synonyms</strong>
</TableCell>
</TableRow>
{data?.now_syn_loc?.map((synonym, index) => (
<TableRow key={index}>
<TableCell>{synonym.synonym}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Box>
</Modal>
)
}
@@ -8,6 +8,7 @@ import { LocalitiesMap } from '../Map/LocalitiesMap'
import { generateKml } from '@/util/kml'
import { generateSvg } from '../Map/generateSvg'
import { usePageContext } from '../Page'
import { LocalitySynonymsModal } from './LocalitySynonymsModal'
const decimalCount = (num: number) => {
const numAsString = num.toString()
@@ -18,18 +19,27 @@ const decimalCount = (num: number) => {
}
export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Locality) => void }) => {
const [selectedLocality, setSelectedLocality] = useState<string | undefined>()
const [modalOpen, setModalOpen] = useState<boolean>(false)
const { data: localitiesQueryData, isFetching: localitiesQueryIsFetching } = useGetAllLocalitiesQuery()
const [getLocalitySpeciesList, { isLoading }] = useGetLocalitySpeciesListMutation()
const [filteredLocalities, setFilteredLocalities] = useState<SimplifiedLocality[]>()
const columnFilters = usePageContext()
const notify = useNotify()
const handleLocalityRowActionClick = (row: Locality) => {
setSelectedLocality(row.lid.toString())
setModalOpen(true)
}
useEffect(() => {
// Filter localities for the map component
const localityIds = columnFilters.idList as unknown as number[]
const validIds = localityIds.filter(id => typeof id === 'number')
const localities = localitiesQueryData?.filter(locality => validIds.includes(locality.lid))
setFilteredLocalities(localities as SimplifiedLocality[])
if (validIds.length > 0)
setFilteredLocalities(localitiesQueryData?.filter(locality => validIds.includes(locality.lid)))
else setFilteredLocalities(localitiesQueryData as SimplifiedLocality[])
}, [columnFilters, localitiesQueryData])
const columns = useMemo<MRT_ColumnDef<Locality>[]>(
@@ -427,9 +437,7 @@ export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Localit
return (
<>
{filteredLocalities && filteredLocalities.length !== 0 && (
<LocalitiesMap localities={filteredLocalities} isFetching={localitiesQueryIsFetching} />
)}
<LocalitiesMap localities={filteredLocalities} isFetching={localitiesQueryIsFetching} />
<TableView<Locality>
title="Localities"
selectorFn={selectorFn}
@@ -445,7 +453,9 @@ export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Localit
svgExport={svgExport}
exportIsLoading={isLoading}
enableColumnFilterModes={true}
tableRowAction={handleLocalityRowActionClick}
/>
<LocalitySynonymsModal open={modalOpen} onClose={() => setModalOpen(false)} selectedLocality={selectedLocality} />
</>
)
}
@@ -192,7 +192,7 @@ export const LocalityTab = () => {
>
<ArrayFrame array={latlong} title="Latitude & Longitude" />
<Box sx={{ width: '50%' }}>
{locality && <SingleLocalityMap dec_lat={editData.dec_lat} dec_long={editData.dec_long} />}
{locality && <SingleLocalityMap decLat={editData.dec_lat} decLong={editData.dec_long} />}
</Box>
</Box>
{!mode.read && coordinateButton}
+33 -36
View File
@@ -1,11 +1,9 @@
import { useGetLocalityDetailsQuery } from '../../redux/localityReducer'
import { useState, useEffect, useRef } from 'react'
import 'leaflet/dist/leaflet.css'
import { borders } from './country_borders_WGS84'
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
import { countryPolygons } from '../../country_data/countryPolygons.ts'
import L, { LatLngExpression } from 'leaflet'
import { SimplifiedLocality } from '@/shared/types/data.js'
import { usePageContext } from '../Page'
import { SimplifiedLocality } from '../../shared/types/data.js'
import { skipToken } from '@reduxjs/toolkit/query'
import './leaflet.markercluster.js'
import './MarkerCluster.css'
@@ -29,12 +27,12 @@ export const LocalitiesMap = ({ localities, isFetching }: Props) => {
const [map, setMap] = useState<L.Map | null>(null)
const [isOpen, setIsOpen] = useState(false)
const [localityDetailsIsOpen, setLocalityDetailsIsOpen] = useState(false)
const columnFilters = usePageContext()
const [cluster, setCluster] = useState(true)
const [clusteringEnabled, setClusteringEnabled] = useState(true)
// Use skipToken to skip query when state is null
const { data: localityDetailsQueryData, isFetching: detailsLoading } = useGetLocalityDetailsQuery(
selectedLocality ?? skipToken
) // use skipToken to skip query when state is null
)
useEffect(() => {
if (!mapRef.current) return
@@ -45,29 +43,28 @@ export const LocalitiesMap = ({ localities, isFetching }: Props) => {
const coords: LatLngExpression = [15, 13]
mapInstance.setView(coords, 3)
// BASE MAPS
//// OpenTopoMap
// ---- Base maps ----
// OpenTopoMap
const topomap = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
attribution: 'Map data: © <a href="https://opentopomap.org">OpenTopoMap</a> (CC-BY-SA)',
noWrap: true,
}).addTo(mapInstance)
//// OpenStreetMap
// OpenStreetMap
const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
noWrap: true,
})
// LAYERS ON TOP OF BASE MAPS
//// Add borders to the map
borders.forEach(poly => {
L.polygon(poly as LatLngExpression[], { color: 'gray', weight: 1 }).addTo(mapInstance)
})
// ---- Layers on top of base maps ----
// create a polygon layer for the country borders that is in layer control panel..
// Create a polygon layer for the country borders that is in layer control panel.
const borderLayer = L.layerGroup()
borders.forEach(country_border => {
const polygon = L.polygon(country_border as LatLngExpression[], {
countryPolygons.forEach(countryBorder => {
L.polygon(countryBorder as LatLngExpression[], { color: 'gray', weight: 1 }).addTo(mapInstance)
const polygon = L.polygon(countryBorder as LatLngExpression[], {
color: '#136f94',
fillOpacity: 0.3,
weight: 1,
@@ -81,13 +78,13 @@ export const LocalitiesMap = ({ localities, isFetching }: Props) => {
Countries: borderLayer,
}
// Add a scale bar to the map
// Scale bar
L.control.scale({ position: 'bottomright' }).addTo(mapInstance)
//Add a layer control to the map
// Layer control
L.control.layers(baseMaps, {}, { position: 'topright' }).addTo(mapInstance)
// Add north-arrow to the map
// North-arrow
const northArrowControl = L.Control.extend({
onAdd: function () {
const img = L.DomUtil.create('img')
@@ -96,7 +93,6 @@ export const LocalitiesMap = ({ localities, isFetching }: Props) => {
return img
},
})
const northArrow = new northArrowControl({ position: 'bottomright' })
northArrow.addTo(mapInstance)
@@ -116,7 +112,7 @@ export const LocalitiesMap = ({ localities, isFetching }: Props) => {
// @ts-expect-error The marker cluster library is a plain javascript library
// with no module exports that extends 'L' when imported
const newMarkers: Layer = cluster ? L.markerClusterGroup() : L.layerGroup()
const newMarkers: Layer = clusteringEnabled ? L.markerClusterGroup() : L.layerGroup()
localities?.forEach(locality => {
const options: CustomCircleMarkerOptions = {
@@ -149,28 +145,29 @@ export const LocalitiesMap = ({ localities, isFetching }: Props) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
map.removeLayer(newMarkers)
}
}, [localities, isFetching, columnFilters, map, cluster])
}, [localities, isFetching, map, clusteringEnabled])
document.title = 'Map'
return (
<>
<article id="localities-map">
<div id="map-container" className={isOpen ? 'open' : ''}>
<div className={'map'} ref={mapRef} style={{ flex: 1 }} />
<button className="cluster-btn" onClick={() => setCluster(cluster => !cluster)}>
{cluster ? 'Show individual' : 'Show cluster'}
<article id="localities-map" className={isOpen ? 'open' : ''}>
<div className="map-container">
<div className="map" ref={mapRef} />
<button className="cluster" onClick={() => setClusteringEnabled(cluster => !cluster)}>
{clusteringEnabled ? 'Show individual' : 'Show cluster'}
</button>
<div id="blur-container" className={isOpen ? 'open' : ''}></div>
<div className="gradient" onClick={() => setIsOpen(v => !v)}></div>
</div>
<div className="button-row">
<KeyboardArrowDownIcon
className={isOpen ? 'map-btn-up' : 'map-btn-down'}
onClick={() => setIsOpen(v => !v)}
/>
<div className="open-button" onClick={() => setIsOpen(v => !v)} title="Close map">
<span className="downarrow"></span>
</div>
</article>
<SlidingModal isOpen={localityDetailsIsOpen} onClose={() => setLocalityDetailsIsOpen(false)}>
<SlidingModal
isOpen={localityDetailsIsOpen}
onClose={() => setLocalityDetailsIsOpen(false)}
returnButtonText="Return to map"
>
<LocalityInfo
localityDetailsQueryData={localityDetailsQueryData}
detailsLoading={detailsLoading}
@@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'
import { LocalityDetailsType } from '@/shared/types/data.js'
import { SpeciesTable } from './SpeciesTable'
import '../../styles/LocalityDetailsPanel.css'
import { Link } from 'react-router-dom'
interface Props {
localityDetailsQueryData: LocalityDetailsType | undefined
@@ -37,7 +38,9 @@ export const LocalityInfo = ({ localityDetailsQueryData, selectedLocality, detai
<p>Loading...</p>
) : (
<div>
<h2>{localityDetailsQueryData.loc_name}</h2>
<Link to={`/locality/${localityDetailsQueryData.lid}`}>
<h2>{localityDetailsQueryData.loc_name}</h2>
</Link>
<p>
<strong>ID:</strong> {localityDetailsQueryData.lid}
</p>
@@ -1,51 +1,52 @@
import { useEffect, useRef, useState } from 'react'
import 'leaflet/dist/leaflet.css'
import { borders } from './country_borders_WGS84'
import { countryPolygons } from '../../country_data/countryPolygons.ts'
import L, { LatLngExpression } from 'leaflet'
import '../../styles/SingleLocalityMap.css'
import northarrow from './images/north-arrow.png'
import { CountryBoundingBox } from '@/country_data/countryBoundingBoxes.ts'
interface Props {
dec_lat: number | undefined
dec_long: number | undefined
decLat?: number
decLong?: number
boxes?: CountryBoundingBox[]
}
export const SingleLocalityMap = ({ dec_lat, dec_long }: Props) => {
export const SingleLocalityMap = ({ decLat, decLong, boxes }: Props) => {
const mapRef = useRef<HTMLDivElement | null>(null)
const [map, setMap] = useState<L.Map | null>(null)
useEffect(() => {
if (!mapRef.current || !dec_lat || !dec_long) return
if (!mapRef.current || !decLat || !decLong) return
const mapInstance = L.map(mapRef.current, { maxZoom: 16 })
setMap(mapInstance)
const coords: LatLngExpression = [dec_lat, dec_long]
const coords: LatLngExpression = [decLat, decLong]
mapInstance.setView(coords, 4)
// BASE MAPS
//// OpenTopoMap
// ---- Base maps ----
// OpenTopoMap
const topomap = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
attribution: 'Map data: © <a href="https://opentopomap.org">OpenTopoMap</a> (CC-BY-SA)',
noWrap: true,
}).addTo(mapInstance)
//// OpenStreetMap
// OpenStreetMap
const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
noWrap: true,
})
// LAYERS ON TOP OF BASE MAPS
//// Add borders to the map
borders.forEach(poly => {
L.polygon(poly as LatLngExpression[], { color: 'gray', weight: 1 }).addTo(mapInstance)
})
// ---- Layers on top of base maps ----
// create a polygon layer for the country borders that is in layer control panel..
// Create a polygon layer for the country borders that is in layer control panel..
const borderLayer = L.layerGroup()
borders.forEach(country_border => {
const polygon = L.polygon(country_border as LatLngExpression[], {
countryPolygons.forEach(countryBorder => {
L.polygon(countryBorder as LatLngExpression[], { color: 'gray', weight: 1 }).addTo(mapInstance)
const polygon = L.polygon(countryBorder as LatLngExpression[], {
color: '#136f94',
fillOpacity: 0.3,
weight: 1,
@@ -62,7 +63,7 @@ export const SingleLocalityMap = ({ dec_lat, dec_long }: Props) => {
// Add a scale bar to the map
L.control.scale({ position: 'bottomright' }).addTo(mapInstance)
//Add a layer control to the map
// Add a layer control to the map
L.control.layers(baseMaps, {}, { position: 'topright' }).addTo(mapInstance)
// Add north-arrow to the map
@@ -81,15 +82,29 @@ export const SingleLocalityMap = ({ dec_lat, dec_long }: Props) => {
return () => {
mapInstance.remove()
}
}, [dec_lat, dec_long])
}, [decLat, decLong])
useEffect(() => {
if (!map || !dec_lat || !dec_long) return
if (!map || !decLat || !decLong) return
if (boxes) {
boxes.forEach(box => {
L.polygon(
[
[box.top, box.left],
[box.top, box.right],
[box.bottom, box.right],
[box.bottom, box.left],
] as LatLngExpression[],
{ color: 'blue' }
).addTo(map)
})
}
const newMarker = L.layerGroup()
newMarker.addLayer(
L.circleMarker([dec_lat, dec_long], {
L.circleMarker([decLat, decLong], {
radius: 4,
color: '#db2c2c',
fillColor: '#d95050',
@@ -106,7 +121,7 @@ export const SingleLocalityMap = ({ dec_lat, dec_long }: Props) => {
return () => {
map.removeLayer(newMarker)
}
}, [map, dec_lat, dec_long])
}, [map, decLat, decLong, boxes])
document.title = 'Map'
+10 -2
View File
@@ -1,14 +1,18 @@
import { Button } from '@mui/material'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import '../../styles/SlidingModal.css'
interface Props {
children: JSX.Element
isOpen: boolean
onClose: () => void
returnButtonText?: string
id?: string
}
export const SlidingModal = ({ children, isOpen, onClose }: Props) => {
export const SlidingModal = ({ children, isOpen, onClose, returnButtonText = 'Return', id }: Props) => {
return (
<div className={`sliding-modal-container ${isOpen ? 'open' : ''}`}>
<div className={`sliding-modal-container ${isOpen ? 'open' : ''}`} id={id}>
<div className="background" onClick={onClose}></div>
<div
className="content"
@@ -16,6 +20,10 @@ export const SlidingModal = ({ children, isOpen, onClose }: Props) => {
e.stopPropagation()
}}
>
<Button sx={{ marginTop: '2.5em', marginLeft: '1em' }} onClick={onClose}>
<ArrowBackIcon color="primary" style={{ marginRight: '0.2em' }} />
{returnButtonText}
</Button>
{children}
</div>
</div>
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,5 +1,5 @@
import { SVG } from '@svgdotjs/svg.js'
import { borders } from './country_borders_WGS84'
import { countryPolygons } from '../../country_data/countryPolygons.ts'
import { Locality } from '@/shared/types'
interface PixelCoordinates {
@@ -36,10 +36,10 @@ export const generateSvg = (
if (!transparent) draw.polygon(`0,0 ${width},0 ${width},${width} 0,${width}`).fill('#ffffff')
borders.forEach(country_border => {
countryPolygons.forEach(countryBorder => {
const points: string[] = []
country_border.forEach(coordinate => {
countryBorder.forEach(coordinate => {
const pixelCoords = webMercatorConvert(coordinate[0], coordinate[1], width)
points.push(`${pixelCoords.x},${pixelCoords.y}`)
})
+7 -11
View File
@@ -5,12 +5,13 @@ import { clearUser } from '../redux/userReducer'
import { api } from '../redux/api'
import { Role } from '@/shared/types'
import { useUser } from '@/hooks/user'
import { ENV } from '@/util/config'
import { useNotify } from '@/hooks/notification'
import PersonIcon from '@mui/icons-material/Person'
import '../styles/NavBar.css'
import { LinkDefinition, NavBarLink } from './NavBarLink'
import logo from '../resource/nowlogo.jpg'
export const NavBar = () => {
const user = useUser()
const navigate = useNavigate()
@@ -19,7 +20,7 @@ export const NavBar = () => {
const pages: LinkDefinition[] = [
{ title: 'Localities', url: '/locality' },
{ title: 'Species', url: '/species' },
{ title: 'Cross-Search', url: '/crosssearch' },
{ title: 'Locality-Species', url: '/crosssearch' },
{ title: 'References', url: '/reference' },
{ title: 'Time Units', url: '/time-unit' },
{ title: 'Time Bounds', url: '/time-bound', allowedRoles: [Role.Admin, Role.EditUnrestricted] },
@@ -53,16 +54,11 @@ export const NavBar = () => {
</MenuItem>
)
const getModeText = () => {
if (ENV === 'staging') return '(test)'
if (ENV === 'dev') return '(dev)'
return ''
}
return (
<AppBar position="static" id="main-navbar">
<Stack spacing={10} direction="row" marginLeft={2} justifyContent="flex-start">
<MenuItem component={Link} to="/">
<Typography fontSize={'1.4em'}>NOW Database {getModeText()}</Typography>
<div className="container">
<MenuItem component={Link} to="/" className="logo-link">
<img src={logo} title="Logo © Noira Martiskainen" />
</MenuItem>
<MenuList className="menu-list">
{pages
@@ -86,7 +82,7 @@ export const NavBar = () => {
</Stack>
)}
</Box>
</Stack>
</div>
</AppBar>
)
}
@@ -1,5 +1,9 @@
import { useParams, useNavigate } from 'react-router-dom'
import { useEditPersonMutation, useGetPersonDetailsQuery } from '../../redux/personReducer'
import {
useEditPersonMutation,
useGetPersonDetailsIdMutation,
useGetPersonDetailsQuery,
} from '../../redux/personReducer'
import { CircularProgress } from '@mui/material'
import { DetailView, TabType } from '../DetailView/DetailView'
import { PersonTab } from './Tabs/PersonTab'
@@ -8,6 +12,7 @@ import { EditDataType, PersonDetailsType, Role, ValidationErrors } from '@/share
import { validatePerson } from '@/shared/validators/person'
import { useNotify } from '@/hooks/notification'
import { useEffect } from 'react'
import { emptyPerson } from '../DetailView/common/defaultValues'
export const PersonDetails = () => {
const { id: idFromUrl } = useParams()
@@ -21,8 +26,10 @@ export const PersonDetails = () => {
// We designate special id 'user-page' instead of normal initials to mean current user's own page.
const isUserPage = idFromUrl === 'user-page'
const id = isUserPage ? user.initials : idFromUrl
const isNew = idFromUrl === 'new'
const { isLoading, isError, data } = useGetPersonDetailsQuery(id!)
const { isLoading, isError, data } = useGetPersonDetailsQuery(id!, { skip: isNew })
const [getPersonDetailsId] = useGetPersonDetailsIdMutation()
useEffect(() => {
if (!isUserPage && user.role !== Role.Admin) {
@@ -32,8 +39,23 @@ export const PersonDetails = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onWrite = async (editData: EditDataType<PersonDetailsType>) => {
const personExists = async (initials: string) => {
try {
const isPerson = await getPersonDetailsId(initials).unwrap()
if (isPerson) return true
return false
} catch {
return false
}
}
const onWrite = async (editData: EditDataType<PersonDetailsType>) => {
if (!editData.initials) return
try {
if (isNew && (await personExists(editData.initials))) {
notify('Initials already exists. Select Edit.', 'error')
return
}
const { initials } = await editPersonRequest(editData).unwrap()
notify('Saved person successfully.')
if (isUserPage) {
@@ -48,9 +70,9 @@ export const PersonDetails = () => {
}
if (isError) return <div>Error loading data</div>
if (isLoading || !data || mutationLoading) return <CircularProgress />
if (isLoading || (!data && !isNew) || mutationLoading) return <CircularProgress />
document.title = `User - ${data.user?.user_name}`
document.title = isNew ? 'New person' : `User - ${data!.user?.user_name}`
const tabs: TabType[] = [
{
@@ -62,10 +84,11 @@ export const PersonDetails = () => {
return (
<DetailView
onWrite={onWrite}
isNew={isNew}
isUserPage={isUserPage}
isPersonPage={true}
tabs={tabs}
data={data}
data={isNew ? emptyPerson : data!}
validator={validatePerson}
/>
)
@@ -0,0 +1,171 @@
import { SlidingModal } from '@/components/Map/SlidingModal'
import { useNotify } from '@/hooks/notification'
import '../../../styles/AddUserModal.css'
import { Button, IconButton, MenuItem, Select, SelectChangeEvent, TextField } from '@mui/material'
import { ChangeEventHandler, useState } from 'react'
import SaveIcon from '@mui/icons-material/Save'
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'
import VisibilityIcon from '@mui/icons-material/Visibility'
import CasinoIcon from '@mui/icons-material/Casino'
import { UserDetailsType, userGroups } from '@/shared/types'
import { useCreateUserMutation } from '@/redux/userReducer'
const UserFieldInput = ({
field,
label,
onChange,
value,
password,
}: {
field: string
label?: string
onChange: ChangeEventHandler<HTMLInputElement>
value: string
password?: boolean
}) => {
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1)
return (
<>
<label htmlFor={`user-form-${field}`}>{label ? label : capitalize(field)}:</label>
<TextField
variant="outlined"
size="small"
className="field"
type={password ? 'password' : 'text'}
id={`user-form-${field}`}
onChange={onChange}
value={value}
/>
</>
)
}
const UserPasswordInput = ({ onChange, value }: { onChange: (value: string) => void; value: string }) => {
const [hidePassword, setHidePassword] = useState(false)
const randomPassword = () => {
const passwordLength = 14
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789^?$%&~!'
let result: string = ''
for (let i = 0; i < passwordLength; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length))
}
onChange(result)
}
return (
<>
<label htmlFor="user-form-password">Password:</label>
<div className="password-input-container">
<TextField
variant="outlined"
size="small"
className="field"
type={hidePassword ? 'password' : 'text'}
id="user-form-password"
onChange={e => onChange(e.target.value)}
value={value}
/>
<IconButton onClick={() => setHidePassword(v => !v)}>
{hidePassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton>
<IconButton onClick={randomPassword}>
<CasinoIcon />
</IconButton>
</div>
</>
)
}
const UserGroupSelect = ({
value,
onChange,
id,
}: {
value: string
onChange: (e: SelectChangeEvent) => void
id?: string
}) => {
return (
<Select label={'User group'} value={value} onChange={onChange} size="small" id={id}>
{userGroups.map(group => (
<MenuItem key={group} value={group} style={{ height: '2em' }}>
{group}
</MenuItem>
))}
</Select>
)
}
interface UserFieldValues {
username: string
password: string
userGroup: string
}
interface Props {
isOpen: boolean
onClose: () => void
onSave: () => void
personInitials: string
}
export const AddUserModal = ({ isOpen, onClose, onSave, personInitials }: Props) => {
const [fieldValues, setFieldValues] = useState<UserFieldValues>({
username: '',
password: '',
userGroup: userGroups[0],
})
const [createUser, { isLoading }] = useCreateUserMutation()
const notify = useNotify()
const save = () => {
const userDetails: UserDetailsType = {
initials: personInitials,
password: fieldValues.password,
user_name: fieldValues.username,
now_user_group: fieldValues.userGroup,
}
createUser(userDetails)
.unwrap()
.then(() => {
notify('User saved successfully.')
onClose()
onSave()
})
.catch(e => {
notify((e as { data: { message: string } }).data.message, 'error')
})
}
return (
<SlidingModal isOpen={isOpen} onClose={onClose} id="add-user-modal">
<div className="inputs">
<UserFieldInput
field="username"
value={fieldValues.username}
onChange={e => setFieldValues({ ...fieldValues, username: e.target.value })}
/>
<UserPasswordInput
value={fieldValues.password}
onChange={value => setFieldValues({ ...fieldValues, password: value })}
/>
<label htmlFor="user-form-usergroup">User group:</label>
<UserGroupSelect
id="user-form-usergroup"
value={fieldValues.userGroup}
onChange={e => setFieldValues({ ...fieldValues, userGroup: e.target.value })}
/>
<div></div>
<div className="save-btn-container">
<Button variant="contained" className="save-btn" startIcon={<SaveIcon />} onClick={save} disabled={isLoading}>
Save
</Button>
</div>
</div>
</SlidingModal>
)
}
@@ -1,18 +1,26 @@
import { PersonDetailsType } from '@/shared/types'
import { PersonDetailsType, Role, userGroups } from '@/shared/types'
import { ArrayFrame, Grouped } from '../../DetailView/common/tabLayoutHelpers'
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
import { formatLastLoginDate } from '@/common'
import { useUser } from '@/hooks/user'
import { ChangePasswordForm } from './ChangePasswordForm'
import { Box } from '@mui/material'
import { useEffect } from 'react'
import { Box, Button } from '@mui/material'
import { useEffect, useState } from 'react'
import { useNotify } from '@/hooks/notification'
import { validCountries } from '@/shared/validators/countryList'
import { useParams } from 'react-router-dom'
import PersonAddIcon from '@mui/icons-material/PersonAdd'
import { AddUserModal } from './AddUserModal'
export const PersonTab = () => {
const { textField, dropdownWithSearch, data } = useDetailContext<PersonDetailsType>()
const { textField, dropdownWithSearch, data, mode } = useDetailContext<PersonDetailsType>()
const currentUser = useUser()
const notify = useNotify()
const { id: idFromUrl } = useParams()
const isNew = idFromUrl === 'new'
const isAdmin = currentUser.role == Role.Admin
const [isAddUserModalOpen, setIsAddUserModalOpen] = useState(false)
const [disableAddUserButton, setDisableAddUserButton] = useState(false)
useEffect(() => {
if (!currentUser?.isFirstLogin) return
@@ -22,10 +30,9 @@ export const PersonTab = () => {
}, [currentUser])
const countryOptions = ['', ...validCountries]
const userGroupOptions = ['su', 'eu', 'er', 'pl', 'plp', 'no', 'ro']
const person = [
['Initials', textField('initials', { type: 'text', disabled: true })],
['Initials', textField('initials', { type: 'text', disabled: !isNew })],
['First Name', textField('first_name')],
['Surname', textField('surname')],
['Email', textField('email')],
@@ -34,21 +41,36 @@ export const PersonTab = () => {
]
const lastLogin = data.user?.last_login
const user = data.user
? [
['User Name', data.user?.user_name ?? ''],
['Last log in', lastLogin ? formatLastLoginDate(lastLogin) : 'No data'],
[
'User Group',
dropdownWithSearch('now_user_group', userGroupOptions, 'User Group', false, 'Choose user group'),
],
]
: [['Not a user']]
const user = [
['User Name', data.user?.user_name ?? ''],
['Last log in', lastLogin ? formatLastLoginDate(lastLogin) : 'No data'],
['User Group', dropdownWithSearch('now_user_group', userGroups, 'User Group', !isAdmin, 'Choose user group')],
]
return (
<>
<AddUserModal
isOpen={isAddUserModalOpen}
onClose={() => {
setIsAddUserModalOpen(false)
}}
onSave={() => setDisableAddUserButton(true)}
personInitials={data.initials}
/>
<ArrayFrame array={person} title="Person" />
<ArrayFrame array={user} title="User" />
{data.user && <ArrayFrame array={user} title="User" />}
{isAdmin && !data.user && !disableAddUserButton && mode.option === 'read' && (
<Button
variant="contained"
startIcon={<PersonAddIcon />}
onClick={() => setIsAddUserModalOpen(true)}
sx={{ alignSelf: 'flex-start' }}
>
Add user
</Button>
)}
{currentUser.initials === data.initials && (
<Grouped title="Change password" style={{ padding: '1em' }}>
{currentUser.isFirstLogin && (
@@ -1,11 +1,20 @@
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { type MRT_ColumnDef } from 'material-react-table'
import { useGetAllSpeciesQuery } from '../../redux/speciesReducer'
import { Species } from '@/shared/types'
import { TableView } from '../TableView/TableView'
import { SynonymsModal } from './SynonymsModal'
export const SpeciesTable = ({ selectorFn }: { selectorFn?: (id: Species) => void }) => {
const [selectedSpecies, setSelectedSpecies] = useState<string | undefined>()
const [modalOpen, setModalOpen] = useState<boolean>(false)
const { data: speciesQueryData, isFetching } = useGetAllSpeciesQuery()
const handleSpeciesRowActionClick = (row: Species) => {
setSelectedSpecies(row.species_id.toString())
setModalOpen(true)
}
const columns = useMemo<MRT_ColumnDef<Species>[]>(
() => [
{
@@ -367,16 +376,20 @@ export const SpeciesTable = ({ selectorFn }: { selectorFn?: (id: Species) => voi
}
return (
<TableView<Species>
title="Species"
selectorFn={selectorFn}
idFieldName="species_id"
columns={columns}
isFetching={isFetching}
visibleColumns={visibleColumns}
data={speciesQueryData}
url="species"
enableColumnFilterModes={true}
/>
<>
<TableView<Species>
title="Species"
selectorFn={selectorFn}
idFieldName="species_id"
columns={columns}
isFetching={isFetching}
visibleColumns={visibleColumns}
data={speciesQueryData}
url="species"
enableColumnFilterModes={true}
tableRowAction={handleSpeciesRowActionClick}
/>
<SynonymsModal open={modalOpen} onClose={() => setModalOpen(false)} selectedSpecies={selectedSpecies} />
</>
)
}
@@ -0,0 +1,69 @@
import { Box, Modal, Alert, Typography, IconButton, Table, TableBody, TableRow, TableCell } from '@mui/material'
import { useGetSpeciesDetailsQuery } from '../../redux/speciesReducer'
import { skipToken } from '@reduxjs/toolkit/query/react'
import CloseIcon from '@mui/icons-material/Close'
import '../../styles/species/SynonymsModal.css'
export const SynonymsModal = ({
open,
onClose,
selectedSpecies,
}: {
open: boolean
onClose: () => void
selectedSpecies: string | undefined
}) => {
const { isError, isFetching, data } = useGetSpeciesDetailsQuery(selectedSpecies ? selectedSpecies : skipToken)
if (isFetching) {
return (
<Modal open={open} onClose={onClose}>
<Box className="modal-content"></Box>
</Modal>
)
}
return (
<Modal open={open} onClose={onClose} className="synonyms-modal modal">
<Box className="modal-content">
<Box className="modal-header">
<Typography variant="h6" component="h2">
{`Synonyms ${isError ? '' : `- ${data?.genus_name} ${data?.species_name}`}`}
</Typography>
<IconButton sx={{ color: 'white' }} onClick={onClose} aria-label="close" className="close-button">
<CloseIcon />
</IconButton>
</Box>
{isError ? (
<Alert severity="error" sx={{ m: 2 }}>
Failed to load species details. Please try again later.
</Alert>
) : (
<Table className="modal-table">
<TableBody>
<TableRow>
<TableCell>
<strong>Genus</strong>
</TableCell>
<TableCell>
<strong>Species</strong>
</TableCell>
<TableCell>
<strong>Comments</strong>
</TableCell>
</TableRow>
{data?.com_taxa_synonym?.map((synonym, index) => (
<TableRow key={index}>
<TableCell>{synonym.syn_genus_name}</TableCell>
<TableCell>{synonym.syn_species_name}</TableCell>
<TableCell>{synonym.syn_comment}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Box>
</Modal>
)
}
@@ -1,4 +1,4 @@
import { Box, Button, Tooltip } from '@mui/material'
import { Box, Button, Tooltip, Typography } from '@mui/material'
import { MRT_RowData, MRT_Row } from 'material-react-table'
import { useNavigate } from 'react-router-dom'
import ManageSearchIcon from '@mui/icons-material/ManageSearch'
@@ -11,32 +11,58 @@ export const ActionComponent = <T extends MRT_RowData>({
checkRowRestriction,
url,
selectorFn,
tableRowAction,
}: {
row: MRT_Row<T>
idFieldName: keyof T
checkRowRestriction?: ((row: T) => boolean) | undefined
url: string | undefined
selectorFn?: (id: T) => void
tableRowAction?: (row: T) => void
}) => {
const navigate = useNavigate()
const id = row.original[idFieldName]
const getIconToShow = () => {
if (selectorFn) return <AddCircleOutlineIcon />
if (selectorFn) {
return <AddCircleOutlineIcon />
} else if (tableRowAction) {
return (
<Typography
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
height: '100%',
width: '100%',
}}
variant="button"
component="p"
>
S
</Typography>
)
}
return <ManageSearchIcon />
}
const onClick = () => {
const onClick = (event: React.MouseEvent) => {
if (selectorFn) {
event.stopPropagation()
selectorFn(row.original)
} else if (tableRowAction) {
event.stopPropagation()
tableRowAction(row.original)
} else {
navigate(`/${url}/${id}`)
}
}
return (
<Box display="flex" gap="0.2em" alignItems="center" width="3.6em">
<Box display="flex" gap="0.2em" alignItems="center">
<Button data-cy={`detailview-button-${id}`} variant="text" style={{ width: '2em' }} onClick={onClick}>
{getIconToShow()}
</Button>
@@ -10,7 +10,7 @@ import {
MRT_VisibilityState,
MRT_TableInstance,
} from 'material-react-table'
import { Box, CircularProgress, Paper } from '@mui/material'
import { Box, CircularProgress, Paper, Tooltip } from '@mui/material'
import { useLocation, useNavigate } from 'react-router-dom'
import { ActionComponent } from './ActionComponent'
import { usePageContext } from '../Page'
@@ -18,6 +18,8 @@ import { useUser } from '@/hooks/user'
import { defaultPagination, defaultPaginationSmall } from '@/common'
import '../../styles/TableView.css'
import { TableToolBar } from './TableToolBar'
import NotListedLocationIcon from '@mui/icons-material/NotListedLocation'
import '../../styles/tableview/TableView.css'
type TableStateInUrl = 'sorting' | 'columnfilters' | 'pagination'
@@ -35,6 +37,7 @@ export const TableView = <T extends MRT_RowData>({
idFieldName,
checkRowRestriction,
selectorFn,
tableRowAction,
url,
title,
combinedExport,
@@ -46,14 +49,16 @@ export const TableView = <T extends MRT_RowData>({
serverSidePagination,
isFetching,
}: {
title?: string
data: T[] | undefined
columns: MRT_ColumnDef<T>[]
visibleColumns: MRT_VisibilityState
idFieldName: keyof T
checkRowRestriction?: (row: T) => boolean
selectorFn?: (id: T) => void
// here
tableRowAction?: (row: T) => void
url?: string
title?: string
combinedExport?: (lids: number[]) => Promise<void>
kmlExport?: (table: MRT_TableInstance<T>) => void
svgExport?: (table: MRT_TableInstance<T>) => void
@@ -115,6 +120,14 @@ export const TableView = <T extends MRT_RowData>({
const table = useMaterialReactTable({
columns: columns,
data: data || [],
muiTableBodyRowProps: ({ row }) => ({
onClick: () => {
navigate(`/${url}/${row.original[idFieldName]}`)
},
sx: {
cursor: 'pointer',
},
}),
state: {
columnFilters,
showColumnFilters: true,
@@ -127,7 +140,27 @@ export const TableView = <T extends MRT_RowData>({
columnVisibility: visibleColumns,
},
onColumnFiltersChange: setColumnFilters,
renderRowActions: ({ row }) => <ActionComponent {...{ selectorFn, url, checkRowRestriction, row, idFieldName }} />,
renderRowActions: ({ row }) => {
return (
<Box className="row-actions-column">
<Box>
<ActionComponent {...{ selectorFn, url, checkRowRestriction, row, idFieldName }} />
</Box>
<Box>
{row.original.has_synonym && (
<ActionComponent {...{ selectorFn, tableRowAction, url, checkRowRestriction, row, idFieldName }} />
)}
</Box>
<Box display="flex" alignItems="center" px="1.35em">
{row.original.has_no_locality && (
<Tooltip title="This species is not currently in any locality" placement="right-start">
<NotListedLocationIcon color="disabled" sx={{}} />
</Tooltip>
)}
</Box>
</Box>
)
},
displayColumnDefOptions: { 'mrt-row-actions': { size: 50, header: '' } },
enableRowActions: true,
enableMultiSort: !serverSidePagination,
@@ -204,7 +237,7 @@ export const TableView = <T extends MRT_RowData>({
<Paper elevation={5}>
{user && (
<div className="table-top-row">
<Box className="title">{title ?? ''}</Box>
{title && <Box className="title">{title}</Box>}
<TableToolBar<T>
table={table}
combinedExport={combinedExport}
@@ -216,7 +249,6 @@ export const TableView = <T extends MRT_RowData>({
/>
</div>
)}
<MaterialReactTable table={table} />
</Paper>
)
+3
View File
@@ -28,6 +28,7 @@ import { ProjectDetails } from './Project/ProjectDetails'
import { RegionTable } from './Region/RegionTable'
import { RegionDetails } from './Region/RegionDetails'
import { UserState } from '@/redux/userReducer'
import { FrontPage } from './FrontPage'
const noRights: EditRights = {}
const fullRights: EditRights = { new: true, edit: true, delete: true }
@@ -51,6 +52,8 @@ export const localityPage = (
/>
)
export const frontPage = <FrontPage />
export const crossSearchPage = (
<Page
tableView={<CrossSearchTable />}
+94
View File
@@ -0,0 +1,94 @@
# Country border polygon and bounding box data update instructions
## Create GeoJSON File in QGIS
Follow the steps below to create a GeoJSON file from a layer in QGIS.
### Prerequisites
- QGIS installed on your computer (version 3.x or later recommended)
- A vector layer loaded in QGIS (e.g., shapefile, CSV with geometry, or other spatial data)
### Instructions
1. **Open your project in QGIS.**
2. **Load the layer you want to export**, if it's not already visible:
- Use `Layer` > `Add Layer` > `Add Vector Layer…` to load spatial data.
3. **Right-click on the layer** in the **Layers Panel** and select:
- `Export` > `Save Features As…`
4. In the **Format** dropdown, choose:
- `GeoJSON`
5. Set the **File name**:
- Click the `…` button next to the file name field and choose a save location.
- Make sure the file ends with `.geojson`
6. Choose the **CRS** (Coordinate Reference System):
- Use `EPSG:4326 - WGS 84` for GeoJSON (standard for web use).
7. Choose just geometry to export, no other attributes/fields:
- You can choose which features or fields to export, but choose just geometry.
8. Click **OK** to export.
9. Your GeoJSON file is now saved and ready to use.
### Notes
- You can preview the GeoJSON file in QGIS or any code/text editor.
- If you're using the file for web mapping (e.g., Leaflet, Mapbox), ensure the CRS is WGS 84 (`EPSG:4326`).
---
## Country data conversion script
In this directory, there is a script for converting a country administrative boundaries `.geojson` file into data useful for this application.
When run, the script will output two files:
- `countryPolygons.ts`: An exported array of polygons representing country boundaries.
Each polygon is an array, inside which each coordinate is an array of two numbers.
The array is of type `number[][][]`.
- `countryBoundingBoxes.ts`: An object of the following type:
```ts
{
[key: string] : {
top: number;
left: number;
bottom: number;
right: number;
}
};
```
Where `key` is the country name and each member is an edge of the country's bounding box.
`top` and `bottom` are longitudes while `left` and `right` are latitudes.
### Usage
The script can be run with `node` and accepts a single command line argument, the country border `.geojson` filepath.
For example to convert the already included `.geojson` in this directory one would run:
```
$ node countryExport.js world-administrative-boundaries.geojson
```
Which will either quietly create the aforementioned files in this directory or crash with an error.
#### Country name mappings
Sometimes country names between the country name list in the validators folder and the U.N. data differ.
For this purpose, at the top of the script there is a mapping object to convert the U.N. name to the name used in this application.
Simply edit this object as needed.
```js
const nameMappings = {
// 'U.N. name': 'Our in-project name',
'Antigua & Barbuda': 'Antigua and Barbuda',
'U.K. of Great Britain and Northern Ireland': 'United Kingdom',
'United States Virgin Islands': 'Virgin Islands',
// ...
}
```
File diff suppressed because one or more lines are too long
+117
View File
@@ -0,0 +1,117 @@
import fs from 'fs'
// Edit these in cases where names in frontend/src/shared/validators/countryList.ts
// differ from the country boundary data
const nameMappings = {
'Antigua & Barbuda': 'Antigua and Barbuda',
'Bosnia & Herzegovina': 'Bosnia and Herzegovina',
'Brunei Darussalam': 'Brunei',
'Democratic Republic of the Congo': 'Congo, Democratic republic of the (prev. Zaire)',
"Côte d'Ivoire": "Cote D'Ivoire",
'Timor-Leste': 'East Timor',
'French Southern and Antarctic Territories': 'French Southern and Antarctic Lands',
Bahamas: 'Bahamas, The',
Gambia: 'Gambia, The',
'Iran (Islamic Republic of)': 'Iran',
"Lao People's Democratic Republic": 'Laos',
'Libyan Arab Jamahiriya': 'Libya',
'The former Yugoslav Republic of Macedonia': 'North Macedonia',
'Micronesia (Federated States of)': 'Micronesia, Federated States of',
'Moldova, Republic of': 'Moldova',
"Democratic People's Republic of Korea": 'North Korea',
'Russian Federation': 'Russia',
'South Georgia & the South Sandwich Islands': 'South Georgia and the South Sandwich Islands',
'Republic of Korea': 'South Korea',
'Syrian Arab Republic': 'Syria',
'United Republic of Tanzania': 'Tanzania',
'U.K. of Great Britain and Northern Ireland': 'United Kingdom',
'United States Virgin Islands': 'Virgin Islands',
}
function main() {
if (process.argv.length != 3) {
console.log('Usage: node countryDataExportTool.js [administrative boundaries .geojson file]')
return
}
const filepath = process.argv[2]
const data = fs.readFileSync(filepath, 'utf8')
let borders = JSON.parse(data)
const polygons = []
const boundingBoxes = {}
borders.features.forEach(feature => {
const bounds = {
top: null,
left: null,
bottom: null,
right: null,
}
const halvedBounds = {
top: null,
left: null,
bottom: null,
right: null,
}
feature.geometry.coordinates.forEach(poly => {
polygons.push(
poly[0].map(coord => {
const lat = coord[1]
const long = coord[0]
if (!bounds.top) bounds.top = lat
if (!bounds.bottom) bounds.bottom = lat
if (!bounds.left) bounds.left = long
if (!bounds.right) bounds.right = long
bounds.top = Math.min(bounds.top, lat)
bounds.bottom = Math.max(bounds.bottom, lat)
bounds.left = Math.min(bounds.left, long)
bounds.right = Math.max(bounds.right, long)
if (!halvedBounds.top) halvedBounds.top = lat
if (!halvedBounds.bottom) halvedBounds.bottom = lat
if (!halvedBounds.right) halvedBounds.right = -180
if (!halvedBounds.left) halvedBounds.left = long
halvedBounds.top = Math.min(halvedBounds.top, lat)
halvedBounds.bottom = Math.max(halvedBounds.bottom, lat)
if (long < 0) halvedBounds.right = Math.max(halvedBounds.right, long)
else halvedBounds.left = Math.min(halvedBounds.left, long)
return [lat, long]
})
)
})
let country = feature.properties.name
if (country in nameMappings) country = nameMappings[country]
const boundsWidth = bounds.right - bounds.left
const halvedBoundsWidth = halvedBounds.right + 180 + 180 - halvedBounds.left
const box = boundsWidth < halvedBoundsWidth ? bounds : halvedBounds
boundingBoxes[country] = box
})
const polygonString = 'export const countryPolygons: number[][][] = ' + JSON.stringify(polygons) + ';'
const boundsType = `export type CountryBoundingBox = {
top: number;
left: number;
bottom: number;
right: number;
};
export type CountryBoundingBoxes =
{
[key: string] : CountryBoundingBox
};
`
const boundsString =
boundsType + 'export const countryBoundingBoxes: CountryBoundingBoxes = ' + JSON.stringify(boundingBoxes) + ';'
fs.writeFileSync('countryPolygons.ts', polygonString)
fs.writeFileSync('countryBoundingBoxes.ts', boundsString)
}
main()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7 -1
View File
@@ -15,6 +15,11 @@ const personsApi = api.injectEndpoints({
}),
providesTags: result => (result ? [{ type: 'person', id: result.initials }] : []),
}),
getPersonDetailsId: builder.mutation<PersonDetailsType, string>({
query: id => ({
url: `/person/${id}`,
}),
}),
editPerson: builder.mutation<PersonDetailsType, EditDataType<PersonDetailsType>>({
query: person => ({
url: `/person`,
@@ -26,4 +31,5 @@ const personsApi = api.injectEndpoints({
}),
})
export const { useGetAllPersonsQuery, useGetPersonDetailsQuery, useEditPersonMutation } = personsApi
export const { useGetAllPersonsQuery, useGetPersonDetailsQuery, useEditPersonMutation, useGetPersonDetailsIdMutation } =
personsApi
+24
View File
@@ -0,0 +1,24 @@
import { api } from './api'
import { ActivityStatistic, Statistics } from '@/shared/types'
const statisticsApi = api.injectEndpoints({
endpoints: builder => ({
getStatistics: builder.query<Statistics, void>({
query: () => ({
url: `/statistics`,
}),
}),
getLocalityStatistics: builder.query<ActivityStatistic[], void>({
query: () => ({
url: `/statistics/locality`,
}),
}),
getSpeciesStatistics: builder.query<ActivityStatistic[], void>({
query: () => ({
url: `/statistics/species`,
}),
}),
}),
})
export const { useGetStatisticsQuery, useGetLocalityStatisticsQuery, useGetSpeciesStatisticsQuery } = statisticsApi
+9 -2
View File
@@ -1,6 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { api } from '@/redux/api'
import { Role } from '@/shared/types'
import { Role, UserDetailsType } from '@/shared/types'
export type LoginResponse = {
token: string
@@ -35,6 +35,13 @@ const userApi = api.injectEndpoints({
method: 'PUT',
}),
}),
createUser: builder.mutation<void, UserDetailsType>({
query: (user: UserDetailsType) => ({
url: '/user/new',
body: user,
method: 'POST',
}),
}),
}),
})
@@ -77,4 +84,4 @@ const userSlice = createSlice({
export const { setUser, setToken, clearUser, removeFirstLogin } = userSlice.actions
export const userReducer = userSlice.reducer
export const { useTryLoginMutation, useChangePasswordMutation } = userApi
export const { useTryLoginMutation, useChangePasswordMutation, useCreateUserMutation } = userApi
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+21
View File
@@ -5,6 +5,15 @@ import * as LogPrisma from '../../../../backend/prisma/generated/now_log_test_cl
import { Editable, FixBigInt, RowState } from './util'
import { Decimal } from '../../../../backend/prisma/generated/now_test_client/runtime/library'
export type UserDetailsType = {
password: string | null
user_name: string | null
now_user_group: string | null
initials: string
}
export type Statistics = { localityCount: number; speciesCount: number; localitySpeciesCount: number }
export type ActivityStatistic = { year: number; month: number; surname: string }
export type SpeciesType = FixBigInt<Prisma.com_species>
export type SedimentaryStructureValues = Prisma.now_ss_values
export type CollectingMethod = Prisma.now_coll_meth
@@ -328,6 +337,15 @@ export type RegionDetails = Prisma.now_reg_coord & { now_reg_coord_people: Array
now_reg_coord_country: Array<RegionCountry>
}
export type PersonDetails = {
initials: string | null
first_name: string | null
surname: string | null
email: string | null
organization: string | null
country: string | null
}
// used in Region's CoordinatorTab to allow adding com_people field to the editData manually
export type RegionDetailsWithComPeople = Prisma.now_reg_coord & {
now_reg_coord_people: Array<RegionCoordinator & { com_people?: Prisma.com_people }>
@@ -474,6 +492,9 @@ export type GeonamesJSON = {
export type ParsedGeoname = Pick<Geoname, 'name' | 'countryName' | 'fclName' | 'adminName1' | 'lat' | 'lng'>
export type UserGroup = 'su (admin)' | 'eu (edit unrestricted)' | 'er (edit restricted)' | 'ro (read only)'
export const userGroups = ['ro (read only)', 'er (edit restricted)', 'eu (edit unrestricted)', 'su (admin)']
export type SimplifiedLocality = {
lid: number
dec_lat: number
+15
View File
@@ -9,22 +9,37 @@ export const validatePerson = (
initials: {
name: 'initials',
required: true,
asString: true,
minLength: 2,
maxLength: 8,
},
first_name: {
name: 'First Name',
required: true,
asString: true,
minLength: 2,
maxLength: 20,
},
surname: {
name: 'Surname',
required: true,
asString: true,
minLength: 2,
maxLength: 20,
},
email: {
name: 'Email',
required: true,
asString: true,
minLength: 5,
maxLength: 30,
},
organization: {
name: 'Organization',
required: true,
asString: true,
minLength: 2,
maxLength: 30,
},
country: {
name: 'Country',
+45
View File
@@ -0,0 +1,45 @@
#add-user-modal .content {
width: 100%;
max-width: 40em;
}
#add-user-modal .inputs {
display: grid;
padding: 2em;
grid-template-columns: 1fr 2fr;
align-items: center;
row-gap: 0.8em;
column-gap: 1em;
}
#add-user-modal .inputs > label {
font-weight: 600;
}
#add-user-modal .save-btn-container {
display: flex;
justify-content: flex-end;
padding-top: 1em;
}
#add-user-modal .password-input-container {
display: flex;
}
#add-user-modal .password-input-container .field {
flex: 1;
margin-right: 0.6em;
}
@media screen and (max-width: 600px) {
#add-user-modal .inputs {
grid-template-columns: 1fr;
column-gap: 0;
row-gap: 0.3em;
}
#add-user-modal .inputs > label {
margin-top: 0.6em;
}
}
+15
View File
@@ -0,0 +1,15 @@
#wrapper {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-between;
}
footer {
background-color: #2e7fab;
color: white;
padding: 0.6em;
padding-left: 1em;
padding-top: 0.7em;
font-weight: 500;
}
+156
View File
@@ -0,0 +1,156 @@
#front-page .top-info {
display: flex;
justify-content: space-between;
}
#front-page a {
color: #2e40aa;
}
#front-page h3 {
font-size: 1rem;
}
#front-page .top-info .left {
margin-left: 1em;
}
#front-page .top-info .image-container {
/* aspect-ratio: initial; */
flex: 1;
margin-left: 3em;
display: flex;
gap: 0.4em;
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
}
#front-page .top-info .image-container img {
width: 100%;
max-width: 16em;
min-width: 10em;
}
#front-page p {
font-size: 1.05rem;
}
#front-page p,
#front-page h1 {
max-width: 40em;
}
#front-page .map-container {
overflow: hidden;
position: relative;
margin-top: 0em;
user-select: none;
}
#front-page .stats {
display: flex;
gap: 1.4em;
font-size: 1.1rem;
padding: 1em;
padding-bottom: 0;
top: -1px;
left: 0;
right: 0;
z-index: 2;
}
#front-page .stats span b {
margin-right: 0.2em;
}
#front-page .map-container .map {
--margin-top: 18%;
margin-top: calc(var(--margin-top) * -1);
width: 100%;
clip-path: polygon(0% 23%, 100% var(--margin-top), 97% 62%, 10% 70%);
background-color: #2e7fab;
margin-bottom: -23%;
}
#front-page .map-container .map img {
width: 100%;
}
#front-page .map-container:hover .map img {
transform: translateX(calc(var(--x) * (-1 / 1.5))) translateY(calc(var(--y) * (-1 / 1.5))) scale(1.5);
transform-origin: 0 0;
}
#front-page .additional-info {
display: flex;
justify-content: stretch;
flex-wrap: wrap;
margin-bottom: 3em;
}
#front-page .additional-info p {
white-space: break-word;
word-wrap: break-word;
text-wrap: wrap;
overflow-x: hidden;
max-width: 100vw;
}
#front-page .additional-info > div {
flex: 1;
min-width: 24em;
}
#front-page .additional-info .activity-stats {
display: flex;
flex-wrap: wrap;
}
#front-page .additional-info .activity-stats > div {
width: fit-content;
}
#front-page .stat-table {
padding-right: 2em;
}
#front-page .stat-table table {
width: fit-content;
border-collapse: collapse;
}
#front-page .stat-table table * {
white-space: nowrap;
}
#front-page .stat-table table td,
#front-page .stat-table table th {
border: 1px solid rgb(177, 177, 177);
border-width: 0 1px 1px 0;
text-align: left;
padding: 0.1em 0.4em;
}
#front-page .stat-table table th {
border-width: 0 1px 2px 0;
}
#front-page .stat-table table th:last-child,
#front-page .stat-table table td:last-child {
border-right: none;
}
#front-page .stat-table table tr:last-child td {
border-bottom: none;
}
@media screen and (max-width: 900px) {
#front-page .stats {
padding-bottom: 1em;
}
#front-page .top-info .image-container {
display: none;
}
}
+89 -31
View File
@@ -1,67 +1,125 @@
#map-container {
#localities-map .map-container {
position: relative;
--map-height: 70vh;
overflow: hidden;
height: 200px;
height: calc(var(--map-height) * 0.15);
transition: height 0.2s ease-out;
margin-bottom: 0.8em;
border-radius: 0.25em;
z-index: 1;
}
#blur-container {
z-index: 9999;
#localities-map .gradient {
z-index: 400;
position: absolute;
top: 0;
bottom: 0;
width: 100%;
height: 800px;
left: 0;
right: 0;
opacity: 1;
transition: opacity 0.3s ease-in-out;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 85%, rgb(255, 255, 255) 100%);
pointer-events: none;
transition: opacity 0.2s ease-out;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 40%, rgb(255, 255, 255) 100%);
cursor: pointer;
}
#blur-container.open {
#localities-map.open .gradient {
opacity: 0;
pointer-events: none;
}
#localities-map .map-btn-up {
font-size: 50px;
transform: rotateX(180deg);
transition: transform 0.5s ease-in-out;
}
#localities-map .map-btn-down {
font-size: 50px;
transform: rotateX(0deg);
transition: transform 0.5s ease-in-out;
}
#map-container.open {
#localities-map.open .map-container {
height: var(--map-height);
display: flex;
position: relative;
}
#map-container > .map {
#localities-map .map-container .map {
height: var(--map-height);
width: 100%;
}
#localities-map .cluster-btn {
#localities-map:not(.open) .map-container .map {
pointer-events: none !important;
user-select: none;
}
#localities-map:not(.open) .map-container .map * {
pointer-events: none;
}
#localities-map:not(.open) button.cluster {
pointer-events: none;
}
#localities-map button.cluster {
user-select: none;
cursor: pointer;
background-color: #fdfeff;
height: 2.7em;
width: 5.5em;
position: absolute;
padding: 0.3em 0.6em;
z-index: 1000;
margin-left: 4.2em;
margin-top: 0.95em;
left: 4.2em;
top: 0.90em;
border-radius: 0.25em;
border: none;
box-shadow: 0 0 0 0.15em rgba(0, 0, 0, 0.22);
}
#localities-map .button-row {
#localities-map button.cluster:hover {
background-color: #f4f4f4;
}
#localities-map .open-button {
width: 100%;
display: flex;
justify-content: center;
margin-bottom: 1rem;
height: 4em;
cursor: pointer;
}
#localities-map .downarrow {
--line-width: 4px;
--radius: 3px;
--angle: 130deg;
width: 2em;
height: 2em;
display: flex;
align-items: flex-end;
transform: translateY(-0.4em) rotate(-45deg);
transition: transform 0.3s ease-out;
}
#localities-map.open .downarrow {
transform: scaleY(-1) translateY(-1.8em) rotate(-45deg);
}
#localities-map .downarrow::before,
#localities-map .downarrow::after {
content: "";
display: block;
background-color: #a5a5a5;
transition: background-color 0.2s ease-out;
}
#localities-map:not(.open):hover .open-button .downarrow::before,
#localities-map:not(.open):hover .open-button .downarrow::after,
#localities-map.open .open-button:hover .downarrow::before,
#localities-map.open .open-button:hover .downarrow::after {
background-color: #606060;
}
#localities-map .downarrow::before {
height: 100%;
width: var(--line-width);
border-radius: var(--radius);
transform: rotate(calc(45deg - var(--angle) * 0.5));
transform-origin: 50% 100%;
}
#localities-map .downarrow::after {
width: 100%;
height: var(--line-width);
border-radius: var(--radius);
transform: rotate(calc(-1 * (45deg - var(--angle) * 0.5)));
transform-origin: 0% 50%;
margin-left: calc(-1 * var(--line-width));
}
+22 -18
View File
@@ -1,40 +1,44 @@
.species-table {
overflow-x: auto;
padding-bottom: 1em;
margin: 2em;
overflow-x: auto;
padding-bottom: 1em;
margin: 2em;
}
.species-table table {
width: fit-content ;
border-collapse: collapse;
width: fit-content;
border-collapse: collapse;
}
.species-table table * {
white-space: nowrap;
white-space: nowrap;
}
.species-table table td ,
.species-table table th
{
border: 1px solid rgb(177, 177, 177);
border-width: 0 1px 1px 0;
text-align: left;
padding: 0.1em 0.4em;
.species-table table td,
.species-table table th {
border: 1px solid rgb(177, 177, 177);
border-width: 0 1px 1px 0;
text-align: left;
padding: 0.1em 0.4em;
}
.species-table table th {
border-width: 0 1px 2px 0;
border-width: 0 1px 2px 0;
}
.species-table table th:last-child ,
.species-table table th:last-child,
.species-table table td:last-child {
border-right: none;
border-right: none;
}
.species-table table tr:last-child td {
border-bottom: none;
border-bottom: none;
}
.locality-details-panel {
padding: 1em 2em;
padding: 1em 2em;
}
.locality-details-panel a {
text-decoration: none;
color: black;
}
+21 -2
View File
@@ -1,6 +1,8 @@
#main-navbar {
background-color: #2e7fab;
--color-hl: #3896c9;
padding-left: 0;
margin-left: 0;
}
#main-navbar > * {
@@ -25,6 +27,7 @@
#main-navbar .username-box {
display: flex;
align-items: center;
border: 2px solid var(--color-hl);
border-radius: 0.25rem;
padding: 0.4em 1em 0.3em 0.5em;
@@ -34,11 +37,17 @@
font-weight: 500;
}
#main-navbar .container {
display: flex;
align-items: stretch;
}
#main-navbar .menu-list {
margin-left: 1.4em;
margin-left: 2em;
padding: 0.6em 0;
display: flex;
flex-wrap: wrap;
align-items: center;
align-items: stretch;
}
#main-navbar .dropdown-link {
@@ -77,3 +86,13 @@
#main-navbar .username-box:hover {
background-color: var(--color-hl);
}
#main-navbar .logo-link {
padding: 0;
}
#main-navbar .logo-link img {
height: 4em;
transform-origin: 0 80%;
transform: scale(1.03);
}
+6 -5
View File
@@ -19,7 +19,7 @@
/* width was 40em*/
.sliding-modal-container .content {
--max-width: calc(100vw - 6em);
--max-width: calc(100vw - 16em);
background-color: white;
position: absolute;
top: 0;
@@ -33,10 +33,11 @@
@media screen and (max-width: 600px) {
.sliding-modal-container .content {
--max-width: calc(100vw - 3em);
}}
--max-width: calc(100vw - 3em);
}
}
.sliding-modal-container.open .content {
.sliding-modal-container.open .content {
transform: translateX(0);
pointer-events: initial;
}
@@ -44,4 +45,4 @@
.sliding-modal-container.open .background {
background-color: rgba(0, 0, 0, 0.4);
pointer-events: initial;
}
}
+2 -2
View File
@@ -12,9 +12,9 @@
background-color: white;
padding: 2em 2em;
width: calc(100% - 4em);
height: calc(100% - 2em);
max-height: calc(100% - 2em);
height: fit-content;
max-width: 50em;
max-height: fit-content;
border-radius: 0.25rem;
overflow-y: auto;
}
@@ -0,0 +1,33 @@
.synonyms-modal .modal-content {
padding: 0;
background-color: white;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #3896c9;
color: white;
padding: 8px 16px;
font-weight: 700;
border-radius: 0.25rem 0.25rem 0 0;
}
.modal-table {
padding: 1em;
display: grid;
grid-template-columns: repeat(3, 1fr);
text-align: center;
gap: 16px;
background-color: white;
}
.close-button {
cursor: pointer;
}
.close-button:focus {
outline: none;
}
@@ -0,0 +1,6 @@
.row-actions-column {
display: flex;
flex-wrap: nowrap;
gap: 1px;
min-width: 13em;
}
@@ -0,0 +1,56 @@
import { fail } from 'assert'
import { countryBoundingBoxes } from '../country_data/countryBoundingBoxes'
import { validCountries } from '../shared/validators/countryList'
import { it, describe } from '@jest/globals'
describe('Country bounding box coordinate checking', () => {
it('All countries are present', () => {
// NOTE: Possible political connotations are purely coincidental.
// The ignored countries and territories simply represent names not
// present in the U.N. data used for country boundaries.
const ignore = [
'Aland Islands',
'Antarctica',
'Ascension Island',
'Ashmore and Cartier Islands',
'Baker Island',
'Bassas Da India',
'Bonaire',
'Clipperton Island',
'Coral Sea Islands',
'Curacao',
'Europa Island',
'Falkland Islands (Islas Malvinas)',
'Heard Island and Mcdonald Islands',
'Howland Island',
'Isle Of Man',
'Jan Mayen',
'Juan De Nova Island',
'Kosovo',
'Macau',
'Midway Islands',
"No Man's Land",
'Pitcairn Islands',
'Saba',
'Saint Barthelemy',
'Saint Eustatius',
'Saint Helena',
'Saint Martin',
'Saint Pierre and Miquelon',
'Svalbard',
'Tromelin Island',
'United States Minor Outlying Islands',
'Vatican City',
'Wake Island',
'Wallis and Futuna',
'Palestine',
]
for (const country of validCountries) {
if (ignore.includes(country)) continue
if (!(country in countryBoundingBoxes)) {
fail(`${country} not in bounding box data.`)
}
}
})
})
+34
View File
@@ -0,0 +1,34 @@
import { CountryBoundingBox } from '@/country_data/countryBoundingBoxes'
// Checks whether or not given latitude longitude point is within a bounding box.
export const isPointInBoxes = (dec_lat: number, dec_long: number, boxes: CountryBoundingBox[]): boolean => {
for (const box of boxes) {
if (dec_lat < box.top) continue
if (dec_lat > box.bottom) continue
if (dec_long < box.left) continue
if (dec_long > box.right) continue
return true
}
return false
}
// Splits bounding box that wraps around the longitude
export const boundingBoxSplit = (box: CountryBoundingBox): CountryBoundingBox[] => {
if (box.right >= box.left) return [box]
return [
{
top: box.top,
bottom: box.bottom,
left: -180,
right: box.right,
},
{
top: box.top,
bottom: box.bottom,
left: box.left,
right: 180,
},
]
}
+1 -1
View File
@@ -31,7 +31,7 @@
}
},
"include": ["src"],
"exclude": ["../backend/**/*", "./src/components/Map/country_borders_WGS84.ts"],
"exclude": ["../backend/**/*", "./src/country_data/**"],
"references": [{ "path": "./tsconfig.node.json" }]
}