@sctools/statical
Version:
A high-level stats video framework built on top of D3.js.
407 lines (370 loc) • 12.5 kB
JavaScript
var index = 0;
var pastIndex = index === 0 ? 0 : index - 1;
var statical = new EventTarget();
var __STATICAL_dates = [];
// #region Number utilities
/**
* Smoothly animate a value from `start` to `end`
* @param {string | HTMLElement} element The element to animate the text of
* @param {number} start The starting value
* @param {number} end The ending value
* @param {Object} options
* @param {string} options.type The type of value to animate
* @param {string} options.prefix The prefix to add to the value
* @param {string} options.suffix The suffix to add to the value
* @param {number} options.duration The duration of the animation in milliseconds
*/
function animateValue(element, start, end, options = {}) {
const { type = "count", prefix = "", suffix = "", duration = DPMS } = options;
const obj =
typeof element === "string" ? document.getElementById(element) : element;
let startTimestamp = null;
const step = (timestamp) => {
if (!startTimestamp) startTimestamp = timestamp;
const progress = Math.min((timestamp - startTimestamp) / duration, 1);
const value = Math.floor(progress * (end - start) + start);
const formattedValue = prefix + valueTypes[type](value, obj) + suffix;
obj.innerHTML = formattedValue;
if (progress < 1) {
window.requestAnimationFrame(step);
}
};
window.requestAnimationFrame(step);
}
/**
* Abbreviate a number to a shorter string
* @example abbreviate(123456789) => "123M"
* @param {string} count The number to abbreviate
* @param {number?} digits The number of digits to keep
* @returns {string} The abbreviated string
*/
function abbreviate(count, digits = 3) {
function abbreviateStep1(value, digits) {
if (digits == undefined) {
digits = 3;
}
if (parseFloat(value) > 999 || parseFloat(value) < -999) {
let newValue = value;
const suffixes = ["", "K", "M", "B", "T"];
let suffixNum = 0;
while (newValue >= 1000 || newValue <= -1000) {
newValue /= 1000;
suffixNum++;
}
newValue = newValue.toPrecision(digits);
newValue += suffixes[suffixNum];
return newValue;
} else {
return parseFloat(value);
}
}
function abbreviateStep2(count, digits) {
if (digits == undefined) {
digits = 3;
}
if (count > 999 && count < 1_000_000_000) {
count = count.toString();
let first = count.slice(0, digits);
let rest = count.slice(digits);
let abb = new Array(rest.length).fill(0).join("");
return parseFloat(first + abb);
} else {
return count;
}
}
return abbreviateStep1(
abbreviateStep2(Math.floor(parseFloat(count)), digits),
digits
);
}
// #endregion
// #region Chart utilities
/**
* Updates a chart's lines and avatars
* @param {string} chart The chart to update
* @param {number | number[]} newCounts The new counts to add to the chart
* @param {((chart: string, index) => void | undefined)} update The function to call to update additional things, such as avatars
*/
function updateChart(chart, newCounts, update = () => {}) {
if (
Array.isArray(charts[chart].lines) &&
Array.isArray(newCounts) &&
newCounts.length === charts[chart].lines.length
) {
for (let i = 0; i < newCounts.length; i++) {
charts[chart].lines[i].data.push({
date: parseFloat(getText("date-value")),
count: parseFloat(newCounts[i]),
});
if (index - charts.startIndex > (charts[chart].limit ?? charts.limit)) {
charts[chart].lines[i].data.shift();
}
}
let axisValues = {
xMin: Math.min(...charts[chart].lines.map((c) => c.data[0].date)),
xMax: Math.max(
...charts[chart].lines.map((c) => c.data.slice(-1)[0].date)
),
yMin: Math.min(
...charts[chart].lines.map((c) => c.data.map((cc) => cc.count)).flat()
),
yMax: Math.max(
...charts[chart].lines.map((c) => c.data.map((cc) => cc.count)).flat()
),
};
charts[chart].x.scale.domain([axisValues.xMin, axisValues.xMax]);
charts[chart].y.scale.domain([axisValues.yMin - 1, axisValues.yMax + 2]);
charts[chart].x.axis.call(d3.axisBottom(charts[chart].x.scale).ticks(5));
charts[chart].y.axis.call(
d3
.axisLeft(charts[chart].y.scale)
.tickFormat((d) => {
return abbreviate(d);
})
.ticks(5)
);
for (let i = 0; i < charts[chart].lines.length; i++) {
charts[chart].lines[i].base
.datum(charts[chart].lines[i].data)
.attr(
"d",
d3
.line()
.x(function (d) {
return charts[chart].x.scale(d.date);
})
.y(function (d) {
return charts[chart].y.scale(d.count);
})
.curve(d3.curveMonotoneX)
)
.style("opacity", charts[chart].lines[i].style?.opacity ?? 1);
update(chart, i);
}
} else {
charts[chart].lines.data.push({
date: parseFloat(getText("date-value")),
count: parseFloat(newCounts),
});
if (index - charts.startIndex > (charts[chart].limit ?? charts.limit)) {
charts[chart].lines.data.shift();
}
let axisValues = {
xMin: Math.min(...charts[chart].lines.data.map((c) => c.date)),
xMax: Math.max(...charts[chart].lines.data.map((c) => c.date)),
yMin: Math.min(...charts[chart].lines.data.map((c) => c.count)),
yMax: Math.max(...charts[chart].lines.data.map((c) => c.count)),
};
charts[chart].x.scale.domain([axisValues.xMin, axisValues.xMax]);
charts[chart].y.scale.domain([axisValues.yMin - 1, axisValues.yMax + 2]);
charts[chart].x.axis.call(d3.axisBottom(charts[chart].x.scale).ticks(5));
charts[chart].y.axis.call(
d3
.axisLeft(charts[chart].y.scale)
.tickFormat((d) => {
return abbreviate(d);
})
.ticks(5)
);
let lineData = charts[chart].lines.data;
charts[chart].lines.base
.datum(lineData)
.attr(
"d",
d3
.line()
.x(function (d) {
return charts[chart].x.scale(d.date);
})
.y(function (d) {
return charts[chart].y.scale(d.count);
})
.curve(d3.curveMonotoneX)
)
.style("opacity", charts[chart].lines.style?.opacity ?? 1);
update(chart, 0);
}
}
/**
* Calculate a bar's width
* @param {number} count This bar's count
* @param {number} firstCount The first bar's count
* @param {number} maxWidth The maximum width of the bar
* @returns {number} The width of the bar
*/
function calculateBarWidth(count, firstCount, maxWidth) {
return (count / firstCount) * maxWidth;
}
// #endregion
// #region Text utilities
/**
* Get the `textContent` of an element
* @param {string | HTMLElement} element The element to get the `textContent` of
* @returns {string} The text content of the element
*/
function getText(element) {
const obj =
typeof element === "string" ? document.getElementById(element) : element;
return obj.textContent.replaceAll(",", "").replaceAll("+", "");
}
/**
* Change the `textContent` of an element
* @param {string | HTMLElement} element The element to change the `textContent` of
* @param {string} text The new text content
*/
function changeText(element, text) {
const obj =
typeof element === "string" ? document.getElementById(element) : element;
obj.textContent = text;
}
// #endregion
document.addEventListener("DOMContentLoaded", () => {
function toId(str) {
return str.replace(/([A-Z])/g, "-$1").toLowerCase();
}
if (!charts.startIndex) {
charts.startIndex = index;
}
const chartKeys = Object.keys(charts).filter(
(key) => typeof charts[key] === "object"
);
for (let i = 0; i < chartKeys.length; i++) {
const key = chartKeys[i];
charts[key].style.marginTop ??= 0;
charts[key].style.marginBottom ??= 0;
charts[key].style.marginLeft ??= 0;
charts[key].style.marginRight ??= 0;
charts[key].chart = d3
.select("#" + toId(key))
.append("svg")
.attr(
"width",
charts[key].style.width +
charts[key].style.marginLeft +
charts[key].style.marginRight
)
.attr(
"height",
charts[key].style.height +
charts[key].style.marginTop +
charts[key].style.marginBottom
)
.append("g")
.attr(
"transform",
`translate(${charts[key].style.marginLeft},${charts[key].style.marginTop})`
);
charts[key].x = {
scale: {},
axis: {},
...charts[key].x,
};
charts[key].x.scale = d3
.scaleTime()
.domain([dates[0], dates[1]])
.range([0, charts[key].style.width]);
charts[key].x.axis = charts[key].chart
.append("g")
.attr("transform", `translate(0, ${charts[key].style.height})`)
.style("font-size", charts[key].x.style?.fontSize)
.style("font-family", charts[key].x.style?.fontFamily)
.call(d3.axisBottom(charts[key].x.scale).ticks(5));
charts[key].y = {
scale: {},
axis: {},
...charts[key].y,
};
charts[key].y.scale = d3
.scaleLinear()
.domain([0, 150])
.range([charts[key].style.height, 0]);
charts[key].y.axis = charts[key].chart
.append("g")
.attr("transform", `translate(0, 0)`)
.style("font-size", charts[key].y.style?.fontSize)
.style("font-family", charts[key].y.style?.fontFamily)
.call(d3.axisLeft(charts[key].y.scale).ticks(5));
if (Array.isArray(charts[key].lines)) {
for (let j = 0; j < charts[key].lines.length; j++) {
charts[key].lines[j] = {
base: {},
data: [],
...charts[key].lines[j],
};
charts[key].lines[j].base = charts[key].chart
.append("path")
.attr("class", "line")
.attr("stroke", charts[key].lines[j].color)
.attr("stroke-width", charts[key].lines[j].style?.strokeWidth ?? 4)
.attr("fill", "none")
.attr("clip-path", `url(#clip${i})`)
.style("opacity", charts[key].lines[j].opacity);
}
} else if (typeof charts[key].lines === "object") {
charts[key].lines = {
base: {},
data: [],
...charts[key].lines,
};
charts[key].lines.base = charts[key].chart
.append("path")
.attr("class", "line")
.attr("stroke", charts[key].lines.color)
.attr("stroke-width", charts[key].lines.style?.strokeWidth ?? 4)
.attr("fill", "none")
.attr("clip-path", `url(#clip${i})`)
.style("opacity", charts[key].lines.opacity);
}
charts[key].chart
.append("clipPath")
.attr("id", `clip${i}`)
.append("rect")
.attr("width", charts[key].style.width)
.attr("height", charts[key].style.height)
.attr("fill", "blue");
statical.dispatchEvent(
new CustomEvent("create-chart", {
detail: { chart: key },
})
);
}
__STATICAL_dates = dates.map((d) => {
if (typeof d === "number") return d;
else if (typeof d === "string") return new Date(d).getTime();
else if (d instanceof Date) return d.getTime();
});
if (!document.getElementById("date-value")) {
const dateValue = document.createElement("p");
dateValue.id = "date-value";
dateValue.style.display = "none";
dateValue.textContent = __STATICAL_dates[index].toLocaleString();
document.body.appendChild(dateValue);
}
function staticalUpdate() {
animateValue(
"date-value",
__STATICAL_dates[pastIndex],
__STATICAL_dates[index]
);
update();
}
setInterval(() => {
if (index === dates.length - 1) {
index = dates.length - 1;
pastIndex = dates.length - 1;
} else {
index += 1;
if (index > 0) {
pastIndex = index - 1;
}
}
if (pastIndex < index) {
staticalUpdate();
}
}, DPMS);
setInterval(() => {
if (pastIndex < index) {
animateCharts();
}
}, charts.interval ?? 30);
});