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...
+
+
+
+
+
+
+
+
+
+
+
+
Fields
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Attribution
+
{{ projectAttribution.authors }}
@@ -480,9 +581,24 @@
WebUI: {{ webuiVersion }}
Commit: {{ webuiCommit }}
+
{{ licenseText }}
-
+
Project
@@ -558,12 +674,95 @@
+
+
+
+ Loading changelog...
+
+
+
+
+
+
+
+
+
+
+ Disable resolutions that should not be used while zooming the map.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No map resolutions are available yet.
+
+
+
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;
+ };
+}