mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Merge branch 'main' into codex/character-set-mojibake-check
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react'
|
||||
import { type MRT_ColumnDef } from 'material-react-table'
|
||||
import { type MRT_ColumnDef, type MRT_RowData, type MRT_TableInstance } from 'material-react-table'
|
||||
import { CrossSearch, formatDevelopmentalCrownType } from '@/shared/types'
|
||||
import { TableView } from '../TableView/TableView'
|
||||
import type { ColumnVisibilityGroup } from '../TableView/TableToolBar'
|
||||
@@ -12,6 +12,11 @@ import { OccurrenceDwcExportMenuItem } from '@/components/Occurrence/OccurrenceD
|
||||
import { OccurrenceDwcDpExportMenuItem } from '@/components/Occurrence/OccurrenceDwcDpExportMenuItem'
|
||||
import { OccurrenceFullDarwinCoreExportMenuItem } from '@/components/Occurrence/OccurrenceFullDarwinCoreExportMenuItem'
|
||||
import { matchesCountryOrContinent } from '@/shared/validators/countryContinents'
|
||||
import {
|
||||
exportOccurrenceMapKml,
|
||||
exportOccurrenceMapSvg,
|
||||
getUniqueCrossSearchMapExportLocalities,
|
||||
} from '@/components/Species/localitySpeciesMapExport'
|
||||
|
||||
export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: CrossSearch) => void }) => {
|
||||
const { sqlLimit, sqlOffset, sqlColumnFilters, sqlOrderBy } = usePageContext()
|
||||
@@ -1072,6 +1077,19 @@ export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: Cros
|
||||
return !!row.loc_status
|
||||
}
|
||||
|
||||
const getExportLocalities = <T extends MRT_RowData>(table: MRT_TableInstance<T>) => {
|
||||
const rows = table.getPrePaginationRowModel().rows.map(row => row.original as unknown as CrossSearch)
|
||||
return getUniqueCrossSearchMapExportLocalities(rows)
|
||||
}
|
||||
|
||||
const kmlExport = <T extends MRT_RowData>(table: MRT_TableInstance<T>) => {
|
||||
exportOccurrenceMapKml(table, 'occurrences', getExportLocalities)
|
||||
}
|
||||
|
||||
const svgExport = async <T extends MRT_RowData>(table: MRT_TableInstance<T>) => {
|
||||
await exportOccurrenceMapSvg(table, 'occurrences-map', getExportLocalities)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LocalitiesMap localities={localitiesData} isFetching={localitiesFetching || isFetching} />
|
||||
@@ -1090,6 +1108,8 @@ export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: Cros
|
||||
enableColumnFilterModes={true}
|
||||
serverSidePagination={true}
|
||||
isCrossSearchTable={true}
|
||||
kmlExport={kmlExport}
|
||||
svgExport={svgExport}
|
||||
isError={isError}
|
||||
error={error}
|
||||
renderExtraExportMenuItems={handleClose => (
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
MRT_PaginationState,
|
||||
MRT_Row,
|
||||
MRT_RowData,
|
||||
MRT_TableInstance,
|
||||
MRT_TableOptions,
|
||||
MRT_VisibilityState,
|
||||
type MRT_ColumnDef,
|
||||
@@ -128,6 +129,8 @@ type DetailTabTableEditProps<T extends MRT_RowData> = {
|
||||
muiTableBodyRowProps?: MRT_TableOptions<T>['muiTableBodyRowProps']
|
||||
tableName?: string
|
||||
tableContainerMaxHeight?: string | number
|
||||
kmlExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
|
||||
svgExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
|
||||
}
|
||||
|
||||
type DetailTabTableProps<T extends MRT_RowData> = DetailTabTableReadSelectProps<T> | DetailTabTableEditProps<T>
|
||||
@@ -199,6 +202,8 @@ const DetailTabEditableTable = <T extends MRT_RowData>({
|
||||
muiTableBodyRowProps,
|
||||
tableName = 'table',
|
||||
tableContainerMaxHeight = '60vh',
|
||||
kmlExport,
|
||||
svgExport,
|
||||
}: DetailTabTableEditProps<T>) => {
|
||||
const [pagination, setPagination] = useState<MRT_PaginationState>(paginationState ?? defaultEditPagination)
|
||||
|
||||
@@ -230,7 +235,9 @@ const DetailTabEditableTable = <T extends MRT_RowData>({
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{enableTopToolbar && <DetailTabEditableToolbar table={table} tableName={tableName} />}
|
||||
{enableTopToolbar && (
|
||||
<DetailTabEditableToolbar table={table} tableName={tableName} kmlExport={kmlExport} svgExport={svgExport} />
|
||||
)}
|
||||
<MaterialReactTable table={table} />
|
||||
</Box>
|
||||
)
|
||||
@@ -239,9 +246,13 @@ const DetailTabEditableTable = <T extends MRT_RowData>({
|
||||
const DetailTabEditableToolbar = <T extends MRT_RowData>({
|
||||
table,
|
||||
tableName,
|
||||
kmlExport,
|
||||
svgExport,
|
||||
}: {
|
||||
table: ReturnType<typeof useMaterialReactTable<T>>
|
||||
tableName: string
|
||||
kmlExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
|
||||
svgExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
|
||||
}) => {
|
||||
const user = useUser()
|
||||
|
||||
@@ -251,7 +262,13 @@ const DetailTabEditableToolbar = <T extends MRT_RowData>({
|
||||
<div className="table-top-row">
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<TableHelp showFiltering showSorting showMultiSorting showColumnVisibility showExport />
|
||||
<TableToolBar<T> table={table} tableName={tableName} hideLeftButtons={true} />
|
||||
<TableToolBar<T>
|
||||
table={table}
|
||||
tableName={tableName}
|
||||
hideLeftButtons={true}
|
||||
kmlExport={kmlExport}
|
||||
svgExport={svgExport}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EditDataType, RowState } from '@/shared/types'
|
||||
import { CircularProgress, Box, Button, Tooltip } from '@mui/material'
|
||||
import { type MRT_ColumnDef, type MRT_Row, type MRT_RowData } from 'material-react-table'
|
||||
import { type MRT_ColumnDef, type MRT_Row, type MRT_RowData, type MRT_TableInstance } from 'material-react-table'
|
||||
import { useDetailContext } from '../Context/DetailContext'
|
||||
import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'
|
||||
@@ -34,6 +34,8 @@ export const EditableTable = <
|
||||
url,
|
||||
getDetailPath,
|
||||
checkRowRestriction,
|
||||
kmlExport,
|
||||
svgExport,
|
||||
}: {
|
||||
tableData?: Array<T> | null
|
||||
editTableData?: Array<EditDataType<T>> | null
|
||||
@@ -47,6 +49,8 @@ export const EditableTable = <
|
||||
url?: string
|
||||
getDetailPath?: (row: T) => string
|
||||
checkRowRestriction?: (row: T) => boolean
|
||||
kmlExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
|
||||
svgExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
|
||||
}) => {
|
||||
const { editData, setEditData, mode, data, validator, fieldsWithErrors, setFieldsWithErrors } =
|
||||
useDetailContext<ParentType>()
|
||||
@@ -172,6 +176,8 @@ export const EditableTable = <
|
||||
enableSorting={enableAdvancedTableControls}
|
||||
enableRowActions={!mode.read || Boolean(checkRowRestriction)}
|
||||
renderRowActions={resolveRenderRowActions()}
|
||||
kmlExport={kmlExport}
|
||||
svgExport={svgExport}
|
||||
muiTableBodyRowProps={({ row }: { row: MRT_Row<T> }) => ({
|
||||
'data-cy': idFieldName ? `table-row-${String(row.original[idFieldName])}` : undefined,
|
||||
onClick: () => {
|
||||
|
||||
@@ -4,13 +4,18 @@ import { EditingModal } from '@/components/DetailView/common/EditingModal'
|
||||
import { Grouped } from '@/components/DetailView/common/tabLayoutHelpers'
|
||||
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
|
||||
import { Box, TextField } from '@mui/material'
|
||||
import { MRT_ColumnDef, MRT_Row } from 'material-react-table'
|
||||
import { MRT_ColumnDef, MRT_Row, MRT_RowData, MRT_TableInstance } from 'material-react-table'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { calculateNormalizedMesowearScore } from '@/shared/utils/mesowear'
|
||||
import { applyDefaultSpeciesOrdering, hasActiveSortingInSearch } from '@/components/DetailView/common/DetailTabTable'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useMemo } from 'react'
|
||||
import { occurrenceLabels } from '@/constants/occurrenceLabels'
|
||||
import {
|
||||
exportOccurrenceMapKml,
|
||||
exportOccurrenceMapSvg,
|
||||
getUniqueLocalityOccurrenceMapExportLocalities,
|
||||
} from '@/components/Species/localitySpeciesMapExport'
|
||||
|
||||
const hasMesowearScoreInputs = (row: LocalitySpecies) => {
|
||||
return (
|
||||
@@ -215,6 +220,19 @@ export const OccurrencesTab = () => {
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
|
||||
const getExportLocalities = <T extends MRT_RowData>(table: MRT_TableInstance<T>) => {
|
||||
const rows = table.getPrePaginationRowModel().rows.map(row => row.original as unknown as LocalitySpecies)
|
||||
return getUniqueLocalityOccurrenceMapExportLocalities(data, rows)
|
||||
}
|
||||
|
||||
const kmlExport = <T extends MRT_RowData>(table: MRT_TableInstance<T>) => {
|
||||
exportOccurrenceMapKml(table, 'locality-occurrences', getExportLocalities)
|
||||
}
|
||||
|
||||
const svgExport = async <T extends MRT_RowData>(table: MRT_TableInstance<T>) => {
|
||||
await exportOccurrenceMapSvg(table, 'locality-occurrences-map', getExportLocalities)
|
||||
}
|
||||
|
||||
const editingModal = (
|
||||
<EditingModal buttonText={occurrenceLabels.addNewButton} onSave={onSave}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1em' }}>
|
||||
@@ -238,6 +256,8 @@ export const OccurrencesTab = () => {
|
||||
idFieldName="species_id"
|
||||
url="occurrence"
|
||||
getDetailPath={row => `/occurrence/${row.lid}/${row.species_id}`}
|
||||
kmlExport={kmlExport}
|
||||
svgExport={svgExport}
|
||||
/>
|
||||
</Grouped>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { EditingModal } from '@/components/DetailView/common/EditingModal'
|
||||
import { Grouped } from '@/components/DetailView/common/tabLayoutHelpers'
|
||||
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
|
||||
import { Box, TextField } from '@mui/material'
|
||||
import { MRT_ColumnDef, MRT_Row } from 'material-react-table'
|
||||
import { MRT_ColumnDef, MRT_Row, MRT_RowData, MRT_TableInstance } from 'material-react-table'
|
||||
import { matchesCountryOrContinent } from '@/shared/validators/countryContinents'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { calculateNormalizedMesowearScore } from '@/shared/utils/mesowear'
|
||||
@@ -12,6 +12,11 @@ import { applyDefaultSpeciesOrdering, hasActiveSortingInSearch } from '@/compone
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useMemo } from 'react'
|
||||
import { occurrenceLabels } from '@/constants/occurrenceLabels'
|
||||
import {
|
||||
exportOccurrenceMapKml,
|
||||
exportOccurrenceMapSvg,
|
||||
getUniqueSpeciesLocalityMapExportLocalities,
|
||||
} from '../localitySpeciesMapExport'
|
||||
|
||||
const hasMesowearScoreInputs = (row: SpeciesLocality) => {
|
||||
return (
|
||||
@@ -197,6 +202,19 @@ export const LocalitySpeciesTab = () => {
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
|
||||
const getExportLocalities = <T extends MRT_RowData>(table: MRT_TableInstance<T>) => {
|
||||
const rows = table.getPrePaginationRowModel().rows.map(row => row.original as unknown as SpeciesLocality)
|
||||
return getUniqueSpeciesLocalityMapExportLocalities(data, rows)
|
||||
}
|
||||
|
||||
const kmlExport = <T extends MRT_RowData>(table: MRT_TableInstance<T>) => {
|
||||
exportOccurrenceMapKml(table, 'locality-species', getExportLocalities)
|
||||
}
|
||||
|
||||
const svgExport = async <T extends MRT_RowData>(table: MRT_TableInstance<T>) => {
|
||||
await exportOccurrenceMapSvg(table, 'locality-species-map', getExportLocalities)
|
||||
}
|
||||
|
||||
const editingModal = (
|
||||
<EditingModal buttonText={occurrenceLabels.addNewButton} onSave={onSave}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1em' }}>
|
||||
@@ -224,6 +242,8 @@ export const LocalitySpeciesTab = () => {
|
||||
url="occurrence"
|
||||
getDetailPath={row => `/occurrence/${row.lid}/${row.species_id}`}
|
||||
checkRowRestriction={row => Boolean(row.now_loc?.loc_status)}
|
||||
kmlExport={kmlExport}
|
||||
svgExport={svgExport}
|
||||
/>
|
||||
</Grouped>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { CrossSearch, Locality, LocalityDetailsType, LocalitySpecies, SpeciesLocality } from '@/shared/types'
|
||||
import { generateKml } from '@/util/kml'
|
||||
import { currentDateAsString } from '@/shared/currentDateAsString'
|
||||
import type { MRT_RowData, MRT_TableInstance } from 'material-react-table'
|
||||
|
||||
type SpeciesSource = {
|
||||
species_id?: number | null
|
||||
genus_name?: string | null
|
||||
species_name?: string | null
|
||||
unique_identifier?: string | null
|
||||
}
|
||||
|
||||
export type MapExportLocality = Locality & {
|
||||
species?: string[]
|
||||
}
|
||||
|
||||
type OccurrenceMapExportRow = {
|
||||
locality: MapExportLocalitySource | null | undefined
|
||||
species: SpeciesSource | null
|
||||
}
|
||||
|
||||
type MapExportLocalitySource = {
|
||||
lid?: unknown
|
||||
loc_name?: unknown
|
||||
country?: unknown
|
||||
dms_lat?: unknown
|
||||
dms_long?: unknown
|
||||
dec_lat?: unknown
|
||||
dec_long?: unknown
|
||||
max_age?: unknown
|
||||
min_age?: unknown
|
||||
bfa_max?: unknown
|
||||
bfa_min?: unknown
|
||||
altitude?: unknown
|
||||
appr_num_spm?: unknown
|
||||
species?: string[]
|
||||
}
|
||||
|
||||
const toFiniteNumber = (value: unknown): number | null => {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const asNumber = Number(value)
|
||||
return Number.isFinite(asNumber) ? asNumber : null
|
||||
}
|
||||
|
||||
const toStringValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return `${value}`
|
||||
return ''
|
||||
}
|
||||
|
||||
const toNullableString = (value: unknown): string | null => {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return `${value}`
|
||||
return null
|
||||
}
|
||||
|
||||
const formatSpeciesName = (species: SpeciesSource): string | null => {
|
||||
const genusSpecies = [species.genus_name, species.species_name].filter(Boolean).join(' ').trim()
|
||||
const uniqueIdentifier = species.unique_identifier?.trim()
|
||||
const name =
|
||||
uniqueIdentifier && uniqueIdentifier !== '-' ? `${genusSpecies} ${uniqueIdentifier}`.trim() : genusSpecies
|
||||
|
||||
if (name) return name
|
||||
return species.species_id === null || species.species_id === undefined ? null : `species_id ${species.species_id}`
|
||||
}
|
||||
|
||||
const addSpecies = (locality: MapExportLocality, species: SpeciesSource | null) => {
|
||||
const speciesName = species ? formatSpeciesName(species) : null
|
||||
if (!speciesName) return
|
||||
|
||||
locality.species ??= []
|
||||
if (!locality.species.includes(speciesName)) {
|
||||
locality.species.push(speciesName)
|
||||
}
|
||||
}
|
||||
|
||||
const getUniqueOccurrenceMapExportLocalities = (rows: OccurrenceMapExportRow[]): MapExportLocality[] => {
|
||||
const localitiesById = new Map<number, MapExportLocality>()
|
||||
|
||||
rows.forEach(row => {
|
||||
const locality = toMapExportLocality(row.locality)
|
||||
if (!locality) return
|
||||
|
||||
const exportLocality = localitiesById.get(locality.lid) ?? locality
|
||||
addSpecies(exportLocality, row.species)
|
||||
localitiesById.set(locality.lid, exportLocality)
|
||||
})
|
||||
|
||||
return [...localitiesById.values()]
|
||||
}
|
||||
|
||||
export const toMapExportLocality = (locality: MapExportLocalitySource | null | undefined): MapExportLocality | null => {
|
||||
if (!locality) return null
|
||||
|
||||
const decLat = toFiniteNumber(locality.dec_lat)
|
||||
const decLong = toFiniteNumber(locality.dec_long)
|
||||
if (decLat === null || decLong === null) return null
|
||||
|
||||
return {
|
||||
...locality,
|
||||
lid: Number(locality.lid),
|
||||
loc_name: toStringValue(locality.loc_name),
|
||||
country: toStringValue(locality.country),
|
||||
dms_lat: toStringValue(locality.dms_lat),
|
||||
dms_long: toStringValue(locality.dms_long),
|
||||
dec_lat: decLat,
|
||||
dec_long: decLong,
|
||||
bfa_max: toNullableString(locality.bfa_max),
|
||||
bfa_min: toNullableString(locality.bfa_min),
|
||||
max_age: toFiniteNumber(locality.max_age) ?? 0,
|
||||
min_age: toFiniteNumber(locality.min_age) ?? 0,
|
||||
altitude: toFiniteNumber(locality.altitude) ?? 0,
|
||||
appr_num_spm: toFiniteNumber(locality.appr_num_spm) ?? 0,
|
||||
} as unknown as MapExportLocality
|
||||
}
|
||||
|
||||
export const getUniqueSpeciesLocalityMapExportLocalities = (
|
||||
species: SpeciesSource,
|
||||
rows: SpeciesLocality[]
|
||||
): MapExportLocality[] =>
|
||||
getUniqueOccurrenceMapExportLocalities(
|
||||
rows.map(row => ({
|
||||
locality: { ...row.now_loc, lid: row.now_loc?.lid ?? row.lid },
|
||||
species,
|
||||
}))
|
||||
)
|
||||
|
||||
export const getUniqueLocalityOccurrenceMapExportLocalities = (
|
||||
locality: LocalityDetailsType,
|
||||
rows: LocalitySpecies[]
|
||||
): MapExportLocality[] =>
|
||||
getUniqueOccurrenceMapExportLocalities(rows.map(row => ({ locality, species: row.com_species })))
|
||||
|
||||
export const getUniqueCrossSearchMapExportLocalities = (rows: CrossSearch[]): MapExportLocality[] =>
|
||||
getUniqueOccurrenceMapExportLocalities(
|
||||
rows.map(row => ({
|
||||
locality: {
|
||||
lid: row.lid_now_loc,
|
||||
loc_name: row.loc_name,
|
||||
country: row.country,
|
||||
dms_lat: row.dms_lat,
|
||||
dms_long: row.dms_long,
|
||||
dec_lat: row.dec_lat,
|
||||
dec_long: row.dec_long,
|
||||
bfa_max: row.bfa_max,
|
||||
bfa_min: row.bfa_min,
|
||||
max_age: row.max_age,
|
||||
min_age: row.min_age,
|
||||
altitude: row.altitude,
|
||||
appr_num_spm: row.appr_num_spm,
|
||||
},
|
||||
species: {
|
||||
species_id: row.species_id_com_species,
|
||||
genus_name: row.genus_name,
|
||||
species_name: row.species_name,
|
||||
unique_identifier: row.unique_identifier,
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
const downloadTextFile = (dataString: string, type: string, filename: string) => {
|
||||
const blob = new Blob([dataString], { type })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
}
|
||||
|
||||
export const exportOccurrenceMapKml = <T extends MRT_RowData>(
|
||||
table: MRT_TableInstance<T>,
|
||||
filenamePrefix: string,
|
||||
getLocalities: (table: MRT_TableInstance<T>) => MapExportLocality[]
|
||||
) => {
|
||||
downloadTextFile(generateKml(getLocalities(table)), 'text/kml', `${filenamePrefix}-${currentDateAsString()}.kml`)
|
||||
}
|
||||
|
||||
export const exportOccurrenceMapSvg = async <T extends MRT_RowData>(
|
||||
table: MRT_TableInstance<T>,
|
||||
filenamePrefix: string,
|
||||
getLocalities: (table: MRT_TableInstance<T>) => MapExportLocality[]
|
||||
) => {
|
||||
const { generateSvg } = await import('@/components/Map/generateSvg')
|
||||
downloadTextFile(generateSvg(getLocalities(table)), 'image/svg+xml', `${filenamePrefix}-${currentDateAsString()}.svg`)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from '@jest/globals'
|
||||
|
||||
import {
|
||||
getUniqueCrossSearchMapExportLocalities,
|
||||
getUniqueLocalityOccurrenceMapExportLocalities,
|
||||
getUniqueSpeciesLocalityMapExportLocalities,
|
||||
toMapExportLocality,
|
||||
} from '@/components/Species/localitySpeciesMapExport'
|
||||
import { generateKml } from '@/util/kml'
|
||||
import type { CrossSearch, LocalityDetailsType, LocalitySpecies, SpeciesLocality } from '@/shared/types'
|
||||
|
||||
const crossSearch = (overrides: unknown): CrossSearch => overrides as CrossSearch
|
||||
const speciesLocality = (overrides: unknown): SpeciesLocality => overrides as SpeciesLocality
|
||||
const localitySpecies = (overrides: unknown): LocalitySpecies => overrides as LocalitySpecies
|
||||
const locality = (overrides: unknown): LocalityDetailsType => overrides as LocalityDetailsType
|
||||
|
||||
describe('locality species map export helpers', () => {
|
||||
it('maps a species locality row to a locality export row', () => {
|
||||
const result = toMapExportLocality({
|
||||
lid: 123,
|
||||
loc_name: 'Test locality',
|
||||
country: 'Finland',
|
||||
dms_lat: '60 00 N',
|
||||
dms_long: '25 00 E',
|
||||
dec_lat: '60.5',
|
||||
dec_long: '25.25',
|
||||
max_age: '12.5',
|
||||
min_age: '10.2',
|
||||
altitude: null,
|
||||
appr_num_spm: null,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
lid: 123,
|
||||
loc_name: 'Test locality',
|
||||
country: 'Finland',
|
||||
dec_lat: 60.5,
|
||||
dec_long: 25.25,
|
||||
max_age: 12.5,
|
||||
min_age: 10.2,
|
||||
altitude: 0,
|
||||
appr_num_spm: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('deduplicates localities and skips rows without valid coordinates', () => {
|
||||
const rows = [
|
||||
speciesLocality({ lid: 1, now_loc: { lid: 1, loc_name: 'A', dec_lat: 1, dec_long: 2 } }),
|
||||
speciesLocality({ lid: 1, now_loc: { lid: 1, loc_name: 'A duplicate', dec_lat: 1, dec_long: 2 } }),
|
||||
speciesLocality({ lid: 2, now_loc: { lid: 2, loc_name: 'B', dec_lat: null, dec_long: 2 } }),
|
||||
speciesLocality({ lid: 3, now_loc: { lid: 3, loc_name: 'C', dec_lat: 3, dec_long: 4 } }),
|
||||
]
|
||||
|
||||
expect(
|
||||
getUniqueSpeciesLocalityMapExportLocalities(
|
||||
{ species_id: 100, genus_name: 'Panthera', species_name: 'leo' },
|
||||
rows
|
||||
).map(locality => locality.lid)
|
||||
).toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('uses the parent species name for species locality exports', () => {
|
||||
const rows = [
|
||||
speciesLocality({
|
||||
species_id: 100,
|
||||
lid: 1,
|
||||
now_loc: { lid: 1, loc_name: 'A', dec_lat: 1, dec_long: 2 },
|
||||
}),
|
||||
speciesLocality({
|
||||
species_id: 100,
|
||||
lid: 1,
|
||||
now_loc: { lid: 1, loc_name: 'A', dec_lat: 1, dec_long: 2 },
|
||||
}),
|
||||
]
|
||||
|
||||
expect(
|
||||
getUniqueSpeciesLocalityMapExportLocalities(
|
||||
{ species_id: 100, genus_name: 'Panthera', species_name: 'leo', unique_identifier: '-' },
|
||||
rows
|
||||
)[0]?.species
|
||||
).toEqual(['Panthera leo'])
|
||||
})
|
||||
|
||||
it('adds species names from occurrence rows for a locality tab export', () => {
|
||||
const result = getUniqueLocalityOccurrenceMapExportLocalities(
|
||||
locality({ lid: 1, loc_name: 'A', dec_lat: 1, dec_long: 2 }),
|
||||
[
|
||||
localitySpecies({
|
||||
com_species: { species_id: 100, genus_name: 'Ursus', species_name: 'arctos', unique_identifier: '-' },
|
||||
}),
|
||||
localitySpecies({
|
||||
com_species: { species_id: 101, genus_name: 'Vulpes', species_name: 'vulpes', unique_identifier: null },
|
||||
}),
|
||||
]
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.species).toEqual(['Ursus arctos', 'Vulpes vulpes'])
|
||||
})
|
||||
|
||||
it('groups cross-search occurrence rows by locality with species names', () => {
|
||||
const result = getUniqueCrossSearchMapExportLocalities([
|
||||
crossSearch({
|
||||
lid_now_loc: 1,
|
||||
loc_name: 'A',
|
||||
country: 'Finland',
|
||||
dec_lat: 1,
|
||||
dec_long: 2,
|
||||
species_id_com_species: 100,
|
||||
genus_name: 'Ursus',
|
||||
species_name: 'arctos',
|
||||
unique_identifier: '-',
|
||||
}),
|
||||
crossSearch({
|
||||
lid_now_loc: 1,
|
||||
loc_name: 'A',
|
||||
country: 'Finland',
|
||||
dec_lat: 1,
|
||||
dec_long: 2,
|
||||
species_id_com_species: 101,
|
||||
genus_name: 'Vulpes',
|
||||
species_name: 'vulpes',
|
||||
unique_identifier: null,
|
||||
}),
|
||||
crossSearch({
|
||||
lid_now_loc: 2,
|
||||
loc_name: 'B',
|
||||
country: 'Sweden',
|
||||
dec_lat: null,
|
||||
dec_long: 2,
|
||||
species_id_com_species: 102,
|
||||
genus_name: 'Alces',
|
||||
species_name: 'alces',
|
||||
}),
|
||||
])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({ lid: 1, loc_name: 'A', country: 'Finland' })
|
||||
expect(result[0]?.species).toEqual(['Ursus arctos', 'Vulpes vulpes'])
|
||||
})
|
||||
|
||||
it('writes species lists into KML descriptions when provided', () => {
|
||||
const exportLocality = toMapExportLocality({
|
||||
lid: 1,
|
||||
loc_name: 'A',
|
||||
country: 'Finland',
|
||||
dms_lat: '60 00 N',
|
||||
dms_long: '25 00 E',
|
||||
dec_lat: 1,
|
||||
dec_long: 2,
|
||||
species: ['Ursus arctos', 'Vulpes vulpes'],
|
||||
})
|
||||
|
||||
expect(exportLocality).not.toBeNull()
|
||||
if (!exportLocality) return
|
||||
|
||||
const kml = generateKml([exportLocality])
|
||||
|
||||
expect(kml).toContain('<b>Species:</b>')
|
||||
expect(kml).toContain('<li>Ursus arctos</li>')
|
||||
expect(kml).toContain('<li>Vulpes vulpes</li>')
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Locality } from '@/shared/types'
|
||||
|
||||
type KmlLocality = Locality & {
|
||||
species?: string[]
|
||||
}
|
||||
|
||||
// Replaces special characters with html entities.
|
||||
const htmlEncode = (str: string) => {
|
||||
const el = document.createElement('div')
|
||||
el.innerText = str
|
||||
el.textContent = str
|
||||
return el.innerHTML
|
||||
}
|
||||
|
||||
@@ -26,8 +30,15 @@ const tableRow = (title: string, value: string, indentLevel: number) => {
|
||||
return tableRowString
|
||||
}
|
||||
|
||||
const speciesTableRow = (species: string[], indentLevel: number) => {
|
||||
if (species.length === 0) return ''
|
||||
|
||||
const speciesListItems = species.map(speciesName => `<li>${htmlEncode(speciesName)}</li>`).join('')
|
||||
return tableRow('Species:', `<ul>${speciesListItems}</ul>`, indentLevel)
|
||||
}
|
||||
|
||||
// Locality info table that shows up when clicking map marker in Google Earth for example.
|
||||
const localityInfoTable = (locality: Locality, indentLevel: number = 3) => {
|
||||
const localityInfoTable = (locality: KmlLocality, indentLevel: number = 3) => {
|
||||
const indent = '\t'.repeat(indentLevel)
|
||||
|
||||
let tableString = `${indent}<![CDATA[\n`
|
||||
@@ -41,6 +52,7 @@ const localityInfoTable = (locality: Locality, indentLevel: number = 3) => {
|
||||
`${locality.max_age} Ma (${locality.bfa_max}) - ${locality.min_age} Ma (${locality.bfa_min})`,
|
||||
indentLevel + 1
|
||||
)
|
||||
tableString += speciesTableRow(locality.species ?? [], indentLevel + 1)
|
||||
|
||||
tableString += `${indent}</table>\n`
|
||||
tableString += `${indent}]]>\n`
|
||||
@@ -48,7 +60,7 @@ const localityInfoTable = (locality: Locality, indentLevel: number = 3) => {
|
||||
return tableString
|
||||
}
|
||||
|
||||
const generatePlacemarkKml = (locality: Locality) => {
|
||||
const generatePlacemarkKml = (locality: KmlLocality) => {
|
||||
let kmlString = `\t<Placemark>\n`
|
||||
kmlString += `\t\t<name>${htmlEncode(locality.loc_name)}</name>\n`
|
||||
|
||||
@@ -66,7 +78,7 @@ const generatePlacemarkKml = (locality: Locality) => {
|
||||
}
|
||||
|
||||
// Returns an XML / KML string containing locality information as map markers, openable in Google Earth for example.
|
||||
export const generateKml = (localities: Locality[]) => {
|
||||
export const generateKml = (localities: KmlLocality[]) => {
|
||||
let kmlString = `<?xml version="1.0" encoding="UTF-8"?>\n`
|
||||
kmlString +=
|
||||
`<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2" ` +
|
||||
|
||||
Reference in New Issue
Block a user