diff --git a/CHANGELOG.md b/CHANGELOG.md index 6be2598..e5e17c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/CITATION.cff b/CITATION.cff index c217005..620aff0 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -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: diff --git a/Cargo.lock b/Cargo.lock index 5f5a3db..98ef65a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,11 +61,11 @@ dependencies = [ [[package]] name = "moddotplot-core" -version = "1.0.3" +version = "1.0.4" [[package]] name = "moddotplot-wasm" -version = "1.0.3" +version = "1.0.4" dependencies = [ "console_error_panic_hook", "moddotplot-core", diff --git a/Cargo.toml b/Cargo.toml index af14512..8141de9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/SBOM.cdx.json b/SBOM.cdx.json index 234c6ea..bb85d6d 100644 --- a/SBOM.cdx.json +++ b/SBOM.cdx.json @@ -6,7 +6,7 @@ "component": { "type": "application", "name": "moddotplot-interactive", - "version": "1.0.3", + "version": "1.0.4", "licenses": [ { "license": { diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 6e6b443..3bbfaf5 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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 diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 698daac..4545008 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -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 @@ -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. diff --git a/web/e2e/application.spec.ts b/web/e2e/application.spec.ts index f63005e..d065ca7 100644 --- a/web/e2e/application.spec.ts +++ b/web/e2e/application.spec.ts @@ -2662,6 +2662,9 @@ test("heatmap palette and plot background controls update without recomputation" 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"); diff --git a/web/package-lock.json b/web/package-lock.json index 542963d..bd153ea 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "moddotplot-interactive", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "moddotplot-interactive", - "version": "1.0.3", + "version": "1.0.4", "devDependencies": { "@playwright/test": "1.62.1", "typescript": "5.9.3", diff --git a/web/package.json b/web/package.json index ebe0f2d..3e67590 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "moddotplot-interactive", "private": true, - "version": "1.0.3", + "version": "1.0.4", "type": "module", "engines": { "node": "25.9.0", diff --git a/web/src/histogram.ts b/web/src/histogram.ts new file mode 100644 index 0000000..fb97414 --- /dev/null +++ b/web/src/histogram.ts @@ -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, + 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>(); + 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, + 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, + 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; +} diff --git a/web/src/main.ts b/web/src/main.ts index 977a833..6232f7f 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -8,6 +8,7 @@ import { paletteColors, paletteColorCounts, paletteDefaultColorCount, + paletteDefaultReversed, } from "./palettes"; import { selectDetailResolution, selectTiles } from "./detail"; import { DnaLoader } from "./dna-loader"; @@ -33,7 +34,6 @@ import { import { DEFAULT_HEATMAP_COLOR_COUNT, DEFAULT_HEATMAP_PALETTE, - addIdentitiesToHistogram, heatmapGradient, normalizeHeatmapRange, type HeatmapPalette, @@ -348,8 +348,8 @@ let paletteReversed = DEFAULT_PALETTE_REVERSED; let activePaletteColorIndex: number | null = null; let paletteColorInvoker: HTMLButtonElement | null = null; const paletteColorOverrides = new Map(); -let histogramBins = new Uint32Array(101); -const histogramTiles = new Set(); +let histogramBins: Uint32Array = new Uint32Array(101); +let histogramFrame = 0; const GRID_OVERVIEW_RESOLUTION = 512; interface GridRunEntry { comparison: GridComparison; @@ -519,7 +519,7 @@ function handleWorkerMessage(event: MessageEvent): void { exportImageButton.disabled = false; } setPlotLoading(false); - collectHistogram(message); + queueHistogramUpdate(); updateMemory(); break; case "complete": @@ -719,7 +719,7 @@ for (const [name, input] of Object.entries(heatmapInputs) as Array<[keyof Heatma heatmapPaletteSelect.addEventListener("change", () => { const palette = selectedHeatmapPalette(); updatePaletteColorCountControl(palette); - paletteReversed = palette === DEFAULT_HEATMAP_PALETTE && DEFAULT_PALETTE_REVERSED; + paletteReversed = paletteDefaultReversed(palette); closePaletteColorEditor(false); updatePaletteDirectionControl(); renderPaletteSwatches(); @@ -821,6 +821,7 @@ renderer.onViewChange((change) => { if (activeFeatureTrackMetadata) featureTracks.setView(activeFeatureTrackMetadata, change); if (activeFeatureTrackMetadata) importedTracks.setView(activeFeatureTrackMetadata, change); if (comparisonReady) { + queueHistogramUpdate(); queueFeatureTracks(40); cancelAndQueueVisibleDetail(240); } @@ -846,6 +847,7 @@ document.addEventListener("keydown", (event) => { showHover(lastHoverDatum, lastHoverEvent); }); renderer.onTileEvict((tile) => { + queueHistogramUpdate(); post({ type: "evict-tiles", generation, tiles: [tile] }); }); populatePaletteControls(); @@ -1760,7 +1762,7 @@ function prepareComparison(): void { importedTracks.updateSequenceContext(activeFeatureTrackMetadata); pendingView = null; histogramBins = new Uint32Array(101); - histogramTiles.clear(); + cancelHistogramUpdate(); if (!preserveViewport) featureTracks.clear(); drawHistogram(); setPlotLoading(true); @@ -1827,6 +1829,7 @@ function requestVisibleDetail(): void { updateScientificProvenance(activeConfigurations, parameters.k); updateExactModeUi(); renderer.setActiveResolution(resolution); + queueHistogramUpdate(); const tiles = selectTiles(resolution, parameters.resolution, change.view); const computeLabel = exactKmerLevel ? `Drawing exact k-mers ยท ${exactGeometryLabel(exactGeometry).toLowerCase()}` @@ -2359,15 +2362,19 @@ function selectedColorMode(): "similarity" | "direction" { ?.dataset.colorMode === "direction" ? "direction" : "similarity"; } -function collectHistogram(message: Extract): void { - if (!activeParameters || message.resolution !== activeParameters.resolution) return; - const finalQuality = "refined"; - if (message.quality !== finalQuality) return; - const key = `${message.x}:${message.y}`; - if (histogramTiles.has(key)) return; - histogramTiles.add(key); - addIdentitiesToHistogram(histogramBins, message.identity); - drawHistogram(); +function queueHistogramUpdate(): void { + if (plotMode === "grid" || histogramFrame !== 0) return; + histogramFrame = window.requestAnimationFrame(() => { + histogramFrame = 0; + histogramBins = renderer.visibleIdentityHistogram(); + drawHistogram(); + }); +} + +function cancelHistogramUpdate(): void { + if (histogramFrame === 0) return; + window.cancelAnimationFrame(histogramFrame); + histogramFrame = 0; } function drawHistogram(): void { @@ -2567,7 +2574,7 @@ function clearSession(): void { sequenceLoadPending = false; wasmBytes = 0; histogramBins = new Uint32Array(101); - histogramTiles.clear(); + cancelHistogramUpdate(); clearGrid(); activeRuler = null; lastHoverDatum = null; @@ -2796,6 +2803,7 @@ function prepareDetailedExport( updateScientificProvenance(activeConfigurations, parameters.k); updateExactModeUi(); renderer.setActiveResolution(resolution); + queueHistogramUpdate(); const tiles = selectTiles(resolution, parameters.resolution, change.view, 256, 0); if (renderer.hasDetailedTileCoverage(resolution, tiles)) return Promise.resolve(); tileRequestId += 1; diff --git a/web/src/palettes.ts b/web/src/palettes.ts index 534378b..cd0c5d3 100644 --- a/web/src/palettes.ts +++ b/web/src/palettes.ts @@ -86,7 +86,12 @@ export const PALETTE_IDS: readonly PaletteId[] = Object.freeze( export const DEFAULT_PALETTE: PaletteId = VIRIDIS_PALETTE_ID; export const DEFAULT_PALETTE_COLOR_COUNT = 12; -export const DEFAULT_PALETTE_REVERSED = false; +export const DEFAULT_PALETTE_REVERSED = paletteDefaultReversed(DEFAULT_PALETTE); + +/** Spectral reads from cool low-identity colors to red high-identity colors by default. */ +export function paletteDefaultReversed(palette: PaletteId): boolean { + return palette === "Spectral"; +} // Published as a compact RGB table by d3-scale-chromatic. Its ramp selects // floor(t * 256), clamped to the final color; buildViridisPalette preserves diff --git a/web/src/renderer.ts b/web/src/renderer.ts index 7889c37..6dbc983 100644 --- a/web/src/renderer.ts +++ b/web/src/renderer.ts @@ -19,6 +19,7 @@ import { type ViewportState, } from "./viewport"; import { selectTileEvictionCandidate } from "./cache"; +import { visibleIdentityHistogram } from "./histogram"; const MISSING_IDENTITY = 65_535; const MAX_TILE_CACHE_BYTES = 192 * 1024 * 1024; @@ -351,6 +352,16 @@ export class DotplotRenderer { })); } + /** Returns identity counts clipped to the current view at the finest complete level. */ + visibleIdentityHistogram(): Uint32Array { + return visibleIdentityHistogram( + this.#tiles.values(), + this.#metadata.resolution, + this.#activeResolution, + this.#view, + ); + } + /** Reports whether every requested tile is already cached at refined or exact quality. */ hasDetailedTileCoverage(resolution: number, coordinates: readonly TileCoordinate[]): boolean { const available = new Set( diff --git a/web/tests/histogram.test.ts b/web/tests/histogram.test.ts new file mode 100644 index 0000000..9e04319 --- /dev/null +++ b/web/tests/histogram.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { visibleIdentityHistogram } from "../src/histogram"; +import type { MatrixTilePayload } from "../src/protocol"; +import type { ViewportState } from "../src/viewport"; + +describe("visible identity histogram", () => { + it("clips counts to the current viewport while panning", () => { + const tile = histogramTile(4, 0, 0, 4, 2, [8_500, 8_600, 9_900, 10_000, 8_500, 8_600, 9_900, 10_000]); + const left = visibleIdentityHistogram([tile], 4, 4, view(0, 0, 2, 2), 4); + const right = visibleIdentityHistogram([tile], 4, 4, view(2, 0, 2, 2), 4); + + expect([left[85], left[86], left[99], left[100]]).toEqual([2, 2, 0, 0]); + expect([right[85], right[86], right[99], right[100]]).toEqual([0, 0, 2, 2]); + }); + + it("uses a complete overview until the finer level fully covers the view", () => { + const overview = histogramTile(2, 0, 0, 2, 2, [8_500, 8_500, 8_500, 8_500]); + const detail = [ + histogramTile(4, 0, 0, 2, 2, [9_900, 9_900, 9_900, 9_900]), + histogramTile(4, 2, 0, 2, 2, [9_900, 9_900, 9_900, 9_900]), + histogramTile(4, 0, 2, 2, 2, [9_900, 9_900, 9_900, 9_900]), + histogramTile(4, 2, 2, 2, 2, [9_900, 9_900, 9_900, 9_900]), + ]; + + const loading = visibleIdentityHistogram([overview, detail[0]!], 2, 4, view(0, 0, 2, 2), 2); + expect(loading[85]).toBe(4); + expect(loading[99]).toBe(0); + + const complete = visibleIdentityHistogram([overview, ...detail], 2, 4, view(0, 0, 2, 2), 2); + expect(complete[85]).toBe(0); + expect(complete[99]).toBe(16); + }); + + it("ignores preview and missing-identity cells", () => { + const preview = histogramTile(2, 0, 0, 2, 2, [10_000, 10_000, 10_000, 10_000], "preview"); + const refined = histogramTile(2, 0, 0, 2, 2, [8_500, 65_535, 8_500, 65_535]); + const bins = visibleIdentityHistogram([preview, refined], 2, 2, view(0, 0, 2, 2), 2); + + expect(bins[85]).toBe(2); + expect(bins[100]).toBe(0); + }); + + it("bounds work for large visible matrices", () => { + const side = 1_024; + const tile = histogramTile( + side, + 0, + 0, + side, + side, + Array(side * side).fill(9_200), + ); + const bins = visibleIdentityHistogram([tile], side, side, view(0, 0, side, side), side); + + expect(bins[92]).toBe(256 * 256); + }); +}); + +function histogramTile( + resolution: number, + x: number, + y: number, + width: number, + height: number, + identities: number[], + quality: MatrixTilePayload["quality"] = "refined", +): MatrixTilePayload { + return { + generation: 1, + configDigest: "test", + quality, + resolution, + x, + y, + width, + height, + identity: new Uint16Array(identities), + direction: new Int16Array(width * height), + directionSupport: new Uint16Array(width * height), + }; +} + +function view(x: number, y: number, width: number, height: number): ViewportState { + return { x, y, width, height, domain: 4, minSize: 1 }; +} diff --git a/web/tests/palettes.test.ts b/web/tests/palettes.test.ts index 307c75c..be4b144 100644 --- a/web/tests/palettes.test.ts +++ b/web/tests/palettes.test.ts @@ -11,6 +11,7 @@ import { paletteColorCounts, paletteColors, paletteDefaultColorCount, + paletteDefaultReversed, } from "../src/palettes"; import { paletteColors as colorBrewerColors } from "../src/colorbrewer"; @@ -52,6 +53,12 @@ describe("application palette catalog", () => { expect(DEFAULT_PALETTE_REVERSED).toBe(false); }); + it("defaults Spectral to red for high identity", () => { + expect(paletteDefaultReversed("Spectral")).toBe(true); + expect(paletteDefaultReversed("Viridis")).toBe(false); + expect(paletteDefaultReversed("RdGy")).toBe(false); + }); + it("provides Viridis variants from three through twelve colors", () => { expect(paletteColorCounts("Viridis")).toEqual([ 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,