diff --git a/integration-tests/tests/pages/seqset.page.ts b/integration-tests/tests/pages/seqset.page.ts index f8f8e06d4f..9c915b015f 100644 --- a/integration-tests/tests/pages/seqset.page.ts +++ b/integration-tests/tests/pages/seqset.page.ts @@ -22,7 +22,7 @@ export class SeqSetPage { } getHeading(name: string) { - return this.page.getByRole('heading', { name }); + return this.page.getByRole('heading').filter({ hasText: name }); } async openCreateDialog() { @@ -35,7 +35,7 @@ export class SeqSetPage { async createSeqSet(input: SeqSetFormInput) { await this.openCreateDialog(); await this.fillSeqSetForm(input); - await this.submitSeqSetForm(input.name); + await this.submitSeqSetForm(); } async fillSeqSetForm(input: SeqSetFormInput) { @@ -58,18 +58,23 @@ export class SeqSetPage { } } - async submitSeqSetForm(expectedName: string) { + async submitSeqSetForm() { await this.page.getByRole('button', { name: 'Save' }).click(); - await expect(this.getHeading(expectedName)).toBeVisible(); } - async expectDetailLayout(name: string) { - await expect(this.getHeading(name)).toBeVisible(); + async expectDetailLayout(name: string, description: string) { + await this.expectAccessionMatchesUrl(); await expect(this.page.getByRole('button', { name: 'Export' })).toBeVisible(); await expect(this.page.getByRole('button', { name: 'Edit' })).toBeVisible(); await expect(this.page.getByRole('button', { name: 'Delete' })).toBeVisible(); - await expect(this.page.getByText('Created date')).toBeVisible(); + await expect(this.page.getByText('Name', { exact: true })).toBeVisible(); + await expect(this.page.getByText(name, { exact: true })).toBeVisible(); + await expect(this.page.getByText('Description', { exact: true })).toBeVisible(); + await expect(this.page.getByText(description, { exact: true })).toBeVisible(); await expect(this.page.getByText('Version', { exact: true })).toBeVisible(); + await expect(this.page.getByText('Created by', { exact: true })).toBeVisible(); + await expect(this.page.getByText('Created date', { exact: true })).toBeVisible(); + await expect(this.page.getByText('Size', { exact: true })).toBeVisible(); await expect(this.page.getByText('Accession', { exact: true })).toBeVisible(); } @@ -111,12 +116,19 @@ export class SeqSetPage { await this.page.waitForLoadState('networkidle'); } + async expectAccessionMatchesUrl() { + const url = this.page.url(); + const accession = url.split('/seqsets/')[1]; + await expect(this.getHeading(accession)).toBeVisible(); + } + async editSeqSetName(newName: string) { await this.page.getByRole('button', { name: 'Edit' }).click(); const nameField = this.page.locator('#seqSet-name'); await nameField.waitFor({ state: 'visible' }); await nameField.fill(newName); await this.page.getByRole('button', { name: 'Save' }).click(); - await expect(this.getHeading(newName)).toBeVisible(); + await this.expectAccessionMatchesUrl(); + await expect(this.page.getByText(newName, { exact: true })).toBeVisible(); } } diff --git a/integration-tests/tests/specs/features/seqsets.dependent.spec.ts b/integration-tests/tests/specs/features/seqsets.dependent.spec.ts index 21196207aa..f4a9b4c7bd 100644 --- a/integration-tests/tests/specs/features/seqsets.dependent.spec.ts +++ b/integration-tests/tests/specs/features/seqsets.dependent.spec.ts @@ -61,7 +61,7 @@ test.describe('SeqSet management', () => { backgroundAccessions: [backgroundAccession], }); - await seqSetPage.expectDetailLayout(seqSetName); + await seqSetPage.expectDetailLayout(seqSetName, seqSetDescription); const jsonDownload = await seqSetPage.exportSeqSet('json'); expect(jsonDownload.suggestedFilename()).toContain(seqSetName); diff --git a/website/.dockerignore b/website/.dockerignore index 46923f3cb4..0dbc2d9919 100644 --- a/website/.dockerignore +++ b/website/.dockerignore @@ -6,6 +6,6 @@ src/**/*.spec.ts* !package-lock.json !astro.config.mjs !tailwind.config.cjs -!colors.cjs +!colors.json !tsconfig.json !.env.docker diff --git a/website/colors.cjs b/website/colors.cjs deleted file mode 100644 index f1afb1d865..0000000000 --- a/website/colors.cjs +++ /dev/null @@ -1,18 +0,0 @@ -const mainTailwindColor = { - 50: '#f3f6fb', - 100: '#e4e9f5', - 200: '#cfdaee', - 300: '#aec1e2', - 400: '#88a1d2', - 500: '#6b84c6', - 600: '#586bb8', - 700: '#4d5ba8', - 800: '#3e467e', - 900: '#3a416e', - 950: '#272b44', - 1500: '#25396e', -}; - -module.exports = { - mainTailwindColor, -}; diff --git a/website/colors.json b/website/colors.json new file mode 100644 index 0000000000..26fad9f25e --- /dev/null +++ b/website/colors.json @@ -0,0 +1,16 @@ +{ + "mainTailwindColor": { + "50": "#f3f6fb", + "100": "#e4e9f5", + "200": "#cfdaee", + "300": "#aec1e2", + "400": "#88a1d2", + "500": "#6b84c6", + "600": "#586bb8", + "700": "#4d5ba8", + "800": "#3e467e", + "900": "#3a416e", + "950": "#272b44", + "1500": "#25396e" + } +} diff --git a/website/package-lock.json b/website/package-lock.json index 1c1d46b914..f78250b433 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -24,6 +24,7 @@ "axios": "^1.13.6", "change-case": "~5.3.0", "chart.js": "^4.5.1", + "chartjs-adapter-date-fns": "^3.0.0", "cookie": "^1.1.1", "fflate": "^0.8.2", "flowbite-react": "^0.10.2", @@ -1305,6 +1306,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1337,6 +1339,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1369,6 +1372,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2834,6 +2838,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz", "integrity": "sha512-i0GV1yJnm2n3Yq1qw6QrUrd/LI9bE8WEBOTtOkpCXHHdyN3TAGgqAK/DAT05z4fq2x04cARXt2pDmjWjL92iTQ==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2873,6 +2878,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2893,6 +2899,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2913,6 +2920,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2933,6 +2941,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2953,6 +2962,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2973,6 +2983,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2993,6 +3004,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3013,6 +3025,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3033,6 +3046,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3053,6 +3067,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3073,6 +3088,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3093,6 +3109,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3113,6 +3130,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3130,6 +3148,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, "license": "MIT", "optional": true }, @@ -6316,6 +6335,16 @@ "pnpm": ">=8" } }, + "node_modules/chartjs-adapter-date-fns": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz", + "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=2.8.0", + "date-fns": ">=2.0.0" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -6995,6 +7024,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, "license": "Apache-2.0", "optional": true, "bin": { diff --git a/website/package.json b/website/package.json index 937032bf6f..568f5b0748 100644 --- a/website/package.json +++ b/website/package.json @@ -34,6 +34,7 @@ "axios": "^1.13.6", "change-case": "~5.3.0", "chart.js": "^4.5.1", + "chartjs-adapter-date-fns": "^3.0.0", "cookie": "^1.1.1", "fflate": "^0.8.2", "flowbite-react": "^0.10.2", diff --git a/website/src/components/SeqSetCitations/CitationPlot.tsx b/website/src/components/SeqSetCitations/CitationPlot.tsx index dbfa6f2cd7..6d80cd1817 100644 --- a/website/src/components/SeqSetCitations/CitationPlot.tsx +++ b/website/src/components/SeqSetCitations/CitationPlot.tsx @@ -1,25 +1,15 @@ -import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js'; -import { type FC, useState, useEffect } from 'react'; -import { Bar } from 'react-chartjs-2'; +import React from 'react'; -import type { CitedByResult } from '../../types/seqSetCitation'; +import { type CitedByResult } from '../../types/seqSetCitation'; +import { BarPlot } from '../common/BarPlot'; type CitationPlotProps = { citedByData: CitedByResult; + description?: string; + barColor?: string; }; -export const CitationPlot: FC = ({ citedByData }) => { - const [isRegistered, setIsRegistered] = useState(false); - - useEffect(() => { - ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend); - setIsRegistered(true); - }, []); - - if (!isRegistered) { - return null; - } - +export const CitationPlot: React.FC = ({ citedByData, description, barColor }) => { const emptyCitedByData = { years: [2020, 2021, 2022, 2023, 2024], citations: [0, 0, 0, 0, 0], @@ -28,37 +18,30 @@ export const CitationPlot: FC = ({ citedByData }) => { const renderData = citedByData.years.length > 0 ? citedByData : emptyCitedByData; return ( - ); }; diff --git a/website/src/components/SeqSetCitations/SeqSetItem.tsx b/website/src/components/SeqSetCitations/SeqSetItem.tsx index 9f6cbefac8..a541ce3939 100644 --- a/website/src/components/SeqSetCitations/SeqSetItem.tsx +++ b/website/src/components/SeqSetCitations/SeqSetItem.tsx @@ -3,39 +3,81 @@ import { AxiosError } from 'axios'; import { type FC, useState } from 'react'; import { toast } from 'react-toastify'; +import { AuthorDetails } from './AuthorDetails.tsx'; import { CitationPlot } from './CitationPlot'; +import { DatePlot, CountriesPlot, UseTermsPlot } from './SeqSetPlots.tsx'; import { SeqSetRecordsTableWithMetadata } from './SeqSetRecordsTableWithMetadata'; +import type { AggregateRow } from './getSeqSetStatistics.ts'; import { getClientLogger } from '../../clientLogger'; import { seqSetCitationClientHooks } from '../../services/serviceHooks'; import type { ProblemDetail } from '../../types/backend.ts'; import type { ClientConfig } from '../../types/runtimeConfig'; -import { type CitedByResult, type SeqSet, type SeqSetRecord } from '../../types/seqSetCitation'; +import { type AuthorProfile, type CitedByResult, type SeqSet, type SeqSetRecord } from '../../types/seqSetCitation'; import { createAuthorizationHeader } from '../../utils/createAuthorizationHeader'; import { displayConfirmationDialog } from '../ConfirmationDialog.tsx'; import { withQueryProvider } from '../common/withQueryProvider.tsx'; const logger = getClientLogger('SeqSetItem'); +const SeqSetSection: FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => ( +
+
+

{title}

+
+ {children} +
+); + +const SeqSetSectionSeparator: FC = () =>
; + +const SeqSetDetails: FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => ( +
+
+

{title}

+
+ {children} +
+); + +const SeqSetDetailsEntry: FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => ( +
+
{label}
+
{value}
+
+); + type SeqSetItemProps = { clientConfig: ClientConfig; accessToken: string; + seqSetAccessionVersion: string; seqSet: SeqSet; + seqSetAuthor?: AuthorProfile; seqSetRecords: SeqSetRecord[]; citedByData: CitedByResult; + collectionDatesData: AggregateRow[]; + collectionCountriesData: AggregateRow[]; + dataUseTermsData: AggregateRow[]; isAdminView?: boolean; fieldsToDisplay?: { field: string; displayName: string }[]; organismDisplayNames?: Record; + barGraphColor?: string; }; const SeqSetItemInner: FC = ({ clientConfig, accessToken, + seqSetAccessionVersion, seqSet, + seqSetAuthor, seqSetRecords, citedByData, + collectionDatesData, + collectionCountriesData, + dataUseTermsData, isAdminView = false, fieldsToDisplay, organismDisplayNames, + barGraphColor, }) => { const [page, setPage] = useState(1); const sequencesPerPage = 10; @@ -97,54 +139,84 @@ const SeqSetItemInner: FC = ({ }; return ( -
-
-
-

Description

-

{seqSet.description ?? 'N/A'}

-
-
-

Version

-

{seqSet.seqSetVersion}

-
-
-

Created date

-

{formatDate(seqSet.createdAt)}

-
-
-

Size

-

{`${seqSetRecords.length} sequence${seqSetRecords.length === 1 ? '' : 's'}`}

-
-
-

DOI

- {renderDOI()} -
-
-

Total citations

- {seqSet.seqSetDOI === undefined || seqSet.seqSetDOI === null ? ( -

Cited by 0

- ) : ( - - Cited by 0 - - )} -
-
-

-
- -

- Number of times this SeqSet has been cited by a publication -

-
-
+
+
+ + + + + + ) : ( + 'Unknown' + ) + } + /> + + + + + + Cited by 0

+ ) : ( + + Cited by 0 + + ) + } + /> + + } + /> +
-
-

Sequences

+ + +
+ + + +
+
+ + = ({ }} /> ) : null} -
+
); }; diff --git a/website/src/components/SeqSetCitations/SeqSetItemActions.tsx b/website/src/components/SeqSetCitations/SeqSetItemActions.tsx index 6e82f02a48..9d8692afdb 100644 --- a/website/src/components/SeqSetCitations/SeqSetItemActions.tsx +++ b/website/src/components/SeqSetCitations/SeqSetItemActions.tsx @@ -8,6 +8,7 @@ import { seqSetCitationClientHooks } from '../../services/serviceHooks'; import type { ClientConfig } from '../../types/runtimeConfig'; import type { SeqSetRecord, SeqSet } from '../../types/seqSetCitation'; import { createAuthorizationHeader } from '../../utils/createAuthorizationHeader'; +import { getAccessionVersionString } from '../../utils/extractAccessionVersion.ts'; import { displayConfirmationDialog } from '../ConfirmationDialog.tsx'; import { Button } from '../common/Button'; import Modal from '../common/Modal'; @@ -35,6 +36,11 @@ const SeqSetItemActionsInner: FC = ({ isAdminView = false, databaseName, }) => { + const seqSetAccessionVersion = getAccessionVersionString({ + accession: seqSet.seqSetId, + version: seqSet.seqSetVersion, + }); + const [editModalVisible, setEditModalVisible] = useState(false); const [exportModalVisible, setExportModalVisible] = useState(false); @@ -51,16 +57,18 @@ const SeqSetItemActionsInner: FC = ({ }; return ( -
-

{seqSet.name}

-
+
+
+

{seqSetAccessionVersion}

+
+
{isAdminView ? ( ) : null} {isAdminView && (seqSet.seqSetDOI === null || seqSet.seqSetDOI === undefined) ? ( @@ -82,7 +90,7 @@ const SeqSetItemActionsInner: FC = ({ } > - Delete + Delete ) : null}
diff --git a/website/src/components/SeqSetCitations/SeqSetPlots.spec.tsx b/website/src/components/SeqSetCitations/SeqSetPlots.spec.tsx new file mode 100644 index 0000000000..ca22372885 --- /dev/null +++ b/website/src/components/SeqSetCitations/SeqSetPlots.spec.tsx @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; + +import { + getDateFormatFromData, + getGraphData, + getGraphTimeProperties, + groupByDateFormat, + groupRemainingPoints, +} from './SeqSetPlots'; + +describe('getGraphData', () => { + it('maps values to labels and counts to dataset data', () => { + const input = [ + { value: 'USA', count: 10 }, + { value: 'Germany', count: 5 }, + ]; + const result = getGraphData(input); + expect(result.labels).toEqual(['USA', 'Germany']); + expect(result.datasets[0].data).toEqual([10, 5]); + }); + + it('uses null value as "Unknown" label', () => { + const result = getGraphData([{ value: null, count: 3 }]); + expect(result.labels).toEqual(['Unknown']); + }); + + it('returns empty labels and data for empty input', () => { + const result = getGraphData([]); + expect(result.labels).toEqual([]); + expect(result.datasets[0].data).toEqual([]); + }); +}); + +describe('getDateFormatFromData', () => { + it('returns yyyy-MM-dd for dates within 60 days of each other', () => { + const data = [ + { value: '2024-01-01', count: 1 }, + { value: '2024-02-01', count: 1 }, + ]; + expect(getDateFormatFromData(data)).toBe('yyyy-MM-dd'); + }); + + it('returns yyyy-MM for dates between 60 and 365 days apart', () => { + const data = [ + { value: '2024-01-01', count: 1 }, + { value: '2024-06-01', count: 1 }, + ]; + expect(getDateFormatFromData(data)).toBe('yyyy-MM'); + }); + + it('returns yyyy for dates more than 365 days apart', () => { + const data = [ + { value: '2022-01-01', count: 1 }, + { value: '2024-01-01', count: 1 }, + ]; + expect(getDateFormatFromData(data)).toBe('yyyy'); + }); + + it('returns yyyy-MM-dd for a single date', () => { + expect(getDateFormatFromData([{ value: '2024-03-15', count: 1 }])).toBe('yyyy-MM-dd'); + }); + + it('ignores rows with non-string or invalid date values', () => { + const data = [ + { value: null, count: 1 }, + { value: 'not-a-date', count: 1 }, + { value: '2024-01-01', count: 1 }, + ]; + expect(getDateFormatFromData(data)).toBe('yyyy-MM-dd'); + }); +}); + +describe('groupByDateFormat', () => { + it('groups dates by month when format is yyyy-MM', () => { + const data = [ + { value: '2024-01-01', count: 3 }, + { value: '2024-01-15', count: 2 }, + { value: '2024-02-01', count: 5 }, + ]; + const result = groupByDateFormat(data, 'yyyy-MM'); + expect(result).toEqual([ + { value: '2024-01', count: 5 }, + { value: '2024-02', count: 5 }, + ]); + }); + + it('groups dates by year when format is yyyy', () => { + const data = [ + { value: '2022-06-01', count: 4 }, + { value: '2022-12-01', count: 6 }, + { value: '2023-03-01', count: 2 }, + ]; + const result = groupByDateFormat(data, 'yyyy'); + expect(result).toEqual([ + { value: '2022', count: 10 }, + { value: '2023', count: 2 }, + ]); + }); + + it('skips rows with non-string or invalid date values', () => { + const data = [ + { value: null, count: 99 }, + { value: 'not-a-date', count: 99 }, + { value: '2024-03-01', count: 1 }, + ]; + const result = groupByDateFormat(data, 'yyyy-MM'); + expect(result).toEqual([{ value: '2024-03', count: 1 }]); + }); + + it('returns empty array for empty input', () => { + expect(groupByDateFormat([], 'yyyy-MM')).toEqual([]); + }); +}); + +describe('getGraphTimeProperties', () => { + it('returns day properties for yyyy-MM-dd', () => { + expect(getGraphTimeProperties('yyyy-MM-dd')).toEqual({ + unit: 'day', + displayFormats: { day: 'dd MMM yyyy' }, + tooltipFormat: 'yyyy-MM-dd', + }); + }); + + it('returns month properties for yyyy-MM', () => { + expect(getGraphTimeProperties('yyyy-MM')).toEqual({ + unit: 'month', + displayFormats: { month: 'MMM yyyy' }, + tooltipFormat: 'yyyy-MM', + }); + }); + + it('returns year properties for yyyy', () => { + expect(getGraphTimeProperties('yyyy')).toEqual({ + unit: 'year', + displayFormats: { year: 'yyyy' }, + tooltipFormat: 'yyyy', + }); + }); +}); + +describe('groupRemainingPoints', () => { + it('returns all rows unchanged when count is within cutoff', () => { + const data = [ + { value: 'USA', count: 10 }, + { value: 'Germany', count: 5 }, + ]; + expect(groupRemainingPoints(data, 10)).toEqual([ + { value: 'USA', count: 10 }, + { value: 'Germany', count: 5 }, + ]); + }); + + it('groups rows beyond cutoff into an Others entry', () => { + const data = [ + { value: 'USA', count: 10 }, + { value: 'Germany', count: 5 }, + { value: 'France', count: 3 }, + { value: 'Spain', count: 2 }, + ]; + const result = groupRemainingPoints(data, 2); + expect(result).toEqual([ + { value: 'USA', count: 10 }, + { value: 'Germany', count: 5 }, + { value: 'Others (2)', count: 5 }, + ]); + }); + + it('sorts by count descending before applying cutoff', () => { + const data = [ + { value: 'Spain', count: 2 }, + { value: 'USA', count: 10 }, + { value: 'Germany', count: 5 }, + ]; + const [first, second] = groupRemainingPoints(data, 2); + expect(first.value).toBe('USA'); + expect(second.value).toBe('Germany'); + }); + + it('omits Others entry when all rows fit within cutoff', () => { + const data = [ + { value: 'USA', count: 10 }, + { value: 'Germany', count: 5 }, + ]; + const result = groupRemainingPoints(data, 2); + expect(result.find((r) => String(r.value).startsWith('Others'))).toBeUndefined(); + }); + + it('returns empty array for empty input', () => { + expect(groupRemainingPoints([], 10)).toEqual([]); + }); +}); diff --git a/website/src/components/SeqSetCitations/SeqSetPlots.tsx b/website/src/components/SeqSetCitations/SeqSetPlots.tsx new file mode 100644 index 0000000000..75ce8ac888 --- /dev/null +++ b/website/src/components/SeqSetCitations/SeqSetPlots.tsx @@ -0,0 +1,114 @@ +import { DateTime } from 'luxon'; +import React from 'react'; + +import type { AggregateRow } from './getSeqSetStatistics'; +import { BarPlot } from '../common/BarPlot'; + +type SeqSetPlotProps = { + data: AggregateRow[]; + description?: string; + barColor?: string; +}; + +type GraphTimeProperties = { + unit: 'day' | 'month' | 'year'; + displayFormats: { day?: string; month?: string; year?: string }; + tooltipFormat: string; +}; + +/** Transform data into the format required by the graph component. */ +export const getGraphData = (data: AggregateRow[], barColor?: string) => ({ + labels: data.map((item) => item.value ?? 'Unknown'), + datasets: [{ data: data.map((item) => item.count), backgroundColor: barColor, maxBarThickness: 30 }], +}); + +/** Get the appropriate date format for the graph based on the range of dates in the data. */ +export const getDateFormatFromData = (data: AggregateRow[]): string => { + const dateValues = data + .map((row) => (typeof row.value === 'string' ? DateTime.fromISO(row.value) : null)) + .filter((date) => date !== null) + .filter((date) => date.isValid); + const minDate = DateTime.min(...dateValues); + const maxDate = DateTime.max(...dateValues); + const diff = minDate && maxDate ? maxDate.diff(minDate, 'days').days : 0; + + let format; + if (diff <= 60) format = 'yyyy-MM-dd'; + else if (diff <= 365) format = 'yyyy-MM'; + else format = 'yyyy'; + + return format; +}; + +/** Group the data by the specified date format */ +export const groupByDateFormat = (data: AggregateRow[], format: string): AggregateRow[] => { + const yearMonths = new Map(); + + data.forEach((row) => { + if (typeof row.value !== 'string') return; + + const dateValue = DateTime.fromISO(row.value); + if (!dateValue.isValid) return; + + const yearMonth = dateValue.toFormat(format); + yearMonths.set(yearMonth, (yearMonths.get(yearMonth) ?? 0) + row.count); + }); + + return Array.from(yearMonths.entries()).map(([value, count]) => ({ value, count })); +}; + +/** Get the corresponding graph properties for the specified date format. */ +export const getGraphTimeProperties = (format: string): GraphTimeProperties => { + if (format === 'yyyy-MM-dd') + return { unit: 'day', displayFormats: { day: 'dd MMM yyyy' }, tooltipFormat: 'yyyy-MM-dd' }; + + if (format === 'yyyy-MM') return { unit: 'month', displayFormats: { month: 'MMM yyyy' }, tooltipFormat: 'yyyy-MM' }; + + return { unit: 'year', displayFormats: { year: 'yyyy' }, tooltipFormat: 'yyyy' }; +}; + +/** Group the data by the specified cutoff, grouping remaining points into an "Others" category. */ +export const groupRemainingPoints = (data: AggregateRow[], cutoff: number): AggregateRow[] => { + const sortedData = data.sort((a, b) => b.count - a.count); + const topData = sortedData.slice(0, cutoff); + const otherData = sortedData.slice(cutoff); + const otherCount = otherData.reduce((sum, row) => sum + row.count, 0); + return otherCount > 0 ? [...topData, { value: `Others (${otherData.length})`, count: otherCount }] : topData; +}; + +export const DatePlot: React.FC = ({ data, description, barColor }) => { + const dateFormat = getDateFormatFromData(data); + const groupedData = groupByDateFormat(data, dateFormat); + const graphData = getGraphData(groupedData, barColor); + const graphTimeProperties = getGraphTimeProperties(dateFormat); + + return ( + + ); +}; + +export const CountriesPlot: React.FC = ({ data, description, barColor }) => { + const groupedData = groupRemainingPoints(data, 10); + const graphData = getGraphData(groupedData, barColor); + + return ; +}; + +export const UseTermsPlot: React.FC = ({ data, description, barColor }) => { + const graphData = getGraphData(data, barColor); + return ; +}; diff --git a/website/src/components/SeqSetCitations/SeqSetRecordsTableWithMetadata.tsx b/website/src/components/SeqSetCitations/SeqSetRecordsTableWithMetadata.tsx index 048958d289..5b9fc2bd64 100644 --- a/website/src/components/SeqSetCitations/SeqSetRecordsTableWithMetadata.tsx +++ b/website/src/components/SeqSetCitations/SeqSetRecordsTableWithMetadata.tsx @@ -116,6 +116,16 @@ const fetchRecordsMetadata = async ( return metadataMap; }; +const SeqSetRecordsTableHeader: FC<{ title: string }> = ({ title }) => ( + {title} +); + +const SeqSetRecordsTableCell: FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => ( + + {children} + +); + export const SeqSetRecordsTableWithMetadata: FC = ({ seqSetRecords, clientConfig, @@ -150,20 +160,18 @@ export const SeqSetRecordsTableWithMetadata: FC + - - - - + + + + {fieldsToDisplay.map((fieldConfig) => ( - + ))} - + {sortedSeqRecords.map((seqSetRecord, index) => { const metadata = metadataMap?.get(seqSetRecord.accession); const handleRowClick = () => { @@ -172,14 +180,13 @@ export const SeqSetRecordsTableWithMetadata: FC - - + {fieldsToDisplay.map((fieldConfig) => ( - ); diff --git a/website/src/components/SeqSetCitations/getSeqSetStatistics.ts b/website/src/components/SeqSetCitations/getSeqSetStatistics.ts new file mode 100644 index 0000000000..3b3ef24909 --- /dev/null +++ b/website/src/components/SeqSetCitations/getSeqSetStatistics.ts @@ -0,0 +1,59 @@ +import { ok } from 'neverthrow'; +import type { Result } from 'neverthrow'; + +import { getConfiguredOrganisms, getSchema } from '../../config.ts'; +import { LapisClient } from '../../services/lapisClient.ts'; +import { ACCESSION_VERSION_FIELD } from '../../settings.ts'; +import type { ProblemDetail } from '../../types/backend.ts'; + +type AggregateValue = string | number | boolean | null; +export type AggregateRow = { value: AggregateValue; count: number }; + +const getAggregate = async ( + client: LapisClient, + accessions: string[], + field: string, +): Promise> => { + const result = await client.call('aggregated', { + [ACCESSION_VERSION_FIELD]: accessions, + fields: [field], + }); + + return result.map(({ data }) => + data.map((item) => ({ + value: item[field], + count: item.count, + })), + ); +}; + +export const getSeqSetStatistics = async ( + accessions: string[], + fieldOptions: string[], +): Promise> => { + if (accessions.length === 0) { + return ok([]); + } + + const organisms = getConfiguredOrganisms(); + const aggregateResponses = await Promise.all( + organisms.map((organism) => { + const client = LapisClient.createForOrganism(organism.key); + const schema = getSchema(organism.key); + const field = fieldOptions.find((option) => schema.metadata.some((f) => f.name === option)); + if (!field) return Promise.resolve(ok([])); + return getAggregate(client, accessions, field); + }), + ); + + const crossAggregate = new Map(); + for (const aggregateResponse of aggregateResponses) { + if (aggregateResponse.isErr()) return aggregateResponse; + + for (const aggregateRow of aggregateResponse.value) { + crossAggregate.set(aggregateRow.value, (crossAggregate.get(aggregateRow.value) ?? 0) + aggregateRow.count); + } + } + + return ok(Array.from(crossAggregate.entries()).map(([value, count]) => ({ value, count }))); +}; diff --git a/website/src/components/common/BarPlot.tsx b/website/src/components/common/BarPlot.tsx new file mode 100644 index 0000000000..36ce4900a2 --- /dev/null +++ b/website/src/components/common/BarPlot.tsx @@ -0,0 +1,61 @@ +import 'chartjs-adapter-date-fns'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + TimeScale, + BarElement, + Tooltip, + Legend, + type ChartData, + type ChartOptions, +} from 'chart.js'; +import { type FC, useState, useEffect } from 'react'; +import { Bar } from 'react-chartjs-2'; + +type BarPlotProps = { + data: ChartData<'bar'>; + options?: ChartOptions<'bar'>; + description?: string; +}; + +export const BarPlot: FC = ({ data, options, description }) => { + const [isRegistered, setIsRegistered] = useState(false); + + useEffect(() => { + ChartJS.register(CategoryScale, LinearScale, TimeScale, BarElement, Tooltip, Legend); + setIsRegistered(true); + }, []); + + if (!isRegistered) + return ( +
+
+
+ ); + + return ( +
+ + {description &&

{description}

} +
+ ); +}; diff --git a/website/src/pages/seqsets/[seqSetId].[version].astro b/website/src/pages/seqsets/[seqSetId].[version].astro index 99064fca7f..610edba55b 100644 --- a/website/src/pages/seqsets/[seqSetId].[version].astro +++ b/website/src/pages/seqsets/[seqSetId].[version].astro @@ -1,11 +1,16 @@ --- +import { err, type Result } from 'neverthrow'; + +import { mainTailwindColor } from '../../../colors.json'; import { ErrorFeedback } from '../../components/ErrorFeedback'; -import { AuthorDetails } from '../../components/SeqSetCitations/AuthorDetails'; import { SeqSetItem } from '../../components/SeqSetCitations/SeqSetItem'; import { SeqSetItemActions } from '../../components/SeqSetCitations/SeqSetItemActions'; +import { getSeqSetStatistics } from '../../components/SeqSetCitations/getSeqSetStatistics.ts'; import { getRuntimeConfig, seqSetsAreEnabled, getWebsiteConfig } from '../../config'; import BaseLayout from '../../layouts/BaseLayout.astro'; import { SeqSetCitationClient } from '../../services/seqSetCitationClient.ts'; +import { DATA_USE_TERMS_FIELD } from '../../settings'; +import type { ProblemDetail } from '../../types/backend'; import type { SeqSet } from '../../types/seqSetCitation'; import { getAccessToken } from '../../utils/getAccessToken'; @@ -28,17 +33,9 @@ if (!seqSetsAreEnabled()) { const seqSetClient = SeqSetCitationClient.create(); -const seqSetResponse = await seqSetClient.call('getSeqSet', { - params: { seqSetId, version }, -}); - -const seqSetRecordsResponse = await seqSetClient.call('getSeqSetRecords', { - params: { seqSetId, version }, -}); - -const seqSetCitedByResponse = await seqSetClient.call('getSeqSetCitedBy', { - params: { seqSetId, version }, -}); +const seqSetVersionsResponse = await seqSetClient.call('getSeqSet', { params: { seqSetId, version } }); +const seqSetRecordsResponse = await seqSetClient.call('getSeqSetRecords', { params: { seqSetId, version } }); +const seqSetCitedByResponse = await seqSetClient.call('getSeqSetCitedBy', { params: { seqSetId, version } }); const getSeqSetByVersion = (seqSetVersions: SeqSet[], version: string) => { const matchedVersion = seqSetVersions.find((obj) => { @@ -50,98 +47,95 @@ const getSeqSetByVersion = (seqSetVersions: SeqSet[], version: string) => { return matchedVersion; }; -const seqSet = seqSetResponse.isOk() ? getSeqSetByVersion(seqSetResponse.value, version) : undefined; +const seqSetResponse = seqSetVersionsResponse.map((seqSetVersions) => getSeqSetByVersion(seqSetVersions, version)); +const seqSetAuthorResponse = seqSetResponse.isOk() + ? await seqSetClient.getAuthor(seqSetResponse.value.createdBy) + : err(); + +const seqSetAccessions = seqSetRecordsResponse.unwrapOr([]).map((record) => record.accession); +const seqSetCollectionDatesResponse = seqSetRecordsResponse.isOk() + ? await getSeqSetStatistics(seqSetAccessions, ['sampleCollectionDate', 'date']) + : err(); +const seqSetCollectionCountriesResponse = seqSetRecordsResponse.isOk() + ? await getSeqSetStatistics(seqSetAccessions, ['geoLocCountry', 'country']) + : err(); +const seqSetDataUseTermsResponse = seqSetRecordsResponse.isOk() + ? await getSeqSetStatistics(seqSetAccessions, [DATA_USE_TERMS_FIELD]) + : err(); + +const seqSetAuthor = seqSetAuthorResponse.unwrapOr(undefined); +const citedByData = seqSetCitedByResponse.unwrapOr({ years: [], citations: [] }); -const authorResponse = seqSet !== undefined ? await seqSetClient.getAuthor(seqSet.createdBy) : undefined; +const collectionDatesData = seqSetCollectionDatesResponse.unwrapOr([]); +const collectionCountriesData = seqSetCollectionCountriesResponse.unwrapOr([]); +const dataUseTermsData = seqSetDataUseTermsResponse.unwrapOr([]); + +const seqSetAccessionVersion = `${seqSetId}.${version}`; +const barGraphColor = mainTailwindColor[500]; + +const secondaryErrors = ( + [ + [seqSetAuthorResponse, 'Error while fetching author profile'], + [seqSetCitedByResponse, 'Error while fetching seqSet citations'], + [seqSetCollectionCountriesResponse, 'Error while fetching seqSet statistics'], + [seqSetCollectionDatesResponse, 'Error while fetching seqSet statistics'], + [seqSetDataUseTermsResponse, 'Error while fetching seqSet statistics'], + ] as [Result, string][] +).flatMap(([response, message]) => (response.isErr() ? [`${message}: ${JSON.stringify(response.error)}`] : [])); --- - -
- { - seqSet !== undefined ? ( -
-
- {authorResponse?.isOk() ? ( - - ) : ( - - )} -
-
- {seqSetRecordsResponse.isOk() ? ( - name !== null) - .join(' ') - : seqSet.createdBy, - }} - seqSetRecords={seqSetRecordsResponse.value} - isAdminView={seqSet.createdBy === username} - databaseName={websiteConfig.name} - client:only='react' - /> - ) : ( - - )} - {seqSetRecordsResponse.isOk() && seqSetCitedByResponse.isOk() ? ( - - ) : ( - - )} -
-
- ) : ( - + { + seqSetResponse.isOk() && seqSetRecordsResponse.isOk() ? ( +
+ {secondaryErrors.length > 0 && ( + + )} + - ) - } -
+ +
+ ) : ( + + ) + }
diff --git a/website/src/pages/seqsets/index.astro b/website/src/pages/seqsets/index.astro index 36d8ee4c21..69a9f2265b 100644 --- a/website/src/pages/seqsets/index.astro +++ b/website/src/pages/seqsets/index.astro @@ -96,15 +96,11 @@ const editAccountUrl = (await getUrlForKeycloakAccountPage()) + '/#/personal-inf
Cited by {/* We show an empty plot for now until we get real data. */} -
- -

- Number of times your sequences have been cited in publications -

-
+
) diff --git a/website/tailwind.config.cjs b/website/tailwind.config.cjs index f8b47c9e58..0103c8aa9d 100644 --- a/website/tailwind.config.cjs +++ b/website/tailwind.config.cjs @@ -1,10 +1,8 @@ /** @type {import('tailwindcss').Config} */ -const colors = require('./colors.cjs'); +const { mainTailwindColor } = require('./colors.json'); const flowbite = require('flowbite-react/tailwind'); -const mainTailwindColor = colors.mainTailwindColor; - module.exports = { content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}', flowbite.content()], theme: {
AccessionOrganismContext
- {fieldConfig.displayName} -
+ {seqSetRecord.accession} - + - + + {seqSetRecord.isFocal ? 'Focal' : 'Background'} - + ))}