← back to Watches
src/components/LuxuryDataVisualizations.jsx
545 lines
/**
* LUXURY DATA VISUALIZATIONS
* Premium chart and data visualization components
*
* Features:
* - 3D price trend visualizations
* - Interactive timeline
* - Comparison heat maps
* - Animated statistics
* - Cinematic chart reveals
*/
import React, { useEffect, useRef, useMemo } from 'react';
import { motion } from 'framer-motion';
import { Line, Bar, Radar, Scatter } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
RadialLinearScale,
ArcElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js';
import { AnimatedNumber } from './LuxuryInteractions';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
RadialLinearScale,
ArcElement,
Title,
Tooltip,
Legend,
Filler
);
// ============================================================================
// LUXURY CHART WRAPPER with cinematic reveal
// ============================================================================
export const LuxuryChartContainer = ({ title, subtitle, children, icon, height = 400 }) => {
return (
<motion.div
initial={{ opacity: 0, y: 30, rotateX: -10 }}
whileInView={{ opacity: 1, y: 0, rotateX: 0 }}
viewport={{ once: true, margin: '-100px' }}
transition={{ duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] }}
className="
bg-white dark:bg-gray-800
rounded-2xl shadow-xl
overflow-hidden
border border-gray-200 dark:border-gray-700
"
>
{/* Header */}
<div className="px-6 py-5 bg-gradient-to-r from-omega-navy to-gray-800 text-white">
<div className="flex items-center space-x-3">
{icon && <span className="text-2xl">{icon}</span>}
<div>
<h3 className="text-xl font-bold">{title}</h3>
{subtitle && (
<p className="text-sm text-gray-300 mt-1">{subtitle}</p>
)}
</div>
</div>
</div>
{/* Chart content */}
<div className="p-6" style={{ height }}>
{children}
</div>
</motion.div>
);
};
// ============================================================================
// PREMIUM LINE CHART with gradient fill
// ============================================================================
export const PremiumLineChart = ({ data, title, yAxisLabel, xAxisLabel }) => {
const chartData = {
labels: data.labels,
datasets: [
{
label: title,
data: data.values,
borderColor: '#C8102E',
backgroundColor: (context) => {
const ctx = context.chart.ctx;
const gradient = ctx.createLinearGradient(0, 0, 0, 400);
gradient.addColorStop(0, 'rgba(200, 16, 46, 0.3)');
gradient.addColorStop(0.5, 'rgba(200, 16, 46, 0.1)');
gradient.addColorStop(1, 'rgba(200, 16, 46, 0)');
return gradient;
},
borderWidth: 3,
fill: true,
tension: 0.4,
pointRadius: 6,
pointHoverRadius: 10,
pointBackgroundColor: '#C8102E',
pointBorderColor: '#fff',
pointBorderWidth: 3,
pointHoverBackgroundColor: '#FFD700',
pointHoverBorderColor: '#C8102E',
pointHoverBorderWidth: 3,
},
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: '#1A1A2E',
titleColor: '#FFD700',
bodyColor: '#FFFFFF',
borderColor: '#C8102E',
borderWidth: 2,
padding: 16,
displayColors: false,
titleFont: {
size: 14,
weight: 'bold',
},
bodyFont: {
size: 13,
},
callbacks: {
label: (context) => {
return `$${context.parsed.y.toLocaleString()}`;
},
},
},
},
scales: {
x: {
grid: {
display: false,
},
ticks: {
color: '#9CA3AF',
font: {
size: 11,
},
},
title: {
display: !!xAxisLabel,
text: xAxisLabel,
color: '#6B7280',
font: {
size: 12,
weight: 'bold',
},
},
},
y: {
grid: {
color: 'rgba(156, 163, 175, 0.1)',
drawBorder: false,
},
ticks: {
color: '#9CA3AF',
font: {
size: 11,
},
callback: (value) => `$${(value / 1000).toFixed(0)}K`,
},
title: {
display: !!yAxisLabel,
text: yAxisLabel,
color: '#6B7280',
font: {
size: 12,
weight: 'bold',
},
},
},
},
};
return <Line data={chartData} options={options} />;
};
// ============================================================================
// COMPARISON HEAT MAP
// ============================================================================
export const ComparisonHeatMap = ({ watches }) => {
const heatmapData = useMemo(() => {
return watches.map(watch => {
const first = watch.priceHistory[0].price;
const last = watch.priceHistory[watch.priceHistory.length - 1].price;
const appreciation = ((last - first) / first) * 100;
return {
name: watch.model,
appreciation,
currentPrice: last,
series: watch.series,
};
}).sort((a, b) => b.appreciation - a.appreciation);
}, [watches]);
const getHeatColor = (appreciation) => {
if (appreciation >= 100) return 'from-green-600 to-green-700';
if (appreciation >= 50) return 'from-green-500 to-green-600';
if (appreciation >= 25) return 'from-yellow-500 to-green-500';
if (appreciation >= 0) return 'from-yellow-400 to-yellow-500';
return 'from-red-500 to-red-600';
};
return (
<div className="space-y-2">
{heatmapData.map((watch, index) => (
<motion.div
key={watch.name}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className="relative overflow-hidden rounded-lg group"
>
{/* Background bar */}
<motion.div
initial={{ scaleX: 0 }}
whileInView={{ scaleX: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: index * 0.05, ease: 'easeOut' }}
className={`
absolute inset-0 bg-gradient-to-r ${getHeatColor(watch.appreciation)}
origin-left
`}
style={{ opacity: 0.15 }}
/>
{/* Content */}
<div className="relative flex items-center justify-between p-4 hover:bg-white/5 transition-colors">
<div className="flex items-center space-x-4 flex-1 min-w-0">
<span className="text-sm font-bold text-gray-500 dark:text-gray-400 w-8 text-center">
#{index + 1}
</span>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-gray-800 dark:text-white truncate group-hover:text-omega-red transition-colors">
{watch.name}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{watch.series} • ${watch.currentPrice.toLocaleString()}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<AnimatedNumber
value={watch.appreciation}
prefix="+"
suffix="%"
decimals={2}
duration={1000 + index * 50}
/>
<div className={`px-3 py-1 rounded-full text-xs font-bold ${
watch.appreciation >= 50 ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
watch.appreciation >= 0 ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' :
'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}`}>
{watch.appreciation >= 50 ? 'Excellent' :
watch.appreciation >= 25 ? 'Very Good' :
watch.appreciation >= 0 ? 'Good' : 'Fair'}
</div>
</div>
</div>
</motion.div>
))}
</div>
);
};
// ============================================================================
// INTERACTIVE OMEGA TIMELINE
// ============================================================================
export const OmegaTimeline = ({ watches }) => {
const timelineEvents = useMemo(() => {
const events = watches.map(watch => ({
year: watch.yearIntroduced,
model: watch.model,
series: watch.series,
reference: watch.reference,
}));
return events.sort((a, b) => a.year - b.year);
}, [watches]);
const decades = useMemo(() => {
const years = timelineEvents.map(e => e.year);
const minYear = Math.min(...years);
const maxYear = Math.max(...years);
const result = [];
for (let year = Math.floor(minYear / 10) * 10; year <= maxYear; year += 10) {
result.push(year);
}
return result;
}, [timelineEvents]);
return (
<div className="relative py-8">
{/* Timeline line */}
<div className="absolute left-1/2 top-0 bottom-0 w-1 bg-gradient-to-b from-omega-red via-omega-gold to-omega-red" />
{/* Events */}
<div className="space-y-12">
{timelineEvents.map((event, index) => {
const isLeft = index % 2 === 0;
return (
<motion.div
key={`${event.year}-${event.reference}`}
initial={{ opacity: 0, x: isLeft ? -50 : 50 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: '-100px' }}
transition={{ duration: 0.6, delay: index * 0.1 }}
className={`flex items-center ${isLeft ? 'flex-row' : 'flex-row-reverse'}`}
>
{/* Content card */}
<div className={`w-5/12 ${isLeft ? 'text-right pr-8' : 'text-left pl-8'}`}>
<motion.div
whileHover={{ scale: 1.05, rotateY: 5 }}
className="
bg-white dark:bg-gray-800
rounded-xl shadow-lg p-6
border-2 border-transparent
hover:border-omega-red
transition-all duration-300
cursor-pointer
"
>
<span className="text-sm font-bold text-omega-red">{event.year}</span>
<h4 className="text-lg font-bold text-gray-800 dark:text-white mt-1">
{event.model}
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{event.series}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2 font-mono">
{event.reference}
</p>
</motion.div>
</div>
{/* Timeline dot */}
<motion.div
initial={{ scale: 0 }}
whileInView={{ scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: index * 0.1 + 0.3 }}
className="
relative z-10
w-6 h-6 rounded-full
bg-omega-red
border-4 border-white dark:border-gray-900
shadow-lg
"
>
<motion.div
animate={{ scale: [1, 1.5, 1] }}
transition={{
duration: 2,
repeat: Infinity,
delay: index * 0.2,
}}
className="absolute inset-0 rounded-full bg-omega-red/30"
/>
</motion.div>
{/* Empty space on other side */}
<div className="w-5/12" />
</motion.div>
);
})}
</div>
</div>
);
};
// ============================================================================
// RADIAL PERFORMANCE CHART
// ============================================================================
export const RadialPerformanceChart = ({ watch }) => {
const specs = watch.specifications;
const data = {
labels: [
'Value Appreciation',
'Brand Heritage',
'Build Quality',
'Movement',
'Rarity',
'Condition',
],
datasets: [
{
label: watch.model,
data: [
Math.min(((watch.priceHistory[watch.priceHistory.length - 1].price / watch.priceHistory[0].price - 1) * 100), 100),
85, // Brand heritage (Omega is premium)
90, // Build quality
specs?.movement?.includes('Co-Axial') ? 95 : 80,
70, // Rarity (example)
90, // Condition (example - could be from data)
],
backgroundColor: 'rgba(200, 16, 46, 0.2)',
borderColor: '#C8102E',
borderWidth: 3,
pointBackgroundColor: '#C8102E',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#FFD700',
pointHoverBorderColor: '#C8102E',
pointRadius: 5,
pointHoverRadius: 8,
},
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
scales: {
r: {
min: 0,
max: 100,
ticks: {
stepSize: 20,
backdropColor: 'transparent',
color: '#9CA3AF',
font: {
size: 10,
},
},
grid: {
color: 'rgba(156, 163, 175, 0.2)',
},
pointLabels: {
color: '#6B7280',
font: {
size: 12,
weight: 'bold',
},
},
},
},
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: '#1A1A2E',
titleColor: '#FFD700',
bodyColor: '#FFFFFF',
borderColor: '#C8102E',
borderWidth: 2,
padding: 12,
callbacks: {
label: (context) => {
return `${context.label}: ${context.parsed.r.toFixed(0)}%`;
},
},
},
},
};
return <Radar data={data} options={options} />;
};
// ============================================================================
// ANIMATED STAT CARD
// ============================================================================
export const AnimatedStatCard = ({ title, value, prefix = '', suffix = '', icon, trend, color = 'omega-red' }) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
whileHover={{ y: -5, boxShadow: '0 20px 40px rgba(0,0,0,0.1)' }}
className="
bg-white dark:bg-gray-800
rounded-2xl shadow-lg p-6
border border-gray-200 dark:border-gray-700
relative overflow-hidden
"
>
{/* Background gradient */}
<div className={`absolute top-0 right-0 w-32 h-32 bg-${color}/5 rounded-full blur-3xl`} />
<div className="relative z-10">
<div className="flex items-start justify-between mb-4">
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">
{title}
</span>
{icon && (
<div className={`p-3 bg-${color}/10 rounded-xl`}>
<span className="text-2xl">{icon}</span>
</div>
)}
</div>
<div className="flex items-baseline space-x-2">
<span className={`text-4xl font-bold text-${color}`}>
<AnimatedNumber value={value} prefix={prefix} suffix={suffix} duration={1500} />
</span>
{trend && (
<span className={`text-sm font-semibold ${
trend > 0 ? 'text-green-600' : 'text-red-600'
}`}>
{trend > 0 ? '↑' : '↓'} {Math.abs(trend)}%
</span>
)}
</div>
</div>
</motion.div>
);
};
export default {
LuxuryChartContainer,
PremiumLineChart,
ComparisonHeatMap,
OmegaTimeline,
RadialPerformanceChart,
AnimatedStatCard,
};