diff --git a/.gitignore b/.gitignore index edca00c..f7267df 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ /node_modules/ /web/dist/ /web/node_modules/ -/web/public/examples/Col-CEN_v1.2.fasta /web/src/wasm/ /web/playwright-report/ /web/test-results/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2c2299e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,826 @@ +# Changelog + +> **Note:** This project was generated using ChatGPT 5.6 Sol (ie. vibe-coded) under the direction of Adam Phillippy and Alex Sweeten. For analyses intended for publication, please use the official [ModDotPlot CLI](https://github.com/marbl/ModDotPlot) and cite the [ModDotPlot paper](https://doi.org/10.1093/bioinformatics/btae493). + +All notable changes to ModDotPlot Browser are recorded here. The project follows +[Semantic Versioning](https://semver.org/) from the first public tag. + +## [Unreleased] + +## [1.0.2] - 2026-10-11 + +### Changed + +* Replaced **Export ModDotPlot command** with **Export ModDotPlot config**. It now + saves one `moddotplot_config.json` file and no longer builds BEDPE or ZIP output. +* Simplified one-base exact mode to its useful base-colored view and removed the + identity/base view selector. +* Added always-on coordinate-linked highlighting for computed and imported X and Y + feature tracks. +* Reduced the palette menu to the requested sequential, qualitative, and diverging + ColorBrewer choices while retaining the featured Spectral and Viridis palettes. +* Compressed the palette color editor to its content height so it no longer fills the + plot vertically. + +## [1.0.1] - 2026-10-11 + +### Added + +* Added an **Insert URL** field beneath the landing drop zone for HTTPS FASTA and + FASTA.gz sources. Remote responses and redirects stream directly through the + browser worker's existing parser and decompressor, with no application-server proxy. +* Added automatic remote FAI/GZI discovery and CORS-safe HTTP range loading. Indexed + URL sources open from their small companion files and stream only selected records, + avoiding an eager full download of multi-gigabyte assemblies such as HG002. +* Added a keyboard ruler for interactive plots. Press `R` over the plot to anchor a + cross ruler, move the pointer to read signed X/Y genomic distances from it, and + press `R` again to return to absolute coordinates. +* Added an opt-in regression test using the official CHM13 v2.0 RefSeq Liftoff v5.3 + annotation across all 24 chromosomes. + +### Changed + +* Removed the cached Col-CEN FASTA, annotation, and precomputed overview assets. The + existing *Arabidopsis thaliana* example button now streams the Col-CEN v1.2 FASTA + from the Schatz lab GitHub release through the same URL-import pipeline. +* Merged the landing-page FASTA and annotation targets into one drop zone that routes + FASTA/index files and BED, GFF3, or GTF annotations automatically. +* Restored Viridis on a black background as the initial plot appearance. +* Rotated grid-plot Y-axis sequence labels by 90 degrees to leave more room for plots. +* Replaced the X- and Y-feature-track placement menus with two-option toggle controls. + +### Fixed + +* Count retained logical features, rather than every exon/CDS/UTR source row, against + the one-million-feature annotation limit. Large genome annotations such as the + 4,007,856-row CHM13 file now import across every matching chromosome. +* Preserve parser errors raised while consuming compressed annotations instead of + incorrectly reporting them as gzip/BGZF decompression failures. +* Allow generic `.gz` and `.bgz` suffixes in the in-plot **Add track** chooser, so + browsers that match only the final extension can select compressed GFF annotations. + +## [0.9.8] - 2026-10-03 + +### Changed + +* Split the FASTA compatibility and optional-index guidance across two landing-page + lines, and corrected `indixes` to `indices`. +* Replaced the landing-page publication warning with a privacy explanation, a link to + the ModDotPlot publication, and guidance linking to the official CLI. + +### Fixed + +* Run Firefox end-to-end coverage against an Xvfb display on Linux CI and explicitly + enable its software WebGL2 backend. This prevents renderer startup from collapsing + the landing page and turning the Firefox suite into repeated locator timeouts. +* Preserve the WebGL back buffer only inside the exact-render pixel test so Linux + WebKit screenshots measure the rendered categories instead of a discarded black + buffer. + +## [0.9.7] - 2026-10-03 + +### Added + +* Added an explicit Open Graph and large-card social preview using the bundled + `browser_1.png` artwork, including its dimensions and accessible description. +* Added **Export ModDotPlot command** to grid mode. The ZIP contains one BEDPE for + every selected self plot and unique pairwise plot, plus a config that lists all + companion files and records a runnable `moddotplot --grid-only -c … -l …` command. + +### Changed + +* Report one grid-wide plot window size from the largest selected sequence and the + shared 512-cell overview scale, with the same genomic-interval tooltip used by + interactive plots. + +## [0.9.6] - 2026-10-03 + +### Added + +* Added native one-finger plot panning and two-finger pinch zooming for touch screens. + Pinching follows the moving midpoint while retaining the same 100-bp zoom floor and + background-refinement behavior as desktop navigation. + +### Changed + +* Constrained the animated landing logo to the FASTA drop area's width on screens up to + 760 pixels, keeping it centered without changing its 575-pixel desktop size. + +## [0.9.5] - 2026-10-02 + +### Fixed + +* Download **Export ModDotPlot command** as one ZIP package so browser multi-download + protection cannot discard either artifact. The package contains both the current-view + BEDPE and its companion JSON config. +* Configure the official CLI with `"load": ["./.bedpe"]` instead of a source + `fasta` entry, allowing the extracted config to load the exported BEDPE directly. + +## [0.9.4] - 2026-10-02 + +### Added + +* Added deterministic static 2,000-by-2,000 and 4,000-by-4,000 refined Chr1 detail + levels for the bundled *Arabidopsis thaliana* example. They load in the background + after first paint and serve the first two zoom levels without FASTA range reads or + browser-side sketch construction. +* Added a focusable gray help marker beside **Plot window size**. Its tooltip reports + the nearest whole number of genomic bases represented by each current plot pixel. + +### Changed + +* Count only nonzero rows that will actually be written when enforcing the two-million + row numeric-export safety limit. Large sparse renderer views no longer fail merely + because their rectangular bounds exceed the limit. +* Renamed **Export data** to **Export ModDotPlot command** and fixed its tabular output + to BEDPE. Each action saves the BEDPE plus its companion JSON config, whose metadata + records the runnable `moddotplot -c name.config.json -l name.bedpe` command. +* Moved **Plot window size** from Advanced options into the primary controls directly + below the identity color bar. + +## [0.9.3] - 2026-10-02 + +### Added + +* Added GitHub marks beside both landing-page repository links and a visible publication + warning directing publication work to the official ModDotPlot CLI. +* Added a second JSON artifact to every numeric export. It is accepted by the official + ModDotPlot develop config loader and maps the selected FASTA records, visible regions, + cell size, k-mer length, identity range, exact colors, direction mode, and comparison + layout for recomputation with `moddotplot -c config.json`. + +### Changed + +* Preserve sub-CLI-resolution browser zoom in JSON metadata while clamping the executable + CLI window to its supported minimum and k-mer length. + +## [0.9.2] - 2026-10-02 + +### Added + +* Added direct GitHub links for the original ModDotPlot project and ModDotPlot Browser + beside the bundled example action on the landing page. + +### Changed + +* Corrected and italicized the bundled example species name as *Arabidopsis thaliana*. + +## [0.9.1] - 2026-09-30 + +### Added + +* Added a deterministic, gzip-compressed precomputed default overview for the bundled + *Arabidopsis thaliana* Chr1 self plot. The approximately 256 KB artifact carries a + complete refined 1,000-by-1,000 matrix with identity, direction, and support channels. +* Added a same-origin HTTP byte-range source for the bundled raw FASTA. Uncached + interactions can stream only their indexed sequence ranges instead of downloading + the complete genome. +* Added a reproducible overview generator and a versioned artifact decoder with strict + schema, payload, tile-coverage, source-manifest, and scientific-configuration checks. + +### Changed + +* Made the precomputed Chr1 overview the bundled example's initial view. Raw FASTA + ranges are requested only when an interaction cannot be served by the precomputed or + subsequently cached data. +* Made overview loading compatible with static servers such as Vite that advertise the + `.gz` asset with `Content-Encoding: gzip`. Startup now accepts either the exact + compressed artifact or its exact browser-decoded payload, with independent pinned + sizes and SHA-256 checks for both representations. +* Bound lazy FASTA probe and range fetches to the active worker global, fixing the + WebKit/Safari `WorkerGlobalScope.fetch` receiver error after precomputed startup. +* Removed the bundled CEN180 annotation from the startup fetch set. Its retrieval and + parsing begin separately after the precomputed workspace opens and do not gate the + default plot. + +## [0.9.0] - 2026-09-30 + +### Changed + +* Added the selected X and Y sequence names to plot hover readouts before their + genomic coordinate intervals. Self plots therefore name the same sequence on both + axes, while pairwise plots name the two records being compared. +* Required Pairwise mode to use two different records. A newly opened Pairwise plot + selects two distinct records by default; choosing the other axis's record moves that + axis automatically so the pair remains distinct while its orientation can be swapped. +* Rendered exact cells without supporting k-mer evidence with the selected plot + background instead of a separate gray fill. The obsolete bottom-left exact-state + key is removed; the **Base** color legend remains available for A/C/G/T matches. +* Limited interactive zoom to a minimum visible span of 100 bp per axis, preventing + navigation closer than a 100-by-100-bp viewport when both sequences are at least + that long. + +## [0.8.9] - 2026-09-30 + +### Changed + +* Made the bundled *Arabidopsis thaliana* example's + `Col-CEN_v1.2.fasta.fai` companion explicit in the example-loading path and added + percentage progress while the bundled FASTA, FAI, and GFF3 assets are retrieved. +* Consolidated per-chromosome instances of the same imported logical annotation track + into one feature-track row. The row automatically uses the records matching the + sequence on each displayed axis instead of adding a duplicate entry for every + chromosome. + +## [0.8.8] - 2026-09-30 + +### Added + +* Added a one-click bundled *Arabidopsis thaliana* example on the landing page. The + example opens `Col-CEN_v1.2.fasta` together with its FAI index and the + `ColCEN_CEN180.gff3` annotation, then enters the plot workspace without requiring + the user to locate local files. +* Added pointer-following horizontal and vertical plot guides, shown as dotted lines + by default. Advanced options can hide the guides, change them to crossing diagonals, + or select dotted, dashed, or solid strokes. + +### Changed + +* Clarified the landing-page companion-file hint to identify optional FAI/GZI indexes + as the faster loading path. + +## [0.8.7] - 2026-09-29 + +### Added + +* Added an optional indexed-FASTA path for large inputs. A plain FASTA with a matching + `.fai`, or a BGZF FASTA with matching `.fai` and `.gzi` files, publishes record + metadata from the indexes and loads a selected record only when a plot needs it. +* Added strict FAI/GZI parsing and bounded indexed record streaming, including index, + range, truncation, and BGZF checkpoint validation. +* Added transactional indexed-record finalization. Lazy appends are committed only + after one parsed record matches the FAI name and length; cancellation or mismatch + rolls back the attempted record and its retained-memory accounting. + +### Changed + +* Replaced the ambiguous landing status **Checking** with **Reading and validating** + and a visible percentage while eager sequence input is read. +* Reused one bounded sequence-ingestion buffer across streamed shards and avoided + redundant ordinary ArrayBuffer and multi-chunk BGZF output copies. +* Compacted completed sequence storage only when unused capacity is both at least + 1 MiB and at least 25% of used storage, avoiding routine shrink-to-fit copies while + retaining large-slack reclamation. +* Maintained the retained packed-sequence memory total incrementally across append, + rollback, and clear instead of rescanning every loaded record for each estimate. +* Unrolled bulk sequence content-identity updates eight bases at a time while + preserving the established identity bytes exactly. +* Deferred annotations staged on the landing page until the first interactive plot + tile is available, so annotation parsing no longer delays initial sequence display. +* Kept incomplete indexed-input combinations and ordinary gzip FASTA on the existing + transactional eager-read path. Orphan `.fai` or `.gzi` files are rejected instead of + being interpreted as sequence input. + +## [0.8.6] - 2026-09-29 + +### Changed + +* Made **Self plot** the initial workflow whenever more than one sequence is loaded. +* Made **Bases** the initial terminal exact visualization and shortened its categorical + legend title from **X-axis base** to **Base**. +* Standardized the user-facing product name as **ModDotPlot Browser** while retaining + `moddotplot-interactive` for technical package, repository, and archive identifiers. + +### Removed + +* Removed the terminal exact geometry selector and **Show direction** shortcut from + the interface. Terminal exact plots now always use complete k-mer footprints; + orientation remains available through the main color-mode control. +* Removed the landing-page claims about being vibe-coded, files never leaving the + browser, and suitability for human-sized genomes. + +## [0.8.5] - 2026-09-29 + +### Added + +* Added explicit **Identity** and **Bases** exact-view choices. Identity is the new + default and follows the selected similarity palette; Bases retains the prior + A/C/G/T coloring for direct comparison with v0.8.4. +* Added **Full footprints** and **K-mer anchors** exact-geometry choices. Full + footprints preserve existing behavior, while anchors draw one cell per matching + canonical k-mer start pair. +* Added an exact-mode explanation, categorical base legend, missing-evidence legend, + and a shortcut to orientation coloring. +* Added categorical image-export legends for exact identity, base, and orientation + views, including explicit mismatch and no-k-mer-evidence states. +* Added numerical repeat-rich, tile-seam, even-k palindrome, and browser rendering + regression coverage for terminal exact plots. + +### Changed + +* Render cells without supporting exact k-mer evidence separately from valid + below-threshold or zero-identity cells. +* Hardened exact texture lookup with integer texel coordinates and sized the WebGL + backing buffer from physical-pixel ResizeObserver measurements where available. + +### Fixed + +* Defined even-k reverse-complement-palindrome footprints as orientation-ambiguous + evidence instead of silently painting only the forward geometry. + +## [0.8.4] - 2026-09-29 + +### Changed + +* Put every grid preview on one genomic X/Y scale defined by the longest selected + sequence. Shorter sequences now occupy only their proportional width and height, + leaving unsupported space blank instead of stretching each self plot to fill its + cell. +* Aligned pairwise preview footprints with the proportional self plots in their rows + and columns while preserving reciprocal-cell transposition and displayed X/Y order. + +## [0.8.3] - 2026-09-29 + +### Added + +* Added pointer and keyboard reordering from either grid axis. Reordering changes the + shared record order on both axes and rearranges the completed overview without + recomputing its comparisons. + +### Changed + +* Arranged grid self plots on the bottom-left-to-top-right diagonal, with X labels + ordered left to right and the same sequence order running from bottom to top on Y. +* Simplified **Plot window size** to one nearest-whole-base value per cell instead of + displaying the floor and ceiling of uneven partitions as a range. + +### Fixed + +* Corrected reciprocal grid cells so each off-diagonal preview is the transpose of its + paired cell and its opened interactive plot preserves the displayed X/Y ordering. + +## [0.8.2] - 2026-09-29 + +### Added + +* Added a read-only **Plot window size** value under Advanced options that reports the + genomic interval represented by each matrix cell. + +### Changed + +* Renamed **Plot detail** to **Plot resolution** in Advanced options to distinguish the + matrix dimensions from the genomic span represented by a cell. + +### Fixed + +* Kept the plot-header **Clear** control separated from the plot when the browser window + is narrowed or resized. + +## [0.8.1] - 2026-09-28 + +### Added + +* Added a compact palette-direction button beside the discrete color-count control. +* Added one editable color swatch per active palette class. Each swatch opens an editor + with a browser-native color control, editable hexadecimal code, and reset action; + edits are shared by the plot, grid previews, image exports, legends, and display + provenance. + +### Changed + +* Reversed the default 11-class Spectral palette so lower displayed identity begins at + purple and higher identity reaches red. Other palettes retain their canonical default + direction. +* Reordered the palette selector into dedicated Spectral and Viridis groups followed by + the remaining ColorBrewer Sequential, Diverging, and Qualitative groups. Viridis is + presented as a separately sourced application palette rather than as ColorBrewer. +* Reduced the width of the discrete color-count input so the direction control occupies + the other half of the same control row. + +## [0.8.0] - 2026-09-28 + +### Added + +* Added **Back to grid** to interactive comparisons opened from a grid cell, preserving + the grid's selected records and completed overview for return navigation. +* Added the ColorBrewer sequential, diverging, and qualitative palette families, with + a scheme-specific discrete color-count control. +* Added separate FASTA and annotation drop targets to the launch screen. A green + **Explore** action appears after sequence input is viewable and explicitly starts the + plot workspace. + +### Changed + +* Increased the bounded grid overview from 256 by 256 to 512 by 512 cells to reduce + blur without enabling the full interactive refinement pipeline for every grid cell. +* Changed the initial plot style to Spectral on a white background. +* Adjusted the Export image control text so it fits within its button. +* Renamed the release-facing application, npm package, archives, citation metadata, + SBOM component, and repository references from `moddotplot-rs` to + `moddotplot-interactive`. The established `moddotplot-core` and `moddotplot-wasm` + Rust crate names remain unchanged for compatibility. + +## [0.7.0] - 2026-09-28 + +### Added + +* Added Self plot, Pairwise plot, and Grid plot modes when more than one FASTA record + is loaded. Single-record sessions retain a single self-sequence selector, while + pairwise selection retains its independent X and Y controls. +* Added a grid overview for 2 through the smaller of 6 or the number of loaded records. + Its sequence selectors must be distinct. +* Grid mode calculates the `n` diagonal self comparisons and `n choose 2` unique + pairwise comparisons serially as bounded 256 by 256 quick overviews. It displays a + complete symmetric matrix, reusing each pairwise result as a transposed mirror. +* Clicking any grid cell opens that comparison in the corresponding interactive Self + plot or Pairwise plot mode. + +### Changed + +* Feature tracks, Reset view, and current-view data/image export are unavailable while + the grid overview is active. Opening a cell restores the applicable interactive + controls. + +## [0.6.2] - 2026-08-30 + +### Changed + +* FASTA headers are split at the first whitespace: selectors and numeric outputs use + the sequence identifier, while axis labels may also show the description. +* Sequence selectors and default export filenames use collision-safe + `SeqID_FileStem` identifiers, with a numeric suffix only for remaining duplicates. +* BEDPE and CSV omit zero-valued and missing cells. CSV is reduced from renderer + diagnostics to nine scientific columns: sequence IDs, interval pairs, `ANI_c`, + direction, and direction support. Exact schemas and formulas are now documented. +* Export uses the native system save picker when available and browser download + behavior otherwise. Format selection is confined to the native picker, which is + asked to start in Downloads; fallback downloads use BEDPE and PNG defaults. +* Current-view exports wait for detailed-register tiles (or terminal exact tiles) + rather than publishing quick-view estimates. BEDPE no longer repeats tile quality + and configuration digest in each row; those details remain in file provenance. +* SVG heatmaps use an embedded PNG through the Illustrator-compatible XLink form, and + rotated PDF text placement now centers Y-axis labels on their tick marks. +* Viridis on black is now the initial plot style. Image exports add a vector heatmap + legend in a dedicated margin outside the data area. Its ANI_c axis is numerically + linear with six evenly spaced ticks, while its colors follow the same adjustable + three-anchor mapping as the UI; the slider midpoint is not itself a legend tick and + the palette name is omitted beneath the scale. +* Empty and loading plot regions retain the application paper color; the selected + black background is now painted only inside rendered tile bounds. +* Export now keeps a blocking progress dialog open from preparation through file-write + completion. Complete cached detailed/exact viewports skip the forced-detail request, + and image generation uses an offscreen framebuffer without resizing or repainting the + visible plot canvas. + +## [0.6.1] - 2026-08-30 + +### Changed + +* Clarified in the normative scientific specification that sparse correction is + confined to the non-default validation feature and excluded from the production + estimator and release accuracy contract. +* Updated the design, validation sequence, ADR status labels, README feature summary, + and release provenance to reflect the completed v0.6 series accurately. +* Strengthened automated documentation checks for the production/validation boundary, + completed release sequence, historical ADR labeling, and export documentation. + +## [0.6.0] - 2026-08-30 + +### Added + +* Added current-view BEDPE export as the numeric default, with CSV as an alternate. +* Added full-composition PNG, SVG, and PDF image export. SVG and PDF keep axes, + sequence labels, and visible tracks as vectors while embedding the heatmap as a + raster image. +* Added Save As dialogs with editable filenames and format selectors for both export + actions; BEDPE and PNG are the defaults. + +### Changed + +* Removed sparse correction from the browser protocol, UI, Wasm API, resource model, + and ordinary web build. +* Consolidated candidate estimators, rejected fused projection, sparse policy, and + sparse kernels under the non-default Rust `validation` module. +* Split export composition/controller responsibilities out of the main UI and moved + tile selection policy out of the compute-worker orchestrator. + +## [0.5.0] - 2026-08-30 + +### Changed + +* Replaced fixed refinement bands with scheduler policy 2, which evaluates compact + per-tile identity and direction-support histograms against the active display floor. +* Added immediate-neighbor refinement and deterministic exploration so a weak or blank + provisional tile cannot permanently exclude nearby detailed work. +* Kept 256-register preview quick and provisional, 1,024-register detail as the default, + and sparse correction disabled outside the validation-only Rust feature. + +### Added + +* Added a preregistered four-layout exact-containment grid covering multiple sequence + realizations, controlled point substitutions, repeats, ambiguity, large indels, + inversion, coordinate offset, and 4:1 cardinality imbalance. +* Added aggregate `ANI_c` bias, variance, occupancy, threshold confusion, spatial + structure, feature overlap, fingerprint-collision expectation, and runtime reporting. +* Added a stable similarity-pixel golden while retaining the schema-1 numeric-channel + golden from v0.2.1. +* Added numeric CSV and high-resolution PNG export of the current viewport. Both carry + complete method, software, scientific-configuration, scheduler, display, viewport, + and sequence provenance. + +### Validation + +* The four-layout grid measured mean/P95 absolute `ANI_c` errors of 0.035283/0.226630 + for 256 registers and 0.005333/0.017890 for 1,024 registers. Combined 0.80 display + floor crossing rates were 2.8604% and 1.1987%, respectively. +* The selective scheduler met its structural comparison limits while refining 63 of + 108 characterization tiles, 58.3% of all-detail work. +* No verified-winner fingerprint collision was observed; the occupancy-weighted + expectations were below `4.8e-7` per cell for both production tiers. + +## [0.4.0] - 2026-08-30 + +### Changed + +* Replaced correlated compute-worker globals with owned dataset, comparison, and + feature jobs carrying immutable IDs, cancellation signals, and publication guards. +* Added a complete 4 GiB browser resource plan spanning Wasm, JavaScript tiles, GPU + textures, annotations, sequence-derived tracks, and transient publication data. +* Split FASTA ingestion into bounded 4 MiB shards and bounded progressive axis work; + OPFS spill remains disabled pending evidence that in-memory sharding is insufficient. +* Made annotation-worker cancellation cooperative during streamed parsing and suppress + completion or error publication from cancelled imports. + +### Added + +* Added randomized stale-publication tests, synchronous cancellation limits, + generation-lifecycle coverage, cross-layer fallback tests, and representative + haploid/diploid human resource-envelope checks. +* Added predicted-versus-observed retained-axis memory tests and exposed separate + renderer CPU-tile and GPU-texture estimates. + +### Validation + +* Retained the existing compact annotation graph after the one-million-record case + measured 176,000,000 retained bytes and 1.40 seconds including publication cloning, + both within the preregistered v0.4.0 envelope. + +## [0.3.0] - 2026-08-30 + +### Changed + +* Made ordinary matrix requests geometric only; k-mer length, register count, + comparison width, estimator, and transform are now derived from the axes. +* Made frozen-axis representation fields private and attached the complete immutable + scientific configuration and stable source-sequence identity to every axis. +* Reject incompatible scientific identities, coordinate layouts, ranges, and + contiguous-axis provenance before output allocation. +* Isolated legacy estimator selection, fused register projection, and sparse exact + correction behind the non-default `validation` Cargo feature. +* Split core axis, frozen-representation, ordinary-tile, and validation-only contracts, + and split Wasm preparation, cache, resource admission, composition, and session + context ownership into focused modules. + +### Added + +* Added a schema-1 numeric fixture whose complete tile-channel digests are verified + byte-for-byte against the retained v0.2.1 source checkpoint. +* Added configuration, sequence-provenance, and contiguous-range mismatch tests. + +## [0.2.1] - 2026-08-30 + +### Added + +* Added assertion-backed exact-k-mer acceptance for the real sparse-disabled + 256-register preview and 1,024-register detailed production paths, including the + current selective composite and direction behavior. +* Added exact scientific identities to Wasm zoom-axis cache keys and field-sensitivity + tests covering every configurable scientific parameter. +* Added variance, union-occupancy, and selected-configuration pass/fail columns to the + bounded estimator report. +* Added a generated million-record annotation parse/publication gate with explicit + 512 MiB and one-million-matching-record admission limits. + +### Changed + +* Accuracy release gates and reports now use absolute error in displayed `ANI_c`. + Preview mean/P95 limits are 0.05/0.30 and detailed limits are 0.01/0.04 on the fixed + production path; only crossing of the operational 0.80 floor is release-blocking. +* Kept sparse correction disabled by default after both browser tiers passed the + accepted `ANI_c` gates without its runtime cost. +* Deprecated the legacy estimator-selection and fused-projection APIs ahead of their + removal or validation-only isolation in v0.3.0. + +### Documentation + +* Defined `ANI_c` explicitly as the historical containment-derived ModDotPlot display + score, not biological ANI or alignment-derived `ANI_m`; validation targets exact + complete k-mer-set containment, with biological comparison limited to point mutation + without large indels. +* Added the post-v0.2.0 validation contract and recorded that development pauses after + each version for manual testing; v1.0.0 is deferred until after additional usability + work. +* Amended the v0.1 review response and ADR 0004 for the final v0.2.0 memory budget, + feature-track support, browser-exposed modes, estimator-report schema, and completed + validation gates. +* Extended generated documentation checks to derive memory budgets, annotation formats, + sparse-correction exposure, and validation columns from the maintained implementation + and release evidence. + +## [0.2.0] - 2026-08-29 + +### Changed + +* Stabilized direct track reordering: a dragged track now swaps only after its center + crosses the adjacent track midpoint, preventing rapid back-and-forth oscillation. +* Added visible software-version labels to the landing page and control panel, clarified + local privacy and project caveats, and made the file-chooser affordance explicit. + +## [0.2.0-beta.4] - 2026-08-29 + +### Changed + +* Accelerated streamed FASTA uptake with table-driven eight-base packing, 4 MiB Wasm + handoffs, compact completed-record storage, and percentage-throttled progress updates. +* Raised the application-owned Wasm session budget from 1.5 GiB to 3 GiB for complete + diploid human assemblies, while retaining every parsed chromosome in the session. +* Replaced the dense validity bitmap with a constant-time two-level sparse invalid-base + bit vector, avoiding one retained validity bit for every canonical base. + +### Added + +* Streaming BGZF support for FASTA and annotation input, using bounded parallel block + decompression while preserving member order. + +## [0.2.0-beta.3] - 2026-08-29 + +### Changed + +* Removed the confirmation dialog after successful track imports; validation errors and + required axis or alias choices still use dialogs. +* Replaced the simple SVG plot-loading mark with a depth-sorted, rotating B-DNA canvas + animation. +* Moved X coordinates and horizontal tracks above the plot and the X sequence + identifier below it, with bars extending upward away from the plot. Status, + progress, and memory reporting now live in the control panel so the plot pane can + use the full browser height. +* Made sequence-derived tracks pan immediately from an overscanned cache and clear on + the same frame whenever cached coverage is insufficient. +* Replaced per-axis import buttons with one sequence-aware **Add track** action. A + unique sequence match is assigned automatically; ambiguous pairwise matches ask the + user to choose an axis. +* Made imported-track reordering complete on the first pointer release, added a + pointer-following drag preview, and made a drop outside the track strip disable the + corresponding axis copy. +* Compacted the Y axis by rotating coordinate labels and aligned vertical track labels + with the lower-coordinate end of their tracks. +* Replaced the plot-background checkbox with adjacent White/Black buttons, refined + control-description spacing, and restored grab/grabbing track cursors. +* Replaced the small drag-away removal icon with prominent diagonal hatching over the + complete track. +* Centered the plot assembly when the browser has surplus horizontal space and added a + small rotating double-helix shadow while an empty plot is being calculated. +* Increased heatmap-slider precision to 0.1 percentage point and added a **Both** + shortcut to each GC% and CpG track control. + +### Added + +* Stackable GC% and CpG observed/expected tracks for both plot axes, computed locally + on demand with one unsmoothed bar per sampled display column or row and hover values. +* Local BED3–12, GFF3, and GTF imports, either plain text or gzip compressed, from + highlighted axis drop targets or a control-panel file chooser, including validation + reports, exact/conservative sequence mapping, UCSC `itemRgb`, BED blocks, and grouped + transcript/exon/CDS/intron models with category-aware GFF colors. +* Sequence-associated imported tracks with one to ten packing rows, direct hold-drag + reordering, drag-away disabling, control-panel enable/delete operations, and feature + mouseovers. File drops elsewhere in the application are intercepted and reported + instead of allowing browser navigation. +* Preflight rejection of annotation files in the FASTA landing target and modal error + reporting for malformed FASTA input, without publishing a partial workspace. + +## [0.2.0-beta.2] - 2026-08-29 + +### Changed + +* Remapped identity sliders to the selected palette's low, midpoint, and high colors; + below-minimum cells now use the plot background and above-maximum cells retain the + palette's top color. +* Preserved the current genomic viewport when scientific controls rebuild a comparison. +* Removed the high-sensitivity checkbox from the browser controls. + +### Fixed + +* Extended exact canonical k-mer matches across their full forward or reverse-complement + footprints, including matches that cross exact-tile boundaries. +* Increased Y-axis label spacing and constrained the plot plus X-axis label to the + browser viewport without page scrollbars. + +### Added + +* Similarity palette selection, including ModDotPlot Spectral, Viridis, and blue-red. +* A white/black plot-background control. + +## [0.2.0-beta.1] - 2026-08-29 + +### Changed + +* Replaced the legacy collision-corrected b-bit statistic with the validated + verified-winner OPH estimator. +* Reduced HLL precision from 12 to 10 and default detailed registers from 2,048 to + 1,024; changed the default palette floor to 85%. +* Made sparse exact correction an optional high-sensitivity mode rather than a default. +* Introduced immutable versioned scientific configuration and per-tile digests. +* Pooled both directed strand evidence sources and desaturated low-support colors. +* Paints compatible quick blocks during interleaved X/Y axis construction; detailed + blocks now replace quick blocks individually as they finish. Detailed work is + display-uncertainty-selected unless high-sensitivity mode requests all tiles. +* Corrected the multi-seed validation path and expanded spatial diagnostics across + thresholds, holes, continuity, neighborhoods, feature ranking, and components. +* Removed Phase 0, minmer/bottom-s, alternate-hash, densified OPH, inverted-matrix, + and legacy base-kernel implementations after preserving the v0.1.0 tag. +* Removed BED/GFF/GTF import and feature tracks from the beta because the subsystem did + not meet the release reliability bar. + +### Fixed + +* Made FASTA loading transactional and cancellable so stale load/clear operations + cannot publish or mutate the committed session. +* Entered exact k-mer mode on the first frame of an initially terminal view. +* Removed redundant tile-array copies and duplicate retained full-sketch builders. +* Restored WebGL resources after context loss and added keyboard navigation. +* Added an actionable compute-worker restart path that reloads retained input files. + +### Added + +* Exact canonical k-mer containment oracle independent of the production hash. +* Resource admission and total Wasm session budget. +* Real-Wasm Playwright coverage for cancellation, exact entry, privacy, graphics + recovery, keyboard navigation, progressive refinement, and worker recovery. +* Pinned toolchains/actions, atomic build, CycloneDX SBOM, and complete generated + dependency-license bundle. +* Explicit and tested `U/u` to `T/t` normalization. +* Automated documentation-note, generated-parameter, and local-link drift checks. + +### Still planned + +* Sharded sequence and sketch storage with bounded OPFS spill for very large records +* Numeric and high-resolution image export +* Stable numeric and visual-regression goldens +* Broader scientific validation of the probability-based sparse correction policy + +## [0.1.0] - 2026-08-28 + +### Added + +* Entirely client-side multi-file, multi-record FASTA and gzip FASTA loading +* Independent X/Y record selection for self and pairwise comparisons +* Rust/Wasm ntHash2, OPH b-bit, HLL, containment-derived ANI backend +* Bounded detailed-only exact correction for statistically sparse cores +* Configurable quick and detailed progressive estimates with paint acknowledgement +* Responsive tiled WebGL2 rendering, pointer zoom, pan, reset, and coarse-level fallback +* Similarity and forward/reverse orientation display modes +* Three-anchor 80% to 100% identity color transfer and compact histogram +* Exact canonical k-mer terminal zoom with genome-browser base colors +* BED3-12, GFF3, and GTF axis feature tracks with record and bounds validation +* Byte-budgeted Wasm and renderer caches with compact memory reporting +* Exact, scalar, bottom-s/minmer, and MashMap-compatible reference implementations +* Rust, TypeScript, differential, statistical, and Phase 0 benchmark suites +* Design reference, user guide, privacy statement, contribution policy, and release + checklist +* Release-facing `moddotplot-rs` project name and explicit ChatGPT 5.6 Sol/Adam + Phillippy authorship statement +* Preferred original ModDotPlot citation plus structured method and reference credits + +### Known limitations + +* This is an initial functional preview, not a research-validated release. +* The contiguous in-memory representation is not approved for reliable 1 Gb records. +* Session persistence and data/image export are not implemented. +* Browser compatibility and graphics recovery need broader automated coverage. + +[Unreleased]: https://github.com/marbl/ModDotPlot-Browser/compare/v1.0.2...HEAD +[1.0.2]: https://github.com/marbl/ModDotPlot-Browser/compare/v1.0.1...v1.0.2 +[1.0.1]: https://github.com/marbl/ModDotPlot-Browser/compare/v1.0.0...v1.0.1 +[0.9.8]: https://github.com/marbl/ModDotPlot-Browser/compare/v0.9.7...v0.9.8 +[0.9.7]: https://github.com/marbl/ModDotPlot-Browser/compare/v0.9.6...v0.9.7 +[0.9.6]: https://github.com/marbl/ModDotPlot-Browser/compare/v0.9.5...v0.9.6 +[0.9.5]: https://github.com/marbl/ModDotPlot-Browser/compare/v0.9.4...v0.9.5 +[0.9.4]: https://github.com/marbl/ModDotPlot-Browser/compare/v0.9.3...v0.9.4 +[0.9.3]: https://github.com/marbl/ModDotPlot-Browser/compare/v0.9.2...v0.9.3 +[0.9.2]: https://github.com/marbl/ModDotPlot-Browser/compare/v0.9.1...v0.9.2 +[0.9.1]: https://github.com/marbl/ModDotPlot-Browser/compare/v0.9.0...v0.9.1 +[0.9.0]: https://github.com/marbl/moddotplot-interactive/compare/v0.8.9...v0.9.0 +[0.8.9]: https://github.com/marbl/moddotplot-interactive/compare/v0.8.8...v0.8.9 +[0.8.8]: https://github.com/marbl/moddotplot-interactive/compare/v0.8.7...v0.8.8 +[0.8.7]: https://github.com/marbl/moddotplot-interactive/compare/v0.8.6...v0.8.7 +[0.8.6]: https://github.com/marbl/moddotplot-interactive/compare/v0.8.5...v0.8.6 +[0.8.5]: https://github.com/marbl/moddotplot-interactive/compare/v0.8.4...v0.8.5 +[0.8.4]: https://github.com/marbl/moddotplot-interactive/compare/v0.8.3...v0.8.4 +[0.8.3]: https://github.com/marbl/moddotplot-interactive/compare/v0.8.2...v0.8.3 +[0.8.2]: https://github.com/marbl/moddotplot-interactive/compare/v0.8.1...v0.8.2 +[0.8.1]: https://github.com/marbl/moddotplot-interactive/compare/v0.8.0...v0.8.1 +[0.8.0]: https://github.com/marbl/moddotplot-interactive/compare/v0.7.0...v0.8.0 +[0.7.0]: https://github.com/marbl/moddotplot-interactive/compare/v0.6.2...v0.7.0 +[0.6.2]: https://github.com/marbl/moddotplot-interactive/compare/v0.6.1...v0.6.2 +[0.6.1]: https://github.com/marbl/moddotplot-interactive/compare/v0.6.0...v0.6.1 +[0.6.0]: https://github.com/marbl/moddotplot-interactive/compare/v0.5.0...v0.6.0 +[0.5.0]: https://github.com/marbl/moddotplot-interactive/compare/v0.4.0...v0.5.0 +[0.4.0]: https://github.com/marbl/moddotplot-interactive/compare/v0.3.0...v0.4.0 +[0.3.0]: https://github.com/marbl/moddotplot-interactive/compare/v0.2.1...v0.3.0 +[0.2.1]: https://github.com/marbl/moddotplot-interactive/compare/v0.2.0...v0.2.1 +[0.2.0]: https://github.com/marbl/moddotplot-interactive/compare/v0.2.0-beta.4...v0.2.0 +[0.2.0-beta.4]: https://github.com/marbl/moddotplot-interactive/compare/v0.2.0-beta.3...v0.2.0-beta.4 +[0.2.0-beta.3]: https://github.com/marbl/moddotplot-interactive/compare/v0.2.0-beta.2...v0.2.0-beta.3 +[0.2.0-beta.2]: https://github.com/marbl/moddotplot-interactive/compare/v0.2.0-beta.1...v0.2.0-beta.2 +[0.2.0-beta.1]: https://github.com/marbl/moddotplot-interactive/compare/v0.1.0...v0.2.0-beta.1 +[0.1.0]: https://github.com/marbl/moddotplot-interactive/releases/tag/v0.1.0 diff --git a/CITATION.cff b/CITATION.cff index 279258f..0a6fc86 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -4,8 +4,8 @@ message: >- preferred citation and credit the relevant method papers listed in references. title: "ModDotPlot Browser" type: software -version: 1.0.0 -date-released: 2026-10-04 +version: 1.0.2 +date-released: 2026-10-11 repository-code: "https://github.com/marbl/ModDotPlot-Browser" license: MIT-0 abstract: >- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ca80d34 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,92 @@ +# Contributing to ModDotPlot Browser + +> **Note:** This project was generated using ChatGPT 5.6 Sol (ie. vibe-coded) under the direction of Adam Phillippy and Alex Sweeten. For analyses intended for publication, please use the official [ModDotPlot CLI](https://github.com/marbl/ModDotPlot) and cite the [ModDotPlot paper](https://doi.org/10.1093/bioinformatics/btae493). + +Thank you for improving ModDotPlot Browser. Correct and reproducible scientific output is +the project's first priority, followed by interactive performance and maintainability. + +Start with the [methods reference](docs/METHODS.md), the +[release checklist](docs/RELEASE_CHECKLIST.md), and the +[user guide](docs/USER_GUIDE.md). + +## Development environment + +Use the versions pinned by `rust-toolchain.toml`, `.node-version`, `web/package.json`, +and both lockfiles. Install `wasm-pack`, then run: + +```sh +npm --prefix web ci +npm --prefix web run dev +``` + +The browser bundle uses only static assets and browser APIs at runtime. + +## Before submitting a change + +```sh +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +cargo check -p moddotplot-wasm --target wasm32-unknown-unknown +npm --prefix web run verify +``` + +Keep changes modular, document public Rust APIs, and add focused unit tests for both +successful and erroneous input. Avoid unrelated formatting or generated-file changes. + +## Scientific changes + +A change is scientific if it affects sequence normalization, coordinates, hashing, +sketches, cardinality, containment, ANI conversion, strand evidence, sparse correction, +matrix boundaries, exact mode, missing values, or numeric tile contents. + +Scientific changes must: + +1. State the invariant or estimator being changed. +2. Add or update a simple exact/scalar reference implementation when possible. +3. Differentially test optimized output against that reference. +4. Include ambiguity, short-range, low-complexity, reverse-complement, and boundary + cases relevant to the change. +5. Quantify approximation error and performance when an estimator changes. +6. Update `docs/METHODS.md`, `docs/RELEASE_CHECKLIST.md`, the user guide, and the + changelog when the decision is durable. + +Never loosen a correctness threshold to make an optimization pass without documenting +and scientifically justifying the changed error model. + +The production backend is ntHash2 plus undensified OPH verified winners and HLL p10. +Exact canonical k-mer containment is the scientific oracle. Rejected minmer, bottom-s, +alternate-hash, densified, and inverted implementations are historical only and must +not be restored to the production surface without a reviewed ADR and new validation. + +## Browser and renderer changes + +Preserve the numeric tile contract and keep coloring in the renderer. Tile keys must +include every parameter that can change numeric output. A newly selected generation +must not accept stale tiles, and detailed computation must not delay presentation of a +complete quick viewport. + +Test pure TypeScript state and parsing logic outside WebGL where possible. Renderer +changes should be manually checked in all supported desktop browser families until the +automated visual matrix is complete. Inspect pan, zoom, reset, resize, cache eviction, +self-comparison symmetry, exact zoom, and both color modes. + +## Performance work + +Benchmark the exact workload affected by the change. Report hardware, browser or +native target, build profile, input, parameters, elapsed time, and peak memory where +available. Keep raw machine-readable results beside the Markdown interpretation under +`docs/benchmarks/`. + +An optimization is acceptable only after its numeric output passes the relevant +reference and statistical gates. + +## Documentation and release notes + +Update [CHANGELOG.md](CHANGELOG.md) for user-visible changes. Documentation should call +zero-based and one-based coordinates explicitly, distinguish exact from estimated +values, and avoid claiming research validation until the gates in +[release checklist](docs/RELEASE_CHECKLIST.md) are complete. + +ModDotPlot Browser is MIT-0 licensed. By contributing, you agree that your contribution is +provided under that license. diff --git a/Cargo.lock b/Cargo.lock index e0a76ab..8ecc1d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,11 +61,11 @@ dependencies = [ [[package]] name = "moddotplot-core" -version = "1.0.0" +version = "1.0.2" [[package]] name = "moddotplot-wasm" -version = "1.0.0" +version = "1.0.2" dependencies = [ "console_error_panic_hook", "moddotplot-core", diff --git a/Cargo.toml b/Cargo.toml index 5a3961e..7f161c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/core", "crates/wasm"] resolver = "2" [workspace.package] -version = "1.0.0" +version = "1.0.2" edition = "2024" license = "MIT-0" repository = "https://github.com/marbl/ModDotPlot-Browser" diff --git a/README.md b/README.md index b659d5f..9ffe02f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ **Live application:** [marbl.github.io/ModDotPlot-Browser](https://marbl.github.io/ModDotPlot-Browser/) -> **Note:** This project was generated by ChatGPT 5.6 Sol under the direction of Adam Phillippy and Alex Sweeten. It is intended to be used for exploratory purposes only. For analyses intended for publication, please use the official [ModDotPlot CLI](https://github.com/marbl/ModDotPlot) and cite the [ModDotPlot paper](https://doi.org/10.1093/bioinformatics/btae493). +> **Note:** This project was generated using ChatGPT 5.6 Sol (ie. vibe-coded) under the direction of Adam Phillippy and Alex Sweeten. For analyses intended for publication, please use the official [ModDotPlot CLI](https://github.com/marbl/ModDotPlot) and cite the [ModDotPlot paper](https://doi.org/10.1093/bioinformatics/btae493). ## Documentation diff --git a/SBOM.cdx.json b/SBOM.cdx.json index e844b46..587a483 100644 --- a/SBOM.cdx.json +++ b/SBOM.cdx.json @@ -6,7 +6,7 @@ "component": { "type": "application", "name": "moddotplot-interactive", - "version": "1.0.0", + "version": "1.0.2", "licenses": [ { "license": { diff --git a/assets/example-data/Col-CEN_v1.2.fasta.gz b/assets/example-data/Col-CEN_v1.2.fasta.gz deleted file mode 100644 index 9b86340..0000000 Binary files a/assets/example-data/Col-CEN_v1.2.fasta.gz and /dev/null differ diff --git a/docs/CITATIONS.md b/docs/CITATIONS.md index 60978a0..683e45e 100644 --- a/docs/CITATIONS.md +++ b/docs/CITATIONS.md @@ -1,6 +1,6 @@ # Citations -> **Note:** This project was generated by ChatGPT 5.6 Sol under the direction of Adam Phillippy and Alex Sweeten. For analyses intended for publication, please use the official [ModDotPlot CLI](https://github.com/marbl/ModDotPlot) and cite the [ModDotPlot paper](https://doi.org/10.1093/bioinformatics/btae493). +> **Note:** This project was generated using ChatGPT 5.6 Sol (ie. vibe-coded) under the direction of Adam Phillippy and Alex Sweeten. For analyses intended for publication, please use the official [ModDotPlot CLI](https://github.com/marbl/ModDotPlot) and cite the [ModDotPlot paper](https://doi.org/10.1093/bioinformatics/btae493). ## Preferred citation diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 4e61a34..0bb715b 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,6 +1,6 @@ # Local installation and HPC deployment -> **Note:** This project was generated by ChatGPT 5.6 Sol under the direction of Adam Phillippy and Alex Sweeten. For analyses intended for publication, please use the official [ModDotPlot CLI](https://github.com/marbl/ModDotPlot) and cite the [ModDotPlot paper](https://doi.org/10.1093/bioinformatics/btae493). +> **Note:** This project was generated using ChatGPT 5.6 Sol (ie. vibe-coded) under the direction of Adam Phillippy and Alex Sweeten. For analyses intended for publication, please use the official [ModDotPlot CLI](https://github.com/marbl/ModDotPlot) and cite the [ModDotPlot paper](https://doi.org/10.1093/bioinformatics/btae493). `ModDotPlot Browser` is a static Rust/WebAssembly web application. After it has been built, it does not require an application server or database. A web server only @@ -124,9 +124,8 @@ The deployable website is written to: web/dist/ ``` -The build also runs the TypeScript tests, prepares the bundled *Arabidopsis thaliana* -example, generates the WebAssembly bindings, checks the documentation contracts, and -updates the software dependency inventory. +The build also runs the TypeScript tests, generates the WebAssembly bindings, checks +the documentation contracts, and updates the software dependency inventory. ### 5. Launch locally @@ -261,24 +260,24 @@ Package the generated static site: ```bash tar -C web/dist \ - -czf moddotplot-browser-v1.0.0-dist.tar.gz \ + -czf moddotplot-browser-v1.0.2-dist.tar.gz \ . ``` Copy it to the HPC system: ```bash -scp moddotplot-browser-v1.0.0-dist.tar.gz \ +scp moddotplot-browser-v1.0.2-dist.tar.gz \ USER@login.cluster.example.edu:/path/to/destination/ ``` On the HPC system: ```bash -mkdir -p /path/to/moddotplot-browser-v1.0.0 +mkdir -p /path/to/moddotplot-browser-v1.0.2 -tar -xzf /path/to/destination/moddotplot-browser-v1.0.0-dist.tar.gz \ - -C /path/to/moddotplot-browser-v1.0.0 +tar -xzf /path/to/destination/moddotplot-browser-v1.0.2-dist.tar.gz \ + -C /path/to/moddotplot-browser-v1.0.2 ``` The extracted directory is the complete static website. It can be published through @@ -423,12 +422,11 @@ The static server should: - Serve `.wasm` files using the MIME type `application/wasm`. - Support HTTP byte-range requests. - Preserve `Range`, `Content-Range`, and `Content-Length` headers. -- Serve bundled `.gz` files as binary files without automatically adding a - `Content-Encoding` header. - Permit JavaScript module workers. - Use HTTPS for a remotely shared deployment. -Byte-range support is important for indexed access to the bundled example FASTA. +The example button streams Col-CEN from GitHub, so deployed pages must allow outbound +HTTPS requests to that host through their Content Security Policy. ### Open OnDemand diff --git a/docs/METHODS.md b/docs/METHODS.md index c3573de..fbd4d87 100644 --- a/docs/METHODS.md +++ b/docs/METHODS.md @@ -1,6 +1,6 @@ # Methods -> **Note:** This project was generated by ChatGPT 5.6 Sol under the direction of Adam Phillippy and Alex Sweeten. For analyses intended for publication, please use the official [ModDotPlot CLI](https://github.com/marbl/ModDotPlot) and cite the [ModDotPlot paper](https://doi.org/10.1093/bioinformatics/btae493). +> **Note:** This project was generated using ChatGPT 5.6 Sol (ie. vibe-coded) under the direction of Adam Phillippy and Alex Sweeten. For analyses intended for publication, please use the official [ModDotPlot CLI](https://github.com/marbl/ModDotPlot) and cite the [ModDotPlot paper](https://doi.org/10.1093/bioinformatics/btae493). ## How the algorithm works @@ -165,9 +165,9 @@ At this resolution: - Reverse-complement matches produce reverse diagonals. - Reverse-complement-palindromic *k*-mers have neutral direction evidence. -The default exact view colors matches by the corresponding base from the X sequence. -An identity-colored exact view is also available. Changing exact-view colors does not -change the underlying exact match matrix. +The exact view colors matches by the corresponding base from the X sequence. Exact +containment values remain part of the scientific provenance, but the redundant +identity-colored exact display is not exposed. ## How this differs from the published ModDotPlot algorithm diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 874ba77..a54b1a2 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy and local data handling -> **Note:** This project was generated by ChatGPT 5.6 Sol under the direction of Adam Phillippy and Alex Sweeten. For analyses intended for publication, please use the official [ModDotPlot CLI](https://github.com/marbl/ModDotPlot) and cite the [ModDotPlot paper](https://doi.org/10.1093/bioinformatics/btae493). +> **Note:** This project was generated using ChatGPT 5.6 Sol (ie. vibe-coded) under the direction of Adam Phillippy and Alex Sweeten. For analyses intended for publication, please use the official [ModDotPlot CLI](https://github.com/marbl/ModDotPlot) and cite the [ModDotPlot paper](https://doi.org/10.1093/bioinformatics/btae493). ModDotPlot Browser is designed to process genomic data locally in the user's browser. @@ -10,6 +10,9 @@ The current application: * reads FASTA, FAI, GZI, BED, GFF3, and GTF files, including gzip- and BGZF-compressed sequence and annotation versions where supported, through browser file APIs; +* can, only when a user submits a URL, fetch an HTTPS FASTA or FASTA.gz response + directly from that remote host and stream it through the local parser, including + adjacent FAI/GZI indexes and bounded byte ranges when the host provides them; * parses and sketches sequences in a dedicated Web Worker and WebAssembly instance; * renders numeric results through WebGL2; * makes no application API calls; @@ -20,8 +23,14 @@ The current application: The built application is a set of static files. It still requires HTTP or HTTPS hosting because browsers restrict module workers and WebAssembly on `file://` pages. The hosting provider can observe normal requests for the application files, including -IP address and browser metadata according to that provider's policy. Loaded genomic -files are not part of those requests. +IP address and browser metadata according to that provider's policy. Locally selected +genomic files are not part of those requests. + +For a user-requested URL import, the remote data host receives a normal browser GET +request and can observe request metadata such as IP address and browser headers under +that host's policy. ModDotPlot Browser omits credentials and referrer information from +this request, does not proxy the sequence through the application host, and does not +send the parsed sequence elsewhere. The host must permit cross-origin requests (CORS). ## Memory and lifetime diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 1c61aa2..e7233d6 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -1,8 +1,8 @@ # Release checklist -> **Note:** This project was generated by ChatGPT 5.6 Sol under the direction of Adam Phillippy and Alex Sweeten. For analyses intended for publication, please use the official [ModDotPlot CLI](https://github.com/marbl/ModDotPlot) and cite the [ModDotPlot paper](https://doi.org/10.1093/bioinformatics/btae493). +> **Note:** This project was generated using ChatGPT 5.6 Sol (ie. vibe-coded) under the direction of Adam Phillippy and Alex Sweeten. For analyses intended for publication, please use the official [ModDotPlot CLI](https://github.com/marbl/ModDotPlot) and cite the [ModDotPlot paper](https://doi.org/10.1093/bioinformatics/btae493). -This checklist applies to the v1.0.0 release. The browser remains intended for +This checklist applies to the v1.0.2 release. The browser remains intended for exploratory use; analyses intended for publication should use the official ModDotPlot CLI. ## Source and identity @@ -66,8 +66,10 @@ npm --prefix web run verify - [x] Confirm no monotonic live-cache growth during a fixed pan/zoom trace. - [x] Clearly retain the 1 Gb input value as an upper bound rather than a laptop performance guarantee until sharding/spill validation is complete. -- [x] Run the generated million-record annotation parser/publication gate and enforce - explicit per-file byte and matching-record admission limits. +- [x] Run the generated million-feature annotation parser/publication gate and enforce + explicit per-file byte and retained-feature admission limits. +- [x] Import the official CHM13 v2.0 RefSeq Liftoff v5.3 GFF across all 24 chromosomes + without treating collapsed exon, CDS, and UTR rows as separately retained features. ## Reproducibility and distribution gate @@ -103,4 +105,4 @@ npm --prefix web run verify - [ ] Create and verify an annotated tag from a clean tree as the final source action. - [ ] Produce source/binary archives, checksums, provenance, release notes, and known limitations under the local ignored `release-artifacts/` directory. -- [ ] Push the verified v1.0.0 release commit to the `main` branch. +- [ ] Push the verified v1.0.2 release commit to the `main` branch. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index f2667bd..d5463b1 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -4,32 +4,36 @@ ## Table of contents -- [Getting started](#getting-started) -- [Plot Navigation](#plot-navigation) - - [Self Plot](#self-plot) - - [Pairwise Plot](#pairwise-plot) - - [Grid Plot](#grid-plot) -- [Plot Resolution](#plot-resolution) -- [Plot Customization](#plot-customization) -- [Exporting Data to ModDotPlot CLI](#exporting-data-to-moddotplot-cli) +- [ModDotPlot Browser User Guide \& Tutorial](#moddotplot-browser-user-guide--tutorial) + - [Table of contents](#table-of-contents) + - [Getting started](#getting-started) + - [Plot Navigation](#plot-navigation) + - [Self Plot](#self-plot) + - [Pairwise Plot](#pairwise-plot) + - [Grid plot](#grid-plot) + - [Plot Resolution](#plot-resolution) + - [Plot Customization](#plot-customization) + - [Exporting a config for ModDotPlot CLI](#exporting-a-config-for-moddotplot-cli) ## Getting started `ModDotPlot Browser` has been tested and works on current versions of Google Chrome, Microsoft Edge, Mozilla Firefox, and Safari. Web browsers must have WebAssembly, Web Workers, and WebGL2 enabled. Mobile browsers appear to be compatible, although navigating plots with touchscreen controls may be more challenging. -There is no fixed input-size limit because files are processed locally rather than uploaded. The practical limit depends on available system memory and browser WebAssembly constraints. As a reference, the approximately 6 GB HG002 diploid human genome uses about 830 MB of browser memory at the default settings. - ![](../web/public/tutorial_images/landing_page.png) -To get started, simply drop one or more plain, gzip-compressed, or BGZF FASTA assembly files on the large FASTA target, or use its file chooser. BGZF is detected from its header even when the suffix is `.gz`. For indexed loading, include `sample.fa.fai` with `sample.fa`, or include both `sample.fa.gz.fai` and `sample.fa.gz.gzi` with a BGZF `sample.fa.gz`: +To get started, simply drop one or more plain, gzip-compressed, or BGZF FASTA assembly files on the landing-page target, or use its file chooser. The same target also accepts optional BED, GFF3, or GTF annotations and automatically routes each file by type. BGZF is detected from its header even when the suffix is `.gz`. For indexed loading, include `sample.fa.fai` with `sample.fa`, or include both `sample.fa.gz.fai` and `sample.fa.gz.gzi` with a BGZF `sample.fa.gz`: ![](../web/public/tutorial_images/accepted_files.png) -ModDotPlot will index and validate the provided assembly and, upon completion, create an `Explore` button that launches the interactive browser. You can optionally add BED, GFF, or GTF annotation files on the smaller annotation target. Make sure their sequence identifiers match the corresponding FASTA headers. +Alternatively, paste a direct HTTPS link ending in `.fa`, `.fasta`, `.fna`, or `.fas` (optionally followed by `.gz`) into **Insert URL** below the drop target and select **Load URL**. The browser streams the response directly through the same local parser and gzip decompressor used for selected files; it does not send the FASTA through the ModDotPlot Browser host. The remote server must permit cross-origin browser requests (CORS), and it can observe normal request metadata such as your IP address. Redirects are supported when every destination uses HTTPS. + +ModDotPlot will index and validate the provided assembly or streamed URL and, upon completion, create an `Explore` button that launches the interactive browser. An annotation file selected with the assembly is matched against every loaded sequence after the first plot opens. Make sure annotation sequence identifiers match the corresponding FASTA headers. ![](../web/public/tutorial_images/explore.png) -The following tutorial will use the provided *Arabidopsis thaliana* genome assembly from [Naish *et al.* 2021](https://doi.org/10.1126/science.abi7489). +The following tutorial uses the *Arabidopsis thaliana* Col-CEN v1.2 genome assembly +from [Naish *et al.* 2021](https://doi.org/10.1126/science.abi7489). Select the +*Arabidopsis thaliana* example button. ![](../web/public/tutorial_images/self-similarity_level1.png) @@ -41,12 +45,16 @@ To navigate an interactive plot: - Click and drag to pan across the plot. - Select `Reset view` to return to the complete plot, and `Clear` to return back to the landing page. -Zooming is limited to a minimum visible interval of approximately 100 bp on each eligible axis. At the closest zoom levels, the browser switches to exact *k*-mer rendering. The default exact view colors matching cells by nucleotide base, while the `Identity` option applies the selected identity palette. +Zooming is limited to a minimum visible interval of approximately 100 bp on each eligible axis. At the closest zoom levels, the browser switches to exact *k*-mer rendering and colors matching cells by nucleotide base. ![](../web/public/tutorial_images/kmer_coloring.png) Hovering over a plot displays the X and Y sequence names, genomic intervals, and estimated identity for the cell beneath the pointer. Cursor guide lines can be configured or disabled in `Advanced options`. +Press `R` while hovering over an interactive plot to anchor a cross ruler at the current position. Subsequent hover readouts show signed X and Y genomic distances from that anchor, which is useful for measuring arrays, duplications, and other structures. Press `R` again to remove the ruler and restore absolute coordinates. + +![](../web/public/tutorial_images/ruler.png) + `ModDotPlot Browser` supports three plot modes when multiple sequences are available: ### Self Plot @@ -97,7 +105,7 @@ The `Color` control provides two display modes: ![](../web/public/tutorial_images/direction.png) -The plot background can be switched between white and black. Under `Identity colors`, the palette selector contains supported ColorBrewer palettes grouped by palette class, together with Spectral and Viridis options. Spectral is the default. +The plot background can be switched between white and black. Under `Identity colors`, the palette selector contains Blues, Greens, Reds, and Greys; Paired, Pastel1, and Pastel2; BrBG, PuOr, and RdGy; plus Spectral and Viridis. Viridis on black is the default. The identity color controls can be used to: @@ -118,42 +126,27 @@ The `Feature tracks` panel can display the following computed tracks on the X ax BED, GFF3, and GTF annotation files can also be added from the landing page or with the `Add track` button. Plain-text, gzip-compressed, and BGZF-compressed annotations are supported. A multi-sequence annotation file is represented by one logical track, and features are automatically matched to the sequence currently displayed on each axis. Use the `Track placement` controls to display X-axis tracks above or below the plot and Y-axis tracks to its left or right. The selected placement applies to computed and imported feature tracks. +Computed bins and imported features beneath the current plot cursor are highlighted on +their respective X or Y track. Select `Export image` to save a high-quality rendering of the current interactive view. *Please do not use plots generated this way for official publications; use the validated [ModDotPlot CLI](https://github.com/marbl/ModDotPlot) instead.* Depending on browser support and the selected filename extension, plots can be exported as PNG, SVG, or PDF. Image exports include the visible axes, sequence labels, feature tracks, and legend. SVG and PDF exports retain vector axes, labels, tracks, and legends around the rasterized heatmap. -## Exporting Data to ModDotPlot CLI +## Exporting a config for ModDotPlot CLI -As a reminder, ***this browser is meant for exploratory analysis only!*** Once you have styled a plot to your liking, select `Export ModDotPlot command` to download files for use in the [ModDotPlot CLI](https://github.com/marbl/ModDotPlot). The browser will finish preparing detailed data for the visible view and download a ZIP archive. +As a reminder, ***this browser is meant for exploratory analysis only!*** Once you have styled a plot to your liking, select `Export ModDotPlot config` to download `moddotplot_config.json` for use in the [ModDotPlot CLI](https://github.com/marbl/ModDotPlot). The config records browser display settings only; add the original FASTA inputs when running it with the CLI. -![](../web/public/tutorial_images/export_mdp.png) - -For a Self or Pairwise plot, the archive contains: - -```text -.bedpe -.config.json -``` - -The BEDPE file contains the non-empty similarity cells from the exported view. The JSON configuration records the corresponding ModDotPlot display settings and refers to the BEDPE file through its `load` field. - -Extract both files into the same directory: -```bash -unzip .zip -cd -``` - -The exported command can then be run with the official ModDotPlot CLI: +The exported config can then be run with the official ModDotPlot CLI after adding +the original input paths required for the analysis: ```bash -moddotplot -c .config.json -l .bedpe +moddotplot -c moddotplot_config.json -f input_fasta(s).fa ``` ![](../web/public/tutorial_images/Chr1_TRI.png) ![](../web/public/tutorial_images/Chr1_HIST.png) -The actual exported filenames are written into the JSON metadata, so replace `` with the name of the downloaded package. - -For a Grid plot, the ZIP archive contains one BEDPE file for every selected self-comparison and every unique pairwise comparison, together with a single configuration file. Run the exported grid with: +For a Grid plot, use the same config with the CLI's grid-only mode and appropriate +input paths: ```bash -moddotplot --grid-only -c .config.json -l +moddotplot --grid-only -c moddotplot_config.json -f input_fasta(s).fa ``` diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index ca2c386..57587e0 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -3,6 +3,8 @@ import { dirname, join, normalize, relative, resolve } from "node:path"; const repository = resolve(import.meta.dirname, ".."); const allowedMarkdown = new Set([ + "CHANGELOG.md", + "CONTRIBUTING.md", "README.md", "docs/CITATIONS.md", "docs/DEPLOYMENT.md", @@ -16,7 +18,9 @@ const ignoredDirectories = new Set([ "node_modules", "target", "dist", + "playwright-report", "release-artifacts", + "test-results", ]); const markdown = []; diff --git a/scripts/generate-arabidopsis-overview.mjs b/scripts/generate-arabidopsis-overview.mjs deleted file mode 100644 index 6153764..0000000 --- a/scripts/generate-arabidopsis-overview.mjs +++ /dev/null @@ -1,272 +0,0 @@ -#!/usr/bin/env node - -import { createHash } from "node:crypto"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { basename, dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { gzipSync } from "node:zlib"; -import { encodePrecomputedOverview } from "../web/src/precomputed-overview.ts"; -import { transposeTile } from "../web/src/tile.ts"; -import { initSync, ComputeSession, ScientificConfig } from "../web/src/wasm/moddotplot_wasm.js"; - -const SCRIPT_VERSION = 2; -const RESOLUTIONS = [1_000, 2_000, 4_000]; -const K = 21; -const REGISTER_COUNT = 1_024; -const TILE_EDGE = 256; -const FASTA_CHUNK_BYTES = 8 * 1024 * 1024; -const PREPARATION_CHUNK_BINS = 64; -const scriptDirectory = dirname(fileURLToPath(import.meta.url)); -const repositoryRoot = resolve(scriptDirectory, ".."); -const examplesDirectory = resolve(repositoryRoot, "web/public/examples"); -const wasmDirectory = resolve(repositoryRoot, "web/src/wasm"); -const paths = { - fasta: resolve(examplesDirectory, "Col-CEN_v1.2.fasta"), - fai: resolve(examplesDirectory, "Col-CEN_v1.2.fasta.fai"), - annotation: resolve(examplesDirectory, "ColCEN_CEN180.gff3"), - wasm: resolve(wasmDirectory, "moddotplot_wasm_bg.wasm"), -}; - -function outputPath(resolution) { - return resolve( - examplesDirectory, - resolution === 1_000 - ? "Col-CEN_v1.2.Chr1.mdp-overview-v1.gz" - : `Col-CEN_v1.2.Chr1.${resolution}.mdp-overview-v1.gz`, - ); -} - -const checkOnly = process.argv.slice(2).includes("--check"); -const unknownArguments = process.argv.slice(2).filter((argument) => argument !== "--check"); -if (unknownArguments.length > 0) { - throw new Error(`Unknown argument(s): ${unknownArguments.join(", ")}`); -} -const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10); -if (nodeMajor < 25) { - throw new Error("Generating the precomputed overview requires Node.js 25 or newer for native TypeScript imports"); -} - -const wasmBytes = readFileSync(paths.wasm); -initSync({ module: wasmBytes }); -const fastaBytes = readFileSync(paths.fasta); -const faiBytes = readFileSync(paths.fai); -const annotationBytes = readFileSync(paths.annotation); -const faiText = faiBytes.toString("utf8"); -const faiRecords = parseFai(faiText); -const session = new ComputeSession(); -const config = new ScientificConfig(K, REGISTER_COUNT); - -try { - const parsedRecords = ingestFasta(session, fastaBytes); - assertRecordsMatchFai(parsedRecords, faiRecords); - const first = parsedRecords[0]; - if (!first || first.name !== "Chr1") { - throw new Error("The bundled overview contract requires Chr1 to be FASTA record zero"); - } - - const configMetadata = scientificMetadata(config); - for (const resolution of RESOLUTIONS) { - session.begin_prepare_scientific(0, 0, resolution, config, false); - let progress = 0; - while (progress < 1) progress = session.prepare_comparison_chunk(PREPARATION_CHUNK_BINS); - - const tileMap = computeWorkerEquivalentSelfTiles(session, config, resolution); - const artifact = { - schemaVersion: 1, - generator: `moddotplot-interactive/generate-arabidopsis-overview@${SCRIPT_VERSION}`, - sourceAssets: [ - sourceAsset("fasta", paths.fasta, fastaBytes), - sourceAsset("fai", paths.fai, faiBytes), - sourceAsset("annotation", paths.annotation, annotationBytes), - ], - fastaIndexText: faiText, - sequences: faiRecords.map((record, index) => ({ - index, - name: record.name, - description: "", - length: record.length, - sourceFile: basename(paths.fasta), - })), - comparison: { - xIndex: 0, - yIndex: 0, - resolution, - k: K, - configDigest: configMetadata.digest, - }, - configurations: [configMetadata], - tiles: [...tileMap.values()].sort((left, right) => left.y - right.y || left.x - right.x), - }; - const encoded = encodePrecomputedOverview(artifact); - const compressed = deterministicGzip(encoded); - const output = outputPath(resolution); - - if (checkOnly) { - if (!existsSync(output)) throw new Error(`Missing generated artifact: ${output}`); - const checkedIn = readFileSync(output); - if (!checkedIn.equals(compressed)) { - throw new Error( - `Generated overview is stale: run '${process.execPath} scripts/generate-arabidopsis-overview.mjs'`, - ); - } - console.log(`Precomputed ${resolution.toLocaleString()} overview is reproducible (${compressed.byteLength.toLocaleString()} bytes)`); - } else { - writeFileSync(output, compressed); - console.log(`Wrote ${output}`); - console.log(` resolution: ${resolution.toLocaleString()}`); - console.log(` raw: ${encoded.byteLength.toLocaleString()} bytes`); - console.log(` gzip: ${compressed.byteLength.toLocaleString()} bytes`); - console.log(` sha256: ${sha256(compressed)}`); - console.log(` decoded-sha256: ${sha256(encoded)}`); - console.log(` config: ${configMetadata.digest} (${configMetadata.identity})`); - } - } -} finally { - config.free(); - session.free(); -} - -function ingestFasta(target, fasta) { - target.begin_fasta(); - const records = []; - for (let offset = 0; offset < fasta.byteLength; offset += FASTA_CHUNK_BYTES) { - const end = Math.min(fasta.byteLength, offset + FASTA_CHUNK_BYTES); - appendRecords(records, target.push_fasta_chunk(fasta.subarray(offset, end))); - } - appendRecords(records, target.finish_fasta()); - return records; -} - -function appendRecords(output, value) { - if (!Array.isArray(value)) throw new Error("Wasm FASTA parsing returned invalid record metadata"); - for (const record of value) { - if ( - typeof record !== "object" || record === null - || typeof record.name !== "string" - || !(typeof record.length === "number" || typeof record.length === "bigint") - ) { - throw new Error("Wasm FASTA parsing returned a malformed record"); - } - output.push({ name: record.name, length: Number(record.length) }); - } -} - -function parseFai(text) { - return text.split(/\r?\n/u).filter(Boolean).map((line) => { - const fields = line.split("\t"); - if (fields.length < 5) throw new Error(`Malformed FAI line: ${line}`); - const name = fields[0]; - const length = Number(fields[1]); - if (!name || !Number.isSafeInteger(length) || length <= 0) { - throw new Error(`Invalid FAI record: ${line}`); - } - return { name, length }; - }); -} - -function assertRecordsMatchFai(records, fai) { - if (records.length !== fai.length) { - throw new Error(`FASTA produced ${records.length} records but its FAI declares ${fai.length}`); - } - for (let index = 0; index < fai.length; index += 1) { - const parsed = records[index]; - const indexed = fai[index]; - if (!parsed || !indexed || parsed.name !== indexed.name || parsed.length !== indexed.length) { - throw new Error(`FASTA record ${index} does not match its FAI metadata`); - } - } -} - -function computeWorkerEquivalentSelfTiles(target, scientificConfig, resolution) { - const tiles = new Map(); - for (let y = 0; y < resolution; y += TILE_EDGE) { - for (let x = y; x < resolution; x += TILE_EDGE) { - const wasmTile = target.compute_tile_scientific(x, y, TILE_EDGE, TILE_EDGE, scientificConfig); - const tile = { - quality: "refined", - configDigest: scientificConfig.digest, - resolution, - x, - y, - width: wasmTile.width, - height: wasmTile.height, - identity: wasmTile.take_identity(), - direction: wasmTile.take_direction(), - directionSupport: wasmTile.take_direction_support(), - }; - wasmTile.free(); - tiles.set(tileKey(x, y), tile); - if (x !== y) { - const mirror = transposeTile( - tile.identity, - tile.direction, - tile.directionSupport, - tile.width, - tile.height, - ); - tiles.set(tileKey(y, x), { - ...tile, - x: y, - y: x, - width: mirror.width, - height: mirror.height, - identity: mirror.identity, - direction: mirror.direction, - directionSupport: mirror.directionSupport, - }); - } - } - } - const tilesPerAxis = Math.ceil(resolution / TILE_EDGE); - if (tiles.size !== tilesPerAxis * tilesPerAxis) { - throw new Error(`Expected ${tilesPerAxis * tilesPerAxis} full-matrix tiles, generated ${tiles.size}`); - } - return tiles; -} - -function tileKey(x, y) { - return `${x}:${y}`; -} - -function scientificMetadata(scientificConfig) { - const identity = scientificConfig.identity; - if (typeof identity !== "string" || !/^[0-9a-f]{108}$/u.test(identity)) { - throw new Error("The generated Wasm bindings do not expose the canonical ScientificConfig.identity"); - } - return { - version: scientificConfig.version, - identity, - digest: scientificConfig.digest, - hashAlgorithm: scientificConfig.hash_algorithm, - hashSeed: scientificConfig.hash_seed, - estimator: scientificConfig.estimator, - k: scientificConfig.k, - registerCount: scientificConfig.register_count, - hllPrecision: scientificConfig.hll_precision, - bBits: scientificConfig.b_bits, - verificationBits: scientificConfig.verification_bits, - identityScale: scientificConfig.identity_scale, - missingIdentity: scientificConfig.missing_identity, - }; -} - -function sourceAsset(role, path, bytes) { - return { - role, - fileName: basename(path), - byteLength: bytes.byteLength, - sha256: sha256(bytes), - }; -} - -function sha256(bytes) { - return createHash("sha256").update(bytes).digest("hex"); -} - -function deterministicGzip(bytes) { - const compressed = gzipSync(bytes, { level: 9, mtime: 0 }); - // RFC 1952 byte 9 is an informational originating-OS marker. Neutralizing it - // makes output identical across Unix, macOS, and Windows Node distributions. - compressed[9] = 255; - return compressed; -} diff --git a/scripts/generate-current-parameters.mjs b/scripts/generate-current-parameters.mjs index 6d13203..ab5bda0 100644 --- a/scripts/generate-current-parameters.mjs +++ b/scripts/generate-current-parameters.mjs @@ -112,7 +112,7 @@ const lines = [ `| Browser sparse correction | ${documentationContract.browserSparseCorrectionSummary} |`, `| Imported annotation formats | ${documentationContract.importedAnnotationSummary} |`, `| Imported annotation file limit | ${formatMib(documentationContract.annotationLimits.bytes)} |`, - `| Imported annotation matching-record limit | ${documentationContract.annotationLimits.records.toLocaleString("en-US")} per file |`, + `| Imported annotation retained-feature limit | ${documentationContract.annotationLimits.records.toLocaleString("en-US")} per file |`, `| Estimator validation schema | ${documentationContract.validationColumns.length} columns, checked against release TSV |`, `| Zoom-axis cache budget | ${formatMib(documentationContract.budgets.zoomAxisBytes)} |`, `| Prepared-axis budget | ${formatMib(documentationContract.budgets.preparedAxisBytes)} |`, diff --git a/scripts/prepare-example-assets.mjs b/scripts/prepare-example-assets.mjs deleted file mode 100644 index bfc1e1f..0000000 --- a/scripts/prepare-example-assets.mjs +++ /dev/null @@ -1,89 +0,0 @@ -import { createHash } from "node:crypto"; -import { createReadStream, createWriteStream } from "node:fs"; -import { mkdir, rename, stat, unlink } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { Transform } from "node:stream"; -import { pipeline } from "node:stream/promises"; -import { fileURLToPath } from "node:url"; -import { createGunzip } from "node:zlib"; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const compressedPath = resolve(root, "assets/example-data/Col-CEN_v1.2.fasta.gz"); -const outputPath = resolve(root, "web/public/examples/Col-CEN_v1.2.fasta"); -const temporaryPath = `${outputPath}.${process.pid}.tmp`; - -const EXPECTED_COMPRESSED_BYTES = 36_070_352; -const EXPECTED_COMPRESSED_SHA256 = "8ed838f8a405abf00e8de32bccf4f78a969b58c6ac22c3182f53e3982abc00e7"; -const EXPECTED_FASTA_BYTES = 134_282_475; -const EXPECTED_FASTA_SHA256 = "21c467339262bcc1854492451894241ac1bc31de63c5a1521caeb5439075bcd2"; - -const compressed = await fingerprint(compressedPath); -requireFingerprint("compressed Arabidopsis source", compressed, { - bytes: EXPECTED_COMPRESSED_BYTES, - sha256: EXPECTED_COMPRESSED_SHA256, -}); - -const existing = await fingerprint(outputPath, true); -if ( - existing?.bytes === EXPECTED_FASTA_BYTES - && existing.sha256 === EXPECTED_FASTA_SHA256 -) { - console.log("Bundled Arabidopsis FASTA is already prepared."); - process.exit(0); -} - -await mkdir(dirname(outputPath), { recursive: true }); -await unlink(temporaryPath).catch((error) => { - if (error?.code !== "ENOENT") throw error; -}); - -let writtenBytes = 0; -const outputHash = createHash("sha256"); -const verifier = new Transform({ - transform(chunk, _encoding, callback) { - writtenBytes += chunk.byteLength; - outputHash.update(chunk); - callback(null, chunk); - }, -}); - -try { - await pipeline( - createReadStream(compressedPath), - createGunzip(), - verifier, - createWriteStream(temporaryPath, { flags: "wx" }), - ); - const generated = { bytes: writtenBytes, sha256: outputHash.digest("hex") }; - requireFingerprint("decompressed Arabidopsis FASTA", generated, { - bytes: EXPECTED_FASTA_BYTES, - sha256: EXPECTED_FASTA_SHA256, - }); - await rename(temporaryPath, outputPath); -} catch (error) { - await unlink(temporaryPath).catch(() => undefined); - throw error; -} - -console.log(`Prepared ${outputPath} (${writtenBytes.toLocaleString("en-US")} bytes).`); - -async function fingerprint(path, optional = false) { - let metadata; - try { - metadata = await stat(path); - } catch (error) { - if (optional && error?.code === "ENOENT") return null; - throw error; - } - const hash = createHash("sha256"); - for await (const chunk of createReadStream(path)) hash.update(chunk); - return { bytes: metadata.size, sha256: hash.digest("hex") }; -} - -function requireFingerprint(label, actual, expected) { - if (actual.bytes !== expected.bytes || actual.sha256 !== expected.sha256) { - throw new Error( - `${label} failed integrity validation: received ${actual.bytes} bytes / ${actual.sha256}.`, - ); - } -} diff --git a/web/e2e/application.spec.ts b/web/e2e/application.spec.ts index 68d87a4..cb50a3c 100644 --- a/web/e2e/application.spec.ts +++ b/web/e2e/application.spec.ts @@ -4,25 +4,6 @@ import { gzipSync } from "node:zlib"; const shortFasta = ">chrShort\nACGTACGTACGTACGTACGTACGTACGTACGT\n"; -function readStoredZip(buffer: Buffer): Map { - const entries = new Map(); - let offset = 0; - while (offset + 30 <= buffer.length && buffer.readUInt32LE(offset) === 0x04034b50) { - const method = buffer.readUInt16LE(offset + 8); - if (method !== 0) throw new Error(`Unexpected ZIP compression method ${method}`); - const size = buffer.readUInt32LE(offset + 18); - const nameLength = buffer.readUInt16LE(offset + 26); - const extraLength = buffer.readUInt16LE(offset + 28); - const nameStart = offset + 30; - const dataStart = nameStart + nameLength + extraLength; - const name = buffer.subarray(nameStart, nameStart + nameLength).toString("utf8"); - entries.set(name, buffer.subarray(dataStart, dataStart + size)); - offset = dataStart + size; - } - if (buffer.readUInt32LE(offset) !== 0x02014b50) throw new Error("ZIP central directory is missing"); - return entries; -} - function deterministicDna(length: number, seed = 17): string { let state = seed; return Array.from({ length }, () => { @@ -104,10 +85,10 @@ async function canvasPaintBounds(canvas: Locator): Promise { for (let y = 0; y < element.height; y += 1) { for (let x = 0; x < element.width; x += 1) { const offset = (y * element.width + x) * 4; - const distanceFromWhite = Math.abs((pixels[offset] ?? 255) - 255) - + Math.abs((pixels[offset + 1] ?? 255) - 255) - + Math.abs((pixels[offset + 2] ?? 255) - 255); - if (distanceFromWhite <= 12) continue; + const distanceFromBackground = Math.abs((pixels[offset] ?? 5) - 5) + + Math.abs((pixels[offset + 1] ?? 5) - 5) + + Math.abs((pixels[offset + 2] ?? 6) - 6); + if (distanceFromBackground <= 12) continue; minimumX = Math.min(minimumX, x); minimumY = Math.min(minimumY, y); maximumX = Math.max(maximumX, x); @@ -194,13 +175,19 @@ test("short inputs enter exact mode without network egress", async ({ page }) => await expect(page.locator("#landing .landing-logo-heading")).toHaveCSS("width", "575px"); await expect(page.locator("#landing-version")).toHaveText(/^v\d/); await expect(page.locator(".landing-notes")).toHaveCount(0); - await expect(page.locator("#drop-title")).toHaveText("Drop FASTA files here"); + await expect(page.locator("#drop-title")).toHaveText("Drop FASTA and annotation files here"); await expect(page.locator(".drop-detail")).toHaveText( - "or click to choose files · FASTA, gzip, and BGZF optional fai/gzi indices (faster loading)", + "or click to choose · FASTA, BED, GFF3, or GTF gzip/BGZF supported · optional fai/gzi indices for FASTA", ); - await expect(page.locator(".annotation-drop-title")).toHaveText("Drop annotation files here"); - await expect(page.locator(".annotation-drop-detail")).toContainText("BED, GFF3, or GTF"); - await expect(page.locator("#annotation-drop-zone")).toBeVisible(); + await expect(page.locator("#annotation-drop-zone")).toHaveCount(0); + await expect(page.locator("#remote-fasta-form")).toBeVisible(); + await expect(page.locator("#remote-fasta-url")).toHaveAttribute( + "placeholder", + "https://example.org/assembly.fasta.gz", + ); + await expect(page.locator("#remote-fasta-submit")).toHaveText("Load URL"); + await expect(page.locator("#remote-fasta-add")).toHaveAccessibleName("Add another genome URL"); + await expect(page.locator(".remote-fasta-url")).toHaveCount(1); await expect(page.locator("#explore-button")).toBeHidden(); await expect(page.locator("#example-button")).toHaveText( "Try ModDotPlot on an Arabidopsis thaliana genome", @@ -214,7 +201,7 @@ test("short inputs enter exact mode without network egress", async ({ page }) => await expect(tutorialLink).toContainText("Browser Tutorial"); await expect(page.locator(".github-link .github-icon")).toHaveCount(2); await expect(page.locator(".publication-warning")).toHaveText( - "This web application uses WebAssembly to run ModDotPlot locally in your browser. Your files stay on your device and are not uploaded to a server. If you use ModDotPlot in your research, please cite our ModDotPlot paper. For analyses intended for publication, please use the official ModDotPlot CLI.", + "This web application uses WebAssembly to run ModDotPlot locally in your browser. Local files stay on your device and are not uploaded to a server. URL imports are fetched directly from the remote host. If you use ModDotPlot in your research, please cite our ModDotPlot paper. For analyses intended for publication, please use the official ModDotPlot CLI.", ); await expect(page.locator('.publication-warning a[href="https://doi.org/10.1093/bioinformatics/btae493"]')).toHaveText("ModDotPlot paper"); await expect(page.locator('.publication-warning a[href="https://github.com/marbl/moddotplot"]')).toHaveText("ModDotPlot CLI"); @@ -240,9 +227,8 @@ test("short inputs enter exact mode without network egress", async ({ page }) => await expect(page.locator("#status")).toContainText("Ready", { timeout: 15_000 }); await expect(page.locator("#plot-canvas")).toBeVisible(); await expect(page.locator("#plot-loading")).toBeHidden(); - await expect(page.locator("#exact-mode-controls")).toBeVisible(); - await expect(page.locator('[data-exact-view="bases"]')).toHaveAttribute("aria-pressed", "true"); - await expect(page.locator('[data-exact-view="identity"]')).toHaveAttribute("aria-pressed", "false"); + await expect(page.locator("#exact-mode-controls")).toHaveCount(0); + await expect(page.locator("[data-exact-view]")).toHaveCount(0); await expect(page.locator("[data-exact-geometry]")).toHaveCount(0); await expect(page.locator("#show-direction")).toHaveCount(0); await expect(page.locator("#exact-mode-banner")).toHaveText( @@ -252,9 +238,6 @@ test("short inputs enter exact mode without network egress", async ({ page }) => await expect(page.locator("#exact-base-legend")).toBeVisible(); await expect(page.locator("#exact-base-legend strong")).toHaveText("Base"); - await page.locator('[data-exact-view="identity"]').click(); - await expect(page.locator("#exact-base-legend")).toBeHidden(); - await expect(page.locator("#exact-mode-banner")).toContainText("color = identity"); await page.locator('[data-color-mode="direction"]').click(); await expect(page.locator('[data-color-mode="direction"]')).toHaveAttribute("aria-pressed", "true"); await expect(page.locator("#exact-base-legend")).toBeHidden(); @@ -342,119 +325,54 @@ test("Clear restores Bases with similarity coloring for a new exact session", as await expect(page.locator('[data-color-mode="similarity"]')).toHaveAttribute("aria-pressed", "true"); await expect(page.locator('[data-color-mode="direction"]')).toHaveAttribute("aria-pressed", "false"); - await expect(page.locator('[data-exact-view="bases"]')).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator("[data-exact-view]")).toHaveCount(0); await expect(page.locator("#exact-mode-banner")).toContainText("color = Base"); await expect(page.locator("#exact-base-legend")).toBeVisible(); }); -test("the bundled Arabidopsis overview reaches Ready before FASTA or annotations", async ({ page }) => { - const examplesDirectory = new URL("../public/examples/", import.meta.url); - const exampleFai = await readFile(new URL("Col-CEN_v1.2.fasta.fai", examplesDirectory)); - const exampleGff = await readFile(new URL("ColCEN_CEN180.gff3", examplesDirectory)); - const requestedAssets: string[] = []; - const fastaRequests: Array<{ range: string | null }> = []; - let releaseAnnotation = (): void => undefined; - const annotationGate = new Promise((resolve) => { - releaseAnnotation = resolve; - }); - - await page.route(/\/examples\/Col-CEN_v1\.2\.fasta$/, async (route) => { - requestedAssets.push("fasta"); - const range = route.request().headers().range ?? null; - fastaRequests.push({ range }); - if (!range) { - await route.abort("blockedbyclient"); - return; - } - await route.continue(); - }); - await page.route(/\/examples\/Col-CEN_v1\.2\.fasta\.fai$/, async (route) => { - requestedAssets.push("fai"); - await route.fulfill({ status: 200, contentType: "text/plain", body: exampleFai }); - }); - await page.route(/\/examples\/Col-CEN_v1\.2\.Chr1\.mdp-overview-v1\.gz$/, async (route) => { - requestedAssets.push("overview"); - await new Promise((resolve) => setTimeout(resolve, 100)); - await route.continue(); - }); - await page.route(/\/examples\/Col-CEN_v1\.2\.Chr1\.(?:2000|4000)\.mdp-overview-v1\.gz$/, async (route) => { - requestedAssets.push(route.request().url().includes(".2000.") ? "overview-2000" : "overview-4000"); - await route.continue(); - }); - await page.route(/\/examples\/ColCEN_CEN180\.gff3$/, async (route) => { - requestedAssets.push("gff3"); - await annotationGate; - await route.fulfill({ status: 200, contentType: "text/plain", body: exampleGff }); +test("the Arabidopsis example button streams the online Col-CEN FASTA", async ({ page }) => { + const requestedUrl = "https://github.com/schatzlab/Col-CEN/raw/main/v1.2/Col-CEN_v1.2.fasta.gz"; + const normalizedUrl = "https://raw.githubusercontent.com/schatzlab/Col-CEN/main/v1.2/Col-CEN_v1.2.fasta.gz"; + const remoteFasta = bgzfBlock([ + `>Chr1 online example\n${deterministicDna(96, 31)}`, + `>Chr2\n${deterministicDna(80, 37)}`, + ].join("\n")); + const cachedAssetRequests: string[] = []; + let remoteRequestSeen = false; + page.on("request", (request) => { + if (request.url().includes("/examples/")) cachedAssetRequests.push(request.url()); + }); + await page.route(`${normalizedUrl}.fai`, async (route) => { + await route.fulfill({ status: 404, headers: { "access-control-allow-origin": "*" } }); + }); + await page.route(`${normalizedUrl}.gzi`, async (route) => { + await route.fulfill({ status: 404, headers: { "access-control-allow-origin": "*" } }); + }); + await page.route(normalizedUrl, async (route) => { + remoteRequestSeen = true; + await route.fulfill({ + status: 200, + contentType: "application/octet-stream", + headers: { + "access-control-allow-origin": "*", + "content-length": String(remoteFasta.byteLength), + }, + body: remoteFasta, + }); }); await page.goto("/"); await page.locator("#example-button").click(); + + await expect(page.locator("#remote-fasta-url")).toHaveValue(requestedUrl); await expect(page.locator("#workspace")).toBeVisible({ timeout: 20_000 }); await expect(page.locator("#landing")).toBeHidden(); - await expect(page.locator("#self-sequence option")).toHaveCount(7); - await expect(page.locator("#self-sequence option").first()) - .toHaveText("Chr1_Col-CEN_v1.2 · 32.5 Mb"); - await expect(page.locator("#self-sequence")).toHaveValue("0"); - await expect(page.locator("#fasta-selection")).toContainText("Col-CEN_v1.2.fasta"); - await expect(page.locator("#fasta-selection")).toContainText("ready to explore"); - await expect(page.locator("#annotation-selection")).toContainText("Loading included CEN180"); - await expect(page.locator("#status")).toHaveText( - /^Ready · (?:precomputed|bundled overview cached)$/u, - { - timeout: 15_000, - }, - ); - await expect(page.locator("#plot-loading")).toBeHidden(); - await expect(page.locator("#export-data")).toBeEnabled(); - await expect(page.locator("#export-image")).toBeEnabled(); - expect(requestedAssets).toContain("fai"); - expect(requestedAssets).toContain("overview"); - expect(requestedAssets).toContain("gff3"); - expect(fastaRequests).toEqual([]); - - const summary = await hoverPlotCenter(page); - expect(summary).toMatch(/identity 100\.00%/u); - expect(summary).toMatch(/x Chr1 ·/u); - expect(summary).toMatch(/y Chr1 ·/u); - await expect.poll(() => page.locator("#identity-histogram").evaluate((canvas: HTMLCanvasElement) => { - const pixels = canvas.getContext("2d")?.getImageData(0, 0, canvas.width, canvas.height).data; - return pixels?.filter((channel, index) => index % 4 === 3 && channel !== 0).length ?? 0; - })).toBeGreaterThan(0); - - await expect.poll(() => requestedAssets.filter((asset) => asset.startsWith("overview-")).sort()) - .toEqual(["overview-2000", "overview-4000"]); - await expect(page.locator("body")).toHaveAttribute("data-bundled-detail-cache", "ready", { - timeout: 15_000, - }); - const plotCanvas = page.locator("#plot-canvas"); - await plotCanvas.focus(); - await page.keyboard.press("+"); - await expect(page.locator("#status")).toHaveText("Ready · bundled overview cached", { - timeout: 15_000, - }); - expect(fastaRequests).toEqual([]); - await page.keyboard.press("Home"); - await expect(page.locator("#status")).toHaveText("Ready · bundled overview cached", { - timeout: 15_000, - }); - - const firstRangeResponse = page.waitForResponse((response) => ( - response.url().endsWith("/examples/Col-CEN_v1.2.fasta") - && response.request().headers().range !== undefined - && response.status() === 206 - ), { timeout: 20_000 }); - await page.locator("#self-sequence").selectOption("1"); - const rangeResponse = await firstRangeResponse; - expect(rangeResponse.request().headers().range).toMatch(/^bytes=\d+-\d+$/u); - expect(await rangeResponse.headerValue("content-range")) - .toMatch(/^bytes \d+-\d+\/134282475$/u); - expect(fastaRequests.length).toBeGreaterThan(0); - expect(fastaRequests.every(({ range }) => /^bytes=\d+-\d+$/u.test(range ?? ""))).toBe(true); - - releaseAnnotation(); - await expect(page.locator("#annotation-selection")).toHaveText("ColCEN_CEN180.gff3 loaded", { - timeout: 15_000, - }); + await expect(page.locator("#self-sequence option")).toHaveCount(2); + await expect(page.locator("#self-sequence option").first()).toContainText("Chr1"); + await expect(page.locator("#fasta-selection")).toHaveText("Col-CEN_v1.2.fasta.gz ready to explore"); + await expect(page.locator("#status")).toContainText("Ready", { timeout: 15_000 }); + expect(remoteRequestSeen).toBe(true); + expect(cachedAssetRequests).toEqual([]); }); test("cursor guides follow the pointer and advanced controls change their appearance", async ({ page }) => { @@ -528,27 +446,273 @@ test("cursor guides follow the pointer and advanced controls change their appear .toBe(prepareMessages); }); -test("landing annotations stay staged until Explore opens the workspace", async ({ page }) => { +test("R toggles a fixed ruler and hover readouts switch to relative distances", async ({ page }) => { await page.goto("/"); - await page.locator("#annotation-file-input").setInputFiles({ - name: "landing.bed", - mimeType: "text/plain", - buffer: Buffer.from([ - "track name=landing-track", - "first\t1\t20\tfirst staged feature", - "second\t2\t18\tsecond staged feature", - ].join("\n")), + await loadFasta(page, "ruler.fa", `>ruler\n${deterministicDna(8_000, 13)}\n`); + await expect(page.locator("#status")).toContainText("Ready", { timeout: 15_000 }); + const bounds = await page.locator("#plot-canvas").boundingBox(); + if (!bounds) throw new Error("plot canvas has no bounds"); + + await page.mouse.move(bounds.x + bounds.width * 0.3, bounds.y + bounds.height * 0.65); + await expect(page.locator("#hover-card")).toContainText("x ruler"); + await page.keyboard.press("r"); + await expect(page.locator("#ruler-marker")).toBeVisible(); + const anchorPosition = await page.locator("#ruler-marker").evaluate((element) => ({ + x: (element as HTMLElement).style.getPropertyValue("--ruler-x"), + y: (element as HTMLElement).style.getPropertyValue("--ruler-y"), + })); + + await page.mouse.move(bounds.x + bounds.width * 0.7, bounds.y + bounds.height * 0.25); + await expect(page.locator("#hover-card")).toContainText("Δx ruler"); + await expect(page.locator("#hover-card")).toContainText("Δy ruler"); + await expect(page.locator("#hover-card")).toContainText("Ruler active"); + await expect.poll(() => page.locator("#ruler-marker").evaluate((element) => ({ + x: (element as HTMLElement).style.getPropertyValue("--ruler-x"), + y: (element as HTMLElement).style.getPropertyValue("--ruler-y"), + }))).toEqual(anchorPosition); + + await page.keyboard.press("R"); + await expect(page.locator("#ruler-marker")).toBeHidden(); + await expect(page.locator("#hover-card")).toContainText("x ruler"); + await expect(page.locator("#hover-card")).not.toContainText("Δx"); +}); + +test("the supplied Col-CEN FASTA.gz URL streams into the landing workflow", async ({ page }) => { + const requestedUrl = "https://github.com/schatzlab/Col-CEN/raw/main/v1.2/Col-CEN_v1.2.fasta.gz"; + const normalizedUrl = "https://raw.githubusercontent.com/schatzlab/Col-CEN/main/v1.2/Col-CEN_v1.2.fasta.gz"; + const remoteFasta = bgzfBlock([ + `>Chr1 remote Col-CEN test\n${deterministicDna(96, 11)}`, + `>Chr2\n${deterministicDna(80, 13)}`, + ].join("\n")); + let remoteRequestSeen = false; + await page.route(`${normalizedUrl}.fai`, async (route) => { + await route.fulfill({ status: 404, headers: { "access-control-allow-origin": "*" } }); + }); + await page.route(`${normalizedUrl}.gzi`, async (route) => { + await route.fulfill({ status: 404, headers: { "access-control-allow-origin": "*" } }); + }); + await page.route(normalizedUrl, async (route) => { + remoteRequestSeen = true; + await route.fulfill({ + status: 200, + contentType: "application/octet-stream", + headers: { + "access-control-allow-origin": "*", + "content-length": String(remoteFasta.byteLength), + }, + body: remoteFasta, + }); + }); + + await page.goto("/"); + await page.locator("#remote-fasta-url").fill(requestedUrl); + await page.locator("#remote-fasta-submit").click(); + + await expect(page.locator("#fasta-selection")).toHaveText( + "Col-CEN_v1.2.fasta.gz ready to explore", + { timeout: 20_000 }, + ); + expect(remoteRequestSeen).toBe(true); + await expect(page.locator("#remote-fasta-submit")).toBeEnabled(); + await expect(page.locator("#explore-button")).toBeVisible(); + await exploreStagedFasta(page); + await expect(page.locator("#self-sequence option")).toHaveCount(2); + await expect(page.locator("#self-sequence option").first()).toContainText("Chr1"); +}); + +test("additional URL fields load several remote genomes into one workspace", async ({ page }) => { + const sources = [ + { + url: "https://data.example/genome-a.fa", + fasta: `>genomeA\n${deterministicDna(96, 17)}\n`, + }, + { + url: "https://data.example/genome-b.fa", + fasta: `>genomeB\n${deterministicDna(80, 19)}\n`, + }, + ]; + for (const source of sources) { + await page.route(source.url, async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/plain", + headers: { + "access-control-allow-origin": "*", + "content-length": String(Buffer.byteLength(source.fasta)), + }, + body: source.fasta, + }); + }); + } + + await page.goto("/"); + await page.locator("#remote-fasta-url").fill(sources[0]!.url); + await page.locator("#remote-fasta-add").click(); + await expect(page.locator(".remote-fasta-url")).toHaveCount(2); + await expect(page.locator("#remote-fasta-submit")).toHaveText("Load URLs"); + await page.locator(".remote-fasta-url").nth(1).fill(sources[1]!.url); + await page.locator("#remote-fasta-submit").click(); + + await expect(page.locator("#fasta-selection")).toHaveText( + "genome-a.fa, genome-b.fa ready to explore", + { timeout: 20_000 }, + ); + await expect(page.locator("#self-sequence option")).toHaveCount(2); + await expect(page.locator("#self-sequence option").first()).toContainText("genomeA"); + await expect(page.locator("#self-sequence option").nth(1)).toContainText("genomeB"); + await exploreStagedFasta(page); + await expect(page.locator("#x-sequence option")).toHaveCount(2); + await expect(page.locator("#y-sequence option")).toHaveCount(2); +}); + +test("remote FAI and GZI companions avoid an eager full FASTA download", async ({ page }) => { + const fastaUrl = "https://data.example/hg002-test.fasta.gz"; + const first = deterministicDna(1_024, 71); + const second = deterministicDna(768, 89); + const firstHeader = ">remoteOne\n"; + const secondHeader = ">remoteTwo\n"; + const firstRecord = `${firstHeader}${first}\n`; + const secondRecord = `${secondHeader}${second}\n`; + const firstBlock = bgzfBlock(firstRecord); + const secondBlock = bgzfBlock(secondRecord); + const bgzf = Buffer.concat([firstBlock, secondBlock]); + const fai = [ + `remoteOne\t${first.length}\t${Buffer.byteLength(firstHeader)}\t${first.length}\t${first.length + 1}`, + `remoteTwo\t${second.length}\t${Buffer.byteLength(firstRecord) + Buffer.byteLength(secondHeader)}\t${second.length}\t${second.length + 1}`, + "", + ].join("\n"); + const gzi = gziBuffer([{ + compressed: firstBlock.byteLength, + uncompressed: Buffer.byteLength(firstRecord), + }]); + const requestedRanges: string[] = []; + let fullRequestSeen = false; + await page.route(`${fastaUrl}.fai`, async (route) => { + await route.fulfill({ + status: 200, + body: fai, + headers: { "access-control-allow-origin": "*", "content-length": String(Buffer.byteLength(fai)) }, + }); + }); + await page.route(`${fastaUrl}.gzi`, async (route) => { + await route.fulfill({ + status: 200, + body: gzi, + headers: { "access-control-allow-origin": "*", "content-length": String(gzi.byteLength) }, + }); + }); + await page.route(fastaUrl, async (route) => { + const range = route.request().headers()["range"]; + if (!range) { + fullRequestSeen = true; + await route.fulfill({ status: 200, body: bgzf, headers: { "access-control-allow-origin": "*" } }); + return; + } + requestedRanges.push(range); + const match = /^bytes=(\d+)-(\d+)$/u.exec(range); + if (!match) throw new Error(`Unexpected range ${range}`); + const start = Number(match[1]); + const end = Math.min(Number(match[2]), bgzf.byteLength - 1); + const body = bgzf.subarray(start, end + 1); + await route.fulfill({ + status: 206, + body, + headers: { + "accept-ranges": "bytes", + "access-control-allow-origin": "*", + "access-control-expose-headers": "Content-Range, Content-Length, Accept-Ranges", + "content-length": String(body.byteLength), + "content-range": `bytes ${start}-${end}/${bgzf.byteLength}`, + etag: '"indexed-fixture"', + }, + }); + }); + + await page.goto("/"); + await page.locator("#remote-fasta-url").fill(fastaUrl); + await page.locator("#remote-fasta-submit").click(); + await expect(page.locator("#fasta-selection")).toHaveText( + "hg002-test.fasta.gz ready to explore", + { timeout: 20_000 }, + ); + await expect(page.locator("#self-sequence option")).toHaveCount(2); + expect(fullRequestSeen).toBe(false); + expect(requestedRanges).toContain("bytes=0-1"); + await exploreStagedFasta(page); + await expect(page.locator("#status")).toContainText("Ready", { timeout: 20_000 }); + expect(fullRequestSeen).toBe(false); +}); + +test("live Col-CEN GitHub URL normalizes and loads the real BGZF assembly", async ({ page, browserName }) => { + test.setTimeout(150_000); + test.skip( + process.env.MODDOTPLOT_REMOTE_FASTA_LIVE !== "1" || browserName !== "chromium", + "opt-in network regression for the upstream Col-CEN archive", + ); + await page.goto("/"); + await page.locator("#remote-fasta-url").fill( + "https://github.com/schatzlab/Col-CEN/raw/main/v1.2/Col-CEN_v1.2.fasta.gz", + ); + await page.locator("#remote-fasta-submit").click(); + await expect(page.locator("#fasta-selection")).toHaveText( + "Col-CEN_v1.2.fasta.gz ready to explore", + { timeout: 120_000 }, + ); + await exploreStagedFasta(page); + await expect(page.locator("#self-sequence option", { hasText: "Chr1" })).toHaveCount(1); +}); + +test("live HG002 URL opens from remote indexes without a full assembly request", async ({ page, browserName }) => { + test.setTimeout(90_000); + test.skip( + process.env.MODDOTPLOT_HG002_LIVE !== "1" || browserName !== "chromium", + "opt-in network regression for the indexed upstream HG002 assembly", + ); + const fastaUrl = "https://s3-us-west-2.amazonaws.com/human-pangenomics/T2T/HG002/assemblies/hg002v1.2.fasta.gz"; + const fastaRequests: Array = []; + page.on("request", (request) => { + if (request.url() === fastaUrl) fastaRequests.push(request.headers()["range"]); }); + await page.goto("/"); + await page.locator("#remote-fasta-url").fill(fastaUrl); + await page.locator("#remote-fasta-submit").click(); + await expect(page.locator("#fasta-selection")).toHaveText( + "hg002v1.2.fasta.gz ready to explore", + { timeout: 60_000 }, + ); + expect(await page.locator("#self-sequence option").count()).toBeGreaterThan(20); + expect(fastaRequests).toContain("bytes=0-1"); + expect(fastaRequests).not.toContain(undefined); +}); + +test("the merged landing drop stages FASTA and annotations until Explore opens the workspace", async ({ page }) => { + await page.goto("/"); + await page.locator("#file-input").setInputFiles([ + { + name: "landing.bed", + mimeType: "text/plain", + buffer: Buffer.from([ + "track name=landing-track", + "first\t1\t20\tfirst staged feature", + "second\t2\t18\tsecond staged feature", + ].join("\n")), + }, + { + name: "multi.fa", + mimeType: "text/plain", + buffer: Buffer.from([ + `>first\n${deterministicDna(64, 3)}`, + `>second\n${deterministicDna(64, 7)}`, + ].join("\n")), + }, + ]); + await expect(page.locator("#annotation-selection")).toHaveText("landing.bed selected"); - await expect(page.locator("#annotation-drop-zone")).toHaveClass(/has-selection/); - await expect(page.locator("#explore-button")).toBeHidden(); + await expect(page.locator("#drop-zone")).toHaveClass(/has-selection/); + await expect(page.locator("#fasta-selection")).toContainText("ready to explore", { timeout: 15_000 }); + await expect(page.locator("#explore-button")).toBeVisible(); await expect(page.locator("#workspace")).toBeHidden(); - - await stageFasta(page, "multi.fa", [ - `>first\n${deterministicDna(64, 3)}`, - `>second\n${deterministicDna(64, 7)}`, - ].join("\n")); await expect(page.locator("#annotation-selection")).toHaveText("landing.bed selected"); await expect(page.locator(".imported-track-control")).toHaveCount(0); await exploreStagedFasta(page); @@ -559,7 +723,7 @@ test("landing annotations stay staged until Explore opens the workspace", async await expect(stagedTrack.locator('input[id$="-both"]')).toBeChecked(); }); -test("Save As exports current-view BEDPE and complete PNG/SVG/PDF compositions", async ({ page, browserName }) => { +test("Save As exports one ModDotPlot config and complete PNG/SVG/PDF compositions", async ({ page, browserName }) => { test.skip(browserName !== "chromium", "download payload inspection runs on the Chromium channels"); await page.addInitScript(() => { type SaveHarness = Window & { @@ -613,13 +777,13 @@ test("Save As exports current-view BEDPE and complete PNG/SVG/PDF compositions", await expect(page.locator("#status")).toContainText("Ready", { timeout: 15_000 }); await expect(page.locator("#export-data-format")).toHaveCount(0); await expect(page.locator("#export-image-format")).toHaveCount(0); - await expect(page.locator("#export-data")).toHaveText("Export ModDotPlot command"); - await expect(page.locator("#heatmap-palette")).toHaveValue("Spectral"); - await expect(page.locator("#heatmap-color-count")).toHaveValue("11"); + await expect(page.locator("#export-data")).toHaveText("Export ModDotPlot config"); + await expect(page.locator("#heatmap-palette")).toHaveValue("Viridis"); + await expect(page.locator("#heatmap-color-count")).toHaveValue("12"); await expect(page.locator("#heatmap-color-count")).toHaveAttribute("min", "3"); - await expect(page.locator("#heatmap-color-count")).toHaveAttribute("max", "11"); - await expect(page.locator('[data-background-mode="white"]')).toHaveAttribute("aria-pressed", "true"); - await expect(page.locator('[data-background-mode="black"]')).toHaveAttribute("aria-pressed", "false"); + await expect(page.locator("#heatmap-color-count")).toHaveAttribute("max", "12"); + await expect(page.locator('[data-background-mode="white"]')).toHaveAttribute("aria-pressed", "false"); + await expect(page.locator('[data-background-mode="black"]')).toHaveAttribute("aria-pressed", "true"); const exportImageFit = await page.locator("#export-image").evaluate((button) => ({ clientWidth: button.clientWidth, @@ -630,48 +794,35 @@ test("Save As exports current-view BEDPE and complete PNG/SVG/PDF compositions", expect(exportImageFit.scrollWidth).toBeLessThanOrEqual(exportImageFit.clientWidth); expect(exportImageFit.scrollHeight).toBeLessThanOrEqual(exportImageFit.clientHeight); - const commandDownload = page.waitForEvent("download"); + const configDownload = page.waitForEvent("download"); await page.locator("#export-data").click(); - const commandArtifact = await commandDownload; - expect(commandArtifact.suggestedFilename()).toBe( - "moddotplot-chrShort_short-vs-chrShort_short.zip", - ); - const commandPath = await commandArtifact.path(); - if (!commandPath) throw new Error("ModDotPlot command ZIP has no temporary path"); - const commandEntries = readStoredZip(await readFile(commandPath)); - expect([...commandEntries.keys()]).toEqual([ - "moddotplot-chrShort_short-vs-chrShort_short.bedpe", - "moddotplot-chrShort_short-vs-chrShort_short.config.json", - ]); - const bedpe = commandEntries.get("moddotplot-chrShort_short-vs-chrShort_short.bedpe")?.toString("utf8"); - if (!bedpe) throw new Error("ModDotPlot command ZIP has no BEDPE entry"); - expect(bedpe).toContain("# provenance="); - expect(bedpe).toContain("exact complete distinct canonical k-mer-set containment"); - expect(bedpe).toContain("#chrom1\tstart1\tend1\tchrom2\tstart2\tend2"); - expect(bedpe).not.toContain("quality\tconfig_digest"); + const configArtifact = await configDownload; + expect(configArtifact.suggestedFilename()).toBe("moddotplot_config.json"); + const configPath = await configArtifact.path(); + if (!configPath) throw new Error("ModDotPlot config has no temporary path"); const saveOptions = await page.evaluate(() => (window as unknown as { __saveOptions?: { startIn?: string; types?: unknown[] } }).__saveOptions); expect(saveOptions?.startIn).toBe("downloads"); expect(saveOptions?.types).toHaveLength(1); - const configEntry = commandEntries.get("moddotplot-chrShort_short-vs-chrShort_short.config.json"); - if (!configEntry) throw new Error("ModDotPlot command ZIP has no config entry"); - const cliConfig = JSON.parse(configEntry.toString("utf8")); + const cliConfig = JSON.parse((await readFile(configPath)).toString("utf8")); expect(cliConfig).toMatchObject({ - load: ["./moddotplot-chrShort_short-vs-chrShort_short.bedpe"], sequence: ["chrShort"], kmer: 21, compare_only: false, grid_only: false, - palette: "Spectral_11", + palette: "Viridis", + output_dir: "chrshort", colors: expect.any(Array), breakpoints: expect.any(Array), _moddotplot_browser: { - command: "moddotplot -c moddotplot-chrShort_short-vs-chrShort_short.config.json -l moddotplot-chrShort_short-vs-chrShort_short.bedpe", + command: "moddotplot -c moddotplot_config.json", }, }); + expect(cliConfig).not.toHaveProperty("load"); expect(cliConfig).not.toHaveProperty("fasta"); - expect(cliConfig.colors).toHaveLength(11); - expect(cliConfig.breakpoints).toHaveLength(12); + expect(cliConfig).not.toHaveProperty("modimizer"); + expect(cliConfig.colors).toHaveLength(12); + expect(cliConfig.breakpoints).toHaveLength(13); expect(cliConfig.identity).toBe(cliConfig.breakpoints[0]); await expect(page.locator("#export-progress-dialog")).toBeHidden(); @@ -814,12 +965,12 @@ test("Save As exports current-view BEDPE and complete PNG/SVG/PDF compositions", expect(bytes.includes(Buffer.from("GC%"))).toBe(true); expect(bytes.includes(Buffer.from("CpG O/E"))).toBe(true); expect(bytes.includes(Buffer.from("ANI_c"))).toBe(true); - expect(bytes.includes(Buffer.from("Viridis"))).toBe(false); + expect(bytes.includes(Buffer.from("Viridis"))).toBe(true); } } }); -test("exports wait for detailed visible tiles behind a blocking progress dialog", async ({ page, browserName }) => { +test("image exports wait for detailed visible tiles behind a blocking progress dialog", async ({ page, browserName }) => { test.skip(browserName !== "chromium", "export payload inspection runs on the Chromium channel"); await page.addInitScript(() => { const state = { bytes: [] as number[], closedCount: 0 }; @@ -842,7 +993,7 @@ test("exports wait for detailed visible tiles behind a blocking progress dialog" const sequence = deterministicDna(6_000); await loadFasta(page, "detailed.fa", `>detailed\n${sequence}\n`); await expect(page.locator("#status")).toContainText("Ready", { timeout: 30_000 }); - await page.locator("#export-data").click(); + await page.locator("#export-image").click(); await expect(page.locator("#export-progress-dialog")).toBeVisible(); expect(await page.locator("#export-progress-dialog").evaluate((dialog) => dialog.matches(":modal"))).toBe(true); await page.waitForFunction(() => @@ -851,21 +1002,7 @@ test("exports wait for detailed visible tiles behind a blocking progress dialog" await expect(page.locator("#export-progress-dialog")).toBeHidden(); const bytes = await page.evaluate(() => (window as unknown as { __detailedSave: { bytes: number[] } }).__detailedSave.bytes); - const entries = readStoredZip(Buffer.from(bytes)); - const contents = entries.get("moddotplot-detailed_detailed-vs-detailed_detailed.bedpe")?.toString("utf8"); - if (!contents) throw new Error("Detailed export ZIP has no BEDPE entry"); - expect(contents).toContain("#chrom1\tstart1\tend1\tchrom2\tstart2\tend2"); - const rows = contents.split("\n").filter((row) => /^detailed\t/.test(row)); - expect(rows.length).toBeGreaterThan(0); - expect(rows.every((row) => row.split("\t").length === 13)).toBe(true); - expect(contents).not.toContain("identity_fixed"); - expect(contents).not.toContain("config_digest"); - const config = entries.get("moddotplot-detailed_detailed-vs-detailed_detailed.config.json"); - if (!config) throw new Error("Detailed export ZIP has no config entry"); - expect(JSON.parse(config.toString("utf8"))).toMatchObject({ - load: ["./moddotplot-detailed_detailed-vs-detailed_detailed.bedpe"], - sequence: ["detailed"], - }); + expect(Buffer.from(bytes).subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); }); test("FASTA descriptions stay on axes while source-qualified IDs disambiguate selections and saves", async ({ page }) => { @@ -918,22 +1055,13 @@ test("FASTA descriptions stay on axes while source-qualified IDs disambiguate se (window as unknown as { __nativeSave?: { closedCount: number } }).__nativeSave?.closedCount === 1); const saved = await page.evaluate(() => (window as unknown as { __nativeSave: { suggestedName: string; bytes: number[] } }).__nativeSave); - expect(saved.suggestedName).toBe("moddotplot-chr1_human-vs-chr1_gorilla.zip"); - const entries = readStoredZip(Buffer.from(saved.bytes)); - const bedpe = entries.get("moddotplot-chr1_human-vs-chr1_gorilla.bedpe")?.toString("utf8"); - if (!bedpe) throw new Error("Qualified-sequence export ZIP has no BEDPE entry"); - const dataRows = bedpe.split("\n").filter((row) => row && !row.startsWith("#")); - expect(dataRows.length).toBeGreaterThan(0); - expect(dataRows.every((row) => row.startsWith("chr1\t"))).toBe(true); - expect(bedpe).not.toContain("human chromosome one\t"); - expect(bedpe).not.toContain("gorilla chromosome one\t"); - const config = entries.get("moddotplot-chr1_human-vs-chr1_gorilla.config.json"); - if (!config) throw new Error("Qualified-sequence export ZIP has no config entry"); - expect(JSON.parse(config.toString("utf8"))).toMatchObject({ - load: ["./moddotplot-chr1_human-vs-chr1_gorilla.bedpe"], + expect(saved.suggestedName).toBe("moddotplot_config.json"); + const config = JSON.parse(Buffer.from(saved.bytes).toString("utf8")); + expect(config).toMatchObject({ sequence: ["chr1", "chr1"], compare_only: true, }); + expect(config).not.toHaveProperty("load"); }); test("multi-record inputs expose self, pairwise, and bounded grid plot modes", async ({ page }) => { @@ -1021,6 +1149,8 @@ test("multi-record inputs expose self, pairwise, and bounded grid plot modes", a .toHaveCSS("grid-row-start", "4"); await expect(page.locator('.plot-grid-axis-label[data-grid-axis="y"][data-sequence-index="2"]')) .toHaveCSS("grid-row-start", "2"); + await expect(page.locator('.plot-grid-axis-label[data-grid-axis="y"][data-sequence-index="2"]')) + .toHaveCSS("writing-mode", "vertical-rl"); await expect(page.locator('.grid-plot-cell[data-grid-row="0"][data-grid-column="0"]')) .toHaveAttribute("aria-label", /Status: Ready$/); expect(await page.evaluate(() => { @@ -1044,24 +1174,16 @@ test("multi-record inputs expose self, pairwise, and bounded grid plot modes", a (window as unknown as { __gridSave?: { closedCount: number } }).__gridSave?.closedCount === 1); const gridSave = await page.evaluate(() => (window as unknown as { __gridSave: { suggestedName: string; bytes: number[] } }).__gridSave); - expect(gridSave.suggestedName).toBe( - "moddotplot-grid-alpha_three-beta_three-gamma_three.zip", - ); - const gridCommandEntries = readStoredZip(Buffer.from(gridSave.bytes)); - const gridBedpeNames = [...gridCommandEntries.keys()].filter((name) => name.endsWith(".bedpe")); - expect(gridBedpeNames).toHaveLength(6); - const gridConfigName = [...gridCommandEntries.keys()].find((name) => name.endsWith(".config.json")); - if (!gridConfigName) throw new Error("Grid ModDotPlot command ZIP has no config entry"); - const gridConfig = JSON.parse(gridCommandEntries.get(gridConfigName)!.toString("utf8")); + expect(gridSave.suggestedName).toBe("moddotplot_config.json"); + const gridConfig = JSON.parse(Buffer.from(gridSave.bytes).toString("utf8")); expect(gridConfig).toMatchObject({ sequence: ["alpha", "beta", "gamma"], grid_only: true, compare_only: false, }); - expect(gridConfig.load).toHaveLength(6); - expect(gridConfig.load).toEqual(gridBedpeNames.map((name) => `./${name}`)); + expect(gridConfig).not.toHaveProperty("load"); expect(gridConfig._moddotplot_browser.command).toBe( - `moddotplot --grid-only -c ${gridConfigName} -l ${gridBedpeNames.join(" ")}`, + "moddotplot --grid-only -c moddotplot_config.json", ); const openedGridCell = page.locator('.grid-plot-cell[data-grid-row="0"][data-grid-column="0"]'); @@ -1340,7 +1462,7 @@ test("realistic grid sketches preserve focus and avoid no-op recomputation", asy const preparesAfterDetail = await page.evaluate(() => (window as Window & { __gridPrepareRequests?: number }).__gridPrepareRequests ?? 0); expect(preparesAfterDetail).toBeGreaterThan(preparesAfterInitialGrid); - await page.locator('[data-background-mode="black"]').click(); + await page.locator('[data-background-mode="white"]').click(); expect(await page .locator('.grid-plot-cell[data-grid-row="0"][data-grid-column="1"] canvas') .evaluate((canvas: HTMLCanvasElement) => canvas.toDataURL())).toBe(retainedGridImage); @@ -1636,10 +1758,10 @@ test("exact mode keeps fixed full footprints while the initial comparison is pre await page.goto("/"); await loadFasta(page, "delayed-exact.fa", shortFasta); - await expect(page.locator("#exact-mode-controls")).toBeVisible(); + await expect(page.locator("#exact-mode-controls")).toHaveCount(0); await expect(page.locator("[data-exact-geometry]")).toHaveCount(0); await expect(page.locator("#show-direction")).toHaveCount(0); - await expect(page.locator('[data-exact-view="bases"]')).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator("[data-exact-view]")).toHaveCount(0); await expect(page.locator("#exact-mode-banner")).toContainText("full footprints"); await expect(page.locator("#scientific-provenance")).toContainText("Full footprints"); @@ -1745,7 +1867,7 @@ test("pairwise orientation, navigation, and reset remain coherent", async ({ pag await expect(canvas).toBeVisible(); }); -test("gzip FASTA and ambiguous bases load without enabling feature imports", async ({ page }) => { +test("the merged drop accepts annotations before gzip FASTA with ambiguous bases", async ({ page }) => { await page.goto("/"); const annotationDrop = await page.evaluate(() => { const data = new DataTransfer(); @@ -1755,11 +1877,10 @@ test("gzip FASTA and ambiguous bases load without enabling feature imports", asy return event.defaultPrevented; }); expect(annotationDrop).toBe(true); - await expect(page.locator("#sequence-load-dialog")).toBeVisible(); - await expect(page.locator("#sequence-load-summary")).toContainText("annotation file"); + await expect(page.locator("#annotation-selection")).toHaveText("not-sequence.bed selected"); + await expect(page.locator("#sequence-load-dialog")).toBeHidden(); await expect(page.locator("#landing")).toBeVisible(); await expect(page.locator("#workspace")).toBeHidden(); - await page.locator("#sequence-load-close").click(); await page.locator("#file-input").setInputFiles({ name: "invalid.fa", @@ -1767,7 +1888,7 @@ test("gzip FASTA and ambiguous bases load without enabling feature imports", asy buffer: Buffer.from("chr1\t0\t10\n"), }); await expect(page.locator("#sequence-load-dialog")).toBeVisible(); - await expect(page.locator("#sequence-load-summary")).toContainText("could not be parsed as FASTA"); + await expect(page.locator("#sequence-load-summary")).toContainText("could not be loaded as FASTA"); await expect(page.locator("#sequence-load-details")).toContainText("before a FASTA header"); await expect(page.locator("#landing")).toBeVisible(); await expect(page.locator("#workspace")).toBeHidden(); @@ -1787,7 +1908,7 @@ test("gzip FASTA and ambiguous bases load without enabling feature imports", asy await expect(page.locator("#x-sequence option")).toHaveText("ambiguous_ambiguous · 33 bp"); await expect(page.locator("#status")).toContainText("Ready", { timeout: 15_000 }); await expect(page.locator("#scientific-provenance")).toContainText("Exact canonical 21-mer mode"); - await expect(page.locator("#file-input")).not.toHaveAttribute("accept", /bed|gff|gtf/i); + await expect(page.locator("#file-input")).toHaveAttribute("accept", /bed.*gff.*gtf/i); }); test("BGZF multi-FASTA members load as one ordered sequence stream", async ({ page }) => { @@ -1989,6 +2110,31 @@ test("stacked sequence tracks pan with the plot and preserve its genomic viewpor && pixels[index + 3] === 255, ) ?? false; })).toBe(true); + const computedTrackHighlightWidth = (): Promise => page.locator("#gc-track-x-canvas") + .evaluate((track: HTMLCanvasElement) => { + const pixels = track.getContext("2d")?.getImageData(0, 0, track.width, track.height).data; + if (!pixels) return 0; + const highlightedColumns = new Set(); + for (let index = 0; index < pixels.length; index += 4) { + if ( + (pixels[index] ?? 0) > 240 + && (pixels[index + 1] ?? 0) > 190 + && (pixels[index + 2] ?? 255) < 160 + && (pixels[index + 3] ?? 0) > 0 + ) highlightedColumns.add((index / 4) % track.width); + } + return highlightedColumns.size; + }); + await hoverPlotCenter(page); + await expect.poll(computedTrackHighlightWidth).toBeGreaterThanOrEqual(6); + await expect(page.locator("#feature-hover-highlight")).toHaveCount(0); + const plotBoundsForHighlight = await page.locator("#plot-canvas").boundingBox(); + if (!plotBoundsForHighlight) throw new Error("Plot has no bounds"); + await page.mouse.move( + plotBoundsForHighlight.x + plotBoundsForHighlight.width / 2, + plotBoundsForHighlight.y + plotBoundsForHighlight.height / 2, + ); + await expect.poll(computedTrackHighlightWidth).toBeGreaterThanOrEqual(6); const gcBounds = await page.locator("#gc-track-x-canvas").boundingBox(); if (!gcBounds) throw new Error("GC track has no bounds"); await page.mouse.move(gcBounds.x + gcBounds.width / 2, gcBounds.y + gcBounds.height / 2); @@ -2108,8 +2254,10 @@ test("stacked sequence tracks pan with the plot and preserve its genomic viewpor expect(layout.documentHeight).toBeLessThanOrEqual(layout.viewportHeight); expect(layout.documentWidth).toBeLessThanOrEqual(layout.viewportWidth); - await page.locator("#x-track-position").selectOption("bottom"); - await page.locator("#y-track-position").selectOption("right"); + await page.locator('#x-track-position [data-track-position="bottom"]').click(); + await page.locator('#y-track-position [data-track-position="right"]').click(); + await expect(page.locator('#x-track-position [data-track-position="bottom"]')).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator('#y-track-position [data-track-position="right"]')).toHaveAttribute("aria-pressed", "true"); const movedLayout = await page.evaluate(() => { const shell = document.querySelector("#plot-shell")!; const plot = document.querySelector("#plot-frame")!.getBoundingClientRect(); @@ -2146,6 +2294,8 @@ test("BED and GFF logical tracks span matching sequences and disable by dragging await page.locator("#y-sequence").selectOption("1"); await expect(page.locator("#status")).toContainText("Ready", { timeout: 15_000 }); await page.locator(".feature-track-controls summary").click(); + await expect(page.locator("#track-file-input")).toHaveAttribute("accept", /(?:^|,)\.gz(?:,|$)/); + await expect(page.locator("#track-file-input")).toHaveAttribute("accept", /(?:^|,)\.bgz(?:,|$)/); const dropSafety = await page.evaluate(() => { const axis = document.querySelector("#x-axis-overlay")!; @@ -2205,6 +2355,15 @@ test("BED and GFF logical tracks span matching sequences and disable by dragging && (pixels[index + 2] ?? 255) < 80 && pixels[index + 3] === 255) ?? false; })).toBe(true); + await expect(page.locator("#feature-hover-highlight")).toHaveCount(0); + const plotHoverBounds = await page.locator("#plot-canvas").boundingBox(); + if (!plotHoverBounds) throw new Error("Plot has no bounds"); + await page.mouse.move( + plotHoverBounds.x + plotHoverBounds.width * (100 / 1_200), + plotHoverBounds.y + plotHoverBounds.height / 2, + ); + await expect(bedPanel).toHaveClass(/has-hover-highlight/); + await expect(bedPanel).toHaveAttribute("data-hover-highlight-count", "1"); const bedHoverBounds = await bedPanel.boundingBox(); if (!bedHoverBounds) throw new Error("BED track has no bounds"); await page.mouse.move( @@ -2222,7 +2381,7 @@ test("BED and GFF logical tracks span matching sequences and disable by dragging })).toBe(false); await page.locator("#track-file-input").setInputFiles({ - name: "genes.gff3.gz", + name: "genes.gff.gz", mimeType: "application/gzip", buffer: gzipSync(Buffer.from([ "##gff-version 3", @@ -2363,21 +2522,21 @@ test("heatmap palette and plot background controls update without recomputation" expect(paletteGroups).toEqual([ { label: "Spectral", options: 1 }, { label: "Viridis", options: 1 }, - { label: "Sequential", options: 18 }, - { label: "Diverging", options: 8 }, - { label: "Qualitative", options: 8 }, + { label: "Sequential", options: 4 }, + { label: "Diverging", options: 3 }, + { label: "Qualitative", options: 3 }, ]); - await expect(page.locator("#heatmap-palette option")).toHaveCount(36); + await expect(page.locator("#heatmap-palette option")).toHaveCount(12); await expect(page.locator("#heatmap-palette option").nth(0)).toHaveAttribute("value", "Spectral"); await expect(page.locator("#heatmap-palette option").nth(1)).toHaveAttribute("value", "Viridis"); - await expect(page.locator("#heatmap-palette")).toHaveValue("Spectral"); - await expect(page.locator("#heatmap-color-count")).toHaveValue("11"); + await expect(page.locator("#heatmap-palette")).toHaveValue("Viridis"); + await expect(page.locator("#heatmap-color-count")).toHaveValue("12"); await expect(page.locator("#heatmap-color-count")).toHaveAttribute("min", "3"); - await expect(page.locator("#heatmap-color-count")).toHaveAttribute("max", "11"); - await expect(page.locator("#palette-flip")).toHaveAttribute("aria-pressed", "true"); - await expect(page.locator(".palette-swatch")).toHaveCount(11); - await expect(page.locator(".palette-swatch").first()).toHaveCSS("background-color", "rgb(94, 79, 162)"); - await expect(page.locator(".palette-swatch").last()).toHaveCSS("background-color", "rgb(158, 1, 66)"); + await expect(page.locator("#heatmap-color-count")).toHaveAttribute("max", "12"); + await expect(page.locator("#palette-flip")).toHaveAttribute("aria-pressed", "false"); + await expect(page.locator(".palette-swatch")).toHaveCount(12); + await expect(page.locator(".palette-swatch").first()).toHaveCSS("background-color", "rgb(68, 1, 84)"); + await expect(page.locator(".palette-swatch").last()).toHaveCSS("background-color", "rgb(253, 231, 37)"); const paletteOptionLayout = await page.locator(".palette-options-row").evaluate((row) => { const count = row.querySelector("label")!.getBoundingClientRect(); const flip = row.querySelector(".palette-direction-control")!.getBoundingClientRect(); @@ -2408,24 +2567,26 @@ test("heatmap palette and plot background controls update without recomputation" const gradient = page.locator("#heatmap-gradient"); const initialGradient = await gradient.evaluate((element) => element.style.backgroundImage); - expect(initialGradient).toContain("rgb(255, 255, 255)"); - expect(initialGradient.indexOf("rgb(94, 79, 162)")).toBeLessThan( - initialGradient.indexOf("rgb(158, 1, 66)"), + expect(initialGradient).toContain("rgb(5, 5, 6)"); + expect(initialGradient.indexOf("rgb(68, 1, 84)")).toBeLessThan( + initialGradient.indexOf("rgb(253, 231, 37)"), ); await page.locator("#palette-flip").click(); - await expect(page.locator("#palette-flip")).toHaveAttribute("aria-pressed", "false"); - const canonicalGradient = await gradient.evaluate((element) => element.style.backgroundImage); - expect(canonicalGradient).not.toBe(initialGradient); - expect(canonicalGradient.indexOf("rgb(158, 1, 66)")).toBeLessThan( - canonicalGradient.indexOf("rgb(94, 79, 162)"), + await expect(page.locator("#palette-flip")).toHaveAttribute("aria-pressed", "true"); + const reversedGradient = await gradient.evaluate((element) => element.style.backgroundImage); + expect(reversedGradient).not.toBe(initialGradient); + expect(reversedGradient.indexOf("rgb(253, 231, 37)")).toBeLessThan( + reversedGradient.indexOf("rgb(68, 1, 84)"), ); await page.locator("#palette-flip").click(); - await expect(page.locator("#palette-flip")).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator("#palette-flip")).toHaveAttribute("aria-pressed", "false"); const firstPaletteSwatch = page.locator(".palette-swatch").first(); await firstPaletteSwatch.click(); await expect(page.locator("#palette-color-editor")).toBeVisible(); - await expect(page.locator("#palette-color-wheel")).toHaveValue("#5e4fa2"); + const editorBox = await page.locator("#palette-color-editor").boundingBox(); + expect(editorBox?.height).toBeLessThanOrEqual(360); + await expect(page.locator("#palette-color-wheel")).toHaveValue("#440154"); await expect(page.locator("#palette-color-hex")).toBeFocused(); await page.locator("#palette-color-hex").fill("#123456"); await expect(page.locator("#palette-color-hex")).toHaveAttribute("aria-invalid", "false"); @@ -2436,7 +2597,7 @@ test("heatmap palette and plot background controls update without recomputation" await page.screenshot({ path: process.env.MODDOTPLOT_PALETTE_QA, fullPage: true }); } await page.locator("#palette-color-reset").click(); - await expect(page.locator(".palette-swatch").first()).toHaveCSS("background-color", "rgb(94, 79, 162)"); + await expect(page.locator(".palette-swatch").first()).toHaveCSS("background-color", "rgb(68, 1, 84)"); await page.locator("#palette-color-done").click(); await expect(page.locator("#palette-color-editor")).toBeHidden(); await expect(firstPaletteSwatch).toBeFocused(); @@ -2446,6 +2607,7 @@ test("heatmap palette and plot background controls update without recomputation" await expect(page.locator("#palette-color-editor")).toBeHidden(); await expect(firstPaletteSwatch).toBeFocused(); + await page.locator("#heatmap-palette").selectOption("Spectral"); await page.locator("#heatmap-palette").selectOption("Viridis"); await expect(page.locator("#palette-flip")).toHaveAttribute("aria-pressed", "false"); await expect(page.locator("#heatmap-color-count")).toHaveValue("11"); @@ -2474,9 +2636,7 @@ test("heatmap palette and plot background controls update without recomputation" }); await expect(page.locator("#heatmap-min-value")).toHaveText("85.1%"); - await expect(page.locator('[data-background-mode="white"]')).toHaveAttribute("aria-pressed", "true"); - await expect(page.locator("#plot-frame")).not.toHaveClass(/dark-background/); - await page.locator('[data-background-mode="black"]').click(); + await expect(page.locator('[data-background-mode="black"]')).toHaveAttribute("aria-pressed", "true"); await expect(page.locator("#plot-frame")).toHaveClass(/dark-background/); await expect.poll(() => gradient.evaluate((element) => element.style.backgroundImage)) .toContain("rgb(5, 5, 6)"); diff --git a/web/e2e/exact-periodic-rendering.spec.ts b/web/e2e/exact-periodic-rendering.spec.ts index ccbc17c..5b50753 100644 --- a/web/e2e/exact-periodic-rendering.spec.ts +++ b/web/e2e/exact-periodic-rendering.spec.ts @@ -10,13 +10,8 @@ const EXACT_FOOTPRINT_DIGEST = "e2577c370efa7023a0e62bfe69c8ea9f5dc7d2cc91855bf7 type Rgb = readonly [number, number, number]; -const IDENTITY_COLORS = new Map([ - ["255,255,255", 0], // Explicit mismatch / below threshold. - ["158,1,66", 2], // Reversed Spectral maximum, 100% identity. -]); - const BASE_COLORS = new Set([ - "255,255,255", // Explicit mismatch / below threshold. + "5,5,6", // Explicit mismatch / below threshold. "51,173,61", // A "31,115,224", // C "245,163,31", // G @@ -40,7 +35,7 @@ interface DecodedPng { rgba: Uint8Array; } -test("periodic exact mode remains categorical across DPR, resize, and exact view", async ({ +test("periodic exact base mode remains categorical across DPR and resize", async ({ browser, baseURL, }) => { @@ -58,12 +53,9 @@ test("periodic exact mode remains categorical across DPR, resize, and exact view expect(await page.evaluate(() => window.devicePixelRatio)).toBe(deviceScaleFactor); await loadPeriodicFasta(page); - await expect(page.locator("#heatmap-palette")).toHaveValue("Spectral"); - await expect(page.locator("#palette-flip")).toHaveAttribute("aria-pressed", "true"); - await expect(page.locator('[data-exact-view="bases"]')).toHaveAttribute( - "aria-pressed", - "true", - ); + await expect(page.locator("#heatmap-palette")).toHaveValue("Viridis"); + await expect(page.locator("#palette-flip")).toHaveAttribute("aria-pressed", "false"); + await expect(page.locator("[data-exact-view]")).toHaveCount(0); await expect(page.locator("[data-exact-geometry]")).toHaveCount(0); await expect(page.locator("#show-direction")).toHaveCount(0); @@ -72,41 +64,21 @@ test("periodic exact mode remains categorical across DPR, resize, and exact view const footprintDigest = categoricalDigest(footprint.categories); footprintDigests.add(footprintDigest); expect(footprintDigest).toBe(EXACT_FOOTPRINT_DIGEST); - const renderedFootprintDigest = categoricalDigest( - footprint.categories.map((category) => category === 1 ? 0 : category), - ); - const bases = decodePng(await unobscuredCanvasScreenshot(page)); expectOnlyColors(bases, BASE_COLORS); - await page.locator('[data-exact-view="identity"]').click(); - await expect(page.locator('[data-exact-view="identity"]')).toHaveAttribute( - "aria-pressed", - "true", - ); - await waitForTwoFrames(page); - await setPlotCssSize(page, 720); - const identityAtIntegerScale = await captureCategoricalCanvas(page, IDENTITY_COLORS); - expect(identityAtIntegerScale.width).toBe(720 * deviceScaleFactor); - expect(identityAtIntegerScale.height).toBe(720 * deviceScaleFactor); - expect(identityAtIntegerScale.digest).toBe(renderedFootprintDigest); + const basesAtIntegerScale = decodePng(await unobscuredCanvasScreenshot(page)); + expect(basesAtIntegerScale.width).toBe(720 * deviceScaleFactor); + expect(basesAtIntegerScale.height).toBe(720 * deviceScaleFactor); + expectOnlyColors(basesAtIntegerScale, BASE_COLORS); // 701 is deliberately not divisible by 72. It exercises fractional CSS-to-cell // allocation at both one and two device pixels per CSS pixel. await setPlotCssSize(page, 701); - const identityAfterResize = await captureCategoricalCanvas(page, IDENTITY_COLORS); - expect(identityAfterResize.width).toBe(701 * deviceScaleFactor); - expect(identityAfterResize.height).toBe(701 * deviceScaleFactor); - expect(identityAfterResize.digest).toBe(renderedFootprintDigest); - - await page.locator('[data-exact-view="bases"]').click(); - await expect(page.locator('[data-exact-view="bases"]')).toHaveAttribute( - "aria-pressed", - "true", - ); - await waitForTwoFrames(page); const basesAfterResize = decodePng(await unobscuredCanvasScreenshot(page)); + expect(basesAfterResize.width).toBe(701 * deviceScaleFactor); + expect(basesAfterResize.height).toBe(701 * deviceScaleFactor); expectOnlyColors(basesAfterResize, BASE_COLORS); await context.close(); @@ -319,44 +291,6 @@ async function unobscuredCanvasScreenshot(page: Page): Promise { return page.locator("#plot-canvas").screenshot({ animations: "disabled" }); } -async function captureCategoricalCanvas( - page: Page, - colors: ReadonlyMap, -): Promise<{ width: number; height: number; digest: string }> { - const png = decodePng(await unobscuredCanvasScreenshot(page)); - expectOnlyColors(png, new Set(colors.keys())); - const categories = sampleCellCenters(png, colors); - return { - width: png.width, - height: png.height, - digest: categoricalDigest(categories), - }; -} - -function sampleCellCenters(png: DecodedPng, colors: ReadonlyMap): number[] { - const categories: number[] = []; - // PNG rows run top-to-bottom while exact tile rows run bottom-to-top. Reverse the - // sampled rows so screenshot and worker digests describe the same matrix order. - for (let tileY = 0; tileY < MATRIX_SIZE; tileY += 1) { - const screenshotY = MATRIX_SIZE - 1 - tileY; - const pixelY = Math.min( - png.height - 1, - Math.floor((screenshotY + 0.5) * png.height / MATRIX_SIZE), - ); - for (let tileX = 0; tileX < MATRIX_SIZE; tileX += 1) { - const pixelX = Math.min( - png.width - 1, - Math.floor((tileX + 0.5) * png.width / MATRIX_SIZE), - ); - const key = pixelKey(png.rgba, (pixelY * png.width + pixelX) * 4); - const category = colors.get(key); - if (category === undefined) throw new Error(`Unexpected cell-center color ${key}`); - categories.push(category); - } - } - return categories; -} - function expectOnlyColors(png: DecodedPng, expected: ReadonlySet): void { const observed = new Set(); const unexpectedAlpha = new Set(); diff --git a/web/index.html b/web/index.html index 132260a..ad0679b 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,7 @@ - +
- +
+ +
+
+ + +
+
+ +
@@ -67,7 +90,8 @@

This web application uses WebAssembly to run ModDotPlot locally in your browser. - Your files stay on your device and are not uploaded to a server. If you use + Local files stay on your device and are not uploaded to a server. URL imports + are fetched directly from the remote host. If you use ModDotPlot in your research, please cite our ModDotPlot paper. For analyses intended for publication, please use the official @@ -76,7 +100,7 @@

- - +