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 (
+
+ );
+};
+
+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.
+
+