diff --git a/Cargo.lock b/Cargo.lock index b0c6ba611..482e7bd77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1737,6 +1737,7 @@ dependencies = [ "num-traits", "num_cpus", "optfield", + "ordered-float", "owo-colors", "pretty_assertions", "rayon", @@ -1786,6 +1787,7 @@ dependencies = [ "log", "nextclade", "num_cpus", + "ordered-float", "owo-colors", "pretty_assertions", "rayon", @@ -1799,6 +1801,7 @@ dependencies = [ "serde_json", "strum 0.25.0", "strum_macros 0.25.0", + "tinytemplate", "url", "zip", ] @@ -1808,6 +1811,7 @@ name = "nextclade-web" version = "3.0.0-alpha.0" dependencies = [ "assert2", + "chrono", "console_error_panic_hook", "eyre", "getrandom", @@ -1945,11 +1949,14 @@ dependencies = [ [[package]] name = "ordered-float" -version = "3.7.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc2dbde8f8a79f2102cc474ceb0ad68e3b80b85289ea62389b60e66777e4213" +checksum = "2a54938017eacd63036332b4ae5c8a49fc8c0c1d6d629893057e4f13609edd06" dependencies = [ "num-traits", + "rand", + "schemars", + "serde", ] [[package]] @@ -2165,6 +2172,7 @@ dependencies = [ "libc", "rand_chacha", "rand_core", + "serde", ] [[package]] @@ -2184,6 +2192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ "getrandom", + "serde", ] [[package]] diff --git a/packages_rs/nextclade-cli/Cargo.toml b/packages_rs/nextclade-cli/Cargo.toml index 5d29296b0..b82b3e730 100644 --- a/packages_rs/nextclade-cli/Cargo.toml +++ b/packages_rs/nextclade-cli/Cargo.toml @@ -28,6 +28,7 @@ lazy_static = "=1.4.0" log = "=0.4.19" nextclade = { path = "../nextclade" } num_cpus = "=1.16.0" +ordered-float = { version = "=3.9.1", features = ["rand", "serde", "schemars"] } owo-colors = "=3.5.0" pretty_assertions = "=1.3.0" rayon = "=1.7.0" @@ -39,6 +40,7 @@ serde = { version = "=1.0.164", features = ["derive"] } serde_json = { version = "=1.0.99", features = ["preserve_order", "indexmap", "unbounded_depth"] } strum = "=0.25.0" strum_macros = "=0.25" +tinytemplate = "=1.2.1" url = { version = "=2.4.0", features = ["serde"] } zip = { version = "=0.6.6", default-features = false, features = ["aes-crypto", "bzip2", "deflate", "time"] } diff --git a/packages_rs/nextclade-cli/src/cli/mod.rs b/packages_rs/nextclade-cli/src/cli/mod.rs index 9647610b4..1a058aba2 100644 --- a/packages_rs/nextclade-cli/src/cli/mod.rs +++ b/packages_rs/nextclade-cli/src/cli/mod.rs @@ -3,4 +3,5 @@ pub mod nextclade_dataset_get; pub mod nextclade_dataset_list; pub mod nextclade_loop; pub mod nextclade_ordered_writer; +pub mod nextclade_seq_sort; pub mod verbosity; diff --git a/packages_rs/nextclade-cli/src/cli/nextclade_cli.rs b/packages_rs/nextclade-cli/src/cli/nextclade_cli.rs index 2107ce2a1..710c6c218 100644 --- a/packages_rs/nextclade-cli/src/cli/nextclade_cli.rs +++ b/packages_rs/nextclade-cli/src/cli/nextclade_cli.rs @@ -1,6 +1,7 @@ use crate::cli::nextclade_dataset_get::nextclade_dataset_get; use crate::cli::nextclade_dataset_list::nextclade_dataset_list; use crate::cli::nextclade_loop::nextclade_run; +use crate::cli::nextclade_seq_sort::nextclade_seq_sort; use crate::cli::verbosity::{Verbosity, WarnLevel}; use crate::io::http_client::ProxyConfig; use clap::builder::styling; @@ -12,6 +13,7 @@ use itertools::Itertools; use lazy_static::lazy_static; use nextclade::io::fs::add_extension; use nextclade::run::params::NextcladeInputParamsOptional; +use nextclade::sort::params::NextcladeSeqSortParams; use nextclade::utils::global_init::setup_logger; use nextclade::{getenv, make_error}; use std::fmt::Debug; @@ -76,15 +78,20 @@ pub enum NextcladeCommands { shell: String, }, - /// Run alignment, mutation calling, clade assignment, quality checks and phylogenetic placement + /// Run sequence analysis: alignment, mutation calling, clade assignment, quality checks and phylogenetic placement /// /// For short help type: `nextclade -h`, for extended help type: `nextclade --help`. Each subcommand has its own help, for example: `nextclade run --help`. Run(Box), - /// List and download available Nextclade datasets + /// List and download available Nextclade datasets (pathogens) /// - /// For short help type: `nextclade -h`, for extended help type: `nextclade --help`. Each subcommand has its own help, for example: `nextclade run --help`. + /// For short help type: `nextclade -h`, for extended help type: `nextclade --help`. Each subcommand has its own help, for example: `nextclade dataset --help`. Dataset(Box), + + /// Sort sequences according to the inferred Nextclade dataset (pathogen) + /// + /// For short help type: `nextclade -h`, for extended help type: `nextclade --help`. Each subcommand has its own help, for example: `nextclade sort --help`. + Sort(Box), } #[derive(Parser, Debug)] @@ -621,6 +628,80 @@ pub struct NextcladeRunArgs { pub other_params: NextcladeRunOtherParams, } +#[allow(clippy::struct_excessive_bools)] +#[derive(Parser, Debug)] +#[clap(verbatim_doc_comment)] +pub struct NextcladeSortArgs { + /// Path to one or multiple FASTA files with input sequences + /// + /// Supports the following compression formats: "gz", "bz2", "xz", "zst". If no files provided, the plain fasta input is read from standard input (stdin). + /// + /// See: https://en.wikipedia.org/wiki/FASTA_format + #[clap(value_hint = ValueHint::FilePath)] + pub input_fastas: Vec, + + /// Path to input minimizer index JSON file. + /// + /// By default the latest reference minimizer index is fetched from the dataset server (default or customized with `--server` argument). If this argument is provided, the algorithm skips fetching the default index and uses the index provided in the the JSON file. + /// + /// Supports the following compression formats: "gz", "bz2", "xz", "zst". Use "-" to read uncompressed data from standard input (stdin). + #[clap(long, short = 'm')] + #[clap(value_hint = ValueHint::FilePath)] + pub input_minimizer_index_json: Option, + + /// Path to output directory + /// + /// Sequences will be written in subdirectories: one subdirectory per dataset. Sequences inferred to be belonging to a particular dataset wil lbe places in the corresponding subdirectory. The subdirectory tree can be nested, depending on how dataset names are organized. + /// + /// Mutually exclusive with `--output`. + /// + #[clap(short = 'O', long)] + #[clap(value_hint = ValueHint::DirPath)] + #[clap(group = "outputs")] + pub output_dir: Option, + + /// Template string for the file path to output sorted sequences. A separate file will be generated per dataset. + /// + /// The string should contain template variable `{name}`, where the dataset name will be substituted. Note that if the `{name}` variable contains slashes, they will be interpreted as path segments and subdirectories will be created. + /// + /// Make sure you properly quote and/or escape the curly braces, so that your shell, programming language or pipeline manager does not attempt to substitute the variables. + /// + /// Mutually exclusive with `--output-dir`. + /// + /// If the provided file path ends with one of the supported extensions: "gz", "bz2", "xz", "zst", then the file will be written compressed. If the required directory tree does not exist, it will be created. + /// + /// Example for bash shell: + /// + /// --output='outputs/{name}/sorted.fasta.gz' + #[clap(short = 'o', long)] + #[clap(group = "outputs")] + pub output_path: Option, + + /// Path to output results TSV file + /// + /// If the provided file path ends with one of the supported extensions: "gz", "bz2", "xz", "zst", then the file will be written compressed. Use "-" to write uncompressed to standard output (stdout). If the required directory tree does not exist, it will be created. + #[clap(short = 'r', long)] + #[clap(value_hint = ValueHint::FilePath)] + pub output_results_tsv: Option, + + #[clap(flatten, next_help_heading = "Algorithm")] + pub search_params: NextcladeSeqSortParams, + + #[clap(flatten, next_help_heading = "Other")] + pub other_params: NextcladeRunOtherParams, + + /// Use custom dataset server. + /// + /// You can host your own dataset server, with one or more datasets, grouped into dataset collections, and use this server to provide datasets to users of Nextclade CLI and Nextclade Web. Refer to Nextclade dataset documentation for more details. + #[clap(long)] + #[clap(value_hint = ValueHint::Url)] + #[clap(default_value_t = Url::from_str(DATA_FULL_DOMAIN).expect("Invalid URL"))] + pub server: Url, + + #[clap(flatten)] + pub proxy_config: ProxyConfig, +} + fn generate_completions(shell: &str) -> Result<(), Report> { let mut command = NextcladeArgs::command(); @@ -907,5 +988,6 @@ pub fn nextclade_parse_cli_args() -> Result<(), Report> { NextcladeDatasetCommands::List(dataset_list_args) => nextclade_dataset_list(dataset_list_args), NextcladeDatasetCommands::Get(dataset_get_args) => nextclade_dataset_get(&dataset_get_args), }, + NextcladeCommands::Sort(seq_sort_args) => nextclade_seq_sort(&seq_sort_args), } } diff --git a/packages_rs/nextclade-cli/src/cli/nextclade_seq_sort.rs b/packages_rs/nextclade-cli/src/cli/nextclade_seq_sort.rs new file mode 100644 index 000000000..847a3d1c4 --- /dev/null +++ b/packages_rs/nextclade-cli/src/cli/nextclade_seq_sort.rs @@ -0,0 +1,401 @@ +use crate::cli::nextclade_cli::{NextcladeRunOtherParams, NextcladeSortArgs}; +use crate::dataset::dataset_download::download_datasets_index_json; +use crate::io::http_client::HttpClient; +use eyre::{Report, WrapErr}; +use itertools::Itertools; +use log::{trace, LevelFilter}; +use nextclade::io::csv::CsvStructFileWriter; +use nextclade::io::fasta::{FastaReader, FastaRecord, FastaWriter}; +use nextclade::io::fs::path_to_string; +use nextclade::make_error; +use nextclade::sort::minimizer_index::{MinimizerIndexJson, MINIMIZER_INDEX_ALGO_VERSION}; +use nextclade::sort::minimizer_search::{run_minimizer_search, MinimizerSearchRecord}; +use nextclade::utils::option::{OptionMapMutFallible, OptionMapRefFallible}; +use nextclade::utils::string::truncate; +use ordered_float::OrderedFloat; +use owo_colors::OwoColorize; +use schemars::JsonSchema; +use serde::Serialize; +use std::collections::btree_map::Entry::{Occupied, Vacant}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use tinytemplate::TinyTemplate; + +pub fn nextclade_seq_sort(args: &NextcladeSortArgs) -> Result<(), Report> { + check_args(args)?; + + let NextcladeSortArgs { + server, + proxy_config, + input_minimizer_index_json, + .. + } = args; + + let verbose = log::max_level() >= LevelFilter::Info; + + let minimizer_index = if let Some(input_minimizer_index_json) = &input_minimizer_index_json { + // If a file is provided, use data from it + MinimizerIndexJson::from_path(input_minimizer_index_json) + } else { + // Otherwise fetch from dataset server + let mut http = HttpClient::new(server, proxy_config, verbose)?; + let index = download_datasets_index_json(&mut http)?; + let minimizer_index_path = index + .minimizer_index + .iter() + .find(|minimizer_index| MINIMIZER_INDEX_ALGO_VERSION == minimizer_index.version) + .map(|minimizer_index| &minimizer_index.path); + + if let Some(minimizer_index_path) = minimizer_index_path { + let minimizer_index_str = http.get(minimizer_index_path)?; + MinimizerIndexJson::from_str(String::from_utf8(minimizer_index_str)?) + } else { + let server_versions = index + .minimizer_index + .iter() + .map(|minimizer_index| format!("'{}'", minimizer_index.version)) + .join(","); + let server_versions = if server_versions.is_empty() { + "none available".to_owned() + } else { + format!(": {server_versions}") + }; + + make_error!("No compatible reference minimizer index data is found for this dataset sever. Cannot proceed. \n\nThis version of Nextclade supports index versions up to '{}', but the server has {}.\n\nTry to to upgrade Nextclade to the latest version and/or contact dataset server maintainers.", MINIMIZER_INDEX_ALGO_VERSION, server_versions) + } + }?; + + run(args, &minimizer_index, verbose) +} + +pub fn run(args: &NextcladeSortArgs, minimizer_index: &MinimizerIndexJson, verbose: bool) -> Result<(), Report> { + let NextcladeSortArgs { + input_fastas, + search_params, + other_params: NextcladeRunOtherParams { jobs }, + .. + } = args; + + std::thread::scope(|s| { + const CHANNEL_SIZE: usize = 128; + let (fasta_sender, fasta_receiver) = crossbeam_channel::bounded::(CHANNEL_SIZE); + let (result_sender, result_receiver) = crossbeam_channel::bounded::(CHANNEL_SIZE); + + s.spawn(|| { + let mut reader = FastaReader::from_paths(input_fastas).unwrap(); + loop { + let mut record = FastaRecord::default(); + reader.read(&mut record).unwrap(); + if record.is_empty() { + break; + } + fasta_sender + .send(record) + .wrap_err("When sending a FastaRecord") + .unwrap(); + } + drop(fasta_sender); + }); + + for _ in 0..*jobs { + let fasta_receiver = fasta_receiver.clone(); + let result_sender = result_sender.clone(); + + s.spawn(move || { + let result_sender = result_sender.clone(); + + for fasta_record in &fasta_receiver { + trace!("Processing sequence '{}'", fasta_record.seq_name); + + let result = run_minimizer_search(&fasta_record, minimizer_index, search_params) + .wrap_err_with(|| { + format!( + "When processing sequence #{} '{}'", + fasta_record.index, fasta_record.seq_name + ) + }) + .unwrap(); + + result_sender + .send(MinimizerSearchRecord { fasta_record, result }) + .wrap_err("When sending minimizer record into the channel") + .unwrap(); + } + + drop(result_sender); + }); + } + + let writer = s.spawn(move || { + writer_thread(args, result_receiver, verbose).unwrap(); + }); + }); + + Ok(()) +} + +#[derive(Clone, Default, Debug, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +struct SeqSortCsvEntry<'a> { + seq_name: &'a str, + dataset: Option<&'a str>, + score: Option, + num_hits: Option, +} + +fn writer_thread( + args: &NextcladeSortArgs, + result_receiver: crossbeam_channel::Receiver, + verbose: bool, +) -> Result<(), Report> { + let NextcladeSortArgs { + output_dir, + output_path, + output_results_tsv, + .. + } = args; + + let template = output_path.map_ref_fallible(move |output_path| -> Result { + let mut template = TinyTemplate::new(); + template + .add_template("output", output_path) + .wrap_err_with(|| format!("When parsing template: '{output_path}'"))?; + Ok(template) + })?; + + let mut writers = BTreeMap::new(); + let mut stats = StatsPrinter::new(verbose); + + let mut results_csv = + output_results_tsv.map_ref_fallible(|output_results_tsv| CsvStructFileWriter::new(output_results_tsv, b'\t'))?; + + for record in result_receiver { + stats.print_seq(&record); + + let datasets = &record.result.datasets; + + if datasets.is_empty() { + results_csv.map_mut_fallible(|results_csv| { + results_csv.write(&SeqSortCsvEntry { + seq_name: &record.fasta_record.seq_name, + dataset: None, + score: None, + num_hits: None, + }) + })?; + } + + for dataset in datasets { + let name = &dataset.name; + + results_csv.map_mut_fallible(|results_csv| { + results_csv.write(&SeqSortCsvEntry { + seq_name: &record.fasta_record.seq_name, + dataset: Some(&dataset.name), + score: Some(dataset.score), + num_hits: Some(dataset.n_hits), + }) + })?; + + let names = name + .split('/') + .scan(PathBuf::new(), |name, component| { + *name = name.join(component); + Some(name.clone()) + }) + .unique() + .map(path_to_string) + .collect::, Report>>()?; + + for name in names { + let filepath = get_filepath(&name, &template, output_dir)?; + + if let Some(filepath) = filepath { + let writer = get_or_insert_writer(&mut writers, filepath)?; + writer.write(&record.fasta_record.seq_name, &record.fasta_record.seq, false)?; + } + } + } + } + + stats.finish(); + + Ok(()) +} + +struct StatsPrinter { + enabled: bool, + stats: BTreeMap, + n_undetected: usize, +} + +impl StatsPrinter { + pub fn new(enabled: bool) -> Self { + if enabled { + println!("Suggested datasets for each sequence"); + println!("{}┐", "─".repeat(110)); + println!( + "{:^40} │ {:^40} │ {:^10} │ {:^10} │", + "Sequence name", "Dataset", "Score", "Num. hits" + ); + println!("{}┤", "─".repeat(110)); + } + + Self { + enabled, + stats: BTreeMap::new(), + n_undetected: 0, + } + } + + pub fn print_seq(&mut self, record: &MinimizerSearchRecord) { + if !self.enabled { + return; + } + + let datasets = record + .result + .datasets + .iter() + .sorted_by_key(|dataset| -OrderedFloat(dataset.score)) + .collect_vec(); + + print!("{:<40}", truncate(&record.fasta_record.seq_name, 40)); + + if datasets.is_empty() { + println!(" │ {:40} │ {:>10.3} │ {:>10} │", "undetected".red(), "", ""); + self.n_undetected += 1; + } + + for (i, dataset) in datasets.into_iter().enumerate() { + let name = &dataset.name; + *self.stats.entry(name.clone()).or_insert(1) += 1; + + if i != 0 { + print!("{:<40}", ""); + } + + println!( + " │ {:40} │ {:>10.3} │ {:>10} │", + &truncate(&dataset.name, 40), + &dataset.score, + &dataset.n_hits, + ); + } + + println!("{}┤", "─".repeat(110)); + } + + pub fn finish(&self) { + if !self.enabled { + return; + } + + println!("\n\nSuggested datasets"); + println!("{}┐", "─".repeat(67)); + println!("{:^40} │ {:^10} │ {:^10} │", "Dataset", "Num. seq", "Percent"); + println!("{}┤", "─".repeat(67)); + + let total_seq = self.stats.values().sum::() + self.n_undetected; + let stats = self + .stats + .iter() + .sorted_by_key(|(name, n_seq)| (-(**n_seq as isize), (*name).clone())); + + for (name, n_seq) in stats { + println!( + "{:<40} │ {:>10} │ {:>9.3}% │", + name, + n_seq, + 100.0 * (*n_seq as f64 / total_seq as f64) + ); + } + + if self.n_undetected > 0 { + println!("{}┤", "─".repeat(67)); + println!( + "{:<40} │ {:>10} │ {:>10} │", + "undetected".red(), + self.n_undetected.red(), + format!("{:>9.3}%", 100.0 * (self.n_undetected as f64 / total_seq as f64)).red() + ); + } + + println!("{}┤", "─".repeat(67)); + println!( + "{:>40} │ {:>10} │ {:>10} │", + "total".bold(), + total_seq.bold(), + format!("{:>9.3}%", 100.0).bold() + ); + println!("{}┘", "─".repeat(67)); + } +} + +fn get_or_insert_writer( + writers: &mut BTreeMap, + filepath: impl AsRef, +) -> Result<&mut FastaWriter, Report> { + Ok(match writers.entry(filepath.as_ref().to_owned()) { + Occupied(e) => e.into_mut(), + Vacant(e) => e.insert(FastaWriter::from_path(filepath)?), + }) +} + +fn get_filepath( + name: &str, + tt: &Option, + output_dir: &Option, +) -> Result, Report> { + Ok(match (&tt, output_dir) { + (Some(tt), None) => { + let filepath_str = tt + .render("output", &OutputTemplateContext { name }) + .wrap_err("When rendering output path template")?; + + Some(PathBuf::from_str(&filepath_str).wrap_err_with(|| format!("Invalid output path: '{filepath_str}'"))?) + } + (None, Some(output_dir)) => Some(output_dir.join(name).join("sequences.fasta")), + _ => None, + }) +} + +#[derive(Serialize)] +struct OutputTemplateContext<'a> { + name: &'a str, +} + +fn check_args(args: &NextcladeSortArgs) -> Result<(), Report> { + let NextcladeSortArgs { + output_dir, + output_path: output, + .. + } = args; + + if output.is_some() && output_dir.is_some() { + return make_error!( + "The arguments `--output-dir` and `--output` cannot be used together. Remove one or the other." + ); + } + + if let Some(output) = output { + if !output.contains("{name}") { + return make_error!( + r#" +Expected `--output` argument to contain a template string containing template variable {{name}} (with curly braces), but received: + + {output} + +Make sure the variable is not substituted by your shell, programming language or workflow manager. Apply proper escaping as needed. +Example for bash shell: + + --output='outputs/{{name}}/sorted.fasta.gz' + + "# + ); + } + } + + Ok(()) +} diff --git a/packages_rs/nextclade-web/Cargo.toml b/packages_rs/nextclade-web/Cargo.toml index 8288ad8f1..08f8b8da2 100644 --- a/packages_rs/nextclade-web/Cargo.toml +++ b/packages_rs/nextclade-web/Cargo.toml @@ -12,6 +12,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] assert2 = "=0.3.11" +chrono = { version = "=0.4.26", default-features = false, features = ["clock", "std", "wasmbind"] } console_error_panic_hook = "=0.1.7" eyre = "=0.6.8" getrandom = { version = "=0.2.10", features = ["js"] } @@ -19,12 +20,12 @@ itertools = "=0.11.0" js-sys = { version = "=0.3.64", features = [] } log = "=0.4.19" nextclade = { path = "../nextclade" } +schemars = { version = "=0.8.12", features = ["chrono", "either", "enumset", "indexmap1"] } serde = { version = "=1.0.164", features = ["derive"] } serde-wasm-bindgen = { version = "=0.5.0" } wasm-bindgen = { version = "=0.2.87", features = ["serde-serialize"] } wasm-logger = "=0.2.0" web-sys = { version = "=0.3.64", features = ["console"] } -schemars = { version = "=0.8.12", features = ["chrono", "either", "enumset", "indexmap1"] } [build-dependencies] nextclade = { path = "../nextclade" } diff --git a/packages_rs/nextclade-web/package.json b/packages_rs/nextclade-web/package.json index 0c7446ac1..7c5b95673 100644 --- a/packages_rs/nextclade-web/package.json +++ b/packages_rs/nextclade-web/package.json @@ -117,6 +117,7 @@ "luxon": "2.3.2", "marked": "4.0.14", "memoize-one": "6.0.0", + "nanoid": "3.3.6", "next": "12.1.6", "next-compose-plugins": "2.2.1", "numbro": "2.3.6", @@ -133,7 +134,7 @@ "react-file-icon": "1.1.0", "react-helmet": "6.1.0", "react-i18next": "11.3.3", - "react-icons": "4.3.1", + "react-icons": "4.11.0", "react-if": "4.1.4", "react-loader-spinner": "5.1.4", "react-markdown": "6.0.3", @@ -239,13 +240,13 @@ "allow-methods": "3.1.0", "babel-plugin-parameter-decorator": "1.0.16", "babel-plugin-transform-typescript-metadata": "0.3.2", + "commander": "10.0.1", "compression-webpack-plugin": "9.2.0", "connect-history-api-fallback": "1.6.0", "conventional-changelog-cli": "2.2.2", "copy-webpack-plugin": "10.2.4", "cross-env": "7.0.3", "css-loader": "6.7.1", - "commander": "10.0.1", "dotenv": "16.0.0", "eslint": "8.14.0", "eslint-config-airbnb": "19.0.4", diff --git a/packages_rs/nextclade-web/src/build.rs b/packages_rs/nextclade-web/src/build.rs index 9d6b85f9e..6dcd7284b 100644 --- a/packages_rs/nextclade-web/src/build.rs +++ b/packages_rs/nextclade-web/src/build.rs @@ -16,6 +16,8 @@ use nextclade::qc::qc_run::QcResult; use nextclade::run::nextclade_wasm::{ AnalysisInitialData, AnalysisInput, NextcladeParams, NextcladeParamsRaw, NextcladeResult, OutputTrees, }; +use nextclade::sort::minimizer_index::MinimizerIndexJson; +use nextclade::sort::minimizer_search::{MinimizerSearchRecord, MinimizerSearchResult}; use nextclade::translate::translate_genes::Translation; use nextclade::tree::tree::{AuspiceTree, CladeNodeAttrKeyDesc}; use nextclade::types::outputs::{NextcladeErrorOutputs, NextcladeOutputs}; @@ -73,4 +75,7 @@ struct _SchemaRoot<'a> { _27: DatasetAttributeValue, _28: DatasetAttributes, _29: DatasetCollectionUrl, + _30: MinimizerIndexJson, + _31: MinimizerSearchResult, + _32: MinimizerSearchRecord, } diff --git a/packages_rs/nextclade-web/src/components/Autodetect/AutodetectPage.tsx b/packages_rs/nextclade-web/src/components/Autodetect/AutodetectPage.tsx new file mode 100644 index 000000000..4dd2a51e6 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Autodetect/AutodetectPage.tsx @@ -0,0 +1,249 @@ +// import classNames from 'classnames' +// import { sortBy } from 'lodash' +// import { mix, transparentize } from 'polished' +// import React, { useMemo } from 'react' +// import { Col as ColBase, Row as RowBase } from 'reactstrap' +// import { useRecoilValue } from 'recoil' +// import styled, { useTheme } from 'styled-components' +// import type { MinimizerIndexJson, MinimizerSearchRecord } from 'src/types' +// import { isEven } from 'src/helpers/number' +// import { TableSlim } from 'src/components/Common/TableSlim' +// import { Layout } from 'src/components/Layout/Layout' +// import { safeZip3 } from 'src/helpers/safeZip' +// import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +// import { autodetectResultsAtom, minimizerIndexAtom } from 'src/state/autodetect.state' +// +// const Container = styled.div` +// margin-top: 1rem; +// padding-bottom: 1.5rem; +// height: 100%; +// overflow: hidden; +// ` +// +// const Row = styled(RowBase)` +// overflow: hidden; +// height: 100%; +// ` +// +// const Col = styled(ColBase)` +// overflow: hidden; +// height: 100%; +// ` +// +// const Table = styled(TableSlim)` +// padding-top: 50px; +// +// & thead { +// height: 51px; +// position: sticky; +// top: -2px; +// background-color: ${(props) => props.theme.gray700}; +// color: ${(props) => props.theme.gray100}; +// } +// +// & thead th { +// margin: auto; +// text-align: center; +// vertical-align: middle; +// } +// +// & td { +// border: none; +// border-left: 1px solid #ccc; +// } +// +// & tr { +// border: none !important; +// } +// +// & th { +// border: 1px solid #ccc; +// } +// ` +// +// const TableWrapper = styled.div` +// height: 100%; +// overflow-y: auto; +// ` +// +// export function AutodetectPage() { +// const { t } = useTranslationSafe() +// const minimizerIndex = useRecoilValue(minimizerIndexAtom) +// const autodetectResults = useRecoilValue(autodetectResultsAtom) +// +// const rows = useMemo(() => { +// const results = sortBy(autodetectResults, (result) => result.fastaRecord.index) +// return results.map((res, i) => ( +// +// )) +// }, [autodetectResults, minimizerIndex]) +// +// return ( +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// +// {rows} +//
{'#'}{t('Seq. name')}{t('Length')}{t('Total nHits')}{t('Max norm. hit')}{t('Dataset')}{t('Ref. length')}{t('Num. nHits')}{t('Norm. hit')}
+//
+// +//
+//
+//
+// ) +// } +// +// interface AutodetectTableRowSpanProps { +// order: number +// res: MinimizerSearchRecord +// minimizerIndex: MinimizerIndexJson +// } +// +// function AutodetectTableRowSpan({ order, res, minimizerIndex }: AutodetectTableRowSpanProps) { +// const theme = useTheme() +// +// const { datasets, maxScore, totalHits } = res.result +// const { seqName, index: seqIndex, seq } = res.fastaRecord +// const qryLen = seq.length +// +// const rows = useMemo(() => { +// let entries = sortBy(datasets, (entry) => -entry.score) +// +// let color = isEven(order) ? theme.table.rowBg.even : theme.table.rowBg.odd +// +// const goodEntries = entries.filter(({ score, nHits }) => maxScore >= 0.6 && nHits >= 10 && score >= maxScore * 0.5) +// +// const mediocreEntries = entries.filter( +// ({ score, nHits }) => maxScore >= 0.3 && nHits >= 10 && score >= maxScore * 0.5, +// ) +// +// const badEntries = entries.filter(({ score, nHits }) => maxScore >= 0.05 && nHits > 0 && score >= maxScore * 0.5) +// +// if (goodEntries.length > 0) { +// entries = goodEntries +// } else if (mediocreEntries.length > 0) { +// entries = mediocreEntries +// color = mix(0.3, transparentize(0.3)(theme.warning), color) +// } else { +// entries = badEntries +// color = mix(0.5, transparentize(0.5)(theme.danger), color) +// } +// +// return entries.map(({ dataset, score, nHits, refLen }, i) => { +// const cls = classNames(i === 0 && 'font-weight-bold') +// +// return ( +// +// {i === 0 && ( +// <> +// +// {seqIndex} +// +// +// +// {seqName} +// +// +// +// {qryLen} +// +// +// +// {totalHits} +// +// +// +// {maxScore.toFixed(3)} +// +// +// )} +// +// {dataset} +// +// +// {refLen} +// +// +// {nHits} +// +// +// {score.toFixed(3)} +// +// +// ) +// }) +// }, [ +// datasets, +// order, +// theme.table.rowBg.even, +// theme.table.rowBg.odd, +// theme.warning, +// theme.danger, +// maxScore, +// seqIndex, +// seqName, +// qryLen, +// totalHits, +// ]) +// +// return ( +// <> +// {rows} +// +// +// +// +// ) +// } +// +// const Tr = styled.tr<{ $bg?: string }>` +// background-color: ${(props) => props.$bg}; +// ` +// +// const Td = styled.td` +// white-space: nowrap; +// overflow: hidden; +// text-overflow: ellipsis; +// max-width: 100px; +// font-size: 0.95rem; +// ` +// +// const TdName = styled(Td)` +// min-width: 200px; +// font-size: 0.9rem; +// ` +// +// const TdNumeric = styled(Td)` +// text-align: right; +// font-family: ${(props) => props.theme.font.monospace}; +// font-size: 0.9rem; +// ` +// +// const TdIndex = styled(TdNumeric)` +// background-color: ${(props) => props.theme.gray700}; +// color: ${(props) => props.theme.gray100}; +// ` +// +// const TrSpacer = styled.tr` +// height: 2px; +// +// & td { +// background-color: ${(props) => props.theme.gray400}; +// } +// ` diff --git a/packages_rs/nextclade-web/src/components/Common/List.tsx b/packages_rs/nextclade-web/src/components/Common/List.tsx index e8496a60f..0ec0fdc27 100644 --- a/packages_rs/nextclade-web/src/components/Common/List.tsx +++ b/packages_rs/nextclade-web/src/components/Common/List.tsx @@ -1,4 +1,4 @@ -import styled from 'styled-components' +import styled, { css } from 'styled-components' export const Ul = styled.ul` padding-left: 1.5rem; @@ -13,3 +13,35 @@ export const UlInvisible = styled.ul` export const LiInvisible = styled.li` list-style: none; ` + +// @formatter:off +// prettier-ignore +export const ScrollShadowVerticalCss = css` + /** Taken from: https://css-tricks.com/books/greatest-css-tricks/scroll-shadows */ + background: + /* Shadow Cover TOP */ linear-gradient(white 30%, rgba(255, 255, 255, 0)) center top, + /* Shadow Cover BOTTOM */ linear-gradient(rgba(255, 255, 255, 0), white 70%) center bottom, + /* Shadow TOP */ radial-gradient(farthest-side at 50% 0, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0)) center top, + /* Shadow BOTTOM */ radial-gradient(farthest-side at 50% 100%, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0)) center bottom; + background-repeat: no-repeat; + background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px; + background-attachment: local, local, scroll, scroll; +` +// @formatter:on + +export const ListGenericCss = css` + ${ScrollShadowVerticalCss}; + list-style: none; + padding: 0; + margin: 0; + -webkit-overflow-scrolling: touch; + overflow-scrolling: touch; + + & li { + border: 0; + } +` + +export const UlGeneric = styled.ul` + ${ListGenericCss} +` diff --git a/packages_rs/nextclade-web/src/components/Common/SearchBox.tsx b/packages_rs/nextclade-web/src/components/Common/SearchBox.tsx new file mode 100644 index 000000000..c5b634d7a --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Common/SearchBox.tsx @@ -0,0 +1,96 @@ +import React, { ChangeEvent, useCallback, useMemo, HTMLProps } from 'react' +import styled from 'styled-components' +import { Form, Input as InputBase } from 'reactstrap' +import { MdSearch as IconSearchBase, MdClear as IconClearBase } from 'react-icons/md' +import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { ButtonTransparent } from 'src/components/Common/ButtonTransparent' + +const SearchForm = styled(Form)` + display: inline; + position: relative; +` + +const IconSearchWrapper = styled.span` + display: inline; + position: absolute; + padding: 5px 7px; +` + +const IconSearch = styled(IconSearchBase)` + * { + color: ${(props) => props.theme.gray500}; + } +` + +const ButtonClear = styled(ButtonTransparent)` + display: inline; + position: absolute; + right: 0; + padding: 0 7px; +` + +const IconClear = styled(IconClearBase)` + * { + color: ${(props) => props.theme.gray500}; + } +` + +const Input = styled(InputBase)` + display: inline !important; + padding-left: 35px; + padding-right: 30px; + height: 2.2em; +` + +export interface SearchBoxProps extends Omit, 'as'> { + searchTitle?: string + searchTerm: string + onSearchTermChange(term: string): void +} + +export function SearchBox({ searchTitle, searchTerm, onSearchTermChange, ...restProps }: SearchBoxProps) { + const { t } = useTranslationSafe() + + const onChange = useCallback( + (event: ChangeEvent) => { + onSearchTermChange(event.target.value) + }, + [onSearchTermChange], + ) + + const onClear = useCallback(() => { + onSearchTermChange('') + }, [onSearchTermChange]) + + const buttonClear = useMemo(() => { + if (searchTerm.length === 0) { + return null + } + return ( + + + + ) + }, [onClear, searchTerm.length, t]) + + return ( + + + + + + {buttonClear} + + ) +} diff --git a/packages_rs/nextclade-web/src/components/Common/Toggle.tsx b/packages_rs/nextclade-web/src/components/Common/Toggle.tsx index ab6afbcb6..5f15d029a 100644 --- a/packages_rs/nextclade-web/src/components/Common/Toggle.tsx +++ b/packages_rs/nextclade-web/src/components/Common/Toggle.tsx @@ -6,6 +6,8 @@ import ReactToggle, { ToggleProps as ReactToggleProps } from 'react-toggle' import 'react-toggle/style.css' export const ToggleBase = styled(ReactToggle)` + display: block; + &.react-toggle-custom { & > .react-toggle-track { background-color: #9c3434; diff --git a/packages_rs/nextclade-web/src/components/FilePicker/FilePicker.tsx b/packages_rs/nextclade-web/src/components/FilePicker/FilePicker.tsx index 30f2e95b9..87aaf3d68 100644 --- a/packages_rs/nextclade-web/src/components/FilePicker/FilePicker.tsx +++ b/packages_rs/nextclade-web/src/components/FilePicker/FilePicker.tsx @@ -16,6 +16,7 @@ import { UploadedFileInfo } from './UploadedFileInfo' import { UploadedFileInfoCompact } from './UploadedFileInfoCompact' export const FilePickerContainer = styled.div` + flex: 1; display: flex; flex-direction: column; ` @@ -38,7 +39,11 @@ export const FilePickerTitle = styled.h4` margin: auto 0; ` -export const TabsPanelStyled = styled(TabsPanel)`` +export const TabsPanelStyled = styled(TabsPanel)` + * { + background: transparent !important; + } +` const TabsContentStyled = styled(TabsContent)` height: 100%; @@ -106,12 +111,12 @@ export function FilePicker({ const onPaste = useCallback( (content: string) => { if (multiple) { - onInputs?.([new AlgorithmInputString(content)]) + onInputs?.([new AlgorithmInputString(content, t('Pasted sequences'))]) } else { - onInput?.(new AlgorithmInputString(content)) + onInput?.(new AlgorithmInputString(content, t('Pasted sequences'))) } }, - [multiple, onInput, onInputs], + [multiple, onInput, onInputs, t], ) // eslint-disable-next-line no-void diff --git a/packages_rs/nextclade-web/src/components/FilePicker/UploadBox.tsx b/packages_rs/nextclade-web/src/components/FilePicker/UploadBox.tsx index 24d1995e6..42a711b95 100644 --- a/packages_rs/nextclade-web/src/components/FilePicker/UploadBox.tsx +++ b/packages_rs/nextclade-web/src/components/FilePicker/UploadBox.tsx @@ -56,6 +56,7 @@ export const UploadZoneTextContainer = styled.div` export const UploadZoneText = styled.div` font-size: 1.1rem; text-align: center; + max-width: 150px; ` export const UploadZoneButton = styled(Button)` @@ -83,7 +84,7 @@ export function UploadBox({ onUpload, children, multiple = false, ...props }: Pr const normal = useMemo( () => ( - {t('Drag & drop files')} + {t('Drag & drop files or folders')} {t('Select files')} ), diff --git a/packages_rs/nextclade-web/src/components/Layout/LanguageSwitcher.tsx b/packages_rs/nextclade-web/src/components/Layout/LanguageSwitcher.tsx index d0455bf72..a788f45ec 100644 --- a/packages_rs/nextclade-web/src/components/Layout/LanguageSwitcher.tsx +++ b/packages_rs/nextclade-web/src/components/Layout/LanguageSwitcher.tsx @@ -1,7 +1,13 @@ import React, { useCallback, useMemo, useState } from 'react' -import { Dropdown, DropdownToggle, DropdownMenu, DropdownItem, DropdownProps } from 'reactstrap' +import { + Dropdown as DropdownBase, + DropdownToggle as DropdownToggleBase, + DropdownMenu as DropdownMenuBase, + DropdownItem, + DropdownProps, +} from 'reactstrap' import { useRecoilState } from 'recoil' - +import styled from 'styled-components' import { localeAtom } from 'src/state/locale.state' import { getLocaleWithKey, Locale, localesArray } from 'src/i18n/i18n' @@ -14,11 +20,11 @@ export function LanguageSwitcher({ ...restProps }: LanguageSwitcherProps) { const setLocaleLocal = useCallback((locale: Locale) => () => setCurrentLocale(locale.key), [setCurrentLocale]) return ( - + - + - + {localesArray.map((locale) => { const isCurrent = locale.key === currentLocale return ( @@ -33,20 +39,42 @@ export function LanguageSwitcher({ ...restProps }: LanguageSwitcherProps) { } export function LanguageSwitcherItem({ locale }: { locale: string }) { - const { Flag, name, native } = getLocaleWithKey(locale) - - const label = useMemo(() => { - if (name === native) { - return name - } - - return `${native} (${name})` + const { name, native } = getLocaleWithKey(locale) + const { label, tooltip } = useMemo(() => { + return { label: `(${native})`, tooltip: `${name} (${native})` } }, [name, native]) - return ( - <> - - {label} - + + + {label} + ) } + +export function LabelShort({ locale, ...restProps }: { locale: string; className?: string }) { + const { key } = getLocaleWithKey(locale) + return {key} +} + +const LabelShortText = styled.span` + font-family: ${(props) => props.theme.font.monospace}; + text-transform: uppercase !important; + color: unset !important; +` + +const Dropdown = styled(DropdownBase)` + padding: 0; + margin: 0; +` + +const DropdownToggle = styled(DropdownToggleBase)` + color: ${(props) => props.theme.bodyColor}; + padding: 0; + margin: 0; +` + +const DropdownMenu = styled(DropdownMenuBase)` + background-color: ${(props) => props.theme.bodyBg}; + box-shadow: 1px 1px 20px 0 #0005; + transition: opacity ease-out 0.25s; +` diff --git a/packages_rs/nextclade-web/src/components/Layout/NavigationBar.tsx b/packages_rs/nextclade-web/src/components/Layout/NavigationBar.tsx index b7933b6b3..89f737e01 100644 --- a/packages_rs/nextclade-web/src/components/Layout/NavigationBar.tsx +++ b/packages_rs/nextclade-web/src/components/Layout/NavigationBar.tsx @@ -8,7 +8,7 @@ import { } from 'reactstrap' import { useRecoilValue } from 'recoil' import { Link } from 'src/components/Link/Link' -import { FaDocker, FaGithub, FaTwitter } from 'react-icons/fa' +import { FaDocker, FaGithub, FaXTwitter, FaDiscourse } from 'react-icons/fa6' import { LinkSmart } from 'src/components/Link/LinkSmart' import { hasRanAtom, hasTreeAtom } from 'src/state/results.state' import styled from 'styled-components' @@ -18,15 +18,14 @@ import { CitationButton } from 'src/components/Citation/CitationButton' import { NextcladeTextLogo } from 'src/components/Layout/NextcladeTextLogo' import { LanguageSwitcher } from './LanguageSwitcher' -const LOGO_SIZE = 30 +const LOGO_SIZE = 36 export const Navbar = styled(NavbarBase)` - height: 38px; + height: 45px; display: flex; - vertical-align: middle; padding: 0 !important; margin: 0 !important; - box-shadow: ${(props) => props.theme.shadows.large}; + box-shadow: 0 0 8px 0 #0004; ` export const Nav = styled(NavBase)` @@ -40,7 +39,11 @@ export const NavItem = styled(NavItemBase)` padding: 0 0.5rem; flex-grow: 0; flex-shrink: 0; - margin: 0 !important; + margin: auto; + + * { + vertical-align: middle; + } ` const NavbarBrand = styled(NavbarBrandBase)` @@ -138,18 +141,23 @@ export function NavigationBar() { }, { url: 'https://twitter.com/nextstrain', - title: t('Link to our Twitter'), - content: , + title: t('Link to our X.com (Twitter)'), + content: , + }, + { + url: 'https://discussion.nextstrain.org/', + title: t('Link to our discussion forum'), + content: , }, { url: 'https://hub.docker.com/r/nextstrain/nextclade', title: t('Link to our Docker containers'), - content: , + content: , }, { url: 'https://github.com/nextstrain/nextclade', title: t('Link to our Github page'), - content: , + content: , }, { title: t('Change language'), diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetCurrent.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetCurrent.tsx index af0807201..0c4d5167e 100644 --- a/packages_rs/nextclade-web/src/components/Main/DatasetCurrent.tsx +++ b/packages_rs/nextclade-web/src/components/Main/DatasetCurrent.tsx @@ -1,5 +1,5 @@ import { isNil } from 'lodash' -import React, { useCallback, useState } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import { Button, Col, Collapse, Row, UncontrolledAlert } from 'reactstrap' import { useRecoilState, useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil' import styled from 'styled-components' @@ -82,6 +82,28 @@ export function DatasetCurrent() { const onCustomizeClicked = useCallback(() => setAdvancedOpen((advancedOpen) => !advancedOpen), []) + const customize = useMemo(() => { + if (datasetCurrent?.path === 'autodetect') { + return null + } + + return ( + + + + + + + + + + + + + + ) + }, [advancedOpen, datasetCurrent?.path, onCustomizeClicked]) + if (!datasetCurrent) { return null } @@ -105,29 +127,11 @@ export function DatasetCurrent() { {t('Change')} - - {t('Recent dataset updates')} - - - - - - - - - - - - - - + {customize} ) diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetInfo.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetInfo.tsx index 0ea36038c..db8e79afc 100644 --- a/packages_rs/nextclade-web/src/components/Main/DatasetInfo.tsx +++ b/packages_rs/nextclade-web/src/components/Main/DatasetInfo.tsx @@ -1,13 +1,50 @@ +import { isNil } from 'lodash' +import { darken } from 'polished' import React, { useMemo } from 'react' import { Badge } from 'reactstrap' - -import styled from 'styled-components' - -import type { Dataset } from 'src/types' +import { useRecoilValue } from 'recoil' +import { colorHash } from 'src/helpers/colorHash' import { formatDateIsoUtcSimple } from 'src/helpers/formatDate' +import { firstLetter } from 'src/helpers/string' import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { + autodetectResultsByDatasetAtom, + DATASET_ID_UNDETECTED, + numberAutodetectResultsAtom, +} from 'src/state/autodetect.state' +import type { Dataset } from 'src/types' +import styled from 'styled-components' + +export const Container = styled.div` + display: flex; + //border: 1px #ccc9 solid; + //border-radius: 5px; + + //margin-top: 3px !important; + //margin-bottom: 3px !important; + //margin-left: 5px; + //padding: 15px; + + margin: 0; + padding: 15px; + box-shadow: 0 0 12px 0 #0002; + border: 1px #ccc9 solid; + border-radius: 5px; +` + +export const FlexLeft = styled.div` + flex: 0; + display: flex; + flex-direction: column; + margin: auto 0; +` -export const DatasetInfoContainer = styled.div`` +export const FlexRight = styled.div` + flex: 1; + display: flex; + flex-direction: column; + margin-left: 1rem; +` export const DatasetName = styled.h4` display: flex; @@ -21,6 +58,11 @@ export const DatasetInfoLine = styled.p` font-size: 0.9rem; padding: 0; margin: 0; + + &:after { + content: ' '; + white-space: pre; + } ` const DatasetInfoBadge = styled(Badge)` @@ -50,60 +92,168 @@ export function DatasetInfo({ dataset }: DatasetInfoProps) { return null } + if (path === DATASET_ID_UNDETECTED) { + return + } + + return ( + + + + + + + + {name.valueFriendly ?? name.value ?? path} + + + {official ? ( + + {t('official')} + + ) : ( + + {t('community')} + + )} + + {experimental && ( + + {t('experimental')} + + )} + + {deprecated && ( + + {t('deprecated')} + + )} + + + + + {t('Reference: {{ name }} ({{ accession }})', { + name: reference.valueFriendly ?? 'Untitled', + accession: reference.value, + })} + + {t('Updated at: {{updated}}', { updated: updatedAt })} + {t('Dataset name: {{name}}', { name: path })} + + + ) +} + +export function DatasetUndetectedInfo() { + const { t } = useTranslationSafe() + return ( - + - {name.valueFriendly ?? name.value ?? path} - - - {official ? ( - - {t('official')} - - ) : ( - - {t('community')} - - )} - - {experimental && ( - - {t('experimental')} - - )} - - {deprecated && ( - - {t('deprecated')} - - )} - + {t('Autodetect')} + {t('Detect pathogen automatically from sequences')} + + + + ) +} + +export interface DatasetInfoCircleProps { + dataset: Dataset +} + +function DatasetInfoAutodetectProgressCircle({ dataset }: DatasetInfoCircleProps) { + const { attributes, path } = dataset + const { name } = attributes + + const circleBg = useMemo(() => darken(0.1)(colorHash(path, { saturation: 0.5, reverse: true })), [path]) + const records = useRecoilValue(autodetectResultsByDatasetAtom(path)) + const numberAutodetectResults = useRecoilValue(numberAutodetectResultsAtom) + + const { circleText, countText, percentage } = useMemo(() => { + if (isNil(records)) { + return { + circleText: (firstLetter(name.valueFriendly ?? name.value) ?? ' ').toUpperCase(), + percentage: 0, + countText: '\u00A0', + } + } + + if (records.length > 0) { + const percentage = records.length / numberAutodetectResults + const circleText = `${(100 * percentage).toFixed(0)}%` + const countText = `${records.length} / ${numberAutodetectResults}` + return { circleText, percentage, countText } + } + return { circleText: `0%`, percentage: 0, countText: `0 / ${numberAutodetectResults}` } + }, [records, name.value, name.valueFriendly, numberAutodetectResults]) + + return ( + <> + + {circleText} + - - {t('Reference: {{ name }} ({{ accession }})', { - name: reference.valueFriendly ?? 'Untitled', - accession: reference.value, - })} - - {t('Updated at: {{updated}}', { updated: updatedAt })} - {t('Dataset name: {{name}}', { name: path })} - + {countText} + ) } + +const CountText = styled.span` + text-align: center; + font-size: 0.8rem; +` + +interface CircleBorderProps { + $percentage: number + $fg?: string + $bg?: string +} + +const CircleBorder = styled.div.attrs((props) => ({ + style: { + background: ` + radial-gradient(closest-side, white 79%, transparent 80% 100%), + conic-gradient( + ${props.$fg ?? props.theme.success} calc(${props.$percentage} * 100%), + ${props.$bg ?? 'lightgray'} 0 + )`, + }, +}))` + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + width: 75px; + height: 75px; +` + +const Circle = styled.div<{ $bg?: string; $fg?: string }>` + display: flex; + margin: auto; + justify-content: center; + align-items: center; + border-radius: 50%; + background: ${(props) => props.$bg ?? props.theme.gray700}; + color: ${(props) => props.$fg ?? props.theme.gray100}; + width: 60px; + height: 60px; + font-size: 1.2rem; +` diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetSelector.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetSelector.tsx index 4a61c1ab5..ca7f290b4 100644 --- a/packages_rs/nextclade-web/src/components/Main/DatasetSelector.tsx +++ b/packages_rs/nextclade-web/src/components/Main/DatasetSelector.tsx @@ -1,35 +1,89 @@ -import React, { HTMLProps, useCallback, useState } from 'react' -import classNames from 'classnames' -import { ThreeDots } from 'react-loader-spinner' -import { Button, Col, Container, Input, Row } from 'reactstrap' +import React, { HTMLProps, useState } from 'react' import { useRecoilState, useRecoilValue } from 'recoil' import styled from 'styled-components' -import type { Dataset } from 'src/types' -import { datasetCurrentAtom, datasetsAtom } from 'src/state/dataset.state' +import { ThreeDots } from 'react-loader-spinner' +import { SuggestionPanel } from 'src/components/Main/SuggestionPanel' import { useTranslationSafe } from 'src/helpers/useTranslationSafe' -import { DatasetSelectorList } from './DatasetSelectorList' +import { datasetCurrentAtom, datasetsAtom } from 'src/state/dataset.state' +import { SearchBox } from 'src/components/Common/SearchBox' +import { DatasetSelectorList } from 'src/components/Main/DatasetSelectorList' + +export function DatasetSelector() { + const { t } = useTranslationSafe() + const [searchTerm, setSearchTerm] = useState('') + const { datasets } = useRecoilValue(datasetsAtom) + const [datasetCurrent, setDatasetCurrent] = useRecoilState(datasetCurrentAtom) + + const isBusy = datasets.length === 0 + + return ( + +
+ {t('Select dataset')} + + +
+ +
+ {!isBusy && ( + + )} + + {isBusy && ( + + + + + + )} +
+ +
+ +
+
+ ) +} -const DatasetSelectorContainer = styled(Container)` +const Container = styled.div` display: flex; + flex: 1; flex-direction: column; - width: 100%; height: 100%; overflow: hidden; - padding: 0; + margin-right: 10px; ` -const DatasetSelectorTitle = styled.h4` - flex: 1; - margin: auto 0; +const Header = styled.div` + display: flex; + flex: 0; + padding-left: 10px; + margin-top: 10px; + margin-bottom: 3px; ` -const DatasetSelectorListContainer = styled.section` +const Main = styled.div` display: flex; - width: 100%; - height: 100%; + flex: 1; + flex-direction: column; overflow: hidden; ` +const Footer = styled.div` + display: flex; + flex: 0; +` + +const Title = styled.h4` + flex: 1; + margin: auto 0; +` + const SpinnerWrapper = styled.div>` width: 100%; height: 100%; @@ -45,97 +99,3 @@ const Spinner = styled(ThreeDots)` margin: auto; height: 100%; ` - -export interface DatasetSelectorProps { - searchTerm: string - setSearchTerm(searchTerm: string): void -} - -export function DatasetSelector({ searchTerm, setSearchTerm }: DatasetSelectorProps) { - const { t } = useTranslationSafe() - const [error, setError] = useState() - const { datasets } = useRecoilValue(datasetsAtom) - const [datasetCurrent, setDatasetCurrent] = useRecoilState(datasetCurrentAtom) - const [datasetHighlighted, setDatasetHighlighted] = useState(datasetCurrent) - - const onSearchTermChange = useCallback( - (event: React.ChangeEvent) => { - const { value } = event.target - setSearchTerm(value) - }, - [setSearchTerm], - ) - - const onNextClicked = useCallback(() => { - if (datasetHighlighted) { - setDatasetCurrent(datasetHighlighted) - setError(undefined) - } else { - setError(t('Please select a pathogen first')) - } - }, [datasetHighlighted, setDatasetCurrent, t]) - - const isBusy = datasets.length === 0 - - return ( - - - - {t('Select a pathogen')} - - - - - - - - - - - {!isBusy && ( - - )} - - {isBusy && ( - - - - - - )} - - - - - - - {error &&

{error}

} - - -
-
- ) -} diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetSelectorList.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetSelectorList.tsx index 6ccdafb79..2bb5be734 100644 --- a/packages_rs/nextclade-web/src/components/Main/DatasetSelectorList.tsx +++ b/packages_rs/nextclade-web/src/components/Main/DatasetSelectorList.tsx @@ -1,52 +1,20 @@ -import React, { useCallback, useMemo } from 'react' - -import { ListGroup, ListGroupItem } from 'reactstrap' -import styled from 'styled-components' - +import { get, isNil, sortBy } from 'lodash' +import { lighten } from 'polished' +import React, { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react' +import { ListGroup } from 'reactstrap' +import { useRecoilState, useRecoilValue } from 'recoil' +import { ListGenericCss } from 'src/components/Common/List' +import { DatasetInfo } from 'src/components/Main/DatasetInfo' +import { search } from 'src/helpers/search' +import { + autodetectResultsAtom, + AutodetectRunState, + autodetectRunStateAtom, + groupByDatasets, +} from 'src/state/autodetect.state' import type { Dataset } from 'src/types' import { areDatasetsEqual } from 'src/types' -import { search } from 'src/helpers/search' -import { DatasetInfo } from 'src/components/Main/DatasetInfo' - -// export const DatasetSelectorContainer = styled.div` -// flex: 1 0 100%; -// display: flex; -// flex-direction: column; -// overflow: hidden; -// height: 100%; -// border: 1px #ccc solid; -// border-radius: 5px; -// ` - -export const DatasetSelectorUl = styled(ListGroup)` - flex: 1; - overflow-y: scroll; - height: 100%; -` - -export const DatasetSelectorLi = styled(ListGroupItem)<{ $isDimmed?: boolean }>` - list-style: none; - margin: 0; - padding: 0.5rem; - cursor: pointer; - opacity: ${(props) => props.$isDimmed && 0.33}; - background-color: transparent; -` - -export interface DatasetSelectorListItemProps { - dataset: Dataset - isCurrent?: boolean - isDimmed?: boolean - onClick?: () => void -} - -export function DatasetSelectorListItem({ dataset, isCurrent, isDimmed, onClick }: DatasetSelectorListItemProps) { - return ( - - - - ) -} +import styled from 'styled-components' export interface DatasetSelectorListProps { datasets: Dataset[] @@ -64,44 +32,151 @@ export function DatasetSelectorList({ }: DatasetSelectorListProps) { const onItemClick = useCallback((dataset: Dataset) => () => onDatasetHighlighted(dataset), [onDatasetHighlighted]) - const { itemsStartWith, itemsInclude, itemsNotInclude } = useMemo(() => { + const autodetectResults = useRecoilValue(autodetectResultsAtom) + const [autodetectRunState, setAutodetectRunState] = useRecoilState(autodetectRunStateAtom) + + const autodetectResult = useMemo(() => { + if (isNil(autodetectResults) || autodetectResults.length === 0) { + return { itemsStartWith: [], itemsInclude: datasets, itemsNotInclude: [] } + } + + const recordsByDataset = groupByDatasets(autodetectResults) + + let itemsInclude = datasets.filter((candidate) => + Object.entries(recordsByDataset).some(([dataset, _]) => dataset === candidate.path), + ) + + itemsInclude = sortBy(itemsInclude, (dataset) => -get(recordsByDataset, dataset.path, []).length) + + const itemsNotInclude = datasets.filter((candidate) => !itemsInclude.map((it) => it.path).includes(candidate.path)) + + return { itemsStartWith: [], itemsInclude, itemsNotInclude } + }, [autodetectResults, datasets]) + + const searchResult = useMemo(() => { if (searchTerm.trim().length === 0) { - return { itemsStartWith: datasets, itemsInclude: [], itemsNotInclude: [] } + return autodetectResult + } + + return search( + [...autodetectResult.itemsStartWith, ...autodetectResult.itemsInclude, ...autodetectResult.itemsNotInclude], + searchTerm, + (dataset) => [ + dataset.attributes.name.value, + dataset.attributes.name.valueFriendly ?? '', + dataset.attributes.reference.value, + ], + ) + }, [autodetectResult, searchTerm]) + + const { itemsStartWith, itemsInclude, itemsNotInclude } = searchResult + + const itemsRef = useRef>(new Map()) + + function scrollToId(itemId: string) { + const node = itemsRef.current.get(itemId) + node?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center', + }) + } + + if (datasetHighlighted) { + scrollToId(datasetHighlighted.path) + } + + useEffect(() => { + const topSuggestion = autodetectResult.itemsInclude[0] + if (autodetectRunState === AutodetectRunState.Done) { + onDatasetHighlighted(topSuggestion) + setAutodetectRunState(AutodetectRunState.Idle) } + }, [autodetectRunState, autodetectResult.itemsInclude, onDatasetHighlighted, setAutodetectRunState]) - return search(datasets, searchTerm, (dataset) => [ - dataset.attributes.name.value, - dataset.attributes.name.valueFriendly ?? '', - dataset.attributes.reference.value, - ]) - }, [datasets, searchTerm]) - - return ( - // - - {[itemsStartWith, itemsInclude].map((datasets) => - datasets.map((dataset) => ( - - )), - )} - - {[itemsNotInclude].map((datasets) => - datasets.map((dataset) => ( - - )), - )} - - // - ) + const listItems = useMemo(() => { + return ( + <> + {[itemsStartWith, itemsInclude].map((datasets) => + datasets.map((dataset) => ( + + )), + )} + + {[itemsNotInclude].map((datasets) => + datasets.map((dataset) => ( + + )), + )} + + ) + }, [datasetHighlighted, itemsInclude, itemsNotInclude, itemsStartWith, onItemClick]) + + return
    {listItems}
} + +function nodeRefSetOrDelete(map: Map, key: string) { + return function nodeRefSetOrDeleteImpl(node: T) { + if (node) { + map.set(key, node) + } else { + map.delete(key) + } + } +} + +export const Ul = styled(ListGroup)` + ${ListGenericCss}; + flex: 1; + overflow: auto; + padding: 5px 5px; + border-radius: 0 !important; +` + +export const Li = styled.li<{ $active?: boolean; $isDimmed?: boolean }>` + cursor: pointer; + opacity: ${(props) => props.$isDimmed && 0.4}; + background-color: transparent; + + margin: 3px 3px !important; + padding: 0 !important; + border-radius: 5px !important; + + ${(props) => + props.$active && + ` + background-color: ${lighten(0.033)(props.theme.primary)}; + box-shadow: -3px 3px 12px 3px #0005; + opacity: ${props.$isDimmed && 0.66}; + `}; +` + +interface DatasetSelectorListItemProps { + dataset: Dataset + isCurrent?: boolean + isDimmed?: boolean + onClick?: () => void +} + +const DatasetSelectorListItem = forwardRef( + function DatasetSelectorListItemWithRef({ dataset, isCurrent, isDimmed, onClick }, ref) { + return ( +
  • + +
  • + ) + }, +) diff --git a/packages_rs/nextclade-web/src/components/Main/MainInputForm.tsx b/packages_rs/nextclade-web/src/components/Main/MainInputForm.tsx index 165973665..94b4b9e84 100644 --- a/packages_rs/nextclade-web/src/components/Main/MainInputForm.tsx +++ b/packages_rs/nextclade-web/src/components/Main/MainInputForm.tsx @@ -1,16 +1,14 @@ -import React, { useMemo, useState } from 'react' -import { useRecoilValue } from 'recoil' +import React from 'react' +import { QuerySequenceFilePicker } from 'src/components/Main/QuerySequenceFilePicker' import styled from 'styled-components' import { Col as ColBase, Row as RowBase } from 'reactstrap' -import { DatasetContentSection } from 'src/components/Main/DatasetContentSection' -import { DatasetSelector } from 'src/components/Main/DatasetSelector' -import { MainInputFormRunStep } from 'src/components/Main/MainInputFormRunStep' -import { datasetCurrentAtom } from 'src/state/dataset.state' import { useUpdatedDatasetIndex } from 'src/io/fetchDatasets' +import { DatasetSelector } from 'src/components/Main/DatasetSelector' const Container = styled.div` height: 100%; overflow: hidden; + margin-top: 10px; ` const Row = styled(RowBase)` @@ -24,29 +22,18 @@ const Col = styled(ColBase)` ` export function MainInputForm() { - const [searchTerm, setSearchTerm] = useState('') - const currentDataset = useRecoilValue(datasetCurrentAtom) - // This periodically fetches dataset index and updates the list of datasets. useUpdatedDatasetIndex() - const FormBody = useMemo( - () => - currentDataset ? ( - - ) : ( - - ), - [currentDataset, searchTerm], - ) - return ( - - + + + + + - {FormBody} ) diff --git a/packages_rs/nextclade-web/src/components/Main/MainInputFormRunStep.tsx b/packages_rs/nextclade-web/src/components/Main/MainInputFormRunStep.tsx deleted file mode 100644 index f06d76b6d..000000000 --- a/packages_rs/nextclade-web/src/components/Main/MainInputFormRunStep.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react' - -import { Col, Container, Row } from 'reactstrap' -import styled from 'styled-components' - -import { MainInputFormSequenceFilePicker } from 'src/components/Main/MainInputFormSequenceFilePicker' -import { DatasetCurrent } from './DatasetCurrent' - -const MainInputFormContainer = styled(Container)` - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - overflow: hidden; - margin: 0; - padding: 0; -` - -export function MainInputFormRunStep() { - return ( - - - - - - - - - - - - - - ) -} diff --git a/packages_rs/nextclade-web/src/components/Main/MainInputFormSequenceFilePicker.tsx b/packages_rs/nextclade-web/src/components/Main/MainInputFormSequenceFilePicker.tsx deleted file mode 100644 index 8b9a0f25a..000000000 --- a/packages_rs/nextclade-web/src/components/Main/MainInputFormSequenceFilePicker.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { noop } from 'lodash' -import React, { useCallback, useMemo } from 'react' -import { Button, Col, Form, FormGroup, Row } from 'reactstrap' -import { useRecoilState, useRecoilValue } from 'recoil' -import { MainInputFormSequencesCurrent } from 'src/components/Main/MainInputFormSequencesCurrent' -import { useRunAnalysis } from 'src/hooks/useRunAnalysis' -import { canRunAtom } from 'src/state/results.state' -import styled from 'styled-components' - -import { datasetCurrentAtom } from 'src/state/dataset.state' -import { hasInputErrorsAtom, qrySeqErrorAtom } from 'src/state/error.state' -import { shouldRunAutomaticallyAtom } from 'src/state/settings.state' -import type { AlgorithmInput } from 'src/types' -import { Toggle } from 'src/components/Common/Toggle' -import { FlexLeft, FlexRight } from 'src/components/FilePicker/FilePickerStyles' -import { useTranslationSafe } from 'src/helpers/useTranslationSafe' -import { AlgorithmInputDefault } from 'src/io/AlgorithmInput' -import { FilePicker } from 'src/components/FilePicker/FilePicker' -import { FileIconFasta } from 'src/components/Common/FileIcons' -import { hasRequiredInputsAtom, useQuerySeqInputs } from 'src/state/inputs.state' - -const SequenceFilePickerContainer = styled.section` - display: flex; - flex-direction: column; - width: 100%; - height: 100%; -` - -const ButtonRunStyled = styled(Button)` - min-width: 160px; - min-height: 50px; - margin-left: 1rem; -` - -export function MainInputFormSequenceFilePicker() { - const { t } = useTranslationSafe() - - const datasetCurrent = useRecoilValue(datasetCurrentAtom) - const { qryInputs, addQryInputs } = useQuerySeqInputs() - const qrySeqError = useRecoilValue(qrySeqErrorAtom) - - const canRun = useRecoilValue(canRunAtom) - const [shouldRunAutomatically, setShouldRunAutomatically] = useRecoilState(shouldRunAutomaticallyAtom) - const hasRequiredInputs = useRecoilValue(hasRequiredInputsAtom) - const hasInputErrors = useRecoilValue(hasInputErrorsAtom) - - const icon = useMemo(() => , []) - - const run = useRunAnalysis() - - const setSequences = useCallback( - (inputs: AlgorithmInput[]) => { - addQryInputs(inputs) - - if (shouldRunAutomatically) { - run() - } - }, - [addQryInputs, run, shouldRunAutomatically], - ) - - const setExampleSequences = useCallback(() => { - if (datasetCurrent) { - addQryInputs([new AlgorithmInputDefault(datasetCurrent)]) - - if (shouldRunAutomatically) { - run() - } - } - }, [addQryInputs, datasetCurrent, run, shouldRunAutomatically]) - - const { isRunButtonDisabled, runButtonColor, runButtonTooltip } = useMemo(() => { - const isRunButtonDisabled = !(canRun && hasRequiredInputs) || hasInputErrors - return { - isRunButtonDisabled, - runButtonColor: isRunButtonDisabled ? 'secondary' : 'success', - runButtonTooltip: isRunButtonDisabled - ? t('Please provide input files for the algorithm') - : t('Launch the algorithm!'), - } - }, [canRun, hasInputErrors, hasRequiredInputs, t]) - - const LoadExampleLink = useMemo(() => { - const cannotLoadExample = hasInputErrors || !datasetCurrent - return ( - - ) - }, [datasetCurrent, hasInputErrors, setExampleSequences, t]) - - const onToggleRunAutomatically = useCallback(() => { - setShouldRunAutomatically((shouldRunAutomatically) => !shouldRunAutomatically) - }, [setShouldRunAutomatically]) - - const headerText = useMemo(() => { - if (qryInputs.length > 0) { - return t('Add more sequence data') - } - return t('Provide sequence data') - }, [qryInputs.length, t]) - - return ( - - - - - - - - -
    - - - - {t('Run automatically')} - - - -
    -
    - - - {LoadExampleLink} - - - {t('Run')} - - - -
    -
    - ) -} diff --git a/packages_rs/nextclade-web/src/components/Main/MainInputFormSequencesCurrent.tsx b/packages_rs/nextclade-web/src/components/Main/MainInputFormSequencesCurrent.tsx deleted file mode 100644 index feef13229..000000000 --- a/packages_rs/nextclade-web/src/components/Main/MainInputFormSequencesCurrent.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React, { useCallback, useMemo } from 'react' -import { Button, Col, Container, Row } from 'reactstrap' -import styled from 'styled-components' -import { ImCross } from 'react-icons/im' -import { rgba } from 'polished' - -import { AlgorithmInput } from 'src/types' -import { ButtonTransparent } from 'src/components/Common/ButtonTransparent' -import { useTranslationSafe } from 'src/helpers/useTranslationSafe' -import { useQuerySeqInputs } from 'src/state/inputs.state' - -const SequencesCurrentWrapper = styled(Container)` - border: 1px #ccc9 solid; - border-radius: 5px; -` - -const InputFileInfoWrapper = styled.section` - box-shadow: ${(props) => `1px 1px 5px ${rgba(props.theme.black, 0.1)}`}; - border: 1px #ccc9 solid; - border-radius: 5px; - margin: 0.5rem 0; - padding: 0.5rem 1rem; -` - -export interface InputFileInfoProps { - input: AlgorithmInput - index: number -} - -export function InputFileInfo({ input, index }: InputFileInfoProps) { - const { t } = useTranslationSafe() - const { removeQryInput } = useQuerySeqInputs() - const onRemoveClicked = useCallback(() => { - removeQryInput(index) - }, [index, removeQryInput]) - - return ( - - - {input.description} - - - - - - ) -} - -export function MainInputFormSequencesCurrent() { - const { t } = useTranslationSafe() - const { qryInputs, clearQryInputs } = useQuerySeqInputs() - - const inputComponents = useMemo( - () => ( - - - {qryInputs.map((input, index) => ( - // eslint-disable-next-line react/no-array-index-key - - ))} - - - ), - [qryInputs], - ) - - const removeButton = useMemo( - () => - qryInputs.length > 0 ? ( - - - - - - ) : null, - - [clearQryInputs, qryInputs.length, t], - ) - const headerText = useMemo(() => { - if (qryInputs.length === 0) { - return null - } - return ( - - -

    {t("Sequence data you've added")}

    - -
    - ) - }, [qryInputs.length, t]) - - if (qryInputs.length === 0) { - return null - } - - return ( -
    - {headerText} - - - - {inputComponents} - {removeButton} - - - -
    - ) -} diff --git a/packages_rs/nextclade-web/src/components/Main/QuerySequenceFilePicker.tsx b/packages_rs/nextclade-web/src/components/Main/QuerySequenceFilePicker.tsx new file mode 100644 index 000000000..0c69f6f48 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/QuerySequenceFilePicker.tsx @@ -0,0 +1,104 @@ +import React, { useCallback, useMemo } from 'react' +import { useRecoilValue } from 'recoil' +import styled from 'styled-components' +import type { AlgorithmInput } from 'src/types' +import { QuerySequenceList } from 'src/components/Main/QuerySequenceList' +import { RunPanel } from 'src/components/Main/RunPanel' +import { useRunAnalysis } from 'src/hooks/useRunAnalysis' +import { useRunSeqAutodetect } from 'src/hooks/useRunSeqAutodetect' +import { useRecoilToggle } from 'src/hooks/useToggle' +import { qrySeqErrorAtom } from 'src/state/error.state' +import { shouldRunAutomaticallyAtom, shouldSuggestDatasetsAtom } from 'src/state/settings.state' +import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { FilePicker } from 'src/components/FilePicker/FilePicker' +import { FileIconFasta } from 'src/components/Common/FileIcons' +import { useQuerySeqInputs } from 'src/state/inputs.state' + +export function QuerySequenceFilePicker() { + const { t } = useTranslationSafe() + + const { qryInputs, addQryInputs } = useQuerySeqInputs() + const qrySeqError = useRecoilValue(qrySeqErrorAtom) + + const { state: shouldRunAutomatically } = useRecoilToggle(shouldRunAutomaticallyAtom) + const shouldSuggestDatasets = useRecoilValue(shouldSuggestDatasetsAtom) + + const icon = useMemo(() => , []) + + const runAnalysis = useRunAnalysis() + const runAutodetect = useRunSeqAutodetect() + + const setSequences = useCallback( + (inputs: AlgorithmInput[]) => { + addQryInputs(inputs) + if (shouldSuggestDatasets) { + runAutodetect() + } + if (shouldRunAutomatically) { + runAnalysis() + } + }, + [addQryInputs, runAnalysis, runAutodetect, shouldRunAutomatically, shouldSuggestDatasets], + ) + + const headerText = useMemo(() => { + if (qryInputs.length > 0) { + return t('Add more sequence data') + } + return t('Provide sequence data') + }, [qryInputs.length, t]) + + return ( + +
    + +
    + +
    + +
    + +
    + +
    +
    + ) +} + +const Container = styled.div` + display: flex; + flex: 1; + flex-direction: column; + height: 100%; + overflow: hidden; + margin-left: 10px; + margin-right: 12px; +` + +const Header = styled.div` + display: flex; + flex: 0; + margin-bottom: 15px; +` + +const Main = styled.div` + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; +` + +const Footer = styled.div` + display: flex; + flex: 0; +` diff --git a/packages_rs/nextclade-web/src/components/Main/QuerySequenceList.tsx b/packages_rs/nextclade-web/src/components/Main/QuerySequenceList.tsx new file mode 100644 index 000000000..61b0f91eb --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/QuerySequenceList.tsx @@ -0,0 +1,88 @@ +import React, { useCallback, useMemo } from 'react' +import { Button } from 'reactstrap' +import styled, { useTheme } from 'styled-components' +import { ImCross } from 'react-icons/im' +import { AlgorithmInput } from 'src/types' +import { ButtonTransparent } from 'src/components/Common/ButtonTransparent' +import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { useQuerySeqInputs } from 'src/state/inputs.state' +import { UlGeneric } from '../Common/List' + +export function QuerySequenceList() { + const { t } = useTranslationSafe() + const { qryInputs, clearQryInputs } = useQuerySeqInputs() + + const listItems = useMemo(() => { + return qryInputs.map((input, index) => ( +
  • + +
  • + )) + }, [qryInputs]) + + const headerText = useMemo(() => { + if (qryInputs.length === 0) { + return null + } + return ( +
    +

    {t("Sequence data you've added")}

    + +
    + ) + }, [clearQryInputs, qryInputs.length, t]) + + if (qryInputs.length === 0) { + return null + } + + return ( + <> + {headerText} +
      {listItems}
    + + ) +} + +export const Ul = styled(UlGeneric)` + flex: 1; + overflow: auto; +` + +export const Li = styled.li` + margin: 5px 0; + border-radius: 5px !important; +` + +export interface InputFileInfoProps { + input: AlgorithmInput + index: number +} + +export function InputFileInfo({ input, index }: InputFileInfoProps) { + const { t } = useTranslationSafe() + const theme = useTheme() + const { removeQryInput } = useQuerySeqInputs() + const onRemoveClicked = useCallback(() => { + removeQryInput(index) + }, [index, removeQryInput]) + + return ( + +
    {input.description}
    + + + +
    + ) +} + +const Container = styled.section` + display: flex; + padding: 0.5rem 1rem; + box-shadow: 0 0 12px 0 #0002; + border: 1px #ccc9 solid; + border-radius: 5px; +` diff --git a/packages_rs/nextclade-web/src/components/Main/RunPanel.tsx b/packages_rs/nextclade-web/src/components/Main/RunPanel.tsx new file mode 100644 index 000000000..9e573dc0d --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/RunPanel.tsx @@ -0,0 +1,132 @@ +import React, { useCallback, useMemo } from 'react' +import styled from 'styled-components' +import { Button, Form as FormBase, FormGroup } from 'reactstrap' +import { useRecoilValue } from 'recoil' +import { useRunAnalysis } from 'src/hooks/useRunAnalysis' +import { useRunSeqAutodetect } from 'src/hooks/useRunSeqAutodetect' +import { useRecoilToggle } from 'src/hooks/useToggle' +import { canRunAtom } from 'src/state/results.state' +import { datasetCurrentAtom } from 'src/state/dataset.state' +import { hasInputErrorsAtom } from 'src/state/error.state' +import { shouldRunAutomaticallyAtom, shouldSuggestDatasetsAtom } from 'src/state/settings.state' +import { Toggle } from 'src/components/Common/Toggle' +import { FlexLeft, FlexRight } from 'src/components/FilePicker/FilePickerStyles' +import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { AlgorithmInputDefault } from 'src/io/AlgorithmInput' +import { hasRequiredInputsAtom, useQuerySeqInputs } from 'src/state/inputs.state' + +export function RunPanel() { + const { t } = useTranslationSafe() + + const datasetCurrent = useRecoilValue(datasetCurrentAtom) + const { addQryInputs } = useQuerySeqInputs() + + const canRun = useRecoilValue(canRunAtom) + const { state: shouldRunAutomatically, toggle: toggleRunAutomatically } = useRecoilToggle(shouldRunAutomaticallyAtom) + const shouldSuggestDatasets = useRecoilValue(shouldSuggestDatasetsAtom) + + const hasRequiredInputs = useRecoilValue(hasRequiredInputsAtom) + const hasInputErrors = useRecoilValue(hasInputErrorsAtom) + + const runAnalysis = useRunAnalysis() + const runAutodetect = useRunSeqAutodetect() + + const setExampleSequences = useCallback(() => { + if (datasetCurrent) { + addQryInputs([new AlgorithmInputDefault(datasetCurrent)]) + if (shouldSuggestDatasets) { + runAutodetect() + } + if (shouldRunAutomatically) { + runAnalysis() + } + } + }, [addQryInputs, datasetCurrent, runAnalysis, runAutodetect, shouldRunAutomatically, shouldSuggestDatasets]) + + const { isRunButtonDisabled, runButtonColor, runButtonTooltip } = useMemo(() => { + const isRunButtonDisabled = !(canRun && hasRequiredInputs) || hasInputErrors + return { + isRunButtonDisabled, + runButtonColor: isRunButtonDisabled ? 'secondary' : 'success', + runButtonTooltip: isRunButtonDisabled + ? t('Please provide sequence data for the algorithm') + : t('Launch the algorithm!'), + } + }, [canRun, hasInputErrors, hasRequiredInputs, t]) + + return ( + +
    + + + + + {t('Run automatically')} + + + + + + + + + + {t('Run')} + + +
    +
    + ) +} + +const Container = styled.div` + flex: 1; + margin-top: auto; + margin-bottom: 7px; + padding: 7px 0; + padding-right: 5px; +` + +const Form = styled(FormBase)` + display: flex; + width: 100%; + height: 100%; + margin-top: auto; + padding: 10px; + border: 1px #ccc9 solid; + border-radius: 5px; +` + +// const Container = styled.div` +// flex: 1; +// margin-top: auto; +// margin-bottom: 7px; +// padding: 10px; +// padding-right: 5px; +// box-shadow: 0 3px 20px 3px #0003; +// ` +// +// const Form = styled(FormBase)` +// display: flex; +// width: 100%; +// height: 100%; +// padding: 10px; +// border: 1px #ccc9 solid; +// border-radius: 5px; +// ` + +const ButtonRunStyled = styled(Button)` + min-width: 150px; + min-height: 45px; +` diff --git a/packages_rs/nextclade-web/src/components/Main/SuggestionPanel.tsx b/packages_rs/nextclade-web/src/components/Main/SuggestionPanel.tsx new file mode 100644 index 000000000..8d9800c17 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/SuggestionPanel.tsx @@ -0,0 +1,101 @@ +import { isNil } from 'lodash' +import React, { useMemo } from 'react' +import { useRunSeqAutodetect } from 'src/hooks/useRunSeqAutodetect' +import { hasRequiredInputsAtom } from 'src/state/inputs.state' +import styled from 'styled-components' +import { Button, Form as FormBase, FormGroup } from 'reactstrap' +import { useRecoilValue, useResetRecoilState } from 'recoil' +import { Toggle } from 'src/components/Common/Toggle' +import { FlexLeft, FlexRight } from 'src/components/FilePicker/FilePickerStyles' +import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { useRecoilToggle } from 'src/hooks/useToggle' +import { autodetectResultsAtom, hasAutodetectResultsAtom } from 'src/state/autodetect.state' +import { minimizerIndexVersionAtom } from 'src/state/dataset.state' +import { shouldSuggestDatasetsAtom } from 'src/state/settings.state' + +export function SuggestionPanel() { + const { t } = useTranslationSafe() + const minimizerIndexVersion = useRecoilValue(minimizerIndexVersionAtom) + const resetAutodetectResults = useResetRecoilState(autodetectResultsAtom) + const hasAutodetectResults = useRecoilValue(hasAutodetectResultsAtom) + const hasRequiredInputs = useRecoilValue(hasRequiredInputsAtom) + const runSuggest = useRunSeqAutodetect() + + const { canRun, runButtonColor, runButtonTooltip } = useMemo(() => { + const canRun = hasRequiredInputs + return { + canRun, + runButtonColor: !canRun ? 'secondary' : 'success', + runButtonTooltip: !canRun ? t('Please provide sequence data for the algorithm') : t('Launch suggestions engine!'), + } + }, [hasRequiredInputs, t]) + + if (isNil(minimizerIndexVersion)) { + return null + } + + return ( + +
    + + + + + + + + + {t('Suggest')} + + +
    +
    + ) +} + +const Container = styled.div` + flex: 1; + margin-top: auto; + margin-bottom: 7px; + padding: 7px 0; + padding-left: 5px; +` + +const Form = styled(FormBase)` + display: flex; + width: 100%; + height: 100%; + margin-top: auto; + padding: 10px; + border: 1px #ccc9 solid; + border-radius: 5px; +` + +const ButtonRunStyled = styled(Button)` + min-width: 150px; + min-height: 45px; +` + +function AutosuggestionToggle() { + const { t } = useTranslationSafe() + const { state: shouldSuggestDatasets, toggle: toggleSuggestDatasets } = useRecoilToggle(shouldSuggestDatasetsAtom) + return ( + + + + {t('Suggest automatically')} + + + + ) +} diff --git a/packages_rs/nextclade-web/src/helpers/colorHash.ts b/packages_rs/nextclade-web/src/helpers/colorHash.ts new file mode 100644 index 000000000..79c91fad5 --- /dev/null +++ b/packages_rs/nextclade-web/src/helpers/colorHash.ts @@ -0,0 +1,215 @@ +/* eslint-disable no-param-reassign,no-plusplus,no-loops/no-loops,prefer-destructuring,no-else-return,unicorn/prefer-code-point */ + +/** + * Color Hash + * by Simone Piccian (zanza00) + * taken with modifications from + * https://github.com/zanza00/color-hash/blob/6d43fa1b103fa090e1f0d788f5bfc4e99bf02263/src/color-hash.ts + */ + +/** + * BKDR Hash (modified version) + * + * @param {String} str string to hash + * @returns {Number} + */ +export function BKDRHash(str: string): number { + const seed = 131 + const seed2 = 137 + let hash = 0 + // make hash more sensitive for short string like 'a', 'b', 'c' + str += 'x' + const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER / seed2 + for (let i = 0; i < str.length; i++) { + if (hash > MAX_SAFE_INTEGER) { + hash = Math.floor(hash / seed2) + } + hash = hash * seed + str.charCodeAt(i) + } + return hash +} + +/** + * Convert RGB Array to HEX + * + * @param {Array} RGBArray - [R, G, B] + * @returns {String} 6 digits hex starting with # + */ +function RGB2HEX(RGBArray: [number, number, number]): string { + let hex = '#' + RGBArray.forEach((value) => { + if (value < 16) { + hex += 0 + } + hex += value.toString(16) + }) + return hex +} + +type func = (p: number, q: number) => (color: number) => number +const paramToColor: func = (p, q) => (color) => { + if (color < 0) { + color++ + } + if (color > 1) { + color-- + } + if (color < 1 / 6) { + color = p + (q - p) * 6 * color + } else if (color < 0.5) { + color = q + } else if (color < 2 / 3) { + color = p + (q - p) * 6 * (2 / 3 - color) + } else { + color = p + } + return Math.round(color * 255) +} + +/** + * Convert HSL to RGB + * + * @see {@link https://en.wikipedia.org/wiki/HSL_and_HSV} for further information. + * @param {Number} H Hue ∈ [0, 360) + * @param {Number} S Saturation ∈ [0, 1] + * @param {Number} L Lightness ∈ [0, 1] + * @returns {Array} R, G, B ∈ [0, 255] + */ +function HSL2RGB(H: number, S: number, L: number): [number, number, number] { + const H360 = H / 360 + + const q = L < 0.5 ? L * (1 + S) : L + S - L * S + const p = 2 * L - q + + const partial = paramToColor(p, q) + + return [partial(H360 + 1 / 3), partial(H360), partial(H360 - 1 / 3)] +} + +export { HSL2RGB as testFroHSL2RGB } + +export type Options = { + lightness?: number | number[] + saturation?: number | number[] + hue?: number | { min: number; max: number } | { min: number; max: number }[] + hash?: typeof BKDRHash +} + +/** + * Color Hash Class + * + * @class + */ +class ColorHash { + private L: number[] + private S: number[] + private hueRanges: { min: number; max: number }[] + private hash: (str: string) => number + + constructor(options: Options = {}) { + const LS = [options.lightness ?? [0.35, 0.5, 0.65], options.saturation ?? [0.35, 0.5, 0.65]].map((param) => { + return Array.isArray(param) ? param.concat() : [param] + }) + + this.L = LS[0] + this.S = LS[1] + + if (typeof options.hue === 'number') { + options.hue = { min: options.hue, max: options.hue } + } + if (typeof options.hue === 'object' && !Array.isArray(options.hue)) { + options.hue = [options.hue] + } + if (typeof options.hue === 'undefined') { + options.hue = [] + } + this.hueRanges = options.hue.map((range) => { + return { + min: typeof range.min === 'undefined' ? 0 : range.min, + max: typeof range.max === 'undefined' ? 360 : range.max, + } + }) + + this.hash = options.hash ?? BKDRHash + } + + private getHue(hash: number): number { + if (this.hueRanges.length > 0) { + const range = this.hueRanges[hash % this.hueRanges.length] + const hueResolution = 727 // note that 727 is a prime + return (((hash / this.hueRanges.length) % hueResolution) * (range.max - range.min)) / hueResolution + range.min + } else { + return hash % 359 // note that 359 is a prime + } + } + + /** + * Returns the hash in [h, s, l]. + * Note that H ∈ [0, 360); S ∈ [0, 1]; L ∈ [0, 1]; + * + * @param {String} str string to hash + * @returns {Array} [h, s, l] + */ + hsl(str: string): [number, number, number] { + const hash = this.hash(str) + + const H = this.getHue(hash) + + const sHash = Math.floor(hash / 360) + + const S = this.S[sHash % this.S.length] + + const lHash = Math.floor(sHash / this.S.length) + + const L = this.L[lHash % this.L.length] + + return [H, S, L] + } + + /** + * Returns the hash in [r, g, b]. + * Note that R, G, B ∈ [0, 255] + * + * @param {String} str string to hash + * @returns {Array} [r, g, b] + */ + rgb(str: string): [number, number, number] { + const hsl = this.hsl(str) + return HSL2RGB(...hsl) + } + + /** + * Returns the hash in hex + * + * @param {String} str string to hash + * @returns {String} hex with # + */ + hex(str: string): string { + const rgb = this.rgb(str) + return RGB2HEX(rgb) + } +} + +export interface ColorHashOptions extends Options { + reverse?: boolean + prefix?: string + suffix?: string +} + +export function colorHash(content: string, options?: ColorHashOptions) { + let contentModified = content + + if (options?.reverse) { + contentModified = contentModified.split('').reverse().join('') + } + + if (options?.prefix) { + contentModified = `${options.prefix}${contentModified}` + } + + if (options?.suffix) { + contentModified = `${contentModified}${options.suffix}` + } + + return new ColorHash(options).hex(contentModified) +} diff --git a/packages_rs/nextclade-web/src/helpers/number.ts b/packages_rs/nextclade-web/src/helpers/number.ts index 009cf5e23..e50f45095 100644 --- a/packages_rs/nextclade-web/src/helpers/number.ts +++ b/packages_rs/nextclade-web/src/helpers/number.ts @@ -4,3 +4,14 @@ export function ensureNumber(x?: boolean | number | null): number { } return x } + +export function isEven(x: number): boolean { + if (!Number.isInteger(x)) { + return false + } + return x % 2 === 0 +} + +export function isOdd(x: number): boolean { + return !isEven(x) +} diff --git a/packages_rs/nextclade-web/src/helpers/string.ts b/packages_rs/nextclade-web/src/helpers/string.ts index 47accaea3..05647c8f8 100644 --- a/packages_rs/nextclade-web/src/helpers/string.ts +++ b/packages_rs/nextclade-web/src/helpers/string.ts @@ -41,3 +41,7 @@ export function findSimilarStrings(haystack: string[], needle: string): string[] scores = sortBy(scores, ({ score }) => -score) return scores.map(({ candidate }) => candidate) } + +export function firstLetter(s: string): string | undefined { + return s.split('').find((c) => c.toLowerCase().match(/[a-z]/)) +} diff --git a/packages_rs/nextclade-web/src/helpers/uniqueId.ts b/packages_rs/nextclade-web/src/helpers/uniqueId.ts new file mode 100644 index 000000000..e1525d609 --- /dev/null +++ b/packages_rs/nextclade-web/src/helpers/uniqueId.ts @@ -0,0 +1,5 @@ +import { nanoid } from 'nanoid' + +export function uniqueId(): string { + return nanoid() +} diff --git a/packages_rs/nextclade-web/src/hooks/useRunSeqAutodetect.ts b/packages_rs/nextclade-web/src/hooks/useRunSeqAutodetect.ts new file mode 100644 index 000000000..fb1ce5837 --- /dev/null +++ b/packages_rs/nextclade-web/src/hooks/useRunSeqAutodetect.ts @@ -0,0 +1,108 @@ +import type { Subscription } from 'observable-fns' +import { useRecoilCallback } from 'recoil' +import { ErrorInternal } from 'src/helpers/ErrorInternal' +import { axiosFetch } from 'src/io/axiosFetch' +import { + autodetectResultByIndexAtom, + autodetectResultsAtom, + AutodetectRunState, + autodetectRunStateAtom, + minimizerIndexAtom, +} from 'src/state/autodetect.state' +import { minimizerIndexVersionAtom } from 'src/state/dataset.state' +import { globalErrorAtom } from 'src/state/error.state' +import { MinimizerIndexJson, MinimizerSearchRecord } from 'src/types' +import { qrySeqInputsStorageAtom } from 'src/state/inputs.state' +import { getQueryFasta } from 'src/workers/launchAnalysis' +import { NextcladeSeqAutodetectWasmWorker } from 'src/workers/nextcladeAutodetect.worker' +import { spawn } from 'src/workers/spawn' + +export function useRunSeqAutodetect() { + return useRecoilCallback( + ({ set, reset, snapshot }) => + () => { + const { getPromise } = snapshot + + set(autodetectRunStateAtom, AutodetectRunState.Started) + + reset(minimizerIndexAtom) + reset(autodetectResultsAtom) + reset(autodetectRunStateAtom) + + function onResult(results: MinimizerSearchRecord[]) { + results.forEach((res) => { + set(autodetectResultByIndexAtom(res.fastaRecord.index), res) + }) + } + + function onError(error: Error) { + set(autodetectRunStateAtom, AutodetectRunState.Failed) + set(globalErrorAtom, error) + } + + function onComplete() { + set(autodetectRunStateAtom, AutodetectRunState.Done) + } + + Promise.all([getPromise(qrySeqInputsStorageAtom), getPromise(minimizerIndexVersionAtom)]) + .then(async ([qrySeqInputs, minimizerIndexVersion]) => { + if (!minimizerIndexVersion) { + throw new ErrorInternal('Tried to run minimizer search without minimizer index available') + } + const fasta = await getQueryFasta(qrySeqInputs) + const minimizerIndex: MinimizerIndexJson = await axiosFetch(minimizerIndexVersion.path) + set(minimizerIndexAtom, minimizerIndex) + return runAutodetect(fasta, minimizerIndex, { onResult, onError, onComplete }) + }) + .catch((error) => { + throw error + }) + }, + [], + ) +} + +interface Callbacks { + onResult: (r: MinimizerSearchRecord[]) => void + onError?: (error: Error) => void + onComplete?: () => void +} + +async function runAutodetect(fasta: string, minimizerIndex: MinimizerIndexJson, callbacks: Callbacks) { + const worker = await SeqAutodetectWasmWorker.create(minimizerIndex) + await worker.autodetect(fasta, callbacks) + await worker.destroy() +} + +export class SeqAutodetectWasmWorker { + private thread!: NextcladeSeqAutodetectWasmWorker + private subscription?: Subscription + + private constructor() {} + + static async create(minimizerIndex: MinimizerIndexJson) { + const self = new SeqAutodetectWasmWorker() + await self.init(minimizerIndex) + return self + } + + async init(minimizerIndex: MinimizerIndexJson) { + this.thread = await spawn( + new Worker(new URL('src/workers/nextcladeAutodetect.worker.ts', import.meta.url), { + name: 'nextcladeAutodetectWorker', + }), + ) + + await this.thread.create(minimizerIndex) + } + + async autodetect(fastaStr: string, { onResult, onError, onComplete }: Callbacks) { + this.subscription = this.thread.values().subscribe(onResult, onError, onComplete) + await this.thread.autodetect(fastaStr) + } + + async destroy() { + this.subscription?.unsubscribe() + await this.thread.destroy() + } +} diff --git a/packages_rs/nextclade-web/src/hooks/useToggle.ts b/packages_rs/nextclade-web/src/hooks/useToggle.ts index 1b51e6038..da562079c 100644 --- a/packages_rs/nextclade-web/src/hooks/useToggle.ts +++ b/packages_rs/nextclade-web/src/hooks/useToggle.ts @@ -1,4 +1,5 @@ import { useCallback, useState } from 'react' +import { RecoilState, useRecoilState } from 'recoil' export type VoidFunc = () => void @@ -9,3 +10,11 @@ export function useToggle(initialState = false): [boolean, VoidFunc, VoidFunc, V const disable = useCallback(() => setState(false), []) return [state, toggle, enable, disable] } + +export function useRecoilToggle(recoilState: RecoilState) { + const [state, setState] = useRecoilState(recoilState) + const toggle = useCallback(() => setState((state) => !state), [setState]) + const enable = useCallback(() => setState(true), [setState]) + const disable = useCallback(() => setState(false), [setState]) + return { state, setState, toggle, enable, disable } +} diff --git a/packages_rs/nextclade-web/src/i18n/i18n.ts b/packages_rs/nextclade-web/src/i18n/i18n.ts index 7dfa189e8..abb25b55c 100644 --- a/packages_rs/nextclade-web/src/i18n/i18n.ts +++ b/packages_rs/nextclade-web/src/i18n/i18n.ts @@ -1,11 +1,7 @@ -import { ElementType, FC } from 'react' - import type { StrictOmit } from 'ts-essentials' import { get, isNil, mapValues } from 'lodash' - import i18nOriginal, { i18n as I18N, Resource } from 'i18next' import { initReactI18next } from 'react-i18next' - import { Settings as LuxonSettings } from 'luxon' import numbro from 'numbro' import { languages } from 'countries-list' @@ -15,28 +11,6 @@ import prettyBytesOriginal, { Options as PrettyBytesOptionsOriginal } from 'pret // @ts-ignore import numbroLanguages from 'numbro/dist/languages.min' -import CN from 'flag-icon-css/flags/1x1/cn.svg' -import DE from 'flag-icon-css/flags/1x1/de.svg' -import ES from 'flag-icon-css/flags/1x1/es.svg' -import FR from 'flag-icon-css/flags/1x1/fr.svg' -import GB from 'flag-icon-css/flags/1x1/gb.svg' -import GR from 'flag-icon-css/flags/1x1/gr.svg' -import ID from 'flag-icon-css/flags/1x1/id.svg' -import IL from 'flag-icon-css/flags/1x1/il.svg' -import IN from 'flag-icon-css/flags/1x1/in.svg' -import IR from 'flag-icon-css/flags/1x1/ir.svg' -import IT from 'flag-icon-css/flags/1x1/it.svg' -import JP from 'flag-icon-css/flags/1x1/jp.svg' -import KR from 'flag-icon-css/flags/1x1/kr.svg' -import NL from 'flag-icon-css/flags/1x1/nl.svg' -import PK from 'flag-icon-css/flags/1x1/pk.svg' -import PT from 'flag-icon-css/flags/1x1/pt.svg' -import RU from 'flag-icon-css/flags/1x1/ru.svg' -import SA from 'flag-icon-css/flags/1x1/sa.svg' -import TH from 'flag-icon-css/flags/1x1/th.svg' -import TR from 'flag-icon-css/flags/1x1/tr.svg' -import VN from 'flag-icon-css/flags/1x1/vn.svg' - import ar from './resources/ar/common.json' import de from './resources/de/common.json' import el from './resources/el/common.json' @@ -97,32 +71,32 @@ export interface Locale { readonly full: string readonly name: string readonly native: string - readonly Flag: ElementType + readonly rtl: number | undefined } export const locales: Record = { - en: { key: 'en', full: 'en-US', name: languages.en.name, native: languages.en.native, Flag: GB as FC }, - ar: { key: 'ar', full: 'ar-SA', name: languages.ar.name, native: languages.ar.native, Flag: SA as FC }, - de: { key: 'de', full: 'de-DE', name: languages.de.name, native: languages.de.native, Flag: DE as FC }, - el: { key: 'el', full: 'el-GR', name: languages.el.name, native: languages.el.native, Flag: GR as FC }, - es: { key: 'es', full: 'es-ES', name: languages.es.name, native: languages.es.native, Flag: ES as FC }, - fa: { key: 'fa', full: 'fa-IR', name: languages.fa.name, native: languages.fa.native, Flag: IR as FC }, - fr: { key: 'fr', full: 'fr-FR', name: languages.fr.name, native: languages.fr.native, Flag: FR as FC }, - he: { key: 'he', full: 'he-IL', name: languages.he.name, native: languages.he.native, Flag: IL as FC }, - hi: { key: 'hi', full: 'hi-IN', name: languages.hi.name, native: languages.hi.native, Flag: IN as FC }, - id: { key: 'id', full: 'id-ID', name: languages.id.name, native: languages.id.native, Flag: ID as FC }, - it: { key: 'it', full: 'it-IT', name: languages.it.name, native: languages.it.native, Flag: IT as FC }, - ja: { key: 'ja', full: 'ja-JP', name: languages.ja.name, native: languages.ja.native, Flag: JP as FC }, - ko: { key: 'ko', full: 'ko-KR', name: languages.ko.name, native: languages.ko.native, Flag: KR as FC }, - nl: { key: 'nl', full: 'nl-NL', name: languages.nl.name, native: languages.nl.native, Flag: NL as FC }, - pt: { key: 'pt', full: 'pt-PT', name: languages.pt.name, native: languages.pt.native, Flag: PT as FC }, - ru: { key: 'ru', full: 'ru-RU', name: languages.ru.name, native: languages.ru.native, Flag: RU as FC }, - ta: { key: 'ta', full: 'ta-IN', name: languages.ta.name, native: languages.ta.native, Flag: IN as FC }, - th: { key: 'th', full: 'th-TH', name: languages.th.name, native: languages.th.native, Flag: TH as FC }, - tr: { key: 'tr', full: 'tr-TR', name: languages.tr.name, native: languages.tr.native, Flag: TR as FC }, - ur: { key: 'ur', full: 'ur-PK', name: languages.ur.name, native: languages.ur.native, Flag: PK as FC }, - vi: { key: 'vi', full: 'vi-VN', name: languages.vi.name, native: languages.vi.native, Flag: VN as FC }, - zh: { key: 'zh', full: 'zh-CN', name: languages.zh.name, native: languages.zh.native, Flag: CN as FC }, + en: { key: 'en', full: 'en-US', name: languages.en.name, native: languages.en.native, rtl: languages.en.rtl }, + ar: { key: 'ar', full: 'ar-SA', name: languages.ar.name, native: languages.ar.native, rtl: languages.ar.rtl }, + de: { key: 'de', full: 'de-DE', name: languages.de.name, native: languages.de.native, rtl: languages.de.rtl }, + el: { key: 'el', full: 'el-GR', name: languages.el.name, native: languages.el.native, rtl: languages.el.rtl }, + es: { key: 'es', full: 'es-ES', name: languages.es.name, native: languages.es.native, rtl: languages.es.rtl }, + fa: { key: 'fa', full: 'fa-IR', name: languages.fa.name, native: languages.fa.native, rtl: languages.fa.rtl }, + fr: { key: 'fr', full: 'fr-FR', name: languages.fr.name, native: languages.fr.native, rtl: languages.fr.rtl }, + he: { key: 'he', full: 'he-IL', name: languages.he.name, native: languages.he.native, rtl: languages.he.rtl }, + hi: { key: 'hi', full: 'hi-IN', name: languages.hi.name, native: languages.hi.native, rtl: languages.hi.rtl }, + id: { key: 'id', full: 'id-ID', name: languages.id.name, native: languages.id.native, rtl: languages.id.rtl }, + it: { key: 'it', full: 'it-IT', name: languages.it.name, native: languages.it.native, rtl: languages.it.rtl }, + ja: { key: 'ja', full: 'ja-JP', name: languages.ja.name, native: languages.ja.native, rtl: languages.ja.rtl }, + ko: { key: 'ko', full: 'ko-KR', name: languages.ko.name, native: languages.ko.native, rtl: languages.ko.rtl }, + nl: { key: 'nl', full: 'nl-NL', name: languages.nl.name, native: languages.nl.native, rtl: languages.nl.rtl }, + pt: { key: 'pt', full: 'pt-PT', name: languages.pt.name, native: languages.pt.native, rtl: languages.pt.rtl }, + ru: { key: 'ru', full: 'ru-RU', name: languages.ru.name, native: languages.ru.native, rtl: languages.ru.rtl }, + ta: { key: 'ta', full: 'ta-IN', name: languages.ta.name, native: languages.ta.native, rtl: languages.ta.rtl }, + th: { key: 'th', full: 'th-TH', name: languages.th.name, native: languages.th.native, rtl: languages.th.rtl }, + tr: { key: 'tr', full: 'tr-TR', name: languages.tr.name, native: languages.tr.native, rtl: languages.tr.rtl }, + ur: { key: 'ur', full: 'ur-PK', name: languages.ur.name, native: languages.ur.native, rtl: languages.ur.rtl }, + vi: { key: 'vi', full: 'vi-VN', name: languages.vi.name, native: languages.vi.native, rtl: languages.vi.rtl }, + zh: { key: 'zh', full: 'zh-CN', name: languages.zh.name, native: languages.zh.native, rtl: languages.zh.rtl }, } as const export const localeKeys = Object.keys(locales) diff --git a/packages_rs/nextclade-web/src/io/AlgorithmInput.ts b/packages_rs/nextclade-web/src/io/AlgorithmInput.ts index 248d9c304..ea6e33e8f 100644 --- a/packages_rs/nextclade-web/src/io/AlgorithmInput.ts +++ b/packages_rs/nextclade-web/src/io/AlgorithmInput.ts @@ -1,6 +1,6 @@ +import { uniqueId } from 'src/helpers/uniqueId' import { AlgorithmInput, AlgorithmInputType, Dataset } from 'src/types' import { axiosFetchRaw } from 'src/io/axiosFetch' - import { readFile } from 'src/helpers/readFile' import { numbro } from 'src/i18n/i18n' @@ -16,12 +16,20 @@ function formatBytes(bytes: number) { } export class AlgorithmInputFile implements AlgorithmInput { + public readonly uid = uniqueId() + public readonly path: string public readonly type: AlgorithmInputType = AlgorithmInputType.File as const - private readonly file: File constructor(file: File) { this.file = file + + // eslint-disable-next-line unicorn/prefer-ternary + if (this.file.webkitRelativePath.trim().length > 0) { + this.path = this.file.webkitRelativePath + } else { + this.path = `${this.uid}-${this.file.name}` + } } public get name(): string { @@ -38,12 +46,14 @@ export class AlgorithmInputFile implements AlgorithmInput { } export class AlgorithmInputUrl implements AlgorithmInput { + public readonly uid = uniqueId() + public readonly path: string public readonly type: AlgorithmInputType = AlgorithmInputType.Url as const - private readonly url: string constructor(url: string) { this.url = url + this.path = this.url } public get name(): string { @@ -60,12 +70,14 @@ export class AlgorithmInputUrl implements AlgorithmInput { } export class AlgorithmInputString implements AlgorithmInput { + public readonly uid = uniqueId() + public readonly path: string public readonly type: AlgorithmInputType = AlgorithmInputType.String as const - private readonly content: string private readonly contentName: string constructor(content: string, contentName?: string) { + this.path = `pasted-${this.uid}.fasta` this.content = content this.contentName = contentName ?? 'Pasted sequences' } @@ -84,21 +96,22 @@ export class AlgorithmInputString implements AlgorithmInput { } export class AlgorithmInputDefault implements AlgorithmInput { + public readonly uid = uniqueId() + public readonly path: string public readonly type: AlgorithmInputType = AlgorithmInputType.Default as const - public dataset: Dataset constructor(dataset: Dataset) { this.dataset = dataset + this.path = `Examples for '${this.dataset.path}'` } public get name(): string { - const { value, valueFriendly } = this.dataset.attributes.name - return `${valueFriendly ?? value} example sequences` + return this.path } public get description(): string { - return `${this.name}` + return this.name } public async getContent(): Promise { diff --git a/packages_rs/nextclade-web/src/io/fetchDatasets.ts b/packages_rs/nextclade-web/src/io/fetchDatasets.ts index a21063d58..be86b86cd 100644 --- a/packages_rs/nextclade-web/src/io/fetchDatasets.ts +++ b/packages_rs/nextclade-web/src/io/fetchDatasets.ts @@ -9,11 +9,18 @@ import { fetchDatasetsIndex, filterDatasets, findDataset, + getCompatibleMinimizerIndexVersion, getLatestCompatibleEnabledDatasets, } from 'src/io/fetchDatasetsIndex' import { getQueryParamMaybe } from 'src/io/getQueryParamMaybe' import { useRecoilValue, useSetRecoilState } from 'recoil' -import { datasetCurrentAtom, datasetsAtom, datasetUpdatedAtom } from 'src/state/dataset.state' +import { + datasetCurrentAtom, + datasetsAtom, + datasetServerUrlAtom, + datasetUpdatedAtom, + minimizerIndexVersionAtom, +} from 'src/state/dataset.state' import { useQuery } from 'react-query' import { isNil } from 'lodash' import urljoin from 'url-join' @@ -85,7 +92,8 @@ export async function getDatasetServerUrl(urlQuery: ParsedUrlQuery) { // `.env` file), or using `&dataset-server=gh` or `&dataset-server=github` URL parameters, then check if the // corresponding branch in the default data repo on GitHub contains an `index.json` file. And and if yes, use it. const datasetServerTryGithubBranch = - process.env.DATA_TRY_GITHUB_BRANCH === '1' || (datasetServerUrl && ['gh', 'github'].includes(datasetServerUrl)) + (isNil(datasetServerUrl) && process.env.DATA_TRY_GITHUB_BRANCH === '1') || + (datasetServerUrl && ['gh', 'github'].includes(datasetServerUrl)) if (datasetServerTryGithubBranch) { const githubDatasetServerUrl = await getGithubDatasetServerUrl() if (githubDatasetServerUrl) { @@ -100,27 +108,30 @@ export async function getDatasetServerUrl(urlQuery: ParsedUrlQuery) { return toAbsoluteUrl(datasetServerUrl) } -export async function initializeDatasets(urlQuery: ParsedUrlQuery) { - const datasetServerUrl = await getDatasetServerUrl(urlQuery) - +export async function initializeDatasets(datasetServerUrl: string, urlQuery: ParsedUrlQuery = {}) { const datasetsIndexJson = await fetchDatasetsIndex(datasetServerUrl) const { datasets } = getLatestCompatibleEnabledDatasets(datasetServerUrl, datasetsIndexJson) + const minimizerIndexVersion = await getCompatibleMinimizerIndexVersion(datasetServerUrl, datasetsIndexJson) + // Check if URL params specify dataset params and try to find the corresponding dataset const currentDataset = await getDatasetFromUrlParams(urlQuery, datasets) - return { datasets, currentDataset } + return { datasets, currentDataset, minimizerIndexVersion } } /** Refetch dataset index periodically and update the local copy of if */ export function useUpdatedDatasetIndex() { + const datasetServerUrl = useRecoilValue(datasetServerUrlAtom) const setDatasetsState = useSetRecoilState(datasetsAtom) + const setMinimizerIndexVersion = useSetRecoilState(minimizerIndexVersionAtom) useQuery( 'refetchDatasetIndex', async () => { - const { currentDataset: _, ...datasetsState } = await initializeDatasets({}) - setDatasetsState(datasetsState) + const { currentDataset: _, minimizerIndexVersion, ...datasets } = await initializeDatasets(datasetServerUrl) + setDatasetsState(datasets) + setMinimizerIndexVersion(minimizerIndexVersion) }, { suspense: false, diff --git a/packages_rs/nextclade-web/src/io/fetchDatasetsIndex.ts b/packages_rs/nextclade-web/src/io/fetchDatasetsIndex.ts index d39e3403f..c1690972f 100644 --- a/packages_rs/nextclade-web/src/io/fetchDatasetsIndex.ts +++ b/packages_rs/nextclade-web/src/io/fetchDatasetsIndex.ts @@ -1,19 +1,21 @@ import { head, mapValues, sortBy, sortedUniq } from 'lodash' import semver from 'semver' +import { takeFirstMaybe } from 'src/helpers/takeFirstMaybe' import urljoin from 'url-join' -import { Dataset, DatasetFiles, DatasetsIndexJson, DatasetsIndexV2Json } from 'src/types' +import { Dataset, DatasetFiles, DatasetsIndexJson, DatasetsIndexV2Json, MinimizerIndexVersion } from 'src/types' import { axiosFetch } from 'src/io/axiosFetch' -const thisVersion = process.env.PACKAGE_VERSION ?? '' +const MINIMIZER_INDEX_ALGO_VERSION = 'v1' +const PACKAGE_VERSION = process.env.PACKAGE_VERSION ?? '' export function isEnabled(dataset: Dataset) { return dataset.enabled } export function isCompatible(dataset: Dataset): boolean { - const minVersion = dataset.version?.compatibility?.web ?? thisVersion - return semver.gte(thisVersion, minVersion) + const minVersion = dataset.version?.compatibility?.web ?? PACKAGE_VERSION + return semver.gte(PACKAGE_VERSION, minVersion) } export function isLatest(dataset: Dataset): boolean { @@ -60,3 +62,21 @@ export function filterDatasets(datasets: Dataset[], name?: string, tag?: string) export async function fetchDatasetsIndex(datasetServerUrl: string) { return axiosFetch(urljoin(datasetServerUrl, 'index.json')) } + +export async function getCompatibleMinimizerIndexVersion( + datasetServerUrl: string, + datasetsIndexJson: DatasetsIndexV2Json, +): Promise { + let candidates = datasetsIndexJson.minimizerIndex?.filter( + (minimizerIndexVer) => MINIMIZER_INDEX_ALGO_VERSION >= minimizerIndexVer.version, + ) + candidates = sortBy(candidates, (candidate) => candidate.version).reverse() + const index = takeFirstMaybe(candidates) + if (index) { + return { + ...index, + path: urljoin(datasetServerUrl, index.path), + } + } + return undefined +} diff --git a/packages_rs/nextclade-web/src/io/fetchSingleDatasetFromGithub.ts b/packages_rs/nextclade-web/src/io/fetchSingleDatasetFromGithub.ts index f648a3a0a..050ad9b45 100644 --- a/packages_rs/nextclade-web/src/io/fetchSingleDatasetFromGithub.ts +++ b/packages_rs/nextclade-web/src/io/fetchSingleDatasetFromGithub.ts @@ -1,4 +1,3 @@ -/* eslint-disable prefer-template */ import { isNil } from 'lodash' import pMemoize from 'p-memoize' @@ -95,19 +94,13 @@ export function isGithubUrlOrShortcut(url: string): boolean { return !isNil(/^(github:|gh:|https?:\/\/github.com).*/.exec(url)) } -const GITHUB_URL_EXAMPLE = - 'https://github.com/nextstrain/nextclade_data/tree/6ab9560b86e3384792235fa72d1c3eaf30e71213/data/datasets/flu_yam_ha/references/JN993010/versions/2022-07-27T12:00:00Z/files/' - -const GITHUB_URL_ERROR_HINTS = ` Check the correctness of the URL. If you don't intend to use custom dataset, remove the parameter from the address or restart the application. An example of a correct URL: '${GITHUB_URL_EXAMPLE}'` +const GITHUB_URL_ERROR_HINTS = ` Check the correctness of the URL. If it's a full GitHub URL, please try to navigate to it - you should see a GitHub repo branch with your files listed. If it's a GitHub URL shortcut, please double check the syntax. See documentation for the correct syntax and examples. If you don't intend to use custom datasets, remove the parameter from the address or restart the application.` export class ErrorDatasetGithubUrlPatternInvalid extends Error { public readonly datasetGithubUrl: string constructor(datasetGithubUrl: string) { - super( - `Dataset GitHub URL (provided using 'dataset-url' URL parameter) is invalid: '${datasetGithubUrl}'.` + - GITHUB_URL_ERROR_HINTS, - ) + super(`Dataset GitHub URL is invalid: '${datasetGithubUrl}'.${GITHUB_URL_ERROR_HINTS}`) this.datasetGithubUrl = datasetGithubUrl } } @@ -122,9 +115,7 @@ export class ErrorDatasetGithubUrlComponentsInvalid extends Error { .join(',') super( - `Dataset GitHub URL (provided using 'dataset-url' URL parameter) is invalid: '${datasetGithubUrl}'.` + - ` Detected the following components ${componentsListStr}.` + - GITHUB_URL_ERROR_HINTS, + `Dataset GitHub URL is invalid: '${datasetGithubUrl}'. Detected the following components ${componentsListStr}.${GITHUB_URL_ERROR_HINTS}`, ) this.datasetGithubUrl = datasetGithubUrl this.parsedRepoUrlComponents = parsedRepoUrlComponents diff --git a/packages_rs/nextclade-web/src/lib.rs b/packages_rs/nextclade-web/src/lib.rs index f539b360f..c2c9a50cb 100644 --- a/packages_rs/nextclade-web/src/lib.rs +++ b/packages_rs/nextclade-web/src/lib.rs @@ -1 +1,9 @@ +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen(start)] +pub fn main() { + wasm_logger::init(wasm_logger::Config::default()); + console_error_panic_hook::set_once(); +} + mod wasm; diff --git a/packages_rs/nextclade-web/src/pages/_app.tsx b/packages_rs/nextclade-web/src/pages/_app.tsx index 20868096e..d92728277 100644 --- a/packages_rs/nextclade-web/src/pages/_app.tsx +++ b/packages_rs/nextclade-web/src/pages/_app.tsx @@ -39,7 +39,7 @@ import { ReactQueryDevtools } from 'react-query/devtools' import { DOMAIN_STRIPPED } from 'src/constants' import { parseUrl } from 'src/helpers/parseUrl' -import { initializeDatasets } from 'src/io/fetchDatasets' +import { getDatasetServerUrl, initializeDatasets } from 'src/io/fetchDatasets' import { fetchSingleDataset } from 'src/io/fetchSingleDataset' import { ErrorPopup } from 'src/components/Error/ErrorPopup' import Loading from 'src/components/Loading/Loading' @@ -48,7 +48,12 @@ import { SEO } from 'src/components/Common/SEO' import { Plausible } from 'src/components/Common/Plausible' import i18n, { changeLocale, getLocaleWithKey } from 'src/i18n/i18n' import { theme } from 'src/theme' -import { datasetCurrentAtom, datasetsAtom } from 'src/state/dataset.state' +import { + datasetCurrentAtom, + datasetsAtom, + datasetServerUrlAtom, + minimizerIndexVersionAtom, +} from 'src/state/dataset.state' import { ErrorBoundary } from 'src/components/Error/ErrorBoundary' import { PreviewWarning } from 'src/components/Common/PreviewWarning' @@ -96,10 +101,15 @@ export function RecoilStateInitializer() { const datasetInfo = await fetchSingleDataset(urlQuery) if (!isNil(datasetInfo)) { - return datasetInfo + const { datasets, currentDataset } = datasetInfo + return { datasets, currentDataset, minimizerIndexVersion: undefined } } - return initializeDatasets(urlQuery) + const datasetServerUrl = await getDatasetServerUrl(urlQuery) + set(datasetServerUrlAtom, datasetServerUrl) + + const { datasets, currentDataset, minimizerIndexVersion } = await initializeDatasets(datasetServerUrl, urlQuery) + return { datasets, currentDataset, minimizerIndexVersion } }) .catch((error) => { // Dataset error is fatal and we want error to be handled in the ErrorBoundary @@ -107,11 +117,12 @@ export function RecoilStateInitializer() { set(globalErrorAtom, sanitizeError(error)) throw error }) - .then(async ({ datasets, currentDataset }) => { + .then(async ({ datasets, currentDataset, minimizerIndexVersion }) => { set(datasetsAtom, { datasets }) const previousDataset = await getPromise(datasetCurrentAtom) const dataset = currentDataset ?? previousDataset set(datasetCurrentAtom, dataset) + set(minimizerIndexVersionAtom, minimizerIndexVersion) return dataset }) .then((dataset) => { diff --git a/packages_rs/nextclade-web/src/state/autodetect.state.ts b/packages_rs/nextclade-web/src/state/autodetect.state.ts new file mode 100644 index 000000000..5697f4875 --- /dev/null +++ b/packages_rs/nextclade-web/src/state/autodetect.state.ts @@ -0,0 +1,133 @@ +/* eslint-disable no-loops/no-loops */ +import unique from 'fork-ts-checker-webpack-plugin/lib/utils/array/unique' +import { isEmpty, isNil } from 'lodash' +import { atom, atomFamily, DefaultValue, selector, selectorFamily } from 'recoil' +import type { MinimizerIndexJson, MinimizerSearchRecord } from 'src/types' +import { isDefaultValue } from 'src/state/utils/isDefaultValue' + +export const minimizerIndexAtom = atom({ + key: 'minimizerIndexAtom', +}) + +const autodetectResultInternalAtom = atomFamily({ + key: 'autodetectResultInternalAtom', +}) + +export const autodetectResultIndicesAtom = atom({ + key: 'autodetectResultIndicesAtom', + default: [], +}) + +export const autodetectResultByIndexAtom = selectorFamily({ + key: 'autodetectResultByIndexAtom', + + get: + (index: number) => + ({ get }): MinimizerSearchRecord => { + return get(autodetectResultInternalAtom(index)) + }, + + set: + (index) => + ({ set, reset }, result: MinimizerSearchRecord | DefaultValue) => { + if (isDefaultValue(result)) { + reset(autodetectResultInternalAtom(index)) + reset(autodetectResultIndicesAtom) + } else { + set(autodetectResultInternalAtom(index), result) + + // Add to the list of indices + set(autodetectResultIndicesAtom, (prev) => { + if (result) { + return [...prev, result.fastaRecord.index] + } + return prev + }) + } + }, +}) + +// Dataset ID to use for when dataset is not autodetected +export const DATASET_ID_UNDETECTED = 'undetected' + +export function groupByDatasets(records: MinimizerSearchRecord[]): Record { + const names = unique(records.flatMap((record) => record.result.datasets.map((dataset) => dataset.name))) + let byDataset = {} + for (const name of names) { + const selectedRecords = records.filter((record) => record.result.datasets.some((dataset) => dataset.name === name)) + byDataset = { ...byDataset, [name]: selectedRecords } + } + return byDataset +} + +// Select autodetect results by dataset name +export const autodetectResultsByDatasetAtom = selectorFamily({ + key: 'autodetectResultByDatasetAtom', + + get: + (datasetId: string) => + ({ get }): MinimizerSearchRecord[] | undefined => { + const records = get(autodetectResultsAtom) + if (isNil(records)) { + return undefined + } + + if (datasetId === DATASET_ID_UNDETECTED) { + return records.filter((record) => isEmpty(record.result.datasets)) + } + + return records.filter((record) => record.result.datasets.some((dataset) => dataset.name === datasetId)) + }, +}) + +export const autodetectResultsAtom = selector({ + key: 'autodetectResultsAtom', + + get({ get }): MinimizerSearchRecord[] | undefined { + const indices = get(autodetectResultIndicesAtom) + if (indices.length === 0) { + return undefined + } + return indices.map((index) => get(autodetectResultByIndexAtom(index))) + }, + + set({ get, set, reset }, results: MinimizerSearchRecord[] | DefaultValue | undefined) { + const seqIndices = get(autodetectResultIndicesAtom) + + // Remove all results + seqIndices.forEach((index) => { + reset(autodetectResultByIndexAtom(index)) + }) + + // If the operation is not 'reset', add the new items + if (!isDefaultValue(results) && !isNil(results)) { + results.forEach((result) => set(autodetectResultByIndexAtom(result.fastaRecord.index), result)) + } + }, +}) + +export const numberAutodetectResultsAtom = selector({ + key: 'numberAutodetectResultsAtom', + get({ get }) { + return (get(autodetectResultsAtom) ?? []).length + }, +}) + +export const hasAutodetectResultsAtom = selector({ + key: 'hasAutodetectResultsAtom', + get({ get }) { + return get(numberAutodetectResultsAtom) > 0 + }, +}) + +export enum AutodetectRunState { + Idle = 'Idle', + Started = 'Started', + Failed = 'Failed', + Done = 'Done', +} + +export const autodetectRunStateAtom = atom({ + key: 'autodetectRunStateAtom', + default: AutodetectRunState.Idle, +}) diff --git a/packages_rs/nextclade-web/src/state/dataset.state.ts b/packages_rs/nextclade-web/src/state/dataset.state.ts index b0c7cb6a4..880acf271 100644 --- a/packages_rs/nextclade-web/src/state/dataset.state.ts +++ b/packages_rs/nextclade-web/src/state/dataset.state.ts @@ -1,9 +1,8 @@ import { isNil } from 'lodash' import { atom, DefaultValue, selector } from 'recoil' -import type { Dataset } from 'src/types' +import type { Dataset, MinimizerIndexVersion } from 'src/types' // import { GENE_OPTION_NUC_SEQUENCE } from 'src/constants' -import { inputResetAtom } from 'src/state/inputs.state' import { persistAtom } from 'src/state/persist/localStorage' // import { viewedGeneAtom } from 'src/state/seqViewSettings.state' import { isDefaultValue } from 'src/state/utils/isDefaultValue' @@ -13,6 +12,11 @@ export interface Datasets { datasets: Dataset[] } +export const datasetServerUrlAtom = atom({ + key: 'datasetServerUrlAtom', + default: '/', +}) + export const datasetsAtom = atom({ key: 'datasets', }) @@ -36,7 +40,6 @@ export const datasetCurrentAtom = selector({ set(datasetCurrentStorageAtom, dataset) // FIXME // set(viewedGeneAtom, dataset?.defaultGene ?? GENE_OPTION_NUC_SEQUENCE) - reset(inputResetAtom) } }, }) @@ -55,3 +58,8 @@ export const geneOrderPreferenceAtom = selector({ return [] }, }) + +export const minimizerIndexVersionAtom = atom({ + key: 'minimizerIndexVersionAtom', + default: undefined, +}) diff --git a/packages_rs/nextclade-web/src/state/inputs.state.ts b/packages_rs/nextclade-web/src/state/inputs.state.ts index be09c858f..2ddff2b13 100644 --- a/packages_rs/nextclade-web/src/state/inputs.state.ts +++ b/packages_rs/nextclade-web/src/state/inputs.state.ts @@ -1,6 +1,7 @@ import { isEmpty } from 'lodash' import { useCallback } from 'react' import { atom, selector, useRecoilState, useResetRecoilState } from 'recoil' +import { autodetectResultsAtom } from 'src/state/autodetect.state' import { AlgorithmInput } from 'src/types' import { notUndefinedOrNull } from 'src/helpers/notUndefined' @@ -11,7 +12,8 @@ export const qrySeqInputsStorageAtom = atom({ export function useQuerySeqInputs() { const [qryInputs, setQryInputs] = useRecoilState(qrySeqInputsStorageAtom) - const clearQryInputs = useResetRecoilState(qrySeqInputsStorageAtom) + const resetSeqInputsStorage = useResetRecoilState(qrySeqInputsStorageAtom) + const resetAutodetectResults = useResetRecoilState(autodetectResultsAtom) const addQryInputs = useCallback( (newInputs: AlgorithmInput[]) => { @@ -27,6 +29,11 @@ export function useQuerySeqInputs() { [setQryInputs], ) + const clearQryInputs = useCallback(() => { + resetAutodetectResults() + resetSeqInputsStorage() + }, [resetAutodetectResults, resetSeqInputsStorage]) + return { qryInputs, addQryInputs, removeQryInput, clearQryInputs } } diff --git a/packages_rs/nextclade-web/src/state/settings.state.ts b/packages_rs/nextclade-web/src/state/settings.state.ts index d84aed43e..91f17aa36 100644 --- a/packages_rs/nextclade-web/src/state/settings.state.ts +++ b/packages_rs/nextclade-web/src/state/settings.state.ts @@ -32,7 +32,7 @@ export const isNewRunPopupShownAtom = atom({ }) export const isResultsFilterPanelCollapsedAtom = atom({ - key: 'isResultsfilterPanelCollapsed', + key: 'isResultsFilterPanelCollapsedAtom', default: true, }) @@ -42,6 +42,12 @@ export const shouldRunAutomaticallyAtom = atom({ effects: [persistAtom], }) +export const shouldSuggestDatasetsAtom = atom({ + key: 'shouldSuggestDatasetsAtom', + default: true, + effects: [persistAtom], +}) + export const changelogIsShownAtom = atom({ key: 'changelogIsShown', default: false, diff --git a/packages_rs/nextclade-web/src/styles/components/LanguageSwitcher.scss b/packages_rs/nextclade-web/src/styles/components/LanguageSwitcher.scss deleted file mode 100644 index bd2f66132..000000000 --- a/packages_rs/nextclade-web/src/styles/components/LanguageSwitcher.scss +++ /dev/null @@ -1,19 +0,0 @@ -@import '../variables'; - -.language-switcher { - margin-top: 3px; - - .language-switcher-menu.dropdown-menu { - background-color: $body-bg; - box-shadow: 1px 1px 2px 2px rgba($gray-600, 0.25); - } - - .language-switcher-flag { - width: 20px; - height: 20px; - margin-bottom: 5px; - background-size: cover; - border-radius: 2px; - box-shadow: 1px 1px 2px 2px rgba($gray-600, 0.25); - } -} diff --git a/packages_rs/nextclade-web/src/styles/global.scss b/packages_rs/nextclade-web/src/styles/global.scss index 5be3cef65..a8f6a4289 100644 --- a/packages_rs/nextclade-web/src/styles/global.scss +++ b/packages_rs/nextclade-web/src/styles/global.scss @@ -9,7 +9,6 @@ @import './auspice'; -@import './components/LanguageSwitcher'; @import './components/Results'; html, body, #__next { diff --git a/packages_rs/nextclade-web/src/theme.ts b/packages_rs/nextclade-web/src/theme.ts index 22ac50a4a..4d13bc8a0 100644 --- a/packages_rs/nextclade-web/src/theme.ts +++ b/packages_rs/nextclade-web/src/theme.ts @@ -150,6 +150,13 @@ export const uploadZone = { }, } +export const table = { + rowBg: { + even: '#fcfcfc', + odd: '#ededed', + }, +} + export const theme = { bodyColor: basicColors.gray700, bodyBg: basicColors.white, @@ -163,6 +170,7 @@ export const theme = { shadows, filePicker, uploadZone, + table, seqView: { markers: { diff --git a/packages_rs/nextclade-web/src/types.ts b/packages_rs/nextclade-web/src/types.ts index 7e8a7b50a..03fc0a055 100644 --- a/packages_rs/nextclade-web/src/types.ts +++ b/packages_rs/nextclade-web/src/types.ts @@ -1,4 +1,4 @@ -import { isEqual, isNil, range, sumBy } from 'lodash' +import { isNil, range, sumBy } from 'lodash' import type { Aa, Cds, @@ -127,6 +127,8 @@ export enum AlgorithmInputType { } export interface AlgorithmInput { + uid: string + path: string type: AlgorithmInputType name: string description: string @@ -135,5 +137,5 @@ export interface AlgorithmInput { } export function areDatasetsEqual(left?: Dataset, right?: Dataset): boolean { - return !isNil(left) && !isNil(right) && isEqual(left.attributes, right.attributes) + return !isNil(left?.path) && !isNil(right?.path) && left?.path === right?.path } diff --git a/packages_rs/nextclade-web/src/wasm/jserr.rs b/packages_rs/nextclade-web/src/wasm/jserr.rs new file mode 100644 index 000000000..0a7b13b8d --- /dev/null +++ b/packages_rs/nextclade-web/src/wasm/jserr.rs @@ -0,0 +1,13 @@ +use eyre::Report; +use nextclade::utils::error::report_to_string; +use wasm_bindgen::{JsError, JsValue}; + +/// Converts Result's Err variant from eyre::Report to wasm_bindgen::JsError +pub fn jserr(result: Result) -> Result { + result.map_err(|report| JsError::new(&report_to_string(&report))) +} + +/// Converts Result's Err variant from eyre::Report to wasm_bindgen::JsError +pub fn jserr2(result: Result) -> Result { + result.map_err(|err_val| JsError::new(&format!("{err_val:#?}"))) +} diff --git a/packages_rs/nextclade-web/src/wasm/main.rs b/packages_rs/nextclade-web/src/wasm/main.rs index e370f8c01..cac8bb953 100644 --- a/packages_rs/nextclade-web/src/wasm/main.rs +++ b/packages_rs/nextclade-web/src/wasm/main.rs @@ -1,3 +1,4 @@ +use crate::wasm::jserr::jserr; use eyre::{Report, WrapErr}; use itertools::Itertools; use nextclade::analyze::virus_properties::{AaMotifsDesc, PhenotypeAttrDesc}; @@ -14,11 +15,6 @@ use std::io::Read; use std::str::FromStr; use wasm_bindgen::prelude::*; -/// Converts Result's Err variant from eyre::Report to wasm_bindgen::JsError -fn jserr(result: Result) -> Result { - result.map_err(|report| JsError::new(&report_to_string(&report))) -} - /// Nextclade WebAssembly module. /// /// Encapsulates all the Nextclade Rust functionality required for Nextclade Web to operate. @@ -211,9 +207,3 @@ impl NextcladeWasm { )) } } - -#[wasm_bindgen(start)] -pub fn main() { - wasm_logger::init(wasm_logger::Config::default()); - console_error_panic_hook::set_once(); -} diff --git a/packages_rs/nextclade-web/src/wasm/mod.rs b/packages_rs/nextclade-web/src/wasm/mod.rs index 2a043412b..b5f7dee46 100644 --- a/packages_rs/nextclade-web/src/wasm/mod.rs +++ b/packages_rs/nextclade-web/src/wasm/mod.rs @@ -1 +1,3 @@ +pub mod jserr; pub mod main; +pub mod seq_autodetect; diff --git a/packages_rs/nextclade-web/src/wasm/seq_autodetect.rs b/packages_rs/nextclade-web/src/wasm/seq_autodetect.rs new file mode 100644 index 000000000..1a3fe6c3e --- /dev/null +++ b/packages_rs/nextclade-web/src/wasm/seq_autodetect.rs @@ -0,0 +1,97 @@ +use crate::wasm::jserr::{jserr, jserr2}; +use chrono::Duration; +use eyre::WrapErr; +use nextclade::io::fasta::{FastaReader, FastaRecord}; +use nextclade::io::json::json_parse; +use nextclade::sort::minimizer_index::MinimizerIndexJson; +use nextclade::sort::minimizer_search::{run_minimizer_search, MinimizerSearchRecord}; +use nextclade::sort::params::NextcladeSeqSortParams; +use nextclade::utils::datetime::date_now; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::io::Read; +use std::str::FromStr; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct NextcladeSeqAutodetectWasmParams { + batch_interval_ms: i64, + max_batch_size: usize, +} + +impl Default for NextcladeSeqAutodetectWasmParams { + fn default() -> Self { + Self { + batch_interval_ms: 500, + max_batch_size: 100, + } + } +} + +#[wasm_bindgen] +pub struct NextcladeSeqAutodetectWasm { + minimizer_index: MinimizerIndexJson, + run_params: NextcladeSeqAutodetectWasmParams, +} + +#[wasm_bindgen] +impl NextcladeSeqAutodetectWasm { + pub fn new(minimizer_index_json_str: &str, params: &str) -> Result { + let minimizer_index = jserr(MinimizerIndexJson::from_str(minimizer_index_json_str))?; + Ok(Self { + minimizer_index, + run_params: jserr(json_parse(params))?, + }) + } + + pub fn autodetect(&self, qry_fasta_str: &str, callback: &js_sys::Function) -> Result<(), JsError> { + let mut reader = jserr(FastaReader::from_str(&qry_fasta_str).wrap_err_with(|| "When creating fasta reader"))?; + + let search_params = NextcladeSeqSortParams::default(); + + let mut batch = vec![]; + let mut last_flush = date_now(); + + loop { + let mut fasta_record = FastaRecord::default(); + jserr(reader.read(&mut fasta_record).wrap_err("When reading a fasta record"))?; + if fasta_record.is_empty() { + break; + } + + let result = jserr( + run_minimizer_search(&fasta_record, &self.minimizer_index, &search_params).wrap_err_with(|| { + format!( + "When processing sequence #{} '{}'", + fasta_record.index, fasta_record.seq_name + ) + }), + )?; + + batch.push(MinimizerSearchRecord { fasta_record, result }); + + if date_now() - last_flush >= Duration::milliseconds(self.run_params.batch_interval_ms) + || batch.len() >= self.run_params.max_batch_size + { + self.flush_batch(callback, &mut batch)?; + last_flush = date_now(); + } + } + + self.flush_batch(callback, &mut batch)?; + + Ok(()) + } + + fn flush_batch(&self, callback: &js_sys::Function, batch: &mut Vec) -> Result<(), JsError> { + if batch.is_empty() { + return Ok(()); + } + let result_js = serde_wasm_bindgen::to_value(&batch)?; + jserr2(callback.call1(&JsValue::null(), &result_js))?; + batch.clear(); + Ok(()) + } +} diff --git a/packages_rs/nextclade-web/src/workers/launchAnalysis.ts b/packages_rs/nextclade-web/src/workers/launchAnalysis.ts index cc4b1c27e..f7af20736 100644 --- a/packages_rs/nextclade-web/src/workers/launchAnalysis.ts +++ b/packages_rs/nextclade-web/src/workers/launchAnalysis.ts @@ -88,7 +88,7 @@ export async function launchAnalysis( } } -async function getQueryFasta(inputs: AlgorithmInput[]) { +export async function getQueryFasta(inputs: AlgorithmInput[]) { if (isEmpty(inputs)) { throw new Error('Sequence fasta data is not available, but required') } diff --git a/packages_rs/nextclade-web/src/workers/nextcladeAutodetect.worker.ts b/packages_rs/nextclade-web/src/workers/nextcladeAutodetect.worker.ts new file mode 100644 index 000000000..f2824f216 --- /dev/null +++ b/packages_rs/nextclade-web/src/workers/nextcladeAutodetect.worker.ts @@ -0,0 +1,76 @@ +import 'regenerator-runtime' + +import { ErrorInternal } from 'src/helpers/ErrorInternal' +import { sanitizeError } from 'src/helpers/sanitizeError' +import { MinimizerIndexJson, MinimizerSearchRecord } from 'src/types' +import { Observable as ThreadsObservable, Subject } from 'threads/observable' +import type { Thread } from 'threads' +import { expose } from 'threads/worker' +import { NextcladeSeqAutodetectWasm } from 'src/gen/nextclade-wasm' + +const gSubject = new Subject() + +function onResultParsed(res: MinimizerSearchRecord[]) { + gSubject.next(res) +} + +/** + * Keeps the reference to the WebAssembly module.The module is stateful and requires manual initialization + * and teardown. + * This cloud be a class instance, but unfortunately we cannot pass classes to/from WebWorkers (yet?). + */ +let nextcladeAutodetect: NextcladeSeqAutodetectWasm | undefined + +/** Creates the underlying WebAssembly module. */ +async function create(minimizerIndexJsonStr: MinimizerIndexJson) { + nextcladeAutodetect = NextcladeSeqAutodetectWasm.new( + JSON.stringify(minimizerIndexJsonStr), + JSON.stringify({ batchIntervalMs: 250, maxBatchSize: 1000 }), + ) +} + +/** Destroys the underlying WebAssembly module. */ +async function destroy() { + if (!nextcladeAutodetect) { + return + } + + nextcladeAutodetect.free() + nextcladeAutodetect = undefined +} + +async function autodetect(fasta: string): Promise { + if (!nextcladeAutodetect) { + throw new ErrorModuleNotInitialized('autodetect') + } + + try { + nextcladeAutodetect.autodetect(fasta, onResultParsed) + } catch (error: unknown) { + gSubject.error(sanitizeError(error)) + } + + gSubject.complete() +} + +const worker = { + create, + destroy, + autodetect, + values(): ThreadsObservable { + return ThreadsObservable.from(gSubject) + }, +} + +expose(worker) + +export type NextcladeSeqAutodetectWasmWorker = typeof worker +export type NextcladeSeqAutodetectWasmThread = NextcladeSeqAutodetectWasmWorker & Thread + +export class ErrorModuleNotInitialized extends ErrorInternal { + constructor(fnName: string) { + super( + `This WebWorker module has not been initialized yet. When calling module.${fnName} Make sure to call 'module.create()' function.`, + ) + } +} diff --git a/packages_rs/nextclade-web/src/workers/nextcladeWasm.worker.ts b/packages_rs/nextclade-web/src/workers/nextcladeWasm.worker.ts index 940f17667..df51a0a02 100644 --- a/packages_rs/nextclade-web/src/workers/nextcladeWasm.worker.ts +++ b/packages_rs/nextclade-web/src/workers/nextcladeWasm.worker.ts @@ -75,8 +75,8 @@ async function getInitialData(): Promise { if (!nextcladeWasm) { throw new ErrorModuleNotInitialized('getInitialData') } - const aaa = nextcladeWasm.get_initial_data() - const initialData = JSON.parse(aaa) as AnalysisInitialData + const initialDataStr = nextcladeWasm.get_initial_data() + const initialData = JSON.parse(initialDataStr) as AnalysisInitialData return { ...initialData, geneMap: prepareGeneMap(initialData.geneMap), diff --git a/packages_rs/nextclade-web/yarn.lock b/packages_rs/nextclade-web/yarn.lock index 5c15055fe..df7774898 100644 --- a/packages_rs/nextclade-web/yarn.lock +++ b/packages_rs/nextclade-web/yarn.lock @@ -11515,6 +11515,11 @@ nano-time@1.0.0: dependencies: big-integer "^1.6.16" +nanoid@3.3.6: + version "3.3.6" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" + integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== + nanoid@^3.1.30, nanoid@^3.3.1: version "3.3.3" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.3.tgz#fd8e8b7aa761fe807dba2d1b98fb7241bb724a25" @@ -13274,10 +13279,10 @@ react-i18next@11.3.3, react-i18next@^11.3.3: "@babel/runtime" "^7.3.1" html-parse-stringify2 "2.0.1" -react-icons@4.3.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.3.1.tgz#2fa92aebbbc71f43d2db2ed1aed07361124e91ca" - integrity sha512-cB10MXLTs3gVuXimblAdI71jrJx8njrJZmNMEMC+sQu5B/BIOmlsAjskdqpn81y8UBVEGuHODd7/ci5DvoSzTQ== +react-icons@4.11.0: + version "4.11.0" + resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.11.0.tgz#4b0e31c9bfc919608095cc429c4f1846f4d66c65" + integrity sha512-V+4khzYcE5EBk/BvcuYRq6V/osf11ODUM2J8hg2FDSswRrGvqiYUYPRy4OdrWaQOBj4NcpJfmHZLNaD+VH0TyA== react-icons@^3.9.0: version "3.11.0" diff --git a/packages_rs/nextclade/Cargo.toml b/packages_rs/nextclade/Cargo.toml index b8b0962d1..e49b356b5 100644 --- a/packages_rs/nextclade/Cargo.toml +++ b/packages_rs/nextclade/Cargo.toml @@ -43,6 +43,7 @@ num = "=0.4.0" num-traits = "=0.2.15" num_cpus = "=1.16.0" optfield = "=0.3.0" +ordered-float = { version = "=3.9.1", features = ["rand", "serde", "schemars"] } owo-colors = "=3.5.0" pretty_assertions = "=1.3.0" rayon = "=1.7.0" diff --git a/packages_rs/nextclade/src/gene/gene_map_display.rs b/packages_rs/nextclade/src/gene/gene_map_display.rs index 2158fe64d..10079b49b 100644 --- a/packages_rs/nextclade/src/gene/gene_map_display.rs +++ b/packages_rs/nextclade/src/gene/gene_map_display.rs @@ -10,7 +10,6 @@ use eyre::Report; use itertools::{max as iter_max, Itertools}; use num_traits::clamp; use owo_colors::OwoColorize; -use regex::internal::Input; use std::cmp::{max, min}; use std::io::Write; diff --git a/packages_rs/nextclade/src/io/dataset.rs b/packages_rs/nextclade/src/io/dataset.rs index ef06e09db..8d9a90c65 100644 --- a/packages_rs/nextclade/src/io/dataset.rs +++ b/packages_rs/nextclade/src/io/dataset.rs @@ -19,6 +19,9 @@ pub struct DatasetsIndexJson { pub schema_version: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub minimizer_index: Vec, + #[serde(flatten)] pub other: serde_json::Value, } @@ -323,3 +326,12 @@ pub struct DatasetCollectionUrl { #[serde(flatten)] pub other: serde_json::Value, } + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MinimizerIndexVersion { + pub version: String, + pub path: String, + #[serde(flatten)] + pub other: serde_json::Value, +} diff --git a/packages_rs/nextclade/src/io/fs.rs b/packages_rs/nextclade/src/io/fs.rs index 4120049a9..6c2aaff32 100644 --- a/packages_rs/nextclade/src/io/fs.rs +++ b/packages_rs/nextclade/src/io/fs.rs @@ -89,3 +89,11 @@ pub fn read_reader_to_string(reader: impl Read) -> Result { reader.read_to_string(&mut data)?; Ok(data) } + +pub fn path_to_string(p: impl AsRef) -> Result { + p.as_ref() + .as_os_str() + .to_str() + .map(ToOwned::to_owned) + .ok_or_else(|| eyre!("Unable to convert path to string: {:#?}", p.as_ref())) +} diff --git a/packages_rs/nextclade/src/io/nextclade_csv.rs b/packages_rs/nextclade/src/io/nextclade_csv.rs index 676b736a7..b01cba894 100644 --- a/packages_rs/nextclade/src/io/nextclade_csv.rs +++ b/packages_rs/nextclade/src/io/nextclade_csv.rs @@ -24,7 +24,6 @@ use eyre::Report; use indexmap::{indexmap, IndexMap}; use itertools::{chain, Either, Itertools}; use lazy_static::lazy_static; -use regex::internal::Input; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::fmt::Display; diff --git a/packages_rs/nextclade/src/lib.rs b/packages_rs/nextclade/src/lib.rs index 4ad4d2a09..479947cb6 100644 --- a/packages_rs/nextclade/src/lib.rs +++ b/packages_rs/nextclade/src/lib.rs @@ -9,6 +9,7 @@ pub mod graph; pub mod io; pub mod qc; pub mod run; +pub mod sort; pub mod translate; pub mod tree; pub mod types; diff --git a/packages_rs/nextclade/src/sort/minimizer_index.rs b/packages_rs/nextclade/src/sort/minimizer_index.rs new file mode 100644 index 000000000..291b88ce8 --- /dev/null +++ b/packages_rs/nextclade/src/sort/minimizer_index.rs @@ -0,0 +1,126 @@ +use crate::io::fs::read_file_to_string; +use crate::io::json::json_parse; +use crate::io::schema_version::{SchemaVersion, SchemaVersionParams}; +use eyre::{Report, WrapErr}; +use log::warn; +use schemars::JsonSchema; +use serde::ser::SerializeMap; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::collections::BTreeMap; +use std::path::Path; +use std::str::FromStr; + +pub const MINIMIZER_INDEX_SCHEMA_VERSION_FROM: &str = "3.0.0"; +pub const MINIMIZER_INDEX_SCHEMA_VERSION_TO: &str = "3.0.0"; +pub const MINIMIZER_INDEX_ALGO_VERSION: &str = "1"; + +pub type MinimizerMap = BTreeMap; + +/// Contains external configuration and data specific for a particular pathogen +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MinimizerIndexJson { + #[serde(rename = "schemaVersion")] + pub schema_version: String, + + pub version: String, + + pub params: MinimizerIndexParams, + + #[schemars(with = "BTreeMap")] + #[serde(serialize_with = "serde_serialize_minimizers")] + #[serde(deserialize_with = "serde_deserialize_minimizers")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub minimizers: MinimizerMap, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub references: Vec, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub normalization: Vec, + + #[serde(flatten)] + pub other: serde_json::Value, +} + +/// Serde serializer for Letter sequences +pub fn serde_serialize_minimizers(minimizers: &MinimizerMap, s: S) -> Result { + let mut map = s.serialize_map(Some(minimizers.len()))?; + for (k, v) in minimizers { + map.serialize_entry(&k.to_string(), &v.to_string())?; + } + map.end() +} + +/// Serde deserializer for Letter sequences +pub fn serde_deserialize_minimizers<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let map = BTreeMap::::deserialize(deserializer)?; + + let res = map + .into_iter() + .map(|(k, v)| Ok((u64::from_str(&k)?, v))) + .collect::>() + .unwrap(); + + Ok(res) +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MinimizerIndexParams { + pub k: i64, + + pub cutoff: i64, + + #[serde(flatten)] + pub other: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MinimizerIndexRefInfo { + pub length: i64, + pub name: String, + pub n_minimizers: i64, + + #[serde(flatten)] + pub other: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct VersionCheck { + pub version: String, + + #[serde(flatten)] + pub other: serde_json::Value, +} + +impl MinimizerIndexJson { + pub fn from_path(filepath: impl AsRef) -> Result { + let filepath = filepath.as_ref(); + let data = + read_file_to_string(filepath).wrap_err_with(|| format!("When reading minimizer index file: {filepath:#?}"))?; + Self::from_str(data) + } + + pub fn from_str(s: impl AsRef) -> Result { + let s = s.as_ref(); + + SchemaVersion::check_warn( + s, + &SchemaVersionParams { + name: "minimizer_index.json", + ver_from: Some(MINIMIZER_INDEX_SCHEMA_VERSION_FROM), + ver_to: Some(MINIMIZER_INDEX_SCHEMA_VERSION_TO), + }, + ); + + let VersionCheck { version, .. } = json_parse(s)?; + if version.as_str() > MINIMIZER_INDEX_ALGO_VERSION { + warn!("Version of the minimizer index data ({version}) is greater than maximum supported by this version of Nextclade ({MINIMIZER_INDEX_ALGO_VERSION}). This may lead to errors or incorrect results. Please try to update your version of Nextclade and/or contact dataset maintainers for more details."); + } + + json_parse(s) + } +} diff --git a/packages_rs/nextclade/src/sort/minimizer_search.rs b/packages_rs/nextclade/src/sort/minimizer_search.rs new file mode 100644 index 000000000..bc01dbf1b --- /dev/null +++ b/packages_rs/nextclade/src/sort/minimizer_search.rs @@ -0,0 +1,146 @@ +use crate::io::fasta::FastaRecord; +use crate::sort::minimizer_index::{MinimizerIndexJson, MinimizerIndexParams}; +use crate::sort::params::NextcladeSeqSortParams; +use eyre::Report; +use itertools::{izip, Itertools}; +use ordered_float::OrderedFloat; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MinimizerSearchDatasetResult { + pub name: String, + pub length: i64, + pub n_hits: u64, + pub score: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MinimizerSearchResult { + pub total_hits: u64, + pub max_score: f64, + pub datasets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MinimizerSearchRecord { + pub fasta_record: FastaRecord, + pub result: MinimizerSearchResult, +} + +#[allow(clippy::string_slice)] +pub fn run_minimizer_search( + fasta_record: &FastaRecord, + index: &MinimizerIndexJson, + search_params: &NextcladeSeqSortParams, +) -> Result { + let normalization = &index.normalization; + let n_refs = index.references.len(); + + let minimizers = get_ref_search_minimizers(fasta_record, &index.params); + let mut hit_counts = vec![0; n_refs]; + for m in minimizers { + if let Some(mz) = index.minimizers.get(&m) { + for i in 0..n_refs { + hit_counts[i] += u64::from_str(&mz[i..=i])?; + } + } + } + + // we expect hits to be proportional to the length of the sequence and the number of minimizers per reference + let mut scores: Vec = vec![0.0; hit_counts.len()]; + for i in 0..n_refs { + scores[i] = hit_counts[i] as f64 * normalization[i] / fasta_record.seq.len() as f64; + } + + let max_score = scores.iter().copied().fold(0.0, f64::max); + let total_hits: u64 = hit_counts.iter().sum(); + + let datasets = izip!(&index.references, hit_counts, scores) + .filter_map(|(ref_info, n_hits, score)| { + (n_hits >= search_params.min_hits && score >= search_params.min_score).then_some(MinimizerSearchDatasetResult { + name: ref_info.name.clone(), + length: ref_info.length, + n_hits, + score, + }) + }) + .sorted_by_key(|result| -OrderedFloat(result.score)) + .collect_vec(); + + Ok(MinimizerSearchResult { + total_hits, + max_score, + datasets, + }) +} + +const fn invertible_hash(x: u64) -> u64 { + let m: u64 = (1 << 32) - 1; + let mut x: u64 = (!x).wrapping_add(x << 21) & m; + x = x ^ (x >> 24); + x = (x + (x << 3) + (x << 8)) & m; + x = x ^ (x >> 14); + x = (x + (x << 2) + (x << 4)) & m; + x = x ^ (x >> 28); + x = (x + (x << 31)) & m; + x +} + +fn get_hash(kmer: &[u8], params: &MinimizerIndexParams) -> u64 { + let cutoff = params.cutoff as u64; + + let mut x = 0; + let mut j = 0; + + for (i, nuc) in kmer.iter().enumerate() { + let nuc = *nuc as char; + + if i % 3 == 2 { + continue; // skip every third nucleotide to pick up conserved patterns + } + + if !"ACGT".contains(nuc) { + return cutoff + 1; // break out of loop, return hash above cutoff + } + + // A=11=3, C=10=2, G=00=0, T=01=1 + if "AC".contains(nuc) { + x += 1 << j; + } + + if "AT".contains(nuc) { + x += 1 << (j + 1); + } + + j += 2; + } + + invertible_hash(x) +} + +pub fn get_ref_search_minimizers(seq: &FastaRecord, params: &MinimizerIndexParams) -> Vec { + let k = params.k as usize; + let cutoff = params.cutoff as u64; + + let seq_str = preprocess_seq(&seq.seq); + let n = seq_str.len().saturating_sub(k); + let mut minimizers = Vec::with_capacity(n); + for i in 0..n { + let kmer = &seq_str.as_bytes()[i..i + k]; + let mhash = get_hash(kmer, params); + // accept only hashes below cutoff --> reduces the size of the index and the number of lookups + if mhash < cutoff { + minimizers.push(mhash); + } + } + minimizers.into_iter().unique().collect_vec() +} + +fn preprocess_seq(seq: impl AsRef) -> String { + seq.as_ref().to_uppercase().replace('-', "") +} diff --git a/packages_rs/nextclade/src/sort/mod.rs b/packages_rs/nextclade/src/sort/mod.rs new file mode 100644 index 000000000..c8b4c34db --- /dev/null +++ b/packages_rs/nextclade/src/sort/mod.rs @@ -0,0 +1,3 @@ +pub mod minimizer_index; +pub mod minimizer_search; +pub mod params; diff --git a/packages_rs/nextclade/src/sort/params.rs b/packages_rs/nextclade/src/sort/params.rs new file mode 100644 index 000000000..a3a93f264 --- /dev/null +++ b/packages_rs/nextclade/src/sort/params.rs @@ -0,0 +1,28 @@ +use clap::Parser; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[allow(clippy::struct_excessive_bools)] +#[derive(Parser, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct NextcladeSeqSortParams { + /// Minimum value of the score being considered for a detection + #[clap(long)] + #[clap(default_value_t = NextcladeSeqSortParams::default().min_score)] + pub min_score: f64, + + /// Minimum number of the index hits required for a detection + #[clap(long)] + #[clap(default_value_t = NextcladeSeqSortParams::default().min_hits)] + pub min_hits: u64, +} + +#[allow(clippy::derivable_impls)] +impl Default for NextcladeSeqSortParams { + fn default() -> Self { + Self { + min_score: 0.3, + min_hits: 10, + } + } +} diff --git a/packages_rs/nextclade/src/tree/split_muts.rs b/packages_rs/nextclade/src/tree/split_muts.rs index 3057150cc..73942395a 100644 --- a/packages_rs/nextclade/src/tree/split_muts.rs +++ b/packages_rs/nextclade/src/tree/split_muts.rs @@ -6,7 +6,6 @@ use crate::coord::position::PositionLike; use crate::make_internal_error; use eyre::{Report, WrapErr}; use itertools::{chain, Itertools}; -use regex::internal::Input; use std::collections::BTreeMap; use std::fmt::Display; use std::hash::Hash; diff --git a/packages_rs/nextclade/src/tree/split_muts2.rs b/packages_rs/nextclade/src/tree/split_muts2.rs index d1dd46fab..5fe4ab0f0 100644 --- a/packages_rs/nextclade/src/tree/split_muts2.rs +++ b/packages_rs/nextclade/src/tree/split_muts2.rs @@ -4,7 +4,6 @@ use crate::analyze::nuc_del::NucDel; use crate::analyze::nuc_sub::NucSub; use crate::tree::split_muts::SplitMutsResult; use itertools::Itertools; -use regex::internal::Input; use std::collections::{BTreeMap, HashSet}; /// Split mutations into 3 groups: diff --git a/packages_rs/nextclade/src/tree/tree_builder.rs b/packages_rs/nextclade/src/tree/tree_builder.rs index 6955ca65a..69dcdbba5 100644 --- a/packages_rs/nextclade/src/tree/tree_builder.rs +++ b/packages_rs/nextclade/src/tree/tree_builder.rs @@ -15,7 +15,6 @@ use crate::types::outputs::NextcladeOutputs; use crate::utils::collections::concat_to_vec; use eyre::{Report, WrapErr}; use itertools::Itertools; -use regex::internal::Input; use std::collections::BTreeMap; pub fn graph_attach_new_nodes_in_place( diff --git a/packages_rs/nextclade/src/utils/option.rs b/packages_rs/nextclade/src/utils/option.rs index cb0ad0f5a..5ff800ad2 100644 --- a/packages_rs/nextclade/src/utils/option.rs +++ b/packages_rs/nextclade/src/utils/option.rs @@ -36,3 +36,26 @@ impl<'o, T: 'o> OptionMapRefFallible<'o, T> for Option { (*self).as_ref().map(f).transpose() } } + +pub trait OptionMapMutFallible<'o, T: 'o> { + /// Borrows the internal value of an `Option`, maps it using the provided closure + /// and transposes `Option` of `Result` to `Result` of `Option`. + /// + /// Convenient to use with fallible mapping functions (which returns a `Result`) + /// + /// Inspired by + /// https://github.com/ammongit/rust-ref-map/blob/4b1251c6d2fd192d89a114395b36aeeab5c5433c/src/option.rs + fn map_mut_fallible(&'o mut self, f: F) -> Result, E> + where + F: FnOnce(&'o mut T) -> Result; +} + +impl<'o, T: 'o> OptionMapMutFallible<'o, T> for Option { + #[inline] + fn map_mut_fallible(&'o mut self, f: F) -> Result, E> + where + F: FnOnce(&'o mut T) -> Result, + { + (*self).as_mut().map(f).transpose() + } +}