changed chart to candles
This commit is contained in:
@ -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>
|
||||
|
Reference in New Issue
Block a user