// 개념 우주 메인 화면: 우주(단원) → 은하(절) → 별자리(용어) 탐색, 검색, 상세, 경로 찾기, 비교. import { api, esc, toast, STATUS_LABEL, REL_TYPE_LABEL, METHOD_LABEL, debounce } from "./common.js"; import { buildIndex, search, initialGroup } from "./search.js"; import { universeLayout, sectionLayout, fit, cullLabels, labelWidth, shortestPath } from "./layout.js"; const $ = (s, el = document) => el.querySelector(s); const $$ = (s, el = document) => [...el.querySelectorAll(s)]; const NS = "http://www.w3.org/2000/svg"; const S = { data: null, byId: new Map(), galaxyById: new Map(), sectionById: new Map(), sectionsByGalaxy: new Map(), termsBySection: new Map(), termsByGalaxy: new Map(), adj: new Map(), degree: new Map(), index: null, route: { name: "universe" }, view: null, // {kind, id} selected: null, mode: "map", cam: { s: 1, tx: 0, ty: 0 }, world: null, // 연속 우주 좌표·요소 level: "universe", path: null, // {ids:Set, rels:Set, steps} detailCache: new Map(), }; const map = $(".map"); const objects = $(".objects"); const linksG = $(".links-g"); const svg = $(".links"); const detail = $("#detail"); // ------------------------------------------------------------------ 데이터 async function load() { stars(); try { [S.data, S.member] = await Promise.all([api("/api/atlas"), api("/api/members/me").catch(() => ({ can_view: false }))]); const acc = $("[data-account]"); if (S.member.logged_in) { acc.textContent = `${S.member.name} 님`; acc.href = "/account"; } if (S.member.admin) { // 교수자에게만 관리 메뉴 acc.insertAdjacentHTML("beforebegin", `회원 관리관리`); if (!S.member.logged_in) acc.remove(); } } catch (e) { $(".map-loading").textContent = "데이터를 불러오지 못했습니다. 잠시 후 새로고침해 주세요. (" + e.message + ")"; throw e; } const d = S.data; for (const g of d.galaxies) { S.galaxyById.set(g.galaxy_id, g); S.sectionsByGalaxy.set(g.galaxy_id, []); S.termsByGalaxy.set(g.galaxy_id, []); } for (const s of d.sections) { S.sectionById.set(s.id, s); if (S.sectionsByGalaxy.has(s.galaxy_id)) S.sectionsByGalaxy.get(s.galaxy_id).push(s); S.termsBySection.set(s.id, []); } for (const t of d.terms) { S.byId.set(t.i, t); S.adj.set(t.i, []); if (S.termsByGalaxy.has(t.g)) S.termsByGalaxy.get(t.g).push(t); if (S.termsBySection.has(t.s)) S.termsBySection.get(t.s).push(t); } for (const r of d.relations) { S.adj.get(r.s)?.push(r); S.adj.get(r.t)?.push(r); } for (const [id, list] of S.adj) S.degree.set(id, list.length); // 용어가 없는 절은 숨김 for (const [gid, secs] of S.sectionsByGalaxy) { S.sectionsByGalaxy.set(gid, secs.filter((s) => (S.termsBySection.get(s.id) || []).length)); } S.index = buildIndex(d.terms); buildWorld(); $(".map-loading").remove(); const gen = (d.meta.generated_at || "").slice(0, 10); $(".footer-data").textContent = `용어 ${d.terms.length.toLocaleString()}개 · 관계 ${d.relations.length.toLocaleString()}개` + (gen ? ` · 데이터 ${gen}` : ""); } const glow = (gid) => S.galaxyById.get(gid)?.color || "#c2b4ff"; const termGalaxy = (t) => S.galaxyById.get(t.g); const statusBadge = (st) => (S.data.meta.show_status_badges && st && st !== "confirmed" ? `${STATUS_LABEL[st] || st}` : ""); const announce = (msg) => { $("#announce").textContent = msg; }; function stars() { const field = $(".starfield"); let seed = 17; const next = () => ((seed = (seed * 16807) % 2147483647) / 2147483647); for (let i = 0; i < 170; i++) { const dot = document.createElement("i"); dot.className = "spark" + (i % 16 === 0 ? " big" : ""); dot.style.left = next() * 100 + "%"; dot.style.top = next() * 100 + "%"; dot.style.opacity = 0.1 + next() * 0.4; field.appendChild(dot); } } // ------------------------------------------------------------------ 라우팅 function parseHash() { const parts = location.hash.replace(/^#\/?/, "").split("/").filter(Boolean).map(decodeURIComponent); const [name, a, b] = parts; if (name === "g" && a) return { name: "galaxy", id: a }; if (name === "s" && a) return { name: "section", id: a }; if (name === "t" && a) return { name: "term", id: a }; if (name === "path") return { name: "path", a, b }; if (name === "compare") return { name: "compare", a, b }; if (name === "search") return { name: "search", q: a || "" }; if (name === "quiz") return { name: "quiz", wrong: a === "wrong", galaxy: a && a !== "wrong" ? a : null }; return { name: "universe" }; } let flyOnRoute = true; let clearOnRoute = false; export function go(hash, { fly = true, clear = false } = {}) { flyOnRoute = fly; clearOnRoute = clear; if (location.hash === hash) onRoute(); else location.hash = hash; } function onRoute() { const r = (S.route = parseHash()); const fly = flyOnRoute; const clear = clearOnRoute; flyOnRoute = true; clearOnRoute = false; let view = S.view; // 선택한 별은 지도를 옮겨도 유지 (빈 곳 클릭·Esc·선택 해제로만 풀림) if (clear || !["universe", "galaxy", "section", "quiz"].includes(r.name)) S.selected = null; if (r.name === "universe") view = { kind: "universe" }; else if (r.name === "galaxy") view = S.galaxyById.has(r.id) ? { kind: "galaxy", id: r.id } : { kind: "universe" }; else if (r.name === "section") view = S.sectionById.has(r.id) ? { kind: "section", id: r.id } : { kind: "universe" }; else if (r.name === "term") { const t = S.byId.get(r.id); if (!t) { toast("이 용어는 아직 공개되지 않았거나 존재하지 않습니다.", true); view = { kind: "universe" }; } else { S.selected = t.i; view = t.s && S.sectionById.has(t.s) ? { kind: "section", id: t.s } : { kind: "galaxy", id: t.g }; pushTrail(t.i); } } else if (r.name === "path" || r.name === "compare") { const t = S.byId.get(r.a); if (t) { S.selected = t.i; if (!view || view.kind === "universe") view = { kind: "section", id: t.s }; } } if (!view) view = { kind: "universe" }; S.path = null; if (r.name === "path" && r.a && r.b) computePath(r.a, r.b); S.view = view; if (S.mode === "list") renderList(); else if (fly) flyToRoute(r); else draw(); renderPanel(); renderCrumbs(); renderTrail(); if (r.name === "compare" && r.a && r.b) openCompare(r.a, r.b); $$(".topnav a").forEach((a) => { const href = a.getAttribute("href"); const cur = (r.name === "path" && href === "/#/path") || (r.name === "compare" && href === "/#/compare") || (!["path", "compare"].includes(r.name) && href === "/#/"); if (cur) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current"); }); $("[data-feedback-link]").href = "/feedback?page=" + encodeURIComponent(location.href) + (S.selected ? "&term=" + S.selected : ""); } // ------------------------------------------------------------------ 연속 우주 (확대할수록 세부로) // // 모든 은하·절·용어를 하나의 좌표계에 배치하고, 화면 배율에 따라 보여 줄 단계를 정한다. // 멀리: 은하(행성) + 용어 점 → 은하가 커지면 별자리(절) 원 → 별자리가 커지면 용어 별과 이름 const LOD = { galaxyOpen: 190, sectionOpen: 150, sectionText: 46, itemLabels: 330 }; function buildWorld() { const W = { gal: new Map(), sec: new Map(), term: new Map(), bounds: null }; // 1) 절마다 용어 배치 (자연 배율: 1 = 별 간격 약 25px) for (const [gid, secs] of S.sectionsByGalaxy) { for (const sec of secs) { const terms = S.termsBySection.get(sec.id) || []; const byItem = new Map(sec.items.map((it) => [it.id, { id: it.id, title: `${it.number}. ${it.title}`, terms: [] }])); const other = { id: "_other", title: "그 밖의 용어", terms: [] }; for (const t of terms) (byItem.get(t.it) || other).terms.push(t); const used = [...byItem.values(), other].filter((g) => g.terms.length); for (const g of used) g.terms.sort((a, b) => S.degree.get(b.i) - S.degree.get(a.i) || a.n.localeCompare(b.n, "ko")); const L = sectionLayout(used); const b = L.bounds; const cx = (b.minX + b.maxX) / 2, cy = (b.minY + b.maxY) / 2; const E = Math.max(b.maxX - b.minX, b.maxY - b.minY) / 2; W.sec.set(sec.id, { sec, gid, L, cx, cy, E, used, x: 0, y: 0, mode: "hidden", itemEls: null }); } } // 2) 은하 안에서 절을 원형으로 for (const g of S.data.galaxies) { const secs = (S.sectionsByGalaxy.get(g.galaxy_id) || []).map((s) => W.sec.get(s.id)); const gap = 60; let R = 0, maxr = 0; if (secs.length === 1) { maxr = secs[0].E; secs[0].ox = 0; secs[0].oy = 0; } else if (secs.length) { maxr = Math.max(...secs.map((s) => s.E)); const circ = secs.reduce((a, s) => a + 2 * s.E + gap, 0); R = Math.max(circ / (2 * Math.PI), maxr * 1.15); let angle = -Math.PI / 2; for (const s of secs) { const share = ((2 * s.E + gap) / circ) * Math.PI * 2; const a = angle + share / 2; s.ox = Math.cos(a) * R; s.oy = Math.sin(a) * R; angle += share; } } W.gal.set(g.galaxy_id, { g, secs, E: R + maxr + 40, x: 0, y: 0, mode: "planet" }); } // 3) 우주: 고정 슬롯을 은하 크기에 맞게 벌림 const slots = universeLayout(S.data.galaxies).pos; const ids = [...W.gal.keys()]; let U = 1; for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) { const a = slots[ids[i]], b = slots[ids[j]]; const d = Math.hypot(a.x - b.x, a.y - b.y) || 1; U = Math.max(U, (1.12 * (W.gal.get(ids[i]).E + W.gal.get(ids[j]).E)) / d); } let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (const [gid, G] of W.gal) { G.x = slots[gid].x * U; G.y = slots[gid].y * U; minX = Math.min(minX, G.x - G.E); maxX = Math.max(maxX, G.x + G.E); minY = Math.min(minY, G.y - G.E); maxY = Math.max(maxY, G.y + G.E); for (const s of G.secs) { s.x = G.x + s.ox; s.y = G.y + s.oy; for (const grp of s.used) for (const t of grp.terms) { const p = s.L.pos[t.i]; W.term.set(t.i, { t, x: s.x + p.x - s.cx, y: s.y + p.y - s.cy, sec: s, el: null, shown: false }); } } } W.bounds = { minX, maxX, minY, maxY }; S.world = W; createWorldElements(); } function createWorldElements() { const W = S.world; objects.replaceChildren(); for (const [gid, G] of W.gal) { const g = G.g; const b = document.createElement("button"); b.type = "button"; b.className = "galaxy"; b.style.setProperty("--glow", g.color); b.innerHTML = `${esc(g.name)}${esc(g.sub || "")}${g.chapter_no}장 · 용어 ${g.count}개`; b.setAttribute("aria-label", `${g.chapter_no}장 ${g.name}: ${g.sub || ""}. 용어 ${g.count}개`); b.addEventListener("click", () => go("#/g/" + gid)); objects.appendChild(b); G.el = b; const lab = document.createElement("div"); lab.className = "galaxy-title"; lab.style.setProperty("--glow", g.color); lab.innerHTML = `${esc(g.name)}${esc(g.sub || "")}`; objects.appendChild(lab); G.labelEl = lab; } for (const [sid, s] of W.sec) { const sec = s.sec; const g = S.galaxyById.get(s.gid); const n = (S.termsBySection.get(sid) || []).length; const sample = s.used.flatMap((x) => x.terms).sort((a, b) => S.degree.get(b.i) - S.degree.get(a.i)).slice(0, 3).map((t) => t.n).join(", "); const b = document.createElement("button"); b.type = "button"; b.className = "cluster"; b.style.setProperty("--glow", g.color); b.innerHTML = `${esc(sec.number)}절 ${esc(sec.title)}${esc(sec.easy_sub || ("예: " + sample))}용어 ${n}개`; b.setAttribute("aria-label", `${g.name} ${sec.number}절 ${sec.title}, 용어 ${n}개. 예: ${sample}`); b.addEventListener("click", () => go("#/s/" + sid)); objects.appendChild(b); s.el = b; const title = document.createElement("a"); title.className = "sec-title"; title.href = "#/s/" + sid; title.style.setProperty("--glow", g.color); title.textContent = `${sec.number}절 ${sec.title}`; objects.appendChild(title); s.titleEl = title; } } function starEl(t) { const b = document.createElement("button"); b.type = "button"; b.className = "star" + (t.k === "person" ? " person" : ""); b.style.setProperty("--glow", glow(t.g)); b.dataset.term = t.i; const st = t.st !== "confirmed" && S.data.meta.show_status_badges ? ` (${STATUS_LABEL[t.st]})` : ""; b.setAttribute("aria-label", `${t.n}${st}, ${termGalaxy(t)?.name || ""}`); b.innerHTML = `${esc(t.n)}`; b.addEventListener("click", (e) => { e.stopPropagation(); go("#/t/" + t.i, { fly: false }); }); return b; } function shortLabel(r) { const base = REL_TYPE_LABEL[r.ty] || "관련"; if (r.m === "wireframe" || r.m === "manual" || r.m === "curated") return r.l && r.l.length <= 16 ? r.l : base; return base; } // ------------------------------------------------------------------ 카메라 const toScreen = (x, y) => [x * S.cam.s + S.cam.tx, y * S.cam.s + S.cam.ty]; function camFor(bounds, pad = 30) { const w = map.clientWidth, h = map.clientHeight; const c = fit(bounds, w, h - 50, { max: 6, min: 0.005, margin: pad }); c.ty += 25; return c; } function boundsOf(x, y, r) { return { minX: x - r, maxX: x + r, minY: y - r, maxY: y + r }; } function minScale() { return camFor(S.world.bounds).s * 0.6; } function targetCam(r) { const W = S.world; const w = map.clientWidth, h = map.clientHeight; const sel = ["term", "path", "compare"].includes(r?.name) && S.selected && W.term.get(S.selected); if (sel) { // 별 이름이 읽히고 그 절이 펼쳐질 만큼 확대, 이미 충분하면 배율 유지 const need = Math.max(0.95, (LOD.sectionOpen * 1.3) / sel.sec.E); const s = Math.max(S.cam.s, need); return { s, tx: w / 2 - sel.x * s, ty: h / 2 - sel.y * s }; } const v = S.view; if (v.kind === "section") { const s = W.sec.get(v.id); if (s) return camFor(boundsOf(s.x, s.y, s.E)); } if (v.kind === "galaxy") { const G = W.gal.get(v.id); if (G) return camFor(boundsOf(G.x, G.y, G.E)); } return camFor(W.bounds); } let anim = null; function flyTo(cam, ms = 520) { cancelAnimationFrame(anim); const from = { ...S.cam }; const w = map.clientWidth / 2, h = map.clientHeight / 2; // 화면 중앙의 세계 좌표를 보간하고 배율은 로그로 보간 const c0 = { x: (w - from.tx) / from.s, y: (h - from.ty) / from.s }; const c1 = { x: (w - cam.tx) / cam.s, y: (h - cam.ty) / cam.s }; const t0 = performance.now(); const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const step = (now) => { let k = reduce ? 1 : Math.min(1, (now - t0) / ms); k = k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2; const s = Math.exp(Math.log(from.s) + (Math.log(cam.s) - Math.log(from.s)) * k); const x = c0.x + (c1.x - c0.x) * k, y = c0.y + (c1.y - c0.y) * k; S.cam = { s, tx: w - x * s, ty: h - y * s }; draw(); if (k < 1) anim = requestAnimationFrame(step); else updateContext(); }; anim = requestAnimationFrame(step); } function flyToRoute(r) { if (!S.world) return; const sel = S.selected && S.world.term.get(S.selected); if (sel && r.name === "term") { // 이미 보이는 별을 눌렀으면 움직이지 않음 const [sx, sy] = toScreen(sel.x, sel.y); const ok = S.cam.s >= 0.85 && sel.sec.mode === "open" && sx > 80 && sx < map.clientWidth - 80 && sy > 90 && sy < map.clientHeight - 50; if (ok) { draw(); return; } } flyTo(targetCam(r)); } function zoomAt(factor, px, py) { cancelAnimationFrame(anim); const s0 = S.cam.s; const s1 = Math.min(6, Math.max(minScale(), s0 * factor)); S.cam.tx = px - ((px - S.cam.tx) * s1) / s0; S.cam.ty = py - ((py - S.cam.ty) * s1) / s0; S.cam.s = s1; draw(); scheduleContext(); } // ------------------------------------------------------------------ 그리기 const dotsCanvas = (() => { const c = document.createElement("canvas"); c.className = "dots"; c.setAttribute("aria-hidden", "true"); map.insertBefore(c, svg); return c; })(); let drawQueued = false; function draw() { if (drawQueued) return; drawQueued = true; requestAnimationFrame(() => { drawQueued = false; drawNow(); }); } function drawNow() { const W = S.world; if (!W || S.mode !== "map") return; const { s } = S.cam; const w = map.clientWidth, h = map.clientHeight; const margin = 80; const inView = (x, y, r = 0) => x + r > -margin && x - r < w + margin && y + r > -margin && y - r < h + margin; svg.setAttribute("viewBox", `0 0 ${w} ${h}`); // 캔버스: 펼쳐지지 않은 절의 용어를 작은 점으로 const dpr = window.devicePixelRatio || 1; if (dotsCanvas.width !== Math.round(w * dpr) || dotsCanvas.height !== Math.round(h * dpr)) { dotsCanvas.width = Math.round(w * dpr); dotsCanvas.height = Math.round(h * dpr); } const ctx = dotsCanvas.getContext("2d"); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); let level = "universe"; for (const [, G] of W.gal) { const [gx, gy] = toScreen(G.x, G.y); const rpx = G.E * s; const open = rpx >= LOD.galaxyOpen; G.mode = open ? "open" : "planet"; if (open && inView(gx, gy, rpx)) level = level === "universe" ? "galaxy" : level; const visible = inView(gx, gy, rpx); G.el.hidden = open || !visible; if (!G.el.hidden) G.el.style.transform = `translate(calc(${gx}px - 50%), ${gy - 18}px)`; const ty = gy - rpx + 10; G.labelEl.hidden = !open || !visible || ty < 48 || gx < 0 || gx > w; if (!G.labelEl.hidden) G.labelEl.style.transform = `translate(calc(${gx}px - 50%), ${ty}px)`; for (const sec of G.secs) { const [sx, sy] = toScreen(sec.x, sec.y); const spx = sec.E * s; const secVisible = open && inView(sx, sy, spx); const secOpen = secVisible && spx >= LOD.sectionOpen; sec.mode = !secVisible ? (open ? "offscreen" : "hidden") : secOpen ? "open" : "cluster"; if (secOpen) level = "section"; // 절 원(접힌 상태) sec.el.hidden = sec.mode !== "cluster"; if (!sec.el.hidden) { const d = spx * 2; sec.el.style.width = d + "px"; sec.el.style.height = d + "px"; sec.el.style.transform = `translate(${sx - d / 2}px, ${sy - d / 2}px)`; sec.el.classList.toggle("tiny", spx < LOD.sectionText); } sec.titleEl.hidden = sec.mode !== "open"; if (!sec.titleEl.hidden) sec.titleEl.style.transform = `translate(calc(${sx}px - 50%), ${sy - spx - 4}px)`; // 항목(소주제) 라벨 const showItems = sec.mode === "open" && spx >= LOD.itemLabels && sec.used.length > 1; if (showItems && !sec.itemEls) { sec.itemEls = sec.used.map((grp) => { const el = document.createElement("div"); el.className = "item-label"; el.style.setProperty("--glow", glow(sec.gid)); el.textContent = grp.title; el.title = grp.title; objects.appendChild(el); return { el, c: sec.L.centers[grp.id] }; }); } if (sec.itemEls) for (const it of sec.itemEls) { it.el.hidden = !showItems; if (showItems) { const [ix, iy] = toScreen(sec.x + it.c.x - sec.cx, sec.y + it.c.y - sec.cy - it.c.r); it.el.style.transform = `translate(calc(${ix}px - 50%), ${iy - 20}px)`; } } // 점(펼쳐지지 않은 절) if (sec.mode !== "open" && inView(...toScreen(sec.x, sec.y), sec.E * s)) { ctx.fillStyle = S.galaxyById.get(sec.gid).color; ctx.globalAlpha = open ? 0.55 : 0.45; const r = Math.max(0.8, Math.min(2, s * 3)); for (const grp of sec.used) for (const t of grp.terms) { const p = W.term.get(t.i); const [x, y] = toScreen(p.x, p.y); if (x < -5 || x > w + 5 || y < -5 || y > h + 5) continue; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); } } } } ctx.globalAlpha = 1; S.level = level; // 별(펼쳐진 절의 용어) const sel = S.selected; const neighbors = new Set(); if (sel) for (const r of S.adj.get(sel) || []) neighbors.add(r.s === sel ? r.t : r.s); const shown = []; for (const [id, p] of W.term) { const [x, y] = toScreen(p.x, p.y); const vis = p.sec.mode === "open" && x > -40 && x < w + 40 && y > -30 && y < h + 30; if (vis) { if (!p.el) { p.el = starEl(p.t); objects.appendChild(p.el); } p.el.hidden = false; p.el.style.transform = `translate(calc(${x}px - 50%), ${y - 6}px)`; p.sx = x; p.sy = y; const cls = "star" + (p.t.k === "person" ? " person" : "") + (id === sel ? " selected" : neighbors.has(id) ? " nb" : sel ? " dim" : "") + (S.path?.ids.has(id) ? " on-path" : "") + (p.hideLabel && id !== sel ? " hide-label" : ""); if (p.el.className !== cls) p.el.className = cls; p.el.setAttribute("aria-pressed", String(id === sel)); shown.push(p); } else if (p.el && !p.el.hidden) { p.el.hidden = true; } p.shown = vis; } // 선택한 별은 절이 접혀 있어도 표시 if (sel && W.term.has(sel) && !W.term.get(sel).shown) { const p = W.term.get(sel); const [x, y] = toScreen(p.x, p.y); if (x > -40 && x < w + 40 && y > -30 && y < h + 30) { if (!p.el) { p.el = starEl(p.t); objects.appendChild(p.el); } p.el.hidden = false; p.el.className = "star selected" + (p.t.k === "person" ? " person" : ""); p.el.style.transform = `translate(calc(${x}px - 50%), ${y - 6}px)`; p.sx = x; p.sy = y; p.shown = true; shown.push(p); } } drawLinks(shown); scheduleLabels(shown); $(".map-hint").textContent = { universe: "휠·두 손가락으로 확대하면 은하 → 별자리(절) → 별(용어)로 들어갑니다", galaxy: "더 확대하면 별자리(절) 안의 용어 별이 펼쳐집니다", section: "별을 누르면 설명과 연결된 개념을 봅니다 · 이름이 숨은 별은 더 확대하세요", }[level]; } // 관계 그리기: 각 용어를 '지금 화면에서 보이는 모습'(별·별자리 원·은하 행성)에 붙여 연결한다. // 상대가 화면 밖이면 별에서 짧은 꼬리선을 내고, 화면 가장자리에 은하(또는 절)별 연결 표지를 둔다. function anchorOf(id) { const W = S.world; const p = W.term.get(id); if (!p) return null; if (p.shown) return { key: "t:" + id, x: p.sx, y: p.sy, kind: "star", gid: p.t.g }; const sec = p.sec; if (sec.mode === "cluster") { const [x, y] = toScreen(sec.x, sec.y); return { key: "s:" + sec.sec.id, x, y, kind: "sec", gid: sec.gid, r: sec.E * S.cam.s }; } const G = W.gal.get(p.t.g); if (G.mode === "planet" && !G.el.hidden) { const [x, y] = toScreen(G.x, G.y); return { key: "g:" + p.t.g, x, y, kind: "gal", gid: p.t.g }; } return null; } function drawLinks(shown) { const W = S.world; const sel = S.selected; const w = map.clientWidth, h = map.clientHeight; const onScreen = (x, y) => x > 8 && x < w - 8 && y > 50 && y < h - 8; const plain = []; // 별 ↔ 별 개별 관계 const bundles = new Map(); // 묶음선: 앵커쌍 → {a, b, n} const outward = new Map(); // 화면 밖 대상: 그룹 → {gid, label, n, names, wx, wy, fly} const stubs = []; const selOff = []; // 선택한 별의 화면 밖 이웃 const anchors = new Map(); const A = (id) => { if (!anchors.has(id)) anchors.set(id, anchorOf(id)); return anchors.get(id); }; for (const r of S.data.relations) { const isSel = sel && (r.s === sel || r.t === sel); const a = A(r.s), b = A(r.t); if (isSel) { const other = r.s === sel ? r.t : r.s; const o = W.term.get(other); if (o && !(o.shown && onScreen(o.sx, o.sy))) selOff.push({ r, o }); continue; // 선택한 별의 관계는 아래에서 실제 위치까지 강조선으로 } if (a && b) { if (a.key === b.key) continue; if (a.kind === "star" && b.kind === "star") { plain.push(r); continue; } const k = a.key < b.key ? a.key + "|" + b.key : b.key + "|" + a.key; const e = bundles.get(k) || { a, b, n: 0 }; e.n++; bundles.set(k, e); continue; } if (!a && !b) continue; // 한쪽만 보임 → 화면 밖으로 나가는 연결 const src = a || b; const tgtId = a ? r.t : r.s; const T = W.term.get(tgtId); if (!T) continue; const otherGalaxy = T.t.g !== src.gid; const gkey = otherGalaxy ? "g:" + T.t.g : "s:" + T.sec.sec.id; let o = outward.get(gkey); if (!o) { const G = W.gal.get(T.t.g); o = otherGalaxy ? { gid: T.t.g, label: G.g.name, n: 0, names: new Set(), wx: G.x, wy: G.y, fly: "#/g/" + T.t.g } : { gid: T.t.g, label: `${T.sec.sec.number}절 ${T.sec.sec.title}`, n: 0, names: new Set(), wx: T.sec.x, wy: T.sec.y, fly: "#/s/" + T.sec.sec.id }; outward.set(gkey, o); } o.n++; if (o.names.size < 8) o.names.add(T.t.n); if (src.kind === "star" && stubs.length < 400) stubs.push({ src, T }); } // ---- SVG: 매 프레임 다시 그림 (선 수가 많지 않음) let html = ""; const qpath = (ax, ay, bx, by, bend, back = 0) => { const mx = (ax + bx) / 2, my = (ay + by) / 2; const cx = mx - (by - ay) * bend, cy = my + (bx - ax) * bend; const len = Math.hypot(bx - cx, by - cy) || 1; return { d: `M ${ax.toFixed(1)} ${ay.toFixed(1)} Q ${cx.toFixed(1)} ${cy.toFixed(1)} ${(bx - ((bx - cx) / len) * back).toFixed(1)} ${(by - ((by - cy) / len) * back).toFixed(1)}`, cx, cy }; }; for (const [, e] of bundles) { const { d } = qpath(e.a.x, e.a.y, e.b.x, e.b.y, e.a.kind === "gal" && e.b.kind === "gal" ? 0.06 : 0.1); const wdt = Math.min(6, 0.8 + Math.log2(e.n + 1) * 0.9); const cross = e.a.gid !== e.b.gid; html += ``; } for (const r of plain) { const a = anchors.get(r.s), b = anchors.get(r.t); const arrow = r.d && r.ty !== "contrasts_with" && r.ty !== "related_to"; const cross = a.gid !== b.gid; const onPath = S.path?.rels.has(r.i); const { d } = qpath(a.x, a.y, b.x, b.y, 0.12, arrow ? 9 : 0); html += ``; } for (const { src, T } of stubs) { const [tx, ty] = toScreen(T.x, T.y); const dx = tx - src.x, dy = ty - src.y, len = Math.hypot(dx, dy) || 1; const L = 24; html += ``; } // 선택한 별: 모든 관계를 실제 위치까지 강조 let labels = 0; if (sel && W.term.get(sel)) { const me = W.term.get(sel); const [mx, my] = me.shown ? [me.sx, me.sy] : toScreen(me.x, me.y); for (const r of S.adj.get(sel) || []) { const o = W.term.get(r.s === sel ? r.t : r.s); if (!o) continue; const [ox, oy] = o.shown ? [o.sx, o.sy] : toScreen(o.x, o.y); const [ax, ay, bx, by] = r.s === sel ? [mx, my, ox, oy] : [ox, oy, mx, my]; const arrow = r.d && r.ty !== "contrasts_with" && r.ty !== "related_to"; const pth = qpath(ax, ay, bx, by, 0.12, arrow ? 9 : 0); const onPath = S.path?.rels.has(r.i); html += ``; if (labels < 12) { const k1 = r.s === sel ? 0.3 : 0.7; const qx = (1 - k1) ** 2 * ax + 2 * (1 - k1) * k1 * pth.cx + k1 ** 2 * bx; const qy = (1 - k1) ** 2 * ay + 2 * (1 - k1) * k1 * pth.cy + k1 ** 2 * by; html += `${esc(shortLabel(r))}`; labels++; } } } // 경로 찾기: 경로 위 관계 강조 if (S.path?.steps) for (const st of S.path.steps) { const a = W.term.get(st.rel.s), b = W.term.get(st.rel.t); if (!a || !b || (st.rel.s === sel || st.rel.t === sel)) continue; const [ax, ay] = a.shown ? [a.sx, a.sy] : toScreen(a.x, a.y); const [bx, by] = b.shown ? [b.sx, b.sy] : toScreen(b.x, b.y); html += ``; } linksG.innerHTML = html; // ---- 화면 가장자리 표지 for (const el of objects.querySelectorAll(".edge-chip")) el.remove(); const placed = []; // 절·은하 제목과 위쪽 도구 막대는 가리지 않도록 장애물로 등록 const mrect = map.getBoundingClientRect(); const compact = w < 600; const topY = Math.max(62, ...[...document.querySelectorAll(".map-bar > *")].map((el) => el.getBoundingClientRect().bottom - mrect.top + 18)); for (const el of [...objects.querySelectorAll(".sec-title:not([hidden]), .galaxy-title:not([hidden])"), ...document.querySelectorAll(".map-bar > *")]) { const r = el.getBoundingClientRect(); placed.push({ x: r.left - mrect.left + r.width / 2, y: r.top - mrect.top + r.height / 2, w: r.width + 10 }); } const place = (tx, ty, width, ox = w / 2, oy = (h + 50) / 2) => { let x = tx, y = ty; const cx0 = ox, cy0 = oy; const inside = x > 70 && x < w - 70 && y > topY && y < h - 30; if (!inside) { // 출발점(화면 중심 또는 선택한 별)에서 대상 쪽으로 쏜 반직선이 가장자리와 만나는 점 const dx = tx - cx0, dy = ty - cy0; const sx = dx > 0 ? (w - 16 - width / 2 - cx0) / dx : (16 + width / 2 - cx0) / dx; const sy = dy > 0 ? (h - 22 - cy0) / dy : (topY - cy0) / dy; const k = Math.min(Math.abs(sx) || Infinity, Math.abs(sy) || Infinity); x = cx0 + dx * k; y = cy0 + dy * k; } for (let i = 0; i < 12; i++) { const hit = placed.find((p) => Math.abs(p.x - x) < (p.w + width) / 2 + 4 && Math.abs(p.y - y) < 26); if (!hit) break; const gapX = (hit.w + width) / 2 + 6; if (x < 120 || x > w - 120) y = hit.y + (y < h / 2 ? 28 : -28); if (y < topY) y = topY; else x = x <= hit.x ? hit.x - gapX : hit.x + gapX; } placed.push({ x, y, w: width }); return [x, y, inside]; }; const chip = (html2, title, color, tx, ty, width, onClick, cls = "", origin = []) => { const [x, y, inside] = place(tx, ty, width, ...origin); const el = document.createElement("button"); el.type = "button"; el.className = "edge-chip " + cls + (inside ? " inside" : ""); el.style.setProperty("--glow", color); el.style.transform = `translate(calc(${x}px - 50%), calc(${y}px - 50%))`; el.innerHTML = html2; el.title = title; el.setAttribute("aria-label", title); el.addEventListener("click", onClick); objects.appendChild(el); }; // 선택한 별의 화면 밖 이웃은 이름으로 const meP = sel && W.term.get(sel); const me = meP ? (meP.shown ? { sx: meP.sx, sy: meP.sy } : null) : null; if (meP && !(meP.shown && onScreen(meP.sx, meP.sy))) { const [tx, ty] = toScreen(meP.x, meP.y); chip(`선택: ${esc(meP.t.n)}돌아가기`, `선택한 ‘${meP.t.n}’ 위치로 돌아가기`, glow(meP.t.g), tx, ty, labelWidth(meP.t.n, 11.5) + 118, () => go("#/t/" + sel), "named selected-chip"); } const short = (t, n) => (compact && t.length > n ? t.slice(0, n) + "…" : t); for (const { r, o } of selOff.slice(0, compact ? 5 : 10)) { if (o.shown && onScreen(o.sx, o.sy)) continue; const [tx, ty] = toScreen(o.x, o.y); const where = o.t.g !== S.byId.get(sel).g ? S.galaxyById.get(o.t.g).name : o.sec.sec.title; const arrow = r.ty === "contrasts_with" || r.ty === "related_to" ? "↔" : r.s === sel ? "→" : "←"; const whereTxt = compact ? "" : where; chip(`${esc(short(o.t.n, 8))}${whereTxt ? `${esc(whereTxt)}` : ""}`, `${shortLabel(r)} ${arrow} ${o.t.n} (${where})`, glow(o.t.g), tx, ty, labelWidth(short(o.t.n, 8) + whereTxt, 11.5) + 34, () => go("#/t/" + o.t.i), "named", me ? [me.sx, me.sy] : []); } // 그 밖의 바깥 연결은 은하·절 단위로 묶어서 const groups = [...outward.values()].sort((a, b) => b.n - a.n).slice(0, compact ? 5 : 14); for (const o of groups) { const [tx, ty] = toScreen(o.wx, o.wy); const names = [...o.names].join(", "); chip(`${esc(short(o.label, 6))}${o.n}`, `${o.label}과(와) 연결 ${o.n}개: ${names}${o.n > o.names.size ? " 외" : ""}`, glow(o.gid), tx, ty, labelWidth(short(o.label, 6), 11.5) + 46, () => go(o.fly)); } } const scheduleLabels = debounce((shown) => cullStarLabels(shown), 50); function cullStarLabels(shown) { if (!shown) return; const sel = S.selected; const neighbors = new Set(); if (sel) for (const r of S.adj.get(sel) || []) neighbors.add(r.s === sel ? r.t : r.s); const items = []; for (const p of shown) { const id = p.t.i; const isSel = id === sel, isNb = neighbors.has(id), isPath = S.path?.ids.has(id); items.push({ id, x: p.sx, y: p.sy + 4, w: labelWidth(p.t.n, isSel ? 13.5 : 12), h: 19, prio: isSel ? 1e6 : isPath ? 5e5 : isNb ? 1e5 : S.degree.get(id) * 10 + (p.t.e ? 5 : 0) - p.t.n.length * 0.1, force: isSel || isPath, }); } for (const el of objects.querySelectorAll(".sec-title:not([hidden]), .item-label:not([hidden]), .galaxy-title:not([hidden]), .edge-chip")) { const r = el.getBoundingClientRect(), m = map.getBoundingClientRect(); items.push({ id: "_o" + items.length, x: r.left - m.left + r.width / 2, y: r.top - m.top, w: r.width, h: r.height, prio: 2e6, force: true }); } const vis = cullLabels(items); for (const p of shown) { const hide = !vis.has(p.t.i); p.hideLabel = hide; if (p.el) p.el.classList.toggle("hide-label", hide && p.t.i !== sel); } } // 확대·이동이 멈추면 화면 가운데의 은하·절을 '현재 위치'로 삼아 위치 표시와 설명을 맞춘다 const scheduleContext = debounce(() => updateContext(), 260); function updateContext() { const W = S.world; if (!W || S.mode !== "map") return; const r = S.route; if (!["universe", "galaxy", "section"].includes(r.name)) return; const w = map.clientWidth / 2, h = map.clientHeight / 2; const cx = (w - S.cam.tx) / S.cam.s, cy = (h - S.cam.ty) / S.cam.s; let view = { kind: "universe" }; let best = Infinity; for (const [gid, G] of W.gal) { if (G.mode !== "open") continue; const d = Math.hypot(G.x - cx, G.y - cy) / G.E; if (d < 1.1 && d < best) { best = d; view = { kind: "galaxy", id: gid }; } } if (view.kind === "galaxy") { best = Infinity; for (const s of W.gal.get(view.id).secs) { if (s.mode !== "open") continue; const d = Math.hypot(s.x - cx, s.y - cy) / s.E; if (d < 1.2 && d < best) { best = d; view = { kind: "section", id: s.sec.id }; } } } const cur = S.view || {}; if (cur.kind === view.kind && cur.id === view.id) return; S.view = view; const hash = view.kind === "universe" ? "#/" : view.kind === "galaxy" ? "#/g/" + view.id : "#/s/" + view.id; history.replaceState(null, "", hash); S.route = parseHash(); renderPanel(); renderCrumbs(); } // 경로 찾기 등에서 강조가 바뀌었을 때 function refreshScene() { draw(); } function position() { draw(); } function fitCam() { S.cam = targetCam(S.route); } function initCamera() { // 마우스 드래그·한 손가락 이동, 두 손가락 핀치(확대·축소+이동). 별·은하 위에서 시작해도 제스처로 인식하고, // 제스처가 끝난 직후의 클릭은 막아서 손가락을 뗀 자리의 버튼이 눌리지 않게 한다. const pointers = new Map(); let drag = null; // {x, y, tx, ty} let pinch = null; // {d0, s0, wx, wy} let gesture = false; // 이번 터치/드래그가 이동·확대였는지 let tapOnEmpty = false; let suppressUntil = 0; const TAP_SLOP = 8; const rectOf = () => map.getBoundingClientRect(); const capture = (id) => { try { map.setPointerCapture(id); } catch { /* 이미 끝난 포인터 */ } }; const startDrag = (p) => { drag = { x: p.x, y: p.y, tx: S.cam.tx, ty: S.cam.ty }; }; const startPinch = () => { const [a, b] = [...pointers.values()]; const r = rectOf(); const mx = (a.x + b.x) / 2 - r.left, my = (a.y + b.y) / 2 - r.top; // 두 손가락 가운데 지점 아래의 세계 좌표를 고정점으로 pinch = { d0: Math.max(10, Math.hypot(a.x - b.x, a.y - b.y)), s0: S.cam.s, wx: (mx - S.cam.tx) / S.cam.s, wy: (my - S.cam.ty) / S.cam.s }; drag = null; gesture = true; for (const id of pointers.keys()) capture(id); }; map.addEventListener("pointerdown", (e) => { if (e.pointerType === "mouse" && e.button !== 0) return; cancelAnimationFrame(anim); const onControl = !!e.target.closest("button, a, input"); pointers.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, onControl }); if (pointers.size === 1) { gesture = false; tapOnEmpty = !onControl; startDrag(pointers.get(e.pointerId)); // 버튼 위에서 시작했으면 클릭이 될 수 있으므로 아직 포인터를 붙잡지 않는다 if (!onControl) capture(e.pointerId); } else if (pointers.size === 2) { tapOnEmpty = false; startPinch(); } map.classList.add("dragging"); }); map.addEventListener("pointermove", (e) => { const p = pointers.get(e.pointerId); if (!p) return; p.x = e.clientX; p.y = e.clientY; if (pinch && pointers.size >= 2) { const [a, b] = [...pointers.values()]; const r = rectOf(); const d = Math.max(10, Math.hypot(a.x - b.x, a.y - b.y)); const mx = (a.x + b.x) / 2 - r.left, my = (a.y + b.y) / 2 - r.top; const s1 = Math.min(6, Math.max(minScale(), pinch.s0 * (d / pinch.d0))); S.cam = { s: s1, tx: mx - pinch.wx * s1, ty: my - pinch.wy * s1 }; draw(); return; } if (drag && pointers.size === 1) { const dx = p.x - drag.x, dy = p.y - drag.y; if (!gesture && Math.hypot(p.x - p.sx, p.y - p.sy) > TAP_SLOP) { gesture = true; tapOnEmpty = false; capture(e.pointerId); // 버튼 위에서 시작한 경우도 이제부터는 이동 } if (gesture) { S.cam.tx = drag.tx + dx; S.cam.ty = drag.ty + dy; draw(); } } }); const end = (e) => { if (!pointers.has(e.pointerId)) return; pointers.delete(e.pointerId); if (pointers.size === 1) { // 핀치 중 한 손가락을 떼면 남은 손가락으로 이어서 이동 (튀지 않게 기준점 재설정) pinch = null; startDrag([...pointers.values()][0]); return; } if (pointers.size >= 2) { startPinch(); return; } // 모든 포인터가 떨어짐 pinch = null; drag = null; map.classList.remove("dragging"); if (gesture) { suppressUntil = performance.now() + 350; scheduleContext(); } else if (tapOnEmpty && e.type === "pointerup" && S.selected) { clearSelection(); // 빈 곳을 짧게 누르면 선택 해제 (화면은 그대로) } gesture = false; }; map.addEventListener("pointerup", end); map.addEventListener("pointercancel", end); map.addEventListener("lostpointercapture", (e) => { if (pointers.has(e.pointerId) && e.pointerType !== "mouse") end(e); }); // 제스처 직후의 클릭(손가락을 뗀 자리의 별·은하)은 무시 map.addEventListener("click", (e) => { if (performance.now() < suppressUntil) { e.stopPropagation(); e.preventDefault(); } }, true); // iOS Safari: 지도 위 핀치가 페이지 전체 확대로 번지지 않게 for (const t of ["gesturestart", "gesturechange", "gestureend"]) { map.addEventListener(t, (e) => e.preventDefault(), { passive: false }); } map.addEventListener("touchmove", (e) => { if (e.touches.length > 1) e.preventDefault(); }, { passive: false }); map.addEventListener("wheel", (e) => { e.preventDefault(); const rect = map.getBoundingClientRect(); const f = Math.pow(1.0022, -e.deltaY * (e.deltaMode === 1 ? 30 : 1) * (e.ctrlKey ? 4 : 1)); zoomAt(f, e.clientX - rect.left, e.clientY - rect.top); }, { passive: false }); map.addEventListener("dblclick", (e) => { if (e.target.closest("button, a")) return; const rect = map.getBoundingClientRect(); flyTo({ ...zoomedCam(2.2, e.clientX - rect.left, e.clientY - rect.top) }, 350); }); $$("[data-zoom]").forEach((b) => b.addEventListener("click", () => { const w = map.clientWidth, h = map.clientHeight; if (b.dataset.zoom === "fit") flyTo(camFor(S.world.bounds)); else flyTo(zoomedCam(b.dataset.zoom === "in" ? 1.8 : 0.55, w / 2, h / 2), 300); })); map.addEventListener("keydown", (e) => { if (e.target !== map) return; const step = 60; const k = e.key; const cw = map.clientWidth / 2, ch = map.clientHeight / 2; if (k === "+" || k === "=") zoomAt(1.3, cw, ch); else if (k === "-") zoomAt(0.77, cw, ch); else if (k === "ArrowLeft") { S.cam.tx += step; draw(); scheduleContext(); } else if (k === "ArrowRight") { S.cam.tx -= step; draw(); scheduleContext(); } else if (k === "ArrowUp") { S.cam.ty += step; draw(); scheduleContext(); } else if (k === "ArrowDown") { S.cam.ty -= step; draw(); scheduleContext(); } else return; e.preventDefault(); }); let lastW = 0, lastH = 0; new ResizeObserver(debounce(() => { if (!S.world || S.mode !== "map") return; const w = map.clientWidth, h = map.clientHeight; if (lastW) { S.cam.tx += (w - lastW) / 2; S.cam.ty += (h - lastH) / 2; } lastW = w; lastH = h; draw(); }, 100)).observe(map); } function zoomedCam(factor, px, py) { const s1 = Math.min(6, Math.max(minScale(), S.cam.s * factor)); return { s: s1, tx: px - ((px - S.cam.tx) * s1) / S.cam.s, ty: py - ((py - S.cam.ty) * s1) / S.cam.s }; } // ------------------------------------------------------------------ 위치 표시줄 · 최근 기록 function renderCrumbs() { const v = S.view; const parts = [`전체 우주`]; let g = null, sec = null; if (v.kind === "galaxy") g = S.galaxyById.get(v.id); if (v.kind === "section") { sec = S.sectionById.get(v.id); g = S.galaxyById.get(sec.galaxy_id); } if (g) parts.push(`›`, `${esc(g.name)}`); if (sec) parts.push(`›`, `${esc(sec.title)}`); if (S.selected && S.route.name === "term") parts.push(`›`, `${esc(S.byId.get(S.selected)?.n || "")}`); $(".crumbs").innerHTML = parts.join(""); } const TRAIL_KEY = "atlas.trail"; function readTrail() { try { return JSON.parse(localStorage.getItem(TRAIL_KEY) || "[]").filter((id) => S.byId.has(id)); } catch { return []; } } function pushTrail(id) { const list = [id, ...readTrail().filter((x) => x !== id)].slice(0, 12); try { localStorage.setItem(TRAIL_KEY, JSON.stringify(list)); } catch { /* 저장 불가 환경 */ } } function renderTrail() { const list = readTrail(); const el = $(".trail"); if (!list.length) { el.innerHTML = `최근 살펴본 용어가 여기에 쌓입니다.`; return; } el.innerHTML = `최근 탐색` + list.map((id) => `${esc(S.byId.get(id).n)}`).join("") + ``; $(".clear", el).addEventListener("click", () => { try { localStorage.removeItem(TRAIL_KEY); } catch { /* 무시 */ } renderTrail(); }); } // ------------------------------------------------------------------ 목록 보기 (접근성·모바일) function setMode(mode) { S.mode = mode; document.body.classList.toggle("list-mode", mode === "list"); $$("[data-mode]").forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.mode === mode))); $(".listview").hidden = mode !== "list"; try { localStorage.setItem("atlas.mode", mode); } catch { /* 무시 */ } if (mode === "list") renderList(); else requestAnimationFrame(() => { S.cam = targetCam(S.route); draw(); }); } function termLinks(terms) { return `
${terms.map((t) => `${esc(t.n)}`).join("")}
`; } function renderList() { const v = S.view; const el = $(".listview"); if (v.kind === "universe") { const tab = el.dataset.tab || "topic"; let body; if (tab === "index") { const groups = new Map(); for (const t of [...S.data.terms].sort((a, b) => a.n.localeCompare(b.n, "ko"))) { const k = initialGroup(t.n); if (!groups.has(k)) groups.set(k, []); groups.get(k).push(t); } body = `` + [...groups].map(([k, ts]) => `

${esc(k)} (${ts.length})

${termLinks(ts)}`).join(""); } else { body = ``; } el.innerHTML = `

전체 우주

교과서 단원(은하)과 가나다순 찾아보기로 용어를 찾을 수 있습니다.

${body}`; $$("[data-tab]", el).forEach((b) => b.addEventListener("click", () => { el.dataset.tab = b.dataset.tab; renderList(); })); $$("[data-jump]", el).forEach((a) => a.addEventListener("click", (e) => { e.preventDefault(); document.getElementById(a.dataset.jump)?.scrollIntoView({ block: "start" }); })); } else if (v.kind === "galaxy") { const g = S.galaxyById.get(v.id); el.innerHTML = `

${esc(g.name)}

${esc(g.sub || "")}

` + (S.sectionsByGalaxy.get(v.id) || []).map((s) => { const ts = [...S.termsBySection.get(s.id)].sort((a, b) => a.n.localeCompare(b.n, "ko")); return `

${esc(s.number)}절 ${esc(s.title)} (${ts.length})

${s.easy_sub ? `

${esc(s.easy_sub)}

` : ""}${termLinks(ts)}`; }).join(""); } else { const sec = S.sectionById.get(v.id); const ts = S.termsBySection.get(v.id) || []; const byItem = new Map(sec.items.map((it) => [it.id, []])); const rest = []; for (const t of ts) (byItem.get(t.it) || rest).push(t); el.innerHTML = `

${esc(sec.title)}

${esc(S.galaxyById.get(sec.galaxy_id).name)} · 용어 ${ts.length}개

` + sec.items.filter((it) => byItem.get(it.id).length).map((it) => `

${esc(it.number)}. ${esc(it.title)}

${termLinks(byItem.get(it.id).sort((a, b) => a.n.localeCompare(b.n, "ko")))}`).join("") + (rest.length ? `

그 밖의 용어

${termLinks(rest.sort((a, b) => a.n.localeCompare(b.n, "ko")))}` : ""); } } // ------------------------------------------------------------------ 설명 패널 function renderPanel() { const r = S.route; detail.style.removeProperty("--glow"); if (r.name === "term" && S.selected) return renderTerm(S.selected); if (r.name === "path") return renderPathPanel(r.a, r.b); if (r.name === "compare") return renderComparePanel(r.a, r.b); if (r.name === "search") return renderSearchPanel(r.q); if (r.name === "quiz") return renderQuizPanel(r); if (S.view.kind === "galaxy") renderGalaxyPanel(S.view.id); else if (S.view.kind === "section") renderSectionPanel(S.view.id); else renderIntro(); if (S.selected) { const t = S.byId.get(S.selected); const bar = document.createElement("div"); bar.className = "keep-sel"; bar.style.setProperty("--glow", glow(t.g)); bar.innerHTML = `선택 유지 중 · ${esc(t.n)}설명 보기 `; $("button", bar).addEventListener("click", clearSelection); detail.prepend(bar); } } function clearSelection() { if (!S.selected) return; const v = S.view || { kind: "universe" }; const hash = v.kind === "section" ? "#/s/" + v.id : v.kind === "galaxy" ? "#/g/" + v.id : "#/"; go(hash, { fly: false, clear: true }); } function renderIntro() { const d = S.data; const st = d.meta.statuses || []; detail.innerHTML = `
개념 우주 사용법

교과서 색인을 지도처럼 탐색해요

  1. 은하는 교과서의 장(단원), 별자리는 절, 별은 색인에 실린 용어입니다.
  2. 용어를 알면 위 검색창에, 모르면 은하의 쉬운 설명을 보고 들어가세요.
  3. 별을 누르면 쉬운 뜻, 떠올려 볼 장면, 연결된 개념을 볼 수 있습니다.
  4. 선은 이름이 붙은 관계만 나타냅니다. 가까이 있다고 비슷한 개념은 아닙니다.
${st.includes("draft") && d.meta.show_status_badges ? `

‘검수 전’ 표시가 있는 설명과 관계는 교재에서 자동으로 뽑은 초안입니다. 틀린 곳은 오류 신고로 알려 주세요.

` : ""}

은하 둘러보기

`; } function renderGalaxyPanel(gid) { const g = S.galaxyById.get(gid); detail.style.setProperty("--glow", g.color); const secs = S.sectionsByGalaxy.get(gid) || []; const top = [...S.termsByGalaxy.get(gid)].sort((a, b) => S.degree.get(b.i) - S.degree.get(a.i)).slice(0, 14); detail.innerHTML = `
지금 살펴보는 은하
${g.chapter_no}장

${esc(g.name)}

${esc(g.sub || "")}

연결이 많은 용어

${termLinks(top)}
`; } function renderSectionPanel(sid) { const sec = S.sectionById.get(sid); const g = S.galaxyById.get(sec.galaxy_id); detail.style.setProperty("--glow", g.color); const ts = S.termsBySection.get(sid) || []; const top = [...ts].sort((a, b) => S.degree.get(b.i) - S.degree.get(a.i)).slice(0, 18); detail.innerHTML = `
지금 살펴보는 별자리
${esc(g.name)} › ${esc(sec.number)}절

${esc(sec.title)}

${sec.easy_sub ? `

${esc(sec.easy_sub)}

` : ""}

용어 ${ts.length}개

이 절의 소주제

${sec.items.map((it) => esc(it.title)).join(" · ") || "소주제 정보 없음"}

연결이 많은 용어부터

${termLinks(top)}
`; } function highlight(text, name) { const safe = esc(text); if (!name) return safe; const pat = esc(name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s?"); try { return safe.replace(new RegExp(pat, "g"), (m) => `${m}`); } catch { return safe; } } const REL_GROUPS = [ ["up", "상위 개념 (이 용어가 구성 요소인 개념)"], ["down", "하위 개념 · 구성 요소"], ["prev", "이전 단계"], ["next", "다음 단계"], ["informs_out", "이 개념이 자료로 쓰이는 곳"], ["informs_in", "이 개념의 근거가 되는 자료"], ["contrast", "구분해서 알아둘 개념"], ["related", "관련 개념"], ]; function relGroup(r) { if (r.type === "part_of") return r.outgoing ? "up" : "down"; if (r.type === "precedes") return r.outgoing ? "next" : "prev"; if (r.type === "informs") return r.outgoing ? "informs_out" : "informs_in"; if (r.type === "contrasts_with") return "contrast"; return "related"; } async function getDetail(id) { if (!S.detailCache.has(id)) S.detailCache.set(id, api("/api/terms/" + encodeURIComponent(id))); try { return await S.detailCache.get(id); } catch (e) { S.detailCache.delete(id); throw e; } } async function renderTerm(id) { const t0 = S.byId.get(id); const g = termGalaxy(t0); detail.style.setProperty("--glow", g?.color || ""); detail.innerHTML = `
지금 살펴보는 별

${esc(t0.n)}

불러오는 중…

`; let t; try { t = await getDetail(id); } catch (e) { detail.innerHTML += `

설명을 불러오지 못했습니다: ${esc(e.message)}

`; return; } if (S.selected !== id) return; // 그 사이 다른 용어 선택 const sec = t.section, item = t.item; const aliases = [...(t.aliases || []), ...(t.search_terms || [])]; const groups = new Map(REL_GROUPS.map(([k]) => [k, []])); for (const r of t.relations) groups.get(relGroup(r)).push(r); const relHtml = REL_GROUPS.filter(([k]) => groups.get(k).length).map(([k, title]) => { const list = groups.get(k); const items = list.map((r, idx) => { const o = r.other; const otherG = S.galaxyById.get(o.galaxy_id); const where = o.galaxy_id !== t.galaxy_id ? ` · 다른 은하: ${esc(otherG?.name || "")}` : ""; const st = r.review_status !== "confirmed" && S.data.meta.show_status_badges ? ` · ${STATUS_LABEL[r.review_status]} ${esc(METHOD_LABEL[r.method] || "")}` : ""; const arrow = r.type === "contrasts_with" ? "↔" : r.outgoing ? "→" : "←"; return `= 5 ? "hidden data-more" : ""}>${esc(o.name)}${esc(r.label || REL_TYPE_LABEL[r.type])}${where}${st}`; }).join(""); return `

${title} (${list.length})

${items}
${list.length > 5 ? `` : ""}
`; }).join(""); const fbUrl = `/feedback?term=${encodeURIComponent(t.term_id)}&page=${encodeURIComponent(location.href)}`; detail.innerHTML = `
지금 살펴보는 별
${esc(g?.name || "")}${sec ? ` › ${esc(sec.title)}` : ""}${item ? ` › ${esc(item.title)}` : ""}${t.section_guess ? ` (배치 추정)` : ""}

${esc(t.name)}${statusBadge(t.review_status)}

${aliases.length ? "함께 찾는 말 · " + aliases.slice(0, 6).map(esc).join(", ") : ""}

쉬운 뜻

${t.easy ? `

${esc(t.easy)}

${t.easy_method === "wireframe" ? `시안 예시 설명 · 교수자 검토 전` : t.easy_method === "draft" ? `초안 · 교수자 검토 전` : ""}` : `

아직 쉬운 설명이 작성되지 않았습니다.

`}
${t.example ? `

이런 장면을 떠올려 보세요

${esc(t.example)}

` : t.example_locked ? `

이런 장면을 떠올려 보세요

${lockedHtml("사례")}
` : ""}

확인 문제

${S.member?.can_view ? `

문제를 불러오는 중…

` : lockedHtml("문제")}

연결된 개념 ${t.relations.length}

${relHtml || `

아직 등록된 관계가 없습니다.

`}
`; $$("[data-expand]", detail).forEach((b) => b.addEventListener("click", () => { $$("[data-more]", b.parentElement).forEach((x) => (x.hidden = false)); b.remove(); })); announce(`${t.name} 선택됨. ${t.easy || ""}`); if (S.member?.can_view) loadTermQuestions(id); } // ------------------------------------------------------------------ 회원 전용: 사례·문제 function lockedHtml(what) { const m = S.member || {}; const next = encodeURIComponent(location.pathname + location.hash); return `
🔒 ${what}는 회원 전용입니다. 로그인 · 가입 코드로 회원 가입
`; } async function loadTermQuestions(id) { const box = $("#term-questions"); if (!box) return; let d; try { d = await api("/api/members/questions?term=" + encodeURIComponent(id)); } catch (e) { box.innerHTML = `

확인 문제

${esc(e.message)}

`; return; } if (S.selected !== id || !$("#term-questions")) return; box.innerHTML = `

확인 문제 ${d.questions.length}

` + (d.questions.length ? "" : `

이 용어로 만들 수 있는 문제가 아직 없습니다.

`); for (const q of d.questions) { const el = document.createElement("div"); box.appendChild(el); renderQuestion(el, q, { showTermLink: false }); } } /** 문제 하나를 그리고, 보기를 누르면 서버에서 채점한다. */ function renderQuestion(el, q, { showTermLink = true, onAnswer } = {}) { el.className = "question"; el.innerHTML = `

${esc(q.prompt)}

${q.quote ? `
${esc(q.quote)}
` : ""}
${q.choices.map((c, i) => ``).join("")}
`; $$(".q-choice", el).forEach((b) => b.addEventListener("click", async () => { $$(".q-choice", el).forEach((x) => (x.disabled = true)); let r; try { r = await api("/api/members/answer", { method: "POST", body: { token: q.token, choice: +b.dataset.i } }); } catch (e) { toast(e.message, true); $$(".q-choice", el).forEach((x) => (x.disabled = false)); return; } $$(".q-choice", el).forEach((x) => { const i = +x.dataset.i; if (i === r.answer) x.classList.add("right"); else if (i === +b.dataset.i) x.classList.add("wrong"); }); const t = r.term_id && S.byId.get(r.term_id); $(".q-result", el).innerHTML = `${r.correct ? "정답이에요!" : "아쉬워요."} ${esc(r.explanation || "")} ${showTermLink && t ? ` ` : ""}`; $("[data-show]", el)?.addEventListener("click", () => showOnMap(t.i)); onAnswer?.(r); })); } /** 패널은 그대로 두고 지도에서 용어 위치만 보여 준다 */ function showOnMap(id) { S.selected = id; if (S.mode === "list") setMode("map"); flyTo(targetCam({ name: "term" })); } function renderQuizPanel(r) { const m = S.member || {}; if (!m.can_view) { detail.innerHTML = `
문제 풀기

단원별 확인 문제

${lockedHtml("문제 풀기")}`; return; } const gopts = S.data.galaxies.map((g) => ``).join(""); detail.innerHTML = `
문제 풀기

${r.wrong ? "틀린 문제 다시 풀기" : "단원별 확인 문제"}

쉬운 뜻을 보고 용어를 고르거나, 용어를 보고 뜻을 고릅니다. 풀이 기록은 내 계정에서 볼 수 있어요.

`; const start = async () => { const body = $("#qz-body"); body.innerHTML = `

문제를 만드는 중…

`; const g = $("#qz-g").value, n = $("#qz-n").value; let d; try { d = await api(`/api/members/quiz?n=${n}${g ? "&galaxy=" + g : ""}${r.wrong ? "&wrong_only=1" : ""}`); } catch (e) { body.innerHTML = `

${esc(e.message)}

`; return; } if (!d.questions.length) { body.innerHTML = `

${r.wrong ? "다시 풀 틀린 문제가 없어요. 잘했어요!" : "이 단원에서 만들 수 있는 문제가 아직 없습니다."}

`; return; } let idx = 0, score = 0; const show = () => { if (idx >= d.questions.length) { body.innerHTML = `
${score} / ${d.questions.length}맞혔어요
내 기록 보기
`; $("#qz-again").onclick = start; return; } body.innerHTML = `
${idx + 1} / ${d.questions.length}맞힌 문제 ${score}
`; renderQuestion($("#qz-q"), d.questions[idx], { onAnswer: (res) => { if (res.correct) score++; $("#qz-next").hidden = false; $("#qz-next").focus(); }, }); $("#qz-next").onclick = () => { idx++; show(); }; }; show(); }; $("#qz-go").onclick = start; if (r.wrong || r.galaxy) start(); } // ------------------------------------------------------------------ 검색 function resultRow(h, idx, activeIdx) { const t = h.t; const g = termGalaxy(t); const sec = S.sectionById.get(t.s); return ``; } function initSearch() { const input = $("#q"); const box = $("#results"); let hits = []; let active = -1; const close = () => { box.hidden = true; input.setAttribute("aria-expanded", "false"); active = -1; }; const draw = () => { const q = input.value.trim(); if (!q) return close(); hits = search(S.index, q); const shown = hits.slice(0, 8); box.innerHTML = `
등록된 용어·동의어·설명에서 ${hits.length}개 찾음
` + (shown.length ? shown.map((h, i) => resultRow(h, i, active)).join("") + (hits.length > 8 ? `` : "") : `

일치하는 용어가 없습니다.
더 짧은 단어로 찾거나, 지도에서 은하(주제)를 골라 보세요.
찾는 용어가 없다면 알려 주세요

`); box.hidden = false; input.setAttribute("aria-expanded", "true"); input.setAttribute("aria-activedescendant", active >= 0 ? "opt-" + active : ""); announce(`${hits.length}개의 검색 결과`); }; const pick = (id) => { close(); input.value = S.byId.get(id).n; go("#/t/" + id); }; input.addEventListener("input", debounce(() => { active = -1; draw(); }, 80)); input.addEventListener("focus", () => { if (input.value.trim()) draw(); }); input.addEventListener("keydown", (e) => { if (box.hidden && e.key !== "Enter") return; const n = Math.min(8, hits.length); if (e.key === "ArrowDown") { active = (active + 1) % n; draw(); e.preventDefault(); } else if (e.key === "ArrowUp") { active = (active - 1 + n) % n; draw(); e.preventDefault(); } else if (e.key === "Escape") { close(); } }); $(".search-form").addEventListener("submit", (e) => { e.preventDefault(); const q = input.value.trim(); if (!q) return; hits = search(S.index, q); if (active >= 0 && hits[active]) pick(hits[active].t.i); else if (hits.length === 1 || (hits[0] && hits[0].score >= 99)) pick(hits[0].t.i); else { close(); go("#/search/" + encodeURIComponent(q)); } }); box.addEventListener("click", (e) => { const r = e.target.closest("[data-id]"); if (r) return pick(r.dataset.id); if (e.target.closest("[data-close]")) { close(); input.focus(); } if (e.target.closest("[data-all]")) { close(); go("#/search/" + encodeURIComponent(input.value.trim())); } }); document.addEventListener("click", (e) => { if (!e.target.closest(".search-area")) close(); }); } function renderSearchPanel(q) { const hits = search(S.index, q); let page = 0; const per = 40; const draw = () => { detail.innerHTML = `
검색 결과

‘${esc(q)}’

${hits.length}개 · 용어·동의어·검색 표현·설명 문자열에서 찾았습니다.

${hits.slice(0, (page + 1) * per).map((h) => { const g = termGalaxy(h.t), sec = S.sectionById.get(h.t.s); return `${esc(h.t.n)}${esc(h.reason)}${esc(g?.name || "")}${sec ? " › " + esc(sec.title) : ""}`; }).join("") || `

일치하는 용어가 없습니다. 짧은 단어나 초성으로 다시 찾아보세요.

`}
${hits.length > (page + 1) * per ? `` : ""}`; $("[data-more-results]", detail)?.addEventListener("click", () => { page++; draw(); }); }; draw(); $("#q").value = q; } /** 경로·비교용 작은 검색 선택기 */ function picker(el, label, id, onPick) { el.className = "picker"; el.innerHTML = ``; const input = $("input", el), box = $(".results", el); input.addEventListener("input", debounce(() => { const hits = search(S.index, input.value, { limit: 8 }); box.innerHTML = hits.map((h, i) => resultRow(h, i, -1)).join("") || `

일치하는 용어가 없습니다.

`; box.hidden = !input.value.trim(); }, 80)); box.addEventListener("click", (e) => { const r = e.target.closest("[data-id]"); if (!r) return; box.hidden = true; input.value = S.byId.get(r.dataset.id).n; onPick(r.dataset.id); }); input.addEventListener("keydown", (e) => { if (e.key === "Escape") box.hidden = true; }); } // ------------------------------------------------------------------ 경로 찾기 function computePath(a, b, confirmedOnly = false) { if (!S.byId.has(a) || !S.byId.has(b)) return; const steps = shortestPath(S.adj, a, b, { allow: (r) => !confirmedOnly || r.st === "confirmed" }); S.path = steps ? { steps, ids: new Set([a, ...steps.map((s) => s.to)]), rels: new Set(steps.map((s) => s.rel.i)) } : { steps: null, ids: new Set([a, b]), rels: new Set() }; } function stepPhrase(st) { const r = st.rel; const ty = REL_TYPE_LABEL[r.ty]; if (r.ty === "contrasts_with" || r.ty === "related_to") return `${ty}${r.l ? " · " + r.l : ""}`; return `${st.forward ? "→" : "←"} ${ty}${r.l ? " · " + r.l : ""}`; } function renderPathPanel(a, b) { detail.innerHTML = `
경로 찾기

두 개념은 어떻게 이어질까?

등록된 관계만 따라갑니다. 직접 연결이 없으면 중간 개념을 거치는 가장 짧은 길을 보여 줍니다.

`; let A = a, B = b; const update = () => { if (A && B) go(`#/path/${A}/${B}`); else if (A) go(`#/path/${A}`); }; picker($("#pk-a"), "출발 개념", A, (id) => { A = id; update(); }); picker($("#pk-b"), "도착 개념", B, (id) => { B = id; update(); }); const conf = $("#pk-conf"); const out = $("#path-out"); const draw = () => { if (!(A && B)) { out.innerHTML = `

두 개념을 고르세요.

`; return; } computePath(A, B, conf.checked); refreshScene(); position(); const p = S.path; if (A === B) { out.innerHTML = `

서로 다른 두 개념을 고르세요.

`; return; } if (!p.steps) { out.innerHTML = `

결과

등록된 관계로는 두 개념이 이어지지 않습니다 (최대 8단계까지 확인).

관계가 있어야 한다고 생각하면 오류 신고로 알려 주세요.

`; return; } const n = p.steps.length; out.innerHTML = `

결과 · ${n === 1 ? "직접 연결" : `${n - 1}개 개념을 거쳐 연결`}

  1. ${esc(S.byId.get(A).n)}
  2. ${p.steps.map((st) => `
  3. ${esc(stepPhrase(st))}${st.rel.st !== "confirmed" ? ` ${STATUS_LABEL[st.rel.st]}` : ""}
    ${esc(S.byId.get(st.to).n)} ${esc(termGalaxy(S.byId.get(st.to))?.name || "")}
  4. `).join("")}

지도에서는 같은 절에 있는 경로가 노란색으로 표시됩니다.

`; }; conf.addEventListener("change", draw); draw(); } // ------------------------------------------------------------------ 비교 function renderComparePanel(a, b) { detail.innerHTML = `
비교하기

두 개념을 나란히

헷갈리는 두 용어의 뜻, 사례, 연결을 나란히 봅니다.

`; let A = a, B = b; picker($("#cp-a"), "첫 번째 개념", A, (id) => { A = id; suggest(); }); picker($("#cp-b"), "두 번째 개념", B, (id) => { B = id; }); $("#cp-go").addEventListener("click", () => { if (!A || !B || A === B) return toast("서로 다른 두 개념을 고르세요.", true); go(`#/compare/${A}/${B}`); }); const suggest = () => { const el = $("#cp-suggest"); if (!A) { el.innerHTML = ""; return; } const pairs = (S.adj.get(A) || []).filter((r) => r.ty === "contrasts_with"); el.innerHTML = pairs.length ? `

함께 비교하면 좋은 개념

${pairs.map((r) => { const o = r.s === A ? r.t : r.s; return `${esc(S.byId.get(o).n)}${esc(r.l || "")}`; }).join("")}
` : ""; }; suggest(); } async function openCompare(a, b) { const dlg = $(".compare-dialog"); dlg.innerHTML = `

비교하는 중…

`; if (!dlg.open) dlg.showModal(); let A, B; try { [A, B] = await Promise.all([getDetail(a), getDetail(b)]); } catch (e) { dlg.innerHTML = `

불러오지 못했습니다

`; $("[data-close]", dlg).addEventListener("click", () => dlg.close()); return; } const direct = A.relations.filter((r) => r.other_id === b); const nbA = new Map(A.relations.map((r) => [r.other_id, r.other.name])); const shared = B.relations.filter((r) => nbA.has(r.other_id)).map((r) => r.other); const col = (t) => { const g = S.galaxyById.get(t.galaxy_id); return `
${esc(g?.name || "")}${t.section ? " › " + esc(t.section.title) : ""}

${esc(t.name)} ${statusBadge(t.review_status)}

${esc((t.aliases || []).slice(0, 4).join(", "))}

쉬운 뜻

${t.easy ? `

${esc(t.easy)}

` : `

아직 작성되지 않았습니다.

`} ${t.example ? `

사례

${esc(t.example)}

` : ""}

연결 ${t.relations.length}개

${t.relations.slice(0, 8).map((r) => esc(r.other.name)).join(", ") || "-"}

`; }; dlg.innerHTML = `

${esc(A.name)} vs ${esc(B.name)}

${direct.length ? direct.map((r) => `${esc(REL_TYPE_LABEL[r.type])} · ${esc(r.label || "")} ${r.review_status !== "confirmed" ? `${STATUS_LABEL[r.review_status]}` : ""}`).join("
") : "두 개념 사이에 직접 등록된 관계는 없습니다."} ${shared.length ? `
공통으로 연결된 개념: ${shared.map((o) => esc(o.name)).join(", ")}
` : ""}
${col(A)}${col(B)}
`; $$("[data-close]", dlg).forEach((x) => x.addEventListener("click", () => dlg.close())); } $(".compare-dialog").addEventListener("close", () => { if (S.route.name === "compare" && S.route.b) history.replaceState(null, "", `#/compare/${S.route.a}`); }); // ------------------------------------------------------------------ 시작 async function main() { initCamera(); initSearch(); $$("[data-mode]").forEach((b) => b.addEventListener("click", () => setMode(b.dataset.mode))); await load(); let mode = "map"; try { mode = localStorage.getItem("atlas.mode") || "map"; } catch { /* 무시 */ } window.addEventListener("hashchange", onRoute); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !e.target.closest("input, dialog")) { const v = S.view; if (S.selected) clearSelection(); else if (v.kind === "section") go("#/g/" + S.sectionById.get(v.id).galaxy_id); else if (v.kind === "galaxy") go("#/"); } if (e.key === "/" && !e.target.closest("input, textarea")) { e.preventDefault(); $("#q").focus(); } }); S.view = null; S.cam = camFor(S.world.bounds); if (mode === "list") { S.mode = "list"; document.body.classList.add("list-mode"); $(".listview").hidden = false; $$("[data-mode]").forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.mode === "list"))); } onRoute(); } main();