1- from dash import Input , Output , State
1+ from dash import Input , Output , State , ctx , no_update
22import dash_bootstrap_components as dbc
33from dash import html , dcc
44import pandas as pd
1010from app .constants .constants import COUNTRIES_WHITELIST
1111
1212
13- def fig_epmc_countries_pie (countries_df ):
14- """Pie chart – article count by affiliation country."""
13+ def fig_epmc_countries_pie (countries_df , hidden_labels = None ):
14+ """Pie chart – article count by affiliation country.
15+
16+ hidden_labels: collection of country names currently toggled off in the
17+ legend. Percentages are recalculated against the visible-only total so
18+ the displayed values stay correct after toggling.
19+ """
1520 if countries_df is None or countries_df .empty :
1621 return go .Figure ().update_layout (title = "No country data available" )
1722
@@ -34,24 +39,32 @@ def fig_epmc_countries_pie(countries_df):
3439 return go .Figure ().update_layout (title = "No country data available (after filtering)" )
3540
3641 counts = pd .to_numeric (df ["count" ], errors = "coerce" ).fillna (0.0 )
37- total = counts .sum ()
38- if total <= 0 :
39- return go .Figure ().update_layout (title = "No country data available (zero total)" )
40-
4142 df = df .copy ()
4243 df ["count" ] = counts
4344 df = df .sort_values ("count" , ascending = False ).reset_index (drop = True )
4445
45- percents = (df ["count" ] / total * 100 )
46+ hidden = set (hidden_labels ) if hidden_labels else set ()
47+ visible_mask = ~ df ["country_normalized" ].isin (hidden )
48+ visible_total = df .loc [visible_mask , "count" ].sum ()
49+ if visible_total <= 0 :
50+ visible_total = df ["count" ].sum ()
51+ if visible_total <= 0 :
52+ return go .Figure ().update_layout (title = "No country data available (zero total)" )
53+
4654 slice_text = []
4755 hover_text = []
48- for cn , cnt , pct in zip (df ["country_normalized" ], df ["count" ], percents ):
49- pct_fmt = f" { pct :.1f } %"
50- if pct > 5.0 :
51- slice_text .append (f" { cn } <br> { pct_fmt } " )
56+ for cn , cnt , is_vis in zip (df ["country_normalized" ], df ["count" ], visible_mask ):
57+ if not is_vis :
58+ slice_text . append ( "" )
59+ hover_text .append (" " )
5260 else :
53- slice_text .append (f"{ pct_fmt } " )
54- hover_text .append (f"{ cn } : { int (cnt )} ({ pct_fmt } )" )
61+ pct = cnt / visible_total * 100
62+ pct_fmt = f"{ pct :.1f} %"
63+ if pct > 5.0 :
64+ slice_text .append (f"{ cn } <br>{ pct_fmt } " )
65+ else :
66+ slice_text .append (pct_fmt )
67+ hover_text .append (f"{ cn } : { int (cnt )} ({ pct_fmt } )" )
5568
5669 text_positions = ["outside" if "<br>" in t else "inside" for t in slice_text ]
5770
@@ -84,6 +97,72 @@ def fig_epmc_countries_pie(countries_df):
8497 x = 0.5 ,
8598 ),
8699 )
100+ if hidden :
101+ fig .update_layout (hiddenlabels = list (hidden ))
102+ return fig
103+
104+
105+ def fig_epmc_countries_choropleth (countries_df ):
106+ """Choropleth world map — each country's % share of total author affiliations."""
107+ if countries_df is None or countries_df .empty :
108+ return go .Figure ().update_layout (title = "No country data available" )
109+
110+ cols = list (countries_df .columns )
111+ if "country" in [c .lower () for c in cols ] and "count" in [c .lower () for c in cols ]:
112+ country_col = next (c for c in cols if c .lower () == "country" )
113+ count_col = next (c for c in cols if c .lower () == "count" )
114+ df = countries_df [[country_col , count_col ]].copy ()
115+ df .columns = ["country" , "count" ]
116+ else :
117+ df = countries_df .iloc [:, :2 ].copy ()
118+ df .columns = ["country" , "count" ]
119+
120+ whitelist = {c .strip ().lower () for c in COUNTRIES_WHITELIST }
121+ df ["country" ] = df ["country" ].astype (str ).str .strip ()
122+ df = df [df ["country" ].str .lower ().isin (whitelist )].copy ()
123+ df ["count" ] = pd .to_numeric (df ["count" ], errors = "coerce" ).fillna (0 )
124+
125+ if df .empty :
126+ return go .Figure ().update_layout (title = "No country data available" )
127+
128+ total = df ["count" ].sum ()
129+ df ["pct" ] = (df ["count" ] / total * 100 ).round (2 )
130+ df ["hover_text" ] = df .apply (
131+ lambda r : f"{ r ['country' ]} <br>{ r ['pct' ]} % of author affiliations" , axis = 1
132+ )
133+
134+ fig = px .choropleth (
135+ df ,
136+ locations = "country" ,
137+ locationmode = "country names" ,
138+ color = "pct" ,
139+ color_continuous_scale = "Reds" ,
140+ custom_data = ["pct" ],
141+ labels = {"pct" : "Share (%)" , "country" : "Country" },
142+ template = "simple_white" ,
143+ )
144+ fig .update_traces (
145+ hovertemplate = "<b>%{location}</b><br>%{customdata[0]:.2f}% of author affiliations<extra></extra>" ,
146+ marker_line_color = "white" ,
147+ marker_line_width = 0.5 ,
148+ )
149+ fig .update_layout (
150+ autosize = True ,
151+ margin = {"l" : 0 , "r" : 0 , "t" : 0 , "b" : 0 },
152+ coloraxis_colorbar = {
153+ "title" : "Share (%)" ,
154+ "thickness" : 12 ,
155+ "ticksuffix" : "%" ,
156+ },
157+ )
158+ fig .update_geos (
159+ showland = True , landcolor = "#DAECC1" ,
160+ showocean = True , oceancolor = "#BBDFF1" ,
161+ showlakes = True , lakecolor = "#BBDFF1" ,
162+ showcountries = True , countrycolor = "#999999" ,
163+ projection_type = "natural earth" ,
164+ showframe = False ,
165+ )
87166 return fig
88167
89168
@@ -424,11 +503,18 @@ def aff_item(num, org):
424503 Output ("epmc-countries-pie" , "figure" ),
425504 Output ("epmc-authors-bar" , "figure" ),
426505 Output ("epmc-authors-card-body" , "style" ),
427- Input ("epmc-top-n-slider" , "value" ), # Responds to slider but uses same cached authors
506+ Input ("epmc-top-n-slider" , "value" ),
507+ Input ("epmc-countries-pie" , "relayoutData" ),
428508 )
429- def update_epmc_graphs (top_n ):
509+ def update_epmc_graphs (top_n , relayout_data ):
510+ # Legend toggle on the pie — only rebuild the pie with updated percentages
511+ if ctx .triggered_id == "epmc-countries-pie" :
512+ hidden = (relayout_data or {}).get ("hiddenlabels" ) or []
513+ if "hiddenlabels" not in (relayout_data or {}):
514+ return no_update , no_update , no_update
515+ return fig_epmc_countries_pie (countries_df , hidden_labels = hidden ), no_update , no_update
516+
430517 fig_pie = fig_epmc_countries_pie (countries_df )
431- # Use pre-fetched top_authors_default (no API call needed)
432518 fig_bar = fig_epmc_top_authors_bar (top_authors_default , top_n )
433519 graph_height = max (400 , 25 * min (top_n , len (top_authors_default )))
434520 return fig_pie , fig_bar , {"minHeight" : f"{ graph_height + 96 } px" }
@@ -463,3 +549,22 @@ def toggle_aff_collapse(n, is_open, first_affiliation):
463549 html .Span (first_affiliation or "Affiliations" ),
464550 ]
465551 return new_state , label
552+
553+ # -----------------------
554+ # Interactive YoY growth KPI
555+ # -----------------------
556+ @app .callback (
557+ Output ("yoy-growth-value" , "children" ),
558+ Input ("yoy-year-selector" , "value" ),
559+ State ("yearly-pub-counts" , "data" ),
560+ )
561+ def update_yoy_growth (selected_year , yearly_counts ):
562+ if not selected_year or not yearly_counts :
563+ return "N/A"
564+ curr = yearly_counts .get (str (selected_year )) or yearly_counts .get (selected_year )
565+ prev = yearly_counts .get (str (selected_year - 1 )) or yearly_counts .get (selected_year - 1 )
566+ if curr is None or prev is None or prev == 0 :
567+ return "N/A"
568+ pct = round ((curr - prev ) / prev * 100 , 1 )
569+ return f"+{ pct } %" if pct >= 0 else f"{ pct } %"
570+
0 commit comments