dynamic_network / app.py
Jomaric's picture
feat: initial release of network analyzer space
b04994c
Raw
History Blame Contribute Delete
14 kB
import gradio as gr
import pandas as pd
import networkx as nx
from pyvis.network import Network
import tempfile
import os
import plotly.graph_objects as go
def clean_time_column(df, time_col):
# Try converting to integer (years) first
try:
df[time_col] = pd.to_numeric(df[time_col]).astype(int)
return df, True
except:
# Fallback to string-based categorical if not purely numeric
df[time_col] = df[time_col].astype(str)
return df, False
def generate_vis_html(df_filtered):
nodes = set(df_filtered['Source'].unique()).union(set(df_filtered['Target'].unique()))
net = Network(
height="500px",
width="100%",
bgcolor="#16100c",
font_color="#f4eee6",
notebook=False
)
net.set_options("""
var options = {
"nodes": {
"borderWidth": 2,
"color": {
"border": "#2c1e16",
"background": "#ff7043",
"highlight": {
"border": "#ff7043",
"background": "#ffffff"
}
},
"font": {
"color": "#f4eee6",
"size": 14,
"face": "Inter, sans-serif"
}
},
"edges": {
"color": {
"color": "rgba(255, 112, 67, 0.4)",
"highlight": "#ff7043"
},
"smooth": {
"type": "continuous"
}
},
"physics": {
"barnesHut": {
"gravitationalConstant": -12000,
"centralGravity": 0.3,
"springLength": 120,
"springConstant": 0.04
}
}
}
""")
# Scale nodes by degree inside the filtered slice
G = nx.from_pandas_edgelist(df_filtered, 'Source', 'Target', edge_attr='Weight' if 'Weight' in df_filtered.columns else None)
degrees = dict(G.degree())
for node in nodes:
deg = degrees.get(node, 1)
net.add_node(node, label=node, size=10 + (deg * 2), title=f"Connection count: {deg}")
for _, row in df_filtered.iterrows():
weight = row.get('Weight', 1.0)
try:
w_val = float(weight)
if pd.isna(w_val): w_val = 1.0
except:
w_val = 1.0
net.add_edge(row['Source'], row['Target'], value=w_val)
temp_dir = tempfile.gettempdir()
temp_path = os.path.join(temp_dir, next(tempfile._get_candidate_names()) + ".html")
net.save_graph(temp_path)
with open(temp_path, "r", encoding="utf-8") as f:
html_content = f.read()
try:
os.remove(temp_path)
except:
pass
escaped_html = html_content.replace('"', '"')
iframe_code = f'<iframe srcdoc="{escaped_html}" style="width: 100%; height: 530px; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 8px;"></iframe>'
return iframe_code
def analyze_dynamic_network(file_obj, min_val, max_val, cat_val, is_cumulative):
if file_obj is None:
return "Please upload a CSV or Excel file.", None, None, gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
try:
if file_obj.name.endswith('.csv'):
df = pd.read_csv(file_obj.name)
else:
df = pd.read_excel(file_obj.name)
except Exception as e:
return f"Error reading file: {str(e)}", None, None, gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
# Standardize headers
rename_map = {}
for col in df.columns:
if col.lower() in ['source', 'from', 'node1']:
rename_map[col] = 'Source'
elif col.lower() in ['target', 'to', 'node2']:
rename_map[col] = 'Target'
elif col.lower() in ['weight', 'value', 'strength']:
rename_map[col] = 'Weight'
elif col.lower() in ['time', 'year', 'timestamp', 'date', 'interval']:
rename_map[col] = 'Time'
df = df.rename(columns=rename_map)
if 'Source' not in df.columns or 'Target' not in df.columns or 'Time' not in df.columns:
return "CSV/Excel must contain 'Source', 'Target', and a temporal column ('Time', 'Year', etc.).", None, None, gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
if 'Weight' not in df.columns:
df['Weight'] = 1.0
df, is_numeric = clean_time_column(df, 'Time')
# Filter operations
if is_numeric:
if is_cumulative:
df_filtered = df[df['Time'] <= max_val]
else:
df_filtered = df[(df['Time'] >= min_val) & (df['Time'] <= max_val)]
else:
# Categorical slice
if is_cumulative:
# Cumulative categorical doesn't follow strict ordering, but we slice up to the selected value in sorting order
unique_cats = sorted(df['Time'].unique())
idx = unique_cats.index(cat_val) + 1 if cat_val in unique_cats else len(unique_cats)
df_filtered = df[df['Time'].isin(unique_cats[:idx])]
else:
df_filtered = df[df['Time'] == cat_val]
if df_filtered.empty:
return "No network links found inside this specific timeframe.", None, None, gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
# Calculate global stats vs time to plot evolution
stats_data = []
if is_numeric:
unique_times = sorted(df['Time'].unique())
for t in unique_times:
if is_cumulative:
sub_df = df[df['Time'] <= t]
else:
sub_df = df[df['Time'] == t]
sub_G = nx.from_pandas_edgelist(sub_df, 'Source', 'Target')
stats_data.append({
"Time": t,
"Nodes": sub_G.number_of_nodes(),
"Edges": sub_G.number_of_edges(),
"Density": nx.density(sub_G)
})
else:
unique_times = sorted(df['Time'].unique())
for idx, t in enumerate(unique_times):
if is_cumulative:
sub_df = df[df['Time'].isin(unique_times[:idx+1])]
else:
sub_df = df[df['Time'] == t]
sub_G = nx.from_pandas_edgelist(sub_df, 'Source', 'Target')
stats_data.append({
"Time": t,
"Nodes": sub_G.number_of_nodes(),
"Edges": sub_G.number_of_edges(),
"Density": nx.density(sub_G)
})
stats_df = pd.DataFrame(stats_data)
# Generate Plotly trend line chart
fig = go.Figure()
fig.add_trace(go.Scatter(x=stats_df['Time'], y=stats_df['Nodes'], mode='lines+markers', name='Nodes', line=dict(color='#ff7043', width=3)))
fig.add_trace(go.Scatter(x=stats_df['Time'], y=stats_df['Edges'], mode='lines+markers', name='Edges', line=dict(color='#ffffff', width=2)))
fig.update_layout(
title="Temporal Network Growth",
paper_bgcolor='#16100c',
plot_bgcolor='#16100c',
font_color='#f4eee6',
margin=dict(l=40, r=40, t=50, b=40),
xaxis=dict(gridcolor='rgba(255,255,255,0.05)'),
yaxis=dict(gridcolor='rgba(255,255,255,0.05)')
)
# Current stats block
G_curr = nx.from_pandas_edgelist(df_filtered, 'Source', 'Target')
stats_html = f"""
<div style='display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 1rem;'>
<div style='background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 8px; padding: 1rem; text-align: center;'>
<div style='font-size: 0.75rem; text-transform: uppercase; color: #ff7043; letter-spacing: 0.1em;'>Nodes (Active)</div>
<div style='font-size: 2rem; font-weight: bold; margin-top: 0.5rem;'>{G_curr.number_of_nodes()}</div>
</div>
<div style='background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 8px; padding: 1rem; text-align: center;'>
<div style='font-size: 0.75rem; text-transform: uppercase; color: #ff7043; letter-spacing: 0.1em;'>Edges (Active)</div>
<div style='font-size: 2rem; font-weight: bold; margin-top: 0.5rem;'>{G_curr.number_of_edges()}</div>
</div>
<div style='background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 8px; padding: 1rem; text-align: center;'>
<div style='font-size: 0.75rem; text-transform: uppercase; color: #ff7043; letter-spacing: 0.1em;'>Time-slice Density</div>
<div style='font-size: 2rem; font-weight: bold; margin-top: 0.5rem;'>{nx.density(G_curr):.4f}</div>
</div>
</div>
"""
# Generate interactive Vis.js HTML
vis_html = generate_pyvis_html(df_filtered)
# Create downloadable filtered edge list CSV
out_csv = tempfile.mktemp(suffix=".csv")
df_filtered.to_csv(out_csv, index=False)
return "", stats_html, vis_html, fig, out_csv
def init_sliders(file_obj):
if file_obj is None:
return (
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
"Please upload a file."
)
try:
if file_obj.name.endswith('.csv'):
df = pd.read_csv(file_obj.name)
else:
df = pd.read_excel(file_obj.name)
except Exception as e:
return (
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
f"Error parsing sheet: {str(e)}"
)
# Find time col
time_col = None
for col in df.columns:
if col.lower() in ['time', 'year', 'timestamp', 'date', 'interval']:
time_col = col
break
if not time_col:
return (
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
"Error: Temporal column ('Time', 'Year', etc.) was not found in columns."
)
df, is_numeric = clean_time_column(df, time_col)
if is_numeric:
min_val = int(df['Time'].min())
max_val = int(df['Time'].max())
return (
gr.update(minimum=min_val, maximum=max_val, value=min_val, visible=True),
gr.update(minimum=min_val, maximum=max_val, value=max_val, visible=True),
gr.update(visible=False),
gr.update(visible=True),
f"Loaded numeric temporal range: {min_val} to {max_val}."
)
else:
unique_cats = sorted(df['Time'].unique())
return (
gr.update(visible=False),
gr.update(visible=False),
gr.update(choices=unique_cats, value=unique_cats[0], visible=True),
gr.update(visible=True),
f"Loaded {len(unique_cats)} unique categorical time intervals."
)
theme = gr.themes.Default(
primary_hue="orange",
neutral_hue="stone"
).set(
body_background_fill="#0d0907",
body_text_color="#c4bbae",
block_background_fill="#16100c",
block_border_width="1px",
block_label_text_color="#f4eee6"
)
with gr.Blocks(theme=theme, title="Dynamic Network Analyzer") as demo:
gr.Markdown(
"""
# ⏳ Dynamic Network Evolution Analyzer
### Upload time-stamped datasets (Source, Target, Year/Interval) to slide through histories and observe network change, structural growth, and relation trends.
"""
)
error_msg = gr.Markdown("", visible=False)
with gr.Row():
with gr.Column(scale=1):
file_obj = gr.File(label="Upload Time-stamped Network CSV", file_types=[".csv", ".xlsx"])
status_text = gr.Markdown("💡 **Tip**: Make sure your dataset contains **Source**, **Target**, and a temporal column (e.g., **Year**, **Time**, **Date**).")
with gr.Group(visible=False) as controls_group:
is_cumulative = gr.Checkbox(label="Cumulative Display", value=False, info="Show all connections up to selected window (instead of only within window).")
with gr.Row():
min_slider = gr.Slider(minimum=0, maximum=100, step=1, label="Min Time Boundary", visible=False)
max_slider = gr.Slider(minimum=0, maximum=100, step=1, label="Max Time Boundary", visible=False)
cat_dropdown = gr.Dropdown(choices=[], label="Select Time Interval", visible=False)
btn = gr.Button("Re-render Network", variant="primary")
with gr.Column(scale=2):
stats_box = gr.HTML()
with gr.Tabs():
with gr.TabItem("Interactive Graph Slice"):
vis_box = gr.HTML()
download_btn = gr.File(label="Download Filtered Edge List CSV")
with gr.TabItem("Temporal Trend Charts"):
trend_plot = gr.Plot()
# Initial file parsing
file_obj.change(
init_sliders,
inputs=[file_obj],
outputs=[min_slider, max_slider, cat_dropdown, controls_group, status_text]
)
def process(file_obj, min_val, max_val, cat_val, is_cumulative):
err, stats, vis, plot, csv_path = analyze_dynamic_network(file_obj, min_val, max_val, cat_val, is_cumulative)
if err:
return gr.update(value=err, visible=True), "", "", None, gr.update(visible=False)
return gr.update(visible=False), stats, vis, plot, gr.update(value=csv_path, visible=True)
btn.click(
process,
inputs=[file_obj, min_slider, max_slider, cat_dropdown, is_cumulative],
outputs=[error_msg, stats_box, vis_box, trend_plot, download_btn]
)
if __name__ == "__main__":
demo.launch()