Dioxus Tsne Perplexity Rules — Free Perplexity Rules…
    Neura MarketNeura Market/Perplexity
    ChatGPTChatGPTClaudeClaudeGeminiGeminiCursorCursorGrokGrokPerplexityPerplexityDeepSeekDeepSeek
    CoPilotCoPilotStable DiffusionStable DiffusionMidjourneyMidjourney
    View All Directories
    OverviewRulesPromptsMCPsAgentsGamesBlogVideosGuidesCoursesCommunityTrending
    PerplexityRulesDioxus Tsne Perplexity Rules
    Back to Rules

    Dioxus Tsne Perplexity Rules

    LucaCappelletti94 July 19, 2026
    0 copies 0 downloads
    Rule Content
    //! 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"));
        }
    }
    

    Comments

    More Rules

    View all

    Textbased Predictor Perplexity Rules

    H
    Havcker243

    Intelligent Team Building Recommendation System Perplexity Rules

    R
    royswastik

    Pita Perplexity Rules

    S
    SaratBobbili

    Eskrev Perplexity Rules

    R
    rfmss

    Founder Control Room Perplexity Rules

    J
    jussray

    N8nWorkflows Perplexity Rules

    S
    Samarth-ITM

    Stay up to date

    Get the latest Perplexity prompts, rules, and resources delivered to your inbox weekly.

    Neura Market LogoNeura Market

    Discover the best AI prompts, plugins, and resources for Perplexity and more.

    Content Types

    • Rules
    • Prompts
    • MCPs
    • Agents
    • Guides

    Platforms

    • ChatGPT Directory
    • Claude Directory
    • Gemini Directory
    • Cursor Directory
    • Grok Directory
    • Perplexity Directory
    • DeepSeek Directory
    • CoPilot Directory
    • Stable Diffusion Directory
    • Midjourney Directory
    • All Directories

    Resources

    • Blog
    • Documentation
    • Help Center
    • Marketplace

    Legal

    • Privacy Policy
    • Terms of Service

    © 2026 Neura Market. All rights reserved.

    |

    Not affiliated with any AI platform vendors.

    Neura Market

    Custom AI Systems & Services

    Our team of experienced AI builders will help build custom AI systems, workflows, and solutions for your business.

    Request custom work

    Ready-made automations for this

    Workflows from the Neura Market marketplace related to this Perplexity resource

    • Automated Lead Generation & Contact Enrichment with Hunter.io and Perplexity AIn8n · $24.99 · Related topic
    • Automate SEO-Optimized Blog Creation with GPT-4, Perplexity AI & Multi-Language Supportn8n · $24.99 · Related topic
    • Automate SEO Blog Content Creation with GPT-4, Perplexity AI, and WordPressn8n · $24.99 · Related topic
    • Automate SEO Blog Creation + Social Media with GPT-4, Perplexity, and WordPressn8n · $24.99 · Related topic
    Browse all workflows