korean-search-utils
Version:
한글 초성 검색 및 React 훅 지원 라이브러리
240 lines (235 loc) • 7.82 kB
JavaScript
'use strict';
var react = require('react');
const CHO = [
"ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", "ㅅ",
"ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ",
];
const JUNG = [
"ㅏ", "ㅐ", "ㅑ", "ㅒ", "ㅓ", "ㅔ", "ㅕ", "ㅖ", "ㅗ", "ㅘ",
"ㅙ", "ㅚ", "ㅛ", "ㅜ", "ㅝ", "ㅞ", "ㅟ", "ㅠ", "ㅡ", "ㅢ", "ㅣ",
];
const JONG = [
"", "ㄱ", "ㄲ", "ㄳ", "ㄴ", "ㄵ", "ㄶ", "ㄷ", "ㄹ", "ㄺ",
"ㄻ", "ㄼ", "ㄽ", "ㄾ", "ㄿ", "ㅀ", "ㅁ", "ㅂ", "ㅄ", "ㅅ",
"ㅆ", "ㅇ", "ㅈ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ",
];
const HANGUL_START = 0xac00;
const HANGUL_END = 0xd7a3;
const JUNG_COUNT = 21;
const JONG_COUNT = 28;
const splitHangulChar = (char) => {
const code = char.charCodeAt(0);
if (code < HANGUL_START || code > HANGUL_END)
return [char];
const idx = code - HANGUL_START;
const cho = CHO[Math.floor(idx / (JUNG_COUNT * JONG_COUNT))];
const jung = JUNG[Math.floor((idx % (JUNG_COUNT * JONG_COUNT)) / JONG_COUNT)];
const jong = JONG[idx % JONG_COUNT];
return jong ? [cho, jung, jong] : [cho, jung];
};
/**
* 문자열 전체를 자모로 분리 (공백 제거)
*/
const disassembleHangul = (str) => str
.replace(/\s+/g, "")
.toLowerCase()
.split("")
.flatMap(splitHangulChar)
.join("");
/**
* 한글 문자열이 다른 문자열을 포함하는지 확인 (초성/자모 fuzzy 검색 지원)
*/
const isMatch = (source, target) => {
if (!source || !target)
return target === "";
const src = disassembleHangul(source);
const tgt = disassembleHangul(target);
let i = 0;
for (let j = 0; j < tgt.length; j++) {
let found = false;
while (i < src.length) {
if (src[i++] === tgt[j]) {
found = true;
break;
}
}
if (!found)
return false;
}
return true;
};
/**
* 검색어가 문자열에서 매칭되는 시작 위치 인덱스를 반환 (자모 기준, 매칭 실패 시 -1)
*/
const getMatchStartIndex = (source, target) => {
if (!source || !target)
return target === "" ? 0 : -1;
const src = disassembleHangul(source);
const tgt = disassembleHangul(target);
let i = 0;
let matchStartIndex = -1;
for (let j = 0; j < tgt.length; j++) {
let found = false;
while (i < src.length) {
if (src[i] === tgt[j]) {
if (j === 0)
matchStartIndex = i;
found = true;
i++;
break;
}
i++;
}
if (!found)
return -1;
}
return matchStartIndex;
};
/**
* 매칭 품질 점수를 반환 (낮을수록 상위, 매칭되지 않으면 Infinity)
* 매칭 시작 위치가 빠를수록, 원본 문자열이 짧을수록 높은 우선순위
*/
const getMatchScore = (source, target) => {
const matchIndex = getMatchStartIndex(source, target);
if (matchIndex === -1)
return Infinity;
return matchIndex * 1000 + source.length;
};
/**
* 검색어와 매칭되는 구간을 세그먼트 배열로 반환 — UI 하이라이팅에 활용
* @example
* getMatchSegments("홍길동", "길")
* // → [{text:"홍", matched:false}, {text:"길", matched:true}, {text:"동", matched:false}]
*/
const getMatchSegments = (source, target) => {
if (!source)
return [];
if (!target.trim())
return [{ text: source, matched: false }];
const tgt = disassembleHangul(target);
const charCount = source.length;
const charMatched = new Uint8Array(charCount);
let tgtIdx = 0;
for (let i = 0; i < charCount && tgtIdx < tgt.length; i++) {
const char = source[i];
if (char === " ")
continue;
for (const jamo of splitHangulChar(char.toLowerCase())) {
if (tgtIdx < tgt.length && jamo === tgt[tgtIdx]) {
charMatched[i] = 1;
tgtIdx++;
}
}
}
if (tgtIdx < tgt.length)
return [{ text: source, matched: false }];
const segments = [];
let i = 0;
while (i < charCount) {
const matched = charMatched[i] === 1;
let j = i + 1;
while (j < charCount && (charMatched[j] === 1) === matched)
j++;
segments.push({ text: source.slice(i, j), matched });
i = j;
}
return segments;
};
/**
* React 없이 사용 가능한 필터+정렬 유틸리티
* @param items 검색 대상 배열
* @param target 검색어
* @param getField 각 아이템에서 검색할 문자열을 반환하는 함수
*/
const filterAndSort = (items, target, getField) => {
if (!target.trim())
return items;
const tgt = disassembleHangul(target);
const scored = [];
for (const item of items) {
const field = getField(item);
const src = disassembleHangul(field);
let i = 0;
let matchStartIndex = -1;
outer: {
for (let j = 0; j < tgt.length; j++) {
let found = false;
while (i < src.length) {
if (src[i] === tgt[j]) {
if (j === 0)
matchStartIndex = i;
found = true;
i++;
break;
}
i++;
}
if (!found) {
matchStartIndex = -1;
break outer;
}
}
}
if (matchStartIndex !== -1) {
scored.push({ item, score: matchStartIndex * 1000 + field.length });
}
}
return scored.sort((a, b) => a.score - b.score).map(({ item }) => item);
};
function useKoreanSearch(items, getSearchField) {
const [searchTerm, setSearchTerm] = react.useState("");
const filteredItems = react.useMemo(() => {
const trimmed = searchTerm.trim();
if (!trimmed)
return items;
// 검색어 자모 분해를 아이템 순회 전에 한 번만 수행
const tgt = disassembleHangul(trimmed);
const scored = [];
for (const item of items) {
const field = getSearchField(item);
const src = disassembleHangul(field);
let i = 0;
let matchStartIndex = -1;
let matched = true;
for (let j = 0; j < tgt.length; j++) {
let found = false;
while (i < src.length) {
if (src[i] === tgt[j]) {
if (j === 0)
matchStartIndex = i;
found = true;
i++;
break;
}
i++;
}
if (!found) {
matched = false;
break;
}
}
if (matched && matchStartIndex !== -1) {
scored.push({ item, score: matchStartIndex * 1000 + field.length });
}
}
return scored.sort((a, b) => a.score - b.score).map(({ item }) => item);
}, [items, searchTerm, getSearchField]);
return {
searchTerm,
setSearchTerm,
filteredItems,
resetSearch: () => setSearchTerm(""),
isMatch,
disassembleHangul,
getMatchSegments,
filterAndSort,
};
}
exports.disassembleHangul = disassembleHangul;
exports.filterAndSort = filterAndSort;
exports.getMatchScore = getMatchScore;
exports.getMatchSegments = getMatchSegments;
exports.getMatchStartIndex = getMatchStartIndex;
exports.isMatch = isMatch;
exports.useKoreanSearch = useKoreanSearch;
//# sourceMappingURL=index.cjs.map