diff --git a/src/views/pages/marketing/MarketingAnalysis.vue b/src/views/pages/marketing/MarketingAnalysis.vue index c99e2a9..08f8187 100644 --- a/src/views/pages/marketing/MarketingAnalysis.vue +++ b/src/views/pages/marketing/MarketingAnalysis.vue @@ -932,91 +932,6 @@ 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; @@ -1709,109 +1624,138 @@ 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', - align: 'center' - } - }, - scales: { - y: { - beginAtZero: true, - max: 100, - ticks: { - callback: function (value) { - return value + '%'; - } - } - } - } + plugins: { legend: { display: true, position: 'bottom' } }, + scales: { y: { beginAtZero: true, ticks: { callback: (val) => val + '%' } } } }; const conversionFunnelChartOptions = { responsive: true, maintainAspectRatio: false, - plugins: { - legend: { - display: true, - position: 'bottom', - align: 'center' - } - }, - scales: { - y: { - beginAtZero: true, - title: { - display: true, - text: 'Количество пользователей' - } - } - } + plugins: { legend: { display: true, position: 'bottom' } }, + scales: { y: { beginAtZero: true, title: { display: true, text: 'Пользователи' } } } }; const channelsPotentialChartOptions = { responsive: true, maintainAspectRatio: false, - plugins: { - legend: { - display: 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: { - beginAtZero: true, - max: 100, - ticks: { - callback: function (value) { - return value; - } - } - } + y: { display: false, beginAtZero: true }, + x: { grid: { display: false } } } }; -// Marketing-friendly color palette (cycled as needed) -const MARKETING_COLORS = ['#4F46E5', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899']; +// 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}%` } } + } +}; + +// --- 2. БИЛДЕРЫ ДАННЫХ --- + +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.length ? MARKETING_COLORS : ['#3B82F6']; + const base = MARKETING_COLORS; 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: Array.isArray(seasonality.datasets) ? seasonality.datasets : [] - }; + return { labels: seasonality.labels, 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 + }] }; }; @@ -1819,162 +1763,61 @@ 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), - borderColor: '#ffffff', - borderWidth: 2, - borderRadius: 4 - } - ] + datasets: [{ + label: 'Пользователей', + data: funnel.map((item) => item.value), + backgroundColor: getMarketingColors(funnel.length), + 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), - borderColor: '#ffffff', - borderWidth: 2 - } - ] + datasets: [{ + data: ageValues, + backgroundColor: getMarketingColors(ageValues.length), + borderWidth: 2, + borderColor: '#ffffff' + }] }; - // 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; + 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; 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; @@ -1983,117 +1826,123 @@ const buildIncomeChartData = (targetAudience) => { return { labels: labels, - datasets: [ - { - label: 'Средний доход (KZT)', - data: values, - backgroundColor: getMarketingColors(values.length), - borderRadius: 4 - } - ] + datasets: [{ + label: 'Средний доход (KZT)', + data: values, + backgroundColor: getMarketingColors(values.length), + borderRadius: 4, + barPercentage: 0.7 + }] }; }; +// Билдер Каналов 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 - } - ] + 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 || {} })) }; }; -// Get heatmap color intensity based on value (0-100) const getHeatmapColor = (value) => { - 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 + 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)'; }; -// 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)); -}; +const isChartJsData = (value) => !!(value && typeof value === 'object' && !Array.isArray(value) && Array.isArray(value.labels)); -// 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: 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 - }; - }) + labels: raw.labels || [], + datasets: raw.datasets.map((ds, idx) => ({ + ...ds, + backgroundColor: ds.backgroundColor || getMarketingColors(ds.data.length, idx), + borderRadius: ds.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 : []; - if (datasets.length === 0) return false; - return datasets.some((ds) => Array.isArray(ds?.data) && ds.data.length > 0); + return datasets.length > 0 && datasets.some((ds) => Array.isArray(ds?.data) && ds.data.length > 0); }; -// Main function to map chartsData to segments +// --- 3. ГЛАВНЫЙ МАППЕР (С поддержкой V2) --- + const mapChartsDataToSegments = (chartsData) => { if (!chartsData || typeof chartsData !== 'object') return []; - const segments = []; - // ========================================== - // 1. НОВАЯ ЛОГИКА (V2 JSON - Исправления графиков) - // ========================================== - - // --- График ДОХОДА (Горизонтальный) и ВОЗРАСТА --- + // 1. Доход и Аудитория if (chartsData.target_audience || chartsData.targetAudience) { const source = chartsData.target_audience || chartsData.targetAudience; @@ -2106,12 +1955,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); @@ -2129,13 +1978,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); @@ -2143,16 +1992,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; @@ -2166,20 +2015,13 @@ 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, ' ')); @@ -2189,270 +2031,54 @@ 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' }); } } - // ========================================== - // 2. СТАРАЯ ЛОГИКА (Legacy Support) - // ========================================== - - // Handle case when chartsData is an array (shouldn't happen, but handle it) + // --- Legacy Support --- 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)) { - const reachChartData = buildCompetitorsBarChartData(chartsData, 'reach'); - const followersChartData = buildCompetitorsBarChartData(chartsData, 'followers'); - + if (chartsData.length > 0 && chartsData[0]?.name) { + const reach = buildCompetitorsBarChartData(chartsData, 'reach'); segments.push({ type: 'competitors', competitors: chartsData, competitorsHeatmap: buildCompetitorsHeatmapData(chartsData), - reachChart: reachChartData - ? { - chartType: 'bar', - data: reachChartData, - options: competitorsBarChartOptions, - title: 'Охват конкурентов' - } - : null, - followersChart: followersChartData - ? { - chartType: 'bar', - data: followersChartData, - options: competitorsBarChartOptions, - title: 'Подписчики конкурентов' - } - : null + reachChart: reach ? { chartType: 'bar', data: reach, 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 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' - }); - } + 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' }); } - - // 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.comparisonTable && Array.isArray(chartsData.comparisonTable)) { + segments.push({ type: 'comparisonTable', data: chartsData.comparisonTable }); } - - // 4. Comparison Table - if (chartsData.comparisonTable && Array.isArray(chartsData.comparisonTable) && chartsData.comparisonTable.length > 0) { - segments.push({ - type: 'comparisonTable', - data: chartsData.comparisonTable - }); + 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' }); } - - // 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 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' - }); - } + 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' }); } - - // 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.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' }); } - - // 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 || [] - } - }); + if (chartsData.swot) { + segments.push({ type: 'swot', data: chartsData.swot }); } return segments; }; + + const mapJsonBlockToSegment = (identifier, data, rawText) => { const key = identifier?.toLowerCase(); switch (key) {