diff --git a/docs/frontend-chart-rendering.md b/docs/frontend-chart-rendering.md new file mode 100644 index 0000000..8893ed2 --- /dev/null +++ b/docs/frontend-chart-rendering.md @@ -0,0 +1,124 @@ +## Документация для фронтенда: как отображать графики из маркетингового анализа + +Сервис возвращает данные для графиков в двух местах: + +- **`report.chartsData`** (объект-словарь ключ → данные графика/таблицы) +- **`report.fullAnalysis`** (markdown-текст), где данные вставлены **инлайн** как fenced-блоки: + - формат: ```json: … ``` + +Фронтенд может рендерить графики либо **по `chartsData` (проще)**, либо **инлайн** — парся `fullAnalysis` и заменяя ` ```json:` на React-компоненты. + +--- + +### Ключи `json:`, которые нужно поддержать + +#### Chart.js config (рендерить через Chart.js / react-chartjs-2) +- **`seasonality`**: сезонность (Chart.js config) +- **`audienceAge`**: возрастное распределение (Chart.js config) +- **`audienceGender`**: гендерное распределение (Chart.js config) +- **`marketShareChart`**: доли рынка (Chart.js config) +- **`channelsPotential`**: потенциал каналов (Chart.js config) +- **`funnel`**: воронка (Chart.js config) + Примечание: в `chartsData` ключ может быть `conversionFunnel`, но в тексте `fullAnalysis` блок идёт как `json:funnel`. + +#### Таблица (рендерить табличным компонентом) +- **`comparisonTable`**: сравнительная таблица конкурентов (обычно `Array`) + +--- + +### Формат данных для графиков (Chart.js config) + +Ваш пример — это **валидный Chart.js config** (минимально нужные поля: `labels`, `datasets[]`): + +```json +{ + "datasets": [ + { + "data": [25, 30, 20, 25], + "label": "Возрастные группы" + } + ], + "labels": ["18-24", "25-34", "35-44", "45+"] +} +``` + +Рендеринг (пример для Bar): +```ts + +``` + +--- + +### Инлайн-рендеринг из `fullAnalysis` (React + react-markdown) + +Идея: перехватить `code`-блоки, найти `json:`, распарсить JSON и заменить на компонент. + +```tsx +import React from "react"; +import ReactMarkdown from "react-markdown"; + +function parseInfoString(className?: string) { + // react-markdown обычно кладёт info string как className вида: + // "language-json:audienceAge" или "language-json:marketShareChart" + const m = /language-([^ ]+)/.exec(className || ""); + if (!m) return null; + const raw = m[1]; // "json:audienceAge" + const idx = raw.indexOf(":"); + if (idx === -1) return { lang: raw, key: null }; + return { lang: raw.slice(0, idx), key: raw.slice(idx + 1) }; +} + +export function ReportMarkdown({ markdown }: { markdown: string }) { + return ( + {children}; + + const info = parseInfoString(className); + if (!info || info.lang !== "json" || !info.key) { + return
{children}
; + } + + const raw = String(children).replace(/\n$/, ""); + let data: any; + try { + data = JSON.parse(raw); + } catch { + return
{children}
; + } + + switch (info.key) { + case "seasonality": + return ; + case "audienceAge": + return ; + case "audienceGender": + return ; + case "marketShareChart": + return ; + case "channelsPotential": + return ; + case "funnel": + return ; + case "comparisonTable": + return ; + default: + return
{children}
; + } + }, + }} + > + {markdown} +
+ ); +} +``` + +--- + +### Рекомендации по устойчивости + +- **JSON.parse**: всегда `trim`/убирайте trailing newline (`replace(/\n$/, "")`). +- **Fallback**: для неизвестных ключей оставляйте `
` (чтобы ничего не “ломалось”).
+- **Таблицы**: `comparisonTable` лучше рендерить как таблицу, а не Chart.js.
diff --git a/src/views/pages/marketing/MarketingAnalysis.vue b/src/views/pages/marketing/MarketingAnalysis.vue
index 3b5bd9b..ceb982f 100644
--- a/src/views/pages/marketing/MarketingAnalysis.vue
+++ b/src/views/pages/marketing/MarketingAnalysis.vue
@@ -194,130 +194,317 @@
                     
                 
 
-                
-                
-
-
-
-
-
-
+ +
+
+
+
+ +
+
+
+
-
-
-

{{ segment.title }}

-
-
-
- -
-
-
-
- {{ channel.name }} - +
+
+
+

{{ segment.media.title }}

+
+
+
+ +
+
+
+
+
+ {{ channel.name }} + +
+

{{ channel.justification }}

+
+
+
+ +
+
+

{{ segment.media.title }}

+
+
+
+ +
+
+
+ +
+
+

Сравнительная таблица

+
+
+ + + + + + + + + + + + + + + + + +
ХарактеристикаМыКонкурент AКонкурент B
{{ row.feature }} + + + + + +
+
-

{{ channel.justification }}

-
-
-
-
-

Сегментация целевой аудитории

-
-
-
-
+
+
+
+ +
+
+

{{ segment.title }}

+
+
+ +
+
+
+
+ {{ channel.name }} + +
+

{{ channel.justification }}

+
+
+
+ +
+
+

Сегментация целевой аудитории

+
+
+
+
+ +

По возрасту

+
+
+ +
+
+
+
+ +

По полу

+
+
+ +
+
+
+ + +
- -

По возрасту

+ +

Матрица каналов по сегментам

-
- +
+ + + + + + + + + + + + + +
Сегмент + {{ channel }} +
{{ row.segmentName }} + + {{ row[channel] || '-' }} + +
-
+ + +
- -

По полу

+ +

Ключевые выводы

-
- +
+
    +
  • + + {{ takeaway }} +
  • +
- -
-
- -

Матрица каналов по сегментам

+
+
+

Анализ конкурентов

-
- - - - - - - - - - - - - -
Сегмент - {{ channel }} -
{{ row.segmentName }} - - {{ row[channel] || '-' }} - -
+ +
+
+ +

{{ segment.marketShareChart.title }}

+
+
+ +
+
+ + +
+
+ +

Сравнительная таблица

+
+
+ + + + + + + + + + + + + + + + + +
ХарактеристикаМыКонкурент AКонкурент B
{{ row.feature }} + + + + + +
+
+
+
+
+
+

{{ competitor.name }}

+
+
+ Охват: + {{ competitor.reach?.toLocaleString() || 0 }} +
+
+ Подписчики: + {{ competitor.followers?.toLocaleString() || 0 }} +
+
+ Активность: + {{ competitor.activity }}% +
+
+ Ценовая стратегия: + +
+
+
+

Сильные стороны:

+
    +
  • + + {{ strength }} +
  • +
+
+
+

Слабые стороны:

+
    +
  • + + {{ weakness }} +
  • +
+
+
+
+
+ + +
+
+ +

Тепловая карта каналов конкурентов

+
+
+ + + + + + + + + + + + + +
Конкурент + {{ channel }} +
{{ compData.name }} + + {{ compData.channelsHeatmap[channel] || 0 }} + +
+
- -
-
- -

Ключевые выводы

+ +
+
+

{{ segment.title }}

-
-
    -
  • - - {{ takeaway }} -
  • -
-
-
-
-
- -
-
-

Анализ конкурентов

-
-
- -
-
- -

{{ segment.marketShareChart.title }}

-
-
- +
+
- -
-
- -

Сравнительная таблица

+ +
+
+

Сравнительная таблица

@@ -330,7 +517,7 @@ - +
{{ row.feature }} @@ -346,44 +533,29 @@
-
-
-
-

{{ competitor.name }}

-
-
- Охват: - {{ competitor.reach?.toLocaleString() || 0 }} -
-
- Подписчики: - {{ competitor.followers?.toLocaleString() || 0 }} -
-
- Активность: - {{ competitor.activity }}% -
-
- Ценовая стратегия: - -
-
-
-

Сильные стороны:

+ + +
+
+

SWOT-анализ конкурентов

+
+
+
+
+

Сильные стороны

    -
  • +
  • {{ strength }}
-
-

Слабые стороны:

+
+
+
+

Слабые стороны

    -
  • +
  • {{ weakness }}
  • @@ -393,228 +565,116 @@
- -
-
- -

Тепловая карта каналов конкурентов

+ +
+
+

Анализ рынка

-
- - - - - - - - - - - - - -
Конкурент - {{ channel }} -
{{ compData.name }} - - {{ compData.channelsHeatmap[channel] || 0 }} - -
+
+
+
+
+ +

Размер рынка

+
+

{{ segment.data.size || 'Не указано' }}

+
+
+
+
+
+ +

Темп роста

+
+

{{ segment.data.growthRate || 'Не указано' }}

+
+
-
-
-
- -
-
-

{{ segment.title }}

-
-
-
- -
-
-
- - -
-
-

Сравнительная таблица

-
-
-
- - - - - - - - - - - - - - - - - -
ХарактеристикаМыКонкурент AКонкурент B
{{ row.feature }} - - - - - -
-
-
-
- - -
-
-

SWOT-анализ конкурентов

-
-
-
-
-
-

Сильные стороны

+
+
+ +

Тренды

+
+
    -
  • - - {{ strength }} +
  • + + {{ trend }}
-
-
-

Слабые стороны

-
    -
  • - - {{ weakness }} -
  • -
-
-
-
-
-
- -
-
-

Анализ рынка

-
-
-
-
-
-
- -

Размер рынка

+
+
+
+

Возможности

+
    +
  • + {{ opportunity }} +
  • +
-

{{ segment.data.size || 'Не указано' }}

-
-
-
-
- -

Темп роста

+
+
+

Угрозы

+
    +
  • {{ threat }}
  • +
-

{{ segment.data.growthRate || 'Не указано' }}

-
-
- -

Тренды

+
+
+

SWOT-анализ

-
-
    -
  • - - {{ trend }} -
  • -
-
-
- -
-
-
-

Возможности

-
    -
  • {{ opportunity }}
  • -
+
+
+
+

Сильные стороны

+
    +
  • {{ strength }}
  • +
+
-
-
-
-

Угрозы

-
    -
  • {{ threat }}
  • -
+
+
+

Слабые стороны

+
    +
  • {{ weakness }}
  • +
+
+
+
+
+

Возможности

+
    +
  • + {{ opportunity }} +
  • +
+
+
+
+
+

Угрозы

+
    +
  • {{ threat }}
  • +
+
-
-
- -
-
-

SWOT-анализ

-
-
-
-
-
-

Сильные стороны

-
    -
  • {{ strength }}
  • -
-
-
-
-
-

Слабые стороны

-
    -
  • {{ weakness }}
  • -
-
-
-
-
-

Возможности

-
    -
  • {{ opportunity }}
  • -
-
-
-
-
-

Угрозы

-
    -
  • {{ threat }}
  • -
-
-
+ +
+

Неизвестный тип сегмента: {{ segment.type }}

- -
-
-

Неизвестный тип сегмента: {{ segment.type }}

-
-
@@ -1041,6 +1101,256 @@ const resetAnalysis = () => { }; // Render markdown to HTML +// Helper function to extract JSON from rendered HTML (after markdown rendering) +const extractJsonFromRenderedHtml = (html) => { + if (!html || typeof html !== 'string') { + return { htmlSegments: [html], chartSegments: [] }; + } + + const chartSegments = []; + const htmlSegments = []; + let processedHtml = html; + const processedMatches = new Set(); + + // Find all
 blocks that might contain JSON
+    const codeBlockRegex = /]*>]*>([\s\S]*?)<\/code><\/pre>/gi;
+    let match;
+
+    while ((match = codeBlockRegex.exec(html)) !== null) {
+        const [fullMatch, codeContent] = match;
+        if (processedMatches.has(fullMatch)) continue;
+
+        // Try to extract and parse JSON from code content
+        const jsonSegment = tryParseJsonSegment(codeContent);
+        if (jsonSegment) {
+            processedMatches.add(fullMatch);
+            chartSegments.push(jsonSegment);
+            // Remove the code block from HTML
+            processedHtml = processedHtml.replace(fullMatch, '');
+        }
+    }
+
+    // Also check for standalone  blocks
+    const standaloneCodeRegex = /]*>([\s\S]*?)<\/code>/gi;
+    while ((match = standaloneCodeRegex.exec(processedHtml)) !== null) {
+        const [fullMatch, codeContent] = match;
+        if (processedMatches.has(fullMatch)) continue;
+
+        const jsonSegment = tryParseJsonSegment(codeContent);
+        if (jsonSegment) {
+            processedMatches.add(fullMatch);
+            chartSegments.push(jsonSegment);
+            processedHtml = processedHtml.replace(fullMatch, '');
+        }
+    }
+
+    // Also try to find JSON in plain text (not in code blocks)
+    // Look for JSON objects/arrays in the remaining HTML text
+    const findJsonInHtmlText = (text) => {
+        const results = [];
+        let depth = 0;
+        let start = -1;
+        let inString = false;
+        let stringChar = '';
+        let inTag = false;
+
+        for (let i = 0; i < text.length; i++) {
+            const char = text[i];
+            const prevChar = i > 0 ? text[i - 1] : '';
+
+            // Skip HTML tags
+            if (char === '<') inTag = true;
+            if (char === '>') inTag = false;
+            if (inTag) continue;
+
+            // Handle string escaping
+            if (prevChar === '\\') continue;
+
+            // Handle string boundaries
+            if ((char === '"' || char === "'") && !inString) {
+                inString = true;
+                stringChar = char;
+            } else if (char === stringChar && inString) {
+                inString = false;
+                stringChar = '';
+            }
+
+            if (inString) continue;
+
+            // Track bracket depth
+            if (char === '[' || char === '{') {
+                if (depth === 0) {
+                    start = i;
+                }
+                depth++;
+            } else if (char === ']' || char === '}') {
+                depth--;
+                if (depth === 0 && start !== -1) {
+                    const jsonText = text.substring(start, i + 1);
+                    const jsonSegment = tryParseJsonSegment(jsonText);
+                    if (jsonSegment) {
+                        results.push({ text: jsonText, index: start, segment: jsonSegment });
+                    }
+                    start = -1;
+                }
+            }
+        }
+
+        return results;
+    };
+
+    const jsonMatches = findJsonInHtmlText(processedHtml);
+    // Process matches from end to start to preserve indices
+    jsonMatches.reverse().forEach(({ text, segment }) => {
+        if (!processedMatches.has(text)) {
+            processedMatches.add(text);
+            chartSegments.push(segment);
+            // Remove JSON from HTML
+            const index = processedHtml.indexOf(text);
+            if (index !== -1) {
+                processedHtml = processedHtml.substring(0, index) + processedHtml.substring(index + text.length);
+            }
+        }
+    });
+
+    // Split remaining HTML by removed code blocks and add as segments
+    if (processedHtml.trim()) {
+        // Split by multiple newlines to create separate segments
+        const parts = processedHtml.split(/\n\s*\n/).filter((p) => p.trim());
+        htmlSegments.push(...parts);
+    }
+
+    return { htmlSegments, chartSegments };
+};
+
+// Helper function to extract JSON from markdown before rendering
+const extractJsonFromMarkdown = (markdown) => {
+    if (!markdown || typeof markdown !== 'string') {
+        return { processedText: markdown, jsonSegments: [] };
+    }
+
+    const jsonSegments = [];
+    let processedText = markdown;
+    const processedMatches = new Set();
+
+    // Find all code blocks that might contain JSON
+    // Match ```json, ```json:identifier, or plain ``` blocks
+    const codeBlockRegex = /```(?:json(?::(\w+))?)?\n([\s\S]*?)\n```/g;
+    let match;
+
+    while ((match = codeBlockRegex.exec(markdown)) !== null) {
+        const [fullMatch, identifier, codeContent] = match;
+        const jsonSegment = tryParseJsonSegment(codeContent);
+
+        if (jsonSegment && !processedMatches.has(fullMatch)) {
+            processedMatches.add(fullMatch);
+            jsonSegments.push(jsonSegment);
+            // Replace the code block with empty string (we'll add the segment separately)
+            processedText = processedText.replace(fullMatch, '');
+        }
+    }
+
+    // Also check for JSON objects/arrays that are not in code blocks
+    // Look for JSON at the end of text or standalone
+    // Use a more sophisticated approach: find balanced brackets
+    const findJsonInText = (text) => {
+        const results = [];
+        let depth = 0;
+        let start = -1;
+        let inString = false;
+        let stringChar = '';
+
+        for (let i = 0; i < text.length; i++) {
+            const char = text[i];
+            const prevChar = i > 0 ? text[i - 1] : '';
+
+            // Handle string escaping
+            if (prevChar === '\\') continue;
+
+            // Handle string boundaries
+            if ((char === '"' || char === "'") && !inString) {
+                inString = true;
+                stringChar = char;
+            } else if (char === stringChar && inString) {
+                inString = false;
+                stringChar = '';
+            }
+
+            if (inString) continue;
+
+            // Track bracket depth
+            if (char === '[' || char === '{') {
+                if (depth === 0) {
+                    start = i;
+                }
+                depth++;
+            } else if (char === ']' || char === '}') {
+                depth--;
+                if (depth === 0 && start !== -1) {
+                    const jsonText = text.substring(start, i + 1);
+                    const jsonSegment = tryParseJsonSegment(jsonText);
+                    if (jsonSegment) {
+                        results.push({ text: jsonText, index: start, segment: jsonSegment });
+                    }
+                    start = -1;
+                }
+            }
+        }
+
+        return results;
+    };
+
+    const jsonMatches = findJsonInText(processedText);
+
+    // Process matches from end to start to preserve indices
+    jsonMatches.reverse().forEach(({ text, segment }) => {
+        if (!processedMatches.has(text)) {
+            processedMatches.add(text);
+            jsonSegments.push(segment);
+            // Use a more precise replacement to avoid partial matches
+            processedText = processedText.substring(0, processedText.indexOf(text)) + processedText.substring(processedText.indexOf(text) + text.length);
+        }
+    });
+
+    // Also check if the entire remaining text is JSON (common case when JSON is at the end)
+    // This handles cases where JSON is at the very end of the text
+    const finalTrimmed = processedText.trim();
+    if (finalTrimmed && (finalTrimmed.startsWith('[') || finalTrimmed.startsWith('{'))) {
+        const finalJsonSegment = tryParseJsonSegment(finalTrimmed);
+        if (finalJsonSegment && !processedMatches.has(finalTrimmed)) {
+            jsonSegments.push(finalJsonSegment);
+            processedText = '';
+        }
+    }
+
+    // Also check for JSON that might be at the end but after some whitespace/newlines
+    // Look for JSON patterns at the end of the text (after removing trailing whitespace)
+    const lines = processedText.split('\n');
+    let jsonStartLine = -1;
+    for (let i = lines.length - 1; i >= 0; i--) {
+        const line = lines[i].trim();
+        if (line.startsWith('[') || line.startsWith('{')) {
+            jsonStartLine = i;
+            break;
+        }
+        if (line && !line.match(/^[\[\]{}:,\s"']+$/)) {
+            // Not a JSON line, stop searching
+            break;
+        }
+    }
+
+    if (jsonStartLine >= 0) {
+        const jsonText = lines.slice(jsonStartLine).join('\n').trim();
+        const finalJsonSegment = tryParseJsonSegment(jsonText);
+        if (finalJsonSegment && !processedMatches.has(jsonText)) {
+            jsonSegments.push(finalJsonSegment);
+            processedText = lines.slice(0, jsonStartLine).join('\n').trim();
+        }
+    }
+
+    return { processedText: processedText.trim(), jsonSegments };
+};
+
 const renderMarkdown = (markdown) => {
     if (!markdown || typeof markdown !== 'string') {
         return '';
@@ -1089,27 +1399,9 @@ const conversionFunnelChartOptions = {
     scales: {
         y: {
             beginAtZero: true,
-            position: 'left',
             title: {
                 display: true,
-                text: 'Значение'
-            }
-        },
-        y1: {
-            beginAtZero: true,
-            max: 100,
-            position: 'right',
-            title: {
-                display: true,
-                text: 'Конверсия (%)'
-            },
-            grid: {
-                drawOnChartArea: false
-            },
-            ticks: {
-                callback: function (value) {
-                    return value + '%';
-                }
+                text: 'Количество пользователей'
             }
         }
     }
@@ -1136,22 +1428,39 @@ const channelsPotentialChartOptions = {
     }
 };
 
+const competitorsBarChartOptions = {
+    responsive: true,
+    maintainAspectRatio: false,
+    plugins: {
+        legend: {
+            display: true,
+            position: 'top'
+        }
+    },
+    scales: {
+        y: {
+            beginAtZero: true,
+            ticks: {
+                callback: function (value) {
+                    return value.toLocaleString();
+                }
+            }
+        }
+    }
+};
+
 const buildSeasonalityChartData = (seasonality) => {
     if (!seasonality || typeof seasonality !== 'object') return null;
-    const months = Object.keys(seasonality);
-    const values = Object.values(seasonality);
-    if (!months.length) return null;
-
     return {
-        labels: months,
+        labels: Object.keys(seasonality),
         datasets: [
             {
-                label: 'Спрос (%)',
-                data: values,
+                label: 'Индекс спроса',
+                data: Object.values(seasonality),
                 borderColor: '#3B82F6',
-                backgroundColor: 'rgba(59, 130, 246, 0.1)',
-                tension: 0.4,
-                fill: true
+                backgroundColor: 'rgba(59, 130, 246, 0.2)',
+                fill: true,
+                tension: 0.4
             }
         ]
     };
@@ -1163,19 +1472,10 @@ const buildConversionFunnelChartData = (funnel) => {
         labels: funnel.map((item) => item.stage),
         datasets: [
             {
-                label: 'Значение',
+                label: 'Пользователей',
                 data: funnel.map((item) => item.value),
                 backgroundColor: '#3B82F6',
-                borderColor: '#2563EB',
-                borderWidth: 1
-            },
-            {
-                label: 'Конверсия (%)',
-                data: funnel.map((item) => item.conversion),
-                backgroundColor: '#10B981',
-                borderColor: '#059669',
-                borderWidth: 1,
-                yAxisID: 'y1'
+                borderRadius: 4
             }
         ]
     };
@@ -1183,57 +1483,54 @@ const buildConversionFunnelChartData = (funnel) => {
 
 const buildChannelsPotentialChartData = (channels) => {
     if (!Array.isArray(channels) || channels.length === 0) return null;
+
     return {
         labels: channels.map((ch) => ch.name),
         datasets: [
             {
-                label: 'Потенциал',
+                label: 'Потенциал канала',
                 data: channels.map((ch) => ch.potential),
-                backgroundColor: channels.map((ch) => {
-                    if (ch.potential >= 80) return '#10B981';
-                    if (ch.potential >= 60) return '#F59E0B';
-                    return '#3B82F6';
-                }),
-                borderColor: channels.map((ch) => {
-                    if (ch.potential >= 80) return '#059669';
-                    if (ch.potential >= 60) return '#D97706';
-                    return '#2563EB';
-                }),
-                borderWidth: 1
+                backgroundColor: (ctx) => {
+                    const val = ctx.raw;
+                    return val >= 80 ? '#10B981' : val >= 50 ? '#F59E0B' : '#EF4444';
+                },
+                borderRadius: 4
             }
         ]
     };
 };
 
 const buildAudienceSegmentationChartData = (segmentation) => {
-    if (!segmentation) return { ageGroupsChartData: null, gendersChartData: null };
-    const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];
+    if (!segmentation || !segmentation.ageGroups) return { ageGroupsChartData: null, gendersChartData: null };
 
-    const ageGroupsChartData =
-        Array.isArray(segmentation.ageGroups) && segmentation.ageGroups.length > 0
-            ? {
-                  labels: segmentation.ageGroups.map((ag) => ag.label),
-                  datasets: [
-                      {
-                          data: segmentation.ageGroups.map((ag) => parseFloat(ag.value)),
-                          backgroundColor: segmentation.ageGroups.map((_, idx) => colors[idx % colors.length]),
-                          borderColor: '#fff',
-                          borderWidth: 2
-                      }
-                  ]
-              }
-            : 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: ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'],
+                borderWidth: 0
+            }
+        ]
+    };
+
+    // 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),
+                  labels: segmentation.genders.map((g) => g?.label || ''),
                   datasets: [
                       {
-                          data: segmentation.genders.map((g) => parseFloat(g.value)),
+                          data: segmentation.genders.map((g) => {
+                              const value = g?.value;
+                              return value != null ? parseFloat(value) : 0;
+                          }),
                           backgroundColor: segmentation.genders.map((_, idx) => ['#EC4899', '#3B82F6'][idx % 2]),
-                          borderColor: '#fff',
-                          borderWidth: 2
+                          borderWidth: 0
                       }
                   ]
               }
@@ -1281,6 +1578,24 @@ const buildMarketShareChartData = (marketShareChart) => {
     };
 };
 
+// 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: metricType === 'reach' ? '#6366F1' : '#3B82F6', // Purple for reach, blue for followers
+                borderRadius: 4
+            }
+        ]
+    };
+};
+
 // Build competitors heatmap data
 const buildCompetitorsHeatmapData = (competitors) => {
     if (!Array.isArray(competitors) || competitors.length === 0) return null;
@@ -1321,6 +1636,31 @@ const getChannelMatrixColor = (value) => {
     return 'rgba(156, 163, 175, 0.5)'; // gray
 };
 
+// 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
+const normalizeChartJsData = (raw) => {
+    if (!isChartJsData(raw)) return null;
+    const colors = ['#6366F1', '#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4'];
+
+    return {
+        labels: raw.labels,
+        datasets: raw.datasets.map((dataset, idx) => {
+            const fallback = colors[idx % colors.length];
+            const bg = dataset.backgroundColor || dataset.borderColor || fallback;
+            return {
+                ...dataset,
+                backgroundColor: dataset.backgroundColor || bg,
+                borderColor: dataset.borderColor || bg,
+                borderRadius: dataset.borderRadius ?? 4
+            };
+        })
+    };
+};
+
 // Main function to map chartsData to segments
 const mapChartsDataToSegments = (chartsData) => {
     if (!chartsData || typeof chartsData !== 'object') return [];
@@ -1330,17 +1670,53 @@ const mapChartsDataToSegments = (chartsData) => {
     // Handle case when chartsData is an array (shouldn't happen, but handle it)
     if (Array.isArray(chartsData)) {
         // If it's an array of competitors, process it
-        if (chartsData.length > 0 && chartsData[0]?.name && chartsData[0]?.channelsHeatmap) {
+        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)
+                competitorsHeatmap: buildCompetitorsHeatmapData(chartsData),
+                reachChart: reachChartData
+                    ? {
+                          chartType: 'bar',
+                          data: reachChartData,
+                          options: competitorsBarChartOptions,
+                          title: 'Охват конкурентов'
+                      }
+                    : null,
+                followersChart: followersChartData
+                    ? {
+                          chartType: 'bar',
+                          data: followersChartData,
+                          options: competitorsBarChartOptions,
+                          title: 'Подписчики конкурентов'
+                      }
+                    : null
             });
         }
         return segments;
     }
 
-    // 1. Audience Segmentation
+    // 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) {
+        const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData);
+        if (ageGroupsChartData || gendersChartData) {
+            segments.push({
+                type: 'audienceSegmentation',
+                ageGroupsChartData,
+                gendersChartData,
+                segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData.segmentsChannelMatrix),
+                keyTakeaways: chartsData.keyTakeaways || []
+            });
+            // Return early to avoid processing it again as audienceSegmentation property
+            // But continue to check for other properties like competitors
+        }
+    }
+
+    // 1. Audience Segmentation (nested property)
     if (chartsData.audienceSegmentation) {
         const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData.audienceSegmentation);
 
@@ -1356,8 +1732,9 @@ const mapChartsDataToSegments = (chartsData) => {
     }
 
     // 2. Market Share Chart (in root of chartsData)
-    if (chartsData.marketShareChart && Array.isArray(chartsData.marketShareChart)) {
-        const marketShareChartData = buildMarketShareChartData(chartsData.marketShareChart);
+    if (chartsData.marketShareChart) {
+        const marketShareChartData = Array.isArray(chartsData.marketShareChart) ? buildMarketShareChartData(chartsData.marketShareChart) : normalizeChartJsData(chartsData.marketShareChart);
+
         if (marketShareChartData) {
             segments.push({
                 type: 'marketShareChart',
@@ -1384,10 +1761,30 @@ const mapChartsDataToSegments = (chartsData) => {
         }
 
         if (competitorsArray && competitorsArray.length > 0) {
+            // Build bar charts for reach and followers using flat structure
+            const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach');
+            const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers');
+
             segments.push({
                 type: 'competitors',
                 competitors: competitorsArray,
-                competitorsHeatmap: buildCompetitorsHeatmapData(competitorsArray)
+                competitorsHeatmap: buildCompetitorsHeatmapData(competitorsArray),
+                reachChart: reachChartData
+                    ? {
+                          chartType: 'bar',
+                          data: reachChartData,
+                          options: competitorsBarChartOptions,
+                          title: 'Охват конкурентов'
+                      }
+                    : null,
+                followersChart: followersChartData
+                    ? {
+                          chartType: 'bar',
+                          data: followersChartData,
+                          options: competitorsBarChartOptions,
+                          title: 'Подписчики конкурентов'
+                      }
+                    : null
             });
         }
 
@@ -1479,6 +1876,36 @@ const mapChartsDataToSegments = (chartsData) => {
         }
     }
 
+    // 11. Audience charts (Chart.js config)
+    if (chartsData.audienceAge) {
+        const chartData = normalizeChartJsData(chartsData.audienceAge);
+        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);
+        if (chartData) {
+            segments.push({
+                type: 'chart',
+                key: 'audienceGender',
+                title: 'Распределение по полу',
+                chartType: 'pie',
+                data: chartData,
+                options: pieChartOptions,
+                chartHeight: 'height: 350px'
+            });
+        }
+    }
+
     // 10. SWOT (if exists for backward compatibility)
     if (chartsData.swot && typeof chartsData.swot === 'object') {
         segments.push({
@@ -1498,8 +1925,34 @@ const mapChartsDataToSegments = (chartsData) => {
 const mapJsonBlockToSegment = (identifier, data, rawText) => {
     const key = identifier?.toLowerCase();
     switch (key) {
+        case 'audienceage': {
+            const chartData = normalizeChartJsData(data);
+            if (!chartData) return null;
+            return {
+                type: 'chart',
+                key: 'audienceAge',
+                title: 'Распределение по возрасту',
+                chartType: 'bar',
+                data: chartData,
+                options: competitorsBarChartOptions,
+                chartHeight: 'height: 350px'
+            };
+        }
+        case 'audiencegender': {
+            const chartData = normalizeChartJsData(data);
+            if (!chartData) return null;
+            return {
+                type: 'chart',
+                key: 'audienceGender',
+                title: 'Распределение по полу',
+                chartType: 'pie',
+                data: chartData,
+                options: pieChartOptions,
+                chartHeight: 'height: 350px'
+            };
+        }
         case 'seasonality': {
-            const chartData = buildSeasonalityChartData(data);
+            const chartData = isChartJsData(data) ? normalizeChartJsData(data) : buildSeasonalityChartData(data);
             if (!chartData) return null;
             return {
                 type: 'chart',
@@ -1513,7 +1966,7 @@ const mapJsonBlockToSegment = (identifier, data, rawText) => {
         }
         case 'funnel':
         case 'conversionfunnel': {
-            const chartData = buildConversionFunnelChartData(data);
+            const chartData = isChartJsData(data) ? normalizeChartJsData(data) : buildConversionFunnelChartData(data);
             if (!chartData) return null;
             return {
                 type: 'chart',
@@ -1526,7 +1979,7 @@ const mapJsonBlockToSegment = (identifier, data, rawText) => {
             };
         }
         case 'channelspotential': {
-            const chartData = buildChannelsPotentialChartData(data);
+            const chartData = isChartJsData(data) ? normalizeChartJsData(data) : buildChannelsPotentialChartData(data);
             if (!chartData) return null;
             return {
                 type: 'chart',
@@ -1539,6 +1992,25 @@ const mapJsonBlockToSegment = (identifier, data, rawText) => {
                 channels: Array.isArray(data) ? data : []
             };
         }
+        case 'marketsharechart': {
+            const chartData = isChartJsData(data) ? normalizeChartJsData(data) : null;
+            if (!chartData) return null;
+            return {
+                type: 'marketShareChart',
+                title: 'Доли рынка',
+                chartType: 'pie',
+                data: chartData,
+                options: pieChartOptions,
+                chartHeight: 'height: 300px'
+            };
+        }
+        case 'comparisontable': {
+            if (!Array.isArray(data) || data.length === 0) return null;
+            return {
+                type: 'comparisonTable',
+                data
+            };
+        }
         case 'audiencesegmentation': {
             const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(data || {});
             if (!ageGroupsChartData && !gendersChartData) return null;
@@ -1550,9 +2022,29 @@ const mapJsonBlockToSegment = (identifier, data, rawText) => {
         }
         case 'competitors': {
             if (!Array.isArray(data) || data.length === 0) return null;
+            const reachChartData = buildCompetitorsBarChartData(data, 'reach');
+            const followersChartData = buildCompetitorsBarChartData(data, 'followers');
+
             return {
                 type: 'competitors',
-                competitors: data
+                competitors: data,
+                competitorsHeatmap: buildCompetitorsHeatmapData(data),
+                reachChart: reachChartData
+                    ? {
+                          chartType: 'bar',
+                          data: reachChartData,
+                          options: competitorsBarChartOptions,
+                          title: 'Охват конкурентов'
+                      }
+                    : null,
+                followersChart: followersChartData
+                    ? {
+                          chartType: 'bar',
+                          data: followersChartData,
+                          options: competitorsBarChartOptions,
+                          title: 'Подписчики конкурентов'
+                      }
+                    : null
             };
         }
         case 'swot': {
@@ -1600,10 +2092,29 @@ const getChartSegmentByPlaceholder = (placeholder, chartsData) => {
             const competitorsArray = Array.isArray(chartsData.competitors) ? chartsData.competitors : chartsData.competitors?.competitors || [];
 
             if (competitorsArray.length > 0) {
+                const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach');
+                const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers');
+
                 const segment = {
                     type: 'competitors',
                     competitors: competitorsArray,
-                    competitorsHeatmap: buildCompetitorsHeatmapData(competitorsArray)
+                    competitorsHeatmap: buildCompetitorsHeatmapData(competitorsArray),
+                    reachChart: reachChartData
+                        ? {
+                              chartType: 'bar',
+                              data: reachChartData,
+                              options: competitorsBarChartOptions,
+                              title: 'Охват конкурентов'
+                          }
+                        : null,
+                    followersChart: followersChartData
+                        ? {
+                              chartType: 'bar',
+                              data: followersChartData,
+                              options: competitorsBarChartOptions,
+                              title: 'Подписчики конкурентов'
+                          }
+                        : null
                 };
 
                 // Add marketShareChart if exists
@@ -1704,6 +2215,32 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => {
     const key = identifier?.toLowerCase();
 
     switch (key) {
+        case 'audienceage': {
+            const chartData = normalizeChartJsData(chartsData.audienceAge);
+            if (!chartData) return null;
+            return {
+                type: 'chart',
+                key: 'audienceAge',
+                title: 'Распределение по возрасту',
+                chartType: 'bar',
+                data: chartData,
+                options: competitorsBarChartOptions,
+                chartHeight: 'height: 350px'
+            };
+        }
+        case 'audiencegender': {
+            const chartData = normalizeChartJsData(chartsData.audienceGender);
+            if (!chartData) return null;
+            return {
+                type: 'chart',
+                key: 'audienceGender',
+                title: 'Распределение по полу',
+                chartType: 'pie',
+                data: chartData,
+                options: pieChartOptions,
+                chartHeight: 'height: 350px'
+            };
+        }
         case 'audiencesegmentation':
             if (chartsData.audienceSegmentation) {
                 const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData.audienceSegmentation);
@@ -1722,10 +2259,29 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => {
         case 'competitors':
             const competitorsArray = Array.isArray(chartsData.competitors) ? chartsData.competitors : chartsData.competitors?.competitors || [];
             if (competitorsArray.length > 0) {
+                const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach');
+                const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers');
+
                 const segment = {
                     type: 'competitors',
                     competitors: competitorsArray,
-                    competitorsHeatmap: buildCompetitorsHeatmapData(competitorsArray)
+                    competitorsHeatmap: buildCompetitorsHeatmapData(competitorsArray),
+                    reachChart: reachChartData
+                        ? {
+                              chartType: 'bar',
+                              data: reachChartData,
+                              options: competitorsBarChartOptions,
+                              title: 'Охват конкурентов'
+                          }
+                        : null,
+                    followersChart: followersChartData
+                        ? {
+                              chartType: 'bar',
+                              data: followersChartData,
+                              options: competitorsBarChartOptions,
+                              title: 'Подписчики конкурентов'
+                          }
+                        : null
                 };
 
                 // Add marketShareChart if exists
@@ -1780,6 +2336,27 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => {
             }
             return null;
 
+        case 'marketsharechart': {
+            const marketShareChartData = Array.isArray(chartsData.marketShareChart) ? buildMarketShareChartData(chartsData.marketShareChart) : normalizeChartJsData(chartsData.marketShareChart);
+            if (!marketShareChartData) return null;
+            return {
+                type: 'marketShareChart',
+                title: 'Доли рынка',
+                chartType: 'pie',
+                data: marketShareChartData,
+                options: pieChartOptions,
+                chartHeight: 'height: 300px'
+            };
+        }
+        case 'comparisontable': {
+            if (Array.isArray(chartsData.comparisonTable) && chartsData.comparisonTable.length > 0) {
+                return {
+                    type: 'comparisonTable',
+                    data: chartsData.comparisonTable
+                };
+            }
+            return null;
+        }
         case 'channelspotential':
         case 'channels':
             if (chartsData.channelsPotential && Array.isArray(chartsData.channelsPotential)) {
@@ -1839,27 +2416,177 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => {
     }
 };
 
+// Helper function to check if text is a JSON object that should be processed
+const tryParseJsonSegment = (text) => {
+    if (!text || typeof text !== 'string') return null;
+
+    let trimmed = text.trim();
+
+    // Remove HTML tags if present (from rendered markdown code blocks)
+    if (trimmed.includes('
') || trimmed.includes('') || trimmed.includes('<')) {
+        // Try to extract JSON from HTML code blocks - handle nested tags
+        let codeContent = trimmed;
+        // Remove all HTML tags recursively
+        while (codeContent.includes('<') && codeContent.includes('>')) {
+            codeContent = codeContent.replace(/<[^>]+>/g, '');
+        }
+        // Decode common HTML entities
+        codeContent = codeContent
+            .replace(/"/g, '"')
+            .replace(/'/g, "'")
+            .replace(/</g, '<')
+            .replace(/>/g, '>')
+            .replace(/&/g, '&');
+        trimmed = codeContent.trim();
+    }
+
+    // Also try to extract JSON if it's wrapped in HTML but not in code tags
+    // Look for JSON-like patterns in the text
+    if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
+        // Try to find JSON object/array in the text
+        const jsonMatch = trimmed.match(/\{[\s\S]*\}|\[[\s\S]*\]/);
+        if (jsonMatch) {
+            trimmed = jsonMatch[0].trim();
+        }
+    }
+
+    // Check if it's a JSON object or array
+    if ((!trimmed.startsWith('{') || !trimmed.endsWith('}')) && (!trimmed.startsWith('[') || !trimmed.endsWith(']'))) {
+        return null;
+    }
+
+    try {
+        const parsedJson = JSON.parse(trimmed);
+
+        // Check if it's an array of competitors
+        if (Array.isArray(parsedJson) && parsedJson.length > 0) {
+            // Check if it looks like competitors data
+            if (parsedJson[0]?.name && (parsedJson[0]?.channelsHeatmap || parsedJson[0]?.reach || parsedJson[0]?.followers)) {
+                return {
+                    type: 'competitors',
+                    competitors: parsedJson,
+                    competitorsHeatmap: buildCompetitorsHeatmapData(parsedJson)
+                };
+            }
+        }
+
+        // Check if it's Chart.js format (datasets and labels)
+        if (parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson)) {
+            if (parsedJson.datasets && Array.isArray(parsedJson.datasets) && parsedJson.labels && Array.isArray(parsedJson.labels)) {
+                // Prepare chart data with proper formatting
+                const chartData = {
+                    labels: parsedJson.labels,
+                    datasets: parsedJson.datasets.map((dataset, index) => {
+                        // Add default colors if not provided
+                        const colors = ['#6366F1', '#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'];
+                        const color = dataset.backgroundColor || dataset.borderColor || colors[index % colors.length];
+
+                        return {
+                            ...dataset,
+                            backgroundColor: dataset.backgroundColor || color,
+                            borderColor: dataset.borderColor || color,
+                            borderRadius: dataset.borderRadius || 4
+                        };
+                    })
+                };
+
+                // Determine chart type based on dataset type or default to bar
+                const chartType = parsedJson.chartType || 'bar';
+
+                // Use appropriate options based on chart type
+                const options = chartType === 'pie' ? pieChartOptions : competitorsBarChartOptions;
+
+                // Get title from label or use default
+                const title = parsedJson.datasets[0]?.label || 'График';
+
+                return {
+                    type: 'chart',
+                    chartType: chartType,
+                    data: chartData,
+                    options: options,
+                    title: title,
+                    chartHeight: 'height: 400px'
+                };
+            }
+        }
+
+        // Check if it's an audienceSegmentation object
+        if (parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson)) {
+            if (parsedJson.genders || parsedJson.ageGroups || parsedJson.segments) {
+                const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(parsedJson);
+                if (ageGroupsChartData || gendersChartData) {
+                    return {
+                        type: 'audienceSegmentation',
+                        ageGroupsChartData,
+                        gendersChartData,
+                        segmentsChannelMatrix: buildSegmentsChannelMatrix(parsedJson.segmentsChannelMatrix),
+                        keyTakeaways: parsedJson.keyTakeaways || []
+                    };
+                }
+            }
+        }
+    } catch (e) {
+        // Not valid JSON
+        console.debug('Failed to parse JSON segment:', e);
+    }
+    return null;
+};
+
 // Parse fullAnalysis and replace placeholders and JSON blocks with charts from chartsData
 const parseFullAnalysisWithCharts = (fullAnalysis, chartsData) => {
     if (!fullAnalysis) return [];
 
     const segments = [];
-    // Combined regex for both placeholders [[CHART_*]] and JSON blocks ```json:identifier
-    const combinedRegex = /(\[\[CHART_(\w+)\]\]|```json:(\w+)\n([\s\S]*?)\n```)/g;
+    // Combined regex for both placeholders [[CHART_*]], JSON blocks ```json:identifier, and plain ```json blocks
+    const combinedRegex = /(\[\[CHART_(\w+)\]\]|```json:(\w+)\n([\s\S]*?)\n```|```json\n([\s\S]*?)\n```|```\n([\s\S]*?)\n```)/g;
     let lastIndex = 0;
     let match;
 
     while ((match = combinedRegex.exec(fullAnalysis)) !== null) {
-        const [fullMatch, , chartTypePlaceholder, jsonIdentifier, jsonBlock] = match;
+        const [fullMatch, , chartTypePlaceholder, jsonIdentifier, jsonBlockWithId, jsonBlockPlain, codeBlock] = match;
 
         // Add markdown before match
         if (match.index > lastIndex) {
             const textBefore = fullAnalysis.slice(lastIndex, match.index);
             if (textBefore.trim()) {
-                segments.push({ type: 'markdown', html: renderMarkdown(textBefore) });
+                // Extract JSON from markdown before rendering
+                const { processedText, jsonSegments: extractedSegments } = extractJsonFromMarkdown(textBefore);
+
+                // Add extracted JSON segments
+                extractedSegments.forEach((seg) => segments.push(seg));
+
+                // Add remaining markdown (without JSON blocks)
+                if (processedText.trim() && !processedText.match(/^__JSON_SEGMENT_\d+__$/)) {
+                    const renderedHtml = renderMarkdown(processedText);
+                    // Process rendered HTML to extract JSON from code blocks
+                    const { htmlSegments, chartSegments } = extractJsonFromRenderedHtml(renderedHtml);
+                    // Add chart segments first
+                    chartSegments.forEach((seg) => segments.push(seg));
+                    // Add remaining HTML
+                    if (htmlSegments.length > 0) {
+                        htmlSegments.forEach((html) => segments.push({ type: 'markdown', html }));
+                    } else if (renderedHtml.trim()) {
+                        segments.push({ type: 'markdown', html: renderedHtml });
+                    }
+                }
             }
         }
 
+        // Handle code blocks that might contain JSON
+        if (codeBlock || jsonBlockPlain) {
+            const jsonContent = codeBlock || jsonBlockPlain;
+            // Try to parse as JSON and process
+            const jsonSegment = tryParseJsonSegment(jsonContent);
+            if (jsonSegment) {
+                segments.push(jsonSegment);
+            } else {
+                // If not JSON, add as markdown code block
+                segments.push({ type: 'markdown', html: renderMarkdown(fullMatch) });
+            }
+            lastIndex = combinedRegex.lastIndex;
+            continue;
+        }
+
         // Handle placeholder [[CHART_*]]
         if (chartTypePlaceholder) {
             const chartSegment = getChartSegmentByPlaceholder(chartTypePlaceholder, chartsData);
@@ -1875,7 +2602,7 @@ const parseFullAnalysisWithCharts = (fullAnalysis, chartsData) => {
             } else {
                 // If no chart found, try to parse JSON and use old logic as fallback
                 try {
-                    const parsedData = JSON.parse(jsonBlock);
+                    const parsedData = JSON.parse(jsonBlockWithId);
                     const segment = mapJsonBlockToSegment(jsonIdentifier, parsedData, fullMatch);
                     if (segment) {
                         segments.push(segment);
@@ -1897,7 +2624,26 @@ const parseFullAnalysisWithCharts = (fullAnalysis, chartsData) => {
     // Add remaining markdown
     const remaining = fullAnalysis.slice(lastIndex);
     if (remaining.trim()) {
-        segments.push({ type: 'markdown', html: renderMarkdown(remaining) });
+        // Extract JSON from markdown before rendering
+        const { processedText, jsonSegments: extractedSegments } = extractJsonFromMarkdown(remaining);
+
+        // Add extracted JSON segments
+        extractedSegments.forEach((seg) => segments.push(seg));
+
+        // Add remaining markdown (without JSON blocks)
+        if (processedText.trim() && !processedText.match(/^__JSON_SEGMENT_\d+__$/)) {
+            const renderedHtml = renderMarkdown(processedText);
+            // Process rendered HTML to extract JSON from code blocks
+            const { htmlSegments, chartSegments } = extractJsonFromRenderedHtml(renderedHtml);
+            // Add chart segments first
+            chartSegments.forEach((seg) => segments.push(seg));
+            // Add remaining HTML
+            if (htmlSegments.length > 0) {
+                htmlSegments.forEach((html) => segments.push({ type: 'markdown', html }));
+            } else if (renderedHtml.trim()) {
+                segments.push({ type: 'markdown', html: renderedHtml });
+            }
+        }
     }
 
     return segments;
@@ -1912,12 +2658,31 @@ const chartsDataSegments = computed(() => {
     // Handle case when chartsData is an array directly (shouldn't happen, but handle it)
     if (Array.isArray(chartsData)) {
         // If it's an array of competitors, process it
-        if (chartsData.length > 0 && chartsData[0]?.name && chartsData[0]?.channelsHeatmap) {
+        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');
+
             return [
                 {
                     type: 'competitors',
                     competitors: chartsData,
-                    competitorsHeatmap: buildCompetitorsHeatmapData(chartsData)
+                    competitorsHeatmap: buildCompetitorsHeatmapData(chartsData),
+                    reachChart: reachChartData
+                        ? {
+                              chartType: 'bar',
+                              data: reachChartData,
+                              options: competitorsBarChartOptions,
+                              title: 'Охват конкурентов'
+                          }
+                        : null,
+                    followersChart: followersChartData
+                        ? {
+                              chartType: 'bar',
+                              data: followersChartData,
+                              options: competitorsBarChartOptions,
+                              title: 'Подписчики конкурентов'
+                          }
+                        : null
                 }
             ];
         }
@@ -1939,6 +2704,23 @@ const chartsDataSegments = computed(() => {
         return [];
     }
 
+    // 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) {
+        const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(chartsData);
+        if (ageGroupsChartData || gendersChartData) {
+            return [
+                {
+                    type: 'audienceSegmentation',
+                    ageGroupsChartData,
+                    gendersChartData,
+                    segmentsChannelMatrix: buildSegmentsChannelMatrix(chartsData.segmentsChannelMatrix),
+                    keyTakeaways: chartsData.keyTakeaways || []
+                }
+            ];
+        }
+    }
+
     return mapChartsDataToSegments(chartsData);
 });
 
@@ -1957,30 +2739,58 @@ const parsedContent = computed(() => {
     // Otherwise, fall back to parsing fullAnalysis with JSON blocks (for old data)
     else if (report.value?.fullAnalysis) {
         const content = report.value.fullAnalysis;
-        const regex = /```json:(\w+)\n([\s\S]*?)\n```/g;
+        // Regex for both ```json:identifier and plain ```json or ``` code blocks
+        const regex = /```json:(\w+)\n([\s\S]*?)\n```|```json\n([\s\S]*?)\n```|```\n([\s\S]*?)\n```/g;
         let lastIndex = 0;
         let match;
 
         while ((match = regex.exec(content)) !== null) {
-            const [fullMatch, identifier, jsonBlock] = match;
+            const [fullMatch, identifier, jsonBlockWithId, jsonBlockPlain, codeBlock] = match;
 
             if (match.index > lastIndex) {
                 const textBefore = content.slice(lastIndex, match.index);
                 if (textBefore.trim()) {
-                    segments.push({ type: 'markdown', html: renderMarkdown(textBefore) });
+                    // Extract JSON from markdown before rendering
+                    const { processedText, jsonSegments: extractedSegments } = extractJsonFromMarkdown(textBefore);
+
+                    // Add extracted JSON segments
+                    extractedSegments.forEach((seg) => segments.push(seg));
+
+                    // Add remaining markdown (without JSON blocks)
+                    if (processedText.trim() && !processedText.match(/^__JSON_SEGMENT_\d+__$/)) {
+                        segments.push({ type: 'markdown', html: renderMarkdown(processedText) });
+                    }
                 }
             }
 
-            let parsedData = null;
-            try {
-                parsedData = JSON.parse(jsonBlock);
-            } catch (error) {
-                console.error(`Не удалось распарсить блок ${identifier}:`, error);
+            // Handle code blocks that might contain JSON
+            if (codeBlock || jsonBlockPlain) {
+                const jsonContent = codeBlock || jsonBlockPlain;
+                // Try to parse as JSON and process
+                const jsonSegment = tryParseJsonSegment(jsonContent);
+                if (jsonSegment) {
+                    segments.push(jsonSegment);
+                } else {
+                    // If not JSON, add as markdown code block
+                    segments.push({ type: 'markdown', html: renderMarkdown(fullMatch) });
+                }
+                lastIndex = regex.lastIndex;
+                continue;
             }
 
-            const segment = mapJsonBlockToSegment(identifier, parsedData, fullMatch);
-            if (segment) {
-                segments.push(segment);
+            // Handle ```json:identifier blocks
+            if (identifier && jsonBlockWithId) {
+                let parsedData = null;
+                try {
+                    parsedData = JSON.parse(jsonBlockWithId);
+                } catch (error) {
+                    console.error(`Не удалось распарсить блок ${identifier}:`, error);
+                }
+
+                const segment = mapJsonBlockToSegment(identifier, parsedData, fullMatch);
+                if (segment) {
+                    segments.push(segment);
+                }
             }
 
             lastIndex = regex.lastIndex;
@@ -1988,7 +2798,16 @@ const parsedContent = computed(() => {
 
         const remaining = content.slice(lastIndex);
         if (remaining.trim()) {
-            segments.push({ type: 'markdown', html: renderMarkdown(remaining) });
+            // Extract JSON from markdown before rendering
+            const { processedText, jsonSegments: extractedSegments } = extractJsonFromMarkdown(remaining);
+
+            // Add extracted JSON segments
+            extractedSegments.forEach((seg) => segments.push(seg));
+
+            // Add remaining markdown (without JSON blocks)
+            if (processedText.trim() && !processedText.match(/^__JSON_SEGMENT_\d+__$/)) {
+                segments.push({ type: 'markdown', html: renderMarkdown(processedText) });
+            }
         }
     }
 
@@ -2027,6 +2846,138 @@ const parsedContent = computed(() => {
     });
 });
 
+// Layout segments: group adjacent "paragraph markdown" + (chart/marketShareChart/comparisonTable) into a two-column container.
+const isParagraphMarkdownSegment = (segment) => {
+    return segment?.type === 'markdown' && typeof segment.html === 'string' && /]/i.test(segment.html);
+};
+
+const isPairableMediaSegment = (segment) => {
+    return segment?.type === 'chart' || segment?.type === 'marketShareChart' || segment?.type === 'comparisonTable';
+};
+
+const displayContent = computed(() => {
+    const input = parsedContent.value || [];
+    const out = [];
+
+    for (let i = 0; i < input.length; i++) {
+        const cur = input[i];
+        const next = input[i + 1];
+
+        // Text then media
+        if (isParagraphMarkdownSegment(cur) && isPairableMediaSegment(next)) {
+            out.push({
+                type: 'paired',
+                text: cur,
+                media: next,
+                // on desktop we still keep text left; on mobile we can keep original (text first)
+                mobileOrder: 'text-first'
+            });
+            i++; // skip next
+            continue;
+        }
+
+        // Media then text
+        if (isPairableMediaSegment(cur) && isParagraphMarkdownSegment(next)) {
+            out.push({
+                type: 'paired',
+                text: next,
+                media: cur,
+                // keep original order on mobile (chart first)
+                mobileOrder: 'chart-first'
+            });
+            i++; // skip next
+            continue;
+        }
+
+        out.push(cur);
+    }
+
+    return out;
+});
+
+// Compact chart display settings for "side-by-side" (paired) layout
+const PAIRED_CHART_MAX_HEIGHT_PX = 280;
+const pairedChartContainerStyle = {
+    height: `${PAIRED_CHART_MAX_HEIGHT_PX}px`,
+    maxHeight: `${PAIRED_CHART_MAX_HEIGHT_PX}px`
+};
+
+const toCompactChartOptions = (options) => {
+    const base = options && typeof options === 'object' ? options : {};
+
+    const basePlugins = base.plugins && typeof base.plugins === 'object' ? base.plugins : {};
+    const baseLegend = basePlugins.legend && typeof basePlugins.legend === 'object' ? basePlugins.legend : {};
+    const baseLegendLabels = baseLegend.labels && typeof baseLegend.labels === 'object' ? baseLegend.labels : {};
+
+    // Keep "legend.display: false" if it was explicitly turned off
+    const legendDisplay = typeof baseLegend.display === 'boolean' ? baseLegend.display : true;
+
+    const compact = {
+        ...base,
+        maintainAspectRatio: false,
+        layout: {
+            ...(base.layout && typeof base.layout === 'object' ? base.layout : {}),
+            padding: 0
+        },
+        plugins: {
+            ...basePlugins,
+            legend: {
+                ...baseLegend,
+                display: legendDisplay,
+                // Avoid legend eating vertical space in compact mode
+                position: baseLegend.position || 'bottom',
+                labels: {
+                    ...baseLegendLabels,
+                    padding: 6,
+                    boxWidth: 10,
+                    boxHeight: 10,
+                    usePointStyle: true,
+                    font: {
+                        ...(baseLegendLabels.font && typeof baseLegendLabels.font === 'object' ? baseLegendLabels.font : {}),
+                        size: 10
+                    }
+                }
+            }
+        }
+    };
+
+    // Compact axis labels/ticks if scales exist
+    if (compact.scales && typeof compact.scales === 'object') {
+        const nextScales = { ...compact.scales };
+        for (const axisKey of Object.keys(nextScales)) {
+            const axis = nextScales[axisKey];
+            if (!axis || typeof axis !== 'object') continue;
+            const ticks = axis.ticks && typeof axis.ticks === 'object' ? axis.ticks : {};
+            const title = axis.title && typeof axis.title === 'object' ? axis.title : {};
+
+            nextScales[axisKey] = {
+                ...axis,
+                ticks: {
+                    ...ticks,
+                    autoSkip: typeof ticks.autoSkip === 'boolean' ? ticks.autoSkip : true,
+                    maxRotation: typeof ticks.maxRotation === 'number' ? ticks.maxRotation : 0,
+                    padding: 4,
+                    font: {
+                        ...(ticks.font && typeof ticks.font === 'object' ? ticks.font : {}),
+                        size: 10
+                    }
+                },
+                title: {
+                    ...title,
+                    padding: typeof title.padding !== 'undefined' ? title.padding : 4,
+                    font: {
+                        ...(title.font && typeof title.font === 'object' ? title.font : {}),
+                        size: 11
+                    }
+                }
+            };
+        }
+        compact.scales = nextScales;
+    }
+
+    return compact;
+};
+
 const pieChartOptions = {
     responsive: true,
     maintainAspectRatio: false,
@@ -2315,6 +3266,66 @@ onBeforeUnmount(() => {
     background-color: var(--surface-50);
 }
 
+.report-pair {
+    display: flex;
+    /* Don't stretch the media column to the full height of long text */
+    align-items: flex-start;
+    gap: 1.5rem;
+}
+
+.report-pair__text,
+.report-pair__media {
+    flex: 1 1 0;
+    min-width: 0;
+}
+
+.report-pair__text {
+    flex: 1.5 1 0;
+}
+
+.report-pair__media {
+    flex: 1 1 0;
+    max-width: 40%;
+    display: flex;
+    align-items: flex-start;
+    justify-content: flex-start;
+    min-width: 0;
+}
+
+.report-pair__mediaChart {
+    width: 100%;
+    display: flex;
+    align-items: flex-start;
+    justify-content: flex-start;
+}
+
+.report-pair__chart-container {
+    width: 100%;
+    display: flex;
+    align-items: stretch;
+    justify-content: stretch;
+}
+
+@media (max-width: 768px) {
+    .report-pair {
+        flex-direction: column;
+        gap: 1rem;
+    }
+
+    /* Preserve original order when the report had chart before text */
+    .report-pair--mobile-chart-first .report-pair__media {
+        order: 1;
+    }
+    .report-pair--mobile-chart-first .report-pair__text {
+        order: 2;
+    }
+
+    .report-pair__media {
+        max-width: none;
+        width: 100%;
+    }
+}
+
 .chart-container {
     position: relative;
 }