// 지도 배치와 그래프 계산 (DOM 없음). 좌표는 '세계 좌표'이며 화면에서 확대·이동을 적용한다. const GOLDEN = Math.PI * (3 - Math.sqrt(5)); // 전체 우주: 은하 9개의 고정 배치 (지역사회 간호과정을 가운데에) const UNIVERSE_SLOTS = { g1: [-0.36, -0.30], g2: [0, -0.36], g4: [0.36, -0.30], g5: [-0.40, 0.02], g3: [0, 0], g6: [0.40, 0.02], g7: [-0.34, 0.33], g8: [0, 0.38], g9: [0.34, 0.33], }; export function universeLayout(galaxies) { const W = 1000, H = 760; const pos = {}; galaxies.forEach((g, i) => { const slot = UNIVERSE_SLOTS[g.galaxy_id] || [Math.cos(i) * 0.35, Math.sin(i) * 0.3]; pos[g.galaxy_id] = { x: slot[0] * W, y: slot[1] * H }; }); return { pos, bounds: { minX: -W / 2, maxX: W / 2, minY: -H / 2, maxY: H / 2 } }; } /** 은하 안: 절(별자리)을 원형으로 */ export function galaxyLayout(sections, countOf) { const n = sections.length; const sizes = sections.map((s) => 128 + Math.sqrt(countOf(s.id)) * 7); // 지름 const gap = 34; const circumference = sizes.reduce((a, b) => a + b + gap, 0); const R = Math.max(240, circumference / (2 * Math.PI) / 0.9); const pos = {}; let angle = -Math.PI / 2; sections.forEach((s, i) => { const share = ((sizes[i] + gap) / circumference) * Math.PI * 2; const a = angle + share / 2; pos[s.id] = { x: Math.cos(a) * R, y: Math.sin(a) * R * 0.86, r: sizes[i] / 2 }; angle += share; }); const pad = Math.max(...sizes, 60) / 2 + 30; return { pos, R, bounds: { minX: -R - pad, maxX: R + pad, minY: -R * 0.86 - pad, maxY: R * 0.86 + pad } }; } /** 절 안: 항목별 작은 묶음 + 묶음 안 해바라기 배치 */ export function sectionLayout(groups) { // groups: [{id, title, terms:[term]}] const SPACING = 40; const clusters = groups.map((g) => ({ ...g, r: Math.sqrt(g.terms.length) * SPACING * 0.62 + 34 })); const pos = {}; const centers = {}; if (clusters.length === 1) { centers[clusters[0].id] = { x: 0, y: 0, r: clusters[0].r }; } else { const circumference = clusters.reduce((a, c) => a + c.r * 2 + 50, 0); const R = Math.max(230, circumference / (2 * Math.PI)); let angle = -Math.PI / 2; for (const c of clusters) { const share = ((c.r * 2 + 50) / circumference) * Math.PI * 2; const a = angle + share / 2; centers[c.id] = { x: Math.cos(a) * R, y: Math.sin(a) * R, r: c.r }; angle += share; } } for (const c of clusters) { const ctr = centers[c.id]; // 이름순 정렬 후 해바라기 배치 (관계 많은 별은 가운데) c.terms.forEach((t, k) => { const rr = Math.sqrt(k + 0.6) * SPACING * 0.62; const a = k * GOLDEN; pos[t.i] = { x: ctr.x + Math.cos(a) * rr, y: ctr.y + Math.sin(a) * rr * 0.9 }; }); } let minX = 0, maxX = 0, minY = 0, maxY = 0; for (const c of Object.values(centers)) { minX = Math.min(minX, c.x - c.r); maxX = Math.max(maxX, c.x + c.r); minY = Math.min(minY, c.y - c.r); maxY = Math.max(maxY, c.y + c.r); } const pad = 70; return { pos, centers, bounds: { minX: minX - pad, maxX: maxX + pad, minY: minY - pad - 20, maxY: maxY + pad } }; } /** 화면 크기에 맞춰 보기 (scale, tx, ty) */ export function fit(bounds, w, h, { max = 1.4, min = 0.25, margin = 24 } = {}) { const bw = bounds.maxX - bounds.minX, bh = bounds.maxY - bounds.minY; const s = Math.max(min, Math.min(max, (w - margin * 2) / bw, (h - margin * 2) / bh)); const cx = (bounds.minX + bounds.maxX) / 2, cy = (bounds.minY + bounds.maxY) / 2; return { s, tx: w / 2 - cx * s, ty: h / 2 - cy * s }; } /** 라벨 겹침 제거: 우선순위 순으로 넣고 겹치면 숨김. items: [{id, x, y, w, h, prio}] → Set(보이는 id) */ export function cullLabels(items) { const placed = []; const visible = new Set(); const sorted = [...items].sort((a, b) => b.prio - a.prio); const cell = 80; const grid = new Map(); const key = (gx, gy) => gx + "," + gy; for (const it of sorted) { const box = { x0: it.x - it.w / 2, x1: it.x + it.w / 2, y0: it.y, y1: it.y + it.h }; let hit = false; const gx0 = Math.floor(box.x0 / cell), gx1 = Math.floor(box.x1 / cell); const gy0 = Math.floor(box.y0 / cell), gy1 = Math.floor(box.y1 / cell); outer: for (let gx = gx0; gx <= gx1; gx++) { for (let gy = gy0; gy <= gy1; gy++) { for (const o of grid.get(key(gx, gy)) || []) { if (box.x0 < o.x1 && box.x1 > o.x0 && box.y0 < o.y1 && box.y1 > o.y0) { hit = true; break outer; } } } } if (hit && !it.force) continue; visible.add(it.id); placed.push(box); for (let gx = gx0; gx <= gx1; gx++) for (let gy = gy0; gy <= gy1; gy++) { const k = key(gx, gy); if (!grid.has(k)) grid.set(k, []); grid.get(k).push(box); } } return visible; } export function labelWidth(text, px = 12) { let w = 0; for (const ch of text) w += /[ㄱ-힣]/.test(ch) ? px * 1.1 : px * 0.6; return w + 16; } /** 등록된 관계로 최단 경로 (방향 무시) — [{term, rel, forward}] 또는 null */ export function shortestPath(adj, from, to, { maxDepth = 8, allow = () => true } = {}) { if (from === to) return []; const prev = new Map([[from, null]]); let frontier = [from]; for (let depth = 0; depth < maxDepth && frontier.length; depth++) { const next = []; for (const id of frontier) { for (const r of adj.get(id) || []) { if (!allow(r)) continue; const other = r.s === id ? r.t : r.s; if (prev.has(other)) continue; prev.set(other, { id, r }); if (other === to) { const path = []; let cur = to; while (prev.get(cur)) { const p = prev.get(cur); path.unshift({ from: p.id, to: cur, rel: p.r, forward: p.r.s === p.id }); cur = p.id; } return path; } next.push(other); } } frontier = next; } return null; }