1382 lines
43 KiB
JavaScript
1382 lines
43 KiB
JavaScript
const webNodes = [
|
||
{
|
||
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 linuxNodes = [
|
||
{
|
||
id: "root",
|
||
type: "root",
|
||
title: "리눅스",
|
||
subtitle: "클릭해서 펼치기",
|
||
image: "imgs/linux_card_root.png",
|
||
category: "전체 개요",
|
||
focus: "리눅스를 터미널, 파일 시스템, 권한, 프로세스, 패키지, 네트워크 흐름으로 나누어 봅니다.",
|
||
x: 1020,
|
||
y: 700,
|
||
children: ["linux-basics", "linux-shell", "linux-filesystem", "linux-permissions", "linux-processes", "linux-packages-network"],
|
||
},
|
||
{
|
||
id: "linux-basics",
|
||
parent: "root",
|
||
type: "category",
|
||
title: "기초 개념",
|
||
subtitle: "운영체제와 도움말",
|
||
image: "imgs/linux_card_linux_basics.png",
|
||
category: "기초 개념",
|
||
focus: "리눅스가 커널, 배포판, 셸, 패키지 저장소로 구성된 운영체제 생태계라는 큰 그림을 봅니다.",
|
||
x: 1020,
|
||
y: 420,
|
||
children: ["linux-kernel", "linux-distro", "linux-terminal", "linux-man-pages", "linux-sudo-basics"],
|
||
},
|
||
{
|
||
id: "linux-shell",
|
||
parent: "root",
|
||
type: "category",
|
||
title: "셸 명령어",
|
||
subtitle: "명령 실행과 조합",
|
||
image: "imgs/linux_card_linux_shell.png",
|
||
category: "셸 명령어",
|
||
focus: "셸은 명령어, 옵션, 인자, 파이프, 리다이렉트를 조합해 작업을 자동화하는 인터페이스입니다.",
|
||
x: 1360,
|
||
y: 560,
|
||
children: ["linux-command-shape", "linux-pwd-ls-cd", "linux-file-ops", "linux-pipes", "linux-grep-find", "linux-history", "linux-env-path"],
|
||
},
|
||
{
|
||
id: "linux-filesystem",
|
||
parent: "root",
|
||
type: "category",
|
||
title: "파일 시스템",
|
||
subtitle: "경로와 디렉터리",
|
||
image: "imgs/linux_card_linux_filesystem.png",
|
||
category: "파일 시스템",
|
||
focus: "리눅스는 모든 경로가 루트(/)에서 시작하고, 설정·로그·사용자 파일이 관례적인 위치에 놓입니다.",
|
||
x: 1320,
|
||
y: 900,
|
||
children: ["linux-root-path", "linux-home-etc-var", "linux-hidden-files", "linux-mounts", "linux-links"],
|
||
},
|
||
{
|
||
id: "linux-permissions",
|
||
parent: "root",
|
||
type: "category",
|
||
title: "권한",
|
||
subtitle: "사용자와 접근 제어",
|
||
image: "imgs/linux_card_linux_permissions.png",
|
||
category: "권한",
|
||
focus: "사용자, 그룹, 읽기/쓰기/실행 권한을 이해하면 파일 접근과 관리자 작업을 안전하게 다룰 수 있습니다.",
|
||
x: 1020,
|
||
y: 1030,
|
||
children: ["linux-users-groups", "linux-rwx", "linux-chmod-chown", "linux-sudo", "linux-ssh-keys"],
|
||
},
|
||
{
|
||
id: "linux-processes",
|
||
parent: "root",
|
||
type: "category",
|
||
title: "프로세스",
|
||
subtitle: "실행 중인 작업",
|
||
image: "imgs/linux_card_linux_processes.png",
|
||
category: "프로세스",
|
||
focus: "프로세스와 서비스 상태를 보고, 필요하면 종료하거나 로그를 확인하고, 예약 실행까지 연결합니다.",
|
||
x: 660,
|
||
y: 900,
|
||
children: ["linux-ps-top", "linux-kill", "linux-systemd", "linux-logs", "linux-cron"],
|
||
},
|
||
{
|
||
id: "linux-packages-network",
|
||
parent: "root",
|
||
type: "category",
|
||
title: "패키지·네트워크",
|
||
subtitle: "설치와 원격 접속",
|
||
image: "imgs/linux_card_linux_packages_network.png",
|
||
category: "패키지·네트워크",
|
||
focus: "패키지 관리자로 도구를 설치하고, 네트워크 명령과 SSH로 서버 상태를 확인합니다.",
|
||
x: 660,
|
||
y: 560,
|
||
children: ["linux-apt-dnf", "linux-update-upgrade", "linux-ping-ss", "linux-curl-wget", "linux-ssh", "linux-firewall"],
|
||
},
|
||
{
|
||
id: "linux-kernel",
|
||
parent: "linux-basics",
|
||
type: "term",
|
||
title: "커널",
|
||
subtitle: "기초 개념",
|
||
image: "imgs/linux_card_linux_kernel.png",
|
||
category: "기초 개념",
|
||
focus: "커널은 CPU, 메모리, 파일, 네트워크 같은 하드웨어 자원을 프로그램이 안전하게 쓰도록 중재합니다.",
|
||
x: 650,
|
||
y: 170,
|
||
},
|
||
{
|
||
id: "linux-distro",
|
||
parent: "linux-basics",
|
||
type: "term",
|
||
title: "배포판",
|
||
subtitle: "기초 개념",
|
||
image: "imgs/linux_card_linux_distro.png",
|
||
category: "기초 개념",
|
||
focus: "Ubuntu, Debian, Fedora 같은 배포판은 커널 위에 기본 도구, 패키지 저장소, 설정 방식을 묶어 제공합니다.",
|
||
x: 720,
|
||
y: 330,
|
||
},
|
||
{
|
||
id: "linux-terminal",
|
||
parent: "linux-basics",
|
||
type: "term",
|
||
title: "터미널",
|
||
subtitle: "기초 개념",
|
||
image: "imgs/linux_card_linux_terminal.png",
|
||
category: "기초 개념",
|
||
focus: "터미널은 셸을 통해 명령을 입력하고 결과를 확인하는 창입니다.",
|
||
x: 1020,
|
||
y: 240,
|
||
},
|
||
{
|
||
id: "linux-man-pages",
|
||
parent: "linux-basics",
|
||
type: "term",
|
||
title: "man · --help",
|
||
subtitle: "기초 개념",
|
||
image: "imgs/linux_card_linux_man_pages.png",
|
||
category: "기초 개념",
|
||
focus: "명령어가 헷갈릴 때는 man, --help, tldr 같은 도움말로 옵션과 사용 예를 먼저 확인합니다.",
|
||
x: 1320,
|
||
y: 330,
|
||
},
|
||
{
|
||
id: "linux-sudo-basics",
|
||
parent: "linux-basics",
|
||
type: "term",
|
||
title: "관리자 작업",
|
||
subtitle: "기초 개념",
|
||
image: "imgs/linux_card_linux_sudo_basics.png",
|
||
category: "기초 개념",
|
||
focus: "시스템 설정, 패키지 설치, 서비스 제어처럼 영향 범위가 큰 작업은 관리자 권한이 필요합니다.",
|
||
x: 1290,
|
||
y: 150,
|
||
},
|
||
{
|
||
id: "linux-command-shape",
|
||
parent: "linux-shell",
|
||
type: "term",
|
||
title: "명령어 구조",
|
||
subtitle: "셸 명령어",
|
||
image: "imgs/linux_card_linux_command_shape.png",
|
||
category: "셸 명령어",
|
||
focus: "대부분의 명령은 command --option argument 형태이며, 옵션은 동작 방식을 바꾸고 인자는 대상을 지정합니다.",
|
||
x: 1530,
|
||
y: 380,
|
||
},
|
||
{
|
||
id: "linux-pwd-ls-cd",
|
||
parent: "linux-shell",
|
||
type: "term",
|
||
title: "pwd · ls · cd",
|
||
subtitle: "셸 명령어",
|
||
image: "imgs/linux_card_linux_pwd_ls_cd.png",
|
||
category: "셸 명령어",
|
||
focus: "pwd로 현재 위치를 보고, ls로 목록을 확인하고, cd로 디렉터리를 이동합니다.",
|
||
x: 1640,
|
||
y: 560,
|
||
},
|
||
{
|
||
id: "linux-file-ops",
|
||
parent: "linux-shell",
|
||
type: "term",
|
||
title: "cp · mv · rm",
|
||
subtitle: "셸 명령어",
|
||
image: "imgs/linux_card_linux_file_ops.png",
|
||
category: "셸 명령어",
|
||
focus: "cp는 복사, mv는 이동/이름 변경, rm은 삭제입니다. 삭제 명령은 되돌리기 어렵기 때문에 대상 경로를 먼저 확인합니다.",
|
||
x: 1720,
|
||
y: 410,
|
||
},
|
||
{
|
||
id: "linux-pipes",
|
||
parent: "linux-shell",
|
||
type: "term",
|
||
title: "파이프 · 리다이렉트",
|
||
subtitle: "셸 명령어",
|
||
image: "imgs/linux_card_linux_pipes.png",
|
||
category: "셸 명령어",
|
||
focus: "파이프(|)는 한 명령의 출력을 다음 명령으로 넘기고, > 또는 >>는 출력을 파일에 저장합니다.",
|
||
x: 1530,
|
||
y: 740,
|
||
},
|
||
{
|
||
id: "linux-grep-find",
|
||
parent: "linux-shell",
|
||
type: "term",
|
||
title: "grep · find",
|
||
subtitle: "셸 명령어",
|
||
image: "imgs/linux_card_linux_grep_find.png",
|
||
category: "셸 명령어",
|
||
focus: "grep은 텍스트 안에서 패턴을 찾고, find는 파일 시스템에서 조건에 맞는 파일과 디렉터리를 찾습니다.",
|
||
x: 1740,
|
||
y: 760,
|
||
},
|
||
{
|
||
id: "linux-history",
|
||
parent: "linux-shell",
|
||
type: "term",
|
||
title: "history",
|
||
subtitle: "셸 명령어",
|
||
image: "imgs/linux_card_linux_history.png",
|
||
category: "셸 명령어",
|
||
focus: "history와 방향키, Ctrl+R 검색을 쓰면 이전 명령을 빠르게 재사용할 수 있습니다.",
|
||
x: 1780,
|
||
y: 620,
|
||
},
|
||
{
|
||
id: "linux-env-path",
|
||
parent: "linux-shell",
|
||
type: "term",
|
||
title: "환경 변수 · PATH",
|
||
subtitle: "셸 명령어",
|
||
image: "imgs/linux_card_linux_env_path.png",
|
||
category: "셸 명령어",
|
||
focus: "환경 변수는 현재 셸과 프로그램에 전달되는 설정이고, PATH는 명령어 실행 파일을 찾는 디렉터리 목록입니다.",
|
||
x: 1470,
|
||
y: 240,
|
||
},
|
||
{
|
||
id: "linux-root-path",
|
||
parent: "linux-filesystem",
|
||
type: "term",
|
||
title: "루트 경로 /",
|
||
subtitle: "파일 시스템",
|
||
image: "imgs/linux_card_linux_root_path.png",
|
||
category: "파일 시스템",
|
||
focus: "리눅스의 모든 절대 경로는 루트(/)에서 시작합니다.",
|
||
x: 1510,
|
||
y: 810,
|
||
},
|
||
{
|
||
id: "linux-home-etc-var",
|
||
parent: "linux-filesystem",
|
||
type: "term",
|
||
title: "/home · /etc · /var",
|
||
subtitle: "파일 시스템",
|
||
image: "imgs/linux_card_linux_home_etc_var.png",
|
||
category: "파일 시스템",
|
||
focus: "/home은 사용자 파일, /etc는 시스템 설정, /var는 로그와 변하는 데이터를 주로 담습니다.",
|
||
x: 1600,
|
||
y: 940,
|
||
},
|
||
{
|
||
id: "linux-hidden-files",
|
||
parent: "linux-filesystem",
|
||
type: "term",
|
||
title: "숨김 파일",
|
||
subtitle: "파일 시스템",
|
||
image: "imgs/linux_card_linux_hidden_files.png",
|
||
category: "파일 시스템",
|
||
focus: ".bashrc처럼 점(.)으로 시작하는 파일은 기본 목록에서 숨겨지며, 보통 사용자 설정을 담습니다.",
|
||
x: 1460,
|
||
y: 1080,
|
||
},
|
||
{
|
||
id: "linux-mounts",
|
||
parent: "linux-filesystem",
|
||
type: "term",
|
||
title: "mount",
|
||
subtitle: "파일 시스템",
|
||
image: "imgs/linux_card_linux_mounts.png",
|
||
category: "파일 시스템",
|
||
focus: "디스크, USB, 네트워크 저장소는 특정 디렉터리에 마운트되어 파일 시스템 일부처럼 보입니다.",
|
||
x: 1190,
|
||
y: 1160,
|
||
},
|
||
{
|
||
id: "linux-links",
|
||
parent: "linux-filesystem",
|
||
type: "term",
|
||
title: "심볼릭 링크",
|
||
subtitle: "파일 시스템",
|
||
image: "imgs/linux_card_linux_links.png",
|
||
category: "파일 시스템",
|
||
focus: "심볼릭 링크는 다른 파일이나 디렉터리를 가리키는 바로가기이며 ln -s로 만듭니다.",
|
||
x: 1740,
|
||
y: 1040,
|
||
},
|
||
{
|
||
id: "linux-users-groups",
|
||
parent: "linux-permissions",
|
||
type: "term",
|
||
title: "사용자 · 그룹",
|
||
subtitle: "권한",
|
||
image: "imgs/linux_card_linux_users_groups.png",
|
||
category: "권한",
|
||
focus: "리눅스는 사용자와 그룹 단위로 파일 접근, 서비스 실행, 관리자 작업 가능 여부를 나눕니다.",
|
||
x: 760,
|
||
y: 1010,
|
||
},
|
||
{
|
||
id: "linux-rwx",
|
||
parent: "linux-permissions",
|
||
type: "term",
|
||
title: "rwx 권한",
|
||
subtitle: "권한",
|
||
image: "imgs/linux_card_linux_rwx.png",
|
||
category: "권한",
|
||
focus: "r은 읽기, w는 쓰기, x는 실행 권한입니다. 디렉터리의 x는 내부로 들어갈 수 있는 권한에 가깝습니다.",
|
||
x: 880,
|
||
y: 1280,
|
||
},
|
||
{
|
||
id: "linux-chmod-chown",
|
||
parent: "linux-permissions",
|
||
type: "term",
|
||
title: "chmod · chown",
|
||
subtitle: "권한",
|
||
image: "imgs/linux_card_linux_chmod_chown.png",
|
||
category: "권한",
|
||
focus: "chmod는 권한 비트를 바꾸고, chown은 파일의 소유자나 그룹을 바꿉니다.",
|
||
x: 1180,
|
||
y: 1300,
|
||
},
|
||
{
|
||
id: "linux-sudo",
|
||
parent: "linux-permissions",
|
||
type: "term",
|
||
title: "sudo",
|
||
subtitle: "권한",
|
||
image: "imgs/linux_card_linux_sudo.png",
|
||
category: "권한",
|
||
focus: "sudo는 허용된 사용자가 한 명령을 관리자 권한으로 실행하게 해 줍니다.",
|
||
x: 810,
|
||
y: 1500,
|
||
},
|
||
{
|
||
id: "linux-ssh-keys",
|
||
parent: "linux-permissions",
|
||
type: "term",
|
||
title: "SSH Key 권한",
|
||
subtitle: "권한",
|
||
image: "imgs/linux_card_linux_ssh_keys.png",
|
||
category: "권한",
|
||
focus: "개인 키는 너무 열려 있으면 SSH가 거부할 수 있으므로 보통 chmod 600처럼 제한적으로 둡니다.",
|
||
x: 1420,
|
||
y: 1210,
|
||
},
|
||
{
|
||
id: "linux-ps-top",
|
||
parent: "linux-processes",
|
||
type: "term",
|
||
title: "ps · top",
|
||
subtitle: "프로세스",
|
||
image: "imgs/linux_card_linux_ps_top.png",
|
||
category: "프로세스",
|
||
focus: "ps는 프로세스 목록을 출력하고, top/htop은 CPU와 메모리 사용량을 실시간으로 보여 줍니다.",
|
||
x: 390,
|
||
y: 820,
|
||
},
|
||
{
|
||
id: "linux-kill",
|
||
parent: "linux-processes",
|
||
type: "term",
|
||
title: "kill",
|
||
subtitle: "프로세스",
|
||
image: "imgs/linux_card_linux_kill.png",
|
||
category: "프로세스",
|
||
focus: "kill은 프로세스에 종료 신호를 보내며, 강제 종료 전에 정상 종료 신호를 먼저 고려합니다.",
|
||
x: 420,
|
||
y: 1080,
|
||
},
|
||
{
|
||
id: "linux-systemd",
|
||
parent: "linux-processes",
|
||
type: "term",
|
||
title: "systemd · service",
|
||
subtitle: "프로세스",
|
||
image: "imgs/linux_card_linux_systemd.png",
|
||
category: "프로세스",
|
||
focus: "systemctl로 서비스를 시작, 중지, 재시작, 자동 실행 설정할 수 있습니다.",
|
||
x: 260,
|
||
y: 940,
|
||
},
|
||
{
|
||
id: "linux-logs",
|
||
parent: "linux-processes",
|
||
type: "term",
|
||
title: "로그",
|
||
subtitle: "프로세스",
|
||
image: "imgs/linux_card_linux_logs.png",
|
||
category: "프로세스",
|
||
focus: "journalctl과 /var/log는 서비스 오류와 시스템 이벤트를 추적하는 첫 번째 단서입니다.",
|
||
x: 200,
|
||
y: 1190,
|
||
},
|
||
{
|
||
id: "linux-cron",
|
||
parent: "linux-processes",
|
||
type: "term",
|
||
title: "cron",
|
||
subtitle: "프로세스",
|
||
image: "imgs/linux_card_linux_cron.png",
|
||
category: "프로세스",
|
||
focus: "cron은 정해진 시간에 백업, 정리, 점검 스크립트를 반복 실행할 때 사용합니다.",
|
||
x: 610,
|
||
y: 1190,
|
||
},
|
||
{
|
||
id: "linux-apt-dnf",
|
||
parent: "linux-packages-network",
|
||
type: "term",
|
||
title: "apt · dnf",
|
||
subtitle: "패키지·네트워크",
|
||
image: "imgs/linux_card_linux_apt_dnf.png",
|
||
category: "패키지·네트워크",
|
||
focus: "apt는 Debian/Ubuntu 계열, dnf는 Fedora/RHEL 계열에서 패키지를 설치하고 제거하는 대표 도구입니다.",
|
||
x: 560,
|
||
y: 310,
|
||
},
|
||
{
|
||
id: "linux-update-upgrade",
|
||
parent: "linux-packages-network",
|
||
type: "term",
|
||
title: "update · upgrade",
|
||
subtitle: "패키지·네트워크",
|
||
image: "imgs/linux_card_linux_update_upgrade.png",
|
||
category: "패키지·네트워크",
|
||
focus: "update는 패키지 목록을 새로 받고, upgrade는 설치된 패키지를 새 버전으로 올립니다.",
|
||
x: 390,
|
||
y: 470,
|
||
},
|
||
{
|
||
id: "linux-ping-ss",
|
||
parent: "linux-packages-network",
|
||
type: "term",
|
||
title: "ping · ss",
|
||
subtitle: "패키지·네트워크",
|
||
image: "imgs/linux_card_linux_ping_ss.png",
|
||
category: "패키지·네트워크",
|
||
focus: "ping은 연결 가능성을 확인하고, ss는 열린 포트와 소켓 상태를 확인합니다.",
|
||
x: 780,
|
||
y: 420,
|
||
},
|
||
{
|
||
id: "linux-curl-wget",
|
||
parent: "linux-packages-network",
|
||
type: "term",
|
||
title: "curl · wget",
|
||
subtitle: "패키지·네트워크",
|
||
image: "imgs/linux_card_linux_curl_wget.png",
|
||
category: "패키지·네트워크",
|
||
focus: "curl은 HTTP 요청과 응답 확인에 강하고, wget은 파일 다운로드에 자주 쓰입니다.",
|
||
x: 350,
|
||
y: 650,
|
||
},
|
||
{
|
||
id: "linux-ssh",
|
||
parent: "linux-packages-network",
|
||
type: "term",
|
||
title: "ssh",
|
||
subtitle: "패키지·네트워크",
|
||
image: "imgs/linux_card_linux_ssh.png",
|
||
category: "패키지·네트워크",
|
||
focus: "ssh는 원격 서버의 셸에 안전하게 접속하는 기본 도구입니다.",
|
||
x: 780,
|
||
y: 660,
|
||
},
|
||
{
|
||
id: "linux-firewall",
|
||
parent: "linux-packages-network",
|
||
type: "term",
|
||
title: "방화벽",
|
||
subtitle: "패키지·네트워크",
|
||
image: "imgs/linux_card_linux_firewall.png",
|
||
category: "패키지·네트워크",
|
||
focus: "ufw, firewalld, iptables 같은 도구로 외부에서 접근 가능한 포트와 규칙을 제한합니다.",
|
||
x: 480,
|
||
y: 210,
|
||
},
|
||
];
|
||
|
||
const maps = {
|
||
web: {
|
||
eyebrow: "Web Learning Map",
|
||
title: "웹 개발 용어 마인드맵",
|
||
ariaLabel: "접을 수 있는 웹 개발 용어 지도",
|
||
nodes: webNodes,
|
||
},
|
||
linux: {
|
||
eyebrow: "Linux Learning Map",
|
||
title: "리눅스 기초 마인드맵",
|
||
ariaLabel: "접을 수 있는 리눅스 기초 개념 지도",
|
||
nodes: linuxNodes,
|
||
},
|
||
};
|
||
|
||
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 pageTitle = document.querySelector("#page-title");
|
||
const mapEyebrow = document.querySelector("#mapEyebrow");
|
||
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");
|
||
|
||
let activeMapId = "web";
|
||
let nodes = maps[activeMapId].nodes;
|
||
let 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 syncMapChrome() {
|
||
const activeMap = maps[activeMapId];
|
||
pageTitle.textContent = activeMap.title;
|
||
mapEyebrow.textContent = activeMap.eyebrow;
|
||
nodeLayer.setAttribute("aria-label", activeMap.ariaLabel);
|
||
for (const button of document.querySelectorAll(".map-tab")) {
|
||
const isSelected = button.dataset.map === activeMapId;
|
||
button.classList.toggle("is-selected", isSelected);
|
||
button.setAttribute("aria-pressed", String(isSelected));
|
||
}
|
||
}
|
||
|
||
function switchMap(mapId) {
|
||
if (!maps[mapId] || mapId === activeMapId) return;
|
||
activeMapId = mapId;
|
||
nodes = maps[activeMapId].nodes;
|
||
nodeMap = new Map(nodes.map((node) => [node.id, node]));
|
||
expanded.clear();
|
||
syncMapChrome();
|
||
resetView();
|
||
render();
|
||
}
|
||
|
||
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);
|
||
for (const button of document.querySelectorAll(".map-tab")) {
|
||
button.addEventListener("click", () => switchMap(button.dataset.map));
|
||
}
|
||
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();
|
||
syncMapChrome();
|
||
render();
|