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.