loom-browser 0.0.6 → 0.0.8
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 +1177 -342
- package/dist/loom-react.esm.min.js +1 -1
- package/dist/loom-react.esm.min.js.map +1 -1
- package/dist/loom-worker.js +10596 -0
- package/dist/loom-worker.min.js +2 -0
- package/dist/loom-worker.min.js.map +1 -0
- package/dist/loom.esm.js +8516 -7691
- package/dist/loom.esm.min.js +1 -1
- package/dist/loom.esm.min.js.map +1 -1
- package/dist/loom.js +8518 -7691
- package/dist/loom.min.js +1 -1
- package/dist/loom.min.js.map +1 -1
- package/dist/tsconfig.src.tsbuildinfo +1 -1
- package/dist/types/bigwig/index.d.ts +2 -5
- package/dist/types/dataSourceWorkerProvider.d.ts +79 -0
- package/dist/types/dataSources/bigWigDataSource.d.ts +1 -3
- package/dist/types/dataSources/featureSourceFactory.d.ts +1 -2
- package/dist/types/dataSources/gtxDataSource.d.ts +1 -3
- package/dist/types/dataSources/textFeatureSource.d.ts +0 -4
- package/dist/types/genomeBrowser.d.ts +56 -1
- package/dist/types/gtx/index.d.ts +1 -4
- package/dist/types/headlessGenomeBrowser.d.ts +43 -2
- package/dist/types/index.d.ts +8 -2
- package/dist/types/mainThreadDataSourceProvider.d.ts +19 -0
- package/dist/types/react/LoomBrowser.d.ts +16 -1
- package/dist/types/react/ROISet.d.ts +14 -0
- package/dist/types/react/hooks/index.d.ts +1 -0
- package/dist/types/react/hooks/useTrackManager.d.ts +13 -1
- package/dist/types/react/index.d.ts +3 -0
- package/dist/types/react/tracks/BedTrack.d.ts +13 -4
- package/dist/types/react/tracks/GeneTrack.d.ts +7 -2
- package/dist/types/react/tracks/GtxTrack.d.ts +7 -2
- package/dist/types/react/tracks/InteractionTrack.d.ts +6 -1
- package/dist/types/react/tracks/WigTrack.d.ts +6 -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 +10 -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 +13 -1
- package/dist/types/tracks/interaction/interactionRenderer.d.ts +4 -1
- package/dist/types/tracks/interaction/interactionTrackCanvas.d.ts +1 -0
- package/dist/types/tracks/trackLabel.d.ts +22 -0
- package/dist/types/tracks/wig/wigTrackCanvas.d.ts +3 -0
- package/dist/types/types.d.ts +18 -0
- package/dist/types/worker/dataSourceRegistry.d.ts +33 -0
- package/dist/types/worker/dataSourceWorkerScript.d.ts +13 -0
- package/dist/types/worker/unifiedWorkerScript.d.ts +17 -0
- package/dist/types/worker/webDataSourceWorkerProvider.d.ts +59 -0
- package/dist/types/worker/webUnifiedWorkerProvider.d.ts +64 -0
- package/dist/types/worker/webWorkerPool.d.ts +64 -0
- package/dist/types/worker/workerPoolScript.d.ts +17 -0
- package/dist/types/workerDataSource.d.ts +32 -0
- package/dist/types/workerProvider.d.ts +10 -3
- package/package.json +3 -1
package/dist/loom-react.esm.js
CHANGED
|
@@ -1074,6 +1074,47 @@ class DOMCanvasProvider {
|
|
|
1074
1074
|
/** Default CanvasProvider for browser environments. */
|
|
1075
1075
|
const defaultCanvasProvider = new DOMCanvasProvider();
|
|
1076
1076
|
|
|
1077
|
+
/**
|
|
1078
|
+
* WorkerDataSource — main-thread proxy that forwards DataSource.fetch() to a worker.
|
|
1079
|
+
*
|
|
1080
|
+
* Implements DataSource<F> so it's a drop-in replacement for any direct DataSource.
|
|
1081
|
+
* The real DataSource instance lives inside a web worker; this proxy communicates
|
|
1082
|
+
* via the DataSourceWorkerProvider.
|
|
1083
|
+
*
|
|
1084
|
+
* Non-serializable configuration (like chromosome name resolver functions) is
|
|
1085
|
+
* stored locally on the proxy and applied before forwarding to the worker.
|
|
1086
|
+
*
|
|
1087
|
+
* Layer 1 (Data + Layout): no DOM.
|
|
1088
|
+
*/
|
|
1089
|
+
class WorkerDataSource {
|
|
1090
|
+
constructor(provider, instanceId) {
|
|
1091
|
+
this.provider = provider;
|
|
1092
|
+
this.instanceId = instanceId;
|
|
1093
|
+
}
|
|
1094
|
+
async fetch(locus, bpPerPixel, signal) {
|
|
1095
|
+
// Resolve chromosome name locally (functions can't cross postMessage boundary)
|
|
1096
|
+
const resolvedLocus = this.resolver
|
|
1097
|
+
? { ...locus, chr: this.resolver(locus.chr) }
|
|
1098
|
+
: locus;
|
|
1099
|
+
return this.provider.fetch(this.instanceId, resolvedLocus, bpPerPixel, signal);
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Set a chromosome name resolver. Stored locally — the proxy resolves
|
|
1103
|
+
* chromosome names before forwarding the locus to the worker.
|
|
1104
|
+
*/
|
|
1105
|
+
setChromNameResolver(resolver) {
|
|
1106
|
+
this.resolver = resolver;
|
|
1107
|
+
}
|
|
1108
|
+
/** Forward cumulative offsets to the worker-resident DataSource. */
|
|
1109
|
+
setCumulativeOffsets(offsets) {
|
|
1110
|
+
this.provider.configure(this.instanceId, 'setCumulativeOffsets', offsets);
|
|
1111
|
+
}
|
|
1112
|
+
/** Forward window function change to the worker-resident DataSource. */
|
|
1113
|
+
setWindowFunction(wf) {
|
|
1114
|
+
this.provider.configure(this.instanceId, 'setWindowFunction', wf);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1077
1118
|
/**
|
|
1078
1119
|
* Unified RenderTheme system — shadcn-style palette-driven theming.
|
|
1079
1120
|
*
|
|
@@ -2212,6 +2253,9 @@ function createCachedSequence(provider) {
|
|
|
2212
2253
|
currentQuery = null;
|
|
2213
2254
|
return interval;
|
|
2214
2255
|
});
|
|
2256
|
+
// Clear stale currentQuery on failure (e.g. abort) so subsequent
|
|
2257
|
+
// requests don't keep reusing the rejected promise via dedup.
|
|
2258
|
+
promise.catch(() => { currentQuery = null; });
|
|
2215
2259
|
currentQuery = { interval: queryLocus, promise };
|
|
2216
2260
|
return promise;
|
|
2217
2261
|
}
|
|
@@ -2447,6 +2491,14 @@ class BaseTrackCanvas {
|
|
|
2447
2491
|
hitTest(_x, _y) {
|
|
2448
2492
|
return [];
|
|
2449
2493
|
}
|
|
2494
|
+
/**
|
|
2495
|
+
* Return feature bounding rectangles for the current render state.
|
|
2496
|
+
* Used by SVG overlay system for hover/click interactivity.
|
|
2497
|
+
* Default: empty (no overlay). Subclasses override for interactive tracks.
|
|
2498
|
+
*/
|
|
2499
|
+
getFeatureRects() {
|
|
2500
|
+
return [];
|
|
2501
|
+
}
|
|
2450
2502
|
/**
|
|
2451
2503
|
* Return context menu items for a right-click at canvas-relative coordinates.
|
|
2452
2504
|
* Default: undefined (no track-specific items). Subclasses override.
|
|
@@ -2503,10 +2555,12 @@ class BaseTrackCanvas {
|
|
|
2503
2555
|
ctx.fillRect(0, 0, width, height);
|
|
2504
2556
|
if (this._error) {
|
|
2505
2557
|
this.renderError(ctx, width, height);
|
|
2558
|
+
this.renderLabelOverlay(ctx, width, height);
|
|
2506
2559
|
return;
|
|
2507
2560
|
}
|
|
2508
2561
|
if (this._zoomedOut) {
|
|
2509
2562
|
this.renderZoomInNotice(ctx, width, height);
|
|
2563
|
+
this.renderLabelOverlay(ctx, width, height);
|
|
2510
2564
|
return;
|
|
2511
2565
|
}
|
|
2512
2566
|
const bpPerPixel = (this._locus.end - this._locus.start) / width;
|
|
@@ -2537,6 +2591,14 @@ class BaseTrackCanvas {
|
|
|
2537
2591
|
ctx.textBaseline = 'middle';
|
|
2538
2592
|
ctx.fillText('Zoom in to see features', width / 2, height / 2);
|
|
2539
2593
|
}
|
|
2594
|
+
/**
|
|
2595
|
+
* Render the track name label overlay. Called after error/zoom-in notices
|
|
2596
|
+
* so labels remain visible even when the track can't render data.
|
|
2597
|
+
* No-op by default — subclasses with name labels should override.
|
|
2598
|
+
*/
|
|
2599
|
+
renderLabelOverlay(_ctx, _width, _height) {
|
|
2600
|
+
// No-op — subclasses override
|
|
2601
|
+
}
|
|
2540
2602
|
/**
|
|
2541
2603
|
* Render this track onto an arbitrary context (e.g. Canvas2SVG for SVG export).
|
|
2542
2604
|
* Skips DPR scaling and canvas-element lifecycle — draws directly at the given
|
|
@@ -2551,10 +2613,12 @@ class BaseTrackCanvas {
|
|
|
2551
2613
|
ctx.fillRect(0, 0, width, height);
|
|
2552
2614
|
if (this._error) {
|
|
2553
2615
|
this.renderError(ctx, width, height);
|
|
2616
|
+
this.renderLabelOverlay(ctx, width, height);
|
|
2554
2617
|
return;
|
|
2555
2618
|
}
|
|
2556
2619
|
if (this._zoomedOut) {
|
|
2557
2620
|
this.renderZoomInNotice(ctx, width, height);
|
|
2621
|
+
this.renderLabelOverlay(ctx, width, height);
|
|
2558
2622
|
return;
|
|
2559
2623
|
}
|
|
2560
2624
|
const bpPerPixel = (this._locus.end - this._locus.start) / width;
|
|
@@ -2568,6 +2632,47 @@ class BaseTrackCanvas {
|
|
|
2568
2632
|
}
|
|
2569
2633
|
}
|
|
2570
2634
|
|
|
2635
|
+
/**
|
|
2636
|
+
* Shared track name overlay label — renders a horizontal label in the top-left
|
|
2637
|
+
* corner of a track canvas with a semi-transparent background pill.
|
|
2638
|
+
*
|
|
2639
|
+
* Used by wig, annotation, and interaction tracks for consistent labeling.
|
|
2640
|
+
* Layer 2 (Track Canvases): uses canvas API, no DOM.
|
|
2641
|
+
*/
|
|
2642
|
+
/**
|
|
2643
|
+
* Render a track name as a horizontal overlay in the top-left corner.
|
|
2644
|
+
* Draws a semi-transparent background pill behind the text for readability.
|
|
2645
|
+
*/
|
|
2646
|
+
function renderTrackNameLabel(ctx, options, pixelHeight) {
|
|
2647
|
+
var _a, _b;
|
|
2648
|
+
if (pixelHeight < 12)
|
|
2649
|
+
return;
|
|
2650
|
+
const pad = 4;
|
|
2651
|
+
const vPad = 2;
|
|
2652
|
+
const hPad = 4;
|
|
2653
|
+
ctx.font = 'normal 10px sans-serif';
|
|
2654
|
+
ctx.textBaseline = 'top';
|
|
2655
|
+
ctx.textAlign = 'left';
|
|
2656
|
+
const topOffset = pad + ((_a = options.topOffset) !== null && _a !== void 0 ? _a : 0);
|
|
2657
|
+
const metrics = ctx.measureText(options.name);
|
|
2658
|
+
const w = metrics.width + hPad * 2;
|
|
2659
|
+
const h = ((_b = metrics.actualBoundingBoxDescent) !== null && _b !== void 0 ? _b : 10) + vPad * 2;
|
|
2660
|
+
// Semi-transparent background
|
|
2661
|
+
ctx.fillStyle = options.background;
|
|
2662
|
+
ctx.globalAlpha = 0.75;
|
|
2663
|
+
ctx.fillRect(pad, topOffset, w, h);
|
|
2664
|
+
ctx.globalAlpha = 1.0;
|
|
2665
|
+
// Border — use labelColor at reduced opacity for theme awareness
|
|
2666
|
+
ctx.strokeStyle = options.labelColor;
|
|
2667
|
+
ctx.globalAlpha = 0.3;
|
|
2668
|
+
ctx.lineWidth = 0.5;
|
|
2669
|
+
ctx.strokeRect(pad, topOffset, w, h);
|
|
2670
|
+
ctx.globalAlpha = 1.0;
|
|
2671
|
+
// Text
|
|
2672
|
+
ctx.fillStyle = options.labelColor;
|
|
2673
|
+
ctx.fillText(options.name, pad + hPad, topOffset + vPad);
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2571
2676
|
/**
|
|
2572
2677
|
* Dynamic sequence (dynseq) renderer for wig tracks.
|
|
2573
2678
|
*
|
|
@@ -2950,50 +3055,30 @@ function renderDataRangeLabels(ctx, config, pixelHeight) {
|
|
|
2950
3055
|
}
|
|
2951
3056
|
// ─── Track name overlay label ────────────────────────────────────────────────
|
|
2952
3057
|
/**
|
|
2953
|
-
*
|
|
2954
|
-
*
|
|
2955
|
-
* When data range labels are also visible, positions below the max label.
|
|
3058
|
+
* Wig-specific track name label: computes topOffset to clear data range labels,
|
|
3059
|
+
* then delegates to the shared renderTrackNameLabel().
|
|
2956
3060
|
*/
|
|
2957
|
-
function
|
|
2958
|
-
var _a
|
|
3061
|
+
function renderWigTrackNameLabel(ctx, config, pixelHeight) {
|
|
3062
|
+
var _a;
|
|
2959
3063
|
if (!config.trackName)
|
|
2960
3064
|
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
3065
|
// Compute vertical offset — push below data range max label when visible
|
|
2970
|
-
let topOffset =
|
|
3066
|
+
let topOffset = 0;
|
|
2971
3067
|
if (config.showDataRange) {
|
|
3068
|
+
const vPad = 2;
|
|
2972
3069
|
ctx.save();
|
|
2973
3070
|
ctx.font = config.labelFont;
|
|
2974
3071
|
const maxLabel = prettyPrint(config.flipAxis ? config.dataRange.min : config.dataRange.max);
|
|
2975
3072
|
const maxH = ((_a = ctx.measureText(maxLabel).actualBoundingBoxDescent) !== null && _a !== void 0 ? _a : 10) + vPad * 2;
|
|
2976
3073
|
ctx.restore();
|
|
2977
|
-
|
|
2978
|
-
topOffset = pad + maxH + 2;
|
|
3074
|
+
topOffset = maxH + 2;
|
|
2979
3075
|
}
|
|
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);
|
|
3076
|
+
renderTrackNameLabel(ctx, {
|
|
3077
|
+
name: config.trackName,
|
|
3078
|
+
background: config.background,
|
|
3079
|
+
labelColor: config.labelColor,
|
|
3080
|
+
topOffset,
|
|
3081
|
+
}, pixelHeight);
|
|
2997
3082
|
}
|
|
2998
3083
|
// ─── Baseline and guide lines ───────────────────────────────────────────────
|
|
2999
3084
|
function renderBaseline(ctx, config, pixelWidth, pixelHeight) {
|
|
@@ -3068,7 +3153,7 @@ function renderWigTrack(ctx, features, config, rc) {
|
|
|
3068
3153
|
renderBaseline(ctx, config, rc.pixelWidth, pixelHeight);
|
|
3069
3154
|
renderGuideLines(ctx, config, rc.pixelWidth, yScale);
|
|
3070
3155
|
renderDataRangeLabels(ctx, config, pixelHeight);
|
|
3071
|
-
|
|
3156
|
+
renderWigTrackNameLabel(ctx, config, pixelHeight);
|
|
3072
3157
|
}
|
|
3073
3158
|
|
|
3074
3159
|
/**
|
|
@@ -3235,11 +3320,17 @@ class WigTrackCanvas extends BaseTrackCanvas {
|
|
|
3235
3320
|
this._lastDataRange = null;
|
|
3236
3321
|
/** Abort controller for in-flight sequence fetch. */
|
|
3237
3322
|
this._seqAbort = null;
|
|
3323
|
+
/** User-set config overrides, re-applied on theme change. */
|
|
3324
|
+
this._userOverrides = {};
|
|
3238
3325
|
this.type = 'wig';
|
|
3239
3326
|
this.features = options.features;
|
|
3240
3327
|
this.fixedHeight = options.height;
|
|
3241
3328
|
this._name = options.name;
|
|
3242
3329
|
this._sequenceProvider = options.sequenceProvider;
|
|
3330
|
+
if (options.config)
|
|
3331
|
+
this._userOverrides = { ...options.config };
|
|
3332
|
+
if (options.background !== undefined)
|
|
3333
|
+
this._userOverrides.background = options.background;
|
|
3243
3334
|
}
|
|
3244
3335
|
/** Set a callback invoked when the user changes the windowing function via context menu. */
|
|
3245
3336
|
set onWindowFunctionChange(cb) {
|
|
@@ -3267,6 +3358,7 @@ class WigTrackCanvas extends BaseTrackCanvas {
|
|
|
3267
3358
|
}
|
|
3268
3359
|
/** Merge partial config and re-render. Triggers sequence fetch when switching to dynseq. */
|
|
3269
3360
|
setConfig(config) {
|
|
3361
|
+
Object.assign(this._userOverrides, config);
|
|
3270
3362
|
const wasDynseq = this._config.graphType === 'dynseq';
|
|
3271
3363
|
super.setConfig(config);
|
|
3272
3364
|
if (!wasDynseq && this._config.graphType === 'dynseq') {
|
|
@@ -3335,9 +3427,20 @@ class WigTrackCanvas extends BaseTrackCanvas {
|
|
|
3335
3427
|
getBackground() {
|
|
3336
3428
|
return this.config.background;
|
|
3337
3429
|
}
|
|
3430
|
+
renderLabelOverlay(ctx, _width, height) {
|
|
3431
|
+
if (this._name) {
|
|
3432
|
+
renderTrackNameLabel(ctx, {
|
|
3433
|
+
name: this._name,
|
|
3434
|
+
background: this.config.background,
|
|
3435
|
+
labelColor: this.config.labelColor,
|
|
3436
|
+
}, height);
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3338
3439
|
doRender(ctx, _width, height, rc) {
|
|
3339
|
-
if (this.features.length === 0)
|
|
3440
|
+
if (this.features.length === 0) {
|
|
3441
|
+
this.renderLabelOverlay(ctx, _width, height);
|
|
3340
3442
|
return;
|
|
3443
|
+
}
|
|
3341
3444
|
// Apply value scaling (normalize then scaleFactor), matching igv.js getFeatures() order.
|
|
3342
3445
|
// Creates a scaled copy to avoid mutating the original features array.
|
|
3343
3446
|
const needsScaling = (this.config.normalizationFactor != null && this.config.normalizationFactor !== 1)
|
|
@@ -3365,14 +3468,14 @@ class WigTrackCanvas extends BaseTrackCanvas {
|
|
|
3365
3468
|
this._config = { ...this._config, dataRange: autoRange };
|
|
3366
3469
|
}
|
|
3367
3470
|
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
3471
|
renderWigTrack(ctx, features, effectiveConfig, rc);
|
|
3370
3472
|
}
|
|
3371
3473
|
getAxisInfo() {
|
|
3372
|
-
|
|
3474
|
+
var _a;
|
|
3475
|
+
if (!this._name && !this._lastDataRange)
|
|
3373
3476
|
return undefined;
|
|
3374
3477
|
return {
|
|
3375
|
-
dataRange: this._lastDataRange,
|
|
3478
|
+
dataRange: (_a = this._lastDataRange) !== null && _a !== void 0 ? _a : undefined,
|
|
3376
3479
|
color: typeof this.config.color === 'string' ? this.config.color : undefined,
|
|
3377
3480
|
label: this._name,
|
|
3378
3481
|
flipAxis: this.config.flipAxis || undefined,
|
|
@@ -3450,7 +3553,7 @@ class WigTrackCanvas extends BaseTrackCanvas {
|
|
|
3450
3553
|
}
|
|
3451
3554
|
setTheme(theme) {
|
|
3452
3555
|
const dataRange = this._config.dataRange;
|
|
3453
|
-
this._config = { ...resolveWigConfig(theme), dataRange };
|
|
3556
|
+
this._config = { ...resolveWigConfig(theme), ...this._userOverrides, dataRange };
|
|
3454
3557
|
this.render();
|
|
3455
3558
|
}
|
|
3456
3559
|
serializeConfig(theme) {
|
|
@@ -3858,7 +3961,10 @@ function getUtrColorForFeature(feature, config) {
|
|
|
3858
3961
|
* Features should already have `.row` assigned (via `pack()`).
|
|
3859
3962
|
* The canvas should be sized and cleared before calling this.
|
|
3860
3963
|
*/
|
|
3861
|
-
function renderAnnotationTrack(ctx, features, config, rc
|
|
3964
|
+
function renderAnnotationTrack(ctx, features, config, rc,
|
|
3965
|
+
/** Optional track name label overlay (top-left corner). */
|
|
3966
|
+
trackLabel) {
|
|
3967
|
+
var _a;
|
|
3862
3968
|
const rowLastLabelX = {};
|
|
3863
3969
|
for (const feature of features) {
|
|
3864
3970
|
if (feature.row === undefined)
|
|
@@ -3870,6 +3976,9 @@ function renderAnnotationTrack(ctx, features, config, rc) {
|
|
|
3870
3976
|
continue;
|
|
3871
3977
|
renderSingleFeature(ctx, feature, config, rc, rowLastLabelX);
|
|
3872
3978
|
}
|
|
3979
|
+
if (trackLabel) {
|
|
3980
|
+
renderTrackNameLabel(ctx, trackLabel, ctx.canvas.height / ((_a = globalThis.devicePixelRatio) !== null && _a !== void 0 ? _a : 1));
|
|
3981
|
+
}
|
|
3873
3982
|
}
|
|
3874
3983
|
|
|
3875
3984
|
/**
|
|
@@ -3890,6 +3999,8 @@ class AnnotationTrackCanvas extends BaseTrackCanvas {
|
|
|
3890
3999
|
const theme = resolveTheme(options.theme);
|
|
3891
4000
|
const config = resolveAnnotationConfig(theme, options.config);
|
|
3892
4001
|
super(canvas, options.locus, config, options.canvasProvider);
|
|
4002
|
+
/** User-set config overrides, re-applied on theme change. */
|
|
4003
|
+
this._userOverrides = {};
|
|
3893
4004
|
/** Most recently packed features, available after render(). */
|
|
3894
4005
|
this.packedFeatures = [];
|
|
3895
4006
|
/** Whether packedFeatures was pre-computed by async worker (skip sync pack in computeHeight). */
|
|
@@ -3899,30 +4010,20 @@ class AnnotationTrackCanvas extends BaseTrackCanvas {
|
|
|
3899
4010
|
this.fixedHeight = options.height;
|
|
3900
4011
|
this.background = (_a = options.background) !== null && _a !== void 0 ? _a : theme.palette.background;
|
|
3901
4012
|
this._name = options.name;
|
|
3902
|
-
|
|
4013
|
+
if (options.config)
|
|
4014
|
+
this._userOverrides = { ...options.config };
|
|
4015
|
+
this._userBackground = options.background;
|
|
4016
|
+
}
|
|
4017
|
+
/** Set or clear the fixed pixel height. When set, overrides auto-computed row-based height. */
|
|
4018
|
+
setFixedHeight(height) {
|
|
4019
|
+
this.fixedHeight = height;
|
|
4020
|
+
this.render();
|
|
3903
4021
|
}
|
|
3904
|
-
/** Update features and re-render.
|
|
4022
|
+
/** Update features and re-render. */
|
|
3905
4023
|
setFeatures(features) {
|
|
3906
4024
|
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
|
-
}
|
|
4025
|
+
this.packedReady = false;
|
|
4026
|
+
this.render();
|
|
3926
4027
|
}
|
|
3927
4028
|
computeHeight(_width) {
|
|
3928
4029
|
var _a;
|
|
@@ -3940,11 +4041,31 @@ class AnnotationTrackCanvas extends BaseTrackCanvas {
|
|
|
3940
4041
|
getBackground() {
|
|
3941
4042
|
return this.background;
|
|
3942
4043
|
}
|
|
4044
|
+
renderLabelOverlay(ctx, _width, height) {
|
|
4045
|
+
var _a;
|
|
4046
|
+
if (this._name) {
|
|
4047
|
+
renderTrackNameLabel(ctx, {
|
|
4048
|
+
name: this._name,
|
|
4049
|
+
background: this.background,
|
|
4050
|
+
labelColor: (_a = this.config.labelColor) !== null && _a !== void 0 ? _a : '#333',
|
|
4051
|
+
}, height);
|
|
4052
|
+
}
|
|
4053
|
+
}
|
|
3943
4054
|
doRender(ctx, _width, _height, rc) {
|
|
3944
|
-
|
|
4055
|
+
var _a;
|
|
4056
|
+
// Ensure label background matches track background so clearRect doesn't
|
|
4057
|
+
// punch transparent holes (which show the underlying CSS background).
|
|
4058
|
+
const config = this.config.labelBackground
|
|
4059
|
+
? this.config
|
|
4060
|
+
: { ...this.config, labelBackground: this.background };
|
|
4061
|
+
renderAnnotationTrack(ctx, this.packedFeatures, config, rc, this._name ? {
|
|
4062
|
+
name: this._name,
|
|
4063
|
+
background: this.background,
|
|
4064
|
+
labelColor: (_a = config.labelColor) !== null && _a !== void 0 ? _a : '#333',
|
|
4065
|
+
} : undefined);
|
|
3945
4066
|
}
|
|
3946
4067
|
getAxisInfo() {
|
|
3947
|
-
return
|
|
4068
|
+
return undefined;
|
|
3948
4069
|
}
|
|
3949
4070
|
getContextMenuItems(_x, _y) {
|
|
3950
4071
|
const modes = ['EXPANDED', 'SQUISHED', 'COLLAPSED'];
|
|
@@ -3958,6 +4079,43 @@ class AnnotationTrackCanvas extends BaseTrackCanvas {
|
|
|
3958
4079
|
})),
|
|
3959
4080
|
}];
|
|
3960
4081
|
}
|
|
4082
|
+
getFeatureRects() {
|
|
4083
|
+
var _a;
|
|
4084
|
+
if (this.packedFeatures.length === 0)
|
|
4085
|
+
return [];
|
|
4086
|
+
const width = this._canvas.clientWidth;
|
|
4087
|
+
if (width === 0)
|
|
4088
|
+
return [];
|
|
4089
|
+
const bpPerPixel = (this._locus.end - this._locus.start) / width;
|
|
4090
|
+
const bpStart = this._locus.start;
|
|
4091
|
+
const config = this._config;
|
|
4092
|
+
const rowHeight = config.displayMode === 'SQUISHED'
|
|
4093
|
+
? config.squishedRowHeight
|
|
4094
|
+
: config.expandedRowHeight;
|
|
4095
|
+
const featureH = config.displayMode === 'SQUISHED'
|
|
4096
|
+
? config.featureHeight / 2
|
|
4097
|
+
: config.featureHeight;
|
|
4098
|
+
const rects = [];
|
|
4099
|
+
for (const f of this.packedFeatures) {
|
|
4100
|
+
if (f.row === undefined)
|
|
4101
|
+
continue;
|
|
4102
|
+
const pxStart = (f.start - bpStart) / bpPerPixel;
|
|
4103
|
+
const pxEnd = (f.end - bpStart) / bpPerPixel;
|
|
4104
|
+
if (pxEnd < 0 || pxStart > width)
|
|
4105
|
+
continue;
|
|
4106
|
+
const py = config.margin + rowHeight * f.row;
|
|
4107
|
+
let px = pxStart;
|
|
4108
|
+
let pw = pxEnd - pxStart;
|
|
4109
|
+
if (pw < 3) {
|
|
4110
|
+
px -= (3 - pw) / 2;
|
|
4111
|
+
pw = 3;
|
|
4112
|
+
}
|
|
4113
|
+
// Resolve feature color (same logic as annotationRenderer's getColorForFeature)
|
|
4114
|
+
const color = (_a = f.color) !== null && _a !== void 0 ? _a : (config.altColor && f.strand === '-' ? config.altColor : config.color);
|
|
4115
|
+
rects.push({ feature: f, x: px, y: py, width: pw, height: featureH, color });
|
|
4116
|
+
}
|
|
4117
|
+
return rects;
|
|
4118
|
+
}
|
|
3961
4119
|
hitTest(x, y) {
|
|
3962
4120
|
if (this.packedFeatures.length === 0)
|
|
3963
4121
|
return [];
|
|
@@ -3982,9 +4140,14 @@ class AnnotationTrackCanvas extends BaseTrackCanvas {
|
|
|
3982
4140
|
}
|
|
3983
4141
|
return results;
|
|
3984
4142
|
}
|
|
4143
|
+
setConfig(config) {
|
|
4144
|
+
Object.assign(this._userOverrides, config);
|
|
4145
|
+
super.setConfig(config);
|
|
4146
|
+
}
|
|
3985
4147
|
setTheme(theme) {
|
|
3986
|
-
|
|
3987
|
-
this.
|
|
4148
|
+
var _a;
|
|
4149
|
+
this._config = resolveAnnotationConfig(theme, this._userOverrides);
|
|
4150
|
+
this.background = (_a = this._userBackground) !== null && _a !== void 0 ? _a : theme.palette.background;
|
|
3988
4151
|
this.render();
|
|
3989
4152
|
}
|
|
3990
4153
|
serializeConfig(theme) {
|
|
@@ -4747,7 +4910,7 @@ function featureToWig(f, chr, wf) {
|
|
|
4747
4910
|
* Thin wrapper around @gmod/bbi BigWig that preserves the legacy API.
|
|
4748
4911
|
*/
|
|
4749
4912
|
class BigWigReader {
|
|
4750
|
-
constructor(url
|
|
4913
|
+
constructor(url) {
|
|
4751
4914
|
this.bw = getGmodReader(url);
|
|
4752
4915
|
}
|
|
4753
4916
|
async loadHeader(signal) {
|
|
@@ -4818,7 +4981,7 @@ class BigWigReader {
|
|
|
4818
4981
|
*/
|
|
4819
4982
|
async function fetchBigWigFeatures(url, locus, options = {}) {
|
|
4820
4983
|
var _a;
|
|
4821
|
-
const reader = new BigWigReader(url
|
|
4984
|
+
const reader = new BigWigReader(url);
|
|
4822
4985
|
return reader.readFeatures(locus.chr, locus.start, locus.end, options.bpPerPixel, (_a = options.windowFunction) !== null && _a !== void 0 ? _a : 'mean', options.signal);
|
|
4823
4986
|
}
|
|
4824
4987
|
/**
|
|
@@ -4827,15 +4990,14 @@ async function fetchBigWigFeatures(url, locus, options = {}) {
|
|
|
4827
4990
|
*/
|
|
4828
4991
|
async function fetchBigWigWGFeatures(url, chromNames, bpPerPixel, options = {}) {
|
|
4829
4992
|
var _a;
|
|
4830
|
-
const reader = new BigWigReader(url
|
|
4993
|
+
const reader = new BigWigReader(url);
|
|
4831
4994
|
return reader.readWGFeatures(chromNames, bpPerPixel, (_a = options.windowFunction) !== null && _a !== void 0 ? _a : 'mean', options.signal);
|
|
4832
4995
|
}
|
|
4833
4996
|
|
|
4834
4997
|
class BigWigDataSource {
|
|
4835
|
-
constructor(url, windowFunction = 'mean'
|
|
4998
|
+
constructor(url, windowFunction = 'mean') {
|
|
4836
4999
|
this.url = url;
|
|
4837
5000
|
this._windowFunction = windowFunction;
|
|
4838
|
-
this.workerProvider = workerProvider;
|
|
4839
5001
|
}
|
|
4840
5002
|
get windowFunction() { return this._windowFunction; }
|
|
4841
5003
|
/** Update the window function for future fetches. */
|
|
@@ -4861,20 +5023,10 @@ class BigWigDataSource {
|
|
|
4861
5023
|
const features = await fetchBigWigFeatures(this.url, resolvedLocus, {
|
|
4862
5024
|
bpPerPixel,
|
|
4863
5025
|
windowFunction: this._windowFunction,
|
|
4864
|
-
workerProvider: this.workerProvider,
|
|
4865
5026
|
signal,
|
|
4866
5027
|
});
|
|
4867
5028
|
// Summarize to pixel resolution (matching igv.js wigTrack.getFeatures())
|
|
4868
5029
|
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
5030
|
return summarizeWigData(features, locus.start, bpPerPixel, this._windowFunction);
|
|
4879
5031
|
}
|
|
4880
5032
|
return features;
|
|
@@ -4885,7 +5037,7 @@ class BigWigDataSource {
|
|
|
4885
5037
|
*/
|
|
4886
5038
|
async fetchWG(bpPerPixel, signal) {
|
|
4887
5039
|
const offsets = this._cumulativeOffsets;
|
|
4888
|
-
const features = await fetchBigWigWGFeatures(this.url, offsets.chromosomeNames, bpPerPixel, { windowFunction: this._windowFunction,
|
|
5040
|
+
const features = await fetchBigWigWGFeatures(this.url, offsets.chromosomeNames, bpPerPixel, { windowFunction: this._windowFunction, signal });
|
|
4889
5041
|
// Transform to genome-wide coordinates
|
|
4890
5042
|
const wgFeatures = [];
|
|
4891
5043
|
for (const f of features) {
|
|
@@ -4902,15 +5054,6 @@ class BigWigDataSource {
|
|
|
4902
5054
|
wgFeatures.sort((a, b) => a.start - b.start);
|
|
4903
5055
|
// Summarize at genome-wide scale
|
|
4904
5056
|
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
5057
|
return summarizeWigData(wgFeatures, 0, bpPerPixel, this._windowFunction);
|
|
4915
5058
|
}
|
|
4916
5059
|
return wgFeatures;
|
|
@@ -5864,11 +6007,10 @@ function getReader(url) {
|
|
|
5864
6007
|
return reader;
|
|
5865
6008
|
}
|
|
5866
6009
|
class GtxDataSource {
|
|
5867
|
-
constructor(url, experimentId, windowFunction = 'mean'
|
|
6010
|
+
constructor(url, experimentId, windowFunction = 'mean') {
|
|
5868
6011
|
this.url = url;
|
|
5869
6012
|
this.experimentId = experimentId;
|
|
5870
6013
|
this._windowFunction = windowFunction;
|
|
5871
|
-
this.workerProvider = workerProvider;
|
|
5872
6014
|
this.reader = getReader(url);
|
|
5873
6015
|
}
|
|
5874
6016
|
get windowFunction() { return this._windowFunction; }
|
|
@@ -5901,15 +6043,6 @@ class GtxDataSource {
|
|
|
5901
6043
|
const features = await coordinator.request(this.experimentId, resolvedLocus, bpPerPixel, signal);
|
|
5902
6044
|
// Summarize to pixel resolution
|
|
5903
6045
|
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
6046
|
return summarizeWigData(features, locus.start, bpPerPixel, this._windowFunction);
|
|
5914
6047
|
}
|
|
5915
6048
|
return features;
|
|
@@ -5936,15 +6069,6 @@ class GtxDataSource {
|
|
|
5936
6069
|
wgFeatures.sort((a, b) => a.start - b.start);
|
|
5937
6070
|
// Summarize at genome-wide scale
|
|
5938
6071
|
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
6072
|
return summarizeWigData(wgFeatures, 0, bpPerPixel, this._windowFunction);
|
|
5949
6073
|
}
|
|
5950
6074
|
return wgFeatures;
|
|
@@ -8019,7 +8143,6 @@ class TextFeatureSource {
|
|
|
8019
8143
|
var _a, _b;
|
|
8020
8144
|
this.allFeaturesLoaded = false;
|
|
8021
8145
|
this.url = config.url;
|
|
8022
|
-
this.workerProvider = config.workerProvider;
|
|
8023
8146
|
// Detect format
|
|
8024
8147
|
const detected = (_a = config.format) !== null && _a !== void 0 ? _a : inferFormatFromPath(config.url);
|
|
8025
8148
|
this.format = detected !== null && detected !== void 0 ? detected : 'bed';
|
|
@@ -8037,7 +8160,7 @@ class TextFeatureSource {
|
|
|
8037
8160
|
// Set up TabixReader for indexed files
|
|
8038
8161
|
if (this._indexed) {
|
|
8039
8162
|
const indexUrl = (_b = config.indexURL) !== null && _b !== void 0 ? _b : inferIndexURL(config.url);
|
|
8040
|
-
this.tabixReader = new TabixReader(config.url, { indexUrl
|
|
8163
|
+
this.tabixReader = new TabixReader(config.url, { indexUrl });
|
|
8041
8164
|
}
|
|
8042
8165
|
}
|
|
8043
8166
|
/** Whether this source is indexed (queryable by region). */
|
|
@@ -8123,19 +8246,11 @@ class TextFeatureSource {
|
|
|
8123
8246
|
const lines = text.split(/\r?\n/);
|
|
8124
8247
|
// Parse header
|
|
8125
8248
|
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
|
-
}));
|
|
8249
|
+
// Parse features
|
|
8250
|
+
const features = parseFeatures(lines, this.format, {
|
|
8251
|
+
header: this.header,
|
|
8252
|
+
assembleGFF: this.assembleGFF,
|
|
8253
|
+
});
|
|
8139
8254
|
// Sort by chr then start
|
|
8140
8255
|
features.sort((a, b) => {
|
|
8141
8256
|
if (a.chr === b.chr)
|
|
@@ -8316,18 +8431,26 @@ class MemoryDataSource {
|
|
|
8316
8431
|
* Stateless: all state is passed in, no side effects beyond canvas drawing
|
|
8317
8432
|
* and attaching drawState to features for hit-testing.
|
|
8318
8433
|
*/
|
|
8319
|
-
function renderInteractionTrack(ctx, features, config, rc
|
|
8434
|
+
function renderInteractionTrack(ctx, features, config, rc,
|
|
8435
|
+
/** Optional track name label overlay (top-left corner). */
|
|
8436
|
+
trackLabel) {
|
|
8320
8437
|
// Clear background
|
|
8321
8438
|
ctx.fillStyle = config.background;
|
|
8322
8439
|
ctx.fillRect(0, 0, rc.pixelWidth, config.height);
|
|
8323
|
-
if (!features || features.length === 0)
|
|
8440
|
+
if (!features || features.length === 0) {
|
|
8441
|
+
if (trackLabel)
|
|
8442
|
+
renderTrackNameLabel(ctx, trackLabel, config.height);
|
|
8324
8443
|
return;
|
|
8444
|
+
}
|
|
8325
8445
|
if (config.displayMode === 'proportional') {
|
|
8326
8446
|
drawProportional(ctx, features, config, rc);
|
|
8327
8447
|
}
|
|
8328
8448
|
else {
|
|
8329
8449
|
drawNested(ctx, features, config, rc);
|
|
8330
8450
|
}
|
|
8451
|
+
if (trackLabel) {
|
|
8452
|
+
renderTrackNameLabel(ctx, trackLabel, config.height);
|
|
8453
|
+
}
|
|
8331
8454
|
}
|
|
8332
8455
|
// ─── Nested arcs ─────────────────────────────────────────────────────────────
|
|
8333
8456
|
function drawNested(ctx, features, config, rc) {
|
|
@@ -8630,8 +8753,21 @@ class InteractionTrackCanvas extends BaseTrackCanvas {
|
|
|
8630
8753
|
getBackground() {
|
|
8631
8754
|
return this.config.background;
|
|
8632
8755
|
}
|
|
8756
|
+
renderLabelOverlay(ctx, _width, height) {
|
|
8757
|
+
if (this._name) {
|
|
8758
|
+
renderTrackNameLabel(ctx, {
|
|
8759
|
+
name: this._name,
|
|
8760
|
+
background: this._config.background,
|
|
8761
|
+
labelColor: '#333',
|
|
8762
|
+
}, height);
|
|
8763
|
+
}
|
|
8764
|
+
}
|
|
8633
8765
|
doRender(ctx, _width, _height, rc) {
|
|
8634
|
-
renderInteractionTrack(ctx, this.features, this._config, rc
|
|
8766
|
+
renderInteractionTrack(ctx, this.features, this._config, rc, this._name ? {
|
|
8767
|
+
name: this._name,
|
|
8768
|
+
background: this._config.background,
|
|
8769
|
+
labelColor: '#333',
|
|
8770
|
+
} : undefined);
|
|
8635
8771
|
}
|
|
8636
8772
|
/** Hit-test: find features at canvas-relative pixel coordinates. */
|
|
8637
8773
|
hitTest(x, y) {
|
|
@@ -8851,12 +8987,12 @@ function knownTrackTypes() {
|
|
|
8851
8987
|
return types;
|
|
8852
8988
|
}
|
|
8853
8989
|
// ─── Built-in data source helpers ────────────────────────────────────────────
|
|
8854
|
-
function createDataSource(config
|
|
8990
|
+
function createDataSource(config) {
|
|
8855
8991
|
switch (config.type) {
|
|
8856
8992
|
case 'bigwig':
|
|
8857
|
-
return new BigWigDataSource(config.url, config.windowFunction
|
|
8993
|
+
return new BigWigDataSource(config.url, config.windowFunction);
|
|
8858
8994
|
case 'gtx':
|
|
8859
|
-
return new GtxDataSource(config.url, config.experimentId, config.windowFunction
|
|
8995
|
+
return new GtxDataSource(config.url, config.experimentId, config.windowFunction);
|
|
8860
8996
|
case 'ucsc':
|
|
8861
8997
|
return new GeneDataSource({ genome: config.genome, track: config.track });
|
|
8862
8998
|
case 'text':
|
|
@@ -8865,7 +9001,6 @@ function createDataSource(config, workerProvider) {
|
|
|
8865
9001
|
format: config.format,
|
|
8866
9002
|
indexURL: config.indexURL,
|
|
8867
9003
|
indexed: config.indexed,
|
|
8868
|
-
workerProvider,
|
|
8869
9004
|
});
|
|
8870
9005
|
case 'memory':
|
|
8871
9006
|
// Memory data sources are created directly with features by the caller.
|
|
@@ -8889,7 +9024,7 @@ function createWigTrack(trackConfig, ctx) {
|
|
|
8889
9024
|
let dataSourceConfig = null;
|
|
8890
9025
|
if (config.dataSource) {
|
|
8891
9026
|
dataSourceConfig = config.dataSource;
|
|
8892
|
-
dataSource = createDataSource(config.dataSource
|
|
9027
|
+
dataSource = createDataSource(config.dataSource);
|
|
8893
9028
|
}
|
|
8894
9029
|
return { track, dataSource, dataSourceConfig, name: config.name, order: config.order };
|
|
8895
9030
|
}
|
|
@@ -8902,13 +9037,12 @@ function createAnnotationTrack(trackConfig, ctx) {
|
|
|
8902
9037
|
config: config.config,
|
|
8903
9038
|
theme: ctx.theme,
|
|
8904
9039
|
canvasProvider: ctx.canvasProvider,
|
|
8905
|
-
workerProvider: ctx.workerProvider,
|
|
8906
9040
|
});
|
|
8907
9041
|
let dataSource = null;
|
|
8908
9042
|
let dataSourceConfig = null;
|
|
8909
9043
|
if (config.dataSource) {
|
|
8910
9044
|
dataSourceConfig = config.dataSource;
|
|
8911
|
-
dataSource = createDataSource(config.dataSource
|
|
9045
|
+
dataSource = createDataSource(config.dataSource);
|
|
8912
9046
|
}
|
|
8913
9047
|
return { track, dataSource, dataSourceConfig, name: config.name, order: config.order };
|
|
8914
9048
|
}
|
|
@@ -8954,7 +9088,7 @@ function createInteractionTrack(trackConfig, ctx) {
|
|
|
8954
9088
|
let dataSourceConfig = null;
|
|
8955
9089
|
if (config.dataSource) {
|
|
8956
9090
|
dataSourceConfig = config.dataSource;
|
|
8957
|
-
dataSource = createDataSource(config.dataSource
|
|
9091
|
+
dataSource = createDataSource(config.dataSource);
|
|
8958
9092
|
}
|
|
8959
9093
|
return { track, dataSource, dataSourceConfig, name: config.name, order: config.order };
|
|
8960
9094
|
}
|
|
@@ -9000,7 +9134,6 @@ function createTrackFromConfig(trackConfig, locus, options = {}) {
|
|
|
9000
9134
|
return creator(trackConfig, {
|
|
9001
9135
|
locus,
|
|
9002
9136
|
canvasProvider: (_a = options.canvasProvider) !== null && _a !== void 0 ? _a : defaultCanvasProvider,
|
|
9003
|
-
workerProvider: options.workerProvider,
|
|
9004
9137
|
theme: options.theme,
|
|
9005
9138
|
sequenceProvider: options.sequenceProvider,
|
|
9006
9139
|
});
|
|
@@ -9384,6 +9517,28 @@ function selectTracks(tracks, selector) {
|
|
|
9384
9517
|
* browser.setLocus({ chr: 'chr17', start: 7670000, end: 7680000 })
|
|
9385
9518
|
* browser.dispose()
|
|
9386
9519
|
*/
|
|
9520
|
+
/** Duck-type check: does this object implement DataSourceWorkerProvider? */
|
|
9521
|
+
function isDataSourceWorkerProvider(obj) {
|
|
9522
|
+
if (!obj || typeof obj !== 'object')
|
|
9523
|
+
return false;
|
|
9524
|
+
const o = obj;
|
|
9525
|
+
return typeof o.create === 'function'
|
|
9526
|
+
&& typeof o.fetch === 'function'
|
|
9527
|
+
&& typeof o.destroy === 'function';
|
|
9528
|
+
}
|
|
9529
|
+
/** Check if an error is an AbortError (from fetch abort, signal abort, or worker abort). */
|
|
9530
|
+
function isAbortError(err) {
|
|
9531
|
+
if (err instanceof DOMException && err.name === 'AbortError')
|
|
9532
|
+
return true;
|
|
9533
|
+
if (err instanceof Error && err.name === 'AbortError')
|
|
9534
|
+
return true;
|
|
9535
|
+
// Worker-relayed abort errors may arrive as plain Error with the browser's
|
|
9536
|
+
// abort message (e.g., "signal is aborted without reason") because the
|
|
9537
|
+
// worker-side DOMException check can miss non-DOMException abort errors.
|
|
9538
|
+
if (err instanceof Error && err.message.includes('aborted'))
|
|
9539
|
+
return true;
|
|
9540
|
+
return false;
|
|
9541
|
+
}
|
|
9387
9542
|
const BrowserEvent = {
|
|
9388
9543
|
LocusChange: 'locuschange',
|
|
9389
9544
|
TrackAdded: 'trackadded',
|
|
@@ -9407,6 +9562,25 @@ let nextTrackId = 0;
|
|
|
9407
9562
|
function generateTrackId(type) {
|
|
9408
9563
|
return `${type !== null && type !== void 0 ? type : 'track'}-${nextTrackId++}`;
|
|
9409
9564
|
}
|
|
9565
|
+
/**
|
|
9566
|
+
* Track type sort priority — adapts the igv.js `reorderTracks()` two-level
|
|
9567
|
+
* sort (js/browser.ts:1153-1172) to a single numeric priority.
|
|
9568
|
+
*
|
|
9569
|
+
* igv.js pins ideogram (1), ruler (2) to the top, then sorts by `track.order`.
|
|
9570
|
+
* Loom uses positive priority for top-pinned tracks, 0 for user data tracks,
|
|
9571
|
+
* and negative values to push tracks (e.g., gene reference) to the bottom.
|
|
9572
|
+
*
|
|
9573
|
+
* Each ManagedTrack can also set `order` which is added to the type priority,
|
|
9574
|
+
* giving callers fine-grained control (e.g., addGeneTrack sets order=-1).
|
|
9575
|
+
*/
|
|
9576
|
+
const DEFAULT_TRACK_TYPE_PRIORITY = {
|
|
9577
|
+
ruler: 2,
|
|
9578
|
+
sequence: 1,
|
|
9579
|
+
};
|
|
9580
|
+
function trackTypePriority(type) {
|
|
9581
|
+
var _a;
|
|
9582
|
+
return (_a = DEFAULT_TRACK_TYPE_PRIORITY[type]) !== null && _a !== void 0 ? _a : 0;
|
|
9583
|
+
}
|
|
9410
9584
|
class HeadlessGenomeBrowser {
|
|
9411
9585
|
get theme() { return this._theme; }
|
|
9412
9586
|
get locus() { return this._locus; }
|
|
@@ -9423,6 +9597,12 @@ class HeadlessGenomeBrowser {
|
|
|
9423
9597
|
this.roiSets = [];
|
|
9424
9598
|
/** Inflight fetch promises keyed by (cacheKey + fetchRegion + bpPerPixel) for deduplication. */
|
|
9425
9599
|
this.inflightFetches = new Map();
|
|
9600
|
+
/** Timer for debouncing data loads during rapid zoom/pan. */
|
|
9601
|
+
this.loadDebounceTimer = null;
|
|
9602
|
+
/** Set to true by dispose() to suppress post-dispose promise handlers. */
|
|
9603
|
+
this._disposed = false;
|
|
9604
|
+
/** When true, sortTracks() is a no-op. Used for batch track additions (e.g. loadSession). */
|
|
9605
|
+
this._deferSort = false;
|
|
9426
9606
|
this.events = new EventEmitter();
|
|
9427
9607
|
this.genome = options.genome === null ? undefined : ((_a = options.genome) !== null && _a !== void 0 ? _a : hg38Genome);
|
|
9428
9608
|
this.chromSizes = (_b = this.genome) === null || _b === void 0 ? void 0 : _b.chromSizes;
|
|
@@ -9432,12 +9612,33 @@ class HeadlessGenomeBrowser {
|
|
|
9432
9612
|
this._viewportWidth = (_e = options.viewportWidth) !== null && _e !== void 0 ? _e : 0;
|
|
9433
9613
|
this.canvasProvider = (_f = options.canvasProvider) !== null && _f !== void 0 ? _f : defaultCanvasProvider;
|
|
9434
9614
|
this.workerProvider = options.workerProvider;
|
|
9615
|
+
// Auto-detect: if workerProvider also implements DataSourceWorkerProvider, use it for both
|
|
9616
|
+
this.dataSourceWorkerProvider = isDataSourceWorkerProvider(options.workerProvider)
|
|
9617
|
+
? options.workerProvider : undefined;
|
|
9435
9618
|
this.popupProvider = (_g = options.popupProvider) !== null && _g !== void 0 ? _g : undefined;
|
|
9436
9619
|
this.contextMenuProvider = (_h = options.contextMenuProvider) !== null && _h !== void 0 ? _h : undefined;
|
|
9437
9620
|
this._theme = resolveTheme(options.theme);
|
|
9438
9621
|
if (options.stateProjection)
|
|
9439
9622
|
this._state = options.stateProjection;
|
|
9440
9623
|
}
|
|
9624
|
+
/**
|
|
9625
|
+
* Create a WorkerDataSource proxy for worker-eligible configs, or return null
|
|
9626
|
+
* if dataSourceWorkerProvider is not set. Handles create + chrom alias + offsets wiring.
|
|
9627
|
+
*/
|
|
9628
|
+
createWorkerDataSource(config) {
|
|
9629
|
+
if (!this.dataSourceWorkerProvider)
|
|
9630
|
+
return null;
|
|
9631
|
+
const instanceId = `ds-${nextTrackId}-${Date.now()}`;
|
|
9632
|
+
this.dataSourceWorkerProvider.create(instanceId, config);
|
|
9633
|
+
const proxy = new WorkerDataSource(this.dataSourceWorkerProvider, instanceId);
|
|
9634
|
+
if (this.genome) {
|
|
9635
|
+
proxy.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
9636
|
+
}
|
|
9637
|
+
if (this.cumulativeOffsets) {
|
|
9638
|
+
proxy.setCumulativeOffsets(this.cumulativeOffsets);
|
|
9639
|
+
}
|
|
9640
|
+
return proxy;
|
|
9641
|
+
}
|
|
9441
9642
|
/** Clamp a locus to valid chromosome bounds. No-op if chromSizes is not set. */
|
|
9442
9643
|
clamp(locus) {
|
|
9443
9644
|
return this.chromSizes ? clampLocus(locus, this.chromSizes, this.cumulativeOffsets) : locus;
|
|
@@ -9461,7 +9662,7 @@ class HeadlessGenomeBrowser {
|
|
|
9461
9662
|
this.loadAllTracksIfNeeded();
|
|
9462
9663
|
}
|
|
9463
9664
|
/** Add a track with an optional data source for automatic data management. */
|
|
9464
|
-
addTrack(track, dataSource, dataSourceConfig, maxTrackHeight) {
|
|
9665
|
+
addTrack(track, dataSource, dataSourceConfig, maxTrackHeight, order) {
|
|
9465
9666
|
var _a;
|
|
9466
9667
|
const id = generateTrackId(track.type);
|
|
9467
9668
|
const mt = {
|
|
@@ -9472,8 +9673,10 @@ class HeadlessGenomeBrowser {
|
|
|
9472
9673
|
cache: null,
|
|
9473
9674
|
abortController: null,
|
|
9474
9675
|
maxTrackHeight,
|
|
9676
|
+
order,
|
|
9475
9677
|
};
|
|
9476
9678
|
this.managedTracks.push(mt);
|
|
9679
|
+
this.sortTracks();
|
|
9477
9680
|
track.setLocus(this._locus);
|
|
9478
9681
|
this.events.emit(BrowserEvent.TrackAdded, { track });
|
|
9479
9682
|
// Trigger initial data load if data source provided
|
|
@@ -9491,6 +9694,9 @@ class HeadlessGenomeBrowser {
|
|
|
9491
9694
|
const mt = this.managedTracks[idx];
|
|
9492
9695
|
if (mt.abortController)
|
|
9493
9696
|
mt.abortController.abort();
|
|
9697
|
+
if (mt.dataSource instanceof WorkerDataSource && this.dataSourceWorkerProvider) {
|
|
9698
|
+
this.dataSourceWorkerProvider.destroy(mt.dataSource.instanceId);
|
|
9699
|
+
}
|
|
9494
9700
|
this.managedTracks.splice(idx, 1);
|
|
9495
9701
|
this.events.emit(BrowserEvent.TrackRemoved, { track: mt.track });
|
|
9496
9702
|
}
|
|
@@ -9508,8 +9714,42 @@ class HeadlessGenomeBrowser {
|
|
|
9508
9714
|
return;
|
|
9509
9715
|
const [moved] = this.managedTracks.splice(fromIndex, 1);
|
|
9510
9716
|
this.managedTracks.splice(clampedIndex, 0, moved);
|
|
9717
|
+
this._assignOrderFromPosition();
|
|
9511
9718
|
this.events.emit(BrowserEvent.TrackOrderChanged, { tracks: this.managedTracks.map(mt => mt.track) });
|
|
9512
9719
|
}
|
|
9720
|
+
/**
|
|
9721
|
+
* Sort tracks by priority (descending): positive = top, 0 = middle, negative = bottom.
|
|
9722
|
+
* Priority comes from the track type default + per-track `order` override.
|
|
9723
|
+
* Within the same effective priority, insertion order is preserved (stable sort).
|
|
9724
|
+
*/
|
|
9725
|
+
sortTracks() {
|
|
9726
|
+
if (this._deferSort)
|
|
9727
|
+
return;
|
|
9728
|
+
this.managedTracks.sort((a, b) => {
|
|
9729
|
+
var _a, _b;
|
|
9730
|
+
const pa = ((_a = a.order) !== null && _a !== void 0 ? _a : 0) + trackTypePriority(a.track.type);
|
|
9731
|
+
const pb = ((_b = b.order) !== null && _b !== void 0 ? _b : 0) + trackTypePriority(b.track.type);
|
|
9732
|
+
return pb - pa; // descending: higher priority first
|
|
9733
|
+
});
|
|
9734
|
+
this.onTracksSorted();
|
|
9735
|
+
}
|
|
9736
|
+
/** Hook for subclasses to react to track order changes (e.g., DOM reorder). */
|
|
9737
|
+
onTracksSorted() { }
|
|
9738
|
+
/**
|
|
9739
|
+
* Persist current array positions into mt.order so future sorts preserve
|
|
9740
|
+
* manual reordering. Uses descending values (top track = highest) scaled
|
|
9741
|
+
* above the type-priority range so explicit order dominates.
|
|
9742
|
+
*/
|
|
9743
|
+
_assignOrderFromPosition() {
|
|
9744
|
+
const n = this.managedTracks.length;
|
|
9745
|
+
for (let i = 0; i < n; i++) {
|
|
9746
|
+
this.managedTracks[i].order = (n - i) * 10;
|
|
9747
|
+
}
|
|
9748
|
+
}
|
|
9749
|
+
/** Find the ManagedTrack entry for a given track canvas. */
|
|
9750
|
+
findMT(track) {
|
|
9751
|
+
return this.managedTracks.find(mt => mt.track === track);
|
|
9752
|
+
}
|
|
9513
9753
|
/** Get the current track order. */
|
|
9514
9754
|
getTrackOrder() {
|
|
9515
9755
|
return this.managedTracks.map(mt => mt.track);
|
|
@@ -9574,7 +9814,7 @@ class HeadlessGenomeBrowser {
|
|
|
9574
9814
|
for (const mt of this.managedTracks) {
|
|
9575
9815
|
mt.track.setLocus(this._locus);
|
|
9576
9816
|
}
|
|
9577
|
-
this.
|
|
9817
|
+
this.debouncedLoad();
|
|
9578
9818
|
this.events.emit(BrowserEvent.LocusChange, { locus: this._locus });
|
|
9579
9819
|
}
|
|
9580
9820
|
/**
|
|
@@ -9725,6 +9965,17 @@ class HeadlessGenomeBrowser {
|
|
|
9725
9965
|
}
|
|
9726
9966
|
return undefined;
|
|
9727
9967
|
}
|
|
9968
|
+
/** Remove a specific ROI set by instance. Returns true if found and removed. */
|
|
9969
|
+
removeROISet(set) {
|
|
9970
|
+
const idx = this.roiSets.indexOf(set);
|
|
9971
|
+
if (idx < 0)
|
|
9972
|
+
return false;
|
|
9973
|
+
this.roiSets.splice(idx, 1);
|
|
9974
|
+
for (const roi of set.features) {
|
|
9975
|
+
this.events.emit(BrowserEvent.ROIRemoved, { roi, set });
|
|
9976
|
+
}
|
|
9977
|
+
return true;
|
|
9978
|
+
}
|
|
9728
9979
|
/** Remove all ROI sets. */
|
|
9729
9980
|
clearROIs() {
|
|
9730
9981
|
this.roiSets = [];
|
|
@@ -9819,7 +10070,8 @@ class HeadlessGenomeBrowser {
|
|
|
9819
10070
|
serialized.order = mt.order;
|
|
9820
10071
|
if (mt.metadata)
|
|
9821
10072
|
serialized.metadata = mt.metadata;
|
|
9822
|
-
if (mt.dataSourceConfig
|
|
10073
|
+
if (mt.dataSourceConfig
|
|
10074
|
+
&& serialized.type !== 'ruler' && serialized.type !== 'sequence') {
|
|
9823
10075
|
serialized.dataSource = mt.dataSourceConfig;
|
|
9824
10076
|
}
|
|
9825
10077
|
tracks.push(serialized);
|
|
@@ -9865,35 +10117,49 @@ class HeadlessGenomeBrowser {
|
|
|
9865
10117
|
// Recreate tracks from session config
|
|
9866
10118
|
const trackOptions = {
|
|
9867
10119
|
canvasProvider: this.canvasProvider,
|
|
9868
|
-
workerProvider: this.workerProvider,
|
|
9869
10120
|
theme: options === null || options === void 0 ? void 0 : options.theme,
|
|
9870
10121
|
sequenceProvider: this.sequenceProvider,
|
|
9871
10122
|
};
|
|
9872
|
-
|
|
9873
|
-
|
|
9874
|
-
|
|
9875
|
-
|
|
9876
|
-
|
|
9877
|
-
|
|
10123
|
+
this._deferSort = true;
|
|
10124
|
+
try {
|
|
10125
|
+
for (const trackConfig of session.tracks) {
|
|
10126
|
+
const created = createTrackFromSession(trackConfig, this._locus, trackOptions);
|
|
10127
|
+
// Use worker proxy for worker-eligible data sources
|
|
10128
|
+
let dataSource = created.dataSource;
|
|
10129
|
+
if (dataSource && created.dataSourceConfig && this.dataSourceWorkerProvider) {
|
|
10130
|
+
const dsType = created.dataSourceConfig.type;
|
|
10131
|
+
if (dsType === 'bigwig' || dsType === 'gtx' || dsType === 'text' || dsType === 'ucsc') {
|
|
10132
|
+
const workerDS = this.createWorkerDataSource(created.dataSourceConfig);
|
|
10133
|
+
if (workerDS)
|
|
10134
|
+
dataSource = workerDS;
|
|
10135
|
+
}
|
|
9878
10136
|
}
|
|
9879
|
-
|
|
9880
|
-
|
|
9881
|
-
if (
|
|
9882
|
-
|
|
10137
|
+
// Wire chromosome alias resolution for non-worker data sources
|
|
10138
|
+
if (!this.dataSourceWorkerProvider && this.genome && dataSource) {
|
|
10139
|
+
if (dataSource instanceof BigWigDataSource || dataSource instanceof GtxDataSource) {
|
|
10140
|
+
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10141
|
+
}
|
|
10142
|
+
else if (dataSource instanceof TextFeatureSource) {
|
|
10143
|
+
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10144
|
+
if (this.cumulativeOffsets) {
|
|
10145
|
+
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10146
|
+
}
|
|
9883
10147
|
}
|
|
9884
10148
|
}
|
|
10149
|
+
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);
|
|
10150
|
+
// Restore bookkeeping fields for round-trip serialization
|
|
10151
|
+
const mt = this.findMT(created.track);
|
|
10152
|
+
if (trackConfig.id)
|
|
10153
|
+
mt.id = trackConfig.id;
|
|
10154
|
+
if (created.name)
|
|
10155
|
+
mt.name = created.name;
|
|
10156
|
+
if (trackConfig.metadata)
|
|
10157
|
+
mt.metadata = trackConfig.metadata;
|
|
9885
10158
|
}
|
|
9886
|
-
|
|
9887
|
-
|
|
9888
|
-
|
|
9889
|
-
|
|
9890
|
-
mt.id = trackConfig.id;
|
|
9891
|
-
if (created.name)
|
|
9892
|
-
mt.name = created.name;
|
|
9893
|
-
if (created.order != null)
|
|
9894
|
-
mt.order = created.order;
|
|
9895
|
-
if (trackConfig.metadata)
|
|
9896
|
-
mt.metadata = trackConfig.metadata;
|
|
10159
|
+
}
|
|
10160
|
+
finally {
|
|
10161
|
+
this._deferSort = false;
|
|
10162
|
+
this.sortTracks();
|
|
9897
10163
|
}
|
|
9898
10164
|
// Restore ROI sets
|
|
9899
10165
|
if (session.rois) {
|
|
@@ -9935,30 +10201,37 @@ class HeadlessGenomeBrowser {
|
|
|
9935
10201
|
var _a, _b;
|
|
9936
10202
|
const created = createTrackFromConfig(trackConfig, this._locus, {
|
|
9937
10203
|
canvasProvider: this.canvasProvider,
|
|
9938
|
-
workerProvider: this.workerProvider,
|
|
9939
10204
|
theme: this.theme,
|
|
9940
10205
|
sequenceProvider: this.sequenceProvider,
|
|
9941
10206
|
});
|
|
9942
|
-
//
|
|
9943
|
-
|
|
9944
|
-
|
|
9945
|
-
|
|
9946
|
-
|
|
9947
|
-
|
|
9948
|
-
|
|
10207
|
+
// Use worker proxy for worker-eligible data sources
|
|
10208
|
+
let dataSource = created.dataSource;
|
|
10209
|
+
if (dataSource && created.dataSourceConfig && this.dataSourceWorkerProvider) {
|
|
10210
|
+
const dsType = created.dataSourceConfig.type;
|
|
10211
|
+
if (dsType === 'bigwig' || dsType === 'gtx' || dsType === 'text' || dsType === 'ucsc') {
|
|
10212
|
+
const workerDS = this.createWorkerDataSource(created.dataSourceConfig);
|
|
10213
|
+
if (workerDS)
|
|
10214
|
+
dataSource = workerDS;
|
|
10215
|
+
}
|
|
10216
|
+
}
|
|
10217
|
+
// Wire chromosome alias resolution for non-worker data sources
|
|
10218
|
+
if (!this.dataSourceWorkerProvider && this.genome && dataSource) {
|
|
10219
|
+
if (dataSource instanceof BigWigDataSource) {
|
|
10220
|
+
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10221
|
+
}
|
|
10222
|
+
else if (dataSource instanceof TextFeatureSource) {
|
|
10223
|
+
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
9949
10224
|
if (this.cumulativeOffsets) {
|
|
9950
|
-
|
|
10225
|
+
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
9951
10226
|
}
|
|
9952
10227
|
}
|
|
9953
10228
|
}
|
|
9954
|
-
this.addTrack(created.track, (_a = created.
|
|
9955
|
-
const mt = this.
|
|
10229
|
+
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);
|
|
10230
|
+
const mt = this.findMT(created.track);
|
|
9956
10231
|
if (trackConfig.id)
|
|
9957
10232
|
mt.id = trackConfig.id;
|
|
9958
10233
|
if (created.name)
|
|
9959
10234
|
mt.name = created.name;
|
|
9960
|
-
if (created.order != null)
|
|
9961
|
-
mt.order = created.order;
|
|
9962
10235
|
if (trackConfig.metadata)
|
|
9963
10236
|
mt.metadata = trackConfig.metadata;
|
|
9964
10237
|
return created.track;
|
|
@@ -9994,22 +10267,29 @@ class HeadlessGenomeBrowser {
|
|
|
9994
10267
|
name: options === null || options === void 0 ? void 0 : options.name,
|
|
9995
10268
|
sequenceProvider: this.sequenceProvider,
|
|
9996
10269
|
});
|
|
9997
|
-
const dataSource = new BigWigDataSource(url, windowFunction, this.workerProvider);
|
|
9998
|
-
if (this.cumulativeOffsets) {
|
|
9999
|
-
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10000
|
-
}
|
|
10001
|
-
if (this.genome) {
|
|
10002
|
-
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10003
|
-
}
|
|
10004
10270
|
const dataSourceConfig = {
|
|
10005
10271
|
type: 'bigwig', url, windowFunction,
|
|
10006
10272
|
};
|
|
10273
|
+
const workerDS = this.createWorkerDataSource(dataSourceConfig);
|
|
10274
|
+
const dataSource = workerDS !== null && workerDS !== void 0 ? workerDS : (() => {
|
|
10275
|
+
const ds = new BigWigDataSource(url, windowFunction);
|
|
10276
|
+
if (this.cumulativeOffsets)
|
|
10277
|
+
ds.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10278
|
+
if (this.genome)
|
|
10279
|
+
ds.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10280
|
+
return ds;
|
|
10281
|
+
})();
|
|
10007
10282
|
this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
|
|
10008
10283
|
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
10009
|
-
this.
|
|
10284
|
+
this.findMT(track).metadata = options.metadata;
|
|
10010
10285
|
// Wire windowing function callback: update data source, invalidate cache, re-fetch
|
|
10011
10286
|
track.onWindowFunctionChange = (wf) => {
|
|
10012
|
-
|
|
10287
|
+
if (workerDS) {
|
|
10288
|
+
workerDS.setWindowFunction(wf);
|
|
10289
|
+
}
|
|
10290
|
+
else {
|
|
10291
|
+
dataSource.setWindowFunction(wf);
|
|
10292
|
+
}
|
|
10013
10293
|
const mt = this.managedTracks.find(m => m.track === track);
|
|
10014
10294
|
if (mt) {
|
|
10015
10295
|
mt.cache = null;
|
|
@@ -10037,22 +10317,29 @@ class HeadlessGenomeBrowser {
|
|
|
10037
10317
|
name: options.name,
|
|
10038
10318
|
sequenceProvider: this.sequenceProvider,
|
|
10039
10319
|
});
|
|
10040
|
-
const dataSource = new GtxDataSource(url, options.experimentId, windowFunction, this.workerProvider);
|
|
10041
|
-
if (this.cumulativeOffsets) {
|
|
10042
|
-
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10043
|
-
}
|
|
10044
|
-
if (this.genome) {
|
|
10045
|
-
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10046
|
-
}
|
|
10047
10320
|
const dataSourceConfig = {
|
|
10048
10321
|
type: 'gtx', url, experimentId: options.experimentId, windowFunction,
|
|
10049
10322
|
};
|
|
10323
|
+
const workerDS = this.createWorkerDataSource(dataSourceConfig);
|
|
10324
|
+
const dataSource = workerDS !== null && workerDS !== void 0 ? workerDS : (() => {
|
|
10325
|
+
const ds = new GtxDataSource(url, options.experimentId, windowFunction);
|
|
10326
|
+
if (this.cumulativeOffsets)
|
|
10327
|
+
ds.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10328
|
+
if (this.genome)
|
|
10329
|
+
ds.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10330
|
+
return ds;
|
|
10331
|
+
})();
|
|
10050
10332
|
this.addTrack(track, dataSource, dataSourceConfig, options.maxTrackHeight);
|
|
10051
10333
|
if (options.metadata)
|
|
10052
|
-
this.
|
|
10334
|
+
this.findMT(track).metadata = options.metadata;
|
|
10053
10335
|
// Wire windowing function callback
|
|
10054
10336
|
track.onWindowFunctionChange = (wf) => {
|
|
10055
|
-
|
|
10337
|
+
if (workerDS) {
|
|
10338
|
+
workerDS.setWindowFunction(wf);
|
|
10339
|
+
}
|
|
10340
|
+
else {
|
|
10341
|
+
dataSource.setWindowFunction(wf);
|
|
10342
|
+
}
|
|
10056
10343
|
const mt = this.managedTracks.find(m => m.track === track);
|
|
10057
10344
|
if (mt) {
|
|
10058
10345
|
mt.cache = null;
|
|
@@ -10076,21 +10363,23 @@ class HeadlessGenomeBrowser {
|
|
|
10076
10363
|
background: options === null || options === void 0 ? void 0 : options.background,
|
|
10077
10364
|
theme: this.theme,
|
|
10078
10365
|
canvasProvider: this.canvasProvider,
|
|
10079
|
-
workerProvider: this.workerProvider,
|
|
10080
10366
|
name: (_a = options === null || options === void 0 ? void 0 : options.name) !== null && _a !== void 0 ? _a : 'Genes',
|
|
10081
10367
|
});
|
|
10082
10368
|
const genome = options === null || options === void 0 ? void 0 : options.genome;
|
|
10083
10369
|
const ucscTrack = options === null || options === void 0 ? void 0 : options.track;
|
|
10084
|
-
const dataSource = new GeneDataSource({ genome, track: ucscTrack });
|
|
10085
|
-
if (this.cumulativeOffsets) {
|
|
10086
|
-
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10087
|
-
}
|
|
10088
10370
|
const dataSourceConfig = {
|
|
10089
10371
|
type: 'ucsc', genome, track: ucscTrack,
|
|
10090
10372
|
};
|
|
10091
|
-
this.
|
|
10373
|
+
const workerDS = this.createWorkerDataSource(dataSourceConfig);
|
|
10374
|
+
const dataSource = workerDS !== null && workerDS !== void 0 ? workerDS : (() => {
|
|
10375
|
+
const ds = new GeneDataSource({ genome, track: ucscTrack });
|
|
10376
|
+
if (this.cumulativeOffsets)
|
|
10377
|
+
ds.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10378
|
+
return ds;
|
|
10379
|
+
})();
|
|
10380
|
+
this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight, -1);
|
|
10092
10381
|
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
10093
|
-
this.
|
|
10382
|
+
this.findMT(track).metadata = options.metadata;
|
|
10094
10383
|
return track;
|
|
10095
10384
|
}
|
|
10096
10385
|
/** Add a BED/peak annotation track from a URL. Supports plain text and tabix-indexed files. */
|
|
@@ -10105,28 +10394,23 @@ class HeadlessGenomeBrowser {
|
|
|
10105
10394
|
background: options === null || options === void 0 ? void 0 : options.background,
|
|
10106
10395
|
theme: this.theme,
|
|
10107
10396
|
canvasProvider: this.canvasProvider,
|
|
10108
|
-
workerProvider: this.workerProvider,
|
|
10109
10397
|
name: options === null || options === void 0 ? void 0 : options.name,
|
|
10110
10398
|
});
|
|
10111
|
-
const dataSource = new TextFeatureSource({
|
|
10112
|
-
url,
|
|
10113
|
-
format,
|
|
10114
|
-
indexURL: options === null || options === void 0 ? void 0 : options.indexURL,
|
|
10115
|
-
indexed: options === null || options === void 0 ? void 0 : options.indexed,
|
|
10116
|
-
workerProvider: this.workerProvider,
|
|
10117
|
-
});
|
|
10118
|
-
if (this.genome) {
|
|
10119
|
-
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10120
|
-
}
|
|
10121
|
-
if (this.cumulativeOffsets) {
|
|
10122
|
-
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10123
|
-
}
|
|
10124
10399
|
const dataSourceConfig = {
|
|
10125
10400
|
type: 'text', url, format, indexURL: options === null || options === void 0 ? void 0 : options.indexURL, indexed: options === null || options === void 0 ? void 0 : options.indexed,
|
|
10126
10401
|
};
|
|
10402
|
+
const workerDS = this.createWorkerDataSource(dataSourceConfig);
|
|
10403
|
+
const dataSource = workerDS !== null && workerDS !== void 0 ? workerDS : (() => {
|
|
10404
|
+
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 });
|
|
10405
|
+
if (this.genome)
|
|
10406
|
+
ds.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10407
|
+
if (this.cumulativeOffsets)
|
|
10408
|
+
ds.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10409
|
+
return ds;
|
|
10410
|
+
})();
|
|
10127
10411
|
this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
|
|
10128
10412
|
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
10129
|
-
this.
|
|
10413
|
+
this.findMT(track).metadata = options.metadata;
|
|
10130
10414
|
return track;
|
|
10131
10415
|
}
|
|
10132
10416
|
/** Add an interaction (arc/BEDPE) track from a URL. */
|
|
@@ -10142,25 +10426,21 @@ class HeadlessGenomeBrowser {
|
|
|
10142
10426
|
canvasProvider: this.canvasProvider,
|
|
10143
10427
|
name: options === null || options === void 0 ? void 0 : options.name,
|
|
10144
10428
|
});
|
|
10145
|
-
const dataSource = new TextFeatureSource({
|
|
10146
|
-
url,
|
|
10147
|
-
format,
|
|
10148
|
-
indexURL: options === null || options === void 0 ? void 0 : options.indexURL,
|
|
10149
|
-
indexed: options === null || options === void 0 ? void 0 : options.indexed,
|
|
10150
|
-
workerProvider: this.workerProvider,
|
|
10151
|
-
});
|
|
10152
|
-
if (this.genome) {
|
|
10153
|
-
dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10154
|
-
}
|
|
10155
|
-
if (this.cumulativeOffsets) {
|
|
10156
|
-
dataSource.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10157
|
-
}
|
|
10158
10429
|
const dataSourceConfig = {
|
|
10159
10430
|
type: 'text', url, format, indexURL: options === null || options === void 0 ? void 0 : options.indexURL, indexed: options === null || options === void 0 ? void 0 : options.indexed,
|
|
10160
10431
|
};
|
|
10432
|
+
const workerDS = this.createWorkerDataSource(dataSourceConfig);
|
|
10433
|
+
const dataSource = workerDS !== null && workerDS !== void 0 ? workerDS : (() => {
|
|
10434
|
+
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 });
|
|
10435
|
+
if (this.genome)
|
|
10436
|
+
ds.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
|
|
10437
|
+
if (this.cumulativeOffsets)
|
|
10438
|
+
ds.setCumulativeOffsets(this.cumulativeOffsets);
|
|
10439
|
+
return ds;
|
|
10440
|
+
})();
|
|
10161
10441
|
this.addTrack(track, dataSource, dataSourceConfig);
|
|
10162
10442
|
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
10163
|
-
this.
|
|
10443
|
+
this.findMT(track).metadata = options.metadata;
|
|
10164
10444
|
return track;
|
|
10165
10445
|
}
|
|
10166
10446
|
/** Add a BigWig-style signal track backed by in-memory features (no URL required). */
|
|
@@ -10187,7 +10467,7 @@ class HeadlessGenomeBrowser {
|
|
|
10187
10467
|
const dataSourceConfig = { type: 'memory' };
|
|
10188
10468
|
this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
|
|
10189
10469
|
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
10190
|
-
this.
|
|
10470
|
+
this.findMT(track).metadata = options.metadata;
|
|
10191
10471
|
return track;
|
|
10192
10472
|
}
|
|
10193
10473
|
/** Add a BED/annotation track backed by in-memory features (no URL required). Features must include `chr`. */
|
|
@@ -10201,7 +10481,6 @@ class HeadlessGenomeBrowser {
|
|
|
10201
10481
|
background: options === null || options === void 0 ? void 0 : options.background,
|
|
10202
10482
|
theme: this.theme,
|
|
10203
10483
|
canvasProvider: this.canvasProvider,
|
|
10204
|
-
workerProvider: this.workerProvider,
|
|
10205
10484
|
name: options === null || options === void 0 ? void 0 : options.name,
|
|
10206
10485
|
});
|
|
10207
10486
|
const dataSource = new MemoryDataSource(features);
|
|
@@ -10214,7 +10493,7 @@ class HeadlessGenomeBrowser {
|
|
|
10214
10493
|
const dataSourceConfig = { type: 'memory' };
|
|
10215
10494
|
this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
|
|
10216
10495
|
if (options === null || options === void 0 ? void 0 : options.metadata)
|
|
10217
|
-
this.
|
|
10496
|
+
this.findMT(track).metadata = options.metadata;
|
|
10218
10497
|
return track;
|
|
10219
10498
|
}
|
|
10220
10499
|
/** Add a DNA/RNA sequence track. Data fetching is handled automatically via the genome's sequence provider. */
|
|
@@ -10238,6 +10517,11 @@ class HeadlessGenomeBrowser {
|
|
|
10238
10517
|
}
|
|
10239
10518
|
/** Clean up event listeners, abort in-flight requests, clear tracks and ROIs. */
|
|
10240
10519
|
dispose() {
|
|
10520
|
+
this._disposed = true;
|
|
10521
|
+
if (this.loadDebounceTimer !== null) {
|
|
10522
|
+
clearTimeout(this.loadDebounceTimer);
|
|
10523
|
+
this.loadDebounceTimer = null;
|
|
10524
|
+
}
|
|
10241
10525
|
this.events.removeAllListeners();
|
|
10242
10526
|
if (this.popupProvider)
|
|
10243
10527
|
this.popupProvider.dispose();
|
|
@@ -10246,6 +10530,10 @@ class HeadlessGenomeBrowser {
|
|
|
10246
10530
|
for (const mt of this.managedTracks) {
|
|
10247
10531
|
if (mt.abortController)
|
|
10248
10532
|
mt.abortController.abort();
|
|
10533
|
+
// Destroy worker-resident data sources
|
|
10534
|
+
if (mt.dataSource instanceof WorkerDataSource && this.dataSourceWorkerProvider) {
|
|
10535
|
+
this.dataSourceWorkerProvider.destroy(mt.dataSource.instanceId);
|
|
10536
|
+
}
|
|
10249
10537
|
}
|
|
10250
10538
|
this.managedTracks = [];
|
|
10251
10539
|
this.roiSets = [];
|
|
@@ -10273,6 +10561,30 @@ class HeadlessGenomeBrowser {
|
|
|
10273
10561
|
};
|
|
10274
10562
|
}
|
|
10275
10563
|
// ─── Data lifecycle ──────────────────────────────────────────────────────
|
|
10564
|
+
/**
|
|
10565
|
+
* Debounced wrapper around loadAllTracksIfNeeded.
|
|
10566
|
+
* During rapid zoom/pan, this coalesces multiple setLocus() calls into a
|
|
10567
|
+
* single data fetch cycle after the interaction settles (100ms quiet period).
|
|
10568
|
+
* Abort in-flight requests immediately so they don't race with the eventual fetch.
|
|
10569
|
+
*/
|
|
10570
|
+
debouncedLoad() {
|
|
10571
|
+
// Immediately abort stale in-flight requests so cancelled fetches
|
|
10572
|
+
// don't resolve after the debounce fires with a new locus.
|
|
10573
|
+
for (const mt of this.managedTracks) {
|
|
10574
|
+
if (mt.abortController) {
|
|
10575
|
+
mt.abortController.abort();
|
|
10576
|
+
mt.abortController = null;
|
|
10577
|
+
}
|
|
10578
|
+
}
|
|
10579
|
+
this.inflightFetches.clear();
|
|
10580
|
+
if (this.loadDebounceTimer !== null) {
|
|
10581
|
+
clearTimeout(this.loadDebounceTimer);
|
|
10582
|
+
}
|
|
10583
|
+
this.loadDebounceTimer = setTimeout(() => {
|
|
10584
|
+
this.loadDebounceTimer = null;
|
|
10585
|
+
this.loadAllTracksIfNeeded();
|
|
10586
|
+
}, 100);
|
|
10587
|
+
}
|
|
10276
10588
|
loadAllTracksIfNeeded() {
|
|
10277
10589
|
// Clear stale inflight entries — navigation invalidates all pending fetches
|
|
10278
10590
|
this.inflightFetches.clear();
|
|
@@ -10310,9 +10622,11 @@ class HeadlessGenomeBrowser {
|
|
|
10310
10622
|
if (mt.cache && cacheCoversViewport(mt.cache, this._locus, bpPerPixel)) {
|
|
10311
10623
|
return;
|
|
10312
10624
|
}
|
|
10313
|
-
// Cancel stale in-flight request for this track
|
|
10625
|
+
// Cancel stale in-flight request for this track and clear any
|
|
10626
|
+
// lingering error so it doesn't persist through the new fetch.
|
|
10314
10627
|
if (mt.abortController)
|
|
10315
10628
|
mt.abortController.abort();
|
|
10629
|
+
mt.track.setError(null);
|
|
10316
10630
|
const fetchRegion = bufferLocus(this._locus);
|
|
10317
10631
|
const dsKey = mt.dataSourceConfig ? dataSourceCacheKey(mt.dataSourceConfig) : null;
|
|
10318
10632
|
// Check if another track with the same data source already has a valid cache
|
|
@@ -10351,12 +10665,16 @@ class HeadlessGenomeBrowser {
|
|
|
10351
10665
|
this.events.emit(BrowserEvent.DataLoaded, { track: mt.track });
|
|
10352
10666
|
})
|
|
10353
10667
|
.catch(err => {
|
|
10354
|
-
|
|
10355
|
-
if (err instanceof DOMException && err.name === 'AbortError')
|
|
10668
|
+
if (this._disposed || isAbortError(err))
|
|
10356
10669
|
return;
|
|
10357
10670
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
10358
|
-
|
|
10359
|
-
|
|
10671
|
+
if (mt.cache) {
|
|
10672
|
+
console.warn('[loom] Data fetch failed (dedup), showing cached data:', error.message);
|
|
10673
|
+
}
|
|
10674
|
+
else {
|
|
10675
|
+
console.error('[loom] Data fetch error (dedup):', error);
|
|
10676
|
+
mt.track.setError(error);
|
|
10677
|
+
}
|
|
10360
10678
|
this.events.emit(BrowserEvent.DataError, { track: mt.track, error });
|
|
10361
10679
|
});
|
|
10362
10680
|
return;
|
|
@@ -10383,12 +10701,20 @@ class HeadlessGenomeBrowser {
|
|
|
10383
10701
|
this.events.emit(BrowserEvent.DataLoaded, { track: mt.track });
|
|
10384
10702
|
})
|
|
10385
10703
|
.catch(err => {
|
|
10386
|
-
if (
|
|
10387
|
-
|
|
10704
|
+
if (this._disposed || controller.signal.aborted || isAbortError(err))
|
|
10705
|
+
return;
|
|
10706
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
10707
|
+
// If we have cached data, keep showing it (stale) rather than
|
|
10708
|
+
// replacing it with an error message. Only show error if there's
|
|
10709
|
+
// nothing to display at all.
|
|
10710
|
+
if (mt.cache) {
|
|
10711
|
+
console.warn('[loom] Data fetch failed, showing cached data:', error.message);
|
|
10712
|
+
}
|
|
10713
|
+
else {
|
|
10388
10714
|
console.error('[loom] Data fetch error:', error);
|
|
10389
10715
|
mt.track.setError(error);
|
|
10390
|
-
this.events.emit(BrowserEvent.DataError, { track: mt.track, error });
|
|
10391
10716
|
}
|
|
10717
|
+
this.events.emit(BrowserEvent.DataError, { track: mt.track, error });
|
|
10392
10718
|
})
|
|
10393
10719
|
.finally(() => {
|
|
10394
10720
|
var _a;
|
|
@@ -10400,6 +10726,228 @@ class HeadlessGenomeBrowser {
|
|
|
10400
10726
|
}
|
|
10401
10727
|
}
|
|
10402
10728
|
|
|
10729
|
+
/**
|
|
10730
|
+
* WebWorkerPool — single worker pool for both stateless tasks and stateful data sources.
|
|
10731
|
+
*
|
|
10732
|
+
* Implements both WorkerProvider (for CPU-intensive tasks like pack, summarize) and
|
|
10733
|
+
* DataSourceWorkerProvider (for persistent DataSource instances in workers).
|
|
10734
|
+
*
|
|
10735
|
+
* Routing:
|
|
10736
|
+
* - Stateless tasks: round-robin across pool
|
|
10737
|
+
* - DataSource operations: sticky routing via URL hash (preserves reader caches)
|
|
10738
|
+
*
|
|
10739
|
+
* Usage:
|
|
10740
|
+
* const pool = new WebWorkerPool({
|
|
10741
|
+
* workerFactory: () => new Worker(new URL('./workerPoolScript.ts', import.meta.url)),
|
|
10742
|
+
* poolSize: 4,
|
|
10743
|
+
* })
|
|
10744
|
+
* const browser = new GenomeBrowser(container, { workerProvider: pool })
|
|
10745
|
+
* // browser auto-detects DataSourceWorkerProvider support
|
|
10746
|
+
* pool.dispose()
|
|
10747
|
+
*/
|
|
10748
|
+
// ─── Provider ────────────────────────────────────────────────────────────────
|
|
10749
|
+
class WebWorkerPool {
|
|
10750
|
+
constructor(options) {
|
|
10751
|
+
var _a;
|
|
10752
|
+
// Stateless task tracking
|
|
10753
|
+
this.nextId = 0;
|
|
10754
|
+
this.nextWorker = 0;
|
|
10755
|
+
this.pending = new Map();
|
|
10756
|
+
// Stateful data source tracking
|
|
10757
|
+
this.instanceToWorker = new Map();
|
|
10758
|
+
this.pendingFetches = new Map();
|
|
10759
|
+
this.nextFetchId = 0;
|
|
10760
|
+
const count = Math.max(1, (_a = options.poolSize) !== null && _a !== void 0 ? _a : 1);
|
|
10761
|
+
let createWorker;
|
|
10762
|
+
if (options.workerFactory) {
|
|
10763
|
+
createWorker = options.workerFactory;
|
|
10764
|
+
}
|
|
10765
|
+
else if (options.workerUrl) {
|
|
10766
|
+
const url = options.workerUrl;
|
|
10767
|
+
createWorker = () => new Worker(url, { type: 'module' });
|
|
10768
|
+
}
|
|
10769
|
+
else {
|
|
10770
|
+
throw new Error('WebWorkerPoolOptions requires either workerUrl or workerFactory');
|
|
10771
|
+
}
|
|
10772
|
+
this.workers = [];
|
|
10773
|
+
this.readyPromises = [];
|
|
10774
|
+
for (let i = 0; i < count; i++) {
|
|
10775
|
+
const worker = createWorker();
|
|
10776
|
+
// Ready handshake — rejects on worker error so awaiting fetches
|
|
10777
|
+
// don't hang forever if the worker script fails to load.
|
|
10778
|
+
let rejectReady;
|
|
10779
|
+
const readyPromise = new Promise((resolveReady, rej) => {
|
|
10780
|
+
rejectReady = rej;
|
|
10781
|
+
const onReady = (e) => {
|
|
10782
|
+
if (e.data.type === 'ready') {
|
|
10783
|
+
resolveReady();
|
|
10784
|
+
}
|
|
10785
|
+
};
|
|
10786
|
+
worker.addEventListener('message', onReady, { once: true });
|
|
10787
|
+
});
|
|
10788
|
+
// Prevent unhandled rejection warnings if worker fails before any fetch
|
|
10789
|
+
readyPromise.catch(() => { });
|
|
10790
|
+
this.readyPromises.push(readyPromise);
|
|
10791
|
+
worker.onmessage = (e) => {
|
|
10792
|
+
const msg = e.data;
|
|
10793
|
+
if (msg.type === 'ready')
|
|
10794
|
+
return; // handled by one-time listener
|
|
10795
|
+
if (msg.type === 'taskResult') {
|
|
10796
|
+
const p = this.pending.get(msg.id);
|
|
10797
|
+
if (p) {
|
|
10798
|
+
this.pending.delete(msg.id);
|
|
10799
|
+
if (msg.error) {
|
|
10800
|
+
p.reject(new Error(msg.error));
|
|
10801
|
+
}
|
|
10802
|
+
else {
|
|
10803
|
+
p.resolve(msg.result);
|
|
10804
|
+
}
|
|
10805
|
+
}
|
|
10806
|
+
}
|
|
10807
|
+
else if (msg.type === 'fetchResult') {
|
|
10808
|
+
const p = this.pendingFetches.get(msg.fetchId);
|
|
10809
|
+
if (p) {
|
|
10810
|
+
this.pendingFetches.delete(msg.fetchId);
|
|
10811
|
+
p.resolve(msg.features);
|
|
10812
|
+
}
|
|
10813
|
+
}
|
|
10814
|
+
else if (msg.type === 'fetchError') {
|
|
10815
|
+
const p = this.pendingFetches.get(msg.fetchId);
|
|
10816
|
+
if (p) {
|
|
10817
|
+
this.pendingFetches.delete(msg.fetchId);
|
|
10818
|
+
if (msg.error === 'AbortError') {
|
|
10819
|
+
p.reject(new DOMException('Aborted', 'AbortError'));
|
|
10820
|
+
}
|
|
10821
|
+
else {
|
|
10822
|
+
p.reject(new Error(msg.error));
|
|
10823
|
+
}
|
|
10824
|
+
}
|
|
10825
|
+
}
|
|
10826
|
+
};
|
|
10827
|
+
worker.onerror = (e) => {
|
|
10828
|
+
const error = new Error(`Worker error: ${e.message}`);
|
|
10829
|
+
rejectReady(error);
|
|
10830
|
+
for (const { reject } of this.pending.values())
|
|
10831
|
+
reject(error);
|
|
10832
|
+
this.pending.clear();
|
|
10833
|
+
for (const { reject } of this.pendingFetches.values())
|
|
10834
|
+
reject(error);
|
|
10835
|
+
this.pendingFetches.clear();
|
|
10836
|
+
};
|
|
10837
|
+
this.workers.push(worker);
|
|
10838
|
+
}
|
|
10839
|
+
}
|
|
10840
|
+
/** Number of workers in the pool. */
|
|
10841
|
+
get poolSize() {
|
|
10842
|
+
return this.workers.length;
|
|
10843
|
+
}
|
|
10844
|
+
// ─── WorkerProvider (stateless tasks) ────────────────────────────────────
|
|
10845
|
+
execute(task, transfer) {
|
|
10846
|
+
const id = this.nextId++;
|
|
10847
|
+
const worker = this.workers[this.nextWorker % this.workers.length];
|
|
10848
|
+
this.nextWorker++;
|
|
10849
|
+
return new Promise((resolve, reject) => {
|
|
10850
|
+
this.pending.set(id, {
|
|
10851
|
+
resolve: resolve,
|
|
10852
|
+
reject,
|
|
10853
|
+
});
|
|
10854
|
+
worker.postMessage({ type: 'task', id, task }, transfer !== null && transfer !== void 0 ? transfer : []);
|
|
10855
|
+
});
|
|
10856
|
+
}
|
|
10857
|
+
// ─── DataSourceWorkerProvider (stateful data sources) ────────────────────
|
|
10858
|
+
create(instanceId, config) {
|
|
10859
|
+
const workerIdx = this.routeToWorker(config);
|
|
10860
|
+
this.instanceToWorker.set(instanceId, workerIdx);
|
|
10861
|
+
const worker = this.workers[workerIdx];
|
|
10862
|
+
this.readyPromises[workerIdx].then(() => {
|
|
10863
|
+
worker.postMessage({ type: 'create', instanceId, config });
|
|
10864
|
+
});
|
|
10865
|
+
}
|
|
10866
|
+
async fetch(instanceId, locus, bpPerPixel, signal) {
|
|
10867
|
+
const workerIdx = this.instanceToWorker.get(instanceId);
|
|
10868
|
+
if (workerIdx === undefined) {
|
|
10869
|
+
throw new Error(`No worker assigned for DataSource: ${instanceId}`);
|
|
10870
|
+
}
|
|
10871
|
+
await this.readyPromises[workerIdx];
|
|
10872
|
+
const fetchId = this.nextFetchId++;
|
|
10873
|
+
const worker = this.workers[workerIdx];
|
|
10874
|
+
const onAbort = () => {
|
|
10875
|
+
worker.postMessage({ type: 'cancel', fetchId });
|
|
10876
|
+
const p = this.pendingFetches.get(fetchId);
|
|
10877
|
+
if (p) {
|
|
10878
|
+
this.pendingFetches.delete(fetchId);
|
|
10879
|
+
p.reject(new DOMException('Aborted', 'AbortError'));
|
|
10880
|
+
}
|
|
10881
|
+
};
|
|
10882
|
+
if (signal.aborted) {
|
|
10883
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
10884
|
+
}
|
|
10885
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
10886
|
+
try {
|
|
10887
|
+
return await new Promise((resolve, reject) => {
|
|
10888
|
+
this.pendingFetches.set(fetchId, {
|
|
10889
|
+
resolve: resolve,
|
|
10890
|
+
reject,
|
|
10891
|
+
});
|
|
10892
|
+
worker.postMessage({ type: 'fetch', instanceId, fetchId, locus, bpPerPixel });
|
|
10893
|
+
});
|
|
10894
|
+
}
|
|
10895
|
+
finally {
|
|
10896
|
+
signal.removeEventListener('abort', onAbort);
|
|
10897
|
+
}
|
|
10898
|
+
}
|
|
10899
|
+
configure(instanceId, method, ...args) {
|
|
10900
|
+
const workerIdx = this.instanceToWorker.get(instanceId);
|
|
10901
|
+
if (workerIdx === undefined)
|
|
10902
|
+
return;
|
|
10903
|
+
const worker = this.workers[workerIdx];
|
|
10904
|
+
this.readyPromises[workerIdx].then(() => {
|
|
10905
|
+
worker.postMessage({ type: 'configure', instanceId, method, args });
|
|
10906
|
+
});
|
|
10907
|
+
}
|
|
10908
|
+
destroy(instanceId) {
|
|
10909
|
+
const workerIdx = this.instanceToWorker.get(instanceId);
|
|
10910
|
+
if (workerIdx === undefined)
|
|
10911
|
+
return;
|
|
10912
|
+
const worker = this.workers[workerIdx];
|
|
10913
|
+
worker.postMessage({ type: 'destroy', instanceId });
|
|
10914
|
+
this.instanceToWorker.delete(instanceId);
|
|
10915
|
+
}
|
|
10916
|
+
// ─── Shared ──────────────────────────────────────────────────────────────
|
|
10917
|
+
dispose() {
|
|
10918
|
+
for (const worker of this.workers) {
|
|
10919
|
+
worker.terminate();
|
|
10920
|
+
}
|
|
10921
|
+
for (const { reject } of this.pending.values()) {
|
|
10922
|
+
reject(new Error('Worker terminated'));
|
|
10923
|
+
}
|
|
10924
|
+
this.pending.clear();
|
|
10925
|
+
for (const { reject } of this.pendingFetches.values()) {
|
|
10926
|
+
reject(new Error('Worker terminated'));
|
|
10927
|
+
}
|
|
10928
|
+
this.pendingFetches.clear();
|
|
10929
|
+
this.instanceToWorker.clear();
|
|
10930
|
+
this.workers = [];
|
|
10931
|
+
}
|
|
10932
|
+
// ─── Internal ────────────────────────────────────────────────────────────
|
|
10933
|
+
/**
|
|
10934
|
+
* Sticky routing: deterministically assign a DataSourceConfig to a worker
|
|
10935
|
+
* based on its URL. Preserves reader caches (BigWig headers, Tabix indices).
|
|
10936
|
+
*/
|
|
10937
|
+
routeToWorker(config) {
|
|
10938
|
+
const key = 'url' in config ? config.url : config.type;
|
|
10939
|
+
return Math.abs(hashString(key)) % this.workers.length;
|
|
10940
|
+
}
|
|
10941
|
+
}
|
|
10942
|
+
/** Simple string hash (djb2). */
|
|
10943
|
+
function hashString(s) {
|
|
10944
|
+
let hash = 5381;
|
|
10945
|
+
for (let i = 0; i < s.length; i++) {
|
|
10946
|
+
hash = ((hash << 5) + hash + s.charCodeAt(i)) | 0;
|
|
10947
|
+
}
|
|
10948
|
+
return hash;
|
|
10949
|
+
}
|
|
10950
|
+
|
|
10403
10951
|
/**
|
|
10404
10952
|
* CommandDispatcher — transport-agnostic command dispatch for HeadlessGenomeBrowser.
|
|
10405
10953
|
*
|
|
@@ -10870,9 +11418,8 @@ class RemoteConnection {
|
|
|
10870
11418
|
* These are pure canvas renderers — they take a 2D context, axis info, and
|
|
10871
11419
|
* dimensions, and paint the axis. No DOM, no state, no side effects.
|
|
10872
11420
|
*
|
|
10873
|
-
*
|
|
10874
|
-
*
|
|
10875
|
-
* - renderLabelAxis: centered rotated text label for annotation tracks (gene)
|
|
11421
|
+
* renderQuantitativeAxis: background, color strip, track label, and
|
|
11422
|
+
* tick marks + data range labels (when data is available) for numeric tracks.
|
|
10876
11423
|
*/
|
|
10877
11424
|
/** Width of the color strip indicator on the right edge of the axis. */
|
|
10878
11425
|
const COLOR_STRIP_WIDTH = 4;
|
|
@@ -10891,13 +11438,10 @@ function prettyPrintNumber(n) {
|
|
|
10891
11438
|
return n.toExponential(1);
|
|
10892
11439
|
}
|
|
10893
11440
|
/**
|
|
10894
|
-
* Paint a quantitative axis
|
|
11441
|
+
* Paint a quantitative axis.
|
|
10895
11442
|
*
|
|
10896
|
-
*
|
|
10897
|
-
*
|
|
10898
|
-
* - Color strip on right edge (if info.color is set)
|
|
10899
|
-
* - Vertical axis line with top/bottom ticks and labels (max/min)
|
|
10900
|
-
* - Middle tick at midpoint (if height > 60)
|
|
11443
|
+
* Always renders: background and color strip (if info.color).
|
|
11444
|
+
* When `info.dataRange` is set, also renders: vertical axis line, tick marks, and min/max/mid labels.
|
|
10901
11445
|
*/
|
|
10902
11446
|
/** Transform a data value to log space, matching wigRenderer's computeYPixel logic. */
|
|
10903
11447
|
function toLogValue(v) {
|
|
@@ -10921,19 +11465,10 @@ function valueToY(value, min, max, topY, bottomY, flip, logScale) {
|
|
|
10921
11465
|
}
|
|
10922
11466
|
function renderQuantitativeAxis(ctx, info, width, height) {
|
|
10923
11467
|
var _a, _b, _c, _d;
|
|
10924
|
-
if (
|
|
11468
|
+
if (height === 0)
|
|
10925
11469
|
return;
|
|
10926
|
-
const
|
|
10927
|
-
const
|
|
10928
|
-
const logScale = (_b = info.logScale) !== null && _b !== void 0 ? _b : false;
|
|
10929
|
-
const shim = 0.01;
|
|
10930
|
-
const topY = shim * height;
|
|
10931
|
-
const bottomY = (1.0 - shim) * height;
|
|
10932
|
-
// When flipped, min is at top and max is at bottom (used by GWAS/QTL tracks)
|
|
10933
|
-
const topValue = flip ? min : max;
|
|
10934
|
-
const bottomValue = flip ? max : min;
|
|
10935
|
-
const bg = (_c = info.backgroundColor) !== null && _c !== void 0 ? _c : 'white';
|
|
10936
|
-
const fg = (_d = info.labelColor) !== null && _d !== void 0 ? _d : 'black';
|
|
11470
|
+
const bg = (_a = info.backgroundColor) !== null && _a !== void 0 ? _a : 'white';
|
|
11471
|
+
const fg = (_b = info.labelColor) !== null && _b !== void 0 ? _b : 'black';
|
|
10937
11472
|
// Clear with background
|
|
10938
11473
|
ctx.fillStyle = bg;
|
|
10939
11474
|
ctx.fillRect(0, 0, width, height);
|
|
@@ -10942,6 +11477,9 @@ function renderQuantitativeAxis(ctx, info, width, height) {
|
|
|
10942
11477
|
ctx.fillStyle = info.color;
|
|
10943
11478
|
ctx.fillRect(width - COLOR_STRIP_WIDTH - 1, 0, COLOR_STRIP_WIDTH, height);
|
|
10944
11479
|
}
|
|
11480
|
+
const shim = 0.01;
|
|
11481
|
+
const topY = shim * height;
|
|
11482
|
+
const bottomY = (1.0 - shim) * height;
|
|
10945
11483
|
const tickEnd = width - COLOR_STRIP_WIDTH - 3;
|
|
10946
11484
|
const tickStart = tickEnd - 6;
|
|
10947
11485
|
ctx.strokeStyle = fg;
|
|
@@ -10949,30 +11487,37 @@ function renderQuantitativeAxis(ctx, info, width, height) {
|
|
|
10949
11487
|
ctx.font = 'normal 9px Arial';
|
|
10950
11488
|
ctx.textAlign = 'right';
|
|
10951
11489
|
ctx.lineWidth = 1;
|
|
10952
|
-
// Vertical axis line
|
|
11490
|
+
// Vertical axis line + top/bottom ticks (always drawn)
|
|
10953
11491
|
ctx.beginPath();
|
|
10954
11492
|
ctx.moveTo(tickEnd, topY);
|
|
10955
11493
|
ctx.lineTo(tickEnd, bottomY);
|
|
10956
11494
|
ctx.stroke();
|
|
10957
|
-
// Top tick + label
|
|
10958
11495
|
ctx.beginPath();
|
|
10959
11496
|
ctx.moveTo(tickStart, topY);
|
|
10960
11497
|
ctx.lineTo(tickEnd, topY);
|
|
10961
11498
|
ctx.stroke();
|
|
10962
|
-
ctx.textBaseline = 'top';
|
|
10963
|
-
ctx.fillText(prettyPrintNumber(topValue), tickStart - 2, topY + 1);
|
|
10964
|
-
// Bottom tick + label
|
|
10965
11499
|
ctx.beginPath();
|
|
10966
11500
|
ctx.moveTo(tickStart, bottomY);
|
|
10967
11501
|
ctx.lineTo(tickEnd, bottomY);
|
|
10968
11502
|
ctx.stroke();
|
|
11503
|
+
// Data range labels — only when we have data
|
|
11504
|
+
if (!info.dataRange)
|
|
11505
|
+
return;
|
|
11506
|
+
const { min, max } = info.dataRange;
|
|
11507
|
+
const flip = (_c = info.flipAxis) !== null && _c !== void 0 ? _c : false;
|
|
11508
|
+
const logScale = (_d = info.logScale) !== null && _d !== void 0 ? _d : false;
|
|
11509
|
+
const topValue = flip ? min : max;
|
|
11510
|
+
const bottomValue = flip ? max : min;
|
|
11511
|
+
// Top label
|
|
11512
|
+
ctx.textBaseline = 'top';
|
|
11513
|
+
ctx.fillText(prettyPrintNumber(topValue), tickStart - 2, topY + 1);
|
|
11514
|
+
// Bottom label
|
|
10969
11515
|
ctx.textBaseline = 'bottom';
|
|
10970
11516
|
ctx.fillText(prettyPrintNumber(bottomValue), tickStart - 2, bottomY - 1);
|
|
10971
|
-
// Middle tick
|
|
11517
|
+
// Middle tick + label
|
|
10972
11518
|
if (height > 60) {
|
|
10973
11519
|
const midVal = logScale
|
|
10974
11520
|
? (() => {
|
|
10975
|
-
// Midpoint in log space, converted back to data space
|
|
10976
11521
|
const logMid = (toLogValue(min) + toLogValue(max)) / 2;
|
|
10977
11522
|
return logMid >= 0 ? Math.pow(10, logMid) - 1 : -(Math.pow(10, -logMid) - 1);
|
|
10978
11523
|
})()
|
|
@@ -10986,31 +11531,6 @@ function renderQuantitativeAxis(ctx, info, width, height) {
|
|
|
10986
11531
|
ctx.fillText(prettyPrintNumber(midVal), tickStart + 1, midY);
|
|
10987
11532
|
}
|
|
10988
11533
|
}
|
|
10989
|
-
/**
|
|
10990
|
-
* Paint a label-only axis (e.g., gene track name).
|
|
10991
|
-
* Renders a vertically-rotated centered text label.
|
|
10992
|
-
*/
|
|
10993
|
-
function renderLabelAxis(ctx, info, width, height) {
|
|
10994
|
-
var _a, _b;
|
|
10995
|
-
if (!info.label || height === 0)
|
|
10996
|
-
return;
|
|
10997
|
-
const bg = (_a = info.backgroundColor) !== null && _a !== void 0 ? _a : 'white';
|
|
10998
|
-
const fg = (_b = info.labelColor) !== null && _b !== void 0 ? _b : '#333';
|
|
10999
|
-
// Clear with background
|
|
11000
|
-
ctx.fillStyle = bg;
|
|
11001
|
-
ctx.fillRect(0, 0, width, height);
|
|
11002
|
-
ctx.font = '10px sans-serif';
|
|
11003
|
-
ctx.fillStyle = fg;
|
|
11004
|
-
ctx.textAlign = 'center';
|
|
11005
|
-
ctx.textBaseline = 'middle';
|
|
11006
|
-
ctx.save();
|
|
11007
|
-
ctx.translate(width / 2, height / 2);
|
|
11008
|
-
ctx.rotate(-Math.PI / 2);
|
|
11009
|
-
// Clip text to available height
|
|
11010
|
-
const maxTextWidth = height - 10;
|
|
11011
|
-
ctx.fillText(info.label, 0, 0, maxTextWidth);
|
|
11012
|
-
ctx.restore();
|
|
11013
|
-
}
|
|
11014
11534
|
|
|
11015
11535
|
/**
|
|
11016
11536
|
* Common context menu item factories.
|
|
@@ -11145,6 +11665,157 @@ function roiContextMenuItems(roi, callbacks) {
|
|
|
11145
11665
|
];
|
|
11146
11666
|
}
|
|
11147
11667
|
|
|
11668
|
+
/**
|
|
11669
|
+
* SVG overlay for interactive feature highlighting.
|
|
11670
|
+
*
|
|
11671
|
+
* Positions transparent SVG `<rect>` elements on top of a track's canvas,
|
|
11672
|
+
* providing native CSS `:hover` states and click targets without canvas repaints.
|
|
11673
|
+
* Follows the same DOM-over-canvas pattern used by ROI overlays in GenomeBrowser.
|
|
11674
|
+
*
|
|
11675
|
+
* On hover, features get a tinted fill matching their own color plus a subtle
|
|
11676
|
+
* glow/shadow effect, making the canvas-rendered feature feel highlighted.
|
|
11677
|
+
*/
|
|
11678
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
11679
|
+
/** Default glow color when feature has no color. */
|
|
11680
|
+
const DEFAULT_GLOW_COLOR = 'rgb(0, 0, 150)';
|
|
11681
|
+
/** Padding (px) added around the feature rect for the glow area. */
|
|
11682
|
+
const GLOW_PADDING = 3;
|
|
11683
|
+
class SVGFeatureOverlay {
|
|
11684
|
+
constructor(container) {
|
|
11685
|
+
this.svg = document.createElementNS(SVG_NS, 'svg');
|
|
11686
|
+
this.svg.classList.add('loom-feature-overlay');
|
|
11687
|
+
this.svg.style.cssText = `
|
|
11688
|
+
position: absolute;
|
|
11689
|
+
top: 0;
|
|
11690
|
+
left: 0;
|
|
11691
|
+
width: 100%;
|
|
11692
|
+
height: 100%;
|
|
11693
|
+
pointer-events: none;
|
|
11694
|
+
overflow: visible;
|
|
11695
|
+
`;
|
|
11696
|
+
this.defs = document.createElementNS(SVG_NS, 'defs');
|
|
11697
|
+
this.svg.appendChild(this.defs);
|
|
11698
|
+
this.group = document.createElementNS(SVG_NS, 'g');
|
|
11699
|
+
this.svg.appendChild(this.group);
|
|
11700
|
+
container.style.position = 'relative';
|
|
11701
|
+
container.appendChild(this.svg);
|
|
11702
|
+
}
|
|
11703
|
+
/** Rebuild SVG rects from feature geometry. */
|
|
11704
|
+
update(rects) {
|
|
11705
|
+
var _a;
|
|
11706
|
+
this.group.textContent = '';
|
|
11707
|
+
this.defs.textContent = '';
|
|
11708
|
+
if (rects.length === 0 || rects.length > 500)
|
|
11709
|
+
return;
|
|
11710
|
+
const frag = document.createDocumentFragment();
|
|
11711
|
+
for (let i = 0; i < rects.length; i++) {
|
|
11712
|
+
const rect = rects[i];
|
|
11713
|
+
const color = (_a = rect.color) !== null && _a !== void 0 ? _a : DEFAULT_GLOW_COLOR;
|
|
11714
|
+
const filterId = `glow-${i}`;
|
|
11715
|
+
this.defs.appendChild(this.createGlowFilter(filterId, color));
|
|
11716
|
+
frag.appendChild(this.createRect(rect, filterId));
|
|
11717
|
+
}
|
|
11718
|
+
this.group.appendChild(frag);
|
|
11719
|
+
}
|
|
11720
|
+
/** Suppress pointer events on overlay rects (e.g., during drag). */
|
|
11721
|
+
setSuppressed(suppressed) {
|
|
11722
|
+
this.group.style.pointerEvents = suppressed ? 'none' : '';
|
|
11723
|
+
}
|
|
11724
|
+
dispose() {
|
|
11725
|
+
this.svg.remove();
|
|
11726
|
+
}
|
|
11727
|
+
/** Create an SVG filter that produces a colored glow. */
|
|
11728
|
+
createGlowFilter(id, color) {
|
|
11729
|
+
const filter = document.createElementNS(SVG_NS, 'filter');
|
|
11730
|
+
filter.setAttribute('id', id);
|
|
11731
|
+
// Expand filter region to allow glow beyond rect bounds
|
|
11732
|
+
filter.setAttribute('x', '-20%');
|
|
11733
|
+
filter.setAttribute('y', '-40%');
|
|
11734
|
+
filter.setAttribute('width', '140%');
|
|
11735
|
+
filter.setAttribute('height', '180%');
|
|
11736
|
+
// Flood with feature color
|
|
11737
|
+
const flood = document.createElementNS(SVG_NS, 'feFlood');
|
|
11738
|
+
flood.setAttribute('flood-color', color);
|
|
11739
|
+
flood.setAttribute('flood-opacity', '0.4');
|
|
11740
|
+
flood.setAttribute('result', 'color');
|
|
11741
|
+
filter.appendChild(flood);
|
|
11742
|
+
// Clip flood to rect shape
|
|
11743
|
+
const composite = document.createElementNS(SVG_NS, 'feComposite');
|
|
11744
|
+
composite.setAttribute('in', 'color');
|
|
11745
|
+
composite.setAttribute('in2', 'SourceGraphic');
|
|
11746
|
+
composite.setAttribute('operator', 'in');
|
|
11747
|
+
composite.setAttribute('result', 'colored');
|
|
11748
|
+
filter.appendChild(composite);
|
|
11749
|
+
// Blur for glow
|
|
11750
|
+
const blur = document.createElementNS(SVG_NS, 'feGaussianBlur');
|
|
11751
|
+
blur.setAttribute('in', 'colored');
|
|
11752
|
+
blur.setAttribute('stdDeviation', '3');
|
|
11753
|
+
blur.setAttribute('result', 'glow');
|
|
11754
|
+
filter.appendChild(blur);
|
|
11755
|
+
// Layer: glow behind, then original rect on top
|
|
11756
|
+
const merge = document.createElementNS(SVG_NS, 'feMerge');
|
|
11757
|
+
const node1 = document.createElementNS(SVG_NS, 'feMergeNode');
|
|
11758
|
+
node1.setAttribute('in', 'glow');
|
|
11759
|
+
const node2 = document.createElementNS(SVG_NS, 'feMergeNode');
|
|
11760
|
+
node2.setAttribute('in', 'SourceGraphic');
|
|
11761
|
+
merge.appendChild(node1);
|
|
11762
|
+
merge.appendChild(node2);
|
|
11763
|
+
filter.appendChild(merge);
|
|
11764
|
+
return filter;
|
|
11765
|
+
}
|
|
11766
|
+
createRect(rect, filterId) {
|
|
11767
|
+
var _a;
|
|
11768
|
+
const el = document.createElementNS(SVG_NS, 'rect');
|
|
11769
|
+
// Expand the rect slightly so the hover target covers the full feature
|
|
11770
|
+
// and the glow extends naturally beyond.
|
|
11771
|
+
el.setAttribute('x', String(rect.x - GLOW_PADDING));
|
|
11772
|
+
el.setAttribute('y', String(rect.y - GLOW_PADDING));
|
|
11773
|
+
el.setAttribute('width', String(rect.width + GLOW_PADDING * 2));
|
|
11774
|
+
el.setAttribute('height', String(rect.height + GLOW_PADDING * 2));
|
|
11775
|
+
el.setAttribute('rx', '2');
|
|
11776
|
+
const filterRef = `url(#${filterId})`;
|
|
11777
|
+
const color = (_a = rect.color) !== null && _a !== void 0 ? _a : DEFAULT_GLOW_COLOR;
|
|
11778
|
+
el.addEventListener('mouseenter', () => {
|
|
11779
|
+
el.style.fill = toRGBA(color, 0.15);
|
|
11780
|
+
el.style.filter = filterRef;
|
|
11781
|
+
});
|
|
11782
|
+
el.addEventListener('mouseleave', () => {
|
|
11783
|
+
el.style.fill = 'transparent';
|
|
11784
|
+
el.style.filter = '';
|
|
11785
|
+
});
|
|
11786
|
+
el.addEventListener('click', (e) => {
|
|
11787
|
+
var _a;
|
|
11788
|
+
e.stopPropagation();
|
|
11789
|
+
(_a = this.onFeatureClick) === null || _a === void 0 ? void 0 : _a.call(this, rect, e);
|
|
11790
|
+
});
|
|
11791
|
+
return el;
|
|
11792
|
+
}
|
|
11793
|
+
}
|
|
11794
|
+
/**
|
|
11795
|
+
* Convert a CSS color string to rgba with the given alpha.
|
|
11796
|
+
* Handles `rgb(r, g, b)`, `rgba(r, g, b, a)`, and hex formats.
|
|
11797
|
+
*/
|
|
11798
|
+
function toRGBA(color, alpha) {
|
|
11799
|
+
// Already rgba — replace the alpha
|
|
11800
|
+
const rgbaMatch = color.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
|
|
11801
|
+
if (rgbaMatch) {
|
|
11802
|
+
return `rgba(${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}, ${alpha})`;
|
|
11803
|
+
}
|
|
11804
|
+
// Hex color
|
|
11805
|
+
const hexMatch = color.match(/^#([0-9a-f]{3,8})$/i);
|
|
11806
|
+
if (hexMatch) {
|
|
11807
|
+
let hex = hexMatch[1];
|
|
11808
|
+
if (hex.length === 3)
|
|
11809
|
+
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
11810
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
11811
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
11812
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
11813
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
11814
|
+
}
|
|
11815
|
+
// Fallback: can't parse, just return with opacity
|
|
11816
|
+
return color;
|
|
11817
|
+
}
|
|
11818
|
+
|
|
11148
11819
|
/**
|
|
11149
11820
|
* DOM-attached GenomeBrowser — extends HeadlessGenomeBrowser with pointer events,
|
|
11150
11821
|
* ResizeObserver, canvas stacking, and sweep-to-zoom.
|
|
@@ -11259,6 +11930,11 @@ function injectScrollStyles(container) {
|
|
|
11259
11930
|
style.textContent = `
|
|
11260
11931
|
.loom-track-scroll { scrollbar-width: none; }
|
|
11261
11932
|
.loom-track-scroll::-webkit-scrollbar { display: none; }
|
|
11933
|
+
.loom-feature-overlay rect {
|
|
11934
|
+
fill: transparent;
|
|
11935
|
+
pointer-events: auto;
|
|
11936
|
+
cursor: pointer;
|
|
11937
|
+
}
|
|
11262
11938
|
`;
|
|
11263
11939
|
if (root instanceof Document) {
|
|
11264
11940
|
root.head.appendChild(style);
|
|
@@ -11269,7 +11945,7 @@ function injectScrollStyles(container) {
|
|
|
11269
11945
|
}
|
|
11270
11946
|
class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
11271
11947
|
constructor(container, options) {
|
|
11272
|
-
var _a, _b, _c;
|
|
11948
|
+
var _a, _b, _c, _d;
|
|
11273
11949
|
// Default-on: create providers unless explicitly set to null.
|
|
11274
11950
|
const contextMenuProvider = options.contextMenuProvider === null
|
|
11275
11951
|
? undefined
|
|
@@ -11277,8 +11953,33 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11277
11953
|
const popupProvider = options.popupProvider === null
|
|
11278
11954
|
? undefined
|
|
11279
11955
|
: ((_b = options.popupProvider) !== null && _b !== void 0 ? _b : createDefaultPopupProvider());
|
|
11956
|
+
// Auto-create worker pool when `workers` or `workerFactory` is set
|
|
11957
|
+
// and no explicit workerProvider was passed.
|
|
11958
|
+
let workerProvider = options.workerProvider;
|
|
11959
|
+
let ownedProvider = null;
|
|
11960
|
+
if (!workerProvider && (options.workers || options.workerFactory)) {
|
|
11961
|
+
const poolSize = typeof options.workers === 'number'
|
|
11962
|
+
? options.workers
|
|
11963
|
+
: Math.min((_c = navigator === null || navigator === void 0 ? void 0 : navigator.hardwareConcurrency) !== null && _c !== void 0 ? _c : 4, 4);
|
|
11964
|
+
try {
|
|
11965
|
+
ownedProvider = new WebWorkerPool({
|
|
11966
|
+
workerFactory: options.workerFactory,
|
|
11967
|
+
// webpackIgnore prevents webpack from statically resolving this URL at build time.
|
|
11968
|
+
// At runtime in the dist bundle, import.meta.url → dist/loom.esm.js,
|
|
11969
|
+
// and loom-worker.js is a sibling file in dist/.
|
|
11970
|
+
workerUrl: options.workerFactory ? undefined : new URL(/* webpackIgnore: true */ './loom-worker.js', import.meta.url),
|
|
11971
|
+
poolSize,
|
|
11972
|
+
});
|
|
11973
|
+
workerProvider = ownedProvider;
|
|
11974
|
+
}
|
|
11975
|
+
catch (_f) {
|
|
11976
|
+
// Worker creation failed (CSP, bundler, etc.) — fall back to main thread
|
|
11977
|
+
console.warn('[loom] Failed to create web workers, falling back to main-thread execution');
|
|
11978
|
+
}
|
|
11979
|
+
}
|
|
11280
11980
|
super({
|
|
11281
11981
|
...options,
|
|
11982
|
+
workerProvider,
|
|
11282
11983
|
contextMenuProvider,
|
|
11283
11984
|
popupProvider,
|
|
11284
11985
|
viewportWidth: container.clientWidth,
|
|
@@ -11329,8 +12030,13 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11329
12030
|
// ROI overlay state
|
|
11330
12031
|
this.roiOverlayContainer = null;
|
|
11331
12032
|
this.roiElements = new Map();
|
|
12033
|
+
// Per-track SVG feature overlays for hover/click interactivity
|
|
12034
|
+
this.featureOverlays = new Map();
|
|
12035
|
+
/** Worker provider auto-created by `workers` option. Disposed on cleanup. */
|
|
12036
|
+
this.ownedWorkerProvider = null;
|
|
12037
|
+
this.ownedWorkerProvider = ownedProvider;
|
|
11332
12038
|
this.container = container;
|
|
11333
|
-
this.interactive = (
|
|
12039
|
+
this.interactive = (_d = options.interactive) !== null && _d !== void 0 ? _d : true;
|
|
11334
12040
|
injectScrollStyles(container);
|
|
11335
12041
|
container.style.userSelect = "none";
|
|
11336
12042
|
container.style.touchAction = "none";
|
|
@@ -11356,8 +12062,16 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11356
12062
|
document.addEventListener("mousedown", this.handleDocMouseDown);
|
|
11357
12063
|
}
|
|
11358
12064
|
}
|
|
11359
|
-
// Re-render tracks when container resizes (e.g. first layout in Shadow DOM)
|
|
11360
|
-
|
|
12065
|
+
// Re-render tracks when container resizes (e.g. first layout in Shadow DOM).
|
|
12066
|
+
// Also trigger data loads if width went from 0 → non-zero (common in React
|
|
12067
|
+
// flex layouts where the container hasn't laid out when GenomeBrowser is created).
|
|
12068
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
12069
|
+
const prevWidth = this._viewportWidth;
|
|
12070
|
+
this.render(); // updates _viewportWidth from container.clientWidth
|
|
12071
|
+
if (prevWidth === 0 && this._viewportWidth > 0) {
|
|
12072
|
+
this.loadAllTracksIfNeeded();
|
|
12073
|
+
}
|
|
12074
|
+
});
|
|
11361
12075
|
this.resizeObserver.observe(container);
|
|
11362
12076
|
// Axis content is updated in two places:
|
|
11363
12077
|
// 1. After super.render() in render() — handles resize, pan, initial load
|
|
@@ -11366,6 +12080,7 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11366
12080
|
// This may repaint the axis twice on initial load (negligible for a 50px canvas).
|
|
11367
12081
|
this.events.on(BrowserEvent.DataLoaded, ({ track }) => {
|
|
11368
12082
|
this.updateAxisContent(track);
|
|
12083
|
+
this.updateFeatureOverlay(track);
|
|
11369
12084
|
});
|
|
11370
12085
|
// Repaint axis canvases when theme changes (track canvases update via
|
|
11371
12086
|
// track.setTheme(), but axis sidebar is managed here in GenomeBrowser).
|
|
@@ -11380,13 +12095,11 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11380
12095
|
this.events.on(BrowserEvent.ROIChanged, () => this.renderROIOverlays());
|
|
11381
12096
|
this.events.on(BrowserEvent.LocusChange, () => this.renderROIOverlays());
|
|
11382
12097
|
}
|
|
11383
|
-
/**
|
|
11384
|
-
|
|
11385
|
-
const id = super.addTrack(track, dataSource, dataSourceConfig, maxTrackHeight);
|
|
12098
|
+
/** Build a DOM row for a track (axis column + viewport wrapper). */
|
|
12099
|
+
_buildTrackRow(track, maxTrackHeight) {
|
|
11386
12100
|
const canvas = track.canvas;
|
|
11387
12101
|
canvas.style.display = "block";
|
|
11388
12102
|
canvas.style.width = "100%";
|
|
11389
|
-
// Flex row: [axis (50px)] [viewport (flex: 1)]
|
|
11390
12103
|
const row = document.createElement("div");
|
|
11391
12104
|
row.style.display = "flex";
|
|
11392
12105
|
row.style.width = "100%";
|
|
@@ -11401,10 +12114,8 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11401
12114
|
const viewportWrapper = document.createElement("div");
|
|
11402
12115
|
viewportWrapper.style.flex = "1";
|
|
11403
12116
|
viewportWrapper.style.minWidth = "0";
|
|
11404
|
-
|
|
11405
|
-
|
|
11406
|
-
if (mt.maxTrackHeight != null) {
|
|
11407
|
-
viewportWrapper.style.maxHeight = `${mt.maxTrackHeight}px`;
|
|
12117
|
+
if (maxTrackHeight != null) {
|
|
12118
|
+
viewportWrapper.style.maxHeight = `${maxTrackHeight}px`;
|
|
11408
12119
|
viewportWrapper.style.overflowY = "auto";
|
|
11409
12120
|
viewportWrapper.style.overscrollBehaviorY = "none";
|
|
11410
12121
|
viewportWrapper.style.backgroundColor =
|
|
@@ -11414,15 +12125,33 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11414
12125
|
viewportWrapper.appendChild(canvas);
|
|
11415
12126
|
row.appendChild(axisDiv);
|
|
11416
12127
|
row.appendChild(viewportWrapper);
|
|
11417
|
-
|
|
11418
|
-
this.
|
|
11419
|
-
|
|
11420
|
-
|
|
11421
|
-
|
|
11422
|
-
|
|
11423
|
-
|
|
12128
|
+
// Create SVG feature overlay for hover/click interactivity
|
|
12129
|
+
if (this.interactive) {
|
|
12130
|
+
const overlay = new SVGFeatureOverlay(viewportWrapper);
|
|
12131
|
+
overlay.onFeatureClick = (rect, event) => {
|
|
12132
|
+
// Resolve interaction using the rect center, then show popup
|
|
12133
|
+
const cx = rect.x + rect.width / 2;
|
|
12134
|
+
const cy = rect.y + rect.height / 2;
|
|
12135
|
+
const interaction = this.resolveInteraction(track, cx, cy);
|
|
12136
|
+
if (interaction) {
|
|
12137
|
+
this.events.emit(BrowserEvent.TrackClick, interaction);
|
|
12138
|
+
if (this.popupProvider && interaction.popupData.length > 0) {
|
|
12139
|
+
const containerRect = this.container.getBoundingClientRect();
|
|
12140
|
+
const canvasRect = track.canvas.getBoundingClientRect();
|
|
12141
|
+
this.popupProvider.show(interaction.popupData, {
|
|
12142
|
+
x: canvasRect.left - containerRect.left + event.offsetX,
|
|
12143
|
+
y: canvasRect.top - containerRect.top + event.offsetY,
|
|
12144
|
+
}, this.container);
|
|
12145
|
+
}
|
|
12146
|
+
}
|
|
12147
|
+
};
|
|
12148
|
+
this.featureOverlays.set(track, overlay);
|
|
12149
|
+
}
|
|
12150
|
+
return { row, axisDiv, axisCanvas: null, viewportWrapper };
|
|
12151
|
+
}
|
|
12152
|
+
/** Post-registration UI setup for a track row (axis content, cursors, drag handlers). */
|
|
12153
|
+
_setupTrackRowUI(track) {
|
|
11424
12154
|
// Suppress on-canvas data range labels for wig tracks — the axis column handles it.
|
|
11425
|
-
// This keeps showDataRange=true as the default for standalone WigTrackCanvas users.
|
|
11426
12155
|
if (track.type === "wig") {
|
|
11427
12156
|
track.setConfig({
|
|
11428
12157
|
showDataRange: false,
|
|
@@ -11431,24 +12160,38 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11431
12160
|
this.updateAxisContent(track);
|
|
11432
12161
|
// Ruler tracks: crosshair for sweep-to-zoom, pointer in WG mode (click to navigate)
|
|
11433
12162
|
if (this.interactive && track.type === "ruler") {
|
|
11434
|
-
canvas.style.cursor = isWholeGenomeView(this._locus)
|
|
12163
|
+
track.canvas.style.cursor = isWholeGenomeView(this._locus)
|
|
11435
12164
|
? "pointer"
|
|
11436
12165
|
: "crosshair";
|
|
11437
12166
|
}
|
|
11438
12167
|
// Enable drag-to-reorder on the axis column for non-ruler tracks
|
|
11439
|
-
|
|
11440
|
-
|
|
12168
|
+
const entry = this.trackRows.get(track);
|
|
12169
|
+
if (this.interactive && track.type !== "ruler" && entry) {
|
|
12170
|
+
this.setupReorderHandlers(track, entry.axisDiv);
|
|
11441
12171
|
}
|
|
12172
|
+
}
|
|
12173
|
+
/** Add a track and attach its canvas to the container. */
|
|
12174
|
+
addTrack(track, dataSource, dataSourceConfig, maxTrackHeight, order) {
|
|
12175
|
+
// Build and register DOM row BEFORE super.addTrack() so that
|
|
12176
|
+
// syncDOMOrder() (called from sortTracks) can position it correctly.
|
|
12177
|
+
const trackRow = this._buildTrackRow(track, maxTrackHeight);
|
|
12178
|
+
this.container.appendChild(trackRow.row);
|
|
12179
|
+
this.trackRows.set(track, trackRow);
|
|
12180
|
+
const id = super.addTrack(track, dataSource, dataSourceConfig, maxTrackHeight, order);
|
|
12181
|
+
this._setupTrackRowUI(track);
|
|
11442
12182
|
return id;
|
|
11443
12183
|
}
|
|
11444
12184
|
/** Remove a track and its row from the container. */
|
|
11445
12185
|
removeTrack(trackOrId) {
|
|
12186
|
+
var _a;
|
|
11446
12187
|
const track = typeof trackOrId === 'string'
|
|
11447
12188
|
? this.getTrack(trackOrId)
|
|
11448
12189
|
: trackOrId;
|
|
11449
12190
|
if (!track)
|
|
11450
12191
|
return;
|
|
11451
12192
|
this.teardownReorderHandlers(track);
|
|
12193
|
+
(_a = this.featureOverlays.get(track)) === null || _a === void 0 ? void 0 : _a.dispose();
|
|
12194
|
+
this.featureOverlays.delete(track);
|
|
11452
12195
|
const entry = this.trackRows.get(track);
|
|
11453
12196
|
if (entry && entry.row.parentNode === this.container) {
|
|
11454
12197
|
this.container.removeChild(entry.row);
|
|
@@ -11503,6 +12246,8 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11503
12246
|
}
|
|
11504
12247
|
// Re-render ROI overlays
|
|
11505
12248
|
this.renderROIOverlays();
|
|
12249
|
+
// Update feature overlays
|
|
12250
|
+
this.updateFeatureOverlays();
|
|
11506
12251
|
}
|
|
11507
12252
|
/** Clean up event listeners, remove canvases, and dispose headless core. */
|
|
11508
12253
|
// ─── Remote connection ────────────────────────────────────────────────
|
|
@@ -11552,6 +12297,9 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11552
12297
|
}
|
|
11553
12298
|
this.removeSweepOverlay();
|
|
11554
12299
|
this.clearROIOverlay();
|
|
12300
|
+
for (const overlay of this.featureOverlays.values())
|
|
12301
|
+
overlay.dispose();
|
|
12302
|
+
this.featureOverlays.clear();
|
|
11555
12303
|
for (const mt of this.managedTracks) {
|
|
11556
12304
|
this.teardownReorderHandlers(mt.track);
|
|
11557
12305
|
const entry = this.trackRows.get(mt.track);
|
|
@@ -11562,6 +12310,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11562
12310
|
this.reorderHandlers.clear();
|
|
11563
12311
|
this.trackRows.clear();
|
|
11564
12312
|
super.dispose();
|
|
12313
|
+
if (this.ownedWorkerProvider) {
|
|
12314
|
+
this.ownedWorkerProvider.dispose();
|
|
12315
|
+
this.ownedWorkerProvider = null;
|
|
12316
|
+
}
|
|
11565
12317
|
}
|
|
11566
12318
|
/**
|
|
11567
12319
|
* Update the axis content for a track based on its getAxisInfo().
|
|
@@ -11596,12 +12348,7 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11596
12348
|
const visibleHeight = (mt === null || mt === void 0 ? void 0 : mt.maxTrackHeight) != null
|
|
11597
12349
|
? Math.min(track.height, mt.maxTrackHeight)
|
|
11598
12350
|
: track.height;
|
|
11599
|
-
|
|
11600
|
-
this.paintAxisCanvas(entry, info, visibleHeight, renderQuantitativeAxis);
|
|
11601
|
-
}
|
|
11602
|
-
else if (info.label) {
|
|
11603
|
-
this.paintAxisCanvas(entry, info, visibleHeight, renderLabelAxis);
|
|
11604
|
-
}
|
|
12351
|
+
this.paintAxisCanvas(entry, info, visibleHeight, renderQuantitativeAxis);
|
|
11605
12352
|
}
|
|
11606
12353
|
/**
|
|
11607
12354
|
* Prepare an axis canvas at the correct size with DPR scaling,
|
|
@@ -11714,6 +12461,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11714
12461
|
this.lastPointerX = e.clientX;
|
|
11715
12462
|
this.container.setPointerCapture(e.pointerId);
|
|
11716
12463
|
this.container.style.cursor = "grabbing";
|
|
12464
|
+
// Suppress feature overlay pointer events during drag
|
|
12465
|
+
for (const overlay of this.featureOverlays.values()) {
|
|
12466
|
+
overlay.setSuppressed(true);
|
|
12467
|
+
}
|
|
11717
12468
|
};
|
|
11718
12469
|
this.handlePointerMove = (e) => {
|
|
11719
12470
|
if (this.isSweeping && this.sweepOverlay && this.sweepRulerCanvas) {
|
|
@@ -11786,6 +12537,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11786
12537
|
const wasDragging = this.isDragging;
|
|
11787
12538
|
this.isDragging = false;
|
|
11788
12539
|
this.container.style.cursor = "grab";
|
|
12540
|
+
// Restore feature overlay pointer events
|
|
12541
|
+
for (const overlay of this.featureOverlays.values()) {
|
|
12542
|
+
overlay.setSuppressed(false);
|
|
12543
|
+
}
|
|
11789
12544
|
// Click detection: pointer didn't move more than threshold
|
|
11790
12545
|
const dx = e.clientX - this.pointerDownX;
|
|
11791
12546
|
const dy = e.clientY - this.pointerDownY;
|
|
@@ -11961,7 +12716,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
11961
12716
|
// Common items (set height, remove)
|
|
11962
12717
|
const callbacks = {
|
|
11963
12718
|
setTrackHeight: (t, h) => {
|
|
11964
|
-
if (t instanceof
|
|
12719
|
+
if (t instanceof AnnotationTrackCanvas) {
|
|
12720
|
+
t.setFixedHeight(h);
|
|
12721
|
+
}
|
|
12722
|
+
else if (t instanceof BaseTrackCanvas) {
|
|
11965
12723
|
t.setConfig({ height: h });
|
|
11966
12724
|
}
|
|
11967
12725
|
},
|
|
@@ -12057,6 +12815,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
12057
12815
|
}
|
|
12058
12816
|
}
|
|
12059
12817
|
// ─── Track reorder drag ─────────────────────────────────────────────
|
|
12818
|
+
/** Sync DOM after any sort (addTrack, addGeneTrack, etc.). */
|
|
12819
|
+
onTracksSorted() {
|
|
12820
|
+
this.syncDOMOrder();
|
|
12821
|
+
}
|
|
12060
12822
|
/** Reorder DOM rows to match managedTracks order. */
|
|
12061
12823
|
syncDOMOrder() {
|
|
12062
12824
|
for (const mt of this.managedTracks) {
|
|
@@ -12133,6 +12895,8 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
12133
12895
|
if (dragEntry) {
|
|
12134
12896
|
dragEntry.axisDiv.style.cursor = "grab";
|
|
12135
12897
|
}
|
|
12898
|
+
// Persist the drag result so future addTrack sorts don't revert positions.
|
|
12899
|
+
this._assignOrderFromPosition();
|
|
12136
12900
|
// Emit the final order change event
|
|
12137
12901
|
this.events.emit(BrowserEvent.TrackOrderChanged, {
|
|
12138
12902
|
tracks: this.managedTracks.map((mt) => mt.track),
|
|
@@ -12255,6 +13019,21 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
12255
13019
|
* DOM-based (not canvas) for easy hover/click interaction without
|
|
12256
13020
|
* re-rendering track canvases.
|
|
12257
13021
|
*/
|
|
13022
|
+
/** Update all feature overlays after render. */
|
|
13023
|
+
updateFeatureOverlays() {
|
|
13024
|
+
for (const mt of this.managedTracks) {
|
|
13025
|
+
this.updateFeatureOverlay(mt.track);
|
|
13026
|
+
}
|
|
13027
|
+
}
|
|
13028
|
+
/** Update the feature overlay for a single track. */
|
|
13029
|
+
updateFeatureOverlay(track) {
|
|
13030
|
+
const overlay = this.featureOverlays.get(track);
|
|
13031
|
+
if (!overlay)
|
|
13032
|
+
return;
|
|
13033
|
+
if (track instanceof BaseTrackCanvas) {
|
|
13034
|
+
overlay.update(track.getFeatureRects());
|
|
13035
|
+
}
|
|
13036
|
+
}
|
|
12258
13037
|
renderROIOverlays() {
|
|
12259
13038
|
var _a, _b;
|
|
12260
13039
|
const visibleROIs = this.getVisibleROIs();
|
|
@@ -12501,9 +13280,6 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
|
|
|
12501
13280
|
if (info.dataRange) {
|
|
12502
13281
|
renderQuantitativeAxis(ctx, info, axisWidth, h);
|
|
12503
13282
|
}
|
|
12504
|
-
else if (info.label) {
|
|
12505
|
-
renderLabelAxis(ctx, info, axisWidth, h);
|
|
12506
|
-
}
|
|
12507
13283
|
ctx.restore();
|
|
12508
13284
|
}
|
|
12509
13285
|
// Render track (right of axis)
|
|
@@ -12618,7 +13394,7 @@ function inferShellTheme(theme) {
|
|
|
12618
13394
|
return luminance < 0.5 ? 'dark' : 'modern';
|
|
12619
13395
|
}
|
|
12620
13396
|
const LoomBrowser = forwardRef(function LoomBrowser(props, ref) {
|
|
12621
|
-
const { defaultLocus, locus, onLocusChange, theme, genome, interactive, wheelZoom, workerProvider, popupProvider, contextMenuProvider, ruler, genes, sequence, session, tracks: trackConfigs, remoteSocket, className, style, children, } = props;
|
|
13397
|
+
const { defaultLocus, locus, onLocusChange, theme, genome, interactive, wheelZoom, workers, workerFactory, workerProvider, popupProvider, contextMenuProvider, ruler, genes, sequence, session, tracks: trackConfigs, remoteSocket, className, style, children, } = props;
|
|
12622
13398
|
const containerRef = useRef(null);
|
|
12623
13399
|
const browserRef = useRef(null);
|
|
12624
13400
|
const [browser, setBrowser] = useState(null);
|
|
@@ -12641,6 +13417,8 @@ const LoomBrowser = forwardRef(function LoomBrowser(props, ref) {
|
|
|
12641
13417
|
locus: initialLocus,
|
|
12642
13418
|
interactive,
|
|
12643
13419
|
wheelZoom,
|
|
13420
|
+
workers,
|
|
13421
|
+
workerFactory,
|
|
12644
13422
|
workerProvider,
|
|
12645
13423
|
popupProvider,
|
|
12646
13424
|
contextMenuProvider,
|
|
@@ -12741,6 +13519,7 @@ const LoomBrowser = forwardRef(function LoomBrowser(props, ref) {
|
|
|
12741
13519
|
// ROI
|
|
12742
13520
|
addROI(roi, setName) { return browserRef.current.addROI(roi, setName); },
|
|
12743
13521
|
addROISet(config) { return browserRef.current.addROISet(config); },
|
|
13522
|
+
removeROISet(set) { var _a, _b; return (_b = (_a = browserRef.current) === null || _a === void 0 ? void 0 : _a.removeROISet(set)) !== null && _b !== void 0 ? _b : false; },
|
|
12744
13523
|
removeROI(roiId) { var _a, _b; return (_b = (_a = browserRef.current) === null || _a === void 0 ? void 0 : _a.removeROI(roiId)) !== null && _b !== void 0 ? _b : false; },
|
|
12745
13524
|
updateROI(roiId, changes) { var _a; return (_a = browserRef.current) === null || _a === void 0 ? void 0 : _a.updateROI(roiId, changes); },
|
|
12746
13525
|
clearROIs() { var _a; (_a = browserRef.current) === null || _a === void 0 ? void 0 : _a.clearROIs(); },
|
|
@@ -12824,12 +13603,15 @@ function useLocus() {
|
|
|
12824
13603
|
* @param recreationDeps When these change, the track is removed and re-added (new data source).
|
|
12825
13604
|
* @param updateTrack Called when only updateDeps change (in-place config update).
|
|
12826
13605
|
* @param updateDeps When these change (but recreationDeps don't), updateTrack is called.
|
|
13606
|
+
* @param eventHandlers Optional typed click/context-menu handlers scoped to this track.
|
|
12827
13607
|
*/
|
|
12828
|
-
function useTrackManager(addTrack, recreationDeps, updateTrack, updateDeps) {
|
|
13608
|
+
function useTrackManager(addTrack, recreationDeps, updateTrack, updateDeps, eventHandlers) {
|
|
12829
13609
|
const browser = useGenomeBrowser();
|
|
12830
13610
|
const trackRef = useRef(null);
|
|
12831
13611
|
const prevRecreationDeps = useRef(recreationDeps);
|
|
12832
13612
|
const isFirstRender = useRef(true);
|
|
13613
|
+
const handlersRef = useRef(eventHandlers);
|
|
13614
|
+
handlersRef.current = eventHandlers;
|
|
12833
13615
|
// Add track on mount or when recreation deps change
|
|
12834
13616
|
useEffect(() => {
|
|
12835
13617
|
if (!browser)
|
|
@@ -12860,6 +13642,29 @@ function useTrackManager(addTrack, recreationDeps, updateTrack, updateDeps) {
|
|
|
12860
13642
|
updateTrack(trackRef.current, browser);
|
|
12861
13643
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
12862
13644
|
}, updateDeps);
|
|
13645
|
+
// Subscribe to track click/context-menu events, filtered to this track
|
|
13646
|
+
useEffect(() => {
|
|
13647
|
+
if (!browser)
|
|
13648
|
+
return;
|
|
13649
|
+
function handleEvent(event, handler) {
|
|
13650
|
+
if (!handler || event.track !== trackRef.current)
|
|
13651
|
+
return;
|
|
13652
|
+
handler({
|
|
13653
|
+
features: event.features,
|
|
13654
|
+
genomicLocation: event.genomicLocation,
|
|
13655
|
+
x: event.x,
|
|
13656
|
+
y: event.y,
|
|
13657
|
+
});
|
|
13658
|
+
}
|
|
13659
|
+
const onClickListener = (e) => { var _a; return handleEvent(e, (_a = handlersRef.current) === null || _a === void 0 ? void 0 : _a.onClick); };
|
|
13660
|
+
const onContextMenuListener = (e) => { var _a; return handleEvent(e, (_a = handlersRef.current) === null || _a === void 0 ? void 0 : _a.onContextMenu); };
|
|
13661
|
+
browser.on(BrowserEvent.TrackClick, onClickListener);
|
|
13662
|
+
browser.on(BrowserEvent.TrackContextMenu, onContextMenuListener);
|
|
13663
|
+
return () => {
|
|
13664
|
+
browser.off(BrowserEvent.TrackClick, onClickListener);
|
|
13665
|
+
browser.off(BrowserEvent.TrackContextMenu, onContextMenuListener);
|
|
13666
|
+
};
|
|
13667
|
+
}, [browser]);
|
|
12863
13668
|
return trackRef.current;
|
|
12864
13669
|
}
|
|
12865
13670
|
|
|
@@ -12869,7 +13674,7 @@ function RulerTrack({ config, maxTrackHeight }) {
|
|
|
12869
13674
|
return null;
|
|
12870
13675
|
}
|
|
12871
13676
|
|
|
12872
|
-
function WigTrack({ url, features, config, height, background, windowFunction, maxTrackHeight, name, metadata }) {
|
|
13677
|
+
function WigTrack({ url, features, config, height, background, windowFunction, maxTrackHeight, name, metadata, onClick, onContextMenu }) {
|
|
12873
13678
|
useTrackManager((browser) => {
|
|
12874
13679
|
if (features) {
|
|
12875
13680
|
return browser.addWigTrackWithFeatures(features, { config, height, background, maxTrackHeight, name, metadata });
|
|
@@ -12878,26 +13683,28 @@ function WigTrack({ url, features, config, height, background, windowFunction, m
|
|
|
12878
13683
|
throw new Error('WigTrack requires either a `url` or `features` prop');
|
|
12879
13684
|
return browser.addWigTrack(url, { config, height, background, windowFunction, maxTrackHeight, name, metadata });
|
|
12880
13685
|
}, [url, features, windowFunction], (track) => { if (config)
|
|
12881
|
-
track.setConfig(config); }, [config, height, background, name]);
|
|
13686
|
+
track.setConfig(config); }, [config, height, background, name], { onClick, onContextMenu });
|
|
12882
13687
|
return null;
|
|
12883
13688
|
}
|
|
12884
13689
|
|
|
12885
|
-
function GeneTrack({ config, height, background, genome, track, maxTrackHeight, name, metadata }) {
|
|
13690
|
+
function GeneTrack({ config, height, background, genome, track, maxTrackHeight, name, metadata, onClick, onContextMenu }) {
|
|
12886
13691
|
useTrackManager((browser) => browser.addGeneTrack({ config, height, background, genome, track, maxTrackHeight, name, metadata }), [genome, track], (t) => { if (config)
|
|
12887
|
-
t.setConfig(config); }, [config, height, background, name]);
|
|
13692
|
+
t.setConfig(config); }, [config, height, background, name], { onClick, onContextMenu });
|
|
12888
13693
|
return null;
|
|
12889
13694
|
}
|
|
12890
13695
|
|
|
12891
|
-
function BedTrack({ url, features, config, height, background, format, indexURL, indexed, maxTrackHeight, name, metadata }) {
|
|
13696
|
+
function BedTrack({ url, features, config, height, background, format, indexURL, indexed, maxTrackHeight, name, metadata, colorBy, onClick, onContextMenu }) {
|
|
13697
|
+
// Apply colorBy to in-memory features
|
|
13698
|
+
const coloredFeatures = useMemo(() => features && colorBy ? features.map(f => ({ ...f, color: colorBy(f) })) : features, [features, colorBy]);
|
|
12892
13699
|
useTrackManager((browser) => {
|
|
12893
|
-
if (
|
|
12894
|
-
return browser.addBedTrackWithFeatures(
|
|
13700
|
+
if (coloredFeatures) {
|
|
13701
|
+
return browser.addBedTrackWithFeatures(coloredFeatures, { config, height, background, maxTrackHeight, name, metadata });
|
|
12895
13702
|
}
|
|
12896
13703
|
if (!url)
|
|
12897
13704
|
throw new Error('BedTrack requires either a `url` or `features` prop');
|
|
12898
13705
|
return browser.addBedTrack(url, { config, height, background, format, indexURL, indexed, maxTrackHeight, name, metadata });
|
|
12899
|
-
}, [url,
|
|
12900
|
-
track.setConfig(config); }, [config, height, background, name]);
|
|
13706
|
+
}, [url, coloredFeatures, format, indexURL, indexed], (track) => { if (config)
|
|
13707
|
+
track.setConfig(config); }, [config, height, background, name], { onClick, onContextMenu });
|
|
12901
13708
|
return null;
|
|
12902
13709
|
}
|
|
12903
13710
|
|
|
@@ -12907,15 +13714,43 @@ function SequenceTrack({ config, maxTrackHeight }) {
|
|
|
12907
13714
|
return null;
|
|
12908
13715
|
}
|
|
12909
13716
|
|
|
12910
|
-
function InteractionTrack({ url, config, background, format, indexURL, indexed, name, metadata }) {
|
|
13717
|
+
function InteractionTrack({ url, config, background, format, indexURL, indexed, name, metadata, onClick, onContextMenu }) {
|
|
12911
13718
|
useTrackManager((browser) => browser.addInteractionTrack(url, { config, background, format, indexURL, indexed, name, metadata }), [url, format, indexURL, indexed], (track) => { if (config)
|
|
12912
|
-
track.setConfig(config); }, [config, background, name]);
|
|
13719
|
+
track.setConfig(config); }, [config, background, name], { onClick, onContextMenu });
|
|
12913
13720
|
return null;
|
|
12914
13721
|
}
|
|
12915
13722
|
|
|
12916
|
-
function GtxTrack({ url, experimentId, config, height, background, windowFunction, maxTrackHeight, name, metadata }) {
|
|
13723
|
+
function GtxTrack({ url, experimentId, config, height, background, windowFunction, maxTrackHeight, name, metadata, onClick, onContextMenu }) {
|
|
12917
13724
|
useTrackManager((browser) => browser.addGtxTrack(url, { experimentId, config, height, background, windowFunction, maxTrackHeight, name, metadata }), [url, experimentId, windowFunction], (track) => { if (config)
|
|
12918
|
-
track.setConfig(config); }, [config, height, background, name]);
|
|
13725
|
+
track.setConfig(config); }, [config, height, background, name], { onClick, onContextMenu });
|
|
13726
|
+
return null;
|
|
13727
|
+
}
|
|
13728
|
+
|
|
13729
|
+
/**
|
|
13730
|
+
* Declarative ROI set component. Renders nothing — manages ROIs on the
|
|
13731
|
+
* GenomeBrowser via context. Add/remove `<ROISet>` children to control ROIs.
|
|
13732
|
+
*/
|
|
13733
|
+
function DeclarativeROISet({ rois, name = 'Declarative', color }) {
|
|
13734
|
+
const browser = useGenomeBrowser();
|
|
13735
|
+
const setRef = useRef(null);
|
|
13736
|
+
useEffect(() => {
|
|
13737
|
+
if (!browser)
|
|
13738
|
+
return;
|
|
13739
|
+
// Remove previous set if it exists
|
|
13740
|
+
if (setRef.current) {
|
|
13741
|
+
browser.removeROISet(setRef.current);
|
|
13742
|
+
setRef.current = null;
|
|
13743
|
+
}
|
|
13744
|
+
const config = { name, color, features: rois };
|
|
13745
|
+
const set = browser.addROISet(config);
|
|
13746
|
+
setRef.current = set;
|
|
13747
|
+
return () => {
|
|
13748
|
+
if (setRef.current) {
|
|
13749
|
+
browser.removeROISet(setRef.current);
|
|
13750
|
+
setRef.current = null;
|
|
13751
|
+
}
|
|
13752
|
+
};
|
|
13753
|
+
}, [browser, name, color, rois]);
|
|
12919
13754
|
return null;
|
|
12920
13755
|
}
|
|
12921
13756
|
|
|
@@ -14235,4 +15070,4 @@ var LoomInputDialog$1 = /*#__PURE__*/Object.freeze({
|
|
|
14235
15070
|
LoomInputDialog: LoomInputDialog
|
|
14236
15071
|
});
|
|
14237
15072
|
|
|
14238
|
-
export { BedTrack, ChromosomeSelect, ExportControls, GeneTrack, GenomeBrowserContext, GtxTrack, InteractionTrack, LocusInput, LoomBrowser, Navbar, RulerTrack, SequenceTrack, WigTrack, WindowSize, ZoomControls, useBrowserEvent, useGenomeBrowser, useLocus };
|
|
15073
|
+
export { BedTrack, ChromosomeSelect, ExportControls, GeneTrack, GenomeBrowserContext, GtxTrack, InteractionTrack, LocusInput, LoomBrowser, Navbar, DeclarativeROISet as ROISet, RulerTrack, SequenceTrack, WigTrack, WindowSize, ZoomControls, useBrowserEvent, useGenomeBrowser, useLocus };
|