Files changed (1) hide show
  1. app.py +162 -213
app.py CHANGED
@@ -1,11 +1,6 @@
1
  # app.py
2
- # Static weighted semi-layer arc diagram (Option B)
3
- # - Top half: BUY arcs (AMC -> Company) (green solid)
4
- # - Bottom half: SELL arcs (Company -> AMC) (red dotted)
5
- # - Transfers: grey chords across center (inferred)
6
- # - Loops: external arc outside circle (highlight loops)
7
- # Interaction: click node -> highlight its flows; Reset button
8
- # Mobile-friendly; no D3 simulation.
9
 
10
  import gradio as gr
11
  import pandas as pd
@@ -14,7 +9,7 @@ import numpy as np
14
  from collections import defaultdict
15
 
16
  # ---------------------------
17
- # Data (same as before)
18
  # ---------------------------
19
  AMCS = [
20
  "SBI MF", "ICICI Pru MF", "HDFC MF", "Nippon India MF", "Kotak MF",
@@ -83,13 +78,13 @@ def infer_amc_transfers(buy_map, sell_map):
83
  for c in set(c2s.keys()) | set(c2b.keys()):
84
  for s in c2s[c]:
85
  for b in c2b[c]:
86
- transfers[(s,b)] += 1
87
  return transfers
88
 
89
  TRANSFER_COUNTS = infer_amc_transfers(BUY_MAP, SELL_MAP)
90
 
91
  # ---------------------------
92
- # Mixed ordering (reduce crossings)
93
  # ---------------------------
94
  def build_mixed_ordering(amcs, companies):
95
  mixed = []
@@ -105,43 +100,36 @@ NODES = build_mixed_ordering(AMCS, COMPANIES)
105
  NODE_TYPE = {n: ("amc" if n in AMCS else "company") for n in NODES}
106
 
107
  # ---------------------------
108
- # Build list of flows with weights
109
  # ---------------------------
110
  def build_flows():
111
- # BUY flows: (source_amc, target_company, weight, type)
112
  buys = []
113
  for amc, comps in BUY_MAP.items():
114
  for c in comps:
115
  w = 3 if (amc in FRESH_BUY and c in FRESH_BUY.get(amc, [])) else 1
116
  buys.append((amc, c, w))
117
- # SELL flows: (source_company, target_amc, weight)
118
  sells = []
119
  for amc, comps in SELL_MAP.items():
120
  for c in comps:
121
  w = 3 if (amc in COMPLETE_EXIT and c in COMPLETE_EXIT.get(amc, [])) else 1
122
  sells.append((c, amc, w))
123
- # transfers: amc -> amc (inferred)
124
  transfers = []
125
  for (s,b), w in TRANSFER_COUNTS.items():
126
  transfers.append((s, b, w))
127
- # loops: find AMC -> Company -> AMC where both buy & sell exist
128
  loops = []
129
- # map buys and sells for quick lookup
130
- buy_pairs = set((a,c) for a,c,_ in buys)
131
- sell_pairs = set((c,a) for c,a,_ in sells)
132
- for (a,c,w1) in buys:
133
- for (c2,b,w2) in sells:
134
  if c == c2:
135
- # loop: a -> c -> b
136
- loops.append((a, c, b, 1)) # weight of loop visual (1)
137
- # dedupe loops by tuple
138
- loops = list({(a,c,b) for (a,c,b,_) in loops})
139
  return buys, sells, transfers, loops
140
 
141
  BUYS, SELLS, TRANSFERS, LOOPS = build_flows()
142
 
143
  # ---------------------------
144
- # Helper summaries for inspectors
145
  # ---------------------------
146
  def company_trade_summary(company):
147
  buyers = [a for a, cs in BUY_MAP.items() if company in cs]
@@ -154,10 +142,10 @@ def company_trade_summary(company):
154
  "AMC": buyers + sellers + fresh + exits
155
  })
156
  if df.empty:
157
- return None, pd.DataFrame([], columns=["Role","AMC"])
158
  counts = df.groupby("Role").size().reset_index(name="Count")
159
- fig = go_bar = {
160
- "data": [{"type": "bar", "x": counts["Role"].tolist(), "y": counts["Count"].tolist()}],
161
  "layout": {"title": f"Trades for {company}"}
162
  }
163
  return fig, df
@@ -171,27 +159,19 @@ def amc_transfer_summary(amc):
171
  transfers.append({"security": s, "buyer_amc": b})
172
  df = pd.DataFrame(transfers)
173
  if df.empty:
174
- return None, pd.DataFrame([], columns=["security","buyer_amc"])
175
  counts = df["buyer_amc"].value_counts().reset_index()
176
- counts.columns = ["Buyer AMC","Count"]
177
  fig = {
178
- "data": [{"type": "bar", "x": counts["Buyer AMC"].tolist(), "y": counts["Count"].tolist()}],
179
  "layout": {"title": f"Inferred transfers from {amc}"}
180
  }
181
  return fig, df
182
 
183
  # ---------------------------
184
- # Make HTML + JS with D3 to draw arc layers
185
  # ---------------------------
186
- def make_arc_html(nodes, node_type, buys, sells, transfers, loops):
187
- nodes_json = json.dumps(nodes)
188
- types_json = json.dumps(node_type)
189
- buys_json = json.dumps(buys)
190
- sells_json = json.dumps(sells)
191
- transfers_json = json.dumps(transfers)
192
- loops_json = json.dumps(loops)
193
-
194
- html = f"""
195
  <div id="arc-container" style="width:100%; height:720px;"></div>
196
  <div style="margin-top:8px;">
197
  <button id="arc-reset" style="padding:8px 12px; border-radius:6px;">Reset</button>
@@ -202,20 +182,20 @@ def make_arc_html(nodes, node_type, buys, sells, transfers, loops):
202
  <span style="display:inline-block;width:12px;height:8px;background:#2e8540;margin-right:6px;"></span> BUY (green solid)<br/>
203
  <span style="display:inline-block;width:12px;height:8px;background:#c0392b;margin-right:6px;border-bottom:3px dotted #c0392b;"></span> SELL (red dotted)<br/>
204
  <span style="display:inline-block;width:12px;height:8px;background:#7d7d7d;margin-right:6px;"></span> TRANSFER (grey, inferred)<br/>
205
- <span style="display:inline-block;width:12px;height:8px;background:#227a6d;margin-right:6px;"></span> LOOP (external arc)
206
- <div style="margin-top:6px;color:#666;font-size:12px;">Note: Transfers are inferred based on matching sells & buys across AMCs. Numbers show relative weight.</div>
207
  </div>
208
 
209
  <script src="https://d3js.org/d3.v7.min.js"></script>
210
  <script>
211
- const NODES = {nodes_json};
212
- const NODE_TYPE = {types_json};
213
- const BUYS = {buys_json}; // [ [amc, company, weight], ... ]
214
- const SELLS = {sells_json}; // [ [company, amc, weight], ... ]
215
- const TRANSFERS = {transfers_json}; // [ [amc, amc, weight], ... ]
216
- const LOOPS = {loops_json}; // [ [amc, company, amc], ... ]
217
-
218
- function draw() {{
219
  const container = document.getElementById("arc-container");
220
  container.innerHTML = "";
221
  const w = Math.min(920, container.clientWidth || 820);
@@ -226,270 +206,239 @@ function draw() {{
226
  .attr("viewBox", [-w/2, -h/2, w, h].join(" "));
227
 
228
  const radius = Math.min(w, h) * 0.36;
229
- const outer = radius + 18;
230
- const center = {x:0, y:0};
231
 
232
- // compute node angles evenly around circle (mixed order), but we'll draw buys on top & sells below
233
  const n = NODES.length;
234
- const angleFor = (i) => ( (i / n) * 2 * Math.PI ); // full circle evenly
235
- const nodePos = NODES.map((name,i) => {{
236
- const angle = angleFor(i) - Math.PI/2; // start at top
237
- return {{
238
- name: name,
239
- angle: angle,
240
- x: Math.cos(angle) * radius,
241
- y: Math.sin(angle) * radius
242
- }};
243
- }});
244
-
245
- const nameToIndex = {{}};
246
- NODES.forEach((n,i)=>nameToIndex[n]=i);
247
-
248
- // Draw outer node arcs as small blocks (just visual)
249
  const group = svg.append("g").selectAll("g").data(nodePos).enter().append("g")
250
- .attr("transform", d => `translate(${d.x},${d.y}) rotate(${(d.angle*180/Math.PI)+90})`);
251
 
252
  group.append("circle")
253
- .attr("r", 18)
254
  .style("fill", d => NODE_TYPE[d.name] === "amc" ? "#2b6fa6" : "#f2c88d")
255
  .style("stroke", "#222")
256
- .style("stroke-width", 1);
 
257
 
258
- // labels (outside)
259
  group.append("text")
260
- .attr("x", d => (Math.cos(d.angle) * (radius + 28)))
261
- .attr("y", d => (Math.sin(d.angle) * (radius + 28)))
262
  .attr("dy", "0.35em")
263
  .style("font-family", "sans-serif")
264
- .style("font-size", Math.max(10, Math.min(14, radius*0.045)))
265
  .style("text-anchor", d => {
266
  const deg = (d.angle * 180 / Math.PI);
267
  return (deg > -90 && deg < 90) ? "start" : "end";
268
  })
269
  .attr("transform", d => {
270
  const deg = (d.angle * 180 / Math.PI);
271
- const rotate = deg;
272
  const flip = (deg > 90 || deg < -90) ? 180 : 0;
273
- return `rotate(${rotate}) translate(${radius + 28}) rotate(${flip})`;
274
  })
 
275
  .text(d => d.name);
276
 
277
- // helper: build a bezier path between two points with control y offset
278
- function bezierPath(x0,y0,x1,y1, curvature=0.7, above=true) {{
279
- // mid point
280
  const mx = (x0 + x1)/2;
281
  const my = (y0 + y1)/2;
282
- // control point offset outward from center to create arch
283
- const dx = mx - 0;
284
- const dy = my - 0;
285
- // normalize radial direction
286
  const len = Math.sqrt(dx*dx + dy*dy) || 1;
287
- const ux = dx/len;
288
- const uy = dy/len;
289
- const offset = (above ? -1 : 1) * Math.max(30, radius*curvature);
290
  const cx = mx + ux * offset;
291
  const cy = my + uy * offset;
292
  return `M ${x0} ${y0} Q ${cx} ${cy} ${x1} ${y1}`;
293
- }}
294
 
295
- // scale for stroke-width (weights)
296
- const allWeights = [].concat(BUYS.map(d=>d[2]), SELLS.map(d=>d[2]), TRANSFERS.map(d=>d[2]));
297
- const wmin = Math.min(...(allWeights.length?allWeights:[1]));
298
- const wmax = Math.max(...(allWeights.length?allWeights:[1]));
299
- const strokeScale = d3.scaleLinear().domain([wmin, Math.max(wmax,1)]).range([1.0, 6.0]);
300
 
301
- // --------------------
302
- // Draw BUY arcs (top half: above center)
303
- // --------------------
304
  const buyGroup = svg.append("g").attr("class","buys");
305
- BUYS.forEach(b => {{
306
- const [a, c, wt] = b;
307
  if (!(a in nameToIndex) || !(c in nameToIndex)) return;
308
  const s = nodePos[nameToIndex[a]];
309
  const t = nodePos[nameToIndex[c]];
310
- // draw only arc above center if midpoint y is < 0 => above; else we still force above
311
- const path = bezierPath(s.x, s.y, t.x, t.y, 0.9, true);
312
  buyGroup.append("path")
313
  .attr("d", path)
314
- .attr("fill", "none")
315
- .attr("stroke", "#2e8540")
316
- .attr("stroke-width", strokeScale(wt))
317
  .attr("stroke-linecap","round")
318
  .attr("opacity", 0.92)
319
  .attr("data-src", a)
320
  .attr("data-tgt", c)
321
- .attr("class","buy-link")
322
- .on("mouseover", function() {{ d3.select(this).attr("opacity",1).attr("stroke-width", +d3.select(this).attr("stroke-width")*1.2); }})
323
- .on("mouseout", function() {{ d3.select(this).attr("opacity",0.92).attr("stroke-width", +d3.select(this).attr("stroke-width")/1.2); }});
324
- }});
325
-
326
- // --------------------
327
- // Draw SELL arcs (bottom half: below center)
328
- // --------------------
329
  const sellGroup = svg.append("g").attr("class","sells");
330
- SELLS.forEach(sell => {{
331
- const [c, a, wt] = sell;
332
  if (!(c in nameToIndex) || !(a in nameToIndex)) return;
333
- const s = nodePos[nameToIndex[c]];
334
- const t = nodePos[nameToIndex[a]];
335
- const path = bezierPath(s.x, s.y, t.x, t.y, 0.9, false);
336
  sellGroup.append("path")
337
  .attr("d", path)
338
- .attr("fill", "none")
339
- .attr("stroke", "#c0392b")
340
- .attr("stroke-width", strokeScale(wt))
341
  .attr("stroke-linecap","round")
342
  .attr("stroke-dasharray","4,3")
343
- .attr("opacity", 0.86)
344
  .attr("data-src", c)
345
  .attr("data-tgt", a)
346
- .attr("class","sell-link")
347
- .on("mouseover", function() {{ d3.select(this).attr("opacity",1).attr("stroke-width", +d3.select(this).attr("stroke-width")*1.15); }})
348
- .on("mouseout", function() {{ d3.select(this).attr("opacity",0.86).attr("stroke-width", +d3.select(this).attr("stroke-width")/1.15); }});
349
- }});
350
-
351
- // --------------------
352
- // Transfers (grey chords across center) - arch going through center with smaller curvature
353
- // --------------------
354
  const transferGroup = svg.append("g").attr("class","transfers");
355
- TRANSFERS.forEach(tr => {{
356
- const [sname, tname, wt] = tr;
357
  if (!(sname in nameToIndex) || !(tname in nameToIndex)) return;
358
- const s = nodePos[nameToIndex[sname]];
359
- const t = nodePos[nameToIndex[tname]];
360
- // chord via center: control point near center
361
- const mx = (s.x + t.x)/2;
362
- const my = (s.y + t.y)/2;
363
- const cx = mx * 0.3; // pull control slightly toward center
364
- const cy = my * 0.3;
365
- const path = `M ${s.x} ${s.y} Q ${cx} ${cy} ${t.x} ${t.y}`;
366
  transferGroup.append("path")
367
  .attr("d", path)
368
- .attr("fill", "none")
369
- .attr("stroke", "#7d7d7d")
370
- .attr("stroke-width", strokeScale(wt))
371
- .attr("opacity", 0.7)
372
  .attr("data-src", sname)
373
  .attr("data-tgt", tname)
374
- .on("mouseover", function() {{ d3.select(this).attr("opacity",1); }})
375
- .on("mouseout", function() {{ d3.select(this).attr("opacity",0.7); }});
376
- }});
377
 
378
- // --------------------
379
- // Loops: external arcs (outside the circle)
380
- // --------------------
381
  const loopGroup = svg.append("g").attr("class","loops");
382
- LOOPS.forEach(lp => {{
383
- const [a, c, b] = lp;
384
- if (!(a in nameToIndex) || !(c in nameToIndex) || !(b in nameToIndex)) return;
385
  const sa = nodePos[nameToIndex[a]];
386
- const sc = nodePos[nameToIndex[c]];
387
  const sb = nodePos[nameToIndex[b]];
388
- // external arc connecting sa and sb that bows outward
389
  const mx = (sa.x + sb.x)/2;
390
  const my = (sa.y + sb.y)/2;
391
- const len = Math.sqrt((sa.x - sb.x)**2 + (sa.y - sb.y)**2);
392
- const outward = Math.max(40, radius * 0.28 + len * 0.15);
393
- // outward normal from center to midpoint
394
- const ndx = mx; const ndy = my;
395
  const nlen = Math.sqrt(ndx*ndx + ndy*ndy) || 1;
396
- const ux = ndx / nlen; const uy = ndy / nlen;
397
  const cx = mx + ux * outward;
398
  const cy = my + uy * outward;
399
  const path = `M ${sa.x} ${sa.y} Q ${cx} ${cy} ${sb.x} ${sb.y}`;
400
  loopGroup.append("path")
401
  .attr("d", path)
402
- .attr("fill","none")
403
- .attr("stroke","#227a6d")
404
  .attr("stroke-width", 2.8)
405
- .attr("opacity", 0.95)
406
- .on("mouseover", function() {{ d3.select(this).attr("opacity",1.0); }})
407
- .on("mouseout", function() {{ d3.select(this).attr("opacity",0.95); }});
408
- }});
409
-
410
- // --------------------
411
- // Interactivity: click a node to focus its connected flows
412
- // --------------------
413
- function setOpacityFor(nodeName) {{
414
- // nodes (circles)
415
  group.selectAll("circle").style("opacity", d => (d.name === nodeName ? 1.0 : 0.18));
416
  group.selectAll("text").style("opacity", d => (d.name === nodeName ? 1.0 : 0.28));
417
-
418
- // buys
419
- buyGroup.selectAll("path").style("opacity", function() {{
420
  const src = this.getAttribute("data-src");
421
  const tgt = this.getAttribute("data-tgt");
422
  return (src === nodeName || tgt === nodeName) ? 0.98 : 0.06;
423
- }});
424
- // sells
425
- sellGroup.selectAll("path").style("opacity", function() {{
426
  const src = this.getAttribute("data-src");
427
  const tgt = this.getAttribute("data-tgt");
428
  return (src === nodeName || tgt === nodeName) ? 0.98 : 0.06;
429
- }});
430
- // transfers
431
- transferGroup.selectAll("path").style("opacity", function() {{
432
  const src = this.getAttribute("data-src");
433
  const tgt = this.getAttribute("data-tgt");
434
  return (src === nodeName || tgt === nodeName) ? 0.98 : 0.06;
435
- }});
436
- // loops - highlight if nodeName is involved
437
- loopGroup.selectAll("path").style("opacity", function() {{
438
- // we can't attach data easily; use geometry check via stroke or leave always visible but dim
439
- return 0.95; // keep visible
440
- }});
441
- }}
442
-
443
- function resetOpacity() {{
444
  group.selectAll("circle").style("opacity", 1.0);
445
  group.selectAll("text").style("opacity", 1.0);
446
  buyGroup.selectAll("path").style("opacity", 0.92);
447
  sellGroup.selectAll("path").style("opacity", 0.86);
448
  transferGroup.selectAll("path").style("opacity", 0.7);
449
  loopGroup.selectAll("path").style("opacity", 0.95);
450
- }}
451
 
452
- // click handler on circles
453
- group.selectAll("circle").style("cursor", "pointer").on("click", function(e,d) {{
454
  setOpacityFor(d.name);
455
- d3.event && d3.event.stopPropagation && d3.event.stopPropagation();
456
- }});
457
- // click on label also focuses
458
- group.selectAll("text").style("cursor","pointer").on("click", function(e,d) {{
459
  setOpacityFor(d.name);
460
- d3.event && d3.event.stopPropagation && d3.event.stopPropagation();
461
- }});
462
 
463
- // reset button
464
  document.getElementById("arc-reset").onclick = resetOpacity;
465
-
466
- // click outside resets
467
- svg.on("click", function(event) {{
468
- // if clicked on background, reset
469
  if (event.target.tagName === "svg") resetOpacity();
470
- }});
471
- }}
472
 
473
- // initial draw and resize
474
  draw();
475
  window.addEventListener("resize", draw);
476
  </script>
477
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  return html
479
 
480
- # ---------------------------
481
- # Build Gradio UI
482
- # ---------------------------
483
  initial_html = make_arc_html(NODES, NODE_TYPE, BUYS, SELLS, TRANSFERS, LOOPS)
484
 
485
- # minimal CSS to keep mobile-friendly
 
 
486
  responsive_css = """
487
  #arc-container { padding:0; margin:0; }
488
  svg { font-family: sans-serif; }
489
  """
490
 
491
- with gr.Blocks(css=responsive_css, title="MF Churn — Semi-layer Arc Diagram") as demo:
492
- gr.Markdown("## Mutual Fund Churn — Weighted Arcs (BUY top / SELL bottom) — loops highlighted")
493
  gr.HTML(initial_html)
494
 
495
  gr.Markdown("### Inspect Company / AMC")
@@ -512,4 +461,4 @@ with gr.Blocks(css=responsive_css, title="MF Churn — Semi-layer Arc Diagram")
512
  select_amc.change(on_amc, inputs=[select_amc], outputs=[amc_plot, amc_table])
513
 
514
  if __name__ == "__main__":
515
- demo.launch()
 
1
  # app.py
2
+ # Static weighted semi-layer arc diagram (L1 labels outside)
3
+ # JS block injected safely (no Python f-strings inside JS)
 
 
 
 
 
4
 
5
  import gradio as gr
6
  import pandas as pd
 
9
  from collections import defaultdict
10
 
11
  # ---------------------------
12
+ # Data
13
  # ---------------------------
14
  AMCS = [
15
  "SBI MF", "ICICI Pru MF", "HDFC MF", "Nippon India MF", "Kotak MF",
 
78
  for c in set(c2s.keys()) | set(c2b.keys()):
79
  for s in c2s[c]:
80
  for b in c2b[c]:
81
+ transfers[(s, b)] += 1
82
  return transfers
83
 
84
  TRANSFER_COUNTS = infer_amc_transfers(BUY_MAP, SELL_MAP)
85
 
86
  # ---------------------------
87
+ # Mixed ordering to reduce crossings
88
  # ---------------------------
89
  def build_mixed_ordering(amcs, companies):
90
  mixed = []
 
100
  NODE_TYPE = {n: ("amc" if n in AMCS else "company") for n in NODES}
101
 
102
  # ---------------------------
103
+ # Build flows
104
  # ---------------------------
105
  def build_flows():
 
106
  buys = []
107
  for amc, comps in BUY_MAP.items():
108
  for c in comps:
109
  w = 3 if (amc in FRESH_BUY and c in FRESH_BUY.get(amc, [])) else 1
110
  buys.append((amc, c, w))
 
111
  sells = []
112
  for amc, comps in SELL_MAP.items():
113
  for c in comps:
114
  w = 3 if (amc in COMPLETE_EXIT and c in COMPLETE_EXIT.get(amc, [])) else 1
115
  sells.append((c, amc, w))
 
116
  transfers = []
117
  for (s,b), w in TRANSFER_COUNTS.items():
118
  transfers.append((s, b, w))
 
119
  loops = []
120
+ # loops: a -> c -> b
121
+ for a,c,w1 in buys:
122
+ for c2,b,w2 in sells:
 
 
123
  if c == c2:
124
+ loops.append((a, c, b))
125
+ # dedupe
126
+ loops = list({(a,c,b) for (a,c,b) in loops})
 
127
  return buys, sells, transfers, loops
128
 
129
  BUYS, SELLS, TRANSFERS, LOOPS = build_flows()
130
 
131
  # ---------------------------
132
+ # Inspector summaries
133
  # ---------------------------
134
  def company_trade_summary(company):
135
  buyers = [a for a, cs in BUY_MAP.items() if company in cs]
 
142
  "AMC": buyers + sellers + fresh + exits
143
  })
144
  if df.empty:
145
+ return None, pd.DataFrame([], columns=["Role", "AMC"])
146
  counts = df.groupby("Role").size().reset_index(name="Count")
147
+ fig = {
148
+ "data": [{"type":"bar", "x": counts["Role"].tolist(), "y": counts["Count"].tolist()}],
149
  "layout": {"title": f"Trades for {company}"}
150
  }
151
  return fig, df
 
159
  transfers.append({"security": s, "buyer_amc": b})
160
  df = pd.DataFrame(transfers)
161
  if df.empty:
162
+ return None, pd.DataFrame([], columns=["security", "buyer_amc"])
163
  counts = df["buyer_amc"].value_counts().reset_index()
164
+ counts.columns = ["Buyer AMC", "Count"]
165
  fig = {
166
+ "data": [{"type":"bar", "x": counts["Buyer AMC"].tolist(), "y": counts["Count"].tolist()}],
167
  "layout": {"title": f"Inferred transfers from {amc}"}
168
  }
169
  return fig, df
170
 
171
  # ---------------------------
172
+ # HTML template (JS inserted safely via replace)
173
  # ---------------------------
174
+ JS_TEMPLATE = """
 
 
 
 
 
 
 
 
175
  <div id="arc-container" style="width:100%; height:720px;"></div>
176
  <div style="margin-top:8px;">
177
  <button id="arc-reset" style="padding:8px 12px; border-radius:6px;">Reset</button>
 
182
  <span style="display:inline-block;width:12px;height:8px;background:#2e8540;margin-right:6px;"></span> BUY (green solid)<br/>
183
  <span style="display:inline-block;width:12px;height:8px;background:#c0392b;margin-right:6px;border-bottom:3px dotted #c0392b;"></span> SELL (red dotted)<br/>
184
  <span style="display:inline-block;width:12px;height:8px;background:#7d7d7d;margin-right:6px;"></span> TRANSFER (grey, inferred)<br/>
185
+ <span style="display:inline-block;width:12px;height:8px;background:#227a6d;margin-right:6px;"></span> LOOP (external arc)<br/>
186
+ <div style="margin-top:6px;color:#666;font-size:12px;">Note: Transfers are inferred by matching sells and buys across AMCs. Thickness shows relative weight.</div>
187
  </div>
188
 
189
  <script src="https://d3js.org/d3.v7.min.js"></script>
190
  <script>
191
+ const NODES = __NODES__ ;
192
+ const NODE_TYPE = __NODE_TYPE__ ;
193
+ const BUYS = __BUYS__ ;
194
+ const SELLS = __SELLS__ ;
195
+ const TRANSFERS = __TRANSFERS__ ;
196
+ const LOOPS = __LOOPS__ ;
197
+
198
+ function draw() {
199
  const container = document.getElementById("arc-container");
200
  container.innerHTML = "";
201
  const w = Math.min(920, container.clientWidth || 820);
 
206
  .attr("viewBox", [-w/2, -h/2, w, h].join(" "));
207
 
208
  const radius = Math.min(w, h) * 0.36;
 
 
209
 
210
+ // node positions around circle
211
  const n = NODES.length;
212
+ function angleFor(i) { return (i / n) * 2 * Math.PI; }
213
+ const nodePos = NODES.map((name,i) => {
214
+ const ang = angleFor(i) - Math.PI/2; // start at top
215
+ return { name: name, angle: ang, x: Math.cos(ang)*radius, y: Math.sin(ang)*radius };
216
+ });
217
+
218
+ const nameToIndex = {};
219
+ NODES.forEach((nm,i)=> nameToIndex[nm]=i);
220
+
221
+ // small node circles and labels outside
 
 
 
 
 
222
  const group = svg.append("g").selectAll("g").data(nodePos).enter().append("g")
223
+ .attr("transform", d => `translate(${d.x},${d.y})`);
224
 
225
  group.append("circle")
226
+ .attr("r", 16)
227
  .style("fill", d => NODE_TYPE[d.name] === "amc" ? "#2b6fa6" : "#f2c88d")
228
  .style("stroke", "#222")
229
+ .style("stroke-width", 1)
230
+ .style("cursor", "pointer");
231
 
 
232
  group.append("text")
233
+ .attr("x", d => Math.cos(d.angle) * (radius + 26))
234
+ .attr("y", d => Math.sin(d.angle) * (radius + 26))
235
  .attr("dy", "0.35em")
236
  .style("font-family", "sans-serif")
237
+ .style("font-size", Math.max(10, Math.min(14, radius*0.04)))
238
  .style("text-anchor", d => {
239
  const deg = (d.angle * 180 / Math.PI);
240
  return (deg > -90 && deg < 90) ? "start" : "end";
241
  })
242
  .attr("transform", d => {
243
  const deg = (d.angle * 180 / Math.PI);
 
244
  const flip = (deg > 90 || deg < -90) ? 180 : 0;
245
+ return `rotate(${deg}) translate(${radius + 26}) rotate(${flip})`;
246
  })
247
+ .style("cursor","pointer")
248
  .text(d => d.name);
249
 
250
+ // bezier helper
251
+ function bezierPath(x0,y0,x1,y1,above=true) {
 
252
  const mx = (x0 + x1)/2;
253
  const my = (y0 + y1)/2;
254
+ const dx = mx;
255
+ const dy = my;
 
 
256
  const len = Math.sqrt(dx*dx + dy*dy) || 1;
257
+ const ux = dx/len, uy = dy/len;
258
+ const offset = (above ? -1 : 1) * Math.max(30, radius*0.9);
 
259
  const cx = mx + ux * offset;
260
  const cy = my + uy * offset;
261
  return `M ${x0} ${y0} Q ${cx} ${cy} ${x1} ${y1}`;
262
+ }
263
 
264
+ // stroke width scale
265
+ const allW = [].concat(BUYS.map(d=>d[2]), SELLS.map(d=>d[2]), TRANSFERS.map(d=>d[2]));
266
+ const wmin = Math.min(...(allW.length?allW:[1]));
267
+ const wmax = Math.max(...(allW.length?allW:[1]));
268
+ const stroke = d3.scaleLinear().domain([wmin, Math.max(wmax,1)]).range([1.0, 6.0]);
269
 
270
+ // buys (top)
 
 
271
  const buyGroup = svg.append("g").attr("class","buys");
272
+ BUYS.forEach(b => {
273
+ const a = b[0], c = b[1], wt = b[2];
274
  if (!(a in nameToIndex) || !(c in nameToIndex)) return;
275
  const s = nodePos[nameToIndex[a]];
276
  const t = nodePos[nameToIndex[c]];
277
+ const path = bezierPath(s.x,s.y,t.x,t.y,true);
 
278
  buyGroup.append("path")
279
  .attr("d", path)
280
+ .attr("fill","none")
281
+ .attr("stroke","#2e8540")
282
+ .attr("stroke-width", stroke(wt))
283
  .attr("stroke-linecap","round")
284
  .attr("opacity", 0.92)
285
  .attr("data-src", a)
286
  .attr("data-tgt", c)
287
+ .on("mouseover", function() { d3.select(this).attr("opacity",1); })
288
+ .on("mouseout", function() { d3.select(this).attr("opacity",0.92); });
289
+ });
290
+
291
+ // sells (bottom)
 
 
 
292
  const sellGroup = svg.append("g").attr("class","sells");
293
+ SELLS.forEach(s => {
294
+ const c = s[0], a = s[1], wt = s[2];
295
  if (!(c in nameToIndex) || !(a in nameToIndex)) return;
296
+ const sp = nodePos[nameToIndex[c]];
297
+ const tp = nodePos[nameToIndex[a]];
298
+ const path = bezierPath(sp.x,sp.y,tp.x,tp.y,false);
299
  sellGroup.append("path")
300
  .attr("d", path)
301
+ .attr("fill","none")
302
+ .attr("stroke","#c0392b")
303
+ .attr("stroke-width", stroke(wt))
304
  .attr("stroke-linecap","round")
305
  .attr("stroke-dasharray","4,3")
306
+ .attr("opacity",0.86)
307
  .attr("data-src", c)
308
  .attr("data-tgt", a)
309
+ .on("mouseover", function() { d3.select(this).attr("opacity",1); })
310
+ .on("mouseout", function() { d3.select(this).attr("opacity",0.86); });
311
+ });
312
+
313
+ // transfers (grey chords)
 
 
 
314
  const transferGroup = svg.append("g").attr("class","transfers");
315
+ TRANSFERS.forEach(tr => {
316
+ const sname = tr[0], tname = tr[1], wt = tr[2];
317
  if (!(sname in nameToIndex) || !(tname in nameToIndex)) return;
318
+ const sp = nodePos[nameToIndex[sname]];
319
+ const tp = nodePos[nameToIndex[tname]];
320
+ const mx = (sp.x + tp.x)/2;
321
+ const my = (sp.y + tp.y)/2;
322
+ const cx = mx * 0.3, cy = my * 0.3;
323
+ const path = `M ${sp.x} ${sp.y} Q ${cx} ${cy} ${tp.x} ${tp.y}`;
 
 
324
  transferGroup.append("path")
325
  .attr("d", path)
326
+ .attr("fill","none")
327
+ .attr("stroke","#7d7d7d")
328
+ .attr("stroke-width", stroke(wt))
329
+ .attr("opacity",0.7)
330
  .attr("data-src", sname)
331
  .attr("data-tgt", tname)
332
+ .on("mouseover", function() { d3.select(this).attr("opacity",1); })
333
+ .on("mouseout", function() { d3.select(this).attr("opacity",0.7); });
334
+ });
335
 
336
+ // loops (external arcs)
 
 
337
  const loopGroup = svg.append("g").attr("class","loops");
338
+ LOOPS.forEach(lp => {
339
+ const a = lp[0], c = lp[1], b = lp[2];
340
+ if (!(a in nameToIndex) || !(b in nameToIndex)) return;
341
  const sa = nodePos[nameToIndex[a]];
 
342
  const sb = nodePos[nameToIndex[b]];
 
343
  const mx = (sa.x + sb.x)/2;
344
  const my = (sa.y + sb.y)/2;
345
+ const len = Math.sqrt((sa.x - sb.x)*(sa.x - sb.x) + (sa.y - sb.y)*(sa.y - sb.y));
346
+ const outward = Math.max(40, radius*0.28 + len * 0.12);
347
+ const ndx = mx, ndy = my;
 
348
  const nlen = Math.sqrt(ndx*ndx + ndy*ndy) || 1;
349
+ const ux = ndx/nlen, uy = ndy/nlen;
350
  const cx = mx + ux * outward;
351
  const cy = my + uy * outward;
352
  const path = `M ${sa.x} ${sa.y} Q ${cx} ${cy} ${sb.x} ${sb.y}`;
353
  loopGroup.append("path")
354
  .attr("d", path)
355
+ .attr("fill", "none")
356
+ .attr("stroke", "#227a6d")
357
  .attr("stroke-width", 2.8)
358
+ .attr("opacity",0.95)
359
+ .on("mouseover", function() { d3.select(this).attr("opacity",1); })
360
+ .on("mouseout", function() { d3.select(this).attr("opacity",0.95); });
361
+ });
362
+
363
+ // interactivity: focus on node
364
+ function setOpacityFor(nodeName) {
 
 
 
365
  group.selectAll("circle").style("opacity", d => (d.name === nodeName ? 1.0 : 0.18));
366
  group.selectAll("text").style("opacity", d => (d.name === nodeName ? 1.0 : 0.28));
367
+ buyGroup.selectAll("path").style("opacity", function() {
 
 
368
  const src = this.getAttribute("data-src");
369
  const tgt = this.getAttribute("data-tgt");
370
  return (src === nodeName || tgt === nodeName) ? 0.98 : 0.06;
371
+ });
372
+ sellGroup.selectAll("path").style("opacity", function() {
 
373
  const src = this.getAttribute("data-src");
374
  const tgt = this.getAttribute("data-tgt");
375
  return (src === nodeName || tgt === nodeName) ? 0.98 : 0.06;
376
+ });
377
+ transferGroup.selectAll("path").style("opacity", function() {
 
378
  const src = this.getAttribute("data-src");
379
  const tgt = this.getAttribute("data-tgt");
380
  return (src === nodeName || tgt === nodeName) ? 0.98 : 0.06;
381
+ });
382
+ loopGroup.selectAll("path").style("opacity", 0.95);
383
+ }
384
+
385
+ function resetOpacity() {
 
 
 
 
386
  group.selectAll("circle").style("opacity", 1.0);
387
  group.selectAll("text").style("opacity", 1.0);
388
  buyGroup.selectAll("path").style("opacity", 0.92);
389
  sellGroup.selectAll("path").style("opacity", 0.86);
390
  transferGroup.selectAll("path").style("opacity", 0.7);
391
  loopGroup.selectAll("path").style("opacity", 0.95);
392
+ }
393
 
394
+ group.selectAll("circle").style("cursor","pointer").on("click", function(e,d) {
 
395
  setOpacityFor(d.name);
396
+ if (e && e.stopPropagation) e.stopPropagation();
397
+ });
398
+ group.selectAll("text").style("cursor","pointer").on("click", function(e,d) {
 
399
  setOpacityFor(d.name);
400
+ if (e && e.stopPropagation) e.stopPropagation();
401
+ });
402
 
 
403
  document.getElementById("arc-reset").onclick = resetOpacity;
404
+ svg.on("click", function(event) {
 
 
 
405
  if (event.target.tagName === "svg") resetOpacity();
406
+ });
407
+ }
408
 
 
409
  draw();
410
  window.addEventListener("resize", draw);
411
  </script>
412
  """
413
+
414
+ def make_arc_html(nodes, node_type, buys, sells, transfers, loops):
415
+ # prepare JSON strings
416
+ nodes_json = json.dumps(nodes)
417
+ node_type_json = json.dumps(node_type)
418
+ buys_json = json.dumps(buys)
419
+ sells_json = json.dumps(sells)
420
+ transfers_json = json.dumps(transfers)
421
+ loops_json = json.dumps(loops)
422
+ html = JS_TEMPLATE.replace("__NODES__", nodes_json) \
423
+ .replace("__NODE_TYPE__", node_type_json) \
424
+ .replace("__BUYS__", buys_json) \
425
+ .replace("__SELLS__", sells_json) \
426
+ .replace("__TRANSFERS__", transfers_json) \
427
+ .replace("__LOOPS__", loops_json)
428
  return html
429
 
 
 
 
430
  initial_html = make_arc_html(NODES, NODE_TYPE, BUYS, SELLS, TRANSFERS, LOOPS)
431
 
432
+ # ---------------------------
433
+ # Gradio UI
434
+ # ---------------------------
435
  responsive_css = """
436
  #arc-container { padding:0; margin:0; }
437
  svg { font-family: sans-serif; }
438
  """
439
 
440
+ with gr.Blocks(css=responsive_css, title="MF Churn — Semi-layer Arc Diagram (L1 labels)") as demo:
441
+ gr.Markdown("## Mutual Fund Churn — Weighted Arcs (BUY top / SELL bottom) — labels outside (L1)")
442
  gr.HTML(initial_html)
443
 
444
  gr.Markdown("### Inspect Company / AMC")
 
461
  select_amc.change(on_amc, inputs=[select_amc], outputs=[amc_plot, amc_table])
462
 
463
  if __name__ == "__main__":
464
+ demo.launch()