loom-browser 0.0.6 → 0.0.8

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