import { LitElement, html, css } from 'lit' import { customElement, property, state } from 'lit/decorators.js' import { ScrollbarStyles } from '@operato/styles' import GoogleMapLoader from './google-map-loader.js' declare global { interface Window { google: any markerClusterer?: any } } declare const google: any declare namespace google.maps { interface MapsLibrary { Map: any InfoWindow: any } interface MarkerLibrary { AdvancedMarkerElement: any PinElement: any } } @customElement('common-google-map') export class CommonGoogleMap extends LitElement { static styles = [ ScrollbarStyles, css` :host { display: flex; } [map] { flex: 1; } .gm-style .gm-style-iw-c { padding: 0; } .gm-style .gm-style-iw-d { overflow: auto !important; } .gm-style .gm-style-iw-d + button { top: 0 !important; right: 0 !important; } ` ] @property({ type: Object }) center @property({ type: Number }) zoom @property({ type: Array }) locations: any[] = [] @property({ type: Object }) focused @property({ type: Array }) polygons @property({ type: Array }) polylines @property({ type: Array }) markers @property({ type: Array }) boundCoords @property({ type: Object }) controls @property({ type: Number }) clusterZoom = 10 @state() map: any = null @state() defaultCenter: any = { lat: 36.5, lng: 127.5 } private _infoWindow: any = null private _markerClusterer: any = null get anchor() { return this.renderRoot.querySelector('[map]') } async readyMap() { await GoogleMapLoader.load() // MarkerClusterer 라이브러리 로드 if (!window.markerClusterer) { await GoogleMapLoader.loadMarkerClusterer() } if (this.map) { return } // DOM이 준비될 때까지 기다림 await this.updateComplete // anchor가 준비될 때까지 기다림 let attempts = 0 const maxAttempts = 20 while (attempts < maxAttempts) { const anchor = this.anchor as HTMLElement if (anchor && anchor.offsetWidth > 0) { break } await new Promise(resolve => setTimeout(resolve, 50)) attempts++ } if (!this.anchor) { console.error('Map anchor element not found') return } var show = async (center, zoom) => { try { // Google Maps 최신 API 사용 const { Map } = (await google.maps.importLibrary('maps')) as google.maps.MapsLibrary const mapOptions = { zoom, center, mapId: 'DEMO_MAP_ID' } // controls 속성이 있으면 지도 옵션에 추가 if (this.controls) { Object.assign(mapOptions, this.controls) } const map = new Map(this.anchor, mapOptions) this.markers && this.markers.forEach(marker => marker.setMap(map)) this.map = map this.dispatchEvent( new CustomEvent('map-change', { detail: this.map }) ) this.resetBounds() } catch (e) { console.error(e) } } var { center, zoom = 10 } = this /* center 속성이 설정되어있지 않으면, 현재 위치를 구해서 지도의 center로 설정한다. */ if (!center && 'geolocation' in navigator && !this.boundCoords?.length) { navigator.geolocation.getCurrentPosition( ({ coords: { latitude: lat, longitude: lng } }) => show({ lat, lng }, zoom), err => { console.warn(`navigator.geolocation.getCurrentPosition failed. (${err.code}): ${err.message}`) show(this.defaultCenter, zoom) }, { /* https://stackoverflow.com/questions/3397585/navigator-geolocation-getcurrentposition-sometimes-works-sometimes-doesnt */ timeout: 500 } ) } else { show(center, zoom) } } async buildMarkers(locations: any[] = []) { if (!this.map) { return } if (this.markers) { this.markers.forEach(marker => marker.setMap(null)) this.markers = [] } // 기존 클러스터 제거 if (this._markerClusterer) { this._markerClusterer.clearMarkers() this._markerClusterer = null } // Google Maps 최신 API 사용 const { AdvancedMarkerElement, PinElement } = (await google.maps.importLibrary( 'marker' )) as google.maps.MarkerLibrary this.markers = locations .map(location => { // location 객체가 유효한지 확인 if (!location || typeof location !== 'object') { console.warn('Invalid location object:', location) return null } // lat, lng 값이 유효한지 확인 const lat = parseFloat(location.lat) const lng = parseFloat(location.lng) if (isNaN(lat) || isNaN(lng)) { console.warn('Invalid lat/lng values:', location) return null } // LatLng 객체 생성 const position = new google.maps.LatLng(lat, lng) // 커스텀 마커 콘텐츠가 있으면 사용 let markerElement if (location.markerContent) { // HTML 문자열을 DOM 요소로 변환 const tempDiv = document.createElement('div') tempDiv.innerHTML = location.markerContent markerElement = tempDiv.firstElementChild } else { // 기본 핀 사용 markerElement = new PinElement({ background: '#1976d2', borderColor: '#1565c0', glyphColor: '#ffffff', scale: 1.2 }) } // AdvancedMarkerElement 사용 const marker = new AdvancedMarkerElement({ position: position, map: null, // 클러스터에서 관리하므로 지도에 직접 추가하지 않음 content: markerElement }) marker.addListener('click', () => { // InfoWindow 대신 커스텀 이벤트 발생 if (location?.region) { this.dispatchEvent( new CustomEvent('region-click', { detail: { region: location.region }, bubbles: true, composed: true }) ) } }) return marker }) .filter(marker => marker !== null) // null 마커 제거 // Google Maps 공식 MarkerClusterer 사용 (예시와 동일한 방식) if (this.markers.length > 0 && window.markerClusterer) { this._markerClusterer = new window.markerClusterer.MarkerClusterer({ markers: this.markers, map: this.map }) } } get infoWindow() { if (!this._infoWindow && this.map) { this._infoWindow = new google.maps.InfoWindow({ content: 'loading...' }) } return this._infoWindow } setFocus(focus, icon) { focus.setZIndex(1) focus.setIcon(icon) } resetFocus(focus, icon) { focus.setZIndex(0) focus.setIcon(icon) } async changeFocus(after, before) { await this.readyMap() // map이 준비되지 않았으면 포커스 변경하지 않음 if (!this.map) { return } var locations = this.locations || [] if (before) { var idx = locations.findIndex(location => { // location 객체의 구조를 안전하게 확인 const beforePos = before?.position const locationPos = location?.position return ( location?.name == before?.name && locationPos?.lat == beforePos?.lat && locationPos?.lng == beforePos?.lng ) }) idx !== -1 && this.markers && this.resetFocus(this.markers[idx], locations[idx]?.icon) } if (after) { var idx = locations.findIndex(location => { // location 객체의 구조를 안전하게 확인 const afterPos = after?.position const locationPos = location?.position return location?.name == after?.name && locationPos?.lat == afterPos?.lat && locationPos?.lng == afterPos?.lng }) idx !== -1 && this.markers && this.setFocus(this.markers[idx], after?.icon) } } async updated(changes) { if (!this.map) { await this.readyMap() } if (changes.has('locations')) { this.buildMarkers(this.locations) } if (changes.has('focused')) { this.changeFocus(this.focused, changes.get('focused')) } if (changes.has('center')) { this.map.setCenter(this.center) } if (changes.has('controls')) { // controls가 변경되면 기존 지도의 옵션만 업데이트 if (this.map && this.controls) { // Google Maps API의 setOptions 메서드 사용 this.map.setOptions(this.controls) } } if (changes.has('polygons')) { ;(changes.get('polygons') || []).forEach(geofence => geofence.setMap(null)) ;(this.polygons || []).forEach(geofence => geofence.setMap(this.map)) } if (changes.has('polylines')) { ;(changes.get('polylines') || []).forEach(polyline => polyline.setMap(null)) ;(this.polylines || []).forEach(polyline => polyline.setMap(this.map)) } if (changes.has('markers')) { ;(changes.get('markers') || []).forEach(marker => marker.setMap(null)) ;(this.markers || []).forEach(marker => marker.setMap(this.map)) } if (changes.has('boundCoords')) { this.resetBounds() } // 클러스터링 설정 변경 시 마커 재구성 if (changes.has('clusterZoom')) { this.buildMarkers(this.locations) } } render() { return html`
` } resetBounds() { if (!this.boundCoords || this.boundCoords.length < 1 || !this.map) { return } var bounds = new google.maps.LatLngBounds() this.boundCoords.forEach(coord => bounds.extend(coord)) this.map.fitBounds(bounds) } }