Spaces:
Sleeping
Sleeping
Update app.py
#13
by
singhn9
- opened
app.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
|
|
| 1 |
# app.py
|
| 2 |
# D3 physics (client-side) + Plotly visualization for MF churn explorer
|
| 3 |
-
#
|
| 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
|
| 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 |
-
|
| 102 |
for (s,b), w in transfers.items():
|
| 103 |
-
|
| 104 |
-
return
|
| 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,
|
| 115 |
-
if
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
G.add_edge(u, v, weight=attrs.get("weight",1), actions=[attrs["action"]])
|
| 121 |
if include_transfers:
|
| 122 |
-
for s,b,
|
| 123 |
-
if
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
G.add_edge(s,b,weight=attrs.get("weight",1), actions=["transfer"])
|
| 129 |
return G
|
| 130 |
|
| 131 |
# ---------------------------
|
| 132 |
-
#
|
| 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 |
-
|
| 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 |
-
|
| 159 |
-
|
| 160 |
edge_colors = []
|
| 161 |
edge_widths = []
|
| 162 |
-
for u,
|
| 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"
|
| 166 |
-
|
| 167 |
-
|
| 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,
|
| 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,
|
| 188 |
-
xaxis=dict(visible=False), yaxis=dict(visible=False))
|
| 189 |
meta = {
|
| 190 |
"node_names": node_names,
|
| 191 |
-
"edge_source_index":
|
| 192 |
-
"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 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 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:
|
| 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 |
-
//
|
| 229 |
const nodeTraceIndex = fig.data.length - 1;
|
| 230 |
const edgeCount = fig.data.length - 1;
|
| 231 |
|
| 232 |
-
// build nodes
|
| 233 |
-
const nodes = meta.node_names.map((n,
|
| 234 |
-
|
| 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 |
-
//
|
| 243 |
const simulation = d3.forceSimulation(nodes)
|
| 244 |
-
.force("link", d3.forceLink(links).id(d => d.id).distance(
|
| 245 |
-
.force("charge", d3.forceManyBody().strength(-
|
| 246 |
-
.force("collision", d3.forceCollide().radius(d => d.r * 0.
|
| 247 |
.force("center", d3.forceCenter(0,0))
|
| 248 |
-
.velocityDecay(0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
|
| 250 |
-
|
| 251 |
-
|
| 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 |
-
|
| 259 |
-
//
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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].
|
| 274 |
-
const sy = nodes[sIdx].
|
| 275 |
-
const tx = nodes[tIdx].
|
| 276 |
-
const ty = nodes[tIdx].
|
| 277 |
Plotly.restyle(container, {{ 'x': [[sx, tx]], 'y': [[sy, ty]] }}, [e]);
|
|
|
|
|
|
|
| 278 |
}}
|
| 279 |
|
| 280 |
-
// stop
|
| 281 |
-
if (simulation.alpha() < 0.
|
| 282 |
simulation.stop();
|
| 283 |
}}
|
| 284 |
}});
|
| 285 |
|
| 286 |
-
//
|
| 287 |
document.getElementById("{div_id}-stop").addEventListener('click', () => {{
|
| 288 |
-
|
| 289 |
}});
|
| 290 |
|
| 291 |
-
//
|
| 292 |
const nameToIndex = {{}};
|
| 293 |
-
meta.node_names.forEach((n,i)
|
| 294 |
|
| 295 |
-
//
|
| 296 |
function focusNode(nodeName) {{
|
| 297 |
const idx = nameToIndex[nodeName];
|
| 298 |
-
const
|
| 299 |
-
// find neighbors
|
| 300 |
for (let e = 0; e < meta.edge_source_index.length; e++) {{
|
| 301 |
-
const s = meta.edge_source_index[e];
|
| 302 |
-
|
| 303 |
-
if (
|
| 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
|
| 312 |
-
if (
|
| 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
|
| 323 |
-
for (let e
|
| 324 |
-
const s = meta.edge_source_index[e];
|
| 325 |
-
const
|
| 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
|
| 333 |
const nodesTrace = fig.data[nodeTraceIndex];
|
| 334 |
const xs = [], ys = [];
|
| 335 |
-
for (let j
|
| 336 |
-
if (
|
| 337 |
-
xs.push(nodesTrace.x[j]);
|
| 338 |
-
ys.push(nodesTrace.y[j]);
|
| 339 |
-
}}
|
| 340 |
}}
|
| 341 |
-
if (xs.length
|
| 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
|
| 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 |
-
|
| 364 |
-
|
| 365 |
-
simulation.alpha(0.6);
|
| 366 |
simulation.restart();
|
| 367 |
}}
|
| 368 |
|
| 369 |
-
// click handler
|
| 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
|
| 380 |
document.getElementById("{div_id}-reset").addEventListener('click', function() {{
|
| 381 |
resetView();
|
| 382 |
}});
|
| 383 |
-
|
| 384 |
</script>
|
| 385 |
"""
|
| 386 |
return html
|
| 387 |
|
| 388 |
# ---------------------------
|
| 389 |
-
# Company / AMC
|
| 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
|
| 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
|
| 438 |
|
| 439 |
initial_html = build_network_html()
|
| 440 |
|
| 441 |
# ---------------------------
|
| 442 |
-
# Mobile CSS
|
| 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:
|
| 448 |
-
@media(max-width:780px){ .js-plotly-plot{ height:
|
| 449 |
body, html { overflow-x:hidden !important; }
|
| 450 |
"""
|
| 451 |
|
| 452 |
-
with gr.Blocks(css=responsive_css, title="MF Churn Explorer (
|
| 453 |
-
gr.Markdown("## Mutual Fund Churn Explorer —
|
| 454 |
|
| 455 |
-
# interactive chart (HTML block)
|
| 456 |
network_html = gr.HTML(value=initial_html)
|
| 457 |
|
| 458 |
-
|
| 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):
|