//! The reusable Dioxus components.
//!
//! [`Decomposition`] is the entry point: a method neutral (t-SNE, PCA, more to
//! come) visualizer configured through a fluent builder. Everything lives on a
//! single decomposer panel, the plot with a thin toolbar of controls on top, an
//! empty state that doubles as the drag and drop loader (with optional example
//! dataset buttons), and an automatic color legend.
use std::cell::RefCell;
use std::rc::Rc;
use crate::color::{ColorScale, Coloring, Marker, colorize};
use crate::ingest::{Dataset, LabelColumn};
use crate::messages::{DecompositionMethod, TsneParams, TsnePhase, WorkerRequest, WorkerResponse};
use crate::plot::{ScatterPlot, SelectionMode};
use crate::plot3d::{
Camera, HeldAxes, KEY_ROT_PER_TICK, KEY_TICK_MS, RotationAxis, ScatterPlot3D,
project_to_display, unproject_display_delta,
};
use crate::worker::DecompositionWorker;
use dioxus::html::HasFileData;
use dioxus::prelude::*;
use dioxus_free_icons::Icon;
use dioxus_free_icons::icons::fa_brands_icons::FaGithub;
use dioxus_free_icons::icons::fa_solid_icons::{
FaBan, FaBolt, FaCalculator, FaChevronLeft, FaCircleNodes, FaCircleQuestion, FaCompress,
FaCube, FaDatabase, FaDownload, FaExpand, FaFileArrowUp, FaFire, FaGaugeHigh, FaHashtag,
FaHeart, FaImage, FaPalette, FaPause, FaPlay, FaRepeat, FaRotateLeft, FaShareNodes, FaShirt,
FaSliders, FaSpinner, FaTable, FaTag, FaTriangleExclamation, FaVectorSquare, FaVideo, FaXmark,
};
use gloo_worker::{Spawnable, WorkerBridge};
use wasm_bindgen::JsCast;
use wasm_bindgen::closure::Closure;
/// Longest legend rendered before truncation.
const MAX_LEGEND_ENTRIES: usize = 20;
/// How long the Dimension toggle's error state (red border + inline message)
/// stays visible after the user asks to display a higher dimensionality
/// than the current embedding carries. Cleared automatically after this,
/// or reset by a fresh error before the timer elapses.
const DIM_ERROR_TIMEOUT_MS: u32 = 10_000;
/// "thread" or "threads", to read naturally next to a pool size in the status.
fn thread_word(threads: usize) -> &'static str {
if threads == 1 { "thread" } else { "threads" }
}
/// Plain-language explanation of a fitting phase, shown when the status line's
/// phase label is hovered.
fn phase_help(phase: TsnePhase) -> &'static str {
match phase {
TsnePhase::FindingNeighbors => {
"Building each point's nearest-neighbor graph in the original high-dimensional space, before the layout starts."
}
TsnePhase::EarlyExaggeration => {
"An opening phase that inflates the pull between neighbors so clusters separate before the layout is fine-tuned."
}
TsnePhase::Optimizing => {
"Refining the 2-D layout by gradient descent, balancing attraction to neighbors against repulsion from everything else."
}
}
}
/// Current viewport size in CSS pixels, the logical canvas size for the
/// full-bleed plot. Falls back to a sensible default when the window is
/// unavailable.
fn window_size() -> (u32, u32) {
web_sys::window()
.map(|window| {
let width = window
.inner_width()
.ok()
.and_then(|v| v.as_f64())
.unwrap_or(1024.0);
let height = window
.inner_height()
.ok()
.and_then(|v| v.as_f64())
.unwrap_or(768.0);
(width.max(1.0) as u32, height.max(1.0) as u32)
})
.unwrap_or((1024, 768))
}
/// Which section of the "About t-SNE" overlay is currently visible. Kept
/// as a small enum so the tab buttons switch on discriminant equality
/// rather than string comparisons.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AboutTab {
Overview,
Read,
Controls,
Rust,
}
/// Handle to the currently-mounted "About t-SNE" modal element, or `None`
/// if the overlay is closed or the DOM is unreachable. Used by the tab
/// switcher to freeze the modal's height for the FLIP-style height
/// transition between tab bodies of different intrinsic sizes.
fn about_modal_element() -> Option<web_sys::HtmlElement> {
web_sys::window()
.and_then(|w| w.document())
.and_then(|doc| doc.query_selector(".decompositions-about").ok().flatten())
.and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok())
}
/// The role a parsed column plays once the user has assigned it: a t-SNE input
/// feature, a label only used to color points, or dropped entirely.
#[derive(Clone, Copy, PartialEq, Eq)]
enum ColumnRole {
Feature,
Label,
Ignore,
}
/// Live numbers behind the status line, kept structured (not only as the
/// formatted string) so each part can show its own explanation on hover.
#[derive(Clone, Copy, PartialEq)]
enum RunInfo {
/// A snapshot mid-run.
Running {
epoch: usize,
elapsed_s: f64,
threads: usize,
},
/// The finished fit.
Done {
elapsed_s: f64,
threads: usize,
kl: Option<f32>,
},
}
/// Worksheets of the loaded file. Empty `names` (not a spreadsheet) hides the
/// sheet picker.
#[derive(Clone, PartialEq, Default)]
struct SheetState {
/// Worksheet names in file order.
names: Vec<String>,
/// Index into `names` of the worksheet shown.
active: usize,
}
/// Whether a file name is a spreadsheet (so it can carry multiple worksheets).
/// Mirrors the extensions `ingest::parse_file` routes to calamine.
fn is_spreadsheet_name(name: &str) -> bool {
name.rsplit('.')
.next()
.map(str::to_ascii_lowercase)
.is_some_and(|ext| matches!(ext.as_str(), "xlsx" | "ods" | "xls" | "xlsb"))
}
/// Where a column's values live in the parsed [`Dataset`]: either a column of
/// the numeric feature matrix, or one of the text label columns.
#[derive(Clone, Copy, PartialEq, Eq)]
enum ColumnSource {
/// Index into the row-major feature matrix (`feature_names[i]`).
Feature(usize),
/// Index into `label_columns`.
Label(usize),
}
/// A parsed column together with the role the user has assigned it. Numeric
/// columns default to `Feature`, text columns to `Label`.
#[derive(Clone, PartialEq)]
struct ColumnEntry {
name: String,
source: ColumnSource,
role: ColumnRole,
/// Numeric columns can be features; text columns can only label or be
/// ignored.
numeric: bool,
}
/// Builds the default column roles for a freshly parsed dataset: every numeric
/// column a feature, every text column a label, in matrix-then-label order.
fn default_columns(dataset: &Dataset) -> Vec<ColumnEntry> {
let mut entries = Vec::with_capacity(dataset.feature_names.len() + dataset.label_columns.len());
for (index, name) in dataset.feature_names.iter().enumerate() {
entries.push(ColumnEntry {
name: name.clone(),
source: ColumnSource::Feature(index),
role: ColumnRole::Feature,
numeric: true,
});
}
for (index, column) in dataset.label_columns.iter().enumerate() {
entries.push(ColumnEntry {
name: column.name.clone(),
source: ColumnSource::Label(index),
role: ColumnRole::Label,
numeric: false,
});
}
entries
}
/// Assembles the t-SNE input matrix from the columns the user marked as
/// features. Returns `None` when no feature column is selected.
fn build_feature_matrix(dataset: &Dataset, columns: &[ColumnEntry]) -> Option<(Vec<f32>, usize)> {
let feature_indices: Vec<usize> = columns
.iter()
.filter_map(|entry| match (entry.role, entry.source) {
(ColumnRole::Feature, ColumnSource::Feature(index)) => Some(index),
_ => None,
})
.collect();
if feature_indices.is_empty() {
return None;
}
let n_features = feature_indices.len();
let mut data = Vec::with_capacity(dataset.n_samples * n_features);
for row in 0..dataset.n_samples {
for &index in &feature_indices {
data.push(dataset.data[row * dataset.n_features + index]);
}
}
Some((data, n_features))
}
/// The label columns to color by given the current roles: text columns kept as
/// labels, plus any numeric column the user reassigned to a label (its values
/// stringified so the continuous color scale can parse them into a heatmap).
fn effective_label_columns(dataset: &Dataset, columns: &[ColumnEntry]) -> Vec<LabelColumn> {
let mut labels = Vec::new();
for entry in columns {
if entry.role != ColumnRole::Label {
continue;
}
match entry.source {
ColumnSource::Label(index) => {
if let Some(column) = dataset.label_columns.get(index) {
labels.push(column.clone());
}
}
ColumnSource::Feature(index) => {
let values = (0..dataset.n_samples)
.map(|row| dataset.data[row * dataset.n_features + index].to_string())
.collect();
labels.push(LabelColumn {
name: entry.name.clone(),
values,
});
}
}
}
labels
}
/// The size-scaled learning rate bhtsne resolves when none is set,
/// `max(n_samples / early_exaggeration / 4, 50)` with the standard 12x
/// exaggeration. Used only to preview the auto value in the control's
/// placeholder, the actual value is resolved inside the library.
fn auto_learning_rate(n_samples: usize) -> f32 {
(n_samples as f32 / 12.0 / 4.0).max(50.0)
}
// Plain language explanations shown as hover tooltips (`title`) and to screen
// readers (`aria-label`), so a newcomer can learn what each control does just by
const HELP_PERPLEXITY: &str = "Roughly how many close neighbors each point pays attention to. Smaller makes tight local clumps, larger spreads things out. 5 to 50 is typical.";
const HELP_DIMENSION: &str = "Output dimensionality of the embedding. 2 for a flat scatter plot, 3 for an interactive 3D plot with orbit controls, 4 for a 4D embedding shown as a rotatable 3D projection (hold W to spin into the 4th axis).";
const HELP_EPOCHS: &str = "How many refinement steps to run. More steps polish the layout further but take longer. 1000 is a good default, a few hundred is often enough.";
const HELP_LEARNING_RATE: &str = "How big each refinement step is. Too small and it gets stuck, too large and it looks chaotic. Leave it empty for 'auto', a value scaled to the dataset size (shown greyed in the box). 200 is a common manual value.";
const HELP_PCA_DIMS: &str = "Before t-SNE the data is first squeezed to this many dimensions with PCA to speed things up and cut noise. 30 by default, a typical range is 30 to 50, 2 or more.";
const HELP_COLUMNS: &str = "What each column does. Feature: fed into t-SNE. Label: kept out of t-SNE and offered as a color (numeric labels become a heatmap). Ignore: dropped entirely.";
const HELP_SHEET: &str = "Which worksheet of the spreadsheet to read. Switching re-parses that sheet, so its columns and coloring reset.";
const HELP_EARLY_EXAGGERATION: &str = "How hard clusters are pushed apart early in the run, before the layout settles. 12 is the standard value, 1 turns it off. Ignored when continuing a run.";
const HELP_EXAGGERATION_EPOCHS: &str = "How many epochs that early push lasts. 250 is typical. 0 turns early exaggeration off. Ignored when continuing a run.";
const HELP_RUN: &str =
"Start the layout from scratch and watch the scatter plot animate as it improves.";
const HELP_COLOR_BY: &str =
"Color the points by one of the dataset's label columns to see where each group lands.";
const HELP_SETTINGS: &str = "Advanced t-SNE settings.";
const HELP_RECORD: &str = "Record the scatter plot animation as a WebM video. Toggle while a run is active to capture the embedding evolving.";
const HELP_LEGEND_EXPORT: &str = "Bake the color legend into the downloaded snapshot. It gets its own strip and the points are framed beside it, so the legend never covers them.";
// Status-line tooltips: short explanations so a newcomer can learn the terms by
// hovering them while the fit runs.
const HELP_STATUS_EPOCH: &str = "An epoch is one full optimization pass over every point. t-SNE nudges the layout a little on each pass, so more epochs means a more settled map.";
const HELP_STATUS_DONE: &str = "The fit has finished: it used up the epoch budget (or you paused it). The scatter plot below is the result.";
const HELP_STATUS_THREADS: &str = "How many CPU cores the fit is using at once. It runs on a SharedArrayBuffer thread pool right in your browser, with no server involved.";
const HELP_STATUS_ELAPSED: &str = "Wall-clock time spent fitting so far.";
const HELP_STATUS_KL: &str = "Kullback-Leibler divergence: how faithfully the 2-D map preserves the original high-dimensional neighborhoods. It is the quantity t-SNE minimizes, so lower is a better fit.";
const HELP_STATUS_LOADING: &str = "Reading and parsing your file off the main thread.";
/// File input `accept` attribute.
const DATA_ACCEPT: &str = ".csv,.tsv,.parquet,.arrow,.feather,.npy,.xlsx,.ods,.xls,.xlsb";
/// Default URL the worker loader module is served from, the path the reference
/// `build.rs` writes it to (`public/dioxus-decompositions/loader.js`, served at
/// the site root). [`Decomposition::new`] uses this unless overridden with
/// [`Decomposition::worker_url`].
pub const DEFAULT_WORKER_URL: &str = "/dioxus-decompositions/loader.js";
/// Spawns a worker bridge whose callback writes replies into `status` and
/// `embedding`. Kept as a free function so a fresh bridge can be spawned on
/// demand (the pause feature orphans the running worker and installs a new
/// one). The `worker_url` points at the loader module, see the crate docs.
#[allow(clippy::too_many_arguments)]
fn spawn_bridge(
worker_url: &str,
status: Signal<String>,
phase: Signal<Option<TsnePhase>>,
embedding: Signal<Option<Vec<f32>>>,
dataset: Signal<Option<Dataset>>,
ingest_error: Signal<Option<String>>,
color_source: Signal<String>,
run_info: Signal<Option<RunInfo>>,
sheet_state: Signal<SheetState>,
svg_exporting: Signal<bool>,
svg_export_fraction: Signal<f32>,
svg_export_data: Signal<Option<String>>,
) -> WorkerBridge<DecompositionWorker> {
DecompositionWorker::spawner()
// The URL points at a loader module that initializes the wasm-bindgen
// output itself, see the crate documentation.
.with_loader(true)
.callback(move |response| {
// Signals are Copy, the rebindings make the closure a plain Fn.
let mut status = status;
let mut phase = phase;
let mut embedding = embedding;
let mut dataset = dataset;
let mut ingest_error = ingest_error;
let mut color_source = color_source;
let mut run_info = run_info;
let mut sheet_state = sheet_state;
let mut svg_exporting = svg_exporting;
let mut svg_export_fraction = svg_export_fraction;
let mut svg_export_data = svg_export_data;
match response {
WorkerResponse::Loaded {
dataset: parsed,
sheets,
sheet,
} => {
// Parsing happened off the main thread; adopt the result,
// coloring by the first label column.
ingest_error.set(None);
sheet_state.set(SheetState {
names: sheets,
active: sheet,
});
color_source.set(
parsed
.label_columns
.first()
.map(|c| format!("column:{}", c.name))
.unwrap_or_else(|| String::from("none")),
);
if status.read().as_str() == "loading" {
status.set(String::from("idle"));
}
dataset.set(Some(parsed));
}
WorkerResponse::LoadError { message } => {
// Keep `sheet_state` as is: if a sheet switch failed (a sheet
// with no numeric column), the picker must survive so the
// user can pick another. `load` clears it for a new file.
dataset.set(None);
status.set(String::from("idle"));
ingest_error.set(Some(message));
}
WorkerResponse::Phase { phase: current } => {
phase.set(Some(current));
}
WorkerResponse::Snapshot {
epoch,
embedding: snapshot,
phase: current,
elapsed_ms,
threads,
} => {
phase.set(Some(current));
status.set(format!(
"epoch {epoch} ({:.1}s, {threads} {})",
elapsed_ms / 1000.0,
thread_word(threads)
));
run_info.set(Some(RunInfo::Running {
epoch,
elapsed_s: elapsed_ms / 1000.0,
threads,
}));
embedding.set(Some(snapshot));
}
WorkerResponse::Done {
embedding: done,
kl_divergence,
elapsed_ms,
threads,
} => {
let seconds = elapsed_ms / 1000.0;
let pool = format!("{threads} {}", thread_word(threads));
status.set(match kl_divergence {
Some(kl) => format!("done in {seconds:.1}s ({pool}), KL {kl:.4}"),
None => format!("done in {seconds:.1}s ({pool})"),
});
run_info.set(Some(RunInfo::Done {
elapsed_s: seconds,
threads,
kl: kl_divergence,
}));
embedding.set(Some(done));
}
WorkerResponse::Error { message } => {
status.set(format!("error: {message}"));
run_info.set(None);
}
WorkerResponse::SvgProgress { fraction } => {
svg_exporting.set(true);
svg_export_fraction.set(fraction);
}
WorkerResponse::SvgReady { svg } => {
svg_export_data.set(Some(svg));
svg_exporting.set(false);
}
}
})
.spawn(worker_url)
}
/// An optional glyph shown on an example button, hinting at the dataset's
/// domain. Kept as a small enum (rather than a concrete icon type) so
/// [`ExampleDataset`] stays `Clone`/`PartialEq` for the props system.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExampleIcon {
/// Numbers / digits (e.g. MNIST).
Numbers,
/// Apparel / clothing (e.g. Fashion-MNIST).
Apparel,
/// A graph or citation network (e.g. Cora).
Network,
}
/// A bundled example dataset offered through a one click load button.
#[derive(Debug, Clone, PartialEq)]
pub struct ExampleDataset {
/// Human readable name shown on the button.
pub name: String,
/// URL the file is served from, any supported format.
pub url: String,
/// Optional glyph shown before the name.
pub icon: Option<ExampleIcon>,
/// Optional plain-language description shown on hover (tooltip and aria
/// label). Falls back to a generic "load" message when absent.
pub description: Option<String>,
}
/// Configuration of the drag and drop loader shown over the empty plot, built
/// fluently.
///
/// ```
/// use dioxus_tsne::DropZone;
///
/// let zone = DropZone::new()
/// .accept(["csv", "parquet"])
/// .prompt("Drop a dataset to begin");
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct DropZone {
/// Lowercase extensions (without the dot) accepted on drop. Empty accepts
/// anything and lets the format sniffing decide.
accept: Vec<String>,
/// Text inviting a drop, shown over the empty plot.
prompt: String,
}
impl Default for DropZone {
fn default() -> Self {
Self {
accept: [
"csv", "tsv", "parquet", "arrow", "feather", "npy", "xlsx", "ods", "xls", "xlsb",
]
.into_iter()
.map(String::from)
.collect(),
prompt: String::from(
"Drop a CSV, TSV, Parquet, Arrow, NumPy (.npy) or Excel/ODS file here, or click to browse",
),
}
}
}
impl DropZone {
/// A loader accepting CSV, TSV and Parquet with a default prompt.
pub fn new() -> Self {
Self::default()
}
/// Restricts the accepted file extensions (leading dots and case are
/// ignored). An empty list accepts anything.
pub fn accept<I, S>(mut self, extensions: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.accept = extensions
.into_iter()
.map(|ext| ext.into().trim_start_matches('.').to_ascii_lowercase())
.collect();
self
}
/// Sets the invitation text shown on the empty plot.
pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
self.prompt = prompt.into();
self
}
/// True when `extension` (without a dot) is accepted.
fn allows(&self, extension: &str) -> bool {
self.accept.is_empty() || self.accept.iter().any(|a| a == extension)
}
}
/// A method neutral decomposition visualizer, configured with a fluent builder.
///
/// `Decomposition::new().render()` is a bare scatter plot driven by the worker.
/// Chain the builder methods to opt into the loader, example buttons, controls
/// and draggable points, then call [`render`](Self::render) to get the
/// [`Element`]. Loaded data is colored automatically by its first label column,
/// with a legend over the plot.
///
/// ```ignore
/// use dioxus_tsne::{Decomposition, ExampleDataset};
///
/// Decomposition::new()
/// .drop_zone()
/// .examples(vec![ExampleDataset { name: "MNIST".into(), url: "/mnist.parquet".into(), icon: None, description: None }])
/// .controls()
/// .draggable_points()
/// .render()
/// # ;
/// ```
///
/// The worker is loaded from [`DEFAULT_WORKER_URL`] by default, the path the
/// reference `build.rs` writes the loader to when the app is served at the site
/// root. Override it with [`worker_url`](Self::worker_url) for a custom output
/// path or a site served under a subpath.
#[derive(Debug, Clone, PartialEq)]
pub struct Decomposition {
worker_url: String,
dataset: Option<Dataset>,
drop_zone: Option<DropZone>,
examples: Vec<ExampleDataset>,
controls: bool,
draggable: bool,
styled: bool,
pixel_ratio: Option<f64>,
logo: Option<String>,
repo_url: Option<String>,
support_url: Option<String>,
}
impl Default for Decomposition {
fn default() -> Self {
Self::new()
}
}
impl Decomposition {
/// A bare plot driven by the worker at [`DEFAULT_WORKER_URL`], every extra
/// feature off, the default stylesheet on.
pub fn new() -> Self {
Self {
worker_url: String::from(DEFAULT_WORKER_URL),
dataset: None,
drop_zone: None,
examples: Vec::new(),
controls: false,
draggable: false,
styled: true,
pixel_ratio: None,
logo: None,
repo_url: None,
support_url: None,
}
}
/// Sets the brand logo shown in the top-left corner (an image URL, e.g. a
/// bundled asset). Without it the top bar shows the plain title text.
pub fn logo(mut self, url: impl Into<String>) -> Self {
self.logo = Some(url.into());
self
}
/// Adds a GitHub button to the top bar linking to the project `url` (opened
/// in a new tab). Omitted when unset.
pub fn repository(mut self, url: impl Into<String>) -> Self {
self.repo_url = Some(url.into());
self
}
/// Adds a heart button to the top bar linking to a support or sponsor page
/// at `url` (opened in a new tab). Omitted when unset.
pub fn support(mut self, url: impl Into<String>) -> Self {
self.support_url = Some(url.into());
self
}
/// Overrides the plot's backing buffer resolution multiplier (over its
/// logical size) for crisp rendering. Defaults to the device pixel ratio,
/// raise it to supersample further (e.g. `2.0` on a standard-DPI display).
/// Clamped to `[1, 4]`.
pub fn pixel_ratio(mut self, ratio: f64) -> Self {
self.pixel_ratio = Some(ratio);
self
}
/// Overrides where the worker loader module is served from (default
/// [`DEFAULT_WORKER_URL`]). Set this if your `build.rs` writes the worker
/// elsewhere or your site is served under a subpath.
pub fn worker_url(mut self, url: impl Into<String>) -> Self {
self.worker_url = url.into();
self
}
/// Preloads an in-memory dataset to decompose, a row major `n_samples *
/// n_features` matrix, so the component starts ready to run without the
/// user loading a file. Attach columns to color by with
/// [`labels`](Self::labels). Replaces any previously set dataset.
pub fn dataset(mut self, data: Vec<f32>, n_samples: usize, n_features: usize) -> Self {
self.dataset = Some(Dataset {
data,
n_samples,
n_features,
feature_names: Vec::new(),
label_columns: Vec::new(),
});
self
}
/// Adds a column of per point labels to color the preloaded
/// [`dataset`](Self::dataset) by (call after it). The labels are colorized
/// like any label column: a palette and markers per class with a legend, or
/// the viridis scale for numeric values. The first column added is the one
/// colored by default. No op if no dataset has been set.
pub fn labels(mut self, name: impl Into<String>, values: Vec<String>) -> Self {
if let Some(dataset) = self.dataset.as_mut() {
dataset.label_columns.push(LabelColumn {
name: name.into(),
values,
});
}
self
}
/// The loader, controls and draggable points all switched on (no example
/// datasets, add them with [`examples`](Self::examples)).
pub fn full() -> Self {
Self::new().drop_zone().controls().draggable_points()
}
/// Makes the empty plot a drag and drop (and click to browse) loader with
/// the default [`DropZone`].
pub fn drop_zone(mut self) -> Self {
self.drop_zone = Some(DropZone::default());
self
}
/// Makes the empty plot a loader configured by `zone`.
pub fn drop_zone_with(mut self, zone: DropZone) -> Self {
self.drop_zone = Some(zone);
self
}
/// Adds one click load buttons for the given bundled datasets, shown on the
/// empty plot beneath the drop prompt.
pub fn examples(mut self, examples: Vec<ExampleDataset>) -> Self {
self.examples = examples;
self
}
/// Shows the toolbar on the plot: the method selector, run and continue
/// buttons, the color by selector, the advanced settings and the status.
pub fn controls(mut self) -> Self {
self.controls = true;
self
}
/// Lets the user grab points: dragging during a run pauses it and resuming
/// continues from the edited layout.
pub fn draggable_points(mut self) -> Self {
self.draggable = true;
self
}
/// Whether to inject the default stylesheet (also exported as
/// [`crate::DEFAULT_STYLE`]). On by default, disable it to bring your own
/// rules for the `decompositions-*` class names.
pub fn styled(mut self, styled: bool) -> Self {
self.styled = styled;
self
}
/// Renders the configured visualizer.
pub fn render(self) -> Element {
rsx! {
DecompositionView { config: self }
}
}
}
/// The component behind [`Decomposition::render`]. Holds all the state and
/// renders only the pieces enabled in the config.
// Dioxus RSX `"{var}"` expands to `format!("{}", var)` in the macro output;
// clippy sees the pre-expansion form and flags it as useless.
#[allow(clippy::useless_format)]
#[component]
fn DecompositionView(config: Decomposition) -> Element {
let Decomposition {
worker_url,
dataset: preset_dataset,
drop_zone,
examples,
controls,
draggable,
styled,
pixel_ratio,
logo,
repo_url,
support_url,
} = config;
// A preloaded dataset starts colored by its first label column.
let initial_color_source = preset_dataset
.as_ref()
.and_then(|d| d.label_columns.first())
.map(|c| format!("column:{}", c.name))
.unwrap_or_else(|| String::from("none"));
let dataset = use_signal(move || preset_dataset);
let ingest_error = use_signal(|| None::<String>);
// Worksheets of the loaded file, set from the worker's Loaded response.
// Drives the sheet picker in settings.
let sheet_state = use_signal(SheetState::default);
// Bytes and name of the loaded spreadsheet, retained so a sheet switch can
// re-parse without a re-upload. `None` for non-spreadsheet files.
let workbook_file = use_signal(|| None::<(String, Vec<u8>)>);
let status = use_signal(|| String::from("idle"));
let phase = use_signal(|| None::<TsnePhase>);
let embedding = use_signal(|| None::<Vec<f32>>);
// Structured numbers for the status line, set alongside `status` from the
// worker responses (see `spawn_bridge`).
let run_info = use_signal(|| None::<RunInfo>);
// A new run (file parsing or the affinity setup) clears any prior run's
// numbers so the status line never shows stale info during it. The first
// snapshot then sets fresh Running numbers.
use_effect(move || {
if matches!(status.read().as_str(), "running" | "loading") {
let mut run_info = run_info;
run_info.set(None);
}
});
// Canvas size, tracking the viewport so the plot fills the page and refits
// on resize (a window resize listener is installed below).
let viewport = use_signal(window_size);
let defaults = TsneParams::default();
let mut pca_dims = use_signal(|| defaults.pca_dims);
let mut perplexity = use_signal(|| defaults.perplexity);
let mut dimension = use_signal(|| defaults.dimension);
// Display dimensionality: what the plot renders as, independent of
// `dimension` (which is the target for the *next* run). The toggle
// synchronizes both; the digit keys `2`/`3`/`4` only steer this one, so
// the user can peek at a lower-D projection of a higher-D embedding
// without disturbing the fit. Kicks off at the same default and stays
// in lock-step until the user chooses otherwise.
let mut display_dim = use_signal(|| defaults.dimension);
// Transient error banner shown on the toggle whenever the requested
// display exceeds the current embedding's row width (e.g. asking for
// 4-D of a 3-D run). Cleared after `DIM_ERROR_TIMEOUT_MS`.
let mut dim_error = use_signal(|| None::<String>);
// Generation counter so a fresh error resets the auto-clear window
// instead of the previous timer clearing the newer message early.
let dim_error_gen = use_signal(|| 0u64);
let mut epochs = use_signal(|| defaults.epochs);
// Bake the color legend into the downloaded snapshot (a reserved strip).
let mut legend_in_export = use_signal(|| false);
// True when the transport bar shows the download panel (PNG, SVG, CSV).
let showing_data_download = use_signal(|| false);
// SVG export state: driven by the worker callback, consumed by the progress
// bar and a download effect.
let svg_exporting = use_signal(|| false);
let svg_export_fraction = use_signal(|| 0.0f32);
let svg_export_data = use_signal(|| None::<String>);
let mut learning_rate = use_signal(|| defaults.learning_rate);
let mut early_exaggeration = use_signal(|| defaults.early_exaggeration);
let mut exaggeration_epochs = use_signal(|| defaults.early_exaggeration_epochs);
let mut color_source = use_signal(|| initial_color_source);
let mut color_select = use_signal(|| None::<web_sys::HtmlSelectElement>);
let mut dragging_over = use_signal(|| false);
// Per-column roles (feature / label / ignore), reset to type-based defaults
// whenever a new dataset is parsed (see the effect below).
let mut columns = use_signal(Vec::<ColumnEntry>::new);
// Reset the column roles to defaults each time the dataset changes.
use_effect(move || {
let defaults = dataset
.read()
.as_ref()
.map(default_columns)
.unwrap_or_default();
columns.set(defaults);
});
// The label columns to color by under the current roles (text labels plus
// any numeric column reassigned to a heatmap).
let effective_labels = use_memo(move || -> Vec<LabelColumn> {
dataset
.read()
.as_ref()
.map(|d| effective_label_columns(d, &columns.read()))
.unwrap_or_default()
});
// Builds the decomposition method from the current controls, shared by the
// Run button, the auto-run on loading a dataset, and Continue.
let build_method = move || -> DecompositionMethod {
DecompositionMethod::Tsne(TsneParams {
perplexity: perplexity(),
epochs: epochs(),
learning_rate: learning_rate(),
pca_dims: pca_dims(),
dimension: dimension(),
early_exaggeration: early_exaggeration(),
early_exaggeration_epochs: exaggeration_epochs(),
..TsneParams::default()
})
};
// The browser snaps a select back to its first option when the bound value
// is applied before the matching option exists, which happens when loading a
// dataset sets both the label column options and the source in one update.
// Re-syncing the DOM value after renders keeps the displayed value honest.
use_effect(move || {
let source = color_source.read().clone();
let _ = dataset.read();
if let Some(select) = color_select() {
select.set_value(&source);
}
});
// Row width of the currently loaded embedding, derived from its total
// length and the loaded dataset's row count. `None` until both are set
// in agreement. The memo compares by value, so successive snapshots of
// the same run do not re-fire the display-dim auto-sync below.
let embedding_row_dim = use_memo(move || -> Option<usize> {
let emb = embedding.read();
let ds = dataset.read();
let points = emb.as_ref()?;
let ds = ds.as_ref()?;
if ds.n_samples == 0 || points.is_empty() || points.len() % ds.n_samples != 0 {
return None;
}
Some(points.len() / ds.n_samples)
});
// Snap the display dimensionality to whatever the fresh embedding
// actually carries. Only re-fires when the row width changes, so a
// user's keyboard-driven preview (`2`/`3`/`4` while running) survives
// subsequent snapshots at the same row width.
use_effect(move || {
if let Some(dim) = embedding_row_dim() {
display_dim.set(dim);
// A fresh embedding invalidates any stale "cannot display Nd"
// message: either the new run answers the previous ask, or the
// user gets a clean starting slate.
dim_error.set(None);
}
});
// Point set actually handed to the plot: the raw embedding truncated to
// `display_dim` per row (row width unchanged when `display_dim` matches
// the embedding, or the first `display_dim` coordinates kept when it is
// smaller). Returns `None` when the embedding is missing, the row width
// is unknown, or the requested display exceeds what the embedding
// carries (the toggle refuses that combination through
// `try_set_display_dim`, but the plot must degrade gracefully if the
// effect races ahead of the guard).
// Shared camera state (rotation, zoom, pan) owned by the parent so
// the rotation the user set up in 3-D mode remains visible when the
// display drops to 2-D, and vice versa. `ScatterPlot3D` writes into
// this signal from its drag / key handlers; the 2-D and 3-D
// projection memos read from it.
let camera = use_signal(Camera::default);
// Rotation-key state: which of `X`/`Y`/`Z`/`W` is currently held. The
// ticker below reads it and advances the camera in place, so the
// rotation stays live in 2-D display too (the 2-D scatter has no
// focusable canvas of its own to hang key handlers off).
let held_axes = use_signal(HeldAxes::default);
// 60 Hz key-driven rotation ticker. Runs for the lifetime of the
// decomposition view, so pressing `X`/`Y`/`Z`/`W` rotates the camera
// regardless of which scatter is mounted, and the projection follows.
use_hook(move || {
spawn(async move {
let mut camera = camera;
let held_axes = held_axes;
loop {
gloo_timers::future::TimeoutFuture::new(KEY_TICK_MS).await;
let axes = held_axes();
if !axes.any() {
continue;
}
let mut cam = camera();
if axes.x {
cam.rot_x += KEY_ROT_PER_TICK;
}
if axes.y {
cam.rot_y += KEY_ROT_PER_TICK;
}
if axes.z {
cam.rot_z += KEY_ROT_PER_TICK;
}
if axes.w {
cam.rot_w += KEY_ROT_PER_TICK;
}
camera.set(cam);
}
});
});
let display_embedding: ReadSignal<Option<Vec<f32>>> = use_memo(move || -> Option<Vec<f32>> {
let dd = display_dim();
let ed = embedding_row_dim()?;
if dd > ed {
return None;
}
let cam = camera();
let emb = embedding.read();
let points = emb.as_ref()?;
project_to_display(points, ed, dd, cam)
})
.into();
// Attempts to change the display dimensionality. On success the toggle
// and plot re-render at the new dim. On failure (`requested > embedding
// row width`) it flashes the toggle red and posts an error banner that
// clears itself after `DIM_ERROR_TIMEOUT_MS`. Wrapping in `Rc` so both
// the toggle click handlers and the global keydown handler can call it
// without cloning the state closures separately.
let try_set_display_dim: Rc<dyn Fn(usize)> = Rc::new(move |requested: usize| {
if !(2..=4).contains(&requested) {
return;
}
// Signals are `Copy` handles, so rebinding them here as mutable
// locals gives us `.set` access from inside the `Fn` closure
// without demanding `FnMut`.
let mut display_dim = display_dim;
let mut dim_error = dim_error;
let mut dim_error_gen = dim_error_gen;
let allow = match embedding_row_dim() {
Some(ed) => requested <= ed,
None => true,
};
if allow {
display_dim.set(requested);
dim_error.set(None);
return;
}
let current = embedding_row_dim().unwrap_or(0);
let generation = dim_error_gen() + 1;
dim_error_gen.set(generation);
dim_error.set(Some(format!(
"Cannot display {requested}D: the current embedding is {current}D. Run a new {requested}D fit first."
)));
spawn(async move {
gloo_timers::future::TimeoutFuture::new(DIM_ERROR_TIMEOUT_MS).await;
// Clear only if no newer error has replaced this one.
if dim_error_gen() == generation {
dim_error.set(None);
}
});
});
// The active coloring, recomputed on source or dataset changes. Recoloring
// is a pure redraw, the embedding is never recomputed.
let coloring_result = use_memo(move || -> Option<Coloring> {
let source = color_source.read().clone();
let n_samples = dataset.read().as_ref()?.n_samples;
let labels = effective_labels.read();
let column = labels
.iter()
.find(|c| c.name == source.strip_prefix("column:").unwrap_or(&source))?;
if column.values.len() != n_samples {
return None;
}
Some(colorize(&column.values))
});
let colors = use_memo(move || coloring_result.read().as_ref().map(|c| c.colors.clone()));
let markers = use_memo(move || coloring_result.read().as_ref().map(|c| c.markers.clone()));
// Legend focus: hovering a class dims every other class to grey
// temporarily, clicking a class pins that dimming on until it is clicked
// again or a click lands outside the legend. Hover takes precedence over the
// pin while it lasts. Only categorical colorings have per-class entries to
// focus, the continuous scale's legend is just its extremes.
let legend_hovered = use_signal(|| None::<usize>);
let legend_pinned = use_signal(|| None::<usize>);
let highlight = use_memo(move || -> Option<(String, Marker)> {
let coloring = coloring_result.read();
let coloring = coloring.as_ref()?;
if coloring.scale != ColorScale::Categorical {
return None;
}
let index = legend_hovered().or(legend_pinned())?;
coloring
.legend
.get(index)
.map(|e| (e.color.clone(), e.marker))
});
// The worker is busy while parsing ("loading") or running.
let busy = use_memo(move || {
let status = status.read();
matches!(status.as_str(), "running" | "loading") || status.starts_with("epoch ")
});
// Text for the spinner shown over the empty plot before the first embedding
// streams back. It must track the phase too: a fresh run has no embedding
// through both parsing and the neighbor search, so a hardcoded "Loading the
// dataset" would mislabel the neighbor search.
let loading_label = use_memo(move || {
match (status.read().as_str(), *phase.read()) {
("loading", _) => "Loading the dataset",
(_, Some(TsnePhase::FindingNeighbors)) => "Finding neighbors",
_ => "Working",
}
.to_string()
});
// Compute progress for the loading bar: `(indeterminate, fraction)`, or
// None when hidden. "loading" (parsing in the worker) and "running" (the
// affinity setup) have no sub-progress (indeterminate); "epoch N" is
// determinate against the epoch budget. Everything else (idle, done,
// paused, error) hides the bar.
let progress = use_memo(move || -> Option<(bool, f32)> {
// SVG export progress takes priority: shows a determinate bar while
// the worker builds the SVG string.
if svg_exporting() {
Some((false, svg_export_fraction()))
} else if matches!(status.read().as_str(), "running" | "loading") {
Some((true, 0.0))
} else if let Some(rest) = status.read().strip_prefix("epoch ") {
let total = epochs().max(1) as f32;
let epoch = rest
.split_whitespace()
.next()
.and_then(|token| token.parse::<f32>().ok())
.unwrap_or(0.0);
Some((false, (epoch / total).clamp(0.0, 1.0)))
} else {
None
}
});
// Download the SVG when the worker finishes building it. The effect fires
// when `svg_export_data` transitions from `None` to `Some`, creates a blob
// URL, triggers the download, and clears the signal so the effect is ready
// for the next export.
let mut export_data_for_download = svg_export_data;
use_effect(move || {
let Some(svg) = export_data_for_download.read().clone() else {
return;
};
export_data_for_download.set(None);
if let Some(window) = web_sys::window() {
let bytes = js_sys::Uint8Array::from(svg.as_bytes());
let options = web_sys::BlobPropertyBag::new();
options.set_type("image/svg+xml");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(
&js_sys::Array::of1(&bytes.into()),
&options,
);
if let Ok(blob) = blob
&& let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob)
&& let Some(document) = window.document()
&& let Ok(anchor_el) = document.create_element("a")
&& let Ok(anchor) = anchor_el.dyn_into::<web_sys::HtmlAnchorElement>()
{
anchor.set_href(&url);
anchor.set_download("tsne.svg");
anchor.click();
let _ = web_sys::Url::revoke_object_url(&url);
}
}
});
let can_drag = use_memo(move || embedding.read().is_some());
// Set true when a grab paused a running fit, so releasing resumes it.
let mut resume_pending = use_signal(|| false);
// Set true when a user has dragged a point since the last run started (or
// since Clear). Drives the orange warning banner and the green invitation
// pulse on the play button. Cleared automatically the moment a fit resumes
// so the callout goes away as soon as the optimizer is running over the new
// arrangement, or when the panel is cleared.
let mut embedding_modified = use_signal(|| false);
// True for ~900ms the moment the banner first appears; the transport row
// picks up the "flashing" class and washes warm orange once. Kicked from
// `on_drag_end` only on the clean->modified transition, so a second drop
// while the banner is already up does not retrigger the flash.
let mut flash_pulse = use_signal(|| false);
use_effect(move || {
if busy() {
embedding_modified.set(false);
}
});
// Current 2D rubber-band selection: sorted, deduped point indices. An
// empty vec means "no selection", which the plot renders with every point
// at full color and disables the group drag. Cleared automatically when
// the embedding is dropped (Clear or a new dataset). It survives a Play
// -> run cycle so the user can iteratively nudge the same group and let
// the optimizer catch up in between.
let mut selection = use_signal(Vec::<usize>::new);
use_effect(move || {
if embedding.read().is_none() && !selection.read().is_empty() {
selection.set(Vec::new());
}
});
// Live modifier state driving the plot cursor hint: `Add` while Shift is
// held, `Subtract` while Alt is held (Shift wins over Alt so `Shift+Alt`
// is unambiguous), `Replace` otherwise. Updated on every keydown/keyup
// and reset on window blur so a focus change never leaves the cursor
// stuck in an inflated state.
let mut select_mode_hint = use_signal(|| SelectionMode::Replace);
// The bridge owns the worker and must live across renders. It is held behind
// a RefCell so the running worker can be orphaned and replaced by a fresh one
// when a grab pauses the fit (the orphaned worker self-closes after its
// current run finishes).
let bridge = use_hook(|| {
Rc::new(RefCell::new(spawn_bridge(
&worker_url,
status,
phase,
embedding,
dataset,
ingest_error,
color_source,
run_info,
sheet_state,
svg_exporting,
svg_export_fraction,
svg_export_data,
)))
});
// WebM recording of the embedding animation through MediaRecorder. Arming
// the checkbox only marks intent: capture begins at the first epoch (so the
// loading and affinity-setup frames are skipped) and stops automatically on
// convergence, leaving a finished video to download. The recorder and the
// collected blob chunks live behind RefCell so the event closures can mutate
// them.
let recording_armed = use_signal(|| false);
let recording_active = use_signal(|| false);
let recorded_url = use_signal(|| None::<String>);
let recorder = use_hook(|| Rc::new(RefCell::new(None::<web_sys::MediaRecorder>)));
let recording_chunks = use_hook(|| Rc::new(RefCell::new(Vec::<wasm_bindgen::JsValue>::new())));
// Check browser support once.
let recording_supported = use_memo(|| {
web_sys::MediaRecorder::is_type_supported("video/webm;codecs=vp9")
|| web_sys::MediaRecorder::is_type_supported("video/webm")
});
// Settings sidebar open state, toggled by the gear button. A document level
// pointerdown listener closes it on any press outside both the sidebar and
// the gear (the gear is excluded so its own toggle is not immediately
// undone by this listener firing on the same press).
let settings_open = use_signal(|| false);
// In-app "About t-SNE" overlay, opened by the help button.
let about_open = use_signal(|| false);
let about_tab = use_signal(|| AboutTab::Overview);
// Holds the modal's pixel height captured the instant a tab switch is
// requested. The onclick handler freezes the modal at that height via
// inline `style`, then the effect below reads the new content's natural
// height once Dioxus has committed the new tab and animates between the
// two. Consumed with `.take()` so a run does not re-fire on the next
// unrelated re-render.
let mut pending_about_start = use_signal(|| None::<f64>);
use_effect(move || {
// Subscribe to tab changes.
let _ = about_tab();
let start = pending_about_start.write().take();
let Some(start) = start else {
return;
};
let Some(modal) = about_modal_element() else {
return;
};
// Height is currently pinned at `start` via the onclick freeze. Clear
// it to measure the natural height Dioxus just committed.
let style = modal.style();
let _ = style.remove_property("height");
let _ = style.remove_property("transition");
let end = f64::from(modal.offset_height());
if (end - start).abs() < 1.0 {
return;
}
// Snap back to `start` with transitions disabled, force a reflow, then
// enable the transition and set the target height so the interpolation
// begins from the frozen value rather than snapping to `end` first.
let _ = style.set_property("transition", "none");
let _ = style.set_property("height", &format!("{start}px"));
// The read forces a synchronous layout so the "none" transition and
// the pinned start height are applied before the next declaration
// switches the transition back on.
let _ = modal.offset_height();
let _ = style.set_property("transition", "height 240ms cubic-bezier(0.2, 0.7, 0.3, 1)");
let _ = style.set_property("height", &format!("{end}px"));
// Clear the inline overrides once the transition has run so the modal
// returns to natural sizing (and honours future content changes such
// as a viewport resize).
let modal_clone = modal.clone();
spawn(async move {
gloo_timers::future::TimeoutFuture::new(280).await;
let style = modal_clone.style();
let _ = style.remove_property("height");
let _ = style.remove_property("transition");
});
});
use_hook(move || {
let Some(document) = web_sys::window().and_then(|window| window.document()) else {
return;
};
let closure = Closure::wrap(Box::new(move |event: web_sys::Event| {
if !settings_open() {
return;
}
let inside = event
.target()
.and_then(|target| target.dyn_into::<web_sys::Node>().ok())
.map(|node| {
web_sys::window()
.and_then(|window| window.document())
.map(|document| {
let in_panel = document
.get_element_by_id("decompositions-sidebar")
.is_some_and(|el| el.contains(Some(&node)));
let in_gear = document
.get_element_by_id("settings")
.is_some_and(|el| el.contains(Some(&node)));
in_panel || in_gear
})
.unwrap_or(false)
})
.unwrap_or(false);
if !inside {
let mut settings_open = settings_open;
settings_open.set(false);
}
}) as Box<dyn FnMut(web_sys::Event)>);
let _ = document
.add_event_listener_with_callback("pointerdown", closure.as_ref().unchecked_ref());
closure.forget();
});
// Close the download panel when clicking outside the transport bar.
use_hook(move || {
let showing_data_download = showing_data_download.to_owned();
let Some(document) = web_sys::window().and_then(|window| window.document()) else {
return;
};
let closure = Closure::wrap(Box::new(move |event: web_sys::Event| {
let inside = event
.target()
.and_then(|target| target.dyn_into::<web_sys::Node>().ok())
.map(|node| {
web_sys::window()
.and_then(|window| window.document())
.map(|document| {
document
.get_element_by_id("transport")
.is_some_and(|el| el.contains(Some(&node)))
})
.unwrap_or(false)
})
.unwrap_or(false);
if !inside && showing_data_download() {
let mut panel = showing_data_download;
panel.set(false);
}
}) as Box<dyn FnMut(web_sys::Event)>);
let _ = document
.add_event_listener_with_callback("pointerdown", closure.as_ref().unchecked_ref());
closure.forget();
});
// Keep the canvas size in step with the viewport.
use_hook(move || {
let Some(window) = web_sys::window() else {
return;
};
let closure = Closure::wrap(Box::new(move |_event: web_sys::Event| {
let mut viewport = viewport;
viewport.set(window_size());
}) as Box<dyn FnMut(web_sys::Event)>);
let _ = window.add_event_listener_with_callback("resize", closure.as_ref().unchecked_ref());
closure.forget();
});
// Loads a file by parsing it in the worker (off the main thread, so the UI
// stays responsive), optionally running `run` on the result. A load in
// flight orphans the current worker so the new one starts immediately.
let load = {
let bridge = bridge.clone();
let worker_url = worker_url.clone();
move |name: String, bytes: Vec<u8>, run: Option<DecompositionMethod>, sheet: usize| {
if busy() {
*bridge.borrow_mut() = spawn_bridge(
&worker_url,
status,
phase,
embedding,
dataset,
ingest_error,
color_source,
run_info,
sheet_state,
svg_exporting,
svg_export_fraction,
svg_export_data,
);
}
// Retain a spreadsheet's bytes so the picker can re-parse another
// sheet without a re-upload. A switch (same file) keeps the picker;
// any new file forgets it. Other formats do not retain their bytes.
let mut workbook_file = workbook_file;
let mut sheet_state = sheet_state;
let switching_sheet = is_spreadsheet_name(&name)
&& workbook_file
.read()
.as_ref()
.is_some_and(|(n, _)| n == &name);
if !switching_sheet {
sheet_state.set(SheetState::default());
}
workbook_file.set(is_spreadsheet_name(&name).then(|| (name.clone(), bytes.clone())));
let mut status = status;
let mut embedding = embedding;
let mut ingest_error = ingest_error;
ingest_error.set(None);
embedding.set(None);
status.set(String::from(if run.is_some() {
"running"
} else {
"loading"
}));
bridge.borrow().send(WorkerRequest::Load {
name,
bytes,
sheet,
run,
});
}
};
// Starts a fresh run on the current dataset with the configured method, the
// shared body of the Run button and of the auto-run when a dataset loads.
let start_run = {
let bridge = bridge.clone();
move || {
let Some(parsed) = dataset.read().clone() else {
return;
};
let mut status = status;
let mut ingest_error = ingest_error;
let Some((data, n_features)) = build_feature_matrix(&parsed, &columns.read()) else {
ingest_error.set(Some(String::from(
"select at least one feature column in settings",
)));
status.set(String::from("idle"));
return;
};
ingest_error.set(None);
status.set(String::from("running"));
bridge.borrow().send(WorkerRequest::Decompose {
data,
n_samples: parsed.n_samples,
n_features,
method: build_method(),
});
}
};
// Sends a warm-started t-SNE run seeded with the current embedding, the
// shared body of Continue and of resuming after a pause.
let send_warm_start = {
let bridge = bridge.clone();
move || {
let Some(parsed) = dataset.read().clone() else {
return;
};
let Some(seed) = embedding.read().clone() else {
return;
};
if seed.len() != parsed.n_samples * dimension() {
return;
}
let Some((data, n_features)) = build_feature_matrix(&parsed, &columns.read()) else {
return;
};
let selected = DecompositionMethod::Tsne(TsneParams {
perplexity: perplexity(),
epochs: epochs(),
learning_rate: learning_rate(),
pca_dims: pca_dims(),
dimension: dimension(),
early_exaggeration: early_exaggeration(),
early_exaggeration_epochs: exaggeration_epochs(),
initial_embedding: Some(seed),
..TsneParams::default()
});
let mut status = status;
status.set(String::from("running"));
bridge.borrow().send(WorkerRequest::Decompose {
data,
n_samples: parsed.n_samples,
n_features,
method: selected,
});
}
};
// Grabbing a point pauses a running fit: orphan the worker (it self-closes
// once its current run ends) and install a fresh idle one, freezing the
// layout for the drag.
let on_drag_start = {
let bridge = bridge.clone();
let worker_url = worker_url.clone();
move |_index: usize| {
if busy() {
*bridge.borrow_mut() = spawn_bridge(
&worker_url,
status,
phase,
embedding,
dataset,
ingest_error,
color_source,
run_info,
sheet_state,
svg_exporting,
svg_export_fraction,
svg_export_data,
);
let mut status = status;
status.set(String::from("paused"));
resume_pending.set(true);
}
}
};
// Releasing a paused point resumes the fit, warm starting from the dragged
// layout on the fresh worker installed when the grab paused it.
let on_drag_end = {
let send_warm_start = send_warm_start.clone();
move |()| {
// On the first drop of a fresh session (or after a fit resumed
// and cleared the flag), light up the banner and play the one-shot
// orange wash across the commands. Subsequent drops while the
// banner is still up leave both alone so the flash reads as "this
// is a new state" rather than firing on every gesture.
if !embedding_modified() {
embedding_modified.set(true);
flash_pulse.set(true);
spawn(async move {
gloo_timers::future::TimeoutFuture::new(900).await;
flash_pulse.set(false);
});
}
if resume_pending() {
resume_pending.set(false);
send_warm_start();
}
}
};
// Resets the panel to its empty state: stop any run (orphaning its worker),
// drop the dataset and embedding and clear the status. Tuning parameters and
// the method are left as the user set them.
let clear = {
let bridge = bridge.clone();
let worker_url = worker_url.clone();
let recorder = recorder.clone();
let recording_chunks = recording_chunks.clone();
move |_| {
if busy() {
*bridge.borrow_mut() = spawn_bridge(
&worker_url,
status,
phase,
embedding,
dataset,
ingest_error,
color_source,
run_info,
sheet_state,
svg_exporting,
svg_export_fraction,
svg_export_data,
);
}
// Stop and discard any recording, armed or finished.
if let Some(rec) = recorder.borrow_mut().take() {
let _ = rec.stop();
}
recording_chunks.borrow_mut().clear();
let mut recording_armed = recording_armed;
let mut recording_active = recording_active;
let mut recorded_url = recorded_url;
if let Some(url) = recorded_url.read().clone() {
let _ = web_sys::Url::revoke_object_url(&url);
}
recording_armed.set(false);
recording_active.set(false);
recorded_url.set(None);
let mut dataset = dataset;
let mut embedding = embedding;
let mut status = status;
let mut ingest_error = ingest_error;
let mut color_source = color_source;
let mut resume_pending = resume_pending;
let mut run_info = run_info;
dataset.set(None);
embedding.set(None);
status.set(String::from("idle"));
ingest_error.set(None);
run_info.set(None);
color_source.set(String::from("none"));
resume_pending.set(false);
embedding_modified.set(false);
flash_pulse.set(false);
}
};
// Sets up the MediaRecorder on the plot canvas and starts capturing. Called
// at the first epoch so the loading and affinity-setup frames are skipped.
let start_capture = {
let recorder = recorder.clone();
let recording_chunks = recording_chunks.clone();
move || {
let mut recording_active = recording_active;
let Some(canvas) = web_sys::window()
.and_then(|window| window.document())
.and_then(|document| document.get_element_by_id("scatter-plot"))
.and_then(|element| element.dyn_into::<web_sys::HtmlCanvasElement>().ok())
else {
return;
};
let Ok(stream) = canvas.capture_stream_with_frame_request_rate(30.0) else {
return;
};
let mime = if web_sys::MediaRecorder::is_type_supported("video/webm;codecs=vp9") {
"video/webm;codecs=vp9"
} else {
"video/webm"
};
let options = web_sys::MediaRecorderOptions::new();
options.set_mime_type(mime);
let Ok(media_recorder) =
web_sys::MediaRecorder::new_with_media_stream_and_media_recorder_options(
&stream, &options,
)
else {
return;
};
// Collect dataavailable chunks.
let chunks = recording_chunks.clone();
let data_closure = Closure::wrap(Box::new(move |event: web_sys::BlobEvent| {
if let Some(data) = event.data() {
chunks.borrow_mut().push(data.into());
}
}) as Box<dyn FnMut(web_sys::BlobEvent)>);
let _ = media_recorder.add_event_listener_with_callback(
"dataavailable",
data_closure.as_ref().unchecked_ref(),
);
data_closure.forget();
// On stop, combine the chunks into a WebM and expose its object URL
// for the download button (no automatic download).
let chunks_on_stop = recording_chunks.clone();
let stop_closure = Closure::wrap(Box::new(move || {
// Runs from the recorder's stop event, outside any dioxus flush,
// so setting signals here is safe (unlike from the effect).
let mut recording_active = recording_active;
let mut recording_armed = recording_armed;
let mut recorded_url = recorded_url;
recording_active.set(false);
// One shot: disarm so the control becomes the download button.
recording_armed.set(false);
let array = js_sys::Array::new();
{
let mut chunks = chunks_on_stop.borrow_mut();
for chunk in chunks.iter() {
array.push(chunk);
}
chunks.clear();
}
if array.length() == 0 {
return;
}
let bag = web_sys::BlobPropertyBag::new();
bag.set_type("video/webm");
let Ok(blob) = web_sys::Blob::new_with_blob_sequence_and_options(&array, &bag)
else {
return;
};
if let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) {
recorded_url.set(Some(url));
}
}) as Box<dyn FnMut()>);
let _ = media_recorder
.add_event_listener_with_callback("stop", stop_closure.as_ref().unchecked_ref());
stop_closure.forget();
recording_chunks.borrow_mut().clear();
let _ = media_recorder.start();
*recorder.borrow_mut() = Some(media_recorder);
recording_active.set(true);
}
};
// Stops the recorder, the stop event then assembles the video.
let stop_capture = {
let recorder = recorder.clone();
move || {
if let Some(rec) = recorder.borrow_mut().take() {
let _ = rec.stop();
}
}
};
// Whether a capture is in flight. Non reactive on purpose: the effect below
// must not read a signal it also writes, or setting one mid-flush re-enters
// the async executor and panics ("RefCell already borrowed").
let capturing = use_hook(|| Rc::new(RefCell::new(false)));
// Drive capture off the run status: begin at the first epoch (skipping the
// load and affinity setup) while armed, and stop once the animation ends
// (convergence, error, pause, or clear). start_capture and stop_capture only
// write signals the effect does not read, and the rest of the state (the
// active flag, the video URL, disarming) is updated from the recorder's stop
// event, which runs outside the flush.
{
let start_capture = start_capture.clone();
let stop_capture = stop_capture.clone();
let capturing = capturing.clone();
use_effect(move || {
let status = status.read();
let armed = recording_armed();
let running = matches!(status.as_str(), "running" | "loading");
let animating = status.starts_with("epoch ");
let mut capturing = capturing.borrow_mut();
if armed && !*capturing && animating {
*capturing = true;
start_capture();
} else if *capturing && !animating && !running {
*capturing = false;
stop_capture();
}
});
}
// Downloads the finished video, then releases its object URL.
let download_video = move |_| {
let mut recorded_url = recorded_url;
let Some(url) = recorded_url.read().clone() else {
return;
};
if let Some(anchor) = web_sys::window()
.and_then(|window| window.document())
.and_then(|document| document.create_element("a").ok())
.and_then(|element| element.dyn_into::<web_sys::HtmlAnchorElement>().ok())
{
anchor.set_href(&url);
anchor.set_download("decomposition.webm");
anchor.click();
}
let _ = web_sys::Url::revoke_object_url(&url);
recorded_url.set(None);
};
// Downloads the current frame as a PNG. Rendered to a fresh square canvas
// framed to the embedding (not the full-bleed page) with a transparent
// background, so the picture is tight and drops onto any backdrop.
let download_image = move |_| {
let embedding = embedding.read();
let Some(points) = embedding.as_ref() else {
return;
};
let colors = colors.read();
let markers = markers.read();
let highlight = highlight.read();
// Bake the legend into the picture only when asked and there is one.
let legend = legend_in_export()
.then(|| coloring_result.read().as_ref().map(|c| c.legend.clone()))
.flatten()
.filter(|entries| !entries.is_empty());
let (vw, vh) = viewport();
let size = vw.min(vh).max(1);
let Some(data_url) = crate::plot::snapshot_png(
points,
colors.as_deref(),
markers.as_deref(),
highlight.as_ref().map(|(c, m)| (c.as_str(), *m)),
legend.as_deref(),
size,
) else {
return;
};
if let Some(anchor) = web_sys::window()
.and_then(|window| window.document())
.and_then(|document| document.create_element("a").ok())
.and_then(|element| element.dyn_into::<web_sys::HtmlAnchorElement>().ok())
{
anchor.set_href(&data_url);
anchor.set_download("tsne.png");
anchor.click();
}
};
// Export the current embedding as an SVG string via the worker.
let mut export_svg = {
let bridge = bridge.clone();
let embedding = embedding.to_owned();
let colors = colors.to_owned();
let markers = markers.to_owned();
let highlight = highlight.to_owned();
let coloring_result = coloring_result.to_owned();
let legend_in_export = legend_in_export.to_owned();
let mut svg_exporting = svg_exporting.to_owned();
let dimension = dimension.to_owned();
move |_| {
let points = match &*embedding.read() {
Some(p) => p.clone(),
None => return,
};
let colors_data = colors.read().clone().unwrap_or_default();
let markers_data = markers.read().clone().unwrap_or_default();
let highlight_data = highlight.read().clone();
let legend = legend_in_export()
.then(|| coloring_result.read().as_ref().map(|c| c.legend.clone()))
.flatten()
.unwrap_or_default();
let markers_vec: Vec<u8> = markers_data
.iter()
.map(|m| match m {
crate::Marker::Circle => 0,
crate::Marker::Triangle => 1,
crate::Marker::Square => 2,
crate::Marker::Diamond => 3,
crate::Marker::Plus => 4,
crate::Marker::TriangleDown => 5,
})
.collect();
let highlight_tuple = highlight_data.as_ref().map(|(c, m)| {
(
c.clone(),
match m {
crate::Marker::Circle => 0,
crate::Marker::Triangle => 1,
crate::Marker::Square => 2,
crate::Marker::Diamond => 3,
crate::Marker::Plus => 4,
crate::Marker::TriangleDown => 5,
},
)
});
svg_exporting.set(false);
bridge.borrow().send(WorkerRequest::ExportSvg {
dimension: dimension(),
points,
colors: colors_data,
markers: markers_vec,
highlight: highlight_tuple,
legend,
});
}
};
// Export the current embedding coordinates as a CSV file.
let export_csv = {
let embedding = embedding.to_owned();
let dataset = dataset.to_owned();
let color_source = color_source.to_owned();
move |_| {
use std::fmt::Write;
let Some(points) = &*embedding.read() else {
return;
};
let n = points.len() / 2;
// Determine if a label column is active.
let label_values: Option<Vec<String>> = if color_source() != "none" {
let source = color_source();
if let Some(col_name) = source.strip_prefix("column:") {
let dataset_data = dataset.read();
if let Some(ds) = &*dataset_data {
ds.label_columns
.iter()
.find(|lc| lc.name == col_name)
.map(|lc| lc.values.clone())
} else {
None
}
} else {
None
}
} else {
None
};
let mut csv = String::from("tsne_x,tsne_y");
if label_values.is_some() {
csv.push_str(",label");
}
csv.push('\n');
for i in 0..n {
let x = points[i * 2];
let y = points[i * 2 + 1];
if let Some(ref labels) = label_values {
let label = labels.get(i).map(|s| s.as_str()).unwrap_or("");
let _ = write!(csv, "{x},{y},{label}");
} else {
let _ = write!(csv, "{x},{y}");
}
csv.push('\n');
}
if let Some(window) = web_sys::window() {
let bytes = js_sys::Uint8Array::from(csv.as_bytes());
let options = web_sys::BlobPropertyBag::new();
options.set_type("text/csv");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(
&js_sys::Array::of1(&bytes.into()),
&options,
);
if let Ok(blob) = blob
&& let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob)
&& let Some(document) = window.document()
&& let Ok(anchor_el) = document.create_element("a")
&& let Ok(anchor) = anchor_el.dyn_into::<web_sys::HtmlAnchorElement>()
{
anchor.set_href(&url);
anchor.set_download("tsne.csv");
anchor.click();
let _ = web_sys::Url::revoke_object_url(&url);
}
}
}
};
// Export the current embedding coordinates as a NumPy .npy file.
let export_npy = {
let embedding = embedding.to_owned();
move |_| {
let Some(points) = &*embedding.read() else {
return;
};
let n = points.len() / 2;
// Build the .npy header.
let header = format!(
"{{'descr': '', 'fortran_order': False, 'shape': ({}, 2), }}",
n
);
// NPY v1.0: 6-byte magic + 2-byte version + 2-byte header len + header + newline.
let header_len = header.len() + 1; // +1 for trailing newline
// Pad header to be divisible by 64 (including magic + version + len fields = 10 bytes).
let total_header_len = 10 + header_len;
let padding = 64 - (total_header_len % 64);
let padded_len = header_len + padding - 1; // -1 because newline counts
let mut buf = Vec::with_capacity(10 + padded_len + n * 2 * 4);
// Magic: \x93NUMPY
buf.extend_from_slice(&[0x93, b'N', b'U', b'M', b'P', b'Y']);
// Version 1.0
buf.push(1);
buf.push(0);
// Header length (little-endian u16)
let hl = padded_len as u16;
buf.push((hl & 0xFF) as u8);
buf.push(((hl >> 8) & 0xFF) as u8);
// Header dict
buf.extend_from_slice(header.as_bytes());
// Padding spaces
buf.extend(std::iter::repeat_n(b' ', padding - 1));
// Trailing newline
buf.push(b'\n');
// Data: (N, 2) float32, C-order (row-major).
for i in 0..n {
buf.extend_from_slice(&points[i * 2].to_le_bytes());
buf.extend_from_slice(&points[i * 2 + 1].to_le_bytes());
}
if let Some(window) = web_sys::window() {
let bytes = js_sys::Uint8Array::from(buf.as_slice());
let options = web_sys::BlobPropertyBag::new();
options.set_type("application/octet-stream");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(
&js_sys::Array::of1(&bytes.into()),
&options,
);
if let Ok(blob) = blob
&& let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob)
&& let Some(document) = window.document()
&& let Ok(anchor_el) = document.create_element("a")
&& let Ok(anchor) = anchor_el.dyn_into::<web_sys::HtmlAnchorElement>()
{
anchor.set_href(&url);
anchor.set_download("tsne.npy");
anchor.click();
let _ = web_sys::Url::revoke_object_url(&url);
}
}
}
};
// Export the current embedding as a Parquet file.
let export_parquet = {
let embedding = embedding.to_owned();
let dataset = dataset.to_owned();
let color_source = color_source.to_owned();
move |_| {
let Some(points) = &*embedding.read() else {
return;
};
// Build Arrow arrays for tsne_x and tsne_y.
use arrow_array::{Float32Array, RecordBatch, StringArray};
use arrow_schema::{DataType, Field, Schema};
let x_array =
Float32Array::from(points.chunks_exact(2).map(|c| c[0]).collect::<Vec<_>>());
let y_array =
Float32Array::from(points.chunks_exact(2).map(|c| c[1]).collect::<Vec<_>>());
// Determine if a label column is active.
let label_array: Option<StringArray> = if color_source() != "none" {
let source = color_source();
if let Some(col_name) = source.strip_prefix("column:") {
if let Some(ds) = &*dataset.read() {
ds.label_columns
.iter()
.find(|lc| lc.name == col_name)
.map(|lc| StringArray::from(lc.values.clone()))
} else {
None
}
} else {
None
}
} else {
None
};
let schema = if label_array.is_some() {
Schema::new(vec![
Field::new("tsne_x", DataType::Float32, false),
Field::new("tsne_y", DataType::Float32, false),
Field::new("label", DataType::Utf8, true),
])
} else {
Schema::new(vec![
Field::new("tsne_x", DataType::Float32, false),
Field::new("tsne_y", DataType::Float32, false),
])
};
let batch = if let Some(label) = label_array {
RecordBatch::try_new(
std::sync::Arc::new(schema),
vec![
std::sync::Arc::new(x_array),
std::sync::Arc::new(y_array),
std::sync::Arc::new(label),
],
)
} else {
RecordBatch::try_new(
std::sync::Arc::new(schema),
vec![std::sync::Arc::new(x_array), std::sync::Arc::new(y_array)],
)
};
let Ok(batch) = batch else {
return;
};
// Write to memory via Cursor.
use parquet::arrow::ArrowWriter;
use parquet::file::properties::WriterProperties;
let mut buf = vec![];
let props = WriterProperties::builder().build();
let mut writer = ArrowWriter::try_new(&mut buf, batch.schema(), Some(props)).unwrap();
if writer.write(&batch).is_err() || writer.close().is_err() {
return;
}
if let Some(window) = web_sys::window() {
let bytes = js_sys::Uint8Array::from(buf.as_slice());
let options = web_sys::BlobPropertyBag::new();
options.set_type("application/octet-stream");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(
&js_sys::Array::of1(&bytes.into()),
&options,
);
if let Ok(blob) = blob
&& let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob)
&& let Some(document) = window.document()
&& let Ok(anchor_el) = document.create_element("a")
&& let Ok(anchor) = anchor_el.dyn_into::<web_sys::HtmlAnchorElement>()
{
anchor.set_href(&url);
anchor.set_download("tsne.parquet");
anchor.click();
let _ = web_sys::Url::revoke_object_url(&url);
}
}
}
};
// Export the current embedding as an Arrow IPC file.
let export_arrow = {
let embedding = embedding.to_owned();
let dataset = dataset.to_owned();
let color_source = color_source.to_owned();
move |_| {
let Some(points) = &*embedding.read() else {
return;
};
use arrow_array::{Float32Array, RecordBatch, StringArray};
use arrow_ipc::writer::FileWriter;
use arrow_schema::{DataType, Field, Schema};
let x_array =
Float32Array::from(points.chunks_exact(2).map(|c| c[0]).collect::<Vec<_>>());
let y_array =
Float32Array::from(points.chunks_exact(2).map(|c| c[1]).collect::<Vec<_>>());
let label_array: Option<StringArray> = if color_source() != "none" {
let source = color_source();
if let Some(col_name) = source.strip_prefix("column:") {
if let Some(ds) = &*dataset.read() {
ds.label_columns
.iter()
.find(|lc| lc.name == col_name)
.map(|lc| StringArray::from(lc.values.clone()))
} else {
None
}
} else {
None
}
} else {
None
};
let schema = if label_array.is_some() {
Schema::new(vec![
Field::new("tsne_x", DataType::Float32, false),
Field::new("tsne_y", DataType::Float32, false),
Field::new("label", DataType::Utf8, true),
])
} else {
Schema::new(vec![
Field::new("tsne_x", DataType::Float32, false),
Field::new("tsne_y", DataType::Float32, false),
])
};
let batch = if let Some(label) = label_array {
RecordBatch::try_new(
std::sync::Arc::new(schema),
vec![
std::sync::Arc::new(x_array),
std::sync::Arc::new(y_array),
std::sync::Arc::new(label),
],
)
} else {
RecordBatch::try_new(
std::sync::Arc::new(schema),
vec![std::sync::Arc::new(x_array), std::sync::Arc::new(y_array)],
)
};
let Ok(batch) = batch else {
return;
};
let mut buf = vec![];
let mut writer = FileWriter::try_new(&mut buf, &batch.schema()).unwrap();
if writer.write(&batch).is_err() {
return;
}
if writer.finish().is_err() {
return;
}
if let Some(window) = web_sys::window() {
let bytes = js_sys::Uint8Array::from(buf.as_slice());
let options = web_sys::BlobPropertyBag::new();
options.set_type("application/octet-stream");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(
&js_sys::Array::of1(&bytes.into()),
&options,
);
if let Ok(blob) = blob
&& let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob)
&& let Some(document) = window.document()
&& let Ok(anchor_el) = document.create_element("a")
&& let Ok(anchor) = anchor_el.dyn_into::<web_sys::HtmlAnchorElement>()
{
anchor.set_href(&url);
anchor.set_download("tsne.arrow");
anchor.click();
let _ = web_sys::Url::revoke_object_url(&url);
}
}
}
};
// Transport controls (media-player style). Pause freezes a running fit by
// orphaning its worker (it self-closes after its current run) and installing
// a fresh idle one, so Play can warm-start from the frozen layout.
let pause = {
let bridge = bridge.clone();
let worker_url = worker_url.clone();
move || {
if busy() {
*bridge.borrow_mut() = spawn_bridge(
&worker_url,
status,
phase,
embedding,
dataset,
ingest_error,
color_source,
run_info,
sheet_state,
svg_exporting,
svg_export_fraction,
svg_export_data,
);
let mut status = status;
status.set(String::from("paused"));
}
}
};
// Play resumes from the current layout when there is one (a paused or
// finished run), otherwise it starts a fresh run.
let play = {
let start_run = start_run.clone();
let send_warm_start = send_warm_start.clone();
move || {
if dataset.read().is_none() {
return;
}
if embedding.read().is_some() {
send_warm_start();
} else {
start_run();
}
}
};
let toggle_play = {
let play = play.clone();
let pause = pause.clone();
move |_| {
if busy() {
pause();
} else {
play();
}
}
};
let restart = {
let start_run = start_run.clone();
move |_| start_run()
};
// Rec arms recording and acts like Play. Toggling it off while armed or
// recording stops the capture (which assembles the downloadable video).
let rec_toggle = {
let play = play.clone();
let stop_capture = stop_capture.clone();
let capturing = capturing.clone();
move |_| {
let mut recording_armed = recording_armed;
if recording_armed() || recording_active() {
recording_armed.set(false);
stop_capture();
*capturing.borrow_mut() = false;
} else {
let mut recorded_url = recorded_url;
if let Some(url) = recorded_url.read().clone() {
let _ = web_sys::Url::revoke_object_url(&url);
}
recorded_url.set(None);
recording_armed.set(true);
if !busy() {
play();
}
}
}
};
// Keyboard shortcuts. Escape clears the dataset, Space toggles play /
// pause, the digit keys `2`/`3`/`4` switch the display dimensionality
// (validated against the embedding, error-flashing the toggle on a
// dimension it cannot show). The `X`/`Y`/`Z`/`W` keys drive the
// rotation ticker on the shared `camera` signal, and are wired at
// window scope so they work in every display mode (including the 2-D
// scatter, which has no focusable canvas of its own).
let display_dim_from_key = try_set_display_dim.clone();
use_hook(move || {
let mut clear = clear.clone();
let toggle_play = {
let play = play.clone();
let pause = pause.clone();
move || {
if busy() {
pause();
} else {
play();
}
}
};
// Rotation-key handlers share the `held_axes` signal with the
// ticker via three separate closures (keydown, keyup, blur), each
// rebinding the signal as mutable so its `with_mut` call works
// through the outer `FnMut` closure signature.
let display_dim_from_key = display_dim_from_key.clone();
let mut held_axes_kd = held_axes;
let keydown = Closure::wrap(Box::new(move |event: web_sys::Event| {
let Some(keyboard) = event.dyn_ref::<web_sys::KeyboardEvent>() else {
return;
};
// Keep the cursor hint synced with the current modifier state on
// every keydown, regardless of which key fired. Reading the flags
// off the event, rather than tracking Shift/Alt keydown/keyup
// separately, absorbs edge cases like a Shift press swallowed by
// the OS's dead-key handling.
let hint = if keyboard.shift_key() {
SelectionMode::Add
} else if keyboard.alt_key() {
SelectionMode::Subtract
} else {
SelectionMode::Replace
};
if select_mode_hint() != hint {
select_mode_hint.set(hint);
}
let key = keyboard.key();
match key.as_str() {
"Escape" => {
keyboard.prevent_default();
// Peel the manipulation state back one layer at a time so
// Esc reads as "undo my last mode": drop the selection
// before wiping the whole dataset when both are present.
if !selection.read().is_empty() {
selection.set(Vec::new());
} else {
clear(());
}
return;
}
" " if dataset.read().is_some() => {
keyboard.prevent_default();
toggle_play();
return;
}
"2" | "3" | "4" => {
keyboard.prevent_default();
if let Ok(dim) = key.parse::<usize>() {
display_dim_from_key(dim);
}
return;
}
_ => {}
}
// Axis-rotation keys. Physical `Code` (not layout `Key`) so
// dvorak / azerty land on the same row of keys, and the OS
// key-repeat replays are ignored (the ticker already spins as
// long as the flag is set).
if keyboard.repeat() {
return;
}
let axis = match keyboard.code().as_str() {
"KeyX" => Some(RotationAxis::X),
"KeyY" => Some(RotationAxis::Y),
"KeyZ" => Some(RotationAxis::Z),
"KeyW" => Some(RotationAxis::W),
_ => None,
};
if let Some(axis) = axis {
keyboard.prevent_default();
held_axes_kd.with_mut(|a| a.set(axis, true));
}
}) as Box<dyn FnMut(web_sys::Event)>);
let mut held_axes_ku = held_axes;
let keyup = Closure::wrap(Box::new(move |event: web_sys::Event| {
let Some(keyboard) = event.dyn_ref::<web_sys::KeyboardEvent>() else {
return;
};
// Same rules as keydown, so releasing Shift/Alt drops back to
// Replace immediately.
let hint = if keyboard.shift_key() {
SelectionMode::Add
} else if keyboard.alt_key() {
SelectionMode::Subtract
} else {
SelectionMode::Replace
};
if select_mode_hint() != hint {
select_mode_hint.set(hint);
}
let axis = match keyboard.code().as_str() {
"KeyX" => Some(RotationAxis::X),
"KeyY" => Some(RotationAxis::Y),
"KeyZ" => Some(RotationAxis::Z),
"KeyW" => Some(RotationAxis::W),
_ => None,
};
if let Some(axis) = axis {
held_axes_ku.with_mut(|a| a.set(axis, false));
}
}) as Box<dyn FnMut(web_sys::Event)>);
// Window blur: focus loss (Alt-Tab, dev-tools popping open, click
// in another window) never delivers keyup, so held keys would
// strand the ticker into an infinite spin. Reset every axis flag
// on blur to keep the state in sync with what the fingers are
// actually doing.
let mut held_axes_blur = held_axes;
let mut select_mode_hint_blur = select_mode_hint;
let blur = Closure::wrap(Box::new(move |_event: web_sys::Event| {
held_axes_blur.set(HeldAxes::default());
select_mode_hint_blur.set(SelectionMode::Replace);
}) as Box<dyn FnMut(web_sys::Event)>);
if let Some(win) = web_sys::window() {
let _ =
win.add_event_listener_with_callback("keydown", keydown.as_ref().unchecked_ref());
let _ = win.add_event_listener_with_callback("keyup", keyup.as_ref().unchecked_ref());
let _ = win.add_event_listener_with_callback("blur", blur.as_ref().unchecked_ref());
}
keydown.forget();
keyup.forget();
blur.forget();
});
let drop_enabled = drop_zone.is_some();
let drop_prompt = drop_zone
.as_ref()
.map(|z| z.prompt.clone())
.unwrap_or_default();
let drop_zone = drop_zone.clone();
let has_examples = !examples.is_empty();
// Whether there is any label column to color by under the current roles.
let has_labels = use_memo(move || !effective_labels.read().is_empty());
// Plot props gated on the enabled features, hoisted out of the rsx so the
// conditionals stay plain Rust (`.then`) rather than inline if/else.
let plot_draggable = draggable.then(|| can_drag.into());
let on_point_moved = draggable.then(|| {
EventHandler::new(move |(index, dx, dy): (usize, f32, f32)| {
// The plot hands back deltas in the current projection (2-D
// display space). Unrotate them into the raw embedding basis so a
// 3-D or 4-D fit dragged in its projected view moves along the
// right axes, not the first two of every row.
let Some(ed) = embedding_row_dim() else {
return;
};
let delta = unproject_display_delta(dx, dy, ed, camera());
let mut embedding = embedding;
embedding.with_mut(|current| {
if let Some(current) = current.as_mut()
&& ed * (index + 1) <= current.len()
{
for axis in 0..ed {
current[ed * index + axis] += delta[axis];
}
}
});
})
});
let on_drag_start = draggable.then(|| EventHandler::new(on_drag_start));
let on_drag_end = draggable.then(|| EventHandler::new(on_drag_end));
// Multi-point selection props, wired only when point dragging is enabled
// AND the current display is the 2D scatter. Higher dimensions go through
// ScatterPlot3D which does not accept selection props today.
let selection_enabled = draggable && display_dim() == 2;
let plot_selection = selection_enabled.then(|| selection.into());
let on_selection_changed = selection_enabled.then(|| {
EventHandler::new(move |(mut rect, mode): (Vec<usize>, SelectionMode)| {
// Contract with the plot: keep indices sorted and deduped so the
// grayscale mask build is a straight `mask[i] = true` fill.
rect.sort_unstable();
rect.dedup();
let mut selection = selection;
let new_selection = match mode {
SelectionMode::Replace => rect,
SelectionMode::Add => {
let current = selection.read().clone();
let mut merged = current;
merged.extend(rect);
merged.sort_unstable();
merged.dedup();
merged
}
SelectionMode::Subtract => {
// Sorted-difference walk: both inputs are sorted so we
// trim in one linear pass without a hashset detour.
let current = selection.read().clone();
let mut out = Vec::with_capacity(current.len());
let mut i = 0usize;
let mut j = 0usize;
while i < current.len() {
match rect.get(j).copied() {
Some(r) if r < current[i] => j += 1,
Some(r) if r == current[i] => {
i += 1;
j += 1;
}
_ => {
out.push(current[i]);
i += 1;
}
}
}
out
}
};
selection.set(new_selection);
})
});
let on_group_moved = selection_enabled.then(|| {
EventHandler::new(move |(dx, dy): (f32, f32)| {
let sel = selection.read().clone();
if sel.is_empty() {
return;
}
let Some(ed) = embedding_row_dim() else {
return;
};
let delta = unproject_display_delta(dx, dy, ed, camera());
let mut embedding = embedding;
embedding.with_mut(|current| {
let Some(current) = current.as_mut() else {
return;
};
for index in sel {
if ed * (index + 1) <= current.len() {
for axis in 0..ed {
current[ed * index + axis] += delta[axis];
}
}
}
});
})
});
rsx! {
div {
class: "decompositions-explorer",
// A click anywhere outside the legend clears a pinned focus (the
// legend stops propagation of its own clicks).
onclick: move |_| {
let mut legend_pinned = legend_pinned;
if legend_pinned().is_some() {
legend_pinned.set(None);
}
},
// The whole page is the drop target.
ondragover: move |evt| {
if drop_enabled {
evt.prevent_default();
dragging_over.set(true);
}
},
ondragleave: move |_| dragging_over.set(false),
ondrop: {
let load = load.clone();
move |evt: Event<DragData>| {
let zone = drop_zone.clone();
let load = load.clone();
async move {
let Some(zone) = zone else {
return;
};
evt.prevent_default();
dragging_over.set(false);
let Some(file) = evt.files().into_iter().next() else {
return;
};
let name = file.name();
let extension = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
if !zone.allows(&extension) {
let mut dataset = dataset;
let mut ingest_error = ingest_error;
dataset.set(None);
ingest_error.set(Some(format!("unsupported file type .{extension}")));
return;
}
match file.read_bytes().await {
Ok(bytes) => load(name, bytes.to_vec(), None, 0),
Err(error) => {
let mut ingest_error = ingest_error;
ingest_error.set(Some(error.to_string()));
}
}
}
}
},
if styled {
style { {crate::DEFAULT_STYLE} }
}
// Full-bleed plot.
div { class: "decompositions-plot-area",
if display_dim() >= 3 {
ScatterPlot3D {
embedding: display_embedding,
camera,
colors: Some(colors.into()),
markers: Some(markers.into()),
highlight: Some(highlight.into()),
width: viewport().0,
height: viewport().1,
pixel_ratio,
}
} else {
ScatterPlot {
embedding: display_embedding,
colors: Some(colors.into()),
markers: Some(markers.into()),
highlight: Some(highlight.into()),
draggable: plot_draggable,
on_point_moved,
on_drag_start,
on_drag_end,
selection: plot_selection,
on_selection_changed,
on_group_moved,
select_mode: selection_enabled.then(|| select_mode_hint.into()),
width: viewport().0,
height: viewport().1,
pixel_ratio,
}
}
}
// Color legend overlay (keeps data colors). For categorical scales
// the entries focus their class on hover and pin it on click.
if embedding.read().is_some() {
if let Some(active) = coloring_result.read().as_ref() {
{
let interactive = active.scale == ColorScale::Categorical;
// Above the marker limit the plot draws dots, not shapes, so
// the legend shows a uniform dot to match.
let uniform_marker = active.colors.len() > crate::plot::SHAPED_MARKER_LIMIT;
rsx! {
div {
id: "legend",
class: if interactive { "decompositions-legend decompositions-legend--interactive" } else { "decompositions-legend" },
// Keep clicks inside the legend from clearing the pin.
onclick: move |evt| evt.stop_propagation(),
for (index, entry) in active.legend.iter().take(MAX_LEGEND_ENTRIES).enumerate() {
span {
class: if interactive && legend_pinned() == Some(index) { "decompositions-legend-entry decompositions-legend-entry--pinned" } else { "decompositions-legend-entry" },
onmouseenter: move |_| {
if interactive {
let mut legend_hovered = legend_hovered;
legend_hovered.set(Some(index));
}
},
onmouseleave: move |_| {
if interactive {
let mut legend_hovered = legend_hovered;
legend_hovered.set(None);
}
},
onclick: move |_| {
if interactive {
let mut legend_pinned = legend_pinned;
let next = if legend_pinned() == Some(index) { None } else { Some(index) };
legend_pinned.set(next);
}
},
span {
class: "decompositions-legend-swatch",
style: "color: {entry.color};",
{ (if uniform_marker { Marker::Circle } else { entry.marker }).glyph().to_string() }
}
"{entry.label}"
}
}
if active.legend.len() > MAX_LEGEND_ENTRIES {
span { class: "decompositions-legend-more",
"(+{active.legend.len() - MAX_LEGEND_ENTRIES} more)"
}
}
}
}
}
}
}
// Top bar: brand/logo on the left, settings gear on the right.
if controls {
div { class: "decompositions-topbar",
div { class: "decompositions-brand",
a {
class: "decompositions-brand-link",
href: "/",
title: "Reload",
"aria-label": "Reload the page",
if let Some(logo) = logo.as_ref() {
img { class: "decompositions-logo", src: "{logo}", alt: "dioxus-decompositions" }
} else {
span { "dioxus-decompositions" }
}
}
}
div { class: "decompositions-topbar-actions",
button {
id: "help",
class: "decompositions-iconbtn",
title: "What is t-SNE?",
"aria-label": "What is t-SNE? Opens an explainer.",
onclick: move |_| {
let mut about_open = about_open;
about_open.set(true);
},
Icon { icon: FaCircleQuestion, width: 17, height: 17, class: "decompositions-icon" }
}
if let Some(repo_url) = repo_url.as_ref() {
a {
id: "repo",
class: "decompositions-iconbtn",
href: "{repo_url}",
target: "_blank",
rel: "noopener",
title: "Source code on GitHub",
"aria-label": "Source code on GitHub. Opens in a new tab.",
Icon { icon: FaGithub, width: 17, height: 17, class: "decompositions-icon" }
}
}
if let Some(support_url) = support_url.as_ref() {
a {
id: "support",
// After a run finishes, a few elegant red pulses
// nudge the user toward the sponsor link. The
// pause and pulse count are pure CSS.
class: if matches!(*run_info.read(), Some(RunInfo::Done { .. })) { "decompositions-iconbtn decompositions-heartbtn decompositions-heart-attention" } else { "decompositions-iconbtn decompositions-heartbtn" },
href: "{support_url}",
target: "_blank",
rel: "noopener",
title: "Support this project",
"aria-label": "Support this project. Opens in a new tab.",
Icon { icon: FaHeart, width: 16, height: 16, class: "decompositions-icon" }
}
}
}
}
}
// Empty state covering the page: example buttons and the drop hint.
// Gone once a dataset is loaded (so dropping a file clears it), but
// the loading spinner still shows while a load or run is in flight.
if (drop_enabled || has_examples)
&& embedding.read().is_none()
&& (dataset.read().is_none() || busy())
{
div {
id: "dropzone",
class: if dragging_over() { "decompositions-empty decompositions-empty--over" } else { "decompositions-empty" },
if busy() {
div { id: "loading", class: "decompositions-loading",
Icon { icon: FaSpinner, width: 32, height: 32, class: "decompositions-icon decompositions-spinner" }
span { "{loading_label}" }
}
} else {
if has_examples {
div { class: "decompositions-examples",
for (index, example) in examples.iter().enumerate() {
button {
id: "load-example-{index}",
class: "decompositions-example",
title: example.description.clone().unwrap_or_else(|| format!("Load the {} example dataset.", example.name)),
"aria-label": example.description.clone().unwrap_or_else(|| format!("Load the {} example dataset.", example.name)),
onclick: {
let url = example.url.clone();
let load = load.clone();
move |_| {
let url = url.clone();
let load = load.clone();
let mut status = status;
status.set(String::from("loading"));
async move {
let fetched = match gloo_net::http::Request::get(&url).send().await {
Ok(response) => response.binary().await,
Err(error) => Err(error),
};
match fetched {
Ok(bytes) => load(url, bytes, None, 0),
Err(error) => {
let mut status = status;
let mut ingest_error = ingest_error;
status.set(String::from("idle"));
ingest_error.set(Some(error.to_string()));
}
}
}
}
},
match example.icon {
Some(ExampleIcon::Numbers) => rsx! {
Icon { icon: FaCalculator, width: 14, height: 14, class: "decompositions-icon" }
},
Some(ExampleIcon::Apparel) => rsx! {
Icon { icon: FaShirt, width: 14, height: 14, class: "decompositions-icon" }
},
Some(ExampleIcon::Network) => rsx! {
Icon { icon: FaShareNodes, width: 14, height: 14, class: "decompositions-icon" }
},
None => rsx! {},
}
"{example.name}"
}
}
}
}
if drop_enabled {
label { r#for: "file-input", class: "decompositions-drophint",
Icon { icon: FaFileArrowUp, width: 16, height: 16, class: "decompositions-icon" }
span { {drop_prompt} }
}
input {
id: "file-input",
r#type: "file",
class: "decompositions-fileinput",
accept: DATA_ACCEPT,
onchange: {
let load = load.clone();
move |evt: Event<FormData>| {
let load = load.clone();
async move {
let Some(file) = evt.files().into_iter().next() else {
return;
};
match file.read_bytes().await {
Ok(bytes) => load(file.name(), bytes.to_vec(), None, 0),
Err(error) => {
let mut ingest_error = ingest_error;
ingest_error.set(Some(error.to_string()));
}
}
}
}
},
}
}
if let Some(error) = ingest_error.read().as_ref() {
p { id: "ingest-error", class: "decompositions-error",
Icon { icon: FaTriangleExclamation, width: 14, height: 14, class: "decompositions-icon" }
"{error.clone()}"
}
}
}
}
}
// Media-player transport bar. Centered as a call to action once a
// dataset is loaded but not yet run, then animates down to the
// bottom when the run (and any recording) starts.
if controls && dataset.read().is_some() {
div {
id: "transport",
class: if embedding.read().is_none() && !busy() { "decompositions-transport decompositions-transport--centered" } else { "decompositions-transport" },
div {
class: if flash_pulse() { "decompositions-transport-row decompositions-transport-row--flashing" } else { "decompositions-transport-row" },
if showing_data_download() {
div { class: "decompositions-panel-content",
button {
class: "decompositions-iconbtn decompositions-tp-back",
title: "Back to controls",
"aria-label": "Back to controls",
onclick: move |_| {
let mut panel = showing_data_download;
panel.set(false);
},
Icon { icon: FaChevronLeft, width: 14, height: 14, class: "decompositions-icon" }
}
button {
class: "decompositions-export-btn decompositions-export-btn--png",
title: "Download the current frame as a PNG image",
"aria-label": "Download as PNG image",
onclick: download_image,
span { class: "decompositions-export-icon",
Icon { icon: FaImage, width: 16, height: 16, class: "decompositions-icon" }
}
span { class: "decompositions-export-label", "PNG" }
}
button {
class: "decompositions-export-btn decompositions-export-btn--svg",
title: "Download the plot as a scalable SVG vector graphic",
"aria-label": "Download as SVG vector graphic",
onclick: {
let mut panel = showing_data_download.to_owned();
move |evt| {
panel.set(false);
export_svg(evt);
}
},
span { class: "decompositions-export-icon",
Icon { icon: FaVectorSquare, width: 16, height: 16, class: "decompositions-icon" }
}
span { class: "decompositions-export-label", "SVG" }
}
button {
class: "decompositions-export-btn decompositions-export-btn--csv",
title: "Download embedding coordinates as a CSV spreadsheet",
"aria-label": "Download as CSV spreadsheet",
onclick: {
let mut panel = showing_data_download.to_owned();
move |evt| {
panel.set(false);
export_csv(evt);
}
},
span { class: "decompositions-export-icon",
Icon { icon: FaTable, width: 16, height: 16, class: "decompositions-icon" }
}
span { class: "decompositions-export-label", "CSV" }
}
button {
class: "decompositions-export-btn decompositions-export-btn--npy",
title: "Download embedding coordinates as a NumPy binary array",
"aria-label": "Download as NumPy array",
onclick: {
let mut panel = showing_data_download.to_owned();
move |evt| {
panel.set(false);
export_npy(evt);
}
},
span { class: "decompositions-export-icon",
Icon { icon: FaCube, width: 16, height: 16, class: "decompositions-icon" }
}
span { class: "decompositions-export-label", "NPY" }
}
button {
class: "decompositions-export-btn decompositions-export-btn--parquet",
title: "Download embedding as a Parquet table",
"aria-label": "Download as Parquet table",
onclick: {
let mut panel = showing_data_download.to_owned();
move |evt| {
panel.set(false);
export_parquet(evt);
}
},
span { class: "decompositions-export-icon",
Icon { icon: FaDatabase, width: 16, height: 16, class: "decompositions-icon" }
}
span { class: "decompositions-export-label", "Parquet" }
}
button {
class: "decompositions-export-btn decompositions-export-btn--arrow",
title: "Download embedding as an Arrow IPC file",
"aria-label": "Download as Arrow IPC file",
onclick: {
let mut panel = showing_data_download.to_owned();
move |evt| {
panel.set(false);
export_arrow(evt);
}
},
span { class: "decompositions-export-icon",
Icon { icon: FaBolt, width: 16, height: 16, class: "decompositions-icon" }
}
span { class: "decompositions-export-label", "Arrow" }
}
if recorded_url.read().is_some() {
button {
class: "decompositions-export-btn decompositions-export-btn--webm",
title: "Download the recorded animation as a WebM video",
"aria-label": "Download as WebM video",
onclick: {
let mut panel = showing_data_download.to_owned();
move |evt| {
panel.set(false);
download_video(evt);
}
},
span { class: "decompositions-export-icon",
Icon { icon: FaVideo, width: 16, height: 16, class: "decompositions-icon" }
}
span { class: "decompositions-export-label", "WEBM" }
}
}
}
} else {
div { class: "decompositions-panel-content",
if !(busy() && (recording_active() || recording_armed())) {
button {
id: "play",
class: if embedding_modified() && !busy() { "decompositions-iconbtn decompositions-tp-play decompositions-tp-play--modified" } else if !busy() && embedding.read().is_none() && dataset.read().is_some() { "decompositions-iconbtn decompositions-tp-play decompositions-tp-play--attention" } else { "decompositions-iconbtn decompositions-tp-play" },
title: if busy() { "Pause (Space)" } else { "Play (Space)" },
"aria-label": if busy() { "Pause (Space)" } else { "Play (Space)" },
"aria-keyshortcuts": "Space",
onclick: toggle_play,
if busy() {
Icon { icon: FaPause, width: 15, height: 15, class: "decompositions-icon" }
} else {
Icon { icon: FaPlay, width: 15, height: 15, class: "decompositions-icon" }
}
}
}
if !busy() && embedding.read().is_some() {
button {
id: "restart",
class: if embedding_modified() { "decompositions-iconbtn decompositions-tp-restart decompositions-tp-restart--modified" } else { "decompositions-iconbtn decompositions-tp-restart" },
title: HELP_RUN,
"aria-label": HELP_RUN,
onclick: restart,
Icon { icon: FaRotateLeft, width: 14, height: 14, class: "decompositions-icon" }
}
}
if recording_supported() && (!busy() || recording_active() || recording_armed()) {
button {
id: "record",
class: if recording_active() || recording_armed() { "decompositions-iconbtn decompositions-tp-rec decompositions-rec--active" } else { "decompositions-iconbtn decompositions-tp-rec" },
title: HELP_RECORD,
"aria-label": HELP_RECORD,
onclick: rec_toggle,
Icon { icon: FaVideo, width: 15, height: 15, class: "decompositions-icon" }
}
}
if !busy() && embedding.read().is_some() {
button {
id: "download-data",
class: "decompositions-iconbtn decompositions-tp-download",
title: "Download",
"aria-label": "Download",
onclick: move |_| {
let mut panel = showing_data_download;
panel.set(true);
},
Icon { icon: FaDownload, width: 14, height: 14, class: "decompositions-icon" }
}
}
div {
class: if progress().is_some() { "decompositions-scrubber decompositions-scrubber--active" } else { "decompositions-scrubber" },
if let Some((indeterminate, fraction)) = progress() {
div {
class: if indeterminate { "decompositions-scrubber-fill decompositions-scrubber-fill--indeterminate" } else { "decompositions-scrubber-fill" },
style: if indeterminate { String::new() } else { format!("width: {}%;", fraction * 100.0) },
}
}
}
if !busy() {
button {
id: "settings",
class: "decompositions-iconbtn decompositions-tp-settings",
title: HELP_SETTINGS,
"aria-label": HELP_SETTINGS,
onclick: move |_| {
let mut settings_open = settings_open;
settings_open.set(!settings_open());
},
Icon { icon: FaSliders, width: 14, height: 14, class: "decompositions-icon" }
}
}
}
}
}
// Orange callout shown while the embedding has been edited
// by hand and no fit has since caught up. Sits between the
// commands and the status line so the eye lands on it after
// seeing the play button pulse.
if embedding_modified() {
div {
class: "decompositions-modified-banner",
role: "status",
"aria-live": "polite",
Icon { icon: FaTriangleExclamation, width: 13, height: 13, class: "decompositions-icon" }
span {
"Point moved. Play to re-fit from here."
}
}
}
// Second line: the live status, each part hover-explained so a
// newcomer can learn the terms. Hidden when idle.
{
let status_str = status.read().clone();
let info = *run_info.read();
let current_phase = *phase.read();
let total = epochs();
if let Some(error) = status_str.strip_prefix("error: ") {
rsx! {
div { class: "decompositions-statusline decompositions-statusline--error",
Icon { icon: FaTriangleExclamation, width: 12, height: 12, class: "decompositions-icon" }
span { "{error}" }
}
}
} else {
match info {
Some(RunInfo::Running { epoch, elapsed_s, threads }) => {
let epoch_text = format!("epoch {epoch}/{total}");
rsx! {
div { class: "decompositions-statusline",
if let Some(ph) = current_phase {
span { class: "decompositions-statseg", title: phase_help(ph), "{ph.label()}" }
}
span { class: "decompositions-statseg", title: HELP_STATUS_EPOCH, "{epoch_text}" }
span { class: "decompositions-statseg", title: HELP_STATUS_ELAPSED, "{elapsed_s:.1}s" }
span { class: "decompositions-statseg", title: HELP_STATUS_THREADS, "{threads} {thread_word(threads)}" }
}
}
}
Some(RunInfo::Done { elapsed_s, threads, kl }) => {
rsx! {
div { class: "decompositions-statusline",
span { class: "decompositions-statseg", title: HELP_STATUS_DONE, "Done" }
span { class: "decompositions-statseg", title: HELP_STATUS_ELAPSED, "in {elapsed_s:.1}s" }
span { class: "decompositions-statseg", title: HELP_STATUS_THREADS, "{threads} {thread_word(threads)}" }
if let Some(kl) = kl {
span { class: "decompositions-statseg", title: HELP_STATUS_KL, "KL {kl:.4}" }
}
}
}
}
None => {
// Before the first snapshot: the loading or
// neighbor-search phase, or nothing when idle.
let segment = match status_str.as_str() {
"loading" => Some(("Loading the dataset".to_string(), HELP_STATUS_LOADING)),
"running" => current_phase
.map(|ph| (ph.label().to_string(), phase_help(ph))),
_ => None,
};
if let Some((text, help)) = segment {
rsx! {
div { class: "decompositions-statusline",
span { class: "decompositions-statseg", title: help, "{text}" }
}
}
} else {
rsx! {}
}
}
}
}
}
}
}
// Right settings sidebar with the tuning parameters.
if controls {
div {
id: "decompositions-sidebar",
class: if settings_open() { "decompositions-sidebar decompositions-sidebar--open" } else { "decompositions-sidebar" },
p { class: "decompositions-section-title", "Parameters" }
label { class: "decompositions-field", r#for: "perplexity", title: HELP_PERPLEXITY, "aria-label": HELP_PERPLEXITY,
span { class: "decompositions-field-label",
Icon { icon: FaCircleNodes, width: 14, height: 14, class: "decompositions-icon" }
"Perplexity"
}
input {
id: "perplexity",
r#type: "number",
min: "1",
step: "1",
value: "{perplexity}",
onchange: move |evt| {
if let Ok(value) = evt.value().parse::<f32>() {
perplexity.set(value.max(1.0));
}
},
}
}
div {
class: if dim_error.read().is_some() { "decompositions-field decompositions-field--error" } else { "decompositions-field" },
title: HELP_DIMENSION,
span { class: "decompositions-field-label",
Icon { icon: FaCube, width: 14, height: 14, class: "decompositions-icon" }
"Dimension"
}
div {
class: if dim_error.read().is_some() { "decompositions-toggle decompositions-toggle--error" } else { "decompositions-toggle" },
role: "radiogroup",
"aria-label": "Dimension",
for value in [2usize, 3, 4] {
{
let active = dimension() == value;
let try_set = try_set_display_dim.clone();
rsx! {
button {
key: "{value}",
r#type: "button",
class: if active { "decompositions-toggle-option decompositions-toggle-option--active" } else { "decompositions-toggle-option" },
role: "radio",
"aria-checked": if active { "true" } else { "false" },
title: "Show as {value}D ({value})",
"aria-label": "Show as {value}D ({value})",
"aria-keyshortcuts": "{value}",
onclick: move |_| {
dimension.set(value);
try_set(value);
},
"{value}D"
}
}
}
}
}
}
if let Some(message) = dim_error.read().as_ref() {
p {
class: "decompositions-dim-error",
role: "alert",
"{message}"
}
}
label { class: "decompositions-field", r#for: "epochs", title: HELP_EPOCHS, "aria-label": HELP_EPOCHS,
span { class: "decompositions-field-label",
Icon { icon: FaRepeat, width: 14, height: 14, class: "decompositions-icon" }
"Epochs"
}
input {
id: "epochs",
r#type: "number",
min: "1",
step: "50",
value: "{epochs}",
onchange: move |evt| {
if let Ok(value) = evt.value().parse::<usize>() {
epochs.set(value.max(1));
}
},
}
}
label { class: "decompositions-field", r#for: "learning-rate", title: HELP_LEARNING_RATE, "aria-label": HELP_LEARNING_RATE,
span { class: "decompositions-field-label",
Icon { icon: FaGaugeHigh, width: 14, height: 14, class: "decompositions-icon" }
"Learning rate"
}
input {
id: "learning-rate",
r#type: "number",
min: "1",
step: "10",
value: learning_rate().map(|v| v.to_string()).unwrap_or_default(),
placeholder: match dataset.read().as_ref() {
Some(d) => format!("auto ({:.0})", auto_learning_rate(d.n_samples)),
None => String::from("auto"),
},
onchange: move |evt| {
let text = evt.value();
if text.trim().is_empty() {
learning_rate.set(None);
} else if let Ok(value) = text.parse::<f32>() {
learning_rate.set(Some(value.max(1.0)));
}
},
}
}
label { class: "decompositions-field", r#for: "pca-dims", title: HELP_PCA_DIMS, "aria-label": HELP_PCA_DIMS,
span { class: "decompositions-field-label",
Icon { icon: FaCompress, width: 14, height: 14, class: "decompositions-icon" }
"PCA dimensions"
}
input {
id: "pca-dims",
r#type: "number",
min: "2",
value: "{pca_dims}",
onchange: move |evt| {
if let Ok(dims) = evt.value().parse::<usize>() {
pca_dims.set(dims.max(2));
}
},
}
}
label { class: "decompositions-field", r#for: "early-exaggeration", title: HELP_EARLY_EXAGGERATION, "aria-label": HELP_EARLY_EXAGGERATION,
span { class: "decompositions-field-label",
Icon { icon: FaExpand, width: 14, height: 14, class: "decompositions-icon" }
"Early exaggeration"
}
input {
id: "early-exaggeration",
r#type: "number",
min: "1",
step: "1",
value: "{early_exaggeration}",
onchange: move |evt| {
if let Ok(value) = evt.value().parse::<f32>() {
early_exaggeration.set(value.max(1.0));
}
},
}
}
label { class: "decompositions-field", r#for: "exaggeration-epochs", title: HELP_EXAGGERATION_EPOCHS, "aria-label": HELP_EXAGGERATION_EPOCHS,
span { class: "decompositions-field-label",
Icon { icon: FaFire, width: 14, height: 14, class: "decompositions-icon" }
"Exaggeration epochs"
}
input {
id: "exaggeration-epochs",
r#type: "number",
min: "0",
step: "10",
value: "{exaggeration_epochs}",
onchange: move |evt| {
if let Ok(value) = evt.value().parse::<usize>() {
exaggeration_epochs.set(value);
}
},
}
}
if has_labels() {
p { class: "decompositions-section-title", "Color" }
label { class: "decompositions-field", r#for: "color-source", title: HELP_COLOR_BY, "aria-label": HELP_COLOR_BY,
span { class: "decompositions-field-label",
Icon { icon: FaPalette, width: 14, height: 14, class: "decompositions-icon" }
"Color by"
}
select {
id: "color-source",
class: "decompositions-select",
value: "{color_source}",
onmounted: move |evt| {
color_select.set(
evt.data()
.downcast::<web_sys::Element>()
.and_then(|element| {
element.clone().dyn_into::<web_sys::HtmlSelectElement>().ok()
}),
);
},
onchange: move |evt| color_source.set(evt.value()),
option { value: "none", "no color" }
for column in effective_labels.read().iter() {
option { value: "column:{column.name}", "{column.name}" }
}
}
}
label { class: "decompositions-field", r#for: "legend-export", title: HELP_LEGEND_EXPORT, "aria-label": HELP_LEGEND_EXPORT,
span { class: "decompositions-field-label",
Icon { icon: FaImage, width: 14, height: 14, class: "decompositions-icon" }
"Legend in snapshot"
}
input {
id: "legend-export",
r#type: "checkbox",
checked: legend_in_export(),
onchange: move |evt| legend_in_export.set(evt.checked()),
}
}
}
if sheet_state.read().names.len() > 1 {
p {
class: "decompositions-section-title",
title: HELP_SHEET,
"aria-label": HELP_SHEET,
"Sheet"
}
{
let load = load.clone();
rsx! {
select {
class: "decompositions-sheet-select",
title: HELP_SHEET,
"aria-label": HELP_SHEET,
value: "{sheet_state.read().active}",
onchange: move |evt| {
let Ok(index) = evt.value().parse::<usize>() else {
return;
};
if index == sheet_state.read().active {
return;
}
// Re-parse the chosen worksheet from the
// retained bytes, paused (Play runs it).
let file = workbook_file.read().clone();
if let Some((name, bytes)) = file {
load(name, bytes, None, index);
}
},
for (index, name) in sheet_state.read().names.iter().enumerate() {
option { value: "{index}", "{name}" }
}
}
}
}
}
if !columns.read().is_empty() {
{
let cols = columns.read();
let features = cols.iter().filter(|c| c.role == ColumnRole::Feature).count();
let labels = cols.iter().filter(|c| c.role == ColumnRole::Label).count();
let counts = format!(
"{features} {} · {labels} {}",
if features == 1 { "feature" } else { "features" },
if labels == 1 { "label" } else { "labels" },
);
drop(cols);
rsx! {
p {
class: "decompositions-section-title",
title: HELP_COLUMNS,
"aria-label": HELP_COLUMNS,
"Columns "
span { class: "decompositions-section-count", "{counts}" }
}
}
}
div { class: "decompositions-columns",
for (index, column) in columns.read().iter().enumerate() {
div { class: "decompositions-column-row",
span { class: "decompositions-column-label",
match column.role {
ColumnRole::Feature => rsx! {
Icon { icon: FaHashtag, width: 11, height: 11, class: "decompositions-icon decompositions-column-roleicon", title: "Feature (fed into t-SNE)" }
},
ColumnRole::Label => rsx! {
Icon { icon: FaTag, width: 11, height: 11, class: "decompositions-icon decompositions-column-roleicon", title: "Label (color only)" }
},
ColumnRole::Ignore => rsx! {
Icon { icon: FaBan, width: 11, height: 11, class: "decompositions-icon decompositions-column-roleicon", title: "Ignored" }
},
}
span {
class: "decompositions-column-name",
title: "{column.name}",
"{column.name}"
}
}
select {
title: HELP_COLUMNS,
"aria-label": HELP_COLUMNS,
value: match column.role {
ColumnRole::Feature => "feature",
ColumnRole::Label => "label",
ColumnRole::Ignore => "ignore",
},
onchange: move |evt| {
let role = match evt.value().as_str() {
"feature" => ColumnRole::Feature,
"label" => ColumnRole::Label,
_ => ColumnRole::Ignore,
};
columns.write()[index].role = role;
},
if column.numeric {
option { value: "feature", "Feature" }
}
option { value: "label", "Label" }
option { value: "ignore", "Ignore" }
}
}
}
}
}
}
}
// In-app "About t-SNE" overlay, tabbed into the four sections
// below so the box stays scanable at a larger canvas.
if about_open() {
{
let active = about_tab();
rsx! {
div {
class: "decompositions-about-backdrop",
onclick: move |_| {
let mut about_open = about_open;
about_open.set(false);
},
div {
class: "decompositions-about",
role: "dialog",
"aria-modal": "true",
onclick: move |evt| evt.stop_propagation(),
button {
class: "decompositions-about-close",
title: "Close",
"aria-label": "Close",
onclick: move |_| {
let mut about_open = about_open;
about_open.set(false);
},
Icon { icon: FaXmark, width: 16, height: 16, class: "decompositions-icon" }
}
h2 { "t-SNE" }
p { class: "decompositions-about-sub", "t-distributed Stochastic Neighbor Embedding" }
div {
class: "decompositions-about-tabs",
role: "tablist",
"aria-label": "About t-SNE sections",
for (tab, label) in [
(AboutTab::Overview, "Overview"),
(AboutTab::Read, "How to read"),
(AboutTab::Controls, "Controls"),
(AboutTab::Rust, "Under the hood"),
] {
{
let selected = active == tab;
rsx! {
button {
key: "{label}",
r#type: "button",
role: "tab",
"aria-selected": if selected { "true" } else { "false" },
class: if selected { "decompositions-about-tab decompositions-about-tab--active" } else { "decompositions-about-tab" },
onclick: move |_| {
let mut about_tab = about_tab;
if about_tab() == tab {
return;
}
let mut pending_about_start = pending_about_start;
if let Some(modal) = about_modal_element() {
let start = f64::from(modal.offset_height());
let style = modal.style();
let _ = style.set_property("transition", "none");
let _ = style
.set_property("height", &format!("{start}px"));
pending_about_start.set(Some(start));
}
about_tab.set(tab);
},
"{label}"
}
}
}
}
}
div {
class: "decompositions-about-tabpanel",
role: "tabpanel",
if active == AboutTab::Overview {
h3 { "What it is" }
p {
"t-SNE is a nonlinear dimensionality-reduction method for visualizing "
"high-dimensional data in two dimensions. It places each point so that "
"points near each other in the original space stay near each other in "
"the picture, which makes local structure and clusters easy to see "
a {
href: "https://www.jmlr.org/papers/v9/vandermaaten08a.html",
target: "_blank",
rel: "noopener",
"(van der Maaten & Hinton, 2008)"
}
"."
}
p {
"This tool runs Barnes-Hut t-SNE "
a {
href: "https://www.jmlr.org/papers/v15/vandermaaten14a.html",
target: "_blank",
rel: "noopener",
"(van der Maaten, 2014)"
}
", an approximation that scales to tens of thousands of points, "
"entirely in your browser on a background worker. The input is first "
"reduced with PCA (30 dimensions by default) to speed up the neighbor "
"search and cut noise, then t-SNE produces the layout you watch evolve. "
"The embedding is initialized from the top eigenvectors of the "
"affinity graph's normalized Laplacian (a spectral embedding) rather "
"than from random noise, which preserves the global layout of the data "
"and makes runs reproducible "
a {
href: "https://doi.org/10.1038/s41467-019-13056-x",
target: "_blank",
rel: "noopener",
"(Kobak & Berens, 2019)"
}
" "
a {
href: "https://doi.org/10.1038/s41587-020-00809-z",
target: "_blank",
rel: "noopener",
"(Kobak & Linderman, 2021)"
}
"."
}
h3 { "When to use it" }
p {
"Reach for t-SNE when you want to explore high-dimensional data and ask "
"whether it has structure and what clusters together. Common inputs are "
"learned embeddings, image or text feature vectors, single-cell gene "
"expression, and any table of numeric features per sample. It is an "
"exploratory and presentation tool, not a preprocessing step for "
"downstream models."
}
}
if active == AboutTab::Read {
h3 { "How to read it (and what not to read into it)" }
p {
"t-SNE maps are powerful but easy to over-interpret. The caveats below "
"are drawn from "
a {
href: "https://distill.pub/2016/misread-tsne/",
target: "_blank",
rel: "noopener",
"(Wattenberg et al., 2016)"
}
":"
}
ul {
li {
b { "Perplexity matters. " }
"It sets roughly how many neighbors each point considers, and "
"different values give different pictures. 5 to 50 is typical."
}
li {
b { "Cluster sizes are not meaningful. " }
"t-SNE expands dense clusters and contracts sparse ones, so a blob's "
"area says little about how spread out that group really is."
}
li {
b { "Distances between clusters are often not meaningful. " }
"Treat the global arrangement with caution."
}
li {
b { "Let it converge. " }
"Stopping early leaves a half-formed layout. Run enough epochs, or "
"use \"run forever\" and watch."
}
li {
b { "Runs vary. " }
"The optimization is stochastic, so the stable signal is the cluster "
"structure, not the exact positions."
}
}
p {
"The settings panel exposes the knobs that drive all of this: "
"perplexity, epochs, learning rate, PCA dimensions, and the "
"early-exaggeration phase."
}
}
if active == AboutTab::Controls {
h3 { "Global shortcuts" }
p {
"These work anywhere on the page, regardless of which display "
"dimensionality is currently visible."
}
dl { class: "decompositions-shortcuts",
div { class: "decompositions-shortcut",
dt { kbd { "Space" } }
dd { "Play the fit if it is paused or has not started yet, pause it if it is running. Ignored before a dataset is loaded." }
}
div { class: "decompositions-shortcut",
dt { kbd { "Esc" } }
dd { "Clear the current multi-point selection if one is up. With no selection, drop the loaded dataset and go back to the drop zone." }
}
}
h3 { "Display dimensionality" }
p {
"Switching the display never touches the computation: the running fit "
"and the stored embedding keep whatever dimensionality they started with. "
"If the requested display is higher than the current embedding, the "
"Dimension toggle flashes red and an inline banner explains why."
}
dl { class: "decompositions-shortcuts",
div { class: "decompositions-shortcut",
dt { kbd { "2" } }
dd { "Flatten to a 2-D scatter. Draggable points and per-class marker shapes come back at this dimensionality." }
}
div { class: "decompositions-shortcut",
dt { kbd { "3" } }
dd { "Show the 3-D orbit view. Pointer drag rotates, right drag pans, wheel zooms." }
}
div { class: "decompositions-shortcut",
dt { kbd { "4" } }
dd { "Same 3-D orbit view, plus the ZW rotation the W key drives, so the 4th axis of a 4-D embedding rotates into the visible three." }
}
}
h3 { "Rotation keys" }
p {
"Hold a letter to spin the world about that axis at a constant rate. "
"Holding two keys inclines the rotation between their axes; holding "
"three at once traces a curve around the (1, 1, 1) diagonal, and adding "
"the fourth key drops the extra dimension into the mix. The rotation "
"stays consistent across display switches, so changing your view of a "
"3-D or 4-D embedding preserves whatever orientation you had set."
}
dl { class: "decompositions-shortcuts",
div { class: "decompositions-shortcut",
dt { kbd { "X" } }
dd { "Pitch: rotate the world around its X axis." }
}
div { class: "decompositions-shortcut",
dt { kbd { "Y" } }
dd { "Yaw: rotate the world around its Y axis." }
}
div { class: "decompositions-shortcut",
dt { kbd { "Z" } }
dd { "Roll: rotate the world around its Z axis." }
}
div { class: "decompositions-shortcut",
dt { kbd { "W" } }
dd { "4-D rotation in the ZW plane. Only visibly changes anything when the current embedding is 4-D." }
}
}
h3 { "Pointer in the 2-D scatter" }
p {
"The 2-D scatter is a selection and hand-edit surface. Empty space "
"starts a rubber-band drag, points move individually, and the current "
"selection moves as a group. The cursor advertises the current mode: "
"crosshair for a fresh selection, a green plus for adding to the "
"selection, a red minus for removing, and the closed-hand grip while a "
"drag is in flight. Any hand edit switches the reminder on until the "
"optimizer catches up."
}
dl { class: "decompositions-shortcuts",
div { class: "decompositions-shortcut",
dt {
kbd { "Left" }
" drag"
}
dd { "On empty space, draw a rubber-band and replace the current selection with the points it encloses. On a point that is already selected, translate the whole selection together. On any other point, translate that one point." }
}
div { class: "decompositions-shortcut",
dt {
kbd { "Shift" }
" + "
kbd { "Left" }
" drag"
}
dd { "Add the points enclosed by the drag to the current selection." }
}
div { class: "decompositions-shortcut",
dt {
kbd { "Alt" }
" + "
kbd { "Left" }
" drag"
}
dd { "Remove the points enclosed by the drag from the current selection." }
}
div { class: "decompositions-shortcut",
dt { "Click on empty space" }
dd { "Clear the current selection so every point is colored again." }
}
}
h3 { "Pointer in the 3-D scatter" }
p {
"The 3-D and 4-D scatters do not accept point selection or hand-editing. "
"The whole canvas is an orbit / pan / zoom control. The cursor stays the "
"open hand until a drag begins, then the closed hand while orbiting or "
"panning."
}
dl { class: "decompositions-shortcuts",
div { class: "decompositions-shortcut",
dt {
kbd { "Left" }
" or "
kbd { "Middle" }
" drag"
}
dd { "Orbit the camera: horizontal is yaw, vertical is pitch, both applied on top of any rotation the keys have set." }
}
div { class: "decompositions-shortcut",
dt {
kbd { "Right" }
" drag"
}
dd { "Pan the view without rotating." }
}
div { class: "decompositions-shortcut",
dt { kbd { "Wheel" } }
dd { "Zoom in and out around the current viewpoint." }
}
}
}
if active == AboutTab::Rust {
h3 { "Built in Rust" }
p {
"This whole tool is "
a { href: "https://www.rust-lang.org/what/wasm", target: "_blank", rel: "noopener", "Rust compiled to WebAssembly" }
", served as static files with no backend. The interface (rendered by "
a { href: "https://dioxuslabs.com", target: "_blank", rel: "noopener", "Dioxus" }
"), the file parsing, and "
a { href: "https://github.com/frjnn/bhtsne", target: "_blank", rel: "noopener", "t-SNE" }
" itself all run in your browser, so the data you load never leaves "
"your machine."
}
p {
"t-SNE runs across all of your CPU cores at once with "
a { href: "https://github.com/rayon-rs/rayon", target: "_blank", rel: "noopener", "Rayon" }
", which works in the browser through "
a { href: "https://github.com/RReverser/wasm-bindgen-rayon", target: "_blank", rel: "noopener", "wasm-bindgen-rayon" }
" once the page is cross-origin isolated (the "
a { href: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cross-Origin-Opener-Policy", target: "_blank", rel: "noopener", "COOP" }
" and "
a { href: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cross-Origin-Embedder-Policy", target: "_blank", rel: "noopener", "COEP" }
" headers)."
}
p {
"Modern web development will be written in Rust. "
a { href: "https://xkcd.com/2314/", target: "_blank", rel: "noopener", "Carcinization advances." }
}
}
}
}
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_leaves_every_feature_off() {
let decomposition = Decomposition::new();
assert!(decomposition.dataset.is_none());
assert!(decomposition.drop_zone.is_none());
assert!(decomposition.examples.is_empty());
assert!(!decomposition.controls);
assert!(!decomposition.draggable);
assert!(decomposition.styled);
}
#[test]
fn full_enables_loader_controls_and_dragging() {
let decomposition = Decomposition::full();
assert!(decomposition.drop_zone.is_some());
assert!(decomposition.controls);
assert!(decomposition.draggable);
}
#[test]
fn worker_url_defaults_and_overrides() {
assert_eq!(Decomposition::new().worker_url, DEFAULT_WORKER_URL);
let custom = Decomposition::new().worker_url("/custom/worker.js");
assert_eq!(custom.worker_url, "/custom/worker.js");
}
#[test]
fn dataset_and_labels_preload_a_colored_dataset() {
let decomposition = Decomposition::new()
.dataset(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], 3, 2)
.labels("group", vec!["a".into(), "b".into(), "a".into()]);
let dataset = decomposition.dataset.as_ref().expect("a preset dataset");
assert_eq!(dataset.n_samples, 3);
assert_eq!(dataset.n_features, 2);
assert_eq!(dataset.data.len(), 6);
assert_eq!(dataset.label_columns.len(), 1);
assert_eq!(dataset.label_columns[0].name, "group");
assert_eq!(dataset.label_columns[0].values, ["a", "b", "a"]);
}
#[test]
fn multiple_label_columns_are_kept_in_order() {
let decomposition = Decomposition::new()
.dataset(vec![0.0; 4], 2, 2)
.labels("first", vec!["x".into(), "y".into()])
.labels("second", vec!["1".into(), "2".into()]);
let names: Vec<&str> = decomposition
.dataset
.as_ref()
.unwrap()
.label_columns
.iter()
.map(|c| c.name.as_str())
.collect();
assert_eq!(names, ["first", "second"]);
}
#[test]
fn labels_without_a_dataset_is_a_noop() {
let decomposition = Decomposition::new().labels("group", vec!["a".into()]);
assert!(decomposition.dataset.is_none());
}
#[test]
fn drop_zone_accepts_normalize_extensions() {
let zone = DropZone::new().accept([".CSV", "Parquet"]);
assert!(zone.allows("csv"));
assert!(zone.allows("parquet"));
assert!(!zone.allows("tsv"));
}
}
Workflows from the Neura Market marketplace related to this Perplexity resource