1
- import { useEffect , useMemo , useRef , useState } from "react" ;
2
- import ReactECharts from "echarts-for-react" ;
3
- import 'echarts-extension-gmap' ;
4
- import { findIndex } from "lodash" ;
5
-
6
- const googleMapsApiUrl = "https://maps.googleapis.com/maps/api/js" ;
7
-
8
- function loadGoogleMapsScript ( apiKey : string ) {
9
- const mapsUrl = `${ googleMapsApiUrl } ?key=${ apiKey } ` ;
10
- const scripts = document . getElementsByTagName ( 'script' ) ;
11
- // is script already loaded
12
- let scriptIndex = findIndex ( scripts , ( script ) => script . src . endsWith ( mapsUrl ) ) ;
13
- if ( scriptIndex > - 1 ) {
14
- return scripts [ scriptIndex ] ;
15
- }
16
- // is script loaded with diff api_key, remove the script and load again
17
- scriptIndex = findIndex ( scripts , ( script ) => script . src . startsWith ( googleMapsApiUrl ) ) ;
18
- if ( scriptIndex > - 1 ) {
19
- scripts [ scriptIndex ] . remove ( ) ;
20
- }
1
+ import { useMemo , useState , useCallback } from "react" ;
2
+ import { Map , Marker , Overlay , Bounds } from 'pigeon-maps' ;
3
+ import Supercluster , { PointFeature } from 'supercluster' ;
4
+ import styled from 'styled-components' ;
21
5
22
- const script = document . createElement ( "script" ) ;
23
- script . type = "text/javascript" ;
24
- script . src = mapsUrl ;
25
- script . async = true ;
26
- script . defer = true ;
27
- window . document . body . appendChild ( script ) ;
6
+ function getClusterSize ( count : number ) : number {
7
+ // Logarithmic scaling for better visualization of large numbers
8
+ const minSize = 30 ;
9
+ const maxSize = 60 ;
10
+ const scale = Math . log10 ( count + 1 ) ;
11
+ return Math . min ( maxSize , Math . max ( minSize , minSize + ( scale * 10 ) ) ) ;
12
+ }
28
13
29
- return script ;
14
+ function getClusterColor ( count : number ) : string {
15
+ if ( count > 1000 ) return '#d32f2f' ; // red for very high density
16
+ if ( count > 500 ) return '#f57c00' ; // orange for high density
17
+ if ( count > 100 ) return '#f9a825' ; // yellow for medium density
18
+ return '#1976d2' ; // blue for low density
30
19
}
31
20
32
- interface Props {
33
- data : Array < any > ;
21
+ interface ClusterProperties {
22
+ id : string ;
23
+ count : number ;
24
+ cluster : boolean ;
25
+ point_count_abbreviated ?: string ;
34
26
}
35
27
36
- function getRandomLatLng ( minLat : number , maxLat : number , minLng : number , maxLng : number ) {
37
- const lat = Math . random ( ) * ( maxLat - minLat ) + minLat
38
- const lng = Math . random ( ) * ( maxLng - minLng ) + minLng
39
- return [ lat , lng ]
28
+ interface GeoPoint {
29
+ latitude : number ;
30
+ longitude : number ;
31
+ count : number ;
32
+ id : string ;
40
33
}
41
34
42
- const UserEngagementByRegionChart = ( { data } : Props ) => {
43
- const chartRef = useRef < any > ( null ) ;
44
- const [ mapScriptLoaded , setMapScriptLoaded ] = useState ( false ) ;
35
+ interface TooltipState {
36
+ lat : number ;
37
+ lng : number ;
38
+ text : string ;
39
+ }
45
40
46
- const isMapScriptLoaded = useMemo ( ( ) => {
47
- return mapScriptLoaded || ( window as any ) ?. google ;
48
- } , [ mapScriptLoaded ] )
49
-
50
- const handleOnMapScriptLoad = ( ) => {
51
- setMapScriptLoaded ( true ) ;
41
+ interface Props {
42
+ data : Array < any > ;
43
+ }
44
+
45
+ const ClusterMarker = styled . div < { size : number ; color : string } > `
46
+ background: ${ props => props . color } ;
47
+ width: ${ props => props . size } px;
48
+ height: ${ props => props . size } px;
49
+ border-radius: 50%;
50
+ color: #fff;
51
+ display: flex;
52
+ align-items: center;
53
+ justify-content: center;
54
+ font-weight: bold;
55
+ font-size: ${ props => props . size / 3 } px;
56
+ border: 2px solid white;
57
+ box-shadow: 0 0 6px rgba(0,0,0,0.3);
58
+ cursor: pointer;
59
+ pointer-events: auto;
60
+ opacity: 0.5;
61
+ transition: opacity 0.2s ease;
62
+
63
+ &:hover {
64
+ opacity: 1;
52
65
}
66
+ ` ;
67
+
68
+ const TooltipContainer = styled . div `
69
+ background: white;
70
+ border: 1px solid #ccc;
71
+ padding: 5px 10px;
72
+ border-radius: 4px;
73
+ box-shadow: 0 2px 6px rgba(0,0,0,0.2);
74
+ pointer-events: none;
75
+ transform: translateY(-120px);
76
+ white-space: nowrap;
77
+ ` ;
53
78
54
- useEffect ( ( ) => {
55
- const gMapScript = loadGoogleMapsScript ( '' ) ;
56
- if ( isMapScriptLoaded ) {
57
- handleOnMapScriptLoad ( ) ;
58
- return ;
59
- }
60
- gMapScript . addEventListener ( 'load' , handleOnMapScriptLoad ) ;
61
- return ( ) => {
62
- gMapScript . removeEventListener ( 'load' , handleOnMapScriptLoad ) ;
63
- }
64
- } , [ ] )
79
+ const MapContainer = styled . div `
80
+ height: 400px;
81
+ width: 100%;
82
+ position: relative;
83
+ ` ;
84
+
85
+ const UserEngagementByRegionChart = ( { data } : Props ) => {
86
+ const [ zoom , setZoom ] = useState ( 3 ) ;
87
+ const [ bounds , setBounds ] = useState < Bounds | null > ( null ) ;
88
+ const [ tooltip , setTooltip ] = useState < TooltipState | null > ( null ) ;
65
89
66
90
const geoPoints = useMemo ( ( ) => {
67
91
return data . reduce ( ( acc , log ) => {
68
92
const region = log ?. geolocationDataJsonb ?. city ?. names ?. en || 'Unknown' ; // assuming `region` is added to each event
69
- let regionData = {
93
+ let regionData : GeoPoint = {
70
94
latitude : log ?. geolocationDataJsonb ?. location ?. latitude ?? 55 ,
71
95
longitude : log ?. geolocationDataJsonb ?. location ?. longitude ?? 15 ,
72
96
count : 0 ,
97
+ id : region ,
73
98
} ;
74
99
if ( acc [ region ] ) {
75
100
acc [ region ] = {
@@ -80,63 +105,105 @@ const UserEngagementByRegionChart = ({ data }: Props) => {
80
105
acc [ region ] = regionData ;
81
106
}
82
107
return acc ;
83
- } , { } as Record < string , number > ) ;
108
+ } , { } as Record < string , GeoPoint > ) ;
84
109
} , [ data ] ) ;
85
110
86
- const series = useMemo ( ( ) => {
87
- return [
88
- {
89
- "name" : "Users/Region" ,
90
- "type" : "scatter" ,
91
- "coordinateSystem" : "gmap" ,
92
- "itemStyle" : {
93
- "color" : "#ff00ff"
94
- } ,
95
- "data" : Object . keys ( geoPoints ) . map ( key => ( {
96
- name : key ,
97
- value : [
98
- geoPoints [ key ] . longitude ,
99
- geoPoints [ key ] . latitude ,
100
- geoPoints [ key ] . count ,
101
- ]
102
- } ) ) ,
103
- "symbolSize" : ( val : number [ ] ) => { return 8 + ( ( Math . log ( val [ 2 ] ) - Math . log ( 2 ) ) / ( Math . log ( 40 ) - Math . log ( 2 ) ) ) * ( 40 - 8 ) } ,
104
- "encode" : {
105
- "value" : 2 ,
106
- "lng" : 0 ,
107
- "lat" : 1
108
- }
109
- }
110
- ]
111
+ const cluster = useMemo ( ( ) => {
112
+ const sc = new Supercluster < ClusterProperties > ( {
113
+ radius : 300 ,
114
+ maxZoom : 20 ,
115
+ } ) ;
116
+
117
+ const geojsonPoints : PointFeature < ClusterProperties > [ ] = ( Object . values ( geoPoints ) as GeoPoint [ ] ) . map ( ( { id, latitude, longitude, count } ) => ( {
118
+ type : 'Feature' ,
119
+ properties : { id, count, cluster : true } ,
120
+ geometry : {
121
+ type : 'Point' ,
122
+ coordinates : [ longitude , latitude ] ,
123
+ } ,
124
+ } ) ) ;
125
+
126
+ sc . load ( geojsonPoints ) ;
127
+ return sc ;
111
128
} , [ geoPoints ] ) ;
112
129
130
+ const clusters = useMemo ( ( ) => {
131
+ if ( ! bounds ?. ne || ! bounds ?. sw ) return [ ] ;
132
+
133
+ const westLng = bounds . sw [ 1 ] ;
134
+ const southLat = bounds . sw [ 0 ] ;
135
+ const eastLng = bounds . ne [ 1 ] ;
136
+ const northLat = bounds . ne [ 0 ] ;
137
+
138
+ return cluster . getClusters ( [ westLng , southLat , eastLng , northLat ] , zoom ) ;
139
+ } , [ cluster , bounds , zoom ] ) ;
140
+
141
+ const handleBoundsChanged = useCallback ( ( { zoom, bounds } : { zoom : number ; bounds : Bounds } ) => {
142
+ setZoom ( zoom ) ;
143
+ setBounds ( bounds ) ;
144
+ } , [ ] ) ;
145
+
146
+ const handleMarkerMouseOver = useCallback ( ( lat : number , lng : number , id : string , count : number ) => {
147
+ setTooltip ( { lat, lng, text : `${ id } : ${ count } ` } ) ;
148
+ } , [ ] ) ;
149
+
150
+ const handleMarkerMouseLeave = useCallback ( ( ) => {
151
+ setTooltip ( null ) ;
152
+ } , [ ] ) ;
153
+
113
154
return (
114
- < >
115
- { isMapScriptLoaded && (
116
- < ReactECharts
117
- ref = { chartRef }
118
- option = { {
119
- gmap : {
120
- center : [ 15 , 55 ] ,
121
- zoom : 3 ,
122
- renderOnMoving : true ,
123
- echartsLayerZIndex : 2019 ,
124
- roam : true
125
- } ,
126
- tooltip : {
127
- trigger : "item" ,
128
- formatter : ( params : { data : { name : string ; value : any [ ] ; } ; } ) => {
129
- return `${ params . data . name } : ${ params . data . value [ 2 ] } ` ;
130
- }
131
- } ,
132
- animation : true ,
133
- series : series ,
134
- } }
135
- style = { { height : "400px" } }
136
- />
137
- ) }
138
- </ >
139
- )
140
- }
155
+ < MapContainer >
156
+ < Map
157
+ height = { 400 }
158
+ defaultCenter = { [ 55 , 15 ] }
159
+ defaultZoom = { 5 }
160
+ onBoundsChanged = { handleBoundsChanged }
161
+ >
162
+ { clusters . map ( ( c , i ) => {
163
+ const [ lng , lat ] = c . geometry . coordinates ;
164
+ const isCluster = ! ! c . properties . cluster ;
165
+
166
+ if ( isCluster ) {
167
+ const count = c . properties . count ;
168
+ const size = getClusterSize ( count ) ;
169
+ const color = getClusterColor ( count ) ;
170
+ return (
171
+ < Marker
172
+ key = { `cluster-${ i } ` }
173
+ anchor = { [ lat , lng ] }
174
+ >
175
+ < ClusterMarker
176
+ size = { size }
177
+ color = { color }
178
+ onMouseEnter = { ( ) => handleMarkerMouseOver ( lat , lng , c . properties . id , c . properties . count ) }
179
+ onMouseLeave = { handleMarkerMouseLeave }
180
+ >
181
+ { c . properties . point_count_abbreviated }
182
+ </ ClusterMarker >
183
+ </ Marker >
184
+ ) ;
185
+ }
186
+
187
+ return (
188
+ < Marker
189
+ key = { `marker-${ i } ` }
190
+ anchor = { [ lat , lng ] }
191
+ onMouseOver = { ( ) => handleMarkerMouseOver ( lat , lng , c . properties . id , c . properties . count ) }
192
+ onMouseOut = { handleMarkerMouseLeave }
193
+ />
194
+ ) ;
195
+ } ) }
196
+
197
+ { tooltip && (
198
+ < Overlay anchor = { [ tooltip . lat , tooltip . lng ] } offset = { [ 0 , - 40 ] } >
199
+ < TooltipContainer >
200
+ { tooltip . text }
201
+ </ TooltipContainer >
202
+ </ Overlay >
203
+ ) }
204
+ </ Map >
205
+ </ MapContainer >
206
+ ) ;
207
+ } ;
141
208
142
209
export default UserEngagementByRegionChart ;
0 commit comments