diff --git a/src/App.vue b/src/App.vue index e5147df..aa48f18 100644 --- a/src/App.vue +++ b/src/App.vue @@ -32,7 +32,7 @@ import MainUIComponent from "@/app/ui/MainUIComponent.vue"; font-family: var(--hict-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - text-align: center; + text-align: left; color: var(--hict-ui-fg, #2c3e50); /* margin-top: 60px; */ } diff --git a/src/app/core/mapmanagers/CommonEventManager.ts b/src/app/core/mapmanagers/CommonEventManager.ts index 6e2d90f..ee3bad3 100644 --- a/src/app/core/mapmanagers/CommonEventManager.ts +++ b/src/app/core/mapmanagers/CommonEventManager.ts @@ -38,6 +38,7 @@ import { MoveSelectionToDebrisRequest, } from "../net/api/request"; import { ContactMapManager } from "./ContactMapManager"; +import { useMatrixViewStore } from "@/app/stores/matrixViewStore"; import { ActiveTool } from "./HiCViewAndLayersManager"; import { BorderStyle, NamePlacement } from "@/app/core/tracks/Track2DSymmetric"; import { Coordinate } from "ol/coordinate"; @@ -681,6 +682,7 @@ class CommonEventManager { } public onExportFASTAForSelectionClicked(): void { + const matrixViewStore = useMatrixViewStore(); this.mapManager.deactivateTranslocation(); const [fromPx, toPx] = [ this.mapManager.viewAndLayersManager.currentViewState.selectionBorders @@ -713,6 +715,8 @@ class CommonEventManager { fromBpY: fromBpY, toBpX: toBpX, toBpY: toBpY, + horizontalSource: matrixViewStore.horizontalFastaSource, + verticalSource: matrixViewStore.verticalFastaSource, }) ) .then((data) => { diff --git a/src/app/core/mapmanagers/ContactMapManager.ts b/src/app/core/mapmanagers/ContactMapManager.ts index 7cdeaf7..80b785b 100644 --- a/src/app/core/mapmanagers/ContactMapManager.ts +++ b/src/app/core/mapmanagers/ContactMapManager.ts @@ -27,7 +27,6 @@ import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; import Feature from "ol/Feature"; import Polygon, { fromExtent } from "ol/geom/Polygon"; -import Fill from "ol/style/Fill"; import Stroke from "ol/style/Stroke"; import Style from "ol/style/Style"; import { transform, transformExtent } from "ol/proj"; @@ -65,6 +64,8 @@ class ContactMapManager { private minimapViewportFeature: Feature | null; private minimapResizeObserver: ResizeObserver | null; private minimapSyncListeners: EventsKey[]; + private minimapPointerCleanup: (() => void)[]; + private minimapDragPointerId: number | null; private minimapRenderFramePending: boolean; constructor( @@ -109,6 +110,8 @@ class ContactMapManager { this.minimapViewportFeature = null; this.minimapResizeObserver = null; this.minimapSyncListeners = []; + this.minimapPointerCleanup = []; + this.minimapDragPointerId = null; this.minimapRenderFramePending = false; } @@ -182,18 +185,15 @@ class ContactMapManager { style: [ new Style({ stroke: new Stroke({ - color: "rgba(255,255,255,0.97)", - width: 4, + color: "rgba(34, 211, 238, 0.98)", + width: 5, }), }), new Style({ stroke: new Stroke({ - color: "rgba(220,38,38,0.98)", + color: "rgba(217, 70, 239, 0.98)", width: 2, }), - fill: new Fill({ - color: "rgba(220,38,38,0.10)", - }), }), ], }); @@ -215,6 +215,7 @@ class ContactMapManager { this.minimap = minimap; this.minimapViewportFeature = viewportFeature; this.fitMinimapToFullExtent(); + this.bindMinimapPointerInteractions(resolvedTarget); this.minimapResizeObserver = new ResizeObserver(() => { this.minimap?.updateSize(); this.fitMinimapToFullExtent(); @@ -238,6 +239,7 @@ class ContactMapManager { public clearOverviewMapTarget(): void { this.minimapResizeObserver?.disconnect(); this.minimapResizeObserver = null; + this.teardownMinimapPointerInteractions(); if (this.minimapSyncListeners.length > 0) { unByKey(this.minimapSyncListeners); this.minimapSyncListeners = []; @@ -1156,6 +1158,7 @@ class ContactMapManager { } public dispose() { + this.visualizationManager.dispose(); this.linearTrackManager.dispose(); this.clearOverviewMapTarget(); this.viewAndLayersManager?.dispose?.(); @@ -1281,6 +1284,103 @@ class ContactMapManager { view.setResolution(this.estimateMinimapResolution(extentTuple, target)); } + private bindMinimapPointerInteractions(target: HTMLElement): void { + this.teardownMinimapPointerInteractions(); + target.style.cursor = "grab"; + target.style.touchAction = "none"; + + const stopDragging = (pointerId?: number): void => { + if ( + pointerId != null && + this.minimapDragPointerId != null && + pointerId !== this.minimapDragPointerId + ) { + return; + } + if (pointerId != null && target.hasPointerCapture?.(pointerId)) { + try { + target.releasePointerCapture(pointerId); + } catch { + // Ignore stale pointer capture state during teardown. + } + } + this.minimapDragPointerId = null; + target.style.cursor = "grab"; + }; + + const onPointerDown = (event: PointerEvent): void => { + if (event.button !== 0 || !this.minimap) { + return; + } + this.minimapDragPointerId = event.pointerId; + target.style.cursor = "grabbing"; + try { + target.setPointerCapture?.(event.pointerId); + } catch { + // Some browsers may reject capture during rapid target swaps. + } + event.preventDefault(); + this.recenterMainViewFromMinimapEvent(event); + }; + + const onPointerMove = (event: PointerEvent): void => { + if (this.minimapDragPointerId !== event.pointerId) { + return; + } + event.preventDefault(); + this.recenterMainViewFromMinimapEvent(event); + }; + + const onPointerUp = (event: PointerEvent): void => { + if (this.minimapDragPointerId !== event.pointerId) { + return; + } + event.preventDefault(); + stopDragging(event.pointerId); + }; + + const onPointerCancel = (event: PointerEvent): void => { + stopDragging(event.pointerId); + }; + + const onWheel = (event: WheelEvent): void => { + if (!this.minimap) { + return; + } + event.preventDefault(); + this.zoomMainViewFromMinimapEvent(event); + }; + + target.addEventListener("pointerdown", onPointerDown); + target.addEventListener("pointermove", onPointerMove); + target.addEventListener("pointerup", onPointerUp); + target.addEventListener("pointercancel", onPointerCancel); + target.addEventListener("wheel", onWheel, { passive: false }); + + this.minimapPointerCleanup = [ + () => target.removeEventListener("pointerdown", onPointerDown), + () => target.removeEventListener("pointermove", onPointerMove), + () => target.removeEventListener("pointerup", onPointerUp), + () => target.removeEventListener("pointercancel", onPointerCancel), + () => target.removeEventListener("wheel", onWheel), + () => stopDragging(), + () => { + target.style.removeProperty("cursor"); + target.style.removeProperty("touch-action"); + }, + ]; + } + + private teardownMinimapPointerInteractions(): void { + if (this.minimapPointerCleanup.length === 0) { + this.minimapDragPointerId = null; + return; + } + this.minimapPointerCleanup.forEach((cleanup) => cleanup()); + this.minimapPointerCleanup = []; + this.minimapDragPointerId = null; + } + private scheduleMinimapViewportSync(): void { if (this.minimapRenderFramePending) { return; @@ -1292,6 +1392,74 @@ class ContactMapManager { }); } + private resolveMainCoordinateFromMinimapEvent( + event: MouseEvent | PointerEvent | WheelEvent + ): [number, number] | null { + if (!this.minimap) { + return null; + } + const minimapView = this.minimap.getView(); + const minimapProjection = minimapView.getProjection(); + const minimapBounds = minimapProjection.getExtent(); + if (!minimapBounds) { + return null; + } + const minimapCoordinate = this.minimap.getEventCoordinate(event); + if ( + !Array.isArray(minimapCoordinate) || + minimapCoordinate.length < 2 || + !minimapCoordinate.every((value) => Number.isFinite(value)) + ) { + return null; + } + const clampedCoordinate = this.clampCoordinateToBounds( + minimapCoordinate as [number, number], + this.getMinimapVisibleBounds( + minimapBounds as [number, number, number, number] + ) + ); + const mainProjection = this.map.getView().getProjection(); + const transformedCoordinate = transform( + clampedCoordinate, + minimapProjection, + mainProjection + ) as [number, number]; + if (!transformedCoordinate.every((value) => Number.isFinite(value))) { + return null; + } + return transformedCoordinate; + } + + private recenterMainViewFromMinimapEvent( + event: MouseEvent | PointerEvent | WheelEvent + ): boolean { + const nextCenter = this.resolveMainCoordinateFromMinimapEvent(event); + if (!nextCenter) { + return false; + } + this.map.getView().setCenter(nextCenter); + return true; + } + + private zoomMainViewFromMinimapEvent(event: WheelEvent): void { + const mainView = this.map.getView(); + const nextCenter = this.resolveMainCoordinateFromMinimapEvent(event); + const currentResolution = mainView.getResolution(); + if (!nextCenter || !Number.isFinite(currentResolution ?? NaN)) { + return; + } + const resolutionRatio = event.deltaY > 0 ? 1.25 : 0.8; + const minResolution = mainView.getMinResolution() ?? currentResolution ?? 1; + const maxResolution = mainView.getMaxResolution() ?? currentResolution ?? 1; + const nextResolution = Math.max( + minResolution, + Math.min(maxResolution, (currentResolution ?? 1) * resolutionRatio) + ); + mainView.cancelAnimations(); + mainView.setCenter(nextCenter); + mainView.setResolution(nextResolution); + } + private syncMinimapViewport(): void { if (!this.minimap || !this.minimapViewportFeature) { return; @@ -1332,28 +1500,73 @@ class ContactMapManager { target.style.backgroundColor = color; } + private getMinimapVisibleBounds( + bounds: [number, number, number, number] + ): [number, number, number, number] { + const minimapResolution = this.minimap?.getView().getResolution() ?? 1; + const inset = Math.max(1e-6, Math.abs(minimapResolution) * 3); + const maxInsetX = Math.max(0, (getWidth(bounds) - 1e-6) / 2); + const maxInsetY = Math.max(0, (getHeight(bounds) - 1e-6) / 2); + const insetX = Math.min(inset, maxInsetX); + const insetY = Math.min(inset, maxInsetY); + return [ + bounds[0] + insetX, + bounds[1] + insetY, + bounds[2] - insetX, + bounds[3] - insetY, + ]; + } + + private clampCoordinateToBounds( + coordinate: [number, number], + bounds: [number, number, number, number] + ): [number, number] { + return [ + Math.max(bounds[0], Math.min(bounds[2], coordinate[0])), + Math.max(bounds[1], Math.min(bounds[3], coordinate[1])), + ]; + } + private clampExtentToBounds( extent: [number, number, number, number], bounds: [number, number, number, number] ): [number, number, number, number] { + const visibleBounds = this.getMinimapVisibleBounds(bounds); if (!intersects(extent, bounds)) { - return bounds; + return visibleBounds; } + const visibleWidth = Math.max(1e-6, getWidth(visibleBounds)); + const visibleHeight = Math.max(1e-6, getHeight(visibleBounds)); + const minimapResolution = Math.abs(this.minimap?.getView().getResolution() ?? 1); + const minimumVisibleSize = Math.max(1e-6, minimapResolution * 4); + const requestedWidth = Math.max(1e-6, extent[2] - extent[0]); + const requestedHeight = Math.max(1e-6, extent[3] - extent[1]); + const width = Math.min( + visibleWidth, + Math.max(requestedWidth, minimumVisibleSize) + ); + const height = Math.min( + visibleHeight, + Math.max(requestedHeight, minimumVisibleSize) + ); 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]; + const centerX = clamp( + (extent[0] + extent[2]) / 2, + visibleBounds[0] + width / 2, + visibleBounds[2] - width / 2 + ); + const centerY = clamp( + (extent[1] + extent[3]) / 2, + visibleBounds[1] + height / 2, + visibleBounds[3] - height / 2 + ); + return [ + centerX - width / 2, + centerY - height / 2, + centerX + width / 2, + centerY + height / 2, + ]; } } diff --git a/src/app/core/mapmanagers/VisualizationManager.ts b/src/app/core/mapmanagers/VisualizationManager.ts index ad8e230..9d89eee 100644 --- a/src/app/core/mapmanagers/VisualizationManager.ts +++ b/src/app/core/mapmanagers/VisualizationManager.ts @@ -26,12 +26,271 @@ import { import { ContactMapManager } from "./ContactMapManager"; import { useVisualizationOptionsStore } from "@/app/stores/visualizationOptionsStore"; import VisualizationOptions from "../visualization/VisualizationOptions"; +import SimpleLinearGradient from "../visualization/colormap/SimpleLinearGradient"; +import type { EventsKey } from "ol/events"; +import { unByKey } from "ol/Observable"; + +type ViewportPixelBounds = { + bpResolution: number; + startRowPx: number; + endRowPx: number; + startColPx: number; + endColPx: number; +}; + +type SourceName = "PRIMARY" | "SECONDARY"; + +type PipelineSignalProfile = { + source: SourceName; + preLogBase: number | null; + postLogBase: number | null; + applyCoolerWeights: boolean; + resolutionScaling: boolean; + resolutionLinearScaling: boolean; +}; + +type ColormapTarget = { + node: Record; + profile: PipelineSignalProfile; + minSignal: number; + maxSignal: number; +}; + +const BUILTIN_COOLER_WEIGHTS_TRACK_ID = "__builtin_cooler_weights__"; + +const clampQuantile = (value: number): number => { + if (!Number.isFinite(value)) { + return 0.995; + } + return Math.min(0.999999, Math.max(0.5, value)); +}; + +const computeFiniteQuantile = (values: Float32Array, quantile: number): number | null => { + const filtered: number[] = []; + for (const value of values) { + if (Number.isFinite(value) && value > 0) { + filtered.push(value); + } + } + if (filtered.length === 0) { + return null; + } + filtered.sort((left, right) => left - right); + const position = Math.min( + filtered.length - 1, + Math.max(0, Math.floor(clampQuantile(quantile) * (filtered.length - 1))) + ); + return filtered[position] ?? filtered[filtered.length - 1] ?? null; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const toFiniteNumber = (value: unknown, fallback: number): number => { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : fallback; +}; + +const normalizePositiveLogBase = (value: unknown): number | null => { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0 || Math.abs(numeric - 1) < 1e-9) { + return null; + } + return numeric; +}; + +const isDynamicFieldNode = (value: unknown, field: string): boolean => + isRecord(value) && + String(value.type ?? "").trim().toLowerCase() === "dynamic" && + String(value.field ?? "").trim().toUpperCase() === field; + +const isCoolerWeightsTrackNode = (value: unknown, axis: "ROW" | "COL"): boolean => + isRecord(value) && + String(value.type ?? "").trim().toLowerCase() === "track1d" && + String(value.trackId ?? "").trim() === BUILTIN_COOLER_WEIGHTS_TRACK_ID && + String(value.axis ?? "").trim().toUpperCase() === axis; + +const isCoolerWeightsExpression = (value: unknown): boolean => { + if (!isRecord(value) || String(value.type ?? "").trim().toLowerCase() !== "binary") { + return false; + } + if (String(value.op ?? "").trim().toUpperCase() !== "MUL") { + return false; + } + const left = value.left; + const right = value.right; + return ( + (isCoolerWeightsTrackNode(left, "ROW") && isCoolerWeightsTrackNode(right, "COL")) || + (isCoolerWeightsTrackNode(left, "COL") && isCoolerWeightsTrackNode(right, "ROW")) + ); +}; + +const extractPipelineSignalProfile = ( + expression: unknown +): PipelineSignalProfile | null => { + if (!isRecord(expression)) { + return null; + } + const type = String(expression.type ?? "").trim().toLowerCase(); + if (type === "source") { + return { + source: + String(expression.source ?? "PRIMARY").trim().toUpperCase() === "SECONDARY" + ? "SECONDARY" + : "PRIMARY", + preLogBase: null, + postLogBase: null, + applyCoolerWeights: false, + resolutionScaling: false, + resolutionLinearScaling: false, + }; + } + if (type === "log") { + const inputProfile = extractPipelineSignalProfile(expression.input); + const base = normalizePositiveLogBase(expression.base); + if (!inputProfile || base == null) { + return null; + } + if (inputProfile.preLogBase == null) { + return { + ...inputProfile, + preLogBase: base, + }; + } + if (inputProfile.postLogBase == null) { + return { + ...inputProfile, + postLogBase: base, + }; + } + return null; + } + if (type === "binary" && String(expression.op ?? "").trim().toUpperCase() === "MUL") { + const left = expression.left; + const right = expression.right; + const childProfile = + extractPipelineSignalProfile(left) ?? extractPipelineSignalProfile(right); + if (!childProfile) { + return null; + } + if ( + isDynamicFieldNode(left, "RESOLUTION_SCALING_COEFF") || + isDynamicFieldNode(right, "RESOLUTION_SCALING_COEFF") + ) { + return { + ...childProfile, + resolutionScaling: true, + }; + } + if ( + isDynamicFieldNode(left, "RESOLUTION_LINEAR_SCALING_COEFF") || + isDynamicFieldNode(right, "RESOLUTION_LINEAR_SCALING_COEFF") + ) { + return { + ...childProfile, + resolutionLinearScaling: true, + }; + } + if (isCoolerWeightsExpression(left) || isCoolerWeightsExpression(right)) { + return { + ...childProfile, + applyCoolerWeights: true, + }; + } + } + return null; +}; + +const collectColormapTargets = ( + expression: unknown, + output: ColormapTarget[] +): void => { + if (!isRecord(expression)) { + return; + } + const type = String(expression.type ?? "").trim().toLowerCase(); + if (type === "colormap") { + const profile = extractPipelineSignalProfile(expression.input); + if (profile) { + output.push({ + node: expression, + profile, + minSignal: toFiniteNumber(expression.minSignal, 0), + maxSignal: toFiniteNumber(expression.maxSignal, 1), + }); + } + } + [ + "input", + "left", + "right", + "top", + "bottom", + "c1", + "c2", + "c3", + "alpha", + ].forEach((key) => { + if (key in expression) { + collectColormapTargets(expression[key], output); + } + }); +}; + +const transformSignalsForProfile = ( + values: Float32Array, + profile: PipelineSignalProfile, + scalingCoefficients: { quadratic: number; linear: number } +): Float32Array => { + const transformed = new Float32Array(values.length); + const preLogDivisor = + profile.preLogBase != null ? Math.log(profile.preLogBase) : null; + const postLogDivisor = + profile.postLogBase != null ? Math.log(profile.postLogBase) : null; + for (let index = 0; index < values.length; index += 1) { + let signal = values[index] ?? 0; + if (!Number.isFinite(signal) || signal < 0) { + signal = 0; + } + if (preLogDivisor != null && preLogDivisor > 0) { + signal = Math.log1p(signal) / preLogDivisor; + } + if (profile.resolutionScaling) { + signal *= scalingCoefficients.quadratic; + } + if (profile.resolutionLinearScaling) { + signal *= scalingCoefficients.linear; + } + if (postLogDivisor != null && postLogDivisor > 0) { + signal = + Number.isFinite(signal) && signal > 0 + ? Math.log1p(signal) / postLogDivisor + : 0; + } + transformed[index] = Number.isFinite(signal) && signal > 0 ? signal : 0; + } + return transformed; +}; class VisualizationManager { public static readonly VISUALIZATION_OPTIONS_UPDATED_EVENT = "hict:visualization-options-updated"; public readonly visualizationOptionsStore = useVisualizationOptionsStore(); - public constructor(public readonly mapManager: ContactMapManager) {} + private readonly moveEndListener?: EventsKey; + private expectedProfileSyncInFlight = false; + private pendingExpectedProfileRefresh = false; + + public constructor(public readonly mapManager: ContactMapManager) { + this.moveEndListener = this.mapManager.getMap().on("moveend", () => { + void this.refreshExpectedProfileOnMoveEnd(); + }); + } + + public dispose(): void { + if (this.moveEndListener) { + unByKey(this.moveEndListener); + } + } public fetchVisualizationOptions(): Promise { return this.mapManager.networkManager.requestManager @@ -47,11 +306,290 @@ class VisualizationManager { }); } - public sendVisualizationOptionsToServer(): Promise { + private async getActiveRenderPipelineConfig(): Promise | null> { + const config = + await this.mapManager.networkManager.requestManager + .getRenderPipelineConfig() + .catch(() => null); + return isRecord(config) && Boolean(config.enabled ?? false) ? config : null; + } + + private resolveResolutionScalingCoefficients(): { + quadratic: number; + linear: number; + } { + const descriptor = + this.mapManager.viewAndLayersManager.currentViewState.resolutionDesciptor; + const resolutions = this.mapManager.getOptions().response.resolutions ?? []; + const resolutionIndex = Math.max( + 0, + Math.trunc( + Number.isFinite(descriptor.imageSizeIndex) ? descriptor.imageSizeIndex : 0 + ) + ); + if (resolutionIndex <= 1) { + return { quadratic: 1, linear: 1 }; + } + const referenceResolution = Number(resolutions[1] ?? descriptor.bpResolution); + const currentResolution = Number( + resolutions[resolutionIndex] ?? descriptor.bpResolution + ); + if ( + !Number.isFinite(referenceResolution) || + !Number.isFinite(currentResolution) || + referenceResolution <= 0 || + currentResolution <= 0 + ) { + return { quadratic: 1, linear: 1 }; + } + const ratio = currentResolution / referenceResolution; + if (!Number.isFinite(ratio) || ratio <= 0) { + return { quadratic: 1, linear: 1 }; + } + return { + quadratic: 1 / (ratio * ratio), + linear: 1 / ratio, + }; + } + + private resolveViewportPixelBounds(paddingPx = 0): ViewportPixelBounds | null { + const size = this.mapManager.map.getSize(); + if (!size || size.length < 2 || size[0] <= 0 || size[1] <= 0) { + return null; + } + const descriptor = + this.mapManager.viewAndLayersManager.currentViewState.resolutionDesciptor; + if ( + !Number.isFinite(descriptor.bpResolution) || + !Number.isFinite(descriptor.pixelResolution) || + descriptor.pixelResolution <= 0 + ) { + return null; + } + const rawMapSizePx = Number( + this.mapManager.viewAndLayersManager.imageSizes[descriptor.imageSizeIndex] ?? NaN + ); + if (!Number.isFinite(rawMapSizePx) || rawMapSizePx <= 0) { + return null; + } + const mapSizePx = Math.max(1, Math.round(rawMapSizePx)); + const extent = this.mapManager.getView().calculateExtent(size); + const pad = Math.max(0, Math.round(paddingPx)); + const clampLowerBound = (value: number): number => + Math.max(0, Math.min(mapSizePx - 1, value)); + const clampUpperBound = (value: number): number => + Math.max(0, Math.min(mapSizePx, value)); + const startColPx = clampLowerBound( + Math.floor((extent[0] ?? 0) / descriptor.pixelResolution) - pad + ); + const endColPx = Math.min( + mapSizePx, + Math.max(startColPx + 1, clampUpperBound( + Math.ceil((extent[2] ?? 0) / descriptor.pixelResolution) + pad + )) + ); + const startRowPx = clampLowerBound( + Math.floor(-(extent[3] ?? 0) / descriptor.pixelResolution) - pad + ); + const endRowPx = Math.min( + mapSizePx, + Math.max(startRowPx + 1, clampUpperBound( + Math.ceil(-(extent[1] ?? 0) / descriptor.pixelResolution) + pad + )) + ); + return { + bpResolution: descriptor.bpResolution, + startRowPx, + endRowPx, + startColPx, + endColPx, + }; + } + + private resolveExpectedProfileBounds(): ViewportPixelBounds | null { + return this.resolveViewportPixelBounds( + Math.max(0, Math.round(this.mapManager.getOptions().tileSize || 0)) + ); + } + + public async syncExpectedProfileToViewport(): Promise { + const options = this.visualizationOptionsStore.asVisualizationOptions(); + if (options.signalDisplayMode === "OBSERVED") { + return false; + } + const bounds = this.resolveExpectedProfileBounds(); + if (!bounds) { + return false; + } + await this.mapManager.networkManager.requestManager.setViewportExpectedProfile( + bounds + ); + return true; + } + + private async refreshExpectedProfileOnMoveEnd(): Promise { + const options = this.visualizationOptionsStore.asVisualizationOptions(); + if (options.signalDisplayMode === "OBSERVED") { + return; + } + if (this.expectedProfileSyncInFlight) { + this.pendingExpectedProfileRefresh = true; + return; + } + this.expectedProfileSyncInFlight = true; + try { + const updated = await this.syncExpectedProfileToViewport(); + if (updated) { + this.mapManager.reloadTiles(); + } + } catch { + // Keep the current view visible even if viewport-profile refresh fails. + } finally { + this.expectedProfileSyncInFlight = false; + if (this.pendingExpectedProfileRefresh) { + this.pendingExpectedProfileRefresh = false; + void this.refreshExpectedProfileOnMoveEnd(); + } + } + } + + public async syncAutoThresholdToViewport(): Promise { + const options = this.visualizationOptionsStore.asVisualizationOptions(); + if (!options.autoThresholdEnabled) { + return null; + } + if (!(options.colormap instanceof SimpleLinearGradient)) { + return null; + } + const bounds = this.resolveViewportPixelBounds(); + if (!bounds) { + return null; + } + const response = await this.mapManager.networkManager.requestManager.queryMatrixFloat32( + { + ...bounds, + signalMode: "TRADITIONAL_NORMALIZED", + } + ); + const nextUpperBound = computeFiniteQuantile( + response.values, + options.autoThresholdQuantile + ); + if ( + nextUpperBound == null || + !Number.isFinite(nextUpperBound) || + nextUpperBound <= options.colormap.minSignal + ) { + return null; + } + if (Math.abs(nextUpperBound - options.colormap.maxSignal) < 1e-9) { + return nextUpperBound; + } + this.visualizationOptionsStore.setVisualizationOptions( + new VisualizationOptions( + options.preLogBase, + options.postLogBase, + options.applyCoolerWeights, + options.resolutionScaling, + options.resolutionLinearScaling, + new SimpleLinearGradient( + options.colormap.startColorRGBA, + options.colormap.endColorRGBA, + options.colormap.minSignal, + nextUpperBound + ), + options.autoThresholdEnabled, + options.autoThresholdQuantile, + options.signalDisplayMode + ) + ); + return nextUpperBound; + } + + private async syncPipelineAutoThresholdToViewport( + config?: Record + ): Promise { + const options = this.visualizationOptionsStore.asVisualizationOptions(); + if (!options.autoThresholdEnabled) { + return false; + } + const bounds = this.resolveViewportPixelBounds(); + if (!bounds) { + return false; + } + const activeConfig = config ?? (await this.getActiveRenderPipelineConfig()); + if (!activeConfig) { + return false; + } + + const targets: ColormapTarget[] = []; + collectColormapTargets(activeConfig.upperExpression, targets); + collectColormapTargets(activeConfig.lowerExpression, targets); + if (targets.length === 0) { + return false; + } + + const scalingCoefficients = this.resolveResolutionScalingCoefficients(); + const thresholdCache = new Map(); + let changed = false; + + for (const target of targets) { + const cacheKey = JSON.stringify(target.profile); + let nextUpperBound = thresholdCache.get(cacheKey); + if (nextUpperBound === undefined) { + const response = + await this.mapManager.networkManager.requestManager.queryMatrixFloat32({ + ...bounds, + source: target.profile.source, + signalMode: target.profile.applyCoolerWeights + ? "COOLER_WEIGHTED" + : "RAW_COUNTS", + }); + nextUpperBound = computeFiniteQuantile( + transformSignalsForProfile( + response.values, + target.profile, + scalingCoefficients + ), + options.autoThresholdQuantile + ); + thresholdCache.set(cacheKey, nextUpperBound); + } + + if ( + nextUpperBound != null && + Number.isFinite(nextUpperBound) && + nextUpperBound > target.minSignal && + Math.abs(nextUpperBound - target.maxSignal) >= 1e-9 + ) { + target.node.maxSignal = nextUpperBound; + changed = true; + } + } + + if (!changed) { + return true; + } + + await this.mapManager.networkManager.requestManager.setRenderPipelineConfig( + activeConfig + ); + await this.mapManager.reloadTilesFromBackend(); + return true; + } + + public async sendVisualizationOptionsToServer(options?: { + skipAutoThresholdRefresh?: boolean; + preserveCustomPipeline?: boolean; + }): Promise { + if (!options?.skipAutoThresholdRefresh) { + await this.syncAutoThresholdToViewport().catch(() => null); + } return this.mapManager.networkManager.requestManager .setVisualizationOptions( new SetVisualizationOptionsRequest({ options: this.visualizationOptionsStore.asVisualizationOptions(), + preserveRenderPipeline: options?.preserveCustomPipeline ?? false, }) ) .then((options) => { @@ -62,8 +600,54 @@ class VisualizationManager { }) ); return options; + }) + .then(async (updatedOptions) => { + if (updatedOptions.signalDisplayMode !== "OBSERVED") { + await this.syncExpectedProfileToViewport(); + } + return updatedOptions; }); } + + public async sendVisualizationOptionsAndReload(options?: { + skipAutoThresholdRefresh?: boolean; + preserveCustomPipeline?: boolean; + }): Promise { + const result = await this.sendVisualizationOptionsToServer(options); + this.mapManager.reloadTiles(); + return result; + } + + public async applyVisualizationSettingsAndReload(): Promise { + if (!this.visualizationOptionsStore.autoThresholdEnabled) { + return this.sendVisualizationOptionsAndReload(); + } + const pipelineConfig = await this.getActiveRenderPipelineConfig(); + const preserveCustomPipeline = pipelineConfig != null; + const result = await this.sendVisualizationOptionsToServer({ + skipAutoThresholdRefresh: true, + preserveCustomPipeline, + }); + await this.refreshAutoThresholdAndReload(); + return result; + } + + public async refreshAutoThresholdAndReload(): Promise { + const pipelineConfig = await this.getActiveRenderPipelineConfig(); + if (pipelineConfig) { + await this.syncPipelineAutoThresholdToViewport(pipelineConfig); + return null; + } + const nextUpperBound = await this.syncAutoThresholdToViewport(); + if (nextUpperBound == null) { + return null; + } + await this.sendVisualizationOptionsToServer({ + skipAutoThresholdRefresh: true, + }); + this.mapManager.reloadTiles(); + return nextUpperBound; + } } export { VisualizationManager }; diff --git a/src/app/core/net/api/RequestManager.ts b/src/app/core/net/api/RequestManager.ts index 3288288..1883ed1 100644 --- a/src/app/core/net/api/RequestManager.ts +++ b/src/app/core/net/api/RequestManager.ts @@ -29,14 +29,17 @@ import { import { HiCTAPIRequestDTO } from "../dto/requestDTO"; import { ConversionJobResponseDTO, + ConversionToolchainStatusResponseDTO, CurrentSignalRangeResponseDTO, FastaLinkResponseDTO, NameMappingResponseDTO, TracksPrecomputeStatusResponseDTO, TrackCompatibilityReportResponseDTO, FileEntryResponseDTO, + MatrixSourceResolutionResponseDTO, TrackFeatureContextResponseDTO, TrackFeatureSearchResponseDTO, + TrackPrecomputeCacheProbeResponseDTO, TrackQueryResponseDTO, TrackSummaryResponseDTO, WorkerSchedulerDiagnosticsResponseDTO, @@ -52,9 +55,12 @@ import { LinkFASTARequest, ListAGPFilesRequest, ListCoolerFilesRequest, + ListConvertibleMatrixFilesRequest, ListFilesDetailedRequest, ListFASTAFilesRequest, ListFilesRequest, + ResolveMatrixSourceRequest, + DropAllCachesRequest, LoadAGPRequest, MoveSelectionRangeRequest, OpenFileRequest, @@ -66,10 +72,12 @@ import { MoveSelectionToDebrisRequest, GetVisualizationOptionsRequest, SetVisualizationOptionsRequest, + SetViewportExpectedProfileRequest, StartBatchConversionJobsRequest, StartConversionJobRequest, ListConversionJobsRequest, GetConversionJobRequest, + GetConversionToolchainStatusRequest, StopConversionJobRequest, RenameContigRequest, RenameScaffoldRequest, @@ -96,6 +104,7 @@ import { GetTrackFeatureContextRequest, StartTracksPrecomputeRequest, GetTracksPrecomputeStatusRequest, + ProbeTrackPrecomputeCacheRequest, GetWorkerDiagnosticsRequest, GetRenderPipelineRequest, SetRenderPipelineRequest, @@ -103,13 +112,16 @@ import { } from "./request"; import { ConversionJobResponse, + ConversionToolchainStatusResponse, CurrentSignalRangeResponse, FastaLinkResponse, FileEntryResponse, + MatrixSourceResolutionResponse, NameMappingResponse, TrackCompatibilityReportResponse, TrackFeatureContextResponse, TrackFeatureSearchResponse, + TrackPrecomputeCacheProbeResponse, TracksPrecomputeStatusResponse, TrackQueryResponse, TrackSummaryResponse, @@ -236,7 +248,8 @@ class RequestManager { public async sendRequest( request: HiCTAPIRequest, - axiosConfig?: AxiosRequestConfig | undefined + axiosConfig?: AxiosRequestConfig | undefined, + options?: { suppressErrorToast?: boolean } ): Promise { const host = this.networkManager.host.replace(/\/+$/, ""); const path = request.requestPath.replace(/^\/+/, ""); @@ -271,7 +284,7 @@ class RequestManager { }) .catch((err) => { const errorToastStore = useErrorToastStore(); - if (errorToastStore.requestErrorToastsEnabled) { + if (!options?.suppressErrorToast && errorToastStore.requestErrorToastsEnabled) { const message = err?.response?.data?.error ?? err?.message ?? "Request failed"; toast.error(message); @@ -398,11 +411,40 @@ class RequestManager { return response.data as string[]; } + public async listConvertibleMatrices(): Promise { + const response = await this.sendRequest( + new ListConvertibleMatrixFilesRequest() + ); + return response.data as string[]; + } + public async listTrackFiles(): Promise { const response = await this.sendRequest(new ListTrackFilesRequest()); return response.data as string[]; } + public async resolveMatrixSource( + filename: string + ): Promise { + return this.sendRequest(new ResolveMatrixSourceRequest({ filename })) + .then((response) => response.data) + .then((json) => new MatrixSourceResolutionResponseDTO(json).toEntity()); + } + + public async dropAllCaches(): Promise<{ + status: string; + matrixMetadataDeleted: number; + trackCacheEntriesDeleted: number; + }> { + return this.sendRequest(new DropAllCachesRequest()) + .then((response) => response.data as Record) + .then((json) => ({ + status: String(json.status ?? "unknown"), + matrixMetadataDeleted: Number(json.matrixMetadataDeleted ?? 0), + trackCacheEntriesDeleted: Number(json.trackCacheEntriesDeleted ?? 0), + })); + } + public async openTrack( filename: string, name?: string, @@ -423,9 +465,14 @@ class RequestManager { } public async probeTrackCompatibility( - filename: string + filename: string, + options?: { suppressErrorToast?: boolean } ): Promise { - return this.sendRequest(new ProbeTrackCompatibilityRequest({ filename })) + return this.sendRequest( + new ProbeTrackCompatibilityRequest({ filename }), + undefined, + options + ) .then((response) => response.data) .then((json) => new TrackCompatibilityReportResponseDTO(json).toEntity()); } @@ -610,6 +657,21 @@ class RequestManager { .then((json) => new TracksPrecomputeStatusResponseDTO(json).toEntity()); } + public async probeTrackPrecomputeCache( + filename: string, + options?: { suppressErrorToast?: boolean } + ): Promise { + return this.sendRequest( + new ProbeTrackPrecomputeCacheRequest({ filename }), + undefined, + options + ) + .then((response) => response.data) + .then((json) => + new TrackPrecomputeCacheProbeResponseDTO(json).toEntity() + ); + } + public async getWorkerDiagnostics(): Promise { return this.sendRequest(new GetWorkerDiagnosticsRequest()) .then((response) => response.data) @@ -688,6 +750,13 @@ class RequestManager { ); } + public async getConversionToolchainStatus(): Promise { + return this.sendRequest(new GetConversionToolchainStatusRequest()).then( + (response) => + new ConversionToolchainStatusResponseDTO(response.data).toEntity() + ); + } + public async renameContig( contigId: number, newName: string | null @@ -745,6 +814,56 @@ class RequestManager { .catch(() => "unknown"); } + public async queryMatrixFloat32(options: { + bpResolution: number; + startRowPx: number; + endRowPx: number; + startColPx: number; + endColPx: number; + source?: "PRIMARY" | "SECONDARY"; + signalMode?: "RAW_COUNTS" | "COOLER_WEIGHTED" | "TRADITIONAL_NORMALIZED" | "PIPELINE_SIGNAL"; + }): Promise<{ + rows: number; + cols: number; + values: Float32Array; + }> { + const host = this.networkManager.host.replace(/\/+$/, ""); + const response = await axios.post( + `${host}/matrix/query`, + { + unit: "PIXELS", + bpResolution: options.bpResolution, + startRowPx: options.startRowPx, + endRowPx: options.endRowPx, + startColPx: options.startColPx, + endColPx: options.endColPx, + source: options.source ?? "PRIMARY", + signalMode: options.signalMode ?? "TRADITIONAL_NORMALIZED", + format: "BINARY_FLOAT32", + }, + { + responseType: "arraybuffer", + headers: { + Accept: "application/octet-stream", + }, + } + ); + const rows = Number.parseInt(response.headers["x-hict-rows"] || "0", 10); + const cols = Number.parseInt(response.headers["x-hict-cols"] || "0", 10); + const buffer = response.data as ArrayBuffer; + const count = Math.max(0, Math.floor(buffer.byteLength / 4)); + const view = new DataView(buffer); + const values = new Float32Array(count); + for (let index = 0; index < count; index += 1) { + values[index] = view.getFloat32(index * 4, true); + } + return { + rows, + cols, + values, + }; + } + public async listAGPFiles(): Promise { const response = await this.sendRequest(new ListAGPFilesRequest()); return response.data as string[]; @@ -880,6 +999,16 @@ class RequestManager { .then((json) => new VisualizationOptionsDTO(json).toEntity()); } + public async setViewportExpectedProfile(options: { + bpResolution: number; + startRowPx: number; + endRowPx: number; + startColPx: number; + endColPx: number; + }): Promise { + await this.sendRequest(new SetViewportExpectedProfileRequest(options)); + } + /* public async loadTilePOSTFunction(tile: Tile, requestPath: string): Promise { assert(tile instanceof ImageTile, "TileLoadPOSTRequest is only applicable for loading ImageTiles"); diff --git a/src/app/core/net/api/request.ts b/src/app/core/net/api/request.ts index 696b7e6..c8f0d08 100644 --- a/src/app/core/net/api/request.ts +++ b/src/app/core/net/api/request.ts @@ -73,10 +73,28 @@ class ListCoolerFilesRequest implements HiCTAPIRequest { requestPath = "/list_coolers"; } +class ListConvertibleMatrixFilesRequest implements HiCTAPIRequest { + requestPath = "/list_convertible_matrices"; +} + class ListTrackFilesRequest implements HiCTAPIRequest { requestPath = "/tracks/list_files"; } +class ResolveMatrixSourceRequest implements HiCTAPIRequest { + requestPath = "/resolve_matrix_source"; + + public constructor( + public readonly options: { + readonly filename: string; + } + ) {} +} + +class DropAllCachesRequest implements HiCTAPIRequest { + requestPath = "/cache/drop_all"; +} + class CloseFileRequest implements HiCTAPIRequest { requestPath = "/close"; } @@ -87,6 +105,12 @@ class AttachSessionRequest implements HiCTAPIRequest { class GetFastaForAssemblyRequest implements HiCTAPIRequest { requestPath = "/get_fasta_for_assembly"; + + public constructor( + public readonly options: { + readonly source?: "PRIMARY" | "SECONDARY"; + } = {} + ) {} } class GetAGPForAssemblyRequest implements HiCTAPIRequest { @@ -205,6 +229,10 @@ class StopConversionJobRequest implements HiCTAPIRequest { } } +class GetConversionToolchainStatusRequest implements HiCTAPIRequest { + requestPath = "/convert/toolchain"; +} + class SetContrastRangeRequest implements HiCTAPIRequest { requestPath = "/set_contrast_range"; @@ -294,6 +322,8 @@ class GetFastaForSelectionRequest implements HiCTAPIRequest { readonly fromBpY: number; readonly toBpX: number; readonly toBpY: number; + readonly horizontalSource?: "PRIMARY" | "SECONDARY"; + readonly verticalSource?: "PRIMARY" | "SECONDARY"; } ) {} } @@ -305,6 +335,7 @@ class LinkFASTARequest implements HiCTAPIRequest { public readonly options: { readonly fastaFilename: string; readonly allowMismatch?: boolean; + readonly source?: "PRIMARY" | "SECONDARY"; } ) {} } @@ -315,6 +346,7 @@ class LoadAGPRequest implements HiCTAPIRequest { public constructor( public readonly options: { readonly agpFilename: string; + readonly source?: "PRIMARY" | "SECONDARY"; } ) {} } @@ -385,6 +417,21 @@ class SetVisualizationOptionsRequest implements HiCTAPIRequest { public constructor( public readonly options: { options: VisualizationOptions; + preserveRenderPipeline?: boolean; + } + ) {} +} + +class SetViewportExpectedProfileRequest implements HiCTAPIRequest { + requestPath = "/visualization/expected_profile"; + + public constructor( + public readonly options: { + readonly bpResolution: number; + readonly startRowPx: number; + readonly endRowPx: number; + readonly startColPx: number; + readonly endColPx: number; } ) {} } @@ -528,6 +575,16 @@ class GetTracksPrecomputeStatusRequest implements HiCTAPIRequest { requestPath = "/tracks/precompute/status"; } +class ProbeTrackPrecomputeCacheRequest implements HiCTAPIRequest { + requestPath = "/tracks/precompute/probe"; + + public constructor( + public readonly options: { + readonly filename: string; + } + ) {} +} + // class TileLoadPOSTRequest implements HiCTAPIRequest { // requestPath = "/get_tile"; @@ -546,11 +603,15 @@ export { AttachSessionRequest, CloseFileRequest, ListCoolerFilesRequest, + ListConvertibleMatrixFilesRequest, + ResolveMatrixSourceRequest, + DropAllCachesRequest, StartConversionJobRequest, StartBatchConversionJobsRequest, ListConversionJobsRequest, GetConversionJobRequest, StopConversionJobRequest, + GetConversionToolchainStatusRequest, RenameContigRequest, RenameScaffoldRequest, ExportNameMappingRequest, @@ -584,6 +645,7 @@ export { MoveSelectionToDebrisRequest, GetVisualizationOptionsRequest, SetVisualizationOptionsRequest, + SetViewportExpectedProfileRequest, ListTrackFilesRequest, OpenTrackRequest, OpenCoolerWeightsTrackRequest, @@ -597,6 +659,7 @@ export { GetTrackFeatureContextRequest, StartTracksPrecomputeRequest, GetTracksPrecomputeStatusRequest, + ProbeTrackPrecomputeCacheRequest, GetWorkerDiagnosticsRequest, GetRenderPipelineRequest, SetRenderPipelineRequest, diff --git a/src/app/core/net/api/response.ts b/src/app/core/net/api/response.ts index 3ad4e51..892494e 100644 --- a/src/app/core/net/api/response.ts +++ b/src/app/core/net/api/response.ts @@ -42,6 +42,10 @@ class ConversionJobResponse { public readonly sourceFilename: string, public readonly outputFilename: string, public readonly direction: string, + public readonly currentStage: string, + public readonly currentStageLabel: string, + public readonly stageDetail: string, + public readonly stageProgress: number, public readonly overallProgress: number, public readonly resolutionProgress: number, public readonly currentResolution: number, @@ -51,11 +55,34 @@ class ConversionJobResponse { public readonly resolutionEtaMillis: number, public readonly inputSizeBytes: number, public readonly outputSizeBytes: number, + public readonly toolchainSource: string, + public readonly toolchainSummary: string, + public readonly toolchainNotices: string[], + public readonly toolchainCitations: string[], public readonly logs: string[], public readonly error: string ) {} } +class ConversionToolchainStatusResponse { + public constructor( + public readonly platform: string, + public readonly source: string, + public readonly supportedPlatform: boolean, + public readonly hicConversionAvailable: boolean, + public readonly hictkAvailable: boolean, + public readonly hictkCommand: string | null, + public readonly coolerAvailable: boolean, + public readonly coolerCommand: string | null, + public readonly pythonAvailable: boolean, + public readonly pythonCommand: string | null, + public readonly summary: string, + public readonly notices: string[], + public readonly citations: string[], + public readonly limitations: string[] + ) {} +} + class NameMappingResponse { public constructor( public readonly contigs: { @@ -224,6 +251,45 @@ class FileEntryResponse { ) {} } +class FileFingerprintResponse { + public constructor( + public readonly sizeBytes: number, + public readonly modifiedAtMs: number, + public readonly sha256: string, + public readonly sha512: string + ) {} +} + +class MatrixSourceResolutionResponse { + public constructor( + public readonly inputFilename: string, + public readonly inputKind: string, + public readonly action: string, + public readonly resolvedFilename: string, + public readonly expectedOutputFilename: string | null, + public readonly conversionDirection: string | null, + public readonly cachedOutputExists: boolean, + public readonly cacheCurrent: boolean, + public readonly warnings: string[], + public readonly sourceFingerprint: FileFingerprintResponse | null, + public readonly outputFingerprint: FileFingerprintResponse | null + ) {} +} + +class TrackPrecomputeCacheProbeResponse { + public constructor( + public readonly filename: string, + public readonly trackType: string, + public readonly supported: boolean, + public readonly cacheAvailable: boolean, + public readonly cacheCurrent: boolean, + public readonly cacheSidecarPath: string, + public readonly warnings: string[], + public readonly sourceFingerprint: FileFingerprintResponse | null, + public readonly hictFingerprint: FileFingerprintResponse + ) {} +} + class WorkerPoolDiagnosticsResponse { public constructor( public readonly corePoolSize: number, @@ -301,6 +367,7 @@ export { CurrentSignalRangeResponse, TilePOSTResponse, ConversionJobResponse, + ConversionToolchainStatusResponse, NameMappingResponse, TrackSummaryResponse, TrackBinBlockResponse, @@ -314,6 +381,9 @@ export { TracksPrecomputeStatusResponse, TrackCompatibilityReportResponse, FileEntryResponse, + FileFingerprintResponse, + MatrixSourceResolutionResponse, + TrackPrecomputeCacheProbeResponse, WorkerPoolDiagnosticsResponse, WorkerCancellationDomainDiagnosticsResponse, WorkerSchedulerDiagnosticsResponse, diff --git a/src/app/core/net/dto/dto.ts b/src/app/core/net/dto/dto.ts index 2bbddc5..66c9708 100644 --- a/src/app/core/net/dto/dto.ts +++ b/src/app/core/net/dto/dto.ts @@ -251,6 +251,9 @@ class VisualizationOptionsDTO extends InboundDTO { applyCoolerWeights: e.applyCoolerWeights, resolutionScaling: e.resolutionScaling, resolutionLinearScaling: e.resolutionLinearScaling, + autoThresholdEnabled: e.autoThresholdEnabled, + autoThresholdQuantile: e.autoThresholdQuantile, + signalDisplayMode: e.signalDisplayMode, colormap: ColormapDTO.fromEntity(e.colormap), }); } @@ -265,7 +268,14 @@ class VisualizationOptionsDTO extends InboundDTO { this.json["resolutionLinearScaling"] as boolean, new ColormapDTO( this.json["colormap"] as Record - ).toEntity() + ).toEntity(), + Boolean(this.json["autoThresholdEnabled"] ?? false), + typeof this.json["autoThresholdQuantile"] === "number" + ? (this.json["autoThresholdQuantile"] as number) + : 0.995, + typeof this.json["signalDisplayMode"] === "string" + ? (this.json["signalDisplayMode"] as VisualizationOptions["signalDisplayMode"]) + : "OBSERVED" ); } } diff --git a/src/app/core/net/dto/requestDTO.ts b/src/app/core/net/dto/requestDTO.ts index 608ecbc..7a426fe 100644 --- a/src/app/core/net/dto/requestDTO.ts +++ b/src/app/core/net/dto/requestDTO.ts @@ -52,6 +52,8 @@ import { GetAGPForAssemblyRequest, ListCoolerFilesRequest, ListTrackFilesRequest, + ResolveMatrixSourceRequest, + DropAllCachesRequest, OpenTrackRequest, OpenCoolerWeightsTrackRequest, ProbeTrackCompatibilityRequest, @@ -64,6 +66,7 @@ import { GetTrackFeatureContextRequest, StartTracksPrecomputeRequest, GetTracksPrecomputeStatusRequest, + ProbeTrackPrecomputeCacheRequest, StartConversionJobRequest, StartBatchConversionJobsRequest, ListConversionJobsRequest, @@ -78,6 +81,7 @@ import { MoveSelectionToDebrisRequest, GetVisualizationOptionsRequest, SetVisualizationOptionsRequest, + SetViewportExpectedProfileRequest, } from "../api/request"; import { ColormapDTO, OutboundDTO, VisualizationOptionsDTO } from "./dto"; @@ -147,6 +151,12 @@ abstract class HiCTAPIRequestDTO< return new ListCoolerFilesRequestDTO(entity); case entity instanceof ListTrackFilesRequest: return new ListTrackFilesRequestDTO(entity); + case entity instanceof ResolveMatrixSourceRequest: + return new ResolveMatrixSourceRequestDTO( + entity as ResolveMatrixSourceRequest + ); + case entity instanceof DropAllCachesRequest: + return new DropAllCachesRequestDTO(entity as DropAllCachesRequest); case entity instanceof OpenTrackRequest: return new OpenTrackRequestDTO(entity as OpenTrackRequest); case entity instanceof OpenCoolerWeightsTrackRequest: @@ -177,6 +187,10 @@ abstract class HiCTAPIRequestDTO< return new StartTracksPrecomputeRequestDTO(entity as StartTracksPrecomputeRequest); case entity instanceof GetTracksPrecomputeStatusRequest: return new GetTracksPrecomputeStatusRequestDTO(entity as GetTracksPrecomputeStatusRequest); + case entity instanceof ProbeTrackPrecomputeCacheRequest: + return new ProbeTrackPrecomputeCacheRequestDTO( + entity as ProbeTrackPrecomputeCacheRequest + ); case entity instanceof ListConversionJobsRequest: return new ListConversionJobsRequestDTO(entity); case entity instanceof GetConversionJobRequest: @@ -253,6 +267,10 @@ abstract class HiCTAPIRequestDTO< return new SetVisualizationOptionsRequestDTO( entity as SetVisualizationOptionsRequest ); + case entity instanceof SetViewportExpectedProfileRequest: + return new SetViewportExpectedProfileRequestDTO( + entity as SetViewportExpectedProfileRequest + ); default: return HiCTAPIRequestDTO.toDTOByRequestPath(entity); } @@ -374,12 +392,29 @@ class SetVisualizationOptionsRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return { + bpResolution: this.entity.options.bpResolution, + startRowPx: this.entity.options.startRowPx, + endRowPx: this.entity.options.endRowPx, + startColPx: this.entity.options.startColPx, + endColPx: this.entity.options.endColPx, + }; + } +} + class ReverseSelectionRangeRequestDTO extends HiCTAPIRequestDTO { toDTO(): Record { return { @@ -516,6 +551,7 @@ class LinkFASTARequestDTO extends HiCTAPIRequestDTO { return { fastaFilename: this.entity.options.fastaFilename, allowMismatch: this.entity.options.allowMismatch, + source: this.entity.options.source, }; } } @@ -523,6 +559,7 @@ class LoadAGPRequestDTO extends HiCTAPIRequestDTO { toDTO(): Record { return { agpFilename: this.entity.options.agpFilename, + source: this.entity.options.source, }; } } @@ -604,6 +641,20 @@ class ListTrackFilesRequestDTO extends HiCTAPIRequestDTO } } +class ResolveMatrixSourceRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return { + filename: this.entity.options.filename, + }; + } +} + +class DropAllCachesRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return {}; + } +} + class OpenTrackRequestDTO extends HiCTAPIRequestDTO { toDTO(): Record { return { @@ -757,6 +808,14 @@ class GetTracksPrecomputeStatusRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return { + filename: this.entity.options.filename, + }; + } +} + class ListFASTAFilesRequestDTO extends HiCTAPIRequestDTO { toDTO(): Record { return {}; @@ -782,10 +841,12 @@ class AttachSessionRequestDTO extends HiCTAPIRequestDTO { class GetFastaForAssemblyRequestDTO extends HiCTAPIRequestDTO { toDTO(): Record { - return {}; + return { + source: this.entity.options.source, + }; } } -class GetAGPForAssemblyRequestDTO extends HiCTAPIRequestDTO { +class GetAGPForAssemblyRequestDTO extends HiCTAPIRequestDTO { toDTO(): Record { return {}; } @@ -798,6 +859,8 @@ class GetFastaForSelectionRequestDTO extends HiCTAPIRequestDTO { this.json["sourceFilename"] as string, this.json["outputFilename"] as string, this.json["direction"] as string, + (this.json["currentStage"] as string) ?? "", + (this.json["currentStageLabel"] as string) ?? "", + (this.json["stageDetail"] as string) ?? "", + (this.json["stageProgress"] as number) ?? 0, this.json["overallProgress"] as number, this.json["resolutionProgress"] as number, this.json["currentResolution"] as number, @@ -104,12 +112,37 @@ class ConversionJobResponseDTO extends InboundDTO { this.json["resolutionEtaMillis"] as number, this.json["inputSizeBytes"] as number, this.json["outputSizeBytes"] as number, + (this.json["toolchainSource"] as string) ?? "", + (this.json["toolchainSummary"] as string) ?? "", + (this.json["toolchainNotices"] as string[]) ?? [], + (this.json["toolchainCitations"] as string[]) ?? [], (this.json["logs"] as string[]) ?? [], (this.json["error"] as string) ?? "" ); } } +class ConversionToolchainStatusResponseDTO extends InboundDTO { + public toEntity(): ConversionToolchainStatusResponse { + return new ConversionToolchainStatusResponse( + (this.json["platform"] as string) ?? "unknown", + (this.json["source"] as string) ?? "unknown", + Boolean(this.json["supportedPlatform"] ?? false), + Boolean(this.json["hicConversionAvailable"] ?? false), + Boolean(this.json["hictkAvailable"] ?? false), + (this.json["hictkCommand"] as string) ?? null, + Boolean(this.json["coolerAvailable"] ?? false), + (this.json["coolerCommand"] as string) ?? null, + Boolean(this.json["pythonAvailable"] ?? false), + (this.json["pythonCommand"] as string) ?? null, + (this.json["summary"] as string) ?? "", + (this.json["notices"] as string[]) ?? [], + (this.json["citations"] as string[]) ?? [], + (this.json["limitations"] as string[]) ?? [] + ); + } +} + class NameMappingResponseDTO extends InboundDTO { public toEntity(): NameMappingResponse { return new NameMappingResponse( @@ -316,6 +349,65 @@ class FileEntryResponseDTO extends InboundDTO { } } +class FileFingerprintResponseDTO extends InboundDTO { + public toEntity(): FileFingerprintResponse { + return new FileFingerprintResponse( + (this.json["sizeBytes"] as number) ?? -1, + (this.json["modifiedAtMs"] as number) ?? 0, + (this.json["sha256"] as string) ?? "", + (this.json["sha512"] as string) ?? "" + ); + } +} + +class MatrixSourceResolutionResponseDTO extends InboundDTO { + public toEntity(): MatrixSourceResolutionResponse { + return new MatrixSourceResolutionResponse( + (this.json["inputFilename"] as string) ?? "", + (this.json["inputKind"] as string) ?? "UNKNOWN", + (this.json["action"] as string) ?? "UNSUPPORTED", + (this.json["resolvedFilename"] as string) ?? "", + (this.json["expectedOutputFilename"] as string) ?? null, + (this.json["conversionDirection"] as string) ?? null, + Boolean(this.json["cachedOutputExists"] ?? false), + Boolean(this.json["cacheCurrent"] ?? false), + (this.json["warnings"] as string[]) ?? [], + this.json["sourceFingerprint"] + ? new FileFingerprintResponseDTO( + this.json["sourceFingerprint"] as Record + ).toEntity() + : null, + this.json["outputFingerprint"] + ? new FileFingerprintResponseDTO( + this.json["outputFingerprint"] as Record + ).toEntity() + : null + ); + } +} + +class TrackPrecomputeCacheProbeResponseDTO extends InboundDTO { + public toEntity(): TrackPrecomputeCacheProbeResponse { + return new TrackPrecomputeCacheProbeResponse( + (this.json["filename"] as string) ?? "", + (this.json["trackType"] as string) ?? "UNSUPPORTED", + Boolean(this.json["supported"] ?? false), + Boolean(this.json["cacheAvailable"] ?? false), + Boolean(this.json["cacheCurrent"] ?? false), + (this.json["cacheSidecarPath"] as string) ?? "", + (this.json["warnings"] as string[]) ?? [], + this.json["sourceFingerprint"] + ? new FileFingerprintResponseDTO( + this.json["sourceFingerprint"] as Record + ).toEntity() + : null, + new FileFingerprintResponseDTO( + (this.json["hictFingerprint"] as Record) ?? {} + ).toEntity() + ); + } +} + class WorkerPoolDiagnosticsResponseDTO extends InboundDTO { public toEntity(): WorkerPoolDiagnosticsResponse { return new WorkerPoolDiagnosticsResponse( @@ -421,6 +513,7 @@ export { CurrentSignalRangeResponseDTO, TilePOSTResponseDTO, ConversionJobResponseDTO, + ConversionToolchainStatusResponseDTO, NameMappingResponseDTO, TrackSummaryResponseDTO, TrackQueryResponseDTO, @@ -429,6 +522,8 @@ export { TracksPrecomputeStatusResponseDTO, TrackCompatibilityReportResponseDTO, FileEntryResponseDTO, + MatrixSourceResolutionResponseDTO, + TrackPrecomputeCacheProbeResponseDTO, WorkerSchedulerDiagnosticsResponseDTO, FastaLinkResponseDTO, }; diff --git a/src/app/core/visualization/VisualizationOptions.ts b/src/app/core/visualization/VisualizationOptions.ts index 8875279..de94f8e 100644 --- a/src/app/core/visualization/VisualizationOptions.ts +++ b/src/app/core/visualization/VisualizationOptions.ts @@ -21,6 +21,11 @@ import Colormap from "./colormap/Colormap"; +export type SignalDisplayMode = + | "OBSERVED" + | "EXPECTED" + | "OBSERVED_OVER_EXPECTED"; + export default class VisualizationOptions { public constructor( public readonly preLogBase: number, @@ -28,6 +33,9 @@ export default class VisualizationOptions { public readonly applyCoolerWeights: boolean | undefined, public readonly resolutionScaling: boolean | undefined, public readonly resolutionLinearScaling: boolean | undefined, - public readonly colormap: Colormap + public readonly colormap: Colormap, + public readonly autoThresholdEnabled: boolean = false, + public readonly autoThresholdQuantile: number = 0.995, + public readonly signalDisplayMode: SignalDisplayMode = "OBSERVED" ) {} } diff --git a/src/app/core/visualization/colormap/default_options.json b/src/app/core/visualization/colormap/default_options.json index ba3e00a..c568a8a 100644 --- a/src/app/core/visualization/colormap/default_options.json +++ b/src/app/core/visualization/colormap/default_options.json @@ -393,6 +393,55 @@ "labelColor": "rgba(255, 255, 0, 1)" } } + }, + { + "option_id": 8, + "options": { + "preLogBase": 10, + "postLogBase": 0, + "applyCoolerWeights": false, + "colormap": { + "colormapType": "SimpleLinearGradient", + "startColorRGBAString": "rgba(255,255,255,1.000000)", + "endColorRGBAString": "rgba(0,0,0,1.000000)", + "minSignal": 0, + "maxSignal": 1 + } + }, + "name": "Dotplot black", + "backgroundColor": "rgb(255,255,255)", + "signalThresholds": { + "lowerSignalBound": 0, + "upperSignalBound": 1 + }, + "trackStyles": { + "contigs": { + "borderColor": "rgba(255, 64, 64, 1.0)", + "fillColor": "rgba(0, 127, 127, 0.0)", + "width": 2, + "labelSize": 12, + "borderStyle": 0, + "namePlacement": "TOP", + "labelBold": true, + "labelOutline": true, + "labelOutlineWidth": 2, + "labelOffsetMultiplier": 1.25, + "labelColor": "rgba(255, 64, 64, 1.0)" + }, + "scaffolds": { + "borderColor": "rgba(255, 255, 0, 1)", + "fillColor": "rgba(64, 64, 255, 0.0)", + "width": 4, + "labelSize": 12, + "borderStyle": 0, + "namePlacement": "TOP", + "labelBold": true, + "labelOutline": true, + "labelOutlineWidth": 2, + "labelOffsetMultiplier": 1.25, + "labelColor": "rgba(255, 255, 0, 1)" + } + } } ] } diff --git a/src/app/core/visualization/presetCatalog.ts b/src/app/core/visualization/presetCatalog.ts new file mode 100644 index 0000000..2619eff --- /dev/null +++ b/src/app/core/visualization/presetCatalog.ts @@ -0,0 +1,271 @@ +/* + Copyright (c) 2021-2026 Aleksandr Serdiukov, Anton Zamyatin, Aleksandr Sinitsyn, Vitalii Dravgelis, Zakhar Lobanov, Nikita Zheleznov and Computer Technologies Laboratory ITMO University team. + + Permission is hereby granted, free of charge, to any person obtaining a copy of + this software and associated documentation files (the "Software"), to deal in + the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import type { TrackStylePresetBundle } from "@/app/core/tracks/TrackStylePreset"; +import defaultOptions from "@/app/core/visualization/colormap/default_options.json"; +import type { SessionVisualizationPreset } from "@/app/stores/sessionStore"; +import VisualizationOptions from "./VisualizationOptions"; +import Colormap from "./colormap/Colormap"; +import SimpleLinearGradient from "./colormap/SimpleLinearGradient"; +import { ColorTranslator } from "colortranslator"; + +export type VisualizationPresetRecord = { + option_id: number; + name: string; + options: VisualizationOptions; + backgroundColor: string; + trackStyles?: TrackStylePresetBundle; + signalThresholds?: { + lowerSignalBound?: number; + upperSignalBound?: number; + }; + origin: "builtin" | "session"; +}; + +type UnknownRecord = Record; + +const isRecord = (value: unknown): value is UnknownRecord => + typeof value === "object" && value !== null; + +const safeColor = (value: unknown, fallback: string): ColorTranslator => { + if (typeof value !== "string" || value.length > 128) { + return new ColorTranslator(fallback, { legacyCSS: true }); + } + try { + return new ColorTranslator(value, { legacyCSS: true }); + } catch { + return new ColorTranslator(fallback, { legacyCSS: true }); + } +}; + +export const serializeVisualizationOptions = ( + options: VisualizationOptions +): Record => { + const cmap = options.colormap; + if (cmap instanceof SimpleLinearGradient) { + return { + preLogBase: options.preLogBase, + postLogBase: options.postLogBase, + applyCoolerWeights: options.applyCoolerWeights ?? false, + resolutionScaling: options.resolutionScaling ?? false, + resolutionLinearScaling: options.resolutionLinearScaling ?? false, + autoThresholdEnabled: options.autoThresholdEnabled ?? false, + autoThresholdQuantile: options.autoThresholdQuantile ?? 0.995, + signalDisplayMode: options.signalDisplayMode ?? "OBSERVED", + colormap: { + colormapType: cmap.colormapType, + startColorRGBAString: cmap.startColorRGBA.RGBA, + endColorRGBAString: cmap.endColorRGBA.RGBA, + minSignal: cmap.minSignal, + maxSignal: cmap.maxSignal, + }, + }; + } + return { + preLogBase: options.preLogBase, + postLogBase: options.postLogBase, + applyCoolerWeights: options.applyCoolerWeights ?? false, + resolutionScaling: options.resolutionScaling ?? false, + resolutionLinearScaling: options.resolutionLinearScaling ?? false, + autoThresholdEnabled: options.autoThresholdEnabled ?? false, + autoThresholdQuantile: options.autoThresholdQuantile ?? 0.995, + signalDisplayMode: options.signalDisplayMode ?? "OBSERVED", + colormap: { + colormapType: options.colormap?.colormapType ?? "Unknown", + }, + }; +}; + +export const deserializeVisualizationOptions = ( + raw: Record +): VisualizationOptions => { + const preLogBase = typeof raw.preLogBase === "number" ? raw.preLogBase : -1; + const postLogBase = + typeof raw.postLogBase === "number" ? raw.postLogBase : 10; + const applyCoolerWeights = + typeof raw.applyCoolerWeights === "boolean" + ? raw.applyCoolerWeights + : false; + const resolutionScaling = + typeof raw.resolutionScaling === "boolean" ? raw.resolutionScaling : false; + const resolutionLinearScaling = + typeof raw.resolutionLinearScaling === "boolean" + ? raw.resolutionLinearScaling + : false; + const autoThresholdEnabled = + typeof raw.autoThresholdEnabled === "boolean" + ? raw.autoThresholdEnabled + : false; + const autoThresholdQuantile = + typeof raw.autoThresholdQuantile === "number" && + Number.isFinite(raw.autoThresholdQuantile) + ? raw.autoThresholdQuantile + : 0.995; + const signalDisplayMode = + raw.signalDisplayMode === "EXPECTED" || + raw.signalDisplayMode === "OBSERVED_OVER_EXPECTED" + ? raw.signalDisplayMode + : "OBSERVED"; + const cmapRaw = isRecord(raw.colormap) ? raw.colormap : {}; + const cmapType = + typeof cmapRaw.colormapType === "string" + ? cmapRaw.colormapType + : "Unknown"; + let cmap: Colormap; + if (cmapType === "SimpleLinearGradient") { + const startColor = + typeof cmapRaw.startColorRGBAString === "string" + ? cmapRaw.startColorRGBAString + : "rgba(0,255,0,0.0)"; + const endColor = + typeof cmapRaw.endColorRGBAString === "string" + ? cmapRaw.endColorRGBAString + : "rgba(0,96,0,1.0)"; + const minSignal = + typeof cmapRaw.minSignal === "number" ? cmapRaw.minSignal : 0; + const maxSignal = + typeof cmapRaw.maxSignal === "number" ? cmapRaw.maxSignal : 1; + cmap = new SimpleLinearGradient( + safeColor(startColor, "rgba(0,255,0,0.0)"), + safeColor(endColor, "rgba(0,96,0,1.0)"), + minSignal, + maxSignal + ); + } else { + cmap = new Colormap(cmapType); + } + return new VisualizationOptions( + preLogBase, + postLogBase, + applyCoolerWeights, + resolutionScaling, + resolutionLinearScaling, + cmap, + autoThresholdEnabled, + autoThresholdQuantile, + signalDisplayMode + ); +}; + +export const getVisualizationSignalThresholds = ( + options: VisualizationOptions +): { + lowerSignalBound?: number; + upperSignalBound?: number; +} => { + const cmap = options.colormap; + if (cmap instanceof SimpleLinearGradient) { + return { + lowerSignalBound: cmap.minSignal, + upperSignalBound: cmap.maxSignal, + }; + } + return {}; +}; + +const toPresetRecord = ( + option: { + option_id?: number; + name?: string; + options?: Record; + backgroundColor?: string; + trackStyles?: TrackStylePresetBundle; + signalThresholds?: { + lowerSignalBound?: number; + upperSignalBound?: number; + }; + }, + optionId: number, + origin: "builtin" | "session" +): VisualizationPresetRecord => ({ + option_id: option.option_id ?? optionId, + name: option.name ?? `Preset ${optionId}`, + options: deserializeVisualizationOptions(option.options ?? {}), + backgroundColor: option.backgroundColor ?? "rgba(255,255,255,1.0)", + trackStyles: option.trackStyles, + signalThresholds: + option.signalThresholds ?? + getVisualizationSignalThresholds( + deserializeVisualizationOptions(option.options ?? {}) + ), + origin, +}); + +const extractBuiltinPresetRows = (): Array<{ + option_id?: number; + name?: string; + options?: Record; + backgroundColor?: string; + trackStyles?: TrackStylePresetBundle; + signalThresholds?: { + lowerSignalBound?: number; + upperSignalBound?: number; + }; +}> => { + const data = isRecord(defaultOptions) + ? ((defaultOptions as UnknownRecord).data as UnknownRecord | undefined) + : undefined; + if (!data) { + return []; + } + const source = Array.isArray(data.savedVisualizationPresets) + ? data.savedVisualizationPresets + : Array.isArray(data.savedLocations) + ? data.savedLocations + : []; + return source.filter(isRecord) as Array<{ + option_id?: number; + name?: string; + options?: Record; + backgroundColor?: string; + trackStyles?: TrackStylePresetBundle; + signalThresholds?: { + lowerSignalBound?: number; + upperSignalBound?: number; + }; + }>; +}; + +export const loadBuiltinVisualizationPresets = (): VisualizationPresetRecord[] => + extractBuiltinPresetRows().map((option, index) => + toPresetRecord(option, index, "builtin") + ); + +export const mergeVisualizationPresets = ( + sessionPresets: SessionVisualizationPreset[] +): VisualizationPresetRecord[] => { + const builtins = loadBuiltinVisualizationPresets(); + const session = sessionPresets.map((preset, index) => + toPresetRecord( + { + option_id: preset.option_id ?? index, + name: preset.name, + options: preset.options, + backgroundColor: preset.backgroundColor, + trackStyles: preset.trackStyles as TrackStylePresetBundle | undefined, + signalThresholds: preset.signalThresholds, + }, + index, + "session" + ) + ); + return [...builtins, ...session]; +}; diff --git a/src/app/core/visualization/renderPipelineWizard.ts b/src/app/core/visualization/renderPipelineWizard.ts new file mode 100644 index 0000000..929b95c --- /dev/null +++ b/src/app/core/visualization/renderPipelineWizard.ts @@ -0,0 +1,240 @@ +/* + Copyright (c) 2021-2026 Aleksandr Serdiukov, Anton Zamyatin, Aleksandr Sinitsyn, Vitalii Dravgelis, Zakhar Lobanov, Nikita Zheleznov and Computer Technologies Laboratory ITMO University team. + + Permission is hereby granted, free of charge, to any person obtaining a copy of + this software and associated documentation files (the "Software"), to deal in + the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import SimpleLinearGradient from "@/app/core/visualization/colormap/SimpleLinearGradient"; +import VisualizationOptions from "@/app/core/visualization/VisualizationOptions"; + +export type WizardViewMode = "single" | "overlay" | "split"; +export type WizardBlendMode = + | "OVER" + | "ADD" + | "SUBTRACT" + | "MULTIPLY" + | "SCREEN" + | "DIFFERENCE" + | "LIGHTEN" + | "DARKEN" + | "XOR"; + +type SourceName = "PRIMARY" | "SECONDARY"; +type TrackAxis = "ROW" | "COL"; + +type PipelineExpression = + | { type: "source"; source: SourceName } + | { type: "track1d"; trackId: string; axis: TrackAxis } + | { + type: "dynamic"; + field: "RESOLUTION_SCALING_COEFF" | "RESOLUTION_LINEAR_SCALING_COEFF"; + } + | { type: "log"; input: PipelineExpression; base: number } + | { + type: "binary"; + op: "MUL"; + left: PipelineExpression; + right: PipelineExpression; + } + | { + type: "colormap"; + mode: "LINEAR"; + input: PipelineExpression; + startColor: string; + endColor: string; + minSignal: number; + maxSignal: number; + } + | { + type: "pixel_blend"; + mode: WizardBlendMode; + top: PipelineExpression; + bottom: PipelineExpression; + topOpacity: number; + bottomOpacity: number; + }; + +const BUILTIN_COOLER_WEIGHTS_TRACK_ID = "__builtin_cooler_weights__"; + +const cloneExpression = (value: T): T => + JSON.parse(JSON.stringify(value)) as T; + +const safeHexa = (value: string, fallback: string): string => { + const normalized = String(value ?? fallback).trim().toLowerCase(); + if (/^#[0-9a-f]{8}$/.test(normalized)) { + return normalized; + } + if (/^#[0-9a-f]{6}$/.test(normalized)) { + return `${normalized}ff`; + } + return fallback; +}; + +const buildSignalExpression = ( + source: SourceName, + options: VisualizationOptions +): PipelineExpression => { + let expression: PipelineExpression = { + type: "source", + source, + }; + if ( + Number.isFinite(options.preLogBase) && + options.preLogBase > 0 && + Math.abs(options.preLogBase - 1) > 1e-9 + ) { + expression = { + type: "log", + input: expression, + base: options.preLogBase, + }; + } + if (options.resolutionScaling) { + expression = { + type: "binary", + op: "MUL", + left: expression, + right: { + type: "dynamic", + field: "RESOLUTION_SCALING_COEFF", + }, + }; + } + if (options.resolutionLinearScaling) { + expression = { + type: "binary", + op: "MUL", + left: expression, + right: { + type: "dynamic", + field: "RESOLUTION_LINEAR_SCALING_COEFF", + }, + }; + } + if (options.applyCoolerWeights) { + expression = { + type: "binary", + op: "MUL", + left: expression, + right: { + type: "binary", + op: "MUL", + left: { + type: "track1d", + trackId: BUILTIN_COOLER_WEIGHTS_TRACK_ID, + axis: "ROW", + }, + right: { + type: "track1d", + trackId: BUILTIN_COOLER_WEIGHTS_TRACK_ID, + axis: "COL", + }, + }, + }; + } + if ( + Number.isFinite(options.postLogBase) && + options.postLogBase > 0 && + Math.abs(options.postLogBase - 1) > 1e-9 + ) { + expression = { + type: "log", + input: expression, + base: options.postLogBase, + }; + } + return expression; +}; + +const buildColorExpression = ( + source: SourceName, + options: VisualizationOptions +): PipelineExpression => { + const gradient = options.colormap; + const linearGradient = + gradient instanceof SimpleLinearGradient ? gradient : undefined; + return { + type: "colormap", + mode: "LINEAR", + input: buildSignalExpression(source, options), + startColor: safeHexa( + linearGradient?.startColorRGBA?.HEXA ?? "#ffffff00", + "#ffffff00" + ), + endColor: safeHexa( + linearGradient?.endColorRGBA?.HEXA ?? "#006000ff", + "#006000ff" + ), + minSignal: + typeof linearGradient?.minSignal === "number" + ? linearGradient.minSignal + : 0, + maxSignal: + typeof linearGradient?.maxSignal === "number" + ? linearGradient.maxSignal + : 1, + }; +}; + +export const buildWizardRenderPipelineConfig = (options: { + viewMode: WizardViewMode; + primaryOptions: VisualizationOptions; + secondaryOptions?: VisualizationOptions; + blendMode?: WizardBlendMode; + topOpacity?: number; + bottomOpacity?: number; +}): Record => { + const primary = buildColorExpression("PRIMARY", options.primaryOptions); + if (options.viewMode === "single" || !options.secondaryOptions) { + return { + enabled: true, + swapUpperLower: false, + upperExpression: primary, + lowerExpression: cloneExpression(primary), + }; + } + + const secondary = buildColorExpression("SECONDARY", options.secondaryOptions); + if (options.viewMode === "overlay") { + const overlay: PipelineExpression = { + type: "pixel_blend", + mode: options.blendMode ?? "OVER", + top: primary, + bottom: secondary, + topOpacity: + typeof options.topOpacity === "number" ? options.topOpacity : 0.5, + bottomOpacity: + typeof options.bottomOpacity === "number" + ? options.bottomOpacity + : 1.0, + }; + return { + enabled: true, + swapUpperLower: false, + upperExpression: overlay, + lowerExpression: cloneExpression(overlay), + }; + } + + return { + enabled: true, + swapUpperLower: false, + upperExpression: primary, + lowerExpression: secondary, + }; +}; diff --git a/src/app/stores/matrixViewStore.ts b/src/app/stores/matrixViewStore.ts new file mode 100644 index 0000000..029daef --- /dev/null +++ b/src/app/stores/matrixViewStore.ts @@ -0,0 +1,38 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; + +export type MatrixPresentationMode = "single" | "overlay" | "split"; +export type MatrixSourceName = "PRIMARY" | "SECONDARY"; + +export const useMatrixViewStore = defineStore("matrixViewStore", () => { + const presentationMode = ref("single"); + const horizontalFastaSource = ref("PRIMARY"); + const verticalFastaSource = ref("PRIMARY"); + + function setPresentationMode(mode: MatrixPresentationMode) { + presentationMode.value = mode; + } + + function setSelectionFastaSources( + horizontal: MatrixSourceName, + vertical: MatrixSourceName + ) { + horizontalFastaSource.value = horizontal; + verticalFastaSource.value = vertical; + } + + function reset() { + presentationMode.value = "single"; + horizontalFastaSource.value = "PRIMARY"; + verticalFastaSource.value = "PRIMARY"; + } + + return { + presentationMode, + horizontalFastaSource, + verticalFastaSource, + setPresentationMode, + setSelectionFastaSources, + reset, + }; +}); diff --git a/src/app/stores/visualizationOptionsStore.ts b/src/app/stores/visualizationOptionsStore.ts index 40c45cc..2669dd6 100644 --- a/src/app/stores/visualizationOptionsStore.ts +++ b/src/app/stores/visualizationOptionsStore.ts @@ -34,6 +34,11 @@ export const useVisualizationOptionsStore = defineStore( const resolutionScaling = ref(false); const resolutionLinearScaling = ref(false); const postLogBase = ref(10); + const autoThresholdEnabled = ref(false); + const autoThresholdQuantile = ref(0.995); + const signalDisplayMode = ref( + "OBSERVED" + ); const colormap: Ref = ref( new SimpleLinearGradient( new ColorTranslator("rgba(0,255,0,0.0)", { legacyCSS: true }), @@ -50,7 +55,10 @@ export const useVisualizationOptionsStore = defineStore( applyCoolerWeights.value, resolutionScaling.value, resolutionLinearScaling.value, - colormap.value + colormap.value, + autoThresholdEnabled.value, + autoThresholdQuantile.value, + signalDisplayMode.value ); } @@ -60,6 +68,9 @@ export const useVisualizationOptionsStore = defineStore( applyCoolerWeights.value = options.applyCoolerWeights ?? false; resolutionScaling.value = options.resolutionScaling ?? false; resolutionLinearScaling.value = options.resolutionLinearScaling ?? false; + autoThresholdEnabled.value = options.autoThresholdEnabled ?? false; + autoThresholdQuantile.value = options.autoThresholdQuantile ?? 0.995; + signalDisplayMode.value = options.signalDisplayMode ?? "OBSERVED"; colormap.value = options.colormap; } @@ -69,6 +80,9 @@ export const useVisualizationOptionsStore = defineStore( resolutionScaling, resolutionLinearScaling, postLogBase, + autoThresholdEnabled, + autoThresholdQuantile, + signalDisplayMode, colormap, asVisualizationOptions, setVisualizationOptions, diff --git a/src/app/ui/MainUIComponent.vue b/src/app/ui/MainUIComponent.vue index 080f355..d1e0262 100644 --- a/src/app/ui/MainUIComponent.vue +++ b/src/app/ui/MainUIComponent.vue @@ -20,7 +20,7 @@ --> diff --git a/src/app/ui/components/upper_ribbon/converter/CoolerFileSelector.vue b/src/app/ui/components/upper_ribbon/converter/CoolerFileSelector.vue index ef51174..3c831a6 100644 --- a/src/app/ui/components/upper_ribbon/converter/CoolerFileSelector.vue +++ b/src/app/ui/components/upper_ribbon/converter/CoolerFileSelector.vue @@ -32,7 +32,7 @@ class="form-select form-select-lg mb-3" v-model="selectedCoolerFilename" > - + @@ -105,7 +105,7 @@ const chooseInitialFilename = ( onMounted(() => { props.networkManager.requestManager - .listCoolers() + .listConvertibleMatrices() .then((lst) => { filenames.value = lst; const initial = chooseInitialFilename(props.initialFilename, lst); diff --git a/src/app/ui/components/workspace/InteractiveWorkspace.vue b/src/app/ui/components/workspace/InteractiveWorkspace.vue index 3d4c747..3679845 100644 --- a/src/app/ui/components/workspace/InteractiveWorkspace.vue +++ b/src/app/ui/components/workspace/InteractiveWorkspace.vue @@ -26,14 +26,24 @@ :style="iwcStyle" >
-
+
-
+
+
+
@@ -92,6 +102,7 @@ const props = defineProps<{ }>(); const workspaceRoot = ref(null); const cornerMiniMapTarget = ref(null); +const cornerMiniMapFallbackTarget = ref(null); const visibleTrackCount = ref(0); let unsubscribeTrackList: (() => void) | undefined; @@ -125,11 +136,15 @@ const bindTrackVisibility = () => { }; const bindCornerMinimap = () => { - if (!props.mapManager || !cornerMiniMapTarget.value || visibleTrackCount.value <= 0) { + const target = + visibleTrackCount.value > 0 + ? cornerMiniMapTarget.value + : cornerMiniMapFallbackTarget.value; + if (!props.mapManager || !target) { props.mapManager?.clearOverviewMapTarget(); return; } - props.mapManager.addOverviewMapTarget(cornerMiniMapTarget.value); + props.mapManager.addOverviewMapTarget(target); }; onMounted(() => { @@ -281,6 +296,8 @@ const rulerPanelCssSize = "44px"; box-sizing: border-box; border: 1px solid rgba(31, 41, 55, 0.55); background: inherit; + cursor: grab; + touch-action: none; } .interactive-workspace_corner_minimap :deep(.ol-viewport) { @@ -309,11 +326,18 @@ const rulerPanelCssSize = "44px"; .interactive-workspace_corner_ruler { grid-area: corner-ruler; + display: flex; + align-items: stretch; + justify-content: stretch; background: inherit; border-right: 1px solid black; border-bottom: 1px solid black; } +.interactive-workspace_corner_minimap--compact { + padding: 1px; +} + .interactive-workspace_horizontal_tracks { grid-area: horizontal-tracks; } diff --git a/src/assets/base.css b/src/assets/base.css index 5056fc2..54cf339 100644 --- a/src/assets/base.css +++ b/src/assets/base.css @@ -22,12 +22,18 @@ /* color palette from */ :root { --hict-font-sans: Avenir, Helvetica, "Segoe UI", Inter, "Helvetica Neue", Arial, sans-serif; - --hict-surface-bg: rgba(255, 255, 255, 0.98); - --hict-surface-bg-muted: rgba(248, 250, 252, 0.98); - --hict-surface-fg: rgba(18, 25, 35, 0.96); - --hict-surface-muted: rgba(73, 84, 99, 0.88); - --hict-surface-border: rgba(15, 23, 38, 0.18); + --hict-ui-bg: rgba(255, 255, 255, 0.98); + --hict-ui-fg: rgba(18, 25, 35, 0.96); + --hict-ui-muted: rgba(73, 84, 99, 0.88); + --hict-ui-border: rgba(15, 23, 38, 0.18); + --hict-ui-outline: rgba(255, 255, 255, 0.92); + --hict-surface-bg: var(--hict-ui-bg); + --hict-surface-bg-muted: var(--hict-ui-bg); + --hict-surface-fg: var(--hict-ui-fg); + --hict-surface-muted: var(--hict-ui-muted); + --hict-surface-border: var(--hict-ui-border); --hict-surface-shadow: 0 20px 55px rgba(15, 23, 38, 0.18); + --hict-surface-close-filter: none; --vt-c-white: #ffffff; --vt-c-white-soft: #f8f8f8; --vt-c-white-mute: #f2f2f2; @@ -109,6 +115,7 @@ body { color: var(--hict-surface-fg); border-color: var(--hict-surface-border); box-shadow: var(--hict-surface-shadow); + text-align: left; } .modal-header, @@ -149,7 +156,7 @@ body { .form-control, .form-select { - background: rgba(255, 255, 255, 0.98); + background: var(--hict-surface-bg-muted); color: var(--hict-surface-fg); border-color: var(--hict-surface-border); } @@ -160,17 +167,18 @@ body { .form-control:focus, .form-select:focus { - background: rgba(255, 255, 255, 1); + background: var(--hict-surface-bg-muted); color: var(--hict-surface-fg); border-color: rgba(37, 99, 235, 0.42); box-shadow: 0 0 0 0.25rem rgba(37, 99, 235, 0.16); } .form-select option { - background: #ffffff; + background: var(--hict-surface-bg); color: var(--hict-surface-fg); } .btn-close { - filter: none; + filter: var(--hict-surface-close-filter); + opacity: 0.82; } diff --git a/src/main.ts b/src/main.ts index 3815ed8..fafb487 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,7 +28,7 @@ import "primeflex/primeflex.css"; import "primevue/resources/themes/lara-light-teal/theme.css"; import "primevue/resources/primevue.min.css"; /* Deprecated */ import "primeicons/primeicons.css"; -// import "./assets/main.css"; +import "./assets/base.css"; import { createApp } from "vue"; import { createPinia } from "pinia"; import { useErrorToastStore } from "@/app/stores/errorToastStore";