diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6f22d9c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: WebUI CI + +on: + push: + branches: ["master", "dev*", "jvm-api-v1"] + pull_request: + branches: ["master", "dev*", "jvm-api-v1"] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Type-check + run: npm run type-check + + - name: Build + run: npm run build-only diff --git a/README.md b/README.md index 309edc9..f620e1b 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,10 @@ Visit [releases page](https://github.com/ctlab/HiCT_WebUI/releases) to obtain bi Make [HiCT server](https://github.com/ctlab/HiCT_Server) available at `http://localhost:5000` either by starting it locally or by using port forwarding from remote server. Put HiCT files into server's data directory (you can convert Coolers using [HiCT_Utils](https://github.com/ctlab/HiCT_Utils)). Start Electron-based Web UI by unzipping binary distribution and launching `.exe` file (Windows) or by launching `.AppImage` file (linux, make sure you've made `chmod +x` for it). Click File -> Open and select HiCT file you want to interact with. Tiles should start loading on a contact map. Use single clicks or Shift+dragging at any time to perform range selection. Click tool buttons on the left side to perform actions with selection range. After you've done, you can either save state using File -> Save in HiCT format, or export assembly info using Assembly menu in navigation bar at the top of the window. + +## API documentation integration + +WebUI now links directly to backend API docs via **View → API v1 docs...**. + +- Interactive Swagger UI: `http://localhost:5000/api/v1/` +- OpenAPI source: `http://localhost:5000/api/v1/openapi.yaml` diff --git a/package-lock.json b/package-lock.json index 00c6c4d..b3f0c63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "display-si": "^1.0.7", "igv": "^2.13.3", "jspdf": "^2.5.1", + "litegraph.js": "^0.7.18", "mobx": "^6.6.2", "mobx-vue-lite": "^0.3.1", "multi-range-slider-vue": "^1.1.4", @@ -5895,6 +5896,11 @@ "node": ">= 0.8.0" } }, + "node_modules/litegraph.js": { + "version": "0.7.18", + "resolved": "https://registry.npmjs.org/litegraph.js/-/litegraph.js-0.7.18.tgz", + "integrity": "sha512-1WEwjOO58j4FcLX8DvsuMXM371MEq4Y+8pBr3q2pBhJ9nDkwBtBd9Gj6bxArBKhW6i42bSOyv9ybeuez6NAxoQ==" + }, "node_modules/load-json-file": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", @@ -12805,6 +12811,11 @@ "type-check": "~0.4.0" } }, + "litegraph.js": { + "version": "0.7.18", + "resolved": "https://registry.npmjs.org/litegraph.js/-/litegraph.js-0.7.18.tgz", + "integrity": "sha512-1WEwjOO58j4FcLX8DvsuMXM371MEq4Y+8pBr3q2pBhJ9nDkwBtBd9Gj6bxArBKhW6i42bSOyv9ybeuez6NAxoQ==" + }, "load-json-file": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", diff --git a/package.json b/package.json index 99314d1..1e356b9 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "author": "Aleksandr Serdiukov, Anton Zamyatin and CT Lab ITMO University team", "main": "dist/electron/electron/main/main.js", - "license":"MIT", + "license": "MIT", "scripts": { "dev": "vite", "build": "run-p type-check build-only && tsc", @@ -30,14 +30,17 @@ "binary-search-bounds": "2.0.5", "bootstrap": "^5.2.2", "bootstrap-icons": "^1.9.1", + "colortranslator": "^4.1.0", "core-js": "^3.25.5", + "display-si": "^1.0.7", "igv": "^2.13.3", + "jspdf": "^2.5.1", + "litegraph.js": "^0.7.18", "mobx": "^6.6.2", "mobx-vue-lite": "^0.3.1", "multi-range-slider-vue": "^1.1.4", "normalize.css": "^8.0.1", "ol": "^9.1.0", - "display-si": "^1.0.7", "path-browserify": "^1.0.1", "pinia": "^2.1.6", "primeflex": "^3.3.1", @@ -48,9 +51,7 @@ "vanilla-picker": "^2.12.2", "vue": "^3.2.41", "vue-color-kit": "^1.0.6", - "vue-sonner": "^0.4.2", - "colortranslator": "^4.1.0", - "jspdf": "^2.5.1" + "vue-sonner": "^0.4.2" }, "devDependencies": { "@rushstack/eslint-patch": "^1.2.0", diff --git a/src/app/core/VersionedXYZSource.ts b/src/app/core/VersionedXYZSource.ts index 6e7a834..c1614fa 100644 --- a/src/app/core/VersionedXYZSource.ts +++ b/src/app/core/VersionedXYZSource.ts @@ -25,6 +25,11 @@ import XYZ, { type Options as XYZOptions } from "ol/source/XYZ"; import { unref } from "vue"; import type { HiCViewAndLayersManager } from "./mapmanagers/HiCViewAndLayersManager"; import { CurrentSignalRangeResponseDTO } from "./net/dto/responseDTO"; +import type { CurrentSignalRangeResponse } from "./net/api/response"; +import { useUiSettingsStore } from "@/app/stores/uiSettingsStore"; +import { useVisualizationOptionsStore } from "@/app/stores/visualizationOptionsStore"; +import { useStyleStore } from "@/app/stores/styleStore"; +import SimpleLinearGradient from "@/app/core/visualization/colormap/SimpleLinearGradient"; class VersionedXYZContactMapSource extends XYZ { protected sourceVersion: number; @@ -42,36 +47,38 @@ class VersionedXYZContactMapSource extends XYZ { const imageTile: ImageTile = tile as ImageTile; const image: HTMLImageElement | HTMLVideoElement = imageTile.getImage() as HTMLImageElement | HTMLVideoElement; - const xhr = new XMLHttpRequest(); - xhr.responseType = "json"; - xhr.addEventListener("loadend", function (evt) { - // console.log("Got XHR Response: ", this.response); - const data = this.response; - if (data && data.image) { - // image.src = URL.createObjectURL(data.image); - // image.src = "data:image/png;base64," + data.image; - image.src = data.image; - tile.setState(TileState.LOADED); - // @ts-expect-error Adding field to object is ok in JS but not in TS - tile.lastResponse = data; - layersManager.callbackFns.contrastSliderRangesCallbacks.forEach( - (callbackFn) => { - callbackFn( - new CurrentSignalRangeResponseDTO(data.ranges).toEntity() - ); - } - ); - } /* if (this.status >= 400) */ else { - // @ts-expect-error If tile was loaded successfully at least once, last response is saved - if (tile.lastResponse) { - // @ts-expect-error If tile was loaded successfully at least once, last response is saved - image.src = tile.lastResponse.image; - } else { - tile.setState(TileState.ERROR); // tile.setState(TileState.EMPTY); + const uiSettingsStore = useUiSettingsStore(); + if (uiSettingsStore.binaryTileTransportEnabled) { + this.loadBinarySignalTile(imageTile, image, src); + return; + } + this.loadPngTile(imageTile, image, src); + }); + this.do_reload(); + } + + private loadPngTile( + tile: ImageTile, + image: HTMLImageElement | HTMLVideoElement, + src: string + ): void { + const xhr = new XMLHttpRequest(); + xhr.responseType = "json"; + xhr.addEventListener("loadend", () => { + const data = xhr.response; + if (data && data.image) { + image.src = data.image; + tile.setState(TileState.LOADED); + // @ts-expect-error Adding field to object is ok in JS but not in TS + tile.lastResponse = data; + this.layersManager.callbackFns.contrastSliderRangesCallbacks.forEach( + (callbackFn: (ranges: CurrentSignalRangeResponse) => void) => { + callbackFn( + new CurrentSignalRangeResponseDTO(data.ranges).toEntity() + ); } - } - }); - xhr.addEventListener("error", function () { + ); + } else { // @ts-expect-error If tile was loaded successfully at least once, last response is saved if (tile.lastResponse) { // @ts-expect-error If tile was loaded successfully at least once, last response is saved @@ -79,11 +86,217 @@ class VersionedXYZContactMapSource extends XYZ { } else { tile.setState(TileState.ERROR); } - }); - xhr.open("GET", src); - xhr.send(); + } }); - this.do_reload(); + xhr.addEventListener("error", () => { + // @ts-expect-error If tile was loaded successfully at least once, last response is saved + if (tile.lastResponse) { + // @ts-expect-error If tile was loaded successfully at least once, last response is saved + image.src = tile.lastResponse.image; + } else { + tile.setState(TileState.ERROR); + } + }); + xhr.open("GET", src); + xhr.send(); + } + + private loadBinarySignalTile( + tile: ImageTile, + image: HTMLImageElement | HTMLVideoElement, + src: string + ): void { + const parsed = this.parseTileUrl(src); + if (!parsed) { + this.loadPngTile(tile, image, src); + return; + } + const host = `${unref(this.layersManager.mapManager.networkManager.host)}`.replace( + /\/+$/, + "" + ); + const payload = { + bpResolution: this.bpResolution, + unit: "PIXELS", + startRowPx: parsed.startRowPx, + endRowPx: parsed.endRowPx, + startColPx: parsed.startColPx, + endColPx: parsed.endColPx, + signalMode: "PIPELINE_SIGNAL", + format: "BINARY_FLOAT32", + }; + const xhr = new XMLHttpRequest(); + xhr.responseType = "arraybuffer"; + xhr.addEventListener("loadend", () => { + const rows = Number.parseInt(xhr.getResponseHeader("x-hict-rows") || "0", 10); + const cols = Number.parseInt(xhr.getResponseHeader("x-hict-cols") || "0", 10); + const response = xhr.response; + if ( + xhr.status >= 200 && + xhr.status < 300 && + response instanceof ArrayBuffer && + rows > 0 && + cols > 0 + ) { + const values = this.decodeFloat32Array(response, rows * cols); + const dataUrl = this.renderSignalTile(values, rows, cols); + image.src = dataUrl; + tile.setState(TileState.LOADED); + // @ts-expect-error dynamic cache field + tile.lastResponse = { image: dataUrl }; + return; + } + // @ts-expect-error dynamic cache field + if (tile.lastResponse?.image) { + // @ts-expect-error dynamic cache field + image.src = tile.lastResponse.image; + } else { + tile.setState(TileState.ERROR); + } + }); + xhr.addEventListener("error", () => { + // @ts-expect-error dynamic cache field + if (tile.lastResponse?.image) { + // @ts-expect-error dynamic cache field + image.src = tile.lastResponse.image; + } else { + tile.setState(TileState.ERROR); + } + }); + xhr.open("POST", `${host}/matrix/query`); + xhr.setRequestHeader("Content-Type", "application/json"); + xhr.send(JSON.stringify(payload)); + } + + private parseTileUrl(src: string): + | { + startRowPx: number; + endRowPx: number; + startColPx: number; + endColPx: number; + } + | null { + try { + const url = new URL(src, window.location.href); + const row = Number.parseInt(url.searchParams.get("row") || "0", 10); + const col = Number.parseInt(url.searchParams.get("col") || "0", 10); + const tileSize = Number.parseInt( + url.searchParams.get("tile_size") || + String(unref(this.layersManager.tileSize)), + 10 + ); + if ( + !Number.isFinite(row) || + !Number.isFinite(col) || + !Number.isFinite(tileSize) || + tileSize <= 0 + ) { + return null; + } + return { + startRowPx: row * tileSize, + endRowPx: (row + 1) * tileSize, + startColPx: col * tileSize, + endColPx: (col + 1) * tileSize, + }; + } catch (error) { + return null; + } + } + + private decodeFloat32Array(buffer: ArrayBuffer, expectedLength: number): Float32Array { + const view = new DataView(buffer); + const valueCount = Math.min(expectedLength, Math.floor(buffer.byteLength / 4)); + const out = new Float32Array(valueCount); + for (let idx = 0; idx < valueCount; idx += 1) { + out[idx] = view.getFloat32(idx * 4, true); + } + return out; + } + + private renderSignalTile(values: Float32Array, rows: number, cols: number): string { + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, cols); + canvas.height = Math.max(1, rows); + const context = canvas.getContext("2d"); + if (!context) { + return ""; + } + const imageData = context.createImageData(canvas.width, canvas.height); + const visualizationOptionsStore = useVisualizationOptionsStore(); + const styleStore = useStyleStore(); + const colormap = visualizationOptionsStore.colormap; + let minSignal = 0; + let maxSignal = 1; + let start = this.parseRgba("rgba(0,255,0,0.0)"); + let end = this.parseRgba("rgba(0,96,0,1.0)"); + if (colormap instanceof SimpleLinearGradient) { + minSignal = colormap.minSignal; + maxSignal = colormap.maxSignal; + start = this.parseRgba(colormap.startColorRGBA?.RGBA || "rgba(0,255,0,0.0)"); + end = this.parseRgba(colormap.endColorRGBA?.RGBA || "rgba(0,96,0,1.0)"); + } + const backgroundColor = + ((styleStore.mapBackgroundColor as unknown as { RGBA?: string })?.RGBA as + | string + | undefined) ?? "rgba(255,255,255,1.0)"; + const background = this.parseRgba(backgroundColor); + const signalRange = Math.max(1e-9, maxSignal - minSignal); + const pixelCount = Math.min(values.length, rows * cols); + for (let idx = 0; idx < pixelCount; idx += 1) { + const signal = Number.isFinite(values[idx]) ? values[idx] : 0; + const normalized = Math.max(0, Math.min(1, (signal - minSignal) / signalRange)); + const mix = normalized; + const alpha = + (start[3] + (end[3] - start[3]) * mix) / 255.0; + const colorR = start[0] + (end[0] - start[0]) * mix; + const colorG = start[1] + (end[1] - start[1]) * mix; + const colorB = start[2] + (end[2] - start[2]) * mix; + const dstR = Math.round(colorR * alpha + background[0] * (1 - alpha)); + const dstG = Math.round(colorG * alpha + background[1] * (1 - alpha)); + const dstB = Math.round(colorB * alpha + background[2] * (1 - alpha)); + const pixelIndex = idx * 4; + imageData.data[pixelIndex] = dstR; + imageData.data[pixelIndex + 1] = dstG; + imageData.data[pixelIndex + 2] = dstB; + imageData.data[pixelIndex + 3] = 255; + } + context.putImageData(imageData, 0, 0); + return canvas.toDataURL("image/png"); + } + + private parseRgba(color: string): [number, number, number, number] { + const trimmed = (color ?? "").trim(); + const rgbaMatch = trimmed.match( + /^rgba\(\s*([+-]?\d*\.?\d+)\s*,\s*([+-]?\d*\.?\d+)\s*,\s*([+-]?\d*\.?\d+)\s*,\s*([+-]?\d*\.?\d+)\s*\)$/i + ); + if (rgbaMatch) { + return [ + this.clampByte(Number.parseFloat(rgbaMatch[1])), + this.clampByte(Number.parseFloat(rgbaMatch[2])), + this.clampByte(Number.parseFloat(rgbaMatch[3])), + this.clampByte(Number.parseFloat(rgbaMatch[4]) * 255.0), + ]; + } + const rgbMatch = trimmed.match( + /^rgb\(\s*([+-]?\d*\.?\d+)\s*,\s*([+-]?\d*\.?\d+)\s*,\s*([+-]?\d*\.?\d+)\s*\)$/i + ); + if (rgbMatch) { + return [ + this.clampByte(Number.parseFloat(rgbMatch[1])), + this.clampByte(Number.parseFloat(rgbMatch[2])), + this.clampByte(Number.parseFloat(rgbMatch[3])), + 255, + ]; + } + return [255, 255, 255, 255]; + } + + private clampByte(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.max(0, Math.min(255, Math.round(value))); } /** diff --git a/src/app/core/controls/RulerControl.ts b/src/app/core/controls/RulerControl.ts index f60e560..8dd93a6 100644 --- a/src/app/core/controls/RulerControl.ts +++ b/src/app/core/controls/RulerControl.ts @@ -114,8 +114,6 @@ class RulerControl extends Control { this.mapBackgroundColor = mapBackgroundColor as Ref; this.canvasSize = canvasSize; - - console.log("RulerControl constructor finished", this); } render(mapEvent: MapEvent) { @@ -127,9 +125,7 @@ class RulerControl extends Control { this.canvas.width = width; this.canvas.height = height; const context = this.canvas.getContext("2d"); - console.log("Got context: ", context, "RulerControl: ", this); if (!context) return; - console.log("Context available"); context.clearRect(0, 0, this.canvas.width, this.canvas.height); @@ -145,11 +141,6 @@ class RulerControl extends Control { const targetProjection = activeHiCLayer.getSource()?.getProjection(); if (!targetProjection) { - console.log( - "Active Hi-C layer", - activeHiCLayer, - "does not have a target projection set" - ); return; } @@ -157,10 +148,6 @@ class RulerControl extends Control { resolutionDescriptor.bpResolution ); if (!ps) { - console.log( - "No prefix sum for current bpResolution??", - resolutionDescriptor - ); return; } const pixelMapSize = ps[ps.length - 1]; @@ -204,14 +191,14 @@ class RulerControl extends Control { right: Math.round( Math.min( mapBoxPixelCoordinates.left + pixelMapSize / fraction1, - this.canvasSize[0] + this.canvas.width ) ), top: Math.round(Math.max(0, topmostMapPx)), bottom: Math.round( Math.min( mapBoxPixelCoordinates.top + pixelMapSize / fraction1, - this.canvasSize[1] + this.canvas.height ) ), }; @@ -282,25 +269,18 @@ class RulerControl extends Control { // const endX = visibleMapBoxExtentPixel.right; // const y0 = Math.round(this.canvas.height / 2); // context.save(); - this.setFillStrokeContrastColors(context); - const strokeStyle = context.strokeStyle; - context.strokeStyle = context.fillStyle; + const { mainStroke, outlineStroke } = this.getRulerStrokeColors(); + context.strokeStyle = outlineStroke; context.lineWidth = 10; context.beginPath(); context.moveTo(start[0], start[1]); context.lineTo(end[0], end[1]); - // context.moveTo(start[0] - 5 * deltaDir[0], start[1] - 5 * deltaDir[1]); - // context.lineTo(end[0] - 5 * deltaDir[0], end[1] - 5 * deltaDir[1]); - // context.moveTo(start[0] + 5 * deltaDir[0], start[1] + 5 * deltaDir[1]); - // context.lineTo(end[0] + 5 * deltaDir[0], end[1] + 5 * deltaDir[1]); - context.strokeStyle = "white"; context.stroke(); - // context.strokeStyle = strokeStyle; context.lineWidth = 5; context.beginPath(); context.moveTo(start[0], start[1]); context.lineTo(end[0], end[1]); - context.strokeStyle = "black"; + context.strokeStyle = mainStroke; context.stroke(); // context.reset(); @@ -323,9 +303,12 @@ class RulerControl extends Control { // context.strokeStyle = "black"; // context.stroke(); - console.log("start", start, "end", end, "deltaDir", deltaDir); - - const tickInterval = 50; + const axisSpanPx = Math.max( + 1, + Math.round((end[0] - start[0]) * deltaDir[0] + (end[1] - start[1]) * deltaDir[1]) + ); + const desiredTickCount = Math.max(2, Math.min(12, Math.floor(axisSpanPx / 90))); + const tickInterval = Math.max(24, Math.round(axisSpanPx / desiredTickCount)); { const TICK_SEMI_HEIGHT = @@ -335,6 +318,9 @@ class RulerControl extends Control { ); const FONT_STRING = `bold ${FONT_SIZE_PX}px serif`; const LAST_TICK_MARGIN = Math.round(tickInterval / 2); + let tickIndex = 0; + let previousAbsoluteLabel: string | null = null; + let previousAbsoluteBp: number | null = null; for ( let coord: [number, number] = [start[0], start[1]]; coord[0] < end[0] - LAST_TICK_MARGIN || @@ -342,17 +328,7 @@ class RulerControl extends Control { coord[0] += deltaDir[0] * tickInterval, coord[1] += deltaDir[1] * tickInterval ) { - // console.log( - // "start", - // start, - // "end", - // end, - // "deltaDir", - // deltaDir, - // "coord", - // coord - // ); - this.drawTickAtPxOffset( + const tickState = this.drawTickAtPxOffset( context, resolutionDescriptor, coord, @@ -365,8 +341,14 @@ class RulerControl extends Control { fraction1, TICK_SEMI_HEIGHT, FONT_SIZE_PX, - FONT_STRING + FONT_STRING, + tickIndex, + previousAbsoluteLabel, + previousAbsoluteBp ); + previousAbsoluteLabel = tickState.absoluteLabel; + previousAbsoluteBp = tickState.absoluteBp; + tickIndex++; } this.drawTickAtPxOffset( context, @@ -381,7 +363,10 @@ class RulerControl extends Control { fraction1, TICK_SEMI_HEIGHT, FONT_SIZE_PX, - FONT_STRING + FONT_STRING, + tickIndex, + previousAbsoluteLabel, + previousAbsoluteBp ); } // Actually, if false, allows drawing smaller grid, currently disabled @@ -427,7 +412,10 @@ class RulerControl extends Control { fraction1, TICK_SEMI_HEIGHT, FONT_SIZE_PX, - FONT_STRING + FONT_STRING, + 0, + null, + null ); } } @@ -477,8 +465,11 @@ class RulerControl extends Control { fraction1: number, TICK_SEMI_HEIGHT: number, FONT_SIZE_PX: number, - FONT_STRING: string - ): void { + FONT_STRING: string, + tickIndex: number, + previousAbsoluteLabel: string | null, + previousAbsoluteBp: number | null + ): { absoluteLabel: string; absoluteBp: number } { coord = coord.map(Math.round) as [number, number]; const dPx = (() => { @@ -498,54 +489,19 @@ class RulerControl extends Control { } })(); - const dBp = - dPx == 0 - ? 0 - : this.contigDimensionHolder.getStartBpOfPx( - dPx, - resolutionDescriptor.bpResolution - ) - - this.contigDimensionHolder.getStartBpOfPx( - Math.max( - 0, - Math.round(-Math.min(0, mapBoxPixelCoordinates.left)) * fraction1 - ), - resolutionDescriptor.bpResolution - ); - - const [preBP, postBP] = (() => { + const preBP = (() => { if (dPx == 0) { - return [ - 0, - this.contigDimensionHolder.getStartBpOfPx( - 0, - resolutionDescriptor.bpResolution - ), - ]; + return 0; } else if (coord == end) { - return [ - this.contigDimensionHolder.getStartBpOfPx( - dPx - 1, - resolutionDescriptor.bpResolution - ), - - this.contigDimensionHolder.getStartBpOfPx( - dPx + 100, - resolutionDescriptor.bpResolution - ), - ]; + return this.contigDimensionHolder.getStartBpOfPx( + dPx - 1, + resolutionDescriptor.bpResolution + ); } else { - return [ - this.contigDimensionHolder.getStartBpOfPx( - dPx, - resolutionDescriptor.bpResolution - ), - - this.contigDimensionHolder.getStartBpOfPx( - dPx, - resolutionDescriptor.bpResolution - ), - ]; + return this.contigDimensionHolder.getStartBpOfPx( + dPx, + resolutionDescriptor.bpResolution + ); } })(); @@ -563,8 +519,8 @@ class RulerControl extends Control { // postBP // ); - // this.setFillStrokeContrastColors(context); - context.strokeStyle = "white"; + const { mainStroke, outlineStroke } = this.getRulerStrokeColors(); + context.strokeStyle = outlineStroke; context.lineWidth = 6; context.beginPath(); context.moveTo( @@ -576,7 +532,7 @@ class RulerControl extends Control { coord[1] + TICK_SEMI_HEIGHT * deltaDir[0] ); context.stroke(); - context.strokeStyle = "black"; + context.strokeStyle = mainStroke; context.lineWidth = 3; context.beginPath(); context.moveTo( @@ -608,89 +564,41 @@ class RulerControl extends Control { })(); const fillBackground = this.opt_options.direction === "horizontal"; - - if (postBP - preBP > resolutionDescriptor.bpResolution) { - const SIStringPre = toSI(preBP); // + "bp"; - const SIStringPost = toSI(postBP); // + "bp"; - const mtPre = context.measureText(SIStringPre); - // const mtPost = context.measureText(SIStringPost); - this.drawRotatedText( - SIStringPre, - Math.round( - coord[0] - - TICK_SEMI_HEIGHT * deltaDir[0] - - (FONT_SIZE_PX + 5) * deltaDir[1] - ), - Math.round( - coord[1] - - TICK_SEMI_HEIGHT * deltaDir[1] - - (FONT_SIZE_PX + 5) * deltaDir[0] - ), - context, - angleDeg, - FONT_STRING, - textAlign, - false, - fillBackground - ); - this.drawRotatedText( - SIStringPost, - Math.round( - coord[0] + - TICK_SEMI_HEIGHT * deltaDir[0] - - (FONT_SIZE_PX + 5) * deltaDir[1] - ), - Math.round( - coord[1] + - TICK_SEMI_HEIGHT * deltaDir[1] - - (FONT_SIZE_PX + 5) * deltaDir[0] - ), - context, - angleDeg, - FONT_STRING, - textAlign, - false, - fillBackground - ); - } else { - const SIString = toSI(preBP); // + "bp"; - const mt = context.measureText(SIString); - this.drawRotatedText( - SIString, - Math.round( - coord[0] + - (FONT_SIZE_PX / 3) * deltaDir[0] - - (TICK_SEMI_HEIGHT + 5) * deltaDir[1] - ), - Math.round( - coord[1] + - (FONT_SIZE_PX / 3) * deltaDir[1] - - (TICK_SEMI_HEIGHT + 5) * deltaDir[0] - ), - context, - angleDeg, - FONT_STRING, - textAlign, - false, - fillBackground - ); - } - + const absoluteLabel = toSI(preBP); + const useDeltaLabel = + tickIndex > 0 && + previousAbsoluteLabel !== null && + previousAbsoluteBp !== null && + previousAbsoluteLabel === absoluteLabel && + preBP > previousAbsoluteBp; + const deltaLabel = "+" + toSI(Math.max(0, preBP - (previousAbsoluteBp ?? preBP))); + const label = useDeltaLabel ? deltaLabel : absoluteLabel; + const fontSize = useDeltaLabel + ? Math.max(9, FONT_SIZE_PX - 2) + : Math.max(11, FONT_SIZE_PX + 2); this.drawRotatedText( - "+" + toSI(dBp), // + "bp", - Math.round(coord[0] + (TICK_SEMI_HEIGHT + 5) * deltaDir[1]), + label, Math.round( - coord[1] + (TICK_SEMI_HEIGHT + FONT_SIZE_PX + 5) * deltaDir[0] + coord[0] + + (FONT_SIZE_PX / 3) * deltaDir[0] - + (TICK_SEMI_HEIGHT + 5) * deltaDir[1] + ), + Math.round( + coord[1] + + (FONT_SIZE_PX / 3) * deltaDir[1] - + (TICK_SEMI_HEIGHT + 5) * deltaDir[0] ), context, - 0, - FONT_STRING, - "left", + angleDeg, + `${useDeltaLabel ? "normal" : "bold"} ${fontSize}px sans-serif`, + textAlign, false, fillBackground ); - - context.restore(); + return { + absoluteLabel, + absoluteBp: preBP, + }; } protected drawRotatedText( @@ -737,35 +645,31 @@ class RulerControl extends Control { context: CanvasRenderingContext2D ): void { const backgroundColor = this.mapBackgroundColor.value; + const darkBackground = backgroundColor.L <= 55; + context.fillStyle = darkBackground + ? "rgba(248,250,252,0.96)" + : "rgba(17,24,39,0.96)"; + context.strokeStyle = darkBackground + ? "rgba(17,24,39,0.96)" + : "rgba(248,250,252,0.96)"; + } - const fillColor = new ColorTranslator( - { - H: (180 + backgroundColor.H) % 360.0, - S: backgroundColor.S, // > 50 ? 30 : 70, - L: backgroundColor.L > 50 ? 30 : 70, - A: 1.0, - }, - { legacyCSS: true } - ).RGB; - context.fillStyle = fillColor; - - const cmap = this.colormap.value; - - if (!(cmap instanceof SimpleLinearGradient)) { - context.fillStyle = "black"; - } else { - const cmapEndColor = cmap.endColorRGBA; - const strokeColor = new ColorTranslator( - { - H: (180 + cmapEndColor.H) % 360.0, - S: cmapEndColor.S, // > 50 ? 30 : 70, - L: cmapEndColor.L > 50 ? 30 : 70, - A: 1.0, - }, - { legacyCSS: true } - ).RGB; - context.strokeStyle = strokeColor; + private getRulerStrokeColors(): { + mainStroke: string; + outlineStroke: string; + } { + const backgroundColor = this.mapBackgroundColor.value; + const darkBackground = backgroundColor.L <= 55; + if (darkBackground) { + return { + mainStroke: "rgba(248,250,252,0.95)", + outlineStroke: "rgba(15,23,42,0.75)", + }; } + return { + mainStroke: "rgba(15,23,42,0.9)", + outlineStroke: "rgba(248,250,252,0.8)", + }; } } diff --git a/src/app/core/mapmanagers/CommonEventManager.ts b/src/app/core/mapmanagers/CommonEventManager.ts index 079f5a1..6e2d90f 100644 --- a/src/app/core/mapmanagers/CommonEventManager.ts +++ b/src/app/core/mapmanagers/CommonEventManager.ts @@ -52,6 +52,7 @@ class CommonEventManager { }): void { this.mapManager.contigDimensionHolder.updateContigData(asmInfo.contigDescriptors); this.mapManager.scaffoldHolder.updateScaffoldData(asmInfo.scaffoldDescriptors); + this.mapManager.refreshOverviewMinimap(); } public reloadTiles() { diff --git a/src/app/core/mapmanagers/ContactMapManager.ts b/src/app/core/mapmanagers/ContactMapManager.ts index adc1d67..d26dd98 100644 --- a/src/app/core/mapmanagers/ContactMapManager.ts +++ b/src/app/core/mapmanagers/ContactMapManager.ts @@ -20,9 +20,21 @@ */ import { Map, View } from "ol"; -import { ScaleLine, OverviewMap, ZoomSlider } from "ol/control"; +import { ZoomSlider } from "ol/control"; import { DoubleClickZoom, DragPan } from "ol/interaction"; import TileLayer from "ol/layer/Tile"; +import VectorLayer from "ol/layer/Vector"; +import VectorSource from "ol/source/Vector"; +import Feature from "ol/Feature"; +import Polygon, { fromExtent } from "ol/geom/Polygon"; +import Fill from "ol/style/Fill"; +import Stroke from "ol/style/Stroke"; +import Style from "ol/style/Style"; +import { transform, transformExtent } from "ol/proj"; +import { getCenter, getHeight, getWidth, intersects } from "ol/extent"; +import { unByKey } from "ol/Observable"; +import type { EventsKey } from "ol/events"; +import { toSI } from "display-si"; import ContigDimensionHolder from "./ContigDimensionHolder"; import { ScaffoldHolder } from "./ScaffoldHolder"; import { HiCViewAndLayersManager } from "./HiCViewAndLayersManager"; @@ -36,6 +48,7 @@ import { VisualizationManager } from "./VisualizationManager"; import { Ref } from "vue"; import { VersionedXYZContactMapSource } from "../VersionedXYZSource"; import { LinearTrackManager } from "./LinearTrackManager"; +import { useStyleStore } from "@/app/stores/styleStore"; class ContactMapManager { public readonly map: Map; @@ -48,7 +61,11 @@ class ContactMapManager { public readonly toastHandlers: (() => void)[] = []; public readonly visualizationManager: VisualizationManager; public readonly linearTrackManager: LinearTrackManager; - public minimap: OverviewMap | null; + public minimap: Map | null; + private minimapViewportFeature: Feature | null; + private minimapResizeObserver: ResizeObserver | null; + private minimapSyncListeners: EventsKey[]; + private minimapRenderFramePending: boolean; constructor( protected readonly options: { @@ -89,6 +106,10 @@ class ContactMapManager { this.linearTrackManager = new LinearTrackManager(this); this.minimap = null; + this.minimapViewportFeature = null; + this.minimapResizeObserver = null; + this.minimapSyncListeners = []; + this.minimapRenderFramePending = false; } public initializeMap(): void { @@ -136,14 +157,97 @@ class ContactMapManager { } public addOverviewMapTarget(target: HTMLElement | string) { - this.map.addControl( - new OverviewMap({ - collapsed: false, - target: target, - layers: this.viewAndLayersManager.layersHolder.hicDataLayers, - collapsible: false, - }) + this.clearOverviewMapTarget(); + const resolvedTarget = + typeof target === "string" + ? document.getElementById(target) + : target; + if (!resolvedTarget) { + return; + } + this.applyMinimapBackground(resolvedTarget); + const coarsestLayer = this.createCoarsestMinimapLayer(); + const source = coarsestLayer?.getSource(); + const projection = source?.getProjection(); + const projectionExtent = projection?.getExtent(); + if (!coarsestLayer || !source || !projection || !projectionExtent) { + return; + } + + const viewportSource = new VectorSource(); + const viewportFeature = new Feature(fromExtent(projectionExtent)); + viewportSource.addFeature(viewportFeature); + const viewportLayer = new VectorLayer({ + source: viewportSource, + style: [ + new Style({ + stroke: new Stroke({ + color: "rgba(255,255,255,0.97)", + width: 4, + }), + }), + new Style({ + stroke: new Stroke({ + color: "rgba(220,38,38,0.98)", + width: 2, + }), + fill: new Fill({ + color: "rgba(220,38,38,0.10)", + }), + }), + ], + }); + + const projectionExtentTuple = projectionExtent as [number, number, number, number]; + const minimap = new Map({ + target: resolvedTarget, + controls: [], + interactions: [], + layers: [coarsestLayer, viewportLayer], + view: new View({ + projection, + center: getCenter(projectionExtentTuple), + resolution: this.estimateMinimapResolution(projectionExtentTuple, resolvedTarget), + constrainResolution: false, + extent: projectionExtentTuple, + }), + }); + this.minimap = minimap; + this.minimapViewportFeature = viewportFeature; + this.fitMinimapToFullExtent(); + this.minimapResizeObserver = new ResizeObserver(() => { + this.minimap?.updateSize(); + this.fitMinimapToFullExtent(); + this.scheduleMinimapViewportSync(); + }); + this.minimapResizeObserver.observe(resolvedTarget); + + const mainView = this.map.getView(); + this.minimapSyncListeners.push( + this.map.on("moveend", () => this.scheduleMinimapViewportSync()) + ); + this.minimapSyncListeners.push( + mainView.on("change:center", () => this.scheduleMinimapViewportSync()) + ); + this.minimapSyncListeners.push( + mainView.on("change:resolution", () => this.scheduleMinimapViewportSync()) ); + this.scheduleMinimapViewportSync(); + } + + public clearOverviewMapTarget(): void { + this.minimapResizeObserver?.disconnect(); + this.minimapResizeObserver = null; + if (this.minimapSyncListeners.length > 0) { + unByKey(this.minimapSyncListeners); + this.minimapSyncListeners = []; + } + this.minimapViewportFeature = null; + if (!this.minimap) { + return; + } + this.minimap.setTarget(undefined); + this.minimap = null; } public getOptions() { @@ -154,7 +258,7 @@ class ContactMapManager { return this.map; } - public getMiniMap(): OverviewMap { + public getMiniMap(): Map { const minimap = this.minimap; if (minimap) { return minimap; @@ -182,12 +286,14 @@ class ContactMapManager { public reloadTiles(): void { this.viewAndLayersManager.reloadTiles(); void this.linearTrackManager.clearCachesAndRender(); + this.scheduleMinimapViewportSync(); } public async reloadTilesFromBackend(): Promise { const version = await this.networkManager.requestManager.reloadTilesVersion(); this.viewAndLayersManager.reloadTiles(version); void this.linearTrackManager.clearCachesAndRender(); + this.scheduleMinimapViewportSync(); } private async buildCurrentMapSvg( @@ -195,8 +301,34 @@ class ContactMapManager { options?: { backgroundColor?: string; metadata?: Record; + includeWorkspaceComposite?: boolean; } ): Promise { + if (options?.includeWorkspaceComposite ?? true) { + const composite = await this.renderWorkspaceCompositeCanvas( + options?.backgroundColor + ); + const dataUrl = composite.toDataURL("image/png"); + progressCallback?.(1); + let svg = + `` + + ``; + svg += ``; + if (options?.metadata) { + const metaJson = JSON.stringify(options.metadata); + svg += `${metaJson}`; + } + svg += ``; + svg += ``; + return svg; + } + const descriptor = this.viewAndLayersManager.currentViewState.resolutionDesciptor; const imageSize = @@ -320,14 +452,473 @@ class ContactMapManager { return svg; } + private async loadSvgImage(svg: string): Promise { + const blob = new Blob([svg], { type: "image/svg+xml" }); + const url = URL.createObjectURL(blob); + try { + const image = new Image(); + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = (error) => reject(error); + image.src = url; + }); + return image; + } finally { + URL.revokeObjectURL(url); + } + } + + private drawOutlinedCanvasText( + context: CanvasRenderingContext2D, + text: string, + x: number, + y: number + ): void { + context.strokeText(text, x, y); + context.fillText(text, x, y); + } + + private niceStep(value: number): number { + if (!Number.isFinite(value) || value <= 0) { + return 1; + } + const exponent = Math.floor(Math.log10(value)); + const base = Math.pow(10, exponent); + const normalized = value / base; + const multiplier = + normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10; + return multiplier * base; + } + + private buildRulerTickPositions(pixelSpan: number): number[] { + const desiredTickCount = Math.max( + 3, + Math.min(12, Math.floor(pixelSpan / 90)) + ); + const stepPx = this.niceStep(pixelSpan / Math.max(1, desiredTickCount - 1)); + const ticks: number[] = [0]; + for (let value = stepPx; value < pixelSpan; value += stepPx) { + ticks.push(Math.round(value)); + } + ticks.push(pixelSpan); + return [...new Set(ticks.map((value) => Math.max(0, Math.min(pixelSpan, value))))].sort( + (a, b) => a - b + ); + } + + private formatRulerTickLabel( + bpValue: number, + previousRawLabel: string | null, + previousBpValue: number | null + ): { text: string; compact: boolean; rawLabel: string } { + const rawLabel = toSI(Math.max(0, Math.round(bpValue))); + if (previousRawLabel === rawLabel && previousBpValue !== null) { + const delta = Math.max(0, Math.round(bpValue - previousBpValue)); + if (delta > 0) { + return { + text: `+${toSI(delta)}`, + compact: true, + rawLabel, + }; + } + } + return { + text: rawLabel, + compact: false, + rawLabel, + }; + } + + private drawFullExtentExportRulers( + context: CanvasRenderingContext2D, + options: { + mapOffset: number; + mapSizePx: number; + trackPanelSizePx: number; + rulerPanelSizePx: number; + bpResolution: number; + backgroundColor: string; + } + ): void { + const { mapOffset, mapSizePx, trackPanelSizePx, rulerPanelSizePx, bpResolution } = + options; + const palette = (() => { + const darkBackground = useStyleStore().mapBackgroundColor.L <= 55; + return darkBackground + ? { + line: "rgba(239,244,251,0.95)", + text: "rgba(245,248,252,0.96)", + outline: "rgba(0,0,0,0.88)", + } + : { + line: "rgba(16,24,36,0.92)", + text: "rgba(22,28,38,0.94)", + outline: "rgba(255,255,255,0.92)", + }; + })(); + + context.save(); + context.fillStyle = options.backgroundColor; + context.fillRect(mapOffset, trackPanelSizePx, mapSizePx, rulerPanelSizePx); + context.fillRect(trackPanelSizePx, mapOffset, rulerPanelSizePx, mapSizePx); + + const axisY = trackPanelSizePx + rulerPanelSizePx - 8; + context.strokeStyle = palette.line; + context.lineWidth = 2; + context.beginPath(); + context.moveTo(mapOffset, axisY); + context.lineTo(mapOffset + mapSizePx, axisY); + context.stroke(); + + const axisX = trackPanelSizePx + rulerPanelSizePx - 8; + context.beginPath(); + context.moveTo(axisX, mapOffset); + context.lineTo(axisX, mapOffset + mapSizePx); + context.stroke(); + + const tickPositions = this.buildRulerTickPositions(mapSizePx); + context.strokeStyle = palette.line; + context.fillStyle = palette.text; + context.lineWidth = 1.5; + context.textAlign = "center"; + context.textBaseline = "top"; + context.strokeStyle = palette.outline; + + let previousHorizontalRaw: string | null = null; + let previousHorizontalBp: number | null = null; + for (const tickPx of tickPositions) { + const mapPx = Math.max(0, Math.min(mapSizePx - 1, tickPx)); + const bpValue = this.contigDimensionHolder.getStartBpOfPx( + mapPx, + bpResolution + ); + const label = this.formatRulerTickLabel( + bpValue, + previousHorizontalRaw, + previousHorizontalBp + ); + previousHorizontalRaw = label.rawLabel; + previousHorizontalBp = bpValue; + + const x = mapOffset + tickPx; + context.beginPath(); + context.moveTo(x + 0.5, axisY); + context.lineTo(x + 0.5, axisY - 8); + context.stroke(); + + context.save(); + context.translate(x + 2, axisY - 12); + context.rotate(-Math.PI / 5); + context.font = label.compact ? "9px sans-serif" : "bold 11px sans-serif"; + context.lineWidth = label.compact ? 2 : 2.5; + this.drawOutlinedCanvasText(context, label.text, 0, 0); + context.restore(); + } + + let previousVerticalRaw: string | null = null; + let previousVerticalBp: number | null = null; + for (const tickPx of tickPositions) { + const mapPx = Math.max(0, Math.min(mapSizePx - 1, tickPx)); + const bpValue = this.contigDimensionHolder.getStartBpOfPx( + mapPx, + bpResolution + ); + const label = this.formatRulerTickLabel( + bpValue, + previousVerticalRaw, + previousVerticalBp + ); + previousVerticalRaw = label.rawLabel; + previousVerticalBp = bpValue; + + const y = mapOffset + tickPx; + context.beginPath(); + context.moveTo(axisX, y + 0.5); + context.lineTo(axisX - 8, y + 0.5); + context.stroke(); + + context.save(); + context.translate(axisX - 10, y + 2); + context.rotate(-Math.PI / 2); + context.textAlign = "left"; + context.font = label.compact ? "9px sans-serif" : "bold 11px sans-serif"; + context.lineWidth = label.compact ? 2 : 2.5; + this.drawOutlinedCanvasText(context, label.text, 0, 0); + context.restore(); + } + context.restore(); + } + + private getCurrentViewportInMapPixels( + mapSizePx: number + ): { left: number; top: number; width: number; height: number } | null { + const mapSize = this.map.getSize(); + if (!mapSize) { + return null; + } + const mapView = this.map.getView(); + const activeHiCLayer = this.viewAndLayersManager.getActiveHiCDataLayer(); + const targetProjection = + activeHiCLayer.getSource()?.getProjection() ?? mapView.getProjection(); + const extent = mapView.calculateExtent(mapSize); + const pixelResolution = mapView.getResolution() ?? 1; + const fixed = transform(extent, mapView.getProjection(), targetProjection).map( + (coordinate) => coordinate / pixelResolution + ); + const unclampedLeft = -fixed[0]; + const unclampedRight = fixed[2]; + const unclampedTop = fixed[3]; + const unclampedBottom = -fixed[1]; + const left = Math.max(0, Math.min(mapSizePx, unclampedLeft)); + const right = Math.max(left + 1, Math.min(mapSizePx, unclampedRight)); + const top = Math.max(0, Math.min(mapSizePx, unclampedTop)); + const bottom = Math.max(top + 1, Math.min(mapSizePx, unclampedBottom)); + return { + left, + top, + width: Math.max(1, right - left), + height: Math.max(1, bottom - top), + }; + } + + private drawExportMinimap( + context: CanvasRenderingContext2D, + mapImage: HTMLImageElement, + options: { + mapSizePx: number; + mapOffset: number; + totalWidth: number; + totalHeight: number; + backgroundColor: string; + } + ): void { + const minimapMaxWidth = Math.max(56, options.mapOffset - 16); + const minimapMaxHeight = Math.max(56, options.mapOffset - 16); + const minimapSize = Math.max( + 56, + Math.min( + 180, + Math.round(options.mapSizePx * 0.18), + minimapMaxWidth, + minimapMaxHeight + ) + ); + const x = 8; + const y = 8; + context.save(); + context.fillStyle = options.backgroundColor; + context.fillRect(x, y, minimapSize, minimapSize); + context.strokeStyle = "rgba(31,41,55,0.55)"; + context.lineWidth = 1; + context.strokeRect(x + 0.5, y + 0.5, minimapSize - 1, minimapSize - 1); + const sourceSize = Math.max( + 1, + Math.min(options.mapSizePx, mapImage.width, mapImage.height) + ); + context.drawImage( + mapImage, + 0, + 0, + sourceSize, + sourceSize, + x, + y, + minimapSize, + minimapSize + ); + + const viewport = this.getCurrentViewportInMapPixels(options.mapSizePx); + if (viewport) { + const scale = minimapSize / Math.max(1, options.mapSizePx); + const rectX = x + viewport.left * scale; + const rectY = y + viewport.top * scale; + const rectW = Math.max(2, viewport.width * scale); + const rectH = Math.max(2, viewport.height * scale); + context.strokeStyle = "rgba(255,255,255,0.98)"; + context.lineWidth = 3; + context.strokeRect(rectX, rectY, rectW, rectH); + context.strokeStyle = "rgba(220,38,38,0.98)"; + context.lineWidth = 1.5; + context.strokeRect(rectX, rectY, rectW, rectH); + } + context.restore(); + } + + private async buildDataDrivenExportCompositeCanvas( + progressCallback?: (progress: number) => void, + options?: { + backgroundColor?: string; + metadata?: Record; + } + ): Promise { + const descriptor = + this.viewAndLayersManager.currentViewState.resolutionDesciptor; + const bpResolution = descriptor.bpResolution; + const configuredMapSizePx = + this.viewAndLayersManager.imageSizes[descriptor.imageSizeIndex] ?? 1; + const prefixPx = this.contigDimensionHolder.prefix_sum_px.get(bpResolution); + const assemblyMapSizePx = + prefixPx?.[this.contigDimensionHolder.contig_count] ?? configuredMapSizePx; + const mapSizePx = Math.max( + 1, + Math.min(configuredMapSizePx, assemblyMapSizePx) + ); + const visibleTrackCount = this.linearTrackManager + .getTracksSnapshot() + .filter((track) => track.visible).length; + const trackPanelSizePx = visibleTrackCount > 0 ? 140 : 0; + const rulerPanelSizePx = 44; + const mapOffset = trackPanelSizePx + rulerPanelSizePx; + const totalWidth = mapOffset + mapSizePx; + const totalHeight = mapOffset + mapSizePx; + const backgroundColor = + options?.backgroundColor ?? useStyleStore().mapBackgroundColor.RGBA; + + const canvas = document.createElement("canvas"); + canvas.width = totalWidth; + canvas.height = totalHeight; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Cannot export: canvas context is unavailable"); + } + context.clearRect(0, 0, totalWidth, totalHeight); + context.fillStyle = backgroundColor; + context.fillRect(0, 0, totalWidth, totalHeight); + + const mapSvg = await this.buildCurrentMapSvg( + (progress) => progressCallback?.(progress * 0.64), + { + ...options, + includeWorkspaceComposite: false, + } + ); + const mapImage = await this.loadSvgImage(mapSvg); + const sourceSize = Math.min(mapSizePx, mapImage.width, mapImage.height); + context.drawImage( + mapImage, + 0, + 0, + sourceSize, + sourceSize, + mapOffset, + mapOffset, + mapSizePx, + mapSizePx + ); + progressCallback?.(0.68); + + if (visibleTrackCount > 0) { + const [horizontalTrackCanvas, verticalTrackCanvas] = await Promise.all([ + this.linearTrackManager.renderFullExtentCanvasForExport("horizontal", { + bpResolution, + startPx: 0, + endPx: mapSizePx, + trackPanelSizePx, + }), + this.linearTrackManager.renderFullExtentCanvasForExport("vertical", { + bpResolution, + startPx: 0, + endPx: mapSizePx, + trackPanelSizePx, + }), + ]); + if (horizontalTrackCanvas) { + context.drawImage( + horizontalTrackCanvas, + mapOffset, + 0, + mapSizePx, + trackPanelSizePx + ); + } + if (verticalTrackCanvas) { + context.drawImage( + verticalTrackCanvas, + 0, + mapOffset, + trackPanelSizePx, + mapSizePx + ); + } + } + progressCallback?.(0.84); + + this.drawFullExtentExportRulers(context, { + mapOffset, + mapSizePx, + trackPanelSizePx, + rulerPanelSizePx, + bpResolution, + backgroundColor, + }); + + // this.drawExportMinimap(context, mapImage, { + // mapSizePx, + // mapOffset, + // totalWidth, + // totalHeight, + // backgroundColor, + // }); + progressCallback?.(1); + return canvas; + } + public async exportCurrentMapSvg( progressCallback?: (progress: number) => void, options?: { backgroundColor?: string; metadata?: Record; + includeWorkspaceComposite?: boolean; } ): Promise { - const svg = await this.buildCurrentMapSvg(progressCallback, options); + let svg: string; + if (options?.includeWorkspaceComposite === true) { + const workspaceCanvas = await this.renderWorkspaceCompositeCanvas( + options?.backgroundColor + ); + progressCallback?.(1); + const dataUrl = workspaceCanvas.toDataURL("image/png"); + svg = + `` + + `` + + `${ + options?.metadata + ? `${JSON.stringify(options.metadata)}` + : "" + }` + + `` + + `` + + ``; + } else { + const composite = await this.buildDataDrivenExportCompositeCanvas( + progressCallback, + options + ); + const dataUrl = composite.toDataURL("image/png"); + svg = + `` + + `` + + `${ + options?.metadata + ? `${JSON.stringify(options.metadata)}` + : "" + }` + + `` + + `` + + ``; + } const blob = new Blob([svg], { type: "image/svg+xml" }); const a = document.createElement("a"); a.download = `${this.options.filename}.svg`; @@ -341,33 +932,19 @@ class ContactMapManager { options?: { backgroundColor?: string; metadata?: Record; + includeWorkspaceComposite?: boolean; } ): Promise { - const svg = await this.buildCurrentMapSvg(progressCallback, options); - const svgBlob = new Blob([svg], { type: "image/svg+xml" }); - const svgUrl = URL.createObjectURL(svgBlob); - const image = new Image(); - const descriptor = - this.viewAndLayersManager.currentViewState.resolutionDesciptor; - const imageSize = - this.viewAndLayersManager.imageSizes[descriptor.imageSizeIndex]; - - await new Promise((resolve, reject) => { - image.onload = () => resolve(); - image.onerror = (e) => reject(e); - image.src = svgUrl; - }); - - const canvas = document.createElement("canvas"); - canvas.width = imageSize; - canvas.height = imageSize; - const ctx = canvas.getContext("2d"); - if (!ctx) { - URL.revokeObjectURL(svgUrl); - throw new Error("Cannot export PNG: canvas not available"); + const canvas = + options?.includeWorkspaceComposite === true + ? await this.renderWorkspaceCompositeCanvas(options?.backgroundColor) + : await this.buildDataDrivenExportCompositeCanvas( + progressCallback, + options + ); + if (options?.includeWorkspaceComposite === true) { + progressCallback?.(1); } - ctx.drawImage(image, 0, 0); - await new Promise((resolve) => { canvas.toBlob((blob) => { if (!blob) { @@ -382,7 +959,6 @@ class ContactMapManager { resolve(); }, "image/png"); }); - URL.revokeObjectURL(svgUrl); } public async exportCurrentMapPdf( @@ -390,43 +966,29 @@ class ContactMapManager { options?: { backgroundColor?: string; metadata?: Record; + includeWorkspaceComposite?: boolean; } ): Promise { - const svg = await this.buildCurrentMapSvg(progressCallback, options); - const svgBlob = new Blob([svg], { type: "image/svg+xml" }); - const svgUrl = URL.createObjectURL(svgBlob); - const image = new Image(); - const descriptor = - this.viewAndLayersManager.currentViewState.resolutionDesciptor; - const imageSize = - this.viewAndLayersManager.imageSizes[descriptor.imageSizeIndex]; - - await new Promise((resolve, reject) => { - image.onload = () => resolve(); - image.onerror = (e) => reject(e); - image.src = svgUrl; - }); - - const canvas = document.createElement("canvas"); - canvas.width = imageSize; - canvas.height = imageSize; - const ctx = canvas.getContext("2d"); - if (!ctx) { - URL.revokeObjectURL(svgUrl); - throw new Error("Cannot export PDF: canvas not available"); - } - ctx.drawImage(image, 0, 0); + const canvas = + options?.includeWorkspaceComposite === true + ? await this.renderWorkspaceCompositeCanvas(options?.backgroundColor) + : await this.buildDataDrivenExportCompositeCanvas( + progressCallback, + options + ); + if (options?.includeWorkspaceComposite === true) { + progressCallback?.(1); + } const dataUrl = canvas.toDataURL("image/png"); const { jsPDF } = await import("jspdf"); - const orientation = imageSize >= imageSize ? "landscape" : "portrait"; + const orientation = canvas.width >= canvas.height ? "landscape" : "portrait"; const pdf = new jsPDF({ orientation, unit: "px", - format: [imageSize, imageSize], + format: [canvas.width, canvas.height], }); - pdf.addImage(dataUrl, "PNG", 0, 0, imageSize, imageSize); + pdf.addImage(dataUrl, "PNG", 0, 0, canvas.width, canvas.height); pdf.save(`${this.options.filename}.pdf`); - URL.revokeObjectURL(svgUrl); } private exportTracksSvg(bpResolution: number): string { @@ -443,21 +1005,11 @@ class ContactMapManager { const contigTrack = this.viewAndLayersManager.track2DHolder.contigBordersTrack; const scaffoldTrack = this.viewAndLayersManager.track2DHolder.scaffoldBordersTrack; - const prefixPx = this.contigDimensionHolder.prefix_sum_px.get(bpResolution); - if (!prefixPx) { + const pixelResolution = + this.viewAndLayersManager.resolutionToPixelResolution.get(bpResolution) ?? 1; + if (!Number.isFinite(pixelResolution) || pixelResolution <= 0) { return ""; } - - const measureCanvas = - typeof document !== "undefined" - ? document.createElement("canvas") - : null; - const measureCtx = measureCanvas?.getContext("2d") ?? null; - const measureLabelWidth = (text: string, font: string): number => { - if (!measureCtx) return text.length * 6; - measureCtx.font = font; - return measureCtx.measureText(text).width; - }; const escapeXml = ContactMapManager.escapeXml; const escapeAttr = ContactMapManager.escapeXml; @@ -470,105 +1022,95 @@ class ContactMapManager { }; const toFontFamily = (track: typeof contigTrack) => toFont(track).replace(/^[^ ]+ /, ""); - - let svg = ""; - - if (flags.contigBorders || flags.contigNames) { - const borderStyle = contigTrack.getStyleType(); - const strokeColor = contigTrack.options.borderColor as string; - const labelColor = contigTrack.getLabelColor() as string; - const strokeWidth = contigTrack.options.width; - const fillColor = contigTrack.options.fillColor as string; - const labelSize = contigTrack.getLabelSize(); - const labelOffset = labelSize * contigTrack.getLabelOffsetMultiplier(); - const namePlacement = contigTrack.getNamePlacement(); - const labelFont = toFont(contigTrack); - const labelStroke = contigTrack.getLabelOutline() - ? `stroke="rgba(0,0,0,0.9)" stroke-width="${contigTrack.getLabelOutlineWidth()}" paint-order="stroke fill" stroke-linejoin="round"` - : ""; - for (let i = 0; i < this.contigDimensionHolder.contigDescriptors.length; i++) { - const cd = this.contigDimensionHolder.contigDescriptors[i]; - const startPx = prefixPx[i] ?? 0; - const lenBins = cd.contigLengthBins.get(bpResolution) ?? 0; - const endPx = startPx + lenBins; - if (endPx <= startPx) continue; - if (flags.contigBorders) { - if (borderStyle === 0) { - svg += ``; - } else if (borderStyle === 1) { - svg += ``; - } else if (borderStyle === 2) { - svg += ``; - } - } - if (flags.contigNames) { - const rectWidth = endPx - startPx; - const labelWidth = measureLabelWidth(cd.contigName, labelFont); - if (rectWidth > 0 && labelWidth < rectWidth * 0.9) { - let labelY = (startPx + endPx) / 2; - if (namePlacement === 0) { - labelY = startPx - labelOffset; - } else if (namePlacement === 1) { - labelY = endPx + labelOffset; - } - const midPx = (startPx + endPx) / 2; - svg += `${escapeXml(cd.contigName)}`; - } - } + const toSvgPoint = (coordinate: [number, number]): [number, number] => [ + coordinate[0] / pixelResolution, + -coordinate[1] / pixelResolution, + ]; + const polylineSvg = (coordinates: [number, number][]): string => + coordinates + .map((coordinate) => { + const [x, y] = toSvgPoint(coordinate); + return `${x},${y}`; + }) + .join(" "); + const renderTrackFeatures = ( + featureSource: typeof contigTrack, + borderEnabled: boolean, + namesEnabled: boolean, + borderTrackType: "contigBorders" | "scaffoldBorders", + namesTrackType: "contigNames" | "scaffoldNames", + labelExtractor: (featureName: unknown) => string + ): string => { + const features = featureSource.features.get(bpResolution) ?? []; + if (features.length === 0) { + return ""; } - } - - if (flags.scaffoldBorders || flags.scaffoldNames) { - const borderStyle = scaffoldTrack.getStyleType(); - const strokeColor = scaffoldTrack.options.borderColor as string; - const labelColor = scaffoldTrack.getLabelColor() as string; - const strokeWidth = scaffoldTrack.options.width; - const fillColor = scaffoldTrack.options.fillColor as string; - const labelSize = scaffoldTrack.getLabelSize(); - const labelOffset = labelSize * scaffoldTrack.getLabelOffsetMultiplier(); - const namePlacement = scaffoldTrack.getNamePlacement(); - const labelFont = toFont(scaffoldTrack); - const labelStroke = scaffoldTrack.getLabelOutline() - ? `stroke="rgba(0,0,0,0.9)" stroke-width="${scaffoldTrack.getLabelOutlineWidth()}" paint-order="stroke fill" stroke-linejoin="round"` + const strokeColor = featureSource.options.borderColor as string; + const labelColor = featureSource.getLabelColor() as string; + const strokeWidth = featureSource.options.width; + const fillColor = featureSource.options.fillColor as string; + const labelSize = featureSource.getLabelSize(); + const labelStroke = featureSource.getLabelOutline() + ? `stroke="rgba(0,0,0,0.9)" stroke-width="${featureSource.getLabelOutlineWidth()}" paint-order="stroke fill" stroke-linejoin="round"` : ""; - for (const sd of this.scaffoldHolder.scaffoldTable.values()) { - const borders = sd.scaffoldBordersBP; - if (!borders) continue; - const startPx = this.contigDimensionHolder.getPxContainingBp( - borders.startBP, - bpResolution - ); - const endPx = this.contigDimensionHolder.getPxContainingBp( - borders.endBP, - bpResolution - ); - if (endPx <= startPx) continue; - const lenBins = endPx - startPx; - if (flags.scaffoldBorders) { - if (borderStyle === 0) { - svg += ``; - } else if (borderStyle === 1) { - svg += ``; - } else if (borderStyle === 2) { - svg += ``; - } + let result = ""; + for (const feature of features) { + const trackType = String(feature.get("trackType") ?? ""); + const geometry = feature.getGeometry(); + if (!geometry) { + continue; } - if (flags.scaffoldNames) { - const rectWidth = endPx - startPx; - const labelWidth = measureLabelWidth(sd.scaffoldName, labelFont); - if (rectWidth > 0 && labelWidth < rectWidth * 0.9) { - let labelY = (startPx + endPx) / 2; - if (namePlacement === 0) { - labelY = startPx - labelOffset; - } else if (namePlacement === 1) { - labelY = endPx + labelOffset; - } - const midPx = (startPx + endPx) / 2; - svg += `${escapeXml(sd.scaffoldName)}`; + if (borderEnabled && trackType === borderTrackType) { + const geometryType = geometry.getType(); + if (geometryType === "Polygon") { + const ring = (geometry as unknown as { getCoordinates(): [number, number][][] }) + .getCoordinates()[0] as [number, number][]; + result += ``; + } else if (geometryType === "LineString") { + const line = (geometry as unknown as { getCoordinates(): [number, number][] }) + .getCoordinates() as [number, number][]; + result += ``; } + } else if (namesEnabled && trackType === namesTrackType) { + if (geometry.getType() !== "Point") { + continue; + } + const point = (geometry as unknown as { getCoordinates(): [number, number] }) + .getCoordinates(); + const [x, y] = toSvgPoint(point); + const labelText = labelExtractor(feature.get("name")); + result += `${escapeXml(labelText)}`; } } - } + return result; + }; + const extractFeatureName = (raw: unknown): string => { + const value = String(raw ?? ""); + const firstDash = value.indexOf("-"); + const secondDash = value.lastIndexOf("-bp"); + if (firstDash >= 0 && secondDash > firstDash) { + return value.slice(firstDash + 1, secondDash); + } + return value; + }; + + let svg = ""; + svg += renderTrackFeatures( + contigTrack, + flags.contigBorders, + flags.contigNames, + "contigBorders", + "contigNames", + extractFeatureName + ); + svg += renderTrackFeatures( + scaffoldTrack, + flags.scaffoldBorders, + flags.scaffoldNames, + "scaffoldBorders", + "scaffoldNames", + extractFeatureName + ); return svg; } @@ -582,8 +1124,27 @@ class ContactMapManager { .replace(/'/g, "'"); } + private async renderWorkspaceCompositeCanvas( + backgroundColor?: string + ): Promise { + const workspace = document.querySelector( + ".interactive-workspace" + ) as HTMLElement | null; + if (!workspace) { + throw new Error("Cannot export composite: workspace is not available"); + } + const { default: html2canvas } = await import("html2canvas"); + return html2canvas(workspace, { + backgroundColor: backgroundColor ?? null, + useCORS: true, + scale: Math.min(2, window.devicePixelRatio || 1), + logging: false, + }); + } + public dispose() { this.linearTrackManager.dispose(); + this.clearOverviewMapTarget(); this.viewAndLayersManager?.dispose?.(); this.map.setTarget(undefined); } @@ -642,6 +1203,144 @@ class ContactMapManager { public reloadVisuals(): void { this.viewAndLayersManager.reloadVisuals(); void this.linearTrackManager.clearCachesAndRender(); + this.scheduleMinimapViewportSync(); + } + + public refreshOverviewMinimap(): void { + this.scheduleMinimapViewportSync(); + } + + private createCoarsestMinimapLayer(): + | TileLayer + | null { + if (this.viewAndLayersManager.resolutionTuples.length === 0) { + return null; + } + const coarsestDescriptor = this.viewAndLayersManager.resolutionTuples.reduce( + (accumulator, descriptor) => + descriptor.pixelResolution > accumulator.pixelResolution + ? descriptor + : accumulator + ); + const layer = this.viewAndLayersManager.layersHolder.bpResolutionToHiCDataLayer.get( + coarsestDescriptor.bpResolution + ); + if (!(layer instanceof TileLayer)) { + return null; + } + const source = layer.getSource(); + if (!(source instanceof VersionedXYZContactMapSource)) { + return null; + } + return new TileLayer({ + source, + preload: 0, + }); + } + + private estimateMinimapResolution( + projectionExtent: [number, number, number, number], + target: HTMLElement + ): number { + const width = Math.max(1, target.clientWidth || 1); + const height = Math.max(1, target.clientHeight || 1); + const widthResolution = getWidth(projectionExtent) / width; + const heightResolution = getHeight(projectionExtent) / height; + const estimatedResolution = Math.max(widthResolution, heightResolution); + return Number.isFinite(estimatedResolution) && estimatedResolution > 0 + ? estimatedResolution + : 1; + } + + private fitMinimapToFullExtent(): void { + if (!this.minimap) { + return; + } + const view = this.minimap.getView(); + const extent = view.getProjection().getExtent(); + const target = this.minimap.getTargetElement(); + if (!extent || !target) { + return; + } + this.applyMinimapBackground(target); + const extentTuple = extent as [number, number, number, number]; + view.setCenter(getCenter(extentTuple)); + view.setResolution(this.estimateMinimapResolution(extentTuple, target)); + } + + private scheduleMinimapViewportSync(): void { + if (this.minimapRenderFramePending) { + return; + } + this.minimapRenderFramePending = true; + window.requestAnimationFrame(() => { + this.minimapRenderFramePending = false; + this.syncMinimapViewport(); + }); + } + + private syncMinimapViewport(): void { + if (!this.minimap || !this.minimapViewportFeature) { + return; + } + const minimapTarget = this.minimap.getTargetElement(); + if (minimapTarget) { + this.applyMinimapBackground(minimapTarget); + } + const minimapView = this.minimap.getView(); + const minimapProjection = minimapView.getProjection(); + const minimapProjectionExtent = minimapProjection.getExtent(); + if (!minimapProjectionExtent) { + return; + } + const mainMapSize = this.map.getSize(); + if (!mainMapSize) { + return; + } + const mainExtent = this.map.getView().calculateExtent(mainMapSize); + const transformedMainExtent = transformExtent( + mainExtent, + this.map.getView().getProjection(), + minimapProjection + ); + if (!transformedMainExtent.every((value) => Number.isFinite(value))) { + return; + } + const clampedExtent = this.clampExtentToBounds( + transformedMainExtent as [number, number, number, number], + minimapProjectionExtent as [number, number, number, number] + ); + this.minimapViewportFeature.setGeometry(fromExtent(clampedExtent)); + this.minimap.render(); + } + + private applyMinimapBackground(target: HTMLElement): void { + const color = useStyleStore().mapBackgroundColor.RGB; + target.style.backgroundColor = color; + } + + private clampExtentToBounds( + extent: [number, number, number, number], + bounds: [number, number, number, number] + ): [number, number, number, number] { + if (!intersects(extent, bounds)) { + return bounds; + } + const clamp = (value: number, minValue: number, maxValue: number): number => + Math.max(minValue, Math.min(maxValue, value)); + let left = clamp(extent[0], bounds[0], bounds[2]); + let right = clamp(extent[2], bounds[0], bounds[2]); + let bottom = clamp(extent[1], bounds[1], bounds[3]); + let top = clamp(extent[3], bounds[1], bounds[3]); + if (right <= left) { + right = Math.min(bounds[2], left + 1); + left = Math.max(bounds[0], right - 1); + } + if (top <= bottom) { + top = Math.min(bounds[3], bottom + 1); + bottom = Math.max(bounds[1], top - 1); + } + return [left, bottom, right, top]; } } diff --git a/src/app/core/mapmanagers/HiCViewAndLayersManager.ts b/src/app/core/mapmanagers/HiCViewAndLayersManager.ts index 404ff4c..5da6c31 100644 --- a/src/app/core/mapmanagers/HiCViewAndLayersManager.ts +++ b/src/app/core/mapmanagers/HiCViewAndLayersManager.ts @@ -898,15 +898,24 @@ class HiCViewAndLayersManager { }); try { const map = this.mapManager.getMap(); - map.on("moveend", (event) => { - rulerH.render(event); - }); - map.on("moveend", (event) => { - rulerV.render(event); - }); + const view = map.getView(); + let framePending = false; + const scheduleRulerRender = () => { + if (framePending) { + return; + } + framePending = true; + window.requestAnimationFrame(() => { + framePending = false; + rulerH.render({ map } as never); + rulerV.render({ map } as never); + }); + }; + map.on("moveend", scheduleRulerRender); + view.on("change:center", scheduleRulerRender); + view.on("change:resolution", scheduleRulerRender); map.once("postrender", () => { - rulerH.render({ map } as never); - rulerV.render({ map } as never); + scheduleRulerRender(); }); } catch (e: unknown) { console.log("Error while adding rulers", e); diff --git a/src/app/core/mapmanagers/LinearTrackManager.ts b/src/app/core/mapmanagers/LinearTrackManager.ts index 5528458..e12b932 100644 --- a/src/app/core/mapmanagers/LinearTrackManager.ts +++ b/src/app/core/mapmanagers/LinearTrackManager.ts @@ -23,13 +23,29 @@ import type { EventsKey } from "ol/events"; import { unByKey } from "ol/Observable"; import { transform } from "ol/proj"; import type { ContactMapManager } from "./ContactMapManager"; +import { useStyleStore } from "@/app/stores/styleStore"; +import { useUiSettingsStore } from "@/app/stores/uiSettingsStore"; import type { + FileEntryResponse, + TrackBinBlockResponse, + TrackFeatureSearchHitResponse, + TrackBinResponse, + TrackCompatibilityReportResponse, TrackQueryResponse, + TrackRenderResponse, TrackSummaryResponse, TracksPrecomputeStatusResponse, } from "@/app/core/net/api/response"; type Orientation = "horizontal" | "vertical"; +type TrackTextPalette = { + primary: string; + muted: string; + error: string; + axis: string; + axisStroke: string; + outline: string; +}; const DEFAULT_PREFETCH_EXTENT_SCREENS = 2; const CACHE_MAX_AGE_MS = 1500; const MAX_PREFETCH_QUERY_WIDTH_PX = 2048; @@ -41,6 +57,7 @@ class LinearTrackManager { private horizontalResizeObserver: ResizeObserver | null = null; private verticalResizeObserver: ResizeObserver | null = null; private tracks: TrackSummaryResponse[] = []; + private readonly trackLogBases = new Map(); private renderRequestId = 0; private readonly moveEndListener?: EventsKey; private readonly centerListener?: EventsKey; @@ -68,6 +85,21 @@ class LinearTrackManager { horizontal: new Set(), vertical: new Set(), }; + private readonly lastRenderedSnapshot: Record< + Orientation, + { + tracks: TrackRenderResponse[]; + viewport: ViewportGeometry; + canvasWidth: number; + canvasHeight: number; + renderedAtMs: number; + } | null + > = { + horizontal: null, + vertical: null, + }; + private readonly featureSearchCache = new Map(); + private selectedFeature: SelectedTrackFeature | null = null; constructor(private readonly mapManager: ContactMapManager) { const map = this.mapManager.getMap(); @@ -169,16 +201,39 @@ class LinearTrackManager { public async refreshTrackList(): Promise { this.tracks = await this.mapManager.networkManager.requestManager.listTracks(); + this.syncTrackRenderSettings(); this.invalidateQueryCache(); this.notifyTrackListChanged(); await this.render(); return this.tracks.slice(); } + public getTrackLogBase(trackId: string): number { + return this.trackLogBases.get(trackId) ?? 10; + } + + public setTrackLogBase(trackId: string, value: number): void { + const normalized = Number.isFinite(value) ? Math.max(1.0000001, value) : 10; + this.trackLogBases.set(trackId, normalized); + void this.render({ allowFetch: false }); + } + public async listTrackFiles(): Promise { return this.mapManager.networkManager.requestManager.listTrackFiles(); } + public async listFilesDetailed(): Promise { + return this.mapManager.networkManager.requestManager.listFilesDetailed(); + } + + public async probeTrackCompatibility( + filename: string + ): Promise { + return this.mapManager.networkManager.requestManager.probeTrackCompatibility( + filename + ); + } + public async openTrack(filename: string, name?: string): Promise { await this.mapManager.networkManager.requestManager.openTrack( filename, @@ -187,11 +242,29 @@ class LinearTrackManager { await this.refreshTrackList(); } + public async openCoolerWeightsTrack(name?: string): Promise { + await this.mapManager.networkManager.requestManager.openCoolerWeightsTrack( + name + ); + await this.refreshTrackList(); + } + public async removeTrack(trackId: string): Promise { await this.mapManager.networkManager.requestManager.removeTrack(trackId); await this.refreshTrackList(); } + public async reorderTrack(trackId: string, targetIndex: number): Promise { + this.tracks = await this.mapManager.networkManager.requestManager.reorderTrack( + trackId, + targetIndex + ); + this.syncTrackRenderSettings(); + this.invalidateQueryCache(); + this.notifyTrackListChanged(); + await this.render({ allowFetch: true }); + } + public async updateTrack( trackId: string, options: { @@ -200,6 +273,7 @@ class LinearTrackManager { name?: string; renderMode?: string; aggregationMode?: string; + logScale?: boolean; } ): Promise { await this.mapManager.networkManager.requestManager.updateTrack( @@ -242,6 +316,51 @@ class LinearTrackManager { await Promise.all([horizontalPromise, verticalPromise]); } + public async renderFullExtentCanvasForExport( + orientation: Orientation, + options: { + bpResolution: number; + startPx: number; + endPx: number; + trackPanelSizePx?: number; + } + ): Promise { + const spanPx = Math.max(1, options.endPx - options.startPx); + const visibleTracks = this.tracks.filter((track) => track.visible); + if (visibleTracks.length === 0) { + return null; + } + const trackPanelSizePx = Math.max(60, options.trackPanelSizePx ?? 140); + const canvas = document.createElement("canvas"); + if (orientation === "horizontal") { + canvas.width = spanPx; + canvas.height = trackPanelSizePx; + } else { + canvas.width = trackPanelSizePx; + canvas.height = spanPx; + } + const response = await this.mapManager.networkManager.requestManager.queryTracks1D( + options.startPx, + options.endPx, + spanPx, + options.bpResolution + ); + const viewport: ViewportGeometry = { + startBp: response.startBp, + endBp: response.endBp, + startPx: options.startPx, + endPx: options.endPx, + bpResolution: options.bpResolution, + visibleWidthPx: spanPx, + pxToScreen: (px: number) => { + const normalized = (px - options.startPx) / Math.max(1, spanPx); + return normalized * spanPx; + }, + }; + this.drawTrackCanvas(canvas, orientation, response, viewport); + return canvas; + } + private isViewInteracting(): boolean { const view = this.mapManager.getView(); return view.getInteracting() || view.getAnimating(); @@ -289,11 +408,291 @@ class LinearTrackManager { this.prefetchInFlight.vertical.clear(); } + private syncTrackRenderSettings(): void { + const existingIds = new Set(this.tracks.map((track) => track.trackId)); + for (const trackId of [...this.trackLogBases.keys()]) { + if (!existingIds.has(trackId)) { + this.trackLogBases.delete(trackId); + } + } + this.tracks.forEach((track) => { + if (!this.trackLogBases.has(track.trackId)) { + this.trackLogBases.set(track.trackId, 10); + } + }); + for (const [key, entry] of this.featureSearchCache.entries()) { + if (!existingIds.has(entry.trackId)) { + this.featureSearchCache.delete(key); + } + } + } + public async clearCachesAndRender(): Promise { this.invalidateQueryCache(); await this.render({ allowFetch: true }); } + public getFeatureHoverAt( + orientation: Orientation, + axisOffsetPx: number, + crossOffsetPx: number + ): FeatureHoverInfo | null { + const snapshot = this.lastRenderedSnapshot[orientation]; + if (!snapshot || snapshot.tracks.length === 0) { + return null; + } + const laneSize = + orientation === "horizontal" + ? snapshot.canvasHeight / snapshot.tracks.length + : snapshot.canvasWidth / snapshot.tracks.length; + if (!Number.isFinite(laneSize) || laneSize <= 0) { + return null; + } + const laneIndex = Math.floor(crossOffsetPx / laneSize); + if (laneIndex < 0 || laneIndex >= snapshot.tracks.length) { + return null; + } + const track = snapshot.tracks[laneIndex]; + if ((track.renderStyle ?? "SIGNAL").toUpperCase() !== "FEATURE") { + return null; + } + const laneStart = laneIndex * laneSize; + const laneEnd = laneStart + laneSize; + if (crossOffsetPx < laneStart || crossOffsetPx > laneEnd) { + return null; + } + for (const bin of track.bins) { + const interval = this.resolveBinIntervalPx(bin, snapshot.viewport.bpResolution); + if (!interval.visible) { + continue; + } + if ( + interval.endPx <= snapshot.viewport.startPx || + interval.startPx >= snapshot.viewport.endPx + ) { + continue; + } + const start = Math.floor(snapshot.viewport.pxToScreen(interval.startPx)); + const end = Math.max(start + 1, Math.ceil(snapshot.viewport.pxToScreen(interval.endPx))); + if (axisOffsetPx < start || axisOffsetPx > end) { + continue; + } + const label = (bin.label ?? "").trim(); + const featureType = (bin.featureType ?? "").trim(); + return { + trackId: track.trackId, + trackName: track.name, + label: label.length > 0 ? label : null, + featureType: featureType.length > 0 ? featureType : null, + strand: bin.strand, + startBp: Math.min(bin.startBp, bin.endBp), + endBp: Math.max(bin.startBp, bin.endBp), + value: bin.value, + }; + } + return null; + } + + public searchFeatureSuggestions( + queryRaw: string, + limit = 50 + ): FeatureSearchEntry[] { + const query = queryRaw.trim().toLowerCase(); + if (query.length < 2) { + return []; + } + const now = Date.now(); + const maxAgeMs = 6 * 60 * 1000; + const out: FeatureSearchEntry[] = []; + for (const [key, entry] of this.featureSearchCache.entries()) { + if (now - entry.updatedAtMs > maxAgeMs) { + this.featureSearchCache.delete(key); + continue; + } + if ( + entry.label.toLowerCase().includes(query) || + entry.trackName.toLowerCase().includes(query) || + (entry.featureType ?? "").toLowerCase().includes(query) + ) { + out.push(entry); + } + } + out.sort((a, b) => b.updatedAtMs - a.updatedAtMs); + return out.slice(0, Math.max(1, limit)); + } + + public async searchFeatureSuggestionsRemote( + queryRaw: string, + limit = 100 + ): Promise { + const query = queryRaw.trim(); + if (query.length < 2) { + return []; + } + const response = + await this.mapManager.networkManager.requestManager.searchTrackFeatures({ + query, + limit: Math.max(1, Math.min(300, limit)), + offset: 0, + }); + const now = Date.now(); + for (const hit of response.hits) { + const entry = this.mapFeatureHitToSearchEntry(hit, now); + this.featureSearchCache.set(entry.key, entry); + } + return this.searchFeatureSuggestions(query, limit); + } + + public centerOnFeature(entry: FeatureSearchEntry): void { + const view = this.mapManager.getView(); + const map = this.mapManager.getMap(); + const currentResolution = view.getResolution() ?? 1; + const mapSize = map.getSize() ?? [1200, 900]; + const spanBp = Math.max(1, entry.endBp - entry.startBp); + const tuples = this.mapManager.getLayersManager().resolutionTuples; + const finestBpResolution = tuples.reduce( + (acc, tuple) => Math.min(acc, tuple.bpResolution), + Number.POSITIVE_INFINITY + ); + const safeFinestBpResolution = + Number.isFinite(finestBpResolution) && finestBpResolution > 0 + ? finestBpResolution + : this.mapManager.getLayersManager().currentViewState.resolutionDesciptor + .bpResolution; + const finestPixelResolution = + this.mapManager.getLayersManager().resolutionToPixelResolution.get( + safeFinestBpResolution + ) ?? currentResolution; + const targetSpanPx = Math.max(180, Math.min(mapSize[0], mapSize[1]) * 0.52); + const spanPxAtFinest = spanBp / Math.max(1, safeFinestBpResolution); + const minViewResolution = view.getMinResolution() ?? 0; + const desiredScaleFactor = + spanPxAtFinest > 0 ? Math.min(1, spanPxAtFinest / targetSpanPx) : 1; + const pixelResolution = Math.max( + minViewResolution, + finestPixelResolution * desiredScaleFactor + ); + const midpointBp = (entry.startBp + entry.endBp) / 2; + const midpointPx = this.mapManager + .getContigDimensionHolder() + .getPxContainingBp( + Math.max(0, Math.round(midpointBp)), + safeFinestBpResolution + ); + this.setSelectedFeature(entry); + view.animate({ + center: [midpointPx, -midpointPx], + resolution: pixelResolution, + duration: 220, + }); + } + + public setSelectedFeature(entry: { + trackId: string; + label: string; + featureType: string | null; + startBp: number; + endBp: number; + }): void { + const startBp = Math.min(entry.startBp, entry.endBp); + const endBp = Math.max(entry.startBp, entry.endBp); + this.selectedFeature = { + trackId: entry.trackId, + label: (entry.label ?? "").trim(), + featureType: (entry.featureType ?? "").trim() || null, + startBp, + endBp, + }; + void this.render({ allowFetch: false }); + } + + public clearSelectedFeature(): void { + this.selectedFeature = null; + void this.render({ allowFetch: false }); + } + + public toggleFeatureSelectionAt( + orientation: Orientation, + axisOffsetPx: number, + crossOffsetPx: number + ): void { + const hit = this.getFeatureHoverAt(orientation, axisOffsetPx, crossOffsetPx); + if (!hit) { + return; + } + const next: SelectedTrackFeature = { + trackId: hit.trackId, + label: (hit.label ?? "").trim(), + featureType: (hit.featureType ?? "").trim() || null, + startBp: Math.min(hit.startBp, hit.endBp), + endBp: Math.max(hit.startBp, hit.endBp), + }; + if (this.isSameSelectedFeature(this.selectedFeature, next)) { + this.selectedFeature = null; + } else { + this.selectedFeature = next; + } + void this.render({ allowFetch: false }); + } + + public async prefetchFeatureContextAround( + startBp: number, + endBp: number, + options?: { + marginScreens?: number; + widthPx?: number; + bpResolution?: number; + } + ): Promise { + const descriptor = + this.mapManager.getLayersManager().currentViewState.resolutionDesciptor; + const mapSize = this.mapManager.getMap().getSize() ?? [1200, 900]; + const widthPx = Math.max( + 96, + Math.round(options?.widthPx ?? Math.max(mapSize[0], mapSize[1])) + ); + const bpResolution = Math.max( + 1, + Math.round(options?.bpResolution ?? descriptor.bpResolution) + ); + const marginScreens = + Number.isFinite(options?.marginScreens) && (options?.marginScreens ?? 0) >= 0 + ? Number(options?.marginScreens) + : this.prefetchExtentScreens; + try { + const context = + await this.mapManager.networkManager.requestManager.getTrackFeatureContext({ + unit: "BP", + start: Math.max(0, Math.floor(Math.min(startBp, endBp))), + end: Math.max( + Math.floor(Math.min(startBp, endBp)) + 1, + Math.ceil(Math.max(startBp, endBp)) + ), + widthPx, + bpResolution, + marginScreens, + }); + const cachedAt = Date.now(); + const cacheRecord: TrackQueryCache = { + bpResolution: context.query.bpResolution, + prefetchStartPx: context.query.startPx, + prefetchEndPx: context.query.endPx, + fetchedAtMs: cachedAt, + response: context.query, + }; + this.queryCache.horizontal.set(context.query.bpResolution, { ...cacheRecord }); + this.queryCache.vertical.set(context.query.bpResolution, { ...cacheRecord }); + this.pruneCache("horizontal"); + this.pruneCache("vertical"); + for (const track of context.query.tracks) { + this.refreshFeatureSearchCache(track, track.bins); + } + await this.render({ allowFetch: false }); + } catch (error) { + console.debug("Feature context prefetch failed", error); + } + } + private createResizeObserver( canvas: HTMLCanvasElement | null ): ResizeObserver | null { @@ -454,7 +853,7 @@ class LinearTrackManager { } ctx.textBaseline = "top"; ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = "rgba(248,249,250,0.98)"; + ctx.fillStyle = this.resolveTrackBackgroundColor(); ctx.fillRect(0, 0, canvas.width, canvas.height); const fallbackTracks = this.tracks .filter((track) => track.visible) @@ -463,16 +862,31 @@ class LinearTrackManager { name: track.name, type: track.type, color: track.color, + renderStyle: track.renderStyle ?? "SIGNAL", bins: [], maxValue: 0, error: null, })); const tracks = (response.tracks ?? []).length > 0 ? response.tracks : fallbackTracks; + this.lastRenderedSnapshot[orientation] = { + tracks, + viewport, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + renderedAtMs: Date.now(), + }; + const textPalette = this.resolveTrackTextPalette(); if (tracks.length === 0) { - ctx.fillStyle = "rgba(120,120,120,0.6)"; + ctx.fillStyle = textPalette.muted; ctx.font = "12px sans-serif"; - ctx.fillText(statusMessage ?? "No tracks loaded", 8, 8); + this.drawOutlinedText( + ctx, + statusMessage ?? "No tracks loaded", + 8, + 8, + textPalette + ); this.setRenderState(orientation, { statusMessage: statusMessage ?? "No tracks loaded", trackCount: 0, @@ -482,6 +896,7 @@ class LinearTrackManager { const descriptor = this.mapManager.getLayersManager().currentViewState.resolutionDesciptor; const bpResolution = descriptor.bpResolution; + const laneBackgroundColor = this.resolveTrackBackgroundColor(); const laneSize = orientation === "horizontal" ? canvas.height / tracks.length @@ -491,125 +906,207 @@ class LinearTrackManager { const laneEnd = laneStart + laneSize; const laneInnerStart = laneStart + 2; const laneInnerEnd = laneEnd - 2; - const maxValue = Math.max(track.maxValue, 1); - ctx.fillStyle = "rgba(226,232,240,0.95)"; + ctx.fillStyle = laneBackgroundColor; if (orientation === "horizontal") { ctx.fillRect(0, laneStart, canvas.width, laneSize - 1); - ctx.strokeStyle = "rgba(120,130,145,0.55)"; + ctx.strokeStyle = "rgba(120,130,145,0.35)"; ctx.strokeRect(0.5, laneStart + 0.5, canvas.width - 1, laneSize - 1); } else { ctx.fillRect(laneStart, 0, laneSize - 1, canvas.height); - ctx.strokeStyle = "rgba(120,130,145,0.55)"; + ctx.strokeStyle = "rgba(120,130,145,0.35)"; ctx.strokeRect(laneStart + 0.5, 0.5, laneSize - 1, canvas.height - 1); } + const renderStyle = + (track.renderStyle ?? "SIGNAL").toUpperCase() === "FEATURE" + ? "FEATURE" + : "SIGNAL"; + const trackSummary = this.tracks.find( + (item) => item.trackId === track.trackId + ); + const useLogScale = + renderStyle === "SIGNAL" && !!trackSummary?.logScale; + const logBase = this.getTrackLogBase(track.trackId); + const maxValue = + renderStyle === "SIGNAL" + ? this.getVisibleSignalMax(track.bins, viewport, bpResolution) + : Math.max(track.maxValue, 0); + const scaleTransform = this.buildScaleTransform(maxValue, useLogScale, logBase); + const binsToRender = + renderStyle === "FEATURE" + ? this.sortFeatureBinsForDraw(track.bins) + : track.bins; ctx.fillStyle = track.color ?? "#4e79a7"; - for (const bin of track.bins) { - const hasProjectedPx = - typeof bin.startPx === "number" && - Number.isFinite(bin.startPx) && - typeof bin.endPx === "number" && - Number.isFinite(bin.endPx); - const startPx = hasProjectedPx - ? Math.max(0, Math.min(bin.startPx ?? 0, bin.endPx ?? 0)) - : this.mapManager - .getContigDimensionHolder() - .getPxContainingBp( - Math.max(0, Math.min(bin.startBp, bin.endBp)), - bpResolution - ); - const endPx = hasProjectedPx - ? Math.max(startPx + 1, Math.max(bin.startPx ?? startPx, bin.endPx ?? startPx)) - : this.mapManager - .getContigDimensionHolder() - .getPxContainingBp( - Math.max( - Math.max(0, Math.min(bin.startBp, bin.endBp)), - Math.max(0, Math.max(bin.startBp, bin.endBp) - 1) - ), - bpResolution - ) + 1; - if (!hasProjectedPx) { - const intervalStart = Math.max(0, Math.min(bin.startBp, bin.endBp)); - const intervalEnd = Math.max(intervalStart + 1, Math.max(bin.startBp, bin.endBp)); - const intervalProbeEnd = Math.max(intervalStart, intervalEnd - 1); - if ( - !this.mapManager - .getContigDimensionHolder() - .isBpVisibleAtResolution(intervalStart, bpResolution) && - !this.mapManager - .getContigDimensionHolder() - .isBpVisibleAtResolution(intervalProbeEnd, bpResolution) - ) { - continue; - } + for (const bin of binsToRender) { + const interval = this.resolveBinIntervalPx(bin, bpResolution); + if (!interval.visible || interval.endPx <= viewport.startPx || interval.startPx >= viewport.endPx) { + continue; } - const normalizedValue = Math.max( - 0, - Math.min(1, (bin.value ?? 0) / maxValue) - ); + const startPx = interval.startPx; + const endPx = interval.endPx; + + const isSelectedFeature = + renderStyle === "FEATURE" && + this.matchesSelectedFeature(track.trackId, bin); + if (orientation === "horizontal") { const x0ByPx = Math.floor(viewport.pxToScreen(startPx)); - const x1ByPx = Math.max(x0ByPx + 1, Math.ceil(viewport.pxToScreen(endPx))); + const x1ByPx = Math.max( + x0ByPx + 1, + Math.ceil(viewport.pxToScreen(endPx)) + ); const x0 = Math.max(0, Math.min(canvas.width - 1, x0ByPx)); const x1 = Math.max(x0 + 1, Math.min(canvas.width, x1ByPx)); if (x1 <= x0 || x1ByPx === x0ByPx) { continue; } - const barHeight = - (laneInnerEnd - laneInnerStart) * normalizedValue; - const y = laneInnerEnd - barHeight; - ctx.fillRect(x0, y, x1 - x0, Math.max(1, barHeight)); + if (renderStyle === "SIGNAL") { + const normalizedValue = scaleTransform.normalize(bin.value ?? 0); + const barHeight = (laneInnerEnd - laneInnerStart) * normalizedValue; + const y = laneInnerEnd - barHeight; + ctx.fillRect(x0, y, x1 - x0, Math.max(1, barHeight)); + } else { + this.drawHorizontalFeatureBin( + ctx, + bin, + viewport, + laneInnerStart, + laneInnerEnd, + canvas.width, + x0, + x1, + isSelectedFeature + ); + } } else { const y0ByPx = Math.floor(viewport.pxToScreen(startPx)); - const y1ByPx = Math.max(y0ByPx + 1, Math.ceil(viewport.pxToScreen(endPx))); + const y1ByPx = Math.max( + y0ByPx + 1, + Math.ceil(viewport.pxToScreen(endPx)) + ); const y0 = Math.max(0, Math.min(canvas.height - 1, y0ByPx)); const y1 = Math.max(y0 + 1, Math.min(canvas.height, y1ByPx)); if (y1 <= y0 || y1ByPx === y0ByPx) { continue; } - const barWidth = (laneInnerEnd - laneInnerStart) * normalizedValue; - const x = laneInnerEnd - Math.max(1, barWidth); - ctx.fillRect(x, y0, Math.max(1, barWidth), y1 - y0); + if (renderStyle === "SIGNAL") { + const normalizedValue = scaleTransform.normalize(bin.value ?? 0); + const barWidth = (laneInnerEnd - laneInnerStart) * normalizedValue; + const x = laneInnerEnd - Math.max(1, barWidth); + ctx.fillRect(x, y0, Math.max(1, barWidth), y1 - y0); + } else { + this.drawVerticalFeatureBin( + ctx, + bin, + viewport, + laneInnerStart, + laneInnerEnd, + canvas.height, + y0, + y1, + isSelectedFeature + ); + } } } - ctx.fillStyle = "rgba(20,20,20,0.85)"; + ctx.fillStyle = textPalette.primary; ctx.font = "bold 11px sans-serif"; if (orientation === "horizontal") { - ctx.fillText(track.name, 6, laneStart + 4); + this.drawOutlinedText(ctx, track.name, 6, laneStart + 4, textPalette); if (track.error) { - ctx.fillStyle = "rgba(160, 30, 30, 0.88)"; + ctx.fillStyle = textPalette.error; ctx.font = "10px sans-serif"; - ctx.fillText(track.error, 6, laneStart + 20); + this.drawOutlinedText(ctx, track.error, 6, laneStart + 20, textPalette); } else if (track.bins.length === 0) { - ctx.fillStyle = "rgba(90,90,90,0.75)"; + ctx.fillStyle = textPalette.muted; ctx.font = "10px sans-serif"; - ctx.fillText(statusMessage ?? "No signal in current view", 6, laneStart + 20); + this.drawOutlinedText( + ctx, + statusMessage ?? "No signal in current view", + 6, + laneStart + 20, + textPalette + ); + } + if ((track.renderStyle ?? "SIGNAL").toUpperCase() !== "FEATURE") { + this.drawSignalScaleTicks( + ctx, + orientation, + laneStart, + laneEnd, + laneInnerStart, + laneInnerEnd, + scaleTransform, + canvas.width, + canvas.height, + textPalette + ); + } else { + this.drawFeatureLabels( + ctx, + orientation, + laneInnerStart, + laneInnerEnd, + viewport, + track.bins, + textPalette + ); } } else { ctx.save(); - ctx.translate(laneStart + 12, 6); - ctx.rotate(Math.PI / 2); - ctx.fillText(track.name, 0, 0); + ctx.translate(laneStart + 10, canvas.height - 4); + ctx.rotate(-Math.PI / 2); + this.drawOutlinedText(ctx, track.name, 0, 0, textPalette); ctx.restore(); if (track.error) { ctx.save(); - ctx.translate(laneStart + 24, 6); - ctx.rotate(Math.PI / 2); - ctx.fillStyle = "rgba(160, 30, 30, 0.88)"; + ctx.translate(laneStart + 22, canvas.height - 4); + ctx.rotate(-Math.PI / 2); + ctx.fillStyle = textPalette.error; ctx.font = "10px sans-serif"; - ctx.fillText(track.error, 0, 0); + this.drawOutlinedText(ctx, track.error, 0, 0, textPalette); ctx.restore(); } else if (track.bins.length === 0) { ctx.save(); - ctx.translate(laneStart + 24, 6); - ctx.rotate(Math.PI / 2); - ctx.fillStyle = "rgba(90,90,90,0.75)"; + ctx.translate(laneStart + 22, canvas.height - 4); + ctx.rotate(-Math.PI / 2); + ctx.fillStyle = textPalette.muted; ctx.font = "10px sans-serif"; - ctx.fillText(statusMessage ?? "No signal", 0, 0); + this.drawOutlinedText( + ctx, + statusMessage ?? "No signal", + 0, + 0, + textPalette + ); ctx.restore(); } + if ((track.renderStyle ?? "SIGNAL").toUpperCase() !== "FEATURE") { + this.drawSignalScaleTicks( + ctx, + orientation, + laneStart, + laneEnd, + laneInnerStart, + laneInnerEnd, + scaleTransform, + canvas.width, + canvas.height, + textPalette + ); + } else { + this.drawFeatureLabels( + ctx, + orientation, + laneInnerStart, + laneInnerEnd, + viewport, + track.bins, + textPalette + ); + } } + this.refreshFeatureSearchCache(track, track.bins); }); const hasAnySignal = tracks.some((track) => track.bins.length > 0); const firstError = tracks.find((track) => track.error)?.error; @@ -623,6 +1120,949 @@ class LinearTrackManager { }); } + private refreshFeatureSearchCache( + track: { + trackId: string; + name: string; + renderStyle?: string; + }, + bins: TrackBinResponse[] + ): void { + if ((track.renderStyle ?? "SIGNAL").toUpperCase() !== "FEATURE") { + return; + } + const now = Date.now(); + for (const bin of bins) { + const label = (bin.label ?? "").trim(); + if (!label) { + continue; + } + const startBp = Math.min(bin.startBp, bin.endBp); + const endBp = Math.max(bin.startBp, bin.endBp); + const featureType = (bin.featureType ?? "").trim(); + const key = `${track.trackId}:${startBp}:${endBp}:${label}:${featureType}`; + this.featureSearchCache.set(key, { + key, + trackId: track.trackId, + trackName: track.name, + label, + featureType: featureType.length > 0 ? featureType : null, + strand: bin.strand, + startBp, + endBp, + updatedAtMs: now, + }); + } + } + + private mapFeatureHitToSearchEntry( + hit: TrackFeatureSearchHitResponse, + updatedAtMs: number + ): FeatureSearchEntry { + const featureType = (hit.featureType ?? "").trim(); + const label = hit.label?.trim() || `${hit.sourceName}:${hit.startBp}-${hit.endBp}`; + const startBp = Math.min(hit.startBp, hit.endBp); + const endBp = Math.max(hit.startBp, hit.endBp); + const key = `${hit.trackId}:${startBp}:${endBp}:${label}:${featureType}`; + return { + key, + trackId: hit.trackId, + trackName: hit.trackName, + label, + featureType: featureType.length > 0 ? featureType : null, + strand: hit.strand, + startBp, + endBp, + updatedAtMs, + }; + } + + private drawFeatureLabels( + ctx: CanvasRenderingContext2D, + orientation: Orientation, + laneInnerStart: number, + laneInnerEnd: number, + viewport: ViewportGeometry, + bins: TrackBinResponse[], + textPalette: TrackTextPalette + ): void { + const minFeatureSpanPx = 22; + const minLabelGapPx = 6; + const maxLabelsPerLane = 32; + let drawnCount = 0; + let lastLabelEnd = Number.NEGATIVE_INFINITY; + ctx.fillStyle = textPalette.primary; + ctx.font = "10px sans-serif"; + ctx.textAlign = "left"; + for (const bin of bins) { + if (drawnCount >= maxLabelsPerLane) { + break; + } + const label = (bin.label ?? "").trim(); + if (!label) { + continue; + } + const interval = this.resolveBinIntervalPx(bin, viewport.bpResolution); + if (!interval.visible) { + continue; + } + if (interval.endPx <= viewport.startPx || interval.startPx >= viewport.endPx) { + continue; + } + const axisStart = Math.floor(viewport.pxToScreen(interval.startPx)); + const axisEnd = Math.max(axisStart + 1, Math.ceil(viewport.pxToScreen(interval.endPx))); + const axisSpan = axisEnd - axisStart; + if (axisSpan < minFeatureSpanPx) { + continue; + } + if (orientation === "horizontal") { + const textWidth = ctx.measureText(label).width; + if (textWidth > axisSpan - 4) { + continue; + } + const drawX = axisStart + 2; + if (drawX < lastLabelEnd + minLabelGapPx) { + continue; + } + this.drawOutlinedText(ctx, label, drawX, laneInnerStart + 14, textPalette); + lastLabelEnd = drawX + textWidth; + } else { + const textLength = ctx.measureText(label).width; + if (textLength > axisSpan - 4) { + continue; + } + const drawY = axisStart + 2; + if (drawY < lastLabelEnd + minLabelGapPx) { + continue; + } + ctx.save(); + ctx.translate(laneInnerStart + 10, drawY + textLength); + ctx.rotate(-Math.PI / 2); + this.drawOutlinedText(ctx, label, 0, 0, textPalette); + ctx.restore(); + lastLabelEnd = drawY + textLength; + } + drawnCount += 1; + } + } + + private drawHorizontalFeatureBin( + ctx: CanvasRenderingContext2D, + bin: TrackBinResponse, + viewport: ViewportGeometry, + laneInnerStart: number, + laneInnerEnd: number, + canvasWidth: number, + x0: number, + x1: number, + isSelectedFeature: boolean + ): void { + const laneCenter = (laneInnerStart + laneInnerEnd) / 2; + const laneHeight = Math.max(1, laneInnerEnd - laneInnerStart); + const connectorHeight = Math.max(1, Math.round(laneHeight * 0.12)); + const exonHeight = Math.max(2, Math.round(laneHeight * 0.34)); + const codingHeight = Math.max(exonHeight + 1, Math.round(laneHeight * 0.5)); + const connectorY = Math.floor(laneCenter - connectorHeight / 2); + const exonY = Math.floor(laneCenter - exonHeight / 2); + const codingY = Math.floor(laneCenter - codingHeight / 2); + const projectedBlocks = this.resolveFeatureBlocksIntervals( + bin, + viewport.bpResolution + ) + .filter( + (block) => + block.visible && + block.endPx > viewport.startPx && + block.startPx < viewport.endPx + ) + .map((block) => ({ + coding: block.coding, + x0: Math.max( + x0, + Math.min(canvasWidth - 1, Math.floor(viewport.pxToScreen(block.startPx))) + ), + x1: Math.max( + x0 + 1, + Math.min(canvasWidth, Math.ceil(viewport.pxToScreen(block.endPx))) + ), + })) + .filter((block) => block.x1 > block.x0); + if (projectedBlocks.length > 0) { + ctx.fillRect(x0, connectorY, Math.max(1, x1 - x0), connectorHeight); + for (const block of projectedBlocks) { + const blockY = block.coding ? codingY : exonY; + const blockHeight = block.coding ? codingHeight : exonHeight; + ctx.fillRect(block.x0, blockY, Math.max(1, block.x1 - block.x0), blockHeight); + } + } else { + const thinHeight = Math.max(1, Math.round(laneHeight * 0.16)); + const thickHeight = Math.max(thinHeight + 1, Math.round(laneHeight * 0.48)); + const thinY = Math.floor(laneCenter - thinHeight / 2); + const thickY = Math.floor(laneCenter - thickHeight / 2); + ctx.fillRect(x0, thinY, x1 - x0, thinHeight); + const hasThickPx = + typeof bin.thickStartPx === "number" && + Number.isFinite(bin.thickStartPx) && + typeof bin.thickEndPx === "number" && + Number.isFinite(bin.thickEndPx); + let thickX0 = x0; + let thickX1 = x1; + if (hasThickPx) { + const thickStartPx = Math.max( + 0, + Math.min(bin.thickStartPx ?? 0, bin.thickEndPx ?? 0) + ); + const thickEndPx = Math.max( + thickStartPx + 1, + Math.max(bin.thickStartPx ?? thickStartPx, bin.thickEndPx ?? thickStartPx) + ); + const thickX0ByPx = Math.floor(viewport.pxToScreen(thickStartPx)); + const thickX1ByPx = Math.max( + thickX0ByPx + 1, + Math.ceil(viewport.pxToScreen(thickEndPx)) + ); + thickX0 = Math.max(x0, Math.min(canvasWidth - 1, thickX0ByPx)); + thickX1 = Math.max(thickX0 + 1, Math.min(x1, thickX1ByPx)); + } + ctx.fillRect(thickX0, thickY, Math.max(1, thickX1 - thickX0), thickHeight); + } + this.drawFeatureDirectionArrowsHorizontal(ctx, bin.strand, x0, x1, laneCenter); + this.drawFeatureTerminalTriangleHorizontal( + ctx, + bin.strand, + x0, + x1, + laneCenter, + laneInnerStart, + laneInnerEnd + ); + if (isSelectedFeature) { + ctx.save(); + ctx.strokeStyle = "rgba(255, 218, 66, 0.98)"; + ctx.lineWidth = 2; + ctx.strokeRect( + x0 + 0.5, + laneInnerStart + 0.5, + Math.max(1, x1 - x0 - 1), + Math.max(1, laneInnerEnd - laneInnerStart - 1) + ); + ctx.restore(); + } + } + + private drawVerticalFeatureBin( + ctx: CanvasRenderingContext2D, + bin: TrackBinResponse, + viewport: ViewportGeometry, + laneInnerStart: number, + laneInnerEnd: number, + canvasHeight: number, + y0: number, + y1: number, + isSelectedFeature: boolean + ): void { + const laneCenter = (laneInnerStart + laneInnerEnd) / 2; + const laneWidth = Math.max(1, laneInnerEnd - laneInnerStart); + const connectorWidth = Math.max(1, Math.round(laneWidth * 0.12)); + const exonWidth = Math.max(2, Math.round(laneWidth * 0.34)); + const codingWidth = Math.max(exonWidth + 1, Math.round(laneWidth * 0.5)); + const connectorX = Math.floor(laneCenter - connectorWidth / 2); + const exonX = Math.floor(laneCenter - exonWidth / 2); + const codingX = Math.floor(laneCenter - codingWidth / 2); + const projectedBlocks = this.resolveFeatureBlocksIntervals( + bin, + viewport.bpResolution + ) + .filter( + (block) => + block.visible && + block.endPx > viewport.startPx && + block.startPx < viewport.endPx + ) + .map((block) => ({ + coding: block.coding, + y0: Math.max( + y0, + Math.min(canvasHeight - 1, Math.floor(viewport.pxToScreen(block.startPx))) + ), + y1: Math.max( + y0 + 1, + Math.min(canvasHeight, Math.ceil(viewport.pxToScreen(block.endPx))) + ), + })) + .filter((block) => block.y1 > block.y0); + if (projectedBlocks.length > 0) { + ctx.fillRect(connectorX, y0, connectorWidth, Math.max(1, y1 - y0)); + for (const block of projectedBlocks) { + const blockX = block.coding ? codingX : exonX; + const blockWidth = block.coding ? codingWidth : exonWidth; + ctx.fillRect(blockX, block.y0, blockWidth, Math.max(1, block.y1 - block.y0)); + } + } else { + const thinWidth = Math.max(1, Math.round(laneWidth * 0.16)); + const thickWidth = Math.max(thinWidth + 1, Math.round(laneWidth * 0.48)); + const thinX = Math.floor(laneCenter - thinWidth / 2); + const thickX = Math.floor(laneCenter - thickWidth / 2); + ctx.fillRect(thinX, y0, thinWidth, y1 - y0); + const hasThickPx = + typeof bin.thickStartPx === "number" && + Number.isFinite(bin.thickStartPx) && + typeof bin.thickEndPx === "number" && + Number.isFinite(bin.thickEndPx); + let thickY0 = y0; + let thickY1 = y1; + if (hasThickPx) { + const thickStartPx = Math.max( + 0, + Math.min(bin.thickStartPx ?? 0, bin.thickEndPx ?? 0) + ); + const thickEndPx = Math.max( + thickStartPx + 1, + Math.max(bin.thickStartPx ?? thickStartPx, bin.thickEndPx ?? thickStartPx) + ); + const thickY0ByPx = Math.floor(viewport.pxToScreen(thickStartPx)); + const thickY1ByPx = Math.max( + thickY0ByPx + 1, + Math.ceil(viewport.pxToScreen(thickEndPx)) + ); + thickY0 = Math.max(y0, Math.min(canvasHeight - 1, thickY0ByPx)); + thickY1 = Math.max(thickY0 + 1, Math.min(y1, thickY1ByPx)); + } + ctx.fillRect(thickX, thickY0, thickWidth, Math.max(1, thickY1 - thickY0)); + } + this.drawFeatureDirectionArrowsVertical(ctx, bin.strand, y0, y1, laneCenter); + this.drawFeatureTerminalTriangleVertical( + ctx, + bin.strand, + y0, + y1, + laneCenter, + laneInnerStart, + laneInnerEnd + ); + if (isSelectedFeature) { + ctx.save(); + ctx.strokeStyle = "rgba(255, 218, 66, 0.98)"; + ctx.lineWidth = 2; + ctx.strokeRect( + laneInnerStart + 0.5, + y0 + 0.5, + Math.max(1, laneInnerEnd - laneInnerStart - 1), + Math.max(1, y1 - y0 - 1) + ); + ctx.restore(); + } + } + + private drawFeatureDirectionArrowsHorizontal( + ctx: CanvasRenderingContext2D, + strand: string | null, + x0: number, + x1: number, + laneCenter: number + ): void { + if ((strand !== "+" && strand !== "-") || x1 - x0 <= 12) { + return; + } + const arrowSpacing = 16; + const arrowSize = 3; + const arrowY = Math.floor(laneCenter); + ctx.beginPath(); + if (strand === "+") { + for (let x = x0 + 6; x < x1 - 4; x += arrowSpacing) { + ctx.moveTo(x - arrowSize, arrowY - arrowSize); + ctx.lineTo(x + arrowSize, arrowY); + ctx.lineTo(x - arrowSize, arrowY + arrowSize); + } + } else { + for (let x = x1 - 6; x > x0 + 4; x -= arrowSpacing) { + ctx.moveTo(x + arrowSize, arrowY - arrowSize); + ctx.lineTo(x - arrowSize, arrowY); + ctx.lineTo(x + arrowSize, arrowY + arrowSize); + } + } + ctx.fill(); + } + + private drawFeatureDirectionArrowsVertical( + ctx: CanvasRenderingContext2D, + strand: string | null, + y0: number, + y1: number, + laneCenter: number + ): void { + if ((strand !== "+" && strand !== "-") || y1 - y0 <= 12) { + return; + } + const arrowSpacing = 16; + const arrowSize = 3; + const arrowX = Math.floor(laneCenter); + ctx.beginPath(); + if (strand === "+") { + for (let y = y0 + 6; y < y1 - 4; y += arrowSpacing) { + ctx.moveTo(arrowX - arrowSize, y - arrowSize); + ctx.lineTo(arrowX, y + arrowSize); + ctx.lineTo(arrowX + arrowSize, y - arrowSize); + } + } else { + for (let y = y1 - 6; y > y0 + 4; y -= arrowSpacing) { + ctx.moveTo(arrowX - arrowSize, y + arrowSize); + ctx.lineTo(arrowX, y - arrowSize); + ctx.lineTo(arrowX + arrowSize, y + arrowSize); + } + } + ctx.fill(); + } + + private drawFeatureTerminalTriangleHorizontal( + ctx: CanvasRenderingContext2D, + strand: string | null, + x0: number, + x1: number, + laneCenter: number, + laneInnerStart: number, + laneInnerEnd: number + ): void { + if ((strand !== "+" && strand !== "-") || x1 - x0 <= 8) { + return; + } + const triangleSize = Math.max(3, Math.round((laneInnerEnd - laneInnerStart) * 0.22)); + const y = Math.floor(laneCenter); + ctx.beginPath(); + if (strand === "+") { + const x = x1 - 1; + ctx.moveTo(x, y); + ctx.lineTo(x - triangleSize, y - triangleSize); + ctx.lineTo(x - triangleSize, y + triangleSize); + } else { + const x = x0 + 1; + ctx.moveTo(x, y); + ctx.lineTo(x + triangleSize, y - triangleSize); + ctx.lineTo(x + triangleSize, y + triangleSize); + } + ctx.closePath(); + ctx.fill(); + } + + private drawFeatureTerminalTriangleVertical( + ctx: CanvasRenderingContext2D, + strand: string | null, + y0: number, + y1: number, + laneCenter: number, + laneInnerStart: number, + laneInnerEnd: number + ): void { + if ((strand !== "+" && strand !== "-") || y1 - y0 <= 8) { + return; + } + const triangleSize = Math.max(3, Math.round((laneInnerEnd - laneInnerStart) * 0.22)); + const x = Math.floor(laneCenter); + ctx.beginPath(); + if (strand === "+") { + const y = y1 - 1; + ctx.moveTo(x, y); + ctx.lineTo(x - triangleSize, y - triangleSize); + ctx.lineTo(x + triangleSize, y - triangleSize); + } else { + const y = y0 + 1; + ctx.moveTo(x, y); + ctx.lineTo(x - triangleSize, y + triangleSize); + ctx.lineTo(x + triangleSize, y + triangleSize); + } + ctx.closePath(); + ctx.fill(); + } + + private sortFeatureBinsForDraw(bins: TrackBinResponse[]): TrackBinResponse[] { + if (bins.length <= 1) { + return bins; + } + return [...bins].sort((left, right) => { + const depthDiff = + this.resolveFeatureHierarchyDepth(left.featureType) - + this.resolveFeatureHierarchyDepth(right.featureType); + if (depthDiff !== 0) { + return depthDiff; + } + const leftSpan = Math.max(1, left.endBp - left.startBp); + const rightSpan = Math.max(1, right.endBp - right.startBp); + if (leftSpan !== rightSpan) { + return rightSpan - leftSpan; + } + if (left.startBp !== right.startBp) { + return left.startBp - right.startBp; + } + return left.endBp - right.endBp; + }); + } + + private resolveFeatureHierarchyDepth(featureType: string | null): number { + const normalized = (featureType ?? "").trim().toLowerCase(); + if (!normalized) { + return 1; + } + if (normalized === "gene" || normalized === "pseudogene") { + return 0; + } + if ( + normalized === "transcript" || + normalized === "mrna" || + normalized === "ncrna" || + normalized === "trna" || + normalized === "rrna" || + normalized === "snrna" || + normalized === "snorna" || + normalized === "lncrna" || + normalized === "mirna" || + normalized === "pirna" || + normalized === "guide_rna" || + normalized === "primary_transcript" || + normalized === "pseudogenic_transcript" + ) { + return 1; + } + if ( + normalized === "exon" || + normalized === "cds" || + normalized === "utr" || + normalized === "five_prime_utr" || + normalized === "three_prime_utr" || + normalized === "start_codon" || + normalized === "stop_codon" + ) { + return 2; + } + return 1; + } + + private resolveFeatureBlocksIntervals( + bin: TrackBinResponse, + bpResolution: number + ): FeatureBlockInterval[] { + if (!Array.isArray(bin.blocks) || bin.blocks.length === 0) { + return []; + } + return bin.blocks + .map((block) => this.resolveFeatureBlockIntervalPx(block, bpResolution)) + .filter((block) => block.endPx > block.startPx); + } + + private resolveFeatureBlockIntervalPx( + block: TrackBinBlockResponse, + bpResolution: number + ): FeatureBlockInterval { + const hasProjectedPx = + typeof block.startPx === "number" && + Number.isFinite(block.startPx) && + typeof block.endPx === "number" && + Number.isFinite(block.endPx); + const contigDimensionHolder = this.mapManager.getContigDimensionHolder(); + const startPx = hasProjectedPx + ? Math.max(0, Math.min(block.startPx ?? 0, block.endPx ?? 0)) + : contigDimensionHolder.getPxContainingBp( + Math.max(0, Math.min(block.startBp, block.endBp)), + bpResolution + ); + const endPx = hasProjectedPx + ? Math.max(startPx + 1, Math.max(block.startPx ?? startPx, block.endPx ?? startPx)) + : contigDimensionHolder.getPxContainingBp( + Math.max( + Math.max(0, Math.min(block.startBp, block.endBp)), + Math.max(0, Math.max(block.startBp, block.endBp) - 1) + ), + bpResolution + ) + 1; + if (hasProjectedPx) { + return { + startPx, + endPx, + visible: true, + coding: !!block.coding, + }; + } + const intervalStart = Math.max(0, Math.min(block.startBp, block.endBp)); + const intervalEnd = Math.max(intervalStart + 1, Math.max(block.startBp, block.endBp)); + const intervalProbeEnd = Math.max(intervalStart, intervalEnd - 1); + const visible = + contigDimensionHolder.isBpVisibleAtResolution(intervalStart, bpResolution) || + contigDimensionHolder.isBpVisibleAtResolution(intervalProbeEnd, bpResolution); + return { + startPx, + endPx, + visible, + coding: !!block.coding, + }; + } + + private getVisibleSignalMax( + bins: TrackBinResponse[], + viewport: ViewportGeometry, + bpResolution: number + ): number { + let maxValue = 0; + for (const bin of bins) { + const interval = this.resolveBinIntervalPx(bin, bpResolution); + if (!interval.visible) { + continue; + } + if (interval.endPx <= viewport.startPx || interval.startPx >= viewport.endPx) { + continue; + } + const value = Number.isFinite(bin.value) ? Math.max(0, bin.value) : 0; + if (value > maxValue) { + maxValue = value; + } + } + return maxValue; + } + + private resolveBinIntervalPx( + bin: TrackBinResponse, + bpResolution: number + ): { startPx: number; endPx: number; visible: boolean } { + const hasProjectedPx = + typeof bin.startPx === "number" && + Number.isFinite(bin.startPx) && + typeof bin.endPx === "number" && + Number.isFinite(bin.endPx); + const contigDimensionHolder = this.mapManager.getContigDimensionHolder(); + const startPx = hasProjectedPx + ? Math.max(0, Math.min(bin.startPx ?? 0, bin.endPx ?? 0)) + : contigDimensionHolder.getPxContainingBp( + Math.max(0, Math.min(bin.startBp, bin.endBp)), + bpResolution + ); + const endPx = hasProjectedPx + ? Math.max(startPx + 1, Math.max(bin.startPx ?? startPx, bin.endPx ?? startPx)) + : contigDimensionHolder.getPxContainingBp( + Math.max( + Math.max(0, Math.min(bin.startBp, bin.endBp)), + Math.max(0, Math.max(bin.startBp, bin.endBp) - 1) + ), + bpResolution + ) + 1; + if (hasProjectedPx) { + return { startPx, endPx, visible: true }; + } + const intervalStart = Math.max(0, Math.min(bin.startBp, bin.endBp)); + const intervalEnd = Math.max(intervalStart + 1, Math.max(bin.startBp, bin.endBp)); + const intervalProbeEnd = Math.max(intervalStart, intervalEnd - 1); + const visible = + contigDimensionHolder.isBpVisibleAtResolution(intervalStart, bpResolution) || + contigDimensionHolder.isBpVisibleAtResolution(intervalProbeEnd, bpResolution); + return { startPx, endPx, visible }; + } + + private formatScaleValue(value: number): string { + if (!Number.isFinite(value) || value <= 0) { + return "0"; + } + if (value >= 1000 || value < 0.01) { + return value.toExponential(2); + } + if (value >= 10) { + return value.toFixed(1); + } + return value.toFixed(3); + } + + private resolveTrackBackgroundColor(): string { + const uiSettingsStore = useUiSettingsStore(); + if (uiSettingsStore.inheritTrackBackgroundFromMap) { + return useStyleStore().mapBackgroundColor.RGB; + } + return uiSettingsStore.trackBackgroundColor || "rgba(244,247,251,0.98)"; + } + + private buildScaleTransform( + maxValue: number, + logScale: boolean, + logBase: number + ): SignalScaleTransform { + const safeMax = + Number.isFinite(maxValue) && maxValue > 0 ? maxValue : 1; + if (!logScale) { + return { + logScale: false, + maxValue: safeMax, + logBase: 10, + display: (value: number) => (Number.isFinite(value) ? Math.max(0, value) : 0), + normalize: (value: number) => + Math.max(0, Math.min(1, (Number.isFinite(value) ? value : 0) / safeMax)), + }; + } + const safeBase = Number.isFinite(logBase) && logBase > 1 ? logBase : 10; + const logDenominator = Math.log(safeBase); + const toLog = (value: number): number => Math.log1p(Math.max(0, value)) / logDenominator; + const maxLog = toLog(safeMax); + const safeMaxLog = Number.isFinite(maxLog) && maxLog > 0 ? maxLog : 1; + return { + logScale: true, + maxValue: safeMax, + logBase: safeBase, + display: (value: number) => { + if (!Number.isFinite(value) || value <= 0) { + return 0; + } + return toLog(value); + }, + normalize: (value: number) => { + if (!Number.isFinite(value) || value <= 0) { + return 0; + } + return Math.max(0, Math.min(1, toLog(value) / safeMaxLog)); + }, + }; + } + + private drawSignalScaleTicks( + ctx: CanvasRenderingContext2D, + orientation: Orientation, + laneStart: number, + laneEnd: number, + laneInnerStart: number, + laneInnerEnd: number, + scale: SignalScaleTransform, + canvasWidth: number, + canvasHeight: number, + textPalette: TrackTextPalette + ): void { + const axisSpan = Math.max(1, laneInnerEnd - laneInnerStart); + const ticks = this.buildScaleTicks(scale, axisSpan); + if (ticks.length === 0) { + return; + } + ctx.fillStyle = textPalette.axis; + ctx.strokeStyle = textPalette.axisStroke; + ctx.font = "9px monospace"; + let lastLabelCoord = Number.NEGATIVE_INFINITY; + const minLabelSpacing = 14; + if (orientation === "horizontal") { + ctx.textAlign = "left"; + const tickX0 = canvasWidth - 22; + const tickX1 = canvasWidth - 15; + const labelX = canvasWidth - 13; + for (const tick of ticks) { + const normalized = scale.normalize(tick); + const y = laneInnerEnd - normalized * axisSpan; + const iy = Math.max(laneStart + 4, Math.min(laneEnd - 12, y)); + if (Math.abs(iy - lastLabelCoord) < minLabelSpacing) { + continue; + } + lastLabelCoord = iy; + ctx.beginPath(); + ctx.moveTo(tickX0, iy + 3.5); + ctx.lineTo(tickX1, iy + 3.5); + ctx.stroke(); + this.drawOutlinedText( + ctx, + this.formatScaleValue(scale.display(tick)), + labelX, + iy - 2, + textPalette + ); + } + if (scale.logScale) { + ctx.fillStyle = textPalette.axis; + ctx.font = "8px monospace"; + ctx.textAlign = "left"; + this.drawOutlinedText( + ctx, + `log${this.formatScaleLogBase(scale.logBase)}`, + 2, + Math.max(laneInnerStart, laneEnd - 12), + textPalette + ); + } + ctx.textAlign = "left"; + return; + } + const labelAxisY = Math.max(1, laneStart + 2); + const markY0 = labelAxisY + 16; + const markY1 = markY0 + 6; + ctx.textAlign = "center"; + for (const tick of ticks) { + const normalized = scale.normalize(tick); + const x = laneInnerEnd - normalized * axisSpan; + const ix = Math.max(laneStart + 3, Math.min(laneEnd - 14, x)); + if (Math.abs(ix - lastLabelCoord) < minLabelSpacing) { + continue; + } + lastLabelCoord = ix; + ctx.beginPath(); + ctx.moveTo(ix + 3.5, markY0); + ctx.lineTo(ix + 3.5, markY1); + ctx.stroke(); + ctx.save(); + ctx.translate(ix + 9, labelAxisY + 1); + ctx.rotate(-Math.PI / 2); + ctx.textAlign = "left"; + this.drawOutlinedText( + ctx, + this.formatScaleValue(scale.display(tick)), + 0, + 0, + textPalette + ); + ctx.restore(); + } + if (scale.logScale) { + ctx.save(); + ctx.fillStyle = textPalette.axis; + ctx.translate(Math.max(laneStart + 2, laneEnd - 4), Math.max(14, canvasHeight - 6)); + ctx.rotate(-Math.PI / 2); + ctx.textAlign = "left"; + this.drawOutlinedText( + ctx, + `log${this.formatScaleLogBase(scale.logBase)}`, + 0, + 0, + textPalette + ); + ctx.restore(); + } + } + + private formatScaleLogBase(base: number): string { + if (!Number.isFinite(base) || base <= 1) { + return "10"; + } + if (Math.abs(base - Math.round(base)) < 1e-9) { + return String(Math.round(base)); + } + return Number(base.toFixed(3)).toString(); + } + + private drawOutlinedText( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + palette: TrackTextPalette + ): void { + const previousLineWidth = ctx.lineWidth; + const previousStrokeStyle = ctx.strokeStyle; + ctx.lineWidth = 3; + ctx.strokeStyle = palette.outline; + ctx.strokeText(text, x, y); + ctx.fillText(text, x, y); + ctx.lineWidth = previousLineWidth; + ctx.strokeStyle = previousStrokeStyle; + } + + private resolveTrackTextPalette(): TrackTextPalette { + const background = this.resolveTrackBackgroundColor(); + const dark = this.isDarkColor(background); + return dark + ? { + primary: "rgba(245,248,252,0.96)", + muted: "rgba(220,225,235,0.84)", + error: "rgba(255,142,142,0.98)", + axis: "rgba(238,244,250,0.92)", + axisStroke: "rgba(235,241,250,0.66)", + outline: "rgba(8,10,14,0.95)", + } + : { + primary: "rgba(20,20,20,0.9)", + muted: "rgba(80,86,96,0.84)", + error: "rgba(165,28,28,0.96)", + axis: "rgba(26,35,47,0.86)", + axisStroke: "rgba(26,35,47,0.56)", + outline: "rgba(255,255,255,0.95)", + }; + } + + private isDarkColor(color: string): boolean { + const normalized = color.trim().toLowerCase(); + const rgbMatch = + normalized.match( + /^rgba?\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)(?:\s*,\s*([0-9.]+))?\s*\)$/ + ) ?? null; + if (rgbMatch) { + const r = Number(rgbMatch[1]); + const g = Number(rgbMatch[2]); + const b = Number(rgbMatch[3]); + return this.relativeLuminance(r, g, b) < 0.45; + } + const hexMatch = normalized.match(/^#([0-9a-f]{3,8})$/i); + if (hexMatch) { + const hex = hexMatch[1]; + if (hex.length === 3 || hex.length === 4) { + const r = Number.parseInt(hex[0] + hex[0], 16); + const g = Number.parseInt(hex[1] + hex[1], 16); + const b = Number.parseInt(hex[2] + hex[2], 16); + return this.relativeLuminance(r, g, b) < 0.45; + } + if (hex.length === 6 || hex.length === 8) { + const r = Number.parseInt(hex.slice(0, 2), 16); + const g = Number.parseInt(hex.slice(2, 4), 16); + const b = Number.parseInt(hex.slice(4, 6), 16); + return this.relativeLuminance(r, g, b) < 0.45; + } + } + return useStyleStore().mapBackgroundColor.L <= 55; + } + + private relativeLuminance(r: number, g: number, b: number): number { + const normalize = (channel: number): number => { + const srgb = Math.max(0, Math.min(255, channel)) / 255; + return srgb <= 0.04045 ? srgb / 12.92 : Math.pow((srgb + 0.055) / 1.055, 2.4); + }; + const nr = normalize(r); + const ng = normalize(g); + const nb = normalize(b); + return 0.2126 * nr + 0.7152 * ng + 0.0722 * nb; + } + + private buildScaleTicks( + scale: SignalScaleTransform, + pixelSpan: number + ): number[] { + const maxTickCount = Math.max(2, Math.min(7, Math.floor(pixelSpan / 16))); + const maxValue = Math.max(scale.maxValue, 0); + if (!Number.isFinite(maxValue) || maxValue <= 0) { + return [0]; + } + if (!scale.logScale) { + const step = this.niceStep(maxValue / Math.max(1, maxTickCount - 1)); + const ticks: number[] = [0]; + for (let value = step; value < maxValue; value += step) { + ticks.push(value); + } + ticks.push(maxValue); + return this.uniqueSortedTicks(ticks); + } + const safeBase = + Number.isFinite(scale.logBase) && scale.logBase > 1 ? scale.logBase : 10; + const maxLog = Math.log1p(maxValue) / Math.log(safeBase); + const stepLog = maxLog / Math.max(1, maxTickCount - 1); + const ticks: number[] = []; + for (let i = 0; i < maxTickCount; i++) { + ticks.push(Math.max(0, Math.pow(safeBase, i * stepLog) - 1)); + } + ticks.push(maxValue); + return this.uniqueSortedTicks(ticks); + } + + private uniqueSortedTicks(values: number[]): number[] { + return [...new Set(values.map((value) => Number(value.toFixed(6))))] + .filter((value) => Number.isFinite(value)) + .sort((a, b) => a - b); + } + + private niceStep(value: number): number { + if (!Number.isFinite(value) || value <= 0) { + return 1; + } + const exponent = Math.floor(Math.log10(value)); + const base = Math.pow(10, exponent); + const normalized = value / base; + const multiplier = + normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10; + return multiplier * base; + } + private getViewportGeometry(orientation: Orientation): ViewportGeometry { const descriptor = this.mapManager.getLayersManager().currentViewState.resolutionDesciptor; @@ -879,6 +2319,49 @@ class LinearTrackManager { visibleWidthPx: viewport.visibleWidthPx, }; } + + private matchesSelectedFeature( + trackId: string, + bin: TrackBinResponse + ): boolean { + const selected = this.selectedFeature; + if (!selected) { + return false; + } + if (selected.trackId.trim().length > 0 && selected.trackId !== trackId) { + return false; + } + const startBp = Math.min(bin.startBp, bin.endBp); + const endBp = Math.max(bin.startBp, bin.endBp); + if (startBp !== selected.startBp || endBp !== selected.endBp) { + return false; + } + const label = (bin.label ?? "").trim(); + if (selected.label.length > 0 && label !== selected.label) { + return false; + } + const featureType = (bin.featureType ?? "").trim(); + if (selected.featureType && featureType !== selected.featureType) { + return false; + } + return true; + } + + private isSameSelectedFeature( + current: SelectedTrackFeature | null, + next: SelectedTrackFeature + ): boolean { + if (!current) { + return false; + } + return ( + current.trackId === next.trackId && + current.startBp === next.startBp && + current.endBp === next.endBp && + current.label === next.label && + current.featureType === next.featureType + ); + } } export { LinearTrackManager, type Orientation }; @@ -912,3 +2395,49 @@ type TrackQueryCache = { fetchedAtMs: number; response: TrackQueryResponse; }; + +type SignalScaleTransform = { + logScale: boolean; + maxValue: number; + logBase: number; + display: (value: number) => number; + normalize: (value: number) => number; +}; + +type FeatureSearchEntry = { + key: string; + trackId: string; + trackName: string; + label: string; + featureType: string | null; + strand: string | null; + startBp: number; + endBp: number; + updatedAtMs: number; +}; + +type FeatureHoverInfo = { + trackId: string; + trackName: string; + label: string | null; + featureType: string | null; + strand: string | null; + startBp: number; + endBp: number; + value: number; +}; + +type SelectedTrackFeature = { + trackId: string; + label: string; + featureType: string | null; + startBp: number; + endBp: number; +}; + +type FeatureBlockInterval = { + startPx: number; + endPx: number; + visible: boolean; + coding: boolean; +}; diff --git a/src/app/core/mapmanagers/VisualizationManager.ts b/src/app/core/mapmanagers/VisualizationManager.ts index 9ee99d0..ad8e230 100644 --- a/src/app/core/mapmanagers/VisualizationManager.ts +++ b/src/app/core/mapmanagers/VisualizationManager.ts @@ -28,6 +28,8 @@ import { useVisualizationOptionsStore } from "@/app/stores/visualizationOptionsS import VisualizationOptions from "../visualization/VisualizationOptions"; class VisualizationManager { + public static readonly VISUALIZATION_OPTIONS_UPDATED_EVENT = + "hict:visualization-options-updated"; public readonly visualizationOptionsStore = useVisualizationOptionsStore(); public constructor(public readonly mapManager: ContactMapManager) {} @@ -36,6 +38,11 @@ class VisualizationManager { .getVisualizationOptions(new GetVisualizationOptionsRequest({})) .then((options) => { this.visualizationOptionsStore.setVisualizationOptions(options); + window.dispatchEvent( + new CustomEvent(VisualizationManager.VISUALIZATION_OPTIONS_UPDATED_EVENT, { + detail: { source: "server_fetch", options }, + }) + ); return options; }); } @@ -49,6 +56,11 @@ class VisualizationManager { ) .then((options) => { this.visualizationOptionsStore.setVisualizationOptions(options); + window.dispatchEvent( + new CustomEvent(VisualizationManager.VISUALIZATION_OPTIONS_UPDATED_EVENT, { + detail: { source: "server", options }, + }) + ); return options; }); } diff --git a/src/app/core/net/api/RequestManager.ts b/src/app/core/net/api/RequestManager.ts index 3106b37..cc5e8f1 100644 --- a/src/app/core/net/api/RequestManager.ts +++ b/src/app/core/net/api/RequestManager.ts @@ -37,6 +37,10 @@ import { FastaLinkResponseDTO, NameMappingResponseDTO, TracksPrecomputeStatusResponseDTO, + TrackCompatibilityReportResponseDTO, + FileEntryResponseDTO, + TrackFeatureContextResponseDTO, + TrackFeatureSearchResponseDTO, TrackQueryResponseDTO, TrackSummaryResponseDTO, TilePOSTResponseDTO, @@ -53,6 +57,7 @@ import { LinkFASTARequest, ListAGPFilesRequest, ListCoolerFilesRequest, + ListFilesDetailedRequest, ListFASTAFilesRequest, ListFilesRequest, LoadAGPRequest, @@ -79,21 +84,37 @@ import { AttachSessionRequest, CloseFileRequest, OpenProgressRequest, + OpenSecondarySourceRequest, + CloseSecondarySourceRequest, + GetSecondarySourceStatusRequest, + SetAssemblyInfoSourceRequest, ListTrackFilesRequest, OpenTrackRequest, + OpenCoolerWeightsTrackRequest, + ProbeTrackCompatibilityRequest, ListTracksRequest, UpdateTrackRequest, RemoveTrackRequest, + ReorderTrackRequest, QueryTracks1DRequest, + SearchTrackFeaturesRequest, + GetTrackFeatureContextRequest, StartTracksPrecomputeRequest, GetTracksPrecomputeStatusRequest, GetWorkerDiagnosticsRequest, + GetRenderPipelineRequest, + SetRenderPipelineRequest, + ResetRenderPipelineRequest, } from "./request"; import { ConversionJobResponse, CurrentSignalRangeResponse, FastaLinkResponse, + FileEntryResponse, NameMappingResponse, + TrackCompatibilityReportResponse, + TrackFeatureContextResponse, + TrackFeatureSearchResponse, TracksPrecomputeStatusResponse, TrackQueryResponse, TrackSummaryResponse, @@ -103,6 +124,27 @@ import { toast } from "vue-sonner"; import { useErrorToastStore } from "@/app/stores/errorToastStore"; import VisualizationOptions from "../../visualization/VisualizationOptions"; +export type SecondarySourceCompatibility = { + sameResolutions: boolean; + sameMatrixSizes: boolean; + exactMatch: boolean; + primaryMaxBins: number; + secondaryMaxBins: number; + primaryBinsByResolution: number[]; + secondaryBinsByResolution: number[]; + mismatchedResolutionOrders: number[]; +}; + +export type SecondarySourceStatusResponse = { + attached: boolean; + filename: string; + assemblySource: "PRIMARY" | "SECONDARY"; + requiresConfirmation: boolean; + requestedFilename?: string; + warnings: string[]; + compatibility?: SecondarySourceCompatibility; +}; + class RequestManager { constructor(public readonly networkManager: NetworkManager) {} @@ -111,6 +153,55 @@ class RequestManager { return assemblyInfo ?? json; } + private parseSecondarySourceStatus(json: Record): SecondarySourceStatusResponse { + if (typeof json.error === "string" && json.error.trim().length > 0) { + throw new Error(json.error); + } + const compatibilityRaw = + (json.compatibility as Record | undefined) ?? undefined; + const compatibility: SecondarySourceCompatibility | undefined = compatibilityRaw + ? { + sameResolutions: Boolean(compatibilityRaw.sameResolutions ?? false), + sameMatrixSizes: Boolean(compatibilityRaw.sameMatrixSizes ?? false), + exactMatch: Boolean(compatibilityRaw.exactMatch ?? false), + primaryMaxBins: Number(compatibilityRaw.primaryMaxBins ?? 0), + secondaryMaxBins: Number(compatibilityRaw.secondaryMaxBins ?? 0), + primaryBinsByResolution: Array.isArray(compatibilityRaw.primaryBinsByResolution) + ? (compatibilityRaw.primaryBinsByResolution as unknown[]).map((value) => + Number(value ?? 0) + ) + : [], + secondaryBinsByResolution: Array.isArray(compatibilityRaw.secondaryBinsByResolution) + ? (compatibilityRaw.secondaryBinsByResolution as unknown[]).map((value) => + Number(value ?? 0) + ) + : [], + mismatchedResolutionOrders: Array.isArray(compatibilityRaw.mismatchedResolutionOrders) + ? (compatibilityRaw.mismatchedResolutionOrders as unknown[]).map((value) => + Number(value ?? 0) + ) + : [], + } + : undefined; + return { + attached: Boolean(json.attached ?? false), + filename: String(json.filename ?? ""), + assemblySource: + String(json.assemblySource ?? "PRIMARY").toUpperCase() === "SECONDARY" + ? "SECONDARY" + : "PRIMARY", + requiresConfirmation: Boolean(json.requiresConfirmation ?? false), + requestedFilename: + typeof json.requestedFilename === "string" + ? json.requestedFilename + : undefined, + warnings: Array.isArray(json.warnings) + ? (json.warnings as unknown[]).map((value) => String(value)) + : [], + compatibility, + }; + } + public async sendRequest( request: HiCTAPIRequest, axiosConfig?: AxiosRequestConfig | undefined @@ -196,6 +287,46 @@ class RequestManager { }); } + public async getSecondarySourceStatus(): Promise { + return this.sendRequest(new GetSecondarySourceStatusRequest()) + .then((response) => response.data as Record) + .then((json) => this.parseSecondarySourceStatus(json)); + } + + public async openSecondarySource( + filename: string, + allowMismatch = false + ): Promise { + return this.sendRequest(new OpenSecondarySourceRequest({ filename, allowMismatch })) + .then((response) => response.data as Record) + .then((json) => this.parseSecondarySourceStatus(json)); + } + + public async closeSecondarySource(): Promise { + return this.sendRequest(new CloseSecondarySourceRequest()) + .then((response) => response.data as Record) + .then((json) => this.parseSecondarySourceStatus(json)); + } + + public async setAssemblyInfoSource( + assemblySource: "PRIMARY" | "SECONDARY" + ): Promise<{ + assemblySource: "PRIMARY" | "SECONDARY"; + assemblyInfo: AssemblyInfo; + }> { + return this.sendRequest(new SetAssemblyInfoSourceRequest({ assemblySource })) + .then((response) => response.data as Record) + .then((json) => ({ + assemblySource: + String(json.assemblySource ?? "PRIMARY").toUpperCase() === "SECONDARY" + ? "SECONDARY" + : "PRIMARY", + assemblyInfo: new AssemblyInfoDTO( + (json.assemblyInfo ?? {}) as Record + ).toEntity(), + })); + } + public async closeFile(): Promise { await this.sendRequest(new CloseFileRequest()); } @@ -231,6 +362,12 @@ class RequestManager { return response.data as string[]; } + public async listFilesDetailed(): Promise { + return this.sendRequest(new ListFilesDetailedRequest()) + .then((response) => response.data as Record[]) + .then((items) => items.map((item) => new FileEntryResponseDTO(item).toEntity())); + } + public async listCoolers(): Promise { const response = await this.sendRequest(new ListCoolerFilesRequest()); return response.data as string[]; @@ -251,6 +388,23 @@ class RequestManager { .then((json) => new TrackSummaryResponseDTO(json).toEntity()); } + public async openCoolerWeightsTrack( + name?: string, + color?: string + ): Promise { + return this.sendRequest(new OpenCoolerWeightsTrackRequest({ name, color })) + .then((response) => response.data) + .then((json) => new TrackSummaryResponseDTO(json).toEntity()); + } + + public async probeTrackCompatibility( + filename: string + ): Promise { + return this.sendRequest(new ProbeTrackCompatibilityRequest({ filename })) + .then((response) => response.data) + .then((json) => new TrackCompatibilityReportResponseDTO(json).toEntity()); + } + public async listTracks(): Promise { return this.sendRequest(new ListTracksRequest()) .then((response) => response.data as Record[]) @@ -265,6 +419,7 @@ class RequestManager { name?: string; renderMode?: string; aggregationMode?: string; + logScale?: boolean; } ): Promise { return this.sendRequest( @@ -275,6 +430,7 @@ class RequestManager { name: options.name, renderMode: options.renderMode, aggregationMode: options.aggregationMode, + logScale: options.logScale, }) ) .then((response) => response.data) @@ -285,17 +441,131 @@ class RequestManager { await this.sendRequest(new RemoveTrackRequest({ trackId })); } + public async reorderTrack( + trackId: string, + targetIndex: number + ): Promise { + return this.sendRequest(new ReorderTrackRequest({ trackId, targetIndex })) + .then((response) => response.data as Record[]) + .then((items) => + items.map((item) => new TrackSummaryResponseDTO(item).toEntity()) + ); + } + public async queryTracks1D( startPx: number, endPx: number, widthPx: number, bpResolution: number ): Promise { - return this.sendRequest(new QueryTracks1DRequest({ startPx, endPx, widthPx, bpResolution })) + return this.sendRequest( + new QueryTracks1DRequest({ + unit: "PIXELS", + startPx, + endPx, + widthPx, + bpResolution, + }) + ) + .then((response) => response.data) + .then((json) => new TrackQueryResponseDTO(json).toEntity()); + } + + public async queryTracks1DByUnits(options: { + unit: "PIXELS" | "BINS" | "BP"; + start: number; + end: number; + widthPx: number; + bpResolution: number; + }): Promise { + const payload: { + unit: "PIXELS" | "BINS" | "BP"; + widthPx: number; + bpResolution: number; + startPx?: number; + endPx?: number; + startBin?: number; + endBin?: number; + startBP?: number; + endBP?: number; + } = { + unit: options.unit, + widthPx: options.widthPx, + bpResolution: options.bpResolution, + }; + if (options.unit === "PIXELS") { + payload.startPx = options.start; + payload.endPx = options.end; + } else if (options.unit === "BINS") { + payload.startBin = options.start; + payload.endBin = options.end; + } else { + payload.startBP = options.start; + payload.endBP = options.end; + } + return this.sendRequest(new QueryTracks1DRequest(payload)) .then((response) => response.data) .then((json) => new TrackQueryResponseDTO(json).toEntity()); } + public async searchTrackFeatures(options: { + query: string; + limit?: number; + offset?: number; + trackId?: string; + }): Promise { + return this.sendRequest( + new SearchTrackFeaturesRequest({ + query: options.query, + limit: options.limit, + offset: options.offset, + trackId: options.trackId, + }) + ) + .then((response) => response.data) + .then((json) => new TrackFeatureSearchResponseDTO(json).toEntity()); + } + + public async getTrackFeatureContext(options: { + unit: "PIXELS" | "BINS" | "BP"; + start: number; + end: number; + widthPx: number; + bpResolution: number; + marginScreens?: number; + }): Promise { + const payload: { + unit: "PIXELS" | "BINS" | "BP"; + widthPx: number; + bpResolution: number; + marginScreens?: number; + startPx?: number; + endPx?: number; + startBin?: number; + endBin?: number; + startBP?: number; + endBP?: number; + } = { + unit: options.unit, + widthPx: options.widthPx, + bpResolution: options.bpResolution, + marginScreens: options.marginScreens, + }; + if (options.unit === "PIXELS") { + payload.startPx = options.start; + payload.endPx = options.end; + } else if (options.unit === "BINS") { + payload.startBin = options.start; + payload.endBin = options.end; + } else { + payload.startBP = options.start; + payload.endBP = options.end; + } + return this.sendRequest(new GetTrackFeatureContextRequest(payload)) + .then((response) => response.data) + .then((json) => new TrackFeatureContextResponseDTO(json).toEntity()); + } + public async startTracksPrecompute( trackId?: string, force = false @@ -317,6 +587,23 @@ class RequestManager { .then((json) => new WorkerSchedulerDiagnosticsResponseDTO(json).toEntity()); } + public async getRenderPipelineConfig(): Promise> { + return this.sendRequest(new GetRenderPipelineRequest()) + .then((response) => response.data as Record); + } + + public async setRenderPipelineConfig( + config: Record + ): Promise> { + return this.sendRequest(new SetRenderPipelineRequest(config)) + .then((response) => response.data as Record); + } + + public async resetRenderPipelineConfig(): Promise> { + return this.sendRequest(new ResetRenderPipelineRequest()) + .then((response) => response.data as Record); + } + public async listFASTAFiles(): Promise { const response = await this.sendRequest(new ListFASTAFilesRequest()); return response.data as string[]; diff --git a/src/app/core/net/api/request.ts b/src/app/core/net/api/request.ts index ee04fc6..696b7e6 100644 --- a/src/app/core/net/api/request.ts +++ b/src/app/core/net/api/request.ts @@ -57,6 +57,10 @@ class ListFilesRequest implements HiCTAPIRequest { requestPath = "/list_files"; } +class ListFilesDetailedRequest implements HiCTAPIRequest { + requestPath = "/list_files_detailed"; +} + class ListFASTAFilesRequest implements HiCTAPIRequest { requestPath = "/list_fasta_files"; } @@ -156,6 +160,7 @@ class StartConversionJobRequest implements HiCTAPIRequest { public readonly options: { readonly filename: string; readonly direction?: string; + readonly overwrite?: boolean; readonly resolutions?: string; readonly compression?: number; readonly compressionAlgorithm?: string; @@ -173,6 +178,7 @@ class StartBatchConversionJobsRequest implements HiCTAPIRequest { readonly files: string[]; readonly parallelJobs: number; readonly parallelism: number; + readonly overwrite?: boolean; readonly resolutions?: string; readonly compression?: number; readonly compressionAlgorithm?: string; @@ -318,10 +324,55 @@ class OpenProgressRequest implements HiCTAPIRequest { public constructor() {} } +class OpenSecondarySourceRequest implements HiCTAPIRequest { + requestPath = "/secondary/open"; + + public constructor( + public readonly options: { + readonly filename: string; + readonly allowMismatch?: boolean; + } + ) {} +} + +class CloseSecondarySourceRequest implements HiCTAPIRequest { + requestPath = "/secondary/close"; +} + +class GetSecondarySourceStatusRequest implements HiCTAPIRequest { + requestPath = "/secondary/status"; +} + +class SetAssemblyInfoSourceRequest implements HiCTAPIRequest { + requestPath = "/secondary/set_assembly_source"; + + public constructor( + public readonly options: { + readonly assemblySource: "PRIMARY" | "SECONDARY"; + } + ) {} +} + class GetWorkerDiagnosticsRequest implements HiCTAPIRequest { requestPath = "/diagnostics/workers"; } +class GetRenderPipelineRequest implements HiCTAPIRequest { + requestPath = "/render_pipeline/get"; +} + +class SetRenderPipelineRequest implements HiCTAPIRequest { + requestPath = "/render_pipeline/set"; + + public constructor( + public readonly options: Record + ) {} +} + +class ResetRenderPipelineRequest implements HiCTAPIRequest { + requestPath = "/render_pipeline/reset"; +} + class GetVisualizationOptionsRequest implements HiCTAPIRequest { requestPath = "/get_visualization_options"; @@ -350,6 +401,27 @@ class OpenTrackRequest implements HiCTAPIRequest { ) {} } +class OpenCoolerWeightsTrackRequest implements HiCTAPIRequest { + requestPath = "/tracks/open_cooler_weights"; + + public constructor( + public readonly options: { + readonly name?: string; + readonly color?: string; + } = {} + ) {} +} + +class ProbeTrackCompatibilityRequest implements HiCTAPIRequest { + requestPath = "/tracks/probe"; + + public constructor( + public readonly options: { + readonly filename: string; + } + ) {} +} + class ListTracksRequest implements HiCTAPIRequest { requestPath = "/tracks/list"; } @@ -365,6 +437,7 @@ class UpdateTrackRequest implements HiCTAPIRequest { readonly name?: string; readonly renderMode?: string; readonly aggregationMode?: string; + readonly logScale?: boolean; } ) {} } @@ -379,15 +452,63 @@ class RemoveTrackRequest implements HiCTAPIRequest { ) {} } +class ReorderTrackRequest implements HiCTAPIRequest { + requestPath = "/tracks/reorder"; + + public constructor( + public readonly options: { + readonly trackId: string; + readonly targetIndex: number; + } + ) {} +} + class QueryTracks1DRequest implements HiCTAPIRequest { requestPath = "/tracks/query_1d"; public constructor( public readonly options: { - readonly startPx: number; - readonly endPx: number; + readonly startPx?: number; + readonly endPx?: number; + readonly startBin?: number; + readonly endBin?: number; + readonly startBP?: number; + readonly endBP?: number; + readonly unit?: "PIXELS" | "BINS" | "BP"; + readonly widthPx: number; + readonly bpResolution: number; + } + ) {} +} + +class SearchTrackFeaturesRequest implements HiCTAPIRequest { + requestPath = "/tracks/search_features"; + + public constructor( + public readonly options: { + readonly query: string; + readonly limit?: number; + readonly offset?: number; + readonly trackId?: string; + } + ) {} +} + +class GetTrackFeatureContextRequest implements HiCTAPIRequest { + requestPath = "/tracks/feature_context"; + + public constructor( + public readonly options: { + readonly startPx?: number; + readonly endPx?: number; + readonly startBin?: number; + readonly endBin?: number; + readonly startBP?: number; + readonly endBP?: number; + readonly unit?: "PIXELS" | "BINS" | "BP"; readonly widthPx: number; readonly bpResolution: number; + readonly marginScreens?: number; } ) {} } @@ -439,6 +560,7 @@ export { GetAGPForAssemblyRequest, OpenFileRequest, ListFilesRequest, + ListFilesDetailedRequest, GroupContigsIntoScaffoldRequest, UngroupContigsFromScaffoldRequest, ReverseSelectionRangeRequest, @@ -448,6 +570,10 @@ export { ListAGPFilesRequest, LoadAGPRequest, OpenProgressRequest, + OpenSecondarySourceRequest, + CloseSecondarySourceRequest, + GetSecondarySourceStatusRequest, + SetAssemblyInfoSourceRequest, GetFastaForSelectionRequest, SetNormalizationRequest, SetContrastRangeRequest, @@ -460,11 +586,19 @@ export { SetVisualizationOptionsRequest, ListTrackFilesRequest, OpenTrackRequest, + OpenCoolerWeightsTrackRequest, + ProbeTrackCompatibilityRequest, ListTracksRequest, UpdateTrackRequest, RemoveTrackRequest, + ReorderTrackRequest, QueryTracks1DRequest, + SearchTrackFeaturesRequest, + GetTrackFeatureContextRequest, StartTracksPrecomputeRequest, GetTracksPrecomputeStatusRequest, GetWorkerDiagnosticsRequest, + GetRenderPipelineRequest, + SetRenderPipelineRequest, + ResetRenderPipelineRequest, }; diff --git a/src/app/core/net/api/response.ts b/src/app/core/net/api/response.ts index a1c8683..3ad4e51 100644 --- a/src/app/core/net/api/response.ts +++ b/src/app/core/net/api/response.ts @@ -80,8 +80,20 @@ class TrackSummaryResponse { public readonly color: string, public readonly visible: boolean, public readonly featureCount: number, + public readonly renderStyle: string, public readonly renderMode: string, - public readonly aggregationMode: string + public readonly aggregationMode: string, + public readonly logScale: boolean + ) {} +} + +class TrackBinBlockResponse { + public constructor( + public readonly startBp: number, + public readonly endBp: number, + public readonly startPx: number, + public readonly endPx: number, + public readonly coding: boolean ) {} } @@ -93,7 +105,14 @@ class TrackBinResponse { public readonly count: number, public readonly label: string | null, public readonly startPx: number | null, - public readonly endPx: number | null + public readonly endPx: number | null, + public readonly strand: string | null, + public readonly thickStartBp: number | null, + public readonly thickEndBp: number | null, + public readonly thickStartPx: number | null, + public readonly thickEndPx: number | null, + public readonly featureType: string | null, + public readonly blocks: TrackBinBlockResponse[] ) {} } @@ -103,6 +122,7 @@ class TrackRenderResponse { public readonly name: string, public readonly type: string, public readonly color: string, + public readonly renderStyle: string, public readonly bins: TrackBinResponse[], public readonly maxValue: number, public readonly error: string | null @@ -121,6 +141,42 @@ class TrackQueryResponse { ) {} } +class TrackFeatureSearchHitResponse { + public constructor( + public readonly trackId: string, + public readonly trackName: string, + public readonly sourceName: string, + public readonly label: string, + public readonly featureType: string | null, + public readonly strand: string | null, + public readonly startBp: number, + public readonly endBp: number + ) {} +} + +class TrackFeatureSearchResponse { + public constructor( + public readonly query: string, + public readonly limit: number, + public readonly offset: number, + public readonly hasMore: boolean, + public readonly hits: TrackFeatureSearchHitResponse[] + ) {} +} + +class TrackFeatureContextResponse { + public constructor( + public readonly startBp: number, + public readonly endBp: number, + public readonly contextStartBp: number, + public readonly contextEndBp: number, + public readonly marginScreens: number, + public readonly contextWidthPx: number, + public readonly bpResolution: number, + public readonly query: TrackQueryResponse + ) {} +} + class TrackPrecomputeTrackStatusResponse { public constructor( public readonly trackId: string, @@ -143,6 +199,31 @@ class TracksPrecomputeStatusResponse { ) {} } +class TrackCompatibilityReportResponse { + public constructor( + public readonly filename: string, + public readonly trackType: string, + public readonly status: string, + public readonly totalNames: number, + public readonly matchedSourceNames: number, + public readonly matchedAssemblyNames: number, + public readonly matchedAnyNames: number, + public readonly unknownNames: string[], + public readonly recommendation: string, + public readonly message: string + ) {} +} + +class FileEntryResponse { + public constructor( + public readonly path: string, + public readonly name: string, + public readonly sizeBytes: number, + public readonly modifiedAtMs: number, + public readonly extension: string + ) {} +} + class WorkerPoolDiagnosticsResponse { public constructor( public readonly corePoolSize: number, @@ -222,11 +303,17 @@ export { ConversionJobResponse, NameMappingResponse, TrackSummaryResponse, + TrackBinBlockResponse, TrackBinResponse, TrackRenderResponse, TrackQueryResponse, + TrackFeatureSearchHitResponse, + TrackFeatureSearchResponse, + TrackFeatureContextResponse, TrackPrecomputeTrackStatusResponse, TracksPrecomputeStatusResponse, + TrackCompatibilityReportResponse, + FileEntryResponse, WorkerPoolDiagnosticsResponse, WorkerCancellationDomainDiagnosticsResponse, WorkerSchedulerDiagnosticsResponse, diff --git a/src/app/core/net/dto/requestDTO.ts b/src/app/core/net/dto/requestDTO.ts index fa3c535..608ecbc 100644 --- a/src/app/core/net/dto/requestDTO.ts +++ b/src/app/core/net/dto/requestDTO.ts @@ -21,6 +21,7 @@ import { ListFilesRequest, + ListFilesDetailedRequest, OpenFileRequest, CloseFileRequest, AttachSessionRequest, @@ -35,7 +36,14 @@ import { ListAGPFilesRequest, LoadAGPRequest, OpenProgressRequest, + OpenSecondarySourceRequest, + CloseSecondarySourceRequest, + GetSecondarySourceStatusRequest, + SetAssemblyInfoSourceRequest, GetWorkerDiagnosticsRequest, + GetRenderPipelineRequest, + SetRenderPipelineRequest, + ResetRenderPipelineRequest, GetFastaForSelectionRequest, SetNormalizationRequest, SetContrastRangeRequest, @@ -45,10 +53,15 @@ import { ListCoolerFilesRequest, ListTrackFilesRequest, OpenTrackRequest, + OpenCoolerWeightsTrackRequest, + ProbeTrackCompatibilityRequest, ListTracksRequest, UpdateTrackRequest, RemoveTrackRequest, + ReorderTrackRequest, QueryTracks1DRequest, + SearchTrackFeaturesRequest, + GetTrackFeatureContextRequest, StartTracksPrecomputeRequest, GetTracksPrecomputeStatusRequest, StartConversionJobRequest, @@ -128,20 +141,38 @@ abstract class HiCTAPIRequestDTO< return new SaveFileRequestDTO(entity as SaveFileRequest); case entity instanceof ListFilesRequest: return new ListFilesRequestDTO(entity); + case entity instanceof ListFilesDetailedRequest: + return new ListFilesDetailedRequestDTO(entity); case entity instanceof ListCoolerFilesRequest: return new ListCoolerFilesRequestDTO(entity); case entity instanceof ListTrackFilesRequest: return new ListTrackFilesRequestDTO(entity); case entity instanceof OpenTrackRequest: return new OpenTrackRequestDTO(entity as OpenTrackRequest); + case entity instanceof OpenCoolerWeightsTrackRequest: + return new OpenCoolerWeightsTrackRequestDTO( + entity as OpenCoolerWeightsTrackRequest + ); + case entity instanceof ProbeTrackCompatibilityRequest: + return new ProbeTrackCompatibilityRequestDTO(entity as ProbeTrackCompatibilityRequest); case entity instanceof ListTracksRequest: return new ListTracksRequestDTO(entity); case entity instanceof UpdateTrackRequest: return new UpdateTrackRequestDTO(entity as UpdateTrackRequest); case entity instanceof RemoveTrackRequest: return new RemoveTrackRequestDTO(entity as RemoveTrackRequest); + case entity instanceof ReorderTrackRequest: + return new ReorderTrackRequestDTO(entity as ReorderTrackRequest); case entity instanceof QueryTracks1DRequest: return new QueryTracks1DRequestDTO(entity as QueryTracks1DRequest); + case entity instanceof SearchTrackFeaturesRequest: + return new SearchTrackFeaturesRequestDTO( + entity as SearchTrackFeaturesRequest + ); + case entity instanceof GetTrackFeatureContextRequest: + return new GetTrackFeatureContextRequestDTO( + entity as GetTrackFeatureContextRequest + ); case entity instanceof StartTracksPrecomputeRequest: return new StartTracksPrecomputeRequestDTO(entity as StartTracksPrecomputeRequest); case entity instanceof GetTracksPrecomputeStatusRequest: @@ -174,8 +205,30 @@ abstract class HiCTAPIRequestDTO< return new LoadAGPRequestDTO(entity as LoadAGPRequest); case entity instanceof OpenProgressRequest: return new OpenProgressRequestDTO(entity); + case entity instanceof OpenSecondarySourceRequest: + return new OpenSecondarySourceRequestDTO( + entity as OpenSecondarySourceRequest + ); + case entity instanceof CloseSecondarySourceRequest: + return new CloseSecondarySourceRequestDTO( + entity as CloseSecondarySourceRequest + ); + case entity instanceof GetSecondarySourceStatusRequest: + return new GetSecondarySourceStatusRequestDTO( + entity as GetSecondarySourceStatusRequest + ); + case entity instanceof SetAssemblyInfoSourceRequest: + return new SetAssemblyInfoSourceRequestDTO( + entity as SetAssemblyInfoSourceRequest + ); case entity instanceof GetWorkerDiagnosticsRequest: return new GetWorkerDiagnosticsRequestDTO(entity); + case entity instanceof GetRenderPipelineRequest: + return new GetRenderPipelineRequestDTO(entity); + case entity instanceof SetRenderPipelineRequest: + return new SetRenderPipelineRequestDTO(entity as SetRenderPipelineRequest); + case entity instanceof ResetRenderPipelineRequest: + return new ResetRenderPipelineRequestDTO(entity); case entity instanceof CloseFileRequest: return new CloseFileRequestDTO(entity as CloseFileRequest); case entity instanceof AttachSessionRequest: @@ -200,6 +253,43 @@ abstract class HiCTAPIRequestDTO< return new SetVisualizationOptionsRequestDTO( entity as SetVisualizationOptionsRequest ); + default: + return HiCTAPIRequestDTO.toDTOByRequestPath(entity); + } + } + + private static toDTOByRequestPath(entity: HiCTAPIRequest) { + switch (entity.requestPath) { + case "/tracks/open_cooler_weights": + return new OpenCoolerWeightsTrackRequestDTO( + entity as OpenCoolerWeightsTrackRequest + ); + case "/secondary/open": + return new OpenSecondarySourceRequestDTO( + entity as OpenSecondarySourceRequest + ); + case "/secondary/close": + return new CloseSecondarySourceRequestDTO( + entity as CloseSecondarySourceRequest + ); + case "/secondary/status": + return new GetSecondarySourceStatusRequestDTO( + entity as GetSecondarySourceStatusRequest + ); + case "/secondary/set_assembly_source": + return new SetAssemblyInfoSourceRequestDTO( + entity as SetAssemblyInfoSourceRequest + ); + case "/tracks/search_features": + return new SearchTrackFeaturesRequestDTO( + entity as SearchTrackFeaturesRequest + ); + case "/tracks/feature_context": + return new GetTrackFeatureContextRequestDTO( + entity as GetTrackFeatureContextRequest + ); + case "/tracks/reorder": + return new ReorderTrackRequestDTO(entity as ReorderTrackRequest); default: throw new Error( `Unknown HiCTAPIRequest type: ${typeof entity}, constructor ${ @@ -222,12 +312,59 @@ class OpenProgressRequestDTO extends HiCTAPIRequestDTO { } } +class OpenSecondarySourceRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return { + filename: this.entity.options.filename, + allowMismatch: this.entity.options.allowMismatch ?? false, + }; + } +} + +class CloseSecondarySourceRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return {}; + } +} + +class GetSecondarySourceStatusRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return {}; + } +} + +class SetAssemblyInfoSourceRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return { + assemblySource: this.entity.options.assemblySource, + }; + } +} + class GetWorkerDiagnosticsRequestDTO extends HiCTAPIRequestDTO { toDTO(): Record { return {}; } } +class GetRenderPipelineRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return {}; + } +} + +class SetRenderPipelineRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return this.entity.options; + } +} + +class ResetRenderPipelineRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return {}; + } +} + class SetVisualizationOptionsRequestDTO extends HiCTAPIRequestDTO { toDTO(): Record { return { @@ -256,6 +393,7 @@ class StartConversionJobRequestDTO extends HiCTAPIRequestDTO { } } +class ListFilesDetailedRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return {}; + } +} + class ListCoolerFilesRequestDTO extends HiCTAPIRequestDTO { toDTO(): Record { return {}; @@ -469,6 +614,23 @@ class OpenTrackRequestDTO extends HiCTAPIRequestDTO { } } +class OpenCoolerWeightsTrackRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return { + name: this.entity.options.name, + color: this.entity.options.color, + }; + } +} + +class ProbeTrackCompatibilityRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return { + filename: this.entity.options.filename, + }; + } +} + class ListTracksRequestDTO extends HiCTAPIRequestDTO { toDTO(): Record { return {}; @@ -484,6 +646,7 @@ class UpdateTrackRequestDTO extends HiCTAPIRequestDTO { name: this.entity.options.name, renderMode: this.entity.options.renderMode, aggregationMode: this.entity.options.aggregationMode, + logScale: this.entity.options.logScale, }; } } @@ -496,14 +659,86 @@ class RemoveTrackRequestDTO extends HiCTAPIRequestDTO { } } +class ReorderTrackRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return { + trackId: this.entity.options.trackId, + targetIndex: this.entity.options.targetIndex, + }; + } +} + class QueryTracks1DRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + const dto: Record = { + widthPx: this.entity.options.widthPx, + bpResolution: this.entity.options.bpResolution, + }; + if (this.entity.options.unit) { + dto.unit = this.entity.options.unit; + } + if (this.entity.options.startPx !== undefined) { + dto.startPx = this.entity.options.startPx; + } + if (this.entity.options.endPx !== undefined) { + dto.endPx = this.entity.options.endPx; + } + if (this.entity.options.startBin !== undefined) { + dto.startBin = this.entity.options.startBin; + } + if (this.entity.options.endBin !== undefined) { + dto.endBin = this.entity.options.endBin; + } + if (this.entity.options.startBP !== undefined) { + dto.startBP = this.entity.options.startBP; + } + if (this.entity.options.endBP !== undefined) { + dto.endBP = this.entity.options.endBP; + } + return dto; + } +} + +class SearchTrackFeaturesRequestDTO extends HiCTAPIRequestDTO { toDTO(): Record { return { - startPx: this.entity.options.startPx, - endPx: this.entity.options.endPx, + query: this.entity.options.query, + limit: this.entity.options.limit, + offset: this.entity.options.offset, + trackId: this.entity.options.trackId, + }; + } +} + +class GetTrackFeatureContextRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + const dto: Record = { widthPx: this.entity.options.widthPx, bpResolution: this.entity.options.bpResolution, + marginScreens: this.entity.options.marginScreens, }; + if (this.entity.options.unit) { + dto.unit = this.entity.options.unit; + } + if (this.entity.options.startPx !== undefined) { + dto.startPx = this.entity.options.startPx; + } + if (this.entity.options.endPx !== undefined) { + dto.endPx = this.entity.options.endPx; + } + if (this.entity.options.startBin !== undefined) { + dto.startBin = this.entity.options.startBin; + } + if (this.entity.options.endBin !== undefined) { + dto.endBin = this.entity.options.endBin; + } + if (this.entity.options.startBP !== undefined) { + dto.startBP = this.entity.options.startBP; + } + if (this.entity.options.endBP !== undefined) { + dto.endBP = this.entity.options.endBP; + } + return dto; } } @@ -571,6 +806,7 @@ export { HiCTAPIRequestDTO, OpenFileRequestDTO, ListFilesRequestDTO, + ListFilesDetailedRequestDTO, CloseFileRequestDTO, AttachSessionRequestDTO, StartConversionJobRequestDTO, @@ -594,10 +830,14 @@ export { SaveFileRequestDTO, ListTrackFilesRequestDTO, OpenTrackRequestDTO, + ProbeTrackCompatibilityRequestDTO, ListTracksRequestDTO, UpdateTrackRequestDTO, RemoveTrackRequestDTO, + ReorderTrackRequestDTO, QueryTracks1DRequestDTO, + SearchTrackFeaturesRequestDTO, + GetTrackFeatureContextRequestDTO, StartTracksPrecomputeRequestDTO, GetTracksPrecomputeStatusRequestDTO, ListCoolerFilesRequestDTO, diff --git a/src/app/core/net/dto/responseDTO.ts b/src/app/core/net/dto/responseDTO.ts index 4737f9b..e2c79b3 100644 --- a/src/app/core/net/dto/responseDTO.ts +++ b/src/app/core/net/dto/responseDTO.ts @@ -22,13 +22,19 @@ import { ConversionJobResponse, CurrentSignalRangeResponse, + FileEntryResponse, FastaLinkCompatibilityResponse, FastaLinkMismatchResponse, FastaLinkResponse, NameMappingResponse, + TrackCompatibilityReportResponse, TrackPrecomputeTrackStatusResponse, TracksPrecomputeStatusResponse, + TrackBinBlockResponse, TrackBinResponse, + TrackFeatureContextResponse, + TrackFeatureSearchHitResponse, + TrackFeatureSearchResponse, TrackQueryResponse, TrackRenderResponse, TrackSummaryResponse, @@ -131,8 +137,10 @@ class TrackSummaryResponseDTO extends InboundDTO { this.json["color"] as string, this.json["visible"] as boolean, this.json["featureCount"] as number, + (this.json["renderStyle"] as string) ?? "SIGNAL", (this.json["renderMode"] as string) ?? "COVERAGE", - (this.json["aggregationMode"] as string) ?? "MAX" + (this.json["aggregationMode"] as string) ?? "MAX", + (this.json["logScale"] as boolean) ?? false ); } } @@ -146,7 +154,28 @@ class TrackBinResponseDTO extends InboundDTO { this.json["count"] as number, (this.json["label"] as string) ?? null, (this.json["startPx"] as number) ?? null, - (this.json["endPx"] as number) ?? null + (this.json["endPx"] as number) ?? null, + (this.json["strand"] as string) ?? null, + (this.json["thickStartBp"] as number) ?? null, + (this.json["thickEndBp"] as number) ?? null, + (this.json["thickStartPx"] as number) ?? null, + (this.json["thickEndPx"] as number) ?? null, + (this.json["featureType"] as string) ?? null, + ((this.json["blocks"] as Record[]) ?? []).map( + (block) => new TrackBinBlockResponseDTO(block).toEntity() + ) + ); + } +} + +class TrackBinBlockResponseDTO extends InboundDTO { + public toEntity(): TrackBinBlockResponse { + return new TrackBinBlockResponse( + (this.json["startBp"] as number) ?? 0, + (this.json["endBp"] as number) ?? 0, + (this.json["startPx"] as number) ?? 0, + (this.json["endPx"] as number) ?? 0, + Boolean(this.json["coding"] ?? false) ); } } @@ -158,6 +187,7 @@ class TrackRenderResponseDTO extends InboundDTO { this.json["name"] as string, this.json["type"] as string, this.json["color"] as string, + (this.json["renderStyle"] as string) ?? "SIGNAL", ((this.json["bins"] as Record[]) ?? []).map((bin) => new TrackBinResponseDTO(bin).toEntity() ), @@ -183,6 +213,52 @@ class TrackQueryResponseDTO extends InboundDTO { } } +class TrackFeatureSearchHitResponseDTO extends InboundDTO { + public toEntity(): TrackFeatureSearchHitResponse { + return new TrackFeatureSearchHitResponse( + (this.json["trackId"] as string) ?? "", + (this.json["trackName"] as string) ?? "", + (this.json["sourceName"] as string) ?? "", + (this.json["label"] as string) ?? "", + (this.json["featureType"] as string) ?? null, + (this.json["strand"] as string) ?? null, + (this.json["startBp"] as number) ?? 0, + (this.json["endBp"] as number) ?? 0 + ); + } +} + +class TrackFeatureSearchResponseDTO extends InboundDTO { + public toEntity(): TrackFeatureSearchResponse { + return new TrackFeatureSearchResponse( + (this.json["query"] as string) ?? "", + (this.json["limit"] as number) ?? 0, + (this.json["offset"] as number) ?? 0, + Boolean(this.json["hasMore"] ?? false), + ((this.json["hits"] as Record[]) ?? []).map((item) => + new TrackFeatureSearchHitResponseDTO(item).toEntity() + ) + ); + } +} + +class TrackFeatureContextResponseDTO extends InboundDTO { + public toEntity(): TrackFeatureContextResponse { + return new TrackFeatureContextResponse( + (this.json["startBp"] as number) ?? 0, + (this.json["endBp"] as number) ?? 1, + (this.json["contextStartBp"] as number) ?? 0, + (this.json["contextEndBp"] as number) ?? 1, + (this.json["marginScreens"] as number) ?? 1, + (this.json["contextWidthPx"] as number) ?? 0, + (this.json["bpResolution"] as number) ?? 1, + new TrackQueryResponseDTO( + (this.json["query"] as Record) ?? {} + ).toEntity() + ); + } +} + class TrackPrecomputeTrackStatusResponseDTO extends InboundDTO { public toEntity(): TrackPrecomputeTrackStatusResponse { return new TrackPrecomputeTrackStatusResponse( @@ -211,6 +287,35 @@ class TracksPrecomputeStatusResponseDTO extends InboundDTO { + public toEntity(): TrackCompatibilityReportResponse { + return new TrackCompatibilityReportResponse( + (this.json["filename"] as string) ?? "", + (this.json["trackType"] as string) ?? "", + (this.json["status"] as string) ?? "ok", + (this.json["totalNames"] as number) ?? 0, + (this.json["matchedSourceNames"] as number) ?? 0, + (this.json["matchedAssemblyNames"] as number) ?? 0, + (this.json["matchedAnyNames"] as number) ?? 0, + (this.json["unknownNames"] as string[]) ?? [], + (this.json["recommendation"] as string) ?? "SOURCE", + (this.json["message"] as string) ?? "" + ); + } +} + +class FileEntryResponseDTO extends InboundDTO { + public toEntity(): FileEntryResponse { + return new FileEntryResponse( + (this.json["path"] as string) ?? "", + (this.json["name"] as string) ?? "", + (this.json["sizeBytes"] as number) ?? -1, + (this.json["modifiedAtMs"] as number) ?? 0, + (this.json["extension"] as string) ?? "" + ); + } +} + class WorkerPoolDiagnosticsResponseDTO extends InboundDTO { public toEntity(): WorkerPoolDiagnosticsResponse { return new WorkerPoolDiagnosticsResponse( @@ -319,7 +424,11 @@ export { NameMappingResponseDTO, TrackSummaryResponseDTO, TrackQueryResponseDTO, + TrackFeatureSearchResponseDTO, + TrackFeatureContextResponseDTO, TracksPrecomputeStatusResponseDTO, + TrackCompatibilityReportResponseDTO, + FileEntryResponseDTO, WorkerSchedulerDiagnosticsResponseDTO, FastaLinkResponseDTO, }; diff --git a/src/app/stores/uiSettingsStore.ts b/src/app/stores/uiSettingsStore.ts index ae94fec..ddffbaf 100644 --- a/src/app/stores/uiSettingsStore.ts +++ b/src/app/stores/uiSettingsStore.ts @@ -3,8 +3,16 @@ import { ref } from "vue"; export const useUiSettingsStore = defineStore("uiSettings", () => { const customZoomSliderEnabled = ref(false); + const binaryTileTransportEnabled = ref(false); + const fileSelectorMode = ref<"explorer" | "tree">("explorer"); + const inheritTrackBackgroundFromMap = ref(true); + const trackBackgroundColor = ref("rgba(244,247,251,0.98)"); return { customZoomSliderEnabled, + binaryTileTransportEnabled, + fileSelectorMode, + inheritTrackBackgroundFromMap, + trackBackgroundColor, }; }); diff --git a/src/app/ui/MainUIComponent.vue b/src/app/ui/MainUIComponent.vue index ea349fd..706a0d5 100644 --- a/src/app/ui/MainUIComponent.vue +++ b/src/app/ui/MainUIComponent.vue @@ -90,7 +90,7 @@ import { ContactMapManager, // type ContactMapManagerOptions, } from "@/app/core/mapmanagers/ContactMapManager"; -import { ref, watch, type Ref } from "vue"; +import { onMounted, ref, watch, type Ref } from "vue"; import { NetworkManager } from "@/app/core/net/NetworkManager"; import defaultOptions from "@/app/core/visualization/colormap/default_options.json"; import { useVisualizationOptionsStore } from "@/app/stores/visualizationOptionsStore"; @@ -184,6 +184,29 @@ function safeColorTranslator(value: unknown, fallback: string): ColorTranslator } } +function syncUiChromePalette(): void { + const root = document.documentElement; + const background = mapBackgroundColor.value; + const dark = background.L <= 55; + root.style.setProperty("--hict-ui-bg", background.RGB); + root.style.setProperty( + "--hict-ui-fg", + dark ? "rgba(246,248,252,0.96)" : "rgba(24,30,38,0.95)" + ); + root.style.setProperty( + "--hict-ui-outline", + dark ? "rgba(0,0,0,0.84)" : "rgba(255,255,255,0.92)" + ); + root.style.setProperty( + "--hict-ui-muted", + dark ? "rgba(220,226,236,0.88)" : "rgba(75,82,92,0.86)" + ); + root.style.setProperty( + "--hict-ui-border", + dark ? "rgba(248,250,255,0.42)" : "rgba(15,23,38,0.26)" + ); +} + function resetState() { mapManager.value?.dispose(); filename.value = ""; @@ -605,6 +628,17 @@ watch( } ); +watch( + () => mapBackgroundColor.value.RGB, + () => { + syncUiChromePalette(); + } +); + +onMounted(() => { + syncUiChromePalette(); +}); + function onFileSelected(newFilename: string) { if (newFilename !== filename.value) { resetState(); diff --git a/src/app/ui/components/sidebar/SideBar.vue b/src/app/ui/components/sidebar/SideBar.vue index 65c31d3..e1875d7 100644 --- a/src/app/ui/components/sidebar/SideBar.vue +++ b/src/app/ui/components/sidebar/SideBar.vue @@ -413,14 +413,18 @@ function getEventManager(): CommonEventManager | undefined { right: 0px; top: 109px; - /* Global/07. Light */ - background: #f8f9fa; + background: var(--hict-ui-bg, #f8f9fa); + color: var(--hict-ui-fg, #1f2937); /* background-color: green; */ /* Shadows/02. Regular */ box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.15); } +.sidebar :deep(*) { + text-shadow: 0 0 1px var(--hict-ui-outline, rgba(255, 255, 255, 0.9)); +} + #upper-block { /* upper block */ @@ -447,8 +451,7 @@ function getEventManager(): CommonEventManager | undefined { height: fit-content; - /* Global/09. White */ - background: #ffffff; + background: var(--hict-ui-bg, #ffffff); /* Shadows/01. Small */ box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.075); @@ -495,7 +498,7 @@ function getEventManager(): CommonEventManager | undefined { height: fit-content; - background: #ffffff; + background: var(--hict-ui-bg, #ffffff); box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.075); @@ -519,8 +522,7 @@ function getEventManager(): CommonEventManager | undefined { height: fit-content; - /* Global/09. White */ - background: #ffffff; + background: var(--hict-ui-bg, #ffffff); /* Shadows/01. Small */ box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.075); diff --git a/src/app/ui/components/toolbar/ToolBar.vue b/src/app/ui/components/toolbar/ToolBar.vue index 520afbc..f0ee835 100644 --- a/src/app/ui/components/toolbar/ToolBar.vue +++ b/src/app/ui/components/toolbar/ToolBar.vue @@ -61,13 +61,17 @@ const props = defineProps<{ width: 60px; /* height: 100%; TODO: сделать иначе */ - /* Global/07. Light */ - background: #f8f9fa; + background: var(--hict-ui-bg, #f8f9fa); + color: var(--hict-ui-fg, #1f2937); /* Shadows/02. Regular */ box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.15); } +.toolbar-outer :deep(*) { + text-shadow: 0 0 1px var(--hict-ui-outline, rgba(255, 255, 255, 0.9)); +} + .toolbar-upper { /* upper block */ diff --git a/src/app/ui/components/tracks/HorizontalIGVTrack.vue b/src/app/ui/components/tracks/HorizontalIGVTrack.vue index 96d05c8..57ed458 100644 --- a/src/app/ui/components/tracks/HorizontalIGVTrack.vue +++ b/src/app/ui/components/tracks/HorizontalIGVTrack.vue @@ -20,26 +20,79 @@ --> @@ -117,7 +312,12 @@ onBeforeUnmount(() => { height: 100%; border: 1px solid black; overflow: hidden; - background: rgba(244, 247, 251, 0.98); + touch-action: none; + cursor: grab; +} + +#horizontal-igv-track-div:active { + cursor: grabbing; } #horizontal-igv-track-div canvas { @@ -127,24 +327,7 @@ onBeforeUnmount(() => { } .track-chip-list { - position: absolute; - left: 6px; - top: 6px; display: none; - flex-wrap: wrap; - gap: 4px; - max-width: calc(100% - 12px); - pointer-events: none; -} - -.track-chip { - padding: 2px 6px; - border-radius: 999px; - background: rgba(40, 48, 66, 0.78); - color: white; - font-size: 10px; - line-height: 1.2; - white-space: nowrap; } .track-status-overlay { @@ -160,4 +343,28 @@ onBeforeUnmount(() => { line-height: 1.2; pointer-events: none; } + +.track-feature-tooltip { + position: absolute; + z-index: 14; + max-width: 240px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid rgba(17, 24, 39, 0.35); + background: rgba(17, 24, 39, 0.9); + color: rgba(244, 247, 252, 0.98); + font-size: 11px; + line-height: 1.25; + pointer-events: none; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28); +} + +.feature-title { + font-weight: 700; + margin-bottom: 2px; +} + +.feature-meta { + opacity: 0.95; +} diff --git a/src/app/ui/components/tracks/VerticalIGVTrack.vue b/src/app/ui/components/tracks/VerticalIGVTrack.vue index 1525d4b..8acf5e6 100644 --- a/src/app/ui/components/tracks/VerticalIGVTrack.vue +++ b/src/app/ui/components/tracks/VerticalIGVTrack.vue @@ -20,26 +20,79 @@ --> @@ -117,7 +312,12 @@ onBeforeUnmount(() => { height: 100%; border: 1px solid black; overflow: hidden; - background: rgba(244, 247, 251, 0.98); + touch-action: none; + cursor: grab; +} + +#vertical-igv-track-div:active { + cursor: grabbing; } #vertical-igv-track-div canvas { @@ -127,26 +327,7 @@ onBeforeUnmount(() => { } .track-chip-list { - position: absolute; - left: 6px; - top: 6px; display: none; - flex-direction: column; - gap: 4px; - max-height: calc(100% - 12px); - pointer-events: none; -} - -.track-chip { - padding: 2px 6px; - border-radius: 999px; - background: rgba(40, 48, 66, 0.78); - color: white; - font-size: 10px; - line-height: 1.2; - white-space: nowrap; - writing-mode: vertical-rl; - text-orientation: mixed; } .track-status-overlay { @@ -165,4 +346,28 @@ onBeforeUnmount(() => { text-orientation: mixed; pointer-events: none; } + +.track-feature-tooltip { + position: absolute; + z-index: 14; + max-width: 240px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid rgba(17, 24, 39, 0.35); + background: rgba(17, 24, 39, 0.9); + color: rgba(244, 247, 252, 0.98); + font-size: 11px; + line-height: 1.25; + pointer-events: none; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28); +} + +.feature-title { + font-weight: 700; + margin-bottom: 2px; +} + +.feature-meta { + opacity: 0.95; +} diff --git a/src/app/ui/components/upper_ribbon/CoolerConverter.vue b/src/app/ui/components/upper_ribbon/CoolerConverter.vue index a79f36a..84291cb 100644 --- a/src/app/ui/components/upper_ribbon/CoolerConverter.vue +++ b/src/app/ui/components/upper_ribbon/CoolerConverter.vue @@ -64,6 +64,7 @@
@@ -207,6 +208,22 @@ Dismiss
+
+
+
+
Overwrite converted output?
+

{{ overwriteConfirmMessage }}

+
+ + +
+
+
+
@@ -230,6 +247,7 @@ const emit = defineEmits<{ const props = defineProps<{ networkManager: NetworkManager; + initialCoolerFilename?: string; }>(); const selectedCoolerFilename: Ref = ref(null); @@ -239,7 +257,7 @@ const modal: Ref = ref(null); const convertCoolerModal = ref(null); const jobId: Ref = ref(null); const jobs: Ref = ref([]); -const mode: Ref<"single" | "batch"> = ref("single"); +const mode: Ref<"single" | "batch"> = ref("batch"); const batchStep: Ref<"select" | "settings" | "progress"> = ref("select"); const batchFiles: Ref = ref([]); const batchSelection: Ref> = ref(new Set()); @@ -250,8 +268,39 @@ const batchStatusMap: Ref> = ref(new Map()); const batchProgressMap: Ref> = ref(new Map()); const allFiles: Ref> = ref(new Set()); const lastSelectedIndex: Ref = ref(null); +const overwriteConfirmVisible: Ref = ref(false); +const overwriteConfirmMessage: Ref = ref(""); +let overwriteConfirmResolver: ((approved: boolean) => void) | null = null; + +function cancelPendingOverwriteConfirm(): void { + overwriteConfirmVisible.value = false; + overwriteConfirmMessage.value = ""; + if (overwriteConfirmResolver) { + overwriteConfirmResolver(false); + overwriteConfirmResolver = null; + } +} + +function askOverwriteConfirmation(message: string): Promise { + cancelPendingOverwriteConfirm(); + overwriteConfirmMessage.value = message; + overwriteConfirmVisible.value = true; + return new Promise((resolve) => { + overwriteConfirmResolver = resolve; + }); +} + +function resolveOverwriteConfirm(approved: boolean): void { + overwriteConfirmVisible.value = false; + overwriteConfirmMessage.value = ""; + if (overwriteConfirmResolver) { + overwriteConfirmResolver(approved); + overwriteConfirmResolver = null; + } +} function resetState(): void { + cancelPendingOverwriteConfirm(); try { modal.value?.dispose(); } catch (e: unknown) { @@ -263,7 +312,7 @@ function resetState(): void { selectedCoolerFilename.value = null; jobId.value = null; jobs.value = []; - mode.value = "single"; + mode.value = "batch"; batchStep.value = "select"; batchFiles.value = []; batchSelection.value = new Set(); @@ -284,31 +333,49 @@ function onCoolerFileSelected(coolerFilename: string): void { selectedCoolerFilename.value = coolerFilename; } -function convertCooler(): void { +async function convertCooler(): Promise { const filename = selectedCoolerFilename.value; - if (filename) { - props.networkManager.requestManager - .startConversionJob( - new StartConversionJobRequest({ - filename: filename, - direction: "mcool-to-hict", - }) - ) - .then((resp) => { - jobId.value = resp.jobId; - }) - .catch((e) => { - errorMessage.value = e; - }) - .finally(() => { - converting.value = false; - }); - converting.value = true; + if (!filename) { + return; } + const overwriteExisting = isConverted(filename); + if (overwriteExisting) { + const output = deriveOutputFilename(filename); + const approved = await askOverwriteConfirmation( + `Converted file already exists (${output}). Overwrite it with a new conversion?` + ); + if (!approved) { + return; + } + } + converting.value = true; + props.networkManager.requestManager + .startConversionJob( + new StartConversionJobRequest({ + filename: filename, + direction: "mcool-to-hict", + overwrite: overwriteExisting, + }) + ) + .then((resp) => { + jobId.value = resp.jobId; + }) + .catch((e) => { + errorMessage.value = e; + }) + .finally(() => { + converting.value = false; + }); } onMounted(() => { converting.value = false; + if (props.initialCoolerFilename && props.initialCoolerFilename.trim().length > 0) { + selectedCoolerFilename.value = props.initialCoolerFilename; + } + mode.value = "batch"; + batchStep.value = "select"; + loadBatchFiles(); modal.value = new Modal(convertCoolerModal.value ?? "loadAGPModal", { backdrop: "static", keyboard: false, @@ -347,7 +414,14 @@ function loadBatchFiles(): void { .then(([coolers, files]) => { batchFiles.value = coolers; allFiles.value = new Set(files); - batchSelection.value = new Set(); + if ( + props.initialCoolerFilename && + coolers.includes(props.initialCoolerFilename) + ) { + batchSelection.value = new Set([props.initialCoolerFilename]); + } else { + batchSelection.value = new Set(); + } }) .catch((e) => { errorMessage.value = e; @@ -436,8 +510,18 @@ function proceedBatchSettings(): void { batchStep.value = "settings"; } -function startBatchConversion(): void { +async function startBatchConversion(): Promise { const files = Array.from(batchSelection.value); + const alreadyConverted = files.filter((file) => isConverted(file)); + const overwriteExisting = alreadyConverted.length > 0; + if (overwriteExisting) { + const approved = await askOverwriteConfirmation( + `${alreadyConverted.length} selected file(s) already have converted outputs. Overwrite existing outputs?` + ); + if (!approved) { + return; + } + } batchStatusMap.value.clear(); batchProgressMap.value.clear(); props.networkManager.requestManager @@ -446,6 +530,7 @@ function startBatchConversion(): void { files, parallelJobs: batchParallelJobs.value, parallelism: batchParallelism.value, + overwrite: overwriteExisting, }) ) .then((resp) => { @@ -592,4 +677,23 @@ const overallBatchStatusClass = computed(() => { background: #e5e7eb; color: #374151; } + +.modal-content { + position: relative; +} + +.overwrite-confirm-backdrop { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + background: rgba(17, 24, 39, 0.45); +} + +.overwrite-confirm { + width: min(460px, calc(100% - 32px)); + border: 1px solid #d1d5db; +} diff --git a/src/app/ui/components/upper_ribbon/HeaderRibbon.vue b/src/app/ui/components/upper_ribbon/HeaderRibbon.vue index 3de1064..7843bcd 100644 --- a/src/app/ui/components/upper_ribbon/HeaderRibbon.vue +++ b/src/app/ui/components/upper_ribbon/HeaderRibbon.vue @@ -42,9 +42,13 @@
+
+ + Searching genome features... +
+ + diff --git a/src/app/ui/components/upper_ribbon/converter/CoolerFileSelector.vue b/src/app/ui/components/upper_ribbon/converter/CoolerFileSelector.vue index 574930d..ef51174 100644 --- a/src/app/ui/components/upper_ribbon/converter/CoolerFileSelector.vue +++ b/src/app/ui/components/upper_ribbon/converter/CoolerFileSelector.vue @@ -51,7 +51,7 @@ diff --git a/vite.config.ts b/vite.config.ts index 81e810d..e8542d7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -31,6 +31,28 @@ import vueJsx from "@vitejs/plugin-vue-jsx"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [ + { + name: "sanitize-litegraph-eval", + enforce: "pre", + transform(code, id) { + if (!id.includes("node_modules/litegraph.js/build/litegraph.js")) { + return null; + } + let patched = code; + patched = patched.replace( + /var _foo = eval;\s*eval = null;\s*\(new Function\("with\(this\) \{ " \+ code \+ "\}"\)\)\.call\(this\);\s*eval = _foo;/g, + '(new Function("with(this) { " + code + "}")).call(this);' + ); + patched = patched.replace(/v = eval\(v\);/g, "v = Number(v);"); + if (patched === code) { + return null; + } + return { + code: patched, + map: null, + }; + }, + }, vue({ template: { compilerOptions: { @@ -51,4 +73,17 @@ export default defineConfig({ strictPort: true, // https: true, }, + build: { + chunkSizeWarningLimit: 900, + rollupOptions: { + output: { + manualChunks: { + "vendor-vue": ["vue", "pinia"], + "vendor-ol": ["ol"], + "vendor-ui": ["bootstrap", "@popperjs/core", "axios"], + "vendor-export": ["jspdf", "html2canvas"], + }, + }, + }, + }, });