Files changed (1) hide show
  1. app.py +198 -203
app.py CHANGED
@@ -1,6 +1,7 @@
1
  # app.py
2
- # Static weighted semi-layer arc diagram (L1 labels outside)
3
- # With short labels by default & full label on click
 
4
 
5
  import gradio as gr
6
  import pandas as pd
@@ -63,38 +64,24 @@ COMPLETE_EXIT = sanitize_map(COMPLETE_EXIT)
63
  FRESH_BUY = sanitize_map(FRESH_BUY)
64
 
65
  # ---------------------------
66
- # Label maps (NEW)
67
  # ---------------------------
68
- SHORT_LABEL = {
69
- "SBI MF": "SBI",
70
- "ICICI Pru MF": "ICICI",
71
- "HDFC MF": "HDFC",
72
- "Nippon India MF": "NIP",
73
- "Kotak MF": "KOTAK",
74
- "UTI MF": "UTI",
75
- "Axis MF": "AXIS",
76
- "Aditya Birla SL MF": "ABSL",
77
- "Mirae MF": "MIRAE",
78
  "DSP MF": "DSP",
79
 
80
- "HDFC Bank": "HDFC Bk",
81
- "ICICI Bank": "ICICI Bk",
82
- "Bajaj Finance": "Bajaj Fin",
83
- "Bajaj Finserv": "Bajaj Fsrv",
84
- "Adani Ports": "AdaniPt",
85
- "Tata Motors": "TataMot",
86
- "Shriram Finance": "Shriram",
87
- "HAL": "HAL",
88
- "TCS": "TCS",
89
- "AU Small Finance Bank": "AU SFB",
90
- "Pearl Global": "PearlG",
91
- "Hindalco": "Hindalco",
92
- "Tata Elxsi": "Elxsi",
93
- "Cummins India": "Cummins",
94
- "Vedanta": "Vedanta"
95
  }
96
 
97
- FULL_LABEL = {k: k for k in SHORT_LABEL}
98
 
99
  # ---------------------------
100
  # Infer AMC→AMC transfers
@@ -103,28 +90,34 @@ def infer_amc_transfers(buy_map, sell_map):
103
  transfers = defaultdict(int)
104
  c2s = defaultdict(list)
105
  c2b = defaultdict(list)
 
106
  for amc, comps in sell_map.items():
107
- for c in comps: c2s[c].append(amc)
 
 
108
  for amc, comps in buy_map.items():
109
- for c in comps: c2b[c].append(amc)
 
 
110
  for c in set(c2s) | set(c2b):
111
  for s in c2s[c]:
112
  for b in c2b[c]:
113
  transfers[(s, b)] += 1
 
114
  return transfers
115
 
116
  TRANSFER_COUNTS = infer_amc_transfers(BUY_MAP, SELL_MAP)
117
 
118
  # ---------------------------
119
- # Mixed ordering to reduce crossings
120
  # ---------------------------
121
  def build_mixed_ordering(amcs, companies):
122
- mixed = []
123
- n = max(len(amcs), len(companies))
124
- for i in range(n):
125
- if i < len(amcs): mixed.append(amcs[i])
126
- if i < len(companies): mixed.append(companies[i])
127
- return mixed
128
 
129
  NODES = build_mixed_ordering(AMCS, COMPANIES)
130
  NODE_TYPE = {n: ("amc" if n in AMCS else "company") for n in NODES}
@@ -134,20 +127,28 @@ NODE_TYPE = {n: ("amc" if n in AMCS else "company") for n in NODES}
134
  # ---------------------------
135
  def build_flows():
136
  buys, sells, transfers, loops = [], [], [], []
 
137
  for amc, comps in BUY_MAP.items():
138
  for c in comps:
139
- w = 3 if (amc in FRESH_BUY and c in FRESH_BUY.get(amc,[])) else 1
140
- buys.append((amc,c,w))
 
141
  for amc, comps in SELL_MAP.items():
142
  for c in comps:
143
- w = 3 if (amc in COMPLETE_EXIT and c in COMPLETE_EXIT.get(amc,[])) else 1
144
- sells.append((c,amc,w))
145
- for (s,b),w in TRANSFER_COUNTS.items():
146
- transfers.append((s,b,w))
147
- for a,c,_ in buys:
148
- for c2,b,_ in sells:
149
- if c==c2: loops.append((a,c,b))
150
- loops = list({(a,c,b) for (a,c,b) in loops})
 
 
 
 
 
 
151
  return buys, sells, transfers, loops
152
 
153
  BUYS, SELLS, TRANSFERS, LOOPS = build_flows()
@@ -156,52 +157,61 @@ BUYS, SELLS, TRANSFERS, LOOPS = build_flows()
156
  # Inspect panels
157
  # ---------------------------
158
  def company_trade_summary(company):
159
- buyers = [a for a,cs in BUY_MAP.items() if company in cs]
160
- sellers = [a for a,cs in SELL_MAP.items() if company in cs]
161
- fresh = [a for a,cs in FRESH_BUY.items() if company in cs]
162
- exits = [a for a,cs in COMPLETE_EXIT.items() if company in cs]
163
- df = pd.DataFrame({"Role": (["Buyer"]*len(buyers))+(["Seller"]*len(sellers))+
164
- (["Fresh buy"]*len(fresh))+(["Complete exit"]*len(exits)),
165
- "AMC": buyers+sellers+fresh+exits})
166
- if df.empty: return None, df
 
 
 
 
 
 
 
 
167
  counts = df.groupby("Role").size().reset_index(name="Count")
168
- fig = {"data":[{"type":"bar","x":counts["Role"].tolist(),"y":counts["Count"].tolist()}],
169
- "layout":{"title":f"Trades for {company}"}}
170
- return fig,df
 
 
 
171
 
172
  def amc_transfer_summary(amc):
173
- sold = SELL_MAP.get(amc,[])
174
- transfers=[]
175
  for s in sold:
176
- buyers=[a for a,cs in BUY_MAP.items() if s in cs]
177
- for b in buyers: transfers.append({"security":s,"buyer_amc":b})
178
- df=pd.DataFrame(transfers)
179
- if df.empty:return None,df
180
- counts=df["buyer_amc"].value_counts().reset_index()
181
- counts.columns=["Buyer AMC","Count"]
182
- fig={"data":[{"type":"bar","x":counts["Buyer AMC"].tolist(),"y":counts["Count"].tolist()}],
183
- "layout":{"title":f"Inferred transfers from {amc}"}}
184
- return fig,df
 
 
 
 
 
 
185
 
186
  # ---------------------------
187
- # HTML template: safe, no f-strings
188
  # ---------------------------
189
- JS_TEMPLATE = """
190
  <div id="arc-container" style="width:100%; height:720px;"></div>
191
  <div style="margin-top:8px;">
192
  <button id="arc-reset" style="padding:8px 12px; border-radius:6px;">Reset</button>
193
  </div>
194
 
195
- <div style="margin-top:10px; font-family:sans-serif; font-size:13px;">
196
- <b>Legend</b><br/>
197
- BUY = green solid<br/>
198
- SELL = red dotted<br/>
199
- TRANSFER = grey<br/>
200
- LOOP = teal external arc<br/>
201
- <div style="margin-top:6px;color:#666;font-size:12px;">Labels: short by default. Clicking a node shows full name.</div>
202
- </div>
203
-
204
  <script src="https://d3js.org/d3.v7.min.js"></script>
 
205
  <script>
206
  const NODES = __NODES__;
207
  const NODE_TYPE = __NODE_TYPE__;
@@ -216,8 +226,9 @@ const FULL_LABEL_JS = __FULL_LABEL__;
216
  function draw() {
217
  const container = document.getElementById("arc-container");
218
  container.innerHTML = "";
219
- const w = Math.min(920, container.clientWidth || 820);
220
- const h = Math.max(420, Math.floor(w * 0.75));
 
221
  const svg = d3.select(container).append("svg")
222
  .attr("width","100%")
223
  .attr("height",h)
@@ -225,163 +236,147 @@ function draw() {
225
 
226
  const radius = Math.min(w,h)*0.36;
227
 
228
- const n=NODES.length;
229
- function angleFor(i){return (i/n)*2*Math.PI;}
230
- const nodePos = NODES.map((name,i)=>{
231
- const ang=angleFor(i)-Math.PI/2;
232
- return {name,angle:ang,x:Math.cos(ang)*radius,y:Math.sin(ang)*radius};
 
 
 
 
 
 
233
  });
234
- const nameToIndex={};
235
- NODES.forEach((nm,i)=>nameToIndex[nm]=i);
236
 
237
- const group=svg.append("g").selectAll("g").data(nodePos).enter().append("g")
238
- .attr("transform", d=>`translate(${d.x},${d.y})`);
 
 
 
 
 
239
 
240
- group.append("circle")
241
  .attr("r",16)
242
- .style("fill",d=>NODE_TYPE[d.name]==="amc"?"#2b6fa6":"#f2c88d")
243
  .style("stroke","#222")
244
  .style("stroke-width",1)
245
  .style("cursor","pointer");
246
 
247
- group.append("text")
248
- .attr("x", d => Math.cos(d.angle) * (radius + 20))
249
- .attr("y", d => Math.sin(d.angle) * (radius + 20))
250
- .attr("dy", "0.35em")
251
- .style("font-family", "sans-serif")
252
- .style("font-size", Math.max(10, Math.min(13, radius*0.038)))
253
- .style("text-anchor", "middle")
254
- .style("cursor","pointer")
255
- .text(d => d.name);
256
 
257
-
258
- function bezierPath(x0,y0,x1,y1,above=true){
259
  const mx=(x0+x1)/2, my=(y0+y1)/2;
260
- const dx=mx, dy=my;
261
- const len=Math.sqrt(dx*dx+dy*dy)||1;
262
- const ux=dx/len, uy=dy/len;
263
- const offset=(above?-1:1)*Math.max(30,radius*0.9);
264
  const cx=mx+ux*offset, cy=my+uy*offset;
265
  return `M ${x0} ${y0} Q ${cx} ${cy} ${x1} ${y1}`;
266
  }
267
 
268
- const allW=[].concat(BUYS.map(d=>d[2]),SELLS.map(d=>d[2]),TRANSFERS.map(d=>d[2]));
269
- const stroke=d3.scaleLinear().domain([1,Math.max(...allW,1)]).range([1.0,6.0]);
 
 
270
 
271
- // BUYS top
272
- const buyG=svg.append("g");
273
- BUYS.forEach(b=>{
274
- const a=b[0], c=b[1], wt=b[2];
275
- if(!(a in nameToIndex)||!(c in nameToIndex))return;
276
- const s=nodePos[nameToIndex[a]], t=nodePos[nameToIndex[c]];
277
  buyG.append("path")
278
- .attr("d",bezierPath(s.x,s.y,t.x,t.y,true))
279
- .attr("fill","none")
280
  .attr("stroke","#2e8540")
281
- .attr("stroke-width",stroke(wt))
282
- .attr("opacity",0.92)
283
  .attr("data-src",a)
284
- .attr("data-tgt",c);
 
285
  });
286
 
287
- // SELLS bottom
288
- const sellG=svg.append("g");
289
- SELLS.forEach(s=>{
290
- const c=s[0], a=s[1], wt=s[2];
291
- if(!(c in nameToIndex)||!(a in nameToIndex))return;
292
- const sp=nodePos[nameToIndex[c]], tp=nodePos[nameToIndex[a]];
293
  sellG.append("path")
294
- .attr("d",bezierPath(sp.x,sp.y,tp.x,tp.y,false))
295
- .attr("fill","none")
296
  .attr("stroke","#c0392b")
297
- .attr("stroke-width",stroke(wt))
298
  .attr("stroke-dasharray","4,3")
299
- .attr("opacity",0.86)
300
  .attr("data-src",c)
301
- .attr("data-tgt",a);
 
302
  });
303
 
304
- // transfers
305
- const trG=svg.append("g");
306
- TRANSFERS.forEach(t=>{
307
- const s=t[0], b=t[1], wt=t[2];
308
- if(!(s in nameToIndex)||!(b in nameToIndex))return;
309
- const sp=nodePos[nameToIndex[s]], tp=nodePos[nameToIndex[b]];
310
  const mx=(sp.x+tp.x)/2, my=(sp.y+tp.y)/2;
311
  const path=`M ${sp.x} ${sp.y} Q ${mx*0.3} ${my*0.3} ${tp.x} ${tp.y}`;
312
  trG.append("path")
313
  .attr("d",path)
314
- .attr("fill","none")
315
  .attr("stroke","#7d7d7d")
316
- .attr("stroke-width",stroke(wt))
 
317
  .attr("opacity",0.7)
318
  .attr("data-src",s)
319
  .attr("data-tgt",b);
320
  });
321
 
322
- // loops
323
- const loopG=svg.append("g");
324
- LOOPS.forEach(lp=>{
325
- const a=lp[0], c=lp[1], b=lp[2];
326
- if(!(a in nameToIndex)||!(b in nameToIndex))return;
327
- const sa=nodePos[nameToIndex[a]], sb=nodePos[nameToIndex[b]];
328
- const mx=(sa.x+sb.x)/2, my=(sa.y+sb.y)/2;
329
- const len=Math.sqrt((sa.x-sb.x)**2+(sa.y-sb.y)**2);
330
- const outward=Math.max(40,radius*0.28+len*0.12);
331
- const ndx=mx, ndy=my;
332
- const nlen=Math.sqrt(ndx*ndx+ndy*ndy)||1;
333
- const ux=ndx/nlen, uy=ndy/nlen;
334
- const cx=mx+ux*outward, cy=my+uy*outward;
335
- const path=`M ${sa.x} ${sa.y} Q ${cx} ${cy} ${sb.x} ${sb.y}`;
336
  loopG.append("path")
337
  .attr("d",path)
338
- .attr("fill","none")
339
  .attr("stroke","#227a6d")
 
340
  .attr("stroke-width",2.8)
341
  .attr("opacity",0.95);
342
  });
343
 
344
- // click -> highlight + full label
345
- function setOpacityFor(nodeName){
346
- group.selectAll("circle").style("opacity",d=>d.name===nodeName?1.0:0.18);
347
- group.selectAll("text")
348
- .style("opacity",d=>d.name===nodeName?1.0:0.28)
349
- .text(d=>d.name===nodeName?FULL_LABEL_JS[d.name]:SHORT_LABEL_JS[d.name]);
350
- buyG.selectAll("path").style("opacity",function(){
351
- return (this.getAttribute("data-src")===nodeName||
352
- this.getAttribute("data-tgt")===nodeName)?0.98:0.06;
353
- });
354
- sellG.selectAll("path").style("opacity",function(){
355
- return (this.getAttribute("data-src")===nodeName||
356
- this.getAttribute("data-tgt")===nodeName)?0.98:0.06;
357
- });
358
- trG.selectAll("path").style("opacity",function(){
359
- return (this.getAttribute("data-src")===nodeName||
360
- this.getAttribute("data-tgt")===nodeName)?0.98:0.06;
361
- });
362
  }
363
 
364
- function resetOpacity(){
365
- group.selectAll("circle").style("opacity",1.0);
366
- group.selectAll("text").style("opacity",1.0)
367
- .text(d=>SHORT_LABEL_JS[d.name]);
368
  buyG.selectAll("path").style("opacity",0.92);
369
  sellG.selectAll("path").style("opacity",0.86);
370
  trG.selectAll("path").style("opacity",0.7);
371
  loopG.selectAll("path").style("opacity",0.95);
372
  }
373
 
374
- group.selectAll("circle").on("click",function(e,d){
375
- setOpacityFor(d.name);
376
- e.stopPropagation();
377
- });
378
- group.selectAll("text").on("click",function(e,d){
379
- setOpacityFor(d.name);
380
- e.stopPropagation();
381
- });
382
 
383
- document.getElementById("arc-reset").onclick=resetOpacity;
384
- svg.on("click",()=>resetOpacity());
385
  }
386
 
387
  draw();
@@ -390,27 +385,27 @@ window.addEventListener("resize",draw);
390
  """
391
 
392
  # ---------------------------
393
- # Build final HTML
394
  # ---------------------------
395
- def make_arc_html(nodes, node_type, buys, sells, transfers, loops):
396
  html = JS_TEMPLATE
397
- html = html.replace("__NODES__", json.dumps(nodes))
398
- html = html.replace("__NODE_TYPE__", json.dumps(node_type))
399
- html = html.replace("__BUYS__", json.dumps(buys))
400
- html = html.replace("__SELLS__", json.dumps(sells))
401
- html = html.replace("__TRANSFERS__", json.dumps(transfers))
402
- html = html.replace("__LOOPS__", json.dumps(loops))
403
- html = html.replace("__SHORT_LABEL__", json.dumps(SHORT_LABEL))
404
  html = html.replace("__FULL_LABEL__", json.dumps(FULL_LABEL))
405
  return html
406
 
407
- initial_html = make_arc_html(NODES, NODE_TYPE, BUYS, SELLS, TRANSFERS, LOOPS)
408
 
409
  # ---------------------------
410
  # Gradio UI
411
  # ---------------------------
412
- with gr.Blocks(title="MF Churn Arc Diagram (Short→Full Label on Click)") as demo:
413
- gr.Markdown("## Mutual Fund Churn — Weighted Arc Diagram (Short labels → Full label on click)")
414
  gr.HTML(initial_html)
415
 
416
  gr.Markdown("### Inspect Company / AMC")
@@ -422,8 +417,8 @@ with gr.Blocks(title="MF Churn — Arc Diagram (Short→Full Label on Click)") a
422
  amc_plot = gr.Plot()
423
  amc_table = gr.DataFrame()
424
 
425
- select_company.change(company_trade_summary, inputs=[select_company], outputs=[company_plot, company_table])
426
- select_amc.change(amc_transfer_summary, inputs=[select_amc], outputs=[amc_plot, amc_table])
427
 
428
  if __name__ == "__main__":
429
- demo.launch()
 
1
  # app.py
2
+ # Mutual Fund Churn Static Weighted Arc Diagram
3
+ # Short labels inside nodes, full labels on click
4
+ # Fully corrected version (NO syntax errors)
5
 
6
  import gradio as gr
7
  import pandas as pd
 
64
  FRESH_BUY = sanitize_map(FRESH_BUY)
65
 
66
  # ---------------------------
67
+ # Short + Full Label Maps
68
  # ---------------------------
69
+ SHORT_LABELS = {
70
+ "SBI MF": "SBI", "ICICI Pru MF": "ICICI", "HDFC MF": "HDFC",
71
+ "Nippon India MF": "NIPP", "Kotak MF": "KOT", "UTI MF": "UTI",
72
+ "Axis MF": "AXIS", "Aditya Birla SL MF": "ABSL", "Mirae MF": "MIR",
 
 
 
 
 
 
73
  "DSP MF": "DSP",
74
 
75
+ "HDFC Bank": "HDFCB", "ICICI Bank": "ICICB",
76
+ "Bajaj Finance": "BajFin", "Bajaj Finserv": "BajFsv",
77
+ "Adani Ports": "AdPorts", "Tata Motors": "TataM",
78
+ "Shriram Finance": "ShrFin", "HAL": "HAL", "TCS": "TCS",
79
+ "AU Small Finance Bank": "AUSFB", "Pearl Global": "PearlG",
80
+ "Hindalco": "Hind", "Tata Elxsi": "Elxsi",
81
+ "Cummins India": "Cumm", "Vedanta": "Ved"
 
 
 
 
 
 
 
 
82
  }
83
 
84
+ FULL_LABEL = {k: k for k in SHORT_LABELS}
85
 
86
  # ---------------------------
87
  # Infer AMC→AMC transfers
 
90
  transfers = defaultdict(int)
91
  c2s = defaultdict(list)
92
  c2b = defaultdict(list)
93
+
94
  for amc, comps in sell_map.items():
95
+ for c in comps:
96
+ c2s[c].append(amc)
97
+
98
  for amc, comps in buy_map.items():
99
+ for c in comps:
100
+ c2b[c].append(amc)
101
+
102
  for c in set(c2s) | set(c2b):
103
  for s in c2s[c]:
104
  for b in c2b[c]:
105
  transfers[(s, b)] += 1
106
+
107
  return transfers
108
 
109
  TRANSFER_COUNTS = infer_amc_transfers(BUY_MAP, SELL_MAP)
110
 
111
  # ---------------------------
112
+ # Mixed ordering for layout
113
  # ---------------------------
114
  def build_mixed_ordering(amcs, companies):
115
+ out = []
116
+ N = max(len(amcs), len(companies))
117
+ for i in range(N):
118
+ if i < len(amcs): out.append(amcs[i])
119
+ if i < len(companies): out.append(companies[i])
120
+ return out
121
 
122
  NODES = build_mixed_ordering(AMCS, COMPANIES)
123
  NODE_TYPE = {n: ("amc" if n in AMCS else "company") for n in NODES}
 
127
  # ---------------------------
128
  def build_flows():
129
  buys, sells, transfers, loops = [], [], [], []
130
+
131
  for amc, comps in BUY_MAP.items():
132
  for c in comps:
133
+ w = 3 if c in FRESH_BUY.get(amc, []) else 1
134
+ buys.append((amc, c, w))
135
+
136
  for amc, comps in SELL_MAP.items():
137
  for c in comps:
138
+ w = 3 if c in COMPLETE_EXIT.get(amc, []) else 1
139
+ sells.append((c, amc, w))
140
+
141
+ for (s, b), w in TRANSFER_COUNTS.items():
142
+ transfers.append((s, b, w))
143
+
144
+ # loops (AMC → Company → AMC)
145
+ seen = set()
146
+ for a, c, _ in buys:
147
+ for c2, b, _ in sells:
148
+ if c == c2:
149
+ seen.add((a, c, b))
150
+ loops = list(seen)
151
+
152
  return buys, sells, transfers, loops
153
 
154
  BUYS, SELLS, TRANSFERS, LOOPS = build_flows()
 
157
  # Inspect panels
158
  # ---------------------------
159
  def company_trade_summary(company):
160
+ buyers = [a for a, cs in BUY_MAP.items() if company in cs]
161
+ sellers = [a for a, cs in SELL_MAP.items() if company in cs]
162
+ fresh = [a for a, cs in FRESH_BUY.items() if company in cs]
163
+ exits = [a for a, cs in COMPLETE_EXIT.items() if company in cs]
164
+
165
+ df = pd.DataFrame({
166
+ "Role": ["Buyer"] * len(buyers) +
167
+ ["Seller"] * len(sellers) +
168
+ ["Fresh buy"] * len(fresh) +
169
+ ["Complete exit"] * len(exits),
170
+ "AMC": buyers + sellers + fresh + exits
171
+ })
172
+
173
+ if df.empty:
174
+ return None, df
175
+
176
  counts = df.groupby("Role").size().reset_index(name="Count")
177
+ fig = {
178
+ "data": [{"type": "bar", "x": counts["Role"].tolist(), "y": counts["Count"].tolist()}],
179
+ "layout": {"title": f"Trades for {company}"}
180
+ }
181
+ return fig, df
182
+
183
 
184
  def amc_transfer_summary(amc):
185
+ sold = SELL_MAP.get(amc, [])
186
+ transfers = []
187
  for s in sold:
188
+ buyers = [a for a, cs in BUY_MAP.items() if s in cs]
189
+ for b in buyers:
190
+ transfers.append({"security": s, "buyer_amc": b})
191
+
192
+ df = pd.DataFrame(transfers)
193
+ if df.empty:
194
+ return None, df
195
+
196
+ counts = df["buyer_amc"].value_counts().reset_index()
197
+ counts.columns = ["Buyer AMC", "Count"]
198
+ fig = {
199
+ "data": [{"type": "bar", "x": counts["Buyer AMC"].tolist(), "y": counts["Count"].tolist()}],
200
+ "layout": {"title": f"Inferred transfers from {amc}"}
201
+ }
202
+ return fig, df
203
 
204
  # ---------------------------
205
+ # JavaScript Template (raw, safe)
206
  # ---------------------------
207
+ JS_TEMPLATE = r"""
208
  <div id="arc-container" style="width:100%; height:720px;"></div>
209
  <div style="margin-top:8px;">
210
  <button id="arc-reset" style="padding:8px 12px; border-radius:6px;">Reset</button>
211
  </div>
212
 
 
 
 
 
 
 
 
 
 
213
  <script src="https://d3js.org/d3.v7.min.js"></script>
214
+
215
  <script>
216
  const NODES = __NODES__;
217
  const NODE_TYPE = __NODE_TYPE__;
 
226
  function draw() {
227
  const container = document.getElementById("arc-container");
228
  container.innerHTML = "";
229
+
230
+ const w = Math.min(950, container.clientWidth || 850);
231
+ const h = Math.max(500, Math.floor(w * 0.75));
232
  const svg = d3.select(container).append("svg")
233
  .attr("width","100%")
234
  .attr("height",h)
 
236
 
237
  const radius = Math.min(w,h)*0.36;
238
 
239
+ const n = NODES.length;
240
+ function angle(i){ return (i/n)*2*Math.PI; }
241
+
242
+ const pos = NODES.map((name,i)=>{
243
+ const ang = angle(i) - Math.PI/2;
244
+ return {
245
+ name,
246
+ angle: ang,
247
+ x: Math.cos(ang)*radius,
248
+ y: Math.sin(ang)*radius
249
+ };
250
  });
 
 
251
 
252
+ const index = {};
253
+ NODES.forEach((n,i)=> index[n]=i );
254
+
255
+ // Node groups
256
+ const g = svg.append("g").selectAll("g")
257
+ .data(pos).enter().append("g")
258
+ .attr("transform",d=>`translate(${d.x},${d.y})`);
259
 
260
+ g.append("circle")
261
  .attr("r",16)
262
+ .style("fill",d=> NODE_TYPE[d.name]==="amc" ? "#2b6fa6" : "#f2c88d")
263
  .style("stroke","#222")
264
  .style("stroke-width",1)
265
  .style("cursor","pointer");
266
 
267
+ g.append("text")
268
+ .attr("dy","0.35em")
269
+ .style("font-size","9px")
270
+ .style("fill","#fff")
271
+ .style("text-anchor","middle")
272
+ .style("pointer-events","none")
273
+ .text(d=> SHORT_LABEL_JS[d.name] || d.name);
 
 
274
 
275
+ // Helper for arcs
276
+ function arcPath(x0,y0,x1,y1,above=true){
277
  const mx=(x0+x1)/2, my=(y0+y1)/2;
278
+ const len=Math.sqrt(mx*mx+my*my)||1;
279
+ const ux=mx/len, uy=my/len;
280
+ const offset=(above?-1:1)*Math.max(35,radius*0.9);
 
281
  const cx=mx+ux*offset, cy=my+uy*offset;
282
  return `M ${x0} ${y0} Q ${cx} ${cy} ${x1} ${y1}`;
283
  }
284
 
285
+ const allW = [].concat(
286
+ BUYS.map(d=>d[2]), SELLS.map(d=>d[2]), TRANSFERS.map(d=>d[2])
287
+ );
288
+ const sw = d3.scaleLinear().domain([1,Math.max(...allW,1)]).range([1.2,6]);
289
 
290
+ const buyG = svg.append("g");
291
+ BUYS.forEach(([a,c,w])=>{
292
+ if(!(a in index) || !(c in index)) return;
293
+ const s=pos[index[a]], t=pos[index[c]];
 
 
294
  buyG.append("path")
295
+ .attr("d",arcPath(s.x,s.y,t.x,t.y,true))
 
296
  .attr("stroke","#2e8540")
297
+ .attr("fill","none")
298
+ .attr("stroke-width",sw(w))
299
  .attr("data-src",a)
300
+ .attr("data-tgt",c)
301
+ .attr("opacity",0.92);
302
  });
303
 
304
+ const sellG = svg.append("g");
305
+ SELLS.forEach(([c,a,w])=>{
306
+ if(!(c in index) || !(a in index)) return;
307
+ const s=pos[index[c]], t=pos[index[a]];
 
 
308
  sellG.append("path")
309
+ .attr("d",arcPath(s.x,s.y,t.x,t.y,false))
 
310
  .attr("stroke","#c0392b")
311
+ .attr("fill","none")
312
  .attr("stroke-dasharray","4,3")
313
+ .attr("stroke-width",sw(w))
314
  .attr("data-src",c)
315
+ .attr("data-tgt",a)
316
+ .attr("opacity",0.86);
317
  });
318
 
319
+ const trG = svg.append("g");
320
+ TRANSFERS.forEach(([s,b,w])=>{
321
+ if(!(s in index)||!(b in index))return;
322
+ const sp=pos[index[s]], tp=pos[index[b]];
 
 
323
  const mx=(sp.x+tp.x)/2, my=(sp.y+tp.y)/2;
324
  const path=`M ${sp.x} ${sp.y} Q ${mx*0.3} ${my*0.3} ${tp.x} ${tp.y}`;
325
  trG.append("path")
326
  .attr("d",path)
 
327
  .attr("stroke","#7d7d7d")
328
+ .attr("fill","none")
329
+ .attr("stroke-width",sw(w))
330
  .attr("opacity",0.7)
331
  .attr("data-src",s)
332
  .attr("data-tgt",b);
333
  });
334
 
335
+ const loopG = svg.append("g");
336
+ LOOPS.forEach(([a,c,b])=>{
337
+ if(!(a in index)||!(b in index))return;
338
+ const s=pos[index[a]], t=pos[index[b]];
339
+ const mx=(s.x+t.x)/2, my=(s.y+t.y)/2;
340
+ const len=Math.sqrt((s.x-t.x)**2+(s.y-t.y)**2);
341
+ const offset=Math.max(40,radius*0.28+len*0.12);
342
+ const ux=mx/Math.sqrt(mx*mx+my*my), uy=my/Math.sqrt(mx*mx+my*my);
343
+ const cx=mx+ux*offset, cy=my+uy*offset;
344
+ const path=`M ${s.x} ${s.y} Q ${cx} ${cy} ${t.x} ${t.y}`;
 
 
 
 
345
  loopG.append("path")
346
  .attr("d",path)
 
347
  .attr("stroke","#227a6d")
348
+ .attr("fill","none")
349
  .attr("stroke-width",2.8)
350
  .attr("opacity",0.95);
351
  });
352
 
353
+ function highlight(node){
354
+ g.selectAll("circle").style("opacity",d=> d.name===node ? 1:0.18);
355
+ g.selectAll("text")
356
+ .style("opacity",d=> d.name===node ? 1:0.28)
357
+ .text(d=> d.name===node ? FULL_LABEL_JS[d.name] : SHORT_LABEL_JS[d.name]);
358
+
359
+ function match(el){ return el.getAttribute("data-src")===node || el.getAttribute("data-tgt")===node }
360
+
361
+ buyG.selectAll("path").style("opacity",function(){return match(this)?0.98:0.06});
362
+ sellG.selectAll("path").style("opacity",function(){return match(this)?0.98:0.06});
363
+ trG.selectAll("path").style("opacity",function(){return match(this)?0.98:0.06});
 
 
 
 
 
 
 
364
  }
365
 
366
+ function reset(){
367
+ g.selectAll("circle").style("opacity",1);
368
+ g.selectAll("text").style("opacity",1).text(d=>SHORT_LABEL_JS[d.name]);
 
369
  buyG.selectAll("path").style("opacity",0.92);
370
  sellG.selectAll("path").style("opacity",0.86);
371
  trG.selectAll("path").style("opacity",0.7);
372
  loopG.selectAll("path").style("opacity",0.95);
373
  }
374
 
375
+ g.selectAll("circle").on("click",function(e,d){ highlight(d.name); e.stopPropagation(); });
376
+ g.selectAll("text").on("click",function(e,d){ highlight(d.name); e.stopPropagation(); });
 
 
 
 
 
 
377
 
378
+ document.getElementById("arc-reset").onclick=reset;
379
+ svg.on("click",reset);
380
  }
381
 
382
  draw();
 
385
  """
386
 
387
  # ---------------------------
388
+ # Build HTML with replacements
389
  # ---------------------------
390
+ def make_arc_html():
391
  html = JS_TEMPLATE
392
+ html = html.replace("__NODES__", json.dumps(NODES))
393
+ html = html.replace("__NODE_TYPE__", json.dumps(NODE_TYPE))
394
+ html = html.replace("__BUYS__", json.dumps(BUYS))
395
+ html = html.replace("__SELLS__", json.dumps(SELLS))
396
+ html = html.replace("__TRANSFERS__", json.dumps(TRANSFERS))
397
+ html = html.replace("__LOOPS__", json.dumps(LOOPS))
398
+ html = html.replace("__SHORT_LABEL__", json.dumps(SHORT_LABELS))
399
  html = html.replace("__FULL_LABEL__", json.dumps(FULL_LABEL))
400
  return html
401
 
402
+ initial_html = make_arc_html()
403
 
404
  # ---------------------------
405
  # Gradio UI
406
  # ---------------------------
407
+ with gr.Blocks(title="MF Churn Arc Diagram") as demo:
408
+ gr.Markdown("## Mutual Fund Churn — Weighted Arc Diagram (short labels → full label on click)")
409
  gr.HTML(initial_html)
410
 
411
  gr.Markdown("### Inspect Company / AMC")
 
417
  amc_plot = gr.Plot()
418
  amc_table = gr.DataFrame()
419
 
420
+ select_company.change(company_trade_summary, select_company, [company_plot, company_table])
421
+ select_amc.change(amc_transfer_summary, select_amc, [amc_plot, amc_table])
422
 
423
  if __name__ == "__main__":
424
+ demo.launch()