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' ;
215
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+ }
2813
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
3019}
3120
32- interface Props {
33- data : Array < any > ;
21+ interface ClusterProperties {
22+ id : string ;
23+ count : number ;
24+ cluster : boolean ;
25+ point_count_abbreviated ?: string ;
3426}
3527
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 ;
4033}
4134
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+ }
4540
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;
5265 }
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+ ` ;
5378
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 ) ;
6589
6690 const geoPoints = useMemo ( ( ) => {
6791 return data . reduce ( ( acc , log ) => {
6892 const region = log ?. geolocationDataJsonb ?. city ?. names ?. en || 'Unknown' ; // assuming `region` is added to each event
69- let regionData = {
93+ let regionData : GeoPoint = {
7094 latitude : log ?. geolocationDataJsonb ?. location ?. latitude ?? 55 ,
7195 longitude : log ?. geolocationDataJsonb ?. location ?. longitude ?? 15 ,
7296 count : 0 ,
97+ id : region ,
7398 } ;
7499 if ( acc [ region ] ) {
75100 acc [ region ] = {
@@ -80,63 +105,105 @@ const UserEngagementByRegionChart = ({ data }: Props) => {
80105 acc [ region ] = regionData ;
81106 }
82107 return acc ;
83- } , { } as Record < string , number > ) ;
108+ } , { } as Record < string , GeoPoint > ) ;
84109 } , [ data ] ) ;
85110
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 ;
111128 } , [ geoPoints ] ) ;
112129
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+
113154 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+ } ;
141208
142209export default UserEngagementByRegionChart ;
0 commit comments