(() => { "use strict"; const homes = [ { id: "home-1", index: "01", name: "정원형 연립주택", trade: "sale", tradeLabel: "매매", priceEok: 38, areaM2: 118.42, areaPyeong: 35.8, parking: 2, moveDate: "2026-12-20", moveLabel: "2026.12.20 이후 협의", life: "한강진역 북쪽 생활권", fee: "월 48만 원", included: "공용 관리, 청소, 승강기", excluded: "전기, 가스, 수도", rooms: "방 3 · 욕실 2", image: "./assets/hannam-interior-1200.webp", imageAlt: "큰 창과 목재 수납이 있는 거실을 표현한 이미지" }, { id: "home-2", index: "02", name: "복층형 공동주택", trade: "jeonse", tradeLabel: "전세", priceEok: 22, areaM2: 142.8, areaPyeong: 43.2, parking: 2, moveDate: "2027-01-15", moveLabel: "2027.01.15 이후 협의", life: "독서당길 남쪽 생활권", fee: "월 56만 원", included: "공용 관리, 보안, 승강기", excluded: "전기, 가스, 수도, 냉난방", rooms: "방 4 · 욕실 2", image: "./assets/hannam-evening-1600.webp", imageAlt: "서울 언덕 생활권의 현대 주거 외관을 표현한 이미지" }, { id: "home-3", index: "03", name: "테라스형 다세대주택", trade: "monthly", tradeLabel: "월세", priceEok: 5, monthlyManwon: 1200, areaM2: 140.84, areaPyeong: 42.6, parking: 1, moveDate: "2026-11-01", moveLabel: "2026.11.01 이후 협의", life: "이태원로 동쪽 생활권", fee: "월 52만 원", included: "공용 관리, 청소", excluded: "전기, 가스, 수도, 승강기 점검", rooms: "방 3 · 욕실 2", image: "./assets/hannam-interior-1200.webp", imageAlt: "큰 창과 목재 수납이 있는 거실을 표현한 이미지" } ]; const STORAGE_KEY = "hannam-jari-conditions-v2"; const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector)); const state = { selectedId: "home-1", areaUnit: "m2", priceUnit: "total", analyzed: false, conditions: null, toastTimer: null, consultationComplete: false }; const form = $("[data-condition-form]"); const dialog = $("[data-consult-dialog]"); const formatNumber = (value, digits = 0) => new Intl.NumberFormat("ko-KR", { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(value); const getHome = (id = state.selectedId) => homes.find((home) => home.id === id) || homes[0]; const formatArea = (home) => state.areaUnit === "m2" ? `${formatNumber(home.areaM2, 2)}㎡` : `${formatNumber(home.areaPyeong, 1)}평`; const unitPriceText = (home) => { const perPyeong = Math.round(((home.priceEok * 10000) / home.areaPyeong) / 10) * 10; if (home.trade === "monthly") return `보증금 평당 약 ${formatNumber(perPyeong)}만 · 월 ${formatNumber(home.monthlyManwon)}만`; return `평당 약 ${formatNumber(perPyeong)}만 원`; }; const totalPriceText = (home) => home.trade === "monthly" ? `보증금 ${formatNumber(home.priceEok)}억 · 월 ${formatNumber(home.monthlyManwon)}만` : `${formatNumber(home.priceEok)}억 원`; const formatPrice = (home) => state.priceUnit === "total" ? totalPriceText(home) : unitPriceText(home); const readConditions = () => { const data = new FormData(form); return { trade: String(data.get("trade") || "sale"), budget: Number(data.get("budget") || 0), minArea: Number(data.get("minArea") || 0), moveIn: String(data.get("moveIn") || ""), parking: Number(data.get("parking") || 0) }; }; const announce = (message) => { const region = $("[data-live-region]"); if (!region) return; region.textContent = message; region.hidden = false; window.clearTimeout(state.toastTimer); state.toastTimer = window.setTimeout(() => { region.hidden = true; region.textContent = ""; }, 3200); }; const claim = (title, text, source) => ({ title, text, source }); const evaluateHome = (home, conditions) => { const fit = []; const adjust = []; const check = []; if (home.trade === conditions.trade) { fit.push(claim("거래 형태", `${home.tradeLabel} 조건과 같습니다.`, "입력한 조건 · 매물 정보")); } else { adjust.push(claim("거래 형태", `입력은 ${tradeLabel(conditions.trade)}, 선택한 집은 ${home.tradeLabel}입니다.`, "입력한 조건 · 매물 정보")); } if (home.trade === "monthly") { if (home.priceEok > conditions.budget) { adjust.push(claim("보증금 상한", `보증금이 입력한 상한보다 ${formatNumber(home.priceEok - conditions.budget)}억 원 높습니다.`, "입력한 조건 · 매물 정보")); } else { adjust.push(claim("월 납부 조건", `보증금은 상한 안쪽이지만 월 ${formatNumber(home.monthlyManwon)}만 원을 별도로 살펴야 합니다.`, "입력한 조건 · 매물 정보")); } } else if (home.priceEok <= conditions.budget) { fit.push(claim("가격 상한", `입력한 상한보다 ${formatNumber(conditions.budget - home.priceEok)}억 원 낮습니다.`, "입력한 조건 · 매물 정보")); } else { adjust.push(claim("가격 상한", `입력한 상한보다 ${formatNumber(home.priceEok - conditions.budget)}억 원 높습니다.`, "입력한 조건 · 매물 정보")); } if (home.areaM2 >= conditions.minArea) { fit.push(claim("전용면적", `최소 면적보다 ${formatNumber(home.areaM2 - conditions.minArea, 2)}㎡ 넓습니다.`, "입력한 조건 · 매물 정보")); } else { adjust.push(claim("전용면적", `최소 면적보다 ${formatNumber(conditions.minArea - home.areaM2, 2)}㎡ 작습니다.`, "입력한 조건 · 매물 정보")); } if (home.parking >= conditions.parking) { fit.push(claim("주차", `${home.parking}대로 필요한 수를 충족합니다.`, "입력한 조건 · 매물 정보")); } else { adjust.push(claim("주차", `필요한 수보다 ${conditions.parking - home.parking}대 부족합니다.`, "입력한 조건 · 매물 정보")); } if (!conditions.moveIn) { adjust.push(claim("입주 희망일", "날짜를 정하면 입주 가능 시점과 대조할 수 있습니다.", "입력한 조건")); } else if (home.moveDate <= conditions.moveIn) { fit.push(claim("입주 시점", `${home.moveLabel}으로 희망일 전후 협의가 가능합니다.`, "입력한 조건 · 매물 정보")); } else { adjust.push(claim("입주 시점", `${home.moveLabel}으로 입력한 희망일보다 늦습니다.`, "입력한 조건 · 매물 정보")); } check.push(claim("권리 관계", "등기와 계약 조건은 서류를 통해 다시 살펴야 합니다.", "공인중개사 확인")); check.push(claim("현장 상태", "소음, 마감과 채광은 방문해서 확인해야 합니다.", "현장 확인")); return { fit, adjust, check }; }; const tradeLabel = (trade) => ({ sale: "매매", jeonse: "전세", monthly: "월세" })[trade] || "미선택"; const scoreHome = (home, conditions) => { let score = home.trade === conditions.trade ? 4 : 0; if (home.trade === "monthly") score += home.priceEok <= conditions.budget ? 0.5 : 0; else score += home.priceEok <= conditions.budget ? 1 : 0; score += home.areaM2 >= conditions.minArea ? 1 : 0; score += home.parking >= conditions.parking ? 1 : 0; score += conditions.moveIn && home.moveDate <= conditions.moveIn ? 1 : 0; return score; }; const createClaimItem = ({ title, text, source }) => { const item = document.createElement("li"); const strong = document.createElement("strong"); const span = document.createElement("span"); const small = document.createElement("small"); strong.textContent = title; span.textContent = text; small.textContent = source; item.append(strong, span, small); return item; }; const fillClaimList = (selector, items, emptyText) => { const list = $(selector); if (!list) return; const nextItems = items.length ? items : [claim(emptyText, "현재 입력에서 바로 조정할 항목이 없습니다.", "입력한 조건 · 매물 정보")]; list.replaceChildren(...nextItems.map(createClaimItem)); }; const renderBriefing = (home, conditions) => { const result = evaluateHome(home, conditions); $("[data-analysis-empty]").hidden = true; $("[data-analysis-groups]").hidden = false; $("[data-analysis-home]").textContent = `${home.name} 브리핑`; fillClaimList("[data-fit-list]", result.fit, "바로 맞는 조건 없음"); fillClaimList("[data-adjust-list]", result.adjust, "바로 조정할 조건 없음"); fillClaimList("[data-check-list]", result.check, "추가 확인 항목 없음"); return result; }; const resetBriefing = () => { state.analyzed = false; state.conditions = null; $("[data-analysis-empty]").hidden = false; $("[data-analysis-groups]").hidden = true; $("[data-analysis-home]").textContent = "아직 분석하지 않았습니다"; }; const setMatrixNote = (selector, text, tone = "") => { const node = $(selector); if (!node) return; node.textContent = text; node.className = ["matrix-note", tone].filter(Boolean).join(" "); }; const renderMatrix = (home, conditions = readConditions()) => { $("[data-matrix-trade]").textContent = home.tradeLabel; $("[data-matrix-price]").textContent = formatPrice(home); $("[data-matrix-area]").textContent = formatArea(home); $("[data-matrix-parking]").textContent = `${home.parking}대`; $("[data-matrix-move]").textContent = home.moveLabel; if (!state.analyzed) { setMatrixNote("[data-matrix-trade-note]", "분석 후 거래 조건 대조"); setMatrixNote("[data-matrix-price-note]", "분석 후 예산과 대조"); setMatrixNote("[data-matrix-area-note]", "분석 후 면적과 대조"); setMatrixNote("[data-matrix-parking-note]", "분석 후 주차와 대조"); setMatrixNote("[data-matrix-move-note]", "분석 후 입주일과 대조"); return; } setMatrixNote("[data-matrix-trade-note]", home.trade === conditions.trade ? "입력과 같음" : `${tradeLabel(conditions.trade)}에서 조정 필요`, home.trade === conditions.trade ? "" : "is-adjust"); if (home.trade === "monthly") { const depositNote = home.priceEok <= conditions.budget ? "보증금은 상한 안쪽 · 월 납부 별도" : `보증금이 상한보다 ${formatNumber(home.priceEok - conditions.budget)}억 높음`; setMatrixNote("[data-matrix-price-note]", depositNote, "is-check"); } else { const pricePass = home.priceEok <= conditions.budget; setMatrixNote("[data-matrix-price-note]", pricePass ? `예산 ${formatNumber(conditions.budget)}억 안쪽` : `예산보다 ${formatNumber(home.priceEok - conditions.budget)}억 높음`, pricePass ? "" : "is-adjust"); } const areaPass = home.areaM2 >= conditions.minArea; setMatrixNote("[data-matrix-area-note]", areaPass ? `최소 ${formatNumber(conditions.minArea)}㎡ 충족` : `최소보다 ${formatNumber(conditions.minArea - home.areaM2, 2)}㎡ 작음`, areaPass ? "" : "is-adjust"); const parkingPass = home.parking >= conditions.parking; setMatrixNote("[data-matrix-parking-note]", parkingPass ? `필요 ${conditions.parking}대 충족` : `${conditions.parking - home.parking}대 부족`, parkingPass ? "" : "is-adjust"); if (!conditions.moveIn) { setMatrixNote("[data-matrix-move-note]", "희망일 입력 필요", "is-check"); } else { const movePass = home.moveDate <= conditions.moveIn; setMatrixNote("[data-matrix-move-note]", movePass ? "희망일 전후 협의 가능" : "희망일보다 늦음", movePass ? "" : "is-adjust"); } }; const renderCandidateRail = () => { $$('[data-select-listing]').forEach((button) => { const home = getHome(button.dataset.selectListing); const active = home.id === state.selectedId; button.setAttribute("aria-pressed", String(active)); button.closest(".candidate-option")?.classList.toggle("is-selected", active); const content = button.nextElementSibling; const small = content?.querySelector("small"); if (small) small.textContent = `${home.tradeLabel} · ${formatArea(home)}`; }); }; const renderCompareScene = (home) => { const image = $("[data-compare-image]"); if (image) { image.hidden = false; image.src = home.image; image.alt = home.imageAlt; image.parentElement?.classList.remove("is-image-error"); $("[data-image-fallback]", image.parentElement).hidden = true; } $("[data-compare-index]").textContent = home.index; $("[data-compare-name]").textContent = home.name; $("[data-compare-life]").textContent = home.life; }; const setSimpleList = (selector, items) => { const list = $(selector); if (!list) return; list.replaceChildren(...items.map((text) => { const item = document.createElement("li"); item.textContent = text; return item; })); }; const renderSpecs = (home) => { const root = $("[data-spec-strip]"); if (!root) return; const pairs = [ ["거래", home.tradeLabel], ["가격", formatPrice(home)], ["전용", formatArea(home)], ["주차", `${home.parking}대`], ["입주", home.moveLabel] ]; root.replaceChildren(...pairs.map(([label, value]) => { const wrapper = document.createElement("div"); const term = document.createElement("dt"); const description = document.createElement("dd"); term.textContent = label; description.textContent = value; wrapper.append(term, description); return wrapper; })); }; const renderDossier = (home, conditions = readConditions()) => { $("[data-dossier-title]").textContent = home.name; $("[data-dossier-life]").textContent = home.life; const consultHome = $("[data-consult-home]"); if (consultHome) consultHome.textContent = home.name; $("[data-location-heading]").textContent = `${home.life}을 직접 살핍니다.`; $("[data-page-budget]").textContent = `${formatNumber(conditions.budget)}억 원`; $("[data-page-price]").textContent = totalPriceText(home); $("[data-page-fee]").textContent = home.fee; $("[data-page-included]").textContent = home.included; $("[data-page-excluded]").textContent = home.excluded; $("[data-page-rooms]").textContent = home.rooms; renderSpecs(home); if (!state.analyzed) { setSimpleList("[data-dossier-fit]", ["브리핑을 실행하면 입력 조건과의 관계를 표시합니다."]); setSimpleList("[data-dossier-risk]", ["등기와 권리 관계를 확인해야 합니다.", "소음과 마감 상태는 현장에서 살펴야 합니다.", "입주 가능일은 중개사와 다시 맞춰야 합니다."]); return; } const result = evaluateHome(home, conditions); const fits = result.fit.length ? result.fit.map((item) => item.text) : ["현재 입력과 바로 맞는 항목은 없습니다."]; const risks = [...result.adjust, ...result.check].map((item) => item.text).slice(0, 4); setSimpleList("[data-dossier-fit]", fits); setSimpleList("[data-dossier-risk]", risks); }; const renderSelection = () => { const home = getHome(); const conditions = state.conditions || readConditions(); renderCandidateRail(); renderCompareScene(home); renderMatrix(home, conditions); renderDossier(home, conditions); if (state.analyzed) renderBriefing(home, conditions); }; const selectListing = (id, announceChange = true) => { const home = getHome(id); state.selectedId = home.id; renderSelection(); if (announceChange) announce(`${home.name}을 선택했습니다. 비교표와 서류철을 바꿨습니다.`); }; const runAnalysis = () => { if (!form.reportValidity()) return; const conditions = readConditions(); state.conditions = conditions; state.analyzed = true; const best = homes.reduce((current, home) => scoreHome(home, conditions) > scoreHome(current, conditions) ? home : current, homes[0]); state.selectedId = best.id; renderSelection(); $("#briefing")?.scrollIntoView({ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }); announce(`${best.name}을 가장 가까운 후보로 골라 세 묶음의 브리핑을 만들었습니다.`); }; const markConditionsChanged = () => { if (!state.analyzed) return; resetBriefing(); renderSelection(); announce("조건이 바뀌어 이전 브리핑을 비웠습니다. 다시 분석해 주세요."); }; const saveConditions = () => { if (!form.reportValidity()) return; try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(readConditions())); announce("현재 조건을 이 브라우저에 저장했습니다."); } catch { announce("브라우저 저장소를 사용할 수 없어 조건을 저장하지 않았습니다."); } }; const restoreConditions = () => { try { const saved = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "null"); if (!saved || typeof saved !== "object") return; if (["sale", "jeonse", "monthly"].includes(saved.trade)) form.elements.trade.value = saved.trade; if (Number.isFinite(saved.budget)) form.elements.budget.value = String(saved.budget); if (Number.isFinite(saved.minArea)) form.elements.minArea.value = String(saved.minArea); if (typeof saved.moveIn === "string") form.elements.moveIn.value = saved.moveIn; if (Number.isFinite(saved.parking)) form.elements.parking.value = String(saved.parking); } catch { window.localStorage.removeItem(STORAGE_KEY); } }; const focusConditions = () => { form.scrollIntoView({ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "center" }); window.setTimeout(() => form.elements.trade?.focus(), 180); }; const activateDossierTab = (name, focus = false) => { $$('[data-dossier-tab]').forEach((tab) => { const active = tab.dataset.dossierTab === name; tab.setAttribute("aria-selected", String(active)); tab.tabIndex = active ? 0 : -1; if (active && focus) tab.focus(); }); $$('[data-dossier-panel]').forEach((panel) => { panel.hidden = panel.dataset.dossierPanel !== name; }); }; const initDossierTabs = () => { const tabs = $$('[data-dossier-tab]'); tabs.forEach((tab, index) => { tab.addEventListener("click", () => activateDossierTab(tab.dataset.dossierTab)); tab.addEventListener("keydown", (event) => { let target = null; if (event.key === "ArrowRight" || event.key === "ArrowDown") target = (index + 1) % tabs.length; if (event.key === "ArrowLeft" || event.key === "ArrowUp") target = (index - 1 + tabs.length) % tabs.length; if (event.key === "Home") target = 0; if (event.key === "End") target = tabs.length - 1; if (target !== null) { event.preventDefault(); activateDossierTab(tabs[target].dataset.dossierTab, true); } }); }); }; const resetConsultation = () => { state.consultationComplete = false; const content = $("[data-consult-content]", dialog); content.replaceChildren(); const copy = document.createElement("p"); const strong = document.createElement("strong"); strong.dataset.consultHome = ""; strong.textContent = getHome().name; copy.append(strong, "을 기준으로 준비할 항목을 골라 보세요."); content.append(copy); [["rights", "권리 관계와 계약 조건", true], ["visit", "현장 방문 때 볼 항목", true], ["move", "입주 가능일 조율", false]].forEach(([value, label, checked]) => { const wrapper = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.name = "agenda"; input.value = value; input.checked = checked; wrapper.append(input, ` ${label}`); content.append(wrapper); }); $("[data-finish-consult]", dialog).textContent = "다음 순서 보기"; }; const openConsultation = () => { resetConsultation(); dialog.showModal(); document.body.classList.add("is-locked"); }; const closeConsultation = () => { if (dialog.open) dialog.close(); }; const finishConsultation = () => { if (state.consultationComplete) { resetConsultation(); return; } state.consultationComplete = true; const content = $("[data-consult-content]", dialog); const selected = $$('input[name="agenda"]:checked', content).map((input) => input.closest("label")?.textContent.trim()).filter(Boolean); content.replaceChildren(); const heading = document.createElement("p"); const strong = document.createElement("strong"); strong.textContent = "준비할 순서"; heading.append(strong); const list = document.createElement("ol"); const items = selected.length ? selected : ["집의 기본 조건 다시 보기"]; items.forEach((text) => { const item = document.createElement("li"); item.textContent = text; list.append(item); }); content.append(heading, list); $("[data-finish-consult]", dialog).textContent = "처음부터 보기"; }; const initImageFallback = () => { $$('[data-fallback-image]').forEach((image) => { const handleError = () => { image.hidden = true; const frame = image.closest("[data-image-frame]"); frame?.classList.add("is-image-error"); const fallback = $("[data-image-fallback]", frame); if (fallback) fallback.hidden = false; }; image.addEventListener("error", handleError); if (image.complete && image.naturalWidth === 0) handleError(); }); }; const initMenu = () => { const button = $("[data-menu-button]"); const nav = $("[data-site-nav]"); const close = () => { nav.classList.remove("is-open"); button.setAttribute("aria-expanded", "false"); }; button?.addEventListener("click", () => { const open = nav.classList.toggle("is-open"); button.setAttribute("aria-expanded", String(open)); }); $$("a", nav).forEach((link) => link.addEventListener("click", close)); document.addEventListener("keydown", (event) => { if (event.key === "Escape") close(); }); }; const init = () => { restoreConditions(); initMenu(); initDossierTabs(); initImageFallback(); resetBriefing(); renderSelection(); form.addEventListener("submit", (event) => { event.preventDefault(); runAnalysis(); }); form.addEventListener("input", markConditionsChanged); form.addEventListener("change", markConditionsChanged); $$('[data-save-condition]').forEach((button) => button.addEventListener("click", saveConditions)); $$('[data-focus-condition]').forEach((button) => button.addEventListener("click", focusConditions)); $$('[data-select-listing]').forEach((button) => button.addEventListener("click", () => selectListing(button.dataset.selectListing))); $$('[data-area-unit]').forEach((button) => button.addEventListener("click", () => { state.areaUnit = button.dataset.areaUnit; $$('[data-area-unit]').forEach((item) => item.setAttribute("aria-pressed", String(item === button))); renderSelection(); announce(`면적을 ${state.areaUnit === "m2" ? "제곱미터" : "평"}으로 표시합니다.`); })); $$('[data-price-unit]').forEach((button) => button.addEventListener("click", () => { state.priceUnit = button.dataset.priceUnit; $$('[data-price-unit]').forEach((item) => item.setAttribute("aria-pressed", String(item === button))); renderSelection(); announce(`가격을 ${state.priceUnit === "total" ? "총액" : "면적 단가"}으로 표시합니다.`); })); $$('[data-open-consult]').forEach((button) => button.addEventListener("click", openConsultation)); $("[data-close-consult]")?.addEventListener("click", closeConsultation); $("[data-finish-consult]")?.addEventListener("click", finishConsultation); dialog?.addEventListener("click", (event) => { if (event.target === dialog) closeConsultation(); }); dialog?.addEventListener("close", () => { document.body.classList.remove("is-locked"); resetConsultation(); }); dialog?.addEventListener("cancel", () => document.body.classList.remove("is-locked")); }; if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init, { once: true }); else init(); })();