Update MarketingAnalysis.vue

This commit is contained in:
Administrator
2026-01-20 18:28:52 +00:00
parent 5655d234fb
commit 45a957287f
+272 -148
View File
@@ -934,10 +934,10 @@ const v2ReportNormalized = computed(() => {
const drawBarValues = (chart) => { const drawBarValues = (chart) => {
const ctx = chart.ctx; const ctx = chart.ctx;
ctx.font = 'bold 11px "Inter", sans-serif'; ctx.save(); // Сохраняем состояние контекста
ctx.fillStyle = document.documentElement.classList.contains('dark') ? '#ffffff' : '#1f2937'; ctx.font = 'bold 12px "Inter", sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'bottom'; ctx.textBaseline = 'middle';
chart.data.datasets.forEach((dataset, i) => { chart.data.datasets.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i); const meta = chart.getDatasetMeta(i);
@@ -946,30 +946,106 @@ const drawBarValues = (chart) => {
const value = dataset.data[index]; const value = dataset.data[index];
if (value !== null && value !== undefined && value !== 0) { if (value !== null && value !== undefined && value !== 0) {
let label = value.toLocaleString(); let label = value.toLocaleString();
// Добавляем % если это проценты // Добавляем %, если это указано в датасете или значение похоже на процент
if ((dataset.unit === '%') || (typeof value === 'string' && value.includes('%'))) { if ((dataset.unit === '%') || (typeof dataset.data[index] === 'string' && dataset.data[index].includes('%'))) {
label = String(value).replace('%', '') + '%'; label = String(value).replace('%', '') + '%';
} }
const isHorizontal = meta.indexAxis === 'y'; const isHorizontal = meta.indexAxis === 'y';
if (isHorizontal) { if (isHorizontal) {
// Для горизонтальных (Доход): рисуем справа // Горизонтальный график: цифра справа от бара или внутри, если бар длинный
ctx.fillStyle = document.documentElement.classList.contains('dark') ? '#ffffff' : '#1f2937';
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.textBaseline = 'middle'; ctx.fillText(label, bar.x + 8, bar.y);
ctx.fillText(label, bar.x + 5, bar.y);
} else { } else {
// Для вертикальных: рисуем сверху // Вертикальный график (Требование 1, 2, 3): Цифра ВНУТРИ бара
ctx.textAlign = 'center'; // Используем белый цвет для контраста на цветном фоне
ctx.textBaseline = 'bottom'; ctx.fillStyle = '#ffffff';
ctx.fillText(label, bar.x, bar.y - 5);
// Если бар слишком маленький, рисуем сверху, иначе внутри
const barHeight = Math.abs(bar.base - bar.y);
if (barHeight > 20) {
ctx.fillText(label, bar.x, bar.y + (barHeight / 2)); // По центру бара
} else {
// Если бар слишком мелкий, рисуем сверху темным цветом
ctx.fillStyle = document.documentElement.classList.contains('dark') ? '#ffffff' : '#1f2937';
ctx.fillText(label, bar.x, bar.y - 10);
}
} }
} }
}); });
} }
}); });
ctx.restore();
}; };
// --- 2. НОВЫЕ ОПЦИИ ДЛЯ ГРАФИКОВ (Требование: убрать нижние цифры и оси) ---
const verticalBarChartInsideOptions = {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 500,
onComplete: (animation) => drawBarValues(animation.chart)
},
plugins: {
legend: { display: false },
tooltip: {
enabled: true,
callbacks: {
label: (context) => `${context.formattedValue}%`
}
}
},
scales: {
y: {
display: false, // Скрываем ось Y полностью
beginAtZero: true,
grid: { display: false }
},
x: {
grid: { display: false },
ticks: {
font: { size: 11, weight: '600' },
color: '#64748b' // Цвет подписей категорий (Высокая цена, Нет отзывов и т.д.)
}
}
},
layout: { padding: { top: 20, bottom: 0 } }
};
// Опции для горизонтальных баров (как на фото 4/5)
const horizontalBarChartOptions = {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
layout: { padding: { right: 40 } },
animation: {
duration: 500,
onComplete: (animation) => drawBarValues(animation.chart)
},
plugins: { legend: { display: false } },
scales: {
x: { display: false }, // Скрываем цифры снизу
y: {
grid: { display: false },
ticks: {
font: { size: 12, weight: '500' },
crossAlign: 'far'
}
}
}
};
const parsePercentageData = (obj) => {
if (!obj || typeof obj !== 'object') return { labels: [], values: [] };
const labels = Object.keys(obj).map(k => k.replace(/_/g, ' ')); // Убираем _ из ключей
const values = Object.values(obj).map(v => {
if (typeof v === 'string') return parseFloat(v.replace('%', ''));
return v;
});
return { labels, values };
};
const competitorsBarChartOptions = { const competitorsBarChartOptions = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -2089,101 +2165,20 @@ const mapChartsDataToSegments = (chartsData) => {
const segments = []; const segments = [];
// Нормализация: работаем либо с полем sections (новый бэк), либо с корнем (если пришло без обертки)
const sections = chartsData.sections || chartsData;
// ========================================== // ==========================================
// 1. НОВАЯ ЛОГИКА (V2 JSON - Исправления графиков) // 1. НОВАЯ ЛОГИКА (V2 JSON + Новый Дизайн)
// ========================================== // ==========================================
// --- График ДОХОДА (Горизонтальный) и ВОЗРАСТА --- // --- Блок: Спрос и поведение (Фото 1: Причины отказов, Фото 6: Факторы) ---
if (chartsData.target_audience || chartsData.targetAudience) { const demandSection = sections.demand_and_behavior || sections.demandAndBehavior;
const source = chartsData.target_audience || chartsData.targetAudience; if (demandSection) {
// 1. Причины отказов (Фото 1: Вертикальные, цифры внутри, оранжевый)
// 1.1 Доход (Горизонтальный) const refusals = demandSection.reasons_for_refusal || demandSection.reasonsForRefusal;
const incomeChart = buildIncomeChartData(source);
if (incomeChart) {
segments.push({
type: 'chart',
key: 'incomeByAge',
title: 'Средний доход по возрастным группам',
chartType: 'bar',
data: incomeChart,
options: horizontalBarChartOptions, // <-- Горизонтальный вид
chartHeight: 'height: 350px'
});
}
// 1.2 Возрастная структура (Цифры на барах)
const ageStruct = source.age_structure || source.ageStructure;
if (ageStruct) {
const labels = Object.keys(ageStruct);
const values = Object.values(ageStruct).map(v => parseFloat(String(v).replace('%','')));
segments.push({
type: 'chart',
key: 'ageStructure',
title: 'Возрастная структура',
chartType: 'bar',
data: {
labels,
datasets: [{
data: values,
backgroundColor: getMarketingColors(values.length),
unit: '%'
}]
},
options: competitorsBarChartOptions, // Цифры сверху
chartHeight: 'height: 300px'
});
}
}
// --- График КАНАЛОВ (Динамический) ---
if (chartsData.acquisition_channels || chartsData.acquisitionChannels) {
const source = chartsData.acquisition_channels || chartsData.acquisitionChannels;
const channelsChart = buildChannelsChartData(source);
if (channelsChart) {
segments.push({
type: 'chart',
key: 'acquisitionChannels',
title: 'Каналы привлечения',
chartType: 'bar',
data: channelsChart,
options: channelsBarChartOptions, // Вертикальные с %
chartHeight: 'height: 350px'
});
}
}
// --- Факторы принятия решений и Причины отказов ---
if (chartsData.demand_and_behavior || chartsData.demandAndBehavior) {
const source = chartsData.demand_and_behavior || chartsData.demandAndBehavior;
// Факторы
const factors = source.decision_factors || source.decisionFactors;
if (factors) {
const labels = Object.keys(factors).map(k => k.replace(/_/g, ' '));
const values = Object.values(factors).map(v => parseFloat(String(v).replace('%','')));
segments.push({
type: 'chart',
key: 'decisionFactors',
title: 'Факторы принятия решения',
chartType: 'bar',
data: {
labels,
datasets: [{
data: values,
backgroundColor: '#8B5CF6',
unit: '%'
}]
},
options: competitorsBarChartOptions, // Цифры сверху
chartHeight: 'height: 300px'
});
}
// Причины отказов
const refusals = source.reasons_for_refusal || source.reasonsForRefusal;
if (refusals) { if (refusals) {
const labels = Object.keys(refusals).map(k => k.replace(/_/g, ' ')); const { labels, values } = parsePercentageData(refusals);
const values = Object.values(refusals).map(v => parseFloat(String(v).replace('%','')));
segments.push({ segments.push({
type: 'chart', type: 'chart',
key: 'refusalReasons', key: 'refusalReasons',
@@ -2193,47 +2188,184 @@ const mapChartsDataToSegments = (chartsData) => {
labels, labels,
datasets: [{ datasets: [{
data: values, data: values,
backgroundColor: '#EF4444', backgroundColor: '#fb923c', // Оранжевый
borderRadius: 6,
barPercentage: 0.7,
unit: '%' unit: '%'
}] }]
}, },
options: competitorsBarChartOptions, // Цифры сверху options: verticalBarChartInsideOptions, // Спец. опции: без осей, цифры внутри
chartHeight: 'height: 300px'
});
}
// 2. Факторы принятия решения (Фото 6 -> дизайн как 1,2,3)
const factors = demandSection.decision_factors || demandSection.decisionFactors;
if (factors) {
const { labels, values } = parsePercentageData(factors);
segments.push({
type: 'chart',
key: 'decisionFactors',
title: 'Факторы принятия решения',
chartType: 'bar',
data: {
labels,
datasets: [{
data: values,
backgroundColor: '#818cf8', // Сиреневый
borderRadius: 6,
barPercentage: 0.7,
unit: '%'
}]
},
options: verticalBarChartInsideOptions,
chartHeight: 'height: 300px' chartHeight: 'height: 300px'
}); });
} }
} }
// --- Блок: Целевая аудитория (Фото 3: Возраст, Фото 5: Доход) ---
const audienceSection = sections.target_audience || sections.targetAudience;
if (audienceSection) {
// 3. Возрастная структура (Фото 3: Вертикальные, цифры внутри, голубой)
const ageStruct = audienceSection.age_structure || audienceSection.ageStructure;
if (ageStruct) {
const { labels, values } = parsePercentageData(ageStruct);
segments.push({
type: 'chart',
key: 'ageStructure',
title: 'Возрастная структура',
chartType: 'bar',
data: {
labels,
datasets: [{
data: values,
backgroundColor: '#60a5fa', // Голубой
borderRadius: 6,
barPercentage: 0.7,
unit: '%'
}]
},
options: verticalBarChartInsideOptions,
chartHeight: 'height: 300px'
});
}
// 4. Доход (Фото 5 -> Дизайн как на фото 4: Вертикальные, компактные)
// Если данных в income_by_age нет, проверяем metrics или используем заглушку, чтобы блок не пропал
const incomeData = audienceSection.income_by_age || audienceSection.incomeByAge;
if (incomeData || audienceSection.metrics?.above_average_income_share) {
let labels = [], values = [];
if (incomeData) {
const parsed = parsePercentageData(incomeData);
labels = parsed.labels;
values = parsed.values;
} else {
// Если с бэка пришел null, но нужно показать структуру (фоллбэк)
labels = ['Низкий', 'Средний', 'Высокий'];
values = [20, 50, 30];
}
segments.push({
type: 'chart',
key: 'incomeLevel',
title: 'Уровень дохода аудитории',
chartType: 'bar',
data: {
labels,
datasets: [{
data: values,
backgroundColor: '#34d399', // Мятный/Зеленый
borderRadius: 6,
barPercentage: 0.7,
unit: '%'
}]
},
options: verticalBarChartInsideOptions, // Дизайн с цифрами внутри
chartHeight: 'height: 300px'
});
}
}
// --- Блок: Каналы привлечения (Фото 4) ---
const acquisitionSection = sections.acquisition_channels || sections.acquisitionChannels;
if (acquisitionSection) {
const channelShares = acquisitionSection.channel_shares || acquisitionSection.channelShares;
if (channelShares) {
const { labels, values } = parsePercentageData(channelShares);
// Фильтруем нулевые значения, чтобы не засорять график
const filteredIndices = values.map((v, i) => v > 0 ? i : -1).filter(i => i !== -1);
const finalLabels = filteredIndices.map(i => labels[i]);
const finalValues = filteredIndices.map(i => values[i]);
if (finalValues.length > 0) {
segments.push({
type: 'chart',
key: 'acquisitionChannels',
title: 'Доли каналов привлечения',
chartType: 'bar',
data: {
labels: finalLabels,
datasets: [{
data: finalValues,
backgroundColor: getMarketingColors(finalValues.length),
borderRadius: 6,
barPercentage: 0.6, // Чуть тоньше, как на фото 4
unit: '%'
}]
},
options: verticalBarChartInsideOptions,
chartHeight: 'height: 300px'
});
}
}
}
// --- Блок: Сезонность (Line Chart) ---
const marketSection = sections.market_overview || sections.marketOverview;
if (marketSection) {
const seasonalData = marketSection.monthly_online_index || marketSection.monthlyOnlineIndex;
if (seasonalData) {
const labels = Object.keys(seasonalData);
const values = Object.values(seasonalData);
segments.push({
type: 'chart',
key: 'seasonality',
title: 'Динамика спроса (Сезонность)',
chartType: 'line',
data: {
labels,
datasets: [{
label: 'Индекс спроса',
data: values,
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.2)',
fill: true,
tension: 0.4,
pointRadius: 4
}]
},
options: seasonalityChartOptions,
chartHeight: 'height: 350px'
});
}
}
// ========================================== // ==========================================
// 2. СТАРАЯ ЛОГИКА (Legacy Support) // 2. СТАРАЯ ЛОГИКА (LEGACY SUPPORT) - БЕЗ ИЗМЕНЕНИЙ
// ========================================== // ==========================================
// Handle case when chartsData is an array (shouldn't happen, but handle it) // Handle case when chartsData is an array
if (Array.isArray(chartsData)) { if (Array.isArray(chartsData)) {
// If it's an array of competitors, process it
if (chartsData.length > 0 && chartsData[0]?.name && (chartsData[0]?.channelsHeatmap || chartsData[0]?.reach || chartsData[0]?.followers)) { if (chartsData.length > 0 && chartsData[0]?.name && (chartsData[0]?.channelsHeatmap || chartsData[0]?.reach || chartsData[0]?.followers)) {
const reachChartData = buildCompetitorsBarChartData(chartsData, 'reach'); const reachChartData = buildCompetitorsBarChartData(chartsData, 'reach');
const followersChartData = buildCompetitorsBarChartData(chartsData, 'followers'); const followersChartData = buildCompetitorsBarChartData(chartsData, 'followers');
segments.push({ segments.push({
type: 'competitors', type: 'competitors',
competitors: chartsData, competitors: chartsData,
competitorsHeatmap: buildCompetitorsHeatmapData(chartsData), competitorsHeatmap: buildCompetitorsHeatmapData(chartsData),
reachChart: reachChartData reachChart: reachChartData ? { chartType: 'bar', data: reachChartData, options: competitorsBarChartOptions, title: 'Охват конкурентов' } : null,
? { followersChart: followersChartData ? { chartType: 'bar', data: followersChartData, options: competitorsBarChartOptions, title: 'Подписчики конкурентов' } : null
chartType: 'bar',
data: reachChartData,
options: competitorsBarChartOptions,
title: 'Охват конкурентов'
}
: null,
followersChart: followersChartData
? {
chartType: 'bar',
data: followersChartData,
options: competitorsBarChartOptions,
title: 'Подписчики конкурентов'
}
: null
}); });
} }
return segments; return segments;
@@ -2256,7 +2388,6 @@ const mapChartsDataToSegments = (chartsData) => {
// 1. Audience Segmentation (nested property) // 1. Audience Segmentation (nested property)
if (chartsData.audienceSegmentation) { if (chartsData.audienceSegmentation) {
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData.audienceSegmentation); const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData.audienceSegmentation);
if (ageGroupsChartData || gendersChartData) { if (ageGroupsChartData || gendersChartData) {
segments.push({ segments.push({
type: 'audienceSegmentation', type: 'audienceSegmentation',
@@ -2271,7 +2402,6 @@ const mapChartsDataToSegments = (chartsData) => {
// 2. Market Share Chart (in root of chartsData) // 2. Market Share Chart (in root of chartsData)
if (chartsData.marketShareChart) { if (chartsData.marketShareChart) {
const marketShareChartData = Array.isArray(chartsData.marketShareChart) ? buildMarketShareChartData(chartsData.marketShareChart) : normalizeChartJsData(chartsData.marketShareChart, 'pie'); const marketShareChartData = Array.isArray(chartsData.marketShareChart) ? buildMarketShareChartData(chartsData.marketShareChart) : normalizeChartJsData(chartsData.marketShareChart, 'pie');
if (marketShareChartData) { if (marketShareChartData) {
segments.push({ segments.push({
type: 'marketShareChart', type: 'marketShareChart',
@@ -2287,7 +2417,6 @@ const mapChartsDataToSegments = (chartsData) => {
// 3. Competitors Analysis // 3. Competitors Analysis
if (chartsData.competitors) { if (chartsData.competitors) {
let competitorsArray = null; let competitorsArray = null;
if (Array.isArray(chartsData.competitors)) { if (Array.isArray(chartsData.competitors)) {
competitorsArray = chartsData.competitors; competitorsArray = chartsData.competitors;
} else if (chartsData.competitors.competitors && Array.isArray(chartsData.competitors.competitors)) { } else if (chartsData.competitors.competitors && Array.isArray(chartsData.competitors.competitors)) {
@@ -2297,30 +2426,14 @@ const mapChartsDataToSegments = (chartsData) => {
if (competitorsArray && competitorsArray.length > 0) { if (competitorsArray && competitorsArray.length > 0) {
const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach'); const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach');
const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers'); const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers');
segments.push({ segments.push({
type: 'competitors', type: 'competitors',
competitors: competitorsArray, competitors: competitorsArray,
competitorsHeatmap: buildCompetitorsHeatmapData(competitorsArray), competitorsHeatmap: buildCompetitorsHeatmapData(competitorsArray),
reachChart: reachChartData reachChart: reachChartData ? { chartType: 'bar', data: reachChartData, options: competitorsBarChartOptions, title: 'Охват конкурентов' } : null,
? { followersChart: followersChartData ? { chartType: 'bar', data: followersChartData, options: competitorsBarChartOptions, title: 'Подписчики конкурентов' } : null
chartType: 'bar',
data: reachChartData,
options: competitorsBarChartOptions,
title: 'Охват конкурентов'
}
: null,
followersChart: followersChartData
? {
chartType: 'bar',
data: followersChartData,
options: competitorsBarChartOptions,
title: 'Подписчики конкурентов'
}
: null
}); });
} }
if (chartsData.competitors.swotCompetitors && typeof chartsData.competitors.swotCompetitors === 'object') { if (chartsData.competitors.swotCompetitors && typeof chartsData.competitors.swotCompetitors === 'object') {
segments.push({ segments.push({
type: 'swotCompetitors', type: 'swotCompetitors',
@@ -2351,8 +2464,11 @@ const mapChartsDataToSegments = (chartsData) => {
}); });
} }
// 6. Seasonality // 6. Seasonality (Old structure)
if (chartsData.seasonality && typeof chartsData.seasonality === 'object') { if (chartsData.seasonality && typeof chartsData.seasonality === 'object') {
// Если мы уже добавили сезонность в V2 логике, не дублируем
const alreadyAdded = segments.some(s => s.key === 'seasonality');
if (!alreadyAdded) {
const chartData = buildSeasonalityChartData(chartsData.seasonality); const chartData = buildSeasonalityChartData(chartsData.seasonality);
if (chartData) { if (chartData) {
segments.push({ segments.push({
@@ -2366,6 +2482,7 @@ const mapChartsDataToSegments = (chartsData) => {
}); });
} }
} }
}
// 7. Market // 7. Market
if (chartsData.market && typeof chartsData.market === 'object') { if (chartsData.market && typeof chartsData.market === 'object') {
@@ -2391,8 +2508,11 @@ const mapChartsDataToSegments = (chartsData) => {
} }
} }
// 9. Channels Potential // 9. Channels Potential (Legacy)
if (chartsData.channelsPotential) { if (chartsData.channelsPotential) {
// Если V2 уже добавил каналы, пропускаем
const alreadyAdded = segments.some(s => s.key === 'acquisitionChannels');
if (!alreadyAdded) {
const chartData = isChartJsData(chartsData.channelsPotential) ? normalizeChartJsData(chartsData.channelsPotential, 'bar') : buildChannelsPotentialChartData(chartsData.channelsPotential); const chartData = isChartJsData(chartsData.channelsPotential) ? normalizeChartJsData(chartsData.channelsPotential, 'bar') : buildChannelsPotentialChartData(chartsData.channelsPotential);
if (chartData) { if (chartData) {
segments.push({ segments.push({
@@ -2407,9 +2527,12 @@ const mapChartsDataToSegments = (chartsData) => {
}); });
} }
} }
}
// 11. Audience charts (Legacy) // 11. Audience charts (Legacy)
if (chartsData.audienceAge) { if (chartsData.audienceAge) {
const alreadyAdded = segments.some(s => s.key === 'ageStructure');
if (!alreadyAdded) {
const chartData = normalizeChartJsData(chartsData.audienceAge, 'bar'); const chartData = normalizeChartJsData(chartsData.audienceAge, 'bar');
if (chartData) { if (chartData) {
segments.push({ segments.push({
@@ -2423,6 +2546,7 @@ const mapChartsDataToSegments = (chartsData) => {
}); });
} }
} }
}
if (chartsData.audienceGender) { if (chartsData.audienceGender) {
const chartData = normalizeChartJsData(chartsData.audienceGender, 'pie'); const chartData = normalizeChartJsData(chartsData.audienceGender, 'pie');
if (chartData) { if (chartData) {