vovkes222 commited on
Commit
70319ce
·
1 Parent(s): 5350181

feat: replace pyvis with plotly for reliable graph rendering in Gradio

Browse files
Files changed (2) hide show
  1. app.py +179 -108
  2. requirements.txt +2 -1
app.py CHANGED
@@ -1,13 +1,9 @@
1
  import gradio as gr
2
  import pandas as pd
3
  import networkx as nx
4
- from pyvis.network import Network
5
  from huggingface_hub import hf_hub_download
6
- import tempfile, os
7
-
8
- # ---------------------------------------------------------------------------
9
- # Data loading (cached at module level so it happens once on Space boot)
10
- # ---------------------------------------------------------------------------
11
 
12
  REPO_ID = "overthelex/ua-court-citation-graph"
13
 
@@ -27,20 +23,20 @@ edges_df["node_b"] = edges_df["law_b"] + " ст. " + edges_df["article_b"]
27
  ALL_LAWS = sorted(edges_df["law_a"].unique().tolist())
28
 
29
  DOMAIN_COLORS = {
30
- "Кримінальний кодекс України": "#e74c3c",
31
- "Кримінальний процесуальний кодекс України": "#c0392b",
32
- "Цивільний кодекс України": "#3498db",
33
- "Цивільний процесуальний кодекс України": "#2980b9",
34
- "Господарський кодекс України": "#2ecc71",
35
- "Господарський процесуальний кодекс України": "#27ae60",
36
- "Кодекс адміністративного судочинства України": "#f39c12",
37
- "КУпАП": "#e67e22",
38
- "Податковий кодекс України": "#9b59b6",
39
- "Сімейний кодекс України": "#1abc9c",
40
- "Земельний кодекс України": "#d35400",
41
- "Конституція України": "#f1c40f",
42
  }
43
- DEFAULT_COLOR = "#95a5a6"
44
 
45
  print(f"Loaded {len(edges_df):,} edges, {len(centrality_df)} centrality nodes, {len(stats_df):,} citation stats")
46
 
@@ -52,12 +48,31 @@ def get_color(law_name: str) -> str:
52
  return DEFAULT_COLOR
53
 
54
 
55
- def build_graph(
56
- law_filter: list[str],
57
- min_weight: int,
58
- max_edges: int,
59
- layout: str,
60
- ) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  filtered = edges_df[edges_df["weight"] >= min_weight]
62
 
63
  if law_filter:
@@ -67,7 +82,16 @@ def build_graph(
67
  filtered = filtered.nlargest(max_edges, "weight")
68
 
69
  if filtered.empty:
70
- return "<div style='padding:40px;text-align:center;color:#666;font-size:18px;'>Немає ребер для обраних фільтрів. Спробуйте зменшити мінімальну вагу або змінити фільтр.</div>"
 
 
 
 
 
 
 
 
 
71
 
72
  G = nx.Graph()
73
  for _, row in filtered.iterrows():
@@ -76,78 +100,143 @@ def build_graph(
76
  degree_dict = dict(G.degree(weight="weight"))
77
  pr = nx.pagerank(G, weight="weight")
78
 
79
- height = "750px"
80
- net = Network(height=height, width="100%", bgcolor="#1a1a2e", font_color="white")
81
- net.barnes_hut(
82
- gravity=-3000,
83
- central_gravity=0.3,
84
- spring_length=150,
85
- spring_strength=0.01,
86
- damping=0.09,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  )
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  max_pr = max(pr.values()) if pr else 1
 
90
  for node in G.nodes():
91
  parts = node.split(" ст. ", 1)
92
- law = parts[0] if len(parts) == 2 else ""
93
- size = 10 + 40 * (pr.get(node, 0) / max_pr)
94
  color = get_color(law)
95
- title = (
 
 
 
 
 
 
 
96
  f"<b>{node}</b><br>"
97
  f"Зважений ступінь: {degree_dict.get(node, 0):,}<br>"
98
  f"PageRank: {pr.get(node, 0):.6f}<br>"
99
- f"Зв'язки: {G.degree(node)}"
100
  )
101
- net.add_node(node, label=parts[1] if len(parts) == 2 else node,
102
- title=title, size=size, color=color, group=law)
103
-
104
- max_w = filtered["weight"].max()
105
- for u, v, d in G.edges(data=True):
106
- w = d["weight"]
107
- width = 1 + 9 * (w / max_w)
108
- net.add_edge(u, v, value=width,
109
- title=f"{u} ↔ {v}<br>Вага: {w:,} рішень")
110
-
111
- net.set_options("""
112
- {
113
- "interaction": {
114
- "hover": true,
115
- "tooltipDelay": 100,
116
- "navigationButtons": true,
117
- "keyboard": true
118
- },
119
- "physics": {
120
- "stabilization": {"iterations": 200}
121
- }
122
- }
123
- """)
124
-
125
- path = os.path.join(tempfile.gettempdir(), "graph.html")
126
- net.save_graph(path)
127
-
128
- with open(path, "r") as f:
129
- html = f.read()
130
-
131
- legend_items = "".join(
132
- f'<span style="margin-right:12px;"><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:{c};margin-right:4px;vertical-align:middle;"></span>{k}</span>'
133
- for k, c in DOMAIN_COLORS.items()
134
- if any(k in n for n in G.nodes())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  )
136
- legend = f'<div style="padding:6px 12px;background:#16213e;color:#eee;font-size:13px;border-radius:6px;margin-bottom:4px;display:flex;flex-wrap:wrap;gap:4px;">{legend_items}</div>' if legend_items else ""
137
-
138
- stats_html = (
139
- f'<div style="padding:6px 12px;background:#0f3460;color:#eee;font-size:13px;border-radius:6px;margin-bottom:4px;">'
140
- f'Вузлів: <b>{G.number_of_nodes()}</b> | '
141
- f'Ребер: <b>{G.number_of_edges()}</b> | '
142
- f'Мін. вага: <b>{filtered["weight"].min():,}</b> | '
143
- f'Макс. вага: <b>{filtered["weight"].max():,}</b>'
144
- f'</div>'
145
  )
146
 
147
- return stats_html + legend + html
148
 
149
 
150
- def get_top_articles(n: int = 20):
151
  top = stats_df.nlargest(n, "total_citations")[
152
  ["law_number", "law_article", "citation_type", "total_citations", "unique_decisions"]
153
  ].copy()
@@ -176,20 +265,13 @@ def get_communities():
176
  return df
177
 
178
 
179
- # ---------------------------------------------------------------------------
180
- # Gradio UI
181
- # ---------------------------------------------------------------------------
182
-
183
  with gr.Blocks(
184
  title="Ukrainian Court Citation Graph",
185
  theme=gr.themes.Base(
186
  primary_hue="blue",
187
  neutral_hue="slate",
188
  ),
189
- css="""
190
- .graph-container iframe { border: none; border-radius: 8px; }
191
- footer { display: none !important; }
192
- """,
193
  ) as demo:
194
  gr.Markdown(
195
  """
@@ -220,16 +302,14 @@ with gr.Blocks(
220
  info="Топ-N найважчих ребер",
221
  )
222
  layout = gr.Radio(
223
- choices=["Barnes-Hut"],
224
- value="Barnes-Hut",
225
  label="Алгоритм розташування",
226
  )
227
  btn = gr.Button("Побудувати граф", variant="primary", size="lg")
228
 
229
  with gr.Column(scale=3):
230
- graph_output = gr.HTML(
231
- value="<div style='padding:40px;text-align:center;color:#666;'>Натисніть 'Побудувати граф' для візуалізації</div>",
232
- )
233
 
234
  btn.click(
235
  fn=build_graph,
@@ -239,24 +319,15 @@ with gr.Blocks(
239
 
240
  with gr.Tab("Топ статей за цитуваннями"):
241
  gr.Markdown("### Найбільш цитовані нормативні положення")
242
- top_articles = gr.Dataframe(
243
- value=get_top_articles(30),
244
- interactive=False,
245
- )
246
 
247
  with gr.Tab("Centrality (PageRank, HITS)"):
248
  gr.Markdown("### Топ-20 за PageRank у графі со-цитувань")
249
- centrality_table = gr.Dataframe(
250
- value=get_top_centrality(),
251
- interactive=False,
252
- )
253
 
254
  with gr.Tab("Спільноти (Louvain)"):
255
  gr.Markdown("### Виявлені спільноти за періодами (Louvain community detection)")
256
- communities_table = gr.Dataframe(
257
- value=get_communities(),
258
- interactive=False,
259
- )
260
 
261
  gr.Markdown(
262
  """
 
1
  import gradio as gr
2
  import pandas as pd
3
  import networkx as nx
4
+ import plotly.graph_objects as go
5
  from huggingface_hub import hf_hub_download
6
+ import numpy as np
 
 
 
 
7
 
8
  REPO_ID = "overthelex/ua-court-citation-graph"
9
 
 
23
  ALL_LAWS = sorted(edges_df["law_a"].unique().tolist())
24
 
25
  DOMAIN_COLORS = {
26
+ "Кримінальний кодекс України": "#ef4444",
27
+ "Кримінальний процесуальний кодекс України": "#f97316",
28
+ "Цивільний кодекс України": "#3b82f6",
29
+ "Цивільний процесуальний кодекс України": "#60a5fa",
30
+ "Господарський кодекс України": "#22c55e",
31
+ "Господарський процесуальний кодекс України": "#4ade80",
32
+ "Кодекс адміністративного судочинства України": "#eab308",
33
+ "КУпАП": "#f59e0b",
34
+ "Податковий кодекс України": "#a855f7",
35
+ "Сімейний кодекс України": "#14b8a6",
36
+ "Земельний кодекс України": "#f97316",
37
+ "Конституція України": "#fbbf24",
38
  }
39
+ DEFAULT_COLOR = "#94a3b8"
40
 
41
  print(f"Loaded {len(edges_df):,} edges, {len(centrality_df)} centrality nodes, {len(stats_df):,} citation stats")
42
 
 
48
  return DEFAULT_COLOR
49
 
50
 
51
+ SHORT_NAMES = [
52
+ ("Кримінальний процесуальний кодекс України", "КПК"),
53
+ ("Кримінальний кодекс України", "ККУ"),
54
+ ("Цивільний процесуальний кодекс України", "ЦПК"),
55
+ ("Цивільний кодекс України", "ЦКУ"),
56
+ ("Господарський процесуальний кодекс України", "ГПК"),
57
+ ("Господарський кодекс України", "ГКУ"),
58
+ ("Кодекс адміністративного судочинства України", "КАСУ"),
59
+ ("Податковий кодекс України", "ПКУ"),
60
+ ("Сімейний кодекс України", "СКУ"),
61
+ ("Земельний кодекс України", "ЗКУ"),
62
+ ("Конституція України", "КУ"),
63
+ ]
64
+
65
+
66
+ def shorten(name: str) -> str:
67
+ for long, short in SHORT_NAMES:
68
+ if name == long:
69
+ return short
70
+ if len(name) > 20:
71
+ return name[:18] + "..."
72
+ return name
73
+
74
+
75
+ def build_graph(law_filter: list[str], min_weight: int, max_edges: int, layout: str):
76
  filtered = edges_df[edges_df["weight"] >= min_weight]
77
 
78
  if law_filter:
 
82
  filtered = filtered.nlargest(max_edges, "weight")
83
 
84
  if filtered.empty:
85
+ fig = go.Figure()
86
+ fig.update_layout(
87
+ paper_bgcolor="#0f172a", plot_bgcolor="#0f172a",
88
+ annotations=[dict(text="Немає ребер. Зменшіть мінімальну вагу.",
89
+ showarrow=False, font=dict(size=18, color="#94a3b8"),
90
+ xref="paper", yref="paper", x=0.5, y=0.5)],
91
+ xaxis=dict(visible=False), yaxis=dict(visible=False),
92
+ height=750,
93
+ )
94
+ return fig
95
 
96
  G = nx.Graph()
97
  for _, row in filtered.iterrows():
 
100
  degree_dict = dict(G.degree(weight="weight"))
101
  pr = nx.pagerank(G, weight="weight")
102
 
103
+ if layout == "Kamada-Kawai":
104
+ pos = nx.kamada_kawai_layout(G, weight="weight")
105
+ else:
106
+ pos = nx.spring_layout(G, k=2.5 / np.sqrt(G.number_of_nodes()),
107
+ iterations=100, weight="weight", seed=42)
108
+
109
+ max_w = filtered["weight"].max()
110
+ min_w = filtered["weight"].min()
111
+ w_range = max_w - min_w if max_w > min_w else 1
112
+
113
+ edge_x, edge_y, edge_hover = [], [], []
114
+ for u, v, d in G.edges(data=True):
115
+ x0, y0 = pos[u]
116
+ x1, y1 = pos[v]
117
+ edge_x.extend([x0, x1, None])
118
+ edge_y.extend([y0, y1, None])
119
+
120
+ edge_trace = go.Scatter(
121
+ x=edge_x, y=edge_y,
122
+ mode="lines",
123
+ line=dict(width=0.5, color="rgba(148,163,184,0.2)"),
124
+ hoverinfo="none",
125
+ showlegend=False,
126
  )
127
 
128
+ thick_edge_traces = []
129
+ sorted_edges = sorted(G.edges(data=True), key=lambda e: e[2]["weight"], reverse=True)
130
+ top_n = min(50, len(sorted_edges))
131
+ for u, v, d in sorted_edges[:top_n]:
132
+ x0, y0 = pos[u]
133
+ x1, y1 = pos[v]
134
+ w = d["weight"]
135
+ width = 1 + 5 * ((w - min_w) / w_range)
136
+ opacity = 0.3 + 0.5 * ((w - min_w) / w_range)
137
+ thick_edge_traces.append(go.Scatter(
138
+ x=[x0, x1, None], y=[y0, y1, None],
139
+ mode="lines",
140
+ line=dict(width=width, color=f"rgba(96,165,250,{opacity:.2f})"),
141
+ hoverinfo="text",
142
+ hovertext=f"{u} ↔ {v}<br>Вага: {w:,} рішень",
143
+ showlegend=False,
144
+ ))
145
+
146
  max_pr = max(pr.values()) if pr else 1
147
+ node_groups: dict[str, dict] = {}
148
  for node in G.nodes():
149
  parts = node.split(" ст. ", 1)
150
+ law = parts[0] if len(parts) == 2 else "Інше"
151
+ article = parts[1] if len(parts) == 2 else node
152
  color = get_color(law)
153
+
154
+ if law not in node_groups:
155
+ node_groups[law] = {"x": [], "y": [], "text": [], "hover": [],
156
+ "size": [], "color": color}
157
+
158
+ x, y = pos[node]
159
+ size = 8 + 40 * (pr.get(node, 0) / max_pr)
160
+ hover = (
161
  f"<b>{node}</b><br>"
162
  f"Зважений ступінь: {degree_dict.get(node, 0):,}<br>"
163
  f"PageRank: {pr.get(node, 0):.6f}<br>"
164
+ f"Зв'язків: {G.degree(node)}"
165
  )
166
+ node_groups[law]["x"].append(x)
167
+ node_groups[law]["y"].append(y)
168
+ node_groups[law]["text"].append(f"ст. {article}")
169
+ node_groups[law]["hover"].append(hover)
170
+ node_groups[law]["size"].append(size)
171
+
172
+ node_traces = []
173
+ for group_name in sorted(node_groups.keys()):
174
+ data = node_groups[group_name]
175
+ node_traces.append(go.Scatter(
176
+ x=data["x"], y=data["y"],
177
+ mode="markers+text",
178
+ marker=dict(
179
+ size=data["size"],
180
+ color=data["color"],
181
+ line=dict(width=1.5, color="rgba(255,255,255,0.4)"),
182
+ ),
183
+ text=data["text"],
184
+ textposition="top center",
185
+ textfont=dict(size=9, color="#cbd5e1"),
186
+ hoverinfo="text",
187
+ hovertext=data["hover"],
188
+ name=shorten(group_name),
189
+ ))
190
+
191
+ fig = go.Figure(data=[edge_trace] + thick_edge_traces + node_traces)
192
+
193
+ fig.update_layout(
194
+ paper_bgcolor="#0f172a",
195
+ plot_bgcolor="#0f172a",
196
+ height=750,
197
+ margin=dict(l=5, r=5, t=45, b=5),
198
+ title=dict(
199
+ text=(f"Вузлів: {G.number_of_nodes()} | "
200
+ f"Ребер: {G.number_of_edges()} | "
201
+ f"Мін. вага: {filtered['weight'].min():,} | "
202
+ f"Макс. вага: {filtered['weight'].max():,}"),
203
+ font=dict(size=13, color="#94a3b8"),
204
+ x=0.5,
205
+ ),
206
+ xaxis=dict(visible=False, showgrid=False, zeroline=False),
207
+ yaxis=dict(visible=False, showgrid=False, zeroline=False),
208
+ legend=dict(
209
+ font=dict(color="#e2e8f0", size=11),
210
+ bgcolor="rgba(15,23,42,0.9)",
211
+ bordercolor="rgba(71,85,105,0.5)",
212
+ borderwidth=1,
213
+ orientation="h",
214
+ yanchor="bottom",
215
+ y=1.02,
216
+ xanchor="center",
217
+ x=0.5,
218
+ ),
219
+ hoverlabel=dict(
220
+ bgcolor="#1e293b",
221
+ font_size=13,
222
+ font_color="#e2e8f0",
223
+ bordercolor="#475569",
224
+ ),
225
+ dragmode="pan",
226
  )
227
+
228
+ fig.update_layout(
229
+ modebar=dict(
230
+ bgcolor="rgba(15,23,42,0.8)",
231
+ color="#94a3b8",
232
+ activecolor="#60a5fa",
233
+ )
 
 
234
  )
235
 
236
+ return fig
237
 
238
 
239
+ def get_top_articles(n: int = 30):
240
  top = stats_df.nlargest(n, "total_citations")[
241
  ["law_number", "law_article", "citation_type", "total_citations", "unique_decisions"]
242
  ].copy()
 
265
  return df
266
 
267
 
 
 
 
 
268
  with gr.Blocks(
269
  title="Ukrainian Court Citation Graph",
270
  theme=gr.themes.Base(
271
  primary_hue="blue",
272
  neutral_hue="slate",
273
  ),
274
+ css="footer { display: none !important; }",
 
 
 
275
  ) as demo:
276
  gr.Markdown(
277
  """
 
302
  info="Топ-N найважчих ребер",
303
  )
304
  layout = gr.Radio(
305
+ choices=["Spring", "Kamada-Kawai"],
306
+ value="Spring",
307
  label="Алгоритм розташування",
308
  )
309
  btn = gr.Button("Побудувати граф", variant="primary", size="lg")
310
 
311
  with gr.Column(scale=3):
312
+ graph_output = gr.Plot()
 
 
313
 
314
  btn.click(
315
  fn=build_graph,
 
319
 
320
  with gr.Tab("Топ статей за цитуваннями"):
321
  gr.Markdown("### Найбільш цитовані нормативні положення")
322
+ gr.Dataframe(value=get_top_articles(30), interactive=False)
 
 
 
323
 
324
  with gr.Tab("Centrality (PageRank, HITS)"):
325
  gr.Markdown("### Топ-20 за PageRank у графі со-цитувань")
326
+ gr.Dataframe(value=get_top_centrality(), interactive=False)
 
 
 
327
 
328
  with gr.Tab("Спільноти (Louvain)"):
329
  gr.Markdown("### Виявлені спільноти за періодами (Louvain community detection)")
330
+ gr.Dataframe(value=get_communities(), interactive=False)
 
 
 
331
 
332
  gr.Markdown(
333
  """
requirements.txt CHANGED
@@ -3,5 +3,6 @@ huggingface_hub
3
  pandas
4
  pyarrow
5
  networkx
6
- pyvis
7
  scipy
 
 
3
  pandas
4
  pyarrow
5
  networkx
6
+ plotly
7
  scipy
8
+ numpy