Files
web_mindmap/script.js
2026-05-11 17:32:14 +09:00

847 lines
26 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: ["url-dns", "browser", "html-css", "css-layout", "dom", "devtools"],
},
{
id: "frontend",
type: "category",
title: "프론트엔드",
subtitle: "UI를 만드는 도구",
image: "imgs/category_frontend.png",
category: "프론트엔드",
focus: "사용자가 보는 화면과 백엔드가 만나는 경계를 먼저 파악합니다.",
x: 1360,
y: 560,
children: ["javascript", "typescript", "react", "front-back", "web-storage"],
},
{
id: "network",
type: "category",
title: "통신",
subtitle: "요청과 응답",
image: "imgs/category_network.png",
category: "통신",
focus: "요청과 응답, 상태 코드, 헤더, 바디가 웹 통신에서 맡는 역할을 봅니다.",
x: 1320,
y: 900,
children: ["auth", "cookie", "api", "http", "request-response", "cors", "xss-csrf"],
},
{
id: "backend",
type: "category",
title: "백엔드·데이터",
subtitle: "서버와 저장소",
image: "imgs/category_backend_data.png",
category: "백엔드·데이터",
focus: "서버가 요청을 처리하고 데이터를 저장·조회하는 큰 흐름을 봅니다.",
x: 1020,
y: 1030,
children: ["web-server", "database", "json", "env-vars"],
},
{
id: "workflow",
type: "category",
title: "프레임워크·도구",
subtitle: "개발 흐름",
image: "imgs/category_framework_tools.png",
category: "프레임워크·도구",
focus: "Next.js 같은 프레임워크가 라우팅, 렌더링, 개발 흐름을 어떻게 묶는지 봅니다.",
x: 660,
y: 900,
children: ["next", "next-structure", "npm-package", "git-version-control", "build-bundling", "deployment-hosting", "testing"],
},
{
id: "url-dns",
parent: "structure",
type: "term",
title: "URL · 도메인 · DNS",
subtitle: "웹의 뼈대",
image: "imgs/term_url_dns.png",
category: "웹의 뼈대",
focus: "주소창의 도메인이 DNS를 통해 IP로 바뀌고 서버에 연결되는 흐름을 봅니다.",
x: 650,
y: 170,
},
{
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: "css-layout",
parent: "structure",
type: "term",
title: "CSS 레이아웃",
subtitle: "웹의 뼈대",
image: "imgs/term_css_layout.png",
category: "웹의 뼈대",
focus: "Box Model, Flexbox, Grid, 반응형 규칙으로 화면을 배치하는 방법을 봅니다.",
x: 910,
y: 110,
},
{
id: "devtools",
parent: "structure",
type: "term",
title: "DevTools",
subtitle: "웹의 뼈대",
image: "imgs/term_devtools.png",
category: "웹의 뼈대",
focus: "Elements, Console, Network, Application 패널로 브라우저 안의 상태를 확인합니다.",
x: 1290,
y: 150,
},
{
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: "typescript",
parent: "frontend",
type: "term",
title: "TypeScript",
subtitle: "프론트엔드",
image: "imgs/term_typescript.png",
category: "프론트엔드",
focus: "JavaScript에 타입 검사를 더해 런타임 전에 오류를 줄이는 방법을 봅니다.",
x: 1720,
y: 410,
},
{
id: "front-back",
parent: "frontend",
type: "term",
title: "프론트엔드와 백엔드",
subtitle: "프론트엔드",
image: "imgs/frontend_backend.png",
category: "프론트엔드",
focus: "화면 담당 영역과 서버 담당 영역이 어디서 만나는지 확인합니다.",
x: 1530,
y: 740,
},
{
id: "web-storage",
parent: "frontend",
type: "term",
title: "localStorage · sessionStorage",
subtitle: "프론트엔드",
image: "imgs/term_web_storage.png",
category: "프론트엔드",
focus: "브라우저에 문자열 데이터를 저장하는 방식과 쿠키와의 차이를 봅니다.",
x: 1740,
y: 760,
},
{
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: "category",
title: "API",
subtitle: "통신",
image: "imgs/term_api.png",
category: "통신",
focus: "프론트엔드와 백엔드가 약속된 주소와 데이터 형식으로 대화하는 개념입니다.",
x: 1460,
y: 1080,
children: ["rest-crud"],
},
{
id: "http",
parent: "network",
type: "category",
title: "HTTP 프로토콜",
subtitle: "통신",
image: "imgs/http_protocol.png",
category: "통신",
focus: "웹에서 클라이언트와 서버가 약속된 방식으로 통신하는 규칙입니다.",
x: 1190,
y: 1160,
children: ["https-tls"],
},
{
id: "request-response",
parent: "network",
type: "term",
title: "HTTP 요청/응답",
subtitle: "통신",
image: "imgs/httpReq_Res.png",
category: "통신",
focus: "메서드, 헤더, 바디, 상태 코드가 실제 흐름에서 어떤 역할인지 봅니다.",
x: 930,
y: 1180,
},
{
id: "https-tls",
parent: "http",
type: "term",
title: "HTTPS · TLS",
subtitle: "통신",
image: "imgs/term_https_tls.png",
category: "통신",
focus: "TLS 암호화와 인증서로 HTTP 통신을 안전하게 보호하는 원리를 봅니다.",
x: 1120,
y: 1370,
},
{
id: "rest-crud",
parent: "api",
type: "term",
title: "REST · CRUD",
subtitle: "통신",
image: "imgs/term_rest_crud.png",
category: "통신",
focus: "리소스 중심 API와 생성/조회/수정/삭제 흐름을 HTTP 메서드와 연결합니다.",
x: 1620,
y: 1220,
},
{
id: "cors",
parent: "network",
type: "term",
title: "CORS",
subtitle: "통신",
image: "imgs/term_cors.png",
category: "통신",
focus: "다른 출처의 API 요청이 브라우저 보안 정책과 서버 헤더로 허용되는 방식을 봅니다.",
x: 1740,
y: 1040,
},
{
id: "xss-csrf",
parent: "network",
type: "term",
title: "XSS · CSRF",
subtitle: "통신",
image: "imgs/term_xss_csrf.png",
category: "통신",
focus: "스크립트 주입과 위조 요청을 구분하고 입력 검증, 토큰, 쿠키 옵션을 봅니다.",
x: 1790,
y: 1370,
},
{
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", "orm-migration"],
},
{
id: "postgres",
parent: "database",
type: "term",
title: "PostgreSQL",
subtitle: "백엔드·데이터",
image: "imgs/postgress.png",
category: "백엔드·데이터",
focus: "관계형 데이터베이스와 SQL, 트랜잭션, 확장성 개념을 연결합니다.",
x: 1180,
y: 1300,
},
{
id: "orm-migration",
parent: "database",
type: "term",
title: "ORM · Migration",
subtitle: "백엔드·데이터",
image: "imgs/term_orm_migration.png",
category: "백엔드·데이터",
focus: "코드 모델과 DB 테이블을 연결하고 스키마 변경을 버전으로 관리하는 방법을 봅니다.",
x: 810,
y: 1500,
},
{
id: "json",
parent: "backend",
type: "term",
title: "JSON",
subtitle: "백엔드·데이터",
image: "imgs/term_json.png",
category: "백엔드·데이터",
focus: "API에서 주고받는 데이터가 키-값, 배열, 객체로 표현되는 형식입니다.",
x: 1420,
y: 1210,
},
{
id: "env-vars",
parent: "backend",
type: "term",
title: "환경 변수(.env)",
subtitle: "백엔드·데이터",
image: "imgs/term_env_vars.png",
category: "백엔드·데이터",
focus: "API Key, DB URL 같은 설정을 코드와 분리하고 Git에 올리지 않는 이유를 봅니다.",
x: 650,
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,
},
{
id: "npm-package",
parent: "workflow",
type: "term",
title: "npm · package.json",
subtitle: "프레임워크·도구",
image: "imgs/term_npm_package.png",
category: "프레임워크·도구",
focus: "패키지 설치, scripts, dependencies가 프로젝트 실행 흐름을 잡는 방식을 봅니다.",
x: 260,
y: 940,
},
{
id: "git-version-control",
parent: "workflow",
type: "term",
title: "Git · 버전 관리",
subtitle: "프레임워크·도구",
image: "imgs/term_git_version_control.png",
category: "프레임워크·도구",
focus: "commit, branch, merge, remote를 통해 변경 이력과 협업을 관리하는 방법을 봅니다.",
x: 200,
y: 1190,
},
{
id: "build-bundling",
parent: "workflow",
type: "term",
title: "빌드 · 번들링",
subtitle: "프레임워크·도구",
image: "imgs/term_build_bundling.png",
category: "프레임워크·도구",
focus: "소스 코드를 배포 가능한 정적 파일로 최적화하는 과정을 봅니다.",
x: 610,
y: 1190,
},
{
id: "deployment-hosting",
parent: "workflow",
type: "category",
title: "배포 · 호스팅",
subtitle: "프레임워크·도구",
image: "imgs/term_deployment_hosting.png",
category: "프레임워크·도구",
focus: "빌드 결과물을 서버나 CDN에 올려 외부 사용자가 접근하게 만드는 흐름을 봅니다.",
x: 560,
y: 1410,
children: ["nginx-reverse-proxy"],
},
{
id: "nginx-reverse-proxy",
parent: "deployment-hosting",
type: "term",
title: "Nginx · Reverse Proxy",
subtitle: "프레임워크·도구",
image: "imgs/term_nginx_reverse_proxy.png",
category: "프레임워크·도구",
focus: "공개 요청을 받아 정적 파일과 API 서버로 나누어 전달하는 프록시 역할을 봅니다.",
x: 350,
y: 1620,
},
{
id: "testing",
parent: "workflow",
type: "term",
title: "테스트",
subtitle: "프레임워크·도구",
image: "imgs/term_testing.png",
category: "프레임워크·도구",
focus: "Unit, Integration, E2E, Smoke 테스트로 변경이 실제로 동작하는지 확인합니다.",
x: 190,
y: 1450,
},
];
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 1900");
connectorLayer.setAttribute("width", "2400");
connectorLayer.setAttribute("height", "1900");
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();