Files changed (1) hide show
  1. app.py +128 -150
app.py CHANGED
@@ -1,6 +1,7 @@
 
1
  # app.py
2
  # D3 physics (client-side) + Plotly visualization for MF churn explorer
3
- # Option A: Replace Python layout with D3 force simulation in browser
4
  # Requirements: gradio, networkx, plotly, pandas, numpy
5
 
6
  import gradio as gr
@@ -66,7 +67,7 @@ COMPLETE_EXIT = sanitize_map(COMPLETE_EXIT)
66
  FRESH_BUY = sanitize_map(FRESH_BUY)
67
 
68
  # ---------------------------
69
- # GRAPH BUILDING
70
  # ---------------------------
71
  company_edges = []
72
  for amc, comps in BUY_MAP.items():
@@ -98,10 +99,10 @@ def infer_amc_transfers(buy_map, sell_map):
98
  for s in sellers:
99
  for b in buyers:
100
  transfers[(s,b)] += 1
101
- edge_list = []
102
  for (s,b), w in transfers.items():
103
- edge_list.append((s,b, {"action": "transfer", "weight": w}))
104
- return edge_list
105
 
106
  transfer_edges = infer_amc_transfers(BUY_MAP, SELL_MAP)
107
 
@@ -111,25 +112,23 @@ def build_graph(include_transfers=True):
111
  G.add_node(a, type="amc")
112
  for c in COMPANIES:
113
  G.add_node(c, type="company")
114
- for u, v, attrs in company_edges:
115
- if u in G.nodes and v in G.nodes:
116
- if G.has_edge(u, v):
117
- G[u][v]["weight"] += attrs.get("weight",1)
118
- G[u][v]["actions"].append(attrs["action"])
119
- else:
120
- G.add_edge(u, v, weight=attrs.get("weight",1), actions=[attrs["action"]])
121
  if include_transfers:
122
- for s,b,attrs in transfer_edges:
123
- if s in G.nodes and b in G.nodes:
124
- if G.has_edge(s,b):
125
- G[s][b]["weight"] += attrs.get("weight",1)
126
- G[s][b]["actions"].append("transfer")
127
- else:
128
- G.add_edge(s,b,weight=attrs.get("weight",1), actions=["transfer"])
129
  return G
130
 
131
  # ---------------------------
132
- # Build Plotly figure (positions will be set by D3 in browser)
133
  # ---------------------------
134
  def build_plotly_figure(G,
135
  node_color_amc="#9EC5FF",
@@ -138,34 +137,29 @@ def build_plotly_figure(G,
138
  edge_color_sell="#d62728",
139
  edge_color_transfer="#888888",
140
  edge_thickness_base=1.4):
141
- # For D3 we don't need Python positions. Use zeros placeholder
142
  node_names = []
143
  node_x = []
144
  node_y = []
145
  node_color = []
146
  node_size = []
147
-
148
- for n, d in G.nodes(data=True):
149
  node_names.append(n)
150
- node_x.append(0.0)
151
- node_y.append(0.0)
152
- if d["type"] == "amc":
153
  node_color.append(node_color_amc); node_size.append(36)
154
  else:
155
  node_color.append(node_color_company); node_size.append(56)
156
-
157
  edge_traces = []
158
- edge_source_index = []
159
- edge_target_index = []
160
  edge_colors = []
161
  edge_widths = []
162
- for u, v, attrs in G.edges(data=True):
163
- # placeholder coordinates, will be updated by D3
164
  edge_traces.append(go.Scatter(x=[0,0], y=[0,0], mode="lines",
165
- line=dict(color="#888", width=1), hoverinfo="none", opacity=1.0))
166
- edge_source_index.append(node_names.index(u))
167
- edge_target_index.append(node_names.index(v))
168
- acts = attrs.get("actions", [])
169
  weight = attrs.get("weight",1)
170
  if "complete_exit" in acts:
171
  edge_colors.append(edge_color_sell); edge_widths.append(edge_thickness_base*3)
@@ -177,168 +171,161 @@ def build_plotly_figure(G,
177
  edge_colors.append(edge_color_sell); edge_widths.append(edge_thickness_base*(1+np.log1p(weight)))
178
  else:
179
  edge_colors.append(edge_color_buy); edge_widths.append(edge_thickness_base*(1+np.log1p(weight)))
180
-
181
  node_trace = go.Scatter(x=node_x, y=node_y, mode="markers+text",
182
- marker=dict(color=node_color, size=node_size, line=dict(width=2, color="#222")),
183
  text=node_names, textposition="top center", hoverinfo="text")
184
-
185
  fig = go.Figure(data=edge_traces + [node_trace])
186
  fig.update_layout(showlegend=False, autosize=True,
187
- margin=dict(l=8, r=8, t=36, b=8),
188
- xaxis=dict(visible=False), yaxis=dict(visible=False))
189
  meta = {
190
  "node_names": node_names,
191
- "edge_source_index": edge_source_index,
192
- "edge_target_index": edge_target_index,
193
  "edge_colors": edge_colors,
194
  "edge_widths": edge_widths,
195
- "node_colors": node_color,
196
  "node_sizes": node_size
197
  }
198
  return fig, meta
199
 
200
- def make_network_html_d3(fig, meta, div_id="network-plot-div"):
201
- """
202
- Build HTML embedding Plotly figure and D3 physics logic.
203
- Important: all { and } inside the JS template below are doubled {{ }} so f-string stays valid.
204
- """
205
  fig_json = json.dumps(fig.to_plotly_json())
206
  meta_json = json.dumps(meta)
207
-
208
  html = f"""
209
- <div id="{div_id}" style="width:100%;height:520px;"></div>
210
  <div style="margin-top:6px;margin-bottom:8px;">
211
  <button id="{div_id}-reset" style="padding:8px 12px;border-radius:6px;">Reset view</button>
212
  <button id="{div_id}-stop" style="padding:8px 12px;border-radius:6px;margin-left:8px;">Stop layout</button>
213
  </div>
214
 
215
- <!-- load libs -->
216
  <script src="https://d3js.org/d3.v7.min.js"></script>
217
  <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
218
 
219
  <script>
220
- // Embed figure and metadata
221
  const fig = {fig_json};
222
  const meta = {meta_json};
223
-
224
- // create plot
225
  const container = document.getElementById("{div_id}");
226
  Plotly.newPlot(container, fig.data, fig.layout, {{responsive:true}});
227
 
228
- // indices
229
  const nodeTraceIndex = fig.data.length - 1;
230
  const edgeCount = fig.data.length - 1;
231
 
232
- // build nodes array for D3
233
- const nodes = meta.node_names.map((n, i) => {{
234
- return {{id: i, name: n, r: meta.node_sizes[i] || 20}};
235
- }});
236
-
237
- // build links array
238
- const links = meta.edge_source_index.map((s, i) => {{
239
- return {{source: s, target: meta.edge_target_index[i], color: meta.edge_colors[i], width: meta.edge_widths[i] || 1}};
240
- }});
241
 
242
- // D3 force simulation parameters tuned for mobile friendliness
243
  const simulation = d3.forceSimulation(nodes)
244
- .force("link", d3.forceLink(links).id(d => d.id).distance(130).strength(0.4))
245
- .force("charge", d3.forceManyBody().strength(-60))
246
- .force("collision", d3.forceCollide().radius(d => d.r * 0.6))
247
  .force("center", d3.forceCenter(0,0))
248
- .velocityDecay(0.45);
 
 
 
 
 
 
 
 
249
 
250
- // Keep track of whether to keep sim running
251
- let stopSimulation = false;
252
- let lastTickTime = Date.now();
253
- let frameSkip = 0;
254
 
255
- // throttle Plotly updates: update every N ticks for performance
256
- let tickCounter = 0;
257
  simulation.on("tick", () => {{
258
- tickCounter++;
259
- // throttle updates - every 2 ticks (adjustable)
260
- if (tickCounter % 2 !== 0) return;
261
-
262
- // update node coordinates arrays
263
- const xs = nodes.map(n => n.x || 0);
264
- const ys = nodes.map(n => n.y || 0);
265
-
266
- // update node trace position
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  Plotly.restyle(container, {{ 'x': [xs], 'y': [ys] }}, [nodeTraceIndex]);
268
 
269
- // update each edge trace
270
  for (let e = 0; e < edgeCount; e++) {{
271
  const sIdx = meta.edge_source_index[e];
272
  const tIdx = meta.edge_target_index[e];
273
- const sx = nodes[sIdx].x || 0;
274
- const sy = nodes[sIdx].y || 0;
275
- const tx = nodes[tIdx].x || 0;
276
- const ty = nodes[tIdx].y || 0;
277
  Plotly.restyle(container, {{ 'x': [[sx, tx]], 'y': [[sy, ty]] }}, [e]);
 
 
278
  }}
279
 
280
- // stop the simulation gracefully after it's cooled
281
- if (simulation.alpha() < 0.03 || stopSimulation) {{
282
  simulation.stop();
283
  }}
284
  }});
285
 
286
- // allow explicit stop
287
  document.getElementById("{div_id}-stop").addEventListener('click', () => {{
288
- stopSimulation = true;
289
  }});
290
 
291
- // Map node name -> index for click focus
292
  const nameToIndex = {{}};
293
- meta.node_names.forEach((n,i) => nameToIndex[n] = i);
294
 
295
- // focusNode: hides everything except node + neighbors
296
  function focusNode(nodeName) {{
297
  const idx = nameToIndex[nodeName];
298
- const keepSet = new Set([idx]);
299
- // find neighbors
300
  for (let e = 0; e < meta.edge_source_index.length; e++) {{
301
- const s = meta.edge_source_index[e];
302
- const t = meta.edge_target_index[e];
303
- if (s === idx) keepSet.add(t);
304
- if (t === idx) keepSet.add(s);
305
  }}
306
 
307
- // node opacity and label colors
308
  const N = meta.node_names.length;
309
  const nodeOp = Array(N).fill(0.0);
310
  const textColors = Array(N).fill("rgba(0,0,0,0)");
311
- for (let i = 0; i < N; i++) {{
312
- if (keepSet.has(i)) {{
313
- nodeOp[i] = 1.0;
314
- textColors[i] = "black";
315
- }}
316
  }}
317
- Plotly.restyle(container, {{
318
- "marker.opacity": [nodeOp],
319
- "textfont.color": [textColors]
320
- }}, [nodeTraceIndex]);
321
 
322
- // edges: show only those connecting kept nodes
323
- for (let e = 0; e < edgeCount; e++) {{
324
- const s = meta.edge_source_index[e];
325
- const t = meta.edge_target_index[e];
326
- const show = keepSet.has(s) && keepSet.has(t);
327
  const color = show ? meta.edge_colors[e] : 'rgba(0,0,0,0)';
328
  const width = show ? meta.edge_widths[e] : 0.1;
329
  Plotly.restyle(container, {{ 'line.color': [color], 'line.width': [width] }}, [e]);
330
  }}
331
 
332
- // zoom to bounding box of kept nodes
333
  const nodesTrace = fig.data[nodeTraceIndex];
334
  const xs = [], ys = [];
335
- for (let j = 0; j < meta.node_names.length; j++) {{
336
- if (keepSet.has(j)) {{
337
- xs.push(nodesTrace.x[j]);
338
- ys.push(nodesTrace.y[j]);
339
- }}
340
  }}
341
- if (xs.length > 0) {{
342
  const xmin = Math.min(...xs), xmax = Math.max(...xs);
343
  const ymin = Math.min(...ys), ymax = Math.max(...ys);
344
  const padX = (xmax - xmin) * 0.4 + 10;
@@ -347,26 +334,23 @@ function focusNode(nodeName) {{
347
  }}
348
  }}
349
 
350
- // reset view function: restore everything and restart a short simulation to settle
351
  function resetView() {{
352
  const N = meta.node_names.length;
353
  const nodeOp = Array(N).fill(1.0);
354
  const textColors = Array(N).fill("black");
355
  Plotly.restyle(container, {{ "marker.opacity": [nodeOp], "textfont.color": [textColors] }}, [nodeTraceIndex]);
356
-
357
- for (let e = 0; e < edgeCount; e++) {{
358
  Plotly.restyle(container, {{ 'line.color': [meta.edge_colors[e]], 'line.width': [meta.edge_widths[e]] }}, [e]);
359
  }}
360
- // autorange
361
  Plotly.relayout(container, {{ xaxis: {{autorange:true}}, yaxis: {{autorange:true}} }});
362
-
363
- // restart a short simulation to re-space nodes
364
- stopSimulation = false;
365
- simulation.alpha(0.6);
366
  simulation.restart();
367
  }}
368
 
369
- // click handler: only react if node trace clicked
370
  container.on('plotly_click', function(eventData) {{
371
  const p = eventData.points[0];
372
  if (p.curveNumber === nodeTraceIndex) {{
@@ -376,17 +360,16 @@ container.on('plotly_click', function(eventData) {{
376
  }}
377
  }});
378
 
379
- // reset button hookup
380
  document.getElementById("{div_id}-reset").addEventListener('click', function() {{
381
  resetView();
382
  }});
383
-
384
  </script>
385
  """
386
  return html
387
 
388
  # ---------------------------
389
- # Company / AMC inspection helpers (unchanged)
390
  # ---------------------------
391
  def company_trade_summary(company_name):
392
  buyers = [a for a, comps in BUY_MAP.items() if company_name in comps]
@@ -421,7 +404,7 @@ def amc_transfer_summary(amc_name):
421
  return fig, df
422
 
423
  # ---------------------------
424
- # Build the initial HTML (Plotly + D3)
425
  # ---------------------------
426
  def build_network_html(node_color_company="#FFCF9E", node_color_amc="#9EC5FF",
427
  edge_color_buy="#2ca02c", edge_color_sell="#d62728",
@@ -434,29 +417,27 @@ def build_network_html(node_color_company="#FFCF9E", node_color_amc="#9EC5FF",
434
  edge_color_sell=edge_color_sell,
435
  edge_color_transfer=edge_color_transfer,
436
  edge_thickness_base=edge_thickness)
437
- return make_network_html_d3(fig, meta)
438
 
439
  initial_html = build_network_html()
440
 
441
  # ---------------------------
442
- # Mobile CSS and Gradio UI
443
  # ---------------------------
444
  responsive_css = """
445
  .gradio-container { padding:0 !important; margin:0 !important; }
446
  .plotly-graph-div, .js-plotly-plot, .output_plot { width:100% !important; max-width:100% !important; }
447
- .js-plotly-plot { height:460px !important; }
448
- @media(max-width:780px){ .js-plotly-plot{ height:420px !important; } }
449
  body, html { overflow-x:hidden !important; }
450
  """
451
 
452
- with gr.Blocks(css=responsive_css, title="MF Churn Explorer (D3 physics)") as demo:
453
- gr.Markdown("## Mutual Fund Churn Explorer — D3 force-directed layout (mobile friendly)")
454
 
455
- # interactive chart (HTML block)
456
  network_html = gr.HTML(value=initial_html)
457
 
458
- # Legend (updated with inferred note)
459
- legend_html = gr.HTML(value="""
460
  <div style='font-family:sans-serif;font-size:14px;margin-top:10px;line-height:1.6;'>
461
  <b>Legend</b><br>
462
  <div><span style="display:inline-block;width:28px;border-bottom:3px solid #2ca02c;"></span> BUY (green solid)</div>
@@ -465,9 +446,8 @@ with gr.Blocks(css=responsive_css, title="MF Churn Explorer (D3 physics)") as de
465
  <div><span style="display:inline-block;width:28px;border-bottom:5px solid #2ca02c;"></span> FRESH BUY (thick green)</div>
466
  <div><span style="display:inline-block;width:28px;border-bottom:5px solid #d62728;"></span> COMPLETE EXIT (thick red)</div>
467
  </div>
468
- """)
469
 
470
- # Controls (unchanged)
471
  with gr.Accordion("Network Customization — expand to edit", open=False):
472
  node_color_company = gr.ColorPicker("#FFCF9E", label="Company node color")
473
  node_color_amc = gr.ColorPicker("#9EC5FF", label="AMC node color")
@@ -478,7 +458,6 @@ with gr.Blocks(css=responsive_css, title="MF Churn Explorer (D3 physics)") as de
478
  include_transfers = gr.Checkbox(value=True, label="Show AMC→AMC inferred transfers")
479
  update_button = gr.Button("Update Network Graph")
480
 
481
- # Company & AMC inspect (unchanged)
482
  gr.Markdown("### Inspect Company (buyers / sellers)")
483
  select_company = gr.Dropdown(choices=COMPANIES, label="Select company")
484
  company_plot = gr.Plot()
@@ -489,7 +468,6 @@ with gr.Blocks(css=responsive_css, title="MF Churn Explorer (D3 physics)") as de
489
  amc_plot = gr.Plot()
490
  amc_table = gr.DataFrame()
491
 
492
- # Callbacks
493
  def update_network_html(node_color_company_val, node_color_amc_val,
494
  edge_color_buy_val, edge_color_sell_val, edge_color_transfer_val,
495
  edge_thickness_val, include_transfers_val):
 
1
+
2
  # app.py
3
  # D3 physics (client-side) + Plotly visualization for MF churn explorer
4
+ # Liquid "gel" motion (viscous, slow, ooze-like) - Option L2
5
  # Requirements: gradio, networkx, plotly, pandas, numpy
6
 
7
  import gradio as gr
 
67
  FRESH_BUY = sanitize_map(FRESH_BUY)
68
 
69
  # ---------------------------
70
+ # BUILD GRAPH
71
  # ---------------------------
72
  company_edges = []
73
  for amc, comps in BUY_MAP.items():
 
99
  for s in sellers:
100
  for b in buyers:
101
  transfers[(s,b)] += 1
102
+ out = []
103
  for (s,b), w in transfers.items():
104
+ out.append((s,b, {"action":"transfer","weight":w}))
105
+ return out
106
 
107
  transfer_edges = infer_amc_transfers(BUY_MAP, SELL_MAP)
108
 
 
112
  G.add_node(a, type="amc")
113
  for c in COMPANIES:
114
  G.add_node(c, type="company")
115
+ for u,v,attr in company_edges:
116
+ if G.has_edge(u,v):
117
+ G[u][v]["weight"] += attr["weight"]
118
+ G[u][v]["actions"].append(attr["action"])
119
+ else:
120
+ G.add_edge(u,v,weight=attr["weight"], actions=[attr["action"]])
 
121
  if include_transfers:
122
+ for s,b,attr in transfer_edges:
123
+ if G.has_edge(s,b):
124
+ G[s][b]["weight"] += attr["weight"]
125
+ G[s][b]["actions"].append("transfer")
126
+ else:
127
+ G.add_edge(s,b,weight=attr["weight"], actions=["transfer"])
 
128
  return G
129
 
130
  # ---------------------------
131
+ # BUILD PLOTLY FIGURE (placeholders for positions)
132
  # ---------------------------
133
  def build_plotly_figure(G,
134
  node_color_amc="#9EC5FF",
 
137
  edge_color_sell="#d62728",
138
  edge_color_transfer="#888888",
139
  edge_thickness_base=1.4):
 
140
  node_names = []
141
  node_x = []
142
  node_y = []
143
  node_color = []
144
  node_size = []
145
+ for n,d in G.nodes(data=True):
 
146
  node_names.append(n)
147
+ node_x.append(0.0); node_y.append(0.0)
148
+ if d["type"]=="amc":
 
149
  node_color.append(node_color_amc); node_size.append(36)
150
  else:
151
  node_color.append(node_color_company); node_size.append(56)
 
152
  edge_traces = []
153
+ edge_src = []
154
+ edge_tgt = []
155
  edge_colors = []
156
  edge_widths = []
157
+ for u,v,attrs in G.edges(data=True):
 
158
  edge_traces.append(go.Scatter(x=[0,0], y=[0,0], mode="lines",
159
+ line=dict(color="#888", width=1), hoverinfo="none"))
160
+ edge_src.append(node_names.index(u))
161
+ edge_tgt.append(node_names.index(v))
162
+ acts = attrs.get("actions",[])
163
  weight = attrs.get("weight",1)
164
  if "complete_exit" in acts:
165
  edge_colors.append(edge_color_sell); edge_widths.append(edge_thickness_base*3)
 
171
  edge_colors.append(edge_color_sell); edge_widths.append(edge_thickness_base*(1+np.log1p(weight)))
172
  else:
173
  edge_colors.append(edge_color_buy); edge_widths.append(edge_thickness_base*(1+np.log1p(weight)))
 
174
  node_trace = go.Scatter(x=node_x, y=node_y, mode="markers+text",
175
+ marker=dict(color=node_color, size=node_size, line=dict(width=2,color="#222")),
176
  text=node_names, textposition="top center", hoverinfo="text")
 
177
  fig = go.Figure(data=edge_traces + [node_trace])
178
  fig.update_layout(showlegend=False, autosize=True,
179
+ margin=dict(l=8,r=8,t=36,b=8), xaxis=dict(visible=False), yaxis=dict(visible=False))
 
180
  meta = {
181
  "node_names": node_names,
182
+ "edge_source_index": edge_src,
183
+ "edge_target_index": edge_tgt,
184
  "edge_colors": edge_colors,
185
  "edge_widths": edge_widths,
 
186
  "node_sizes": node_size
187
  }
188
  return fig, meta
189
 
190
+ # ---------------------------
191
+ # Build HTML with D3 + viscous "gel" motion
192
+ # ---------------------------
193
+ def make_network_html_d3_gel(fig, meta, div_id="network-plot-div"):
 
194
  fig_json = json.dumps(fig.to_plotly_json())
195
  meta_json = json.dumps(meta)
 
196
  html = f"""
197
+ <div id="{div_id}" style="width:100%;height:560px;"></div>
198
  <div style="margin-top:6px;margin-bottom:8px;">
199
  <button id="{div_id}-reset" style="padding:8px 12px;border-radius:6px;">Reset view</button>
200
  <button id="{div_id}-stop" style="padding:8px 12px;border-radius:6px;margin-left:8px;">Stop layout</button>
201
  </div>
202
 
 
203
  <script src="https://d3js.org/d3.v7.min.js"></script>
204
  <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
205
 
206
  <script>
 
207
  const fig = {fig_json};
208
  const meta = {meta_json};
 
 
209
  const container = document.getElementById("{div_id}");
210
  Plotly.newPlot(container, fig.data, fig.layout, {{responsive:true}});
211
 
212
+ // index bookkeeping
213
  const nodeTraceIndex = fig.data.length - 1;
214
  const edgeCount = fig.data.length - 1;
215
 
216
+ // build nodes and links for D3
217
+ const nodes = meta.node_names.map((n,i)=>({{id:i, name:n, r: meta.node_sizes[i] || 20}}));
218
+ const links = meta.edge_source_index.map((s,i)=>({{source:s, target: meta.edge_target_index[i], color: meta.edge_colors[i], width: meta.edge_widths[i] || 1}}));
 
 
 
 
 
 
219
 
220
+ // Viscous gel simulation parameters (softer, slower motion)
221
  const simulation = d3.forceSimulation(nodes)
222
+ .force("link", d3.forceLink(links).id(d => d.id).distance(140).strength(0.35))
223
+ .force("charge", d3.forceManyBody().strength(-40))
224
+ .force("collision", d3.forceCollide().radius(d => d.r * 0.85))
225
  .force("center", d3.forceCenter(0,0))
226
+ .velocityDecay(0.55);
227
+
228
+ // We add per-node velocity smoothing variables to create "gel" feel
229
+ nodes.forEach(n => {{
230
+ n.vx_smooth = 0;
231
+ n.vy_smooth = 0;
232
+ n.displayX = n.x || 0;
233
+ n.displayY = n.y || 0;
234
+ }});
235
 
236
+ let tickCount = 0;
237
+ const maxTicks = 400; // safety cap
 
 
238
 
 
 
239
  simulation.on("tick", () => {{
240
+ tickCount++;
241
+ // On each tick, update the target positions from d3, then apply viscous smoothing
242
+ nodes.forEach(n => {{
243
+ // D3 provides n.x/n.y; we do gel smoothing on displayX/displayY using velocity
244
+ const targetX = n.x || 0;
245
+ const targetY = n.y || 0;
246
+
247
+ // viscous velocity update (gel-like): vx_smooth integrates difference slowly
248
+ n.vx_smooth = (n.vx_smooth * 0.82) + (targetX - n.displayX) * 0.06;
249
+ n.vy_smooth = (n.vy_smooth * 0.82) + (targetY - n.displayY) * 0.06;
250
+
251
+ // apply a small damping to give heavy 'gel' inertia
252
+ n.vx_smooth *= 0.92;
253
+ n.vy_smooth *= 0.92;
254
+
255
+ // update display positions
256
+ n.displayX += n.vx_smooth;
257
+ n.displayY += n.vy_smooth;
258
+ }});
259
+
260
+ // prepare arrays for Plotly update using displayX/displayY
261
+ const xs = nodes.map(n => n.displayX);
262
+ const ys = nodes.map(n => n.displayY);
263
+
264
+ // update node trace
265
  Plotly.restyle(container, {{ 'x': [xs], 'y': [ys] }}, [nodeTraceIndex]);
266
 
267
+ // update each edge trace using display positions
268
  for (let e = 0; e < edgeCount; e++) {{
269
  const sIdx = meta.edge_source_index[e];
270
  const tIdx = meta.edge_target_index[e];
271
+ const sx = nodes[sIdx].displayX || 0;
272
+ const sy = nodes[sIdx].displayY || 0;
273
+ const tx = nodes[tIdx].displayX || 0;
274
+ const ty = nodes[tIdx].displayY || 0;
275
  Plotly.restyle(container, {{ 'x': [[sx, tx]], 'y': [[sy, ty]] }}, [e]);
276
+ // set line style color/width (ensure visual matches original meta)
277
+ Plotly.restyle(container, {{ 'line.color': [meta.edge_colors[e]], 'line.width': [meta.edge_widths[e]] }}, [e]);
278
  }}
279
 
280
+ // Safety stop conditions
281
+ if (simulation.alpha() < 0.02 || tickCount > maxTicks) {{
282
  simulation.stop();
283
  }}
284
  }});
285
 
286
+ // stop button
287
  document.getElementById("{div_id}-stop").addEventListener('click', () => {{
288
+ simulation.stop();
289
  }});
290
 
291
+ // map name to index
292
  const nameToIndex = {{}};
293
+ meta.node_names.forEach((n,i)=> nameToIndex[n]=i);
294
 
295
+ // focus and reset functions (hide others on focus - Option A)
296
  function focusNode(nodeName) {{
297
  const idx = nameToIndex[nodeName];
298
+ const keep = new Set([idx]);
 
299
  for (let e = 0; e < meta.edge_source_index.length; e++) {{
300
+ const s = meta.edge_source_index[e], t = meta.edge_target_index[e];
301
+ if (s === idx) keep.add(t);
302
+ if (t === idx) keep.add(s);
 
303
  }}
304
 
 
305
  const N = meta.node_names.length;
306
  const nodeOp = Array(N).fill(0.0);
307
  const textColors = Array(N).fill("rgba(0,0,0,0)");
308
+ for (let i=0;i<N;i++) {{
309
+ if (keep.has(i)) {{ nodeOp[i]=1.0; textColors[i]="black"; }}
 
 
 
310
  }}
311
+ Plotly.restyle(container, {{ "marker.opacity": [nodeOp], "textfont.color": [textColors] }}, [nodeTraceIndex]);
 
 
 
312
 
313
+ // edges
314
+ for (let e=0;e<edgeCount;e++) {{
315
+ const s = meta.edge_source_index[e], t = meta.edge_target_index[e];
316
+ const show = keep.has(s) && keep.has(t);
 
317
  const color = show ? meta.edge_colors[e] : 'rgba(0,0,0,0)';
318
  const width = show ? meta.edge_widths[e] : 0.1;
319
  Plotly.restyle(container, {{ 'line.color': [color], 'line.width': [width] }}, [e]);
320
  }}
321
 
322
+ // zoom to bbox
323
  const nodesTrace = fig.data[nodeTraceIndex];
324
  const xs = [], ys = [];
325
+ for (let j=0;j<meta.node_names.length;j++) {{
326
+ if (keep.has(j)) {{ xs.push(nodesTrace.x[j]); ys.push(nodesTrace.y[j]); }}
 
 
 
327
  }}
328
+ if (xs.length>0) {{
329
  const xmin = Math.min(...xs), xmax = Math.max(...xs);
330
  const ymin = Math.min(...ys), ymax = Math.max(...ys);
331
  const padX = (xmax - xmin) * 0.4 + 10;
 
334
  }}
335
  }}
336
 
337
+ // reset
338
  function resetView() {{
339
  const N = meta.node_names.length;
340
  const nodeOp = Array(N).fill(1.0);
341
  const textColors = Array(N).fill("black");
342
  Plotly.restyle(container, {{ "marker.opacity": [nodeOp], "textfont.color": [textColors] }}, [nodeTraceIndex]);
343
+ for (let e=0;e<edgeCount;e++) {{
 
344
  Plotly.restyle(container, {{ 'line.color': [meta.edge_colors[e]], 'line.width': [meta.edge_widths[e]] }}, [e]);
345
  }}
 
346
  Plotly.relayout(container, {{ xaxis: {{autorange:true}}, yaxis: {{autorange:true}} }});
347
+ // restart a gentle simulation to re-space nodes
348
+ tickCount = 0;
349
+ simulation.alpha(0.5);
 
350
  simulation.restart();
351
  }}
352
 
353
+ // click handler
354
  container.on('plotly_click', function(eventData) {{
355
  const p = eventData.points[0];
356
  if (p.curveNumber === nodeTraceIndex) {{
 
360
  }}
361
  }});
362
 
363
+ // reset button
364
  document.getElementById("{div_id}-reset").addEventListener('click', function() {{
365
  resetView();
366
  }});
 
367
  </script>
368
  """
369
  return html
370
 
371
  # ---------------------------
372
+ # Company / AMC summaries (unchanged)
373
  # ---------------------------
374
  def company_trade_summary(company_name):
375
  buyers = [a for a, comps in BUY_MAP.items() if company_name in comps]
 
404
  return fig, df
405
 
406
  # ---------------------------
407
+ # Build initial HTML
408
  # ---------------------------
409
  def build_network_html(node_color_company="#FFCF9E", node_color_amc="#9EC5FF",
410
  edge_color_buy="#2ca02c", edge_color_sell="#d62728",
 
417
  edge_color_sell=edge_color_sell,
418
  edge_color_transfer=edge_color_transfer,
419
  edge_thickness_base=edge_thickness)
420
+ return make_network_html_d3_gel(fig, meta)
421
 
422
  initial_html = build_network_html()
423
 
424
  # ---------------------------
425
+ # Mobile CSS & UI
426
  # ---------------------------
427
  responsive_css = """
428
  .gradio-container { padding:0 !important; margin:0 !important; }
429
  .plotly-graph-div, .js-plotly-plot, .output_plot { width:100% !important; max-width:100% !important; }
430
+ .js-plotly-plot { height:560px !important; }
431
+ @media(max-width:780px){ .js-plotly-plot{ height:520px !important; } }
432
  body, html { overflow-x:hidden !important; }
433
  """
434
 
435
+ with gr.Blocks(css=responsive_css, title="MF Churn Explorer (Gel Motion)") as demo:
436
+ gr.Markdown("## Mutual Fund Churn Explorer — Gel-like liquid motion (L2)")
437
 
 
438
  network_html = gr.HTML(value=initial_html)
439
 
440
+ legend_html = gr.HTML(value=\"\"\"
 
441
  <div style='font-family:sans-serif;font-size:14px;margin-top:10px;line-height:1.6;'>
442
  <b>Legend</b><br>
443
  <div><span style="display:inline-block;width:28px;border-bottom:3px solid #2ca02c;"></span> BUY (green solid)</div>
 
446
  <div><span style="display:inline-block;width:28px;border-bottom:5px solid #2ca02c;"></span> FRESH BUY (thick green)</div>
447
  <div><span style="display:inline-block;width:28px;border-bottom:5px solid #d62728;"></span> COMPLETE EXIT (thick red)</div>
448
  </div>
449
+ \"\"\")
450
 
 
451
  with gr.Accordion("Network Customization — expand to edit", open=False):
452
  node_color_company = gr.ColorPicker("#FFCF9E", label="Company node color")
453
  node_color_amc = gr.ColorPicker("#9EC5FF", label="AMC node color")
 
458
  include_transfers = gr.Checkbox(value=True, label="Show AMC→AMC inferred transfers")
459
  update_button = gr.Button("Update Network Graph")
460
 
 
461
  gr.Markdown("### Inspect Company (buyers / sellers)")
462
  select_company = gr.Dropdown(choices=COMPANIES, label="Select company")
463
  company_plot = gr.Plot()
 
468
  amc_plot = gr.Plot()
469
  amc_table = gr.DataFrame()
470
 
 
471
  def update_network_html(node_color_company_val, node_color_amc_val,
472
  edge_color_buy_val, edge_color_sell_val, edge_color_transfer_val,
473
  edge_thickness_val, include_transfers_val):