From fd77a612e0fa7e14f87e633c048922886e281f16 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Tue, 9 Apr 2024 10:02:02 +1200 Subject: [PATCH 1/4] Separate Modal component from modal contents The original design had only one modal so the modal design was wrapped up with the contents shown in the modal. Here we separate them to allow different modals in future commits. There should be no user-facing changes with this change. --- src/actions/types.js | 3 +- src/components/download/downloadModal.js | 145 ++++++----------------- src/components/framework/fine-print.js | 4 +- src/components/main/index.js | 4 +- src/components/modal/Modal.jsx | 112 +++++++++++++++++ src/reducers/controls.ts | 10 +- 6 files changed, 154 insertions(+), 124 deletions(-) create mode 100644 src/components/modal/Modal.jsx 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..696048898 100644 --- a/src/components/framework/fine-print.js +++ b/src/components/framework/fine-print.js @@ -4,7 +4,7 @@ import styled from 'styled-components'; import { withTranslation } from "react-i18next"; import { FaDownload } 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"; @@ -82,7 +82,7 @@ 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/modal/LinkOutModalContents.jsx b/src/components/modal/LinkOutModalContents.jsx new file mode 100644 index 000000000..58d107bcb --- /dev/null +++ b/src/components/modal/LinkOutModalContents.jsx @@ -0,0 +1,134 @@ +/** + * This page defines a number of link-outs, which will appear in a modal when requested. + * They are currently developed exclusively for Auspice as used within Nextstrain.org + * and are opt-in - i.e. they must be enabled by setting (undocumented) extension parameters. + * If the need arises we can make these fully extendable and shift their definitions into + * the extension architecture. + */ + +import React from "react"; +import styled from 'styled-components'; +import { useSelector } from "react-redux"; +import { infoPanelStyles } from "../../globalStyles"; +import { dataFont, lighterGrey} from "../../globalStyles"; +import { hasExtension, getExtension } from "../../util/extensions"; + + +/** + * The following value is useful for development purposes as we'll not show any + * link-outs on localhost (because external sites can't access it!). Setting to + * a string such as "https://nextstrain.org" will allow testing the links. Note + * that if this is set we won't check for the relevant extension parameter - i.e. + * the modal will always be available. + */ +let forceLinkOutHost = false; // eslint-disable-line prefer-const +// forceLinkOutHost = "https://nextstrain.org"; // uncomment for dev purposes + +const ButtonText = styled.a` + border: 1px solid ${lighterGrey}; + border-radius: 4px; + cursor: pointer; + padding: 4px 7px; + margin-right: 10px; + font-family: ${dataFont}; + background-color: rgba(0,0,0,0); + color: white !important; + font-weight: 400; + text-decoration: none !important; + font-size: 16px; + flex-shrink: 0; + & :hover { + background-color: ${(props) => 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}) => { + return [] +} + +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 index 312193891..025c5500e 100644 --- a/src/components/modal/Modal.jsx +++ b/src/components/modal/Modal.jsx @@ -6,6 +6,7 @@ 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, @@ -85,6 +86,9 @@ class Modal extends React.Component { case 'download': Contents = DownloadModalContents; break; + case 'linkOut': + Contents = LinkOutModalContents; + break; default: return null; } From 767be39fd3d65577c44d01b62ae7d0e818471da0 Mon Sep 17 00:00:00 2001 From: james hadfield Date: Tue, 9 Apr 2024 14:49:24 +1200 Subject: [PATCH 3/4] Add Taxonium link-out --- src/components/modal/LinkOutModalContents.jsx | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/src/components/modal/LinkOutModalContents.jsx b/src/components/modal/LinkOutModalContents.jsx index 58d107bcb..5121229b4 100644 --- a/src/components/modal/LinkOutModalContents.jsx +++ b/src/components/modal/LinkOutModalContents.jsx @@ -12,6 +12,7 @@ import { useSelector } from "react-redux"; import { infoPanelStyles } from "../../globalStyles"; import { dataFont, lighterGrey} from "../../globalStyles"; import { hasExtension, getExtension } from "../../util/extensions"; +import { isColorByGenotype, decodeColorByGenotype} from "../../util/getGenotype"; /** @@ -73,7 +74,62 @@ const ButtonContainer = styled.div` ` const data = ({distanceMeasure, colorBy, mainTreeNumTips, tangle}) => { - return [] + 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("&")}`; + } + }, + ] } export const LinkOutModalContents = () => { From 39b4ec64545a50e578646bd3607ddcd0f5e4164f Mon Sep 17 00:00:00 2001 From: james hadfield Date: Tue, 9 Apr 2024 14:50:20 +1200 Subject: [PATCH 4/4] Add MicrobeTrace linkout --- src/components/modal/LinkOutModalContents.jsx | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/components/modal/LinkOutModalContents.jsx b/src/components/modal/LinkOutModalContents.jsx index 5121229b4..1746bfa80 100644 --- a/src/components/modal/LinkOutModalContents.jsx +++ b/src/components/modal/LinkOutModalContents.jsx @@ -129,6 +129,39 @@ const data = ({distanceMeasure, colorBy, mainTreeNumTips, tangle}) => { 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}`)}` + }, + }, ] }