diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c2299e..6be2598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ All notable changes to ModDotPlot Browser are recorded here. The project follows ## [Unreleased] +## [1.0.3] - 2026-10-12 + +### Added + +* Added intersecting imported annotation names to the main plot hover card, grouped + beneath their corresponding X or Y genomic coordinates. +* Added selected GC% and CpG O/E values to the main plot hover card for both axes. + +### Changed + +* Large landing-page annotations are parsed initially for only the chromosomes in + the active plot and retained for on-demand parsing when other chromosomes are + selected, reducing startup work for whole-genome GFF/GTF files. +* Reorganized plot hover details into distinct X and Y sections for faster reading. +* Simplified citation metadata and consolidated the palette license notices. + +### Fixed + +* Removed crosshair lag over dense imported annotations by caching the base feature + canvas and painting coalesced hover highlights on a separate overlay. +* Enforced the one-million-feature annotation limit cumulatively across deferred + chromosome batches from the same file. + ## [1.0.2] - 2026-10-11 ### Changed @@ -788,7 +811,8 @@ All notable changes to ModDotPlot Browser are recorded here. The project follows * 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 +[Unreleased]: https://github.com/marbl/ModDotPlot-Browser/compare/v1.0.3...HEAD +[1.0.3]: https://github.com/marbl/ModDotPlot-Browser/compare/v1.0.2...v1.0.3 [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 diff --git a/CITATION.cff b/CITATION.cff index 0a6fc86..c217005 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,16 +1,11 @@ cff-version: 1.2.0 -message: >- - If you use ModDotPlot Browser, please cite the original ModDotPlot paper listed as the - preferred citation and credit the relevant method papers listed in references. +message: "If you use ModDotPlot Browser, please cite the original ModDotPlot paper." title: "ModDotPlot Browser" type: software -version: 1.0.2 -date-released: 2026-10-11 +version: 1.0.3 +date-released: 2026-10-12 repository-code: "https://github.com/marbl/ModDotPlot-Browser" license: MIT-0 -abstract: >- - Browser-native genomic dotplot software written by ChatGPT 5.6 Sol under the - direction of Adam Phillippy. authors: - name: "ChatGPT 5.6 Sol" - family-names: "Phillippy" @@ -26,200 +21,5 @@ preferred-citation: given-names: "Michael C." - family-names: "Phillippy" given-names: "Adam M." - journal: "Bioinformatics" year: 2024 - month: 8 - volume: 40 - issue: 8 - pages: "btae493" doi: "10.1093/bioinformatics/btae493" - url: "https://pubmed.ncbi.nlm.nih.gov/39110522/" - notes: "PMID: 39110522; PMCID: PMC11321072." - -references: - - type: article - title: "Mash: fast genome and metagenome distance estimation using MinHash" - authors: - - family-names: "Ondov" - given-names: "Brian D." - - family-names: "Treangen" - given-names: "Todd J." - - family-names: "Melsted" - given-names: "Páll" - - family-names: "Mallonee" - given-names: "Adam B." - - family-names: "Bergman" - given-names: "Nicholas H." - - family-names: "Koren" - given-names: "Sergey" - - family-names: "Phillippy" - given-names: "Adam M." - journal: "Genome Biology" - year: 2016 - volume: 17 - issue: 1 - pages: "132" - doi: "10.1186/s13059-016-0997-x" - url: "https://pubmed.ncbi.nlm.nih.gov/27323842/" - - - type: article - title: "A fast approximate algorithm for mapping long reads to large reference databases" - authors: - - family-names: "Jain" - given-names: "Chirag" - - family-names: "Dilthey" - given-names: "Alexander" - - family-names: "Koren" - given-names: "Sergey" - - family-names: "Aluru" - given-names: "Srinivas" - - family-names: "Phillippy" - given-names: "Adam M." - journal: "Journal of Computational Biology" - year: 2018 - volume: 25 - issue: 7 - pages: "766-779" - doi: "10.1089/cmb.2018.0036" - url: "https://pubmed.ncbi.nlm.nih.gov/29708767/" - - - type: article - title: "Minmers are a generalization of minimizers that enable unbiased local Jaccard estimation" - authors: - - family-names: "Kille" - given-names: "Bryce" - - family-names: "Garrison" - given-names: "Erik" - - family-names: "Treangen" - given-names: "Todd J." - - family-names: "Phillippy" - given-names: "Adam M." - journal: "Bioinformatics" - year: 2023 - volume: 39 - issue: 9 - pages: "btad512" - doi: "10.1093/bioinformatics/btad512" - url: "https://academic.oup.com/bioinformatics/article/39/9/btad512/7246743" - - - type: article - title: "ntHash2: recursive spaced seed hashing for nucleotide sequences" - authors: - - family-names: "Kazemi" - given-names: "Parham" - - family-names: "Wong" - given-names: "Johnathan" - - family-names: "Nikolić" - given-names: "Vladimir" - - family-names: "Mohamadi" - given-names: "Hamid" - - family-names: "Warren" - given-names: "René L." - - family-names: "Birol" - given-names: "Inanç" - journal: "Bioinformatics" - year: 2022 - volume: 38 - issue: 20 - pages: "4812-4813" - doi: "10.1093/bioinformatics/btac564" - url: "https://academic.oup.com/bioinformatics/article/38/20/4812/6674501" - - - type: conference-paper - title: "b-Bit minwise hashing" - authors: - - family-names: "Li" - given-names: "Ping" - - family-names: "König" - given-names: "Arnd Christian" - collection-title: "Proceedings of the 19th International Conference on World Wide Web" - year: 2010 - pages: "671-680" - doi: "10.1145/1772690.1772759" - url: "https://doi.org/10.1145/1772690.1772759" - - - type: conference-paper - title: "One permutation hashing" - authors: - - family-names: "Li" - given-names: "Ping" - - family-names: "Owen" - given-names: "Art B." - - family-names: "Zhang" - given-names: "Cun-Hui" - collection-title: "Advances in Neural Information Processing Systems 25" - year: 2012 - pages: "3122-3130" - url: "https://papers.nips.cc/paper_files/paper/2012/hash/eaa32c96f620053cf442ad32258076b9-Abstract.html" - - - type: conference-paper - title: "HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm" - authors: - - family-names: "Flajolet" - given-names: "Philippe" - - family-names: "Fusy" - given-names: "Éric" - - family-names: "Gandouet" - given-names: "Olivier" - - family-names: "Meunier" - given-names: "Frédéric" - collection-title: "Discrete Mathematics and Theoretical Computer Science Proceedings, Analysis of Algorithms 2007" - year: 2007 - volume: "AH" - pages: "127-146" - url: "https://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf" - - - type: conference-paper - title: "On the resemblance and containment of documents" - authors: - - family-names: "Broder" - given-names: "Andrei Z." - collection-title: "Compression and Complexity of Sequences 1997" - year: 1997 - pages: "21-29" - doi: "10.1109/SEQUEN.1997.666900" - url: "https://doi.org/10.1109/SEQUEN.1997.666900" - - - type: software - title: "ModDotPlot" - authors: - - family-names: "Sweeten" - given-names: "Alexander P." - - family-names: "Schatz" - given-names: "Michael C." - - family-names: "Phillippy" - given-names: "Adam M." - repository-code: "https://github.com/marbl/moddotplot" - - - type: software - title: "MashMap" - authors: - - name: "MashMap contributors" - repository-code: "https://github.com/marbl/MashMap" - commit: "1a07d0e" - - - type: software - title: "ntHash" - authors: - - name: "BCGSC ntHash contributors" - repository-code: "https://github.com/bcgsc/ntHash" - commit: "c26bd4572a19de81e30d55042dbd33c1fd21d4b6" - - - type: software - title: "SMHasher and the MurmurHash3 reference implementation" - authors: - - family-names: "Appleby" - given-names: "Austin" - url: "https://github.com/aappleby/smhasher/blob/master/src/MurmurHash3.cpp" - - - type: software - title: "ColorBrewer 2" - version: "2.0" - authors: - - family-names: "Brewer" - given-names: "Cynthia" - - family-names: "Harrower" - given-names: "Mark" - - name: "The Pennsylvania State University" - repository-code: "https://github.com/axismaps/colorbrewer" - url: "https://colorbrewer2.org/" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index ca80d34..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,92 +0,0 @@ -# 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 8ecc1d6..5f5a3db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,11 +61,11 @@ dependencies = [ [[package]] name = "moddotplot-core" -version = "1.0.2" +version = "1.0.3" [[package]] name = "moddotplot-wasm" -version = "1.0.2" +version = "1.0.3" dependencies = [ "console_error_panic_hook", "moddotplot-core", diff --git a/Cargo.toml b/Cargo.toml index 7f161c4..af14512 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/core", "crates/wasm"] resolver = "2" [workspace.package] -version = "1.0.2" +version = "1.0.3" edition = "2024" license = "MIT-0" repository = "https://github.com/marbl/ModDotPlot-Browser" diff --git a/D3_SCALE_CHROMATIC_LICENSE.txt b/D3_SCALE_CHROMATIC_LICENSE.txt deleted file mode 100644 index 088e458..0000000 --- a/D3_SCALE_CHROMATIC_LICENSE.txt +++ /dev/null @@ -1,13 +0,0 @@ -Copyright 2010-2024 Mike Bostock - -Permission to use, copy, modify, and/or distribute this software for any purpose -with or without fee is hereby granted, provided that the above copyright notice -and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND -FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS -OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER -TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF -THIS SOFTWARE. diff --git a/COLORBREWER_LICENSE.txt b/PALETTE_LICENSES.txt similarity index 92% rename from COLORBREWER_LICENSE.txt rename to PALETTE_LICENSES.txt index e3a0611..6c9a38e 100644 --- a/COLORBREWER_LICENSE.txt +++ b/PALETTE_LICENSES.txt @@ -1,3 +1,4 @@ +===== ColorBrewer 2 :: Apache-2.0 ===== ColorBrewer 2 color schemes Copyright (c) 2002 Cynthia Brewer, Mark Harrower, and The Pennsylvania State University. @@ -180,3 +181,18 @@ Licensed under the Apache License, Version 2.0. of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + +===== d3-scale-chromatic 3.1.0 :: LICENSE ===== +Copyright 2010-2024 Mike Bostock + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. diff --git a/README.md b/README.md index 9ffe02f..a50201c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ * [Local/HPC Cluster Deployment](docs/DEPLOYMENT.md) * [Methods](docs/METHODS.md) * [Privacy & Data Handling](docs/PRIVACY.md) -* [Citation and method credits](docs/CITATIONS.md) +* [Citation metadata](CITATION.cff) * [Release checklist](docs/RELEASE_CHECKLIST.md) ## Citation, authorship, and license diff --git a/SBOM.cdx.json b/SBOM.cdx.json index 587a483..234c6ea 100644 --- a/SBOM.cdx.json +++ b/SBOM.cdx.json @@ -6,7 +6,7 @@ "component": { "type": "application", "name": "moddotplot-interactive", - "version": "1.0.2", + "version": "1.0.3", "licenses": [ { "license": { diff --git a/THIRD_PARTY_LICENSES.txt b/THIRD_PARTY_LICENSES.txt index 4ee3746..a1db7aa 100644 --- a/THIRD_PARTY_LICENSES.txt +++ b/THIRD_PARTY_LICENSES.txt @@ -644,6 +644,21 @@ Licensed under the Apache License, Version 2.0. END OF TERMS AND CONDITIONS +===== d3-scale-chromatic 3.1.0 :: LICENSE ===== +Copyright 2010-2024 Mike Bostock + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. + ===== assertion-error 2.0.1 :: LICENSE ===== MIT License @@ -734,21 +749,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -===== d3-scale-chromatic 3.1.0 :: LICENSE ===== -Copyright 2010-2024 Mike Bostock - -Permission to use, copy, modify, and/or distribute this software for any purpose -with or without fee is hereby granted, provided that the above copyright notice -and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND -FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS -OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER -TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF -THIS SOFTWARE. - ===== debug 4.4.3 :: LICENSE ===== (The MIT License) diff --git a/docs/CITATIONS.md b/docs/CITATIONS.md deleted file mode 100644 index 683e45e..0000000 --- a/docs/CITATIONS.md +++ /dev/null @@ -1,93 +0,0 @@ -# Citations - -> **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 - -Users of ModDotPlot Browser are requested to cite the original ModDotPlot paper: - -Sweeten AP, Schatz MC, Phillippy AM. ModDotPlot-rapid and interactive visualization of -tandem repeats. *Bioinformatics*. 2024;40(8):btae493. -[doi:10.1093/bioinformatics/btae493](https://doi.org/10.1093/bioinformatics/btae493). -PMID: 39110522; PMCID: PMC11321072. - -The structured version of this request is the `preferred-citation` entry in -[`CITATION.cff`](../CITATION.cff). - -## Scientific methods and reference implementations - -ModDotPlot Browser relies on or was validated against the following methods and reference -implementations. Cite the relevant works when publishing analyses that depend on those -components. - -### MinHash, containment, and genomic sketching - -* Broder AZ. On the resemblance and containment of documents. *Compression and - Complexity of Sequences 1997*. 1997:21-29. - [doi:10.1109/SEQUEN.1997.666900](https://doi.org/10.1109/SEQUEN.1997.666900). -* Ondov BD, Treangen TJ, Melsted P, Mallonee AB, Bergman NH, Koren S, Phillippy AM. - Mash: fast genome and metagenome distance estimation using MinHash. *Genome Biology*. - 2016;17:132. - [doi:10.1186/s13059-016-0997-x](https://doi.org/10.1186/s13059-016-0997-x). - -### Historical MashMap and minmer design references - -* Jain C, Dilthey A, Koren S, Aluru S, Phillippy AM. A fast approximate algorithm for - mapping long reads to large reference databases. *Journal of Computational Biology*. - 2018;25(7):766-779. - [doi:10.1089/cmb.2018.0036](https://doi.org/10.1089/cmb.2018.0036). -* Kille B, Garrison E, Treangen TJ, Phillippy AM. Minmers are a generalization of - minimizers that enable unbiased local Jaccard estimation. *Bioinformatics*. - 2023;39(9):btad512. - [doi:10.1093/bioinformatics/btad512](https://doi.org/10.1093/bioinformatics/btad512). - -MashMap/minmers informed the original design and Phase 0 evaluation, but are not part -of the current executable or scientific oracle. Their former reference code is -recoverable at Git tag `v0.1.0`; exact distinct canonical k-mer containment is now the -sole scientific oracle. - -### Hashing and sketch representation - -* Kazemi P, Wong J, Nikolić V, Mohamadi H, Warren RL, Birol I. ntHash2: recursive - spaced seed hashing for nucleotide sequences. *Bioinformatics*. - 2022;38(20):4812-4813. - [doi:10.1093/bioinformatics/btac564](https://doi.org/10.1093/bioinformatics/btac564). -* Li P, König AC. b-Bit minwise hashing. *Proceedings of the 19th International - Conference on World Wide Web*. 2010:671-680. - [doi:10.1145/1772690.1772759](https://doi.org/10.1145/1772690.1772759). -* Li P, Owen AB, Zhang C-H. One permutation hashing. *Advances in Neural Information - Processing Systems 25*. 2012:3122-3130. - [Proceedings record](https://papers.nips.cc/paper_files/paper/2012/hash/eaa32c96f620053cf442ad32258076b9-Abstract.html). -* Flajolet P, Fusy É, Gandouet O, Meunier F. HyperLogLog: the analysis of a near-optimal - cardinality estimation algorithm. *Discrete Mathematics and Theoretical Computer - Science Proceedings, Analysis of Algorithms 2007*. 2007;AH:127-146. - [Official paper](https://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf). - -### Historical MurmurHash3 compatibility reference - -The removed Phase 0 MashMap path was checked against Austin Appleby's public-domain -[MurmurHash3 reference implementation](https://github.com/aappleby/smhasher/blob/master/src/MurmurHash3.cpp) -and a pinned MashMap fixture. The current release compiles only ntHash2. - -## Reference software - -The structured metadata also credits the original -[ModDotPlot](https://github.com/marbl/moddotplot) implementation, -[MashMap](https://github.com/marbl/MashMap) commit `1a07d0e`, the -[ntHash](https://github.com/bcgsc/ntHash) source commit pinned in the test fixtures, -and the historically consulted SMHasher/MurmurHash3 reference implementation. - -Similarity color schemes use [ColorBrewer 2](https://colorbrewer2.org/), created by -Cynthia Brewer, Mark Harrower, and The Pennsylvania State University. Viridis color -data are adapted from -[d3-scale-chromatic 3.1.0](https://github.com/d3/d3-scale-chromatic/blob/main/src/sequential-multi/viridis.js), -Copyright 2010-2024 Mike Bostock. The application stores these color tables locally and -makes no runtime request to either source. - -## Source-level licensing - -The Rust ntHash2 implementation is a clean implementation of the published method and -constants. Its upstream license, along with the ColorBrewer and d3-scale-chromatic -notices, is included in [`THIRD_PARTY_LICENSES.txt`](../THIRD_PARTY_LICENSES.txt). -ModDotPlot Browser itself is released under MIT-0. The complete locked dependency -inventory is generated as [`SBOM.cdx.json`](../SBOM.cdx.json). diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 0bb715b..6e6b443 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,6 +1,6 @@ # Local installation and HPC deployment -> **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). +> **Note:** This document was generated using GPT-5.6 Sol and has not been thoroughly checked by a human for correctness. `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 @@ -260,24 +260,24 @@ Package the generated static site: ```bash tar -C web/dist \ - -czf moddotplot-browser-v1.0.2-dist.tar.gz \ + -czf moddotplot-browser-v1.0.3-dist.tar.gz \ . ``` Copy it to the HPC system: ```bash -scp moddotplot-browser-v1.0.2-dist.tar.gz \ +scp moddotplot-browser-v1.0.3-dist.tar.gz \ USER@login.cluster.example.edu:/path/to/destination/ ``` On the HPC system: ```bash -mkdir -p /path/to/moddotplot-browser-v1.0.2 +mkdir -p /path/to/moddotplot-browser-v1.0.3 -tar -xzf /path/to/destination/moddotplot-browser-v1.0.2-dist.tar.gz \ - -C /path/to/moddotplot-browser-v1.0.2 +tar -xzf /path/to/destination/moddotplot-browser-v1.0.3-dist.tar.gz \ + -C /path/to/moddotplot-browser-v1.0.3 ``` The extracted directory is the complete static website. It can be published through diff --git a/docs/METHODS.md b/docs/METHODS.md index fbd4d87..f4ff1a4 100644 --- a/docs/METHODS.md +++ b/docs/METHODS.md @@ -1,6 +1,6 @@ # Methods -> **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). +> **Note:** This document was generated using GPT-5.6 Sol and has not been thoroughly checked by a human for correctness. ## How the algorithm works diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index a54b1a2..799f7bd 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy and local data handling -> **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). +> **Note:** This document was generated using GPT-5.6 Sol and has not been thoroughly checked by a human for correctness. ModDotPlot Browser is designed to process genomic data locally in the user's browser. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index e7233d6..698daac 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -1,8 +1,8 @@ # Release checklist -> **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). +> **Note:** This document was generated using GPT-5.6 Sol and has not been thoroughly checked by a human for correctness. -This checklist applies to the v1.0.2 release. The browser remains intended for +This checklist applies to the v1.0.3 release. The browser remains intended for exploratory use; analyses intended for publication should use the official ModDotPlot CLI. ## Source and identity @@ -12,8 +12,7 @@ exploratory use; analyses intended for publication should use the official ModDo `moddotplot-interactive`; license is MIT-0 in the root and packaged crates. - [x] Canonical authorship is recorded in `CITATION.cff`; generated prose uses the standard file note. -- [x] `CITATION.cff` requests the original ModDotPlot citation and credits method and - historical reference implementations. +- [x] `CITATION.cff` requests the original ModDotPlot citation with concise metadata. - [x] The static application processes user-selected data locally without uploading it. ## Scientific gate @@ -105,4 +104,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.2 release commit to the `main` branch. +- [ ] Push the verified v1.0.3 release commit to the `main` branch. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index d5463b1..8a7c2e9 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -1,6 +1,6 @@ # ModDotPlot Browser User Guide & Tutorial -> **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). +Citation metadata is available in [`CITATION.cff`](../CITATION.cff). ## Table of contents diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index 57587e0..5d8010b 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -4,9 +4,7 @@ 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", "docs/METHODS.md", "docs/PRIVACY.md", diff --git a/scripts/generate-sbom.mjs b/scripts/generate-sbom.mjs index d5eb43b..9ce6294 100644 --- a/scripts/generate-sbom.mjs +++ b/scripts/generate-sbom.mjs @@ -44,10 +44,6 @@ addComponent({ { type: "vcs", url: "https://github.com/axismaps/colorbrewer" }, ], }); -licenseSections.push( - `===== ColorBrewer 2 :: Apache-2.0 =====\n${readFileSync(join(root, "COLORBREWER_LICENSE.txt"), "utf8").trim()}`, -); - addComponent({ type: "library", name: "d3-scale-chromatic", @@ -59,9 +55,7 @@ addComponent({ { type: "vcs", url: "https://github.com/d3/d3-scale-chromatic" }, ], }); -licenseSections.push( - `===== d3-scale-chromatic 3.1.0 :: LICENSE =====\n${readFileSync(join(root, "D3_SCALE_CHROMATIC_LICENSE.txt"), "utf8").trim()}`, -); +licenseSections.push(readFileSync(join(root, "PALETTE_LICENSES.txt"), "utf8").trim()); const cargo = JSON.parse(execFileSync("cargo", ["metadata", "--format-version", "1", "--locked"], { cwd: root, diff --git a/scripts/package-release.sh b/scripts/package-release.sh index d219a4c..fb91d3a 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -48,7 +48,7 @@ binary_root="$staging_directory/moddotplot-interactive-$release_version-web" mkdir -p "$binary_root/docs" cp -R web/dist/. "$binary_root/" cp README.md LICENSE CITATION.cff SBOM.cdx.json THIRD_PARTY_LICENSES.txt "$binary_root/" -cp docs/CITATIONS.md docs/DEPLOYMENT.md docs/METHODS.md docs/PRIVACY.md docs/RELEASE_CHECKLIST.md docs/USER_GUIDE.md "$binary_root/docs/" +cp docs/DEPLOYMENT.md docs/METHODS.md docs/PRIVACY.md docs/RELEASE_CHECKLIST.md docs/USER_GUIDE.md "$binary_root/docs/" find "$binary_root" -exec touch -t 198001010000 {} + ( cd "$staging_directory" diff --git a/web/e2e/application.spec.ts b/web/e2e/application.spec.ts index cb50a3c..f63005e 100644 --- a/web/e2e/application.spec.ts +++ b/web/e2e/application.spec.ts @@ -719,8 +719,11 @@ test("the merged landing drop stages FASTA and annotations until Explore opens t const stagedTrack = page.locator(".imported-track-control", { hasText: "landing-track" }); await expect(stagedTrack).toHaveCount(1, { timeout: 15_000 }); - await expect(stagedTrack).toContainText("2 matching sequences · BED · 2 displayed features"); + await expect(stagedTrack).toContainText("first · BED · 1 displayed features"); await expect(stagedTrack.locator('input[id$="-both"]')).toBeChecked(); + + await page.locator("#self-sequence").selectOption("1"); + await expect(stagedTrack).toContainText("2 matching sequences · BED · 2 displayed features", { timeout: 15_000 }); }); test("Save As exports one ModDotPlot config and complete PNG/SVG/PDF compositions", async ({ page, browserName }) => { @@ -2135,6 +2138,10 @@ test("stacked sequence tracks pan with the plot and preserve its genomic viewpor plotBoundsForHighlight.y + plotBoundsForHighlight.height / 2, ); await expect.poll(computedTrackHighlightWidth).toBeGreaterThanOrEqual(6); + await expect(page.locator("#hover-card")).toContainText(/x GC% · \d+\.\d%/); + await expect(page.locator("#hover-card")).toContainText(/x CpG O\/E · \d+\.\d{3}/); + await expect(page.locator("#hover-card")).toContainText(/y GC% · \d+\.\d%/); + await expect(page.locator("#hover-card")).toContainText(/y CpG O\/E · \d+\.\d{3}/); 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); @@ -2287,6 +2294,16 @@ test("stacked sequence tracks pan with the plot and preserve its genomic viewpor }); test("BED and GFF logical tracks span matching sequences and disable by dragging away", async ({ page }) => { + await page.addInitScript(() => { + const originalClearRect = CanvasRenderingContext2D.prototype.clearRect; + const counts = { base: 0, hover: 0 }; + Object.defineProperty(window, "__importedTrackClearCounts", { value: counts }); + CanvasRenderingContext2D.prototype.clearRect = function(x, y, width, height): void { + if (this.canvas.classList.contains("imported-feature-track-base")) counts.base += 1; + if (this.canvas.classList.contains("imported-feature-track-hover")) counts.hover += 1; + originalClearRect.call(this, x, y, width, height); + }; + }); await page.goto("/"); await page.locator("#resolution").selectOption("500", { force: true }); await loadFasta(page, "annotations.fa", `>chr1\n${"ACGT".repeat(300)}\n>chr2\n${"TGCA".repeat(300)}\n`); @@ -2344,10 +2361,13 @@ test("BED and GFF logical tracks span matching sequences and disable by dragging expect(controlLayout.listBottom).toBeLessThanOrEqual(controlLayout.addTop + 1); const bedPanel = page.locator('#x-feature-track-stack .imported-feature-track-panel:not([hidden])', { hasText: "colored" }); const bedYPanel = page.locator('#y-feature-track-stack .imported-feature-track-panel:not([hidden])', { hasText: "colored" }); + const bedCanvas = bedPanel.locator(".imported-feature-track-base"); + const bedHoverCanvas = bedPanel.locator(".imported-feature-track-hover"); + const bedYCanvas = bedYPanel.locator(".imported-feature-track-base"); await expect(bedPanel).toBeVisible(); await expect(bedYPanel).toBeVisible(); - await expect.poll(() => bedPanel.locator("canvas").evaluate(hasOpaqueTrackBar)).toBe(true); - await expect.poll(() => bedPanel.locator("canvas").evaluate((track: HTMLCanvasElement) => { + await expect.poll(() => bedCanvas.evaluate(hasOpaqueTrackBar)).toBe(true); + await expect.poll(() => bedCanvas.evaluate((track: HTMLCanvasElement) => { const pixels = track.getContext("2d")?.getImageData(0, 0, track.width, track.height).data; return pixels?.some((red, index) => index % 4 === 0 && red > 180 @@ -2356,6 +2376,13 @@ test("BED and GFF logical tracks span matching sequences and disable by dragging && pixels[index + 3] === 255) ?? false; })).toBe(true); await expect(page.locator("#feature-hover-highlight")).toHaveCount(0); + await page.evaluate(async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + const counts = (window as unknown as { __importedTrackClearCounts: { base: number; hover: number } }) + .__importedTrackClearCounts; + counts.base = 0; + counts.hover = 0; + }); const plotHoverBounds = await page.locator("#plot-canvas").boundingBox(); if (!plotHoverBounds) throw new Error("Plot has no bounds"); await page.mouse.move( @@ -2364,6 +2391,32 @@ test("BED and GFF logical tracks span matching sequences and disable by dragging ); await expect(bedPanel).toHaveClass(/has-hover-highlight/); await expect(bedPanel).toHaveAttribute("data-hover-highlight-count", "1"); + await expect.poll(() => page.evaluate(() => + (window as unknown as { __importedTrackClearCounts: { hover: number } }) + .__importedTrackClearCounts.hover)).toBeGreaterThan(0); + expect(await page.evaluate(() => + (window as unknown as { __importedTrackClearCounts: { base: number } }) + .__importedTrackClearCounts.base)).toBe(0); + await expect.poll(() => bedHoverCanvas.evaluate((track: HTMLCanvasElement) => { + const pixels = track.getContext("2d")?.getImageData(0, 0, track.width, track.height).data; + return pixels?.some((red, index) => index % 4 === 0 + && red > 240 + && (pixels[index + 1] ?? 0) > 180 + && (pixels[index + 2] ?? 255) < 160 + && (pixels[index + 3] ?? 0) > 0) ?? false; + })).toBe(true); + await expect(page.locator("#hover-card")).toContainText("x feature · red feature"); + const hoverOrder = await page.locator("#hover-card > *").evaluateAll((elements) => + elements.map((element) => ({ + text: element.textContent ?? "", + separator: element.classList.contains("hover-card-separator"), + }))); + expect(hoverOrder.findIndex(({ text }) => text.startsWith("x chr1"))) + .toBeLessThan(hoverOrder.findIndex(({ text }) => text === "x feature · red feature")); + expect(hoverOrder.findIndex(({ text }) => text === "x feature · red feature")) + .toBeLessThan(hoverOrder.findIndex(({ separator }) => separator)); + expect(hoverOrder.findIndex(({ separator }) => separator)) + .toBeLessThan(hoverOrder.findIndex(({ text }) => text.startsWith("y chr2"))); const bedHoverBounds = await bedPanel.boundingBox(); if (!bedHoverBounds) throw new Error("BED track has no bounds"); await page.mouse.move( @@ -2371,7 +2424,7 @@ test("BED and GFF logical tracks span matching sequences and disable by dragging bedHoverBounds.y + bedHoverBounds.height * 0.75, ); await expect(page.locator("#track-hover-card")).toContainText("red feature"); - await expect.poll(() => bedYPanel.locator("canvas").evaluate((track: HTMLCanvasElement) => { + await expect.poll(() => bedYCanvas.evaluate((track: HTMLCanvasElement) => { const pixels = track.getContext("2d")?.getImageData(0, 0, track.width, track.height).data; return pixels?.some((red, index) => index % 4 === 0 && red > 180 @@ -2395,7 +2448,8 @@ test("BED and GFF logical tracks span matching sequences and disable by dragging const geneRow = page.locator(".imported-track-control", { hasText: "genes" }); await expect(geneRow).toContainText("1 displayed feature"); const genePanel = page.locator('#x-feature-track-stack .imported-feature-track-panel:not([hidden])', { hasText: "genes" }); - await expect.poll(() => genePanel.locator("canvas").evaluate((track: HTMLCanvasElement) => { + const geneCanvas = genePanel.locator(".imported-feature-track-base"); + await expect.poll(() => geneCanvas.evaluate((track: HTMLCanvasElement) => { const pixels = track.getContext("2d")?.getImageData(0, 0, track.width, track.height).data; return pixels?.some((red, index) => index % 4 === 0 && red === 23 @@ -2403,7 +2457,7 @@ test("BED and GFF logical tracks span matching sequences and disable by dragging && pixels[index + 2] === 115 && pixels[index + 3] === 255) ?? false; })).toBe(true); - const exonThickness = await genePanel.locator("canvas").evaluate((track: HTMLCanvasElement) => { + const exonThickness = await geneCanvas.evaluate((track: HTMLCanvasElement) => { const pixels = track.getContext("2d")!.getImageData(0, 0, track.width, track.height).data; const count = (fraction: number): number => { const x = Math.min(track.width - 1, Math.round(track.width * fraction)); @@ -2463,7 +2517,7 @@ test("BED and GFF logical tracks span matching sequences and disable by dragging await expect(page.locator("#status")).toContainText("Ready", { timeout: 15_000 }); await expect(bedY).toBeEnabled(); await expect(page.locator('#y-feature-track-stack .imported-feature-track-panel:not([hidden])', { hasText: "colored" })).toBeVisible(); - await expect.poll(() => bedYPanel.locator("canvas").evaluate((track: HTMLCanvasElement) => { + await expect.poll(() => bedYCanvas.evaluate((track: HTMLCanvasElement) => { const pixels = track.getContext("2d")?.getImageData(0, 0, track.width, track.height).data; return pixels?.some((red, index) => index % 4 === 0 && red > 180 diff --git a/web/package-lock.json b/web/package-lock.json index 68fc62a..542963d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "moddotplot-interactive", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "moddotplot-interactive", - "version": "1.0.2", + "version": "1.0.3", "devDependencies": { "@playwright/test": "1.62.1", "typescript": "5.9.3", diff --git a/web/package.json b/web/package.json index 7602754..ebe0f2d 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "moddotplot-interactive", "private": true, - "version": "1.0.2", + "version": "1.0.3", "type": "module", "engines": { "node": "25.9.0", diff --git a/web/src/feature-track.ts b/web/src/feature-track.ts index b7cdcbf..8027fa2 100644 --- a/web/src/feature-track.ts +++ b/web/src/feature-track.ts @@ -175,6 +175,18 @@ export class FeatureTrackOverlay { this.#draw(); } + plotHoverValues(): Record { + const values: Record = { x: [], y: [] }; + for (const definition of this.#definitions) { + const coordinate = this.#hoverCoordinates[definition.axis]; + const cached = this.#cache.get(trackKey(definition.kind, definition.axis)); + if (coordinate === null || !cached) continue; + const hover = quantitativeTrackHover(definition.kind, cached, coordinate); + if (hover) values[definition.axis].push(hover); + } + return values; + } + clear(kind?: FeatureTrackKind, axis?: FeatureTrackAxis): void { for (const definition of this.#definitions) { if (kind && definition.kind !== kind) continue; @@ -309,28 +321,33 @@ export class FeatureTrackOverlay { const visibleStart = coordinate / metadata.baseResolution * metadata.domainLength; const visibleSpan = matrixSpan / metadata.baseResolution * metadata.domainLength; const genomicCoordinate = visibleStart + Math.max(0, Math.min(1, fraction)) * visibleSpan; - const cachedSpan = cached.end - cached.start; - if (cachedSpan <= 0) { + const hover = quantitativeTrackHover(definition.kind, cached, genomicCoordinate); + if (!hover) { listener(null, event); return; } - const index = Math.floor((genomicCoordinate - cached.start) / Math.max(Number.EPSILON, cachedSpan) * cached.values.length); - const rawValue = cached.values[index]; - if (rawValue === undefined || !Number.isFinite(rawValue)) { - listener(null, event); - return; - } - const start = cached.start + index / cached.values.length * cachedSpan; - const end = cached.start + (index + 1) / cached.values.length * cachedSpan; - listener({ - label: definition.kind === "gc" ? "GC%" : "CpG O/E", - value: definition.kind === "gc" ? `${(rawValue * 100).toFixed(1)}%` : rawValue.toFixed(3), - start, - end, - }, event); + listener(hover, event); } } +function quantitativeTrackHover( + kind: FeatureTrackKind, + cached: CachedTrack, + coordinate: number, +): QuantitativeTrackHover | null { + const cachedSpan = cached.end - cached.start; + if (cachedSpan <= 0 || coordinate < cached.start || coordinate >= cached.end) return null; + const index = Math.floor((coordinate - cached.start) / cachedSpan * cached.values.length); + const rawValue = cached.values[index]; + if (rawValue === undefined || !Number.isFinite(rawValue)) return null; + return { + label: kind === "gc" ? "GC%" : "CpG O/E", + value: kind === "gc" ? `${(rawValue * 100).toFixed(1)}%` : rawValue.toFixed(3), + start: cached.start + index / cached.values.length * cachedSpan, + end: cached.start + (index + 1) / cached.values.length * cachedSpan, + }; +} + function trackKey(kind: FeatureTrackKind, axis: FeatureTrackAxis): string { return `${kind}:${axis}`; } diff --git a/web/src/imported-track-types.ts b/web/src/imported-track-types.ts index bde302c..4837f0a 100644 --- a/web/src/imported-track-types.ts +++ b/web/src/imported-track-types.ts @@ -94,6 +94,8 @@ export type TrackParserRequest = requestId: number; file: File; candidates: TrackSequenceCandidate[]; + /** Features already admitted from earlier deferred batches of this file. */ + initialAdmittedFeatures: number; } | { type: "inspect"; @@ -117,6 +119,8 @@ export type TrackParserMessage = identifiers: string[]; ambiguousIdentifiers: string[]; ambiguousRecords: number; + /** Cumulative features admitted across this file's deferred batches. */ + admittedFeatures: number; } | { type: "complete"; requestId: number; report: TrackImportReport } | { type: "error"; requestId: number; message: string; issues: ImportIssue[] }; diff --git a/web/src/imported-track.ts b/web/src/imported-track.ts index 55e476d..9dbf306 100644 --- a/web/src/imported-track.ts +++ b/web/src/imported-track.ts @@ -38,7 +38,9 @@ interface ImportedTrackState { bothInput: HTMLInputElement; panels: Record; canvases: Record; + hoverCanvases: Record; hitTargets: Record; + hitTargetsByFeature: Record>; highlighted: Record>; row: HTMLElement; detail: HTMLElement; @@ -59,9 +61,19 @@ interface BatchAliasConfirmation { ambiguousIdentifiers: string[]; } +interface DeferredTrackSource { + sourceId: number; + file: File; + candidates: Map; + admittedFeatures: number; + loadedSequenceIndices: Set; + pendingSequenceIndices: Set; + queuedSequenceIndices: Set; +} + type PendingImport = | { kind: "parse"; axis: FeatureTrackAxis; sequence: SequenceTarget } - | { kind: "batch"; sourceId: number }; + | { kind: "batch"; sourceId: number; deferredSequenceIndices?: number[] }; type SequenceTarget = Pick; @@ -95,6 +107,7 @@ export class ImportedTrackManager { #worker: Worker | null = null; readonly #tracks: ImportedTrackState[] = []; readonly #pending = new Map(); + readonly #deferredSources: DeferredTrackSource[] = []; readonly #resizeObserver: ResizeObserver; #nextRequestId = 0; #nextTrackId = 0; @@ -102,6 +115,8 @@ export class ImportedTrackManager { #metadata: FeatureTrackMetadata | null = null; #view: ViewChange | null = null; #hoverCoordinates: Record = { x: null, y: null }; + readonly #pendingHoverDraws = new Map>(); + #hoverDrawFrame = 0; readonly #batchAliasConfirmations: BatchAliasConfirmation[] = []; #batchAliasConfirmationActive = false; @@ -142,6 +157,7 @@ export class ImportedTrackManager { updateSequenceContext(metadata: FeatureTrackMetadata | null): void { this.#metadata = metadata; + this.#queueDeferredTracksForContext(); this.#updateEligibilityAndDraw(); } @@ -170,6 +186,23 @@ export class ImportedTrackManager { this.#updateHoverHighlights(); } + plotHoverFeatureNames(): Record { + const names: Record> = { + x: new Set(), + y: new Set(), + }; + for (const track of this.#tracks) { + for (const axis of ["x", "y"] as const) { + if (!track.enabled[axis] || track.panels[axis].hidden) continue; + for (const feature of track.highlighted[axis]) { + const name = feature.name.trim(); + if (name) names[axis].add(name); + } + } + } + return { x: [...names.x], y: [...names.y] }; + } + estimatedBytes(): number { return this.#tracks.reduce((total, track) => total + [...track.sequenceData.values()].reduce( (sequenceTotal, sequence) => sequenceTotal + sequence.features.reduce( @@ -185,21 +218,17 @@ export class ImportedTrackManager { if (files.length > 0) this.#autoImportFiles([...files]); } - /** Matches launch-screen annotations against every record in the loaded FASTA set. */ + /** Retains launch-screen annotations for the visible chromosomes and loads others on demand. */ importFilesForSequences(files: readonly File[], sequences: readonly SequenceMetadata[]): void { if (files.length === 0 || sequences.length === 0) return; - const x = this.#options.getSequence("x"); - const candidates: TrackSequenceCandidate[] = sequences.map((sequence) => ({ - axis: sequence.index === x?.index ? "x" : "y", - index: sequence.index, - name: sequence.name, - length: sequence.length, - })); - for (const file of files) this.#startBatchParse(file, candidates); + this.#registerDeferredFiles(files, sequences); this.#options.onStatus(`Reading ${files[0]?.name ?? "feature track"}`); } clear(): void { + if (this.#hoverDrawFrame !== 0) window.cancelAnimationFrame(this.#hoverDrawFrame); + this.#hoverDrawFrame = 0; + this.#pendingHoverDraws.clear(); for (const track of this.#tracks) { this.#resizeObserver.unobserve(track.panels.x); this.#resizeObserver.unobserve(track.panels.y); @@ -208,6 +237,7 @@ export class ImportedTrackManager { track.row.remove(); } this.#tracks.length = 0; + this.#deferredSources.length = 0; this.#hoverCoordinates = { x: null, y: null }; if (this.#worker && this.#pending.size > 0) { const request: TrackParserRequest = { @@ -283,27 +313,98 @@ export class ImportedTrackManager { this.#showReport("Track import unavailable", "Load a FASTA sequence before importing a feature track.", []); return; } - const x = this.#options.getSequence("x"); - const y = this.#options.getSequence("y"); - const candidates: TrackSequenceCandidate[] = sequences.map((sequence) => ({ - axis: sequence.index === y?.index && sequence.index !== x?.index ? "y" : "x", - index: sequence.index, - name: sequence.name, - length: sequence.length, - })); + this.#registerDeferredFiles(files, sequences); + this.#options.onStatus(`Matching ${files[0]?.name ?? "feature track"}`); + } + + #registerDeferredFiles(files: readonly File[], sequences: readonly SequenceTarget[]): void { for (const file of files) { - this.#startBatchParse(file, candidates); + this.#deferredSources.push({ + sourceId: ++this.#nextSourceId, + file, + candidates: new Map(sequences.map((sequence) => [sequence.index, { + index: sequence.index, + name: sequence.name, + length: sequence.length, + }])), + admittedFeatures: 0, + loadedSequenceIndices: new Set(), + pendingSequenceIndices: new Set(), + queuedSequenceIndices: new Set(), + }); } - this.#options.onStatus(`Matching ${files[0]?.name ?? "feature track"}`); + this.#queueDeferredTracksForContext(); } - #startBatchParse(file: File, candidates: TrackSequenceCandidate[]): void { + #startBatchParse( + file: File, + candidates: TrackSequenceCandidate[], + sourceId = ++this.#nextSourceId, + deferredSequenceIndices?: number[], + initialAdmittedFeatures = 0, + ): void { const requestId = ++this.#nextRequestId; - this.#pending.set(requestId, { kind: "batch", sourceId: ++this.#nextSourceId }); - const request: TrackParserRequest = { type: "parse-batch", requestId, file, candidates }; + this.#pending.set(requestId, { kind: "batch", sourceId, deferredSequenceIndices }); + const request: TrackParserRequest = { + type: "parse-batch", + requestId, + file, + candidates, + initialAdmittedFeatures, + }; this.#parserWorker().postMessage(request); } + #queueDeferredTracksForContext(): void { + const metadata = this.#metadata; + if (!metadata) return; + const desired = new Set([metadata.xIndex, metadata.yIndex]); + for (const source of this.#deferredSources) { + for (const sequenceIndex of desired) { + if (!source.candidates.has(sequenceIndex) + || source.loadedSequenceIndices.has(sequenceIndex) + || source.pendingSequenceIndices.has(sequenceIndex)) continue; + source.queuedSequenceIndices.add(sequenceIndex); + } + this.#startNextDeferredBatch(source); + } + } + + #startNextDeferredBatch(source: DeferredTrackSource): void { + if (source.pendingSequenceIndices.size > 0 || source.queuedSequenceIndices.size === 0) return; + const metadata = this.#metadata; + const sequenceIndices = [...source.queuedSequenceIndices]; + source.queuedSequenceIndices.clear(); + const candidates = sequenceIndices.map((sequenceIndex): TrackSequenceCandidate | null => { + const sequence = source.candidates.get(sequenceIndex); + if (!sequence) return null; + const axis: FeatureTrackAxis = metadata && sequenceIndex === metadata.yIndex && metadata.yIndex !== metadata.xIndex + ? "y" + : "x"; + return { ...sequence, axis }; + }).filter((candidate): candidate is TrackSequenceCandidate => candidate !== null); + if (candidates.length === 0) return; + source.pendingSequenceIndices = new Set(candidates.map((candidate) => candidate.index)); + this.#startBatchParse( + source.file, + candidates, + source.sourceId, + [...source.pendingSequenceIndices], + source.admittedFeatures, + ); + } + + #finishDeferredBatch(pending: Extract): void { + if (!pending.deferredSequenceIndices) return; + const source = this.#deferredSources.find((candidate) => candidate.sourceId === pending.sourceId); + if (!source) return; + for (const sequenceIndex of pending.deferredSequenceIndices) { + source.loadedSequenceIndices.add(sequenceIndex); + source.pendingSequenceIndices.delete(sequenceIndex); + } + this.#startNextDeferredBatch(source); + } + #importFiles(files: File[], axis: FeatureTrackAxis): void { const sequence = this.#options.getSequence(axis); if (!sequence) { @@ -347,6 +448,7 @@ export class ImportedTrackManager { } this.#pending.delete(message.requestId); if (message.type === "error") { + if (pending.kind === "batch") this.#finishDeferredBatch(pending); this.#showReport("Track import failed", message.message, message.issues.map( (issue) => `Line ${issue.line.toLocaleString()}: ${issue.message}`, )); @@ -358,34 +460,38 @@ export class ImportedTrackManager { return; } if (message.type === "batch-complete") { - if (pending.kind === "batch") { - const exactResults = message.results.filter(({ report }) => report.matchKind === "exact"); - const aliasResults = message.results.filter(({ report }) => report.matchKind === "alias"); - for (const { candidate, report } of exactResults) { - this.#addReport( - report, - { kind: "parse", axis: candidate.axis, sequence: candidate }, - pending.sourceId, - ); - } - if (aliasResults.length > 0) { - this.#confirmBatchAliases( - aliasResults, - message.ambiguousRecords, - message.ambiguousIdentifiers, - pending.sourceId, - ); - } else if (message.ambiguousRecords > 0) { - this.#showReport( - "Some annotation records were skipped", - `${message.ambiguousRecords.toLocaleString()} records had aliases matching more than one loaded FASTA sequence.`, - [ - "Ambiguous records were not duplicated.", - `Identifiers: ${message.ambiguousIdentifiers.slice(0, 12).join(", ")}`, - ], - ); - } + if (pending.kind !== "batch") return; + const source = this.#deferredSources.find((candidate) => candidate.sourceId === pending.sourceId); + // Reserve alias results before confirmation so a following batch cannot reuse + // feature budget that is still retained by the pending dialog. + if (source) source.admittedFeatures = Math.max(source.admittedFeatures, message.admittedFeatures); + const exactResults = message.results.filter(({ report }) => report.matchKind === "exact"); + const aliasResults = message.results.filter(({ report }) => report.matchKind === "alias"); + for (const { candidate, report } of exactResults) { + this.#addReport( + report, + { kind: "parse", axis: candidate.axis, sequence: candidate }, + pending.sourceId, + ); } + if (aliasResults.length > 0) { + this.#confirmBatchAliases( + aliasResults, + message.ambiguousRecords, + message.ambiguousIdentifiers, + pending.sourceId, + ); + } else if (message.ambiguousRecords > 0) { + this.#showReport( + "Some annotation records were skipped", + `${message.ambiguousRecords.toLocaleString()} records had aliases matching more than one loaded FASTA sequence.`, + [ + "Ambiguous records were not duplicated.", + `Identifiers: ${message.ambiguousIdentifiers.slice(0, 12).join(", ")}`, + ], + ); + } + this.#finishDeferredBatch(pending); if (this.#pending.size === 0) this.#options.onIdle(); return; } @@ -537,7 +643,9 @@ export class ImportedTrackManager { bothInput: bothInput.input, panels: { x: xView.panel, y: yView.panel }, canvases: { x: xView.canvas, y: yView.canvas }, + hoverCanvases: { x: xView.hoverCanvas, y: yView.hoverCanvas }, hitTargets: { x: [], y: [] }, + hitTargetsByFeature: { x: new Map(), y: new Map() }, highlighted: { x: new Set(), y: new Set() }, row, detail, @@ -596,6 +704,7 @@ export class ImportedTrackManager { } #deleteTrack(track: ImportedTrackState): void { + this.#pendingHoverDraws.delete(track); this.#resizeObserver.unobserve(track.panels.x); this.#resizeObserver.unobserve(track.panels.y); track.panels.x.remove(); @@ -631,7 +740,10 @@ export class ImportedTrackManager { track.panels[axis].dataset.trackSize = String(importedTrackSize(track.lanes)); track.panels[axis].style.flexBasis = `${importedTrackSize(track.lanes)}px`; if (visible) this.#draw(track, axis); - else clearCanvas(track.canvases[axis]); + else { + clearCanvas(track.canvases[axis]); + clearCanvas(track.hoverCanvases[axis]); + } } #draw(track: ImportedTrackState, axis: FeatureTrackAxis): void { @@ -647,13 +759,17 @@ export class ImportedTrackManager { context.setTransform(1, 0, 0, 1, 0, 0); context.clearRect(0, 0, canvas.width, canvas.height); track.hitTargets[axis] = []; + track.hitTargetsByFeature[axis].clear(); const coordinate = axis === "x" ? view.view.x : view.view.y; const matrixSpan = axis === "x" ? view.view.width : view.view.height; const length = axis === "x" ? metadata.xLength : metadata.yLength; const visibleStart = coordinate / metadata.baseResolution * metadata.domainLength; const visibleSpan = matrixSpan / metadata.baseResolution * metadata.domainLength; const visibleEnd = Math.min(length, visibleStart + visibleSpan); - if (visibleSpan <= 0 || visibleEnd <= visibleStart) return; + if (visibleSpan <= 0 || visibleEnd <= visibleStart) { + this.#drawHoverHighlight(track, axis); + return; + } const hits: FeatureHit[] = []; const first = firstOverlappingIndex(sequenceData.prefixMaximumEnds, visibleStart); const dpr = canvas.width / Math.max(1, canvas.clientWidth); @@ -673,9 +789,23 @@ export class ImportedTrackManager { if (hit) hits.push(hit); } track.hitTargets[axis] = hits; + track.hitTargetsByFeature[axis] = new Map(hits.map((hit) => [hit.feature, hit])); + this.#drawHoverHighlight(track, axis); + } + + #drawHoverHighlight(track: ImportedTrackState, axis: FeatureTrackAxis): void { + const canvas = track.hoverCanvases[axis]; + const context = canvas.getContext("2d"); + if (!context) return; + resizeCanvas(canvas); + context.setTransform(1, 0, 0, 1, 0, 0); + context.clearRect(0, 0, canvas.width, canvas.height); + if (track.panels[axis].hidden) return; + const dpr = canvas.width / Math.max(1, canvas.clientWidth); const highlighted = track.highlighted[axis]; - for (const hit of hits) { - if (!highlighted.has(hit.feature)) continue; + for (const feature of highlighted) { + const hit = track.hitTargetsByFeature[axis].get(feature); + if (!hit) continue; context.save(); context.fillStyle = "rgba(255, 216, 77, 0.24)"; context.strokeStyle = "#ffd84d"; @@ -704,11 +834,27 @@ export class ImportedTrackManager { track.highlighted[axis] = next; track.panels[axis].dataset.hoverHighlightCount = String(next.size); track.panels[axis].classList.toggle("has-hover-highlight", next.size > 0); - if (!track.panels[axis].hidden) this.#draw(track, axis); + if (!track.panels[axis].hidden) this.#queueHoverDraw(track, axis); } } } + #queueHoverDraw(track: ImportedTrackState, axis: FeatureTrackAxis): void { + const axes = this.#pendingHoverDraws.get(track) ?? new Set(); + axes.add(axis); + this.#pendingHoverDraws.set(track, axes); + if (this.#hoverDrawFrame !== 0) return; + this.#hoverDrawFrame = window.requestAnimationFrame(() => { + this.#hoverDrawFrame = 0; + const pending = [...this.#pendingHoverDraws]; + this.#pendingHoverDraws.clear(); + for (const [pendingTrack, pendingAxes] of pending) { + if (!this.#tracks.includes(pendingTrack)) continue; + for (const pendingAxis of pendingAxes) this.#drawHoverHighlight(pendingTrack, pendingAxis); + } + }); + } + #syncBothInput(track: ImportedTrackState): void { track.bothInput.checked = track.enabled.x && track.enabled.y; track.bothInput.indeterminate = track.enabled.x !== track.enabled.y; @@ -885,7 +1031,11 @@ function smallButton(text: string, ariaLabel: string): HTMLButtonElement { return button; } -function createTrackPanel(name: string, axis: FeatureTrackAxis, id: number): { panel: HTMLElement; canvas: HTMLCanvasElement } { +function createTrackPanel( + name: string, + axis: FeatureTrackAxis, + id: number, +): { panel: HTMLElement; canvas: HTMLCanvasElement; hoverCanvas: HTMLCanvasElement } { const panel = document.createElement("div"); panel.id = `imported-track-${id}-${axis}-panel`; panel.className = `feature-track-panel feature-track-panel-${axis} imported-feature-track-panel`; @@ -894,9 +1044,13 @@ function createTrackPanel(name: string, axis: FeatureTrackAxis, id: number): { p label.className = "feature-track-name"; label.textContent = name; const canvas = document.createElement("canvas"); + canvas.className = "imported-feature-track-base"; canvas.setAttribute("aria-label", `${axis.toUpperCase()}-axis ${name} feature track`); - panel.append(label, canvas); - return { panel, canvas }; + const hoverCanvas = document.createElement("canvas"); + hoverCanvas.className = "imported-feature-track-hover"; + hoverCanvas.setAttribute("aria-hidden", "true"); + panel.append(label, canvas, hoverCanvas); + return { panel, canvas, hoverCanvas }; } function prefixMaximumEnds(features: ImportedFeature[]): number[] { diff --git a/web/src/main.ts b/web/src/main.ts index ad7ce48..977a833 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -28,6 +28,7 @@ import { featureTrackRequests, type FeatureTrackMetadata, type FeatureTrackSelection, + type QuantitativeTrackHover, } from "./feature-track"; import { DEFAULT_HEATMAP_COLOR_COUNT, @@ -73,6 +74,7 @@ import { } from "./resource-plan"; import type { ComparisonParameters, + FeatureTrackAxis, MainToWorkerMessage, RemoteFastaDownloadDescriptor, ScientificConfigMetadata, @@ -1969,6 +1971,8 @@ function showHover(datum: HoverDatum | null, event: PointerEvent): void { }; featureTracks.setPlotHover(hoverCoordinates); importedTracks.setPlotHover(hoverCoordinates); + const featureNames = importedTracks.plotHoverFeatureNames(); + const quantitativeValues = featureTracks.plotHoverValues(); const identity = datum.identity === null ? "identity unavailable" : `identity ${datum.identity.toFixed(2)}%`; @@ -1995,7 +1999,13 @@ function showHover(datum: HoverDatum | null, event: PointerEvent): void { const lines = [ Object.assign(document.createElement("strong"), { textContent: measurement }), Object.assign(document.createElement("span"), { textContent: xPosition }), + ...plotHoverAxisFeatureLines("x", featureNames.x, quantitativeValues.x), + Object.assign(document.createElement("span"), { + className: "hover-card-separator", + ariaHidden: "true", + }), Object.assign(document.createElement("span"), { textContent: yPosition }), + ...plotHoverAxisFeatureLines("y", featureNames.y, quantitativeValues.y), ]; if (delta) { lines.push(Object.assign(document.createElement("small"), { @@ -2003,7 +2013,15 @@ function showHover(datum: HoverDatum | null, event: PointerEvent): void { })); } hoverCard.replaceChildren(...lines); - plotSummary.textContent = `${measurement}; ${xPosition}; ${yPosition}.`; + const xFeatureSummary = plotHoverAxisFeatureText("x", featureNames.x, quantitativeValues.x); + const yFeatureSummary = plotHoverAxisFeatureText("y", featureNames.y, quantitativeValues.y); + plotSummary.textContent = `${[ + measurement, + xPosition, + ...xFeatureSummary, + yPosition, + ...yFeatureSummary, + ].join("; ")}.`; hoverCard.hidden = false; const frame = hoverCard.parentElement?.getBoundingClientRect(); if (frame) { @@ -2014,6 +2032,33 @@ function showHover(datum: HoverDatum | null, event: PointerEvent): void { } } +function plotHoverAxisFeatureLines( + axis: FeatureTrackAxis, + names: readonly string[], + quantitative: readonly QuantitativeTrackHover[], +): HTMLSpanElement[] { + return plotHoverAxisFeatureText(axis, names, quantitative).map((textContent) => + Object.assign(document.createElement("span"), { textContent })); +} + +function plotHoverAxisFeatureText( + axis: FeatureTrackAxis, + names: readonly string[], + quantitative: readonly QuantitativeTrackHover[], +): string[] { + const featureNames = formatPlotHoverFeatureNames(names); + return [ + featureNames ? `${axis} feature · ${featureNames}` : null, + ...quantitative.map(({ label, value }) => `${axis} ${label} · ${value}`), + ].filter((value): value is string => value !== null); +} + +function formatPlotHoverFeatureNames(names: readonly string[]): string | null { + if (names.length === 0) return null; + const visible = names.slice(0, 3).join(", "); + return names.length > 3 ? `${visible} +${names.length - 3} more` : visible; +} + function formatSignedDistance(value: number): string { const rounded = Math.round(value); if (rounded === 0) return "0 bp"; diff --git a/web/src/styles.css b/web/src/styles.css index b2e732e..99a1c24 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1021,6 +1021,7 @@ details[open] summary { margin-bottom: 4px; } } .hover-card strong { font-size: 12px; } .hover-card small { margin-top: 2px; color: #c9c5cb; } +.hover-card-separator { height: 3px; } .exact-mode-banner, .exact-base-legend { @@ -1376,6 +1377,10 @@ body.is-track-pointer-dragging * { cursor: grabbing !important; } width: 100%; height: 100%; } +.imported-feature-track-hover { + z-index: 1; + pointer-events: none; +} .feature-track-name { position: absolute; z-index: 2; diff --git a/web/src/track-parser-worker.ts b/web/src/track-parser-worker.ts index 3bdf611..9f6dea4 100644 --- a/web/src/track-parser-worker.ts +++ b/web/src/track-parser-worker.ts @@ -166,7 +166,11 @@ async function parseBatchFile( signal: AbortSignal, ): Promise { const candidates = [...new Map(request.candidates.map((candidate) => [candidate.index, candidate])).values()]; - const admission: RecordAdmission = { features: 0 }; + const admissionError = annotationAdmissionError(0, request.initialAdmittedFeatures); + if (!Number.isSafeInteger(request.initialAdmittedFeatures) || request.initialAdmittedFeatures < 0 || admissionError) { + throw new Error(admissionError ?? "Annotation feature admission state is invalid."); + } + const admission: RecordAdmission = { features: request.initialAdmittedFeatures }; const states = new Map(candidates.map((candidate) => [ candidate.index, createParseState(request.file, candidate, admission), @@ -238,6 +242,7 @@ async function parseBatchFile( identifiers: [...identifiers].slice(0, 64), ambiguousIdentifiers: [...ambiguousIdentifiers].slice(0, 64), ambiguousRecords, + admittedFeatures: admission.features, }); } diff --git a/web/tests/track-parser-scale.test.ts b/web/tests/track-parser-scale.test.ts index 82139ac..f52c28c 100644 --- a/web/tests/track-parser-scale.test.ts +++ b/web/tests/track-parser-scale.test.ts @@ -91,6 +91,7 @@ test("batch parsing reads a file once and never duplicates ambiguous aliases", a type: "parse-batch", requestId: 8, file, + initialAdmittedFeatures: 0, candidates: [ { axis: "x", index: 0, name: "chr1", length: 100 }, { axis: "y", index: 1, name: "2", length: 100 }, @@ -107,6 +108,68 @@ test("batch parsing reads a file once and never duplicates ambiguous aliases", a expect(result.results.reduce((sum, { report }) => sum + report.validRecords, 0)).toBe(2); expect(result.ambiguousRecords).toBe(1); expect(result.ambiguousIdentifiers).toEqual(["chrMT"]); + expect(result.admittedFeatures).toBe(2); +}); + +test("batch parsing admits features against the cumulative deferred-file count", async () => { + const file = new File([ + ["chr1\t0\t10\tfirst", "chr1\t10\t20\tsecond"].join("\n"), + ], "deferred-limit.bed", { type: "text/plain" }); + const terminal = new Promise((resolve) => { + vi.stubGlobal("self", { + onmessage: null as ((event: MessageEvent) => void) | null, + postMessage(message: TrackParserMessage) { + if (message.type === "batch-complete" || message.type === "error") resolve(message); + }, + }); + }); + await import("../src/track-parser-worker"); + const worker = globalThis.self as unknown as { + onmessage: (event: MessageEvent) => void; + }; + worker.onmessage({ + data: { + type: "parse-batch", + requestId: 9, + file, + initialAdmittedFeatures: MAX_IMPORTED_ANNOTATION_RECORDS - 1, + candidates: [{ axis: "x", index: 0, name: "chr1", length: 100 }], + }, + } as MessageEvent); + + const result = await terminal; + expect(result.type).toBe("error"); + if (result.type === "error") expect(result.message).toContain("retained features per file"); +}); + +test("batch parsing reports the updated cumulative deferred-file count", async () => { + const file = new File(["chr1\t0\t10\tlast"], "deferred-boundary.bed", { type: "text/plain" }); + const completed = new Promise>((resolve, reject) => { + vi.stubGlobal("self", { + onmessage: null as ((event: MessageEvent) => void) | null, + postMessage(message: TrackParserMessage) { + if (message.type === "error") reject(new Error(message.message)); + if (message.type === "batch-complete") resolve(message); + }, + }); + }); + await import("../src/track-parser-worker"); + const worker = globalThis.self as unknown as { + onmessage: (event: MessageEvent) => void; + }; + worker.onmessage({ + data: { + type: "parse-batch", + requestId: 10, + file, + initialAdmittedFeatures: MAX_IMPORTED_ANNOTATION_RECORDS - 1, + candidates: [{ axis: "x", index: 0, name: "chr1", length: 100 }], + }, + } as MessageEvent); + + const result = await completed; + expect(result.admittedFeatures).toBe(MAX_IMPORTED_ANNOTATION_RECORDS); + expect(result.results[0]?.report.tracks[0]?.features).toHaveLength(1); }); test.skipIf(!CHM13_ANNOTATION_PATH)( @@ -133,6 +196,7 @@ test.skipIf(!CHM13_ANNOTATION_PATH)( type: "parse-batch", requestId: 53, file, + initialAdmittedFeatures: 0, candidates: names.map((name, index) => ({ axis: index === 0 ? "x" : "y", index,