function createRatingChart(container, history, opts) { opts = opts || {}; 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 = opts.w || 880; var H = opts.h || 240; var pad = opts.padding || { left: 44, right: 16, top: 20, bottom: 32 }; var tickCount = opts.tickCount !== undefined ? opts.tickCount : 4; var xLabelCount = opts.xLabelCount !== undefined ? opts.xLabelCount : 5; var dotR = opts.dotR !== undefined ? opts.dotR : 3; var lastDotR = opts.lastDotR !== undefined ? opts.lastDotR : 4; 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 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: String(lastDotR), 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: String(dotR), fill: 'var(--accent)' })); } }); // X-axis labels (evenly spaced) var labelCount = Math.min(xLabelCount, 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); }