diff --git a/Cargo.lock b/Cargo.lock index 38c58df..2e18dae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1738,6 +1738,7 @@ dependencies = [ name = "eskin-data-analysis" version = "0.1.0" dependencies = [ + "anyhow", "dioxus", "futures", "polars", diff --git a/Cargo.toml b/Cargo.toml index 23fc76d..c7ce508 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +anyhow = "1.0.102" dioxus = { version = "0.7.1", features = ["router"] } futures = "0.3.32" polars = { version="0.53.0", features = ["lazy", "csv"] } @@ -17,3 +18,4 @@ default = ["desktop"] web = ["dioxus/web"] desktop = ["dioxus/desktop"] mobile = ["dioxus/mobile"] + diff --git a/Dioxus.toml b/Dioxus.toml index 04bfe2b..3a0c806 100644 --- a/Dioxus.toml +++ b/Dioxus.toml @@ -19,3 +19,7 @@ script = [] # Javascript code file # serve: [dev-server] only script = [] + +[bundle] +identifier = "com.qunin" +publisher = "JOYSONQUNIN" \ No newline at end of file diff --git a/assets/tailwind.css b/assets/tailwind.css index 07408b4..45e0724 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -182,6 +182,9 @@ max-width: 96rem; } } + .mr-3 { + margin-right: calc(var(--spacing) * 3); + } .mb-4 { margin-bottom: calc(var(--spacing) * 4); } diff --git a/src/components/filters_rate_threshold.rs b/src/components/filters_rate_threshold.rs index 3a9fae9..3a50834 100644 --- a/src/components/filters_rate_threshold.rs +++ b/src/components/filters_rate_threshold.rs @@ -1,45 +1,92 @@ -use dioxus::{Ok, html::{div, script::r#async, text}, prelude::*}; +use std::{fs::File, future::Future}; + use dioxus::prelude::*; -use crate::guide_router::Route; -use rfd::AsyncFileDialog; use polars::prelude::*; -const RATE_LABELS: [i32; 11] = [60, 160, 260, 360, 460, 560, 660, 860, 1060, 1560, 2060]; + #[derive(Clone, Copy)] pub struct FilterRateThresholdState { - pub thresholds: Signal>, + pub items: Signal>, + pub next_id: Signal, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ThresholdItem { + pub id: usize, + pub label: String, + pub value: String, } #[component] pub fn FilterRateThreshold() -> Element { let mut state = use_context::(); - let mut navigator = use_navigator(); + let navigator = use_navigator(); + rsx! { div { class: "section", div { class: "container", div { class: "card", div { class: "card-header", - p { class: "card-header-title", "过滤阈值配置" } + p { class: "card-header-title", "阈值配置" } } div { class: "card-content", p { class: "subtitle is-6 has-text-grey", - "支持手动输入、CSV 导入,后续可扩展其他输入方式。" + "支持手动输入和 CSV 导入。手动输入时可以随时新增、删除和修改条目。" } - // 手动输入 div { class: "box", h2 { class: "title is-6 mb-4", "手动输入" } div { class: "columns is-multiline", - for (index , label) in RATE_LABELS.iter().enumerate() { + for item in state.items.read().iter() { div { class: "column is-half", - ThresholdField { label: *label, index } + ThresholdField { + id: item.id, + label: item.label.clone(), + value: item.value.clone(), + } + } + } + + div { class: "column is-full", + button { + class: "button is-primary mr-3", + onclick: move |_| { + let id = (state.next_id)(); + state + .items + .write() + .push(ThresholdItem { + id, + label: String::new(), + value: String::new(), + }); + state.next_id.with_mut(|next_id| *next_id += 1); + }, + "添加" + } + button { + class: "button is-light", + onclick: move |_| { + // let id = (state.next_id)(); + // state + // .items + // .write() + // .push(ThresholdItem { + // id, + // label: String::new(), + // value: String::new(), + // }); + // state.next_id.with_mut(|next_id| *next_id += 1); + state.items.write().clear(); + state.next_id.with_mut(|n| *n = 0); + }, + "重置" } } } } - // 数据导入 div { class: "box", h2 { class: "title is-6 mb-4", "数据导入" } @@ -47,7 +94,7 @@ pub fn FilterRateThreshold() -> Element { button { class: "button is-primary", onclick: move |_| { - load_csv_data(&mut state.thresholds); + load_csv_data(state.items); }, span { class: "icon", i { class: "fas fa-file-import" } @@ -55,39 +102,13 @@ pub fn FilterRateThreshold() -> Element { span { "导入 CSV" } } - button { - class: "button is-link is-light", - onclick: move |_| { - paste_data(state.thresholds); - }, - "批量粘贴" - } - button { class: "button is-light", - onclick: move |_| { - navigator.go_back(); - }, // navigator.go_back();, // back();, + onclick: move |_| navigator.go_back(), "返回" } } } - - // 当前预览 - div { class: "box", - h2 { class: "title is-6 mb-4", "当前预览" } - - div { class: "content", - ul { - for (index , label) in RATE_LABELS.iter().enumerate() { - li { - strong { "{label}: " } - "{state.thresholds.read()[index]}" - } - } - } - } - } } } } @@ -96,24 +117,57 @@ pub fn FilterRateThreshold() -> Element { } #[component] -pub fn ThresholdField(label: i32, index: usize) -> Element { +fn ThresholdField(id: usize, label: String, value: String) -> Element { let mut state = use_context::(); rsx! { - div { class: "field", - label { class: "label", "{label}" } - div { class: "control", - input { - class: "input is-info", - r#type: "text", - placeholder: "输入阈值", - value: "{state.thresholds.read()[index]}", - oninput: move |evt| { - // state.thresholds.write()[index] = evt as i32; - if let Ok(v) = evt.value().parse::() { - state.thresholds.write()[index] = v; - } - }, + div { class: "box", + div { class: "columns is-vcentered", + div { class: "column", + input { + class: "input", + r#type: "text", + placeholder: "边界值", + value: label, + oninput: move |evt| { + let new_label = evt.value(); + state + .items + .with_mut(|items| { + if let Some(item) = items.iter_mut().find(|item| item.id == id) { + item.label = new_label; + } + }); + }, + } + } + + div { class: "column", + input { + class: "input is-info", + r#type: "text", + placeholder: "阈值", + value, + oninput: move |evt| { + let new_value = evt.value(); + state + .items + .with_mut(|items| { + if let Some(item) = items.iter_mut().find(|item| item.id == id) { + item.value = new_value; + } + }); + }, + } + } + + div { class: "column is-narrow", + button { + class: "delete is-medium", + onclick: move |_| { + state.items.with_mut(|items| items.retain(|item| item.id != id)); + }, + } } } } @@ -122,76 +176,162 @@ pub fn ThresholdField(label: i32, index: usize) -> Element { #[derive(Debug, Clone)] struct SmoothRateConfig { - thresholds: Vec, + ths: Vec, rates: Vec, file_path: Vec, } impl Default for SmoothRateConfig { fn default() -> Self { - SmoothRateConfig { thresholds: Vec::new(), rates: Vec::new(), file_path: Vec::new() } + SmoothRateConfig { ths: Vec::new(), rates: Vec::new(), file_path: Vec::new() } } } impl SmoothRateConfig { - fn new(ths: Vec, files: Vec) -> Self { - if ths.len() != 11 { + pub fn new(items: Vec, files: Vec) -> Self { + let mut points = items + .into_iter() + .filter_map(|item| { + let label = item.label.trim().parse::().ok()?; + let value = item.value.trim().parse::().ok()?; + Some((label, value)) + }) + .collect::>(); + + points.sort_by_key(|(label, _)| *label); + + if points.len() < 2 { return SmoothRateConfig::default(); } - let rates = ths.chunks(2) - .map(|ck| ck[1] - ck[0]).collect::>(); - - SmoothRateConfig { thresholds: ths, rates, file_path: files } + + // let labels = points.iter().map(|(label, _)| *label).collect::>(); + let ths: Vec = points.iter().map(|(_, v)| *v).collect::>(); + let rates = points + .windows(2) + .filter_map(|window| { + let (start_label, start_value) = window[0]; + let (end_label, end_value) = window[1]; + let distance = end_label.checked_sub(start_label)?; + if distance == 0 { + return None; + } + Some((end_value - start_value) / distance as f32) + }) + .collect::>(); + + if rates.len() + 1 != ths.len() { + return SmoothRateConfig::default(); + } + println!("ths: {:?}", ths); + println!("rates: {:?}", rates); + + SmoothRateConfig { ths, rates, file_path: files } } - fn analysis_file_once(file: &str) -> PolarsResult<()> { + pub fn analysis_all_files(&self) -> anyhow::Result<()> { + if self.file_path.is_empty() { + return anyhow::Ok(()); + } + + let mut failed = vec![]; + for file in &self.file_path { + match self.analysis_file_once(file) { + Ok(_) => {} + Err(error) => failed.push((file.clone(), error.to_string())), + } + } + + if !failed.is_empty() { + for (file, error) in failed { + println!("{file} -> {error}"); + } + } + + anyhow::Ok(()) + } + + fn analysis_file_once(&self, file: &str) -> anyhow::Result<()> { let df = LazyCsvReader::new(file.into()) .with_has_header(true) .finish()? + .with_columns( + (1..=84) + .map(|i| { + let name = format!("channel{i}"); + SmoothRateConfig::analysis_col_once(&name, &self.ths, &self.rates) + .cast(DataType::Float32) + .alias(&name) + }) + .collect::>(), + ) .collect()?; - let df = df.with_column( - (1..=84).map(|i| { - let name = format!("channel{}", i); - - }) - ) + let output = format!("{file}_processed.csv"); + let mut output_file = File::create(output)?; + CsvWriter::new(&mut output_file) + .include_header(true) + .finish(&mut df.clone())?; + debug!("analysis finished"); + anyhow::Ok(()) } fn analysis_col_once(name: &str, thresholds: &[f32], rates: &[f32]) -> Expr { - + let mut expr: Expr = + col(name).cast(DataType::Float64) * lit(rates[rates.len() - 1] as f64); + + for i in (0..rates.len()).rev() { + let t0 = thresholds[i] as f64; + let t1 = thresholds[i + 1] as f64; + let rate = rates[i] as f64; + + expr = when( + col(name) + .cast(DataType::Float64) + .gt_eq(lit(t0)) + .and(col(name).cast(DataType::Float64).lt(lit(t1))), + ) + .then(col(name).cast(DataType::Float64) * lit(rate)) + .otherwise(expr); + } + + when(col(name).cast(DataType::Float64).lt(lit(thresholds[0] as f64))) + .then(lit(0.0)) + .otherwise(expr) } } - - -fn load_csv_data(thresholds: &mut Signal>) { +fn load_csv_data(items: Signal>) { + let current_items = items.read().clone(); + let task = rfd::AsyncFileDialog::new() .add_filter("CSV", &["csv"]) .pick_files(); - execute(async { - let file_handles = task.await; - if let Some(files) = file_handles { - for file in files { - let path = file.path(); + execute(async move { + let file_handles = task.await; + + if let Some(files) = file_handles { + let file_path_vec = files + .iter() + .map(|file| file.path().to_string_lossy().to_string()) + .collect::>(); + + let config = SmoothRateConfig::new(current_items, file_path_vec); + + if let Err(error) = config.analysis_all_files() { + eprintln!("批量处理失败: {error}"); } } }); } -fn paste_data(thresholds: Signal>) { - todo!() +#[cfg(not(target_arch = "wasm32"))] +fn execute + Send + 'static>(future: F) { + std::thread::spawn(move || futures::executor::block_on(future)); } -use std::{fs::FileTimes, future::Future}; -#[cfg(not(target_arch = "wasm32"))] -fn execute + Send + 'static>(f: F) { - // this is stupid... use any executor of your choice instead - std::thread::spawn(move || futures::executor::block_on(f)); -} #[cfg(target_arch = "wasm32")] -fn execute + 'static>(f: F) { - wasm_bindgen_futures::spawn_local(f); -} \ No newline at end of file +fn execute + 'static>(future: F) { + wasm_bindgen_futures::spawn_local(future); +} diff --git a/src/main.rs b/src/main.rs index 38e35d2..2e50cc7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,12 @@ pub mod components; pub mod guide_router; -use dioxus::{html::{div, text}, prelude::*}; use dioxus::prelude::*; const FAVICON: Asset = asset!("/assets/favicon.ico"); const MAIN_CSS: Asset = asset!("/assets/bulma.css"); const HEADER_SVG: Asset = asset!("/assets/header.svg"); -use crate::components::filters_rate_threshold::{FilterRateThreshold, FilterRateThresholdState}; +use crate::components::filters_rate_threshold::FilterRateThresholdState; use crate::guide_router::Route; fn main() { dioxus::launch(App); @@ -15,8 +14,9 @@ fn main() { #[component] fn App() -> Element { - let thresholds = use_signal(|| vec![0.0; 11]); - use_context_provider(|| FilterRateThresholdState { thresholds }); + let items = use_signal(Vec::new); + let next_id = use_signal(|| 0usize); + use_context_provider(|| FilterRateThresholdState { items, next_id }); rsx! { document::Link { rel: "icon", href: FAVICON } document::Link { rel: "stylesheet", href: MAIN_CSS }