[페이지 원복] 기존 테스트 페이지 원복복
This commit is contained in:
955
pages/[tabId]/test/igv2.vue
Normal file
955
pages/[tabId]/test/igv2.vue
Normal file
@@ -0,0 +1,955 @@
|
||||
<template>
|
||||
<div id="app" style="position: relative;">
|
||||
<main role="main" class="container-fluid">
|
||||
<div style="padding-top: 24px; position: relative;">
|
||||
<!-- IGV 위 컨트롤 바 -->
|
||||
<div style="display: flex; gap: 16px; align-items: center; margin-bottom: 8px; flex-wrap: wrap;">
|
||||
<!-- 파란 핸들 위치 input -->
|
||||
<label>시작 위치
|
||||
<input type="number" :value="startPos" @input="onInputPosChange('start', $event.target.value)" class="igv-control-input" style="width:110px; margin-left:4px;" />
|
||||
</label>
|
||||
<label>끝 위치
|
||||
<input type="number" :value="endPos" @input="onInputPosChange('end', $event.target.value)" class="igv-control-input" style="width:110px; margin-left:4px;" />
|
||||
</label>
|
||||
<button @click="reverseSequence" :class="['igv-control-btn', { 'reverse-active': isReverse }]" style="margin-bottom: 0; margin-left: 8px;">리버스(상보서열) 변환</button>
|
||||
|
||||
<!-- 서버 파일 목록 드롭다운 -->
|
||||
<div style="margin-left: 8px; position: relative;">
|
||||
<button @click="toggleServerFilesDropdown" class="igv-control-btn">
|
||||
서버 파일 목록
|
||||
<span v-if="isServerFilesDropdownOpen">▼</span>
|
||||
<span v-else>▶</span>
|
||||
</button>
|
||||
<div v-if="isServerFilesDropdownOpen" class="server-files-dropdown">
|
||||
<div v-if="loadingFiles" class="dropdown-item">로딩 중...</div>
|
||||
<div v-else-if="serverFiles.length === 0" class="dropdown-item">업로드된 파일이 없습니다.</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="file in serverFiles"
|
||||
:key="file.fileName"
|
||||
@click="selectServerFile(file)"
|
||||
class="dropdown-item file-item"
|
||||
:class="{ 'selected': selectedServerFile && selectedServerFile.fileName === file.fileName }"
|
||||
>
|
||||
<div class="file-name">{{ file.fileName }}</div>
|
||||
<div class="file-info">
|
||||
크기: {{ formatFileSize(file.fileSize) }} |
|
||||
업로드: {{ formatDate(file.uploadTime) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FASTA 업로드 -->
|
||||
<label style="margin-left: 8px;">
|
||||
<input type="file" accept=".fa,.fasta" @change="onFastaUpload" style="display:none;" ref="fastaInput" />
|
||||
<button type="button" class="igv-control-btn" @click="$refs.fastaInput.click()">FASTA 업로드</button>
|
||||
</label>
|
||||
<!-- 대용량 파일 분할 -->
|
||||
<button v-if="showSplitOption" @click="splitLargeFile" class="igv-control-btn" style="margin-left:4px;">파일 분할</button>
|
||||
<!-- 범위 내 염기서열 변경 -->
|
||||
<input type="text" v-model="editSequence" placeholder="새 염기서열 입력" class="igv-control-input" style="width:180px; margin-left:8px;" />
|
||||
<button @click="replaceSequenceInRange" class="igv-control-btn" style="margin-left:4px;">범위 염기서열 변경</button>
|
||||
<!-- Tracks 드롭다운 -->
|
||||
<ul
|
||||
class="navbar-nav mr-auto"
|
||||
style="list-style:none; padding-left:0; margin:0;"
|
||||
>
|
||||
<li
|
||||
class="nav-item dropdown"
|
||||
style="position: relative; display: inline-block;"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
id="igv-example-api-dropdown"
|
||||
@click.prevent="toggleDropdown"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="isDropdownOpen.toString()"
|
||||
style="color: black; cursor: pointer; user-select: none;"
|
||||
>Tracks</a
|
||||
>
|
||||
<ul
|
||||
v-show="isDropdownOpen"
|
||||
class="dropdown-menu"
|
||||
style="width:350px; position: absolute; top: 100%; left: 0; background: white; border: 1px solid #ccc; box-shadow: 0 2px 5px rgba(0,0,0,0.15); padding: 5px 0; margin: 0; list-style:none; z-index: 1000;"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="loadCopyNumberTrack"
|
||||
style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
|
||||
>Copy Number</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="loadDbSnpTrack"
|
||||
style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
|
||||
>dbSNP 137 (bed tabix)</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="loadBigWigTrack"
|
||||
style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
|
||||
>Encode bigwig</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="loadBamTrack"
|
||||
style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
|
||||
>1KG Bam (HG02450)</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- IGV 뷰어 영역 -->
|
||||
<div id="igvDiv" ref="igvDiv" style="padding-top: 20px; min-height: 500px; position: relative;"></div>
|
||||
<!-- 염기서열 결과 -->
|
||||
<div style="margin-top: 12px;">
|
||||
<strong :class="{ 'reverse-active': isReverse }">염기서열:</strong>
|
||||
<pre :class="{ 'reverse-active': isReverse }" style="white-space: pre-wrap; word-break: break-all;">{{ displaySequence }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// igv.js와 igv_custom.js를 script 태그로 동적 로드(SSR 방지)
|
||||
if (typeof window !== 'undefined') {
|
||||
function loadScriptOnce(src, globalCheck, callback) {
|
||||
if (globalCheck()) {
|
||||
if (callback) callback();
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 로딩 중인지 확인
|
||||
if (document.querySelector(`script[src="${src}"]`)) {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (globalCheck()) {
|
||||
clearInterval(checkInterval);
|
||||
if (callback) callback();
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = function() {
|
||||
if (callback) callback();
|
||||
};
|
||||
script.onerror = function() {
|
||||
console.error(`Failed to load script: ${src}`);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
// igv.js 먼저 로드
|
||||
loadScriptOnce('/dist/igv.js', function() {
|
||||
return typeof window.igv !== 'undefined' && typeof window.igv.createBrowser === 'function';
|
||||
}, function() {
|
||||
console.log('IGV.js loaded successfully');
|
||||
// igv_custom.js는 igv.js가 로드된 후에만 로드
|
||||
loadScriptOnce('/dist/igv_custom.js', function() {
|
||||
return !!window.igvCustomLoaded;
|
||||
}, function() {
|
||||
window.igvCustomLoaded = true;
|
||||
console.log('IGV Custom loaded successfully');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "App",
|
||||
data() {
|
||||
return {
|
||||
browser: null,
|
||||
locus: null, // ex: "chr1:10000-10200"
|
||||
overlayWidth: 900,
|
||||
overlayHeight: 500,
|
||||
sequence: '',
|
||||
displaySequence: '',
|
||||
isReverse: false,
|
||||
isDropdownOpen: false,
|
||||
startPos: null,
|
||||
endPos: null,
|
||||
prevStartPos: null,
|
||||
prevEndPos: null,
|
||||
fastaFile: null, // 업로드된 FASTA 파일 Blob
|
||||
fastaName: '', // 업로드된 FASTA 파일명
|
||||
editSequence: '', // 범위 내 교체할 염기서열
|
||||
showSplitOption: false, // 파일 분할 옵션 표시
|
||||
largeFile: null, // 대용량 파일 저장
|
||||
isServerFilesDropdownOpen: false, // 서버 파일 목록 드롭다운 상태
|
||||
loadingFiles: false, // 서버 파일 목록 로딩 상태
|
||||
serverFiles: [], // 서버에 업로드된 파일 목록
|
||||
selectedServerFile: null, // 선택된 서버 파일
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
locus: 'fetchSequence',
|
||||
},
|
||||
async mounted() {
|
||||
// IGV.js와 igv_custom.js를 script 태그로 동적 로드(SSR 방지)
|
||||
if (typeof window !== 'undefined') {
|
||||
function loadScriptOnce(src, globalCheck, callback) {
|
||||
if (globalCheck()) {
|
||||
if (callback) callback();
|
||||
return;
|
||||
}
|
||||
// 이미 로딩 중인지 확인
|
||||
if (document.querySelector(`script[src="${src}"]`)) {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (globalCheck()) {
|
||||
clearInterval(checkInterval);
|
||||
if (callback) callback();
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = function() {
|
||||
if (callback) callback();
|
||||
};
|
||||
script.onerror = function() {
|
||||
console.error(`Failed to load script: ${src}`);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
// igv.js 먼저 로드
|
||||
loadScriptOnce('/dist/igv.js', function() {
|
||||
return typeof window.igv !== 'undefined' && typeof window.igv.createBrowser === 'function';
|
||||
}, function() {
|
||||
console.log('IGV.js loaded successfully');
|
||||
// igv_custom.js는 igv.js가 로드된 후에만 로드
|
||||
loadScriptOnce('/dist/igv_custom.js', function() {
|
||||
return !!window.igvCustomLoaded;
|
||||
}, function() {
|
||||
window.igvCustomLoaded = true;
|
||||
console.log('IGV Custom loaded successfully');
|
||||
});
|
||||
});
|
||||
}
|
||||
await this.initIGV();
|
||||
// IGV가 완전히 생성될 때까지 대기
|
||||
const registerListener = () => {
|
||||
if (window.igvCustomLoaded && this.browser) {
|
||||
window.addEventListener('igv-blue-lines-changed', this.onBlueLinesChanged);
|
||||
} else {
|
||||
setTimeout(registerListener, 200);
|
||||
}
|
||||
};
|
||||
registerListener();
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('igv-blue-lines-changed', this.onBlueLinesChanged);
|
||||
},
|
||||
methods: {
|
||||
async initIGV() {
|
||||
try {
|
||||
await this.waitForIGV();
|
||||
await this.$nextTick();
|
||||
const igvDiv = this.$refs.igvDiv;
|
||||
if (!igvDiv) {
|
||||
console.error("❌ #igvDiv가 존재하지 않습니다.");
|
||||
return;
|
||||
}
|
||||
// FASTA 업로드 시 genome 옵션 변경
|
||||
const genomeOpt = this.fastaFile
|
||||
? {
|
||||
id: this.fastaName || 'custom',
|
||||
fastaURL: this.fastaFile,
|
||||
indexURL: undefined,
|
||||
}
|
||||
: 'hg19';
|
||||
console.log('IGV 옵션:', { locus: this.locus || "chr1:10000-10200", genome: genomeOpt });
|
||||
const options = {
|
||||
locus: this.locus || "chr1:10000-10200",
|
||||
genome: genomeOpt,
|
||||
};
|
||||
try {
|
||||
console.log('IGV 브라우저 생성 시작');
|
||||
this.browser = await window.igv.createBrowser(igvDiv, options);
|
||||
console.log('IGV 브라우저 생성 완료:', this.browser);
|
||||
// 이전 좌표 저장용
|
||||
// prevStartPos, prevEndPos는 data에 저장된 this.prevStartPos, this.prevEndPos 사용
|
||||
this.browser.on("locuschange", (locus) => {
|
||||
// locus 업데이트
|
||||
this.locus = locus;
|
||||
// locus 파싱
|
||||
let locusStart, locusEnd;
|
||||
if (typeof locus === 'string') {
|
||||
const match = locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
|
||||
if (!match) return;
|
||||
locusStart = parseInt(match[2].replace(/,/g, ''), 10);
|
||||
locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
|
||||
} else if (typeof locus === 'object' && locus.chr && locus.start && locus.end) {
|
||||
locusStart = parseInt(String(locus.start).replace(/,/g, ''), 10);
|
||||
locusEnd = parseInt(String(locus.end).replace(/,/g, ''), 10);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
// input 박스의 startPos, endPos 기준으로 ratio 계산
|
||||
if (this.startPos !== null && this.endPos !== null) {
|
||||
const startRatio = (this.startPos - locusStart) / (locusEnd - locusStart);
|
||||
const endRatio = (this.endPos - locusStart) / (locusEnd - locusStart);
|
||||
if (window.igvCustom && typeof window.igvCustom.setLineRatios === 'function') {
|
||||
window.igvCustom.setLineRatios(startRatio, endRatio);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.locus = options.locus;
|
||||
// IGV, igvCustom 모두 준비된 후 최초 막대/염기서열 동기화
|
||||
const syncInitialBlueLines = async () => {
|
||||
if (window.igvCustom && typeof window.igvCustom.getLineRatios === 'function') {
|
||||
// locus 파싱
|
||||
let chrom, locusStart, locusEnd;
|
||||
if (typeof this.locus === 'string') {
|
||||
const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
|
||||
if (!match) return;
|
||||
chrom = match[1];
|
||||
locusStart = parseInt(match[2].replace(/,/g, ''), 10);
|
||||
locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
|
||||
} else if (typeof this.locus === 'object' && this.locus.chr && this.locus.start && this.locus.end) {
|
||||
chrom = this.locus.chr;
|
||||
locusStart = parseInt(String(this.locus.start).replace(/,/g, ''), 10);
|
||||
locusEnd = parseInt(String(this.locus.end).replace(/,/g, ''), 10);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
const { startRatio, endRatio } = window.igvCustom.getLineRatios();
|
||||
const pos1 = Math.round(locusStart + (locusEnd - locusStart) * startRatio);
|
||||
const pos2 = Math.round(locusStart + (locusEnd - locusStart) * endRatio);
|
||||
const start = Math.min(pos1, pos2);
|
||||
const end = Math.max(pos1, pos2);
|
||||
this.startPos = start;
|
||||
this.endPos = end;
|
||||
if (end - start < 1) {
|
||||
this.sequence = '';
|
||||
this.displaySequence = '';
|
||||
return;
|
||||
}
|
||||
// 염기서열 fetch
|
||||
if (
|
||||
this.browser.genome &&
|
||||
this.browser.genome.sequence &&
|
||||
typeof this.browser.genome.sequence.getSequence === 'function'
|
||||
) {
|
||||
const seq = await this.browser.genome.sequence.getSequence(chrom, start, end);
|
||||
this.sequence = seq || '';
|
||||
this.displaySequence = this.isReverse ? this.getComplement(seq) : seq;
|
||||
}
|
||||
} else {
|
||||
setTimeout(syncInitialBlueLines, 200);
|
||||
}
|
||||
};
|
||||
syncInitialBlueLines();
|
||||
} catch (error) {
|
||||
console.error("IGV 브라우저 생성 중 오류:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("IGV 초기화 중 오류:", error);
|
||||
}
|
||||
},
|
||||
// 파란 라인 위치 변경 이벤트 핸들러
|
||||
async onBlueLinesChanged(e) {
|
||||
if (!this.locus || !this.browser) {
|
||||
this.sequence = 'locus/browser 없음';
|
||||
this.displaySequence = 'locus/browser 없음';
|
||||
this.startPos = null;
|
||||
this.endPos = null;
|
||||
if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
|
||||
window.igvCustom.setMiniMapInfo(null, null, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let chrom, locusStart, locusEnd;
|
||||
if (typeof this.locus === 'string') {
|
||||
const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
|
||||
if (!match) {
|
||||
this.sequence = 'locus 파싱 실패';
|
||||
this.displaySequence = 'locus 파싱 실패';
|
||||
this.startPos = null;
|
||||
this.endPos = null;
|
||||
if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
|
||||
window.igvCustom.setMiniMapInfo(null, null, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
chrom = match[1];
|
||||
locusStart = parseInt(match[2].replace(/,/g, ''), 10);
|
||||
locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
|
||||
} else if (typeof this.locus === 'object' && this.locus.chr && this.locus.start && this.locus.end) {
|
||||
chrom = this.locus.chr;
|
||||
locusStart = parseInt(String(this.locus.start).replace(/,/g, ''), 10);
|
||||
locusEnd = parseInt(String(this.locus.end).replace(/,/g, ''), 10);
|
||||
} else {
|
||||
this.sequence = 'locus 파싱 실패';
|
||||
this.displaySequence = 'locus 파싱 실패';
|
||||
this.startPos = null;
|
||||
this.endPos = null;
|
||||
if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
|
||||
window.igvCustom.setMiniMapInfo(null, null, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { startRatio, endRatio } = e.detail;
|
||||
const pos1 = Math.round(locusStart + (locusEnd - locusStart) * startRatio);
|
||||
const pos2 = Math.round(locusStart + (locusEnd - locusStart) * endRatio);
|
||||
const start = Math.min(pos1, pos2);
|
||||
const end = Math.max(pos1, pos2);
|
||||
this.startPos = start;
|
||||
this.endPos = end;
|
||||
if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
|
||||
window.igvCustom.setMiniMapInfo(chrom, start, end);
|
||||
}
|
||||
if (end - start < 1) {
|
||||
this.sequence = '';
|
||||
this.displaySequence = '';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (
|
||||
this.browser.genome &&
|
||||
this.browser.genome.sequence &&
|
||||
typeof this.browser.genome.sequence.getSequence === 'function'
|
||||
) {
|
||||
const seq = await this.browser.genome.sequence.getSequence(chrom, start, end);
|
||||
this.sequence = seq || '';
|
||||
this.displaySequence = this.isReverse ? this.getComplement(seq) : seq;
|
||||
return;
|
||||
}
|
||||
this.sequence = 'getSequence 없음(IGV 내부 구조 확인 필요)';
|
||||
this.displaySequence = 'getSequence 없음(IGV 내부 구조 확인 필요)';
|
||||
} catch {
|
||||
this.sequence = '불러오기 실패';
|
||||
this.displaySequence = '불러오기 실패';
|
||||
}
|
||||
// locus 이동 전 좌표 저장
|
||||
this.prevStartPos = this.startPos;
|
||||
this.prevEndPos = this.endPos;
|
||||
},
|
||||
reverseSequence() {
|
||||
this.isReverse = !this.isReverse;
|
||||
this.displaySequence = this.isReverse ? this.getComplement(this.sequence) : this.sequence;
|
||||
},
|
||||
getComplement(seq) {
|
||||
if (!seq) return '';
|
||||
return seq.replace(/[ATCG]/gi, c => {
|
||||
switch (c.toUpperCase()) {
|
||||
case 'A': return 'T';
|
||||
case 'T': return 'A';
|
||||
case 'C': return 'G';
|
||||
case 'G': return 'C';
|
||||
default: return c;
|
||||
}
|
||||
});
|
||||
},
|
||||
toggleDropdown() {
|
||||
this.isDropdownOpen = !this.isDropdownOpen;
|
||||
},
|
||||
closeDropdown() {
|
||||
this.isDropdownOpen = false;
|
||||
},
|
||||
handleClickOutside(event) {
|
||||
const dropdown = this.$el.querySelector("#igv-example-api-dropdown");
|
||||
const menu = this.$el.querySelector(".dropdown-menu");
|
||||
if (
|
||||
dropdown &&
|
||||
menu &&
|
||||
!dropdown.contains(event.target) &&
|
||||
!menu.contains(event.target)
|
||||
) {
|
||||
this.closeDropdown();
|
||||
}
|
||||
},
|
||||
waitForIGV() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 100; // 10초 타임아웃
|
||||
|
||||
const checkIGV = () => {
|
||||
attempts++;
|
||||
|
||||
if (typeof window.igv !== "undefined" && typeof window.igv.createBrowser === "function") {
|
||||
console.log('IGV is ready');
|
||||
resolve();
|
||||
} else if (attempts >= maxAttempts) {
|
||||
console.error('IGV loading timeout');
|
||||
reject(new Error('IGV loading timeout'));
|
||||
} else {
|
||||
setTimeout(checkIGV, 100);
|
||||
}
|
||||
};
|
||||
checkIGV();
|
||||
});
|
||||
},
|
||||
loadCopyNumberTrack() {
|
||||
if (this.browser) {
|
||||
this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
|
||||
this.browser.loadTrackList([
|
||||
{
|
||||
url: "https://s3.amazonaws.com/igv.org.demo/GBM-TP.seg.gz",
|
||||
indexed: false,
|
||||
isLog: true,
|
||||
name: "GBM Copy # (TCGA Broad GDAC)",
|
||||
},
|
||||
]);
|
||||
}
|
||||
this.closeDropdown();
|
||||
},
|
||||
loadDbSnpTrack() {
|
||||
if (this.browser) {
|
||||
this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
|
||||
this.browser.loadTrackList([
|
||||
{
|
||||
type: "annotation",
|
||||
format: "bed",
|
||||
url: "https://data.broadinstitute.org/igvdata/annotations/hg19/dbSnp/snp137.hg19.bed.gz",
|
||||
indexURL:
|
||||
"https://data.broadinstitute.org/igvdata/annotations/hg19/dbSnp/snp137.hg19.bed.gz.tbi",
|
||||
visibilityWindow: 200000,
|
||||
name: "dbSNP 137",
|
||||
},
|
||||
]);
|
||||
}
|
||||
this.closeDropdown();
|
||||
},
|
||||
loadBigWigTrack() {
|
||||
if (this.browser) {
|
||||
this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
|
||||
this.browser.loadTrackList([
|
||||
{
|
||||
type: "wig",
|
||||
format: "bigwig",
|
||||
url: "https://s3.amazonaws.com/igv.broadinstitute.org/data/hg19/encode/wgEncodeBroadHistoneGm12878H3k4me3StdSig.bigWig",
|
||||
name: "Gm12878H3k4me3",
|
||||
},
|
||||
]);
|
||||
}
|
||||
this.closeDropdown();
|
||||
},
|
||||
loadBamTrack() {
|
||||
if (this.browser) {
|
||||
this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
|
||||
this.browser.loadTrackList([
|
||||
{
|
||||
type: "alignment",
|
||||
format: "bam",
|
||||
url: "https://1000genomes.s3.amazonaws.com/phase3/data/HG02450/alignment/HG02450.mapped.ILLUMINA.bwa.ACB.low_coverage.20120522.bam",
|
||||
indexURL:
|
||||
"https://1000genomes.s3.amazonaws.com/phase3/data/HG02450/alignment/HG02450.mapped.ILLUMINA.bwa.ACB.low_coverage.20120522.bam.bai",
|
||||
name: "HG02450",
|
||||
},
|
||||
]);
|
||||
}
|
||||
this.closeDropdown();
|
||||
},
|
||||
// input 박스에서 직접 좌표 입력 시 호출
|
||||
onInputPosChange(which, val) {
|
||||
let locusStart, locusEnd, chrom;
|
||||
if (typeof this.locus === 'string') {
|
||||
const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
|
||||
if (!match) return;
|
||||
chrom = match[1];
|
||||
locusStart = parseInt(match[2].replace(/,/g, ''), 10);
|
||||
locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
|
||||
} else if (typeof this.locus === 'object' && this.locus.chr && this.locus.start && this.locus.end) {
|
||||
chrom = this.locus.chr;
|
||||
locusStart = parseInt(String(this.locus.start).replace(/,/g, ''), 10);
|
||||
locusEnd = parseInt(String(this.locus.end).replace(/,/g, ''), 10);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
let start = this.startPos, end = this.endPos;
|
||||
if (which === 'start') {
|
||||
start = Number(val);
|
||||
} else {
|
||||
end = Number(val);
|
||||
}
|
||||
// 범위 보정
|
||||
if (start >= end) return;
|
||||
// ratio 계산
|
||||
const startRatio = (start - locusStart) / (locusEnd - locusStart);
|
||||
const endRatio = (end - locusStart) / (locusEnd - locusStart);
|
||||
if (window.igvCustom && typeof window.igvCustom.setLineRatios === 'function') {
|
||||
window.igvCustom.setLineRatios(startRatio, endRatio);
|
||||
}
|
||||
if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
|
||||
window.igvCustom.setMiniMapInfo(chrom, start, end);
|
||||
}
|
||||
},
|
||||
// FASTA 파일 업로드 핸들러
|
||||
async onFastaUpload(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
console.log('FASTA 파일 업로드:', file.name, file.size);
|
||||
this.fastaName = file.name.replace(/\.[^/.]+$/, "");
|
||||
// 파란 범위 임시 저장
|
||||
const prevStartPos = this.startPos;
|
||||
const prevEndPos = this.endPos;
|
||||
try {
|
||||
// 서버에 파일 업로드
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
console.log('서버에 파일 업로드 중...');
|
||||
if (typeof window !== 'undefined') {
|
||||
const { data: _data, error: _error } = await useApi('/api/fasta/upload', {
|
||||
method: 'post',
|
||||
body: formData,
|
||||
headers: {
|
||||
// 'Content-Type'은 브라우저가 자동으로 설정하므로 명시하지 않음
|
||||
}
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`서버 업로드 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
const result = await response.json();
|
||||
console.log('서버 업로드 완료:', result);
|
||||
|
||||
// 원격 URL 사용
|
||||
this.fastaFile = result.remoteUrl;
|
||||
|
||||
// IGV 브라우저 완전 재생성
|
||||
if (this.browser) {
|
||||
if (typeof this.browser.removeAllTracks === 'function') {
|
||||
this.browser.removeAllTracks();
|
||||
}
|
||||
this.browser = null;
|
||||
}
|
||||
// igvDiv 완전히 비우기
|
||||
const igvDiv = this.$refs.igvDiv;
|
||||
if (igvDiv) {
|
||||
igvDiv.innerHTML = '';
|
||||
}
|
||||
// locus, 시퀀스 등 초기화
|
||||
this.locus = "chr1:1-100";
|
||||
this.sequence = '';
|
||||
this.displaySequence = '';
|
||||
this.startPos = null;
|
||||
this.endPos = null;
|
||||
console.log('IGV 재초기화 시작');
|
||||
await this.initIGV();
|
||||
// IGV 재초기화 후 파란 범위 복원 (딜레이 추가)
|
||||
setTimeout(() => {
|
||||
if (prevStartPos !== null && prevEndPos !== null && window.igvCustom && typeof window.igvCustom.setLineRatios === 'function') {
|
||||
// locus 파싱
|
||||
let locusStart = 1, locusEnd = 100;
|
||||
if (typeof this.locus === 'string') {
|
||||
const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
|
||||
if (match) {
|
||||
locusStart = parseInt(match[2].replace(/,/g, ''), 10);
|
||||
locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
|
||||
}
|
||||
}
|
||||
const startRatio = (prevStartPos - locusStart) / (locusEnd - locusStart);
|
||||
const endRatio = (prevEndPos - locusStart) / (locusEnd - locusStart);
|
||||
window.igvCustom.setLineRatios(startRatio, endRatio);
|
||||
if (typeof window.waitForIGVAndInit === 'function') {
|
||||
window.waitForIGVAndInit();
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('파일 업로드 오류:', error);
|
||||
alert(`파일 업로드 실패: ${error.message}\n\n서버 API가 설정되지 않았거나, 파일이 너무 클 수 있습니다.`);
|
||||
|
||||
// 서버 업로드 실패 시 기존 방식으로 폴백
|
||||
if (file.size <= 100 * 1024 * 1024) { // 100MB 이하만
|
||||
this.largeFile = file;
|
||||
this.showSplitOption = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
// 대용량 파일 분할
|
||||
async splitLargeFile() {
|
||||
if (!this.largeFile) return;
|
||||
|
||||
try {
|
||||
const chunkSize = 50 * 1024 * 1024; // 50MB 청크
|
||||
const totalChunks = Math.ceil(this.largeFile.size / chunkSize);
|
||||
|
||||
alert(`파일을 ${totalChunks}개 청크로 분할합니다. 각 청크는 약 50MB입니다.`);
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * chunkSize;
|
||||
const end = Math.min(start + chunkSize, this.largeFile.size);
|
||||
const chunk = this.largeFile.slice(start, end);
|
||||
|
||||
// 청크를 파일로 다운로드
|
||||
const url = URL.createObjectURL(chunk);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${this.largeFile.name.replace(/\.[^/.]+$/, "")}_part${i + 1}.fasta`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// 진행률 표시
|
||||
const progress = ((i + 1) / totalChunks * 100).toFixed(1);
|
||||
console.log(`분할 진행률: ${progress}%`);
|
||||
}
|
||||
|
||||
alert(`파일 분할 완료! ${totalChunks}개의 파일이 다운로드되었습니다.\n각 파일을 개별적으로 업로드하여 사용하세요.`);
|
||||
this.showSplitOption = false;
|
||||
this.largeFile = null;
|
||||
|
||||
} catch (error) {
|
||||
console.error('파일 분할 중 오류:', error);
|
||||
alert('파일 분할 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
// 범위 내 염기서열 교체
|
||||
replaceSequenceInRange() {
|
||||
if (!this.sequence || !this.editSequence) {
|
||||
alert('염기서열 또는 입력값이 없습니다.');
|
||||
return;
|
||||
}
|
||||
if (this.editSequence.length !== (this.endPos - this.startPos)) {
|
||||
alert('입력한 염기서열 길이가 범위와 일치해야 합니다.');
|
||||
return;
|
||||
}
|
||||
// 기존 염기서열을 교체
|
||||
// 실제 FASTA 파일은 수정하지 않고, 화면에만 반영
|
||||
this.sequence = this.editSequence;
|
||||
this.displaySequence = this.isReverse ? this.getComplement(this.sequence) : this.sequence;
|
||||
alert('범위 내 염기서열이 변경되었습니다. (화면에만 반영)');
|
||||
},
|
||||
// 서버 파일 목록 토글
|
||||
toggleServerFilesDropdown() {
|
||||
this.isServerFilesDropdownOpen = !this.isServerFilesDropdownOpen;
|
||||
if (this.isServerFilesDropdownOpen) {
|
||||
this.loadServerFiles();
|
||||
}
|
||||
},
|
||||
// 서버 파일 목록 로드
|
||||
async loadServerFiles() {
|
||||
this.loadingFiles = true;
|
||||
try {
|
||||
const response = await fetch('http://localhost/api/fasta/files');
|
||||
if (!response.ok) {
|
||||
throw new Error(`서버 파일 목록 로드 실패: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
this.serverFiles = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : (Array.isArray(result) ? result : []);
|
||||
} catch (error) {
|
||||
console.error('서버 파일 목록 로드 중 오류:', error);
|
||||
this.serverFiles = [];
|
||||
alert(`서버 파일 목록을 불러오는데 실패했습니다: ${error.message}`);
|
||||
} finally {
|
||||
this.loadingFiles = false;
|
||||
}
|
||||
},
|
||||
// 서버 파일 선택
|
||||
selectServerFile(file) {
|
||||
const prevStartPos = this.startPos;
|
||||
const prevEndPos = this.endPos;
|
||||
this.selectedServerFile = file;
|
||||
this.fastaFile = file.fileUrl; // 선택된 파일의 URL을 fastaFile에 저장
|
||||
this.fastaName = file.fileName; // 파일명 업데이트
|
||||
|
||||
// IGV 브라우저 완전 재생성
|
||||
if (this.browser) {
|
||||
if (typeof this.browser.removeAllTracks === 'function') {
|
||||
this.browser.removeAllTracks();
|
||||
}
|
||||
this.browser = null;
|
||||
}
|
||||
// igvDiv 완전히 비우기
|
||||
const igvDiv = this.$refs.igvDiv;
|
||||
if (igvDiv) {
|
||||
igvDiv.innerHTML = '';
|
||||
}
|
||||
// locus, 시퀀스 등 초기화
|
||||
this.locus = "chr1:1-100";
|
||||
this.sequence = '';
|
||||
this.displaySequence = '';
|
||||
this.startPos = null;
|
||||
this.endPos = null;
|
||||
console.log('IGV 재초기화 시작');
|
||||
this.initIGV().then(() => {
|
||||
// IGV 재초기화 후 파란 범위 복원 (딜레이 추가)
|
||||
setTimeout(() => {
|
||||
if (prevStartPos !== null && prevEndPos !== null && window.igvCustom && typeof window.igvCustom.setLineRatios === 'function') {
|
||||
// locus 파싱
|
||||
let locusStart = 1, locusEnd = 100;
|
||||
if (typeof this.locus === 'string') {
|
||||
const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
|
||||
if (match) {
|
||||
locusStart = parseInt(match[2].replace(/,/g, ''), 10);
|
||||
locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
|
||||
}
|
||||
}
|
||||
const startRatio = (prevStartPos - locusStart) / (locusEnd - locusStart);
|
||||
const endRatio = (prevEndPos - locusStart) / (locusEnd - locusStart);
|
||||
window.igvCustom.setLineRatios(startRatio, endRatio);
|
||||
if (typeof window.igvCustom.setupLines === 'function') {
|
||||
window.igvCustom.setupLines();
|
||||
}
|
||||
if (typeof window.waitForIGVAndInit === 'function') {
|
||||
window.waitForIGVAndInit();
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
this.isServerFilesDropdownOpen = false; // 드롭다운 닫기
|
||||
},
|
||||
// 파일 크기 포맷
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
},
|
||||
// 날짜 포맷
|
||||
formatDate(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.reverse-active {
|
||||
background: #e6f0ff !important;
|
||||
color: #0056b3 !important;
|
||||
font-weight: bold;
|
||||
border: 2px solid #0056b3 !important;
|
||||
box-shadow: 0 0 4px #b3d1ff;
|
||||
}
|
||||
|
||||
.igv-control-btn.reverse-active {
|
||||
background: #0056b3 !important;
|
||||
color: #fff !important;
|
||||
border: 2px solid #003366 !important;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.igv-control-input {
|
||||
border: 1.5px solid #007bff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 5px;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: border 0.2s;
|
||||
}
|
||||
|
||||
.igv-control-input:focus {
|
||||
border: 2px solid #0056b3;
|
||||
}
|
||||
|
||||
.igv-control-btn {
|
||||
border: 1.5px solid #007bff;
|
||||
background: #f5faff;
|
||||
color: #007bff;
|
||||
border-radius: 5px;
|
||||
padding: 6px 16px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.igv-control-btn:hover {
|
||||
background: #007bff;
|
||||
color: #fff;
|
||||
border: 2px solid #0056b3;
|
||||
}
|
||||
|
||||
/* 서버 파일 목록 드롭다운 스타일 */
|
||||
.server-files-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.15);
|
||||
padding: 5px 0;
|
||||
margin-top: 5px;
|
||||
z-index: 1000;
|
||||
max-height: 300px; /* 스크롤 가능하도록 높이 제한 */
|
||||
overflow-y: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.server-files-dropdown .dropdown-item {
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.server-files-dropdown .dropdown-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.server-files-dropdown .dropdown-item.selected {
|
||||
background-color: #e6f0ff;
|
||||
font-weight: bold;
|
||||
color: #0056b3;
|
||||
}
|
||||
|
||||
.server-files-dropdown .file-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.server-files-dropdown .file-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.server-files-dropdown .file-item.selected {
|
||||
background-color: #e6f0ff;
|
||||
font-weight: bold;
|
||||
color: #0056b3;
|
||||
}
|
||||
|
||||
.server-files-dropdown .file-name {
|
||||
flex-grow: 1;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.server-files-dropdown .file-info {
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user