'use client'; import React, { useEffect, useRef } from 'react'; import { motion } from 'framer-motion'; import { FiActivity } from 'react-icons/fi'; interface ChartDataPoint { city: string; myPosition: number; competitorPosition: number; } interface PositionChartProps { data: ChartDataPoint[]; myArticleId: string; competitorArticleId: string; } const PositionChart: React.FC = ({ data, myArticleId, competitorArticleId }) => { const chartContainerRef = useRef(null); const chartRef = useRef(null); const scriptsLoadedRef = useRef(false); // Загружаем скрипты AnyChart (строго по порядку) const loadAnyChartScripts = () => { return new Promise(async (resolve, reject) => { if (typeof window === 'undefined') return resolve(); if (scriptsLoadedRef.current && (window as any).anychart) return resolve(); const scripts = [ 'https://cdn.anychart.com/releases/v8/js/anychart-base.min.js', 'https://cdn.anychart.com/releases/v8/js/anychart-ui.min.js', 'https://cdn.anychart.com/releases/v8/js/anychart-exports.min.js', 'https://cdn.anychart.com/releases/v8/js/anychart-data-adapter.min.js' ]; const loadScript = (src: string) => new Promise((res, rej) => { // Не дублируем const existing = document.querySelector(`script[src="${src}"]`) as HTMLScriptElement | null; if (existing && existing.getAttribute('data-loaded') === 'true') return res(); const script = existing || document.createElement('script'); script.src = src; script.async = false; // важен порядок script.onload = () => { script.setAttribute('data-loaded', 'true'); res(); }; script.onerror = () => rej(new Error(`Failed to load script: ${src}`)); if (!existing) document.head.appendChild(script); }); try { for (const src of scripts) { // eslint-disable-next-line no-await-in-loop await loadScript(src); } // CSS const link = document.querySelector('link[href*="anychart-ui.min.css"]') as HTMLLinkElement | null || document.createElement('link'); link.href = 'https://cdn.anychart.com/releases/v8/css/anychart-ui.min.css'; link.type = 'text/css'; link.rel = 'stylesheet'; if (!link.parentElement) document.head.appendChild(link); const fontLink = document.querySelector('link[href*="anychart-font.min.css"]') as HTMLLinkElement | null || document.createElement('link'); fontLink.href = 'https://cdn.anychart.com/releases/v8/fonts/css/anychart-font.min.css'; fontLink.type = 'text/css'; fontLink.rel = 'stylesheet'; if (!fontLink.parentElement) document.head.appendChild(fontLink); scriptsLoadedRef.current = true; resolve(); } catch (err) { reject(err as Error); } }); }; // Преобразование данных для column chart с цветовой логикой (один столбец = средняя позиция) const prepareColumnData = (points: ChartDataPoint[]) => { if (!points || points.length === 0) return []; return points.map((item) => { const myPos = item.myPosition > 0 ? item.myPosition : null; const compPos = item.competitorPosition > 0 ? item.competitorPosition : null; // Вычисляем среднюю позицию const positions = [myPos, compPos].filter(p => p !== null) as number[]; let averagePosition = null; let color = '#8b5cf6'; // Purple default if (positions.length > 0) { averagePosition = positions.reduce((a, b) => a + b, 0) / positions.length; // Определяем цвет: кто лучше (меньшая позиция) if (myPos && compPos) { if (myPos < compPos) { color = '#22c55e'; // Green - мой лучше } else if (myPos > compPos) { color = '#ef4444'; // Red - конкурент лучше } // Удалили amber цвет для одинаковых позиций, так как это невозможно в реальном каталоге } else if (myPos) { color = '#8b5cf6'; // Purple - только мой } else if (compPos) { color = '#a855f7'; // Light purple - только конкурент } } return { x: item.city, value: averagePosition, fill: color, myPosition: myPos, competitorPosition: compPos }; }); }; // Создание/обновление графика const createChart = async () => { if (!chartContainerRef.current || !(window as any).anychart || !data || data.length === 0) return; // Финальная проверка API наличия фабрики column if (typeof (window as any).anychart.column !== 'function') return; try { if (chartRef.current) { chartRef.current.dispose(); chartRef.current = null; } const columnData = prepareColumnData(data); if (columnData.length === 0) return; const chart = (window as any).anychart.column(); const title = chart.title('Сравнение позиций товаров по городам'); if (title && typeof title === 'object' && 'fontColor' in title) { (title as any).fontColor('#7c3aed'); (title as any).fontSize(16); (title as any).fontWeight(600); } chart.yAxis().title('Средняя позиция'); chart.yAxis().labels().format('{%value}'); try { (chart.yAxis().labels() as any).fontColor('#6b7280'); } catch {} chart.xAxis().title('Города'); chart.xAxis().staggerMode(true); try { (chart.xAxis().labels() as any).fontColor('#6b7280'); } catch {} // Инвертируем ось Y (меньшая позиция = лучше = выше на графике) try { const yScale = (chart as any).yScale(); if (yScale && typeof yScale.inverted === 'function') yScale.inverted(true); } catch {} // Создаем единственную серию для средних позиций const series = chart.column(columnData); series.name('Средняя позиция'); // Настраиваем цвет для каждого столбца индивидуально series.fill((ctx: any) => ctx.getData('fill')); series.stroke((ctx: any) => ctx.getData('fill')); series.tooltip().format((ctx: any) => { const myPos = ctx.getData('myPosition'); const compPos = ctx.getData('competitorPosition'); let tooltip = 'Город: ' + ctx.getData('x') + '\nСредняя позиция: ' + ctx.getData('value'); if (myPos && compPos) { tooltip += '\n\n' + myArticleId + ': ' + myPos; tooltip += '\n' + competitorArticleId + ': ' + compPos; } else if (myPos) { tooltip += '\n\nТолько ' + myArticleId + ': ' + myPos; } else if (compPos) { tooltip += '\n\nТолько ' + competitorArticleId + ': ' + compPos; } return tooltip; }); // Настройка сетки try { (chart as any).grid(0).stroke('#e5e7eb', 1, '2 2'); (chart as any).grid(1).layout('vertical').stroke('#e5e7eb', 1, '2 2'); } catch { try { (chart as any).yGrid(true); (chart as any).xGrid(true); } catch {} } // Легенда const legend = chart.legend(); legend.enabled(true); legend.fontSize(11); legend.padding([10, 0, 0, 0]); (chart as any).background().fill('transparent'); (chart as any).container(chartContainerRef.current); (chart as any).draw(); chartRef.current = chart; } catch (e) { console.error('Ошибка при создании графика:', e); } }; useEffect(() => { const init = async () => { try { await loadAnyChartScripts(); // Дождёмся готовности AnyChart (poll, чтобы избежать несовместимости) const waitReady = async () => { const maxTries = 20; for (let i = 0; i < maxTries; i++) { if ((window as any).anychart && typeof (window as any).anychart.column === 'function') return; await new Promise(res => setTimeout(res, 150)); } }; await waitReady(); await createChart(); } catch (e) { console.error('Ошибка инициализации графика:', e); } }; init(); return () => { if (chartRef.current) { chartRef.current.dispose(); chartRef.current = null; } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { const update = async () => { if (!scriptsLoadedRef.current || !(window as any).anychart) return; // Убедимся, что AnyChart готов if (typeof (window as any).anychart.column !== 'function') return; await createChart(); }; update(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [data, myArticleId, competitorArticleId]); if (!data || data.length === 0) { return (
Нет данных для отображения
Выполните поиск для просмотра графика позиций
); } return (
{/* График */}
{/* Минимальная легенда */}
{myArticleId} лучше
{competitorArticleId || 'Конкурент'} лучше
Один товар
); }; export default PositionChart;