Files
bio_frontend/pages/test/igv2.vue
leejisun9 2ec34ff321 mearge
2025-09-12 11:10:43 +09:00

955 lines
35 KiB
Vue

<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>