955 lines
		
	
	
		
			35 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
		
		
			
		
	
	
			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> 
							 |