yogl commited on
Commit
5dc2489
·
verified ·
1 Parent(s): e603ea0

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +12 -0
  2. requirements.txt +7 -0
  3. server.py +1585 -0
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt requirements.txt
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ EXPOSE 8051
11
+
12
+ CMD ["python", "server.py"]
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ dash
2
+ pandas
3
+ plotly
4
+ networkx
5
+ numpy
6
+ pyarrow
7
+ scipy
server.py ADDED
@@ -0,0 +1,1585 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import dash
2
+ from dash import dcc, html, Output, Input, State
3
+ import pandas as pd
4
+ import plotly.graph_objs as go
5
+ import plotly.express as px
6
+ import networkx as nx
7
+ import numpy as np
8
+ import os
9
+ import re
10
+ import colorsys
11
+ from functools import lru_cache
12
+
13
+ # === Основные параметры ===
14
+ BASE_PARQUET_DIR = r"/app/parquet"
15
+ PDF_PATH = r"/app/pdf/report.pdf"
16
+
17
+ ORG_ID = 373
18
+ YEAR_START = 2005
19
+ YEAR_END = 2024
20
+ COLOR_ALPHA = 0.8
21
+ COLOR_LIGHTEN = 0.2
22
+ WEB20_PALETTE = [
23
+ "#0074D9", "#00B8D4", "#6A4C93", "#FF851B", "#B10DC9",
24
+ "#FFDC00", "#39CCCC", "#FFB347", "#7FDBFF", "#3D9970",
25
+ "#F012BE", "#85144b", "#FF6F61", "#C70039", "#FF9F1C",
26
+ "#C0CA33", "#2ECC40", "#01FF70", "#FF4136", "#B8E986",
27
+ ]
28
+ MAX_LEN_HOVER = 60
29
+ SHOW_SUMMARY_RU = False
30
+ SHOW_SUMMARY_EN = True
31
+ SPRING_K = 0.6
32
+ SPRING_ITER = 500
33
+ NODE_SIZE_BASE = 10
34
+ NODE_SIZE_MAX = 50
35
+ EDGE_ALPHA = 0.13
36
+ EDGE_WIDTH_BASE = 1
37
+ EDGE_WIDTH_MAX = 10
38
+ SINGLE_NODE_BORDER_WIDTH = 1
39
+ NODE_SIZE_SCALE_MODE = "diameter"
40
+
41
+ import threading
42
+
43
+ # Кеш для датафреймов
44
+ _PARQUET_CACHE = {}
45
+ _CACHE_LOCK = threading.Lock()
46
+
47
+ def load_df_cached(name, **kwargs):
48
+ """Быстрая загрузка parquet-файлов с кешированием в памяти."""
49
+ with _CACHE_LOCK:
50
+ if name not in _PARQUET_CACHE:
51
+ _PARQUET_CACHE[name] = pd.read_parquet(os.path.join(BASE_PARQUET_DIR, name), **kwargs)
52
+ return _PARQUET_CACHE[name]
53
+
54
+ PRELOAD_FILES = [
55
+ fname for fname in os.listdir(BASE_PARQUET_DIR)
56
+ if fname.endswith(".parquet")
57
+ ]
58
+
59
+ print("Загрузка файлов в кеш:", PRELOAD_FILES)
60
+ for fname in PRELOAD_FILES:
61
+ load_df_cached(fname)
62
+ print("Все parquet-файлы успешно загружены в RAM.")
63
+
64
+
65
+
66
+
67
+
68
+ def parquet_path(*parts):
69
+ return os.path.join(BASE_PARQUET_DIR, *parts)
70
+
71
+ def load_df(name, **kwargs):
72
+ return pd.read_parquet(parquet_path(name), **kwargs)
73
+
74
+ def list_period_files(prefix):
75
+ files = []
76
+ for fname in os.listdir(BASE_PARQUET_DIR):
77
+ if fname.startswith(prefix + "__") and fname.endswith(".parquet"):
78
+ period = fname.split("__")[1].replace(".parquet", "")
79
+ files.append((period, fname))
80
+ return sorted(files)
81
+
82
+ def get_periods(prefix):
83
+ files = list_period_files(prefix)
84
+ result = []
85
+ for period, fname in files:
86
+ label = period.replace("_", "–")
87
+ result.append({
88
+ "key": period,
89
+ "label": label,
90
+ "fname": fname
91
+ })
92
+ return result
93
+
94
+ PERIODS = get_periods("bar_data")
95
+ period_options = [{"label": p["label"], "value": p["key"]} for p in PERIODS]
96
+
97
+ # === 0. График продуктивности ===
98
+ def load_and_prepare_data(year_start, year_end, org_id):
99
+ df_orgs = load_df_cached("organizations.parquet")
100
+ df_values = load_df_cached("publication_values.parquet")[["pub_id", "fract_value"]].rename(columns={"fract_value": "fract_author_value"})
101
+ df_orgs = df_orgs[pd.notnull(df_orgs["year"])].copy()
102
+ df_orgs["year"] = df_orgs["year"].astype(int)
103
+ df_orgs_filtered = df_orgs[df_orgs["year"].between(year_start, year_end)].copy()
104
+ df_hse = df_orgs_filtered[df_orgs_filtered["org_id"] == org_id]
105
+ df = df_hse.merge(df_values, on="pub_id", how="left")
106
+ affil_counts = df_orgs_filtered.groupby(["pub_id", "author_id"]).size().reset_index(name="affil_count")
107
+ multi_affil = affil_counts[affil_counts["affil_count"] > 1]["pub_id"].nunique()
108
+ df = df.merge(affil_counts, on=["pub_id", "author_id"], how="left")
109
+ df["fract_author_affiliation_value"] = df["fract_author_value"] / df["affil_count"]
110
+ return df, multi_affil
111
+
112
+ def build_productivity_figure(df, multi_affil):
113
+ pubs_per_year = df.drop_duplicates(subset=["pub_id", "year"]).groupby("year")["pub_id"].count()
114
+ authors_per_year = df.drop_duplicates(subset=["year", "author_id"]).groupby("year")["author_id"].count()
115
+ value_per_year = df.groupby("year")["fract_author_value"].sum()
116
+ adjusted_value_per_year = df.groupby("year")["fract_author_affiliation_value"].sum()
117
+ total_val = value_per_year.sum()
118
+ total_a_val = adjusted_value_per_year.sum()
119
+ total_pubs = df["pub_id"].nunique()
120
+ total_authors = df["author_id"].nunique()
121
+ percent_multi = multi_affil / total_pubs * 100 if total_pubs > 0 else 0
122
+
123
+ fig = go.Figure()
124
+ fig.add_bar(
125
+ x=value_per_year.index,
126
+ y=value_per_year.values,
127
+ marker_color="rgba(0,128,0,0.25)",
128
+ name=f"Продуктивность (без учёта множественных аффилиаций): {total_val:.1f}"
129
+ )
130
+ fig.add_bar(
131
+ x=adjusted_value_per_year.index,
132
+ y=adjusted_value_per_year.values,
133
+ marker_color="rgba(0,128,0,0.6)",
134
+ name=f"Продуктивность (с учётом множественных аффилиаций): {total_a_val:.1f}"
135
+ )
136
+ fig.add_trace(go.Scatter(
137
+ x=pubs_per_year.index, y=pubs_per_year.values,
138
+ name=f"Публикации: {total_pubs}",
139
+ mode='lines+markers',
140
+ line=dict(color="salmon", width=2),
141
+ yaxis="y2"
142
+ ))
143
+ fig.add_trace(go.Scatter(
144
+ x=authors_per_year.index, y=authors_per_year.values,
145
+ name=f"Авторы: {total_authors}",
146
+ mode='lines+markers',
147
+ line=dict(color="royalblue", width=2),
148
+ yaxis="y2"
149
+ ))
150
+ fig.update_layout(
151
+ xaxis=dict(title="Год", tickmode='linear', tick0=int(value_per_year.index.min()), dtick=1),
152
+ yaxis=dict(title="Продуктивность"),
153
+ yaxis2=dict(
154
+ title="Число публикаций / авторов",
155
+ overlaying='y',
156
+ side='right'
157
+ ),
158
+ legend=dict(x=0.01, y=0.99, bgcolor='rgba(255,255,255,0.7)', bordercolor="gray"),
159
+ template="plotly_white",
160
+ margin=dict(l=40, r=40, t=40, b=40),
161
+ height=470,
162
+ autosize=True
163
+ )
164
+ return fig
165
+
166
+ # === Авторы и аннотации
167
+ authors_df = load_df_cached("authors.parquet")
168
+ author_id2short = dict(zip(authors_df["author_id"], authors_df["short_author_name"]))
169
+ pub_content_df = load_df_cached("publication_content.parquet")
170
+
171
+ # === 1. GRNTI/GRNTI_AGG
172
+ def grnti_hex_to_rgba(hex_color, alpha=COLOR_ALPHA):
173
+ hex_color = hex_color.lstrip('#')
174
+ r, g, b = (int(hex_color[i:i+2], 16) for i in (0, 2, 4))
175
+ return f"rgba({r},{g},{b},{alpha})"
176
+
177
+ def grnti_lighten_color(hex_color, factor=COLOR_LIGHTEN):
178
+ hex_color = hex_color.lstrip('#')
179
+ r, g, b = [int(hex_color[i:i+2], 16) for i in (0, 2, 4)]
180
+ r = int(r + (255 - r) * factor)
181
+ g = int(g + (255 - g) * factor)
182
+ b = int(b + (255 - b) * factor)
183
+ return f'#{r:02x}{g:02x}{b:02x}'
184
+
185
+ def grnti_get_lvl1_colors(df, alpha=COLOR_ALPHA, lighten=COLOR_LIGHTEN):
186
+ lvl1_codes = sorted(df['code_lvl1'].dropna().unique())
187
+ palette = WEB20_PALETTE
188
+ if len(lvl1_codes) > len(palette):
189
+ base_len = len(palette)
190
+ palette_extended = []
191
+ for i in range(len(lvl1_codes)):
192
+ base = palette[i % base_len]
193
+ if i < base_len:
194
+ palette_extended.append(base)
195
+ else:
196
+ rgb = tuple(int(base[j:j+2], 16)/255. for j in (1,3,5))
197
+ h, s, v = colorsys.rgb_to_hsv(*rgb)
198
+ v = min(1, v * (0.8 + 0.2 * ((i//base_len)%2)))
199
+ r, g, b = colorsys.hsv_to_rgb(h, s, v)
200
+ palette_extended.append('#%02x%02x%02x' % (int(r*255), int(g*255), int(b*255)))
201
+ palette = palette_extended
202
+ color_map = {
203
+ code: grnti_hex_to_rgba(grnti_lighten_color(palette[i], factor=lighten), alpha=alpha)
204
+ for i, code in enumerate(lvl1_codes)
205
+ }
206
+ return color_map
207
+
208
+ def grnti_make_treemap(df, norm_mode):
209
+ if norm_mode == 'value':
210
+ col = 'value'
211
+ label_percent = "Доля:"
212
+ total = df['value'].sum()
213
+ else:
214
+ if 'npubs' not in df.columns:
215
+ df['npubs'] = df['frac_npubs']
216
+ col = 'npubs'
217
+ label_percent = "Доля публикаций:"
218
+ total = df['npubs'].sum()
219
+ color_map = grnti_get_lvl1_colors(df, alpha=COLOR_ALPHA, lighten=COLOR_LIGHTEN)
220
+ df_lvl1 = df[df['code_lvl2'].isnull()].copy()
221
+ df_lvl2 = df[df['code_lvl2'].notnull()].copy()
222
+ labels, parents, customdata, values_out, marker_colors = [], [], [], [], []
223
+
224
+ for _, row in df_lvl1.iterrows():
225
+ l = f"{row['code_lvl1']} {row['name_lvl1']}"
226
+ labels.append(l)
227
+ parents.append("")
228
+ values_out.append(row[col])
229
+ customdata.append([
230
+ row['code_lvl1'],
231
+ row['name_lvl1'],
232
+ row[col] / total if total else 0,
233
+ ])
234
+ marker_colors.append(color_map.get(row['code_lvl1'], "rgba(200,200,200,0.3)"))
235
+
236
+ for _, row in df_lvl2.iterrows():
237
+ l = f"{row['code_lvl2']} {row['name_lvl2']}"
238
+ labels.append(l)
239
+ parents.append(f"{row['code_lvl1']} {row['name_lvl1']}")
240
+ values_out.append(row[col])
241
+ customdata.append([
242
+ row['code_lvl2'],
243
+ row['name_lvl2'],
244
+ row[col] / total if total else 0,
245
+ ])
246
+ marker_colors.append(color_map.get(row['code_lvl1'], "rgba(200,200,200,0.3)"))
247
+
248
+ label_seen = {}
249
+ for i, label in enumerate(labels):
250
+ orig_label = label
251
+ idx = 1
252
+ while label in label_seen:
253
+ label = f"{orig_label} [{idx}]"
254
+ idx += 1
255
+ label_seen[label] = 1
256
+ labels[i] = label
257
+
258
+ hovertemplates = []
259
+ for parent, cd in zip(parents, customdata):
260
+ hovertemplates.append(
261
+ f"Код: {cd[0]}<br>Название: {cd[1]}<br>{label_percent} {cd[2]:.1%}<extra></extra>"
262
+ )
263
+
264
+ texttemplate = (
265
+ "Код: %{customdata[0]}<br>"
266
+ "Название: %{customdata[1]}<br>"
267
+ f"{label_percent} "+"%{customdata[2]:.1%}"
268
+ )
269
+
270
+ fig = go.Figure(go.Treemap(
271
+ labels=labels,
272
+ parents=parents,
273
+ values=values_out,
274
+ customdata=customdata,
275
+ marker_colors=marker_colors,
276
+ texttemplate=texttemplate,
277
+ hovertemplate=hovertemplates,
278
+ branchvalues="total"
279
+ ))
280
+ fig.update_layout(
281
+ margin=dict(t=20, l=0, r=0, b=0),
282
+ title=None
283
+ )
284
+ return fig
285
+
286
+ @lru_cache(maxsize=12)
287
+ def grnti_load_df_by_period(period_label):
288
+ fname = f"grnti_agg_result_multi__{period_label}.parquet"
289
+ return load_df_cached(fname)
290
+
291
+ # === Кластеры и bar ===
292
+ @lru_cache(maxsize=8)
293
+ def load_cluster_data(period_key):
294
+ info = load_df_cached(f"coauthor_clusters_info__{period_key}.parquet")
295
+ summary = load_df_cached(f"coauthor_clusters_summary__{period_key}.parquet")
296
+ return info, summary
297
+
298
+ @lru_cache(maxsize=8)
299
+ def load_bar_data(period_key):
300
+ return load_df_cached(f"bar_data__{period_key}.parquet")
301
+
302
+
303
+
304
+ def period_label_to_filename(period_label):
305
+ # Преобразуем любые тире и пробелы к подчёркиванию
306
+ # '2005–2024' -> '2005_2024'
307
+ return period_label.replace("–", "_").replace("-", "_").replace(" ", "")
308
+
309
+ @lru_cache(maxsize=8)
310
+ def load_science_map_sheet(period_label):
311
+ fname = period_label_to_filename(period_label)
312
+ parquet_path = os.path.join(BASE_PARQUET_DIR, f"hse_science_map__{fname}.parquet")
313
+
314
+ return pd.read_parquet(parquet_path)
315
+
316
+
317
+
318
+ def cluster_sorter(vals):
319
+ numeric = []
320
+ non_numeric = []
321
+ for v in vals:
322
+ try:
323
+ numeric.append(int(v))
324
+ except Exception:
325
+ non_numeric.append(v)
326
+ numeric = [str(x) for x in sorted(numeric)]
327
+ non_numeric = sorted(non_numeric)
328
+ return numeric + non_numeric
329
+
330
+ def get_cluster_labels(metrics_df, clusters_sorted):
331
+ labels = []
332
+ for cid in clusters_sorted:
333
+ authors = metrics_df[metrics_df['cluster_id'] == int(cid)]
334
+ if authors.empty:
335
+ labels.append(f'К{cid}')
336
+ continue
337
+ max_centrality = authors['degree_centrality'].max()
338
+ top_authors = authors[authors['degree_centrality'] == max_centrality]['author_short'].tolist()
339
+ names = ', '.join(top_authors)
340
+ if len(authors) > len(top_authors):
341
+ names += ' и др.'
342
+ labels.append(f"{names} К{cid}")
343
+ return labels
344
+
345
+ def get_hover_texts(metrics_df, clusters_sorted, hse_vals, th_vals, other_vals):
346
+ hover_hse, hover_2th, hover_other = [], [], []
347
+ for idx, cid in enumerate(clusters_sorted):
348
+ authors_df = metrics_df[metrics_df['cluster_id'] == int(cid)].copy()
349
+ authors_df = authors_df.sort_values('total_value', ascending=False)
350
+ hse_value = hse_vals[::-1].iloc[idx]
351
+ th_value = th_vals[::-1].iloc[idx]
352
+ other_value = other_vals[::-1].iloc[idx]
353
+ if authors_df.empty:
354
+ hover_hse.append(f"Продуктивность: {hse_value:.1f}<br>Авторы:<br>Нет авторов")
355
+ else:
356
+ lines = [
357
+ f"{i}. {row.author_short} — {row.total_value:.1f}"
358
+ for i, row in enumerate(authors_df.itertuples(), 1)
359
+ ]
360
+ hover_hse.append(
361
+ f"Продуктивность: {hse_value:.1f}<br>Авторы:<br>" + "<br>".join(lines)
362
+ )
363
+ hover_2th.append(f"Продуктивность: {th_value:.1f}")
364
+ hover_other.append(f"Продуктивность: {other_value:.1f}")
365
+ return hover_hse, hover_2th, hover_other
366
+
367
+ def make_figure(df, period_label, sort_by='sum'):
368
+ df = df.copy()
369
+ df['sum_value'] = df['hse_value'] + df['2th_org_value'] + df['other_orgs_value']
370
+ df['hse_plus_2th'] = df['hse_value'] + df['2th_org_value']
371
+
372
+ if sort_by == 'sum':
373
+ df_sorted = df.sort_values("sum_value", ascending=False).reset_index(drop=True)
374
+ elif sort_by == 'hse_plus_2th':
375
+ df_sorted = df.sort_values("hse_plus_2th", ascending=False).reset_index(drop=True)
376
+ elif sort_by == 'hse':
377
+ df_sorted = df.sort_values("hse_value", ascending=False).reset_index(drop=True)
378
+ else:
379
+ raise ValueError('sort_by must be "sum", "hse_plus_2th" or "hse"')
380
+
381
+ clusters_sorted = df_sorted['cluster_id'].tolist()[::-1]
382
+ # --- Метрики для ярлыков и ховеров ---
383
+ metrics_df = load_df_cached(f"coauthor_clusters_metrics__{period_label}.parquet")
384
+ y_labels = get_cluster_labels(metrics_df, clusters_sorted)
385
+ hover_hse, hover_2th, hover_other = get_hover_texts(
386
+ metrics_df, clusters_sorted,
387
+ df_sorted['hse_value'], df_sorted['2th_org_value'], df_sorted['other_orgs_value']
388
+ )
389
+
390
+ sum_values = df_sorted['sum_value']
391
+ hse_text = [
392
+ f"{100 * v / s:.1f}%" if s > 0 else ""
393
+ for v, s in zip(df_sorted['hse_value'][::-1], sum_values[::-1])
394
+ ]
395
+ org2_text = [
396
+ f"{100 * v / s:.1f}%" if s > 0 else ""
397
+ for v, s in zip(df_sorted['2th_org_value'][::-1], sum_values[::-1])
398
+ ]
399
+ other_text = [
400
+ f"{100 * v / s:.1f}%" if s > 0 else ""
401
+ for v, s in zip(df_sorted['other_orgs_value'][::-1], sum_values[::-1])
402
+ ]
403
+
404
+ fig = go.Figure()
405
+ fig.add_trace(go.Bar(
406
+ y=y_labels,
407
+ x=df_sorted['hse_value'][::-1],
408
+ orientation='h',
409
+ name='Ядро организации',
410
+ marker_color='royalblue',
411
+ text=hse_text,
412
+ textposition='inside',
413
+ insidetextanchor='middle',
414
+ hovertext=hover_hse,
415
+ hoverinfo="text"
416
+ ))
417
+ fig.add_trace(go.Bar(
418
+ y=y_labels,
419
+ x=df_sorted['2th_org_value'][::-1],
420
+ orientation='h',
421
+ name='Совместители',
422
+ marker_color='orange',
423
+ text=org2_text,
424
+ textposition='inside',
425
+ insidetextanchor='middle',
426
+ hovertext=hover_2th,
427
+ hoverinfo="text"
428
+ ))
429
+ fig.add_trace(go.Bar(
430
+ y=y_labels,
431
+ x=df_sorted['other_orgs_value'][::-1],
432
+ orientation='h',
433
+ name='Другие',
434
+ marker_color='lightgray',
435
+ text=other_text,
436
+ textposition='inside',
437
+ insidetextanchor='middle',
438
+ hovertext=hover_other,
439
+ hoverinfo="text"
440
+ ))
441
+ fig.update_layout(
442
+ barmode='stack',
443
+ xaxis_title='Общая продуктивность публикаций кластера',
444
+ yaxis_title='Кластер',
445
+ height=max(600, 30*len(df_sorted)),
446
+ legend_title_text='',
447
+ template='simple_white',
448
+ xaxis=dict(side='top'),
449
+ legend=dict(
450
+ orientation="h",
451
+ x=0,
452
+ y=1.02,
453
+ xanchor='left',
454
+ yanchor='bottom'
455
+ )
456
+ )
457
+ return fig
458
+
459
+ # === Сеть соавторства (network graph)
460
+ def plotly_network_graph(
461
+ df, df_summary,
462
+ weight_mode="value",
463
+ show_singles=True,
464
+ node_size_scale_mode=NODE_SIZE_SCALE_MODE,
465
+ k=SPRING_K, iterations=SPRING_ITER
466
+ ):
467
+ import networkx as nx
468
+ G = nx.Graph()
469
+ for _, row in df.iterrows():
470
+ if not G.has_node(row['author_id']):
471
+ G.add_node(row['author_id'], cluster=row['cluster_id'])
472
+ pubs = df.groupby('pub_id')
473
+ edge_weights = {}
474
+ for pub_id, group in pubs:
475
+ authors = group['author_id'].tolist()
476
+ pub_value = group['fract_author_affiliation_value'].sum() if 'fract_author_affiliation_value' in group else 1
477
+ for i in range(len(authors)):
478
+ for j in range(i+1, len(authors)):
479
+ edge = tuple(sorted((authors[i], authors[j])))
480
+ if edge not in edge_weights:
481
+ edge_weights[edge] = {"count": 0, "value": 0.0}
482
+ edge_weights[edge]["count"] += 1
483
+ edge_weights[edge]["value"] += pub_value
484
+ for (a, b), ew in edge_weights.items():
485
+ G.add_edge(a, b, count=ew["count"], value=ew["value"])
486
+
487
+ if G.number_of_nodes() == 0 or G.number_of_edges() == 0:
488
+ return go.Figure(layout=go.Layout(
489
+ title="Нет кластеров для отображения",
490
+ margin=dict(t=60, b=30, l=10, r=10),
491
+ template="plotly_white"
492
+ ))
493
+
494
+ pos = nx.spring_layout(G, k=k, iterations=iterations, seed=42)
495
+
496
+ nice_colors = [
497
+ "#E53935", "#1E88E5", "#43A047", "#FDD835", "#8E24AA",
498
+ "#00ACC1", "#F4511E", "#3949AB", "#7CB342", "#FB8C00",
499
+ "#C2185B", "#00897B", "#C0CA33", "#5E35B1", "#039BE5",
500
+ "#E64A19", "#9E9D24", "#6D4C41", "#546E7A", "#D81B60",
501
+ "#F06292", "#7E57C2", "#26A69A", "#789262", "#FDD835",
502
+ ]
503
+ cluster_ids_sorted = sorted(df_summary["cluster_id"].unique())
504
+ palette = nice_colors * ((len(cluster_ids_sorted)//len(nice_colors))+2)
505
+ cluster_color_map = {
506
+ cluster: palette[i % len(palette)] for i, cluster in enumerate(cluster_ids_sorted)
507
+ }
508
+
509
+ is_single_dict = {}
510
+ if 'is_single' in df_summary.columns:
511
+ is_single_dict = dict(zip(df_summary["cluster_id"], df_summary["is_single"]))
512
+ else:
513
+ is_single_dict = {cid: False for cid in cluster_ids_sorted}
514
+ single_nodes = [n for n, data in G.nodes(data=True) if is_single_dict.get(data['cluster'], False)]
515
+ non_single_nodes = [n for n in G.nodes() if n not in single_nodes]
516
+
517
+ weights = [G[a][b][weight_mode] for a, b in G.edges()]
518
+ if weights:
519
+ w_arr = np.array(weights)
520
+ w_arr = np.log1p(w_arr)
521
+ wmin, wmax = w_arr.min(), w_arr.max()
522
+ def scale(w):
523
+ lw = np.log1p(w)
524
+ if wmax > wmin:
525
+ return EDGE_WIDTH_BASE + (EDGE_WIDTH_MAX - EDGE_WIDTH_BASE) * ((lw - wmin) / (wmax - wmin))
526
+ else:
527
+ return (EDGE_WIDTH_BASE + EDGE_WIDTH_MAX) / 2
528
+ else:
529
+ scale = lambda w: EDGE_WIDTH_BASE
530
+
531
+ edge_traces = []
532
+ for a, b in G.edges():
533
+ if not show_singles and (a in single_nodes or b in single_nodes):
534
+ continue
535
+ w = G[a][b][weight_mode]
536
+ width = scale(w)
537
+ x0, y0 = pos[a]
538
+ x1, y1 = pos[b]
539
+ edge_traces.append(
540
+ go.Scatter(
541
+ x=[x0, x1], y=[y0, y1],
542
+ mode='lines',
543
+ line=dict(width=width, color='#888'),
544
+ opacity=EDGE_ALPHA,
545
+ hoverinfo='skip',
546
+ showlegend=False,
547
+ )
548
+ )
549
+
550
+ node_sizes_raw = {}
551
+ for n in G.nodes():
552
+ if weight_mode == "count":
553
+ node_sizes_raw[n] = df[df['author_id'] == n]['pub_id'].nunique()
554
+ else:
555
+ node_sizes_raw[n] = df[df['author_id'] == n]['fract_author_affiliation_value'].sum()
556
+ node_size_values = np.array(list(node_sizes_raw.values()))
557
+ ns_min, ns_max = node_size_values.min(), node_size_values.max() if len(node_size_values) > 0 else (0, 1)
558
+
559
+ def scale_node_size(v):
560
+ if ns_max > ns_min:
561
+ norm = (v - ns_min) / (ns_max - ns_min)
562
+ else:
563
+ norm = 0.5
564
+ if node_size_scale_mode == "area":
565
+ min_area = np.pi * (NODE_SIZE_BASE / 2) ** 2
566
+ max_area = np.pi * (NODE_SIZE_MAX / 2) ** 2
567
+ area = min_area + (max_area - min_area) * norm
568
+ diameter = 2 * np.sqrt(area / np.pi)
569
+ return diameter
570
+ else: # "diameter"
571
+ return NODE_SIZE_BASE + (NODE_SIZE_MAX - NODE_SIZE_BASE) * norm
572
+
573
+ node_x, node_y, node_color, node_text, node_size = [], [], [], [], []
574
+ for node in non_single_nodes:
575
+ x, y = pos[node]
576
+ cluster_id = G.nodes[node]['cluster']
577
+ color = cluster_color_map.get(cluster_id, "#ccc")
578
+ n_pubs = df[df['author_id'] == node]['pub_id'].nunique()
579
+ n_value = df[df['author_id'] == node]['fract_author_affiliation_value'].sum()
580
+ author_short = author_id2short.get(node, str(node))
581
+ node_x.append(x)
582
+ node_y.append(y)
583
+ node_color.append(color)
584
+ node_text.append(
585
+ f"Авторский кластер: {int(cluster_id)}<br>Автор: {author_short}"
586
+ f"<br>Публикаций: {n_pubs}"
587
+ f"<br>Продуктивность: {n_value:.2f}"
588
+ )
589
+ sz = node_sizes_raw[node]
590
+ node_size.append(scale_node_size(sz))
591
+ node_trace = go.Scatter(
592
+ x=node_x, y=node_y,
593
+ mode='markers',
594
+ hoverinfo='text',
595
+ text=node_text,
596
+ marker=dict(
597
+ showscale=False,
598
+ color=node_color,
599
+ size=node_size,
600
+ line_width=1
601
+ ),
602
+ name="Кластеры"
603
+ )
604
+
605
+ single_x, single_y, single_color, single_text, single_size = [], [], [], [], []
606
+ for node in single_nodes:
607
+ x, y = pos[node]
608
+ cluster_id = G.nodes[node]['cluster']
609
+ color = cluster_color_map.get(cluster_id, "#ccc")
610
+ n_pubs = df[df['author_id'] == node]['pub_id'].nunique()
611
+ n_value = df[df['author_id'] == node]['fract_author_affiliation_value'].sum()
612
+ author_short = author_id2short.get(node, str(node))
613
+ single_x.append(x)
614
+ single_y.append(y)
615
+ single_color.append(color)
616
+ single_text.append(
617
+ f"Моноавторский кластер: {int(cluster_id)}<br>Автор: {author_short}"
618
+ f"<br>Публикаций: {n_pubs}"
619
+ f"<br>Продуктивность: {n_value:.2f}"
620
+ )
621
+ sz = node_sizes_raw[node]
622
+ single_size.append(scale_node_size(sz))
623
+ single_node_trace = go.Scatter(
624
+ x=single_x, y=single_y,
625
+ mode='markers',
626
+ hoverinfo='text',
627
+ text=single_text,
628
+ marker=dict(
629
+ showscale=False,
630
+ color='rgba(0,0,0,0)',
631
+ size=single_size,
632
+ line=dict(width=SINGLE_NODE_BORDER_WIDTH, color=single_color)
633
+ ),
634
+ name="Одиночные авторы",
635
+ visible=show_singles
636
+ )
637
+
638
+ traces = edge_traces + [node_trace]
639
+ if show_singles and len(single_nodes) > 0:
640
+ traces.append(single_node_trace)
641
+
642
+ fig = go.Figure(
643
+ data=traces,
644
+ layout=go.Layout(
645
+ showlegend=False,
646
+ hovermode='closest',
647
+ margin=dict(b=10, l=10, r=10, t=10),
648
+ xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
649
+ yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
650
+ template="plotly_white"
651
+ )
652
+ )
653
+ return fig
654
+
655
+ def shorten_text(s, max_len=60):
656
+ if not isinstance(s, str) or len(s) <= max_len:
657
+ return s
658
+ cut = s[:max_len]
659
+ if " " in cut:
660
+ cut = cut[:cut.rfind(" ")]
661
+ return cut.strip() + " ..."
662
+
663
+ # ========== LAYOUT И CALLBACKS =============
664
+
665
+ # Подготовка данных для продуктивности (делается единожды)
666
+ df_prod, multi_affil_prod = load_and_prepare_data(YEAR_START, YEAR_END, ORG_ID)
667
+
668
+ app = dash.Dash(
669
+ __name__,
670
+ title="Вузометрия.РФ",
671
+ update_title="Загрузка...",
672
+ meta_tags=[
673
+ {"name": "description", "content": "Вузометрия.РФ – измеряем университеты"}
674
+ ]
675
+ )
676
+
677
+ app.layout = html.Div([
678
+ # Верхний заголовок
679
+ html.Div(
680
+ "Московский государственный институт электроники и математики (МИЭМ) НИУ ВШЭ",
681
+ style={
682
+ "fontFamily": "Arial, sans-serif",
683
+ "fontSize": "22px",
684
+ "fontWeight": "bold",
685
+ "textAlign": "center",
686
+ "color": "#123157",
687
+ "padding": "22px 0 4px 0",
688
+ }
689
+ ),
690
+
691
+ # === График продуктивности с подложкой ===
692
+ html.Div([
693
+ html.H4(
694
+ "Динамика публикационной продуктивности",
695
+ style={
696
+ "margin-bottom": "12px",
697
+ "marginTop": "6px",
698
+ "font-family": "Arial, sans-serif",
699
+ "font-weight": "bold",
700
+ "text-align": "center",
701
+ "font-size": "20px",
702
+ "color": "#22335b",
703
+ }
704
+ ),
705
+ dcc.Loading(
706
+ id="loading-productivity-graph",
707
+ type="circle",
708
+ color="#22335b",
709
+ children=[
710
+ dcc.Graph(
711
+ id='productivity-graph',
712
+ figure=build_productivity_figure(df_prod, multi_affil_prod),
713
+ style={
714
+ "width": "100%",
715
+ "height": "470px",
716
+ "padding": "0"
717
+ },
718
+ config={
719
+ "displaylogo": False,
720
+ "modeBarButtonsToRemove": ["sendDataToCloud"]
721
+ }
722
+ ),
723
+ ]
724
+ ),
725
+ ], style={
726
+ 'background': '#fff',
727
+ 'border-radius': '18px',
728
+ 'box-shadow': '0 0 8px #ccc4',
729
+ 'padding': '22px 12px 12px 12px',
730
+ 'width': '90%',
731
+ 'margin': '20px auto 20px auto'
732
+ }),
733
+
734
+ dcc.Store(id='sidebar-state', data={'show': False}),
735
+ # Кнопка-гамбургер
736
+ html.Button('☰', id='toggle-sidebar', n_clicks=0, style={
737
+ "fontSize": "24px",
738
+ "margin": "0 0 0 2px",
739
+ "padding": "2px 2px",
740
+ 'position': 'fixed',
741
+ 'top': '0px',
742
+ 'left': '0px',
743
+ 'zIndex': 1102
744
+ }),
745
+ # Сайдбар
746
+ html.Div([
747
+ html.Img(
748
+ src='/assets/logo.png',
749
+ style={
750
+ "width": "110px",
751
+ "margin": "0 auto",
752
+ "display": "block",
753
+ "marginBottom": "8px"
754
+ }
755
+ ),
756
+ html.Div(
757
+ "Вузометрия.РФ",
758
+ style={
759
+ "fontFamily": "Arial, sans-serif",
760
+ "fontSize": "22px",
761
+ "fontWeight": "bold",
762
+ "textAlign": "center",
763
+ "color": "#22335b",
764
+ "marginBottom": "2px"
765
+ }
766
+ ),
767
+ html.Div(
768
+ "Вы опрашиваете? Мы — измеряем",
769
+ style={
770
+ "fontFamily": "Arial, sans-serif",
771
+ "fontSize": "13px",
772
+ "fontWeight": "normal",
773
+ "textAlign": "center",
774
+ "color": "#666",
775
+ "marginBottom": "7px"
776
+ }
777
+ ),
778
+ html.H3("Общие настройки", style={
779
+ "margin-bottom": "10px",
780
+ "fontSize": "15px",
781
+ "fontWeight": "bold",
782
+ "marginTop": "10px"
783
+ }),
784
+ html.Label("Организация:", style={
785
+ "margin-bottom": "3px",
786
+ "fontSize": "13px"
787
+ }),
788
+ dcc.Dropdown(
789
+ id='org-dropdown',
790
+ options=[{"label": "МИЭМ НИУ ВШЭ", "value": "miem_hse"}],
791
+ value="miem_hse",
792
+ style={'width': '100%', "margin-bottom": "7px", "fontSize": "13px"},
793
+ searchable=False,
794
+ clearable=False,
795
+ disabled=True
796
+ ),
797
+ html.Label("Период:", style={
798
+ "margin-bottom": "3px",
799
+ "fontSize": "13px"
800
+ }),
801
+ dcc.Dropdown(
802
+ id='period-dropdown',
803
+ options=[{"label": p["label"], "value": p["key"]} for p in PERIODS],
804
+ value=PERIODS[2]["key"],
805
+ style={'width': '100%', "margin-bottom": "13px", "fontSize": "13px"},
806
+ searchable=False,
807
+ clearable=False
808
+ ),
809
+ html.Div([
810
+ html.Span(
811
+ "© Антон Лощилов, 2025",
812
+ style={
813
+ "fontFamily": "Arial, sans-serif",
814
+ "fontSize": "11px",
815
+ "color": "#aaa"
816
+ }
817
+ ),
818
+ html.Span(
819
+ "v. 0.10",
820
+ style={
821
+ "fontFamily": "Arial, sans-serif",
822
+ "fontSize": "11.5px",
823
+ "fontWeight": "bold",
824
+ "color": "#aaa"
825
+ }
826
+ ),
827
+ ], style={
828
+ "position": "absolute",
829
+ "bottom": "10px",
830
+ "left": "0",
831
+ "right": "0",
832
+ "width": "92%",
833
+ "margin": "0 4%",
834
+ "display": "flex",
835
+ "flexDirection": "row",
836
+ "justifyContent": "space-between"
837
+ })
838
+ ], id='sidebar-content', style={
839
+ 'width': '310px',
840
+ 'padding': '18px 16px 18px 16px',
841
+ 'background': '#f8f8f8',
842
+ 'border-radius': '18px',
843
+ 'box-shadow': '0 0 22px #ccc8',
844
+ 'font-family': 'Arial, sans-serif',
845
+ 'font-size': '13px',
846
+ 'overflowY': 'auto',
847
+ 'zIndex': 1101,
848
+ 'position': 'fixed',
849
+ 'top': '60px',
850
+ 'left': '18px',
851
+ 'minHeight': '450px',
852
+ 'maxHeight': '95vh',
853
+ 'transition': 'opacity 0.35s, pointer-events 0.35s',
854
+ 'opacity': 0,
855
+ 'pointerEvents': 'none',
856
+ 'display': 'none'
857
+ }
858
+ ),
859
+
860
+ # === Основное содержимое страницы ===
861
+ html.Div([
862
+ # --- 1. ГРНТИ-карта (тримап) ---
863
+ html.Div([
864
+ html.H4(id='grnti-main-title', style={
865
+ "margin-bottom": "12px",
866
+ "marginTop": "6px",
867
+ "font-family": "Arial, sans-serif",
868
+ "font-weight": "bold",
869
+ "text-align": "center",
870
+ "font-size": "20px",
871
+ "color": "#22335b",
872
+ }),
873
+ html.Div([
874
+ html.Label("Тип взвешивания:", style={
875
+ "marginRight": "12px",
876
+ "fontFamily": 'Open Sans, Arial, sans-serif',
877
+ "fontSize": "14px",
878
+ "whiteSpace": "nowrap"
879
+ }),
880
+ dcc.RadioItems(
881
+ id='grnti-norm-mode',
882
+ options=[
883
+ {'label': 'По продуктивности', 'value': 'value'},
884
+ {'label': 'По числу публикаций', 'value': 'frac_npubs'},
885
+ ],
886
+ value='value',
887
+ labelStyle={'display': 'inline-block', 'margin-right': '16px', 'fontSize': '14px', 'fontFamily': 'Arial, sans-serif'},
888
+ inputStyle={"margin-right": "5px"},
889
+ style={'display': 'inline-block'}
890
+ ),
891
+ ], style={
892
+ "display": "flex",
893
+ "alignItems": "center",
894
+ "gap": "8px",
895
+ "marginBottom": "5px",
896
+ "marginLeft": "18px"
897
+ }),
898
+ dcc.Loading(
899
+ id="loading-grnti-treemap",
900
+ type="circle",
901
+ color="#22335b",
902
+ children=[
903
+ dcc.Graph(
904
+ id='grnti-treemap-graph',
905
+ style={'width': '100%'},
906
+ config={
907
+ 'displayModeBar': True,
908
+ 'displaylogo': False
909
+ }
910
+ )
911
+ ]
912
+ ),
913
+ ], style={
914
+ 'background': '#fff',
915
+ 'border-radius': '18px',
916
+ 'box-shadow': '0 0 8px #ccc4',
917
+ 'padding': '22px 12px 12px 12px',
918
+ 'width': '90%',
919
+ 'margin': '20px auto 20px auto',
920
+ }),
921
+
922
+ # --- 2. Карта соавторства ---
923
+ html.Div([
924
+ html.H4(id='main-title', style={
925
+ "margin-bottom": "12px",
926
+ "marginTop": "6px",
927
+ "font-family": "Arial, sans-serif",
928
+ "font-weight": "bold",
929
+ "text-align": "center",
930
+ "font-size": "20px",
931
+ "color": "#22335b",
932
+ }),
933
+ html.Div([
934
+ html.Label("Тип взвешивания:", style={
935
+ "margin-bottom": "0",
936
+ "font-family": "Arial, sans-serif",
937
+ "font-size": "14px"
938
+ }),
939
+ html.Div([
940
+ dcc.RadioItems(
941
+ id='weight-mode',
942
+ options=[
943
+ {"label": "По продуктивности", "value": "value"},
944
+ {"label": "По числу публикаций", "value": "count"}
945
+ ],
946
+ value="value",
947
+ labelStyle={'display': 'inline-block', 'margin-right': '16px'},
948
+ inputStyle={"margin-right": "4px"},
949
+ style={"margin-bottom": "0"}
950
+ ),
951
+ dcc.Checklist(
952
+ id='show-singles',
953
+ options=[{"label": "Показывать одиночных авторов", "value": "show"}],
954
+ value=[],
955
+ style={
956
+ "margin-left": "28px",
957
+ "font-family": "Arial, sans-serif",
958
+ "font-size": "14px",
959
+ "display": "inline-block",
960
+ "verticalAlign": "middle"
961
+ },
962
+ inputStyle={"margin-right": "4px"}
963
+ ),
964
+ ], style={
965
+ "display": "flex",
966
+ "alignItems": "center",
967
+ "margin-bottom": "10px"
968
+ }),
969
+ ], style={
970
+ "margin-bottom": "18px",
971
+ "margin-left": "14px"
972
+ }),
973
+ dcc.Loading(
974
+ id="loading-cluster-graph",
975
+ type="circle",
976
+ color="#22335b",
977
+ children=[
978
+ dcc.Graph(
979
+ id='cluster-graph',
980
+ style={"width": "100%"},
981
+ config={
982
+ 'displaylogo': False,
983
+ 'modeBarButtonsToRemove': ['sendDataToCloud']
984
+ }
985
+ )
986
+ ]
987
+ ),
988
+ ], style={
989
+ 'background': '#fff',
990
+ 'border-radius': '18px',
991
+ 'box-shadow': '0 0 8px #ccc4',
992
+ 'padding': '22px 12px 12px 12px',
993
+ 'width': '90%',
994
+ 'margin': '20px auto 20px auto'
995
+ }),
996
+
997
+ # --- 3. Семантическая карта ---
998
+ html.Div([
999
+ html.H4(
1000
+ id='science-map-title',
1001
+ style={
1002
+ "margin-bottom": "12px",
1003
+ "marginTop": "6px",
1004
+ "font-family": "Arial, sans-serif",
1005
+ "font-weight": "bold",
1006
+ "text-align": "center",
1007
+ "font-size": "20px",
1008
+ "color": "#22335b",
1009
+ }
1010
+ ),
1011
+ html.Div([
1012
+ html.Label("Размер маркера:", style={
1013
+ "fontFamily": "Arial, sans-serif",
1014
+ "fontSize": "14px",
1015
+ "marginRight": "8px",
1016
+ "whiteSpace": "nowrap"
1017
+ }),
1018
+ dcc.Dropdown(
1019
+ id='size-dropdown',
1020
+ options=[
1021
+ {'label': 'Общая продуктивность', 'value': 'value'},
1022
+ {'label': 'Вклад организации', 'value': 'org_value'}
1023
+ ],
1024
+ value='value',
1025
+ style={
1026
+ "fontFamily": "Arial, sans-serif",
1027
+ "fontSize": "14px",
1028
+ "width": "250px",
1029
+ "marginRight": "30px"
1030
+ },
1031
+ searchable=False,
1032
+ clearable=False
1033
+ ),
1034
+ html.Label("Легенда:", style={
1035
+ "fontFamily": "Arial, sans-serif",
1036
+ "fontSize": "14px",
1037
+ "marginRight": "8px",
1038
+ "whiteSpace": "nowrap"
1039
+ }),
1040
+ dcc.Dropdown(
1041
+ id='color-dropdown',
1042
+ options=[
1043
+ {'label': 'Авторские кластеры', 'value': 'author_cluster_id'},
1044
+ {'label': 'Семантические кластеры', 'value': 'semantic_cluster_id'}
1045
+ ],
1046
+ value='author_cluster_id',
1047
+ style={
1048
+ "fontFamily": "Arial, sans-serif",
1049
+ "fontSize": "14px",
1050
+ "width": "250px"
1051
+ },
1052
+ searchable=False,
1053
+ clearable=False
1054
+ ),
1055
+ ], style={
1056
+ "display": "flex",
1057
+ "flexDirection": "row",
1058
+ "alignItems": "center",
1059
+ "marginBottom": "18px"
1060
+ }),
1061
+ html.Div([
1062
+ dcc.Loading(
1063
+ id="loading-science-map-plot",
1064
+ type="circle",
1065
+ color="#22335b",
1066
+ children=[
1067
+ dcc.Graph(
1068
+ id='science-map-plot',
1069
+ style={"width": "100%", "height": "850px"},
1070
+ config={
1071
+ 'displaylogo': False,
1072
+ 'modeBarButtonsToRemove': ['sendDataToCloud']
1073
+ }
1074
+ )
1075
+ ]
1076
+ )
1077
+ ], style={
1078
+ "width": "65%",
1079
+ "display": "inline-block",
1080
+ "verticalAlign": "top"
1081
+ }),
1082
+ html.Div([
1083
+ html.Div([
1084
+ html.Div(id='detail-title'),
1085
+ html.Div(id='detail-table')
1086
+ ], style={
1087
+ 'background': '#fafbfc',
1088
+ 'border-radius': '16px',
1089
+ 'box-shadow': '0 0 8px #ccc4',
1090
+ 'padding': '18px 14px 12px 14px',
1091
+ 'margin-top': '18px',
1092
+ 'font-size': '14px',
1093
+ 'height': '800px',
1094
+ 'overflowY': 'auto',
1095
+ 'display': 'flex',
1096
+ 'flexDirection': 'column'
1097
+ })
1098
+ ], style={
1099
+ "width": "30%",
1100
+ "display": "inline-block",
1101
+ "verticalAlign": "top",
1102
+ "paddingLeft": "24px"
1103
+ })
1104
+ ], style={
1105
+ 'background': '#fff',
1106
+ 'padding': '22px 12px 12px 12px',
1107
+ 'border-radius': '18px',
1108
+ 'box-shadow': '0 0 8px #ccc4',
1109
+ 'width': '90%',
1110
+ 'margin': '20px auto 20px auto'
1111
+ }),
1112
+
1113
+ # --- 4. Bar-график ---
1114
+ html.Div([
1115
+ html.H4(id='bar-title', style={
1116
+ "margin-bottom": "12px",
1117
+ "marginTop": "6px",
1118
+ "font-family": "Arial, sans-serif",
1119
+ "font-weight": "bold",
1120
+ "text-align": "center",
1121
+ "font-size": "20px",
1122
+ "color": "#22335b",
1123
+ }),
1124
+ html.Div([
1125
+ html.Span("Сортировка:", style={
1126
+ "marginRight": "10px",
1127
+ "fontFamily": 'Open Sans, Arial, sans-serif',
1128
+ "fontSize": "14px"
1129
+ }),
1130
+ dcc.Dropdown(
1131
+ id='sort-dropdown',
1132
+ options=[
1133
+ {'label': 'по ядру организации', 'value': 'hse'},
1134
+ {'label': 'по ядру организации и совместителям', 'value': 'hse_plus_2th'},
1135
+ {'label': 'по суммарной продуктивности', 'value': 'sum'}
1136
+ ],
1137
+ value='hse',
1138
+ searchable=False,
1139
+ clearable=False,
1140
+ style={
1141
+ 'width': '280px',
1142
+ 'fontFamily': 'Open Sans, Arial, sans-serif',
1143
+ 'fontSize': '14px',
1144
+ 'verticalAlign': 'middle'
1145
+ }
1146
+ ),
1147
+ ], style={
1148
+ "display": "flex",
1149
+ "flexDirection": "row",
1150
+ "alignItems": "center",
1151
+ "marginBottom": "4px"
1152
+ }),
1153
+ dcc.Loading(
1154
+ id="loading-bar-graph",
1155
+ type="circle",
1156
+ color="#22335b",
1157
+ children=[
1158
+ dcc.Graph(
1159
+ id='bar-graph',
1160
+ style={'width': '100%'},
1161
+ config={
1162
+ 'displaylogo': False,
1163
+ 'modeBarButtonsToRemove': ['sendDataToCloud']
1164
+ }
1165
+ )
1166
+ ]
1167
+ ),
1168
+ ], style={
1169
+ 'background': '#fff',
1170
+ 'border-radius': '18px',
1171
+ 'box-shadow': '0 0 8px #ccc4',
1172
+ 'padding': '22px 12px 12px 12px',
1173
+ 'width': '90%',
1174
+ 'margin': '20px auto 20px auto'
1175
+ }),
1176
+
1177
+ # --- Кнопка ОТЧЕТ и Download ---
1178
+ html.Div([
1179
+ html.Button(
1180
+ "ОТЧЕТ",
1181
+ id="download-report-btn",
1182
+ className="fancy-download-btn",
1183
+ ),
1184
+ dcc.Download(id="download-report"),
1185
+ ], style={
1186
+ "width": "100%",
1187
+ "textAlign": "center",
1188
+ "marginBottom": "0"
1189
+ }),
1190
+ ], id='main-content', style={
1191
+ 'width': '100%',
1192
+ 'paddingLeft': '0',
1193
+ 'transition': 'none'
1194
+ }),
1195
+ ], style={
1196
+ 'width': '100%',
1197
+ 'overflowX': 'hidden',
1198
+ 'position': 'relative',
1199
+ 'minHeight': '100vh',
1200
+ 'background': '#fcfcfc'
1201
+ })
1202
+
1203
+
1204
+
1205
+ # === GRNTI-TreeMap ===
1206
+ @app.callback(
1207
+ Output('grnti-treemap-graph', 'figure'),
1208
+ Output('grnti-main-title', 'children'),
1209
+ Input('period-dropdown', 'value'),
1210
+ Input('grnti-norm-mode', 'value')
1211
+ )
1212
+ def update_grnti_treemap(period_value, norm_mode):
1213
+ period = next((p for p in PERIODS if p['key'] == period_value), PERIODS[0])
1214
+ df = grnti_load_df_by_period(period['key'])
1215
+ title = f"Карта рубрик ГРНТИ ({period['label']})"
1216
+ fig = grnti_make_treemap(df, norm_mode)
1217
+ return fig, title
1218
+
1219
+ # === Sidebar ===
1220
+ @app.callback(
1221
+ Output('sidebar-content', 'style'),
1222
+ Output('sidebar-state', 'data'),
1223
+ Input('toggle-sidebar', 'n_clicks'),
1224
+ State('sidebar-state', 'data'),
1225
+ prevent_initial_call=True
1226
+ )
1227
+ def toggle_sidebar(n_clicks, sidebar_state):
1228
+ show = not sidebar_state.get('show', False)
1229
+ base_style = {
1230
+ 'width': '310px',
1231
+ 'padding': '18px 16px 18px 16px',
1232
+ 'background': '#f8f8f8',
1233
+ 'border-radius': '18px',
1234
+ 'box-shadow': '0 0 22px #ccc8',
1235
+ 'font-family': 'Arial, sans-serif',
1236
+ 'font-size': '13px',
1237
+ 'overflowY': 'auto',
1238
+ 'zIndex': 1101,
1239
+ 'transition': 'opacity 0.35s, pointer-events 0.35s',
1240
+ 'position': 'fixed',
1241
+ 'top': '60px',
1242
+ 'left': '18px',
1243
+ 'minHeight': '450px',
1244
+ 'maxHeight': '95vh'
1245
+ }
1246
+ if show:
1247
+ base_style['opacity'] = 1
1248
+ base_style['pointerEvents'] = 'auto'
1249
+ base_style['display'] = 'block'
1250
+ else:
1251
+ base_style['opacity'] = 0
1252
+ base_style['pointerEvents'] = 'none'
1253
+ base_style['display'] = 'none'
1254
+ return base_style, {'show': show}
1255
+
1256
+ # === Кластеры и bar ===
1257
+ @app.callback(
1258
+ Output('main-title', 'children'),
1259
+ Output('cluster-graph', 'figure'),
1260
+ Output('bar-title', 'children'),
1261
+ Output('bar-graph', 'figure'),
1262
+ Input('period-dropdown', 'value'),
1263
+ Input('weight-mode', 'value'),
1264
+ Input('show-singles', 'value'),
1265
+ Input('sort-dropdown', 'value'),
1266
+ )
1267
+ def update_graphs(period_key, weight_mode, show_singles, sort_by):
1268
+ period = next((p for p in PERIODS if p['key'] == period_key), PERIODS[0])
1269
+ period_years = period["label"]
1270
+ main_title = f"Карта соавторства ({period_years})"
1271
+ bar_title = f"Продуктивность авторских кластеров ({period_years})"
1272
+ df, df_summary = load_cluster_data(period['key'])
1273
+ show_singles_flag = "show" in (show_singles if show_singles else [])
1274
+ fig = plotly_network_graph(
1275
+ df, df_summary,
1276
+ weight_mode=weight_mode,
1277
+ show_singles=show_singles_flag,
1278
+ node_size_scale_mode="diameter"
1279
+ )
1280
+ bar_df = load_bar_data(period['key'])
1281
+ bar_fig = make_figure(bar_df, period['key'], sort_by)
1282
+ return main_title, fig, bar_title, bar_fig
1283
+
1284
+ # === Семантическая карта ===
1285
+ @app.callback(
1286
+ Output('science-map-title', 'children'),
1287
+ Input('period-dropdown', 'value')
1288
+ )
1289
+ def update_science_map_title(period_key):
1290
+ period = next((p for p in PERIODS if p['key'] == period_key), PERIODS[0])
1291
+ period_years = period["label"]
1292
+ return f"Семантическая карта ({period_years})"
1293
+
1294
+ @app.callback(
1295
+ Output('science-map-plot', 'figure'),
1296
+ [Input('period-dropdown', 'value'),
1297
+ Input('size-dropdown', 'value'),
1298
+ Input('color-dropdown', 'value')]
1299
+ )
1300
+ def update_science_map(period_key, size_col, color_col):
1301
+ period = next((p for p in PERIODS if p['key'] == period_key), PERIODS[0])
1302
+ period_label = period["label"]
1303
+ df = load_science_map_sheet(period_label)
1304
+ df[size_col] = pd.to_numeric(df[size_col], errors='coerce')
1305
+ df['pub_title_short'] = df['pub_title'].apply(lambda s: shorten_text(s, MAX_LEN_HOVER))
1306
+ df['pub_authors_short'] = df['pub_authors'].apply(lambda s: shorten_text(s, MAX_LEN_HOVER))
1307
+ # ВАЖНО: Все cluster_id к строкам!
1308
+ df['author_cluster_id'] = df['author_cluster_id'].astype(str)
1309
+ df['semantic_cluster_id'] = df['semantic_cluster_id'].astype(str)
1310
+ cluster_ids = cluster_sorter(df[color_col].unique())
1311
+ # Готовим свою цветовую карту:
1312
+ palette = px.colors.qualitative.Alphabet if len(cluster_ids) <= 20 else px.colors.qualitative.Light24
1313
+ color_discrete_map = {k: palette[i % len(palette)] for i, k in enumerate(cluster_ids)}
1314
+
1315
+ custom_data = [
1316
+ 'pub_id', 'pub_title_short', 'author_cluster_id', 'semantic_cluster_id',
1317
+ 'pub_source', 'year', 'pub_authors_short', 'org_value', 'value', 'umap_x', 'umap_y'
1318
+ ]
1319
+ fig = px.scatter(
1320
+ df,
1321
+ x='umap_x',
1322
+ y='umap_y',
1323
+ color=color_col,
1324
+ size=size_col,
1325
+ hover_data=[],
1326
+ custom_data=custom_data,
1327
+ template="plotly_white",
1328
+ height=850,
1329
+ category_orders={color_col: cluster_ids},
1330
+ color_discrete_map=color_discrete_map
1331
+ )
1332
+ hovertemplate = (
1333
+ "<b>%{customdata[1]}</b><br>"
1334
+ "Авторы: %{customdata[6]}<br>"
1335
+ "Источник: %{customdata[4]}<br>"
1336
+ "Год: %{customdata[5]}<br>"
1337
+ "ID публикации: %{customdata[0]}<br>"
1338
+ "Авторский кластер: %{customdata[2]}<br>"
1339
+ "Семантический кластер: %{customdata[3]}<br>"
1340
+ "Общая продуктивность: %{customdata[8]}<br>"
1341
+ "Вклад организации: %{customdata[7]}<br>"
1342
+ "Координаты: (%{customdata[9]}, %{customdata[10]})<br>"
1343
+ "<extra></extra>"
1344
+ )
1345
+ fig.update_traces(
1346
+ hovertemplate=hovertemplate,
1347
+ marker=dict(opacity=0.5, line=dict(width=1), sizemin=7),
1348
+ selector=dict(mode='markers')
1349
+ )
1350
+ fig.update_layout(
1351
+ hoverlabel=dict(font_size=10, font_family="Arial"),
1352
+ legend_title_text="",
1353
+ xaxis_title="X",
1354
+ yaxis_title="Y",
1355
+ legend=dict(
1356
+ orientation="h", yanchor="bottom", y=-0.8, xanchor="center", x=0.5,
1357
+ # Можно добавить ещё стилей, если надо
1358
+ ),
1359
+ margin=dict(l=40, r=40, t=60, b=40),
1360
+ )
1361
+ return fig
1362
+
1363
+
1364
+ @app.callback(
1365
+ [Output('detail-title', 'children'),
1366
+ Output('detail-table', 'children')],
1367
+ [Input('science-map-plot', 'clickData'),
1368
+ Input('period-dropdown', 'value')]
1369
+ )
1370
+ def show_details(clickData, period_key):
1371
+ # Безопасный выбор периода
1372
+ period = next((p for p in PERIODS if p['key'] == period_key), None)
1373
+ if not period:
1374
+ return html.Div(
1375
+ "Ошибка: некорректные параметры периода! Проверьте PERIODS.",
1376
+ style={
1377
+ 'fontFamily': 'Arial, sans-serif',
1378
+ 'fontSize': '13px',
1379
+ 'color': '#c00',
1380
+ 'padding': '12px 0',
1381
+ 'textAlign': 'center'
1382
+ }
1383
+ ), ""
1384
+
1385
+ if clickData is None:
1386
+ return html.Div(
1387
+ "Кликните по точке для подробностей.",
1388
+ style={
1389
+ 'fontFamily': 'Arial, sans-serif',
1390
+ 'fontSize': '13px',
1391
+ 'color': '#888',
1392
+ 'padding': '12px 0',
1393
+ 'textAlign': 'center'
1394
+ }
1395
+ ), ""
1396
+
1397
+ pub_id = clickData['points'][0]['customdata'][0]
1398
+
1399
+ # Загружаем данные для выбранного периода
1400
+ df = load_science_map_sheet(period['label'])
1401
+ # Проверка — есть ли такая публикация?
1402
+ df_row = df[df['pub_id'] == pub_id]
1403
+ if df_row.empty:
1404
+ return html.Div(
1405
+ f"Публикация с pub_id={pub_id} не найдена в данных за период!",
1406
+ style={
1407
+ 'fontFamily': 'Arial, sans-serif',
1408
+ 'fontSize': '13px',
1409
+ 'color': '#c00',
1410
+ 'padding': '12px 0',
1411
+ 'textAlign': 'center'
1412
+ }
1413
+ ), ""
1414
+ row = df_row.iloc[0]
1415
+
1416
+ # --- Дальше оригинальный код по формированию detail-title и detail-table ---
1417
+ title = html.Div([
1418
+ html.Div(row['pub_title'], style={'fontWeight': 'bold', 'fontSize': '12px', 'marginBottom': '3px'}),
1419
+ html.Div([
1420
+ f"ID публикации: {row['pub_id']}", html.Br(),
1421
+ f"Авторский кластер: {row['author_cluster_id']}", html.Br(),
1422
+ f"Семантический кластер: {row['semantic_cluster_id']}", html.Br(),
1423
+ f"Источник: {row['pub_source']}", html.Br(),
1424
+ f"Год: {row['year']}", html.Br(),
1425
+ ], style={'fontSize': '10px', 'color': '#555'}),
1426
+ ], style={'marginBottom': '5px'})
1427
+
1428
+ abs_text = None
1429
+ sum_en = None
1430
+ sum_ru = None
1431
+ try:
1432
+ content_row = pub_content_df[pub_content_df['pub_id'] == pub_id]
1433
+ if not content_row.empty:
1434
+ abs_text = content_row.iloc[0]['abstract']
1435
+ sum_en = content_row.iloc[0]['summary_en']
1436
+ sum_ru = content_row.iloc[0]['summary_ru']
1437
+ except Exception:
1438
+ abs_text = sum_en = sum_ru = ""
1439
+
1440
+ author_line = row['pub_authors']
1441
+ authors_block = html.Div([
1442
+ html.Div("Авторы:", style={
1443
+ 'fontWeight': 'bold',
1444
+ 'fontSize': '12px',
1445
+ 'marginTop': '8px',
1446
+ 'marginBottom': '2px'
1447
+ }),
1448
+ html.Div(
1449
+ author_line,
1450
+ style={
1451
+ 'fontSize': '10px',
1452
+ 'color': '#222',
1453
+ 'background': '#f8f8f8',
1454
+ 'borderRadius': '8px',
1455
+ 'padding': '8px 10px',
1456
+ 'marginBottom': '5px',
1457
+ 'maxHeight': '70px',
1458
+ 'overflowY': 'auto'
1459
+ }
1460
+ ),
1461
+ ])
1462
+
1463
+ abstract_block = None
1464
+ if abs_text and isinstance(abs_text, str) and abs_text.strip():
1465
+ abstract_block = html.Div([
1466
+ html.Div("Аннотация:", style={
1467
+ 'fontWeight': 'bold',
1468
+ 'fontSize': '12px',
1469
+ 'marginTop': '8px',
1470
+ 'marginBottom': '2px'
1471
+ }),
1472
+ html.Div(abs_text, style={
1473
+ 'fontSize': '10px',
1474
+ 'color': '#222',
1475
+ 'background': '#f8f8f8',
1476
+ 'borderRadius': '8px',
1477
+ 'padding': '8px 10px',
1478
+ 'marginBottom': '5px',
1479
+ 'maxHeight': '200px',
1480
+ 'overflowY': 'auto'
1481
+ }),
1482
+ ])
1483
+
1484
+ summary_en_block = None
1485
+ if sum_en and isinstance(sum_en, str) and sum_en.strip():
1486
+ summary_en_block = html.Div([
1487
+ html.Div("Текст вектора EN:", style={
1488
+ 'fontWeight': 'bold',
1489
+ 'fontSize': '12px',
1490
+ 'marginTop': '10px',
1491
+ 'marginBottom': '2px'
1492
+ }),
1493
+ html.Div(sum_en, style={
1494
+ 'fontSize': '10px',
1495
+ 'color': '#222',
1496
+ 'background': '#f8f8f8',
1497
+ 'borderRadius': '8px',
1498
+ 'padding': '8px 10px',
1499
+ 'marginBottom': '5px'
1500
+ }),
1501
+ ])
1502
+
1503
+ summary_ru_block = None
1504
+ if sum_ru and isinstance(sum_ru, str) and sum_ru.strip():
1505
+ summary_ru_block = html.Div([
1506
+ html.Div("Текст вектора RU:", style={
1507
+ 'fontWeight': 'bold',
1508
+ 'fontSize': '12px',
1509
+ 'marginTop': '10px',
1510
+ 'marginBottom': '2px'
1511
+ }),
1512
+ html.Div(sum_ru, style={
1513
+ 'fontSize': '10px',
1514
+ 'color': '#222',
1515
+ 'background': '#f4f4ff',
1516
+ 'borderRadius': '8px',
1517
+ 'padding': '8px 10px',
1518
+ 'marginBottom': '5px'
1519
+ }),
1520
+ ])
1521
+
1522
+ v = float(row['value']) if not pd.isnull(row['value']) else 0
1523
+ ov = float(row['org_value']) if not pd.isnull(row['org_value']) else 0
1524
+ if ov > v:
1525
+ ov = v
1526
+ other_value = max(v - ov, 0)
1527
+ pie_fig = go.Figure(go.Pie(
1528
+ labels=['Организация', 'Остальные'],
1529
+ values=[ov, other_value],
1530
+ hole=0.5,
1531
+ domain=dict(x=[0.2, 0.8], y=[0.2, 0.8])
1532
+ ))
1533
+ pie_fig.update_traces(
1534
+ textinfo='percent',
1535
+ textfont_size=11,
1536
+ marker=dict(line=dict(color='#fff', width=1))
1537
+ )
1538
+ pie_fig.update_layout(
1539
+ showlegend=False,
1540
+ width=250, height=250,
1541
+ margin=dict(l=0, r=0, t=0, b=0),
1542
+ paper_bgcolor='#fafbfc'
1543
+ )
1544
+
1545
+ detail_items = []
1546
+ detail_items.append(authors_block)
1547
+ if abstract_block:
1548
+ detail_items.append(abstract_block)
1549
+ if SHOW_SUMMARY_EN and summary_en_block:
1550
+ detail_items.append(summary_en_block)
1551
+ if SHOW_SUMMARY_RU and summary_ru_block:
1552
+ detail_items.append(summary_ru_block)
1553
+ detail_items.extend([
1554
+ html.Div("Вклад в продуктивность:", style={
1555
+ 'fontWeight': 'bold', 'fontSize': '12px', 'marginTop': '8px', 'marginBottom': '2px'
1556
+ }),
1557
+ dcc.Graph(
1558
+ figure=pie_fig,
1559
+ style={'height': '200px', 'width': '100%'},
1560
+ config={'displayModeBar': False}
1561
+ )
1562
+ ])
1563
+ detail_html = html.Div(detail_items, style={'fontSize': '10px'})
1564
+ return title, detail_html
1565
+
1566
+
1567
+
1568
+ # === Download отчёт ===
1569
+ @app.callback(
1570
+ Output("download-report", "data"),
1571
+ Input("download-report-btn", "n_clicks"),
1572
+ prevent_initial_call=True,
1573
+ )
1574
+ def download_pdf(n_clicks):
1575
+ if not n_clicks:
1576
+ return dash.no_update
1577
+ if os.path.exists(PDF_PATH):
1578
+ with open(PDF_PATH, "rb") as f:
1579
+ return dcc.send_bytes(f.read(), "report.pdf")
1580
+ else:
1581
+ return dcc.send_string("В данной версии генерация отчетов отключена. Обратитесь к разработчику.", "report.txt")
1582
+
1583
+ # === Запуск ===
1584
+ if __name__ == '__main__':
1585
+ app.run(debug=False, host="0.0.0.0", port=8051)