Files
api-copp/stats/dashboard.py

746 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()