""" dashboard.py — главный вид статистического дашборда. Графики строятся через matplotlib и отображаются как изображения (ft.Image). """ import asyncio import base64 import io import math import flet as ft import matplotlib import matplotlib.colors import matplotlib.pyplot as plt import matplotlib.font_manager as fm import designer as ds import api_client as ac matplotlib.use("Agg") # без GUI, только в память # Цвета графиков _PALETTE = [ "#F85A40", "#007FBD", "#85C446", "#FFC845", "#D94530", "#5A6779", "#006AA3", "#4A9E21", ] def _wrap_label(text: str, max_len: int = 18) -> str: """Перенос длинного текста через \n для подписей осей.""" if len(text) <= max_len: return text words = text.split() lines, line = [], "" for w in words: if len(line) + len(w) + 1 <= max_len: line = f"{line} {w}".strip() else: if line: lines.append(line) line = w if line: lines.append(line) return "\n".join(lines) def _build_chart_image(data: list[dict], title: str, color: str) -> bytes | None: """Строит горизонтальную bar chart через matplotlib, возвращает PNG bytes.""" if not data: return None labels = [_wrap_label(d["label"]) for d in data] counts = [d["count"] for d in data] n = len(labels) fig_h = max(4.5, n * 0.55 + 1.2) fig, ax = plt.subplots(figsize=(10, fig_h)) fig.patch.set_facecolor("#FFFFFF") ax.set_facecolor("#F4F6F8") bars = ax.barh( range(n), counts, color=color, height=0.6, edgecolor="white", linewidth=0.5, ) # Подписи значений for bar, v in zip(bars, counts): ax.text( v + max(counts) * 0.01, bar.get_y() + bar.get_height() / 2, str(v), va="center", ha="left", fontsize=11, color="#1E2A3A", ) ax.set_yticks(range(n)) ax.set_yticklabels(labels, fontsize=11, color="#5A6779") ax.invert_yaxis() ax.set_xlabel("Количество ответов", fontsize=11, color="#5A6779") ax.xaxis.set_tick_params(labelsize=11, colors="#5A6779") ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) ax.spines["left"].set_color("#DDE3EA") ax.spines["bottom"].set_color("#DDE3EA") ax.set_xlim(0, max(counts) * 1.18 if max(counts) > 0 else 1) ax.grid(axis="x", color="#DDE3EA", linewidth=0.5, linestyle="--") plt.tight_layout(pad=1.2) buf = io.BytesIO() fig.savefig(buf, format="png", dpi=130, bbox_inches="tight") plt.close(fig) buf.seek(0) return buf.read() def _build_line_chart_image(data: list[dict]) -> bytes | None: """Строит линейный график для данных по месяцам.""" if not data: return None labels = [d["label"] for d in data] counts = [d["count"] for d in data] n = len(labels) fig, ax = plt.subplots(figsize=(max(10, n * 1.2), 5.5)) fig.patch.set_facecolor("#FFFFFF") ax.set_facecolor("#F4F6F8") ax.plot(range(n), counts, color="#F85A40", linewidth=2.5, marker="o", markersize=7, markerfacecolor="#F85A40", markeredgecolor="white", markeredgewidth=1.5) ax.fill_between(range(n), counts, alpha=0.12, color="#F85A40") for i, v in enumerate(counts): ax.text(i, v + max(counts) * 0.03, str(v), ha="center", fontsize=11, color="#1E2A3A") ax.set_xticks(range(n)) ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=11, color="#5A6779") ax.yaxis.set_tick_params(labelsize=11, colors="#5A6779") ax.set_ylabel("Ответов", fontsize=11, color="#5A6779") ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) ax.spines["left"].set_color("#DDE3EA") ax.spines["bottom"].set_color("#DDE3EA") ax.set_ylim(0, max(counts) * 1.25) ax.grid(axis="y", color="#DDE3EA", linewidth=0.5, linestyle="--") plt.tight_layout(pad=1.2) buf = io.BytesIO() fig.savefig(buf, format="png", dpi=130, bbox_inches="tight") plt.close(fig) buf.seek(0) return buf.read() def _build_avg_bar(data: list[dict], xlabel: str = "Средний балл") -> bytes | None: """Горизонтальный bar chart со средними баллами (поле 'avg').""" if not data: return None labels = [_wrap_label(d["label"]) for d in data] values = [d["avg"] for d in data] n = len(labels) colors = [_PALETTE[i % len(_PALETTE)] for i in range(n)] fig_h = max(5.5, n * 0.65 + 1.5) fig, ax = plt.subplots(figsize=(14, fig_h)) fig.patch.set_facecolor("#FFFFFF") ax.set_facecolor("#F4F6F8") bars = ax.barh(range(n), values, color=colors, height=0.6, edgecolor="white", linewidth=0.5) for bar, v in zip(bars, values): ax.text(v + max(values) * 0.01, bar.get_y() + bar.get_height() / 2, f"{v:.1f}", va="center", ha="left", fontsize=11, color="#1E2A3A") ax.set_yticks(range(n)) ax.set_yticklabels(labels, fontsize=11, color="#5A6779") ax.invert_yaxis() ax.set_xlabel(xlabel, fontsize=11, color="#5A6779") ax.xaxis.set_tick_params(labelsize=11, colors="#5A6779") ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) ax.spines["left"].set_color("#DDE3EA") ax.spines["bottom"].set_color("#DDE3EA") ax.set_xlim(0, max(values) * 1.18 if max(values) > 0 else 1) ax.grid(axis="x", color="#DDE3EA", linewidth=0.5, linestyle="--") plt.tight_layout(pad=1.2) buf = io.BytesIO() fig.savefig(buf, format="png", dpi=130, bbox_inches="tight") plt.close(fig) buf.seek(0) return buf.read() def _build_pie_chart(data: list[dict]) -> bytes | None: """Круговая диаграмма распределения ведущего типа.""" if not data: return None labels = [d["label"] for d in data] values = [d["count"] for d in data] n_colors = len(labels) if n_colors <= len(_PALETTE): colors = _PALETTE[:n_colors] else: # Генерируем уникальные цвета через hsv colormap cmap = matplotlib.colormaps["hsv"] colors = [matplotlib.colors.to_hex(cmap(i / n_colors)) for i in range(n_colors)] # Размер фигуры растёт с числом элементов для читаемой легенды legend_rows = math.ceil(n_colors / 2) fig_h = max(7, legend_rows * 0.35 + 5) fig, ax = plt.subplots(figsize=(11, fig_h)) fig.patch.set_facecolor("#FFFFFF") wedges, texts, autotexts = ax.pie( values, labels=None, autopct="%1.1f%%", colors=colors, startangle=140, wedgeprops=dict(edgecolor="white", linewidth=1.5), pctdistance=0.78, ) for t in autotexts: t.set_fontsize(9 if n_colors > 15 else 10) t.set_color("#1E2A3A") ncol = 3 if n_colors > 20 else 2 ax.legend( wedges, [f"{l} ({v})" for l, v in zip(labels, values)], loc="lower center", bbox_to_anchor=(0.5, -0.05 - legend_rows * 0.04), ncol=ncol, fontsize=9, frameon=False, ) plt.tight_layout(pad=1.5) buf = io.BytesIO() fig.savefig(buf, format="png", dpi=130, bbox_inches="tight") plt.close(fig) buf.seek(0) return buf.read() def _build_avg_line(data: list[dict]) -> bytes | None: """Линейный тренд средних баллов по месяцам (поле 'avg').""" if not data: return None labels = [d["label"] for d in data] values = [d["avg"] for d in data] n = len(labels) fig, ax = plt.subplots(figsize=(max(10, n * 1.2), 5.5)) fig.patch.set_facecolor("#FFFFFF") ax.set_facecolor("#F4F6F8") ax.plot(range(n), values, color="#F85A40", linewidth=2.5, marker="o", markersize=7, markerfacecolor="#F85A40", markeredgecolor="white", markeredgewidth=1.5) ax.fill_between(range(n), values, alpha=0.12, color="#F85A40") for i, v in enumerate(values): ax.text(i, v + max(values) * 0.03, f"{v:.1f}", ha="center", fontsize=11, color="#1E2A3A") ax.set_xticks(range(n)) ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=11, color="#5A6779") ax.yaxis.set_tick_params(labelsize=11, colors="#5A6779") ax.set_ylabel("Средний балл", fontsize=11, color="#5A6779") ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) ax.spines["left"].set_color("#DDE3EA") ax.spines["bottom"].set_color("#DDE3EA") ax.set_ylim(0, max(values) * 1.25 if max(values) > 0 else 1) ax.grid(axis="y", color="#DDE3EA", linewidth=0.5, linestyle="--") plt.tight_layout(pad=1.2) buf = io.BytesIO() fig.savefig(buf, format="png", dpi=130, bbox_inches="tight") plt.close(fig) buf.seek(0) return buf.read() def _image_or_placeholder(img_bytes: bytes | None, width: int = 1100) -> ft.Control: if img_bytes: b64 = base64.b64encode(img_bytes).decode() return ft.Image( src=f"data:image/png;base64,{b64}", fit=ft.BoxFit.FIT_WIDTH, width=width, ) return ft.Container( content=ft.Text("Нет данных", color=ds.colors.text_secondary, size=ds.s(13), italic=True), alignment=ft.Alignment(0, 0), height=ds.s(120), width=width, ) class DashboardView(ft.Column): def __init__(self, page: ft.Page): self._page = page # ── Общие фильтры ──────────────────────────────────────────────────── self._poll_dd = ds.SearchableDropdown(label="Тест", hint_text="Все тесты") self._poll_dd.on_select = self._on_filter_change self._org_dd = ds.SearchableDropdown(label="Организация", hint_text="Все организации") self._org_dd.on_select = self._on_filter_change self._group_dd = ds.SearchableDropdown(label="Группа", hint_text="Все группы") self._group_dd.on_select = self._on_filter_change self._year_dd = ds.SearchableDropdown(label="Год", hint_text="Все годы") self._year_dd.on_select = self._on_filter_change self._reset_btn = ft.TextButton( "Сбросить фильтры", icon=ft.Icons.FILTER_ALT_OFF, style=ft.ButtonStyle(color=ds.colors.primary), on_click=self._reset_filters, ) self._refresh_btn = ft.FilledButton( "Обновить", icon=ft.Icons.REFRESH, style=ft.ButtonStyle( bgcolor={ft.ControlState.DEFAULT: ds.colors.primary, ft.ControlState.HOVERED: ds.colors.primary_dark}, color=ds.colors.text_on_primary, shape=ft.RoundedRectangleBorder(radius=ds.s(8)), ), on_click=lambda e: page.run_task(self._refresh_current_tab), ) # ── Summary карточки ───────────────────────────────────────────────── self._card_total = ft.Text("—", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD) self._card_polls = ft.Text("—", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD) self._card_active = ft.Text("—", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD) self._card_orgs = ft.Text("—", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD) # ── Таб 1: графики по прохождениям ─────────────────────────────────── self._chart_poll = self._make_chart_box() self._chart_org = self._make_chart_box() self._chart_group = self._make_chart_box() self._chart_month = self._make_chart_box() # ── Таб 2: результаты — фильтр по оси ──────────────────────────────── self._dim_dd = ds.SearchableDropdown(label="Ось/Тип", hint_text="Выберите ось") self._dim_dd.on_select = self._on_results_filter_change self._compare_by_dd = ds.SearchableDropdown(label="Сравнение по", hint_text="Организациям") self._compare_by_dd.options = [ ft.DropdownOption(key="organization", text="Организациям"), ft.DropdownOption(key="group", text="Группам"), ] self._compare_by_dd.value = "organization" self._compare_by_dd.on_select = self._on_results_filter_change self._chart_avg_dim = self._make_chart_box() self._chart_leading = self._make_chart_box() self._chart_compare = self._make_chart_box() self._chart_trend = self._make_chart_box() # Таблица топ-пользователей self._top_table = ft.DataTable( columns=[ ft.DataColumn(ft.Text("Участник", size=ds.s(12), weight=ft.FontWeight.W_600)), ft.DataColumn(ft.Text("Тест", size=ds.s(12), weight=ft.FontWeight.W_600)), ft.DataColumn(ft.Text("Балл", size=ds.s(12), weight=ft.FontWeight.W_600), numeric=True), ], rows=[], border=ft.border.all(1, ds.colors.border), border_radius=ds.s(8), horizontal_lines=ft.BorderSide(1, ds.colors.border), column_spacing=ds.s(20), ) # ── Навигация по табам ──────────────────────────────────────────────── self._current_tab = 0 # ── Содержимое табов ────────────────────────────────────────────────── self._tab1_content = ft.Column( spacing=ds.s(16), controls=[ ft.Container( content=self._chart_card_wrap("По тестам", self._chart_poll), padding=ft.padding.symmetric(horizontal=ds.s(16)), ), ft.Container( content=self._chart_card_wrap("По организациям", self._chart_org), padding=ft.padding.symmetric(horizontal=ds.s(16)), ), ft.Container( content=self._chart_card_wrap("По группам", self._chart_group), padding=ft.padding.symmetric(horizontal=ds.s(16)), ), ft.Container( content=self._chart_card_wrap("Динамика по месяцам", self._chart_month), padding=ft.padding.symmetric(horizontal=ds.s(16)), margin=ft.margin.only(bottom=ds.s(24)), ), ], ) self._tab2_content = ft.Column( spacing=ds.s(16), controls=[ # Дополнительный фильтр по оси ft.Container( content=ft.Column( spacing=ds.s(8), controls=[ ft.Text("Фильтр по оси/типу", color=ds.colors.text_secondary, size=ds.s(12), weight=ft.FontWeight.W_600), ft.Row( wrap=True, spacing=ds.s(10), run_spacing=ds.s(10), controls=[self._dim_dd, self._compare_by_dd], ), ], ), bgcolor=ds.colors.surface, border_radius=ds.s(12), padding=ft.padding.all(ds.s(16)), margin=ft.margin.symmetric(horizontal=ds.s(16)), border=ft.border.all(1, ds.colors.border), ), ft.Container( content=self._chart_card_wrap("Средний балл по осям", self._chart_avg_dim), padding=ft.padding.symmetric(horizontal=ds.s(16)), ), ft.Container( content=self._chart_card_wrap("Распределение ведущего типа", self._chart_leading), padding=ft.padding.symmetric(horizontal=ds.s(16)), ), ft.Container( content=self._chart_card_wrap("Сравнение по группам/организациям", self._chart_compare), padding=ft.padding.symmetric(horizontal=ds.s(16)), ), ft.Container( content=self._chart_card_wrap("Динамика среднего балла", self._chart_trend), padding=ft.padding.symmetric(horizontal=ds.s(16)), ), # Топ участников ft.Container( content=ft.Column( spacing=ds.s(10), controls=[ ft.Text("Топ-10 участников по выбранной оси", color=ds.colors.text_primary, size=ds.s(14), weight=ft.FontWeight.W_600), ft.Container(content=self._top_table, expand=True), ], ), bgcolor=ds.colors.surface, border_radius=ds.s(12), padding=ft.padding.all(ds.s(16)), margin=ft.margin.only(left=ds.s(16), right=ds.s(16), bottom=ds.s(24)), border=ft.border.all(1, ds.colors.border), ), ], ) # ── Tabs (TabBar + ручное переключение) ────────────────────────────── self._tab_body = ft.Container(content=self._tab1_content, expand=True) self._tabs_ctrl = ft.Tabs( content=ft.Column( expand=True, spacing=0, controls=[ ft.TabBar( tabs=[ ft.Tab(label="Прохождения", icon=ft.Icons.BAR_CHART), ft.Tab(label="Результаты тестов", icon=ft.Icons.INSIGHTS), ], scrollable=False, ), self._tab_body, ], ), length=2, selected_index=0, on_change=self._on_tab_change, expand=True, ) super().__init__( spacing=ds.s(16), scroll=ft.ScrollMode.AUTO, expand=True, controls=[ # Заголовок ft.Container( content=ft.Row( controls=[ ft.Text("ЦОПП", color=ds.colors.primary, size=ds.s(22), weight=ft.FontWeight.BOLD), ft.VerticalDivider(width=ds.s(16), color=ds.colors.border), ft.Text("Статистика прохождений", color=ds.colors.text_primary, size=ds.s(16)), ], vertical_alignment=ft.CrossAxisAlignment.CENTER, ), bgcolor=ds.colors.surface, padding=ft.padding.symmetric(horizontal=ds.s(20), vertical=ds.s(14)), border=ft.border.only(bottom=ft.BorderSide(1, ds.colors.border)), ), # Summary карточки ft.Container( content=ft.Row( wrap=True, spacing=ds.s(12), run_spacing=ds.s(12), controls=[ self._summary_tile("Всего ответов", self._card_total, ft.Icons.ASSIGNMENT_TURNED_IN, ds.colors.primary), self._summary_tile("Тестов всего", self._card_polls, ft.Icons.QUIZ, ds.colors.info), self._summary_tile("Активных тестов", self._card_active, ft.Icons.PLAY_CIRCLE, ds.colors.success), self._summary_tile("Организаций с ответами", self._card_orgs, ft.Icons.BUSINESS, ds.colors.accent), ], ), padding=ft.padding.symmetric(horizontal=ds.s(16)), ), # Общие фильтры ft.Container( content=ft.Column( spacing=ds.s(10), controls=[ ft.Text("Фильтры", color=ds.colors.text_secondary, size=ds.s(12), weight=ft.FontWeight.W_600), ft.Row( wrap=True, spacing=ds.s(10), run_spacing=ds.s(10), controls=[self._poll_dd, self._org_dd, self._group_dd, self._year_dd], ), ft.Row(spacing=ds.s(8), controls=[self._reset_btn, self._refresh_btn]), ], ), bgcolor=ds.colors.surface, border_radius=ds.s(12), padding=ft.padding.all(ds.s(16)), margin=ft.margin.symmetric(horizontal=ds.s(16)), border=ft.border.all(1, ds.colors.border), ), # Вкладки self._tabs_ctrl, ], ) # ── helpers ───────────────────────────────────────────────────────────── def _make_chart_box(self) -> ft.Container: return ft.Container( content=ft.ProgressRing(width=ds.s(24), height=ds.s(24)), alignment=ft.Alignment(0, 0), expand=True, ) def _summary_tile(self, title: str, value_text: ft.Text, icon: str, color: str) -> ft.Container: return ft.Container( content=ft.Column( spacing=ds.s(4), tight=True, controls=[ ft.Row(spacing=ds.s(6), controls=[ ft.Icon(icon, color=color, size=ds.s(18)), ft.Text(title, color=ds.colors.text_secondary, size=ds.s(11)), ]), value_text, ], ), bgcolor=ds.colors.surface, border_radius=ds.s(10), padding=ft.padding.all(ds.s(14)), border=ft.border.all(1, ds.colors.border), width=ds.s(200), ) def _chart_card_wrap(self, title: str, body: ft.Container) -> ft.Container: return ft.Container( content=ft.Column( spacing=ds.s(10), tight=True, controls=[ ft.Text(title, color=ds.colors.text_primary, size=ds.s(14), weight=ft.FontWeight.W_600), body, ], ), bgcolor=ds.colors.surface, border_radius=ds.s(12), padding=ft.padding.all(ds.s(16)), border=ft.border.all(1, ds.colors.border), expand=True, ) # ── mounting ───────────────────────────────────────────────────────────── def _chart_width(self) -> int: """Ширина графика = ширина страницы минус отступы.""" try: w = self.page.width or 1200 except Exception: w = 1200 return max(600, int(w) - 80) def did_mount(self): self.page.run_task(self._initial_load) async def _initial_load(self): polls, orgs, groups, years, summary, dimensions = await asyncio.gather( ac.get_polls(), ac.get_organizations(), ac.get_groups(), ac.get_years(), ac.get_summary(), ac.get_dimensions(), ) _dd_opt = lambda key, text: ft.DropdownOption(key=key, text=text) self._poll_dd.options = [_dd_opt(p["id"], p["title"]) for p in (polls or [])] self._org_dd.options = [_dd_opt(o["id"], o["name_organization"]) for o in (orgs or [])] self._group_dd.options = [_dd_opt(g["id"], g["name_group"]) for g in (groups or [])] self._year_dd.options = [_dd_opt(str(y), str(y)) for y in (years or [])] self._dim_dd.options = [_dd_opt(d, d) for d in (dimensions or [])] if dimensions: self._dim_dd.value = dimensions[0] self._card_total.value = str(summary.get("total_responses", "—")) self._card_polls.value = str(summary.get("total_polls", "—")) self._card_active.value = str(summary.get("active_polls", "—")) self._card_orgs.value = str(summary.get("orgs_active", "—")) self.update() await self._refresh_tab1() await self._refresh_tab2() # ── tab switching ───────────────────────────────────────────────────────── def _on_tab_change(self, e): self._current_tab = int(e.data) self._tab_body.content = self._tab1_content if self._current_tab == 0 else self._tab2_content self._tab_body.update() async def _refresh_current_tab(self): if self._current_tab == 0: await self._refresh_tab1() else: await self._refresh_tab2() # ── filters ─────────────────────────────────────────────────────────────── def _on_filter_change(self, e): self.page.run_task(self._refresh_current_tab) def _on_results_filter_change(self, e): self.page.run_task(self._refresh_tab2) def _reset_filters(self, e): self._poll_dd.value = None self._org_dd.value = None self._group_dd.value = None self._year_dd.value = None self.update() self.page.run_task(self._refresh_current_tab) def _get_filters(self) -> dict: year_str = self._year_dd.value return { "poll_id": self._poll_dd.value, "org_id": self._org_dd.value, "group_id": self._group_dd.value, "year": int(year_str) if year_str else None, } # ── TAB 1: прохождения ─────────────────────────────────────────────────── async def _refresh_tab1(self): f = self._get_filters() spinner = lambda: ft.ProgressRing(width=ds.s(24), height=ds.s(24)) for c in [self._chart_poll, self._chart_org, self._chart_group, self._chart_month]: c.content = spinner() self.update() d_poll, d_org, d_group, d_month = await asyncio.gather( ac.get_chart_data("poll", **f), ac.get_chart_data("organization", **f), ac.get_chart_data("group", **f), ac.get_chart_data("month", **f), ) loop = asyncio.get_event_loop() img_poll, img_org, img_group, img_month = await asyncio.gather( loop.run_in_executor(None, _build_chart_image, d_poll, "По тестам", "#F85A40"), loop.run_in_executor(None, _build_chart_image, d_org, "По организациям", "#007FBD"), loop.run_in_executor(None, _build_chart_image, d_group, "По группам", "#85C446"), loop.run_in_executor(None, _build_line_chart_image, d_month), ) w = self._chart_width() self._chart_poll.content = _image_or_placeholder(img_poll, w) self._chart_org.content = _image_or_placeholder(img_org, w) self._chart_group.content = _image_or_placeholder(img_group, w) self._chart_month.content = _image_or_placeholder(img_month, w) self.update() # ── TAB 2: результаты ──────────────────────────────────────────────────── async def _refresh_tab2(self): f = self._get_filters() dim = self._dim_dd.value by = self._compare_by_dd.value or "organization" spinner = lambda: ft.ProgressRing(width=ds.s(24), height=ds.s(24)) for c in [self._chart_avg_dim, self._chart_leading, self._chart_compare, self._chart_trend]: c.content = spinner() self._top_table.rows = [] self.update() # Параллельная загрузка d_avg, d_lead, d_top = await asyncio.gather( ac.get_avg_by_dimension(**f), ac.get_leading_type(**f), ac.get_top_users(dim, **f) if dim else asyncio.sleep(0), ) if not dim: d_top = [] d_compare = await ac.get_compare_groups(dim or "", by=by, poll_id=f["poll_id"], year=f["year"]) if dim else [] d_trend = await ac.get_dimension_trend(dim or "", poll_id=f["poll_id"], org_id=f["org_id"], group_id=f["group_id"]) if dim else [] loop = asyncio.get_event_loop() img_avg, img_lead, img_compare, img_trend = await asyncio.gather( loop.run_in_executor(None, _build_avg_bar, d_avg, "Средний балл"), loop.run_in_executor(None, _build_pie_chart, d_lead), loop.run_in_executor(None, _build_avg_bar, d_compare, f"Средний балл по {'организациям' if by == 'organization' else 'группам'}"), loop.run_in_executor(None, _build_avg_line, d_trend), ) w = self._chart_width() self._chart_avg_dim.content = _image_or_placeholder(img_avg, w) self._chart_leading.content = _image_or_placeholder(img_lead, w) self._chart_compare.content = _image_or_placeholder(img_compare, w) self._chart_trend.content = _image_or_placeholder(img_trend, w) # Таблица топ self._top_table.rows = [ ft.DataRow(cells=[ ft.DataCell(ft.Text(row["name"], size=ds.s(12))), ft.DataCell(ft.Text(row["poll"], size=ds.s(11), color=ds.colors.text_secondary, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1)), ft.DataCell(ft.Text(str(row["value"]), size=ds.s(12), weight=ft.FontWeight.BOLD, color=ds.colors.primary)), ]) for row in (d_top or []) ] self.update()