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