Files
scan-sfera/src/app/components/PositionChart.tsx
2025-08-09 07:34:49 +03:00

310 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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;