Commit 4096bf95 authored by Maksym Tishchenko's avatar Maksym Tishchenko
Browse files

Accession overview

-created overview page with cards that show overview data with tree map component.
-added translations for overview page.
-updated Property component to apply custom styles to value field or render value as a number.
parent 5b7c989a
......@@ -19,6 +19,9 @@
<li class="nav-item">
<a class="nav-link active" href="#/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#/overview">Overview</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#/api-info">API Info</a>
</li>
......
import * as React from 'react';
import { d3, DOM } from 'd3js';
interface IProps {
nullLabel?: string;
style: object;
data: any;
}
/**
* TreeMap based on https://observablehq.com/@d3/zoomable-treemap
*/
export const TreeMap = ({ nullLabel = 'NULL', style, data }: IProps) => {
const chartRef = React.useRef(null);
if (typeof window !== 'undefined' && d3) {
const format = d3.format(',d');
const name = (d) => d.data.term || d.data.name;
const nameWithPath = (d) => d.ancestors().reverse().map(name).join(' / ');
// useEffect
React.useEffect(() => {
// console.log('React effect!', chartRef, data);
if (! data || ! chartRef.current) {
return;
}
const headerHeight = 60;
const width = chartRef.current.parentNode.getBoundingClientRect().width;
let height = chartRef.current.parentNode.getBoundingClientRect().height;
const svg = d3.select(chartRef.current)
.attr('height', height)
.attr('width', width)
height -= headerHeight;
const color = d3.scaleOrdinal(d3.quantize(d3.interpolateSpectral, data.children.length + 1));
const tile = (node, x0, y0, x1, y1) => {
d3.treemapBinary(node, 0, 0, width, height);
for (const child of node.children) {
child.x0 = x0 + child.x0 / width * (x1 - x0);
child.x1 = x0 + child.x1 / width * (x1 - x0);
child.y0 = y0 + child.y0 / height * (y1 - y0);
child.y1 = y0 + child.y1 / height * (y1 - y0);
}
}
const treemap = (data) => d3.treemap().tile(tile)(d3.hierarchy(data).sum((d) => d.count).sort((a, b) => b.count - a.count));
const x = d3.scaleLinear().rangeRound([0, width]);
const y = d3.scaleLinear().rangeRound([0, height]);
svg
.attr('viewBox', [0, -headerHeight - 0, width, height + headerHeight])
.style('font', '14px sans-serif');
const old = svg.selectAll('g');
// console.log('SVG', svg, old);
if (old) {
// console.log('Removing', old);
old.remove();
}
let group = svg.append('g')
.call(render, treemap(data));
group.exit().remove();
function render(group, root) {
const node = group
.selectAll('g')
.data(root.children.concat(root))
.join('g');
node.filter((d) => d === root ? d.parent : d.children)
.attr('cursor', 'pointer')
.on('click', (d) => d === root ? zoomout(root) : zoomin(d));
node.append('title')
.text((d) => `${nameWithPath(d)}\n${format(d.value)}`);
node.append('rect')
.attr('id', (d) => (d.leafUid = DOM.uid('leaf')).id)
.attr('fill', (d) => d === root ? '#fff' : d.children ? '#ccc' : color(d.data.term))
.attr('stroke', '#fff');
node.append('clipPath')
.attr('id', (d) => (d.clipUid = DOM.uid('clip')).id)
.append('use')
.attr('xlink:href', (d) => d.leafUid.href);
node.append('text')
.attr('clip-path', (d) => d.clipUid)
.attr('font-weight', (d) => d === root ? 'bold' : null)
.selectAll('tspan')
.data((d) => [ (d === root ? nameWithPath(d) : name(d)) ].concat(format(d.value)))
.join('tspan')
.attr('x', 3)
.attr('y', (d, i, nodes) => `${(i === nodes.length - 1 ? 1 : 0) * 0.3 + 1.1 + i * 0.9}em`)
.attr('fill-opacity', (d, i, nodes) => i === nodes.length - 1 ? 0.7 : null)
.attr('font-weight', (d, i, nodes) => i === nodes.length - 1 ? 'normal' : null)
.text((d) => d);
group.call(position, root);
}
function position(group, root) {
group.selectAll('g')
.attr('transform', (d) => d === root ? 'translate(0,-60)' : `translate(${x(d.x0)},${y(d.y0)})`)
.select('rect')
.attr('width', (d) => d === root ? width : x(d.x1) - x(d.x0))
.attr('height', (d) => d === root ? 60 : y(d.y1) - y(d.y0));
}
// When zooming in, draw the new nodes on top, and fade them in.
function zoomin(d) {
const group0 = group.attr('pointer-events', 'none');
const group1 = group = svg.append('g').call(render, d);
x.domain([d.x0, d.x1]);
y.domain([d.y0, d.y1]);
svg.transition()
.duration(750)
.call((t) => group0.transition(t).remove()
.call(position, d.parent))
.call((t) => group1.transition(t)
.attrTween('opacity', () => d3.interpolate(0, 1))
.call(position, d));
}
// When zooming out, draw the old nodes on top, and fade them out.
function zoomout(d) {
const group0 = group.attr('pointer-events', 'none');
const group1 = group = svg.insert('g', '*').call(render, d.parent);
x.domain([d.parent.x0, d.parent.x1]);
y.domain([d.parent.y0, d.parent.y1]);
svg.transition()
.duration(750)
.call((t) => group0.transition(t).remove()
.attrTween('opacity', () => d3.interpolate(1, 0))
.call(position, d))
.call((t) => group1.transition(t)
.call(position, d.parent));
}
}, [ data ]);
}
console.log('Rendering TreeMap', style, chartRef);
return (
<svg style={ style } ref={ chartRef }></svg>
);
};
import { TreeMap } from './TreeMap';
let d3 = null;
if (typeof window !== 'undefined') {
d3 = require('d3');
}
let defLoc = '';
if (typeof document !== 'undefined' && document.location) {
defLoc = document.location.hash
? document.location.href.substring(0, document.location.href.length - document.location.hash.length)
: document.location.href;
}
/**
* Reimplementation of DOM.uid used by Observable.
*/
class UID {
private static location = defLoc;
public id: string;
public href: URL;
public constructor(id: string) {
this.id = id;
this.href = new URL(`#${id}`, UID.location);
}
public toString() {
return 'url(' + this.href + ')';
}
};
/**
* Implementation of DOM class used by Observable.
*/
class DOM {
private static prefix = `${new Date().getTime()}`;
private static counter = {};
public static uid = (scope: string) => {
const id = DOM.counter.hasOwnProperty(scope) ? DOM.counter[scope] = DOM.counter[scope] + 1 : DOM.counter[scope] = 1;
return new UID(`O-${DOM.prefix}-${scope}-${id}`);
}
};
export { d3, DOM, TreeMap };
......@@ -27,6 +27,33 @@
"storageType": "Type of germplasm storage",
"datasets": "Datasets",
"subsets": "Subsets",
"overview": {
"title": "Accession overview",
"subTitle": "Summary information about selected accessions",
"about": "About",
"total": "Total",
"Other": "Other",
"Not specified": "Not specified",
"NULL": "Data not recorded",
"institute code": "Holding Institute",
"institute country code3": "Country of holding institute",
"crop shortName": "Crop",
"cropName": "Crop name",
"sampStat": "Biological status of accession",
"taxonomy genus": "Genus name provided to Genesys",
"taxonomy genusSpecies": "Species name provided to Genesys",
"taxonomy grinTaxonomySpecies": "Matched GRIN taxon",
"taxonomy currentTaxonomySpecies": "Current GRIN taxon",
"countryOfOrigin code3": "Provenance of material",
"donorCode": "FAO WIEWS code of donor institute",
"mlsStatus": "ITGPRFA Multi-lateral system",
"available": "Available for distribution",
"duplSite": "Site of safety duplication",
"breederCode": "Breeder code",
"sgsv": "Safety duplicated in Svalbard",
"storage": "Type of Germplasm storage",
"aegis": "AEGIS"
},
"coll": {
"collCode": "Collecting institute code",
"collNumb": "Collecting number",
......@@ -101,6 +128,32 @@
"61": "Roadside",
"62": "Field margin",
"99": "Other"
},
"mlsStatus": {
"true": "Accession is part of the Multi-lateral system of ITPGRFA",
"false": "Not declared in the Multi-lateral system of ITPGRFA",
"1": "Accession is part of the Multi-lateral system of ITPGRFA",
"0": "Not declared in the Multi-lateral system of ITPGRFA",
"null": "Status not provided"
},
"available": {
"0": "Not available for distribution",
"1": "Available for distribution",
"true": "Available for distribution",
"false": "Not available for distribution",
"null": "Availability not provided"
},
"sgsv": {
"true": "Backed up in SGSV",
"false": "Not in SGSV",
"1": "Backed up in SGSV",
"0": "Not in SGSV"
},
"aegisStatus": {
"true": "Accession is part of the European Collection",
"false": "Accession is not part of the European Collection",
"1": "Accession is part of the European Collection",
"0": "Accession is not part of the European Collection"
}
},
"cart": {
......
import * as React from 'react';
import {withTranslation, WithTranslation} from 'react-i18next';
// model
import AccessionOverview from '@genesys/client/model/accession/AccessionOverview';
import PropertiesCard from "ui/PropertiesCard";
import {VocabularyService} from "@genesys/client/service";
interface IAccessionOverviewPageState {
countryCodes: object;
}
interface IAccessionOverviewPageProps extends React.ClassAttributes<any> {
overview: AccessionOverview;
}
class AccessionOverviewSection extends React.Component<IAccessionOverviewPageProps & WithTranslation, IAccessionOverviewPageState> {
public constructor(props) {
super(props);
this.state = {
countryCodes: null,
}
}
private overviewKeys = ['institute.code', 'institute.country.code3', 'cropName', 'crop.shortName', 'sampStat', 'taxonomy.genus', 'taxonomy.genusSpecies',
'taxonomy.grinTaxonomySpecies.name', 'taxonomy.currentTaxonomySpecies.name',
'countryOfOrigin.code3', 'donorCode', 'mlsStatus', 'available', 'duplSite', 'sgsv', 'storage', 'breederCode', 'aegis'];
public async componentDidMount() {
const codes = await VocabularyService.decode3166Alpha3Terms(this.props.i18n.language);
this.setState({countryCodes: codes})
}
public render() {
const {overview, t} = this.props;
const {countryCodes} = this.state;
if (!overview) {
return null;
}
const overviewsTerms = new Map();
this.overviewKeys.forEach((key) => {
const overviewEl = overview[key];
const terms = overviewEl && overviewEl.terms ? [].concat(overviewEl.terms) : [];
if (overviewEl && overviewEl.other && overviewEl.other > 0) {
terms.push({term: 'accession.overview.Other', count: overviewEl.other});
}
if (overviewEl && overviewEl.missing && overviewEl.missing > 0) {
terms.push({term: 'accession.overview.Not specified', count: overviewEl.missing});
}
overviewsTerms.set(key, terms);
});
return (
<div className="row">
{overviewsTerms && overviewsTerms.get('institute.code') &&
<PropertiesCard
propertiesList={overviewsTerms.get('institute.code')}
title={t(`accession.overview.institute code`)}
propertyItemProps={{numeric: true}}
/>
}
{overviewsTerms && overviewsTerms.get('institute.country.code3') &&
<PropertiesCard
propertiesList={overviewsTerms.get('institute.country.code3')}
countyCodes={countryCodes}
title={t(`accession.overview.institute country code3`)}
propertyItemProps={{numeric: true}}
/>
}
{overviewsTerms && overviewsTerms.get('crop.shortName') &&
<PropertiesCard
propertiesList={overviewsTerms.get('crop.shortName')}
title={t(`accession.overview.crop shortName`)}
propertyItemProps={{numeric: true}}
/>
}
{overviewsTerms && overviewsTerms.get('cropName') &&
<PropertiesCard
propertiesList={overviewsTerms.get('cropName')}
title={t(`accession.overview.cropName`)}
propertyItemProps={{numeric: true}}
/>
}
{overviewsTerms && overviewsTerms.get('taxonomy.genus') &&
<PropertiesCard
propertiesList={overviewsTerms.get('taxonomy.genus')}
title={t(`accession.overview.taxonomy genus`)}
propertyItemProps={{numeric: true, titleStyle: {fontStyle: 'italic'}}}
/>
}
{overviewsTerms && overviewsTerms.get('taxonomy.genusSpecies') &&
<PropertiesCard
propertiesList={overviewsTerms.get('taxonomy.genusSpecies')}
title={t(`accession.overview.taxonomy genusSpecies`)}
propertyItemProps={{numeric: true, titleStyle: {fontStyle: 'italic'}}}
/>
}
{overviewsTerms && overviewsTerms.get('taxonomy.grinTaxonomySpecies.name') &&
<PropertiesCard
propertiesList={overviewsTerms.get('taxonomy.grinTaxonomySpecies.name')}
title={t(`accession.overview.taxonomy grinTaxonomySpecies`)}
propertyItemProps={{numeric: true, titleStyle: {fontStyle: 'italic'}}}
/>
}
{overviewsTerms && overviewsTerms.get('taxonomy.currentTaxonomySpecies.name') &&
<PropertiesCard
propertiesList={overviewsTerms.get('taxonomy.currentTaxonomySpecies.name')}
title={t(`accession.overview.taxonomy currentTaxonomySpecies`)}
propertyItemProps={{numeric: true, titleStyle: {fontStyle: 'italic'}}}
/>
}
{overviewsTerms && overviewsTerms.get('sampStat') &&
<PropertiesCard
propertiesList={overviewsTerms.get('sampStat')}
translationGroup="sampleStatus"
title={t(`accession.overview.sampStat`)}
propertyItemProps={{numeric: true}}
/>
}
{overviewsTerms && overviewsTerms.get('storage') &&
<PropertiesCard
propertiesList={overviewsTerms.get('storage')}
translationGroup="storage"
title={t(`accession.overview.storage`)}
propertyItemProps={{numeric: true}}
/>
}
{overviewsTerms && overviewsTerms.get('countryOfOrigin.code3') &&
<PropertiesCard
propertiesList={overviewsTerms.get('countryOfOrigin.code3')}
countyCodes={countryCodes}
title={t(`accession.overview.countryOfOrigin code3`)}
propertyItemProps={{numeric: true}}
/>
}
{overviewsTerms && overviewsTerms.get('donorCode') &&
<PropertiesCard
propertiesList={overviewsTerms.get('donorCode')}
title={t(`accession.overview.donorCode`)}
propertyItemProps={{numeric: true}}
/>
}
{overviewsTerms && overviewsTerms.get('duplSite') &&
<PropertiesCard
propertiesList={overviewsTerms.get('duplSite')}
title={t(`accession.overview.duplSite`)}
propertyItemProps={{numeric: true}}
/>
}
{overviewsTerms && overviewsTerms.get('breederCode') &&
<PropertiesCard
propertiesList={overviewsTerms.get('breederCode')}
title={t(`accession.overview.breederCode`)}
propertyItemProps={{numeric: true}}
/>
}
{overviewsTerms && overviewsTerms.get('mlsStatus') &&
<PropertiesCard
propertiesList={overviewsTerms.get('mlsStatus')}
translationGroup="mlsStatus"
title={t(`accession.overview.mlsStatus`)}
propertyItemProps={{numeric: true}}
/>
}
{overviewsTerms && overviewsTerms.get('aegis') &&
<PropertiesCard
propertiesList={overviewsTerms.get('aegis')}
translationGroup="aegisStatus"
title={t(`accession.overview.aegis`)}
propertyItemProps={{numeric: true}}
/>
}
{overviewsTerms && overviewsTerms.get('available') &&
<PropertiesCard
propertiesList={overviewsTerms.get('available')}
translationGroup="available"
title={t(`accession.overview.available`)}
propertyItemProps={{numeric: true}}
/>
}
{overviewsTerms && overviewsTerms.get('historic') &&
<PropertiesCard
propertiesList={overviewsTerms.get('historic')}
translationGroup="historic"
title={t(`accession.overview.historic`)}
propertyItemProps={{numeric: true}}
/>
}
{overviewsTerms && overviewsTerms.get('sgsv') &&
<PropertiesCard
propertiesList={overviewsTerms.get('sgsv')}
translationGroup="sgsv"
title={t(`accession.overview.sgsv`)}
propertyItemProps={{numeric: true}}
/>
}
</div>
);
}
}
export default withTranslation()(AccessionOverviewSection);
......@@ -6,6 +6,7 @@ import AccessionList from './AccessionListPage';
import AccessionDetails from './AccessionDetailsPage';
import { createHashHistory } from 'history';
import CartPage from './CartPage';
import OverviewPage from "ui/OverviewPage";
const hashHistory = createHashHistory({});
......@@ -27,6 +28,7 @@ export default class App extends React.Component<IAppProps, any> {
<Route path="/" exact render={ (props) => <AccessionList { ...props } filter={ this.props.filter }/> }/>
<Route path="/api-info" exact component={ ApiInfoPage }/>
<Route path="/cart/" exact component={ CartPage }/>
<Route path="/overview" exact component={ OverviewPage }/>
<Route component={ NotFound }/>
</Switch>
......
......@@ -14,6 +14,7 @@ class Navigation extends React.Component<INavigation, any> {
<nav>
<ul style={ { display: 'flex', listStyle: 'none' } }>
<li style={ { marginRight: '20px' } }><Link to="/">{t("nav.home")}</Link></li>
<li style={ { marginRight: '20px' } }><Link to="/overview">{t('nav.overview')}</Link></li>
<li style={ { marginRight: '20px' } }><Link to="/api-info">{t("nav.apiInfo")}</Link></li>
<li style={ { marginRight: '20px' } }><Link to="/cart/">{t('nav.cart')}</Link></li>
</ul>
......
import * as React from 'react';
import {WithTranslation, withTranslation} from "react-i18next";
interface INumber extends React.ClassAttributes<any>, WithTranslation {
value: number;
config?: Intl.NumberFormatOptions;
}
// Print a number
// TBD maybe use l10n instead?
class Number extends React.Component<INumber, any> {
public constructor(props) {
super(props);
}
public render() {
const {value, config = {}, i18n} = this.props
if (typeof value === 'number') {
return <span>{ value.toLocaleString(i18n.language, { maximumFractionDigits: 6, ...config }) }</span>;
} else {
return <span>&mdash;</span>;
}
}
}
export default React.memo(withTranslation()(Number));
import * as React from 'react';
import { WithTranslation, withTranslation } from 'react-i18next';
import { parse } from 'query-string';
// Models
import { IPageRequest } from '@genesys/client/model/FilteredPage';
import { AccessionService } from '@genesys/client/service';
// UI
import AccessionOverviewSection from './AccessionOverviewSection';
interface IOverviewPageProps extends React.ClassAttributes<any>, WithTranslation {
location: any;
}
class BrowsePage extends React.Component<IOverviewPageProps, any> {
public constructor(props: IOverviewPageProps, context: any) {
super(props, context);
this.state = {
overviewData: null,
}