Files
web_mindmap/script.js

640 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `
<span class="node-title">${node.title}</span>
<span class="node-subtitle">${node.subtitle}</span>
${
node.children
? `<button class="node-badge" type="button" aria-label="${node.title} ${expanded.has(node.id) ? "접기" : "펼치기"}">${expanded.has(node.id) ? "" : "+"}</button>`
: `<span class="node-badge" aria-hidden="true">↗</span>`
}
`;
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();