[페이지 원복] 기존 테스트 페이지 원복복

This commit is contained in:
2025-09-03 17:18:25 +09:00
parent 5f1d1f5018
commit 0f0317e356
12 changed files with 4783 additions and 3 deletions

3
.gitignore vendored
View File

@@ -144,6 +144,3 @@ dist
# TODO: where does this rule come from?
docs/_book
# TODO: where does this rule come from?
test/

View File

@@ -0,0 +1,413 @@
<template>
<div>
<PageDescription>
<h1>배양 그래프 (멀티)</h1>
<div class="box">
<h2>1. 그래프 구성 기능</h2>
<ul>
<li> 30개의 그래프를 화면에 동시에 있습니다.</li>
<li>렌더링 최적화를 위해 최소한의 기능만 제공됩니다.</li>
</ul>
</div>
<div class="box">
<h2>2. 출력되는 데이터 개수</h2>
<ul>
<li>
그래프 30 × 시리즈 12 × 10 간격(100시간, 601포인트) =
<span class="highlight">216,360</span>
</li>
<li>화면 렌더링 시간은 600ms ~ 800ms 정도 소요됩니다.</li>
</ul>
</div>
<div class="box">
<h2>3. 그래프 복사</h2>
<ul>
<li>
<span class="highlight">그래프 복사</span> 버튼을 클릭하면 그래프가
클립보드에 복사됩니다.
</li>
</ul>
</div>
</PageDescription>
<div class="multi-graph-list">
<div v-for="series in seriesList" :key="series.name" class="single-graph">
<div class="graph-title">
{{ series.name }}<span v-if="series.unit"> {{ series.unit }}</span>
<button class="copy-btn" @click="copyChartImage(series.name)">
그래프 복사
</button>
</div>
<div
:ref="el => setGraphRef(series.name, el as Element | null)"
class="echarts-graph"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from "vue";
import * as echarts from "echarts";
// 파스텔 컬러 15개 반복
const pastelColors = [
"#A3D8F4",
"#F7B7A3",
"#B5EAD7",
"#FFDAC1",
"#C7CEEA",
"#FFF1BA",
"#FFB7B2",
"#B4A7D6",
"#AED9E0",
"#FFC3A0",
"#E2F0CB",
"#FFB347",
"#C1C8E4",
"#FFFACD",
"#FFD1DC",
];
// 그래프 개수 변수로 분리
const NUM_GRAPHS = 30;
// 30개 y축 정보 (culture-graph.vue와 동일하게 수정)
const yAxisList = [
{ name: "ORP", unit: "", color: pastelColors[0], min: 0, max: 1000 },
{
name: "Air flow",
unit: "(L/min)",
color: pastelColors[1],
min: 0,
max: 30,
},
{ name: "DO", unit: "", color: pastelColors[2], min: 0, max: 200 },
{ name: "Feed TK1", unit: "(L)", color: pastelColors[3], min: 0, max: 10 },
{ name: "Feed TK2", unit: "(L)", color: pastelColors[4], min: 0, max: 10 },
{ name: "pH", unit: "", color: pastelColors[5], min: 6.0, max: 8.0 },
{ name: "Pressure", unit: "(bar)", color: pastelColors[6], min: 0, max: 2 },
{ name: "RPM", unit: "", color: pastelColors[7], min: 0, max: 3000 },
{ name: "CO2", unit: "(%)", color: pastelColors[8], min: 0, max: 10 },
{ name: "JAR Vol", unit: "(L)", color: pastelColors[9], min: 0, max: 20 },
{ name: "WEIGHT", unit: "(kg)", color: pastelColors[10], min: 0, max: 100 },
{ name: "O2", unit: "(%)", color: pastelColors[11], min: 0, max: 100 },
{ name: "NV", unit: "", color: pastelColors[12], min: 0, max: 10 },
{ name: "NIR", unit: "", color: pastelColors[13], min: 0, max: 10 },
{
name: "Temperature",
unit: "(℃)",
color: pastelColors[14],
min: 0,
max: 50,
},
{ name: "Humidity", unit: "(%)", color: pastelColors[0], min: 0, max: 100 },
{
name: "Flow Rate",
unit: "(L/min)",
color: pastelColors[1],
min: 0,
max: 60,
},
{
name: "Conductivity",
unit: "(μS/cm)",
color: pastelColors[2],
min: 0,
max: 600,
},
{ name: "Turbidity", unit: "(NTU)", color: pastelColors[3], min: 0, max: 12 },
{
name: "Dissolved Solids",
unit: "(mg/L)",
color: pastelColors[4],
min: 0,
max: 250,
},
{
name: "Alkalinity",
unit: "(mg/L)",
color: pastelColors[5],
min: 0,
max: 120,
},
{
name: "Hardness",
unit: "(mg/L)",
color: pastelColors[6],
min: 0,
max: 100,
},
{ name: "Chlorine", unit: "(mg/L)", color: pastelColors[7], min: 0, max: 6 },
{ name: "Nitrate", unit: "(mg/L)", color: pastelColors[8], min: 0, max: 25 },
{
name: "Phosphate",
unit: "(mg/L)",
color: pastelColors[9],
min: 0,
max: 12,
},
{ name: "Sulfate", unit: "(mg/L)", color: pastelColors[10], min: 0, max: 60 },
{ name: "Ammonia", unit: "(mg/L)", color: pastelColors[11], min: 0, max: 18 },
{ name: "Nitrite", unit: "(mg/L)", color: pastelColors[12], min: 0, max: 6 },
{ name: "BOD", unit: "(mg/L)", color: pastelColors[13], min: 0, max: 35 },
{ name: "COD", unit: "(mg/L)", color: pastelColors[14], min: 0, max: 120 },
];
// NUM_GRAPHS까지 자동 생성
if (yAxisList.length < NUM_GRAPHS) {
for (let i = yAxisList.length; i < NUM_GRAPHS; i++) {
yAxisList.push({
name: `Graph ${i + 1}`,
unit: "",
color: pastelColors[i % pastelColors.length],
min: 0,
max: 100 + (i % 10) * 100, // 100, 200, ... 1000 반복
});
}
}
// x축 간격(초) - 5초 또는 1분(60초) 등으로 변경 가능
// 예시: const X_INTERVAL = 5; // 5초 간격
// const X_INTERVAL = 60; // 1분 간격
const X_INTERVAL = 600; // 필요시 60으로 변경
// 100시간치, X_INTERVAL 간격
const xLabels = Array.from(
{ length: (100 * 60 * 60) / X_INTERVAL + 1 },
(_, i) => i * X_INTERVAL
);
// 부드러운 곡선형 + 구간별 변화 가데이터 생성 함수 (xLabels를 파라미터로 받도록 변경)
function smoothData(
min: number,
max: number,
xLabels: number[],
phase = 0,
_amp = 1, // amp는 사용하지 않으므로 _amp로 변경
offset = 0,
seriesIndex: number
) {
let _prevValue = 0.5;
const values = [];
// 데이터 범위 축소: min+10% ~ max-10%
const rangeMin = min + (max - min) * 0.1;
const rangeMax = max - (max - min) * 0.1;
// 시리즈별 패턴 다양화: 증가/감소/진동/트렌드 섞기
// 패턴 결정 (시리즈 인덱스에 따라)
const trendType = seriesIndex % 4; // 0:증가, 1:감소, 2:진동, 3:랜덤
// 진동폭(ampVar, randomFactor)은 원복 (더 낮게)
const ampVar = 0.2 + (seriesIndex % 5) * 0.1; // 0.2~0.6
const randomFactor = 0.01 + 0.01 * (seriesIndex % 4); // 0.01~0.04
for (let i = 0; i < xLabels.length; i++) {
const t = i / (xLabels.length - 1);
let base;
// 오르내림(파동 주기/계수)을 키움 (파동이 더 자주, 더 크게)
if (trendType === 0) {
base = 0.2 + 0.6 * t + 0.13 * Math.sin(phase + t * Math.PI * ampVar * 8);
} else if (trendType === 1) {
base = 0.8 - 0.6 * t + 0.13 * Math.cos(phase + t * Math.PI * ampVar * 8);
} else if (trendType === 2) {
base = 0.5 + 0.22 * Math.sin(phase + t * Math.PI * ampVar * 12);
} else {
base = 0.5 + 0.08 * Math.sin(phase + t * Math.PI * ampVar * 6) + 0.2 * t;
}
// 노이즈는 낮게
const noise = (Math.random() - 0.5) * randomFactor;
base += noise;
base = Math.max(0, Math.min(1, base));
const value = +(rangeMin + (rangeMax - rangeMin) * base + offset).toFixed(
2
);
_prevValue = base;
values.push([xLabels[i], value]);
}
return values;
}
// 시리즈 데이터 30개 생성 → 각 그래프마다 12개 라인(시리즈)로 확장
const NUM_LINES_PER_GRAPH = 12;
const seriesList = yAxisList.map((y, idx) => {
// 12개 라인(시리즈) 생성
const lines = Array.from({ length: NUM_LINES_PER_GRAPH }, (_, lineIdx) => ({
name: String(lineIdx + 1), // "1", "2", ... "12"
color: pastelColors[lineIdx % pastelColors.length],
data: smoothData(
y.min,
y.max,
xLabels,
Math.PI / (idx + 1 + lineIdx), // phase를 다르게
1,
0,
idx + lineIdx * 2 // 패턴 다양화
),
}));
return {
name: y.name,
unit: y.unit,
color: y.color,
min: y.min,
max: y.max,
lines, // 12개 시리즈 배열
};
});
// 차트 렌더링
const graphRefs = ref<Record<string, HTMLDivElement | null>>({});
const chartInstances = ref<Record<string, echarts.ECharts | null>>({});
function setGraphRef(name: string, el: Element | null) {
if (el && el instanceof HTMLDivElement) {
graphRefs.value[name] = el;
}
}
function renderAllCharts() {
nextTick(() => {
seriesList.forEach(series => {
const el = graphRefs.value[series.name];
if (!el) return;
if (chartInstances.value[series.name]) {
chartInstances.value[series.name]?.dispose();
}
const chart = echarts.init(el);
chart.setOption({
grid: { left: 50, right: 20, top: 30, bottom: 40, containLabel: true },
xAxis: {
type: "value",
axisLabel: {
fontSize: 11,
color: "#666",
formatter: (v: number) =>
`${Math.floor(v / 3600)}:${String(Math.floor((v % 3600) / 60)).padStart(2, "0")}`,
},
},
yAxis: {
type: "value",
min: series.min,
max: series.max,
name: series.unit ? `${series.name} ${series.unit}` : series.name,
nameTextStyle: {
color: series.color,
fontWeight: "bold",
fontSize: 12,
},
axisLabel: { color: series.color, fontWeight: "bold", fontSize: 12 },
axisLine: { lineStyle: { color: series.color, width: 2 } },
},
series: series.lines.map(line => ({
name: line.name,
data: line.data,
type: "line",
lineStyle: { color: line.color, width: 1 },
symbol: "none",
})),
animation: false,
legend: {
show: true,
bottom: 8, // 하단에 위치
left: "center", // 중앙 정렬
orient: "horizontal",
itemWidth: 18,
itemHeight: 10,
icon: "circle",
textStyle: { fontSize: 12, padding: [0, 4, 0, 0] },
},
tooltip: { show: true, trigger: "axis" },
});
chartInstances.value[series.name] = chart;
});
});
}
// 이미지 복사 함수 추가
async function copyImageToClipboard(dataUrl: string) {
const res = await fetch(dataUrl);
const blob = await res.blob();
await navigator.clipboard.write([
new window.ClipboardItem({ [blob.type]: blob }),
]);
}
async function copyChartImage(name: string) {
const chart = chartInstances.value[name];
if (!chart) return;
const dataUrl = chart.getDataURL({
type: "png",
pixelRatio: 2,
backgroundColor: "#fff",
});
try {
await copyImageToClipboard(dataUrl);
alert("그래프가 클립보드에 복사되었습니다!");
} catch {
alert("클립보드 복사에 실패했습니다. 브라우저 권한을 확인하세요.");
}
}
onMounted(() => {
renderAllCharts();
});
</script>
<style scoped>
.multi-graph-list {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px 1.5%;
padding: 16px 0;
align-content: flex-start;
flex-direction: row;
background: #fff;
}
.single-graph {
min-height: 260px;
width: 100%;
flex: 0 0 100%;
max-width: 100%;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
padding: 10px 16px 10px 16px;
position: relative;
display: flex;
flex-direction: column;
margin-bottom: 20px;
align-items: flex-start;
}
.graph-title {
font-weight: bold;
font-size: 1.05rem;
color: #1976d2;
margin-bottom: 6px;
width: 100%;
}
.echarts-graph {
min-height: 200px;
height: 200px !important;
width: 100%;
}
.copy-btn {
margin-left: 10px;
font-size: 0.9rem;
padding: 2px 8px;
border: 1px solid #1976d2;
background: #e3f2fd;
color: #1976d2;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
float: right;
}
.copy-btn:hover {
background: #bbdefb;
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<div>
<PageDescription>
<h1>배양 그래프 ()</h1>
<div class="box">
<h2>1. 그래프 구성 기능</h2>
<ul>
<li>여러 개의 배양 그래프를 탭으로 전환하여 있습니다.</li>
<li> 탭은 독립적인 그래프를 표시합니다.</li>
</ul>
</div>
<div class="box">
<h2>2. 출력되는 데이터 개수</h2>
<ul>
<li>
4 × 시리즈 30 × 1 간격(100시간, 6,001포인트) =
<span class="highlight"> 탭당 180,030</span>
</li>
<li>전체(4 합산): <span class="highlight">720,120</span></li>
<li>
화면 초기 렌더링 시간은 300ms ~ 400ms 정도 소요되며,
이동시에는 거의 딜레이 없이 화면이 출력되고 있습니다.
</li>
</ul>
</div>
</PageDescription>
<BatchTabs />
</div>
</template>
<script setup lang="ts">
import BatchTabs from "~/components/BatchTabs.vue";
</script>

File diff suppressed because it is too large Load Diff

955
pages/[tabId]/test/igv2.vue Normal file
View File

@@ -0,0 +1,955 @@
<template>
<div id="app" style="position: relative;">
<main role="main" class="container-fluid">
<div style="padding-top: 24px; position: relative;">
<!-- IGV 컨트롤 -->
<div style="display: flex; gap: 16px; align-items: center; margin-bottom: 8px; flex-wrap: wrap;">
<!-- 파란 핸들 위치 input -->
<label>시작 위치
<input type="number" :value="startPos" @input="onInputPosChange('start', $event.target.value)" class="igv-control-input" style="width:110px; margin-left:4px;" />
</label>
<label> 위치
<input type="number" :value="endPos" @input="onInputPosChange('end', $event.target.value)" class="igv-control-input" style="width:110px; margin-left:4px;" />
</label>
<button @click="reverseSequence" :class="['igv-control-btn', { 'reverse-active': isReverse }]" style="margin-bottom: 0; margin-left: 8px;">리버스(상보서열) 변환</button>
<!-- 서버 파일 목록 드롭다운 -->
<div style="margin-left: 8px; position: relative;">
<button @click="toggleServerFilesDropdown" class="igv-control-btn">
서버 파일 목록
<span v-if="isServerFilesDropdownOpen"></span>
<span v-else></span>
</button>
<div v-if="isServerFilesDropdownOpen" class="server-files-dropdown">
<div v-if="loadingFiles" class="dropdown-item">로딩 중...</div>
<div v-else-if="serverFiles.length === 0" class="dropdown-item">업로드된 파일이 없습니다.</div>
<div v-else>
<div
v-for="file in serverFiles"
:key="file.fileName"
@click="selectServerFile(file)"
class="dropdown-item file-item"
:class="{ 'selected': selectedServerFile && selectedServerFile.fileName === file.fileName }"
>
<div class="file-name">{{ file.fileName }}</div>
<div class="file-info">
크기: {{ formatFileSize(file.fileSize) }} |
업로드: {{ formatDate(file.uploadTime) }}
</div>
</div>
</div>
</div>
</div>
<!-- FASTA 업로드 -->
<label style="margin-left: 8px;">
<input type="file" accept=".fa,.fasta" @change="onFastaUpload" style="display:none;" ref="fastaInput" />
<button type="button" class="igv-control-btn" @click="$refs.fastaInput.click()">FASTA 업로드</button>
</label>
<!-- 대용량 파일 분할 -->
<button v-if="showSplitOption" @click="splitLargeFile" class="igv-control-btn" style="margin-left:4px;">파일 분할</button>
<!-- 범위 염기서열 변경 -->
<input type="text" v-model="editSequence" placeholder="새 염기서열 입력" class="igv-control-input" style="width:180px; margin-left:8px;" />
<button @click="replaceSequenceInRange" class="igv-control-btn" style="margin-left:4px;">범위 염기서열 변경</button>
<!-- Tracks 드롭다운 -->
<ul
class="navbar-nav mr-auto"
style="list-style:none; padding-left:0; margin:0;"
>
<li
class="nav-item dropdown"
style="position: relative; display: inline-block;"
>
<a
href="#"
id="igv-example-api-dropdown"
@click.prevent="toggleDropdown"
aria-haspopup="true"
:aria-expanded="isDropdownOpen.toString()"
style="color: black; cursor: pointer; user-select: none;"
>Tracks</a
>
<ul
v-show="isDropdownOpen"
class="dropdown-menu"
style="width:350px; position: absolute; top: 100%; left: 0; background: white; border: 1px solid #ccc; box-shadow: 0 2px 5px rgba(0,0,0,0.15); padding: 5px 0; margin: 0; list-style:none; z-index: 1000;"
>
<li>
<a
href="#"
@click.prevent="loadCopyNumberTrack"
style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
>Copy Number</a
>
</li>
<li>
<a
href="#"
@click.prevent="loadDbSnpTrack"
style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
>dbSNP 137 (bed tabix)</a
>
</li>
<li>
<a
href="#"
@click.prevent="loadBigWigTrack"
style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
>Encode bigwig</a
>
</li>
<li>
<a
href="#"
@click.prevent="loadBamTrack"
style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
>1KG Bam (HG02450)</a
>
</li>
</ul>
</li>
</ul>
</div>
<!-- IGV 뷰어 영역 -->
<div id="igvDiv" ref="igvDiv" style="padding-top: 20px; min-height: 500px; position: relative;"></div>
<!-- 염기서열 결과 -->
<div style="margin-top: 12px;">
<strong :class="{ 'reverse-active': isReverse }">염기서열:</strong>
<pre :class="{ 'reverse-active': isReverse }" style="white-space: pre-wrap; word-break: break-all;">{{ displaySequence }}</pre>
</div>
</div>
</main>
</div>
</template>
<script>
// igv.js와 igv_custom.js를 script 태그로 동적 로드(SSR 방지)
if (typeof window !== 'undefined') {
function loadScriptOnce(src, globalCheck, callback) {
if (globalCheck()) {
if (callback) callback();
return;
}
// 이미 로딩 중인지 확인
if (document.querySelector(`script[src="${src}"]`)) {
const checkInterval = setInterval(() => {
if (globalCheck()) {
clearInterval(checkInterval);
if (callback) callback();
}
}, 100);
return;
}
const script = document.createElement('script');
script.src = src;
script.onload = function() {
if (callback) callback();
};
script.onerror = function() {
console.error(`Failed to load script: ${src}`);
};
document.head.appendChild(script);
}
// igv.js 먼저 로드
loadScriptOnce('/dist/igv.js', function() {
return typeof window.igv !== 'undefined' && typeof window.igv.createBrowser === 'function';
}, function() {
console.log('IGV.js loaded successfully');
// igv_custom.js는 igv.js가 로드된 후에만 로드
loadScriptOnce('/dist/igv_custom.js', function() {
return !!window.igvCustomLoaded;
}, function() {
window.igvCustomLoaded = true;
console.log('IGV Custom loaded successfully');
});
});
}
export default {
name: "App",
data() {
return {
browser: null,
locus: null, // ex: "chr1:10000-10200"
overlayWidth: 900,
overlayHeight: 500,
sequence: '',
displaySequence: '',
isReverse: false,
isDropdownOpen: false,
startPos: null,
endPos: null,
prevStartPos: null,
prevEndPos: null,
fastaFile: null, // 업로드된 FASTA 파일 Blob
fastaName: '', // 업로드된 FASTA 파일명
editSequence: '', // 범위 내 교체할 염기서열
showSplitOption: false, // 파일 분할 옵션 표시
largeFile: null, // 대용량 파일 저장
isServerFilesDropdownOpen: false, // 서버 파일 목록 드롭다운 상태
loadingFiles: false, // 서버 파일 목록 로딩 상태
serverFiles: [], // 서버에 업로드된 파일 목록
selectedServerFile: null, // 선택된 서버 파일
};
},
watch: {
locus: 'fetchSequence',
},
async mounted() {
// IGV.js와 igv_custom.js를 script 태그로 동적 로드(SSR 방지)
if (typeof window !== 'undefined') {
function loadScriptOnce(src, globalCheck, callback) {
if (globalCheck()) {
if (callback) callback();
return;
}
// 이미 로딩 중인지 확인
if (document.querySelector(`script[src="${src}"]`)) {
const checkInterval = setInterval(() => {
if (globalCheck()) {
clearInterval(checkInterval);
if (callback) callback();
}
}, 100);
return;
}
const script = document.createElement('script');
script.src = src;
script.onload = function() {
if (callback) callback();
};
script.onerror = function() {
console.error(`Failed to load script: ${src}`);
};
document.head.appendChild(script);
}
// igv.js 먼저 로드
loadScriptOnce('/dist/igv.js', function() {
return typeof window.igv !== 'undefined' && typeof window.igv.createBrowser === 'function';
}, function() {
console.log('IGV.js loaded successfully');
// igv_custom.js는 igv.js가 로드된 후에만 로드
loadScriptOnce('/dist/igv_custom.js', function() {
return !!window.igvCustomLoaded;
}, function() {
window.igvCustomLoaded = true;
console.log('IGV Custom loaded successfully');
});
});
}
await this.initIGV();
// IGV가 완전히 생성될 때까지 대기
const registerListener = () => {
if (window.igvCustomLoaded && this.browser) {
window.addEventListener('igv-blue-lines-changed', this.onBlueLinesChanged);
} else {
setTimeout(registerListener, 200);
}
};
registerListener();
},
beforeUnmount() {
window.removeEventListener('igv-blue-lines-changed', this.onBlueLinesChanged);
},
methods: {
async initIGV() {
try {
await this.waitForIGV();
await this.$nextTick();
const igvDiv = this.$refs.igvDiv;
if (!igvDiv) {
console.error("❌ #igvDiv가 존재하지 않습니다.");
return;
}
// FASTA 업로드 시 genome 옵션 변경
const genomeOpt = this.fastaFile
? {
id: this.fastaName || 'custom',
fastaURL: this.fastaFile,
indexURL: undefined,
}
: 'hg19';
console.log('IGV 옵션:', { locus: this.locus || "chr1:10000-10200", genome: genomeOpt });
const options = {
locus: this.locus || "chr1:10000-10200",
genome: genomeOpt,
};
try {
console.log('IGV 브라우저 생성 시작');
this.browser = await window.igv.createBrowser(igvDiv, options);
console.log('IGV 브라우저 생성 완료:', this.browser);
// 이전 좌표 저장용
// prevStartPos, prevEndPos는 data에 저장된 this.prevStartPos, this.prevEndPos 사용
this.browser.on("locuschange", (locus) => {
// locus 업데이트
this.locus = locus;
// locus 파싱
let locusStart, locusEnd;
if (typeof locus === 'string') {
const match = locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
if (!match) return;
locusStart = parseInt(match[2].replace(/,/g, ''), 10);
locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
} else if (typeof locus === 'object' && locus.chr && locus.start && locus.end) {
locusStart = parseInt(String(locus.start).replace(/,/g, ''), 10);
locusEnd = parseInt(String(locus.end).replace(/,/g, ''), 10);
} else {
return;
}
// input 박스의 startPos, endPos 기준으로 ratio 계산
if (this.startPos !== null && this.endPos !== null) {
const startRatio = (this.startPos - locusStart) / (locusEnd - locusStart);
const endRatio = (this.endPos - locusStart) / (locusEnd - locusStart);
if (window.igvCustom && typeof window.igvCustom.setLineRatios === 'function') {
window.igvCustom.setLineRatios(startRatio, endRatio);
}
}
});
this.locus = options.locus;
// IGV, igvCustom 모두 준비된 후 최초 막대/염기서열 동기화
const syncInitialBlueLines = async () => {
if (window.igvCustom && typeof window.igvCustom.getLineRatios === 'function') {
// locus 파싱
let chrom, locusStart, locusEnd;
if (typeof this.locus === 'string') {
const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
if (!match) return;
chrom = match[1];
locusStart = parseInt(match[2].replace(/,/g, ''), 10);
locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
} else if (typeof this.locus === 'object' && this.locus.chr && this.locus.start && this.locus.end) {
chrom = this.locus.chr;
locusStart = parseInt(String(this.locus.start).replace(/,/g, ''), 10);
locusEnd = parseInt(String(this.locus.end).replace(/,/g, ''), 10);
} else {
return;
}
const { startRatio, endRatio } = window.igvCustom.getLineRatios();
const pos1 = Math.round(locusStart + (locusEnd - locusStart) * startRatio);
const pos2 = Math.round(locusStart + (locusEnd - locusStart) * endRatio);
const start = Math.min(pos1, pos2);
const end = Math.max(pos1, pos2);
this.startPos = start;
this.endPos = end;
if (end - start < 1) {
this.sequence = '';
this.displaySequence = '';
return;
}
// 염기서열 fetch
if (
this.browser.genome &&
this.browser.genome.sequence &&
typeof this.browser.genome.sequence.getSequence === 'function'
) {
const seq = await this.browser.genome.sequence.getSequence(chrom, start, end);
this.sequence = seq || '';
this.displaySequence = this.isReverse ? this.getComplement(seq) : seq;
}
} else {
setTimeout(syncInitialBlueLines, 200);
}
};
syncInitialBlueLines();
} catch (error) {
console.error("IGV 브라우저 생성 중 오류:", error);
}
} catch (error) {
console.error("IGV 초기화 중 오류:", error);
}
},
// 파란 라인 위치 변경 이벤트 핸들러
async onBlueLinesChanged(e) {
if (!this.locus || !this.browser) {
this.sequence = 'locus/browser 없음';
this.displaySequence = 'locus/browser 없음';
this.startPos = null;
this.endPos = null;
if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
window.igvCustom.setMiniMapInfo(null, null, null);
}
return;
}
let chrom, locusStart, locusEnd;
if (typeof this.locus === 'string') {
const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
if (!match) {
this.sequence = 'locus 파싱 실패';
this.displaySequence = 'locus 파싱 실패';
this.startPos = null;
this.endPos = null;
if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
window.igvCustom.setMiniMapInfo(null, null, null);
}
return;
}
chrom = match[1];
locusStart = parseInt(match[2].replace(/,/g, ''), 10);
locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
} else if (typeof this.locus === 'object' && this.locus.chr && this.locus.start && this.locus.end) {
chrom = this.locus.chr;
locusStart = parseInt(String(this.locus.start).replace(/,/g, ''), 10);
locusEnd = parseInt(String(this.locus.end).replace(/,/g, ''), 10);
} else {
this.sequence = 'locus 파싱 실패';
this.displaySequence = 'locus 파싱 실패';
this.startPos = null;
this.endPos = null;
if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
window.igvCustom.setMiniMapInfo(null, null, null);
}
return;
}
const { startRatio, endRatio } = e.detail;
const pos1 = Math.round(locusStart + (locusEnd - locusStart) * startRatio);
const pos2 = Math.round(locusStart + (locusEnd - locusStart) * endRatio);
const start = Math.min(pos1, pos2);
const end = Math.max(pos1, pos2);
this.startPos = start;
this.endPos = end;
if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
window.igvCustom.setMiniMapInfo(chrom, start, end);
}
if (end - start < 1) {
this.sequence = '';
this.displaySequence = '';
return;
}
try {
if (
this.browser.genome &&
this.browser.genome.sequence &&
typeof this.browser.genome.sequence.getSequence === 'function'
) {
const seq = await this.browser.genome.sequence.getSequence(chrom, start, end);
this.sequence = seq || '';
this.displaySequence = this.isReverse ? this.getComplement(seq) : seq;
return;
}
this.sequence = 'getSequence 없음(IGV 내부 구조 확인 필요)';
this.displaySequence = 'getSequence 없음(IGV 내부 구조 확인 필요)';
} catch {
this.sequence = '불러오기 실패';
this.displaySequence = '불러오기 실패';
}
// locus 이동 전 좌표 저장
this.prevStartPos = this.startPos;
this.prevEndPos = this.endPos;
},
reverseSequence() {
this.isReverse = !this.isReverse;
this.displaySequence = this.isReverse ? this.getComplement(this.sequence) : this.sequence;
},
getComplement(seq) {
if (!seq) return '';
return seq.replace(/[ATCG]/gi, c => {
switch (c.toUpperCase()) {
case 'A': return 'T';
case 'T': return 'A';
case 'C': return 'G';
case 'G': return 'C';
default: return c;
}
});
},
toggleDropdown() {
this.isDropdownOpen = !this.isDropdownOpen;
},
closeDropdown() {
this.isDropdownOpen = false;
},
handleClickOutside(event) {
const dropdown = this.$el.querySelector("#igv-example-api-dropdown");
const menu = this.$el.querySelector(".dropdown-menu");
if (
dropdown &&
menu &&
!dropdown.contains(event.target) &&
!menu.contains(event.target)
) {
this.closeDropdown();
}
},
waitForIGV() {
return new Promise((resolve, reject) => {
let attempts = 0;
const maxAttempts = 100; // 10초 타임아웃
const checkIGV = () => {
attempts++;
if (typeof window.igv !== "undefined" && typeof window.igv.createBrowser === "function") {
console.log('IGV is ready');
resolve();
} else if (attempts >= maxAttempts) {
console.error('IGV loading timeout');
reject(new Error('IGV loading timeout'));
} else {
setTimeout(checkIGV, 100);
}
};
checkIGV();
});
},
loadCopyNumberTrack() {
if (this.browser) {
this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
this.browser.loadTrackList([
{
url: "https://s3.amazonaws.com/igv.org.demo/GBM-TP.seg.gz",
indexed: false,
isLog: true,
name: "GBM Copy # (TCGA Broad GDAC)",
},
]);
}
this.closeDropdown();
},
loadDbSnpTrack() {
if (this.browser) {
this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
this.browser.loadTrackList([
{
type: "annotation",
format: "bed",
url: "https://data.broadinstitute.org/igvdata/annotations/hg19/dbSnp/snp137.hg19.bed.gz",
indexURL:
"https://data.broadinstitute.org/igvdata/annotations/hg19/dbSnp/snp137.hg19.bed.gz.tbi",
visibilityWindow: 200000,
name: "dbSNP 137",
},
]);
}
this.closeDropdown();
},
loadBigWigTrack() {
if (this.browser) {
this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
this.browser.loadTrackList([
{
type: "wig",
format: "bigwig",
url: "https://s3.amazonaws.com/igv.broadinstitute.org/data/hg19/encode/wgEncodeBroadHistoneGm12878H3k4me3StdSig.bigWig",
name: "Gm12878H3k4me3",
},
]);
}
this.closeDropdown();
},
loadBamTrack() {
if (this.browser) {
this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
this.browser.loadTrackList([
{
type: "alignment",
format: "bam",
url: "https://1000genomes.s3.amazonaws.com/phase3/data/HG02450/alignment/HG02450.mapped.ILLUMINA.bwa.ACB.low_coverage.20120522.bam",
indexURL:
"https://1000genomes.s3.amazonaws.com/phase3/data/HG02450/alignment/HG02450.mapped.ILLUMINA.bwa.ACB.low_coverage.20120522.bam.bai",
name: "HG02450",
},
]);
}
this.closeDropdown();
},
// input 박스에서 직접 좌표 입력 시 호출
onInputPosChange(which, val) {
let locusStart, locusEnd, chrom;
if (typeof this.locus === 'string') {
const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
if (!match) return;
chrom = match[1];
locusStart = parseInt(match[2].replace(/,/g, ''), 10);
locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
} else if (typeof this.locus === 'object' && this.locus.chr && this.locus.start && this.locus.end) {
chrom = this.locus.chr;
locusStart = parseInt(String(this.locus.start).replace(/,/g, ''), 10);
locusEnd = parseInt(String(this.locus.end).replace(/,/g, ''), 10);
} else {
return;
}
let start = this.startPos, end = this.endPos;
if (which === 'start') {
start = Number(val);
} else {
end = Number(val);
}
// 범위 보정
if (start >= end) return;
// ratio 계산
const startRatio = (start - locusStart) / (locusEnd - locusStart);
const endRatio = (end - locusStart) / (locusEnd - locusStart);
if (window.igvCustom && typeof window.igvCustom.setLineRatios === 'function') {
window.igvCustom.setLineRatios(startRatio, endRatio);
}
if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
window.igvCustom.setMiniMapInfo(chrom, start, end);
}
},
// FASTA 파일 업로드 핸들러
async onFastaUpload(e) {
const file = e.target.files[0];
if (!file) return;
console.log('FASTA 파일 업로드:', file.name, file.size);
this.fastaName = file.name.replace(/\.[^/.]+$/, "");
// 파란 범위 임시 저장
const prevStartPos = this.startPos;
const prevEndPos = this.endPos;
try {
// 서버에 파일 업로드
const formData = new FormData();
formData.append('file', file);
console.log('서버에 파일 업로드 중...');
if (typeof window !== 'undefined') {
const { data: _data, error: _error } = await useApi('/api/fasta/upload', {
method: 'post',
body: formData,
headers: {
// 'Content-Type'은 브라우저가 자동으로 설정하므로 명시하지 않음
}
})
if (!response.ok) {
throw new Error(`서버 업로드 실패: ${response.status}`);
}
const result = await response.json();
console.log('서버 업로드 완료:', result);
// 원격 URL 사용
this.fastaFile = result.remoteUrl;
// IGV 브라우저 완전 재생성
if (this.browser) {
if (typeof this.browser.removeAllTracks === 'function') {
this.browser.removeAllTracks();
}
this.browser = null;
}
// igvDiv 완전히 비우기
const igvDiv = this.$refs.igvDiv;
if (igvDiv) {
igvDiv.innerHTML = '';
}
// locus, 시퀀스 등 초기화
this.locus = "chr1:1-100";
this.sequence = '';
this.displaySequence = '';
this.startPos = null;
this.endPos = null;
console.log('IGV 재초기화 시작');
await this.initIGV();
// IGV 재초기화 후 파란 범위 복원 (딜레이 추가)
setTimeout(() => {
if (prevStartPos !== null && prevEndPos !== null && window.igvCustom && typeof window.igvCustom.setLineRatios === 'function') {
// locus 파싱
let locusStart = 1, locusEnd = 100;
if (typeof this.locus === 'string') {
const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
if (match) {
locusStart = parseInt(match[2].replace(/,/g, ''), 10);
locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
}
}
const startRatio = (prevStartPos - locusStart) / (locusEnd - locusStart);
const endRatio = (prevEndPos - locusStart) / (locusEnd - locusStart);
window.igvCustom.setLineRatios(startRatio, endRatio);
if (typeof window.waitForIGVAndInit === 'function') {
window.waitForIGVAndInit();
}
}
}, 500);
}
} catch (error) {
console.error('파일 업로드 오류:', error);
alert(`파일 업로드 실패: ${error.message}\n\n서버 API가 설정되지 않았거나, 파일이 너무 클 수 있습니다.`);
// 서버 업로드 실패 시 기존 방식으로 폴백
if (file.size <= 100 * 1024 * 1024) { // 100MB 이하만
this.largeFile = file;
this.showSplitOption = true;
}
}
},
// 대용량 파일 분할
async splitLargeFile() {
if (!this.largeFile) return;
try {
const chunkSize = 50 * 1024 * 1024; // 50MB 청크
const totalChunks = Math.ceil(this.largeFile.size / chunkSize);
alert(`파일을 ${totalChunks}개 청크로 분할합니다. 각 청크는 약 50MB입니다.`);
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, this.largeFile.size);
const chunk = this.largeFile.slice(start, end);
// 청크를 파일로 다운로드
const url = URL.createObjectURL(chunk);
const a = document.createElement('a');
a.href = url;
a.download = `${this.largeFile.name.replace(/\.[^/.]+$/, "")}_part${i + 1}.fasta`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// 진행률 표시
const progress = ((i + 1) / totalChunks * 100).toFixed(1);
console.log(`분할 진행률: ${progress}%`);
}
alert(`파일 분할 완료! ${totalChunks}개의 파일이 다운로드되었습니다.\n각 파일을 개별적으로 업로드하여 사용하세요.`);
this.showSplitOption = false;
this.largeFile = null;
} catch (error) {
console.error('파일 분할 중 오류:', error);
alert('파일 분할 중 오류가 발생했습니다.');
}
},
// 범위 내 염기서열 교체
replaceSequenceInRange() {
if (!this.sequence || !this.editSequence) {
alert('염기서열 또는 입력값이 없습니다.');
return;
}
if (this.editSequence.length !== (this.endPos - this.startPos)) {
alert('입력한 염기서열 길이가 범위와 일치해야 합니다.');
return;
}
// 기존 염기서열을 교체
// 실제 FASTA 파일은 수정하지 않고, 화면에만 반영
this.sequence = this.editSequence;
this.displaySequence = this.isReverse ? this.getComplement(this.sequence) : this.sequence;
alert('범위 내 염기서열이 변경되었습니다. (화면에만 반영)');
},
// 서버 파일 목록 토글
toggleServerFilesDropdown() {
this.isServerFilesDropdownOpen = !this.isServerFilesDropdownOpen;
if (this.isServerFilesDropdownOpen) {
this.loadServerFiles();
}
},
// 서버 파일 목록 로드
async loadServerFiles() {
this.loadingFiles = true;
try {
const response = await fetch('http://localhost/api/fasta/files');
if (!response.ok) {
throw new Error(`서버 파일 목록 로드 실패: ${response.status}`);
}
const result = await response.json();
this.serverFiles = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : (Array.isArray(result) ? result : []);
} catch (error) {
console.error('서버 파일 목록 로드 중 오류:', error);
this.serverFiles = [];
alert(`서버 파일 목록을 불러오는데 실패했습니다: ${error.message}`);
} finally {
this.loadingFiles = false;
}
},
// 서버 파일 선택
selectServerFile(file) {
const prevStartPos = this.startPos;
const prevEndPos = this.endPos;
this.selectedServerFile = file;
this.fastaFile = file.fileUrl; // 선택된 파일의 URL을 fastaFile에 저장
this.fastaName = file.fileName; // 파일명 업데이트
// IGV 브라우저 완전 재생성
if (this.browser) {
if (typeof this.browser.removeAllTracks === 'function') {
this.browser.removeAllTracks();
}
this.browser = null;
}
// igvDiv 완전히 비우기
const igvDiv = this.$refs.igvDiv;
if (igvDiv) {
igvDiv.innerHTML = '';
}
// locus, 시퀀스 등 초기화
this.locus = "chr1:1-100";
this.sequence = '';
this.displaySequence = '';
this.startPos = null;
this.endPos = null;
console.log('IGV 재초기화 시작');
this.initIGV().then(() => {
// IGV 재초기화 후 파란 범위 복원 (딜레이 추가)
setTimeout(() => {
if (prevStartPos !== null && prevEndPos !== null && window.igvCustom && typeof window.igvCustom.setLineRatios === 'function') {
// locus 파싱
let locusStart = 1, locusEnd = 100;
if (typeof this.locus === 'string') {
const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
if (match) {
locusStart = parseInt(match[2].replace(/,/g, ''), 10);
locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
}
}
const startRatio = (prevStartPos - locusStart) / (locusEnd - locusStart);
const endRatio = (prevEndPos - locusStart) / (locusEnd - locusStart);
window.igvCustom.setLineRatios(startRatio, endRatio);
if (typeof window.igvCustom.setupLines === 'function') {
window.igvCustom.setupLines();
}
if (typeof window.waitForIGVAndInit === 'function') {
window.waitForIGVAndInit();
}
}
}, 500);
});
this.isServerFilesDropdownOpen = false; // 드롭다운 닫기
},
// 파일 크기 포맷
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
// 날짜 포맷
formatDate(timestamp) {
const date = new Date(timestamp);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
}
}
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
}
pre {
padding: 10px;
border-radius: 4px;
}
.reverse-active {
background: #e6f0ff !important;
color: #0056b3 !important;
font-weight: bold;
border: 2px solid #0056b3 !important;
box-shadow: 0 0 4px #b3d1ff;
}
.igv-control-btn.reverse-active {
background: #0056b3 !important;
color: #fff !important;
border: 2px solid #003366 !important;
}
.nav-tabs {
margin-bottom: 20px;
}
.tab-content {
padding-top: 20px;
}
.igv-control-input {
border: 1.5px solid #007bff;
padding: 4px 8px;
border-radius: 5px;
font-size: 15px;
outline: none;
transition: border 0.2s;
}
.igv-control-input:focus {
border: 2px solid #0056b3;
}
.igv-control-btn {
border: 1.5px solid #007bff;
background: #f5faff;
color: #007bff;
border-radius: 5px;
padding: 6px 16px;
font-size: 15px;
cursor: pointer;
transition: background 0.2s, border 0.2s, color 0.2s;
}
.igv-control-btn:hover {
background: #007bff;
color: #fff;
border: 2px solid #0056b3;
}
/* 서버 파일 목록 드롭다운 스타일 */
.server-files-dropdown {
position: absolute;
top: 100%;
left: 0;
background-color: #fff;
border: 1px solid #ccc;
box-shadow: 0 2px 5px rgba(0,0,0,0.15);
padding: 5px 0;
margin-top: 5px;
z-index: 1000;
max-height: 300px; /* 스크롤 가능하도록 높이 제한 */
overflow-y: auto;
border-radius: 4px;
}
.server-files-dropdown .dropdown-item {
padding: 8px 20px;
cursor: pointer;
transition: background-color 0.2s;
}
.server-files-dropdown .dropdown-item:hover {
background-color: #f0f0f0;
}
.server-files-dropdown .dropdown-item.selected {
background-color: #e6f0ff;
font-weight: bold;
color: #0056b3;
}
.server-files-dropdown .file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 20px;
cursor: pointer;
transition: background-color 0.2s;
}
.server-files-dropdown .file-item:hover {
background-color: #f0f0f0;
}
.server-files-dropdown .file-item.selected {
background-color: #e6f0ff;
font-weight: bold;
color: #0056b3;
}
.server-files-dropdown .file-name {
flex-grow: 1;
margin-right: 10px;
}
.server-files-dropdown .file-info {
font-size: 0.8em;
color: #666;
}
</style>

View File

@@ -0,0 +1,247 @@
<template>
<div style="position: relative;">
<h2>KEGG Pathway Viewer (Compact Overlay)</h2>
<div ref="cyContainer" style="width: 100%; height: 800px; border: 1px solid #ccc; position: relative;"></div>
<div ref="overlayContainer" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 1000;"></div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import cytoscape from 'cytoscape'
import { Chart, registerables } from 'chart.js'
Chart.register(...registerables)
const cyContainer = ref(null)
const overlayContainer = ref(null)
const chartMap = new Map()
function toRenderedPosition(pos, cy) {
const zoom = cy.zoom()
const pan = cy.pan()
return {
x: pos.x * zoom + pan.x,
y: pos.y * zoom + pan.y
}
}
onMounted(async () => {
await nextTick()
const resizeOverlay = () => {
if (!cyContainer.value || !overlayContainer.value) return
overlayContainer.value.style.width = cyContainer.value.offsetWidth + 'px'
overlayContainer.value.style.height = cyContainer.value.offsetHeight + 'px'
}
resizeOverlay()
window.addEventListener('resize', resizeOverlay)
const res = await fetch('/pon00061.xml')
const xmlText = await res.text()
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(xmlText, 'application/xml')
const scale = 5
const entryMap = new Map()
const entryDataMap = new Map()
const parentMap = new Map()
const entries = Array.from(xmlDoc.getElementsByTagName('entry'))
for (const entry of entries) {
const id = entry.getAttribute('id')
entryDataMap.set(id, entry)
if (entry.getAttribute('type') === 'group') {
const components = entry.getElementsByTagName('component')
for (const comp of components) {
const childId = comp.getAttribute('id')
parentMap.set(childId, id)
}
}
}
const nodes = entries.map(entry => {
const id = entry.getAttribute('id')
const graphics = entry.getElementsByTagName('graphics')[0]
const x = parseFloat(graphics?.getAttribute('x') || '0') * scale
const y = parseFloat(graphics?.getAttribute('y') || '0') * scale
const label = graphics?.getAttribute('name') || id
const fgColor = graphics?.getAttribute('fgcolor') || '#000000'
const bgColor = graphics?.getAttribute('bgcolor') || '#ffffff'
const entryType = entry.getAttribute('type')
const shapeType = entryType === 'compound' ? 'compound' : entryType === 'group' ? 'group' : entryType === 'ortholog' ? 'ortholog' : 'gene'
const parent = parentMap.get(id)
const node = {
data: {
id,
label,
link: entry.getAttribute('link') || null,
reaction: entry.getAttribute('reaction') || null
},
position: { x, y },
classes: shapeType,
style: {
color: fgColor,
'background-color': bgColor
}
}
if (parent) node.data.parent = parent
entryMap.set(id, true)
return node
})
function resolveToRealNode(id) {
if (!entryMap.has(id)) return null
const entry = entryDataMap.get(id)
if (entry?.getAttribute('type') === 'group') {
const components = Array.from(entry.getElementsByTagName('component'))
if (components.length > 0) return components[0].getAttribute('id')
}
return id
}
const edges = []
const relations = Array.from(xmlDoc.getElementsByTagName('relation'))
relations.forEach((rel, i) => {
let source = resolveToRealNode(rel.getAttribute('entry1'))
let target = resolveToRealNode(rel.getAttribute('entry2'))
const type = rel.getAttribute('type')
const subtypes = Array.from(rel.getElementsByTagName('subtype'))
const compoundSubtype = subtypes.find(s => s.getAttribute('name') === 'compound')
if (compoundSubtype) {
const compoundId = compoundSubtype.getAttribute('value')
if (entryMap.has(source) && entryMap.has(target) && entryMap.has(compoundId)) {
edges.push(
{ data: { id: `edge${i}-1`, source, target: compoundId, label: 'via compound' } },
{ data: { id: `edge${i}-2`, source: compoundId, target, label: 'via compound' } }
)
}
} else {
if (entryMap.has(source) && entryMap.has(target)) {
edges.push({ data: { id: `edge${i}`, source, target, label: type } })
}
}
})
const reactions = Array.from(xmlDoc.getElementsByTagName('reaction'))
reactions.forEach((reaction, i) => {
const reactionType = reaction.getAttribute('type')
const reactionLabel = reaction.getAttribute('name') || `reaction${i}`
const substrates = Array.from(reaction.getElementsByTagName('substrate'))
const products = Array.from(reaction.getElementsByTagName('product'))
substrates.forEach(substrate => {
const sid = resolveToRealNode(substrate.getAttribute('id'))
products.forEach(product => {
const pid = resolveToRealNode(product.getAttribute('id'))
if (entryMap.has(sid) && entryMap.has(pid)) {
edges.push({ data: { id: `reaction-${i}-${sid}-${pid}`, source: sid, target: pid, label: `${reactionLabel} (${reactionType})` } })
if (reactionType === 'reversible') {
edges.push({ data: { id: `reaction-${i}-${pid}-${sid}`, source: pid, target: sid, label: `${reactionLabel} (reversible)` } })
}
}
})
})
})
const cy = cytoscape({
container: cyContainer.value,
elements: { nodes, edges },
style: [
{ selector: 'node', style: { label: 'data(label)', 'text-valign': 'center', 'text-halign': 'center', shape: 'rectangle', 'font-size': 10, 'border-width': 1, 'border-color': '#333' } },
{ selector: '.ortholog', style: { 'background-color': '#ccffcc', shape: 'round-rectangle', 'border-width': 2, 'border-color': '#339933' } },
{ selector: '$node > node', style: { 'background-color': '#f3f3f3', 'border-width': 2, 'border-color': '#666', shape: 'roundrectangle' } },
{ selector: '.compound', style: { 'background-color': '#ffe135', shape: 'ellipse' } },
{ selector: 'edge', style: { width: 2, 'line-color': '#888', 'target-arrow-shape': 'triangle', 'label': 'data(label)', 'font-size': 8 } }
],
layout: { name: 'preset' }
})
cy.ready(() => {
cy.fit(cy.elements(), 100)
createNodeOverlays(cy)
})
cy.on('render', () => {
requestAnimationFrame(() => updateOverlayPositions(cy))
})
})
function createNodeOverlays(cy) {
cy.nodes().forEach(node => {
const pos = node.renderedPosition()
const id = node.id()
const wrapper = document.createElement('div')
wrapper.style.position = 'absolute'
wrapper.style.left = `${pos.x + 30}px`
wrapper.style.top = `${pos.y - 40}px`
wrapper.style.width = '20px'
wrapper.style.height = '80px'
wrapper.style.background = 'rgba(255,255,255,0.9)'
wrapper.style.fontSize = '6px'
wrapper.style.border = '1px solid #ccc'
wrapper.style.borderRadius = '2px'
wrapper.style.overflow = 'hidden'
wrapper.style.pointerEvents = 'none'
wrapper.style.zIndex = '9999' // 보장
const table = document.createElement('table')
table.style.borderCollapse = 'collapse'
table.style.width = '100%'
for (let i = 0; i < 7; i++) {
const tr = document.createElement('tr')
for (let j = 0; j < 2; j++) {
const td = document.createElement('td')
td.textContent = '·'
td.style.padding = '0'
td.style.fontSize = '6px'
td.style.textAlign = 'center'
tr.appendChild(td)
}
table.appendChild(tr)
}
const canvas = document.createElement('canvas')
canvas.width = 16
canvas.height = 16
canvas.style.margin = '2px auto 0'
canvas.style.display = 'block'
wrapper.appendChild(table)
wrapper.appendChild(canvas)
overlayContainer.value.appendChild(wrapper)
const ctx = canvas.getContext('2d')
const chart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['A', 'B'],
datasets: [{ data: [30, 70], backgroundColor: ['#ff6384', '#36a2eb'] }]
},
options: {
responsive: false,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
cutout: '60%',
animation: false
}
})
chartMap.set(id, { wrapper, chart })
})
}
function updateOverlayPositions(cy) {
cy.nodes().forEach(node => {
const entry = chartMap.get(node.id())
if (entry) {
const pos = node.renderedPosition()
entry.wrapper.style.left = `${pos.x + 30}px`
entry.wrapper.style.top = `${pos.y - 40}px`
}
})
}
</script>

View File

@@ -0,0 +1,263 @@
<template>
<div>
<h2>KEGG Pathway Viewer (Cytoscape.js)</h2>
<div ref="cyContainer" style="width: 100%; height: 800px; border: 1px solid #ccc;"></div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import cytoscape from 'cytoscape'
const cyContainer = ref(null)
onMounted(async () => {
await nextTick()
const res = await fetch('/pon00061.xml')
const xmlText = await res.text()
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(xmlText, 'application/xml')
const scale = 3
const entryMap = new Map()
const entryDataMap = new Map()
const parentMap = new Map()
const entries = Array.from(xmlDoc.getElementsByTagName('entry'))
// 1. 부모-자식 관계 정리
for (const entry of entries) {
const id = entry.getAttribute('id')
entryDataMap.set(id, entry)
if (entry.getAttribute('type') === 'group') {
const components = entry.getElementsByTagName('component')
for (const comp of components) {
const childId = comp.getAttribute('id')
parentMap.set(childId, id)
}
}
}
// 2. 노드 생성
const nodes = entries.map(entry => {
const id = entry.getAttribute('id')
const graphics = entry.getElementsByTagName('graphics')[0]
const x = parseFloat(graphics?.getAttribute('x') || '0') * scale
const y = parseFloat(graphics?.getAttribute('y') || '0') * scale
const label = graphics?.getAttribute('name') || id
const fgColor = graphics?.getAttribute('fgcolor') || '#000000'
const bgColor = graphics?.getAttribute('bgcolor') || '#ffffff'
const shapeType = graphics?.getAttribute('type') === 'circle' ? 'compound' : 'gene'
const parent = parentMap.get(id)
const node = {
data: {
id,
label,
link: entry.getAttribute('link') || null,
reaction: entry.getAttribute('reaction') || null
},
position: { x, y },
classes: shapeType,
style: {
'color': fgColor,
'background-color': bgColor
}
}
// parent 설정
if (parent) node.data.parent = parent
// ortholog 스타일 구분
if (entry.getAttribute('type') === 'ortholog') {
node.classes += ' ortholog'
}
entryMap.set(id, true)
return node
})
// 3. group 노드가 edge에 등장할 경우 첫 자식으로 대체
function resolveToRealNode(id) {
if (!entryMap.has(id)) return null
const entry = entryDataMap.get(id)
if (entry?.getAttribute('type') === 'group') {
const components = Array.from(entry.getElementsByTagName('component'))
if (components.length > 0) return components[0].getAttribute('id')
}
return id
}
const edges = []
// 4. relation 기반 edge 처리
const relations = Array.from(xmlDoc.getElementsByTagName('relation'))
relations.forEach((rel, i) => {
let source = resolveToRealNode(rel.getAttribute('entry1'))
let target = resolveToRealNode(rel.getAttribute('entry2'))
const type = rel.getAttribute('type')
const subtypes = Array.from(rel.getElementsByTagName('subtype'))
const compoundSubtype = subtypes.find(s => s.getAttribute('name') === 'compound')
if (compoundSubtype) {
const compoundId = compoundSubtype.getAttribute('value')
if (entryMap.has(source) && entryMap.has(target) && entryMap.has(compoundId)) {
edges.push(
{
data: {
id: `edge${i}-1`,
source,
target: compoundId,
label: 'via compound'
}
},
{
data: {
id: `edge${i}-2`,
source: compoundId,
target,
label: 'via compound'
}
}
)
}
} else {
if (entryMap.has(source) && entryMap.has(target)) {
edges.push({
data: {
id: `edge${i}`,
source,
target,
label: type
}
})
}
}
})
// 5. reaction 기반 edge 처리
const reactions = Array.from(xmlDoc.getElementsByTagName('reaction'))
reactions.forEach((reaction, i) => {
const reactionType = reaction.getAttribute('type')
const reactionLabel = reaction.getAttribute('name') || `reaction${i}`
const substrates = Array.from(reaction.getElementsByTagName('substrate'))
const products = Array.from(reaction.getElementsByTagName('product'))
substrates.forEach(substrate => {
const sid = resolveToRealNode(substrate.getAttribute('id'))
products.forEach(product => {
const pid = resolveToRealNode(product.getAttribute('id'))
if (entryMap.has(sid) && entryMap.has(pid)) {
edges.push({
data: {
id: `reaction-${i}-${sid}-${pid}`,
source: sid,
target: pid,
label: `${reactionLabel} (${reactionType})`
}
})
if (reactionType === 'reversible') {
edges.push({
data: {
id: `reaction-${i}-${pid}-${sid}`,
source: pid,
target: sid,
label: `${reactionLabel} (reversible)`
}
})
}
}
})
})
})
// 6. Cytoscape 초기화
const cy = cytoscape({
container: cyContainer.value,
elements: { nodes, edges },
style: [
{
selector: 'node',
style: {
label: 'data(label)',
'text-valign': 'center',
'text-halign': 'center',
'shape': 'rectangle',
'padding': '6px',
'text-wrap': 'wrap',
'color': '#000',
'font-size': 10,
'border-width': 1,
'border-color': '#333'
}
},
{
selector: '.ortholog',
style: {
'background-color': '#ccffcc',
'shape': 'round-rectangle',
'border-width': 2,
'border-color': '#339933'
}
},
{
selector: '$node > node',
style: {
'background-color': '#f3f3f3',
'border-width': 2,
'border-color': '#666',
'shape': 'roundrectangle',
'text-valign': 'top',
'text-halign': 'center',
'font-weight': 'bold',
'padding': '20px'
}
},
{
selector: '.compound',
style: {
'background-color': '#ffe135',
'shape': 'ellipse'
}
},
{
selector: 'edge',
style: {
width: 2,
'line-color': '#888',
'target-arrow-color': '#888',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'label': 'data(label)',
'font-size': 8,
'text-background-opacity': 1,
'text-background-color': '#fff',
'text-background-shape': 'roundrectangle',
'text-rotation': 'autorotate'
}
}
],
layout: { name: 'preset' }
})
cy.ready(() => {
cy.fit(cy.elements(), 100)
})
// 7. 노드 클릭 시 KEGG 링크 열기
cy.on('tap', 'node', (evt) => {
const node = evt.target
const link = node.data('link')
if (link) {
window.open(link, '_blank')
}
})
})
</script>

View File

@@ -0,0 +1,254 @@
<template>
<div ref="cyContainer" class="cy-container"></div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import cytoscape from 'cytoscape'
if (typeof document !== 'undefined') {
const script = document.createElement('script');
script.src = '/dist/cy_custom.js';
script.onload = () => {
window.igvCustomLoaded = true;
};
document.head.appendChild(script);
}
let CytoscapeOverlays = null
if (import.meta.client) {
const useOverlay = (await import('@/composables/useOverlay')).default
CytoscapeOverlays = await useOverlay()
}
const cyContainer = ref(null)
onMounted(async () => {
await nextTick()
cytoscape.use(CytoscapeOverlays.default)
const res = await fetch('/pon00061.xml')
const xmlText = await res.text()
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(xmlText, 'application/xml')
const scale = 3
const entryMap = new Map()
const entryDataMap = new Map()
const parentMap = new Map()
const entries = Array.from(xmlDoc.getElementsByTagName('entry'))
for (const entry of entries) {
const id = entry.getAttribute('id')
entryDataMap.set(id, entry)
if (entry.getAttribute('type') === 'group') {
const components = entry.getElementsByTagName('component')
for (const comp of components) {
parentMap.set(comp.getAttribute('id'), id)
}
}
}
const nodes = entries.map(entry => {
const id = entry.getAttribute('id')
const graphics = entry.getElementsByTagName('graphics')[0]
const x = parseFloat(graphics?.getAttribute('x') || '0') * scale
const y = parseFloat(graphics?.getAttribute('y') || '0') * scale
const label = graphics?.getAttribute('name') || id
const fgColor = graphics?.getAttribute('fgcolor') || '#000000'
const bgColor = graphics?.getAttribute('bgcolor') || '#ffffff'
const parent = parentMap.get(id)
const valueA = Math.floor(Math.random() * 50)
const valueB = 100 - valueA
const node = {
data: {
id,
label,
link: entry.getAttribute('link') || null,
reaction: entry.getAttribute('reaction') || null,
chartData: [valueA, valueB]
},
position: { x, y },
classes: entry.getAttribute('type'),
style: {
color: fgColor,
'background-color': bgColor
}
}
if (parent) node.data.parent = parent
entryMap.set(id, true)
return node
})
function resolveToRealNode(id) {
if (!entryMap.has(id)) return null
const entry = entryDataMap.get(id)
if (entry?.getAttribute('type') === 'group') {
const components = Array.from(entry.getElementsByTagName('component'))
if (components.length > 0) return components[0].getAttribute('id')
}
return id
}
const edges = []
const relations = Array.from(xmlDoc.getElementsByTagName('relation'))
relations.forEach((rel, i) => {
const source = resolveToRealNode(rel.getAttribute('entry1'))
const target = resolveToRealNode(rel.getAttribute('entry2'))
const type = rel.getAttribute('type')
const subtypes = Array.from(rel.getElementsByTagName('subtype'))
const compoundSubtype = subtypes.find(s => s.getAttribute('name') === 'compound')
if (compoundSubtype) {
const compoundId = compoundSubtype.getAttribute('value')
if (entryMap.has(source) && entryMap.has(target) && entryMap.has(compoundId)) {
const sourceConst = source;
const targetConst = target;
edges.push(
{
data: {
id: `edge${i}-1`,
source: sourceConst,
target: compoundId,
label: 'via compound'
}
},
{
data: {
id: `edge${i}-2`,
source: compoundId,
target: targetConst,
label: 'via compound'
}
}
)
}
} else {
if (entryMap.has(source) && entryMap.has(target)) {
const sourceConst = source;
const targetConst = target;
edges.push({
data: {
id: `edge${i}`,
source: sourceConst,
target: targetConst,
label: type
}
})
}
}
})
const reactions = Array.from(xmlDoc.getElementsByTagName('reaction'))
reactions.forEach((reaction, i) => {
const reactionType = reaction.getAttribute('type')
const substrates = Array.from(reaction.getElementsByTagName('substrate'))
const products = Array.from(reaction.getElementsByTagName('product'))
substrates.forEach(substrate => {
const sid = resolveToRealNode(substrate.getAttribute('id'))
products.forEach(product => {
const pid = resolveToRealNode(product.getAttribute('id'))
if (entryMap.has(sid) && entryMap.has(pid)) {
edges.push({
data: {
id: `reaction-${i}-${sid}-${pid}`,
source: sid,
target: pid,
label: `${reactionType}`
}
})
}
})
})
})
// 원형(도넛/파이) 차트 overlay 생성 (흰색 라인 없이)
const pieOverlay = CytoscapeOverlays.renderSymbol({
symbol: node => {
const data = node.data('chartData') || [50, 50]
return {
draw: (ctx, size) => {
const total = data[0] + data[1]
const r = Math.sqrt(size / Math.PI)
const innerR = r * 0.5
const startAngle = 0
const angleA = (data[0] / total) * 2 * Math.PI
// A 영역
ctx.beginPath()
ctx.arc(0, 0, r, startAngle, startAngle + angleA)
ctx.arc(0, 0, innerR, startAngle + angleA, startAngle, true)
ctx.closePath()
ctx.fillStyle = '#36a2eb'
ctx.fill()
// B 영역
ctx.beginPath()
ctx.arc(0, 0, r, startAngle + angleA, startAngle + 2 * Math.PI)
ctx.arc(0, 0, innerR, startAngle + 2 * Math.PI, startAngle + angleA, true)
ctx.closePath()
ctx.fillStyle = '#ff6384'
ctx.fill()
}
}
},
color: '',
width: 32,
height: 32,
borderColor: '#333'
})
const cy = cytoscape({
container: cyContainer.value,
elements: { nodes, edges },
style: [
{ selector: 'node', style: {
label: 'data(label)',
'background-color': '#eee',
'text-valign': 'center',
'text-halign': 'center',
'border-width': 2,
'border-color': '#333',
'font-size': 8,
'padding': '6px',
'width': 32,
'height': 32,
}
},
{ selector: 'edge', style: { 'curve-style': 'bezier', 'target-arrow-shape': 'triangle', 'line-color': '#888' } },
],
layout: { name: 'preset', padding: 100 }
})
cy.overlays(
[
{ position: 'right', vis: pieOverlay }
],
{
updateOn: 'render',
backgroundColor: 'white'
}
)
applyCustomZoomHandling(cy);
});
</script>
<style>
.cy-container {
width: 100vw !important;
max-width: 100vw !important;
min-width: 0 !important;
height: 800px !important;
overflow: hidden !important;
position: relative;
}
</style>

View File

@@ -0,0 +1,260 @@
<template>
<div ref="cyContainer" class="cy-container"></div>
</template>
<script setup>
import { ref, onMounted, nextTick } from "vue";
import cytoscape from "cytoscape";
let CytoscapeOverlays = null;
if (import.meta.client) {
const useOverlay = (await import("@/composables/useOverlay")).default;
CytoscapeOverlays = await useOverlay();
}
const cyContainer = ref(null);
onMounted(async () => {
await nextTick();
cytoscape.use(CytoscapeOverlays.default);
const res = await fetch("/expanded_pathway21600.xml");
const xmlText = await res.text();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, "application/xml");
const scale = 3;
const entryMap = new Map();
const entryDataMap = new Map();
const parentMap = new Map();
const entries = Array.from(xmlDoc.getElementsByTagName("entry"));
for (const entry of entries) {
const id = entry.getAttribute("id");
entryDataMap.set(id, entry);
if (entry.getAttribute("type") === "group") {
const components = entry.getElementsByTagName("component");
for (const comp of components) {
parentMap.set(comp.getAttribute("id"), id);
}
}
}
const nodes = entries.map(entry => {
const id = entry.getAttribute("id");
const graphics = entry.getElementsByTagName("graphics")[0];
const x = parseFloat(graphics?.getAttribute("x") || "0") * scale;
const y = parseFloat(graphics?.getAttribute("y") || "0") * scale;
const label = graphics?.getAttribute("name") || id;
const fgColor = graphics?.getAttribute("fgcolor") || "#000000";
const bgColor = graphics?.getAttribute("bgcolor") || "#ffffff";
const parent = parentMap.get(id);
const valueA = Math.floor(Math.random() * 50);
const valueB = 100 - valueA;
const node = {
data: {
id,
label,
link: entry.getAttribute("link") || null,
reaction: entry.getAttribute("reaction") || null,
chartData: [valueA, valueB],
},
position: { x, y },
classes: entry.getAttribute("type"),
style: {
color: fgColor,
"background-color": bgColor,
},
};
if (parent) node.data.parent = parent;
entryMap.set(id, true);
return node;
});
function resolveToRealNode(id) {
if (!entryMap.has(id)) return null;
const entry = entryDataMap.get(id);
if (entry?.getAttribute("type") === "group") {
const components = Array.from(entry.getElementsByTagName("component"));
if (components.length > 0) return components[0].getAttribute("id");
}
return id;
}
const edges = [];
const relations = Array.from(xmlDoc.getElementsByTagName("relation"));
relations.forEach((rel, i) => {
const source = resolveToRealNode(rel.getAttribute("entry1"));
const target = resolveToRealNode(rel.getAttribute("entry2"));
const type = rel.getAttribute("type");
const subtypes = Array.from(rel.getElementsByTagName("subtype"));
const compoundSubtype = subtypes.find(
s => s.getAttribute("name") === "compound"
);
if (compoundSubtype) {
const compoundId = compoundSubtype.getAttribute("value");
if (
entryMap.has(source) &&
entryMap.has(target) &&
entryMap.has(compoundId)
) {
const sourceConst = source;
const targetConst = target;
edges.push(
{
data: {
id: `edge${i}-1`,
source: sourceConst,
target: compoundId,
label: "via compound",
},
},
{
data: {
id: `edge${i}-2`,
source: compoundId,
target: targetConst,
label: "via compound",
},
}
);
}
} else {
if (entryMap.has(source) && entryMap.has(target)) {
const sourceConst = source;
const targetConst = target;
edges.push({
data: {
id: `edge${i}`,
source: sourceConst,
target: targetConst,
label: type,
},
});
}
}
});
const reactions = Array.from(xmlDoc.getElementsByTagName("reaction"));
reactions.forEach((reaction, i) => {
const reactionType = reaction.getAttribute("type");
const substrates = Array.from(reaction.getElementsByTagName("substrate"));
const products = Array.from(reaction.getElementsByTagName("product"));
substrates.forEach(substrate => {
const sid = resolveToRealNode(substrate.getAttribute("id"));
products.forEach(product => {
const pid = resolveToRealNode(product.getAttribute("id"));
if (entryMap.has(sid) && entryMap.has(pid)) {
edges.push({
data: {
id: `reaction-${i}-${sid}-${pid}`,
source: sid,
target: pid,
label: `${reactionType}`,
},
});
}
});
});
});
// 원형(도넛/파이) 차트 overlay 생성 (흰색 라인 없이)
const pieOverlay = CytoscapeOverlays.renderSymbol({
symbol: node => {
const data = node.data("chartData") || [50, 50];
return {
draw: (ctx, size) => {
const total = data[0] + data[1];
const r = Math.sqrt(size / Math.PI);
const innerR = r * 0.5;
const startAngle = 0;
const angleA = (data[0] / total) * 2 * Math.PI;
// A 영역
ctx.beginPath();
ctx.arc(0, 0, r, startAngle, startAngle + angleA);
ctx.arc(0, 0, innerR, startAngle + angleA, startAngle, true);
ctx.closePath();
ctx.fillStyle = "#36a2eb";
ctx.fill();
// B 영역
ctx.beginPath();
ctx.arc(0, 0, r, startAngle + angleA, startAngle + 2 * Math.PI);
ctx.arc(
0,
0,
innerR,
startAngle + 2 * Math.PI,
startAngle + angleA,
true
);
ctx.closePath();
ctx.fillStyle = "#ff6384";
ctx.fill();
},
};
},
color: "",
width: 32,
height: 32,
borderColor: "#333",
});
const cy = cytoscape({
container: cyContainer.value,
elements: { nodes, edges },
style: [
{
selector: "node",
style: {
label: "data(label)",
"background-color": "#eee",
"text-valign": "center",
"text-halign": "center",
"border-width": 2,
"border-color": "#333",
"font-size": 8,
padding: "6px",
width: 32,
height: 32,
},
},
{
selector: "edge",
style: {
"curve-style": "bezier",
"target-arrow-shape": "triangle",
"line-color": "#888",
},
},
],
layout: { name: "preset", padding: 100 },
});
cy.overlays([{ position: "right", vis: pieOverlay }], {
updateOn: "render",
backgroundColor: "white",
});
applyCustomZoomHandling(cy);
});
</script>
<style>
.cy-container {
width: 100vw !important;
max-width: 100vw !important;
min-width: 0 !important;
height: 800px !important;
overflow: hidden !important;
position: relative;
}
</style>

View File

@@ -0,0 +1,130 @@
<template>
<div>
<select v-model="selectedGroup" @change="focusGroup">
<option v-for="g in groups" :key="g" :value="g">
Group {{ g }}
</option>
</select>
<div ref="cyEl" style="width: 100%; height: 800px;"></div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import cytoscape from "cytoscape";
let CytoscapeOverlays = null
if (import.meta.client) {
const useOverlay = (await import('@/composables/useOverlay')).default
CytoscapeOverlays = await useOverlay()
}
useHead({
script: [
{
src: '/dist/cy_custom.js',
defer: true
}
]
});
const cyEl = ref(null);
const cy = ref(null);
const selectedGroup = ref(null);
const groups = ref([]);
onMounted(() => {
fetch("/group43200.json")
.then(res => res.json())
.then(data => {
const nodes = data.entries.map(e => ({
data: { id: `n${e.id}`, label: e.graphics.name, group: e.group },
position: { x: e.graphics.x, y: e.graphics.y },
}));
const edges = data.relations.map(r => ({
data: {
source: `n${r.entry1}`,
target: `n${r.entry2}`,
}
}));
groups.value = [...new Set(data.entries.map(e => e.group))];
const colorPalette = ['#e6194b', '#3cb44b', '#ffe119', '#4363d8', '#f58231', '#911eb4', '#46f0f0', '#f032e6', '#bcf60c', '#fabebe', '#008080', '#e6beff', '#9a6324', '#fffac8', '#800000', '#aaffc3', '#808000', '#ffd8b1', '#000075', '#808080'];
const groupColorMap = {};
groups.value.forEach((group, index) => {
groupColorMap[group] = colorPalette[index % colorPalette.length];
});
cy.value = cytoscape({
container: cyEl.value,
elements: [...nodes, ...edges],
style: [
{
selector: 'node',
style: {
label: 'data(label)',
'background-color': (node) => groupColorMap[node.data('group')] || '#ccc',
'text-valign': 'center',
'text-halign': 'center',
'width': 30,
'height': 30
}
},
{
selector: 'edge',
style: {
'line-color': '#ccc',
'target-arrow-shape': 'triangle'
}
}
],
layout: { name: 'preset' },
zoomingEnabled: true,
userZoomingEnabled: true
});
setTimeout(() => {
if (typeof applyCustomZoomHandling === 'function') {
applyCustomZoomHandling(cy.value);
}
}, 100);
});
});
function focusGroup() {
if (!cy.value || !selectedGroup.value) return;
const groupNodes = cy.value.nodes().filter(ele => ele.data('group') === selectedGroup.value);
if (groupNodes.length === 0) return;
const bb = groupNodes.boundingBox();
const container = cy.value.container();
const viewportWidth = container.clientWidth;
const viewportHeight = container.clientHeight;
const padding = 40;
const zoomX = (viewportWidth - 2 * padding) / bb.w;
const zoomY = (viewportHeight - 2 * padding) / bb.h;
const zoom = Math.min(zoomX, zoomY, 5);
cy.value.animate({
zoom: zoom,
center: { eles: groupNodes }
}, {
duration: 800
});
}
</script>
<style>
.cy-container {
width: 100vw !important;
max-width: 100vw !important;
min-width: 0 !important;
height: 800px !important;
overflow: hidden !important;
position: relative;
}
</style>

View File

@@ -0,0 +1,299 @@
<template>
<div class="test-page">
<div class="container">
<h1 class="title">테스트 페이지 01</h1>
<div class="test-section">
<h2>기본 기능 테스트</h2>
<div class="test-card">
<h3>사용자 정보 테스트</h3>
<p>
로그인 상태: {{ userStore.isLoggedIn ? "로그인됨" : "로그아웃됨" }}
</p>
<p v-if="userStore.user">사용자: {{ userStore.user.name }}</p>
<p v-if="userStore.user">역할: {{ userStore.user.role }}</p>
<div class="button-group">
<button class="btn btn-success" @click="loginTest">
테스트 로그인
</button>
<button class="btn btn-info" @click="adminLoginTest">
관리자 로그인
</button>
<button class="btn btn-warning" @click="logoutTest">
테스트 로그아웃
</button>
</div>
</div>
</div>
<div class="test-section">
<h2>API 테스트</h2>
<div class="test-card">
<h3>데이터 로딩 테스트</h3>
<button
class="btn btn-primary"
:disabled="loading"
@click="loadTestData"
>
{{ loading ? "로딩 중..." : "테스트 데이터 로드" }}
</button>
<div v-if="testData" class="data-display">
<pre>{{ JSON.stringify(testData, null, 2) }}</pre>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useUserStore } from "~/stores/user";
// 페이지 메타데이터
definePageMeta({
title: "테스트 페이지 01",
description: "기능 테스트를 위한 페이지",
});
const userStore = useUserStore();
// 반응형 데이터
const loading = ref(false);
const testData = ref<{
id: number;
name: string;
timestamp: string;
items: string[];
} | null>(null);
// 메서드
const loginTest = () => {
// 테스트용 로그인 (실제로는 API 호출이 필요)
userStore.user = {
id: "1",
userId: "test",
email: "test@example.com",
name: "테스트 사용자",
role: "user",
};
userStore.isLoggedIn = true;
};
const adminLoginTest = () => {
// 관리자 로그인 로직 구현
userStore.user = {
id: "2",
userId: "admin",
email: "admin@example.com",
name: "관리자",
role: "admin",
};
userStore.isLoggedIn = true;
};
const logoutTest = () => {
userStore.logout();
};
const loadTestData = async () => {
loading.value = true;
try {
// 실제 API 호출 대신 시뮬레이션
await new Promise((resolve) => setTimeout(resolve, 1000));
testData.value = {
id: 1,
name: "테스트 데이터",
timestamp: new Date().toISOString(),
items: ["항목 1", "항목 2", "항목 3"],
};
} catch (error) {
console.error("데이터 로딩 실패:", error);
} finally {
loading.value = false;
}
};
// 라이프사이클
onMounted(() => {
console.log("테스트 페이지 01이 마운트되었습니다.");
});
</script>
<style scoped>
.test-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 2rem 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
.title {
text-align: center;
color: white;
font-size: 2.5rem;
font-weight: bold;
margin-bottom: 2rem;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.test-section {
background: white;
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.test-section h2 {
color: #333;
margin-bottom: 1.5rem;
font-size: 1.5rem;
border-bottom: 2px solid #667eea;
padding-bottom: 0.5rem;
}
.test-card {
background: #f8f9fa;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
border-left: 4px solid #667eea;
}
.test-card h3 {
color: #495057;
margin-bottom: 1rem;
font-size: 1.2rem;
}
.test-card p {
color: #6c757d;
margin-bottom: 1rem;
font-size: 1rem;
}
.button-group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-block;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5a6fd8;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover {
background: #218838;
}
.btn-warning {
background: #ffc107;
color: #212529;
}
.btn-warning:hover {
background: #e0a800;
}
.btn-info {
background: #17a2b8;
color: white;
}
.btn-info:hover {
background: #138496;
}
.data-display {
margin-top: 1rem;
background: #f1f3f4;
border-radius: 6px;
padding: 1rem;
border: 1px solid #dee2e6;
}
.data-display pre {
margin: 0;
font-size: 0.85rem;
color: #495057;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 768px) {
.container {
padding: 0 0.5rem;
}
.test-section {
padding: 1rem;
}
.button-group {
flex-direction: column;
}
.btn {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,282 @@
<template>
<div id="app">
<main role="main" class="container-fluid">
<div style="padding-top: 64px">
<div>
<ul
class="navbar-nav mr-auto"
style="list-style:none; padding-left:0; margin:0;"
>
<li
class="nav-item dropdown"
style="position: relative; display: inline-block;"
>
<a
href="#"
id="igv-example-api-dropdown"
@click.prevent="toggleDropdown"
aria-haspopup="true"
:aria-expanded="isDropdownOpen.toString()"
style="color: black; cursor: pointer; user-select: none;"
>Tracks</a
>
<ul
v-show="isDropdownOpen"
class="dropdown-menu"
style="width:350px; position: absolute; top: 100%; left: 0; background: white; border: 1px solid #ccc; box-shadow: 0 2px 5px rgba(0,0,0,0.15); padding: 5px 0; margin: 0; list-style:none; z-index: 1000;"
>
<li>
<a
href="#"
@click.prevent="loadCopyNumberTrack"
style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
>Copy Number</a
>
</li>
<li>
<a
href="#"
@click.prevent="loadDbSnpTrack"
style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
>dbSNP 137 (bed tabix)</a
>
</li>
<li>
<a
href="#"
@click.prevent="loadBigWigTrack"
style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
>Encode bigwig</a
>
</li>
<li>
<a
href="#"
@click.prevent="loadBamTrack"
style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
>1KG Bam (HG02450)</a
>
</li>
</ul>
</li>
</ul>
</div>
<!-- 콘텐츠 -->
<div class="tab-content" id="viewerTabContent">
<div
class="tab-pane fade show active"
id="igv-viewer"
role="tabpanel"
>
<div style="padding-top: 20px">
예제는 드롭다운 메뉴에서 동적으로 트랙을 추가하는 igv.js API의
사용을 보여줍니다.
위의 메뉴에서 'CopyNumber' 선택하면 다음과 같은 호출이 실행됩니다.
<p>
<pre>
igv.browser.loadTrack({
url: 'https://s3.amazonaws.com/igv.org.demo/GBM-TP.seg.gz',
name: 'GBM Copy # (TCGA Broad GDAC)'});
</pre>
</p>
자세한 내용은
<a href="https://github.com/igvteam/igv.js/wiki">개발자 위키</a>
참조하세요.
</div>
<div id="igvDiv" style="padding-top: 20px"></div>
</div>
</div>
</div>
</main>
</div>
</template>
<script>
export default {
name: "App",
data() {
return {
browser: null,
isDropdownOpen: false,
};
},
mounted() {
// 외부 클릭시 드롭다운 닫기
document.addEventListener("click", this.handleClickOutside);
this.initializeIGV();
},
beforeUnmount() {
document.removeEventListener("click", this.handleClickOutside);
},
methods: {
toggleDropdown() {
this.isDropdownOpen = !this.isDropdownOpen;
},
closeDropdown() {
this.isDropdownOpen = false;
},
handleClickOutside(event) {
const dropdown = this.$el.querySelector("#igv-example-api-dropdown");
const menu = this.$el.querySelector(".dropdown-menu");
if (
dropdown &&
menu &&
!dropdown.contains(event.target) &&
!menu.contains(event.target)
) {
this.closeDropdown();
}
},
async initializeIGV() {
await this.waitForIGV();
await this.$nextTick();
const igvDiv = document.getElementById("igvDiv");
if (!igvDiv) {
console.error("❌ #igvDiv가 존재하지 않습니다.");
return;
}
const options = {
locus: "chr1:155,160,475-155,184,282",
genome: "hg19",
};
try {
this.browser = await igv.createBrowser(igvDiv, options);
window.igv = { browser: this.browser };
this.addBaseClickEvent();
this.browser.on("locuschange", this.addBaseClickEvent);
this.browser.on("trackclick", this.addBaseClickEvent);
} catch (error) {
console.error("IGV 브라우저 생성 중 오류:", error);
}
},
waitForIGV() {
return new Promise((resolve) => {
const checkIGV = () => {
if (typeof igv !== "undefined") {
resolve();
} else {
setTimeout(checkIGV, 100);
}
};
checkIGV();
});
},
addBaseClickEvent() {
setTimeout(() => {
const texts = document.querySelectorAll("#igvDiv text");
texts.forEach((text) => {
const base = text.textContent;
if (["A", "T", "C", "G"].includes(base)) {
text.style.cursor = "pointer";
text.onclick = () => {
text.textContent = this.getComplement(text.textContent);
};
}
});
}, 300);
},
getComplement(base) {
switch (base) {
case "A":
return "T";
case "T":
return "A";
case "C":
return "G";
case "G":
return "C";
default:
return base;
}
},
loadCopyNumberTrack() {
if (this.browser) {
this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
this.browser.loadTrackList([
{
url: "https://s3.amazonaws.com/igv.org.demo/GBM-TP.seg.gz",
indexed: false,
isLog: true,
name: "GBM Copy # (TCGA Broad GDAC)",
},
]);
}
this.closeDropdown();
},
loadDbSnpTrack() {
if (this.browser) {
this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
this.browser.loadTrackList([
{
type: "annotation",
format: "bed",
url: "https://data.broadinstitute.org/igvdata/annotations/hg19/dbSnp/snp137.hg19.bed.gz",
indexURL:
"https://data.broadinstitute.org/igvdata/annotations/hg19/dbSnp/snp137.hg19.bed.gz.tbi",
visibilityWindow: 200000,
name: "dbSNP 137",
},
]);
}
this.closeDropdown();
},
loadBigWigTrack() {
if (this.browser) {
this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
this.browser.loadTrackList([
{
type: "wig",
format: "bigwig",
url: "https://s3.amazonaws.com/igv.broadinstitute.org/data/hg19/encode/wgEncodeBroadHistoneGm12878H3k4me3StdSig.bigWig",
name: "Gm12878H3k4me3",
},
]);
}
this.closeDropdown();
},
loadBamTrack() {
if (this.browser) {
this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
this.browser.loadTrackList([
{
type: "alignment",
format: "bam",
url: "https://1000genomes.s3.amazonaws.com/phase3/data/HG02450/alignment/HG02450.mapped.ILLUMINA.bwa.ACB.low_coverage.20120522.bam",
indexURL:
"https://1000genomes.s3.amazonaws.com/phase3/data/HG02450/alignment/HG02450.mapped.ILLUMINA.bwa.ACB.low_coverage.20120522.bam.bai",
name: "HG02450",
},
]);
}
this.closeDropdown();
},
},
};
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
}
pre {
padding: 10px;
border-radius: 4px;
}
.nav-tabs {
margin-bottom: 20px;
}
.tab-content {
padding-top: 20px;
}
</style>