Files changed (1) hide show
  1. app.py +337 -186
app.py CHANGED
@@ -1,17 +1,21 @@
1
  # app.py
2
- # MBB-style chord diagram (mixed node order) for Mutual Fund churn
3
- # Uses D3 chord layout in browser, static layout (no physics). Mobile-friendly.
 
 
 
 
 
4
 
5
  import gradio as gr
6
  import pandas as pd
7
- import networkx as nx
8
- import numpy as np
9
  import json
 
10
  from collections import defaultdict
11
 
12
- # -------------------------
13
- # DATA (same as before)
14
- # -------------------------
15
  AMCS = [
16
  "SBI MF", "ICICI Pru MF", "HDFC MF", "Nippon India MF", "Kotak MF",
17
  "UTI MF", "Axis MF", "Aditya Birla SL MF", "Mirae MF", "DSP MF"
@@ -63,9 +67,9 @@ SELL_MAP = sanitize_map(SELL_MAP)
63
  COMPLETE_EXIT = sanitize_map(COMPLETE_EXIT)
64
  FRESH_BUY = sanitize_map(FRESH_BUY)
65
 
66
- # -------------------------
67
- # Inferred AMC->AMC transfers (same heuristic)
68
- # -------------------------
69
  def infer_amc_transfers(buy_map, sell_map):
70
  transfers = defaultdict(int)
71
  c2s = defaultdict(list)
@@ -82,11 +86,11 @@ def infer_amc_transfers(buy_map, sell_map):
82
  transfers[(s,b)] += 1
83
  return transfers
84
 
85
- transfer_counts = infer_amc_transfers(BUY_MAP, SELL_MAP)
86
 
87
- # -------------------------
88
- # Build mixed ordering (AMC, company, AMC, company...)
89
- # -------------------------
90
  def build_mixed_ordering(amcs, companies):
91
  mixed = []
92
  n = max(len(amcs), len(companies))
@@ -98,69 +102,63 @@ def build_mixed_ordering(amcs, companies):
98
  return mixed
99
 
100
  NODES = build_mixed_ordering(AMCS, COMPANIES)
101
-
102
- # Node types map for styling
103
  NODE_TYPE = {n: ("amc" if n in AMCS else "company") for n in NODES}
104
 
105
- # -------------------------
106
- # Build flow matrix: nodes x nodes
107
- # Matrix interpretation:
108
- # - AMC -> Company for BUY
109
- # - Company -> AMC for SELL
110
- # - AMC -> AMC for inferred TRANSFER
111
- # Fresh buy and complete exit use higher weight
112
- # -------------------------
113
- def build_flow_matrix(nodes):
114
- idx = {n:i for i,n in enumerate(nodes)}
115
- n = len(nodes)
116
- M = [[0]*n for _ in range(n)]
117
-
118
- # buys: AMC -> Company
119
  for amc, comps in BUY_MAP.items():
120
  for c in comps:
121
- if amc in idx and c in idx:
122
- w = 1
123
- if amc in FRESH_BUY and c in FRESH_BUY.get(amc, []):
124
- w = 3
125
- M[idx[amc]][idx[c]] += w
126
-
127
- # sells: Company -> AMC
128
  for amc, comps in SELL_MAP.items():
129
  for c in comps:
130
- if amc in idx and c in idx:
131
- w = 1
132
- if amc in COMPLETE_EXIT and c in COMPLETE_EXIT.get(amc, []):
133
- w = 3
134
- # represent sell as company -> amc
135
- M[idx[c]][idx[amc]] += w
136
-
137
- # inferred transfers: AMC -> AMC
138
- for (s,b), w in transfer_counts.items():
139
- if s in idx and b in idx:
140
- M[idx[s]][idx[b]] += w
141
-
142
- return M
143
-
144
- MATRIX = build_flow_matrix(NODES)
145
-
146
- # -------------------------
147
- # Helper summaries (unchanged)
148
- # -------------------------
 
 
 
 
 
 
149
  def company_trade_summary(company):
150
  buyers = [a for a, cs in BUY_MAP.items() if company in cs]
151
  sellers = [a for a, cs in SELL_MAP.items() if company in cs]
152
  fresh = [a for a, cs in FRESH_BUY.items() if company in cs]
153
  exits = [a for a, cs in COMPLETE_EXIT.items() if company in cs]
154
  df = pd.DataFrame({
155
- "Role": (["Buyer"] * len(buyers)) + (["Seller"] * len(sellers)) + (["Fresh buy"] * len(fresh)) + (["Complete exit"] * len(exits)),
 
156
  "AMC": buyers + sellers + fresh + exits
157
  })
158
  if df.empty:
159
  return None, pd.DataFrame([], columns=["Role","AMC"])
160
  counts = df.groupby("Role").size().reset_index(name="Count")
161
- fig = {
162
- "data": [{"type":"bar","x": counts["Role"].tolist(), "y": counts["Count"].tolist()}],
163
- "layout":{"title":f"Trades for {company}"}
164
  }
165
  return fig, df
166
 
@@ -177,171 +175,324 @@ def amc_transfer_summary(amc):
177
  counts = df["buyer_amc"].value_counts().reset_index()
178
  counts.columns = ["Buyer AMC","Count"]
179
  fig = {
180
- "data": [{"type":"bar","x": counts["Buyer AMC"].tolist(), "y": counts["Count"].tolist()}],
181
- "layout":{"title":f"Inferred transfers from {amc}"}
182
  }
183
  return fig, df
184
 
185
- # -------------------------
186
- # Build HTML with D3 chord
187
- # -------------------------
188
- def make_chord_html(nodes, matrix, node_type):
189
  nodes_json = json.dumps(nodes)
190
- mat_json = json.dumps(matrix)
191
  types_json = json.dumps(node_type)
 
 
 
 
192
 
193
- # D3 chord diagram: mixed nodes around circle, modern palette
194
  html = f"""
195
- <div id="chord-container" style="width:100%; height:640px;"></div>
196
  <div style="margin-top:8px;">
197
- <button id="chord-reset" style="padding:8px 12px; border-radius:6px;">Reset</button>
198
  </div>
199
 
200
  <div style="margin-top:10px; font-family:sans-serif; font-size:13px;">
201
  <b>Legend</b><br/>
202
- <span style="display:inline-block;width:12px;height:12px;background:#2b6fa6;margin-right:6px;border-radius:2px;"></span> AMC nodes<br/>
203
- <span style="display:inline-block;width:12px;height:12px;background:#f2c88d;margin-right:6px;border-radius:2px;"></span> Company nodes<br/>
204
- <em style="color:#666;">Note: TRANSFER connections are inferred from simultaneous buys/sells, not explicitly reported.</em>
 
 
205
  </div>
206
 
207
  <script src="https://d3js.org/d3.v7.min.js"></script>
208
  <script>
209
- const NODE_NAMES = {nodes_json};
210
- const MATRIX = {mat_json};
211
  const NODE_TYPE = {types_json};
 
 
 
 
212
 
213
- // Dimensions responsive
214
- const container = document.getElementById("chord-container");
215
  function draw() {{
216
- container.innerHTML = ""; // clear
217
- const width = Math.min(900, container.clientWidth || 900);
218
- const height = Math.max(420, Math.min(700, Math.floor(width * 0.75)));
219
- const outerRadius = Math.min(width, height) * 0.45;
220
- const innerRadius = outerRadius * 0.86;
221
-
222
- const svg = d3.select(container)
223
- .append("svg")
224
  .attr("width", "100%")
225
- .attr("height", height)
226
- .attr("viewBox", [-width/2, -height/2, width, height].join(" "));
227
-
228
- // color scheme
229
- const colorNode = d => (NODE_TYPE[d] === "amc") ? "#2b6fa6" : "#f2c88d"; // muted blue and amber
230
- const chord = d3.chord()
231
- .padAngle(0.02)
232
- .sortSubgroups(d3.descending)
233
- (MATRIX);
234
-
235
- const arc = d3.arc()
236
- .innerRadius(innerRadius)
237
- .outerRadius(outerRadius + 6);
238
-
239
- const ribbon = d3.ribbon()
240
- .radius(innerRadius)
241
- .padAngle(0.01);
242
-
243
- // groups (outer arcs)
244
- const group = svg.append("g")
245
- .selectAll("g")
246
- .data(chord.groups)
247
- .enter().append("g")
248
- .attr("class","group");
249
-
250
- group.append("path")
251
- .style("fill", d => colorNode(NODE_NAMES[d.index]))
252
- .style("stroke", d => d3.color(colorNode(NODE_NAMES[d.index])).darker(0.6))
253
- .attr("d", arc)
254
- .attr("cursor","pointer")
255
- .on("click", (e,d) => focusNode(d.index));
256
-
257
- // labels
 
258
  group.append("text")
259
- .each(function(d) {{
260
- const name = NODE_NAMES[d.index];
261
- d.angle = (d.startAngle + d.endAngle) / 2;
262
- this._currentAngle = d.angle;
263
- }})
264
- .attr("dy", ".35em")
265
- .attr("transform", function(d) {{
266
- const angle = (d.startAngle + d.endAngle) / 2;
267
- const deg = angle * 180 / Math.PI - 90;
268
- const rotate = deg;
269
- const translate = outerRadius + 18;
270
- return "rotate(" + rotate + ") translate(" + translate + ")" + ( (deg > 90) ? " rotate(180)" : "" );
271
- }})
272
  .style("font-family", "sans-serif")
273
- .style("font-size", Math.max(10, Math.min(14, outerRadius*0.04)))
274
- .style("text-anchor", function(d) {{
275
- const angle = (d.startAngle + d.endAngle) / 2;
276
- const deg = angle * 180 / Math.PI - 90;
277
- return (deg > 90) ? "end" : "start";
278
- }})
279
- .text(d => NODE_NAMES[d.index]);
280
-
281
- // ribbons (flows)
282
- const ribbons = svg.append("g")
283
- .attr("class","ribbons")
284
- .selectAll("path")
285
- .data(chord)
286
- .enter().append("path")
287
- .attr("d", ribbon)
288
- .style("fill", d => colorNode(NODE_NAMES[d.source.index]) )
289
- .style("stroke", d => d3.color(colorNode(NODE_NAMES[d.source.index])).darker(0.6) )
290
- .style("opacity", 0.85)
291
- .on("mouseover", function(e, d) {{
292
- d3.select(this).transition().style("opacity", 1.0).style("filter","brightness(1.05)");
293
- }})
294
- .on("mouseout", function(e, d) {{
295
- d3.select(this).transition().style("opacity", 0.85).style("filter",null);
296
- }});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
 
298
- // interactivity: focus/hide
299
- function focusNode(index) {{
300
- // highlight groups and ribbons connected to index
301
- ribbons.transition().style("opacity", r => (r.source.index === index || r.target.index === index) ? 1.0 : 0.08);
302
- group.selectAll("path").transition().style("opacity", (g) => (g.index === index ? 1.0 : 0.4));
303
- group.selectAll("text").transition().style("opacity", (g) => (g.index === index ? 1.0 : 0.45));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  }}
305
 
306
- // reset function
307
- function resetView() {{
308
- ribbons.transition().style("opacity", 0.85);
309
- group.selectAll("path").transition().style("opacity", 1.0);
310
- group.selectAll("text").transition().style("opacity", 1.0);
 
 
311
  }}
312
 
313
- // click outside to reset
314
- svg.on("click", (event) => {{
315
- const target = event.target;
316
- if (target.tagName === "svg" || target.tagName === "g") {{
317
- resetView();
318
- }}
 
 
 
319
  }});
320
 
321
- // expose reset button
322
- document.getElementById("chord-reset").onclick = resetView;
323
 
324
- // responsive text sizing: done via font-size above
 
 
 
 
325
  }}
326
 
327
- // initial draw and redraw on resize
328
  draw();
329
- window.addEventListener("resize", () => {{
330
- draw();
331
- }});
332
  </script>
333
  """
334
  return html
335
 
336
- # -------------------------
337
- # Build Gradio app
338
- # -------------------------
339
- initial_html = make_chord_html(NODES, MATRIX, NODE_TYPE)
 
 
 
 
 
 
340
 
341
- with gr.Blocks(title="MBB-style chord diagram Mutual Fund churn") as demo:
342
- gr.Markdown("## Mutual Fund Churn — Chord Diagram (consulting-grade)")
343
  gr.HTML(initial_html)
344
- gr.Markdown("### Inspect Company / AMC (unchanged)")
 
345
  select_company = gr.Dropdown(choices=COMPANIES, label="Select company")
346
  company_plot = gr.Plot()
347
  company_table = gr.DataFrame()
 
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
 
 
12
  import json
13
+ 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",
21
  "UTI MF", "Axis MF", "Aditya Birla SL MF", "Mirae MF", "DSP MF"
 
67
  COMPLETE_EXIT = sanitize_map(COMPLETE_EXIT)
68
  FRESH_BUY = sanitize_map(FRESH_BUY)
69
 
70
+ # ---------------------------
71
+ # Infer AMC->AMC transfers
72
+ # ---------------------------
73
  def infer_amc_transfers(buy_map, sell_map):
74
  transfers = defaultdict(int)
75
  c2s = defaultdict(list)
 
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 = []
96
  n = max(len(amcs), len(companies))
 
102
  return mixed
103
 
104
  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]
148
  sellers = [a for a, cs in SELL_MAP.items() if company in cs]
149
  fresh = [a for a, cs in FRESH_BUY.items() if company in cs]
150
  exits = [a for a, cs in COMPLETE_EXIT.items() if company in cs]
151
  df = pd.DataFrame({
152
+ "Role": (["Buyer"] * len(buyers)) + (["Seller"] * len(sellers)) +
153
+ (["Fresh buy"] * len(fresh)) + (["Complete exit"] * len(exits)),
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
164
 
 
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>
198
  </div>
199
 
200
  <div style="margin-top:10px; font-family:sans-serif; font-size:13px;">
201
  <b>Legend</b><br/>
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);
222
+ const h = Math.max(420, Math.floor(w * 0.75));
223
+ const svg = d3.select(container).append("svg")
 
 
 
224
  .attr("width", "100%")
225
+ .attr("height", h)
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 ChurnSemi-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")
496
  select_company = gr.Dropdown(choices=COMPANIES, label="Select company")
497
  company_plot = gr.Plot()
498
  company_table = gr.DataFrame()