.
This commit is contained in:
@@ -453,7 +453,19 @@
|
||||
></i>
|
||||
</div>
|
||||
<div class="h-[260px]">
|
||||
<Chart type="doughnut" :data="queryTypesDonutData" :options="compactDonutOptions" />
|
||||
<Chart type="doughnut" :data="queryTypesDonutData" :options="queryTypesDonutOptions" />
|
||||
</div>
|
||||
<!-- Метрики под диаграммой -->
|
||||
<div v-if="queryTypesMetrics.length > 0" class="mt-4 pt-4 border-t border-slate-200/70">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div v-for="metric in queryTypesMetrics" :key="metric.label" class="flex items-center gap-2">
|
||||
<div class="h-3 w-3 rounded-full" :style="{ background: metric.color }"></div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-[12px] text-slate-600 truncate">{{ metric.label }}</div>
|
||||
</div>
|
||||
<div class="text-[13px] font-semibold text-slate-800 whitespace-nowrap">{{ metric.value }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 = ['Акция/скидка', 'Отзыв/рекоменд.', 'Гарантия/качество', 'Удобство/сервис', 'Наличие мест', 'Цена'];
|
||||
|
||||
Reference in New Issue
Block a user