/* TCG Study — interactive price chart
Single-series line with crosshair, tooltip, keyboard readout, range windows
and a table view. Dark-surface only (the desk has no light mode).
Series color: brand gold #c9962a — contrast vs panel #131a28 passes >= 3:1.
Verified vs estimated closes carry a shape difference (filled vs ring), so
the distinction never rests on color alone.
*/
(function (global) {
'use strict';
var GOLD = '#c9962a';
var SURFACE = '#131a28';
var GRID = '#232d40';
var INK_MUTED = '#8a94a6';
var INK = '#e4e0d6';
var CSS = [
'.tcgc{position:relative;width:100%}',
'.tcgc svg{display:block;width:100%;height:auto;touch-action:none}',
'.tcgc svg:focus{outline:1px solid ' + GOLD + ';outline-offset:3px}',
'.tcgc-bar{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:0 0 12px;',
'font-family:ui-monospace,Menlo,monospace;font-size:10px;letter-spacing:.16em;text-transform:uppercase}',
'.tcgc-bar button{background:transparent;border:1px solid ' + GRID + ';color:' + INK_MUTED + ';',
'border-radius:4px;padding:5px 9px;cursor:pointer;font:inherit;letter-spacing:inherit}',
'.tcgc-bar button:hover{color:' + GOLD + ';border-color:' + GOLD + '}',
'.tcgc-bar button[aria-pressed="true"]{color:#0c111c;background:' + GOLD + ';border-color:' + GOLD + ';font-weight:700}',
'.tcgc-spacer{flex:1}',
'.tcgc-key{color:' + INK_MUTED + ';letter-spacing:.1em;display:flex;gap:14px;align-items:center}',
'.tcgc-key i{font-style:normal}',
'.tcgc-tip{position:absolute;pointer-events:none;opacity:0;transition:opacity .09s;',
'background:#0a0f18;border:1px solid ' + GRID + ';border-radius:6px;padding:9px 11px;',
'font-family:ui-monospace,Menlo,monospace;min-width:132px;max-width:250px;z-index:5;',
'box-shadow:0 6px 20px rgba(0,0,0,.55)}',
'.tcgc-tip.on{opacity:1}',
'.tcgc-tip .v{color:' + INK + ';font-size:16px;font-weight:700;letter-spacing:-.01em;display:flex;align-items:center;gap:7px}',
'.tcgc-tip .v s{display:inline-block;width:14px;height:2px;background:' + GOLD + ';text-decoration:none;flex:none}',
'.tcgc-tip .l{color:' + INK_MUTED + ';font-size:10px;letter-spacing:.16em;text-transform:uppercase;margin-top:5px}',
'.tcgc-tip .n{color:' + INK_MUTED + ';font-size:11px;line-height:1.5;margin-top:7px;border-top:1px solid ' + GRID + ';padding-top:6px}',
'.tcgc-table{width:100%;border-collapse:collapse;margin-top:14px;font-family:ui-monospace,Menlo,monospace;font-size:12px}',
'.tcgc-table th{text-align:left;color:' + INK_MUTED + ';font-size:10px;letter-spacing:.16em;text-transform:uppercase;',
'font-weight:400;padding:7px 10px 7px 0;border-bottom:1px solid ' + GRID + '}',
'.tcgc-table td{padding:7px 10px 7px 0;border-bottom:1px solid rgba(35,45,64,.55);color:' + INK + '}',
'.tcgc-table td.m{color:' + INK_MUTED + '}',
'.tcgc-empty{color:' + INK_MUTED + ';font-family:ui-monospace,Menlo,monospace;font-size:12px;',
'letter-spacing:.14em;text-transform:uppercase;padding:52px 0;text-align:center}'
].join('');
function injectCSS() {
if (document.getElementById('tcgc-css')) return;
var s = document.createElement('style');
s.id = 'tcgc-css';
s.textContent = CSS;
document.head.appendChild(s);
}
var NS = 'http://www.w3.org/2000/svg';
function el(name, attrs) {
var n = document.createElementNS(NS, name);
for (var k in attrs) if (attrs.hasOwnProperty(k)) n.setAttribute(k, attrs[k]);
return n;
}
function axisMoney(v, span) {
var dec = span < 20 ? 2 : 0;
return '$' + v.toLocaleString(undefined, { minimumFractionDigits: dec, maximumFractionDigits: dec });
}
function money(v) {
return '$' + v.toLocaleString(undefined, {
minimumFractionDigits: v < 100 ? 2 : 0,
maximumFractionDigits: v < 100 ? 2 : 0
});
}
function TCGChart(host, opts) {
if (!host) return;
injectCSS();
opts = opts || {};
var all = (opts.points || []).filter(function (p) { return p && p.v > 0; });
var labels = opts.labels || [];
host.className = (host.className || '') + ' tcgc';
host.textContent = '';
if (!all.length) {
var e = document.createElement('div');
e.className = 'tcgc-empty';
e.textContent = 'No verified series — tracking not current';
host.appendChild(e);
return;
}
// Labels rarely map 1:1 to points (17 axis marks against 64 closes, say).
// Only claim a per-point label when the counts actually line up; otherwise
// the supplied labels are axis ticks spread across the series, and each
// point carries no invented date. Its note already names the source month.
var exact = labels.length === all.length || labels.length === all.length - 1;
if (exact) {
var lab = labels.slice();
while (lab.length < all.length) lab.push('Live');
all.forEach(function (p, i) { p.label = lab[i]; });
} else {
all.forEach(function (p) { p.label = ''; });
}
// ---- controls -------------------------------------------------------
var bar = document.createElement('div');
bar.className = 'tcgc-bar';
var windows = [];
[6, 12, 24].forEach(function (n) { if (all.length > n) windows.push(n); });
windows.push(all.length);
var state = { win: all.length, idx: -1, table: false, log: false };
var winBtns = windows.map(function (n) {
var b = document.createElement('button');
b.type = 'button';
b.textContent = n === all.length ? 'All' : 'Last ' + n;
b.setAttribute('aria-pressed', String(n === state.win));
b.onclick = function () {
state.win = n; state.idx = -1;
winBtns.forEach(function (o, j) { o.setAttribute('aria-pressed', String(windows[j] === n)); });
draw();
};
bar.appendChild(b);
return b;
});
var spacer = document.createElement('span');
spacer.className = 'tcgc-spacer';
bar.appendChild(spacer);
var key = document.createElement('span');
key.className = 'tcgc-key';
key.innerHTML = '● verified close○ estimated';
bar.appendChild(key);
var vals0 = all.map(function (p) { return p.v; });
var spread = Math.max.apply(null, vals0) / Math.max(0.01, Math.min.apply(null, vals0));
if (spread >= 4) {
var logBtn = document.createElement('button');
logBtn.type = 'button';
logBtn.textContent = 'Log scale';
logBtn.setAttribute('aria-pressed', 'false');
logBtn.title = 'Log scale keeps early years readable when a price has multiplied';
logBtn.onclick = function () {
state.log = !state.log;
logBtn.setAttribute('aria-pressed', String(state.log));
draw();
};
bar.appendChild(logBtn);
}
var tblBtn = document.createElement('button');
tblBtn.type = 'button';
tblBtn.textContent = 'Table';
tblBtn.setAttribute('aria-pressed', 'false');
tblBtn.onclick = function () {
state.table = !state.table;
tblBtn.setAttribute('aria-pressed', String(state.table));
tableWrap.style.display = state.table ? '' : 'none';
};
bar.appendChild(tblBtn);
host.appendChild(bar);
// ---- svg shell ------------------------------------------------------
var W = 1000, H = 380, PL = 64, PR = 20, PT = 20, PB = 36;
var svg = el('svg', {
viewBox: '0 0 ' + W + ' ' + H, role: 'img', tabindex: '0',
'aria-label': (opts.title ? opts.title + ' — ' : '') + 'price history, ' + all.length + ' points. Use arrow keys to read values.'
});
host.appendChild(svg);
var tip = document.createElement('div');
tip.className = 'tcgc-tip';
host.appendChild(tip);
var tableWrap = document.createElement('div');
tableWrap.style.display = 'none';
host.appendChild(tableWrap);
var view = [], X = null, Y = null;
function draw() {
svg.textContent = '';
view = all.slice(Math.max(0, all.length - state.win));
var vals = view.map(function (p) { return p.v; });
var mn = Math.min.apply(null, vals), mx = Math.max.apply(null, vals);
var pad = (mx - mn) * 0.08 || (mx * 0.06) || 1;
var lo = mn - pad, hi = mx + pad;
if (lo < 0) lo = 0;
var useLog = state.log && mn > 0 && (mx / mn) >= 4;
var f = useLog ? Math.log : function (x) { return x; };
if (useLog) { lo = mn / 1.08; hi = mx * 1.08; }
var flo = f(lo), fhi = f(hi), rg = (fhi - flo) || 1;
X = function (i) { return view.length === 1 ? (PL + (W - PL - PR) / 2) : PL + (W - PL - PR) * i / (view.length - 1); };
Y = function (v) { return H - PB - (H - PT - PB) * ((f(v) - flo) / rg); };
// grid + y axis
for (var g = 0; g <= 4; g++) {
var y = PT + (H - PT - PB) * g / 4;
svg.appendChild(el('line', { x1: PL, y1: y, x2: W - PR, y2: y, stroke: GRID, 'stroke-width': 1 }));
var tv = useLog ? Math.exp(fhi - rg * g / 4) : (hi - rg * g / 4);
var t = el('text', { x: PL - 10, y: y + 4, fill: INK_MUTED, 'text-anchor': 'end',
'font-family': 'ui-monospace,Menlo,monospace', 'font-size': 11 });
t.textContent = axisMoney(tv, hi - lo);
svg.appendChild(t);
}
// area fill
var defs = el('defs', {});
var grad = el('linearGradient', { id: 'tcgcg', x1: '0', y1: '0', x2: '0', y2: '1' });
grad.appendChild(el('stop', { offset: '0', 'stop-color': GOLD, 'stop-opacity': '.20' }));
grad.appendChild(el('stop', { offset: '1', 'stop-color': GOLD, 'stop-opacity': '0' }));
defs.appendChild(grad); svg.appendChild(defs);
var d = view.map(function (p, i) { return (i ? 'L' : 'M') + X(i) + ' ' + Y(p.v); }).join(' ');
svg.appendChild(el('path', {
d: d + ' L' + X(view.length - 1) + ' ' + (H - PB) + ' L' + X(0) + ' ' + (H - PB) + ' Z',
fill: 'url(#tcgcg)', stroke: 'none'
}));
svg.appendChild(el('path', { d: d, fill: 'none', stroke: GOLD, 'stroke-width': 2,
'stroke-linejoin': 'round', 'stroke-linecap': 'round' }));
// x axis — thinned so ticks never collide
var ticks = [];
if (exact) {
var step = Math.max(1, Math.ceil(view.length / 7));
view.forEach(function (p, i) {
if (i % step === 0 || i === view.length - 1) ticks.push({ x: X(i), text: p.label });
});
} else if (labels.length) {
var want = Math.min(7, labels.length);
for (var q = 0; q < want; q++) {
var li = Math.round(q * (labels.length - 1) / (want - 1 || 1));
ticks.push({ x: PL + (W - PL - PR) * (li / (labels.length - 1 || 1)), text: labels[li] });
}
}
ticks.forEach(function (tk) {
var t = el('text', { x: tk.x, y: H - 12, fill: INK_MUTED, 'text-anchor': 'middle',
'font-family': 'ui-monospace,Menlo,monospace', 'font-size': 11 });
t.textContent = tk.text;
svg.appendChild(t);
});
// crosshair (hidden until hover/focus)
var cross = el('line', { y1: PT, y2: H - PB, stroke: GOLD, 'stroke-width': 1,
'stroke-dasharray': '3 3', opacity: '0' });
cross.setAttribute('data-cross', '1');
svg.appendChild(cross);
// markers — filled = verified close, ring = estimated
view.forEach(function (p, i) {
var m = p.solid
? el('circle', { cx: X(i), cy: Y(p.v), r: 4, fill: GOLD, stroke: SURFACE, 'stroke-width': 2 })
: el('circle', { cx: X(i), cy: Y(p.v), r: 4, fill: SURFACE, stroke: GOLD, 'stroke-width': 2 });
m.setAttribute('data-m', i);
svg.appendChild(m);
});
// generous transparent hit layer
var hit = el('rect', { x: 0, y: 0, width: W, height: H, fill: 'transparent' });
svg.appendChild(hit);
buildTable();
hideTip();
}
function nearest(clientX) {
var r = svg.getBoundingClientRect();
var px = (clientX - r.left) / r.width * W;
var best = 0, bd = Infinity;
for (var i = 0; i < view.length; i++) {
var dd = Math.abs(X(i) - px);
if (dd < bd) { bd = dd; best = i; }
}
return best;
}
function showTip(i) {
if (i < 0 || i >= view.length) return;
state.idx = i;
var p = view[i];
var cross = svg.querySelector('[data-cross]');
if (cross) { cross.setAttribute('x1', X(i)); cross.setAttribute('x2', X(i)); cross.setAttribute('opacity', '.7'); }
Array.prototype.forEach.call(svg.querySelectorAll('[data-m]'), function (m) {
var on = +m.getAttribute('data-m') === i;
m.setAttribute('r', on ? 6 : 4);
});
tip.textContent = '';
var v = document.createElement('div'); v.className = 'v';
var s = document.createElement('s'); v.appendChild(s);
v.appendChild(document.createTextNode(money(p.v)));
tip.appendChild(v);
var l = document.createElement('div'); l.className = 'l';
l.textContent = (p.label ? p.label + ' · ' : '') + (p.solid ? 'verified close' : 'estimated');
tip.appendChild(l);
if (p.tip) {
var n = document.createElement('div'); n.className = 'n';
n.textContent = p.tip;
tip.appendChild(n);
}
var r = svg.getBoundingClientRect();
var scale = r.width / W;
var left = X(i) * scale;
tip.classList.add('on');
var tw = tip.offsetWidth;
if (left + tw + 16 > r.width) left -= tw + 14; else left += 14;
tip.style.left = Math.max(0, left) + 'px';
tip.style.top = Math.max(0, Y(p.v) * scale - 12) + 'px';
}
function hideTip() {
state.idx = -1;
tip.classList.remove('on');
var cross = svg.querySelector('[data-cross]');
if (cross) cross.setAttribute('opacity', '0');
Array.prototype.forEach.call(svg.querySelectorAll('[data-m]'), function (m) { m.setAttribute('r', 4); });
}
function buildTable() {
tableWrap.textContent = '';
var t = document.createElement('table');
t.className = 'tcgc-table';
var head = document.createElement('tr');
[exact ? 'Point' : '#', 'Close', 'Source', 'Note'].forEach(function (h) {
var th = document.createElement('th'); th.textContent = h; head.appendChild(th);
});
t.appendChild(head);
view.forEach(function (p, ri) {
var tr = document.createElement('tr');
[exact ? p.label : String(ri + 1), money(p.v), p.solid ? 'Verified' : 'Estimated', p.tip || '—'].forEach(function (c, ci) {
var td = document.createElement('td');
if (ci > 1) td.className = 'm';
td.textContent = c;
tr.appendChild(td);
});
t.appendChild(tr);
});
tableWrap.appendChild(t);
}
svg.addEventListener('pointermove', function (e) { showTip(nearest(e.clientX)); });
svg.addEventListener('pointerleave', hideTip);
svg.addEventListener('blur', hideTip);
svg.addEventListener('focus', function () { showTip(state.idx < 0 ? view.length - 1 : state.idx); });
svg.addEventListener('keydown', function (e) {
var i = state.idx < 0 ? view.length - 1 : state.idx;
if (e.key === 'ArrowRight') { showTip(Math.min(view.length - 1, i + 1)); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { showTip(Math.max(0, i - 1)); e.preventDefault(); }
else if (e.key === 'Home') { showTip(0); e.preventDefault(); }
else if (e.key === 'End') { showTip(view.length - 1); e.preventDefault(); }
else if (e.key === 'Escape') { hideTip(); }
});
draw();
global.addEventListener('resize', function () { if (state.idx >= 0) showTip(state.idx); });
return { redraw: draw };
}
global.TCGChart = TCGChart;
})(window);