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.js CHANGED
@@ -8453,7 +8453,7 @@
8453
8453
  function isGoogleURL(url) {
8454
8454
  return (url.includes("googleapis") && !url.includes("urlshortener")) ||
8455
8455
  isGoogleStorageURL(url) ||
8456
- isGoogleDriveURL(url);
8456
+ isGoogleDriveURL$1(url);
8457
8457
  }
8458
8458
 
8459
8459
  function isGoogleStorageURL(url) {
@@ -8463,7 +8463,7 @@
8463
8463
  url.startsWith("https://storage.googleapis.com");
8464
8464
  }
8465
8465
 
8466
- function isGoogleDriveURL(url) {
8466
+ function isGoogleDriveURL$1(url) {
8467
8467
  return url.startsWith("https://www.googleapis.com/drive/v3/files");
8468
8468
  }
8469
8469
 
@@ -8616,7 +8616,7 @@
8616
8616
  // })
8617
8617
 
8618
8618
  function getScopeForURL(url) {
8619
- if (isGoogleDriveURL(url)) {
8619
+ if (isGoogleDriveURL$1(url)) {
8620
8620
  return "https://www.googleapis.com/auth/drive.file"
8621
8621
  } else if (isGoogleStorageURL(url)) {
8622
8622
  return "https://www.googleapis.com/auth/devstorage.read_only"
@@ -8835,7 +8835,7 @@
8835
8835
  return buffer
8836
8836
  }
8837
8837
  } else {
8838
- if (isGoogleDriveURL(url) || url.startsWith("https://www.dropbox.com")) {
8838
+ if (isGoogleDriveURL$1(url) || url.startsWith("https://www.dropbox.com")) {
8839
8839
  return this.googleThrottle.add(async () => {
8840
8840
  return this._loadURL(url, options)
8841
8841
  })
@@ -8859,7 +8859,7 @@
8859
8859
  options = options || {};
8860
8860
 
8861
8861
  let oauthToken;
8862
- if (isGoogleDriveURL(url)) {
8862
+ if (isGoogleDriveURL$1(url)) {
8863
8863
  // Google drive urls always require oAuth
8864
8864
  oauthToken = await getAccessToken("https://www.googleapis.com/auth/drive.file");
8865
8865
  } else {
@@ -8878,7 +8878,7 @@
8878
8878
  }
8879
8879
  url = addApiKey(url);
8880
8880
 
8881
- if (isGoogleDriveURL(url)) {
8881
+ if (isGoogleDriveURL$1(url)) {
8882
8882
  addTeamDrive(url);
8883
8883
  }
8884
8884
 
@@ -12408,7 +12408,7 @@
12408
12408
  complements.set(p2.toLowerCase(), p1.toLowerCase());
12409
12409
  }
12410
12410
 
12411
- function complementBase(base) {
12411
+ function complementBase$1(base) {
12412
12412
  return complements.has(base) ? complements.get(base) : base
12413
12413
  }
12414
12414
 
@@ -16993,6 +16993,35 @@
16993
16993
  return sortedList[low]
16994
16994
  }
16995
16995
 
16996
+ /**
16997
+ * Utilities for detecting problematic resources (local files, Google Drive URLs)
16998
+ * that cannot be reliably loaded when a session is shared or restored.
16999
+ */
17000
+
17001
+ /**
17002
+ * Check if an object is a local File instance
17003
+ * @param {*} obj - The object to check
17004
+ * @returns {boolean} True if the object is a File instance
17005
+ */
17006
+ function isLocalFile(obj) {
17007
+ return obj instanceof File
17008
+ }
17009
+
17010
+ /**
17011
+ * Check if a URL is a Google Drive URL
17012
+ * Google Drive URLs require authentication and will not work when shared.
17013
+ *
17014
+ * @param {string|*} url - The URL to check
17015
+ * @returns {boolean} True if the URL is a Google Drive URL
17016
+ */
17017
+ function isGoogleDriveURL(url) {
17018
+ if (typeof url !== 'string') {
17019
+ return false
17020
+ }
17021
+ // Match both googleapis.com/drive and drive.google.com URLs
17022
+ return url.includes('googleapis.com/drive') || url.includes('drive.google.com')
17023
+ }
17024
+
16996
17025
  const fixColor = (colorString) => {
16997
17026
  if (isString$3(colorString)) {
16998
17027
  return (colorString.indexOf(",") > 0 && !(colorString.startsWith("rgb(") || colorString.startsWith("rgba("))) ?
@@ -17617,7 +17646,20 @@
17617
17646
  }
17618
17647
  }
17619
17648
 
17620
- static localFileInspection(config) {
17649
+ /**
17650
+ * Prepare a track configuration for session serialization by identifying and marking
17651
+ * problematic resources (local files).
17652
+ *
17653
+ * Local files are converted to {file: filename} or {indexFile: filename}
17654
+ * Google Drive URLs are kept in the url/indexURL fields as-is and detected when loading
17655
+ *
17656
+ * This allows the configuration to be serialized while preserving information
17657
+ * about resources that cannot be automatically loaded when the session is restored.
17658
+ *
17659
+ * @param {Object} config - Track configuration to prepare
17660
+ * @returns {Object} Cleaned configuration with problematic resources marked
17661
+ */
17662
+ static prepareConfigForSession(config) {
17621
17663
 
17622
17664
  const cooked = Object.assign({}, config);
17623
17665
  const lut =
@@ -17626,8 +17668,9 @@
17626
17668
  indexURL: 'indexFile'
17627
17669
  };
17628
17670
 
17671
+ // Check for local File objects and convert to filename strings
17629
17672
  for (const key of ['url', 'indexURL']) {
17630
- if (cooked[key] && cooked[key] instanceof File) {
17673
+ if (cooked[key] && isLocalFile(cooked[key])) {
17631
17674
  cooked[lut[key]] = cooked[key].name;
17632
17675
  delete cooked[key];
17633
17676
  }
@@ -17637,8 +17680,6 @@
17637
17680
  }
17638
17681
 
17639
17682
  // Methods to support filtering api
17640
-
17641
-
17642
17683
  set filter(f) {
17643
17684
  this._filter = f;
17644
17685
  this.trackView.repaintViews();
@@ -35106,14 +35147,21 @@
35106
35147
  return (3 - exon.readingFrame) % 3
35107
35148
  }
35108
35149
 
35109
- function getEonStart(exon) {
35150
+ function getCodingStart(exon) {
35110
35151
  return exon.cdStart || exon.start
35111
35152
  }
35112
35153
 
35113
- function getExonEnd(exon) {
35154
+ function getCodingEnd(exon) {
35114
35155
  return exon.cdEnd || exon.end
35115
35156
  }
35116
35157
 
35158
+ function getCodingLength(exon) {
35159
+ if (exon.utr) return 0
35160
+ const start = exon.cdStart || exon.start;
35161
+ const end = exon.cdEnd || exon.end;
35162
+ return end - start
35163
+ }
35164
+
35117
35165
  const aminoAcidSequenceRenderThreshold = 0.25;
35118
35166
 
35119
35167
  /**
@@ -35362,8 +35410,8 @@
35362
35410
  };
35363
35411
 
35364
35412
  const phase = getExonPhase(exon);
35365
- let ss = getEonStart(exon);
35366
- let ee = getExonEnd(exon);
35413
+ let ss = getCodingStart(exon);
35414
+ let ee = getCodingEnd(exon);
35367
35415
 
35368
35416
  let bpTripletStart;
35369
35417
  let bpTripletEnd;
@@ -35532,7 +35580,7 @@
35532
35580
  return undefined
35533
35581
  }
35534
35582
 
35535
- [ss, ee] = [getExonEnd(leftExon) - (3 - phase), getExonEnd(leftExon)];
35583
+ [ss, ee] = [getCodingEnd(leftExon) - (3 - phase), getCodingEnd(leftExon)];
35536
35584
  stringA = sequenceInterval.getSequence(ss, ee);
35537
35585
 
35538
35586
  if (!stringA) {
@@ -35551,7 +35599,7 @@
35551
35599
  }
35552
35600
 
35553
35601
  const ritePhase = getExonPhase(riteExon);
35554
- const riteStart = getEonStart(riteExon);
35602
+ const riteStart = getCodingStart(riteExon);
35555
35603
  stringB = sequenceInterval.getSequence(riteStart, riteStart + ritePhase);
35556
35604
 
35557
35605
  if (!stringB) {
@@ -35571,7 +35619,7 @@
35571
35619
  return undefined
35572
35620
  }
35573
35621
 
35574
- [ss, ee] = [getEonStart(riteExon), getEonStart(riteExon) + (3 - phase)];
35622
+ [ss, ee] = [getCodingStart(riteExon), getCodingStart(riteExon) + (3 - phase)];
35575
35623
  stringB = sequenceInterval.getSequence(ss, ee);
35576
35624
 
35577
35625
  if (!stringB) {
@@ -35591,7 +35639,7 @@
35591
35639
  }
35592
35640
 
35593
35641
  const leftPhase = getExonPhase(leftExon);
35594
- const leftEnd = getExonEnd(leftExon);
35642
+ const leftEnd = getCodingEnd(leftExon);
35595
35643
  stringA = sequenceInterval.getSequence(leftEnd - leftPhase, leftEnd);
35596
35644
 
35597
35645
  if (!stringA) {
@@ -39019,7 +39067,7 @@
39019
39067
 
39020
39068
  const isContainer = (s.hasOwnProperty("superTrack") && !s.hasOwnProperty("bigDataUrl")) ||
39021
39069
  s.hasOwnProperty("compositeTrack") || s.hasOwnProperty("view") ||
39022
- (s.hasOwnProperty("container") && s.getOwnProperty("container").equals("multiWig"));
39070
+ (s.hasOwnProperty("container") && (s.getOwnProperty("container") === "multiWig"));
39023
39071
 
39024
39072
  // Find parent, if any. "group" containers can be implicit, all other types should be explicitly
39025
39073
  // defined before their children
@@ -40795,9 +40843,9 @@
40795
40843
 
40796
40844
  if (typeof this.trackView.track.popupData === "function") {
40797
40845
 
40798
- popupTimerID = setTimeout(() => {
40846
+ popupTimerID = setTimeout(async () => {
40799
40847
 
40800
- const content = this.handleTrackClick(event);
40848
+ const content = await this.handleTrackClick(event);
40801
40849
  if (content) {
40802
40850
 
40803
40851
  if (false === event.shiftKey) {
@@ -40962,7 +41010,7 @@
40962
41010
 
40963
41011
  }
40964
41012
 
40965
- handleTrackClick(event) {
41013
+ async handleTrackClick(event) {
40966
41014
 
40967
41015
  const clickState = this.createClickState(event);
40968
41016
 
@@ -40971,7 +41019,7 @@
40971
41019
  }
40972
41020
 
40973
41021
  let track = this.trackView.track;
40974
- const dataList = track.popupData(clickState);
41022
+ const dataList = await track.popupData(clickState);
40975
41023
 
40976
41024
  const popupClickHandlerResult = this.browser.fireEvent('trackclick', [track, dataList, clickState.genomicLocation]);
40977
41025
 
@@ -44209,7 +44257,7 @@
44209
44257
  track.autoscaleGroup = name;
44210
44258
  }
44211
44259
  track.isMergedTrack = false;
44212
- browser.addTrack(track);
44260
+ await browser.addTrack(track);
44213
44261
  }
44214
44262
  await browser.updateViews();
44215
44263
  browser.reorderTracks();
@@ -44295,7 +44343,7 @@
44295
44343
 
44296
44344
  const mouseClickHandler = () => {
44297
44345
  this.setVisibility(false);
44298
- trackOverlayClickHandler.call(this);
44346
+ this.trackOverlayClickHandler();
44299
44347
  };
44300
44348
 
44301
44349
  this.boundMouseClickHandler = mouseClickHandler.bind(this);
@@ -44305,50 +44353,49 @@
44305
44353
  this.setVisibility(true);
44306
44354
 
44307
44355
  }
44308
- }
44309
44356
 
44310
- async function trackOverlayClickHandler(e) {
44357
+ async trackOverlayClickHandler() {
44311
44358
 
44312
- if (true === isOverlayTrackCriteriaMet(this.browser)) {
44359
+ if (true === isOverlayTrackCriteriaMet(this.browser)) {
44313
44360
 
44314
- const tracks = this.browser.getSelectedTrackViews().map(({track}) => track);
44315
- for (const track of tracks) {
44316
- track.selected = false;
44317
- }
44361
+ const tracks = this.browser.getSelectedTrackViews().map(({track}) => track);
44362
+ for (const track of tracks) {
44363
+ track.selected = false;
44364
+ }
44318
44365
 
44319
- // Flatten any merged tracks. Must do this before their removal
44320
- const flattenedTracks = [];
44321
- for (let t of tracks) {
44322
- if ("merged" === t.type) {
44323
- flattenedTracks.push(...t.tracks);
44324
- } else {
44325
- flattenedTracks.push(t);
44366
+ // Flatten any merged tracks. Must do this before their removal
44367
+ const flattenedTracks = [];
44368
+ for (let t of tracks) {
44369
+ if ("merged" === t.type) {
44370
+ flattenedTracks.push(...t.tracks);
44371
+ } else {
44372
+ flattenedTracks.push(t);
44373
+ }
44326
44374
  }
44327
- }
44328
44375
 
44329
- const config =
44330
- {
44331
- name: 'Overlay',
44332
- type: 'merged',
44333
- autoscale: false,
44334
- alpha: 0.5, //fudge * (1.0/tracks.length),
44335
- height: Math.max(...tracks.map(({height}) => height)),
44336
- order: Math.min(...tracks.map(({order}) => order)),
44337
- };
44376
+ const config =
44377
+ {
44378
+ name: 'Overlay',
44379
+ type: 'merged',
44380
+ autoscale: false,
44381
+ alpha: 0.5, //fudge * (1.0/tracks.length),
44382
+ height: Math.max(...tracks.map(({height}) => height)),
44383
+ order: Math.min(...tracks.map(({order}) => order))
44384
+ };
44338
44385
 
44339
- const mergedTrack = new MergedTrack(config, this.browser, flattenedTracks);
44386
+ const mergedTrack = new MergedTrack(config, this.browser, flattenedTracks);
44340
44387
 
44341
- for (const track of tracks) {
44342
- const idx = this.browser.trackViews.indexOf(track.trackView);
44343
- this.browser.trackViews.splice(idx, 1);
44344
- track.trackView.dispose();
44345
- }
44388
+ for (const track of tracks) {
44389
+ const idx = this.browser.trackViews.indexOf(track.trackView);
44390
+ this.browser.trackViews.splice(idx, 1);
44391
+ track.trackView.dispose();
44392
+ }
44346
44393
 
44347
- this.browser.addTrack(mergedTrack);
44348
- await mergedTrack.trackView.updateViews();
44349
- this.browser.reorderTracks();
44394
+ await this.browser.addTrack(mergedTrack);
44395
+ await mergedTrack.trackView.updateViews();
44396
+ this.browser.reorderTracks();
44397
+ }
44350
44398
  }
44351
-
44352
44399
  }
44353
44400
 
44354
44401
  function isOverlayTrackCriteriaMet(browser) {
@@ -47441,7 +47488,7 @@
47441
47488
  this.base = base;
47442
47489
  this.strand = strand;
47443
47490
  this.modification = modification;
47444
- this.canonicalBase = this.strand === '+' ? this.base : complementBase(this.base);
47491
+ this.canonicalBase = this.strand === '+' ? this.base : complementBase$1(this.base);
47445
47492
  }
47446
47493
 
47447
47494
  getCanonicalBase() {
@@ -47491,7 +47538,7 @@
47491
47538
  this.modification = modification;
47492
47539
  this.strand = strand;
47493
47540
  this.likelihoods = likelihoods;
47494
- this.canonicalBase = this.strand == '+' ? this.base : complementBase(this.base);
47541
+ this.canonicalBase = this.strand == '+' ? this.base : complementBase$1(this.base);
47495
47542
  this.key = BaseModificationKey.getKey(base, strand, modification);
47496
47543
  }
47497
47544
 
@@ -48924,6 +48971,818 @@
48924
48971
  return len
48925
48972
  }
48926
48973
 
48974
+ // Lazy import to avoid circular dependency
48975
+
48976
+ /**
48977
+ * Search for a feature by name across various data sources
48978
+ * This module is separate to avoid circular dependencies between search.js and hgvs.js
48979
+ */
48980
+
48981
+ const DEFAULT_SEARCH_CONFIG = {
48982
+ timeout: 5000,
48983
+ type: "plain",
48984
+ url: 'https://igv.org/genomes/locus.php?genome=$GENOME$&name=$FEATURE$',
48985
+ coords: 0
48986
+ };
48987
+
48988
+ /**
48989
+ * Search for a feature by name in MANE transcripts, searchable tracks, and web services
48990
+ * @param {Object} browser - The IGV browser instance
48991
+ * @param {string} name - The feature name to search for
48992
+ * @returns {Promise<Object|undefined>} The found feature or undefined
48993
+ */
48994
+ async function searchFeatures(browser, name) {
48995
+
48996
+ const searchConfig = browser.searchConfig || DEFAULT_SEARCH_CONFIG;
48997
+ let feature;
48998
+
48999
+ name = name.toUpperCase();
49000
+
49001
+ // Search MANE transcripts first, if available
49002
+ feature = await browser.genome.getManeTranscript(name);
49003
+ if (feature) {
49004
+ return feature
49005
+ }
49006
+
49007
+ const searchableTracks = browser.tracks.filter(t => t.searchable);
49008
+ for (let track of searchableTracks) {
49009
+ const feature = await track.search(name);
49010
+ if (feature) {
49011
+ return feature
49012
+ }
49013
+ }
49014
+
49015
+ // If still not found try webservice, if enabled
49016
+ if (browser.config && false !== browser.config.search) {
49017
+ try {
49018
+ feature = await searchWebService(browser, name, searchConfig);
49019
+ return feature // Might be undefined
49020
+ } catch (error) {
49021
+ console.log("Search service not available " + error);
49022
+ }
49023
+ }
49024
+
49025
+ }
49026
+
49027
+ /**
49028
+ * Search for a feature using a web service
49029
+ * @param {Object} browser - The IGV browser instance
49030
+ * @param {string} locus - The locus to search for
49031
+ * @param {Object} searchConfig - Search configuration
49032
+ * @returns {Promise<Object|undefined>} The search result
49033
+ */
49034
+ async function searchWebService(browser, locus, searchConfig) {
49035
+
49036
+ let path = searchConfig.url.replace("$FEATURE$", locus.toUpperCase());
49037
+ if (path.indexOf("$GENOME$") > -1) {
49038
+ path = path.replace("$GENOME$", (browser.genome.id ? browser.genome.id : "hg19"));
49039
+ }
49040
+ const options = searchConfig.timeout ? {timeout: searchConfig.timeout} : undefined;
49041
+ const result = await igvxhr.loadString(path, options);
49042
+
49043
+ return await processSearchResult(browser, result, searchConfig)
49044
+ }
49045
+
49046
+ /**
49047
+ * Process search results from web service
49048
+ * @param {Object} browser - The IGV browser instance
49049
+ * @param {string} result - The raw result from the web service
49050
+ * @param {Object} searchConfig - Search configuration
49051
+ * @returns {Promise<Object|undefined>} The processed search result
49052
+ */
49053
+ async function processSearchResult(browser, result, searchConfig) {
49054
+
49055
+ let results;
49056
+
49057
+ if ('plain' === searchConfig.type) {
49058
+ results = await parseSearchResults(browser, result);
49059
+ } else {
49060
+ results = JSON.parse(result);
49061
+ }
49062
+
49063
+ if (searchConfig.resultsField) {
49064
+ results = results[searchConfig.resultsField];
49065
+ }
49066
+
49067
+ if (!results || 0 === results.length) {
49068
+ return undefined
49069
+
49070
+ } else {
49071
+
49072
+ const chromosomeField = searchConfig.chromosomeField || "chromosome";
49073
+ const startField = searchConfig.startField || "start";
49074
+ const endField = searchConfig.endField || "end";
49075
+ const coords = searchConfig.coords || 1;
49076
+
49077
+
49078
+ let result;
49079
+ if (Array.isArray(results)) {
49080
+ // Ignoring all but first result for now
49081
+ // TODO -- present all and let user select if results.length > 1
49082
+ result = results[0];
49083
+ } else {
49084
+ // When processing search results from Ensembl REST API
49085
+ // Example: https://rest.ensembl.org/lookup/symbol/macaca_fascicularis/BRCA2?content-type=application/json
49086
+ result = results;
49087
+ }
49088
+
49089
+ if (!(result.hasOwnProperty(chromosomeField) && (result.hasOwnProperty(startField)))) {
49090
+ console.error("Search service results must include chromosome and start fields: " + result);
49091
+ }
49092
+
49093
+ const chr = result[chromosomeField];
49094
+ let start = result[startField] - coords;
49095
+ let end = result[endField];
49096
+ if (undefined === end) {
49097
+ end = start + 1;
49098
+ }
49099
+
49100
+ const locusObject = {chr, start, end};
49101
+
49102
+ // Some GTEX hacks
49103
+ if (searchConfig.geneField && searchConfig.snpField) {
49104
+ const name = result[searchConfig.geneField] || result[searchConfig.snpField]; // Should never have both
49105
+ if (name) locusObject.name = name.toUpperCase();
49106
+ }
49107
+
49108
+ return locusObject
49109
+ }
49110
+ }
49111
+
49112
+ /**
49113
+ * Parse the igv line-oriented (non json) search results.
49114
+ * NOTE: currently, and probably permanently, this will always be a single line
49115
+ * Example
49116
+ * EGFR chr7:55,086,724-55,275,031 refseq
49117
+ *
49118
+ * @param {Object} browser - The IGV browser instance
49119
+ * @param {string} data - The raw search result data
49120
+ * @returns {Array} Array of parsed search results
49121
+ */
49122
+ async function parseSearchResults(browser, data) {
49123
+
49124
+ const results = [];
49125
+ const lines = splitLines$3(data);
49126
+
49127
+ for (let line of lines) {
49128
+
49129
+ const tokens = line.split("\t");
49130
+
49131
+ if (tokens.length >= 3) {
49132
+ const locusTokens = tokens[1].split(":");
49133
+ const rangeTokens = locusTokens[1].split("-");
49134
+ results.push({
49135
+ chromosome: browser.genome.getChromosomeName(locusTokens[0].trim()),
49136
+ start: parseInt(rangeTokens[0].replace(/,/g, '')),
49137
+ end: parseInt(rangeTokens[1].replace(/,/g, '')),
49138
+ name: tokens[0].toUpperCase()
49139
+ });
49140
+ }
49141
+ }
49142
+
49143
+ return results
49144
+
49145
+ }
49146
+
49147
+ const log = console;
49148
+
49149
+ function isValidHGVS(notation) {
49150
+ if (!notation) return false
49151
+ // We only need to validate that we can parse the notation in the search method.
49152
+ // Check for basic structure: <accession>:g.<position> or <accession>:c.<position> or <accession>:p.<position>
49153
+ // We don't validate the variant details since we only need the position for searching.
49154
+
49155
+ // Genomic: g.\d+ (with optional range and anything after)
49156
+ const genomic = "g\\.\\d+.*";
49157
+ // Coding: c. followed by optional -, *, then digits, with optional intronic offset and anything after
49158
+ const coding = "c\\.[-*]?\\d+.*";
49159
+ // Non-coding: n. followed by optional leading '-' then digits, anything after
49160
+ const nonCoding = "n\\.-?\\d+.*";
49161
+ // Protein: p. followed by optional AA letters, digits, with optional range and anything after
49162
+ const protein = "p\\.[A-Za-z*]*\\d+.*";
49163
+ // Optional gene symbol in parentheses immediately after accession
49164
+ const accessionWithOptionalGene = "^[A-Za-z0-9_.]+(?:\\([^)]+\\))?";
49165
+
49166
+ const pattern = new RegExp(accessionWithOptionalGene + ":(?:" + genomic + "|" + coding + "|" + nonCoding + "|" + protein + ")$");
49167
+ return pattern.test(notation)
49168
+ }
49169
+
49170
+ /**
49171
+ * Searches for the given HGVS notation in the provided genome.
49172
+ * Returns a SearchResult with the corresponding chromosome and position if found,
49173
+ * otherwise returns null.
49174
+ */
49175
+ async function search$1(hgvs, browser) {
49176
+
49177
+ if (!isValidHGVS(hgvs)) {
49178
+ return null
49179
+ }
49180
+
49181
+ const genome = browser.genome;
49182
+
49183
+ // Determine type and extract accession and position
49184
+ const idxG = hgvs.indexOf(":g.");
49185
+ const idxC = hgvs.indexOf(":c.");
49186
+ const idxP = hgvs.indexOf(":p.");
49187
+ const idxN = hgvs.indexOf(":n.");
49188
+ let type;
49189
+ let idx;
49190
+ if (idxG >= 0) {
49191
+ type = "g";
49192
+ idx = idxG;
49193
+ } else if (idxC >= 0) {
49194
+ type = "c";
49195
+ idx = idxC;
49196
+ } else if (idxN >= 0) {
49197
+ type = "n";
49198
+ idx = idxN;
49199
+ } else if (idxP >= 0) {
49200
+ type = "p";
49201
+ idx = idxP;
49202
+ } else {
49203
+ return null
49204
+ }
49205
+ let accession = hgvs.substring(0, idx);
49206
+ // Strip optional trailing gene symbol in parentheses, e.g., "NM_000302.3(PLOD1)" -> "NM_000302.3"
49207
+ if (accession.endsWith(")")) {
49208
+ const openIdx = accession.lastIndexOf('(');
49209
+ if (openIdx > 0) {
49210
+ accession = accession.substring(0, openIdx);
49211
+ }
49212
+ }
49213
+ const positionPart = hgvs.substring(idx + 3); // skip ':g.' or ':c.' or ':p.'
49214
+
49215
+ if (type === "g") {
49216
+ if (!positionPart) return null
49217
+ // Match genomic positions including:
49218
+ // - Simple position: 123
49219
+ // - Range: 123_456
49220
+ // - Uncertain positions: 123_? or ?_456 or (123_456)
49221
+ // Extract just the numeric positions, ignoring variant notation after
49222
+ const match = positionPart.match(/^\(?(\d+)(?:_(\d+|\?))?/);
49223
+ if (!match) return null
49224
+ const start = parseInt(match[1], 10);
49225
+ const endGroup = match[2];
49226
+ // If end is '?' or undefined, use start as end
49227
+ const end = (endGroup && endGroup !== '?') ? parseInt(endGroup, 10) : start;
49228
+ const aliasRecord = await genome.getAliasRecord(accession);
49229
+ const chr = aliasRecord ? aliasRecord.chr : accession;
49230
+ return {chr, start: start - 1, end: end}
49231
+
49232
+ } else if (type === "p") {
49233
+
49234
+ // Protein notation not supported for search currently. The code below is ported from Java and kept for
49235
+ // future reference.
49236
+ return null
49237
+
49238
+ // // Protein position mapping: map codon(s) to genomic span.
49239
+ // const transcript = await getTranscript(browser, accession)
49240
+ // if (!transcript) return null
49241
+ //
49242
+ // const proteinPart = positionPart
49243
+ // const pm = proteinPart.match(/^[A-Za-z*]{0,3}(\d+)(?:_[A-Za-z*]{0,3}(\d+))?/)
49244
+ // if (!pm) return null
49245
+ // let p1 = parseInt(pm[1], 10)
49246
+ // const p2Str = pm[2]
49247
+ // let p2 = p1
49248
+ // if (p2Str) {
49249
+ // p2 = parseInt(p2Str, 10)
49250
+ // }
49251
+ //
49252
+ // const codon1 = transcript.getCodon(genome, transcript.chr, p1)
49253
+ // if (!codon1 || !codon1.isGenomePositionsSet()) return null
49254
+ // let start1 = Math.min(...codon1.getGenomePositions())
49255
+ // let end1 = Math.max(...codon1.getGenomePositions())
49256
+ //
49257
+ // let regionStart = start1
49258
+ // let regionEnd = end1
49259
+ // if (p2 !== p1) {
49260
+ // const codon2 = transcript.getCodon(genome, transcript.chr, p2)
49261
+ // if (!codon2 || !codon2.isGenomePositionsSet()) return null
49262
+ // let start2 = Math.min(...codon2.getGenomePositions())
49263
+ // let end2 = Math.max(...codon2.getGenomePositions())
49264
+ // regionStart = Math.min(start1, start2)
49265
+ // regionEnd = Math.max(end1, end2)
49266
+ // }
49267
+ // const halfOpenEnd = regionEnd + 1
49268
+ // return {chr: transcript.chr, start: regionStart, end: halfOpenEnd}
49269
+
49270
+ } else if (type === "n") {
49271
+
49272
+ // Non-coding transcript mapping: n.123 or n.-123 maps relative to transcript start
49273
+ const transcript = await getTranscript(browser, accession);
49274
+ if (!transcript) return null
49275
+
49276
+ // Parse signed position with optional range and intronic offset (e.g., n.123, n.123_456, n.-7080_-1781, n.123+5)
49277
+ const matcher = positionPart.match(/^(-?\d+)(?:_(-?\d+))?([+-]\d+)?/);
49278
+ if (!matcher) return null
49279
+
49280
+ const t1 = parseInt(matcher[1], 10);
49281
+ const t2Str = matcher[2];
49282
+ const t2 = t2Str != null ? parseInt(t2Str, 10) : t1;
49283
+
49284
+ // Map both transcript positions to genomic
49285
+ let g1 = transcriptPositionToGenomicPosition(transcript, t1);
49286
+ let g2 = transcriptPositionToGenomicPosition(transcript, t2);
49287
+ if (g1 <= 0 || g2 <= 0) return null
49288
+
49289
+ // Apply intronic offset (if any) to BOTH endpoints, strand-aware
49290
+ const offsetStr = matcher[3];
49291
+ if (offsetStr) {
49292
+ let offset = parseInt(offsetStr, 10);
49293
+ if (transcript.strand === '-') offset = -offset;
49294
+ g1 += offset;
49295
+ g2 += offset;
49296
+ }
49297
+
49298
+ // Normalize to genomic span regardless of strand
49299
+ const regionStart = Math.min(g1, g2);
49300
+ const regionEndInclusive = Math.max(g1, g2);
49301
+ const halfOpenEnd = regionEndInclusive + 1;
49302
+ return {chr: transcript.chr, start: regionStart, end: halfOpenEnd}
49303
+
49304
+ } else { // "c"
49305
+
49306
+ const transcript = await getTranscript(browser, accession);
49307
+ if (transcript) {
49308
+ // UTR 5' c.-N with optional range and intronic offset (e.g., c.-211_-215 or c.-211-1058C>G)
49309
+ const utr5Matcher = positionPart.match(/^-(\d+)(?:_-(\d+))?([+-]\d+)?/);
49310
+ if (utr5Matcher) {
49311
+ const n1 = parseInt(utr5Matcher[1], 10);
49312
+ const n2Str = utr5Matcher[2];
49313
+ const n2 = n2Str != null ? parseInt(n2Str, 10) : null;
49314
+ const firstCodingGenomic = codingToGenomePosition(transcript, 1);
49315
+ if (firstCodingGenomic > 0) {
49316
+ let g1 = transcript.strand === '+' ? (firstCodingGenomic - n1) : (firstCodingGenomic + n1);
49317
+ let g2 = g1;
49318
+ if (n2 != null) {
49319
+ g2 = transcript.strand === '+' ? (firstCodingGenomic - n2) : (firstCodingGenomic + n2);
49320
+ }
49321
+ // Apply intronic offset (single value) to both ends if present
49322
+ const offsetStr = utr5Matcher[3];
49323
+ if (offsetStr) {
49324
+ let offset = parseInt(offsetStr, 10);
49325
+ if (transcript.strand === '-') offset = -offset;
49326
+ g1 += offset;
49327
+ g2 += offset;
49328
+ }
49329
+ const start = Math.min(g1, g2);
49330
+ const endInclusive = Math.max(g1, g2);
49331
+ const endExclusive = endInclusive + 1;
49332
+ return {resultType: "LOCUS", chr: transcript.chr, start, end: endExclusive}
49333
+ }
49334
+ return null
49335
+ }
49336
+
49337
+ // UTR 3' c.*N with optional range and intronic offset (e.g., c.*526_*529delATCA or c.*123+45)
49338
+ const utr3Matcher = positionPart.match(/^\*(\d+)(?:_\*(\d+))?([+-]\d+)?/);
49339
+ if (utr3Matcher) {
49340
+ const n1 = parseInt(utr3Matcher[1], 10);
49341
+ const n2Str = utr3Matcher[2];
49342
+ const n2 = n2Str != null ? parseInt(n2Str, 10) : null;
49343
+ let codingLen = 0;
49344
+ if (transcript.exons) {
49345
+ for (const exon of transcript.exons) {
49346
+ codingLen += getCodingLength(exon);
49347
+ }
49348
+ }
49349
+ if (codingLen > 0) {
49350
+ const lastCodingGenomic = codingToGenomePosition(transcript, codingLen);
49351
+ if (lastCodingGenomic > 0) {
49352
+ let g1 = transcript.strand === '+' ? (lastCodingGenomic + n1) : (lastCodingGenomic - n1);
49353
+ let g2 = g1;
49354
+ if (n2 != null) {
49355
+ g2 = transcript.strand === '+' ? (lastCodingGenomic + n2) : (lastCodingGenomic - n2);
49356
+ }
49357
+ // Apply intronic offset (single value) to both ends if present
49358
+ const offsetStr = utr3Matcher[3];
49359
+ if (offsetStr) {
49360
+ let offset = parseInt(offsetStr, 10);
49361
+ if (transcript.strand === '-') offset = -offset;
49362
+ g1 += offset;
49363
+ g2 += offset;
49364
+ }
49365
+ const start = Math.min(g1, g2);
49366
+ const endInclusive = Math.max(g1, g2);
49367
+ const endExclusive = endInclusive + 1;
49368
+ return {resultType: "LOCUS", chr: transcript.chr, start, end: endExclusive}
49369
+ }
49370
+ }
49371
+ return null
49372
+ }
49373
+
49374
+ // CDS position with optional range
49375
+ // First parse endpoints c.X(_Y)? ignoring intronic offsets
49376
+ const cpos = positionPart.match(/^(\d+)(?:_(\d+))?/);
49377
+ if (!cpos) return null
49378
+ const c1 = parseInt(cpos[1], 10);
49379
+ const c2Str = cpos[2];
49380
+ const c2 = c2Str != null ? parseInt(c2Str, 10) : c1;
49381
+
49382
+ // Map both coding positions to genomic
49383
+ let g1 = codingToGenomePosition(transcript, c1);
49384
+ let g2 = codingToGenomePosition(transcript, c2);
49385
+ if (g1 <= 0 || g2 <= 0) return null
49386
+
49387
+ // Now parse optional intronic offsets for each endpoint separately
49388
+ // Patterns like: 123+5 or 123-2 at the beginning, optionally followed by _ and second with offset
49389
+ const offs = positionPart.match(/^(\d+)([+-]\d+)?(?:_(\d+)([+-]\d+)?)?/);
49390
+ if (offs) {
49391
+ const off1Str = offs[2];
49392
+ const off2Str = offs[4];
49393
+ if (off1Str) {
49394
+ let off1 = parseInt(off1Str, 10);
49395
+ if (transcript.strand === '-') off1 = -off1;
49396
+ g1 += off1;
49397
+ }
49398
+ if (off2Str) {
49399
+ let off2 = parseInt(off2Str, 10);
49400
+ if (transcript.strand === '-') off2 = -off2;
49401
+ g2 += off2;
49402
+ }
49403
+ }
49404
+
49405
+ // If there is no explicit second coding position, ensure single-site locus
49406
+ if (c2Str == null) {
49407
+ g2 = g1;
49408
+ }
49409
+
49410
+ const start = Math.min(g1, g2);
49411
+ const endInclusive = Math.max(g1, g2);
49412
+ const endExclusive = endInclusive + 1;
49413
+ return {chr: transcript.chr, start, end: endExclusive}
49414
+ }
49415
+ return null
49416
+ }
49417
+
49418
+ }
49419
+
49420
+ async function getTranscript(browser, accession) {
49421
+ return searchFeatures(browser, accession)
49422
+ }
49423
+
49424
+ /**
49425
+ * Convert a transcript position (1-based, from transcription start) to genomic position
49426
+ * for non-coding transcripts. Walks through exons to find the genomic coordinate.
49427
+ */
49428
+ function transcriptPositionToGenomicPosition(transcript, transcriptPos) {
49429
+ // Handle positions upstream of transcript start (negative n. values)
49430
+ if (transcriptPos <= 0) {
49431
+ const d = Math.abs(transcriptPos);
49432
+ return transcript.strand === '+' ? (transcript.getStart() - d) : (transcript.getEnd() + d)
49433
+ }
49434
+
49435
+ const exons = transcript.exons;
49436
+ if (!exons || exons.length === 0) {
49437
+ // No exons, treat as simple feature
49438
+ if (transcript.strand === '+') {
49439
+ return transcript.getStart() + transcriptPos - 1
49440
+ } else {
49441
+ return transcript.getEnd() - transcriptPos + 1
49442
+ }
49443
+ }
49444
+
49445
+ const positive = transcript.strand === '+';
49446
+ let accumulatedLength = 0;
49447
+
49448
+ // Sort exons appropriately based on strand
49449
+ const sortedExons = exons.slice();
49450
+ if (!positive) {
49451
+ sortedExons.sort((e1, e2) => e2.getStart() - e1.getStart());
49452
+ } else {
49453
+ sortedExons.sort((e1, e2) => e1.getStart() - e2.getStart());
49454
+ }
49455
+
49456
+ for (const exon of sortedExons) {
49457
+ const exonLength = exon.getEnd() - exon.getStart();
49458
+ if (accumulatedLength + exonLength >= transcriptPos) {
49459
+ // Position is in this exon
49460
+ const offsetInExon = transcriptPos - accumulatedLength - 1;
49461
+ if (positive) {
49462
+ return exon.getStart() + offsetInExon
49463
+ } else {
49464
+ return exon.getEnd() - offsetInExon - 1
49465
+ }
49466
+ }
49467
+ accumulatedLength += exonLength;
49468
+ }
49469
+
49470
+ // Position beyond transcript end
49471
+ return -1
49472
+ }
49473
+
49474
+ /**
49475
+ * Translate a 1-based coding position to a 0-based genomic position. Supports HGVS parsing
49476
+ *
49477
+ * @param codingPosition 1-based coding position
49478
+ * @return 0-based genomic position, or -1 if not found.
49479
+ */
49480
+ function codingToGenomePosition(feature, codingPosition) {
49481
+ if (codingPosition <= 0) {
49482
+ return -1
49483
+ }
49484
+ const cdna = codingPosition - 1; // Convert to 0-based
49485
+
49486
+ const exons = feature.exons;
49487
+ if (!exons) {
49488
+ return -1
49489
+ }
49490
+
49491
+ const strand = feature.strand;
49492
+ // if (strand === 'NONE') {
49493
+ // throw new Error("Cannot translate from coding position on an unstranded feature.")
49494
+ // }
49495
+ const positive = strand === '+';
49496
+
49497
+ let codingLength = 0;
49498
+ for (let i = 0; i < exons.length; i++) {
49499
+ const exon = positive ? exons[i] : exons[exons.length - 1 - i];
49500
+ const exonCodingLength = getCodingLength(exon);
49501
+ if (codingLength + exonCodingLength > cdna) {
49502
+ const cdnaOffset = cdna - codingLength;
49503
+ if (positive) {
49504
+ return getCodingStart(exon) + cdnaOffset
49505
+ } else {
49506
+ return getCodingEnd(exon) - 1 - cdnaOffset
49507
+ }
49508
+ }
49509
+ codingLength += exonCodingLength;
49510
+ }
49511
+
49512
+ return -1
49513
+ }
49514
+
49515
+ /**
49516
+ * Returns genomic HGVS notation: <RefSeqAccession>:g.<position>
49517
+ * Example: NC_000001.11:g.1234567
49518
+ */
49519
+ async function getHGVSPosition(genome, chr, position) {
49520
+ try {
49521
+ const aliasRecord = await genome.getAliasRecord(chr);
49522
+ let accession = null;
49523
+
49524
+ if (aliasRecord) {
49525
+ for (const alias of Object.values(aliasRecord)) {
49526
+ if (alias.startsWith("NC_") || alias.startsWith("NT_") || alias.startsWith("NW_") ||
49527
+ alias.startsWith("NG_") || alias.startsWith("NM_") || alias.startsWith("NR_") ||
49528
+ alias.startsWith("NP_")) {
49529
+ accession = alias;
49530
+ break
49531
+ }
49532
+ }
49533
+ }
49534
+
49535
+ if (!accession) {
49536
+ accession = chr;
49537
+ }
49538
+
49539
+ return `${accession}:g.${position}`
49540
+ } catch (e) {
49541
+ log.error("Error getting HGVS position", e);
49542
+ return null
49543
+ }
49544
+ }
49545
+
49546
+ /**
49547
+ * Returns HGVS annotation for the position, for ref and alt bases. If a MANE transcript is available that is
49548
+ * used with coding notation (c.), otherwise genome position is used with genomic notation (g.).
49549
+ * Example: NM_000302.3:c.1234A>G or NM_000302.3:c.123+5T>C (intronic) or NC_000001.11:g.1234567G>A
49550
+ *
49551
+ * @param genome The genome
49552
+ * @param chr The chromosome name
49553
+ * @param position The genomic position (0-based)
49554
+ * @param reference The reference base (single-character string)
49555
+ * @param alternate The alternate base (single-character string)
49556
+ * @return {Promise<string|null>} HGVS notation string, or null if error
49557
+ */
49558
+ async function createHGVSAnnotation(genome, chr, position, reference, alternate) {
49559
+
49560
+ try {
49561
+ const transcript = await genome.getManeTranscriptAt(chr, position);
49562
+
49563
+ if (transcript && transcript.exons) {
49564
+
49565
+ // Ensure bases are uppercase
49566
+ reference = reference.toUpperCase();
49567
+ alternate = alternate.toUpperCase();
49568
+
49569
+ if (transcript.strand === '-') {
49570
+ reference = complementBase(reference);
49571
+ alternate = complementBase(alternate);
49572
+ }
49573
+
49574
+
49575
+ let positionString = "";
49576
+
49577
+ let transcriptName = transcript.name;
49578
+ for (const key of Object.keys(transcript)) {
49579
+ const value = transcript[key];
49580
+ if (typeof value === 'string' && (value.startsWith("NM_") || value.startsWith("NR_"))) {
49581
+ transcriptName = value;
49582
+ break
49583
+ }
49584
+ }
49585
+
49586
+ if (transcriptName) {
49587
+ // Check if position is within an exon (coding or non-coding)
49588
+ let positionIsInExon = false;
49589
+ for (const exon of transcript.exons) {
49590
+ if (position >= exon.start && position < exon.end) {
49591
+ positionIsInExon = true;
49592
+ break
49593
+ }
49594
+ }
49595
+
49596
+ const positive = transcript.strand === '+';
49597
+
49598
+ if (positionIsInExon) {
49599
+ // Try to convert to coding position
49600
+ const codingPosition = genomeToCodingPosition(position, positive, transcript.exons);
49601
+
49602
+ if (codingPosition >= 0) {
49603
+ // Position is in a coding region, return c. notation (1-based)
49604
+ positionString = `${transcriptName}:c.${codingPosition + 1}`;
49605
+ } else {
49606
+ // Position is in an exon but not coding - check if in UTR
49607
+ const firstCodingPos = codingToGenomePosition(transcript, 1);
49608
+ if (firstCodingPos > 0) {
49609
+ // Calculate total coding length
49610
+ let codingLen = 0;
49611
+ for (const exon of transcript.exons) {
49612
+ codingLen += getCodingLength(exon);
49613
+ }
49614
+ const lastCodingPos = codingToGenomePosition(transcript, codingLen);
49615
+
49616
+ // Check if in 5' UTR
49617
+ if ((positive && position < firstCodingPos) || (!positive && position > firstCodingPos)) {
49618
+ const distance = Math.abs(position - firstCodingPos);
49619
+ positionString = `${transcriptName}:c.-${distance}`;
49620
+ }
49621
+ // Check if in 3' UTR
49622
+ else if ((positive && position >= lastCodingPos) || (!positive && position <= lastCodingPos)) {
49623
+ const distance = Math.abs(position - lastCodingPos) + 1;
49624
+ positionString = `${transcriptName}:c.*${distance}`;
49625
+ }
49626
+ }
49627
+ }
49628
+ } else {
49629
+ // Position is intronic - find nearest exon boundary
49630
+ // For HGVS, we reference the last coding base in the nearest exon
49631
+ let nearestExonEdge = -1;
49632
+ let nearestCodingPos = -1;
49633
+ let minDistance = Number.MAX_SAFE_INTEGER;
49634
+
49635
+ for (const exon of transcript.exons) {
49636
+ if (getCodingLength(exon) === 0) continue // Skip non-coding exons
49637
+
49638
+ // Check distance to the last coding base at the start side of the exon
49639
+ // exon.start is 0-based inclusive
49640
+ const distToStart = Math.abs(position - exon.start);
49641
+ if (distToStart > 0 && distToStart < minDistance) {
49642
+ minDistance = distToStart;
49643
+ nearestExonEdge = exon.start;
49644
+ // Get coding position of first base in this exon
49645
+ nearestCodingPos = genomeToCodingPosition(getCodingStart(exon), positive, transcript.exons);
49646
+ }
49647
+
49648
+ // Check distance to the last coding base at the end side of the exon
49649
+ // exon.end is 0-based exclusive, so last base is at end-1
49650
+ const distToEnd = Math.abs(position - (exon.end - 1));
49651
+ if (distToEnd > 0 && distToEnd < minDistance) {
49652
+ minDistance = distToEnd;
49653
+ nearestExonEdge = exon.end - 1;
49654
+ // Get coding position of last base in this exon
49655
+ nearestCodingPos = genomeToCodingPosition(getCodingEnd(exon) - 1, positive, transcript.exons);
49656
+ }
49657
+ }
49658
+
49659
+ if (nearestCodingPos >= 0) {
49660
+ // Calculate offset: positive = downstream of exon, negative = upstream of exon
49661
+ let offset = position - nearestExonEdge;
49662
+ // For positive strand: + means to the right, - means to the left
49663
+ // For negative strand: + means to the left (genomically), - means to the right
49664
+ // But in HGVS, the sign is relative to transcript direction, so we need to flip for negative strand
49665
+ if (!positive) {
49666
+ offset = -offset;
49667
+ }
49668
+ const sign = offset >= 0 ? "+" : "";
49669
+ positionString = `${transcriptName}:c.${nearestCodingPos + 1}${sign}${offset}`;
49670
+ }
49671
+ }
49672
+ }
49673
+
49674
+ return positionString + reference + ">" + alternate
49675
+ }
49676
+
49677
+ // Fallback to genomic notation
49678
+ const aliasRecord = await genome.getAliasRecord(chr);
49679
+ let accession = chr;
49680
+
49681
+ if (aliasRecord) {
49682
+ for (const alias of Object.values(aliasRecord)) {
49683
+ if (alias.startsWith("NC_") || alias.startsWith("NT_") || alias.startsWith("NW_") ||
49684
+ alias.startsWith("NG_") || alias.startsWith("NM_") || alias.startsWith("NR_") ||
49685
+ alias.startsWith("NP_")) {
49686
+ accession = alias;
49687
+ break
49688
+ }
49689
+ }
49690
+ }
49691
+
49692
+ // HGVS genomic coordinate is 1-based; position parameter is 0-based
49693
+ return `${accession}:g.${position + 1}${reference}>${alternate}`
49694
+ } catch (e) {
49695
+ log.error("Error creating HGVS annotation", e);
49696
+ return null
49697
+ }
49698
+ }
49699
+
49700
+ // Helper function to complement a base (string)
49701
+ function complementBase(base) {
49702
+ const complementMap = { 'A': 'T', 'T': 'A', 'C': 'G', 'G': 'C' };
49703
+ return complementMap[base] || base
49704
+ }
49705
+
49706
+ function genomeToCodingPosition(genomePosition, positive, exons) {
49707
+
49708
+ if (exons) {
49709
+
49710
+ /*
49711
+ We loop over all exons, either from the beginning or the end.
49712
+ Increment position only on coding regions.
49713
+ */
49714
+
49715
+ let codingOffset = 0;
49716
+
49717
+ for (let exnum = 0; exnum < exons.length; exnum++) {
49718
+
49719
+ const exon = positive ? exons[exnum] : exons[exons.length - 1 - exnum];
49720
+
49721
+ if (exon.start <= genomePosition && exon.end > genomePosition) {
49722
+ const delta = positive
49723
+ ? genomePosition - getCodingStart(exon)
49724
+ : getCodingEnd(exon) - genomePosition - 1;
49725
+ return codingOffset + delta
49726
+ }
49727
+
49728
+ codingOffset += getCodingLength(exon);
49729
+ }
49730
+ }
49731
+ return -1
49732
+ }
49733
+
49734
+
49735
+
49736
+ const HGVS = {
49737
+ isValidHGVS,
49738
+ search: search$1,
49739
+ getHGVSPosition,
49740
+ createHGVSAnnotation
49741
+ };
49742
+
49743
+ /**
49744
+ * ClinVar utilities for searching and retrieving ClinVar variation information
49745
+ */
49746
+
49747
+ /**
49748
+ * Get the ClinVar URL for the given HGVS notation
49749
+ * @param {string} hgvsNotation - The HGVS notation string to search for
49750
+ * @return {Promise<string|null>} The ClinVar variation URL, or null if not found or error occurs
49751
+ */
49752
+ async function getClinVarURL(hgvsNotation) {
49753
+ try {
49754
+ const encodedHgvs = encodeURIComponent(hgvsNotation);
49755
+ const esearchUrl = `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?` +
49756
+ `db=clinvar&term=${encodedHgvs}&retmode=json`;
49757
+
49758
+ const response = await fetch(esearchUrl);
49759
+
49760
+ if (!response.ok) {
49761
+ console.error(`HTTP error! status: ${response.status}`);
49762
+ return null
49763
+ }
49764
+
49765
+ // Parse JSON response to get the first ClinVar accession
49766
+ const json = await response.json();
49767
+ const esearchResult = json.esearchresult;
49768
+
49769
+ if (esearchResult.count > 0) {
49770
+ const uid = esearchResult.idlist[0];
49771
+ return `https://www.ncbi.nlm.nih.gov/clinvar/variation/${uid}/`
49772
+ } else {
49773
+ return null
49774
+ }
49775
+
49776
+ } catch (e) {
49777
+ console.error("Error fetching ClinVar URL", e);
49778
+ return null
49779
+ }
49780
+ }
49781
+
49782
+ const ClinVar = {
49783
+ getClinVarURL
49784
+ };
49785
+
48927
49786
  const READ_PAIRED_FLAG = 0x1;
48928
49787
  const PROPER_PAIR_FLAG = 0x2;
48929
49788
  const READ_UNMAPPED_FLAG = 0x4;
@@ -49058,13 +49917,22 @@
49058
49917
  return (genomicLocation >= s && genomicLocation <= (s + l))
49059
49918
  }
49060
49919
 
49061
- popupData(genomicLocation, hiddenTags, showTags) {
49920
+ /**
49921
+ * Return data to show in the popup. Elements are either strings (for raw HTML) or
49922
+ * objects with name, value, borderTop properties.
49923
+ *
49924
+ * @param genomicLocation - 0-based genomic location
49925
+ * @param hiddenTags - Set of bam tags to hide
49926
+ * @param showTags - Set of bam tags to show (overrides hide/show rules)
49927
+ * @returns {*[]}
49928
+ */
49929
+ async popupData(genomicLocation, hiddenTags, showTags, refBase, genome) {
49062
49930
 
49063
49931
  // if the user clicks on a base next to an insertion, show just the
49064
49932
  // inserted bases in a popup (like in desktop IGV).
49065
49933
  const nameValues = [];
49066
49934
 
49067
- // Consert genomic location to int
49935
+ // Convert genomic location to int
49068
49936
  genomicLocation = Math.floor(genomicLocation);
49069
49937
 
49070
49938
  if (this.insertions) {
@@ -49083,6 +49951,26 @@
49083
49951
 
49084
49952
  nameValues.push({name: 'Read Name', value: this.readName});
49085
49953
 
49954
+
49955
+ // HGVS annotations for variants, and ClinVar links if available
49956
+ const readBase = this.readBaseAt(genomicLocation);
49957
+ if (refBase) {
49958
+ if (readBase && readBase !== refBase && readBase !== '*') {
49959
+ const hgvsNotation = await HGVS.createHGVSAnnotation(genome, this.chr, genomicLocation, refBase, readBase);
49960
+ if (hgvsNotation) {
49961
+ const clinVarURL = await ClinVar.getClinVarURL(hgvsNotation);
49962
+ if (clinVarURL) {
49963
+ nameValues.push({
49964
+ name: 'ClinVar',
49965
+ value: `<a href='${clinVarURL}' target='_blank'>${hgvsNotation}</a>`
49966
+ });
49967
+ } else {
49968
+ nameValues.push({name: 'HGVS', value: hgvsNotation});
49969
+ }
49970
+ }
49971
+ }
49972
+ }
49973
+
49086
49974
  // Sample
49087
49975
  // Read group
49088
49976
  nameValues.push('<hr/>');
@@ -49158,7 +50046,7 @@
49158
50046
 
49159
50047
  nameValues.push('<hr/>');
49160
50048
  nameValues.push({name: 'Genomic Location: ', value: numberFormatter$1(1 + genomicLocation)});
49161
- nameValues.push({name: 'Read Base:', value: this.readBaseAt(genomicLocation)});
50049
+ nameValues.push({name: 'Read Base:', value: readBase});
49162
50050
  nameValues.push({name: 'Base Quality:', value: this.readBaseQualityAt(genomicLocation)});
49163
50051
 
49164
50052
  const bmSets = this.getBaseModificationSets();
@@ -51363,8 +52251,8 @@
51363
52251
  if (alignmentContainer.hasAlignments) {
51364
52252
  const sequence = await genome.getSequence(chr, alignmentContainer.start, alignmentContainer.end);
51365
52253
  if (sequence) {
51366
- alignmentContainer.coverageMap.refSeq = sequence; // TODO -- fix this
51367
- alignmentContainer.sequence = sequence; // TODO -- fix this
52254
+ alignmentContainer.coverageMap.refSeq = sequence;
52255
+ alignmentContainer.sequence = sequence;
51368
52256
  return alignmentContainer
51369
52257
  } else {
51370
52258
  console.error("No sequence for: " + chr + ":" + alignmentContainer.start + "-" + alignmentContainer.end);
@@ -53685,7 +54573,7 @@
53685
54573
  highlightColor: undefined,
53686
54574
  minTLEN: undefined,
53687
54575
  maxTLEN: undefined,
53688
- tagColorPallete: "Set1",
54576
+ tagColorPallete: "Set1"
53689
54577
  }
53690
54578
 
53691
54579
  _colorTables = new Map()
@@ -54035,7 +54923,7 @@
54035
54923
 
54036
54924
  IGVGraphics.strokeLine(ctx, sPixel, yStrokedLine, ePixel, yStrokedLine, {
54037
54925
  strokeStyle: color,
54038
- lineWidth: 2,
54926
+ lineWidth: 2
54039
54927
  });
54040
54928
 
54041
54929
  // Add gap width as text like Java IGV if it fits nicely and is a multi-base gap
@@ -54044,7 +54932,7 @@
54044
54932
  IGVGraphics.fillRect(ctx, textStart - 1, y - 1, gapTextWidth + 2, 12, {fillStyle: "white"});
54045
54933
  IGVGraphics.fillText(ctx, gapLenText, textStart, y + 10, {
54046
54934
  'font': 'normal 10px monospace',
54047
- 'fillStyle': this.deletionTextColor,
54935
+ 'fillStyle': this.deletionTextColor
54048
54936
  });
54049
54937
  }
54050
54938
  }
@@ -54087,7 +54975,7 @@
54087
54975
  if (this.showInsertionText && insertionBlock.len > 1 && basePixelWidth > textPixelWidth) {
54088
54976
  IGVGraphics.fillText(ctx, insertLenText, xBlockStart + 1, y + 10, {
54089
54977
  'font': 'normal 10px monospace',
54090
- 'fillStyle': this.insertionTextColor,
54978
+ 'fillStyle': this.insertionTextColor
54091
54979
  });
54092
54980
  }
54093
54981
  lastXBlockStart = xBlockStart;
@@ -54257,7 +55145,7 @@
54257
55145
  height: alignmentHeight
54258
55146
  },
54259
55147
  baseColor,
54260
- readChar,
55148
+ readChar
54261
55149
  });
54262
55150
  }
54263
55151
 
@@ -54267,13 +55155,28 @@
54267
55155
  return blockBasesToDraw
54268
55156
  }
54269
55157
  }
55158
+ }
54270
55159
 
54271
- };
54272
-
54273
- popupData(clickState) {
55160
+ async popupData(clickState) {
54274
55161
  const clickedObject = this.getClickedObject(clickState);
54275
- return clickedObject?.popupData(clickState.genomicLocation, this.hiddenTags, this.showTags)
54276
- };
55162
+ if (clickedObject) {
55163
+
55164
+ // Determine reference base at clicked position, used for HGVS notation
55165
+ let refBase;
55166
+ if (clickedObject.chr) {
55167
+ const viewport = clickState.viewport;
55168
+ const alignmentContainer = viewport.cachedFeatures;
55169
+ const coverageMap = alignmentContainer?.coverageMap;
55170
+ const refseq = coverageMap?.refSeq;
55171
+ if (refseq) {
55172
+ const genomicLocation = Math.floor(clickState.genomicLocation);
55173
+ refBase = refseq.charAt(genomicLocation - coverageMap.bpStart).toUpperCase();
55174
+ }
55175
+ }
55176
+
55177
+ return clickedObject.popupData(clickState.genomicLocation, this.hiddenTags, this.showTags, refBase, this.browser.genome)
55178
+ }
55179
+ }
54277
55180
 
54278
55181
  /**
54279
55182
  * Return menu items for the AlignmentTrack
@@ -55046,7 +55949,7 @@
55046
55949
  this.colorTable = new PaletteColorTable(this.tagColorPallete);
55047
55950
  }
55048
55951
  color = this.colorTable.getColor(tagValue);
55049
-
55952
+
55050
55953
  }
55051
55954
  break
55052
55955
  }
@@ -55203,7 +56106,7 @@
55203
56106
 
55204
56107
 
55205
56108
  const base = key.base;
55206
- const compl = complementBase(base);
56109
+ const compl = complementBase$1(base);
55207
56110
 
55208
56111
  const modifiable = coverageMap.getCount(pos, base) + coverageMap.getCount(pos, compl);
55209
56112
  const detectable = modificationCounts.simplexModifications.has(key.modification) ?
@@ -55236,7 +56139,7 @@
55236
56139
 
55237
56140
  const DEFAULT_COVERAGE_COLOR = "rgb(150, 150, 150)";
55238
56141
 
55239
- class CoverageTrack {
56142
+ class CoverageTrack {
55240
56143
 
55241
56144
 
55242
56145
  constructor(config, parent) {
@@ -55248,7 +56151,7 @@
55248
56151
  this.top = 0;
55249
56152
 
55250
56153
  this.autoscale = config.autoscale || config.max === undefined;
55251
- if(config.coverageColor) {
56154
+ if (config.coverageColor) {
55252
56155
  this.color = config.coverageColor;
55253
56156
  }
55254
56157
 
@@ -55265,11 +56168,15 @@
55265
56168
  return this.parent.coverageTrackHeight
55266
56169
  }
55267
56170
 
56171
+ get browser() {
56172
+ return this.parent.browser
56173
+ }
56174
+
55268
56175
  draw(options) {
55269
56176
 
55270
56177
  const pixelTop = options.pixelTop;
55271
56178
  pixelTop + options.pixelHeight;
55272
- const nucleotideColors = this.parent.browser.nucleotideColors;
56179
+ const nucleotideColors = this.browser.nucleotideColors;
55273
56180
 
55274
56181
  if (pixelTop > this.height) {
55275
56182
  return //scrolled out of view
@@ -55303,7 +56210,8 @@
55303
56210
  fillStyle: color,
55304
56211
  strokeStyle: color
55305
56212
  });
55306
- const w = Math.max(1, 1.0 / bpPerPixel);
56213
+
56214
+ const w = Math.max(1, 1.0 / bpPerPixel);
55307
56215
  for (let i = 0, len = coverageMap.coverage.length; i < len; i++) {
55308
56216
 
55309
56217
  const bp = (coverageMap.bpStart + i);
@@ -55372,8 +56280,9 @@
55372
56280
  const coverageMap = features.coverageMap;
55373
56281
  const coverageMapIndex = Math.floor(genomicLocation - coverageMap.bpStart);
55374
56282
  const coverage = coverageMap.coverage[coverageMapIndex];
55375
- if(coverage) {
56283
+ if (coverage) {
55376
56284
  return {
56285
+ reference: coverageMap.refSeq ? coverageMap.refSeq.charAt(coverageMapIndex).toUpperCase() : undefined,
55377
56286
  coverage: coverage,
55378
56287
  baseModCounts: features.baseModCounts,
55379
56288
  hoverText: () => coverageMap.coverage[coverageMapIndex].hoverText()
@@ -55381,60 +56290,64 @@
55381
56290
  }
55382
56291
  }
55383
56292
 
55384
- popupData(clickState) {
56293
+ async popupData(clickState) {
55385
56294
 
55386
56295
  const nameValues = [];
55387
56296
 
55388
- const {coverage, baseModCounts} = this.getClickedObject(clickState);
56297
+ const {reference, coverage, baseModCounts} = this.getClickedObject(clickState);
55389
56298
  if (coverage) {
55390
56299
  const genomicLocation = Math.floor(clickState.genomicLocation);
55391
56300
  const referenceFrame = clickState.viewport.referenceFrame;
55392
56301
 
55393
56302
  nameValues.push(referenceFrame.chr + ":" + numberFormatter$1(1 + genomicLocation));
55394
-
55395
56303
  nameValues.push({name: 'Total Count', value: coverage.total});
56304
+ nameValues.push('<HR/>');
55396
56305
 
55397
56306
  // A
55398
- let tmp = coverage.posA + coverage.negA;
55399
- if (tmp > 0) tmp = tmp.toString() + " (" + Math.round((tmp / coverage.total) * 100.0) + "%, " + coverage.posA + "+, " + coverage.negA + "- )";
55400
- nameValues.push({name: 'A', value: tmp});
55401
-
55402
- // C
55403
- tmp = coverage.posC + coverage.negC;
55404
- if (tmp > 0) tmp = tmp.toString() + " (" + Math.round((tmp / coverage.total) * 100.0) + "%, " + coverage.posC + "+, " + coverage.negC + "- )";
55405
- nameValues.push({name: 'C', value: tmp});
55406
-
55407
- // G
55408
- tmp = coverage.posG + coverage.negG;
55409
- if (tmp > 0) tmp = tmp.toString() + " (" + Math.round((tmp / coverage.total) * 100.0) + "%, " + coverage.posG + "+, " + coverage.negG + "- )";
55410
- nameValues.push({name: 'G', value: tmp});
55411
-
55412
- // T
55413
- tmp = coverage.posT + coverage.negT;
55414
- if (tmp > 0) tmp = tmp.toString() + " (" + Math.round((tmp / coverage.total) * 100.0) + "%, " + coverage.posT + "+, " + coverage.negT + "- )";
55415
- nameValues.push({name: 'T', value: tmp});
55416
-
55417
- // N
55418
- tmp = coverage.posN + coverage.negN;
55419
- if (tmp > 0) tmp = tmp.toString() + " (" + Math.round((tmp / coverage.total) * 100.0) + "%, " + coverage.posN + "+, " + coverage.negN + "- )";
55420
- nameValues.push({name: 'N', value: tmp});
56307
+ for (let b of ['A', 'C', 'G', 'T', 'N']) {
56308
+ let tmp = coverage[`pos${b}`] + coverage[`neg${b}`];
56309
+ tmp = tmp.toString() + " (" + Math.round((tmp / coverage.total) * 100.0) + "%, " + coverage[`pos${b}`] + "+, " + coverage[`neg${b}`] + "- )";
56310
+ nameValues.push({name: b, value: tmp});
56311
+ }
55421
56312
 
55422
- nameValues.push('<HR/>');
55423
- nameValues.push({name: 'DEL', value: coverage.del.toString()});
55424
- nameValues.push({name: 'INS', value: coverage.ins.toString()});
56313
+ if (coverage.del > 0) nameValues.push({name: 'DEL', value: coverage.del.toString()});
56314
+ if (coverage.ins > 0) nameValues.push({name: 'INS', value: coverage.ins.toString()});
55425
56315
 
55426
- if(baseModCounts) {
56316
+ if (baseModCounts) {
55427
56317
  nameValues.push('<hr/>');
55428
56318
  nameValues.push(...baseModCounts.popupData(genomicLocation, this.parent.colorBy));
55429
56319
 
55430
56320
  }
55431
56321
 
55432
- }
56322
+ // HGVS annotations for variants, and ClinVar links if available
56323
+ if (reference) {
56324
+ let first = true;
56325
+ for (let b of ['A', 'C', 'G', 'T']) {
56326
+ let count = coverage[`pos${b}`] + coverage[`neg${b}`];
56327
+ if (count > 0 && reference !== b) {
56328
+ if (first) {
56329
+ nameValues.push('<hr/>');
56330
+ first = false;
56331
+ }
56332
+ const hgvsNotation = await HGVS.createHGVSAnnotation(this.browser.genome, referenceFrame.chr, genomicLocation, reference, b);
56333
+ const clinVarURL = await ClinVar.getClinVarURL(hgvsNotation);
56334
+ if (clinVarURL) {
56335
+ nameValues.push({
56336
+ name: 'ClinVar',
56337
+ value: `<a href='${clinVarURL}' target='_blank'>${hgvsNotation}</a>`
56338
+ });
56339
+ } else {
56340
+ nameValues.push({name: 'HGVS', value: hgvsNotation});
56341
+ }
56342
+ }
56343
+ }
56344
+ }
55433
56345
 
55434
- return nameValues
55435
56346
 
55436
- }
56347
+ return nameValues
55437
56348
 
56349
+ }
56350
+ }
55438
56351
  }
55439
56352
 
55440
56353
  class BAMTrack extends TrackBase {
@@ -72366,39 +73279,6 @@ ${indent}columns: ${matrix.columns}
72366
73279
  '*': '#002eff'
72367
73280
  });
72368
73281
 
72369
- const DEFAULT_SEARCH_CONFIG = {
72370
- timeout: 5000,
72371
- type: "plain",
72372
- url: 'https://igv.org/genomes/locus.php?genome=$GENOME$&name=$FEATURE$',
72373
- coords: 0
72374
- };
72375
-
72376
- async function searchFeatures(browser, name) {
72377
-
72378
- const searchConfig = browser.searchConfig || DEFAULT_SEARCH_CONFIG;
72379
- let feature;
72380
-
72381
- name = name.toUpperCase();
72382
- const searchableTracks = browser.tracks.filter(t => t.searchable);
72383
- for (let track of searchableTracks) {
72384
- const feature = await track.search(name);
72385
- if (feature) {
72386
- return feature
72387
- }
72388
- }
72389
-
72390
- // If still not found try webservice, if enabled
72391
- if (browser.config && false !== browser.config.search) {
72392
- try {
72393
- feature = await searchWebService(browser, name, searchConfig);
72394
- return feature // Might be undefined
72395
- } catch (error) {
72396
- console.log("Search service not available " + error);
72397
- }
72398
- }
72399
-
72400
- }
72401
-
72402
73282
  /**
72403
73283
  * Return an object representing the locus of the given string. Object is of the form
72404
73284
  * {
@@ -72425,6 +73305,13 @@ ${indent}columns: ${matrix.columns}
72425
73305
 
72426
73306
  const searchForLocus = async (locus) => {
72427
73307
 
73308
+ if (HGVS.isValidHGVS(locus)) {
73309
+ const hgvsResult = await HGVS.search(locus, browser);
73310
+ if (hgvsResult) {
73311
+ return hgvsResult
73312
+ }
73313
+ }
73314
+
72428
73315
  if (locus.trim().toLowerCase() === "all" || locus === "*") {
72429
73316
  if (browser.genome.wholeGenomeView) {
72430
73317
  const wgChr = browser.genome.getChromosome("all");
@@ -72450,12 +73337,12 @@ ${indent}columns: ${matrix.columns}
72450
73337
 
72451
73338
  // Not a locus string, search track annotations
72452
73339
  const feature = await searchFeatures(browser, locus);
72453
- if(feature) {
73340
+ if (feature) {
72454
73341
  locusObject = {
72455
73342
  chr: feature.chr,
72456
73343
  start: feature.start,
72457
73344
  end: feature.end,
72458
- name: (feature.name || locus).toUpperCase(),
73345
+ name: (feature.name || locus).toUpperCase()
72459
73346
 
72460
73347
  };
72461
73348
  }
@@ -72580,110 +73467,6 @@ ${indent}columns: ${matrix.columns}
72580
73467
 
72581
73468
  }
72582
73469
 
72583
- async function searchWebService(browser, locus, searchConfig) {
72584
-
72585
- let path = searchConfig.url.replace("$FEATURE$", locus.toUpperCase());
72586
- if (path.indexOf("$GENOME$") > -1) {
72587
- path = path.replace("$GENOME$", (browser.genome.id ? browser.genome.id : "hg19"));
72588
- }
72589
- const options = searchConfig.timeout ? {timeout: searchConfig.timeout} : undefined;
72590
- const result = await igvxhr.loadString(path, options);
72591
-
72592
- return processSearchResult(browser, result, searchConfig)
72593
- }
72594
-
72595
- function processSearchResult(browser, result, searchConfig) {
72596
-
72597
- let results;
72598
-
72599
- if ('plain' === searchConfig.type) {
72600
- results = parseSearchResults(browser, result);
72601
- } else {
72602
- results = JSON.parse(result);
72603
- }
72604
-
72605
- if (searchConfig.resultsField) {
72606
- results = results[searchConfig.resultsField];
72607
- }
72608
-
72609
- if (!results || 0 === results.length) {
72610
- return undefined
72611
-
72612
- } else {
72613
-
72614
- const chromosomeField = searchConfig.chromosomeField || "chromosome";
72615
- const startField = searchConfig.startField || "start";
72616
- const endField = searchConfig.endField || "end";
72617
- const coords = searchConfig.coords || 1;
72618
-
72619
-
72620
- let result;
72621
- if (Array.isArray(results)) {
72622
- // Ignoring all but first result for now
72623
- // TODO -- present all and let user select if results.length > 1
72624
- result = results[0];
72625
- } else {
72626
- // When processing search results from Ensembl REST API
72627
- // Example: https://rest.ensembl.org/lookup/symbol/macaca_fascicularis/BRCA2?content-type=application/json
72628
- result = results;
72629
- }
72630
-
72631
- if (!(result.hasOwnProperty(chromosomeField) && (result.hasOwnProperty(startField)))) {
72632
- console.error("Search service results must include chromosome and start fields: " + result);
72633
- }
72634
-
72635
- const chr = result[chromosomeField];
72636
- let start = result[startField] - coords;
72637
- let end = result[endField];
72638
- if (undefined === end) {
72639
- end = start + 1;
72640
- }
72641
-
72642
- const locusObject = {chr, start, end};
72643
-
72644
- // Some GTEX hacks
72645
- result.type ? result.type : "gene";
72646
- if (searchConfig.geneField && searchConfig.snpField) {
72647
- const name = result[searchConfig.geneField] || result[searchConfig.snpField]; // Should never have both
72648
- if (name) locusObject.name = name.toUpperCase();
72649
- }
72650
-
72651
- return locusObject
72652
- }
72653
- }
72654
-
72655
- /**
72656
- * Parse the igv line-oriented (non json) search results.
72657
- * NOTE: currently, and probably permanently, this will always be a single line
72658
- * Example
72659
- * EGFR chr7:55,086,724-55,275,031 refseq
72660
- *
72661
- */
72662
- function parseSearchResults(browser, data) {
72663
-
72664
- const results = [];
72665
- const lines = splitLines$3(data);
72666
-
72667
- for (let line of lines) {
72668
-
72669
- const tokens = line.split("\t");
72670
-
72671
- if (tokens.length >= 3) {
72672
- const locusTokens = tokens[1].split(":");
72673
- const rangeTokens = locusTokens[1].split("-");
72674
- results.push({
72675
- chromosome: browser.genome.getChromosomeName(locusTokens[0].trim()),
72676
- start: parseInt(rangeTokens[0].replace(/,/g, '')),
72677
- end: parseInt(rangeTokens[1].replace(/,/g, '')),
72678
- name: tokens[0].toUpperCase()
72679
- });
72680
- }
72681
- }
72682
-
72683
- return results
72684
-
72685
- }
72686
-
72687
73470
  class QTLTrack extends TrackBase {
72688
73471
 
72689
73472
  constructor(config, browser) {
@@ -75699,7 +76482,7 @@ ${indent}columns: ${matrix.columns}
75699
76482
  })
75700
76483
  }
75701
76484
 
75702
- const _version = "3.6.0";
76485
+ const _version = "3.7.1";
75703
76486
  function version() {
75704
76487
  return _version
75705
76488
  }
@@ -78711,7 +79494,7 @@ ${indent}columns: ${matrix.columns}
78711
79494
  this.sequence = await loadSequence(config, this.browser);
78712
79495
 
78713
79496
  // Load cytobands. This is optional but required to support the ideogram. Only needed for whole genome view
78714
- if(false !== config.showIdeogram && false !== config.wholeGenomeView) {
79497
+ if (false !== config.showIdeogram && false !== config.wholeGenomeView) {
78715
79498
  if (config.cytobandURL) {
78716
79499
  this.cytobandSource = new CytobandFile(config.cytobandURL, Object.assign({}, config));
78717
79500
  } else if (config.cytobandBbURL) {
@@ -78719,8 +79502,8 @@ ${indent}columns: ${matrix.columns}
78719
79502
  }
78720
79503
  }
78721
79504
 
78722
- // Search for chromosomes, that is an array of chromosome objects containing name and length. This is
78723
- // optional but required to support whole genome view.
79505
+ // Search for chromosomes, that is an array of chromosome objects containing name and length. This is
79506
+ // optional but required to support whole genome view.
78724
79507
  if (this.sequence.chromosomes) {
78725
79508
  this.chromosomes = this.sequence.chromosomes;
78726
79509
  } else if (config.chromSizesURL) {
@@ -78756,7 +79539,7 @@ ${indent}columns: ${matrix.columns}
78756
79539
  // Trim to remove non-existent chromosomes
78757
79540
  await this.chromAlias.preload(this.#wgChromosomeNames);
78758
79541
  this.#wgChromosomeNames =
78759
- this.#wgChromosomeNames.map(c => this.getChromosomeName(c)).filter(c => this.chromosomes.has(c));
79542
+ this.#wgChromosomeNames.map(c => this.getChromosomeName(c)).filter(c => this.chromosomes.has(c));
78760
79543
  } else {
78761
79544
  this.#wgChromosomeNames = trimSmallChromosomes(this.chromosomes);
78762
79545
  await this.chromAlias.preload(this.#wgChromosomeNames);
@@ -79000,6 +79783,75 @@ ${indent}columns: ${matrix.columns}
79000
79783
  getHubURLs() {
79001
79784
  return this.config.hubs
79002
79785
  }
79786
+
79787
+ /**
79788
+ * Return the Mane transcript with the given name, or null if not found. We also check the refseq historical
79789
+ * db if available for backward compatibility. This is only available for hg38.
79790
+ * @param {string} name - The name of the Mane transcript to search for.
79791
+ * @return {Promise<Object|null>} A Promise resolving to the Mane transcript object if found, or null otherwise.
79792
+ */
79793
+ async getManeTranscript(name) {
79794
+
79795
+ if (!this.maneFeatureSource && this.config.maneBbURL) {
79796
+ this.loadManeFeatureSource();
79797
+ }
79798
+ if (this.maneFeatureSource) {
79799
+ const feature = await this.maneFeatureSource.search(name);
79800
+ if (feature) {
79801
+ return feature
79802
+ }
79803
+ }
79804
+ if (!this.rsDBFeatureSource && this.config.rsdbURL) {
79805
+ this.rsDBFeatureSource = new BWSource({url: this.config.rsdbURL}, this);
79806
+ }
79807
+ if (this.rsDBFeatureSource) {
79808
+ const feature = await this.rsDBFeatureSource.search(name);
79809
+ if (feature) {
79810
+ return feature
79811
+ }
79812
+ }
79813
+ return null
79814
+ }
79815
+
79816
+ /**
79817
+ * Return the Mane transcript overlapping the given position, or null if none found.
79818
+ *
79819
+ * @param chr Chromosome name (e.g., "chr1", "chrX") in which to search for the transcript.
79820
+ * @param position Genomic position (0-based coordinate) to check for overlap with a Mane transcript.
79821
+ * @return {Promise<*|null>} The feature representing the Mane transcript overlapping the specified position, or null if none is found.
79822
+ */
79823
+ async getManeTranscriptAt(chr, position) {
79824
+ if (!this.maneFeatureSource && this.config.maneBbURL) {
79825
+ this.loadManeFeatureSource();
79826
+ }
79827
+ if (this.maneFeatureSource) {
79828
+ try {
79829
+ const start = position;
79830
+ const end = position + 1;
79831
+ const features = await this.maneFeatureSource.getFeatures({chr, start, end});
79832
+ if (features) {
79833
+ for (const feature of features) {
79834
+ if (feature.start <= position && feature.end >= position) {
79835
+ return feature
79836
+ }
79837
+ }
79838
+ }
79839
+ } catch (e) {
79840
+ console.error("Error fetching MANE transcript", e);
79841
+ }
79842
+ }
79843
+ return null
79844
+ }
79845
+
79846
+ loadManeFeatureSource() {
79847
+ if (this.config.maneBbURL != null) {
79848
+ const bbConfig = {url: this.config.maneBbURL};
79849
+ if (this.config.maneTrixURL) {
79850
+ bbConfig.trixURL = this.config.maneTrixURL;
79851
+ }
79852
+ this.maneFeatureSource = new BWSource(bbConfig, this);
79853
+ }
79854
+ }
79003
79855
  }
79004
79856
 
79005
79857
  /**
@@ -79817,10 +80669,7 @@ ${indent}columns: ${matrix.columns}
79817
80669
 
79818
80670
  let session;
79819
80671
  if (options.url || options.file) {
79820
- session = await Browser.loadSessionFile(options, this.config);
79821
- // if (options.parentApp``) {
79822
- // session.parentApp = options.parentApp
79823
- // }
80672
+ session = await Browser.loadSessionFile(options);
79824
80673
  } else {
79825
80674
  session = options;
79826
80675
  }
@@ -79834,7 +80683,7 @@ ${indent}columns: ${matrix.columns}
79834
80683
  * @param options
79835
80684
  * @returns {Promise<*|XMLSession>}
79836
80685
  */
79837
- static async loadSessionFile(options, defaults) {
80686
+ static async loadSessionFile(options) {
79838
80687
 
79839
80688
  const urlOrFile = options.url || options.file;
79840
80689
 
@@ -79863,7 +80712,7 @@ ${indent}columns: ${matrix.columns}
79863
80712
  config = await igvxhr.loadJson(urlOrFile);
79864
80713
  }
79865
80714
  }
79866
- setDefaults(config, defaults);
80715
+
79867
80716
  return config
79868
80717
  }
79869
80718
 
@@ -79874,6 +80723,9 @@ ${indent}columns: ${matrix.columns}
79874
80723
  */
79875
80724
  async loadSessionObject(session) {
79876
80725
 
80726
+ // Capture current configuration options that might be missing from session
80727
+ setDefaults(session, this.config);
80728
+
79877
80729
  // prepare to load a new session, discarding DOM and state
79878
80730
  this.cleanHouseForSession();
79879
80731
  this.config = session;
@@ -79978,16 +80830,19 @@ ${indent}columns: ${matrix.columns}
79978
80830
 
79979
80831
  // Sample info
79980
80832
  const localSampleInfoFiles = [];
80833
+ const googleDriveSampleInfoFiles = [];
79981
80834
  if (session.sampleinfo) {
79982
80835
  const sampleInfoArray = Array.isArray(session.sampleinfo) ? session.sampleinfo : [session.sampleinfo];
79983
80836
  for (const sampleInfoConfig of sampleInfoArray) {
79984
- // The "file" property is recorded in the session when a local file is referenced. It can't be used
79985
- // on reloading, its only purpose is to present an alert to the user. This could also be used
79986
- // to prompt the user to load the file manually, but we don't currently do that.
79987
80837
  if (sampleInfoConfig.file) {
79988
80838
  localSampleInfoFiles.push(sampleInfoConfig.file);
79989
80839
  } else {
79990
- await this.sampleInfo.loadSampleInfo(sampleInfoConfig);
80840
+ const googleDriveItem = this.#createGoogleDriveItemIfPresent(sampleInfoConfig, 'Sample info', 'url', 'filename', 'Google Drive file');
80841
+ if (googleDriveItem) {
80842
+ googleDriveSampleInfoFiles.push(googleDriveItem);
80843
+ } else {
80844
+ await this.sampleInfo.loadSampleInfo(sampleInfoConfig);
80845
+ }
79991
80846
  }
79992
80847
  }
79993
80848
  }
@@ -80002,22 +80857,40 @@ ${indent}columns: ${matrix.columns}
80002
80857
  trackConfigurations.push({type: "sequence", order: defaultSequenceTrackOrder, removable: false});
80003
80858
  }
80004
80859
 
80005
- const localTrackFileNames = trackConfigurations.filter((config) => undefined !== config.file).map(({file}) => file);
80860
+ // Extract problematic resources from track configurations
80861
+ const { localFileItems, googleDriveItems } = this.#extractProblematicResources(
80862
+ trackConfigurations,
80863
+ localSampleInfoFiles,
80864
+ googleDriveSampleInfoFiles
80865
+ );
80006
80866
 
80007
- const localIndexFileNames = trackConfigurations.filter((config) => undefined !== config.indexFile).map(({indexFile}) => indexFile);
80008
- if (localIndexFileNames.length > 0) {
80009
- localTrackFileNames.push(...localIndexFileNames);
80010
- }
80867
+ // Display warning if problematic resources are found
80868
+ if (localFileItems.length > 0 || googleDriveItems.length > 0) {
80869
+ 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';
80011
80870
 
80012
- if (localSampleInfoFiles.length > 0) {
80013
- localTrackFileNames.push(...localSampleInfoFiles);
80014
- }
80871
+ // Add local file items
80872
+ for (const item of localFileItems) {
80873
+ message += `Local file name: ${item.fileName}\n`;
80874
+ message += `Track name: ${item.trackName}\n\n`;
80875
+
80876
+ }
80877
+
80878
+ // Add Google Drive items
80879
+ for (const item of googleDriveItems) {
80880
+ message += `Google Drive file name: ${item.fileName}\n`;
80881
+ message += `Track name: ${item.trackName}\n\n`;
80882
+
80883
+ }
80015
80884
 
80016
- if (localTrackFileNames.length > 0) {
80017
- alert(`Local files cannot be loaded automatically.\nThis session contains references to these local files:\n${localTrackFileNames.map(str => ` ${str}`).join('\n')}`);
80885
+ alert(message);
80018
80886
  }
80019
80887
 
80020
- const nonLocalTrackConfigurations = trackConfigurations.filter((config) => undefined === config.file);
80888
+ const nonLocalTrackConfigurations = trackConfigurations.filter((config) =>
80889
+ undefined === config.file &&
80890
+ undefined === config.indexFile &&
80891
+ // Filter out tracks with Google Drive URLs in url/indexURL fields
80892
+ !(config.url && isGoogleDriveURL(config.url)) &&
80893
+ !(config.indexURL && isGoogleDriveURL(config.indexURL)));
80021
80894
 
80022
80895
  // Maintain track order unless explicitly set
80023
80896
  let trackOrder = 1;
@@ -80913,7 +81786,7 @@ ${indent}columns: ${matrix.columns}
80913
81786
  }
80914
81787
 
80915
81788
  minimumBases() {
80916
- return this.config.minimumBases
81789
+ return this.config.minimumBases ?? 40
80917
81790
  }
80918
81791
 
80919
81792
  // Zoom in by a factor of 2, keeping the same center location
@@ -81284,11 +82157,6 @@ ${indent}columns: ${matrix.columns}
81284
82157
  }
81285
82158
 
81286
82159
  json["reference"] = this.genome.toJSON();
81287
- if (json.reference.fastaURL instanceof File) { // Test specifically for File. Other types of File-like objects might be savable) {
81288
- throw new Error(`Error. Sessions cannot include local file references ${json.reference.fastaURL.name}.`)
81289
- } else if (json.reference.indexURL instanceof File) { // Test specifically for File. Other types of File-like objects might be savable) {
81290
- throw new Error(`Error. Sessions cannot include local file references ${json.reference.indexURL.name}.`)
81291
- }
81292
82160
 
81293
82161
  // Build locus array (multi-locus view). Use the first track to extract the loci, any track could be used.
81294
82162
  const locus = [];
@@ -81329,9 +82197,9 @@ ${indent}columns: ${matrix.columns}
81329
82197
 
81330
82198
  let config;
81331
82199
  if (typeof track.getState === "function") {
81332
- config = TrackBase.localFileInspection(track.getState());
82200
+ config = TrackBase.prepareConfigForSession(track.getState());
81333
82201
  } else if (track.config) {
81334
- config = TrackBase.localFileInspection(track.config);
82202
+ config = TrackBase.prepareConfigForSession(track.config);
81335
82203
  }
81336
82204
 
81337
82205
  if (config) {
@@ -81362,37 +82230,214 @@ ${indent}columns: ${matrix.columns}
81362
82230
 
81363
82231
  json["tracks"] = trackJson;
81364
82232
 
81365
- const localFileDetections = [];
81366
- for (const json of trackJson) {
81367
- for (const key of Object.keys(json)) {
81368
- if ('file' === key || 'indexFile' === key) {
81369
- localFileDetections.push(json[key]);
81370
- }
82233
+ // Sample info
82234
+ if (this.config.sampleinfo) {
82235
+ json["sampleinfo"] = this.config.sampleinfo;
82236
+ }
82237
+
82238
+ // Validate reference genome and warn about problematic resources
82239
+ this._validateAndWarnResources(json);
82240
+
82241
+ return json
82242
+ }
82243
+
82244
+ /**
82245
+ * Get a display identifier for a Google Drive file.
82246
+ * Returns the provided filename if available, otherwise falls back to a default string.
82247
+ * Note: Filenames should always be present in saved sessions since Google Drive files
82248
+ * can only be added when the user is authenticated.
82249
+ *
82250
+ * @param {string|undefined} filename - The filename property from the config (e.g., config.filename or config.indexFilename)
82251
+ * @param {string} defaultFallback - Default identifier to use if filename is not provided
82252
+ * @returns {string} A display identifier (filename or fallback string)
82253
+ * @private
82254
+ */
82255
+ #getGoogleDriveDisplayName(filename, defaultFallback = 'Google Drive file') {
82256
+ return filename || defaultFallback
82257
+ }
82258
+
82259
+ /**
82260
+ * Check if a config has a Google Drive URL and create a Google Drive item if found.
82261
+ *
82262
+ * @param {Object} config - Track configuration object
82263
+ * @param {string} trackName - Name of the track
82264
+ * @param {string} urlField - Field name to check ('url' or 'indexURL')
82265
+ * @param {string} filenameField - Field name for filename ('filename' or 'indexFilename')
82266
+ * @param {string} defaultFileName - Default filename if not found
82267
+ * @returns {Object|null} Google Drive item object or null if not a Google Drive URL
82268
+ * @private
82269
+ */
82270
+ #createGoogleDriveItemIfPresent(config, trackName, urlField, filenameField, defaultFileName) {
82271
+ const url = config[urlField];
82272
+ if (url && isGoogleDriveURL(url)) {
82273
+ const fileName = this.#getGoogleDriveDisplayName(config[filenameField], defaultFileName);
82274
+ return {
82275
+ trackName: trackName,
82276
+ fileName: fileName
81371
82277
  }
81372
82278
  }
82279
+ return null
82280
+ }
81373
82281
 
81374
- // Sample info
81375
- const localSampleInfoFileDetections = [];
81376
- if (this.config.sampleinfo) {
82282
+ /**
82283
+ * Extract Google Drive items from a track configuration (checks both url and indexURL).
82284
+ *
82285
+ * @param {Object} config - Track configuration object
82286
+ * @returns {Array} Array of Google Drive items found in this config
82287
+ * @private
82288
+ */
82289
+ #extractGoogleDriveItemsFromConfig(config) {
82290
+ const items = [];
82291
+ const trackName = config.name || 'Unnamed track';
81377
82292
 
81378
- json["sampleinfo"] = this.config.sampleinfo;
82293
+ // Check main file URL
82294
+ const mainItem = this.#createGoogleDriveItemIfPresent(config, trackName, 'url', 'filename', 'Google Drive file');
82295
+ if (mainItem) {
82296
+ items.push(mainItem);
82297
+ }
82298
+
82299
+ // Check index file URL
82300
+ const indexItem = this.#createGoogleDriveItemIfPresent(config, `${trackName} index`, 'indexURL', 'indexFilename', 'Google Drive index file');
82301
+ if (indexItem) {
82302
+ items.push(indexItem);
82303
+ }
82304
+
82305
+ return items
82306
+ }
82307
+
82308
+ /**
82309
+ * Extract problematic resources (local files and Google Drive files) from track configurations.
82310
+ * Google Drive files are detected by checking if the url/indexURL fields contain Google Drive URLs,
82311
+ * using the isGoogleDriveURL helper function from sessionResourceValidator.
82312
+ *
82313
+ * @param {Array} trackConfigurations - Array of track configuration objects
82314
+ * @param {Array} localSampleInfoFiles - Array of local sample info filenames
82315
+ * @param {Array} googleDriveSampleInfoFiles - Array of Google Drive sample info items (objects with trackName and fileName)
82316
+ * @returns {{localFileItems: Array, googleDriveItems: Array}} Object containing arrays of problematic resources
82317
+ * @private
82318
+ */
82319
+ #extractProblematicResources(trackConfigurations, localSampleInfoFiles = [], googleDriveSampleInfoFiles = []) {
82320
+ const localFileItems = [];
82321
+ const googleDriveItems = [];
82322
+
82323
+ // Collect local files from track configurations
82324
+ for (const config of trackConfigurations) {
82325
+ const trackName = config.name || 'Unnamed track';
82326
+ if (config.file) {
82327
+ localFileItems.push({
82328
+ trackName: trackName,
82329
+ fileName: config.file
82330
+ });
82331
+ }
82332
+ if (config.indexFile) {
82333
+ localFileItems.push({
82334
+ trackName: `${trackName} index`,
82335
+ fileName: config.indexFile
82336
+ });
82337
+ }
82338
+ }
82339
+
82340
+ // Add sample info local files
82341
+ for (const fileName of localSampleInfoFiles) {
82342
+ localFileItems.push({
82343
+ trackName: 'Sample info',
82344
+ fileName: fileName
82345
+ });
82346
+ }
82347
+
82348
+ // Collect Google Drive files by checking if url/indexURL fields contain Google Drive URLs
82349
+ for (const config of trackConfigurations) {
82350
+ const items = this.#extractGoogleDriveItemsFromConfig(config);
82351
+ googleDriveItems.push(...items);
82352
+ }
82353
+
82354
+ // Add sample info Google Drive files
82355
+ googleDriveItems.push(...googleDriveSampleInfoFiles);
82356
+
82357
+ return { localFileItems, googleDriveItems }
82358
+ }
82359
+
82360
+ /**
82361
+ * Validate reference genome and warn about problematic resources in the session.
82362
+ *
82363
+ * Reference genome: Throws error if local files or Google Drive URLs are detected
82364
+ * Tracks/Sample Info: Shows warning if local files or Google Drive URLs are detected
82365
+ *
82366
+ * @param {Object} json - The session JSON object
82367
+ * @private
82368
+ */
82369
+ _validateAndWarnResources(json) {
82370
+ // 1. Validate reference genome (blocking errors)
82371
+ const refErrors = [];
82372
+
82373
+ if (json.reference.fastaURL) {
82374
+ if (isLocalFile(json.reference.fastaURL)) {
82375
+ refErrors.push(`Local file: ${json.reference.fastaURL.name}`);
82376
+ } else if (isGoogleDriveURL(json.reference.fastaURL)) {
82377
+ refErrors.push(`Google Drive URL: ${json.reference.fastaURL}`);
82378
+ }
82379
+ }
82380
+
82381
+ if (json.reference.indexURL) {
82382
+ if (isLocalFile(json.reference.indexURL)) {
82383
+ refErrors.push(`Local file: ${json.reference.indexURL.name}`);
82384
+ } else if (isGoogleDriveURL(json.reference.indexURL)) {
82385
+ refErrors.push(`Google Drive URL: ${json.reference.indexURL}`);
82386
+ }
82387
+ }
82388
+
82389
+ if (refErrors.length > 0) {
82390
+ throw new Error(
82391
+ `Error: Sessions cannot include the following resources in the reference genome:\n` +
82392
+ refErrors.map(err => ` - ${err}`).join('\n') + '\n' +
82393
+ `These resources require local access or authentication and will not work when the session is shared.`
82394
+ )
82395
+ }
82396
+
82397
+ // 2. Collect warnings from tracks and sample info
82398
+ const localSampleInfoFiles = [];
82399
+ const googleDriveSampleInfoFiles = [];
81379
82400
 
82401
+ // Check sample info
82402
+ if (this.config.sampleinfo) {
81380
82403
  for (const path of this.sampleInfo.sampleInfoFiles) {
81381
- const config = TrackBase.localFileInspection({url: path});
82404
+ const config = TrackBase.prepareConfigForSession({url: path});
81382
82405
  if (config.file) {
81383
- localSampleInfoFileDetections.push(config.file);
82406
+ localSampleInfoFiles.push(config.file);
82407
+ }
82408
+ // Check if the url field contains a Google Drive URL
82409
+ const googleDriveItem = this.#createGoogleDriveItemIfPresent(config, 'Sample info', 'url', 'filename', 'Google Drive file');
82410
+ if (googleDriveItem) {
82411
+ googleDriveSampleInfoFiles.push(googleDriveItem);
81384
82412
  }
81385
- }
81386
- if (localSampleInfoFileDetections.length > 0) {
81387
- localFileDetections.push(...localSampleInfoFileDetections);
81388
82413
  }
81389
82414
  }
81390
82415
 
81391
- if (localFileDetections.length > 0) {
81392
- 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.`);
81393
- }
82416
+ // Extract problematic resources from tracks
82417
+ const { localFileItems, googleDriveItems } = this.#extractProblematicResources(
82418
+ json.tracks || [],
82419
+ localSampleInfoFiles,
82420
+ googleDriveSampleInfoFiles
82421
+ );
81394
82422
 
81395
- return json
82423
+ // 3. Display consolidated warning if any issues found
82424
+ if (localFileItems.length > 0 || googleDriveItems.length > 0) {
82425
+ 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';
82426
+
82427
+ // Add local file items
82428
+ for (const item of localFileItems) {
82429
+ message += `Local file name: ${item.fileName}\n`;
82430
+ message += `Track name: ${item.trackName}\n\n`;
82431
+ }
82432
+
82433
+ // Add Google Drive items
82434
+ for (const item of googleDriveItems) {
82435
+ message += `Google Drive file name: ${item.fileName}\n`;
82436
+ message += `Track name: ${item.trackName}\n\n`;
82437
+ }
82438
+
82439
+ alert(message);
82440
+ }
81396
82441
  }
81397
82442
 
81398
82443
  compressedSession() {
@@ -81896,6 +82941,281 @@ ${indent}columns: ${matrix.columns}
81896
82941
  }
81897
82942
  }
81898
82943
 
82944
+ /**
82945
+ * Handles incoming messages from the WebSocket connection. Performs requested actions on the IGV browser instance
82946
+ * and returns a response message.
82947
+ *
82948
+ * @param json
82949
+ * @param browser
82950
+ * @returns {Promise<{uniqueID, message: string, status: string}>}
82951
+ */
82952
+
82953
+
82954
+ async function handleMessage(json, browser) {
82955
+
82956
+ const returnMsg = {uniqueID: json.uniqueID, status: 'ok'};
82957
+
82958
+ try {
82959
+ let tracks;
82960
+ const {type, args} = json;
82961
+ switch (type.toLowerCase()) {
82962
+
82963
+ case "goto":
82964
+ case "search":
82965
+ const term = args.locus || args.term;
82966
+ const found = await browser.search(term);
82967
+ if (found) {
82968
+ returnMsg.message = `Locus ${term} found and navigated to successfully`;
82969
+ } else {
82970
+ returnMsg.message = `Locus ${term} not found`;
82971
+ returnMsg.status = 'warning';
82972
+ }
82973
+ break
82974
+
82975
+ case "currentloci":
82976
+ returnMsg.data = browser.currentLoci();
82977
+ returnMsg.message = `Retrieved current loci successfully`;
82978
+ break
82979
+
82980
+ case "visibilityChange":
82981
+ returnMsg.message = await browser.visibilityChange();
82982
+ break
82983
+
82984
+ case "tojson":
82985
+ returnMsg.data = browser.toJSON();
82986
+ returnMsg.message = `Session serialized to JSON successfully`;
82987
+ break
82988
+
82989
+ case "compressedsession":
82990
+ returnMsg.data = browser.compressedSession();
82991
+ returnMsg.message = `Session serialized and compressed successfully`;
82992
+ break
82993
+
82994
+ case "tosvg":
82995
+ returnMsg.data = browser.toSVG();
82996
+ returnMsg.message = `Session exported to SVG successfully`;
82997
+ break
82998
+
82999
+ case "removetrackbyname": {
83000
+ let {trackName} = args;
83001
+ if(trackName) {
83002
+ tracks = browser.findTracks(t => trackName ? t.name === trackName : true);
83003
+ if (tracks) {
83004
+ tracks.forEach(t => browser.removeTrack(t));
83005
+ returnMsg.message = `Removed track(s) ${trackName} for ${tracks.length} track(s)`;
83006
+ } else {
83007
+ returnMsg.message = `No tracks found matching name ${trackName}`;
83008
+ returnMsg.status = 'warning';
83009
+ }
83010
+ } else {
83011
+ returnMsg.message = `No track name provided`;
83012
+ returnMsg.status = 'warning';
83013
+ }
83014
+ break
83015
+ }
83016
+
83017
+ case "loadsampleinfo": {
83018
+ browser.loadSampleInfo(args);
83019
+ returnMsg.message = `Sample info loaded successfully`;
83020
+ break
83021
+ }
83022
+
83023
+ case "discardsampleinfo":
83024
+ browser.discardSampleInfo();
83025
+ returnMsg.message = `Sample info discarded successfully`;
83026
+ break
83027
+
83028
+ case "loadroi":
83029
+ browser.loadROI(args);
83030
+ returnMsg.message = `ROI loaded successfully`;
83031
+ break
83032
+
83033
+ case "clearrois":
83034
+ browser.clearROIs();
83035
+ returnMsg.message = `ROIs cleared successfully`;
83036
+ break
83037
+
83038
+ case "getuserdefinedrois":
83039
+ const rois = await browser.getUserDefinedROIs();
83040
+ returnMsg.data = rois;
83041
+ returnMsg.message = `Retrieved ${rois.length} user-defined ROIs successfully`;
83042
+ break
83043
+
83044
+ case 'loadtrack': {
83045
+ const {url, indexURL} = args;
83046
+ const track = await browser.loadTrack({url, indexURL});
83047
+ returnMsg.message = `Track ${track.name} loaded successfully`;
83048
+ break
83049
+ }
83050
+
83051
+ case "genome":
83052
+ const id = args.id;
83053
+ await browser.loadGenome(id);
83054
+ returnMsg.message = `Genome ${id} loaded successfully`;
83055
+ break
83056
+
83057
+ case "loadsession":
83058
+ const url = args.url;
83059
+ await browser.loadSession({url});
83060
+ returnMsg.message = `Session loaded successfully from ${url}`;
83061
+ break
83062
+
83063
+ case "zoomin":
83064
+ await browser.zoomIn();
83065
+ returnMsg.message = `Zoomed in successfully`;
83066
+ break
83067
+
83068
+ case "zoomout":
83069
+ await browser.zoomOut();
83070
+ returnMsg.message = `Zoomed out successfully`;
83071
+ break
83072
+
83073
+ case "setcolor":
83074
+
83075
+ let {color, trackName} = args;
83076
+
83077
+ if (color.includes(",") && !color.startsWith("rgb(")) {
83078
+ // Convert "R,G,B" to "rgb(R,G,B)"
83079
+ color = `rgb(${color})`;
83080
+ }
83081
+
83082
+ tracks = browser.findTracks(t => trackName ? t.name === trackName : true);
83083
+ if (tracks) {
83084
+ tracks.forEach(t => t.color = color);
83085
+ browser.repaintViews();
83086
+ returnMsg.message = `Set color to ${color} for ${tracks.length} track(s)`;
83087
+ } else {
83088
+ returnMsg.message = `No tracks found matching name ${trackName}`;
83089
+ returnMsg.status = 'warning';
83090
+ }
83091
+ break
83092
+
83093
+ case "renametrack":
83094
+
83095
+ const {currentName, newName} = args;
83096
+
83097
+ tracks = browser.findTracks(t => currentName === t.name);
83098
+ if (tracks && tracks.length > 0) {
83099
+ tracks.forEach(t => {
83100
+ t.name = newName;
83101
+ browser.fireEvent('tracknamechange', [t]);
83102
+ });
83103
+ returnMsg.message = `Renamed ${tracks.length} track(s) from ${currentName} to ${newName}`;
83104
+ } else {
83105
+ returnMsg.message = `No track found with name ${currentName}`;
83106
+ returnMsg.status = 'warning';
83107
+ }
83108
+ break
83109
+
83110
+ default:
83111
+ returnMsg.message = `Unrecognized message type: ${type}`;
83112
+ returnMsg.status = 'error';
83113
+ }
83114
+ } catch (err) {
83115
+ returnMsg.message = err?.message || String(err);
83116
+ returnMsg.status = 'error';
83117
+ }
83118
+
83119
+ return returnMsg
83120
+ }
83121
+
83122
+ /**
83123
+ * Create a WebSocket client that connects to a server and handles messages. The client attempts to connect to a
83124
+ * WebSocketServer upon creation. If the connection is not successful or lost, it will attempt to reconnect with an
83125
+ * exponential backoff strategy. Incoming messages are expected to be JSON formatted and are processed by the
83126
+ * handleMessage function. Messages encompass a subset of the igv.js API
83127
+ *
83128
+ * This client was created to interact with an MCP server, but could be used for other purposes.
83129
+ *
83130
+ * @param host Host for the WebSocket server
83131
+ * @param port Port for the WebSocket server
83132
+ * @param browser The igv.js browser instance
83133
+ */
83134
+
83135
+ function createWebSocketClient(host, port, browser) {
83136
+
83137
+ let socket;
83138
+ let retryInterval = 1000; // Initial retry interval in ms
83139
+ const maxRetryInterval = 10000; // Maximum retry interval in ms
83140
+ let reconnectTimer;
83141
+ let intentionalClose = false; // Flag to prevent reconnection on intentional close
83142
+
83143
+ function connect() {
83144
+
83145
+ const isLocal = host === 'localhost' || host === '127.0.0.1';
83146
+ const protocol = window.location.protocol === 'https:' && !isLocal ? 'wss:' : 'ws:';
83147
+ socket = new WebSocket(`${protocol}//${host}:${port}`);
83148
+
83149
+ // helper to safely send
83150
+ const sendJSON = (obj) => {
83151
+ if (socket.readyState === WebSocket.OPEN) {
83152
+ socket.send(JSON.stringify(obj));
83153
+ }
83154
+ };
83155
+
83156
+ socket.addEventListener('open', function (event) {
83157
+ retryInterval = 1000; // Reset retry interval on successful connection
83158
+ sendJSON({message: 'Hello from browser client'});
83159
+ });
83160
+
83161
+ // Listen for incoming messages
83162
+ socket.addEventListener('message', async function (event) {
83163
+ try {
83164
+ const json = JSON.parse(event.data);
83165
+
83166
+ if("close" === json.type) {
83167
+ intentionalClose = true;
83168
+ clearTimeout(reconnectTimer);
83169
+ if (socket && socket.readyState === WebSocket.OPEN) {
83170
+ socket.close();
83171
+ }
83172
+ return
83173
+ }
83174
+
83175
+ const returnMsg = await handleMessage(json, browser);
83176
+ sendJSON(returnMsg);
83177
+
83178
+ } catch (e) {
83179
+ if (e instanceof SyntaxError) {
83180
+ console.warn('Received non-JSON message from server:', event.data);
83181
+ } else {
83182
+ console.error('Error handling message:', e);
83183
+ sendJSON({
83184
+ status: 'error',
83185
+ message: `Error handling message: ${e.message || e.toString()}`
83186
+ });
83187
+ }
83188
+ }
83189
+ });
83190
+
83191
+ socket.addEventListener('error', function (event) {
83192
+ console.error('WebSocket error:', event);
83193
+ // The 'close' event will fire immediately after 'error', triggering the reconnect logic.
83194
+ });
83195
+
83196
+ socket.addEventListener('close', function (event) {
83197
+ if (intentionalClose) {
83198
+ console.log('WebSocket closed intentionally. Not reconnecting.');
83199
+ return
83200
+ }
83201
+ console.log('Disconnected from server. Retrying in ' + (retryInterval / 1000) + ' seconds.');
83202
+ clearTimeout(reconnectTimer);
83203
+ reconnectTimer = setTimeout(connect, retryInterval);
83204
+ // Increase retry interval for next time, up to a max
83205
+ retryInterval = Math.min(maxRetryInterval, retryInterval * 2);
83206
+ });
83207
+ }
83208
+
83209
+ connect(); // Initial connection attempt
83210
+
83211
+ window.addEventListener('beforeunload', function (event) {
83212
+ clearTimeout(reconnectTimer); // Don't try to reconnect when page is closing
83213
+ if (socket && socket.readyState === WebSocket.OPEN) {
83214
+ socket.close();
83215
+ }
83216
+ });
83217
+ }
83218
+
81899
83219
  let allBrowsers = [];
81900
83220
 
81901
83221
  /**
@@ -81953,8 +83273,13 @@ ${indent}columns: ${matrix.columns}
81953
83273
 
81954
83274
  browser.navbar.navbarDidResize();
81955
83275
 
81956
- return browser
83276
+ if(config.enableWebSocket) {
83277
+ const host = config.webSocketHost || "localhost";
83278
+ const port = config.webSocketPort || 60141;
83279
+ createWebSocketClient(host, port, browser);
83280
+ }
81957
83281
 
83282
+ return browser
81958
83283
  }
81959
83284
 
81960
83285
  function removeBrowser(browser) {
@@ -82147,7 +83472,8 @@ ${indent}columns: ${matrix.columns}
82147
83472
  loadSessionFile: Browser.loadSessionFile,
82148
83473
  loadHub,
82149
83474
  uncompressSession: Browser.uncompressSession,
82150
- createIcon
83475
+ createIcon,
83476
+ createWebSocketClient
82151
83477
  };
82152
83478
 
82153
83479
  return index;