Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ All notable changes to ModDotPlot Browser are recorded here. The project follows

## [Unreleased]

## [1.0.4] - 2026-10-14

### Changed

* Reversed Spectral's initial direction so its red endpoint represents the highest
identity values.
* Made the identity histogram follow the visible plot region while zooming and
panning, using the finest fully loaded resolution without mixing tile levels.

## [1.0.3] - 2026-10-12

### Added
Expand Down Expand Up @@ -811,7 +820,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.3...HEAD
[Unreleased]: https://github.com/marbl/ModDotPlot-Browser/compare/v1.0.4...HEAD
[1.0.4]: https://github.com/marbl/ModDotPlot-Browser/compare/v1.0.3...v1.0.4
[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
Expand Down
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ cff-version: 1.2.0
message: "If you use ModDotPlot Browser, please cite the original ModDotPlot paper."
title: "ModDotPlot Browser"
type: software
version: 1.0.3
date-released: 2026-10-12
version: 1.0.4
date-released: 2026-10-14
repository-code: "https://github.com/marbl/ModDotPlot-Browser"
license: MIT-0
authors:
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["crates/core", "crates/wasm"]
resolver = "2"

[workspace.package]
version = "1.0.3"
version = "1.0.4"
edition = "2024"
license = "MIT-0"
repository = "https://github.com/marbl/ModDotPlot-Browser"
Expand Down
2 changes: 1 addition & 1 deletion SBOM.cdx.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"component": {
"type": "application",
"name": "moddotplot-interactive",
"version": "1.0.3",
"version": "1.0.4",
"licenses": [
{
"license": {
Expand Down
10 changes: 5 additions & 5 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,24 +260,24 @@ Package the generated static site:

```bash
tar -C web/dist \
-czf moddotplot-browser-v1.0.3-dist.tar.gz \
-czf moddotplot-browser-v1.0.4-dist.tar.gz \
.
```

Copy it to the HPC system:

```bash
scp moddotplot-browser-v1.0.3-dist.tar.gz \
scp moddotplot-browser-v1.0.4-dist.tar.gz \
USER@login.cluster.example.edu:/path/to/destination/
```

On the HPC system:

```bash
mkdir -p /path/to/moddotplot-browser-v1.0.3
mkdir -p /path/to/moddotplot-browser-v1.0.4

tar -xzf /path/to/destination/moddotplot-browser-v1.0.3-dist.tar.gz \
-C /path/to/moddotplot-browser-v1.0.3
tar -xzf /path/to/destination/moddotplot-browser-v1.0.4-dist.tar.gz \
-C /path/to/moddotplot-browser-v1.0.4
```

The extracted directory is the complete static website. It can be published through
Expand Down
4 changes: 2 additions & 2 deletions docs/RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **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.3 release. The browser remains intended for
This checklist applies to the v1.0.4 release. The browser remains intended for
exploratory use; analyses intended for publication should use the official ModDotPlot CLI.

## Source and identity
Expand Down Expand Up @@ -104,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.3 release commit to the `main` branch.
- [ ] Push the verified v1.0.4 release commit to the `main` branch.
3 changes: 3 additions & 0 deletions web/e2e/application.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { readFile } from "node:fs/promises";
import { gzipSync } from "node:zlib";
Expand Down Expand Up @@ -2166,7 +2166,7 @@

await canvas.focus();
for (let index = 0; index < 6; index += 1) await page.keyboard.press("+");
await expect(page.locator("#status")).toContainText("Ready", { timeout: 30_000 });

Check failure on line 2169 in web/e2e/application.spec.ts

View workflow job for this annotation

GitHub Actions / web

[firefox] › e2e/application.spec.ts:2072:1 › stacked sequence tracks pan with the plot and preserve its genomic viewport

1) [firefox] › e2e/application.spec.ts:2072:1 › stacked sequence tracks pan with the plot and preserve its genomic viewport Error: expect(locator).toContainText(expected) failed Locator: locator('#status') Expected substring: "Ready" Received string: "Refining zoom detail" Call log: - Expect "toContainText" with timeout 30000ms - waiting for locator('#status') 3 × locator resolved to <div id="status" role="status" class="status">Drawing quick detail</div> - unexpected value "Drawing quick detail" 4 × locator resolved to <div id="status" role="status" class="status">Drawing quick detail · 0%</div> - unexpected value "Drawing quick detail · 0%" 16 × locator resolved to <div id="status" role="status" class="status">Drawing quick detail</div> - unexpected value "Drawing quick detail" 29 × locator resolved to <div id="status" role="status" class="status">Refining zoom detail</div> - unexpected value "Refining zoom detail" - Test timeout of 30000ms exceeded. 2167 | await canvas.focus(); 2168 | for (let index = 0; index < 6; index += 1) await page.keyboard.press("+"); > 2169 | await expect(page.locator("#status")).toContainText("Ready", { timeout: 30_000 }); | ^ 2170 | await expect.poll(() => page.locator("#gc-track-x-canvas").evaluate(hasOpaqueTrackBar)).toBe(true); 2171 | const pannedFromCache = await canvas.evaluate((target) => { 2172 | const track = document.querySelector<HTMLCanvasElement>("#gc-track-x-canvas")!; at /home/runner/work/ModDotPlot-Browser/ModDotPlot-Browser/web/e2e/application.spec.ts:2169:41
await expect.poll(() => page.locator("#gc-track-x-canvas").evaluate(hasOpaqueTrackBar)).toBe(true);
const pannedFromCache = await canvas.evaluate((target) => {
const track = document.querySelector<HTMLCanvasElement>("#gc-track-x-canvas")!;
Expand Down Expand Up @@ -2662,6 +2662,9 @@
await expect(firstPaletteSwatch).toBeFocused();

await page.locator("#heatmap-palette").selectOption("Spectral");
await expect(page.locator("#palette-flip")).toHaveAttribute("aria-pressed", "true");
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 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");
Expand Down
4 changes: 2 additions & 2 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "moddotplot-interactive",
"private": true,
"version": "1.0.3",
"version": "1.0.4",
"type": "module",
"engines": {
"node": "25.9.0",
Expand Down
156 changes: 156 additions & 0 deletions web/src/histogram.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { MatrixTilePayload } from "./protocol";
import type { ViewportState } from "./viewport";

const MISSING_IDENTITY = 65_535;
const DEFAULT_TILE_SIZE = 256;
const MAX_HISTOGRAM_SAMPLES = 256 * 256;

type HistogramTile = Pick<
MatrixTilePayload,
"quality" | "resolution" | "x" | "y" | "width" | "height" | "identity"
>;

/**
* Builds an identity histogram for the visible viewport at one consistent
* resolution. The finest completely cached non-preview level wins; while a
* new detail level is loading, the complete lower-resolution level remains in
* use instead of mixing or double-counting resolutions.
*/
export function visibleIdentityHistogram(
tiles: Iterable<HistogramTile>,
baseResolution: number,
activeResolution: number,
view: ViewportState,
tileSize = DEFAULT_TILE_SIZE,
): Uint32Array {
const bins = new Uint32Array(101);
if (baseResolution <= 0 || activeResolution < baseResolution || tileSize <= 0) return bins;

const byResolution = new Map<number, Map<string, HistogramTile>>();
for (const tile of tiles) {
if (
tile.quality === "preview"
|| tile.resolution < baseResolution
|| tile.resolution > activeResolution
|| !tileIntersectsView(tile, baseResolution, view)
) continue;
let level = byResolution.get(tile.resolution);
if (!level) {
level = new Map();
byResolution.set(tile.resolution, level);
}
const key = `${tile.x}:${tile.y}`;
const prior = level.get(key);
if (!prior || qualityRank(tile.quality) > qualityRank(prior.quality)) level.set(key, tile);
}

const resolutions = [...byResolution.keys()].sort((left, right) => right - left);
for (const resolution of resolutions) {
const level = byResolution.get(resolution);
if (!level) continue;
const region = viewportCellRegion(view, baseResolution, resolution);
if (!hasCompleteCoverage(level, region, tileSize)) continue;
addVisibleIdentities(bins, level.values(), region);
break;
}
return bins;
}

interface CellRegion {
startX: number;
endX: number;
startY: number;
endY: number;
}

function viewportCellRegion(
view: ViewportState,
baseResolution: number,
resolution: number,
): CellRegion {
const scale = resolution / baseResolution;
return {
startX: clamp(Math.floor(view.x * scale), 0, resolution),
endX: clamp(Math.ceil((view.x + view.width) * scale), 0, resolution),
startY: clamp(Math.floor(view.y * scale), 0, resolution),
endY: clamp(Math.ceil((view.y + view.height) * scale), 0, resolution),
};
}

function hasCompleteCoverage(
tiles: ReadonlyMap<string, HistogramTile>,
region: CellRegion,
tileSize: number,
): boolean {
if (region.startX >= region.endX || region.startY >= region.endY) return false;
const firstX = Math.floor(region.startX / tileSize) * tileSize;
const firstY = Math.floor(region.startY / tileSize) * tileSize;
for (let y = firstY; y < region.endY; y += tileSize) {
for (let x = firstX; x < region.endX; x += tileSize) {
const tile = tiles.get(`${x}:${y}`);
if (!tile) return false;
const requiredEndX = Math.min(region.endX, x + tileSize);
const requiredEndY = Math.min(region.endY, y + tileSize);
if (
tile.x > Math.max(region.startX, x)
|| tile.y > Math.max(region.startY, y)
|| tile.x + tile.width < requiredEndX
|| tile.y + tile.height < requiredEndY
) return false;
}
}
return true;
}

function addVisibleIdentities(
bins: Uint32Array,
tiles: Iterable<HistogramTile>,
region: CellRegion,
): void {
const area = (region.endX - region.startX) * (region.endY - region.startY);
const stride = Math.max(1, Math.ceil(Math.sqrt(area / MAX_HISTOGRAM_SAMPLES)));
for (const tile of tiles) {
const startX = Math.max(region.startX, tile.x);
const endX = Math.min(region.endX, tile.x + tile.width);
const startY = Math.max(region.startY, tile.y);
const endY = Math.min(region.endY, tile.y + tile.height);
if (startX >= endX || startY >= endY) continue;
const firstY = startY + modulo(region.startY - startY, stride);
const firstX = startX + modulo(region.startX - startX, stride);
for (let y = firstY; y < endY; y += stride) {
const row = (y - tile.y) * tile.width;
for (let x = firstX; x < endX; x += stride) {
const identity = tile.identity[row + x - tile.x];
if (identity === undefined || identity === MISSING_IDENTITY) continue;
const bin = Math.min(100, Math.max(0, Math.round(identity / 100)));
bins[bin] = (bins[bin] ?? 0) + 1;
}
}
}
}

function tileIntersectsView(
tile: HistogramTile,
baseResolution: number,
view: ViewportState,
): boolean {
const scale = baseResolution / tile.resolution;
return (
tile.x * scale < view.x + view.width
&& (tile.x + tile.width) * scale > view.x
&& tile.y * scale < view.y + view.height
&& (tile.y + tile.height) * scale > view.y
);
}

function qualityRank(quality: HistogramTile["quality"]): number {
return quality === "preview" ? 0 : quality === "refined" ? 1 : 2;
}

function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}

function modulo(value: number, divisor: number): number {
return ((value % divisor) + divisor) % divisor;
}
Loading
Loading