// 개념 우주 메인 화면: 우주(단원) → 은하(절) → 별자리(용어) 탐색, 검색, 상세, 경로 찾기, 비교.
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 += `
교과서 단원(은하)과 가나다순 찾아보기로 용어를 찾을 수 있습니다.
${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.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.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(S.galaxyById.get(sec.galaxy_id).name)} · 용어 ${ts.length}개
` + sec.items.filter((it) => byItem.get(it.id).length).map((it) => `‘검수 전’ 표시가 있는 설명과 관계는 교재에서 자동으로 뽑은 초안입니다. 틀린 곳은 오류 신고로 알려 주세요.
` : ""}${esc(g.sub || "")}
${esc(sec.easy_sub)}
` : ""}용어 ${ts.length}개
${sec.items.map((it) => esc(it.title)).join(" · ") || "소주제 정보 없음"}
불러오는 중…
`; 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 `${esc(t.easy)}
${t.easy_method === "wireframe" ? `시안 예시 설명 · 교수자 검토 전` : t.easy_method === "draft" ? `초안 · 교수자 검토 전` : ""}` : `아직 쉬운 설명이 작성되지 않았습니다.
`}${esc(t.example)}
문제를 불러오는 중…
` : lockedHtml("문제")}아직 등록된 관계가 없습니다.
`}${esc(e.message)}
`; return; } if (S.selected !== id || !$("#term-questions")) return; box.innerHTML = `이 용어로 만들 수 있는 문제가 아직 없습니다.
`); 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)}` : ""}
쉬운 뜻을 보고 용어를 고르거나, 용어를 보고 뜻을 고릅니다. 풀이 기록은 내 계정에서 볼 수 있어요.
문제를 만드는 중…
`; 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 = `일치하는 용어가 없습니다.
더 짧은 단어로 찾거나, 지도에서 은하(주제)를 골라 보세요.
찾는 용어가 없다면 알려 주세요
${hits.length}개 · 용어·동의어·검색 표현·설명 문자열에서 찾았습니다.
일치하는 용어가 없습니다. 짧은 단어나 초성으로 다시 찾아보세요.
`}일치하는 용어가 없습니다.
`; 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 = `지도에서는 같은 절에 있는 경로가 노란색으로 표시됩니다.
`; }; 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 ? `${esc(t.easy)}
` : `아직 작성되지 않았습니다.
`} ${t.example ? `${esc(t.example)}
` : ""}${t.relations.slice(0, 8).map((r) => esc(r.other.name)).join(", ") || "-"}