diff --git a/docs-src/docs/advanced-functionality/view-settings.md b/docs-src/docs/advanced-functionality/view-settings.md index 8ca2b5274..6e53b2d0e 100644 --- a/docs-src/docs/advanced-functionality/view-settings.md +++ b/docs-src/docs/advanced-functionality/view-settings.md @@ -20,7 +20,7 @@ Each of these can be overridden by the JSON `display_defaults`, and then the vie * Default phylogeny distance measure is time, if available. * Default geographic resolution is "country", if available. * Default colouring is "country", if available. - +* Default branch labelling is "clade", if available. ## Dataset (JSON) configurable defaults @@ -35,6 +35,7 @@ For instance, if you set `display_defaults.color_by` to `country`, but load the | `distance_measure` | Phylogeny x-axis measure | "div" or "num_date" | | `map_triplicate` | Should the map repeat, so that you can pan further in each direction? | Boolean | | `layout` | Tree layout | "rect", "radial", "clock" or "unrooted | +| `branch_label` | Which set of branch labels are to be displayed | "aa", "lineage" | Furthermore, a JSON property `meta.panels` lists which panels auspice displays. If this is not included, then auspice tries to display as many as possible. @@ -66,7 +67,9 @@ All URL queries modify the view away from the default settings -- if you change | `animate` | Animation settings | | | `n` | Narrative page number | `n=1` goes to the first page | | `s` | Selected strain | `s=1_0199_PF` | -| `clade` | Labeled clade that tree is zoomed to | `clade=B3` (numeric values are buggy) | +| `branchLabel` | Branch labels to display | `branchLabel=aa` | +| `label` | Labeled branch that tree is zoomed to | `label=clade:B3`, `label=lineage:relapse` | +| `clade` | _DEPRECATED_ Labeled clade that tree is zoomed to | `clade=B3` should now become `label=clade:B3` | **See this in action:** diff --git a/src/actions/loadData.js b/src/actions/loadData.js index 91db7b6cc..0b24d4742 100644 --- a/src/actions/loadData.js +++ b/src/actions/loadData.js @@ -181,7 +181,8 @@ const fetchDataAndDispatch = async (dispatch, url, query, narrativeBlocks) => { query, narrativeBlocks, mainTreeName: secondTreeUrl ? mainDatasetUrl : null, - secondTreeName: secondTreeUrl ? secondTreeUrl : null + secondTreeName: secondTreeUrl ? secondTreeUrl : null, + dispatch }) }); @@ -233,7 +234,7 @@ export const loadSecondTree = (secondTreeUrl, firstTreeUrl) => async (dispatch, return; } const oldState = getState(); - const newState = createTreeTooState({treeTooJSON: secondJson.tree, oldState, originalTreeUrl: firstTreeUrl, secondTreeUrl: secondTreeUrl}); + const newState = createTreeTooState({treeTooJSON: secondJson.tree, oldState, originalTreeUrl: firstTreeUrl, secondTreeUrl: secondTreeUrl, dispatch}); dispatch({type: types.TREE_TOO_DATA, ...newState}); }; diff --git a/src/actions/navigation.js b/src/actions/navigation.js index 56ac7e8f4..436beecec 100644 --- a/src/actions/navigation.js +++ b/src/actions/navigation.js @@ -70,7 +70,7 @@ export const changePage = ({ } /* the path (dataset) remains the same... but the state may be modulated by the query */ - const newState = createStateFromQueryOrJSONs({oldState, query}); + const newState = createStateFromQueryOrJSONs({oldState, query, dispatch}); dispatch({ type: URL_QUERY_CHANGE_WITH_COMPUTED_STATE, ...newState, @@ -92,7 +92,7 @@ export const goTo404 = (errorMessage) => ({ export const EXPERIMENTAL_showMainDisplayMarkdown = ({query, queryToDisplay}) => (dispatch, getState) => { - const newState = createStateFromQueryOrJSONs({oldState: getState(), query}); + const newState = createStateFromQueryOrJSONs({oldState: getState(), query, dispatch}); newState.controls.panelsToDisplay = ["EXPERIMENTAL_MainDisplayMarkdown"]; dispatch({ type: URL_QUERY_CHANGE_WITH_COMPUTED_STATE, diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index 3607e166b..c743fe497 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -100,6 +100,10 @@ const modifyStateViaURLQuery = (state, query) => { } else { state.animationPlayPauseButton = "Play"; } + if (query.branchLabel) { + state.selectedBranchLabel = query.branchLabel; + // do not modify the default (only the JSON can do this) + } return state; }; @@ -164,8 +168,8 @@ const modifyStateViaMetadata = (state, metadata) => { console.warn("JSON did not include any filters"); } if (metadata.displayDefaults) { - const keysToCheckFor = ["geoResolution", "colorBy", "distanceMeasure", "layout", "mapTriplicate"]; - const expectedTypes = ["string", "string", "string", "string", "boolean"]; + const keysToCheckFor = ["geoResolution", "colorBy", "distanceMeasure", "layout", "mapTriplicate", "selectedBranchLabel"]; + const expectedTypes = ["string", "string", "string", "string", "boolean", "string"]; for (let i = 0; i < keysToCheckFor.length; i += 1) { if (metadata.displayDefaults[keysToCheckFor[i]]) { @@ -310,7 +314,13 @@ const modifyControlsStateViaTree = (state, tree, treeToo, colorings) => { state.distanceMeasure = state.branchLengthsToDisplay === "divOnly" ? "div" : state.branchLengthsToDisplay === "dateOnly" ? "num_date" : state.distanceMeasure; - state.selectedBranchLabel = tree.availableBranchLabels.indexOf("clade") !== -1 ? "clade" : "none"; + /* if clade is available as a branch label, then set this as the "default". This + is largely due to historical reasons. Note that it *can* and *will* be overridden + by JSON display_defaults and URL query */ + if (tree.availableBranchLabels.indexOf("clade") !== -1) { + state.defaults.selectedBranchLabel = "clade"; + state.selectedBranchLabel = "clade"; + } state.temporalConfidence = getTraitFromNode(tree.nodes[0], "num_date", {confidence: true}) ? {exists: true, display: true, on: false} : @@ -395,6 +405,13 @@ const checkAndCorrectErrorsInState = (state, metadata, query, tree) => { console.warn("JSONs did not include `geoResolutions`"); } + /* show label */ + if (state.selectedBranchLabel && !tree.availableBranchLabels.includes(state.selectedBranchLabel)) { + console.error("Can't set selected branch label to ", state.selectedBranchLabel); + state.selectedBranchLabel = "none"; + state.defaults.selectedBranchLabel = "none"; + } + /* temporalConfidence */ if (state.temporalConfidence.exists) { if (state.layout !== "rect") { @@ -436,7 +453,7 @@ const checkAndCorrectErrorsInState = (state, metadata, query, tree) => { return state; }; -const modifyTreeStateVisAndBranchThickness = (oldState, tipSelected, cladeSelected, controlsState) => { +const modifyTreeStateVisAndBranchThickness = (oldState, tipSelected, zoomSelected, controlsState, dispatch) => { /* calculate new branch thicknesses & visibility */ let tipSelectedIdx = 0; /* check if the query defines a strain to be selected */ @@ -445,16 +462,21 @@ const modifyTreeStateVisAndBranchThickness = (oldState, tipSelected, cladeSelect tipSelectedIdx = strainNameToIdx(oldState.nodes, tipSelected); oldState.selectedStrain = tipSelected; } - if (cladeSelected) { - const cladeSelectedIdx = cladeSelected === 'root' ? 0 : getIdxMatchingLabel(oldState.nodes, "clade", cladeSelected); - oldState.selectedClade = cladeSelected; + if (zoomSelected) { + // Check and fix old format 'clade=B' - in this case selectionClade is just 'B' + const [labelName, labelValue] = zoomSelected.split(":"); + const cladeSelectedIdx = getIdxMatchingLabel(oldState.nodes, labelName, labelValue, dispatch); + oldState.selectedClade = zoomSelected; newIdxRoot = applyInViewNodesToTree(cladeSelectedIdx, oldState); // tipSelectedIdx, oldState); + } else { + oldState.selectedClade = undefined; + newIdxRoot = applyInViewNodesToTree(0, oldState); // tipSelectedIdx, oldState); } const visAndThicknessData = calculateVisiblityAndBranchThickness( oldState, controlsState, {dateMinNumeric: controlsState.dateMinNumeric, dateMaxNumeric: controlsState.dateMaxNumeric}, - {tipSelectedIdx, validIdxRoot: newIdxRoot} + {tipSelectedIdx} ); const newState = Object.assign({}, oldState, visAndThicknessData); @@ -546,6 +568,7 @@ const createMetadataStateFromJSON = (json) => { color_by: "colorBy", geo_resolution: "geoResolution", distance_measure: "distanceMeasure", + branch_label: "selectedBranchLabel", map_triplicate: "mapTriplicate", layout: "layout" }; @@ -574,7 +597,8 @@ export const createStateFromQueryOrJSONs = ({ narrativeBlocks = false, mainTreeName = false, secondTreeName = false, - query + query, + dispatch }) => { let tree, treeToo, entropy, controls, metadata, narrative, frequencies; /* first task is to create metadata, entropy, controls & tree partial state */ @@ -642,7 +666,7 @@ export const createStateFromQueryOrJSONs = ({ /* calculate colours if loading from JSONs or if the query demands change */ - if (json || controls.colorBy !== oldState.colorBy) { + if (json || controls.colorBy !== oldState.controls.colorBy) { const colorScale = calcColorScale(controls.colorBy, controls, tree, treeToo, metadata); const nodeColors = calcNodeColor(tree, colorScale); controls.colorScale = colorScale; @@ -651,16 +675,31 @@ export const createStateFromQueryOrJSONs = ({ tree.nodeColors = nodeColors; } + /* parse the query.label / query.clade */ if (query.clade) { - tree = modifyTreeStateVisAndBranchThickness(tree, undefined, query.clade, controls); - } else { /* if not specifically given in URL, zoom to root */ - tree = modifyTreeStateVisAndBranchThickness(tree, undefined, undefined, controls); + if (!query.label && query.clade !== "root") { + query.label = `clade:${query.clade}`; + } + delete query.clade; } - tree = modifyTreeStateVisAndBranchThickness(tree, query.s, undefined, controls); + if (query.label) { + if (!query.label.includes(":")) { + console.error("Defined a label without ':' separator."); + delete query.label; + } + if (!tree.availableBranchLabels.includes(query.label.split(":")[0])) { + console.error(`Label name ${query.label.split(":")[0]} doesn't exist`); + delete query.label; + } + } + + /* if query.label is undefined then we intend to zoom to the root */ + tree = modifyTreeStateVisAndBranchThickness(tree, query.s, query.label, controls, dispatch); + if (treeToo && treeToo.loaded) { treeToo.nodeColorsVersion = tree.nodeColorsVersion; treeToo.nodeColors = calcNodeColor(treeToo, controls.colorScale); - treeToo = modifyTreeStateVisAndBranchThickness(treeToo, query.s, undefined, controls); + treeToo = modifyTreeStateVisAndBranchThickness(treeToo, query.s, undefined, controls, dispatch); controls = modifyControlsViaTreeToo(controls, treeToo.name); treeToo.tangleTipLookup = constructVisibleTipLookupBetweenTrees(tree.nodes, treeToo.nodes, tree.visibility, treeToo.visibility); } @@ -695,7 +734,8 @@ export const createTreeTooState = ({ treeTooJSON, /* raw json data */ oldState, originalTreeUrl, - secondTreeUrl /* treeToo URL */ + secondTreeUrl, /* treeToo URL */ + dispatch }) => { /* TODO: reconsile choices (filters, colorBys etc) with this new tree */ /* TODO: reconcile query with visibility etc */ @@ -707,7 +747,7 @@ export const createTreeTooState = ({ treeToo.debug = "RIGHT"; controls = modifyControlsStateViaTree(controls, tree, treeToo, oldState.metadata.colorings); controls = modifyControlsViaTreeToo(controls, secondTreeUrl); - treeToo = modifyTreeStateVisAndBranchThickness(treeToo, tree.selectedStrain, undefined, controls); + treeToo = modifyTreeStateVisAndBranchThickness(treeToo, tree.selectedStrain, undefined, controls, dispatch); /* calculate colours if loading from JSONs or if the query demands change */ const colorScale = calcColorScale(controls.colorBy, controls, tree, treeToo, oldState.metadata); diff --git a/src/actions/tree.js b/src/actions/tree.js index e68c0622c..83cac6158 100644 --- a/src/actions/tree.js +++ b/src/actions/tree.js @@ -21,7 +21,7 @@ export const applyInViewNodesToTree = (idx, tree) => { } else { applyToChildren(tree.nodes[validIdxRoot].shell, (d) => {d.inView = true;}); } - } else if (idx !== tree.idxOfInViewRootNode) { /* if shell isn't set yet - have set clade in URL */ + } else { tree.nodes.forEach((d) => { d.inView = false; }); @@ -92,7 +92,7 @@ export const updateVisibleTipsAndBranchThicknesses = ( tree, controls, {dateMinNumeric: controls.dateMinNumeric, dateMaxNumeric: controls.dateMaxNumeric}, - {tipSelectedIdx: tipIdx1, validIdxRoot: rootIdxTree1} + {tipSelectedIdx: tipIdx1} ); const dispatchObj = { type: types.UPDATE_VISIBILITY_AND_BRANCH_THICKNESS, @@ -113,7 +113,7 @@ export const updateVisibleTipsAndBranchThicknesses = ( treeToo, controls, {dateMinNumeric: controls.dateMinNumeric, dateMaxNumeric: controls.dateMaxNumeric}, - {tipSelectedIdx: tipIdx2, validIdxRoot: rootIdxTree2} + {tipSelectedIdx: tipIdx2} ); dispatchObj.tangleTipLookup = constructVisibleTipLookupBetweenTrees(tree.nodes, treeToo.nodes, data.visibility, dataToo.visibility); dispatchObj.visibilityToo = dataToo.visibility; diff --git a/src/components/main/utils.js b/src/components/main/utils.js index 3afb16666..03aee5092 100644 --- a/src/components/main/utils.js +++ b/src/components/main/utils.js @@ -22,11 +22,9 @@ export const calcPanelDims = (grid, panels, narrativeIsDisplayed, availableWidth } /* widths */ if (panels.includes("map") && panels.includes("tree") && !grid) { - console.warn("narrative mode specified full display but we have both map & tree"); bigWidthFraction = 0.5; } if (grid && (!panels.includes("map") || !panels.includes("tree"))) { - console.warn("narrative mode specified grid display but we are not showing both map & tree"); bigWidthFraction = 1; } } diff --git a/src/components/map/map.js b/src/components/map/map.js index a86706800..07ec0abd8 100644 --- a/src/components/map/map.js +++ b/src/components/map/map.js @@ -70,12 +70,12 @@ class Map extends React.Component { demeData: null, transmissionData: null, demeIndices: null, - transmissionIndices: null + transmissionIndices: null, + userHasInteractedWithMap: false }; // 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); this.fitMapBoundsToData = this.fitMapBoundsToData.bind(this); } @@ -137,14 +137,7 @@ class Map extends React.Component { 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 */ - - /* If we are changing the geo resolution in a narrative, then we want to mimic the RESET ZOOM - button by resetting the map bounds to fit the data */ - const mapIsDrawn = !!this.state.map; - if (mapIsDrawn && this.props.narrativeMode && prevProps.geoResolution !== this.props.geoResolution) { - this.fitMapBoundsToData(); - } - this.maybeDrawDemesAndTransmissions(prevProps); /* it's the first time, or they were just removed because we changed dataset or colorby or resolution */ + this.maybeDrawDemesAndTransmissionsAndMoveMap(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 */ @@ -194,7 +187,7 @@ class Map extends React.Component { } } - maybeDrawDemesAndTransmissions() { + maybeDrawDemesAndTransmissionsAndMoveMap(prevProps) { 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; @@ -207,11 +200,6 @@ class Map extends React.Component { 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, @@ -227,6 +215,14 @@ class Map extends React.Component { this.props.dispatch ); + /* Now that the d3 data is created (not yet drawn) we can compute map bounds & move as appropriate */ + this.moveMapAccordingToData({ + geoResolutionChanged: prevProps.geoResolution !== this.props.geoResolution, + visibilityChanged: prevProps.visibility !== this.props.visibility, + demeData, + demeIndices + }); + // const latLongs = this.latLongs(demeData, transmissionData); /* no reference stored, we recompute this for now rather than updating in place */ const d3elems = drawDemesAndTransmissions( demeData, @@ -239,10 +235,6 @@ class Map extends React.Component { 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, @@ -257,7 +249,7 @@ class Map extends React.Component { } /** * removing demes & transmissions, both from the react state & from the DOM. - * They will be created from scratch (& rendered) by `this.maybeDrawDemesAndTransmissions` + * They will be created from scratch (& rendered) by `this.maybeDrawDemesAndTransmissionsAndMoveMap` * This is done when * (a) the dataset has changed * (b) the geo resolution has changed (new transmissions, new deme locations) @@ -287,11 +279,22 @@ class Map extends React.Component { respondToLeafletEvent(leafletEvent) { if (leafletEvent.type === "moveend") { /* zooming and panning */ - if (!this.state.demeData || !this.state.transmissionData) return; + /* Movend: Fired when the center of the map stops changing (e.g. user stopped dragging the map). */ + /* Note - this method is triggered when the map sets up and is essential + for moving the d3 elements to their correct positions. It is later + triggered on pan / zoom (as you'd expect) */ + + if (!this.state.demeData || !this.state.transmissionData) { + /* this seems to happen when the data takes a particularly long time to create. + and the map is ready before the data (??). It's imperitive that this method runs + so if the data's not ready yet we try to rerun it after a short time. + This could be improved */ + window.setTimeout(() => this.respondToLeafletEvent(leafletEvent), 50); + return; + } const newDemes = updateDemeDataLatLong(this.state.demeData, this.state.map); const newTransmissions = updateTransmissionDataLatLong(this.state.transmissionData, this.state.map); - updateOnMoveEnd( newDemes, newTransmissions, @@ -300,20 +303,31 @@ class Map extends React.Component { this.props.dateMaxNumeric, this.props.pieChart ); - this.setState({demeData: newDemes, transmissionData: newTransmissions}); } } - getGeoRange() { + getGeoRange(demeData, demeIndices) { const latitudes = []; const longitudes = []; + /* Loop through the different demes and, if they are in view (i.e. their `count` > 0) + then add their lat-longs to the the respective arrays */ 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); + if (!demeIndices || !demeData) { + /* include them all */ + latitudes.push(demeToLatLongs[deme].latitude); + longitudes.push(demeToLatLongs[deme].longitude); + } else { + demeIndices[deme].forEach((demeIdx) => { + if (demeData[demeIdx] && demeData[demeIdx].count > 0) { + latitudes.push(demeToLatLongs[deme].latitude); + longitudes.push(demeToLatLongs[deme].longitude); + } + }); + } }); } }); @@ -405,6 +419,13 @@ class Map extends React.Component { nextProps.pieChart ); + this.moveMapAccordingToData({ + geoResolutionChanged: nextProps.geoResolution !== this.props.geoResolution, + visibilityChanged: nextProps.visibility !== this.props.visibility, + demeData: newDemes, + demeIndices: this.state.demeIndices + }); + this.setState({ demeData: newDemes, transmissionData: newTransmissions @@ -476,6 +497,9 @@ class Map extends React.Component { L.zoomControlButtons = L.control.zoom({position: "bottomright"}).addTo(map); } + /* Set up leaflet events */ + map.on("moveend", this.respondToLeafletEvent.bind(this)); + this.setState({map}); } @@ -520,7 +544,9 @@ class Map extends React.Component { container = (