UNPKG

korean-search-utils

Version:

한글 초성 검색 및 React 훅 지원 라이브러리

232 lines (228 loc) 7.64 kB
import { useState, useMemo } from '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] = useState(""); const filteredItems = 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, }; } export { disassembleHangul, filterAndSort, getMatchScore, getMatchSegments, getMatchStartIndex, isMatch, useKoreanSearch }; //# sourceMappingURL=index.js.map