loom-browser 0.0.6 → 0.0.7

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