diff --git a/src/BinMousePosition.js b/src/BinMousePosition.js index 083c7a1..03f2411 100644 --- a/src/BinMousePosition.js +++ b/src/BinMousePosition.js @@ -28,6 +28,35 @@ import { identityTransform, } from "ol/proj"; import TileLayer from "ol/layer/Tile"; +import { useUiSettingsStore } from "@/app/stores/uiSettingsStore"; + +function getOsdSettings() { + try { + return useUiSettingsStore(); + } catch { + return null; + } +} + +function overlayStyle(position) { + const location = + position === "bottom-left" + ? "left: 12px; bottom: 12px;" + : "right: 12px; top: 12px;"; + return [ + "display: block", + "position: absolute", + location, + "max-width: min(48rem, calc(100vw - 3rem))", + "padding: 14px 16px", + "background: rgba(0, 0, 0, 0.35)", + "border: 1px solid black", + "border-radius: 12px", + "color: white", + "text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000", + "pointer-events: none", + ].join("; "); +} export default class BinMousePosition extends MousePosition { constructor(opt_options) { @@ -133,6 +162,24 @@ export default class BinMousePosition extends MousePosition { this.getDimensionHolderForDescriptor(guidanceDescriptor); const bpResolution = guidanceDescriptor.bpResolution; const pixelResolution = guidanceDescriptor.pixelResolution; + const osdSettings = getOsdSettings(); + if (osdSettings && !osdSettings.osdOverlayVisible) { + html = ""; + if (!this.renderedHTML_ || html !== this.renderedHTML_) { + this.element.innerHTML = html; + this.renderedHTML_ = html; + } + return; + } + const fields = osdSettings?.osdOverlayFields ?? {}; + const fieldOrder = osdSettings?.osdOverlayFieldOrder ?? []; + const osdLines = {}; + const fieldEnabled = (field) => fields[field] !== false; + const appendLine = (field, text) => { + if (fieldEnabled(field)) { + osdLines[field] = text; + } + }; const fixed_coordinates = mapCoordinate.map((c) => Math.ceil(c / pixelResolution) ); @@ -145,12 +192,14 @@ export default class BinMousePosition extends MousePosition { bpResolution ); - html = - '
'; - html += - "Global projection coordinate: " + coordinate.map(Math.floor); - html = html + "<"; - html = html + "br/>"; + this.element.style.position = "absolute"; + this.element.style.inset = "0"; + this.element.style.pointerEvents = "none"; + html = `
`; + appendLine( + "global", + "Global projection coordinate: " + coordinate.map(Math.floor) + ); // html += // "Center coordinate: " + map.getView().getCenter().map(Math.floor); @@ -158,23 +207,21 @@ export default class BinMousePosition extends MousePosition { // html = html + "br/>"; if (fixed_coordinates) { - html = html + "Bin resolution: 1:" + bpResolution; - html = html + "<"; - html = html + "br/>"; + appendLine("resolution", "Bin resolution: 1:" + bpResolution); if ( Number.isFinite(hoveredBpResolution) && hoveredBpResolution !== bpResolution ) { - html = - html + "Hovered tile resolution: 1:" + hoveredBpResolution; - html = html + "<"; - html = html + "br/>"; + appendLine( + "resolution", + "Hovered tile resolution: 1:" + hoveredBpResolution + ); } if (guidanceDescriptor.sourceName) { - html = - html + "Guidance source: " + guidanceDescriptor.sourceName; - html = html + "<"; - html = html + "br/>"; + appendLine( + "source", + "Guidance source: " + guidanceDescriptor.sourceName + ); } if (this.layersManager?.getVisibleSourceResolutionDescriptors) { const visible = @@ -188,18 +235,19 @@ export default class BinMousePosition extends MousePosition { : null, ].filter(Boolean); if (details.length > 0) { - html = - html + "Visible source resolutions: " + details.join("; "); - html = html + "<"; - html = html + "br/>"; + appendLine( + "visibleResolutions", + "Visible source resolutions: " + details.join("; ") + ); } } - html = - html + + appendLine( + "pixels", "Position: px1=" + int_coordinates_px[0] + " px2=" + - int_coordinates_px[1]; + int_coordinates_px[1] + ); } if (dimensionHolder) { @@ -207,16 +255,13 @@ export default class BinMousePosition extends MousePosition { int_coordinates_px, bpResolution ); - html = html + "<"; - html = html + "br/>"; - html = - html + + appendLine( + "bins", "Position: bin1=" + int_coordinates_bins[0] + " bin2=" + - int_coordinates_bins[1]; - html = html + "<"; - html = html + "br/>"; + int_coordinates_bins[1] + ); const bp1 = dimensionHolder.getStartBpOfPx( int_coordinates_px[0], bpResolution @@ -233,10 +278,8 @@ export default class BinMousePosition extends MousePosition { int_coordinates_px[1], bpResolution ); - html = html + "Position: bp1=" + bp1 + " bp2=" + bp2; - html = html + "<"; - html = html + "br/>"; - html = html + "Contigs: ctg1=" + ctg1 + " ctg2=" + ctg2; + appendLine("basePairs", "Position: bp1=" + bp1 + " bp2=" + bp2); + appendLine("contigs", "Contigs: ctg1=" + ctg1 + " ctg2=" + ctg2); if (dimensionHolder.getContigLocusByPx) { const locus1 = dimensionHolder.getContigLocusByPx( int_coordinates_px[0], @@ -246,39 +289,44 @@ export default class BinMousePosition extends MousePosition { int_coordinates_px[1], bpResolution ); - html = html + "<"; - html = html + "br/>"; - html = - html + + appendLine( + "inContig", "In-contig bp: ctg1=+" + locus1.inContigBp + " ctg2=+" + - locus2.inContigBp; + locus2.inContigBp + ); } if (this.scaffold_holder?.getScaffoldLocusByBp) { const scaffold1 = this.scaffold_holder.getScaffoldLocusByBp(bp1); const scaffold2 = this.scaffold_holder.getScaffoldLocusByBp(bp2); - html = html + "<"; - html = html + "br/>"; - html = - html + + appendLine( + "scaffolds", "Scaffolds: scf1=" + (scaffold1 ? scaffold1.scaffoldName : "unscaffolded") + " scf2=" + - (scaffold2 ? scaffold2.scaffoldName : "unscaffolded"); - html = html + "<"; - html = html + "br/>"; - html = - html + + (scaffold2 ? scaffold2.scaffoldName : "unscaffolded") + ); + appendLine( + "inScaffold", "In-scaffold bp: scf1=" + (scaffold1 ? "+" + scaffold1.inScaffoldBp : "n/a") + " scf2=" + - (scaffold2 ? "+" + scaffold2.inScaffoldBp : "n/a"); + (scaffold2 ? "+" + scaffold2.inScaffoldBp : "n/a") + ); } } + const orderedFields = [ + ...fieldOrder, + ...Object.keys(osdLines).filter((field) => !fieldOrder.includes(field)), + ]; + const orderedLines = orderedFields + .map((field) => osdLines[field]) + .filter(Boolean); + html += orderedLines.join("
"); html += "
"; } catch (error) { console.warn("Unable to update map mouse position overlay", error); diff --git a/src/app/core/controls/RulerControl.ts b/src/app/core/controls/RulerControl.ts index 7e4a5c0..7c5590f 100644 --- a/src/app/core/controls/RulerControl.ts +++ b/src/app/core/controls/RulerControl.ts @@ -32,6 +32,7 @@ import { transform } from "ol/proj"; import { storeToRefs } from "pinia"; import { useStyleStore } from "@/app/stores/styleStore"; import { useVisualizationOptionsStore } from "@/app/stores/visualizationOptionsStore"; +import { useUiSettingsStore } from "@/app/stores/uiSettingsStore"; import { Ref } from "vue"; import Colormap from "../visualization/colormap/Colormap"; import { ColorTranslator } from "colortranslator"; @@ -49,6 +50,8 @@ interface RulerTick { mapPx: number; bp: number; label: string; + contigLabel?: string; + scaffoldLabel?: string; major: boolean; boundary?: "start" | "end"; } @@ -62,6 +65,7 @@ class RulerControl extends Control { protected readonly mapBackgroundColor: Ref; protected readonly colormap: Ref; + protected readonly rulerCoordinateMode: Ref<"global" | "contig" | "scaffold">; public readonly canvasSize: number[]; public readonly direction: "vertical" | "horizontal"; @@ -137,9 +141,12 @@ class RulerControl extends Control { const { colormap } = storeToRefs(visualizationOptionsStore); const stylesStore = useStyleStore(); const { mapBackgroundColor } = storeToRefs(stylesStore); + const uiSettingsStore = useUiSettingsStore(); + const { rulerCoordinateMode } = storeToRefs(uiSettingsStore); this.colormap = colormap; this.mapBackgroundColor = mapBackgroundColor as Ref; + this.rulerCoordinateMode = rulerCoordinateMode; this.canvasSize = canvasSize; this.canvas.addEventListener("mousemove", (event) => @@ -544,20 +551,30 @@ class RulerControl extends Control { options.resolutionDescriptor.bpResolution ) ); - const maxBp = Math.max(...bpValues, 0); + const maxBp = Math.max( + ...bpValues.map((bp) => this.coordinateValueForMode(bp)), + 0 + ); const labelUnit = this.absoluteLabelUnit(maxBp); - let currentAnchor = this.roundDownToUnit(bpValues[0] ?? 0, labelUnit); + let currentAnchor = this.roundDownToUnit( + this.coordinateValueForMode(bpValues[0] ?? 0), + labelUnit + ); return screens.map((screen, index) => { const bp = bpValues[index] ?? 0; const mapPx = mapPxValues[index] ?? 0; - const anchor = this.roundDownToUnit(bp, labelUnit); + const coordinateValue = this.coordinateValueForMode(bp); + const anchor = this.roundDownToUnit(coordinateValue, labelUnit); const boundary = index === 0 ? "start" : index === screens.length - 1 ? "end" : undefined; const major = boundary !== undefined || anchor !== currentAnchor; if (major) { currentAnchor = anchor; } - const delta = Math.max(0, Math.round(bp - currentAnchor)); + const delta = Math.max(0, Math.round(coordinateValue - currentAnchor)); + const contig = this.contigDimensionHolder.getContigLocusByBp(bp); + const scaffold = this.mapManager.scaffoldHolder.getScaffoldLocusByBp(bp); + const prefix = this.coordinatePrefixForMode(); return { screen, mapPx, @@ -566,12 +583,39 @@ class RulerControl extends Control { boundary, label: major || delta <= 0 - ? this.formatBpLabel(anchor, 0) + ? `${prefix}${this.formatBpLabel(anchor, 0)}` : `+${this.formatBpLabel(delta, 0)}`, + contigLabel: major && this.rulerCoordinateMode.value === "global" + ? `ctg +${this.formatBpLabel(contig.inContigBp, 0)}` + : undefined, + scaffoldLabel: + major && scaffold && this.rulerCoordinateMode.value === "global" + ? `scf +${this.formatBpLabel(scaffold.inScaffoldBp, 0)}` + : undefined, }; }); } + private coordinateValueForMode(bp: number): number { + if (this.rulerCoordinateMode.value === "contig") { + return this.contigDimensionHolder.getContigLocusByBp(bp).inContigBp; + } + if (this.rulerCoordinateMode.value === "scaffold") { + return this.mapManager.scaffoldHolder.getScaffoldLocusByBp(bp)?.inScaffoldBp ?? bp; + } + return bp; + } + + private coordinatePrefixForMode(): string { + if (this.rulerCoordinateMode.value === "contig") { + return "ctg "; + } + if (this.rulerCoordinateMode.value === "scaffold") { + return "scf "; + } + return ""; + } + private screenPositionToMapPx( screenPosition: number, mapStartScreen: number, @@ -648,6 +692,19 @@ class RulerControl extends Control { true, false ); + if (tick.major && (tick.contigLabel || tick.scaffoldLabel)) { + this.drawRotatedText( + [tick.contigLabel, tick.scaffoldLabel].filter(Boolean).join(" / "), + textX, + Math.max(22, coord[1] - tickLength + 10), + context, + 0, + "9px sans-serif", + textAlign, + true, + false + ); + } return; } @@ -663,6 +720,19 @@ class RulerControl extends Control { true, false ); + if (tick.major && (tick.contigLabel || tick.scaffoldLabel)) { + this.drawRotatedText( + [tick.contigLabel, tick.scaffoldLabel].filter(Boolean).join(" / "), + Math.max(2, coord[0] - tickLength - 4), + this.clamp(textY + 12, 12, this.canvas.height - 3), + context, + 0, + "9px sans-serif", + "right", + true, + false + ); + } } private formatBpLabel(bp: number, precision: number): string { diff --git a/src/app/core/mapmanagers/HiCViewAndLayersManager.ts b/src/app/core/mapmanagers/HiCViewAndLayersManager.ts index 54c274d..e522ff9 100644 --- a/src/app/core/mapmanagers/HiCViewAndLayersManager.ts +++ b/src/app/core/mapmanagers/HiCViewAndLayersManager.ts @@ -161,6 +161,9 @@ class HiCViewAndLayersManager { private secondaryResolutionSet?: SourceResolutionDescriptorSet; private wheelZoomInteraction?: ContigMouseWheelZoom; private coordinateBaseBp: number; + private enabledBpResolutions?: Set; + private rulerRenderCallback: (() => void) | null = null; + private readonly pendingFeatureStyleRefreshHandles = new Set(); public selectionCollections: { readonly selectedContigFeatures: Collection>; @@ -428,10 +431,74 @@ class HiCViewAndLayersManager { resolutions: number[]; pixelResolutionSet: number[]; } { - return getNavigationResolutionModelForSets( - this.primaryResolutionSet, - this.secondaryResolutionSet + const descriptors = [ + ...this.getEnabledResolutionTuples(this.primaryResolutionSet), + ...(this.secondaryResolutionSet + ? this.getEnabledResolutionTuples(this.secondaryResolutionSet) + : []), + ].sort((a, b) => a.pixelResolution - b.pixelResolution); + const byPixelResolution = new Map(); + for (const descriptor of descriptors) { + if (!byPixelResolution.has(descriptor.pixelResolution)) { + byPixelResolution.set(descriptor.pixelResolution, descriptor); + } + } + const uniqueDescriptors = [...byPixelResolution.values()].sort( + (a, b) => a.pixelResolution - b.pixelResolution + ); + if (uniqueDescriptors.length === 0) { + return getNavigationResolutionModelForSets( + this.primaryResolutionSet, + this.secondaryResolutionSet + ); + } + return { + resolutions: uniqueDescriptors.map((descriptor) => descriptor.bpResolution), + pixelResolutionSet: uniqueDescriptors.map( + (descriptor) => descriptor.pixelResolution + ), + }; + } + + public getAllNavigationBpResolutions(): number[] { + return Array.from( + new Set([ + ...this.primaryResolutionSet.resolutions, + ...(this.secondaryResolutionSet?.resolutions ?? []), + ]) + ).sort((a, b) => a - b); + } + + public getEnabledBpResolutions(): number[] { + const all = this.getAllNavigationBpResolutions(); + if (!this.enabledBpResolutions || this.enabledBpResolutions.size === 0) { + return all; + } + return all.filter((resolution) => this.enabledBpResolutions?.has(resolution)); + } + + public setEnabledBpResolutions(resolutions: number[]): void { + const valid = new Set(this.getAllNavigationBpResolutions()); + const next = resolutions + .map((resolution) => Number(resolution)) + .filter((resolution) => Number.isFinite(resolution) && valid.has(resolution)); + this.enabledBpResolutions = next.length > 0 ? new Set(next) : undefined; + this.updateWheelZoomResolutionModel(); + this.updateProjectionAndViewExtent(1); + this.updateCurrentHiCViewState(); + this.mapManager.getMap().changed(); + } + + private getEnabledResolutionTuples( + set: SourceResolutionDescriptorSet + ): LayerResolutionDescriptor[] { + if (!this.enabledBpResolutions || this.enabledBpResolutions.size === 0) { + return set.resolutionTuples; + } + const filtered = set.resolutionTuples.filter((tuple) => + this.enabledBpResolutions?.has(tuple.bpResolution) ); + return filtered.length > 0 ? filtered : set.resolutionTuples; } public getVectorResolutionTuples(): LayerResolutionDescriptor[] { @@ -848,14 +915,6 @@ class HiCViewAndLayersManager { track: Track2DSymmetric, layersCollection: Layer[] = this.layersHolder.track2DLayers ) { - console.log( - "Adding track: ", - track, - " is it Track2DSymmetric: ", - track instanceof Track2DSymmetric, - " is it ContigBordersTrack2D: ", - track instanceof ContigBordersTrack2D - ); this.getVectorResolutionTuples().forEach( ({ bpResolution, pixelResolution }) => { const vectorSource = new VectorSource(); @@ -989,7 +1048,24 @@ class HiCViewAndLayersManager { if (!set) { throw new Error(`No resolutions available for ${sourceName}`); } - return getResolutionDescriptorForViewResolution(set, viewResolution); + const tuples = this.getEnabledResolutionTuples(set); + if (tuples.length === set.resolutionTuples.length) { + return getResolutionDescriptorForViewResolution(set, viewResolution); + } + let descriptor = tuples[0]; + const sorted = [...tuples].sort( + (a, b) => + a.layerResolutionBorders.minResolutionInclusive - + b.layerResolutionBorders.minResolutionInclusive + ); + for (const tuple of sorted) { + if (tuple.layerResolutionBorders.minResolutionInclusive <= viewResolution) { + descriptor = tuple; + } else { + break; + } + } + return descriptor; } public getVisibleSourceResolutionDescriptors(): { @@ -1251,6 +1327,7 @@ class HiCViewAndLayersManager { rulerV.render({ map } as never); }); }; + this.rulerRenderCallback = scheduleRulerRender; map.on("moveend", scheduleRulerRender); view.on("change:center", scheduleRulerRender); view.on("change:resolution", scheduleRulerRender); @@ -1262,11 +1339,20 @@ class HiCViewAndLayersManager { } } + public scheduleRulerRender(): void { + this.rulerRenderCallback?.(); + } + public dispose(): void { if (!this.mapManager.getMap()) { return; } try { + for (const handle of this.pendingFeatureStyleRefreshHandles) { + window.clearTimeout(handle); + } + this.pendingFeatureStyleRefreshHandles.clear(); + this.rulerRenderCallback = null; this.mapManager.getMap().getControls().clear(); const hRuler = document.getElementById("horizontal-ruler-div"); if (hRuler) { @@ -1592,18 +1678,63 @@ class HiCViewAndLayersManager { 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); + for (const layer of layers) { + const source = layer.getSource() as VectorSource | undefined; + if (!source) { + continue; + } + if (layer.getVisible()) { + for (const feature of source.getFeatures()) { + if (trackTypes.has(String(feature.get("trackType")))) { + feature.setStyle(track.style); + } } + source.changed(); + layer.changed(); + } else { + layer.set(HiCViewAndLayersManager.VECTOR_SOURCE_DIRTY_FLAG, true); } } - for (const layer of layers) { - layer.getSource()?.changed(); - layer.changed(); - } this.mapManager.getMap().changed(); + this.scheduleBackgroundGeneratedFeatureStyleRefresh(track, trackTypes); + } + + private scheduleBackgroundGeneratedFeatureStyleRefresh( + track: Track2DSymmetric, + trackTypes: Set + ): void { + const resolutionFeatureGroups = Array.from(track.features.values()); + let groupIndex = 0; + let featureIndex = 0; + let handle: number | undefined; + const processChunk = () => { + if (handle !== undefined) { + this.pendingFeatureStyleRefreshHandles.delete(handle); + handle = undefined; + } + const deadline = performance.now() + 8; + while (groupIndex < resolutionFeatureGroups.length && performance.now() < deadline) { + const features = resolutionFeatureGroups[groupIndex]; + while (featureIndex < features.length && performance.now() < deadline) { + const feature = features[featureIndex]; + if (trackTypes.has(String(feature.get("trackType")))) { + feature.setStyle(track.style); + } + featureIndex += 1; + } + if (featureIndex >= features.length) { + groupIndex += 1; + featureIndex = 0; + } + } + if (groupIndex < resolutionFeatureGroups.length) { + handle = window.setTimeout(processChunk, 0); + this.pendingFeatureStyleRefreshHandles.add(handle); + } + }; + + handle = window.setTimeout(processChunk, 0); + this.pendingFeatureStyleRefreshHandles.add(handle); } private isVectorLayerDirty(layer: Layer | undefined): boolean { diff --git a/src/app/core/net/api/RequestManager.ts b/src/app/core/net/api/RequestManager.ts index 6bb846d..7d53bdc 100644 --- a/src/app/core/net/api/RequestManager.ts +++ b/src/app/core/net/api/RequestManager.ts @@ -56,6 +56,7 @@ import { ListAGPFilesRequest, ListCoolerFilesRequest, ListConvertibleMatrixFilesRequest, + ListDirectoryRequest, ListFilesDetailedRequest, ListFASTAFilesRequest, ListFilesRequest, @@ -542,6 +543,14 @@ class RequestManager { ); } + public async listDirectory(directory = ""): Promise { + return this.sendRequest(new ListDirectoryRequest({ directory })) + .then((response) => response.data as Record[]) + .then((items) => + items.map((item) => new FileEntryResponseDTO(item).toEntity()) + ); + } + public async listCoolers(): Promise { const response = await this.sendRequest(new ListCoolerFilesRequest()); return response.data as string[]; @@ -1019,6 +1028,20 @@ class RequestManager { .catch(() => "unknown"); } + public async getChangelog(): Promise { + const host = this.networkManager.host.replace(/\/+$/, ""); + return axios + .get(`${host}/changelog`, { headers: { Accept: "application/json" } }) + .then((resp) => { + const data = resp.data; + if (typeof data === "string") { + return data; + } + return String(data?.text ?? "Changelog for this build was not detected"); + }) + .catch(() => "Changelog for this build was not detected"); + } + public async queryMatrixFloat32(options: { bpResolution: number; startRowPx: number; diff --git a/src/app/core/net/api/request.ts b/src/app/core/net/api/request.ts index 1a90bfd..cc35c46 100644 --- a/src/app/core/net/api/request.ts +++ b/src/app/core/net/api/request.ts @@ -61,6 +61,16 @@ class ListFilesDetailedRequest implements HiCTAPIRequest { requestPath = "/list_files_detailed"; } +class ListDirectoryRequest implements HiCTAPIRequest { + requestPath = "/list_directory"; + + public constructor( + public readonly options: { + readonly directory: string; + } + ) {} +} + class ListFASTAFilesRequest implements HiCTAPIRequest { requestPath = "/list_fasta_files"; } @@ -184,6 +194,7 @@ class StartConversionJobRequest implements HiCTAPIRequest { public readonly options: { readonly filename: string; readonly assemblyFilename?: string; + readonly useCurrentAssembly?: boolean; readonly direction?: string; readonly overwrite?: boolean; readonly resolutions?: string; @@ -191,6 +202,9 @@ class StartConversionJobRequest implements HiCTAPIRequest { readonly compressionAlgorithm?: string; readonly chunkSize?: number; readonly parallelism?: number; + readonly exportMode?: string; + readonly exportAllResolutions?: boolean; + readonly buildResolutionPyramid?: boolean; readonly binTableFilename?: string; readonly chromSizesFilename?: string; readonly binSize?: number; @@ -215,6 +229,9 @@ class StartBatchConversionJobsRequest implements HiCTAPIRequest { readonly compression?: number; readonly compressionAlgorithm?: string; readonly chunkSize?: number; + readonly exportMode?: string; + readonly exportAllResolutions?: boolean; + readonly buildResolutionPyramid?: boolean; readonly binTableFilename?: string; readonly chromSizesFilename?: string; readonly binSize?: number; @@ -730,6 +747,7 @@ export { OpenFileRequest, ListFilesRequest, ListFilesDetailedRequest, + ListDirectoryRequest, GroupContigsIntoScaffoldRequest, UngroupContigsFromScaffoldRequest, ReverseSelectionRangeRequest, diff --git a/src/app/core/net/api/response.ts b/src/app/core/net/api/response.ts index deb66d5..6d60124 100644 --- a/src/app/core/net/api/response.ts +++ b/src/app/core/net/api/response.ts @@ -260,7 +260,9 @@ class FileEntryResponse { public readonly name: string, public readonly sizeBytes: number, public readonly modifiedAtMs: number, - public readonly extension: string + public readonly extension: string, + public readonly type: "file" | "directory" = "file", + public readonly symbolicLink: boolean = false ) {} } diff --git a/src/app/core/net/dto/requestDTO.ts b/src/app/core/net/dto/requestDTO.ts index c6ff1aa..993ae15 100644 --- a/src/app/core/net/dto/requestDTO.ts +++ b/src/app/core/net/dto/requestDTO.ts @@ -22,6 +22,7 @@ import { ListFilesRequest, ListFilesDetailedRequest, + ListDirectoryRequest, OpenFileRequest, CloseFileRequest, AttachSessionRequest, @@ -172,6 +173,8 @@ abstract class HiCTAPIRequestDTO< return new ListFilesRequestDTO(entity); case entity instanceof ListFilesDetailedRequest: return new ListFilesDetailedRequestDTO(entity); + case entity instanceof ListDirectoryRequest: + return new ListDirectoryRequestDTO(entity as ListDirectoryRequest); case entity instanceof ListCoolerFilesRequest: return new ListCoolerFilesRequestDTO(entity); case entity instanceof ListConvertibleMatrixFilesRequest: @@ -500,6 +503,7 @@ class StartConversionJobRequestDTO extends HiCTAPIRequestDTO { + toDTO(): Record { + return { + directory: this.entity.options.directory, + }; + } +} + class ListCoolerFilesRequestDTO extends HiCTAPIRequestDTO { toDTO(): Record { return {}; @@ -1030,6 +1048,7 @@ export { OpenFileRequestDTO, ListFilesRequestDTO, ListFilesDetailedRequestDTO, + ListDirectoryRequestDTO, CloseFileRequestDTO, AttachSessionRequestDTO, ResolveMatrixSourceRequestDTO, diff --git a/src/app/core/net/dto/responseDTO.ts b/src/app/core/net/dto/responseDTO.ts index e32625e..d2a9a63 100644 --- a/src/app/core/net/dto/responseDTO.ts +++ b/src/app/core/net/dto/responseDTO.ts @@ -370,7 +370,9 @@ class FileEntryResponseDTO extends InboundDTO { (this.json["name"] as string) ?? "", (this.json["sizeBytes"] as number) ?? -1, (this.json["modifiedAtMs"] as number) ?? 0, - (this.json["extension"] as string) ?? "" + (this.json["extension"] as string) ?? "", + ((this.json["type"] as string) === "directory" ? "directory" : "file"), + Boolean(this.json["symbolicLink"] ?? false) ); } } diff --git a/src/app/stores/uiSettingsStore.ts b/src/app/stores/uiSettingsStore.ts index ddffbaf..89cecce 100644 --- a/src/app/stores/uiSettingsStore.ts +++ b/src/app/stores/uiSettingsStore.ts @@ -1,18 +1,92 @@ import { defineStore } from "pinia"; -import { ref } from "vue"; +import { ref, watch } from "vue"; + +const readStored = (key: string, fallback: T): T => { + try { + const raw = window.localStorage.getItem(key); + return raw == null ? fallback : (JSON.parse(raw) as T); + } catch { + return fallback; + } +}; + +const persistRef = (key: string, value: { value: T }) => { + watch( + value, + (next) => { + try { + window.localStorage.setItem(key, JSON.stringify(next)); + } catch { + // Ignore storage failures; UI settings are non-critical. + } + }, + { deep: true } + ); +}; export const useUiSettingsStore = defineStore("uiSettings", () => { const customZoomSliderEnabled = ref(false); const binaryTileTransportEnabled = ref(false); - const fileSelectorMode = ref<"explorer" | "tree">("explorer"); + const rulerCoordinateMode = ref<"global" | "contig" | "scaffold">( + readStored("hict.ui.rulerCoordinateMode", "global") + ); + const fileSelectorMode = ref<"explorer" | "tree">( + readStored("hict.ui.fileSelectorMode", "explorer") + ); const inheritTrackBackgroundFromMap = ref(true); const trackBackgroundColor = ref("rgba(244,247,251,0.98)"); + const osdOverlayVisible = ref(readStored("hict.ui.osd.visible", true)); + const osdOverlayPosition = ref<"top-right" | "bottom-left">( + readStored("hict.ui.osd.position", "top-right") + ); + const osdOverlayFields = ref>( + readStored("hict.ui.osd.fields", { + global: true, + resolution: true, + source: true, + visibleResolutions: true, + pixels: true, + bins: true, + basePairs: true, + contigs: true, + inContig: true, + scaffolds: true, + inScaffold: true, + }) + ); + const osdOverlayFieldOrder = ref( + readStored("hict.ui.osd.fieldOrder", [ + "global", + "resolution", + "source", + "visibleResolutions", + "pixels", + "bins", + "basePairs", + "contigs", + "inContig", + "scaffolds", + "inScaffold", + ]) + ); + + persistRef("hict.ui.fileSelectorMode", fileSelectorMode); + persistRef("hict.ui.rulerCoordinateMode", rulerCoordinateMode); + persistRef("hict.ui.osd.visible", osdOverlayVisible); + persistRef("hict.ui.osd.position", osdOverlayPosition); + persistRef("hict.ui.osd.fields", osdOverlayFields); + persistRef("hict.ui.osd.fieldOrder", osdOverlayFieldOrder); return { customZoomSliderEnabled, binaryTileTransportEnabled, + rulerCoordinateMode, fileSelectorMode, inheritTrackBackgroundFromMap, trackBackgroundColor, + osdOverlayVisible, + osdOverlayPosition, + osdOverlayFields, + osdOverlayFieldOrder, }; }); diff --git a/src/app/ui/components/common/FileSelectionTable.vue b/src/app/ui/components/common/FileSelectionTable.vue index c414088..cfe8ced 100644 --- a/src/app/ui/components/common/FileSelectionTable.vue +++ b/src/app/ui/components/common/FileSelectionTable.vue @@ -48,6 +48,7 @@ :aria-label="`Select ${data.name}`" :checked="isSelected(data.path)" class="form-check-input file-row-checkbox" + :disabled="data.type === 'directory'" type="checkbox" @click.stop="selectPath(data.path, $event)" @keydown.space.stop.prevent="selectPath(data.path)" @@ -59,6 +60,7 @@ {{ data.name }} + link @@ -136,6 +138,8 @@ type FileSelectionTableRow = { iconClass: string; statusLabel: string; statusClass: string; + type: "file" | "directory"; + symbolicLink: boolean; }; const emit = defineEmits<{ @@ -178,6 +182,7 @@ const tableRows = computed(() => const path = entry.path; const name = entry.name && entry.name.trim().length > 0 ? entry.name : basename(path); const extension = entry.extension ?? extensionOf(path); + const type = entry.type ?? "file"; return { path, name, @@ -186,9 +191,11 @@ const tableRows = computed(() => sizeLabel: formatBytes(entry.sizeBytes ?? -1), modifiedLabel: formatTimestamp(entry.modifiedAtMs ?? 0), extension, - iconClass: entry.iconClass ?? iconClassForPath(path, extension), + iconClass: entry.iconClass ?? iconClassForPath(path, extension, type), statusLabel: props.statusLabel?.(path) ?? "", statusClass: props.statusClass?.(path) ?? "", + type, + symbolicLink: Boolean(entry.symbolicLink), }; }) ); @@ -207,6 +214,11 @@ const isSelected = (path: string): boolean => : path === props.selectedPath; const selectPath = (path: string, event?: MouseEvent): void => { + const row = tableRows.value.find((candidate) => candidate.path === path); + if (row?.type === "directory") { + emit("activate", path); + return; + } if (props.multiSelect) { togglePath(path, event); return; @@ -261,17 +273,19 @@ const emitSelectedPaths = (selected: Set): void => { }; const onRowClick = (event: { data?: FileSelectionTableRow; originalEvent?: Event }): void => { - if (event.data?.path) { - selectPath(event.data.path, event.originalEvent as MouseEvent | undefined); + const path = event.data?.path; + if (path !== undefined) { + selectPath(path, event.originalEvent as MouseEvent | undefined); } }; const onRowDoubleClick = (event: { data?: FileSelectionTableRow }): void => { - if (event.data?.path) { + const path = event.data?.path; + if (path !== undefined) { if (!props.multiSelect) { - selectPath(event.data.path); + selectPath(path); } - emit("activate", event.data.path); + emit("activate", path); } }; @@ -290,7 +304,10 @@ const extensionOf = (path: string): string => { return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ""; }; -const iconClassForPath = (path: string, extension: string): string => { +const iconClassForPath = (path: string, extension: string, type: "file" | "directory" = "file"): string => { + if (type === "directory") { + return "pi pi-fw pi-folder"; + } const normalized = path.toLowerCase(); if (normalized.endsWith(".hict") || normalized.endsWith(".hict.hdf5") || normalized.endsWith(".hdf5")) { return "pi pi-fw pi-map"; @@ -407,6 +424,17 @@ const formatTimestamp = (timestampMs: number): string => { text-overflow: ellipsis; } +.file-link-badge { + border: 1px solid #94a3b8; + border-radius: 0.25rem; + color: #475569; + font-size: 0.68rem; + font-weight: 700; + line-height: 1; + padding: 0.08rem 0.25rem; + text-transform: uppercase; +} + .file-path-cell { display: block; max-width: 100%; diff --git a/src/app/ui/components/common/FileSelectionTableTypes.ts b/src/app/ui/components/common/FileSelectionTableTypes.ts index e1e16c2..8a60689 100644 --- a/src/app/ui/components/common/FileSelectionTableTypes.ts +++ b/src/app/ui/components/common/FileSelectionTableTypes.ts @@ -29,4 +29,6 @@ export type FileSelectionTableEntry = { modifiedAtMs?: number; extension?: string; iconClass?: string; + type?: "file" | "directory"; + symbolicLink?: boolean; }; diff --git a/src/app/ui/components/sidebar/ColorPickerRectangle.vue b/src/app/ui/components/sidebar/ColorPickerRectangle.vue index 3c41044..f052099 100644 --- a/src/app/ui/components/sidebar/ColorPickerRectangle.vue +++ b/src/app/ui/components/sidebar/ColorPickerRectangle.vue @@ -58,6 +58,7 @@ const currentColor = ref(props.getDefaultColor()); const vPicker: Ref = ref(null); const overlayRoot: Ref = ref(null); const pickerOpen = ref(false); +let suppressPickerEvents = false; watch( () => props.getDefaultColor(), @@ -72,7 +73,14 @@ watch( // currentColor.value = nc.replace(re, `rgba($1,$2,$3,${alpha})`); currentColor.value = nc; colorSelectorStyleObject.value["background"] = currentColor.value.RGBA; - picker.value?.setColor(currentColor.value.RGBA, false); + if (picker.value) { + suppressPickerEvents = true; + try { + picker.value.setColor(currentColor.value.RGBA, false); + } finally { + suppressPickerEvents = false; + } + } // console.log("Picker rectangle: new color", currentColor.value); } } @@ -95,13 +103,18 @@ onMounted(() => { legacyCSS: true, }); colorSelectorStyleObject.value["background"] = currentColor.value.RGBA; + if (pickerOpen.value && !suppressPickerEvents) { + emit("onColorChanged", currentColor.value as ColorTranslator); + } }, onDone: function (color) { currentColor.value = new ColorTranslator(color.rgbaString as string, { legacyCSS: true, }); colorSelectorStyleObject.value["background"] = currentColor.value.RGBA; - emit("onColorChanged", currentColor.value as ColorTranslator); + if (pickerOpen.value && !suppressPickerEvents) { + emit("onColorChanged", currentColor.value as ColorTranslator); + } closePicker(); }, popup: false, diff --git a/src/app/ui/components/sidebar/VisualziationSettingsEditor.vue b/src/app/ui/components/sidebar/VisualziationSettingsEditor.vue index 3fad100..a156e92 100644 --- a/src/app/ui/components/sidebar/VisualziationSettingsEditor.vue +++ b/src/app/ui/components/sidebar/VisualziationSettingsEditor.vue @@ -152,7 +152,7 @@ + + diff --git a/src/app/ui/components/upper_ribbon/NavigationBar.vue b/src/app/ui/components/upper_ribbon/NavigationBar.vue index c84b54b..08364f5 100644 --- a/src/app/ui/components/upper_ribbon/NavigationBar.vue +++ b/src/app/ui/components/upper_ribbon/NavigationBar.vue @@ -88,6 +88,14 @@ >Convert matrices +
  • + Export matrix +
  • Rendering pipeline...
  • +
  • + OSD settings... +
  • +
  • + Restrict Resolutions... +
  • API docs... + +
    + +
    Attribution +

    {{ projectAttribution.authors }}

    @@ -480,9 +581,24 @@
    WebUI: {{ webuiVersion }}
    Commit: {{ webuiCommit }}
    +
    +

    Ask Authors

    +
    + + +
    +
    {{ licenseText }}
  • -
    +

    Project

    @@ -558,12 +674,95 @@
    +
    +
    + + Loading changelog... +
    +
    +
    +
    + +
    +
    diff --git a/src/env.d.ts b/src/env.d.ts index 1c2d987..ecba5a4 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -27,3 +27,17 @@ declare module "*.vue" { const component: DefineComponent<{}, {}, any>; export default component; } + +declare module "marked" { + export interface MarkedOptions { + async?: boolean; + breaks?: boolean; + gfm?: boolean; + headerIds?: boolean; + mangle?: boolean; + } + + export const marked: { + parse(markdown: string, options?: MarkedOptions): string; + }; +}