Files
CrazeAnalytix/components/BSRUnitsCorrelationChart.tsx
T

329 lines
13 KiB
TypeScript
Raw Normal View History

import React, { useState, useMemo } from 'react';
import {
ComposedChart, Bar, Line,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts';
import { BSRRecord, CombinedKPIs } from '../types';
interface Props {
bsrData: BSRRecord[];
combinedSalesData: CombinedKPIs[];
}
const MARKETS = ['Amazon DE', 'Amazon ES', 'Amazon FR', 'Amazon IT', 'Amazon UK'] as const;
const MARKET_CONFIG: Record<string, { label: string; color: string; flag: string }> = {
'Amazon DE': { label: 'DE', color: '#f37526', flag: '🇩🇪' },
'Amazon ES': { label: 'ES', color: '#2acbd6', flag: '🇪🇸' },
'Amazon FR': { label: 'FR', color: '#7950f2', flag: '🇫🇷' },
'Amazon IT': { label: 'IT', color: '#10b981', flag: '🇮🇹' },
'Amazon UK': { label: 'UK', color: '#f59e0b', flag: '🇬🇧' },
};
function pearsonR(x: number[], y: number[]): number | null {
const n = x.length;
if (n < 2 || n !== y.length) return null;
const meanX = x.reduce((a, b) => a + b, 0) / n;
const meanY = y.reduce((a, b) => a + b, 0) / n;
const num = x.reduce((s, xi, i) => s + (xi - meanX) * (y[i] - meanY), 0);
const den = Math.sqrt(
x.reduce((s, xi) => s + (xi - meanX) ** 2, 0) *
y.reduce((s, yi) => s + (yi - meanY) ** 2, 0)
);
return den === 0 ? null : num / den;
}
const formatBSR = (v: number) =>
v >= 1_000_000
? `${(v / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
: v >= 1_000
? `${Math.round(v / 1_000)}K`
: String(v);
const CARD_BG = '#161b24';
const TOOLTIP_STYLE = {
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '10px',
color: '#fff',
fontSize: '12px',
};
export const BSRUnitsCorrelationChart: React.FC<Props> = ({ bsrData, combinedSalesData }) => {
const [activeMarket, setActiveMarket] = useState<string>('Amazon DE');
const [bsrInverted, setBsrInverted] = useState(true);
// Derive available markets from actual data
const availableMarkets = useMemo(() => {
const bsrMarkets = new Set(bsrData.map(r => r.market));
const salesMarkets = new Set(combinedSalesData.map(r => r.customer));
return MARKETS.filter(m => bsrMarkets.has(m) || salesMarkets.has(m));
}, [bsrData, combinedSalesData]);
// Ensure activeMarket is always valid when data changes
const resolvedMarket = availableMarkets.includes(activeMarket as any)
? activeMarket
: (availableMarkets[0] ?? 'Amazon DE');
// Current year from sales data — use reduce to avoid call stack overflow with large arrays
const currentYear = useMemo(() => {
let max = 0;
for (const r of combinedSalesData) {
if (r.year && r.year > max) max = r.year;
}
return max > 0 ? max : new Date().getFullYear();
}, [combinedSalesData]);
// Per-market chart data: BSR avg + units sum aligned by week
const { chartData, pearsonCorrelation } = useMemo(() => {
// BSR: average topLevelBSR per week
const bsrByWeek = new Map<number, { sum: number; count: number }>();
bsrData
.filter(r => r.market === resolvedMarket && r.topLevelBSR != null)
.forEach(r => {
const e = bsrByWeek.get(r.week) ?? { sum: 0, count: 0 };
bsrByWeek.set(r.week, { sum: e.sum + r.topLevelBSR!, count: e.count + 1 });
});
// Units: sum unitsTotal per week for current year
const unitsByWeek = new Map<number, number>();
combinedSalesData
.filter(r => r.customer === resolvedMarket && r.year === currentYear && r.week)
.forEach(r => {
unitsByWeek.set(r.week, (unitsByWeek.get(r.week) ?? 0) + r.unitsTotal);
});
const allWeeks = Array.from(
new Set([...bsrByWeek.keys(), ...unitsByWeek.keys()])
).sort((a, b) => a - b);
const chartData = allWeeks.map(week => {
const bsrEntry = bsrByWeek.get(week);
return {
week: `W${String(week).padStart(2, '0')}`,
weekNum: week,
bsr: bsrEntry ? Math.round(bsrEntry.sum / bsrEntry.count) : null,
units: unitsByWeek.get(week) ?? null,
};
});
// Pearson only for weeks where both values exist
const paired = chartData.filter(d => d.bsr !== null && d.units !== null);
const r = pearsonR(
paired.map(d => d.bsr as number),
paired.map(d => d.units as number)
);
return { chartData, pearsonCorrelation: r };
}, [resolvedMarket, bsrData, combinedSalesData, currentYear]);
const config = MARKET_CONFIG[resolvedMarket] ?? { label: '?', color: '#94a3b8', flag: '🌍' };
const pearsonInfo = useMemo(() => {
if (pearsonCorrelation === null) return { text: '', label: 'Insufficient data', color: '#475569' };
const abs = Math.abs(pearsonCorrelation);
const sign = pearsonCorrelation < 0 ? 'negative' : 'positive';
const strength = abs >= 0.7 ? 'Strong' : abs >= 0.4 ? 'Moderate' : 'Weak';
// Negative r is GOOD: lower BSR (better rank) → more units
const baseColor = pearsonCorrelation < 0
? (abs >= 0.7 ? '#10b981' : abs >= 0.4 ? '#6ee7b7' : '#94a3b8')
: (abs >= 0.7 ? '#ef4444' : abs >= 0.4 ? '#fca5a5' : '#94a3b8');
return {
text: pearsonCorrelation.toFixed(2),
label: `${strength} ${sign}`,
color: baseColor,
};
}, [pearsonCorrelation]);
const tooltipFormatter = (value: unknown, name: string) => {
if (name === 'BSR') return [typeof value === 'number' ? value.toLocaleString('de-DE') : '', 'BSR Rank'];
if (name === 'Units') return [typeof value === 'number' ? value.toLocaleString('de-DE') : '', 'Units Sold'];
return [value, name];
};
return (
<div className="bg-[#161b24] border border-[#2a3040] rounded-2xl p-6 shadow-md flex flex-col gap-5">
{/* Header row */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-[17px] font-semibold text-white tracking-wide">BSR vs Units Sold</h3>
<p className="text-[13px] text-[#64748b] mt-0.5">
Weekly correlation · {currentYear} · Top-level category BSR
</p>
</div>
<button
onClick={() => setBsrInverted(v => !v)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-[#2a3040] bg-[#1f2937] text-[11px] font-semibold text-slate-300 hover:border-slate-600 hover:text-white transition-all"
>
<span className="text-base leading-none">{bsrInverted ? '↑' : '↓'}</span>
<span>BSR: {bsrInverted ? 'Lower = Top' : 'Lower = Bottom'}</span>
</button>
</div>
{/* Marketplace tabs */}
{availableMarkets.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{availableMarkets.map(m => {
const conf = MARKET_CONFIG[m];
const isActive = m === resolvedMarket;
return (
<button
key={m}
onClick={() => setActiveMarket(m)}
className="px-3.5 py-1.5 rounded-lg text-[12px] font-bold uppercase tracking-wider transition-all border"
style={
isActive
? { color: conf.color, borderColor: conf.color, backgroundColor: `${conf.color}1a` }
: { color: '#64748b', borderColor: 'transparent' }
}
onMouseEnter={e => {
if (!isActive) (e.currentTarget as HTMLButtonElement).style.color = '#cbd5e1';
}}
onMouseLeave={e => {
if (!isActive) (e.currentTarget as HTMLButtonElement).style.color = '#64748b';
}}
>
{conf.flag} {conf.label}
</button>
);
})}
</div>
)}
{/* Chart */}
{chartData.length === 0 ? (
<div className="h-[260px] flex items-center justify-center text-[#475569] text-sm italic">
No data available for {config.flag} {config.label}
</div>
) : (
<div className="h-[260px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 16, right: 54, left: 8, bottom: 4 }}>
<CartesianGrid
strokeDasharray="0"
stroke="#ffffff"
strokeOpacity={0.03}
vertical={false}
/>
<XAxis
dataKey="week"
axisLine={false}
tickLine={false}
tick={{ fill: '#64748b', fontSize: 11, fontWeight: 500 }}
dy={8}
/>
{/* Left axis: BSR rank */}
<YAxis
yAxisId="bsr"
orientation="left"
reversed={bsrInverted}
domain={['auto', 'auto']}
axisLine={false}
tickLine={false}
tick={{ fill: '#64748b', fontSize: 10 }}
tickFormatter={formatBSR}
width={48}
label={{
value: 'BSR Rank',
angle: -90,
position: 'insideLeft',
offset: 6,
style: { fill: '#475569', fontSize: 10, fontWeight: 600 },
}}
/>
{/* Right axis: units sold */}
<YAxis
yAxisId="units"
orientation="right"
axisLine={false}
tickLine={false}
tick={{ fill: '#64748b', fontSize: 10 }}
tickFormatter={(v: number) => v >= 1_000 ? `${Math.round(v / 1_000)}K` : String(v)}
width={48}
label={{
value: 'Units Sold',
angle: 90,
position: 'insideRight',
offset: 8,
style: { fill: '#475569', fontSize: 10, fontWeight: 600 },
}}
/>
<Tooltip
contentStyle={TOOLTIP_STYLE}
itemStyle={{ fontSize: '12px' }}
labelStyle={{ color: '#94a3b8', fontSize: '11px', marginBottom: '4px' }}
cursor={{ stroke: '#334155', strokeWidth: 1, strokeDasharray: '3 3' }}
formatter={tooltipFormatter}
/>
{/* Units — bars on right axis */}
<Bar
yAxisId="units"
dataKey="units"
name="Units"
fill={config.color}
fillOpacity={0.45}
radius={[3, 3, 0, 0]}
maxBarSize={36}
/>
{/* BSR — line on left axis */}
<Line
yAxisId="bsr"
type="monotone"
dataKey="bsr"
name="BSR"
stroke={config.color}
strokeWidth={2.5}
dot={{ r: 4, fill: CARD_BG, stroke: config.color, strokeWidth: 2 }}
activeDot={{ r: 6, strokeWidth: 0, fill: config.color }}
connectNulls
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
{/* Legend */}
<div className="flex items-center gap-5 justify-center text-[11px] text-[#64748b]">
<div className="flex items-center gap-1.5">
<div className="w-8 h-2.5 rounded-sm" style={{ backgroundColor: config.color, opacity: 0.55 }} />
<span>Units Sold</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-6 h-0.5 rounded" style={{ backgroundColor: config.color }} />
<div className="w-2 h-2 rounded-full border-2" style={{ borderColor: config.color, backgroundColor: CARD_BG }} />
<span>BSR Rank</span>
</div>
</div>
{/* Pearson correlation note */}
<div className="flex flex-wrap items-center gap-4 px-4 py-3 rounded-xl bg-[#111827] border border-[#1e293b]">
<div className="flex items-center gap-2.5">
<span className="text-[10px] font-black uppercase tracking-widest text-[#334155]">Pearson r</span>
<span className="text-[22px] font-black tabular-nums" style={{ color: pearsonInfo.color }}>
{pearsonInfo.text}
</span>
<span className="text-[11px] font-semibold" style={{ color: pearsonInfo.color }}>
{pearsonInfo.label}
</span>
</div>
<span className="text-[10px] text-[#334155] ml-auto">
{pearsonCorrelation !== null && pearsonCorrelation < -0.3
? '↓ Rank improvement correlates with ↑ Units sold'
: pearsonCorrelation !== null && pearsonCorrelation > 0.3
? '↑ Rank number correlates with ↑ Units — unusual'
: pearsonCorrelation !== null
? 'No strong BSRUnits relationship detected'
: ''}
</span>
</div>
</div>
);
};