- 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.
1594 lines
76 KiB
JavaScript
1594 lines
76 KiB
JavaScript
// 개념 우주 메인 화면: 우주(단원) → 은하(절) → 별자리(용어) 탐색, 검색, 상세, 경로 찾기, 비교.
|
||
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();
|