diff --git a/public/hict-logo-512.png b/public/hict-logo-512.png index a73081e..1d460e5 100644 Binary files a/public/hict-logo-512.png and b/public/hict-logo-512.png differ diff --git a/src/app/core/mapmanagers/CommonEventManager.ts b/src/app/core/mapmanagers/CommonEventManager.ts index ee3bad3..ba0d9a0 100644 --- a/src/app/core/mapmanagers/CommonEventManager.ts +++ b/src/app/core/mapmanagers/CommonEventManager.ts @@ -65,7 +65,9 @@ class CommonEventManager { } public reloadTracks() { - this.mapManager.viewAndLayersManager.reloadTracks(); + this.mapManager.viewAndLayersManager.reloadTracks({ + renderLinearTracks: false, + }); } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -691,12 +693,14 @@ class CommonEventManager { .rightPx, ]; if (!fromPx || !toPx) { - console.log( - "Not exporting FASTA because left selection border is ", + const message = + "Cannot export FASTA for selection because there is no valid selection."; + console.warn(message, { fromPx, - " and right selection border is ", - toPx - ); + toPx, + }); + toast.error(message); + return; } const bpResolution = this.mapManager.viewAndLayersManager.currentViewState.resolutionDesciptor @@ -706,6 +710,23 @@ class CommonEventManager { .map((px) => this.mapManager.contigDimensionHolder.getStartBpOfPx(px, bpResolution) ); + if ( + !Number.isFinite(fromBpX) || + !Number.isFinite(fromBpY) || + !Number.isFinite(toBpX) || + !Number.isFinite(toBpY) + ) { + const message = + "Cannot export FASTA for selection because selection coordinates are invalid."; + console.warn(message, { + fromBpX, + fromBpY, + toBpX, + toBpY, + }); + toast.error(message); + return; + } console.log("Bps: ", fromBpX, fromBpY, toBpX, toBpY, " pxs ", fromPx, toPx); this.mapManager.networkManager.requestManager diff --git a/src/app/core/mapmanagers/ContigDimensionHolder.ts b/src/app/core/mapmanagers/ContigDimensionHolder.ts index e3b002e..e58f5c5 100644 --- a/src/app/core/mapmanagers/ContigDimensionHolder.ts +++ b/src/app/core/mapmanagers/ContigDimensionHolder.ts @@ -42,12 +42,11 @@ export default class ContigDimensionHolder { } public updateContigData(contigDescriptors: ContigDescriptor[]): void { - this.contigDescriptors.length = 0; this.contigDescriptors = contigDescriptors; this.contigIdToOrd.length = 0; - contigDescriptors - .map((descriptor) => descriptor.contigId) - .forEach((id, ord) => (this.contigIdToOrd[id] = ord)); + for (let ord = 0; ord < contigDescriptors.length; ord += 1) { + this.contigIdToOrd[contigDescriptors[ord].contigId] = ord; + } this.resolutions.length = 0; if (contigDescriptors.length > 0) { @@ -60,7 +59,6 @@ export default class ContigDimensionHolder { this.updatePrefixSumBp(); this.updatePrefixSumBins(); this.updatePrefixSumPixels(); - console.log("Contig dimension holder: ", this); } public ensureResolution(resolution: number): void { @@ -121,91 +119,93 @@ export default class ContigDimensionHolder { prefix_sum_px: Map = new Map(); protected updatePrefixSumBp(): void { - this.prefix_sum_bp = []; - - this.prefix_sum_bp.push(0); + this.prefix_sum_bp = new Array(this.contig_count + 1); + this.prefix_sum_bp[0] = 0; if (!this.contigDescriptors || this.contig_count <= 0) { return; } for (let i = 0; i < this.contig_count; ++i) { - this.prefix_sum_bp.push( - this.prefix_sum_bp[i] + this.contigDescriptors[i].contigLengthBp - ); + this.prefix_sum_bp[i + 1] = + this.prefix_sum_bp[i] + this.contigDescriptors[i].contigLengthBp; } } protected updatePrefixSumBins(): void { - this.prefix_sum_bins = new Map(); + const prefixSumBins = new Map(); if (!this.contigDescriptors || this.contig_count <= 0) { + this.prefix_sum_bins = prefixSumBins; return; } for (const resolution of this.resolutions) { - this.prefix_sum_bins.set(resolution, [0]); - } - - this.contigDescriptors.forEach((ctg, i) => { - for (const [ - resolution, - lengthBinsAtResolution, - ] of ctg.contigLengthBins.entries()) { - const resolutionPrefixSum: number[] | undefined = - this.prefix_sum_bins.get(resolution); - if (resolutionPrefixSum) { - resolutionPrefixSum.push( - resolutionPrefixSum[i] + lengthBinsAtResolution - ); - } else { + const prefixSum = new Array(this.contig_count + 1); + prefixSum[0] = 0; + prefixSumBins.set(resolution, prefixSum); + } + + for (let ctgOrder = 0; ctgOrder < this.contig_count; ctgOrder += 1) { + const descriptor = this.contigDescriptors[ctgOrder]; + for (const resolution of this.resolutions) { + const resolutionPrefixSum = prefixSumBins.get(resolution); + const lengthBinsAtResolution = descriptor.contigLengthBins.get( + resolution + ); + if (!resolutionPrefixSum || lengthBinsAtResolution === undefined) { throw new Error( `Unknown resolution ${resolution} in updatePrefixSumBins` ); } + resolutionPrefixSum[ctgOrder + 1] = + resolutionPrefixSum[ctgOrder] + lengthBinsAtResolution; } - }); + } + + this.prefix_sum_bins = prefixSumBins; } protected updatePrefixSumPixels(): void { - this.prefix_sum_px = new Map(); + const prefixSumPx = new Map(); if (!this.contigDescriptors || this.contig_count <= 0) { + this.prefix_sum_px = prefixSumPx; return; } for (const resolution of this.resolutions) { - this.prefix_sum_px.set(resolution, [0]); + const prefixSum = new Array(this.contig_count + 1); + prefixSum[0] = 0; + prefixSumPx.set(resolution, prefixSum); } - this.contigDescriptors.forEach((ctg, ctgOrder) => { - ctg.presenceAtResolution.forEach((hideTypeAtResolution, resolution) => { - const resolutionPrefixSum: number[] | undefined = - this.prefix_sum_px.get(resolution); - if (!resolutionPrefixSum) { - throw new Error( - `Unknown resolution ${resolution} in updatePrefixSumPx` - ); + for (let ctgOrder = 0; ctgOrder < this.contig_count; ctgOrder += 1) { + const descriptor = this.contigDescriptors[ctgOrder]; + for (const resolution of this.resolutions) { + const resolutionPrefixSum = prefixSumPx.get(resolution); + const hideTypeAtResolution = + descriptor.presenceAtResolution.get(resolution); + if (!resolutionPrefixSum || hideTypeAtResolution === undefined) { + throw new Error(`Unknown resolution ${resolution} in updatePrefixSumPx`); } switch (hideTypeAtResolution) { case ContigHideType.AUTO_HIDDEN: case ContigHideType.FORCED_HIDDEN: - resolutionPrefixSum.push(resolutionPrefixSum[ctgOrder]); + resolutionPrefixSum[ctgOrder + 1] = resolutionPrefixSum[ctgOrder]; break; case ContigHideType.AUTO_SHOWN: case ContigHideType.FORCED_SHOWN: { - const lengthBinsAtResolution: number | undefined = - ctg.contigLengthBins.get(resolution); - if (lengthBinsAtResolution) { - const res = - resolutionPrefixSum[ctgOrder] + lengthBinsAtResolution; - resolutionPrefixSum.push(res); - } else { + const lengthBinsAtResolution = + descriptor.contigLengthBins.get(resolution); + if (lengthBinsAtResolution === undefined) { throw new Error( `Unknown resolution ${resolution} in updatePrefixSumPx` ); } + resolutionPrefixSum[ctgOrder + 1] = + resolutionPrefixSum[ctgOrder] + lengthBinsAtResolution; } break; default: @@ -213,8 +213,10 @@ export default class ContigDimensionHolder { `Unrecognized contig hide type: ${hideTypeAtResolution}` ); } - }); - }); + } + } + + this.prefix_sum_px = prefixSumPx; } protected clampBinCoordinateAtResolution( diff --git a/src/app/core/mapmanagers/HiCViewAndLayersManager.ts b/src/app/core/mapmanagers/HiCViewAndLayersManager.ts index e215939..54c274d 100644 --- a/src/app/core/mapmanagers/HiCViewAndLayersManager.ts +++ b/src/app/core/mapmanagers/HiCViewAndLayersManager.ts @@ -124,6 +124,9 @@ interface Track2DHolder { } class HiCViewAndLayersManager { + private static readonly VECTOR_SOURCE_DIRTY_FLAG = + "hictVectorSourceDirty"; + protected readonly contigBorderColor: Ref = ref("ffccee"); public readonly pixelResolutionSet: number[] = []; public readonly resolutions: number[] = []; @@ -330,15 +333,15 @@ class HiCViewAndLayersManager { layers: this.layersHolder.contigTranslocationArrowsLayers, style: new Style({ fill: new Fill({ - color: "rgba(255, 36, 64, 0.7)", + color: "rgba(184, 96, 255, 0.68)", }), stroke: new Stroke({ - color: "rgba(64, 0, 255, 0.1)", - width: 2, + color: "rgba(48, 208, 132, 0.98)", + width: 4, }), }), - hitTolerance: 0, condition: pointerMove, + hitTolerance: 8, // filter: (feature, layer) => { // console.log("Hover over", feature, layer); // return true; @@ -349,14 +352,14 @@ class HiCViewAndLayersManager { layers: this.layersHolder.contigTranslocationArrowsLayers, style: new Style({ fill: new Fill({ - color: "rgba(255, 36, 255, 0.7)", + color: "rgba(208, 120, 255, 0.76)", }), stroke: new Stroke({ - color: "rgba(64, 0, 255, 0.1)", - width: 20, + color: "rgba(48, 208, 132, 1)", + width: 5, }), }), - hitTolerance: 0, + hitTolerance: 8, features: this.selectionCollections.selectedTranslocationArrowsFeatures, condition: singleClick, }), @@ -563,7 +566,11 @@ class HiCViewAndLayersManager { this.track2DHolder.contigBordersTrack.style = this.track2DHolder.contigBordersTrack.generateStyleFunction()(); - this.reloadTracks(); + this.refreshGeneratedFeatureStyles( + this.track2DHolder.contigBordersTrack, + this.layersHolder.contigBordersLayers, + new Set(["contigBorders"]) + ); } public onScanffoldBorderColorChanged(scaffoldBorderColor: string): void { @@ -573,7 +580,11 @@ class HiCViewAndLayersManager { this.track2DHolder.scaffoldBordersTrack.style = this.track2DHolder.scaffoldBordersTrack.generateStyleFunction()(); - this.reloadTracks(); + this.refreshGeneratedFeatureStyles( + this.track2DHolder.scaffoldBordersTrack, + this.layersHolder.scaffoldBordersLayers, + new Set(["scaffoldBorders"]) + ); } public onContigBorderStyleChanged(style: BorderStyle): void { @@ -589,14 +600,22 @@ class HiCViewAndLayersManager { this.track2DHolder.contigBordersTrack.options.width = Math.max(1, width); this.track2DHolder.contigBordersTrack.style = this.track2DHolder.contigBordersTrack.generateStyleFunction()(); - this.reloadTracks(); + this.refreshGeneratedFeatureStyles( + this.track2DHolder.contigBordersTrack, + this.layersHolder.contigBordersLayers, + new Set(["contigBorders"]) + ); } public onContigFillColorChanged(fillColor: string): void { this.track2DHolder.contigBordersTrack.options.fillColor = fillColor; this.track2DHolder.contigBordersTrack.style = this.track2DHolder.contigBordersTrack.generateStyleFunction()(); - this.reloadTracks(); + this.refreshGeneratedFeatureStyles( + this.track2DHolder.contigBordersTrack, + this.layersHolder.contigBordersLayers, + new Set(["contigBorders"]) + ); } public onScanffoldBorderStyleChanged(style: BorderStyle): void { @@ -612,14 +631,22 @@ class HiCViewAndLayersManager { this.track2DHolder.scaffoldBordersTrack.options.width = Math.max(1, width); this.track2DHolder.scaffoldBordersTrack.style = this.track2DHolder.scaffoldBordersTrack.generateStyleFunction()(); - this.reloadTracks(); + this.refreshGeneratedFeatureStyles( + this.track2DHolder.scaffoldBordersTrack, + this.layersHolder.scaffoldBordersLayers, + new Set(["scaffoldBorders"]) + ); } public onScaffoldFillColorChanged(fillColor: string): void { this.track2DHolder.scaffoldBordersTrack.options.fillColor = fillColor; this.track2DHolder.scaffoldBordersTrack.style = this.track2DHolder.scaffoldBordersTrack.generateStyleFunction()(); - this.reloadTracks(); + this.refreshGeneratedFeatureStyles( + this.track2DHolder.scaffoldBordersTrack, + this.layersHolder.scaffoldBordersLayers, + new Set(["scaffoldBorders"]) + ); } public onContigLabelSizeChanged(size: number): void { @@ -831,12 +858,8 @@ class HiCViewAndLayersManager { ); this.getVectorResolutionTuples().forEach( ({ bpResolution, pixelResolution }) => { - const boundingBoxPolygonFeatures = track.features.get(bpResolution); - if (!boundingBoxPolygonFeatures) { - return; - } const vectorSource = new VectorSource(); - vectorSource.addFeatures(boundingBoxPolygonFeatures); + vectorSource.addFeatures(track.features.get(bpResolution) ?? []); const vectorLayer = new ( this.useVectorImageLayer ? VectorImageLayer : VectorLayer )({ @@ -850,6 +873,7 @@ class HiCViewAndLayersManager { "pixelResolution", this.getPixelResolutionForBpResolution(bpResolution) ); + vectorLayer.set(HiCViewAndLayersManager.VECTOR_SOURCE_DIRTY_FLAG, true); layersCollection.push(vectorLayer); this.mapManager.getMap().addLayer(vectorLayer); } @@ -1112,8 +1136,8 @@ class HiCViewAndLayersManager { } public initializeTracks(): void { - this.reloadTracks(); this.addBuiltinVectorLayers(); + this.reloadTracks(); } private addBuiltinVectorLayers(): void { @@ -1186,8 +1210,8 @@ class HiCViewAndLayersManager { ); this.removeLayerCollection(this.layersHolder.scaffoldBordersLayers); this.clearBuiltinVectorLayerMaps(); - this.reloadTracks(); this.addBuiltinVectorLayers(); + this.reloadTracks(); } public initializeMapControls(): void { @@ -1511,100 +1535,174 @@ class HiCViewAndLayersManager { } } - public reloadTracks(): void { - for (const track of this.track2DHolder.tracks2D) { - track.recalculateBorders(); + private markBuiltinVectorLayersDirty(): void { + [ + this.layersHolder.annotationLayers, + this.layersHolder.contigBordersLayers, + this.layersHolder.contigTranslocationArrowsLayers, + this.layersHolder.scaffoldBordersLayers, + ].forEach((layers) => { + layers.forEach((layer) => { + layer.set(HiCViewAndLayersManager.VECTOR_SOURCE_DIRTY_FLAG, true); + }); + }); + } + + private refreshVectorLayerFromFeatures( + layer: Layer, + featuresByResolution: Map[]>, + options?: { force?: boolean; clearWhenHidden?: boolean } + ): void { + const source = layer.getSource() as VectorSource | undefined; + if (!source) { + return; } - this.track2DHolder.contigBordersTrack.recalculateBorders(); - this.track2DHolder.scaffoldBordersTrack.recalculateBorders(); - this.track2DHolder.contigTranslocationArrowsTrack.recalculateBorders(); - for (const layer of this.layersHolder.track2DLayers) { - //TODO: - layer.getSource()?.changed(); - layer.changed(); + + const force = options?.force ?? false; + const clearWhenHidden = options?.clearWhenHidden ?? false; + const visible = layer.getVisible(); + const dirty = Boolean( + layer.get(HiCViewAndLayersManager.VECTOR_SOURCE_DIRTY_FLAG) + ); + if (!force && !dirty) { + return; } - for (const layer of this.layersHolder.annotationLayers) { - const source = layer.getSource() as VectorSource | undefined; - if (source) { - source.clear(); - const features = this.track2DHolder.annotationTrack.features.get( - layer.get("bpResolution") + if (!visible && !clearWhenHidden) { + return; + } + + source.clear(true); + if (visible) { + const bpResolution = Number(layer.get("bpResolution")); + const features = featuresByResolution.get(bpResolution); + if (!features) { + throw new Error( + `Cannot refresh vector track at resolution ${bpResolution}` ); - if (features) { - source.addFeatures(features); - } - source.changed(); } - layer.changed(); + source.addFeatures(features); } - for (const layer of this.layersHolder.contigBordersLayers) { - const source = layer.getSource() as VectorSource | undefined; - if (source) { - source.clear(); - const features = this.track2DHolder.contigBordersTrack.features.get( - layer.get("bpResolution") - ); - if (!features) { - throw new Error( - `Cannot refresh contig borders track at resolution ${layer.get( - "bpResolution" - )}` - ); + source.changed(); + layer.set(HiCViewAndLayersManager.VECTOR_SOURCE_DIRTY_FLAG, false); + layer.changed(); + } + + private refreshGeneratedFeatureStyles( + track: Track2DSymmetric, + layers: Layer[], + trackTypes: Set + ): void { + for (const features of track.features.values()) { + for (const feature of features) { + if (trackTypes.has(String(feature.get("trackType")))) { + feature.setStyle(track.style); } - source.addFeatures(features); - source.changed(); } + } + for (const layer of layers) { + layer.getSource()?.changed(); layer.changed(); } - for (const layer of this.layersHolder.contigTranslocationArrowsLayers) { - console.log( - "reloadTracks: active tool is", - this.currentViewState.activeTool + this.mapManager.getMap().changed(); + } + + private isVectorLayerDirty(layer: Layer | undefined): boolean { + return Boolean( + layer?.get(HiCViewAndLayersManager.VECTOR_SOURCE_DIRTY_FLAG) + ); + } + + private recalculateDirtyVisibleBuiltinVectorTracks(): void { + const activeResolution = + this.getActiveVectorResolutionDescriptor().bpResolution; + const annotationLayer = + this.layersHolder.bpResolutionToAnnotationLayer.get(activeResolution); + const contigLayer = + this.layersHolder.bpResolutionToContigBordersLayer.get(activeResolution); + const scaffoldLayer = + this.layersHolder.bpResolutionToScaffoldBordersLayer.get( + activeResolution ); - layer.setVisible( - this.currentViewState.activeTool === ActiveTool.TRANSLOCATION + const translocationLayer = + this.layersHolder.bpResolutionToContigTranslocationArrowsLayer.get( + activeResolution + ); + + if (annotationLayer?.getVisible() && this.isVectorLayerDirty(annotationLayer)) { + this.track2DHolder.annotationTrack.recalculateBorders(activeResolution); + } + if (contigLayer?.getVisible() && this.isVectorLayerDirty(contigLayer)) { + this.track2DHolder.contigBordersTrack.recalculateBorders( + activeResolution + ); + } + if ( + scaffoldLayer?.getVisible() && + this.isVectorLayerDirty(scaffoldLayer) + ) { + this.track2DHolder.scaffoldBordersTrack.recalculateBorders( + activeResolution + ); + } + if ( + translocationLayer?.getVisible() && + this.isVectorLayerDirty(translocationLayer) + ) { + this.track2DHolder.contigTranslocationArrowsTrack.recalculateBorders( + activeResolution + ); + } + } + + private refreshVisibleBuiltinVectorSources(): void { + this.recalculateDirtyVisibleBuiltinVectorTracks(); + for (const layer of this.layersHolder.annotationLayers) { + this.refreshVectorLayerFromFeatures( + layer, + this.track2DHolder.annotationTrack.features + ); + } + for (const layer of this.layersHolder.contigBordersLayers) { + this.refreshVectorLayerFromFeatures( + layer, + this.track2DHolder.contigBordersTrack.features + ); + } + for (const layer of this.layersHolder.contigTranslocationArrowsLayers) { + this.refreshVectorLayerFromFeatures( + layer, + this.track2DHolder.contigTranslocationArrowsTrack.features, + { clearWhenHidden: true } ); - const source = layer.getSource() as VectorSource | undefined; - if (source) { - source.clear(); - if (layer.getVisible()) { - const features = - this.track2DHolder.contigTranslocationArrowsTrack.features.get( - layer.get("bpResolution") - ); - if (!features) { - throw new Error( - `Cannot refresh contig translocation arrows track at resolution ${layer.get( - "bpResolution" - )}` - ); - } - source.addFeatures(features); - } - source.changed(); - } - layer.changed(); } for (const layer of this.layersHolder.scaffoldBordersLayers) { - const source = layer.getSource() as VectorSource | undefined; - if (source) { - source.clear(); - const features = this.track2DHolder.scaffoldBordersTrack.features.get( - layer.get("bpResolution") - ); - if (!features) { - throw new Error( - `Cannot refresh scaffold borders track at resolution ${layer.get( - "bpResolution" - )}` - ); - } - source.addFeatures(features); - source.changed(); - } + this.refreshVectorLayerFromFeatures( + layer, + this.track2DHolder.scaffoldBordersTrack.features + ); + } + } + + public reloadTracks(options?: { renderLinearTracks?: boolean }): void { + const translocationMode = + this.currentViewState.activeTool === ActiveTool.TRANSLOCATION; + this.markBuiltinVectorLayersDirty(); + for (const layer of this.layersHolder.track2DLayers) { + //TODO: + layer.getSource()?.changed(); layer.changed(); } - void this.mapManager.linearTrackManager.render(); + for (const layer of this.layersHolder.contigTranslocationArrowsLayers) { + layer.setVisible( + translocationMode && + Number(layer.get("bpResolution")) === + this.getActiveVectorResolutionDescriptor().bpResolution + ); + } + this.refreshVisibleBuiltinVectorSources(); + if (options?.renderLinearTracks !== false) { + void this.mapManager.linearTrackManager.render(); + } } public reloadVisuals(): void { @@ -1666,6 +1764,7 @@ class HiCViewAndLayersManager { const layerResolution = Number(layer.get("bpResolution")); layer.setVisible(layerResolution === activeVectorResolution); } + this.refreshVisibleBuiltinVectorSources(); } public addAnnotationMarkerAtCenter(name?: string): void { diff --git a/src/app/core/net/api/RequestManager.ts b/src/app/core/net/api/RequestManager.ts index be61b54..6bb846d 100644 --- a/src/app/core/net/api/RequestManager.ts +++ b/src/app/core/net/api/RequestManager.ts @@ -139,6 +139,7 @@ import { import { toast } from "vue-sonner"; import { useErrorToastStore } from "@/app/stores/errorToastStore"; import VisualizationOptions from "../../visualization/VisualizationOptions"; +import { extractErrorMessage } from "./errorMessage"; export type SecondarySourceCompatibility = { sameResolutions: boolean; @@ -421,8 +422,7 @@ class RequestManager { .catch((err) => { const errorToastStore = useErrorToastStore(); if (!options?.suppressErrorToast && errorToastStore.requestErrorToastsEnabled) { - const message = - err?.response?.data?.error ?? err?.message ?? "Request failed"; + const message = extractErrorMessage(err, "Request failed"); toast.error(message); } throw err; @@ -875,7 +875,7 @@ class RequestManager { .then((response) => response.data) .then((json) => new FastaLinkResponseDTO(json).toEntity()) .catch((err) => { - throw new Error("Cannot link FASTA file: " + err); + throw new Error(extractErrorMessage(err, "Cannot link FASTA file")); }); } @@ -1100,7 +1100,7 @@ class RequestManager { return asmInfo; }) .catch((err) => { - throw new Error("Cannot link AGP file: " + err); + throw new Error(extractErrorMessage(err, "Cannot link AGP file")); }); } @@ -1119,7 +1119,9 @@ class RequestManager { return asmInfo; }) .catch((err) => { - throw new Error("Cannot apply Juicebox assembly: " + err); + throw new Error( + extractErrorMessage(err, "Cannot apply Juicebox assembly") + ); }); } @@ -1129,7 +1131,9 @@ class RequestManager { return this.sendRequest(request, { responseType: "arraybuffer" }) .then((response) => response.data) .catch((err) => { - throw new Error("Cannot download FASTA for assembly: " + err); + throw new Error( + extractErrorMessage(err, "Cannot download FASTA for assembly") + ); }); } @@ -1139,7 +1143,9 @@ class RequestManager { return this.sendRequest(request, { responseType: "arraybuffer" }) .then((response) => response.data) .catch((err) => { - throw new Error("Cannot download AGP for assembly: " + err); + throw new Error( + extractErrorMessage(err, "Cannot download AGP for assembly") + ); }); } @@ -1149,7 +1155,9 @@ class RequestManager { return this.sendRequest(request, { responseType: "arraybuffer" }) .then((response) => response.data) .catch((err) => { - throw new Error("Cannot download FASTA for selection: " + err); + throw new Error( + extractErrorMessage(err, "Cannot download FASTA for selection") + ); }); } diff --git a/src/app/core/net/api/errorMessage.ts b/src/app/core/net/api/errorMessage.ts new file mode 100644 index 0000000..4bf5c49 --- /dev/null +++ b/src/app/core/net/api/errorMessage.ts @@ -0,0 +1,96 @@ +const ERROR_FIELD_KEYS = ["error", "message", "detail", "reason", "info"] as const; + +const ERROR_PREFIX_RE = /^(AxiosError|Error|TypeError|ReferenceError|RangeError|URIError|SyntaxError|EvalError|AggregateError):\s*/; + +const normalizeString = (value: string): string => { + const trimmed = value.trim(); + if (trimmed === "[object Object]") { + return ""; + } + return trimmed.replace(ERROR_PREFIX_RE, ""); +}; + +const decodeBinaryPayload = (value: unknown): string => { + if (typeof ArrayBuffer !== "undefined" && value instanceof ArrayBuffer) { + return new TextDecoder("utf-8").decode(new Uint8Array(value)); + } + if (ArrayBuffer.isView(value)) { + return new TextDecoder("utf-8").decode( + new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + ); + } + return ""; +}; + +const readErrorCandidate = (value: unknown): string => { + if (typeof value === "string") { + const normalized = normalizeString(value); + if (normalized.length === 0) { + return ""; + } + try { + const parsed = JSON.parse(normalized) as unknown; + const nested = readFirstStringField(parsed); + if (nested) { + return nested; + } + } catch { + // Ignore non-JSON strings. + } + return normalized; + } + const binary = decodeBinaryPayload(value); + if (binary.length > 0) { + return readErrorCandidate(binary); + } + return readFirstStringField(value); +}; + +const readFirstStringField = (value: unknown): string => { + if (!value || typeof value !== "object") { + return ""; + } + const record = value as Record; + for (const key of ERROR_FIELD_KEYS) { + const candidate = record[key]; + const readable = readErrorCandidate(candidate); + if (readable.length > 0) { + return readable; + } + } + return ""; +}; + +export const extractErrorMessage = ( + error: unknown, + fallback = "Request failed" +): string => { + if (typeof error === "string") { + return normalizeString(error) || fallback; + } + if (error && typeof error === "object") { + const record = error as Record; + const responseData = + record.response && typeof record.response === "object" + ? (record.response as Record).data + : undefined; + const nested = readErrorCandidate(responseData) || readFirstStringField(responseData); + if (nested) { + return nested; + } + const direct = readFirstStringField(error); + if (direct) { + return direct; + } + if (typeof record.message === "string" && record.message.trim().length > 0) { + return normalizeString(record.message) || fallback; + } + if (typeof record.statusText === "string" && record.statusText.trim().length > 0) { + return normalizeString(record.statusText) || fallback; + } + } + if (error instanceof Error) { + return normalizeString(error.message) || fallback; + } + return fallback; +}; diff --git a/src/app/core/tracks/Track2D.ts b/src/app/core/tracks/Track2D.ts index b54f8b3..2a00faa 100644 --- a/src/app/core/tracks/Track2D.ts +++ b/src/app/core/tracks/Track2D.ts @@ -35,7 +35,7 @@ interface Track2DOptions { abstract class Track2D { public abstract getStyle(): Style; - public abstract recalculateBorders(): void; + public abstract recalculateBorders(bpResolution?: number): void; } export { diff --git a/src/app/core/tracks/Track2DAnnotations.ts b/src/app/core/tracks/Track2DAnnotations.ts index 04dafcb..5ce2b6b 100644 --- a/src/app/core/tracks/Track2DAnnotations.ts +++ b/src/app/core/tracks/Track2DAnnotations.ts @@ -151,13 +151,23 @@ class AnnotationTrack2D extends Track2DSymmetric { this.rectangles.length = 0; } - public recalculateBorders(): void { - this.features = new Map(); - for (const resolution of this.contigDimensionHolder.resolutions) { - this.features.set(resolution, []); + public recalculateBorders(targetBpResolution?: number): void { + if (targetBpResolution === undefined) { + this.features = new Map(); + for (const resolution of this.contigDimensionHolder.resolutions) { + this.features.set(resolution, []); + } + } else { + this.features.set(targetBpResolution, []); } const viewManager = this.mapManager.getLayersManager(); - for (const resolutionTuple of viewManager.getVectorResolutionTuples()) { + for (const resolutionTuple of viewManager + .getVectorResolutionTuples() + .filter( + (tuple) => + targetBpResolution === undefined || + tuple.bpResolution === targetBpResolution + )) { const bpResolution = resolutionTuple.bpResolution; const pixelResolution = resolutionTuple.pixelResolution; const targetFeatures = this.features.get(bpResolution); diff --git a/src/app/core/tracks/Track2DSymmetric.ts b/src/app/core/tracks/Track2DSymmetric.ts index f854724..bb3952b 100644 --- a/src/app/core/tracks/Track2DSymmetric.ts +++ b/src/app/core/tracks/Track2DSymmetric.ts @@ -254,14 +254,23 @@ class BasePairsTrack2DSymmetric extends Track2DSymmetric { ); } - public recalculateBorders(): void { - this.features = new Map(); - for (const resolution of this.contigDimensionHolder.resolutions) { - this.features.set(resolution, []); + public recalculateBorders(targetBpResolution?: number): void { + if (targetBpResolution === undefined) { + this.features = new Map(); + for (const resolution of this.contigDimensionHolder.resolutions) { + this.features.set(resolution, []); + } + } else { + this.features.set(targetBpResolution, []); } this.descriptor.bordersBp.forEach((bordersBp) => { for (const resolutionTuple of this.viewAndLayersManager - .getVectorResolutionTuples()) { + .getVectorResolutionTuples() + .filter( + (tuple) => + targetBpResolution === undefined || + tuple.bpResolution === targetBpResolution + )) { const [fromPx, toPx] = bordersBp.map((bp) => this.contigDimensionHolder.getPxContainingBp( bp, @@ -346,15 +355,25 @@ class ContigBordersTrack2D extends WithRing { ); } - public recalculateBorders(): void { - this.features.clear(); - for (const resolution of this.contigDimensionHolder.resolutions) { - this.features.set(resolution, []); + public recalculateBorders(targetBpResolution?: number): void { + if (targetBpResolution === undefined) { + this.features.clear(); + for (const resolution of this.contigDimensionHolder.resolutions) { + this.features.set(resolution, []); + } + } else { + this.features.set(targetBpResolution, []); } const viewAndLayersManager: HiCViewAndLayersManager = this.mapManager.getLayersManager(); this.contigDimensionHolder.contigDescriptors.forEach((cd, contigOrder) => { cd.presenceAtResolution.forEach((hideType, resolution) => { + if ( + targetBpResolution !== undefined && + resolution !== targetBpResolution + ) { + return; + } switch (hideType) { case ContigHideType.AUTO_HIDDEN: case ContigHideType.FORCED_HIDDEN: @@ -479,10 +498,14 @@ class ScaffoldBordersTrack2D extends WithRing { ); } - public recalculateBorders(): void { - this.features.clear(); - for (const resolution of this.contigDimensionHolder.resolutions) { - this.features.set(resolution, []); + public recalculateBorders(targetBpResolution?: number): void { + if (targetBpResolution === undefined) { + this.features.clear(); + for (const resolution of this.contigDimensionHolder.resolutions) { + this.features.set(resolution, []); + } + } else { + this.features.set(targetBpResolution, []); } const viewAndLayersManager: HiCViewAndLayersManager = this.mapManager.getLayersManager(); @@ -494,6 +517,12 @@ class ScaffoldBordersTrack2D extends WithRing { } this.contigDimensionHolder.prefix_sum_px.forEach( (prefix_sum_px, bpResolution) => { + if ( + targetBpResolution !== undefined && + bpResolution !== targetBpResolution + ) { + return; + } const [startBP, endBP] = [borders.startBP, borders.endBP]; const [fromPx, toPx] = [startBP, endBP].map((bp) => @@ -595,6 +624,9 @@ class ScaffoldBordersTrack2D extends WithRing { } class TranslocationArrowsTrack2D extends Track2DSymmetric { + private static readonly MAX_ARROW_SIZE_PX = 56; + private static readonly MIN_ARROW_SIZE_PX = 12; + public constructor(public readonly mapManager: ContactMapManager) { super( { @@ -603,17 +635,80 @@ class TranslocationArrowsTrack2D extends Track2DSymmetric { }, mapManager.getContigDimensionHolder(), { - borderColor: "rgba(0, 0, 0, 0.0)", - fillColor: "rgba(0, 0, 0, 0.0)", - width: 2, + borderColor: "rgba(48, 208, 132, 0.98)", + fillColor: "rgba(160, 72, 255, 0.46)", + width: 3, zIndex: 12, } ); } - public recalculateBorders(): void { - for (const resolution of this.contigDimensionHolder.resolutions) { - this.features.set(resolution, []); + private getArrowSizePx(fromPx: number, toPx: number): number { + const spanPx = Math.max(0, toPx - fromPx); + if (spanPx <= 0) { + return 0; + } + return Math.min( + TranslocationArrowsTrack2D.MAX_ARROW_SIZE_PX, + Math.max(TranslocationArrowsTrack2D.MIN_ARROW_SIZE_PX, spanPx * 0.5) + ); + } + + private scaleRing( + ring: number[][], + pixelResolution: number + ): number[][] { + return ring.map(([x, y]) => [x * pixelResolution, y * pixelResolution]); + } + + private createLeftContigInsertionTriangle( + fromPx: number, + toPx: number, + pixelResolution: number + ): number[][] | undefined { + const arrowSize = this.getArrowSizePx(fromPx, toPx); + if (arrowSize <= 0) { + return undefined; + } + return this.scaleRing( + [ + [toPx, -toPx], + [toPx, -toPx + arrowSize], + [toPx - arrowSize, -toPx], + [toPx, -toPx], + ], + pixelResolution + ); + } + + private createRightContigInsertionTriangle( + fromPx: number, + toPx: number, + pixelResolution: number + ): number[][] | undefined { + const arrowSize = this.getArrowSizePx(fromPx, toPx); + if (arrowSize <= 0) { + return undefined; + } + return this.scaleRing( + [ + [fromPx, -fromPx], + [fromPx + arrowSize, -fromPx], + [fromPx, -fromPx - arrowSize], + [fromPx, -fromPx], + ], + pixelResolution + ); + } + + public recalculateBorders(targetBpResolution?: number): void { + if (targetBpResolution === undefined) { + this.features.clear(); + for (const resolution of this.contigDimensionHolder.resolutions) { + this.features.set(resolution, []); + } + } else { + this.features.set(targetBpResolution, []); } const viewAndLayersManager: HiCViewAndLayersManager = this.mapManager.getLayersManager(); @@ -621,6 +716,12 @@ class TranslocationArrowsTrack2D extends Track2DSymmetric { | { contigDescriptor: ContigDescriptor; contigOrder: number } | undefined = undefined; this.contigDimensionHolder.resolutions.forEach((resolution) => { + if ( + targetBpResolution !== undefined && + resolution !== targetBpResolution + ) { + return; + } this.contigDimensionHolder.contigDescriptors.forEach( (cd, contigOrder) => { switch (cd.presenceAtResolution.get(resolution)) { @@ -655,19 +756,14 @@ class TranslocationArrowsTrack2D extends Track2DSymmetric { prefixSum[1 + previousShown.contigOrder], ]; - const ringL = [ - [toPxL, -toPxL], - [toPxL, -fromPxL], - [fromPxL, -toPxL], - [toPxL, -toPxL], - ]; - - for (const c of ringL) { - c[0] *= pixelResolution; - c[1] *= pixelResolution; + const ringL = this.createLeftContigInsertionTriangle( + fromPxL, + toPxL, + pixelResolution + ); + if (ringL) { + multiPolygonRings.push([ringL]); } - - multiPolygonRings.push([ringL]); } else { previousShown = { contigDescriptor: cd, @@ -680,20 +776,15 @@ class TranslocationArrowsTrack2D extends Track2DSymmetric { prefixSum[1 + contigOrder], ]; - const ringR = [ - [fromPxR, -fromPxR], - [fromPxR, -toPxR], - [toPxR, -fromPxR], - [fromPxR, -fromPxR], - ]; - - for (const c of ringR) { - c[0] *= pixelResolution; - c[1] *= pixelResolution; + const ringR = this.createRightContigInsertionTriangle( + fromPxR, + toPxR, + pixelResolution + ); + if (ringR) { + multiPolygonRings.push([ringR]); } - multiPolygonRings.push([ringR]); - const lrArrow = new MultiPolygon(multiPolygonRings); const multiPolygonFeature = new Feature({ @@ -756,19 +847,15 @@ class TranslocationArrowsTrack2D extends Track2DSymmetric { prefixSum[1 + previousShown.contigOrder], ]; - const ringR = [ - [toPxR, -toPxR], - [toPxR, -fromPxR], - [fromPxR, -toPxR], - [toPxR, -toPxR], - ]; - - for (const c of ringR) { - c[0] *= pixelResolution; - c[1] *= pixelResolution; + const ringR = this.createLeftContigInsertionTriangle( + fromPxR, + toPxR, + pixelResolution + ); + if (ringR) { + multiPolygonRings.push([ringR]); } - multiPolygonRings.push([ringR]); const rightArrow = new MultiPolygon(multiPolygonRings); const multiPolygonFeature = new Feature({ diff --git a/src/app/stores/notificationCenterStore.ts b/src/app/stores/notificationCenterStore.ts index 2698ee5..2a0e9dd 100644 --- a/src/app/stores/notificationCenterStore.ts +++ b/src/app/stores/notificationCenterStore.ts @@ -22,10 +22,12 @@ export const useNotificationCenterStore = defineStore( const isOpen = ref(false); const keepLast100 = ref(true); const nextId = ref(1); + const expandedEntryIds = ref([]); - const add = (level: NotificationLevel, message: string) => { + const add = (level: NotificationLevel, message: string): number => { + const id = nextId.value++; entries.value.push({ - id: nextId.value++, + id, level, message, createdAt: Date.now(), @@ -33,10 +35,12 @@ export const useNotificationCenterStore = defineStore( if (keepLast100.value && entries.value.length > 100) { entries.value.splice(0, entries.value.length - 100); } + return id; }; const clear = () => { entries.value = []; + expandedEntryIds.value = []; }; const setKeepLast100 = (enabled: boolean) => { @@ -46,13 +50,47 @@ export const useNotificationCenterStore = defineStore( } }; + const isEntryExpanded = (entryId: number): boolean => + expandedEntryIds.value.includes(entryId); + + const expandEntry = (entryId: number): void => { + if (!expandedEntryIds.value.includes(entryId)) { + expandedEntryIds.value = [...expandedEntryIds.value, entryId]; + } + }; + + const collapseEntry = (entryId: number): void => { + expandedEntryIds.value = expandedEntryIds.value.filter( + (value) => value !== entryId + ); + }; + + const toggleEntryExpansion = (entryId: number): void => { + if (isEntryExpanded(entryId)) { + collapseEntry(entryId); + } else { + expandEntry(entryId); + } + }; + + const openAndExpandEntry = (entryId: number): void => { + isOpen.value = true; + expandEntry(entryId); + }; + return { entries, isOpen, keepLast100, + expandedEntryIds, add, clear, setKeepLast100, + isEntryExpanded, + expandEntry, + collapseEntry, + toggleEntryExpansion, + openAndExpandEntry, }; } ); diff --git a/src/app/ui/MainUIComponent.vue b/src/app/ui/MainUIComponent.vue index 58bdc4f..a3248b7 100644 --- a/src/app/ui/MainUIComponent.vue +++ b/src/app/ui/MainUIComponent.vue @@ -129,6 +129,11 @@ import { type SessionVisualizationPreset, } from "@/app/stores/sessionStore"; import { useMatrixViewStore } from "@/app/stores/matrixViewStore"; +import { + dismissTopmostEscDialog, + hasAnyOpenEscDialog, + useEscDismissableDialog, +} from "@/app/ui/escapeDialogRegistry"; // Reactively use these refs only inside component // Pass them to Map Manager on creation as values, not Refs as objects @@ -160,6 +165,12 @@ const openProgressPct = ref(0); let openProgressInFlight = false; const wizardOpen = ref(false); +useEscDismissableDialog({ + priority: 1050, + isOpen: () => openProgressVisible.value, + requestClose: closeOpenProgress, +}); + function startOpenProgress() { if (openProgressTimer !== undefined) { return; @@ -199,6 +210,22 @@ function closeOpenProgress() { openProgressVisible.value = false; } +function handleGlobalEscape(event: KeyboardEvent): void { + if (event.key !== "Escape" || event.repeat) { + return; + } + if (dismissTopmostEscDialog()) { + event.preventDefault(); + event.stopPropagation(); + return; + } + if (!hasAnyOpenEscDialog()) { + mapManager.value?.eventManager.resetSelection(); + event.preventDefault(); + event.stopPropagation(); + } +} + function safeColorTranslator( value: unknown, fallback: string @@ -733,10 +760,12 @@ watch( ); onMounted(() => { + window.addEventListener("keydown", handleGlobalEscape, true); syncUiChromePalette(); }); onUnmounted(() => { + window.removeEventListener("keydown", handleGlobalEscape, true); stopOpenProgress(); }); diff --git a/src/app/ui/components/notifications/NotificationCenterModal.vue b/src/app/ui/components/notifications/NotificationCenterModal.vue index 9ec2401..1507dfe 100644 --- a/src/app/ui/components/notifications/NotificationCenterModal.vue +++ b/src/app/ui/components/notifications/NotificationCenterModal.vue @@ -55,17 +55,22 @@ v-if="hasExpandableBody(entry.message)" type="button" class="btn btn-sm btn-outline-secondary notification-expand-toggle" - @click="toggleExpanded(entry.id)" + @click="notificationStore.toggleEntryExpansion(entry.id)" > - {{ isExpanded(entry.id) ? "Collapse" : "Expand" }} + {{ + notificationStore.isEntryExpanded(entry.id) + ? "Collapse" + : "Expand" + }}
{{ previewMessage(entry.message) }}
-
{{
-                  expandedBody(entry.message)
-                }}
+
{{ expandedBody(entry.message) }}