igv 3.6.0 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/igv.esm.js CHANGED
@@ -8447,7 +8447,7 @@ function regExpEscape(s) {
8447
8447
  function isGoogleURL(url) {
8448
8448
  return (url.includes("googleapis") && !url.includes("urlshortener")) ||
8449
8449
  isGoogleStorageURL(url) ||
8450
- isGoogleDriveURL(url);
8450
+ isGoogleDriveURL$1(url);
8451
8451
  }
8452
8452
 
8453
8453
  function isGoogleStorageURL(url) {
@@ -8457,7 +8457,7 @@ function isGoogleURL(url) {
8457
8457
  url.startsWith("https://storage.googleapis.com");
8458
8458
  }
8459
8459
 
8460
- function isGoogleDriveURL(url) {
8460
+ function isGoogleDriveURL$1(url) {
8461
8461
  return url.startsWith("https://www.googleapis.com/drive/v3/files");
8462
8462
  }
8463
8463
 
@@ -8610,7 +8610,7 @@ async function getAccessToken(scope) {
8610
8610
  // })
8611
8611
 
8612
8612
  function getScopeForURL(url) {
8613
- if (isGoogleDriveURL(url)) {
8613
+ if (isGoogleDriveURL$1(url)) {
8614
8614
  return "https://www.googleapis.com/auth/drive.file"
8615
8615
  } else if (isGoogleStorageURL(url)) {
8616
8616
  return "https://www.googleapis.com/auth/devstorage.read_only"
@@ -8829,7 +8829,7 @@ class IGVXhr {
8829
8829
  return buffer
8830
8830
  }
8831
8831
  } else {
8832
- if (isGoogleDriveURL(url) || url.startsWith("https://www.dropbox.com")) {
8832
+ if (isGoogleDriveURL$1(url) || url.startsWith("https://www.dropbox.com")) {
8833
8833
  return this.googleThrottle.add(async () => {
8834
8834
  return this._loadURL(url, options)
8835
8835
  })
@@ -8853,7 +8853,7 @@ class IGVXhr {
8853
8853
  options = options || {};
8854
8854
 
8855
8855
  let oauthToken;
8856
- if (isGoogleDriveURL(url)) {
8856
+ if (isGoogleDriveURL$1(url)) {
8857
8857
  // Google drive urls always require oAuth
8858
8858
  oauthToken = await getAccessToken("https://www.googleapis.com/auth/drive.file");
8859
8859
  } else {
@@ -8872,7 +8872,7 @@ class IGVXhr {
8872
8872
  }
8873
8873
  url = addApiKey(url);
8874
8874
 
8875
- if (isGoogleDriveURL(url)) {
8875
+ if (isGoogleDriveURL$1(url)) {
8876
8876
  addTeamDrive(url);
8877
8877
  }
8878
8878
 
@@ -12402,7 +12402,7 @@ for (let p of pairs) {
12402
12402
  complements.set(p2.toLowerCase(), p1.toLowerCase());
12403
12403
  }
12404
12404
 
12405
- function complementBase(base) {
12405
+ function complementBase$1(base) {
12406
12406
  return complements.has(base) ? complements.get(base) : base
12407
12407
  }
12408
12408
 
@@ -16987,6 +16987,35 @@ function findFeatureAfterCenter(featureList, center, direction = true) {
16987
16987
  return sortedList[low]
16988
16988
  }
16989
16989
 
16990
+ /**
16991
+ * Utilities for detecting problematic resources (local files, Google Drive URLs)
16992
+ * that cannot be reliably loaded when a session is shared or restored.
16993
+ */
16994
+
16995
+ /**
16996
+ * Check if an object is a local File instance
16997
+ * @param {*} obj - The object to check
16998
+ * @returns {boolean} True if the object is a File instance
16999
+ */
17000
+ function isLocalFile(obj) {
17001
+ return obj instanceof File
17002
+ }
17003
+
17004
+ /**
17005
+ * Check if a URL is a Google Drive URL
17006
+ * Google Drive URLs require authentication and will not work when shared.
17007
+ *
17008
+ * @param {string|*} url - The URL to check
17009
+ * @returns {boolean} True if the URL is a Google Drive URL
17010
+ */
17011
+ function isGoogleDriveURL(url) {
17012
+ if (typeof url !== 'string') {
17013
+ return false
17014
+ }
17015
+ // Match both googleapis.com/drive and drive.google.com URLs
17016
+ return url.includes('googleapis.com/drive') || url.includes('drive.google.com')
17017
+ }
17018
+
16990
17019
  const fixColor = (colorString) => {
16991
17020
  if (isString$3(colorString)) {
16992
17021
  return (colorString.indexOf(",") > 0 && !(colorString.startsWith("rgb(") || colorString.startsWith("rgba("))) ?
@@ -17611,7 +17640,20 @@ class TrackBase {
17611
17640
  }
17612
17641
  }
17613
17642
 
17614
- static localFileInspection(config) {
17643
+ /**
17644
+ * Prepare a track configuration for session serialization by identifying and marking
17645
+ * problematic resources (local files).
17646
+ *
17647
+ * Local files are converted to {file: filename} or {indexFile: filename}
17648
+ * Google Drive URLs are kept in the url/indexURL fields as-is and detected when loading
17649
+ *
17650
+ * This allows the configuration to be serialized while preserving information
17651
+ * about resources that cannot be automatically loaded when the session is restored.
17652
+ *
17653
+ * @param {Object} config - Track configuration to prepare
17654
+ * @returns {Object} Cleaned configuration with problematic resources marked
17655
+ */
17656
+ static prepareConfigForSession(config) {
17615
17657
 
17616
17658
  const cooked = Object.assign({}, config);
17617
17659
  const lut =
@@ -17620,8 +17662,9 @@ class TrackBase {
17620
17662
  indexURL: 'indexFile'
17621
17663
  };
17622
17664
 
17665
+ // Check for local File objects and convert to filename strings
17623
17666
  for (const key of ['url', 'indexURL']) {
17624
- if (cooked[key] && cooked[key] instanceof File) {
17667
+ if (cooked[key] && isLocalFile(cooked[key])) {
17625
17668
  cooked[lut[key]] = cooked[key].name;
17626
17669
  delete cooked[key];
17627
17670
  }
@@ -17631,8 +17674,6 @@ class TrackBase {
17631
17674
  }
17632
17675
 
17633
17676
  // Methods to support filtering api
17634
-
17635
-
17636
17677
  set filter(f) {
17637
17678
  this._filter = f;
17638
17679
  this.trackView.repaintViews();
@@ -35100,14 +35141,21 @@ function getExonPhase(exon) {
35100
35141
  return (3 - exon.readingFrame) % 3
35101
35142
  }
35102
35143
 
35103
- function getEonStart(exon) {
35144
+ function getCodingStart(exon) {
35104
35145
  return exon.cdStart || exon.start
35105
35146
  }
35106
35147
 
35107
- function getExonEnd(exon) {
35148
+ function getCodingEnd(exon) {
35108
35149
  return exon.cdEnd || exon.end
35109
35150
  }
35110
35151
 
35152
+ function getCodingLength(exon) {
35153
+ if (exon.utr) return 0
35154
+ const start = exon.cdStart || exon.start;
35155
+ const end = exon.cdEnd || exon.end;
35156
+ return end - start
35157
+ }
35158
+
35111
35159
  const aminoAcidSequenceRenderThreshold = 0.25;
35112
35160
 
35113
35161
  /**
@@ -35356,8 +35404,8 @@ function renderAminoAcidSequence(ctx, strand, leftExon, exon, riteExon, bpStart,
35356
35404
  };
35357
35405
 
35358
35406
  const phase = getExonPhase(exon);
35359
- let ss = getEonStart(exon);
35360
- let ee = getExonEnd(exon);
35407
+ let ss = getCodingStart(exon);
35408
+ let ee = getCodingEnd(exon);
35361
35409
 
35362
35410
  let bpTripletStart;
35363
35411
  let bpTripletEnd;
@@ -35526,7 +35574,7 @@ function getAminoAcidLetterWithExonGap(strand, phase, phaseExtentStart, phaseExt
35526
35574
  return undefined
35527
35575
  }
35528
35576
 
35529
- [ss, ee] = [getExonEnd(leftExon) - (3 - phase), getExonEnd(leftExon)];
35577
+ [ss, ee] = [getCodingEnd(leftExon) - (3 - phase), getCodingEnd(leftExon)];
35530
35578
  stringA = sequenceInterval.getSequence(ss, ee);
35531
35579
 
35532
35580
  if (!stringA) {
@@ -35545,7 +35593,7 @@ function getAminoAcidLetterWithExonGap(strand, phase, phaseExtentStart, phaseExt
35545
35593
  }
35546
35594
 
35547
35595
  const ritePhase = getExonPhase(riteExon);
35548
- const riteStart = getEonStart(riteExon);
35596
+ const riteStart = getCodingStart(riteExon);
35549
35597
  stringB = sequenceInterval.getSequence(riteStart, riteStart + ritePhase);
35550
35598
 
35551
35599
  if (!stringB) {
@@ -35565,7 +35613,7 @@ function getAminoAcidLetterWithExonGap(strand, phase, phaseExtentStart, phaseExt
35565
35613
  return undefined
35566
35614
  }
35567
35615
 
35568
- [ss, ee] = [getEonStart(riteExon), getEonStart(riteExon) + (3 - phase)];
35616
+ [ss, ee] = [getCodingStart(riteExon), getCodingStart(riteExon) + (3 - phase)];
35569
35617
  stringB = sequenceInterval.getSequence(ss, ee);
35570
35618
 
35571
35619
  if (!stringB) {
@@ -35585,7 +35633,7 @@ function getAminoAcidLetterWithExonGap(strand, phase, phaseExtentStart, phaseExt
35585
35633
  }
35586
35634
 
35587
35635
  const leftPhase = getExonPhase(leftExon);
35588
- const leftEnd = getExonEnd(leftExon);
35636
+ const leftEnd = getCodingEnd(leftExon);
35589
35637
  stringA = sequenceInterval.getSequence(leftEnd - leftPhase, leftEnd);
35590
35638
 
35591
35639
  if (!stringA) {
@@ -39013,7 +39061,7 @@ class TrackDbHub {
39013
39061
 
39014
39062
  const isContainer = (s.hasOwnProperty("superTrack") && !s.hasOwnProperty("bigDataUrl")) ||
39015
39063
  s.hasOwnProperty("compositeTrack") || s.hasOwnProperty("view") ||
39016
- (s.hasOwnProperty("container") && s.getOwnProperty("container").equals("multiWig"));
39064
+ (s.hasOwnProperty("container") && (s.getOwnProperty("container") === "multiWig"));
39017
39065
 
39018
39066
  // Find parent, if any. "group" containers can be implicit, all other types should be explicitly
39019
39067
  // defined before their children
@@ -40789,9 +40837,9 @@ class TrackViewport extends Viewport {
40789
40837
 
40790
40838
  if (typeof this.trackView.track.popupData === "function") {
40791
40839
 
40792
- popupTimerID = setTimeout(() => {
40840
+ popupTimerID = setTimeout(async () => {
40793
40841
 
40794
- const content = this.handleTrackClick(event);
40842
+ const content = await this.handleTrackClick(event);
40795
40843
  if (content) {
40796
40844
 
40797
40845
  if (false === event.shiftKey) {
@@ -40956,7 +41004,7 @@ class TrackViewport extends Viewport {
40956
41004
 
40957
41005
  }
40958
41006
 
40959
- handleTrackClick(event) {
41007
+ async handleTrackClick(event) {
40960
41008
 
40961
41009
  const clickState = this.createClickState(event);
40962
41010
 
@@ -40965,7 +41013,7 @@ class TrackViewport extends Viewport {
40965
41013
  }
40966
41014
 
40967
41015
  let track = this.trackView.track;
40968
- const dataList = track.popupData(clickState);
41016
+ const dataList = await track.popupData(clickState);
40969
41017
 
40970
41018
  const popupClickHandlerResult = this.browser.fireEvent('trackclick', [track, dataList, clickState.genomicLocation]);
40971
41019
 
@@ -44203,7 +44251,7 @@ class MergedTrack extends TrackBase {
44203
44251
  track.autoscaleGroup = name;
44204
44252
  }
44205
44253
  track.isMergedTrack = false;
44206
- browser.addTrack(track);
44254
+ await browser.addTrack(track);
44207
44255
  }
44208
44256
  await browser.updateViews();
44209
44257
  browser.reorderTracks();
@@ -44289,7 +44337,7 @@ class OverlayTrackButton extends NavbarButton {
44289
44337
 
44290
44338
  const mouseClickHandler = () => {
44291
44339
  this.setVisibility(false);
44292
- trackOverlayClickHandler.call(this);
44340
+ this.trackOverlayClickHandler();
44293
44341
  };
44294
44342
 
44295
44343
  this.boundMouseClickHandler = mouseClickHandler.bind(this);
@@ -44299,50 +44347,49 @@ class OverlayTrackButton extends NavbarButton {
44299
44347
  this.setVisibility(true);
44300
44348
 
44301
44349
  }
44302
- }
44303
44350
 
44304
- async function trackOverlayClickHandler(e) {
44351
+ async trackOverlayClickHandler() {
44305
44352
 
44306
- if (true === isOverlayTrackCriteriaMet(this.browser)) {
44353
+ if (true === isOverlayTrackCriteriaMet(this.browser)) {
44307
44354
 
44308
- const tracks = this.browser.getSelectedTrackViews().map(({track}) => track);
44309
- for (const track of tracks) {
44310
- track.selected = false;
44311
- }
44355
+ const tracks = this.browser.getSelectedTrackViews().map(({track}) => track);
44356
+ for (const track of tracks) {
44357
+ track.selected = false;
44358
+ }
44312
44359
 
44313
- // Flatten any merged tracks. Must do this before their removal
44314
- const flattenedTracks = [];
44315
- for (let t of tracks) {
44316
- if ("merged" === t.type) {
44317
- flattenedTracks.push(...t.tracks);
44318
- } else {
44319
- flattenedTracks.push(t);
44360
+ // Flatten any merged tracks. Must do this before their removal
44361
+ const flattenedTracks = [];
44362
+ for (let t of tracks) {
44363
+ if ("merged" === t.type) {
44364
+ flattenedTracks.push(...t.tracks);
44365
+ } else {
44366
+ flattenedTracks.push(t);
44367
+ }
44320
44368
  }
44321
- }
44322
44369
 
44323
- const config =
44324
- {
44325
- name: 'Overlay',
44326
- type: 'merged',
44327
- autoscale: false,
44328
- alpha: 0.5, //fudge * (1.0/tracks.length),
44329
- height: Math.max(...tracks.map(({height}) => height)),
44330
- order: Math.min(...tracks.map(({order}) => order)),
44331
- };
44370
+ const config =
44371
+ {
44372
+ name: 'Overlay',
44373
+ type: 'merged',
44374
+ autoscale: false,
44375
+ alpha: 0.5, //fudge * (1.0/tracks.length),
44376
+ height: Math.max(...tracks.map(({height}) => height)),
44377
+ order: Math.min(...tracks.map(({order}) => order))
44378
+ };
44332
44379
 
44333
- const mergedTrack = new MergedTrack(config, this.browser, flattenedTracks);
44380
+ const mergedTrack = new MergedTrack(config, this.browser, flattenedTracks);
44334
44381
 
44335
- for (const track of tracks) {
44336
- const idx = this.browser.trackViews.indexOf(track.trackView);
44337
- this.browser.trackViews.splice(idx, 1);
44338
- track.trackView.dispose();
44339
- }
44382
+ for (const track of tracks) {
44383
+ const idx = this.browser.trackViews.indexOf(track.trackView);
44384
+ this.browser.trackViews.splice(idx, 1);
44385
+ track.trackView.dispose();
44386
+ }
44340
44387
 
44341
- this.browser.addTrack(mergedTrack);
44342
- await mergedTrack.trackView.updateViews();
44343
- this.browser.reorderTracks();
44388
+ await this.browser.addTrack(mergedTrack);
44389
+ await mergedTrack.trackView.updateViews();
44390
+ this.browser.reorderTracks();
44391
+ }
44344
44392
  }
44345
-
44346
44393
  }
44347
44394
 
44348
44395
  function isOverlayTrackCriteriaMet(browser) {
@@ -47435,7 +47482,7 @@ class BaseModificationKey {
47435
47482
  this.base = base;
47436
47483
  this.strand = strand;
47437
47484
  this.modification = modification;
47438
- this.canonicalBase = this.strand === '+' ? this.base : complementBase(this.base);
47485
+ this.canonicalBase = this.strand === '+' ? this.base : complementBase$1(this.base);
47439
47486
  }
47440
47487
 
47441
47488
  getCanonicalBase() {
@@ -47485,7 +47532,7 @@ class BaseModificationSet {
47485
47532
  this.modification = modification;
47486
47533
  this.strand = strand;
47487
47534
  this.likelihoods = likelihoods;
47488
- this.canonicalBase = this.strand == '+' ? this.base : complementBase(this.base);
47535
+ this.canonicalBase = this.strand == '+' ? this.base : complementBase$1(this.base);
47489
47536
  this.key = BaseModificationKey.getKey(base, strand, modification);
47490
47537
  }
47491
47538
 
@@ -48918,6 +48965,818 @@ function computeLengthOnReference(cigarString) {
48918
48965
  return len
48919
48966
  }
48920
48967
 
48968
+ // Lazy import to avoid circular dependency
48969
+
48970
+ /**
48971
+ * Search for a feature by name across various data sources
48972
+ * This module is separate to avoid circular dependencies between search.js and hgvs.js
48973
+ */
48974
+
48975
+ const DEFAULT_SEARCH_CONFIG = {
48976
+ timeout: 5000,
48977
+ type: "plain",
48978
+ url: 'https://igv.org/genomes/locus.php?genome=$GENOME$&name=$FEATURE$',
48979
+ coords: 0
48980
+ };
48981
+
48982
+ /**
48983
+ * Search for a feature by name in MANE transcripts, searchable tracks, and web services
48984
+ * @param {Object} browser - The IGV browser instance
48985
+ * @param {string} name - The feature name to search for
48986
+ * @returns {Promise<Object|undefined>} The found feature or undefined
48987
+ */
48988
+ async function searchFeatures(browser, name) {
48989
+
48990
+ const searchConfig = browser.searchConfig || DEFAULT_SEARCH_CONFIG;
48991
+ let feature;
48992
+
48993
+ name = name.toUpperCase();
48994
+
48995
+ // Search MANE transcripts first, if available
48996
+ feature = await browser.genome.getManeTranscript(name);
48997
+ if (feature) {
48998
+ return feature
48999
+ }
49000
+
49001
+ const searchableTracks = browser.tracks.filter(t => t.searchable);
49002
+ for (let track of searchableTracks) {
49003
+ const feature = await track.search(name);
49004
+ if (feature) {
49005
+ return feature
49006
+ }
49007
+ }
49008
+
49009
+ // If still not found try webservice, if enabled
49010
+ if (browser.config && false !== browser.config.search) {
49011
+ try {
49012
+ feature = await searchWebService(browser, name, searchConfig);
49013
+ return feature // Might be undefined
49014
+ } catch (error) {
49015
+ console.log("Search service not available " + error);
49016
+ }
49017
+ }
49018
+
49019
+ }
49020
+
49021
+ /**
49022
+ * Search for a feature using a web service
49023
+ * @param {Object} browser - The IGV browser instance
49024
+ * @param {string} locus - The locus to search for
49025
+ * @param {Object} searchConfig - Search configuration
49026
+ * @returns {Promise<Object|undefined>} The search result
49027
+ */
49028
+ async function searchWebService(browser, locus, searchConfig) {
49029
+
49030
+ let path = searchConfig.url.replace("$FEATURE$", locus.toUpperCase());
49031
+ if (path.indexOf("$GENOME$") > -1) {
49032
+ path = path.replace("$GENOME$", (browser.genome.id ? browser.genome.id : "hg19"));
49033
+ }
49034
+ const options = searchConfig.timeout ? {timeout: searchConfig.timeout} : undefined;
49035
+ const result = await igvxhr.loadString(path, options);
49036
+
49037
+ return await processSearchResult(browser, result, searchConfig)
49038
+ }
49039
+
49040
+ /**
49041
+ * Process search results from web service
49042
+ * @param {Object} browser - The IGV browser instance
49043
+ * @param {string} result - The raw result from the web service
49044
+ * @param {Object} searchConfig - Search configuration
49045
+ * @returns {Promise<Object|undefined>} The processed search result
49046
+ */
49047
+ async function processSearchResult(browser, result, searchConfig) {
49048
+
49049
+ let results;
49050
+
49051
+ if ('plain' === searchConfig.type) {
49052
+ results = await parseSearchResults(browser, result);
49053
+ } else {
49054
+ results = JSON.parse(result);
49055
+ }
49056
+
49057
+ if (searchConfig.resultsField) {
49058
+ results = results[searchConfig.resultsField];
49059
+ }
49060
+
49061
+ if (!results || 0 === results.length) {
49062
+ return undefined
49063
+
49064
+ } else {
49065
+
49066
+ const chromosomeField = searchConfig.chromosomeField || "chromosome";
49067
+ const startField = searchConfig.startField || "start";
49068
+ const endField = searchConfig.endField || "end";
49069
+ const coords = searchConfig.coords || 1;
49070
+
49071
+
49072
+ let result;
49073
+ if (Array.isArray(results)) {
49074
+ // Ignoring all but first result for now
49075
+ // TODO -- present all and let user select if results.length > 1
49076
+ result = results[0];
49077
+ } else {
49078
+ // When processing search results from Ensembl REST API
49079
+ // Example: https://rest.ensembl.org/lookup/symbol/macaca_fascicularis/BRCA2?content-type=application/json
49080
+ result = results;
49081
+ }
49082
+
49083
+ if (!(result.hasOwnProperty(chromosomeField) && (result.hasOwnProperty(startField)))) {
49084
+ console.error("Search service results must include chromosome and start fields: " + result);
49085
+ }
49086
+
49087
+ const chr = result[chromosomeField];
49088
+ let start = result[startField] - coords;
49089
+ let end = result[endField];
49090
+ if (undefined === end) {
49091
+ end = start + 1;
49092
+ }
49093
+
49094
+ const locusObject = {chr, start, end};
49095
+
49096
+ // Some GTEX hacks
49097
+ if (searchConfig.geneField && searchConfig.snpField) {
49098
+ const name = result[searchConfig.geneField] || result[searchConfig.snpField]; // Should never have both
49099
+ if (name) locusObject.name = name.toUpperCase();
49100
+ }
49101
+
49102
+ return locusObject
49103
+ }
49104
+ }
49105
+
49106
+ /**
49107
+ * Parse the igv line-oriented (non json) search results.
49108
+ * NOTE: currently, and probably permanently, this will always be a single line
49109
+ * Example
49110
+ * EGFR chr7:55,086,724-55,275,031 refseq
49111
+ *
49112
+ * @param {Object} browser - The IGV browser instance
49113
+ * @param {string} data - The raw search result data
49114
+ * @returns {Array} Array of parsed search results
49115
+ */
49116
+ async function parseSearchResults(browser, data) {
49117
+
49118
+ const results = [];
49119
+ const lines = splitLines$3(data);
49120
+
49121
+ for (let line of lines) {
49122
+
49123
+ const tokens = line.split("\t");
49124
+
49125
+ if (tokens.length >= 3) {
49126
+ const locusTokens = tokens[1].split(":");
49127
+ const rangeTokens = locusTokens[1].split("-");
49128
+ results.push({
49129
+ chromosome: browser.genome.getChromosomeName(locusTokens[0].trim()),
49130
+ start: parseInt(rangeTokens[0].replace(/,/g, '')),
49131
+ end: parseInt(rangeTokens[1].replace(/,/g, '')),
49132
+ name: tokens[0].toUpperCase()
49133
+ });
49134
+ }
49135
+ }
49136
+
49137
+ return results
49138
+
49139
+ }
49140
+
49141
+ const log = console;
49142
+
49143
+ function isValidHGVS(notation) {
49144
+ if (!notation) return false
49145
+ // We only need to validate that we can parse the notation in the search method.
49146
+ // Check for basic structure: <accession>:g.<position> or <accession>:c.<position> or <accession>:p.<position>
49147
+ // We don't validate the variant details since we only need the position for searching.
49148
+
49149
+ // Genomic: g.\d+ (with optional range and anything after)
49150
+ const genomic = "g\\.\\d+.*";
49151
+ // Coding: c. followed by optional -, *, then digits, with optional intronic offset and anything after
49152
+ const coding = "c\\.[-*]?\\d+.*";
49153
+ // Non-coding: n. followed by optional leading '-' then digits, anything after
49154
+ const nonCoding = "n\\.-?\\d+.*";
49155
+ // Protein: p. followed by optional AA letters, digits, with optional range and anything after
49156
+ const protein = "p\\.[A-Za-z*]*\\d+.*";
49157
+ // Optional gene symbol in parentheses immediately after accession
49158
+ const accessionWithOptionalGene = "^[A-Za-z0-9_.]+(?:\\([^)]+\\))?";
49159
+
49160
+ const pattern = new RegExp(accessionWithOptionalGene + ":(?:" + genomic + "|" + coding + "|" + nonCoding + "|" + protein + ")$");
49161
+ return pattern.test(notation)
49162
+ }
49163
+
49164
+ /**
49165
+ * Searches for the given HGVS notation in the provided genome.
49166
+ * Returns a SearchResult with the corresponding chromosome and position if found,
49167
+ * otherwise returns null.
49168
+ */
49169
+ async function search$1(hgvs, browser) {
49170
+
49171
+ if (!isValidHGVS(hgvs)) {
49172
+ return null
49173
+ }
49174
+
49175
+ const genome = browser.genome;
49176
+
49177
+ // Determine type and extract accession and position
49178
+ const idxG = hgvs.indexOf(":g.");
49179
+ const idxC = hgvs.indexOf(":c.");
49180
+ const idxP = hgvs.indexOf(":p.");
49181
+ const idxN = hgvs.indexOf(":n.");
49182
+ let type;
49183
+ let idx;
49184
+ if (idxG >= 0) {
49185
+ type = "g";
49186
+ idx = idxG;
49187
+ } else if (idxC >= 0) {
49188
+ type = "c";
49189
+ idx = idxC;
49190
+ } else if (idxN >= 0) {
49191
+ type = "n";
49192
+ idx = idxN;
49193
+ } else if (idxP >= 0) {
49194
+ type = "p";
49195
+ idx = idxP;
49196
+ } else {
49197
+ return null
49198
+ }
49199
+ let accession = hgvs.substring(0, idx);
49200
+ // Strip optional trailing gene symbol in parentheses, e.g., "NM_000302.3(PLOD1)" -> "NM_000302.3"
49201
+ if (accession.endsWith(")")) {
49202
+ const openIdx = accession.lastIndexOf('(');
49203
+ if (openIdx > 0) {
49204
+ accession = accession.substring(0, openIdx);
49205
+ }
49206
+ }
49207
+ const positionPart = hgvs.substring(idx + 3); // skip ':g.' or ':c.' or ':p.'
49208
+
49209
+ if (type === "g") {
49210
+ if (!positionPart) return null
49211
+ // Match genomic positions including:
49212
+ // - Simple position: 123
49213
+ // - Range: 123_456
49214
+ // - Uncertain positions: 123_? or ?_456 or (123_456)
49215
+ // Extract just the numeric positions, ignoring variant notation after
49216
+ const match = positionPart.match(/^\(?(\d+)(?:_(\d+|\?))?/);
49217
+ if (!match) return null
49218
+ const start = parseInt(match[1], 10);
49219
+ const endGroup = match[2];
49220
+ // If end is '?' or undefined, use start as end
49221
+ const end = (endGroup && endGroup !== '?') ? parseInt(endGroup, 10) : start;
49222
+ const aliasRecord = await genome.getAliasRecord(accession);
49223
+ const chr = aliasRecord ? aliasRecord.chr : accession;
49224
+ return {chr, start: start - 1, end: end}
49225
+
49226
+ } else if (type === "p") {
49227
+
49228
+ // Protein notation not supported for search currently. The code below is ported from Java and kept for
49229
+ // future reference.
49230
+ return null
49231
+
49232
+ // // Protein position mapping: map codon(s) to genomic span.
49233
+ // const transcript = await getTranscript(browser, accession)
49234
+ // if (!transcript) return null
49235
+ //
49236
+ // const proteinPart = positionPart
49237
+ // const pm = proteinPart.match(/^[A-Za-z*]{0,3}(\d+)(?:_[A-Za-z*]{0,3}(\d+))?/)
49238
+ // if (!pm) return null
49239
+ // let p1 = parseInt(pm[1], 10)
49240
+ // const p2Str = pm[2]
49241
+ // let p2 = p1
49242
+ // if (p2Str) {
49243
+ // p2 = parseInt(p2Str, 10)
49244
+ // }
49245
+ //
49246
+ // const codon1 = transcript.getCodon(genome, transcript.chr, p1)
49247
+ // if (!codon1 || !codon1.isGenomePositionsSet()) return null
49248
+ // let start1 = Math.min(...codon1.getGenomePositions())
49249
+ // let end1 = Math.max(...codon1.getGenomePositions())
49250
+ //
49251
+ // let regionStart = start1
49252
+ // let regionEnd = end1
49253
+ // if (p2 !== p1) {
49254
+ // const codon2 = transcript.getCodon(genome, transcript.chr, p2)
49255
+ // if (!codon2 || !codon2.isGenomePositionsSet()) return null
49256
+ // let start2 = Math.min(...codon2.getGenomePositions())
49257
+ // let end2 = Math.max(...codon2.getGenomePositions())
49258
+ // regionStart = Math.min(start1, start2)
49259
+ // regionEnd = Math.max(end1, end2)
49260
+ // }
49261
+ // const halfOpenEnd = regionEnd + 1
49262
+ // return {chr: transcript.chr, start: regionStart, end: halfOpenEnd}
49263
+
49264
+ } else if (type === "n") {
49265
+
49266
+ // Non-coding transcript mapping: n.123 or n.-123 maps relative to transcript start
49267
+ const transcript = await getTranscript(browser, accession);
49268
+ if (!transcript) return null
49269
+
49270
+ // Parse signed position with optional range and intronic offset (e.g., n.123, n.123_456, n.-7080_-1781, n.123+5)
49271
+ const matcher = positionPart.match(/^(-?\d+)(?:_(-?\d+))?([+-]\d+)?/);
49272
+ if (!matcher) return null
49273
+
49274
+ const t1 = parseInt(matcher[1], 10);
49275
+ const t2Str = matcher[2];
49276
+ const t2 = t2Str != null ? parseInt(t2Str, 10) : t1;
49277
+
49278
+ // Map both transcript positions to genomic
49279
+ let g1 = transcriptPositionToGenomicPosition(transcript, t1);
49280
+ let g2 = transcriptPositionToGenomicPosition(transcript, t2);
49281
+ if (g1 <= 0 || g2 <= 0) return null
49282
+
49283
+ // Apply intronic offset (if any) to BOTH endpoints, strand-aware
49284
+ const offsetStr = matcher[3];
49285
+ if (offsetStr) {
49286
+ let offset = parseInt(offsetStr, 10);
49287
+ if (transcript.strand === '-') offset = -offset;
49288
+ g1 += offset;
49289
+ g2 += offset;
49290
+ }
49291
+
49292
+ // Normalize to genomic span regardless of strand
49293
+ const regionStart = Math.min(g1, g2);
49294
+ const regionEndInclusive = Math.max(g1, g2);
49295
+ const halfOpenEnd = regionEndInclusive + 1;
49296
+ return {chr: transcript.chr, start: regionStart, end: halfOpenEnd}
49297
+
49298
+ } else { // "c"
49299
+
49300
+ const transcript = await getTranscript(browser, accession);
49301
+ if (transcript) {
49302
+ // UTR 5' c.-N with optional range and intronic offset (e.g., c.-211_-215 or c.-211-1058C>G)
49303
+ const utr5Matcher = positionPart.match(/^-(\d+)(?:_-(\d+))?([+-]\d+)?/);
49304
+ if (utr5Matcher) {
49305
+ const n1 = parseInt(utr5Matcher[1], 10);
49306
+ const n2Str = utr5Matcher[2];
49307
+ const n2 = n2Str != null ? parseInt(n2Str, 10) : null;
49308
+ const firstCodingGenomic = codingToGenomePosition(transcript, 1);
49309
+ if (firstCodingGenomic > 0) {
49310
+ let g1 = transcript.strand === '+' ? (firstCodingGenomic - n1) : (firstCodingGenomic + n1);
49311
+ let g2 = g1;
49312
+ if (n2 != null) {
49313
+ g2 = transcript.strand === '+' ? (firstCodingGenomic - n2) : (firstCodingGenomic + n2);
49314
+ }
49315
+ // Apply intronic offset (single value) to both ends if present
49316
+ const offsetStr = utr5Matcher[3];
49317
+ if (offsetStr) {
49318
+ let offset = parseInt(offsetStr, 10);
49319
+ if (transcript.strand === '-') offset = -offset;
49320
+ g1 += offset;
49321
+ g2 += offset;
49322
+ }
49323
+ const start = Math.min(g1, g2);
49324
+ const endInclusive = Math.max(g1, g2);
49325
+ const endExclusive = endInclusive + 1;
49326
+ return {resultType: "LOCUS", chr: transcript.chr, start, end: endExclusive}
49327
+ }
49328
+ return null
49329
+ }
49330
+
49331
+ // UTR 3' c.*N with optional range and intronic offset (e.g., c.*526_*529delATCA or c.*123+45)
49332
+ const utr3Matcher = positionPart.match(/^\*(\d+)(?:_\*(\d+))?([+-]\d+)?/);
49333
+ if (utr3Matcher) {
49334
+ const n1 = parseInt(utr3Matcher[1], 10);
49335
+ const n2Str = utr3Matcher[2];
49336
+ const n2 = n2Str != null ? parseInt(n2Str, 10) : null;
49337
+ let codingLen = 0;
49338
+ if (transcript.exons) {
49339
+ for (const exon of transcript.exons) {
49340
+ codingLen += getCodingLength(exon);
49341
+ }
49342
+ }
49343
+ if (codingLen > 0) {
49344
+ const lastCodingGenomic = codingToGenomePosition(transcript, codingLen);
49345
+ if (lastCodingGenomic > 0) {
49346
+ let g1 = transcript.strand === '+' ? (lastCodingGenomic + n1) : (lastCodingGenomic - n1);
49347
+ let g2 = g1;
49348
+ if (n2 != null) {
49349
+ g2 = transcript.strand === '+' ? (lastCodingGenomic + n2) : (lastCodingGenomic - n2);
49350
+ }
49351
+ // Apply intronic offset (single value) to both ends if present
49352
+ const offsetStr = utr3Matcher[3];
49353
+ if (offsetStr) {
49354
+ let offset = parseInt(offsetStr, 10);
49355
+ if (transcript.strand === '-') offset = -offset;
49356
+ g1 += offset;
49357
+ g2 += offset;
49358
+ }
49359
+ const start = Math.min(g1, g2);
49360
+ const endInclusive = Math.max(g1, g2);
49361
+ const endExclusive = endInclusive + 1;
49362
+ return {resultType: "LOCUS", chr: transcript.chr, start, end: endExclusive}
49363
+ }
49364
+ }
49365
+ return null
49366
+ }
49367
+
49368
+ // CDS position with optional range
49369
+ // First parse endpoints c.X(_Y)? ignoring intronic offsets
49370
+ const cpos = positionPart.match(/^(\d+)(?:_(\d+))?/);
49371
+ if (!cpos) return null
49372
+ const c1 = parseInt(cpos[1], 10);
49373
+ const c2Str = cpos[2];
49374
+ const c2 = c2Str != null ? parseInt(c2Str, 10) : c1;
49375
+
49376
+ // Map both coding positions to genomic
49377
+ let g1 = codingToGenomePosition(transcript, c1);
49378
+ let g2 = codingToGenomePosition(transcript, c2);
49379
+ if (g1 <= 0 || g2 <= 0) return null
49380
+
49381
+ // Now parse optional intronic offsets for each endpoint separately
49382
+ // Patterns like: 123+5 or 123-2 at the beginning, optionally followed by _ and second with offset
49383
+ const offs = positionPart.match(/^(\d+)([+-]\d+)?(?:_(\d+)([+-]\d+)?)?/);
49384
+ if (offs) {
49385
+ const off1Str = offs[2];
49386
+ const off2Str = offs[4];
49387
+ if (off1Str) {
49388
+ let off1 = parseInt(off1Str, 10);
49389
+ if (transcript.strand === '-') off1 = -off1;
49390
+ g1 += off1;
49391
+ }
49392
+ if (off2Str) {
49393
+ let off2 = parseInt(off2Str, 10);
49394
+ if (transcript.strand === '-') off2 = -off2;
49395
+ g2 += off2;
49396
+ }
49397
+ }
49398
+
49399
+ // If there is no explicit second coding position, ensure single-site locus
49400
+ if (c2Str == null) {
49401
+ g2 = g1;
49402
+ }
49403
+
49404
+ const start = Math.min(g1, g2);
49405
+ const endInclusive = Math.max(g1, g2);
49406
+ const endExclusive = endInclusive + 1;
49407
+ return {chr: transcript.chr, start, end: endExclusive}
49408
+ }
49409
+ return null
49410
+ }
49411
+
49412
+ }
49413
+
49414
+ async function getTranscript(browser, accession) {
49415
+ return searchFeatures(browser, accession)
49416
+ }
49417
+
49418
+ /**
49419
+ * Convert a transcript position (1-based, from transcription start) to genomic position
49420
+ * for non-coding transcripts. Walks through exons to find the genomic coordinate.
49421
+ */
49422
+ function transcriptPositionToGenomicPosition(transcript, transcriptPos) {
49423
+ // Handle positions upstream of transcript start (negative n. values)
49424
+ if (transcriptPos <= 0) {
49425
+ const d = Math.abs(transcriptPos);
49426
+ return transcript.strand === '+' ? (transcript.getStart() - d) : (transcript.getEnd() + d)
49427
+ }
49428
+
49429
+ const exons = transcript.exons;
49430
+ if (!exons || exons.length === 0) {
49431
+ // No exons, treat as simple feature
49432
+ if (transcript.strand === '+') {
49433
+ return transcript.getStart() + transcriptPos - 1
49434
+ } else {
49435
+ return transcript.getEnd() - transcriptPos + 1
49436
+ }
49437
+ }
49438
+
49439
+ const positive = transcript.strand === '+';
49440
+ let accumulatedLength = 0;
49441
+
49442
+ // Sort exons appropriately based on strand
49443
+ const sortedExons = exons.slice();
49444
+ if (!positive) {
49445
+ sortedExons.sort((e1, e2) => e2.getStart() - e1.getStart());
49446
+ } else {
49447
+ sortedExons.sort((e1, e2) => e1.getStart() - e2.getStart());
49448
+ }
49449
+
49450
+ for (const exon of sortedExons) {
49451
+ const exonLength = exon.getEnd() - exon.getStart();
49452
+ if (accumulatedLength + exonLength >= transcriptPos) {
49453
+ // Position is in this exon
49454
+ const offsetInExon = transcriptPos - accumulatedLength - 1;
49455
+ if (positive) {
49456
+ return exon.getStart() + offsetInExon
49457
+ } else {
49458
+ return exon.getEnd() - offsetInExon - 1
49459
+ }
49460
+ }
49461
+ accumulatedLength += exonLength;
49462
+ }
49463
+
49464
+ // Position beyond transcript end
49465
+ return -1
49466
+ }
49467
+
49468
+ /**
49469
+ * Translate a 1-based coding position to a 0-based genomic position. Supports HGVS parsing
49470
+ *
49471
+ * @param codingPosition 1-based coding position
49472
+ * @return 0-based genomic position, or -1 if not found.
49473
+ */
49474
+ function codingToGenomePosition(feature, codingPosition) {
49475
+ if (codingPosition <= 0) {
49476
+ return -1
49477
+ }
49478
+ const cdna = codingPosition - 1; // Convert to 0-based
49479
+
49480
+ const exons = feature.exons;
49481
+ if (!exons) {
49482
+ return -1
49483
+ }
49484
+
49485
+ const strand = feature.strand;
49486
+ // if (strand === 'NONE') {
49487
+ // throw new Error("Cannot translate from coding position on an unstranded feature.")
49488
+ // }
49489
+ const positive = strand === '+';
49490
+
49491
+ let codingLength = 0;
49492
+ for (let i = 0; i < exons.length; i++) {
49493
+ const exon = positive ? exons[i] : exons[exons.length - 1 - i];
49494
+ const exonCodingLength = getCodingLength(exon);
49495
+ if (codingLength + exonCodingLength > cdna) {
49496
+ const cdnaOffset = cdna - codingLength;
49497
+ if (positive) {
49498
+ return getCodingStart(exon) + cdnaOffset
49499
+ } else {
49500
+ return getCodingEnd(exon) - 1 - cdnaOffset
49501
+ }
49502
+ }
49503
+ codingLength += exonCodingLength;
49504
+ }
49505
+
49506
+ return -1
49507
+ }
49508
+
49509
+ /**
49510
+ * Returns genomic HGVS notation: <RefSeqAccession>:g.<position>
49511
+ * Example: NC_000001.11:g.1234567
49512
+ */
49513
+ async function getHGVSPosition(genome, chr, position) {
49514
+ try {
49515
+ const aliasRecord = await genome.getAliasRecord(chr);
49516
+ let accession = null;
49517
+
49518
+ if (aliasRecord) {
49519
+ for (const alias of Object.values(aliasRecord)) {
49520
+ if (alias.startsWith("NC_") || alias.startsWith("NT_") || alias.startsWith("NW_") ||
49521
+ alias.startsWith("NG_") || alias.startsWith("NM_") || alias.startsWith("NR_") ||
49522
+ alias.startsWith("NP_")) {
49523
+ accession = alias;
49524
+ break
49525
+ }
49526
+ }
49527
+ }
49528
+
49529
+ if (!accession) {
49530
+ accession = chr;
49531
+ }
49532
+
49533
+ return `${accession}:g.${position}`
49534
+ } catch (e) {
49535
+ log.error("Error getting HGVS position", e);
49536
+ return null
49537
+ }
49538
+ }
49539
+
49540
+ /**
49541
+ * Returns HGVS annotation for the position, for ref and alt bases. If a MANE transcript is available that is
49542
+ * used with coding notation (c.), otherwise genome position is used with genomic notation (g.).
49543
+ * Example: NM_000302.3:c.1234A>G or NM_000302.3:c.123+5T>C (intronic) or NC_000001.11:g.1234567G>A
49544
+ *
49545
+ * @param genome The genome
49546
+ * @param chr The chromosome name
49547
+ * @param position The genomic position (0-based)
49548
+ * @param reference The reference base (single-character string)
49549
+ * @param alternate The alternate base (single-character string)
49550
+ * @return {Promise<string|null>} HGVS notation string, or null if error
49551
+ */
49552
+ async function createHGVSAnnotation(genome, chr, position, reference, alternate) {
49553
+
49554
+ try {
49555
+ const transcript = await genome.getManeTranscriptAt(chr, position);
49556
+
49557
+ if (transcript && transcript.exons) {
49558
+
49559
+ // Ensure bases are uppercase
49560
+ reference = reference.toUpperCase();
49561
+ alternate = alternate.toUpperCase();
49562
+
49563
+ if (transcript.strand === '-') {
49564
+ reference = complementBase(reference);
49565
+ alternate = complementBase(alternate);
49566
+ }
49567
+
49568
+
49569
+ let positionString = "";
49570
+
49571
+ let transcriptName = transcript.name;
49572
+ for (const key of Object.keys(transcript)) {
49573
+ const value = transcript[key];
49574
+ if (typeof value === 'string' && (value.startsWith("NM_") || value.startsWith("NR_"))) {
49575
+ transcriptName = value;
49576
+ break
49577
+ }
49578
+ }
49579
+
49580
+ if (transcriptName) {
49581
+ // Check if position is within an exon (coding or non-coding)
49582
+ let positionIsInExon = false;
49583
+ for (const exon of transcript.exons) {
49584
+ if (position >= exon.start && position < exon.end) {
49585
+ positionIsInExon = true;
49586
+ break
49587
+ }
49588
+ }
49589
+
49590
+ const positive = transcript.strand === '+';
49591
+
49592
+ if (positionIsInExon) {
49593
+ // Try to convert to coding position
49594
+ const codingPosition = genomeToCodingPosition(position, positive, transcript.exons);
49595
+
49596
+ if (codingPosition >= 0) {
49597
+ // Position is in a coding region, return c. notation (1-based)
49598
+ positionString = `${transcriptName}:c.${codingPosition + 1}`;
49599
+ } else {
49600
+ // Position is in an exon but not coding - check if in UTR
49601
+ const firstCodingPos = codingToGenomePosition(transcript, 1);
49602
+ if (firstCodingPos > 0) {
49603
+ // Calculate total coding length
49604
+ let codingLen = 0;
49605
+ for (const exon of transcript.exons) {
49606
+ codingLen += getCodingLength(exon);
49607
+ }
49608
+ const lastCodingPos = codingToGenomePosition(transcript, codingLen);
49609
+
49610
+ // Check if in 5' UTR
49611
+ if ((positive && position < firstCodingPos) || (!positive && position > firstCodingPos)) {
49612
+ const distance = Math.abs(position - firstCodingPos);
49613
+ positionString = `${transcriptName}:c.-${distance}`;
49614
+ }
49615
+ // Check if in 3' UTR
49616
+ else if ((positive && position >= lastCodingPos) || (!positive && position <= lastCodingPos)) {
49617
+ const distance = Math.abs(position - lastCodingPos) + 1;
49618
+ positionString = `${transcriptName}:c.*${distance}`;
49619
+ }
49620
+ }
49621
+ }
49622
+ } else {
49623
+ // Position is intronic - find nearest exon boundary
49624
+ // For HGVS, we reference the last coding base in the nearest exon
49625
+ let nearestExonEdge = -1;
49626
+ let nearestCodingPos = -1;
49627
+ let minDistance = Number.MAX_SAFE_INTEGER;
49628
+
49629
+ for (const exon of transcript.exons) {
49630
+ if (getCodingLength(exon) === 0) continue // Skip non-coding exons
49631
+
49632
+ // Check distance to the last coding base at the start side of the exon
49633
+ // exon.start is 0-based inclusive
49634
+ const distToStart = Math.abs(position - exon.start);
49635
+ if (distToStart > 0 && distToStart < minDistance) {
49636
+ minDistance = distToStart;
49637
+ nearestExonEdge = exon.start;
49638
+ // Get coding position of first base in this exon
49639
+ nearestCodingPos = genomeToCodingPosition(getCodingStart(exon), positive, transcript.exons);
49640
+ }
49641
+
49642
+ // Check distance to the last coding base at the end side of the exon
49643
+ // exon.end is 0-based exclusive, so last base is at end-1
49644
+ const distToEnd = Math.abs(position - (exon.end - 1));
49645
+ if (distToEnd > 0 && distToEnd < minDistance) {
49646
+ minDistance = distToEnd;
49647
+ nearestExonEdge = exon.end - 1;
49648
+ // Get coding position of last base in this exon
49649
+ nearestCodingPos = genomeToCodingPosition(getCodingEnd(exon) - 1, positive, transcript.exons);
49650
+ }
49651
+ }
49652
+
49653
+ if (nearestCodingPos >= 0) {
49654
+ // Calculate offset: positive = downstream of exon, negative = upstream of exon
49655
+ let offset = position - nearestExonEdge;
49656
+ // For positive strand: + means to the right, - means to the left
49657
+ // For negative strand: + means to the left (genomically), - means to the right
49658
+ // But in HGVS, the sign is relative to transcript direction, so we need to flip for negative strand
49659
+ if (!positive) {
49660
+ offset = -offset;
49661
+ }
49662
+ const sign = offset >= 0 ? "+" : "";
49663
+ positionString = `${transcriptName}:c.${nearestCodingPos + 1}${sign}${offset}`;
49664
+ }
49665
+ }
49666
+ }
49667
+
49668
+ return positionString + reference + ">" + alternate
49669
+ }
49670
+
49671
+ // Fallback to genomic notation
49672
+ const aliasRecord = await genome.getAliasRecord(chr);
49673
+ let accession = chr;
49674
+
49675
+ if (aliasRecord) {
49676
+ for (const alias of Object.values(aliasRecord)) {
49677
+ if (alias.startsWith("NC_") || alias.startsWith("NT_") || alias.startsWith("NW_") ||
49678
+ alias.startsWith("NG_") || alias.startsWith("NM_") || alias.startsWith("NR_") ||
49679
+ alias.startsWith("NP_")) {
49680
+ accession = alias;
49681
+ break
49682
+ }
49683
+ }
49684
+ }
49685
+
49686
+ // HGVS genomic coordinate is 1-based; position parameter is 0-based
49687
+ return `${accession}:g.${position + 1}${reference}>${alternate}`
49688
+ } catch (e) {
49689
+ log.error("Error creating HGVS annotation", e);
49690
+ return null
49691
+ }
49692
+ }
49693
+
49694
+ // Helper function to complement a base (string)
49695
+ function complementBase(base) {
49696
+ const complementMap = { 'A': 'T', 'T': 'A', 'C': 'G', 'G': 'C' };
49697
+ return complementMap[base] || base
49698
+ }
49699
+
49700
+ function genomeToCodingPosition(genomePosition, positive, exons) {
49701
+
49702
+ if (exons) {
49703
+
49704
+ /*
49705
+ We loop over all exons, either from the beginning or the end.
49706
+ Increment position only on coding regions.
49707
+ */
49708
+
49709
+ let codingOffset = 0;
49710
+
49711
+ for (let exnum = 0; exnum < exons.length; exnum++) {
49712
+
49713
+ const exon = positive ? exons[exnum] : exons[exons.length - 1 - exnum];
49714
+
49715
+ if (exon.start <= genomePosition && exon.end > genomePosition) {
49716
+ const delta = positive
49717
+ ? genomePosition - getCodingStart(exon)
49718
+ : getCodingEnd(exon) - genomePosition - 1;
49719
+ return codingOffset + delta
49720
+ }
49721
+
49722
+ codingOffset += getCodingLength(exon);
49723
+ }
49724
+ }
49725
+ return -1
49726
+ }
49727
+
49728
+
49729
+
49730
+ const HGVS = {
49731
+ isValidHGVS,
49732
+ search: search$1,
49733
+ getHGVSPosition,
49734
+ createHGVSAnnotation
49735
+ };
49736
+
49737
+ /**
49738
+ * ClinVar utilities for searching and retrieving ClinVar variation information
49739
+ */
49740
+
49741
+ /**
49742
+ * Get the ClinVar URL for the given HGVS notation
49743
+ * @param {string} hgvsNotation - The HGVS notation string to search for
49744
+ * @return {Promise<string|null>} The ClinVar variation URL, or null if not found or error occurs
49745
+ */
49746
+ async function getClinVarURL(hgvsNotation) {
49747
+ try {
49748
+ const encodedHgvs = encodeURIComponent(hgvsNotation);
49749
+ const esearchUrl = `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?` +
49750
+ `db=clinvar&term=${encodedHgvs}&retmode=json`;
49751
+
49752
+ const response = await fetch(esearchUrl);
49753
+
49754
+ if (!response.ok) {
49755
+ console.error(`HTTP error! status: ${response.status}`);
49756
+ return null
49757
+ }
49758
+
49759
+ // Parse JSON response to get the first ClinVar accession
49760
+ const json = await response.json();
49761
+ const esearchResult = json.esearchresult;
49762
+
49763
+ if (esearchResult.count > 0) {
49764
+ const uid = esearchResult.idlist[0];
49765
+ return `https://www.ncbi.nlm.nih.gov/clinvar/variation/${uid}/`
49766
+ } else {
49767
+ return null
49768
+ }
49769
+
49770
+ } catch (e) {
49771
+ console.error("Error fetching ClinVar URL", e);
49772
+ return null
49773
+ }
49774
+ }
49775
+
49776
+ const ClinVar = {
49777
+ getClinVarURL
49778
+ };
49779
+
48921
49780
  const READ_PAIRED_FLAG = 0x1;
48922
49781
  const PROPER_PAIR_FLAG = 0x2;
48923
49782
  const READ_UNMAPPED_FLAG = 0x4;
@@ -49052,13 +49911,22 @@ class BamAlignment {
49052
49911
  return (genomicLocation >= s && genomicLocation <= (s + l))
49053
49912
  }
49054
49913
 
49055
- popupData(genomicLocation, hiddenTags, showTags) {
49914
+ /**
49915
+ * Return data to show in the popup. Elements are either strings (for raw HTML) or
49916
+ * objects with name, value, borderTop properties.
49917
+ *
49918
+ * @param genomicLocation - 0-based genomic location
49919
+ * @param hiddenTags - Set of bam tags to hide
49920
+ * @param showTags - Set of bam tags to show (overrides hide/show rules)
49921
+ * @returns {*[]}
49922
+ */
49923
+ async popupData(genomicLocation, hiddenTags, showTags, refBase, genome) {
49056
49924
 
49057
49925
  // if the user clicks on a base next to an insertion, show just the
49058
49926
  // inserted bases in a popup (like in desktop IGV).
49059
49927
  const nameValues = [];
49060
49928
 
49061
- // Consert genomic location to int
49929
+ // Convert genomic location to int
49062
49930
  genomicLocation = Math.floor(genomicLocation);
49063
49931
 
49064
49932
  if (this.insertions) {
@@ -49077,6 +49945,26 @@ class BamAlignment {
49077
49945
 
49078
49946
  nameValues.push({name: 'Read Name', value: this.readName});
49079
49947
 
49948
+
49949
+ // HGVS annotations for variants, and ClinVar links if available
49950
+ const readBase = this.readBaseAt(genomicLocation);
49951
+ if (refBase) {
49952
+ if (readBase && readBase !== refBase && readBase !== '*') {
49953
+ const hgvsNotation = await HGVS.createHGVSAnnotation(genome, this.chr, genomicLocation, refBase, readBase);
49954
+ if (hgvsNotation) {
49955
+ const clinVarURL = await ClinVar.getClinVarURL(hgvsNotation);
49956
+ if (clinVarURL) {
49957
+ nameValues.push({
49958
+ name: 'ClinVar',
49959
+ value: `<a href='${clinVarURL}' target='_blank'>${hgvsNotation}</a>`
49960
+ });
49961
+ } else {
49962
+ nameValues.push({name: 'HGVS', value: hgvsNotation});
49963
+ }
49964
+ }
49965
+ }
49966
+ }
49967
+
49080
49968
  // Sample
49081
49969
  // Read group
49082
49970
  nameValues.push('<hr/>');
@@ -49152,7 +50040,7 @@ class BamAlignment {
49152
50040
 
49153
50041
  nameValues.push('<hr/>');
49154
50042
  nameValues.push({name: 'Genomic Location: ', value: numberFormatter$1(1 + genomicLocation)});
49155
- nameValues.push({name: 'Read Base:', value: this.readBaseAt(genomicLocation)});
50043
+ nameValues.push({name: 'Read Base:', value: readBase});
49156
50044
  nameValues.push({name: 'Base Quality:', value: this.readBaseQualityAt(genomicLocation)});
49157
50045
 
49158
50046
  const bmSets = this.getBaseModificationSets();
@@ -51357,8 +52245,8 @@ class BamSource {
51357
52245
  if (alignmentContainer.hasAlignments) {
51358
52246
  const sequence = await genome.getSequence(chr, alignmentContainer.start, alignmentContainer.end);
51359
52247
  if (sequence) {
51360
- alignmentContainer.coverageMap.refSeq = sequence; // TODO -- fix this
51361
- alignmentContainer.sequence = sequence; // TODO -- fix this
52248
+ alignmentContainer.coverageMap.refSeq = sequence;
52249
+ alignmentContainer.sequence = sequence;
51362
52250
  return alignmentContainer
51363
52251
  } else {
51364
52252
  console.error("No sequence for: " + chr + ":" + alignmentContainer.start + "-" + alignmentContainer.end);
@@ -53679,7 +54567,7 @@ class AlignmentTrack extends TrackBase {
53679
54567
  highlightColor: undefined,
53680
54568
  minTLEN: undefined,
53681
54569
  maxTLEN: undefined,
53682
- tagColorPallete: "Set1",
54570
+ tagColorPallete: "Set1"
53683
54571
  }
53684
54572
 
53685
54573
  _colorTables = new Map()
@@ -54029,7 +54917,7 @@ class AlignmentTrack extends TrackBase {
54029
54917
 
54030
54918
  IGVGraphics.strokeLine(ctx, sPixel, yStrokedLine, ePixel, yStrokedLine, {
54031
54919
  strokeStyle: color,
54032
- lineWidth: 2,
54920
+ lineWidth: 2
54033
54921
  });
54034
54922
 
54035
54923
  // Add gap width as text like Java IGV if it fits nicely and is a multi-base gap
@@ -54038,7 +54926,7 @@ class AlignmentTrack extends TrackBase {
54038
54926
  IGVGraphics.fillRect(ctx, textStart - 1, y - 1, gapTextWidth + 2, 12, {fillStyle: "white"});
54039
54927
  IGVGraphics.fillText(ctx, gapLenText, textStart, y + 10, {
54040
54928
  'font': 'normal 10px monospace',
54041
- 'fillStyle': this.deletionTextColor,
54929
+ 'fillStyle': this.deletionTextColor
54042
54930
  });
54043
54931
  }
54044
54932
  }
@@ -54081,7 +54969,7 @@ class AlignmentTrack extends TrackBase {
54081
54969
  if (this.showInsertionText && insertionBlock.len > 1 && basePixelWidth > textPixelWidth) {
54082
54970
  IGVGraphics.fillText(ctx, insertLenText, xBlockStart + 1, y + 10, {
54083
54971
  'font': 'normal 10px monospace',
54084
- 'fillStyle': this.insertionTextColor,
54972
+ 'fillStyle': this.insertionTextColor
54085
54973
  });
54086
54974
  }
54087
54975
  lastXBlockStart = xBlockStart;
@@ -54251,7 +55139,7 @@ class AlignmentTrack extends TrackBase {
54251
55139
  height: alignmentHeight
54252
55140
  },
54253
55141
  baseColor,
54254
- readChar,
55142
+ readChar
54255
55143
  });
54256
55144
  }
54257
55145
 
@@ -54261,13 +55149,28 @@ class AlignmentTrack extends TrackBase {
54261
55149
  return blockBasesToDraw
54262
55150
  }
54263
55151
  }
55152
+ }
54264
55153
 
54265
- };
54266
-
54267
- popupData(clickState) {
55154
+ async popupData(clickState) {
54268
55155
  const clickedObject = this.getClickedObject(clickState);
54269
- return clickedObject?.popupData(clickState.genomicLocation, this.hiddenTags, this.showTags)
54270
- };
55156
+ if (clickedObject) {
55157
+
55158
+ // Determine reference base at clicked position, used for HGVS notation
55159
+ let refBase;
55160
+ if (clickedObject.chr) {
55161
+ const viewport = clickState.viewport;
55162
+ const alignmentContainer = viewport.cachedFeatures;
55163
+ const coverageMap = alignmentContainer?.coverageMap;
55164
+ const refseq = coverageMap?.refSeq;
55165
+ if (refseq) {
55166
+ const genomicLocation = Math.floor(clickState.genomicLocation);
55167
+ refBase = refseq.charAt(genomicLocation - coverageMap.bpStart).toUpperCase();
55168
+ }
55169
+ }
55170
+
55171
+ return clickedObject.popupData(clickState.genomicLocation, this.hiddenTags, this.showTags, refBase, this.browser.genome)
55172
+ }
55173
+ }
54271
55174
 
54272
55175
  /**
54273
55176
  * Return menu items for the AlignmentTrack
@@ -55040,7 +55943,7 @@ class AlignmentTrack extends TrackBase {
55040
55943
  this.colorTable = new PaletteColorTable(this.tagColorPallete);
55041
55944
  }
55042
55945
  color = this.colorTable.getColor(tagValue);
55043
-
55946
+
55044
55947
  }
55045
55948
  break
55046
55949
  }
@@ -55197,7 +56100,7 @@ function drawModifications(ctx,
55197
56100
 
55198
56101
 
55199
56102
  const base = key.base;
55200
- const compl = complementBase(base);
56103
+ const compl = complementBase$1(base);
55201
56104
 
55202
56105
  const modifiable = coverageMap.getCount(pos, base) + coverageMap.getCount(pos, compl);
55203
56106
  const detectable = modificationCounts.simplexModifications.has(key.modification) ?
@@ -55230,7 +56133,7 @@ function drawModifications(ctx,
55230
56133
 
55231
56134
  const DEFAULT_COVERAGE_COLOR = "rgb(150, 150, 150)";
55232
56135
 
55233
- class CoverageTrack {
56136
+ class CoverageTrack {
55234
56137
 
55235
56138
 
55236
56139
  constructor(config, parent) {
@@ -55242,7 +56145,7 @@ class CoverageTrack {
55242
56145
  this.top = 0;
55243
56146
 
55244
56147
  this.autoscale = config.autoscale || config.max === undefined;
55245
- if(config.coverageColor) {
56148
+ if (config.coverageColor) {
55246
56149
  this.color = config.coverageColor;
55247
56150
  }
55248
56151
 
@@ -55259,11 +56162,15 @@ class CoverageTrack {
55259
56162
  return this.parent.coverageTrackHeight
55260
56163
  }
55261
56164
 
56165
+ get browser() {
56166
+ return this.parent.browser
56167
+ }
56168
+
55262
56169
  draw(options) {
55263
56170
 
55264
56171
  const pixelTop = options.pixelTop;
55265
56172
  pixelTop + options.pixelHeight;
55266
- const nucleotideColors = this.parent.browser.nucleotideColors;
56173
+ const nucleotideColors = this.browser.nucleotideColors;
55267
56174
 
55268
56175
  if (pixelTop > this.height) {
55269
56176
  return //scrolled out of view
@@ -55297,7 +56204,8 @@ class CoverageTrack {
55297
56204
  fillStyle: color,
55298
56205
  strokeStyle: color
55299
56206
  });
55300
- const w = Math.max(1, 1.0 / bpPerPixel);
56207
+
56208
+ const w = Math.max(1, 1.0 / bpPerPixel);
55301
56209
  for (let i = 0, len = coverageMap.coverage.length; i < len; i++) {
55302
56210
 
55303
56211
  const bp = (coverageMap.bpStart + i);
@@ -55366,8 +56274,9 @@ class CoverageTrack {
55366
56274
  const coverageMap = features.coverageMap;
55367
56275
  const coverageMapIndex = Math.floor(genomicLocation - coverageMap.bpStart);
55368
56276
  const coverage = coverageMap.coverage[coverageMapIndex];
55369
- if(coverage) {
56277
+ if (coverage) {
55370
56278
  return {
56279
+ reference: coverageMap.refSeq ? coverageMap.refSeq.charAt(coverageMapIndex).toUpperCase() : undefined,
55371
56280
  coverage: coverage,
55372
56281
  baseModCounts: features.baseModCounts,
55373
56282
  hoverText: () => coverageMap.coverage[coverageMapIndex].hoverText()
@@ -55375,60 +56284,64 @@ class CoverageTrack {
55375
56284
  }
55376
56285
  }
55377
56286
 
55378
- popupData(clickState) {
56287
+ async popupData(clickState) {
55379
56288
 
55380
56289
  const nameValues = [];
55381
56290
 
55382
- const {coverage, baseModCounts} = this.getClickedObject(clickState);
56291
+ const {reference, coverage, baseModCounts} = this.getClickedObject(clickState);
55383
56292
  if (coverage) {
55384
56293
  const genomicLocation = Math.floor(clickState.genomicLocation);
55385
56294
  const referenceFrame = clickState.viewport.referenceFrame;
55386
56295
 
55387
56296
  nameValues.push(referenceFrame.chr + ":" + numberFormatter$1(1 + genomicLocation));
55388
-
55389
56297
  nameValues.push({name: 'Total Count', value: coverage.total});
56298
+ nameValues.push('<HR/>');
55390
56299
 
55391
56300
  // A
55392
- let tmp = coverage.posA + coverage.negA;
55393
- if (tmp > 0) tmp = tmp.toString() + " (" + Math.round((tmp / coverage.total) * 100.0) + "%, " + coverage.posA + "+, " + coverage.negA + "- )";
55394
- nameValues.push({name: 'A', value: tmp});
55395
-
55396
- // C
55397
- tmp = coverage.posC + coverage.negC;
55398
- if (tmp > 0) tmp = tmp.toString() + " (" + Math.round((tmp / coverage.total) * 100.0) + "%, " + coverage.posC + "+, " + coverage.negC + "- )";
55399
- nameValues.push({name: 'C', value: tmp});
55400
-
55401
- // G
55402
- tmp = coverage.posG + coverage.negG;
55403
- if (tmp > 0) tmp = tmp.toString() + " (" + Math.round((tmp / coverage.total) * 100.0) + "%, " + coverage.posG + "+, " + coverage.negG + "- )";
55404
- nameValues.push({name: 'G', value: tmp});
55405
-
55406
- // T
55407
- tmp = coverage.posT + coverage.negT;
55408
- if (tmp > 0) tmp = tmp.toString() + " (" + Math.round((tmp / coverage.total) * 100.0) + "%, " + coverage.posT + "+, " + coverage.negT + "- )";
55409
- nameValues.push({name: 'T', value: tmp});
55410
-
55411
- // N
55412
- tmp = coverage.posN + coverage.negN;
55413
- if (tmp > 0) tmp = tmp.toString() + " (" + Math.round((tmp / coverage.total) * 100.0) + "%, " + coverage.posN + "+, " + coverage.negN + "- )";
55414
- nameValues.push({name: 'N', value: tmp});
56301
+ for (let b of ['A', 'C', 'G', 'T', 'N']) {
56302
+ let tmp = coverage[`pos${b}`] + coverage[`neg${b}`];
56303
+ tmp = tmp.toString() + " (" + Math.round((tmp / coverage.total) * 100.0) + "%, " + coverage[`pos${b}`] + "+, " + coverage[`neg${b}`] + "- )";
56304
+ nameValues.push({name: b, value: tmp});
56305
+ }
55415
56306
 
55416
- nameValues.push('<HR/>');
55417
- nameValues.push({name: 'DEL', value: coverage.del.toString()});
55418
- nameValues.push({name: 'INS', value: coverage.ins.toString()});
56307
+ if (coverage.del > 0) nameValues.push({name: 'DEL', value: coverage.del.toString()});
56308
+ if (coverage.ins > 0) nameValues.push({name: 'INS', value: coverage.ins.toString()});
55419
56309
 
55420
- if(baseModCounts) {
56310
+ if (baseModCounts) {
55421
56311
  nameValues.push('<hr/>');
55422
56312
  nameValues.push(...baseModCounts.popupData(genomicLocation, this.parent.colorBy));
55423
56313
 
55424
56314
  }
55425
56315
 
55426
- }
56316
+ // HGVS annotations for variants, and ClinVar links if available
56317
+ if (reference) {
56318
+ let first = true;
56319
+ for (let b of ['A', 'C', 'G', 'T']) {
56320
+ let count = coverage[`pos${b}`] + coverage[`neg${b}`];
56321
+ if (count > 0 && reference !== b) {
56322
+ if (first) {
56323
+ nameValues.push('<hr/>');
56324
+ first = false;
56325
+ }
56326
+ const hgvsNotation = await HGVS.createHGVSAnnotation(this.browser.genome, referenceFrame.chr, genomicLocation, reference, b);
56327
+ const clinVarURL = await ClinVar.getClinVarURL(hgvsNotation);
56328
+ if (clinVarURL) {
56329
+ nameValues.push({
56330
+ name: 'ClinVar',
56331
+ value: `<a href='${clinVarURL}' target='_blank'>${hgvsNotation}</a>`
56332
+ });
56333
+ } else {
56334
+ nameValues.push({name: 'HGVS', value: hgvsNotation});
56335
+ }
56336
+ }
56337
+ }
56338
+ }
55427
56339
 
55428
- return nameValues
55429
56340
 
55430
- }
56341
+ return nameValues
55431
56342
 
56343
+ }
56344
+ }
55432
56345
  }
55433
56346
 
55434
56347
  class BAMTrack extends TrackBase {
@@ -72360,39 +73273,6 @@ const SV_COLOR_TABLE = new ColorTable({
72360
73273
  '*': '#002eff'
72361
73274
  });
72362
73275
 
72363
- const DEFAULT_SEARCH_CONFIG = {
72364
- timeout: 5000,
72365
- type: "plain",
72366
- url: 'https://igv.org/genomes/locus.php?genome=$GENOME$&name=$FEATURE$',
72367
- coords: 0
72368
- };
72369
-
72370
- async function searchFeatures(browser, name) {
72371
-
72372
- const searchConfig = browser.searchConfig || DEFAULT_SEARCH_CONFIG;
72373
- let feature;
72374
-
72375
- name = name.toUpperCase();
72376
- const searchableTracks = browser.tracks.filter(t => t.searchable);
72377
- for (let track of searchableTracks) {
72378
- const feature = await track.search(name);
72379
- if (feature) {
72380
- return feature
72381
- }
72382
- }
72383
-
72384
- // If still not found try webservice, if enabled
72385
- if (browser.config && false !== browser.config.search) {
72386
- try {
72387
- feature = await searchWebService(browser, name, searchConfig);
72388
- return feature // Might be undefined
72389
- } catch (error) {
72390
- console.log("Search service not available " + error);
72391
- }
72392
- }
72393
-
72394
- }
72395
-
72396
73276
  /**
72397
73277
  * Return an object representing the locus of the given string. Object is of the form
72398
73278
  * {
@@ -72419,6 +73299,13 @@ async function search(browser, string) {
72419
73299
 
72420
73300
  const searchForLocus = async (locus) => {
72421
73301
 
73302
+ if (HGVS.isValidHGVS(locus)) {
73303
+ const hgvsResult = await HGVS.search(locus, browser);
73304
+ if (hgvsResult) {
73305
+ return hgvsResult
73306
+ }
73307
+ }
73308
+
72422
73309
  if (locus.trim().toLowerCase() === "all" || locus === "*") {
72423
73310
  if (browser.genome.wholeGenomeView) {
72424
73311
  const wgChr = browser.genome.getChromosome("all");
@@ -72444,12 +73331,12 @@ async function search(browser, string) {
72444
73331
 
72445
73332
  // Not a locus string, search track annotations
72446
73333
  const feature = await searchFeatures(browser, locus);
72447
- if(feature) {
73334
+ if (feature) {
72448
73335
  locusObject = {
72449
73336
  chr: feature.chr,
72450
73337
  start: feature.start,
72451
73338
  end: feature.end,
72452
- name: (feature.name || locus).toUpperCase(),
73339
+ name: (feature.name || locus).toUpperCase()
72453
73340
 
72454
73341
  };
72455
73342
  }
@@ -72574,110 +73461,6 @@ function parseLocusString(locus, isSoftclipped = false) {
72574
73461
 
72575
73462
  }
72576
73463
 
72577
- async function searchWebService(browser, locus, searchConfig) {
72578
-
72579
- let path = searchConfig.url.replace("$FEATURE$", locus.toUpperCase());
72580
- if (path.indexOf("$GENOME$") > -1) {
72581
- path = path.replace("$GENOME$", (browser.genome.id ? browser.genome.id : "hg19"));
72582
- }
72583
- const options = searchConfig.timeout ? {timeout: searchConfig.timeout} : undefined;
72584
- const result = await igvxhr.loadString(path, options);
72585
-
72586
- return processSearchResult(browser, result, searchConfig)
72587
- }
72588
-
72589
- function processSearchResult(browser, result, searchConfig) {
72590
-
72591
- let results;
72592
-
72593
- if ('plain' === searchConfig.type) {
72594
- results = parseSearchResults(browser, result);
72595
- } else {
72596
- results = JSON.parse(result);
72597
- }
72598
-
72599
- if (searchConfig.resultsField) {
72600
- results = results[searchConfig.resultsField];
72601
- }
72602
-
72603
- if (!results || 0 === results.length) {
72604
- return undefined
72605
-
72606
- } else {
72607
-
72608
- const chromosomeField = searchConfig.chromosomeField || "chromosome";
72609
- const startField = searchConfig.startField || "start";
72610
- const endField = searchConfig.endField || "end";
72611
- const coords = searchConfig.coords || 1;
72612
-
72613
-
72614
- let result;
72615
- if (Array.isArray(results)) {
72616
- // Ignoring all but first result for now
72617
- // TODO -- present all and let user select if results.length > 1
72618
- result = results[0];
72619
- } else {
72620
- // When processing search results from Ensembl REST API
72621
- // Example: https://rest.ensembl.org/lookup/symbol/macaca_fascicularis/BRCA2?content-type=application/json
72622
- result = results;
72623
- }
72624
-
72625
- if (!(result.hasOwnProperty(chromosomeField) && (result.hasOwnProperty(startField)))) {
72626
- console.error("Search service results must include chromosome and start fields: " + result);
72627
- }
72628
-
72629
- const chr = result[chromosomeField];
72630
- let start = result[startField] - coords;
72631
- let end = result[endField];
72632
- if (undefined === end) {
72633
- end = start + 1;
72634
- }
72635
-
72636
- const locusObject = {chr, start, end};
72637
-
72638
- // Some GTEX hacks
72639
- result.type ? result.type : "gene";
72640
- if (searchConfig.geneField && searchConfig.snpField) {
72641
- const name = result[searchConfig.geneField] || result[searchConfig.snpField]; // Should never have both
72642
- if (name) locusObject.name = name.toUpperCase();
72643
- }
72644
-
72645
- return locusObject
72646
- }
72647
- }
72648
-
72649
- /**
72650
- * Parse the igv line-oriented (non json) search results.
72651
- * NOTE: currently, and probably permanently, this will always be a single line
72652
- * Example
72653
- * EGFR chr7:55,086,724-55,275,031 refseq
72654
- *
72655
- */
72656
- function parseSearchResults(browser, data) {
72657
-
72658
- const results = [];
72659
- const lines = splitLines$3(data);
72660
-
72661
- for (let line of lines) {
72662
-
72663
- const tokens = line.split("\t");
72664
-
72665
- if (tokens.length >= 3) {
72666
- const locusTokens = tokens[1].split(":");
72667
- const rangeTokens = locusTokens[1].split("-");
72668
- results.push({
72669
- chromosome: browser.genome.getChromosomeName(locusTokens[0].trim()),
72670
- start: parseInt(rangeTokens[0].replace(/,/g, '')),
72671
- end: parseInt(rangeTokens[1].replace(/,/g, '')),
72672
- name: tokens[0].toUpperCase()
72673
- });
72674
- }
72675
- }
72676
-
72677
- return results
72678
-
72679
- }
72680
-
72681
73464
  class QTLTrack extends TrackBase {
72682
73465
 
72683
73466
  constructor(config, browser) {
@@ -75693,7 +76476,7 @@ function createReferenceFrameList(loci, genome, browserFlanking, minimumBases, v
75693
76476
  })
75694
76477
  }
75695
76478
 
75696
- const _version = "3.6.0";
76479
+ const _version = "3.7.1";
75697
76480
  function version() {
75698
76481
  return _version
75699
76482
  }
@@ -78705,7 +79488,7 @@ class Genome {
78705
79488
  this.sequence = await loadSequence(config, this.browser);
78706
79489
 
78707
79490
  // Load cytobands. This is optional but required to support the ideogram. Only needed for whole genome view
78708
- if(false !== config.showIdeogram && false !== config.wholeGenomeView) {
79491
+ if (false !== config.showIdeogram && false !== config.wholeGenomeView) {
78709
79492
  if (config.cytobandURL) {
78710
79493
  this.cytobandSource = new CytobandFile(config.cytobandURL, Object.assign({}, config));
78711
79494
  } else if (config.cytobandBbURL) {
@@ -78713,8 +79496,8 @@ class Genome {
78713
79496
  }
78714
79497
  }
78715
79498
 
78716
- // Search for chromosomes, that is an array of chromosome objects containing name and length. This is
78717
- // optional but required to support whole genome view.
79499
+ // Search for chromosomes, that is an array of chromosome objects containing name and length. This is
79500
+ // optional but required to support whole genome view.
78718
79501
  if (this.sequence.chromosomes) {
78719
79502
  this.chromosomes = this.sequence.chromosomes;
78720
79503
  } else if (config.chromSizesURL) {
@@ -78750,7 +79533,7 @@ class Genome {
78750
79533
  // Trim to remove non-existent chromosomes
78751
79534
  await this.chromAlias.preload(this.#wgChromosomeNames);
78752
79535
  this.#wgChromosomeNames =
78753
- this.#wgChromosomeNames.map(c => this.getChromosomeName(c)).filter(c => this.chromosomes.has(c));
79536
+ this.#wgChromosomeNames.map(c => this.getChromosomeName(c)).filter(c => this.chromosomes.has(c));
78754
79537
  } else {
78755
79538
  this.#wgChromosomeNames = trimSmallChromosomes(this.chromosomes);
78756
79539
  await this.chromAlias.preload(this.#wgChromosomeNames);
@@ -78994,6 +79777,75 @@ class Genome {
78994
79777
  getHubURLs() {
78995
79778
  return this.config.hubs
78996
79779
  }
79780
+
79781
+ /**
79782
+ * Return the Mane transcript with the given name, or null if not found. We also check the refseq historical
79783
+ * db if available for backward compatibility. This is only available for hg38.
79784
+ * @param {string} name - The name of the Mane transcript to search for.
79785
+ * @return {Promise<Object|null>} A Promise resolving to the Mane transcript object if found, or null otherwise.
79786
+ */
79787
+ async getManeTranscript(name) {
79788
+
79789
+ if (!this.maneFeatureSource && this.config.maneBbURL) {
79790
+ this.loadManeFeatureSource();
79791
+ }
79792
+ if (this.maneFeatureSource) {
79793
+ const feature = await this.maneFeatureSource.search(name);
79794
+ if (feature) {
79795
+ return feature
79796
+ }
79797
+ }
79798
+ if (!this.rsDBFeatureSource && this.config.rsdbURL) {
79799
+ this.rsDBFeatureSource = new BWSource({url: this.config.rsdbURL}, this);
79800
+ }
79801
+ if (this.rsDBFeatureSource) {
79802
+ const feature = await this.rsDBFeatureSource.search(name);
79803
+ if (feature) {
79804
+ return feature
79805
+ }
79806
+ }
79807
+ return null
79808
+ }
79809
+
79810
+ /**
79811
+ * Return the Mane transcript overlapping the given position, or null if none found.
79812
+ *
79813
+ * @param chr Chromosome name (e.g., "chr1", "chrX") in which to search for the transcript.
79814
+ * @param position Genomic position (0-based coordinate) to check for overlap with a Mane transcript.
79815
+ * @return {Promise<*|null>} The feature representing the Mane transcript overlapping the specified position, or null if none is found.
79816
+ */
79817
+ async getManeTranscriptAt(chr, position) {
79818
+ if (!this.maneFeatureSource && this.config.maneBbURL) {
79819
+ this.loadManeFeatureSource();
79820
+ }
79821
+ if (this.maneFeatureSource) {
79822
+ try {
79823
+ const start = position;
79824
+ const end = position + 1;
79825
+ const features = await this.maneFeatureSource.getFeatures({chr, start, end});
79826
+ if (features) {
79827
+ for (const feature of features) {
79828
+ if (feature.start <= position && feature.end >= position) {
79829
+ return feature
79830
+ }
79831
+ }
79832
+ }
79833
+ } catch (e) {
79834
+ console.error("Error fetching MANE transcript", e);
79835
+ }
79836
+ }
79837
+ return null
79838
+ }
79839
+
79840
+ loadManeFeatureSource() {
79841
+ if (this.config.maneBbURL != null) {
79842
+ const bbConfig = {url: this.config.maneBbURL};
79843
+ if (this.config.maneTrixURL) {
79844
+ bbConfig.trixURL = this.config.maneTrixURL;
79845
+ }
79846
+ this.maneFeatureSource = new BWSource(bbConfig, this);
79847
+ }
79848
+ }
78997
79849
  }
78998
79850
 
78999
79851
  /**
@@ -79811,10 +80663,7 @@ class Browser {
79811
80663
 
79812
80664
  let session;
79813
80665
  if (options.url || options.file) {
79814
- session = await Browser.loadSessionFile(options, this.config);
79815
- // if (options.parentApp``) {
79816
- // session.parentApp = options.parentApp
79817
- // }
80666
+ session = await Browser.loadSessionFile(options);
79818
80667
  } else {
79819
80668
  session = options;
79820
80669
  }
@@ -79828,7 +80677,7 @@ class Browser {
79828
80677
  * @param options
79829
80678
  * @returns {Promise<*|XMLSession>}
79830
80679
  */
79831
- static async loadSessionFile(options, defaults) {
80680
+ static async loadSessionFile(options) {
79832
80681
 
79833
80682
  const urlOrFile = options.url || options.file;
79834
80683
 
@@ -79857,7 +80706,7 @@ class Browser {
79857
80706
  config = await igvxhr.loadJson(urlOrFile);
79858
80707
  }
79859
80708
  }
79860
- setDefaults(config, defaults);
80709
+
79861
80710
  return config
79862
80711
  }
79863
80712
 
@@ -79868,6 +80717,9 @@ class Browser {
79868
80717
  */
79869
80718
  async loadSessionObject(session) {
79870
80719
 
80720
+ // Capture current configuration options that might be missing from session
80721
+ setDefaults(session, this.config);
80722
+
79871
80723
  // prepare to load a new session, discarding DOM and state
79872
80724
  this.cleanHouseForSession();
79873
80725
  this.config = session;
@@ -79972,16 +80824,19 @@ class Browser {
79972
80824
 
79973
80825
  // Sample info
79974
80826
  const localSampleInfoFiles = [];
80827
+ const googleDriveSampleInfoFiles = [];
79975
80828
  if (session.sampleinfo) {
79976
80829
  const sampleInfoArray = Array.isArray(session.sampleinfo) ? session.sampleinfo : [session.sampleinfo];
79977
80830
  for (const sampleInfoConfig of sampleInfoArray) {
79978
- // The "file" property is recorded in the session when a local file is referenced. It can't be used
79979
- // on reloading, its only purpose is to present an alert to the user. This could also be used
79980
- // to prompt the user to load the file manually, but we don't currently do that.
79981
80831
  if (sampleInfoConfig.file) {
79982
80832
  localSampleInfoFiles.push(sampleInfoConfig.file);
79983
80833
  } else {
79984
- await this.sampleInfo.loadSampleInfo(sampleInfoConfig);
80834
+ const googleDriveItem = this.#createGoogleDriveItemIfPresent(sampleInfoConfig, 'Sample info', 'url', 'filename', 'Google Drive file');
80835
+ if (googleDriveItem) {
80836
+ googleDriveSampleInfoFiles.push(googleDriveItem);
80837
+ } else {
80838
+ await this.sampleInfo.loadSampleInfo(sampleInfoConfig);
80839
+ }
79985
80840
  }
79986
80841
  }
79987
80842
  }
@@ -79996,22 +80851,40 @@ class Browser {
79996
80851
  trackConfigurations.push({type: "sequence", order: defaultSequenceTrackOrder, removable: false});
79997
80852
  }
79998
80853
 
79999
- const localTrackFileNames = trackConfigurations.filter((config) => undefined !== config.file).map(({file}) => file);
80854
+ // Extract problematic resources from track configurations
80855
+ const { localFileItems, googleDriveItems } = this.#extractProblematicResources(
80856
+ trackConfigurations,
80857
+ localSampleInfoFiles,
80858
+ googleDriveSampleInfoFiles
80859
+ );
80000
80860
 
80001
- const localIndexFileNames = trackConfigurations.filter((config) => undefined !== config.indexFile).map(({indexFile}) => indexFile);
80002
- if (localIndexFileNames.length > 0) {
80003
- localTrackFileNames.push(...localIndexFileNames);
80004
- }
80861
+ // Display warning if problematic resources are found
80862
+ if (localFileItems.length > 0 || googleDriveItems.length > 0) {
80863
+ let message = 'Local and Google Drive files cannot be loaded from a saved session. The following file(s) will not be restored with this session.\n\n';
80005
80864
 
80006
- if (localSampleInfoFiles.length > 0) {
80007
- localTrackFileNames.push(...localSampleInfoFiles);
80008
- }
80865
+ // Add local file items
80866
+ for (const item of localFileItems) {
80867
+ message += `Local file name: ${item.fileName}\n`;
80868
+ message += `Track name: ${item.trackName}\n\n`;
80869
+
80870
+ }
80871
+
80872
+ // Add Google Drive items
80873
+ for (const item of googleDriveItems) {
80874
+ message += `Google Drive file name: ${item.fileName}\n`;
80875
+ message += `Track name: ${item.trackName}\n\n`;
80876
+
80877
+ }
80009
80878
 
80010
- if (localTrackFileNames.length > 0) {
80011
- alert(`Local files cannot be loaded automatically.\nThis session contains references to these local files:\n${localTrackFileNames.map(str => ` ${str}`).join('\n')}`);
80879
+ alert(message);
80012
80880
  }
80013
80881
 
80014
- const nonLocalTrackConfigurations = trackConfigurations.filter((config) => undefined === config.file);
80882
+ const nonLocalTrackConfigurations = trackConfigurations.filter((config) =>
80883
+ undefined === config.file &&
80884
+ undefined === config.indexFile &&
80885
+ // Filter out tracks with Google Drive URLs in url/indexURL fields
80886
+ !(config.url && isGoogleDriveURL(config.url)) &&
80887
+ !(config.indexURL && isGoogleDriveURL(config.indexURL)));
80015
80888
 
80016
80889
  // Maintain track order unless explicitly set
80017
80890
  let trackOrder = 1;
@@ -80907,7 +81780,7 @@ class Browser {
80907
81780
  }
80908
81781
 
80909
81782
  minimumBases() {
80910
- return this.config.minimumBases
81783
+ return this.config.minimumBases ?? 40
80911
81784
  }
80912
81785
 
80913
81786
  // Zoom in by a factor of 2, keeping the same center location
@@ -81278,11 +82151,6 @@ class Browser {
81278
82151
  }
81279
82152
 
81280
82153
  json["reference"] = this.genome.toJSON();
81281
- if (json.reference.fastaURL instanceof File) { // Test specifically for File. Other types of File-like objects might be savable) {
81282
- throw new Error(`Error. Sessions cannot include local file references ${json.reference.fastaURL.name}.`)
81283
- } else if (json.reference.indexURL instanceof File) { // Test specifically for File. Other types of File-like objects might be savable) {
81284
- throw new Error(`Error. Sessions cannot include local file references ${json.reference.indexURL.name}.`)
81285
- }
81286
82154
 
81287
82155
  // Build locus array (multi-locus view). Use the first track to extract the loci, any track could be used.
81288
82156
  const locus = [];
@@ -81323,9 +82191,9 @@ class Browser {
81323
82191
 
81324
82192
  let config;
81325
82193
  if (typeof track.getState === "function") {
81326
- config = TrackBase.localFileInspection(track.getState());
82194
+ config = TrackBase.prepareConfigForSession(track.getState());
81327
82195
  } else if (track.config) {
81328
- config = TrackBase.localFileInspection(track.config);
82196
+ config = TrackBase.prepareConfigForSession(track.config);
81329
82197
  }
81330
82198
 
81331
82199
  if (config) {
@@ -81356,37 +82224,214 @@ class Browser {
81356
82224
 
81357
82225
  json["tracks"] = trackJson;
81358
82226
 
81359
- const localFileDetections = [];
81360
- for (const json of trackJson) {
81361
- for (const key of Object.keys(json)) {
81362
- if ('file' === key || 'indexFile' === key) {
81363
- localFileDetections.push(json[key]);
81364
- }
82227
+ // Sample info
82228
+ if (this.config.sampleinfo) {
82229
+ json["sampleinfo"] = this.config.sampleinfo;
82230
+ }
82231
+
82232
+ // Validate reference genome and warn about problematic resources
82233
+ this._validateAndWarnResources(json);
82234
+
82235
+ return json
82236
+ }
82237
+
82238
+ /**
82239
+ * Get a display identifier for a Google Drive file.
82240
+ * Returns the provided filename if available, otherwise falls back to a default string.
82241
+ * Note: Filenames should always be present in saved sessions since Google Drive files
82242
+ * can only be added when the user is authenticated.
82243
+ *
82244
+ * @param {string|undefined} filename - The filename property from the config (e.g., config.filename or config.indexFilename)
82245
+ * @param {string} defaultFallback - Default identifier to use if filename is not provided
82246
+ * @returns {string} A display identifier (filename or fallback string)
82247
+ * @private
82248
+ */
82249
+ #getGoogleDriveDisplayName(filename, defaultFallback = 'Google Drive file') {
82250
+ return filename || defaultFallback
82251
+ }
82252
+
82253
+ /**
82254
+ * Check if a config has a Google Drive URL and create a Google Drive item if found.
82255
+ *
82256
+ * @param {Object} config - Track configuration object
82257
+ * @param {string} trackName - Name of the track
82258
+ * @param {string} urlField - Field name to check ('url' or 'indexURL')
82259
+ * @param {string} filenameField - Field name for filename ('filename' or 'indexFilename')
82260
+ * @param {string} defaultFileName - Default filename if not found
82261
+ * @returns {Object|null} Google Drive item object or null if not a Google Drive URL
82262
+ * @private
82263
+ */
82264
+ #createGoogleDriveItemIfPresent(config, trackName, urlField, filenameField, defaultFileName) {
82265
+ const url = config[urlField];
82266
+ if (url && isGoogleDriveURL(url)) {
82267
+ const fileName = this.#getGoogleDriveDisplayName(config[filenameField], defaultFileName);
82268
+ return {
82269
+ trackName: trackName,
82270
+ fileName: fileName
81365
82271
  }
81366
82272
  }
82273
+ return null
82274
+ }
81367
82275
 
81368
- // Sample info
81369
- const localSampleInfoFileDetections = [];
81370
- if (this.config.sampleinfo) {
82276
+ /**
82277
+ * Extract Google Drive items from a track configuration (checks both url and indexURL).
82278
+ *
82279
+ * @param {Object} config - Track configuration object
82280
+ * @returns {Array} Array of Google Drive items found in this config
82281
+ * @private
82282
+ */
82283
+ #extractGoogleDriveItemsFromConfig(config) {
82284
+ const items = [];
82285
+ const trackName = config.name || 'Unnamed track';
81371
82286
 
81372
- json["sampleinfo"] = this.config.sampleinfo;
82287
+ // Check main file URL
82288
+ const mainItem = this.#createGoogleDriveItemIfPresent(config, trackName, 'url', 'filename', 'Google Drive file');
82289
+ if (mainItem) {
82290
+ items.push(mainItem);
82291
+ }
82292
+
82293
+ // Check index file URL
82294
+ const indexItem = this.#createGoogleDriveItemIfPresent(config, `${trackName} index`, 'indexURL', 'indexFilename', 'Google Drive index file');
82295
+ if (indexItem) {
82296
+ items.push(indexItem);
82297
+ }
82298
+
82299
+ return items
82300
+ }
82301
+
82302
+ /**
82303
+ * Extract problematic resources (local files and Google Drive files) from track configurations.
82304
+ * Google Drive files are detected by checking if the url/indexURL fields contain Google Drive URLs,
82305
+ * using the isGoogleDriveURL helper function from sessionResourceValidator.
82306
+ *
82307
+ * @param {Array} trackConfigurations - Array of track configuration objects
82308
+ * @param {Array} localSampleInfoFiles - Array of local sample info filenames
82309
+ * @param {Array} googleDriveSampleInfoFiles - Array of Google Drive sample info items (objects with trackName and fileName)
82310
+ * @returns {{localFileItems: Array, googleDriveItems: Array}} Object containing arrays of problematic resources
82311
+ * @private
82312
+ */
82313
+ #extractProblematicResources(trackConfigurations, localSampleInfoFiles = [], googleDriveSampleInfoFiles = []) {
82314
+ const localFileItems = [];
82315
+ const googleDriveItems = [];
82316
+
82317
+ // Collect local files from track configurations
82318
+ for (const config of trackConfigurations) {
82319
+ const trackName = config.name || 'Unnamed track';
82320
+ if (config.file) {
82321
+ localFileItems.push({
82322
+ trackName: trackName,
82323
+ fileName: config.file
82324
+ });
82325
+ }
82326
+ if (config.indexFile) {
82327
+ localFileItems.push({
82328
+ trackName: `${trackName} index`,
82329
+ fileName: config.indexFile
82330
+ });
82331
+ }
82332
+ }
82333
+
82334
+ // Add sample info local files
82335
+ for (const fileName of localSampleInfoFiles) {
82336
+ localFileItems.push({
82337
+ trackName: 'Sample info',
82338
+ fileName: fileName
82339
+ });
82340
+ }
82341
+
82342
+ // Collect Google Drive files by checking if url/indexURL fields contain Google Drive URLs
82343
+ for (const config of trackConfigurations) {
82344
+ const items = this.#extractGoogleDriveItemsFromConfig(config);
82345
+ googleDriveItems.push(...items);
82346
+ }
82347
+
82348
+ // Add sample info Google Drive files
82349
+ googleDriveItems.push(...googleDriveSampleInfoFiles);
82350
+
82351
+ return { localFileItems, googleDriveItems }
82352
+ }
82353
+
82354
+ /**
82355
+ * Validate reference genome and warn about problematic resources in the session.
82356
+ *
82357
+ * Reference genome: Throws error if local files or Google Drive URLs are detected
82358
+ * Tracks/Sample Info: Shows warning if local files or Google Drive URLs are detected
82359
+ *
82360
+ * @param {Object} json - The session JSON object
82361
+ * @private
82362
+ */
82363
+ _validateAndWarnResources(json) {
82364
+ // 1. Validate reference genome (blocking errors)
82365
+ const refErrors = [];
82366
+
82367
+ if (json.reference.fastaURL) {
82368
+ if (isLocalFile(json.reference.fastaURL)) {
82369
+ refErrors.push(`Local file: ${json.reference.fastaURL.name}`);
82370
+ } else if (isGoogleDriveURL(json.reference.fastaURL)) {
82371
+ refErrors.push(`Google Drive URL: ${json.reference.fastaURL}`);
82372
+ }
82373
+ }
82374
+
82375
+ if (json.reference.indexURL) {
82376
+ if (isLocalFile(json.reference.indexURL)) {
82377
+ refErrors.push(`Local file: ${json.reference.indexURL.name}`);
82378
+ } else if (isGoogleDriveURL(json.reference.indexURL)) {
82379
+ refErrors.push(`Google Drive URL: ${json.reference.indexURL}`);
82380
+ }
82381
+ }
82382
+
82383
+ if (refErrors.length > 0) {
82384
+ throw new Error(
82385
+ `Error: Sessions cannot include the following resources in the reference genome:\n` +
82386
+ refErrors.map(err => ` - ${err}`).join('\n') + '\n' +
82387
+ `These resources require local access or authentication and will not work when the session is shared.`
82388
+ )
82389
+ }
82390
+
82391
+ // 2. Collect warnings from tracks and sample info
82392
+ const localSampleInfoFiles = [];
82393
+ const googleDriveSampleInfoFiles = [];
81373
82394
 
82395
+ // Check sample info
82396
+ if (this.config.sampleinfo) {
81374
82397
  for (const path of this.sampleInfo.sampleInfoFiles) {
81375
- const config = TrackBase.localFileInspection({url: path});
82398
+ const config = TrackBase.prepareConfigForSession({url: path});
81376
82399
  if (config.file) {
81377
- localSampleInfoFileDetections.push(config.file);
82400
+ localSampleInfoFiles.push(config.file);
82401
+ }
82402
+ // Check if the url field contains a Google Drive URL
82403
+ const googleDriveItem = this.#createGoogleDriveItemIfPresent(config, 'Sample info', 'url', 'filename', 'Google Drive file');
82404
+ if (googleDriveItem) {
82405
+ googleDriveSampleInfoFiles.push(googleDriveItem);
81378
82406
  }
81379
- }
81380
- if (localSampleInfoFileDetections.length > 0) {
81381
- localFileDetections.push(...localSampleInfoFileDetections);
81382
82407
  }
81383
82408
  }
81384
82409
 
81385
- if (localFileDetections.length > 0) {
81386
- alert(`This session includes reference(s) to local file(s):\n${localFileDetections.map(str => ` ${str}`).join('\n')}\nLocal files cannot be loaded automatically when a saved session is restored.`);
81387
- }
82410
+ // Extract problematic resources from tracks
82411
+ const { localFileItems, googleDriveItems } = this.#extractProblematicResources(
82412
+ json.tracks || [],
82413
+ localSampleInfoFiles,
82414
+ googleDriveSampleInfoFiles
82415
+ );
81388
82416
 
81389
- return json
82417
+ // 3. Display consolidated warning if any issues found
82418
+ if (localFileItems.length > 0 || googleDriveItems.length > 0) {
82419
+ let message = 'Local and Google Drive files cannot be loaded automatically when a saved session is restored. This session saves references to the following file(s) that will not be restored.\n\n';
82420
+
82421
+ // Add local file items
82422
+ for (const item of localFileItems) {
82423
+ message += `Local file name: ${item.fileName}\n`;
82424
+ message += `Track name: ${item.trackName}\n\n`;
82425
+ }
82426
+
82427
+ // Add Google Drive items
82428
+ for (const item of googleDriveItems) {
82429
+ message += `Google Drive file name: ${item.fileName}\n`;
82430
+ message += `Track name: ${item.trackName}\n\n`;
82431
+ }
82432
+
82433
+ alert(message);
82434
+ }
81390
82435
  }
81391
82436
 
81392
82437
  compressedSession() {
@@ -81890,6 +82935,281 @@ toggleTrackLabels(trackViews, isVisible) {
81890
82935
  }
81891
82936
  }
81892
82937
 
82938
+ /**
82939
+ * Handles incoming messages from the WebSocket connection. Performs requested actions on the IGV browser instance
82940
+ * and returns a response message.
82941
+ *
82942
+ * @param json
82943
+ * @param browser
82944
+ * @returns {Promise<{uniqueID, message: string, status: string}>}
82945
+ */
82946
+
82947
+
82948
+ async function handleMessage(json, browser) {
82949
+
82950
+ const returnMsg = {uniqueID: json.uniqueID, status: 'ok'};
82951
+
82952
+ try {
82953
+ let tracks;
82954
+ const {type, args} = json;
82955
+ switch (type.toLowerCase()) {
82956
+
82957
+ case "goto":
82958
+ case "search":
82959
+ const term = args.locus || args.term;
82960
+ const found = await browser.search(term);
82961
+ if (found) {
82962
+ returnMsg.message = `Locus ${term} found and navigated to successfully`;
82963
+ } else {
82964
+ returnMsg.message = `Locus ${term} not found`;
82965
+ returnMsg.status = 'warning';
82966
+ }
82967
+ break
82968
+
82969
+ case "currentloci":
82970
+ returnMsg.data = browser.currentLoci();
82971
+ returnMsg.message = `Retrieved current loci successfully`;
82972
+ break
82973
+
82974
+ case "visibilityChange":
82975
+ returnMsg.message = await browser.visibilityChange();
82976
+ break
82977
+
82978
+ case "tojson":
82979
+ returnMsg.data = browser.toJSON();
82980
+ returnMsg.message = `Session serialized to JSON successfully`;
82981
+ break
82982
+
82983
+ case "compressedsession":
82984
+ returnMsg.data = browser.compressedSession();
82985
+ returnMsg.message = `Session serialized and compressed successfully`;
82986
+ break
82987
+
82988
+ case "tosvg":
82989
+ returnMsg.data = browser.toSVG();
82990
+ returnMsg.message = `Session exported to SVG successfully`;
82991
+ break
82992
+
82993
+ case "removetrackbyname": {
82994
+ let {trackName} = args;
82995
+ if(trackName) {
82996
+ tracks = browser.findTracks(t => trackName ? t.name === trackName : true);
82997
+ if (tracks) {
82998
+ tracks.forEach(t => browser.removeTrack(t));
82999
+ returnMsg.message = `Removed track(s) ${trackName} for ${tracks.length} track(s)`;
83000
+ } else {
83001
+ returnMsg.message = `No tracks found matching name ${trackName}`;
83002
+ returnMsg.status = 'warning';
83003
+ }
83004
+ } else {
83005
+ returnMsg.message = `No track name provided`;
83006
+ returnMsg.status = 'warning';
83007
+ }
83008
+ break
83009
+ }
83010
+
83011
+ case "loadsampleinfo": {
83012
+ browser.loadSampleInfo(args);
83013
+ returnMsg.message = `Sample info loaded successfully`;
83014
+ break
83015
+ }
83016
+
83017
+ case "discardsampleinfo":
83018
+ browser.discardSampleInfo();
83019
+ returnMsg.message = `Sample info discarded successfully`;
83020
+ break
83021
+
83022
+ case "loadroi":
83023
+ browser.loadROI(args);
83024
+ returnMsg.message = `ROI loaded successfully`;
83025
+ break
83026
+
83027
+ case "clearrois":
83028
+ browser.clearROIs();
83029
+ returnMsg.message = `ROIs cleared successfully`;
83030
+ break
83031
+
83032
+ case "getuserdefinedrois":
83033
+ const rois = await browser.getUserDefinedROIs();
83034
+ returnMsg.data = rois;
83035
+ returnMsg.message = `Retrieved ${rois.length} user-defined ROIs successfully`;
83036
+ break
83037
+
83038
+ case 'loadtrack': {
83039
+ const {url, indexURL} = args;
83040
+ const track = await browser.loadTrack({url, indexURL});
83041
+ returnMsg.message = `Track ${track.name} loaded successfully`;
83042
+ break
83043
+ }
83044
+
83045
+ case "genome":
83046
+ const id = args.id;
83047
+ await browser.loadGenome(id);
83048
+ returnMsg.message = `Genome ${id} loaded successfully`;
83049
+ break
83050
+
83051
+ case "loadsession":
83052
+ const url = args.url;
83053
+ await browser.loadSession({url});
83054
+ returnMsg.message = `Session loaded successfully from ${url}`;
83055
+ break
83056
+
83057
+ case "zoomin":
83058
+ await browser.zoomIn();
83059
+ returnMsg.message = `Zoomed in successfully`;
83060
+ break
83061
+
83062
+ case "zoomout":
83063
+ await browser.zoomOut();
83064
+ returnMsg.message = `Zoomed out successfully`;
83065
+ break
83066
+
83067
+ case "setcolor":
83068
+
83069
+ let {color, trackName} = args;
83070
+
83071
+ if (color.includes(",") && !color.startsWith("rgb(")) {
83072
+ // Convert "R,G,B" to "rgb(R,G,B)"
83073
+ color = `rgb(${color})`;
83074
+ }
83075
+
83076
+ tracks = browser.findTracks(t => trackName ? t.name === trackName : true);
83077
+ if (tracks) {
83078
+ tracks.forEach(t => t.color = color);
83079
+ browser.repaintViews();
83080
+ returnMsg.message = `Set color to ${color} for ${tracks.length} track(s)`;
83081
+ } else {
83082
+ returnMsg.message = `No tracks found matching name ${trackName}`;
83083
+ returnMsg.status = 'warning';
83084
+ }
83085
+ break
83086
+
83087
+ case "renametrack":
83088
+
83089
+ const {currentName, newName} = args;
83090
+
83091
+ tracks = browser.findTracks(t => currentName === t.name);
83092
+ if (tracks && tracks.length > 0) {
83093
+ tracks.forEach(t => {
83094
+ t.name = newName;
83095
+ browser.fireEvent('tracknamechange', [t]);
83096
+ });
83097
+ returnMsg.message = `Renamed ${tracks.length} track(s) from ${currentName} to ${newName}`;
83098
+ } else {
83099
+ returnMsg.message = `No track found with name ${currentName}`;
83100
+ returnMsg.status = 'warning';
83101
+ }
83102
+ break
83103
+
83104
+ default:
83105
+ returnMsg.message = `Unrecognized message type: ${type}`;
83106
+ returnMsg.status = 'error';
83107
+ }
83108
+ } catch (err) {
83109
+ returnMsg.message = err?.message || String(err);
83110
+ returnMsg.status = 'error';
83111
+ }
83112
+
83113
+ return returnMsg
83114
+ }
83115
+
83116
+ /**
83117
+ * Create a WebSocket client that connects to a server and handles messages. The client attempts to connect to a
83118
+ * WebSocketServer upon creation. If the connection is not successful or lost, it will attempt to reconnect with an
83119
+ * exponential backoff strategy. Incoming messages are expected to be JSON formatted and are processed by the
83120
+ * handleMessage function. Messages encompass a subset of the igv.js API
83121
+ *
83122
+ * This client was created to interact with an MCP server, but could be used for other purposes.
83123
+ *
83124
+ * @param host Host for the WebSocket server
83125
+ * @param port Port for the WebSocket server
83126
+ * @param browser The igv.js browser instance
83127
+ */
83128
+
83129
+ function createWebSocketClient(host, port, browser) {
83130
+
83131
+ let socket;
83132
+ let retryInterval = 1000; // Initial retry interval in ms
83133
+ const maxRetryInterval = 10000; // Maximum retry interval in ms
83134
+ let reconnectTimer;
83135
+ let intentionalClose = false; // Flag to prevent reconnection on intentional close
83136
+
83137
+ function connect() {
83138
+
83139
+ const isLocal = host === 'localhost' || host === '127.0.0.1';
83140
+ const protocol = window.location.protocol === 'https:' && !isLocal ? 'wss:' : 'ws:';
83141
+ socket = new WebSocket(`${protocol}//${host}:${port}`);
83142
+
83143
+ // helper to safely send
83144
+ const sendJSON = (obj) => {
83145
+ if (socket.readyState === WebSocket.OPEN) {
83146
+ socket.send(JSON.stringify(obj));
83147
+ }
83148
+ };
83149
+
83150
+ socket.addEventListener('open', function (event) {
83151
+ retryInterval = 1000; // Reset retry interval on successful connection
83152
+ sendJSON({message: 'Hello from browser client'});
83153
+ });
83154
+
83155
+ // Listen for incoming messages
83156
+ socket.addEventListener('message', async function (event) {
83157
+ try {
83158
+ const json = JSON.parse(event.data);
83159
+
83160
+ if("close" === json.type) {
83161
+ intentionalClose = true;
83162
+ clearTimeout(reconnectTimer);
83163
+ if (socket && socket.readyState === WebSocket.OPEN) {
83164
+ socket.close();
83165
+ }
83166
+ return
83167
+ }
83168
+
83169
+ const returnMsg = await handleMessage(json, browser);
83170
+ sendJSON(returnMsg);
83171
+
83172
+ } catch (e) {
83173
+ if (e instanceof SyntaxError) {
83174
+ console.warn('Received non-JSON message from server:', event.data);
83175
+ } else {
83176
+ console.error('Error handling message:', e);
83177
+ sendJSON({
83178
+ status: 'error',
83179
+ message: `Error handling message: ${e.message || e.toString()}`
83180
+ });
83181
+ }
83182
+ }
83183
+ });
83184
+
83185
+ socket.addEventListener('error', function (event) {
83186
+ console.error('WebSocket error:', event);
83187
+ // The 'close' event will fire immediately after 'error', triggering the reconnect logic.
83188
+ });
83189
+
83190
+ socket.addEventListener('close', function (event) {
83191
+ if (intentionalClose) {
83192
+ console.log('WebSocket closed intentionally. Not reconnecting.');
83193
+ return
83194
+ }
83195
+ console.log('Disconnected from server. Retrying in ' + (retryInterval / 1000) + ' seconds.');
83196
+ clearTimeout(reconnectTimer);
83197
+ reconnectTimer = setTimeout(connect, retryInterval);
83198
+ // Increase retry interval for next time, up to a max
83199
+ retryInterval = Math.min(maxRetryInterval, retryInterval * 2);
83200
+ });
83201
+ }
83202
+
83203
+ connect(); // Initial connection attempt
83204
+
83205
+ window.addEventListener('beforeunload', function (event) {
83206
+ clearTimeout(reconnectTimer); // Don't try to reconnect when page is closing
83207
+ if (socket && socket.readyState === WebSocket.OPEN) {
83208
+ socket.close();
83209
+ }
83210
+ });
83211
+ }
83212
+
81893
83213
  let allBrowsers = [];
81894
83214
 
81895
83215
  /**
@@ -81947,8 +83267,13 @@ async function createBrowser(parentDiv, config) {
81947
83267
 
81948
83268
  browser.navbar.navbarDidResize();
81949
83269
 
81950
- return browser
83270
+ if(config.enableWebSocket) {
83271
+ const host = config.webSocketHost || "localhost";
83272
+ const port = config.webSocketPort || 60141;
83273
+ createWebSocketClient(host, port, browser);
83274
+ }
81951
83275
 
83276
+ return browser
81952
83277
  }
81953
83278
 
81954
83279
  function removeBrowser(browser) {
@@ -82141,7 +83466,8 @@ var index = {
82141
83466
  loadSessionFile: Browser.loadSessionFile,
82142
83467
  loadHub,
82143
83468
  uncompressSession: Browser.uncompressSession,
82144
- createIcon
83469
+ createIcon,
83470
+ createWebSocketClient
82145
83471
  };
82146
83472
 
82147
83473
  export { index as default };