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

155 lines
5.8 KiB
JavaScript

// 지도 배치와 그래프 계산 (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;
}