@things-factory/kpi 9.0.29 → 9.0.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/charts/kpi-mini-trend-chart.ts +125 -0
- package/client/charts/kpi-trend-chart.ts +163 -0
- package/client/google-map/common-google-map.ts +370 -0
- package/client/google-map/google-map-loader.ts +29 -0
- package/client/pages/kpi-dashboard/cards/kpi-level1-card.ts +248 -0
- package/client/pages/kpi-dashboard/cards/kpi-level2-comparison.ts +369 -0
- package/client/pages/kpi-dashboard/cards/kpi-level3-comparison.ts +443 -0
- package/client/pages/kpi-dashboard/components/kpi-chart-toggle.ts +72 -0
- package/client/pages/kpi-dashboard/components/kpi-left-panel.ts +399 -0
- package/client/pages/kpi-dashboard/components/kpi-map-panel.ts +302 -0
- package/client/pages/kpi-dashboard/components/kpi-region-popup.ts +355 -0
- package/client/pages/kpi-dashboard/kpi-dashboard-map.ts +243 -0
- package/client/pages/kpi-dashboard/kpi-dashboard.ts +416 -0
- package/client/route.ts +4 -0
- package/dist-client/charts/kpi-mini-trend-chart.d.ts +14 -0
- package/dist-client/charts/kpi-mini-trend-chart.js +148 -0
- package/dist-client/charts/kpi-mini-trend-chart.js.map +1 -0
- package/dist-client/charts/kpi-trend-chart.d.ts +25 -0
- package/dist-client/charts/kpi-trend-chart.js +186 -0
- package/dist-client/charts/kpi-trend-chart.js.map +1 -0
- package/dist-client/google-map/common-google-map.d.ts +34 -0
- package/dist-client/google-map/common-google-map.js +333 -0
- package/dist-client/google-map/common-google-map.js.map +1 -0
- package/dist-client/google-map/google-map-loader.d.ts +6 -0
- package/dist-client/google-map/google-map-loader.js +22 -0
- package/dist-client/google-map/google-map-loader.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/cards/kpi-level1-card.d.ts +17 -0
- package/dist-client/pages/kpi-dashboard/cards/kpi-level1-card.js +279 -0
- package/dist-client/pages/kpi-dashboard/cards/kpi-level1-card.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/cards/kpi-level2-comparison.d.ts +19 -0
- package/dist-client/pages/kpi-dashboard/cards/kpi-level2-comparison.js +385 -0
- package/dist-client/pages/kpi-dashboard/cards/kpi-level2-comparison.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/cards/kpi-level3-comparison.d.ts +23 -0
- package/dist-client/pages/kpi-dashboard/cards/kpi-level3-comparison.js +465 -0
- package/dist-client/pages/kpi-dashboard/cards/kpi-level3-comparison.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-chart-toggle.d.ts +8 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-chart-toggle.js +78 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-chart-toggle.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.d.ts +22 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js +404 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.d.ts +28 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.js +298 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-map-panel.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.d.ts +23 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js +368 -0
- package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.d.ts +29 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js +271 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js.map +1 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard.d.ts +21 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard.js +398 -0
- package/dist-client/pages/kpi-dashboard/kpi-dashboard.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/route.js +3 -0
- package/dist-client/route.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/index.d.ts +1 -0
- package/dist-server/index.js +1 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/migrations/index.d.ts +1 -0
- package/dist-server/migrations/index.js +12 -0
- package/dist-server/migrations/index.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/server/index.ts +1 -0
- package/server/migrations/index.ts +9 -0
- package/things-factory.config.js +2 -1
- package/translations/en.json +1 -0
- package/translations/ja.json +1 -0
- package/translations/ko.json +1 -0
- package/translations/ms.json +1 -0
- package/translations/zh.json +1 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { LitElement, html, css } from 'lit'
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js'
|
|
3
|
+
import * as d3 from 'd3'
|
|
4
|
+
|
|
5
|
+
@customElement('kpi-mini-trend-chart')
|
|
6
|
+
export class KpiMiniTrendChart extends LitElement {
|
|
7
|
+
@property({ type: Array }) data: number[] = []
|
|
8
|
+
@property({ type: Number }) width: number = 60
|
|
9
|
+
@property({ type: Number }) height: number = 30
|
|
10
|
+
@property({ type: String }) lineColor: string = '#2196f3'
|
|
11
|
+
@property({ type: Number }) strokeWidth: number = 1.5
|
|
12
|
+
@property({ type: Boolean }) showPoints: boolean = true
|
|
13
|
+
@property({ type: Number }) pointRadius: number = 1.5
|
|
14
|
+
|
|
15
|
+
static styles = css`
|
|
16
|
+
:host {
|
|
17
|
+
display: block;
|
|
18
|
+
width: 100%;
|
|
19
|
+
height: 100%;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.mini-chart {
|
|
23
|
+
width: 100%;
|
|
24
|
+
height: 100%;
|
|
25
|
+
background: #f8f9fa;
|
|
26
|
+
border-radius: 4px;
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
justify-content: center;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.trend-line {
|
|
33
|
+
fill: none;
|
|
34
|
+
stroke-linecap: round;
|
|
35
|
+
stroke-linejoin: round;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.data-point {
|
|
39
|
+
fill: white;
|
|
40
|
+
stroke-width: 1;
|
|
41
|
+
}
|
|
42
|
+
`
|
|
43
|
+
|
|
44
|
+
render() {
|
|
45
|
+
return html`
|
|
46
|
+
<div class="mini-chart">
|
|
47
|
+
<svg
|
|
48
|
+
id="mini-trend"
|
|
49
|
+
width=${this.width}
|
|
50
|
+
height=${this.height}
|
|
51
|
+
viewBox="0 0 ${this.width} ${this.height}"
|
|
52
|
+
preserveAspectRatio="xMidYMid meet"
|
|
53
|
+
></svg>
|
|
54
|
+
</div>
|
|
55
|
+
`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
updated() {
|
|
59
|
+
this.drawMiniTrend()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
drawMiniTrend() {
|
|
63
|
+
if (!this.data || this.data.length === 0) return
|
|
64
|
+
|
|
65
|
+
const svg = d3.select(this.renderRoot.querySelector('#mini-trend'))
|
|
66
|
+
svg.selectAll('*').remove()
|
|
67
|
+
|
|
68
|
+
const margin = { top: 2, right: 2, bottom: 2, left: 2 }
|
|
69
|
+
const width = this.width - margin.left - margin.right
|
|
70
|
+
const height = this.height - margin.top - margin.bottom
|
|
71
|
+
|
|
72
|
+
// 스케일 설정
|
|
73
|
+
const xScale = d3
|
|
74
|
+
.scaleLinear()
|
|
75
|
+
.domain([0, this.data.length - 1])
|
|
76
|
+
.range([0, width])
|
|
77
|
+
|
|
78
|
+
const yScale = d3
|
|
79
|
+
.scaleLinear()
|
|
80
|
+
.domain([0, d3.max(this.data) || 100])
|
|
81
|
+
.range([height, 0])
|
|
82
|
+
|
|
83
|
+
// SVG 설정
|
|
84
|
+
svg.attr('width', this.width).attr('height', this.height)
|
|
85
|
+
const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`)
|
|
86
|
+
|
|
87
|
+
// 라인 생성기
|
|
88
|
+
const line = d3
|
|
89
|
+
.line<number>()
|
|
90
|
+
.x((d, i) => xScale(i))
|
|
91
|
+
.y(d => yScale(d))
|
|
92
|
+
.curve(d3.curveMonotoneX)
|
|
93
|
+
|
|
94
|
+
// 트렌드 라인 그리기
|
|
95
|
+
g.append('path')
|
|
96
|
+
.datum(this.data)
|
|
97
|
+
.attr('class', 'trend-line')
|
|
98
|
+
.attr('d', line as any)
|
|
99
|
+
.attr('stroke', this.lineColor)
|
|
100
|
+
.attr('stroke-width', this.strokeWidth)
|
|
101
|
+
|
|
102
|
+
// 데이터 포인트 그리기 (첫 번째와 마지막 포인트만)
|
|
103
|
+
if (this.showPoints && this.data.length > 0) {
|
|
104
|
+
// 첫 번째 포인트
|
|
105
|
+
g.append('circle')
|
|
106
|
+
.attr('class', 'data-point')
|
|
107
|
+
.attr('cx', xScale(0))
|
|
108
|
+
.attr('cy', yScale(this.data[0]))
|
|
109
|
+
.attr('r', this.pointRadius)
|
|
110
|
+
.attr('stroke', '#4caf50')
|
|
111
|
+
.attr('fill', 'white')
|
|
112
|
+
|
|
113
|
+
// 마지막 포인트
|
|
114
|
+
if (this.data.length > 1) {
|
|
115
|
+
g.append('circle')
|
|
116
|
+
.attr('class', 'data-point')
|
|
117
|
+
.attr('cx', xScale(this.data.length - 1))
|
|
118
|
+
.attr('cy', yScale(this.data[this.data.length - 1]))
|
|
119
|
+
.attr('r', this.pointRadius)
|
|
120
|
+
.attr('stroke', '#2196f3')
|
|
121
|
+
.attr('fill', 'white')
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { LitElement, html, css } from 'lit'
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js'
|
|
3
|
+
import * as d3 from 'd3'
|
|
4
|
+
|
|
5
|
+
@customElement('kpi-trend-chart')
|
|
6
|
+
export class KpiTrendChart extends LitElement {
|
|
7
|
+
@property({ type: Array }) data: { date: string; value: number; color?: string }[] = []
|
|
8
|
+
@property({ type: String }) valueKey: string = 'value'
|
|
9
|
+
@property({ type: String }) dateKey: string = 'date'
|
|
10
|
+
@property({ type: Number }) width: number = 400
|
|
11
|
+
@property({ type: Number }) height: number = 200
|
|
12
|
+
@property({ type: String }) lineColor: string = '#2196f3'
|
|
13
|
+
@property({ type: Number }) strokeWidth: number = 2
|
|
14
|
+
@property({ type: Boolean }) showPoints: boolean = true
|
|
15
|
+
@property({ type: Number }) pointRadius: number = 4
|
|
16
|
+
|
|
17
|
+
private chartWidth = 0
|
|
18
|
+
private chartHeight = 0
|
|
19
|
+
private resizeObserver?: ResizeObserver
|
|
20
|
+
|
|
21
|
+
static styles = css`
|
|
22
|
+
:host {
|
|
23
|
+
display: block;
|
|
24
|
+
width: 100%;
|
|
25
|
+
height: 100%;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.chart-container {
|
|
29
|
+
width: 100%;
|
|
30
|
+
height: 100%;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.trend-line {
|
|
34
|
+
fill: none;
|
|
35
|
+
stroke-linecap: round;
|
|
36
|
+
stroke-linejoin: round;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.data-point {
|
|
40
|
+
fill: white;
|
|
41
|
+
stroke-width: 2;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.axis line,
|
|
45
|
+
.axis path {
|
|
46
|
+
stroke: #ddd;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.axis text {
|
|
50
|
+
font-size: 10px;
|
|
51
|
+
fill: #666;
|
|
52
|
+
}
|
|
53
|
+
`
|
|
54
|
+
|
|
55
|
+
render() {
|
|
56
|
+
return html`
|
|
57
|
+
<div class="chart-container">
|
|
58
|
+
<svg
|
|
59
|
+
id="trend-chart"
|
|
60
|
+
width=${this.chartWidth || this.width}
|
|
61
|
+
height=${this.chartHeight || this.height}
|
|
62
|
+
viewBox="0 0 ${this.chartWidth || this.width} ${this.chartHeight || this.height}"
|
|
63
|
+
preserveAspectRatio="xMidYMid meet"
|
|
64
|
+
></svg>
|
|
65
|
+
</div>
|
|
66
|
+
`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
connectedCallback() {
|
|
70
|
+
super.connectedCallback()
|
|
71
|
+
this.resizeObserver = new ResizeObserver(entries => {
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
const rect = entry.contentRect
|
|
74
|
+
this.chartWidth = rect.width
|
|
75
|
+
this.chartHeight = rect.height
|
|
76
|
+
this.requestUpdate()
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
this.resizeObserver.observe(this)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
disconnectedCallback() {
|
|
83
|
+
this.resizeObserver?.disconnect()
|
|
84
|
+
super.disconnectedCallback()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
updated() {
|
|
88
|
+
this.drawTrendChart()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
drawTrendChart() {
|
|
92
|
+
if (!this.data || this.data.length === 0) return
|
|
93
|
+
|
|
94
|
+
const svg = d3.select(this.renderRoot.querySelector('#trend-chart'))
|
|
95
|
+
svg.selectAll('*').remove()
|
|
96
|
+
|
|
97
|
+
const margin = { top: 20, right: 20, bottom: 30, left: 40 }
|
|
98
|
+
const width = this.chartWidth || this.width
|
|
99
|
+
const height = this.chartHeight || this.height
|
|
100
|
+
const chartWidth = width - margin.left - margin.right
|
|
101
|
+
const chartHeight = height - margin.top - margin.bottom
|
|
102
|
+
|
|
103
|
+
// 데이터 파싱
|
|
104
|
+
const parsedData = this.data.map(d => ({
|
|
105
|
+
date: new Date(d[this.dateKey]),
|
|
106
|
+
value: +d[this.valueKey],
|
|
107
|
+
color: d.color || this.lineColor
|
|
108
|
+
}))
|
|
109
|
+
|
|
110
|
+
// 스케일 설정
|
|
111
|
+
const xScale = d3
|
|
112
|
+
.scaleTime()
|
|
113
|
+
.domain(d3.extent(parsedData, d => d.date) as [Date, Date])
|
|
114
|
+
.range([0, chartWidth])
|
|
115
|
+
|
|
116
|
+
const yScale = d3
|
|
117
|
+
.scaleLinear()
|
|
118
|
+
.domain([0, d3.max(parsedData, d => d.value) || 100])
|
|
119
|
+
.range([chartHeight, 0])
|
|
120
|
+
|
|
121
|
+
// SVG 설정
|
|
122
|
+
svg.attr('width', width).attr('height', height)
|
|
123
|
+
const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`)
|
|
124
|
+
|
|
125
|
+
// 축 생성
|
|
126
|
+
const xAxis = d3.axisBottom(xScale).tickFormat(d3.timeFormat('%m/%d')).ticks(5)
|
|
127
|
+
|
|
128
|
+
const yAxis = d3.axisLeft(yScale).ticks(5)
|
|
129
|
+
|
|
130
|
+
g.append('g').attr('class', 'axis').attr('transform', `translate(0,${chartHeight})`).call(xAxis)
|
|
131
|
+
|
|
132
|
+
g.append('g').attr('class', 'axis').call(yAxis)
|
|
133
|
+
|
|
134
|
+
// 라인 생성기
|
|
135
|
+
const line = d3
|
|
136
|
+
.line<{ date: Date; value: number; color: string }>()
|
|
137
|
+
.x(d => xScale(d.date))
|
|
138
|
+
.y(d => yScale(d.value))
|
|
139
|
+
.curve(d3.curveMonotoneX)
|
|
140
|
+
|
|
141
|
+
// 트렌드 라인 그리기
|
|
142
|
+
g.append('path')
|
|
143
|
+
.datum(parsedData)
|
|
144
|
+
.attr('class', 'trend-line')
|
|
145
|
+
.attr('d', line as any)
|
|
146
|
+
.attr('stroke', this.lineColor)
|
|
147
|
+
.attr('stroke-width', this.strokeWidth)
|
|
148
|
+
|
|
149
|
+
// 데이터 포인트 그리기
|
|
150
|
+
if (this.showPoints) {
|
|
151
|
+
g.selectAll('.data-point')
|
|
152
|
+
.data(parsedData)
|
|
153
|
+
.enter()
|
|
154
|
+
.append('circle')
|
|
155
|
+
.attr('class', 'data-point')
|
|
156
|
+
.attr('cx', d => xScale(d.date))
|
|
157
|
+
.attr('cy', d => yScale(d.value))
|
|
158
|
+
.attr('r', this.pointRadius)
|
|
159
|
+
.attr('stroke', d => d.color)
|
|
160
|
+
.attr('fill', 'white')
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { LitElement, html, css } from 'lit'
|
|
2
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
3
|
+
import { ScrollbarStyles } from '@operato/styles'
|
|
4
|
+
|
|
5
|
+
import GoogleMapLoader from './google-map-loader.js'
|
|
6
|
+
|
|
7
|
+
declare global {
|
|
8
|
+
interface Window {
|
|
9
|
+
google: any
|
|
10
|
+
markerClusterer?: any
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
declare const google: any
|
|
15
|
+
|
|
16
|
+
declare namespace google.maps {
|
|
17
|
+
interface MapsLibrary {
|
|
18
|
+
Map: any
|
|
19
|
+
InfoWindow: any
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface MarkerLibrary {
|
|
23
|
+
AdvancedMarkerElement: any
|
|
24
|
+
PinElement: any
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@customElement('common-google-map')
|
|
29
|
+
export class CommonGoogleMap extends LitElement {
|
|
30
|
+
static styles = [
|
|
31
|
+
ScrollbarStyles,
|
|
32
|
+
css`
|
|
33
|
+
:host {
|
|
34
|
+
display: flex;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
[map] {
|
|
38
|
+
flex: 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.gm-style .gm-style-iw-c {
|
|
42
|
+
padding: 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.gm-style .gm-style-iw-d {
|
|
46
|
+
overflow: auto !important;
|
|
47
|
+
}
|
|
48
|
+
.gm-style .gm-style-iw-d + button {
|
|
49
|
+
top: 0 !important;
|
|
50
|
+
right: 0 !important;
|
|
51
|
+
}
|
|
52
|
+
`
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
@property({ type: Object }) center
|
|
56
|
+
@property({ type: Number }) zoom
|
|
57
|
+
@property({ type: Array }) locations: any[] = []
|
|
58
|
+
@property({ type: Object }) focused
|
|
59
|
+
@property({ type: Array }) polygons
|
|
60
|
+
@property({ type: Array }) polylines
|
|
61
|
+
@property({ type: Array }) markers
|
|
62
|
+
@property({ type: Array }) boundCoords
|
|
63
|
+
@property({ type: Object }) controls
|
|
64
|
+
@property({ type: Number }) clusterZoom = 10
|
|
65
|
+
|
|
66
|
+
@state() map: any = null
|
|
67
|
+
@state() defaultCenter: any = { lat: 36.5, lng: 127.5 }
|
|
68
|
+
private _infoWindow: any = null
|
|
69
|
+
private _markerClusterer: any = null
|
|
70
|
+
|
|
71
|
+
get anchor() {
|
|
72
|
+
return this.renderRoot.querySelector('[map]')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async readyMap() {
|
|
76
|
+
await GoogleMapLoader.load()
|
|
77
|
+
|
|
78
|
+
// MarkerClusterer 라이브러리 로드
|
|
79
|
+
if (!window.markerClusterer) {
|
|
80
|
+
await GoogleMapLoader.loadMarkerClusterer()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (this.map) {
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// DOM이 준비될 때까지 기다림
|
|
88
|
+
await this.updateComplete
|
|
89
|
+
|
|
90
|
+
// anchor가 준비될 때까지 기다림
|
|
91
|
+
let attempts = 0
|
|
92
|
+
const maxAttempts = 20
|
|
93
|
+
while (attempts < maxAttempts) {
|
|
94
|
+
const anchor = this.anchor as HTMLElement
|
|
95
|
+
if (anchor && anchor.offsetWidth > 0) {
|
|
96
|
+
break
|
|
97
|
+
}
|
|
98
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
99
|
+
attempts++
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!this.anchor) {
|
|
103
|
+
console.error('Map anchor element not found')
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
var show = async (center, zoom) => {
|
|
108
|
+
try {
|
|
109
|
+
// Google Maps 최신 API 사용
|
|
110
|
+
const { Map } = (await google.maps.importLibrary('maps')) as google.maps.MapsLibrary
|
|
111
|
+
|
|
112
|
+
const mapOptions = {
|
|
113
|
+
zoom,
|
|
114
|
+
center,
|
|
115
|
+
mapId: 'DEMO_MAP_ID'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// controls 속성이 있으면 지도 옵션에 추가
|
|
119
|
+
if (this.controls) {
|
|
120
|
+
Object.assign(mapOptions, this.controls)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const map = new Map(this.anchor, mapOptions)
|
|
124
|
+
|
|
125
|
+
this.markers && this.markers.forEach(marker => marker.setMap(map))
|
|
126
|
+
|
|
127
|
+
this.map = map
|
|
128
|
+
|
|
129
|
+
this.dispatchEvent(
|
|
130
|
+
new CustomEvent('map-change', {
|
|
131
|
+
detail: this.map
|
|
132
|
+
})
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
this.resetBounds()
|
|
136
|
+
} catch (e) {
|
|
137
|
+
console.error(e)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
var { center, zoom = 10 } = this
|
|
142
|
+
|
|
143
|
+
/* center 속성이 설정되어있지 않으면, 현재 위치를 구해서 지도의 center로 설정한다. */
|
|
144
|
+
if (!center && 'geolocation' in navigator && !this.boundCoords?.length) {
|
|
145
|
+
navigator.geolocation.getCurrentPosition(
|
|
146
|
+
({ coords: { latitude: lat, longitude: lng } }) => show({ lat, lng }, zoom),
|
|
147
|
+
err => {
|
|
148
|
+
console.warn(`navigator.geolocation.getCurrentPosition failed. (${err.code}): ${err.message}`)
|
|
149
|
+
show(this.defaultCenter, zoom)
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
/* https://stackoverflow.com/questions/3397585/navigator-geolocation-getcurrentposition-sometimes-works-sometimes-doesnt */
|
|
153
|
+
timeout: 500
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
} else {
|
|
157
|
+
show(center, zoom)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async buildMarkers(locations: any[] = []) {
|
|
162
|
+
if (!this.map) {
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (this.markers) {
|
|
167
|
+
this.markers.forEach(marker => marker.setMap(null))
|
|
168
|
+
this.markers = []
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 기존 클러스터 제거
|
|
172
|
+
if (this._markerClusterer) {
|
|
173
|
+
this._markerClusterer.clearMarkers()
|
|
174
|
+
this._markerClusterer = null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Google Maps 최신 API 사용
|
|
178
|
+
const { AdvancedMarkerElement, PinElement } = (await google.maps.importLibrary(
|
|
179
|
+
'marker'
|
|
180
|
+
)) as google.maps.MarkerLibrary
|
|
181
|
+
|
|
182
|
+
this.markers = locations
|
|
183
|
+
.map(location => {
|
|
184
|
+
// location 객체가 유효한지 확인
|
|
185
|
+
if (!location || typeof location !== 'object') {
|
|
186
|
+
console.warn('Invalid location object:', location)
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// lat, lng 값이 유효한지 확인
|
|
191
|
+
const lat = parseFloat(location.lat)
|
|
192
|
+
const lng = parseFloat(location.lng)
|
|
193
|
+
|
|
194
|
+
if (isNaN(lat) || isNaN(lng)) {
|
|
195
|
+
console.warn('Invalid lat/lng values:', location)
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// LatLng 객체 생성
|
|
200
|
+
const position = new google.maps.LatLng(lat, lng)
|
|
201
|
+
|
|
202
|
+
// 커스텀 마커 콘텐츠가 있으면 사용
|
|
203
|
+
let markerElement
|
|
204
|
+
if (location.markerContent) {
|
|
205
|
+
// HTML 문자열을 DOM 요소로 변환
|
|
206
|
+
const tempDiv = document.createElement('div')
|
|
207
|
+
tempDiv.innerHTML = location.markerContent
|
|
208
|
+
markerElement = tempDiv.firstElementChild
|
|
209
|
+
} else {
|
|
210
|
+
// 기본 핀 사용
|
|
211
|
+
markerElement = new PinElement({
|
|
212
|
+
background: '#1976d2',
|
|
213
|
+
borderColor: '#1565c0',
|
|
214
|
+
glyphColor: '#ffffff',
|
|
215
|
+
scale: 1.2
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// AdvancedMarkerElement 사용
|
|
220
|
+
const marker = new AdvancedMarkerElement({
|
|
221
|
+
position: position,
|
|
222
|
+
map: null, // 클러스터에서 관리하므로 지도에 직접 추가하지 않음
|
|
223
|
+
content: markerElement
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
marker.addListener('click', () => {
|
|
227
|
+
// InfoWindow 대신 커스텀 이벤트 발생
|
|
228
|
+
if (location?.region) {
|
|
229
|
+
this.dispatchEvent(
|
|
230
|
+
new CustomEvent('region-click', {
|
|
231
|
+
detail: { region: location.region },
|
|
232
|
+
bubbles: true,
|
|
233
|
+
composed: true
|
|
234
|
+
})
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
return marker
|
|
240
|
+
})
|
|
241
|
+
.filter(marker => marker !== null) // null 마커 제거
|
|
242
|
+
|
|
243
|
+
// Google Maps 공식 MarkerClusterer 사용 (예시와 동일한 방식)
|
|
244
|
+
if (this.markers.length > 0 && window.markerClusterer) {
|
|
245
|
+
this._markerClusterer = new window.markerClusterer.MarkerClusterer({
|
|
246
|
+
markers: this.markers,
|
|
247
|
+
map: this.map
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
get infoWindow() {
|
|
253
|
+
if (!this._infoWindow && this.map) {
|
|
254
|
+
this._infoWindow = new google.maps.InfoWindow({
|
|
255
|
+
content: 'loading...'
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return this._infoWindow
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
setFocus(focus, icon) {
|
|
263
|
+
focus.setZIndex(1)
|
|
264
|
+
focus.setIcon(icon)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
resetFocus(focus, icon) {
|
|
268
|
+
focus.setZIndex(0)
|
|
269
|
+
focus.setIcon(icon)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async changeFocus(after, before) {
|
|
273
|
+
await this.readyMap()
|
|
274
|
+
|
|
275
|
+
// map이 준비되지 않았으면 포커스 변경하지 않음
|
|
276
|
+
if (!this.map) {
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
var locations = this.locations || []
|
|
281
|
+
|
|
282
|
+
if (before) {
|
|
283
|
+
var idx = locations.findIndex(location => {
|
|
284
|
+
// location 객체의 구조를 안전하게 확인
|
|
285
|
+
const beforePos = before?.position
|
|
286
|
+
const locationPos = location?.position
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
location?.name == before?.name && locationPos?.lat == beforePos?.lat && locationPos?.lng == beforePos?.lng
|
|
290
|
+
)
|
|
291
|
+
})
|
|
292
|
+
idx !== -1 && this.markers && this.resetFocus(this.markers[idx], locations[idx]?.icon)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (after) {
|
|
296
|
+
var idx = locations.findIndex(location => {
|
|
297
|
+
// location 객체의 구조를 안전하게 확인
|
|
298
|
+
const afterPos = after?.position
|
|
299
|
+
const locationPos = location?.position
|
|
300
|
+
|
|
301
|
+
return location?.name == after?.name && locationPos?.lat == afterPos?.lat && locationPos?.lng == afterPos?.lng
|
|
302
|
+
})
|
|
303
|
+
idx !== -1 && this.markers && this.setFocus(this.markers[idx], after?.icon)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async updated(changes) {
|
|
308
|
+
if (!this.map) {
|
|
309
|
+
await this.readyMap()
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (changes.has('locations')) {
|
|
313
|
+
this.buildMarkers(this.locations)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (changes.has('focused')) {
|
|
317
|
+
this.changeFocus(this.focused, changes.get('focused'))
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (changes.has('center')) {
|
|
321
|
+
this.map.setCenter(this.center)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (changes.has('controls')) {
|
|
325
|
+
// controls가 변경되면 기존 지도의 옵션만 업데이트
|
|
326
|
+
if (this.map && this.controls) {
|
|
327
|
+
// Google Maps API의 setOptions 메서드 사용
|
|
328
|
+
this.map.setOptions(this.controls)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (changes.has('polygons')) {
|
|
333
|
+
;(changes.get('polygons') || []).forEach(geofence => geofence.setMap(null))
|
|
334
|
+
;(this.polygons || []).forEach(geofence => geofence.setMap(this.map))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (changes.has('polylines')) {
|
|
338
|
+
;(changes.get('polylines') || []).forEach(polyline => polyline.setMap(null))
|
|
339
|
+
;(this.polylines || []).forEach(polyline => polyline.setMap(this.map))
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (changes.has('markers')) {
|
|
343
|
+
;(changes.get('markers') || []).forEach(marker => marker.setMap(null))
|
|
344
|
+
;(this.markers || []).forEach(marker => marker.setMap(this.map))
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (changes.has('boundCoords')) {
|
|
348
|
+
this.resetBounds()
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// 클러스터링 설정 변경 시 마커 재구성
|
|
352
|
+
if (changes.has('clusterZoom')) {
|
|
353
|
+
this.buildMarkers(this.locations)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
render() {
|
|
358
|
+
return html` <div map></div> `
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
resetBounds() {
|
|
362
|
+
if (!this.boundCoords || this.boundCoords.length < 1 || !this.map) {
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
var bounds = new google.maps.LatLngBounds()
|
|
367
|
+
this.boundCoords.forEach(coord => bounds.extend(coord))
|
|
368
|
+
this.map.fitBounds(bounds)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import ScriptLoader from '@operato/utils/script-loader.js'
|
|
2
|
+
|
|
3
|
+
export default class GoogleMapLoader {
|
|
4
|
+
static loaded = false
|
|
5
|
+
static markerClustererLoaded = false
|
|
6
|
+
|
|
7
|
+
static async load() {
|
|
8
|
+
if (GoogleMapLoader.loaded) {
|
|
9
|
+
return
|
|
10
|
+
}
|
|
11
|
+
var key = 'AIzaSyBgQZb-SFqjQBC_XTxNiz0XapejNwV9PgA'
|
|
12
|
+
|
|
13
|
+
await ScriptLoader.load(
|
|
14
|
+
'https://maps.googleapis.com/maps/api/js' + (key ? '?key=' + key : '') + '&libraries=places'
|
|
15
|
+
)
|
|
16
|
+
GoogleMapLoader.loaded = true
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static async loadMarkerClusterer() {
|
|
20
|
+
if (GoogleMapLoader.markerClustererLoaded) {
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Google Maps 공식 MarkerClusterer 라이브러리 로드
|
|
25
|
+
await ScriptLoader.load('https://unpkg.com/@googlemaps/markerclusterer/dist/index.min.js')
|
|
26
|
+
|
|
27
|
+
GoogleMapLoader.markerClustererLoaded = true
|
|
28
|
+
}
|
|
29
|
+
}
|