singhn9 commited on
Commit
663b7d1
·
verified ·
1 Parent(s): 4a85219

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +242 -172
app.py CHANGED
@@ -1,16 +1,28 @@
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
8
  import json
9
- import numpy as np
 
10
  from collections import defaultdict
11
 
12
  # ---------------------------
13
- # Data
 
 
 
 
 
 
 
 
14
  # ---------------------------
15
  AMCS = [
16
  "SBI MF", "ICICI Pru MF", "HDFC MF", "Nippon India MF", "Kotak MF",
@@ -64,7 +76,7 @@ COMPLETE_EXIT = sanitize_map(COMPLETE_EXIT)
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",
@@ -80,7 +92,6 @@ SHORT_LABELS = {
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
  # ---------------------------
@@ -90,34 +101,30 @@ def infer_amc_transfers(buy_map, sell_map):
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,82 +134,58 @@ 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()
155
 
156
  # ---------------------------
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>
@@ -210,8 +193,16 @@ JS_TEMPLATE = r"""
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__;
@@ -223,169 +214,223 @@ const LOOPS = __LOOPS__;
223
  const SHORT_LABEL_JS = __SHORT_LABEL__;
224
  const FULL_LABEL_JS = __FULL_LABEL__;
225
 
 
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)
235
  .attr("viewBox",[-w/2,-h/2,w,h].join(" "));
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();
383
- window.addEventListener("resize",draw);
384
  </script>
385
  """
386
 
387
  # ---------------------------
388
- # Build HTML with replacements
389
  # ---------------------------
390
  def make_arc_html():
391
  html = JS_TEMPLATE
@@ -399,26 +444,51 @@ def make_arc_html():
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")
412
- select_company = gr.Dropdown(COMPANIES, label="Select company")
413
- company_plot = gr.Plot()
414
- company_table = gr.DataFrame()
415
-
416
- select_amc = gr.Dropdown(AMCS, label="Select 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()
 
1
  # app.py
2
+ # Final gated Arc Diagram app
3
+ # - Email + Code gateway (code: CHATGPTROCKS)
4
+ # - Logs success & failures to session.log
5
+ # - Radial outward full labels on click + connected nodes show full labels
6
+ # - High-contrast colors, legend restored
7
+ # - No JS f-string escaping issues (JS injected via .replace)
8
 
9
  import gradio as gr
10
  import pandas as pd
11
  import json
12
+ import time
13
+ import os
14
  from collections import defaultdict
15
 
16
  # ---------------------------
17
+ # Config
18
+ # ---------------------------
19
+ import os
20
+ ACCESS_CODE = os.getenv("ACCESS_CODE", "")
21
+ LOG_FILE = "session.log" # will be created/used in repo root
22
+ os.makedirs(os.path.dirname(LOG_FILE) or ".", exist_ok=True)
23
+
24
+ # ---------------------------
25
+ # Data (same source)
26
  # ---------------------------
27
  AMCS = [
28
  "SBI MF", "ICICI Pru MF", "HDFC MF", "Nippon India MF", "Kotak MF",
 
76
  FRESH_BUY = sanitize_map(FRESH_BUY)
77
 
78
  # ---------------------------
79
+ # Short + full labels
80
  # ---------------------------
81
  SHORT_LABELS = {
82
  "SBI MF": "SBI", "ICICI Pru MF": "ICICI", "HDFC MF": "HDFC",
 
92
  "Hindalco": "Hind", "Tata Elxsi": "Elxsi",
93
  "Cummins India": "Cumm", "Vedanta": "Ved"
94
  }
 
95
  FULL_LABEL = {k: k for k in SHORT_LABELS}
96
 
97
  # ---------------------------
 
101
  transfers = defaultdict(int)
102
  c2s = defaultdict(list)
103
  c2b = defaultdict(list)
 
104
  for amc, comps in sell_map.items():
105
  for c in comps:
106
  c2s[c].append(amc)
 
107
  for amc, comps in buy_map.items():
108
  for c in comps:
109
  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
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
  # ---------------------------
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 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 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
  seen = set()
148
  for a, c, _ in buys:
149
  for c2, b, _ in sells:
150
  if c == c2:
151
  seen.add((a, c, b))
152
  loops = list(seen)
 
153
  return buys, sells, transfers, loops
154
 
155
  BUYS, SELLS, TRANSFERS, LOOPS = build_flows()
156
 
157
  # ---------------------------
158
+ # Logging helper
159
  # ---------------------------
160
+ def write_log(timestamp, email, code, ua, ok):
161
+ # mask code in logs for security? we'll log full code as requested; you can mask if needed
162
+ status = "OK" if ok else "FAIL"
163
+ line = f"{timestamp} | {status} | email: {email} | code: {code} | ua: {ua}\n"
164
+ try:
165
+ with open(LOG_FILE, "a") as f:
166
+ f.write(line)
167
+ except Exception as e:
168
+ print("Log write failed:", e)
169
+
170
+ # ---------------------------
171
+ # Gate callback
172
+ # ---------------------------
173
+ def check_access(email, code, ua):
174
+ now = time.strftime("%Y-%m-%d %H:%M:%S")
175
+ if (not email) or (not code):
176
+ write_log(now, email or "<empty>", code or "<empty>", ua or "<ua?>", False)
177
+ return gr.update(visible=True), gr.update(visible=False), "Please provide both email and code."
178
+ if code.strip() == ACCESS_CODE:
179
+ # success: log and reveal app
180
+ write_log(now, email, code, ua or "<ua?>", True)
181
+ return gr.update(visible=False), gr.update(visible=True), f"Access granted for {email}."
182
+ else:
183
+ write_log(now, email, code, ua or "<ua?>", False)
184
+ return gr.update(visible=True), gr.update(visible=False), "Invalid code."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
 
186
  # ---------------------------
187
+ # JS Template for arc diagram
188
+ # (raw string; placeholders replaced with .replace())
189
  # ---------------------------
190
  JS_TEMPLATE = r"""
191
  <div id="arc-container" style="width:100%; height:720px;"></div>
 
193
  <button id="arc-reset" style="padding:8px 12px; border-radius:6px;">Reset</button>
194
  </div>
195
 
196
+ <div style="margin-top:10px; font-family:sans-serif; font-size:13px;">
197
+ <b>Legend</b><br/>
198
+ <span style="display:inline-block;width:12px;height:8px;background:#1a9850;margin-right:6px;"></span> BUY (green solid)<br/>
199
+ <span style="display:inline-block;width:12px;height:8px;background:#d73027;margin-right:6px;border-bottom:3px dotted #d73027;"></span> SELL (red dotted)<br/>
200
+ <span style="display:inline-block;width:12px;height:8px;background:#636363;margin-right:6px;"></span> TRANSFER (grey, inferred)<br/>
201
+ <span style="display:inline-block;width:12px;height:8px;background:#1f9e89;margin-right:6px;"></span> LOOP (teal external arc)<br/>
202
+ <div style="margin-top:6px;color:#666;font-size:12px;">Click node to expand full label and show connected nodes' labels (radial outward).</div>
203
+ </div>
204
 
205
+ <script src="https://d3js.org/d3.v7.min.js"></script>
206
  <script>
207
  const NODES = __NODES__;
208
  const NODE_TYPE = __NODE_TYPE__;
 
214
  const SHORT_LABEL_JS = __SHORT_LABEL__;
215
  const FULL_LABEL_JS = __FULL_LABEL__;
216
 
217
+ // draw routine
218
  function draw() {
219
  const container = document.getElementById("arc-container");
220
  container.innerHTML = "";
221
+ const w = Math.min(980, container.clientWidth || 860);
222
+ const h = Math.max(540, 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 n = NODES.length;
230
  function angle(i){ return (i/n)*2*Math.PI; }
231
 
232
  const pos = NODES.map((name,i)=>{
233
  const ang = angle(i) - Math.PI/2;
234
+ return { name, angle: ang, x: Math.cos(ang)*radius, y: Math.sin(ang)*radius };
 
 
 
 
 
235
  });
236
 
237
  const index = {};
238
+ NODES.forEach((nm,i)=> index[nm]=i);
239
 
240
+ // group for nodes
241
  const g = svg.append("g").selectAll("g")
242
  .data(pos).enter().append("g")
243
+ .attr("transform", d => `translate(${d.x},${d.y})`);
244
 
245
  g.append("circle")
246
+ .attr("r", 16)
247
+ .style("fill", d => NODE_TYPE[d.name] === "amc" ? "#003f5c" : "#f59e0b")
248
+ .style("stroke", "#111")
249
+ .style("stroke-width", 1)
250
  .style("cursor","pointer");
251
 
252
+ // short labels inside
253
  g.append("text")
254
+ .attr("dy", "0.35em")
255
+ .style("font-size", "10px")
256
+ .style("fill", "#fff")
257
+ .style("text-anchor", "middle")
258
+ .style("pointer-events", "none")
259
+ .text(d => SHORT_LABEL_JS[d.name] || d.name);
260
+
261
+ // helper bezier
262
  function arcPath(x0,y0,x1,y1,above=true){
263
  const mx=(x0+x1)/2, my=(y0+y1)/2;
264
  const len=Math.sqrt(mx*mx+my*my)||1;
265
  const ux=mx/len, uy=my/len;
266
+ const offset=(above?-1:1)*Math.max(36, radius*0.9);
267
  const cx=mx+ux*offset, cy=my+uy*offset;
268
  return `M ${x0} ${y0} Q ${cx} ${cy} ${x1} ${y1}`;
269
  }
270
 
271
+ const allW = [].concat(BUYS.map(d=>d[2]), SELLS.map(d=>d[2]), TRANSFERS.map(d=>d[2]));
272
+ const sw = d3.scaleLinear().domain([1, Math.max(...allW,1)]).range([1.2,6]);
 
 
273
 
274
+ // BUY arcs (top)
275
  const buyG = svg.append("g");
276
  BUYS.forEach(([a,c,w])=>{
277
  if(!(a in index) || !(c in index)) return;
278
+ const s = pos[index[a]], t = pos[index[c]];
279
  buyG.append("path")
280
+ .attr("d", arcPath(s.x,s.y,t.x,t.y,true))
281
+ .attr("stroke","#1a9850")
282
  .attr("fill","none")
283
+ .attr("stroke-width", sw(w))
284
+ .attr("data-src", a)
285
+ .attr("data-tgt", c)
286
+ .attr("opacity", 0.92);
287
  });
288
 
289
+ // SELL arcs (bottom)
290
  const sellG = svg.append("g");
291
  SELLS.forEach(([c,a,w])=>{
292
  if(!(c in index) || !(a in index)) return;
293
+ const s = pos[index[c]], t = pos[index[a]];
294
  sellG.append("path")
295
+ .attr("d", arcPath(s.x,s.y,t.x,t.y,false))
296
+ .attr("stroke","#d73027")
297
  .attr("fill","none")
298
  .attr("stroke-dasharray","4,3")
299
+ .attr("stroke-width", sw(w))
300
+ .attr("data-src", c)
301
+ .attr("data-tgt", a)
302
+ .attr("opacity", 0.86);
303
  });
304
 
305
+ // TRANSFERS grey chords
306
  const trG = svg.append("g");
307
  TRANSFERS.forEach(([s,b,w])=>{
308
+ if(!(s in index) || !(b in index)) return;
309
+ const sp = pos[index[s]], tp = pos[index[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("stroke", "#636363")
315
  .attr("fill","none")
316
+ .attr("stroke-width", sw(w))
317
+ .attr("opacity", 0.7)
318
+ .attr("data-src", s)
319
+ .attr("data-tgt", b);
320
  });
321
 
322
+ // LOOPS outside
323
  const loopG = svg.append("g");
324
  LOOPS.forEach(([a,c,b])=>{
325
+ if(!(a in index) || !(b in index)) return;
326
+ const sa = pos[index[a]], sb = pos[index[b]];
327
+ const mx = (sa.x + sb.x)/2, my=(sa.y+sb.y)/2;
328
+ const len = Math.sqrt((sa.x-sb.x)**2 + (sa.y-sb.y)**2);
329
+ const outward = Math.max(40, radius*0.28 + len*0.12);
330
+ const ndx = mx, ndy = my;
331
+ const nlen = Math.sqrt(ndx*ndx + ndy*ndy) || 1;
332
+ const ux = ndx/nlen, uy = ndy/nlen;
333
+ const cx = mx + ux*outward, cy = my + uy*outward;
334
+ const path = `M ${sa.x} ${sa.y} Q ${cx} ${cy} ${sb.x} ${sb.y}`;
335
  loopG.append("path")
336
+ .attr("d", path)
337
+ .attr("stroke", "#1f9e89")
338
  .attr("fill","none")
339
+ .attr("stroke-width", 2.8)
340
+ .attr("opacity", 0.95);
341
  });
342
 
343
+ // outside label layer (initially empty)
344
+ const outsideLabelLayer = svg.append("g").attr("class","outside-labels");
345
+
346
+ // helper to place radial outside labels
347
+ function placeOutsideLabels(names){
348
+ outsideLabelLayer.selectAll("*").remove();
349
+ names.forEach(nm => {
350
+ const p = pos[index[nm]];
351
+ const offset = radius + 30;
352
+ const x = p.x + Math.cos(p.angle) * offset;
353
+ const y = p.y + Math.sin(p.angle) * offset;
354
+ const deg = (p.angle * 180 / Math.PI);
355
+ const anchor = (deg > -90 && deg < 90) ? "start" : "end";
356
+ outsideLabelLayer.append("text")
357
+ .attr("x", x)
358
+ .attr("y", y)
359
+ .attr("dy", "0.35em")
360
+ .text(FULL_LABEL_JS[nm] || nm)
361
+ .style("font-family","sans-serif")
362
+ .style("font-size","12px")
363
+ .style("fill","#0b1220")
364
+ .style("text-anchor", anchor)
365
+ .style("background","white");
366
+ });
367
+ }
368
 
369
+ // highlight behavior: show full label for node and its connected nodes
370
+ function highlight(nodeName){
371
+ // dim non-related nodes
372
+ g.selectAll("circle").style("opacity", d => d.name === nodeName ? 1.0 : 0.18);
373
+ // set inside short labels to less prominent
374
+ g.selectAll("text").style("opacity", d => d.name === nodeName ? 0.0 : 0.28);
375
+
376
+ // find connected nodes (sources/targets)
377
+ const connected = new Set([nodeName]);
378
+ buyG.selectAll("path").each(function(){
379
+ const s = this.getAttribute("data-src"), t = this.getAttribute("data-tgt");
380
+ if(s===nodeName || t===nodeName){ connected.add(s); connected.add(t); }
381
+ });
382
+ sellG.selectAll("path").each(function(){
383
+ const s = this.getAttribute("data-src"), t = this.getAttribute("data-tgt");
384
+ if(s===nodeName || t===nodeName){ connected.add(s); connected.add(t); }
385
+ });
386
+ trG.selectAll("path").each(function(){
387
+ const s = this.getAttribute("data-src"), t = this.getAttribute("data-tgt");
388
+ if(s===nodeName || t===nodeName){ connected.add(s); connected.add(t); }
389
+ });
390
+
391
+ // show outside labels for all connected nodes (including clicked)
392
+ placeOutsideLabels(Array.from(connected));
393
+
394
+ // fade arcs except the relevant ones
395
+ buyG.selectAll("path").style("opacity", function(){
396
+ const s = this.getAttribute("data-src"), t = this.getAttribute("data-tgt");
397
+ return (s===nodeName || t===nodeName) ? 0.98 : 0.06;
398
+ });
399
+ sellG.selectAll("path").style("opacity", function(){
400
+ const s = this.getAttribute("data-src"), t = this.getAttribute("data-tgt");
401
+ return (s===nodeName || t===nodeName) ? 0.98 : 0.06;
402
+ });
403
+ trG.selectAll("path").style("opacity", function(){
404
+ const s = this.getAttribute("data-src"), t = this.getAttribute("data-tgt");
405
+ return (s===nodeName || t===nodeName) ? 0.98 : 0.06;
406
+ });
407
  }
408
 
409
  function reset(){
410
+ g.selectAll("circle").style("opacity",1.0);
411
+ g.selectAll("text").style("opacity",1.0).text(d => SHORT_LABEL_JS[d.name] || d.name);
412
  buyG.selectAll("path").style("opacity",0.92);
413
  sellG.selectAll("path").style("opacity",0.86);
414
  trG.selectAll("path").style("opacity",0.7);
415
  loopG.selectAll("path").style("opacity",0.95);
416
+ outsideLabelLayer.selectAll("*").remove();
417
  }
418
 
419
+ g.selectAll("circle").on("click", function(e,d){ highlight(d.name); e.stopPropagation(); });
420
+ g.selectAll("text").on("click", function(e,d){ highlight(d.name); e.stopPropagation(); });
421
 
422
+ document.getElementById("arc-reset").onclick = reset;
423
+ svg.on("click", function(){ reset(); });
424
  }
425
 
426
+ // initial draw & responsive
427
  draw();
428
+ window.addEventListener("resize", draw);
429
  </script>
430
  """
431
 
432
  # ---------------------------
433
+ # make html with replacements
434
  # ---------------------------
435
  def make_arc_html():
436
  html = JS_TEMPLATE
 
444
  html = html.replace("__FULL_LABEL__", json.dumps(FULL_LABEL))
445
  return html
446
 
447
+ ARC_HTML = make_arc_html()
448
 
449
  # ---------------------------
450
+ # Gradio UI with gate
451
  # ---------------------------
452
+ with gr.Blocks(title="MF Churn Gated Arc Diagram") as demo:
453
+ gr.Markdown("## Mutual Fund Churn — Access Controlled")
454
+ # Gate container
455
+ with gr.Row(visible=True) as gate_row:
456
+ with gr.Column():
457
+ gr.Markdown("**Enter your email and access code**")
458
+ email_input = gr.Textbox(label="Email", placeholder="you@example.com")
459
+ code_input = gr.Textbox(label="Access code", placeholder="Enter access code")
460
+ ua_hidden = gr.Textbox(value="", visible=False, elem_id="ua_field")
461
+ submit_btn = gr.Button("Unlock")
462
+ gate_status = gr.Markdown("")
463
+ # small JS to capture user-agent into hidden field
464
+ gr.HTML("<script>document.getElementById('ua_field').value = navigator.userAgent || ''; </script>")
465
+
466
+ # App container hidden until auth
467
+ with gr.Column(visible=False) as app_col:
468
+ gr.Markdown("### Diagram (click a node to expand labels)")
469
+ arc_html = gr.HTML(ARC_HTML)
470
+ gr.Markdown("---")
471
+ gr.Markdown("### Inspect Company / AMC")
472
+ select_company = gr.Dropdown(choices=COMPANIES, label="Select company")
473
+ company_plot = gr.Plot()
474
+ company_table = gr.DataFrame()
475
+ select_amc = gr.Dropdown(choices=AMCS, label="Select AMC")
476
+ amc_plot = gr.Plot()
477
+ amc_table = gr.DataFrame()
478
+
479
+ # Wire up gate
480
+ submit_btn.click(fn=check_access,
481
+ inputs=[email_input, code_input, ua_hidden],
482
+ outputs=[gate_row, app_col, gate_status])
483
+
484
+ # inspectors unchanged
485
+ def company_cb(c):
486
+ return company_trade_summary(c) if c else (None, pd.DataFrame([], columns=["Role","AMC"]))
487
+ def amc_cb(a):
488
+ return amc_transfer_summary(a) if a else (None, pd.DataFrame([], columns=["security","buyer_amc"]))
489
+
490
+ select_company.change(fn=company_cb, inputs=[select_company], outputs=[company_plot, company_table])
491
+ select_amc.change(fn=amc_cb, inputs=[select_amc], outputs=[amc_plot, amc_table])
492
 
493
  if __name__ == "__main__":
494
  demo.launch()