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