310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
'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<PositionChartProps> = ({
|
||
data,
|
||
myArticleId,
|
||
competitorArticleId
|
||
}) => {
|
||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||
const chartRef = useRef<any>(null);
|
||
const scriptsLoadedRef = useRef<boolean>(false);
|
||
|
||
// Загружаем скрипты AnyChart (строго по порядку)
|
||
const loadAnyChartScripts = () => {
|
||
return new Promise<void>(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<void>((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 (
|
||
<div className="bg-white/40 backdrop-blur-md border border-white/30 rounded-lg md:rounded-xl shadow-lg shadow-purple-900/5 p-4 md:p-6 h-full flex flex-col">
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
transition={{ delay: 0.2 }}
|
||
className="flex-1 flex items-center justify-center text-gray-500"
|
||
>
|
||
<div className="text-center">
|
||
<FiActivity size={48} className="mx-auto mb-4 text-purple-400" />
|
||
<div className="text-lg font-medium mb-2">Нет данных для отображения</div>
|
||
<div className="text-sm">Выполните поиск для просмотра графика позиций</div>
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="bg-white/40 backdrop-blur-md border border-white/30 rounded-lg md:rounded-xl shadow-lg shadow-purple-900/5 p-4 md:p-6 h-full flex flex-col">
|
||
{/* График */}
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
transition={{ delay: 0.3 }}
|
||
className="relative flex-1 min-h-0"
|
||
>
|
||
<div
|
||
ref={chartContainerRef}
|
||
className="w-full h-full min-h-[300px] md:min-h-[400px]"
|
||
/>
|
||
</motion.div>
|
||
|
||
{/* Минимальная легенда */}
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
transition={{ delay: 0.5 }}
|
||
className="flex-shrink-0 mt-3 pt-3 border-t border-white/20"
|
||
>
|
||
<div className="flex justify-center items-center gap-4 text-xs text-gray-600">
|
||
<div className="flex items-center gap-1.5">
|
||
<div className="w-3 h-2 bg-green-500 rounded-sm"></div>
|
||
<span>{myArticleId} лучше</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<div className="w-3 h-2 bg-red-500 rounded-sm"></div>
|
||
<span>{competitorArticleId || 'Конкурент'} лучше</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1.5">
|
||
<div className="w-3 h-2 bg-purple-500 rounded-sm"></div>
|
||
<span>Один товар</span>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default PositionChart; |