139 lines
4.5 KiB
JavaScript
139 lines
4.5 KiB
JavaScript
function createRatingChart(container, history) {
|
|
if (!history || history.length === 0) {
|
|
container.textContent = '';
|
|
var empty = document.createElement('div');
|
|
empty.className = 'loading-chart';
|
|
empty.textContent = 'No rating history available';
|
|
container.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
var W = 880, H = 240;
|
|
var pad = { left: 44, right: 16, top: 20, bottom: 32 };
|
|
var chartW = W - pad.left - pad.right;
|
|
var chartH = H - pad.top - pad.bottom;
|
|
|
|
var ratings = history.map(function(h) { return h.rating; });
|
|
var minR = Math.min.apply(null, ratings) - 5;
|
|
var maxR = Math.max.apply(null, ratings) + 5;
|
|
var range = maxR - minR || 1;
|
|
|
|
function xOf(i) {
|
|
return pad.left + (i / Math.max(history.length - 1, 1)) * chartW;
|
|
}
|
|
function yOf(r) {
|
|
return pad.top + ((maxR - r) / range) * chartH;
|
|
}
|
|
|
|
var pts = history.map(function(h, i) {
|
|
return { x: xOf(i), y: yOf(h.rating), rating: h.rating, date: h.date };
|
|
});
|
|
|
|
var linePath = pts.map(function(p, i) {
|
|
return (i === 0 ? 'M' : 'L') + ' ' + p.x.toFixed(1) + ' ' + p.y.toFixed(1);
|
|
}).join(' ');
|
|
|
|
var last = pts[pts.length - 1];
|
|
var bottomY = (pad.top + chartH).toFixed(1);
|
|
var areaPath = linePath +
|
|
' L ' + last.x.toFixed(1) + ' ' + bottomY +
|
|
' L ' + pad.left.toFixed(1) + ' ' + bottomY + ' Z';
|
|
|
|
// Build SVG using DOM API to avoid innerHTML on user-supplied content
|
|
var ns = 'http://www.w3.org/2000/svg';
|
|
|
|
function el(tag, attrs) {
|
|
var e = document.createElementNS(ns, tag);
|
|
Object.keys(attrs).forEach(function(k) { e.setAttribute(k, attrs[k]); });
|
|
return e;
|
|
}
|
|
|
|
function txt(tag, attrs, text) {
|
|
var e = el(tag, attrs);
|
|
e.textContent = text;
|
|
return e;
|
|
}
|
|
|
|
var svg = el('svg', {
|
|
viewBox: '0 0 ' + W + ' ' + H,
|
|
width: '100%',
|
|
style: 'display:block;overflow:visible',
|
|
'aria-hidden': 'true'
|
|
});
|
|
|
|
// Grid lines + y-axis labels (4 ticks)
|
|
var tickCount = 4;
|
|
for (var i = 0; i <= tickCount; i++) {
|
|
var gy = pad.top + (i / tickCount) * chartH;
|
|
var gr = Math.round(maxR - (i / tickCount) * range);
|
|
svg.appendChild(el('line', {
|
|
x1: pad.left, y1: gy.toFixed(1),
|
|
x2: pad.left + chartW, y2: gy.toFixed(1),
|
|
stroke: 'var(--line)', 'stroke-width': '1', 'stroke-dasharray': '2 4'
|
|
}));
|
|
svg.appendChild(txt('text', {
|
|
x: pad.left - 8, y: (gy + 4).toFixed(1),
|
|
'text-anchor': 'end', 'font-size': '10',
|
|
'font-family': "'JetBrains Mono', monospace",
|
|
fill: 'var(--ink-3)'
|
|
}, String(gr)));
|
|
}
|
|
|
|
// Area fill (8% opacity)
|
|
svg.appendChild(el('path', {
|
|
d: areaPath, fill: 'var(--accent)', 'fill-opacity': '0.08'
|
|
}));
|
|
|
|
// Line
|
|
svg.appendChild(el('path', {
|
|
d: linePath, stroke: 'var(--accent)', 'stroke-width': '2',
|
|
fill: 'none', 'stroke-linejoin': 'round', 'stroke-linecap': 'round'
|
|
}));
|
|
|
|
// Dots
|
|
pts.forEach(function(p, i) {
|
|
var isLast = i === pts.length - 1;
|
|
if (isLast) {
|
|
svg.appendChild(el('circle', {
|
|
cx: p.x.toFixed(1), cy: p.y.toFixed(1),
|
|
r: '4', fill: 'var(--accent)', stroke: 'var(--paper)', 'stroke-width': '2'
|
|
}));
|
|
} else {
|
|
svg.appendChild(el('circle', {
|
|
cx: p.x.toFixed(1), cy: p.y.toFixed(1),
|
|
r: '3', fill: 'var(--accent)'
|
|
}));
|
|
}
|
|
});
|
|
|
|
// X-axis labels (5 evenly spaced)
|
|
var labelCount = Math.min(5, history.length);
|
|
var labelIndices = [];
|
|
if (labelCount <= 1) {
|
|
labelIndices.push(0);
|
|
} else {
|
|
for (var k = 0; k < labelCount; k++) {
|
|
labelIndices.push(Math.round(k * (history.length - 1) / (labelCount - 1)));
|
|
}
|
|
}
|
|
|
|
var seen = {};
|
|
labelIndices.forEach(function(idx) {
|
|
if (seen[idx]) return;
|
|
seen[idx] = true;
|
|
var p = pts[idx];
|
|
var d = new Date(history[idx].date);
|
|
var label = d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
|
|
svg.appendChild(txt('text', {
|
|
x: p.x.toFixed(1),
|
|
y: (pad.top + chartH + 16).toFixed(1),
|
|
'text-anchor': 'middle', 'font-size': '10',
|
|
'font-family': "'JetBrains Mono', monospace",
|
|
fill: 'var(--ink-3)'
|
|
}, label));
|
|
});
|
|
|
|
container.textContent = '';
|
|
container.appendChild(svg);
|
|
}
|