diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 198d4c5042..fa022bf8b4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,14 +1,19 @@ ## Purpose + Describe the problem or feature in addition to a link to the issue(s), including context where needed. ## Approach + How does this change address the problem? ## Testing + What test(s) did you write to validate and verify your changes? ## Checklist + - [ ] My PR is scoped properly, and “does one thing only” - [ ] I have reviewed my own code - [ ] I have checked that linting checks pass and type safety is respected - [ ] I have checked that tests pass and coverage has at least improved, and if not explained the reasons why +- [ ] If needed, the changes have been previewed (eg on wwwdev) by all interested parties. diff --git a/src/shared/utils/__tests__/utils.spec.ts b/src/shared/utils/__tests__/utils.spec.ts index 3f3ea57a45..838ab50e4d 100644 --- a/src/shared/utils/__tests__/utils.spec.ts +++ b/src/shared/utils/__tests__/utils.spec.ts @@ -8,6 +8,8 @@ import { deepFindAllByKey, addBlastLinksToFreeText, keysToLowerCase, + counter, + defaultdict, } from '../utils'; describe('Model Utils', () => { @@ -119,3 +121,37 @@ describe('keysToLowerCase', () => { expect(keysToLowerCase(undefined)).toEqual({}); }); }); + +describe('counter', () => { + it('should return default value if not present', () => { + const dd = counter(); + expect(dd.foo).toEqual(0); + }); + it('should increment value correctly', () => { + const dd = counter(); + dd.foo += 1; + expect(dd.foo).toEqual(1); + }); + it('should return current value when assigned', () => { + const dd = counter(); + dd.foo = 100; + expect(dd.foo).toEqual(100); + }); + it('should use initial count value', () => { + const dd = counter(100); + dd.foo += 1; + dd.bar += 2; + expect(dd).toMatchObject({ foo: 101, bar: 102 }); + }); +}); + +describe('defaultdict', () => { + it('should handle arrays', () => { + const dd = defaultdict(() => []); + dd.foo.push(100); + dd.bar.push(200); + dd.foo.push(300); + dd.baz.push(500); + expect(dd).toMatchObject({ foo: [100, 300], bar: [200], baz: [500] }); + }); +}); diff --git a/src/shared/utils/utils.tsx b/src/shared/utils/utils.tsx index 2006b287f3..f4ccf8e527 100644 --- a/src/shared/utils/utils.tsx +++ b/src/shared/utils/utils.tsx @@ -108,3 +108,20 @@ export function keysToLowerCase(o: { [k: string]: T } = {}): { Object.entries(o).map(([k, v]) => [k.toLowerCase(), v]) ); } + +export function defaultdict(defaultFactory: () => T) { + return new Proxy>( + {}, + { + get: (dict, key: string | symbol) => { + if (!(key in dict)) { + // eslint-disable-next-line no-param-reassign + dict[key] = defaultFactory(); + } + return dict[key]; + }, + } + ); +} + +export const counter = (initialCount = 0) => defaultdict(() => initialCount); diff --git a/src/uniprotkb/components/statistics/HistoricalReleasesEntries.tsx b/src/uniprotkb/components/statistics/HistoricalReleasesEntries.tsx new file mode 100644 index 0000000000..903538cba6 --- /dev/null +++ b/src/uniprotkb/components/statistics/HistoricalReleasesEntries.tsx @@ -0,0 +1,143 @@ +import { Card, Loader } from 'franklin-sites'; +import LazyComponent from '../../../shared/components/LazyComponent'; + +import ErrorHandler from '../../../shared/components/error-pages/ErrorHandler'; +import UniProtKBStatsTabs from './UniProtKBStatsTabs'; +import HistoricalReleasesEntriesLinePlot, { + Bounds, + DateCount, +} from './HistoricalReleasesEntriesLinePlot'; + +import useDataApi from '../../../shared/hooks/useDataApi'; + +import { counter } from '../../../shared/utils/utils'; + +import apiUrls from '../../config/apiUrls/apiUrls'; + +import styles from './styles/statistics-page.module.scss'; + +type DateToCount = Record; + +type Proccessed = { + UNIPROTKB: DateToCount; + REVIEWED: DateToCount; + UNREVIEWED: DateToCount; +}; +type StatisticsType = 'UNIPROTKB' | 'REVIEWED' | 'UNREVIEWED'; +type StatisticsTypeToDateCount = Record; +type StatisticsTypeToBounds = Record; + +const sortByDate = (dateToCount: DateToCount) => + Array.from(Object.entries(dateToCount)) + .map(([date, count]): DateCount => [new Date(date), count]) + .sort(([a], [b]) => a.getTime() - b.getTime()); + +const processResults = (results: Results): StatisticsTypeToDateCount => { + const processed: Proccessed = { + UNIPROTKB: counter(), + REVIEWED: counter(), + UNREVIEWED: counter(), + }; + for (const { entryCount, releaseDate, statisticsType } of results) { + processed[statisticsType][releaseDate] += entryCount; + processed.UNIPROTKB[releaseDate] += entryCount; + } + return { + UNIPROTKB: sortByDate(processed.UNIPROTKB), + REVIEWED: sortByDate(processed.REVIEWED), + UNREVIEWED: sortByDate(processed.UNREVIEWED), + }; +}; + +const getBounds = ( + processed: StatisticsTypeToDateCount +): StatisticsTypeToBounds => { + const f = (dateCounts: DateCount[]): Bounds => ({ + date: [dateCounts[0][0], dateCounts[dateCounts.length - 1][0]], + count: [0, Math.max(...dateCounts.map(([, count]) => count))], + }); + return { + UNIPROTKB: f(processed.UNIPROTKB), + REVIEWED: f(processed.REVIEWED), + UNREVIEWED: f(processed.UNREVIEWED), + }; +}; + +type Results = { + statisticsType: 'REVIEWED' | 'UNREVIEWED'; + releaseName: string; + releaseDate: string; + valueCount: number; + entryCount: number; +}[]; + +type HistoricalReleasesEntriesPayload = { + results: Results; +}; + +const HistoricalReleaseEntryCounts = () => { + const { data, error, loading, status } = + useDataApi(apiUrls.statistics.history); + if (loading) { + return ; + } + + if (error || !data) { + return ; + } + + const processed = processResults(data.results); + const bounds = getBounds(processed); + + return ( + +

Total number of entries per release over time

+ +
+
+ } + rootMargin="0px 0px" + > + + +
+
+
+
+ } + rootMargin="0px 0px" + > + + +
+
+
+
+ } + rootMargin="0px 0px" + > + + +
+
+
+
+ ); +}; + +export default HistoricalReleaseEntryCounts; diff --git a/src/uniprotkb/components/statistics/HistoricalReleasesEntriesLinePlot.tsx b/src/uniprotkb/components/statistics/HistoricalReleasesEntriesLinePlot.tsx new file mode 100644 index 0000000000..00850e5a39 --- /dev/null +++ b/src/uniprotkb/components/statistics/HistoricalReleasesEntriesLinePlot.tsx @@ -0,0 +1,121 @@ +import { memo, useCallback, useEffect, useRef } from 'react'; +import { + select, + scaleLinear, + axisBottom, + axisLeft, + line, + scaleTime, + format, + timeFormat, + timeYear, +} from 'd3'; + +import styles from './styles/historical-release-entries-line-plot.module.scss'; + +// Specify the chart’s dimensions. +const width = 400; +const height = 400; +const margin = { top: 20, right: 60, bottom: 45, left: 80 }; + +export type DateCount = [Date, number]; +export type Bounds = { + date: [Date, Date]; + count: [number, number]; +}; + +type Props = { + dateCounts?: DateCount[]; + bounds?: Bounds; +}; + +const START_DATE = new Date(1995, 8, 1); + +const HistoricalReleasesEntriesLinePlot = ({ dateCounts, bounds }: Props) => { + const svgRef = useRef(null); + + const renderPlot = useCallback((dateCounts: DateCount[], bounds: Bounds) => { + const chart = select(svgRef.current).select('g'); + + const endYear = new Date(bounds.date[1].getFullYear() + 2, 1, 1); + + // x-axis + const xScale = scaleTime() + .domain([START_DATE, endYear]) // units: Date + .range([0, width]); // units: pixels + chart + .select('.x-axis') + .transition() + .duration(1_000) + .call( + axisBottom(xScale) + .ticks(timeYear.every(4)) + .tickFormat((d: Date | { valueOf(): number }) => + timeFormat('%Y')(new Date(d.valueOf())) + ) + ); + + // y-axis + const yScale = scaleLinear() + .domain(bounds.count) // units: count + .range([height, 0]) // units: pixels + .nice(); + + chart + .select('.y-axis') + .transition() + .duration(1_000) + .call(axisLeft(yScale).tickFormat(format('.2s'))); + + chart + .select(`.${styles.line}`) + .datum(dateCounts) + .transition() + .duration(1_000) + .attr('opacity', 1) + .attr( + 'd', + line() + .x((d) => xScale(d[0]) || 0) + .y((d) => yScale(d[1]) || 0) + ); + }, []); + + useEffect(() => { + if (svgRef.current && dateCounts && bounds) { + renderPlot(dateCounts, bounds); + } + }, [bounds, dateCounts, renderPlot]); + + return ( + + + + + Release date + + + + Number of entries + + + + + ); +}; + +export default memo(HistoricalReleasesEntriesLinePlot); diff --git a/src/uniprotkb/components/statistics/SequenceLengthLinePlot.tsx b/src/uniprotkb/components/statistics/SequenceLengthLinePlot.tsx index 785295dfdc..ec25e1bad4 100644 --- a/src/uniprotkb/components/statistics/SequenceLengthLinePlot.tsx +++ b/src/uniprotkb/components/statistics/SequenceLengthLinePlot.tsx @@ -78,7 +78,6 @@ const SequenceLengthLinePlot = ({ counts }: Props) => { .x((_, index) => xScale(index) || 0) .y((d) => yScale(d) || 0) ); - // Keeping an empty comment here otherwise somehow prettier messes things }, []); useEffect(() => { diff --git a/src/uniprotkb/components/statistics/StatisticsPage.tsx b/src/uniprotkb/components/statistics/StatisticsPage.tsx index dd37cada50..440f8e102f 100644 --- a/src/uniprotkb/components/statistics/StatisticsPage.tsx +++ b/src/uniprotkb/components/statistics/StatisticsPage.tsx @@ -22,6 +22,7 @@ import UniqueReferencesTable from './UniqueReferencesTable'; import AminoAcidCompositionTable from './AminoAcidCompositionTable'; import { ReviewedLabel, UnreviewedLabel } from './UniProtKBLabels'; import InPageNav from '../../../shared/components/InPageNav'; +import HistoricalReleaseEntryCounts from './HistoricalReleasesEntries'; import useUniProtDataVersion from '../../../shared/hooks/useUniProtDataVersion'; import useDataApi from '../../../shared/hooks/useDataApi'; @@ -647,12 +648,14 @@ const StatisticsPage = () => { query, a link has been provided. In some instances, due to the nature of the statistic, no query link is possible.

+ +