igv 3.6.0 → 3.7.0

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
 
@@ -47441,7 +47489,7 @@
47441
47489
  this.base = base;
47442
47490
  this.strand = strand;
47443
47491
  this.modification = modification;
47444
- this.canonicalBase = this.strand === '+' ? this.base : complementBase(this.base);
47492
+ this.canonicalBase = this.strand === '+' ? this.base : complementBase$1(this.base);
47445
47493
  }
47446
47494
 
47447
47495
  getCanonicalBase() {
@@ -47491,7 +47539,7 @@
47491
47539
  this.modification = modification;
47492
47540
  this.strand = strand;
47493
47541
  this.likelihoods = likelihoods;
47494
- this.canonicalBase = this.strand == '+' ? this.base : complementBase(this.base);
47542
+ this.canonicalBase = this.strand == '+' ? this.base : complementBase$1(this.base);
47495
47543
  this.key = BaseModificationKey.getKey(base, strand, modification);
47496
47544
  }
47497
47545
 
@@ -48924,6 +48972,818 @@
48924
48972
  return len
48925
48973
  }
48926
48974
 
48975
+ // Lazy import to avoid circular dependency
48976
+
48977
+ /**
48978
+ * Search for a feature by name across various data sources
48979
+ * This module is separate to avoid circular dependencies between search.js and hgvs.js
48980
+ */
48981
+
48982
+ const DEFAULT_SEARCH_CONFIG = {
48983
+ timeout: 5000,
48984
+ type: "plain",
48985
+ url: 'https://igv.org/genomes/locus.php?genome=$GENOME$&name=$FEATURE$',
48986
+ coords: 0
48987
+ };
48988
+
48989
+ /**
48990
+ * Search for a feature by name in MANE transcripts, searchable tracks, and web services
48991
+ * @param {Object} browser - The IGV browser instance
48992
+ * @param {string} name - The feature name to search for
48993
+ * @returns {Promise<Object|undefined>} The found feature or undefined
48994
+ */
48995
+ async function searchFeatures(browser, name) {
48996
+
48997
+ const searchConfig = browser.searchConfig || DEFAULT_SEARCH_CONFIG;
48998
+ let feature;
48999
+
49000
+ name = name.toUpperCase();
49001
+
49002
+ // Search MANE transcripts first, if available
49003
+ feature = await browser.genome.getManeTranscript(name);
49004
+ if (feature) {
49005
+ return feature
49006
+ }
49007
+
49008
+ const searchableTracks = browser.tracks.filter(t => t.searchable);
49009
+ for (let track of searchableTracks) {
49010
+ const feature = await track.search(name);
49011
+ if (feature) {
49012
+ return feature
49013
+ }
49014
+ }
49015
+
49016
+ // If still not found try webservice, if enabled
49017
+ if (browser.config && false !== browser.config.search) {
49018
+ try {
49019
+ feature = await searchWebService(browser, name, searchConfig);
49020
+ return feature // Might be undefined
49021
+ } catch (error) {
49022
+ console.log("Search service not available " + error);
49023
+ }
49024
+ }
49025
+
49026
+ }
49027
+
49028
+ /**
49029
+ * Search for a feature using a web service
49030
+ * @param {Object} browser - The IGV browser instance
49031
+ * @param {string} locus - The locus to search for
49032
+ * @param {Object} searchConfig - Search configuration
49033
+ * @returns {Promise<Object|undefined>} The search result
49034
+ */
49035
+ async function searchWebService(browser, locus, searchConfig) {
49036
+
49037
+ let path = searchConfig.url.replace("$FEATURE$", locus.toUpperCase());
49038
+ if (path.indexOf("$GENOME$") > -1) {
49039
+ path = path.replace("$GENOME$", (browser.genome.id ? browser.genome.id : "hg19"));
49040
+ }
49041
+ const options = searchConfig.timeout ? {timeout: searchConfig.timeout} : undefined;
49042
+ const result = await igvxhr.loadString(path, options);
49043
+
49044
+ return await processSearchResult(browser, result, searchConfig)
49045
+ }
49046
+
49047
+ /**
49048
+ * Process search results from web service
49049
+ * @param {Object} browser - The IGV browser instance
49050
+ * @param {string} result - The raw result from the web service
49051
+ * @param {Object} searchConfig - Search configuration
49052
+ * @returns {Promise<Object|undefined>} The processed search result
49053
+ */
49054
+ async function processSearchResult(browser, result, searchConfig) {
49055
+
49056
+ let results;
49057
+
49058
+ if ('plain' === searchConfig.type) {
49059
+ results = await parseSearchResults(browser, result);
49060
+ } else {
49061
+ results = JSON.parse(result);
49062
+ }
49063
+
49064
+ if (searchConfig.resultsField) {
49065
+ results = results[searchConfig.resultsField];
49066
+ }
49067
+
49068
+ if (!results || 0 === results.length) {
49069
+ return undefined
49070
+
49071
+ } else {
49072
+
49073
+ const chromosomeField = searchConfig.chromosomeField || "chromosome";
49074
+ const startField = searchConfig.startField || "start";
49075
+ const endField = searchConfig.endField || "end";
49076
+ const coords = searchConfig.coords || 1;
49077
+
49078
+
49079
+ let result;
49080
+ if (Array.isArray(results)) {
49081
+ // Ignoring all but first result for now
49082
+ // TODO -- present all and let user select if results.length > 1
49083
+ result = results[0];
49084
+ } else {
49085
+ // When processing search results from Ensembl REST API
49086
+ // Example: https://rest.ensembl.org/lookup/symbol/macaca_fascicularis/BRCA2?content-type=application/json
49087
+ result = results;
49088
+ }
49089
+
49090
+ if (!(result.hasOwnProperty(chromosomeField) && (result.hasOwnProperty(startField)))) {
49091
+ console.error("Search service results must include chromosome and start fields: " + result);
49092
+ }
49093
+
49094
+ const chr = result[chromosomeField];
49095
+ let start = result[startField] - coords;
49096
+ let end = result[endField];
49097
+ if (undefined === end) {
49098
+ end = start + 1;
49099
+ }
49100
+
49101
+ const locusObject = {chr, start, end};
49102
+
49103
+ // Some GTEX hacks
49104
+ if (searchConfig.geneField && searchConfig.snpField) {
49105
+ const name = result[searchConfig.geneField] || result[searchConfig.snpField]; // Should never have both
49106
+ if (name) locusObject.name = name.toUpperCase();
49107
+ }
49108
+
49109
+ return locusObject
49110
+ }
49111
+ }
49112
+
49113
+ /**
49114
+ * Parse the igv line-oriented (non json) search results.
49115
+ * NOTE: currently, and probably permanently, this will always be a single line
49116
+ * Example
49117
+ * EGFR chr7:55,086,724-55,275,031 refseq
49118
+ *
49119
+ * @param {Object} browser - The IGV browser instance
49120
+ * @param {string} data - The raw search result data
49121
+ * @returns {Array} Array of parsed search results
49122
+ */
49123
+ async function parseSearchResults(browser, data) {
49124
+
49125
+ const results = [];
49126
+ const lines = splitLines$3(data);
49127
+
49128
+ for (let line of lines) {
49129
+
49130
+ const tokens = line.split("\t");
49131
+
49132
+ if (tokens.length >= 3) {
49133
+ const locusTokens = tokens[1].split(":");
49134
+ const rangeTokens = locusTokens[1].split("-");
49135
+ results.push({
49136
+ chromosome: browser.genome.getChromosomeName(locusTokens[0].trim()),
49137
+ start: parseInt(rangeTokens[0].replace(/,/g, '')),
49138
+ end: parseInt(rangeTokens[1].replace(/,/g, '')),
49139
+ name: tokens[0].toUpperCase()
49140
+ });
49141
+ }
49142
+ }
49143
+
49144
+ return results
49145
+
49146
+ }
49147
+
49148
+ const log = console;
49149
+
49150
+ function isValidHGVS(notation) {
49151
+ if (!notation) return false
49152
+ // We only need to validate that we can parse the notation in the search method.
49153
+ // Check for basic structure: <accession>:g.<position> or <accession>:c.<position> or <accession>:p.<position>
49154
+ // We don't validate the variant details since we only need the position for searching.
49155
+
49156
+ // Genomic: g.\d+ (with optional range and anything after)
49157
+ const genomic = "g\\.\\d+.*";
49158
+ // Coding: c. followed by optional -, *, then digits, with optional intronic offset and anything after
49159
+ const coding = "c\\.[-*]?\\d+.*";
49160
+ // Non-coding: n. followed by optional leading '-' then digits, anything after
49161
+ const nonCoding = "n\\.-?\\d+.*";
49162
+ // Protein: p. followed by optional AA letters, digits, with optional range and anything after
49163
+ const protein = "p\\.[A-Za-z*]*\\d+.*";
49164
+ // Optional gene symbol in parentheses immediately after accession
49165
+ const accessionWithOptionalGene = "^[A-Za-z0-9_.]+(?:\\([^)]+\\))?";
49166
+
49167
+ const pattern = new RegExp(accessionWithOptionalGene + ":(?:" + genomic + "|" + coding + "|" + nonCoding + "|" + protein + ")$");
49168
+ return pattern.test(notation)
49169
+ }
49170
+
49171
+ /**
49172
+ * Searches for the given HGVS notation in the provided genome.
49173
+ * Returns a SearchResult with the corresponding chromosome and position if found,
49174
+ * otherwise returns null.
49175
+ */
49176
+ async function search$1(hgvs, browser) {
49177
+
49178
+ if (!isValidHGVS(hgvs)) {
49179
+ return null
49180
+ }
49181
+
49182
+ const genome = browser.genome;
49183
+
49184
+ // Determine type and extract accession and position
49185
+ const idxG = hgvs.indexOf(":g.");
49186
+ const idxC = hgvs.indexOf(":c.");
49187
+ const idxP = hgvs.indexOf(":p.");
49188
+ const idxN = hgvs.indexOf(":n.");
49189
+ let type;
49190
+ let idx;
49191
+ if (idxG >= 0) {
49192
+ type = "g";
49193
+ idx = idxG;
49194
+ } else if (idxC >= 0) {
49195
+ type = "c";
49196
+ idx = idxC;
49197
+ } else if (idxN >= 0) {
49198
+ type = "n";
49199
+ idx = idxN;
49200
+ } else if (idxP >= 0) {
49201
+ type = "p";
49202
+ idx = idxP;
49203
+ } else {
49204
+ return null
49205
+ }
49206
+ let accession = hgvs.substring(0, idx);
49207
+ // Strip optional trailing gene symbol in parentheses, e.g., "NM_000302.3(PLOD1)" -> "NM_000302.3"
49208
+ if (accession.endsWith(")")) {
49209
+ const openIdx = accession.lastIndexOf('(');
49210
+ if (openIdx > 0) {
49211
+ accession = accession.substring(0, openIdx);
49212
+ }
49213
+ }
49214
+ const positionPart = hgvs.substring(idx + 3); // skip ':g.' or ':c.' or ':p.'
49215
+
49216
+ if (type === "g") {
49217
+ if (!positionPart) return null
49218
+ // Match genomic positions including:
49219
+ // - Simple position: 123
49220
+ // - Range: 123_456
49221
+ // - Uncertain positions: 123_? or ?_456 or (123_456)
49222
+ // Extract just the numeric positions, ignoring variant notation after
49223
+ const match = positionPart.match(/^\(?(\d+)(?:_(\d+|\?))?/);
49224
+ if (!match) return null
49225
+ const start = parseInt(match[1], 10);
49226
+ const endGroup = match[2];
49227
+ // If end is '?' or undefined, use start as end
49228
+ const end = (endGroup && endGroup !== '?') ? parseInt(endGroup, 10) : start;
49229
+ const aliasRecord = await genome.getAliasRecord(accession);
49230
+ const chr = aliasRecord ? aliasRecord.chr : accession;
49231
+ return {chr, start: start - 1, end: end}
49232
+
49233
+ } else if (type === "p") {
49234
+
49235
+ // Protein notation not supported for search currently. The code below is ported from Java and kept for
49236
+ // future reference.
49237
+ return null
49238
+
49239
+ // // Protein position mapping: map codon(s) to genomic span.
49240
+ // const transcript = await getTranscript(browser, accession)
49241
+ // if (!transcript) return null
49242
+ //
49243
+ // const proteinPart = positionPart
49244
+ // const pm = proteinPart.match(/^[A-Za-z*]{0,3}(\d+)(?:_[A-Za-z*]{0,3}(\d+))?/)
49245
+ // if (!pm) return null
49246
+ // let p1 = parseInt(pm[1], 10)
49247
+ // const p2Str = pm[2]
49248
+ // let p2 = p1
49249
+ // if (p2Str) {
49250
+ // p2 = parseInt(p2Str, 10)
49251
+ // }
49252
+ //
49253
+ // const codon1 = transcript.getCodon(genome, transcript.chr, p1)
49254
+ // if (!codon1 || !codon1.isGenomePositionsSet()) return null
49255
+ // let start1 = Math.min(...codon1.getGenomePositions())
49256
+ // let end1 = Math.max(...codon1.getGenomePositions())
49257
+ //
49258
+ // let regionStart = start1
49259
+ // let regionEnd = end1
49260
+ // if (p2 !== p1) {
49261
+ // const codon2 = transcript.getCodon(genome, transcript.chr, p2)
49262
+ // if (!codon2 || !codon2.isGenomePositionsSet()) return null
49263
+ // let start2 = Math.min(...codon2.getGenomePositions())
49264
+ // let end2 = Math.max(...codon2.getGenomePositions())
49265
+ // regionStart = Math.min(start1, start2)
49266
+ // regionEnd = Math.max(end1, end2)
49267
+ // }
49268
+ // const halfOpenEnd = regionEnd + 1
49269
+ // return {chr: transcript.chr, start: regionStart, end: halfOpenEnd}
49270
+
49271
+ } else if (type === "n") {
49272
+
49273
+ // Non-coding transcript mapping: n.123 or n.-123 maps relative to transcript start
49274
+ const transcript = await getTranscript(browser, accession);
49275
+ if (!transcript) return null
49276
+
49277
+ // Parse signed position with optional range and intronic offset (e.g., n.123, n.123_456, n.-7080_-1781, n.123+5)
49278
+ const matcher = positionPart.match(/^(-?\d+)(?:_(-?\d+))?([+-]\d+)?/);
49279
+ if (!matcher) return null
49280
+
49281
+ const t1 = parseInt(matcher[1], 10);
49282
+ const t2Str = matcher[2];
49283
+ const t2 = t2Str != null ? parseInt(t2Str, 10) : t1;
49284
+
49285
+ // Map both transcript positions to genomic
49286
+ let g1 = transcriptPositionToGenomicPosition(transcript, t1);
49287
+ let g2 = transcriptPositionToGenomicPosition(transcript, t2);
49288
+ if (g1 <= 0 || g2 <= 0) return null
49289
+
49290
+ // Apply intronic offset (if any) to BOTH endpoints, strand-aware
49291
+ const offsetStr = matcher[3];
49292
+ if (offsetStr) {
49293
+ let offset = parseInt(offsetStr, 10);
49294
+ if (transcript.strand === '-') offset = -offset;
49295
+ g1 += offset;
49296
+ g2 += offset;
49297
+ }
49298
+
49299
+ // Normalize to genomic span regardless of strand
49300
+ const regionStart = Math.min(g1, g2);
49301
+ const regionEndInclusive = Math.max(g1, g2);
49302
+ const halfOpenEnd = regionEndInclusive + 1;
49303
+ return {chr: transcript.chr, start: regionStart, end: halfOpenEnd}
49304
+
49305
+ } else { // "c"
49306
+
49307
+ const transcript = await getTranscript(browser, accession);
49308
+ if (transcript) {
49309
+ // UTR 5' c.-N with optional range and intronic offset (e.g., c.-211_-215 or c.-211-1058C>G)
49310
+ const utr5Matcher = positionPart.match(/^-(\d+)(?:_-(\d+))?([+-]\d+)?/);
49311
+ if (utr5Matcher) {
49312
+ const n1 = parseInt(utr5Matcher[1], 10);
49313
+ const n2Str = utr5Matcher[2];
49314
+ const n2 = n2Str != null ? parseInt(n2Str, 10) : null;
49315
+ const firstCodingGenomic = codingToGenomePosition(transcript, 1);
49316
+ if (firstCodingGenomic > 0) {
49317
+ let g1 = transcript.strand === '+' ? (firstCodingGenomic - n1) : (firstCodingGenomic + n1);
49318
+ let g2 = g1;
49319
+ if (n2 != null) {
49320
+ g2 = transcript.strand === '+' ? (firstCodingGenomic - n2) : (firstCodingGenomic + n2);
49321
+ }
49322
+ // Apply intronic offset (single value) to both ends if present
49323
+ const offsetStr = utr5Matcher[3];
49324
+ if (offsetStr) {
49325
+ let offset = parseInt(offsetStr, 10);
49326
+ if (transcript.strand === '-') offset = -offset;
49327
+ g1 += offset;
49328
+ g2 += offset;
49329
+ }
49330
+ const start = Math.min(g1, g2);
49331
+ const endInclusive = Math.max(g1, g2);
49332
+ const endExclusive = endInclusive + 1;
49333
+ return {resultType: "LOCUS", chr: transcript.chr, start, end: endExclusive}
49334
+ }
49335
+ return null
49336
+ }
49337
+
49338
+ // UTR 3' c.*N with optional range and intronic offset (e.g., c.*526_*529delATCA or c.*123+45)
49339
+ const utr3Matcher = positionPart.match(/^\*(\d+)(?:_\*(\d+))?([+-]\d+)?/);
49340
+ if (utr3Matcher) {
49341
+ const n1 = parseInt(utr3Matcher[1], 10);
49342
+ const n2Str = utr3Matcher[2];
49343
+ const n2 = n2Str != null ? parseInt(n2Str, 10) : null;
49344
+ let codingLen = 0;
49345
+ if (transcript.exons) {
49346
+ for (const exon of transcript.exons) {
49347
+ codingLen += getCodingLength(exon);
49348
+ }
49349
+ }
49350
+ if (codingLen > 0) {
49351
+ const lastCodingGenomic = codingToGenomePosition(transcript, codingLen);
49352
+ if (lastCodingGenomic > 0) {
49353
+ let g1 = transcript.strand === '+' ? (lastCodingGenomic + n1) : (lastCodingGenomic - n1);
49354
+ let g2 = g1;
49355
+ if (n2 != null) {
49356
+ g2 = transcript.strand === '+' ? (lastCodingGenomic + n2) : (lastCodingGenomic - n2);
49357
+ }
49358
+ // Apply intronic offset (single value) to both ends if present
49359
+ const offsetStr = utr3Matcher[3];
49360
+ if (offsetStr) {
49361
+ let offset = parseInt(offsetStr, 10);
49362
+ if (transcript.strand === '-') offset = -offset;
49363
+ g1 += offset;
49364
+ g2 += offset;
49365
+ }
49366
+ const start = Math.min(g1, g2);
49367
+ const endInclusive = Math.max(g1, g2);
49368
+ const endExclusive = endInclusive + 1;
49369
+ return {resultType: "LOCUS", chr: transcript.chr, start, end: endExclusive}
49370
+ }
49371
+ }
49372
+ return null
49373
+ }
49374
+
49375
+ // CDS position with optional range
49376
+ // First parse endpoints c.X(_Y)? ignoring intronic offsets
49377
+ const cpos = positionPart.match(/^(\d+)(?:_(\d+))?/);
49378
+ if (!cpos) return null
49379
+ const c1 = parseInt(cpos[1], 10);
49380
+ const c2Str = cpos[2];
49381
+ const c2 = c2Str != null ? parseInt(c2Str, 10) : c1;
49382
+
49383
+ // Map both coding positions to genomic
49384
+ let g1 = codingToGenomePosition(transcript, c1);
49385
+ let g2 = codingToGenomePosition(transcript, c2);
49386
+ if (g1 <= 0 || g2 <= 0) return null
49387
+
49388
+ // Now parse optional intronic offsets for each endpoint separately
49389
+ // Patterns like: 123+5 or 123-2 at the beginning, optionally followed by _ and second with offset
49390
+ const offs = positionPart.match(/^(\d+)([+-]\d+)?(?:_(\d+)([+-]\d+)?)?/);
49391
+ if (offs) {
49392
+ const off1Str = offs[2];
49393
+ const off2Str = offs[4];
49394
+ if (off1Str) {
49395
+ let off1 = parseInt(off1Str, 10);
49396
+ if (transcript.strand === '-') off1 = -off1;
49397
+ g1 += off1;
49398
+ }
49399
+ if (off2Str) {
49400
+ let off2 = parseInt(off2Str, 10);
49401
+ if (transcript.strand === '-') off2 = -off2;
49402
+ g2 += off2;
49403
+ }
49404
+ }
49405
+
49406
+ // If there is no explicit second coding position, ensure single-site locus
49407
+ if (c2Str == null) {
49408
+ g2 = g1;
49409
+ }
49410
+
49411
+ const start = Math.min(g1, g2);
49412
+ const endInclusive = Math.max(g1, g2);
49413
+ const endExclusive = endInclusive + 1;
49414
+ return {chr: transcript.chr, start, end: endExclusive}
49415
+ }
49416
+ return null
49417
+ }
49418
+
49419
+ }
49420
+
49421
+ async function getTranscript(browser, accession) {
49422
+ return searchFeatures(browser, accession)
49423
+ }
49424
+
49425
+ /**
49426
+ * Convert a transcript position (1-based, from transcription start) to genomic position
49427
+ * for non-coding transcripts. Walks through exons to find the genomic coordinate.
49428
+ */
49429
+ function transcriptPositionToGenomicPosition(transcript, transcriptPos) {
49430
+ // Handle positions upstream of transcript start (negative n. values)
49431
+ if (transcriptPos <= 0) {
49432
+ const d = Math.abs(transcriptPos);
49433
+ return transcript.strand === '+' ? (transcript.getStart() - d) : (transcript.getEnd() + d)
49434
+ }
49435
+
49436
+ const exons = transcript.exons;
49437
+ if (!exons || exons.length === 0) {
49438
+ // No exons, treat as simple feature
49439
+ if (transcript.strand === '+') {
49440
+ return transcript.getStart() + transcriptPos - 1
49441
+ } else {
49442
+ return transcript.getEnd() - transcriptPos + 1
49443
+ }
49444
+ }
49445
+
49446
+ const positive = transcript.strand === '+';
49447
+ let accumulatedLength = 0;
49448
+
49449
+ // Sort exons appropriately based on strand
49450
+ const sortedExons = exons.slice();
49451
+ if (!positive) {
49452
+ sortedExons.sort((e1, e2) => e2.getStart() - e1.getStart());
49453
+ } else {
49454
+ sortedExons.sort((e1, e2) => e1.getStart() - e2.getStart());
49455
+ }
49456
+
49457
+ for (const exon of sortedExons) {
49458
+ const exonLength = exon.getEnd() - exon.getStart();
49459
+ if (accumulatedLength + exonLength >= transcriptPos) {
49460
+ // Position is in this exon
49461
+ const offsetInExon = transcriptPos - accumulatedLength - 1;
49462
+ if (positive) {
49463
+ return exon.getStart() + offsetInExon
49464
+ } else {
49465
+ return exon.getEnd() - offsetInExon - 1
49466
+ }
49467
+ }
49468
+ accumulatedLength += exonLength;
49469
+ }
49470
+
49471
+ // Position beyond transcript end
49472
+ return -1
49473
+ }
49474
+
49475
+ /**
49476
+ * Translate a 1-based coding position to a 0-based genomic position. Supports HGVS parsing
49477
+ *
49478
+ * @param codingPosition 1-based coding position
49479
+ * @return 0-based genomic position, or -1 if not found.
49480
+ */
49481
+ function codingToGenomePosition(feature, codingPosition) {
49482
+ if (codingPosition <= 0) {
49483
+ return -1
49484
+ }
49485
+ const cdna = codingPosition - 1; // Convert to 0-based
49486
+
49487
+ const exons = feature.exons;
49488
+ if (!exons) {
49489
+ return -1
49490
+ }
49491
+
49492
+ const strand = feature.strand;
49493
+ // if (strand === 'NONE') {
49494
+ // throw new Error("Cannot translate from coding position on an unstranded feature.")
49495
+ // }
49496
+ const positive = strand === '+';
49497
+
49498
+ let codingLength = 0;
49499
+ for (let i = 0; i < exons.length; i++) {
49500
+ const exon = positive ? exons[i] : exons[exons.length - 1 - i];
49501
+ const exonCodingLength = getCodingLength(exon);
49502
+ if (codingLength + exonCodingLength > cdna) {
49503
+ const cdnaOffset = cdna - codingLength;
49504
+ if (positive) {
49505
+ return getCodingStart(exon) + cdnaOffset
49506
+ } else {
49507
+ return getCodingEnd(exon) - 1 - cdnaOffset
49508
+ }
49509
+ }
49510
+ codingLength += exonCodingLength;
49511
+ }
49512
+
49513
+ return -1
49514
+ }
49515
+
49516
+ /**
49517
+ * Returns genomic HGVS notation: <RefSeqAccession>:g.<position>
49518
+ * Example: NC_000001.11:g.1234567
49519
+ */
49520
+ async function getHGVSPosition(genome, chr, position) {
49521
+ try {
49522
+ const aliasRecord = await genome.getAliasRecord(chr);
49523
+ let accession = null;
49524
+
49525
+ if (aliasRecord) {
49526
+ for (const alias of Object.values(aliasRecord)) {
49527
+ if (alias.startsWith("NC_") || alias.startsWith("NT_") || alias.startsWith("NW_") ||
49528
+ alias.startsWith("NG_") || alias.startsWith("NM_") || alias.startsWith("NR_") ||
49529
+ alias.startsWith("NP_")) {
49530
+ accession = alias;
49531
+ break
49532
+ }
49533
+ }
49534
+ }
49535
+
49536
+ if (!accession) {
49537
+ accession = chr;
49538
+ }
49539
+
49540
+ return `${accession}:g.${position}`
49541
+ } catch (e) {
49542
+ log.error("Error getting HGVS position", e);
49543
+ return null
49544
+ }
49545
+ }
49546
+
49547
+ /**
49548
+ * Returns HGVS annotation for the position, for ref and alt bases. If a MANE transcript is available that is
49549
+ * used with coding notation (c.), otherwise genome position is used with genomic notation (g.).
49550
+ * Example: NM_000302.3:c.1234A>G or NM_000302.3:c.123+5T>C (intronic) or NC_000001.11:g.1234567G>A
49551
+ *
49552
+ * @param genome The genome
49553
+ * @param chr The chromosome name
49554
+ * @param position The genomic position (0-based)
49555
+ * @param reference The reference base (single-character string)
49556
+ * @param alternate The alternate base (single-character string)
49557
+ * @return {Promise<string|null>} HGVS notation string, or null if error
49558
+ */
49559
+ async function createHGVSAnnotation(genome, chr, position, reference, alternate) {
49560
+
49561
+ try {
49562
+ const transcript = await genome.getManeTranscriptAt(chr, position);
49563
+
49564
+ if (transcript && transcript.exons) {
49565
+
49566
+ // Ensure bases are uppercase
49567
+ reference = reference.toUpperCase();
49568
+ alternate = alternate.toUpperCase();
49569
+
49570
+ if (transcript.strand === '-') {
49571
+ reference = complementBase(reference);
49572
+ alternate = complementBase(alternate);
49573
+ }
49574
+
49575
+
49576
+ let positionString = "";
49577
+
49578
+ let transcriptName = transcript.name;
49579
+ for (const key of Object.keys(transcript)) {
49580
+ const value = transcript[key];
49581
+ if (typeof value === 'string' && (value.startsWith("NM_") || value.startsWith("NR_"))) {
49582
+ transcriptName = value;
49583
+ break
49584
+ }
49585
+ }
49586
+
49587
+ if (transcriptName) {
49588
+ // Check if position is within an exon (coding or non-coding)
49589
+ let positionIsInExon = false;
49590
+ for (const exon of transcript.exons) {
49591
+ if (position >= exon.start && position < exon.end) {
49592
+ positionIsInExon = true;
49593
+ break
49594
+ }
49595
+ }
49596
+
49597
+ const positive = transcript.strand === '+';
49598
+
49599
+ if (positionIsInExon) {
49600
+ // Try to convert to coding position
49601
+ const codingPosition = genomeToCodingPosition(position, positive, transcript.exons);
49602
+
49603
+ if (codingPosition >= 0) {
49604
+ // Position is in a coding region, return c. notation (1-based)
49605
+ positionString = `${transcriptName}:c.${codingPosition + 1}`;
49606
+ } else {
49607
+ // Position is in an exon but not coding - check if in UTR
49608
+ const firstCodingPos = codingToGenomePosition(transcript, 1);
49609
+ if (firstCodingPos > 0) {
49610
+ // Calculate total coding length
49611
+ let codingLen = 0;
49612
+ for (const exon of transcript.exons) {
49613
+ codingLen += getCodingLength(exon);
49614
+ }
49615
+ const lastCodingPos = codingToGenomePosition(transcript, codingLen);
49616
+
49617
+ // Check if in 5' UTR
49618
+ if ((positive && position < firstCodingPos) || (!positive && position > firstCodingPos)) {
49619
+ const distance = Math.abs(position - firstCodingPos);
49620
+ positionString = `${transcriptName}:c.-${distance}`;
49621
+ }
49622
+ // Check if in 3' UTR
49623
+ else if ((positive && position >= lastCodingPos) || (!positive && position <= lastCodingPos)) {
49624
+ const distance = Math.abs(position - lastCodingPos) + 1;
49625
+ positionString = `${transcriptName}:c.*${distance}`;
49626
+ }
49627
+ }
49628
+ }
49629
+ } else {
49630
+ // Position is intronic - find nearest exon boundary
49631
+ // For HGVS, we reference the last coding base in the nearest exon
49632
+ let nearestExonEdge = -1;
49633
+ let nearestCodingPos = -1;
49634
+ let minDistance = Number.MAX_SAFE_INTEGER;
49635
+
49636
+ for (const exon of transcript.exons) {
49637
+ if (getCodingLength(exon) === 0) continue // Skip non-coding exons
49638
+
49639
+ // Check distance to the last coding base at the start side of the exon
49640
+ // exon.start is 0-based inclusive
49641
+ const distToStart = Math.abs(position - exon.start);
49642
+ if (distToStart > 0 && distToStart < minDistance) {
49643
+ minDistance = distToStart;
49644
+ nearestExonEdge = exon.start;
49645
+ // Get coding position of first base in this exon
49646
+ nearestCodingPos = genomeToCodingPosition(getCodingStart(exon), positive, transcript.exons);
49647
+ }
49648
+
49649
+ // Check distance to the last coding base at the end side of the exon
49650
+ // exon.end is 0-based exclusive, so last base is at end-1
49651
+ const distToEnd = Math.abs(position - (exon.end - 1));
49652
+ if (distToEnd > 0 && distToEnd < minDistance) {
49653
+ minDistance = distToEnd;
49654
+ nearestExonEdge = exon.end - 1;
49655
+ // Get coding position of last base in this exon
49656
+ nearestCodingPos = genomeToCodingPosition(getCodingEnd(exon) - 1, positive, transcript.exons);
49657
+ }
49658
+ }
49659
+
49660
+ if (nearestCodingPos >= 0) {
49661
+ // Calculate offset: positive = downstream of exon, negative = upstream of exon
49662
+ let offset = position - nearestExonEdge;
49663
+ // For positive strand: + means to the right, - means to the left
49664
+ // For negative strand: + means to the left (genomically), - means to the right
49665
+ // But in HGVS, the sign is relative to transcript direction, so we need to flip for negative strand
49666
+ if (!positive) {
49667
+ offset = -offset;
49668
+ }
49669
+ const sign = offset >= 0 ? "+" : "";
49670
+ positionString = `${transcriptName}:c.${nearestCodingPos + 1}${sign}${offset}`;
49671
+ }
49672
+ }
49673
+ }
49674
+
49675
+ return positionString + reference + ">" + alternate
49676
+ }
49677
+
49678
+ // Fallback to genomic notation
49679
+ const aliasRecord = await genome.getAliasRecord(chr);
49680
+ let accession = chr;
49681
+
49682
+ if (aliasRecord) {
49683
+ for (const alias of Object.values(aliasRecord)) {
49684
+ if (alias.startsWith("NC_") || alias.startsWith("NT_") || alias.startsWith("NW_") ||
49685
+ alias.startsWith("NG_") || alias.startsWith("NM_") || alias.startsWith("NR_") ||
49686
+ alias.startsWith("NP_")) {
49687
+ accession = alias;
49688
+ break
49689
+ }
49690
+ }
49691
+ }
49692
+
49693
+ // HGVS genomic coordinate is 1-based; position parameter is 0-based
49694
+ return `${accession}:g.${position + 1}${reference}>${alternate}`
49695
+ } catch (e) {
49696
+ log.error("Error creating HGVS annotation", e);
49697
+ return null
49698
+ }
49699
+ }
49700
+
49701
+ // Helper function to complement a base (string)
49702
+ function complementBase(base) {
49703
+ const complementMap = { 'A': 'T', 'T': 'A', 'C': 'G', 'G': 'C' };
49704
+ return complementMap[base] || base
49705
+ }
49706
+
49707
+ function genomeToCodingPosition(genomePosition, positive, exons) {
49708
+
49709
+ if (exons) {
49710
+
49711
+ /*
49712
+ We loop over all exons, either from the beginning or the end.
49713
+ Increment position only on coding regions.
49714
+ */
49715
+
49716
+ let codingOffset = 0;
49717
+
49718
+ for (let exnum = 0; exnum < exons.length; exnum++) {
49719
+
49720
+ const exon = positive ? exons[exnum] : exons[exons.length - 1 - exnum];
49721
+
49722
+ if (exon.start <= genomePosition && exon.end > genomePosition) {
49723
+ const delta = positive
49724
+ ? genomePosition - getCodingStart(exon)
49725
+ : getCodingEnd(exon) - genomePosition - 1;
49726
+ return codingOffset + delta
49727
+ }
49728
+
49729
+ codingOffset += getCodingLength(exon);
49730
+ }
49731
+ }
49732
+ return -1
49733
+ }
49734
+
49735
+
49736
+
49737
+ const HGVS = {
49738
+ isValidHGVS,
49739
+ search: search$1,
49740
+ getHGVSPosition,
49741
+ createHGVSAnnotation
49742
+ };
49743
+
49744
+ /**
49745
+ * ClinVar utilities for searching and retrieving ClinVar variation information
49746
+ */
49747
+
49748
+ /**
49749
+ * Get the ClinVar URL for the given HGVS notation
49750
+ * @param {string} hgvsNotation - The HGVS notation string to search for
49751
+ * @return {Promise<string|null>} The ClinVar variation URL, or null if not found or error occurs
49752
+ */
49753
+ async function getClinVarURL(hgvsNotation) {
49754
+ try {
49755
+ const encodedHgvs = encodeURIComponent(hgvsNotation);
49756
+ const esearchUrl = `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?` +
49757
+ `db=clinvar&term=${encodedHgvs}&retmode=json`;
49758
+
49759
+ const response = await fetch(esearchUrl);
49760
+
49761
+ if (!response.ok) {
49762
+ console.error(`HTTP error! status: ${response.status}`);
49763
+ return null
49764
+ }
49765
+
49766
+ // Parse JSON response to get the first ClinVar accession
49767
+ const json = await response.json();
49768
+ const esearchResult = json.esearchresult;
49769
+
49770
+ if (esearchResult.count > 0) {
49771
+ const uid = esearchResult.idlist[0];
49772
+ return `https://www.ncbi.nlm.nih.gov/clinvar/variation/${uid}/`
49773
+ } else {
49774
+ return null
49775
+ }
49776
+
49777
+ } catch (e) {
49778
+ console.error("Error fetching ClinVar URL", e);
49779
+ return null
49780
+ }
49781
+ }
49782
+
49783
+ const ClinVar = {
49784
+ getClinVarURL
49785
+ };
49786
+
48927
49787
  const READ_PAIRED_FLAG = 0x1;
48928
49788
  const PROPER_PAIR_FLAG = 0x2;
48929
49789
  const READ_UNMAPPED_FLAG = 0x4;
@@ -49058,13 +49918,22 @@
49058
49918
  return (genomicLocation >= s && genomicLocation <= (s + l))
49059
49919
  }
49060
49920
 
49061
- popupData(genomicLocation, hiddenTags, showTags) {
49921
+ /**
49922
+ * Return data to show in the popup. Elements are either strings (for raw HTML) or
49923
+ * objects with name, value, borderTop properties.
49924
+ *
49925
+ * @param genomicLocation - 0-based genomic location
49926
+ * @param hiddenTags - Set of bam tags to hide
49927
+ * @param showTags - Set of bam tags to show (overrides hide/show rules)
49928
+ * @returns {*[]}
49929
+ */
49930
+ async popupData(genomicLocation, hiddenTags, showTags, refBase, genome) {
49062
49931
 
49063
49932
  // if the user clicks on a base next to an insertion, show just the
49064
49933
  // inserted bases in a popup (like in desktop IGV).
49065
49934
  const nameValues = [];
49066
49935
 
49067
- // Consert genomic location to int
49936
+ // Convert genomic location to int
49068
49937
  genomicLocation = Math.floor(genomicLocation);
49069
49938
 
49070
49939
  if (this.insertions) {
@@ -49083,6 +49952,26 @@
49083
49952
 
49084
49953
  nameValues.push({name: 'Read Name', value: this.readName});
49085
49954
 
49955
+
49956
+ // HGVS annotations for variants, and ClinVar links if available
49957
+ const readBase = this.readBaseAt(genomicLocation);
49958
+ if (refBase) {
49959
+ if (readBase && readBase !== refBase && readBase !== '*') {
49960
+ const hgvsNotation = await HGVS.createHGVSAnnotation(genome, this.chr, genomicLocation, refBase, readBase);
49961
+ if (hgvsNotation) {
49962
+ const clinVarURL = await ClinVar.getClinVarURL(hgvsNotation);
49963
+ if (clinVarURL) {
49964
+ nameValues.push({
49965
+ name: 'ClinVar',
49966
+ value: `<a href='${clinVarURL}' target='_blank'>${hgvsNotation}</a>`
49967
+ });
49968
+ } else {
49969
+ nameValues.push({name: 'HGVS', value: hgvsNotation});
49970
+ }
49971
+ }
49972
+ }
49973
+ }
49974
+
49086
49975
  // Sample
49087
49976
  // Read group
49088
49977
  nameValues.push('<hr/>');
@@ -49158,7 +50047,7 @@
49158
50047
 
49159
50048
  nameValues.push('<hr/>');
49160
50049
  nameValues.push({name: 'Genomic Location: ', value: numberFormatter$1(1 + genomicLocation)});
49161
- nameValues.push({name: 'Read Base:', value: this.readBaseAt(genomicLocation)});
50050
+ nameValues.push({name: 'Read Base:', value: readBase});
49162
50051
  nameValues.push({name: 'Base Quality:', value: this.readBaseQualityAt(genomicLocation)});
49163
50052
 
49164
50053
  const bmSets = this.getBaseModificationSets();
@@ -51363,8 +52252,8 @@
51363
52252
  if (alignmentContainer.hasAlignments) {
51364
52253
  const sequence = await genome.getSequence(chr, alignmentContainer.start, alignmentContainer.end);
51365
52254
  if (sequence) {
51366
- alignmentContainer.coverageMap.refSeq = sequence; // TODO -- fix this
51367
- alignmentContainer.sequence = sequence; // TODO -- fix this
52255
+ alignmentContainer.coverageMap.refSeq = sequence;
52256
+ alignmentContainer.sequence = sequence;
51368
52257
  return alignmentContainer
51369
52258
  } else {
51370
52259
  console.error("No sequence for: " + chr + ":" + alignmentContainer.start + "-" + alignmentContainer.end);
@@ -53685,7 +54574,7 @@
53685
54574
  highlightColor: undefined,
53686
54575
  minTLEN: undefined,
53687
54576
  maxTLEN: undefined,
53688
- tagColorPallete: "Set1",
54577
+ tagColorPallete: "Set1"
53689
54578
  }
53690
54579
 
53691
54580
  _colorTables = new Map()
@@ -54035,7 +54924,7 @@
54035
54924
 
54036
54925
  IGVGraphics.strokeLine(ctx, sPixel, yStrokedLine, ePixel, yStrokedLine, {
54037
54926
  strokeStyle: color,
54038
- lineWidth: 2,
54927
+ lineWidth: 2
54039
54928
  });
54040
54929
 
54041
54930
  // Add gap width as text like Java IGV if it fits nicely and is a multi-base gap
@@ -54044,7 +54933,7 @@
54044
54933
  IGVGraphics.fillRect(ctx, textStart - 1, y - 1, gapTextWidth + 2, 12, {fillStyle: "white"});
54045
54934
  IGVGraphics.fillText(ctx, gapLenText, textStart, y + 10, {
54046
54935
  'font': 'normal 10px monospace',
54047
- 'fillStyle': this.deletionTextColor,
54936
+ 'fillStyle': this.deletionTextColor
54048
54937
  });
54049
54938
  }
54050
54939
  }
@@ -54087,7 +54976,7 @@
54087
54976
  if (this.showInsertionText && insertionBlock.len > 1 && basePixelWidth > textPixelWidth) {
54088
54977
  IGVGraphics.fillText(ctx, insertLenText, xBlockStart + 1, y + 10, {
54089
54978
  'font': 'normal 10px monospace',
54090
- 'fillStyle': this.insertionTextColor,
54979
+ 'fillStyle': this.insertionTextColor
54091
54980
  });
54092
54981
  }
54093
54982
  lastXBlockStart = xBlockStart;
@@ -54257,7 +55146,7 @@
54257
55146
  height: alignmentHeight
54258
55147
  },
54259
55148
  baseColor,
54260
- readChar,
55149
+ readChar
54261
55150
  });
54262
55151
  }
54263
55152
 
@@ -54267,13 +55156,28 @@
54267
55156
  return blockBasesToDraw
54268
55157
  }
54269
55158
  }
55159
+ }
54270
55160
 
54271
- };
54272
-
54273
- popupData(clickState) {
55161
+ async popupData(clickState) {
54274
55162
  const clickedObject = this.getClickedObject(clickState);
54275
- return clickedObject?.popupData(clickState.genomicLocation, this.hiddenTags, this.showTags)
54276
- };
55163
+ if (clickedObject) {
55164
+
55165
+ // Determine reference base at clicked position, used for HGVS notation
55166
+ let refBase;
55167
+ if (clickedObject.chr) {
55168
+ const viewport = clickState.viewport;
55169
+ const alignmentContainer = viewport.cachedFeatures;
55170
+ const coverageMap = alignmentContainer?.coverageMap;
55171
+ const refseq = coverageMap?.refSeq;
55172
+ if (refseq) {
55173
+ const genomicLocation = Math.floor(clickState.genomicLocation);
55174
+ refBase = refseq.charAt(genomicLocation - coverageMap.bpStart).toUpperCase();
55175
+ }
55176
+ }
55177
+
55178
+ return clickedObject.popupData(clickState.genomicLocation, this.hiddenTags, this.showTags, refBase, this.browser.genome)
55179
+ }
55180
+ }
54277
55181
 
54278
55182
  /**
54279
55183
  * Return menu items for the AlignmentTrack
@@ -55046,7 +55950,7 @@
55046
55950
  this.colorTable = new PaletteColorTable(this.tagColorPallete);
55047
55951
  }
55048
55952
  color = this.colorTable.getColor(tagValue);
55049
-
55953
+
55050
55954
  }
55051
55955
  break
55052
55956
  }
@@ -55203,7 +56107,7 @@
55203
56107
 
55204
56108
 
55205
56109
  const base = key.base;
55206
- const compl = complementBase(base);
56110
+ const compl = complementBase$1(base);
55207
56111
 
55208
56112
  const modifiable = coverageMap.getCount(pos, base) + coverageMap.getCount(pos, compl);
55209
56113
  const detectable = modificationCounts.simplexModifications.has(key.modification) ?
@@ -55236,7 +56140,7 @@
55236
56140
 
55237
56141
  const DEFAULT_COVERAGE_COLOR = "rgb(150, 150, 150)";
55238
56142
 
55239
- class CoverageTrack {
56143
+ class CoverageTrack {
55240
56144
 
55241
56145
 
55242
56146
  constructor(config, parent) {
@@ -55248,7 +56152,7 @@
55248
56152
  this.top = 0;
55249
56153
 
55250
56154
  this.autoscale = config.autoscale || config.max === undefined;
55251
- if(config.coverageColor) {
56155
+ if (config.coverageColor) {
55252
56156
  this.color = config.coverageColor;
55253
56157
  }
55254
56158
 
@@ -55265,11 +56169,15 @@
55265
56169
  return this.parent.coverageTrackHeight
55266
56170
  }
55267
56171
 
56172
+ get browser() {
56173
+ return this.parent.browser
56174
+ }
56175
+
55268
56176
  draw(options) {
55269
56177
 
55270
56178
  const pixelTop = options.pixelTop;
55271
56179
  pixelTop + options.pixelHeight;
55272
- const nucleotideColors = this.parent.browser.nucleotideColors;
56180
+ const nucleotideColors = this.browser.nucleotideColors;
55273
56181
 
55274
56182
  if (pixelTop > this.height) {
55275
56183
  return //scrolled out of view
@@ -55303,7 +56211,8 @@
55303
56211
  fillStyle: color,
55304
56212
  strokeStyle: color
55305
56213
  });
55306
- const w = Math.max(1, 1.0 / bpPerPixel);
56214
+
56215
+ const w = Math.max(1, 1.0 / bpPerPixel);
55307
56216
  for (let i = 0, len = coverageMap.coverage.length; i < len; i++) {
55308
56217
 
55309
56218
  const bp = (coverageMap.bpStart + i);
@@ -55372,8 +56281,9 @@
55372
56281
  const coverageMap = features.coverageMap;
55373
56282
  const coverageMapIndex = Math.floor(genomicLocation - coverageMap.bpStart);
55374
56283
  const coverage = coverageMap.coverage[coverageMapIndex];
55375
- if(coverage) {
56284
+ if (coverage) {
55376
56285
  return {
56286
+ reference: coverageMap.refSeq ? coverageMap.refSeq.charAt(coverageMapIndex).toUpperCase() : undefined,
55377
56287
  coverage: coverage,
55378
56288
  baseModCounts: features.baseModCounts,
55379
56289
  hoverText: () => coverageMap.coverage[coverageMapIndex].hoverText()
@@ -55381,60 +56291,64 @@
55381
56291
  }
55382
56292
  }
55383
56293
 
55384
- popupData(clickState) {
56294
+ async popupData(clickState) {
55385
56295
 
55386
56296
  const nameValues = [];
55387
56297
 
55388
- const {coverage, baseModCounts} = this.getClickedObject(clickState);
56298
+ const {reference, coverage, baseModCounts} = this.getClickedObject(clickState);
55389
56299
  if (coverage) {
55390
56300
  const genomicLocation = Math.floor(clickState.genomicLocation);
55391
56301
  const referenceFrame = clickState.viewport.referenceFrame;
55392
56302
 
55393
56303
  nameValues.push(referenceFrame.chr + ":" + numberFormatter$1(1 + genomicLocation));
55394
-
55395
56304
  nameValues.push({name: 'Total Count', value: coverage.total});
56305
+ nameValues.push('<HR/>');
55396
56306
 
55397
56307
  // 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});
56308
+ for (let b of ['A', 'C', 'G', 'T', 'N']) {
56309
+ let tmp = coverage[`pos${b}`] + coverage[`neg${b}`];
56310
+ tmp = tmp.toString() + " (" + Math.round((tmp / coverage.total) * 100.0) + "%, " + coverage[`pos${b}`] + "+, " + coverage[`neg${b}`] + "- )";
56311
+ nameValues.push({name: b, value: tmp});
56312
+ }
55421
56313
 
55422
- nameValues.push('<HR/>');
55423
- nameValues.push({name: 'DEL', value: coverage.del.toString()});
55424
- nameValues.push({name: 'INS', value: coverage.ins.toString()});
56314
+ if (coverage.del > 0) nameValues.push({name: 'DEL', value: coverage.del.toString()});
56315
+ if (coverage.ins > 0) nameValues.push({name: 'INS', value: coverage.ins.toString()});
55425
56316
 
55426
- if(baseModCounts) {
56317
+ if (baseModCounts) {
55427
56318
  nameValues.push('<hr/>');
55428
56319
  nameValues.push(...baseModCounts.popupData(genomicLocation, this.parent.colorBy));
55429
56320
 
55430
56321
  }
55431
56322
 
55432
- }
56323
+ // HGVS annotations for variants, and ClinVar links if available
56324
+ if (reference) {
56325
+ let first = true;
56326
+ for (let b of ['A', 'C', 'G', 'T']) {
56327
+ let count = coverage[`pos${b}`] + coverage[`neg${b}`];
56328
+ if (count > 0 && reference !== b) {
56329
+ if (first) {
56330
+ nameValues.push('<hr/>');
56331
+ first = false;
56332
+ }
56333
+ const hgvsNotation = await HGVS.createHGVSAnnotation(this.browser.genome, referenceFrame.chr, genomicLocation, reference, b);
56334
+ const clinVarURL = await ClinVar.getClinVarURL(hgvsNotation);
56335
+ if (clinVarURL) {
56336
+ nameValues.push({
56337
+ name: 'ClinVar',
56338
+ value: `<a href='${clinVarURL}' target='_blank'>${hgvsNotation}</a>`
56339
+ });
56340
+ } else {
56341
+ nameValues.push({name: 'HGVS', value: hgvsNotation});
56342
+ }
56343
+ }
56344
+ }
56345
+ }
55433
56346
 
55434
- return nameValues
55435
56347
 
55436
- }
56348
+ return nameValues
55437
56349
 
56350
+ }
56351
+ }
55438
56352
  }
55439
56353
 
55440
56354
  class BAMTrack extends TrackBase {
@@ -72366,39 +73280,6 @@ ${indent}columns: ${matrix.columns}
72366
73280
  '*': '#002eff'
72367
73281
  });
72368
73282
 
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
73283
  /**
72403
73284
  * Return an object representing the locus of the given string. Object is of the form
72404
73285
  * {
@@ -72425,6 +73306,13 @@ ${indent}columns: ${matrix.columns}
72425
73306
 
72426
73307
  const searchForLocus = async (locus) => {
72427
73308
 
73309
+ if (HGVS.isValidHGVS(locus)) {
73310
+ const hgvsResult = await HGVS.search(locus, browser);
73311
+ if (hgvsResult) {
73312
+ return hgvsResult
73313
+ }
73314
+ }
73315
+
72428
73316
  if (locus.trim().toLowerCase() === "all" || locus === "*") {
72429
73317
  if (browser.genome.wholeGenomeView) {
72430
73318
  const wgChr = browser.genome.getChromosome("all");
@@ -72450,12 +73338,12 @@ ${indent}columns: ${matrix.columns}
72450
73338
 
72451
73339
  // Not a locus string, search track annotations
72452
73340
  const feature = await searchFeatures(browser, locus);
72453
- if(feature) {
73341
+ if (feature) {
72454
73342
  locusObject = {
72455
73343
  chr: feature.chr,
72456
73344
  start: feature.start,
72457
73345
  end: feature.end,
72458
- name: (feature.name || locus).toUpperCase(),
73346
+ name: (feature.name || locus).toUpperCase()
72459
73347
 
72460
73348
  };
72461
73349
  }
@@ -72580,110 +73468,6 @@ ${indent}columns: ${matrix.columns}
72580
73468
 
72581
73469
  }
72582
73470
 
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
73471
  class QTLTrack extends TrackBase {
72688
73472
 
72689
73473
  constructor(config, browser) {
@@ -75699,7 +76483,7 @@ ${indent}columns: ${matrix.columns}
75699
76483
  })
75700
76484
  }
75701
76485
 
75702
- const _version = "3.6.0";
76486
+ const _version = "3.7.0";
75703
76487
  function version() {
75704
76488
  return _version
75705
76489
  }
@@ -78711,7 +79495,7 @@ ${indent}columns: ${matrix.columns}
78711
79495
  this.sequence = await loadSequence(config, this.browser);
78712
79496
 
78713
79497
  // 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) {
79498
+ if (false !== config.showIdeogram && false !== config.wholeGenomeView) {
78715
79499
  if (config.cytobandURL) {
78716
79500
  this.cytobandSource = new CytobandFile(config.cytobandURL, Object.assign({}, config));
78717
79501
  } else if (config.cytobandBbURL) {
@@ -78719,8 +79503,8 @@ ${indent}columns: ${matrix.columns}
78719
79503
  }
78720
79504
  }
78721
79505
 
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.
79506
+ // Search for chromosomes, that is an array of chromosome objects containing name and length. This is
79507
+ // optional but required to support whole genome view.
78724
79508
  if (this.sequence.chromosomes) {
78725
79509
  this.chromosomes = this.sequence.chromosomes;
78726
79510
  } else if (config.chromSizesURL) {
@@ -78756,7 +79540,7 @@ ${indent}columns: ${matrix.columns}
78756
79540
  // Trim to remove non-existent chromosomes
78757
79541
  await this.chromAlias.preload(this.#wgChromosomeNames);
78758
79542
  this.#wgChromosomeNames =
78759
- this.#wgChromosomeNames.map(c => this.getChromosomeName(c)).filter(c => this.chromosomes.has(c));
79543
+ this.#wgChromosomeNames.map(c => this.getChromosomeName(c)).filter(c => this.chromosomes.has(c));
78760
79544
  } else {
78761
79545
  this.#wgChromosomeNames = trimSmallChromosomes(this.chromosomes);
78762
79546
  await this.chromAlias.preload(this.#wgChromosomeNames);
@@ -79000,6 +79784,75 @@ ${indent}columns: ${matrix.columns}
79000
79784
  getHubURLs() {
79001
79785
  return this.config.hubs
79002
79786
  }
79787
+
79788
+ /**
79789
+ * Return the Mane transcript with the given name, or null if not found. We also check the refseq historical
79790
+ * db if available for backward compatibility. This is only available for hg38.
79791
+ * @param {string} name - The name of the Mane transcript to search for.
79792
+ * @return {Promise<Object|null>} A Promise resolving to the Mane transcript object if found, or null otherwise.
79793
+ */
79794
+ async getManeTranscript(name) {
79795
+
79796
+ if (!this.maneFeatureSource && this.config.maneBbURL) {
79797
+ this.loadManeFeatureSource();
79798
+ }
79799
+ if (this.maneFeatureSource) {
79800
+ const feature = await this.maneFeatureSource.search(name);
79801
+ if (feature) {
79802
+ return feature
79803
+ }
79804
+ }
79805
+ if (!this.rsDBFeatureSource && this.config.rsdbURL) {
79806
+ this.rsDBFeatureSource = new BWSource({url: this.config.rsdbURL}, this);
79807
+ }
79808
+ if (this.rsDBFeatureSource) {
79809
+ const feature = await this.rsDBFeatureSource.search(name);
79810
+ if (feature) {
79811
+ return feature
79812
+ }
79813
+ }
79814
+ return null
79815
+ }
79816
+
79817
+ /**
79818
+ * Return the Mane transcript overlapping the given position, or null if none found.
79819
+ *
79820
+ * @param chr Chromosome name (e.g., "chr1", "chrX") in which to search for the transcript.
79821
+ * @param position Genomic position (0-based coordinate) to check for overlap with a Mane transcript.
79822
+ * @return {Promise<*|null>} The feature representing the Mane transcript overlapping the specified position, or null if none is found.
79823
+ */
79824
+ async getManeTranscriptAt(chr, position) {
79825
+ if (!this.maneFeatureSource && this.config.maneBbURL) {
79826
+ this.loadManeFeatureSource();
79827
+ }
79828
+ if (this.maneFeatureSource) {
79829
+ try {
79830
+ const start = position;
79831
+ const end = position + 1;
79832
+ const features = await this.maneFeatureSource.getFeatures({chr, start, end});
79833
+ if (features) {
79834
+ for (const feature of features) {
79835
+ if (feature.start <= position && feature.end >= position) {
79836
+ return feature
79837
+ }
79838
+ }
79839
+ }
79840
+ } catch (e) {
79841
+ console.error("Error fetching MANE transcript", e);
79842
+ }
79843
+ }
79844
+ return null
79845
+ }
79846
+
79847
+ loadManeFeatureSource() {
79848
+ if (this.config.maneBbURL != null) {
79849
+ const bbConfig = {url: this.config.maneBbURL};
79850
+ if (this.config.maneTrixURL) {
79851
+ bbConfig.trixURL = this.config.maneTrixURL;
79852
+ }
79853
+ this.maneFeatureSource = new BWSource(bbConfig, this);
79854
+ }
79855
+ }
79003
79856
  }
79004
79857
 
79005
79858
  /**
@@ -79817,10 +80670,7 @@ ${indent}columns: ${matrix.columns}
79817
80670
 
79818
80671
  let session;
79819
80672
  if (options.url || options.file) {
79820
- session = await Browser.loadSessionFile(options, this.config);
79821
- // if (options.parentApp``) {
79822
- // session.parentApp = options.parentApp
79823
- // }
80673
+ session = await Browser.loadSessionFile(options);
79824
80674
  } else {
79825
80675
  session = options;
79826
80676
  }
@@ -79834,7 +80684,7 @@ ${indent}columns: ${matrix.columns}
79834
80684
  * @param options
79835
80685
  * @returns {Promise<*|XMLSession>}
79836
80686
  */
79837
- static async loadSessionFile(options, defaults) {
80687
+ static async loadSessionFile(options) {
79838
80688
 
79839
80689
  const urlOrFile = options.url || options.file;
79840
80690
 
@@ -79863,7 +80713,7 @@ ${indent}columns: ${matrix.columns}
79863
80713
  config = await igvxhr.loadJson(urlOrFile);
79864
80714
  }
79865
80715
  }
79866
- setDefaults(config, defaults);
80716
+
79867
80717
  return config
79868
80718
  }
79869
80719
 
@@ -79874,6 +80724,9 @@ ${indent}columns: ${matrix.columns}
79874
80724
  */
79875
80725
  async loadSessionObject(session) {
79876
80726
 
80727
+ // Capture current configuration options that might be missing from session
80728
+ setDefaults(session, this.config);
80729
+
79877
80730
  // prepare to load a new session, discarding DOM and state
79878
80731
  this.cleanHouseForSession();
79879
80732
  this.config = session;
@@ -79978,16 +80831,19 @@ ${indent}columns: ${matrix.columns}
79978
80831
 
79979
80832
  // Sample info
79980
80833
  const localSampleInfoFiles = [];
80834
+ const googleDriveSampleInfoFiles = [];
79981
80835
  if (session.sampleinfo) {
79982
80836
  const sampleInfoArray = Array.isArray(session.sampleinfo) ? session.sampleinfo : [session.sampleinfo];
79983
80837
  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
80838
  if (sampleInfoConfig.file) {
79988
80839
  localSampleInfoFiles.push(sampleInfoConfig.file);
79989
80840
  } else {
79990
- await this.sampleInfo.loadSampleInfo(sampleInfoConfig);
80841
+ const googleDriveItem = this.#createGoogleDriveItemIfPresent(sampleInfoConfig, 'Sample info', 'url', 'filename', 'Google Drive file');
80842
+ if (googleDriveItem) {
80843
+ googleDriveSampleInfoFiles.push(googleDriveItem);
80844
+ } else {
80845
+ await this.sampleInfo.loadSampleInfo(sampleInfoConfig);
80846
+ }
79991
80847
  }
79992
80848
  }
79993
80849
  }
@@ -80002,22 +80858,40 @@ ${indent}columns: ${matrix.columns}
80002
80858
  trackConfigurations.push({type: "sequence", order: defaultSequenceTrackOrder, removable: false});
80003
80859
  }
80004
80860
 
80005
- const localTrackFileNames = trackConfigurations.filter((config) => undefined !== config.file).map(({file}) => file);
80861
+ // Extract problematic resources from track configurations
80862
+ const { localFileItems, googleDriveItems } = this.#extractProblematicResources(
80863
+ trackConfigurations,
80864
+ localSampleInfoFiles,
80865
+ googleDriveSampleInfoFiles
80866
+ );
80006
80867
 
80007
- const localIndexFileNames = trackConfigurations.filter((config) => undefined !== config.indexFile).map(({indexFile}) => indexFile);
80008
- if (localIndexFileNames.length > 0) {
80009
- localTrackFileNames.push(...localIndexFileNames);
80010
- }
80868
+ // Display warning if problematic resources are found
80869
+ if (localFileItems.length > 0 || googleDriveItems.length > 0) {
80870
+ 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
80871
 
80012
- if (localSampleInfoFiles.length > 0) {
80013
- localTrackFileNames.push(...localSampleInfoFiles);
80014
- }
80872
+ // Add local file items
80873
+ for (const item of localFileItems) {
80874
+ message += `Local file name: ${item.fileName}\n`;
80875
+ message += `Track name: ${item.trackName}\n\n`;
80876
+
80877
+ }
80878
+
80879
+ // Add Google Drive items
80880
+ for (const item of googleDriveItems) {
80881
+ message += `Google Drive file name: ${item.fileName}\n`;
80882
+ message += `Track name: ${item.trackName}\n\n`;
80883
+
80884
+ }
80015
80885
 
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')}`);
80886
+ alert(message);
80018
80887
  }
80019
80888
 
80020
- const nonLocalTrackConfigurations = trackConfigurations.filter((config) => undefined === config.file);
80889
+ const nonLocalTrackConfigurations = trackConfigurations.filter((config) =>
80890
+ undefined === config.file &&
80891
+ undefined === config.indexFile &&
80892
+ // Filter out tracks with Google Drive URLs in url/indexURL fields
80893
+ !(config.url && isGoogleDriveURL(config.url)) &&
80894
+ !(config.indexURL && isGoogleDriveURL(config.indexURL)));
80021
80895
 
80022
80896
  // Maintain track order unless explicitly set
80023
80897
  let trackOrder = 1;
@@ -80913,7 +81787,7 @@ ${indent}columns: ${matrix.columns}
80913
81787
  }
80914
81788
 
80915
81789
  minimumBases() {
80916
- return this.config.minimumBases
81790
+ return this.config.minimumBases ?? 40
80917
81791
  }
80918
81792
 
80919
81793
  // Zoom in by a factor of 2, keeping the same center location
@@ -81284,11 +82158,6 @@ ${indent}columns: ${matrix.columns}
81284
82158
  }
81285
82159
 
81286
82160
  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
82161
 
81293
82162
  // Build locus array (multi-locus view). Use the first track to extract the loci, any track could be used.
81294
82163
  const locus = [];
@@ -81329,9 +82198,9 @@ ${indent}columns: ${matrix.columns}
81329
82198
 
81330
82199
  let config;
81331
82200
  if (typeof track.getState === "function") {
81332
- config = TrackBase.localFileInspection(track.getState());
82201
+ config = TrackBase.prepareConfigForSession(track.getState());
81333
82202
  } else if (track.config) {
81334
- config = TrackBase.localFileInspection(track.config);
82203
+ config = TrackBase.prepareConfigForSession(track.config);
81335
82204
  }
81336
82205
 
81337
82206
  if (config) {
@@ -81362,37 +82231,214 @@ ${indent}columns: ${matrix.columns}
81362
82231
 
81363
82232
  json["tracks"] = trackJson;
81364
82233
 
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
- }
82234
+ // Sample info
82235
+ if (this.config.sampleinfo) {
82236
+ json["sampleinfo"] = this.config.sampleinfo;
82237
+ }
82238
+
82239
+ // Validate reference genome and warn about problematic resources
82240
+ this._validateAndWarnResources(json);
82241
+
82242
+ return json
82243
+ }
82244
+
82245
+ /**
82246
+ * Get a display identifier for a Google Drive file.
82247
+ * Returns the provided filename if available, otherwise falls back to a default string.
82248
+ * Note: Filenames should always be present in saved sessions since Google Drive files
82249
+ * can only be added when the user is authenticated.
82250
+ *
82251
+ * @param {string|undefined} filename - The filename property from the config (e.g., config.filename or config.indexFilename)
82252
+ * @param {string} defaultFallback - Default identifier to use if filename is not provided
82253
+ * @returns {string} A display identifier (filename or fallback string)
82254
+ * @private
82255
+ */
82256
+ #getGoogleDriveDisplayName(filename, defaultFallback = 'Google Drive file') {
82257
+ return filename || defaultFallback
82258
+ }
82259
+
82260
+ /**
82261
+ * Check if a config has a Google Drive URL and create a Google Drive item if found.
82262
+ *
82263
+ * @param {Object} config - Track configuration object
82264
+ * @param {string} trackName - Name of the track
82265
+ * @param {string} urlField - Field name to check ('url' or 'indexURL')
82266
+ * @param {string} filenameField - Field name for filename ('filename' or 'indexFilename')
82267
+ * @param {string} defaultFileName - Default filename if not found
82268
+ * @returns {Object|null} Google Drive item object or null if not a Google Drive URL
82269
+ * @private
82270
+ */
82271
+ #createGoogleDriveItemIfPresent(config, trackName, urlField, filenameField, defaultFileName) {
82272
+ const url = config[urlField];
82273
+ if (url && isGoogleDriveURL(url)) {
82274
+ const fileName = this.#getGoogleDriveDisplayName(config[filenameField], defaultFileName);
82275
+ return {
82276
+ trackName: trackName,
82277
+ fileName: fileName
81371
82278
  }
81372
82279
  }
82280
+ return null
82281
+ }
81373
82282
 
81374
- // Sample info
81375
- const localSampleInfoFileDetections = [];
81376
- if (this.config.sampleinfo) {
82283
+ /**
82284
+ * Extract Google Drive items from a track configuration (checks both url and indexURL).
82285
+ *
82286
+ * @param {Object} config - Track configuration object
82287
+ * @returns {Array} Array of Google Drive items found in this config
82288
+ * @private
82289
+ */
82290
+ #extractGoogleDriveItemsFromConfig(config) {
82291
+ const items = [];
82292
+ const trackName = config.name || 'Unnamed track';
81377
82293
 
81378
- json["sampleinfo"] = this.config.sampleinfo;
82294
+ // Check main file URL
82295
+ const mainItem = this.#createGoogleDriveItemIfPresent(config, trackName, 'url', 'filename', 'Google Drive file');
82296
+ if (mainItem) {
82297
+ items.push(mainItem);
82298
+ }
82299
+
82300
+ // Check index file URL
82301
+ const indexItem = this.#createGoogleDriveItemIfPresent(config, `${trackName} index`, 'indexURL', 'indexFilename', 'Google Drive index file');
82302
+ if (indexItem) {
82303
+ items.push(indexItem);
82304
+ }
82305
+
82306
+ return items
82307
+ }
82308
+
82309
+ /**
82310
+ * Extract problematic resources (local files and Google Drive files) from track configurations.
82311
+ * Google Drive files are detected by checking if the url/indexURL fields contain Google Drive URLs,
82312
+ * using the isGoogleDriveURL helper function from sessionResourceValidator.
82313
+ *
82314
+ * @param {Array} trackConfigurations - Array of track configuration objects
82315
+ * @param {Array} localSampleInfoFiles - Array of local sample info filenames
82316
+ * @param {Array} googleDriveSampleInfoFiles - Array of Google Drive sample info items (objects with trackName and fileName)
82317
+ * @returns {{localFileItems: Array, googleDriveItems: Array}} Object containing arrays of problematic resources
82318
+ * @private
82319
+ */
82320
+ #extractProblematicResources(trackConfigurations, localSampleInfoFiles = [], googleDriveSampleInfoFiles = []) {
82321
+ const localFileItems = [];
82322
+ const googleDriveItems = [];
82323
+
82324
+ // Collect local files from track configurations
82325
+ for (const config of trackConfigurations) {
82326
+ const trackName = config.name || 'Unnamed track';
82327
+ if (config.file) {
82328
+ localFileItems.push({
82329
+ trackName: trackName,
82330
+ fileName: config.file
82331
+ });
82332
+ }
82333
+ if (config.indexFile) {
82334
+ localFileItems.push({
82335
+ trackName: `${trackName} index`,
82336
+ fileName: config.indexFile
82337
+ });
82338
+ }
82339
+ }
82340
+
82341
+ // Add sample info local files
82342
+ for (const fileName of localSampleInfoFiles) {
82343
+ localFileItems.push({
82344
+ trackName: 'Sample info',
82345
+ fileName: fileName
82346
+ });
82347
+ }
82348
+
82349
+ // Collect Google Drive files by checking if url/indexURL fields contain Google Drive URLs
82350
+ for (const config of trackConfigurations) {
82351
+ const items = this.#extractGoogleDriveItemsFromConfig(config);
82352
+ googleDriveItems.push(...items);
82353
+ }
82354
+
82355
+ // Add sample info Google Drive files
82356
+ googleDriveItems.push(...googleDriveSampleInfoFiles);
82357
+
82358
+ return { localFileItems, googleDriveItems }
82359
+ }
82360
+
82361
+ /**
82362
+ * Validate reference genome and warn about problematic resources in the session.
82363
+ *
82364
+ * Reference genome: Throws error if local files or Google Drive URLs are detected
82365
+ * Tracks/Sample Info: Shows warning if local files or Google Drive URLs are detected
82366
+ *
82367
+ * @param {Object} json - The session JSON object
82368
+ * @private
82369
+ */
82370
+ _validateAndWarnResources(json) {
82371
+ // 1. Validate reference genome (blocking errors)
82372
+ const refErrors = [];
82373
+
82374
+ if (json.reference.fastaURL) {
82375
+ if (isLocalFile(json.reference.fastaURL)) {
82376
+ refErrors.push(`Local file: ${json.reference.fastaURL.name}`);
82377
+ } else if (isGoogleDriveURL(json.reference.fastaURL)) {
82378
+ refErrors.push(`Google Drive URL: ${json.reference.fastaURL}`);
82379
+ }
82380
+ }
82381
+
82382
+ if (json.reference.indexURL) {
82383
+ if (isLocalFile(json.reference.indexURL)) {
82384
+ refErrors.push(`Local file: ${json.reference.indexURL.name}`);
82385
+ } else if (isGoogleDriveURL(json.reference.indexURL)) {
82386
+ refErrors.push(`Google Drive URL: ${json.reference.indexURL}`);
82387
+ }
82388
+ }
82389
+
82390
+ if (refErrors.length > 0) {
82391
+ throw new Error(
82392
+ `Error: Sessions cannot include the following resources in the reference genome:\n` +
82393
+ refErrors.map(err => ` - ${err}`).join('\n') + '\n' +
82394
+ `These resources require local access or authentication and will not work when the session is shared.`
82395
+ )
82396
+ }
82397
+
82398
+ // 2. Collect warnings from tracks and sample info
82399
+ const localSampleInfoFiles = [];
82400
+ const googleDriveSampleInfoFiles = [];
81379
82401
 
82402
+ // Check sample info
82403
+ if (this.config.sampleinfo) {
81380
82404
  for (const path of this.sampleInfo.sampleInfoFiles) {
81381
- const config = TrackBase.localFileInspection({url: path});
82405
+ const config = TrackBase.prepareConfigForSession({url: path});
81382
82406
  if (config.file) {
81383
- localSampleInfoFileDetections.push(config.file);
82407
+ localSampleInfoFiles.push(config.file);
82408
+ }
82409
+ // Check if the url field contains a Google Drive URL
82410
+ const googleDriveItem = this.#createGoogleDriveItemIfPresent(config, 'Sample info', 'url', 'filename', 'Google Drive file');
82411
+ if (googleDriveItem) {
82412
+ googleDriveSampleInfoFiles.push(googleDriveItem);
81384
82413
  }
81385
- }
81386
- if (localSampleInfoFileDetections.length > 0) {
81387
- localFileDetections.push(...localSampleInfoFileDetections);
81388
82414
  }
81389
82415
  }
81390
82416
 
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
- }
82417
+ // Extract problematic resources from tracks
82418
+ const { localFileItems, googleDriveItems } = this.#extractProblematicResources(
82419
+ json.tracks || [],
82420
+ localSampleInfoFiles,
82421
+ googleDriveSampleInfoFiles
82422
+ );
81394
82423
 
81395
- return json
82424
+ // 3. Display consolidated warning if any issues found
82425
+ if (localFileItems.length > 0 || googleDriveItems.length > 0) {
82426
+ 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';
82427
+
82428
+ // Add local file items
82429
+ for (const item of localFileItems) {
82430
+ message += `Local file name: ${item.fileName}\n`;
82431
+ message += `Track name: ${item.trackName}\n\n`;
82432
+ }
82433
+
82434
+ // Add Google Drive items
82435
+ for (const item of googleDriveItems) {
82436
+ message += `Google Drive file name: ${item.fileName}\n`;
82437
+ message += `Track name: ${item.trackName}\n\n`;
82438
+ }
82439
+
82440
+ alert(message);
82441
+ }
81396
82442
  }
81397
82443
 
81398
82444
  compressedSession() {
@@ -81896,6 +82942,281 @@ ${indent}columns: ${matrix.columns}
81896
82942
  }
81897
82943
  }
81898
82944
 
82945
+ /**
82946
+ * Handles incoming messages from the WebSocket connection. Performs requested actions on the IGV browser instance
82947
+ * and returns a response message.
82948
+ *
82949
+ * @param json
82950
+ * @param browser
82951
+ * @returns {Promise<{uniqueID, message: string, status: string}>}
82952
+ */
82953
+
82954
+
82955
+ async function handleMessage(json, browser) {
82956
+
82957
+ const returnMsg = {uniqueID: json.uniqueID, status: 'ok'};
82958
+
82959
+ try {
82960
+ let tracks;
82961
+ const {type, args} = json;
82962
+ switch (type.toLowerCase()) {
82963
+
82964
+ case "goto":
82965
+ case "search":
82966
+ const term = args.locus || args.term;
82967
+ const found = await browser.search(term);
82968
+ if (found) {
82969
+ returnMsg.message = `Locus ${term} found and navigated to successfully`;
82970
+ } else {
82971
+ returnMsg.message = `Locus ${term} not found`;
82972
+ returnMsg.status = 'warning';
82973
+ }
82974
+ break
82975
+
82976
+ case "currentloci":
82977
+ returnMsg.data = browser.currentLoci();
82978
+ returnMsg.message = `Retrieved current loci successfully`;
82979
+ break
82980
+
82981
+ case "visibilityChange":
82982
+ returnMsg.message = await browser.visibilityChange();
82983
+ break
82984
+
82985
+ case "tojson":
82986
+ returnMsg.data = browser.toJSON();
82987
+ returnMsg.message = `Session serialized to JSON successfully`;
82988
+ break
82989
+
82990
+ case "compressedsession":
82991
+ returnMsg.data = browser.compressedSession();
82992
+ returnMsg.message = `Session serialized and compressed successfully`;
82993
+ break
82994
+
82995
+ case "tosvg":
82996
+ returnMsg.data = browser.toSVG();
82997
+ returnMsg.message = `Session exported to SVG successfully`;
82998
+ break
82999
+
83000
+ case "removetrackbyname": {
83001
+ let {trackName} = args;
83002
+ if(trackName) {
83003
+ tracks = browser.findTracks(t => trackName ? t.name === trackName : true);
83004
+ if (tracks) {
83005
+ tracks.forEach(t => browser.removeTrack(t));
83006
+ returnMsg.message = `Removed track(s) ${trackName} for ${tracks.length} track(s)`;
83007
+ } else {
83008
+ returnMsg.message = `No tracks found matching name ${trackName}`;
83009
+ returnMsg.status = 'warning';
83010
+ }
83011
+ } else {
83012
+ returnMsg.message = `No track name provided`;
83013
+ returnMsg.status = 'warning';
83014
+ }
83015
+ break
83016
+ }
83017
+
83018
+ case "loadsampleinfo": {
83019
+ browser.loadSampleInfo(args);
83020
+ returnMsg.message = `Sample info loaded successfully`;
83021
+ break
83022
+ }
83023
+
83024
+ case "discardsampleinfo":
83025
+ browser.discardSampleInfo();
83026
+ returnMsg.message = `Sample info discarded successfully`;
83027
+ break
83028
+
83029
+ case "loadroi":
83030
+ browser.loadROI(args);
83031
+ returnMsg.message = `ROI loaded successfully`;
83032
+ break
83033
+
83034
+ case "clearrois":
83035
+ browser.clearROIs();
83036
+ returnMsg.message = `ROIs cleared successfully`;
83037
+ break
83038
+
83039
+ case "getuserdefinedrois":
83040
+ const rois = await browser.getUserDefinedROIs();
83041
+ returnMsg.data = rois;
83042
+ returnMsg.message = `Retrieved ${rois.length} user-defined ROIs successfully`;
83043
+ break
83044
+
83045
+ case 'loadtrack': {
83046
+ const {url, indexURL} = args;
83047
+ const track = await browser.loadTrack({url, indexURL});
83048
+ returnMsg.message = `Track ${track.name} loaded successfully`;
83049
+ break
83050
+ }
83051
+
83052
+ case "genome":
83053
+ const id = args.id;
83054
+ await browser.loadGenome(id);
83055
+ returnMsg.message = `Genome ${id} loaded successfully`;
83056
+ break
83057
+
83058
+ case "loadsession":
83059
+ const url = args.url;
83060
+ await browser.loadSession({url});
83061
+ returnMsg.message = `Session loaded successfully from ${url}`;
83062
+ break
83063
+
83064
+ case "zoomin":
83065
+ await browser.zoomIn();
83066
+ returnMsg.message = `Zoomed in successfully`;
83067
+ break
83068
+
83069
+ case "zoomout":
83070
+ await browser.zoomOut();
83071
+ returnMsg.message = `Zoomed out successfully`;
83072
+ break
83073
+
83074
+ case "setcolor":
83075
+
83076
+ let {color, trackName} = args;
83077
+
83078
+ if (color.includes(",") && !color.startsWith("rgb(")) {
83079
+ // Convert "R,G,B" to "rgb(R,G,B)"
83080
+ color = `rgb(${color})`;
83081
+ }
83082
+
83083
+ tracks = browser.findTracks(t => trackName ? t.name === trackName : true);
83084
+ if (tracks) {
83085
+ tracks.forEach(t => t.color = color);
83086
+ browser.repaintViews();
83087
+ returnMsg.message = `Set color to ${color} for ${tracks.length} track(s)`;
83088
+ } else {
83089
+ returnMsg.message = `No tracks found matching name ${trackName}`;
83090
+ returnMsg.status = 'warning';
83091
+ }
83092
+ break
83093
+
83094
+ case "renametrack":
83095
+
83096
+ const {currentName, newName} = args;
83097
+
83098
+ tracks = browser.findTracks(t => currentName === t.name);
83099
+ if (tracks && tracks.length > 0) {
83100
+ tracks.forEach(t => {
83101
+ t.name = newName;
83102
+ browser.fireEvent('tracknamechange', [t]);
83103
+ });
83104
+ returnMsg.message = `Renamed ${tracks.length} track(s) from ${currentName} to ${newName}`;
83105
+ } else {
83106
+ returnMsg.message = `No track found with name ${currentName}`;
83107
+ returnMsg.status = 'warning';
83108
+ }
83109
+ break
83110
+
83111
+ default:
83112
+ returnMsg.message = `Unrecognized message type: ${type}`;
83113
+ returnMsg.status = 'error';
83114
+ }
83115
+ } catch (err) {
83116
+ returnMsg.message = err?.message || String(err);
83117
+ returnMsg.status = 'error';
83118
+ }
83119
+
83120
+ return returnMsg
83121
+ }
83122
+
83123
+ /**
83124
+ * Create a WebSocket client that connects to a server and handles messages. The client attempts to connect to a
83125
+ * WebSocketServer upon creation. If the connection is not successful or lost, it will attempt to reconnect with an
83126
+ * exponential backoff strategy. Incoming messages are expected to be JSON formatted and are processed by the
83127
+ * handleMessage function. Messages encompass a subset of the igv.js API
83128
+ *
83129
+ * This client was created to interact with an MCP server, but could be used for other purposes.
83130
+ *
83131
+ * @param host Host for the WebSocket server
83132
+ * @param port Port for the WebSocket server
83133
+ * @param browser The igv.js browser instance
83134
+ */
83135
+
83136
+ function createWebSocketClient(host, port, browser) {
83137
+
83138
+ let socket;
83139
+ let retryInterval = 1000; // Initial retry interval in ms
83140
+ const maxRetryInterval = 10000; // Maximum retry interval in ms
83141
+ let reconnectTimer;
83142
+ let intentionalClose = false; // Flag to prevent reconnection on intentional close
83143
+
83144
+ function connect() {
83145
+
83146
+ const isLocal = host === 'localhost' || host === '127.0.0.1';
83147
+ const protocol = window.location.protocol === 'https:' && !isLocal ? 'wss:' : 'ws:';
83148
+ socket = new WebSocket(`${protocol}//${host}:${port}`);
83149
+
83150
+ // helper to safely send
83151
+ const sendJSON = (obj) => {
83152
+ if (socket.readyState === WebSocket.OPEN) {
83153
+ socket.send(JSON.stringify(obj));
83154
+ }
83155
+ };
83156
+
83157
+ socket.addEventListener('open', function (event) {
83158
+ retryInterval = 1000; // Reset retry interval on successful connection
83159
+ sendJSON({message: 'Hello from browser client'});
83160
+ });
83161
+
83162
+ // Listen for incoming messages
83163
+ socket.addEventListener('message', async function (event) {
83164
+ try {
83165
+ const json = JSON.parse(event.data);
83166
+
83167
+ if("close" === json.type) {
83168
+ intentionalClose = true;
83169
+ clearTimeout(reconnectTimer);
83170
+ if (socket && socket.readyState === WebSocket.OPEN) {
83171
+ socket.close();
83172
+ }
83173
+ return
83174
+ }
83175
+
83176
+ const returnMsg = await handleMessage(json, browser);
83177
+ sendJSON(returnMsg);
83178
+
83179
+ } catch (e) {
83180
+ if (e instanceof SyntaxError) {
83181
+ console.warn('Received non-JSON message from server:', event.data);
83182
+ } else {
83183
+ console.error('Error handling message:', e);
83184
+ sendJSON({
83185
+ status: 'error',
83186
+ message: `Error handling message: ${e.message || e.toString()}`
83187
+ });
83188
+ }
83189
+ }
83190
+ });
83191
+
83192
+ socket.addEventListener('error', function (event) {
83193
+ console.error('WebSocket error:', event);
83194
+ // The 'close' event will fire immediately after 'error', triggering the reconnect logic.
83195
+ });
83196
+
83197
+ socket.addEventListener('close', function (event) {
83198
+ if (intentionalClose) {
83199
+ console.log('WebSocket closed intentionally. Not reconnecting.');
83200
+ return
83201
+ }
83202
+ console.log('Disconnected from server. Retrying in ' + (retryInterval / 1000) + ' seconds.');
83203
+ clearTimeout(reconnectTimer);
83204
+ reconnectTimer = setTimeout(connect, retryInterval);
83205
+ // Increase retry interval for next time, up to a max
83206
+ retryInterval = Math.min(maxRetryInterval, retryInterval * 2);
83207
+ });
83208
+ }
83209
+
83210
+ connect(); // Initial connection attempt
83211
+
83212
+ window.addEventListener('beforeunload', function (event) {
83213
+ clearTimeout(reconnectTimer); // Don't try to reconnect when page is closing
83214
+ if (socket && socket.readyState === WebSocket.OPEN) {
83215
+ socket.close();
83216
+ }
83217
+ });
83218
+ }
83219
+
81899
83220
  let allBrowsers = [];
81900
83221
 
81901
83222
  /**
@@ -81953,8 +83274,13 @@ ${indent}columns: ${matrix.columns}
81953
83274
 
81954
83275
  browser.navbar.navbarDidResize();
81955
83276
 
81956
- return browser
83277
+ if(config.enableWebSocket) {
83278
+ const host = config.webSocketHost || "localhost";
83279
+ const port = config.webSocketPort || 60141;
83280
+ createWebSocketClient(host, port, browser);
83281
+ }
81957
83282
 
83283
+ return browser
81958
83284
  }
81959
83285
 
81960
83286
  function removeBrowser(browser) {
@@ -82147,7 +83473,8 @@ ${indent}columns: ${matrix.columns}
82147
83473
  loadSessionFile: Browser.loadSessionFile,
82148
83474
  loadHub,
82149
83475
  uncompressSession: Browser.uncompressSession,
82150
- createIcon
83476
+ createIcon,
83477
+ createWebSocketClient
82151
83478
  };
82152
83479
 
82153
83480
  return index;