PageSourceSearch

https://gjcare.net/assets/controllers/base/maps/kakao_controller-ae9df740.js

js gjcare.net collected 2026-10-07 23:12:31 UTC 15,336 bytes, 484 lines download raw bytes

1import { Controller } from '@hotwired/stimulus';
2import { FetchRequest } from '@rails/request.js';
3import EXIF from 'exif-js';
4
5/**
6 * @domain 외부 연동
7 * @concern Kakao Maps API 지도·마커·오버레이 연동
8 * @description Kakao Maps API로 장소 마커·클러스터·정보 오버레이를 렌더하고, 주소 검색·사진 EXIF로 위치를 입력받는 지도 컨트롤러
9 */
10export default class extends Controller {
11  static targets = ['map', 'placeInput', 'latitudeInput', 'longitudeInput', 'infoOverlayContent'];
12  static values = {
13    loadedPlaces: Array,
14    centerLongitude: Number,
15    centerLatitude: Number,
16    infoOverlayZIndex: Number,
17    markerImagePath: String,
18    markerImageWidth: Number,
19    markerImageHeight: Number,
20    useClusterer: { type: Boolean, default: true },
21    hoverPreview: { type: Boolean, default: false },
22    fitAllMarkers: { type: Boolean, default: false },
23    boundsPaddingTop: Number,
24    boundsPaddingRight: Number,
25    boundsPaddingBottom: Number,
26    boundsPaddingLeft: Number,
27    zoomInSteps: Number,
28  };
29
30  connect() {
31    if (this.hasMapTarget) {
32      this._initGeocoder();
33      this._initMap();
34      this._drawLoadedPlaces();
35
36      // 페이지 로드 시 기존에 입력된 좌표가 있으면 마커를 그림
37      const initialPlace = this._getInputtedPlace();
38      if (initialPlace) {
39        this._updateInputtedPlace(initialPlace.latitude, initialPlace.longitude);
40      }
41    }
42  }
43
44  disconnect() {
45    if (this.markers) {
46      this.markers.forEach((marker) => marker.setMap(null));
47      this.markers = [];
48    }
49
50    if (this.mapCluster) {
51      this.mapCluster.clear();
52      this.mapCluster = null;
53    }
54
55    if (this.infoOverlays) {
56      this.infoOverlays.forEach((infoOverlay) => infoOverlay.setMap(null));
57      this.infoOverlays = [];
58    }
59
60    if (this.inputtedMarker) {
61      this.inputtedMarker.setMap(null);
62      this.inputtedMarker = null;
63    }
64  }
65
66  // --- Event Handlers ----------------------------------------------------
67
68  /**
69   * Daum 주소 검색 API를 엽니다.
70   */
71  handlePlaceSearch() {
72    new daum.Postcode({
73      oncomplete: this.handleOnCompleteDaumPostcode.bind(this),
74    }).open();
75  }
76
77  /**
78   * 주소 검색 완료 시 콜백. 검색된 주소의 좌표를 가져와 지도를 업데이트합니다.
79   */
80  handleOnCompleteDaumPostcode(data) {
81    this.placeInputTarget.value = data.address;
82
83    this.geocoder.addressSearch(data.address, (result, status) => {
84      if (status === kakao.maps.services.Status.OK) {
85        // Kakao Geocoder는 y가 위도, x가 경도입니다.
86        this._updateInputtedPlace(result[0].y, result[0].x);
87      }
88    });
89  }
90
91  /**
92   * 이미지 파일 업로드 시 EXIF에서 GPS 정보를 추출하여 지도를 업데이트합니다.
93   */
94  handleImageUpload(event) {
95    const file = event.target.files[0];
96    if (!file) return;
97
98    EXIF.getData(file, () => {
99      const lat = EXIF.getTag(file, 'GPSLatitude');
100      const lon = EXIF.getTag(file, 'GPSLongitude');
101      const latRef = EXIF.getTag(file, 'GPSLatitudeRef');
102      const lonRef = EXIF.getTag(file, 'GPSLongitudeRef');
103
104      if (lat && lon && latRef && lonRef) {
105        const latitude = this._convertDMSToDD(lat[0], lat[1], lat[2], latRef);
106        const longitude = this._convertDMSToDD(lon[0], lon[1], lon[2], lonRef);
107        this._updateInputtedPlace(latitude, longitude);
108      } else {
109        alert('선택하신 사진에 위치 정보(GPS)가 없습니다.');
110        // GPS 정보가 없을 때 input 필드를 비워줍니다.
111        this.placeInputTarget.value = '';
112      }
113    });
114  }
115
116  // --- Private Helper Methods --------------------------------------------
117
118  /**
119   * Kakao Geocoder 초기화
120   * @private
121   */
122  _initGeocoder() {
123    this.geocoder = new kakao.maps.services.Geocoder();
124  }
125
126  /**
127   * Kakao Map 초기화
128   * @private
129   */
130  _initMap() {
131    this.map = new kakao.maps.Map(this.mapTarget, {
132      center: new kakao.maps.LatLng(this.centerLatitudeValue, this.centerLongitudeValue),
133      level: 3,
134    });
135    this.infoOverlays = [];
136    this.markers = [];
137
138    if (this.useClustererValue) {
139      this.mapCluster = new kakao.maps.MarkerClusterer({
140        map: this.map,
141        averageCenter: true,
142      });
143    }
144  }
145
146  /**
147   * 입력된 좌표로 form input과 지도 UI를 모두 업데이트하는 통합 메서드.
148   * @private
149   */
150  _updateInputtedPlace(latitude, longitude) {
151    if (!latitude || !longitude) return;
152
153    // 1. Form의 hidden input 값 업데이트
154    this.latitudeInputTarget.value = latitude;
155    this.longitudeInputTarget.value = longitude;
156
157    const position = new kakao.maps.LatLng(latitude, longitude);
158
159    // 2. 지도 위 마커 업데이트 (없으면 생성, 있으면 위치 이동)
160    if (!this.inputtedMarker) {
161      this.inputtedMarker = this._buildMarker({}); // 위치 없는 마커 우선 생성
162      this.inputtedMarker.setMap(this.map);
163    }
164    this.inputtedMarker.setPosition(position);
165
166    // 3. 지도 중심 이동 및 경계 재조정
167    this.map.setCenter(position);
168    this._adjustMapBounds();
169
170    this._searchAddrFromCoords(position);
171  }
172
173  /**
174   * DMS(도,분,초) 형식의 GPS 좌표를 DD(십진수) 형식으로 변환합니다.
175   * @private
176   */
177  _convertDMSToDD(degrees, minutes, seconds, direction) {
178    let dd = degrees + minutes / 60 + seconds / 3600;
179    if (direction === 'S' || direction === 'W') {
180      dd *= -1;
181    }
182    return dd;
183  }
184
185  /**
186   * Stimulus Target 콜백: infoOverlayContentTargetConnected(element)
187   *
188   * 카카오맵의 '커스텀 오버레이(CustomOverlay)'는 자바스크립트 객체로 존재합니다.
189   * 그런데 이 오버레이 안에 표시될 '내용(content)'은 보통 Turbo Stream 같은 기술로
190   * 비동기적으로 HTML이 삽입되거나 교체됩니다.
191   *
192   * 이 함수는 'connect()'처럼 페이지 로드 시 실행되는 것이 아닙니다.
193   * 컨트롤러 DOM 내부에 `data-maps--kakao-target="infoOverlayContent"` 속성을 가진
194   * 새로운 HTML 요소가 '나중에' 삽입되거나 연결될 때마다 '자동으로' 실행됩니다.
195   *
196   * 1. 현재 열려있는(`isOpen === true`) 오버레이를 찾기 위해 `this.infoOverlays` 배열을 순회합니다.
197   * 2. 새로 도착한 HTML 내용(`element`)의 부모 ID가, 열려있는 오버레이가 가지고 있는
198   * 고유 ID(`infoOverlay.domId`)와 일치하는지 확인합니다.
199   * 3. 일치하는 오버레이를 찾으면, `infoOverlay.setContent(element)`를 호출합니다.
200   */
201  infoOverlayContentTargetConnected(element) {
202    // 뒤로가기(Turbo 캐시 스냅샷 복원) 시 _initMap 전에 target이 연결되면 infoOverlays가 없을 수 있다.
203    if (!this.infoOverlays) return;
204
205    this.infoOverlays.forEach((infoOverlay) => {
206      if (infoOverlay.isOpen && element.parentElement.id === infoOverlay.domId) {
207        infoOverlay.setContent(element);
208      }
209    });
210  }
211
212  // --- Map Drawing & Utility Methods -------------------------------------
213
214  /**
215   * `loadedPlacesValue`에 담긴 장소들의 마커를 클러스터에 추가합니다.
216   */
217  _drawLoadedPlaces() {
218    if (!this.map) return;
219
220    this.loadedPlacesValue.forEach((place) => {
221      const marker = this._buildMarker(place);
222      this.markers.push(marker);
223      if (this.useClustererValue) {
224        this.mapCluster.addMarker(marker);
225      } else {
226        marker.setMap(this.map);
227      }
228
229      const infoOverlay = this._buildInfoOverlay(place);
230      this._bindMarkerOverlayEvents(marker, infoOverlay);
231      this._buildInfoClickEventTrigger(place, marker);
232    });
233
234    this._adjustMapBounds();
235  }
236
237  /**
238   * hidden input 필드에서 현재 입력된 장소 정보를 가져옵니다.
239   */
240  _getInputtedPlace() {
241    if (
242      this.hasLongitudeInputTarget &&
243      this.longitudeInputTarget.value &&
244      this.hasLatitudeInputTarget &&
245      this.latitudeInputTarget.value
246    ) {
247      return {
248        longitude: this.longitudeInputTarget.value,
249        latitude: this.latitudeInputTarget.value,
250      };
251    }
252    return null;
253  }
254
255  /**
256   * 장소 정보(place) 객체로 카카오맵 마커를 생성합니다.
257   */
258  _buildMarker(place) {
259    const imageSize = new kakao.maps.Size(this.markerImageWidthValue, this.markerImageHeightValue);
260    const markerImage = new kakao.maps.MarkerImage(this.markerImagePathValue, imageSize);
261    return new kakao.maps.Marker({
262      position: this._buildPosition(place),
263      image: markerImage,
264    });
265  }
266
267  /**
268   * 장소 정보(place) 객체로 카카오맵 오버레이를 생성해 반환합니다.
269   */
270  _buildInfoOverlay(place) {
271    if (!place.info_overlay_content) return null;
272
273    const infoOverlay = new kakao.maps.CustomOverlay({
274      content: place.info_overlay_content,
275      position: this._buildPosition(place),
276      clickable: true,
277      zIndex: this.infoOverlayZIndexValue || null,
278      yAnchor: -0.1,
279    });
280    infoOverlay.isOpen = false;
281    infoOverlay.pinned = false;
282    infoOverlay.domId = place.info_overlay_dom_id;
283    this.infoOverlays.push(infoOverlay);
284
285    return infoOverlay;
286  }
287
288  /**
289   * 마커에 오버레이 표시 이벤트를 연결합니다.
290   * 기본은 클릭 토글, hoverPreview면 호버 미리보기 + 클릭 고정(pin)입니다.
291   */
292  _bindMarkerOverlayEvents(marker, infoOverlay) {
293    if (!infoOverlay) return;
294
295    if (this.hoverPreviewValue) {
296      kakao.maps.event.addListener(marker, 'click', () => {
297        this._closeAllInfoOverlaysExclude(infoOverlay);
298        this._togglePinnedInfoOverlay(infoOverlay);
299      });
300      kakao.maps.event.addListener(marker, 'mouseover', () => {
301        if (infoOverlay.isOpen) return;
302        this._closeAllInfoOverlaysExclude(infoOverlay);
303        this._openInfoOverlay(infoOverlay, false);
304      });
305      kakao.maps.event.addListener(marker, 'mouseout', () => {
306        if (infoOverlay.pinned) return;
307        this._closeInfoOverlay(infoOverlay);
308      });
309      return;
310    }
311
312    kakao.maps.event.addListener(marker, 'click', () => {
313      this._closeAllInfoOverlaysExclude(infoOverlay);
314      this._toggleInfoOverlay(infoOverlay);
315    });
316  }
317
318  /**
319   * 장소 정보(place) 객체로 카카오맵 클릭 이벤트 트리거를 생성합니다.
320   */
321  _buildInfoClickEventTrigger(place, marker) {
322    if (!place.info_click_event_selector) return;
323
324    const eventElement = document.querySelector(place.info_click_event_selector);
325    if (!eventElement) return;
326
327    kakao.maps.event.addListener(marker, 'click', () => {
328      if (eventElement.hasAttribute('data-turbo-stream')) {
329        new FetchRequest('get', eventElement.href, { responseKind: 'turbo-stream' }).perform();
330      } else {
331        eventElement.click();
332      }
333    });
334  }
335
336  /**
337   * 장소 정보로 카카오맵 위치(LatLng) 객체를 생성합니다.
338   */
339  _buildPosition(place) {
340    if (place && place.latitude && place.longitude) {
341      return new kakao.maps.LatLng(place.latitude, place.longitude);
342    }
343    return null;
344  }
345
346  /**
347   * 모든 오버레이를 닫고, 특정 오버레이만 열어둡니다.
348   * @param {kakao.maps.CustomOverlay} excludeInfoOverlay - 제외할 오버레이 객체
349   * @private
350   */
351  _closeAllInfoOverlaysExclude(excludeInfoOverlay) {
352    this.infoOverlays.forEach((otherOverlay) => {
353      if (excludeInfoOverlay !== otherOverlay) {
354        this._closeInfoOverlay(otherOverlay);
355      }
356    });
357  }
358
359  _openInfoOverlay(infoOverlay, pinned) {
360    if (!infoOverlay) return;
361
362    infoOverlay.setMap(this.map);
363    infoOverlay.isOpen = true;
364    infoOverlay.pinned = pinned;
365  }
366
367  _closeInfoOverlay(infoOverlay) {
368    if (!infoOverlay) return;
369
370    infoOverlay.setMap(null);
371    infoOverlay.isOpen = false;
372    infoOverlay.pinned = false;
373  }
374
375  /**
376   * 특정 오버레이의 표시 상태를 토글합니다.
377   * @param {kakao.maps.CustomOverlay} infoOverlay - 토글할 오버레이 객체
378   * @private
379   */
380  _toggleInfoOverlay(infoOverlay) {
381    if (!infoOverlay) return;
382
383    if (infoOverlay.isOpen) {
384      this._closeInfoOverlay(infoOverlay);
385    } else {
386      this._openInfoOverlay(infoOverlay, false);
387    }
388  }
389
390  /**
391   * 호버 미리보기 모드에서 오버레이를 클릭으로 고정(pin)하거나 해제합니다.
392   * @param {kakao.maps.CustomOverlay} infoOverlay - 토글할 오버레이 객체
393   * @private
394   */
395  _togglePinnedInfoOverlay(infoOverlay) {
396    if (!infoOverlay) return;
397
398    if (infoOverlay.pinned) {
399      this._closeInfoOverlay(infoOverlay);
400    } else {
401      this._openInfoOverlay(infoOverlay, true);
402    }
403  }
404
405  /**
406   * 모든 마커가 보이도록 지도의 경계와 줌 레벨을 조정합니다.
407   */
408  _adjustMapBounds() {
409    if (!this.map) return;
410
411    const bounds = new kakao.maps.LatLngBounds();
412    let lastPosition = null;
413
414    this.loadedPlacesValue.forEach((place) => {
415      const position = this._buildPosition(place);
416      if (position) {
417        bounds.extend(position);
418        lastPosition = position;
419      }
420    });
421
422    const inputtedPlace = this._getInputtedPlace();
423    if (inputtedPlace) {
424      const position = this._buildPosition(inputtedPlace);
425      if (position) {
426        bounds.extend(position);
427        lastPosition = position;
428      }
429    }
430
431    if (bounds.isEmpty()) return;
432
433    if (this.fitAllMarkersValue) {
434      // 네 방향 여백으로 마커 묶음의 위치를 밀어낸다. 오른쪽 여백을 주면 마커가
435      // 왼쪽 영역(오버레이 카드에 가리지 않는 쪽)으로 맞춰지고, 반대로 상단·왼쪽
436      // 여백을 주면 마커를 아래·오른쪽으로 옮길 수 있다(모바일 배치 조정).
437      this.map.setBounds(
438        bounds,
439        this.boundsPaddingTopValue,
440        this.boundsPaddingRightValue,
441        this.boundsPaddingBottomValue,
442        this.boundsPaddingLeftValue,
443      );
444      if (this.zoomInStepsValue !== 0) {
445        this.map.setLevel(this.map.getLevel() - this.zoomInStepsValue);
446      }
447      return;
448    }
449
450    this.map.setBounds(bounds);
451    this.map.setCenter(lastPosition);
452
453    if (this.loadedPlacesValue.length == 0) {
454      this.map.setLevel(4);
455    } else {
456      const currentZoom = this.map.getLevel();
457      // 너무 과도하게 줌아웃되는 것을 방지
458      const maxZoomLevel = 11; // 1~14 level
459      if (currentZoom > maxZoomLevel) {
460        this.map.setLevel(maxZoomLevel);
461      }
462    }
463  }
464
465  /**
466   * 좌표(LatLng)를 주소 정보로 변환하고 placeInputTarget에 값을 입력합니다.
467   * @param {kakao.maps.LatLng} coords - 주소로 변환할 좌표 객체
468   * @private
469   */
470  _searchAddrFromCoords(coords) {
471    // geocoder.coord2Address를 사용하여 좌표를 주소로 변환
472    this.geocoder.coord2Address(coords.getLng(), coords.getLat(), (result, status) => {
473      if (status === kakao.maps.services.Status.OK) {
474        // 도로명 주소가 있으면 도로명 주소를, 없으면 지번 주소를 사용
475        const address = result[0].road_address ? result[0].road_address.address_name : result[0].address.address_name;
476
477        // placeInputTarget에 변환된 주소를 입력
478        if (this.hasPlaceInputTarget) {
479          this.placeInputTarget.value = address;
480        }
481      }
482    });
483  }
484}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.