loom-browser 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) 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 +1276 -318
  5. package/dist/loom-react.esm.min.js +1 -1
  6. package/dist/loom-react.esm.min.js.map +1 -1
  7. package/dist/loom-worker.js +10591 -0
  8. package/dist/loom-worker.min.js +2 -0
  9. package/dist/loom-worker.min.js.map +1 -0
  10. package/dist/loom.esm.js +8513 -7651
  11. package/dist/loom.esm.min.js +1 -1
  12. package/dist/loom.esm.min.js.map +1 -1
  13. package/dist/loom.js +8516 -7651
  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/memoryDataSource.d.ts +32 -0
  23. package/dist/types/dataSources/textFeatureSource.d.ts +0 -4
  24. package/dist/types/genomeBrowser.d.ts +56 -1
  25. package/dist/types/gtx/index.d.ts +1 -4
  26. package/dist/types/headlessGenomeBrowser.d.ts +72 -3
  27. package/dist/types/index.d.ts +11 -4
  28. package/dist/types/mainThreadDataSourceProvider.d.ts +19 -0
  29. package/dist/types/react/GenomeBrowserContext.d.ts +3 -0
  30. package/dist/types/react/LoomBrowser.d.ts +15 -1
  31. package/dist/types/react/tracks/BedTrack.d.ts +8 -3
  32. package/dist/types/react/tracks/WigTrack.d.ts +6 -3
  33. package/dist/types/react/ui/Navbar.d.ts +4 -1
  34. package/dist/types/session.d.ts +0 -3
  35. package/dist/types/svgFeatureOverlay.d.ts +27 -0
  36. package/dist/types/tabix/index.d.ts +0 -3
  37. package/dist/types/trackRegistry.d.ts +1 -4
  38. package/dist/types/tracks/annotation/annotationRenderer.d.ts +4 -1
  39. package/dist/types/tracks/annotation/annotationTrackCanvas.d.ts +9 -6
  40. package/dist/types/tracks/axis/axisRenderer.d.ts +2 -8
  41. package/dist/types/tracks/axis/index.d.ts +1 -1
  42. package/dist/types/tracks/baseTrackCanvas.d.ts +7 -1
  43. package/dist/types/tracks/interaction/interactionRenderer.d.ts +4 -1
  44. package/dist/types/tracks/trackLabel.d.ts +22 -0
  45. package/dist/types/tracks/wig/wigTrackCanvas.d.ts +2 -0
  46. package/dist/types/types.d.ts +22 -1
  47. package/dist/types/worker/dataSourceRegistry.d.ts +33 -0
  48. package/dist/types/worker/dataSourceWorkerScript.d.ts +13 -0
  49. package/dist/types/worker/unifiedWorkerScript.d.ts +17 -0
  50. package/dist/types/worker/webDataSourceWorkerProvider.d.ts +59 -0
  51. package/dist/types/worker/webUnifiedWorkerProvider.d.ts +64 -0
  52. package/dist/types/worker/webWorkerPool.d.ts +64 -0
  53. package/dist/types/worker/workerPoolScript.d.ts +17 -0
  54. package/dist/types/workerDataSource.d.ts +32 -0
  55. package/dist/types/workerProvider.d.ts +10 -3
  56. package/package.json +3 -1
@@ -1074,6 +1074,47 @@ class DOMCanvasProvider {
1074
1074
  /** Default CanvasProvider for browser environments. */
1075
1075
  const defaultCanvasProvider = new DOMCanvasProvider();
1076
1076
 
1077
+ /**
1078
+ * WorkerDataSource — main-thread proxy that forwards DataSource.fetch() to a worker.
1079
+ *
1080
+ * Implements DataSource<F> so it's a drop-in replacement for any direct DataSource.
1081
+ * The real DataSource instance lives inside a web worker; this proxy communicates
1082
+ * via the DataSourceWorkerProvider.
1083
+ *
1084
+ * Non-serializable configuration (like chromosome name resolver functions) is
1085
+ * stored locally on the proxy and applied before forwarding to the worker.
1086
+ *
1087
+ * Layer 1 (Data + Layout): no DOM.
1088
+ */
1089
+ class WorkerDataSource {
1090
+ constructor(provider, instanceId) {
1091
+ this.provider = provider;
1092
+ this.instanceId = instanceId;
1093
+ }
1094
+ async fetch(locus, bpPerPixel, signal) {
1095
+ // Resolve chromosome name locally (functions can't cross postMessage boundary)
1096
+ const resolvedLocus = this.resolver
1097
+ ? { ...locus, chr: this.resolver(locus.chr) }
1098
+ : locus;
1099
+ return this.provider.fetch(this.instanceId, resolvedLocus, bpPerPixel, signal);
1100
+ }
1101
+ /**
1102
+ * Set a chromosome name resolver. Stored locally — the proxy resolves
1103
+ * chromosome names before forwarding the locus to the worker.
1104
+ */
1105
+ setChromNameResolver(resolver) {
1106
+ this.resolver = resolver;
1107
+ }
1108
+ /** Forward cumulative offsets to the worker-resident DataSource. */
1109
+ setCumulativeOffsets(offsets) {
1110
+ this.provider.configure(this.instanceId, 'setCumulativeOffsets', offsets);
1111
+ }
1112
+ /** Forward window function change to the worker-resident DataSource. */
1113
+ setWindowFunction(wf) {
1114
+ this.provider.configure(this.instanceId, 'setWindowFunction', wf);
1115
+ }
1116
+ }
1117
+
1077
1118
  /**
1078
1119
  * Unified RenderTheme system — shadcn-style palette-driven theming.
1079
1120
  *
@@ -2212,6 +2253,9 @@ function createCachedSequence(provider) {
2212
2253
  currentQuery = null;
2213
2254
  return interval;
2214
2255
  });
2256
+ // Clear stale currentQuery on failure (e.g. abort) so subsequent
2257
+ // requests don't keep reusing the rejected promise via dedup.
2258
+ promise.catch(() => { currentQuery = null; });
2215
2259
  currentQuery = { interval: queryLocus, promise };
2216
2260
  return promise;
2217
2261
  }
@@ -2447,6 +2491,14 @@ class BaseTrackCanvas {
2447
2491
  hitTest(_x, _y) {
2448
2492
  return [];
2449
2493
  }
2494
+ /**
2495
+ * Return feature bounding rectangles for the current render state.
2496
+ * Used by SVG overlay system for hover/click interactivity.
2497
+ * Default: empty (no overlay). Subclasses override for interactive tracks.
2498
+ */
2499
+ getFeatureRects() {
2500
+ return [];
2501
+ }
2450
2502
  /**
2451
2503
  * Return context menu items for a right-click at canvas-relative coordinates.
2452
2504
  * Default: undefined (no track-specific items). Subclasses override.
@@ -2568,6 +2620,47 @@ class BaseTrackCanvas {
2568
2620
  }
2569
2621
  }
2570
2622
 
2623
+ /**
2624
+ * Shared track name overlay label — renders a horizontal label in the top-left
2625
+ * corner of a track canvas with a semi-transparent background pill.
2626
+ *
2627
+ * Used by wig, annotation, and interaction tracks for consistent labeling.
2628
+ * Layer 2 (Track Canvases): uses canvas API, no DOM.
2629
+ */
2630
+ /**
2631
+ * Render a track name as a horizontal overlay in the top-left corner.
2632
+ * Draws a semi-transparent background pill behind the text for readability.
2633
+ */
2634
+ function renderTrackNameLabel(ctx, options, pixelHeight) {
2635
+ var _a, _b;
2636
+ if (pixelHeight < 12)
2637
+ return;
2638
+ const pad = 4;
2639
+ const vPad = 2;
2640
+ const hPad = 4;
2641
+ ctx.font = 'normal 10px sans-serif';
2642
+ ctx.textBaseline = 'top';
2643
+ ctx.textAlign = 'left';
2644
+ const topOffset = pad + ((_a = options.topOffset) !== null && _a !== void 0 ? _a : 0);
2645
+ const metrics = ctx.measureText(options.name);
2646
+ const w = metrics.width + hPad * 2;
2647
+ const h = ((_b = metrics.actualBoundingBoxDescent) !== null && _b !== void 0 ? _b : 10) + vPad * 2;
2648
+ // Semi-transparent background
2649
+ ctx.fillStyle = options.background;
2650
+ ctx.globalAlpha = 0.75;
2651
+ ctx.fillRect(pad, topOffset, w, h);
2652
+ ctx.globalAlpha = 1.0;
2653
+ // Border — use labelColor at reduced opacity for theme awareness
2654
+ ctx.strokeStyle = options.labelColor;
2655
+ ctx.globalAlpha = 0.3;
2656
+ ctx.lineWidth = 0.5;
2657
+ ctx.strokeRect(pad, topOffset, w, h);
2658
+ ctx.globalAlpha = 1.0;
2659
+ // Text
2660
+ ctx.fillStyle = options.labelColor;
2661
+ ctx.fillText(options.name, pad + hPad, topOffset + vPad);
2662
+ }
2663
+
2571
2664
  /**
2572
2665
  * Dynamic sequence (dynseq) renderer for wig tracks.
2573
2666
  *
@@ -2950,50 +3043,30 @@ function renderDataRangeLabels(ctx, config, pixelHeight) {
2950
3043
  }
2951
3044
  // ─── Track name overlay label ────────────────────────────────────────────────
2952
3045
  /**
2953
- * Render the track name as an igv.js-style overlay in the top-left corner.
2954
- * Draws a semi-transparent background pill behind the text for readability.
2955
- * When data range labels are also visible, positions below the max label.
3046
+ * Wig-specific track name label: computes topOffset to clear data range labels,
3047
+ * then delegates to the shared renderTrackNameLabel().
2956
3048
  */
2957
- function renderTrackNameLabel(ctx, config, pixelHeight) {
2958
- var _a, _b;
3049
+ function renderWigTrackNameLabel(ctx, config, pixelHeight) {
3050
+ var _a;
2959
3051
  if (!config.trackName)
2960
3052
  return;
2961
- if (pixelHeight < 12)
2962
- return;
2963
- const pad = 4;
2964
- const vPad = 2;
2965
- const hPad = 4;
2966
- ctx.font = 'normal 10px sans-serif';
2967
- ctx.textBaseline = 'top';
2968
- ctx.textAlign = 'left';
2969
3053
  // Compute vertical offset — push below data range max label when visible
2970
- let topOffset = pad;
3054
+ let topOffset = 0;
2971
3055
  if (config.showDataRange) {
3056
+ const vPad = 2;
2972
3057
  ctx.save();
2973
3058
  ctx.font = config.labelFont;
2974
3059
  const maxLabel = prettyPrint(config.flipAxis ? config.dataRange.min : config.dataRange.max);
2975
3060
  const maxH = ((_a = ctx.measureText(maxLabel).actualBoundingBoxDescent) !== null && _a !== void 0 ? _a : 10) + vPad * 2;
2976
3061
  ctx.restore();
2977
- ctx.font = 'normal 10px sans-serif';
2978
- topOffset = pad + maxH + 2;
3062
+ topOffset = maxH + 2;
2979
3063
  }
2980
- const metrics = ctx.measureText(config.trackName);
2981
- const w = metrics.width + hPad * 2;
2982
- const h = ((_b = metrics.actualBoundingBoxDescent) !== null && _b !== void 0 ? _b : 10) + vPad * 2;
2983
- // Semi-transparent background
2984
- ctx.fillStyle = config.background;
2985
- ctx.globalAlpha = 0.75;
2986
- ctx.fillRect(pad, topOffset, w, h);
2987
- ctx.globalAlpha = 1.0;
2988
- // Border — use labelColor at reduced opacity for theme awareness
2989
- ctx.strokeStyle = config.labelColor;
2990
- ctx.globalAlpha = 0.3;
2991
- ctx.lineWidth = 0.5;
2992
- ctx.strokeRect(pad, topOffset, w, h);
2993
- ctx.globalAlpha = 1.0;
2994
- // Text
2995
- ctx.fillStyle = config.labelColor;
2996
- ctx.fillText(config.trackName, pad + hPad, topOffset + vPad);
3064
+ renderTrackNameLabel(ctx, {
3065
+ name: config.trackName,
3066
+ background: config.background,
3067
+ labelColor: config.labelColor,
3068
+ topOffset,
3069
+ }, pixelHeight);
2997
3070
  }
2998
3071
  // ─── Baseline and guide lines ───────────────────────────────────────────────
2999
3072
  function renderBaseline(ctx, config, pixelWidth, pixelHeight) {
@@ -3068,7 +3141,7 @@ function renderWigTrack(ctx, features, config, rc) {
3068
3141
  renderBaseline(ctx, config, rc.pixelWidth, pixelHeight);
3069
3142
  renderGuideLines(ctx, config, rc.pixelWidth, yScale);
3070
3143
  renderDataRangeLabels(ctx, config, pixelHeight);
3071
- renderTrackNameLabel(ctx, config, pixelHeight);
3144
+ renderWigTrackNameLabel(ctx, config, pixelHeight);
3072
3145
  }
3073
3146
 
3074
3147
  /**
@@ -3235,11 +3308,17 @@ class WigTrackCanvas extends BaseTrackCanvas {
3235
3308
  this._lastDataRange = null;
3236
3309
  /** Abort controller for in-flight sequence fetch. */
3237
3310
  this._seqAbort = null;
3311
+ /** User-set config overrides, re-applied on theme change. */
3312
+ this._userOverrides = {};
3238
3313
  this.type = 'wig';
3239
3314
  this.features = options.features;
3240
3315
  this.fixedHeight = options.height;
3241
3316
  this._name = options.name;
3242
3317
  this._sequenceProvider = options.sequenceProvider;
3318
+ if (options.config)
3319
+ this._userOverrides = { ...options.config };
3320
+ if (options.background !== undefined)
3321
+ this._userOverrides.background = options.background;
3243
3322
  }
3244
3323
  /** Set a callback invoked when the user changes the windowing function via context menu. */
3245
3324
  set onWindowFunctionChange(cb) {
@@ -3267,6 +3346,7 @@ class WigTrackCanvas extends BaseTrackCanvas {
3267
3346
  }
3268
3347
  /** Merge partial config and re-render. Triggers sequence fetch when switching to dynseq. */
3269
3348
  setConfig(config) {
3349
+ Object.assign(this._userOverrides, config);
3270
3350
  const wasDynseq = this._config.graphType === 'dynseq';
3271
3351
  super.setConfig(config);
3272
3352
  if (!wasDynseq && this._config.graphType === 'dynseq') {
@@ -3336,8 +3416,16 @@ class WigTrackCanvas extends BaseTrackCanvas {
3336
3416
  return this.config.background;
3337
3417
  }
3338
3418
  doRender(ctx, _width, height, rc) {
3339
- if (this.features.length === 0)
3419
+ if (this.features.length === 0) {
3420
+ if (this._name) {
3421
+ renderTrackNameLabel(ctx, {
3422
+ name: this._name,
3423
+ background: this.config.background,
3424
+ labelColor: this.config.labelColor,
3425
+ }, height);
3426
+ }
3340
3427
  return;
3428
+ }
3341
3429
  // Apply value scaling (normalize then scaleFactor), matching igv.js getFeatures() order.
3342
3430
  // Creates a scaled copy to avoid mutating the original features array.
3343
3431
  const needsScaling = (this.config.normalizationFactor != null && this.config.normalizationFactor !== 1)
@@ -3365,14 +3453,14 @@ class WigTrackCanvas extends BaseTrackCanvas {
3365
3453
  this._config = { ...this._config, dataRange: autoRange };
3366
3454
  }
3367
3455
  this._lastDataRange = effectiveConfig.dataRange;
3368
- console.log('[wig doRender]', { logScale: effectiveConfig.logScale, autoscale: effectiveConfig.autoscale, flipAxis: effectiveConfig.flipAxis, dataRange: effectiveConfig.dataRange, featureCount: features.length });
3369
3456
  renderWigTrack(ctx, features, effectiveConfig, rc);
3370
3457
  }
3371
3458
  getAxisInfo() {
3372
- if (!this._lastDataRange)
3459
+ var _a;
3460
+ if (!this._name && !this._lastDataRange)
3373
3461
  return undefined;
3374
3462
  return {
3375
- dataRange: this._lastDataRange,
3463
+ dataRange: (_a = this._lastDataRange) !== null && _a !== void 0 ? _a : undefined,
3376
3464
  color: typeof this.config.color === 'string' ? this.config.color : undefined,
3377
3465
  label: this._name,
3378
3466
  flipAxis: this.config.flipAxis || undefined,
@@ -3450,7 +3538,7 @@ class WigTrackCanvas extends BaseTrackCanvas {
3450
3538
  }
3451
3539
  setTheme(theme) {
3452
3540
  const dataRange = this._config.dataRange;
3453
- this._config = { ...resolveWigConfig(theme), dataRange };
3541
+ this._config = { ...resolveWigConfig(theme), ...this._userOverrides, dataRange };
3454
3542
  this.render();
3455
3543
  }
3456
3544
  serializeConfig(theme) {
@@ -3858,7 +3946,10 @@ function getUtrColorForFeature(feature, config) {
3858
3946
  * Features should already have `.row` assigned (via `pack()`).
3859
3947
  * The canvas should be sized and cleared before calling this.
3860
3948
  */
3861
- function renderAnnotationTrack(ctx, features, config, rc) {
3949
+ function renderAnnotationTrack(ctx, features, config, rc,
3950
+ /** Optional track name label overlay (top-left corner). */
3951
+ trackLabel) {
3952
+ var _a;
3862
3953
  const rowLastLabelX = {};
3863
3954
  for (const feature of features) {
3864
3955
  if (feature.row === undefined)
@@ -3870,6 +3961,9 @@ function renderAnnotationTrack(ctx, features, config, rc) {
3870
3961
  continue;
3871
3962
  renderSingleFeature(ctx, feature, config, rc, rowLastLabelX);
3872
3963
  }
3964
+ if (trackLabel) {
3965
+ renderTrackNameLabel(ctx, trackLabel, ctx.canvas.height / ((_a = globalThis.devicePixelRatio) !== null && _a !== void 0 ? _a : 1));
3966
+ }
3873
3967
  }
3874
3968
 
3875
3969
  /**
@@ -3890,6 +3984,8 @@ class AnnotationTrackCanvas extends BaseTrackCanvas {
3890
3984
  const theme = resolveTheme(options.theme);
3891
3985
  const config = resolveAnnotationConfig(theme, options.config);
3892
3986
  super(canvas, options.locus, config, options.canvasProvider);
3987
+ /** User-set config overrides, re-applied on theme change. */
3988
+ this._userOverrides = {};
3893
3989
  /** Most recently packed features, available after render(). */
3894
3990
  this.packedFeatures = [];
3895
3991
  /** Whether packedFeatures was pre-computed by async worker (skip sync pack in computeHeight). */
@@ -3899,30 +3995,20 @@ class AnnotationTrackCanvas extends BaseTrackCanvas {
3899
3995
  this.fixedHeight = options.height;
3900
3996
  this.background = (_a = options.background) !== null && _a !== void 0 ? _a : theme.palette.background;
3901
3997
  this._name = options.name;
3902
- this.workerProvider = options.workerProvider;
3998
+ if (options.config)
3999
+ this._userOverrides = { ...options.config };
4000
+ this._userBackground = options.background;
3903
4001
  }
3904
- /** Update features and re-render. Dispatches async pack when workerProvider is set. */
4002
+ /** Set or clear the fixed pixel height. When set, overrides auto-computed row-based height. */
4003
+ setFixedHeight(height) {
4004
+ this.fixedHeight = height;
4005
+ this.render();
4006
+ }
4007
+ /** Update features and re-render. */
3905
4008
  setFeatures(features) {
3906
4009
  this.features = features;
3907
- if (this.workerProvider) {
3908
- // Deep-copy for worker, then render when done
3909
- const copies = features.map(f => ({ ...f }));
3910
- this.workerProvider.execute({ task: 'pack', features: copies })
3911
- .then(packed => {
3912
- this.packedFeatures = packed;
3913
- this.packedReady = true;
3914
- this.render();
3915
- })
3916
- .catch(() => {
3917
- // Fallback: render will use synchronous packing
3918
- this.packedReady = false;
3919
- this.render();
3920
- });
3921
- }
3922
- else {
3923
- this.packedReady = false;
3924
- this.render();
3925
- }
4010
+ this.packedReady = false;
4011
+ this.render();
3926
4012
  }
3927
4013
  computeHeight(_width) {
3928
4014
  var _a;
@@ -3941,10 +4027,20 @@ class AnnotationTrackCanvas extends BaseTrackCanvas {
3941
4027
  return this.background;
3942
4028
  }
3943
4029
  doRender(ctx, _width, _height, rc) {
3944
- renderAnnotationTrack(ctx, this.packedFeatures, this.config, rc);
4030
+ var _a;
4031
+ // Ensure label background matches track background so clearRect doesn't
4032
+ // punch transparent holes (which show the underlying CSS background).
4033
+ const config = this.config.labelBackground
4034
+ ? this.config
4035
+ : { ...this.config, labelBackground: this.background };
4036
+ renderAnnotationTrack(ctx, this.packedFeatures, config, rc, this._name ? {
4037
+ name: this._name,
4038
+ background: this.background,
4039
+ labelColor: (_a = config.labelColor) !== null && _a !== void 0 ? _a : '#333',
4040
+ } : undefined);
3945
4041
  }
3946
4042
  getAxisInfo() {
3947
- return this._name ? { label: this._name } : undefined;
4043
+ return undefined;
3948
4044
  }
3949
4045
  getContextMenuItems(_x, _y) {
3950
4046
  const modes = ['EXPANDED', 'SQUISHED', 'COLLAPSED'];
@@ -3958,6 +4054,43 @@ class AnnotationTrackCanvas extends BaseTrackCanvas {
3958
4054
  })),
3959
4055
  }];
3960
4056
  }
4057
+ getFeatureRects() {
4058
+ var _a;
4059
+ if (this.packedFeatures.length === 0)
4060
+ return [];
4061
+ const width = this._canvas.clientWidth;
4062
+ if (width === 0)
4063
+ return [];
4064
+ const bpPerPixel = (this._locus.end - this._locus.start) / width;
4065
+ const bpStart = this._locus.start;
4066
+ const config = this._config;
4067
+ const rowHeight = config.displayMode === 'SQUISHED'
4068
+ ? config.squishedRowHeight
4069
+ : config.expandedRowHeight;
4070
+ const featureH = config.displayMode === 'SQUISHED'
4071
+ ? config.featureHeight / 2
4072
+ : config.featureHeight;
4073
+ const rects = [];
4074
+ for (const f of this.packedFeatures) {
4075
+ if (f.row === undefined)
4076
+ continue;
4077
+ const pxStart = (f.start - bpStart) / bpPerPixel;
4078
+ const pxEnd = (f.end - bpStart) / bpPerPixel;
4079
+ if (pxEnd < 0 || pxStart > width)
4080
+ continue;
4081
+ const py = config.margin + rowHeight * f.row;
4082
+ let px = pxStart;
4083
+ let pw = pxEnd - pxStart;
4084
+ if (pw < 3) {
4085
+ px -= (3 - pw) / 2;
4086
+ pw = 3;
4087
+ }
4088
+ // Resolve feature color (same logic as annotationRenderer's getColorForFeature)
4089
+ const color = (_a = f.color) !== null && _a !== void 0 ? _a : (config.altColor && f.strand === '-' ? config.altColor : config.color);
4090
+ rects.push({ feature: f, x: px, y: py, width: pw, height: featureH, color });
4091
+ }
4092
+ return rects;
4093
+ }
3961
4094
  hitTest(x, y) {
3962
4095
  if (this.packedFeatures.length === 0)
3963
4096
  return [];
@@ -3982,9 +4115,14 @@ class AnnotationTrackCanvas extends BaseTrackCanvas {
3982
4115
  }
3983
4116
  return results;
3984
4117
  }
4118
+ setConfig(config) {
4119
+ Object.assign(this._userOverrides, config);
4120
+ super.setConfig(config);
4121
+ }
3985
4122
  setTheme(theme) {
3986
- this._config = resolveAnnotationConfig(theme);
3987
- this.background = theme.palette.background;
4123
+ var _a;
4124
+ this._config = resolveAnnotationConfig(theme, this._userOverrides);
4125
+ this.background = (_a = this._userBackground) !== null && _a !== void 0 ? _a : theme.palette.background;
3988
4126
  this.render();
3989
4127
  }
3990
4128
  serializeConfig(theme) {
@@ -4747,7 +4885,7 @@ function featureToWig(f, chr, wf) {
4747
4885
  * Thin wrapper around @gmod/bbi BigWig that preserves the legacy API.
4748
4886
  */
4749
4887
  class BigWigReader {
4750
- constructor(url, _workerProvider) {
4888
+ constructor(url) {
4751
4889
  this.bw = getGmodReader(url);
4752
4890
  }
4753
4891
  async loadHeader(signal) {
@@ -4818,7 +4956,7 @@ class BigWigReader {
4818
4956
  */
4819
4957
  async function fetchBigWigFeatures(url, locus, options = {}) {
4820
4958
  var _a;
4821
- const reader = new BigWigReader(url, options.workerProvider);
4959
+ const reader = new BigWigReader(url);
4822
4960
  return reader.readFeatures(locus.chr, locus.start, locus.end, options.bpPerPixel, (_a = options.windowFunction) !== null && _a !== void 0 ? _a : 'mean', options.signal);
4823
4961
  }
4824
4962
  /**
@@ -4827,15 +4965,14 @@ async function fetchBigWigFeatures(url, locus, options = {}) {
4827
4965
  */
4828
4966
  async function fetchBigWigWGFeatures(url, chromNames, bpPerPixel, options = {}) {
4829
4967
  var _a;
4830
- const reader = new BigWigReader(url, options.workerProvider);
4968
+ const reader = new BigWigReader(url);
4831
4969
  return reader.readWGFeatures(chromNames, bpPerPixel, (_a = options.windowFunction) !== null && _a !== void 0 ? _a : 'mean', options.signal);
4832
4970
  }
4833
4971
 
4834
4972
  class BigWigDataSource {
4835
- constructor(url, windowFunction = 'mean', workerProvider) {
4973
+ constructor(url, windowFunction = 'mean') {
4836
4974
  this.url = url;
4837
4975
  this._windowFunction = windowFunction;
4838
- this.workerProvider = workerProvider;
4839
4976
  }
4840
4977
  get windowFunction() { return this._windowFunction; }
4841
4978
  /** Update the window function for future fetches. */
@@ -4861,20 +4998,10 @@ class BigWigDataSource {
4861
4998
  const features = await fetchBigWigFeatures(this.url, resolvedLocus, {
4862
4999
  bpPerPixel,
4863
5000
  windowFunction: this._windowFunction,
4864
- workerProvider: this.workerProvider,
4865
5001
  signal,
4866
5002
  });
4867
5003
  // Summarize to pixel resolution (matching igv.js wigTrack.getFeatures())
4868
5004
  if (bpPerPixel > 1 && this._windowFunction !== 'none' && features.length > 0) {
4869
- if (this.workerProvider) {
4870
- return this.workerProvider.execute({
4871
- task: 'summarize',
4872
- features,
4873
- startBP: locus.start,
4874
- bpPerPixel,
4875
- windowFunction: this._windowFunction,
4876
- });
4877
- }
4878
5005
  return summarizeWigData(features, locus.start, bpPerPixel, this._windowFunction);
4879
5006
  }
4880
5007
  return features;
@@ -4885,7 +5012,7 @@ class BigWigDataSource {
4885
5012
  */
4886
5013
  async fetchWG(bpPerPixel, signal) {
4887
5014
  const offsets = this._cumulativeOffsets;
4888
- const features = await fetchBigWigWGFeatures(this.url, offsets.chromosomeNames, bpPerPixel, { windowFunction: this._windowFunction, workerProvider: this.workerProvider, signal });
5015
+ const features = await fetchBigWigWGFeatures(this.url, offsets.chromosomeNames, bpPerPixel, { windowFunction: this._windowFunction, signal });
4889
5016
  // Transform to genome-wide coordinates
4890
5017
  const wgFeatures = [];
4891
5018
  for (const f of features) {
@@ -4902,15 +5029,6 @@ class BigWigDataSource {
4902
5029
  wgFeatures.sort((a, b) => a.start - b.start);
4903
5030
  // Summarize at genome-wide scale
4904
5031
  if (bpPerPixel > 1 && this._windowFunction !== 'none' && wgFeatures.length > 0) {
4905
- if (this.workerProvider) {
4906
- return this.workerProvider.execute({
4907
- task: 'summarize',
4908
- features: wgFeatures,
4909
- startBP: 0,
4910
- bpPerPixel,
4911
- windowFunction: this._windowFunction,
4912
- });
4913
- }
4914
5032
  return summarizeWigData(wgFeatures, 0, bpPerPixel, this._windowFunction);
4915
5033
  }
4916
5034
  return wgFeatures;
@@ -5864,11 +5982,10 @@ function getReader(url) {
5864
5982
  return reader;
5865
5983
  }
5866
5984
  class GtxDataSource {
5867
- constructor(url, experimentId, windowFunction = 'mean', workerProvider) {
5985
+ constructor(url, experimentId, windowFunction = 'mean') {
5868
5986
  this.url = url;
5869
5987
  this.experimentId = experimentId;
5870
5988
  this._windowFunction = windowFunction;
5871
- this.workerProvider = workerProvider;
5872
5989
  this.reader = getReader(url);
5873
5990
  }
5874
5991
  get windowFunction() { return this._windowFunction; }
@@ -5901,15 +6018,6 @@ class GtxDataSource {
5901
6018
  const features = await coordinator.request(this.experimentId, resolvedLocus, bpPerPixel, signal);
5902
6019
  // Summarize to pixel resolution
5903
6020
  if (bpPerPixel > 1 && this._windowFunction !== 'none' && features.length > 0) {
5904
- if (this.workerProvider) {
5905
- return this.workerProvider.execute({
5906
- task: 'summarize',
5907
- features,
5908
- startBP: locus.start,
5909
- bpPerPixel,
5910
- windowFunction: this._windowFunction,
5911
- });
5912
- }
5913
6021
  return summarizeWigData(features, locus.start, bpPerPixel, this._windowFunction);
5914
6022
  }
5915
6023
  return features;
@@ -5936,15 +6044,6 @@ class GtxDataSource {
5936
6044
  wgFeatures.sort((a, b) => a.start - b.start);
5937
6045
  // Summarize at genome-wide scale
5938
6046
  if (bpPerPixel > 1 && this._windowFunction !== 'none' && wgFeatures.length > 0) {
5939
- if (this.workerProvider) {
5940
- return this.workerProvider.execute({
5941
- task: 'summarize',
5942
- features: wgFeatures,
5943
- startBP: 0,
5944
- bpPerPixel,
5945
- windowFunction: this._windowFunction,
5946
- });
5947
- }
5948
6047
  return summarizeWigData(wgFeatures, 0, bpPerPixel, this._windowFunction);
5949
6048
  }
5950
6049
  return wgFeatures;
@@ -8019,7 +8118,6 @@ class TextFeatureSource {
8019
8118
  var _a, _b;
8020
8119
  this.allFeaturesLoaded = false;
8021
8120
  this.url = config.url;
8022
- this.workerProvider = config.workerProvider;
8023
8121
  // Detect format
8024
8122
  const detected = (_a = config.format) !== null && _a !== void 0 ? _a : inferFormatFromPath(config.url);
8025
8123
  this.format = detected !== null && detected !== void 0 ? detected : 'bed';
@@ -8037,7 +8135,7 @@ class TextFeatureSource {
8037
8135
  // Set up TabixReader for indexed files
8038
8136
  if (this._indexed) {
8039
8137
  const indexUrl = (_b = config.indexURL) !== null && _b !== void 0 ? _b : inferIndexURL(config.url);
8040
- this.tabixReader = new TabixReader(config.url, { indexUrl, workerProvider: this.workerProvider });
8138
+ this.tabixReader = new TabixReader(config.url, { indexUrl });
8041
8139
  }
8042
8140
  }
8043
8141
  /** Whether this source is indexed (queryable by region). */
@@ -8123,19 +8221,11 @@ class TextFeatureSource {
8123
8221
  const lines = text.split(/\r?\n/);
8124
8222
  // Parse header
8125
8223
  this.header = parseHeader(lines, this.format);
8126
- // Parse features (offload to worker for large files)
8127
- const features = (this.workerProvider
8128
- ? await this.workerProvider.execute({
8129
- task: 'parseFeatures',
8130
- lines,
8131
- format: this.format,
8132
- header: this.header,
8133
- assembleGFF: this.assembleGFF,
8134
- })
8135
- : parseFeatures(lines, this.format, {
8136
- header: this.header,
8137
- assembleGFF: this.assembleGFF,
8138
- }));
8224
+ // Parse features
8225
+ const features = parseFeatures(lines, this.format, {
8226
+ header: this.header,
8227
+ assembleGFF: this.assembleGFF,
8228
+ });
8139
8229
  // Sort by chr then start
8140
8230
  features.sort((a, b) => {
8141
8231
  if (a.chr === b.chr)
@@ -8231,6 +8321,72 @@ class SequenceDataSource {
8231
8321
  }
8232
8322
  }
8233
8323
 
8324
+ /**
8325
+ * DataSource backed by an in-memory feature array.
8326
+ *
8327
+ * Wraps a pre-loaded feature array in a FeatureCache for spatial queries,
8328
+ * enabling inline features to flow through the same data lifecycle as
8329
+ * URL-based data sources.
8330
+ *
8331
+ * Layer 1 (Data + Layout): no DOM, no canvas.
8332
+ */
8333
+ /**
8334
+ * DataSource that serves features from an in-memory array.
8335
+ *
8336
+ * Features are indexed in a FeatureCache on construction for efficient
8337
+ * spatial queries. Calling `setFeatures()` replaces the cache entirely.
8338
+ */
8339
+ class MemoryDataSource {
8340
+ constructor(features) {
8341
+ this.cache = new FeatureCache(features);
8342
+ }
8343
+ /** Replace the in-memory features and rebuild the spatial index. */
8344
+ setFeatures(features) {
8345
+ this.cache = new FeatureCache(features);
8346
+ }
8347
+ /** Set a chromosome name resolver for alias resolution. */
8348
+ setChromNameResolver(resolver) {
8349
+ this._resolveChromName = resolver;
8350
+ }
8351
+ /** Set cumulative offsets for whole genome view coordinate transformation. */
8352
+ setCumulativeOffsets(offsets) {
8353
+ this._cumulativeOffsets = offsets;
8354
+ }
8355
+ async fetch(locus, _bpPerPixel, _signal) {
8356
+ if (isWholeGenomeView(locus) && this._cumulativeOffsets) {
8357
+ return this.fetchWG();
8358
+ }
8359
+ const chr = this._resolveChromName
8360
+ ? this._resolveChromName(locus.chr)
8361
+ : locus.chr;
8362
+ return this.cache.queryFeatures(chr, locus.start, locus.end);
8363
+ }
8364
+ fetchWG() {
8365
+ const offsets = this._cumulativeOffsets;
8366
+ const chrNames = mainChromosomeNames(Object.fromEntries(offsets.chromosomeNames.map(name => { var _a; return [name, (_a = offsets.offsets[name]) !== null && _a !== void 0 ? _a : 0]; })));
8367
+ const wgFeatures = [];
8368
+ const allByChrom = this.cache.getAllFeatures();
8369
+ for (const chr of chrNames) {
8370
+ const features = allByChrom[chr];
8371
+ if (!features)
8372
+ continue;
8373
+ const offset = offsets.offsets[chr];
8374
+ if (offset === undefined)
8375
+ continue;
8376
+ for (const f of features) {
8377
+ wgFeatures.push({
8378
+ ...f,
8379
+ chr: 'all',
8380
+ start: offset + f.start,
8381
+ end: offset + f.end,
8382
+ });
8383
+ }
8384
+ }
8385
+ wgFeatures.sort((a, b) => a.start - b.start);
8386
+ return wgFeatures;
8387
+ }
8388
+ }
8389
+
8234
8390
  /**
8235
8391
  * Stateless renderer for interaction (arc/BEDPE) tracks.
8236
8392
  *
@@ -8250,18 +8406,26 @@ class SequenceDataSource {
8250
8406
  * Stateless: all state is passed in, no side effects beyond canvas drawing
8251
8407
  * and attaching drawState to features for hit-testing.
8252
8408
  */
8253
- function renderInteractionTrack(ctx, features, config, rc) {
8409
+ function renderInteractionTrack(ctx, features, config, rc,
8410
+ /** Optional track name label overlay (top-left corner). */
8411
+ trackLabel) {
8254
8412
  // Clear background
8255
8413
  ctx.fillStyle = config.background;
8256
8414
  ctx.fillRect(0, 0, rc.pixelWidth, config.height);
8257
- if (!features || features.length === 0)
8415
+ if (!features || features.length === 0) {
8416
+ if (trackLabel)
8417
+ renderTrackNameLabel(ctx, trackLabel, config.height);
8258
8418
  return;
8419
+ }
8259
8420
  if (config.displayMode === 'proportional') {
8260
8421
  drawProportional(ctx, features, config, rc);
8261
8422
  }
8262
8423
  else {
8263
8424
  drawNested(ctx, features, config, rc);
8264
8425
  }
8426
+ if (trackLabel) {
8427
+ renderTrackNameLabel(ctx, trackLabel, config.height);
8428
+ }
8265
8429
  }
8266
8430
  // ─── Nested arcs ─────────────────────────────────────────────────────────────
8267
8431
  function drawNested(ctx, features, config, rc) {
@@ -8565,7 +8729,11 @@ class InteractionTrackCanvas extends BaseTrackCanvas {
8565
8729
  return this.config.background;
8566
8730
  }
8567
8731
  doRender(ctx, _width, _height, rc) {
8568
- renderInteractionTrack(ctx, this.features, this._config, rc);
8732
+ renderInteractionTrack(ctx, this.features, this._config, rc, this._name ? {
8733
+ name: this._name,
8734
+ background: this._config.background,
8735
+ labelColor: '#333',
8736
+ } : undefined);
8569
8737
  }
8570
8738
  /** Hit-test: find features at canvas-relative pixel coordinates. */
8571
8739
  hitTest(x, y) {
@@ -8785,12 +8953,12 @@ function knownTrackTypes() {
8785
8953
  return types;
8786
8954
  }
8787
8955
  // ─── Built-in data source helpers ────────────────────────────────────────────
8788
- function createDataSource(config, workerProvider) {
8956
+ function createDataSource(config) {
8789
8957
  switch (config.type) {
8790
8958
  case 'bigwig':
8791
- return new BigWigDataSource(config.url, config.windowFunction, workerProvider);
8959
+ return new BigWigDataSource(config.url, config.windowFunction);
8792
8960
  case 'gtx':
8793
- return new GtxDataSource(config.url, config.experimentId, config.windowFunction, workerProvider);
8961
+ return new GtxDataSource(config.url, config.experimentId, config.windowFunction);
8794
8962
  case 'ucsc':
8795
8963
  return new GeneDataSource({ genome: config.genome, track: config.track });
8796
8964
  case 'text':
@@ -8799,8 +8967,12 @@ function createDataSource(config, workerProvider) {
8799
8967
  format: config.format,
8800
8968
  indexURL: config.indexURL,
8801
8969
  indexed: config.indexed,
8802
- workerProvider,
8803
8970
  });
8971
+ case 'memory':
8972
+ // Memory data sources are created directly with features by the caller.
8973
+ // This path is only hit during session restore, where in-memory features
8974
+ // are not available — return an empty MemoryDataSource as a placeholder.
8975
+ return new MemoryDataSource([]);
8804
8976
  }
8805
8977
  }
8806
8978
  // ─── Built-in track creators ─────────────────────────────────────────────────
@@ -8818,7 +8990,7 @@ function createWigTrack(trackConfig, ctx) {
8818
8990
  let dataSourceConfig = null;
8819
8991
  if (config.dataSource) {
8820
8992
  dataSourceConfig = config.dataSource;
8821
- dataSource = createDataSource(config.dataSource, ctx.workerProvider);
8993
+ dataSource = createDataSource(config.dataSource);
8822
8994
  }
8823
8995
  return { track, dataSource, dataSourceConfig, name: config.name, order: config.order };
8824
8996
  }
@@ -8831,13 +9003,12 @@ function createAnnotationTrack(trackConfig, ctx) {
8831
9003
  config: config.config,
8832
9004
  theme: ctx.theme,
8833
9005
  canvasProvider: ctx.canvasProvider,
8834
- workerProvider: ctx.workerProvider,
8835
9006
  });
8836
9007
  let dataSource = null;
8837
9008
  let dataSourceConfig = null;
8838
9009
  if (config.dataSource) {
8839
9010
  dataSourceConfig = config.dataSource;
8840
- dataSource = createDataSource(config.dataSource, ctx.workerProvider);
9011
+ dataSource = createDataSource(config.dataSource);
8841
9012
  }
8842
9013
  return { track, dataSource, dataSourceConfig, name: config.name, order: config.order };
8843
9014
  }
@@ -8883,7 +9054,7 @@ function createInteractionTrack(trackConfig, ctx) {
8883
9054
  let dataSourceConfig = null;
8884
9055
  if (config.dataSource) {
8885
9056
  dataSourceConfig = config.dataSource;
8886
- dataSource = createDataSource(config.dataSource, ctx.workerProvider);
9057
+ dataSource = createDataSource(config.dataSource);
8887
9058
  }
8888
9059
  return { track, dataSource, dataSourceConfig, name: config.name, order: config.order };
8889
9060
  }
@@ -8929,7 +9100,6 @@ function createTrackFromConfig(trackConfig, locus, options = {}) {
8929
9100
  return creator(trackConfig, {
8930
9101
  locus,
8931
9102
  canvasProvider: (_a = options.canvasProvider) !== null && _a !== void 0 ? _a : defaultCanvasProvider,
8932
- workerProvider: options.workerProvider,
8933
9103
  theme: options.theme,
8934
9104
  sequenceProvider: options.sequenceProvider,
8935
9105
  });
@@ -8979,6 +9149,9 @@ function dataSourceCacheKey(config) {
8979
9149
  return `ucsc:${(_b = config.genome) !== null && _b !== void 0 ? _b : ''}:${(_c = config.track) !== null && _c !== void 0 ? _c : ''}`;
8980
9150
  case 'text':
8981
9151
  return `text:${config.url}:${(_d = config.format) !== null && _d !== void 0 ? _d : ''}:${(_e = config.indexURL) !== null && _e !== void 0 ? _e : ''}`;
9152
+ case 'memory':
9153
+ // Each memory data source is unique — no deduplication.
9154
+ return `memory:${Math.random()}`;
8982
9155
  }
8983
9156
  }
8984
9157
 
@@ -9310,6 +9483,15 @@ function selectTracks(tracks, selector) {
9310
9483
  * browser.setLocus({ chr: 'chr17', start: 7670000, end: 7680000 })
9311
9484
  * browser.dispose()
9312
9485
  */
9486
+ /** Duck-type check: does this object implement DataSourceWorkerProvider? */
9487
+ function isDataSourceWorkerProvider(obj) {
9488
+ if (!obj || typeof obj !== 'object')
9489
+ return false;
9490
+ const o = obj;
9491
+ return typeof o.create === 'function'
9492
+ && typeof o.fetch === 'function'
9493
+ && typeof o.destroy === 'function';
9494
+ }
9313
9495
  const BrowserEvent = {
9314
9496
  LocusChange: 'locuschange',
9315
9497
  TrackAdded: 'trackadded',
@@ -9333,6 +9515,25 @@ let nextTrackId = 0;
9333
9515
  function generateTrackId(type) {
9334
9516
  return `${type !== null && type !== void 0 ? type : 'track'}-${nextTrackId++}`;
9335
9517
  }
9518
+ /**
9519
+ * Track type sort priority — adapts the igv.js `reorderTracks()` two-level
9520
+ * sort (js/browser.ts:1153-1172) to a single numeric priority.
9521
+ *
9522
+ * igv.js pins ideogram (1), ruler (2) to the top, then sorts by `track.order`.
9523
+ * Loom uses positive priority for top-pinned tracks, 0 for user data tracks,
9524
+ * and negative values to push tracks (e.g., gene reference) to the bottom.
9525
+ *
9526
+ * Each ManagedTrack can also set `order` which is added to the type priority,
9527
+ * giving callers fine-grained control (e.g., addGeneTrack sets order=-1).
9528
+ */
9529
+ const DEFAULT_TRACK_TYPE_PRIORITY = {
9530
+ ruler: 2,
9531
+ sequence: 1,
9532
+ };
9533
+ function trackTypePriority(type) {
9534
+ var _a;
9535
+ return (_a = DEFAULT_TRACK_TYPE_PRIORITY[type]) !== null && _a !== void 0 ? _a : 0;
9536
+ }
9336
9537
  class HeadlessGenomeBrowser {
9337
9538
  get theme() { return this._theme; }
9338
9539
  get locus() { return this._locus; }
@@ -9349,6 +9550,10 @@ class HeadlessGenomeBrowser {
9349
9550
  this.roiSets = [];
9350
9551
  /** Inflight fetch promises keyed by (cacheKey + fetchRegion + bpPerPixel) for deduplication. */
9351
9552
  this.inflightFetches = new Map();
9553
+ /** Timer for debouncing data loads during rapid zoom/pan. */
9554
+ this.loadDebounceTimer = null;
9555
+ /** When true, sortTracks() is a no-op. Used for batch track additions (e.g. loadSession). */
9556
+ this._deferSort = false;
9352
9557
  this.events = new EventEmitter();
9353
9558
  this.genome = options.genome === null ? undefined : ((_a = options.genome) !== null && _a !== void 0 ? _a : hg38Genome);
9354
9559
  this.chromSizes = (_b = this.genome) === null || _b === void 0 ? void 0 : _b.chromSizes;
@@ -9358,12 +9563,33 @@ class HeadlessGenomeBrowser {
9358
9563
  this._viewportWidth = (_e = options.viewportWidth) !== null && _e !== void 0 ? _e : 0;
9359
9564
  this.canvasProvider = (_f = options.canvasProvider) !== null && _f !== void 0 ? _f : defaultCanvasProvider;
9360
9565
  this.workerProvider = options.workerProvider;
9566
+ // Auto-detect: if workerProvider also implements DataSourceWorkerProvider, use it for both
9567
+ this.dataSourceWorkerProvider = isDataSourceWorkerProvider(options.workerProvider)
9568
+ ? options.workerProvider : undefined;
9361
9569
  this.popupProvider = (_g = options.popupProvider) !== null && _g !== void 0 ? _g : undefined;
9362
9570
  this.contextMenuProvider = (_h = options.contextMenuProvider) !== null && _h !== void 0 ? _h : undefined;
9363
9571
  this._theme = resolveTheme(options.theme);
9364
9572
  if (options.stateProjection)
9365
9573
  this._state = options.stateProjection;
9366
9574
  }
9575
+ /**
9576
+ * Create a WorkerDataSource proxy for worker-eligible configs, or return null
9577
+ * if dataSourceWorkerProvider is not set. Handles create + chrom alias + offsets wiring.
9578
+ */
9579
+ createWorkerDataSource(config) {
9580
+ if (!this.dataSourceWorkerProvider)
9581
+ return null;
9582
+ const instanceId = `ds-${nextTrackId}-${Date.now()}`;
9583
+ this.dataSourceWorkerProvider.create(instanceId, config);
9584
+ const proxy = new WorkerDataSource(this.dataSourceWorkerProvider, instanceId);
9585
+ if (this.genome) {
9586
+ proxy.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
9587
+ }
9588
+ if (this.cumulativeOffsets) {
9589
+ proxy.setCumulativeOffsets(this.cumulativeOffsets);
9590
+ }
9591
+ return proxy;
9592
+ }
9367
9593
  /** Clamp a locus to valid chromosome bounds. No-op if chromSizes is not set. */
9368
9594
  clamp(locus) {
9369
9595
  return this.chromSizes ? clampLocus(locus, this.chromSizes, this.cumulativeOffsets) : locus;
@@ -9387,7 +9613,7 @@ class HeadlessGenomeBrowser {
9387
9613
  this.loadAllTracksIfNeeded();
9388
9614
  }
9389
9615
  /** Add a track with an optional data source for automatic data management. */
9390
- addTrack(track, dataSource, dataSourceConfig, maxTrackHeight) {
9616
+ addTrack(track, dataSource, dataSourceConfig, maxTrackHeight, order) {
9391
9617
  var _a;
9392
9618
  const id = generateTrackId(track.type);
9393
9619
  const mt = {
@@ -9398,8 +9624,10 @@ class HeadlessGenomeBrowser {
9398
9624
  cache: null,
9399
9625
  abortController: null,
9400
9626
  maxTrackHeight,
9627
+ order,
9401
9628
  };
9402
9629
  this.managedTracks.push(mt);
9630
+ this.sortTracks();
9403
9631
  track.setLocus(this._locus);
9404
9632
  this.events.emit(BrowserEvent.TrackAdded, { track });
9405
9633
  // Trigger initial data load if data source provided
@@ -9417,6 +9645,9 @@ class HeadlessGenomeBrowser {
9417
9645
  const mt = this.managedTracks[idx];
9418
9646
  if (mt.abortController)
9419
9647
  mt.abortController.abort();
9648
+ if (mt.dataSource instanceof WorkerDataSource && this.dataSourceWorkerProvider) {
9649
+ this.dataSourceWorkerProvider.destroy(mt.dataSource.instanceId);
9650
+ }
9420
9651
  this.managedTracks.splice(idx, 1);
9421
9652
  this.events.emit(BrowserEvent.TrackRemoved, { track: mt.track });
9422
9653
  }
@@ -9434,8 +9665,42 @@ class HeadlessGenomeBrowser {
9434
9665
  return;
9435
9666
  const [moved] = this.managedTracks.splice(fromIndex, 1);
9436
9667
  this.managedTracks.splice(clampedIndex, 0, moved);
9668
+ this._assignOrderFromPosition();
9437
9669
  this.events.emit(BrowserEvent.TrackOrderChanged, { tracks: this.managedTracks.map(mt => mt.track) });
9438
9670
  }
9671
+ /**
9672
+ * Sort tracks by priority (descending): positive = top, 0 = middle, negative = bottom.
9673
+ * Priority comes from the track type default + per-track `order` override.
9674
+ * Within the same effective priority, insertion order is preserved (stable sort).
9675
+ */
9676
+ sortTracks() {
9677
+ if (this._deferSort)
9678
+ return;
9679
+ this.managedTracks.sort((a, b) => {
9680
+ var _a, _b;
9681
+ const pa = ((_a = a.order) !== null && _a !== void 0 ? _a : 0) + trackTypePriority(a.track.type);
9682
+ const pb = ((_b = b.order) !== null && _b !== void 0 ? _b : 0) + trackTypePriority(b.track.type);
9683
+ return pb - pa; // descending: higher priority first
9684
+ });
9685
+ this.onTracksSorted();
9686
+ }
9687
+ /** Hook for subclasses to react to track order changes (e.g., DOM reorder). */
9688
+ onTracksSorted() { }
9689
+ /**
9690
+ * Persist current array positions into mt.order so future sorts preserve
9691
+ * manual reordering. Uses descending values (top track = highest) scaled
9692
+ * above the type-priority range so explicit order dominates.
9693
+ */
9694
+ _assignOrderFromPosition() {
9695
+ const n = this.managedTracks.length;
9696
+ for (let i = 0; i < n; i++) {
9697
+ this.managedTracks[i].order = (n - i) * 10;
9698
+ }
9699
+ }
9700
+ /** Find the ManagedTrack entry for a given track canvas. */
9701
+ findMT(track) {
9702
+ return this.managedTracks.find(mt => mt.track === track);
9703
+ }
9439
9704
  /** Get the current track order. */
9440
9705
  getTrackOrder() {
9441
9706
  return this.managedTracks.map(mt => mt.track);
@@ -9500,7 +9765,7 @@ class HeadlessGenomeBrowser {
9500
9765
  for (const mt of this.managedTracks) {
9501
9766
  mt.track.setLocus(this._locus);
9502
9767
  }
9503
- this.loadAllTracksIfNeeded();
9768
+ this.debouncedLoad();
9504
9769
  this.events.emit(BrowserEvent.LocusChange, { locus: this._locus });
9505
9770
  }
9506
9771
  /**
@@ -9745,7 +10010,8 @@ class HeadlessGenomeBrowser {
9745
10010
  serialized.order = mt.order;
9746
10011
  if (mt.metadata)
9747
10012
  serialized.metadata = mt.metadata;
9748
- if (mt.dataSourceConfig && 'dataSource' in serialized) {
10013
+ if (mt.dataSourceConfig
10014
+ && serialized.type !== 'ruler' && serialized.type !== 'sequence') {
9749
10015
  serialized.dataSource = mt.dataSourceConfig;
9750
10016
  }
9751
10017
  tracks.push(serialized);
@@ -9791,35 +10057,49 @@ class HeadlessGenomeBrowser {
9791
10057
  // Recreate tracks from session config
9792
10058
  const trackOptions = {
9793
10059
  canvasProvider: this.canvasProvider,
9794
- workerProvider: this.workerProvider,
9795
10060
  theme: options === null || options === void 0 ? void 0 : options.theme,
9796
10061
  sequenceProvider: this.sequenceProvider,
9797
10062
  };
9798
- for (const trackConfig of session.tracks) {
9799
- const created = createTrackFromSession(trackConfig, this._locus, trackOptions);
9800
- // Wire chromosome alias resolution for data sources that support it
9801
- if (this.genome && created.dataSource) {
9802
- if (created.dataSource instanceof BigWigDataSource || created.dataSource instanceof GtxDataSource) {
9803
- created.dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
10063
+ this._deferSort = true;
10064
+ try {
10065
+ for (const trackConfig of session.tracks) {
10066
+ const created = createTrackFromSession(trackConfig, this._locus, trackOptions);
10067
+ // Use worker proxy for worker-eligible data sources
10068
+ let dataSource = created.dataSource;
10069
+ if (dataSource && created.dataSourceConfig && this.dataSourceWorkerProvider) {
10070
+ const dsType = created.dataSourceConfig.type;
10071
+ if (dsType === 'bigwig' || dsType === 'gtx' || dsType === 'text' || dsType === 'ucsc') {
10072
+ const workerDS = this.createWorkerDataSource(created.dataSourceConfig);
10073
+ if (workerDS)
10074
+ dataSource = workerDS;
10075
+ }
9804
10076
  }
9805
- else if (created.dataSource instanceof TextFeatureSource) {
9806
- created.dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
9807
- if (this.cumulativeOffsets) {
9808
- created.dataSource.setCumulativeOffsets(this.cumulativeOffsets);
10077
+ // Wire chromosome alias resolution for non-worker data sources
10078
+ if (!this.dataSourceWorkerProvider && this.genome && dataSource) {
10079
+ if (dataSource instanceof BigWigDataSource || dataSource instanceof GtxDataSource) {
10080
+ dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
10081
+ }
10082
+ else if (dataSource instanceof TextFeatureSource) {
10083
+ dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
10084
+ if (this.cumulativeOffsets) {
10085
+ dataSource.setCumulativeOffsets(this.cumulativeOffsets);
10086
+ }
9809
10087
  }
9810
10088
  }
10089
+ this.addTrack(created.track, dataSource !== null && dataSource !== void 0 ? dataSource : undefined, (_a = created.dataSourceConfig) !== null && _a !== void 0 ? _a : undefined, undefined, (_b = created.order) !== null && _b !== void 0 ? _b : undefined);
10090
+ // Restore bookkeeping fields for round-trip serialization
10091
+ const mt = this.findMT(created.track);
10092
+ if (trackConfig.id)
10093
+ mt.id = trackConfig.id;
10094
+ if (created.name)
10095
+ mt.name = created.name;
10096
+ if (trackConfig.metadata)
10097
+ mt.metadata = trackConfig.metadata;
9811
10098
  }
9812
- this.addTrack(created.track, (_a = created.dataSource) !== null && _a !== void 0 ? _a : undefined, (_b = created.dataSourceConfig) !== null && _b !== void 0 ? _b : undefined);
9813
- // Restore bookkeeping fields for round-trip serialization
9814
- const mt = this.managedTracks[this.managedTracks.length - 1];
9815
- if (trackConfig.id)
9816
- mt.id = trackConfig.id;
9817
- if (created.name)
9818
- mt.name = created.name;
9819
- if (created.order != null)
9820
- mt.order = created.order;
9821
- if (trackConfig.metadata)
9822
- mt.metadata = trackConfig.metadata;
10099
+ }
10100
+ finally {
10101
+ this._deferSort = false;
10102
+ this.sortTracks();
9823
10103
  }
9824
10104
  // Restore ROI sets
9825
10105
  if (session.rois) {
@@ -9861,30 +10141,37 @@ class HeadlessGenomeBrowser {
9861
10141
  var _a, _b;
9862
10142
  const created = createTrackFromConfig(trackConfig, this._locus, {
9863
10143
  canvasProvider: this.canvasProvider,
9864
- workerProvider: this.workerProvider,
9865
10144
  theme: this.theme,
9866
10145
  sequenceProvider: this.sequenceProvider,
9867
10146
  });
9868
- // Wire chromosome alias resolution for data sources that support it
9869
- if (this.genome && created.dataSource) {
9870
- if (created.dataSource instanceof BigWigDataSource) {
9871
- created.dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
9872
- }
9873
- else if (created.dataSource instanceof TextFeatureSource) {
9874
- created.dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
10147
+ // Use worker proxy for worker-eligible data sources
10148
+ let dataSource = created.dataSource;
10149
+ if (dataSource && created.dataSourceConfig && this.dataSourceWorkerProvider) {
10150
+ const dsType = created.dataSourceConfig.type;
10151
+ if (dsType === 'bigwig' || dsType === 'gtx' || dsType === 'text' || dsType === 'ucsc') {
10152
+ const workerDS = this.createWorkerDataSource(created.dataSourceConfig);
10153
+ if (workerDS)
10154
+ dataSource = workerDS;
10155
+ }
10156
+ }
10157
+ // Wire chromosome alias resolution for non-worker data sources
10158
+ if (!this.dataSourceWorkerProvider && this.genome && dataSource) {
10159
+ if (dataSource instanceof BigWigDataSource) {
10160
+ dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
10161
+ }
10162
+ else if (dataSource instanceof TextFeatureSource) {
10163
+ dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
9875
10164
  if (this.cumulativeOffsets) {
9876
- created.dataSource.setCumulativeOffsets(this.cumulativeOffsets);
10165
+ dataSource.setCumulativeOffsets(this.cumulativeOffsets);
9877
10166
  }
9878
10167
  }
9879
10168
  }
9880
- this.addTrack(created.track, (_a = created.dataSource) !== null && _a !== void 0 ? _a : undefined, (_b = created.dataSourceConfig) !== null && _b !== void 0 ? _b : undefined);
9881
- const mt = this.managedTracks[this.managedTracks.length - 1];
10169
+ this.addTrack(created.track, dataSource !== null && dataSource !== void 0 ? dataSource : undefined, (_a = created.dataSourceConfig) !== null && _a !== void 0 ? _a : undefined, undefined, (_b = created.order) !== null && _b !== void 0 ? _b : undefined);
10170
+ const mt = this.findMT(created.track);
9882
10171
  if (trackConfig.id)
9883
10172
  mt.id = trackConfig.id;
9884
10173
  if (created.name)
9885
10174
  mt.name = created.name;
9886
- if (created.order != null)
9887
- mt.order = created.order;
9888
10175
  if (trackConfig.metadata)
9889
10176
  mt.metadata = trackConfig.metadata;
9890
10177
  return created.track;
@@ -9920,22 +10207,29 @@ class HeadlessGenomeBrowser {
9920
10207
  name: options === null || options === void 0 ? void 0 : options.name,
9921
10208
  sequenceProvider: this.sequenceProvider,
9922
10209
  });
9923
- const dataSource = new BigWigDataSource(url, windowFunction, this.workerProvider);
9924
- if (this.cumulativeOffsets) {
9925
- dataSource.setCumulativeOffsets(this.cumulativeOffsets);
9926
- }
9927
- if (this.genome) {
9928
- dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
9929
- }
9930
10210
  const dataSourceConfig = {
9931
10211
  type: 'bigwig', url, windowFunction,
9932
10212
  };
10213
+ const workerDS = this.createWorkerDataSource(dataSourceConfig);
10214
+ const dataSource = workerDS !== null && workerDS !== void 0 ? workerDS : (() => {
10215
+ const ds = new BigWigDataSource(url, windowFunction);
10216
+ if (this.cumulativeOffsets)
10217
+ ds.setCumulativeOffsets(this.cumulativeOffsets);
10218
+ if (this.genome)
10219
+ ds.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
10220
+ return ds;
10221
+ })();
9933
10222
  this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
9934
10223
  if (options === null || options === void 0 ? void 0 : options.metadata)
9935
- this.managedTracks[this.managedTracks.length - 1].metadata = options.metadata;
10224
+ this.findMT(track).metadata = options.metadata;
9936
10225
  // Wire windowing function callback: update data source, invalidate cache, re-fetch
9937
10226
  track.onWindowFunctionChange = (wf) => {
9938
- dataSource.setWindowFunction(wf);
10227
+ if (workerDS) {
10228
+ workerDS.setWindowFunction(wf);
10229
+ }
10230
+ else {
10231
+ dataSource.setWindowFunction(wf);
10232
+ }
9939
10233
  const mt = this.managedTracks.find(m => m.track === track);
9940
10234
  if (mt) {
9941
10235
  mt.cache = null;
@@ -9963,22 +10257,29 @@ class HeadlessGenomeBrowser {
9963
10257
  name: options.name,
9964
10258
  sequenceProvider: this.sequenceProvider,
9965
10259
  });
9966
- const dataSource = new GtxDataSource(url, options.experimentId, windowFunction, this.workerProvider);
9967
- if (this.cumulativeOffsets) {
9968
- dataSource.setCumulativeOffsets(this.cumulativeOffsets);
9969
- }
9970
- if (this.genome) {
9971
- dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
9972
- }
9973
10260
  const dataSourceConfig = {
9974
10261
  type: 'gtx', url, experimentId: options.experimentId, windowFunction,
9975
10262
  };
10263
+ const workerDS = this.createWorkerDataSource(dataSourceConfig);
10264
+ const dataSource = workerDS !== null && workerDS !== void 0 ? workerDS : (() => {
10265
+ const ds = new GtxDataSource(url, options.experimentId, windowFunction);
10266
+ if (this.cumulativeOffsets)
10267
+ ds.setCumulativeOffsets(this.cumulativeOffsets);
10268
+ if (this.genome)
10269
+ ds.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
10270
+ return ds;
10271
+ })();
9976
10272
  this.addTrack(track, dataSource, dataSourceConfig, options.maxTrackHeight);
9977
10273
  if (options.metadata)
9978
- this.managedTracks[this.managedTracks.length - 1].metadata = options.metadata;
10274
+ this.findMT(track).metadata = options.metadata;
9979
10275
  // Wire windowing function callback
9980
10276
  track.onWindowFunctionChange = (wf) => {
9981
- dataSource.setWindowFunction(wf);
10277
+ if (workerDS) {
10278
+ workerDS.setWindowFunction(wf);
10279
+ }
10280
+ else {
10281
+ dataSource.setWindowFunction(wf);
10282
+ }
9982
10283
  const mt = this.managedTracks.find(m => m.track === track);
9983
10284
  if (mt) {
9984
10285
  mt.cache = null;
@@ -10002,21 +10303,23 @@ class HeadlessGenomeBrowser {
10002
10303
  background: options === null || options === void 0 ? void 0 : options.background,
10003
10304
  theme: this.theme,
10004
10305
  canvasProvider: this.canvasProvider,
10005
- workerProvider: this.workerProvider,
10006
10306
  name: (_a = options === null || options === void 0 ? void 0 : options.name) !== null && _a !== void 0 ? _a : 'Genes',
10007
10307
  });
10008
10308
  const genome = options === null || options === void 0 ? void 0 : options.genome;
10009
10309
  const ucscTrack = options === null || options === void 0 ? void 0 : options.track;
10010
- const dataSource = new GeneDataSource({ genome, track: ucscTrack });
10011
- if (this.cumulativeOffsets) {
10012
- dataSource.setCumulativeOffsets(this.cumulativeOffsets);
10013
- }
10014
10310
  const dataSourceConfig = {
10015
10311
  type: 'ucsc', genome, track: ucscTrack,
10016
10312
  };
10017
- this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
10313
+ const workerDS = this.createWorkerDataSource(dataSourceConfig);
10314
+ const dataSource = workerDS !== null && workerDS !== void 0 ? workerDS : (() => {
10315
+ const ds = new GeneDataSource({ genome, track: ucscTrack });
10316
+ if (this.cumulativeOffsets)
10317
+ ds.setCumulativeOffsets(this.cumulativeOffsets);
10318
+ return ds;
10319
+ })();
10320
+ this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight, -1);
10018
10321
  if (options === null || options === void 0 ? void 0 : options.metadata)
10019
- this.managedTracks[this.managedTracks.length - 1].metadata = options.metadata;
10322
+ this.findMT(track).metadata = options.metadata;
10020
10323
  return track;
10021
10324
  }
10022
10325
  /** Add a BED/peak annotation track from a URL. Supports plain text and tabix-indexed files. */
@@ -10031,28 +10334,23 @@ class HeadlessGenomeBrowser {
10031
10334
  background: options === null || options === void 0 ? void 0 : options.background,
10032
10335
  theme: this.theme,
10033
10336
  canvasProvider: this.canvasProvider,
10034
- workerProvider: this.workerProvider,
10035
10337
  name: options === null || options === void 0 ? void 0 : options.name,
10036
10338
  });
10037
- const dataSource = new TextFeatureSource({
10038
- url,
10039
- format,
10040
- indexURL: options === null || options === void 0 ? void 0 : options.indexURL,
10041
- indexed: options === null || options === void 0 ? void 0 : options.indexed,
10042
- workerProvider: this.workerProvider,
10043
- });
10044
- if (this.genome) {
10045
- dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
10046
- }
10047
- if (this.cumulativeOffsets) {
10048
- dataSource.setCumulativeOffsets(this.cumulativeOffsets);
10049
- }
10050
10339
  const dataSourceConfig = {
10051
10340
  type: 'text', url, format, indexURL: options === null || options === void 0 ? void 0 : options.indexURL, indexed: options === null || options === void 0 ? void 0 : options.indexed,
10052
10341
  };
10342
+ const workerDS = this.createWorkerDataSource(dataSourceConfig);
10343
+ const dataSource = workerDS !== null && workerDS !== void 0 ? workerDS : (() => {
10344
+ const ds = new TextFeatureSource({ url, format, indexURL: options === null || options === void 0 ? void 0 : options.indexURL, indexed: options === null || options === void 0 ? void 0 : options.indexed });
10345
+ if (this.genome)
10346
+ ds.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
10347
+ if (this.cumulativeOffsets)
10348
+ ds.setCumulativeOffsets(this.cumulativeOffsets);
10349
+ return ds;
10350
+ })();
10053
10351
  this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
10054
10352
  if (options === null || options === void 0 ? void 0 : options.metadata)
10055
- this.managedTracks[this.managedTracks.length - 1].metadata = options.metadata;
10353
+ this.findMT(track).metadata = options.metadata;
10056
10354
  return track;
10057
10355
  }
10058
10356
  /** Add an interaction (arc/BEDPE) track from a URL. */
@@ -10068,25 +10366,74 @@ class HeadlessGenomeBrowser {
10068
10366
  canvasProvider: this.canvasProvider,
10069
10367
  name: options === null || options === void 0 ? void 0 : options.name,
10070
10368
  });
10071
- const dataSource = new TextFeatureSource({
10072
- url,
10073
- format,
10074
- indexURL: options === null || options === void 0 ? void 0 : options.indexURL,
10075
- indexed: options === null || options === void 0 ? void 0 : options.indexed,
10076
- workerProvider: this.workerProvider,
10369
+ const dataSourceConfig = {
10370
+ type: 'text', url, format, indexURL: options === null || options === void 0 ? void 0 : options.indexURL, indexed: options === null || options === void 0 ? void 0 : options.indexed,
10371
+ };
10372
+ const workerDS = this.createWorkerDataSource(dataSourceConfig);
10373
+ const dataSource = workerDS !== null && workerDS !== void 0 ? workerDS : (() => {
10374
+ const ds = new TextFeatureSource({ url, format, indexURL: options === null || options === void 0 ? void 0 : options.indexURL, indexed: options === null || options === void 0 ? void 0 : options.indexed });
10375
+ if (this.genome)
10376
+ ds.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
10377
+ if (this.cumulativeOffsets)
10378
+ ds.setCumulativeOffsets(this.cumulativeOffsets);
10379
+ return ds;
10380
+ })();
10381
+ this.addTrack(track, dataSource, dataSourceConfig);
10382
+ if (options === null || options === void 0 ? void 0 : options.metadata)
10383
+ this.findMT(track).metadata = options.metadata;
10384
+ return track;
10385
+ }
10386
+ /** Add a BigWig-style signal track backed by in-memory features (no URL required). */
10387
+ addWigTrackWithFeatures(features, options) {
10388
+ const { canvas } = this.canvasProvider.createCanvas(0, 0);
10389
+ const track = new WigTrackCanvas(canvas, {
10390
+ locus: this._locus,
10391
+ features: [],
10392
+ config: options === null || options === void 0 ? void 0 : options.config,
10393
+ height: options === null || options === void 0 ? void 0 : options.height,
10394
+ background: options === null || options === void 0 ? void 0 : options.background,
10395
+ theme: this.theme,
10396
+ canvasProvider: this.canvasProvider,
10397
+ name: options === null || options === void 0 ? void 0 : options.name,
10398
+ sequenceProvider: this.sequenceProvider,
10077
10399
  });
10400
+ const dataSource = new MemoryDataSource(features);
10401
+ if (this.cumulativeOffsets) {
10402
+ dataSource.setCumulativeOffsets(this.cumulativeOffsets);
10403
+ }
10078
10404
  if (this.genome) {
10079
10405
  dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
10080
10406
  }
10407
+ const dataSourceConfig = { type: 'memory' };
10408
+ this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
10409
+ if (options === null || options === void 0 ? void 0 : options.metadata)
10410
+ this.findMT(track).metadata = options.metadata;
10411
+ return track;
10412
+ }
10413
+ /** Add a BED/annotation track backed by in-memory features (no URL required). Features must include `chr`. */
10414
+ addBedTrackWithFeatures(features, options) {
10415
+ const { canvas } = this.canvasProvider.createCanvas(0, 0);
10416
+ const track = new AnnotationTrackCanvas(canvas, {
10417
+ locus: this._locus,
10418
+ features: [],
10419
+ config: options === null || options === void 0 ? void 0 : options.config,
10420
+ height: options === null || options === void 0 ? void 0 : options.height,
10421
+ background: options === null || options === void 0 ? void 0 : options.background,
10422
+ theme: this.theme,
10423
+ canvasProvider: this.canvasProvider,
10424
+ name: options === null || options === void 0 ? void 0 : options.name,
10425
+ });
10426
+ const dataSource = new MemoryDataSource(features);
10081
10427
  if (this.cumulativeOffsets) {
10082
10428
  dataSource.setCumulativeOffsets(this.cumulativeOffsets);
10083
10429
  }
10084
- const dataSourceConfig = {
10085
- type: 'text', url, format, indexURL: options === null || options === void 0 ? void 0 : options.indexURL, indexed: options === null || options === void 0 ? void 0 : options.indexed,
10086
- };
10087
- this.addTrack(track, dataSource, dataSourceConfig);
10430
+ if (this.genome) {
10431
+ dataSource.setChromNameResolver(alias => this.genome.getChromosomeName(alias));
10432
+ }
10433
+ const dataSourceConfig = { type: 'memory' };
10434
+ this.addTrack(track, dataSource, dataSourceConfig, options === null || options === void 0 ? void 0 : options.maxTrackHeight);
10088
10435
  if (options === null || options === void 0 ? void 0 : options.metadata)
10089
- this.managedTracks[this.managedTracks.length - 1].metadata = options.metadata;
10436
+ this.findMT(track).metadata = options.metadata;
10090
10437
  return track;
10091
10438
  }
10092
10439
  /** Add a DNA/RNA sequence track. Data fetching is handled automatically via the genome's sequence provider. */
@@ -10118,6 +10465,10 @@ class HeadlessGenomeBrowser {
10118
10465
  for (const mt of this.managedTracks) {
10119
10466
  if (mt.abortController)
10120
10467
  mt.abortController.abort();
10468
+ // Destroy worker-resident data sources
10469
+ if (mt.dataSource instanceof WorkerDataSource && this.dataSourceWorkerProvider) {
10470
+ this.dataSourceWorkerProvider.destroy(mt.dataSource.instanceId);
10471
+ }
10121
10472
  }
10122
10473
  this.managedTracks = [];
10123
10474
  this.roiSets = [];
@@ -10145,6 +10496,30 @@ class HeadlessGenomeBrowser {
10145
10496
  };
10146
10497
  }
10147
10498
  // ─── Data lifecycle ──────────────────────────────────────────────────────
10499
+ /**
10500
+ * Debounced wrapper around loadAllTracksIfNeeded.
10501
+ * During rapid zoom/pan, this coalesces multiple setLocus() calls into a
10502
+ * single data fetch cycle after the interaction settles (100ms quiet period).
10503
+ * Abort in-flight requests immediately so they don't race with the eventual fetch.
10504
+ */
10505
+ debouncedLoad() {
10506
+ // Immediately abort stale in-flight requests so cancelled fetches
10507
+ // don't resolve after the debounce fires with a new locus.
10508
+ for (const mt of this.managedTracks) {
10509
+ if (mt.abortController) {
10510
+ mt.abortController.abort();
10511
+ mt.abortController = null;
10512
+ }
10513
+ }
10514
+ this.inflightFetches.clear();
10515
+ if (this.loadDebounceTimer !== null) {
10516
+ clearTimeout(this.loadDebounceTimer);
10517
+ }
10518
+ this.loadDebounceTimer = setTimeout(() => {
10519
+ this.loadDebounceTimer = null;
10520
+ this.loadAllTracksIfNeeded();
10521
+ }, 100);
10522
+ }
10148
10523
  loadAllTracksIfNeeded() {
10149
10524
  // Clear stale inflight entries — navigation invalidates all pending fetches
10150
10525
  this.inflightFetches.clear();
@@ -10182,9 +10557,11 @@ class HeadlessGenomeBrowser {
10182
10557
  if (mt.cache && cacheCoversViewport(mt.cache, this._locus, bpPerPixel)) {
10183
10558
  return;
10184
10559
  }
10185
- // Cancel stale in-flight request for this track
10560
+ // Cancel stale in-flight request for this track and clear any
10561
+ // lingering error so it doesn't persist through the new fetch.
10186
10562
  if (mt.abortController)
10187
10563
  mt.abortController.abort();
10564
+ mt.track.setError(null);
10188
10565
  const fetchRegion = bufferLocus(this._locus);
10189
10566
  const dsKey = mt.dataSourceConfig ? dataSourceCacheKey(mt.dataSourceConfig) : null;
10190
10567
  // Check if another track with the same data source already has a valid cache
@@ -10272,6 +10649,222 @@ class HeadlessGenomeBrowser {
10272
10649
  }
10273
10650
  }
10274
10651
 
10652
+ /**
10653
+ * WebWorkerPool — single worker pool for both stateless tasks and stateful data sources.
10654
+ *
10655
+ * Implements both WorkerProvider (for CPU-intensive tasks like pack, summarize) and
10656
+ * DataSourceWorkerProvider (for persistent DataSource instances in workers).
10657
+ *
10658
+ * Routing:
10659
+ * - Stateless tasks: round-robin across pool
10660
+ * - DataSource operations: sticky routing via URL hash (preserves reader caches)
10661
+ *
10662
+ * Usage:
10663
+ * const pool = new WebWorkerPool({
10664
+ * workerFactory: () => new Worker(new URL('./workerPoolScript.ts', import.meta.url)),
10665
+ * poolSize: 4,
10666
+ * })
10667
+ * const browser = new GenomeBrowser(container, { workerProvider: pool })
10668
+ * // browser auto-detects DataSourceWorkerProvider support
10669
+ * pool.dispose()
10670
+ */
10671
+ // ─── Provider ────────────────────────────────────────────────────────────────
10672
+ class WebWorkerPool {
10673
+ constructor(options) {
10674
+ var _a;
10675
+ // Stateless task tracking
10676
+ this.nextId = 0;
10677
+ this.nextWorker = 0;
10678
+ this.pending = new Map();
10679
+ // Stateful data source tracking
10680
+ this.instanceToWorker = new Map();
10681
+ this.pendingFetches = new Map();
10682
+ this.nextFetchId = 0;
10683
+ const count = Math.max(1, (_a = options.poolSize) !== null && _a !== void 0 ? _a : 1);
10684
+ let createWorker;
10685
+ if (options.workerFactory) {
10686
+ createWorker = options.workerFactory;
10687
+ }
10688
+ else if (options.workerUrl) {
10689
+ const url = options.workerUrl;
10690
+ createWorker = () => new Worker(url, { type: 'module' });
10691
+ }
10692
+ else {
10693
+ throw new Error('WebWorkerPoolOptions requires either workerUrl or workerFactory');
10694
+ }
10695
+ this.workers = [];
10696
+ this.readyPromises = [];
10697
+ for (let i = 0; i < count; i++) {
10698
+ const worker = createWorker();
10699
+ // Ready handshake
10700
+ const readyPromise = new Promise((resolveReady) => {
10701
+ const onReady = (e) => {
10702
+ if (e.data.type === 'ready') {
10703
+ resolveReady();
10704
+ }
10705
+ };
10706
+ worker.addEventListener('message', onReady, { once: true });
10707
+ });
10708
+ this.readyPromises.push(readyPromise);
10709
+ worker.onmessage = (e) => {
10710
+ const msg = e.data;
10711
+ if (msg.type === 'ready')
10712
+ return; // handled by one-time listener
10713
+ if (msg.type === 'taskResult') {
10714
+ const p = this.pending.get(msg.id);
10715
+ if (p) {
10716
+ this.pending.delete(msg.id);
10717
+ if (msg.error) {
10718
+ p.reject(new Error(msg.error));
10719
+ }
10720
+ else {
10721
+ p.resolve(msg.result);
10722
+ }
10723
+ }
10724
+ }
10725
+ else if (msg.type === 'fetchResult') {
10726
+ const p = this.pendingFetches.get(msg.fetchId);
10727
+ if (p) {
10728
+ this.pendingFetches.delete(msg.fetchId);
10729
+ p.resolve(msg.features);
10730
+ }
10731
+ }
10732
+ else if (msg.type === 'fetchError') {
10733
+ const p = this.pendingFetches.get(msg.fetchId);
10734
+ if (p) {
10735
+ this.pendingFetches.delete(msg.fetchId);
10736
+ if (msg.error === 'AbortError') {
10737
+ p.reject(new DOMException('Aborted', 'AbortError'));
10738
+ }
10739
+ else {
10740
+ p.reject(new Error(msg.error));
10741
+ }
10742
+ }
10743
+ }
10744
+ };
10745
+ worker.onerror = (e) => {
10746
+ const error = new Error(`Worker error: ${e.message}`);
10747
+ for (const { reject } of this.pending.values())
10748
+ reject(error);
10749
+ this.pending.clear();
10750
+ for (const { reject } of this.pendingFetches.values())
10751
+ reject(error);
10752
+ this.pendingFetches.clear();
10753
+ };
10754
+ this.workers.push(worker);
10755
+ }
10756
+ }
10757
+ /** Number of workers in the pool. */
10758
+ get poolSize() {
10759
+ return this.workers.length;
10760
+ }
10761
+ // ─── WorkerProvider (stateless tasks) ────────────────────────────────────
10762
+ execute(task, transfer) {
10763
+ const id = this.nextId++;
10764
+ const worker = this.workers[this.nextWorker % this.workers.length];
10765
+ this.nextWorker++;
10766
+ return new Promise((resolve, reject) => {
10767
+ this.pending.set(id, {
10768
+ resolve: resolve,
10769
+ reject,
10770
+ });
10771
+ worker.postMessage({ type: 'task', id, task }, transfer !== null && transfer !== void 0 ? transfer : []);
10772
+ });
10773
+ }
10774
+ // ─── DataSourceWorkerProvider (stateful data sources) ────────────────────
10775
+ create(instanceId, config) {
10776
+ const workerIdx = this.routeToWorker(config);
10777
+ this.instanceToWorker.set(instanceId, workerIdx);
10778
+ const worker = this.workers[workerIdx];
10779
+ this.readyPromises[workerIdx].then(() => {
10780
+ worker.postMessage({ type: 'create', instanceId, config });
10781
+ });
10782
+ }
10783
+ async fetch(instanceId, locus, bpPerPixel, signal) {
10784
+ const workerIdx = this.instanceToWorker.get(instanceId);
10785
+ if (workerIdx === undefined) {
10786
+ throw new Error(`No worker assigned for DataSource: ${instanceId}`);
10787
+ }
10788
+ await this.readyPromises[workerIdx];
10789
+ const fetchId = this.nextFetchId++;
10790
+ const worker = this.workers[workerIdx];
10791
+ const onAbort = () => {
10792
+ worker.postMessage({ type: 'cancel', fetchId });
10793
+ const p = this.pendingFetches.get(fetchId);
10794
+ if (p) {
10795
+ this.pendingFetches.delete(fetchId);
10796
+ p.reject(new DOMException('Aborted', 'AbortError'));
10797
+ }
10798
+ };
10799
+ if (signal.aborted) {
10800
+ throw new DOMException('Aborted', 'AbortError');
10801
+ }
10802
+ signal.addEventListener('abort', onAbort, { once: true });
10803
+ try {
10804
+ return await new Promise((resolve, reject) => {
10805
+ this.pendingFetches.set(fetchId, {
10806
+ resolve: resolve,
10807
+ reject,
10808
+ });
10809
+ worker.postMessage({ type: 'fetch', instanceId, fetchId, locus, bpPerPixel });
10810
+ });
10811
+ }
10812
+ finally {
10813
+ signal.removeEventListener('abort', onAbort);
10814
+ }
10815
+ }
10816
+ configure(instanceId, method, ...args) {
10817
+ const workerIdx = this.instanceToWorker.get(instanceId);
10818
+ if (workerIdx === undefined)
10819
+ return;
10820
+ const worker = this.workers[workerIdx];
10821
+ this.readyPromises[workerIdx].then(() => {
10822
+ worker.postMessage({ type: 'configure', instanceId, method, args });
10823
+ });
10824
+ }
10825
+ destroy(instanceId) {
10826
+ const workerIdx = this.instanceToWorker.get(instanceId);
10827
+ if (workerIdx === undefined)
10828
+ return;
10829
+ const worker = this.workers[workerIdx];
10830
+ worker.postMessage({ type: 'destroy', instanceId });
10831
+ this.instanceToWorker.delete(instanceId);
10832
+ }
10833
+ // ─── Shared ──────────────────────────────────────────────────────────────
10834
+ dispose() {
10835
+ for (const worker of this.workers) {
10836
+ worker.terminate();
10837
+ }
10838
+ for (const { reject } of this.pending.values()) {
10839
+ reject(new Error('Worker terminated'));
10840
+ }
10841
+ this.pending.clear();
10842
+ for (const { reject } of this.pendingFetches.values()) {
10843
+ reject(new Error('Worker terminated'));
10844
+ }
10845
+ this.pendingFetches.clear();
10846
+ this.instanceToWorker.clear();
10847
+ this.workers = [];
10848
+ }
10849
+ // ─── Internal ────────────────────────────────────────────────────────────
10850
+ /**
10851
+ * Sticky routing: deterministically assign a DataSourceConfig to a worker
10852
+ * based on its URL. Preserves reader caches (BigWig headers, Tabix indices).
10853
+ */
10854
+ routeToWorker(config) {
10855
+ const key = 'url' in config ? config.url : config.type;
10856
+ return Math.abs(hashString(key)) % this.workers.length;
10857
+ }
10858
+ }
10859
+ /** Simple string hash (djb2). */
10860
+ function hashString(s) {
10861
+ let hash = 5381;
10862
+ for (let i = 0; i < s.length; i++) {
10863
+ hash = ((hash << 5) + hash + s.charCodeAt(i)) | 0;
10864
+ }
10865
+ return hash;
10866
+ }
10867
+
10275
10868
  /**
10276
10869
  * CommandDispatcher — transport-agnostic command dispatch for HeadlessGenomeBrowser.
10277
10870
  *
@@ -10742,9 +11335,8 @@ class RemoteConnection {
10742
11335
  * These are pure canvas renderers — they take a 2D context, axis info, and
10743
11336
  * dimensions, and paint the axis. No DOM, no state, no side effects.
10744
11337
  *
10745
- * Two variants:
10746
- * - renderQuantitativeAxis: tick marks + data range labels for numeric tracks (wig)
10747
- * - renderLabelAxis: centered rotated text label for annotation tracks (gene)
11338
+ * renderQuantitativeAxis: background, color strip, track label, and
11339
+ * tick marks + data range labels (when data is available) for numeric tracks.
10748
11340
  */
10749
11341
  /** Width of the color strip indicator on the right edge of the axis. */
10750
11342
  const COLOR_STRIP_WIDTH = 4;
@@ -10763,13 +11355,10 @@ function prettyPrintNumber(n) {
10763
11355
  return n.toExponential(1);
10764
11356
  }
10765
11357
  /**
10766
- * Paint a quantitative axis with vertical line, tick marks, and data range labels.
11358
+ * Paint a quantitative axis.
10767
11359
  *
10768
- * Requires `info.dataRange` to be set. Renders:
10769
- * - White background
10770
- * - Color strip on right edge (if info.color is set)
10771
- * - Vertical axis line with top/bottom ticks and labels (max/min)
10772
- * - Middle tick at midpoint (if height > 60)
11360
+ * Always renders: background and color strip (if info.color).
11361
+ * When `info.dataRange` is set, also renders: vertical axis line, tick marks, and min/max/mid labels.
10773
11362
  */
10774
11363
  /** Transform a data value to log space, matching wigRenderer's computeYPixel logic. */
10775
11364
  function toLogValue(v) {
@@ -10793,19 +11382,10 @@ function valueToY(value, min, max, topY, bottomY, flip, logScale) {
10793
11382
  }
10794
11383
  function renderQuantitativeAxis(ctx, info, width, height) {
10795
11384
  var _a, _b, _c, _d;
10796
- if (!info.dataRange || height === 0)
11385
+ if (height === 0)
10797
11386
  return;
10798
- const { min, max } = info.dataRange;
10799
- const flip = (_a = info.flipAxis) !== null && _a !== void 0 ? _a : false;
10800
- const logScale = (_b = info.logScale) !== null && _b !== void 0 ? _b : false;
10801
- const shim = 0.01;
10802
- const topY = shim * height;
10803
- const bottomY = (1.0 - shim) * height;
10804
- // When flipped, min is at top and max is at bottom (used by GWAS/QTL tracks)
10805
- const topValue = flip ? min : max;
10806
- const bottomValue = flip ? max : min;
10807
- const bg = (_c = info.backgroundColor) !== null && _c !== void 0 ? _c : 'white';
10808
- const fg = (_d = info.labelColor) !== null && _d !== void 0 ? _d : 'black';
11387
+ const bg = (_a = info.backgroundColor) !== null && _a !== void 0 ? _a : 'white';
11388
+ const fg = (_b = info.labelColor) !== null && _b !== void 0 ? _b : 'black';
10809
11389
  // Clear with background
10810
11390
  ctx.fillStyle = bg;
10811
11391
  ctx.fillRect(0, 0, width, height);
@@ -10814,6 +11394,9 @@ function renderQuantitativeAxis(ctx, info, width, height) {
10814
11394
  ctx.fillStyle = info.color;
10815
11395
  ctx.fillRect(width - COLOR_STRIP_WIDTH - 1, 0, COLOR_STRIP_WIDTH, height);
10816
11396
  }
11397
+ const shim = 0.01;
11398
+ const topY = shim * height;
11399
+ const bottomY = (1.0 - shim) * height;
10817
11400
  const tickEnd = width - COLOR_STRIP_WIDTH - 3;
10818
11401
  const tickStart = tickEnd - 6;
10819
11402
  ctx.strokeStyle = fg;
@@ -10821,30 +11404,37 @@ function renderQuantitativeAxis(ctx, info, width, height) {
10821
11404
  ctx.font = 'normal 9px Arial';
10822
11405
  ctx.textAlign = 'right';
10823
11406
  ctx.lineWidth = 1;
10824
- // Vertical axis line
11407
+ // Vertical axis line + top/bottom ticks (always drawn)
10825
11408
  ctx.beginPath();
10826
11409
  ctx.moveTo(tickEnd, topY);
10827
11410
  ctx.lineTo(tickEnd, bottomY);
10828
11411
  ctx.stroke();
10829
- // Top tick + label
10830
11412
  ctx.beginPath();
10831
11413
  ctx.moveTo(tickStart, topY);
10832
11414
  ctx.lineTo(tickEnd, topY);
10833
11415
  ctx.stroke();
10834
- ctx.textBaseline = 'top';
10835
- ctx.fillText(prettyPrintNumber(topValue), tickStart - 2, topY + 1);
10836
- // Bottom tick + label
10837
11416
  ctx.beginPath();
10838
11417
  ctx.moveTo(tickStart, bottomY);
10839
11418
  ctx.lineTo(tickEnd, bottomY);
10840
11419
  ctx.stroke();
11420
+ // Data range labels — only when we have data
11421
+ if (!info.dataRange)
11422
+ return;
11423
+ const { min, max } = info.dataRange;
11424
+ const flip = (_c = info.flipAxis) !== null && _c !== void 0 ? _c : false;
11425
+ const logScale = (_d = info.logScale) !== null && _d !== void 0 ? _d : false;
11426
+ const topValue = flip ? min : max;
11427
+ const bottomValue = flip ? max : min;
11428
+ // Top label
11429
+ ctx.textBaseline = 'top';
11430
+ ctx.fillText(prettyPrintNumber(topValue), tickStart - 2, topY + 1);
11431
+ // Bottom label
10841
11432
  ctx.textBaseline = 'bottom';
10842
11433
  ctx.fillText(prettyPrintNumber(bottomValue), tickStart - 2, bottomY - 1);
10843
- // Middle tick linear midpoint or log-space midpoint
11434
+ // Middle tick + label
10844
11435
  if (height > 60) {
10845
11436
  const midVal = logScale
10846
11437
  ? (() => {
10847
- // Midpoint in log space, converted back to data space
10848
11438
  const logMid = (toLogValue(min) + toLogValue(max)) / 2;
10849
11439
  return logMid >= 0 ? Math.pow(10, logMid) - 1 : -(Math.pow(10, -logMid) - 1);
10850
11440
  })()
@@ -10858,31 +11448,6 @@ function renderQuantitativeAxis(ctx, info, width, height) {
10858
11448
  ctx.fillText(prettyPrintNumber(midVal), tickStart + 1, midY);
10859
11449
  }
10860
11450
  }
10861
- /**
10862
- * Paint a label-only axis (e.g., gene track name).
10863
- * Renders a vertically-rotated centered text label.
10864
- */
10865
- function renderLabelAxis(ctx, info, width, height) {
10866
- var _a, _b;
10867
- if (!info.label || height === 0)
10868
- return;
10869
- const bg = (_a = info.backgroundColor) !== null && _a !== void 0 ? _a : 'white';
10870
- const fg = (_b = info.labelColor) !== null && _b !== void 0 ? _b : '#333';
10871
- // Clear with background
10872
- ctx.fillStyle = bg;
10873
- ctx.fillRect(0, 0, width, height);
10874
- ctx.font = '10px sans-serif';
10875
- ctx.fillStyle = fg;
10876
- ctx.textAlign = 'center';
10877
- ctx.textBaseline = 'middle';
10878
- ctx.save();
10879
- ctx.translate(width / 2, height / 2);
10880
- ctx.rotate(-Math.PI / 2);
10881
- // Clip text to available height
10882
- const maxTextWidth = height - 10;
10883
- ctx.fillText(info.label, 0, 0, maxTextWidth);
10884
- ctx.restore();
10885
- }
10886
11451
 
10887
11452
  /**
10888
11453
  * Common context menu item factories.
@@ -11017,6 +11582,157 @@ function roiContextMenuItems(roi, callbacks) {
11017
11582
  ];
11018
11583
  }
11019
11584
 
11585
+ /**
11586
+ * SVG overlay for interactive feature highlighting.
11587
+ *
11588
+ * Positions transparent SVG `<rect>` elements on top of a track's canvas,
11589
+ * providing native CSS `:hover` states and click targets without canvas repaints.
11590
+ * Follows the same DOM-over-canvas pattern used by ROI overlays in GenomeBrowser.
11591
+ *
11592
+ * On hover, features get a tinted fill matching their own color plus a subtle
11593
+ * glow/shadow effect, making the canvas-rendered feature feel highlighted.
11594
+ */
11595
+ const SVG_NS = 'http://www.w3.org/2000/svg';
11596
+ /** Default glow color when feature has no color. */
11597
+ const DEFAULT_GLOW_COLOR = 'rgb(0, 0, 150)';
11598
+ /** Padding (px) added around the feature rect for the glow area. */
11599
+ const GLOW_PADDING = 3;
11600
+ class SVGFeatureOverlay {
11601
+ constructor(container) {
11602
+ this.svg = document.createElementNS(SVG_NS, 'svg');
11603
+ this.svg.classList.add('loom-feature-overlay');
11604
+ this.svg.style.cssText = `
11605
+ position: absolute;
11606
+ top: 0;
11607
+ left: 0;
11608
+ width: 100%;
11609
+ height: 100%;
11610
+ pointer-events: none;
11611
+ overflow: visible;
11612
+ `;
11613
+ this.defs = document.createElementNS(SVG_NS, 'defs');
11614
+ this.svg.appendChild(this.defs);
11615
+ this.group = document.createElementNS(SVG_NS, 'g');
11616
+ this.svg.appendChild(this.group);
11617
+ container.style.position = 'relative';
11618
+ container.appendChild(this.svg);
11619
+ }
11620
+ /** Rebuild SVG rects from feature geometry. */
11621
+ update(rects) {
11622
+ var _a;
11623
+ this.group.textContent = '';
11624
+ this.defs.textContent = '';
11625
+ if (rects.length === 0 || rects.length > 500)
11626
+ return;
11627
+ const frag = document.createDocumentFragment();
11628
+ for (let i = 0; i < rects.length; i++) {
11629
+ const rect = rects[i];
11630
+ const color = (_a = rect.color) !== null && _a !== void 0 ? _a : DEFAULT_GLOW_COLOR;
11631
+ const filterId = `glow-${i}`;
11632
+ this.defs.appendChild(this.createGlowFilter(filterId, color));
11633
+ frag.appendChild(this.createRect(rect, filterId));
11634
+ }
11635
+ this.group.appendChild(frag);
11636
+ }
11637
+ /** Suppress pointer events on overlay rects (e.g., during drag). */
11638
+ setSuppressed(suppressed) {
11639
+ this.group.style.pointerEvents = suppressed ? 'none' : '';
11640
+ }
11641
+ dispose() {
11642
+ this.svg.remove();
11643
+ }
11644
+ /** Create an SVG filter that produces a colored glow. */
11645
+ createGlowFilter(id, color) {
11646
+ const filter = document.createElementNS(SVG_NS, 'filter');
11647
+ filter.setAttribute('id', id);
11648
+ // Expand filter region to allow glow beyond rect bounds
11649
+ filter.setAttribute('x', '-20%');
11650
+ filter.setAttribute('y', '-40%');
11651
+ filter.setAttribute('width', '140%');
11652
+ filter.setAttribute('height', '180%');
11653
+ // Flood with feature color
11654
+ const flood = document.createElementNS(SVG_NS, 'feFlood');
11655
+ flood.setAttribute('flood-color', color);
11656
+ flood.setAttribute('flood-opacity', '0.4');
11657
+ flood.setAttribute('result', 'color');
11658
+ filter.appendChild(flood);
11659
+ // Clip flood to rect shape
11660
+ const composite = document.createElementNS(SVG_NS, 'feComposite');
11661
+ composite.setAttribute('in', 'color');
11662
+ composite.setAttribute('in2', 'SourceGraphic');
11663
+ composite.setAttribute('operator', 'in');
11664
+ composite.setAttribute('result', 'colored');
11665
+ filter.appendChild(composite);
11666
+ // Blur for glow
11667
+ const blur = document.createElementNS(SVG_NS, 'feGaussianBlur');
11668
+ blur.setAttribute('in', 'colored');
11669
+ blur.setAttribute('stdDeviation', '3');
11670
+ blur.setAttribute('result', 'glow');
11671
+ filter.appendChild(blur);
11672
+ // Layer: glow behind, then original rect on top
11673
+ const merge = document.createElementNS(SVG_NS, 'feMerge');
11674
+ const node1 = document.createElementNS(SVG_NS, 'feMergeNode');
11675
+ node1.setAttribute('in', 'glow');
11676
+ const node2 = document.createElementNS(SVG_NS, 'feMergeNode');
11677
+ node2.setAttribute('in', 'SourceGraphic');
11678
+ merge.appendChild(node1);
11679
+ merge.appendChild(node2);
11680
+ filter.appendChild(merge);
11681
+ return filter;
11682
+ }
11683
+ createRect(rect, filterId) {
11684
+ var _a;
11685
+ const el = document.createElementNS(SVG_NS, 'rect');
11686
+ // Expand the rect slightly so the hover target covers the full feature
11687
+ // and the glow extends naturally beyond.
11688
+ el.setAttribute('x', String(rect.x - GLOW_PADDING));
11689
+ el.setAttribute('y', String(rect.y - GLOW_PADDING));
11690
+ el.setAttribute('width', String(rect.width + GLOW_PADDING * 2));
11691
+ el.setAttribute('height', String(rect.height + GLOW_PADDING * 2));
11692
+ el.setAttribute('rx', '2');
11693
+ const filterRef = `url(#${filterId})`;
11694
+ const color = (_a = rect.color) !== null && _a !== void 0 ? _a : DEFAULT_GLOW_COLOR;
11695
+ el.addEventListener('mouseenter', () => {
11696
+ el.style.fill = toRGBA(color, 0.15);
11697
+ el.style.filter = filterRef;
11698
+ });
11699
+ el.addEventListener('mouseleave', () => {
11700
+ el.style.fill = 'transparent';
11701
+ el.style.filter = '';
11702
+ });
11703
+ el.addEventListener('click', (e) => {
11704
+ var _a;
11705
+ e.stopPropagation();
11706
+ (_a = this.onFeatureClick) === null || _a === void 0 ? void 0 : _a.call(this, rect, e);
11707
+ });
11708
+ return el;
11709
+ }
11710
+ }
11711
+ /**
11712
+ * Convert a CSS color string to rgba with the given alpha.
11713
+ * Handles `rgb(r, g, b)`, `rgba(r, g, b, a)`, and hex formats.
11714
+ */
11715
+ function toRGBA(color, alpha) {
11716
+ // Already rgba — replace the alpha
11717
+ const rgbaMatch = color.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
11718
+ if (rgbaMatch) {
11719
+ return `rgba(${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}, ${alpha})`;
11720
+ }
11721
+ // Hex color
11722
+ const hexMatch = color.match(/^#([0-9a-f]{3,8})$/i);
11723
+ if (hexMatch) {
11724
+ let hex = hexMatch[1];
11725
+ if (hex.length === 3)
11726
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
11727
+ const r = parseInt(hex.slice(0, 2), 16);
11728
+ const g = parseInt(hex.slice(2, 4), 16);
11729
+ const b = parseInt(hex.slice(4, 6), 16);
11730
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
11731
+ }
11732
+ // Fallback: can't parse, just return with opacity
11733
+ return color;
11734
+ }
11735
+
11020
11736
  /**
11021
11737
  * DOM-attached GenomeBrowser — extends HeadlessGenomeBrowser with pointer events,
11022
11738
  * ResizeObserver, canvas stacking, and sweep-to-zoom.
@@ -11131,6 +11847,11 @@ function injectScrollStyles(container) {
11131
11847
  style.textContent = `
11132
11848
  .loom-track-scroll { scrollbar-width: none; }
11133
11849
  .loom-track-scroll::-webkit-scrollbar { display: none; }
11850
+ .loom-feature-overlay rect {
11851
+ fill: transparent;
11852
+ pointer-events: auto;
11853
+ cursor: pointer;
11854
+ }
11134
11855
  `;
11135
11856
  if (root instanceof Document) {
11136
11857
  root.head.appendChild(style);
@@ -11141,7 +11862,7 @@ function injectScrollStyles(container) {
11141
11862
  }
11142
11863
  class GenomeBrowser extends HeadlessGenomeBrowser {
11143
11864
  constructor(container, options) {
11144
- var _a, _b, _c;
11865
+ var _a, _b, _c, _d;
11145
11866
  // Default-on: create providers unless explicitly set to null.
11146
11867
  const contextMenuProvider = options.contextMenuProvider === null
11147
11868
  ? undefined
@@ -11149,8 +11870,33 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11149
11870
  const popupProvider = options.popupProvider === null
11150
11871
  ? undefined
11151
11872
  : ((_b = options.popupProvider) !== null && _b !== void 0 ? _b : createDefaultPopupProvider());
11873
+ // Auto-create worker pool when `workers` or `workerFactory` is set
11874
+ // and no explicit workerProvider was passed.
11875
+ let workerProvider = options.workerProvider;
11876
+ let ownedProvider = null;
11877
+ if (!workerProvider && (options.workers || options.workerFactory)) {
11878
+ const poolSize = typeof options.workers === 'number'
11879
+ ? options.workers
11880
+ : Math.min((_c = navigator === null || navigator === void 0 ? void 0 : navigator.hardwareConcurrency) !== null && _c !== void 0 ? _c : 4, 4);
11881
+ try {
11882
+ ownedProvider = new WebWorkerPool({
11883
+ workerFactory: options.workerFactory,
11884
+ // webpackIgnore prevents webpack from statically resolving this URL at build time.
11885
+ // At runtime in the dist bundle, import.meta.url → dist/loom.esm.js,
11886
+ // and loom-worker.js is a sibling file in dist/.
11887
+ workerUrl: options.workerFactory ? undefined : new URL(/* webpackIgnore: true */ './loom-worker.js', import.meta.url),
11888
+ poolSize,
11889
+ });
11890
+ workerProvider = ownedProvider;
11891
+ }
11892
+ catch (_f) {
11893
+ // Worker creation failed (CSP, bundler, etc.) — fall back to main thread
11894
+ console.warn('[loom] Failed to create web workers, falling back to main-thread execution');
11895
+ }
11896
+ }
11152
11897
  super({
11153
11898
  ...options,
11899
+ workerProvider,
11154
11900
  contextMenuProvider,
11155
11901
  popupProvider,
11156
11902
  viewportWidth: container.clientWidth,
@@ -11201,8 +11947,13 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11201
11947
  // ROI overlay state
11202
11948
  this.roiOverlayContainer = null;
11203
11949
  this.roiElements = new Map();
11950
+ // Per-track SVG feature overlays for hover/click interactivity
11951
+ this.featureOverlays = new Map();
11952
+ /** Worker provider auto-created by `workers` option. Disposed on cleanup. */
11953
+ this.ownedWorkerProvider = null;
11954
+ this.ownedWorkerProvider = ownedProvider;
11204
11955
  this.container = container;
11205
- this.interactive = (_c = options.interactive) !== null && _c !== void 0 ? _c : true;
11956
+ this.interactive = (_d = options.interactive) !== null && _d !== void 0 ? _d : true;
11206
11957
  injectScrollStyles(container);
11207
11958
  container.style.userSelect = "none";
11208
11959
  container.style.touchAction = "none";
@@ -11238,6 +11989,7 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11238
11989
  // This may repaint the axis twice on initial load (negligible for a 50px canvas).
11239
11990
  this.events.on(BrowserEvent.DataLoaded, ({ track }) => {
11240
11991
  this.updateAxisContent(track);
11992
+ this.updateFeatureOverlay(track);
11241
11993
  });
11242
11994
  // Repaint axis canvases when theme changes (track canvases update via
11243
11995
  // track.setTheme(), but axis sidebar is managed here in GenomeBrowser).
@@ -11252,13 +12004,11 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11252
12004
  this.events.on(BrowserEvent.ROIChanged, () => this.renderROIOverlays());
11253
12005
  this.events.on(BrowserEvent.LocusChange, () => this.renderROIOverlays());
11254
12006
  }
11255
- /** Add a track and attach its canvas to the container. */
11256
- addTrack(track, dataSource, dataSourceConfig, maxTrackHeight) {
11257
- const id = super.addTrack(track, dataSource, dataSourceConfig, maxTrackHeight);
12007
+ /** Build a DOM row for a track (axis column + viewport wrapper). */
12008
+ _buildTrackRow(track, maxTrackHeight) {
11258
12009
  const canvas = track.canvas;
11259
12010
  canvas.style.display = "block";
11260
12011
  canvas.style.width = "100%";
11261
- // Flex row: [axis (50px)] [viewport (flex: 1)]
11262
12012
  const row = document.createElement("div");
11263
12013
  row.style.display = "flex";
11264
12014
  row.style.width = "100%";
@@ -11273,10 +12023,8 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11273
12023
  const viewportWrapper = document.createElement("div");
11274
12024
  viewportWrapper.style.flex = "1";
11275
12025
  viewportWrapper.style.minWidth = "0";
11276
- // Apply maxTrackHeight if set — enables native scroll for tall tracks
11277
- const mt = this.managedTracks[this.managedTracks.length - 1];
11278
- if (mt.maxTrackHeight != null) {
11279
- viewportWrapper.style.maxHeight = `${mt.maxTrackHeight}px`;
12026
+ if (maxTrackHeight != null) {
12027
+ viewportWrapper.style.maxHeight = `${maxTrackHeight}px`;
11280
12028
  viewportWrapper.style.overflowY = "auto";
11281
12029
  viewportWrapper.style.overscrollBehaviorY = "none";
11282
12030
  viewportWrapper.style.backgroundColor =
@@ -11286,15 +12034,33 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11286
12034
  viewportWrapper.appendChild(canvas);
11287
12035
  row.appendChild(axisDiv);
11288
12036
  row.appendChild(viewportWrapper);
11289
- this.container.appendChild(row);
11290
- this.trackRows.set(track, {
11291
- row,
11292
- axisDiv,
11293
- axisCanvas: null,
11294
- viewportWrapper,
11295
- });
12037
+ // Create SVG feature overlay for hover/click interactivity
12038
+ if (this.interactive) {
12039
+ const overlay = new SVGFeatureOverlay(viewportWrapper);
12040
+ overlay.onFeatureClick = (rect, event) => {
12041
+ // Resolve interaction using the rect center, then show popup
12042
+ const cx = rect.x + rect.width / 2;
12043
+ const cy = rect.y + rect.height / 2;
12044
+ const interaction = this.resolveInteraction(track, cx, cy);
12045
+ if (interaction) {
12046
+ this.events.emit(BrowserEvent.TrackClick, interaction);
12047
+ if (this.popupProvider && interaction.popupData.length > 0) {
12048
+ const containerRect = this.container.getBoundingClientRect();
12049
+ const canvasRect = track.canvas.getBoundingClientRect();
12050
+ this.popupProvider.show(interaction.popupData, {
12051
+ x: canvasRect.left - containerRect.left + event.offsetX,
12052
+ y: canvasRect.top - containerRect.top + event.offsetY,
12053
+ }, this.container);
12054
+ }
12055
+ }
12056
+ };
12057
+ this.featureOverlays.set(track, overlay);
12058
+ }
12059
+ return { row, axisDiv, axisCanvas: null, viewportWrapper };
12060
+ }
12061
+ /** Post-registration UI setup for a track row (axis content, cursors, drag handlers). */
12062
+ _setupTrackRowUI(track) {
11296
12063
  // Suppress on-canvas data range labels for wig tracks — the axis column handles it.
11297
- // This keeps showDataRange=true as the default for standalone WigTrackCanvas users.
11298
12064
  if (track.type === "wig") {
11299
12065
  track.setConfig({
11300
12066
  showDataRange: false,
@@ -11303,24 +12069,38 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11303
12069
  this.updateAxisContent(track);
11304
12070
  // Ruler tracks: crosshair for sweep-to-zoom, pointer in WG mode (click to navigate)
11305
12071
  if (this.interactive && track.type === "ruler") {
11306
- canvas.style.cursor = isWholeGenomeView(this._locus)
12072
+ track.canvas.style.cursor = isWholeGenomeView(this._locus)
11307
12073
  ? "pointer"
11308
12074
  : "crosshair";
11309
12075
  }
11310
12076
  // Enable drag-to-reorder on the axis column for non-ruler tracks
11311
- if (this.interactive && track.type !== "ruler") {
11312
- this.setupReorderHandlers(track, axisDiv);
12077
+ const entry = this.trackRows.get(track);
12078
+ if (this.interactive && track.type !== "ruler" && entry) {
12079
+ this.setupReorderHandlers(track, entry.axisDiv);
11313
12080
  }
12081
+ }
12082
+ /** Add a track and attach its canvas to the container. */
12083
+ addTrack(track, dataSource, dataSourceConfig, maxTrackHeight, order) {
12084
+ // Build and register DOM row BEFORE super.addTrack() so that
12085
+ // syncDOMOrder() (called from sortTracks) can position it correctly.
12086
+ const trackRow = this._buildTrackRow(track, maxTrackHeight);
12087
+ this.container.appendChild(trackRow.row);
12088
+ this.trackRows.set(track, trackRow);
12089
+ const id = super.addTrack(track, dataSource, dataSourceConfig, maxTrackHeight, order);
12090
+ this._setupTrackRowUI(track);
11314
12091
  return id;
11315
12092
  }
11316
12093
  /** Remove a track and its row from the container. */
11317
12094
  removeTrack(trackOrId) {
12095
+ var _a;
11318
12096
  const track = typeof trackOrId === 'string'
11319
12097
  ? this.getTrack(trackOrId)
11320
12098
  : trackOrId;
11321
12099
  if (!track)
11322
12100
  return;
11323
12101
  this.teardownReorderHandlers(track);
12102
+ (_a = this.featureOverlays.get(track)) === null || _a === void 0 ? void 0 : _a.dispose();
12103
+ this.featureOverlays.delete(track);
11324
12104
  const entry = this.trackRows.get(track);
11325
12105
  if (entry && entry.row.parentNode === this.container) {
11326
12106
  this.container.removeChild(entry.row);
@@ -11375,6 +12155,8 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11375
12155
  }
11376
12156
  // Re-render ROI overlays
11377
12157
  this.renderROIOverlays();
12158
+ // Update feature overlays
12159
+ this.updateFeatureOverlays();
11378
12160
  }
11379
12161
  /** Clean up event listeners, remove canvases, and dispose headless core. */
11380
12162
  // ─── Remote connection ────────────────────────────────────────────────
@@ -11424,6 +12206,9 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11424
12206
  }
11425
12207
  this.removeSweepOverlay();
11426
12208
  this.clearROIOverlay();
12209
+ for (const overlay of this.featureOverlays.values())
12210
+ overlay.dispose();
12211
+ this.featureOverlays.clear();
11427
12212
  for (const mt of this.managedTracks) {
11428
12213
  this.teardownReorderHandlers(mt.track);
11429
12214
  const entry = this.trackRows.get(mt.track);
@@ -11434,6 +12219,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11434
12219
  this.reorderHandlers.clear();
11435
12220
  this.trackRows.clear();
11436
12221
  super.dispose();
12222
+ if (this.ownedWorkerProvider) {
12223
+ this.ownedWorkerProvider.dispose();
12224
+ this.ownedWorkerProvider = null;
12225
+ }
11437
12226
  }
11438
12227
  /**
11439
12228
  * Update the axis content for a track based on its getAxisInfo().
@@ -11468,12 +12257,7 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11468
12257
  const visibleHeight = (mt === null || mt === void 0 ? void 0 : mt.maxTrackHeight) != null
11469
12258
  ? Math.min(track.height, mt.maxTrackHeight)
11470
12259
  : track.height;
11471
- if (info.dataRange) {
11472
- this.paintAxisCanvas(entry, info, visibleHeight, renderQuantitativeAxis);
11473
- }
11474
- else if (info.label) {
11475
- this.paintAxisCanvas(entry, info, visibleHeight, renderLabelAxis);
11476
- }
12260
+ this.paintAxisCanvas(entry, info, visibleHeight, renderQuantitativeAxis);
11477
12261
  }
11478
12262
  /**
11479
12263
  * Prepare an axis canvas at the correct size with DPR scaling,
@@ -11586,6 +12370,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11586
12370
  this.lastPointerX = e.clientX;
11587
12371
  this.container.setPointerCapture(e.pointerId);
11588
12372
  this.container.style.cursor = "grabbing";
12373
+ // Suppress feature overlay pointer events during drag
12374
+ for (const overlay of this.featureOverlays.values()) {
12375
+ overlay.setSuppressed(true);
12376
+ }
11589
12377
  };
11590
12378
  this.handlePointerMove = (e) => {
11591
12379
  if (this.isSweeping && this.sweepOverlay && this.sweepRulerCanvas) {
@@ -11658,6 +12446,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11658
12446
  const wasDragging = this.isDragging;
11659
12447
  this.isDragging = false;
11660
12448
  this.container.style.cursor = "grab";
12449
+ // Restore feature overlay pointer events
12450
+ for (const overlay of this.featureOverlays.values()) {
12451
+ overlay.setSuppressed(false);
12452
+ }
11661
12453
  // Click detection: pointer didn't move more than threshold
11662
12454
  const dx = e.clientX - this.pointerDownX;
11663
12455
  const dy = e.clientY - this.pointerDownY;
@@ -11833,7 +12625,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11833
12625
  // Common items (set height, remove)
11834
12626
  const callbacks = {
11835
12627
  setTrackHeight: (t, h) => {
11836
- if (t instanceof BaseTrackCanvas) {
12628
+ if (t instanceof AnnotationTrackCanvas) {
12629
+ t.setFixedHeight(h);
12630
+ }
12631
+ else if (t instanceof BaseTrackCanvas) {
11837
12632
  t.setConfig({ height: h });
11838
12633
  }
11839
12634
  },
@@ -11929,6 +12724,10 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11929
12724
  }
11930
12725
  }
11931
12726
  // ─── Track reorder drag ─────────────────────────────────────────────
12727
+ /** Sync DOM after any sort (addTrack, addGeneTrack, etc.). */
12728
+ onTracksSorted() {
12729
+ this.syncDOMOrder();
12730
+ }
11932
12731
  /** Reorder DOM rows to match managedTracks order. */
11933
12732
  syncDOMOrder() {
11934
12733
  for (const mt of this.managedTracks) {
@@ -12005,6 +12804,8 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
12005
12804
  if (dragEntry) {
12006
12805
  dragEntry.axisDiv.style.cursor = "grab";
12007
12806
  }
12807
+ // Persist the drag result so future addTrack sorts don't revert positions.
12808
+ this._assignOrderFromPosition();
12008
12809
  // Emit the final order change event
12009
12810
  this.events.emit(BrowserEvent.TrackOrderChanged, {
12010
12811
  tracks: this.managedTracks.map((mt) => mt.track),
@@ -12127,6 +12928,21 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
12127
12928
  * DOM-based (not canvas) for easy hover/click interaction without
12128
12929
  * re-rendering track canvases.
12129
12930
  */
12931
+ /** Update all feature overlays after render. */
12932
+ updateFeatureOverlays() {
12933
+ for (const mt of this.managedTracks) {
12934
+ this.updateFeatureOverlay(mt.track);
12935
+ }
12936
+ }
12937
+ /** Update the feature overlay for a single track. */
12938
+ updateFeatureOverlay(track) {
12939
+ const overlay = this.featureOverlays.get(track);
12940
+ if (!overlay)
12941
+ return;
12942
+ if (track instanceof BaseTrackCanvas) {
12943
+ overlay.update(track.getFeatureRects());
12944
+ }
12945
+ }
12130
12946
  renderROIOverlays() {
12131
12947
  var _a, _b;
12132
12948
  const visibleROIs = this.getVisibleROIs();
@@ -12373,9 +13189,6 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
12373
13189
  if (info.dataRange) {
12374
13190
  renderQuantitativeAxis(ctx, info, axisWidth, h);
12375
13191
  }
12376
- else if (info.label) {
12377
- renderLabelAxis(ctx, info, axisWidth, h);
12378
- }
12379
13192
  ctx.restore();
12380
13193
  }
12381
13194
  // Render track (right of axis)
@@ -12472,8 +13285,25 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
12472
13285
 
12473
13286
  const GenomeBrowserContext = createContext(null);
12474
13287
 
13288
+ /** Infer the shell theme from a RenderTheme's palette. */
13289
+ function inferShellTheme(theme) {
13290
+ var _a;
13291
+ const bg = (_a = theme === null || theme === void 0 ? void 0 : theme.palette) === null || _a === void 0 ? void 0 : _a.background;
13292
+ if (!bg)
13293
+ return 'modern';
13294
+ // Simple luminance check: dark backgrounds → dark shell theme
13295
+ const el = document.createElement('canvas');
13296
+ const ctx = el.getContext('2d');
13297
+ ctx.fillStyle = bg;
13298
+ const hex = ctx.fillStyle; // normalized to #rrggbb
13299
+ const r = parseInt(hex.slice(1, 3), 16);
13300
+ const g = parseInt(hex.slice(3, 5), 16);
13301
+ const b = parseInt(hex.slice(5, 7), 16);
13302
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
13303
+ return luminance < 0.5 ? 'dark' : 'modern';
13304
+ }
12475
13305
  const LoomBrowser = forwardRef(function LoomBrowser(props, ref) {
12476
- const { defaultLocus, locus, onLocusChange, theme, genome, interactive, wheelZoom, workerProvider, popupProvider, contextMenuProvider, ruler, genes, sequence, session, tracks: trackConfigs, remoteSocket, className, style, children, } = props;
13306
+ const { defaultLocus, locus, onLocusChange, theme, genome, interactive, wheelZoom, workers, workerFactory, workerProvider, popupProvider, contextMenuProvider, ruler, genes, sequence, session, tracks: trackConfigs, remoteSocket, className, style, children, } = props;
12477
13307
  const containerRef = useRef(null);
12478
13308
  const browserRef = useRef(null);
12479
13309
  const [browser, setBrowser] = useState(null);
@@ -12496,6 +13326,8 @@ const LoomBrowser = forwardRef(function LoomBrowser(props, ref) {
12496
13326
  locus: initialLocus,
12497
13327
  interactive,
12498
13328
  wheelZoom,
13329
+ workers,
13330
+ workerFactory,
12499
13331
  workerProvider,
12500
13332
  popupProvider,
12501
13333
  contextMenuProvider,
@@ -12614,7 +13446,8 @@ const LoomBrowser = forwardRef(function LoomBrowser(props, ref) {
12614
13446
  attachRemote(socket) { var _a; (_a = browserRef.current) === null || _a === void 0 ? void 0 : _a.attachRemote(socket); },
12615
13447
  detachRemote() { var _a; (_a = browserRef.current) === null || _a === void 0 ? void 0 : _a.detachRemote(); },
12616
13448
  }), [browser]);
12617
- const ctxValue = useMemo(() => ({ browser }), [browser]);
13449
+ const shellTheme = useMemo(() => inferShellTheme(theme), [theme]);
13450
+ const ctxValue = useMemo(() => ({ browser, shellTheme }), [browser, shellTheme]);
12618
13451
  return (jsx(GenomeBrowserContext.Provider, { value: ctxValue, children: jsxs("div", { className: className, style: style, children: [browser && children, jsx("div", { ref: containerRef })] }) }));
12619
13452
  });
12620
13453
 
@@ -12723,8 +13556,15 @@ function RulerTrack({ config, maxTrackHeight }) {
12723
13556
  return null;
12724
13557
  }
12725
13558
 
12726
- function WigTrack({ url, config, height, background, windowFunction, maxTrackHeight, name, metadata }) {
12727
- useTrackManager((browser) => browser.addWigTrack(url, { config, height, background, windowFunction, maxTrackHeight, name, metadata }), [url, windowFunction], (track) => { if (config)
13559
+ function WigTrack({ url, features, config, height, background, windowFunction, maxTrackHeight, name, metadata }) {
13560
+ useTrackManager((browser) => {
13561
+ if (features) {
13562
+ return browser.addWigTrackWithFeatures(features, { config, height, background, maxTrackHeight, name, metadata });
13563
+ }
13564
+ if (!url)
13565
+ throw new Error('WigTrack requires either a `url` or `features` prop');
13566
+ return browser.addWigTrack(url, { config, height, background, windowFunction, maxTrackHeight, name, metadata });
13567
+ }, [url, features, windowFunction], (track) => { if (config)
12728
13568
  track.setConfig(config); }, [config, height, background, name]);
12729
13569
  return null;
12730
13570
  }
@@ -12735,8 +13575,15 @@ function GeneTrack({ config, height, background, genome, track, maxTrackHeight,
12735
13575
  return null;
12736
13576
  }
12737
13577
 
12738
- function BedTrack({ url, config, height, background, format, indexURL, indexed, maxTrackHeight, name, metadata }) {
12739
- useTrackManager((browser) => browser.addBedTrack(url, { config, height, background, format, indexURL, indexed, maxTrackHeight, name, metadata }), [url, format, indexURL, indexed], (track) => { if (config)
13578
+ function BedTrack({ url, features, config, height, background, format, indexURL, indexed, maxTrackHeight, name, metadata }) {
13579
+ useTrackManager((browser) => {
13580
+ if (features) {
13581
+ return browser.addBedTrackWithFeatures(features, { config, height, background, maxTrackHeight, name, metadata });
13582
+ }
13583
+ if (!url)
13584
+ throw new Error('BedTrack requires either a `url` or `features` prop');
13585
+ return browser.addBedTrack(url, { config, height, background, format, indexURL, indexed, maxTrackHeight, name, metadata });
13586
+ }, [url, features, format, indexURL, indexed], (track) => { if (config)
12740
13587
  track.setConfig(config); }, [config, height, background, name]);
12741
13588
  return null;
12742
13589
  }
@@ -13387,15 +14234,126 @@ function ExportControls({ browser: browserProp }) {
13387
14234
  return jsx("loom-export-controls", { ref: ref });
13388
14235
  }
13389
14236
 
13390
- function Navbar({ browser: browserProp }) {
14237
+ /**
14238
+ * CSS custom property themes for UI shell web components.
14239
+ *
14240
+ * Themes are injected as <style> blocks into Shadow DOM roots.
14241
+ * Components reference CSS custom properties (--loom-*) for all visual styling.
14242
+ */
14243
+ /** Classic theme — matches igv.js navbar look. */
14244
+ const classicThemeCSS = /* css */ `
14245
+ :host {
14246
+ --loom-navbar-bg: #f3f3f3;
14247
+ --loom-navbar-height: 32px;
14248
+ --loom-navbar-padding: 0 8px;
14249
+ --loom-font: 12px Arial, sans-serif;
14250
+ --loom-font-small: 11px Arial, sans-serif;
14251
+ --loom-text-color: #333;
14252
+ --loom-text-muted: #737373;
14253
+ --loom-border: 1px solid #ccc;
14254
+ --loom-border-radius: 4px;
14255
+ --loom-button-bg: white;
14256
+ --loom-button-hover: #e8e8e8;
14257
+ --loom-button-border: 1px solid #b0b0b0;
14258
+ --loom-button-size: 24px;
14259
+ --loom-input-bg: white;
14260
+ --loom-input-border: 1px solid #b0b0b0;
14261
+ --loom-input-focus-border: 1px solid #4A90D9;
14262
+ --loom-input-width: 220px;
14263
+ --loom-input-height: 22px;
14264
+ --loom-accent: #4A90D9;
14265
+ --loom-icon-color: #555;
14266
+ --loom-icon-size: 14px;
14267
+ --loom-gap: 8px;
14268
+ }
14269
+ `;
14270
+ /** Modern theme — softer colors, rounded corners, taller navbar. */
14271
+ const modernThemeCSS = /* css */ `
14272
+ :host {
14273
+ --loom-navbar-bg: #fafbfc;
14274
+ --loom-navbar-height: 40px;
14275
+ --loom-navbar-padding: 0 12px;
14276
+ --loom-font: 13px Inter, system-ui, -apple-system, sans-serif;
14277
+ --loom-font-small: 11px Inter, system-ui, -apple-system, sans-serif;
14278
+ --loom-text-color: #1a1a1a;
14279
+ --loom-text-muted: #6b7280;
14280
+ --loom-border: 1px solid rgba(0, 0, 0, 0.08);
14281
+ --loom-border-radius: 8px;
14282
+ --loom-button-bg: white;
14283
+ --loom-button-hover: #f0f4ff;
14284
+ --loom-button-border: 1px solid rgba(0, 0, 0, 0.1);
14285
+ --loom-button-size: 28px;
14286
+ --loom-input-bg: white;
14287
+ --loom-input-border: 1px solid rgba(0, 0, 0, 0.12);
14288
+ --loom-input-focus-border: 1px solid #4A90D9;
14289
+ --loom-input-width: 260px;
14290
+ --loom-input-height: 28px;
14291
+ --loom-accent: #4A90D9;
14292
+ --loom-icon-color: #6b7280;
14293
+ --loom-icon-size: 16px;
14294
+ --loom-gap: 10px;
14295
+ }
14296
+ `;
14297
+ /** Dark theme — dark backgrounds, light text, high contrast. */
14298
+ const darkThemeCSS = /* css */ `
14299
+ :host {
14300
+ --loom-shell-bg: #16162a;
14301
+ --loom-navbar-bg: #1a1a2e;
14302
+ --loom-navbar-height: 36px;
14303
+ --loom-navbar-padding: 0 10px;
14304
+ --loom-font: 12px Arial, sans-serif;
14305
+ --loom-font-small: 11px Arial, sans-serif;
14306
+ --loom-text-color: #e0e0e0;
14307
+ --loom-text-muted: #888;
14308
+ --loom-border: 1px solid #333;
14309
+ --loom-border-radius: 4px;
14310
+ --loom-button-bg: #2a2a3e;
14311
+ --loom-button-hover: #3a3a50;
14312
+ --loom-button-border: 1px solid #444;
14313
+ --loom-button-size: 24px;
14314
+ --loom-input-bg: #2a2a3e;
14315
+ --loom-input-border: 1px solid #444;
14316
+ --loom-input-focus-border: 1px solid #6a9fd9;
14317
+ --loom-input-width: 220px;
14318
+ --loom-input-height: 22px;
14319
+ --loom-accent: #6a9fd9;
14320
+ --loom-icon-color: #b0b0b0;
14321
+ --loom-icon-size: 14px;
14322
+ --loom-gap: 8px;
14323
+ }
14324
+ `;
14325
+ function getThemeCSS(theme) {
14326
+ if (theme === 'modern')
14327
+ return modernThemeCSS;
14328
+ if (theme === 'dark')
14329
+ return darkThemeCSS;
14330
+ return classicThemeCSS;
14331
+ }
14332
+
14333
+ function Navbar({ browser: browserProp, theme: themeProp }) {
14334
+ var _a;
14335
+ const ctx = useContext(GenomeBrowserContext);
13391
14336
  const contextBrowser = useGenomeBrowser();
13392
14337
  const browser = browserProp !== null && browserProp !== void 0 ? browserProp : contextBrowser;
14338
+ const theme = (_a = themeProp !== null && themeProp !== void 0 ? themeProp : ctx === null || ctx === void 0 ? void 0 : ctx.shellTheme) !== null && _a !== void 0 ? _a : 'modern';
13393
14339
  const ref = useRef(null);
14340
+ const styleRef = useRef(null);
13394
14341
  ensureRegistered();
13395
14342
  useEffect(() => {
13396
14343
  if (ref.current)
13397
14344
  ref.current.browser = browser;
13398
14345
  }, [browser]);
14346
+ // Inject shell theme CSS into the navbar's shadow root
14347
+ useEffect(() => {
14348
+ const el = ref.current;
14349
+ if (!(el === null || el === void 0 ? void 0 : el.shadowRoot))
14350
+ return;
14351
+ if (!styleRef.current) {
14352
+ styleRef.current = document.createElement('style');
14353
+ el.shadowRoot.prepend(styleRef.current);
14354
+ }
14355
+ styleRef.current.textContent = getThemeCSS(theme);
14356
+ }, [theme]);
13399
14357
  return jsx("loom-navbar", { ref: ref });
13400
14358
  }
13401
14359