Compare commits
	
		
			6 Commits
		
	
	
		
			76992e262d
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					195941a402 | ||
| 
						 | 
					f0017a8703 | ||
| dc0fc6e41f | |||
| 
						 | 
					f2a717df4f | ||
| 
						 | 
					7bbbdbe977 | ||
| 268cf9e50f | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -91,6 +91,7 @@ out
 | 
			
		||||
# Nuxt.js build / generate output
 | 
			
		||||
.nuxt
 | 
			
		||||
dist
 | 
			
		||||
.output
 | 
			
		||||
 | 
			
		||||
# Gatsby files
 | 
			
		||||
.cache/
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,4 @@
 | 
			
		||||
export default async function useOverlay() {
 | 
			
		||||
  if (import.meta.server) {
 | 
			
		||||
    // SSR에서는 cytoscape-overlays를 사용하지 않음
 | 
			
		||||
    return null
 | 
			
		||||
  }
 | 
			
		||||
  // 전체 export 객체를 반환
 | 
			
		||||
  return await import('cytoscape-overlays')
 | 
			
		||||
}
 | 
			
		||||
  // SPA 모드에서는 항상 클라이언트에서만 실행됨
 | 
			
		||||
  return await import("cytoscape-overlays");
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,32 +1,30 @@
 | 
			
		||||
export default defineNuxtRouteMiddleware(async (to, _from) => {
 | 
			
		||||
  if (import.meta.client) {
 | 
			
		||||
    const userStore = useUserStore();
 | 
			
		||||
    const { hasPagePermission } = usePermission();
 | 
			
		||||
  const userStore = useUserStore();
 | 
			
		||||
  const { hasPagePermission } = usePermission();
 | 
			
		||||
 | 
			
		||||
    // 공개 라우트 목록 (로그인 없이 접근 가능)
 | 
			
		||||
    const publicRoutes = ["/login", "/register", "/auth-error"];
 | 
			
		||||
  // 공개 라우트 목록 (로그인 없이 접근 가능)
 | 
			
		||||
  const publicRoutes = ["/login", "/register", "/auth-error"];
 | 
			
		||||
 | 
			
		||||
    // 공개 라우트인지 확인
 | 
			
		||||
    const isPublicRoute = publicRoutes.some(route => to.path === route);
 | 
			
		||||
  // 공개 라우트인지 확인
 | 
			
		||||
  const isPublicRoute = publicRoutes.some(route => to.path === route);
 | 
			
		||||
 | 
			
		||||
    // 공개 라우트가 아닌 경우 로그인 체크
 | 
			
		||||
    if (!isPublicRoute && !userStore.isLoggedIn) {
 | 
			
		||||
      return navigateTo("/login");
 | 
			
		||||
  // 공개 라우트가 아닌 경우 로그인 체크
 | 
			
		||||
  if (!isPublicRoute && !userStore.isLoggedIn) {
 | 
			
		||||
    return navigateTo("/login");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 로그인된 사용자의 경우 권한 체크
 | 
			
		||||
  if (userStore.isLoggedIn) {
 | 
			
		||||
    // 홈화면 경로는 항상 허용
 | 
			
		||||
    if (to.path === "/" || to.path === "/1/") {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 로그인된 사용자의 경우 권한 체크
 | 
			
		||||
    if (userStore.isLoggedIn) {
 | 
			
		||||
      // 홈화면 경로는 항상 허용
 | 
			
		||||
      if (to.path === "/" || to.path === "/1/") {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // 페이지 권한 체크
 | 
			
		||||
      if (!hasPagePermission(to.path)) {
 | 
			
		||||
        console.log(`페이지 권한이 없습니다.: ${to.path}`);
 | 
			
		||||
        alert(`페이지 권한이 없습니다.`);
 | 
			
		||||
        return navigateTo("/");
 | 
			
		||||
      }
 | 
			
		||||
    // 페이지 권한 체크
 | 
			
		||||
    if (!hasPagePermission(to.path)) {
 | 
			
		||||
      console.log(`페이지 권한이 없습니다.: ${to.path}`);
 | 
			
		||||
      alert(`페이지 권한이 없습니다.`);
 | 
			
		||||
      return navigateTo("/");
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
// https://nuxt.com/docs/api/configuration/nuxt-config
 | 
			
		||||
export default defineNuxtConfig({
 | 
			
		||||
  compatibilityDate: "2025-05-15",
 | 
			
		||||
  ssr: false,
 | 
			
		||||
  devtools: { enabled: true },
 | 
			
		||||
  modules: [
 | 
			
		||||
    "@nuxt/eslint",
 | 
			
		||||
@@ -36,7 +36,7 @@ export default defineNuxtConfig({
 | 
			
		||||
    shim: false,
 | 
			
		||||
    strict: true,
 | 
			
		||||
  },
 | 
			
		||||
  plugins: ["~/plugins/vue3-tui-grid.client.ts"],
 | 
			
		||||
  plugins: ["~/plugins/vue3-tui-grid.ts"],
 | 
			
		||||
  components: [
 | 
			
		||||
    { path: "~/components/base", pathPrefix: false }, // @base/ 접두사 제거
 | 
			
		||||
    { path: "~/components/layout", pathPrefix: false }, // @layout/ 접두사 제거
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										6046
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6046
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -15,7 +15,6 @@
 | 
			
		||||
    "@nuxt/eslint": "^1.4.1",
 | 
			
		||||
    "@nuxt/icon": "^1.14.0",
 | 
			
		||||
    "@nuxt/image": "^1.10.0",
 | 
			
		||||
    "@nuxtjs/tailwindcss": "^7.0.0-beta.0",
 | 
			
		||||
    "@pinia/nuxt": "^0.11.2",
 | 
			
		||||
    "ag-grid-community": "^34.0.0",
 | 
			
		||||
    "ag-grid-vue3": "^34.0.0",
 | 
			
		||||
@@ -26,6 +25,7 @@
 | 
			
		||||
    "echarts": "^5.6.0",
 | 
			
		||||
    "eslint": "^9.29.0",
 | 
			
		||||
    "ipx": "^3.1.1",
 | 
			
		||||
    "molstar": "^5.1.2",
 | 
			
		||||
    "nuxt": "^3.17.5",
 | 
			
		||||
    "pinia": "^3.0.3",
 | 
			
		||||
    "pinia-plugin-persistedstate": "^4.5.0",
 | 
			
		||||
@@ -36,11 +36,12 @@
 | 
			
		||||
    "vue3-tui-grid": "^0.1.51"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@nuxtjs/tailwindcss": "^6.12.0",
 | 
			
		||||
    "autoprefixer": "^10.4.21",
 | 
			
		||||
    "patch-package": "^8.0.0",
 | 
			
		||||
    "postcss": "^8.5.6",
 | 
			
		||||
    "postinstall-postinstall": "^2.1.0",
 | 
			
		||||
    "prettier": "^3.6.2",
 | 
			
		||||
    "tailwindcss": "^4.1.11"
 | 
			
		||||
    "tailwindcss": "^3.4.0"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										87
									
								
								pages/[tabId]/test/molstar.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								pages/[tabId]/test/molstar.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,87 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="molstar-page">
 | 
			
		||||
    <PageDescription>
 | 
			
		||||
      <h1>Mol* 뷰어 테스트</h1>
 | 
			
		||||
      <div class="box">
 | 
			
		||||
        <p>간단한 PDB 구조(1CRN)를 불러와 Mol*로 렌더링합니다.</p>
 | 
			
		||||
      </div>
 | 
			
		||||
    </PageDescription>
 | 
			
		||||
 | 
			
		||||
    <div
 | 
			
		||||
      ref="containerRef"
 | 
			
		||||
      class="viewer-wrap"
 | 
			
		||||
      :style="{ height: containerHeight }"
 | 
			
		||||
    >
 | 
			
		||||
      <div ref="viewerRef" class="molstar-viewer" />
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { onMounted, onBeforeUnmount, ref } from "vue";
 | 
			
		||||
import { Viewer } from "molstar/lib/apps/viewer/app";
 | 
			
		||||
import "molstar/build/viewer/molstar.css";
 | 
			
		||||
 | 
			
		||||
definePageMeta({
 | 
			
		||||
  title: "Mol* 테스트",
 | 
			
		||||
  description: "Mol* 뷰어 테스트 페이지",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const viewerRef = ref<HTMLDivElement | null>(null);
 | 
			
		||||
const containerRef = ref<HTMLDivElement | null>(null);
 | 
			
		||||
const containerHeight = ref<string>("80vh");
 | 
			
		||||
let viewer: Viewer | null = null;
 | 
			
		||||
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  if (!viewerRef.value) return;
 | 
			
		||||
 | 
			
		||||
  viewer = await Viewer.create(viewerRef.value, {
 | 
			
		||||
    layoutIsExpanded: true,
 | 
			
		||||
    layoutShowControls: true,
 | 
			
		||||
    viewportShowExpand: true,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // 예시 구조: 1CRN
 | 
			
		||||
  await viewer.loadStructureFromUrl(
 | 
			
		||||
    "https://files.rcsb.org/download/1CRN.pdb",
 | 
			
		||||
    "pdb"
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const resizeToAvailableHeight = () => {
 | 
			
		||||
    if (!containerRef.value) return;
 | 
			
		||||
    const rect = containerRef.value.getBoundingClientRect();
 | 
			
		||||
    const bottomPadding = 16; // 여백
 | 
			
		||||
    const available = window.innerHeight - rect.top - bottomPadding;
 | 
			
		||||
    containerHeight.value = `${Math.max(300, available)}px`;
 | 
			
		||||
    viewer?.handleResize();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  resizeToAvailableHeight();
 | 
			
		||||
  window.addEventListener("resize", resizeToAvailableHeight);
 | 
			
		||||
  // 정리 함수 저장
 | 
			
		||||
  (containerRef as any)._off = () =>
 | 
			
		||||
    window.removeEventListener("resize", resizeToAvailableHeight);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
onBeforeUnmount(() => {
 | 
			
		||||
  viewer?.dispose?.();
 | 
			
		||||
  viewer = null;
 | 
			
		||||
  (containerRef as any)?._off?.();
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.viewer-wrap {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  overflow: hidden; /* 내부 Mol*에서 자체 스크롤 관리 */
 | 
			
		||||
}
 | 
			
		||||
.molstar-viewer {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.msp-plugin .msp-layout-expanded {
 | 
			
		||||
  position: inherit;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -19,18 +19,6 @@ export default defineNuxtPlugin(() => {
 | 
			
		||||
        ...(isFormData ? {} : { "Content-Type": "application/json" }),
 | 
			
		||||
        ...(options.headers || {}),
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      // 3) SSR 쿠키 포워딩
 | 
			
		||||
      if (import.meta.server) {
 | 
			
		||||
        const cookie = useRequestHeaders(["cookie"])?.cookie;
 | 
			
		||||
        const reqUrl = String(request);
 | 
			
		||||
        const isBackendApi =
 | 
			
		||||
          !reqUrl.startsWith("http") || reqUrl.startsWith(baseURL);
 | 
			
		||||
 | 
			
		||||
        if (cookie && isBackendApi) {
 | 
			
		||||
          options.headers = { ...(options.headers || {}), cookie } as any;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    onResponseError({ response }) {
 | 
			
		||||
      // 공통 로깅
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
import { defineNuxtPlugin } from '#app'
 | 
			
		||||
import TuiGrid from 'vue3-tui-grid'
 | 
			
		||||
import 'tui-grid/dist/tui-grid.css'
 | 
			
		||||
 | 
			
		||||
export default defineNuxtPlugin(nuxtApp => {
 | 
			
		||||
  nuxtApp.vueApp.use(TuiGrid)
 | 
			
		||||
})
 | 
			
		||||
import { defineNuxtPlugin } from '#app'
 | 
			
		||||
import TuiGrid from 'vue3-tui-grid'
 | 
			
		||||
import 'tui-grid/dist/tui-grid.css'
 | 
			
		||||
 | 
			
		||||
export default defineNuxtPlugin(nuxtApp => {
 | 
			
		||||
  nuxtApp.vueApp.use(TuiGrid)
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										65
									
								
								public/cy_custom.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								public/cy_custom.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,65 @@
 | 
			
		||||
(function () {
 | 
			
		||||
    const EXTREME_ZOOM_MIN = 1e-4;
 | 
			
		||||
    const EXTREME_ZOOM_MAX = 1e4;
 | 
			
		||||
  
 | 
			
		||||
    function applyCustomZoomHandling(cy) {
 | 
			
		||||
      let zoomFramePending = false;
 | 
			
		||||
  
 | 
			
		||||
      if (!cy || typeof cy.zoom !== 'function') {
 | 
			
		||||
        console.warn('🚫 유효하지 않은 Cytoscape 인스턴스입니다.');
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
  
 | 
			
		||||
      cy.userZoomingEnabled(false); // Cytoscape 기본 줌 비활성화
 | 
			
		||||
  
 | 
			
		||||
      const container = cy.container();
 | 
			
		||||
      if (!container) {
 | 
			
		||||
        console.warn('🚫 Cytoscape container를 찾을 수 없습니다.');
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
  
 | 
			
		||||
      container.addEventListener(
 | 
			
		||||
        'wheel',
 | 
			
		||||
        (event) => {
 | 
			
		||||
          event.preventDefault();
 | 
			
		||||
  
 | 
			
		||||
          if (zoomFramePending) return;
 | 
			
		||||
          zoomFramePending = true;
 | 
			
		||||
  
 | 
			
		||||
          requestAnimationFrame(() => {
 | 
			
		||||
            const currentZoom = cy.zoom();
 | 
			
		||||
  
 | 
			
		||||
            const clampedDelta = Math.max(-100, Math.min(100, event.deltaY));
 | 
			
		||||
            const zoomFactor = 1 + (clampedDelta > 0 ? -0.05 : 0.05);
 | 
			
		||||
            const nextZoom = currentZoom * zoomFactor;
 | 
			
		||||
  
 | 
			
		||||
            const isValid =
 | 
			
		||||
              isFinite(nextZoom) &&
 | 
			
		||||
              nextZoom >= EXTREME_ZOOM_MIN &&
 | 
			
		||||
              nextZoom <= EXTREME_ZOOM_MAX;
 | 
			
		||||
  
 | 
			
		||||
            if (isValid) {
 | 
			
		||||
              const rect = container.getBoundingClientRect();
 | 
			
		||||
              const renderedPosition = {
 | 
			
		||||
                x: event.clientX - rect.left,
 | 
			
		||||
                y: event.clientY - rect.top
 | 
			
		||||
              };
 | 
			
		||||
              cy.zoom({
 | 
			
		||||
                level: nextZoom,
 | 
			
		||||
                renderedPosition
 | 
			
		||||
              });
 | 
			
		||||
            } else {
 | 
			
		||||
              console.warn("🚫 휠 줌 비정상 값 차단:", nextZoom);
 | 
			
		||||
            }
 | 
			
		||||
  
 | 
			
		||||
            zoomFramePending = false;
 | 
			
		||||
          });
 | 
			
		||||
        },
 | 
			
		||||
        { passive: false }
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  
 | 
			
		||||
    // ✅ 전역(window)으로 함수 등록
 | 
			
		||||
    window.applyCustomZoomHandling = applyCustomZoomHandling;
 | 
			
		||||
  })();
 | 
			
		||||
  
 | 
			
		||||
							
								
								
									
										28
									
								
								public/igv.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								public/igv.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
/* IGV blue lines & UI custom style (기본 파란색: #3579f6) */
 | 
			
		||||
.igv-blue-line {
 | 
			
		||||
  background: #3579f6 !important;
 | 
			
		||||
  width: 2px !important;
 | 
			
		||||
  z-index: 1000;
 | 
			
		||||
}
 | 
			
		||||
.igv-blue-fill {
 | 
			
		||||
  background: rgba(53, 121, 246, 0.18) !important;
 | 
			
		||||
  z-index: 999;
 | 
			
		||||
}
 | 
			
		||||
.igv-control-btn {
 | 
			
		||||
  background: #f5faff;
 | 
			
		||||
  color: #3579f6;
 | 
			
		||||
  border: 1.5px solid #3579f6;
 | 
			
		||||
  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: #3579f6;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  border: 2px solid #2456a6;
 | 
			
		||||
}
 | 
			
		||||
.igv-blue-line.selected {
 | 
			
		||||
  box-shadow: 0 0 8px #3579f6;
 | 
			
		||||
} 
 | 
			
		||||
							
								
								
									
										67298
									
								
								public/igv.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67298
									
								
								public/igv.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										222
									
								
								public/igv_custom.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										222
									
								
								public/igv_custom.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,222 @@
 | 
			
		||||
// igv_custom.js
 | 
			
		||||
// IGV 브라우저가 생성된 후, 파란색 라인 2개를 ideogram/viewport에 추가하고 드래그로 이동 가능하게 함
 | 
			
		||||
// 라인 위치 변경 시 window에 커스텀 이벤트('igv-blue-lines-changed')를 dispatch
 | 
			
		||||
 | 
			
		||||
(function() {
 | 
			
		||||
  // 설정값
 | 
			
		||||
  const LINE_COLOR = '#007bff';
 | 
			
		||||
  const LINE_WIDTH = 2;
 | 
			
		||||
  const INIT_RATIO_1 = 0.3; // 30% 위치
 | 
			
		||||
  const INIT_RATIO_2 = 0.7; // 70% 위치
 | 
			
		||||
 | 
			
		||||
  // 내부 상태
 | 
			
		||||
  let viewportRect = null;
 | 
			
		||||
  let dragging = null; // 'start' or 'end' or null
 | 
			
		||||
  let dragOffset = 0;
 | 
			
		||||
  let startRatio = INIT_RATIO_1;
 | 
			
		||||
  let endRatio = INIT_RATIO_2;
 | 
			
		||||
 | 
			
		||||
  // 라인 DOM
 | 
			
		||||
  let viewportLines = [];
 | 
			
		||||
  let viewportFill = null;
 | 
			
		||||
  let ideogramLines = [];
 | 
			
		||||
  let ideogramFill = null;
 | 
			
		||||
 | 
			
		||||
  // IGV가 준비되면 실행
 | 
			
		||||
  function waitForIGVAndInit() {
 | 
			
		||||
    const check = () => {
 | 
			
		||||
      const viewport = document.querySelector('.igv-viewport-content');
 | 
			
		||||
      if (viewport) {
 | 
			
		||||
        setupLines();
 | 
			
		||||
      } else {
 | 
			
		||||
        setTimeout(check, 300);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    check();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 라인 생성 및 이벤트 바인딩
 | 
			
		||||
  function setupLines() {
 | 
			
		||||
    // 모든 파란 라인 삭제
 | 
			
		||||
    document.querySelectorAll('.igv-blue-line').forEach(el => el.remove());
 | 
			
		||||
    // 기존 fill 오버레이 삭제
 | 
			
		||||
    document.querySelectorAll('.igv-blue-fill').forEach(el => el.remove());
 | 
			
		||||
 | 
			
		||||
    // 모든 .igv-viewport-content 중, height가 16px 초과인 첫 번째를 메인 뷰포트로 간주
 | 
			
		||||
    const viewports = Array.from(document.querySelectorAll('.igv-viewport-content'));
 | 
			
		||||
    const mainViewport = viewports.find(vp => {
 | 
			
		||||
      const h = vp.offsetHeight || parseInt(vp.style.height, 10);
 | 
			
		||||
      return h > 16;
 | 
			
		||||
    });
 | 
			
		||||
    if (!mainViewport) return;
 | 
			
		||||
 | 
			
		||||
    // 이미 있으면 중복 생성 방지
 | 
			
		||||
    if (mainViewport.parentNode.querySelector('.igv-blue-line')) return;
 | 
			
		||||
 | 
			
		||||
    viewportRect = mainViewport.getBoundingClientRect();
 | 
			
		||||
 | 
			
		||||
    // 라인 2개 생성 (뷰포트)
 | 
			
		||||
    viewportLines = [createLine('start', 'viewport', mainViewport), createLine('end', 'viewport', mainViewport)];
 | 
			
		||||
    viewportLines.forEach(line => mainViewport.parentNode.appendChild(line));
 | 
			
		||||
    // fill 오버레이 생성 및 추가 (뷰포트)
 | 
			
		||||
    viewportFill = document.createElement('div');
 | 
			
		||||
    viewportFill.className = 'igv-blue-fill';
 | 
			
		||||
    Object.assign(viewportFill.style, {
 | 
			
		||||
      position: 'absolute',
 | 
			
		||||
      top: (viewportRect.top - mainViewport.getBoundingClientRect().top) + 'px',
 | 
			
		||||
      height: viewportRect.height + 'px',
 | 
			
		||||
      background: 'rgba(0,123,255,0.18)',
 | 
			
		||||
      zIndex: 999,
 | 
			
		||||
      pointerEvents: 'none',
 | 
			
		||||
      borderRadius: '2px',
 | 
			
		||||
      transition: 'left 0.08s, width 0.08s',
 | 
			
		||||
    });
 | 
			
		||||
    mainViewport.parentNode.appendChild(viewportFill);
 | 
			
		||||
 | 
			
		||||
    // === ideogram(미니맵)에도 라인/오버레이 추가 ===
 | 
			
		||||
    const ideogram = document.querySelector('.igv-ideogram-content') || document.querySelector('.igv-ideogram');
 | 
			
		||||
    if (ideogram) {
 | 
			
		||||
      const ideogramRect = ideogram.getBoundingClientRect();
 | 
			
		||||
      ideogramLines = [createLine('start', 'ideogram', ideogram), createLine('end', 'ideogram', ideogram)];
 | 
			
		||||
      ideogramLines.forEach(line => ideogram.parentNode.appendChild(line));
 | 
			
		||||
      ideogramFill = document.createElement('div');
 | 
			
		||||
      ideogramFill.className = 'igv-blue-fill';
 | 
			
		||||
      Object.assign(ideogramFill.style, {
 | 
			
		||||
        position: 'absolute',
 | 
			
		||||
        top: (ideogramRect.top - ideogram.parentNode.getBoundingClientRect().top) + 'px',
 | 
			
		||||
        height: ideogramRect.height + 'px',
 | 
			
		||||
        background: 'rgba(0,123,255,0.18)',
 | 
			
		||||
        zIndex: 999,
 | 
			
		||||
        pointerEvents: 'none',
 | 
			
		||||
        borderRadius: '2px',
 | 
			
		||||
        transition: 'left 0.08s, width 0.08s',
 | 
			
		||||
      });
 | 
			
		||||
      ideogram.parentNode.appendChild(ideogramFill);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    updateLinePositions();
 | 
			
		||||
    bindDragEvents();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 라인 DOM 생성
 | 
			
		||||
  function createLine(which, area, parentEl) {
 | 
			
		||||
    const line = document.createElement('div');
 | 
			
		||||
    line.className = 'igv-blue-line';
 | 
			
		||||
    line.dataset.which = which;
 | 
			
		||||
    line.dataset.area = area;
 | 
			
		||||
    line._parentEl = parentEl; // 커스텀 속성으로 부모 저장
 | 
			
		||||
    Object.assign(line.style, {
 | 
			
		||||
      position: 'absolute',
 | 
			
		||||
      top: area === 'ideogram' ? (viewportRect.top - parentEl.getBoundingClientRect().top) + 'px' : (viewportRect.top - parentEl.getBoundingClientRect().top) + 'px',
 | 
			
		||||
      width: LINE_WIDTH + 'px',
 | 
			
		||||
      height: area === 'ideogram' ? viewportRect.height + 'px' : viewportRect.height + 'px',
 | 
			
		||||
      background: LINE_COLOR,
 | 
			
		||||
      zIndex: 1000,
 | 
			
		||||
      cursor: 'ew-resize',
 | 
			
		||||
      userSelect: 'none',
 | 
			
		||||
    });
 | 
			
		||||
    line.addEventListener('mousedown', (e) => startDrag(which, e));
 | 
			
		||||
    return line;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 라인 위치 갱신
 | 
			
		||||
  function updateLinePositions() {
 | 
			
		||||
    viewportRect = document.querySelector('.igv-viewport-content').getBoundingClientRect();
 | 
			
		||||
    let baseArea = document.querySelector('.igv-viewport-content canvas, .igv-viewport-content svg');
 | 
			
		||||
    let baseRect = baseArea ? baseArea.getBoundingClientRect() : viewportRect;
 | 
			
		||||
    // viewport 라인 위치 (실제 베이스 표시 영역 기준)
 | 
			
		||||
    const baseWidth = baseRect.width;
 | 
			
		||||
    const baseLeft = baseRect.left;
 | 
			
		||||
    const startX_view = baseLeft + baseWidth * startRatio;
 | 
			
		||||
    const endX_view = baseLeft + baseWidth * endRatio;
 | 
			
		||||
    viewportLines[0].style.left = (startX_view - viewportLines[0]._parentEl.getBoundingClientRect().left) + 'px';
 | 
			
		||||
    viewportLines[1].style.left = (endX_view - viewportLines[1]._parentEl.getBoundingClientRect().left) + 'px';
 | 
			
		||||
    // fill 오버레이 위치/크기 갱신
 | 
			
		||||
    if (viewportFill) {
 | 
			
		||||
      const left = Math.min(startX_view, endX_view) - viewportFill.parentNode.getBoundingClientRect().left;
 | 
			
		||||
      const width = Math.abs(endX_view - startX_view);
 | 
			
		||||
      viewportFill.style.left = left + 'px';
 | 
			
		||||
      viewportFill.style.width = width + 'px';
 | 
			
		||||
    }
 | 
			
		||||
    // === ideogram(미니맵) 라인/오버레이 위치 갱신 ===
 | 
			
		||||
    const ideogram = document.querySelector('.igv-ideogram-content') || document.querySelector('.igv-ideogram');
 | 
			
		||||
    if (ideogram && ideogramLines.length === 2 && ideogramFill) {
 | 
			
		||||
      const ideogramRect = ideogram.getBoundingClientRect();
 | 
			
		||||
      const ideogramWidth = ideogramRect.width;
 | 
			
		||||
      const ideogramLeft = ideogramRect.left;
 | 
			
		||||
      const startX_ideo = ideogramLeft + ideogramWidth * startRatio;
 | 
			
		||||
      const endX_ideo = ideogramLeft + ideogramWidth * endRatio;
 | 
			
		||||
      ideogramLines[0].style.left = (startX_ideo - ideogram.parentNode.getBoundingClientRect().left) + 'px';
 | 
			
		||||
      ideogramLines[1].style.left = (endX_ideo - ideogram.parentNode.getBoundingClientRect().left) + 'px';
 | 
			
		||||
      // fill
 | 
			
		||||
      const left = Math.min(startX_ideo, endX_ideo) - ideogramFill.parentNode.getBoundingClientRect().left;
 | 
			
		||||
      const width = Math.abs(endX_ideo - startX_ideo);
 | 
			
		||||
      ideogramFill.style.left = left + 'px';
 | 
			
		||||
      ideogramFill.style.width = width + 'px';
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 드래그 이벤트 바인딩
 | 
			
		||||
  function bindDragEvents() {
 | 
			
		||||
    document.addEventListener('mousemove', onDrag);
 | 
			
		||||
    document.addEventListener('mouseup', stopDrag);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function startDrag(which, e) {
 | 
			
		||||
    dragging = which;
 | 
			
		||||
    dragOffset = e.clientX;
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    e.stopPropagation();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function onDrag(e) {
 | 
			
		||||
    if (!dragging) return;
 | 
			
		||||
    const areaWidth = viewportRect.width;
 | 
			
		||||
    let x = e.clientX - viewportRect.left;
 | 
			
		||||
    let ratio = x / areaWidth;
 | 
			
		||||
    ratio = Math.max(0, Math.min(1, ratio));
 | 
			
		||||
    if (dragging === 'start') {
 | 
			
		||||
      startRatio = Math.min(ratio, endRatio - 0.01);
 | 
			
		||||
    } else {
 | 
			
		||||
      endRatio = Math.max(ratio, startRatio + 0.01);
 | 
			
		||||
    }
 | 
			
		||||
    updateLinePositions();
 | 
			
		||||
    fireChangeEvent();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function stopDrag() {
 | 
			
		||||
    dragging = null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 라인 위치 변경 이벤트 발생
 | 
			
		||||
  function fireChangeEvent() {
 | 
			
		||||
    console.log('fireChangeEvent 호출됨', { startRatio, endRatio });
 | 
			
		||||
    window.dispatchEvent(new CustomEvent('igv-blue-lines-changed', {
 | 
			
		||||
      detail: {
 | 
			
		||||
        startRatio,
 | 
			
		||||
        endRatio
 | 
			
		||||
      }
 | 
			
		||||
    }));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 외부에서 라인 위치를 강제로 지정할 수 있도록 export
 | 
			
		||||
  window.igvCustom = {
 | 
			
		||||
    setLineRatios: (start, end) => {
 | 
			
		||||
      startRatio = start;
 | 
			
		||||
      endRatio = end;
 | 
			
		||||
      updateLinePositions();
 | 
			
		||||
      fireChangeEvent();
 | 
			
		||||
    },
 | 
			
		||||
    getLineRatios: () => ({ startRatio, endRatio }),
 | 
			
		||||
    setupLines: setupLines
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // 스타일 추가
 | 
			
		||||
  const style = document.createElement('style');
 | 
			
		||||
  style.innerHTML = `.igv-blue-line { pointer-events: auto !important; }
 | 
			
		||||
  .igv-blue-fill { pointer-events: none !important; }`;
 | 
			
		||||
  document.head.appendChild(style);
 | 
			
		||||
 | 
			
		||||
  // IGV가 준비되면 실행
 | 
			
		||||
  setTimeout(waitForIGVAndInit, 1000);
 | 
			
		||||
})(); 
 | 
			
		||||
@@ -302,6 +302,17 @@ export const MOCK_PERMISSIONS: UserPermissions = {
 | 
			
		||||
        description: "테스트 등록 페이지",
 | 
			
		||||
        menuYn: "N",
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        oid: 26,
 | 
			
		||||
        code: "P0118",
 | 
			
		||||
        name: "molstar",
 | 
			
		||||
        type: "PAGE",
 | 
			
		||||
        path: "/test/molstar",
 | 
			
		||||
        parentCode: "PG01",
 | 
			
		||||
        sortOrder: 18,
 | 
			
		||||
        description: "Mol* 뷰어 테스트 페이지",
 | 
			
		||||
        menuYn: "Y",
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      // 관리자 페이지그룹 하위 페이지들 (PG02 > P0201~P0203)
 | 
			
		||||
      {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user