From 0f1ec8108aa62428d5551100a74cc21f058e98ef Mon Sep 17 00:00:00 2001 From: Alexander Serdyukov Date: Sat, 28 Mar 2026 02:54:11 +0400 Subject: [PATCH 01/14] Initial attempts to add GFF support --- .../core/mapmanagers/LinearTrackManager.ts | 220 +++++++- src/app/core/net/api/RequestManager.ts | 67 ++- src/app/core/net/api/request.ts | 25 +- src/app/core/net/api/response.ts | 37 +- src/app/core/net/dto/requestDTO.ts | 48 +- src/app/core/net/dto/responseDTO.ts | 43 +- .../components/upper_ribbon/TrackManager.vue | 142 ++++- .../upper_ribbon/UniversalFileSelector.vue | 514 ++++++------------ 8 files changed, 709 insertions(+), 387 deletions(-) diff --git a/src/app/core/mapmanagers/LinearTrackManager.ts b/src/app/core/mapmanagers/LinearTrackManager.ts index 5528458..808fb24 100644 --- a/src/app/core/mapmanagers/LinearTrackManager.ts +++ b/src/app/core/mapmanagers/LinearTrackManager.ts @@ -24,6 +24,8 @@ import { unByKey } from "ol/Observable"; import { transform } from "ol/proj"; import type { ContactMapManager } from "./ContactMapManager"; import type { + FileEntryResponse, + TrackCompatibilityReportResponse, TrackQueryResponse, TrackSummaryResponse, TracksPrecomputeStatusResponse, @@ -179,6 +181,18 @@ class LinearTrackManager { 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, @@ -463,6 +477,7 @@ class LinearTrackManager { name: track.name, type: track.type, color: track.color, + renderStyle: track.renderStyle ?? "SIGNAL", bins: [], maxValue: 0, error: null, @@ -503,6 +518,10 @@ class LinearTrackManager { ctx.strokeStyle = "rgba(120,130,145,0.55)"; ctx.strokeRect(laneStart + 0.5, 0.5, laneSize - 1, canvas.height - 1); } + const renderStyle = + (track.renderStyle ?? "SIGNAL").toUpperCase() === "FEATURE" + ? "FEATURE" + : "SIGNAL"; ctx.fillStyle = track.color ?? "#4e79a7"; for (const bin of track.bins) { const hasProjectedPx = @@ -519,7 +538,10 @@ class LinearTrackManager { bpResolution ); const endPx = hasProjectedPx - ? Math.max(startPx + 1, Math.max(bin.startPx ?? startPx, bin.endPx ?? startPx)) + ? Math.max( + startPx + 1, + Math.max(bin.startPx ?? startPx, bin.endPx ?? startPx) + ) : this.mapManager .getContigDimensionHolder() .getPxContainingBp( @@ -531,7 +553,10 @@ class LinearTrackManager { ) + 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 intervalEnd = Math.max( + intervalStart + 1, + Math.max(bin.startBp, bin.endBp) + ); const intervalProbeEnd = Math.max(intervalStart, intervalEnd - 1); if ( !this.mapManager @@ -544,33 +569,167 @@ class LinearTrackManager { continue; } } - const normalizedValue = Math.max( - 0, - Math.min(1, (bin.value ?? 0) / maxValue) - ); + 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 = Math.max( + 0, + Math.min(1, (bin.value ?? 0) / maxValue) + ); + const barHeight = (laneInnerEnd - laneInnerStart) * normalizedValue; + const y = laneInnerEnd - barHeight; + ctx.fillRect(x0, y, x1 - x0, Math.max(1, barHeight)); + } else { + const laneCenter = (laneInnerStart + laneInnerEnd) / 2; + const thinHeight = Math.max( + 1, + Math.round((laneInnerEnd - laneInnerStart) * 0.16) + ); + const thickHeight = Math.max( + thinHeight + 1, + Math.round((laneInnerEnd - laneInnerStart) * 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(canvas.width - 1, thickX0ByPx)); + thickX1 = Math.max(thickX0 + 1, Math.min(x1, thickX1ByPx)); + } + ctx.fillRect(thickX0, thickY, Math.max(1, thickX1 - thickX0), thickHeight); + + const strand = bin.strand; + if ((strand === "+" || strand === "-") && x1 - x0 > 8) { + const arrowSpacing = 14; + const arrowSize = 3; + const arrowY = Math.floor(laneCenter); + ctx.beginPath(); + if (strand === "+") { + for (let x = x0 + 4; x < x1 - 2; x += arrowSpacing) { + ctx.moveTo(x - arrowSize, arrowY - arrowSize); + ctx.lineTo(x + arrowSize, arrowY); + ctx.lineTo(x - arrowSize, arrowY + arrowSize); + } + } else { + for (let x = x1 - 4; x > x0 + 2; x -= arrowSpacing) { + ctx.moveTo(x + arrowSize, arrowY - arrowSize); + ctx.lineTo(x - arrowSize, arrowY); + ctx.lineTo(x + arrowSize, arrowY + arrowSize); + } + } + ctx.fill(); + } + } } 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 = Math.max( + 0, + Math.min(1, (bin.value ?? 0) / maxValue) + ); + const barWidth = (laneInnerEnd - laneInnerStart) * normalizedValue; + const x = laneInnerEnd - Math.max(1, barWidth); + ctx.fillRect(x, y0, Math.max(1, barWidth), y1 - y0); + } else { + const laneCenter = (laneInnerStart + laneInnerEnd) / 2; + const thinWidth = Math.max( + 1, + Math.round((laneInnerEnd - laneInnerStart) * 0.16) + ); + const thickWidth = Math.max( + thinWidth + 1, + Math.round((laneInnerEnd - laneInnerStart) * 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(canvas.height - 1, thickY0ByPx)); + thickY1 = Math.max(thickY0 + 1, Math.min(y1, thickY1ByPx)); + } + ctx.fillRect(thickX, thickY0, thickWidth, Math.max(1, thickY1 - thickY0)); + + const strand = bin.strand; + if ((strand === "+" || strand === "-") && y1 - y0 > 8) { + const arrowSpacing = 14; + const arrowSize = 3; + const arrowX = Math.floor(laneCenter); + ctx.beginPath(); + if (strand === "+") { + for (let y = y0 + 4; y < y1 - 2; y += arrowSpacing) { + ctx.moveTo(arrowX - arrowSize, y - arrowSize); + ctx.lineTo(arrowX, y + arrowSize); + ctx.lineTo(arrowX + arrowSize, y - arrowSize); + } + } else { + for (let y = y1 - 4; y > y0 + 2; y -= arrowSpacing) { + ctx.moveTo(arrowX - arrowSize, y + arrowSize); + ctx.lineTo(arrowX, y - arrowSize); + ctx.lineTo(arrowX + arrowSize, y + arrowSize); + } + } + ctx.fill(); + } + } } } ctx.fillStyle = "rgba(20,20,20,0.85)"; @@ -586,6 +745,18 @@ class LinearTrackManager { ctx.font = "10px sans-serif"; ctx.fillText(statusMessage ?? "No signal in current view", 6, laneStart + 20); } + if ((track.renderStyle ?? "SIGNAL").toUpperCase() !== "FEATURE") { + ctx.fillStyle = "rgba(30,40,55,0.72)"; + ctx.font = "9px monospace"; + ctx.textAlign = "right"; + ctx.fillText( + this.formatScaleValue(maxValue), + canvas.width - 4, + laneStart + 4 + ); + ctx.fillText("0", canvas.width - 4, laneEnd - 12); + ctx.textAlign = "left"; + } } else { ctx.save(); ctx.translate(laneStart + 12, 6); @@ -609,6 +780,12 @@ class LinearTrackManager { ctx.fillText(statusMessage ?? "No signal", 0, 0); ctx.restore(); } + if ((track.renderStyle ?? "SIGNAL").toUpperCase() !== "FEATURE") { + ctx.fillStyle = "rgba(30,40,55,0.72)"; + ctx.font = "9px monospace"; + ctx.fillText(this.formatScaleValue(maxValue), laneStart + 2, 2); + ctx.fillText("0", laneStart + 2, canvas.height - 12); + } } }); const hasAnySignal = tracks.some((track) => track.bins.length > 0); @@ -623,6 +800,19 @@ class LinearTrackManager { }); } + 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 getViewportGeometry(orientation: Orientation): ViewportGeometry { const descriptor = this.mapManager.getLayersManager().currentViewState.resolutionDesciptor; diff --git a/src/app/core/net/api/RequestManager.ts b/src/app/core/net/api/RequestManager.ts index 3106b37..16a1ffa 100644 --- a/src/app/core/net/api/RequestManager.ts +++ b/src/app/core/net/api/RequestManager.ts @@ -37,6 +37,8 @@ import { FastaLinkResponseDTO, NameMappingResponseDTO, TracksPrecomputeStatusResponseDTO, + TrackCompatibilityReportResponseDTO, + FileEntryResponseDTO, TrackQueryResponseDTO, TrackSummaryResponseDTO, TilePOSTResponseDTO, @@ -53,6 +55,7 @@ import { LinkFASTARequest, ListAGPFilesRequest, ListCoolerFilesRequest, + ListFilesDetailedRequest, ListFASTAFilesRequest, ListFilesRequest, LoadAGPRequest, @@ -81,6 +84,7 @@ import { OpenProgressRequest, ListTrackFilesRequest, OpenTrackRequest, + ProbeTrackCompatibilityRequest, ListTracksRequest, UpdateTrackRequest, RemoveTrackRequest, @@ -93,7 +97,9 @@ import { ConversionJobResponse, CurrentSignalRangeResponse, FastaLinkResponse, + FileEntryResponse, NameMappingResponse, + TrackCompatibilityReportResponse, TracksPrecomputeStatusResponse, TrackQueryResponse, TrackSummaryResponse, @@ -231,6 +237,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 +263,14 @@ class RequestManager { .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[]) @@ -291,7 +311,52 @@ class RequestManager { 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()); } diff --git a/src/app/core/net/api/request.ts b/src/app/core/net/api/request.ts index ee04fc6..d0605e0 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"; } @@ -350,6 +354,16 @@ class OpenTrackRequest implements HiCTAPIRequest { ) {} } +class ProbeTrackCompatibilityRequest implements HiCTAPIRequest { + requestPath = "/tracks/probe"; + + public constructor( + public readonly options: { + readonly filename: string; + } + ) {} +} + class ListTracksRequest implements HiCTAPIRequest { requestPath = "/tracks/list"; } @@ -384,8 +398,13 @@ class QueryTracks1DRequest implements HiCTAPIRequest { 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; } @@ -439,6 +458,7 @@ export { GetAGPForAssemblyRequest, OpenFileRequest, ListFilesRequest, + ListFilesDetailedRequest, GroupContigsIntoScaffoldRequest, UngroupContigsFromScaffoldRequest, ReverseSelectionRangeRequest, @@ -460,6 +480,7 @@ export { SetVisualizationOptionsRequest, ListTrackFilesRequest, OpenTrackRequest, + ProbeTrackCompatibilityRequest, ListTracksRequest, UpdateTrackRequest, RemoveTrackRequest, diff --git a/src/app/core/net/api/response.ts b/src/app/core/net/api/response.ts index a1c8683..07c1f5f 100644 --- a/src/app/core/net/api/response.ts +++ b/src/app/core/net/api/response.ts @@ -80,6 +80,7 @@ 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 ) {} @@ -93,7 +94,13 @@ 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 ) {} } @@ -103,6 +110,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 @@ -143,6 +151,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, @@ -227,6 +260,8 @@ export { TrackQueryResponse, 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..b577108 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, @@ -45,6 +46,7 @@ import { ListCoolerFilesRequest, ListTrackFilesRequest, OpenTrackRequest, + ProbeTrackCompatibilityRequest, ListTracksRequest, UpdateTrackRequest, RemoveTrackRequest, @@ -128,12 +130,16 @@ 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 ProbeTrackCompatibilityRequest: + return new ProbeTrackCompatibilityRequestDTO(entity as ProbeTrackCompatibilityRequest); case entity instanceof ListTracksRequest: return new ListTracksRequestDTO(entity); case entity instanceof UpdateTrackRequest: @@ -447,6 +453,12 @@ class ListFilesRequestDTO extends HiCTAPIRequestDTO { } } +class ListFilesDetailedRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return {}; + } +} + class ListCoolerFilesRequestDTO extends HiCTAPIRequestDTO { toDTO(): Record { return {}; @@ -469,6 +481,14 @@ class OpenTrackRequestDTO extends HiCTAPIRequestDTO { } } +class ProbeTrackCompatibilityRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return { + filename: this.entity.options.filename, + }; + } +} + class ListTracksRequestDTO extends HiCTAPIRequestDTO { toDTO(): Record { return {}; @@ -498,12 +518,32 @@ class RemoveTrackRequestDTO extends HiCTAPIRequestDTO { class QueryTracks1DRequestDTO extends HiCTAPIRequestDTO { toDTO(): Record { - return { - startPx: this.entity.options.startPx, - endPx: this.entity.options.endPx, + 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; } } @@ -571,6 +611,7 @@ export { HiCTAPIRequestDTO, OpenFileRequestDTO, ListFilesRequestDTO, + ListFilesDetailedRequestDTO, CloseFileRequestDTO, AttachSessionRequestDTO, StartConversionJobRequestDTO, @@ -594,6 +635,7 @@ export { SaveFileRequestDTO, ListTrackFilesRequestDTO, OpenTrackRequestDTO, + ProbeTrackCompatibilityRequestDTO, ListTracksRequestDTO, UpdateTrackRequestDTO, RemoveTrackRequestDTO, diff --git a/src/app/core/net/dto/responseDTO.ts b/src/app/core/net/dto/responseDTO.ts index 4737f9b..609a1cf 100644 --- a/src/app/core/net/dto/responseDTO.ts +++ b/src/app/core/net/dto/responseDTO.ts @@ -22,10 +22,12 @@ import { ConversionJobResponse, CurrentSignalRangeResponse, + FileEntryResponse, FastaLinkCompatibilityResponse, FastaLinkMismatchResponse, FastaLinkResponse, NameMappingResponse, + TrackCompatibilityReportResponse, TrackPrecomputeTrackStatusResponse, TracksPrecomputeStatusResponse, TrackBinResponse, @@ -131,6 +133,7 @@ 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" ); @@ -146,7 +149,13 @@ 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 ); } } @@ -158,6 +167,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() ), @@ -211,6 +221,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( @@ -320,6 +359,8 @@ export { TrackSummaryResponseDTO, TrackQueryResponseDTO, TracksPrecomputeStatusResponseDTO, + TrackCompatibilityReportResponseDTO, + FileEntryResponseDTO, WorkerSchedulerDiagnosticsResponseDTO, FastaLinkResponseDTO, }; diff --git a/src/app/ui/components/upper_ribbon/TrackManager.vue b/src/app/ui/components/upper_ribbon/TrackManager.vue index dd5ee63..5d5ecea 100644 --- a/src/app/ui/components/upper_ribbon/TrackManager.vue +++ b/src/app/ui/components/upper_ribbon/TrackManager.vue @@ -40,16 +40,21 @@ -function onNodeUnselect(evt: unknown) { - // console.log(evt); - selectedFilename.value = null; + From f68ac8baddad3802db76a9c32d403b1c3f7f4ee8 Mon Sep 17 00:00:00 2001 From: Alexander Serdyukov Date: Sat, 28 Mar 2026 04:24:41 +0400 Subject: [PATCH 02/14] Initial (yet still buggy) implementation of rendering pipeline and Cooler weights 1D track --- src/app/core/controls/RulerControl.ts | 294 +++------ .../core/mapmanagers/CommonEventManager.ts | 1 + src/app/core/mapmanagers/ContactMapManager.ts | 330 +++++++++- .../mapmanagers/HiCViewAndLayersManager.ts | 25 +- .../core/mapmanagers/LinearTrackManager.ts | 250 +++++++- src/app/core/net/api/RequestManager.ts | 32 + src/app/core/net/api/request.ts | 32 + src/app/core/net/api/response.ts | 3 +- src/app/core/net/dto/requestDTO.ts | 28 + src/app/core/net/dto/responseDTO.ts | 3 +- src/app/stores/uiSettingsStore.ts | 6 + .../components/tracks/HorizontalIGVTrack.vue | 66 +- .../ui/components/tracks/VerticalIGVTrack.vue | 68 +- .../upper_ribbon/CoolerConverter.vue | 4 + .../components/upper_ribbon/NavigationBar.vue | 30 +- .../upper_ribbon/NormalizationSelector.vue | 19 + .../upper_ribbon/RenderingPipelineModal.vue | 593 ++++++++++++++++++ .../components/upper_ribbon/TrackManager.vue | 115 +++- .../upper_ribbon/UniversalFileSelector.vue | 254 +++++++- .../workspace/InteractiveWorkspace.vue | 71 ++- vite.config.ts | 13 + 21 files changed, 1876 insertions(+), 361 deletions(-) create mode 100644 src/app/ui/components/upper_ribbon/RenderingPipelineModal.vue 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..edaa074 100644 --- a/src/app/core/mapmanagers/ContactMapManager.ts +++ b/src/app/core/mapmanagers/ContactMapManager.ts @@ -20,9 +20,20 @@ */ 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 { transformExtent } from "ol/proj"; +import { getCenter, getHeight, getWidth, intersects } from "ol/extent"; +import { unByKey } from "ol/Observable"; +import type { EventsKey } from "ol/events"; import ContigDimensionHolder from "./ContigDimensionHolder"; import { ScaffoldHolder } from "./ScaffoldHolder"; import { HiCViewAndLayersManager } from "./HiCViewAndLayersManager"; @@ -48,7 +59,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 +104,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 +155,88 @@ 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; + } + 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(220,38,38,0.95)", + 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 +247,7 @@ class ContactMapManager { return this.map; } - public getMiniMap(): OverviewMap { + public getMiniMap(): Map { const minimap = this.minimap; if (minimap) { return minimap; @@ -182,12 +275,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 +290,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 = @@ -325,6 +446,7 @@ class ContactMapManager { options?: { backgroundColor?: string; metadata?: Record; + includeWorkspaceComposite?: boolean; } ): Promise { const svg = await this.buildCurrentMapSvg(progressCallback, options); @@ -341,8 +463,31 @@ class ContactMapManager { options?: { backgroundColor?: string; metadata?: Record; + includeWorkspaceComposite?: boolean; } ): Promise { + if (options?.includeWorkspaceComposite ?? true) { + const canvas = await this.renderWorkspaceCompositeCanvas( + options?.backgroundColor + ); + progressCallback?.(1); + await new Promise((resolve) => { + canvas.toBlob((blob) => { + if (!blob) { + resolve(); + return; + } + const a = document.createElement("a"); + a.download = `${this.options.filename}.png`; + a.href = URL.createObjectURL(blob); + a.click(); + URL.revokeObjectURL(a.href); + resolve(); + }, "image/png"); + }); + return; + } + const svg = await this.buildCurrentMapSvg(progressCallback, options); const svgBlob = new Blob([svg], { type: "image/svg+xml" }); const svgUrl = URL.createObjectURL(svgBlob); @@ -390,8 +535,26 @@ class ContactMapManager { options?: { backgroundColor?: string; metadata?: Record; + includeWorkspaceComposite?: boolean; } ): Promise { + if (options?.includeWorkspaceComposite ?? true) { + const canvas = await this.renderWorkspaceCompositeCanvas( + options?.backgroundColor + ); + progressCallback?.(1); + const dataUrl = canvas.toDataURL("image/png"); + const { jsPDF } = await import("jspdf"); + const pdf = new jsPDF({ + orientation: canvas.width >= canvas.height ? "landscape" : "portrait", + unit: "px", + format: [canvas.width, canvas.height], + }); + pdf.addImage(dataUrl, "PNG", 0, 0, canvas.width, canvas.height); + pdf.save(`${this.options.filename}.pdf`); + return; + } + const svg = await this.buildCurrentMapSvg(progressCallback, options); const svgBlob = new Blob([svg], { type: "image/svg+xml" }); const svgUrl = URL.createObjectURL(svgBlob); @@ -582,8 +745,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 +824,134 @@ 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; + } + 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 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 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 808fb24..952bc7b 100644 --- a/src/app/core/mapmanagers/LinearTrackManager.ts +++ b/src/app/core/mapmanagers/LinearTrackManager.ts @@ -23,6 +23,8 @@ 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, TrackCompatibilityReportResponse, @@ -201,6 +203,13 @@ 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(); @@ -214,6 +223,7 @@ class LinearTrackManager { name?: string; renderMode?: string; aggregationMode?: string; + logScale?: boolean; } ): Promise { await this.mapManager.networkManager.requestManager.updateTrack( @@ -468,7 +478,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) @@ -497,6 +507,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 @@ -506,22 +517,28 @@ class LinearTrackManager { const laneEnd = laneStart + laneSize; const laneInnerStart = laneStart + 2; const laneInnerEnd = laneEnd - 2; - const maxValue = Math.max(track.maxValue, 1); + const maxValue = Math.max(track.maxValue, 0); - 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 scaleTransform = this.buildScaleTransform(maxValue, useLogScale); ctx.fillStyle = track.color ?? "#4e79a7"; for (const bin of track.bins) { const hasProjectedPx = @@ -582,10 +599,7 @@ class LinearTrackManager { continue; } if (renderStyle === "SIGNAL") { - const normalizedValue = Math.max( - 0, - Math.min(1, (bin.value ?? 0) / maxValue) - ); + 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)); @@ -662,10 +676,7 @@ class LinearTrackManager { continue; } if (renderStyle === "SIGNAL") { - const normalizedValue = Math.max( - 0, - Math.min(1, (bin.value ?? 0) / maxValue) - ); + 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); @@ -746,45 +757,53 @@ class LinearTrackManager { ctx.fillText(statusMessage ?? "No signal in current view", 6, laneStart + 20); } if ((track.renderStyle ?? "SIGNAL").toUpperCase() !== "FEATURE") { - ctx.fillStyle = "rgba(30,40,55,0.72)"; - ctx.font = "9px monospace"; - ctx.textAlign = "right"; - ctx.fillText( - this.formatScaleValue(maxValue), - canvas.width - 4, - laneStart + 4 + this.drawSignalScaleTicks( + ctx, + orientation, + laneStart, + laneEnd, + laneInnerStart, + laneInnerEnd, + scaleTransform, + canvas.width, + canvas.height ); - ctx.fillText("0", canvas.width - 4, laneEnd - 12); - ctx.textAlign = "left"; } } else { ctx.save(); - ctx.translate(laneStart + 12, 6); - ctx.rotate(Math.PI / 2); + ctx.translate(laneStart + 10, canvas.height - 4); + ctx.rotate(-Math.PI / 2); ctx.fillText(track.name, 0, 0); ctx.restore(); if (track.error) { ctx.save(); - ctx.translate(laneStart + 24, 6); - ctx.rotate(Math.PI / 2); + ctx.translate(laneStart + 22, canvas.height - 4); + ctx.rotate(-Math.PI / 2); ctx.fillStyle = "rgba(160, 30, 30, 0.88)"; ctx.font = "10px sans-serif"; ctx.fillText(track.error, 0, 0); ctx.restore(); } else if (track.bins.length === 0) { ctx.save(); - ctx.translate(laneStart + 24, 6); - ctx.rotate(Math.PI / 2); + ctx.translate(laneStart + 22, canvas.height - 4); + ctx.rotate(-Math.PI / 2); ctx.fillStyle = "rgba(90,90,90,0.75)"; ctx.font = "10px sans-serif"; ctx.fillText(statusMessage ?? "No signal", 0, 0); ctx.restore(); } if ((track.renderStyle ?? "SIGNAL").toUpperCase() !== "FEATURE") { - ctx.fillStyle = "rgba(30,40,55,0.72)"; - ctx.font = "9px monospace"; - ctx.fillText(this.formatScaleValue(maxValue), laneStart + 2, 2); - ctx.fillText("0", laneStart + 2, canvas.height - 12); + this.drawSignalScaleTicks( + ctx, + orientation, + laneStart, + laneEnd, + laneInnerStart, + laneInnerEnd, + scaleTransform, + canvas.width, + canvas.height + ); } } }); @@ -813,6 +832,167 @@ class LinearTrackManager { 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 + ): SignalScaleTransform { + const safeMax = + Number.isFinite(maxValue) && maxValue > 0 ? maxValue : 1; + if (!logScale) { + return { + logScale: false, + maxValue: safeMax, + normalize: (value: number) => + Math.max(0, Math.min(1, (Number.isFinite(value) ? value : 0) / safeMax)), + }; + } + const maxLog = Math.log10(1 + safeMax); + const safeMaxLog = Number.isFinite(maxLog) && maxLog > 0 ? maxLog : 1; + return { + logScale: true, + maxValue: safeMax, + normalize: (value: number) => { + if (!Number.isFinite(value) || value <= 0) { + return 0; + } + return Math.max(0, Math.min(1, Math.log10(1 + value) / safeMaxLog)); + }, + }; + } + + private drawSignalScaleTicks( + ctx: CanvasRenderingContext2D, + orientation: Orientation, + laneStart: number, + laneEnd: number, + laneInnerStart: number, + laneInnerEnd: number, + scale: SignalScaleTransform, + canvasWidth: number, + canvasHeight: number + ): void { + const axisSpan = Math.max(1, laneInnerEnd - laneInnerStart); + const ticks = this.buildScaleTicks(scale, axisSpan); + if (ticks.length === 0) { + return; + } + ctx.fillStyle = "rgba(30,40,55,0.76)"; + ctx.strokeStyle = "rgba(30,40,55,0.55)"; + ctx.font = "9px monospace"; + let lastLabelCoord = Number.NEGATIVE_INFINITY; + const minLabelSpacing = 12; + if (orientation === "horizontal") { + ctx.textAlign = "right"; + const axisX0 = canvasWidth - 16; + const axisX1 = canvasWidth - 9; + for (const tick of ticks) { + const normalized = scale.normalize(tick); + const y = laneInnerEnd - normalized * axisSpan; + const iy = Math.max(laneStart + 3, Math.min(laneEnd - 10, y)); + if (Math.abs(iy - lastLabelCoord) < minLabelSpacing) { + continue; + } + lastLabelCoord = iy; + ctx.beginPath(); + ctx.moveTo(axisX0, iy + 3); + ctx.lineTo(axisX1, iy + 3); + ctx.stroke(); + ctx.fillText(this.formatScaleValue(tick), canvasWidth - 2, iy - 2); + } + if (scale.logScale) { + ctx.fillStyle = "rgba(30,40,55,0.72)"; + ctx.font = "8px monospace"; + ctx.textAlign = "left"; + ctx.fillText("log10", 2, Math.max(laneStart + 2, laneInnerStart)); + } + ctx.textAlign = "left"; + return; + } + const axisY0 = Math.max(4, canvasHeight - 20); + const axisY1 = axisY0 + 6; + ctx.textAlign = "center"; + for (const tick of ticks) { + const normalized = scale.normalize(tick); + const x = laneInnerEnd - normalized * axisSpan; + const ix = Math.max(laneStart + 2, Math.min(laneEnd - 20, x)); + if (Math.abs(ix - lastLabelCoord) < minLabelSpacing) { + continue; + } + lastLabelCoord = ix; + ctx.beginPath(); + ctx.moveTo(ix + 3, axisY0); + ctx.lineTo(ix + 3, axisY1); + ctx.stroke(); + ctx.save(); + ctx.translate(ix + 6, axisY1 + 1); + ctx.rotate(-Math.PI / 2); + ctx.fillText(this.formatScaleValue(tick), 0, 0); + ctx.restore(); + } + if (scale.logScale) { + ctx.save(); + ctx.fillStyle = "rgba(30,40,55,0.72)"; + ctx.translate(laneStart + 3, axisY1 + 1); + ctx.rotate(-Math.PI / 2); + ctx.fillText("log10", 0, 0); + ctx.restore(); + } + } + + 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 maxLog = Math.log10(1 + maxValue); + 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(10, 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; @@ -1102,3 +1282,9 @@ type TrackQueryCache = { fetchedAtMs: number; response: TrackQueryResponse; }; + +type SignalScaleTransform = { + logScale: boolean; + maxValue: number; + normalize: (value: number) => number; +}; diff --git a/src/app/core/net/api/RequestManager.ts b/src/app/core/net/api/RequestManager.ts index 16a1ffa..35242d6 100644 --- a/src/app/core/net/api/RequestManager.ts +++ b/src/app/core/net/api/RequestManager.ts @@ -84,6 +84,7 @@ import { OpenProgressRequest, ListTrackFilesRequest, OpenTrackRequest, + OpenCoolerWeightsTrackRequest, ProbeTrackCompatibilityRequest, ListTracksRequest, UpdateTrackRequest, @@ -92,6 +93,9 @@ import { StartTracksPrecomputeRequest, GetTracksPrecomputeStatusRequest, GetWorkerDiagnosticsRequest, + GetRenderPipelineRequest, + SetRenderPipelineRequest, + ResetRenderPipelineRequest, } from "./request"; import { ConversionJobResponse, @@ -263,6 +267,15 @@ 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 { @@ -285,6 +298,7 @@ class RequestManager { name?: string; renderMode?: string; aggregationMode?: string; + logScale?: boolean; } ): Promise { return this.sendRequest( @@ -295,6 +309,7 @@ class RequestManager { name: options.name, renderMode: options.renderMode, aggregationMode: options.aggregationMode, + logScale: options.logScale, }) ) .then((response) => response.data) @@ -382,6 +397,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 d0605e0..d0f456c 100644 --- a/src/app/core/net/api/request.ts +++ b/src/app/core/net/api/request.ts @@ -326,6 +326,22 @@ 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"; @@ -354,6 +370,17 @@ 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"; @@ -379,6 +406,7 @@ class UpdateTrackRequest implements HiCTAPIRequest { readonly name?: string; readonly renderMode?: string; readonly aggregationMode?: string; + readonly logScale?: boolean; } ) {} } @@ -480,6 +508,7 @@ export { SetVisualizationOptionsRequest, ListTrackFilesRequest, OpenTrackRequest, + OpenCoolerWeightsTrackRequest, ProbeTrackCompatibilityRequest, ListTracksRequest, UpdateTrackRequest, @@ -488,4 +517,7 @@ export { 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 07c1f5f..7270fba 100644 --- a/src/app/core/net/api/response.ts +++ b/src/app/core/net/api/response.ts @@ -82,7 +82,8 @@ class TrackSummaryResponse { public readonly featureCount: number, public readonly renderStyle: string, public readonly renderMode: string, - public readonly aggregationMode: string + public readonly aggregationMode: string, + public readonly logScale: boolean ) {} } diff --git a/src/app/core/net/dto/requestDTO.ts b/src/app/core/net/dto/requestDTO.ts index b577108..c4244d9 100644 --- a/src/app/core/net/dto/requestDTO.ts +++ b/src/app/core/net/dto/requestDTO.ts @@ -37,6 +37,9 @@ import { LoadAGPRequest, OpenProgressRequest, GetWorkerDiagnosticsRequest, + GetRenderPipelineRequest, + SetRenderPipelineRequest, + ResetRenderPipelineRequest, GetFastaForSelectionRequest, SetNormalizationRequest, SetContrastRangeRequest, @@ -182,6 +185,12 @@ abstract class HiCTAPIRequestDTO< return new OpenProgressRequestDTO(entity); 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: @@ -234,6 +243,24 @@ class GetWorkerDiagnosticsRequestDTO 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 { @@ -504,6 +531,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, }; } } diff --git a/src/app/core/net/dto/responseDTO.ts b/src/app/core/net/dto/responseDTO.ts index 609a1cf..730da77 100644 --- a/src/app/core/net/dto/responseDTO.ts +++ b/src/app/core/net/dto/responseDTO.ts @@ -135,7 +135,8 @@ class TrackSummaryResponseDTO extends InboundDTO { 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 ); } } diff --git a/src/app/stores/uiSettingsStore.ts b/src/app/stores/uiSettingsStore.ts index ae94fec..7122fd5 100644 --- a/src/app/stores/uiSettingsStore.ts +++ b/src/app/stores/uiSettingsStore.ts @@ -3,8 +3,14 @@ import { ref } from "vue"; export const useUiSettingsStore = defineStore("uiSettings", () => { const customZoomSliderEnabled = ref(false); + const fileSelectorMode = ref<"explorer" | "tree">("explorer"); + const inheritTrackBackgroundFromMap = ref(true); + const trackBackgroundColor = ref("rgba(244,247,251,0.98)"); return { customZoomSliderEnabled, + fileSelectorMode, + inheritTrackBackgroundFromMap, + trackBackgroundColor, }; }); diff --git a/src/app/ui/components/tracks/HorizontalIGVTrack.vue b/src/app/ui/components/tracks/HorizontalIGVTrack.vue index 96d05c8..39b6f1c 100644 --- a/src/app/ui/components/tracks/HorizontalIGVTrack.vue +++ b/src/app/ui/components/tracks/HorizontalIGVTrack.vue @@ -20,26 +20,41 @@ --> @@ -117,7 +109,6 @@ onBeforeUnmount(() => { height: 100%; border: 1px solid black; overflow: hidden; - background: rgba(244, 247, 251, 0.98); } #horizontal-igv-track-div canvas { @@ -127,24 +118,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 { diff --git a/src/app/ui/components/tracks/VerticalIGVTrack.vue b/src/app/ui/components/tracks/VerticalIGVTrack.vue index 1525d4b..f733203 100644 --- a/src/app/ui/components/tracks/VerticalIGVTrack.vue +++ b/src/app/ui/components/tracks/VerticalIGVTrack.vue @@ -20,26 +20,41 @@ --> @@ -117,7 +109,6 @@ onBeforeUnmount(() => { height: 100%; border: 1px solid black; overflow: hidden; - background: rgba(244, 247, 251, 0.98); } #vertical-igv-track-div canvas { @@ -127,26 +118,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 { diff --git a/src/app/ui/components/upper_ribbon/CoolerConverter.vue b/src/app/ui/components/upper_ribbon/CoolerConverter.vue index a79f36a..12753dd 100644 --- a/src/app/ui/components/upper_ribbon/CoolerConverter.vue +++ b/src/app/ui/components/upper_ribbon/CoolerConverter.vue @@ -230,6 +230,7 @@ const emit = defineEmits<{ const props = defineProps<{ networkManager: NetworkManager; + initialCoolerFilename?: string; }>(); const selectedCoolerFilename: Ref = ref(null); @@ -309,6 +310,9 @@ function convertCooler(): void { onMounted(() => { converting.value = false; + if (props.initialCoolerFilename && props.initialCoolerFilename.trim().length > 0) { + selectedCoolerFilename.value = props.initialCoolerFilename; + } modal.value = new Modal(convertCoolerModal.value ?? "loadAGPModal", { backdrop: "static", keyboard: false, diff --git a/src/app/ui/components/upper_ribbon/NavigationBar.vue b/src/app/ui/components/upper_ribbon/NavigationBar.vue index 66aee33..f79d9bc 100644 --- a/src/app/ui/components/upper_ribbon/NavigationBar.vue +++ b/src/app/ui/components/upper_ribbon/NavigationBar.vue @@ -259,6 +259,9 @@ @selected="onFileSelected" @dismissed="onFileDismissed" :error-message="errorMessage" + :title="'Open Hi-C dataset'" + :file-type="'.hict.hdf5, .hict, .cool, .mcool'" + :file-name-predicate="isOpenableAssemblyFilename" > @@ -350,6 +354,7 @@ const openingFile = ref(false); const openingFASTAFile = ref(false); const openingAGPFile = ref(false); const convertingCoolers = ref(false); +const coolerToConvert = ref(undefined); const trackManagerOpen = ref(false); const workerDiagnosticsOpen = ref(false); const saving = ref(false); @@ -463,10 +468,12 @@ function onSessionFileSelected(event: Event): void { } function onConvertCoolersClicked(): void { + coolerToConvert.value = undefined; convertingCoolers.value = true; } function onConvertCoolersDismissed(): void { + coolerToConvert.value = undefined; convertingCoolers.value = false; } @@ -508,20 +515,35 @@ function onAGPFileDismissed() { function onFileSelected(filename: string) { if (filename && filename !== "") { - if (filename.endsWith(".hict") || filename.endsWith(".hict.hdf5")) { + const lowered = filename.toLowerCase(); + if (lowered.endsWith(".hict") || lowered.endsWith(".hict.hdf5")) { openingFile.value = false; emit("selected", filename); - } else if (filename.endsWith(".agp")) { + } else if (lowered.endsWith(".cool") || lowered.endsWith(".mcool")) { + openingFile.value = false; + coolerToConvert.value = filename; + convertingCoolers.value = true; + } else if (lowered.endsWith(".agp")) { openAGP(filename); - } else if (filename.endsWith(".fasta") || filename.endsWith(".fa")) { + } else if (lowered.endsWith(".fasta") || lowered.endsWith(".fa")) { linkFASTA(filename); } else { errorMessage.value = "Unknown type of file to be opened: " + filename; - toast.error("errorMessage.value"); + toast.error(String(errorMessage.value)); } } } +function isOpenableAssemblyFilename(name: string): boolean { + const lowered = name.toLowerCase(); + return ( + lowered.endsWith(".hict.hdf5") || + lowered.endsWith(".hict") || + lowered.endsWith(".cool") || + lowered.endsWith(".mcool") + ); +} + function openAGP(filename: string) { props.networkManager.requestManager .loadAGP(new LoadAGPRequest({ agpFilename: filename })) diff --git a/src/app/ui/components/upper_ribbon/NormalizationSelector.vue b/src/app/ui/components/upper_ribbon/NormalizationSelector.vue index cd7ba1a..c861fb9 100644 --- a/src/app/ui/components/upper_ribbon/NormalizationSelector.vue +++ b/src/app/ui/components/upper_ribbon/NormalizationSelector.vue @@ -161,6 +161,14 @@
  • +
  • + +
  • +
  • + +
  • + diff --git a/src/app/ui/components/upper_ribbon/TrackManager.vue b/src/app/ui/components/upper_ribbon/TrackManager.vue index 5d5ecea..7e315c2 100644 --- a/src/app/ui/components/upper_ribbon/TrackManager.vue +++ b/src/app/ui/components/upper_ribbon/TrackManager.vue @@ -63,9 +63,12 @@ placeholder="Optional" /> -
    +
    +
    @@ -99,6 +102,32 @@ No background jobs yet. +
    +
    + Track panel background +
    +
    + + +
    + +
    +
    +
    +
    mean +
    + + +
    +
    + + +
    -