wr: 도로명 입력시 추천 나타나고 결과주소선택시 시도,시군구 설정

This commit is contained in:
2026-03-24 16:38:16 +09:00
parent c86dd2bb9e
commit 7cb1a9ab6f
5 changed files with 477 additions and 207 deletions
@@ -235,6 +235,47 @@ export function SdmRegForm({
[form],
);
const applyZipRegion = useCallback(
(ctpvNm?: string, sggNm?: string) => {
const normalizeRegionName = (value?: string) =>
value?.replace(/\s+/g, "").trim() ?? "";
const sidoName = normalizeRegionName(ctpvNm);
const sigunName = normalizeRegionName(sggNm);
if (!sidoName) return;
const matchedSido = selectSidoData.find(
(item) => normalizeRegionName(item.text) === sidoName,
);
if (!matchedSido) return;
setSidoValue(matchedSido.val);
form.setValue("ctpvCd", matchedSido.val, { shouldValidate: true });
initializeSigunData(matchedSido.val);
if (!sigunName) return;
const sigunCandidates = selectSigunData.filter(
(item) => item.val.substring(0, 2) === matchedSido.val,
);
const matchedSigun =
sigunCandidates.find(
(item) => normalizeRegionName(item.text) === sigunName,
) ??
sigunCandidates.find((item) => {
const itemName = normalizeRegionName(item.text);
return itemName.includes(sigunName) || sigunName.includes(itemName);
});
if (!matchedSigun) return;
setSigunValue(matchedSigun.val);
form.setValue("sggCd", matchedSigun.val, { shouldValidate: true });
},
[form, initializeSigunData, selectSidoData, selectSigunData],
);
const bznNoChange = useCallback(
(
e: React.ChangeEvent<HTMLInputElement>,
@@ -584,6 +625,7 @@ export function SdmRegForm({
setZoneNo(zip.zoneNo || "");
setAddress(zip.zipAddr || "");
setDetailedAddress("");
applyZipRegion(zip.ctpvNm, zip.sggNm);
}}
/>
</>
@@ -1,12 +1,17 @@
/**
* 주소검색
*/
import { Button, Input, Label, cn } from "@common/components";
import { Button, Input, cn } from "@common/components";
import React, { useState, useEffect } from "react";
import { UiModal } from "../dialog_ui";
import { UiTable } from "../table";
import type { Zip, ZipSearchParams } from "./zipService";
import { findAddress } from "./findAddress";
import {
fetchPostcodeSuggestions,
findAddress,
parseZipSearchInput,
type Zip,
type ZipSearchParams,
} from "./zipService";
interface SearchAddressModalProps {
open: boolean;
@@ -14,20 +19,35 @@ interface SearchAddressModalProps {
onSelect: (zip: Zip) => void;
}
/**
* 주소 검색 값 감시
* @param value
* @param delay
* @returns
*/
function useDebouncedValue<T>(value: T, delay = 250) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timeoutId = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timeoutId);
}, [delay, value]);
return debouncedValue;
}
export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddressModalProps) {
const PAGE_SIZE = 10;
const [curPage, setCurPage] = useState<number>(1);
const [searchInput, setSearchInput] = useState<string>("");
const debouncedSearchInput = useDebouncedValue(searchInput, 250);
const [zipSearchResults, setZipSearchResults] = useState<Zip[]>([]);
const [zipTotalElements, setZipTotalElements] = useState<number>(-1); //주소 검색 결과 건수
const [searchQuery, setSearchQuery] = useState<ZipSearchParams>({
roadNm: "",
bldgNoMno: null,
bldgNoSno: null,
page: 1,
size: PAGE_SIZE
});
const [searchMessage, setSearchMessage] = useState<string>("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState<boolean>(true);
const [searchQuery, setSearchQuery] = useState<ZipSearchParams | undefined>(undefined);
const [isResetPage, setIsResetPage] = useState<boolean>(false);
const [isResetSelection] = useState<boolean>(false);
const [selectedRowIndex, setSelectedRowIndex] = useState<number | undefined>(undefined);
const [selectedZip, setSelectedZip] = useState<Zip>();
const [isSelectedZip, setIsSelectedZip] = useState<boolean>(false);
@@ -36,15 +56,13 @@ export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddre
useEffect(() => {
if (open) {
setCurPage(1);
setSearchInput("");
setZipSearchResults([]);
setZipTotalElements(-1);
setSearchQuery({
roadNm: "",
bldgNoMno: null,
bldgNoSno: null,
page: 0,
size: PAGE_SIZE
});
setSearchMessage("");
setSuggestions([]);
setShowSuggestions(true);
setSearchQuery(undefined);
setSelectedZip(undefined);
setIsSelectedZip(false);
setSelectedRowIndex(undefined);
@@ -53,43 +71,119 @@ export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddre
}
}, [open]);
useEffect(() => {
if (!open) return;
const keyword = debouncedSearchInput.trim();
if (!keyword) {
setSuggestions([]);
return;
}
if (!showSuggestions) {
setSuggestions([]);
return;
}
let alive = true;
const fetchSuggestions = async () => {
try {
const nextSuggestions = await fetchPostcodeSuggestions(keyword);
if (!alive) return;
setSuggestions(nextSuggestions);
} catch {
if (!alive) return;
setSuggestions([]);
}
};
void fetchSuggestions();
return () => {
alive = false;
};
}, [debouncedSearchInput, open, showSuggestions]);
// Pagination Effect
useEffect(() => {
if (!open || zipTotalElements === -1) return; // 주소 검색 결과 건수 초기값이 -1일 때는 아직 검색 전 상태
if (!open || zipTotalElements === -1 || !searchQuery) return; // 주소 검색 결과 건수 초기값이 -1일 때는 아직 검색 전 상태
// Correctly update query for pagination without mutation
const nextPage = curPage - 1;
if (searchQuery.page !== nextPage) {
const newQuery = { ...searchQuery, page: nextPage };
setSearchQuery(newQuery);
const newQuery = { ...searchQuery, page: nextPage };
setSearchQuery(newQuery);
setSelectedZip(undefined);
setIsSelectedZip(false);
setSelectedRowIndex(undefined);
const fetchAddress = async () => {
const fetchAddressByPage = async () => {
try {
const result = await findAddress(newQuery);
setZipSearchResults(result?.content || []);
};
fetchAddress();
setSearchMessage("");
setZipSearchResults(result?.content || []); // 주소검색결과
} catch {
setZipSearchResults([]);
setSearchMessage("주소 검색 중 오류가 발생했습니다.");
}
};
void fetchAddressByPage();
}
}, [curPage, open, zipTotalElements]);
}, [curPage, open, searchQuery, zipTotalElements]);
const handleCellClick = (index: number | undefined) => {
if (index === undefined) return;
setSelectedZip(zipSearchResults[index]);
const zip = zipSearchResults[index];
if (!zip) return;
setSelectedZip(zip);
setIsSelectedZip(true);
setSelectedRowIndex(index);
onSelect(zip);
onOpenChange(false);
};
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault(); // 폼 제출 동작을 막음.
e.stopPropagation(); // 이벤트 버블링을 막음. 이벤트가 부모 컴포넌트나 바깥쪽 핸들러로 올라가지 않도록 막음.
setShowSuggestions(false);
setSuggestions([]);
const query = { ...searchQuery, page: 0 };
const parsed = parseZipSearchInput(searchInput, 1);
if (!parsed.ok) {
setSearchMessage(parsed.message);
setZipSearchResults([]);
setZipTotalElements(-1);
setSelectedZip(undefined);
setIsSelectedZip(false);
setSelectedRowIndex(undefined);
return;
}
const query: ZipSearchParams = {
roadNm: parsed.value.roadNm,
bldgNoMno: parsed.value.bldgNoMno,
bldgNoSno: parsed.value.bldgNoSno,
page: parsed.value.page - 1,
size: PAGE_SIZE,
};
setSearchMessage("");
setSearchQuery(query);
setCurPage(1);
setIsResetPage(true);
setSelectedZip(undefined);
setIsSelectedZip(false);
setSelectedRowIndex(undefined);
const result = await findAddress(query);
setZipSearchResults(result?.content || []);
setZipTotalElements(result?.totalElements || 0); //주소 검색 결과 건수
try {
const result = await findAddress(query);
setZipSearchResults(result?.content || []);
setZipTotalElements(result?.totalElements ?? 0); //주소 검색 결과 건수
} catch {
setZipSearchResults([]);
setZipTotalElements(-1);
setSearchMessage("주소 검색 중 오류가 발생했습니다.");
}
setTimeout(() => setIsResetPage(false), 100);
};
@@ -101,6 +195,8 @@ export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddre
}
};
const showSuggest = open && showSuggestions && searchInput.trim().length >= 1 && suggestions.length > 0;
return (
<UiModal
headerTitle="주소 검색"
@@ -129,64 +225,44 @@ export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddre
onSubmit={handleSearch}
className="mb-4 space-y-2"
>
<div className="card card-border !m-0 flex flex-col gap-4 p-4">
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col">
<Label className="mb-1 font-bold text-base">도로명</Label>
<Input
value={searchQuery.roadNm || ""}
onChange={(e) =>
setSearchQuery({
...searchQuery,
roadNm: e.target.value,
})
}
placeholder="예) 테헤란로 152-1"
className="!text-[1.3rem]"
/>
</div>
<div className="flex flex-col">
<Label className="mb-1 text-base">건물본번</Label>
<Input
type="number"
value={searchQuery.bldgNoMno ?? ""}
onChange={(e) => {
const value = e.target.value
? parseInt(e.target.value)
: null;
setSearchQuery({
...searchQuery,
bldgNoMno: value,
});
}}
placeholder="예: 123"
className="!text-[1.3rem]"
/>
</div>
<div className="flex flex-col">
<Label className="mb-1 text-base">건물부번</Label>
<Input
type="number"
value={searchQuery.bldgNoSno ?? ""}
onChange={(e) => {
const value = e.target.value
? parseInt(e.target.value)
: null;
setSearchQuery({
...searchQuery,
bldgNoSno: value,
});
}}
placeholder="예: 45"
className="!text-[1.3rem]"
/>
<div className="card card-border !m-0 flex flex-col gap-3 p-4">
<div className="relative">
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Input
value={searchInput}
onChange={(e) => {
setSearchInput(e.target.value);
setShowSuggestions(true);
}}
placeholder="예) 테헤란로 152-1"
className="!text-[1.3rem]"
/>
{showSuggest && (
<div className="absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm hover:bg-gray-50"
onClick={() => {
setSearchInput(suggestion);
setShowSuggestions(false);
setSuggestions([]);
}}
>
<span className="truncate text-gray-800">{suggestion}</span>
<span className="ml-4 text-gray-400">↵</span>
</button>
))}
</div>
)}
</div>
<Button className="rounded-md shrink-0" type="submit">검색</Button>
</div>
</div>
<div className="flex justify-end">
<Button className="rounded-md" type="submit">검색</Button>
<div className="text-sm text-gray-600">
{searchMessage || "도로명과 건물번호를 함께 입력해 주세요. 예) 테헤란로 152-1"}
</div>
</div>
</form>
@@ -201,7 +277,6 @@ export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddre
cellClick={handleCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
index={selectedRowIndex}
/>
)}
@@ -1,118 +0,0 @@
// const Key = 'U01TX0FVVEgyMDI1MDgyODE0MTAxNzExNjEyMjA'
// const addresssKey = 'devU01TX0FVVEgyMDI1MDgyNTEwMzkwNDExNjEwNTM='; //인증키 만료일은 2025-11-23
import type { Zip, ZipSearchParams } from "./zipService";
/**
* 주소 API 응답 타입
* https://business.juso.go.kr/addrlink/main.do 해당 사이트 참조
*/
export type AddressApiResponse = {
results: {
common: {
errorMessage: string;
countPerPage: string;
totalCount: string;
errorCode: string;
currentPage: string;
};
juso: addressType[];
};
}
export type addressType = {
detBdNmList: string;
engAddr: string;
rn: string;
emdNm: string;
zipNo: string;
roadAddrPart2: string;
emdNo: string;
sggNm: string;
jibunAddr: string;
siNm: string;
roadAddrPart1: string;
bdNm: string;
admCd: string;
udrtYn: string;
lnbrMnnm: string;
roadAddr: string;
lnbrSlno: string;
buldMnnm: string;
bdKdcd: string;
liNm: string;
rnMgtSn: string;
mtYn: string;
bdMgtSn: string;
buldSlno: string;
}
/**
* 주소에 따른 검색
* @param search : 검색어
* @returns addressType[] 주소 배열
*/
export const findAddress = async (searchQuery: ZipSearchParams): Promise<any> => {
if (!searchQuery) return [];
// 쿼리 파라미터 생성
const params = new URLSearchParams();
if (searchQuery.roadNm) params.append('roadNm', searchQuery.roadNm);
if (searchQuery.bldgNoMno != null) params.append('bldgNoMno', searchQuery.bldgNoMno.toString());
if (searchQuery.bldgNoSno != null) params.append('bldgNoSno', searchQuery.bldgNoSno.toString());
if (searchQuery.page != null) params.append('page', searchQuery.page.toString());
if (searchQuery.size != null) params.append('size', searchQuery.size.toString());
const url = `/cms-service/api/v1/zips?${params.toString()}`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Network response was not ok');
const data = await response.json();
if (data) return data
else return [];
} catch (error) {
console.error('Error fetching address data:', error);
return [];
}
}
export const searchFilter = (search: string) => {
if (search.length > 0) {
//특수문자 제거
const expText = /[%=><]/;
if (expText.test(search) == true) {
alert("특수문자를 입력 할수 없습니다.");
search = search.split(expText).join("");
return false;
}
//특정문자열(sql예약어의 앞뒤공백포함) 제거
const sqlArray = [
//sql 예약어
"OR", "SELECT", "INSERT", "DELETE", "UPDATE", "CREATE", "DROP", "EXEC",
"UNION", "FETCH", "DECLARE", "TRUNCATE"
]
let regex;
for (let i = 0; i < sqlArray.length; i++) {
regex = new RegExp(sqlArray[i] as string, "gi");
if (regex.test(search)) {
alert("\"" + sqlArray[i] + "\"와(과) 같은 특정문자로 검색할 수 없습니다.");
search = search.replace(regex, "");
return false;
}
}
}
return true;
}
@@ -1,3 +1,2 @@
export * from "./SearchAddressModal";
export * from "./findAddress";
export * from "./zipService";
@@ -64,3 +64,275 @@ export interface Zip {
}
export type ZipList = PageableList<Zip>;
export interface ParsedZipSearchParams {
roadNm: string;
bldgNoMno: number;
bldgNoSno: number | null;
page: number;
}
export type ParseZipSearchInputResult =
| { ok: true; value: ParsedZipSearchParams; keyword: string }
| { ok: false; message: string };
type SuggestionSeed = {
roadNm: string;
bldgNoMno: number;
};
/**
* 주소 검색 API를 호출해 우편번호/주소 목록을 조회한다.
* @param searchQuery 조회 조건 객체
* @param searchQuery.roadNm 검색할 도로명
* @param searchQuery.bldgNoMno 건물 본번
* @param searchQuery.bldgNoSno 건물 부번
* @param searchQuery.page 0부터 시작하는 페이지 번호
* @param searchQuery.size 페이지당 조회 건수
* @returns 검색 결과 목록과 전체 건수를 포함한 페이징 응답
*/
export async function findAddress(searchQuery: ZipSearchParams): Promise<ZipList | undefined> {
if (!searchQuery) return undefined;
const params = new URLSearchParams();
if (searchQuery.roadNm) params.append("roadNm", searchQuery.roadNm);
if (searchQuery.bldgNoMno != null) params.append("bldgNoMno", searchQuery.bldgNoMno.toString());
if (searchQuery.bldgNoSno != null) params.append("bldgNoSno", searchQuery.bldgNoSno.toString());
if (searchQuery.page != null) params.append("page", searchQuery.page.toString());
if (searchQuery.size != null) params.append("size", searchQuery.size.toString());
const url = `/cms-service/api/v1/zips?${params.toString()}`;
const response = await fetch(url);
if (!response.ok) throw new Error("Network response was not ok");
return (await response.json()) as ZipList;
}
/**
* 입력 문자열에서 자동완성 조회용 도로명만 추출한다.
* 마지막 토큰이 숫자 또는 숫자-숫자 형식이면 건물번호로 보고 제외한다.
* @param input 사용자가 입력창에 입력한 원본 문자열
* @returns 자동완성 조회에 사용할 도로명
*/
export function extractRoadNameForSuggestion(input: string): string {
const normalized = input.replace(/\s+/g, " ").trim();
if (!normalized) return "";
const parts = normalized.split(" ");
const tail = parts[parts.length - 1] ?? "";
if (/^\d+(?:-\d+)?$/.test(tail) && parts.length > 1) {
return parts.slice(0, -1).join(" ").trim();
}
return normalized;
}
/**
* 검색어에 허용되지 않는 특수문자나 SQL 예약어가 포함되어 있는지 검사한다.
* @param search 사용자가 입력한 검색 문자열
* @returns 검색 가능하면 true, 차단 대상이면 false
*/
export function searchFilter(search: string): boolean {
if (search.length === 0) return true;
const expText = /[%=><]/;
if (expText.test(search)) {
alert("특수문자를 입력 할수 없습니다.");
return false;
}
const sqlArray = [
"OR",
"SELECT",
"INSERT",
"DELETE",
"UPDATE",
"CREATE",
"DROP",
"EXEC",
"UNION",
"FETCH",
"DECLARE",
"TRUNCATE",
];
for (const sqlWord of sqlArray) {
const regex = new RegExp(sqlWord, "gi");
if (regex.test(search)) {
alert(`"${sqlWord}"와(과) 같은 특정문자로 검색할 수 없습니다.`);
return false;
}
}
return true;
}
/**
* 자동완성 후보 생성을 위해 주소 항목에서 도로명과 건물 본번을 추출한다.
* 우선 응답 필드를 사용하고, 값이 없으면 표시 주소 문자열을 파싱해 보정한다.
* @param item 주소 검색 결과 1건
* @returns 자동완성 생성에 사용할 도로명/건물본번 또는 추출 실패 시 null
*/
export function extractSuggestionSeed(item: Zip): SuggestionSeed | null {
const mnoByField = Number(item.bldgNoMno);
const roadNmByField = (item.roadNm ?? "").trim();
if (roadNmByField && Number.isInteger(mnoByField) && mnoByField > 0) {
return { roadNm: roadNmByField, bldgNoMno: mnoByField };
}
const firstSegment = (item.zipAddr ?? "").split(",")[0]?.trim() ?? "";
if (!firstSegment) return null;
const parts = firstSegment.split(/\s+/);
const bldgToken = parts[parts.length - 1] ?? "";
const matched = bldgToken.match(/^(\d+)(?:-\d+)?$/);
if (!matched) return null;
const bldgNoMno = Number(matched[1]);
const roadNm = parts.slice(0, -1).join(" ").trim();
if (!roadNm || !Number.isInteger(bldgNoMno) || bldgNoMno <= 0) {
return null;
}
return { roadNm, bldgNoMno };
}
/**
* 단일 입력 주소 문자열을 "도로명 + 건물번호" 형식으로 파싱해 API 조회 파라미터로 변환한다.
* 예) "테헤란로 152-1" -> { roadNm: "테헤란로", bldgNoMno: 152, bldgNoSno: 1, page }
* @param input 사용자가 입력한 주소 검색어
* @param page 1부터 시작하는 화면 페이지 번호
* @returns 파싱 성공 시 조회 파라미터와 정규화된 검색어, 실패 시 오류 메시지
*/
export function parseZipSearchInput(
input: string,
page = 1,
): ParseZipSearchInputResult {
const normalized = input.replace(/\s+/g, " ").trim();
if (!normalized) {
return {
ok: false,
message: "도로명과 건물번호를 입력해 주세요. 예) 테헤란로 152-1",
};
}
if (!searchFilter(normalized)) {
return {
ok: false,
message: "검색어를 다시 확인해 주세요.",
};
}
const parts = normalized.split(" ");
if (parts.length < 2) {
if (/^\d+(?:-\d+)?$/.test(normalized)) {
return { ok: false, message: "도로명을 입력해 주세요." };
}
if (/[가-힣]/.test(normalized) && !/\d/.test(normalized)) {
return { ok: false, message: "건물번호를 입력해 주세요." };
}
return {
ok: false,
message: "도로명과 건물번호를 공백으로 구분해 주세요. 예) 테헤란로 152-1",
};
}
const bldgToken = parts[parts.length - 1] ?? "";
const roadNm = parts.slice(0, -1).join(" ").trim();
if (!roadNm || !/[가-힣]/.test(roadNm)) {
return {
ok: false,
message: "도로명을 입력해 주세요.",
};
}
if (!/\d/.test(bldgToken)) {
return { ok: false, message: "건물번호를 입력해 주세요." };
}
if (!/^\d+(?:-\d+)?$/.test(bldgToken)) {
return {
ok: false,
message: "건물번호는 숫자 또는 숫자-숫자 형식으로 입력해 주세요. 예) 152 또는 152-1",
};
}
const [mnoStr, snoStr] = bldgToken.split("-");
const bldgNoMno = Number(mnoStr);
const bldgNoSno = snoStr ? Number(snoStr) : null;
if (!Number.isInteger(bldgNoMno) || bldgNoMno <= 0) {
return { ok: false, message: "건물본번은 1 이상의 숫자로 입력해 주세요." };
}
if (bldgNoSno !== null && (!Number.isInteger(bldgNoSno) || bldgNoSno <= 0)) {
return { ok: false, message: "건물부번은 1 이상의 숫자로 입력해 주세요." };
}
return {
ok: true,
value: {
roadNm,
bldgNoMno,
bldgNoSno,
page,
},
keyword: `${roadNm} ${bldgNoMno}${bldgNoSno ? `-${bldgNoSno}` : ""}`,
};
}
/**
* 도로명 주소 문자열에서 재검색에 사용할 "도로명 건물번호" 입력값만 추출한다.
* 예) "테헤란로 152, OO빌딩" -> "테헤란로 152"
* @param roadAddr 화면에 표시된 도로명 주소 문자열
* @returns 입력란에 다시 넣을 수 있는 문자열, 추출 실패 시 빈 문자열
*/
export function extractSearchInputFromRoadAddr(roadAddr: string): string {
const firstSegment = roadAddr.split(",")[0]?.trim() ?? "";
if (!firstSegment) return "";
const parts = firstSegment.split(/\s+/);
const bldgToken = parts[parts.length - 1] ?? "";
if (!/^\d+(?:-\d+)?$/.test(bldgToken)) return "";
return `${parts.slice(0, -1).join(" ")} ${bldgToken}`.trim();
}
/**
* 사용자가 입력 중인 주소 문자열을 기준으로 도로명 후보를 조회한다.
* 건물번호가 함께 입력된 경우에는 마지막 숫자 토큰을 제외한 도로명만으로 후보를 찾는다.
* @param input 사용자가 입력창에 입력한 원본 문자열
* @returns 최대 5개의 "도로명 건물본번" 자동완성 문자열 목록
*/
export async function fetchPostcodeSuggestions(input: string): Promise<string[]> {
const roadNm = extractRoadNameForSuggestion(input);
if (!roadNm) return [];
if (!searchFilter(roadNm)) return [];
const data = await findAddress({
roadNm,
page: 0,
size: 50,
});
const list = Array.isArray(data?.content) ? data.content : [];
const suggestions: string[] = [];
const seen = new Set<string>();
for (const item of list) {
const seed = extractSuggestionSeed(item);
if (!seed) continue;
const label = `${seed.roadNm} ${seed.bldgNoMno}`;
if (seen.has(label)) continue;
seen.add(label);
suggestions.push(label);
if (suggestions.length >= 5) break;
}
return suggestions;
}