auspice
Version:
Web app for visualizing pathogen evolution
577 lines (542 loc) • 21.4 kB
JavaScript
import React from "react";
import PropTypes from 'prop-types';
import { connect } from "react-redux";
import leaflet from "leaflet";
import _min from "lodash/min";
import _max from "lodash/max";
import { select } from "d3-selection";
import 'd3-transition';
import leafletImage from "leaflet-image";
import Card from "../framework/card";
import { drawDemesAndTransmissions, updateOnMoveEnd, updateVisibility } from "./mapHelpers";
import {
createDemeAndTransmissionData,
updateDemeAndTransmissionDataColAndVis,
updateTransmissionDataLatLong,
updateDemeDataLatLong
} from "./mapHelpersLatLong";
import { changeDateFilter } from "../../actions/tree";
import { MAP_ANIMATION_PLAY_PAUSE_BUTTON } from "../../actions/types";
// import { incommingMapPNG } from "../download/helperFunctions";
import { timerStart, timerEnd } from "../../util/perf";
import { tabSingle, darkGrey, lightGrey, goColor, pauseColor } from "../../globalStyles";
/* global L */
// L is global in scope and placed by leaflet()
class Map extends React.Component {
static propTypes = {
treeVersion: PropTypes.number.isRequired,
treeLoaded: PropTypes.bool.isRequired,
colorScaleVersion: PropTypes.number.isRequired
}
constructor(props) {
super(props);
this.state = {
map: null,
d3DOMNode: null,
d3elems: null,
responsive: null,
demeData: null,
transmissionData: null,
demeIndices: null,
transmissionIndices: null
};
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-no-bind.md#es6-classes
this.playPauseButtonClicked = this.playPauseButtonClicked.bind(this);
this.resetButtonClicked = this.resetButtonClicked.bind(this);
this.resetZoomButtonClicked = this.resetZoomButtonClicked.bind(this);
}
componentWillMount() {
if (!window.L) {
leaflet(); /* this sets up window.L */
}
if (!window.L.getMapTiles) {
/* Get the map tiles
https://github.com/mapbox/leaflet-image
https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob
https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL
*/
window.L.getMapTiles = (loadendCallback, errorCallback) => {
leafletImage(this.state.map, (err, canvas) => {
if (err) {
errorCallback(err);
return;
}
const mapDimensions = this.state.map.getSize();
const loadendCallbackWrapper = (e) => {
// loadendCallback is a bound version of writeSVGPossiblyIncludingMapPNG
loadendCallback({
base64map: e.srcElement.result,
mapDimensions,
panOffsets: this.state.map._getMapPanePos()
});
};
canvas.toBlob((blob) => {
const reader = new FileReader();
reader.addEventListener('loadend', loadendCallbackWrapper);
reader.addEventListener('onerror', errorCallback);
reader.readAsDataURL(blob);
}, "image/png;base64;", 1);
});
};
}
}
componentDidMount() {
this.maybeChangeSize(this.props);
const removed = this.maybeRemoveAllDemesAndTransmissions(this.props); /* geographic resolution just changed (ie., country to division), remove everything. this change is upstream of maybeDraw */
// TODO: if demes are color blended circles, updating rather than redrawing demes would do
if (!removed) {
this.maybeUpdateDemesAndTransmissions(this.props); /* every time we change something like colorBy */
}
this.maybeInvalidateMapSize(this.props);
}
componentWillReceiveProps(nextProps) {
this.modulateInterfaceForNarrativeMode(nextProps);
this.maybeChangeSize(nextProps);
const removed = this.maybeRemoveAllDemesAndTransmissions(nextProps); /* geographic resolution just changed (ie., country to division), remove everything. this change is upstream of maybeDraw */
// TODO: if demes are color blended circles, updating rather than redrawing demes would do
if (!removed) {
this.maybeUpdateDemesAndTransmissions(nextProps); /* every time we change something like colorBy */
}
this.maybeInvalidateMapSize(nextProps);
}
componentDidUpdate(prevProps) {
if (this.props.nodes === null) { return; }
this.maybeCreateLeafletMap(); /* puts leaflet in the DOM, only done once */
this.maybeSetupD3DOMNode(); /* attaches the D3 SVG DOM node to the Leaflet DOM node, only done once */
this.maybeDrawDemesAndTransmissions(prevProps); /* it's the first time, or they were just removed because we changed dataset or colorby or resolution */
}
maybeInvalidateMapSize(nextProps) {
/* when we procedurally change the size of the card, for instance, when we swap from grid to full */
if (this.state.map && (this.props.width !== nextProps.width || this.props.height !== nextProps.height)) {
window.setTimeout(this.invalidateMapSize.bind(this), 1500);
}
}
invalidateMapSize() {
this.state.map.invalidateSize();
}
maybeCreateLeafletMap() {
/* first time map, this sets up leaflet */
if (this.props.metadata.loaded && !this.state.map && document.getElementById("map")) {
this.createMap();
}
}
maybeChangeSize(nextProps) {
if (this.props.width !== nextProps.width ||
this.props.height !== nextProps.height ||
!this.state.responsive ||
this.props.treeVersion !== nextProps.treeVersion // treeVersion change implies tree is ready (modified by the same action)
) {
/* This is stored in state because it's used by both the map and the d3 overlay */
this.setState({responsive: {width: nextProps.width, height: nextProps.height}});
}
}
maybeSetupD3DOMNode() {
if (
this.state.map &&
this.state.responsive &&
!this.state.d3DOMNode
) {
const d3DOMNode = select("#map svg").attr("id", "d3DemesTransmissions");
this.setState({d3DOMNode});
}
}
modulateInterfaceForNarrativeMode(nextProps) {
if (this.props.narrativeMode === nextProps.narrativeMode || !this.state.map) return;
if (nextProps.narrativeMode) {
L.zoomControlButtons.remove();
this.state.map.dragging.disable();
this.state.map.doubleClickZoom.disable();
} else {
L.zoomControlButtons = L.control.zoom({position: "bottomright"}).addTo(this.state.map);
this.state.map.dragging.enable();
this.state.map.doubleClickZoom.enable();
}
}
maybeDrawDemesAndTransmissions() {
const mapIsDrawn = !!this.state.map;
const allDataPresent = !!(this.props.metadata.loaded && this.props.treeLoaded && this.state.responsive && this.state.d3DOMNode);
const demesTransmissionsNotComputed = !this.state.demeData && !this.state.transmissionData;
/* if at any point we change dataset and app doesn't remount, we'll need these again */
// const newColorScale = this.props.colorScale.version !== prevProps.colorScale.version;
// const newGeoResolution = this.props.geoResolution !== prevProps.geoResolution;
// const initialVisibilityVersion = this.props.visibilityVersion === 1; /* see tree reducer, we set this to 1 after tree comes back */
// const newVisibilityVersion = this.props.visibilityVersion !== prevProps.visibilityVersion;
if (mapIsDrawn && allDataPresent && demesTransmissionsNotComputed) {
timerStart("drawDemesAndTransmissions");
/* data structures to feed to d3 latLongs = { tips: [{}, {}], transmissions: [{}, {}] } */
if (!this.state.boundsSet) { // we are doing the initial render -> set map to the range of the data
const SWNE = this.getGeoRange();
// L. available because leaflet() was called in componentWillMount
this.state.map.fitBounds(L.latLngBounds(SWNE[0], SWNE[1]));
}
const {demeData, transmissionData, demeIndices, transmissionIndices} = createDemeAndTransmissionData(
this.props.nodes,
this.props.visibility,
this.props.geoResolution,
this.props.nodeColors,
this.props.mapTriplicate,
this.props.metadata,
this.state.map,
this.props.pieChart,
this.props.legendValues,
this.props.colorBy,
this.props.dispatch
);
// const latLongs = this.latLongs(demeData, transmissionData); /* no reference stored, we recompute this for now rather than updating in place */
const d3elems = drawDemesAndTransmissions(
demeData,
transmissionData,
this.state.d3DOMNode,
this.state.map,
this.props.nodes,
this.props.dateMinNumeric,
this.props.dateMaxNumeric,
this.props.pieChart
);
/* Set up leaflet events */
// this.state.map.on("viewreset", this.respondToLeafletEvent.bind(this));
this.state.map.on("moveend", this.respondToLeafletEvent.bind(this));
// don't redraw on every rerender - need to seperately handle virus change redraw
this.setState({
boundsSet: true,
d3elems,
demeData,
transmissionData,
demeIndices,
transmissionIndices
});
timerEnd("drawDemesAndTransmissions");
}
}
/**
* removing demes & transmissions, both from the react state & from the DOM.
* They will be created from scratch (& rendered) by `this.maybeDrawDemesAndTransmissions`
* This is done when
* (a) the dataset has changed
* (b) the geo resolution has changed (new transmissions, new deme locations)
* (c) we change colorBy -- mainly as the `demeData` structure is different both
* pie charts vs circles, and also between different pie charts (different num of slices)
*
* Note: we do not modify `state.boundsSet` to stop the map resetting position
*/
maybeRemoveAllDemesAndTransmissions(nextProps) {
const mapIsDrawn = !!this.state.map;
const geoResolutionChanged = this.props.geoResolution !== nextProps.geoResolution;
const dataChanged = (!nextProps.treeLoaded || this.props.treeVersion !== nextProps.treeVersion);
const colorByChanged = (nextProps.colorScaleVersion !== this.props.colorScaleVersion);
if (mapIsDrawn && (geoResolutionChanged || dataChanged || colorByChanged)) {
this.state.d3DOMNode.selectAll("*").remove();
this.setState({
d3elems: null,
demeData: null,
transmissionData: null,
demeIndices: null,
transmissionIndices: null
});
return true;
}
return false;
}
respondToLeafletEvent(leafletEvent) {
if (leafletEvent.type === "moveend") { /* zooming and panning */
if (!this.state.demeData || !this.state.transmissionData) return;
const newDemes = updateDemeDataLatLong(this.state.demeData, this.state.map);
const newTransmissions = updateTransmissionDataLatLong(this.state.transmissionData, this.state.map);
updateOnMoveEnd(
newDemes,
newTransmissions,
this.state.d3elems,
this.props.dateMinNumeric,
this.props.dateMaxNumeric,
this.props.pieChart
);
this.setState({demeData: newDemes, transmissionData: newTransmissions});
}
}
getGeoRange() {
const latitudes = [];
const longitudes = [];
this.props.metadata.geoResolutions.forEach((geoData) => {
if (geoData.key === this.props.geoResolution) {
const demeToLatLongs = geoData.demes;
Object.keys(demeToLatLongs).forEach((deme) => {
latitudes.push(demeToLatLongs[deme].latitude);
longitudes.push(demeToLatLongs[deme].longitude);
});
}
});
const maxLat = _max(latitudes);
const minLat = _min(latitudes);
const maxLng = _max(longitudes);
const minLng = _min(longitudes);
const lngRange = (maxLng - minLng) % 360;
const latRange = (maxLat - minLat);
const south = _max([-55, minLat - (0.1 * latRange)]);
const north = _min([70, maxLat + (0.1 * latRange)]);
const east = _max([-180, minLng - (0.1 * lngRange)]);
const west = _min([180, maxLng + (0.1 * lngRange)]);
return [L.latLng(south, west), L.latLng(north, east)];
}
/**
* updates demes & transmissions when redux (tree) visibility or colorScale (i.e. colorBy) has changed
* returns early if the map or tree isn't ready
* uses deme & transmission indicies for smart (quick) updating
*/
maybeUpdateDemesAndTransmissions(nextProps) {
if (!this.state.map || !this.props.treeLoaded || !this.state.d3elems) { return; }
const visibilityChange = nextProps.visibilityVersion !== this.props.visibilityVersion;
const haveData = nextProps.nodes && nextProps.visibility && nextProps.geoResolution && nextProps.nodeColors;
if (!(visibilityChange && haveData)) { return; }
timerStart("updateDemesAndTransmissions");
if (this.props.geoResolution !== nextProps.geoResolution) {
/* This `if` statement added as part of https://github.com/nextstrain/auspice/issues/722
* and should be a prime candidate for refactoring in https://github.com/nextstrain/auspice/issues/735
*/
const {demeData, transmissionData, demeIndices, transmissionIndices} = createDemeAndTransmissionData(
nextProps.nodes,
nextProps.visibility,
nextProps.geoResolution,
nextProps.nodeColors,
nextProps.mapTriplicate,
nextProps.metadata,
this.state.map,
nextProps.pieChart,
nextProps.legendValues,
nextProps.colorBy,
nextProps.dispatch
);
const d3elems = drawDemesAndTransmissions(
demeData,
transmissionData,
this.state.d3DOMNode,
this.state.map,
nextProps.nodes,
nextProps.dateMinNumeric,
nextProps.dateMaxNumeric,
nextProps.pieChart
);
this.setState({
d3elems,
demeData,
transmissionData,
demeIndices,
transmissionIndices
});
} else {
const { newDemes, newTransmissions } = updateDemeAndTransmissionDataColAndVis(
this.state.demeData,
this.state.transmissionData,
this.state.demeIndices,
this.state.transmissionIndices,
nextProps.nodes,
nextProps.visibility,
nextProps.geoResolution,
nextProps.nodeColors,
nextProps.pieChart,
nextProps.colorBy,
nextProps.legendValues
);
updateVisibility(
/* updated in the function above */
newDemes,
newTransmissions,
/* we already have all this */
this.state.d3elems,
this.state.map,
nextProps.nodes,
nextProps.dateMinNumeric,
nextProps.dateMaxNumeric,
nextProps.pieChart
);
this.setState({
demeData: newDemes,
transmissionData: newTransmissions
});
}
timerEnd("updateDemesAndTransmissions");
}
getInitialBounds() {
let southWest;
let northEast;
/* initial map bounds */
if (this.props.mapTriplicate) {
southWest = L.latLng(-70, -360);
northEast = L.latLng(80, 360);
} else {
southWest = L.latLng(-70, -180);
northEast = L.latLng(80, 180);
}
const bounds = L.latLngBounds(southWest, northEast);
return bounds;
}
createMap() {
const zoom = 2;
const center = [0, 0];
/* *****************************************
GET LEAFLET IN THE DOM
**************************************** */
const map = L.map('map', {
center: center,
zoom: zoom,
scrollWheelZoom: false,
maxBounds: this.getInitialBounds(),
minZoom: 2,
maxZoom: 14,
zoomSnap: 0.5,
zoomControl: false,
/* leaflet sleep see https://cliffcloud.github.io/Leaflet.Sleep/#summary */
// true by default, false if you want a wild map
sleep: false,
// time(ms) for the map to fall asleep upon mouseout
sleepTime: 750,
// time(ms) until map wakes on mouseover
wakeTime: 750,
// defines whether or not the user is prompted oh how to wake map
sleepNote: true,
// should hovering wake the map? (clicking always will)
hoverToWake: false,
// if mobile OR narrative mode, disable single finger dragging
dragging: (!L.Browser.mobile) && (!this.props.narrativeMode),
doubleClickZoom: !this.props.narrativeMode,
tap: false
});
map.getRenderer(map).options.padding = 2;
L.tileLayer('https://api.mapbox.com/styles/v1/trvrb/ciu03v244002o2in5hlm3q6w2/tiles/256/{z}/{x}/{y}?access_token=pk.eyJ1IjoidHJ2cmIiLCJhIjoiY2l1MDRoMzg5MDEwbjJvcXBpNnUxMXdwbCJ9.PMqX7vgORuXLXxtI3wISjw', {
attribution: '© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
if (!this.props.narrativeMode) {
L.zoomControlButtons = L.control.zoom({position: "bottomright"}).addTo(map);
}
this.setState({map});
}
animationButtons() {
if (this.props.narrativeMode) return null;
const buttonBaseStyle = {
color: "#FFFFFF",
fontWeight: 400,
fontSize: 12,
borderRadius: 3,
padding: 12,
border: "none",
zIndex: 900,
position: "absolute",
textTransform: "uppercase"
};
if (this.props.branchLengthsToDisplay !== "divOnly") {
return (
<div>
<button
style={{...buttonBaseStyle, top: 20, left: 20, width: 60, backgroundColor: this.props.animationPlayPauseButton === "Pause" ? pauseColor : goColor}}
onClick={this.playPauseButtonClicked}
>
{this.props.animationPlayPauseButton}
</button>
<button
style={{...buttonBaseStyle, top: 20, left: 88, width: 60, backgroundColor: lightGrey}}
onClick={this.resetButtonClicked}
>
Reset
</button>
</div>
);
}
/* else - divOnly */
return (<div/>);
}
maybeCreateMapDiv() {
let container = null;
if (this.state.responsive) {
container = (
<div style={{position: "relative"}}>
{this.animationButtons()}
<div id="map"
style={{
height: this.state.responsive.height,
width: this.state.responsive.width
}}
/>
</div>
);
}
return container;
}
playPauseButtonClicked() {
if (this.props.animationPlayPauseButton === "Play") {
this.props.dispatch({type: MAP_ANIMATION_PLAY_PAUSE_BUTTON, data: "Pause"});
} else {
this.props.dispatch({type: MAP_ANIMATION_PLAY_PAUSE_BUTTON, data: "Play"});
}
}
resetButtonClicked() {
this.props.dispatch({type: MAP_ANIMATION_PLAY_PAUSE_BUTTON, data: "Play"});
this.props.dispatch(changeDateFilter({newMin: this.props.absoluteDateMin, newMax: this.props.absoluteDateMax, quickdraw: false}));
}
resetZoomButtonClicked() {
const SWNE = this.getGeoRange();
// L. available because leaflet() was called in componentWillMount
this.state.map.fitBounds(L.latLngBounds(SWNE[0], SWNE[1]));
this.maybeDrawDemesAndTransmissions();
}
getStyles = () => {
const activeResetZoomButton = true;
return {
resetZoomButton: {
zIndex: 100,
position: "absolute",
right: 5,
top: 0,
cursor: activeResetZoomButton ? "pointer" : "auto",
color: activeResetZoomButton ? darkGrey : lightGrey
}
};
};
render() {
const styles = this.getStyles();
const transmissionsExist = this.state.transmissionData && this.state.transmissionData.length;
// clear layers - store all markers in map state https://github.com/Leaflet/Leaflet/issues/3238#issuecomment-77061011
return (
<Card center title={transmissionsExist ? "Transmissions" : "Geography"}>
{this.maybeCreateMapDiv()}
{this.props.narrativeMode ? null : (
<button
style={{...tabSingle, ...styles.resetZoomButton}}
onClick={this.resetZoomButtonClicked}
>
reset zoom
</button>
)}
</Card>
);
}
}
export default Map;