Merge from import_from_csv feature branch

Of course, there were other features that got tacked on...
Squashed commit of the following:

commit 3ee3bd2dab
Author: Emilia <contact@emiliaallison.com>
Date:   Fri Dec 29 19:12:16 2023 -0500

    Superior clipboard manipulation

    Won't work on non-https connections, but actually works...

commit 08f647cd01
Author: Emilia <contact@emiliaallison.com>
Date:   Fri Dec 29 18:50:01 2023 -0500

    Utility for copying plates as image

commit 3456be2e9a
Author: Emilia <contact@emiliaallison.com>
Date:   Fri Dec 29 17:46:38 2023 -0500

    Change wording in options menu

    Father suggests that this wording is more clear to the end user.
    I agree!

commit 4c79cc0b4d
Author: Emilia <contact@emiliaallison.com>
Date:   Fri Dec 29 18:20:00 2023 -0500

    Set default plate format to 96 well

commit 056688c4ec
Author: Emilia <contact@emiliaallison.com>
Date:   Fri Dec 29 18:12:00 2023 -0500

    Implement in_transfer hashes toggle in plates

commit 4937d4ad28
Author: Emilia <contact@emiliaallison.com>
Date:   Fri Dec 29 18:11:00 2023 -0500

    Preferences menu and toggle for in_transfer hashes

commit 0101846b52
Author: Emilia <contact@emiliaallison.com>
Date:   Fri Dec 29 18:10:00 2023 -0500

    Add preferences struct to main state

commit ec37887c2f
Author: Emilia <contact@emiliaallison.com>
Date:   Fri Dec 29 18:05:00 2023 -0500

    Squashed commit of the following:

    commit 5e1137c460
    Author: Emilia <contact@emiliaallison.com>
    Date:   Fri Dec 29 18:03:00 2023 -0500

        Fix: indexing error w.r.t. logarithm argument

    commit 535b14a586
    Author: Emilia <contact@emiliaallison.com>
    Date:   Fri Dec 29 18:02:00 2023 -0500

        Space colors evenly, consistently, etc

        Colors should now:
        	- Not change if new transfers are added
        	- Be evenly spaced throughout the palette
        	- Be persistent across refreshes

    commit 6e08f47955
    Author: Emilia <contact@emiliaallison.com>
    Date:   Fri Dec 29 18:01:00 2023 -0500

        Add palette function for ordered ids

        Given an id and a list of sorted ids, yields a color

    commit 88e838e102
    Author: Emilia <contact@emiliaallison.com>
    Date:   Fri Dec 29 18:00:00 2023 -0500

        Switch to v7 UUIDs from v4

        v7 UUIDs are timestamp based and thus we can establish a useful
        total ordering over them; will base colors on this

commit 85d4b30d47
Author: Emilia <contact@emiliaallison.com>
Date:   Tue Oct 24 21:18:10 2023 -0400

    Update README.md

    Updated info about import/export, including the new Import Transfer from CSV feature.

commit 11a561c1d4
Author: Emilia <contact@emiliaallison.com>
Date:   Tue Oct 24 20:32:51 2023 -0400

    Add text to button

commit 562dc2adf6
Author: Emilia <contact@emiliaallison.com>
Date:   Tue Oct 24 20:32:40 2023 -0400

    Change to make colors more evenly distributed

commit 6b09aad289
Author: Emilia <contact@emiliaallison.com>
Date:   Tue Oct 24 19:27:02 2023 -0400

    Implementation 1

commit a9e5f05fd9
Author: Emilia <contact@emiliaallison.com>
Date:   Tue Oct 24 17:18:45 2023 -0400

    Hide parts of transfer menu when Custom transfer selected

commit db345bfbb5
Author: Emilia <contact@emiliaallison.com>
Date:   Tue Oct 24 17:18:08 2023 -0400

    delete weird whitespace from Cargo.toml

commit edcc3528aa
Author: Emilia <contact@emiliaallison.com>
Date:   Tue Oct 24 16:41:58 2023 -0400

    First implementation of custom region type

commit 9a3a10c8b4
Author: Emilia <contact@emiliaallison.com>
Date:   Tue Oct 24 16:21:30 2023 -0400

    Transfer region no longer copy
This commit is contained in:
Emilia Allison 2023-12-29 20:39:00 -05:00
parent 0b2704d4ab
commit a710054a98
Signed by: emilia
GPG Key ID: 7A3F8997BFE894E0
18 changed files with 8382 additions and 51 deletions

17
Cargo.lock generated
View File

@ -34,6 +34,12 @@ dependencies = [
"syn 2.0.16", "syn 2.0.16",
] ]
[[package]]
name = "atomic"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.1.0" version = "1.1.0"
@ -555,9 +561,11 @@ name = "plate-tool"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"csv", "csv",
"getrandom",
"js-sys", "js-sys",
"lazy_static", "lazy_static",
"log", "log",
"rand",
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
@ -883,10 +891,11 @@ checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.3.3" version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "345444e32442451b267fc254ae85a209c64be56d2890e601a0c37ff0c3c5ecd2" checksum = "5e395fcf16a7a3d8127ec99782007af141946b4795001f876d54fb0d55978560"
dependencies = [ dependencies = [
"atomic",
"getrandom", "getrandom",
"rand", "rand",
"serde", "serde",
@ -896,9 +905,9 @@ dependencies = [
[[package]] [[package]]
name = "uuid-macro-internal" name = "uuid-macro-internal"
version = "1.3.3" version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f67b459f42af2e6e1ee213cb9da4dbd022d3320788c3fb3e1b893093f1e45da" checksum = "f49e7f3f3db8040a100710a11932239fd30697115e2ba4107080d8252939845e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

View File

@ -11,17 +11,19 @@ yewdux = "0.9"
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["FormData", "HtmlFormElement", web-sys = { version = "0.3", features = ["FormData", "HtmlFormElement",
"HtmlDialogElement", "Blob", "Url", "Window", "HtmlDialogElement", "Blob", "Url", "Window",
"HtmlAnchorElement", "ReadableStream", "HtmlAnchorElement", "ReadableStream", "HtmlSelectElement", "HtmlOptionElement", "HtmlButtonElement",
"FileReader"] } "FileReader"] }
js-sys = "0.3" js-sys = "0.3"
log = "0.4" log = "0.4"
wasm-logger = "0.2" wasm-logger = "0.2"
regex = "1" regex = "1"
lazy_static = "1.4" lazy_static = "1.4"
uuid = { version = "1.3", features = ["v4", "fast-rng", "macro-diagnostics", "js", "serde"] } uuid = { version = "1.6", features = ["v7", "fast-rng", "macro-diagnostics", "js", "serde"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
csv = "1.2" csv = "1.2"
getrandom = { version = "0.2", features = ["js"] }
rand = { version = "0.8", features = ["small_rng"] }
[dev-dependencies] [dev-dependencies]
wasm-bindgen-test = "0.3.0" wasm-bindgen-test = "0.3.0"

View File

@ -54,6 +54,7 @@ To add a new plate, click the "New Plate" button:
### Importing and Exporting ### Importing and Exporting
#### Export as CSV
Exporting the transfers we have created to a CSV format is the primary (if not sole) usage of Plate Tool. Exporting the transfers we have created to a CSV format is the primary (if not sole) usage of Plate Tool.
To do so, first note the "File" tab at the top-left of the screen (above the list pane). To do so, first note the "File" tab at the top-left of the screen (above the list pane).
Mouse over this tab, and a few more options will be revealed. Mouse over this tab, and a few more options will be revealed.
@ -61,20 +62,29 @@ To add a new plate, click the "New Plate" button:
You will be reminded that this is a one-way export (see JSON export/import below), You will be reminded that this is a one-way export (see JSON export/import below),
and then prompted by your browser to select a location for your file. and then prompted by your browser to select a location for your file.
Currently, it is not possible to import from nor export to a format produced by other similar software. #### Export as JSON (Saving Your Work)
Currently, it is not possible to export to a format produced by other similar software.
However, you might reasonably want to save a copy of your work However, you might reasonably want to save a copy of your work
either as a backup or to share. either as a backup or to share.
Mouse over the "File" tab, then "Export" as above, then alternatively select "Export as JSON". Mouse over the "File" tab, then "Export" as above, then alternatively select "Export as JSON".
Your browser will then prompt you to pick a suitable location to save your work as a file. Your browser will then prompt you to pick a suitable location to save your work as a file.
(See note 1 below) (See note 1 below)
#### Import from JSON (Recovering Your Work)
If we want to import one such file, mouse over the "File" tab as before If we want to import one such file, mouse over the "File" tab as before
and select "Import". and select "Import", and finally click "Import from JSON".
This opens a modal where you are prompted to upload (see note 2) This opens a modal where you are prompted to upload (see note 2)
your file; it will then be processed and loaded. your file; it will then be processed and loaded.
Keep in mind that this will overwrite any work you currently have open, Keep in mind that this will overwrite any work you currently have open,
so you may wish to export first (see above). so you may wish to export first (see above).
#### Import Transfer from CSV (Using a picklist as a transfer)
If you have a CSV generated by another tool (or plate-tool),
you can import it as a single transfer.
To do so, mouse over the "File" tab, then "Import", and finally "Import Transfer from CSV".
When creating transfers via this method, the transfer cannot be edited.
This is useful if you have a pre-existing picklist that you would like to visualize in plate-tool.
_Note 1_: JSON files are plaintext! _Note 1_: JSON files are plaintext!
By default there is little whitespace (this makes comprehending them a challenge) By default there is little whitespace (this makes comprehending them a challenge)
but if we pass it through a "JSON Beautifier" (enter this into your search engine of choice) but if we pass it through a "JSON Beautifier" (enter this into your search engine of choice)

7830
assets/js/html2canvas.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,25 @@
function copy_screenshot(el) {
html2canvas(el).then((canvas) => {
console.log("Copying image to clipboard");
canvas.toBlob((b) => {
try {
navigator.clipboard.write([
new ClipboardItem({
'image/png': b
})
]);
} catch (e) {
console.error("Failed to copy!");
}
});
});
}
function copy_screenshot_dest() {
let plate = document.getElementsByClassName("dest_plate")[0];
copy_screenshot(plate);
}
function copy_screenshot_src() {
let plate = document.getElementsByClassName("source_plate")[0];
copy_screenshot(plate);
}

View File

@ -12,6 +12,8 @@ div.upper_menu {
visibility: inherit; visibility: inherit;
display: flex;
div.dropdown { div.dropdown {
margin-right: 2px; margin-right: 2px;

View File

@ -4,6 +4,8 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<link data-trunk rel="scss" href="assets/scss/index.scss"> <link data-trunk rel="scss" href="assets/scss/index.scss">
<link data-trunk rel="copy-dir" href="assets/fonts"> <link data-trunk rel="copy-dir" href="assets/fonts">
<script data-trunk src="assets/js/screenshot_utility.js"></script>
<script data-trunk src="assets/js/html2canvas.js"></script>
<title>Plate Tool</title> <title>Plate Tool</title>
</head> </head>
</html> </html>

View File

@ -1,18 +1,27 @@
#![allow(non_snake_case)] #![allow(non_snake_case)]
use std::collections::HashSet;
use js_sys::Array; use js_sys::Array;
use lazy_static::lazy_static;
use regex::Regex;
use wasm_bindgen::{prelude::*, JsCast, JsValue}; use wasm_bindgen::{prelude::*, JsCast, JsValue};
use web_sys::{Blob, HtmlAnchorElement, HtmlDialogElement, HtmlFormElement, HtmlInputElement, Url}; use web_sys::{
Blob, HtmlAnchorElement, HtmlButtonElement, HtmlDialogElement, HtmlFormElement,
HtmlInputElement, HtmlOptionElement, HtmlSelectElement, Url,
};
use yew::prelude::*; use yew::prelude::*;
use yewdux::prelude::*; use yewdux::prelude::*;
use super::new_plate_dialog::NewPlateDialog; use super::new_plate_dialog::NewPlateDialog;
use super::plates::plate_container::PlateContainer; use super::plates::plate_container::PlateContainer;
use super::states::{CurrentTransfer, MainState}; use super::states::{CurrentTransfer, MainState};
use super::transfer_menu::TransferMenu; use super::transfer_menu::{letters_to_num, RegionDisplay, TransferMenu};
use super::tree::Tree; use super::tree::Tree;
use crate::data::csv::state_to_csv; use crate::data::csv::state_to_csv;
use crate::data::plate_instances::PlateInstance; use crate::data::plate_instances::PlateInstance;
use crate::data::transfer::Transfer;
use crate::data::transfer_region::{Region, TransferRegion};
#[function_component] #[function_component]
pub fn MainWindow() -> Html { pub fn MainWindow() -> Html {
@ -40,6 +49,15 @@ pub fn MainWindow() -> Html {
}); });
} }
let toggle_in_transfer_hashes_callback = {
let main_dispatch = main_dispatch.clone();
Callback::from(move |_| {
main_dispatch.reduce_mut(|state| {
state.preferences.in_transfer_hashes ^= true;
})
})
};
let new_plate_dialog_is_open = use_state_eq(|| false); let new_plate_dialog_is_open = use_state_eq(|| false);
let new_plate_dialog_callback = { let new_plate_dialog_callback = {
let new_plate_dialog_is_open = new_plate_dialog_is_open.clone(); let new_plate_dialog_is_open = new_plate_dialog_is_open.clone();
@ -100,6 +118,7 @@ pub fn MainWindow() -> Html {
}; };
let import_json_button_callback = { let import_json_button_callback = {
let main_dispatch = main_dispatch.clone();
Callback::from(move |_| { Callback::from(move |_| {
let window = web_sys::window().unwrap(); let window = web_sys::window().unwrap();
let document = window.document().unwrap(); let document = window.document().unwrap();
@ -177,6 +196,274 @@ pub fn MainWindow() -> Html {
}) })
}; };
let import_transfer_csv_callback = {
Callback::from(move |_| {
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let body = document.body().unwrap();
let modal = document
.create_element("dialog")
.unwrap()
.dyn_into::<HtmlDialogElement>()
.unwrap();
modal.set_text_content(Some("Import File:"));
let onclose_callback = {
let modal = modal.clone();
Closure::<dyn FnMut(_)>::new(move |_: Event| {
modal.remove();
})
};
modal.set_onclose(Some(onclose_callback.as_ref().unchecked_ref()));
onclose_callback.forget();
let form = document
.create_element("form")
.unwrap()
.dyn_into::<HtmlFormElement>()
.unwrap();
let input = document
.create_element("input")
.unwrap()
.dyn_into::<HtmlInputElement>()
.unwrap();
input.set_type("file");
input.set_accept(".csv");
form.append_child(&input).unwrap();
let input_callback = {
let main_dispatch = main_dispatch.clone();
let modal = modal.clone();
Closure::<dyn FnMut(_)>::new(move |e: Event| {
if let Some(input) = e.current_target() {
let input = input
.dyn_into::<HtmlInputElement>()
.expect("We know this is an input.");
if let Some(files) = input.files() {
if let Some(file) = files.get(0) {
let fr = web_sys::FileReader::new().unwrap();
fr.read_as_text(&file).unwrap();
let fr1 = fr.clone(); // Clone to avoid outliving closure
let main_dispatch = main_dispatch.clone(); // Clone to satisfy FnMut
// trait
let modal = modal.clone();
let onload = Closure::<dyn FnMut(_)>::new(move |_: Event| {
if let Some(value) =
&fr1.result().ok().and_then(|v| v.as_string())
{
let mut rdr = csv::Reader::from_reader(value.as_bytes());
let mut records = Vec::new();
for record in
rdr.deserialize::<crate::data::csv::TransferRecord>()
{
match record {
Ok(r) => {
//log::debug!("{:?}", r);
records.push(r);
}
Err(e) => {
log::debug!("{:?}", e);
}
}
}
let mut sources: HashSet<String> = HashSet::new();
let mut destinations: HashSet<String> = HashSet::new();
for record in records.iter() {
sources.insert(record.source_plate.clone());
destinations.insert(record.destination_plate.clone());
}
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let form = document
.create_element("form")
.unwrap()
.dyn_into::<HtmlFormElement>()
.unwrap();
let from_source = document
.create_element("select")
.unwrap()
.dyn_into::<HtmlSelectElement>()
.unwrap();
for source in sources {
let option = document
.create_element("option")
.unwrap()
.dyn_into::<HtmlOptionElement>()
.unwrap();
option.set_value(&source);
option.set_text(&source);
from_source.append_child(&option).unwrap();
}
let to_source = document
.create_element("select")
.unwrap()
.dyn_into::<HtmlSelectElement>()
.unwrap();
for source in &main_dispatch.get().source_plates {
let option = document
.create_element("option")
.unwrap()
.dyn_into::<HtmlOptionElement>()
.unwrap();
option.set_value(&source.name);
option.set_text(&source.name);
to_source.append_child(&option).unwrap();
}
let from_dest = document
.create_element("select")
.unwrap()
.dyn_into::<HtmlSelectElement>()
.unwrap();
for dest in destinations {
let option = document
.create_element("option")
.unwrap()
.dyn_into::<HtmlOptionElement>()
.unwrap();
option.set_value(&dest);
option.set_text(&dest);
from_dest.append_child(&option).unwrap();
}
let to_dest = document
.create_element("select")
.unwrap()
.dyn_into::<HtmlSelectElement>()
.unwrap();
for dest in &main_dispatch.get().destination_plates {
let option = document
.create_element("option")
.unwrap()
.dyn_into::<HtmlOptionElement>()
.unwrap();
option.set_value(&dest.name);
option.set_text(&dest.name);
to_dest.append_child(&option).unwrap();
}
let submit = document
.create_element("button")
.unwrap()
.dyn_into::<HtmlButtonElement>()
.unwrap();
submit.set_value("Submit");
submit.set_inner_text("Submit");
let submit_callback = {
let main_dispatch = main_dispatch.clone();
let from_source = from_source.clone();
let to_source = to_source.clone();
let from_dest = from_dest.clone();
let to_dest = to_dest.clone();
Closure::<dyn FnMut(_)>::new(move |_: Event| {
let from_source = from_source.value();
let to_source = to_source.value();
let from_dest = from_dest.value();
let to_dest = to_dest.value();
lazy_static! {
static ref REGEX: Regex =
Regex::new(r"([A-Z]+)(\d+)").unwrap();
}
let records: Vec<((u8, u8), (u8, u8))> = records
.iter()
.filter(|record| {
record.source_plate == from_source
})
.filter(|record| {
record.destination_plate == from_dest
})
.map(|record| {
let c1 = REGEX
.captures(&record.source_well)
.unwrap();
let c2 = REGEX
.captures(&record.destination_well)
.unwrap();
log::debug!("{} {}", &record.source_well, &record.destination_well);
log::debug!("{},{} {},{}", &c1[1], &c1[2], &c2[1], &c2[2]);
(
(
letters_to_num(&c1[1]).unwrap(),
c1[2].parse::<u8>().unwrap(),
),
(
letters_to_num(&c2[1]).unwrap(),
c2[2].parse::<u8>().unwrap(),
),
)
})
.collect();
let spi = main_dispatch
.get()
.source_plates
.iter()
.find(|src| src.name == to_source)
.unwrap()
.clone();
let dpi = main_dispatch
.get()
.destination_plates
.iter()
.find(|dest| dest.name == to_dest)
.unwrap()
.clone();
let custom_region = Region::new_custom(&records);
let transfer_region = TransferRegion {
source_region: custom_region.clone(),
dest_region: custom_region,
interleave_source: (1, 1),
interleave_dest: (1, 1),
source_plate: spi.plate,
dest_plate: dpi.plate,
};
let transfer = Transfer::new(
spi,
dpi,
transfer_region,
"Custom Transfer".to_string(),
);
main_dispatch.reduce_mut(|state| {
state.transfers.push(transfer);
state.selected_transfer = state
.transfers
.last()
.expect("An element should have just been added")
.get_uuid();
});
})
};
submit.set_onclick(Some(
submit_callback.as_ref().unchecked_ref(),
));
submit_callback.forget();
form.append_child(&from_source).unwrap();
form.append_child(&to_source).unwrap();
form.append_child(&from_dest).unwrap();
form.append_child(&to_dest).unwrap();
modal.append_child(&submit).unwrap();
modal.append_child(&form).unwrap();
}
});
fr.set_onload(Some(onload.as_ref().unchecked_ref()));
onload.forget(); // Magic (don't touch)
}
}
}
})
};
input.set_onchange(Some(input_callback.as_ref().unchecked_ref()));
input_callback.forget(); // Magic straight from the docs, don't touch :(
modal.append_child(&form).unwrap();
body.append_child(&modal).unwrap();
modal.show_modal().unwrap();
})
};
html! { html! {
<> <>
<div class="upper_menu"> <div class="upper_menu">
@ -190,7 +477,22 @@ pub fn MainWindow() -> Html {
<button onclick={export_json_button_callback}>{"Export as JSON"}</button> <button onclick={export_json_button_callback}>{"Export as JSON"}</button>
</div> </div>
</div> </div>
<button onclick={import_json_button_callback}>{"Import"}</button> <div class="dropdown-sub">
<button>{"Import"}</button>
<div>
<button onclick={import_json_button_callback}>{"Import from JSON"}</button>
<button onclick={import_transfer_csv_callback}>{"Import Transfer from CSV"}</button>
</div>
</div>
</div>
<div class="dropdown">
<button>{"Options"}</button>
<div class="dropdown-sub">
<button>{"Styles"}</button>
<div>
<button onclick={toggle_in_transfer_hashes_callback}>{"Toggle transfer hashes"}</button>
</div>
</div>
</div> </div>
</div> </div>
<div class="main_container"> <div class="main_container">

View File

@ -99,8 +99,8 @@ pub fn NewPlateDialog(props: &NewPlateDialogProps) -> Html {
<option value="12">{"12"}</option> <option value="12">{"12"}</option>
<option value="24">{"24"}</option> <option value="24">{"24"}</option>
<option value="48">{"48"}</option> <option value="48">{"48"}</option>
<option value="96">{"96"}</option> <option value="96" selected={true}>{"96"}</option>
<option value="384" selected={true}>{"384"}</option> <option value="384">{"384"}</option>
<option value="1536">{"1536"}</option> <option value="1536">{"1536"}</option>
<option value="3456">{"3456"}</option> <option value="3456">{"3456"}</option>
</select> </select>

View File

@ -34,12 +34,21 @@ pub fn DestinationPlate(props: &DestinationPlateProps) -> Html {
let (pt1, pt2) = match ct_state.transfer.transfer_region.dest_region { let (pt1, pt2) = match ct_state.transfer.transfer_region.dest_region {
Region::Point((x, y)) => ((x, y), (x, y)), Region::Point((x, y)) => ((x, y), (x, y)),
Region::Rect(c1, c2) => (c1, c2), Region::Rect(c1, c2) => (c1, c2),
Region::Custom(_) => ((0,0), (0,0)),
}; };
m_start_handle.set(Some(pt1)); m_start_handle.set(Some(pt1));
m_end_handle.set(Some(pt2)); m_end_handle.set(Some(pt2));
} }
let destination_wells = ct_state.transfer.transfer_region.get_destination_wells(); let destination_wells = ct_state.transfer.transfer_region.get_destination_wells();
let ordered_ids: Vec<uuid::Uuid> = {
let mut ids: Vec<uuid::Uuid> = main_state.transfers.clone().iter()
.map(|x| x.id)
.collect();
ids.sort_unstable();
ids
};
let mouse_callback = { let mouse_callback = {
let m_start_handle = m_start_handle.clone(); let m_start_handle = m_start_handle.clone();
let m_end_handle = m_end_handle.clone(); let m_end_handle = m_end_handle.clone();
@ -98,6 +107,11 @@ pub fn DestinationPlate(props: &DestinationPlateProps) -> Html {
let mouseleave_callback = Callback::clone(&mouseup_callback); let mouseleave_callback = Callback::clone(&mouseup_callback);
let screenshot_callback = Callback::from(|_| {
let _ = js_sys::eval("copy_screenshot_dest()");
});
let column_header = { let column_header = {
let headers = (1..=props.destination_plate.plate.size().1) let headers = (1..=props.destination_plate.plate.size().1)
.map(|j| { .map(|j| {
@ -114,10 +128,10 @@ pub fn DestinationPlate(props: &DestinationPlateProps) -> Html {
<DestPlateCell i={i} j={j} <DestPlateCell i={i} j={j}
selected={super::source_plate::in_rect(*m_start_handle.clone(), *m_end_handle.clone(), (i,j))} selected={super::source_plate::in_rect(*m_start_handle.clone(), *m_end_handle.clone(), (i,j))}
mouse={mouse_callback.clone()} mouse={mouse_callback.clone()}
in_transfer={destination_wells.contains(&(i,j))} in_transfer={destination_wells.contains(&(i,j)) && main_state.preferences.in_transfer_hashes}
color={transfer_map.get(&(i,j)) color={transfer_map.get(&(i,j))
.and_then(|t| t.last()) .and_then(|t| t.last())
.map(|t| PALETTE.get_uuid(t.get_uuid())) .map(|t| PALETTE.get_ordered(t.get_uuid(), &ordered_ids))
} }
cell_height={props.cell_height} cell_height={props.cell_height}
title={transfer_map.get(&(i,j)).map(|transfers| format!("Used by: {}", transfers.iter().map(|t| t.name.clone()) title={transfer_map.get(&(i,j)).map(|transfers| format!("Used by: {}", transfers.iter().map(|t| t.name.clone())
@ -134,7 +148,8 @@ pub fn DestinationPlate(props: &DestinationPlateProps) -> Html {
.collect::<Html>(); .collect::<Html>();
html! { html! {
<div class={classes!{"dest_plate", <div ondblclick={screenshot_callback}
class={classes!{"dest_plate",
"W".to_owned()+&props.source_plate.plate.plate_format.to_string()}}> "W".to_owned()+&props.source_plate.plate.plate_format.to_string()}}>
<table <table
onmouseup={move |e| { onmouseup={move |e| {

View File

@ -34,6 +34,7 @@ pub fn SourcePlate(props: &SourcePlateProps) -> Html {
let (pt1, pt2) = match ct_state.transfer.transfer_region.source_region { let (pt1, pt2) = match ct_state.transfer.transfer_region.source_region {
Region::Point((x, y)) => ((x, y), (x, y)), Region::Point((x, y)) => ((x, y), (x, y)),
Region::Rect(c1, c2) => (c1, c2), Region::Rect(c1, c2) => (c1, c2),
Region::Custom(_) => ((0,0), (0,0)),
}; };
m_start_handle.set(Some(pt1)); m_start_handle.set(Some(pt1));
m_end_handle.set(Some(pt2)); m_end_handle.set(Some(pt2));
@ -60,6 +61,14 @@ pub fn SourcePlate(props: &SourcePlateProps) -> Html {
let source_wells = ct_state.transfer.transfer_region.get_source_wells(); let source_wells = ct_state.transfer.transfer_region.get_source_wells();
let ordered_ids: Vec<uuid::Uuid> = {
let mut ids: Vec<uuid::Uuid> = main_state.transfers.clone().iter()
.map(|x| x.id)
.collect();
ids.sort_unstable();
ids
};
let mouse_callback = { let mouse_callback = {
let m_start_handle = m_start_handle.clone(); let m_start_handle = m_start_handle.clone();
let m_end_handle = m_end_handle.clone(); let m_end_handle = m_end_handle.clone();
@ -99,6 +108,10 @@ pub fn SourcePlate(props: &SourcePlateProps) -> Html {
let mouseleave_callback = Callback::clone(&mouseup_callback); let mouseleave_callback = Callback::clone(&mouseup_callback);
let screenshot_callback = Callback::from(|_| {
let _ = js_sys::eval("copy_screenshot_src()");
});
let column_header = { let column_header = {
let headers = (1..=props.source_plate.plate.size().1) let headers = (1..=props.source_plate.plate.size().1)
.map(|j| { .map(|j| {
@ -118,10 +131,10 @@ pub fn SourcePlate(props: &SourcePlateProps) -> Html {
<SourcePlateCell i={i} j={j} <SourcePlateCell i={i} j={j}
selected={in_rect(*m_start_handle.clone(), *m_end_handle.clone(), (i,j))} selected={in_rect(*m_start_handle.clone(), *m_end_handle.clone(), (i,j))}
mouse={mouse_callback.clone()} mouse={mouse_callback.clone()}
in_transfer={source_wells.contains(&(i,j))} in_transfer={source_wells.contains(&(i,j)) && main_state.preferences.in_transfer_hashes}
color={transfer_map.get(&(i,j)) color={transfer_map.get(&(i,j))
.and_then(|t| t.last()) .and_then(|t| t.last())
.map(|t| PALETTE.get_uuid(t.get_uuid())) .map(|t| PALETTE.get_ordered(t.get_uuid(), &ordered_ids))
} }
cell_height={props.cell_height} cell_height={props.cell_height}
title={transfer_map.get(&(i,j)).map(|transfers| format!("Used by: {}", transfers.iter().map(|t| t.name.clone()) title={transfer_map.get(&(i,j)).map(|transfers| format!("Used by: {}", transfers.iter().map(|t| t.name.clone())
@ -139,7 +152,8 @@ pub fn SourcePlate(props: &SourcePlateProps) -> Html {
.collect::<Html>(); .collect::<Html>();
html! { html! {
<div class={classes!{"source_plate", <div ondblclick={screenshot_callback}
class={classes!{"source_plate",
"W".to_owned()+&props.source_plate.plate.plate_format.to_string()}}> "W".to_owned()+&props.source_plate.plate.plate_format.to_string()}}>
<table <table
onmouseup={move |e| { onmouseup={move |e| {

View File

@ -2,6 +2,10 @@
// https://iquilezles.org/articles/palettes/ // https://iquilezles.org/articles/palettes/
// http://dev.thi.ng/gradients/ // http://dev.thi.ng/gradients/
use rand::prelude::*;
use rand::rngs::SmallRng;
use lazy_static::lazy_static;
#[derive(Clone, Copy, PartialEq, Debug)] #[derive(Clone, Copy, PartialEq, Debug)]
pub struct ColorPalette { pub struct ColorPalette {
a: [f64; 3], a: [f64; 3],
@ -31,9 +35,23 @@ impl ColorPalette {
self.get((2f64.powi(-(t.ilog2() as i32))) * (t as f64 + 0.5f64) - 1.0f64) self.get((2f64.powi(-(t.ilog2() as i32))) * (t as f64 + 0.5f64) - 1.0f64)
} }
pub fn get_uuid(&self, t: uuid::Uuid) -> [f64; 3] { // pub fn get_uuid(&self, t: uuid::Uuid) -> [f64; 3] {
log::debug!("{}", t.as_u128() as f64 / (u128::MAX) as f64); // // self.get(t.as_u128() as f64 / (u128::MAX) as f64)
self.get(t.as_u128() as f64 / (u128::MAX) as f64) // let mut r = SmallRng::seed_from_u64(t.as_u128() as u64);
// self.get(r.gen_range(0.0..1.0f64))
// }
pub fn get_ordered(&self, t: uuid::Uuid, ordered_uuids: &Vec<uuid::Uuid>)
-> [f64; 3] {
let index = ordered_uuids.iter().position(|&x| x == t).expect("uuid must be in list of uuids") + 1;
return self.get(Self::space_evenly(index))
}
fn space_evenly(x: usize) -> f64 {
let e: usize = (x.ilog2() + 1) as usize;
let d: usize = (2usize.pow(e as u32)) as usize;
let n: usize = (2*x + 1) % d;
return (n as f64) / (d as f64);
} }
} }
@ -54,4 +72,5 @@ impl Palettes {
c: [0.100, 0.500, 0.360], c: [0.100, 0.500, 0.360],
d: [0.000, 0.000, 0.650], d: [0.000, 0.000, 0.650],
}; };
} }

View File

@ -13,6 +13,17 @@ pub struct CurrentTransfer {
pub transfer: Transfer, pub transfer: Transfer,
} }
#[derive(PartialEq, Clone, Copy, Serialize, Deserialize)]
pub struct Preferences {
pub in_transfer_hashes: bool,
}
impl Default for Preferences {
fn default() -> Self {
Self { in_transfer_hashes: true }
}
}
#[derive(Default, PartialEq, Clone, Serialize, Deserialize)] #[derive(Default, PartialEq, Clone, Serialize, Deserialize)]
#[non_exhaustive] #[non_exhaustive]
pub struct MainState { pub struct MainState {
@ -22,6 +33,9 @@ pub struct MainState {
pub selected_source_plate: Uuid, pub selected_source_plate: Uuid,
pub selected_dest_plate: Uuid, pub selected_dest_plate: Uuid,
pub selected_transfer: Uuid, pub selected_transfer: Uuid,
#[serde(default)]
pub preferences: Preferences,
} }
impl Store for MainState { impl Store for MainState {

View File

@ -39,6 +39,9 @@ pub fn TransferMenu() -> Html {
let ct_dispatch = ct_dispatch.clone(); let ct_dispatch = ct_dispatch.clone();
Callback::from(move |e: Event| { Callback::from(move |e: Event| {
if matches!(ct_dispatch.get().transfer.transfer_region.source_region, Region::Custom(_)) {
return; // Do nothing here!
}
let target: Option<EventTarget> = e.target(); let target: Option<EventTarget> = e.target();
let input = target.and_then(|t| t.dyn_into::<HtmlInputElement>().ok()); let input = target.and_then(|t| t.dyn_into::<HtmlInputElement>().ok());
if let Some(input) = input { if let Some(input) = input {
@ -192,7 +195,7 @@ pub fn TransferMenu() -> Html {
let new_transfer = Transfer::new( let new_transfer = Transfer::new(
spi.clone(), spi.clone(),
dpi.clone(), dpi.clone(),
ct_state.transfer.transfer_region, ct_state.transfer.transfer_region.clone(),
ct_state.transfer.name.clone(), ct_state.transfer.name.clone(),
); );
main_dispatch.reduce_mut(|state| { main_dispatch.reduce_mut(|state| {
@ -250,6 +253,8 @@ pub fn TransferMenu() -> Html {
onchange={on_name_change} onchange={on_name_change}
value={ct_state.transfer.name.clone()}/> value={ct_state.transfer.name.clone()}/>
</div> </div>
// Anything below here is not rendered when a Custom transfer is selected
if !matches!(&ct_state.transfer.transfer_region.source_region, Region::Custom(_)) {
<div> <div>
<label for="src_region"><h3>{"Source Region:"}</h3></label> <label for="src_region"><h3>{"Source Region:"}</h3></label>
<input type="text" name="src_region" <input type="text" name="src_region"
@ -291,6 +296,7 @@ pub fn TransferMenu() -> Html {
onchange={on_volume_change} onchange={on_volume_change}
value={ct_state.transfer.volume.to_string()}/> value={ct_state.transfer.volume.to_string()}/>
</div> </div>
}
<div id="controls"> <div id="controls">
<input type="button" name="new_transfer" onclick={new_transfer_button_callback} <input type="button" name="new_transfer" onclick={new_transfer_button_callback}
value={"New"} /> value={"New"} />
@ -344,6 +350,37 @@ impl TryFrom<String> for RegionDisplay {
} }
} }
} }
impl TryFrom<&str> for RegionDisplay {
type Error = &'static str;
fn try_from(value: &str) -> Result<Self, Self::Error> {
lazy_static! {
static ref REGION_REGEX: Regex = Regex::new(r"([A-Z]+)(\d+):([A-Z]+)(\d+)").unwrap();
}
if let Some(captures) = REGION_REGEX.captures(&value) {
if captures.len() != 5 {
return Err("Not enough capture groups");
}
let col_start = letters_to_num(&captures[1]).ok_or("Column start failed to parse")?;
let col_end = letters_to_num(&captures[3]).ok_or("Column end failed to parse")?;
let row_start: u8 = captures[2]
.parse::<u8>()
.or(Err("Row start failed to parse"))?;
let row_end: u8 = captures[4]
.parse::<u8>()
.or(Err("Row end failed to parse"))?;
Ok(RegionDisplay {
text: value.to_string(),
col_start,
row_start,
col_end,
row_end,
})
} else {
Err("Regex match failed")
}
}
}
impl From<&Region> for RegionDisplay { impl From<&Region> for RegionDisplay {
fn from(value: &Region) -> Self { fn from(value: &Region) -> Self {
match *value { match *value {
@ -353,6 +390,7 @@ impl From<&Region> for RegionDisplay {
Region::Rect(c1, c2) => RegionDisplay::try_from((c1.0, c1.1, c2.0, c2.1)) Region::Rect(c1, c2) => RegionDisplay::try_from((c1.0, c1.1, c2.0, c2.1))
.ok() .ok()
.unwrap(), .unwrap(),
Region::Custom(_) => RegionDisplay { text: "CUSTOM".to_string(), col_start: 0, row_start: 0, col_end: 0, row_end: 0 }
} }
} }
} }
@ -385,9 +423,10 @@ impl TryFrom<(u8, u8, u8, u8)> for RegionDisplay {
}) })
} }
} }
fn letters_to_num(letters: &str) -> Option<u8> { pub fn letters_to_num(letters: &str) -> Option<u8> {
let mut num: u8 = 0; let mut num: u8 = 0;
for (i, letter) in letters.chars().rev().enumerate() { for (i, letter) in letters.to_ascii_uppercase().chars().rev().enumerate() {
log::debug!("{}, {}", i, letter);
let n = letter as u8; let n = letter as u8;
if !(65..=90).contains(&n) { if !(65..=90).contains(&n) {
return None; return None;

View File

@ -2,23 +2,23 @@ use crate::components::states::MainState;
use crate::components::transfer_menu::num_to_letters; use crate::components::transfer_menu::num_to_letters;
use crate::data::transfer::Transfer; use crate::data::transfer::Transfer;
use serde::Serialize; use serde::{Serialize, Deserialize};
use std::error::Error; use std::error::Error;
#[derive(Serialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
struct TransferRecord { pub struct TransferRecord {
#[serde(rename = "Source Plate Barcode")] #[serde(rename = "Source Plate")]
source_plate: String, pub source_plate: String,
#[serde(rename = "Source Well")] #[serde(rename = "Source Well")]
source_well: String, pub source_well: String,
#[serde(rename = "Destination Plate Barcode")] #[serde(rename = "Dest Plate")]
destination_plate: String, pub destination_plate: String,
#[serde(rename = "Destination Well")] #[serde(rename = "Destination Well")]
destination_well: String, pub destination_well: String,
#[serde(rename = "Volume")] #[serde(rename = "Transfer Volume")]
volume: f32, pub volume: f32,
#[serde(rename = "Concentration")] #[serde(rename = "Concentration")]
concentration: Option<f32>, pub concentration: Option<f32>,
} }
pub fn state_to_csv(state: &MainState) -> Result<String, Box<dyn Error>> { pub fn state_to_csv(state: &MainState) -> Result<String, Box<dyn Error>> {

View File

@ -5,6 +5,8 @@ use uuid::Uuid;
#[derive(PartialEq, Clone, Serialize, Deserialize)] #[derive(PartialEq, Clone, Serialize, Deserialize)]
pub struct PlateInstance { pub struct PlateInstance {
pub plate: Plate, pub plate: Plate,
#[serde(rename = "id_v7")]
#[serde(default = "Uuid::now_v7")]
id: Uuid, id: Uuid,
pub name: String, pub name: String,
} }
@ -16,7 +18,7 @@ impl PlateInstance {
plate_type: sort, plate_type: sort,
plate_format: format, plate_format: format,
}, },
id: Uuid::new_v4(), id: Uuid::now_v7(),
name, name,
} }
} }
@ -34,7 +36,7 @@ impl From<Plate> for PlateInstance {
fn from(value: Plate) -> Self { fn from(value: Plate) -> Self {
PlateInstance { PlateInstance {
plate: value, plate: value,
id: Uuid::new_v4(), id: Uuid::now_v7(),
name: "New Plate".to_string(), name: "New Plate".to_string(),
} }
} }

View File

@ -10,7 +10,9 @@ pub struct Transfer {
pub source_id: Uuid, pub source_id: Uuid,
pub dest_id: Uuid, pub dest_id: Uuid,
pub name: String, pub name: String,
id: Uuid, #[serde(rename = "id_v7")]
#[serde(default = "Uuid::now_v7")]
pub id: Uuid,
pub transfer_region: TransferRegion, pub transfer_region: TransferRegion,
#[serde(default = "default_volume")] #[serde(default = "default_volume")]
pub volume: f32, pub volume: f32,
@ -44,7 +46,7 @@ impl Transfer {
source_id: source.get_uuid(), source_id: source.get_uuid(),
dest_id: dest.get_uuid(), dest_id: dest.get_uuid(),
name, name,
id: Uuid::new_v4(), id: Uuid::now_v7(),
transfer_region: tr, transfer_region: tr,
volume: 2.5, volume: 2.5,
} }

View File

@ -1,11 +1,20 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::components::transfer_menu::RegionDisplay;
use super::plate::Plate; use super::plate::Plate;
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)] #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug)]
pub struct CustomRegion {
src: Vec<(u8, u8)>,
dest: Vec<(u8, u8)>,
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug)]
pub enum Region { pub enum Region {
Rect((u8, u8), (u8, u8)), Rect((u8, u8), (u8, u8)),
Point((u8, u8)), Point((u8, u8)),
Custom(CustomRegion),
} }
impl Default for Region { impl Default for Region {
fn default() -> Self { fn default() -> Self {
@ -23,8 +32,24 @@ impl TryFrom<Region> for ((u8, u8), (u8, u8)) {
} }
} }
} }
impl Region {
pub fn new_custom(transfers: &Vec<((u8, u8), (u8, u8))>) -> Self {
let mut src_pts: Vec<(u8, u8)> = Vec::with_capacity(transfers.len());
let mut dest_pts: Vec<(u8, u8)> = Vec::with_capacity(transfers.len());
#[derive(PartialEq, Eq, Clone, Copy, Serialize, Deserialize, Debug)] for transfer in transfers {
src_pts.push(transfer.0);
dest_pts.push(transfer.1);
}
Region::Custom(CustomRegion {
src: src_pts,
dest: dest_pts,
})
}
}
#[derive(PartialEq, Eq, Clone, Serialize, Deserialize, Debug)]
pub struct TransferRegion { pub struct TransferRegion {
pub source_plate: Plate, pub source_plate: Plate,
pub source_region: Region, // Even if it is just a point, we don't want corners. pub source_region: Region, // Even if it is just a point, we don't want corners.
@ -49,7 +74,7 @@ impl Default for TransferRegion {
impl TransferRegion { impl TransferRegion {
pub fn get_source_wells(&self) -> Vec<(u8, u8)> { pub fn get_source_wells(&self) -> Vec<(u8, u8)> {
match self.source_region { match &self.source_region {
Region::Rect(c1, c2) => { Region::Rect(c1, c2) => {
let mut wells = Vec::<(u8, u8)>::new(); let mut wells = Vec::<(u8, u8)>::new();
let (ul, br) = standardize_rectangle(&c1, &c2); let (ul, br) = standardize_rectangle(&c1, &c2);
@ -71,7 +96,8 @@ impl TransferRegion {
} }
wells wells
} }
Region::Point(p) => vec![p], Region::Point(p) => vec![*p],
Region::Custom(c) => c.src.clone(),
} }
} }
@ -112,6 +138,7 @@ impl TransferRegion {
let source_corners: ((u8, u8), (u8, u8)) = match self.source_region { let source_corners: ((u8, u8), (u8, u8)) = match self.source_region {
Region::Point((x, y)) => ((x, y), (x, y)), Region::Point((x, y)) => ((x, y), (x, y)),
Region::Rect(c1, c2) => (c1, c2), Region::Rect(c1, c2) => (c1, c2),
Region::Custom(_) => ((0, 0), (0, 0)),
}; };
let (source_ul, _) = standardize_rectangle(&source_corners.0, &source_corners.1); let (source_ul, _) = standardize_rectangle(&source_corners.0, &source_corners.1);
// This map is not necessarily injective or surjective, // This map is not necessarily injective or surjective,
@ -120,7 +147,7 @@ impl TransferRegion {
// and simple then we *will* have injectivity. // and simple then we *will* have injectivity.
// Non-replicate transfers: // Non-replicate transfers:
match self.dest_region { match &self.dest_region {
Region::Point((x, y)) => { Region::Point((x, y)) => {
Box::new(move |(i, j)| { Box::new(move |(i, j)| {
if source_wells.contains(&(i, j)) { if source_wells.contains(&(i, j)) {
@ -224,6 +251,22 @@ impl TransferRegion {
} }
}) })
} }
Region::Custom(c) => Box::new(move |(i, j)| {
let src = c.src.clone();
let dest = c.dest.clone();
let points: Vec<(u8, u8)> = src
.iter()
.enumerate()
.filter(|(_index, (x, y))| *x == i && *y == j)
.map(|(index, _)| dest[index])
.collect();
if points.is_empty() {
None
} else {
Some(points)
}
}),
} }
} }
@ -257,6 +300,7 @@ impl TransferRegion {
return Err("Source region is out-of-bounds! (Too wide)"); return Err("Source region is out-of-bounds! (Too wide)");
} }
} }
Region::Custom(_) => return Ok(()),
} }
if il_source.0 == 0 || il_dest.1 == 0 { if il_source.0 == 0 || il_dest.1 == 0 {