diff --git a/src/views/pages/marketing/MarketingAnalysis.vue b/src/views/pages/marketing/MarketingAnalysis.vue
index ab54262..30df7d4 100644
--- a/src/views/pages/marketing/MarketingAnalysis.vue
+++ b/src/views/pages/marketing/MarketingAnalysis.vue
@@ -448,6 +448,171 @@
+
+
Анализ конкурентов
@@ -1958,6 +2123,104 @@ const buildCompetitorsBarChartData = (competitors, metricType = 'reach') => {
};
};
+// Build competitors visibility score chart data (horizontal bar chart)
+const buildCompetitorsVisibilityChartData = (competitors) => {
+ if (!Array.isArray(competitors) || competitors.length === 0) return null;
+
+ // Sort by visibility_score descending for better visualization
+ const sortedCompetitors = [...competitors].sort((a, b) => (b.visibility_score || 0) - (a.visibility_score || 0));
+
+ return {
+ labels: sortedCompetitors.map((comp) => comp.name),
+ datasets: [
+ {
+ label: 'Видимость (score)',
+ data: sortedCompetitors.map((comp) => comp.visibility_score || 0),
+ backgroundColor: sortedCompetitors.map((comp) => {
+ const score = comp.visibility_score || 0;
+ // Color coding: high (green), medium (yellow), low (red)
+ if (score >= 70) return '#10B981'; // green-500
+ if (score >= 40) return '#F59E0B'; // amber-500
+ return '#EF4444'; // red-500
+ }),
+ borderColor: '#ffffff',
+ borderWidth: 2,
+ borderRadius: 4
+ }
+ ]
+ };
+};
+
+// Process competitive environment data into segment format
+const processCompetitiveEnvironmentData = (competitiveEnv) => {
+ if (!competitiveEnv || typeof competitiveEnv !== 'object') return null;
+
+ const competitors = competitiveEnv.competitors || [];
+ if (!Array.isArray(competitors) || competitors.length === 0) return null;
+
+ // Build visibility chart data
+ const visibilityChartData = buildCompetitorsVisibilityChartData(competitors);
+
+ return {
+ type: 'competitiveEnvironment',
+ title: competitiveEnv.title || 'Конкурентная среда',
+ metrics: competitiveEnv.metrics || {},
+ competitors: competitors,
+ visibilityChart: visibilityChartData
+ ? {
+ chartType: 'bar',
+ data: visibilityChartData,
+ options: {
+ indexAxis: 'y',
+ responsive: true,
+ maintainAspectRatio: false,
+ layout: { padding: { right: 40 } },
+ animation: {
+ duration: 500,
+ onComplete: (animation) => drawBarValues(animation.chart)
+ },
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ callbacks: {
+ label: (context) => `Видимость: ${context.parsed.x}%`
+ }
+ }
+ },
+ scales: {
+ x: {
+ display: true,
+ beginAtZero: true,
+ max: 100,
+ ticks: {
+ callback: function(value) {
+ return value + '%';
+ },
+ font: { size: 12 }
+ },
+ grid: {
+ display: true,
+ drawBorder: false,
+ color: 'rgba(0,0,0,0.05)'
+ }
+ },
+ y: {
+ grid: { display: false },
+ ticks: {
+ font: { size: 12, weight: '500' },
+ crossAlign: 'far'
+ }
+ }
+ }
+ },
+ title: 'Видимость конкурентов',
+ chartHeight: 'height: 400px'
+ }
+ : null,
+ conclusion: competitiveEnv.conclusion || ''
+ };
+};
+
// Build competitors heatmap data
const buildCompetitorsHeatmapData = (competitors) => {
if (!Array.isArray(competitors) || competitors.length === 0) return null;
@@ -2303,7 +2566,7 @@ const mapJsonBlockToSegment = (identifier, data, rawText) => {
title: 'Распределение по возрасту',
chartType: 'bar',
data: chartData,
- options: competitorsBarChartOptions,
+ options: horizontalBarChartOptions,
chartHeight: 'height: 350px'
};
}
@@ -2389,7 +2652,23 @@ const mapJsonBlockToSegment = (identifier, data, rawText) => {
gendersChartData
};
}
+ case 'competitiveenvironment':
+ case 'competitive_environment': {
+ // New structure: { title, metrics, competitors, conclusion }
+ const processed = processCompetitiveEnvironmentData(data);
+ if (processed) return processed;
+ // Fallback to old structure if processing fails
+ return null;
+ }
case 'competitors': {
+ // Check if it's new structure (has metrics, competitors array with visibility_score)
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
+ if (data.metrics && Array.isArray(data.competitors)) {
+ const processed = processCompetitiveEnvironmentData(data);
+ if (processed) return processed;
+ }
+ }
+ // Old structure: array of competitors
if (!Array.isArray(data) || data.length === 0) return null;
const reachChartData = buildCompetitorsBarChartData(data, 'reach');
const followersChartData = buildCompetitorsBarChartData(data, 'followers');
@@ -2402,7 +2681,7 @@ const mapJsonBlockToSegment = (identifier, data, rawText) => {
? {
chartType: 'bar',
data: reachChartData,
- options: competitorsBarChartOptions,
+ options: horizontalBarChartOptions,
title: 'Охват конкурентов'
}
: null,
@@ -2410,7 +2689,7 @@ const mapJsonBlockToSegment = (identifier, data, rawText) => {
? {
chartType: 'bar',
data: followersChartData,
- options: competitorsBarChartOptions,
+ options: horizontalBarChartOptions,
title: 'Подписчики конкурентов'
}
: null
@@ -2467,9 +2746,28 @@ const getChartSegmentByPlaceholder = (placeholder, chartsData) => {
return null;
case 'CHART_COMPETITORS':
+ case 'CHART_COMPETITIVE_ENVIRONMENT': {
+ // Check for new competitive environment structure first
+ if (chartsData.competitiveEnvironment || chartsData.competitive_environment) {
+ const competitiveEnv = chartsData.competitiveEnvironment || chartsData.competitive_environment;
+ const processed = processCompetitiveEnvironmentData(competitiveEnv);
+ if (processed) return processed;
+ }
+ // Fallback to old competitors structure
const competitorsArray = Array.isArray(chartsData.competitors) ? chartsData.competitors : chartsData.competitors?.competitors || [];
if (competitorsArray.length > 0) {
+ // Check if it's new structure (has visibility_score)
+ if (competitorsArray[0]?.visibility_score !== undefined) {
+ const processed = processCompetitiveEnvironmentData({
+ competitors: competitorsArray,
+ metrics: chartsData.metrics || {},
+ title: chartsData.title || 'Конкурентная среда',
+ conclusion: chartsData.conclusion || ''
+ });
+ if (processed) return processed;
+ }
+ // Old structure
const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach');
const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers');
@@ -2481,7 +2779,7 @@ const getChartSegmentByPlaceholder = (placeholder, chartsData) => {
? {
chartType: 'bar',
data: reachChartData,
- options: competitorsBarChartOptions,
+ options: horizontalBarChartOptions,
title: 'Охват конкурентов'
}
: null,
@@ -2489,7 +2787,7 @@ const getChartSegmentByPlaceholder = (placeholder, chartsData) => {
? {
chartType: 'bar',
data: followersChartData,
- options: competitorsBarChartOptions,
+ options: horizontalBarChartOptions,
title: 'Подписчики конкурентов'
}
: null
@@ -2521,6 +2819,7 @@ const getChartSegmentByPlaceholder = (placeholder, chartsData) => {
return segment;
}
return null;
+ }
case 'CHART_SWOT':
// Check for swotCompetitors first
@@ -2602,7 +2901,7 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => {
title: 'Распределение по возрасту',
chartType: 'bar',
data: chartData,
- options: competitorsBarChartOptions,
+ options: horizontalBarChartOptions,
chartHeight: 'height: 350px'
};
}
@@ -2634,9 +2933,29 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => {
}
return null;
- case 'competitors':
+ case 'competitiveenvironment':
+ case 'competitive_environment': {
+ const competitiveEnv = chartsData.competitiveEnvironment || chartsData.competitive_environment;
+ if (competitiveEnv) {
+ const processed = processCompetitiveEnvironmentData(competitiveEnv);
+ if (processed) return processed;
+ }
+ return null;
+ }
+ case 'competitors': {
const competitorsArray = Array.isArray(chartsData.competitors) ? chartsData.competitors : chartsData.competitors?.competitors || [];
if (competitorsArray.length > 0) {
+ // Check if it's new structure (has visibility_score)
+ if (competitorsArray[0]?.visibility_score !== undefined) {
+ const processed = processCompetitiveEnvironmentData({
+ competitors: competitorsArray,
+ metrics: chartsData.metrics || {},
+ title: chartsData.title || 'Конкурентная среда',
+ conclusion: chartsData.conclusion || ''
+ });
+ if (processed) return processed;
+ }
+ // Old structure
const reachChartData = buildCompetitorsBarChartData(competitorsArray, 'reach');
const followersChartData = buildCompetitorsBarChartData(competitorsArray, 'followers');
@@ -2648,7 +2967,7 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => {
? {
chartType: 'bar',
data: reachChartData,
- options: competitorsBarChartOptions,
+ options: horizontalBarChartOptions,
title: 'Охват конкурентов'
}
: null,
@@ -2656,7 +2975,7 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => {
? {
chartType: 'bar',
data: followersChartData,
- options: competitorsBarChartOptions,
+ options: horizontalBarChartOptions,
title: 'Подписчики конкурентов'
}
: null
@@ -2688,6 +3007,7 @@ const getChartSegmentByIdentifier = (identifier, chartsData) => {
return segment;
}
return null;
+ }
case 'swot':
// Check for swotCompetitors first
@@ -2836,9 +3156,27 @@ const tryParseJsonSegment = (text) => {
try {
const parsedJson = JSON.parse(trimmed);
+ // Check if it's new competitive environment structure
+ if (parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson)) {
+ if (parsedJson.competitors && Array.isArray(parsedJson.competitors) && parsedJson.metrics) {
+ const processed = processCompetitiveEnvironmentData(parsedJson);
+ if (processed) return processed;
+ }
+ }
+
// Check if it's an array of competitors
if (Array.isArray(parsedJson) && parsedJson.length > 0) {
- // Check if it looks like competitors data
+ // Check if it's new structure (has visibility_score)
+ if (parsedJson[0]?.visibility_score !== undefined) {
+ const processed = processCompetitiveEnvironmentData({
+ competitors: parsedJson,
+ metrics: {},
+ title: 'Конкурентная среда',
+ conclusion: ''
+ });
+ if (processed) return processed;
+ }
+ // Check if it looks like old competitors data
if (parsedJson[0]?.name && (parsedJson[0]?.channelsHeatmap || parsedJson[0]?.reach || parsedJson[0]?.followers)) {
return {
type: 'competitors',
@@ -2855,7 +3193,7 @@ const tryParseJsonSegment = (text) => {
const chartType = parsedJson.chartType || 'bar';
// Use appropriate options based on chart type
- const options = chartType === 'pie' || chartType === 'doughnut' ? pieChartOptions : chartType === 'line' ? seasonalityChartOptions : competitorsBarChartOptions;
+ const options = chartType === 'pie' || chartType === 'doughnut' ? pieChartOptions : chartType === 'line' ? seasonalityChartOptions : horizontalBarChartOptions;
// Normalize chart data (colors/borders, safety)
const chartData = normalizeChartJsData(parsedJson, chartType);
@@ -3001,7 +3339,7 @@ const chartsDataSegments = computed(() => {
? {
chartType: 'bar',
data: reachChartData,
- options: competitorsBarChartOptions,
+ options: horizontalBarChartOptions,
title: 'Охват конкурентов'
}
: null,
@@ -3009,7 +3347,7 @@ const chartsDataSegments = computed(() => {
? {
chartType: 'bar',
data: followersChartData,
- options: competitorsBarChartOptions,
+ options: horizontalBarChartOptions,
title: 'Подписчики конкурентов'
}
: null
@@ -3058,13 +3396,24 @@ const chartsDataSegments = computed(() => {
const parsedContent = computed(() => {
let segments = [];
+ // Check for v2ReportNormalized competitive_environment first
+ if (v2ReportNormalized.value?.sections?.competitive_environment) {
+ const competitiveEnv = v2ReportNormalized.value.sections.competitive_environment;
+ const processed = processCompetitiveEnvironmentData(competitiveEnv);
+ if (processed) {
+ segments.push(processed);
+ }
+ }
+
// If we have both fullAnalysis and chartsData, parse fullAnalysis and inject charts
if (report.value?.fullAnalysis && report.value?.chartsData) {
- segments = parseFullAnalysisWithCharts(report.value.fullAnalysis, report.value.chartsData);
+ const parsedSegments = parseFullAnalysisWithCharts(report.value.fullAnalysis, report.value.chartsData);
+ segments = [...segments, ...parsedSegments];
}
// If only chartsData exists, use it
else if (report.value?.chartsData) {
- segments = chartsDataSegments.value;
+ const chartSegments = chartsDataSegments.value;
+ segments = [...segments, ...chartSegments];
}
// Otherwise, fall back to parsing fullAnalysis with JSON blocks (for old data)
else if (report.value?.fullAnalysis) {
@@ -3128,8 +3477,32 @@ const parsedContent = computed(() => {
return segments.filter((segment) => {
// If segment doesn't have a type, try to process it
if (!segment || !segment.type) {
+ // Check if it's new competitive environment structure
+ if (segment && typeof segment === 'object' && !Array.isArray(segment)) {
+ if (segment.competitors && Array.isArray(segment.competitors) && segment.metrics) {
+ const processed = processCompetitiveEnvironmentData(segment);
+ if (processed) {
+ segments[segments.indexOf(segment)] = processed;
+ return true;
+ }
+ }
+ }
// If it's an array (like competitors), process it
if (Array.isArray(segment) && segment.length > 0 && segment[0]?.name) {
+ // Check if it's new structure (has visibility_score)
+ if (segment[0]?.visibility_score !== undefined) {
+ const processed = processCompetitiveEnvironmentData({
+ competitors: segment,
+ metrics: {},
+ title: 'Конкурентная среда',
+ conclusion: ''
+ });
+ if (processed) {
+ segments[segments.indexOf(segment)] = processed;
+ return true;
+ }
+ }
+ // Old structure
const processed = {
type: 'competitors',
competitors: segment,
@@ -3341,6 +3714,79 @@ const goToStrategy = () => {
}
};
+// Competitive Environment: Chart and Table Synchronization
+const highlightedRowIndex = ref(null);
+const visibilityChartRef = ref(null);
+
+const highlightRow = (index) => {
+ highlightedRowIndex.value = index;
+ // Highlight corresponding bar in chart if chart is available
+ if (visibilityChartRef.value && visibilityChartRef.value.chart && index !== null) {
+ const chart = visibilityChartRef.value.chart;
+ const meta = chart.getDatasetMeta(0);
+ if (meta && meta.data[index]) {
+ // Reset all bars
+ meta.data.forEach((bar) => {
+ bar._model.backgroundColor = bar._view.backgroundColor;
+ });
+ // Highlight the hovered bar
+ const hoveredBar = meta.data[index];
+ if (hoveredBar) {
+ const originalColor = hoveredBar._view.backgroundColor;
+ hoveredBar._model.backgroundColor = originalColor + 'CC'; // Add transparency
+ chart.update('none');
+ }
+ }
+ }
+};
+
+const onChartHover = (event, elements) => {
+ if (elements && elements.length > 0) {
+ const elementIndex = elements[0].index;
+ highlightedRowIndex.value = elementIndex;
+ }
+};
+
+const onChartClick = (event, elements) => {
+ if (elements && elements.length > 0) {
+ const elementIndex = elements[0].index;
+ // Could trigger some action on click
+ }
+};
+
+const handleRowClick = (competitor) => {
+ if (competitor.website) {
+ window.open(competitor.website, '_blank', 'noopener,noreferrer');
+ }
+};
+
+const formatWebsite = (url) => {
+ if (!url) return '';
+ try {
+ const urlObj = new URL(url);
+ return urlObj.hostname.replace('www.', '');
+ } catch {
+ return url;
+ }
+};
+
+const getScoreClass = (score) => {
+ if (score >= 70) return 'competitive-env-score-high';
+ if (score >= 40) return 'competitive-env-score-medium';
+ return 'competitive-env-score-low';
+};
+
+const getPriceSegmentSeverity = (segment) => {
+ const segmentLower = (segment || '').toLowerCase();
+ if (segmentLower.includes('premium') || segmentLower.includes('высок') || segmentLower.includes('премиум')) {
+ return 'danger';
+ }
+ if (segmentLower.includes('low') || segmentLower.includes('низк')) {
+ return 'success';
+ }
+ return 'warning';
+};
+
// Load analysis from query params
onMounted(async () => {
const queryAnalysisId = route.query.analysisId;
@@ -3741,4 +4187,81 @@ onBeforeUnmount(() => {
.comparison-table-tr:hover {
background-color: var(--surface-50);
}
+
+/* Competitive Environment Table Styles */
+.competitive-environment-table {
+ font-size: 0.9rem;
+}
+
+.competitive-env-th {
+ padding: 1rem;
+ text-align: left;
+ font-weight: 600;
+ color: var(--text-color);
+ background-color: var(--surface-50);
+ border-bottom: 2px solid var(--surface-border);
+ white-space: nowrap;
+}
+
+.competitive-env-td {
+ padding: 1rem;
+ border-bottom: 1px solid var(--surface-border);
+ color: var(--text-color);
+ vertical-align: middle;
+}
+
+.competitive-env-tr {
+ transition: all 0.2s ease;
+ cursor: pointer;
+}
+
+.competitive-env-tr:hover {
+ background-color: var(--surface-hover);
+ transform: translateX(2px);
+}
+
+.competitive-env-tr-highlighted {
+ background-color: var(--primary-50);
+ border-left: 3px solid var(--primary-500);
+}
+
+.competitive-env-score-badge {
+ display: inline-block;
+ padding: 0.375rem 0.75rem;
+ border-radius: 0.5rem;
+ font-weight: 600;
+ font-size: 0.875rem;
+ min-width: 3rem;
+ text-align: center;
+}
+
+.competitive-env-score-high {
+ background-color: rgba(16, 185, 129, 0.1);
+ color: #10b981;
+}
+
+.competitive-env-score-medium {
+ background-color: rgba(245, 158, 11, 0.1);
+ color: #f59e0b;
+}
+
+.competitive-env-score-low {
+ background-color: rgba(239, 68, 68, 0.1);
+ color: #ef4444;
+}
+
+.dark .competitive-env-score-high {
+ background-color: rgba(16, 185, 129, 0.2);
+ color: #34d399;
+}
+
+.dark .competitive-env-score-medium {
+ background-color: rgba(245, 158, 11, 0.2);
+ color: #fbbf24;
+}
+
+.dark .competitive-env-score-low {
+ background-color: rgba(239, 68, 68, 0.2);
+ color: #f87171;
+}