214 lines
6.4 KiB
JavaScript
214 lines
6.4 KiB
JavaScript
|
|
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();
|