loom-browser 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2555,10 +2555,12 @@ class BaseTrackCanvas {
2555
2555
  ctx.fillRect(0, 0, width, height);
2556
2556
  if (this._error) {
2557
2557
  this.renderError(ctx, width, height);
2558
+ this.renderLabelOverlay(ctx, width, height);
2558
2559
  return;
2559
2560
  }
2560
2561
  if (this._zoomedOut) {
2561
2562
  this.renderZoomInNotice(ctx, width, height);
2563
+ this.renderLabelOverlay(ctx, width, height);
2562
2564
  return;
2563
2565
  }
2564
2566
  const bpPerPixel = (this._locus.end - this._locus.start) / width;
@@ -2589,6 +2591,14 @@ class BaseTrackCanvas {
2589
2591
  ctx.textBaseline = 'middle';
2590
2592
  ctx.fillText('Zoom in to see features', width / 2, height / 2);
2591
2593
  }
2594
+ /**
2595
+ * Render the track name label overlay. Called after error/zoom-in notices
2596
+ * so labels remain visible even when the track can't render data.
2597
+ * No-op by default — subclasses with name labels should override.
2598
+ */
2599
+ renderLabelOverlay(_ctx, _width, _height) {
2600
+ // No-op — subclasses override
2601
+ }
2592
2602
  /**
2593
2603
  * Render this track onto an arbitrary context (e.g. Canvas2SVG for SVG export).
2594
2604
  * Skips DPR scaling and canvas-element lifecycle — draws directly at the given
@@ -2603,10 +2613,12 @@ class BaseTrackCanvas {
2603
2613
  ctx.fillRect(0, 0, width, height);
2604
2614
  if (this._error) {
2605
2615
  this.renderError(ctx, width, height);
2616
+ this.renderLabelOverlay(ctx, width, height);
2606
2617
  return;
2607
2618
  }
2608
2619
  if (this._zoomedOut) {
2609
2620
  this.renderZoomInNotice(ctx, width, height);
2621
+ this.renderLabelOverlay(ctx, width, height);
2610
2622
  return;
2611
2623
  }
2612
2624
  const bpPerPixel = (this._locus.end - this._locus.start) / width;
@@ -3415,15 +3427,18 @@ class WigTrackCanvas extends BaseTrackCanvas {
3415
3427
  getBackground() {
3416
3428
  return this.config.background;
3417
3429
  }
3430
+ renderLabelOverlay(ctx, _width, height) {
3431
+ if (this._name) {
3432
+ renderTrackNameLabel(ctx, {
3433
+ name: this._name,
3434
+ background: this.config.background,
3435
+ labelColor: this.config.labelColor,
3436
+ }, height);
3437
+ }
3438
+ }
3418
3439
  doRender(ctx, _width, height, rc) {
3419
3440
  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
- }
3441
+ this.renderLabelOverlay(ctx, _width, height);
3427
3442
  return;
3428
3443
  }
3429
3444
  // Apply value scaling (normalize then scaleFactor), matching igv.js getFeatures() order.
@@ -4026,6 +4041,16 @@ class AnnotationTrackCanvas extends BaseTrackCanvas {
4026
4041
  getBackground() {
4027
4042
  return this.background;
4028
4043
  }
4044
+ renderLabelOverlay(ctx, _width, height) {
4045
+ var _a;
4046
+ if (this._name) {
4047
+ renderTrackNameLabel(ctx, {
4048
+ name: this._name,
4049
+ background: this.background,
4050
+ labelColor: (_a = this.config.labelColor) !== null && _a !== void 0 ? _a : '#333',
4051
+ }, height);
4052
+ }
4053
+ }
4029
4054
  doRender(ctx, _width, _height, rc) {
4030
4055
  var _a;
4031
4056
  // Ensure label background matches track background so clearRect doesn't
@@ -8728,6 +8753,15 @@ class InteractionTrackCanvas extends BaseTrackCanvas {
8728
8753
  getBackground() {
8729
8754
  return this.config.background;
8730
8755
  }
8756
+ renderLabelOverlay(ctx, _width, height) {
8757
+ if (this._name) {
8758
+ renderTrackNameLabel(ctx, {
8759
+ name: this._name,
8760
+ background: this._config.background,
8761
+ labelColor: '#333',
8762
+ }, height);
8763
+ }
8764
+ }
8731
8765
  doRender(ctx, _width, _height, rc) {
8732
8766
  renderInteractionTrack(ctx, this.features, this._config, rc, this._name ? {
8733
8767
  name: this._name,
@@ -9492,6 +9526,19 @@ function isDataSourceWorkerProvider(obj) {
9492
9526
  && typeof o.fetch === 'function'
9493
9527
  && typeof o.destroy === 'function';
9494
9528
  }
9529
+ /** Check if an error is an AbortError (from fetch abort, signal abort, or worker abort). */
9530
+ function isAbortError(err) {
9531
+ if (err instanceof DOMException && err.name === 'AbortError')
9532
+ return true;
9533
+ if (err instanceof Error && err.name === 'AbortError')
9534
+ return true;
9535
+ // Worker-relayed abort errors may arrive as plain Error with the browser's
9536
+ // abort message (e.g., "signal is aborted without reason") because the
9537
+ // worker-side DOMException check can miss non-DOMException abort errors.
9538
+ if (err instanceof Error && err.message.includes('aborted'))
9539
+ return true;
9540
+ return false;
9541
+ }
9495
9542
  const BrowserEvent = {
9496
9543
  LocusChange: 'locuschange',
9497
9544
  TrackAdded: 'trackadded',
@@ -9552,6 +9599,8 @@ class HeadlessGenomeBrowser {
9552
9599
  this.inflightFetches = new Map();
9553
9600
  /** Timer for debouncing data loads during rapid zoom/pan. */
9554
9601
  this.loadDebounceTimer = null;
9602
+ /** Set to true by dispose() to suppress post-dispose promise handlers. */
9603
+ this._disposed = false;
9555
9604
  /** When true, sortTracks() is a no-op. Used for batch track additions (e.g. loadSession). */
9556
9605
  this._deferSort = false;
9557
9606
  this.events = new EventEmitter();
@@ -9916,6 +9965,17 @@ class HeadlessGenomeBrowser {
9916
9965
  }
9917
9966
  return undefined;
9918
9967
  }
9968
+ /** Remove a specific ROI set by instance. Returns true if found and removed. */
9969
+ removeROISet(set) {
9970
+ const idx = this.roiSets.indexOf(set);
9971
+ if (idx < 0)
9972
+ return false;
9973
+ this.roiSets.splice(idx, 1);
9974
+ for (const roi of set.features) {
9975
+ this.events.emit(BrowserEvent.ROIRemoved, { roi, set });
9976
+ }
9977
+ return true;
9978
+ }
9919
9979
  /** Remove all ROI sets. */
9920
9980
  clearROIs() {
9921
9981
  this.roiSets = [];
@@ -10457,6 +10517,11 @@ class HeadlessGenomeBrowser {
10457
10517
  }
10458
10518
  /** Clean up event listeners, abort in-flight requests, clear tracks and ROIs. */
10459
10519
  dispose() {
10520
+ this._disposed = true;
10521
+ if (this.loadDebounceTimer !== null) {
10522
+ clearTimeout(this.loadDebounceTimer);
10523
+ this.loadDebounceTimer = null;
10524
+ }
10460
10525
  this.events.removeAllListeners();
10461
10526
  if (this.popupProvider)
10462
10527
  this.popupProvider.dispose();
@@ -10600,12 +10665,16 @@ class HeadlessGenomeBrowser {
10600
10665
  this.events.emit(BrowserEvent.DataLoaded, { track: mt.track });
10601
10666
  })
10602
10667
  .catch(err => {
10603
- // AbortErrors propagate from the source track's abort — ignore them
10604
- if (err instanceof DOMException && err.name === 'AbortError')
10668
+ if (this._disposed || isAbortError(err))
10605
10669
  return;
10606
10670
  const error = err instanceof Error ? err : new Error(String(err));
10607
- console.error('[loom] Data fetch error (dedup):', error);
10608
- mt.track.setError(error);
10671
+ if (mt.cache) {
10672
+ console.warn('[loom] Data fetch failed (dedup), showing cached data:', error.message);
10673
+ }
10674
+ else {
10675
+ console.error('[loom] Data fetch error (dedup):', error);
10676
+ mt.track.setError(error);
10677
+ }
10609
10678
  this.events.emit(BrowserEvent.DataError, { track: mt.track, error });
10610
10679
  });
10611
10680
  return;
@@ -10632,12 +10701,20 @@ class HeadlessGenomeBrowser {
10632
10701
  this.events.emit(BrowserEvent.DataLoaded, { track: mt.track });
10633
10702
  })
10634
10703
  .catch(err => {
10635
- if (!controller.signal.aborted) {
10636
- const error = err instanceof Error ? err : new Error(String(err));
10704
+ if (this._disposed || controller.signal.aborted || isAbortError(err))
10705
+ return;
10706
+ const error = err instanceof Error ? err : new Error(String(err));
10707
+ // If we have cached data, keep showing it (stale) rather than
10708
+ // replacing it with an error message. Only show error if there's
10709
+ // nothing to display at all.
10710
+ if (mt.cache) {
10711
+ console.warn('[loom] Data fetch failed, showing cached data:', error.message);
10712
+ }
10713
+ else {
10637
10714
  console.error('[loom] Data fetch error:', error);
10638
10715
  mt.track.setError(error);
10639
- this.events.emit(BrowserEvent.DataError, { track: mt.track, error });
10640
10716
  }
10717
+ this.events.emit(BrowserEvent.DataError, { track: mt.track, error });
10641
10718
  })
10642
10719
  .finally(() => {
10643
10720
  var _a;
@@ -10696,8 +10773,11 @@ class WebWorkerPool {
10696
10773
  this.readyPromises = [];
10697
10774
  for (let i = 0; i < count; i++) {
10698
10775
  const worker = createWorker();
10699
- // Ready handshake
10700
- const readyPromise = new Promise((resolveReady) => {
10776
+ // Ready handshake — rejects on worker error so awaiting fetches
10777
+ // don't hang forever if the worker script fails to load.
10778
+ let rejectReady;
10779
+ const readyPromise = new Promise((resolveReady, rej) => {
10780
+ rejectReady = rej;
10701
10781
  const onReady = (e) => {
10702
10782
  if (e.data.type === 'ready') {
10703
10783
  resolveReady();
@@ -10705,6 +10785,8 @@ class WebWorkerPool {
10705
10785
  };
10706
10786
  worker.addEventListener('message', onReady, { once: true });
10707
10787
  });
10788
+ // Prevent unhandled rejection warnings if worker fails before any fetch
10789
+ readyPromise.catch(() => { });
10708
10790
  this.readyPromises.push(readyPromise);
10709
10791
  worker.onmessage = (e) => {
10710
10792
  const msg = e.data;
@@ -10744,6 +10826,7 @@ class WebWorkerPool {
10744
10826
  };
10745
10827
  worker.onerror = (e) => {
10746
10828
  const error = new Error(`Worker error: ${e.message}`);
10829
+ rejectReady(error);
10747
10830
  for (const { reject } of this.pending.values())
10748
10831
  reject(error);
10749
10832
  this.pending.clear();
@@ -11979,8 +12062,16 @@ class GenomeBrowser extends HeadlessGenomeBrowser {
11979
12062
  document.addEventListener("mousedown", this.handleDocMouseDown);
11980
12063
  }
11981
12064
  }
11982
- // Re-render tracks when container resizes (e.g. first layout in Shadow DOM)
11983
- this.resizeObserver = new ResizeObserver(() => this.render());
12065
+ // Re-render tracks when container resizes (e.g. first layout in Shadow DOM).
12066
+ // Also trigger data loads if width went from 0 → non-zero (common in React
12067
+ // flex layouts where the container hasn't laid out when GenomeBrowser is created).
12068
+ this.resizeObserver = new ResizeObserver(() => {
12069
+ const prevWidth = this._viewportWidth;
12070
+ this.render(); // updates _viewportWidth from container.clientWidth
12071
+ if (prevWidth === 0 && this._viewportWidth > 0) {
12072
+ this.loadAllTracksIfNeeded();
12073
+ }
12074
+ });
11984
12075
  this.resizeObserver.observe(container);
11985
12076
  // Axis content is updated in two places:
11986
12077
  // 1. After super.render() in render() — handles resize, pan, initial load
@@ -13428,6 +13519,7 @@ const LoomBrowser = forwardRef(function LoomBrowser(props, ref) {
13428
13519
  // ROI
13429
13520
  addROI(roi, setName) { return browserRef.current.addROI(roi, setName); },
13430
13521
  addROISet(config) { return browserRef.current.addROISet(config); },
13522
+ removeROISet(set) { var _a, _b; return (_b = (_a = browserRef.current) === null || _a === void 0 ? void 0 : _a.removeROISet(set)) !== null && _b !== void 0 ? _b : false; },
13431
13523
  removeROI(roiId) { var _a, _b; return (_b = (_a = browserRef.current) === null || _a === void 0 ? void 0 : _a.removeROI(roiId)) !== null && _b !== void 0 ? _b : false; },
13432
13524
  updateROI(roiId, changes) { var _a; return (_a = browserRef.current) === null || _a === void 0 ? void 0 : _a.updateROI(roiId, changes); },
13433
13525
  clearROIs() { var _a; (_a = browserRef.current) === null || _a === void 0 ? void 0 : _a.clearROIs(); },
@@ -13511,12 +13603,15 @@ function useLocus() {
13511
13603
  * @param recreationDeps When these change, the track is removed and re-added (new data source).
13512
13604
  * @param updateTrack Called when only updateDeps change (in-place config update).
13513
13605
  * @param updateDeps When these change (but recreationDeps don't), updateTrack is called.
13606
+ * @param eventHandlers Optional typed click/context-menu handlers scoped to this track.
13514
13607
  */
13515
- function useTrackManager(addTrack, recreationDeps, updateTrack, updateDeps) {
13608
+ function useTrackManager(addTrack, recreationDeps, updateTrack, updateDeps, eventHandlers) {
13516
13609
  const browser = useGenomeBrowser();
13517
13610
  const trackRef = useRef(null);
13518
13611
  const prevRecreationDeps = useRef(recreationDeps);
13519
13612
  const isFirstRender = useRef(true);
13613
+ const handlersRef = useRef(eventHandlers);
13614
+ handlersRef.current = eventHandlers;
13520
13615
  // Add track on mount or when recreation deps change
13521
13616
  useEffect(() => {
13522
13617
  if (!browser)
@@ -13547,6 +13642,29 @@ function useTrackManager(addTrack, recreationDeps, updateTrack, updateDeps) {
13547
13642
  updateTrack(trackRef.current, browser);
13548
13643
  // eslint-disable-next-line react-hooks/exhaustive-deps
13549
13644
  }, updateDeps);
13645
+ // Subscribe to track click/context-menu events, filtered to this track
13646
+ useEffect(() => {
13647
+ if (!browser)
13648
+ return;
13649
+ function handleEvent(event, handler) {
13650
+ if (!handler || event.track !== trackRef.current)
13651
+ return;
13652
+ handler({
13653
+ features: event.features,
13654
+ genomicLocation: event.genomicLocation,
13655
+ x: event.x,
13656
+ y: event.y,
13657
+ });
13658
+ }
13659
+ const onClickListener = (e) => { var _a; return handleEvent(e, (_a = handlersRef.current) === null || _a === void 0 ? void 0 : _a.onClick); };
13660
+ const onContextMenuListener = (e) => { var _a; return handleEvent(e, (_a = handlersRef.current) === null || _a === void 0 ? void 0 : _a.onContextMenu); };
13661
+ browser.on(BrowserEvent.TrackClick, onClickListener);
13662
+ browser.on(BrowserEvent.TrackContextMenu, onContextMenuListener);
13663
+ return () => {
13664
+ browser.off(BrowserEvent.TrackClick, onClickListener);
13665
+ browser.off(BrowserEvent.TrackContextMenu, onContextMenuListener);
13666
+ };
13667
+ }, [browser]);
13550
13668
  return trackRef.current;
13551
13669
  }
13552
13670
 
@@ -13556,7 +13674,7 @@ function RulerTrack({ config, maxTrackHeight }) {
13556
13674
  return null;
13557
13675
  }
13558
13676
 
13559
- function WigTrack({ url, features, config, height, background, windowFunction, maxTrackHeight, name, metadata }) {
13677
+ function WigTrack({ url, features, config, height, background, windowFunction, maxTrackHeight, name, metadata, onClick, onContextMenu }) {
13560
13678
  useTrackManager((browser) => {
13561
13679
  if (features) {
13562
13680
  return browser.addWigTrackWithFeatures(features, { config, height, background, maxTrackHeight, name, metadata });
@@ -13565,26 +13683,28 @@ function WigTrack({ url, features, config, height, background, windowFunction, m
13565
13683
  throw new Error('WigTrack requires either a `url` or `features` prop');
13566
13684
  return browser.addWigTrack(url, { config, height, background, windowFunction, maxTrackHeight, name, metadata });
13567
13685
  }, [url, features, windowFunction], (track) => { if (config)
13568
- track.setConfig(config); }, [config, height, background, name]);
13686
+ track.setConfig(config); }, [config, height, background, name], { onClick, onContextMenu });
13569
13687
  return null;
13570
13688
  }
13571
13689
 
13572
- function GeneTrack({ config, height, background, genome, track, maxTrackHeight, name, metadata }) {
13690
+ function GeneTrack({ config, height, background, genome, track, maxTrackHeight, name, metadata, onClick, onContextMenu }) {
13573
13691
  useTrackManager((browser) => browser.addGeneTrack({ config, height, background, genome, track, maxTrackHeight, name, metadata }), [genome, track], (t) => { if (config)
13574
- t.setConfig(config); }, [config, height, background, name]);
13692
+ t.setConfig(config); }, [config, height, background, name], { onClick, onContextMenu });
13575
13693
  return null;
13576
13694
  }
13577
13695
 
13578
- function BedTrack({ url, features, config, height, background, format, indexURL, indexed, maxTrackHeight, name, metadata }) {
13696
+ function BedTrack({ url, features, config, height, background, format, indexURL, indexed, maxTrackHeight, name, metadata, colorBy, onClick, onContextMenu }) {
13697
+ // Apply colorBy to in-memory features
13698
+ const coloredFeatures = useMemo(() => features && colorBy ? features.map(f => ({ ...f, color: colorBy(f) })) : features, [features, colorBy]);
13579
13699
  useTrackManager((browser) => {
13580
- if (features) {
13581
- return browser.addBedTrackWithFeatures(features, { config, height, background, maxTrackHeight, name, metadata });
13700
+ if (coloredFeatures) {
13701
+ return browser.addBedTrackWithFeatures(coloredFeatures, { config, height, background, maxTrackHeight, name, metadata });
13582
13702
  }
13583
13703
  if (!url)
13584
13704
  throw new Error('BedTrack requires either a `url` or `features` prop');
13585
13705
  return browser.addBedTrack(url, { config, height, background, format, indexURL, indexed, maxTrackHeight, name, metadata });
13586
- }, [url, features, format, indexURL, indexed], (track) => { if (config)
13587
- track.setConfig(config); }, [config, height, background, name]);
13706
+ }, [url, coloredFeatures, format, indexURL, indexed], (track) => { if (config)
13707
+ track.setConfig(config); }, [config, height, background, name], { onClick, onContextMenu });
13588
13708
  return null;
13589
13709
  }
13590
13710
 
@@ -13594,15 +13714,43 @@ function SequenceTrack({ config, maxTrackHeight }) {
13594
13714
  return null;
13595
13715
  }
13596
13716
 
13597
- function InteractionTrack({ url, config, background, format, indexURL, indexed, name, metadata }) {
13717
+ function InteractionTrack({ url, config, background, format, indexURL, indexed, name, metadata, onClick, onContextMenu }) {
13598
13718
  useTrackManager((browser) => browser.addInteractionTrack(url, { config, background, format, indexURL, indexed, name, metadata }), [url, format, indexURL, indexed], (track) => { if (config)
13599
- track.setConfig(config); }, [config, background, name]);
13719
+ track.setConfig(config); }, [config, background, name], { onClick, onContextMenu });
13600
13720
  return null;
13601
13721
  }
13602
13722
 
13603
- function GtxTrack({ url, experimentId, config, height, background, windowFunction, maxTrackHeight, name, metadata }) {
13723
+ function GtxTrack({ url, experimentId, config, height, background, windowFunction, maxTrackHeight, name, metadata, onClick, onContextMenu }) {
13604
13724
  useTrackManager((browser) => browser.addGtxTrack(url, { experimentId, config, height, background, windowFunction, maxTrackHeight, name, metadata }), [url, experimentId, windowFunction], (track) => { if (config)
13605
- track.setConfig(config); }, [config, height, background, name]);
13725
+ track.setConfig(config); }, [config, height, background, name], { onClick, onContextMenu });
13726
+ return null;
13727
+ }
13728
+
13729
+ /**
13730
+ * Declarative ROI set component. Renders nothing — manages ROIs on the
13731
+ * GenomeBrowser via context. Add/remove `<ROISet>` children to control ROIs.
13732
+ */
13733
+ function DeclarativeROISet({ rois, name = 'Declarative', color }) {
13734
+ const browser = useGenomeBrowser();
13735
+ const setRef = useRef(null);
13736
+ useEffect(() => {
13737
+ if (!browser)
13738
+ return;
13739
+ // Remove previous set if it exists
13740
+ if (setRef.current) {
13741
+ browser.removeROISet(setRef.current);
13742
+ setRef.current = null;
13743
+ }
13744
+ const config = { name, color, features: rois };
13745
+ const set = browser.addROISet(config);
13746
+ setRef.current = set;
13747
+ return () => {
13748
+ if (setRef.current) {
13749
+ browser.removeROISet(setRef.current);
13750
+ setRef.current = null;
13751
+ }
13752
+ };
13753
+ }, [browser, name, color, rois]);
13606
13754
  return null;
13607
13755
  }
13608
13756
 
@@ -14922,4 +15070,4 @@ var LoomInputDialog$1 = /*#__PURE__*/Object.freeze({
14922
15070
  LoomInputDialog: LoomInputDialog
14923
15071
  });
14924
15072
 
14925
- export { BedTrack, ChromosomeSelect, ExportControls, GeneTrack, GenomeBrowserContext, GtxTrack, InteractionTrack, LocusInput, LoomBrowser, Navbar, RulerTrack, SequenceTrack, WigTrack, WindowSize, ZoomControls, useBrowserEvent, useGenomeBrowser, useLocus };
15073
+ export { BedTrack, ChromosomeSelect, ExportControls, GeneTrack, GenomeBrowserContext, GtxTrack, InteractionTrack, LocusInput, LoomBrowser, Navbar, DeclarativeROISet as ROISet, RulerTrack, SequenceTrack, WigTrack, WindowSize, ZoomControls, useBrowserEvent, useGenomeBrowser, useLocus };