/* ========================================================================== Brew Log — charts Uses the vendored Chart.js (static/js/chart.umd.js) — no CDN at runtime, so the container works on an isolated network. ========================================================================== */ (function () { "use strict"; if (typeof Chart === "undefined") { console.warn("Chart.js is not loaded; charts are disabled."); return; } var ACCENT = "#be7f39"; var ACCENT_BRIGHT = "#dda05c"; var MUTED = "#afafaf"; var GRID = "rgba(175, 175, 175, 0.13)"; var WARN = "#e8a33d"; Chart.defaults.color = MUTED; Chart.defaults.font.family = 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; Chart.defaults.font.size = 11; Chart.defaults.borderColor = GRID; function withAlpha(hex, alpha) { var r = parseInt(hex.slice(1, 3), 16); var g = parseInt(hex.slice(3, 5), 16); var b = parseInt(hex.slice(5, 7), 16); return "rgba(" + r + "," + g + "," + b + "," + alpha + ")"; } function baseOptions(extra) { var options = { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { boxWidth: 10, boxHeight: 10, usePointStyle: true } } }, scales: { x: { grid: { color: GRID }, ticks: { maxRotation: 0, autoSkipPadding: 12 } }, y: { grid: { color: GRID } } } }; if (extra) { Object.keys(extra).forEach(function (key) { options[key] = extra[key]; }); } return options; } /* ------------------------------------------------------- sparklines */ function drawSparklines() { var canvases = document.querySelectorAll("canvas.sparkline"); for (var i = 0; i < canvases.length; i++) { (function (canvas) { var labels = JSON.parse(canvas.getAttribute("data-labels") || "[]"); var values = JSON.parse(canvas.getAttribute("data-values") || "[]"); var points = []; for (var j = 0; j < values.length; j++) { if (values[j] !== null && values[j] !== undefined) { points.push({ x: labels[j], y: values[j] }); } } if (!points.length) { return; } new Chart(canvas.getContext("2d"), { type: "line", data: { datasets: [{ data: points, borderColor: ACCENT_BRIGHT, backgroundColor: withAlpha(ACCENT, 0.18), borderWidth: 2, pointRadius: points.length > 12 ? 0 : 2, fill: true, tension: 0.3 }] }, options: baseOptions({ plugins: { legend: { display: false }, tooltip: { callbacks: { label: function (ctx) { return ctx.parsed.y + " (" + ctx.parsed.x + ")"; } } } }, scales: { x: { display: false, grid: { display: false } }, y: { display: false, grid: { display: false } } } }) }); })(canvases[i]); } } /* --------------------------------------------------- run detail chart */ function drawRunChart() { var canvas = document.getElementById("run-chart"); if (!canvas) { return; } // The run id comes from a data attribute rather than a global, so the // page needs no inline script (a strict CSP forbids inline scripts). var runId = canvas.getAttribute("data-run-id"); if (!runId) { return; } fetch("/api/runs/" + encodeURIComponent(runId), { headers: { "Accept": "application/json" } }) .then(function (response) { if (!response.ok) { throw new Error("run fetch failed"); } return response.json(); }) .then(function (run) { var points = []; (run.readings || []).forEach(function (r) { if (r.sg !== null && r.sg !== undefined) { points.push({ x: r.date, y: r.sg }); } }); if (!points.length) { return; } new Chart(canvas.getContext("2d"), { type: "line", data: { datasets: [{ label: "Gravity", data: points, borderColor: ACCENT, backgroundColor: withAlpha(ACCENT, 0.12), borderWidth: 2, pointRadius: 3, pointBackgroundColor: ACCENT, fill: true, tension: 0.25 }] }, options: baseOptions({ plugins: { legend: { display: false }, tooltip: { callbacks: { label: function (ctx) { return ctx.parsed.y + " points (" + ctx.parsed.x + ")"; } } } }, scales: { x: { // The dates are strings, so this axis stays // categorical - but it must be declared, // because Chart.js would otherwise infer // something else from the {x, y} points. type: "category", ticks: { maxRotation: 0, autoSkipPadding: 12 } }, y: { // NOT reversed - see the fermentation curve. // Gravity falls as the beer ferments, so the // line must descend left to right. title: { display: true, text: "Specific gravity (points)" } } } }) }); }) .catch(function (err) { console.warn(err); }); } /* ------------------------------------------------------ history charts */ function drawAbvChart() { var canvas = document.getElementById("abv-chart"); if (!canvas) { return; } fetchRuns() .then(function (runs) { // Only finished brews have a measured ABV. An unfinished run // has a projection, not a measurement, so it is not plotted // here. // // A suspect run is left out too. Its ABV comes from an // impossible reading (run 12 reads 18.64%), and plotting it // stretched the y axis so that every real bar was squashed // into the bottom of the plot - while the "Strongest" tile // on the same page correctly showed 8.53%. The chart and // the tile contradicted each other. var completed = runs.filter(function (r) { return !r.suspect && r.abv !== null && r.abv !== undefined; }); if (!completed.length) { return; } new Chart(canvas.getContext("2d"), { type: "bar", data: { labels: completed.map(function (r) { return r.canonical_name || ("Run " + r.beer_run_id); }), datasets: [{ label: "ABV %", data: completed.map(function (r) { return r.abv; }), backgroundColor: withAlpha(ACCENT, 0.65), borderColor: ACCENT, borderWidth: 1, borderRadius: 4 }] }, options: baseOptions({ plugins: { legend: { display: false }, tooltip: { callbacks: { label: function (ctx) { return ctx.parsed.y.toFixed(2) + "% ABV"; } } } }, scales: { y: { beginAtZero: true, title: { display: true, text: "% ABV" } }, x: { // The names are long and the chart is narrow, // so the labels are trimmed above and // rotated. autoSkip keeps them from // overlapping; the tooltip carries the // full name for every bar. ticks: { maxRotation: 45, minRotation: 45, autoSkip: true, autoSkipPadding: 8 } } } }) }); }) .catch(function (err) { console.warn(err); }); } function drawMonthsChart() { var canvas = document.getElementById("months-chart"); if (!canvas) { return; } fetchRuns() .then(function (runs) { var counts = {}; runs.forEach(function (r) { var stamp = r.final_date || r.first_date; if (!stamp) { return; } var month = stamp.slice(0, 7); counts[month] = (counts[month] || 0) + 1; }); var months = Object.keys(counts).sort(); if (!months.length) { return; } // Include the months with no brews, with a zero. Leaving the // gaps out would space the busy months evenly on the axis and // make the chart read as continuous activity. var filled = []; var cursor = months[0]; var last = months[months.length - 1]; while (cursor <= last) { filled.push(cursor); var year = parseInt(cursor.slice(0, 4), 10); var month = parseInt(cursor.slice(5, 7), 10); month += 1; if (month > 12) { month = 1; year += 1; } cursor = year + "-" + (month < 10 ? "0" + month : month); } months = filled; new Chart(canvas.getContext("2d"), { type: "line", data: { labels: months, datasets: [{ label: "Brews", data: months.map(function (m) { return counts[m]; }), borderColor: ACCENT, backgroundColor: withAlpha(ACCENT, 0.12), borderWidth: 2, pointRadius: 3, pointBackgroundColor: ACCENT, fill: true, tension: 0.3 }] }, options: baseOptions({ plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } } }) }); }) .catch(function (err) { console.warn(err); }); } /* ------------------------------------------------ attenuation by brew */ function drawAttenuationChart() { var canvas = document.getElementById("attenuation-chart"); if (!canvas) { return; } fetchRuns() .then(function (runs) { // A run with an impossible reading has no attenuation, and // is flagged as suspect. Both are excluded, so one bad // reading cannot become a bar in the chart. // // Active runs are excluded as well. Their attenuation is // measured against their CURRENT gravity, so a brew that // has just started shows a 0% bar - which is not a result, // it is the absence of one. The chart is about finished // brews, and it sits above the table of finished brews. var done = runs.filter(function (r) { return !r.suspect && !r.active && r.attenuation !== null && r.attenuation !== undefined; }); if (!done.length) { return; } new Chart(canvas.getContext("2d"), { type: "bar", data: { labels: done.map(function (r) { var name = r.canonical_name || ("Run " + r.beer_run_id); // Long names were clipped at the left edge of // the plot when rotated. Trim them to a // readable length; the tooltip and the table // below carry the full name. if (name.length > 18) { name = name.slice(0, 17) + "\u2026"; } return name; }), datasets: [{ label: "Attenuation %", data: done.map(function (r) { return r.attenuation; }), backgroundColor: withAlpha(ACCENT, 0.65), borderColor: ACCENT, borderWidth: 1, borderRadius: 4 }] }, options: baseOptions({ plugins: { legend: { display: false }, tooltip: { callbacks: { label: function (ctx) { return ctx.parsed.y.toFixed(1) + "% attenuated"; } } } }, scales: { y: { beginAtZero: true, // Attenuation cannot exceed 100%, so the axis // is capped there. Without the cap a single // bad reading would squash every other bar // into an unreadable stub. suggestedMax: 100, title: { display: true, text: "% attenuated" } }, x: { // The names are long and the chart is narrow, // so the labels are trimmed above and // rotated. autoSkip keeps them from // overlapping; the tooltip carries the // full name for every bar. ticks: { maxRotation: 45, minRotation: 45, autoSkip: true, autoSkipPadding: 8 } } } }) }); }) .catch(function (err) { console.warn(err); }); } /* -------------------------------------------- fermentation curve overlay */ function drawCurveChart() { var canvas = document.getElementById("curve-chart"); if (!canvas) { return; } fetchRuns() .then(function (runs) { // A run with an impossible reading has a broken curve - it // plunges to a gravity below water - which would drag the // median line down and stretch the envelope to cover it. // Only FINISHED brews belong on the curve. An active run's // curve is a partial fermentation, and the curve is meant to // describe how a completed fermentation behaves - including // a partial one pulls the median line off the real shape. var withCurve = runs.filter(function (r) { return !r.suspect && !r.active && r.curve && r.curve.length > 1; }); if (!withCurve.length) { return; } // Plotting every brew individually gives 16 near-identical // lines and an unreadable legend. Instead: a MEDIAN curve // with the min/max envelope behind it, which shows the // typical shape and the spread without the clutter. // // Each curve is the fraction of its own gravity drop // completed, so brews with different starting gravities can // be compared directly. var samples = 21; // 0%, 5%, ... 100% // Interpolate each brew's curve onto the common progress axis. // // The brew's readings are at its own progress values (say // 0%, 40%, 100%), so each sample point is interpolated // BETWEEN the two readings that bracket it. Looking the // value up by an exact key only works when every brew // happened to be read at the same progress points, which is // why the first attempt produced three points. function interpolate(curve, pct) { if (!curve.length) { return null; } if (pct <= curve[0].x) { return curve[0].y; } var last = curve[curve.length - 1]; if (pct >= last.x) { return last.y; } for (var k = 0; k < curve.length - 1; k++) { var a = curve[k]; var b = curve[k + 1]; if (pct >= a.x && pct <= b.x) { var span = b.x - a.x; if (span === 0) { return a.y; } return a.y + (b.y - a.y) * ((pct - a.x) / span); } } return last.y; } // The raw readings of each brew, sorted by progress. var rawCurves = withCurve.map(function (r) { var points = r.curve.map(function (p) { return { x: p.progress * 100, y: parseFloat(p.sg_decimal) }; }).filter(function (p) { return !isNaN(p.y); }); points.sort(function (a, b) { return a.x - b.x; }); return points; }).filter(function (c) { return c.length > 1; }); if (!rawCurves.length) { return; } // The interpolated value of every brew at every sample point. var sampled = rawCurves.map(function (curve) { var out = []; for (var i = 0; i < samples; i++) { var pct = (i / (samples - 1)) * 100; var y = interpolate(curve, pct); if (y === null) { continue; } out.push({ x: pct, y: y }); } return out; }).filter(function (c) { return c.length > 1; }); if (!sampled.length) { return; } // The envelope: the highest and lowest gravity at each // progress point. var envelope = []; var median = []; var envelopeMax = []; for (var i = 0; i < samples; i++) { var pct = (i / (samples - 1)) * 100; var values = []; sampled.forEach(function (curve) { var match = curve.filter(function (p) { return Math.abs(p.x - pct) < 0.01; }); if (match.length) { values.push(match[0].y); } }); if (!values.length) { continue; } values.sort(function (a, b) { return a - b; }); envelope.push({ x: pct, y: values[0], yMax: values[values.length - 1] }); envelopeMax.push({ x: pct, y: values[values.length - 1] }); var mid = Math.floor(values.length / 2); median.push({ x: pct, y: values.length % 2 ? values[mid] : (values[mid - 1] + values[mid]) / 2 }); } new Chart(canvas.getContext("2d"), { type: "line", data: { datasets: [ { // The band is drawn as TWO datasets: the min // curve filled down to the max curve. With // a single dataset and `fill: "+1"` the band // was filled to the NEXT dataset - which was // the median line, not the max - so the // tooltip and the shading disagreed. label: "Range (min–max)", data: envelope, borderColor: withAlpha(ACCENT, 0.35), backgroundColor: withAlpha(ACCENT, 0.12), borderWidth: 1, pointRadius: 0, fill: "+1", tension: 0.3 }, { // The upper edge of the band. It is the // dataset the one above fills to. label: "Range max", data: envelopeMax, borderColor: withAlpha(ACCENT, 0.35), backgroundColor: "transparent", borderWidth: 1, pointRadius: 0, fill: false, tension: 0.3 }, { label: "Median brew", data: median, borderColor: ACCENT_BRIGHT, backgroundColor: withAlpha(ACCENT_BRIGHT, 0.15), borderWidth: 2.5, pointRadius: 3, pointBackgroundColor: ACCENT_BRIGHT, fill: false, tension: 0.3 } ] }, options: baseOptions({ plugins: { legend: { display: true, labels: { boxWidth: 10, boxHeight: 10, usePointStyle: true, font: { size: 11 }, // The max curve is the helper the band // fills to, not a series of its own. filter: function (item, data) { return item.text !== "Range max"; } } }, tooltip: { callbacks: { label: function (ctx) { var point = ctx.parsed; if (ctx.datasetIndex === 0) { return "range " + point.y + " to " + (point.yMax || point.y); } if (ctx.datasetIndex === 1) { return null; } return "median " + point.y; } } } }, scales: { x: { // The x values are NUMERIC (0-100), so the // axis must be linear. Chart.js defaults a // line chart's x-axis to category, which // collapses every point onto index 0 and // renders the curve as a sliver at the // origin. type: "linear", title: { display: true, text: "% of gravity drop" }, min: 0, max: 100, ticks: { stepSize: 10 } }, y: { // NOT reversed. Specific gravity falls as // the beer ferments, and the scale puts the // HIGH reading at the top by default, so the // curve descends left to right - the way a // brewer reads a fermentation. With // reverse:true the curve climbed instead, // which read as gravity rising. title: { display: true, text: "Specific gravity" } } } }) }); }) .catch(function (err) { console.warn(err); }); } /* ------------------------------------------------- shared run payload */ // The analysis page has two charts that both need /api/runs, and the // payload contains every reading of every run. Fetch it once and // share it rather than making two identical round-trips. var runsPromise = null; function fetchRuns() { if (!runsPromise) { runsPromise = fetch("/api/runs", { headers: { "Accept": "application/json" } }) .then(function (r) { return r.json(); }) .catch(function (err) { console.warn(err); runsPromise = null; return []; }); } return runsPromise; } function init() { drawSparklines(); drawRunChart(); drawAbvChart(); drawMonthsChart(); drawAttenuationChart(); drawCurveChart(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();