- 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.
132 lines
4.8 KiB
JavaScript
132 lines
4.8 KiB
JavaScript
// 규칙 기반 용어 검색 (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 "기타";
|
||
}
|