diff --git a/src/views/pages/MarketingAnalysis.vue b/src/views/pages/MarketingAnalysis.vue
index 15dc80f..aa7d184 100644
--- a/src/views/pages/MarketingAnalysis.vue
+++ b/src/views/pages/MarketingAnalysis.vue
@@ -453,7 +453,19 @@
>
-
+
+
+
+
+
+
+
+
+
{{ metric.value }}%
+
+
@@ -1229,6 +1241,23 @@ function parsePercent(value) {
const ageRows = computed(() => {
const a = report.value?.sections?.target_audience?.age_structure ?? {};
+ // Маппинг ключей бэкенда на ключи фронтенда
+ // Если ключ бэкенда пересекается с несколькими группами, распределяем значение
+ const backendKeyMap = {
+ '18-24': { '18-24': 1.0 },
+ '18-25': { '18-24': 1.0 }, // Маппим на 18-24
+ '25-34': { '25-34': 1.0 },
+ '25-45': { '25-34': 0.5, '35-44': 0.5 }, // Распределяем поровну
+ '35-44': { '35-44': 1.0 },
+ '20-40': { '25-34': 0.5, '35-44': 0.5 }, // Распределяем поровну
+ '45-54': { '45-54': 1.0 },
+ '40-60': { '45-54': 1.0 }, // Маппим на 45-54
+ '55+': { '55+': 1.0 },
+ '60+': { '55+': 1.0 },
+ '60_plus': { '55+': 1.0 }
+ };
+
+ // Стандартный порядок для отображения
const order = [
{ key: '18-24', label: '18–24' },
{ key: '25-34', label: '25–34' },
@@ -1237,9 +1266,28 @@ const ageRows = computed(() => {
{ key: '55+', label: '55+' }
];
+ // Агрегируем значения из бэкенда
+ const aggregated = {};
+
+ Object.entries(a).forEach(([backendKey, value]) => {
+ const mapping = backendKeyMap[backendKey];
+ if (mapping) {
+ const parsedValue = parsePercent(value);
+ if (Number.isFinite(parsedValue)) {
+ Object.entries(mapping).forEach(([frontendKey, ratio]) => {
+ aggregated[frontendKey] = (aggregated[frontendKey] || 0) + parsedValue * ratio;
+ });
+ }
+ }
+ });
+
return order.map((o) => {
- const v = parsePercent(a?.[o.key]);
- return { key: o.key, label: o.label, value: Number.isFinite(v) ? Math.round(v) : 0 };
+ const v = aggregated[o.key] || 0;
+ return {
+ key: o.key,
+ label: o.label,
+ value: Number.isFinite(v) ? Math.round(v) : 0
+ };
});
});
@@ -1260,6 +1308,75 @@ const compactDonutOptions = computed(() => ({
cutout: '62%'
}));
+// Опции для donut диаграммы с отображением процентов
+const queryTypesDonutOptions = computed(() => {
+ return {
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { position: 'right', labels: { usePointStyle: true, color: '#334155', boxWidth: 8 } },
+ tooltip: {
+ callbacks: {
+ label: function (context) {
+ const label = context.label || '';
+ const value = context.parsed || 0;
+ const total = context.dataset.data.reduce((a, b) => a + b, 0);
+ const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
+ return `${label}: ${percentage}%`;
+ }
+ }
+ },
+ // Кастомный плагин для отображения процентов на сегментах
+ donutPercent: {
+ id: 'donutPercent',
+ afterDatasetsDraw(chart) {
+ const ctx = chart.ctx;
+ const data = chart.data.datasets[0]?.data || [];
+ const total = data.reduce((a, b) => a + b, 0);
+
+ if (total === 0) return;
+
+ chart.data.datasets.forEach((dataset, i) => {
+ const meta = chart.getDatasetMeta(i);
+ meta.data.forEach((element, index) => {
+ const value = dataset.data[index];
+ const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
+
+ // Пропускаем отображение, если процент слишком мал
+ if (parseFloat(percentage) < 3) return;
+
+ // Получаем центр сегмента (для donut диаграммы используем центр дуги)
+ const arc = element;
+ const { x, y, startAngle, endAngle, innerRadius, outerRadius } = arc;
+
+ // Вычисляем средний угол для размещения текста
+ const angle = (startAngle + endAngle) / 2;
+ // Размещаем текст на середине между innerRadius и outerRadius
+ const radius = (innerRadius + outerRadius) / 2;
+
+ // Вычисляем координаты центра сегмента
+ const centerX = x + Math.cos(angle) * radius;
+ const centerY = y + Math.sin(angle) * radius;
+
+ ctx.save();
+ ctx.fillStyle = '#ffffff';
+ ctx.font = 'bold 13px Inter, system-ui, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.4)';
+ ctx.shadowBlur = 4;
+ ctx.shadowOffsetX = 1;
+ ctx.shadowOffsetY = 1;
+ ctx.fillText(`${percentage}%`, centerX, centerY);
+ ctx.restore();
+ });
+ });
+ }
+ }
+ },
+ cutout: '62%'
+ };
+});
+
const ageBarData = computed(() => ({
labels: ageRows.value.map((r) => r.label),
datasets: [
@@ -1495,6 +1612,22 @@ const queryTypesDonutData = computed(() => {
};
});
+// Метрики для отображения под диаграммой
+const queryTypesMetrics = computed(() => {
+ const q = report.value?.sections?.demand_and_behavior?.search_query_types ?? {};
+ const labels = ['Прямой интерес', 'Поиск по вопросу', 'Конкуренты', 'Решение проблемы'];
+ const values = [q.direct_interest_catalog, q.question_based_articles, q.competitors_reviews, q.problem_solving_hot_promo].map(percentToNumber);
+ const colors = [PPT_BLUE, '#7C3AED', PPT_ORANGE, '#34D399'];
+
+ return labels
+ .map((label, index) => ({
+ label,
+ value: values[index] || 0,
+ color: colors[index]
+ }))
+ .filter((metric) => metric.value > 0);
+});
+
const decisionFactorsBarData = computed(() => {
const f = report.value?.sections?.demand_and_behavior?.decision_factors ?? {};
const labels = ['Акция/скидка', 'Отзыв/рекоменд.', 'Гарантия/качество', 'Удобство/сервис', 'Наличие мест', 'Цена'];