import io
import json
from typing import Dict, Tuple
from itertools import combinations
import imageio.v2 as imageio
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
import networkx as nx
from ngf import NGFGenerator, EnergyDistribution
st.set_page_config(page_title="NGF Generator (Bianconi–Rahmede) + GIF", layout="wide")
# ---------- Sidebar Controls ----------
st.sidebar.title("NGF Controls")
with st.sidebar:
st.markdown("**Network Geometry with Flavor (NGF)**")
d = st.number_input("Dimension d", min_value=1, max_value=3, value=2, step=1)
s = st.selectbox("Flavor s", options=[-1, 0, 1], index=1)
beta = st.number_input("Inverse temperature β", min_value=0.0, value=0.1, step=0.1)
if "total_nodes_exact" not in st.session_state:
st.session_state.total_nodes_exact = 1000
st.caption("N (exact)")
total_nodes = st.number_input(
"N (exact)",
min_value=2, max_value=50000, value=int(st.session_state.total_nodes_exact), step=1,
label_visibility="collapsed",
help="Exakten Knotenwert eingeben (2–50000)."
)
st.session_state.total_nodes_exact = int(total_nodes)
seed = st.number_input("Random seed", min_value=0, value=42, step=1)
st.divider()
st.markdown("**Energy distribution g(ε)**")
dist_kind = st.selectbox("Kind", ["uniform_int", "poisson", "custom_list"], index=0)
if dist_kind == "uniform_int":
e_low = st.number_input("ε low (inclusive)", value=0, step=1)
e_high = st.number_input("ε high (exclusive)", value=10, step=1)
dist_params = {"low": int(e_low), "high": int(e_high)}
elif dist_kind == "poisson":
lam = st.number_input("λ (Poisson mean)", value=4.0, step=0.5)
dist_params = {"lam": float(lam)}
else:
vals = st.text_input("Custom integer values (comma-separated)", "0,1,2,3,5,8,13")
try:
arr = [int(x.strip()) for x in vals.split(",") if x.strip()!=""]
except Exception:
arr = [0,1,2,3]
dist_params = {"values": arr}
st.divider()
st.markdown("**Visualization options**")
show_triangles = st.checkbox("Show 2-faces (triangles) as mesh (if d≥2)", value=True)
show_boundary = st.checkbox("Highlight boundary (d-1)-faces", value=False)
size_by_degree = st.checkbox("Scale node size by 1-skeleton degree (Fig. 2 style)", value=True)
st.divider()
do_generate = st.button("Generate NGF", type="primary")
# Animation controls under the main button
fps = st.number_input("Animation FPS", min_value=1, max_value=60, value=12, step=1)
step_N = st.number_input("ΔN pro Frame", min_value=1, max_value=5000, value=20, step=1)
do_anim = st.button("Generate Animation")
@st.cache_data(show_spinner=False)
def build_ngf(d, s, beta, total_nodes, dist_kind, dist_params, seed):
gen = NGFGenerator(
d=int(d),
s=int(s),
beta=float(beta),
energy_dist=EnergyDistribution(kind=dist_kind, params=dist_params, seed=seed),
seed=int(seed),
)
gen.run(total_nodes=int(total_nodes))
return gen
def graph_layout_3d(G: nx.Graph, seed: int = 42) -> Dict[int, Tuple[float, float, float]]:
"""Preferred: spring_layout in 3D (requires SciPy). Fallback: random_layout (SciPy-free)."""
try:
import scipy # probe availability
pos = nx.spring_layout(G, dim=3, seed=seed, k=None, iterations=200)
except Exception:
pos = nx.random_layout(G, center=(0.0, 0.0, 0.0), dim=3, seed=seed)
return {int(k): (float(v[0]), float(v[1]), float(v[2])) for k, v in pos.items()}
def plot_ngf_3d(gen: NGFGenerator, show_triangles: bool = True, show_boundary: bool = False, size_by_degree: bool = True):
from itertools import combinations
G = gen.to_networkx()
pos = graph_layout_3d(G, seed=123)
import numpy as np
energies = np.array([gen.node_energy[i] for i in gen.nodes], dtype=float)
deg1 = gen.node_degrees_1skeleton()
degrees = np.array([deg1[i] for i in gen.nodes], dtype=float)
# Node size ∝ degree (robust scaling)
if size_by_degree and len(degrees):
deg_min = degrees.min()
deg_range = float(np.ptp(degrees))
size_min, size_max = 6.0, 22.0
if deg_range <= 1e-12:
node_sizes = np.full_like(energies, (size_min + size_max) * 0.5)
else:
node_sizes = size_min + (size_max - size_min) * (degrees - deg_min) / deg_range
else:
node_sizes = np.full_like(energies, 10.0)
xs = [pos[i][0] for i in gen.nodes]
ys = [pos[i][1] for i in gen.nodes]
zs = [pos[i][2] for i in gen.nodes]
import plotly.graph_objects as go
node_trace = go.Scatter3d(
x=xs, y=ys, z=zs,
mode="markers",
marker=dict(
size=node_sizes,
color=energies if len(energies) else [0.0],
cmin=float(energies.min()) if len(energies) else 0.0,
cmax=float(energies.max()) if len(energies) else 1.0,
colorscale=[
[0.0, "#ff0000"], [0.2, "#ff7f00"], [0.4, "#ffff00"],
[0.6, "#00ff00"], [0.8, "#0000ff"], [1.0, "#8b00ff"],
],
showscale=True,
colorbar=dict(title="ε"),
opacity=0.9,
),
text=[f"node {i}
ε={gen.node_energy[i]}
deg={deg1[i]}" for i in gen.nodes],
hoverinfo="text",
name="nodes",
)
# Edges
ex, ey, ez = [], [], []
for u, v in gen.edges:
x0, y0, z0 = pos[u]
x1, y1, z1 = pos[v]
ex += [x0, x1, None]
ey += [y0, y1, None]
ez += [z0, z1, None]
edge_trace = go.Scatter3d(
x=ex, y=ey, z=ez,
mode="lines",
line=dict(width=2.0, color="rgba(90,90,90,0.75)"),
hoverinfo="none",
name="edges",
opacity=0.75,
)
data = [edge_trace, node_trace]
# Triangles (2-faces)
if show_triangles and gen.d >= 2:
tris_set = set()
if gen.d == 2:
for s in gen.simplexes_d:
if len(s) >= 3:
a, b, c = sorted(list(s))[:3]
tris_set.add((a, b, c))
else:
for s in gen.simplexes_d:
for (a, b, c) in combinations(s, 3):
tris_set.add(tuple(sorted((a, b, c))))
if tris_set:
idx_map = {nid: idx for idx, nid in enumerate(gen.nodes)}
I, J, K = [], [], []
for (a, b, c) in tris_set:
if a in idx_map and b in idx_map and c in idx_map:
I.append(idx_map[a]); J.append(idx_map[b]); K.append(idx_map[c])
if I:
mesh = go.Mesh3d(
x=xs, y=ys, z=zs,
i=I, j=J, k=K,
opacity=0.18,
name="2-faces (triangles)",
hoverinfo="skip"
)
data.insert(0, mesh)
if show_boundary and gen.d >= 1:
bx, by, bz = [], [], []
boundary = gen.get_boundary_faces()
for face in boundary:
nodes = list(face)
for i in range(len(nodes)):
for j in range(i + 1, len(nodes)):
u, v = nodes[i], nodes[j]
x0, y0, z0 = pos[u]
x1, y1, z1 = pos[v]
bx += [x0, x1, None]
by += [y0, y1, None]
bz += [z0, z1, None]
if bx:
boundary_trace = go.Scatter3d(
x=bx, y=by, z=bz, mode="lines",
line=dict(width=4, color="rgba(50,50,50,0.35)"),
opacity=0.3, name="boundary (d-1)-faces", hoverinfo="skip"
)
data.append(boundary_trace)
fig = go.Figure(data=data)
fig.update_layout(
scene=dict(xaxis=dict(visible=False), yaxis=dict(visible=False), zaxis=dict(visible=False)),
margin=dict(l=0, r=0, t=30, b=0),
legend=dict(orientation="h"),
height=700,
title=f"NGF d={gen.d}, s={gen.s}, β={gen.beta}, N={gen.N}, V={len(gen.simplexes_d)} (Fig. 2 style)"
)
return fig
def render_frame_png_matplotlib(gen, pos, uptoN, show_triangles, show_boundary, size_by_degree,
width=1200, height=800, dpi=150):
"""Matplotlib-Frame ohne Kaleido, mit skalierter Knotengröße (Grad) relativ zur GIF-Größe."""
import io
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.colors import LinearSegmentedColormap, Normalize
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
from itertools import combinations
import numpy as np
nodes_sub = [i for i in gen.nodes if i < uptoN]
node_set = set(nodes_sub)
edges_sub = [(u, v) for (u, v) in gen.edges if u in node_set and v in node_set]
energies = np.array([gen.node_energy[i] for i in nodes_sub], dtype=float)
# Grad im Teilgraph
deg = {i: 0 for i in nodes_sub}
for u, v in edges_sub:
deg[u] += 1
deg[v] += 1
degrees = np.array([deg[i] for i in nodes_sub], dtype=float) if nodes_sub else np.array([])
# --- Knoten-Skalierung: Pixel -> Points² (Matplotlib erwartet Fläche in pt²)
min_side = float(min(width, height))
# sinnvolle Relationen zur Bildgröße (Passe bei Bedarf an)
min_px = max(4.0, 0.008 * min_side) # ~6–10 px bei 800–1200
max_px = max(10.0, 0.030 * min_side) # ~24–36 px bei 800–1200
if size_by_degree and degrees.size:
dmin = float(degrees.min())
drng = float(np.ptp(degrees))
if drng <= 1e-12:
# alle Grade (fast) gleich -> uniforme Größe
sizes_px = np.full_like(degrees, (min_px + max_px) * 0.5)
else:
dnorm = (degrees - dmin) / drng
sizes_px = min_px + (max_px - min_px) * dnorm
else:
sizes_px = np.full_like(energies, (min_px + max_px) * 0.5)
# Umrechnung Pixel -> Points²
px_to_pt = 72.0 / float(dpi)
sizes_pts2 = (sizes_px * px_to_pt) ** 2
xs = np.array([pos[i][0] for i in nodes_sub]) if nodes_sub else np.array([])
ys = np.array([pos[i][1] for i in nodes_sub]) if nodes_sub else np.array([])
zs = np.array([pos[i][2] for i in nodes_sub]) if nodes_sub else np.array([])
# Fig.2 Farbskala (Energie)
cmap = LinearSegmentedColormap.from_list(
"rainbow6", ["#ff0000", "#ff7f00", "#ffff00", "#00ff00", "#0000ff", "#8b00ff"]
)
if energies.size:
norm = Normalize(vmin=float(energies.min()), vmax=float(energies.max()))
colors = cmap(norm(energies))
else:
from numpy import array
norm = Normalize(vmin=0.0, vmax=1.0)
colors = cmap(norm(np.array([0.0])))
fig = plt.figure(figsize=(width/100.0, height/100.0), dpi=dpi)
ax = fig.add_subplot(111, projection="3d")
ax.set_axis_off()
# Dreiecks-Mesh (2-faces)
if show_triangles and gen.d >= 2 and len(nodes_sub) >= 3:
tris = []
if gen.d == 2:
for s in gen.simplexes_d:
if max(s) < uptoN and len(s) >= 3:
a, b, c = sorted(list(s))[:3]
if a in node_set and b in node_set and c in node_set:
tris.append([(pos[a][0], pos[a][1], pos[a][2]),
(pos[b][0], pos[b][1], pos[b][2]),
(pos[c][0], pos[c][1], pos[c][2])])
else:
for s in gen.simplexes_d:
if max(s) < uptoN:
for (a, b, c) in combinations(s, 3):
if a in node_set and b in node_set and c in node_set:
tris.append([(pos[a][0], pos[a][1], pos[a][2]),
(pos[b][0], pos[b][1], pos[b][2]),
(pos[c][0], pos[c][1], pos[c][2])])
if tris:
mesh = Poly3DCollection(tris, alpha=0.15, facecolor="#9aa0a6", edgecolor="none")
ax.add_collection3d(mesh)
# Kantenbreite relativ zur Bildgröße
lw_edge = max(1.0, 0.0015 * min_side)
for u, v in edges_sub:
x0, y0, z0 = pos[u]; x1, y1, z1 = pos[v]
ax.plot([x0, x1], [y0, y1], [z0, z1], linewidth=lw_edge, color=(0.35, 0.35, 0.35, 0.8))
# Randkanten (optional etwas dicker)
if show_boundary and gen.d >= 1:
lw_b = max(1.8, 0.0028 * min_side)
for face, n in gen.face_n.items():
if n == 0 and all(v in node_set for v in face):
f = list(face)
for i in range(len(f)):
for j in range(i + 1, len(f)):
u, v = f[i], f[j]
x0, y0, z0 = pos[u]; x1, y1, z1 = pos[v]
ax.plot([x0, x1], [y0, y1], [z0, z1], linewidth=lw_b, color=(0.2, 0.2, 0.2, 0.35))
# Knoten
if xs.size:
ax.scatter(xs, ys, zs, s=sizes_pts2, c=colors, depthshade=True)
# halbwegs gleiche Achsenskalierung
mins = np.array([xs.min(), ys.min(), zs.min()])
maxs = np.array([xs.max(), ys.max(), zs.max()])
centers = (mins + maxs) / 2.0
span = (maxs - mins).max() * 0.55 + 1e-9
ax.set_xlim(centers[0]-span, centers[0]+span)
ax.set_ylim(centers[1]-span, centers[1]+span)
ax.set_zlim(centers[2]-span, centers[2]+span)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", pad_inches=0.02)
buf.seek(0)
plt.close(fig)
return buf.getvalue()
# ---------- App Body ----------
st.title("Network Geometry with Flavor — NGF Generator & 3D Viewer (+ GIF, no-kaleido, HF-ready)")
st.caption("Fig. 2 style: node color ~ energy (red→violet), node size ~ degree; 2-faces optional.")
if do_generate:
with st.spinner("Generating NGF..."):
gen = build_ngf(d, s, beta, total_nodes, dist_kind, dist_params, seed)
t1, t2, t3, t4 = st.tabs(["3D View", "Distributions", "Growth", "Export"])
with t1:
fig = plot_ngf_3d(gen, show_triangles=show_triangles, show_boundary=show_boundary, size_by_degree=size_by_degree)
st.plotly_chart(fig, use_container_width=True)
col1, col2, col3, col4 = st.columns(4)
with col1:
st.metric("N (nodes)", f"{gen.N}")
with col2:
st.metric("V (d-simplices)", f"{len(gen.simplexes_d)}")
with col3:
st.metric("Area A (boundary faces)", f"{sum(1 for f,n in gen.face_n.items() if n==0)}")
with col4:
st.metric("Edges (1-skeleton)", f"{len(gen.edges)}")
with t2:
deg1 = gen.node_degrees_1skeleton()
hist_deg = {}
for k in deg1.values():
hist_deg[k] = hist_deg.get(k, 0) + 1
xs = np.array(list(hist_deg.keys()), dtype=float)
ys = np.array(list(hist_deg.values()), dtype=float)
fig_deg = go.Figure(); fig_deg.add_bar(x=xs, y=ys)
fig_deg.update_layout(title="1-skeleton degree distribution", xaxis_title="k", yaxis_title="count", height=360)
fig_deg.update_xaxes(type="log"); fig_deg.update_yaxes(type="log")
st.plotly_chart(fig_deg, use_container_width=True)
if gen.d >= 1:
hist_d1 = gen.generalized_degree_distribution(gen.d - 1)
xs = np.array(list(hist_d1.keys()), dtype=float); ys = np.array(list(hist_d1.values()), dtype=float)
fig_d1 = go.Figure(); fig_d1.add_bar(x=xs, y=ys)
fig_d1.update_layout(title=f"Generalized degrees k₍{gen.d},{gen.d-1}₎ of (d-1)-faces", height=360)
fig_d1.update_xaxes(type="log"); fig_d1.update_yaxes(type="log")
st.plotly_chart(fig_d1, use_container_width=True)
hist_nodes = gen.generalized_degree_distribution(0)
xs = np.array(list(hist_nodes.keys()), dtype=float); ys = np.array(list(hist_nodes.values()), dtype=float)
fig_n = go.Figure(); fig_n.add_bar(x=xs, y=ys)
fig_n.update_layout(title=f"Generalized degrees k₍{gen.d},0₎ of nodes", height=360)
fig_n.update_xaxes(type="log"); fig_n.update_yaxes(type="log")
st.plotly_chart(fig_n, use_container_width=True)
if gen.d >= 2:
hist_links = gen.generalized_degree_distribution(1)
xs = np.array(list(hist_links.keys()), dtype=float); ys = np.array(list(hist_links.values()), dtype=float)
fig_l = go.Figure(); fig_l.add_bar(x=xs, y=ys)
fig_l.update_layout(title=f"Generalized degrees k₍{gen.d},1₎ of links", height=360)
fig_l.update_xaxes(type="log"); fig_l.update_yaxes(type="log")
st.plotly_chart(fig_l, use_container_width=True)
with t4:
data = gen.to_json()
st.json({"dimension": data["dimension"], "flavor": data["flavor"], "beta": data["beta"], "N": data["N"], "V": len(gen.simplexes_d)})
j = json.dumps(data).encode("utf-8")
st.download_button("Download NGF as JSON", j, file_name=f"ngf_d{gen.d}_s{gen.s}_N{gen.N}.json", mime="application/json")
# --- Animation (matplotlib-based, BytesIO download) ---
if do_anim:
with st.spinner("Baue Animation (ohne Kaleido, HF-ready)..."):
gen_anim = build_ngf(d, s, beta, total_nodes, dist_kind, dist_params, seed)
G_final = gen_anim.to_networkx()
pos = graph_layout_3d(G_final, seed=123)
startN = gen_anim.d + 1
endN = int(total_nodes)
steps = list(range(startN, endN + 1, int(step_N)))
if steps[-1] != endN:
steps.append(endN)
max_frames = 600
if len(steps) > max_frames:
st.warning(f"Viele Frames ({len(steps)}). Kürze auf {max_frames}. Erhöhe ΔN.")
import numpy as np
keep = np.linspace(0, len(steps)-1, max_frames).astype(int).tolist()
steps = [steps[i] for i in keep]
frames = []
prog = st.progress(0.0)
for i, upto in enumerate(steps):
png_bytes = render_frame_png_matplotlib(gen_anim, pos, upto,
show_triangles=show_triangles,
show_boundary=show_boundary,
size_by_degree=size_by_degree,
width=1200, height=800, dpi=150)
frames.append(imageio.imread(io.BytesIO(png_bytes)))
prog.progress((i + 1) / len(steps))
# Write GIF to memory buffer for direct download (no filesystem path needed)
gif_buf = io.BytesIO()
imageio.mimsave(gif_buf, frames, format="GIF", fps=int(fps))
gif_buf.seek(0)
st.success(f"Animation erstellt: {len(steps)} Frames @ {int(fps)} FPS")
st.download_button("Download GIF", data=gif_buf, file_name="ngf_animation.gif", mime="image/gif")