diff --git a/.github/workflows/make_prs_for_other_repos.yaml b/.github/workflows/make_prs_for_other_repos.yaml new file mode 100644 index 000000000..5343471ab --- /dev/null +++ b/.github/workflows/make_prs_for_other_repos.yaml @@ -0,0 +1,44 @@ +name: "Make PRs for Nextstrain projects which depend on Auspice" +on: + pull_request: +jobs: + make-pr-on-nextstrain-dot-org: # + runs-on: ubuntu-latest + steps: + - uses: actions/setup-node@v2 + with: + node-version: '14' + - name: Checkout nextstrain.org repo + uses: actions/checkout@v2 + with: + repository: nextstrain/nextstrain.org + - name: Install Auspice from PRs HEAD commit + if: ${{ github.event_name == 'pull_request' }} + # Note: $GITHUB_SHA is _not_ the same commit as the HEAD commit on the PR branch + # see https://github.community/t/github-sha-not-the-same-as-the-triggering-commit/18286/2 + shell: bash + run: | + AUSPICE_COMMIT=$(cat $GITHUB_EVENT_PATH | jq -r .pull_request.head.sha) + echo "auspice_commit=$AUSPICE_COMMIT" >> $GITHUB_ENV + npm ci + npm install nextstrain/auspice#${AUSPICE_COMMIT} + git add package.json package-lock.json + - name: Create Pull Request for testing on nextstrain.org repo + if: ${{ github.event_name == 'pull_request' }} + id: cpr + uses: peter-evans/create-pull-request@v3 + with: + token: ${{ secrets.JAMES_PAT }} + branch: "auspice-pr-${{ github.event.pull_request.number }}" + commit-message: "[testing only] upgrade auspice to ${{ env.auspice_commit }}" + title: 'Test auspice PR ${{ github.event.pull_request.number }}' + body: | + This PR has been created to test Auspice from [PR ${{ github.event.pull_request.number }}](https://github.com/nextstrain/auspice/pull/${{ github.event.pull_request.number }}) + + This message and corresponding commits were automatically created by a GitHub Action from [nextstrain/auspice](https://github.com/nextstrain/auspice) + draft: true + delete-branch: true + - name: Check outputs + run: | + echo "Nextstrain.org PR: ${{ steps.cpr.outputs.pull-request-number }}" + echo "Pull Request URL: ${{ steps.cpr.outputs.pull-request-url }}" \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1622755d5..a5628b8b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,82 @@ title: Changelog --- +## version 2.28.0 - 2021/07/05 + + +* Remove ability to download metadata TSV from GISAID datasets. Replace with acknowledgments TSV. See [PR 1366](https://github.com/nextstrain/auspice/pull/1366). +* Cleanup header fields when downloading metadata TSV, including switch to use name (eg `pango_lineage`) instead of title (eg `PANGO lineage`). See [PR 1367](https://github.com/nextstrain/auspice/pull/1367). +* Update styling of footer text. See [PR 1364](https://github.com/nextstrain/auspice/pull/1364). + +## version 2.27.0 - 2021/06/05 + +* Adjust greyscale colour ramp. +See [PR 1353](https://github.com/nextstrain/auspice/pull/1353) for more. +* (Bugfix) Fixed the situation where the regression toggle would not appear for clock layouts. +See [PR 1352](https://github.com/nextstrain/auspice/pull/1352) for more. + +## version 2.26.0 - 2021/05/25 +* Scatterplot improvements: + * Non-continuous variables can now be used, which allows all colourings (including Genotype, if that's the current colouring) to be scatterplot variables. + * Jittering is applied when the spacing between axis variables is more than 50 pixels. + * See [PR 1346](https://github.com/nextstrain/auspice/pull/1346) for more. +* Normalized frequency values now tend to zero in the absence of data. +See [PR 1325](https://github.com/nextstrain/auspice/pull/1325) for more. +* Colour scale improvements: + * Continuous colourings can provide a scale, which we interpolate between to get the colour scheme + * Custom legend data can be provided, including display text and, for continuous variables, bounds to map legend entries to values in the data. + * Displayed legend entires may be restricted by specifying them in the dataset JSON. + * See [PR 1340](https://github.com/nextstrain/auspice/pull/1340) for more. +* Filtering via the sidebar UI now returns options which match each of the space-separated queries, rather than requiring an exact match of the query. +See [PR 1344](https://github.com/nextstrain/auspice/pull/1344) for more. +* Legend text now takes the maximum available space. +See [PR 1328](https://github.com/nextstrain/auspice/pull/1328) for more. + +## version 2.25.1 - 2021/04/07 +* Bugfix for cases where certain interactions with scatterplot variables would cause auspice to crash. +See [PR 1332](https://github.com/nextstrain/auspice/pull/1332) for more. + + +## version 2.25.0 - 2021/03/31 + +* Scatterplots are now available as a tree layout. +These allow graphs to be created between any two continuous traits (colourings), similar to the "clock" layout but with user-definable variables +Branches and regression lines can be toggled on/off, and nodes which do not define valid values for both variables will be hidden. +Note that the regression line is calculated with a free intercept, which differs from the clock view where we force it to pass through the root. +See [PR 1310](https://github.com/nextstrain/auspice/pull/1310) and [PR 1326](https://github.com/nextstrain/auspice/pull/1326) for more. +* Datasets may now define "data provenance" which will be rendered in the byline. +See [PR 1313](https://github.com/nextstrain/auspice/pull/1313) for more. +* Names within the filtering UI now use the metadata-provided title, which is clearer. +See [PR 1327](https://github.com/nextstrain/auspice/pull/1327) for more. +* Frequency rounding is improved for small values. +See [PR 1301](https://github.com/nextstrain/auspice/pull/1301) for more. +* Node traits may define a URL which will result in the value being displayed as a link. +See [PR 1308](https://github.com/nextstrain/auspice/pull/1308) for more. +* A bug was fixed which caused some datasets to crash auspice when metadata files were dragged on. +See [PR 1319](https://github.com/nextstrain/auspice/pull/1319) for more. + +## version 2.24.1 - 2021/03/19 + +* [bugfix] Fixes a bug introduced in v2.24.0 where certain datasets wouldn't load + +## version 2.24.0 - 2021/03/17 + +* Frequencies are no longer normalized when the data is lacking. +See [PR 1278](https://github.com/nextstrain/auspice/pull/1278) for more. +* Fixed a stack size bug, which mainly affected the TB dataset on certain browsers. +See [PR 1293](https://github.com/nextstrain/auspice/pull/1293) for more. +* Root-to-tip mutations are now displayed in the tip-clicked info box. +See [PR 1280](https://github.com/nextstrain/auspice/pull/1280) for more. +* Datasets may now define the default language. +See [PR 1303](https://github.com/nextstrain/auspice/pull/1303) for more. +* Polish language added. +See [PR 1288](https://github.com/nextstrain/auspice/pull/1288) for more. +* Tips in the tree should no longer be obscured behind the legend. +See [PR 1302](https://github.com/nextstrain/auspice/pull/1302) for more. +* Dates BCE are now correctly displayed in the phylogeny axis. +See [PR 1297](https://github.com/nextstrain/auspice/pull/1297) for more. + + ## version 2.23.0 - 2021/01/28 * [feature] Implement genotype filtering. The sidebar, typing-based filter UI now includes genotypes (for datasets which define mutations on branches). @@ -98,7 +174,7 @@ This version reverts the change to URL parsing introduced in 2.18.2 which broke * Improve parsing of auspice URLs with colon characters in the pathname. See [PR 1210](https://github.com/nextstrain/auspice/pull/1210). ## version 2.18.1 - 2020/08/07 -* Add between-paragraph padding for text rendering in (non-mobile) narratives. +* Add between-paragraph padding for text rendering in (non-mobile) narratives. ## version 2.18.0 - 2020/08/03 * Parse narratives client side. @@ -161,7 +237,7 @@ See [PR 1166](https://github.com/nextstrain/auspice/pull/1166). [See PR 1126](https://github.com/nextstrain/auspice/pull/1126) for more. * Add a toggle for whether or not to show transmission lines on the map. [See PR 1147](https://github.com/nextstrain/auspice/pull/1147) and [PR 1103](https://github.com/nextstrain/auspice/pull/1147) for more. -* Dynamically adjust deme circle size on the map when filtering. +* Dynamically adjust deme circle size on the map when filtering. [See PR 1135](https://github.com/nextstrain/auspice/pull/1135) for more. * Allow the genomic diversity data (the data behind the entropy panel) to be downloaded as a TSV. [See PR 1144](https://github.com/nextstrain/auspice/pull/1144) for more. @@ -172,7 +248,7 @@ See [PR 1166](https://github.com/nextstrain/auspice/pull/1166). #### Other * Temporarily disable integration tests from the GitHub CI. [See PR 1148](https://github.com/nextstrain/auspice/pull/1148) for more. * Add a CC-BY license for the downloaded SVG (screenshots) . [See PR 1140](https://github.com/nextstrain/auspice/pull/1140) for more. -* Improvement in code which decides which footers to show. +* Improvement in code which decides which footers to show. [See PR 1118](https://github.com/nextstrain/auspice/pull/1118) for more. * Documentation improvements -- see [PR 1127](https://github.com/nextstrain/auspice/pull/1127) for more. * Fix an error in map positioning in some narrative slides. [See PR 958](https://github.com/nextstrain/auspice/pull/958) for more. @@ -226,7 +302,7 @@ This will allow narratives to render slides with the CI displayed. [See PR 1046](https://github.com/nextstrain/auspice/pull/1046) * Add the ability to export per-strain metadata of only those strains currently being displayed. [See PR 1067](https://github.com/nextstrain/auspice/pull/1067) -* Move to using `react-icons` which allows the removal of the font-awesome CSS. +* Move to using `react-icons` which allows the removal of the font-awesome CSS. This improves ease-of-use and reduces the bundle size. [See PR 1065](https://github.com/nextstrain/auspice/pull/1065), [PR 1041](https://github.com/nextstrain/auspice/pull/1041) & [PR 1073](https://github.com/nextstrain/auspice/pull/1073) @@ -306,8 +382,8 @@ This improves ease-of-use and reduces the bundle size. * Update Spanish locale data (still in a partially complete state). See [commit f9c8ad2](https://github.com/nextstrain/auspice/commit/f9c8ad209a1e5d304fc6f15ec708f3d0be3dec43) * Reorganisation and general improvements to documentation around contributing to auspice development. -[See PR 978](https://github.com/nextstrain/auspice/pull/978), -[commit 707f563](https://github.com/nextstrain/auspice/commit/707f563aab0a62e0504e393af0cd23da3e4504e0) and +[See PR 978](https://github.com/nextstrain/auspice/pull/978), +[commit 707f563](https://github.com/nextstrain/auspice/commit/707f563aab0a62e0504e393af0cd23da3e4504e0) and [commit 9f002c9](https://github.com/nextstrain/auspice/commit/9f002c96a676e4603b7b9c06ef7df8a26be6d04c) * Fix a bug where the narrative table styling introduced in 2.9.0 were applied outside the narratives. * Fix all linting errors and warnings (potentially the first time this has happened!) diff --git a/bundlesize.config.json b/bundlesize.config.json index 29bb26276..0b5d9cea7 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -6,7 +6,7 @@ }, { "path": "./dist/auspice.chunk.+([0-9]).bundle.*.js", - "maxSize": "75 kB" + "maxSize": "100 kB" }, { "path": "./dist/auspice.chunk.core-vendors.bundle.*.js", @@ -14,7 +14,7 @@ }, { "path": "./dist/auspice.chunk.other-vendors.bundle.*.js", - "maxSize": "85 kB" + "maxSize": "150 kB" }, { "path": "./dist/auspice.chunk.locales.bundle.*.js", diff --git a/docs/advanced-functionality/view-settings.md b/docs/advanced-functionality/view-settings.md index af43a8ee1..e4cce272f 100644 --- a/docs/advanced-functionality/view-settings.md +++ b/docs/advanced-functionality/view-settings.md @@ -33,11 +33,11 @@ For instance, if you set `display_defaults.color_by` to `country`, but load the | `geo_resolution` | Geographic resolution | "country" | | `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 | +| `layout` | Tree layout | "rect", "radial", "unrooted", "clock" or "scatter" | | `branch_label` | Which set of branch labels are to be displayed | "aa", "lineage" | | `panels` | List of panels which (if available) are to be displayed | ["tree", "map"] | | `transmission_lines`| Should transmission lines (if available) be rendered on the map? | Boolean | - +| `language` | Language to display Auspice in | "ja" | Note that `meta.display_defaults.panels` (optional) differs from `meta.panels` (required), where the latter lists the possible panels that auspice may display for the dataset. See the [JSON schema](https://github.com/nextstrain/augur/blob/master/augur/data/schema-export-v2.json) for more details. @@ -58,6 +58,11 @@ All URL queries modify the view away from the default settings -- if you change | `r` | Geographic resolution | `r=region` | | `m` | Phylogeny x-axis measure | `m=div` | | `l` | Phylogeny layout | `l=clock` | +| `scatterX` | Scatterplot X variable | `scatterX=num_date` | +| `scatterY` | Scatterplot Y variable | `scatterY=num_date` | +| `branches` | Hide branches | `branches=hide` | +| `regression` | Show/Hide regression line | `regression=hide`, `regression=show` | +| `transmissions` | Hide transmission lines | `transmissions=hide`| | `lang` | Language | `lang=ja` (Japanese) | | `dmin` | Temporal range (minimum) | `dmin=2008-05-13` | | `dmax` | Temporal range (maximum) | `dmax=2010-05-13` | diff --git a/package.json b/package.json index 4ccfe3ec7..c56c2c827 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "auspice", - "version": "2.23.0", + "version": "2.28.0", "description": "Web app for visualizing pathogen evolution", "author": "James Hadfield, Trevor Bedford and Richard Neher", "license": "AGPL-3.0-only", diff --git a/src/actions/frequencies.js b/src/actions/frequencies.js index b49b2bf86..8987bc1fb 100644 --- a/src/actions/frequencies.js +++ b/src/actions/frequencies.js @@ -1,13 +1,15 @@ import { debounce } from 'lodash'; import * as types from "./types"; import { timerStart, timerEnd } from "../util/perf"; -import { computeMatrixFromRawData, processFrequenciesJSON } from "../util/processFrequencies"; +import { computeMatrixFromRawData, checkIfNormalizableFromRawData, processFrequenciesJSON } from "../util/processFrequencies"; export const loadFrequencies = (json) => (dispatch, getState) => { const { tree, controls } = getState(); + const { data, pivots, matrix, projection_pivot, normalizeFrequencies } = processFrequenciesJSON(json, tree, controls); dispatch({ type: types.LOAD_FREQUENCIES, - frequencies: {loaded: true, version: 1, ...processFrequenciesJSON(json, tree, controls)} + frequencies: {loaded: true, version: 1, data, pivots, matrix, projection_pivot}, + normalizeFrequencies }); }; @@ -22,6 +24,10 @@ const updateFrequencyData = (dispatch, getState) => { console.error("Race condition in updateFrequencyData. Frequencies data not in state. Matrix can't be calculated."); return; } + + const normalizeFrequencies = controls.normalizeFrequencies && + checkIfNormalizableFromRawData(frequencies.data, frequencies.pivots, tree.nodes, tree.visibility); + const matrix = computeMatrixFromRawData( frequencies.data, frequencies.pivots, @@ -29,10 +35,10 @@ const updateFrequencyData = (dispatch, getState) => { tree.visibility, controls.colorScale, controls.colorBy, - controls.normalizeFrequencies + normalizeFrequencies ); timerEnd("updateFrequencyData"); - dispatch({type: types.FREQUENCY_MATRIX, matrix}); + dispatch({type: types.FREQUENCY_MATRIX, matrix, normalizeFrequencies}); }; /* debounce works better than throttle, as it _won't_ update while events are still coming in (e.g. dragging the date slider) */ diff --git a/src/actions/layout.js b/src/actions/layout.js new file mode 100644 index 000000000..098129a0f --- /dev/null +++ b/src/actions/layout.js @@ -0,0 +1,37 @@ +import { CHANGE_LAYOUT } from "./types"; +import { validateScatterVariables, addScatterAxisInfo} from "../util/scatterplotHelpers"; + +/** + * Redux Thunk to change a layout, including aspects of the scatterplot / clock layouts. + */ +export const changeLayout = ({layout, showBranches, showRegression, x, xLabel, y, yLabel}) => { + return (dispatch, getState) => { + if (window.NEXTSTRAIN && window.NEXTSTRAIN.animationTickReference) return; + const { controls, tree, metadata } = getState(); + + if (layout==="rect" || layout==="unrooted" || layout==="radial") { + dispatch({type: CHANGE_LAYOUT, layout, scatterVariables: controls.scatterVariables, canRenderBranchLabels: true}); + return; + } + + let scatterVariables = (layout==="clock" || layout==="scatter") ? + validateScatterVariables(controls, metadata, tree, layout==="clock") : // occurs when switching to this layout + controls.scatterVariables; + + if (x && xLabel) scatterVariables = {...scatterVariables, ...addScatterAxisInfo({x, xLabel}, "x", controls, tree, metadata)}; + if (y && yLabel) scatterVariables = {...scatterVariables, ...addScatterAxisInfo({y, yLabel}, "y", controls, tree, metadata)}; + if (showBranches!==undefined) scatterVariables.showBranches = showBranches; + if (showRegression!==undefined) scatterVariables.showRegression = showRegression; + if (layout==="scatter" && (!scatterVariables.xContinuous || !scatterVariables.yContinuous)) { + scatterVariables.showRegression= false; + } + + dispatch({ + type: CHANGE_LAYOUT, + layout: layout || controls.layout, + scatterVariables: {...scatterVariables}, // ensures redux is aware of change + canRenderBranchLabels: scatterVariables.showBranches + }); + + }; +}; diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.js index 4eff90912..b6e4c3058 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.js @@ -12,8 +12,9 @@ import { treeJsonToState } from "../util/treeJsonProcessing"; import { entropyCreateState } from "../util/entropyCreateStateFromJsons"; import { determineColorByGenotypeMutType, calcNodeColor } from "../util/colorHelpers"; import { calcColorScale, createVisibleLegendValues } from "../util/colorScale"; -import { computeMatrixFromRawData } from "../util/processFrequencies"; +import { computeMatrixFromRawData, checkIfNormalizableFromRawData } from "../util/processFrequencies"; import { applyInViewNodesToTree } from "../actions/tree"; +import { validateScatterVariables } from "../util/scatterplotHelpers"; import { isColorByGenotype, decodeColorByGenotype, decodeGenotypeFilters, encodeGenotypeFilters } from "../util/getGenotype"; import { getTraitFromNode, getDivFromNode, collectGenotypeStates } from "../util/treeMiscHelpers"; import { collectAvailableTipLabelOptions } from "../components/controls/choose-tip-label"; @@ -145,6 +146,12 @@ const modifyStateViaURLQuery = (state, query) => { state.showTransmissionLines = false; } } + /* parse queries which may modify scatterplot-like views. These will be validated before dispatch. */ + if (query.branches==="hide") state.scatterVariables.showBranches = false; + if (query.regression==="show") state.scatterVariables.showRegression = true; + if (query.regression==="hide") state.scatterVariables.showRegression = false; + if (query.scatterX) state.scatterVariables.x = query.scatterX; + if (query.scatterY) state.scatterVariables.y = query.scatterY; return state; }; @@ -176,6 +183,7 @@ const restoreQueryableStateToDefaults = (state) => { state["panelLayout"] = calcBrowserDimensionsInitialState().width > twoColumnBreakpoint ? "grid" : "full"; state.panelsToDisplay = state.panelsAvailable.slice(); state.tipLabelKey = strainSymbol; + state.scatterVariables = {}; // console.log("state now", state); return state; }; @@ -211,6 +219,7 @@ const modifyStateViaMetadata = (state, metadata) => { }); } else { console.warn("JSON did not include any filters"); + state.filtersInFooter = []; } state.filters[strainSymbol] = []; state.filters[genotypeSymbol] = []; // this doesn't necessitate that mutations are defined @@ -241,6 +250,8 @@ const modifyStateViaMetadata = (state, metadata) => { } } } + } else { + metadata.displayDefaults = {}; // allows code to rely on `displayDefaults` existing } if (metadata.panels) { @@ -551,6 +562,28 @@ const checkAndCorrectErrorsInState = (state, metadata, query, tree, viewingNarra state.sidebarOpen=true; } + /* if we are starting in a scatterplot-like layout, we need to ensure we have `scatterVariables` + If not, we deliberately don't instantiate them, so that they are instantiated when first + triggering a scatterplot, thus defaulting to the colorby in use at that time */ + // todo: these should be JSON definable (via display_defaults) + if (state.layout==="scatter" || state.layout==="clock") { + state.scatterVariables = validateScatterVariables( + state, metadata, tree, state.layout==="clock" + ); + if (query.scatterX && query.scatterX!==state.scatterVariables.x) delete query.scatterX; + if (query.scatterY && query.scatterY!==state.scatterVariables.y) delete query.scatterY; + if (state.layout==="clock") { + delete query.scatterX; + delete query.scatterY; + } + } else { + state.scatterVariables = {}; + delete query.scatterX; + delete query.scatterY; + delete query.regression; + delete query.branches; + } + return state; }; @@ -646,6 +679,9 @@ const createMetadataStateFromJSON = (json) => { if (json.meta.genome_annotations) { metadata.genomeAnnotations = json.meta.genome_annotations; } + if (json.meta.data_provenance) { + metadata.dataProvenance = json.meta.data_provenance; + } if (json.meta.filters) { metadata.filters = json.meta.filters; } @@ -661,6 +697,7 @@ const createMetadataStateFromJSON = (json) => { branch_label: "selectedBranchLabel", map_triplicate: "mapTriplicate", layout: "layout", + language: "language", sidebar: "sidebar", panels: "panels", transmission_lines: "showTransmissionLines" @@ -829,6 +866,18 @@ export const createStateFromQueryOrJSONs = ({ /* update frequencies if they exist (not done for new JSONs) */ if (frequencies && frequencies.loaded) { frequencies.version++; + + const allowNormalization = checkIfNormalizableFromRawData( + frequencies.data, + frequencies.pivots, + tree.nodes, + tree.visibility + ); + + if (!allowNormalization) { + controls.normalizeFrequencies = false; + } + frequencies.matrix = computeMatrixFromRawData( frequencies.data, frequencies.pivots, diff --git a/src/components/controls/choose-branch-labelling.js b/src/components/controls/choose-branch-labelling.js index 4313cdc39..5705f3f1e 100644 --- a/src/components/controls/choose-branch-labelling.js +++ b/src/components/controls/choose-branch-labelling.js @@ -9,7 +9,8 @@ import { controlsWidth } from "../../util/globals"; @connect((state) => ({ selected: state.controls.selectedBranchLabel, - available: state.tree.availableBranchLabels + available: state.tree.availableBranchLabels, + canRenderBranchLabels: state.controls.canRenderBranchLabels })) class ChooseBranchLabelling extends React.Component { constructor(props) { @@ -17,6 +18,7 @@ class ChooseBranchLabelling extends React.Component { this.change = (value) => {this.props.dispatch({type: CHANGE_BRANCH_LABEL, value: value.value});}; } render() { + if (!this.props.canRenderBranchLabels) return null; const { t } = this.props; return (
diff --git a/src/components/controls/choose-layout.js b/src/components/controls/choose-layout.js index 9bd5cdf96..d2332862f 100644 --- a/src/components/controls/choose-layout.js +++ b/src/components/controls/choose-layout.js @@ -1,20 +1,22 @@ -/* eslint-disable react/jsx-no-bind */ -/* ^^^ We can get away with this because doesn't rerender frequently, but fixes are welcome */ - import React from "react"; import PropTypes from 'prop-types'; import { connect } from "react-redux"; import styled, { withTheme } from 'styled-components'; import { withTranslation } from 'react-i18next'; +import Select from "react-select/lib/Select"; import * as icons from "../framework/svg-icons"; -import { CHANGE_LAYOUT } from "../../actions/types"; -import { analyticsControlsEvent } from "../../util/googleAnalytics"; +import { controlsWidth } from "../../util/globals"; +import { collectAvailableScatterVariables} from "../../util/scatterplotHelpers"; import { SidebarSubtitle, SidebarButton } from "./styles"; +import { changeLayout } from "../../actions/layout"; +import Toggle from "./toggle"; + const RectangularTreeIcon = withTheme(icons.RectangularTree); const RadialTreeIcon = withTheme(icons.RadialTree); const UnrootedTreeIcon = withTheme(icons.UnrootedTree); const ClockIcon = withTheme(icons.Clock); +const ScatterIcon = withTheme(icons.Scatter); export const RowContainer = styled.div` padding: 0px 5px 1px 5px; @@ -23,6 +25,9 @@ export const RowContainer = styled.div` @connect((state) => { return { layout: state.controls.layout, + scatterVariables: state.controls.scatterVariables, + colorings: state.metadata.colorings, + colorBy: state.controls.colorBy, showTreeToo: state.controls.showTreeToo, branchLengthsToDisplay: state.controls.branchLengthsToDisplay }; @@ -32,29 +37,65 @@ class ChooseLayout extends React.Component { layout: PropTypes.string.isRequired, dispatch: PropTypes.func.isRequired } + renderScatterplotAxesSelector() { + const options = collectAvailableScatterVariables(this.props.colorings, this.props.colorBy); + const selectedX = options.filter((o) => o.value===this.props.scatterVariables.x)[0]; + const selectedY = options.filter((o) => o.value===this.props.scatterVariables.y)[0]; + const miscSelectProps = {options, clearable: false, searchable: false, multi: false, valueKey: "label"}; - handleChangeLayoutClicked(userSelectedLayout) { - const loopRunning = window.NEXTSTRAIN && window.NEXTSTRAIN.animationTickReference; - if (!loopRunning) { - if (userSelectedLayout === "rect") { - analyticsControlsEvent("change-layout-rectangular"); - } else if (userSelectedLayout === "radial") { - analyticsControlsEvent("change-layout-radial"); - } else if (userSelectedLayout === "unrooted") { - analyticsControlsEvent("change-layout-unrooted"); - } else if (userSelectedLayout === "clock") { - analyticsControlsEvent("change-layout-clock"); - } else { - console.warn("Odd... controls/choose-layout.js tried to set a layout we don't offer..."); - } + return ( + <> + + x + + this.props.dispatch(changeLayout({y: value.value, yLabel: value.label}))} + /> + + + + ); + } + renderBranchToggle() { + return ( + + this.props.dispatch(changeLayout({showBranches: !this.props.scatterVariables.showBranches}))} + label={"Show branches"} + /> + + ); + } + renderRegressionToggle() { + if (this.props.layout === "scatter" && !(this.props.scatterVariables.xContinuous && this.props.scatterVariables.yContinuous)) { + return null; // scatterplot regressions only available if _both_ variables are continuous } + return ( + + this.props.dispatch(changeLayout({showRegression: !this.props.scatterVariables.showRegression}))} + label={"Show regression"} + /> + + ); } - render() { const { t } = this.props; if (this.props.showTreeToo) return null; @@ -68,7 +109,7 @@ class ChooseLayout extends React.Component { this.props.dispatch(changeLayout({layout: "rect"}))} > {t("sidebar:rectangular")} @@ -77,7 +118,7 @@ class ChooseLayout extends React.Component { this.props.dispatch(changeLayout({layout: "radial"}))} > {t("sidebar:radial")} @@ -86,11 +127,12 @@ class ChooseLayout extends React.Component { this.props.dispatch(changeLayout({layout: "unrooted"}))} > {t("sidebar:unrooted")} + { /* Show clock view only if both time and divergence are defined for the tree */ } { this.props.branchLengthsToDisplay === "divAndDate" ? ( @@ -98,14 +140,29 @@ class ChooseLayout extends React.Component { this.props.dispatch(changeLayout({layout: "clock"}))} > {t("sidebar:clock")} + {selected==="clock" && this.renderBranchToggle()} + {selected==="clock" && this.renderRegressionToggle()} ) : null } + { /* Scatterplot view -- when selected this shows x & y dropdown selectors etc */ } + + + this.props.dispatch(changeLayout({layout: "scatter"}))} + > + {t("sidebar:scatter")} + + {selected==="scatter" && this.renderScatterplotAxesSelector()} + {selected==="scatter" && this.renderBranchToggle()} + {selected==="scatter" && this.renderRegressionToggle()} +
); } @@ -113,3 +170,34 @@ class ChooseLayout extends React.Component { const WithTranslation = withTranslation()(ChooseLayout); export default WithTranslation; + + +const ScatterVariableContainer = styled.div` + display: flex; + flex-direction: row; + align-content: stretch; + flex-wrap: nowrap; + height: 100%; + order: 0; + flex-grow: 0; + flex-shrink: 1; + flex-basis: auto; + align-self: auto; + padding: ${(props) => props.padAbove?"2":"0"}px 0px 2px 15px; +`; + +const ScatterAxisName = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + min-width: 18px; + font-size: 14px; + font-weight: 400; + font-family: ${(props) => props.theme["font-family"]}; + color: ${(props) => props.theme.color}; +`; + +const ScatterSelectContainer = styled.div` + width: ${controlsWidth-18}px; + font-size: 12px; +`; diff --git a/src/components/controls/choose-metric.js b/src/components/controls/choose-metric.js index 25f367b02..ed970fd05 100644 --- a/src/components/controls/choose-metric.js +++ b/src/components/controls/choose-metric.js @@ -11,6 +11,7 @@ import Toggle from "./toggle"; @connect((state) => { return { distanceMeasure: state.controls.distanceMeasure, + layout: state.controls.layout, showTreeToo: state.controls.showTreeToo, branchLengthsToDisplay: state.controls.branchLengthsToDisplay, temporalConfidence: state.controls.temporalConfidence @@ -20,6 +21,7 @@ class ChooseMetric extends React.Component { render() { const { t } = this.props; if (this.props.branchLengthsToDisplay !== "divAndDate") return null; + if (this.props.layout==="scatter" || this.props.layout==="clock") return null; /* this used to be added to the first SidebarSubtitle const potentialOffset = this.props.showTreeToo ? {marginTop: "0px"} : {}; */ return ( diff --git a/src/components/controls/filter.js b/src/components/controls/filter.js index 763c2898c..afeca92ef 100644 --- a/src/components/controls/filter.js +++ b/src/components/controls/filter.js @@ -21,6 +21,7 @@ const DEBOUNCE_TIME = 200; @connect((state) => { return { activeFilters: state.controls.filters, + colorings: state.metadata.colorings, totalStateCounts: state.tree.totalStateCounts, nodes: state.tree.nodes }; @@ -39,6 +40,9 @@ class FilterData extends React.Component { } }; } + getFilterTitle(filterName) { + return this.props.colorings && this.props.colorings[filterName] && this.props.colorings[filterName].title || filterName; + } makeOptions = () => { /** * The expects, i.e. each element + * is an object with `value` and `label` props), return one option. + * First scans through a list of values to try (`tryTheseFirst`) + * Will not return an option whose key matches `notThisValue` + */ +export function getFirstMatchingScatterVariable(options, tryTheseFirst, notThisValue) { + const availableValues = options.map((opt) => opt.value); + for (let i=0; i { const nodeAttr = getTipColorAttribute(node, colorScale); if (colorScale.continuous) { return (nodeAttr <= colorScale.legendBounds[selectedLegendItem][1]) && - (nodeAttr >= colorScale.legendBounds[selectedLegendItem][0]); + (nodeAttr > colorScale.legendBounds[selectedLegendItem][0]); } return nodeAttr === selectedLegendItem; }; diff --git a/src/util/treeMiscHelpers.js b/src/util/treeMiscHelpers.js index 106fa286b..82f4af346 100644 --- a/src/util/treeMiscHelpers.js +++ b/src/util/treeMiscHelpers.js @@ -69,15 +69,42 @@ export const getFullAuthorInfoFromNode = (node) => export const getAccessionFromNode = (node) => { /* see comment at top of this file */ - if (node.node_attrs && node.node_attrs.accession) { - return node.node_attrs.accession; + let accession, url; + if (node.node_attrs) { + if (isValueValid(node.node_attrs.accession)) { + accession = node.node_attrs.accession; + } + url = validateUrl(node.node_attrs.url); } - return undefined; + return {accession, url}; }; /* see comment at top of this file */ -export const getUrlFromNode = (node) => - (node.node_attrs && node.node_attrs.url) ? node.node_attrs.url : undefined; +export const getUrlFromNode = (node, trait) => { + if (!node.node_attrs || !node.node_attrs[trait]) return undefined; + return validateUrl(node.node_attrs[trait].url); +}; + +/** + * Check if a URL seems valid & return it. + * For historical reasons, we allow URLs to be defined as `http[s]_` and coerce these into `http[s]:` + * URls are interpreted by `new URL()` and thus may be returned with a trailing slash + * @param {String} url URL string to validate + * @returns {String|undefined} potentially modified URL string or `undefined` (if it doesn't seem valid) + */ +function validateUrl(url) { + if (url===undefined) return undefined; // urls are optional, so return early to avoid the console warning + try { + if (typeof url !== "string") throw new Error(); + if (url.startsWith("http_")) url = url.replace("http_", "http:"); // eslint-disable-line no-param-reassign + if (url.startsWith("https_")) url = url.replace("https_", "https:"); // eslint-disable-line no-param-reassign + const urlObj = new URL(url); + return urlObj.href; + } catch (err) { + console.warn(`Dataset provided the invalid URL ${url}`); + return undefined; + } +} /** * Traverses the tree and returns a set of genotype states such as @@ -100,3 +127,47 @@ export function collectGenotypeStates(nodes) { }); return observedStates; } + +/** + * Collect mutations from node `fromNode` to the root. + * Reversions (e.g. root -> AB -> BA -> fromNode) will not be reported + * Multiple mutations (e.g. root -> AB -> BC -> fromNode) will be represented as AC + * We may want to expand this function to take a second argument as the "stopping node" + * @param {TreeNode} fromNode + */ +export const collectMutations = (fromNode, include_nuc=false) => { + const mutations = {}; + const walk = (n) => { + if (n.branch_attrs && n.branch_attrs.mutations && Object.keys(n.branch_attrs.mutations).length) { + Object.entries(n.branch_attrs.mutations).forEach(([gene, muts]) => { + if ((gene === "nuc" && include_nuc) || gene !== "nuc") { + if (!mutations[gene]) mutations[gene] = {}; + muts.forEach((m) => { + const [from, pos, to] = [m.slice(0, 1), m.slice(1, -1), m.slice(-1)]; // note: `pos` is a string + if (mutations[gene][pos]) { + mutations[gene][pos][0] = from; // mutation already seen => update ancestral state. + } else { + mutations[gene][pos] = [from, to]; + } + }); + } + }); + } + const nIdx = n.arrayIdx; + const parent = n.parent; + if (parent && parent.arrayIdx !== nIdx) { + walk(parent); + } + }; + walk(fromNode); + // update structure to be returned + Object.keys(mutations).forEach((gene) => { + mutations[gene] = Object.entries(mutations[gene]) + .map(([pos, [from, to]]) => { + if (from===to) return undefined; // reversion to ancestral (root) state + return `${from}${pos}${to}`; + }) + .filter((value) => !!value); + }); + return mutations; +}; diff --git a/src/version.js b/src/version.js index 63faa1818..4e37eadc4 100644 --- a/src/version.js +++ b/src/version.js @@ -1,4 +1,4 @@ -const version = "2.23.0"; +const version = "2.28.0"; module.exports = { version diff --git a/test/colorScales.test.js b/test/colorScales.test.js index 86931a87a..fb61a75a1 100644 --- a/test/colorScales.test.js +++ b/test/colorScales.test.js @@ -1,4 +1,4 @@ -import { unknownColor, createScaleFromProvidedScaleMap } from "../src/util/colorScale"; +import { unknownColor, createNonContinuousScaleFromProvidedScaleMap } from "../src/util/colorScale"; const crypto = require("crypto"); @@ -7,7 +7,7 @@ const crypto = require("crypto"); test("Test that a JSON-provided scale results in nodes using the correct colours", () => { const nodes = makeNodes(50, {country: ['A', 'B', 'D', undefined]}); const providedScale = [['A', 'AMBER'], ['B', 'BLUE'], ['C', "CYAN"]]; - const {colorScale} = createScaleFromProvidedScaleMap("country", providedScale, nodes, undefined); + const {colorScale} = createNonContinuousScaleFromProvidedScaleMap("country", providedScale, nodes, undefined); expect(colorScale('A')).toEqual("AMBER"); expect(colorScale('B')).toEqual("BLUE"); expect(colorScale('C')).toEqual("CYAN"); // Note that 'C' is in the colorMap, even though it is not defined on tree @@ -18,7 +18,7 @@ test("Test that a JSON-provided scale results in nodes using the correct colours test("Test that a misformatted JSON-provided scale throws an error", () => { const nodes = makeNodes(50, {country: ['A', 'B', 'D', undefined]}); const providedScale = {A: 'AMBER', B: 'BLUE', C: "CYAN"}; - expect(() => {createScaleFromProvidedScaleMap("country", providedScale, nodes, undefined);}) + expect(() => {createNonContinuousScaleFromProvidedScaleMap("country", providedScale, nodes, undefined);}) .toThrow(/has defined a scale which wasn't an array/); }); diff --git a/test/dates.test.js b/test/dates.test.js index 8ae5e9154..463a57f56 100644 --- a/test/dates.test.js +++ b/test/dates.test.js @@ -91,4 +91,5 @@ test("dates are prettified as expected", () => { expect(prettifyDate("YEAR", "2020-01-01")).toStrictEqual("2020"); expect(prettifyDate("MONTH", "2020-01-05")).toStrictEqual("2020-Jan-05"); expect(prettifyDate("MONTH", "2020-01-01")).toStrictEqual("2020-Jan"); + expect(prettifyDate("CENTURY", "-3000-01-01")).toStrictEqual("-3000"); // BCE }); diff --git a/test/treeHelpers.test.js b/test/treeHelpers.test.js new file mode 100644 index 000000000..362e820a6 --- /dev/null +++ b/test/treeHelpers.test.js @@ -0,0 +1,89 @@ +import { collectMutations, getUrlFromNode, getAccessionFromNode } from "../src/util/treeMiscHelpers"; +import { treeJsonToState } from "../src/util/treeJsonProcessing"; + +/** + * `dummyTree` is a simple tree with two tips: tipX and tipY + * root to tipX mutations: + * single mutation at position 100 in gene "GENE" of A->B + * a reversion of C->D->C at position 200 + * multiple mutations at 300 E->F->G + * root to tipY mutations: + * ["A100B", "C200D", "E300F"] + */ +const dummyTree = treeJsonToState({ + name: "ROOT", + children: [ + { + name: "node1", + branch_attrs: {mutations: {GENE: ["A100B", "C200D", "E300F"]}}, + children: [ + { // start 1st child of node1 + name: "node1.1", + branch_attrs: {mutations: {GENE: ["D200C", "F300G"]}}, + children: [ + { + name: "tipX" + } + ] + }, + { // start 2nd child of node1 + name: "tipY" + } + ] + } + ] +}).nodes; + + +test("Tip->root mutations are correctly parsed", () => { + const tipXMutations = collectMutations(getNodeByName(dummyTree, "tipX")).GENE; + const tipYMutations = collectMutations(getNodeByName(dummyTree, "tipY")).GENE; + expect(tipXMutations.sort()) + .toEqual(["A100B", "E300G"].sort()); // note that pos 200 (reversion) has no mutations here + expect(tipYMutations.sort()) + .toEqual(["A100B", "C200D", "E300F"].sort()); +}); + +function getNodeByName(tree, name) { + let namedNode; + const recurse = (node) => { + if (node.name === name) namedNode = node; + else if (node.children) node.children.forEach((n) => recurse(n)); + }; + recurse(tree[0]); + return namedNode; +} + +describe('Extract various values from node_attrs', () => { + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + // the following test also covers `validateUrl` + test("getUrlFromNode correctly handles various URLs", () => { + expect(getUrlFromNode({}, "trait")).toEqual(undefined); // no node_attrs on node + expect(getUrlFromNode({node_attrs: {}}, "trait")).toEqual(undefined); // no "trait" defined in node_attrs + expect(getUrlFromNode({node_attrs: {trait: "str_value"}}, "trait")).toEqual(undefined); // incorrectly formatted "trait" + expect(getUrlFromNode({node_attrs: {trait: {}}}, "trait")).toEqual(undefined); // correctly formatted "trait", no URL + expect(getUrlFromNode({node_attrs: {trait: {url: 1234}}}, "trait")).toEqual(undefined); // invalid URL + expect(getUrlFromNode({node_attrs: {trait: {url: "bad url"}}}, "trait")).toEqual(undefined); // invalid URL + expect(getUrlFromNode({node_attrs: {trait: {url: "https://nextstrain.org"}}}, "trait")).toEqual("https://nextstrain.org/"); + expect(getUrlFromNode({node_attrs: {trait: {url: "http://nextstrain.org"}}}, "trait")).toEqual("http://nextstrain.org/"); + expect(getUrlFromNode({node_attrs: {trait: {url: "https_//nextstrain.org"}}}, "trait")).toEqual("https://nextstrain.org/"); // see code for details + expect(getUrlFromNode({node_attrs: {trait: {url: "http_//nextstrain.org"}}}, "trait")).toEqual("http://nextstrain.org/"); // see code for details + }); + + test("extract accession & corresponding URL from a node", () => { + expect(getAccessionFromNode({})) + .toStrictEqual({accession: undefined, url: undefined}); // no node_attrs on node + expect(getAccessionFromNode({node_attrs: {}})) + .toStrictEqual({accession: undefined, url: undefined}); // no "accession" on node_attrs + expect(getAccessionFromNode({node_attrs: {accession: "MK049251", url: "https://www.ncbi.nlm.nih.gov/nuccore/MK049251"}})) + .toStrictEqual({accession: "MK049251", url: "https://www.ncbi.nlm.nih.gov/nuccore/MK049251"}); + expect(getAccessionFromNode({node_attrs: {url: "https://www.ncbi.nlm.nih.gov/nuccore/MK049251"}})) + .toStrictEqual({accession: undefined, url: "https://www.ncbi.nlm.nih.gov/nuccore/MK049251"}); // url can be defined without accession + expect(getAccessionFromNode({node_attrs: {accession: "MK049251", url: "nuccore/MK049251"}})) + .toStrictEqual({accession: "MK049251", url: undefined}); // invalid URL + }); + +});