From 4bd96fe87e1269c5586430117f50b534c92a5dc2 Mon Sep 17 00:00:00 2001 From: Administrator Date: Tue, 20 Jan 2026 17:43:44 +0000 Subject: [PATCH] Revert "Update MarketingAnalysis.vue" This reverts commit d4ae88cc95ef776374098c762e29cbc3ba6cfcb1 --- .../pages/marketing/MarketingAnalysis.vue | 862 +++++++++++++----- 1 file changed, 618 insertions(+), 244 deletions(-) diff --git a/src/views/pages/marketing/MarketingAnalysis.vue b/src/views/pages/marketing/MarketingAnalysis.vue index 08f8187..c99e2a9 100644 --- a/src/views/pages/marketing/MarketingAnalysis.vue +++ b/src/views/pages/marketing/MarketingAnalysis.vue @@ -932,6 +932,91 @@ 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 () => { if (!v2AnalysisId.value) return; @@ -1624,138 +1709,109 @@ const pushMarkdownTokens = (segments, markdownChunk) => { }); } }; -// --- 1. ОПЦИИ ГРАФИКОВ И РИСОВАЛКА ЦИФР --- - -const drawBarValues = (chart) => { - const ctx = chart.ctx; - ctx.font = 'bold 12px "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('%', '') + '%'; - } else if (dataset.label && dataset.label.includes('KZT')) { - label += ' ₸'; - } - - const isHorizontal = meta.indexAxis === 'y'; - - if (isHorizontal) { - // Горизонтальный (Доход): цифры справа - ctx.textAlign = 'left'; - ctx.textBaseline = 'middle'; - ctx.fillText(label, bar.x + 8, bar.y); - } else { - // Вертикальный: цифры сверху - ctx.textAlign = 'center'; - ctx.textBaseline = 'bottom'; - ctx.fillText(label, bar.x, bar.y - 5); - } - } - }); - } - }); -}; const seasonalityChartOptions = { responsive: true, maintainAspectRatio: false, - plugins: { legend: { display: true, position: 'bottom' } }, - scales: { y: { beginAtZero: true, ticks: { callback: (val) => val + '%' } } } + plugins: { + legend: { + display: true, + position: 'bottom', + align: 'center' + } + }, + scales: { + y: { + beginAtZero: true, + max: 100, + ticks: { + callback: function (value) { + return value + '%'; + } + } + } + } }; const conversionFunnelChartOptions = { responsive: true, maintainAspectRatio: false, - plugins: { legend: { display: true, position: 'bottom' } }, - scales: { y: { beginAtZero: true, title: { display: true, text: 'Пользователи' } } } + plugins: { + legend: { + display: true, + position: 'bottom', + align: 'center' + } + }, + scales: { + y: { + beginAtZero: true, + title: { + display: true, + text: 'Количество пользователей' + } + } + } }; const channelsPotentialChartOptions = { responsive: true, maintainAspectRatio: false, - plugins: { legend: { display: false } }, - scales: { y: { beginAtZero: true, max: 100 } } -}; - -// 1. Вертикальные (Причины отказов, Факторы) -const competitorsBarChartOptions = { - responsive: true, - maintainAspectRatio: false, - layout: { padding: { top: 25 } }, - animation: { - duration: 500, - onComplete: (animation) => drawBarValues(animation.chart) - }, - plugins: { legend: { display: false }, tooltip: { enabled: true } }, - scales: { - y: { display: false, beginAtZero: true }, - x: { grid: { display: false } } - } -}; - -// 2. Горизонтальные (Доход) -const horizontalBarChartOptions = { - indexAxis: 'y', - responsive: true, - maintainAspectRatio: false, - layout: { padding: { right: 60 } }, - animation: { - duration: 500, - onComplete: (animation) => drawBarValues(animation.chart) - }, - plugins: { legend: { display: false } }, - scales: { - x: { display: false, beginAtZero: true }, - y: { grid: { display: false } } - } -}; - -// 3. Каналы (цветные) -const channelsBarChartOptions = { - ...competitorsBarChartOptions, plugins: { - legend: { display: false }, - tooltip: { callbacks: { label: (ctx) => `${ctx.formattedValue}%` } } + legend: { + display: false + } + }, + scales: { + y: { + beginAtZero: true, + max: 100, + ticks: { + callback: function (value) { + return value; + } + } + } } }; -// --- 2. БИЛДЕРЫ ДАННЫХ --- - +// Marketing-friendly color palette (cycled as needed) const MARKETING_COLORS = ['#4F46E5', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899']; + const getMarketingColors = (count, offset = 0) => { const safeCount = Math.max(0, Number(count) || 0); if (safeCount === 0) return []; - const base = MARKETING_COLORS; + const base = MARKETING_COLORS.length ? MARKETING_COLORS : ['#3B82F6']; return Array.from({ length: safeCount }, (_, i) => base[(i + offset) % base.length]); }; const buildSeasonalityChartData = (seasonality) => { if (!seasonality || typeof seasonality !== 'object') return null; + + // New backend format: Chart.js data shape { labels: [...], datasets: [...] } if (isChartJsData(seasonality)) return normalizeChartJsData(seasonality, 'line'); + + // Chart.js-like (labels exist) but datasets missing/empty -> return safe shape so UI can show placeholder if (Array.isArray(seasonality.labels)) { - return { labels: seasonality.labels, datasets: seasonality.datasets || [] }; + return { + labels: seasonality.labels, + datasets: Array.isArray(seasonality.datasets) ? seasonality.datasets : [] + }; } + + // Legacy format: object map { "Январь": 80, ... } return { labels: Object.keys(seasonality), - datasets: [{ - label: 'Индекс спроса', - data: Object.values(seasonality), - borderColor: '#3B82F6', - backgroundColor: 'rgba(59, 130, 246, 0.2)', - fill: true, - tension: 0.4 - }] + datasets: [ + { + label: 'Индекс спроса', + data: Object.values(seasonality), + borderColor: '#3B82F6', + backgroundColor: 'rgba(59, 130, 246, 0.2)', + fill: true, + tension: 0.4 + } + ] }; }; @@ -1763,61 +1819,162 @@ const buildConversionFunnelChartData = (funnel) => { if (!Array.isArray(funnel) || funnel.length === 0) return null; return { labels: funnel.map((item) => item.stage), - datasets: [{ - label: 'Пользователей', - data: funnel.map((item) => item.value), - backgroundColor: getMarketingColors(funnel.length), - borderRadius: 4 - }] + datasets: [ + { + label: 'Пользователей', + data: funnel.map((item) => item.value), + backgroundColor: getMarketingColors(funnel.length), + borderColor: '#ffffff', + borderWidth: 2, + borderRadius: 4 + } + ] }; }; const buildChannelsPotentialChartData = (channels) => { if (!Array.isArray(channels) || channels.length === 0) return null; + return { labels: channels.map((ch) => ch.name), - datasets: [{ - label: 'Потенциал', - data: channels.map((ch) => ch.potential), - backgroundColor: (ctx) => { - const val = ctx.raw; - return val >= 80 ? '#10B981' : val >= 50 ? '#F59E0B' : '#EF4444'; - }, - borderRadius: 4 - }] + datasets: [ + { + label: 'Потенциал канала', + data: channels.map((ch) => ch.potential), + backgroundColor: (ctx) => { + const val = ctx.raw; + return val >= 80 ? '#10B981' : val >= 50 ? '#F59E0B' : '#EF4444'; + }, + borderRadius: 4 + } + ] }; }; const buildAudienceSegmentationChartData = (segmentation) => { if (!segmentation || !segmentation.ageGroups) return { ageGroupsChartData: null, gendersChartData: null }; - + + // Build chart data for age groups (primary chart) const ageLabels = segmentation.ageGroups.map((i) => i.label); const ageValues = segmentation.ageGroups.map((i) => parseFloat(i.value)); + const ageGroupsChartData = { labels: ageLabels, - datasets: [{ - data: ageValues, - backgroundColor: getMarketingColors(ageValues.length), - borderWidth: 2, - borderColor: '#ffffff' - }] + datasets: [ + { + data: ageValues, + backgroundColor: getMarketingColors(ageValues.length), + borderColor: '#ffffff', + borderWidth: 2 + } + ] }; - const gendersChartData = Array.isArray(segmentation.genders) && segmentation.genders.length > 0 ? { - labels: segmentation.genders.map((g) => g?.label || ''), - datasets: [{ - data: segmentation.genders.map((g) => (g?.value != null ? parseFloat(g.value) : 0)), - backgroundColor: getMarketingColors(segmentation.genders.length), - borderWidth: 2, - borderColor: '#ffffff' - }] - } : null; + // Keep genders chart for backward compatibility if needed + const gendersChartData = + Array.isArray(segmentation.genders) && segmentation.genders.length > 0 + ? { + labels: segmentation.genders.map((g) => g?.label || ''), + datasets: [ + { + data: segmentation.genders.map((g) => { + const value = g?.value; + return value != null ? parseFloat(value) : 0; + }), + backgroundColor: getMarketingColors(segmentation.genders.length), + borderColor: '#ffffff', + borderWidth: 2 + } + ] + } + : null; return { ageGroupsChartData, gendersChartData }; }; -// Билдер Дохода +// Build segments channel matrix data for heatmap +const buildSegmentsChannelMatrix = (matrix) => { + if (!Array.isArray(matrix) || matrix.length === 0) return null; + + // Extract all unique channels from matrix + const channels = new Set(); + matrix.forEach((row) => { + Object.keys(row).forEach((key) => { + if (key !== 'segmentName') { + channels.add(key); + } + }); + }); + + return { + segments: matrix.map((row) => row.segmentName), + channels: Array.from(channels), + data: matrix + }; +}; + +// Build market share chart data +const buildMarketShareChartData = (marketShareChart) => { + if (!Array.isArray(marketShareChart) || marketShareChart.length === 0) return null; + return { + labels: marketShareChart.map((item) => item.name), + datasets: [ + { + data: marketShareChart.map((item) => item.value), + backgroundColor: getMarketingColors(marketShareChart.length), + borderColor: '#ffffff', + borderWidth: 2 + } + ] + }; +}; + +// Build competitors bar chart data for reach and followers +const buildCompetitorsBarChartData = (competitors, metricType = 'reach') => { + if (!Array.isArray(competitors) || competitors.length === 0) return null; + + return { + labels: competitors.map((comp) => comp.name), + datasets: [ + { + label: metricType === 'reach' ? 'Охват аудитории' : 'Подписчики', + // Check nested structure digitalMetrics! + data: competitors.map((comp) => (comp.digitalMetrics ? comp.digitalMetrics[metricType] || 0 : comp[metricType] || 0)), + backgroundColor: getMarketingColors(competitors.length, metricType === 'reach' ? 0 : 1), + borderColor: '#ffffff', + borderWidth: 2, + borderRadius: 4 + } + ] + }; +}; + +// Build competitors heatmap data +const buildCompetitorsHeatmapData = (competitors) => { + if (!Array.isArray(competitors) || competitors.length === 0) return null; + + const channels = new Set(); + competitors.forEach((comp) => { + if (comp.channelsHeatmap && typeof comp.channelsHeatmap === 'object') { + Object.keys(comp.channelsHeatmap).forEach((channel) => channels.add(channel)); + } + }); + + return { + competitors: competitors.map((comp) => comp.name), + channels: Array.from(channels), + data: competitors.map((comp) => ({ + name: comp.name, + channelsHeatmap: comp.channelsHeatmap || {} + })) + }; +}; + +// --- ВСТАВИТЬ СЮДА --- + +// Сборка данных для ДОХОДА (Фото 2) const buildIncomeChartData = (targetAudience) => { + // Данные могут быть в snake_case или camelCase const incomeData = targetAudience?.income_by_age || targetAudience?.incomeByAge; if (!incomeData || typeof incomeData !== 'object') return null; @@ -1826,123 +1983,117 @@ const buildIncomeChartData = (targetAudience) => { return { labels: labels, - datasets: [{ - label: 'Средний доход (KZT)', - data: values, - backgroundColor: getMarketingColors(values.length), - borderRadius: 4, - barPercentage: 0.7 - }] + 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 - }] - }; -}; - -const buildSegmentsChannelMatrix = (matrix) => { - if (!Array.isArray(matrix) || matrix.length === 0) return null; - const channels = new Set(); - matrix.forEach((row) => { - Object.keys(row).forEach((key) => { if (key !== 'segmentName') channels.add(key); }); - }); - return { segments: matrix.map((row) => row.segmentName), channels: Array.from(channels), data: matrix }; -}; - -const buildMarketShareChartData = (marketShareChart) => { - if (!Array.isArray(marketShareChart) || marketShareChart.length === 0) return null; - return { - labels: marketShareChart.map((item) => item.name), - datasets: [{ - data: marketShareChart.map((item) => item.value), - backgroundColor: getMarketingColors(marketShareChart.length), - borderWidth: 2, - borderColor: '#ffffff' - }] - }; -}; - -const buildCompetitorsBarChartData = (competitors, metricType = 'reach') => { - if (!Array.isArray(competitors) || competitors.length === 0) return null; - return { - labels: competitors.map((comp) => comp.name), - datasets: [{ - label: metricType === 'reach' ? 'Охват' : 'Подписчики', - data: competitors.map((comp) => (comp.digitalMetrics ? comp.digitalMetrics[metricType] || 0 : comp[metricType] || 0)), - backgroundColor: getMarketingColors(competitors.length, metricType === 'reach' ? 0 : 1), - borderRadius: 4 - }] - }; -}; - -const buildCompetitorsHeatmapData = (competitors) => { - if (!Array.isArray(competitors) || competitors.length === 0) return null; - const channels = new Set(); - competitors.forEach((comp) => { - if (comp.channelsHeatmap) Object.keys(comp.channelsHeatmap).forEach((c) => channels.add(c)); - }); - return { - competitors: competitors.map((comp) => comp.name), - channels: Array.from(channels), - data: competitors.map((comp) => ({ name: comp.name, channelsHeatmap: comp.channelsHeatmap || {} })) + datasets: [ + { + label: 'Доля', + data: values, + unit: '%', // Маркер, чтобы рисовать % + backgroundColor: getMarketingColors(values.length, 2), + borderRadius: 6 + } + ] }; }; +// Get heatmap color intensity based on value (0-100) const getHeatmapColor = (value) => { - if (value >= 80) return 'rgba(16, 185, 129, 0.9)'; - if (value >= 60) return 'rgba(245, 158, 11, 0.9)'; - if (value >= 40) return 'rgba(251, 191, 36, 0.9)'; - if (value >= 20) return 'rgba(249, 115, 22, 0.9)'; - return 'rgba(239, 68, 68, 0.9)'; + if (value >= 80) return 'rgba(16, 185, 129, 0.9)'; // green-500 + if (value >= 60) return 'rgba(245, 158, 11, 0.9)'; // amber-500 + if (value >= 40) return 'rgba(251, 191, 36, 0.9)'; // yellow-500 + if (value >= 20) return 'rgba(249, 115, 22, 0.9)'; // orange-500 + return 'rgba(239, 68, 68, 0.9)'; // red-500 }; -const isChartJsData = (value) => !!(value && typeof value === 'object' && !Array.isArray(value) && Array.isArray(value.labels)); +// Helper: detect Chart.js "data" shape (labels + datasets) +const isChartJsData = (value) => { + return !!(value && typeof value === 'object' && !Array.isArray(value) && Array.isArray(value.labels) && Array.isArray(value.datasets)); +}; +// Helper: normalize Chart.js datasets to have sane colors +// - For pie/doughnut/bar we default to per-item colors (arrays) + white borders +// - If backgroundColor is already an array or function, we keep it (custom colors) const normalizeChartJsData = (raw, chartType) => { if (!isChartJsData(raw)) return null; + const type = String(chartType || raw.chartType || '').toLowerCase(); + const wantsPerItemColors = type === 'pie' || type === 'doughnut' || type === 'bar'; + return { - labels: raw.labels || [], - datasets: raw.datasets.map((ds, idx) => ({ - ...ds, - backgroundColor: ds.backgroundColor || getMarketingColors(ds.data.length, idx), - borderRadius: ds.borderRadius ?? 4 - })) + labels: Array.isArray(raw.labels) ? raw.labels : [], + datasets: raw.datasets.map((dataset, idx) => { + const data = Array.isArray(dataset?.data) ? dataset.data : []; + + const hasCustomBgArray = Array.isArray(dataset?.backgroundColor); + const hasCustomBgFn = typeof dataset?.backgroundColor === 'function'; + + const backgroundColor = wantsPerItemColors && !hasCustomBgArray && !hasCustomBgFn ? getMarketingColors(data.length, idx) : dataset?.backgroundColor; + + // Keep existing borderColor if provided; otherwise apply white borders for separation on partitioned charts + const borderColor = dataset?.borderColor ?? (wantsPerItemColors ? '#ffffff' : undefined); + const borderWidth = dataset?.borderWidth ?? (wantsPerItemColors ? 2 : undefined); + + // For non-partitioned charts, keep old behavior: if colors are missing, use a reasonable fallback + const fallbackColor = getMarketingColors(1, idx)[0]; + const resolvedBg = typeof backgroundColor === 'undefined' ? dataset?.borderColor || fallbackColor : backgroundColor; + const resolvedBorder = typeof borderColor === 'undefined' ? dataset?.borderColor || resolvedBg : borderColor; + + return { + ...dataset, + data, + backgroundColor: resolvedBg, + borderColor: resolvedBorder, + ...(typeof borderWidth !== 'undefined' ? { borderWidth } : {}), + borderRadius: dataset.borderRadius ?? 4 + }; + }) }; }; +// Helper: check if chart has any renderable data (avoid crashing when datasets is missing/empty) const hasRenderableChartData = (chartData) => { if (!chartData || typeof chartData !== 'object') return false; const datasets = Array.isArray(chartData.datasets) ? chartData.datasets : []; - return datasets.length > 0 && datasets.some((ds) => Array.isArray(ds?.data) && ds.data.length > 0); + if (datasets.length === 0) return false; + return datasets.some((ds) => Array.isArray(ds?.data) && ds.data.length > 0); }; -// --- 3. ГЛАВНЫЙ МАППЕР (С поддержкой V2) --- - +// Main function to map chartsData to segments const mapChartsDataToSegments = (chartsData) => { if (!chartsData || typeof chartsData !== 'object') return []; + const segments = []; - // 1. Доход и Аудитория + // ========================================== + // 1. НОВАЯ ЛОГИКА (V2 JSON - Исправления графиков) + // ========================================== + + // --- График ДОХОДА (Горизонтальный) и ВОЗРАСТА --- if (chartsData.target_audience || chartsData.targetAudience) { const source = chartsData.target_audience || chartsData.targetAudience; @@ -1955,12 +2106,12 @@ const mapChartsDataToSegments = (chartsData) => { title: 'Средний доход по возрастным группам', chartType: 'bar', data: incomeChart, - options: horizontalBarChartOptions, // Горизонтальный + options: horizontalBarChartOptions, // <-- Горизонтальный вид chartHeight: 'height: 350px' }); } - // 1.2 Возрастная структура (Вертикальный + цифры) + // 1.2 Возрастная структура (Цифры на барах) const ageStruct = source.age_structure || source.ageStructure; if (ageStruct) { const labels = Object.keys(ageStruct); @@ -1978,13 +2129,13 @@ const mapChartsDataToSegments = (chartsData) => { unit: '%' }] }, - options: competitorsBarChartOptions, + options: competitorsBarChartOptions, // Цифры сверху chartHeight: 'height: 300px' }); } } - // 2. Каналы (Динамические) + // --- График КАНАЛОВ (Динамический) --- if (chartsData.acquisition_channels || chartsData.acquisitionChannels) { const source = chartsData.acquisition_channels || chartsData.acquisitionChannels; const channelsChart = buildChannelsChartData(source); @@ -1992,16 +2143,16 @@ const mapChartsDataToSegments = (chartsData) => { segments.push({ type: 'chart', key: 'acquisitionChannels', - title: 'Эффективность каналов привлечения', + title: 'Каналы привлечения', chartType: 'bar', data: channelsChart, - options: channelsBarChartOptions, + options: channelsBarChartOptions, // Вертикальные с % chartHeight: 'height: 350px' }); } } - // 3. Факторы и Отказы + // --- Факторы принятия решений и Причины отказов --- if (chartsData.demand_and_behavior || chartsData.demandAndBehavior) { const source = chartsData.demand_and_behavior || chartsData.demandAndBehavior; @@ -2015,13 +2166,20 @@ const mapChartsDataToSegments = (chartsData) => { key: 'decisionFactors', title: 'Факторы принятия решения', chartType: 'bar', - data: { labels, datasets: [{ data: values, backgroundColor: '#8B5CF6', unit: '%' }] }, - options: competitorsBarChartOptions, + 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, ' ')); @@ -2031,54 +2189,270 @@ const mapChartsDataToSegments = (chartsData) => { key: 'refusalReasons', title: 'Причины отказов', chartType: 'bar', - data: { labels, datasets: [{ data: values, backgroundColor: '#EF4444', unit: '%' }] }, - options: competitorsBarChartOptions, + data: { + labels, + datasets: [{ + data: values, + backgroundColor: '#EF4444', + unit: '%' + }] + }, + options: competitorsBarChartOptions, // Цифры сверху chartHeight: 'height: 300px' }); } } - // --- Legacy Support --- + // ========================================== + // 2. СТАРАЯ ЛОГИКА (Legacy Support) + // ========================================== + + // Handle case when chartsData is an array (shouldn't happen, but handle it) if (Array.isArray(chartsData)) { - if (chartsData.length > 0 && chartsData[0]?.name) { - const reach = buildCompetitorsBarChartData(chartsData, 'reach'); + // 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)) { + const reachChartData = buildCompetitorsBarChartData(chartsData, 'reach'); + const followersChartData = buildCompetitorsBarChartData(chartsData, 'followers'); + segments.push({ type: 'competitors', competitors: chartsData, competitorsHeatmap: buildCompetitorsHeatmapData(chartsData), - reachChart: reach ? { chartType: 'bar', data: reach, options: competitorsBarChartOptions, title: 'Охват' } : null + reachChart: reachChartData + ? { + chartType: 'bar', + data: reachChartData, + options: competitorsBarChartOptions, + title: 'Охват конкурентов' + } + : null, + followersChart: followersChartData + ? { + chartType: 'bar', + data: followersChartData, + options: competitorsBarChartOptions, + title: 'Подписчики конкурентов' + } + : null }); } return segments; } - + + // Handle case when chartsData is an audienceSegmentation object directly + if (chartsData.genders || chartsData.ageGroups || chartsData.segments) { + const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData); + if (ageGroupsChartData || gendersChartData) { + segments.push({ + type: 'audienceSegmentation', + ageGroupsChartData, + gendersChartData, + segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData.segmentsChannelMatrix), + keyTakeaways: chartsData.keyTakeaways || [] + }); + } + } + + // 1. Audience Segmentation (nested property) + if (chartsData.audienceSegmentation) { + const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData.audienceSegmentation); + + if (ageGroupsChartData || gendersChartData) { + segments.push({ + type: 'audienceSegmentation', + ageGroupsChartData, + gendersChartData, + segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData.audienceSegmentation.segmentsChannelMatrix), + keyTakeaways: chartsData.audienceSegmentation.keyTakeaways || [] + }); + } + } + + // 2. Market Share Chart (in root of chartsData) if (chartsData.marketShareChart) { - const mData = Array.isArray(chartsData.marketShareChart) ? buildMarketShareChartData(chartsData.marketShareChart) : normalizeChartJsData(chartsData.marketShareChart, 'pie'); - if (mData) segments.push({ type: 'marketShareChart', title: 'Доли рынка', chartType: 'pie', data: mData, options: pieChartOptions, chartHeight: 'height: 300px' }); + const marketShareChartData = Array.isArray(chartsData.marketShareChart) ? buildMarketShareChartData(chartsData.marketShareChart) : normalizeChartJsData(chartsData.marketShareChart, 'pie'); + + if (marketShareChartData) { + segments.push({ + type: 'marketShareChart', + title: 'Доли рынка', + chartType: 'pie', + data: marketShareChartData, + options: pieChartOptions, + chartHeight: 'height: 300px' + }); + } } - if (chartsData.comparisonTable && Array.isArray(chartsData.comparisonTable)) { - segments.push({ type: 'comparisonTable', data: chartsData.comparisonTable }); + + // 3. Competitors Analysis + if (chartsData.competitors) { + let competitorsArray = null; + + if (Array.isArray(chartsData.competitors)) { + competitorsArray = chartsData.competitors; + } else if (chartsData.competitors.competitors && Array.isArray(chartsData.competitors.competitors)) { + competitorsArray = chartsData.competitors.competitors; + } + + if (competitorsArray && competitorsArray.length > 0) { + const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach'); + const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers'); + + segments.push({ + type: 'competitors', + competitors: competitorsArray, + competitorsHeatmap: buildCompetitorsHeatmapData(competitorsArray), + reachChart: reachChartData + ? { + 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') { + segments.push({ + type: 'swotCompetitors', + data: { + strengths: chartsData.competitors.swotCompetitors.strengths || [], + weaknesses: chartsData.competitors.swotCompetitors.weaknesses || [] + } + }); + } } - if (chartsData.seasonality) { - const sData = buildSeasonalityChartData(chartsData.seasonality); - if (sData) segments.push({ type: 'chart', key: 'seasonality', title: 'Сезонность', chartType: 'line', data: sData, options: seasonalityChartOptions, chartHeight: 'height: 300px' }); + + // 4. Comparison Table + if (chartsData.comparisonTable && Array.isArray(chartsData.comparisonTable) && chartsData.comparisonTable.length > 0) { + segments.push({ + type: 'comparisonTable', + data: chartsData.comparisonTable + }); } + + // 5. SWOT Competitors + if (chartsData.swotCompetitors && typeof chartsData.swotCompetitors === 'object') { + segments.push({ + type: 'swotCompetitors', + data: { + strengths: chartsData.swotCompetitors.strengths || [], + weaknesses: chartsData.swotCompetitors.weaknesses || [] + } + }); + } + + // 6. Seasonality + if (chartsData.seasonality && typeof chartsData.seasonality === 'object') { + const chartData = buildSeasonalityChartData(chartsData.seasonality); + if (chartData) { + segments.push({ + type: 'chart', + key: 'seasonality', + title: 'Сезонность спроса', + chartType: 'line', + data: chartData, + options: seasonalityChartOptions, + chartHeight: 'height: 300px' + }); + } + } + + // 7. Market + if (chartsData.market && typeof chartsData.market === 'object') { + segments.push({ + type: 'market', + data: chartsData.market + }); + } + + // 8. Conversion Funnel if (chartsData.conversionFunnel) { - const fData = isChartJsData(chartsData.conversionFunnel) ? normalizeChartJsData(chartsData.conversionFunnel, 'bar') : buildConversionFunnelChartData(chartsData.conversionFunnel); - if (fData) segments.push({ type: 'chart', key: 'funnel', title: 'Воронка', chartType: 'bar', data: fData, options: conversionFunnelChartOptions, chartHeight: 'height: 400px' }); + const chartData = isChartJsData(chartsData.conversionFunnel) ? normalizeChartJsData(chartsData.conversionFunnel, 'bar') : buildConversionFunnelChartData(chartsData.conversionFunnel); + if (chartData) { + segments.push({ + type: 'chart', + key: 'funnel', + title: 'Воронка конверсии', + chartType: 'bar', + data: chartData, + options: conversionFunnelChartOptions, + chartHeight: 'height: 400px' + }); + } } - if (chartsData.channelsPotential && !chartsData.acquisition_channels) { - const cData = isChartJsData(chartsData.channelsPotential) ? normalizeChartJsData(chartsData.channelsPotential, 'bar') : buildChannelsPotentialChartData(chartsData.channelsPotential); - if (cData) segments.push({ type: 'chart', key: 'channelsPotential', title: 'Потенциал каналов', chartType: 'bar', data: cData, options: channelsPotentialChartOptions, chartHeight: 'height: 300px' }); + + // 9. Channels Potential + if (chartsData.channelsPotential) { + const chartData = isChartJsData(chartsData.channelsPotential) ? normalizeChartJsData(chartsData.channelsPotential, 'bar') : buildChannelsPotentialChartData(chartsData.channelsPotential); + if (chartData) { + segments.push({ + type: 'chart', + key: 'channelsPotential', + title: 'Потенциал каналов продвижения', + chartType: 'bar', + data: chartData, + options: channelsPotentialChartOptions, + chartHeight: 'height: 300px', + channels: Array.isArray(chartsData.channelsPotential) ? chartsData.channelsPotential : [] + }); + } } - if (chartsData.swot) { - segments.push({ type: 'swot', data: chartsData.swot }); + + // 11. Audience charts (Legacy) + if (chartsData.audienceAge) { + const chartData = normalizeChartJsData(chartsData.audienceAge, 'bar'); + if (chartData) { + segments.push({ + type: 'chart', + key: 'audienceAge', + title: 'Распределение по возрасту', + chartType: 'bar', + data: chartData, + options: competitorsBarChartOptions, + chartHeight: 'height: 350px' + }); + } + } + if (chartsData.audienceGender) { + const chartData = normalizeChartJsData(chartsData.audienceGender, 'pie'); + if (chartData) { + segments.push({ + type: 'chart', + key: 'audienceGender', + title: 'Распределение по полу', + chartType: 'pie', + data: chartData, + options: pieChartOptions, + chartHeight: 'height: 350px' + }); + } + } + + // 10. SWOT + if (chartsData.swot && typeof chartsData.swot === 'object') { + segments.push({ + type: 'swot', + data: { + strengths: chartsData.swot.strengths || [], + weaknesses: chartsData.swot.weaknesses || [], + opportunities: chartsData.swot.opportunities || [], + threats: chartsData.swot.threats || [] + } + }); } return segments; }; - - const mapJsonBlockToSegment = (identifier, data, rawText) => { const key = identifier?.toLowerCase(); switch (key) {