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

132 lines
4.8 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.
// 규칙 기반 용어 검색 (AI 없음). 등록된 이름·동의어·검색 표현·설명 문자열만 찾는다.
const PUNCT = /[\s·\-–_/.,()()'"’‘“”「」『』\[\]:;!?~]/g;
const CHO = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ";
export function normalize(s) {
return String(s ?? "").normalize("NFC").toLowerCase().replace(PUNCT, "");
}
export function choseong(s) {
let out = "";
for (const ch of String(s ?? "")) {
const c = ch.charCodeAt(0);
if (c >= 0xac00 && c <= 0xd7a3) out += CHO[Math.floor((c - 0xac00) / 588)];
else if (!PUNCT.test(ch)) out += ch.toLowerCase();
PUNCT.lastIndex = 0;
}
return out;
}
export const isChoseongQuery = (q) => /^[ㄱ-ㅎ]{2,}$/.test(q.replace(/\s/g, ""));
function editDistance(a, b, max) {
if (Math.abs(a.length - b.length) > max) return max + 1;
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
const cur = [i];
let rowMin = i;
for (let j = 1; j <= b.length; j++) {
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
rowMin = Math.min(rowMin, cur[j]);
}
if (rowMin > max) return max + 1;
prev = cur;
}
return prev[b.length];
}
/** terms: [{i, n, a:[], q:[], x}] → 검색용 색인 */
export function buildIndex(terms) {
return terms.map((t) => ({
t,
name: normalize(t.n),
cho: choseong(t.n),
aliases: (t.a || []).map((a) => [a, normalize(a)]),
extra: (t.q || []).map((a) => [a, normalize(a)]),
text: normalize(t.x || ""),
}));
}
/**
* 반환: [{t, score, reason}] 점수순.
* reason: 사용자에게 보여줄 일치 이유 (예: "동의어 ‘HBM’ 일치")
*/
export function search(index, query, { limit = Infinity, fuzzy = true } = {}) {
const raw = String(query ?? "").trim();
if (!raw) return [];
const q = normalize(raw);
const words = raw.split(/[\s,]+/).map(normalize).filter(Boolean);
const cho = isChoseongQuery(raw) ? raw.replace(/\s/g, "") : null;
const hits = [];
for (const e of index) {
let score = 0;
let reason = "";
if (q) {
if (e.name === q) { score = 100; reason = "용어 일치"; }
else if (e.name.startsWith(q)) { score = 80; reason = "용어 앞부분 일치"; }
else if (e.name.includes(q)) { score = 62; reason = "용어 일부 일치"; }
if (score < 70) {
for (const [orig, a] of e.aliases) {
if (a === q) { score = 72; reason = `동의어 ‘${orig}’ 일치`; break; }
if (a.includes(q) && score < 46) { score = 46; reason = `동의어 ‘${orig}’에 포함`; }
}
}
if (score < 42) {
for (const [orig, a] of e.extra) {
if (a.includes(q) || (a.length >= 3 && q.includes(a))) { score = 42; reason = `검색 표현 ‘${orig}’`; break; }
}
}
if (!score && e.text.includes(q)) { score = 16; reason = "설명 속 단어"; }
}
if (words.length > 1) {
let matched = 0;
let sub = 0;
for (const w of words) {
if (e.name.includes(w)) { sub += 8; matched++; }
else if (e.aliases.some(([, a]) => a.includes(w)) || e.extra.some(([, a]) => a.includes(w))) { sub += 5; matched++; }
else if (e.text.includes(w)) { sub += 2; matched++; }
}
if (matched) {
const all = matched === words.length;
const s = sub + (all ? 12 : 0);
if (s > score) {
score = s;
reason = all ? "모든 단어 포함" : `${words.length}개 단어 중 ${matched}개 포함`;
}
}
}
if (cho && !score) {
if (e.cho.startsWith(cho)) { score = 50; reason = "초성 일치"; }
else if (e.cho.includes(cho)) { score = 34; reason = "초성 포함"; }
}
if (score) hits.push({ t: e.t, score: score - Math.min(e.name.length, 30) * 0.05, reason });
}
if (fuzzy && hits.length < 3 && q.length >= 2 && !cho) {
const max = q.length <= 4 ? 1 : 2;
const seen = new Set(hits.map((h) => h.t.i));
for (const e of index) {
if (seen.has(e.t.i)) continue;
let d = editDistance(q, e.name, max);
if (d > max && e.name.length > q.length) {
// 긴 이름 앞부분과 비교 (예: '우선순이' → '우선순위 설정')
d = editDistance(q, e.name.slice(0, q.length), max);
}
if (d <= max) hits.push({ t: e.t, score: 8 - d, reason: "비슷한 표기" });
}
}
hits.sort((a, b) => b.score - a.score || a.t.n.localeCompare(b.t.n, "ko"));
return hits.slice(0, limit);
}
/** 가나다 색인용 첫 글자 묶음 */
export function initialGroup(name) {
const c = String(name).charCodeAt(0);
if (c >= 0xac00 && c <= 0xd7a3) {
const ch = CHO[Math.floor((c - 0xac00) / 588)];
return { ㄲ: "ㄱ", ㄸ: "ㄷ", ㅃ: "ㅂ", ㅆ: "ㅅ", ㅉ: "ㅈ" }[ch] || ch;
}
if (/[a-z]/i.test(name[0])) return "A–Z";
return "기타";
}