mearge
This commit is contained in:
413
pages/test/culture-graph-multi.vue
Normal file
413
pages/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/test/culture-graph-tab.vue
Normal file
32
pages/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>
|
||||
1647
pages/test/culture-graph.vue
Normal file
1647
pages/test/culture-graph.vue
Normal file
File diff suppressed because it is too large
Load Diff
1038
pages/test/culture_graph.html
Normal file
1038
pages/test/culture_graph.html
Normal file
File diff suppressed because it is too large
Load Diff
955
pages/test/igv2.vue
Normal file
955
pages/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/test/pathway.vue
Normal file
247
pages/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/test/pathway2.vue
Normal file
263
pages/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/test/pathway3.vue
Normal file
254
pages/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>
|
||||
|
||||
245
pages/test/pathway4.vue
Normal file
245
pages/test/pathway4.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<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/test/pathwayJson.vue
Normal file
130
pages/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/test/test01.vue
Normal file
299
pages/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/test/test02.vue
Normal file
282
pages/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