demo.vuzometry / server.py
yogl's picture
Update server.py
a91fe14 verified
Raw
History Blame Contribute Delete
59.1 kB
import dash
from dash import dcc, html, Output, Input, State
import pandas as pd
import plotly.graph_objs as go
import plotly.express as px
import networkx as nx
import numpy as np
import os
import re
import colorsys
from functools import lru_cache
from huggingface_hub import snapshot_download
# === Основные параметры ===
# --- Источник parquet-данных ---
# Если задан DATASET_ID, то parquet будут скачаны из Hugging Face Dataset repo (может быть приватным).
# Токен для доступа берётся из переменной окружения HF_TOKEN (в Spaces храните его в Secrets).
DATASET_ID = os.environ.get("DATASET_ID") # например: username/vuzometry-data
LOCAL_DATA_DIR = os.environ.get("DATA_DIR", "/tmp/vuzometry-data")
FALLBACK_PARQUET_DIR = os.environ.get("PARQUET_DIR", "/app/parquet")
HF_TOKEN = (os.environ.get("HF_TOKEN")
or os.environ.get("HUGGINGFACE_HUB_TOKEN")
or os.environ.get("HUGGINGFACE_TOKEN"))
def _ensure_parquet_dir() -> str:
"""Определяет каталог с parquet. При необходимости скачивает dataset в LOCAL_DATA_DIR."""
if DATASET_ID:
snapshot_download(
repo_id=DATASET_ID,
repo_type="dataset",
local_dir=LOCAL_DATA_DIR,
local_dir_use_symlinks=False,
allow_patterns=["**/*.parquet"],
token=HF_TOKEN,
)
candidate = os.path.join(LOCAL_DATA_DIR, "parquet")
return candidate if os.path.isdir(candidate) else LOCAL_DATA_DIR
return FALLBACK_PARQUET_DIR
BASE_PARQUET_DIR = _ensure_parquet_dir()
if not os.path.isdir(BASE_PARQUET_DIR):
raise FileNotFoundError(
f"Parquet directory not found: {BASE_PARQUET_DIR}. "
f"Set DATASET_ID (and HF_TOKEN) to download a private dataset, "
f"or provide local parquet files in PARQUET_DIR (default /app/parquet)."
)
PDF_PATH = r"/app/pdf/report.pdf"
ORG_ID = 373
YEAR_START = 2005
YEAR_END = 2024
COLOR_ALPHA = 0.8
COLOR_LIGHTEN = 0.2
WEB20_PALETTE = [
"#0074D9", "#00B8D4", "#6A4C93", "#FF851B", "#B10DC9",
"#FFDC00", "#39CCCC", "#FFB347", "#7FDBFF", "#3D9970",
"#F012BE", "#85144b", "#FF6F61", "#C70039", "#FF9F1C",
"#C0CA33", "#2ECC40", "#01FF70", "#FF4136", "#B8E986",
]
MAX_LEN_HOVER = 60
SHOW_SUMMARY_RU = False
SHOW_SUMMARY_EN = True
SPRING_K = 0.6
SPRING_ITER = 500
NODE_SIZE_BASE = 10
NODE_SIZE_MAX = 50
EDGE_ALPHA = 0.13
EDGE_WIDTH_BASE = 1
EDGE_WIDTH_MAX = 10
SINGLE_NODE_BORDER_WIDTH = 1
NODE_SIZE_SCALE_MODE = "diameter"
import threading
# Кеш для датафреймов
_PARQUET_CACHE = {}
_CACHE_LOCK = threading.Lock()
def load_df_cached(name, **kwargs):
"""Быстрая загрузка parquet-файлов с кешированием в памяти."""
with _CACHE_LOCK:
if name not in _PARQUET_CACHE:
_PARQUET_CACHE[name] = pd.read_parquet(os.path.join(BASE_PARQUET_DIR, name), **kwargs)
return _PARQUET_CACHE[name]
PRELOAD_FILES = [
fname for fname in os.listdir(BASE_PARQUET_DIR)
if fname.endswith(".parquet")
]
print("Загрузка файлов в кеш:", PRELOAD_FILES)
for fname in PRELOAD_FILES:
load_df_cached(fname)
print("Все parquet-файлы успешно загружены в RAM.")
def parquet_path(*parts):
return os.path.join(BASE_PARQUET_DIR, *parts)
def load_df(name, **kwargs):
return pd.read_parquet(parquet_path(name), **kwargs)
def list_period_files(prefix):
files = []
for fname in os.listdir(BASE_PARQUET_DIR):
if fname.startswith(prefix + "__") and fname.endswith(".parquet"):
period = fname.split("__")[1].replace(".parquet", "")
files.append((period, fname))
return sorted(files)
def get_periods(prefix):
files = list_period_files(prefix)
result = []
for period, fname in files:
label = period.replace("_", "–")
result.append({
"key": period,
"label": label,
"fname": fname
})
return result
PERIODS = get_periods("bar_data")
period_options = [{"label": p["label"], "value": p["key"]} for p in PERIODS]
# === 0. График продуктивности ===
def load_and_prepare_data(year_start, year_end, org_id):
df_orgs = load_df_cached("organizations.parquet")
df_values = load_df_cached("publication_values.parquet")[["pub_id", "fract_value"]].rename(columns={"fract_value": "fract_author_value"})
df_orgs = df_orgs[pd.notnull(df_orgs["year"])].copy()
df_orgs["year"] = df_orgs["year"].astype(int)
df_orgs_filtered = df_orgs[df_orgs["year"].between(year_start, year_end)].copy()
df_hse = df_orgs_filtered[df_orgs_filtered["org_id"] == org_id]
df = df_hse.merge(df_values, on="pub_id", how="left")
affil_counts = df_orgs_filtered.groupby(["pub_id", "author_id"]).size().reset_index(name="affil_count")
multi_affil = affil_counts[affil_counts["affil_count"] > 1]["pub_id"].nunique()
df = df.merge(affil_counts, on=["pub_id", "author_id"], how="left")
df["fract_author_affiliation_value"] = df["fract_author_value"] / df["affil_count"]
return df, multi_affil
def build_productivity_figure(df, multi_affil):
pubs_per_year = df.drop_duplicates(subset=["pub_id", "year"]).groupby("year")["pub_id"].count()
authors_per_year = df.drop_duplicates(subset=["year", "author_id"]).groupby("year")["author_id"].count()
value_per_year = df.groupby("year")["fract_author_value"].sum()
adjusted_value_per_year = df.groupby("year")["fract_author_affiliation_value"].sum()
total_val = value_per_year.sum()
total_a_val = adjusted_value_per_year.sum()
total_pubs = df["pub_id"].nunique()
total_authors = df["author_id"].nunique()
percent_multi = multi_affil / total_pubs * 100 if total_pubs > 0 else 0
fig = go.Figure()
fig.add_bar(
x=value_per_year.index,
y=value_per_year.values,
marker_color="rgba(0,128,0,0.25)",
name=f"Продуктивность (без учёта множественных аффилиаций): {total_val:.1f}"
)
fig.add_bar(
x=adjusted_value_per_year.index,
y=adjusted_value_per_year.values,
marker_color="rgba(0,128,0,0.6)",
name=f"Продуктивность (с учётом множественных аффилиаций): {total_a_val:.1f}"
)
fig.add_trace(go.Scatter(
x=pubs_per_year.index, y=pubs_per_year.values,
name=f"Публикации: {total_pubs}",
mode='lines+markers',
line=dict(color="salmon", width=2),
yaxis="y2"
))
fig.add_trace(go.Scatter(
x=authors_per_year.index, y=authors_per_year.values,
name=f"Авторы: {total_authors}",
mode='lines+markers',
line=dict(color="royalblue", width=2),
yaxis="y2"
))
fig.update_layout(
xaxis=dict(title="Год", tickmode='linear', tick0=int(value_per_year.index.min()), dtick=1),
yaxis=dict(title="Продуктивность"),
yaxis2=dict(
title="Число публикаций / авторов",
overlaying='y',
side='right'
),
legend=dict(x=0.01, y=0.99, bgcolor='rgba(255,255,255,0.7)', bordercolor="gray"),
template="plotly_white",
margin=dict(l=40, r=40, t=40, b=40),
height=470,
autosize=True
)
return fig
# === Авторы и аннотации
authors_df = load_df_cached("authors.parquet")
author_id2short = dict(zip(authors_df["author_id"], authors_df["short_author_name"]))
pub_content_df = load_df_cached("publication_content.parquet")
# === 1. GRNTI/GRNTI_AGG
def grnti_hex_to_rgba(hex_color, alpha=COLOR_ALPHA):
hex_color = hex_color.lstrip('#')
r, g, b = (int(hex_color[i:i+2], 16) for i in (0, 2, 4))
return f"rgba({r},{g},{b},{alpha})"
def grnti_lighten_color(hex_color, factor=COLOR_LIGHTEN):
hex_color = hex_color.lstrip('#')
r, g, b = [int(hex_color[i:i+2], 16) for i in (0, 2, 4)]
r = int(r + (255 - r) * factor)
g = int(g + (255 - g) * factor)
b = int(b + (255 - b) * factor)
return f'#{r:02x}{g:02x}{b:02x}'
def grnti_get_lvl1_colors(df, alpha=COLOR_ALPHA, lighten=COLOR_LIGHTEN):
lvl1_codes = sorted(df['code_lvl1'].dropna().unique())
palette = WEB20_PALETTE
if len(lvl1_codes) > len(palette):
base_len = len(palette)
palette_extended = []
for i in range(len(lvl1_codes)):
base = palette[i % base_len]
if i < base_len:
palette_extended.append(base)
else:
rgb = tuple(int(base[j:j+2], 16)/255. for j in (1,3,5))
h, s, v = colorsys.rgb_to_hsv(*rgb)
v = min(1, v * (0.8 + 0.2 * ((i//base_len)%2)))
r, g, b = colorsys.hsv_to_rgb(h, s, v)
palette_extended.append('#%02x%02x%02x' % (int(r*255), int(g*255), int(b*255)))
palette = palette_extended
color_map = {
code: grnti_hex_to_rgba(grnti_lighten_color(palette[i], factor=lighten), alpha=alpha)
for i, code in enumerate(lvl1_codes)
}
return color_map
def grnti_make_treemap(df, norm_mode):
if norm_mode == 'value':
col = 'value'
label_percent = "Доля:"
total = df['value'].sum()
else:
if 'npubs' not in df.columns:
df['npubs'] = df['frac_npubs']
col = 'npubs'
label_percent = "Доля публикаций:"
total = df['npubs'].sum()
color_map = grnti_get_lvl1_colors(df, alpha=COLOR_ALPHA, lighten=COLOR_LIGHTEN)
df_lvl1 = df[df['code_lvl2'].isnull()].copy()
df_lvl2 = df[df['code_lvl2'].notnull()].copy()
labels, parents, customdata, values_out, marker_colors = [], [], [], [], []
for _, row in df_lvl1.iterrows():
l = f"{row['code_lvl1']} {row['name_lvl1']}"
labels.append(l)
parents.append("")
values_out.append(row[col])
customdata.append([
row['code_lvl1'],
row['name_lvl1'],
row[col] / total if total else 0,
])
marker_colors.append(color_map.get(row['code_lvl1'], "rgba(200,200,200,0.3)"))
for _, row in df_lvl2.iterrows():
l = f"{row['code_lvl2']} {row['name_lvl2']}"
labels.append(l)
parents.append(f"{row['code_lvl1']} {row['name_lvl1']}")
values_out.append(row[col])
customdata.append([
row['code_lvl2'],
row['name_lvl2'],
row[col] / total if total else 0,
])
marker_colors.append(color_map.get(row['code_lvl1'], "rgba(200,200,200,0.3)"))
label_seen = {}
for i, label in enumerate(labels):
orig_label = label
idx = 1
while label in label_seen:
label = f"{orig_label} [{idx}]"
idx += 1
label_seen[label] = 1
labels[i] = label
hovertemplates = []
for parent, cd in zip(parents, customdata):
hovertemplates.append(
f"Код: {cd[0]}<br>Название: {cd[1]}<br>{label_percent} {cd[2]:.1%}<extra></extra>"
)
texttemplate = (
"Код: %{customdata[0]}<br>"
"Название: %{customdata[1]}<br>"
f"{label_percent} "+"%{customdata[2]:.1%}"
)
fig = go.Figure(go.Treemap(
labels=labels,
parents=parents,
values=values_out,
customdata=customdata,
marker_colors=marker_colors,
texttemplate=texttemplate,
hovertemplate=hovertemplates,
branchvalues="total"
))
fig.update_layout(
margin=dict(t=20, l=0, r=0, b=0),
title=None
)
return fig
@lru_cache(maxsize=12)
def grnti_load_df_by_period(period_label):
fname = f"grnti_agg_result_multi__{period_label}.parquet"
return load_df_cached(fname)
# === Кластеры и bar ===
@lru_cache(maxsize=8)
def load_cluster_data(period_key):
info = load_df_cached(f"coauthor_clusters_info__{period_key}.parquet")
summary = load_df_cached(f"coauthor_clusters_summary__{period_key}.parquet")
return info, summary
@lru_cache(maxsize=8)
def load_bar_data(period_key):
return load_df_cached(f"bar_data__{period_key}.parquet")
def period_label_to_filename(period_label):
# Преобразуем любые тире и пробелы к подчёркиванию
# '2005–2024' -> '2005_2024'
return period_label.replace("–", "_").replace("-", "_").replace(" ", "")
@lru_cache(maxsize=8)
def load_science_map_sheet(period_label):
fname = period_label_to_filename(period_label)
parquet_path = os.path.join(BASE_PARQUET_DIR, f"hse_science_map__{fname}.parquet")
return pd.read_parquet(parquet_path)
def cluster_sorter(vals):
numeric = []
non_numeric = []
for v in vals:
try:
numeric.append(int(v))
except Exception:
non_numeric.append(v)
numeric = [str(x) for x in sorted(numeric)]
non_numeric = sorted(non_numeric)
return numeric + non_numeric
def get_cluster_labels(metrics_df, clusters_sorted):
labels = []
for cid in clusters_sorted:
authors = metrics_df[metrics_df['cluster_id'] == int(cid)]
if authors.empty:
labels.append(f'К{cid}')
continue
max_centrality = authors['degree_centrality'].max()
top_authors = authors[authors['degree_centrality'] == max_centrality]['author_short'].tolist()
names = ', '.join(top_authors)
if len(authors) > len(top_authors):
names += ' и др.'
labels.append(f"{names} К{cid}")
return labels
def get_hover_texts(metrics_df, clusters_sorted, hse_vals, th_vals, other_vals):
hover_hse, hover_2th, hover_other = [], [], []
for idx, cid in enumerate(clusters_sorted):
authors_df = metrics_df[metrics_df['cluster_id'] == int(cid)].copy()
authors_df = authors_df.sort_values('total_value', ascending=False)
hse_value = hse_vals[::-1].iloc[idx]
th_value = th_vals[::-1].iloc[idx]
other_value = other_vals[::-1].iloc[idx]
if authors_df.empty:
hover_hse.append(f"Продуктивность: {hse_value:.1f}<br>Авторы:<br>Нет авторов")
else:
lines = [
f"{i}. {row.author_short}{row.total_value:.1f}"
for i, row in enumerate(authors_df.itertuples(), 1)
]
hover_hse.append(
f"Продуктивность: {hse_value:.1f}<br>Авторы:<br>" + "<br>".join(lines)
)
hover_2th.append(f"Продуктивность: {th_value:.1f}")
hover_other.append(f"Продуктивность: {other_value:.1f}")
return hover_hse, hover_2th, hover_other
def make_figure(df, period_label, sort_by='sum'):
df = df.copy()
df['sum_value'] = df['hse_value'] + df['2th_org_value'] + df['other_orgs_value']
df['hse_plus_2th'] = df['hse_value'] + df['2th_org_value']
if sort_by == 'sum':
df_sorted = df.sort_values("sum_value", ascending=False).reset_index(drop=True)
elif sort_by == 'hse_plus_2th':
df_sorted = df.sort_values("hse_plus_2th", ascending=False).reset_index(drop=True)
elif sort_by == 'hse':
df_sorted = df.sort_values("hse_value", ascending=False).reset_index(drop=True)
else:
raise ValueError('sort_by must be "sum", "hse_plus_2th" or "hse"')
clusters_sorted = df_sorted['cluster_id'].tolist()[::-1]
# --- Метрики для ярлыков и ховеров ---
metrics_df = load_df_cached(f"coauthor_clusters_metrics__{period_label}.parquet")
y_labels = get_cluster_labels(metrics_df, clusters_sorted)
hover_hse, hover_2th, hover_other = get_hover_texts(
metrics_df, clusters_sorted,
df_sorted['hse_value'], df_sorted['2th_org_value'], df_sorted['other_orgs_value']
)
sum_values = df_sorted['sum_value']
hse_text = [
f"{100 * v / s:.1f}%" if s > 0 else ""
for v, s in zip(df_sorted['hse_value'][::-1], sum_values[::-1])
]
org2_text = [
f"{100 * v / s:.1f}%" if s > 0 else ""
for v, s in zip(df_sorted['2th_org_value'][::-1], sum_values[::-1])
]
other_text = [
f"{100 * v / s:.1f}%" if s > 0 else ""
for v, s in zip(df_sorted['other_orgs_value'][::-1], sum_values[::-1])
]
fig = go.Figure()
fig.add_trace(go.Bar(
y=y_labels,
x=df_sorted['hse_value'][::-1],
orientation='h',
name='Ядро организации',
marker_color='royalblue',
text=hse_text,
textposition='inside',
insidetextanchor='middle',
hovertext=hover_hse,
hoverinfo="text"
))
fig.add_trace(go.Bar(
y=y_labels,
x=df_sorted['2th_org_value'][::-1],
orientation='h',
name='Совместители',
marker_color='orange',
text=org2_text,
textposition='inside',
insidetextanchor='middle',
hovertext=hover_2th,
hoverinfo="text"
))
fig.add_trace(go.Bar(
y=y_labels,
x=df_sorted['other_orgs_value'][::-1],
orientation='h',
name='Другие',
marker_color='lightgray',
text=other_text,
textposition='inside',
insidetextanchor='middle',
hovertext=hover_other,
hoverinfo="text"
))
fig.update_layout(
barmode='stack',
xaxis_title='Общая продуктивность публикаций кластера',
yaxis_title='Кластер',
height=max(600, 30*len(df_sorted)),
legend_title_text='',
template='simple_white',
xaxis=dict(side='top'),
legend=dict(
orientation="h",
x=0,
y=1.02,
xanchor='left',
yanchor='bottom'
)
)
return fig
# === Сеть соавторства (network graph)
def plotly_network_graph(
df, df_summary,
weight_mode="value",
show_singles=True,
node_size_scale_mode=NODE_SIZE_SCALE_MODE,
k=SPRING_K, iterations=SPRING_ITER
):
import networkx as nx
G = nx.Graph()
for _, row in df.iterrows():
if not G.has_node(row['author_id']):
G.add_node(row['author_id'], cluster=row['cluster_id'])
pubs = df.groupby('pub_id')
edge_weights = {}
for pub_id, group in pubs:
authors = group['author_id'].tolist()
pub_value = group['fract_author_affiliation_value'].sum() if 'fract_author_affiliation_value' in group else 1
for i in range(len(authors)):
for j in range(i+1, len(authors)):
edge = tuple(sorted((authors[i], authors[j])))
if edge not in edge_weights:
edge_weights[edge] = {"count": 0, "value": 0.0}
edge_weights[edge]["count"] += 1
edge_weights[edge]["value"] += pub_value
for (a, b), ew in edge_weights.items():
G.add_edge(a, b, count=ew["count"], value=ew["value"])
if G.number_of_nodes() == 0 or G.number_of_edges() == 0:
return go.Figure(layout=go.Layout(
title="Нет кластеров для отображения",
margin=dict(t=60, b=30, l=10, r=10),
template="plotly_white"
))
pos = nx.spring_layout(G, k=k, iterations=iterations, seed=42)
nice_colors = [
"#E53935", "#1E88E5", "#43A047", "#FDD835", "#8E24AA",
"#00ACC1", "#F4511E", "#3949AB", "#7CB342", "#FB8C00",
"#C2185B", "#00897B", "#C0CA33", "#5E35B1", "#039BE5",
"#E64A19", "#9E9D24", "#6D4C41", "#546E7A", "#D81B60",
"#F06292", "#7E57C2", "#26A69A", "#789262", "#FDD835",
]
cluster_ids_sorted = sorted(df_summary["cluster_id"].unique())
palette = nice_colors * ((len(cluster_ids_sorted)//len(nice_colors))+2)
cluster_color_map = {
cluster: palette[i % len(palette)] for i, cluster in enumerate(cluster_ids_sorted)
}
is_single_dict = {}
if 'is_single' in df_summary.columns:
is_single_dict = dict(zip(df_summary["cluster_id"], df_summary["is_single"]))
else:
is_single_dict = {cid: False for cid in cluster_ids_sorted}
single_nodes = [n for n, data in G.nodes(data=True) if is_single_dict.get(data['cluster'], False)]
non_single_nodes = [n for n in G.nodes() if n not in single_nodes]
weights = [G[a][b][weight_mode] for a, b in G.edges()]
if weights:
w_arr = np.array(weights)
w_arr = np.log1p(w_arr)
wmin, wmax = w_arr.min(), w_arr.max()
def scale(w):
lw = np.log1p(w)
if wmax > wmin:
return EDGE_WIDTH_BASE + (EDGE_WIDTH_MAX - EDGE_WIDTH_BASE) * ((lw - wmin) / (wmax - wmin))
else:
return (EDGE_WIDTH_BASE + EDGE_WIDTH_MAX) / 2
else:
scale = lambda w: EDGE_WIDTH_BASE
edge_traces = []
for a, b in G.edges():
if not show_singles and (a in single_nodes or b in single_nodes):
continue
w = G[a][b][weight_mode]
width = scale(w)
x0, y0 = pos[a]
x1, y1 = pos[b]
edge_traces.append(
go.Scatter(
x=[x0, x1], y=[y0, y1],
mode='lines',
line=dict(width=width, color='#888'),
opacity=EDGE_ALPHA,
hoverinfo='skip',
showlegend=False,
)
)
node_sizes_raw = {}
for n in G.nodes():
if weight_mode == "count":
node_sizes_raw[n] = df[df['author_id'] == n]['pub_id'].nunique()
else:
node_sizes_raw[n] = df[df['author_id'] == n]['fract_author_affiliation_value'].sum()
node_size_values = np.array(list(node_sizes_raw.values()))
ns_min, ns_max = node_size_values.min(), node_size_values.max() if len(node_size_values) > 0 else (0, 1)
def scale_node_size(v):
if ns_max > ns_min:
norm = (v - ns_min) / (ns_max - ns_min)
else:
norm = 0.5
if node_size_scale_mode == "area":
min_area = np.pi * (NODE_SIZE_BASE / 2) ** 2
max_area = np.pi * (NODE_SIZE_MAX / 2) ** 2
area = min_area + (max_area - min_area) * norm
diameter = 2 * np.sqrt(area / np.pi)
return diameter
else: # "diameter"
return NODE_SIZE_BASE + (NODE_SIZE_MAX - NODE_SIZE_BASE) * norm
node_x, node_y, node_color, node_text, node_size = [], [], [], [], []
for node in non_single_nodes:
x, y = pos[node]
cluster_id = G.nodes[node]['cluster']
color = cluster_color_map.get(cluster_id, "#ccc")
n_pubs = df[df['author_id'] == node]['pub_id'].nunique()
n_value = df[df['author_id'] == node]['fract_author_affiliation_value'].sum()
author_short = author_id2short.get(node, str(node))
node_x.append(x)
node_y.append(y)
node_color.append(color)
node_text.append(
f"Авторский кластер: {int(cluster_id)}<br>Автор: {author_short}"
f"<br>Публикаций: {n_pubs}"
f"<br>Продуктивность: {n_value:.2f}"
)
sz = node_sizes_raw[node]
node_size.append(scale_node_size(sz))
node_trace = go.Scatter(
x=node_x, y=node_y,
mode='markers',
hoverinfo='text',
text=node_text,
marker=dict(
showscale=False,
color=node_color,
size=node_size,
line_width=1
),
name="Кластеры"
)
single_x, single_y, single_color, single_text, single_size = [], [], [], [], []
for node in single_nodes:
x, y = pos[node]
cluster_id = G.nodes[node]['cluster']
color = cluster_color_map.get(cluster_id, "#ccc")
n_pubs = df[df['author_id'] == node]['pub_id'].nunique()
n_value = df[df['author_id'] == node]['fract_author_affiliation_value'].sum()
author_short = author_id2short.get(node, str(node))
single_x.append(x)
single_y.append(y)
single_color.append(color)
single_text.append(
f"Моноавторский кластер: {int(cluster_id)}<br>Автор: {author_short}"
f"<br>Публикаций: {n_pubs}"
f"<br>Продуктивность: {n_value:.2f}"
)
sz = node_sizes_raw[node]
single_size.append(scale_node_size(sz))
single_node_trace = go.Scatter(
x=single_x, y=single_y,
mode='markers',
hoverinfo='text',
text=single_text,
marker=dict(
showscale=False,
color='rgba(0,0,0,0)',
size=single_size,
line=dict(width=SINGLE_NODE_BORDER_WIDTH, color=single_color)
),
name="Одиночные авторы",
visible=show_singles
)
traces = edge_traces + [node_trace]
if show_singles and len(single_nodes) > 0:
traces.append(single_node_trace)
fig = go.Figure(
data=traces,
layout=go.Layout(
showlegend=False,
hovermode='closest',
margin=dict(b=10, l=10, r=10, t=10),
xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
template="plotly_white"
)
)
return fig
def shorten_text(s, max_len=60):
if not isinstance(s, str) or len(s) <= max_len:
return s
cut = s[:max_len]
if " " in cut:
cut = cut[:cut.rfind(" ")]
return cut.strip() + " ..."
# ========== LAYOUT И CALLBACKS =============
# Подготовка данных для продуктивности (делается единожды)
df_prod, multi_affil_prod = load_and_prepare_data(YEAR_START, YEAR_END, ORG_ID)
app = dash.Dash(
__name__,
title="Вузометрия.РФ",
update_title="Загрузка...",
meta_tags=[
{"name": "description", "content": "Вузометрия.РФ – измеряем университеты"}
]
)
app.layout = html.Div([
# Верхний заголовок
html.Div(
"Московский государственный институт электроники и математики (МИЭМ) НИУ ВШЭ",
style={
"fontFamily": "Arial, sans-serif",
"fontSize": "22px",
"fontWeight": "bold",
"textAlign": "center",
"color": "#123157",
"padding": "22px 0 4px 0",
}
),
# === График продуктивности с подложкой ===
html.Div([
html.H4(
"Динамика публикационной продуктивности",
style={
"margin-bottom": "12px",
"marginTop": "6px",
"font-family": "Arial, sans-serif",
"font-weight": "bold",
"text-align": "center",
"font-size": "20px",
"color": "#22335b",
}
),
dcc.Loading(
id="loading-productivity-graph",
type="circle",
color="#22335b",
children=[
dcc.Graph(
id='productivity-graph',
figure=build_productivity_figure(df_prod, multi_affil_prod),
style={
"width": "100%",
"height": "470px",
"padding": "0"
},
config={
"displaylogo": False,
"modeBarButtonsToRemove": ["sendDataToCloud"]
}
),
]
),
], style={
'background': '#fff',
'border-radius': '18px',
'box-shadow': '0 0 8px #ccc4',
'padding': '22px 12px 12px 12px',
'width': '90%',
'margin': '20px auto 20px auto'
}),
dcc.Store(id='sidebar-state', data={'show': False}),
# Кнопка-гамбургер
html.Button('☰', id='toggle-sidebar', n_clicks=0, style={
"fontSize": "24px",
"margin": "0 0 0 2px",
"padding": "2px 2px",
'position': 'fixed',
'top': '0px',
'left': '0px',
'zIndex': 1102
}),
# Сайдбар
html.Div([
html.Img(
src='/assets/logo.png',
style={
"width": "110px",
"margin": "0 auto",
"display": "block",
"marginBottom": "8px"
}
),
html.Div(
"Вузометрия.РФ",
style={
"fontFamily": "Arial, sans-serif",
"fontSize": "22px",
"fontWeight": "bold",
"textAlign": "center",
"color": "#22335b",
"marginBottom": "2px"
}
),
html.Div(
"Вы опрашиваете? Мы — измеряем",
style={
"fontFamily": "Arial, sans-serif",
"fontSize": "13px",
"fontWeight": "normal",
"textAlign": "center",
"color": "#666",
"marginBottom": "7px"
}
),
html.H3("Общие настройки", style={
"margin-bottom": "10px",
"fontSize": "15px",
"fontWeight": "bold",
"marginTop": "10px"
}),
html.Label("Организация:", style={
"margin-bottom": "3px",
"fontSize": "13px"
}),
dcc.Dropdown(
id='org-dropdown',
options=[{"label": "МИЭМ НИУ ВШЭ", "value": "miem_hse"}],
value="miem_hse",
style={'width': '100%', "margin-bottom": "7px", "fontSize": "13px"},
searchable=False,
clearable=False,
disabled=True
),
html.Label("Период:", style={
"margin-bottom": "3px",
"fontSize": "13px"
}),
dcc.Dropdown(
id='period-dropdown',
options=[{"label": p["label"], "value": p["key"]} for p in PERIODS],
value=PERIODS[2]["key"],
style={'width': '100%', "margin-bottom": "13px", "fontSize": "13px"},
searchable=False,
clearable=False
),
html.Div([
html.Span(
"© Антон Лощилов, 2025",
style={
"fontFamily": "Arial, sans-serif",
"fontSize": "11px",
"color": "#aaa"
}
),
html.Span(
"v. 0.10",
style={
"fontFamily": "Arial, sans-serif",
"fontSize": "11.5px",
"fontWeight": "bold",
"color": "#aaa"
}
),
], style={
"position": "absolute",
"bottom": "10px",
"left": "0",
"right": "0",
"width": "92%",
"margin": "0 4%",
"display": "flex",
"flexDirection": "row",
"justifyContent": "space-between"
})
], id='sidebar-content', style={
'width': '310px',
'padding': '18px 16px 18px 16px',
'background': '#f8f8f8',
'border-radius': '18px',
'box-shadow': '0 0 22px #ccc8',
'font-family': 'Arial, sans-serif',
'font-size': '13px',
'overflowY': 'auto',
'zIndex': 1101,
'position': 'fixed',
'top': '60px',
'left': '18px',
'minHeight': '450px',
'maxHeight': '95vh',
'transition': 'opacity 0.35s, pointer-events 0.35s',
'opacity': 0,
'pointerEvents': 'none',
'display': 'none'
}
),
# === Основное содержимое страницы ===
html.Div([
# --- 1. ГРНТИ-карта (тримап) ---
html.Div([
html.H4(id='grnti-main-title', style={
"margin-bottom": "12px",
"marginTop": "6px",
"font-family": "Arial, sans-serif",
"font-weight": "bold",
"text-align": "center",
"font-size": "20px",
"color": "#22335b",
}),
html.Div([
html.Label("Тип взвешивания:", style={
"marginRight": "12px",
"fontFamily": 'Open Sans, Arial, sans-serif',
"fontSize": "14px",
"whiteSpace": "nowrap"
}),
dcc.RadioItems(
id='grnti-norm-mode',
options=[
{'label': 'По продуктивности', 'value': 'value'},
{'label': 'По числу публикаций', 'value': 'frac_npubs'},
],
value='value',
labelStyle={'display': 'inline-block', 'margin-right': '16px', 'fontSize': '14px', 'fontFamily': 'Arial, sans-serif'},
inputStyle={"margin-right": "5px"},
style={'display': 'inline-block'}
),
], style={
"display": "flex",
"alignItems": "center",
"gap": "8px",
"marginBottom": "5px",
"marginLeft": "18px"
}),
dcc.Loading(
id="loading-grnti-treemap",
type="circle",
color="#22335b",
children=[
dcc.Graph(
id='grnti-treemap-graph',
style={'width': '100%'},
config={
'displayModeBar': True,
'displaylogo': False
}
)
]
),
], style={
'background': '#fff',
'border-radius': '18px',
'box-shadow': '0 0 8px #ccc4',
'padding': '22px 12px 12px 12px',
'width': '90%',
'margin': '20px auto 20px auto',
}),
# --- 2. Карта соавторства ---
html.Div([
html.H4(id='main-title', style={
"margin-bottom": "12px",
"marginTop": "6px",
"font-family": "Arial, sans-serif",
"font-weight": "bold",
"text-align": "center",
"font-size": "20px",
"color": "#22335b",
}),
html.Div([
html.Label("Тип взвешивания:", style={
"margin-bottom": "0",
"font-family": "Arial, sans-serif",
"font-size": "14px"
}),
html.Div([
dcc.RadioItems(
id='weight-mode',
options=[
{"label": "По продуктивности", "value": "value"},
{"label": "По числу публикаций", "value": "count"}
],
value="value",
labelStyle={'display': 'inline-block', 'margin-right': '16px'},
inputStyle={"margin-right": "4px"},
style={"margin-bottom": "0"}
),
dcc.Checklist(
id='show-singles',
options=[{"label": "Показывать одиночных авторов", "value": "show"}],
value=[],
style={
"margin-left": "28px",
"font-family": "Arial, sans-serif",
"font-size": "14px",
"display": "inline-block",
"verticalAlign": "middle"
},
inputStyle={"margin-right": "4px"}
),
], style={
"display": "flex",
"alignItems": "center",
"margin-bottom": "10px"
}),
], style={
"margin-bottom": "18px",
"margin-left": "14px"
}),
dcc.Loading(
id="loading-cluster-graph",
type="circle",
color="#22335b",
children=[
dcc.Graph(
id='cluster-graph',
style={"width": "100%"},
config={
'displaylogo': False,
'modeBarButtonsToRemove': ['sendDataToCloud']
}
)
]
),
], style={
'background': '#fff',
'border-radius': '18px',
'box-shadow': '0 0 8px #ccc4',
'padding': '22px 12px 12px 12px',
'width': '90%',
'margin': '20px auto 20px auto'
}),
# --- 3. Семантическая карта ---
html.Div([
html.H4(
id='science-map-title',
style={
"margin-bottom": "12px",
"marginTop": "6px",
"font-family": "Arial, sans-serif",
"font-weight": "bold",
"text-align": "center",
"font-size": "20px",
"color": "#22335b",
}
),
html.Div([
html.Label("Размер маркера:", style={
"fontFamily": "Arial, sans-serif",
"fontSize": "14px",
"marginRight": "8px",
"whiteSpace": "nowrap"
}),
dcc.Dropdown(
id='size-dropdown',
options=[
{'label': 'Общая продуктивность', 'value': 'value'},
{'label': 'Вклад организации', 'value': 'org_value'}
],
value='value',
style={
"fontFamily": "Arial, sans-serif",
"fontSize": "14px",
"width": "250px",
"marginRight": "30px"
},
searchable=False,
clearable=False
),
html.Label("Легенда:", style={
"fontFamily": "Arial, sans-serif",
"fontSize": "14px",
"marginRight": "8px",
"whiteSpace": "nowrap"
}),
dcc.Dropdown(
id='color-dropdown',
options=[
{'label': 'Авторские кластеры', 'value': 'author_cluster_id'},
{'label': 'Семантические кластеры', 'value': 'semantic_cluster_id'}
],
value='author_cluster_id',
style={
"fontFamily": "Arial, sans-serif",
"fontSize": "14px",
"width": "250px"
},
searchable=False,
clearable=False
),
], style={
"display": "flex",
"flexDirection": "row",
"alignItems": "center",
"marginBottom": "18px"
}),
html.Div([
dcc.Loading(
id="loading-science-map-plot",
type="circle",
color="#22335b",
children=[
dcc.Graph(
id='science-map-plot',
style={"width": "100%", "height": "850px"},
config={
'displaylogo': False,
'modeBarButtonsToRemove': ['sendDataToCloud']
}
)
]
)
], style={
"width": "65%",
"display": "inline-block",
"verticalAlign": "top"
}),
html.Div([
html.Div([
html.Div(id='detail-title'),
html.Div(id='detail-table')
], style={
'background': '#fafbfc',
'border-radius': '16px',
'box-shadow': '0 0 8px #ccc4',
'padding': '18px 14px 12px 14px',
'margin-top': '18px',
'font-size': '14px',
'height': '800px',
'overflowY': 'auto',
'display': 'flex',
'flexDirection': 'column'
})
], style={
"width": "30%",
"display": "inline-block",
"verticalAlign": "top",
"paddingLeft": "24px"
})
], style={
'background': '#fff',
'padding': '22px 12px 12px 12px',
'border-radius': '18px',
'box-shadow': '0 0 8px #ccc4',
'width': '90%',
'margin': '20px auto 20px auto'
}),
# --- 4. Bar-график ---
html.Div([
html.H4(id='bar-title', style={
"margin-bottom": "12px",
"marginTop": "6px",
"font-family": "Arial, sans-serif",
"font-weight": "bold",
"text-align": "center",
"font-size": "20px",
"color": "#22335b",
}),
html.Div([
html.Span("Сортировка:", style={
"marginRight": "10px",
"fontFamily": 'Open Sans, Arial, sans-serif',
"fontSize": "14px"
}),
dcc.Dropdown(
id='sort-dropdown',
options=[
{'label': 'по ядру организации', 'value': 'hse'},
{'label': 'по ядру организации и совместителям', 'value': 'hse_plus_2th'},
{'label': 'по суммарной продуктивности', 'value': 'sum'}
],
value='hse',
searchable=False,
clearable=False,
style={
'width': '280px',
'fontFamily': 'Open Sans, Arial, sans-serif',
'fontSize': '14px',
'verticalAlign': 'middle'
}
),
], style={
"display": "flex",
"flexDirection": "row",
"alignItems": "center",
"marginBottom": "4px"
}),
dcc.Loading(
id="loading-bar-graph",
type="circle",
color="#22335b",
children=[
dcc.Graph(
id='bar-graph',
style={'width': '100%'},
config={
'displaylogo': False,
'modeBarButtonsToRemove': ['sendDataToCloud']
}
)
]
),
], style={
'background': '#fff',
'border-radius': '18px',
'box-shadow': '0 0 8px #ccc4',
'padding': '22px 12px 12px 12px',
'width': '90%',
'margin': '20px auto 20px auto'
}),
# --- Кнопка ОТЧЕТ и Download ---
html.Div([
html.Button(
"ОТЧЕТ",
id="download-report-btn",
className="fancy-download-btn",
),
dcc.Download(id="download-report"),
], style={
"width": "100%",
"textAlign": "center",
"marginBottom": "0"
}),
], id='main-content', style={
'width': '100%',
'paddingLeft': '0',
'transition': 'none'
}),
], style={
'width': '100%',
'overflowX': 'hidden',
'position': 'relative',
'minHeight': '100vh',
'background': '#fcfcfc'
})
# === GRNTI-TreeMap ===
@app.callback(
Output('grnti-treemap-graph', 'figure'),
Output('grnti-main-title', 'children'),
Input('period-dropdown', 'value'),
Input('grnti-norm-mode', 'value')
)
def update_grnti_treemap(period_value, norm_mode):
period = next((p for p in PERIODS if p['key'] == period_value), PERIODS[0])
df = grnti_load_df_by_period(period['key'])
title = f"Карта рубрик ГРНТИ ({period['label']})"
fig = grnti_make_treemap(df, norm_mode)
return fig, title
# === Sidebar ===
@app.callback(
Output('sidebar-content', 'style'),
Output('sidebar-state', 'data'),
Input('toggle-sidebar', 'n_clicks'),
State('sidebar-state', 'data'),
prevent_initial_call=True
)
def toggle_sidebar(n_clicks, sidebar_state):
show = not sidebar_state.get('show', False)
base_style = {
'width': '310px',
'padding': '18px 16px 18px 16px',
'background': '#f8f8f8',
'border-radius': '18px',
'box-shadow': '0 0 22px #ccc8',
'font-family': 'Arial, sans-serif',
'font-size': '13px',
'overflowY': 'auto',
'zIndex': 1101,
'transition': 'opacity 0.35s, pointer-events 0.35s',
'position': 'fixed',
'top': '60px',
'left': '18px',
'minHeight': '450px',
'maxHeight': '95vh'
}
if show:
base_style['opacity'] = 1
base_style['pointerEvents'] = 'auto'
base_style['display'] = 'block'
else:
base_style['opacity'] = 0
base_style['pointerEvents'] = 'none'
base_style['display'] = 'none'
return base_style, {'show': show}
# === Кластеры и bar ===
@app.callback(
Output('main-title', 'children'),
Output('cluster-graph', 'figure'),
Output('bar-title', 'children'),
Output('bar-graph', 'figure'),
Input('period-dropdown', 'value'),
Input('weight-mode', 'value'),
Input('show-singles', 'value'),
Input('sort-dropdown', 'value'),
)
def update_graphs(period_key, weight_mode, show_singles, sort_by):
period = next((p for p in PERIODS if p['key'] == period_key), PERIODS[0])
period_years = period["label"]
main_title = f"Карта соавторства ({period_years})"
bar_title = f"Продуктивность авторских кластеров ({period_years})"
df, df_summary = load_cluster_data(period['key'])
show_singles_flag = "show" in (show_singles if show_singles else [])
fig = plotly_network_graph(
df, df_summary,
weight_mode=weight_mode,
show_singles=show_singles_flag,
node_size_scale_mode="diameter"
)
bar_df = load_bar_data(period['key'])
bar_fig = make_figure(bar_df, period['key'], sort_by)
return main_title, fig, bar_title, bar_fig
# === Семантическая карта ===
@app.callback(
Output('science-map-title', 'children'),
Input('period-dropdown', 'value')
)
def update_science_map_title(period_key):
period = next((p for p in PERIODS if p['key'] == period_key), PERIODS[0])
period_years = period["label"]
return f"Семантическая карта ({period_years})"
@app.callback(
Output('science-map-plot', 'figure'),
[Input('period-dropdown', 'value'),
Input('size-dropdown', 'value'),
Input('color-dropdown', 'value')]
)
def update_science_map(period_key, size_col, color_col):
period = next((p for p in PERIODS if p['key'] == period_key), PERIODS[0])
period_label = period["label"]
df = load_science_map_sheet(period_label)
df[size_col] = pd.to_numeric(df[size_col], errors='coerce')
df['pub_title_short'] = df['pub_title'].apply(lambda s: shorten_text(s, MAX_LEN_HOVER))
df['pub_authors_short'] = df['pub_authors'].apply(lambda s: shorten_text(s, MAX_LEN_HOVER))
# ВАЖНО: Все cluster_id к строкам!
df['author_cluster_id'] = df['author_cluster_id'].astype(str)
df['semantic_cluster_id'] = df['semantic_cluster_id'].astype(str)
cluster_ids = cluster_sorter(df[color_col].unique())
# Готовим свою цветовую карту:
palette = px.colors.qualitative.Alphabet if len(cluster_ids) <= 20 else px.colors.qualitative.Light24
color_discrete_map = {k: palette[i % len(palette)] for i, k in enumerate(cluster_ids)}
custom_data = [
'pub_id', 'pub_title_short', 'author_cluster_id', 'semantic_cluster_id',
'pub_source', 'year', 'pub_authors_short', 'org_value', 'value', 'umap_x', 'umap_y'
]
fig = px.scatter(
df,
x='umap_x',
y='umap_y',
color=color_col,
size=size_col,
hover_data=[],
custom_data=custom_data,
template="plotly_white",
height=850,
category_orders={color_col: cluster_ids},
color_discrete_map=color_discrete_map
)
hovertemplate = (
"<b>%{customdata[1]}</b><br>"
"Авторы: %{customdata[6]}<br>"
"Источник: %{customdata[4]}<br>"
"Год: %{customdata[5]}<br>"
"Авторский кластер: %{customdata[2]}<br>"
"Семантический кластер: %{customdata[3]}<br>"
"Общая продуктивность: %{customdata[8]}<br>"
"Вклад организации: %{customdata[7]}<br>"
"Координаты: (%{customdata[9]}, %{customdata[10]})<br>"
"<extra></extra>"
)
fig.update_traces(
hovertemplate=hovertemplate,
marker=dict(opacity=0.5, line=dict(width=1), sizemin=7),
selector=dict(mode='markers')
)
fig.update_layout(
hoverlabel=dict(font_size=10, font_family="Arial"),
legend_title_text="",
xaxis_title="X",
yaxis_title="Y",
legend=dict(
orientation="h", yanchor="bottom", y=-0.8, xanchor="center", x=0.5,
# Можно добавить ещё стилей, если надо
),
margin=dict(l=40, r=40, t=60, b=40),
)
return fig
@app.callback(
[Output('detail-title', 'children'),
Output('detail-table', 'children')],
[Input('science-map-plot', 'clickData'),
Input('period-dropdown', 'value')]
)
def show_details(clickData, period_key):
# Безопасный выбор периода
period = next((p for p in PERIODS if p['key'] == period_key), None)
if not period:
return html.Div(
"Ошибка: некорректные параметры периода! Проверьте PERIODS.",
style={
'fontFamily': 'Arial, sans-serif',
'fontSize': '13px',
'color': '#c00',
'padding': '12px 0',
'textAlign': 'center'
}
), ""
if clickData is None:
return html.Div(
"Кликните по точке для подробностей.",
style={
'fontFamily': 'Arial, sans-serif',
'fontSize': '13px',
'color': '#888',
'padding': '12px 0',
'textAlign': 'center'
}
), ""
pub_id = clickData['points'][0]['customdata'][0]
# Загружаем данные для выбранного периода
df = load_science_map_sheet(period['label'])
# Проверка — есть ли такая публикация?
df_row = df[df['pub_id'] == pub_id]
if df_row.empty:
return html.Div(
"Публикация не найдена в данных за период!",
style={
'fontFamily': 'Arial, sans-serif',
'fontSize': '13px',
'color': '#c00',
'padding': '12px 0',
'textAlign': 'center'
}
), ""
row = df_row.iloc[0]
# --- Дальше оригинальный код по формированию detail-title и detail-table ---
title = html.Div([
html.Div(row['pub_title'], style={'fontWeight': 'bold', 'fontSize': '12px', 'marginBottom': '3px'}),
html.Div([
f"Авторский кластер: {row['author_cluster_id']}", html.Br(),
f"Семантический кластер: {row['semantic_cluster_id']}", html.Br(),
f"Источник: {row['pub_source']}", html.Br(),
f"Год: {row['year']}", html.Br(),
], style={'fontSize': '10px', 'color': '#555'}),
], style={'marginBottom': '5px'})
abs_text = None
sum_en = None
sum_ru = None
try:
content_row = pub_content_df[pub_content_df['pub_id'] == pub_id]
if not content_row.empty:
abs_text = content_row.iloc[0]['abstract']
sum_en = content_row.iloc[0]['summary_en']
sum_ru = content_row.iloc[0]['summary_ru']
except Exception:
abs_text = sum_en = sum_ru = ""
author_line = row['pub_authors']
authors_block = html.Div([
html.Div("Авторы:", style={
'fontWeight': 'bold',
'fontSize': '12px',
'marginTop': '8px',
'marginBottom': '2px'
}),
html.Div(
author_line,
style={
'fontSize': '10px',
'color': '#222',
'background': '#f8f8f8',
'borderRadius': '8px',
'padding': '8px 10px',
'marginBottom': '5px',
'maxHeight': '70px',
'overflowY': 'auto'
}
),
])
abstract_block = None
if abs_text and isinstance(abs_text, str) and abs_text.strip():
abstract_block = html.Div([
html.Div("Аннотация:", style={
'fontWeight': 'bold',
'fontSize': '12px',
'marginTop': '8px',
'marginBottom': '2px'
}),
html.Div(abs_text, style={
'fontSize': '10px',
'color': '#222',
'background': '#f8f8f8',
'borderRadius': '8px',
'padding': '8px 10px',
'marginBottom': '5px',
'maxHeight': '200px',
'overflowY': 'auto'
}),
])
summary_en_block = None
if sum_en and isinstance(sum_en, str) and sum_en.strip():
summary_en_block = html.Div([
html.Div("Текст вектора EN:", style={
'fontWeight': 'bold',
'fontSize': '12px',
'marginTop': '10px',
'marginBottom': '2px'
}),
html.Div(sum_en, style={
'fontSize': '10px',
'color': '#222',
'background': '#f8f8f8',
'borderRadius': '8px',
'padding': '8px 10px',
'marginBottom': '5px'
}),
])
summary_ru_block = None
if sum_ru and isinstance(sum_ru, str) and sum_ru.strip():
summary_ru_block = html.Div([
html.Div("Текст вектора RU:", style={
'fontWeight': 'bold',
'fontSize': '12px',
'marginTop': '10px',
'marginBottom': '2px'
}),
html.Div(sum_ru, style={
'fontSize': '10px',
'color': '#222',
'background': '#f4f4ff',
'borderRadius': '8px',
'padding': '8px 10px',
'marginBottom': '5px'
}),
])
v = float(row['value']) if not pd.isnull(row['value']) else 0
ov = float(row['org_value']) if not pd.isnull(row['org_value']) else 0
if ov > v:
ov = v
other_value = max(v - ov, 0)
pie_fig = go.Figure(go.Pie(
labels=['Организация', 'Остальные'],
values=[ov, other_value],
hole=0.5,
domain=dict(x=[0.2, 0.8], y=[0.2, 0.8])
))
pie_fig.update_traces(
textinfo='percent',
textfont_size=11,
marker=dict(line=dict(color='#fff', width=1))
)
pie_fig.update_layout(
showlegend=False,
width=250, height=250,
margin=dict(l=0, r=0, t=0, b=0),
paper_bgcolor='#fafbfc'
)
detail_items = []
detail_items.append(authors_block)
if abstract_block:
detail_items.append(abstract_block)
if SHOW_SUMMARY_EN and summary_en_block:
detail_items.append(summary_en_block)
if SHOW_SUMMARY_RU and summary_ru_block:
detail_items.append(summary_ru_block)
detail_items.extend([
html.Div("Вклад в продуктивность:", style={
'fontWeight': 'bold', 'fontSize': '12px', 'marginTop': '8px', 'marginBottom': '2px'
}),
dcc.Graph(
figure=pie_fig,
style={'height': '200px', 'width': '100%'},
config={'displayModeBar': False}
)
])
detail_html = html.Div(detail_items, style={'fontSize': '10px'})
return title, detail_html
# === Download отчёт ===
@app.callback(
Output("download-report", "data"),
Input("download-report-btn", "n_clicks"),
prevent_initial_call=True,
)
def download_pdf(n_clicks):
if not n_clicks:
return dash.no_update
if os.path.exists(PDF_PATH):
with open(PDF_PATH, "rb") as f:
return dcc.send_bytes(f.read(), "report.pdf")
else:
return dcc.send_string("В данной версии генерация отчетов отключена. Обратитесь к разработчику.", "report.txt")
# === Запуск ===
if __name__ == '__main__':
app.run(debug=False, host="0.0.0.0", port=int(os.environ.get("PORT", "8051")))