Spaces:
Sleeping
Sleeping
| 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 | |
| def grnti_load_df_by_period(period_label): | |
| fname = f"grnti_agg_result_multi__{period_label}.parquet" | |
| return load_df_cached(fname) | |
| # === Кластеры и bar === | |
| 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 | |
| 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(" ", "") | |
| 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 === | |
| 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 === | |
| 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 === | |
| 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 | |
| # === Семантическая карта === | |
| 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})" | |
| 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 | |
| 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 отчёт === | |
| 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"))) | |