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 = (
{this.animationButtons()} -
{this.setState({userHasInteractedWithMap: true});}} + id="map" style={{ height: this.state.responsive.height, width: this.state.responsive.width @@ -542,15 +568,37 @@ class Map extends React.Component { 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})); } - fitMapBoundsToData() { - const SWNE = this.getGeoRange(); + moveMapAccordingToData({geoResolutionChanged, visibilityChanged, demeData, demeIndices}) { + /* Given d3 data (may not be drawn) we can compute map bounds & move as appropriate */ + if (!this.state.boundsSet) { + /* we are doing the initial render -> set map to the range of the data in view */ + /* P.S. This is how upon initial loading the map zooms into the data */ + this.fitMapBoundsToData(demeData, demeIndices); + return; + } + + /* if we're animating, then we don't want to move the map all the time */ + if (this.props.animationPlayPauseButton === "Pause") { + return; + } + + if (!this.state.userHasInteractedWithMap || this.props.narrative) { + if (geoResolutionChanged) { + /* changed geo-resolution in narrative mode => reset view */ + this.fitMapBoundsToData(demeData, demeIndices); + } else if (visibilityChanged) { + /* changed visiblity (e.g. filters applied) in narrative mode => reset view */ + this.fitMapBoundsToData(demeData, demeIndices); + } + } + } + + fitMapBoundsToData(demeData, demeIndices) { + const SWNE = this.getGeoRange(demeData, demeIndices); // window.L available because leaflet() was called in componentWillMount + this.state.currentBounds = window.L.latLngBounds(SWNE[0], SWNE[1]); this.state.map.fitBounds(window.L.latLngBounds(SWNE[0], SWNE[1])); } - resetZoomButtonClicked() { - this.fitMapBoundsToData(); - this.maybeDrawDemesAndTransmissions(); - } getStyles = () => { const activeResetZoomButton = true; return { @@ -574,7 +622,10 @@ class Map extends React.Component { {this.props.narrativeMode ? null : ( diff --git a/src/components/tree/reactD3Interface/callbacks.js b/src/components/tree/reactD3Interface/callbacks.js index 6eaa006af..633e85f2e 100644 --- a/src/components/tree/reactD3Interface/callbacks.js +++ b/src/components/tree/reactD3Interface/callbacks.js @@ -75,12 +75,25 @@ export const onBranchClick = function onBranchClick(d) { if (this.props.narrativeMode) return; const root = [undefined, undefined]; let cladeSelected; - if ( - d.n.branch_attrs && - d.n.branch_attrs.labels !== undefined && - d.n.branch_attrs.labels.clade !== undefined - ) { - cladeSelected = d.n.branch_attrs.labels.clade; + // Branches with multiple labels will be used in the order specified by this.props.tree.availableBranchLabels + // (The order of the drop-down on the menu) + // Can't use AA mut lists as zoom labels currently - URL is bad, but also, means every node has a label, and many conflict... + let legalBranchLabels; + // Check has some branch labels, and remove 'aa' ones. + if (d.n.branch_attrs && + d.n.branch_attrs.labels !== undefined) { + legalBranchLabels = Object.keys(d.n.branch_attrs.labels).filter((label) => label !== "aa"); + } + // If has some, then could be clade label - but sort first + if (legalBranchLabels && legalBranchLabels.length) { + const availableBranchLabels = this.props.tree.availableBranchLabels; + // sort the possible branch labels by the order of those available on the tree + legalBranchLabels.sort((a, b) => + availableBranchLabels.indexOf(a) - availableBranchLabels.indexOf(b) + ); + // then use the first! + const key = legalBranchLabels[0]; + cladeSelected = `${key}:${d.n.branch_attrs.labels[key]}`; } if (d.that.params.orientation[0] === 1) root[0] = d.n.arrayIdx; else root[1] = d.n.arrayIdx; diff --git a/src/middleware/changeURL.js b/src/middleware/changeURL.js index c736691cd..288e17c71 100644 --- a/src/middleware/changeURL.js +++ b/src/middleware/changeURL.js @@ -39,6 +39,11 @@ export const changeURLMiddleware = (store) => (next) => (action) => { if (query.n === 0) delete query.n; if (query.tt) delete query.tt; break; + case types.CHANGE_BRANCH_LABEL: + query.branchLabel = state.controls.defaults.selectedBranchLabel === action.value ? + undefined : + action.value; + break; case types.CHANGE_ZOOM: /* entropy panel genome zoom coordinates */ query.gmin = action.zoomc[0] === state.controls.absoluteZoomMin ? undefined : action.zoomc[0]; @@ -88,7 +93,7 @@ export const changeURLMiddleware = (store) => (next) => (action) => { } case types.UPDATE_VISIBILITY_AND_BRANCH_THICKNESS: { query.s = action.selectedStrain ? action.selectedStrain : undefined; - query.clade = action.cladeName ? action.cladeName : undefined; + query.label = action.cladeName ? action.cladeName : undefined; break; } case types.MAP_ANIMATION_PLAY_PAUSE_BUTTON: @@ -174,9 +179,8 @@ export const changeURLMiddleware = (store) => (next) => (action) => { break; } - /* small modifications to desired pathname / query */ Object.keys(query).filter((q) => query[q] === "").forEach((k) => delete query[k]); - let search = queryString.stringify(query).replace(/%2C/g, ',').replace(/%2F/g, '/'); + let search = queryString.stringify(query).replace(/%2C/g, ',').replace(/%2F/g, '/').replace(/%3A/g, ':'); if (search) {search = "?" + search;} if (!pathname.startsWith("/")) {pathname = "/" + pathname;} diff --git a/src/reducers/controls.js b/src/reducers/controls.js index d1bf3eeea..74442bc33 100644 --- a/src/reducers/controls.js +++ b/src/reducers/controls.js @@ -19,7 +19,8 @@ export const getDefaultControlsState = () => { layout: defaultLayout, geoResolution: defaultGeoResolution, filters: {}, - colorBy: defaultColorBy + colorBy: defaultColorBy, + selectedBranchLabel: "none" }; const dateMin = numericToCalendar(currentNumDate() - defaultDateRange); const dateMax = currentCalDate(); @@ -50,7 +51,7 @@ export const getDefaultControlsState = () => { colorBy: defaults.colorBy, colorByConfidence: {display: false, on: false}, colorScale: undefined, - selectedBranchLabel: false, + selectedBranchLabel: "none", analysisSlider: false, geoResolution: defaults.geoResolution, filters: {}, diff --git a/src/util/treeVisibilityHelpers.js b/src/util/treeVisibilityHelpers.js index 941615eb4..dc50d0272 100644 --- a/src/util/treeVisibilityHelpers.js +++ b/src/util/treeVisibilityHelpers.js @@ -1,6 +1,7 @@ import { freqScale, NODE_NOT_VISIBLE, NODE_VISIBLE_TO_MAP_ONLY, NODE_VISIBLE } from "./globals"; import { calcTipCounts } from "./treeCountingHelpers"; import { getTraitFromNode } from "./treeMiscHelpers"; +import { warningNotification } from "../actions/notifications"; export const getVisibleDateRange = (nodes, visibility) => nodes .filter((node, idx) => (visibility[idx] === NODE_VISIBLE && !node.hasChildren)) @@ -30,32 +31,48 @@ export const strainNameToIdx = (nodes, name) => { * @param {string} labelValue label value * @returns {int} the index of the matching node (0 if no match found) */ -export const getIdxMatchingLabel = (nodes, labelName, labelValue) => { +export const getIdxMatchingLabel = (nodes, labelName, labelValue, dispatch) => { let i; + let found = 0; for (i = 0; i < nodes.length; i++) { if ( nodes[i].branch_attrs && nodes[i].branch_attrs.labels !== undefined && nodes[i].branch_attrs.labels[labelName] === labelValue ) { - return i; + if (found === 0) { + found = i; + } else { + console.error(`getIdxMatchingLabel found multiple labels ${labelName}===${labelValue}`); + dispatch(warningNotification({ + message: "Specified Zoom Label Found Multiple Times!", + details: "Multiple nodes in the tree are labelled '"+labelName+" "+labelValue+"' - no zoom performed" + })); + return 0; + } } } - console.error(`getIdxMatchingLabel couldn't find label ${labelName}===${labelValue}`); - return 0; + if (found === 0) { + console.error(`getIdxMatchingLabel couldn't find label ${labelName}===${labelValue}`); + dispatch(warningNotification({ + message: "Specified Zoom Label Value Not Found!", + details: "The label '"+labelName+"' value '"+labelValue+"' was not found in the tree - no zoom performed" + })); + } + return found; }; /** calcBranchThickness ** * returns an array of node (branch) thicknesses based on the tipCount at each node * If the node isn't visible, the thickness is 1. +* Relies on the `tipCount` property of the nodes having been updated. * Pure. * @param nodes - JSON nodes * @param visibility - visibility array (1-1 with nodes) -* @param rootIdx - nodes index of the currently in-view root * @returns array of thicknesses (numeric) */ -const calcBranchThickness = (nodes, visibility, rootIdx) => { - let maxTipCount = nodes[rootIdx].tipCount; +const calcBranchThickness = (nodes, visibility) => { + let maxTipCount = nodes[0].tipCount; /* edge case: no tips selected */ if (!maxTipCount) { maxTipCount = 1; @@ -180,7 +197,7 @@ const calcVisibility = (tree, controls, dates) => { return NODE_VISIBLE; }; -export const calculateVisiblityAndBranchThickness = (tree, controls, dates, {idxOfInViewRootNode = 0, tipSelectedIdx = 0} = {}) => { +export const calculateVisiblityAndBranchThickness = (tree, controls, dates, {tipSelectedIdx = 0} = {}) => { const visibility = tipSelectedIdx ? identifyPathToTip(tree.nodes, tipSelectedIdx) : calcVisibility(tree, controls, dates); /* recalculate tipCounts over the tree - modifies redux tree nodes in place (yeah, I know) */ calcTipCounts(tree.nodes[0], visibility); @@ -188,7 +205,7 @@ export const calculateVisiblityAndBranchThickness = (tree, controls, dates, {idx return { visibility: visibility, visibilityVersion: tree.visibilityVersion + 1, - branchThickness: calcBranchThickness(tree.nodes, visibility, idxOfInViewRootNode), + branchThickness: calcBranchThickness(tree.nodes, visibility), branchThicknessVersion: tree.branchThicknessVersion + 1 }; };