location.vue 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928
  1. <template>
  2. <div>
  3. <breadCrumbs
  4. :main-menu="breadcrumbData.mainMenu"
  5. :current-sub-menu="breadcrumbData.currentSubMenu"
  6. :sub-menu-items="breadcrumbData.subMenuItems"
  7. />
  8. <div class="container">
  9. <div class="showroom--wrap" :class="{ type2: locationType === 2 }">
  10. <div class="search--wrap" :class="{ 'at-bottom': isScrolledToBottom }">
  11. <div class="form--wrap">
  12. <div class="input--wrap mb--12">
  13. <input
  14. type="text"
  15. v-model="searchKeyword"
  16. :placeholder="searchPlaceholder"
  17. @keyup.enter="searchLocations"
  18. />
  19. <button class="search--btn" @click="searchLocations"></button>
  20. </div>
  21. <div class="input--wrap">
  22. <select
  23. name="location"
  24. id="location"
  25. v-model="selectedRegion"
  26. required
  27. @change="searchLocations"
  28. >
  29. <option value="" disabled selected hidden>시/도 선택</option>
  30. <option value="">전체</option>
  31. <option value="서울">서울</option>
  32. <option value="경기">경기</option>
  33. <option value="인천">인천</option>
  34. <option value="부산">부산</option>
  35. <option value="대구">대구</option>
  36. <option value="광주">광주</option>
  37. <option value="대전">대전</option>
  38. <option value="울산">울산</option>
  39. <option value="세종">세종</option>
  40. <option value="강원">강원</option>
  41. <option value="충북">충북</option>
  42. <option value="충남">충남</option>
  43. <option value="전북">전북</option>
  44. <option value="전남">전남</option>
  45. <option value="경북">경북</option>
  46. <option value="경남">경남</option>
  47. <option value="제주">제주</option>
  48. </select>
  49. <!-- <button class="location--btn" @click="findNearbyLocations">
  50. 내 주변<i class="ico"></i>
  51. </button> -->
  52. </div>
  53. <!-- <div class="btn--wrap">
  54. <button class="btn--sky" @click="searchLocations">
  55. {{ nearbyButtonText }}
  56. </button>
  57. </div> -->
  58. </div>
  59. <div class="list--wrap" ref="listWrapRef" @scroll="handleScroll">
  60. <div v-if="isLoading" class="loading">데이터를 불러오는 중...</div>
  61. <div
  62. v-else-if="!filteredLocations || filteredLocations.length === 0"
  63. class="empty"
  64. >
  65. {{ emptyMessage }}
  66. </div>
  67. <div
  68. v-else
  69. v-for="location in filteredLocations"
  70. :key="location.id"
  71. class="list"
  72. :class="{ active: selectedLocation?.id === location.id }"
  73. @click="selectLocation(location)"
  74. >
  75. <div class="list--title">
  76. <h3>{{ location.name }}</h3>
  77. <p>{{ location.branch_name || "" }}</p>
  78. <p v-if="location.distance" class="distance">
  79. {{ location.distance.toFixed(1) }}km
  80. </p>
  81. </div>
  82. <div class="list--info">
  83. <div class="address">
  84. <i class="ico"></i>
  85. <p>{{ location.address }}</p>
  86. </div>
  87. <div class="call">
  88. <i class="ico"></i>
  89. <p>{{ location.main_phone }}</p>
  90. </div>
  91. <div class="time">
  92. <i class="ico"></i>
  93. <p>{{ location.business_hours }}</p>
  94. </div>
  95. </div>
  96. <div class="list--btn" v-if="locationType === 1">
  97. <NuxtLink :to="location.quote_link" target="_blank" class="btn--gray"
  98. >견적 요청</NuxtLink
  99. >
  100. <NuxtLink
  101. :to="location.test_drive_link"
  102. target="_blank"
  103. class="btn--black"
  104. >시승 요청</NuxtLink
  105. >
  106. </div>
  107. <div class="list--btn" v-else>
  108. <NuxtLink
  109. :to="location.service_reservation_link"
  110. target="_blank"
  111. class="btn--black"
  112. >서비스 예약</NuxtLink
  113. >
  114. </div>
  115. </div>
  116. </div>
  117. </div>
  118. <div class="map--wrap">
  119. <div class="title--wrap">
  120. <h2>{{ pageTitle }}</h2>
  121. <p>{{ pageDescription }}</p>
  122. </div>
  123. <!-- 지도 영역 -->
  124. <div id="map" class="map"></div>
  125. </div>
  126. </div>
  127. </div>
  128. </div>
  129. </template>
  130. <script setup>
  131. import { ref, computed, onMounted, nextTick, watch } from "vue";
  132. const route = useRoute();
  133. const { get } = useApi();
  134. // type 파라미터: 1=전시장, 2=서비스센터 (반응형으로 route.query 직접 참조)
  135. const locationType = computed(() => {
  136. const type = parseInt(route.query.type) || 1;
  137. return type === 2 ? 2 : 1;
  138. });
  139. // breadcrumb도 반응형으로
  140. const breadcrumbData = computed(() => ({
  141. mainMenu: "NETWORK",
  142. currentSubMenu: locationType.value === 2 ? "서비스센터 찾기" : "전시장 찾기",
  143. subMenuItems: [
  144. {
  145. label: "전시장 찾기",
  146. to: "/ford/network",
  147. active: locationType.value === 2 ? false : true,
  148. },
  149. {
  150. label: "서비스센터 찾기",
  151. to: "/ford/network",
  152. active: locationType.value === 2 ? true : false,
  153. },
  154. {
  155. label: "포드고객센터",
  156. to: "/ford/owner/contact",
  157. active: false,
  158. },
  159. ],
  160. }));
  161. // 타입별 텍스트 설정
  162. const pageTitle = computed(() =>
  163. locationType.value === 1 ? "전시장 찾기" : "서비스센터 찾기"
  164. );
  165. const pageDescription = computed(() =>
  166. locationType.value === 1
  167. ? "전국의 포드 전시장을 한 눈에 찾아보실 수 있습니다."
  168. : "전국의 포드 서비스센터를 한 눈에 찾아보실 수 있습니다."
  169. );
  170. const searchPlaceholder = computed(() =>
  171. locationType.value === 1
  172. ? "전시장명 또는 지역명을 입력해주세요"
  173. : "서비스센터명 또는 지역명을 입력해주세요"
  174. );
  175. const emptyMessage = computed(() =>
  176. locationType.value === 1
  177. ? "등록된 전시장이 없습니다."
  178. : "등록된 서비스센터가 없습니다."
  179. );
  180. const nearbyButtonText = computed(() =>
  181. locationType.value === 1 ? "가까운 전시장 추천 지정" : "가까운 서비스센터 추천 지정"
  182. );
  183. const locationLabel = computed(() =>
  184. locationType.value === 1 ? "전시장" : "서비스센터"
  185. );
  186. // API endpoint (public API - 인증 불필요)
  187. const apiEndpoint = computed(() =>
  188. locationType.value === 1 ? "/showroom/public" : "/service-center/public"
  189. );
  190. const isLoading = ref(false);
  191. const locations = ref([]);
  192. const searchKeyword = ref("");
  193. const selectedRegion = ref("");
  194. const selectedLocation = ref(null);
  195. const map = ref(null);
  196. const markers = ref([]);
  197. const overlays = ref([]); // 카카오맵 CustomOverlay 저장
  198. const clusterer = ref([]); // 커스텀 클러스터 오버레이 배열
  199. const userLocation = ref(null);
  200. const userLocationMarker = ref(null); // 현재 위치 마커
  201. const userLocationCircle = ref(null); // 반경 원
  202. const nearbyRadius = ref(5); // 주변 검색 반경 (km) - 유동적으로 변경 가능
  203. const listWrapRef = ref(null);
  204. const isScrolledToBottom = ref(false);
  205. // 카카오맵 API Key
  206. const KAKAO_API_KEY = "5e42d358f25c02d08a2596876459f6ca";
  207. //const KAKAO_API_KEY = "47dfc47d66f488da19bdebeabbde1da7";
  208. // 두 지점 간의 거리 계산 (Haversine formula)
  209. const calculateDistance = (lat1, lon1, lat2, lon2) => {
  210. const R = 6371; // 지구 반경 (km)
  211. const dLat = ((lat2 - lat1) * Math.PI) / 180;
  212. const dLon = ((lon2 - lon1) * Math.PI) / 180;
  213. const a =
  214. Math.sin(dLat / 2) * Math.sin(dLat / 2) +
  215. Math.cos((lat1 * Math.PI) / 180) *
  216. Math.cos((lat2 * Math.PI) / 180) *
  217. Math.sin(dLon / 2) *
  218. Math.sin(dLon / 2);
  219. const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  220. return R * c;
  221. };
  222. // 카카오맵 스크립트 로드
  223. const loadKakaoMapsScript = () => {
  224. return new Promise((resolve, reject) => {
  225. if (window.kakao && window.kakao.maps) {
  226. console.log("[KakaoMap] Already loaded");
  227. resolve();
  228. return;
  229. }
  230. const script = document.createElement("script");
  231. script.src = `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${KAKAO_API_KEY}&autoload=false`;
  232. script.async = true;
  233. script.onload = () => {
  234. console.log("[KakaoMap] Script loaded, initializing...");
  235. window.kakao.maps.load(() => {
  236. console.log("[KakaoMap] Maps API ready");
  237. resolve();
  238. });
  239. };
  240. script.onerror = (e) => {
  241. console.error("[KakaoMap] Script load error:", e);
  242. reject(new Error("카카오맵 API 로드 실패"));
  243. };
  244. document.head.appendChild(script);
  245. });
  246. };
  247. // 지도 초기화
  248. const initMap = () => {
  249. console.log("[KakaoMap] initMap called");
  250. if (typeof window === "undefined" || !window.kakao || !window.kakao.maps) {
  251. console.error("[KakaoMap] Kakao maps not available");
  252. return;
  253. }
  254. const container = document.getElementById("map");
  255. if (!container) {
  256. console.error("[KakaoMap] Map container not found");
  257. return;
  258. }
  259. console.log("[KakaoMap] Creating map...", container);
  260. // 서울 중심으로 지도 생성
  261. const options = {
  262. center: new window.kakao.maps.LatLng(37.5665, 126.978),
  263. level: 8, // 카카오맵 줌 레벨 (숫자가 작을수록 확대)
  264. };
  265. map.value = new window.kakao.maps.Map(container, options);
  266. console.log("[KakaoMap] Map created successfully");
  267. };
  268. // 모든 마커 및 오버레이 제거
  269. const clearAllMarkers = () => {
  270. // 오버레이 먼저 제거
  271. overlays.value.forEach((overlay) => overlay.setMap(null));
  272. overlays.value = [];
  273. // 클러스터 오버레이 제거
  274. if (clusterer.value && clusterer.value.length) {
  275. clusterer.value.forEach((c) => c.setMap(null));
  276. }
  277. clusterer.value = [];
  278. // 마커 제거
  279. markers.value.forEach((marker) => marker.setMap(null));
  280. markers.value = [];
  281. // 현재 위치 마커 제거
  282. if (userLocationMarker.value) {
  283. userLocationMarker.value.setMap(null);
  284. userLocationMarker.value = null;
  285. }
  286. // 반경 원 제거
  287. if (userLocationCircle.value) {
  288. userLocationCircle.value.setMap(null);
  289. userLocationCircle.value = null;
  290. }
  291. };
  292. // 툴팁 콘텐츠 생성
  293. const createInfoWindowContent = (location) => {
  294. // 버튼 영역 생성
  295. let buttonsHtml = "";
  296. if (locationType.value === 1) {
  297. // 전시장: 견적요청, 시승요청
  298. buttonsHtml = `
  299. <div style="display: flex; gap: 8px; margin-top: 12px;">
  300. ${
  301. location.quote_link
  302. ? `<a href="${location.quote_link}" target="_blank" style="flex: 1; padding: 8px 12px; background: #6b7280; color: white; text-decoration: none; text-align: center; border-radius: 4px; font-size: 12px;">견적 요청</a>`
  303. : ""
  304. }
  305. ${
  306. location.test_drive_link
  307. ? `<a href="${location.test_drive_link}" target="_blank" style="flex: 1; padding: 8px 12px; background: #1a1a1a; color: white; text-decoration: none; text-align: center; border-radius: 4px; font-size: 12px;">시승 요청</a>`
  308. : ""
  309. }
  310. </div>
  311. `;
  312. } else {
  313. // 서비스센터: 서비스예약
  314. buttonsHtml = location.service_reservation_link
  315. ? `
  316. <div style="margin-top: 12px;">
  317. <a href="${location.service_reservation_link}" target="_blank" style="display: block; padding: 8px 12px; background: #1a1a1a; color: white; text-decoration: none; text-align: center; border-radius: 4px; font-size: 12px;">서비스 예약</a>
  318. </div>
  319. `
  320. : "";
  321. }
  322. // 전시장일 때만 로고 표시 (닫기 버튼 포함)
  323. const logoHtml =
  324. locationType.value === 1
  325. ? `
  326. <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #eee;">
  327. <img src="/img/logo--gate1.svg" alt="Ford" style="height: 24px; width: auto;" />
  328. <button class="close-btn" style="background: none; border: none; cursor: pointer; font-size: 18px; color: #999; padding: 0; line-height: 1;">&times;</button>
  329. </div>
  330. `
  331. : `
  332. <div style="display: flex; justify-content: flex-end; margin-bottom: 8px;">
  333. <button class="close-btn" style="background: none; border: none; cursor: pointer; font-size: 18px; color: #999; padding: 0; line-height: 1;">&times;</button>
  334. </div>
  335. `;
  336. return `
  337. <div class="kakao-infowindow" style="padding: 12px; min-width: 220px; max-width: 280px; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
  338. ${logoHtml}
  339. <div>
  340. <h3 style="margin: 0 0 4px 0; font-size: 15px; font-weight: bold; color: #1a1a1a;">${
  341. location.name
  342. }</h3>
  343. <p style="margin: 0 0 10px 0; font-size: 12px; color: #666;">${
  344. location.branch_name || ""
  345. }</p>
  346. </div>
  347. <div style="font-size: 13px; color: #333; line-height: 1.6;">
  348. <div style="display: flex; align-items: flex-start; margin-bottom: 6px;">
  349. <span style="margin-right: 8px;">📍</span>
  350. <span>${location.address}</span>
  351. </div>
  352. <div style="display: flex; align-items: center; margin-bottom: 6px;">
  353. <span style="margin-right: 8px;">📞</span>
  354. <a href="tel:${
  355. location.main_phone
  356. }" style="color: #333; text-decoration: none;">${location.main_phone}</a>
  357. </div>
  358. ${
  359. location.business_hours
  360. ? `
  361. <div style="display: flex; align-items: flex-start;">
  362. <span style="margin-right: 8px;">🕐</span>
  363. <span style="white-space: pre-line;">${location.business_hours}</span>
  364. </div>
  365. `
  366. : ""
  367. }
  368. </div>
  369. ${buttonsHtml}
  370. </div>
  371. <div style="position: absolute; bottom: -10px; left: 50%; transform: translateX(-50%); width: 0; height: 0; border-left: 10px solid transparent; border-right: 10px solid transparent; border-top: 10px solid white;"></div>
  372. `;
  373. };
  374. // 픽셀 거리 계산 (두 지점 간)
  375. const getPixelDistance = (pos1, pos2) => {
  376. const proj = map.value.getProjection();
  377. const point1 = proj.pointFromCoords(pos1);
  378. const point2 = proj.pointFromCoords(pos2);
  379. return Math.sqrt(Math.pow(point1.x - point2.x, 2) + Math.pow(point1.y - point2.y, 2));
  380. };
  381. // 클러스터 오버레이 생성
  382. const createClusterOverlay = (positions, count) => {
  383. // 클러스터 중심점 계산
  384. let sumLat = 0,
  385. sumLng = 0;
  386. positions.forEach((pos) => {
  387. sumLat += pos.getLat();
  388. sumLng += pos.getLng();
  389. });
  390. const centerLat = sumLat / positions.length;
  391. const centerLng = sumLng / positions.length;
  392. const centerPosition = new window.kakao.maps.LatLng(centerLat, centerLng);
  393. // 클러스터 표시 콘텐츠
  394. const content = document.createElement("div");
  395. content.innerHTML = `
  396. <div style="
  397. width: 40px;
  398. height: 40px;
  399. background: #00095B;
  400. border: 3px solid white;
  401. border-radius: 50%;
  402. display: flex;
  403. align-items: center;
  404. justify-content: center;
  405. color: white;
  406. font-weight: bold;
  407. font-size: 14px;
  408. box-shadow: 0 2px 6px rgba(0,0,0,0.3);
  409. cursor: pointer;
  410. ">${count}</div>
  411. `;
  412. // 클러스터 클릭 시 확대
  413. content.addEventListener("click", () => {
  414. map.value.setCenter(centerPosition);
  415. const currentLevel = map.value.getLevel();
  416. map.value.setLevel(currentLevel - 2);
  417. });
  418. const overlay = new window.kakao.maps.CustomOverlay({
  419. content: content,
  420. position: centerPosition,
  421. xAnchor: 0.5,
  422. yAnchor: 0.5,
  423. zIndex: 2,
  424. });
  425. return overlay;
  426. };
  427. // 마커 클러스터링 업데이트
  428. const updateClustering = () => {
  429. if (!map.value || !window.kakao) return;
  430. const level = map.value.getLevel();
  431. const clusterThreshold = 60; // 클러스터링 픽셀 거리
  432. // 기존 클러스터 제거
  433. if (clusterer.value) {
  434. clusterer.value.forEach((c) => c.setMap(null));
  435. }
  436. clusterer.value = [];
  437. // 줌 레벨이 낮으면 (확대된 상태) 클러스터링 안함
  438. if (level <= 6) {
  439. markers.value.forEach((marker) => marker.setMap(map.value));
  440. return;
  441. }
  442. // 마커 그룹핑
  443. const assigned = new Set();
  444. const clusters = [];
  445. markers.value.forEach((marker, i) => {
  446. if (assigned.has(i)) return;
  447. const group = [marker];
  448. const positions = [marker.getPosition()];
  449. assigned.add(i);
  450. markers.value.forEach((otherMarker, j) => {
  451. if (i === j || assigned.has(j)) return;
  452. const distance = getPixelDistance(
  453. marker.getPosition(),
  454. otherMarker.getPosition()
  455. );
  456. if (distance < clusterThreshold) {
  457. group.push(otherMarker);
  458. positions.push(otherMarker.getPosition());
  459. assigned.add(j);
  460. }
  461. });
  462. clusters.push({ markers: group, positions: positions });
  463. });
  464. // 클러스터 또는 개별 마커 표시
  465. clusters.forEach((cluster) => {
  466. if (cluster.markers.length > 1) {
  467. // 클러스터로 표시
  468. cluster.markers.forEach((m) => m.setMap(null));
  469. const clusterOverlay = createClusterOverlay(
  470. cluster.positions,
  471. cluster.markers.length
  472. );
  473. clusterOverlay.setMap(map.value);
  474. clusterer.value.push(clusterOverlay);
  475. } else {
  476. // 개별 마커 표시
  477. cluster.markers[0].setMap(map.value);
  478. }
  479. });
  480. };
  481. // 마커 생성
  482. const createMarkers = () => {
  483. if (!map.value || !window.kakao) return;
  484. // 기존 오버레이 제거
  485. overlays.value.forEach((overlay) => overlay.setMap(null));
  486. overlays.value = [];
  487. // 기존 클러스터 제거
  488. if (clusterer.value) {
  489. clusterer.value.forEach((c) => c.setMap(null));
  490. }
  491. clusterer.value = [];
  492. // 기존 마커 제거
  493. markers.value.forEach((marker) => marker.setMap(null));
  494. markers.value = [];
  495. // 마커 생성 (CustomOverlay로 로고 마커 적용)
  496. filteredLocations.value.forEach((location) => {
  497. if (location.latitude && location.longitude) {
  498. const position = new window.kakao.maps.LatLng(
  499. parseFloat(location.latitude),
  500. parseFloat(location.longitude)
  501. );
  502. // 커스텀 마커 HTML (타원형 안에 로고)
  503. const markerWrapper = document.createElement("div");
  504. markerWrapper.innerHTML = `
  505. <div class="ford-marker" style="
  506. width: 90px;
  507. height: 40px;
  508. // background: #00095B;
  509. // border: 2px solid white;
  510. border-radius: 20px;
  511. display: flex;
  512. align-items: center;
  513. justify-content: center;
  514. cursor: pointer;
  515. box-shadow: 0 2px 6px rgba(0,0,0,0.3);
  516. ">
  517. <img src="/img/logo--gate1.svg" alt="Ford" style="width: 70px; height: auto; display: block;" />
  518. </div>
  519. `;
  520. // 정보창 오버레이 생성
  521. const overlayContent = document.createElement("div");
  522. overlayContent.innerHTML = createInfoWindowContent(location);
  523. overlayContent.style.position = "relative";
  524. overlayContent.style.bottom = "45px";
  525. const overlay = new window.kakao.maps.CustomOverlay({
  526. content: overlayContent,
  527. position: position,
  528. xAnchor: 0.5,
  529. yAnchor: 1,
  530. zIndex: 3,
  531. });
  532. // 닫기 버튼 이벤트
  533. const closeBtn = overlayContent.querySelector(".close-btn");
  534. if (closeBtn) {
  535. closeBtn.addEventListener("click", (e) => {
  536. e.stopPropagation();
  537. overlay.setMap(null);
  538. });
  539. }
  540. // 마커 클릭 이벤트 핸들러
  541. markerWrapper.onclick = () => {
  542. // 다른 오버레이 닫기
  543. overlays.value.forEach((ov) => ov.setMap(null));
  544. // 현재 오버레이 열기
  545. overlay.setMap(map.value);
  546. selectLocation(location);
  547. };
  548. // 마커 생성 (CustomOverlay)
  549. const marker = new window.kakao.maps.CustomOverlay({
  550. position: position,
  551. content: markerWrapper,
  552. yAnchor: 0.5,
  553. xAnchor: 0.5,
  554. });
  555. marker.overlay = overlay;
  556. markers.value.push(marker);
  557. overlays.value.push(overlay);
  558. }
  559. });
  560. // 클러스터링 적용
  561. updateClustering();
  562. // 줌 변경 시 클러스터링 업데이트 (기존 리스너 제거 후 추가)
  563. window.kakao.maps.event.removeListener(map.value, "zoom_changed", updateClustering);
  564. window.kakao.maps.event.addListener(map.value, "zoom_changed", updateClustering);
  565. };
  566. // 목록 불러오기
  567. const loadLocations = async () => {
  568. isLoading.value = true;
  569. // 기존 마커 모두 제거
  570. clearAllMarkers();
  571. // 지도 새로 초기화 (서울 중심)
  572. if (window.kakao && window.kakao.maps) {
  573. const container = document.getElementById("map");
  574. if (container) {
  575. const options = {
  576. center: new window.kakao.maps.LatLng(37.5665, 126.978),
  577. level: 8,
  578. };
  579. map.value = new window.kakao.maps.Map(container, options);
  580. }
  581. }
  582. const { data } = await get(apiEndpoint.value);
  583. if (data?.success && data?.data) {
  584. // public API는 배열을 직접 반환
  585. locations.value = Array.isArray(data.data) ? data.data : data.data.items || [];
  586. // 마커 생성
  587. await nextTick();
  588. createMarkers();
  589. }
  590. isLoading.value = false;
  591. };
  592. // 필터링된 목록
  593. const filteredLocations = computed(() => {
  594. let result = locations.value;
  595. // 내 주변 필터 (우선순위 최상위)
  596. if (userLocation.value) {
  597. result = result.filter((location) => {
  598. if (!location.latitude || !location.longitude) return false;
  599. const distance = calculateDistance(
  600. userLocation.value.lat,
  601. userLocation.value.lng,
  602. parseFloat(location.latitude),
  603. parseFloat(location.longitude)
  604. );
  605. return distance <= nearbyRadius.value;
  606. });
  607. // 거리순으로 정렬
  608. result = result
  609. .map((location) => {
  610. const distance = calculateDistance(
  611. userLocation.value.lat,
  612. userLocation.value.lng,
  613. parseFloat(location.latitude),
  614. parseFloat(location.longitude)
  615. );
  616. return { ...location, distance };
  617. })
  618. .sort((a, b) => a.distance - b.distance);
  619. }
  620. // 지역 필터
  621. if (selectedRegion.value) {
  622. result = result.filter((location) =>
  623. location.address?.includes(selectedRegion.value)
  624. );
  625. }
  626. // 검색어 필터
  627. if (searchKeyword.value) {
  628. const keyword = searchKeyword.value.toLowerCase();
  629. result = result.filter(
  630. (location) =>
  631. location.name?.toLowerCase().includes(keyword) ||
  632. location.address?.toLowerCase().includes(keyword) ||
  633. location.branch_name?.toLowerCase().includes(keyword)
  634. );
  635. }
  636. return result;
  637. });
  638. // 선택
  639. const selectLocation = (location) => {
  640. selectedLocation.value = location;
  641. if (map.value && location.latitude && location.longitude && window.kakao) {
  642. // 지도 중심 이동 및 줌인
  643. const position = new window.kakao.maps.LatLng(
  644. parseFloat(location.latitude),
  645. parseFloat(location.longitude)
  646. );
  647. map.value.setCenter(position);
  648. map.value.setLevel(3); // 확대
  649. // 해당 마커의 오버레이 열기
  650. const targetLat = parseFloat(location.latitude);
  651. const targetLng = parseFloat(location.longitude);
  652. const marker = markers.value.find((m) => {
  653. const pos = m.getPosition();
  654. // 부동소수점 비교를 위해 근사값 비교
  655. return (
  656. Math.abs(pos.getLat() - targetLat) < 0.0001 &&
  657. Math.abs(pos.getLng() - targetLng) < 0.0001
  658. );
  659. });
  660. if (marker && marker.overlay) {
  661. // 다른 오버레이 닫기
  662. overlays.value.forEach((ov) => ov.setMap(null));
  663. marker.overlay.setMap(map.value);
  664. }
  665. }
  666. };
  667. // 검색
  668. const searchLocations = () => {
  669. // 내 주변 필터 해제
  670. userLocation.value = null;
  671. // computed에서 자동으로 필터링되므로 별도 처리 불필요
  672. // 검색 후 마커 업데이트
  673. nextTick(() => {
  674. createMarkers();
  675. });
  676. };
  677. // 내 주변 찾기
  678. const findNearbyLocations = () => {
  679. if (!navigator.geolocation) {
  680. alert("이 브라우저는 위치 정보를 지원하지 않습니다.");
  681. return;
  682. }
  683. isLoading.value = true;
  684. navigator.geolocation.getCurrentPosition(
  685. (position) => {
  686. userLocation.value = {
  687. lat: position.coords.latitude,
  688. lng: position.coords.longitude,
  689. };
  690. // 지도 중심을 현재 위치로 이동
  691. if (map.value && window.kakao) {
  692. const currentPosition = new window.kakao.maps.LatLng(
  693. userLocation.value.lat,
  694. userLocation.value.lng
  695. );
  696. map.value.setCenter(currentPosition);
  697. map.value.setLevel(5);
  698. // 기존 현재 위치 마커/원 제거
  699. if (userLocationMarker.value) {
  700. userLocationMarker.value.setMap(null);
  701. }
  702. if (userLocationCircle.value) {
  703. userLocationCircle.value.setMap(null);
  704. }
  705. // 현재 위치 마커 추가 (파란색 원)
  706. const markerContent = `
  707. <div style="width: 20px; height: 20px; background: #4285F4; border: 3px solid white; border-radius: 50%; box-shadow: 0 2px 6px rgba(0,0,0,0.3);"></div>
  708. `;
  709. userLocationMarker.value = new window.kakao.maps.CustomOverlay({
  710. content: markerContent,
  711. position: currentPosition,
  712. xAnchor: 0.5,
  713. yAnchor: 0.5,
  714. });
  715. userLocationMarker.value.setMap(map.value);
  716. // 반경 원 표시
  717. userLocationCircle.value = new window.kakao.maps.Circle({
  718. center: currentPosition,
  719. radius: nearbyRadius.value * 1000, // km를 m로 변환
  720. strokeWeight: 2,
  721. strokeColor: "#4285F4",
  722. strokeOpacity: 0.8,
  723. strokeStyle: "solid",
  724. fillColor: "#4285F4",
  725. fillOpacity: 0.15,
  726. });
  727. userLocationCircle.value.setMap(map.value);
  728. }
  729. // 마커 업데이트
  730. nextTick(() => {
  731. createMarkers();
  732. });
  733. isLoading.value = false;
  734. const nearbyCount = filteredLocations.value.length;
  735. if (nearbyCount === 0) {
  736. alert(`주변 ${nearbyRadius.value}km 이내에 ${locationLabel.value}이 없습니다.`);
  737. } else {
  738. alert(
  739. `주변 ${nearbyRadius.value}km 이내에 ${nearbyCount}개의 ${locationLabel.value}을 찾았습니다.`
  740. );
  741. }
  742. },
  743. (error) => {
  744. isLoading.value = false;
  745. let errorMessage = "위치 정보를 가져올 수 없습니다.";
  746. if (error.code === 1) {
  747. errorMessage =
  748. "위치 정보 접근이 거부되었습니다. 브라우저 설정에서 위치 정보 권한을 허용해주세요.";
  749. } else if (error.code === 2) {
  750. errorMessage = "위치 정보를 사용할 수 없습니다.";
  751. } else if (error.code === 3) {
  752. errorMessage = "위치 정보 요청 시간이 초과되었습니다.";
  753. }
  754. alert(errorMessage);
  755. },
  756. {
  757. enableHighAccuracy: true,
  758. timeout: 10000,
  759. maximumAge: 0,
  760. }
  761. );
  762. };
  763. // 스크롤 이벤트 핸들러
  764. const handleScroll = () => {
  765. const el = listWrapRef.value;
  766. if (el) {
  767. const threshold = 10;
  768. isScrolledToBottom.value =
  769. el.scrollTop + el.clientHeight >= el.scrollHeight - threshold;
  770. }
  771. };
  772. // 검색어나 지역 변경시 내 주변 필터 해제
  773. watch([searchKeyword, selectedRegion], () => {
  774. if (userLocation.value) {
  775. userLocation.value = null;
  776. }
  777. });
  778. // type이 변경되면 데이터 다시 로드 및 지도 리셋
  779. watch(
  780. () => route.query.type,
  781. (newType, oldType) => {
  782. // 실제로 타입이 변경된 경우에만 실행
  783. if (newType === oldType) return;
  784. // 데이터 초기화
  785. locations.value = [];
  786. selectedLocation.value = null;
  787. searchKeyword.value = "";
  788. selectedRegion.value = "";
  789. userLocation.value = null;
  790. // 모든 마커 제거
  791. clearAllMarkers();
  792. // 지도 리셋 (서울 중심으로)
  793. if (map.value && window.kakao) {
  794. const center = new window.kakao.maps.LatLng(37.5665, 126.978);
  795. map.value.setCenter(center);
  796. map.value.setLevel(8);
  797. }
  798. // 데이터 다시 로드
  799. loadLocations();
  800. }
  801. );
  802. // 페이지 로드 시 위치 권한 요청 (권한만 요청, 필터는 적용하지 않음)
  803. const requestLocationPermission = () => {
  804. if (!navigator.geolocation) {
  805. return;
  806. }
  807. // 위치 권한만 요청하고 userLocation은 설정하지 않음
  808. navigator.geolocation.getCurrentPosition(
  809. () => {
  810. // 권한 허용됨 - 나중에 "내 주변" 클릭 시 사용
  811. },
  812. () => {
  813. // 권한 거부되어도 페이지는 정상 동작
  814. },
  815. { enableHighAccuracy: false, timeout: 10000, maximumAge: 300000 }
  816. );
  817. };
  818. onMounted(async () => {
  819. console.log("[KakaoMap] onMounted");
  820. // 페이지 로드 시 위치 권한 요청
  821. requestLocationPermission();
  822. try {
  823. await loadKakaoMapsScript();
  824. await nextTick(); // DOM이 렌더링될 때까지 대기
  825. initMap();
  826. } catch (error) {
  827. console.error("[KakaoMap] Load error:", error);
  828. // 지도 로드 실패해도 목록은 표시
  829. } finally {
  830. await loadLocations();
  831. }
  832. });
  833. </script>