diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..15f6ad2 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Copy this file to .env.local and set variables as needed. +# +# Backend origin used by Next.js rewrite proxy. +# - Local development default: http://127.0.0.1:8000 +# - Production: BACKEND_URL must be explicitly set. +BACKEND_URL= +# +# Frontend axios base URL. +# Keep this unset to use "/api" (same-origin via Next.js rewrite). +# Set only when you intentionally bypass the rewrite proxy. +NEXT_PUBLIC_API_BASE_URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..06ec980 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + pull_request: + branches: + - main + - master + - develop + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint-and-build: + name: Lint & Build + runs-on: ubuntu-latest + + env: + NEXT_TELEMETRY_DISABLED: "1" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Inject BACKEND_URL + shell: bash + env: + BACKEND_URL_PRODUCTION: ${{ secrets.BACKEND_URL_PRODUCTION }} + BACKEND_URL_STAGING: ${{ secrets.BACKEND_URL_STAGING }} + run: | + # fork PR은 보안상 secrets가 전달되지 않음 → 공개 URL(또는 더미)로 빌드만 통과 + if [ "${{ github.event.pull_request.head.repo.fork }}" = "true" ]; then + echo "BACKEND_URL=https://primerflow-be.onrender.com" >> "$GITHUB_ENV" + exit 0 + fi + + if [ "${{ github.base_ref }}" = "main" ] || [ "${{ github.base_ref }}" = "master" ]; then + selected="$BACKEND_URL_PRODUCTION" + key="BACKEND_URL_PRODUCTION" + else + selected="$BACKEND_URL_STAGING" + key="BACKEND_URL_STAGING" + fi + + if [ -z "$selected" ]; then + echo "::error::Missing secret $key" + echo "::error::Set it in Settings -> Secrets and variables -> Actions -> Repository secrets" + exit 1 + fi + + echo "BACKEND_URL=$selected" >> "$GITHUB_ENV" + + - name: Lint + run: npm run lint + + - name: Test + run: npm test + + - name: Build + run: npm run build \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5ef6a52..7b8da95 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/README.md b/README.md index e067165..ae435e9 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,22 @@ git clone [https://github.com/Seq-Lab/PrimerFlow-FE.git](https://github.com/Seq- cd PrimerFlow-FE # 3. 패키지 설치 -npm install +npm ci # 4. 환경 변수 설정 (.env.local 생성) -# (백엔드 API 주소 설정 예시) -# echo "NEXT_PUBLIC_API_URL=http://localhost:8000" > .env.local +- `.env.example` 파일을 복사하여 `.env.local`을 생성하세요. +- Next.js `rewrites`에서 백엔드 목적지는 `BACKEND_URL`이 설정되면 해당 값을, 없으면 `http://127.0.0.1:8000`(로컬)로 사용합니다. +- 로컬 기본값(127.0.0.1:8000)을 사용하려면 `.env.local`을 비워 두어도 무방합니다. +- 다른 백엔드로 프록시해야 한다면 `.env.local`에 아래처럼 설정하세요: + +```env +BACKEND_URL=[https://api.example.com](https://api.example.com) +``` + +- Vercel 등 배포 환경에서도 동일한 환경 변수를 프로젝트 환경 변수로 추가하면 됩니다. # 5. 개발 서버 실행 +``` npm run dev ``` @@ -97,7 +106,7 @@ npm run dev - AI 활용: Gemini로 자세한 내용 프롬프트로 작성, codex로 프로젝트 아키텍처 및 스켈레톤 코드 작성. - 다음 주 계획: page.tsx, layout.tsx 구현, 목 데이터 출력 해보기 -### Week 2 (25.12.29 - 26.01.04) +### Week 2 (25.12.29 - 26.01.04)z` - 작업 내역: - 더미 데이터로 페이지에 연결 - 뷰 상태(Zustand)와 줌·패닝 동작을 정돈 @@ -136,3 +145,83 @@ npm run dev ![week3_screenshot#4.png](docs/screenshots/week3_screenshot%234.png) - 다음 주 계획: 실제 데이터 연동, GenomeCanvas 미리보기·컨트롤 마무리. + +### Week 4 (26.01.12 ~ 26.01.18) +- 작업 내역: + - 백엔드 모킹 서비스 구현 및 결과 시각화 + - Step 1 시퀀스 입력 편의성 개선 + - 컴포넌트 아키텍처 개선 및 UI 업데이트 +- AI 활용: + - codex로 캔버스가 표시되는 모달 구현 + - paste등 버튼 기능 구현 +- 완료 기능: + - 목데이터를 모달을 이용하여 표시 + - Step1에서 DNA서열 입력 시, fasta파일 업로드, 클립보드에서 붙여넣기 지원 + +- 테스트 결과: + - 목데이터 표시 확인 + + ![week4_screenshot#1.png](docs/screenshots/week4_screenshot%231.png) +- 다음 주 계획: 완성된 백엔드와 연동하여 결과 표시 및 디버깅 + +### Week 5 (26.01.19 ~ 26.01.25) +- 작업 내역: 프론트엔드-백엔드 간 API 통신 규격(Spec) 정의 및 연동 구현 +- AI 활용: codex 이용하여 복잡한 Nested Object을 UI 전용 상태(Flat Object)로 변환하는 어댑터 패턴 코드 자동 생성 +- 완료 기능: + - 프라이머 설계 요청(Request) 프로세스 구현: 입력값 → 어댑터 → API 호출 흐름 완성 + - 결과 모달(Result Modal) 데이터 바인딩: Mock 데이터를 활용하여 캔버스 및 리스트에 분석 결과 렌더링 +- 다음 주 계획: 사용자 입력 데이터(DNA 서열)에 대한 전처리(Sanitization) 및 유효성 검증 로직 구현 + + +### Week 6 (26.01.26 ~ 26.02.01) +- 작업 내역: + - 대용량 데이터(10,000bp 이상) 렌더링 성능 최적화를 위한 뷰포트 탐색 로직 개선 + - 캔버스 UI 스크롤 조작 시 배경이 함께 밀리는 버그(Jittering) 수정 및 레이어 고정 처리 +- AI 활용: + - codex를 이용하여 binary search 알고리즘 로직 검증 및 최적화 + - gemini로 현재 발생하고 있는 상황을 정확하게 설명하여 해결을 요구하는 프롬프트 작성 및 codex를 이용한 수정 +- 완료 기능: + - Binary Search 렌더링 최적화: $O(N)$ 탐색을 $O(\log N)$으로 개선하여 High BP 구간 프레임 드랍 해결 + - Canvas Background Fix: 스크롤 이벤트 시 배경 이미지가 고정되도록 렌더링 로직 수정 +- 다음 주 계획: + - 입력 데이터 validator 구현 + +### Week 7 (26.02.02 ~ 26.02.08) +- Step1 시퀀스 입력 정규화 및 검증 UX 개선 + - ATGC 대소문자 처리 및 비정상 문자(N, 숫자, 특수문자) 필터링 로직 정립 + - 붙여넣기 및 파일 업로드 시 사용자 동의 UX 일관성 확보 + +- AI 활용: + - 4단계 프롬프트(Phase 1~4)를 구성하여 AI와 단계별 로직 고도화 및 트러블슈팅 진행 + - Next.js Turbopack 빌드 에러(Import 경로 이슈) 분석 및 해결 + - 대량 문자열 붙여넣기 시 발생하는 데이터 손실(과도한 삭제) 문제에 대한 최적화된 Sanitize 접근 방식 제안 및 적용 + +- 완료 기능: + - 실시간 정규화: 입력 즉시 대소문자 구분 없이 대문자 ATGC로 자동 변환 및 실시간 필터링 적용 (안내 캡션 추가) + - 사용자 동의 기반 예외 처리: FASTA 파일 업로드, Paste 버튼, Ctrl+V 입력 시 비정상 문자가 감지되면 즉시 삭제하지 않고 window.confirm을 통한 사용자 제거 동의 로직 구현 + - 로직 최적화: 조각(chunk) 단위 산니타이즈(Sanitize) 방식으로 전환하여 성능 개선 및 Generate 단계의 불필요한 중복 검증 로직 제거 + +- 다음 주 계획: + - 목데이터 제거 및 배포된 백엔드와 연결 + +### Week 8 (26.02.09 ~ 26.02.15) +- 작업 내역: + - 목데이터(Mock Data) 기반 응답 제거 및 실서버 응답 구조 기준으로 프론트 로직 전환 + - 프라이머 분석 요청 파라미터를 백엔드 스펙에 맞게 정리하고 요청/응답 매핑 흐름 점검 + - API 호출 실패 상황(네트워크/서버 오류)에 대한 사용자 메시지 노출 및 상태 처리 보강 +- AI 활용: + - Codex를 활용해 API 클라이언트 경로(`/api/design`)와 서비스 레이어 매핑 로직 검증 + - 응답 데이터 변환(UI 전용 트랙/프라이머 후보 매핑) 과정의 타입 안정성 점검 및 개선 +- 완료 기능: + - 프라이머 설계 요청이 배포된 백엔드 API로 전송되도록 연동 완료 + - 백엔드 응답을 Result Modal/Canvas에 렌더링 가능한 형태로 변환하여 표시 + - Mock 의존 흐름을 제거하고 실데이터 기반 동작으로 전환 +- 다음 주 계획: + - Vercel 환경에 프론트엔드 배포 및 배포 환경 변수(API Base URL) 점검 + +### Week 9 (26.02.16 ~ 26.02.22) +- 작업 내역: +- AI 활용: +- 완료 기능: +- 다음 주 계획: +- 작업 내역: diff --git a/app/api/v1/primer/design/route.ts b/app/api/v1/primer/design/route.ts deleted file mode 100644 index cb872d5..0000000 --- a/app/api/v1/primer/design/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NextResponse } from 'next/server'; - -// 기획서에 명시된 더미 데이터 반환 -export async function POST(request: Request) { - // 실제 요청 바디를 받아서 로그로 확인 가능 - const body = await request.json(); - console.log("프론트에서 받은 요청:", body); - - // 가짜 응답 데이터 (기획서 PrimerDesignResponse 구조) - const mockResponse = { - genome: { - id: "gene_001", - name: "Test Gene", - sequence: "ATGC...", - length_bp: 1000 - }, - candidates: [ - { - id: "primer_1", - sequence: "ATGCATGC", - start_bp: 100, - end_bp: 120, - strand: "forward", - metrics: { tm_c: 60.5, gc_percent: 50 } - } - ], - meta: { - timestamp: new Date().toISOString() - } - }; - - // 1초 뒤에 응답 (네트워크 지연 시뮬레이션) - await new Promise(resolve => setTimeout(resolve, 1000)); - - return NextResponse.json(mockResponse); -} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index 17c9ff3..fe4f87a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -45,6 +45,115 @@ font-display: swap; } +.poppins-thin { + font-family: "Poppins", sans-serif; + font-weight: 100; + font-style: normal; +} + +.poppins-extralight { + font-family: "Poppins", sans-serif; + font-weight: 200; + font-style: normal; +} + +.poppins-light { + font-family: "Poppins", sans-serif; + font-weight: 300; + font-style: normal; +} + +.poppins-regular { + font-family: "Poppins", sans-serif; + font-weight: 400; + font-style: normal; +} + +.poppins-medium { + font-family: "Poppins", sans-serif; + font-weight: 500; + font-style: normal; +} + +.poppins-semibold { + font-family: "Poppins", sans-serif; + font-weight: 600; + font-style: normal; +} + +.poppins-bold { + font-family: "Poppins", sans-serif; + font-weight: 700; + font-style: normal; +} + +.poppins-extrabold { + font-family: "Poppins", sans-serif; + font-weight: 800; + font-style: normal; +} + +.poppins-black { + font-family: "Poppins", sans-serif; + font-weight: 900; + font-style: normal; +} + +.poppins-thin-italic { + font-family: "Poppins", sans-serif; + font-weight: 100; + font-style: italic; +} + +.poppins-extralight-italic { + font-family: "Poppins", sans-serif; + font-weight: 200; + font-style: italic; +} + +.poppins-light-italic { + font-family: "Poppins", sans-serif; + font-weight: 300; + font-style: italic; +} + +.poppins-regular-italic { + font-family: "Poppins", sans-serif; + font-weight: 400; + font-style: italic; +} + +.poppins-medium-italic { + font-family: "Poppins", sans-serif; + font-weight: 500; + font-style: italic; +} + +.poppins-semibold-italic { + font-family: "Poppins", sans-serif; + font-weight: 600; + font-style: italic; +} + +.poppins-bold-italic { + font-family: "Poppins", sans-serif; + font-weight: 700; + font-style: italic; +} + +.poppins-extrabold-italic { + font-family: "Poppins", sans-serif; + font-weight: 800; + font-style: italic; +} + +.poppins-black-italic { + font-family: "Poppins", sans-serif; + font-weight: 900; + font-style: italic; +} + + :root { --background: #060b16; --foreground: #e2e8f0; diff --git a/app/layout.tsx b/app/layout.tsx index 20c504b..cfc0af4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { JetBrains_Mono, Space_Grotesk } from "next/font/google"; +import { JetBrains_Mono, Poppins, Space_Grotesk } from "next/font/google"; import SiteFooter from "@/components/ui/Footer"; import Providers from "./providers"; import "./globals.css"; @@ -14,6 +14,13 @@ const mono = JetBrains_Mono({ subsets: ["latin"], }); +const poppins = Poppins({ + variable: "--font-poppins", + subsets: ["latin"], + weight: ["400", "600", "700"], + display: "swap", +}); + export const metadata: Metadata = { title: "PrimerFlow - Primer Design Workbench", description: "Dark-mode playground for primer visualization and tuning.", @@ -28,7 +35,7 @@ export default function RootLayout({ - +
{children}
diff --git a/app/page.tsx b/app/page.tsx index 2746ee1..22158e1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,11 +1,17 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import PrimerResultModal from "@/components/PrimerResultModal"; -import { analyzeGenome, type AnalyzeRequest, type AnalyzeResponse } from "@/lib/api/analysisService"; -import { demoGenome } from "@/lib/mocks/demoGenome"; -import type { GenomeData } from "@/lib/types/Genome"; +import { + analyzeGenome, + type AnalyzeRequestInput, +} from "@/services/analysisService"; +import type { GenomeData, PrimerDesignResponseUI } from "@/types"; import { useViewStore } from "@/store/useViewStore"; +import { + getInvalidStep1TemplateSequenceChar, + normalizeStep1TemplateSequence, +} from "../src/lib/parsers/step1TemplateSequence"; import Step1TemplateEssential from "@/components/steps/Step1TemplateEssential"; import Step2PrimerProperties from "@/components/steps/Step2PrimerProperties"; import Step3BindingLocation from "@/components/steps/Step3BindingLocation"; @@ -13,37 +19,37 @@ import Step4SpecificityPreview from "@/components/steps/Step4SpecificityPreview" import WizardFooterNav from "@/components/ui/WizardFooterNav"; import WizardHeader from "@/components/ui/WizardHeader"; -const isGenomeFeature = (feature: any) => - feature && - typeof feature.start === "number" && - typeof feature.end === "number" && - feature.start <= feature.end; - -const isGenomeData = (data: any): data is GenomeData => - data && - typeof data.length === "number" && - Array.isArray(data.tracks) && - data.tracks.every( - (track: any) => - track && - typeof track.id === "string" && - Array.isArray(track.features) && - track.features.every(isGenomeFeature), - ); +const toGenomeDataFromResponse = (response: PrimerDesignResponseUI | null): GenomeData | null => { + if (!response?.genome) return null; + const length = + response.genome.length ?? + response.genome.length_bp ?? + response.genome.sequence?.length ?? + 0; + const tracks = response.genome.tracks ?? []; + return { length, tracks }; +}; -const toGenomeDataFromResponse = ( - response: AnalyzeResponse | null, - fallback: GenomeData, -): GenomeData => { - if (!response) return fallback; - const details = response.details; - if (details && typeof details === "object") { - const candidate = (details as any).genome ?? details; - if (isGenomeData(candidate)) { - return candidate; - } - } - return fallback; +const DEFAULT_PREVIEW_GENOME: GenomeData = { + length: 12000, + tracks: [ + { + id: "preview-primer-candidates", + name: "Primer 후보군", + height: 28, + features: [ + { id: "p-01", start: 400, end: 1200, label: "P-01", color: "#2563eb" }, + { id: "p-02", start: 1800, end: 2600, label: "P-02", color: "#0ea5e9" }, + { id: "p-03", start: 3200, end: 4300, label: "P-03", color: "#22c55e" }, + ], + }, + { + id: "preview-target-region", + name: "Target 구간", + height: 18, + features: [{ id: "amplicon", start: 1500, end: 5200, label: "Amplicon", color: "#f97316" }], + }, + ], }; export default function Home() { @@ -61,8 +67,8 @@ export default function Home() { const handleZoomOut = () => setViewState({ ...viewState, scale: clampScale(viewState.scale / zoomStep) }); - // 더미 genome 데이터 - const genome = demoGenome; + const sequenceInputRef = useRef(""); + const [resultGenome, setResultGenome] = useState(null); const steps = [ { id: 1, label: "Template & Essential" }, @@ -73,31 +79,107 @@ export default function Home() { const [step, setStep] = useState<1 | 2 | 3 | 4>(1); const [isLoading, setIsLoading] = useState(false); - const [apiResult, setApiResult] = useState(null); + const [apiResult, setApiResult] = useState(null); const [errorMessage, setErrorMessage] = useState(null); + const [step1WarningMessage, setStep1WarningMessage] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); - const [resultGenome, setResultGenome] = useState(null); const totalSteps = steps.length; const handleStepChange = (next: number) => { const clamped = Math.min(Math.max(next, 1), totalSteps) as 1 | 2 | 3 | 4; setStep(clamped); }; - const handleNext = () => handleStepChange(step + 1); + const clearStep1Warning = () => { + if (step1WarningMessage) { + setStep1WarningMessage(null); + } + }; + + const reportStep1ValidationMessage = ( + mode: "step-transition" | "generate", + message: string | null, + ) => { + // Step 전환 검증은 Step1 인라인 경고를 사용하고, + // Generate 직전 검증은 하단 error banner를 사용해 중복 노출을 피한다. + if (mode === "step-transition") { + setStep1WarningMessage(message); + return; + } + setErrorMessage(message); + }; + + const validateStep1Sequence = (mode: "step-transition" | "generate") => { + const rawInputSequence = sequenceInputRef.current; + const normalizedSequence = normalizeStep1TemplateSequence(rawInputSequence); + const invalidChar = getInvalidStep1TemplateSequenceChar(rawInputSequence); + if (!invalidChar) { + if (mode === "generate" && rawInputSequence.trim().length > 0 && !normalizedSequence) { + // Generate 직전 검증: 입력이 있었는데 정규화 후 빈 문자열이면 요청 중단. + const warningMessage = + "전송할 수 있는 유효한 염기서열이 없습니다. A, T, G, C 문자만 입력해 주세요."; + reportStep1ValidationMessage(mode, warningMessage); + return { isValid: false, normalizedSequence }; + } + + // 단계 이동 검증: Step1 경고를 초기화하고 다음 동작 진행. + reportStep1ValidationMessage(mode, null); + return { isValid: true, normalizedSequence }; + } + + const warningMessage = `대소문자 구분 없이 A, T, G, C만 입력 가능합니다. 잘못된 문자를 제거해 주세요.`; + reportStep1ValidationMessage(mode, warningMessage); + return { isValid: false, normalizedSequence }; + }; + const handleNext = () => { + if (step === 1 && !validateStep1Sequence("step-transition").isValid) { + return; + } + + handleStepChange(step + 1); + }; const handleBack = () => handleStepChange(step - 1); const isLastStep = step === totalSteps; - const trackCount = genome.tracks.length; - const featureCount = genome.tracks.reduce( + const previewGenome = DEFAULT_PREVIEW_GENOME; + const trackCount = previewGenome.tracks.length; + const featureCount = previewGenome.tracks.reduce( (count, track) => count + track.features.length, 0, ); const handleGenerate = async () => { - const payload: AnalyzeRequest = { - target_sequence: "ATGCGTACGTAGCTAGCTAGCTAGCTAATGCGTACGTAGCTAGCTAGCTAGCTA", + const validation = validateStep1Sequence("generate"); + if (!validation.isValid) { + return; + } + + const targetSeq = validation.normalizedSequence?.trim() ?? ""; + if (!targetSeq) { + setErrorMessage("템플릿 시퀀스를 입력해 주세요."); + return; + } + + const payload: AnalyzeRequestInput = { + target_sequence: targetSeq, species: "Homo sapiens", analysis_type: "primer_generation", - notes: "UI mock request while backend is offline", + product_size_min: 100, + product_size_max: 300, + tm_min: 57, + tm_opt: 60, + tm_max: 63, + gc_content_min: 40, + gc_content_max: 60, + max_tm_difference: 1, + gc_clamp: true, + max_poly_x: 5, + concentration: 50, + check_enabled: true, + splice_variant_handling: false, + snp_handling: false, + mispriming_library: false, + end_mismatch_region_size: 5, + end_mismatch_min_mismatch: 1, + search_start: 1, }; setIsLoading(true); @@ -106,8 +188,8 @@ export default function Home() { try { const result = await analyzeGenome(payload); setApiResult(result); - const genomeFromApi = toGenomeDataFromResponse(result, demoGenome); - setResultGenome(genomeFromApi); + const genomeFromApi = toGenomeDataFromResponse(result); + setResultGenome(genomeFromApi ?? null); setIsModalOpen(true); } catch (error) { const message = @@ -118,7 +200,6 @@ export default function Home() { setIsModalOpen(false); // Surface the error for visibility during development. console.error("Generate Primers failed", error); - alert(message); } finally { setIsLoading(false); } @@ -134,7 +215,7 @@ export default function Home() {
- {step === 1 && } + {step === 1 && ( + + )} {step === 2 && } @@ -150,7 +237,7 @@ export default function Home() { {step === 4 && ( void; } @@ -15,30 +19,26 @@ interface PrimerResultModalProps { const MIN_SCALE = 0.1; const MAX_SCALE = 50; const ZOOM_STEP = 1.2; +const CANVAS_PADDING_X = 20; -const createInitialViewState = (): GenomeCanvasViewState => ({ - scale: 1, - offsetX: 0, - offsetY: 0, -}); +interface PrimerResultCanvasPanelProps { + apiResult: PrimerDesignResponseUI | null; + genome: GenomeData; + onClose: () => void; + initialViewState: GenomeCanvasViewState; + canvasShellRef: RefObject; +} const clampScaleValue = (scale: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale)); -export default function PrimerResultModal({ - isOpen, +function PrimerResultCanvasPanel({ apiResult, genome, onClose, -}: PrimerResultModalProps) { - const [viewState, setViewState] = useState(createInitialViewState()); - - useEffect(() => { - if (isOpen) { - setViewState(createInitialViewState()); - } - }, [isOpen, genome]); - - if (!isOpen || !genome) return null; + initialViewState, + canvasShellRef, +}: PrimerResultCanvasPanelProps) { + const [viewState, setViewState] = useState(initialViewState); const handleZoomIn = () => setViewState((prev) => ({ @@ -52,7 +52,7 @@ export default function PrimerResultModal({ scale: clampScaleValue(prev.scale / ZOOM_STEP), })); - const handleReset = () => setViewState(createInitialViewState()); + const handleReset = () => setViewState(initialViewState); return (
@@ -108,7 +108,10 @@ export default function PrimerResultModal({
-
+
{ + const tracks = Array.isArray(data.tracks) ? data.tracks : []; + + tracks.forEach((track) => { const trackHeight = (track.height ?? 18) * layoutScale; ctx.fillStyle = "#a5b4d8"; ctx.font = `${12 * layoutScale}px ui-sans-serif, system-ui`; - ctx.fillText(track.name ?? track.id, paddingX, y - 10 * layoutScale); + const trackLabel = track.name ?? track.id ?? "Track"; + ctx.fillText(trackLabel, paddingX, y - 10 * layoutScale); ctx.strokeStyle = "#23324a"; ctx.beginPath(); @@ -187,9 +193,15 @@ export default function PrimerResultModal({ ctx.lineTo(viewport.width - paddingX, y + trackHeight / 2); ctx.stroke(); - track.features.forEach((feature) => { - const x = bpToX(feature.start); - const width = toScreenWidth(feature.start, feature.end); + const features = Array.isArray(track.features) + ? track.features + : []; + + features.forEach((feature) => { + const start = Number(feature.start ?? feature.start_bp ?? 0); + const end = Number(feature.end ?? feature.end_bp ?? start); + const x = bpToX(start); + const width = toScreenWidth(start, end); const radius = Math.min(6, trackHeight / 2); ctx.fillStyle = feature.color ?? "#38bdf8"; @@ -214,11 +226,17 @@ export default function PrimerResultModal({ drawRoundedRect(ctx, x, y, width, trackHeight, radius); ctx.fill(); - if (feature.label) { + const label = + feature.label || + feature.id || + feature.name || + ""; + + if (label) { const labelPaddingX = 6 * layoutScale; const labelPaddingY = 3 * layoutScale; ctx.font = `600 ${11 * layoutScale}px ui-sans-serif, system-ui`; - const metrics = ctx.measureText(feature.label); + const metrics = ctx.measureText(label); const labelWidth = metrics.width + labelPaddingX * 2; const labelHeight = 16 * layoutScale + labelPaddingY; const labelX = x + 6 * layoutScale; @@ -238,11 +256,7 @@ export default function PrimerResultModal({ ctx.stroke(); ctx.fillStyle = "#e2e8f0"; - ctx.fillText( - feature.label, - labelX + labelPaddingX, - labelY + 12 * layoutScale, - ); + ctx.fillText(label, labelX + labelPaddingX, labelY + 12 * layoutScale); } }); @@ -256,3 +270,105 @@ export default function PrimerResultModal({
); } + +const createInitialViewState = (): GenomeCanvasViewState => ({ + scale: 1, + offsetX: 0, + offsetY: 0, +}); + +export default function PrimerResultModal({ + isOpen, + apiResult, + genome, + onClose, +}: PrimerResultModalProps) { + const canvasShellRef = useRef(null); + const [canvasWidth, setCanvasWidth] = useState(() => + typeof window === "undefined" ? 960 : Math.max(320, Math.floor(window.innerWidth * 0.72)), + ); + + useEffect(() => { + if (!isOpen) return; + + const { body, documentElement } = document; + const previousBodyOverflow = body.style.overflow; + const previousBodyPaddingRight = body.style.paddingRight; + const previousBodyOverscrollBehavior = body.style.overscrollBehavior; + const previousHtmlOverflow = documentElement.style.overflow; + const previousHtmlOverscrollBehavior = documentElement.style.overscrollBehavior; + + const scrollbarWidth = window.innerWidth - documentElement.clientWidth; + body.style.overflow = "hidden"; + body.style.overscrollBehavior = "none"; + documentElement.style.overflow = "hidden"; + documentElement.style.overscrollBehavior = "none"; + + if (scrollbarWidth > 0) { + body.style.paddingRight = `${scrollbarWidth}px`; + } + + return () => { + body.style.overflow = previousBodyOverflow; + body.style.paddingRight = previousBodyPaddingRight; + body.style.overscrollBehavior = previousBodyOverscrollBehavior; + documentElement.style.overflow = previousHtmlOverflow; + documentElement.style.overscrollBehavior = previousHtmlOverscrollBehavior; + }; + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + + const element = canvasShellRef.current; + if (!element) return; + + const updateCanvasWidth = () => { + setCanvasWidth(element.clientWidth); + }; + + updateCanvasWidth(); + + const resizeObserver = new ResizeObserver(() => { + updateCanvasWidth(); + }); + + resizeObserver.observe(element); + + return () => { + resizeObserver.disconnect(); + }; + }, [isOpen]); + + const fittedInitialViewState = useMemo(() => { + if (!genome) return createInitialViewState(); + + return createFocusedPrimerViewState({ + genome, + viewportWidth: canvasWidth, + minScale: MIN_SCALE, + maxScale: MAX_SCALE, + paddingX: CANVAS_PADDING_X, + }); + }, [canvasWidth, genome]); + + if (!isOpen || !genome) return null; + + const featureCount = genome.tracks.reduce( + (count, track) => count + (Array.isArray(track.features) ? track.features.length : 0), + 0, + ); + + const viewStateKey = `${genome.length}-${genome.tracks.length}-${featureCount}-${Math.round(canvasWidth)}`; + + return ( + + ); +} diff --git a/components/canvas/GenomeCanvas.tsx b/components/canvas/GenomeCanvas.tsx index 263b275..1d7d0ce 100644 --- a/components/canvas/GenomeCanvas.tsx +++ b/components/canvas/GenomeCanvas.tsx @@ -6,7 +6,7 @@ import type { GenomeCanvasProps, GenomeCanvasRenderState, GenomeCanvasViewState, -} from "@/lib/types/Genome"; +} from "@/types"; const DEFAULT_VIEW_STATE: GenomeCanvasViewState = { scale: 1, diff --git a/components/steps/Step1TemplateEssential.tsx b/components/steps/Step1TemplateEssential.tsx index 97d7967..3930178 100644 --- a/components/steps/Step1TemplateEssential.tsx +++ b/components/steps/Step1TemplateEssential.tsx @@ -1,33 +1,204 @@ "use client"; -import { type ChangeEvent, useMemo, useRef, useState } from "react"; +import { + type ClipboardEvent, + type ChangeEvent, + type FormEvent, + type MutableRefObject, + useEffect, + useRef, + useState, +} from "react"; import { SlidersHorizontal } from "lucide-react"; -import TextareaAutosize from "react-textarea-autosize"; +import { + getInvalidStep1TemplateSequenceChars, + sanitizeStep1TemplateSequenceInput, +} from "../../src/lib/parsers/step1TemplateSequence"; -export default function Step1TemplateEssential() { +type Step1TemplateEssentialProps = { + sequenceRef: MutableRefObject; + validationMessage?: string | null; + onSequenceChange?: (value: string) => void; +}; + +const countAlphabeticChars = (value: string) => { + let count = 0; + + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + const isUpperCase = code >= 65 && code <= 90; + const isLowerCase = code >= 97 && code <= 122; + + if (isUpperCase || isLowerCase) { + count += 1; + } + } + + return count; +}; + +const COUNT_DELAY_SMALL_MS = 80; +const COUNT_DELAY_LARGE_MS = 240; +const MAX_EDITOR_CHARS = 30_000; +const PREVIEW_HEAD_CHARS = 1_200; +const PREVIEW_TAIL_CHARS = 1_200; + +const getPreviewValue = (value: string) => { + if (value.length <= MAX_EDITOR_CHARS) return value; + + const head = value.slice(0, PREVIEW_HEAD_CHARS); + const tail = value.slice(-PREVIEW_TAIL_CHARS); + + return `${head}\n\n...[large sequence preview: ${value.length.toLocaleString()} chars total]...\n\n${tail}`; +}; + +export default function Step1TemplateEssential({ + sequenceRef, + validationMessage, + onSequenceChange, +}: Step1TemplateEssentialProps) { const fileInputRef = useRef(null); const textareaRef = useRef(null); - const [sequenceInput, setSequenceInput] = useState(""); + const countTimeoutRef = useRef(null); + const invalidRemovalResolverRef = useRef<((approved: boolean) => void) | null>(null); + const [invalidRemovalDialogMessage, setInvalidRemovalDialogMessage] = useState( + null, + ); + const [isLargeSequenceMode, setIsLargeSequenceMode] = useState( + sequenceRef.current.length > MAX_EDITOR_CHARS, + ); + const [basePairCount, setBasePairCount] = useState(() => + countAlphabeticChars(sequenceRef.current), + ); + + const clearPendingCountTimer = () => { + if (countTimeoutRef.current !== null) { + window.clearTimeout(countTimeoutRef.current); + countTimeoutRef.current = null; + } + }; - const basePairCount = useMemo( - () => sequenceInput.replace(/[^A-Za-z]/g, "").length, - [sequenceInput], + const scheduleCount = (value: string, isLargeSequence: boolean) => { + clearPendingCountTimer(); + const delay = isLargeSequence ? COUNT_DELAY_LARGE_MS : COUNT_DELAY_SMALL_MS; + + countTimeoutRef.current = window.setTimeout(() => { + setBasePairCount(countAlphabeticChars(value)); + countTimeoutRef.current = null; + }, delay); + }; + + const updateSequence = (value: string, countMode: "defer" | "immediate" = "defer") => { + sequenceRef.current = value; + onSequenceChange?.(value); + const nextIsLargeSequence = value.length > MAX_EDITOR_CHARS; + setIsLargeSequenceMode(nextIsLargeSequence); + + if (textareaRef.current) { + const nextEditorValue = nextIsLargeSequence ? getPreviewValue(value) : value; + if (textareaRef.current.value !== nextEditorValue) { + textareaRef.current.value = nextEditorValue; + } + } + + if (countMode === "immediate") { + clearPendingCountTimer(); + setBasePairCount(countAlphabeticChars(value)); + return; + } + scheduleCount(value, nextIsLargeSequence); + }; + + useEffect( + () => () => { + clearPendingCountTimer(); + if (invalidRemovalResolverRef.current) { + invalidRemovalResolverRef.current(false); + invalidRemovalResolverRef.current = null; + } + }, + [], ); const focusTextarea = () => textareaRef.current?.focus(); - const appendSequence = (next: string) => { - const sanitized = next.trim(); + const requestInvalidRemovalConsent = (message: string) => + new Promise((resolve) => { + if (invalidRemovalResolverRef.current) { + invalidRemovalResolverRef.current(false); + } + invalidRemovalResolverRef.current = resolve; + setInvalidRemovalDialogMessage(message); + }); + + const resolveInvalidRemovalConsent = (approved: boolean) => { + if (invalidRemovalResolverRef.current) { + invalidRemovalResolverRef.current(approved); + invalidRemovalResolverRef.current = null; + } + setInvalidRemovalDialogMessage(null); + }; + + const confirmInvalidRemoval = async (rawSequence: string) => { + const invalidChars = getInvalidStep1TemplateSequenceChars(rawSequence); + if (invalidChars.length === 0) return true; + + const previewInvalidChars = invalidChars.slice(0, 8).join(", "); + const extraKinds = invalidChars.length > 8 ? ` 외 ${invalidChars.length - 8}종` : ""; + return requestInvalidRemovalConsent( + `붙여넣으려는 데이터에 A, T, G, C 이외 문자가 포함되어 있습니다 (${previewInvalidChars}${extraKinds}). 해당 문자를 제거하고 계속할까요?`, + ); + }; + + const appendSequence = async ( + next: string, + options: { requireInvalidRemovalConsent?: boolean } = {}, + ) => { + if (options.requireInvalidRemovalConsent && !(await confirmInvalidRemoval(next))) { + focusTextarea(); + return; + } - if (!sanitized) { + const sanitizedChunk = sanitizeStep1TemplateSequenceInput(next); + if (!sanitizedChunk) { focusTextarea(); return; } - setSequenceInput((prev) => (prev ? `${prev}\n${sanitized}` : sanitized)); + const currentValue = sequenceRef.current; + const appendedValue = `${currentValue}${sanitizedChunk}`; + + updateSequence(appendedValue, "immediate"); focusTextarea(); }; + const insertChunkAtSelection = ( + textarea: HTMLTextAreaElement, + rawChunk: string, + options: { sanitize: boolean }, + ) => { + const nextChunk = options.sanitize + ? sanitizeStep1TemplateSequenceInput(rawChunk) + : rawChunk; + const currentValue = sequenceRef.current; + const selectionStart = textarea.selectionStart ?? currentValue.length; + const selectionEnd = textarea.selectionEnd ?? currentValue.length; + const nextValue = + currentValue.slice(0, selectionStart) + + nextChunk + + currentValue.slice(selectionEnd); + + if (nextValue !== currentValue) { + updateSequence(nextValue, "immediate"); + } + + if (textareaRef.current && nextValue.length <= MAX_EDITOR_CHARS) { + const cursor = selectionStart + nextChunk.length; + textareaRef.current.selectionStart = cursor; + textareaRef.current.selectionEnd = cursor; + } + }; + const handleUploadClick = () => fileInputRef.current?.click(); const handleFileChange = async (event: ChangeEvent) => { @@ -37,7 +208,7 @@ export default function Step1TemplateEssential() { try { const text = await file.text(); - appendSequence(text); + await appendSequence(text, { requireInvalidRemovalConsent: true }); } catch (error) { console.error("Failed to read FASTA file", error); } finally { @@ -53,26 +224,68 @@ export default function Step1TemplateEssential() { try { const text = await navigator.clipboard.readText(); - appendSequence(text); + await appendSequence(text, { requireInvalidRemovalConsent: true }); } catch (error) { console.error("Failed to read from clipboard", error); } }; + const handleTextareaPaste = async (event: ClipboardEvent) => { + if (isLargeSequenceMode || event.currentTarget.readOnly) { + event.preventDefault(); + return; + } + + const textarea = event.currentTarget; + const pastedText = event.clipboardData.getData("text"); + if (!pastedText) return; + event.preventDefault(); + const shouldSanitize = await confirmInvalidRemoval(pastedText); + insertChunkAtSelection(textarea, pastedText, { sanitize: shouldSanitize }); + focusTextarea(); + }; + + const handleTextareaBeforeInput = (event: FormEvent) => { + if (isLargeSequenceMode || event.currentTarget.readOnly) { + event.preventDefault(); + return; + } + + const nativeEvent = event.nativeEvent as InputEvent; + const inputType = nativeEvent.inputType ?? ""; + if (inputType === "insertFromPaste") return; + if (!inputType.startsWith("insert")) return; + + const rawChunk = nativeEvent.data ?? ""; + if (!rawChunk) return; + + const sanitizedChunk = sanitizeStep1TemplateSequenceInput(rawChunk); + if (sanitizedChunk === rawChunk) return; + + event.preventDefault(); + insertChunkAtSelection(event.currentTarget, rawChunk, { sanitize: true }); + }; + + const handleTextareaChange = (event: ChangeEvent) => { + if (isLargeSequenceMode || event.currentTarget.readOnly) return; + updateSequence(sanitizeStep1TemplateSequenceInput(event.currentTarget.value)); + }; + const handleCleanClick = () => { - setSequenceInput(""); + updateSequence("", "immediate"); focusTextarea(); }; return ( -
-
- Step 1. 템플릿 시퀀스와 기본 설정을 입력하세요. -
+ <> +
+
+ Step 1. 템플릿 시퀀스와 기본 설정을 입력하세요. +
-
+

PCR Template Sequence

@@ -89,23 +302,39 @@ export default function Step1TemplateEssential() { {basePairCount} bp
- Seq1\nATGCGT..."} spellCheck={false} - minRows={10} - maxRows={20} - value={sequenceInput} - onChange={(event) => setSequenceInput(event.target.value)} + readOnly={isLargeSequenceMode} + defaultValue={getPreviewValue(sequenceRef.current)} + onChange={handleTextareaChange} + onBeforeInput={handleTextareaBeforeInput} + onPaste={handleTextareaPaste} />
+ {isLargeSequenceMode && ( +

+ Large sequence mode is enabled for stability. The full sequence is + kept in memory and will be used for generation, but the editor + shows only a preview. +

+ )} +

+ A, T, G, C 이외 문자는 입력 시 자동으로 제거됩니다. +
+ Paste 버튼, Ctrl+V, Upload as file에서는 제거 전에 확인을 요청합니다. +

+ {validationMessage && ( +

{validationMessage}

+ )}
@@ -114,7 +343,7 @@ export default function Step1TemplateEssential() { className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-bold text-slate-300 hover:text-white hover:bg-slate-800 transition-colors border border-transparent hover:border-slate-700" onClick={handleUploadClick} > - Upload FASTA + Upload as file
-
+
+ {invalidRemovalDialogMessage && ( +
{ + if (event.target === event.currentTarget) { + resolveInvalidRemovalConsent(false); + } + }} + > +
+

문자 제거 확인

+

+ {invalidRemovalDialogMessage} +

+
+ + +
+
+
+ )} + ); } diff --git a/components/steps/Step4SpecificityPreview.tsx b/components/steps/Step4SpecificityPreview.tsx index 4bad666..590ee7f 100644 --- a/components/steps/Step4SpecificityPreview.tsx +++ b/components/steps/Step4SpecificityPreview.tsx @@ -1,9 +1,17 @@ "use client"; import GenomeCanvas from "@/components/canvas/GenomeCanvas"; +import { + getVisibleRange, +} from "@/lib/algorithms/visibleRange"; import { createBpScale } from "@/lib/math/coords"; -import type { GenomeCanvasViewState, GenomeData } from "@/lib/types/Genome"; +import type { + GenomeCanvasRenderState, + GenomeCanvasViewState, + GenomeData, +} from "@/types"; import { ShieldCheck } from "lucide-react"; +import { useCallback, useMemo, useRef } from "react"; type Step4SpecificityPreviewProps = { genome: GenomeData; @@ -32,6 +40,15 @@ const drawRoundedRect = ( ctx.closePath(); }; +type TrackLayoutMemo = { + baseTrackHeightsRef: number[]; + layoutScale: number; + trackGap: number; + trackHeights: number[]; + prefixSums: number[]; + totalTracksHeight: number; +}; + export default function Step4SpecificityPreview({ genome, viewState, @@ -40,6 +57,219 @@ export default function Step4SpecificityPreview({ onZoomOut, onResetView, }: Step4SpecificityPreviewProps) { + const tracks = useMemo( + () => (Array.isArray(genome.tracks) ? genome.tracks : []), + [genome.tracks], + ); + const baseTrackHeights = useMemo( + () => tracks.map((track) => track.height ?? 18), + [tracks], + ); + const trackLayoutMemoRef = useRef(null); + + const getTrackLayoutMemo = useCallback( + (layoutScale: number, trackGap: number) => { + const cached = trackLayoutMemoRef.current; + if ( + cached && + cached.baseTrackHeightsRef === baseTrackHeights && + cached.layoutScale === layoutScale && + cached.trackGap === trackGap + ) { + return cached; + } + + const trackHeights = new Array(baseTrackHeights.length); + const prefixSums = new Array(baseTrackHeights.length + 1); + prefixSums[0] = 0; + const lastTrackIndex = baseTrackHeights.length - 1; + + for (let index = 0; index < baseTrackHeights.length; index += 1) { + const trackHeight = baseTrackHeights[index] * layoutScale; + trackHeights[index] = trackHeight; + const gapAfterTrack = index === lastTrackIndex ? 0 : trackGap; + prefixSums[index + 1] = prefixSums[index] + trackHeight + gapAfterTrack; + } + + const memoized: TrackLayoutMemo = { + baseTrackHeightsRef: baseTrackHeights, + layoutScale, + trackGap, + trackHeights, + prefixSums, + totalTracksHeight: prefixSums[prefixSums.length - 1] ?? 0, + }; + trackLayoutMemoRef.current = memoized; + + return memoized; + }, + [baseTrackHeights], + ); + + const handleDraw = useCallback( + ( + ctx: CanvasRenderingContext2D, + _canvas: HTMLCanvasElement, + renderState: GenomeCanvasRenderState, + ) => { + const { data, viewport, viewState: canvasViewState } = renderState; + if (!data) return; + + const dpr = viewport.devicePixelRatio || 1; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + ctx.fillStyle = "#0c1222"; + ctx.fillRect(0, 0, viewport.width, viewport.height); + + const paddingX = 20; + const layoutScale = Math.min(1.4, Math.max(1, viewport.height / 400)); + + const headerY = 28 * layoutScale; + const trackStartY = 64 * layoutScale; + const trackGap = 28 * layoutScale; + + const bpScale = createBpScale( + data.length, + viewport.width - paddingX * 2, + 0, + ); + + const toScreenX = (bp: number) => + paddingX + + canvasViewState.offsetX + + bpScale.bpToX(bp) * canvasViewState.scale; + + const toScreenWidth = (start: number, end: number) => { + const rawWidth = bpScale.spanToWidth(start, end, 0) * canvasViewState.scale; + return Math.max(2, rawWidth); + }; + + ctx.fillStyle = "#e2e8f0"; + ctx.font = `600 ${14 * layoutScale}px ui-sans-serif, system-ui`; + ctx.fillText(`Genome length`, paddingX, headerY - 6 * layoutScale); + + ctx.fillStyle = "#9fb3d4"; + ctx.font = `${12 * layoutScale}px ui-sans-serif, system-ui`; + ctx.fillText( + `${data.length.toLocaleString()} bp`, + paddingX, + headerY + 10 * layoutScale, + ); + + ctx.strokeStyle = "#1f2b3f"; + ctx.lineWidth = 1; + for (let i = 0; i <= 10; i += 1) { + const x = paddingX + i * ((viewport.width - paddingX * 2) / 10); + ctx.beginPath(); + ctx.moveTo(x, trackStartY - 16 * layoutScale); + ctx.lineTo(x, viewport.height - 20); + ctx.stroke(); + } + + if (tracks.length === 0) return; + + const { trackHeights, prefixSums, totalTracksHeight } = getTrackLayoutMemo( + layoutScale, + trackGap, + ); + const trackLayerTop = trackStartY + canvasViewState.offsetY; + const trackLayerBottom = trackLayerTop + totalTracksHeight; + + if (trackLayerBottom <= 0 || trackLayerTop >= viewport.height) return; + + const visibleViewportHeight = Math.max( + 0, + viewport.height - Math.max(trackLayerTop, 0), + ); + const visibleScrollTop = Math.max(0, -trackLayerTop); + const { startIndex, endIndex } = getVisibleRange( + prefixSums, + visibleViewportHeight, + 1, + visibleScrollTop, + ); + + if (endIndex < startIndex) return; + + for (let trackIndex = startIndex; trackIndex <= endIndex; trackIndex += 1) { + const track = tracks[trackIndex]; + if (!track) continue; + + const trackHeight = trackHeights[trackIndex] ?? 0; + const y = trackLayerTop + prefixSums[trackIndex]; + + ctx.fillStyle = "#a5b4d8"; + ctx.font = `${12 * layoutScale}px ui-sans-serif, system-ui`; + const trackLabel = track.name ?? track.id ?? "Track"; + ctx.fillText(trackLabel, paddingX, y - 10 * layoutScale); + + ctx.strokeStyle = "#23324a"; + ctx.beginPath(); + ctx.moveTo(paddingX, y + trackHeight / 2); + ctx.lineTo( + viewport.width - paddingX, + y + trackHeight / 2, + ); + ctx.stroke(); + + const features = Array.isArray(track.features) ? track.features : []; + + features.forEach((feature) => { + const start = Number(feature.start ?? feature.start_bp ?? 0); + const end = Number(feature.end ?? feature.end_bp ?? start); + const x = toScreenX(start); + const width = toScreenWidth(start, end); + const radius = Math.min(6, trackHeight / 2); + + ctx.fillStyle = feature.color ?? "#38bdf8"; + drawRoundedRect( + ctx, + x, + y, + width, + trackHeight, + radius, + ); + ctx.fill(); + + const label = feature.label || feature.id || feature.name || ""; + + if (label) { + const labelPaddingX = 6 * layoutScale; + const labelPaddingY = 3 * layoutScale; + ctx.font = `600 ${11 * layoutScale}px ui-sans-serif, system-ui`; + const metrics = ctx.measureText(label); + const labelWidth = metrics.width + labelPaddingX * 2; + const labelHeight = 16 * layoutScale + labelPaddingY; + const labelX = x + 6 * layoutScale; + const labelY = y + trackHeight + 6 * layoutScale; + + ctx.fillStyle = "rgba(15,23,42,0.9)"; + ctx.strokeStyle = "#1f2b3f"; + drawRoundedRect( + ctx, + labelX, + labelY, + labelWidth, + labelHeight, + 6 * layoutScale, + ); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = "#e2e8f0"; + ctx.fillText( + label, + labelX + labelPaddingX, + labelY + 12 * layoutScale, + ); + } + }); + } + }, + [getTrackLayoutMemo, tracks], + ); + return (
@@ -164,183 +394,11 @@ export default function Step4SpecificityPreview({ onViewStateChange={onViewStateChange} className="w-full" style={{ height: "450px" }} - onDraw={(ctx, _canvas, renderState) => { - const { data, viewport, viewState } = renderState; - if (!data) return; - - const dpr = viewport.devicePixelRatio || 1; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - - ctx.fillStyle = "#0c1222"; - ctx.fillRect(0, 0, viewport.width, viewport.height); - - const paddingX = 20; - const layoutScale = Math.min( - 1.4, - Math.max(1, viewport.height / 400), - ); - - const headerY = 28 * layoutScale; - const trackStartY = 64 * layoutScale; - const trackGap = 28 * layoutScale; - - const bpScale = createBpScale( - data.length, - viewport.width - paddingX * 2, - 0, - ); - - const toScreenX = (bp: number) => - paddingX + - viewState.offsetX + - bpScale.bpToX(bp) * viewState.scale; - - const toScreenWidth = (start: number, end: number) => { - const rawWidth = - bpScale.spanToWidth(start, end, 0) * - viewState.scale; - return Math.max(2, rawWidth); - }; - - ctx.fillStyle = "#e2e8f0"; - ctx.font = `600 ${14 * layoutScale}px ui-sans-serif, system-ui`; - ctx.fillText(`Genome length`, paddingX, headerY - 6 * layoutScale); - - ctx.fillStyle = "#9fb3d4"; - ctx.font = `${12 * layoutScale}px ui-sans-serif, system-ui`; - ctx.fillText( - `${data.length.toLocaleString()} bp`, - paddingX, - headerY + 10 * layoutScale, - ); - - ctx.strokeStyle = "#1f2b3f"; - ctx.lineWidth = 1; - for (let i = 0; i <= 10; i += 1) { - const x = - paddingX + - i * ((viewport.width - paddingX * 2) / 10); - ctx.beginPath(); - ctx.moveTo(x, trackStartY - 16 * layoutScale); - ctx.lineTo(x, viewport.height - 20); - ctx.stroke(); - } - - let y = trackStartY + viewState.offsetY; - - data.tracks.forEach((track) => { - const trackHeight = (track.height ?? 18) * layoutScale; - - ctx.fillStyle = "#a5b4d8"; - ctx.font = `${12 * layoutScale}px ui-sans-serif, system-ui`; - ctx.fillText( - track.name ?? track.id, - paddingX, - y - 10 * layoutScale, - ); - - ctx.strokeStyle = "#23324a"; - ctx.beginPath(); - ctx.moveTo(paddingX, y + trackHeight / 2); - ctx.lineTo( - viewport.width - paddingX, - y + trackHeight / 2, - ); - ctx.stroke(); - - track.features.forEach((feature) => { - const x = toScreenX(feature.start); - const width = toScreenWidth( - feature.start, - feature.end, - ); - const radius = Math.min(6, trackHeight / 2); - - ctx.fillStyle = feature.color ?? "#38bdf8"; - drawRoundedRect( - ctx, - x, - y, - width, - trackHeight, - radius, - ); - ctx.fill(); - - if (feature.label) { - const labelPaddingX = 6 * layoutScale; - const labelPaddingY = 3 * layoutScale; - ctx.font = `600 ${11 * layoutScale}px ui-sans-serif, system-ui`; - const metrics = ctx.measureText(feature.label); - const labelWidth = - metrics.width + labelPaddingX * 2; - const labelHeight = - 16 * layoutScale + labelPaddingY; - const labelX = x + 6 * layoutScale; - const labelY = y + trackHeight + 6 * layoutScale; - - ctx.fillStyle = "rgba(15,23,42,0.9)"; - ctx.strokeStyle = "#1f2b3f"; - drawRoundedRect( - ctx, - labelX, - labelY, - labelWidth, - labelHeight, - 6 * layoutScale, - ); - ctx.fill(); - ctx.stroke(); - - ctx.fillStyle = "#e2e8f0"; - ctx.fillText( - feature.label, - labelX + labelPaddingX, - labelY + 12 * layoutScale, - ); - } - }); - - y += trackHeight + trackGap; - }); - }} + onDraw={handleDraw} />
- -
-
-
-

- Quality notes -

-

- Quick health check -

-
- - Stable - -
- -
); } diff --git a/components/ui/WizardHeader.tsx b/components/ui/WizardHeader.tsx index 6965a05..15d3c5c 100644 --- a/components/ui/WizardHeader.tsx +++ b/components/ui/WizardHeader.tsx @@ -1,5 +1,7 @@ "use client"; +import Image from "next/image"; + type WizardStep = { id: number; label: string; @@ -24,24 +26,30 @@ export default function WizardHeader({ }: WizardHeaderProps) { return (
-
+
-
- PF +
+ PrimerFlow
- - Primerflow Lab - -

- Primer Design Input -

-

- demodesign 흐름을 따라 입력 -> 특성 -> 위치 -> 특이성/미리보기 순으로 진행합니다. -

+
+

+ Primer Designer +

+ + by SeqLab + +
@@ -66,17 +74,17 @@ export default function WizardHeader({ const isUnlocked = item.id <= step; const circle = status === "active" - ? "bg-blue-600 border-blue-400 text-white shadow-lg shadow-blue-900/40" + ? "bg-blue-600 text-white shadow-lg shadow-blue-900/40" : status === "done" - ? "bg-blue-500 border-blue-500 text-white" - : "bg-slate-900 border-slate-800 text-slate-500"; + ? "bg-blue-500 text-white" + : "bg-slate-900 text-slate-500"; return (