Initial web mindmap site

This commit is contained in:
user
2026-05-11 14:20:04 +09:00
commit b91c415129
23 changed files with 1205 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
site-*.png
.DS_Store
Thumbs.db

20
README.md Normal file
View File

@@ -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/`.

BIN
imgs/React.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
imgs/hermes.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
imgs/html_css.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
imgs/httpReq_Res.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
imgs/nextJS.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
imgs/postgress.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
imgs/term_api.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
imgs/term_browser.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
imgs/term_dom.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
imgs/term_javascript.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
imgs/term_json.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
imgs/데이터베이스.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
imgs/쿠키란.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

60
index.html Normal file
View File

@@ -0,0 +1,60 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>웹 개발 용어 마인드맵</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main class="learning-canvas" id="viewport" aria-labelledby="page-title">
<header class="hud">
<div>
<p class="eyebrow">Web Learning Map</p>
<h1 id="page-title">웹 개발 용어 마인드맵</h1>
</div>
<div class="hud-actions">
<button id="resetView" type="button">중앙으로</button>
<button id="foldAll" type="button">접기</button>
</div>
</header>
<section class="canvas-help" aria-label="사용 상태">
<span id="visibleCount">1개 노드</span>
<span>배경 드래그: 이동</span>
<span>노드 드래그: 위치 변경</span>
</section>
<div class="world" id="world">
<svg class="connector-layer" id="connectorLayer" aria-hidden="true"></svg>
<div class="node-layer" id="nodeLayer" aria-label="접을 수 있는 웹 개발 용어 지도"></div>
</div>
</main>
<div class="lightbox" id="lightbox" hidden>
<button class="lightbox-backdrop" type="button" aria-label="팝업 닫기"></button>
<figure class="lightbox-card">
<div class="lightbox-top">
<div>
<p id="lightboxCategory">용어</p>
<figcaption id="lightboxCaption">이미지</figcaption>
</div>
<div class="zoom-toolbar" aria-label="이미지 확대 축소">
<button class="icon-button" id="zoomOut" type="button" aria-label="축소"></button>
<span id="zoomLevel">100%</span>
<button class="icon-button" id="zoomIn" type="button" aria-label="확대">+</button>
<button class="text-button" id="zoomReset" type="button">맞춤</button>
<button class="text-button" id="fullscreenToggle" type="button">전체화면</button>
<button class="icon-button" id="closeLightbox" type="button" aria-label="팝업 닫기">×</button>
</div>
</div>
<div class="zoom-stage" id="zoomStage">
<img id="lightboxImage" alt="" />
</div>
<p class="lightbox-focus" id="lightboxFocus"></p>
</figure>
</div>
<script src="./script.js"></script>
</body>
</html>

662
script.js Normal file
View File

@@ -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 = `
<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();

460
styles.css Normal file
View File

@@ -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;
}
}