loom-browser 0.0.5 → 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 +1276 -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 +8513 -7651
- package/dist/loom.esm.min.js +1 -1
- package/dist/loom.esm.min.js.map +1 -1
- package/dist/loom.js +8516 -7651
- 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/memoryDataSource.d.ts +32 -0
- 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 +72 -3
- package/dist/types/index.d.ts +11 -4
- package/dist/types/mainThreadDataSourceProvider.d.ts +19 -0
- package/dist/types/react/GenomeBrowserContext.d.ts +3 -0
- package/dist/types/react/LoomBrowser.d.ts +15 -1
- package/dist/types/react/tracks/BedTrack.d.ts +8 -3
- package/dist/types/react/tracks/WigTrack.d.ts +6 -3
- package/dist/types/react/ui/Navbar.d.ts +4 -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 +22 -1
- 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)
|
|
@@ -8231,6 +8321,72 @@ class SequenceDataSource {
|
|
|
8231
8321
|
}
|
|
8232
8322
|
}
|
|
8233
8323
|
|
|
8324
|
+
/**
|
|
8325
|
+
* DataSource backed by an in-memory feature array.
|
|
8326
|
+
*
|
|
8327
|
+
* Wraps a pre-loaded feature array in a FeatureCache for spatial queries,
|
|
8328
|
+
* enabling inline features to flow through the same data lifecycle as
|
|
8329
|
+
* URL-based data sources.
|
|
8330
|
+
*
|
|
8331
|
+
* Layer 1 (Data + Layout): no DOM, no canvas.
|
|
8332
|
+
*/
|
|
8333
|
+
/**
|
|
8334
|
+
* DataSource that serves features from an in-memory array.
|
|
8335
|
+
*
|
|
8336
|
+
* Features are indexed in a FeatureCache on construction for efficient
|
|
8337
|
+
* spatial queries. Calling `setFeatures()` replaces the cache entirely.
|
|
8338
|
+
*/
|
|
8339
|
+
class MemoryDataSource {
|
|
8340
|
+
constructor(features) {
|
|
8341
|
+
this.cache = new FeatureCache(features);
|
|
8342
|
+
}
|
|
8343
|
+
/** Replace the in-memory features and rebuild the spatial index. */
|
|
8344
|
+
setFeatures(features) {
|
|
8345
|
+
this.cache = new FeatureCache(features);
|
|
8346
|
+
}
|
|
8347
|
+
/** Set a chromosome name resolver for alias resolution. */
|
|
8348
|
+
setChromNameResolver(resolver) {
|
|
8349
|
+
this._resolveChromName = resolver;
|
|
8350
|
+
}
|
|
8351
|
+
/** Set cumulative offsets for whole genome view coordinate transformation. */
|
|
8352
|
+
setCumulativeOffsets(offsets) {
|
|
8353
|
+
this._cumulativeOffsets = offsets;
|
|
8354
|
+
}
|
|
8355
|
+
async fetch(locus, _bpPerPixel, _signal) {
|
|
8356
|
+
if (isWholeGenomeView(locus) && this._cumulativeOffsets) {
|
|
8357
|
+
return this.fetchWG();
|
|
8358
|
+
}
|
|
8359
|
+
const chr = this._resolveChromName
|
|
8360
|
+
? this._resolveChromName(locus.chr)
|
|
8361
|
+
: locus.chr;
|
|
8362
|
+
return this.cache.queryFeatures(chr, locus.start, locus.end);
|
|
8363
|
+
}
|
|
8364
|
+
fetchWG() {
|
|
8365
|
+
const offsets = this._cumulativeOffsets;
|
|
8366
|
+
const chrNames = mainChromosomeNames(Object.fromEntries(offsets.chromosomeNames.map(name => { var _a; return [name, (_a = offsets.offsets[name]) !== null && _a !== void 0 ? _a : 0]; })));
|
|
8367
|
+
const wgFeatures = [];
|
|
8368
|
+
const allByChrom = this.cache.getAllFeatures();
|
|
8369
|
+
for (const chr of chrNames) {
|
|
8370
|
+
const features = allByChrom[chr];
|
|
8371
|
+
if (!features)
|
|
8372
|
+
continue;
|
|
8373
|
+
const offset = offsets.offsets[chr];
|
|
8374
|
+
if (offset === undefined)
|
|
8375
|
+
continue;
|
|
8376
|
+
for (const f of features) {
|
|
8377
|
+
wgFeatures.push({
|
|
8378
|
+
...f,
|
|
8379
|
+
chr: 'all',
|
|
8380
|
+
start: offset + f.start,
|
|
8381
|
+
end: offset + f.end,
|
|
8382
|
+
});
|
|
8383
|
+
}
|
|
8384
|
+
}
|
|
8385
|
+
wgFeatures.sort((a, b) => a.start - b.start);
|
|
8386
|
+
return wgFeatures;
|
|
8387
|
+
}
|
|
8388
|
+
}
|
|
8389
|
+
|
|
8234
8390
|
/**
|
|
8235
8391
|
* Stateless renderer for interaction (arc/BEDPE) tracks.
|
|
8236
8392
|
*
|
|
@@ -8250,18 +8406,26 @@ class SequenceDataSource {
|
|
|
8250
8406
|
* Stateless: all state is passed in, no side effects beyond canvas drawing
|
|
8251
8407
|
* and attaching drawState to features for hit-testing.
|
|
8252
8408
|
*/
|
|
8253
|
-
function renderInteractionTrack(ctx, features, config, rc
|
|
8409
|
+
function renderInteractionTrack(ctx, features, config, rc,
|
|
8410
|
+
/** Optional track name label overlay (top-left corner). */
|
|
8411
|
+
trackLabel) {
|
|
8254
8412
|
// Clear background
|
|
8255
8413
|
ctx.fillStyle = config.background;
|
|
8256
8414
|
ctx.fillRect(0, 0, rc.pixelWidth, config.height);
|
|
8257
|
-
if (!features || features.length === 0)
|
|
8415
|
+
if (!features || features.length === 0) {
|
|
8416
|
+
if (trackLabel)
|
|
8417
|
+
renderTrackNameLabel(ctx, trackLabel, config.height);
|
|
8258
8418
|
return;
|
|
8419
|
+
}
|
|
8259
8420
|
if (config.displayMode === 'proportional') {
|
|
8260
8421
|
drawProportional(ctx, features, config, rc);
|
|
8261
8422
|
}
|
|
8262
8423
|
else {
|
|
8263
8424
|
drawNested(ctx, features, config, rc);
|
|
8264
8425
|
}
|
|
8426
|
+
if (trackLabel) {
|
|
8427
|
+
renderTrackNameLabel(ctx, trackLabel, config.height);
|
|
8428
|
+
}
|
|
8265
8429
|
}
|
|
8266
8430
|
// ─── Nested arcs ─────────────────────────────────────────────────────────────
|
|
8267
8431
|
function drawNested(ctx, features, config, rc) {
|
|
@@ -8565,7 +8729,11 @@ class InteractionTrackCanvas extends BaseTrackCanvas {
|
|
|
8565
8729
|
return this.config.background;
|
|
8566
8730
|
}
|
|
8567
8731
|
doRender(ctx, _width, _height, rc) {
|
|
8568
|
-
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);
|
|
8569
8737
|
}
|
|
8570
8738
|
/** Hit-test: find features at canvas-relative pixel coordinates. */
|
|
8571
8739
|
hitTest(x, y) {
|
|
@@ -8785,12 +8953,12 @@ function knownTrackTypes() {
|
|
|
8785
8953
|
return types;
|
|
8786
8954
|
}
|
|
8787
8955
|
// ─── Built-in data source helpers ────────────────────────────────────────────
|
|
8788
|
-
function createDataSource(config
|
|
8956
|
+
function createDataSource(config) {
|
|
8789
8957
|
switch (config.type) {
|
|
8790
8958
|
case 'bigwig':
|
|
8791
|
-
return new BigWigDataSource(config.url, config.windowFunction
|
|
8959
|
+
return new BigWigDataSource(config.url, config.windowFunction);
|
|
8792
8960
|
case 'gtx':
|
|
8793
|
-
return new GtxDataSource(config.url, config.experimentId, config.windowFunction
|
|
8961
|
+
return new GtxDataSource(config.url, config.experimentId, config.windowFunction);
|
|
8794
8962
|
case 'ucsc':
|
|
8795
8963
|
return new GeneDataSource({ genome: config.genome, track: config.track });
|
|
8796
8964
|
case 'text':
|
|
@@ -8799,8 +8967,12 @@ function createDataSource(config, workerProvider) {
|
|
|
8799
8967
|
format: config.format,
|
|
8800
8968
|
indexURL: config.indexURL,
|
|
8801
8969
|
indexed: config.indexed,
|
|
8802
|
-
workerProvider,
|
|
8803
8970
|
});
|
|
8971
|
+
case 'memory':
|
|
8972
|
+
// Memory data sources are created directly with features by the caller.
|
|
8973
|
+
// This path is only hit during session restore, where in-memory features
|
|
8974
|
+
// are not available — return an empty MemoryDataSource as a placeholder.
|
|
8975
|
+
return new MemoryDataSource([]);
|
|
8804
8976
|
}
|
|
8805
8977
|
}
|
|
8806
8978
|
// ─── Built-in track creators ─────────────────────────────────────────────────
|
|
@@ -8818,7 +8990,7 @@ function createWigTrack(trackConfig, ctx) {
|
|
|
8818
8990
|
let dataSourceConfig = null;
|
|
8819
8991
|
if (config.dataSource) {
|
|
8820
8992
|
dataSourceConfig = config.dataSource;
|
|
8821
|
-
dataSource = createDataSource(config.dataSource
|
|
8993
|
+
dataSource = createDataSource(config.dataSource);
|
|
8822
8994
|
}
|
|
8823
8995
|
return { track, dataSource, dataSourceConfig, name: config.name, order: config.order };
|
|
8824
8996
|
}
|
|
@@ -8831,13 +9003,12 @@ function createAnnotationTrack(trackConfig, ctx) {
|
|
|
8831
9003
|
config: config.config,
|
|
8832
9004
|
theme: ctx.theme,
|
|
8833
9005
|
canvasProvider: ctx.canvasProvider,
|
|
8834
|
-
workerProvider: ctx.workerProvider,
|
|
8835
9006
|
});
|
|
8836
9007
|
let dataSource = null;
|
|
8837
9008
|
let dataSourceConfig = null;
|
|
8838
9009
|
if (config.dataSource) {
|
|
8839
9010
|
dataSourceConfig = config.dataSource;
|
|
8840
|
-
dataSource = createDataSource(config.dataSource
|
|
9011
|
+
dataSource = createDataSource(config.dataSource);
|
|
8841
9012
|
}
|
|
8842
9013
|
return { track, dataSource, dataSourceConfig, name: config.name, order: config.order };
|
|
8843
9014
|
}
|
|
@@ -8883,7 +9054,7 @@ function createInteractionTrack(trackConfig, ctx) {
|
|
|
8883
9054
|
let dataSourceConfig = null;
|
|
8884
9055
|
if (config.dataSource) {
|
|
8885
9056
|
dataSourceConfig = config.dataSource;
|
|
8886
|
-
dataSource = createDataSource(config.dataSource
|
|
9057
|
+
dataSource = createDataSource(config.dataSource);
|
|
8887
9058
|
}
|
|
8888
9059
|
return { track, dataSource, dataSourceConfig, name: config.name, order: config.order };
|
|
8889
9060
|
}
|
|
@@ -8929,7 +9100,6 @@ function createTrackFromConfig(trackConfig, locus, options = {}) {
|
|
|
8929
9100
|
return creator(trackConfig, {
|
|
8930
9101
|
locus,
|
|
8931
9102
|
canvasProvider: (_a = options.canvasProvider) !== null && _a !== void 0 ? _a : defaultCanvasProvider,
|
|
8932
|
-
workerProvider: options.workerProvider,
|
|
8933
9103
|
theme: options.theme,
|
|
8934
9104
|
sequenceProvider: options.sequenceProvider,
|
|
8935
9105
|
});
|
|
@@ -8979,6 +9149,9 @@ function dataSourceCacheKey(config) {
|
|
|
8979
9149
|
return `ucsc:${(_b = config.genome) !== null && _b !== void 0 ? _b : ''}:${(_c = config.track) !== null && _c !== void 0 ? _c : ''}`;
|
|
8980
9150
|
case 'text':
|
|
8981
9151
|
return `text:${config.url}:${(_d = config.format) !== null && _d !== void 0 ? _d : ''}:${(_e = config.indexURL) !== null && _e !== void 0 ? _e : ''}`;
|
|
9152
|
+
case 'memory':
|
|
9153
|
+
// Each memory data source is unique — no deduplication.
|
|
9154
|
+
return `memory:${Math.random()}`;
|
|
8982
9155
|
}
|
|
8983
9156
|
}
|
|
8984
9157
|
|
|
@@ -9310,6 +9483,15 @@ function selectTracks(tracks, selector) {
|
|
|
9310
9483
|
* browser.setLocus({ chr: 'chr17', start: 7670000, end: 7680000 })
|
|
9311
9484
|
* browser.dispose()
|
|
9312
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
|
+
}
|
|
9313
9495
|
const BrowserEvent = {
|
|
9314
9496
|
LocusChange: 'locuschange',
|
|
9315
9497
|
TrackAdded: 'trackadded',
|
|
@@ -9333,6 +9515,25 @@ let nextTrackId = 0;
|
|
|
9333
9515
|
function generateTrackId(type) {
|
|
9334
9516
|
return `${type !== null && type !== void 0 ? type : 'track'}-${nextTrackId++}`;
|
|
9335
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
|
+
}
|
|
9336
9537
|
class HeadlessGenomeBrowser {
|
|
9337
9538
|
get theme() { return this._theme; }
|
|
9338
9539
|
get locus() { return this._locus; }
|
|
@@ -9349,6 +9550,10 @@ class HeadlessGenomeBrowser {
|
|
|
9349
9550
|
this.roiSets = [];
|
|
9350
9551
|
/** Inflight fetch promises keyed by (cacheKey + fetchRegion + bpPerPixel) for deduplication. */
|
|
9351
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;
|
|
9352
9557
|
this.events = new EventEmitter();
|
|
9353
9558
|
this.genome = options.genome === null ? undefined : ((_a = options.genome) !== null && _a !== void 0 ? _a : hg38Genome);
|
|
9354
9559
|
this.chromSizes = (_b = this.genome) === null || _b === void 0 ? void 0 : _b.chromSizes;
|
|
@@ -9358,12 +9563,33 @@ class HeadlessGenomeBrowser {
|
|
|
9358
9563
|
this._viewportWidth = (_e = options.viewportWidth) !== null && _e !== void 0 ? _e : 0;
|
|
9359
9564
|
this.canvasProvider = (_f = options.canvasProvider) !== null && _f !== void 0 ? _f : defaultCanvasProvider;
|
|
9360
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;
|
|
9361
9569
|
this.popupProvider = (_g = options.popupProvider) !== null && _g !== void 0 ? _g : undefined;
|
|
9362
9570
|
this.contextMenuProvider = (_h = options.contextMenuProvider) !== null && _h !== void 0 ? _h : undefined;
|
|
9363
9571
|
this._theme = resolveTheme(options.theme);
|
|
9364
9572
|
if (options.stateProjection)
|
|
9365
9573
|
this._state = options.stateProjection;
|
|
9366
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
|
+
}
|
|
9367
9593
|
/** Clamp a locus to valid chromosome bounds. No-op if chromSizes is not set. */
|
|
9368
9594
|
clamp(locus) {
|
|
9369
9595
|
return this.chromSizes ? clampLocus(locus, this.chromSizes, this.cumulativeOffsets) : locus;
|
|
@@ -9387,7 +9613,7 @@ class HeadlessGenomeBrowser {
|
|
|
9387
9613
|
this.loadAllTracksIfNeeded();
|
|
9388
9614
|
}
|
|
9389
9615
|
/** Add a track with an optional data source for automatic data management. */
|
|
9390
|
-
addTrack(track, dataSource, dataSourceConfig, maxTrackHeight) {
|
|
9616
|
+
addTrack(track, dataSource, dataSourceConfig, maxTrackHeight, order) {
|
|
9391
9617
|
var _a;
|
|
9392
9618
|
const id = generateTrackId(track.type);
|
|
9393
9619
|
const mt = {
|
|
@@ -9398,8 +9624,10 @@ class HeadlessGenomeBrowser {
|
|
|
9398
9624
|
cache: null,
|
|
9399
9625
|
abortController: null,
|
|
9400
9626
|
maxTrackHeight,
|
|
9627
|
+
order,
|
|
9401
9628
|
};
|
|
9402
9629
|
this.managedTracks.push(mt);
|
|
9630
|
+
this.sortTracks();
|
|
9403
9631
|
track.setLocus(this._locus);
|
|
9404
9632
|
this.events.emit(BrowserEvent.TrackAdded, { track });
|
|
9405
9633
|
// Trigger initial data load if data source provided
|
|
@@ -9417,6 +9645,9 @@ class HeadlessGenomeBrowser {
|
|
|
9417
9645
|
const mt = this.managedTracks[idx];
|
|
9418
9646
|
if (mt.abortController)
|
|
9419
9647
|
mt.abortController.abort();
|
|
9648
|
+
if (mt.dataSource instanceof WorkerDataSource && this.dataSourceWorkerProvider) {
|
|
9649
|
+
this.dataSourceWorkerProvider.destroy(mt.dataSource.instanceId);
|
|
9650
|
+
}
|
|
9420
9651
|
this.managedTracks.splice(idx, 1);
|
|
9421
9652
|
this.events.emit(BrowserEvent.TrackRemoved, { track: mt.track });
|
|
9422
9653
|
}
|
|
@@ -9434,8 +9665,42 @@ class HeadlessGenomeBrowser {
|
|
|
9434
9665
|
return;
|
|
9435
9666
|
const [moved] = this.managedTracks.splice(fromIndex, 1);
|
|
9436
9667
|
this.managedTracks.splice(clampedIndex, 0, moved);
|
|
9668
|
+
this._assignOrderFromPosition();
|
|
9437
9669
|
this.events.emit(BrowserEvent.TrackOrderChanged, { tracks: this.managedTracks.map(mt => mt.track) });
|
|
9438
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
|
+
}
|
|
9439
9704
|
/** Get the current track order. */
|
|
9440
9705
|
getTrackOrder() {
|
|
9441
9706
|
return this.managedTracks.map(mt => mt.track);
|
|
@@ -9500,7 +9765,7 @@ class HeadlessGenomeBrowser {
|
|
|
9500
9765
|
for (const mt of this.managedTracks) {
|
|
9501
9766
|
mt.track.setLocus(this._locus);
|
|
9502
9767
|
}
|
|
9503
|
-
this.
|
|
9768
|
+
this.debouncedLoad();
|
|
9504
9769
|
this.events.emit(BrowserEvent.LocusChange, { locus: this._locus });
|
|
9505
9770
|
}
|
|
9506
9771
|
/**
|
|
@@ -9745,7 +10010,8 @@ class HeadlessGenomeBrowser {
|
|
|
9745
10010
|
serialized.order = mt.order;
|
|
9746
10011
|
if (mt.metadata)
|
|
9747
10012
|
serialized.metadata = mt.metadata;
|
|
9748
|
-
if (mt.dataSourceConfig
|
|
10013
|
+
if (mt.dataSourceConfig
|
|
10014
|
+
&& serialized.type !== 'ruler' && serialized.type !== 'sequence') {
|
|
9749
10015
|
serialized.dataSource = mt.dataSourceConfig;
|
|
9750
10016
|
}
|
|
9751
10017
|
tracks.push(serialized);
|
|
@@ -9791,35 +10057,49 @@ class HeadlessGenomeBrowser {
|
|
|
9791
10057
|
// Recreate tracks from session config
|
|
9792
10058
|
const trackOptions = {
|
|
9793
10059
|
canvasProvider: this.canvasProvider,
|
|
9794
|
-
workerProvider: this.workerProvider,
|
|
9795
10060
|
theme: options === null || options === void 0 ? void 0 : options.theme,
|
|
9796
10061
|
sequenceProvider: this.sequenceProvider,
|
|
9797
10062
|
};
|
|
9798
|
-
|
|
9799
|
-
|
|
9800
|
-
|
|
9801
|
-
|
|
9802
|
-
|
|
9803
|
-
|
|
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
|
+
}
|
|
9804
10076
|
}
|
|
9805
|
-
|
|
9806
|
-
|
|
9807
|
-
if (
|
|
9808
|
-
|
|
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
|
+
}
|
|
9809
10087
|
}
|
|
9810
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;
|
|
9811
10098
|
}
|
|
9812
|
-
|
|
9813
|
-
|
|
9814
|
-
|
|
9815
|
-
|
|
9816
|
-
mt.id = trackConfig.id;
|
|
9817
|
-
if (created.name)
|
|
9818
|
-
mt.name = created.name;
|
|
9819
|
-
if (created.order != null)
|
|
9820
|
-
mt.order = created.order;
|
|
9821
|
-
if (trackConfig.metadata)
|
|
9822
|
-
mt.metadata = trackConfig.metadata;
|
|
10099
|
+
}
|
|
10100
|
+
finally {
|
|
10101
|
+
this._deferSort = false;
|
|
10102
|
+
this.sortTracks();
|
|
9823
10103
|
}
|
|
9824
10104
|
// Restore ROI sets
|
|
9825
10105
|
if (session.rois) {
|
|
@@ -9861,30 +10141,37 @@ class HeadlessGenomeBrowser {
|
|
|
9861
10141
|
var _a, _b;
|
|
9862
10142
|
const created = createTrackFromConfig(trackConfig, this._locus, {
|
|
9863
10143
|
canvasProvider: this.canvasProvider,
|
|
9864
|
-
workerProvider: this.workerProvider,
|
|
9865
10144
|
theme: this.theme,
|
|
9866
10145
|
sequenceProvider: this.sequenceProvider,
|
|
9867
10146
|
});
|
|
9868
|
-
//
|
|
9869
|
-
|
|
9870
|
-
|
|
9871
|
-
|
|
9872
|
-
|
|
9873
|
-
|
|
9874
|
-
|
|
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));
|
|
9875
10164
|
if (this.cumulativeOffsets) {
|
|
9876
|
-
|
|
10165
|
+
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
9877
10166
|
}
|
|
9878
10167
|
}
|
|
9879
10168
|
}
|
|
9880
|
-
this.addTrack(created.track, (_a = created.
|
|
9881
|
-
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);
|
|
9882
10171
|
if (trackConfig.id)
|
|
9883
10172
|
mt.id = trackConfig.id;
|
|
9884
10173
|
if (created.name)
|
|
9885
10174
|
mt.name = created.name;
|
|
9886
|
-
if (created.order != null)
|
|
9887
|
-
mt.order = created.order;
|
|
9888
10175
|
if (trackConfig.metadata)
|
|
9889
10176
|
mt.metadata = trackConfig.metadata;
|
|
9890
10177
|
return created.track;
|
|
@@ -9920,22 +10207,29 @@ class HeadlessGenomeBrowser {
|
|
|
9920
10207
|
name: options === null || options === void 0 ? void 0 : options.name,
|
|
9921
10208
|
sequenceProvider: this.sequenceProvider,
|
|
9922
10209
|
});
|
|
9923
|
-
const dataSource = new BigWigDataSource(url, windowFunction, this.workerProvider);
|
|
9924
|
-
if (this.cumulativeOffsets) {
|
|
9925
|
-
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
9926
|
-
}
|
|
9927
|
-
if (this.genome) {
|
|
9928
|
-
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
9929
|
-
}
|
|
9930
10210
|
const dataSourceConfig = {
|
|
9931
10211
|
type: 'bigwig', url, windowFunction,
|
|
9932
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
|
+
})();
|
|
9933
10222
|
this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
|
|
9934
10223
|
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
9935
|
-
this.
|
|
10224
|
+
this.findMT(track).metadata = options.metadata;
|
|
9936
10225
|
// Wire windowing function callback: update data source, invalidate cache, re-fetch
|
|
9937
10226
|
track.onWindowFunctionChange = (wf) => {
|
|
9938
|
-
|
|
10227
|
+
if (workerDS) {
|
|
10228
|
+
workerDS.setWindowFunction(wf);
|
|
10229
|
+
}
|
|
10230
|
+
else {
|
|
10231
|
+
dataSource.setWindowFunction(wf);
|
|
10232
|
+
}
|
|
9939
10233
|
const mt = this.managedTracks.find(m => m.track === track);
|
|
9940
10234
|
if (mt) {
|
|
9941
10235
|
mt.cache = null;
|
|
@@ -9963,22 +10257,29 @@ class HeadlessGenomeBrowser {
|
|
|
9963
10257
|
name: options.name,
|
|
9964
10258
|
sequenceProvider: this.sequenceProvider,
|
|
9965
10259
|
});
|
|
9966
|
-
const dataSource = new GtxDataSource(url, options.experimentId, windowFunction, this.workerProvider);
|
|
9967
|
-
if (this.cumulativeOffsets) {
|
|
9968
|
-
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
9969
|
-
}
|
|
9970
|
-
if (this.genome) {
|
|
9971
|
-
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
9972
|
-
}
|
|
9973
10260
|
const dataSourceConfig = {
|
|
9974
10261
|
type: 'gtx', url, experimentId: options.experimentId, windowFunction,
|
|
9975
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
|
+
})();
|
|
9976
10272
|
this.addTrack(track, dataSource, dataSourceConfig, options.maxTrackHeight);
|
|
9977
10273
|
if (options.metadata)
|
|
9978
|
-
this.
|
|
10274
|
+
this.findMT(track).metadata = options.metadata;
|
|
9979
10275
|
// Wire windowing function callback
|
|
9980
10276
|
track.onWindowFunctionChange = (wf) => {
|
|
9981
|
-
|
|
10277
|
+
if (workerDS) {
|
|
10278
|
+
workerDS.setWindowFunction(wf);
|
|
10279
|
+
}
|
|
10280
|
+
else {
|
|
10281
|
+
dataSource.setWindowFunction(wf);
|
|
10282
|
+
}
|
|
9982
10283
|
const mt = this.managedTracks.find(m => m.track === track);
|
|
9983
10284
|
if (mt) {
|
|
9984
10285
|
mt.cache = null;
|
|
@@ -10002,21 +10303,23 @@ class HeadlessGenomeBrowser {
|
|
|
10002
10303
|
background: options === null || options === void 0 ? void 0 : options.background,
|
|
10003
10304
|
theme: this.theme,
|
|
10004
10305
|
canvasProvider: this.canvasProvider,
|
|
10005
|
-
workerProvider: this.workerProvider,
|
|
10006
10306
|
name: (_a = options === null || options === void 0 ? void 0 : options.name) !== null && _a !== void 0 ? _a : 'Genes',
|
|
10007
10307
|
});
|
|
10008
10308
|
const genome = options === null || options === void 0 ? void 0 : options.genome;
|
|
10009
10309
|
const ucscTrack = options === null || options === void 0 ? void 0 : options.track;
|
|
10010
|
-
const dataSource = new GeneDataSource({ genome, track: ucscTrack });
|
|
10011
|
-
if (this.cumulativeOffsets) {
|
|
10012
|
-
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10013
|
-
}
|
|
10014
10310
|
const dataSourceConfig = {
|
|
10015
10311
|
type: 'ucsc', genome, track: ucscTrack,
|
|
10016
10312
|
};
|
|
10017
|
-
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);
|
|
10018
10321
|
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
10019
|
-
this.
|
|
10322
|
+
this.findMT(track).metadata = options.metadata;
|
|
10020
10323
|
return track;
|
|
10021
10324
|
}
|
|
10022
10325
|
/** Add a BED/peak annotation track from a URL. Supports plain text and tabix-indexed files. */
|
|
@@ -10031,28 +10334,23 @@ class HeadlessGenomeBrowser {
|
|
|
10031
10334
|
background: options === null || options === void 0 ? void 0 : options.background,
|
|
10032
10335
|
theme: this.theme,
|
|
10033
10336
|
canvasProvider: this.canvasProvider,
|
|
10034
|
-
workerProvider: this.workerProvider,
|
|
10035
10337
|
name: options === null || options === void 0 ? void 0 : options.name,
|
|
10036
10338
|
});
|
|
10037
|
-
const dataSource = new TextFeatureSource({
|
|
10038
|
-
url,
|
|
10039
|
-
format,
|
|
10040
|
-
indexURL: options === null || options === void 0 ? void 0 : options.indexURL,
|
|
10041
|
-
indexed: options === null || options === void 0 ? void 0 : options.indexed,
|
|
10042
|
-
workerProvider: this.workerProvider,
|
|
10043
|
-
});
|
|
10044
|
-
if (this.genome) {
|
|
10045
|
-
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10046
|
-
}
|
|
10047
|
-
if (this.cumulativeOffsets) {
|
|
10048
|
-
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10049
|
-
}
|
|
10050
10339
|
const dataSourceConfig = {
|
|
10051
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,
|
|
10052
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
|
+
})();
|
|
10053
10351
|
this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
|
|
10054
10352
|
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
10055
|
-
this.
|
|
10353
|
+
this.findMT(track).metadata = options.metadata;
|
|
10056
10354
|
return track;
|
|
10057
10355
|
}
|
|
10058
10356
|
/** Add an interaction (arc/BEDPE) track from a URL. */
|
|
@@ -10068,25 +10366,74 @@ class HeadlessGenomeBrowser {
|
|
|
10068
10366
|
canvasProvider: this.canvasProvider,
|
|
10069
10367
|
name: options === null || options === void 0 ? void 0 : options.name,
|
|
10070
10368
|
});
|
|
10071
|
-
const
|
|
10072
|
-
url,
|
|
10073
|
-
|
|
10074
|
-
|
|
10075
|
-
|
|
10076
|
-
|
|
10369
|
+
const dataSourceConfig = {
|
|
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,
|
|
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
|
+
})();
|
|
10381
|
+
this.addTrack(track, dataSource, dataSourceConfig);
|
|
10382
|
+
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
10383
|
+
this.findMT(track).metadata = options.metadata;
|
|
10384
|
+
return track;
|
|
10385
|
+
}
|
|
10386
|
+
/** Add a BigWig-style signal track backed by in-memory features (no URL required). */
|
|
10387
|
+
addWigTrackWithFeatures(features, options) {
|
|
10388
|
+
const { canvas } = this.canvasProvider.createCanvas(0, 0);
|
|
10389
|
+
const track = new WigTrackCanvas(canvas, {
|
|
10390
|
+
locus: this._locus,
|
|
10391
|
+
features: [],
|
|
10392
|
+
config: options === null || options === void 0 ? void 0 : options.config,
|
|
10393
|
+
height: options === null || options === void 0 ? void 0 : options.height,
|
|
10394
|
+
background: options === null || options === void 0 ? void 0 : options.background,
|
|
10395
|
+
theme: this.theme,
|
|
10396
|
+
canvasProvider: this.canvasProvider,
|
|
10397
|
+
name: options === null || options === void 0 ? void 0 : options.name,
|
|
10398
|
+
sequenceProvider: this.sequenceProvider,
|
|
10077
10399
|
});
|
|
10400
|
+
const dataSource = new MemoryDataSource(features);
|
|
10401
|
+
if (this.cumulativeOffsets) {
|
|
10402
|
+
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10403
|
+
}
|
|
10078
10404
|
if (this.genome) {
|
|
10079
10405
|
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10080
10406
|
}
|
|
10407
|
+
const dataSourceConfig = { type: 'memory' };
|
|
10408
|
+
this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
|
|
10409
|
+
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
10410
|
+
this.findMT(track).metadata = options.metadata;
|
|
10411
|
+
return track;
|
|
10412
|
+
}
|
|
10413
|
+
/** Add a BED/annotation track backed by in-memory features (no URL required). Features must include `chr`. */
|
|
10414
|
+
addBedTrackWithFeatures(features, options) {
|
|
10415
|
+
const { canvas } = this.canvasProvider.createCanvas(0, 0);
|
|
10416
|
+
const track = new AnnotationTrackCanvas(canvas, {
|
|
10417
|
+
locus: this._locus,
|
|
10418
|
+
features: [],
|
|
10419
|
+
config: options === null || options === void 0 ? void 0 : options.config,
|
|
10420
|
+
height: options === null || options === void 0 ? void 0 : options.height,
|
|
10421
|
+
background: options === null || options === void 0 ? void 0 : options.background,
|
|
10422
|
+
theme: this.theme,
|
|
10423
|
+
canvasProvider: this.canvasProvider,
|
|
10424
|
+
name: options === null || options === void 0 ? void 0 : options.name,
|
|
10425
|
+
});
|
|
10426
|
+
const dataSource = new MemoryDataSource(features);
|
|
10081
10427
|
if (this.cumulativeOffsets) {
|
|
10082
10428
|
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10083
10429
|
}
|
|
10084
|
-
|
|
10085
|
-
|
|
10086
|
-
}
|
|
10087
|
-
|
|
10430
|
+
if (this.genome) {
|
|
10431
|
+
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10432
|
+
}
|
|
10433
|
+
const dataSourceConfig = { type: 'memory' };
|
|
10434
|
+
this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
|
|
10088
10435
|
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
10089
|
-
this.
|
|
10436
|
+
this.findMT(track).metadata = options.metadata;
|
|
10090
10437
|
return track;
|
|
10091
10438
|
}
|
|
10092
10439
|
/** Add a DNA/RNA sequence track. Data fetching is handled automatically via the genome's sequence provider. */
|
|
@@ -10118,6 +10465,10 @@ class HeadlessGenomeBrowser {
|
|
|
10118
10465
|
for (const mt of this.managedTracks) {
|
|
10119
10466
|
if (mt.abortController)
|
|
10120
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
|
+
}
|
|
10121
10472
|
}
|
|
10122
10473
|
this.managedTracks = [];
|
|
10123
10474
|
this.roiSets = [];
|
|
@@ -10145,6 +10496,30 @@ class HeadlessGenomeBrowser {
|
|
|
10145
10496
|
};
|
|
10146
10497
|
}
|
|
10147
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
|
+
}
|
|
10148
10523
|
loadAllTracksIfNeeded() {
|
|
10149
10524
|
// Clear stale inflight entries — navigation invalidates all pending fetches
|
|
10150
10525
|
this.inflightFetches.clear();
|
|
@@ -10182,9 +10557,11 @@ class HeadlessGenomeBrowser {
|
|
|
10182
10557
|
if (mt.cache && cacheCoversViewport(mt.cache, this._locus, bpPerPixel)) {
|
|
10183
10558
|
return;
|
|
10184
10559
|
}
|
|
10185
|
-
// 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.
|
|
10186
10562
|
if (mt.abortController)
|
|
10187
10563
|
mt.abortController.abort();
|
|
10564
|
+
mt.track.setError(null);
|
|
10188
10565
|
const fetchRegion = bufferLocus(this._locus);
|
|
10189
10566
|
const dsKey = mt.dataSourceConfig ? dataSourceCacheKey(mt.dataSourceConfig) : null;
|
|
10190
10567
|
// Check if another track with the same data source already has a valid cache
|
|
@@ -10272,6 +10649,222 @@ class HeadlessGenomeBrowser {
|
|
|
10272
10649
|
}
|
|
10273
10650
|
}
|
|
10274
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
|
+
|
|
10275
10868
|
/**
|
|
10276
10869
|
* CommandDispatcher — transport-agnostic command dispatch for HeadlessGenomeBrowser.
|
|
10277
10870
|
*
|
|
@@ -10742,9 +11335,8 @@ class RemoteConnection {
|
|
|
10742
11335
|
* These are pure canvas renderers — they take a 2D context, axis info, and
|
|
10743
11336
|
* dimensions, and paint the axis. No DOM, no state, no side effects.
|
|
10744
11337
|
*
|
|
10745
|
-
*
|
|
10746
|
-
*
|
|
10747
|
-
* - 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.
|
|
10748
11340
|
*/
|
|
10749
11341
|
/** Width of the color strip indicator on the right edge of the axis. */
|
|
10750
11342
|
const COLOR_STRIP_WIDTH = 4;
|
|
@@ -10763,13 +11355,10 @@ function prettyPrintNumber(n) {
|
|
|
10763
11355
|
return n.toExponential(1);
|
|
10764
11356
|
}
|
|
10765
11357
|
/**
|
|
10766
|
-
* Paint a quantitative axis
|
|
11358
|
+
* Paint a quantitative axis.
|
|
10767
11359
|
*
|
|
10768
|
-
*
|
|
10769
|
-
*
|
|
10770
|
-
* - Color strip on right edge (if info.color is set)
|
|
10771
|
-
* - Vertical axis line with top/bottom ticks and labels (max/min)
|
|
10772
|
-
* - 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.
|
|
10773
11362
|
*/
|
|
10774
11363
|
/** Transform a data value to log space, matching wigRenderer's computeYPixel logic. */
|
|
10775
11364
|
function toLogValue(v) {
|
|
@@ -10793,19 +11382,10 @@ function valueToY(value, min, max, topY, bottomY, flip, logScale) {
|
|
|
10793
11382
|
}
|
|
10794
11383
|
function renderQuantitativeAxis(ctx, info, width, height) {
|
|
10795
11384
|
var _a, _b, _c, _d;
|
|
10796
|
-
if (
|
|
11385
|
+
if (height === 0)
|
|
10797
11386
|
return;
|
|
10798
|
-
const
|
|
10799
|
-
const
|
|
10800
|
-
const logScale = (_b = info.logScale) !== null && _b !== void 0 ? _b : false;
|
|
10801
|
-
const shim = 0.01;
|
|
10802
|
-
const topY = shim * height;
|
|
10803
|
-
const bottomY = (1.0 - shim) * height;
|
|
10804
|
-
// When flipped, min is at top and max is at bottom (used by GWAS/QTL tracks)
|
|
10805
|
-
const topValue = flip ? min : max;
|
|
10806
|
-
const bottomValue = flip ? max : min;
|
|
10807
|
-
const bg = (_c = info.backgroundColor) !== null && _c !== void 0 ? _c : 'white';
|
|
10808
|
-
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';
|
|
10809
11389
|
// Clear with background
|
|
10810
11390
|
ctx.fillStyle = bg;
|
|
10811
11391
|
ctx.fillRect(0, 0, width, height);
|
|
@@ -10814,6 +11394,9 @@ function renderQuantitativeAxis(ctx, info, width, height) {
|
|
|
10814
11394
|
ctx.fillStyle = info.color;
|
|
10815
11395
|
ctx.fillRect(width - COLOR_STRIP_WIDTH - 1, 0, COLOR_STRIP_WIDTH, height);
|
|
10816
11396
|
}
|
|
11397
|
+
const shim = 0.01;
|
|
11398
|
+
const topY = shim * height;
|
|
11399
|
+
const bottomY = (1.0 - shim) * height;
|
|
10817
11400
|
const tickEnd = width - COLOR_STRIP_WIDTH - 3;
|
|
10818
11401
|
const tickStart = tickEnd - 6;
|
|
10819
11402
|
ctx.strokeStyle = fg;
|
|
@@ -10821,30 +11404,37 @@ function renderQuantitativeAxis(ctx, info, width, height) {
|
|
|
10821
11404
|
ctx.font = 'normal 9px Arial';
|
|
10822
11405
|
ctx.textAlign = 'right';
|
|
10823
11406
|
ctx.lineWidth = 1;
|
|
10824
|
-
// Vertical axis line
|
|
11407
|
+
// Vertical axis line + top/bottom ticks (always drawn)
|
|
10825
11408
|
ctx.beginPath();
|
|
10826
11409
|
ctx.moveTo(tickEnd, topY);
|
|
10827
11410
|
ctx.lineTo(tickEnd, bottomY);
|
|
10828
11411
|
ctx.stroke();
|
|
10829
|
-
// Top tick + label
|
|
10830
11412
|
ctx.beginPath();
|
|
10831
11413
|
ctx.moveTo(tickStart, topY);
|
|
10832
11414
|
ctx.lineTo(tickEnd, topY);
|
|
10833
11415
|
ctx.stroke();
|
|
10834
|
-
ctx.textBaseline = 'top';
|
|
10835
|
-
ctx.fillText(prettyPrintNumber(topValue), tickStart - 2, topY + 1);
|
|
10836
|
-
// Bottom tick + label
|
|
10837
11416
|
ctx.beginPath();
|
|
10838
11417
|
ctx.moveTo(tickStart, bottomY);
|
|
10839
11418
|
ctx.lineTo(tickEnd, bottomY);
|
|
10840
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
|
|
10841
11432
|
ctx.textBaseline = 'bottom';
|
|
10842
11433
|
ctx.fillText(prettyPrintNumber(bottomValue), tickStart - 2, bottomY - 1);
|
|
10843
|
-
// Middle tick
|
|
11434
|
+
// Middle tick + label
|
|
10844
11435
|
if (height > 60) {
|
|
10845
11436
|
const midVal = logScale
|
|
10846
11437
|
? (() => {
|
|
10847
|
-
// Midpoint in log space, converted back to data space
|
|
10848
11438
|
const logMid = (toLogValue(min) + toLogValue(max)) / 2;
|
|
10849
11439
|
return logMid >= 0 ? Math.pow(10, logMid) - 1 : -(Math.pow(10, -logMid) - 1);
|
|
10850
11440
|
})()
|
|
@@ -10858,31 +11448,6 @@ function renderQuantitativeAxis(ctx, info, width, height) {
|
|
|
10858
11448
|
ctx.fillText(prettyPrintNumber(midVal), tickStart + 1, midY);
|
|
10859
11449
|
}
|
|
10860
11450
|
}
|
|
10861
|
-
/**
|
|
10862
|
-
* Paint a label-only axis (e.g., gene track name).
|
|
10863
|
-
* Renders a vertically-rotated centered text label.
|
|
10864
|
-
*/
|
|
10865
|
-
function renderLabelAxis(ctx, info, width, height) {
|
|
10866
|
-
var _a, _b;
|
|
10867
|
-
if (!info.label || height === 0)
|
|
10868
|
-
return;
|
|
10869
|
-
const bg = (_a = info.backgroundColor) !== null && _a !== void 0 ? _a : 'white';
|
|
10870
|
-
const fg = (_b = info.labelColor) !== null && _b !== void 0 ? _b : '#333';
|
|
10871
|
-
// Clear with background
|
|
10872
|
-
ctx.fillStyle = bg;
|
|
10873
|
-
ctx.fillRect(0, 0, width, height);
|
|
10874
|
-
ctx.font = '10px sans-serif';
|
|
10875
|
-
ctx.fillStyle = fg;
|
|
10876
|
-
ctx.textAlign = 'center';
|
|
10877
|
-
ctx.textBaseline = 'middle';
|
|
10878
|
-
ctx.save();
|
|
10879
|
-
ctx.translate(width / 2, height / 2);
|
|
10880
|
-
ctx.rotate(-Math.PI / 2);
|
|
10881
|
-
// Clip text to available height
|
|
10882
|
-
const maxTextWidth = height - 10;
|
|
10883
|
-
ctx.fillText(info.label, 0, 0, maxTextWidth);
|
|
10884
|
-
ctx.restore();
|
|
10885
|
-
}
|
|
10886
11451
|
|
|
10887
11452
|
/**
|
|
10888
11453
|
* Common context menu item factories.
|
|
@@ -11017,6 +11582,157 @@ function roiContextMenuItems(roi, callbacks) {
|
|
|
11017
11582
|
];
|
|
11018
11583
|
}
|
|
11019
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
|
+
|
|
11020
11736
|
/**
|
|
11021
11737
|
* DOM-attached GenomeBrowser — extends HeadlessGenomeBrowser with pointer events,
|
|
11022
11738
|
* ResizeObserver, canvas stacking, and sweep-to-zoom.
|
|
@@ -11131,6 +11847,11 @@ function injectScrollStyles(container) {
|
|
|
11131
11847
|
style.textContent = `
|
|
11132
11848
|
.loom-track-scroll { scrollbar-width: none; }
|
|
11133
11849
|
.loom-track-scroll::-webkit-scrollbar { display: none; }
|
|
11850
|
+
.loom-feature-overlay rect {
|
|
11851
|
+
fill: transparent;
|
|
11852
|
+
pointer-events: auto;
|
|
11853
|
+
cursor: pointer;
|
|
11854
|
+
}
|
|
11134
11855
|
`;
|
|
11135
11856
|
if (root instanceof Document) {
|
|
11136
11857
|
root.head.appendChild(style);
|
|
@@ -11141,7 +11862,7 @@ function injectScrollStyles(container) {
|
|
|
11141
11862
|
}
|
|
11142
11863
|
class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
11143
11864
|
constructor(container, options) {
|
|
11144
|
-
var _a, _b, _c;
|
|
11865
|
+
var _a, _b, _c, _d;
|
|
11145
11866
|
// Default-on: create providers unless explicitly set to null.
|
|
11146
11867
|
const contextMenuProvider = options.contextMenuProvider === null
|
|
11147
11868
|
? undefined
|
|
@@ -11149,8 +11870,33 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11149
11870
|
const popupProvider = options.popupProvider === null
|
|
11150
11871
|
? undefined
|
|
11151
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
|
+
}
|
|
11152
11897
|
super({
|
|
11153
11898
|
...options,
|
|
11899
|
+
workerProvider,
|
|
11154
11900
|
contextMenuProvider,
|
|
11155
11901
|
popupProvider,
|
|
11156
11902
|
viewportWidth: container.clientWidth,
|
|
@@ -11201,8 +11947,13 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11201
11947
|
// ROI overlay state
|
|
11202
11948
|
this.roiOverlayContainer = null;
|
|
11203
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;
|
|
11204
11955
|
this.container = container;
|
|
11205
|
-
this.interactive = (
|
|
11956
|
+
this.interactive = (_d = options.interactive) !== null && _d !== void 0 ? _d : true;
|
|
11206
11957
|
injectScrollStyles(container);
|
|
11207
11958
|
container.style.userSelect = "none";
|
|
11208
11959
|
container.style.touchAction = "none";
|
|
@@ -11238,6 +11989,7 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11238
11989
|
// This may repaint the axis twice on initial load (negligible for a 50px canvas).
|
|
11239
11990
|
this.events.on(BrowserEvent.DataLoaded, ({ track }) => {
|
|
11240
11991
|
this.updateAxisContent(track);
|
|
11992
|
+
this.updateFeatureOverlay(track);
|
|
11241
11993
|
});
|
|
11242
11994
|
// Repaint axis canvases when theme changes (track canvases update via
|
|
11243
11995
|
// track.setTheme(), but axis sidebar is managed here in GenomeBrowser).
|
|
@@ -11252,13 +12004,11 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11252
12004
|
this.events.on(BrowserEvent.ROIChanged, () => this.renderROIOverlays());
|
|
11253
12005
|
this.events.on(BrowserEvent.LocusChange, () => this.renderROIOverlays());
|
|
11254
12006
|
}
|
|
11255
|
-
/**
|
|
11256
|
-
|
|
11257
|
-
const id = super.addTrack(track, dataSource, dataSourceConfig, maxTrackHeight);
|
|
12007
|
+
/** Build a DOM row for a track (axis column + viewport wrapper). */
|
|
12008
|
+
_buildTrackRow(track, maxTrackHeight) {
|
|
11258
12009
|
const canvas = track.canvas;
|
|
11259
12010
|
canvas.style.display = "block";
|
|
11260
12011
|
canvas.style.width = "100%";
|
|
11261
|
-
// Flex row: [axis (50px)] [viewport (flex: 1)]
|
|
11262
12012
|
const row = document.createElement("div");
|
|
11263
12013
|
row.style.display = "flex";
|
|
11264
12014
|
row.style.width = "100%";
|
|
@@ -11273,10 +12023,8 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11273
12023
|
const viewportWrapper = document.createElement("div");
|
|
11274
12024
|
viewportWrapper.style.flex = "1";
|
|
11275
12025
|
viewportWrapper.style.minWidth = "0";
|
|
11276
|
-
|
|
11277
|
-
|
|
11278
|
-
if (mt.maxTrackHeight != null) {
|
|
11279
|
-
viewportWrapper.style.maxHeight = `${mt.maxTrackHeight}px`;
|
|
12026
|
+
if (maxTrackHeight != null) {
|
|
12027
|
+
viewportWrapper.style.maxHeight = `${maxTrackHeight}px`;
|
|
11280
12028
|
viewportWrapper.style.overflowY = "auto";
|
|
11281
12029
|
viewportWrapper.style.overscrollBehaviorY = "none";
|
|
11282
12030
|
viewportWrapper.style.backgroundColor =
|
|
@@ -11286,15 +12034,33 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11286
12034
|
viewportWrapper.appendChild(canvas);
|
|
11287
12035
|
row.appendChild(axisDiv);
|
|
11288
12036
|
row.appendChild(viewportWrapper);
|
|
11289
|
-
|
|
11290
|
-
this.
|
|
11291
|
-
|
|
11292
|
-
|
|
11293
|
-
|
|
11294
|
-
|
|
11295
|
-
|
|
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) {
|
|
11296
12063
|
// Suppress on-canvas data range labels for wig tracks — the axis column handles it.
|
|
11297
|
-
// This keeps showDataRange=true as the default for standalone WigTrackCanvas users.
|
|
11298
12064
|
if (track.type === "wig") {
|
|
11299
12065
|
track.setConfig({
|
|
11300
12066
|
showDataRange: false,
|
|
@@ -11303,24 +12069,38 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11303
12069
|
this.updateAxisContent(track);
|
|
11304
12070
|
// Ruler tracks: crosshair for sweep-to-zoom, pointer in WG mode (click to navigate)
|
|
11305
12071
|
if (this.interactive && track.type === "ruler") {
|
|
11306
|
-
canvas.style.cursor = isWholeGenomeView(this._locus)
|
|
12072
|
+
track.canvas.style.cursor = isWholeGenomeView(this._locus)
|
|
11307
12073
|
? "pointer"
|
|
11308
12074
|
: "crosshair";
|
|
11309
12075
|
}
|
|
11310
12076
|
// Enable drag-to-reorder on the axis column for non-ruler tracks
|
|
11311
|
-
|
|
11312
|
-
|
|
12077
|
+
const entry = this.trackRows.get(track);
|
|
12078
|
+
if (this.interactive && track.type !== "ruler" && entry) {
|
|
12079
|
+
this.setupReorderHandlers(track, entry.axisDiv);
|
|
11313
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);
|
|
11314
12091
|
return id;
|
|
11315
12092
|
}
|
|
11316
12093
|
/** Remove a track and its row from the container. */
|
|
11317
12094
|
removeTrack(trackOrId) {
|
|
12095
|
+
var _a;
|
|
11318
12096
|
const track = typeof trackOrId === 'string'
|
|
11319
12097
|
? this.getTrack(trackOrId)
|
|
11320
12098
|
: trackOrId;
|
|
11321
12099
|
if (!track)
|
|
11322
12100
|
return;
|
|
11323
12101
|
this.teardownReorderHandlers(track);
|
|
12102
|
+
(_a = this.featureOverlays.get(track)) === null || _a === void 0 ? void 0 : _a.dispose();
|
|
12103
|
+
this.featureOverlays.delete(track);
|
|
11324
12104
|
const entry = this.trackRows.get(track);
|
|
11325
12105
|
if (entry && entry.row.parentNode === this.container) {
|
|
11326
12106
|
this.container.removeChild(entry.row);
|
|
@@ -11375,6 +12155,8 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11375
12155
|
}
|
|
11376
12156
|
// Re-render ROI overlays
|
|
11377
12157
|
this.renderROIOverlays();
|
|
12158
|
+
// Update feature overlays
|
|
12159
|
+
this.updateFeatureOverlays();
|
|
11378
12160
|
}
|
|
11379
12161
|
/** Clean up event listeners, remove canvases, and dispose headless core. */
|
|
11380
12162
|
// ─── Remote connection ────────────────────────────────────────────────
|
|
@@ -11424,6 +12206,9 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11424
12206
|
}
|
|
11425
12207
|
this.removeSweepOverlay();
|
|
11426
12208
|
this.clearROIOverlay();
|
|
12209
|
+
for (const overlay of this.featureOverlays.values())
|
|
12210
|
+
overlay.dispose();
|
|
12211
|
+
this.featureOverlays.clear();
|
|
11427
12212
|
for (const mt of this.managedTracks) {
|
|
11428
12213
|
this.teardownReorderHandlers(mt.track);
|
|
11429
12214
|
const entry = this.trackRows.get(mt.track);
|
|
@@ -11434,6 +12219,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11434
12219
|
this.reorderHandlers.clear();
|
|
11435
12220
|
this.trackRows.clear();
|
|
11436
12221
|
super.dispose();
|
|
12222
|
+
if (this.ownedWorkerProvider) {
|
|
12223
|
+
this.ownedWorkerProvider.dispose();
|
|
12224
|
+
this.ownedWorkerProvider = null;
|
|
12225
|
+
}
|
|
11437
12226
|
}
|
|
11438
12227
|
/**
|
|
11439
12228
|
* Update the axis content for a track based on its getAxisInfo().
|
|
@@ -11468,12 +12257,7 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11468
12257
|
const visibleHeight = (mt === null || mt === void 0 ? void 0 : mt.maxTrackHeight) != null
|
|
11469
12258
|
? Math.min(track.height, mt.maxTrackHeight)
|
|
11470
12259
|
: track.height;
|
|
11471
|
-
|
|
11472
|
-
this.paintAxisCanvas(entry, info, visibleHeight, renderQuantitativeAxis);
|
|
11473
|
-
}
|
|
11474
|
-
else if (info.label) {
|
|
11475
|
-
this.paintAxisCanvas(entry, info, visibleHeight, renderLabelAxis);
|
|
11476
|
-
}
|
|
12260
|
+
this.paintAxisCanvas(entry, info, visibleHeight, renderQuantitativeAxis);
|
|
11477
12261
|
}
|
|
11478
12262
|
/**
|
|
11479
12263
|
* Prepare an axis canvas at the correct size with DPR scaling,
|
|
@@ -11586,6 +12370,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11586
12370
|
this.lastPointerX = e.clientX;
|
|
11587
12371
|
this.container.setPointerCapture(e.pointerId);
|
|
11588
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
|
+
}
|
|
11589
12377
|
};
|
|
11590
12378
|
this.handlePointerMove = (e) => {
|
|
11591
12379
|
if (this.isSweeping && this.sweepOverlay && this.sweepRulerCanvas) {
|
|
@@ -11658,6 +12446,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11658
12446
|
const wasDragging = this.isDragging;
|
|
11659
12447
|
this.isDragging = false;
|
|
11660
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
|
+
}
|
|
11661
12453
|
// Click detection: pointer didn't move more than threshold
|
|
11662
12454
|
const dx = e.clientX - this.pointerDownX;
|
|
11663
12455
|
const dy = e.clientY - this.pointerDownY;
|
|
@@ -11833,7 +12625,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11833
12625
|
// Common items (set height, remove)
|
|
11834
12626
|
const callbacks = {
|
|
11835
12627
|
setTrackHeight: (t, h) => {
|
|
11836
|
-
if (t instanceof
|
|
12628
|
+
if (t instanceof AnnotationTrackCanvas) {
|
|
12629
|
+
t.setFixedHeight(h);
|
|
12630
|
+
}
|
|
12631
|
+
else if (t instanceof BaseTrackCanvas) {
|
|
11837
12632
|
t.setConfig({ height: h });
|
|
11838
12633
|
}
|
|
11839
12634
|
},
|
|
@@ -11929,6 +12724,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11929
12724
|
}
|
|
11930
12725
|
}
|
|
11931
12726
|
// ─── Track reorder drag ─────────────────────────────────────────────
|
|
12727
|
+
/** Sync DOM after any sort (addTrack, addGeneTrack, etc.). */
|
|
12728
|
+
onTracksSorted() {
|
|
12729
|
+
this.syncDOMOrder();
|
|
12730
|
+
}
|
|
11932
12731
|
/** Reorder DOM rows to match managedTracks order. */
|
|
11933
12732
|
syncDOMOrder() {
|
|
11934
12733
|
for (const mt of this.managedTracks) {
|
|
@@ -12005,6 +12804,8 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
12005
12804
|
if (dragEntry) {
|
|
12006
12805
|
dragEntry.axisDiv.style.cursor = "grab";
|
|
12007
12806
|
}
|
|
12807
|
+
// Persist the drag result so future addTrack sorts don't revert positions.
|
|
12808
|
+
this._assignOrderFromPosition();
|
|
12008
12809
|
// Emit the final order change event
|
|
12009
12810
|
this.events.emit(BrowserEvent.TrackOrderChanged, {
|
|
12010
12811
|
tracks: this.managedTracks.map((mt) => mt.track),
|
|
@@ -12127,6 +12928,21 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
12127
12928
|
* DOM-based (not canvas) for easy hover/click interaction without
|
|
12128
12929
|
* re-rendering track canvases.
|
|
12129
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
|
+
}
|
|
12130
12946
|
renderROIOverlays() {
|
|
12131
12947
|
var _a, _b;
|
|
12132
12948
|
const visibleROIs = this.getVisibleROIs();
|
|
@@ -12373,9 +13189,6 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
12373
13189
|
if (info.dataRange) {
|
|
12374
13190
|
renderQuantitativeAxis(ctx, info, axisWidth, h);
|
|
12375
13191
|
}
|
|
12376
|
-
else if (info.label) {
|
|
12377
|
-
renderLabelAxis(ctx, info, axisWidth, h);
|
|
12378
|
-
}
|
|
12379
13192
|
ctx.restore();
|
|
12380
13193
|
}
|
|
12381
13194
|
// Render track (right of axis)
|
|
@@ -12472,8 +13285,25 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
12472
13285
|
|
|
12473
13286
|
const GenomeBrowserContext = createContext(null);
|
|
12474
13287
|
|
|
13288
|
+
/** Infer the shell theme from a RenderTheme's palette. */
|
|
13289
|
+
function inferShellTheme(theme) {
|
|
13290
|
+
var _a;
|
|
13291
|
+
const bg = (_a = theme === null || theme === void 0 ? void 0 : theme.palette) === null || _a === void 0 ? void 0 : _a.background;
|
|
13292
|
+
if (!bg)
|
|
13293
|
+
return 'modern';
|
|
13294
|
+
// Simple luminance check: dark backgrounds → dark shell theme
|
|
13295
|
+
const el = document.createElement('canvas');
|
|
13296
|
+
const ctx = el.getContext('2d');
|
|
13297
|
+
ctx.fillStyle = bg;
|
|
13298
|
+
const hex = ctx.fillStyle; // normalized to #rrggbb
|
|
13299
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
13300
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
13301
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
13302
|
+
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
13303
|
+
return luminance < 0.5 ? 'dark' : 'modern';
|
|
13304
|
+
}
|
|
12475
13305
|
const LoomBrowser = forwardRef(function LoomBrowser(props, ref) {
|
|
12476
|
-
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;
|
|
12477
13307
|
const containerRef = useRef(null);
|
|
12478
13308
|
const browserRef = useRef(null);
|
|
12479
13309
|
const [browser, setBrowser] = useState(null);
|
|
@@ -12496,6 +13326,8 @@ const LoomBrowser = forwardRef(function LoomBrowser(props, ref) {
|
|
|
12496
13326
|
locus: initialLocus,
|
|
12497
13327
|
interactive,
|
|
12498
13328
|
wheelZoom,
|
|
13329
|
+
workers,
|
|
13330
|
+
workerFactory,
|
|
12499
13331
|
workerProvider,
|
|
12500
13332
|
popupProvider,
|
|
12501
13333
|
contextMenuProvider,
|
|
@@ -12614,7 +13446,8 @@ const LoomBrowser = forwardRef(function LoomBrowser(props, ref) {
|
|
|
12614
13446
|
attachRemote(socket) { var _a; (_a = browserRef.current) === null || _a === void 0 ? void 0 : _a.attachRemote(socket); },
|
|
12615
13447
|
detachRemote() { var _a; (_a = browserRef.current) === null || _a === void 0 ? void 0 : _a.detachRemote(); },
|
|
12616
13448
|
}), [browser]);
|
|
12617
|
-
const
|
|
13449
|
+
const shellTheme = useMemo(() => inferShellTheme(theme), [theme]);
|
|
13450
|
+
const ctxValue = useMemo(() => ({ browser, shellTheme }), [browser, shellTheme]);
|
|
12618
13451
|
return (jsx(GenomeBrowserContext.Provider, { value: ctxValue, children: jsxs("div", { className: className, style: style, children: [browser && children, jsx("div", { ref: containerRef })] }) }));
|
|
12619
13452
|
});
|
|
12620
13453
|
|
|
@@ -12723,8 +13556,15 @@ function RulerTrack({ config, maxTrackHeight }) {
|
|
|
12723
13556
|
return null;
|
|
12724
13557
|
}
|
|
12725
13558
|
|
|
12726
|
-
function WigTrack({ url, config, height, background, windowFunction, maxTrackHeight, name, metadata }) {
|
|
12727
|
-
useTrackManager((browser) =>
|
|
13559
|
+
function WigTrack({ url, features, config, height, background, windowFunction, maxTrackHeight, name, metadata }) {
|
|
13560
|
+
useTrackManager((browser) => {
|
|
13561
|
+
if (features) {
|
|
13562
|
+
return browser.addWigTrackWithFeatures(features, { config, height, background, maxTrackHeight, name, metadata });
|
|
13563
|
+
}
|
|
13564
|
+
if (!url)
|
|
13565
|
+
throw new Error('WigTrack requires either a `url` or `features` prop');
|
|
13566
|
+
return browser.addWigTrack(url, { config, height, background, windowFunction, maxTrackHeight, name, metadata });
|
|
13567
|
+
}, [url, features, windowFunction], (track) => { if (config)
|
|
12728
13568
|
track.setConfig(config); }, [config, height, background, name]);
|
|
12729
13569
|
return null;
|
|
12730
13570
|
}
|
|
@@ -12735,8 +13575,15 @@ function GeneTrack({ config, height, background, genome, track, maxTrackHeight,
|
|
|
12735
13575
|
return null;
|
|
12736
13576
|
}
|
|
12737
13577
|
|
|
12738
|
-
function BedTrack({ url, config, height, background, format, indexURL, indexed, maxTrackHeight, name, metadata }) {
|
|
12739
|
-
useTrackManager((browser) =>
|
|
13578
|
+
function BedTrack({ url, features, config, height, background, format, indexURL, indexed, maxTrackHeight, name, metadata }) {
|
|
13579
|
+
useTrackManager((browser) => {
|
|
13580
|
+
if (features) {
|
|
13581
|
+
return browser.addBedTrackWithFeatures(features, { config, height, background, maxTrackHeight, name, metadata });
|
|
13582
|
+
}
|
|
13583
|
+
if (!url)
|
|
13584
|
+
throw new Error('BedTrack requires either a `url` or `features` prop');
|
|
13585
|
+
return browser.addBedTrack(url, { config, height, background, format, indexURL, indexed, maxTrackHeight, name, metadata });
|
|
13586
|
+
}, [url, features, format, indexURL, indexed], (track) => { if (config)
|
|
12740
13587
|
track.setConfig(config); }, [config, height, background, name]);
|
|
12741
13588
|
return null;
|
|
12742
13589
|
}
|
|
@@ -13387,15 +14234,126 @@ function ExportControls({ browser: browserProp }) {
|
|
|
13387
14234
|
return jsx("loom-export-controls", { ref: ref });
|
|
13388
14235
|
}
|
|
13389
14236
|
|
|
13390
|
-
|
|
14237
|
+
/**
|
|
14238
|
+
* CSS custom property themes for UI shell web components.
|
|
14239
|
+
*
|
|
14240
|
+
* Themes are injected as <style> blocks into Shadow DOM roots.
|
|
14241
|
+
* Components reference CSS custom properties (--loom-*) for all visual styling.
|
|
14242
|
+
*/
|
|
14243
|
+
/** Classic theme — matches igv.js navbar look. */
|
|
14244
|
+
const classicThemeCSS = /* css */ `
|
|
14245
|
+
:host {
|
|
14246
|
+
--loom-navbar-bg: #f3f3f3;
|
|
14247
|
+
--loom-navbar-height: 32px;
|
|
14248
|
+
--loom-navbar-padding: 0 8px;
|
|
14249
|
+
--loom-font: 12px Arial, sans-serif;
|
|
14250
|
+
--loom-font-small: 11px Arial, sans-serif;
|
|
14251
|
+
--loom-text-color: #333;
|
|
14252
|
+
--loom-text-muted: #737373;
|
|
14253
|
+
--loom-border: 1px solid #ccc;
|
|
14254
|
+
--loom-border-radius: 4px;
|
|
14255
|
+
--loom-button-bg: white;
|
|
14256
|
+
--loom-button-hover: #e8e8e8;
|
|
14257
|
+
--loom-button-border: 1px solid #b0b0b0;
|
|
14258
|
+
--loom-button-size: 24px;
|
|
14259
|
+
--loom-input-bg: white;
|
|
14260
|
+
--loom-input-border: 1px solid #b0b0b0;
|
|
14261
|
+
--loom-input-focus-border: 1px solid #4A90D9;
|
|
14262
|
+
--loom-input-width: 220px;
|
|
14263
|
+
--loom-input-height: 22px;
|
|
14264
|
+
--loom-accent: #4A90D9;
|
|
14265
|
+
--loom-icon-color: #555;
|
|
14266
|
+
--loom-icon-size: 14px;
|
|
14267
|
+
--loom-gap: 8px;
|
|
14268
|
+
}
|
|
14269
|
+
`;
|
|
14270
|
+
/** Modern theme — softer colors, rounded corners, taller navbar. */
|
|
14271
|
+
const modernThemeCSS = /* css */ `
|
|
14272
|
+
:host {
|
|
14273
|
+
--loom-navbar-bg: #fafbfc;
|
|
14274
|
+
--loom-navbar-height: 40px;
|
|
14275
|
+
--loom-navbar-padding: 0 12px;
|
|
14276
|
+
--loom-font: 13px Inter, system-ui, -apple-system, sans-serif;
|
|
14277
|
+
--loom-font-small: 11px Inter, system-ui, -apple-system, sans-serif;
|
|
14278
|
+
--loom-text-color: #1a1a1a;
|
|
14279
|
+
--loom-text-muted: #6b7280;
|
|
14280
|
+
--loom-border: 1px solid rgba(0, 0, 0, 0.08);
|
|
14281
|
+
--loom-border-radius: 8px;
|
|
14282
|
+
--loom-button-bg: white;
|
|
14283
|
+
--loom-button-hover: #f0f4ff;
|
|
14284
|
+
--loom-button-border: 1px solid rgba(0, 0, 0, 0.1);
|
|
14285
|
+
--loom-button-size: 28px;
|
|
14286
|
+
--loom-input-bg: white;
|
|
14287
|
+
--loom-input-border: 1px solid rgba(0, 0, 0, 0.12);
|
|
14288
|
+
--loom-input-focus-border: 1px solid #4A90D9;
|
|
14289
|
+
--loom-input-width: 260px;
|
|
14290
|
+
--loom-input-height: 28px;
|
|
14291
|
+
--loom-accent: #4A90D9;
|
|
14292
|
+
--loom-icon-color: #6b7280;
|
|
14293
|
+
--loom-icon-size: 16px;
|
|
14294
|
+
--loom-gap: 10px;
|
|
14295
|
+
}
|
|
14296
|
+
`;
|
|
14297
|
+
/** Dark theme — dark backgrounds, light text, high contrast. */
|
|
14298
|
+
const darkThemeCSS = /* css */ `
|
|
14299
|
+
:host {
|
|
14300
|
+
--loom-shell-bg: #16162a;
|
|
14301
|
+
--loom-navbar-bg: #1a1a2e;
|
|
14302
|
+
--loom-navbar-height: 36px;
|
|
14303
|
+
--loom-navbar-padding: 0 10px;
|
|
14304
|
+
--loom-font: 12px Arial, sans-serif;
|
|
14305
|
+
--loom-font-small: 11px Arial, sans-serif;
|
|
14306
|
+
--loom-text-color: #e0e0e0;
|
|
14307
|
+
--loom-text-muted: #888;
|
|
14308
|
+
--loom-border: 1px solid #333;
|
|
14309
|
+
--loom-border-radius: 4px;
|
|
14310
|
+
--loom-button-bg: #2a2a3e;
|
|
14311
|
+
--loom-button-hover: #3a3a50;
|
|
14312
|
+
--loom-button-border: 1px solid #444;
|
|
14313
|
+
--loom-button-size: 24px;
|
|
14314
|
+
--loom-input-bg: #2a2a3e;
|
|
14315
|
+
--loom-input-border: 1px solid #444;
|
|
14316
|
+
--loom-input-focus-border: 1px solid #6a9fd9;
|
|
14317
|
+
--loom-input-width: 220px;
|
|
14318
|
+
--loom-input-height: 22px;
|
|
14319
|
+
--loom-accent: #6a9fd9;
|
|
14320
|
+
--loom-icon-color: #b0b0b0;
|
|
14321
|
+
--loom-icon-size: 14px;
|
|
14322
|
+
--loom-gap: 8px;
|
|
14323
|
+
}
|
|
14324
|
+
`;
|
|
14325
|
+
function getThemeCSS(theme) {
|
|
14326
|
+
if (theme === 'modern')
|
|
14327
|
+
return modernThemeCSS;
|
|
14328
|
+
if (theme === 'dark')
|
|
14329
|
+
return darkThemeCSS;
|
|
14330
|
+
return classicThemeCSS;
|
|
14331
|
+
}
|
|
14332
|
+
|
|
14333
|
+
function Navbar({ browser: browserProp, theme: themeProp }) {
|
|
14334
|
+
var _a;
|
|
14335
|
+
const ctx = useContext(GenomeBrowserContext);
|
|
13391
14336
|
const contextBrowser = useGenomeBrowser();
|
|
13392
14337
|
const browser = browserProp !== null && browserProp !== void 0 ? browserProp : contextBrowser;
|
|
14338
|
+
const theme = (_a = themeProp !== null && themeProp !== void 0 ? themeProp : ctx === null || ctx === void 0 ? void 0 : ctx.shellTheme) !== null && _a !== void 0 ? _a : 'modern';
|
|
13393
14339
|
const ref = useRef(null);
|
|
14340
|
+
const styleRef = useRef(null);
|
|
13394
14341
|
ensureRegistered();
|
|
13395
14342
|
useEffect(() => {
|
|
13396
14343
|
if (ref.current)
|
|
13397
14344
|
ref.current.browser = browser;
|
|
13398
14345
|
}, [browser]);
|
|
14346
|
+
// Inject shell theme CSS into the navbar's shadow root
|
|
14347
|
+
useEffect(() => {
|
|
14348
|
+
const el = ref.current;
|
|
14349
|
+
if (!(el === null || el === void 0 ? void 0 : el.shadowRoot))
|
|
14350
|
+
return;
|
|
14351
|
+
if (!styleRef.current) {
|
|
14352
|
+
styleRef.current = document.createElement('style');
|
|
14353
|
+
el.shadowRoot.prepend(styleRef.current);
|
|
14354
|
+
}
|
|
14355
|
+
styleRef.current.textContent = getThemeCSS(theme);
|
|
14356
|
+
}, [theme]);
|
|
13399
14357
|
return jsx("loom-navbar", { ref: ref });
|
|
13400
14358
|
}
|
|
13401
14359
|
|