The second review of PR #12 (after the suspect-run fix) found that the two BAR charts were still plotting runs they should not have been, while the curve chart and the tiles had been corrected. The page was contradicting itself. The ABV chart plotted the suspect run -------------------------------------- Run 12's impossible reading gives it an ABV of 18.64%. The chart's y axis went to 18.64, so the fifteen real bars were squashed into the bottom ~45% of the plot - while the "Strongest 8.53%" tile on the same page showed the correct figure. The chart now leaves the suspect runs out. The attenuation chart plotted the active runs ---------------------------------------------- An active run's attenuation is measured against its CURRENT gravity, so a brew that has just started shows a 0% bar. That is the absence of a result, not a result, and it sat directly above the table of finished brews. The chart now leaves the active runs out. Verified against the live data: ABV chart bars: bars=15 max=8.53 (was 16 bars, max 18.64) attenuation chart bars: bars=15 min=76.2 (was 17 bars, min 0) A suspect active run no longer vanishes --------------------------------------- The API flags suspect runs over EVERY run, so the charts leave them out. But the analysis page only looked at the finished ones, so an active run with a bad reading would be dropped from the charts and never listed in "Readings that need a second look" - it would disappear with no explanation anywhere. The analysis page now lists suspect active runs too. The figures say what they are based on -------------------------------------- - The analysis prose counted the non-suspect runs, not the ones that actually contributed a figure to the mean. It now counts the latter. - The history page's "Average ABV — across finished brews" was averaging 15 of 16 without saying so, while the analysis page said "16 brews (15 counted)". It now says "across 15 of 16 finished brews". - `/api/runs/<run_id>` carries the `suspect` and `active` flags that `/api/runs` does. 148 tests (up from 142).
650 lines
28 KiB
JavaScript
650 lines
28 KiB
JavaScript
/* ==========================================================================
|
||
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();
|
||
}
|
||
})();
|