Files changed (1) hide show
  1. app.py +277 -108
app.py CHANGED
@@ -1,5 +1,7 @@
1
- # app.py — Mobile-first, HF-iframe-friendly Gradio app
2
- # Paste this into your Hugging Face Space (Gradio). Uses inline CSS to handle iframe constraints.
 
 
3
  # Requirements: gradio, networkx, plotly, pandas, numpy
4
 
5
  import gradio as gr
@@ -7,10 +9,11 @@ import pandas as pd
7
  import networkx as nx
8
  import plotly.graph_objects as go
9
  import numpy as np
 
10
  from collections import defaultdict
11
 
12
  # ---------------------------
13
- # Data (same sample dataset)
14
  # ---------------------------
15
  AMCS = [
16
  "SBI MF", "ICICI Pru MF", "HDFC MF", "Nippon India MF", "Kotak MF",
@@ -52,21 +55,19 @@ SELL_MAP = {
52
  COMPLETE_EXIT = {"DSP MF": ["Shriram Finance"]}
53
  FRESH_BUY = {"HDFC MF": ["Tata Elxsi"], "UTI MF": ["Adani Ports"], "Mirae MF": ["HAL"]}
54
 
55
-
56
  def sanitize_map(m):
57
  out = {}
58
  for k, vals in m.items():
59
  out[k] = [v for v in vals if v in COMPANIES]
60
  return out
61
 
62
-
63
  BUY_MAP = sanitize_map(BUY_MAP)
64
  SELL_MAP = sanitize_map(SELL_MAP)
65
  COMPLETE_EXIT = sanitize_map(COMPLETE_EXIT)
66
  FRESH_BUY = sanitize_map(FRESH_BUY)
67
 
68
  # ---------------------------
69
- # Graph construction
70
  # ---------------------------
71
  company_edges = []
72
  for amc, comps in BUY_MAP.items():
@@ -82,7 +83,6 @@ for amc, comps in FRESH_BUY.items():
82
  for c in comps:
83
  company_edges.append((amc, c, {"action": "fresh_buy", "weight": 3}))
84
 
85
-
86
  def infer_amc_transfers(buy_map, sell_map):
87
  transfers = defaultdict(int)
88
  company_to_sellers = defaultdict(list)
@@ -104,10 +104,8 @@ def infer_amc_transfers(buy_map, sell_map):
104
  edge_list.append((s, b, {"action": "transfer", "weight": w}))
105
  return edge_list
106
 
107
-
108
  transfer_edges = infer_amc_transfers(BUY_MAP, SELL_MAP)
109
 
110
-
111
  def build_graph(include_transfers=True):
112
  G = nx.DiGraph()
113
  for a in AMCS:
@@ -132,42 +130,43 @@ def build_graph(include_transfers=True):
132
  return G
133
 
134
  # ---------------------------
135
- # Plotly drawing helper
136
  # ---------------------------
137
- def graph_to_plotly(
138
- G,
139
- node_color_amc="#9EC5FF",
140
- node_color_company="#FFCF9E",
141
- edge_color_buy="#2ca02c",
142
- edge_color_sell="#d62728",
143
- edge_color_transfer="#888888",
144
- edge_thickness_base=1.4,
145
- show_labels=True,
146
- ):
147
- # spring layout - deterministic seed
148
  pos = nx.spring_layout(G, seed=42, k=1.2)
149
 
150
- node_x, node_y, node_text, node_color, node_size = [], [], [], [], []
 
 
 
 
 
151
  for n, d in G.nodes(data=True):
 
152
  x, y = pos[n]
153
- node_x.append(x); node_y.append(y); node_text.append(n)
154
  if d["type"] == "amc":
155
  node_color.append(node_color_amc); node_size.append(36)
156
  else:
157
  node_color.append(node_color_company); node_size.append(56)
158
 
159
- node_trace = go.Scatter(
160
- x=node_x, y=node_y,
161
- mode="markers+text" if show_labels else "markers",
162
- marker=dict(color=node_color, size=node_size, line=dict(width=2, color="#222")),
163
- text=node_text if show_labels else None, textposition="top center", hoverinfo="text"
164
- )
165
-
166
  edge_traces = []
 
 
 
 
167
  for u, v, attrs in G.edges(data=True):
 
168
  acts = attrs.get("actions", [])
169
  weight = attrs.get("weight", 1)
170
- x0, y0 = pos[u]; x1, y1 = pos[v]
171
  if "complete_exit" in acts:
172
  color = edge_color_sell; dash = "solid"; width = edge_thickness_base * 3
173
  elif "fresh_buy" in acts:
@@ -179,23 +178,196 @@ def graph_to_plotly(
179
  else:
180
  color = edge_color_buy; dash = "solid"; width = edge_thickness_base * (1 + np.log1p(weight))
181
 
 
182
  edge_traces.append(go.Scatter(
183
- x=[x0, x1, None], y=[y0, y1, None],
184
- mode="lines", line=dict(color=color, width=width, dash=dash),
185
- hoverinfo="none"
 
 
186
  ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
 
 
188
  fig = go.Figure(data=edge_traces + [node_trace])
189
- # use autosize for better responsiveness inside iframe
190
- fig.update_layout(showlegend=False,
191
- autosize=True,
192
- margin=dict(l=8, r=8, t=36, b=8),
193
- xaxis=dict(visible=False),
194
- yaxis=dict(visible=False))
195
- return fig
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
  # ---------------------------
198
- # Summaries (company/amc)
199
  # ---------------------------
200
  def company_trade_summary(company_name):
201
  buyers = [a for a, comps in BUY_MAP.items() if company_name in comps]
@@ -211,12 +383,10 @@ def company_trade_summary(company_name):
211
  if df.empty:
212
  return None, pd.DataFrame([], columns=["Role", "AMC"])
213
  counts = df.groupby("Role").size().reset_index(name="Count")
214
- colors = ["green", "red", "orange", "black"][:len(counts)]
215
- fig = go.Figure(go.Bar(x=counts["Role"], y=counts["Count"], marker_color=colors))
216
  fig.update_layout(title_text=f"Trade summary for {company_name}", autosize=True, margin=dict(t=30,b=10))
217
  return fig, df
218
 
219
-
220
  def amc_transfer_summary(amc_name):
221
  sold = SELL_MAP.get(amc_name, [])
222
  transfers = []
@@ -234,71 +404,66 @@ def amc_transfer_summary(amc_name):
234
  return fig, df
235
 
236
  # ---------------------------
237
- # Initial graph
238
  # ---------------------------
239
- initial_graph = build_graph(include_transfers=True)
240
- initial_fig = graph_to_plotly(initial_graph)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
  # ---------------------------
243
- # Mobile-first CSS (override HF iframe quirks)
244
  # ---------------------------
245
  responsive_css = """
246
- /* Remove excessive padding inside HF iframe */
247
  .gradio-container { padding: 0 !important; margin: 0 !important; }
248
-
249
- /* Make the plot area truly full-width */
250
- .plotly-graph-div, .js-plotly-plot, .output_plot {
251
- width: 100% !important;
252
- max-width: 100% !important;
253
- }
254
-
255
- /* Ensure plot will shrink on small screens but remain legible */
256
- .js-plotly-plot {
257
- height: 460px !important;
258
- }
259
-
260
- /* Make controls compact and finger-friendly */
261
- .gradio-container .gr-input, .gradio-container .gr-button {
262
- width: 100% !important;
263
- }
264
-
265
- /* Accordion collapsed by default on mobile; larger touch targets */
266
  @media only screen and (max-width: 780px) {
267
  .js-plotly-plot { height: 420px !important; }
268
- .gr-accordion { font-size: 15px; }
269
- .gradio-container { padding: 6px !important; }
270
  }
271
-
272
- /* Avoid horizontal scroll and ensure content uses available width */
273
  body, html { overflow-x: hidden !important; }
274
  """
275
 
276
  # ---------------------------
277
- # Gradio UI (Blocks) — accordion closed by default (mobile-first)
278
  # ---------------------------
279
- with gr.Blocks(css=responsive_css, title="MF Churn Explorer (mobile-first)") as demo:
280
- gr.Markdown("## Mutual Fund Churn Explorer — Mobile Friendly")
281
- # Full-width network on top
282
- network_plot = gr.Plot(value=initial_fig, label="Network graph (tap to zoom)")
 
283
 
284
- # Controls in a collapsed accordion (closed by default to save vertical space)
285
  with gr.Accordion("Network Customization — expand to edit", open=False):
286
  node_color_company = gr.ColorPicker("#FFCF9E", label="Company node color")
287
  node_color_amc = gr.ColorPicker("#9EC5FF", label="AMC node color")
288
- node_shape_company = gr.Dropdown(["circle", "square", "diamond"], value="circle", label="Company node shape")
289
- node_shape_amc = gr.Dropdown(["circle", "square", "diamond"], value="circle", label="AMC node shape")
290
  edge_color_buy = gr.ColorPicker("#2ca02c", label="BUY edge color")
291
  edge_color_sell = gr.ColorPicker("#d62728", label="SELL edge color")
292
  edge_color_transfer = gr.ColorPicker("#888888", label="Transfer edge color")
293
  edge_thickness = gr.Slider(0.5, 6.0, value=1.4, step=0.1, label="Edge thickness base")
294
  include_transfers = gr.Checkbox(value=True, label="Show AMC→AMC inferred transfers")
295
- update_button = gr.Button("Update network")
296
 
297
- gr.Markdown("### Quick inspection (mobile)")
298
  select_company = gr.Dropdown(choices=COMPANIES, label="Select company (buyers / sellers)")
299
  company_out_plot = gr.Plot(label="Company trade summary")
300
  company_out_table = gr.DataFrame(label="Company trade table")
301
 
 
 
302
  select_amc = gr.Dropdown(choices=AMCS, label="Select AMC (inferred transfers)")
303
  amc_out_plot = gr.Plot(label="AMC transfer summary")
304
  amc_out_table = gr.DataFrame(label="AMC transfer table")
@@ -306,37 +471,41 @@ with gr.Blocks(css=responsive_css, title="MF Churn Explorer (mobile-first)") as
306
  # ---------------------------
307
  # Callbacks
308
  # ---------------------------
309
- def update_network(node_color_company_val, node_color_amc_val,
310
- node_shape_company_val, node_shape_amc_val,
311
- edge_color_buy_val, edge_color_sell_val, edge_color_transfer_val,
312
- edge_thickness_val, include_transfers_val):
313
- G = build_graph(include_transfers=include_transfers_val)
314
- fig = graph_to_plotly(G,
315
- node_color_amc=node_color_amc_val,
316
- node_color_company=node_color_company_val,
317
- edge_color_buy=edge_color_buy_val,
318
- edge_color_sell=edge_color_sell_val,
319
- edge_color_transfer=edge_color_transfer_val,
320
- edge_thickness_base=edge_thickness_val,
321
- show_labels=True)
322
- return fig
323
-
324
- def handle_company(company):
325
- fig, df = company_trade_summary(company)
326
  return fig, df
327
 
328
- def handle_amc(amc):
329
- fig, df = amc_transfer_summary(amc)
 
 
330
  return fig, df
331
 
332
- update_button.click(update_network,
333
- inputs=[node_color_company, node_color_amc, node_shape_company, node_shape_amc,
334
- edge_color_buy, edge_color_sell, edge_color_transfer, edge_thickness, include_transfers],
335
- outputs=[network_plot])
 
336
 
337
- select_company.change(handle_company, select_company, [company_out_plot, company_out_table])
338
- select_amc.change(handle_amc, select_amc, [amc_out_plot, amc_out_table])
339
 
340
- # Launch
 
 
341
  if __name__ == "__main__":
342
  demo.launch()
 
1
+ # app.py
2
+ # Interactive MF churn explorer
3
+ # - Chart is client-side interactive: clicking a node hides everything except that node + its neighbors (Option A)
4
+ # - AMC/company inspect sections remain unchanged
5
  # Requirements: gradio, networkx, plotly, pandas, numpy
6
 
7
  import gradio as gr
 
9
  import networkx as nx
10
  import plotly.graph_objects as go
11
  import numpy as np
12
+ import json
13
  from collections import defaultdict
14
 
15
  # ---------------------------
16
+ # Sample dataset (same as before)
17
  # ---------------------------
18
  AMCS = [
19
  "SBI MF", "ICICI Pru MF", "HDFC MF", "Nippon India MF", "Kotak MF",
 
55
  COMPLETE_EXIT = {"DSP MF": ["Shriram Finance"]}
56
  FRESH_BUY = {"HDFC MF": ["Tata Elxsi"], "UTI MF": ["Adani Ports"], "Mirae MF": ["HAL"]}
57
 
 
58
  def sanitize_map(m):
59
  out = {}
60
  for k, vals in m.items():
61
  out[k] = [v for v in vals if v in COMPANIES]
62
  return out
63
 
 
64
  BUY_MAP = sanitize_map(BUY_MAP)
65
  SELL_MAP = sanitize_map(SELL_MAP)
66
  COMPLETE_EXIT = sanitize_map(COMPLETE_EXIT)
67
  FRESH_BUY = sanitize_map(FRESH_BUY)
68
 
69
  # ---------------------------
70
+ # Build edges & infer transfers
71
  # ---------------------------
72
  company_edges = []
73
  for amc, comps in BUY_MAP.items():
 
83
  for c in comps:
84
  company_edges.append((amc, c, {"action": "fresh_buy", "weight": 3}))
85
 
 
86
  def infer_amc_transfers(buy_map, sell_map):
87
  transfers = defaultdict(int)
88
  company_to_sellers = defaultdict(list)
 
104
  edge_list.append((s, b, {"action": "transfer", "weight": w}))
105
  return edge_list
106
 
 
107
  transfer_edges = infer_amc_transfers(BUY_MAP, SELL_MAP)
108
 
 
109
  def build_graph(include_transfers=True):
110
  G = nx.DiGraph()
111
  for a in AMCS:
 
130
  return G
131
 
132
  # ---------------------------
133
+ # Build Plotly figure (Python-side)
134
  # ---------------------------
135
+ def build_plotly_figure(G,
136
+ node_color_amc="#9EC5FF",
137
+ node_color_company="#FFCF9E",
138
+ edge_color_buy="#2ca02c",
139
+ edge_color_sell="#d62728",
140
+ edge_color_transfer="#888888",
141
+ edge_thickness_base=1.4,
142
+ show_labels=True):
 
 
 
143
  pos = nx.spring_layout(G, seed=42, k=1.2)
144
 
145
+ node_names = []
146
+ node_x = []
147
+ node_y = []
148
+ node_color = []
149
+ node_size = []
150
+
151
  for n, d in G.nodes(data=True):
152
+ node_names.append(n)
153
  x, y = pos[n]
154
+ node_x.append(x); node_y.append(y)
155
  if d["type"] == "amc":
156
  node_color.append(node_color_amc); node_size.append(36)
157
  else:
158
  node_color.append(node_color_company); node_size.append(56)
159
 
160
+ # edges: one trace per edge to allow individual styling in JS
 
 
 
 
 
 
161
  edge_traces = []
162
+ edge_source_index = []
163
+ edge_target_index = []
164
+ edge_colors = []
165
+ edge_widths = []
166
  for u, v, attrs in G.edges(data=True):
167
+ x0, y0 = pos[u]; x1, y1 = pos[v]
168
  acts = attrs.get("actions", [])
169
  weight = attrs.get("weight", 1)
 
170
  if "complete_exit" in acts:
171
  color = edge_color_sell; dash = "solid"; width = edge_thickness_base * 3
172
  elif "fresh_buy" in acts:
 
178
  else:
179
  color = edge_color_buy; dash = "solid"; width = edge_thickness_base * (1 + np.log1p(weight))
180
 
181
+ # create trace for this edge
182
  edge_traces.append(go.Scatter(
183
+ x=[x0, x1], y=[y0, y1],
184
+ mode="lines",
185
+ line=dict(color=color, width=width, dash=dash),
186
+ hoverinfo="none",
187
+ opacity=1.0
188
  ))
189
+ edge_source_index.append(node_names.index(u))
190
+ edge_target_index.append(node_names.index(v))
191
+ edge_colors.append(color)
192
+ edge_widths.append(width)
193
+
194
+ # single node trace
195
+ node_trace = go.Scatter(
196
+ x=node_x, y=node_y,
197
+ mode="markers+text" if show_labels else "markers",
198
+ marker=dict(color=node_color, size=node_size, line=dict(width=2, color="#222")),
199
+ text=node_names if show_labels else None,
200
+ textposition="top center",
201
+ hoverinfo="text"
202
+ )
203
 
204
+ # assemble traces: edges first, nodes last
205
  fig = go.Figure(data=edge_traces + [node_trace])
206
+ fig.update_layout(
207
+ showlegend=False,
208
+ autosize=True,
209
+ margin=dict(l=8, r=8, t=36, b=8),
210
+ xaxis=dict(visible=False),
211
+ yaxis=dict(visible=False)
212
+ )
213
+
214
+ # We package helper arrays for JS (node names, edge source/target indices, original edge colors/widths)
215
+ meta = {
216
+ "node_names": node_names,
217
+ "edge_source_index": edge_source_index,
218
+ "edge_target_index": edge_target_index,
219
+ "edge_colors": edge_colors,
220
+ "edge_widths": edge_widths,
221
+ "node_colors": node_color,
222
+ "node_sizes": node_size
223
+ }
224
+ return fig, meta
225
+
226
+ # ---------------------------
227
+ # Helper to produce embeddable HTML with JS click handlers
228
+ # ---------------------------
229
+ def make_network_html(fig, meta, div_id="network-plot-div"):
230
+ # serialize plotly figure and metadata
231
+ fig_json = fig.to_plotly_json()
232
+ fig_json_text = json.dumps(fig_json) # safe to embed
233
+ meta_text = json.dumps(meta)
234
+
235
+ # Build HTML string that:
236
+ # - creates a div with id
237
+ # - loads Plotly (cdn)
238
+ # - creates the plot via Plotly.newPlot
239
+ # - sets up click handler that: when a node is clicked, only the node + its neighbors remain visible
240
+ # - adds a reset button
241
+ html = f"""
242
+ <div id="{div_id}" style="width:100%;height:520px;"></div>
243
+ <div style="margin-top:6px;margin-bottom:8px;">
244
+ <button id="{div_id}-reset" style="padding:8px 12px;border-radius:6px;">Reset view</button>
245
+ </div>
246
+ <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
247
+ <script>
248
+ const fig = {fig_json_text};
249
+ const meta = {meta_text};
250
+
251
+ // create plot
252
+ const container = document.getElementById("{div_id}");
253
+ Plotly.newPlot(container, fig.data, fig.layout, {{responsive: true}});
254
+
255
+ // identify traces: node trace is last
256
+ const nodeTraceIndex = fig.data.length - 1;
257
+ const edgeCount = fig.data.length - 1;
258
+
259
+ // helper: get node name -> index
260
+ const nameToIndex = {{}};
261
+ meta.node_names.forEach((n,i) => nameToIndex[n] = i);
262
+
263
+ // helper: when focusing nodeName, hide all traces/nodes not connected
264
+ function focusNode(nodeName) {{
265
+ const idx = nameToIndex[nodeName];
266
+ // neighbors = nodes that are sources or targets with edges to/from idx
267
+ const keepSet = new Set([idx]);
268
+ for (let e = 0; e < meta.edge_source_index.length; e++) {{
269
+ const s = meta.edge_source_index[e];
270
+ const t = meta.edge_target_index[e];
271
+ if (s === idx) {{ keepSet.add(t); }}
272
+ if (t === idx) {{ keepSet.add(s); }}
273
+ }}
274
+
275
+ // Prepare new marker opacity and text visibility arrays for nodes
276
+ const nodeCount = meta.node_names.length;
277
+ const newMarkerOpacity = Array(nodeCount).fill(0.0);
278
+ const newTextOpacity = Array(nodeCount).fill(0.0);
279
+ for (let i=0;i<nodeCount;i++) {{
280
+ if (keepSet.has(i)) {{
281
+ newMarkerOpacity[i] = 1.0;
282
+ newTextOpacity[i] = 1.0;
283
+ }} else {{
284
+ newMarkerOpacity[i] = 0.0;
285
+ newTextOpacity[i] = 0.0;
286
+ }}
287
+ }}
288
+
289
+ // Update node trace opacity and text via single restyle
290
+ Plotly.restyle(container, {{
291
+ 'marker.opacity': [newMarkerOpacity],
292
+ 'textfont': [{{'color': ['rgba(0,0,0,0)']}}] // optional - hide text for non-kept
293
+ }}, [nodeTraceIndex]);
294
+
295
+ // Update each edge trace: show only if both ends in keepSet
296
+ for (let e=0; e < edgeCount; e++) {{
297
+ const s = meta.edge_source_index[e];
298
+ const t = meta.edge_target_index[e];
299
+ const show = (keepSet.has(s) && keepSet.has(t));
300
+ const color = show ? meta.edge_colors[e] : 'rgba(0,0,0,0)';
301
+ const width = show ? meta.edge_widths[e] : 0.1;
302
+ Plotly.restyle(container, {{
303
+ 'line.color': [color],
304
+ 'line.width': [width]
305
+ }}, [e]);
306
+ }}
307
+
308
+ // Optionally zoom to bounding box of kept nodes
309
+ // compute bbox
310
+ let xs = [], ys = [];
311
+ const nodes = fig.data[nodeTraceIndex];
312
+ for (let j=0;j<meta.node_names.length;j++) {{
313
+ if (keepSet.has(j)) {{
314
+ xs.push(nodes.x[j]); ys.push(nodes.y[j]);
315
+ }}
316
+ }}
317
+ if (xs.length>0) {{
318
+ const xmin = Math.min(...xs), xmax = Math.max(...xs);
319
+ const ymin = Math.min(...ys), ymax = Math.max(...ys);
320
+ const padX = (xmax - xmin) * 0.4 + 0.1;
321
+ const padY = (ymax - ymin) * 0.4 + 0.1;
322
+ const newLayout = {{
323
+ xaxis: {{ range: [xmin - padX, xmax + padX] }},
324
+ yaxis: {{ range: [ymin - padY, ymax + padY] }}
325
+ }};
326
+ Plotly.relayout(container, newLayout);
327
+ }}
328
+ }}
329
+
330
+ // Reset function: restore original colors/widths/opacities
331
+ function resetView() {{
332
+ // restore nodes opacity to 1
333
+ const nodeCount = meta.node_names.length;
334
+ const fullOpacity = Array(nodeCount).fill(1.0);
335
+ Plotly.restyle(container, {{ 'marker.opacity': [fullOpacity] }}, [nodeTraceIndex]);
336
+
337
+ // restore edge colors and widths
338
+ for (let e=0; e < edgeCount; e++) {{
339
+ Plotly.restyle(container, {{
340
+ 'line.color': [meta.edge_colors[e]],
341
+ 'line.width': [meta.edge_widths[e]]
342
+ }}, [e]);
343
+ }}
344
+
345
+ // restore axes auto-range
346
+ Plotly.relayout(container, {{ xaxis: {{autorange: true}}, yaxis: {{autorange: true}} }} );
347
+ }}
348
+
349
+ // attach click handler on plot: if a node is clicked, focus that node
350
+ container.on('plotly_click', function(eventData) {{
351
+ // eventData.points[0].curveNumber is trace index, pointNumber is marker index for node trace
352
+ const p = eventData.points[0];
353
+ if (p.curveNumber === nodeTraceIndex) {{
354
+ const nodeIndex = p.pointNumber;
355
+ const nodeName = meta.node_names[nodeIndex];
356
+ focusNode(nodeName);
357
+ }}
358
+ }});
359
+
360
+ // attach reset button
361
+ document.getElementById("{div_id}-reset").addEventListener('click', function() {{
362
+ resetView();
363
+ }});
364
+
365
+ </script>
366
+ """
367
+ return html
368
 
369
  # ---------------------------
370
+ # Company / AMC inspection helpers (unchanged)
371
  # ---------------------------
372
  def company_trade_summary(company_name):
373
  buyers = [a for a, comps in BUY_MAP.items() if company_name in comps]
 
383
  if df.empty:
384
  return None, pd.DataFrame([], columns=["Role", "AMC"])
385
  counts = df.groupby("Role").size().reset_index(name="Count")
386
+ fig = go.Figure(go.Bar(x=counts["Role"], y=counts["Count"], marker_color=["green", "red", "orange", "black"][:len(counts)]))
 
387
  fig.update_layout(title_text=f"Trade summary for {company_name}", autosize=True, margin=dict(t=30,b=10))
388
  return fig, df
389
 
 
390
  def amc_transfer_summary(amc_name):
391
  sold = SELL_MAP.get(amc_name, [])
392
  transfers = []
 
404
  return fig, df
405
 
406
  # ---------------------------
407
+ # Initial graph HTML (server builds figure & meta, client handles clicks)
408
  # ---------------------------
409
+ def build_network_html(node_color_company="#FFCF9E", node_color_amc="#9EC5FF",
410
+ edge_color_buy="#2ca02c", edge_color_sell="#d62728",
411
+ edge_color_transfer="#888888", edge_thickness=1.4, include_transfers=True):
412
+ G = build_graph(include_transfers=include_transfers)
413
+ fig, meta = build_plotly_figure(G,
414
+ node_color_amc=node_color_amc,
415
+ node_color_company=node_color_company,
416
+ edge_color_buy=edge_color_buy,
417
+ edge_color_sell=edge_color_sell,
418
+ edge_color_transfer=edge_color_transfer,
419
+ edge_thickness_base=edge_thickness,
420
+ show_labels=True)
421
+ html = make_network_html(fig, meta, div_id="network-plot-div")
422
+ return html
423
+
424
+ initial_html = build_network_html()
425
 
426
  # ---------------------------
427
+ # Mobile-friendly CSS (embed)
428
  # ---------------------------
429
  responsive_css = """
430
+ /* remove iframe padding inside HF spaces */
431
  .gradio-container { padding: 0 !important; margin: 0 !important; }
432
+ .plotly-graph-div, .js-plotly-plot, .output_plot { width: 100% !important; max-width: 100% !important; }
433
+ .js-plotly-plot { height: 460px !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  @media only screen and (max-width: 780px) {
435
  .js-plotly-plot { height: 420px !important; }
 
 
436
  }
 
 
437
  body, html { overflow-x: hidden !important; }
438
  """
439
 
440
  # ---------------------------
441
+ # Gradio UI
442
  # ---------------------------
443
+ with gr.Blocks(css=responsive_css, title="MF Churn Explorer (interactive chart)") as demo:
444
+ gr.Markdown("## Mutual Fund Churn Explorer — Interactive Chart (click nodes)")
445
+
446
+ # HTML-based interactive Plotly (client-side click handling)
447
+ network_html = gr.HTML(value=initial_html)
448
 
449
+ # Controls below (unchanged behaviour)
450
  with gr.Accordion("Network Customization — expand to edit", open=False):
451
  node_color_company = gr.ColorPicker("#FFCF9E", label="Company node color")
452
  node_color_amc = gr.ColorPicker("#9EC5FF", label="AMC node color")
 
 
453
  edge_color_buy = gr.ColorPicker("#2ca02c", label="BUY edge color")
454
  edge_color_sell = gr.ColorPicker("#d62728", label="SELL edge color")
455
  edge_color_transfer = gr.ColorPicker("#888888", label="Transfer edge color")
456
  edge_thickness = gr.Slider(0.5, 6.0, value=1.4, step=0.1, label="Edge thickness base")
457
  include_transfers = gr.Checkbox(value=True, label="Show AMC→AMC inferred transfers")
458
+ update_button = gr.Button("Update Network Graph")
459
 
460
+ gr.Markdown("### Inspect a Company (buyers / sellers)")
461
  select_company = gr.Dropdown(choices=COMPANIES, label="Select company (buyers / sellers)")
462
  company_out_plot = gr.Plot(label="Company trade summary")
463
  company_out_table = gr.DataFrame(label="Company trade table")
464
 
465
+ gr.Markdown("### Inspect an AMC (inferred transfers)")
466
+ # AMC inspect unchanged; kept for server-side analysis below chart
467
  select_amc = gr.Dropdown(choices=AMCS, label="Select AMC (inferred transfers)")
468
  amc_out_plot = gr.Plot(label="AMC transfer summary")
469
  amc_out_table = gr.DataFrame(label="AMC transfer table")
 
471
  # ---------------------------
472
  # Callbacks
473
  # ---------------------------
474
+ def update_network_html(node_color_company_val, node_color_amc_val,
475
+ edge_color_buy_val, edge_color_sell_val, edge_color_transfer_val,
476
+ edge_thickness_val, include_transfers_val):
477
+ html = build_network_html(node_color_company=node_color_company_val,
478
+ node_color_amc=node_color_amc_val,
479
+ edge_color_buy=edge_color_buy_val,
480
+ edge_color_sell=edge_color_sell_val,
481
+ edge_color_transfer=edge_color_transfer_val,
482
+ edge_thickness=edge_thickness_val,
483
+ include_transfers=include_transfers_val)
484
+ return html
485
+
486
+ def on_company_select(cname):
487
+ fig, df = company_trade_summary(cname)
488
+ if fig is None:
489
+ return None, pd.DataFrame([], columns=["Role", "AMC"])
 
490
  return fig, df
491
 
492
+ def on_amc_select(aname):
493
+ fig, df = amc_transfer_summary(aname)
494
+ if fig is None:
495
+ return None, pd.DataFrame([], columns=["security", "buyer_amc"])
496
  return fig, df
497
 
498
+ update_button.click(fn=update_network_html,
499
+ inputs=[node_color_company, node_color_amc,
500
+ edge_color_buy, edge_color_sell, edge_color_transfer,
501
+ edge_thickness, include_transfers],
502
+ outputs=[network_html])
503
 
504
+ select_company.change(fn=on_company_select, inputs=[select_company], outputs=[company_out_plot, company_out_table])
505
+ select_amc.change(fn=on_amc_select, inputs=[select_amc], outputs=[amc_out_plot, amc_out_table])
506
 
507
+ # ---------------------------
508
+ # Run
509
+ # ---------------------------
510
  if __name__ == "__main__":
511
  demo.launch()