commit b91c41512994f27e9a8e4143db6986f936092706 Author: user Date: Mon May 11 14:20:04 2026 +0900 Initial web mindmap site diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bb70af --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +site-*.png +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..42bdef4 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# Web Mindmap + +웹 개발 용어를 접을 수 있는 마인드맵으로 학습하는 정적 교육용 웹사이트입니다. + +## Features + +- 접힌 루트 노드에서 시작하는 인터랙티브 마인드맵 +- `+ / -` 버튼으로 하위 노드 펼침/접힘 +- 노드 드래그로 위치 이동 +- 배경 드래그로 viewport 이동 +- 노드 클릭 시 연결된 인포그래픽 이미지 팝업 +- 이미지 확대/축소, 드래그 이동, 앱 내부 전체화면 보기 + +## Local Preview + +```bash +python -m http.server 4173 --bind 127.0.0.1 +``` + +Open `http://127.0.0.1:4173/`. diff --git a/imgs/React.png b/imgs/React.png new file mode 100644 index 0000000..9c79b54 Binary files /dev/null and b/imgs/React.png differ diff --git a/imgs/hermes.png b/imgs/hermes.png new file mode 100644 index 0000000..5a64f5b Binary files /dev/null and b/imgs/hermes.png differ diff --git a/imgs/html_css.png b/imgs/html_css.png new file mode 100644 index 0000000..a2e19d4 Binary files /dev/null and b/imgs/html_css.png differ diff --git a/imgs/httpReq_Res.png b/imgs/httpReq_Res.png new file mode 100644 index 0000000..624d0d7 Binary files /dev/null and b/imgs/httpReq_Res.png differ diff --git a/imgs/http와프로토콜.png b/imgs/http와프로토콜.png new file mode 100644 index 0000000..289776e Binary files /dev/null and b/imgs/http와프로토콜.png differ diff --git a/imgs/nextJS.png b/imgs/nextJS.png new file mode 100644 index 0000000..0a1517c Binary files /dev/null and b/imgs/nextJS.png differ diff --git a/imgs/nextjs디렉터리구조.png b/imgs/nextjs디렉터리구조.png new file mode 100644 index 0000000..454d719 Binary files /dev/null and b/imgs/nextjs디렉터리구조.png differ diff --git a/imgs/postgress.png b/imgs/postgress.png new file mode 100644 index 0000000..c69e08d Binary files /dev/null and b/imgs/postgress.png differ diff --git a/imgs/term_api.png b/imgs/term_api.png new file mode 100644 index 0000000..5027da4 Binary files /dev/null and b/imgs/term_api.png differ diff --git a/imgs/term_authentication.png b/imgs/term_authentication.png new file mode 100644 index 0000000..08eb7d7 Binary files /dev/null and b/imgs/term_authentication.png differ diff --git a/imgs/term_browser.png b/imgs/term_browser.png new file mode 100644 index 0000000..b52b9fe Binary files /dev/null and b/imgs/term_browser.png differ diff --git a/imgs/term_dom.png b/imgs/term_dom.png new file mode 100644 index 0000000..99ca36d Binary files /dev/null and b/imgs/term_dom.png differ diff --git a/imgs/term_javascript.png b/imgs/term_javascript.png new file mode 100644 index 0000000..acecfff Binary files /dev/null and b/imgs/term_javascript.png differ diff --git a/imgs/term_json.png b/imgs/term_json.png new file mode 100644 index 0000000..a7e3b42 Binary files /dev/null and b/imgs/term_json.png differ diff --git a/imgs/데이터베이스.png b/imgs/데이터베이스.png new file mode 100644 index 0000000..e557b08 Binary files /dev/null and b/imgs/데이터베이스.png differ diff --git a/imgs/웹서버 프로그램.png b/imgs/웹서버 프로그램.png new file mode 100644 index 0000000..cf2bd6c Binary files /dev/null and b/imgs/웹서버 프로그램.png differ diff --git a/imgs/쿠키란.png b/imgs/쿠키란.png new file mode 100644 index 0000000..1f748a1 Binary files /dev/null and b/imgs/쿠키란.png differ diff --git a/imgs/프론트엔드와 백엔드.png b/imgs/프론트엔드와 백엔드.png new file mode 100644 index 0000000..bc30fcd Binary files /dev/null and b/imgs/프론트엔드와 백엔드.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..603c514 --- /dev/null +++ b/index.html @@ -0,0 +1,60 @@ + + + + + + 웹 개발 용어 마인드맵 + + + +
+
+
+

Web Learning Map

+

웹 개발 용어 마인드맵

+
+
+ + +
+
+ +
+ 1개 노드 + 배경 드래그: 이동 + 노드 드래그: 위치 변경 +
+ +
+ +
+
+
+ + + + + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..a61a641 --- /dev/null +++ b/script.js @@ -0,0 +1,662 @@ +const nodes = [ + { + id: "root", + type: "root", + title: "웹 개발", + subtitle: "클릭해서 펼치기", + image: "imgs/프론트엔드와 백엔드.png", + category: "전체 개요", + focus: "웹 개발이 프론트엔드, 백엔드, 통신, 데이터 흐름으로 나뉘는 큰 그림을 봅니다.", + x: 1020, + y: 700, + children: ["structure", "frontend", "network", "backend", "workflow", "ai"], + }, + { + id: "structure", + type: "category", + title: "웹의 뼈대", + subtitle: "브라우저와 화면 구성", + image: "imgs/html_css.png", + category: "웹의 뼈대", + focus: "HTML, CSS, 브라우저, DOM이 화면을 구성하는 기본 흐름을 봅니다.", + x: 1020, + y: 420, + children: ["browser", "html-css", "dom"], + }, + { + id: "frontend", + type: "category", + title: "프론트엔드", + subtitle: "UI를 만드는 도구", + image: "imgs/프론트엔드와 백엔드.png", + category: "프론트엔드", + focus: "사용자가 보는 화면과 백엔드가 만나는 경계를 먼저 파악합니다.", + x: 1360, + y: 560, + children: ["javascript", "react", "front-back"], + }, + { + id: "network", + type: "category", + title: "통신", + subtitle: "요청과 응답", + image: "imgs/httpReq_Res.png", + category: "통신", + focus: "요청과 응답, 상태 코드, 헤더, 바디가 웹 통신에서 맡는 역할을 봅니다.", + x: 1320, + y: 900, + children: ["auth", "cookie", "api", "http", "request-response"], + }, + { + id: "backend", + type: "category", + title: "백엔드·데이터", + subtitle: "서버와 저장소", + image: "imgs/데이터베이스.png", + category: "백엔드·데이터", + focus: "서버가 요청을 처리하고 데이터를 저장·조회하는 큰 흐름을 봅니다.", + x: 1020, + y: 1030, + children: ["web-server", "database", "postgres", "json"], + }, + { + id: "workflow", + type: "category", + title: "프레임워크·도구", + subtitle: "개발 흐름", + image: "imgs/nextJS.png", + category: "프레임워크·도구", + focus: "Next.js 같은 프레임워크가 라우팅, 렌더링, 개발 흐름을 어떻게 묶는지 봅니다.", + x: 660, + y: 900, + children: ["next", "next-structure"], + }, + { + id: "ai", + type: "category", + title: "AI 도구", + subtitle: "Agent 활용", + image: "imgs/hermes.png", + category: "AI 도구", + focus: "AI Agent가 개발 보조 도구로 어떤 역할을 하는지 큰 그림을 봅니다.", + x: 660, + y: 560, + children: ["hermes"], + }, + { + 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/프론트엔드와 백엔드.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/쿠키란.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와프로토콜.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/웹서버 프로그램.png", + category: "백엔드·데이터", + focus: "요청을 받아 정적 파일이나 애플리케이션 응답을 돌려주는 서버 역할입니다.", + x: 760, + y: 1010, + }, + { + id: "database", + parent: "backend", + type: "term", + title: "데이터베이스", + subtitle: "백엔드·데이터", + image: "imgs/데이터베이스.png", + category: "백엔드·데이터", + focus: "애플리케이션 데이터가 테이블, 문서, 키-값 등으로 저장되는 방식을 봅니다.", + x: 880, + y: 1280, + }, + { + id: "postgres", + parent: "backend", + 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디렉터리구조.png", + category: "프레임워크·도구", + focus: "app, page, layout, component, public 같은 폴더의 역할을 익힙니다.", + x: 420, + y: 1080, + }, + { + id: "hermes", + parent: "ai", + type: "term", + title: "Hermes Agent", + subtitle: "AI 도구", + image: "imgs/hermes.png", + category: "AI 도구", + focus: "개발 보조 에이전트가 도구, 메모리, 작업 흐름을 활용하는 방식을 봅니다.", + x: 390, + y: 520, + }, +]; + +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(); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..d3e2695 --- /dev/null +++ b/styles.css @@ -0,0 +1,460 @@ +:root { + --bg: #edf3fa; + --surface: #ffffff; + --surface-soft: #eef5ff; + --text: #111d31; + --muted: #65748b; + --line: #bfd0e8; + --accent: #2563eb; + --accent-deep: #12345b; + --ring: rgba(37, 99, 235, 0.24); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + overflow: hidden; + color: var(--text); + background: + linear-gradient(90deg, rgba(170, 191, 219, 0.32) 1px, transparent 1px), + linear-gradient(rgba(170, 191, 219, 0.32) 1px, transparent 1px), + radial-gradient(circle at 18% 16%, rgba(37, 99, 235, 0.14), transparent 34%), + radial-gradient(circle at 92% 78%, rgba(8, 145, 178, 0.13), transparent 30%), + var(--bg); + background-size: 28px 28px, 28px 28px, auto, auto, auto; +} + +button { + font: inherit; +} + +.learning-canvas { + position: relative; + width: 100vw; + height: 100vh; + overflow: hidden; + cursor: grab; + user-select: none; +} + +.learning-canvas.is-panning { + cursor: grabbing; +} + +.hud { + position: fixed; + top: 18px; + left: 18px; + right: 18px; + z-index: 10; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + pointer-events: none; +} + +.hud > *, +.canvas-help, +.lightbox { + pointer-events: auto; +} + +.eyebrow { + margin: 0 0 6px; + color: var(--accent); + font-size: 12px; + font-weight: 850; + letter-spacing: 0; + text-transform: uppercase; +} + +h1 { + margin: 0; + color: var(--text); + font-size: clamp(30px, 5vw, 64px); + line-height: 1.02; + letter-spacing: 0; + text-shadow: 0 2px 16px rgba(255, 255, 255, 0.86); +} + +.hud-actions { + display: flex; + gap: 8px; + padding-top: 8px; +} + +.hud-actions button { + border: 1px solid rgba(191, 208, 232, 0.92); + border-radius: 8px; + padding: 10px 13px; + background: rgba(255, 255, 255, 0.9); + color: var(--text); + box-shadow: 0 12px 28px rgba(18, 32, 51, 0.1); + cursor: pointer; +} + +.hud-actions button:hover { + border-color: var(--accent); +} + +.canvas-help { + position: fixed; + left: 18px; + bottom: 18px; + z-index: 10; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.canvas-help span { + border: 1px solid rgba(191, 208, 232, 0.86); + border-radius: 999px; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.82); + color: var(--muted); + font-size: 13px; + font-weight: 750; + box-shadow: 0 10px 28px rgba(18, 32, 51, 0.08); +} + +.world { + position: absolute; + left: 0; + top: 0; + width: 2400px; + height: 1600px; + transform-origin: 0 0; + will-change: transform; +} + +.connector-layer, +.node-layer { + position: absolute; + inset: 0; +} + +.connector-layer { + pointer-events: none; + width: 2400px; + height: 1600px; + overflow: visible; +} + +.connector-layer path { + fill: none; + stroke: rgba(37, 99, 235, 0.68); + stroke-width: 4.5; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 0; + filter: drop-shadow(0 5px 10px rgba(37, 99, 235, 0.22)); +} + +.node { + position: absolute; + display: grid; + align-content: center; + width: 184px; + min-height: 76px; + border: 1px solid rgba(191, 208, 232, 0.98); + border-radius: 8px; + padding: 13px 15px; + background: rgba(255, 255, 255, 0.94); + color: var(--text); + box-shadow: 0 18px 42px rgba(18, 32, 51, 0.16); + cursor: grab; + text-align: left; + opacity: 1; + transition: border-color 150ms ease, box-shadow 150ms ease; + touch-action: none; +} + +.node.has-image { + cursor: zoom-in; +} + +.node:hover, +.node.is-open { + border-color: var(--accent); + box-shadow: 0 22px 52px rgba(37, 99, 235, 0.2); +} + +.node:active, +.node.is-dragging { + cursor: grabbing; +} + +.node.root { + width: 232px; + min-height: 128px; + padding: 22px; + background: linear-gradient(135deg, #12345b, #1d4d7c); + color: #fff; + border-color: rgba(255, 255, 255, 0.42); +} + +.node.category { + background: rgba(238, 245, 255, 0.96); +} + +.node.term { + width: 176px; +} + +.node-title { + display: block; + font-size: 16px; + font-weight: 880; + line-height: 1.24; + letter-spacing: 0; + overflow-wrap: anywhere; +} + +.root .node-title { + font-size: 33px; +} + +.node-subtitle { + display: block; + margin-top: 7px; + color: inherit; + font-size: 13px; + line-height: 1.35; + opacity: 0.68; +} + +.node-badge { + position: absolute; + top: -10px; + right: -10px; + display: grid; + place-items: center; + min-width: 28px; + height: 28px; + border: 1px solid rgba(191, 208, 232, 0.96); + border-radius: 999px; + background: #fff; + color: var(--accent); + cursor: pointer; + font-size: 14px; + font-weight: 900; + box-shadow: 0 8px 18px rgba(18, 32, 51, 0.12); +} + +.node.term .node-badge { + color: #0f766e; + pointer-events: none; +} + +.node-enter { + opacity: 1; +} + +.lightbox { + position: fixed; + inset: 0; + z-index: 30; + display: grid; + place-items: center; + padding: 28px; +} + +.lightbox[hidden] { + display: none; +} + +.lightbox-backdrop { + position: absolute; + inset: 0; + border: 0; + background: rgba(10, 21, 36, 0.72); + cursor: zoom-out; +} + +.lightbox-card { + position: relative; + z-index: 1; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 12px; + width: min(1180px, 94vw); + max-height: 92vh; + margin: 0; + border-radius: 8px; + padding: 16px; + background: #fff; + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.32); +} + +.lightbox.is-fullscreen { + padding: 0; + background: #081524; +} + +.lightbox.is-fullscreen .lightbox-backdrop { + background: #081524; +} + +.lightbox.is-fullscreen .lightbox-card { + width: 100vw; + height: 100vh; + max-height: none; + border-radius: 0; + padding: 14px; +} + +.lightbox.is-fullscreen .zoom-stage { + max-height: none; +} + +.lightbox.is-fullscreen .lightbox-card img { + width: min(100%, 1400px); + max-height: none; +} + +.lightbox-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.lightbox-top p { + margin: 0 0 5px; + color: var(--accent); + font-size: 12px; + font-weight: 850; +} + +.lightbox-card figcaption { + color: var(--text); + font-size: 24px; + font-weight: 880; + line-height: 1.15; +} + +.zoom-toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.zoom-toolbar span { + min-width: 56px; + color: var(--muted); + font-size: 13px; + font-weight: 850; + text-align: center; +} + +.text-button { + height: 40px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 0 12px; + background: #fff; + color: var(--text); + cursor: pointer; + font-weight: 800; +} + +.zoom-stage { + position: relative; + display: grid; + place-items: center; + min-height: 0; + max-height: 72vh; + overflow: hidden; + border-radius: 8px; + background: + linear-gradient(90deg, rgba(203, 216, 234, 0.42) 1px, transparent 1px), + linear-gradient(rgba(203, 216, 234, 0.42) 1px, transparent 1px), + #f8fbff; + background-size: 24px 24px; + cursor: grab; +} + +.zoom-stage.is-dragging { + cursor: grabbing; +} + +.lightbox-card img { + width: min(100%, 980px); + min-height: 0; + max-height: 72vh; + object-fit: contain; + transform-origin: center center; + user-select: none; + -webkit-user-drag: none; + will-change: transform; +} + +.lightbox-focus { + margin: 0; + color: var(--muted); + line-height: 1.55; +} + +.icon-button { + display: inline-grid; + place-items: center; + width: 40px; + height: 40px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + color: var(--text); + cursor: pointer; + font-size: 25px; + line-height: 1; +} + +button:focus-visible, +.node:focus-visible { + outline: 3px solid var(--ring); + outline-offset: 3px; +} + +@media (max-width: 720px) { + .hud { + flex-direction: column; + } + + .hud-actions { + padding-top: 0; + } + + .canvas-help { + right: 12px; + bottom: 12px; + left: 12px; + } + + .node { + width: 166px; + min-height: 70px; + } + + .node.root { + width: 210px; + } + + .lightbox { + padding: 12px; + } + + .lightbox-card { + width: 100%; + max-height: 95vh; + padding: 12px; + } +}