33from xml .sax .saxutils import escape
44from typing import List , NamedTuple , Optional , Union , Callable
55from math import floor , log10
6+ from functools import reduce
67
78from AnyQt .QtCore import Qt , QObject , QSize , QRectF , pyqtSignal as Signal , \
89 QPointF
5859_ChoroplethRegion = NamedTuple (
5960 "_ChoroplethRegion" , [
6061 ("id" , str ),
61- ("qpoly" , QPolygonF ),
62- ("agg_value" , float ),
62+ ("qpolys" , List [QPolygonF ]),
6363 ("info" , dict ),
6464 ]
6565)
@@ -116,26 +116,33 @@ def paint(self, p, *args):
116116
117117class ChoroplethItem (pg .GraphicsObject ):
118118 """
119- GraphicsObject that is a polygon that represents part of region.
120- Regions can consist of multiple disjoint polygons so they are represented
121- with multiple ChoroplethItem.
119+ GraphicsObject that represents regions.
120+ Regions can consist of multiple disjoint polygons.
122121 """
123122
124123 itemClicked = Signal (str ) # send region id
125124
126125 def __init__ (self , region : _ChoroplethRegion , pen : QPen , brush : QBrush ):
127126 pg .GraphicsObject .__init__ (self )
128127 self .region = region
128+ self .agg_value = None
129129 self .pen = pen
130130 self .brush = brush
131- self .tooltip_text = self ._tooltip (self .region )
131+
132+ self ._region_info = self ._get_region_info (self .region )
133+ self ._bounding_rect = reduce (
134+ lambda br1 , br2 : br1 .united (br2 ),
135+ (qpoly .boundingRect () for qpoly in self .region .qpolys )
136+ )
132137
133138 @staticmethod
134- def _tooltip (region : _ChoroplethRegion ):
135- agg_text = f"<b>Agg. value = { region .agg_value } </b><hr/>"
139+ def _get_region_info (region : _ChoroplethRegion ):
136140 region_text = "<br/>" .join (escape ('{} = {}' .format (k , v ))
137141 for k , v in region .info .items ())
138- return agg_text + "<b>Region info:</b><br/>" + region_text
142+ return "<b>Region info:</b><br/>" + region_text
143+
144+ def tooltip (self ):
145+ return f"<b>Agg. value = { self .agg_value } </b><hr/>{ self ._region_info } "
139146
140147 def setPen (self , pen ):
141148 self .pen = pen
@@ -148,16 +155,19 @@ def setBrush(self, brush):
148155 def paint (self , p : QPainter , * args ):
149156 p .setBrush (self .brush )
150157 p .setPen (self .pen )
151- p .drawPolygon (self .region .qpoly )
158+ for qpoly in self .region .qpolys :
159+ p .drawPolygon (qpoly )
152160
153161 def boundingRect (self ) -> QRectF :
154- return self .region . qpoly . boundingRect ()
162+ return self ._bounding_rect
155163
156164 def contains (self , point : QPointF ) -> bool :
157- return self .region .qpoly .containsPoint (point , Qt .OddEvenFill )
165+ return any (qpoly .containsPoint (point , Qt .OddEvenFill )
166+ for qpoly in self .region .qpolys )
158167
159168 def intersects (self , poly : QPolygonF ) -> bool :
160- return not self .region .qpoly .intersected (poly ).isEmpty ()
169+ return any (not qpoly .intersected (poly ).isEmpty ()
170+ for qpoly in self .region .qpolys )
161171
162172 def mouseClickEvent (self , ev ):
163173 if ev .button () == Qt .LeftButton and self .contains (ev .pos ()):
@@ -263,7 +273,8 @@ def update_choropleth(self):
263273 """Draw new polygons."""
264274 pen = self ._make_pen (QColor (Qt .white ), 1 )
265275 brush = QBrush (Qt .NoBrush )
266- for region in self .master .get_choropleth_regions ():
276+ regions = self .master .get_choropleth_regions ()
277+ for region in regions :
267278 choropleth_item = ChoroplethItem (region , pen = pen , brush = brush )
268279 choropleth_item .itemClicked .connect (self .select_by_id )
269280 self .plot_widget .addItem (choropleth_item )
@@ -273,15 +284,15 @@ def update_choropleth(self):
273284 self .n_ids = len (self .master .region_ids )
274285
275286 def update_colors (self ):
276- """Update inner color of existing polygons."""
287+ """Update agg_value and inner color of existing polygons."""
277288 if not self .choropleth_items :
278289 return
279290
291+ agg_data = self .master .get_agg_data ()
280292 brushes = self .get_colors ()
281- rid2brush = {rid : b
282- for rid , b in zip (self .master .region_ids , brushes )}
283- for ci in self .choropleth_items :
284- ci .setBrush (rid2brush [ci .region .id ])
293+ for ci , d , b in zip (self .choropleth_items , agg_data , brushes ):
294+ ci .agg_value = d
295+ ci .setBrush (b )
285296 self .update_legends ()
286297
287298 def get_colors (self ):
@@ -352,9 +363,8 @@ def update_selection_colors(self):
352363 Update color of selected regions.
353364 """
354365 pens = self .get_colors_sel ()
355- rid2pen = {rid : pen for rid , pen in zip (self .master .region_ids , pens )}
356- for ci in self .choropleth_items :
357- ci .setPen (rid2pen [ci .region .id ])
366+ for ci , pen in zip (self .choropleth_items , pens ):
367+ ci .setPen (pen )
358368
359369 def get_colors_sel (self ):
360370 white_pen = self ._make_pen (QColor (Qt .white ), 1 )
@@ -400,8 +410,7 @@ def select_by_id(self, region_id):
400410
401411 def select_by_rectangle (self , rect : QRectF ):
402412 """
403- Find polygons that intersect with selected rectangle and select all
404- corresponding regions.
413+ Find regions that intersect with selected rectangle.
405414 """
406415 poly_rect = QPolygonF (rect )
407416 indices = set ()
@@ -475,7 +484,7 @@ def help_event(self, event):
475484 ci = next ((ci for ci in self .choropleth_items
476485 if ci .contains (act_pos )), None )
477486 if ci is not None :
478- QToolTip .showText (event .screenPos (), ci .tooltip_text ,
487+ QToolTip .showText (event .screenPos (), ci .tooltip () ,
479488 widget = self .plot_widget )
480489 return True
481490 else :
@@ -634,8 +643,9 @@ def _add_controls(self):
634643 ** options )
635644
636645 self .agg_func_combo = gui .comboBox (agg_box , self , 'agg_func' ,
637- label = 'Agg.:' , items = list (AGG_FUNCS ),
638- callback = self .setup_plot ,
646+ label = 'Agg.:' ,
647+ items = [DEFAULT_AGG_FUNC ],
648+ callback = self .graph .update_colors ,
639649 ** options )
640650
641651 a_slider = gui .hSlider (agg_box , self , 'admin_level' , minValue = 0 ,
@@ -647,13 +657,14 @@ def _add_controls(self):
647657 b_slider = gui .hSlider (visualization_box , self , "binning_index" ,
648658 label = "Bin width:" , minValue = 0 ,
649659 maxValue = max (1 , len (self .binnings ) - 1 ),
650- createLabel = False , callback = self .colors_changed )
660+ createLabel = False ,
661+ callback = self .graph .update_colors )
651662 b_slider .setFixedWidth (176 )
652663
653664 av_slider = gui .hSlider (visualization_box , self , "graph.alpha_value" ,
654665 minValue = 0 , maxValue = 255 , step = 10 ,
655666 label = "Opacity:" , createLabel = False ,
656- callback = self .colors_changed )
667+ callback = self .graph . update_colors )
657668 av_slider .setFixedWidth (176 )
658669
659670 gui .checkBox (visualization_box , self , "graph.show_legend" ,
@@ -693,8 +704,8 @@ def set_data(self, data):
693704 if not (data_existed and self .data is not None and
694705 array_equal (effective_data .X , self .effective_data .X )):
695706 self .clear (cache = True )
696- self .graph .clear ()
697707 self .input_changed .emit (data )
708+ self .setup_plot ()
698709 self .update_agg ()
699710 self .apply_selection ()
700711 self .unconditional_commit ()
@@ -741,7 +752,7 @@ def update_agg(self):
741752 else :
742753 self .agg_func = DEFAULT_AGG_FUNC
743754
744- self .setup_plot ()
755+ self .graph . update_colors ()
745756
746757 def setup_plot (self ):
747758 self .controls .binning_index .setEnabled (not self .is_mode ())
@@ -773,7 +784,7 @@ def commit(self):
773784 def send_data (self ):
774785 data , graph_sel = self .data , self .graph .get_selection ()
775786 group_sel , selected_data , ann_data = None , None , None
776- if data is not None and len (data ):
787+ if data is not None and len (data ) and self . region_ids is not None :
777788 # we get selection by region ids so we have to map it to points
778789 group_sel = np .zeros (len (data ), dtype = int )
779790 for id , s in zip (self .region_ids , graph_sel ):
@@ -782,14 +793,14 @@ def send_data(self):
782793 id_indices = np .where (self .data_ids == id )[0 ]
783794 group_sel [id_indices ] = s
784795
785- if np .sum (graph_sel ) > 0 :
786- selected_data = create_groups_table (data , group_sel , False , "Group" )
796+ if np .sum (graph_sel ) > 0 :
797+ selected_data = create_groups_table (data , group_sel , False , "Group" )
787798
788- if data is not None :
789- if np .max (graph_sel ) > 1 :
790- ann_data = create_groups_table (data , group_sel )
791- else :
792- ann_data = create_annotated_table (data , group_sel .astype (bool ))
799+ if data is not None :
800+ if np .max (graph_sel ) > 1 :
801+ ann_data = create_groups_table (data , group_sel )
802+ else :
803+ ann_data = create_annotated_table (data , group_sel .astype (bool ))
793804
794805 self .output_changed .emit (selected_data )
795806 self .Outputs .selected_data .send (selected_data )
@@ -822,15 +833,18 @@ def get_palette(self):
822833 return self .agg_attr .palette
823834
824835 def get_color_data (self ):
825- return self .get_agg_data ()
836+ return self .get_reduced_agg_data ()
826837
827838 def get_color_labels (self ):
828839 if self .is_mode ():
829- return self .get_agg_data (return_labels = True )
840+ return self .get_reduced_agg_data (return_labels = True )
830841 elif self .is_time ():
831842 return self .agg_attr .str_val
832843
833- def get_agg_data (self , return_labels = False ):
844+ def get_reduced_agg_data (self , return_labels = False ):
845+ """
846+ This returns agg data or its labels. It also merges infrequent data.
847+ """
834848 needs_merging = self .is_mode () \
835849 and len (self .agg_attr .values ) >= MAX_COLORS
836850 if return_labels and not needs_merging :
@@ -865,10 +879,6 @@ def is_time(self):
865879 self .agg_attr .is_time and \
866880 self .agg_func not in ('Count' , 'Count defined' )
867881
868- def colors_changed (self ):
869- if self .choropleth_regions :
870- self .graph .update_colors ()
871-
872882 @memoize_method (3 )
873883 def get_regions (self , lat_attr , lon_attr , admin ):
874884 """
@@ -894,63 +904,70 @@ def get_regions(self, lat_attr, lon_attr, admin):
894904 for _id , poly in zip (unique_ids , get_shape (unique_ids ))}
895905 return ids , region_info , polygons
896906
897- @memoize_method (6 )
898907 def get_grouped (self , lat_attr , lon_attr , admin , attr , agg_func ):
899908 """
900909 Get aggregation value for points grouped by regions.
901910 Returns:
902- dict of region ids matched to their additional info,
903- dict of region ids matched to their polygon,
904911 Series of aggregated values
905912 """
906913 if attr is not None :
907914 data = self .data .get_column_view (attr )[0 ]
908915 else :
909916 data = np .ones (len (self .data ))
910917
911- ids , region_info , polygons = self .get_regions (lat_attr , lon_attr , admin )
918+ ids , _ , _ = self .get_regions (lat_attr , lon_attr , admin )
912919 result = pd .Series (data , dtype = float )\
913920 .groupby (ids )\
914921 .agg (AGG_FUNCS [agg_func ].transform )
915922
916- return region_info , polygons , result
923+ return result
924+
925+ def get_agg_data (self ) -> np .ndarray :
926+ result = self .get_grouped (self .attr_lat , self .attr_lon ,
927+ self .admin_level , self .agg_attr ,
928+ self .agg_func )
929+
930+ self .agg_data = np .array (result .values )
931+ self .region_ids = np .array (result .index )
932+
933+ arg_region_sort = np .argsort (self .region_ids )
934+ self .region_ids = self .region_ids [arg_region_sort ]
935+ self .agg_data = self .agg_data [arg_region_sort ]
936+
937+ self .recompute_binnings ()
938+
939+ return self .agg_data
917940
918941 def _repr_val (self , value ):
919942 if self .agg_func in ('Count' , 'Count defined' ):
920943 return f"{ value :d} "
921944 else :
922945 return self .agg_attr .repr_val (value )
923946
924- def _create_choropleth_regions (self ):
925- """Recalculate regions and group by."""
926- region_info , polygons , result = self .get_grouped (self .attr_lat ,
927- self .attr_lon ,
928- self .admin_level ,
929- self .agg_attr ,
930- self .agg_func )
931- self .agg_data = np .array (result .values )
932- self .region_ids = np .array (result .index )
933- self .recompute_binnings ()
947+ def get_choropleth_regions (self ) -> List [_ChoroplethRegion ]:
948+ """Recalculate regions"""
949+ if not self .is_valid ():
950+ return []
951+
952+ _ , region_info , polygons = self .get_regions (self .attr_lat ,
953+ self .attr_lon ,
954+ self .admin_level )
934955
935956 regions = []
936- for id , res in result . iteritems () :
937- if isinstance (polygons [id ], MultiPolygon ):
957+ for _id in polygons :
958+ if isinstance (polygons [_id ], MultiPolygon ):
938959 # some regions consist of multiple polygons
939- poly = list (polygons [id ].geoms )
960+ polys = list (polygons [_id ].geoms )
940961 else :
941- poly = [polygons [id ]]
962+ polys = [polygons [_id ]]
942963
943- for _poly in poly :
944- qpoly = self .poly2qpoly (transform (self .deg2canvas , _poly ))
945- regions .append (
946- _ChoroplethRegion (id = id , agg_value = self ._repr_val (res ),
947- info = region_info [id ],
948- qpoly = qpoly ))
949- self .choropleth_regions = regions
964+ qpolys = [self .poly2qpoly (transform (self .deg2canvas , poly ))
965+ for poly in polys ]
966+ regions .append (_ChoroplethRegion (id = _id , info = region_info [_id ],
967+ qpolys = qpolys ))
950968
951- def get_choropleth_regions (self ) -> List [_ChoroplethRegion ]:
952- if not self .choropleth_regions and self .is_valid ():
953- self ._create_choropleth_regions ()
969+ self .choropleth_regions = sorted (regions , key = lambda cr : cr .id )
970+ self .get_agg_data ()
954971 return self .choropleth_regions
955972
956973 @staticmethod
@@ -968,7 +985,6 @@ def clear(self, cache=False):
968985 self .choropleth_regions = []
969986 if cache :
970987 self .get_regions .cache_clear ()
971- self .get_grouped .cache_clear ()
972988
973989 def send_report (self ):
974990 if self .data is None :
0 commit comments