import React from "react"; import { withTranslation } from 'react-i18next'; import { connect, MapStateToProps } from "react-redux"; import { FaPlay, FaTimesCircle } from "react-icons/fa"; import type { RootState, AppDispatch } from '../../store' import { changePage } from "../../actions/navigation"; import { MAP_ANIMATION_PLAY_PAUSE_BUTTON } from "../../actions/types"; import CustomSelect from "../controls/customSelect"; import { Dataset, currentDataset, SIDEBAR_DATASET_CHANGE_ID } from "../controls/choose-dataset"; interface State { currentDataset: Dataset; proposedDataset: Dataset; snapshotInput: string; // raw input value (may be invalid) loadButtonHovered: boolean; } /** * A tree-like structure encoding available resources as a hierarchy of their constituent "parts" * E.g. resources seasonal-flu/h3n2/ha/6m and seasonal-flu/h3n2/ha/2y would have the structure * .seasonal-flu.h3n2.ha.6m = true * .2y = true */ interface RequestHierarchy { [key: string]: RequestHierarchy | true } interface StateProps { available: {dataset?: RequestHierarchy, narrative?: RequestHierarchy}; snapshotDatasets: Set; // Set of dataset request paths that support snapshots } /** * The main UI element to change dataset, rendered as a modal */ class DatasetSelector extends React.Component { constructor(props) { super(props); const d = currentDataset(); this.state = { currentDataset: d, proposedDataset: {...d}, // shallow is ok snapshotInput: d.snapshot || '', loadButtonHovered: false, } } handleKeyDown = (e: React.KeyboardEvent): void => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); this.changeDataset(); } } changeDataset = (): void => { // reset redux controls state in preparation for a change // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const nextstrain = (window as any).NEXTSTRAIN; // eslint-disable-line @typescript-eslint/no-explicit-any if (nextstrain && nextstrain.animationTickReference) { clearInterval(nextstrain.animationTickReference); nextstrain.animationTickReference = null; this.props.dispatch({type: MAP_ANIMATION_PLAY_PAUSE_BUTTON, data: "Play"}); } this.props.dispatch(changePage({path: _path(this.state.proposedDataset)})); } updateProposedParts = (idx, value): void => { const selected = { parts: [...this.state.proposedDataset.parts.slice(0, idx), value], snapshot: this.state.proposedDataset.snapshot, }; /* fill in further options! */ let options = this.options(selected.parts); while (options.length > 0) { const values = options.map((opt) => opt.value) // At the proposed position, does the current dataset have a value? const previousValue = this.state.currentDataset.parts[selected.parts.length]; if (values.includes(previousValue)) { selected.parts.push(previousValue) } else { // What to choose for the "default" next value, if the previous one at this level isn't // valid? We simply take the first available option, but in the nextstrain.org context // the server's manifest has a better default. We could explore better choices in the future. selected.parts.push(values[0]); } options = this.options(selected.parts); } // If the (newly) proposed dataset doesn't support snapshots then clear them, // if it does, then use our (previously used) snapshot value (if available) const datasetPath = selected.parts.join('/'); if (!this.props.snapshotDatasets.has(datasetPath)) { selected.snapshot = undefined; } else if (this.state.snapshotInput && _validSnapshot(this.state.snapshotInput)) { selected.snapshot = this.state.snapshotInput; } this.setState({proposedDataset: selected}); } updateProposedSnapshot = (snapshot: string|undefined = undefined): void => { this.setState({ proposedDataset: { parts: this.state.proposedDataset.parts, snapshot: snapshot || undefined, } }); } /** * Check if the currently proposed dataset supports snapshots */ snapshotsAvailable = (): boolean => { const datasetPath = this.state.proposedDataset.parts.join('/'); return this.props.snapshotDatasets.has(datasetPath); } /** * Get the available options of the available data given a list of keys. In * other words, we traverse the props.available hierarchy given some keys and then report * the available keys at that level */ options = (keys: string[]): {value: string, label: string}[] => { let _pointer: RequestHierarchy = {...this.props.available.dataset}; for (const key of keys) { const next = _pointer[key]; if (typeof next === 'object') { _pointer = {...next}; } else { // reached a leaf node (true) - no further options return []; } } const values = Array.from(Object.keys(_pointer)); // format the values for react-select return values.map((value) => ({ value, label: values.length===1 ? `${value} (only available option)` : value, })); } /** * Render a dropdown selector for a given "level" in the dataset hierarchy */ renderLevel = (currentValue: string, currentIdx: number): JSX.Element => { const options = this.options(this.state.proposedDataset.parts.slice(0, currentIdx)); const style = {paddingLeft: '0px', paddingTop: '10px'}; if (options.length===0) { return (
Unexpected error!
) } return (
({ ...base, zIndex: 9999 }), option: (base) => ({ ...base, fontSize: '14px' }), control: (base) => ({ ...base, backgroundColor: options.length === 1 ? '#e0e0e0' : base.backgroundColor, width: '500px', maxWidth: '90%', }), }} options={options} isClearable={false} isSearchable={true} isMulti={false} isDisabled={options.length === 1} value={options.filter((opt) => opt.value === currentValue)} onChange={(opt): void => this.updateProposedParts(currentIdx, opt.value)} />
) } /** * Render a provided dataset as formatted text. * If *matchAgainst* is provided, then parts of the provided dataset which don't match * are emphasized. */ renderDatasetName = (dataset: Dataset, matchAgainst?: Dataset): JSX.Element[] => { const jsx: JSX.Element[] = dataset.parts.flatMap((word, idx) => { // highlight (red) if changed const style = matchAgainst && matchAgainst.parts[idx]!==word ? {color: 'orange'} : {} return [{word}, idx+1===dataset.parts.length ? null : / ] }) if (dataset.snapshot) { jsx.push( @ ) const style = matchAgainst && matchAgainst.snapshot!==dataset.snapshot ? {color: 'orange'} : {} jsx.push({dataset.snapshot}) } return jsx; } /** * Render a freeform text input for snapshot name, styled to match * CustomSelect. Snapshots must match YYYY-MM-DD to be valid and thus be part * of the proposed dataset. * * See the comment at the top of this file for discussino about snapshots & * Auspice */ renderSnapshot = (): JSX.Element => { const style = {paddingLeft: '0px', paddingTop: '10px'}; const containerStyle = { position: 'relative' as const, maxWidth: '300px', width: '90%', }; const inputStyle = { width: '100%', padding: '8px', paddingRight: '32px', fontSize: '14px', border: '1px solid hsl(0, 0%, 80%)', borderRadius: '4px', backgroundColor: 'white', outline: 'none', boxSizing: 'border-box' as const, }; const clearButtonStyle = { position: 'absolute' as const, right: '8px', top: '50%', transform: 'translateY(-50%)', cursor: 'pointer', color: 'hsl(0, 0%, 60%)', display: 'flex', alignItems: 'center', }; const { snapshotInput } = this.state; const isInvalid = snapshotInput !== '' && !_validSnapshot(snapshotInput); return (
{ const value = e.target.value; this.setState({ snapshotInput: value }); // Only update proposedDataset if valid or empty if (value === '' || _validSnapshot(value)) { this.updateProposedSnapshot(value || undefined); } }} onFocus={(e): void => { e.target.style.borderColor = 'hsl(0, 0%, 70%)'; }} onBlur={(e): void => { e.target.style.borderColor = 'hsl(0, 0%, 80%)'; }} /> {snapshotInput && ( { this.setState({ snapshotInput: '' }); this.updateProposedSnapshot(""); }} > )}
); } renderDatasetLoader(): JSX.Element { return ( ); } override render(): JSX.Element { return (
Select a new dataset by changing the hierarchical selectors below

Current dataset: {this.renderDatasetName(this.state.currentDataset)}
Proposed dataset: {this.renderDatasetName(this.state.proposedDataset, this.state.currentDataset)} {this.state.proposedDataset.parts.map(this.renderLevel)} {this.snapshotsAvailable() && this.renderSnapshot()} {this.renderDatasetLoader()}

How does this work?

{`Changing intermediate levels of the selected dataset results in a new set of possible options for lower levels in the hierarchy; if possible we'll use the previous value for new levels but this may not be possible. `} {`You can compare the current dataset against the proposed one above the selectors, with differences shown in orange. `} {`Changing intermediate levels of the selected dataset results in a new set of possible options for lower levels in the hierarchy.`}
{`The full complement of datasets all available datasets are available here, you may need to `} go back to the main page {` to access other resources.`}
) } } const mapStateToProps: MapStateToProps, RootState> = ( state: RootState, ): StateProps => { return _parseAvailable(state.controls.available); }; export default withTranslation()(connect(mapStateToProps)(DatasetSelector)); function Strong({style={}, children}): JSX.Element { return {children} } /** * Style overrides for the modal component (and background etc). The intention is * to appear adjacent to the sidebar dataset display (which you click to open the modal) * insipired by some of the UI in https://github.com/ (e.g. the main search bar) */ export const datasetSelectorStyles = { container: (s: React.CSSProperties): React.CSSProperties => { s.backgroundColor = "rgba(0, 0, 0, .30)"; s.display = undefined; return s }, panel: (s: React.CSSProperties, browserDimensions: {width: number, height: number}): React.CSSProperties => { const source = document.querySelector(`#${SIDEBAR_DATASET_CHANGE_ID}`) ?.getBoundingClientRect(); const smallScreen = browserDimensions.width < (500 + (source?.left||0) * 2) s.left = smallScreen ? 5 : source?.left; s.top = source?.top; s.padding = '2%'; s.color = "#1f2328"; s.backgroundColor = "#ffffff"; if (smallScreen) { s.width = browserDimensions.width - 10; // 2 x 5px spacing buffer side s.maxWidth = s.width; } else { s.minWidth = 500; s.width = browserDimensions.width * 0.5; } return s } }; /** * convert a *Dataset* object to a dataset (url) path */ function _path(dataset: Dataset): string { return dataset.parts.join('/') + (dataset.snapshot ? `@${dataset.snapshot}` : ''); } /** * Construct the structure of possible datasets and narratives. * Note that narratives are parsed since they're in the redux store, but this component * doesn't yet allow them to be selected. * Also extracts the set of datasets that support snapshots. */ function _parseAvailable(availableData: RootState["controls"]["available"]): StateProps { const available: StateProps["available"] = {}; const snapshotDatasets = new Set(); // Helper to build nested hierarchy from path segments const buildHierarchy = (items: { request: string, snapshots?: boolean }[]): RequestHierarchy => { const hierarchy: RequestHierarchy = {}; for (const item of items) { // Track datasets that support snapshots if (item.snapshots) { snapshotDatasets.add(item.request); } const segments = item.request.split("/"); let current: RequestHierarchy = hierarchy; for (let i = 0; i < segments.length; i++) { const segment = segments[i]; const isLast = i === segments.length - 1; if (isLast) { current[segment] = true; } else { if (current[segment] === true || !current[segment]) { current[segment] = {}; } const next = current[segment]; if (typeof next === "object") { current = next; } } } } return hierarchy; }; if (availableData?.datasets?.length) { available.dataset = buildHierarchy(availableData.datasets); } if (availableData?.narratives?.length) { available.narrative = buildHierarchy(availableData.narratives); } return { available, snapshotDatasets }; } function _validSnapshot(snapshot:string): boolean { const snapshotRegex = /^\d{4}-\d{2}-\d{2}$/; if (!snapshotRegex.test(snapshot)) return false const parts = snapshot.split('-').map((p) => parseInt(p, 10)) // Note that the nextstrain server handles "invalid" months/days, but do some low-effort validation here if (parts[1]===0 || parts[1]>12 || parts[2]===0 || parts[2]>31) return false return true }