Merge branch 'arys-main-patch-49109' into 'main'
Update MarketingAnalysis.vue See merge request web/marketing!1
This commit is contained in:
@@ -932,6 +932,92 @@ const v2ReportNormalized = computed(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const drawBarValues = (chart) => {
|
||||||
|
const ctx = chart.ctx;
|
||||||
|
ctx.font = 'bold 11px "Inter", sans-serif';
|
||||||
|
ctx.fillStyle = document.documentElement.classList.contains('dark') ? '#ffffff' : '#1f2937';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'bottom';
|
||||||
|
|
||||||
|
chart.data.datasets.forEach((dataset, i) => {
|
||||||
|
const meta = chart.getDatasetMeta(i);
|
||||||
|
if (!meta.hidden) {
|
||||||
|
meta.data.forEach((bar, index) => {
|
||||||
|
const value = dataset.data[index];
|
||||||
|
if (value !== null && value !== undefined && value !== 0) {
|
||||||
|
let label = value.toLocaleString();
|
||||||
|
// Добавляем % если это проценты
|
||||||
|
if ((dataset.unit === '%') || (typeof value === 'string' && value.includes('%'))) {
|
||||||
|
label = String(value).replace('%', '') + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHorizontal = meta.indexAxis === 'y';
|
||||||
|
|
||||||
|
if (isHorizontal) {
|
||||||
|
// Для горизонтальных (Доход): рисуем справа
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(label, bar.x + 5, bar.y);
|
||||||
|
} else {
|
||||||
|
// Для вертикальных: рисуем сверху
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'bottom';
|
||||||
|
ctx.fillText(label, bar.x, bar.y - 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const competitorsBarChartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
layout: { padding: { top: 20 } },
|
||||||
|
animation: {
|
||||||
|
duration: 500,
|
||||||
|
onComplete: (animation) => drawBarValues(animation.chart)
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: { enabled: true }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: { display: true, drawBorder: false },
|
||||||
|
ticks: { display: false } // Скрываем ось Y, цифры уже на барах
|
||||||
|
},
|
||||||
|
x: { grid: { display: false } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const horizontalBarChartOptions = {
|
||||||
|
indexAxis: 'y', // <-- Делает график горизонтальным
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
layout: { padding: { right: 45 } },
|
||||||
|
animation: {
|
||||||
|
duration: 500,
|
||||||
|
onComplete: (animation) => drawBarValues(animation.chart)
|
||||||
|
},
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: { display: false, beginAtZero: true }, // Скрываем ось X
|
||||||
|
y: { grid: { display: false } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const channelsBarChartOptions = {
|
||||||
|
...competitorsBarChartOptions,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: { label: (ctx) => `${ctx.formattedValue}%` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const refreshV2 = async () => {
|
const refreshV2 = async () => {
|
||||||
if (!v2AnalysisId.value) return;
|
if (!v2AnalysisId.value) return;
|
||||||
v2Loading.value = true;
|
v2Loading.value = true;
|
||||||
@@ -1906,6 +1992,58 @@ const buildCompetitorsHeatmapData = (competitors) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- ВСТАВИТЬ СЮДА ---
|
||||||
|
|
||||||
|
// Сборка данных для ДОХОДА (Фото 2)
|
||||||
|
const buildIncomeChartData = (targetAudience) => {
|
||||||
|
// Данные могут быть в snake_case или camelCase
|
||||||
|
const incomeData = targetAudience?.income_by_age || targetAudience?.incomeByAge;
|
||||||
|
if (!incomeData || typeof incomeData !== 'object') return null;
|
||||||
|
|
||||||
|
const labels = Object.keys(incomeData);
|
||||||
|
const values = Object.values(incomeData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Средний доход (KZT)',
|
||||||
|
data: values,
|
||||||
|
backgroundColor: getMarketingColors(values.length),
|
||||||
|
borderRadius: 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildChannelsChartData = (acquisitionChannels) => {
|
||||||
|
const shares = acquisitionChannels?.channel_shares || acquisitionChannels?.channelShares;
|
||||||
|
if (!shares || typeof shares !== 'object') return null;
|
||||||
|
|
||||||
|
// Превращаем ключи "instagram_target" в "Instagram Target"
|
||||||
|
const labels = Object.keys(shares).map(key =>
|
||||||
|
key.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Чистим значения от знака % для графика
|
||||||
|
const values = Object.values(shares).map(val =>
|
||||||
|
(typeof val === 'string') ? parseFloat(val.replace('%', '')) : val
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Доля',
|
||||||
|
data: values,
|
||||||
|
unit: '%', // Маркер, чтобы рисовать %
|
||||||
|
backgroundColor: getMarketingColors(values.length, 2),
|
||||||
|
borderRadius: 6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Get heatmap color intensity based on value (0-100)
|
// Get heatmap color intensity based on value (0-100)
|
||||||
const getHeatmapColor = (value) => {
|
const getHeatmapColor = (value) => {
|
||||||
if (value >= 80) return 'rgba(16, 185, 129, 0.9)'; // green-500
|
if (value >= 80) return 'rgba(16, 185, 129, 0.9)'; // green-500
|
||||||
@@ -1973,6 +2111,124 @@ const mapChartsDataToSegments = (chartsData) => {
|
|||||||
|
|
||||||
const segments = [];
|
const segments = [];
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 1. НОВАЯ ЛОГИКА (V2 JSON - Исправления графиков)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// --- График ДОХОДА (Горизонтальный) и ВОЗРАСТА ---
|
||||||
|
if (chartsData.target_audience || chartsData.targetAudience) {
|
||||||
|
const source = chartsData.target_audience || chartsData.targetAudience;
|
||||||
|
|
||||||
|
// 1.1 Доход (Горизонтальный)
|
||||||
|
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) {
|
||||||
|
const labels = Object.keys(refusals).map(k => k.replace(/_/g, ' '));
|
||||||
|
const values = Object.values(refusals).map(v => parseFloat(String(v).replace('%','')));
|
||||||
|
segments.push({
|
||||||
|
type: 'chart',
|
||||||
|
key: 'refusalReasons',
|
||||||
|
title: 'Причины отказов',
|
||||||
|
chartType: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
data: values,
|
||||||
|
backgroundColor: '#EF4444',
|
||||||
|
unit: '%'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: competitorsBarChartOptions, // Цифры сверху
|
||||||
|
chartHeight: 'height: 300px'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 2. СТАРАЯ ЛОГИКА (Legacy Support)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
// Handle case when chartsData is an array (shouldn't happen, but handle it)
|
// Handle case when chartsData is an array (shouldn't happen, but handle it)
|
||||||
if (Array.isArray(chartsData)) {
|
if (Array.isArray(chartsData)) {
|
||||||
// If it's an array of competitors, process it
|
// If it's an array of competitors, process it
|
||||||
@@ -2006,7 +2262,6 @@ const mapChartsDataToSegments = (chartsData) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle case when chartsData is an audienceSegmentation object directly
|
// Handle case when chartsData is an audienceSegmentation object directly
|
||||||
// (has genders, ageGroups, segments, etc. but no audienceSegmentation property)
|
|
||||||
if (chartsData.genders || chartsData.ageGroups || chartsData.segments) {
|
if (chartsData.genders || chartsData.ageGroups || chartsData.segments) {
|
||||||
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData);
|
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData);
|
||||||
if (ageGroupsChartData || gendersChartData) {
|
if (ageGroupsChartData || gendersChartData) {
|
||||||
@@ -2017,8 +2272,6 @@ const mapChartsDataToSegments = (chartsData) => {
|
|||||||
segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData.segmentsChannelMatrix),
|
segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData.segmentsChannelMatrix),
|
||||||
keyTakeaways: chartsData.keyTakeaways || []
|
keyTakeaways: chartsData.keyTakeaways || []
|
||||||
});
|
});
|
||||||
// Return early to avoid processing it again as audienceSegmentation property
|
|
||||||
// But continue to check for other properties like competitors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2054,20 +2307,16 @@ const mapChartsDataToSegments = (chartsData) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Competitors Analysis
|
// 3. Competitors Analysis
|
||||||
// Check if competitors is an array (new format) or object with competitors array (old format)
|
|
||||||
if (chartsData.competitors) {
|
if (chartsData.competitors) {
|
||||||
let competitorsArray = null;
|
let competitorsArray = null;
|
||||||
|
|
||||||
if (Array.isArray(chartsData.competitors)) {
|
if (Array.isArray(chartsData.competitors)) {
|
||||||
// New format: competitors is directly an array
|
|
||||||
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)) {
|
||||||
// Old format: competitors is an object with competitors array inside
|
|
||||||
competitorsArray = chartsData.competitors.competitors;
|
competitorsArray = chartsData.competitors.competitors;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (competitorsArray && competitorsArray.length > 0) {
|
if (competitorsArray && competitorsArray.length > 0) {
|
||||||
// Build bar charts for reach and followers using flat structure
|
|
||||||
const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach');
|
const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach');
|
||||||
const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers');
|
const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers');
|
||||||
|
|
||||||
@@ -2094,7 +2343,6 @@ const mapChartsDataToSegments = (chartsData) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// SWOT Competitors (if exists in competitors object)
|
|
||||||
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',
|
||||||
@@ -2106,7 +2354,7 @@ const mapChartsDataToSegments = (chartsData) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Comparison Table (in root of chartsData)
|
// 4. Comparison Table
|
||||||
if (chartsData.comparisonTable && Array.isArray(chartsData.comparisonTable) && chartsData.comparisonTable.length > 0) {
|
if (chartsData.comparisonTable && Array.isArray(chartsData.comparisonTable) && chartsData.comparisonTable.length > 0) {
|
||||||
segments.push({
|
segments.push({
|
||||||
type: 'comparisonTable',
|
type: 'comparisonTable',
|
||||||
@@ -2114,7 +2362,7 @@ const mapChartsDataToSegments = (chartsData) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. SWOT Competitors (in root of chartsData, if exists)
|
// 5. SWOT Competitors
|
||||||
if (chartsData.swotCompetitors && typeof chartsData.swotCompetitors === 'object') {
|
if (chartsData.swotCompetitors && typeof chartsData.swotCompetitors === 'object') {
|
||||||
segments.push({
|
segments.push({
|
||||||
type: 'swotCompetitors',
|
type: 'swotCompetitors',
|
||||||
@@ -2149,7 +2397,7 @@ const mapChartsDataToSegments = (chartsData) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Conversion Funnel (supports Chart.js format too)
|
// 8. Conversion Funnel
|
||||||
if (chartsData.conversionFunnel) {
|
if (chartsData.conversionFunnel) {
|
||||||
const chartData = isChartJsData(chartsData.conversionFunnel) ? normalizeChartJsData(chartsData.conversionFunnel, 'bar') : buildConversionFunnelChartData(chartsData.conversionFunnel);
|
const chartData = isChartJsData(chartsData.conversionFunnel) ? normalizeChartJsData(chartsData.conversionFunnel, 'bar') : buildConversionFunnelChartData(chartsData.conversionFunnel);
|
||||||
if (chartData) {
|
if (chartData) {
|
||||||
@@ -2165,7 +2413,7 @@ const mapChartsDataToSegments = (chartsData) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Channels Potential (supports Chart.js format too)
|
// 9. Channels Potential
|
||||||
if (chartsData.channelsPotential) {
|
if (chartsData.channelsPotential) {
|
||||||
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) {
|
||||||
@@ -2182,7 +2430,7 @@ const mapChartsDataToSegments = (chartsData) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 11. Audience charts (Chart.js config)
|
// 11. Audience charts (Legacy)
|
||||||
if (chartsData.audienceAge) {
|
if (chartsData.audienceAge) {
|
||||||
const chartData = normalizeChartJsData(chartsData.audienceAge, 'bar');
|
const chartData = normalizeChartJsData(chartsData.audienceAge, 'bar');
|
||||||
if (chartData) {
|
if (chartData) {
|
||||||
@@ -2212,7 +2460,7 @@ const mapChartsDataToSegments = (chartsData) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. SWOT (if exists for backward compatibility)
|
// 10. SWOT
|
||||||
if (chartsData.swot && typeof chartsData.swot === 'object') {
|
if (chartsData.swot && typeof chartsData.swot === 'object') {
|
||||||
segments.push({
|
segments.push({
|
||||||
type: 'swot',
|
type: 'swot',
|
||||||
@@ -2227,7 +2475,6 @@ const mapChartsDataToSegments = (chartsData) => {
|
|||||||
|
|
||||||
return segments;
|
return segments;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapJsonBlockToSegment = (identifier, data, rawText) => {
|
const mapJsonBlockToSegment = (identifier, data, rawText) => {
|
||||||
const key = identifier?.toLowerCase();
|
const key = identifier?.toLowerCase();
|
||||||
switch (key) {
|
switch (key) {
|
||||||
|
|||||||
Reference in New Issue
Block a user