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]}
Название: {cd[1]}
{label_percent} {cd[2]:.1%}" ) texttemplate = ( "Код: %{customdata[0]}
" "Название: %{customdata[1]}
" 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}
Авторы:
Нет авторов") 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}
Авторы:
" + "
".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)}
Автор: {author_short}" f"
Публикаций: {n_pubs}" f"
Продуктивность: {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)}
Автор: {author_short}" f"
Публикаций: {n_pubs}" f"
Продуктивность: {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 = ( "%{customdata[1]}
" "Авторы: %{customdata[6]}
" "Источник: %{customdata[4]}
" "Год: %{customdata[5]}
" "Авторский кластер: %{customdata[2]}
" "Семантический кластер: %{customdata[3]}
" "Общая продуктивность: %{customdata[8]}
" "Вклад организации: %{customdata[7]}
" "Координаты: (%{customdata[9]}, %{customdata[10]})
" "" ) 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")))