Skip to content

Commit

Permalink
Merge pull request #478 from nextstrain/entropy
Browse files Browse the repository at this point in the history
Reactive entropy data & URLs handled via middleware
  • Loading branch information
jameshadfield authored Jan 18, 2018
2 parents 6932b75 + 8334e41 commit b33e2c1
Show file tree
Hide file tree
Showing 60 changed files with 1,395 additions and 1,159 deletions.
2 changes: 2 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ rules:
one-var-declaration-per-line: off
no-console: [1, { "allow": ["warn", "error"] }]
no-param-reassign: [1, { "props": false }]
no-underscore-dangle: off
no-unused-expressions: ['error', {"allowTernary": true }]
no-restricted-syntax: ['error', 'ForInStatement', 'WithStatement'] # allow ForOfStatement & LabeledStatement
class-methods-use-this: off
Expand All @@ -34,6 +35,7 @@ rules:
react/prop-types: off # possibly reinstate
react/sort-comp: off # possibly reinstate
jsx-a11y/no-static-element-interactions: off
import/prefer-default-export: off
no-labels: off
no-continue: off
no-unneeded-ternary: ["error", { "defaultAssignment": true }]
Expand Down
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@
"d3-zoom": "^1.1.3",
"express": "^4.13.3",
"express-static-gzip": "^0.2.2",
"history": "^4.6.0",
"leaflet": "^1.2.0",
"leaflet-image": "^0.4.0",
"linspace": "^1.0.0",
Expand All @@ -132,7 +131,6 @@
"react-hot-loader": "^3.0.0-beta.7",
"react-markdown": "^2.5.0",
"react-redux": "^4.4.8",
"react-router-dom": "^4.2.2",
"react-select": "^1.0.0-rc.5",
"react-sidebar": "git://github.com/nextstrain/react-sidebar.git#inset-shadow",
"react-svg-pan-zoom": "2.0.0",
Expand Down
46 changes: 25 additions & 21 deletions src/actions/colors.js
Original file line number Diff line number Diff line change
@@ -1,56 +1,60 @@
import { parseGenotype } from "../util/getGenotype";
import getColorScale from "../util/getColorScale";
import { setGenotype } from "../util/setGenotype";
import { calcNodeColor } from "../components/tree/treeHelpers";
import { modifyURLquery } from "../util/urlHelpers";
import { determineColorByGenotypeType } from "../util/colorHelpers";
import { updateEntropyVisibility } from "./entropy";
import * as types from "./types";

/* providedColorBy: undefined | string
updateURL: undefined | router (this.context.router) */
export const changeColorBy = (providedColorBy = undefined, router = undefined) => {
/* providedColorBy: undefined | string */
export const changeColorBy = (providedColorBy = undefined) => { // eslint-disable-line import/prefer-default-export
return (dispatch, getState) => {
const { controls, tree, sequences, metadata } = getState();
const { controls, tree, metadata } = getState();
/* step 0: bail if all required params aren't (yet) available! */
/* note this *can* run before the tree is loaded - we only need the nodes */
if (!(tree.nodes !== null && sequences.loaded && metadata.loaded)) {
if (!(tree.nodes !== null && metadata.loaded)) {
// console.log(
// "updateColorScale not running due to load statuses of ",
// "tree nodes are null?", tree.nodes === null,
// "sequences", sequences.loaded,
// "metadata", metadata.loaded
// );
return null;
}
const colorBy = providedColorBy ? providedColorBy : controls.colorBy;

if (colorBy.slice(0, 3) === "gt-") {
const x = parseGenotype(colorBy, controls.geneLength);
setGenotype(tree.nodes, x[0][0], x[0][1] + 1);
}

/* step 1: calculate the required colour scale */
const version = controls.colorScale === undefined ? 1 : controls.colorScale.version + 1;
// console.log("updateColorScale setting colorScale to ", version);
const colorScale = getColorScale(colorBy, tree, sequences, metadata.colorOptions, version);
const colorScale = getColorScale(colorBy, tree, controls.geneLength, metadata.colorOptions, version);
/* */
if (colorBy.slice(0, 3) === "gt-" && sequences.geneLength) {
colorScale.genotype = parseGenotype(colorBy, sequences.geneLength);
if (colorBy.slice(0, 3) === "gt-" && controls.geneLength) {
colorScale.genotype = parseGenotype(colorBy, controls.geneLength);
}

/* step 2: calculate the node colours */
const nodeColors = calcNodeColor(tree, colorScale, sequences);
const nodeColors = calcNodeColor(tree, colorScale);

/* step 3: change in mutType? */
const newMutType = determineColorByGenotypeType(colorBy) !== controls.mutType ? determineColorByGenotypeType(colorBy) : false;
if (newMutType) {
updateEntropyVisibility(dispatch, getState);
}

/* step 3: dispatch */
/* step 4: dispatch */
dispatch({
type: types.NEW_COLORS,
colorBy,
colorScale,
nodeColors,
version
version,
newMutType
});

/* step 4 (optional): update the URL query field */
if (router) {
modifyURLquery(router, {c: colorBy}, true);
}

return null;
};
};

/* updateColors calls changeColorBy with no args, i.e. it updates the colorScale & nodeColors */
export const updateColors = () => (dispatch) => {dispatch(changeColorBy());};
25 changes: 25 additions & 0 deletions src/actions/entropy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { debounce } from 'lodash';
import { calcEntropyInView } from "../util/treeTraversals";
import * as types from "./types";

/* debounce works better than throttle, as it _won't_ update while events are still coming in (e.g. dragging the date slider) */
export const updateEntropyVisibility = debounce((dispatch, getState) => {
const { entropy, controls, tree } = getState();
if (!tree.nodes ||
!tree.visibility ||
!entropy.geneMap ||
controls.mapAnimationPlayPauseButton !== "Play"
) {return;}
const [data, maxYVal] = calcEntropyInView(tree.nodes, tree.visibility, controls.mutType, entropy.geneMap, entropy.showCounts);
dispatch({type: types.ENTROPY_DATA, data, maxYVal});
}, 500, { leading: true, trailing: true });

export const changeMutType = (newMutType) => (dispatch, getState) => {
dispatch({type: types.TOGGLE_MUT_TYPE, data: newMutType});
updateEntropyVisibility(dispatch, getState);
};

export const showCountsNotEntropy = (showCounts) => (dispatch, getState) => {
dispatch({type: types.ENTROPY_COUNTS_TOGGLE, showCounts});
updateEntropyVisibility(dispatch, getState);
};
86 changes: 19 additions & 67 deletions src/actions/loadData.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import queryString from "query-string";
import * as types from "./types";
import { updateColors } from "./colors";
import { changeColorBy } from "./colors";
import { updateVisibleTipsAndBranchThicknesses } from "./treeProperties";
import { turnURLtoDataPath } from "../util/urlHelpers";
import { charonAPIAddress, enableNarratives } from "../util/globals";
import { errorNotification } from "./notifications";
import { getManifest } from "../util/clientAPIInterface";
import { getNarrative } from "../util/getNarrative";
import { getNarrative } from "../util/getMarkdown";
import { updateEntropyVisibility } from "./entropy";
import { changePage } from "./navigation";

// /* if the metadata specifies an analysis slider, this is where we process it */
// const addAnalysisSlider = (dispatch, tree, controls) => {
Expand Down Expand Up @@ -69,58 +70,27 @@ import { getNarrative } from "../util/getNarrative";
// };
// };

const populateEntropyStore = (paths) => {
return (dispatch) => {
const entropyJSONpromise = fetch(paths.entropy)
.then((res) => res.json());
entropyJSONpromise
.then((data) => {
dispatch({
type: types.RECEIVE_ENTROPY,
data: data
});
})
.catch((err) => {
/* entropy reducer has already been invalidated */
console.error("entropyJSONpromise error", err);
});
};
};

export const loadJSONs = (router, s3override = undefined) => { // eslint-disable-line import/prefer-default-export
export const loadJSONs = (s3override = undefined) => { // eslint-disable-line import/prefer-default-export
return (dispatch, getState) => {

const { datasets } = getState();
if (!datasets.ready) {
if (!datasets.availableDatasets) {
console.error("Attempted to fetch JSONs before Charon returned initial data.");
return;
}

dispatch({type: types.DATA_INVALID});
const s3bucket = s3override ? s3override : datasets.s3bucket;
const data_path = turnURLtoDataPath(router, {pathogen: datasets.pathogen});
const paths = {
meta: charonAPIAddress + "request=json&path=" + data_path + "_meta.json&s3=" + s3bucket,
tree: charonAPIAddress + "request=json&path=" + data_path + "_tree.json&s3=" + s3bucket,
seqs: charonAPIAddress + "request=json&path=" + data_path + "_sequences.json&s3=" + s3bucket,
entropy: charonAPIAddress + "request=json&path=" + data_path + "_entropy.json&s3=" + s3bucket
};
const metaJSONpromise = fetch(paths.meta)
.then((res) => res.json());
const treeJSONpromise = fetch(paths.tree)
const metaJSONpromise = fetch(charonAPIAddress + "request=json&path=" + datasets.datapath + "_meta.json&s3=" + s3bucket)
.then((res) => res.json());
const seqsJSONpromise = fetch(paths.seqs)
const treeJSONpromise = fetch(charonAPIAddress + "request=json&path=" + datasets.datapath + "_tree.json&s3=" + s3bucket)
.then((res) => res.json());
Promise.all([metaJSONpromise, treeJSONpromise, seqsJSONpromise])
Promise.all([metaJSONpromise, treeJSONpromise])
.then((values) => {
/* initial dispatch sets most values */
dispatch({
type: types.NEW_DATASET,
datasetPathName: router.history.location.pathname,
meta: values[0],
tree: values[1],
seqs: values[2],
query: queryString.parse(router.history.location.search)
query: queryString.parse(window.location.search)
});
/* add analysis slider (if applicable) */
// revisit this when applicable
Expand All @@ -130,16 +100,16 @@ export const loadJSONs = (router, s3override = undefined) => { // eslint-disable
// }
/* there still remain a number of actions to do with calculations */
dispatch(updateVisibleTipsAndBranchThicknesses());
dispatch(updateColors());
dispatch(changeColorBy()); // sets colorScales etc
/* validate the reducers */
dispatch({type: types.DATA_VALID});

/* now load the secondary things */
/* should we display (and therefore calculate) the entropy? */
if (values[0].panels.indexOf("entropy") !== -1) {
dispatch(populateEntropyStore(paths));
updateEntropyVisibility(dispatch, getState);
}
if (enableNarratives) {
getNarrative(dispatch, router.history.location.pathname);
getNarrative(dispatch, datasets.datapath);
}

})
Expand All @@ -149,39 +119,21 @@ export const loadJSONs = (router, s3override = undefined) => { // eslint-disable
errors from the lifecycle methods of components
that run while in the middle of this thunk */
dispatch(errorNotification({
message: "Couldn't load " + router.history.location.pathname.replace(/^\//, '') + " dataset"
message: "Couldn't load dataset " + datasets.datapath
}));
console.error("loadMetaAndTreeJSONs error:", err);
router.history.push({pathname: '/', search: ''});
dispatch(changePage({path: "/", push: false}));
});
};
};

export const urlQueryChange = (query) => {
return (dispatch, getState) => {
const { controls, metadata } = getState();
dispatch({
type: types.URL_QUERY_CHANGE,
query,
metadata
});
const newState = getState();
/* working out whether visibility / thickness needs updating is tricky */
dispatch(updateVisibleTipsAndBranchThicknesses());
if (controls.colorBy !== newState.controls.colorBy) {
dispatch(updateColors());
}
};
};


export const changeS3Bucket = (router) => {
export const changeS3Bucket = () => {
return (dispatch, getState) => {
const {datasets} = getState();
const newBucket = datasets.s3bucket === "live" ? "staging" : "live";
// 1. re-fetch the manifest
getManifest(router, dispatch, newBucket);
getManifest(dispatch, newBucket);
// 2. this can *only* be toggled through the app, so we must reload data
dispatch(loadJSONs(router, newBucket));
dispatch(loadJSONs(newBucket));
};
};
90 changes: 90 additions & 0 deletions src/actions/navigation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import parseParams from "../util/parseParams";
import queryString from "query-string";
import { getPost } from "../util/getMarkdown";
import { PAGE_CHANGE, URL_QUERY_CHANGE } from "./types";
import { updateVisibleTipsAndBranchThicknesses } from "./treeProperties";
import { changeColorBy } from "./colors";

// make prefix for data files with fields joined by _ instead of / as in URL
const makeDataPathFromParsedParams = (parsedParams) => {
const tmp_levels = Object.keys(parsedParams.dataset).map((d) => parsedParams.dataset[d]);
tmp_levels.sort((x, y) => x[0] > y[0]);
return tmp_levels.map((d) => d[1]).join("_");
};

/* match URL pathname to datasets (from manifest) */
const getDatapath = (pathname, availableDatasets) => {
if (!availableDatasets) {return undefined;}
const parsedParams = parseParams(pathname, availableDatasets);
if (parsedParams.valid) {
return makeDataPathFromParsedParams(parsedParams);
}
return pathname.replace(/^\//, '').replace(/\/$/, '').replace('/', '_');
};

export const getPageFromPathname = (pathname) => {
if (pathname === "/") {
return "splash";
} else if (pathname.startsWith("/methods")) {
return "methods";
} else if (pathname.startsWith("/posts")) {
return "posts";
} else if (pathname.startsWith("/about")) {
return "about";
}
return "app"; // fallthrough
};

/* changes the state of the page and (perhaps) the dataset displayed.
required argument path is the destination path - e.g. "zika" or "flu/..."
optional argument query additionally changes the URL query in the middleware (has no effect on the reducers)
(if this is left out, then the query is left unchanged by the middleware) (TYPE: object)
optional argument push signals that pushState should be used (has no effect on the reducers)
this is an action, rather than the reducer, as it is not pure (it may change the URL) */
export const changePage = ({path, query = undefined, push = true}) => (dispatch, getState) => {
if (!path) {console.error("changePage called without a path"); return;}
const { datasets } = getState();
const d = {
type: PAGE_CHANGE,
page: getPageFromPathname(path)
};
d.datapath = d.page === "app" ? getDatapath(path, datasets.availableDatasets) : undefined;
if (query !== undefined) { d.query = query; }
if (push) { d.pushState = true; }
/* check if this is "valid" - we can change it here before it is dispatched */
dispatch(d);
/* if a specific post is specified in the URL, fetch it */
if (d.page === "posts" && path !== "/posts") {
dispatch(getPost(`post_${path.replace("/posts/", "")}.md`));
}
};

/* quite different to changePage - obviously only the query is changing, but this is sent to the reducers (it's not in changePage)
required argument query is sent to the reducers and additionally changes the URL query in the middleware (TYPE: object)
optional argument push signals that pushState should be used (has no effect on the reducers) */
export const changePageQuery = ({query, push = true}) => (dispatch, getState) => {
const { controls, metadata } = getState();
dispatch({
type: URL_QUERY_CHANGE,
query,
metadata,
pushState: push
});
const newState = getState();
/* working out whether visibility / thickness needs updating is tricky */
dispatch(updateVisibleTipsAndBranchThicknesses());
if (controls.colorBy !== newState.controls.colorBy) {
dispatch(changeColorBy());
}
};

export const browserBackForward = () => (dispatch, getState) => {
const { datasets } = getState();
/* if the pathname has changed, trigger the changePage action (will trigger new post to load, new dataset to load, etc) */
console.log("broswer back/forward detected. From: ", datasets.urlPath, datasets.urlSearch, "to:", window.location.pathname, window.location.search)
if (datasets.urlPath !== window.location.pathname) {
dispatch(changePage({path: window.location.pathname}));
} else {
dispatch(changePageQuery({query: queryString.parse(window.location.search)}));
}
};
Loading

0 comments on commit b33e2c1

Please sign in to comment.