{exp.domain}
+{exp.sample}
+{exp.condition}
+ {exp.success_rate}% +{result?.priority ?? "Needs Improvement"}
+diff --git a/.github/workflows/biodocklab-ci.yml b/.github/workflows/biodocklab-ci.yml
new file mode 100644
index 0000000..6a40a5b
--- /dev/null
+++ b/.github/workflows/biodocklab-ci.yml
@@ -0,0 +1,27 @@
+name: BioDockLab CI
+
+on:
+ push:
+ branches:
+ - main
+ - feature/biodocklab-v2-fullstack-start
+ pull_request:
+
+jobs:
+ validate-node:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Validate bio experiment data
+ run: node scripts/validate_bio_data.js
+
+ - name: Generate sample report
+ run: node scripts/generate_report_node.js
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..a99cdf2
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,9 @@
+FROM node:22-alpine
+
+WORKDIR /app
+
+COPY . .
+
+EXPOSE 5173
+
+CMD ["node", "scripts/biodocklab_node_server.js"]
\ No newline at end of file
diff --git a/ai/experiment_analyzer.py b/ai/experiment_analyzer.py
new file mode 100644
index 0000000..844fec0
--- /dev/null
+++ b/ai/experiment_analyzer.py
@@ -0,0 +1,42 @@
+import json
+from pathlib import Path
+
+
+DATA_PATH = Path("../data/sample/bio_experiments.json")
+
+
+def load_experiments():
+ with DATA_PATH.open("r", encoding="utf-8") as f:
+ return json.load(f)
+
+
+def classify_priority(exp):
+ success = exp.get("success_rate", 0)
+ risk = exp.get("risk_level", "Medium")
+
+ if success >= 80 and risk == "Low":
+ return "High Priority"
+ if success >= 70:
+ return "Review"
+ return "Needs Improvement"
+
+
+def analyze():
+ experiments = load_experiments()
+
+ results = []
+ for exp in experiments:
+ results.append({
+ "id": exp["id"],
+ "domain": exp["domain"],
+ "success_rate": exp["success_rate"],
+ "risk_level": exp["risk_level"],
+ "priority": classify_priority(exp)
+ })
+
+ return results
+
+
+if __name__ == "__main__":
+ for item in analyze():
+ print(item)
\ No newline at end of file
diff --git a/ai/models/torch_success_predictor.py b/ai/models/torch_success_predictor.py
new file mode 100644
index 0000000..8eae712
--- /dev/null
+++ b/ai/models/torch_success_predictor.py
@@ -0,0 +1,38 @@
+"""
+BioDockLab PyTorch scaffold.
+
+Goal:
+Predict experiment success probability from structured bio experiment features.
+This file is a model skeleton and will be implemented after Python environment setup.
+"""
+
+try:
+ import torch
+ import torch.nn as nn
+except ImportError:
+ torch = None
+ nn = None
+
+
+if torch and nn:
+ class ExperimentSuccessModel(nn.Module):
+ def __init__(self, input_dim: int = 4):
+ super().__init__()
+ self.network = nn.Sequential(
+ nn.Linear(input_dim, 16),
+ nn.ReLU(),
+ nn.Linear(16, 8),
+ nn.ReLU(),
+ nn.Linear(8, 1),
+ nn.Sigmoid()
+ )
+
+ def forward(self, x):
+ return self.network(x)
+else:
+ class ExperimentSuccessModel:
+ def __init__(self, input_dim: int = 4):
+ self.input_dim = input_dim
+
+ def explain(self):
+ return "PyTorch is not installed. This is a scaffold model."
\ No newline at end of file
diff --git a/backend/main.py b/backend/main.py
index 7ccf8ed..6af4ee2 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,298 +1,35 @@
-from fastapi import FastAPI, Query
-from fastapi.middleware.cors import CORSMiddleware
+from fastapi import FastAPI
from pathlib import Path
-from datetime import datetime
import json
-import random
-app = FastAPI(title="BioDockLab API")
+app = FastAPI(title="BioDockLab API", version="2.1.0")
-app.add_middleware(
- CORSMiddleware,
- allow_origins=["*"],
- allow_credentials=True,
- allow_methods=["*"],
- allow_headers=["*"],
-)
+DATA_PATH = Path(__file__).resolve().parent.parent / "data" / "sample" / "bio_experiments.json"
-BASE_DIR = Path(__file__).resolve().parent.parent
-SAMPLE_DATA_DIR = BASE_DIR / "sample_data"
-EXPERIMENTS_FILE = BASE_DIR / "experiments" / "experiment_runs.json"
-OUTPUT_DIR = BASE_DIR / "docking" / "outputs"
-LOG_DIR = BASE_DIR / "docking" / "logs"
-SAMPLE_DATA_DIR.mkdir(exist_ok=True)
-OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
-LOG_DIR.mkdir(parents=True, exist_ok=True)
-EXPERIMENTS_FILE.parent.mkdir(parents=True, exist_ok=True)
-
-ROLE_POLICIES = {
- "patient": {"dashboard", "patient", "reports_limited", "consent_status"},
- "doctor": {"dashboard", "patient", "doctor", "reports", "prescription"},
- "pharmacist": {"dashboard", "prescription", "patient_limited", "reports_limited"},
- "admin_staff": {"dashboard", "admin", "appointments", "consent_status", "patient_limited"},
- "researcher": {"dashboard", "research_lab", "docking", "data_hub"},
- "security": {"dashboard", "security", "audit_logs", "risk_events"},
- "super_admin": {
- "dashboard", "patient", "doctor", "research_lab", "docking", "prescription",
- "admin", "appointments", "consent_status", "data_hub", "security",
- "audit_logs", "risk_events", "reports", "reports_limited", "patient_limited"
- },
- "bio_data_curator": {"dashboard", "data_hub", "reports_limited", "patient_limited"},
- "ai_model_operator": {"dashboard", "research_lab", "docking", "data_hub", "reports_limited"},
- "patient_explanation_designer": {"dashboard", "patient", "reports_limited", "consent_status"},
- "research_workflow_engineer": {"dashboard", "research_lab", "docking", "data_hub"},
- "clinical_workflow_coordinator": {
- "dashboard", "admin", "appointments", "consent_status",
- "patient_limited", "prescription"
- },
- "bio_security_architect": {"dashboard", "security", "audit_logs", "risk_events"},
- "virtual_lab_developer": {"dashboard", "research_lab", "docking", "data_hub"},
-}
-
-DEFAULT_USERS = [
- {"id": "USER-PATIENT-001", "name": "환자 사용자", "role": "patient", "role_label": "환자", "department": "Patient Portal"},
- {"id": "USER-DOCTOR-001", "name": "이준호 의사", "role": "doctor", "role_label": "의사", "department": "Doctor Workspace"},
- {"id": "USER-PHARM-001", "name": "박민지 약사", "role": "pharmacist", "role_label": "약사", "department": "Pharmacy"},
- {"id": "USER-ADMIN-001", "name": "최하늘 원무", "role": "admin_staff", "role_label": "원무", "department": "Administration"},
- {"id": "USER-RESEARCHER-001", "name": "김서연 박사", "role": "researcher", "role_label": "연구자", "department": "Research Lab"},
- {"id": "USER-SECURITY-001", "name": "보안관리자", "role": "security", "role_label": "보안", "department": "Security Center"},
- {"id": "USER-OWNER-001", "name": "이영준 관리자", "role": "super_admin", "role_label": "플랫폼 관리자", "department": "Platform Admin"},
- {"id": "USER-BIOSEC-001", "name": "바이오 보안 아키텍트", "role": "bio_security_architect", "role_label": "Bio Security Architect", "department": "Bio Security"},
- {"id": "USER-VLAB-001", "name": "가상실험실 개발자", "role": "virtual_lab_developer", "role_label": "Virtual Lab Developer", "department": "Virtual Lab"},
-]
-
-DEFAULT_RISK_EVENTS = [
- {
- "id": "RISK-001",
- "severity": "high",
- "title": "권한 밖 민감 데이터 접근 시도",
- "description": "원무 역할 사용자가 처방 상세 데이터 화면에 접근하려 했습니다.",
- "status": "needs_review",
- "recommended_action": "접근 사유 확인 및 권한 정책 점검"
- },
- {
- "id": "RISK-002",
- "severity": "medium",
- "title": "동일 환자 기록 반복 열람",
- "description": "짧은 시간 내 동일 환자 기록이 반복 조회되었습니다.",
- "status": "monitoring",
- "recommended_action": "내부자 과다열람 여부 확인"
- }
-]
-
-
-def load_json(path: Path, default):
- try:
- if not path.exists():
- save_json(path, default)
- return default
- raw = path.read_text(encoding="utf-8").strip()
- if not raw:
- save_json(path, default)
- return default
- data = json.loads(raw)
- return data
- except Exception:
- save_json(path, default)
- return default
-
-
-def save_json(path: Path, data):
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
-
-
-def can_access(role: str, view: str) -> bool:
- return view in ROLE_POLICIES.get(role, set())
-
-
-def append_audit(actor: str, role: str, action: str, resource: str, patient_id=None, allowed=True, reason=""):
- path = SAMPLE_DATA_DIR / "audit_logs.json"
- logs = load_json(path, [])
- item = {
- "timestamp": datetime.now().isoformat(timespec="seconds"),
- "actor": actor,
- "role": role,
- "action": action,
- "resource": resource,
- "patient_id": patient_id,
- "allowed": allowed,
- "reason": reason
- }
- logs.append(item)
- save_json(path, logs)
- return item
-
-
-def add_risk_event(title: str, description: str, severity="medium"):
- path = SAMPLE_DATA_DIR / "risk_events.json"
- events = load_json(path, DEFAULT_RISK_EVENTS)
- item = {
- "id": f"RISK-{len(events) + 1:03d}",
- "severity": severity,
- "title": title,
- "description": description,
- "status": "needs_review",
- "recommended_action": "접근권한, 접근 사유, 반복 열람 여부 확인"
- }
- events.append(item)
- save_json(path, events)
- return item
+def load_data():
+ with open(DATA_PATH, "r", encoding="utf-8") as f:
+ return json.load(f)
@app.get("/")
def root():
return {
- "message": "BioDockLab Backend Running",
- "mode": "v0.7.2 clean backend",
- "claim_boundary": "research, education, explanation support, and security audit only"
- }
-
-
-@app.get("/health")
-def health():
- return {"status": "ok"}
-
-
-@app.get("/clinical/users")
-def clinical_users():
- return load_json(SAMPLE_DATA_DIR / "clinical_users.json", DEFAULT_USERS)
-
-
-@app.post("/security/access-check")
-def access_check(payload: dict):
- role = payload.get("role", "")
- actor = payload.get("actor", "unknown")
- view = payload.get("view", "")
- patient_id = payload.get("patient_id")
- reason = payload.get("reason", "screen access")
-
- allowed = can_access(role, view)
-
- append_audit(
- actor=actor,
- role=role,
- action="ACCESS_CHECK",
- resource=view,
- patient_id=patient_id,
- allowed=allowed,
- reason=reason
- )
-
- if not allowed:
- add_risk_event(
- title="권한 없는 화면 접근 시도",
- description=f"{actor}({role}) tried to access {view} for patient {patient_id}",
- severity="high"
- )
- return {
- "allowed": False,
- "message": "권한이 없는 화면입니다. 보안 로그에 기록되었습니다."
- }
-
- return {
- "allowed": True,
- "message": "접근 허용"
- }
-
-
-@app.get("/security/audit-logs")
-def audit_logs(role: str = Query("security"), actor: str = Query("Security")):
- allowed = can_access(role, "audit_logs")
-
- append_audit(
- actor=actor,
- role=role,
- action="VIEW_AUDIT_LOGS",
- resource="audit_logs",
- patient_id=None,
- allowed=allowed,
- reason="security center"
- )
-
- if not allowed:
- add_risk_event(
- title="감사 로그 권한 없는 접근",
- description=f"{actor}({role}) attempted audit logs",
- severity="high"
- )
- return {
- "error": "ACCESS_DENIED",
- "message": "감사 로그 접근 권한이 없습니다."
- }
-
- return load_json(SAMPLE_DATA_DIR / "audit_logs.json", [])
-
-
-@app.get("/security/risk-events")
-def risk_events(role: str = Query("security"), actor: str = Query("Security")):
- allowed = can_access(role, "risk_events")
-
- append_audit(
- actor=actor,
- role=role,
- action="VIEW_RISK_EVENTS",
- resource="risk_events",
- patient_id=None,
- allowed=allowed,
- reason="security center"
- )
-
- if not allowed:
- return {
- "error": "ACCESS_DENIED",
- "message": "위험 이벤트 접근 권한이 없습니다."
- }
-
- return load_json(SAMPLE_DATA_DIR / "risk_events.json", DEFAULT_RISK_EVENTS)
-
-
-@app.get("/proteins")
-def get_proteins():
- return load_json(SAMPLE_DATA_DIR / "proteins.json", [])
-
-
-@app.get("/docking/{protein_id}")
-def get_docking_result(protein_id: str):
- protein_id = protein_id.upper()
- data = load_json(SAMPLE_DATA_DIR / "docking_results.json", {})
- if isinstance(data, dict) and protein_id in data:
- return data[protein_id]
- return {
- "protein": protein_id,
- "note": "Sample docking result fallback",
- "ligands": [
- {"rank": 1, "name": "BDL-10234", "binding_score": -10.28},
- {"rank": 2, "name": "BDL-08765", "binding_score": -9.46},
- {"rank": 3, "name": "BDL-09123", "binding_score": -8.74}
- ]
+ "project": "BioDockLab",
+ "version": "2.1",
+ "message": "Bio AI research software platform API"
}
@app.get("/experiments")
def get_experiments():
- return load_json(EXPERIMENTS_FILE, [])
-
-
-@app.post("/experiments/sample/{pdb_id}")
-def create_sample_experiment(pdb_id: str):
- pdb_id = pdb_id.upper()
- experiment_id = f"EXP-{datetime.now().strftime('%Y-%m-%d-%H%M%S')}-{pdb_id}"
- result = {
- "experiment_id": experiment_id,
- "pdb_id": pdb_id,
- "status": "completed_sample_run",
- "engine": "AutoDock Vina sample mode",
- "best_score": round(random.uniform(-10.5, -8.0), 2),
- "created_at": datetime.now().isoformat()
- }
-
- output_file = OUTPUT_DIR / f"{experiment_id}_result.json"
- save_json(output_file, result)
+ return load_data()
- experiments = load_json(EXPERIMENTS_FILE, [])
- experiments.append(result)
- save_json(EXPERIMENTS_FILE, experiments)
- return result
+@app.get("/experiments/{experiment_id}")
+def get_experiment(experiment_id: str):
+ experiments = load_data()
+ for exp in experiments:
+ if exp["id"] == experiment_id:
+ return exp
+ return {"error": "Experiment not found"}
\ No newline at end of file
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..75c3cbe
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,5 @@
+fastapi
+uvicorn
+pandas
+numpy
+scikit-learn
\ No newline at end of file
diff --git a/bio/bioinformatics_toolkit.py b/bio/bioinformatics_toolkit.py
new file mode 100644
index 0000000..b22ca0f
--- /dev/null
+++ b/bio/bioinformatics_toolkit.py
@@ -0,0 +1,47 @@
+"""
+BioDockLab bioinformatics toolkit scaffold.
+
+Planned stack:
+- Biopython: sequence parsing
+- RDKit: molecular structure analysis
+"""
+
+try:
+ from Bio.Seq import Seq
+except ImportError:
+ Seq = None
+
+try:
+ from rdkit import Chem
+except ImportError:
+ Chem = None
+
+
+def summarize_sequence(sequence: str):
+ if Seq is None:
+ return {
+ "sequence": sequence,
+ "length": len(sequence),
+ "status": "Biopython not installed"
+ }
+
+ seq = Seq(sequence)
+ return {
+ "sequence": str(seq),
+ "length": len(seq),
+ "reverse_complement": str(seq.reverse_complement())
+ }
+
+
+def parse_smiles(smiles: str):
+ if Chem is None:
+ return {
+ "smiles": smiles,
+ "status": "RDKit not installed"
+ }
+
+ molecule = Chem.MolFromSmiles(smiles)
+ return {
+ "smiles": smiles,
+ "valid": molecule is not None
+ }
\ No newline at end of file
diff --git a/bio/sequence_parser.py b/bio/sequence_parser.py
new file mode 100644
index 0000000..baf0994
--- /dev/null
+++ b/bio/sequence_parser.py
@@ -0,0 +1,35 @@
+def parse_fasta(text: str):
+ records = []
+ current_id = None
+ sequence = []
+
+ for line in text.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+
+ if line.startswith(">"):
+ if current_id:
+ records.append({
+ "id": current_id,
+ "sequence": "".join(sequence)
+ })
+ current_id = line[1:]
+ sequence = []
+ else:
+ sequence.append(line)
+
+ if current_id:
+ records.append({
+ "id": current_id,
+ "sequence": "".join(sequence)
+ })
+
+ return records
+
+
+if __name__ == "__main__":
+ sample = """>protein_sample_001
+MKTAYIAKQRQISFVKSHFSRQDILDLWQ
+"""
+ print(parse_fasta(sample))
\ No newline at end of file
diff --git a/data/sample/bio_experiments.json b/data/sample/bio_experiments.json
new file mode 100644
index 0000000..879bd4c
--- /dev/null
+++ b/data/sample/bio_experiments.json
@@ -0,0 +1,35 @@
+[
+ {
+ "id": "ORG-001",
+ "domain": "Organoid",
+ "sample": "intestinal organoid",
+ "condition": "growth factor A + drug candidate X",
+ "temperature": 37,
+ "duration_hours": 72,
+ "success_rate": 78,
+ "risk_level": "Medium",
+ "note": "Potential drug response model"
+ },
+ {
+ "id": "CFPS-001",
+ "domain": "CFPS",
+ "sample": "cell-free protein synthesis",
+ "condition": "enzyme mix B + amino acid pool",
+ "temperature": 30,
+ "duration_hours": 6,
+ "success_rate": 84,
+ "risk_level": "Low",
+ "note": "Protein production simulation sample"
+ },
+ {
+ "id": "DT-001",
+ "domain": "Digital Twin",
+ "sample": "virtual patient model",
+ "condition": "treatment response prediction",
+ "temperature": null,
+ "duration_hours": 24,
+ "success_rate": 69,
+ "risk_level": "High",
+ "note": "Digital twin prediction sample"
+ }
+]
\ No newline at end of file
diff --git a/database/schema.sql b/database/schema.sql
new file mode 100644
index 0000000..479eb21
--- /dev/null
+++ b/database/schema.sql
@@ -0,0 +1,20 @@
+CREATE TABLE experiments (
+ id TEXT PRIMARY KEY,
+ domain TEXT NOT NULL,
+ sample TEXT NOT NULL,
+ condition TEXT NOT NULL,
+ temperature REAL,
+ duration_hours REAL,
+ success_rate INTEGER,
+ risk_level TEXT,
+ note TEXT
+);
+
+CREATE TABLE analysis_results (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ experiment_id TEXT NOT NULL,
+ priority TEXT NOT NULL,
+ recommendation TEXT NOT NULL,
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (experiment_id) REFERENCES experiments(id)
+);
\ No newline at end of file
diff --git a/database/seed.sql b/database/seed.sql
new file mode 100644
index 0000000..99256c4
--- /dev/null
+++ b/database/seed.sql
@@ -0,0 +1,33 @@
+INSERT INTO experiments (
+ id,
+ domain,
+ sample,
+ condition,
+ temperature,
+ duration_hours,
+ success_rate,
+ risk_level,
+ note
+) VALUES
+(
+ 'ORG-001',
+ 'Organoid',
+ 'intestinal organoid',
+ 'growth factor A + drug candidate X',
+ 37,
+ 72,
+ 78,
+ 'Medium',
+ 'Potential drug response model'
+),
+(
+ 'CFPS-001',
+ 'CFPS',
+ 'cell-free protein synthesis',
+ 'enzyme mix B + amino acid pool',
+ 30,
+ 6,
+ 84,
+ 'Low',
+ 'Protein production simulation sample'
+);
\ No newline at end of file
diff --git a/database/supabase/supabase_client.ts b/database/supabase/supabase_client.ts
new file mode 100644
index 0000000..f7a4d83
--- /dev/null
+++ b/database/supabase/supabase_client.ts
@@ -0,0 +1,25 @@
+export type SupabaseExperimentRow = {
+ id: string;
+ domain: string;
+ sample: string;
+ condition: string;
+ temperature: number | null;
+ duration_hours: number | null;
+ success_rate: number;
+ risk_level: "Low" | "Medium" | "High";
+ note: string;
+};
+
+// Future implementation:
+// import { createClient } from "@supabase/supabase-js";
+//
+// export const supabase = createClient(
+// process.env.SUPABASE_URL!,
+// process.env.SUPABASE_ANON_KEY!
+// );
+
+export const supabaseTablePlan = {
+ experiments: "stores bio experiment records",
+ analysis_results: "stores AI priority and recommendation results",
+ reports: "stores generated research report metadata"
+};
\ No newline at end of file
diff --git a/docs/architecture/TECH_STACK_ROADMAP.md b/docs/architecture/TECH_STACK_ROADMAP.md
new file mode 100644
index 0000000..d438a37
--- /dev/null
+++ b/docs/architecture/TECH_STACK_ROADMAP.md
@@ -0,0 +1,86 @@
+# BioDockLab Technical Stack Roadmap
+
+## 1. Frontend
+
+- HTML
+- CSS
+- JavaScript
+- React
+- TypeScript
+- Vite
+- Recharts
+- D3.js
+- Three.js
+
+## 2. Backend
+
+- Python
+- FastAPI
+- REST API
+- WebSocket
+
+## 3. Database
+
+- SQLite
+- PostgreSQL
+- Supabase
+
+## 4. AI / Machine Learning
+
+- Pandas
+- NumPy
+- Scikit-learn
+- PyTorch
+- LLM API
+
+## 5. Bioinformatics
+
+- Biopython
+- RDKit
+- FASTA parser
+- Biomedical data parser
+
+## 6. Digital Twin / Simulation
+
+- NumPy
+- SciPy
+- SimPy
+- Recharts
+- D3.js
+- Three.js
+
+## 7. Medical Imaging / Surgery AI
+
+- OpenCV
+- DICOM
+- MONAI
+- PyTorch
+- SimpleITK
+
+## 8. Quantum Biocomputing
+
+- Qiskit
+- PennyLane
+- Cirq
+
+## 9. Report Automation
+
+- Markdown
+- Jinja2
+- ReportLab
+- WeasyPrint
+
+## 10. Deployment / DevOps
+
+- GitHub
+- GitHub Actions
+- Docker
+- Docker Compose
+- Vercel
+- Render
+
+## Development Position
+
+BioDockLab is not simply a bio research project.
+
+It is a research software platform that structures biomedical experiment data, analyzes it, simulates possible outcomes, and generates decision-support reports.
\ No newline at end of file
diff --git a/frontend/BioDashboard.tsx b/frontend/BioDashboard.tsx
new file mode 100644
index 0000000..548fbf6
--- /dev/null
+++ b/frontend/BioDashboard.tsx
@@ -0,0 +1,29 @@
+import type { BioExperiment, AnalysisResult } from "./bio_ai_types";
+
+type Props = {
+ experiments: BioExperiment[];
+ analysis: AnalysisResult[];
+};
+
+export function BioDashboard({ experiments, analysis }: Props) {
+ const analysisMap = new Map(analysis.map((item) => [item.id, item]));
+
+ return (
+ {exp.sample} {exp.condition} {result?.priority ?? "Needs Improvement"}BioDockLab Live Dashboard
+ {experiments.map((exp) => {
+ const result = analysisMap.get(exp.id);
+
+ return (
+ {exp.domain}
+
+ 이 화면은 정적 HTML이 아니라 Node 서버의 API에서 + bio_experiments.json 데이터를 읽어와 카드 UI로 렌더링합니다. +
+Loading experiment data...
+