delete folder
This commit is contained in:
@@ -1,413 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<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>
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,955 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
<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