Files
pokemon/src/assets/js/priceChart.js

214 lines
6.4 KiB
JavaScript
Raw Normal View History

import Chart from 'chart.js/auto';
const CONDITIONS = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
// Match the $tiers colors from your SCSS exactly
const CONDITION_COLORS = {
"Near Mint": { active: 'rgba(156, 204, 102, 1)', muted: 'rgba(156, 204, 102, 0.67)' },
"Lightly Played": { active: 'rgba(211, 225, 86, 1)', muted: 'rgba(211, 225, 86, 0.67)' },
"Moderately Played": { active: 'rgba(255, 238, 87, 1)', muted: 'rgba(255, 238, 87, 0.67)' },
"Heavily Played": { active: 'rgba(255, 201, 41, 1)', muted: 'rgba(255, 201, 41, 0.67)' },
"Damaged": { active: 'rgba(255, 167, 36, 1)', muted: 'rgba(255, 167, 36, 0.67)' },
};
const RANGE_DAYS = { '1m': 30, '3m': 90 };
let chartInstance = null;
let allHistory = [];
let activeCondition = "Near Mint";
let activeRange = '1m';
function formatDate(dateStr) {
const [year, month, day] = dateStr.split('-');
const d = new Date(Number(year), Number(month) - 1, Number(day));
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function buildChartData(history, rangeKey) {
const cutoff = new Date(Date.now() - RANGE_DAYS[rangeKey] * 86_400_000);
const filtered = history.filter(r => new Date(r.calculatedAt) >= cutoff);
const allDates = [...new Set(filtered.map(r => r.calculatedAt))]
.sort((a, b) => new Date(a) - new Date(b));
const labels = allDates.map(formatDate);
const lookup = {};
for (const row of filtered) {
if (!lookup[row.condition]) lookup[row.condition] = {};
lookup[row.condition][row.calculatedAt] = Number(row.marketPrice);
}
const datasets = CONDITIONS.map(condition => {
const isActive = condition === activeCondition;
const colors = CONDITION_COLORS[condition];
const data = allDates.map(date => lookup[condition]?.[date] ?? null);
return {
label: condition,
data,
borderColor: isActive ? colors.active : colors.muted,
borderWidth: isActive ? 2.5 : 1,
pointRadius: isActive ? 3 : 0,
pointHoverRadius: isActive ? 5 : 3,
pointBackgroundColor: isActive ? colors.active : colors.muted,
tension: 0.3,
fill: false,
spanGaps: true,
order: isActive ? 0 : 1,
};
});
return { labels, datasets };
}
function updateChart() {
if (!chartInstance) return;
const { labels, datasets } = buildChartData(allHistory, activeRange);
chartInstance.data.labels = labels;
chartInstance.data.datasets = datasets;
chartInstance.update('none');
}
function initPriceChart(canvas) {
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
try {
allHistory = JSON.parse(canvas.dataset.history ?? '[]');
} catch (err) {
console.error('Failed to parse price history:', err);
return;
}
if (!allHistory.length) {
console.warn('No price history data for this card');
return;
}
const { labels, datasets } = buildChartData(allHistory, activeRange);
chartInstance = new Chart(canvas.getContext('2d'), {
type: 'line',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.85)',
titleColor: 'rgba(255, 255, 255, 0.9)',
bodyColor: 'rgba(255, 255, 255, 0.75)',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
padding: 10,
callbacks: {
labelColor: (ctx) => {
const colors = CONDITION_COLORS[ctx.dataset.label];
return {
borderColor: colors.active,
backgroundColor: colors.active,
};
},
label: (ctx) => {
const isActive = ctx.dataset.label === activeCondition;
const price = ctx.parsed.y != null ? `$${ctx.parsed.y.toFixed(2)}` : '—';
return isActive
? ` ${ctx.dataset.label}: ${price}`
: ` ${ctx.dataset.label}: ${price}`;
}
}
}
},
scales: {
x: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: {
color: 'rgba(255, 255, 255, 0.4)',
maxTicksLimit: 6,
maxRotation: 0,
},
border: { color: 'rgba(255, 255, 255, 0.1)' },
},
y: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: {
color: 'rgba(255, 255, 255, 0.4)',
callback: (val) => `$${Number(val).toFixed(2)}`,
},
border: { color: 'rgba(255, 255, 255, 0.1)' },
}
}
}
});
}
function setupObserver() {
const modal = document.getElementById('cardModal');
if (!modal) {
console.error('cardModal element not found');
return;
}
const observer = new MutationObserver(() => {
const canvas = document.getElementById('priceHistoryChart');
if (!canvas) return;
// Disconnect immediately so tab switches don't retrigger initPriceChart
observer.disconnect();
activeCondition = "Near Mint";
activeRange = '1m';
document.querySelectorAll('.price-range-btn').forEach(b => {
b.classList.toggle('active', b.dataset.range === '1m');
});
initPriceChart(canvas);
});
observer.observe(modal, { childList: true, subtree: true });
modal.addEventListener('hidden.bs.modal', () => {
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
allHistory = [];
// Reconnect so the next card open is detected
observer.observe(modal, { childList: true, subtree: true });
});
}
document.addEventListener('shown.bs.tab', (e) => {
const target = e.target?.getAttribute('data-bs-target');
const conditionMap = {
'#nav-nm': 'Near Mint',
'#nav-lp': 'Lightly Played',
'#nav-mp': 'Moderately Played',
'#nav-hp': 'Heavily Played',
'#nav-dmg': 'Damaged',
};
if (target && conditionMap[target]) {
activeCondition = conditionMap[target];
updateChart();
}
});
document.addEventListener('click', (e) => {
const btn = e.target?.closest('.price-range-btn');
if (!btn) return;
document.querySelectorAll('.price-range-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeRange = btn.dataset.range ?? '1m';
updateChart();
});
setupObserver();