changed chart to candles

This commit is contained in:
54CHA
2025-08-22 09:32:12 +03:00
parent b8e94c72cf
commit e38c4e0801
4 changed files with 3327 additions and 1855 deletions

View File

@ -25,91 +25,55 @@ const PositionChart: React.FC<PositionChartProps> = ({
const chartRef = useRef<any>(null);
const scriptsLoadedRef = useRef<boolean>(false);
// Загружаем скрипты AnyChart (строго по порядку)
const loadAnyChartScripts = () => {
return new Promise<void>(async (resolve, reject) => {
// Загружаем Chart.js
const loadChartJS = () => {
return new Promise<void>((resolve, reject) => {
if (typeof window === 'undefined') return resolve();
if (scriptsLoadedRef.current && (window as any).anychart) return resolve();
if (scriptsLoadedRef.current && (window as any).Chart) 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);
const script = document.querySelector('script[src*="chart.js"]') as HTMLScriptElement | null;
if (script && script.getAttribute('data-loaded') === 'true') return resolve();
const chartScript = script || document.createElement('script');
chartScript.src = 'https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js';
chartScript.onload = () => {
chartScript.setAttribute('data-loaded', 'true');
scriptsLoadedRef.current = true;
resolve();
} catch (err) {
reject(err as Error);
}
};
chartScript.onerror = () => reject(new Error('Failed to load Chart.js'));
if (!script) document.head.appendChild(chartScript);
});
};
// Преобразование данных для column chart с цветовой логикой (один столбец = средняя позиция)
const prepareColumnData = (points: ChartDataPoint[]) => {
// Преобразование данных для candlestick с цветовой логикой
const prepareCandlestickData = (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 - только конкурент
// Определяем цвет: кто лучше (меньшая позиция)
if (myPos && compPos) {
if (myPos < compPos) {
color = '#16a34a'; // Dark green - мой лучше
} else if (myPos > compPos) {
color = '#dc2626'; // Dark red - конкурент лучше
} else {
color = '#7c3aed'; // Purple - равные позиции
}
} 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
@ -117,90 +81,187 @@ const PositionChart: React.FC<PositionChartProps> = ({
});
};
// Создание/обновление графика
// Создание Chart.js графика
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;
if (!chartContainerRef.current || !(window as any).Chart || !data || data.length === 0) return;
try {
if (chartRef.current) {
chartRef.current.dispose();
chartRef.current.destroy();
chartRef.current = null;
}
const columnData = prepareColumnData(data);
if (columnData.length === 0) return;
const candlestickData = prepareCandlestickData(data);
if (candlestickData.length === 0) return;
const chart = (window as any).anychart.column();
// Создаем canvas элемент
const canvas = document.createElement('canvas');
chartContainerRef.current.innerHTML = '';
chartContainerRef.current.appendChild(canvas);
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);
}
const ctx = canvas.getContext('2d');
if (!ctx) return;
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;
// Используем специальный Chart.js плагин для рисования wicks (под барами)
const wickPlugin = {
id: 'candlestickWicks',
beforeDatasetsDraw: (chart: any) => {
const ctx = chart.ctx;
const meta = chart.getDatasetMeta(0);
candlestickData.forEach((item, index) => {
const bar = meta.data[index];
const x = bar.x;
let wickHigh, wickLow;
if (item.myPosition && item.competitorPosition) {
const min = Math.min(item.myPosition, item.competitorPosition);
const max = Math.max(item.myPosition, item.competitorPosition);
// Wicks ограничены только реальными данными
wickHigh = max;
wickLow = min;
} else {
const pos = item.myPosition || item.competitorPosition || 1;
// Для одной позиции - минимальный wick для визуализации
wickHigh = pos + 0.2;
wickLow = Math.max(1, pos - 0.2);
}
// Конвертируем в пиксели
const yWickHigh = chart.scales.y.getPixelForValue(wickHigh);
const yWickLow = chart.scales.y.getPixelForValue(wickLow);
ctx.save();
ctx.strokeStyle = '#666666';
ctx.lineWidth = 1.5;
// Полный wick от низа до верха (под баром)
ctx.beginPath();
ctx.moveTo(x, yWickHigh);
ctx.lineTo(x, yWickLow);
ctx.stroke();
// Маленькие горизонтальные черточки на концах
const wickWidth = 4;
// Верхняя черточка
ctx.beginPath();
ctx.moveTo(x - wickWidth, yWickHigh);
ctx.lineTo(x + wickWidth, yWickHigh);
ctx.stroke();
// Нижняя черточка
ctx.beginPath();
ctx.moveTo(x - wickWidth, yWickLow);
ctx.lineTo(x + wickWidth, yWickLow);
ctx.stroke();
ctx.restore();
});
}
};
// Подготавливаем данные для floating bars (тела свечей)
const chartData = {
labels: candlestickData.map(item => item.x),
datasets: [{
label: 'Диапазон позиций',
data: candlestickData.map(item => {
if (item.myPosition && item.competitorPosition) {
const min = Math.min(item.myPosition, item.competitorPosition);
const max = Math.max(item.myPosition, item.competitorPosition);
const mid = (min + max) / 2;
const bodySize = Math.max((max - min) * 0.6, 0.5); // Тело = 60% от диапазона
return [mid - bodySize/2, mid + bodySize/2]; // Floating bar around middle
} else {
const pos = item.myPosition || item.competitorPosition || 1;
return [pos - 0.25, pos + 0.25]; // Маленькое тело
}
}),
backgroundColor: candlestickData.map(item => item.fill),
borderColor: candlestickData.map(item => item.fill),
borderWidth: 1,
barThickness: 18 // Толщина тел свечей
}]
};
const chart = new (window as any).Chart(ctx, {
type: 'bar',
data: chartData,
plugins: [wickPlugin], // Добавляем наш плагин для wicks
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Сравнение позиций товаров по городам',
color: '#7c3aed',
font: {
size: 16,
weight: '600'
}
},
legend: {
display: false
},
tooltip: {
callbacks: {
label: (context: any) => {
const dataIndex = context.dataIndex;
const item = candlestickData[dataIndex];
let tooltip = [];
if (item.myPosition && item.competitorPosition) {
tooltip.push(`${myArticleId}: ${item.myPosition}`);
tooltip.push(`${competitorArticleId}: ${item.competitorPosition}`);
tooltip.push(`Диапазон: ${Math.min(item.myPosition, item.competitorPosition)} - ${Math.max(item.myPosition, item.competitorPosition)}`);
} else if (item.myPosition) {
tooltip.push(`Только ${myArticleId}: ${item.myPosition}`);
} else if (item.competitorPosition) {
tooltip.push(`Только ${competitorArticleId}: ${item.competitorPosition}`);
}
return tooltip;
}
}
}
},
scales: {
y: {
title: {
display: true,
text: 'Позиции в каталоге',
color: '#6b7280'
},
grid: {
color: '#e5e7eb'
},
ticks: {
color: '#6b7280'
},
reverse: true // Инвертируем: 1 вверху, большие числа внизу
},
x: {
title: {
display: true,
text: 'Города',
color: '#6b7280'
},
grid: {
color: '#e5e7eb'
},
ticks: {
color: '#6b7280'
}
}
}
}
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);
@ -210,12 +271,12 @@ const PositionChart: React.FC<PositionChartProps> = ({
useEffect(() => {
const init = async () => {
try {
await loadAnyChartScripts();
// Дождёмся готовности AnyChart (poll, чтобы избежать несовместимости)
await loadChartJS();
// Дождёмся готовности Chart.js
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;
if ((window as any).Chart) return;
await new Promise(res => setTimeout(res, 150));
}
};
@ -228,7 +289,7 @@ const PositionChart: React.FC<PositionChartProps> = ({
init();
return () => {
if (chartRef.current) {
chartRef.current.dispose();
chartRef.current.destroy();
chartRef.current = null;
}
};
@ -237,9 +298,7 @@ const PositionChart: React.FC<PositionChartProps> = ({
useEffect(() => {
const update = async () => {
if (!scriptsLoadedRef.current || !(window as any).anychart) return;
// Убедимся, что AnyChart готов
if (typeof (window as any).anychart.column !== 'function') return;
if (!scriptsLoadedRef.current || !(window as any).Chart) return;
await createChart();
};
update();
@ -289,14 +348,13 @@ const PositionChart: React.FC<PositionChartProps> = ({
>
<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>
<div className="w-3 h-2 bg-green-600 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>
<div className="w-3 h-2 bg-red-600 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>