cc/startup_app/static/app.js
원본 내용을 읽기 전용으로 표시합니다.
"use strict";const $ = id => document.getElementById(id);const map = L.map("map").setView([36.53, 127.27], 12);L.tileLayer("https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", {attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, Tiles style by <a href="https://www.hotosm.org/">HOT</a>, hosted by <a href="https://www.openstreetmap.fr/">OSM France</a>'}).addTo(map);const 결과레이어 = L.layerGroup().addTo(map);new ResizeObserver(() => map.invalidateSize()).observe($("map"));let 점 = null, 마커 = null, 메타 = null, busy = false, sequence = 0, controller;function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&", "<":"<", ">":">", '"':""", "'":"'"}[c])); }function 퍼센트(v) { return v == null ? "-" : (v * 100).toFixed(1) + "%"; }function 버튼상태() {$("cat").disabled = $("mid").disabled = !메타;$("analyze").disabled = $("recommend").disabled = !메타 || !점 || busy;}function 취소() { sequence++; controller?.abort(); busy = false; $("out").setAttribute("aria-busy", "false"); 버튼상태(); }async function 요청(url, body, signal) {const response = await fetch(url, body ? {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(body), signal} : {signal});let data;try { data = await response.json(); } catch { throw new Error("서버 응답을 읽지 못했습니다. 다시 시도해 주세요."); }if (!response.ok) throw new Error(data.오류 || "요청을 처리하지 못했습니다.");return data;}function 업종채우기() {$("mid").replaceChildren(...메타.업종[$("cat").value].map(m => new Option(m, m)));}function 위치정하기(lat, lon, 이름) {취소(); 점 = [lat, lon];if (마커) 마커.setLatLng(점); else 마커 = L.marker(점).addTo(map);결과레이어.clearLayers(); $("out").replaceChildren();$("pos").textContent = 이름 ? `선택: ${이름}` : `선택 위치: ${lat.toFixed(5)}, ${lon.toFixed(5)}`;$("status").textContent = ""; 버튼상태();}map.on("click", e => 위치정하기(e.latlng.lat, e.latlng.lng));$("cat").addEventListener("change", () => { 취소(); 업종채우기(); $("status").textContent = "업종이 바뀌었습니다. 다시 분석해 주세요."; });$("mid").addEventListener("change", () => { 취소(); $("status").textContent = "업종이 바뀌었습니다. 다시 분석해 주세요."; });function 건물선택() {const b = 메타?.건물.find(x => `${x.건물명} (${x.행정동명})` === $("bld").value);if (b) { map.setView([b.위도, b.경도], 17); 위치정하기(b.위도, b.경도, `${b.건물명} (${b.행정동명}) · 건물 안 업소 ${b.업소수}개`); }}$("bld").addEventListener("input", 건물선택);$("bld").addEventListener("change", 건물선택);function 반경표시(radius) {결과레이어.clearLayers();L.circle(점, {radius, weight:1, fillOpacity:0.05}).addTo(결과레이어);}function 분석표시(d) {반경표시(d.반경);d.경쟁.forEach(c => L.circleMarker([c.위도,c.경도], {radius:5,color:"red"}).bindTooltip(`${esc(c.상호)} (${c.거리}m)`).addTo(결과레이어));const h = d.현황, s = d.참고, p = d.소멸;let html = `<div class="box"><b>${esc(d.동)} · ${esc(d.업종)} · 반경 ${d.반경}m</b><table><tr><td>전체 업소</td><td>${h.업소수}개 (1년 전 ${h.작년업소수}개)</td></tr><tr><td>1년 소멸 ID 비율</td><td>${퍼센트(h.소멸률)}</td></tr><tr><td>같은 업종 (빨간 점)</td><td>${h.같은업종수}개 · 신규 ID ${h.생김}곳, 소멸 ID ${h.없어짐}곳</td></tr><tr><td>업종 구성</td><td>${h.구성.map(([k,v]) => `${esc(k)} ${v.toFixed(0)}%`).join(", ") || "기록 없음"}</td></tr></table></div>`;html += `<div class="box"><b>머신러닝: 주변 구성에 따른 참고 업소 수</b><br>`;html += s ? `현재 <b>${s.실제}</b>개 / 참고 <b>${s.예상}</b>개 ${s.신뢰 ? "" : "(검증 기준 미달)"}<div class="gray">공간 검증 D² ${s.D2 ?? "-"} · 단순 비례 기준 D² ${s.비례기준D2 ?? "-"} · 검증 표본 ${(s.검증비율*100).toFixed(0)}%</div>` : "참고값 없음 (주변 또는 업종 기록 부족 등)";html += `<div class="gray">다른 업종 구성에서 추정한 참고치입니다. 실제 소비 수요나 창업 성공률을 뜻하지 않습니다.</div></div><div class="box"><b>1년 소멸 ID 비율</b><table><tr><td>이 업종 세종 전체</td><td>${퍼센트(p.업종)}</td></tr><tr><td>이 근처 같은 업종</td><td>${p.이근처 == null ? `- (이전 표본 ${p.이근처_수}곳)` : 퍼센트(p.이근처)}</td></tr><tr><td>세종 전체</td><td>${퍼센트(p.세종)}</td></tr></table><div class="gray">이전 자료의 ID가 현재 자료에 없는 비율이며 실제 폐업률과 다릅니다.</div></div><div class="box"><b>해석 안내</b><ul class="notes">${d.안내.map(t => `<li>${esc(t)}</li>`).join("")}</ul></div>`;if (d.경쟁.length) html += `<div class="box"><b>가까운 같은 업종 10곳</b><table>${d.경쟁.slice(0,10).map(c => `<tr><td>${esc(c.상호)}</td><td>${esc(c.소분류)}</td><td>${c.거리}m</td></tr>`).join("")}</table></div>`;$("out").innerHTML = html;}function 추천표시(d) {반경표시(d.반경);if (!d.적은편.length && !d.많은편.length) {$("out").innerHTML = `<div class="box">${d.상태 === "주변기록부족" ? `반경 300m 안 업소가 ${d.업소수}개로 적어 비교하지 않습니다.` : `주변 업소 ${d.업소수}개 중 표시 기준에 해당하는 업종이 없습니다. 업소가 부족하다는 뜻은 아닙니다.`}</div>`; return;}const 표 = rows => rows.length ? `<table><tr><th>업종</th><th>참고</th><th>현재</th><th>소멸 ID</th></tr>${rows.map(r => `<tr><td>${esc(r.업종)}</td><td>${r.예상}</td><td>${r.실제}</td><td>${퍼센트(r.소멸률)}</td></tr>`).join("")}</table>` : '<div class="gray">표시 기준에 해당하는 업종 없음</div>';$("out").innerHTML = `<div class="box"><b>주변 구성에 비해 기록이 적은 업종</b>${표(d.적은편)}</div><div class="box"><b>주변 구성에 비해 기록이 많은 업종</b>${표(d.많은편)}</div><div class="box gray">${esc(d.안내)}</div>`;}async function 실행(mode) {if (!점 || !메타 || busy) return;const id = ++sequence, current = new AbortController(); controller = current;const body = {lat:점[0],lon:점[1],mid:$("mid").value};const timeout = setTimeout(() => current.abort(), 20000);busy = true; 버튼상태(); $("out").setAttribute("aria-busy","true"); $("status").textContent = "분석 중…";try {const d = await 요청(mode === "analyze" ? "/api/analyze" : "/api/recommend", body, current.signal);if (id !== sequence) return;mode === "analyze" ? 분석표시(d) : 추천표시(d);$("status").textContent = "";if (matchMedia("(max-width:800px)").matches) $("out").scrollIntoView({block:"start"});} catch (error) {if (id !== sequence) return;$("out").innerHTML = `<div class="box" role="alert">${esc(current.signal.aborted ? "응답이 늦어 요청을 멈췄습니다. 다시 시도해 주세요." : error.message)}</div>`;$("status").textContent = "분석하지 못했습니다.";} finally {clearTimeout(timeout);if (id === sequence) { busy = false; 버튼상태(); $("out").setAttribute("aria-busy","false"); }}}$("analyze").addEventListener("click", () => 실행("analyze"));$("recommend").addEventListener("click", () => 실행("recommend"));요청("/api/meta").then(m => {메타 = m;$("cat").replaceChildren(...Object.keys(m.업종).map(c => new Option(c,c)));$("bldlist").replaceChildren(...m.건물.map(b => new Option(`${b.건물명} (${b.행정동명})`)));업종채우기(); 버튼상태(); $("status").textContent = "";}).catch(error => { $("status").textContent = error.message + " 새로고침해 주세요."; });
파일 확인 정보
SHA-256: 6dd08084f45d7c752bdef6ec5de2fdf3b8386cd30ed90007c3c1b7b1312dee9b