diff --git a/src/actions/types.js b/src/actions/types.js index edf3cd5bb..10a71d8db 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -28,8 +28,7 @@ export const ADD_NOTIFICATION = "ADD_NOTIFICATION"; export const REMOVE_NOTIFICATION = "REMOVE_NOTIFICATION"; export const CHANGE_PANEL_LAYOUT = "CHANGE_PANEL_LAYOUT"; export const TOGGLE_PANEL_DISPLAY = "TOGGLE_PANEL_DISPLAY"; -export const TRIGGER_DOWNLOAD_MODAL = "TRIGGER_DOWNLOAD_MODAL"; -export const DISMISS_DOWNLOAD_MODAL = "DISMISS_DOWNLOAD_MODAL"; +export const SET_MODAL = "SET_MODAL"; export const ADD_EXTRA_METADATA = "ADD_EXTRA_METADATA"; export const CHANGE_TREE_ROOT_IDX = "CHANGE_TREE_ROOT_IDX"; export const TOGGLE_NARRATIVE = "TOGGLE_NARRATIVE"; diff --git a/src/components/download/downloadModal.js b/src/components/download/downloadModal.js index 4dbc2f688..93d1705b6 100644 --- a/src/components/download/downloadModal.js +++ b/src/components/download/downloadModal.js @@ -1,10 +1,7 @@ import React from "react"; -import Mousetrap from "mousetrap"; import { connect } from "react-redux"; import { withTranslation } from 'react-i18next'; -import { TRIGGER_DOWNLOAD_MODAL, DISMISS_DOWNLOAD_MODAL } from "../../actions/types"; import { infoPanelStyles } from "../../globalStyles"; -import { stopProp } from "../tree/infoPanels/click"; import { getAcknowledgments} from "../framework/footer"; import { datasetSummary } from "../info/datasetSummary"; import { DownloadButtons } from "./downloadButtons"; @@ -43,7 +40,6 @@ export const publications = { @connect((state) => ({ browserDimensions: state.browserDimensions.browserDimensions, - show: state.controls.showDownload, colorBy: state.controls.colorBy, distanceMeasure: state.controls.distanceMeasure, metadata: state.metadata, @@ -54,58 +50,7 @@ export const publications = { panelsToDisplay: state.controls.panelsToDisplay, panelLayout: state.controls.panelLayout })) -class DownloadModal extends React.Component { - constructor(props) { - super(props); - this.getStyles = (bw, bh) => { - return { - behind: { /* covers the screen */ - position: "absolute", - top: 0, - left: 0, - width: "100%", - height: "100%", - pointerEvents: "all", - zIndex: 2000, - backgroundColor: "rgba(80, 80, 80, .20)", - display: "flex", - justifyContent: "center", - alignItems: "center", - wordWrap: "break-word", - wordBreak: "break-word" - }, - title: { - fontWeight: 500, - fontSize: 32, - marginTop: "20px", - marginBottom: "20px" - }, - secondTitle: { - fontWeight: 500, - marginTop: "0px", - marginBottom: "20px" - }, - modal: { - marginLeft: 200, - marginTop: 130, - width: bw - (2 * 200), - height: bh - (2 * 130), - borderRadius: 2, - backgroundColor: "rgba(250, 250, 250, 1)", - overflowY: "auto" - }, - break: { - marginBottom: "10px" - } - }; - }; - this.dismissModal = this.dismissModal.bind(this); - } - componentDidMount() { - Mousetrap.bind('d', () => { - this.props.dispatch({type: this.props.show ? DISMISS_DOWNLOAD_MODAL : TRIGGER_DOWNLOAD_MODAL}); - }); - } +class DownloadModalContents extends React.Component { getRelevantPublications() { const x = [publications.nextstrain, publications.treetime]; if (["cTiter", "rb", "ep", "ne"].indexOf(this.props.colorBy) !== -1) { @@ -128,75 +73,53 @@ class DownloadModal extends React.Component { ); } - dismissModal() { - this.props.dispatch({ type: DISMISS_DOWNLOAD_MODAL }); - } render() { - const { t } = this.props; - - if (!this.props.show) { - return null; - } - const panelStyle = {...infoPanelStyles.panel}; - panelStyle.width = this.props.browserDimensions.width * 0.66; - panelStyle.maxWidth = panelStyle.width; - panelStyle.maxHeight = this.props.browserDimensions.height * 0.66; - panelStyle.fontSize = 14; - panelStyle.lineHeight = 1.4; - + const { t, metadata } = this.props; const relevantPublications = this.getRelevantPublications(); - - const meta = this.props.metadata; return ( -
-
stopProp(e)}> -

- ({t("click outside this box to return to the app")}) -

- -
- {meta.title} ({t("last updated")} {meta.updated}) -
+ <> +
+ {metadata.title} ({t("last updated")} {metadata.updated}) +
-
- {datasetSummary({ - mainTreeNumTips: this.props.metadata.mainTreeNumTips, - nodes: this.props.nodes, - visibility: this.props.visibility, - t: this.props.t - })} -
-
- {" " + t("A full list of sequence authors is available via the TSV files below")} -
- {getAcknowledgments({}, {preamble: {fontWeight: 300}, acknowledgments: {fontWeight: 300}})} +
+ {datasetSummary({ + mainTreeNumTips: this.props.metadata.mainTreeNumTips, + nodes: this.props.nodes, + visibility: this.props.visibility, + t: this.props.t + })} +
+
+ {" " + t("A full list of sequence authors is available via the TSV files below")} +
+ {getAcknowledgments({}, {preamble: {fontWeight: 300}, acknowledgments: {fontWeight: 300}})} -
- {t("Data usage policy")} -
- {t("Data usage part 1") + " " + t("Data usage part 2")} +
+ {t("Data usage policy")} +
+ {t("Data usage part 1") + " " + t("Data usage part 2")} -
- {t("Please cite the authors who contributed genomic data (where relevant), as well as")+":"} -
- {this.formatPublications(relevantPublications)} +
+ {t("Please cite the authors who contributed genomic data (where relevant), as well as")+":"} +
+ {this.formatPublications(relevantPublications)} -
- {t("Download data")}: -
-
-
- -
+
+ {t("Download data")}: +
+
+
+
-
+ ); } } -const WithTranslation = withTranslation()(DownloadModal); +const WithTranslation = withTranslation()(DownloadModalContents); export default WithTranslation; diff --git a/src/components/framework/fine-print.js b/src/components/framework/fine-print.js index 0ae7c0e9d..368639946 100644 --- a/src/components/framework/fine-print.js +++ b/src/components/framework/fine-print.js @@ -2,13 +2,14 @@ import React, { Suspense, lazy } from "react"; import { connect } from "react-redux"; import styled from 'styled-components'; import { withTranslation } from "react-i18next"; -import { FaDownload } from "react-icons/fa"; +import { FaDownload, FaExternalLinkSquareAlt } from "react-icons/fa"; import { dataFont, medGrey, materialButton } from "../../globalStyles"; -import { TRIGGER_DOWNLOAD_MODAL } from "../../actions/types"; +import { SET_MODAL } from "../../actions/types"; import Flex from "./flex"; import { version } from "../../version"; import { publications } from "../download/downloadModal"; import { hasExtension, getExtension } from "../../util/extensions"; +import { canShowLinkOuts } from "../modal/LinkOutModalContents.jsx"; const logoPNG = require("../../images/favicon.png"); @@ -82,10 +83,22 @@ class FinePrint extends React.Component { return ( + ); + } + linkOutButton() { + const { t } = this.props; + return ( + ); } @@ -96,10 +109,16 @@ class FinePrint extends React.Component { return (
- + {this.getUpdated()} {dot} {this.downloadDataButton()} + {canShowLinkOuts() && ( + <> + {dot} + {this.linkOutButton()} + + )} {dot} {"Auspice v" + version} diff --git a/src/components/main/index.js b/src/components/main/index.js index cd357cb0f..5b5d7debd 100644 --- a/src/components/main/index.js +++ b/src/components/main/index.js @@ -8,7 +8,7 @@ import Tree from "../tree"; import Map from "../map/map"; import Footer from "../framework/footer"; import FinePrint from "../framework/fine-print"; -import DownloadModal from "../download/downloadModal"; +import Modal from "../modal/Modal.jsx"; import { analyticsNewPage } from "../../util/googleAnalytics"; import handleFilesDropped from "../../actions/filesDropped"; import { TOGGLE_SIDEBAR } from "../../actions/types"; @@ -134,7 +134,7 @@ class Main extends React.Component { - + props.theme.selectedColor}; + } +` + +const InactiveButton = styled.span` + border: 1px solid ${lighterGrey}; + border-radius: 4px; + cursor: auto; + padding: 4px 7px; + margin-right: 10px; + font-family: ${dataFont}; + background-color: rgba(0,0,0,0); + color: white; + font-weight: 400; + text-decoration: line-through !important; + font-size: 16px; + flex-shrink: 0; +` + +const ButtonDescription = styled.span` + display: inline-block; + font-style: italic; + font-size: 14px; + color: white; +` + +const ButtonContainer = styled.div` + margin-top: 10px; + margin-bottom: 10px; + display: flex; + flex-wrap: nowrap; + align-items: center; +` + +const data = ({distanceMeasure, colorBy, mainTreeNumTips, tangle}) => { + const pathname = window.location.pathname; + const origin = forceLinkOutHost || window.location.origin; + return [ + { + name: 'taxonium.org', + valid() { + // MicrobeTrace should work with all nextstrain URLs which support the {GET, accept JSON} route + // which should be ~all of them (except authn-required routes, which won't work cross-origin, + // and which we don't attempt to detect here). Tanglegrams aren't supported. + return !tangle; + }, + description() { + return this.valid() ? ( + <> + Visualise this dataset in Taxonium (learn more). + + ) : ( + <> + {`The current dataset isn't viewable in taxonium ${tangle ? `as tanglegrams aren't supported` : ''}`} + + ) + }, + taxoniumColoring() { + if (isColorByGenotype(colorBy)) { + /* Taxonium syntax looks like 'color={"field":"genotype","pos":485,"gene":"M"}' + Note that taxonium (I think) does't backfill bases/residues for tips where there + are no observed mutations w.r.t. the root. + */ + const subfields = ['"genotype"']; // include quoting as taxonium uses + const colorInfo = decodeColorByGenotype(colorBy); + // Multiple mutations (positions) aren't allowed + if (!colorInfo || colorInfo.positions.length>1) return null; + // The (integer) position is not enclosed in double quotes + subfields.push(`"pos":${colorInfo.positions[0]}`); + // The gene value is optional, without it we use nucleotide ("nt" in taxonium syntax) + if (colorInfo.aa) subfields.push(`"gene":"${colorInfo.gene}"`); + // Note that this string will be encoded when converted to a URL + return `{"field":${subfields.join(',')}}`; + } + return `{"field":"meta_${colorBy}"}`; + }, + url() { + const baseUrl = 'https://taxonium.org'; + const queries = { + treeUrl: `${origin}${pathname}`, // no nextstrain queries + treeType: 'nextstrain', + ladderizeTree: 'false', // keep same orientation as Auspice + xType: distanceMeasure==='num_date' ? 'x_time' : 'x_dist', + } + const color = this.taxoniumColoring(); + if (color) queries.color = color; + + return `${baseUrl}?${Object.entries(queries).map(([k,v]) => `${k}=${encodeURIComponent(v)}`).join("&")}`; + } + }, + { + name: 'microbetrace.cdc.gov', + valid() { + // MicrobeTrace should work similarly to Taxonium (see above) + // but trees >500 tips are very slow to load + return !tangle; + }, + description() { + return this.valid() ? ( + <> + View this data in MicrobeTrace (learn more). + {mainTreeNumTips>500 && ( + + {` Note that trees with over 500 tips may have trouble loading (this one has ${mainTreeNumTips}).`} + + )} + + ) : ( + <> + {`The current dataset isn't viewable in MicrobeTrace ${tangle ? `as tanglegrams aren't supported` : ''}`} + + ) + }, + url() { + /** + * As of 2024-04-09, the 'origin' must be nextstrain.org or next.nextstrain.org + * for these links to work. This means (nextstrain.org) the links coming from heroku + * review apps will not work. + */ + const baseUrl = 'https://microbetrace.cdc.gov/MicrobeTrace'; + return `${baseUrl}?url=${encodeURIComponent(`${origin}${pathname}`)}` + }, + }, + ] +} + +export const LinkOutModalContents = () => { + const {distanceMeasure, colorBy, showTreeToo} = useSelector((state) => state.controls) + const {mainTreeNumTips} = useSelector((state) => state.metadata); + const linkouts = data({distanceMeasure, colorBy, mainTreeNumTips, tangle: !!showTreeToo}); + + return ( + <> +
+ View this dataset on other platforms: +
+ +
+ +

+ Clicking on the following links will take you to an external site which will attempt to + load the underlying data JSON which you are currently viewing. + These sites are not part of Nextstrain and as such are not under our control, but we + highly encourage interoperability across platforms like these. +

+ +
+ + {linkouts.map((d) => ( + + {d.valid() ? ( + {d.name} + ) : ( + {d.name} + )} + {d.description()} + + ))} + + {linkouts.length===0 && ( +
{`The current data source and/or view settings aren't compatible with any platforms. Sorry!`}
+ )} + + + ); +} + + +export const canShowLinkOuts = () => { + if (forceLinkOutHost) { + // eslint-disable-next-line no-console + console.log("Enabling link-out modal because 'forceLinkOutHost' is set") + return true; + } + if (!hasExtension('linkOutModal') || !getExtension('linkOutModal')) { + return false; + } + if (window.location.hostname==='localhost') { + console.warn("Link-out modal requested but you are running on localhost so the links will not work") + } + return true; +} \ No newline at end of file diff --git a/src/components/modal/Modal.jsx b/src/components/modal/Modal.jsx new file mode 100644 index 000000000..025c5500e --- /dev/null +++ b/src/components/modal/Modal.jsx @@ -0,0 +1,116 @@ +import React from "react"; +import Mousetrap from "mousetrap"; +import { connect } from "react-redux"; +import { withTranslation } from 'react-i18next'; +import { SET_MODAL } from "../../actions/types"; +import { infoPanelStyles } from "../../globalStyles"; +import { stopProp } from "../tree/infoPanels/click"; +import DownloadModalContents from "../download/downloadModal"; +import { LinkOutModalContents } from "./LinkOutModalContents.jsx"; + +@connect((state) => ({ + browserDimensions: state.browserDimensions.browserDimensions, + modal: state.controls.modal, +})) +class Modal extends React.Component { + constructor(props) { + super(props); + this.getStyles = (bw, bh) => { + return { + behind: { /* covers the screen */ + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + pointerEvents: "all", + zIndex: 2000, + backgroundColor: "rgba(80, 80, 80, .20)", + display: "flex", + justifyContent: "center", + alignItems: "center", + wordWrap: "break-word", + wordBreak: "break-word" + }, + title: { + fontWeight: 500, + fontSize: 32, + marginTop: "20px", + marginBottom: "20px" + }, + secondTitle: { + fontWeight: 500, + marginTop: "0px", + marginBottom: "20px" + }, + modal: { + marginLeft: 200, + marginTop: 130, + width: bw - (2 * 200), + height: bh - (2 * 130), + borderRadius: 2, + backgroundColor: "rgba(250, 250, 250, 1)", + overflowY: "auto" + }, + break: { + marginBottom: "10px" + } + }; + }; + this.dismissModal = this.dismissModal.bind(this); + } + + /** + * Key-press 'd' opens the download modal (at the time of implementation this was the only modal) + * and if _any_ modal's open pressing 'd' dismisses it + */ + componentDidMount() { + Mousetrap.bind('d', () => { + this.props.dispatch({ type: SET_MODAL, modal: this.props.modal ? null : 'download' }); + }); + } + + componentWillUnmount() { + Mousetrap.unbind('d'); + } + + dismissModal() { + this.props.dispatch({ type: SET_MODAL, modal: null }); + } + + render() { + const { t } = this.props; + + let Contents = null; + switch (this.props.modal) { + case 'download': + Contents = DownloadModalContents; + break; + case 'linkOut': + Contents = LinkOutModalContents; + break; + default: + return null; + } + + const panelStyle = {...infoPanelStyles.panel}; + panelStyle.width = this.props.browserDimensions.width * 0.66; + panelStyle.maxWidth = panelStyle.width; + panelStyle.maxHeight = this.props.browserDimensions.height * 0.66; + panelStyle.fontSize = 14; + panelStyle.lineHeight = 1.4; + return ( +
+
stopProp(e)}> +

+ ({t("click outside this box to return to the app")}) +

+ +
+
+ ); + } +} + +export default withTranslation()(Modal); + diff --git a/src/reducers/controls.ts b/src/reducers/controls.ts index ca4024447..93683129f 100644 --- a/src/reducers/controls.ts +++ b/src/reducers/controls.ts @@ -77,7 +77,7 @@ export const getDefaultControlsState = () => { geoResolution: defaults.geoResolution, filters: defaults.filters, filtersInFooter: defaults.filtersInFooter, - showDownload: false, + modal: null, quickdraw: false, // if true, components may skip expensive computes. mapAnimationDurationInMilliseconds: 30000, // in milliseconds mapAnimationStartDate: null, // Null so it can pull the absoluteDateMin as the default @@ -273,13 +273,9 @@ const Controls = (state: ControlsState = getDefaultControlsState(), action): Con on: !state.temporalConfidence.on }) }); - case types.TRIGGER_DOWNLOAD_MODAL: + case types.SET_MODAL: return Object.assign({}, state, { - showDownload: true - }); - case types.DISMISS_DOWNLOAD_MODAL: - return Object.assign({}, state, { - showDownload: false + modal: action.modal || null }); case types.REMOVE_TREE_TOO: return Object.assign({}, state, {