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