Files
jsahn 4e943700fc feat: add common utilities and feedback handling scripts
- Introduced common.js for shared functions including API calls, error reporting, and UI helpers.
- Implemented feedback.js for the feedback reporting page, including form handling and displaying feedback status.
- Added layout.js for managing galaxy and section layouts in the visualization.
- Created login.js for user authentication with session management.
- Developed search.js for term searching based on user input and synonyms.
- Implemented signup.js for user registration with validation and immediate login.
2026-10-06 23:14:45 +09:00

1594 lines
76 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 개념 우주 메인 화면: 우주(단원) → 은하(절) → 별자리(용어) 탐색, 검색, 상세, 경로 찾기, 비교.
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", `<a href="/members">회원 관리</a><a href="/admin">관리</a>`);
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" ? `<span class="badge ${st}">${STATUS_LABEL[st] || st}</span>` : "");
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 = `<span class="g-name">${esc(g.name)}</span><span class="g-sub">${esc(g.sub || "")}</span><span class="g-count">${g.chapter_no}장 · 용어 ${g.count}개</span>`;
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 = `<strong>${esc(g.name)}</strong><small>${esc(g.sub || "")}</small>`;
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 = `<span class="c-body"><span class="c-name">${esc(sec.number)}절 ${esc(sec.title)}</span><span class="c-sub">${esc(sec.easy_sub || ("예: " + sample))}</span><span class="c-count">용어 ${n}개</span></span>`;
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 = `<span class="dot" aria-hidden="true"></span><span class="nm">${esc(t.n)}</span>`;
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 += `<path class="bundle${cross ? " cross" : ""}" style="stroke-width:${wdt}" d="${d}"/>`;
}
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 += `<path class="t-${r.ty}${r.st !== "confirmed" ? " draft" : ""}${cross ? " cross" : ""}${onPath ? " path-hi" : ""}" d="${d}"${arrow ? ' marker-end="url(#arrow)"' : ""}/>`;
}
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 += `<path class="stub" style="stroke:${glow(T.t.g)}" d="M ${src.x.toFixed(1)} ${src.y.toFixed(1)} L ${(src.x + (dx / len) * L).toFixed(1)} ${(src.y + (dy / len) * L).toFixed(1)}"/>`;
}
// 선택한 별: 모든 관계를 실제 위치까지 강조
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 += `<path class="hi t-${r.ty}${onPath ? " path-hi" : ""}${o.t.g !== me.t.g ? " cross" : ""}" d="${pth.d}"${arrow ? ' marker-end="url(#arrow-hi)"' : ""}/>`;
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 += `<text x="${qx.toFixed(1)}" y="${(qy - 4).toFixed(1)}">${esc(shortLabel(r))}</text>`;
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 += `<path class="path-hi" d="${qpath(ax, ay, bx, by, 0.12).d}"/>`;
}
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(`<i></i>선택: ${esc(meP.t.n)}<small>돌아가기</small>`, `선택한 ‘${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(`<i></i>${esc(short(o.t.n, 8))}${whereTxt ? `<small>${esc(whereTxt)}</small>` : ""}`, `${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(`<i></i>${esc(short(o.label, 6))}<b>${o.n}</b>`, `${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 = [`<a href="#/">전체 우주</a>`];
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(`<span class="sep">›</span>`, `<a href="#/g/${g.galaxy_id}" ${v.kind === "galaxy" ? 'aria-current="location"' : ""}>${esc(g.name)}</a>`);
if (sec) parts.push(`<span class="sep">›</span>`, `<a href="#/s/${sec.id}" ${!S.selected ? 'aria-current="location"' : ""}>${esc(sec.title)}</a>`);
if (S.selected && S.route.name === "term") parts.push(`<span class="sep">›</span>`, `<span aria-current="location">${esc(S.byId.get(S.selected)?.n || "")}</span>`);
$(".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 = `<span>최근 살펴본 용어가 여기에 쌓입니다.</span>`;
return;
}
el.innerHTML = `<span>최근 탐색</span>` + list.map((id) => `<a class="chip" href="#/t/${id}">${esc(S.byId.get(id).n)}</a>`).join("") +
`<button type="button" class="clear">지우기</button>`;
$(".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 `<div class="lv-terms">${terms.map((t) => `<a href="#/t/${t.i}" ${t.i === S.selected ? 'aria-current="true"' : ""}>${esc(t.n)}</a>`).join("")}</div>`;
}
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 = `<nav class="lv-index-nav" aria-label="첫 글자">${[...groups.keys()].map((k) => `<a href="#" data-jump="ix-${esc(k)}">${esc(k)}</a>`).join("")}</nav>` +
[...groups].map(([k, ts]) => `<h3 id="ix-${esc(k)}">${esc(k)} <span class="faint">(${ts.length})</span></h3>${termLinks(ts)}`).join("");
} else {
body = `<div class="lv-grid">${S.data.galaxies.map((g) => `<a class="lv-card" style="--glow:${g.color}" href="#/g/${g.galaxy_id}"><strong>${g.chapter_no}장 ${esc(g.name)}</strong><small>${esc(g.sub || "")}</small><small>절 ${(S.sectionsByGalaxy.get(g.galaxy_id) || []).length}개 · 용어 ${g.count}개</small></a>`).join("")}</div>`;
}
el.innerHTML = `<h2>전체 우주</h2><p class="muted">교과서 단원(은하)과 가나다순 찾아보기로 용어를 찾을 수 있습니다.</p>
<div class="seg lv-tabs" role="group" aria-label="목록 종류"><button type="button" data-tab="topic" aria-pressed="${tab === "topic"}">단원별</button><button type="button" data-tab="index" aria-pressed="${tab === "index"}">가나다 찾아보기</button></div>${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 = `<h2>${esc(g.name)}</h2><p class="muted">${esc(g.sub || "")}</p>` +
(S.sectionsByGalaxy.get(v.id) || []).map((s) => {
const ts = [...S.termsBySection.get(s.id)].sort((a, b) => a.n.localeCompare(b.n, "ko"));
return `<h3><a href="#/s/${s.id}">${esc(s.number)}절 ${esc(s.title)}</a> <span class="faint">(${ts.length})</span></h3>${s.easy_sub ? `<p class="muted">${esc(s.easy_sub)}</p>` : ""}${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 = `<h2>${esc(sec.title)}</h2><p class="muted">${esc(S.galaxyById.get(sec.galaxy_id).name)} · 용어 ${ts.length}개</p>` +
sec.items.filter((it) => byItem.get(it.id).length).map((it) => `<h3>${esc(it.number)}. ${esc(it.title)}</h3>${termLinks(byItem.get(it.id).sort((a, b) => a.n.localeCompare(b.n, "ko")))}`).join("") +
(rest.length ? `<h3>그 밖의 용어</h3>${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 = `<span><i></i>선택 유지 중 · <b>${esc(t.n)}</b></span><span><a href="#/t/${t.i}">설명 보기</a> <button type="button">선택 해제</button></span>`;
$("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 = `<div class="intro">
<div class="kicker">개념 우주 사용법</div>
<h2>교과서 색인을 지도처럼 탐색해요</h2>
<ol>
<li><b>은하</b>는 교과서의 장(단원), <b>별자리</b>는 절, <b>별</b>은 색인에 실린 용어입니다.</li>
<li>용어를 알면 위 검색창에, 모르면 은하의 쉬운 설명을 보고 들어가세요.</li>
<li>별을 누르면 쉬운 뜻, 떠올려 볼 장면, 연결된 개념을 볼 수 있습니다.</li>
<li>선은 이름이 붙은 관계만 나타냅니다. 가까이 있다고 비슷한 개념은 아닙니다.</li>
</ol>
${st.includes("draft") && d.meta.show_status_badges ? `<p class="muted" style="font-size:12px">‘검수 전’ 표시가 있는 설명과 관계는 교재에서 자동으로 뽑은 초안입니다. 틀린 곳은 <a href="/feedback">오류 신고</a>로 알려 주세요.</p>` : ""}
<div class="d-sec"><h3>은하 둘러보기</h3><div class="galaxy-list">${d.galaxies.map((g) => `<a href="#/g/${g.galaxy_id}" style="--glow:${g.color}"><span><strong>${g.chapter_no}장 ${esc(g.name)}</strong><small>${esc(g.sub || "")}</small></span><span class="cnt">${g.count}개</span></a>`).join("")}</div></div>
</div>`;
}
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 = `<div class="kicker">지금 살펴보는 은하</div>
<div class="d-path">${g.chapter_no}장</div><h2>${esc(g.name)}</h2><p class="muted">${esc(g.sub || "")}</p>
<div class="d-sec"><h3>별자리(절) ${secs.length}개</h3><div class="galaxy-list">${secs.map((s) => `<a href="#/s/${s.id}" style="--glow:${g.color}"><span><strong>${esc(s.number)}절 ${esc(s.title)}</strong>${s.easy_sub ? `<small>${esc(s.easy_sub)}</small>` : ""}</span><span class="cnt">${S.termsBySection.get(s.id).length}개</span></a>`).join("")}</div></div>
<div class="d-sec"><h3>연결이 많은 용어</h3>${termLinks(top)}</div>
<div class="d-actions"><a class="btn wide" href="#/">전체 우주로 돌아가기</a></div>`;
}
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 = `<div class="kicker">지금 살펴보는 별자리</div>
<div class="d-path"><a href="#/g/${g.galaxy_id}">${esc(g.name)}</a> › ${esc(sec.number)}절</div>
<h2>${esc(sec.title)}</h2>${sec.easy_sub ? `<p class="muted">${esc(sec.easy_sub)}</p>` : ""}
<p class="muted" style="font-size:12px">용어 ${ts.length}개</p>
<div class="d-sec"><h3>이 절의 소주제</h3><p style="font-size:13px">${sec.items.map((it) => esc(it.title)).join(" · ") || "<span class='d-none'>소주제 정보 없음</span>"}</p></div>
<div class="d-sec"><h3>연결이 많은 용어부터</h3>${termLinks(top)}</div>
<div class="d-actions"><a class="btn" href="#/g/${g.galaxy_id}">은하로 돌아가기</a><a class="btn" href="#/">전체 우주</a></div>`;
}
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) => `<mark>${m}</mark>`); } 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 = `<div class="kicker">지금 살펴보는 별</div><h2>${esc(t0.n)}</h2><p class="d-none">불러오는 중…</p>`;
let t;
try {
t = await getDetail(id);
} catch (e) {
detail.innerHTML += `<p class="d-none">설명을 불러오지 못했습니다: ${esc(e.message)}</p>`;
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 ? ` · <span class="badge ${r.review_status}">${STATUS_LABEL[r.review_status]}</span> ${esc(METHOD_LABEL[r.method] || "")}` : "";
const arrow = r.type === "contrasts_with" ? "↔" : r.outgoing ? "→" : "←";
return `<a class="neighbor" href="#/t/${o.term_id}" ${idx >= 5 ? "hidden data-more" : ""}><span class="n-name"><span>${esc(o.name)}</span><span class="dir" aria-hidden="true">${arrow}</span></span><small>${esc(r.label || REL_TYPE_LABEL[r.type])}${where}${st}</small></a>`;
}).join("");
return `<div class="rel-group"><h4>${title} (${list.length})</h4><div class="neighbors">${items}</div>${list.length > 5 ? `<button type="button" class="more-btn" data-expand>${list.length - 5}개 더 보기</button>` : ""}</div>`;
}).join("");
const fbUrl = `/feedback?term=${encodeURIComponent(t.term_id)}&page=${encodeURIComponent(location.href)}`;
detail.innerHTML = `<div class="kicker">지금 살펴보는 별</div>
<div class="d-path"><a href="#/g/${t.galaxy_id}">${esc(g?.name || "")}</a>${sec ? ` › <a href="#/s/${sec.toc_id}">${esc(sec.title)}</a>` : ""}${item ? ` › ${esc(item.title)}` : ""}${t.section_guess ? ` <span class="faint" title="가장 가까운 절로 배치했습니다">(배치 추정)</span>` : ""}</div>
<h2>${esc(t.name)}${statusBadge(t.review_status)}</h2>
<div class="d-alias">${aliases.length ? "함께 찾는 말 · " + aliases.slice(0, 6).map(esc).join(", ") : ""}</div>
<section class="d-sec"><h3>쉬운 뜻</h3>${t.easy ? `<p>${esc(t.easy)}</p>${t.easy_method === "wireframe" ? `<span class="cite">시안 예시 설명 · 교수자 검토 전</span>` : t.easy_method === "draft" ? `<span class="cite">초안 · 교수자 검토 전</span>` : ""}` : `<p class="d-none">아직 쉬운 설명이 작성되지 않았습니다.</p>`}</section>
${t.example ? `<section class="d-sec"><h3>이런 장면을 떠올려 보세요</h3><p class="example">${esc(t.example)}</p></section>`
: t.example_locked ? `<section class="d-sec"><h3>이런 장면을 떠올려 보세요</h3>${lockedHtml("사례")}</section>` : ""}
<section class="d-sec" id="term-questions"><h3>확인 문제</h3>${S.member?.can_view ? `<p class="d-none">문제를 불러오는 중…</p>` : lockedHtml("문제")}</section>
<section class="d-sec"><h3>연결된 개념 <span class="faint">${t.relations.length}</span></h3>${relHtml || `<p class="d-none">아직 등록된 관계가 없습니다.</p>`}</section>
<div class="d-actions">
<a class="btn" href="#/path/${t.term_id}">여기서 경로 찾기</a>
<a class="btn" href="#/compare/${t.term_id}">다른 개념과 비교</a>
<a class="btn" href="#/s/${sec?.toc_id || ""}">${esc(sec ? "이 절 전체 보기" : "단원 보기")}</a>
<a class="btn" href="${fbUrl}">오류 신고${t.open_feedback ? ` (${t.open_feedback})` : ""}</a>
</div>`;
$$("[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 `<div class="locked">🔒 ${what}는 회원 전용입니다. <a href="/login?next=${next}">로그인</a> · <a href="/signup?next=${next}">가입 코드로 회원 가입</a></div>`;
}
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 = `<h3>확인 문제</h3><p class="d-none">${esc(e.message)}</p>`;
return;
}
if (S.selected !== id || !$("#term-questions")) return;
box.innerHTML = `<h3>확인 문제 <span class="faint">${d.questions.length}</span></h3>` +
(d.questions.length ? "" : `<p class="d-none">이 용어로 만들 수 있는 문제가 아직 없습니다.</p>`);
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 = `<p class="q-prompt">${esc(q.prompt)}</p>${q.quote ? `<blockquote class="q-quote">${esc(q.quote)}</blockquote>` : ""}
<div class="q-choices ${q.qtype === "ox" ? "ox" : ""}">${q.choices.map((c, i) => `<button type="button" class="q-choice" data-i="${i}"><span class="q-no">${q.qtype === "ox" ? "" : i + 1}</span>${esc(c)}</button>`).join("")}</div>
<div class="q-result" aria-live="polite"></div>`;
$$(".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 = `<b class="${r.correct ? "ok" : "no"}">${r.correct ? "정답이에요!" : "아쉬워요."}</b> ${esc(r.explanation || "")}
${showTermLink && t ? ` <button type="button" class="more-btn" data-show="${t.i}">지도에서 ‘${esc(t.n)}’ 보기</button>` : ""}`;
$("[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 = `<div class="kicker">문제 풀기</div><h2>단원별 확인 문제</h2>${lockedHtml("문제 풀기")}`;
return;
}
const gopts = S.data.galaxies.map((g) => `<option value="${g.galaxy_id}"${r.galaxy === g.galaxy_id ? " selected" : ""}>${g.chapter_no}장 ${esc(g.name)}</option>`).join("");
detail.innerHTML = `<div class="kicker">문제 풀기</div><h2>${r.wrong ? "틀린 문제 다시 풀기" : "단원별 확인 문제"}</h2>
<p class="muted" style="font-size:12.5px">쉬운 뜻을 보고 용어를 고르거나, 용어를 보고 뜻을 고릅니다. 풀이 기록은 내 계정에서 볼 수 있어요.</p>
<div class="row" style="margin:10px 0">
<select class="input grow" id="qz-g" aria-label="단원"><option value="">모든 단원</option>${gopts}</select>
<select class="input" id="qz-n" aria-label="문제 수" style="width:auto"><option>5</option><option selected>10</option><option>20</option></select>
<button type="button" class="btn primary" id="qz-go">시작</button>
</div>
<div id="qz-body"></div>`;
const start = async () => {
const body = $("#qz-body");
body.innerHTML = `<p class="d-none">문제를 만드는 중…</p>`;
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 = `<p class="d-none">${esc(e.message)}</p>`;
return;
}
if (!d.questions.length) {
body.innerHTML = `<p class="d-none">${r.wrong ? "다시 풀 틀린 문제가 없어요. 잘했어요!" : "이 단원에서 만들 수 있는 문제가 아직 없습니다."}</p>`;
return;
}
let idx = 0, score = 0;
const show = () => {
if (idx >= d.questions.length) {
body.innerHTML = `<div class="qz-done"><b>${score} / ${d.questions.length}</b><span>맞혔어요</span></div>
<div class="row"><button type="button" class="btn primary" id="qz-again">다시 풀기</button><a class="btn" href="/account">내 기록 보기</a></div>`;
$("#qz-again").onclick = start;
return;
}
body.innerHTML = `<div class="qz-progress"><span>${idx + 1} / ${d.questions.length}</span><span>맞힌 문제 ${score}</span></div><div id="qz-q"></div>
<button type="button" class="btn" id="qz-next" hidden style="width:100%;margin-top:10px">${idx + 1 < d.questions.length ? "다음 문제" : "결과 보기"}</button>`;
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 `<button type="button" class="result" role="option" id="opt-${idx}" data-id="${t.i}" aria-selected="${idx === activeIdx}">
<span><strong>${esc(t.n)}</strong> ${statusBadge(t.st)}<small>${esc(g?.name || "")}${sec ? " › " + esc(sec.title) : ""}</small></span>
<span class="why">${esc(h.reason)}</span></button>`;
}
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 = `<div class="result-head"><span>등록된 용어·동의어·설명에서 ${hits.length}개 찾음</span><button type="button" data-close>닫기</button></div>` +
(shown.length ? shown.map((h, i) => resultRow(h, i, active)).join("") +
(hits.length > 8 ? `<button type="button" class="btn more" data-all>${hits.length}개 모두 보기</button>` : "")
: `<p class="empty">일치하는 용어가 없습니다.<br>더 짧은 단어로 찾거나, 지도에서 은하(주제)를 골라 보세요.<br><a href="/feedback?page=${encodeURIComponent(location.href)}">찾는 용어가 없다면 알려 주세요</a></p>`);
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 = `<div class="kicker">검색 결과</div><h2>‘${esc(q)}’</h2><p class="muted">${hits.length}개 · 용어·동의어·검색 표현·설명 문자열에서 찾았습니다.</p>
<div class="neighbors" style="margin-top:12px">${hits.slice(0, (page + 1) * per).map((h) => {
const g = termGalaxy(h.t), sec = S.sectionById.get(h.t.s);
return `<a class="neighbor" href="#/t/${h.t.i}"><span class="n-name"><span>${esc(h.t.n)}</span><span class="dir">${esc(h.reason)}</span></span><small>${esc(g?.name || "")}${sec ? " › " + esc(sec.title) : ""}</small></a>`;
}).join("") || `<p class="d-none">일치하는 용어가 없습니다. 짧은 단어나 초성으로 다시 찾아보세요.</p>`}</div>
${hits.length > (page + 1) * per ? `<button type="button" class="btn" style="margin-top:10px;width:100%" data-more-results>더 보기</button>` : ""}`;
$("[data-more-results]", detail)?.addEventListener("click", () => { page++; draw(); });
};
draw();
$("#q").value = q;
}
/** 경로·비교용 작은 검색 선택기 */
function picker(el, label, id, onPick) {
el.className = "picker";
el.innerHTML = `<label>${esc(label)}<input class="input" type="search" autocomplete="off" placeholder="용어 검색" value="${esc(id ? S.byId.get(id)?.n || "" : "")}"></label><div class="results" hidden></div>`;
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("") || `<p class="empty">일치하는 용어가 없습니다.</p>`;
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 = `<div class="kicker">경로 찾기</div><h2>두 개념은 어떻게 이어질까?</h2>
<p class="muted" style="font-size:12.5px">등록된 관계만 따라갑니다. 직접 연결이 없으면 중간 개념을 거치는 가장 짧은 길을 보여 줍니다.</p>
<div id="pk-a"></div><div id="pk-b"></div>
<label class="row" style="font-size:12.5px"><input type="checkbox" id="pk-conf"> 확정된 관계만 사용</label>
<div id="path-out" class="d-sec"></div>`;
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 = `<p class="d-none">두 개념을 고르세요.</p>`; return; }
computePath(A, B, conf.checked);
refreshScene();
position();
const p = S.path;
if (A === B) { out.innerHTML = `<p class="d-none">서로 다른 두 개념을 고르세요.</p>`; return; }
if (!p.steps) {
out.innerHTML = `<h3>결과</h3><p>등록된 관계로는 두 개념이 이어지지 않습니다 (최대 8단계까지 확인).</p><p class="muted" style="font-size:12.5px">관계가 있어야 한다고 생각하면 <a href="/feedback?term=${A}&page=${encodeURIComponent(location.href)}">오류 신고</a>로 알려 주세요.</p>`;
return;
}
const n = p.steps.length;
out.innerHTML = `<h3>결과 · ${n === 1 ? "직접 연결" : `${n - 1}개 개념을 거쳐 연결`}</h3>
<ol class="path-steps"><li><a href="#/t/${A}">${esc(S.byId.get(A).n)}</a></li>${p.steps.map((st) => `<li class="edge-li"><div class="edge">${esc(stepPhrase(st))}${st.rel.st !== "confirmed" ? ` <span class="badge ${st.rel.st}">${STATUS_LABEL[st.rel.st]}</span>` : ""}</div><a href="#/t/${st.to}">${esc(S.byId.get(st.to).n)}</a> <span class="faint" style="font-size:11.5px">${esc(termGalaxy(S.byId.get(st.to))?.name || "")}</span></li>`).join("")}</ol>
<p class="muted" style="font-size:12px;margin-top:10px">지도에서는 같은 절에 있는 경로가 노란색으로 표시됩니다.</p>`;
};
conf.addEventListener("change", draw);
draw();
}
// ------------------------------------------------------------------ 비교
function renderComparePanel(a, b) {
detail.innerHTML = `<div class="kicker">비교하기</div><h2>두 개념을 나란히</h2>
<p class="muted" style="font-size:12.5px">헷갈리는 두 용어의 뜻, 사례, 연결을 나란히 봅니다.</p>
<div id="cp-a"></div><div id="cp-b"></div>
<button type="button" class="btn primary" id="cp-go" style="width:100%">비교 열기</button>
<div class="d-sec" id="cp-suggest"></div>`;
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 ? `<h3>함께 비교하면 좋은 개념</h3><div class="neighbors">${pairs.map((r) => {
const o = r.s === A ? r.t : r.s;
return `<a class="neighbor" href="#/compare/${A}/${o}"><span class="n-name">${esc(S.byId.get(o).n)}</span><small>${esc(r.l || "")}</small></a>`;
}).join("")}</div>` : "";
};
suggest();
}
async function openCompare(a, b) {
const dlg = $(".compare-dialog");
dlg.innerHTML = `<div class="cmp-head"><h2>비교하는 중…</h2></div>`;
if (!dlg.open) dlg.showModal();
let A, B;
try {
[A, B] = await Promise.all([getDetail(a), getDetail(b)]);
} catch (e) {
dlg.innerHTML = `<div class="cmp-head"><h2>불러오지 못했습니다</h2><button class="btn" data-close>닫기</button></div>`;
$("[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 `<div class="cmp-col" style="--glow:${g?.color}">
<div class="d-path">${esc(g?.name || "")}${t.section ? " › " + esc(t.section.title) : ""}</div>
<h2><a href="#/t/${t.term_id}" data-close style="color:inherit">${esc(t.name)}</a> ${statusBadge(t.review_status)}</h2>
<div class="d-alias">${esc((t.aliases || []).slice(0, 4).join(", "))}</div>
<h3>쉬운 뜻</h3>${t.easy ? `<p>${esc(t.easy)}</p>` : `<p class="d-none">아직 작성되지 않았습니다.</p>`}
${t.example ? `<h3>사례</h3><p class="example">${esc(t.example)}</p>` : ""}
<h3>연결 ${t.relations.length}개</h3><p style="font-size:12.5px">${t.relations.slice(0, 8).map((r) => esc(r.other.name)).join(", ") || "-"}</p>
</div>`;
};
dlg.innerHTML = `<div class="cmp-head"><h2>${esc(A.name)} <span class="faint">vs</span> ${esc(B.name)}</h2><button type="button" class="btn" data-close autofocus>닫기</button></div>
<div class="cmp-bridge">${direct.length ? direct.map((r) => `<b>${esc(REL_TYPE_LABEL[r.type])}</b> · ${esc(r.label || "")} ${r.review_status !== "confirmed" ? `<span class="badge ${r.review_status}">${STATUS_LABEL[r.review_status]}</span>` : ""}`).join("<br>") : "두 개념 사이에 직접 등록된 관계는 없습니다."}
${shared.length ? `<div class="muted" style="margin-top:4px">공통으로 연결된 개념: ${shared.map((o) => esc(o.name)).join(", ")}</div>` : ""}</div>
<div class="cmp-grid">${col(A)}${col(B)}</div>`;
$$("[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();