[페이지 원복] 기존 테스트 페이지 원복복
This commit is contained in:
		
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -144,6 +144,3 @@ dist
 | 
				
			|||||||
# TODO: where does this rule come from?
 | 
					# TODO: where does this rule come from?
 | 
				
			||||||
docs/_book
 | 
					docs/_book
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# TODO: where does this rule come from?
 | 
					 | 
				
			||||||
test/
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										413
									
								
								pages/[tabId]/test/culture-graph-multi.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										413
									
								
								pages/[tabId]/test/culture-graph-multi.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,413 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div>
 | 
				
			||||||
 | 
					    <PageDescription>
 | 
				
			||||||
 | 
					      <h1>배양 그래프 (멀티)</h1>
 | 
				
			||||||
 | 
					      <div class="box">
 | 
				
			||||||
 | 
					        <h2>1. 그래프 구성 및 기능</h2>
 | 
				
			||||||
 | 
					        <ul>
 | 
				
			||||||
 | 
					          <li>총 30개의 그래프를 한 화면에 동시에 볼 수 있습니다.</li>
 | 
				
			||||||
 | 
					          <li>렌더링 최적화를 위해 최소한의 기능만 제공됩니다.</li>
 | 
				
			||||||
 | 
					        </ul>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="box">
 | 
				
			||||||
 | 
					        <h2>2. 출력되는 데이터 개수</h2>
 | 
				
			||||||
 | 
					        <ul>
 | 
				
			||||||
 | 
					          <li>
 | 
				
			||||||
 | 
					            그래프 30개 × 시리즈 12개 × 10분 간격(100시간, 601포인트) =
 | 
				
			||||||
 | 
					            <span class="highlight">216,360개</span>
 | 
				
			||||||
 | 
					          </li>
 | 
				
			||||||
 | 
					          <li>화면 렌더링 시간은 약 600ms ~ 800ms 정도 소요됩니다.</li>
 | 
				
			||||||
 | 
					        </ul>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="box">
 | 
				
			||||||
 | 
					        <h2>3. 그래프 복사</h2>
 | 
				
			||||||
 | 
					        <ul>
 | 
				
			||||||
 | 
					          <li>
 | 
				
			||||||
 | 
					            <span class="highlight">그래프 복사</span> 버튼을 클릭하면 그래프가
 | 
				
			||||||
 | 
					            클립보드에 복사됩니다.
 | 
				
			||||||
 | 
					          </li>
 | 
				
			||||||
 | 
					        </ul>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </PageDescription>
 | 
				
			||||||
 | 
					    <div class="multi-graph-list">
 | 
				
			||||||
 | 
					      <div v-for="series in seriesList" :key="series.name" class="single-graph">
 | 
				
			||||||
 | 
					        <div class="graph-title">
 | 
				
			||||||
 | 
					          {{ series.name }}<span v-if="series.unit"> {{ series.unit }}</span>
 | 
				
			||||||
 | 
					          <button class="copy-btn" @click="copyChartImage(series.name)">
 | 
				
			||||||
 | 
					            그래프 복사
 | 
				
			||||||
 | 
					          </button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div
 | 
				
			||||||
 | 
					          :ref="el => setGraphRef(series.name, el as Element | null)"
 | 
				
			||||||
 | 
					          class="echarts-graph"
 | 
				
			||||||
 | 
					        ></div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, onMounted, nextTick } from "vue";
 | 
				
			||||||
 | 
					import * as echarts from "echarts";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// 파스텔 컬러 15개 반복
 | 
				
			||||||
 | 
					const pastelColors = [
 | 
				
			||||||
 | 
					  "#A3D8F4",
 | 
				
			||||||
 | 
					  "#F7B7A3",
 | 
				
			||||||
 | 
					  "#B5EAD7",
 | 
				
			||||||
 | 
					  "#FFDAC1",
 | 
				
			||||||
 | 
					  "#C7CEEA",
 | 
				
			||||||
 | 
					  "#FFF1BA",
 | 
				
			||||||
 | 
					  "#FFB7B2",
 | 
				
			||||||
 | 
					  "#B4A7D6",
 | 
				
			||||||
 | 
					  "#AED9E0",
 | 
				
			||||||
 | 
					  "#FFC3A0",
 | 
				
			||||||
 | 
					  "#E2F0CB",
 | 
				
			||||||
 | 
					  "#FFB347",
 | 
				
			||||||
 | 
					  "#C1C8E4",
 | 
				
			||||||
 | 
					  "#FFFACD",
 | 
				
			||||||
 | 
					  "#FFD1DC",
 | 
				
			||||||
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// 그래프 개수 변수로 분리
 | 
				
			||||||
 | 
					const NUM_GRAPHS = 30;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// 30개 y축 정보 (culture-graph.vue와 동일하게 수정)
 | 
				
			||||||
 | 
					const yAxisList = [
 | 
				
			||||||
 | 
					  { name: "ORP", unit: "", color: pastelColors[0], min: 0, max: 1000 },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    name: "Air flow",
 | 
				
			||||||
 | 
					    unit: "(L/min)",
 | 
				
			||||||
 | 
					    color: pastelColors[1],
 | 
				
			||||||
 | 
					    min: 0,
 | 
				
			||||||
 | 
					    max: 30,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  { name: "DO", unit: "", color: pastelColors[2], min: 0, max: 200 },
 | 
				
			||||||
 | 
					  { name: "Feed TK1", unit: "(L)", color: pastelColors[3], min: 0, max: 10 },
 | 
				
			||||||
 | 
					  { name: "Feed TK2", unit: "(L)", color: pastelColors[4], min: 0, max: 10 },
 | 
				
			||||||
 | 
					  { name: "pH", unit: "", color: pastelColors[5], min: 6.0, max: 8.0 },
 | 
				
			||||||
 | 
					  { name: "Pressure", unit: "(bar)", color: pastelColors[6], min: 0, max: 2 },
 | 
				
			||||||
 | 
					  { name: "RPM", unit: "", color: pastelColors[7], min: 0, max: 3000 },
 | 
				
			||||||
 | 
					  { name: "CO2", unit: "(%)", color: pastelColors[8], min: 0, max: 10 },
 | 
				
			||||||
 | 
					  { name: "JAR Vol", unit: "(L)", color: pastelColors[9], min: 0, max: 20 },
 | 
				
			||||||
 | 
					  { name: "WEIGHT", unit: "(kg)", color: pastelColors[10], min: 0, max: 100 },
 | 
				
			||||||
 | 
					  { name: "O2", unit: "(%)", color: pastelColors[11], min: 0, max: 100 },
 | 
				
			||||||
 | 
					  { name: "NV", unit: "", color: pastelColors[12], min: 0, max: 10 },
 | 
				
			||||||
 | 
					  { name: "NIR", unit: "", color: pastelColors[13], min: 0, max: 10 },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    name: "Temperature",
 | 
				
			||||||
 | 
					    unit: "(℃)",
 | 
				
			||||||
 | 
					    color: pastelColors[14],
 | 
				
			||||||
 | 
					    min: 0,
 | 
				
			||||||
 | 
					    max: 50,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  { name: "Humidity", unit: "(%)", color: pastelColors[0], min: 0, max: 100 },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    name: "Flow Rate",
 | 
				
			||||||
 | 
					    unit: "(L/min)",
 | 
				
			||||||
 | 
					    color: pastelColors[1],
 | 
				
			||||||
 | 
					    min: 0,
 | 
				
			||||||
 | 
					    max: 60,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    name: "Conductivity",
 | 
				
			||||||
 | 
					    unit: "(μS/cm)",
 | 
				
			||||||
 | 
					    color: pastelColors[2],
 | 
				
			||||||
 | 
					    min: 0,
 | 
				
			||||||
 | 
					    max: 600,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  { name: "Turbidity", unit: "(NTU)", color: pastelColors[3], min: 0, max: 12 },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    name: "Dissolved Solids",
 | 
				
			||||||
 | 
					    unit: "(mg/L)",
 | 
				
			||||||
 | 
					    color: pastelColors[4],
 | 
				
			||||||
 | 
					    min: 0,
 | 
				
			||||||
 | 
					    max: 250,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    name: "Alkalinity",
 | 
				
			||||||
 | 
					    unit: "(mg/L)",
 | 
				
			||||||
 | 
					    color: pastelColors[5],
 | 
				
			||||||
 | 
					    min: 0,
 | 
				
			||||||
 | 
					    max: 120,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    name: "Hardness",
 | 
				
			||||||
 | 
					    unit: "(mg/L)",
 | 
				
			||||||
 | 
					    color: pastelColors[6],
 | 
				
			||||||
 | 
					    min: 0,
 | 
				
			||||||
 | 
					    max: 100,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  { name: "Chlorine", unit: "(mg/L)", color: pastelColors[7], min: 0, max: 6 },
 | 
				
			||||||
 | 
					  { name: "Nitrate", unit: "(mg/L)", color: pastelColors[8], min: 0, max: 25 },
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    name: "Phosphate",
 | 
				
			||||||
 | 
					    unit: "(mg/L)",
 | 
				
			||||||
 | 
					    color: pastelColors[9],
 | 
				
			||||||
 | 
					    min: 0,
 | 
				
			||||||
 | 
					    max: 12,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  { name: "Sulfate", unit: "(mg/L)", color: pastelColors[10], min: 0, max: 60 },
 | 
				
			||||||
 | 
					  { name: "Ammonia", unit: "(mg/L)", color: pastelColors[11], min: 0, max: 18 },
 | 
				
			||||||
 | 
					  { name: "Nitrite", unit: "(mg/L)", color: pastelColors[12], min: 0, max: 6 },
 | 
				
			||||||
 | 
					  { name: "BOD", unit: "(mg/L)", color: pastelColors[13], min: 0, max: 35 },
 | 
				
			||||||
 | 
					  { name: "COD", unit: "(mg/L)", color: pastelColors[14], min: 0, max: 120 },
 | 
				
			||||||
 | 
					];
 | 
				
			||||||
 | 
					// NUM_GRAPHS까지 자동 생성
 | 
				
			||||||
 | 
					if (yAxisList.length < NUM_GRAPHS) {
 | 
				
			||||||
 | 
					  for (let i = yAxisList.length; i < NUM_GRAPHS; i++) {
 | 
				
			||||||
 | 
					    yAxisList.push({
 | 
				
			||||||
 | 
					      name: `Graph ${i + 1}`,
 | 
				
			||||||
 | 
					      unit: "",
 | 
				
			||||||
 | 
					      color: pastelColors[i % pastelColors.length],
 | 
				
			||||||
 | 
					      min: 0,
 | 
				
			||||||
 | 
					      max: 100 + (i % 10) * 100, // 100, 200, ... 1000 반복
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// x축 간격(초) - 5초 또는 1분(60초) 등으로 변경 가능
 | 
				
			||||||
 | 
					// 예시: const X_INTERVAL = 5;   // 5초 간격
 | 
				
			||||||
 | 
					//      const X_INTERVAL = 60;  // 1분 간격
 | 
				
			||||||
 | 
					const X_INTERVAL = 600; // 필요시 60으로 변경
 | 
				
			||||||
 | 
					// 100시간치, X_INTERVAL 간격
 | 
				
			||||||
 | 
					const xLabels = Array.from(
 | 
				
			||||||
 | 
					  { length: (100 * 60 * 60) / X_INTERVAL + 1 },
 | 
				
			||||||
 | 
					  (_, i) => i * X_INTERVAL
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// 부드러운 곡선형 + 구간별 변화 가데이터 생성 함수 (xLabels를 파라미터로 받도록 변경)
 | 
				
			||||||
 | 
					function smoothData(
 | 
				
			||||||
 | 
					  min: number,
 | 
				
			||||||
 | 
					  max: number,
 | 
				
			||||||
 | 
					  xLabels: number[],
 | 
				
			||||||
 | 
					  phase = 0,
 | 
				
			||||||
 | 
					  _amp = 1, // amp는 사용하지 않으므로 _amp로 변경
 | 
				
			||||||
 | 
					  offset = 0,
 | 
				
			||||||
 | 
					  seriesIndex: number
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  let _prevValue = 0.5;
 | 
				
			||||||
 | 
					  const values = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 데이터 범위 축소: min+10% ~ max-10%
 | 
				
			||||||
 | 
					  const rangeMin = min + (max - min) * 0.1;
 | 
				
			||||||
 | 
					  const rangeMax = max - (max - min) * 0.1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 시리즈별 패턴 다양화: 증가/감소/진동/트렌드 섞기
 | 
				
			||||||
 | 
					  // 패턴 결정 (시리즈 인덱스에 따라)
 | 
				
			||||||
 | 
					  const trendType = seriesIndex % 4; // 0:증가, 1:감소, 2:진동, 3:랜덤
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 진동폭(ampVar, randomFactor)은 원복 (더 낮게)
 | 
				
			||||||
 | 
					  const ampVar = 0.2 + (seriesIndex % 5) * 0.1; // 0.2~0.6
 | 
				
			||||||
 | 
					  const randomFactor = 0.01 + 0.01 * (seriesIndex % 4); // 0.01~0.04
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  for (let i = 0; i < xLabels.length; i++) {
 | 
				
			||||||
 | 
					    const t = i / (xLabels.length - 1);
 | 
				
			||||||
 | 
					    let base;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 오르내림(파동 주기/계수)을 키움 (파동이 더 자주, 더 크게)
 | 
				
			||||||
 | 
					    if (trendType === 0) {
 | 
				
			||||||
 | 
					      base = 0.2 + 0.6 * t + 0.13 * Math.sin(phase + t * Math.PI * ampVar * 8);
 | 
				
			||||||
 | 
					    } else if (trendType === 1) {
 | 
				
			||||||
 | 
					      base = 0.8 - 0.6 * t + 0.13 * Math.cos(phase + t * Math.PI * ampVar * 8);
 | 
				
			||||||
 | 
					    } else if (trendType === 2) {
 | 
				
			||||||
 | 
					      base = 0.5 + 0.22 * Math.sin(phase + t * Math.PI * ampVar * 12);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      base = 0.5 + 0.08 * Math.sin(phase + t * Math.PI * ampVar * 6) + 0.2 * t;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 노이즈는 낮게
 | 
				
			||||||
 | 
					    const noise = (Math.random() - 0.5) * randomFactor;
 | 
				
			||||||
 | 
					    base += noise;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    base = Math.max(0, Math.min(1, base));
 | 
				
			||||||
 | 
					    const value = +(rangeMin + (rangeMax - rangeMin) * base + offset).toFixed(
 | 
				
			||||||
 | 
					      2
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    _prevValue = base;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    values.push([xLabels[i], value]);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return values;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// 시리즈 데이터 30개 생성 → 각 그래프마다 12개 라인(시리즈)로 확장
 | 
				
			||||||
 | 
					const NUM_LINES_PER_GRAPH = 12;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const seriesList = yAxisList.map((y, idx) => {
 | 
				
			||||||
 | 
					  // 12개 라인(시리즈) 생성
 | 
				
			||||||
 | 
					  const lines = Array.from({ length: NUM_LINES_PER_GRAPH }, (_, lineIdx) => ({
 | 
				
			||||||
 | 
					    name: String(lineIdx + 1), // "1", "2", ... "12"
 | 
				
			||||||
 | 
					    color: pastelColors[lineIdx % pastelColors.length],
 | 
				
			||||||
 | 
					    data: smoothData(
 | 
				
			||||||
 | 
					      y.min,
 | 
				
			||||||
 | 
					      y.max,
 | 
				
			||||||
 | 
					      xLabels,
 | 
				
			||||||
 | 
					      Math.PI / (idx + 1 + lineIdx), // phase를 다르게
 | 
				
			||||||
 | 
					      1,
 | 
				
			||||||
 | 
					      0,
 | 
				
			||||||
 | 
					      idx + lineIdx * 2 // 패턴 다양화
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					  }));
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    name: y.name,
 | 
				
			||||||
 | 
					    unit: y.unit,
 | 
				
			||||||
 | 
					    color: y.color,
 | 
				
			||||||
 | 
					    min: y.min,
 | 
				
			||||||
 | 
					    max: y.max,
 | 
				
			||||||
 | 
					    lines, // 12개 시리즈 배열
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// 차트 렌더링
 | 
				
			||||||
 | 
					const graphRefs = ref<Record<string, HTMLDivElement | null>>({});
 | 
				
			||||||
 | 
					const chartInstances = ref<Record<string, echarts.ECharts | null>>({});
 | 
				
			||||||
 | 
					function setGraphRef(name: string, el: Element | null) {
 | 
				
			||||||
 | 
					  if (el && el instanceof HTMLDivElement) {
 | 
				
			||||||
 | 
					    graphRefs.value[name] = el;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function renderAllCharts() {
 | 
				
			||||||
 | 
					  nextTick(() => {
 | 
				
			||||||
 | 
					    seriesList.forEach(series => {
 | 
				
			||||||
 | 
					      const el = graphRefs.value[series.name];
 | 
				
			||||||
 | 
					      if (!el) return;
 | 
				
			||||||
 | 
					      if (chartInstances.value[series.name]) {
 | 
				
			||||||
 | 
					        chartInstances.value[series.name]?.dispose();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      const chart = echarts.init(el);
 | 
				
			||||||
 | 
					      chart.setOption({
 | 
				
			||||||
 | 
					        grid: { left: 50, right: 20, top: 30, bottom: 40, containLabel: true },
 | 
				
			||||||
 | 
					        xAxis: {
 | 
				
			||||||
 | 
					          type: "value",
 | 
				
			||||||
 | 
					          axisLabel: {
 | 
				
			||||||
 | 
					            fontSize: 11,
 | 
				
			||||||
 | 
					            color: "#666",
 | 
				
			||||||
 | 
					            formatter: (v: number) =>
 | 
				
			||||||
 | 
					              `${Math.floor(v / 3600)}:${String(Math.floor((v % 3600) / 60)).padStart(2, "0")}`,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        yAxis: {
 | 
				
			||||||
 | 
					          type: "value",
 | 
				
			||||||
 | 
					          min: series.min,
 | 
				
			||||||
 | 
					          max: series.max,
 | 
				
			||||||
 | 
					          name: series.unit ? `${series.name} ${series.unit}` : series.name,
 | 
				
			||||||
 | 
					          nameTextStyle: {
 | 
				
			||||||
 | 
					            color: series.color,
 | 
				
			||||||
 | 
					            fontWeight: "bold",
 | 
				
			||||||
 | 
					            fontSize: 12,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          axisLabel: { color: series.color, fontWeight: "bold", fontSize: 12 },
 | 
				
			||||||
 | 
					          axisLine: { lineStyle: { color: series.color, width: 2 } },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        series: series.lines.map(line => ({
 | 
				
			||||||
 | 
					          name: line.name,
 | 
				
			||||||
 | 
					          data: line.data,
 | 
				
			||||||
 | 
					          type: "line",
 | 
				
			||||||
 | 
					          lineStyle: { color: line.color, width: 1 },
 | 
				
			||||||
 | 
					          symbol: "none",
 | 
				
			||||||
 | 
					        })),
 | 
				
			||||||
 | 
					        animation: false,
 | 
				
			||||||
 | 
					        legend: {
 | 
				
			||||||
 | 
					          show: true,
 | 
				
			||||||
 | 
					          bottom: 8, // 하단에 위치
 | 
				
			||||||
 | 
					          left: "center", // 중앙 정렬
 | 
				
			||||||
 | 
					          orient: "horizontal",
 | 
				
			||||||
 | 
					          itemWidth: 18,
 | 
				
			||||||
 | 
					          itemHeight: 10,
 | 
				
			||||||
 | 
					          icon: "circle",
 | 
				
			||||||
 | 
					          textStyle: { fontSize: 12, padding: [0, 4, 0, 0] },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        tooltip: { show: true, trigger: "axis" },
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      chartInstances.value[series.name] = chart;
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// 이미지 복사 함수 추가
 | 
				
			||||||
 | 
					async function copyImageToClipboard(dataUrl: string) {
 | 
				
			||||||
 | 
					  const res = await fetch(dataUrl);
 | 
				
			||||||
 | 
					  const blob = await res.blob();
 | 
				
			||||||
 | 
					  await navigator.clipboard.write([
 | 
				
			||||||
 | 
					    new window.ClipboardItem({ [blob.type]: blob }),
 | 
				
			||||||
 | 
					  ]);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function copyChartImage(name: string) {
 | 
				
			||||||
 | 
					  const chart = chartInstances.value[name];
 | 
				
			||||||
 | 
					  if (!chart) return;
 | 
				
			||||||
 | 
					  const dataUrl = chart.getDataURL({
 | 
				
			||||||
 | 
					    type: "png",
 | 
				
			||||||
 | 
					    pixelRatio: 2,
 | 
				
			||||||
 | 
					    backgroundColor: "#fff",
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    await copyImageToClipboard(dataUrl);
 | 
				
			||||||
 | 
					    alert("그래프가 클립보드에 복사되었습니다!");
 | 
				
			||||||
 | 
					  } catch {
 | 
				
			||||||
 | 
					    alert("클립보드 복사에 실패했습니다. 브라우저 권한을 확인하세요.");
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(() => {
 | 
				
			||||||
 | 
					  renderAllCharts();
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.multi-graph-list {
 | 
				
			||||||
 | 
					  display: grid;
 | 
				
			||||||
 | 
					  grid-template-columns: repeat(4, 1fr);
 | 
				
			||||||
 | 
					  gap: 20px 1.5%;
 | 
				
			||||||
 | 
					  padding: 16px 0;
 | 
				
			||||||
 | 
					  align-content: flex-start;
 | 
				
			||||||
 | 
					  flex-direction: row;
 | 
				
			||||||
 | 
					  background: #fff;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.single-graph {
 | 
				
			||||||
 | 
					  min-height: 260px;
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  flex: 0 0 100%;
 | 
				
			||||||
 | 
					  max-width: 100%;
 | 
				
			||||||
 | 
					  background: #fff;
 | 
				
			||||||
 | 
					  border: 1px solid #e0e0e0;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
 | 
				
			||||||
 | 
					  padding: 10px 16px 10px 16px;
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  flex-direction: column;
 | 
				
			||||||
 | 
					  margin-bottom: 20px;
 | 
				
			||||||
 | 
					  align-items: flex-start;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.graph-title {
 | 
				
			||||||
 | 
					  font-weight: bold;
 | 
				
			||||||
 | 
					  font-size: 1.05rem;
 | 
				
			||||||
 | 
					  color: #1976d2;
 | 
				
			||||||
 | 
					  margin-bottom: 6px;
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.echarts-graph {
 | 
				
			||||||
 | 
					  min-height: 200px;
 | 
				
			||||||
 | 
					  height: 200px !important;
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.copy-btn {
 | 
				
			||||||
 | 
					  margin-left: 10px;
 | 
				
			||||||
 | 
					  font-size: 0.9rem;
 | 
				
			||||||
 | 
					  padding: 2px 8px;
 | 
				
			||||||
 | 
					  border: 1px solid #1976d2;
 | 
				
			||||||
 | 
					  background: #e3f2fd;
 | 
				
			||||||
 | 
					  color: #1976d2;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  transition: background 0.2s;
 | 
				
			||||||
 | 
					  float: right;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.copy-btn:hover {
 | 
				
			||||||
 | 
					  background: #bbdefb;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										32
									
								
								pages/[tabId]/test/culture-graph-tab.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								pages/[tabId]/test/culture-graph-tab.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div>
 | 
				
			||||||
 | 
					    <PageDescription>
 | 
				
			||||||
 | 
					      <h1>배양 그래프 (탭)</h1>
 | 
				
			||||||
 | 
					      <div class="box">
 | 
				
			||||||
 | 
					        <h2>1. 그래프 구성 및 기능</h2>
 | 
				
			||||||
 | 
					        <ul>
 | 
				
			||||||
 | 
					          <li>여러 개의 배양 그래프를 탭으로 전환하여 볼 수 있습니다.</li>
 | 
				
			||||||
 | 
					          <li>각 탭은 독립적인 그래프를 표시합니다.</li>
 | 
				
			||||||
 | 
					        </ul>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="box">
 | 
				
			||||||
 | 
					        <h2>2. 출력되는 데이터 개수</h2>
 | 
				
			||||||
 | 
					        <ul>
 | 
				
			||||||
 | 
					          <li>
 | 
				
			||||||
 | 
					            탭 4개 × 시리즈 30개 × 1분 간격(100시간, 6,001포인트) =
 | 
				
			||||||
 | 
					            <span class="highlight">각 탭당 180,030개</span>
 | 
				
			||||||
 | 
					          </li>
 | 
				
			||||||
 | 
					          <li>전체(4개 탭 합산): <span class="highlight">720,120개</span></li>
 | 
				
			||||||
 | 
					          <li>
 | 
				
			||||||
 | 
					            화면 초기 렌더링 시간은 약 300ms ~ 400ms 정도 소요되며, 탭
 | 
				
			||||||
 | 
					            이동시에는 거의 딜레이 없이 화면이 출력되고 있습니다.
 | 
				
			||||||
 | 
					          </li>
 | 
				
			||||||
 | 
					        </ul>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </PageDescription>
 | 
				
			||||||
 | 
					    <BatchTabs />
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import BatchTabs from "~/components/BatchTabs.vue";
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
							
								
								
									
										1648
									
								
								pages/[tabId]/test/culture-graph.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1648
									
								
								pages/[tabId]/test/culture-graph.vue
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										955
									
								
								pages/[tabId]/test/igv2.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										955
									
								
								pages/[tabId]/test/igv2.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,955 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div id="app" style="position: relative;">
 | 
				
			||||||
 | 
					    <main role="main" class="container-fluid">
 | 
				
			||||||
 | 
					      <div style="padding-top: 24px; position: relative;">
 | 
				
			||||||
 | 
					        <!-- IGV 위 컨트롤 바 -->
 | 
				
			||||||
 | 
					        <div style="display: flex; gap: 16px; align-items: center; margin-bottom: 8px; flex-wrap: wrap;">
 | 
				
			||||||
 | 
					          <!-- 파란 핸들 위치 input -->
 | 
				
			||||||
 | 
					          <label>시작 위치
 | 
				
			||||||
 | 
					            <input type="number" :value="startPos" @input="onInputPosChange('start', $event.target.value)" class="igv-control-input" style="width:110px; margin-left:4px;" />
 | 
				
			||||||
 | 
					          </label>
 | 
				
			||||||
 | 
					          <label>끝 위치
 | 
				
			||||||
 | 
					            <input type="number" :value="endPos" @input="onInputPosChange('end', $event.target.value)" class="igv-control-input" style="width:110px; margin-left:4px;" />
 | 
				
			||||||
 | 
					          </label>
 | 
				
			||||||
 | 
					          <button @click="reverseSequence" :class="['igv-control-btn', { 'reverse-active': isReverse }]" style="margin-bottom: 0; margin-left: 8px;">리버스(상보서열) 변환</button>
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          <!-- 서버 파일 목록 드롭다운 -->
 | 
				
			||||||
 | 
					          <div style="margin-left: 8px; position: relative;">
 | 
				
			||||||
 | 
					            <button @click="toggleServerFilesDropdown" class="igv-control-btn">
 | 
				
			||||||
 | 
					              서버 파일 목록
 | 
				
			||||||
 | 
					              <span v-if="isServerFilesDropdownOpen">▼</span>
 | 
				
			||||||
 | 
					              <span v-else>▶</span>
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					            <div v-if="isServerFilesDropdownOpen" class="server-files-dropdown">
 | 
				
			||||||
 | 
					              <div v-if="loadingFiles" class="dropdown-item">로딩 중...</div>
 | 
				
			||||||
 | 
					              <div v-else-if="serverFiles.length === 0" class="dropdown-item">업로드된 파일이 없습니다.</div>
 | 
				
			||||||
 | 
					              <div v-else>
 | 
				
			||||||
 | 
					                <div 
 | 
				
			||||||
 | 
					                  v-for="file in serverFiles" 
 | 
				
			||||||
 | 
					                  :key="file.fileName"
 | 
				
			||||||
 | 
					                  @click="selectServerFile(file)"
 | 
				
			||||||
 | 
					                  class="dropdown-item file-item"
 | 
				
			||||||
 | 
					                  :class="{ 'selected': selectedServerFile && selectedServerFile.fileName === file.fileName }"
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                  <div class="file-name">{{ file.fileName }}</div>
 | 
				
			||||||
 | 
					                  <div class="file-info">
 | 
				
			||||||
 | 
					                    크기: {{ formatFileSize(file.fileSize) }} | 
 | 
				
			||||||
 | 
					                    업로드: {{ formatDate(file.uploadTime) }}
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          <!-- FASTA 업로드 -->
 | 
				
			||||||
 | 
					          <label style="margin-left: 8px;">
 | 
				
			||||||
 | 
					            <input type="file" accept=".fa,.fasta" @change="onFastaUpload" style="display:none;" ref="fastaInput" />
 | 
				
			||||||
 | 
					            <button type="button" class="igv-control-btn" @click="$refs.fastaInput.click()">FASTA 업로드</button>
 | 
				
			||||||
 | 
					          </label>
 | 
				
			||||||
 | 
					          <!-- 대용량 파일 분할 -->
 | 
				
			||||||
 | 
					          <button v-if="showSplitOption" @click="splitLargeFile" class="igv-control-btn" style="margin-left:4px;">파일 분할</button>
 | 
				
			||||||
 | 
					          <!-- 범위 내 염기서열 변경 -->
 | 
				
			||||||
 | 
					          <input type="text" v-model="editSequence" placeholder="새 염기서열 입력" class="igv-control-input" style="width:180px; margin-left:8px;" />
 | 
				
			||||||
 | 
					          <button @click="replaceSequenceInRange" class="igv-control-btn" style="margin-left:4px;">범위 염기서열 변경</button>
 | 
				
			||||||
 | 
					          <!-- Tracks 드롭다운 -->
 | 
				
			||||||
 | 
					          <ul
 | 
				
			||||||
 | 
					            class="navbar-nav mr-auto"
 | 
				
			||||||
 | 
					            style="list-style:none; padding-left:0; margin:0;"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <li
 | 
				
			||||||
 | 
					              class="nav-item dropdown"
 | 
				
			||||||
 | 
					              style="position: relative; display: inline-block;"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <a
 | 
				
			||||||
 | 
					                href="#"
 | 
				
			||||||
 | 
					                id="igv-example-api-dropdown"
 | 
				
			||||||
 | 
					                @click.prevent="toggleDropdown"
 | 
				
			||||||
 | 
					                aria-haspopup="true"
 | 
				
			||||||
 | 
					                :aria-expanded="isDropdownOpen.toString()"
 | 
				
			||||||
 | 
					                style="color: black; cursor: pointer; user-select: none;"
 | 
				
			||||||
 | 
					                >Tracks</a
 | 
				
			||||||
 | 
					              >
 | 
				
			||||||
 | 
					              <ul
 | 
				
			||||||
 | 
					                v-show="isDropdownOpen"
 | 
				
			||||||
 | 
					                class="dropdown-menu"
 | 
				
			||||||
 | 
					                style="width:350px; position: absolute; top: 100%; left: 0; background: white; border: 1px solid #ccc; box-shadow: 0 2px 5px rgba(0,0,0,0.15); padding: 5px 0; margin: 0; list-style:none; z-index: 1000;"
 | 
				
			||||||
 | 
					              >
 | 
				
			||||||
 | 
					                <li>
 | 
				
			||||||
 | 
					                  <a
 | 
				
			||||||
 | 
					                    href="#"
 | 
				
			||||||
 | 
					                    @click.prevent="loadCopyNumberTrack"
 | 
				
			||||||
 | 
					                    style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
 | 
				
			||||||
 | 
					                    >Copy Number</a
 | 
				
			||||||
 | 
					                  >
 | 
				
			||||||
 | 
					                </li>
 | 
				
			||||||
 | 
					                <li>
 | 
				
			||||||
 | 
					                  <a
 | 
				
			||||||
 | 
					                    href="#"
 | 
				
			||||||
 | 
					                    @click.prevent="loadDbSnpTrack"
 | 
				
			||||||
 | 
					                    style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
 | 
				
			||||||
 | 
					                    >dbSNP 137 (bed tabix)</a
 | 
				
			||||||
 | 
					                  >
 | 
				
			||||||
 | 
					                </li>
 | 
				
			||||||
 | 
					                <li>
 | 
				
			||||||
 | 
					                  <a
 | 
				
			||||||
 | 
					                    href="#"
 | 
				
			||||||
 | 
					                    @click.prevent="loadBigWigTrack"
 | 
				
			||||||
 | 
					                    style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
 | 
				
			||||||
 | 
					                    >Encode bigwig</a
 | 
				
			||||||
 | 
					                  >
 | 
				
			||||||
 | 
					                </li>
 | 
				
			||||||
 | 
					                <li>
 | 
				
			||||||
 | 
					                  <a
 | 
				
			||||||
 | 
					                    href="#"
 | 
				
			||||||
 | 
					                    @click.prevent="loadBamTrack"
 | 
				
			||||||
 | 
					                    style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
 | 
				
			||||||
 | 
					                    >1KG Bam (HG02450)</a
 | 
				
			||||||
 | 
					                  >
 | 
				
			||||||
 | 
					                </li>
 | 
				
			||||||
 | 
					              </ul>
 | 
				
			||||||
 | 
					            </li>
 | 
				
			||||||
 | 
					          </ul>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <!-- IGV 뷰어 영역 -->
 | 
				
			||||||
 | 
					        <div id="igvDiv" ref="igvDiv" style="padding-top: 20px; min-height: 500px; position: relative;"></div>
 | 
				
			||||||
 | 
					        <!-- 염기서열 결과 -->
 | 
				
			||||||
 | 
					        <div style="margin-top: 12px;">
 | 
				
			||||||
 | 
					          <strong :class="{ 'reverse-active': isReverse }">염기서열:</strong>
 | 
				
			||||||
 | 
					          <pre :class="{ 'reverse-active': isReverse }" style="white-space: pre-wrap; word-break: break-all;">{{ displaySequence }}</pre>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </main>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script>
 | 
				
			||||||
 | 
					// igv.js와 igv_custom.js를 script 태그로 동적 로드(SSR 방지)
 | 
				
			||||||
 | 
					if (typeof window !== 'undefined') {
 | 
				
			||||||
 | 
					  function loadScriptOnce(src, globalCheck, callback) {
 | 
				
			||||||
 | 
					    if (globalCheck()) {
 | 
				
			||||||
 | 
					      if (callback) callback();
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // 이미 로딩 중인지 확인
 | 
				
			||||||
 | 
					    if (document.querySelector(`script[src="${src}"]`)) {
 | 
				
			||||||
 | 
					      const checkInterval = setInterval(() => {
 | 
				
			||||||
 | 
					        if (globalCheck()) {
 | 
				
			||||||
 | 
					          clearInterval(checkInterval);
 | 
				
			||||||
 | 
					          if (callback) callback();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }, 100);
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    const script = document.createElement('script');
 | 
				
			||||||
 | 
					    script.src = src;
 | 
				
			||||||
 | 
					    script.onload = function() { 
 | 
				
			||||||
 | 
					      if (callback) callback(); 
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    script.onerror = function() {
 | 
				
			||||||
 | 
					      console.error(`Failed to load script: ${src}`);
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    document.head.appendChild(script);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  // igv.js 먼저 로드
 | 
				
			||||||
 | 
					  loadScriptOnce('/dist/igv.js', function() { 
 | 
				
			||||||
 | 
					    return typeof window.igv !== 'undefined' && typeof window.igv.createBrowser === 'function'; 
 | 
				
			||||||
 | 
					  }, function() {
 | 
				
			||||||
 | 
					    console.log('IGV.js loaded successfully');
 | 
				
			||||||
 | 
					    // igv_custom.js는 igv.js가 로드된 후에만 로드
 | 
				
			||||||
 | 
					    loadScriptOnce('/dist/igv_custom.js', function() { 
 | 
				
			||||||
 | 
					      return !!window.igvCustomLoaded; 
 | 
				
			||||||
 | 
					    }, function() { 
 | 
				
			||||||
 | 
					      window.igvCustomLoaded = true; 
 | 
				
			||||||
 | 
					      console.log('IGV Custom loaded successfully');
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default {
 | 
				
			||||||
 | 
					  name: "App",
 | 
				
			||||||
 | 
					  data() {
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      browser: null,
 | 
				
			||||||
 | 
					      locus: null, // ex: "chr1:10000-10200"
 | 
				
			||||||
 | 
					      overlayWidth: 900,
 | 
				
			||||||
 | 
					      overlayHeight: 500,
 | 
				
			||||||
 | 
					      sequence: '',
 | 
				
			||||||
 | 
					      displaySequence: '',
 | 
				
			||||||
 | 
					      isReverse: false,
 | 
				
			||||||
 | 
					      isDropdownOpen: false,
 | 
				
			||||||
 | 
					      startPos: null,
 | 
				
			||||||
 | 
					      endPos: null,
 | 
				
			||||||
 | 
					      prevStartPos: null,
 | 
				
			||||||
 | 
					      prevEndPos: null,
 | 
				
			||||||
 | 
					      fastaFile: null, // 업로드된 FASTA 파일 Blob
 | 
				
			||||||
 | 
					      fastaName: '', // 업로드된 FASTA 파일명
 | 
				
			||||||
 | 
					      editSequence: '', // 범위 내 교체할 염기서열
 | 
				
			||||||
 | 
					      showSplitOption: false, // 파일 분할 옵션 표시
 | 
				
			||||||
 | 
					      largeFile: null, // 대용량 파일 저장
 | 
				
			||||||
 | 
					      isServerFilesDropdownOpen: false, // 서버 파일 목록 드롭다운 상태
 | 
				
			||||||
 | 
					      loadingFiles: false, // 서버 파일 목록 로딩 상태
 | 
				
			||||||
 | 
					      serverFiles: [], // 서버에 업로드된 파일 목록
 | 
				
			||||||
 | 
					      selectedServerFile: null, // 선택된 서버 파일
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  watch: {
 | 
				
			||||||
 | 
					    locus: 'fetchSequence',
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  async mounted() {
 | 
				
			||||||
 | 
					    // IGV.js와 igv_custom.js를 script 태그로 동적 로드(SSR 방지)
 | 
				
			||||||
 | 
					    if (typeof window !== 'undefined') {
 | 
				
			||||||
 | 
					      function loadScriptOnce(src, globalCheck, callback) {
 | 
				
			||||||
 | 
					        if (globalCheck()) {
 | 
				
			||||||
 | 
					          if (callback) callback();
 | 
				
			||||||
 | 
					          return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        // 이미 로딩 중인지 확인
 | 
				
			||||||
 | 
					        if (document.querySelector(`script[src="${src}"]`)) {
 | 
				
			||||||
 | 
					          const checkInterval = setInterval(() => {
 | 
				
			||||||
 | 
					            if (globalCheck()) {
 | 
				
			||||||
 | 
					              clearInterval(checkInterval);
 | 
				
			||||||
 | 
					              if (callback) callback();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }, 100);
 | 
				
			||||||
 | 
					          return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        const script = document.createElement('script');
 | 
				
			||||||
 | 
					        script.src = src;
 | 
				
			||||||
 | 
					        script.onload = function() { 
 | 
				
			||||||
 | 
					          if (callback) callback(); 
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        script.onerror = function() {
 | 
				
			||||||
 | 
					          console.error(`Failed to load script: ${src}`);
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        document.head.appendChild(script);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      // igv.js 먼저 로드
 | 
				
			||||||
 | 
					      loadScriptOnce('/dist/igv.js', function() { 
 | 
				
			||||||
 | 
					        return typeof window.igv !== 'undefined' && typeof window.igv.createBrowser === 'function'; 
 | 
				
			||||||
 | 
					      }, function() {
 | 
				
			||||||
 | 
					        console.log('IGV.js loaded successfully');
 | 
				
			||||||
 | 
					        // igv_custom.js는 igv.js가 로드된 후에만 로드
 | 
				
			||||||
 | 
					        loadScriptOnce('/dist/igv_custom.js', function() { 
 | 
				
			||||||
 | 
					          return !!window.igvCustomLoaded; 
 | 
				
			||||||
 | 
					        }, function() { 
 | 
				
			||||||
 | 
					          window.igvCustomLoaded = true; 
 | 
				
			||||||
 | 
					          console.log('IGV Custom loaded successfully');
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    await this.initIGV();
 | 
				
			||||||
 | 
					    // IGV가 완전히 생성될 때까지 대기
 | 
				
			||||||
 | 
					    const registerListener = () => {
 | 
				
			||||||
 | 
					      if (window.igvCustomLoaded && this.browser) {
 | 
				
			||||||
 | 
					        window.addEventListener('igv-blue-lines-changed', this.onBlueLinesChanged);
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        setTimeout(registerListener, 200);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    registerListener();
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  beforeUnmount() {
 | 
				
			||||||
 | 
					    window.removeEventListener('igv-blue-lines-changed', this.onBlueLinesChanged);
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  methods: {
 | 
				
			||||||
 | 
					    async initIGV() {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        await this.waitForIGV();
 | 
				
			||||||
 | 
					        await this.$nextTick();
 | 
				
			||||||
 | 
					        const igvDiv = this.$refs.igvDiv;
 | 
				
			||||||
 | 
					        if (!igvDiv) {
 | 
				
			||||||
 | 
					          console.error("❌ #igvDiv가 존재하지 않습니다.");
 | 
				
			||||||
 | 
					          return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        // FASTA 업로드 시 genome 옵션 변경
 | 
				
			||||||
 | 
					        const genomeOpt = this.fastaFile
 | 
				
			||||||
 | 
					          ? {
 | 
				
			||||||
 | 
					              id: this.fastaName || 'custom',
 | 
				
			||||||
 | 
					              fastaURL: this.fastaFile,
 | 
				
			||||||
 | 
					              indexURL: undefined,
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          : 'hg19';
 | 
				
			||||||
 | 
					        console.log('IGV 옵션:', { locus: this.locus || "chr1:10000-10200", genome: genomeOpt });
 | 
				
			||||||
 | 
					        const options = {
 | 
				
			||||||
 | 
					          locus: this.locus || "chr1:10000-10200",
 | 
				
			||||||
 | 
					          genome: genomeOpt,
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					          console.log('IGV 브라우저 생성 시작');
 | 
				
			||||||
 | 
					          this.browser = await window.igv.createBrowser(igvDiv, options);
 | 
				
			||||||
 | 
					          console.log('IGV 브라우저 생성 완료:', this.browser);
 | 
				
			||||||
 | 
					          // 이전 좌표 저장용
 | 
				
			||||||
 | 
					          // prevStartPos, prevEndPos는 data에 저장된 this.prevStartPos, this.prevEndPos 사용
 | 
				
			||||||
 | 
					          this.browser.on("locuschange", (locus) => {
 | 
				
			||||||
 | 
					            // locus 업데이트
 | 
				
			||||||
 | 
					            this.locus = locus;
 | 
				
			||||||
 | 
					            // locus 파싱
 | 
				
			||||||
 | 
					            let locusStart, locusEnd;
 | 
				
			||||||
 | 
					            if (typeof locus === 'string') {
 | 
				
			||||||
 | 
					              const match = locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
 | 
				
			||||||
 | 
					              if (!match) return;
 | 
				
			||||||
 | 
					              locusStart = parseInt(match[2].replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					              locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					            } else if (typeof locus === 'object' && locus.chr && locus.start && locus.end) {
 | 
				
			||||||
 | 
					              locusStart = parseInt(String(locus.start).replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					              locusEnd = parseInt(String(locus.end).replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					              return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            // input 박스의 startPos, endPos 기준으로 ratio 계산
 | 
				
			||||||
 | 
					            if (this.startPos !== null && this.endPos !== null) {
 | 
				
			||||||
 | 
					              const startRatio = (this.startPos - locusStart) / (locusEnd - locusStart);
 | 
				
			||||||
 | 
					              const endRatio = (this.endPos - locusStart) / (locusEnd - locusStart);
 | 
				
			||||||
 | 
					              if (window.igvCustom && typeof window.igvCustom.setLineRatios === 'function') {
 | 
				
			||||||
 | 
					                window.igvCustom.setLineRatios(startRatio, endRatio);
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					          this.locus = options.locus;
 | 
				
			||||||
 | 
					          // IGV, igvCustom 모두 준비된 후 최초 막대/염기서열 동기화
 | 
				
			||||||
 | 
					          const syncInitialBlueLines = async () => {
 | 
				
			||||||
 | 
					            if (window.igvCustom && typeof window.igvCustom.getLineRatios === 'function') {
 | 
				
			||||||
 | 
					              // locus 파싱
 | 
				
			||||||
 | 
					              let chrom, locusStart, locusEnd;
 | 
				
			||||||
 | 
					              if (typeof this.locus === 'string') {
 | 
				
			||||||
 | 
					                const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
 | 
				
			||||||
 | 
					                if (!match) return;
 | 
				
			||||||
 | 
					                chrom = match[1];
 | 
				
			||||||
 | 
					                locusStart = parseInt(match[2].replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					                locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					              } else if (typeof this.locus === 'object' && this.locus.chr && this.locus.start && this.locus.end) {
 | 
				
			||||||
 | 
					                chrom = this.locus.chr;
 | 
				
			||||||
 | 
					                locusStart = parseInt(String(this.locus.start).replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					                locusEnd = parseInt(String(this.locus.end).replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					              } else {
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					              const { startRatio, endRatio } = window.igvCustom.getLineRatios();
 | 
				
			||||||
 | 
					              const pos1 = Math.round(locusStart + (locusEnd - locusStart) * startRatio);
 | 
				
			||||||
 | 
					              const pos2 = Math.round(locusStart + (locusEnd - locusStart) * endRatio);
 | 
				
			||||||
 | 
					              const start = Math.min(pos1, pos2);
 | 
				
			||||||
 | 
					              const end = Math.max(pos1, pos2);
 | 
				
			||||||
 | 
					              this.startPos = start;
 | 
				
			||||||
 | 
					              this.endPos = end;
 | 
				
			||||||
 | 
					              if (end - start < 1) {
 | 
				
			||||||
 | 
					                this.sequence = '';
 | 
				
			||||||
 | 
					                this.displaySequence = '';
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					              // 염기서열 fetch
 | 
				
			||||||
 | 
					              if (
 | 
				
			||||||
 | 
					                this.browser.genome &&
 | 
				
			||||||
 | 
					                this.browser.genome.sequence &&
 | 
				
			||||||
 | 
					                typeof this.browser.genome.sequence.getSequence === 'function'
 | 
				
			||||||
 | 
					              ) {
 | 
				
			||||||
 | 
					                const seq = await this.browser.genome.sequence.getSequence(chrom, start, end);
 | 
				
			||||||
 | 
					                this.sequence = seq || '';
 | 
				
			||||||
 | 
					                this.displaySequence = this.isReverse ? this.getComplement(seq) : seq;
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					              setTimeout(syncInitialBlueLines, 200);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          };
 | 
				
			||||||
 | 
					          syncInitialBlueLines();
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					          console.error("IGV 브라우저 생성 중 오류:", error);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        console.error("IGV 초기화 중 오류:", error);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    // 파란 라인 위치 변경 이벤트 핸들러
 | 
				
			||||||
 | 
					    async onBlueLinesChanged(e) {
 | 
				
			||||||
 | 
					      if (!this.locus || !this.browser) {
 | 
				
			||||||
 | 
					        this.sequence = 'locus/browser 없음';
 | 
				
			||||||
 | 
					        this.displaySequence = 'locus/browser 없음';
 | 
				
			||||||
 | 
					        this.startPos = null;
 | 
				
			||||||
 | 
					        this.endPos = null;
 | 
				
			||||||
 | 
					        if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
 | 
				
			||||||
 | 
					          window.igvCustom.setMiniMapInfo(null, null, null);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      let chrom, locusStart, locusEnd;
 | 
				
			||||||
 | 
					      if (typeof this.locus === 'string') {
 | 
				
			||||||
 | 
					        const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
 | 
				
			||||||
 | 
					        if (!match) {
 | 
				
			||||||
 | 
					          this.sequence = 'locus 파싱 실패';
 | 
				
			||||||
 | 
					          this.displaySequence = 'locus 파싱 실패';
 | 
				
			||||||
 | 
					          this.startPos = null;
 | 
				
			||||||
 | 
					          this.endPos = null;
 | 
				
			||||||
 | 
					          if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
 | 
				
			||||||
 | 
					            window.igvCustom.setMiniMapInfo(null, null, null);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        chrom = match[1];
 | 
				
			||||||
 | 
					        locusStart = parseInt(match[2].replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					        locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					      } else if (typeof this.locus === 'object' && this.locus.chr && this.locus.start && this.locus.end) {
 | 
				
			||||||
 | 
					        chrom = this.locus.chr;
 | 
				
			||||||
 | 
					        locusStart = parseInt(String(this.locus.start).replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					        locusEnd = parseInt(String(this.locus.end).replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        this.sequence = 'locus 파싱 실패';
 | 
				
			||||||
 | 
					        this.displaySequence = 'locus 파싱 실패';
 | 
				
			||||||
 | 
					        this.startPos = null;
 | 
				
			||||||
 | 
					        this.endPos = null;
 | 
				
			||||||
 | 
					        if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
 | 
				
			||||||
 | 
					          window.igvCustom.setMiniMapInfo(null, null, null);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      const { startRatio, endRatio } = e.detail;
 | 
				
			||||||
 | 
					      const pos1 = Math.round(locusStart + (locusEnd - locusStart) * startRatio);
 | 
				
			||||||
 | 
					      const pos2 = Math.round(locusStart + (locusEnd - locusStart) * endRatio);
 | 
				
			||||||
 | 
					      const start = Math.min(pos1, pos2);
 | 
				
			||||||
 | 
					      const end = Math.max(pos1, pos2);
 | 
				
			||||||
 | 
					      this.startPos = start;
 | 
				
			||||||
 | 
					      this.endPos = end;
 | 
				
			||||||
 | 
					      if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
 | 
				
			||||||
 | 
					        window.igvCustom.setMiniMapInfo(chrom, start, end);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      if (end - start < 1) {
 | 
				
			||||||
 | 
					        this.sequence = '';
 | 
				
			||||||
 | 
					        this.displaySequence = '';
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        if (
 | 
				
			||||||
 | 
					          this.browser.genome &&
 | 
				
			||||||
 | 
					          this.browser.genome.sequence &&
 | 
				
			||||||
 | 
					          typeof this.browser.genome.sequence.getSequence === 'function'
 | 
				
			||||||
 | 
					        ) {
 | 
				
			||||||
 | 
					          const seq = await this.browser.genome.sequence.getSequence(chrom, start, end);
 | 
				
			||||||
 | 
					          this.sequence = seq || '';
 | 
				
			||||||
 | 
					          this.displaySequence = this.isReverse ? this.getComplement(seq) : seq;
 | 
				
			||||||
 | 
					          return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.sequence = 'getSequence 없음(IGV 내부 구조 확인 필요)';
 | 
				
			||||||
 | 
					        this.displaySequence = 'getSequence 없음(IGV 내부 구조 확인 필요)';
 | 
				
			||||||
 | 
					      } catch {
 | 
				
			||||||
 | 
					        this.sequence = '불러오기 실패';
 | 
				
			||||||
 | 
					        this.displaySequence = '불러오기 실패';
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      // locus 이동 전 좌표 저장
 | 
				
			||||||
 | 
					      this.prevStartPos = this.startPos;
 | 
				
			||||||
 | 
					      this.prevEndPos = this.endPos;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    reverseSequence() {
 | 
				
			||||||
 | 
					      this.isReverse = !this.isReverse;
 | 
				
			||||||
 | 
					      this.displaySequence = this.isReverse ? this.getComplement(this.sequence) : this.sequence;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    getComplement(seq) {
 | 
				
			||||||
 | 
					      if (!seq) return '';
 | 
				
			||||||
 | 
					      return seq.replace(/[ATCG]/gi, c => {
 | 
				
			||||||
 | 
					        switch (c.toUpperCase()) {
 | 
				
			||||||
 | 
					          case 'A': return 'T';
 | 
				
			||||||
 | 
					          case 'T': return 'A';
 | 
				
			||||||
 | 
					          case 'C': return 'G';
 | 
				
			||||||
 | 
					          case 'G': return 'C';
 | 
				
			||||||
 | 
					          default: return c;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    toggleDropdown() {
 | 
				
			||||||
 | 
					      this.isDropdownOpen = !this.isDropdownOpen;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    closeDropdown() {
 | 
				
			||||||
 | 
					      this.isDropdownOpen = false;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    handleClickOutside(event) {
 | 
				
			||||||
 | 
					      const dropdown = this.$el.querySelector("#igv-example-api-dropdown");
 | 
				
			||||||
 | 
					      const menu = this.$el.querySelector(".dropdown-menu");
 | 
				
			||||||
 | 
					      if (
 | 
				
			||||||
 | 
					        dropdown &&
 | 
				
			||||||
 | 
					        menu &&
 | 
				
			||||||
 | 
					        !dropdown.contains(event.target) &&
 | 
				
			||||||
 | 
					        !menu.contains(event.target)
 | 
				
			||||||
 | 
					      ) {
 | 
				
			||||||
 | 
					        this.closeDropdown();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    waitForIGV() {
 | 
				
			||||||
 | 
					      return new Promise((resolve, reject) => {
 | 
				
			||||||
 | 
					        let attempts = 0;
 | 
				
			||||||
 | 
					        const maxAttempts = 100; // 10초 타임아웃
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        const checkIGV = () => {
 | 
				
			||||||
 | 
					          attempts++;
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          if (typeof window.igv !== "undefined" && typeof window.igv.createBrowser === "function") {
 | 
				
			||||||
 | 
					            console.log('IGV is ready');
 | 
				
			||||||
 | 
					            resolve();
 | 
				
			||||||
 | 
					          } else if (attempts >= maxAttempts) {
 | 
				
			||||||
 | 
					            console.error('IGV loading timeout');
 | 
				
			||||||
 | 
					            reject(new Error('IGV loading timeout'));
 | 
				
			||||||
 | 
					          } else {
 | 
				
			||||||
 | 
					            setTimeout(checkIGV, 100);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        checkIGV();
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    loadCopyNumberTrack() {
 | 
				
			||||||
 | 
					      if (this.browser) {
 | 
				
			||||||
 | 
					        this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
 | 
				
			||||||
 | 
					        this.browser.loadTrackList([
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            url: "https://s3.amazonaws.com/igv.org.demo/GBM-TP.seg.gz",
 | 
				
			||||||
 | 
					            indexed: false,
 | 
				
			||||||
 | 
					            isLog: true,
 | 
				
			||||||
 | 
					            name: "GBM Copy # (TCGA Broad GDAC)",
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ]);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      this.closeDropdown();
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    loadDbSnpTrack() {
 | 
				
			||||||
 | 
					      if (this.browser) {
 | 
				
			||||||
 | 
					        this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
 | 
				
			||||||
 | 
					        this.browser.loadTrackList([
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            type: "annotation",
 | 
				
			||||||
 | 
					            format: "bed",
 | 
				
			||||||
 | 
					            url: "https://data.broadinstitute.org/igvdata/annotations/hg19/dbSnp/snp137.hg19.bed.gz",
 | 
				
			||||||
 | 
					            indexURL:
 | 
				
			||||||
 | 
					              "https://data.broadinstitute.org/igvdata/annotations/hg19/dbSnp/snp137.hg19.bed.gz.tbi",
 | 
				
			||||||
 | 
					            visibilityWindow: 200000,
 | 
				
			||||||
 | 
					            name: "dbSNP 137",
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ]);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      this.closeDropdown();
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    loadBigWigTrack() {
 | 
				
			||||||
 | 
					      if (this.browser) {
 | 
				
			||||||
 | 
					        this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
 | 
				
			||||||
 | 
					        this.browser.loadTrackList([
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            type: "wig",
 | 
				
			||||||
 | 
					            format: "bigwig",
 | 
				
			||||||
 | 
					            url: "https://s3.amazonaws.com/igv.broadinstitute.org/data/hg19/encode/wgEncodeBroadHistoneGm12878H3k4me3StdSig.bigWig",
 | 
				
			||||||
 | 
					            name: "Gm12878H3k4me3",
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ]);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      this.closeDropdown();
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    loadBamTrack() {
 | 
				
			||||||
 | 
					      if (this.browser) {
 | 
				
			||||||
 | 
					        this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
 | 
				
			||||||
 | 
					        this.browser.loadTrackList([
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            type: "alignment",
 | 
				
			||||||
 | 
					            format: "bam",
 | 
				
			||||||
 | 
					            url: "https://1000genomes.s3.amazonaws.com/phase3/data/HG02450/alignment/HG02450.mapped.ILLUMINA.bwa.ACB.low_coverage.20120522.bam",
 | 
				
			||||||
 | 
					            indexURL:
 | 
				
			||||||
 | 
					              "https://1000genomes.s3.amazonaws.com/phase3/data/HG02450/alignment/HG02450.mapped.ILLUMINA.bwa.ACB.low_coverage.20120522.bam.bai",
 | 
				
			||||||
 | 
					            name: "HG02450",
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ]);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      this.closeDropdown();
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    // input 박스에서 직접 좌표 입력 시 호출
 | 
				
			||||||
 | 
					    onInputPosChange(which, val) {
 | 
				
			||||||
 | 
					      let locusStart, locusEnd, chrom;
 | 
				
			||||||
 | 
					      if (typeof this.locus === 'string') {
 | 
				
			||||||
 | 
					        const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
 | 
				
			||||||
 | 
					        if (!match) return;
 | 
				
			||||||
 | 
					        chrom = match[1];
 | 
				
			||||||
 | 
					        locusStart = parseInt(match[2].replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					        locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					      } else if (typeof this.locus === 'object' && this.locus.chr && this.locus.start && this.locus.end) {
 | 
				
			||||||
 | 
					        chrom = this.locus.chr;
 | 
				
			||||||
 | 
					        locusStart = parseInt(String(this.locus.start).replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					        locusEnd = parseInt(String(this.locus.end).replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      let start = this.startPos, end = this.endPos;
 | 
				
			||||||
 | 
					      if (which === 'start') {
 | 
				
			||||||
 | 
					        start = Number(val);
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        end = Number(val);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      // 범위 보정
 | 
				
			||||||
 | 
					      if (start >= end) return;
 | 
				
			||||||
 | 
					      // ratio 계산
 | 
				
			||||||
 | 
					      const startRatio = (start - locusStart) / (locusEnd - locusStart);
 | 
				
			||||||
 | 
					      const endRatio = (end - locusStart) / (locusEnd - locusStart);
 | 
				
			||||||
 | 
					      if (window.igvCustom && typeof window.igvCustom.setLineRatios === 'function') {
 | 
				
			||||||
 | 
					        window.igvCustom.setLineRatios(startRatio, endRatio);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
 | 
				
			||||||
 | 
					        window.igvCustom.setMiniMapInfo(chrom, start, end);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    // FASTA 파일 업로드 핸들러
 | 
				
			||||||
 | 
					    async onFastaUpload(e) {
 | 
				
			||||||
 | 
					      const file = e.target.files[0];
 | 
				
			||||||
 | 
					      if (!file) return;
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      console.log('FASTA 파일 업로드:', file.name, file.size);
 | 
				
			||||||
 | 
					      this.fastaName = file.name.replace(/\.[^/.]+$/, "");
 | 
				
			||||||
 | 
					      // 파란 범위 임시 저장
 | 
				
			||||||
 | 
					      const prevStartPos = this.startPos;
 | 
				
			||||||
 | 
					      const prevEndPos = this.endPos;
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        // 서버에 파일 업로드
 | 
				
			||||||
 | 
					        const formData = new FormData();
 | 
				
			||||||
 | 
					        formData.append('file', file);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        console.log('서버에 파일 업로드 중...');
 | 
				
			||||||
 | 
					        if (typeof window !== 'undefined') {
 | 
				
			||||||
 | 
					          const { data: _data, error: _error } = await useApi('/api/fasta/upload', {
 | 
				
			||||||
 | 
					            method: 'post',
 | 
				
			||||||
 | 
					            body: formData,
 | 
				
			||||||
 | 
					            headers: {
 | 
				
			||||||
 | 
					              // 'Content-Type'은 브라우저가 자동으로 설정하므로 명시하지 않음
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          })
 | 
				
			||||||
 | 
					        if (!response.ok) {
 | 
				
			||||||
 | 
					          throw new Error(`서버 업로드 실패: ${response.status}`);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        const result = await response.json();
 | 
				
			||||||
 | 
					        console.log('서버 업로드 완료:', result);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // 원격 URL 사용
 | 
				
			||||||
 | 
					        this.fastaFile = result.remoteUrl;
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // IGV 브라우저 완전 재생성
 | 
				
			||||||
 | 
					        if (this.browser) {
 | 
				
			||||||
 | 
					          if (typeof this.browser.removeAllTracks === 'function') {
 | 
				
			||||||
 | 
					            this.browser.removeAllTracks();
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          this.browser = null;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        // igvDiv 완전히 비우기
 | 
				
			||||||
 | 
					        const igvDiv = this.$refs.igvDiv;
 | 
				
			||||||
 | 
					        if (igvDiv) {
 | 
				
			||||||
 | 
					          igvDiv.innerHTML = '';
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        // locus, 시퀀스 등 초기화
 | 
				
			||||||
 | 
					        this.locus = "chr1:1-100";
 | 
				
			||||||
 | 
					        this.sequence = '';
 | 
				
			||||||
 | 
					        this.displaySequence = '';
 | 
				
			||||||
 | 
					        this.startPos = null;
 | 
				
			||||||
 | 
					        this.endPos = null;
 | 
				
			||||||
 | 
					        console.log('IGV 재초기화 시작');
 | 
				
			||||||
 | 
					        await this.initIGV();
 | 
				
			||||||
 | 
					        // IGV 재초기화 후 파란 범위 복원 (딜레이 추가)
 | 
				
			||||||
 | 
					        setTimeout(() => {
 | 
				
			||||||
 | 
					          if (prevStartPos !== null && prevEndPos !== null && window.igvCustom && typeof window.igvCustom.setLineRatios === 'function') {
 | 
				
			||||||
 | 
					            // locus 파싱
 | 
				
			||||||
 | 
					            let locusStart = 1, locusEnd = 100;
 | 
				
			||||||
 | 
					            if (typeof this.locus === 'string') {
 | 
				
			||||||
 | 
					              const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
 | 
				
			||||||
 | 
					              if (match) {
 | 
				
			||||||
 | 
					                locusStart = parseInt(match[2].replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					                locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            const startRatio = (prevStartPos - locusStart) / (locusEnd - locusStart);
 | 
				
			||||||
 | 
					            const endRatio = (prevEndPos - locusStart) / (locusEnd - locusStart);
 | 
				
			||||||
 | 
					            window.igvCustom.setLineRatios(startRatio, endRatio);
 | 
				
			||||||
 | 
					            if (typeof window.waitForIGVAndInit === 'function') {
 | 
				
			||||||
 | 
					              window.waitForIGVAndInit();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }, 500);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        console.error('파일 업로드 오류:', error);
 | 
				
			||||||
 | 
					        alert(`파일 업로드 실패: ${error.message}\n\n서버 API가 설정되지 않았거나, 파일이 너무 클 수 있습니다.`);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // 서버 업로드 실패 시 기존 방식으로 폴백
 | 
				
			||||||
 | 
					        if (file.size <= 100 * 1024 * 1024) { // 100MB 이하만
 | 
				
			||||||
 | 
					          this.largeFile = file;
 | 
				
			||||||
 | 
					          this.showSplitOption = true;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    // 대용량 파일 분할
 | 
				
			||||||
 | 
					    async splitLargeFile() {
 | 
				
			||||||
 | 
					      if (!this.largeFile) return;
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        const chunkSize = 50 * 1024 * 1024; // 50MB 청크
 | 
				
			||||||
 | 
					        const totalChunks = Math.ceil(this.largeFile.size / chunkSize);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        alert(`파일을 ${totalChunks}개 청크로 분할합니다. 각 청크는 약 50MB입니다.`);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        for (let i = 0; i < totalChunks; i++) {
 | 
				
			||||||
 | 
					          const start = i * chunkSize;
 | 
				
			||||||
 | 
					          const end = Math.min(start + chunkSize, this.largeFile.size);
 | 
				
			||||||
 | 
					          const chunk = this.largeFile.slice(start, end);
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          // 청크를 파일로 다운로드
 | 
				
			||||||
 | 
					          const url = URL.createObjectURL(chunk);
 | 
				
			||||||
 | 
					          const a = document.createElement('a');
 | 
				
			||||||
 | 
					          a.href = url;
 | 
				
			||||||
 | 
					          a.download = `${this.largeFile.name.replace(/\.[^/.]+$/, "")}_part${i + 1}.fasta`;
 | 
				
			||||||
 | 
					          document.body.appendChild(a);
 | 
				
			||||||
 | 
					          a.click();
 | 
				
			||||||
 | 
					          document.body.removeChild(a);
 | 
				
			||||||
 | 
					          URL.revokeObjectURL(url);
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          // 진행률 표시
 | 
				
			||||||
 | 
					          const progress = ((i + 1) / totalChunks * 100).toFixed(1);
 | 
				
			||||||
 | 
					          console.log(`분할 진행률: ${progress}%`);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        alert(`파일 분할 완료! ${totalChunks}개의 파일이 다운로드되었습니다.\n각 파일을 개별적으로 업로드하여 사용하세요.`);
 | 
				
			||||||
 | 
					        this.showSplitOption = false;
 | 
				
			||||||
 | 
					        this.largeFile = null;
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        console.error('파일 분할 중 오류:', error);
 | 
				
			||||||
 | 
					        alert('파일 분할 중 오류가 발생했습니다.');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    // 범위 내 염기서열 교체
 | 
				
			||||||
 | 
					    replaceSequenceInRange() {
 | 
				
			||||||
 | 
					      if (!this.sequence || !this.editSequence) {
 | 
				
			||||||
 | 
					        alert('염기서열 또는 입력값이 없습니다.');
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      if (this.editSequence.length !== (this.endPos - this.startPos)) {
 | 
				
			||||||
 | 
					        alert('입력한 염기서열 길이가 범위와 일치해야 합니다.');
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      // 기존 염기서열을 교체
 | 
				
			||||||
 | 
					      // 실제 FASTA 파일은 수정하지 않고, 화면에만 반영
 | 
				
			||||||
 | 
					      this.sequence = this.editSequence;
 | 
				
			||||||
 | 
					      this.displaySequence = this.isReverse ? this.getComplement(this.sequence) : this.sequence;
 | 
				
			||||||
 | 
					      alert('범위 내 염기서열이 변경되었습니다. (화면에만 반영)');
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    // 서버 파일 목록 토글
 | 
				
			||||||
 | 
					    toggleServerFilesDropdown() {
 | 
				
			||||||
 | 
					      this.isServerFilesDropdownOpen = !this.isServerFilesDropdownOpen;
 | 
				
			||||||
 | 
					      if (this.isServerFilesDropdownOpen) {
 | 
				
			||||||
 | 
					        this.loadServerFiles();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					         // 서버 파일 목록 로드
 | 
				
			||||||
 | 
					     async loadServerFiles() {
 | 
				
			||||||
 | 
					       this.loadingFiles = true;
 | 
				
			||||||
 | 
					       try {
 | 
				
			||||||
 | 
					         const response = await fetch('http://localhost/api/fasta/files');
 | 
				
			||||||
 | 
					         if (!response.ok) {
 | 
				
			||||||
 | 
					           throw new Error(`서버 파일 목록 로드 실패: ${response.status}`);
 | 
				
			||||||
 | 
					         }
 | 
				
			||||||
 | 
					         const result = await response.json();
 | 
				
			||||||
 | 
					         this.serverFiles = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : (Array.isArray(result) ? result : []);
 | 
				
			||||||
 | 
					       } catch (error) {
 | 
				
			||||||
 | 
					         console.error('서버 파일 목록 로드 중 오류:', error);
 | 
				
			||||||
 | 
					         this.serverFiles = [];
 | 
				
			||||||
 | 
					         alert(`서버 파일 목록을 불러오는데 실패했습니다: ${error.message}`);
 | 
				
			||||||
 | 
					       } finally {
 | 
				
			||||||
 | 
					         this.loadingFiles = false;
 | 
				
			||||||
 | 
					       }
 | 
				
			||||||
 | 
					     },
 | 
				
			||||||
 | 
					         // 서버 파일 선택
 | 
				
			||||||
 | 
					     selectServerFile(file) {
 | 
				
			||||||
 | 
					       const prevStartPos = this.startPos;
 | 
				
			||||||
 | 
					       const prevEndPos = this.endPos;
 | 
				
			||||||
 | 
					       this.selectedServerFile = file;
 | 
				
			||||||
 | 
					       this.fastaFile = file.fileUrl; // 선택된 파일의 URL을 fastaFile에 저장
 | 
				
			||||||
 | 
					       this.fastaName = file.fileName; // 파일명 업데이트
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
 | 
					      // IGV 브라우저 완전 재생성
 | 
				
			||||||
 | 
					      if (this.browser) {
 | 
				
			||||||
 | 
					        if (typeof this.browser.removeAllTracks === 'function') {
 | 
				
			||||||
 | 
					          this.browser.removeAllTracks();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.browser = null;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      // igvDiv 완전히 비우기
 | 
				
			||||||
 | 
					      const igvDiv = this.$refs.igvDiv;
 | 
				
			||||||
 | 
					      if (igvDiv) {
 | 
				
			||||||
 | 
					        igvDiv.innerHTML = '';
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      // locus, 시퀀스 등 초기화
 | 
				
			||||||
 | 
					      this.locus = "chr1:1-100";
 | 
				
			||||||
 | 
					      this.sequence = '';
 | 
				
			||||||
 | 
					      this.displaySequence = '';
 | 
				
			||||||
 | 
					      this.startPos = null;
 | 
				
			||||||
 | 
					      this.endPos = null;
 | 
				
			||||||
 | 
					      console.log('IGV 재초기화 시작');
 | 
				
			||||||
 | 
					      this.initIGV().then(() => {
 | 
				
			||||||
 | 
					        // IGV 재초기화 후 파란 범위 복원 (딜레이 추가)
 | 
				
			||||||
 | 
					        setTimeout(() => {
 | 
				
			||||||
 | 
					          if (prevStartPos !== null && prevEndPos !== null && window.igvCustom && typeof window.igvCustom.setLineRatios === 'function') {
 | 
				
			||||||
 | 
					            // locus 파싱
 | 
				
			||||||
 | 
					            let locusStart = 1, locusEnd = 100;
 | 
				
			||||||
 | 
					            if (typeof this.locus === 'string') {
 | 
				
			||||||
 | 
					              const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
 | 
				
			||||||
 | 
					              if (match) {
 | 
				
			||||||
 | 
					                locusStart = parseInt(match[2].replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					                locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            const startRatio = (prevStartPos - locusStart) / (locusEnd - locusStart);
 | 
				
			||||||
 | 
					            const endRatio = (prevEndPos - locusStart) / (locusEnd - locusStart);
 | 
				
			||||||
 | 
					            window.igvCustom.setLineRatios(startRatio, endRatio);
 | 
				
			||||||
 | 
					            if (typeof window.igvCustom.setupLines === 'function') {
 | 
				
			||||||
 | 
					              window.igvCustom.setupLines();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            if (typeof window.waitForIGVAndInit === 'function') {
 | 
				
			||||||
 | 
					              window.waitForIGVAndInit();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }, 500);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      this.isServerFilesDropdownOpen = false; // 드롭다운 닫기
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    // 파일 크기 포맷
 | 
				
			||||||
 | 
					    formatFileSize(bytes) {
 | 
				
			||||||
 | 
					      if (bytes === 0) return '0 Bytes';
 | 
				
			||||||
 | 
					      const k = 1024;
 | 
				
			||||||
 | 
					      const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
 | 
				
			||||||
 | 
					      const i = Math.floor(Math.log(bytes) / Math.log(k));
 | 
				
			||||||
 | 
					      return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    // 날짜 포맷
 | 
				
			||||||
 | 
					    formatDate(timestamp) {
 | 
				
			||||||
 | 
					      const date = new Date(timestamp);
 | 
				
			||||||
 | 
					      return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style>
 | 
				
			||||||
 | 
					#app {
 | 
				
			||||||
 | 
					  font-family: Avenir, Helvetica, Arial, sans-serif;
 | 
				
			||||||
 | 
					  -webkit-font-smoothing: antialiased;
 | 
				
			||||||
 | 
					  -moz-osx-font-smoothing: grayscale;
 | 
				
			||||||
 | 
					  color: #2c3e50;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					pre {
 | 
				
			||||||
 | 
					  padding: 10px;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.reverse-active {
 | 
				
			||||||
 | 
					  background: #e6f0ff !important;
 | 
				
			||||||
 | 
					  color: #0056b3 !important;
 | 
				
			||||||
 | 
					  font-weight: bold;
 | 
				
			||||||
 | 
					  border: 2px solid #0056b3 !important;
 | 
				
			||||||
 | 
					  box-shadow: 0 0 4px #b3d1ff;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.igv-control-btn.reverse-active {
 | 
				
			||||||
 | 
					  background: #0056b3 !important;
 | 
				
			||||||
 | 
					  color: #fff !important;
 | 
				
			||||||
 | 
					  border: 2px solid #003366 !important;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.nav-tabs {
 | 
				
			||||||
 | 
					  margin-bottom: 20px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tab-content {
 | 
				
			||||||
 | 
					  padding-top: 20px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.igv-control-input {
 | 
				
			||||||
 | 
					  border: 1.5px solid #007bff;
 | 
				
			||||||
 | 
					  padding: 4px 8px;
 | 
				
			||||||
 | 
					  border-radius: 5px;
 | 
				
			||||||
 | 
					  font-size: 15px;
 | 
				
			||||||
 | 
					  outline: none;
 | 
				
			||||||
 | 
					  transition: border 0.2s;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.igv-control-input:focus {
 | 
				
			||||||
 | 
					  border: 2px solid #0056b3;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.igv-control-btn {
 | 
				
			||||||
 | 
					  border: 1.5px solid #007bff;
 | 
				
			||||||
 | 
					  background: #f5faff;
 | 
				
			||||||
 | 
					  color: #007bff;
 | 
				
			||||||
 | 
					  border-radius: 5px;
 | 
				
			||||||
 | 
					  padding: 6px 16px;
 | 
				
			||||||
 | 
					  font-size: 15px;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  transition: background 0.2s, border 0.2s, color 0.2s;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.igv-control-btn:hover {
 | 
				
			||||||
 | 
					  background: #007bff;
 | 
				
			||||||
 | 
					  color: #fff;
 | 
				
			||||||
 | 
					  border: 2px solid #0056b3;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* 서버 파일 목록 드롭다운 스타일 */
 | 
				
			||||||
 | 
					.server-files-dropdown {
 | 
				
			||||||
 | 
					  position: absolute;
 | 
				
			||||||
 | 
					  top: 100%;
 | 
				
			||||||
 | 
					  left: 0;
 | 
				
			||||||
 | 
					  background-color: #fff;
 | 
				
			||||||
 | 
					  border: 1px solid #ccc;
 | 
				
			||||||
 | 
					  box-shadow: 0 2px 5px rgba(0,0,0,0.15);
 | 
				
			||||||
 | 
					  padding: 5px 0;
 | 
				
			||||||
 | 
					  margin-top: 5px;
 | 
				
			||||||
 | 
					  z-index: 1000;
 | 
				
			||||||
 | 
					  max-height: 300px; /* 스크롤 가능하도록 높이 제한 */
 | 
				
			||||||
 | 
					  overflow-y: auto;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.server-files-dropdown .dropdown-item {
 | 
				
			||||||
 | 
					  padding: 8px 20px;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  transition: background-color 0.2s;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.server-files-dropdown .dropdown-item:hover {
 | 
				
			||||||
 | 
					  background-color: #f0f0f0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.server-files-dropdown .dropdown-item.selected {
 | 
				
			||||||
 | 
					  background-color: #e6f0ff;
 | 
				
			||||||
 | 
					  font-weight: bold;
 | 
				
			||||||
 | 
					  color: #0056b3;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.server-files-dropdown .file-item {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  justify-content: space-between;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  padding: 8px 20px;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  transition: background-color 0.2s;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.server-files-dropdown .file-item:hover {
 | 
				
			||||||
 | 
					  background-color: #f0f0f0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.server-files-dropdown .file-item.selected {
 | 
				
			||||||
 | 
					  background-color: #e6f0ff;
 | 
				
			||||||
 | 
					  font-weight: bold;
 | 
				
			||||||
 | 
					  color: #0056b3;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.server-files-dropdown .file-name {
 | 
				
			||||||
 | 
					  flex-grow: 1;
 | 
				
			||||||
 | 
					  margin-right: 10px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.server-files-dropdown .file-info {
 | 
				
			||||||
 | 
					  font-size: 0.8em;
 | 
				
			||||||
 | 
					  color: #666;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style> 
 | 
				
			||||||
							
								
								
									
										247
									
								
								pages/[tabId]/test/pathway.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										247
									
								
								pages/[tabId]/test/pathway.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,247 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div style="position: relative;">
 | 
				
			||||||
 | 
					    <h2>KEGG Pathway Viewer (Compact Overlay)</h2>
 | 
				
			||||||
 | 
					    <div ref="cyContainer" style="width: 100%; height: 800px; border: 1px solid #ccc; position: relative;"></div>
 | 
				
			||||||
 | 
					    <div ref="overlayContainer" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 1000;"></div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { ref, onMounted, nextTick } from 'vue'
 | 
				
			||||||
 | 
					import cytoscape from 'cytoscape'
 | 
				
			||||||
 | 
					import { Chart, registerables } from 'chart.js'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Chart.register(...registerables)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const cyContainer = ref(null)
 | 
				
			||||||
 | 
					const overlayContainer = ref(null)
 | 
				
			||||||
 | 
					const chartMap = new Map()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function toRenderedPosition(pos, cy) {
 | 
				
			||||||
 | 
					  const zoom = cy.zoom()
 | 
				
			||||||
 | 
					  const pan = cy.pan()
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    x: pos.x * zoom + pan.x,
 | 
				
			||||||
 | 
					    y: pos.y * zoom + pan.y
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(async () => {
 | 
				
			||||||
 | 
					  await nextTick()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const resizeOverlay = () => {
 | 
				
			||||||
 | 
					    if (!cyContainer.value || !overlayContainer.value) return
 | 
				
			||||||
 | 
					    overlayContainer.value.style.width = cyContainer.value.offsetWidth + 'px'
 | 
				
			||||||
 | 
					    overlayContainer.value.style.height = cyContainer.value.offsetHeight + 'px'
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  resizeOverlay()
 | 
				
			||||||
 | 
					  window.addEventListener('resize', resizeOverlay)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const res = await fetch('/pon00061.xml')
 | 
				
			||||||
 | 
					  const xmlText = await res.text()
 | 
				
			||||||
 | 
					  const parser = new DOMParser()
 | 
				
			||||||
 | 
					  const xmlDoc = parser.parseFromString(xmlText, 'application/xml')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const scale = 5
 | 
				
			||||||
 | 
					  const entryMap = new Map()
 | 
				
			||||||
 | 
					  const entryDataMap = new Map()
 | 
				
			||||||
 | 
					  const parentMap = new Map()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const entries = Array.from(xmlDoc.getElementsByTagName('entry'))
 | 
				
			||||||
 | 
					  for (const entry of entries) {
 | 
				
			||||||
 | 
					    const id = entry.getAttribute('id')
 | 
				
			||||||
 | 
					    entryDataMap.set(id, entry)
 | 
				
			||||||
 | 
					    if (entry.getAttribute('type') === 'group') {
 | 
				
			||||||
 | 
					      const components = entry.getElementsByTagName('component')
 | 
				
			||||||
 | 
					      for (const comp of components) {
 | 
				
			||||||
 | 
					        const childId = comp.getAttribute('id')
 | 
				
			||||||
 | 
					        parentMap.set(childId, id)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const nodes = entries.map(entry => {
 | 
				
			||||||
 | 
					    const id = entry.getAttribute('id')
 | 
				
			||||||
 | 
					    const graphics = entry.getElementsByTagName('graphics')[0]
 | 
				
			||||||
 | 
					    const x = parseFloat(graphics?.getAttribute('x') || '0') * scale
 | 
				
			||||||
 | 
					    const y = parseFloat(graphics?.getAttribute('y') || '0') * scale
 | 
				
			||||||
 | 
					    const label = graphics?.getAttribute('name') || id
 | 
				
			||||||
 | 
					    const fgColor = graphics?.getAttribute('fgcolor') || '#000000'
 | 
				
			||||||
 | 
					    const bgColor = graphics?.getAttribute('bgcolor') || '#ffffff'
 | 
				
			||||||
 | 
					    const entryType = entry.getAttribute('type')
 | 
				
			||||||
 | 
					    const shapeType = entryType === 'compound' ? 'compound' : entryType === 'group' ? 'group' : entryType === 'ortholog' ? 'ortholog' : 'gene'
 | 
				
			||||||
 | 
					    const parent = parentMap.get(id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const node = {
 | 
				
			||||||
 | 
					      data: {
 | 
				
			||||||
 | 
					        id,
 | 
				
			||||||
 | 
					        label,
 | 
				
			||||||
 | 
					        link: entry.getAttribute('link') || null,
 | 
				
			||||||
 | 
					        reaction: entry.getAttribute('reaction') || null
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      position: { x, y },
 | 
				
			||||||
 | 
					      classes: shapeType,
 | 
				
			||||||
 | 
					      style: {
 | 
				
			||||||
 | 
					        color: fgColor,
 | 
				
			||||||
 | 
					        'background-color': bgColor
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (parent) node.data.parent = parent
 | 
				
			||||||
 | 
					    entryMap.set(id, true)
 | 
				
			||||||
 | 
					    return node
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  function resolveToRealNode(id) {
 | 
				
			||||||
 | 
					    if (!entryMap.has(id)) return null
 | 
				
			||||||
 | 
					    const entry = entryDataMap.get(id)
 | 
				
			||||||
 | 
					    if (entry?.getAttribute('type') === 'group') {
 | 
				
			||||||
 | 
					      const components = Array.from(entry.getElementsByTagName('component'))
 | 
				
			||||||
 | 
					      if (components.length > 0) return components[0].getAttribute('id')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return id
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const edges = []
 | 
				
			||||||
 | 
					  const relations = Array.from(xmlDoc.getElementsByTagName('relation'))
 | 
				
			||||||
 | 
					  relations.forEach((rel, i) => {
 | 
				
			||||||
 | 
					    let source = resolveToRealNode(rel.getAttribute('entry1'))
 | 
				
			||||||
 | 
					    let target = resolveToRealNode(rel.getAttribute('entry2'))
 | 
				
			||||||
 | 
					    const type = rel.getAttribute('type')
 | 
				
			||||||
 | 
					    const subtypes = Array.from(rel.getElementsByTagName('subtype'))
 | 
				
			||||||
 | 
					    const compoundSubtype = subtypes.find(s => s.getAttribute('name') === 'compound')
 | 
				
			||||||
 | 
					    if (compoundSubtype) {
 | 
				
			||||||
 | 
					      const compoundId = compoundSubtype.getAttribute('value')
 | 
				
			||||||
 | 
					      if (entryMap.has(source) && entryMap.has(target) && entryMap.has(compoundId)) {
 | 
				
			||||||
 | 
					        edges.push(
 | 
				
			||||||
 | 
					          { data: { id: `edge${i}-1`, source, target: compoundId, label: 'via compound' } },
 | 
				
			||||||
 | 
					          { data: { id: `edge${i}-2`, source: compoundId, target, label: 'via compound' } }
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      if (entryMap.has(source) && entryMap.has(target)) {
 | 
				
			||||||
 | 
					        edges.push({ data: { id: `edge${i}`, source, target, label: type } })
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const reactions = Array.from(xmlDoc.getElementsByTagName('reaction'))
 | 
				
			||||||
 | 
					  reactions.forEach((reaction, i) => {
 | 
				
			||||||
 | 
					    const reactionType = reaction.getAttribute('type')
 | 
				
			||||||
 | 
					    const reactionLabel = reaction.getAttribute('name') || `reaction${i}`
 | 
				
			||||||
 | 
					    const substrates = Array.from(reaction.getElementsByTagName('substrate'))
 | 
				
			||||||
 | 
					    const products = Array.from(reaction.getElementsByTagName('product'))
 | 
				
			||||||
 | 
					    substrates.forEach(substrate => {
 | 
				
			||||||
 | 
					      const sid = resolveToRealNode(substrate.getAttribute('id'))
 | 
				
			||||||
 | 
					      products.forEach(product => {
 | 
				
			||||||
 | 
					        const pid = resolveToRealNode(product.getAttribute('id'))
 | 
				
			||||||
 | 
					        if (entryMap.has(sid) && entryMap.has(pid)) {
 | 
				
			||||||
 | 
					          edges.push({ data: { id: `reaction-${i}-${sid}-${pid}`, source: sid, target: pid, label: `${reactionLabel} (${reactionType})` } })
 | 
				
			||||||
 | 
					          if (reactionType === 'reversible') {
 | 
				
			||||||
 | 
					            edges.push({ data: { id: `reaction-${i}-${pid}-${sid}`, source: pid, target: sid, label: `${reactionLabel} (reversible)` } })
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const cy = cytoscape({
 | 
				
			||||||
 | 
					    container: cyContainer.value,
 | 
				
			||||||
 | 
					    elements: { nodes, edges },
 | 
				
			||||||
 | 
					    style: [
 | 
				
			||||||
 | 
					      { selector: 'node', style: { label: 'data(label)', 'text-valign': 'center', 'text-halign': 'center', shape: 'rectangle', 'font-size': 10, 'border-width': 1, 'border-color': '#333' } },
 | 
				
			||||||
 | 
					      { selector: '.ortholog', style: { 'background-color': '#ccffcc', shape: 'round-rectangle', 'border-width': 2, 'border-color': '#339933' } },
 | 
				
			||||||
 | 
					      { selector: '$node > node', style: { 'background-color': '#f3f3f3', 'border-width': 2, 'border-color': '#666', shape: 'roundrectangle' } },
 | 
				
			||||||
 | 
					      { selector: '.compound', style: { 'background-color': '#ffe135', shape: 'ellipse' } },
 | 
				
			||||||
 | 
					      { selector: 'edge', style: { width: 2, 'line-color': '#888', 'target-arrow-shape': 'triangle', 'label': 'data(label)', 'font-size': 8 } }
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    layout: { name: 'preset' }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  cy.ready(() => {
 | 
				
			||||||
 | 
					    cy.fit(cy.elements(), 100)
 | 
				
			||||||
 | 
					    createNodeOverlays(cy)
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  cy.on('render', () => {
 | 
				
			||||||
 | 
					    requestAnimationFrame(() => updateOverlayPositions(cy))
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function createNodeOverlays(cy) {
 | 
				
			||||||
 | 
					  cy.nodes().forEach(node => {
 | 
				
			||||||
 | 
					    const pos = node.renderedPosition()
 | 
				
			||||||
 | 
					    const id = node.id()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const wrapper = document.createElement('div')
 | 
				
			||||||
 | 
					    wrapper.style.position = 'absolute'
 | 
				
			||||||
 | 
					    wrapper.style.left = `${pos.x + 30}px`
 | 
				
			||||||
 | 
					    wrapper.style.top = `${pos.y - 40}px`
 | 
				
			||||||
 | 
					    wrapper.style.width = '20px'
 | 
				
			||||||
 | 
					    wrapper.style.height = '80px'
 | 
				
			||||||
 | 
					    wrapper.style.background = 'rgba(255,255,255,0.9)'
 | 
				
			||||||
 | 
					    wrapper.style.fontSize = '6px'
 | 
				
			||||||
 | 
					    wrapper.style.border = '1px solid #ccc'
 | 
				
			||||||
 | 
					    wrapper.style.borderRadius = '2px'
 | 
				
			||||||
 | 
					    wrapper.style.overflow = 'hidden'
 | 
				
			||||||
 | 
					    wrapper.style.pointerEvents = 'none'
 | 
				
			||||||
 | 
					    wrapper.style.zIndex = '9999' // 보장
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const table = document.createElement('table')
 | 
				
			||||||
 | 
					    table.style.borderCollapse = 'collapse'
 | 
				
			||||||
 | 
					    table.style.width = '100%'
 | 
				
			||||||
 | 
					    for (let i = 0; i < 7; i++) {
 | 
				
			||||||
 | 
					      const tr = document.createElement('tr')
 | 
				
			||||||
 | 
					      for (let j = 0; j < 2; j++) {
 | 
				
			||||||
 | 
					        const td = document.createElement('td')
 | 
				
			||||||
 | 
					        td.textContent = '·'
 | 
				
			||||||
 | 
					        td.style.padding = '0'
 | 
				
			||||||
 | 
					        td.style.fontSize = '6px'
 | 
				
			||||||
 | 
					        td.style.textAlign = 'center'
 | 
				
			||||||
 | 
					        tr.appendChild(td)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      table.appendChild(tr)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const canvas = document.createElement('canvas')
 | 
				
			||||||
 | 
					    canvas.width = 16
 | 
				
			||||||
 | 
					    canvas.height = 16
 | 
				
			||||||
 | 
					    canvas.style.margin = '2px auto 0'
 | 
				
			||||||
 | 
					    canvas.style.display = 'block'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    wrapper.appendChild(table)
 | 
				
			||||||
 | 
					    wrapper.appendChild(canvas)
 | 
				
			||||||
 | 
					    overlayContainer.value.appendChild(wrapper)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const ctx = canvas.getContext('2d')
 | 
				
			||||||
 | 
					    const chart = new Chart(ctx, {
 | 
				
			||||||
 | 
					      type: 'doughnut',
 | 
				
			||||||
 | 
					      data: {
 | 
				
			||||||
 | 
					        labels: ['A', 'B'],
 | 
				
			||||||
 | 
					        datasets: [{ data: [30, 70], backgroundColor: ['#ff6384', '#36a2eb'] }]
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      options: {
 | 
				
			||||||
 | 
					        responsive: false,
 | 
				
			||||||
 | 
					        plugins: { legend: { display: false }, tooltip: { enabled: false } },
 | 
				
			||||||
 | 
					        cutout: '60%',
 | 
				
			||||||
 | 
					        animation: false
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    chartMap.set(id, { wrapper, chart })
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function updateOverlayPositions(cy) {
 | 
				
			||||||
 | 
					  cy.nodes().forEach(node => {
 | 
				
			||||||
 | 
					    const entry = chartMap.get(node.id())
 | 
				
			||||||
 | 
					    if (entry) {
 | 
				
			||||||
 | 
					      const pos = node.renderedPosition()
 | 
				
			||||||
 | 
					      entry.wrapper.style.left = `${pos.x + 30}px`
 | 
				
			||||||
 | 
					      entry.wrapper.style.top = `${pos.y - 40}px`
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
							
								
								
									
										263
									
								
								pages/[tabId]/test/pathway2.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										263
									
								
								pages/[tabId]/test/pathway2.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,263 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div>
 | 
				
			||||||
 | 
					    <h2>KEGG Pathway Viewer (Cytoscape.js)</h2>
 | 
				
			||||||
 | 
					    <div ref="cyContainer" style="width: 100%; height: 800px; border: 1px solid #ccc;"></div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { ref, onMounted, nextTick } from 'vue'
 | 
				
			||||||
 | 
					import cytoscape from 'cytoscape'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const cyContainer = ref(null)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(async () => {
 | 
				
			||||||
 | 
					  await nextTick()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const res = await fetch('/pon00061.xml')
 | 
				
			||||||
 | 
					  const xmlText = await res.text()
 | 
				
			||||||
 | 
					  const parser = new DOMParser()
 | 
				
			||||||
 | 
					  const xmlDoc = parser.parseFromString(xmlText, 'application/xml')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const scale = 3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const entryMap = new Map()
 | 
				
			||||||
 | 
					  const entryDataMap = new Map()
 | 
				
			||||||
 | 
					  const parentMap = new Map()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const entries = Array.from(xmlDoc.getElementsByTagName('entry'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 1. 부모-자식 관계 정리
 | 
				
			||||||
 | 
					  for (const entry of entries) {
 | 
				
			||||||
 | 
					    const id = entry.getAttribute('id')
 | 
				
			||||||
 | 
					    entryDataMap.set(id, entry)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (entry.getAttribute('type') === 'group') {
 | 
				
			||||||
 | 
					      const components = entry.getElementsByTagName('component')
 | 
				
			||||||
 | 
					      for (const comp of components) {
 | 
				
			||||||
 | 
					        const childId = comp.getAttribute('id')
 | 
				
			||||||
 | 
					        parentMap.set(childId, id)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 2. 노드 생성
 | 
				
			||||||
 | 
					  const nodes = entries.map(entry => {
 | 
				
			||||||
 | 
					    const id = entry.getAttribute('id')
 | 
				
			||||||
 | 
					    const graphics = entry.getElementsByTagName('graphics')[0]
 | 
				
			||||||
 | 
					    const x = parseFloat(graphics?.getAttribute('x') || '0') * scale
 | 
				
			||||||
 | 
					    const y = parseFloat(graphics?.getAttribute('y') || '0') * scale
 | 
				
			||||||
 | 
					    const label = graphics?.getAttribute('name') || id
 | 
				
			||||||
 | 
					    const fgColor = graphics?.getAttribute('fgcolor') || '#000000'
 | 
				
			||||||
 | 
					    const bgColor = graphics?.getAttribute('bgcolor') || '#ffffff'
 | 
				
			||||||
 | 
					    const shapeType = graphics?.getAttribute('type') === 'circle' ? 'compound' : 'gene'
 | 
				
			||||||
 | 
					    const parent = parentMap.get(id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const node = {
 | 
				
			||||||
 | 
					      data: {
 | 
				
			||||||
 | 
					        id,
 | 
				
			||||||
 | 
					        label,
 | 
				
			||||||
 | 
					        link: entry.getAttribute('link') || null,
 | 
				
			||||||
 | 
					        reaction: entry.getAttribute('reaction') || null
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      position: { x, y },
 | 
				
			||||||
 | 
					      classes: shapeType,
 | 
				
			||||||
 | 
					      style: {
 | 
				
			||||||
 | 
					        'color': fgColor,
 | 
				
			||||||
 | 
					        'background-color': bgColor
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // parent 설정
 | 
				
			||||||
 | 
					    if (parent) node.data.parent = parent
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // ortholog 스타일 구분
 | 
				
			||||||
 | 
					    if (entry.getAttribute('type') === 'ortholog') {
 | 
				
			||||||
 | 
					      node.classes += ' ortholog'
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    entryMap.set(id, true)
 | 
				
			||||||
 | 
					    return node
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 3. group 노드가 edge에 등장할 경우 첫 자식으로 대체
 | 
				
			||||||
 | 
					  function resolveToRealNode(id) {
 | 
				
			||||||
 | 
					    if (!entryMap.has(id)) return null
 | 
				
			||||||
 | 
					    const entry = entryDataMap.get(id)
 | 
				
			||||||
 | 
					    if (entry?.getAttribute('type') === 'group') {
 | 
				
			||||||
 | 
					      const components = Array.from(entry.getElementsByTagName('component'))
 | 
				
			||||||
 | 
					      if (components.length > 0) return components[0].getAttribute('id')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return id
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const edges = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 4. relation 기반 edge 처리
 | 
				
			||||||
 | 
					  const relations = Array.from(xmlDoc.getElementsByTagName('relation'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  relations.forEach((rel, i) => {
 | 
				
			||||||
 | 
					    let source = resolveToRealNode(rel.getAttribute('entry1'))
 | 
				
			||||||
 | 
					    let target = resolveToRealNode(rel.getAttribute('entry2'))
 | 
				
			||||||
 | 
					    const type = rel.getAttribute('type')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const subtypes = Array.from(rel.getElementsByTagName('subtype'))
 | 
				
			||||||
 | 
					    const compoundSubtype = subtypes.find(s => s.getAttribute('name') === 'compound')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (compoundSubtype) {
 | 
				
			||||||
 | 
					      const compoundId = compoundSubtype.getAttribute('value')
 | 
				
			||||||
 | 
					      if (entryMap.has(source) && entryMap.has(target) && entryMap.has(compoundId)) {
 | 
				
			||||||
 | 
					        edges.push(
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            data: {
 | 
				
			||||||
 | 
					              id: `edge${i}-1`,
 | 
				
			||||||
 | 
					              source,
 | 
				
			||||||
 | 
					              target: compoundId,
 | 
				
			||||||
 | 
					              label: 'via compound'
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            data: {
 | 
				
			||||||
 | 
					              id: `edge${i}-2`,
 | 
				
			||||||
 | 
					              source: compoundId,
 | 
				
			||||||
 | 
					              target,
 | 
				
			||||||
 | 
					              label: 'via compound'
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      if (entryMap.has(source) && entryMap.has(target)) {
 | 
				
			||||||
 | 
					        edges.push({
 | 
				
			||||||
 | 
					          data: {
 | 
				
			||||||
 | 
					            id: `edge${i}`,
 | 
				
			||||||
 | 
					            source,
 | 
				
			||||||
 | 
					            target,
 | 
				
			||||||
 | 
					            label: type
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 5. reaction 기반 edge 처리
 | 
				
			||||||
 | 
					  const reactions = Array.from(xmlDoc.getElementsByTagName('reaction'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  reactions.forEach((reaction, i) => {
 | 
				
			||||||
 | 
					    const reactionType = reaction.getAttribute('type')
 | 
				
			||||||
 | 
					    const reactionLabel = reaction.getAttribute('name') || `reaction${i}`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const substrates = Array.from(reaction.getElementsByTagName('substrate'))
 | 
				
			||||||
 | 
					    const products = Array.from(reaction.getElementsByTagName('product'))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    substrates.forEach(substrate => {
 | 
				
			||||||
 | 
					      const sid = resolveToRealNode(substrate.getAttribute('id'))
 | 
				
			||||||
 | 
					      products.forEach(product => {
 | 
				
			||||||
 | 
					        const pid = resolveToRealNode(product.getAttribute('id'))
 | 
				
			||||||
 | 
					        if (entryMap.has(sid) && entryMap.has(pid)) {
 | 
				
			||||||
 | 
					          edges.push({
 | 
				
			||||||
 | 
					            data: {
 | 
				
			||||||
 | 
					              id: `reaction-${i}-${sid}-${pid}`,
 | 
				
			||||||
 | 
					              source: sid,
 | 
				
			||||||
 | 
					              target: pid,
 | 
				
			||||||
 | 
					              label: `${reactionLabel} (${reactionType})`
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          })
 | 
				
			||||||
 | 
					          if (reactionType === 'reversible') {
 | 
				
			||||||
 | 
					            edges.push({
 | 
				
			||||||
 | 
					              data: {
 | 
				
			||||||
 | 
					                id: `reaction-${i}-${pid}-${sid}`,
 | 
				
			||||||
 | 
					                source: pid,
 | 
				
			||||||
 | 
					                target: sid,
 | 
				
			||||||
 | 
					                label: `${reactionLabel} (reversible)`
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 6. Cytoscape 초기화
 | 
				
			||||||
 | 
					  const cy = cytoscape({
 | 
				
			||||||
 | 
					    container: cyContainer.value,
 | 
				
			||||||
 | 
					    elements: { nodes, edges },
 | 
				
			||||||
 | 
					    style: [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        selector: 'node',
 | 
				
			||||||
 | 
					        style: {
 | 
				
			||||||
 | 
					          label: 'data(label)',
 | 
				
			||||||
 | 
					          'text-valign': 'center',
 | 
				
			||||||
 | 
					          'text-halign': 'center',
 | 
				
			||||||
 | 
					          'shape': 'rectangle',
 | 
				
			||||||
 | 
					          'padding': '6px',
 | 
				
			||||||
 | 
					          'text-wrap': 'wrap',
 | 
				
			||||||
 | 
					          'color': '#000',
 | 
				
			||||||
 | 
					          'font-size': 10,
 | 
				
			||||||
 | 
					          'border-width': 1,
 | 
				
			||||||
 | 
					          'border-color': '#333'
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        selector: '.ortholog',
 | 
				
			||||||
 | 
					        style: {
 | 
				
			||||||
 | 
					          'background-color': '#ccffcc',
 | 
				
			||||||
 | 
					          'shape': 'round-rectangle',
 | 
				
			||||||
 | 
					          'border-width': 2,
 | 
				
			||||||
 | 
					          'border-color': '#339933'
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        selector: '$node > node',
 | 
				
			||||||
 | 
					        style: {
 | 
				
			||||||
 | 
					          'background-color': '#f3f3f3',
 | 
				
			||||||
 | 
					          'border-width': 2,
 | 
				
			||||||
 | 
					          'border-color': '#666',
 | 
				
			||||||
 | 
					          'shape': 'roundrectangle',
 | 
				
			||||||
 | 
					          'text-valign': 'top',
 | 
				
			||||||
 | 
					          'text-halign': 'center',
 | 
				
			||||||
 | 
					          'font-weight': 'bold',
 | 
				
			||||||
 | 
					          'padding': '20px'
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        selector: '.compound',
 | 
				
			||||||
 | 
					        style: {
 | 
				
			||||||
 | 
					          'background-color': '#ffe135',
 | 
				
			||||||
 | 
					          'shape': 'ellipse'
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        selector: 'edge',
 | 
				
			||||||
 | 
					        style: {
 | 
				
			||||||
 | 
					          width: 2,
 | 
				
			||||||
 | 
					          'line-color': '#888',
 | 
				
			||||||
 | 
					          'target-arrow-color': '#888',
 | 
				
			||||||
 | 
					          'target-arrow-shape': 'triangle',
 | 
				
			||||||
 | 
					          'curve-style': 'bezier',
 | 
				
			||||||
 | 
					          'label': 'data(label)',
 | 
				
			||||||
 | 
					          'font-size': 8,
 | 
				
			||||||
 | 
					          'text-background-opacity': 1,
 | 
				
			||||||
 | 
					          'text-background-color': '#fff',
 | 
				
			||||||
 | 
					          'text-background-shape': 'roundrectangle',
 | 
				
			||||||
 | 
					          'text-rotation': 'autorotate'
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    layout: { name: 'preset' }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  cy.ready(() => {
 | 
				
			||||||
 | 
					    cy.fit(cy.elements(), 100)
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 7. 노드 클릭 시 KEGG 링크 열기
 | 
				
			||||||
 | 
					  cy.on('tap', 'node', (evt) => {
 | 
				
			||||||
 | 
					    const node = evt.target
 | 
				
			||||||
 | 
					    const link = node.data('link')
 | 
				
			||||||
 | 
					    if (link) {
 | 
				
			||||||
 | 
					      window.open(link, '_blank')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
							
								
								
									
										254
									
								
								pages/[tabId]/test/pathway3.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										254
									
								
								pages/[tabId]/test/pathway3.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,254 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					    <div ref="cyContainer" class="cy-container"></div>
 | 
				
			||||||
 | 
					  </template>
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  <script setup>
 | 
				
			||||||
 | 
					  import { ref, onMounted, nextTick } from 'vue'
 | 
				
			||||||
 | 
					  import cytoscape from 'cytoscape'
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  if (typeof document !== 'undefined') {
 | 
				
			||||||
 | 
					    const script = document.createElement('script');
 | 
				
			||||||
 | 
					    script.src = '/dist/cy_custom.js';
 | 
				
			||||||
 | 
					    script.onload = () => {
 | 
				
			||||||
 | 
					      window.igvCustomLoaded = true;
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    document.head.appendChild(script);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let CytoscapeOverlays = null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (import.meta.client) {
 | 
				
			||||||
 | 
					    const useOverlay = (await import('@/composables/useOverlay')).default
 | 
				
			||||||
 | 
					    CytoscapeOverlays = await useOverlay()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const cyContainer = ref(null)
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  onMounted(async () => {
 | 
				
			||||||
 | 
					    await nextTick()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    cytoscape.use(CytoscapeOverlays.default)
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					    const res = await fetch('/pon00061.xml')
 | 
				
			||||||
 | 
					    const xmlText = await res.text()
 | 
				
			||||||
 | 
					    const parser = new DOMParser()
 | 
				
			||||||
 | 
					    const xmlDoc = parser.parseFromString(xmlText, 'application/xml')
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					    const scale = 3
 | 
				
			||||||
 | 
					    const entryMap = new Map()
 | 
				
			||||||
 | 
					    const entryDataMap = new Map()
 | 
				
			||||||
 | 
					    const parentMap = new Map()
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					    const entries = Array.from(xmlDoc.getElementsByTagName('entry'))
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					    for (const entry of entries) {
 | 
				
			||||||
 | 
					      const id = entry.getAttribute('id')
 | 
				
			||||||
 | 
					      entryDataMap.set(id, entry)
 | 
				
			||||||
 | 
					      if (entry.getAttribute('type') === 'group') {
 | 
				
			||||||
 | 
					        const components = entry.getElementsByTagName('component')
 | 
				
			||||||
 | 
					        for (const comp of components) {
 | 
				
			||||||
 | 
					          parentMap.set(comp.getAttribute('id'), id)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					    const nodes = entries.map(entry => {
 | 
				
			||||||
 | 
					      const id = entry.getAttribute('id')
 | 
				
			||||||
 | 
					      const graphics = entry.getElementsByTagName('graphics')[0]
 | 
				
			||||||
 | 
					      const x = parseFloat(graphics?.getAttribute('x') || '0') * scale
 | 
				
			||||||
 | 
					      const y = parseFloat(graphics?.getAttribute('y') || '0') * scale
 | 
				
			||||||
 | 
					      const label = graphics?.getAttribute('name') || id
 | 
				
			||||||
 | 
					      const fgColor = graphics?.getAttribute('fgcolor') || '#000000'
 | 
				
			||||||
 | 
					      const bgColor = graphics?.getAttribute('bgcolor') || '#ffffff'
 | 
				
			||||||
 | 
					      const parent = parentMap.get(id)
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					      const valueA = Math.floor(Math.random() * 50)
 | 
				
			||||||
 | 
					      const valueB = 100 - valueA
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					      const node = {
 | 
				
			||||||
 | 
					        data: {
 | 
				
			||||||
 | 
					          id,
 | 
				
			||||||
 | 
					          label,
 | 
				
			||||||
 | 
					          link: entry.getAttribute('link') || null,
 | 
				
			||||||
 | 
					          reaction: entry.getAttribute('reaction') || null,
 | 
				
			||||||
 | 
					          chartData: [valueA, valueB]
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        position: { x, y },
 | 
				
			||||||
 | 
					        classes: entry.getAttribute('type'),
 | 
				
			||||||
 | 
					        style: {
 | 
				
			||||||
 | 
					          color: fgColor,
 | 
				
			||||||
 | 
					          'background-color': bgColor
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					      if (parent) node.data.parent = parent
 | 
				
			||||||
 | 
					      entryMap.set(id, true)
 | 
				
			||||||
 | 
					      return node
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					    function resolveToRealNode(id) {
 | 
				
			||||||
 | 
					      if (!entryMap.has(id)) return null
 | 
				
			||||||
 | 
					      const entry = entryDataMap.get(id)
 | 
				
			||||||
 | 
					      if (entry?.getAttribute('type') === 'group') {
 | 
				
			||||||
 | 
					        const components = Array.from(entry.getElementsByTagName('component'))
 | 
				
			||||||
 | 
					        if (components.length > 0) return components[0].getAttribute('id')
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      return id
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					    const edges = []
 | 
				
			||||||
 | 
					    const relations = Array.from(xmlDoc.getElementsByTagName('relation'))
 | 
				
			||||||
 | 
					    relations.forEach((rel, i) => {
 | 
				
			||||||
 | 
					      const source = resolveToRealNode(rel.getAttribute('entry1'))
 | 
				
			||||||
 | 
					      const target = resolveToRealNode(rel.getAttribute('entry2'))
 | 
				
			||||||
 | 
					      const type = rel.getAttribute('type')
 | 
				
			||||||
 | 
					      const subtypes = Array.from(rel.getElementsByTagName('subtype'))
 | 
				
			||||||
 | 
					      const compoundSubtype = subtypes.find(s => s.getAttribute('name') === 'compound')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (compoundSubtype) {
 | 
				
			||||||
 | 
					        const compoundId = compoundSubtype.getAttribute('value')
 | 
				
			||||||
 | 
					        if (entryMap.has(source) && entryMap.has(target) && entryMap.has(compoundId)) {
 | 
				
			||||||
 | 
					          const sourceConst = source;
 | 
				
			||||||
 | 
					          const targetConst = target;
 | 
				
			||||||
 | 
					          edges.push(
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					              data: {
 | 
				
			||||||
 | 
					                id: `edge${i}-1`,
 | 
				
			||||||
 | 
					                source: sourceConst,
 | 
				
			||||||
 | 
					                target: compoundId,
 | 
				
			||||||
 | 
					                label: 'via compound'
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					              data: {
 | 
				
			||||||
 | 
					                id: `edge${i}-2`,
 | 
				
			||||||
 | 
					                source: compoundId,
 | 
				
			||||||
 | 
					                target: targetConst,
 | 
				
			||||||
 | 
					                label: 'via compound'
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        if (entryMap.has(source) && entryMap.has(target)) {
 | 
				
			||||||
 | 
					          const sourceConst = source;
 | 
				
			||||||
 | 
					          const targetConst = target;
 | 
				
			||||||
 | 
					          edges.push({
 | 
				
			||||||
 | 
					            data: {
 | 
				
			||||||
 | 
					              id: `edge${i}`,
 | 
				
			||||||
 | 
					              source: sourceConst,
 | 
				
			||||||
 | 
					              target: targetConst,
 | 
				
			||||||
 | 
					              label: type
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          })
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					    const reactions = Array.from(xmlDoc.getElementsByTagName('reaction'))
 | 
				
			||||||
 | 
					    reactions.forEach((reaction, i) => {
 | 
				
			||||||
 | 
					      const reactionType = reaction.getAttribute('type')
 | 
				
			||||||
 | 
					      const substrates = Array.from(reaction.getElementsByTagName('substrate'))
 | 
				
			||||||
 | 
					      const products = Array.from(reaction.getElementsByTagName('product'))
 | 
				
			||||||
 | 
					      substrates.forEach(substrate => {
 | 
				
			||||||
 | 
					        const sid = resolveToRealNode(substrate.getAttribute('id'))
 | 
				
			||||||
 | 
					        products.forEach(product => {
 | 
				
			||||||
 | 
					          const pid = resolveToRealNode(product.getAttribute('id'))
 | 
				
			||||||
 | 
					          if (entryMap.has(sid) && entryMap.has(pid)) {
 | 
				
			||||||
 | 
					            edges.push({
 | 
				
			||||||
 | 
					              data: {
 | 
				
			||||||
 | 
					                id: `reaction-${i}-${sid}-${pid}`,
 | 
				
			||||||
 | 
					                source: sid,
 | 
				
			||||||
 | 
					                target: pid,
 | 
				
			||||||
 | 
					                label: `${reactionType}`
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					    // 원형(도넛/파이) 차트 overlay 생성 (흰색 라인 없이)
 | 
				
			||||||
 | 
					    const pieOverlay = CytoscapeOverlays.renderSymbol({
 | 
				
			||||||
 | 
					      symbol: node => {
 | 
				
			||||||
 | 
					        const data = node.data('chartData') || [50, 50]
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					          draw: (ctx, size) => {
 | 
				
			||||||
 | 
					            const total = data[0] + data[1]
 | 
				
			||||||
 | 
					            const r = Math.sqrt(size / Math.PI)
 | 
				
			||||||
 | 
					            const innerR = r * 0.5
 | 
				
			||||||
 | 
					            const startAngle = 0
 | 
				
			||||||
 | 
					            const angleA = (data[0] / total) * 2 * Math.PI
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // A 영역
 | 
				
			||||||
 | 
					            ctx.beginPath()
 | 
				
			||||||
 | 
					            ctx.arc(0, 0, r, startAngle, startAngle + angleA)
 | 
				
			||||||
 | 
					            ctx.arc(0, 0, innerR, startAngle + angleA, startAngle, true)
 | 
				
			||||||
 | 
					            ctx.closePath()
 | 
				
			||||||
 | 
					            ctx.fillStyle = '#36a2eb'
 | 
				
			||||||
 | 
					            ctx.fill()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // B 영역
 | 
				
			||||||
 | 
					            ctx.beginPath()
 | 
				
			||||||
 | 
					            ctx.arc(0, 0, r, startAngle + angleA, startAngle + 2 * Math.PI)
 | 
				
			||||||
 | 
					            ctx.arc(0, 0, innerR, startAngle + 2 * Math.PI, startAngle + angleA, true)
 | 
				
			||||||
 | 
					            ctx.closePath()
 | 
				
			||||||
 | 
					            ctx.fillStyle = '#ff6384'
 | 
				
			||||||
 | 
					            ctx.fill()
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      color: '',
 | 
				
			||||||
 | 
					      width: 32,
 | 
				
			||||||
 | 
					      height: 32,
 | 
				
			||||||
 | 
					      borderColor: '#333'
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const cy = cytoscape({
 | 
				
			||||||
 | 
					      container: cyContainer.value,
 | 
				
			||||||
 | 
					      elements: { nodes, edges },
 | 
				
			||||||
 | 
					      style: [
 | 
				
			||||||
 | 
					        { selector: 'node', style: {
 | 
				
			||||||
 | 
					            label: 'data(label)',
 | 
				
			||||||
 | 
					            'background-color': '#eee',
 | 
				
			||||||
 | 
					            'text-valign': 'center',
 | 
				
			||||||
 | 
					            'text-halign': 'center',
 | 
				
			||||||
 | 
					            'border-width': 2,
 | 
				
			||||||
 | 
					            'border-color': '#333',
 | 
				
			||||||
 | 
					            'font-size': 8,
 | 
				
			||||||
 | 
					            'padding': '6px',
 | 
				
			||||||
 | 
					            'width': 32,
 | 
				
			||||||
 | 
					            'height': 32,
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        { selector: 'edge', style: { 'curve-style': 'bezier', 'target-arrow-shape': 'triangle', 'line-color': '#888' } },
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					      layout: { name: 'preset', padding: 100 }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    cy.overlays(
 | 
				
			||||||
 | 
					      [
 | 
				
			||||||
 | 
					        { position: 'right', vis: pieOverlay }
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        updateOn: 'render',
 | 
				
			||||||
 | 
					        backgroundColor: 'white'
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    applyCustomZoomHandling(cy);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					  </script>
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  <style>
 | 
				
			||||||
 | 
					  .cy-container {
 | 
				
			||||||
 | 
					    width: 100vw !important;
 | 
				
			||||||
 | 
					    max-width: 100vw !important;
 | 
				
			||||||
 | 
					    min-width: 0 !important;
 | 
				
			||||||
 | 
					    height: 800px !important;
 | 
				
			||||||
 | 
					    overflow: hidden !important;
 | 
				
			||||||
 | 
					    position: relative;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  </style>
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
							
								
								
									
										260
									
								
								pages/[tabId]/test/pathway4.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										260
									
								
								pages/[tabId]/test/pathway4.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,260 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div ref="cyContainer" class="cy-container"></div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { ref, onMounted, nextTick } from "vue";
 | 
				
			||||||
 | 
					import cytoscape from "cytoscape";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					let CytoscapeOverlays = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if (import.meta.client) {
 | 
				
			||||||
 | 
					  const useOverlay = (await import("@/composables/useOverlay")).default;
 | 
				
			||||||
 | 
					  CytoscapeOverlays = await useOverlay();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const cyContainer = ref(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(async () => {
 | 
				
			||||||
 | 
					  await nextTick();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  cytoscape.use(CytoscapeOverlays.default);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const res = await fetch("/expanded_pathway21600.xml");
 | 
				
			||||||
 | 
					  const xmlText = await res.text();
 | 
				
			||||||
 | 
					  const parser = new DOMParser();
 | 
				
			||||||
 | 
					  const xmlDoc = parser.parseFromString(xmlText, "application/xml");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const scale = 3;
 | 
				
			||||||
 | 
					  const entryMap = new Map();
 | 
				
			||||||
 | 
					  const entryDataMap = new Map();
 | 
				
			||||||
 | 
					  const parentMap = new Map();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const entries = Array.from(xmlDoc.getElementsByTagName("entry"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  for (const entry of entries) {
 | 
				
			||||||
 | 
					    const id = entry.getAttribute("id");
 | 
				
			||||||
 | 
					    entryDataMap.set(id, entry);
 | 
				
			||||||
 | 
					    if (entry.getAttribute("type") === "group") {
 | 
				
			||||||
 | 
					      const components = entry.getElementsByTagName("component");
 | 
				
			||||||
 | 
					      for (const comp of components) {
 | 
				
			||||||
 | 
					        parentMap.set(comp.getAttribute("id"), id);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const nodes = entries.map(entry => {
 | 
				
			||||||
 | 
					    const id = entry.getAttribute("id");
 | 
				
			||||||
 | 
					    const graphics = entry.getElementsByTagName("graphics")[0];
 | 
				
			||||||
 | 
					    const x = parseFloat(graphics?.getAttribute("x") || "0") * scale;
 | 
				
			||||||
 | 
					    const y = parseFloat(graphics?.getAttribute("y") || "0") * scale;
 | 
				
			||||||
 | 
					    const label = graphics?.getAttribute("name") || id;
 | 
				
			||||||
 | 
					    const fgColor = graphics?.getAttribute("fgcolor") || "#000000";
 | 
				
			||||||
 | 
					    const bgColor = graphics?.getAttribute("bgcolor") || "#ffffff";
 | 
				
			||||||
 | 
					    const parent = parentMap.get(id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const valueA = Math.floor(Math.random() * 50);
 | 
				
			||||||
 | 
					    const valueB = 100 - valueA;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const node = {
 | 
				
			||||||
 | 
					      data: {
 | 
				
			||||||
 | 
					        id,
 | 
				
			||||||
 | 
					        label,
 | 
				
			||||||
 | 
					        link: entry.getAttribute("link") || null,
 | 
				
			||||||
 | 
					        reaction: entry.getAttribute("reaction") || null,
 | 
				
			||||||
 | 
					        chartData: [valueA, valueB],
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      position: { x, y },
 | 
				
			||||||
 | 
					      classes: entry.getAttribute("type"),
 | 
				
			||||||
 | 
					      style: {
 | 
				
			||||||
 | 
					        color: fgColor,
 | 
				
			||||||
 | 
					        "background-color": bgColor,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (parent) node.data.parent = parent;
 | 
				
			||||||
 | 
					    entryMap.set(id, true);
 | 
				
			||||||
 | 
					    return node;
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  function resolveToRealNode(id) {
 | 
				
			||||||
 | 
					    if (!entryMap.has(id)) return null;
 | 
				
			||||||
 | 
					    const entry = entryDataMap.get(id);
 | 
				
			||||||
 | 
					    if (entry?.getAttribute("type") === "group") {
 | 
				
			||||||
 | 
					      const components = Array.from(entry.getElementsByTagName("component"));
 | 
				
			||||||
 | 
					      if (components.length > 0) return components[0].getAttribute("id");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return id;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const edges = [];
 | 
				
			||||||
 | 
					  const relations = Array.from(xmlDoc.getElementsByTagName("relation"));
 | 
				
			||||||
 | 
					  relations.forEach((rel, i) => {
 | 
				
			||||||
 | 
					    const source = resolveToRealNode(rel.getAttribute("entry1"));
 | 
				
			||||||
 | 
					    const target = resolveToRealNode(rel.getAttribute("entry2"));
 | 
				
			||||||
 | 
					    const type = rel.getAttribute("type");
 | 
				
			||||||
 | 
					    const subtypes = Array.from(rel.getElementsByTagName("subtype"));
 | 
				
			||||||
 | 
					    const compoundSubtype = subtypes.find(
 | 
				
			||||||
 | 
					      s => s.getAttribute("name") === "compound"
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (compoundSubtype) {
 | 
				
			||||||
 | 
					      const compoundId = compoundSubtype.getAttribute("value");
 | 
				
			||||||
 | 
					      if (
 | 
				
			||||||
 | 
					        entryMap.has(source) &&
 | 
				
			||||||
 | 
					        entryMap.has(target) &&
 | 
				
			||||||
 | 
					        entryMap.has(compoundId)
 | 
				
			||||||
 | 
					      ) {
 | 
				
			||||||
 | 
					        const sourceConst = source;
 | 
				
			||||||
 | 
					        const targetConst = target;
 | 
				
			||||||
 | 
					        edges.push(
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            data: {
 | 
				
			||||||
 | 
					              id: `edge${i}-1`,
 | 
				
			||||||
 | 
					              source: sourceConst,
 | 
				
			||||||
 | 
					              target: compoundId,
 | 
				
			||||||
 | 
					              label: "via compound",
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            data: {
 | 
				
			||||||
 | 
					              id: `edge${i}-2`,
 | 
				
			||||||
 | 
					              source: compoundId,
 | 
				
			||||||
 | 
					              target: targetConst,
 | 
				
			||||||
 | 
					              label: "via compound",
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      if (entryMap.has(source) && entryMap.has(target)) {
 | 
				
			||||||
 | 
					        const sourceConst = source;
 | 
				
			||||||
 | 
					        const targetConst = target;
 | 
				
			||||||
 | 
					        edges.push({
 | 
				
			||||||
 | 
					          data: {
 | 
				
			||||||
 | 
					            id: `edge${i}`,
 | 
				
			||||||
 | 
					            source: sourceConst,
 | 
				
			||||||
 | 
					            target: targetConst,
 | 
				
			||||||
 | 
					            label: type,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const reactions = Array.from(xmlDoc.getElementsByTagName("reaction"));
 | 
				
			||||||
 | 
					  reactions.forEach((reaction, i) => {
 | 
				
			||||||
 | 
					    const reactionType = reaction.getAttribute("type");
 | 
				
			||||||
 | 
					    const substrates = Array.from(reaction.getElementsByTagName("substrate"));
 | 
				
			||||||
 | 
					    const products = Array.from(reaction.getElementsByTagName("product"));
 | 
				
			||||||
 | 
					    substrates.forEach(substrate => {
 | 
				
			||||||
 | 
					      const sid = resolveToRealNode(substrate.getAttribute("id"));
 | 
				
			||||||
 | 
					      products.forEach(product => {
 | 
				
			||||||
 | 
					        const pid = resolveToRealNode(product.getAttribute("id"));
 | 
				
			||||||
 | 
					        if (entryMap.has(sid) && entryMap.has(pid)) {
 | 
				
			||||||
 | 
					          edges.push({
 | 
				
			||||||
 | 
					            data: {
 | 
				
			||||||
 | 
					              id: `reaction-${i}-${sid}-${pid}`,
 | 
				
			||||||
 | 
					              source: sid,
 | 
				
			||||||
 | 
					              target: pid,
 | 
				
			||||||
 | 
					              label: `${reactionType}`,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 원형(도넛/파이) 차트 overlay 생성 (흰색 라인 없이)
 | 
				
			||||||
 | 
					  const pieOverlay = CytoscapeOverlays.renderSymbol({
 | 
				
			||||||
 | 
					    symbol: node => {
 | 
				
			||||||
 | 
					      const data = node.data("chartData") || [50, 50];
 | 
				
			||||||
 | 
					      return {
 | 
				
			||||||
 | 
					        draw: (ctx, size) => {
 | 
				
			||||||
 | 
					          const total = data[0] + data[1];
 | 
				
			||||||
 | 
					          const r = Math.sqrt(size / Math.PI);
 | 
				
			||||||
 | 
					          const innerR = r * 0.5;
 | 
				
			||||||
 | 
					          const startAngle = 0;
 | 
				
			||||||
 | 
					          const angleA = (data[0] / total) * 2 * Math.PI;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          // A 영역
 | 
				
			||||||
 | 
					          ctx.beginPath();
 | 
				
			||||||
 | 
					          ctx.arc(0, 0, r, startAngle, startAngle + angleA);
 | 
				
			||||||
 | 
					          ctx.arc(0, 0, innerR, startAngle + angleA, startAngle, true);
 | 
				
			||||||
 | 
					          ctx.closePath();
 | 
				
			||||||
 | 
					          ctx.fillStyle = "#36a2eb";
 | 
				
			||||||
 | 
					          ctx.fill();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          // B 영역
 | 
				
			||||||
 | 
					          ctx.beginPath();
 | 
				
			||||||
 | 
					          ctx.arc(0, 0, r, startAngle + angleA, startAngle + 2 * Math.PI);
 | 
				
			||||||
 | 
					          ctx.arc(
 | 
				
			||||||
 | 
					            0,
 | 
				
			||||||
 | 
					            0,
 | 
				
			||||||
 | 
					            innerR,
 | 
				
			||||||
 | 
					            startAngle + 2 * Math.PI,
 | 
				
			||||||
 | 
					            startAngle + angleA,
 | 
				
			||||||
 | 
					            true
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					          ctx.closePath();
 | 
				
			||||||
 | 
					          ctx.fillStyle = "#ff6384";
 | 
				
			||||||
 | 
					          ctx.fill();
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    color: "",
 | 
				
			||||||
 | 
					    width: 32,
 | 
				
			||||||
 | 
					    height: 32,
 | 
				
			||||||
 | 
					    borderColor: "#333",
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const cy = cytoscape({
 | 
				
			||||||
 | 
					    container: cyContainer.value,
 | 
				
			||||||
 | 
					    elements: { nodes, edges },
 | 
				
			||||||
 | 
					    style: [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        selector: "node",
 | 
				
			||||||
 | 
					        style: {
 | 
				
			||||||
 | 
					          label: "data(label)",
 | 
				
			||||||
 | 
					          "background-color": "#eee",
 | 
				
			||||||
 | 
					          "text-valign": "center",
 | 
				
			||||||
 | 
					          "text-halign": "center",
 | 
				
			||||||
 | 
					          "border-width": 2,
 | 
				
			||||||
 | 
					          "border-color": "#333",
 | 
				
			||||||
 | 
					          "font-size": 8,
 | 
				
			||||||
 | 
					          padding: "6px",
 | 
				
			||||||
 | 
					          width: 32,
 | 
				
			||||||
 | 
					          height: 32,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        selector: "edge",
 | 
				
			||||||
 | 
					        style: {
 | 
				
			||||||
 | 
					          "curve-style": "bezier",
 | 
				
			||||||
 | 
					          "target-arrow-shape": "triangle",
 | 
				
			||||||
 | 
					          "line-color": "#888",
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					    layout: { name: "preset", padding: 100 },
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  cy.overlays([{ position: "right", vis: pieOverlay }], {
 | 
				
			||||||
 | 
					    updateOn: "render",
 | 
				
			||||||
 | 
					    backgroundColor: "white",
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  applyCustomZoomHandling(cy);
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style>
 | 
				
			||||||
 | 
					.cy-container {
 | 
				
			||||||
 | 
					  width: 100vw !important;
 | 
				
			||||||
 | 
					  max-width: 100vw !important;
 | 
				
			||||||
 | 
					  min-width: 0 !important;
 | 
				
			||||||
 | 
					  height: 800px !important;
 | 
				
			||||||
 | 
					  overflow: hidden !important;
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										130
									
								
								pages/[tabId]/test/pathwayJson.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								pages/[tabId]/test/pathwayJson.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,130 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					    <div>
 | 
				
			||||||
 | 
					      <select v-model="selectedGroup" @change="focusGroup">
 | 
				
			||||||
 | 
					        <option v-for="g in groups" :key="g" :value="g">
 | 
				
			||||||
 | 
					          Group {{ g }}
 | 
				
			||||||
 | 
					        </option>
 | 
				
			||||||
 | 
					      </select>
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					      <div ref="cyEl" style="width: 100%; height: 800px;"></div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </template>
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  <script setup>
 | 
				
			||||||
 | 
					  import { ref, onMounted } from 'vue';
 | 
				
			||||||
 | 
					  import cytoscape from "cytoscape";
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  let CytoscapeOverlays = null
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  if (import.meta.client) {
 | 
				
			||||||
 | 
					    const useOverlay = (await import('@/composables/useOverlay')).default
 | 
				
			||||||
 | 
					    CytoscapeOverlays = await useOverlay()
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  useHead({
 | 
				
			||||||
 | 
					    script: [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        src: '/dist/cy_custom.js',
 | 
				
			||||||
 | 
					        defer: true
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  const cyEl = ref(null);
 | 
				
			||||||
 | 
					  const cy = ref(null);
 | 
				
			||||||
 | 
					  const selectedGroup = ref(null);
 | 
				
			||||||
 | 
					  const groups = ref([]);
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  onMounted(() => {
 | 
				
			||||||
 | 
					    fetch("/group43200.json")
 | 
				
			||||||
 | 
					      .then(res => res.json())
 | 
				
			||||||
 | 
					      .then(data => {
 | 
				
			||||||
 | 
					        const nodes = data.entries.map(e => ({
 | 
				
			||||||
 | 
					          data: { id: `n${e.id}`, label: e.graphics.name, group: e.group },
 | 
				
			||||||
 | 
					          position: { x: e.graphics.x, y: e.graphics.y },
 | 
				
			||||||
 | 
					        }));
 | 
				
			||||||
 | 
					        const edges = data.relations.map(r => ({
 | 
				
			||||||
 | 
					          data: {
 | 
				
			||||||
 | 
					            source: `n${r.entry1}`,
 | 
				
			||||||
 | 
					            target: `n${r.entry2}`,
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }));
 | 
				
			||||||
 | 
					        groups.value = [...new Set(data.entries.map(e => e.group))];
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					        const colorPalette = ['#e6194b', '#3cb44b', '#ffe119', '#4363d8', '#f58231', '#911eb4', '#46f0f0', '#f032e6', '#bcf60c', '#fabebe', '#008080', '#e6beff', '#9a6324', '#fffac8', '#800000', '#aaffc3', '#808000', '#ffd8b1', '#000075', '#808080'];
 | 
				
			||||||
 | 
					        const groupColorMap = {};
 | 
				
			||||||
 | 
					        groups.value.forEach((group, index) => {
 | 
				
			||||||
 | 
					          groupColorMap[group] = colorPalette[index % colorPalette.length];
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        cy.value = cytoscape({
 | 
				
			||||||
 | 
					          container: cyEl.value,
 | 
				
			||||||
 | 
					          elements: [...nodes, ...edges],
 | 
				
			||||||
 | 
					          style: [
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					              selector: 'node',
 | 
				
			||||||
 | 
					              style: {
 | 
				
			||||||
 | 
					                label: 'data(label)',
 | 
				
			||||||
 | 
					                'background-color': (node) => groupColorMap[node.data('group')] || '#ccc',
 | 
				
			||||||
 | 
					                'text-valign': 'center',
 | 
				
			||||||
 | 
					                'text-halign': 'center',
 | 
				
			||||||
 | 
					                'width': 30,
 | 
				
			||||||
 | 
					                'height': 30
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					              selector: 'edge',
 | 
				
			||||||
 | 
					              style: {
 | 
				
			||||||
 | 
					                'line-color': '#ccc',
 | 
				
			||||||
 | 
					                'target-arrow-shape': 'triangle'
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					          layout: { name: 'preset' },
 | 
				
			||||||
 | 
					          zoomingEnabled: true,
 | 
				
			||||||
 | 
					          userZoomingEnabled: true
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        setTimeout(() => {
 | 
				
			||||||
 | 
					          if (typeof applyCustomZoomHandling === 'function') {
 | 
				
			||||||
 | 
					            applyCustomZoomHandling(cy.value);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }, 100);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  function focusGroup() {
 | 
				
			||||||
 | 
					    if (!cy.value || !selectedGroup.value) return;
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					    const groupNodes = cy.value.nodes().filter(ele => ele.data('group') === selectedGroup.value);
 | 
				
			||||||
 | 
					    if (groupNodes.length === 0) return;
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					    const bb = groupNodes.boundingBox();
 | 
				
			||||||
 | 
					    const container = cy.value.container();
 | 
				
			||||||
 | 
					    const viewportWidth = container.clientWidth;
 | 
				
			||||||
 | 
					    const viewportHeight = container.clientHeight;
 | 
				
			||||||
 | 
					    const padding = 40;
 | 
				
			||||||
 | 
					    const zoomX = (viewportWidth - 2 * padding) / bb.w;
 | 
				
			||||||
 | 
					    const zoomY = (viewportHeight - 2 * padding) / bb.h;
 | 
				
			||||||
 | 
					    const zoom = Math.min(zoomX, zoomY, 5);
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					    cy.value.animate({
 | 
				
			||||||
 | 
					        zoom: zoom,
 | 
				
			||||||
 | 
					        center: { eles: groupNodes }
 | 
				
			||||||
 | 
					    }, {
 | 
				
			||||||
 | 
					        duration: 800
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  </script>
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  <style>
 | 
				
			||||||
 | 
					  .cy-container {
 | 
				
			||||||
 | 
					    width: 100vw !important;
 | 
				
			||||||
 | 
					    max-width: 100vw !important;
 | 
				
			||||||
 | 
					    min-width: 0 !important;
 | 
				
			||||||
 | 
					    height: 800px !important;
 | 
				
			||||||
 | 
					    overflow: hidden !important;
 | 
				
			||||||
 | 
					    position: relative;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  </style>
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
							
								
								
									
										299
									
								
								pages/[tabId]/test/test01.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										299
									
								
								pages/[tabId]/test/test01.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,299 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					  <div class="test-page">
 | 
				
			||||||
 | 
					    <div class="container">
 | 
				
			||||||
 | 
					      <h1 class="title">테스트 페이지 01</h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div class="test-section">
 | 
				
			||||||
 | 
					        <h2>기본 기능 테스트</h2>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <div class="test-card">
 | 
				
			||||||
 | 
					          <h3>사용자 정보 테스트</h3>
 | 
				
			||||||
 | 
					          <p>
 | 
				
			||||||
 | 
					            로그인 상태: {{ userStore.isLoggedIn ? "로그인됨" : "로그아웃됨" }}
 | 
				
			||||||
 | 
					          </p>
 | 
				
			||||||
 | 
					          <p v-if="userStore.user">사용자: {{ userStore.user.name }}</p>
 | 
				
			||||||
 | 
					          <p v-if="userStore.user">역할: {{ userStore.user.role }}</p>
 | 
				
			||||||
 | 
					          <div class="button-group">
 | 
				
			||||||
 | 
					            <button class="btn btn-success" @click="loginTest">
 | 
				
			||||||
 | 
					              테스트 로그인
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					            <button class="btn btn-info" @click="adminLoginTest">
 | 
				
			||||||
 | 
					              관리자 로그인
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					            <button class="btn btn-warning" @click="logoutTest">
 | 
				
			||||||
 | 
					              테스트 로그아웃
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div class="test-section">
 | 
				
			||||||
 | 
					        <h2>API 테스트</h2>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <div class="test-card">
 | 
				
			||||||
 | 
					          <h3>데이터 로딩 테스트</h3>
 | 
				
			||||||
 | 
					          <button
 | 
				
			||||||
 | 
					            class="btn btn-primary"
 | 
				
			||||||
 | 
					            :disabled="loading"
 | 
				
			||||||
 | 
					            @click="loadTestData"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            {{ loading ? "로딩 중..." : "테스트 데이터 로드" }}
 | 
				
			||||||
 | 
					          </button>
 | 
				
			||||||
 | 
					          <div v-if="testData" class="data-display">
 | 
				
			||||||
 | 
					            <pre>{{ JSON.stringify(testData, null, 2) }}</pre>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { ref, onMounted } from "vue";
 | 
				
			||||||
 | 
					import { useUserStore } from "~/stores/user";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// 페이지 메타데이터
 | 
				
			||||||
 | 
					definePageMeta({
 | 
				
			||||||
 | 
					  title: "테스트 페이지 01",
 | 
				
			||||||
 | 
					  description: "기능 테스트를 위한 페이지",
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const userStore = useUserStore();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// 반응형 데이터
 | 
				
			||||||
 | 
					const loading = ref(false);
 | 
				
			||||||
 | 
					const testData = ref<{
 | 
				
			||||||
 | 
					  id: number;
 | 
				
			||||||
 | 
					  name: string;
 | 
				
			||||||
 | 
					  timestamp: string;
 | 
				
			||||||
 | 
					  items: string[];
 | 
				
			||||||
 | 
					} | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// 메서드
 | 
				
			||||||
 | 
					const loginTest = () => {
 | 
				
			||||||
 | 
					  // 테스트용 로그인 (실제로는 API 호출이 필요)
 | 
				
			||||||
 | 
					  userStore.user = {
 | 
				
			||||||
 | 
					    id: "1",
 | 
				
			||||||
 | 
					    userId: "test",
 | 
				
			||||||
 | 
					    email: "test@example.com",
 | 
				
			||||||
 | 
					    name: "테스트 사용자",
 | 
				
			||||||
 | 
					    role: "user",
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  userStore.isLoggedIn = true;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const adminLoginTest = () => {
 | 
				
			||||||
 | 
					  // 관리자 로그인 로직 구현
 | 
				
			||||||
 | 
					  userStore.user = {
 | 
				
			||||||
 | 
					    id: "2",
 | 
				
			||||||
 | 
					    userId: "admin",
 | 
				
			||||||
 | 
					    email: "admin@example.com",
 | 
				
			||||||
 | 
					    name: "관리자",
 | 
				
			||||||
 | 
					    role: "admin",
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  userStore.isLoggedIn = true;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const logoutTest = () => {
 | 
				
			||||||
 | 
					  userStore.logout();
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const loadTestData = async () => {
 | 
				
			||||||
 | 
					  loading.value = true;
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    // 실제 API 호출 대신 시뮬레이션
 | 
				
			||||||
 | 
					    await new Promise((resolve) => setTimeout(resolve, 1000));
 | 
				
			||||||
 | 
					    testData.value = {
 | 
				
			||||||
 | 
					      id: 1,
 | 
				
			||||||
 | 
					      name: "테스트 데이터",
 | 
				
			||||||
 | 
					      timestamp: new Date().toISOString(),
 | 
				
			||||||
 | 
					      items: ["항목 1", "항목 2", "항목 3"],
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("데이터 로딩 실패:", error);
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    loading.value = false;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// 라이프사이클
 | 
				
			||||||
 | 
					onMounted(() => {
 | 
				
			||||||
 | 
					  console.log("테스트 페이지 01이 마운트되었습니다.");
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.test-page {
 | 
				
			||||||
 | 
					  min-height: 100vh;
 | 
				
			||||||
 | 
					  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
 | 
				
			||||||
 | 
					  padding: 2rem 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.container {
 | 
				
			||||||
 | 
					  max-width: 1200px;
 | 
				
			||||||
 | 
					  margin: 0 auto;
 | 
				
			||||||
 | 
					  padding: 0 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.title {
 | 
				
			||||||
 | 
					  text-align: center;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					  font-size: 2.5rem;
 | 
				
			||||||
 | 
					  font-weight: bold;
 | 
				
			||||||
 | 
					  margin-bottom: 2rem;
 | 
				
			||||||
 | 
					  text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.test-section {
 | 
				
			||||||
 | 
					  background: white;
 | 
				
			||||||
 | 
					  border-radius: 12px;
 | 
				
			||||||
 | 
					  padding: 2rem;
 | 
				
			||||||
 | 
					  margin-bottom: 2rem;
 | 
				
			||||||
 | 
					  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.test-section h2 {
 | 
				
			||||||
 | 
					  color: #333;
 | 
				
			||||||
 | 
					  margin-bottom: 1.5rem;
 | 
				
			||||||
 | 
					  font-size: 1.5rem;
 | 
				
			||||||
 | 
					  border-bottom: 2px solid #667eea;
 | 
				
			||||||
 | 
					  padding-bottom: 0.5rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.test-card {
 | 
				
			||||||
 | 
					  background: #f8f9fa;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  padding: 1.5rem;
 | 
				
			||||||
 | 
					  margin-bottom: 1.5rem;
 | 
				
			||||||
 | 
					  border-left: 4px solid #667eea;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.test-card h3 {
 | 
				
			||||||
 | 
					  color: #495057;
 | 
				
			||||||
 | 
					  margin-bottom: 1rem;
 | 
				
			||||||
 | 
					  font-size: 1.2rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.test-card p {
 | 
				
			||||||
 | 
					  color: #6c757d;
 | 
				
			||||||
 | 
					  margin-bottom: 1rem;
 | 
				
			||||||
 | 
					  font-size: 1rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.button-group {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  gap: 0.5rem;
 | 
				
			||||||
 | 
					  flex-wrap: wrap;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn {
 | 
				
			||||||
 | 
					  padding: 0.5rem 1rem;
 | 
				
			||||||
 | 
					  border: none;
 | 
				
			||||||
 | 
					  border-radius: 6px;
 | 
				
			||||||
 | 
					  font-size: 0.9rem;
 | 
				
			||||||
 | 
					  font-weight: 500;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  transition: all 0.2s ease;
 | 
				
			||||||
 | 
					  text-decoration: none;
 | 
				
			||||||
 | 
					  display: inline-block;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn:hover {
 | 
				
			||||||
 | 
					  transform: translateY(-1px);
 | 
				
			||||||
 | 
					  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn:disabled {
 | 
				
			||||||
 | 
					  opacity: 0.6;
 | 
				
			||||||
 | 
					  cursor: not-allowed;
 | 
				
			||||||
 | 
					  transform: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-primary {
 | 
				
			||||||
 | 
					  background: #667eea;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-primary:hover {
 | 
				
			||||||
 | 
					  background: #5a6fd8;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-secondary {
 | 
				
			||||||
 | 
					  background: #6c757d;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-secondary:hover {
 | 
				
			||||||
 | 
					  background: #5a6268;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-danger {
 | 
				
			||||||
 | 
					  background: #dc3545;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-danger:hover {
 | 
				
			||||||
 | 
					  background: #c82333;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-success {
 | 
				
			||||||
 | 
					  background: #28a745;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-success:hover {
 | 
				
			||||||
 | 
					  background: #218838;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-warning {
 | 
				
			||||||
 | 
					  background: #ffc107;
 | 
				
			||||||
 | 
					  color: #212529;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-warning:hover {
 | 
				
			||||||
 | 
					  background: #e0a800;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-info {
 | 
				
			||||||
 | 
					  background: #17a2b8;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-info:hover {
 | 
				
			||||||
 | 
					  background: #138496;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.data-display {
 | 
				
			||||||
 | 
					  margin-top: 1rem;
 | 
				
			||||||
 | 
					  background: #f1f3f4;
 | 
				
			||||||
 | 
					  border-radius: 6px;
 | 
				
			||||||
 | 
					  padding: 1rem;
 | 
				
			||||||
 | 
					  border: 1px solid #dee2e6;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.data-display pre {
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					  font-size: 0.85rem;
 | 
				
			||||||
 | 
					  color: #495057;
 | 
				
			||||||
 | 
					  white-space: pre-wrap;
 | 
				
			||||||
 | 
					  word-break: break-word;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@media (max-width: 768px) {
 | 
				
			||||||
 | 
					  .container {
 | 
				
			||||||
 | 
					    padding: 0 0.5rem;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .test-section {
 | 
				
			||||||
 | 
					    padding: 1rem;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .button-group {
 | 
				
			||||||
 | 
					    flex-direction: column;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .btn {
 | 
				
			||||||
 | 
					    width: 100%;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
							
								
								
									
										282
									
								
								pages/[tabId]/test/test02.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										282
									
								
								pages/[tabId]/test/test02.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,282 @@
 | 
				
			|||||||
 | 
					<template>
 | 
				
			||||||
 | 
					    <div id="app">
 | 
				
			||||||
 | 
					      <main role="main" class="container-fluid">
 | 
				
			||||||
 | 
					        <div style="padding-top: 64px">
 | 
				
			||||||
 | 
					          <div>
 | 
				
			||||||
 | 
					            <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>
 | 
				
			||||||
 | 
					          <!-- 탭 콘텐츠 -->
 | 
				
			||||||
 | 
					          <div class="tab-content" id="viewerTabContent">
 | 
				
			||||||
 | 
					            <div
 | 
				
			||||||
 | 
					              class="tab-pane fade show active"
 | 
				
			||||||
 | 
					              id="igv-viewer"
 | 
				
			||||||
 | 
					              role="tabpanel"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <div style="padding-top: 20px">
 | 
				
			||||||
 | 
					                이 예제는 드롭다운 메뉴에서 동적으로 트랙을 추가하는 igv.js API의
 | 
				
			||||||
 | 
					                사용을 보여줍니다.
 | 
				
			||||||
 | 
					                위의 메뉴에서 'CopyNumber'를 선택하면 다음과 같은 호출이 실행됩니다.
 | 
				
			||||||
 | 
					                <p>
 | 
				
			||||||
 | 
					                  <pre>
 | 
				
			||||||
 | 
					                  igv.browser.loadTrack({
 | 
				
			||||||
 | 
					                      url: 'https://s3.amazonaws.com/igv.org.demo/GBM-TP.seg.gz',
 | 
				
			||||||
 | 
					                      name: 'GBM Copy # (TCGA Broad GDAC)'});
 | 
				
			||||||
 | 
					                  </pre>
 | 
				
			||||||
 | 
					                </p>
 | 
				
			||||||
 | 
					                자세한 내용은
 | 
				
			||||||
 | 
					                <a href="https://github.com/igvteam/igv.js/wiki">개발자 위키</a>를
 | 
				
			||||||
 | 
					                참조하세요.
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					              <div id="igvDiv" style="padding-top: 20px"></div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </main>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </template>
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  <script>
 | 
				
			||||||
 | 
					  export default {
 | 
				
			||||||
 | 
					    name: "App",
 | 
				
			||||||
 | 
					    data() {
 | 
				
			||||||
 | 
					      return {
 | 
				
			||||||
 | 
					        browser: null,
 | 
				
			||||||
 | 
					        isDropdownOpen: false,
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    mounted() {
 | 
				
			||||||
 | 
					      // 외부 클릭시 드롭다운 닫기
 | 
				
			||||||
 | 
					      document.addEventListener("click", this.handleClickOutside);
 | 
				
			||||||
 | 
					      this.initializeIGV();
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    beforeUnmount() {
 | 
				
			||||||
 | 
					      document.removeEventListener("click", this.handleClickOutside);
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    methods: {
 | 
				
			||||||
 | 
					      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();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					      async initializeIGV() {
 | 
				
			||||||
 | 
					        await this.waitForIGV();
 | 
				
			||||||
 | 
					        await this.$nextTick();
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					        const igvDiv = document.getElementById("igvDiv");
 | 
				
			||||||
 | 
					        if (!igvDiv) {
 | 
				
			||||||
 | 
					          console.error("❌ #igvDiv가 존재하지 않습니다.");
 | 
				
			||||||
 | 
					          return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					        const options = {
 | 
				
			||||||
 | 
					          locus: "chr1:155,160,475-155,184,282",
 | 
				
			||||||
 | 
					          genome: "hg19",
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					          this.browser = await igv.createBrowser(igvDiv, options);
 | 
				
			||||||
 | 
					          window.igv = { browser: this.browser };
 | 
				
			||||||
 | 
					          this.addBaseClickEvent();
 | 
				
			||||||
 | 
					          this.browser.on("locuschange", this.addBaseClickEvent);
 | 
				
			||||||
 | 
					          this.browser.on("trackclick", this.addBaseClickEvent);
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					          console.error("IGV 브라우저 생성 중 오류:", error);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      waitForIGV() {
 | 
				
			||||||
 | 
					        return new Promise((resolve) => {
 | 
				
			||||||
 | 
					          const checkIGV = () => {
 | 
				
			||||||
 | 
					            if (typeof igv !== "undefined") {
 | 
				
			||||||
 | 
					              resolve();
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					              setTimeout(checkIGV, 100);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          };
 | 
				
			||||||
 | 
					          checkIGV();
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      addBaseClickEvent() {
 | 
				
			||||||
 | 
					        setTimeout(() => {
 | 
				
			||||||
 | 
					          const texts = document.querySelectorAll("#igvDiv text");
 | 
				
			||||||
 | 
					          texts.forEach((text) => {
 | 
				
			||||||
 | 
					            const base = text.textContent;
 | 
				
			||||||
 | 
					            if (["A", "T", "C", "G"].includes(base)) {
 | 
				
			||||||
 | 
					              text.style.cursor = "pointer";
 | 
				
			||||||
 | 
					              text.onclick = () => {
 | 
				
			||||||
 | 
					                text.textContent = this.getComplement(text.textContent);
 | 
				
			||||||
 | 
					              };
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					        }, 300);
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      getComplement(base) {
 | 
				
			||||||
 | 
					        switch (base) {
 | 
				
			||||||
 | 
					          case "A":
 | 
				
			||||||
 | 
					            return "T";
 | 
				
			||||||
 | 
					          case "T":
 | 
				
			||||||
 | 
					            return "A";
 | 
				
			||||||
 | 
					          case "C":
 | 
				
			||||||
 | 
					            return "G";
 | 
				
			||||||
 | 
					          case "G":
 | 
				
			||||||
 | 
					            return "C";
 | 
				
			||||||
 | 
					          default:
 | 
				
			||||||
 | 
					            return base;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      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();
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  </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;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .nav-tabs {
 | 
				
			||||||
 | 
					    margin-bottom: 20px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .tab-content {
 | 
				
			||||||
 | 
					    padding-top: 20px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  </style>
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
		Reference in New Issue
	
	Block a user