loom-browser 0.0.6 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/loom-data-worker.js +10458 -0
- package/dist/loom-data-worker.min.js +2 -0
- package/dist/loom-data-worker.min.js.map +1 -0
- package/dist/loom-react.esm.js +1005 -318
- package/dist/loom-react.esm.min.js +1 -1
- package/dist/loom-react.esm.min.js.map +1 -1
- package/dist/loom-worker.js +10591 -0
- package/dist/loom-worker.min.js +2 -0
- package/dist/loom-worker.min.js.map +1 -0
- package/dist/loom.esm.js +8427 -7693
- package/dist/loom.esm.min.js +1 -1
- package/dist/loom.esm.min.js.map +1 -1
- package/dist/loom.js +8429 -7693
- package/dist/loom.min.js +1 -1
- package/dist/loom.min.js.map +1 -1
- package/dist/tsconfig.src.tsbuildinfo +1 -1
- package/dist/types/bigwig/index.d.ts +2 -5
- package/dist/types/dataSourceWorkerProvider.d.ts +79 -0
- package/dist/types/dataSources/bigWigDataSource.d.ts +1 -3
- package/dist/types/dataSources/featureSourceFactory.d.ts +1 -2
- package/dist/types/dataSources/gtxDataSource.d.ts +1 -3
- package/dist/types/dataSources/textFeatureSource.d.ts +0 -4
- package/dist/types/genomeBrowser.d.ts +56 -1
- package/dist/types/gtx/index.d.ts +1 -4
- package/dist/types/headlessGenomeBrowser.d.ts +39 -2
- package/dist/types/index.d.ts +8 -2
- package/dist/types/mainThreadDataSourceProvider.d.ts +19 -0
- package/dist/types/react/LoomBrowser.d.ts +15 -1
- package/dist/types/session.d.ts +0 -3
- package/dist/types/svgFeatureOverlay.d.ts +27 -0
- package/dist/types/tabix/index.d.ts +0 -3
- package/dist/types/trackRegistry.d.ts +1 -4
- package/dist/types/tracks/annotation/annotationRenderer.d.ts +4 -1
- package/dist/types/tracks/annotation/annotationTrackCanvas.d.ts +9 -6
- package/dist/types/tracks/axis/axisRenderer.d.ts +2 -8
- package/dist/types/tracks/axis/index.d.ts +1 -1
- package/dist/types/tracks/baseTrackCanvas.d.ts +7 -1
- package/dist/types/tracks/interaction/interactionRenderer.d.ts +4 -1
- package/dist/types/tracks/trackLabel.d.ts +22 -0
- package/dist/types/tracks/wig/wigTrackCanvas.d.ts +2 -0
- package/dist/types/types.d.ts +18 -0
- package/dist/types/worker/dataSourceRegistry.d.ts +33 -0
- package/dist/types/worker/dataSourceWorkerScript.d.ts +13 -0
- package/dist/types/worker/unifiedWorkerScript.d.ts +17 -0
- package/dist/types/worker/webDataSourceWorkerProvider.d.ts +59 -0
- package/dist/types/worker/webUnifiedWorkerProvider.d.ts +64 -0
- package/dist/types/worker/webWorkerPool.d.ts +64 -0
- package/dist/types/worker/workerPoolScript.d.ts +17 -0
- package/dist/types/workerDataSource.d.ts +32 -0
- package/dist/types/workerProvider.d.ts +10 -3
- package/package.json +3 -1
package/dist/loom-react.esm.js
CHANGED
|
@@ -1074,6 +1074,47 @@ class DOMCanvasProvider {
|
|
|
1074
1074
|
/** Default CanvasProvider for browser environments. */
|
|
1075
1075
|
const defaultCanvasProvider = new DOMCanvasProvider();
|
|
1076
1076
|
|
|
1077
|
+
/**
|
|
1078
|
+
* WorkerDataSource — main-thread proxy that forwards DataSource.fetch() to a worker.
|
|
1079
|
+
*
|
|
1080
|
+
* Implements DataSource<F> so it's a drop-in replacement for any direct DataSource.
|
|
1081
|
+
* The real DataSource instance lives inside a web worker; this proxy communicates
|
|
1082
|
+
* via the DataSourceWorkerProvider.
|
|
1083
|
+
*
|
|
1084
|
+
* Non-serializable configuration (like chromosome name resolver functions) is
|
|
1085
|
+
* stored locally on the proxy and applied before forwarding to the worker.
|
|
1086
|
+
*
|
|
1087
|
+
* Layer 1 (Data + Layout): no DOM.
|
|
1088
|
+
*/
|
|
1089
|
+
class WorkerDataSource {
|
|
1090
|
+
constructor(provider, instanceId) {
|
|
1091
|
+
this.provider = provider;
|
|
1092
|
+
this.instanceId = instanceId;
|
|
1093
|
+
}
|
|
1094
|
+
async fetch(locus, bpPerPixel, signal) {
|
|
1095
|
+
// Resolve chromosome name locally (functions can't cross postMessage boundary)
|
|
1096
|
+
const resolvedLocus = this.resolver
|
|
1097
|
+
? { ...locus, chr: this.resolver(locus.chr) }
|
|
1098
|
+
: locus;
|
|
1099
|
+
return this.provider.fetch(this.instanceId, resolvedLocus, bpPerPixel, signal);
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Set a chromosome name resolver. Stored locally — the proxy resolves
|
|
1103
|
+
* chromosome names before forwarding the locus to the worker.
|
|
1104
|
+
*/
|
|
1105
|
+
setChromNameResolver(resolver) {
|
|
1106
|
+
this.resolver = resolver;
|
|
1107
|
+
}
|
|
1108
|
+
/** Forward cumulative offsets to the worker-resident DataSource. */
|
|
1109
|
+
setCumulativeOffsets(offsets) {
|
|
1110
|
+
this.provider.configure(this.instanceId, 'setCumulativeOffsets', offsets);
|
|
1111
|
+
}
|
|
1112
|
+
/** Forward window function change to the worker-resident DataSource. */
|
|
1113
|
+
setWindowFunction(wf) {
|
|
1114
|
+
this.provider.configure(this.instanceId, 'setWindowFunction', wf);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1077
1118
|
/**
|
|
1078
1119
|
* Unified RenderTheme system — shadcn-style palette-driven theming.
|
|
1079
1120
|
*
|
|
@@ -2212,6 +2253,9 @@ function createCachedSequence(provider) {
|
|
|
2212
2253
|
currentQuery = null;
|
|
2213
2254
|
return interval;
|
|
2214
2255
|
});
|
|
2256
|
+
// Clear stale currentQuery on failure (e.g. abort) so subsequent
|
|
2257
|
+
// requests don't keep reusing the rejected promise via dedup.
|
|
2258
|
+
promise.catch(() => { currentQuery = null; });
|
|
2215
2259
|
currentQuery = { interval: queryLocus, promise };
|
|
2216
2260
|
return promise;
|
|
2217
2261
|
}
|
|
@@ -2447,6 +2491,14 @@ class BaseTrackCanvas {
|
|
|
2447
2491
|
hitTest(_x, _y) {
|
|
2448
2492
|
return [];
|
|
2449
2493
|
}
|
|
2494
|
+
/**
|
|
2495
|
+
* Return feature bounding rectangles for the current render state.
|
|
2496
|
+
* Used by SVG overlay system for hover/click interactivity.
|
|
2497
|
+
* Default: empty (no overlay). Subclasses override for interactive tracks.
|
|
2498
|
+
*/
|
|
2499
|
+
getFeatureRects() {
|
|
2500
|
+
return [];
|
|
2501
|
+
}
|
|
2450
2502
|
/**
|
|
2451
2503
|
* Return context menu items for a right-click at canvas-relative coordinates.
|
|
2452
2504
|
* Default: undefined (no track-specific items). Subclasses override.
|
|
@@ -2568,6 +2620,47 @@ class BaseTrackCanvas {
|
|
|
2568
2620
|
}
|
|
2569
2621
|
}
|
|
2570
2622
|
|
|
2623
|
+
/**
|
|
2624
|
+
* Shared track name overlay label — renders a horizontal label in the top-left
|
|
2625
|
+
* corner of a track canvas with a semi-transparent background pill.
|
|
2626
|
+
*
|
|
2627
|
+
* Used by wig, annotation, and interaction tracks for consistent labeling.
|
|
2628
|
+
* Layer 2 (Track Canvases): uses canvas API, no DOM.
|
|
2629
|
+
*/
|
|
2630
|
+
/**
|
|
2631
|
+
* Render a track name as a horizontal overlay in the top-left corner.
|
|
2632
|
+
* Draws a semi-transparent background pill behind the text for readability.
|
|
2633
|
+
*/
|
|
2634
|
+
function renderTrackNameLabel(ctx, options, pixelHeight) {
|
|
2635
|
+
var _a, _b;
|
|
2636
|
+
if (pixelHeight < 12)
|
|
2637
|
+
return;
|
|
2638
|
+
const pad = 4;
|
|
2639
|
+
const vPad = 2;
|
|
2640
|
+
const hPad = 4;
|
|
2641
|
+
ctx.font = 'normal 10px sans-serif';
|
|
2642
|
+
ctx.textBaseline = 'top';
|
|
2643
|
+
ctx.textAlign = 'left';
|
|
2644
|
+
const topOffset = pad + ((_a = options.topOffset) !== null && _a !== void 0 ? _a : 0);
|
|
2645
|
+
const metrics = ctx.measureText(options.name);
|
|
2646
|
+
const w = metrics.width + hPad * 2;
|
|
2647
|
+
const h = ((_b = metrics.actualBoundingBoxDescent) !== null && _b !== void 0 ? _b : 10) + vPad * 2;
|
|
2648
|
+
// Semi-transparent background
|
|
2649
|
+
ctx.fillStyle = options.background;
|
|
2650
|
+
ctx.globalAlpha = 0.75;
|
|
2651
|
+
ctx.fillRect(pad, topOffset, w, h);
|
|
2652
|
+
ctx.globalAlpha = 1.0;
|
|
2653
|
+
// Border — use labelColor at reduced opacity for theme awareness
|
|
2654
|
+
ctx.strokeStyle = options.labelColor;
|
|
2655
|
+
ctx.globalAlpha = 0.3;
|
|
2656
|
+
ctx.lineWidth = 0.5;
|
|
2657
|
+
ctx.strokeRect(pad, topOffset, w, h);
|
|
2658
|
+
ctx.globalAlpha = 1.0;
|
|
2659
|
+
// Text
|
|
2660
|
+
ctx.fillStyle = options.labelColor;
|
|
2661
|
+
ctx.fillText(options.name, pad + hPad, topOffset + vPad);
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2571
2664
|
/**
|
|
2572
2665
|
* Dynamic sequence (dynseq) renderer for wig tracks.
|
|
2573
2666
|
*
|
|
@@ -2950,50 +3043,30 @@ function renderDataRangeLabels(ctx, config, pixelHeight) {
|
|
|
2950
3043
|
}
|
|
2951
3044
|
// ─── Track name overlay label ────────────────────────────────────────────────
|
|
2952
3045
|
/**
|
|
2953
|
-
*
|
|
2954
|
-
*
|
|
2955
|
-
* When data range labels are also visible, positions below the max label.
|
|
3046
|
+
* Wig-specific track name label: computes topOffset to clear data range labels,
|
|
3047
|
+
* then delegates to the shared renderTrackNameLabel().
|
|
2956
3048
|
*/
|
|
2957
|
-
function
|
|
2958
|
-
var _a
|
|
3049
|
+
function renderWigTrackNameLabel(ctx, config, pixelHeight) {
|
|
3050
|
+
var _a;
|
|
2959
3051
|
if (!config.trackName)
|
|
2960
3052
|
return;
|
|
2961
|
-
if (pixelHeight < 12)
|
|
2962
|
-
return;
|
|
2963
|
-
const pad = 4;
|
|
2964
|
-
const vPad = 2;
|
|
2965
|
-
const hPad = 4;
|
|
2966
|
-
ctx.font = 'normal 10px sans-serif';
|
|
2967
|
-
ctx.textBaseline = 'top';
|
|
2968
|
-
ctx.textAlign = 'left';
|
|
2969
3053
|
// Compute vertical offset — push below data range max label when visible
|
|
2970
|
-
let topOffset =
|
|
3054
|
+
let topOffset = 0;
|
|
2971
3055
|
if (config.showDataRange) {
|
|
3056
|
+
const vPad = 2;
|
|
2972
3057
|
ctx.save();
|
|
2973
3058
|
ctx.font = config.labelFont;
|
|
2974
3059
|
const maxLabel = prettyPrint(config.flipAxis ? config.dataRange.min : config.dataRange.max);
|
|
2975
3060
|
const maxH = ((_a = ctx.measureText(maxLabel).actualBoundingBoxDescent) !== null && _a !== void 0 ? _a : 10) + vPad * 2;
|
|
2976
3061
|
ctx.restore();
|
|
2977
|
-
|
|
2978
|
-
topOffset = pad + maxH + 2;
|
|
3062
|
+
topOffset = maxH + 2;
|
|
2979
3063
|
}
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
ctx.fillRect(pad, topOffset, w, h);
|
|
2987
|
-
ctx.globalAlpha = 1.0;
|
|
2988
|
-
// Border — use labelColor at reduced opacity for theme awareness
|
|
2989
|
-
ctx.strokeStyle = config.labelColor;
|
|
2990
|
-
ctx.globalAlpha = 0.3;
|
|
2991
|
-
ctx.lineWidth = 0.5;
|
|
2992
|
-
ctx.strokeRect(pad, topOffset, w, h);
|
|
2993
|
-
ctx.globalAlpha = 1.0;
|
|
2994
|
-
// Text
|
|
2995
|
-
ctx.fillStyle = config.labelColor;
|
|
2996
|
-
ctx.fillText(config.trackName, pad + hPad, topOffset + vPad);
|
|
3064
|
+
renderTrackNameLabel(ctx, {
|
|
3065
|
+
name: config.trackName,
|
|
3066
|
+
background: config.background,
|
|
3067
|
+
labelColor: config.labelColor,
|
|
3068
|
+
topOffset,
|
|
3069
|
+
}, pixelHeight);
|
|
2997
3070
|
}
|
|
2998
3071
|
// ─── Baseline and guide lines ───────────────────────────────────────────────
|
|
2999
3072
|
function renderBaseline(ctx, config, pixelWidth, pixelHeight) {
|
|
@@ -3068,7 +3141,7 @@ function renderWigTrack(ctx, features, config, rc) {
|
|
|
3068
3141
|
renderBaseline(ctx, config, rc.pixelWidth, pixelHeight);
|
|
3069
3142
|
renderGuideLines(ctx, config, rc.pixelWidth, yScale);
|
|
3070
3143
|
renderDataRangeLabels(ctx, config, pixelHeight);
|
|
3071
|
-
|
|
3144
|
+
renderWigTrackNameLabel(ctx, config, pixelHeight);
|
|
3072
3145
|
}
|
|
3073
3146
|
|
|
3074
3147
|
/**
|
|
@@ -3235,11 +3308,17 @@ class WigTrackCanvas extends BaseTrackCanvas {
|
|
|
3235
3308
|
this._lastDataRange = null;
|
|
3236
3309
|
/** Abort controller for in-flight sequence fetch. */
|
|
3237
3310
|
this._seqAbort = null;
|
|
3311
|
+
/** User-set config overrides, re-applied on theme change. */
|
|
3312
|
+
this._userOverrides = {};
|
|
3238
3313
|
this.type = 'wig';
|
|
3239
3314
|
this.features = options.features;
|
|
3240
3315
|
this.fixedHeight = options.height;
|
|
3241
3316
|
this._name = options.name;
|
|
3242
3317
|
this._sequenceProvider = options.sequenceProvider;
|
|
3318
|
+
if (options.config)
|
|
3319
|
+
this._userOverrides = { ...options.config };
|
|
3320
|
+
if (options.background !== undefined)
|
|
3321
|
+
this._userOverrides.background = options.background;
|
|
3243
3322
|
}
|
|
3244
3323
|
/** Set a callback invoked when the user changes the windowing function via context menu. */
|
|
3245
3324
|
set onWindowFunctionChange(cb) {
|
|
@@ -3267,6 +3346,7 @@ class WigTrackCanvas extends BaseTrackCanvas {
|
|
|
3267
3346
|
}
|
|
3268
3347
|
/** Merge partial config and re-render. Triggers sequence fetch when switching to dynseq. */
|
|
3269
3348
|
setConfig(config) {
|
|
3349
|
+
Object.assign(this._userOverrides, config);
|
|
3270
3350
|
const wasDynseq = this._config.graphType === 'dynseq';
|
|
3271
3351
|
super.setConfig(config);
|
|
3272
3352
|
if (!wasDynseq && this._config.graphType === 'dynseq') {
|
|
@@ -3336,8 +3416,16 @@ class WigTrackCanvas extends BaseTrackCanvas {
|
|
|
3336
3416
|
return this.config.background;
|
|
3337
3417
|
}
|
|
3338
3418
|
doRender(ctx, _width, height, rc) {
|
|
3339
|
-
if (this.features.length === 0)
|
|
3419
|
+
if (this.features.length === 0) {
|
|
3420
|
+
if (this._name) {
|
|
3421
|
+
renderTrackNameLabel(ctx, {
|
|
3422
|
+
name: this._name,
|
|
3423
|
+
background: this.config.background,
|
|
3424
|
+
labelColor: this.config.labelColor,
|
|
3425
|
+
}, height);
|
|
3426
|
+
}
|
|
3340
3427
|
return;
|
|
3428
|
+
}
|
|
3341
3429
|
// Apply value scaling (normalize then scaleFactor), matching igv.js getFeatures() order.
|
|
3342
3430
|
// Creates a scaled copy to avoid mutating the original features array.
|
|
3343
3431
|
const needsScaling = (this.config.normalizationFactor != null && this.config.normalizationFactor !== 1)
|
|
@@ -3365,14 +3453,14 @@ class WigTrackCanvas extends BaseTrackCanvas {
|
|
|
3365
3453
|
this._config = { ...this._config, dataRange: autoRange };
|
|
3366
3454
|
}
|
|
3367
3455
|
this._lastDataRange = effectiveConfig.dataRange;
|
|
3368
|
-
console.log('[wig doRender]', { logScale: effectiveConfig.logScale, autoscale: effectiveConfig.autoscale, flipAxis: effectiveConfig.flipAxis, dataRange: effectiveConfig.dataRange, featureCount: features.length });
|
|
3369
3456
|
renderWigTrack(ctx, features, effectiveConfig, rc);
|
|
3370
3457
|
}
|
|
3371
3458
|
getAxisInfo() {
|
|
3372
|
-
|
|
3459
|
+
var _a;
|
|
3460
|
+
if (!this._name && !this._lastDataRange)
|
|
3373
3461
|
return undefined;
|
|
3374
3462
|
return {
|
|
3375
|
-
dataRange: this._lastDataRange,
|
|
3463
|
+
dataRange: (_a = this._lastDataRange) !== null && _a !== void 0 ? _a : undefined,
|
|
3376
3464
|
color: typeof this.config.color === 'string' ? this.config.color : undefined,
|
|
3377
3465
|
label: this._name,
|
|
3378
3466
|
flipAxis: this.config.flipAxis || undefined,
|
|
@@ -3450,7 +3538,7 @@ class WigTrackCanvas extends BaseTrackCanvas {
|
|
|
3450
3538
|
}
|
|
3451
3539
|
setTheme(theme) {
|
|
3452
3540
|
const dataRange = this._config.dataRange;
|
|
3453
|
-
this._config = { ...resolveWigConfig(theme), dataRange };
|
|
3541
|
+
this._config = { ...resolveWigConfig(theme), ...this._userOverrides, dataRange };
|
|
3454
3542
|
this.render();
|
|
3455
3543
|
}
|
|
3456
3544
|
serializeConfig(theme) {
|
|
@@ -3858,7 +3946,10 @@ function getUtrColorForFeature(feature, config) {
|
|
|
3858
3946
|
* Features should already have `.row` assigned (via `pack()`).
|
|
3859
3947
|
* The canvas should be sized and cleared before calling this.
|
|
3860
3948
|
*/
|
|
3861
|
-
function renderAnnotationTrack(ctx, features, config, rc
|
|
3949
|
+
function renderAnnotationTrack(ctx, features, config, rc,
|
|
3950
|
+
/** Optional track name label overlay (top-left corner). */
|
|
3951
|
+
trackLabel) {
|
|
3952
|
+
var _a;
|
|
3862
3953
|
const rowLastLabelX = {};
|
|
3863
3954
|
for (const feature of features) {
|
|
3864
3955
|
if (feature.row === undefined)
|
|
@@ -3870,6 +3961,9 @@ function renderAnnotationTrack(ctx, features, config, rc) {
|
|
|
3870
3961
|
continue;
|
|
3871
3962
|
renderSingleFeature(ctx, feature, config, rc, rowLastLabelX);
|
|
3872
3963
|
}
|
|
3964
|
+
if (trackLabel) {
|
|
3965
|
+
renderTrackNameLabel(ctx, trackLabel, ctx.canvas.height / ((_a = globalThis.devicePixelRatio) !== null && _a !== void 0 ? _a : 1));
|
|
3966
|
+
}
|
|
3873
3967
|
}
|
|
3874
3968
|
|
|
3875
3969
|
/**
|
|
@@ -3890,6 +3984,8 @@ class AnnotationTrackCanvas extends BaseTrackCanvas {
|
|
|
3890
3984
|
const theme = resolveTheme(options.theme);
|
|
3891
3985
|
const config = resolveAnnotationConfig(theme, options.config);
|
|
3892
3986
|
super(canvas, options.locus, config, options.canvasProvider);
|
|
3987
|
+
/** User-set config overrides, re-applied on theme change. */
|
|
3988
|
+
this._userOverrides = {};
|
|
3893
3989
|
/** Most recently packed features, available after render(). */
|
|
3894
3990
|
this.packedFeatures = [];
|
|
3895
3991
|
/** Whether packedFeatures was pre-computed by async worker (skip sync pack in computeHeight). */
|
|
@@ -3899,30 +3995,20 @@ class AnnotationTrackCanvas extends BaseTrackCanvas {
|
|
|
3899
3995
|
this.fixedHeight = options.height;
|
|
3900
3996
|
this.background = (_a = options.background) !== null && _a !== void 0 ? _a : theme.palette.background;
|
|
3901
3997
|
this._name = options.name;
|
|
3902
|
-
|
|
3998
|
+
if (options.config)
|
|
3999
|
+
this._userOverrides = { ...options.config };
|
|
4000
|
+
this._userBackground = options.background;
|
|
3903
4001
|
}
|
|
3904
|
-
/**
|
|
4002
|
+
/** Set or clear the fixed pixel height. When set, overrides auto-computed row-based height. */
|
|
4003
|
+
setFixedHeight(height) {
|
|
4004
|
+
this.fixedHeight = height;
|
|
4005
|
+
this.render();
|
|
4006
|
+
}
|
|
4007
|
+
/** Update features and re-render. */
|
|
3905
4008
|
setFeatures(features) {
|
|
3906
4009
|
this.features = features;
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
const copies = features.map(f => ({ ...f }));
|
|
3910
|
-
this.workerProvider.execute({ task: 'pack', features: copies })
|
|
3911
|
-
.then(packed => {
|
|
3912
|
-
this.packedFeatures = packed;
|
|
3913
|
-
this.packedReady = true;
|
|
3914
|
-
this.render();
|
|
3915
|
-
})
|
|
3916
|
-
.catch(() => {
|
|
3917
|
-
// Fallback: render will use synchronous packing
|
|
3918
|
-
this.packedReady = false;
|
|
3919
|
-
this.render();
|
|
3920
|
-
});
|
|
3921
|
-
}
|
|
3922
|
-
else {
|
|
3923
|
-
this.packedReady = false;
|
|
3924
|
-
this.render();
|
|
3925
|
-
}
|
|
4010
|
+
this.packedReady = false;
|
|
4011
|
+
this.render();
|
|
3926
4012
|
}
|
|
3927
4013
|
computeHeight(_width) {
|
|
3928
4014
|
var _a;
|
|
@@ -3941,10 +4027,20 @@ class AnnotationTrackCanvas extends BaseTrackCanvas {
|
|
|
3941
4027
|
return this.background;
|
|
3942
4028
|
}
|
|
3943
4029
|
doRender(ctx, _width, _height, rc) {
|
|
3944
|
-
|
|
4030
|
+
var _a;
|
|
4031
|
+
// Ensure label background matches track background so clearRect doesn't
|
|
4032
|
+
// punch transparent holes (which show the underlying CSS background).
|
|
4033
|
+
const config = this.config.labelBackground
|
|
4034
|
+
? this.config
|
|
4035
|
+
: { ...this.config, labelBackground: this.background };
|
|
4036
|
+
renderAnnotationTrack(ctx, this.packedFeatures, config, rc, this._name ? {
|
|
4037
|
+
name: this._name,
|
|
4038
|
+
background: this.background,
|
|
4039
|
+
labelColor: (_a = config.labelColor) !== null && _a !== void 0 ? _a : '#333',
|
|
4040
|
+
} : undefined);
|
|
3945
4041
|
}
|
|
3946
4042
|
getAxisInfo() {
|
|
3947
|
-
return
|
|
4043
|
+
return undefined;
|
|
3948
4044
|
}
|
|
3949
4045
|
getContextMenuItems(_x, _y) {
|
|
3950
4046
|
const modes = ['EXPANDED', 'SQUISHED', 'COLLAPSED'];
|
|
@@ -3958,6 +4054,43 @@ class AnnotationTrackCanvas extends BaseTrackCanvas {
|
|
|
3958
4054
|
})),
|
|
3959
4055
|
}];
|
|
3960
4056
|
}
|
|
4057
|
+
getFeatureRects() {
|
|
4058
|
+
var _a;
|
|
4059
|
+
if (this.packedFeatures.length === 0)
|
|
4060
|
+
return [];
|
|
4061
|
+
const width = this._canvas.clientWidth;
|
|
4062
|
+
if (width === 0)
|
|
4063
|
+
return [];
|
|
4064
|
+
const bpPerPixel = (this._locus.end - this._locus.start) / width;
|
|
4065
|
+
const bpStart = this._locus.start;
|
|
4066
|
+
const config = this._config;
|
|
4067
|
+
const rowHeight = config.displayMode === 'SQUISHED'
|
|
4068
|
+
? config.squishedRowHeight
|
|
4069
|
+
: config.expandedRowHeight;
|
|
4070
|
+
const featureH = config.displayMode === 'SQUISHED'
|
|
4071
|
+
? config.featureHeight / 2
|
|
4072
|
+
: config.featureHeight;
|
|
4073
|
+
const rects = [];
|
|
4074
|
+
for (const f of this.packedFeatures) {
|
|
4075
|
+
if (f.row === undefined)
|
|
4076
|
+
continue;
|
|
4077
|
+
const pxStart = (f.start - bpStart) / bpPerPixel;
|
|
4078
|
+
const pxEnd = (f.end - bpStart) / bpPerPixel;
|
|
4079
|
+
if (pxEnd < 0 || pxStart > width)
|
|
4080
|
+
continue;
|
|
4081
|
+
const py = config.margin + rowHeight * f.row;
|
|
4082
|
+
let px = pxStart;
|
|
4083
|
+
let pw = pxEnd - pxStart;
|
|
4084
|
+
if (pw < 3) {
|
|
4085
|
+
px -= (3 - pw) / 2;
|
|
4086
|
+
pw = 3;
|
|
4087
|
+
}
|
|
4088
|
+
// Resolve feature color (same logic as annotationRenderer's getColorForFeature)
|
|
4089
|
+
const color = (_a = f.color) !== null && _a !== void 0 ? _a : (config.altColor && f.strand === '-' ? config.altColor : config.color);
|
|
4090
|
+
rects.push({ feature: f, x: px, y: py, width: pw, height: featureH, color });
|
|
4091
|
+
}
|
|
4092
|
+
return rects;
|
|
4093
|
+
}
|
|
3961
4094
|
hitTest(x, y) {
|
|
3962
4095
|
if (this.packedFeatures.length === 0)
|
|
3963
4096
|
return [];
|
|
@@ -3982,9 +4115,14 @@ class AnnotationTrackCanvas extends BaseTrackCanvas {
|
|
|
3982
4115
|
}
|
|
3983
4116
|
return results;
|
|
3984
4117
|
}
|
|
4118
|
+
setConfig(config) {
|
|
4119
|
+
Object.assign(this._userOverrides, config);
|
|
4120
|
+
super.setConfig(config);
|
|
4121
|
+
}
|
|
3985
4122
|
setTheme(theme) {
|
|
3986
|
-
|
|
3987
|
-
this.
|
|
4123
|
+
var _a;
|
|
4124
|
+
this._config = resolveAnnotationConfig(theme, this._userOverrides);
|
|
4125
|
+
this.background = (_a = this._userBackground) !== null && _a !== void 0 ? _a : theme.palette.background;
|
|
3988
4126
|
this.render();
|
|
3989
4127
|
}
|
|
3990
4128
|
serializeConfig(theme) {
|
|
@@ -4747,7 +4885,7 @@ function featureToWig(f, chr, wf) {
|
|
|
4747
4885
|
* Thin wrapper around @gmod/bbi BigWig that preserves the legacy API.
|
|
4748
4886
|
*/
|
|
4749
4887
|
class BigWigReader {
|
|
4750
|
-
constructor(url
|
|
4888
|
+
constructor(url) {
|
|
4751
4889
|
this.bw = getGmodReader(url);
|
|
4752
4890
|
}
|
|
4753
4891
|
async loadHeader(signal) {
|
|
@@ -4818,7 +4956,7 @@ class BigWigReader {
|
|
|
4818
4956
|
*/
|
|
4819
4957
|
async function fetchBigWigFeatures(url, locus, options = {}) {
|
|
4820
4958
|
var _a;
|
|
4821
|
-
const reader = new BigWigReader(url
|
|
4959
|
+
const reader = new BigWigReader(url);
|
|
4822
4960
|
return reader.readFeatures(locus.chr, locus.start, locus.end, options.bpPerPixel, (_a = options.windowFunction) !== null && _a !== void 0 ? _a : 'mean', options.signal);
|
|
4823
4961
|
}
|
|
4824
4962
|
/**
|
|
@@ -4827,15 +4965,14 @@ async function fetchBigWigFeatures(url, locus, options = {}) {
|
|
|
4827
4965
|
*/
|
|
4828
4966
|
async function fetchBigWigWGFeatures(url, chromNames, bpPerPixel, options = {}) {
|
|
4829
4967
|
var _a;
|
|
4830
|
-
const reader = new BigWigReader(url
|
|
4968
|
+
const reader = new BigWigReader(url);
|
|
4831
4969
|
return reader.readWGFeatures(chromNames, bpPerPixel, (_a = options.windowFunction) !== null && _a !== void 0 ? _a : 'mean', options.signal);
|
|
4832
4970
|
}
|
|
4833
4971
|
|
|
4834
4972
|
class BigWigDataSource {
|
|
4835
|
-
constructor(url, windowFunction = 'mean'
|
|
4973
|
+
constructor(url, windowFunction = 'mean') {
|
|
4836
4974
|
this.url = url;
|
|
4837
4975
|
this._windowFunction = windowFunction;
|
|
4838
|
-
this.workerProvider = workerProvider;
|
|
4839
4976
|
}
|
|
4840
4977
|
get windowFunction() { return this._windowFunction; }
|
|
4841
4978
|
/** Update the window function for future fetches. */
|
|
@@ -4861,20 +4998,10 @@ class BigWigDataSource {
|
|
|
4861
4998
|
const features = await fetchBigWigFeatures(this.url, resolvedLocus, {
|
|
4862
4999
|
bpPerPixel,
|
|
4863
5000
|
windowFunction: this._windowFunction,
|
|
4864
|
-
workerProvider: this.workerProvider,
|
|
4865
5001
|
signal,
|
|
4866
5002
|
});
|
|
4867
5003
|
// Summarize to pixel resolution (matching igv.js wigTrack.getFeatures())
|
|
4868
5004
|
if (bpPerPixel > 1 && this._windowFunction !== 'none' && features.length > 0) {
|
|
4869
|
-
if (this.workerProvider) {
|
|
4870
|
-
return this.workerProvider.execute({
|
|
4871
|
-
task: 'summarize',
|
|
4872
|
-
features,
|
|
4873
|
-
startBP: locus.start,
|
|
4874
|
-
bpPerPixel,
|
|
4875
|
-
windowFunction: this._windowFunction,
|
|
4876
|
-
});
|
|
4877
|
-
}
|
|
4878
5005
|
return summarizeWigData(features, locus.start, bpPerPixel, this._windowFunction);
|
|
4879
5006
|
}
|
|
4880
5007
|
return features;
|
|
@@ -4885,7 +5012,7 @@ class BigWigDataSource {
|
|
|
4885
5012
|
*/
|
|
4886
5013
|
async fetchWG(bpPerPixel, signal) {
|
|
4887
5014
|
const offsets = this._cumulativeOffsets;
|
|
4888
|
-
const features = await fetchBigWigWGFeatures(this.url, offsets.chromosomeNames, bpPerPixel, { windowFunction: this._windowFunction,
|
|
5015
|
+
const features = await fetchBigWigWGFeatures(this.url, offsets.chromosomeNames, bpPerPixel, { windowFunction: this._windowFunction, signal });
|
|
4889
5016
|
// Transform to genome-wide coordinates
|
|
4890
5017
|
const wgFeatures = [];
|
|
4891
5018
|
for (const f of features) {
|
|
@@ -4902,15 +5029,6 @@ class BigWigDataSource {
|
|
|
4902
5029
|
wgFeatures.sort((a, b) => a.start - b.start);
|
|
4903
5030
|
// Summarize at genome-wide scale
|
|
4904
5031
|
if (bpPerPixel > 1 && this._windowFunction !== 'none' && wgFeatures.length > 0) {
|
|
4905
|
-
if (this.workerProvider) {
|
|
4906
|
-
return this.workerProvider.execute({
|
|
4907
|
-
task: 'summarize',
|
|
4908
|
-
features: wgFeatures,
|
|
4909
|
-
startBP: 0,
|
|
4910
|
-
bpPerPixel,
|
|
4911
|
-
windowFunction: this._windowFunction,
|
|
4912
|
-
});
|
|
4913
|
-
}
|
|
4914
5032
|
return summarizeWigData(wgFeatures, 0, bpPerPixel, this._windowFunction);
|
|
4915
5033
|
}
|
|
4916
5034
|
return wgFeatures;
|
|
@@ -5864,11 +5982,10 @@ function getReader(url) {
|
|
|
5864
5982
|
return reader;
|
|
5865
5983
|
}
|
|
5866
5984
|
class GtxDataSource {
|
|
5867
|
-
constructor(url, experimentId, windowFunction = 'mean'
|
|
5985
|
+
constructor(url, experimentId, windowFunction = 'mean') {
|
|
5868
5986
|
this.url = url;
|
|
5869
5987
|
this.experimentId = experimentId;
|
|
5870
5988
|
this._windowFunction = windowFunction;
|
|
5871
|
-
this.workerProvider = workerProvider;
|
|
5872
5989
|
this.reader = getReader(url);
|
|
5873
5990
|
}
|
|
5874
5991
|
get windowFunction() { return this._windowFunction; }
|
|
@@ -5901,15 +6018,6 @@ class GtxDataSource {
|
|
|
5901
6018
|
const features = await coordinator.request(this.experimentId, resolvedLocus, bpPerPixel, signal);
|
|
5902
6019
|
// Summarize to pixel resolution
|
|
5903
6020
|
if (bpPerPixel > 1 && this._windowFunction !== 'none' && features.length > 0) {
|
|
5904
|
-
if (this.workerProvider) {
|
|
5905
|
-
return this.workerProvider.execute({
|
|
5906
|
-
task: 'summarize',
|
|
5907
|
-
features,
|
|
5908
|
-
startBP: locus.start,
|
|
5909
|
-
bpPerPixel,
|
|
5910
|
-
windowFunction: this._windowFunction,
|
|
5911
|
-
});
|
|
5912
|
-
}
|
|
5913
6021
|
return summarizeWigData(features, locus.start, bpPerPixel, this._windowFunction);
|
|
5914
6022
|
}
|
|
5915
6023
|
return features;
|
|
@@ -5936,15 +6044,6 @@ class GtxDataSource {
|
|
|
5936
6044
|
wgFeatures.sort((a, b) => a.start - b.start);
|
|
5937
6045
|
// Summarize at genome-wide scale
|
|
5938
6046
|
if (bpPerPixel > 1 && this._windowFunction !== 'none' && wgFeatures.length > 0) {
|
|
5939
|
-
if (this.workerProvider) {
|
|
5940
|
-
return this.workerProvider.execute({
|
|
5941
|
-
task: 'summarize',
|
|
5942
|
-
features: wgFeatures,
|
|
5943
|
-
startBP: 0,
|
|
5944
|
-
bpPerPixel,
|
|
5945
|
-
windowFunction: this._windowFunction,
|
|
5946
|
-
});
|
|
5947
|
-
}
|
|
5948
6047
|
return summarizeWigData(wgFeatures, 0, bpPerPixel, this._windowFunction);
|
|
5949
6048
|
}
|
|
5950
6049
|
return wgFeatures;
|
|
@@ -8019,7 +8118,6 @@ class TextFeatureSource {
|
|
|
8019
8118
|
var _a, _b;
|
|
8020
8119
|
this.allFeaturesLoaded = false;
|
|
8021
8120
|
this.url = config.url;
|
|
8022
|
-
this.workerProvider = config.workerProvider;
|
|
8023
8121
|
// Detect format
|
|
8024
8122
|
const detected = (_a = config.format) !== null && _a !== void 0 ? _a : inferFormatFromPath(config.url);
|
|
8025
8123
|
this.format = detected !== null && detected !== void 0 ? detected : 'bed';
|
|
@@ -8037,7 +8135,7 @@ class TextFeatureSource {
|
|
|
8037
8135
|
// Set up TabixReader for indexed files
|
|
8038
8136
|
if (this._indexed) {
|
|
8039
8137
|
const indexUrl = (_b = config.indexURL) !== null && _b !== void 0 ? _b : inferIndexURL(config.url);
|
|
8040
|
-
this.tabixReader = new TabixReader(config.url, { indexUrl
|
|
8138
|
+
this.tabixReader = new TabixReader(config.url, { indexUrl });
|
|
8041
8139
|
}
|
|
8042
8140
|
}
|
|
8043
8141
|
/** Whether this source is indexed (queryable by region). */
|
|
@@ -8123,19 +8221,11 @@ class TextFeatureSource {
|
|
|
8123
8221
|
const lines = text.split(/\r?\n/);
|
|
8124
8222
|
// Parse header
|
|
8125
8223
|
this.header = parseHeader(lines, this.format);
|
|
8126
|
-
// Parse features
|
|
8127
|
-
const features = (this.
|
|
8128
|
-
|
|
8129
|
-
|
|
8130
|
-
|
|
8131
|
-
format: this.format,
|
|
8132
|
-
header: this.header,
|
|
8133
|
-
assembleGFF: this.assembleGFF,
|
|
8134
|
-
})
|
|
8135
|
-
: parseFeatures(lines, this.format, {
|
|
8136
|
-
header: this.header,
|
|
8137
|
-
assembleGFF: this.assembleGFF,
|
|
8138
|
-
}));
|
|
8224
|
+
// Parse features
|
|
8225
|
+
const features = parseFeatures(lines, this.format, {
|
|
8226
|
+
header: this.header,
|
|
8227
|
+
assembleGFF: this.assembleGFF,
|
|
8228
|
+
});
|
|
8139
8229
|
// Sort by chr then start
|
|
8140
8230
|
features.sort((a, b) => {
|
|
8141
8231
|
if (a.chr === b.chr)
|
|
@@ -8316,18 +8406,26 @@ class MemoryDataSource {
|
|
|
8316
8406
|
* Stateless: all state is passed in, no side effects beyond canvas drawing
|
|
8317
8407
|
* and attaching drawState to features for hit-testing.
|
|
8318
8408
|
*/
|
|
8319
|
-
function renderInteractionTrack(ctx, features, config, rc
|
|
8409
|
+
function renderInteractionTrack(ctx, features, config, rc,
|
|
8410
|
+
/** Optional track name label overlay (top-left corner). */
|
|
8411
|
+
trackLabel) {
|
|
8320
8412
|
// Clear background
|
|
8321
8413
|
ctx.fillStyle = config.background;
|
|
8322
8414
|
ctx.fillRect(0, 0, rc.pixelWidth, config.height);
|
|
8323
|
-
if (!features || features.length === 0)
|
|
8415
|
+
if (!features || features.length === 0) {
|
|
8416
|
+
if (trackLabel)
|
|
8417
|
+
renderTrackNameLabel(ctx, trackLabel, config.height);
|
|
8324
8418
|
return;
|
|
8419
|
+
}
|
|
8325
8420
|
if (config.displayMode === 'proportional') {
|
|
8326
8421
|
drawProportional(ctx, features, config, rc);
|
|
8327
8422
|
}
|
|
8328
8423
|
else {
|
|
8329
8424
|
drawNested(ctx, features, config, rc);
|
|
8330
8425
|
}
|
|
8426
|
+
if (trackLabel) {
|
|
8427
|
+
renderTrackNameLabel(ctx, trackLabel, config.height);
|
|
8428
|
+
}
|
|
8331
8429
|
}
|
|
8332
8430
|
// ─── Nested arcs ─────────────────────────────────────────────────────────────
|
|
8333
8431
|
function drawNested(ctx, features, config, rc) {
|
|
@@ -8631,7 +8729,11 @@ class InteractionTrackCanvas extends BaseTrackCanvas {
|
|
|
8631
8729
|
return this.config.background;
|
|
8632
8730
|
}
|
|
8633
8731
|
doRender(ctx, _width, _height, rc) {
|
|
8634
|
-
renderInteractionTrack(ctx, this.features, this._config, rc
|
|
8732
|
+
renderInteractionTrack(ctx, this.features, this._config, rc, this._name ? {
|
|
8733
|
+
name: this._name,
|
|
8734
|
+
background: this._config.background,
|
|
8735
|
+
labelColor: '#333',
|
|
8736
|
+
} : undefined);
|
|
8635
8737
|
}
|
|
8636
8738
|
/** Hit-test: find features at canvas-relative pixel coordinates. */
|
|
8637
8739
|
hitTest(x, y) {
|
|
@@ -8851,12 +8953,12 @@ function knownTrackTypes() {
|
|
|
8851
8953
|
return types;
|
|
8852
8954
|
}
|
|
8853
8955
|
// ─── Built-in data source helpers ────────────────────────────────────────────
|
|
8854
|
-
function createDataSource(config
|
|
8956
|
+
function createDataSource(config) {
|
|
8855
8957
|
switch (config.type) {
|
|
8856
8958
|
case 'bigwig':
|
|
8857
|
-
return new BigWigDataSource(config.url, config.windowFunction
|
|
8959
|
+
return new BigWigDataSource(config.url, config.windowFunction);
|
|
8858
8960
|
case 'gtx':
|
|
8859
|
-
return new GtxDataSource(config.url, config.experimentId, config.windowFunction
|
|
8961
|
+
return new GtxDataSource(config.url, config.experimentId, config.windowFunction);
|
|
8860
8962
|
case 'ucsc':
|
|
8861
8963
|
return new GeneDataSource({ genome: config.genome, track: config.track });
|
|
8862
8964
|
case 'text':
|
|
@@ -8865,7 +8967,6 @@ function createDataSource(config, workerProvider) {
|
|
|
8865
8967
|
format: config.format,
|
|
8866
8968
|
indexURL: config.indexURL,
|
|
8867
8969
|
indexed: config.indexed,
|
|
8868
|
-
workerProvider,
|
|
8869
8970
|
});
|
|
8870
8971
|
case 'memory':
|
|
8871
8972
|
// Memory data sources are created directly with features by the caller.
|
|
@@ -8889,7 +8990,7 @@ function createWigTrack(trackConfig, ctx) {
|
|
|
8889
8990
|
let dataSourceConfig = null;
|
|
8890
8991
|
if (config.dataSource) {
|
|
8891
8992
|
dataSourceConfig = config.dataSource;
|
|
8892
|
-
dataSource = createDataSource(config.dataSource
|
|
8993
|
+
dataSource = createDataSource(config.dataSource);
|
|
8893
8994
|
}
|
|
8894
8995
|
return { track, dataSource, dataSourceConfig, name: config.name, order: config.order };
|
|
8895
8996
|
}
|
|
@@ -8902,13 +9003,12 @@ function createAnnotationTrack(trackConfig, ctx) {
|
|
|
8902
9003
|
config: config.config,
|
|
8903
9004
|
theme: ctx.theme,
|
|
8904
9005
|
canvasProvider: ctx.canvasProvider,
|
|
8905
|
-
workerProvider: ctx.workerProvider,
|
|
8906
9006
|
});
|
|
8907
9007
|
let dataSource = null;
|
|
8908
9008
|
let dataSourceConfig = null;
|
|
8909
9009
|
if (config.dataSource) {
|
|
8910
9010
|
dataSourceConfig = config.dataSource;
|
|
8911
|
-
dataSource = createDataSource(config.dataSource
|
|
9011
|
+
dataSource = createDataSource(config.dataSource);
|
|
8912
9012
|
}
|
|
8913
9013
|
return { track, dataSource, dataSourceConfig, name: config.name, order: config.order };
|
|
8914
9014
|
}
|
|
@@ -8954,7 +9054,7 @@ function createInteractionTrack(trackConfig, ctx) {
|
|
|
8954
9054
|
let dataSourceConfig = null;
|
|
8955
9055
|
if (config.dataSource) {
|
|
8956
9056
|
dataSourceConfig = config.dataSource;
|
|
8957
|
-
dataSource = createDataSource(config.dataSource
|
|
9057
|
+
dataSource = createDataSource(config.dataSource);
|
|
8958
9058
|
}
|
|
8959
9059
|
return { track, dataSource, dataSourceConfig, name: config.name, order: config.order };
|
|
8960
9060
|
}
|
|
@@ -9000,7 +9100,6 @@ function createTrackFromConfig(trackConfig, locus, options = {}) {
|
|
|
9000
9100
|
return creator(trackConfig, {
|
|
9001
9101
|
locus,
|
|
9002
9102
|
canvasProvider: (_a = options.canvasProvider) !== null && _a !== void 0 ? _a : defaultCanvasProvider,
|
|
9003
|
-
workerProvider: options.workerProvider,
|
|
9004
9103
|
theme: options.theme,
|
|
9005
9104
|
sequenceProvider: options.sequenceProvider,
|
|
9006
9105
|
});
|
|
@@ -9384,6 +9483,15 @@ function selectTracks(tracks, selector) {
|
|
|
9384
9483
|
* browser.setLocus({ chr: 'chr17', start: 7670000, end: 7680000 })
|
|
9385
9484
|
* browser.dispose()
|
|
9386
9485
|
*/
|
|
9486
|
+
/** Duck-type check: does this object implement DataSourceWorkerProvider? */
|
|
9487
|
+
function isDataSourceWorkerProvider(obj) {
|
|
9488
|
+
if (!obj || typeof obj !== 'object')
|
|
9489
|
+
return false;
|
|
9490
|
+
const o = obj;
|
|
9491
|
+
return typeof o.create === 'function'
|
|
9492
|
+
&& typeof o.fetch === 'function'
|
|
9493
|
+
&& typeof o.destroy === 'function';
|
|
9494
|
+
}
|
|
9387
9495
|
const BrowserEvent = {
|
|
9388
9496
|
LocusChange: 'locuschange',
|
|
9389
9497
|
TrackAdded: 'trackadded',
|
|
@@ -9407,6 +9515,25 @@ let nextTrackId = 0;
|
|
|
9407
9515
|
function generateTrackId(type) {
|
|
9408
9516
|
return `${type !== null && type !== void 0 ? type : 'track'}-${nextTrackId++}`;
|
|
9409
9517
|
}
|
|
9518
|
+
/**
|
|
9519
|
+
* Track type sort priority — adapts the igv.js `reorderTracks()` two-level
|
|
9520
|
+
* sort (js/browser.ts:1153-1172) to a single numeric priority.
|
|
9521
|
+
*
|
|
9522
|
+
* igv.js pins ideogram (1), ruler (2) to the top, then sorts by `track.order`.
|
|
9523
|
+
* Loom uses positive priority for top-pinned tracks, 0 for user data tracks,
|
|
9524
|
+
* and negative values to push tracks (e.g., gene reference) to the bottom.
|
|
9525
|
+
*
|
|
9526
|
+
* Each ManagedTrack can also set `order` which is added to the type priority,
|
|
9527
|
+
* giving callers fine-grained control (e.g., addGeneTrack sets order=-1).
|
|
9528
|
+
*/
|
|
9529
|
+
const DEFAULT_TRACK_TYPE_PRIORITY = {
|
|
9530
|
+
ruler: 2,
|
|
9531
|
+
sequence: 1,
|
|
9532
|
+
};
|
|
9533
|
+
function trackTypePriority(type) {
|
|
9534
|
+
var _a;
|
|
9535
|
+
return (_a = DEFAULT_TRACK_TYPE_PRIORITY[type]) !== null && _a !== void 0 ? _a : 0;
|
|
9536
|
+
}
|
|
9410
9537
|
class HeadlessGenomeBrowser {
|
|
9411
9538
|
get theme() { return this._theme; }
|
|
9412
9539
|
get locus() { return this._locus; }
|
|
@@ -9423,6 +9550,10 @@ class HeadlessGenomeBrowser {
|
|
|
9423
9550
|
this.roiSets = [];
|
|
9424
9551
|
/** Inflight fetch promises keyed by (cacheKey + fetchRegion + bpPerPixel) for deduplication. */
|
|
9425
9552
|
this.inflightFetches = new Map();
|
|
9553
|
+
/** Timer for debouncing data loads during rapid zoom/pan. */
|
|
9554
|
+
this.loadDebounceTimer = null;
|
|
9555
|
+
/** When true, sortTracks() is a no-op. Used for batch track additions (e.g. loadSession). */
|
|
9556
|
+
this._deferSort = false;
|
|
9426
9557
|
this.events = new EventEmitter();
|
|
9427
9558
|
this.genome = options.genome === null ? undefined : ((_a = options.genome) !== null && _a !== void 0 ? _a : hg38Genome);
|
|
9428
9559
|
this.chromSizes = (_b = this.genome) === null || _b === void 0 ? void 0 : _b.chromSizes;
|
|
@@ -9432,12 +9563,33 @@ class HeadlessGenomeBrowser {
|
|
|
9432
9563
|
this._viewportWidth = (_e = options.viewportWidth) !== null && _e !== void 0 ? _e : 0;
|
|
9433
9564
|
this.canvasProvider = (_f = options.canvasProvider) !== null && _f !== void 0 ? _f : defaultCanvasProvider;
|
|
9434
9565
|
this.workerProvider = options.workerProvider;
|
|
9566
|
+
// Auto-detect: if workerProvider also implements DataSourceWorkerProvider, use it for both
|
|
9567
|
+
this.dataSourceWorkerProvider = isDataSourceWorkerProvider(options.workerProvider)
|
|
9568
|
+
? options.workerProvider : undefined;
|
|
9435
9569
|
this.popupProvider = (_g = options.popupProvider) !== null && _g !== void 0 ? _g : undefined;
|
|
9436
9570
|
this.contextMenuProvider = (_h = options.contextMenuProvider) !== null && _h !== void 0 ? _h : undefined;
|
|
9437
9571
|
this._theme = resolveTheme(options.theme);
|
|
9438
9572
|
if (options.stateProjection)
|
|
9439
9573
|
this._state = options.stateProjection;
|
|
9440
9574
|
}
|
|
9575
|
+
/**
|
|
9576
|
+
* Create a WorkerDataSource proxy for worker-eligible configs, or return null
|
|
9577
|
+
* if dataSourceWorkerProvider is not set. Handles create + chrom alias + offsets wiring.
|
|
9578
|
+
*/
|
|
9579
|
+
createWorkerDataSource(config) {
|
|
9580
|
+
if (!this.dataSourceWorkerProvider)
|
|
9581
|
+
return null;
|
|
9582
|
+
const instanceId = `ds-${nextTrackId}-${Date.now()}`;
|
|
9583
|
+
this.dataSourceWorkerProvider.create(instanceId, config);
|
|
9584
|
+
const proxy = new WorkerDataSource(this.dataSourceWorkerProvider, instanceId);
|
|
9585
|
+
if (this.genome) {
|
|
9586
|
+
proxy.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
9587
|
+
}
|
|
9588
|
+
if (this.cumulativeOffsets) {
|
|
9589
|
+
proxy.setCumulativeOffsets(this.cumulativeOffsets);
|
|
9590
|
+
}
|
|
9591
|
+
return proxy;
|
|
9592
|
+
}
|
|
9441
9593
|
/** Clamp a locus to valid chromosome bounds. No-op if chromSizes is not set. */
|
|
9442
9594
|
clamp(locus) {
|
|
9443
9595
|
return this.chromSizes ? clampLocus(locus, this.chromSizes, this.cumulativeOffsets) : locus;
|
|
@@ -9461,7 +9613,7 @@ class HeadlessGenomeBrowser {
|
|
|
9461
9613
|
this.loadAllTracksIfNeeded();
|
|
9462
9614
|
}
|
|
9463
9615
|
/** Add a track with an optional data source for automatic data management. */
|
|
9464
|
-
addTrack(track, dataSource, dataSourceConfig, maxTrackHeight) {
|
|
9616
|
+
addTrack(track, dataSource, dataSourceConfig, maxTrackHeight, order) {
|
|
9465
9617
|
var _a;
|
|
9466
9618
|
const id = generateTrackId(track.type);
|
|
9467
9619
|
const mt = {
|
|
@@ -9472,8 +9624,10 @@ class HeadlessGenomeBrowser {
|
|
|
9472
9624
|
cache: null,
|
|
9473
9625
|
abortController: null,
|
|
9474
9626
|
maxTrackHeight,
|
|
9627
|
+
order,
|
|
9475
9628
|
};
|
|
9476
9629
|
this.managedTracks.push(mt);
|
|
9630
|
+
this.sortTracks();
|
|
9477
9631
|
track.setLocus(this._locus);
|
|
9478
9632
|
this.events.emit(BrowserEvent.TrackAdded, { track });
|
|
9479
9633
|
// Trigger initial data load if data source provided
|
|
@@ -9491,6 +9645,9 @@ class HeadlessGenomeBrowser {
|
|
|
9491
9645
|
const mt = this.managedTracks[idx];
|
|
9492
9646
|
if (mt.abortController)
|
|
9493
9647
|
mt.abortController.abort();
|
|
9648
|
+
if (mt.dataSource instanceof WorkerDataSource && this.dataSourceWorkerProvider) {
|
|
9649
|
+
this.dataSourceWorkerProvider.destroy(mt.dataSource.instanceId);
|
|
9650
|
+
}
|
|
9494
9651
|
this.managedTracks.splice(idx, 1);
|
|
9495
9652
|
this.events.emit(BrowserEvent.TrackRemoved, { track: mt.track });
|
|
9496
9653
|
}
|
|
@@ -9508,8 +9665,42 @@ class HeadlessGenomeBrowser {
|
|
|
9508
9665
|
return;
|
|
9509
9666
|
const [moved] = this.managedTracks.splice(fromIndex, 1);
|
|
9510
9667
|
this.managedTracks.splice(clampedIndex, 0, moved);
|
|
9668
|
+
this._assignOrderFromPosition();
|
|
9511
9669
|
this.events.emit(BrowserEvent.TrackOrderChanged, { tracks: this.managedTracks.map(mt => mt.track) });
|
|
9512
9670
|
}
|
|
9671
|
+
/**
|
|
9672
|
+
* Sort tracks by priority (descending): positive = top, 0 = middle, negative = bottom.
|
|
9673
|
+
* Priority comes from the track type default + per-track `order` override.
|
|
9674
|
+
* Within the same effective priority, insertion order is preserved (stable sort).
|
|
9675
|
+
*/
|
|
9676
|
+
sortTracks() {
|
|
9677
|
+
if (this._deferSort)
|
|
9678
|
+
return;
|
|
9679
|
+
this.managedTracks.sort((a, b) => {
|
|
9680
|
+
var _a, _b;
|
|
9681
|
+
const pa = ((_a = a.order) !== null && _a !== void 0 ? _a : 0) + trackTypePriority(a.track.type);
|
|
9682
|
+
const pb = ((_b = b.order) !== null && _b !== void 0 ? _b : 0) + trackTypePriority(b.track.type);
|
|
9683
|
+
return pb - pa; // descending: higher priority first
|
|
9684
|
+
});
|
|
9685
|
+
this.onTracksSorted();
|
|
9686
|
+
}
|
|
9687
|
+
/** Hook for subclasses to react to track order changes (e.g., DOM reorder). */
|
|
9688
|
+
onTracksSorted() { }
|
|
9689
|
+
/**
|
|
9690
|
+
* Persist current array positions into mt.order so future sorts preserve
|
|
9691
|
+
* manual reordering. Uses descending values (top track = highest) scaled
|
|
9692
|
+
* above the type-priority range so explicit order dominates.
|
|
9693
|
+
*/
|
|
9694
|
+
_assignOrderFromPosition() {
|
|
9695
|
+
const n = this.managedTracks.length;
|
|
9696
|
+
for (let i = 0; i < n; i++) {
|
|
9697
|
+
this.managedTracks[i].order = (n - i) * 10;
|
|
9698
|
+
}
|
|
9699
|
+
}
|
|
9700
|
+
/** Find the ManagedTrack entry for a given track canvas. */
|
|
9701
|
+
findMT(track) {
|
|
9702
|
+
return this.managedTracks.find(mt => mt.track === track);
|
|
9703
|
+
}
|
|
9513
9704
|
/** Get the current track order. */
|
|
9514
9705
|
getTrackOrder() {
|
|
9515
9706
|
return this.managedTracks.map(mt => mt.track);
|
|
@@ -9574,7 +9765,7 @@ class HeadlessGenomeBrowser {
|
|
|
9574
9765
|
for (const mt of this.managedTracks) {
|
|
9575
9766
|
mt.track.setLocus(this._locus);
|
|
9576
9767
|
}
|
|
9577
|
-
this.
|
|
9768
|
+
this.debouncedLoad();
|
|
9578
9769
|
this.events.emit(BrowserEvent.LocusChange, { locus: this._locus });
|
|
9579
9770
|
}
|
|
9580
9771
|
/**
|
|
@@ -9819,7 +10010,8 @@ class HeadlessGenomeBrowser {
|
|
|
9819
10010
|
serialized.order = mt.order;
|
|
9820
10011
|
if (mt.metadata)
|
|
9821
10012
|
serialized.metadata = mt.metadata;
|
|
9822
|
-
if (mt.dataSourceConfig
|
|
10013
|
+
if (mt.dataSourceConfig
|
|
10014
|
+
&& serialized.type !== 'ruler' && serialized.type !== 'sequence') {
|
|
9823
10015
|
serialized.dataSource = mt.dataSourceConfig;
|
|
9824
10016
|
}
|
|
9825
10017
|
tracks.push(serialized);
|
|
@@ -9865,35 +10057,49 @@ class HeadlessGenomeBrowser {
|
|
|
9865
10057
|
// Recreate tracks from session config
|
|
9866
10058
|
const trackOptions = {
|
|
9867
10059
|
canvasProvider: this.canvasProvider,
|
|
9868
|
-
workerProvider: this.workerProvider,
|
|
9869
10060
|
theme: options === null || options === void 0 ? void 0 : options.theme,
|
|
9870
10061
|
sequenceProvider: this.sequenceProvider,
|
|
9871
10062
|
};
|
|
9872
|
-
|
|
9873
|
-
|
|
9874
|
-
|
|
9875
|
-
|
|
9876
|
-
|
|
9877
|
-
|
|
10063
|
+
this._deferSort = true;
|
|
10064
|
+
try {
|
|
10065
|
+
for (const trackConfig of session.tracks) {
|
|
10066
|
+
const created = createTrackFromSession(trackConfig, this._locus, trackOptions);
|
|
10067
|
+
// Use worker proxy for worker-eligible data sources
|
|
10068
|
+
let dataSource = created.dataSource;
|
|
10069
|
+
if (dataSource && created.dataSourceConfig && this.dataSourceWorkerProvider) {
|
|
10070
|
+
const dsType = created.dataSourceConfig.type;
|
|
10071
|
+
if (dsType === 'bigwig' || dsType === 'gtx' || dsType === 'text' || dsType === 'ucsc') {
|
|
10072
|
+
const workerDS = this.createWorkerDataSource(created.dataSourceConfig);
|
|
10073
|
+
if (workerDS)
|
|
10074
|
+
dataSource = workerDS;
|
|
10075
|
+
}
|
|
9878
10076
|
}
|
|
9879
|
-
|
|
9880
|
-
|
|
9881
|
-
if (
|
|
9882
|
-
|
|
10077
|
+
// Wire chromosome alias resolution for non-worker data sources
|
|
10078
|
+
if (!this.dataSourceWorkerProvider && this.genome && dataSource) {
|
|
10079
|
+
if (dataSource instanceof BigWigDataSource || dataSource instanceof GtxDataSource) {
|
|
10080
|
+
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10081
|
+
}
|
|
10082
|
+
else if (dataSource instanceof TextFeatureSource) {
|
|
10083
|
+
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10084
|
+
if (this.cumulativeOffsets) {
|
|
10085
|
+
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10086
|
+
}
|
|
9883
10087
|
}
|
|
9884
10088
|
}
|
|
10089
|
+
this.addTrack(created.track, dataSource !== null && dataSource !== void 0 ? dataSource : undefined, (_a = created.dataSourceConfig) !== null && _a !== void 0 ? _a : undefined, undefined, (_b = created.order) !== null && _b !== void 0 ? _b : undefined);
|
|
10090
|
+
// Restore bookkeeping fields for round-trip serialization
|
|
10091
|
+
const mt = this.findMT(created.track);
|
|
10092
|
+
if (trackConfig.id)
|
|
10093
|
+
mt.id = trackConfig.id;
|
|
10094
|
+
if (created.name)
|
|
10095
|
+
mt.name = created.name;
|
|
10096
|
+
if (trackConfig.metadata)
|
|
10097
|
+
mt.metadata = trackConfig.metadata;
|
|
9885
10098
|
}
|
|
9886
|
-
|
|
9887
|
-
|
|
9888
|
-
|
|
9889
|
-
|
|
9890
|
-
mt.id = trackConfig.id;
|
|
9891
|
-
if (created.name)
|
|
9892
|
-
mt.name = created.name;
|
|
9893
|
-
if (created.order != null)
|
|
9894
|
-
mt.order = created.order;
|
|
9895
|
-
if (trackConfig.metadata)
|
|
9896
|
-
mt.metadata = trackConfig.metadata;
|
|
10099
|
+
}
|
|
10100
|
+
finally {
|
|
10101
|
+
this._deferSort = false;
|
|
10102
|
+
this.sortTracks();
|
|
9897
10103
|
}
|
|
9898
10104
|
// Restore ROI sets
|
|
9899
10105
|
if (session.rois) {
|
|
@@ -9935,30 +10141,37 @@ class HeadlessGenomeBrowser {
|
|
|
9935
10141
|
var _a, _b;
|
|
9936
10142
|
const created = createTrackFromConfig(trackConfig, this._locus, {
|
|
9937
10143
|
canvasProvider: this.canvasProvider,
|
|
9938
|
-
workerProvider: this.workerProvider,
|
|
9939
10144
|
theme: this.theme,
|
|
9940
10145
|
sequenceProvider: this.sequenceProvider,
|
|
9941
10146
|
});
|
|
9942
|
-
//
|
|
9943
|
-
|
|
9944
|
-
|
|
9945
|
-
|
|
9946
|
-
|
|
9947
|
-
|
|
9948
|
-
|
|
10147
|
+
// Use worker proxy for worker-eligible data sources
|
|
10148
|
+
let dataSource = created.dataSource;
|
|
10149
|
+
if (dataSource && created.dataSourceConfig && this.dataSourceWorkerProvider) {
|
|
10150
|
+
const dsType = created.dataSourceConfig.type;
|
|
10151
|
+
if (dsType === 'bigwig' || dsType === 'gtx' || dsType === 'text' || dsType === 'ucsc') {
|
|
10152
|
+
const workerDS = this.createWorkerDataSource(created.dataSourceConfig);
|
|
10153
|
+
if (workerDS)
|
|
10154
|
+
dataSource = workerDS;
|
|
10155
|
+
}
|
|
10156
|
+
}
|
|
10157
|
+
// Wire chromosome alias resolution for non-worker data sources
|
|
10158
|
+
if (!this.dataSourceWorkerProvider && this.genome && dataSource) {
|
|
10159
|
+
if (dataSource instanceof BigWigDataSource) {
|
|
10160
|
+
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10161
|
+
}
|
|
10162
|
+
else if (dataSource instanceof TextFeatureSource) {
|
|
10163
|
+
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
9949
10164
|
if (this.cumulativeOffsets) {
|
|
9950
|
-
|
|
10165
|
+
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
9951
10166
|
}
|
|
9952
10167
|
}
|
|
9953
10168
|
}
|
|
9954
|
-
this.addTrack(created.track, (_a = created.
|
|
9955
|
-
const mt = this.
|
|
10169
|
+
this.addTrack(created.track, dataSource !== null && dataSource !== void 0 ? dataSource : undefined, (_a = created.dataSourceConfig) !== null && _a !== void 0 ? _a : undefined, undefined, (_b = created.order) !== null && _b !== void 0 ? _b : undefined);
|
|
10170
|
+
const mt = this.findMT(created.track);
|
|
9956
10171
|
if (trackConfig.id)
|
|
9957
10172
|
mt.id = trackConfig.id;
|
|
9958
10173
|
if (created.name)
|
|
9959
10174
|
mt.name = created.name;
|
|
9960
|
-
if (created.order != null)
|
|
9961
|
-
mt.order = created.order;
|
|
9962
10175
|
if (trackConfig.metadata)
|
|
9963
10176
|
mt.metadata = trackConfig.metadata;
|
|
9964
10177
|
return created.track;
|
|
@@ -9994,22 +10207,29 @@ class HeadlessGenomeBrowser {
|
|
|
9994
10207
|
name: options === null || options === void 0 ? void 0 : options.name,
|
|
9995
10208
|
sequenceProvider: this.sequenceProvider,
|
|
9996
10209
|
});
|
|
9997
|
-
const dataSource = new BigWigDataSource(url, windowFunction, this.workerProvider);
|
|
9998
|
-
if (this.cumulativeOffsets) {
|
|
9999
|
-
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10000
|
-
}
|
|
10001
|
-
if (this.genome) {
|
|
10002
|
-
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10003
|
-
}
|
|
10004
10210
|
const dataSourceConfig = {
|
|
10005
10211
|
type: 'bigwig', url, windowFunction,
|
|
10006
10212
|
};
|
|
10213
|
+
const workerDS = this.createWorkerDataSource(dataSourceConfig);
|
|
10214
|
+
const dataSource = workerDS !== null && workerDS !== void 0 ? workerDS : (() => {
|
|
10215
|
+
const ds = new BigWigDataSource(url, windowFunction);
|
|
10216
|
+
if (this.cumulativeOffsets)
|
|
10217
|
+
ds.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10218
|
+
if (this.genome)
|
|
10219
|
+
ds.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10220
|
+
return ds;
|
|
10221
|
+
})();
|
|
10007
10222
|
this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
|
|
10008
10223
|
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
10009
|
-
this.
|
|
10224
|
+
this.findMT(track).metadata = options.metadata;
|
|
10010
10225
|
// Wire windowing function callback: update data source, invalidate cache, re-fetch
|
|
10011
10226
|
track.onWindowFunctionChange = (wf) => {
|
|
10012
|
-
|
|
10227
|
+
if (workerDS) {
|
|
10228
|
+
workerDS.setWindowFunction(wf);
|
|
10229
|
+
}
|
|
10230
|
+
else {
|
|
10231
|
+
dataSource.setWindowFunction(wf);
|
|
10232
|
+
}
|
|
10013
10233
|
const mt = this.managedTracks.find(m => m.track === track);
|
|
10014
10234
|
if (mt) {
|
|
10015
10235
|
mt.cache = null;
|
|
@@ -10037,22 +10257,29 @@ class HeadlessGenomeBrowser {
|
|
|
10037
10257
|
name: options.name,
|
|
10038
10258
|
sequenceProvider: this.sequenceProvider,
|
|
10039
10259
|
});
|
|
10040
|
-
const dataSource = new GtxDataSource(url, options.experimentId, windowFunction, this.workerProvider);
|
|
10041
|
-
if (this.cumulativeOffsets) {
|
|
10042
|
-
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10043
|
-
}
|
|
10044
|
-
if (this.genome) {
|
|
10045
|
-
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10046
|
-
}
|
|
10047
10260
|
const dataSourceConfig = {
|
|
10048
10261
|
type: 'gtx', url, experimentId: options.experimentId, windowFunction,
|
|
10049
10262
|
};
|
|
10263
|
+
const workerDS = this.createWorkerDataSource(dataSourceConfig);
|
|
10264
|
+
const dataSource = workerDS !== null && workerDS !== void 0 ? workerDS : (() => {
|
|
10265
|
+
const ds = new GtxDataSource(url, options.experimentId, windowFunction);
|
|
10266
|
+
if (this.cumulativeOffsets)
|
|
10267
|
+
ds.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10268
|
+
if (this.genome)
|
|
10269
|
+
ds.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10270
|
+
return ds;
|
|
10271
|
+
})();
|
|
10050
10272
|
this.addTrack(track, dataSource, dataSourceConfig, options.maxTrackHeight);
|
|
10051
10273
|
if (options.metadata)
|
|
10052
|
-
this.
|
|
10274
|
+
this.findMT(track).metadata = options.metadata;
|
|
10053
10275
|
// Wire windowing function callback
|
|
10054
10276
|
track.onWindowFunctionChange = (wf) => {
|
|
10055
|
-
|
|
10277
|
+
if (workerDS) {
|
|
10278
|
+
workerDS.setWindowFunction(wf);
|
|
10279
|
+
}
|
|
10280
|
+
else {
|
|
10281
|
+
dataSource.setWindowFunction(wf);
|
|
10282
|
+
}
|
|
10056
10283
|
const mt = this.managedTracks.find(m => m.track === track);
|
|
10057
10284
|
if (mt) {
|
|
10058
10285
|
mt.cache = null;
|
|
@@ -10076,21 +10303,23 @@ class HeadlessGenomeBrowser {
|
|
|
10076
10303
|
background: options === null || options === void 0 ? void 0 : options.background,
|
|
10077
10304
|
theme: this.theme,
|
|
10078
10305
|
canvasProvider: this.canvasProvider,
|
|
10079
|
-
workerProvider: this.workerProvider,
|
|
10080
10306
|
name: (_a = options === null || options === void 0 ? void 0 : options.name) !== null && _a !== void 0 ? _a : 'Genes',
|
|
10081
10307
|
});
|
|
10082
10308
|
const genome = options === null || options === void 0 ? void 0 : options.genome;
|
|
10083
10309
|
const ucscTrack = options === null || options === void 0 ? void 0 : options.track;
|
|
10084
|
-
const dataSource = new GeneDataSource({ genome, track: ucscTrack });
|
|
10085
|
-
if (this.cumulativeOffsets) {
|
|
10086
|
-
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10087
|
-
}
|
|
10088
10310
|
const dataSourceConfig = {
|
|
10089
10311
|
type: 'ucsc', genome, track: ucscTrack,
|
|
10090
10312
|
};
|
|
10091
|
-
this.
|
|
10313
|
+
const workerDS = this.createWorkerDataSource(dataSourceConfig);
|
|
10314
|
+
const dataSource = workerDS !== null && workerDS !== void 0 ? workerDS : (() => {
|
|
10315
|
+
const ds = new GeneDataSource({ genome, track: ucscTrack });
|
|
10316
|
+
if (this.cumulativeOffsets)
|
|
10317
|
+
ds.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10318
|
+
return ds;
|
|
10319
|
+
})();
|
|
10320
|
+
this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight, -1);
|
|
10092
10321
|
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
10093
|
-
this.
|
|
10322
|
+
this.findMT(track).metadata = options.metadata;
|
|
10094
10323
|
return track;
|
|
10095
10324
|
}
|
|
10096
10325
|
/** Add a BED/peak annotation track from a URL. Supports plain text and tabix-indexed files. */
|
|
@@ -10105,28 +10334,23 @@ class HeadlessGenomeBrowser {
|
|
|
10105
10334
|
background: options === null || options === void 0 ? void 0 : options.background,
|
|
10106
10335
|
theme: this.theme,
|
|
10107
10336
|
canvasProvider: this.canvasProvider,
|
|
10108
|
-
workerProvider: this.workerProvider,
|
|
10109
10337
|
name: options === null || options === void 0 ? void 0 : options.name,
|
|
10110
10338
|
});
|
|
10111
|
-
const dataSource = new TextFeatureSource({
|
|
10112
|
-
url,
|
|
10113
|
-
format,
|
|
10114
|
-
indexURL: options === null || options === void 0 ? void 0 : options.indexURL,
|
|
10115
|
-
indexed: options === null || options === void 0 ? void 0 : options.indexed,
|
|
10116
|
-
workerProvider: this.workerProvider,
|
|
10117
|
-
});
|
|
10118
|
-
if (this.genome) {
|
|
10119
|
-
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10120
|
-
}
|
|
10121
|
-
if (this.cumulativeOffsets) {
|
|
10122
|
-
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10123
|
-
}
|
|
10124
10339
|
const dataSourceConfig = {
|
|
10125
10340
|
type: 'text', url, format, indexURL: options === null || options === void 0 ? void 0 : options.indexURL, indexed: options === null || options === void 0 ? void 0 : options.indexed,
|
|
10126
10341
|
};
|
|
10342
|
+
const workerDS = this.createWorkerDataSource(dataSourceConfig);
|
|
10343
|
+
const dataSource = workerDS !== null && workerDS !== void 0 ? workerDS : (() => {
|
|
10344
|
+
const ds = new TextFeatureSource({ url, format, indexURL: options === null || options === void 0 ? void 0 : options.indexURL, indexed: options === null || options === void 0 ? void 0 : options.indexed });
|
|
10345
|
+
if (this.genome)
|
|
10346
|
+
ds.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10347
|
+
if (this.cumulativeOffsets)
|
|
10348
|
+
ds.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10349
|
+
return ds;
|
|
10350
|
+
})();
|
|
10127
10351
|
this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
|
|
10128
10352
|
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
10129
|
-
this.
|
|
10353
|
+
this.findMT(track).metadata = options.metadata;
|
|
10130
10354
|
return track;
|
|
10131
10355
|
}
|
|
10132
10356
|
/** Add an interaction (arc/BEDPE) track from a URL. */
|
|
@@ -10142,25 +10366,21 @@ class HeadlessGenomeBrowser {
|
|
|
10142
10366
|
canvasProvider: this.canvasProvider,
|
|
10143
10367
|
name: options === null || options === void 0 ? void 0 : options.name,
|
|
10144
10368
|
});
|
|
10145
|
-
const dataSource = new TextFeatureSource({
|
|
10146
|
-
url,
|
|
10147
|
-
format,
|
|
10148
|
-
indexURL: options === null || options === void 0 ? void 0 : options.indexURL,
|
|
10149
|
-
indexed: options === null || options === void 0 ? void 0 : options.indexed,
|
|
10150
|
-
workerProvider: this.workerProvider,
|
|
10151
|
-
});
|
|
10152
|
-
if (this.genome) {
|
|
10153
|
-
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10154
|
-
}
|
|
10155
|
-
if (this.cumulativeOffsets) {
|
|
10156
|
-
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10157
|
-
}
|
|
10158
10369
|
const dataSourceConfig = {
|
|
10159
10370
|
type: 'text', url, format, indexURL: options === null || options === void 0 ? void 0 : options.indexURL, indexed: options === null || options === void 0 ? void 0 : options.indexed,
|
|
10160
10371
|
};
|
|
10372
|
+
const workerDS = this.createWorkerDataSource(dataSourceConfig);
|
|
10373
|
+
const dataSource = workerDS !== null && workerDS !== void 0 ? workerDS : (() => {
|
|
10374
|
+
const ds = new TextFeatureSource({ url, format, indexURL: options === null || options === void 0 ? void 0 : options.indexURL, indexed: options === null || options === void 0 ? void 0 : options.indexed });
|
|
10375
|
+
if (this.genome)
|
|
10376
|
+
ds.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10377
|
+
if (this.cumulativeOffsets)
|
|
10378
|
+
ds.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10379
|
+
return ds;
|
|
10380
|
+
})();
|
|
10161
10381
|
this.addTrack(track, dataSource, dataSourceConfig);
|
|
10162
10382
|
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
10163
|
-
this.
|
|
10383
|
+
this.findMT(track).metadata = options.metadata;
|
|
10164
10384
|
return track;
|
|
10165
10385
|
}
|
|
10166
10386
|
/** Add a BigWig-style signal track backed by in-memory features (no URL required). */
|
|
@@ -10187,7 +10407,7 @@ class HeadlessGenomeBrowser {
|
|
|
10187
10407
|
const dataSourceConfig = { type: 'memory' };
|
|
10188
10408
|
this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
|
|
10189
10409
|
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
10190
|
-
this.
|
|
10410
|
+
this.findMT(track).metadata = options.metadata;
|
|
10191
10411
|
return track;
|
|
10192
10412
|
}
|
|
10193
10413
|
/** Add a BED/annotation track backed by in-memory features (no URL required). Features must include `chr`. */
|
|
@@ -10201,7 +10421,6 @@ class HeadlessGenomeBrowser {
|
|
|
10201
10421
|
background: options === null || options === void 0 ? void 0 : options.background,
|
|
10202
10422
|
theme: this.theme,
|
|
10203
10423
|
canvasProvider: this.canvasProvider,
|
|
10204
|
-
workerProvider: this.workerProvider,
|
|
10205
10424
|
name: options === null || options === void 0 ? void 0 : options.name,
|
|
10206
10425
|
});
|
|
10207
10426
|
const dataSource = new MemoryDataSource(features);
|
|
@@ -10214,7 +10433,7 @@ class HeadlessGenomeBrowser {
|
|
|
10214
10433
|
const dataSourceConfig = { type: 'memory' };
|
|
10215
10434
|
this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
|
|
10216
10435
|
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
10217
|
-
this.
|
|
10436
|
+
this.findMT(track).metadata = options.metadata;
|
|
10218
10437
|
return track;
|
|
10219
10438
|
}
|
|
10220
10439
|
/** Add a DNA/RNA sequence track. Data fetching is handled automatically via the genome's sequence provider. */
|
|
@@ -10246,6 +10465,10 @@ class HeadlessGenomeBrowser {
|
|
|
10246
10465
|
for (const mt of this.managedTracks) {
|
|
10247
10466
|
if (mt.abortController)
|
|
10248
10467
|
mt.abortController.abort();
|
|
10468
|
+
// Destroy worker-resident data sources
|
|
10469
|
+
if (mt.dataSource instanceof WorkerDataSource && this.dataSourceWorkerProvider) {
|
|
10470
|
+
this.dataSourceWorkerProvider.destroy(mt.dataSource.instanceId);
|
|
10471
|
+
}
|
|
10249
10472
|
}
|
|
10250
10473
|
this.managedTracks = [];
|
|
10251
10474
|
this.roiSets = [];
|
|
@@ -10273,6 +10496,30 @@ class HeadlessGenomeBrowser {
|
|
|
10273
10496
|
};
|
|
10274
10497
|
}
|
|
10275
10498
|
// ─── Data lifecycle ──────────────────────────────────────────────────────
|
|
10499
|
+
/**
|
|
10500
|
+
* Debounced wrapper around loadAllTracksIfNeeded.
|
|
10501
|
+
* During rapid zoom/pan, this coalesces multiple setLocus() calls into a
|
|
10502
|
+
* single data fetch cycle after the interaction settles (100ms quiet period).
|
|
10503
|
+
* Abort in-flight requests immediately so they don't race with the eventual fetch.
|
|
10504
|
+
*/
|
|
10505
|
+
debouncedLoad() {
|
|
10506
|
+
// Immediately abort stale in-flight requests so cancelled fetches
|
|
10507
|
+
// don't resolve after the debounce fires with a new locus.
|
|
10508
|
+
for (const mt of this.managedTracks) {
|
|
10509
|
+
if (mt.abortController) {
|
|
10510
|
+
mt.abortController.abort();
|
|
10511
|
+
mt.abortController = null;
|
|
10512
|
+
}
|
|
10513
|
+
}
|
|
10514
|
+
this.inflightFetches.clear();
|
|
10515
|
+
if (this.loadDebounceTimer !== null) {
|
|
10516
|
+
clearTimeout(this.loadDebounceTimer);
|
|
10517
|
+
}
|
|
10518
|
+
this.loadDebounceTimer = setTimeout(() => {
|
|
10519
|
+
this.loadDebounceTimer = null;
|
|
10520
|
+
this.loadAllTracksIfNeeded();
|
|
10521
|
+
}, 100);
|
|
10522
|
+
}
|
|
10276
10523
|
loadAllTracksIfNeeded() {
|
|
10277
10524
|
// Clear stale inflight entries — navigation invalidates all pending fetches
|
|
10278
10525
|
this.inflightFetches.clear();
|
|
@@ -10310,9 +10557,11 @@ class HeadlessGenomeBrowser {
|
|
|
10310
10557
|
if (mt.cache && cacheCoversViewport(mt.cache, this._locus, bpPerPixel)) {
|
|
10311
10558
|
return;
|
|
10312
10559
|
}
|
|
10313
|
-
// Cancel stale in-flight request for this track
|
|
10560
|
+
// Cancel stale in-flight request for this track and clear any
|
|
10561
|
+
// lingering error so it doesn't persist through the new fetch.
|
|
10314
10562
|
if (mt.abortController)
|
|
10315
10563
|
mt.abortController.abort();
|
|
10564
|
+
mt.track.setError(null);
|
|
10316
10565
|
const fetchRegion = bufferLocus(this._locus);
|
|
10317
10566
|
const dsKey = mt.dataSourceConfig ? dataSourceCacheKey(mt.dataSourceConfig) : null;
|
|
10318
10567
|
// Check if another track with the same data source already has a valid cache
|
|
@@ -10400,6 +10649,222 @@ class HeadlessGenomeBrowser {
|
|
|
10400
10649
|
}
|
|
10401
10650
|
}
|
|
10402
10651
|
|
|
10652
|
+
/**
|
|
10653
|
+
* WebWorkerPool — single worker pool for both stateless tasks and stateful data sources.
|
|
10654
|
+
*
|
|
10655
|
+
* Implements both WorkerProvider (for CPU-intensive tasks like pack, summarize) and
|
|
10656
|
+
* DataSourceWorkerProvider (for persistent DataSource instances in workers).
|
|
10657
|
+
*
|
|
10658
|
+
* Routing:
|
|
10659
|
+
* - Stateless tasks: round-robin across pool
|
|
10660
|
+
* - DataSource operations: sticky routing via URL hash (preserves reader caches)
|
|
10661
|
+
*
|
|
10662
|
+
* Usage:
|
|
10663
|
+
* const pool = new WebWorkerPool({
|
|
10664
|
+
* workerFactory: () => new Worker(new URL('./workerPoolScript.ts', import.meta.url)),
|
|
10665
|
+
* poolSize: 4,
|
|
10666
|
+
* })
|
|
10667
|
+
* const browser = new GenomeBrowser(container, { workerProvider: pool })
|
|
10668
|
+
* // browser auto-detects DataSourceWorkerProvider support
|
|
10669
|
+
* pool.dispose()
|
|
10670
|
+
*/
|
|
10671
|
+
// ─── Provider ────────────────────────────────────────────────────────────────
|
|
10672
|
+
class WebWorkerPool {
|
|
10673
|
+
constructor(options) {
|
|
10674
|
+
var _a;
|
|
10675
|
+
// Stateless task tracking
|
|
10676
|
+
this.nextId = 0;
|
|
10677
|
+
this.nextWorker = 0;
|
|
10678
|
+
this.pending = new Map();
|
|
10679
|
+
// Stateful data source tracking
|
|
10680
|
+
this.instanceToWorker = new Map();
|
|
10681
|
+
this.pendingFetches = new Map();
|
|
10682
|
+
this.nextFetchId = 0;
|
|
10683
|
+
const count = Math.max(1, (_a = options.poolSize) !== null && _a !== void 0 ? _a : 1);
|
|
10684
|
+
let createWorker;
|
|
10685
|
+
if (options.workerFactory) {
|
|
10686
|
+
createWorker = options.workerFactory;
|
|
10687
|
+
}
|
|
10688
|
+
else if (options.workerUrl) {
|
|
10689
|
+
const url = options.workerUrl;
|
|
10690
|
+
createWorker = () => new Worker(url, { type: 'module' });
|
|
10691
|
+
}
|
|
10692
|
+
else {
|
|
10693
|
+
throw new Error('WebWorkerPoolOptions requires either workerUrl or workerFactory');
|
|
10694
|
+
}
|
|
10695
|
+
this.workers = [];
|
|
10696
|
+
this.readyPromises = [];
|
|
10697
|
+
for (let i = 0; i < count; i++) {
|
|
10698
|
+
const worker = createWorker();
|
|
10699
|
+
// Ready handshake
|
|
10700
|
+
const readyPromise = new Promise((resolveReady) => {
|
|
10701
|
+
const onReady = (e) => {
|
|
10702
|
+
if (e.data.type === 'ready') {
|
|
10703
|
+
resolveReady();
|
|
10704
|
+
}
|
|
10705
|
+
};
|
|
10706
|
+
worker.addEventListener('message', onReady, { once: true });
|
|
10707
|
+
});
|
|
10708
|
+
this.readyPromises.push(readyPromise);
|
|
10709
|
+
worker.onmessage = (e) => {
|
|
10710
|
+
const msg = e.data;
|
|
10711
|
+
if (msg.type === 'ready')
|
|
10712
|
+
return; // handled by one-time listener
|
|
10713
|
+
if (msg.type === 'taskResult') {
|
|
10714
|
+
const p = this.pending.get(msg.id);
|
|
10715
|
+
if (p) {
|
|
10716
|
+
this.pending.delete(msg.id);
|
|
10717
|
+
if (msg.error) {
|
|
10718
|
+
p.reject(new Error(msg.error));
|
|
10719
|
+
}
|
|
10720
|
+
else {
|
|
10721
|
+
p.resolve(msg.result);
|
|
10722
|
+
}
|
|
10723
|
+
}
|
|
10724
|
+
}
|
|
10725
|
+
else if (msg.type === 'fetchResult') {
|
|
10726
|
+
const p = this.pendingFetches.get(msg.fetchId);
|
|
10727
|
+
if (p) {
|
|
10728
|
+
this.pendingFetches.delete(msg.fetchId);
|
|
10729
|
+
p.resolve(msg.features);
|
|
10730
|
+
}
|
|
10731
|
+
}
|
|
10732
|
+
else if (msg.type === 'fetchError') {
|
|
10733
|
+
const p = this.pendingFetches.get(msg.fetchId);
|
|
10734
|
+
if (p) {
|
|
10735
|
+
this.pendingFetches.delete(msg.fetchId);
|
|
10736
|
+
if (msg.error === 'AbortError') {
|
|
10737
|
+
p.reject(new DOMException('Aborted', 'AbortError'));
|
|
10738
|
+
}
|
|
10739
|
+
else {
|
|
10740
|
+
p.reject(new Error(msg.error));
|
|
10741
|
+
}
|
|
10742
|
+
}
|
|
10743
|
+
}
|
|
10744
|
+
};
|
|
10745
|
+
worker.onerror = (e) => {
|
|
10746
|
+
const error = new Error(`Worker error: ${e.message}`);
|
|
10747
|
+
for (const { reject } of this.pending.values())
|
|
10748
|
+
reject(error);
|
|
10749
|
+
this.pending.clear();
|
|
10750
|
+
for (const { reject } of this.pendingFetches.values())
|
|
10751
|
+
reject(error);
|
|
10752
|
+
this.pendingFetches.clear();
|
|
10753
|
+
};
|
|
10754
|
+
this.workers.push(worker);
|
|
10755
|
+
}
|
|
10756
|
+
}
|
|
10757
|
+
/** Number of workers in the pool. */
|
|
10758
|
+
get poolSize() {
|
|
10759
|
+
return this.workers.length;
|
|
10760
|
+
}
|
|
10761
|
+
// ─── WorkerProvider (stateless tasks) ────────────────────────────────────
|
|
10762
|
+
execute(task, transfer) {
|
|
10763
|
+
const id = this.nextId++;
|
|
10764
|
+
const worker = this.workers[this.nextWorker % this.workers.length];
|
|
10765
|
+
this.nextWorker++;
|
|
10766
|
+
return new Promise((resolve, reject) => {
|
|
10767
|
+
this.pending.set(id, {
|
|
10768
|
+
resolve: resolve,
|
|
10769
|
+
reject,
|
|
10770
|
+
});
|
|
10771
|
+
worker.postMessage({ type: 'task', id, task }, transfer !== null && transfer !== void 0 ? transfer : []);
|
|
10772
|
+
});
|
|
10773
|
+
}
|
|
10774
|
+
// ─── DataSourceWorkerProvider (stateful data sources) ────────────────────
|
|
10775
|
+
create(instanceId, config) {
|
|
10776
|
+
const workerIdx = this.routeToWorker(config);
|
|
10777
|
+
this.instanceToWorker.set(instanceId, workerIdx);
|
|
10778
|
+
const worker = this.workers[workerIdx];
|
|
10779
|
+
this.readyPromises[workerIdx].then(() => {
|
|
10780
|
+
worker.postMessage({ type: 'create', instanceId, config });
|
|
10781
|
+
});
|
|
10782
|
+
}
|
|
10783
|
+
async fetch(instanceId, locus, bpPerPixel, signal) {
|
|
10784
|
+
const workerIdx = this.instanceToWorker.get(instanceId);
|
|
10785
|
+
if (workerIdx === undefined) {
|
|
10786
|
+
throw new Error(`No worker assigned for DataSource: ${instanceId}`);
|
|
10787
|
+
}
|
|
10788
|
+
await this.readyPromises[workerIdx];
|
|
10789
|
+
const fetchId = this.nextFetchId++;
|
|
10790
|
+
const worker = this.workers[workerIdx];
|
|
10791
|
+
const onAbort = () => {
|
|
10792
|
+
worker.postMessage({ type: 'cancel', fetchId });
|
|
10793
|
+
const p = this.pendingFetches.get(fetchId);
|
|
10794
|
+
if (p) {
|
|
10795
|
+
this.pendingFetches.delete(fetchId);
|
|
10796
|
+
p.reject(new DOMException('Aborted', 'AbortError'));
|
|
10797
|
+
}
|
|
10798
|
+
};
|
|
10799
|
+
if (signal.aborted) {
|
|
10800
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
10801
|
+
}
|
|
10802
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
10803
|
+
try {
|
|
10804
|
+
return await new Promise((resolve, reject) => {
|
|
10805
|
+
this.pendingFetches.set(fetchId, {
|
|
10806
|
+
resolve: resolve,
|
|
10807
|
+
reject,
|
|
10808
|
+
});
|
|
10809
|
+
worker.postMessage({ type: 'fetch', instanceId, fetchId, locus, bpPerPixel });
|
|
10810
|
+
});
|
|
10811
|
+
}
|
|
10812
|
+
finally {
|
|
10813
|
+
signal.removeEventListener('abort', onAbort);
|
|
10814
|
+
}
|
|
10815
|
+
}
|
|
10816
|
+
configure(instanceId, method, ...args) {
|
|
10817
|
+
const workerIdx = this.instanceToWorker.get(instanceId);
|
|
10818
|
+
if (workerIdx === undefined)
|
|
10819
|
+
return;
|
|
10820
|
+
const worker = this.workers[workerIdx];
|
|
10821
|
+
this.readyPromises[workerIdx].then(() => {
|
|
10822
|
+
worker.postMessage({ type: 'configure', instanceId, method, args });
|
|
10823
|
+
});
|
|
10824
|
+
}
|
|
10825
|
+
destroy(instanceId) {
|
|
10826
|
+
const workerIdx = this.instanceToWorker.get(instanceId);
|
|
10827
|
+
if (workerIdx === undefined)
|
|
10828
|
+
return;
|
|
10829
|
+
const worker = this.workers[workerIdx];
|
|
10830
|
+
worker.postMessage({ type: 'destroy', instanceId });
|
|
10831
|
+
this.instanceToWorker.delete(instanceId);
|
|
10832
|
+
}
|
|
10833
|
+
// ─── Shared ──────────────────────────────────────────────────────────────
|
|
10834
|
+
dispose() {
|
|
10835
|
+
for (const worker of this.workers) {
|
|
10836
|
+
worker.terminate();
|
|
10837
|
+
}
|
|
10838
|
+
for (const { reject } of this.pending.values()) {
|
|
10839
|
+
reject(new Error('Worker terminated'));
|
|
10840
|
+
}
|
|
10841
|
+
this.pending.clear();
|
|
10842
|
+
for (const { reject } of this.pendingFetches.values()) {
|
|
10843
|
+
reject(new Error('Worker terminated'));
|
|
10844
|
+
}
|
|
10845
|
+
this.pendingFetches.clear();
|
|
10846
|
+
this.instanceToWorker.clear();
|
|
10847
|
+
this.workers = [];
|
|
10848
|
+
}
|
|
10849
|
+
// ─── Internal ────────────────────────────────────────────────────────────
|
|
10850
|
+
/**
|
|
10851
|
+
* Sticky routing: deterministically assign a DataSourceConfig to a worker
|
|
10852
|
+
* based on its URL. Preserves reader caches (BigWig headers, Tabix indices).
|
|
10853
|
+
*/
|
|
10854
|
+
routeToWorker(config) {
|
|
10855
|
+
const key = 'url' in config ? config.url : config.type;
|
|
10856
|
+
return Math.abs(hashString(key)) % this.workers.length;
|
|
10857
|
+
}
|
|
10858
|
+
}
|
|
10859
|
+
/** Simple string hash (djb2). */
|
|
10860
|
+
function hashString(s) {
|
|
10861
|
+
let hash = 5381;
|
|
10862
|
+
for (let i = 0; i < s.length; i++) {
|
|
10863
|
+
hash = ((hash << 5) + hash + s.charCodeAt(i)) | 0;
|
|
10864
|
+
}
|
|
10865
|
+
return hash;
|
|
10866
|
+
}
|
|
10867
|
+
|
|
10403
10868
|
/**
|
|
10404
10869
|
* CommandDispatcher — transport-agnostic command dispatch for HeadlessGenomeBrowser.
|
|
10405
10870
|
*
|
|
@@ -10870,9 +11335,8 @@ class RemoteConnection {
|
|
|
10870
11335
|
* These are pure canvas renderers — they take a 2D context, axis info, and
|
|
10871
11336
|
* dimensions, and paint the axis. No DOM, no state, no side effects.
|
|
10872
11337
|
*
|
|
10873
|
-
*
|
|
10874
|
-
*
|
|
10875
|
-
* - renderLabelAxis: centered rotated text label for annotation tracks (gene)
|
|
11338
|
+
* renderQuantitativeAxis: background, color strip, track label, and
|
|
11339
|
+
* tick marks + data range labels (when data is available) for numeric tracks.
|
|
10876
11340
|
*/
|
|
10877
11341
|
/** Width of the color strip indicator on the right edge of the axis. */
|
|
10878
11342
|
const COLOR_STRIP_WIDTH = 4;
|
|
@@ -10891,13 +11355,10 @@ function prettyPrintNumber(n) {
|
|
|
10891
11355
|
return n.toExponential(1);
|
|
10892
11356
|
}
|
|
10893
11357
|
/**
|
|
10894
|
-
* Paint a quantitative axis
|
|
11358
|
+
* Paint a quantitative axis.
|
|
10895
11359
|
*
|
|
10896
|
-
*
|
|
10897
|
-
*
|
|
10898
|
-
* - Color strip on right edge (if info.color is set)
|
|
10899
|
-
* - Vertical axis line with top/bottom ticks and labels (max/min)
|
|
10900
|
-
* - Middle tick at midpoint (if height > 60)
|
|
11360
|
+
* Always renders: background and color strip (if info.color).
|
|
11361
|
+
* When `info.dataRange` is set, also renders: vertical axis line, tick marks, and min/max/mid labels.
|
|
10901
11362
|
*/
|
|
10902
11363
|
/** Transform a data value to log space, matching wigRenderer's computeYPixel logic. */
|
|
10903
11364
|
function toLogValue(v) {
|
|
@@ -10921,19 +11382,10 @@ function valueToY(value, min, max, topY, bottomY, flip, logScale) {
|
|
|
10921
11382
|
}
|
|
10922
11383
|
function renderQuantitativeAxis(ctx, info, width, height) {
|
|
10923
11384
|
var _a, _b, _c, _d;
|
|
10924
|
-
if (
|
|
11385
|
+
if (height === 0)
|
|
10925
11386
|
return;
|
|
10926
|
-
const
|
|
10927
|
-
const
|
|
10928
|
-
const logScale = (_b = info.logScale) !== null && _b !== void 0 ? _b : false;
|
|
10929
|
-
const shim = 0.01;
|
|
10930
|
-
const topY = shim * height;
|
|
10931
|
-
const bottomY = (1.0 - shim) * height;
|
|
10932
|
-
// When flipped, min is at top and max is at bottom (used by GWAS/QTL tracks)
|
|
10933
|
-
const topValue = flip ? min : max;
|
|
10934
|
-
const bottomValue = flip ? max : min;
|
|
10935
|
-
const bg = (_c = info.backgroundColor) !== null && _c !== void 0 ? _c : 'white';
|
|
10936
|
-
const fg = (_d = info.labelColor) !== null && _d !== void 0 ? _d : 'black';
|
|
11387
|
+
const bg = (_a = info.backgroundColor) !== null && _a !== void 0 ? _a : 'white';
|
|
11388
|
+
const fg = (_b = info.labelColor) !== null && _b !== void 0 ? _b : 'black';
|
|
10937
11389
|
// Clear with background
|
|
10938
11390
|
ctx.fillStyle = bg;
|
|
10939
11391
|
ctx.fillRect(0, 0, width, height);
|
|
@@ -10942,6 +11394,9 @@ function renderQuantitativeAxis(ctx, info, width, height) {
|
|
|
10942
11394
|
ctx.fillStyle = info.color;
|
|
10943
11395
|
ctx.fillRect(width - COLOR_STRIP_WIDTH - 1, 0, COLOR_STRIP_WIDTH, height);
|
|
10944
11396
|
}
|
|
11397
|
+
const shim = 0.01;
|
|
11398
|
+
const topY = shim * height;
|
|
11399
|
+
const bottomY = (1.0 - shim) * height;
|
|
10945
11400
|
const tickEnd = width - COLOR_STRIP_WIDTH - 3;
|
|
10946
11401
|
const tickStart = tickEnd - 6;
|
|
10947
11402
|
ctx.strokeStyle = fg;
|
|
@@ -10949,30 +11404,37 @@ function renderQuantitativeAxis(ctx, info, width, height) {
|
|
|
10949
11404
|
ctx.font = 'normal 9px Arial';
|
|
10950
11405
|
ctx.textAlign = 'right';
|
|
10951
11406
|
ctx.lineWidth = 1;
|
|
10952
|
-
// Vertical axis line
|
|
11407
|
+
// Vertical axis line + top/bottom ticks (always drawn)
|
|
10953
11408
|
ctx.beginPath();
|
|
10954
11409
|
ctx.moveTo(tickEnd, topY);
|
|
10955
11410
|
ctx.lineTo(tickEnd, bottomY);
|
|
10956
11411
|
ctx.stroke();
|
|
10957
|
-
// Top tick + label
|
|
10958
11412
|
ctx.beginPath();
|
|
10959
11413
|
ctx.moveTo(tickStart, topY);
|
|
10960
11414
|
ctx.lineTo(tickEnd, topY);
|
|
10961
11415
|
ctx.stroke();
|
|
10962
|
-
ctx.textBaseline = 'top';
|
|
10963
|
-
ctx.fillText(prettyPrintNumber(topValue), tickStart - 2, topY + 1);
|
|
10964
|
-
// Bottom tick + label
|
|
10965
11416
|
ctx.beginPath();
|
|
10966
11417
|
ctx.moveTo(tickStart, bottomY);
|
|
10967
11418
|
ctx.lineTo(tickEnd, bottomY);
|
|
10968
11419
|
ctx.stroke();
|
|
11420
|
+
// Data range labels — only when we have data
|
|
11421
|
+
if (!info.dataRange)
|
|
11422
|
+
return;
|
|
11423
|
+
const { min, max } = info.dataRange;
|
|
11424
|
+
const flip = (_c = info.flipAxis) !== null && _c !== void 0 ? _c : false;
|
|
11425
|
+
const logScale = (_d = info.logScale) !== null && _d !== void 0 ? _d : false;
|
|
11426
|
+
const topValue = flip ? min : max;
|
|
11427
|
+
const bottomValue = flip ? max : min;
|
|
11428
|
+
// Top label
|
|
11429
|
+
ctx.textBaseline = 'top';
|
|
11430
|
+
ctx.fillText(prettyPrintNumber(topValue), tickStart - 2, topY + 1);
|
|
11431
|
+
// Bottom label
|
|
10969
11432
|
ctx.textBaseline = 'bottom';
|
|
10970
11433
|
ctx.fillText(prettyPrintNumber(bottomValue), tickStart - 2, bottomY - 1);
|
|
10971
|
-
// Middle tick
|
|
11434
|
+
// Middle tick + label
|
|
10972
11435
|
if (height > 60) {
|
|
10973
11436
|
const midVal = logScale
|
|
10974
11437
|
? (() => {
|
|
10975
|
-
// Midpoint in log space, converted back to data space
|
|
10976
11438
|
const logMid = (toLogValue(min) + toLogValue(max)) / 2;
|
|
10977
11439
|
return logMid >= 0 ? Math.pow(10, logMid) - 1 : -(Math.pow(10, -logMid) - 1);
|
|
10978
11440
|
})()
|
|
@@ -10986,31 +11448,6 @@ function renderQuantitativeAxis(ctx, info, width, height) {
|
|
|
10986
11448
|
ctx.fillText(prettyPrintNumber(midVal), tickStart + 1, midY);
|
|
10987
11449
|
}
|
|
10988
11450
|
}
|
|
10989
|
-
/**
|
|
10990
|
-
* Paint a label-only axis (e.g., gene track name).
|
|
10991
|
-
* Renders a vertically-rotated centered text label.
|
|
10992
|
-
*/
|
|
10993
|
-
function renderLabelAxis(ctx, info, width, height) {
|
|
10994
|
-
var _a, _b;
|
|
10995
|
-
if (!info.label || height === 0)
|
|
10996
|
-
return;
|
|
10997
|
-
const bg = (_a = info.backgroundColor) !== null && _a !== void 0 ? _a : 'white';
|
|
10998
|
-
const fg = (_b = info.labelColor) !== null && _b !== void 0 ? _b : '#333';
|
|
10999
|
-
// Clear with background
|
|
11000
|
-
ctx.fillStyle = bg;
|
|
11001
|
-
ctx.fillRect(0, 0, width, height);
|
|
11002
|
-
ctx.font = '10px sans-serif';
|
|
11003
|
-
ctx.fillStyle = fg;
|
|
11004
|
-
ctx.textAlign = 'center';
|
|
11005
|
-
ctx.textBaseline = 'middle';
|
|
11006
|
-
ctx.save();
|
|
11007
|
-
ctx.translate(width / 2, height / 2);
|
|
11008
|
-
ctx.rotate(-Math.PI / 2);
|
|
11009
|
-
// Clip text to available height
|
|
11010
|
-
const maxTextWidth = height - 10;
|
|
11011
|
-
ctx.fillText(info.label, 0, 0, maxTextWidth);
|
|
11012
|
-
ctx.restore();
|
|
11013
|
-
}
|
|
11014
11451
|
|
|
11015
11452
|
/**
|
|
11016
11453
|
* Common context menu item factories.
|
|
@@ -11145,6 +11582,157 @@ function roiContextMenuItems(roi, callbacks) {
|
|
|
11145
11582
|
];
|
|
11146
11583
|
}
|
|
11147
11584
|
|
|
11585
|
+
/**
|
|
11586
|
+
* SVG overlay for interactive feature highlighting.
|
|
11587
|
+
*
|
|
11588
|
+
* Positions transparent SVG `<rect>` elements on top of a track's canvas,
|
|
11589
|
+
* providing native CSS `:hover` states and click targets without canvas repaints.
|
|
11590
|
+
* Follows the same DOM-over-canvas pattern used by ROI overlays in GenomeBrowser.
|
|
11591
|
+
*
|
|
11592
|
+
* On hover, features get a tinted fill matching their own color plus a subtle
|
|
11593
|
+
* glow/shadow effect, making the canvas-rendered feature feel highlighted.
|
|
11594
|
+
*/
|
|
11595
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
11596
|
+
/** Default glow color when feature has no color. */
|
|
11597
|
+
const DEFAULT_GLOW_COLOR = 'rgb(0, 0, 150)';
|
|
11598
|
+
/** Padding (px) added around the feature rect for the glow area. */
|
|
11599
|
+
const GLOW_PADDING = 3;
|
|
11600
|
+
class SVGFeatureOverlay {
|
|
11601
|
+
constructor(container) {
|
|
11602
|
+
this.svg = document.createElementNS(SVG_NS, 'svg');
|
|
11603
|
+
this.svg.classList.add('loom-feature-overlay');
|
|
11604
|
+
this.svg.style.cssText = `
|
|
11605
|
+
position: absolute;
|
|
11606
|
+
top: 0;
|
|
11607
|
+
left: 0;
|
|
11608
|
+
width: 100%;
|
|
11609
|
+
height: 100%;
|
|
11610
|
+
pointer-events: none;
|
|
11611
|
+
overflow: visible;
|
|
11612
|
+
`;
|
|
11613
|
+
this.defs = document.createElementNS(SVG_NS, 'defs');
|
|
11614
|
+
this.svg.appendChild(this.defs);
|
|
11615
|
+
this.group = document.createElementNS(SVG_NS, 'g');
|
|
11616
|
+
this.svg.appendChild(this.group);
|
|
11617
|
+
container.style.position = 'relative';
|
|
11618
|
+
container.appendChild(this.svg);
|
|
11619
|
+
}
|
|
11620
|
+
/** Rebuild SVG rects from feature geometry. */
|
|
11621
|
+
update(rects) {
|
|
11622
|
+
var _a;
|
|
11623
|
+
this.group.textContent = '';
|
|
11624
|
+
this.defs.textContent = '';
|
|
11625
|
+
if (rects.length === 0 || rects.length > 500)
|
|
11626
|
+
return;
|
|
11627
|
+
const frag = document.createDocumentFragment();
|
|
11628
|
+
for (let i = 0; i < rects.length; i++) {
|
|
11629
|
+
const rect = rects[i];
|
|
11630
|
+
const color = (_a = rect.color) !== null && _a !== void 0 ? _a : DEFAULT_GLOW_COLOR;
|
|
11631
|
+
const filterId = `glow-${i}`;
|
|
11632
|
+
this.defs.appendChild(this.createGlowFilter(filterId, color));
|
|
11633
|
+
frag.appendChild(this.createRect(rect, filterId));
|
|
11634
|
+
}
|
|
11635
|
+
this.group.appendChild(frag);
|
|
11636
|
+
}
|
|
11637
|
+
/** Suppress pointer events on overlay rects (e.g., during drag). */
|
|
11638
|
+
setSuppressed(suppressed) {
|
|
11639
|
+
this.group.style.pointerEvents = suppressed ? 'none' : '';
|
|
11640
|
+
}
|
|
11641
|
+
dispose() {
|
|
11642
|
+
this.svg.remove();
|
|
11643
|
+
}
|
|
11644
|
+
/** Create an SVG filter that produces a colored glow. */
|
|
11645
|
+
createGlowFilter(id, color) {
|
|
11646
|
+
const filter = document.createElementNS(SVG_NS, 'filter');
|
|
11647
|
+
filter.setAttribute('id', id);
|
|
11648
|
+
// Expand filter region to allow glow beyond rect bounds
|
|
11649
|
+
filter.setAttribute('x', '-20%');
|
|
11650
|
+
filter.setAttribute('y', '-40%');
|
|
11651
|
+
filter.setAttribute('width', '140%');
|
|
11652
|
+
filter.setAttribute('height', '180%');
|
|
11653
|
+
// Flood with feature color
|
|
11654
|
+
const flood = document.createElementNS(SVG_NS, 'feFlood');
|
|
11655
|
+
flood.setAttribute('flood-color', color);
|
|
11656
|
+
flood.setAttribute('flood-opacity', '0.4');
|
|
11657
|
+
flood.setAttribute('result', 'color');
|
|
11658
|
+
filter.appendChild(flood);
|
|
11659
|
+
// Clip flood to rect shape
|
|
11660
|
+
const composite = document.createElementNS(SVG_NS, 'feComposite');
|
|
11661
|
+
composite.setAttribute('in', 'color');
|
|
11662
|
+
composite.setAttribute('in2', 'SourceGraphic');
|
|
11663
|
+
composite.setAttribute('operator', 'in');
|
|
11664
|
+
composite.setAttribute('result', 'colored');
|
|
11665
|
+
filter.appendChild(composite);
|
|
11666
|
+
// Blur for glow
|
|
11667
|
+
const blur = document.createElementNS(SVG_NS, 'feGaussianBlur');
|
|
11668
|
+
blur.setAttribute('in', 'colored');
|
|
11669
|
+
blur.setAttribute('stdDeviation', '3');
|
|
11670
|
+
blur.setAttribute('result', 'glow');
|
|
11671
|
+
filter.appendChild(blur);
|
|
11672
|
+
// Layer: glow behind, then original rect on top
|
|
11673
|
+
const merge = document.createElementNS(SVG_NS, 'feMerge');
|
|
11674
|
+
const node1 = document.createElementNS(SVG_NS, 'feMergeNode');
|
|
11675
|
+
node1.setAttribute('in', 'glow');
|
|
11676
|
+
const node2 = document.createElementNS(SVG_NS, 'feMergeNode');
|
|
11677
|
+
node2.setAttribute('in', 'SourceGraphic');
|
|
11678
|
+
merge.appendChild(node1);
|
|
11679
|
+
merge.appendChild(node2);
|
|
11680
|
+
filter.appendChild(merge);
|
|
11681
|
+
return filter;
|
|
11682
|
+
}
|
|
11683
|
+
createRect(rect, filterId) {
|
|
11684
|
+
var _a;
|
|
11685
|
+
const el = document.createElementNS(SVG_NS, 'rect');
|
|
11686
|
+
// Expand the rect slightly so the hover target covers the full feature
|
|
11687
|
+
// and the glow extends naturally beyond.
|
|
11688
|
+
el.setAttribute('x', String(rect.x - GLOW_PADDING));
|
|
11689
|
+
el.setAttribute('y', String(rect.y - GLOW_PADDING));
|
|
11690
|
+
el.setAttribute('width', String(rect.width + GLOW_PADDING * 2));
|
|
11691
|
+
el.setAttribute('height', String(rect.height + GLOW_PADDING * 2));
|
|
11692
|
+
el.setAttribute('rx', '2');
|
|
11693
|
+
const filterRef = `url(#${filterId})`;
|
|
11694
|
+
const color = (_a = rect.color) !== null && _a !== void 0 ? _a : DEFAULT_GLOW_COLOR;
|
|
11695
|
+
el.addEventListener('mouseenter', () => {
|
|
11696
|
+
el.style.fill = toRGBA(color, 0.15);
|
|
11697
|
+
el.style.filter = filterRef;
|
|
11698
|
+
});
|
|
11699
|
+
el.addEventListener('mouseleave', () => {
|
|
11700
|
+
el.style.fill = 'transparent';
|
|
11701
|
+
el.style.filter = '';
|
|
11702
|
+
});
|
|
11703
|
+
el.addEventListener('click', (e) => {
|
|
11704
|
+
var _a;
|
|
11705
|
+
e.stopPropagation();
|
|
11706
|
+
(_a = this.onFeatureClick) === null || _a === void 0 ? void 0 : _a.call(this, rect, e);
|
|
11707
|
+
});
|
|
11708
|
+
return el;
|
|
11709
|
+
}
|
|
11710
|
+
}
|
|
11711
|
+
/**
|
|
11712
|
+
* Convert a CSS color string to rgba with the given alpha.
|
|
11713
|
+
* Handles `rgb(r, g, b)`, `rgba(r, g, b, a)`, and hex formats.
|
|
11714
|
+
*/
|
|
11715
|
+
function toRGBA(color, alpha) {
|
|
11716
|
+
// Already rgba — replace the alpha
|
|
11717
|
+
const rgbaMatch = color.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
|
|
11718
|
+
if (rgbaMatch) {
|
|
11719
|
+
return `rgba(${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}, ${alpha})`;
|
|
11720
|
+
}
|
|
11721
|
+
// Hex color
|
|
11722
|
+
const hexMatch = color.match(/^#([0-9a-f]{3,8})$/i);
|
|
11723
|
+
if (hexMatch) {
|
|
11724
|
+
let hex = hexMatch[1];
|
|
11725
|
+
if (hex.length === 3)
|
|
11726
|
+
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
11727
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
11728
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
11729
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
11730
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
11731
|
+
}
|
|
11732
|
+
// Fallback: can't parse, just return with opacity
|
|
11733
|
+
return color;
|
|
11734
|
+
}
|
|
11735
|
+
|
|
11148
11736
|
/**
|
|
11149
11737
|
* DOM-attached GenomeBrowser — extends HeadlessGenomeBrowser with pointer events,
|
|
11150
11738
|
* ResizeObserver, canvas stacking, and sweep-to-zoom.
|
|
@@ -11259,6 +11847,11 @@ function injectScrollStyles(container) {
|
|
|
11259
11847
|
style.textContent = `
|
|
11260
11848
|
.loom-track-scroll { scrollbar-width: none; }
|
|
11261
11849
|
.loom-track-scroll::-webkit-scrollbar { display: none; }
|
|
11850
|
+
.loom-feature-overlay rect {
|
|
11851
|
+
fill: transparent;
|
|
11852
|
+
pointer-events: auto;
|
|
11853
|
+
cursor: pointer;
|
|
11854
|
+
}
|
|
11262
11855
|
`;
|
|
11263
11856
|
if (root instanceof Document) {
|
|
11264
11857
|
root.head.appendChild(style);
|
|
@@ -11269,7 +11862,7 @@ function injectScrollStyles(container) {
|
|
|
11269
11862
|
}
|
|
11270
11863
|
class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
11271
11864
|
constructor(container, options) {
|
|
11272
|
-
var _a, _b, _c;
|
|
11865
|
+
var _a, _b, _c, _d;
|
|
11273
11866
|
// Default-on: create providers unless explicitly set to null.
|
|
11274
11867
|
const contextMenuProvider = options.contextMenuProvider === null
|
|
11275
11868
|
? undefined
|
|
@@ -11277,8 +11870,33 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11277
11870
|
const popupProvider = options.popupProvider === null
|
|
11278
11871
|
? undefined
|
|
11279
11872
|
: ((_b = options.popupProvider) !== null && _b !== void 0 ? _b : createDefaultPopupProvider());
|
|
11873
|
+
// Auto-create worker pool when `workers` or `workerFactory` is set
|
|
11874
|
+
// and no explicit workerProvider was passed.
|
|
11875
|
+
let workerProvider = options.workerProvider;
|
|
11876
|
+
let ownedProvider = null;
|
|
11877
|
+
if (!workerProvider && (options.workers || options.workerFactory)) {
|
|
11878
|
+
const poolSize = typeof options.workers === 'number'
|
|
11879
|
+
? options.workers
|
|
11880
|
+
: Math.min((_c = navigator === null || navigator === void 0 ? void 0 : navigator.hardwareConcurrency) !== null && _c !== void 0 ? _c : 4, 4);
|
|
11881
|
+
try {
|
|
11882
|
+
ownedProvider = new WebWorkerPool({
|
|
11883
|
+
workerFactory: options.workerFactory,
|
|
11884
|
+
// webpackIgnore prevents webpack from statically resolving this URL at build time.
|
|
11885
|
+
// At runtime in the dist bundle, import.meta.url → dist/loom.esm.js,
|
|
11886
|
+
// and loom-worker.js is a sibling file in dist/.
|
|
11887
|
+
workerUrl: options.workerFactory ? undefined : new URL(/* webpackIgnore: true */ './loom-worker.js', import.meta.url),
|
|
11888
|
+
poolSize,
|
|
11889
|
+
});
|
|
11890
|
+
workerProvider = ownedProvider;
|
|
11891
|
+
}
|
|
11892
|
+
catch (_f) {
|
|
11893
|
+
// Worker creation failed (CSP, bundler, etc.) — fall back to main thread
|
|
11894
|
+
console.warn('[loom] Failed to create web workers, falling back to main-thread execution');
|
|
11895
|
+
}
|
|
11896
|
+
}
|
|
11280
11897
|
super({
|
|
11281
11898
|
...options,
|
|
11899
|
+
workerProvider,
|
|
11282
11900
|
contextMenuProvider,
|
|
11283
11901
|
popupProvider,
|
|
11284
11902
|
viewportWidth: container.clientWidth,
|
|
@@ -11329,8 +11947,13 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11329
11947
|
// ROI overlay state
|
|
11330
11948
|
this.roiOverlayContainer = null;
|
|
11331
11949
|
this.roiElements = new Map();
|
|
11950
|
+
// Per-track SVG feature overlays for hover/click interactivity
|
|
11951
|
+
this.featureOverlays = new Map();
|
|
11952
|
+
/** Worker provider auto-created by `workers` option. Disposed on cleanup. */
|
|
11953
|
+
this.ownedWorkerProvider = null;
|
|
11954
|
+
this.ownedWorkerProvider = ownedProvider;
|
|
11332
11955
|
this.container = container;
|
|
11333
|
-
this.interactive = (
|
|
11956
|
+
this.interactive = (_d = options.interactive) !== null && _d !== void 0 ? _d : true;
|
|
11334
11957
|
injectScrollStyles(container);
|
|
11335
11958
|
container.style.userSelect = "none";
|
|
11336
11959
|
container.style.touchAction = "none";
|
|
@@ -11366,6 +11989,7 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11366
11989
|
// This may repaint the axis twice on initial load (negligible for a 50px canvas).
|
|
11367
11990
|
this.events.on(BrowserEvent.DataLoaded, ({ track }) => {
|
|
11368
11991
|
this.updateAxisContent(track);
|
|
11992
|
+
this.updateFeatureOverlay(track);
|
|
11369
11993
|
});
|
|
11370
11994
|
// Repaint axis canvases when theme changes (track canvases update via
|
|
11371
11995
|
// track.setTheme(), but axis sidebar is managed here in GenomeBrowser).
|
|
@@ -11380,13 +12004,11 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11380
12004
|
this.events.on(BrowserEvent.ROIChanged, () => this.renderROIOverlays());
|
|
11381
12005
|
this.events.on(BrowserEvent.LocusChange, () => this.renderROIOverlays());
|
|
11382
12006
|
}
|
|
11383
|
-
/**
|
|
11384
|
-
|
|
11385
|
-
const id = super.addTrack(track, dataSource, dataSourceConfig, maxTrackHeight);
|
|
12007
|
+
/** Build a DOM row for a track (axis column + viewport wrapper). */
|
|
12008
|
+
_buildTrackRow(track, maxTrackHeight) {
|
|
11386
12009
|
const canvas = track.canvas;
|
|
11387
12010
|
canvas.style.display = "block";
|
|
11388
12011
|
canvas.style.width = "100%";
|
|
11389
|
-
// Flex row: [axis (50px)] [viewport (flex: 1)]
|
|
11390
12012
|
const row = document.createElement("div");
|
|
11391
12013
|
row.style.display = "flex";
|
|
11392
12014
|
row.style.width = "100%";
|
|
@@ -11401,10 +12023,8 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11401
12023
|
const viewportWrapper = document.createElement("div");
|
|
11402
12024
|
viewportWrapper.style.flex = "1";
|
|
11403
12025
|
viewportWrapper.style.minWidth = "0";
|
|
11404
|
-
|
|
11405
|
-
|
|
11406
|
-
if (mt.maxTrackHeight != null) {
|
|
11407
|
-
viewportWrapper.style.maxHeight = `${mt.maxTrackHeight}px`;
|
|
12026
|
+
if (maxTrackHeight != null) {
|
|
12027
|
+
viewportWrapper.style.maxHeight = `${maxTrackHeight}px`;
|
|
11408
12028
|
viewportWrapper.style.overflowY = "auto";
|
|
11409
12029
|
viewportWrapper.style.overscrollBehaviorY = "none";
|
|
11410
12030
|
viewportWrapper.style.backgroundColor =
|
|
@@ -11414,15 +12034,33 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11414
12034
|
viewportWrapper.appendChild(canvas);
|
|
11415
12035
|
row.appendChild(axisDiv);
|
|
11416
12036
|
row.appendChild(viewportWrapper);
|
|
11417
|
-
|
|
11418
|
-
this.
|
|
11419
|
-
|
|
11420
|
-
|
|
11421
|
-
|
|
11422
|
-
|
|
11423
|
-
|
|
12037
|
+
// Create SVG feature overlay for hover/click interactivity
|
|
12038
|
+
if (this.interactive) {
|
|
12039
|
+
const overlay = new SVGFeatureOverlay(viewportWrapper);
|
|
12040
|
+
overlay.onFeatureClick = (rect, event) => {
|
|
12041
|
+
// Resolve interaction using the rect center, then show popup
|
|
12042
|
+
const cx = rect.x + rect.width / 2;
|
|
12043
|
+
const cy = rect.y + rect.height / 2;
|
|
12044
|
+
const interaction = this.resolveInteraction(track, cx, cy);
|
|
12045
|
+
if (interaction) {
|
|
12046
|
+
this.events.emit(BrowserEvent.TrackClick, interaction);
|
|
12047
|
+
if (this.popupProvider && interaction.popupData.length > 0) {
|
|
12048
|
+
const containerRect = this.container.getBoundingClientRect();
|
|
12049
|
+
const canvasRect = track.canvas.getBoundingClientRect();
|
|
12050
|
+
this.popupProvider.show(interaction.popupData, {
|
|
12051
|
+
x: canvasRect.left - containerRect.left + event.offsetX,
|
|
12052
|
+
y: canvasRect.top - containerRect.top + event.offsetY,
|
|
12053
|
+
}, this.container);
|
|
12054
|
+
}
|
|
12055
|
+
}
|
|
12056
|
+
};
|
|
12057
|
+
this.featureOverlays.set(track, overlay);
|
|
12058
|
+
}
|
|
12059
|
+
return { row, axisDiv, axisCanvas: null, viewportWrapper };
|
|
12060
|
+
}
|
|
12061
|
+
/** Post-registration UI setup for a track row (axis content, cursors, drag handlers). */
|
|
12062
|
+
_setupTrackRowUI(track) {
|
|
11424
12063
|
// Suppress on-canvas data range labels for wig tracks — the axis column handles it.
|
|
11425
|
-
// This keeps showDataRange=true as the default for standalone WigTrackCanvas users.
|
|
11426
12064
|
if (track.type === "wig") {
|
|
11427
12065
|
track.setConfig({
|
|
11428
12066
|
showDataRange: false,
|
|
@@ -11431,24 +12069,38 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11431
12069
|
this.updateAxisContent(track);
|
|
11432
12070
|
// Ruler tracks: crosshair for sweep-to-zoom, pointer in WG mode (click to navigate)
|
|
11433
12071
|
if (this.interactive && track.type === "ruler") {
|
|
11434
|
-
canvas.style.cursor = isWholeGenomeView(this._locus)
|
|
12072
|
+
track.canvas.style.cursor = isWholeGenomeView(this._locus)
|
|
11435
12073
|
? "pointer"
|
|
11436
12074
|
: "crosshair";
|
|
11437
12075
|
}
|
|
11438
12076
|
// Enable drag-to-reorder on the axis column for non-ruler tracks
|
|
11439
|
-
|
|
11440
|
-
|
|
12077
|
+
const entry = this.trackRows.get(track);
|
|
12078
|
+
if (this.interactive && track.type !== "ruler" && entry) {
|
|
12079
|
+
this.setupReorderHandlers(track, entry.axisDiv);
|
|
11441
12080
|
}
|
|
12081
|
+
}
|
|
12082
|
+
/** Add a track and attach its canvas to the container. */
|
|
12083
|
+
addTrack(track, dataSource, dataSourceConfig, maxTrackHeight, order) {
|
|
12084
|
+
// Build and register DOM row BEFORE super.addTrack() so that
|
|
12085
|
+
// syncDOMOrder() (called from sortTracks) can position it correctly.
|
|
12086
|
+
const trackRow = this._buildTrackRow(track, maxTrackHeight);
|
|
12087
|
+
this.container.appendChild(trackRow.row);
|
|
12088
|
+
this.trackRows.set(track, trackRow);
|
|
12089
|
+
const id = super.addTrack(track, dataSource, dataSourceConfig, maxTrackHeight, order);
|
|
12090
|
+
this._setupTrackRowUI(track);
|
|
11442
12091
|
return id;
|
|
11443
12092
|
}
|
|
11444
12093
|
/** Remove a track and its row from the container. */
|
|
11445
12094
|
removeTrack(trackOrId) {
|
|
12095
|
+
var _a;
|
|
11446
12096
|
const track = typeof trackOrId === 'string'
|
|
11447
12097
|
? this.getTrack(trackOrId)
|
|
11448
12098
|
: trackOrId;
|
|
11449
12099
|
if (!track)
|
|
11450
12100
|
return;
|
|
11451
12101
|
this.teardownReorderHandlers(track);
|
|
12102
|
+
(_a = this.featureOverlays.get(track)) === null || _a === void 0 ? void 0 : _a.dispose();
|
|
12103
|
+
this.featureOverlays.delete(track);
|
|
11452
12104
|
const entry = this.trackRows.get(track);
|
|
11453
12105
|
if (entry && entry.row.parentNode === this.container) {
|
|
11454
12106
|
this.container.removeChild(entry.row);
|
|
@@ -11503,6 +12155,8 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11503
12155
|
}
|
|
11504
12156
|
// Re-render ROI overlays
|
|
11505
12157
|
this.renderROIOverlays();
|
|
12158
|
+
// Update feature overlays
|
|
12159
|
+
this.updateFeatureOverlays();
|
|
11506
12160
|
}
|
|
11507
12161
|
/** Clean up event listeners, remove canvases, and dispose headless core. */
|
|
11508
12162
|
// ─── Remote connection ────────────────────────────────────────────────
|
|
@@ -11552,6 +12206,9 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11552
12206
|
}
|
|
11553
12207
|
this.removeSweepOverlay();
|
|
11554
12208
|
this.clearROIOverlay();
|
|
12209
|
+
for (const overlay of this.featureOverlays.values())
|
|
12210
|
+
overlay.dispose();
|
|
12211
|
+
this.featureOverlays.clear();
|
|
11555
12212
|
for (const mt of this.managedTracks) {
|
|
11556
12213
|
this.teardownReorderHandlers(mt.track);
|
|
11557
12214
|
const entry = this.trackRows.get(mt.track);
|
|
@@ -11562,6 +12219,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11562
12219
|
this.reorderHandlers.clear();
|
|
11563
12220
|
this.trackRows.clear();
|
|
11564
12221
|
super.dispose();
|
|
12222
|
+
if (this.ownedWorkerProvider) {
|
|
12223
|
+
this.ownedWorkerProvider.dispose();
|
|
12224
|
+
this.ownedWorkerProvider = null;
|
|
12225
|
+
}
|
|
11565
12226
|
}
|
|
11566
12227
|
/**
|
|
11567
12228
|
* Update the axis content for a track based on its getAxisInfo().
|
|
@@ -11596,12 +12257,7 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11596
12257
|
const visibleHeight = (mt === null || mt === void 0 ? void 0 : mt.maxTrackHeight) != null
|
|
11597
12258
|
? Math.min(track.height, mt.maxTrackHeight)
|
|
11598
12259
|
: track.height;
|
|
11599
|
-
|
|
11600
|
-
this.paintAxisCanvas(entry, info, visibleHeight, renderQuantitativeAxis);
|
|
11601
|
-
}
|
|
11602
|
-
else if (info.label) {
|
|
11603
|
-
this.paintAxisCanvas(entry, info, visibleHeight, renderLabelAxis);
|
|
11604
|
-
}
|
|
12260
|
+
this.paintAxisCanvas(entry, info, visibleHeight, renderQuantitativeAxis);
|
|
11605
12261
|
}
|
|
11606
12262
|
/**
|
|
11607
12263
|
* Prepare an axis canvas at the correct size with DPR scaling,
|
|
@@ -11714,6 +12370,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11714
12370
|
this.lastPointerX = e.clientX;
|
|
11715
12371
|
this.container.setPointerCapture(e.pointerId);
|
|
11716
12372
|
this.container.style.cursor = "grabbing";
|
|
12373
|
+
// Suppress feature overlay pointer events during drag
|
|
12374
|
+
for (const overlay of this.featureOverlays.values()) {
|
|
12375
|
+
overlay.setSuppressed(true);
|
|
12376
|
+
}
|
|
11717
12377
|
};
|
|
11718
12378
|
this.handlePointerMove = (e) => {
|
|
11719
12379
|
if (this.isSweeping && this.sweepOverlay && this.sweepRulerCanvas) {
|
|
@@ -11786,6 +12446,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11786
12446
|
const wasDragging = this.isDragging;
|
|
11787
12447
|
this.isDragging = false;
|
|
11788
12448
|
this.container.style.cursor = "grab";
|
|
12449
|
+
// Restore feature overlay pointer events
|
|
12450
|
+
for (const overlay of this.featureOverlays.values()) {
|
|
12451
|
+
overlay.setSuppressed(false);
|
|
12452
|
+
}
|
|
11789
12453
|
// Click detection: pointer didn't move more than threshold
|
|
11790
12454
|
const dx = e.clientX - this.pointerDownX;
|
|
11791
12455
|
const dy = e.clientY - this.pointerDownY;
|
|
@@ -11961,7 +12625,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11961
12625
|
// Common items (set height, remove)
|
|
11962
12626
|
const callbacks = {
|
|
11963
12627
|
setTrackHeight: (t, h) => {
|
|
11964
|
-
if (t instanceof
|
|
12628
|
+
if (t instanceof AnnotationTrackCanvas) {
|
|
12629
|
+
t.setFixedHeight(h);
|
|
12630
|
+
}
|
|
12631
|
+
else if (t instanceof BaseTrackCanvas) {
|
|
11965
12632
|
t.setConfig({ height: h });
|
|
11966
12633
|
}
|
|
11967
12634
|
},
|
|
@@ -12057,6 +12724,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
12057
12724
|
}
|
|
12058
12725
|
}
|
|
12059
12726
|
// ─── Track reorder drag ─────────────────────────────────────────────
|
|
12727
|
+
/** Sync DOM after any sort (addTrack, addGeneTrack, etc.). */
|
|
12728
|
+
onTracksSorted() {
|
|
12729
|
+
this.syncDOMOrder();
|
|
12730
|
+
}
|
|
12060
12731
|
/** Reorder DOM rows to match managedTracks order. */
|
|
12061
12732
|
syncDOMOrder() {
|
|
12062
12733
|
for (const mt of this.managedTracks) {
|
|
@@ -12133,6 +12804,8 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
12133
12804
|
if (dragEntry) {
|
|
12134
12805
|
dragEntry.axisDiv.style.cursor = "grab";
|
|
12135
12806
|
}
|
|
12807
|
+
// Persist the drag result so future addTrack sorts don't revert positions.
|
|
12808
|
+
this._assignOrderFromPosition();
|
|
12136
12809
|
// Emit the final order change event
|
|
12137
12810
|
this.events.emit(BrowserEvent.TrackOrderChanged, {
|
|
12138
12811
|
tracks: this.managedTracks.map((mt) => mt.track),
|
|
@@ -12255,6 +12928,21 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
12255
12928
|
* DOM-based (not canvas) for easy hover/click interaction without
|
|
12256
12929
|
* re-rendering track canvases.
|
|
12257
12930
|
*/
|
|
12931
|
+
/** Update all feature overlays after render. */
|
|
12932
|
+
updateFeatureOverlays() {
|
|
12933
|
+
for (const mt of this.managedTracks) {
|
|
12934
|
+
this.updateFeatureOverlay(mt.track);
|
|
12935
|
+
}
|
|
12936
|
+
}
|
|
12937
|
+
/** Update the feature overlay for a single track. */
|
|
12938
|
+
updateFeatureOverlay(track) {
|
|
12939
|
+
const overlay = this.featureOverlays.get(track);
|
|
12940
|
+
if (!overlay)
|
|
12941
|
+
return;
|
|
12942
|
+
if (track instanceof BaseTrackCanvas) {
|
|
12943
|
+
overlay.update(track.getFeatureRects());
|
|
12944
|
+
}
|
|
12945
|
+
}
|
|
12258
12946
|
renderROIOverlays() {
|
|
12259
12947
|
var _a, _b;
|
|
12260
12948
|
const visibleROIs = this.getVisibleROIs();
|
|
@@ -12501,9 +13189,6 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
12501
13189
|
if (info.dataRange) {
|
|
12502
13190
|
renderQuantitativeAxis(ctx, info, axisWidth, h);
|
|
12503
13191
|
}
|
|
12504
|
-
else if (info.label) {
|
|
12505
|
-
renderLabelAxis(ctx, info, axisWidth, h);
|
|
12506
|
-
}
|
|
12507
13192
|
ctx.restore();
|
|
12508
13193
|
}
|
|
12509
13194
|
// Render track (right of axis)
|
|
@@ -12618,7 +13303,7 @@ function inferShellTheme(theme) {
|
|
|
12618
13303
|
return luminance < 0.5 ? 'dark' : 'modern';
|
|
12619
13304
|
}
|
|
12620
13305
|
const LoomBrowser = forwardRef(function LoomBrowser(props, ref) {
|
|
12621
|
-
const { defaultLocus, locus, onLocusChange, theme, genome, interactive, wheelZoom, workerProvider, popupProvider, contextMenuProvider, ruler, genes, sequence, session, tracks: trackConfigs, remoteSocket, className, style, children, } = props;
|
|
13306
|
+
const { defaultLocus, locus, onLocusChange, theme, genome, interactive, wheelZoom, workers, workerFactory, workerProvider, popupProvider, contextMenuProvider, ruler, genes, sequence, session, tracks: trackConfigs, remoteSocket, className, style, children, } = props;
|
|
12622
13307
|
const containerRef = useRef(null);
|
|
12623
13308
|
const browserRef = useRef(null);
|
|
12624
13309
|
const [browser, setBrowser] = useState(null);
|
|
@@ -12641,6 +13326,8 @@ const LoomBrowser = forwardRef(function LoomBrowser(props, ref) {
|
|
|
12641
13326
|
locus: initialLocus,
|
|
12642
13327
|
interactive,
|
|
12643
13328
|
wheelZoom,
|
|
13329
|
+
workers,
|
|
13330
|
+
workerFactory,
|
|
12644
13331
|
workerProvider,
|
|
12645
13332
|
popupProvider,
|
|
12646
13333
|
contextMenuProvider,
|