Commit 3d02490e authored by Matija Obreza's avatar Matija Obreza
Browse files

Merge branch '192-map-click-accessions-list' into 'master'

Map click accessions list

Closes #192

See merge request genesys-pgr/genesys-ui!192
parents 800310cb 3eb4c959
......@@ -1476,7 +1476,7 @@
"NumericListDimension": "NumericListDimension",
"StringListDimension": "StringListDimension"
},
"showRun": "Show run info",
"showRun": "Show",
"executionRunsLoaded": "Execution runs loaded successfully"
}
}
......
......@@ -9,8 +9,9 @@ import AccessionFilter from 'model/accession/AccessionFilter';
import AccessionDetails from 'model/accession/AccessionDetails';
import AccessionMapInfo from 'model/accession/AccessionMapInfo';
import AccessionAuditLog from 'model/accession/AccessionAuditLog';
import MapLayer from 'model/genesys/MapTileLayer';
import { RECEIVE_ACCESSIONS, RECEIVE_ACCESSION, RECEIVE_ACCESSION_OVERVIEW, APPEND_ACCESSIONS, RECEIVE_ACCESSION_MAPINFO, RECEIVE_ACCESSION_AUDIT_LOG } from 'accessions/constants';
import { RECEIVE_ACCESSIONS, RECEIVE_ACCESSION, RECEIVE_ACCESSION_OVERVIEW, APPEND_ACCESSIONS, RECEIVE_ACCESSION_MAPINFO, RECEIVE_ACCESSION_AUDIT_LOG, RECEIVE_TILE_LAYER } from 'accessions/constants';
import AccessionService from 'service/genesys/AccessionService';
import { showSnackbar } from 'actions/snackbar';
import Page from 'model/Page';
......@@ -199,3 +200,13 @@ export const overviewAccessions = (filterCode: string) => (dispatch) => {
dispatch(receiveAccessionOverview(null, error));
});
};
// TileLayers
const receiveTileLayerInfo = (tileLayer: MapLayer) => ({
type: RECEIVE_TILE_LAYER,
payload: {tileLayer},
});
export const updateTileLayerInfo = (tileLayer: MapLayer) => (dispatch) => {
return dispatch(receiveTileLayerInfo(tileLayer));
};
export const RECEIVE_ACCESSIONS = 'accessions/RECEIVE_ACCESSIONS';
export const APPEND_ACCESSIONS = 'accessions/APPEND_ACCESSIONS';
export const RECEIVE_ACCESSION_OVERVIEW = 'accessions/RECEIVE_ACCESSION_OVERVIEW';
export const RECEIVE_TILE_LAYER = 'accessions/RECEIVE_TILE_LAYER';
export const RECEIVE_ACCESSION_AUDIT_LOG = 'accessions/RECEIVE_ACCESSION_AUDIT_LOG';
export const RECEIVE_ACCESSION = 'accessions/RECEIVE_ACCESSION';
export const RECEIVE_ACCESSION_MAPINFO = 'accessions/RECEIVE_ACCESSION_MAPINFO';
......
import update from 'immutability-helper';
import { IReducerAction } from 'model/common.model';
import { RECEIVE_ACCESSIONS, RECEIVE_ACCESSION, RECEIVE_ACCESSION_OVERVIEW, APPEND_ACCESSIONS, RECEIVE_ACCESSION_MAPINFO, RECEIVE_ACCESSION_AUDIT_LOG } from 'accessions/constants';
import {
RECEIVE_ACCESSIONS,
RECEIVE_ACCESSION,
RECEIVE_ACCESSION_OVERVIEW,
APPEND_ACCESSIONS,
RECEIVE_ACCESSION_MAPINFO,
RECEIVE_ACCESSION_AUDIT_LOG,
RECEIVE_TILE_LAYER,
} from 'accessions/constants';
import FilteredPage from 'model/FilteredPage';
import Accession from 'model/accession/Accession';
import AccessionMapInfo from 'model/accession/AccessionMapInfo';
import AccessionOverview from 'model/accession/AccessionOverview';
import AccessionAuditLog from 'model/accession/AccessionAuditLog';
import MapLayer, { AVAILABLE_LAYERS } from 'model/genesys/MapTileLayer';
const INITIAL_STATE: {
accession: Accession;
......@@ -17,6 +26,7 @@ const INITIAL_STATE: {
pagedError: any;
overview: AccessionOverview;
mapInfo: AccessionMapInfo;
mapLayers: MapLayer[]
} = {
accession: null,
auditLog: null,
......@@ -25,6 +35,7 @@ const INITIAL_STATE: {
pagedError: null,
overview: null,
mapInfo: null,
mapLayers: AVAILABLE_LAYERS,
};
function publicAccessions(state = INITIAL_STATE, action: IReducerAction) {
......@@ -109,6 +120,16 @@ function publicAccessions(state = INITIAL_STATE, action: IReducerAction) {
overview: {$set: null},
});
}
case RECEIVE_TILE_LAYER: {
const {tileLayer} = action.payload;
const indexToUpdate = state.mapLayers.findIndex((layerItem) => layerItem.name === tileLayer.name);
return update(state, {
mapLayers: {
[indexToUpdate]: {$set: tileLayer},
},
});
}
default:
return state;
......
......@@ -8,19 +8,20 @@ import { loadAccessionsMapInfo } from 'accessions/actions/public';
import AccessionFilter from 'model/accession/AccessionFilter';
import Loading from 'ui/common/Loading';
import AccessionMapInfo from 'model/accession/AccessionMapInfo';
import PageLayout from 'ui/layout/PageLayout';
import ContentHeader from 'ui/common/heading/ContentHeader';
import MapLayer from 'model/genesys/MapTileLayer';
import Button from '@material-ui/core/Button';
import Tabs, { Tab } from 'ui/common/Tabs';
import PrettyFilters from 'ui/common/filter/PrettyFilters';
import ButtonBar from 'ui/common/buttons/ButtonBar';
import ContentLayout from 'ui/layout/ContentLayout';
import MapConfigSection from './c/MapConfigSection';
import AccessionService from 'service/genesys/AccessionService';
let Map;
let TileLayer;
let Popup;
let Marker;
let Rectangle;
const popupContentLimit = 11;
......@@ -33,6 +34,7 @@ interface IMapPageProps {
currentTab: string;
filterCode: string;
loadAccessionsMapInfo: any;
mapLayers: MapLayer[];
}
const styles = (theme) => ({
......@@ -54,7 +56,8 @@ class BrowsePage extends React.Component<IMapPageProps, any> {
];
public state = {
popupPosition: [],
clickLocation: [],
searchBox: null,
geoData: [],
otherCount: 0,
};
......@@ -64,8 +67,8 @@ class BrowsePage extends React.Component<IMapPageProps, any> {
if (typeof window !== 'undefined') {
Map = require('react-leaflet').Map;
TileLayer = require('react-leaflet').TileLayer;
Marker = require('react-leaflet').Marker;
Popup = require('react-leaflet').Popup;
Rectangle = require('react-leaflet').Rectangle;
}
}
......@@ -103,21 +106,27 @@ class BrowsePage extends React.Component<IMapPageProps, any> {
console.log(e);
const width = Math.pow(2, currentZoom - 2);
console.log(`zoom=${currentZoom} width=${width} diff=${ 3 / width}`);
const searchBounds: number[][] = [
[ e.latlng.lat - (3 / width), correctLng - (3 / width) ],
[ e.latlng.lat + (3 / width), correctLng + (3 / width) ],
];
const filterWithGeo = {
...filter,
geo: {
longitude: {
ge: correctLng - (3 / currentZoom),
le: correctLng + (3 / currentZoom),
ge: searchBounds[0][1],
le: searchBounds[1][1],
},
latitude: {
ge: e.latlng.lat - (3 / currentZoom),
le: e.latlng.lat + (3 / currentZoom),
ge: searchBounds[0][0],
le: searchBounds[1][0],
},
},
};
AccessionService.geoJson(filterWithGeo, popupContentLimit)
.then((res) => this.setState({popupPosition: [ e.latlng.lat, e.latlng.lng], geoData: res.geoJson, otherCount: res.otherCount}));
.then((res) => this.setState({clickLocation: [ e.latlng.lat, e.latlng.lng], searchBox: searchBounds, geoData: res.geoJson, otherCount: res.otherCount}));
}, 200);
}
......@@ -129,10 +138,10 @@ class BrowsePage extends React.Component<IMapPageProps, any> {
}
public render() {
const {popupPosition, geoData, otherCount} = this.state;
const { searchBox, geoData, otherCount } = this.state;
const position = [30, 0];
const { mapInfo, currentTab, classes, filterCode, t } = this.props;
const { mapInfo, mapLayers, currentTab, classes, filterCode, t } = this.props;
if (! mapInfo) {
return <Loading />;
......@@ -142,8 +151,11 @@ class BrowsePage extends React.Component<IMapPageProps, any> {
const layerUrl = `{s}/acn/tile/{z}/{x}/{y}?f=${filterCode ? filterCode : ''}`; // `&color=${color}`;
return (
<PageLayout withFooter>
<ContentHeader title={ t('accessions.public.p.browse.title') } subTitle={ t('accessions.public.p.browse.subTitle') } />
<ContentLayout
right={ <MapConfigSection/> }
rightAlwaysCollapsible
customHeaderHeight
>
<Tabs
tab={ currentTab }
actions={
......@@ -174,18 +186,16 @@ class BrowsePage extends React.Component<IMapPageProps, any> {
zoom={ 3 } minZoom={ 2 } maxZoom={ 14 }
bounds={ mapInfo.bounds }>
<TileLayer
zIndex={ 0 }
opacity={ 0.50 }
attribution={ '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' }
url={ 'https://tiles.wmflabs.org/bw-mapnik/{z}/{x}/{y}.png' }
/>
{ mapLayers && mapLayers.filter((layer) => layer.enabled).map((layer, index) => <TileLayer zIndex={ index + 1 } key={ layer.name } { ...layer }/>) }
<TileLayer
opacity={ 0.50 }
maxZoom={ 7 }
attribution={ '&copy; worldclim.org' }
url={ 'https://{s}.tile.genesys-pgr.org/worldclim1.4/bio1/{z}/{x}/{y}.png' }
subdomains={ [ 'a', 'b', 'c', 'd' ] }
/>
<TileLayer
zIndex={ mapLayers.length + 1 }
updateInterval={ 1000 }
updateWhenZooming={ false }
attribution="&amp;copy Accession localities from <a href=&quot;/&quot;>Genesys PGR</a>"
......@@ -193,25 +203,29 @@ class BrowsePage extends React.Component<IMapPageProps, any> {
subdomains={ mapInfo.tileServers }
/>
{ geoData && geoData.length > 0 &&
<Marker position={ popupPosition } ref={ (marker) => marker && marker.leafletElement.openPopup() } >
<Rectangle
bounds={ searchBox }
ref={ (marker) => marker && marker.leafletElement.openPopup() }
>
<Popup open>
<div>
{ geoData.map((feature) => (<div><Link to={ `/a/${feature.properties.uuid}` }>{ `${feature.properties.accessionNumber} ${feature.properties.instCode}` }</Link></div>)) }
{ otherCount > 0 && <div>{ t('accessions.public.p.map.andMore', {otherMore: otherCount}) }</div> }
</div>
</Popup>
</Marker>
</Rectangle>
}
</Map>
}
</div>
</PageLayout>
</ContentLayout>
);
}
}
const mapStateToProps = (state, ownProps) => ({
mapInfo: state.accessions.public.mapInfo || undefined,
mapLayers: state.accessions.public.mapLayers,
filterCode: ownProps.match.params.filterCode || '',
currentTab: ownProps.match.params.tab || 'map', // current tab, or ownProps.location.pathname
});
......
import * as React from 'react';
import { connect } from 'react-redux';
// model
import MapLayer from 'model/genesys/MapTileLayer';
// ui
import withStyles from '@material-ui/core/styles/withStyles';
import ExpandFiltersComponent from 'ui/common/filter/ExpandFiltersComponent';
import { Divider } from '@material-ui/core';
import MapLayerConfig from './MapLayerConfig';
/*tslint:disable*/
const styles = (theme) => ({
root: {
overflow: 'hidden' as 'hidden',
},
layersTitle: {
paddingLeft: '8px',
fontSize: '15px',
fontWeight: 700,
lineHeight: '40px',
color: '#81807f',
textTransform: 'uppercase' as 'uppercase',
},
});
interface IMapConfigSectionProps extends React.ClassAttributes<any> {
classes: any;
mapLayers: MapLayer[];
}
class MapConfigSection extends React.Component<IMapConfigSectionProps> {
public render() {
const {classes, mapLayers} = this.props;
return (
<div className={ classes.root }>
<ExpandFiltersComponent title="Configure map"/>
<span className={ classes.layersTitle }>Layers</span>
<Divider/>
{ mapLayers.map((layer) => <MapLayerConfig layer={ layer }/>) }
</div>
);
}
}
const mapStateToProps = (state, ownProps) => ({
mapLayers: state.accessions.public.mapLayers,
});
export default connect(mapStateToProps, null)(withStyles(styles)(MapConfigSection));
\ No newline at end of file
import * as React from 'react';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import { translate } from 'react-i18next';
import withStyles from '@material-ui/core/styles/withStyles';
// actions
import { updateTileLayerInfo } from 'accessions/actions/public';
// model
import MapLayer from 'model/genesys/MapTileLayer';
// ui
import CollapsibleComponentSearch from 'ui/common/filter/CollapsibleComponentSearch';
import { Slider } from '@material-ui/lab';
import FormControlLabel from '@material-ui/core/FormControlLabel';
import Checkbox from '@material-ui/core/Checkbox';
import { Divider } from '@material-ui/core';
/*tslint:disable*/
const styles = (theme) => ({
root: {
color: 'initial' as 'initial',
whiteSpace: 'initial' as 'initial',
'& > div > div > span ': {
color: 'initial' as 'initial',
fontWeight: 500,
}
},
rootOpened: {
backgroundColor: '#e9e9e9',
},
content: {
backgroundColor: '#f3f2ee',
margin: '0 -20px -20px -20px',
padding: '0 20px 20px 20px',
},
opacitySlider: {
padding: '8px',
display: 'flex' as 'flex',
alignItems: 'center' as 'center'
},
opacitySliderLabel: {
flexShrink: 0,
fontSize: '12px',
position: 'relative' as 'relative',
left: '-8px',
},
description: {
margin: '0 -20px',
padding: '8px 20px',
whiteSpace: 'initial' as 'initial',
},
statusCheckbox: {
width: '100%',
display: 'flex' as 'flex',
justifyContent: 'space-between' as 'space-between',
flexDirection: 'row-reverse' as 'row-reverse',
marginLeft: '0px',
},
copyright: {
color: 'grey',
fontSize: '12px',
}
});
/*tslint:enable*/
interface IMapLayerConfigProps extends React.ClassAttributes<any> {
title: string;
classes: any;
t: any;
layer: MapLayer;
updateTileLayerInfo: (layerInfo: MapLayer) => void;
}
class MapLayerConfig extends React.Component<IMapLayerConfigProps> {
public state = {
show: false,
};
public componentWillMount() {
const {layer} = this.props;
if (layer && layer.enabled) {
this.setState({show: true});
}
}
public render() {
const {classes, layer, t} = this.props;
const {show} = this.state;
return (
<div className={ `${ classes.root } ${ show && classes.rootOpened }` }>
<CollapsibleComponentSearch
title={ layer.title }
collapsed={ !show }
onToggleCollapsed={ (show) => this.setState({show}) }
>
<Divider style={ {margin: '0 -20px'} }/>
<div className={ classes.content }>
<div className={ classes.description }>
<div>{ t(layer.description) }</div>
</div>
<div className={ classes.opacitySlider }>
<div className={ classes.opacitySliderLabel }>{ `Opacity: ${ Number(layer.opacity).toFixed(2) }` }</div>
<Slider
min={ 0 }
step={ 0.05 }
max={ 1.0 }
onChange={ this.handleSliderChange }
value={ layer.opacity }
color="#88ba42"
/>
</div>
<FormControlLabel
className={ classes.statusCheckbox }
label="Enabled"
control={
<Checkbox
checked={ layer.enabled }
onChange={ this.handleCheckBoxChange }
/>
}
/>
</div>
<span className={ `float-right ${ classes.copyright }` }>{ layer.attribution }</span>
</CollapsibleComponentSearch>
</div>
);
}
private handleSliderChange = (e, value) => {
const {updateTileLayerInfo, layer} = this.props;
updateTileLayerInfo({...layer, opacity: value});
}
private handleCheckBoxChange = (e, value) => {
const {updateTileLayerInfo, layer} = this.props;
updateTileLayerInfo({...layer, enabled: value});
}
}
const mapDispatchToProps = (dispatch) => bindActionCreators({
updateTileLayerInfo,
}, dispatch);
export default translate()(connect(null, mapDispatchToProps)(withStyles(styles)(MapLayerConfig)));
class MapLayer {
public name: string;
public title: string;
public description: string;
public url: string;
public enabled: boolean;
public opacity: number;
public attribution: string;
public maxZoom: number;
public subdomains: string[];
public constructor() {
this.enabled = false;
this.opacity = 0.5;
this.attribution = '© worldclim.org';
this.maxZoom = 7;
this.subdomains = ['a', 'b', 'c', 'd'];
}
}
const layerNames = ['bio1', 'bio5', 'bio6', 'bio12', 'bio13', 'bio14'];
export const AVAILABLE_LAYERS = layerNames.map((layerName) => ({
...new MapLayer(),
name: layerName,
title: `accessions.climate.${ layerName }`,
description: `accessions.climateDescription.${ layerName }`,
url: `https://{s}.tile.genesys-pgr.org/worldclim1.4/${ layerName }/{z}/{x}/{y}.png`,
}));
export default MapLayer;
......@@ -8,6 +8,7 @@ import Divider from '@material-ui/core/Divider';
interface ICollapsibleComponentSearch extends React.ClassAttributes<any> {
classes: any;
title: string;
onToggleCollapsed?: (show: boolean) => void;
collapsed?: boolean;
t: any;
}
......@@ -36,7 +37,11 @@ class CollapsibleComponentSearch extends React.Component<ICollapsibleComponentSe
}
private toggleCollapsed = () => {
const {onToggleCollapsed} = this.props;
const show = !this.state.show;
if (onToggleCollapsed) {
onToggleCollapsed(show);
}
this.setState({ show });
}
......
......@@ -22,11 +22,12 @@ interface IContentLayoutProps extends React.Props<any> {
children?: any;
left?: any;
right?: any;
rightAlwaysCollapsible?: boolean;
className?: string;
customHeaderHeight?: boolean;
}
const ContentLayout = ({classes, children = null, left = null, right = null, customHeaderHeight = false, className = ''}: IContentLayoutProps) => {
const ContentLayout = ({classes, children = null, left = null, right = null, rightAlwaysCollapsible = false, customHeaderHeight = false, className = ''}: IContentLayoutProps) => {
return (
<div className={ className }>
<div className={ classes.root }>
......@@ -36,7 +37,7 @@ const ContentLayout = ({classes, children = null, left = null, right = null, cus
{ children }
</main>
) }
{ right && (<SidebarWrapper sidebarContent={ right } customHeight={ customHeaderHeight } right/>) }
{ right && (<SidebarWrapper sidebarContent={ right } customHeight={ customHeaderHeight } right alwaysCollapsible={ rightAlwaysCollapsible }/>) }
</div>
</div>
);
......
......@@ -18,6 +18,7 @@ interface ISidebarProps extends React.ClassAttributes<any> {
width: Breakpoint;
customHeight?: boolean;
right?: boolean;
alwaysCollapsible?: boolean;
}
const mobile = ['sm', 'xs'] as Breakpoint[];
......@@ -45,12 +46,17 @@ const styles = (theme) => ({
[theme.breakpoints.down('sm')]: {
maxWidth: '90%',
position: 'fixed' as 'fixed',
zIndex: 15,
zIndex: 1250,
},
[theme.breakpoints.down('xs')]: {
maxWidth: '100%',
}
},
drawerAlwaysCollapsible: {
maxWidth: '90%',
position: 'fixed' as 'fixed',
zIndex: 1250,
},
drawerHeightV2: {
top: '83px',
height: 'calc(100vh - 83px)',
......@@ -112,6 +118,9 @@ const styles = (theme) => ({
minHeight: 'calc(100% - 3rem)',
},
},
sidebarContentsAlwaysCollapsible: {
minHeight: 'calc(100% - 3rem)',
},
sidebarContentsCollapsed: {
minHeight: 'calc(100% - 52px)',
whiteSpace: 'nowrap' as 'nowrap',
......@@ -152,6 +161,12 @@ const styles = (theme) => ({
display: 'block' as 'block',
},
},
collapseButtonAlwaysCollapsible:{
display: 'block' as 'block',
'& > span': {
fontSize: '1.5rem',
},
},
buttonCollapsed: {
top: '100%',
transition: theme.transitions.create('width', {
......@@ -192,13 +207,14 @@ class SidebarWrapper extends React.Component<ISidebarProps, any> {
}
public render() {
const { sidebarContent, classes, isOpen, width, customHeight, right = false } = this.props;
const isMobile = mobile.indexOf(width) !== -1;