const nodes = [ { id: "root", type: "root", title: "웹 개발", subtitle: "클릭해서 펼치기", image: "imgs/category_web_overview.png", category: "전체 개요", focus: "웹 개발이 프론트엔드, 백엔드, 통신, 데이터 흐름으로 나뉘는 큰 그림을 봅니다.", x: 1020, y: 700, children: ["structure", "frontend", "network", "backend", "workflow"], }, { id: "structure", type: "category", title: "웹의 뼈대", subtitle: "브라우저와 화면 구성", image: "imgs/category_web_structure.png", category: "웹의 뼈대", focus: "HTML, CSS, 브라우저, DOM이 화면을 구성하는 기본 흐름을 봅니다.", x: 1020, y: 420, children: ["browser", "html-css", "dom"], }, { id: "frontend", type: "category", title: "프론트엔드", subtitle: "UI를 만드는 도구", image: "imgs/category_frontend.png", category: "프론트엔드", focus: "사용자가 보는 화면과 백엔드가 만나는 경계를 먼저 파악합니다.", x: 1360, y: 560, children: ["javascript", "react", "front-back"], }, { id: "network", type: "category", title: "통신", subtitle: "요청과 응답", image: "imgs/category_network.png", category: "통신", focus: "요청과 응답, 상태 코드, 헤더, 바디가 웹 통신에서 맡는 역할을 봅니다.", x: 1320, y: 900, children: ["auth", "cookie", "api", "http", "request-response"], }, { id: "backend", type: "category", title: "백엔드·데이터", subtitle: "서버와 저장소", image: "imgs/category_backend_data.png", category: "백엔드·데이터", focus: "서버가 요청을 처리하고 데이터를 저장·조회하는 큰 흐름을 봅니다.", x: 1020, y: 1030, children: ["web-server", "database", "json"], }, { id: "workflow", type: "category", title: "프레임워크·도구", subtitle: "개발 흐름", image: "imgs/category_framework_tools.png", category: "프레임워크·도구", focus: "Next.js 같은 프레임워크가 라우팅, 렌더링, 개발 흐름을 어떻게 묶는지 봅니다.", x: 660, y: 900, children: ["next", "next-structure"], }, { id: "browser", parent: "structure", type: "term", title: "브라우저", subtitle: "웹의 뼈대", image: "imgs/term_browser.png", category: "웹의 뼈대", focus: "URL 요청, 리소스 해석, 렌더링, 개발자 도구를 연결해서 봅니다.", x: 720, y: 330, }, { id: "html-css", parent: "structure", type: "term", title: "HTML / CSS", subtitle: "웹의 뼈대", image: "imgs/html_css.png", category: "웹의 뼈대", focus: "HTML은 구조, CSS는 표현을 담당한다는 기본 분업을 익힙니다.", x: 1020, y: 240, }, { id: "dom", parent: "structure", type: "term", title: "DOM", subtitle: "웹의 뼈대", image: "imgs/term_dom.png", category: "웹의 뼈대", focus: "HTML이 객체 트리로 바뀌고 JavaScript가 이를 조작하는 흐름을 봅니다.", x: 1320, y: 330, }, { id: "javascript", parent: "frontend", type: "term", title: "JavaScript", subtitle: "프론트엔드", image: "imgs/term_javascript.png", category: "프론트엔드", focus: "이벤트, 상태, 비동기 처리로 화면이 움직이는 원리를 이해합니다.", x: 1530, y: 380, }, { id: "react", parent: "frontend", type: "term", title: "React", subtitle: "프론트엔드", image: "imgs/React.png", category: "프론트엔드", focus: "컴포넌트, 상태, props를 중심으로 UI를 조립하는 방식을 봅니다.", x: 1640, y: 560, }, { id: "front-back", parent: "frontend", type: "term", title: "프론트엔드와 백엔드", subtitle: "프론트엔드", image: "imgs/frontend_backend.png", category: "프론트엔드", focus: "화면 담당 영역과 서버 담당 영역이 어디서 만나는지 확인합니다.", x: 1530, y: 740, }, { id: "auth", parent: "network", type: "term", title: "인증", subtitle: "통신", image: "imgs/term_authentication.png", category: "통신", focus: "로그인, 세션, 토큰, 권한 확인의 차이를 분리해서 봅니다.", x: 1510, y: 810, }, { id: "cookie", parent: "network", type: "term", title: "쿠키", subtitle: "통신", image: "imgs/cookie.png", category: "통신", focus: "브라우저에 저장되는 작은 데이터가 세션과 추적에 쓰이는 방식을 익힙니다.", x: 1600, y: 940, }, { id: "api", parent: "network", type: "term", title: "API", subtitle: "통신", image: "imgs/term_api.png", category: "통신", focus: "프론트엔드와 백엔드가 약속된 주소와 데이터 형식으로 대화하는 개념입니다.", x: 1460, y: 1080, }, { id: "http", parent: "network", type: "term", title: "HTTP 프로토콜", subtitle: "통신", image: "imgs/http_protocol.png", category: "통신", focus: "웹에서 클라이언트와 서버가 약속된 방식으로 통신하는 규칙입니다.", x: 1190, y: 1160, }, { id: "request-response", parent: "network", type: "term", title: "HTTP 요청/응답", subtitle: "통신", image: "imgs/httpReq_Res.png", category: "통신", focus: "메서드, 헤더, 바디, 상태 코드가 실제 흐름에서 어떤 역할인지 봅니다.", x: 930, y: 1180, }, { id: "web-server", parent: "backend", type: "term", title: "웹서버 프로그램", subtitle: "백엔드·데이터", image: "imgs/web_server_program.png", category: "백엔드·데이터", focus: "요청을 받아 정적 파일이나 애플리케이션 응답을 돌려주는 서버 역할입니다.", x: 760, y: 1010, }, { id: "database", parent: "backend", type: "category", title: "데이터베이스", subtitle: "백엔드·데이터", image: "imgs/database.png", category: "백엔드·데이터", focus: "애플리케이션 데이터가 테이블, 문서, 키-값 등으로 저장되는 방식을 봅니다.", x: 880, y: 1280, children: ["postgres"], }, { id: "postgres", parent: "database", type: "term", title: "PostgreSQL", subtitle: "백엔드·데이터", image: "imgs/postgress.png", category: "백엔드·데이터", focus: "관계형 데이터베이스와 SQL, 트랜잭션, 확장성 개념을 연결합니다.", x: 1180, y: 1300, }, { id: "json", parent: "backend", type: "term", title: "JSON", subtitle: "백엔드·데이터", image: "imgs/term_json.png", category: "백엔드·데이터", focus: "API에서 주고받는 데이터가 키-값, 배열, 객체로 표현되는 형식입니다.", x: 1420, y: 1210, }, { id: "next", parent: "workflow", type: "term", title: "Next.js", subtitle: "프레임워크·도구", image: "imgs/nextJS.png", category: "프레임워크·도구", focus: "React 기반 프레임워크가 라우팅, 렌더링, API 경계를 어떻게 제공하는지 봅니다.", x: 390, y: 820, }, { id: "next-structure", parent: "workflow", type: "term", title: "Next.js 디렉터리 구조", subtitle: "프레임워크·도구", image: "imgs/nextjs_directory_structure.png", category: "프레임워크·도구", focus: "app, page, layout, component, public 같은 폴더의 역할을 익힙니다.", x: 420, y: 1080, }, ]; const viewport = document.querySelector("#viewport"); const world = document.querySelector("#world"); const nodeLayer = document.querySelector("#nodeLayer"); const connectorLayer = document.querySelector("#connectorLayer"); const visibleCount = document.querySelector("#visibleCount"); const lightbox = document.querySelector("#lightbox"); const lightboxImage = document.querySelector("#lightboxImage"); const lightboxCaption = document.querySelector("#lightboxCaption"); const lightboxCategory = document.querySelector("#lightboxCategory"); const lightboxFocus = document.querySelector("#lightboxFocus"); const zoomStage = document.querySelector("#zoomStage"); const zoomLevel = document.querySelector("#zoomLevel"); const zoomInButton = document.querySelector("#zoomIn"); const zoomOutButton = document.querySelector("#zoomOut"); const zoomResetButton = document.querySelector("#zoomReset"); const fullscreenToggle = document.querySelector("#fullscreenToggle"); const nodeMap = new Map(nodes.map((node) => [node.id, node])); const expanded = new Set(); let pan = { x: window.innerWidth / 2 - 1136, y: window.innerHeight / 2 - 764 }; let activePointer = null; let zoomState = { scale: 1, x: 0, y: 0, dragging: false, pointerId: null, startX: 0, startY: 0, startOffsetX: 0, startOffsetY: 0 }; connectorLayer.setAttribute("viewBox", "0 0 2400 1600"); connectorLayer.setAttribute("width", "2400"); connectorLayer.setAttribute("height", "1600"); function visibleNodeIds() { const visible = ["root"]; for (const node of nodes) { if (node.id === "root") continue; const parent = node.parent ?? "root"; if (expanded.has(parent)) visible.push(node.id); } return visible; } function setWorldTransform() { world.style.transform = `translate(${pan.x}px, ${pan.y}px)`; } function render() { const visible = new Set(visibleNodeIds()); nodeLayer.innerHTML = ""; for (const node of nodes) { if (!visible.has(node.id)) continue; const element = document.createElement("div"); element.className = `node ${node.type} node-enter${node.image ? " has-image" : ""}`; element.dataset.id = node.id; element.tabIndex = 0; element.setAttribute("role", node.image ? "button" : "group"); element.setAttribute("aria-label", node.image ? `${node.title} 이미지 열기` : node.title); element.style.left = `${node.x}px`; element.style.top = `${node.y}px`; element.innerHTML = ` ${node.title} ${node.subtitle} ${ node.children ? `` : `` } `; if (expanded.has(node.id)) element.classList.add("is-open"); element.addEventListener("pointerdown", onNodePointerDown); element.addEventListener("keydown", onNodeKeyDown); const toggleButton = element.querySelector("button.node-badge"); if (toggleButton) { toggleButton.addEventListener("pointerdown", (event) => event.stopPropagation()); toggleButton.addEventListener("click", (event) => { event.stopPropagation(); toggleNode(node.id); }); } nodeLayer.appendChild(element); } requestAnimationFrame(() => drawConnectors(visible)); visibleCount.textContent = `${visible.size}개 노드`; } function nodeBox(node) { const element = nodeLayer.querySelector(`[data-id="${node.id}"]`); const width = element?.offsetWidth ?? (node.type === "root" ? 232 : node.type === "term" ? 176 : 184); const height = element?.offsetHeight ?? (node.type === "root" ? 128 : 76); return { left: node.x, top: node.y, width, height, cx: node.x + width / 2, cy: node.y + height / 2, }; } function boxEdgePoint(box, toward) { const dx = toward.cx - box.cx; const dy = toward.cy - box.cy; if (dx === 0 && dy === 0) return { x: box.cx, y: box.cy }; const halfW = box.width / 2 + 8; const halfH = box.height / 2 + 8; const scale = Math.min(Math.abs(halfW / dx || Infinity), Math.abs(halfH / dy || Infinity)); return { x: box.cx + dx * scale, y: box.cy + dy * scale, }; } function drawConnectors(visible) { connectorLayer.innerHTML = ""; for (const node of nodes) { if (node.id === "root" || !visible.has(node.id)) continue; const parent = nodeMap.get(node.parent ?? "root"); if (!parent || !visible.has(parent.id)) continue; const parentBox = nodeBox(parent); const childBox = nodeBox(node); const from = boxEdgePoint(parentBox, childBox); const to = boxEdgePoint(childBox, parentBox); const dx = to.x - from.x; const dy = to.y - from.y; const horizontalPull = Math.max(120, Math.abs(dx) * 0.48); const verticalPull = Math.max(40, Math.abs(dy) * 0.16); const c1 = { x: from.x + Math.sign(dx || 1) * horizontalPull, y: from.y + Math.sign(dy || 1) * verticalPull, }; const c2 = { x: to.x - Math.sign(dx || 1) * horizontalPull, y: to.y - Math.sign(dy || 1) * verticalPull, }; const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", `M ${from.x} ${from.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${to.x} ${to.y}`); connectorLayer.appendChild(path); } } function toggleNode(id) { const node = nodeMap.get(id); if (!node) return; if (node.children) { if (expanded.has(id)) collapseBranch(id); else expanded.add(id); render(); return; } openLightbox(node); } function collapseBranch(id) { const node = nodeMap.get(id); expanded.delete(id); for (const childId of node.children ?? []) { collapseBranch(childId); } } function onNodePointerDown(event) { if (event.target.closest(".node-badge")) return; event.preventDefault(); event.stopPropagation(); const element = event.currentTarget; const node = nodeMap.get(element.dataset.id); element.setPointerCapture(event.pointerId); element.classList.add("is-dragging"); activePointer = { mode: "node", pointerId: event.pointerId, node, element, startClientX: event.clientX, startClientY: event.clientY, startX: node.x, startY: node.y, moved: false, }; } function onViewportPointerDown(event) { if (event.target.closest(".node, .hud, .canvas-help, .lightbox")) return; viewport.setPointerCapture(event.pointerId); viewport.classList.add("is-panning"); activePointer = { mode: "pan", pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, startPanX: pan.x, startPanY: pan.y, moved: false, }; } function onPointerMove(event) { if (!activePointer || activePointer.pointerId !== event.pointerId) return; const dx = event.clientX - activePointer.startClientX; const dy = event.clientY - activePointer.startClientY; if (Math.hypot(dx, dy) > 5) activePointer.moved = true; if (activePointer.mode === "pan") { pan = { x: activePointer.startPanX + dx, y: activePointer.startPanY + dy }; setWorldTransform(); return; } activePointer.node.x = activePointer.startX + dx; activePointer.node.y = activePointer.startY + dy; activePointer.element.style.left = `${activePointer.node.x}px`; activePointer.element.style.top = `${activePointer.node.y}px`; drawConnectors(new Set(visibleNodeIds())); } function onPointerUp(event) { if (!activePointer || activePointer.pointerId !== event.pointerId) return; if (activePointer.mode === "node") { activePointer.element.classList.remove("is-dragging"); if (!activePointer.moved && activePointer.node.image) openLightbox(activePointer.node); } viewport.classList.remove("is-panning"); activePointer = null; } function onNodeKeyDown(event) { const node = nodeMap.get(event.currentTarget.dataset.id); if (!node?.image || (event.key !== "Enter" && event.key !== " ")) return; event.preventDefault(); openLightbox(node); } function openLightbox(node) { resetZoom(); lightboxImage.src = encodeURI(node.image); lightboxImage.alt = `${node.title} 인포그래픽`; lightboxCaption.textContent = node.title; lightboxCategory.textContent = node.category; lightboxFocus.textContent = node.focus; lightbox.hidden = false; } function closeLightbox() { setFullscreenMode(false); lightbox.hidden = true; } function setFullscreenMode(enabled) { lightbox.classList.toggle("is-fullscreen", enabled); fullscreenToggle.textContent = enabled ? "전체화면 종료" : "전체화면"; } function toggleFullscreen() { setFullscreenMode(!lightbox.classList.contains("is-fullscreen")); resetZoom(); } function clampZoom(value) { return Math.min(5, Math.max(0.5, value)); } function applyZoom() { zoomState.scale = clampZoom(zoomState.scale); lightboxImage.style.transform = `translate(${zoomState.x}px, ${zoomState.y}px) scale(${zoomState.scale})`; zoomLevel.textContent = `${Math.round(zoomState.scale * 100)}%`; } function resetZoom() { zoomState = { scale: 1, x: 0, y: 0, dragging: false, pointerId: null, startX: 0, startY: 0, startOffsetX: 0, startOffsetY: 0 }; zoomStage?.classList.remove("is-dragging"); if (lightboxImage) applyZoom(); } function setZoom(nextScale) { const previous = zoomState.scale; zoomState.scale = clampZoom(nextScale); if (zoomState.scale <= 1 && !lightbox.classList.contains("is-fullscreen")) { zoomState.x = 0; zoomState.y = 0; } else if (previous <= 1) { zoomState.x = 0; zoomState.y = 0; } applyZoom(); } function onZoomWheel(event) { event.preventDefault(); const direction = event.deltaY > 0 ? -1 : 1; setZoom(zoomState.scale + direction * 0.15); } function onZoomPointerDown(event) { const canPanImage = zoomState.scale > 1 || lightbox.classList.contains("is-fullscreen"); if (!canPanImage || event.button !== 0) return; event.preventDefault(); zoomStage.setPointerCapture(event.pointerId); zoomStage.classList.add("is-dragging"); zoomState.dragging = true; zoomState.pointerId = event.pointerId; zoomState.startX = event.clientX; zoomState.startY = event.clientY; zoomState.startOffsetX = zoomState.x; zoomState.startOffsetY = zoomState.y; } function onZoomPointerMove(event) { if (!zoomState.dragging || zoomState.pointerId !== event.pointerId) return; zoomState.x = zoomState.startOffsetX + event.clientX - zoomState.startX; zoomState.y = zoomState.startOffsetY + event.clientY - zoomState.startY; applyZoom(); } function onZoomPointerUp(event) { if (!zoomState.dragging || zoomState.pointerId !== event.pointerId) return; zoomState.dragging = false; zoomState.pointerId = null; zoomStage.classList.remove("is-dragging"); } function resetView() { pan = { x: window.innerWidth / 2 - 1136, y: window.innerHeight / 2 - 764 }; setWorldTransform(); } function foldAll() { expanded.clear(); render(); } viewport.addEventListener("pointerdown", onViewportPointerDown); window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointerup", onPointerUp); window.addEventListener("pointercancel", onPointerUp); document.querySelector("#resetView").addEventListener("click", resetView); document.querySelector("#foldAll").addEventListener("click", foldAll); document.querySelector(".lightbox-backdrop").addEventListener("click", closeLightbox); document.querySelector("#closeLightbox").addEventListener("click", closeLightbox); zoomInButton.addEventListener("click", () => setZoom(zoomState.scale + 0.25)); zoomOutButton.addEventListener("click", () => setZoom(zoomState.scale - 0.25)); zoomResetButton.addEventListener("click", resetZoom); fullscreenToggle.addEventListener("click", toggleFullscreen); zoomStage.addEventListener("wheel", onZoomWheel, { passive: false }); zoomStage.addEventListener("dblclick", () => setZoom(zoomState.scale > 1 ? 1 : 2)); zoomStage.addEventListener("pointerdown", onZoomPointerDown); zoomStage.addEventListener("pointermove", onZoomPointerMove); zoomStage.addEventListener("pointerup", onZoomPointerUp); zoomStage.addEventListener("pointercancel", onZoomPointerUp); document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeLightbox(); if (lightbox.hidden) return; if (event.key === "+" || event.key === "=") setZoom(zoomState.scale + 0.25); if (event.key === "-") setZoom(zoomState.scale - 0.25); if (event.key === "0") resetZoom(); if (event.key.toLowerCase() === "f") toggleFullscreen(); }); window.addEventListener("resize", resetView); setWorldTransform(); render();