Files

242 lines
9.1 KiB
JavaScript

/* Network stats section: fetches aggregated statistics from the Org Social
Relay and renders stat tiles plus a stacked bar chart of monthly activity.
If the relay is unreachable the section simply stays hidden. */
(function () {
"use strict";
var RELAY_STATS_URL = "https://relay.org-social.org/stats/";
/* Months before this are residual test posts that would flatten the scale. */
var FIRST_YEAR = 2025;
var FIRST_MONTH = 9;
/* Palette validated for color-vision deficiency (worst adjacent pair
ΔE 16.3, target ≥ 8). Order matters: it is the CVD-safety mechanism. */
var SERIES = [
{ key: "posts", label: "Posts", color: "#7f5ab6" },
{ key: "replies", label: "Replies", color: "#008300" },
{ key: "boosts", label: "Boosts", color: "#e87ba4" },
{ key: "reactions", label: "Reactions", color: "#eda100" },
{ key: "group_messages", label: "Group messages", color: "#2a78d6" },
{ key: "polls", label: "Polls", color: "#eb6834" }
];
var MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function formatNumber(value) {
return value.toLocaleString("en-US");
}
/* Flatten data.years into a list of consecutive months from
FIRST_YEAR/FIRST_MONTH to the last month present, filling gaps
with zeros so the time axis stays honest. */
function buildMonths(years) {
var last = null;
Object.keys(years).forEach(function (year) {
Object.keys(years[year]).forEach(function (month) {
var y = parseInt(year, 10);
var m = parseInt(month, 10);
if (!last || y > last.y || (y === last.y && m > last.m)) {
last = { y: y, m: m };
}
});
});
if (!last) {
return [];
}
var months = [];
var y = FIRST_YEAR;
var m = FIRST_MONTH;
while (y < last.y || (y === last.y && m <= last.m)) {
var key = (m < 10 ? "0" : "") + m;
var entry = (years[y] && years[y][key]) || {};
var month = { label: MONTH_NAMES[m - 1] + " " + y };
SERIES.forEach(function (series) {
month[series.key] = entry[series.key] || 0;
});
month.total_posts = entry.total_posts || 0;
month.active_accounts = entry.active_accounts || 0;
months.push(month);
m += 1;
if (m > 12) {
m = 1;
y += 1;
}
}
return months;
}
function animateValue(element, target) {
var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion || !window.requestAnimationFrame) {
element.textContent = formatNumber(target);
return;
}
var duration = 1200;
var start = null;
function step(timestamp) {
if (start === null) {
start = timestamp;
}
var progress = Math.min((timestamp - start) / duration, 1);
var eased = 1 - Math.pow(1 - progress, 3);
element.textContent = formatNumber(Math.round(target * eased));
if (progress < 1) {
window.requestAnimationFrame(step);
}
}
window.requestAnimationFrame(step);
}
function renderTiles(section, global) {
var tiles = section.querySelectorAll(".stat-value");
var targets = [];
tiles.forEach(function (tile) {
targets.push({ element: tile, value: global[tile.dataset.stat] || 0 });
});
if (!("IntersectionObserver" in window)) {
targets.forEach(function (t) {
t.element.textContent = formatNumber(t.value);
});
return;
}
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
observer.disconnect();
targets.forEach(function (t) {
animateValue(t.element, t.value);
});
}
});
}, { threshold: 0.4 });
observer.observe(section.querySelector(".stat-tile"));
}
function renderChart(months) {
var canvas = document.getElementById("activity-chart");
Chart.defaults.font.family = "'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
new Chart(canvas, {
type: "bar",
data: {
labels: months.map(function (month) { return month.label; }),
datasets: SERIES.map(function (series) {
return {
label: series.label,
/* Zero becomes null so empty segments draw nothing
instead of a stray border hairline. */
data: months.map(function (month) {
return month[series.key] || null;
}),
backgroundColor: series.color,
stack: "activity",
borderColor: "#ffffff",
borderWidth: 1,
borderSkipped: false,
borderRadius: 4,
maxBarThickness: 24
};
})
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom",
labels: {
usePointStyle: true,
pointStyle: "circle",
boxHeight: 8,
color: "#4a4a4a",
padding: 16
}
},
tooltip: {
callbacks: {
footer: function (items) {
if (!items.length) {
return "";
}
var month = months[items[0].dataIndex];
return "Total: " + formatNumber(month.total_posts)
+ "\nActive accounts: " + formatNumber(month.active_accounts);
}
}
}
},
scales: {
x: {
stacked: true,
grid: { display: false },
border: { color: "#c3c2b7" },
ticks: { color: "#898781" }
},
y: {
stacked: true,
grid: { color: "#e1e0d9" },
border: { display: false },
ticks: { color: "#898781", precision: 0 }
}
}
}
});
}
/* Accessible fallback and contrast relief: the full dataset as a table. */
function renderTable(months) {
var table = document.getElementById("activity-table");
var html = "<thead><tr><th>Month</th>";
SERIES.forEach(function (series) {
html += "<th>" + series.label + "</th>";
});
html += "<th>Total</th><th>Active accounts</th></tr></thead><tbody>";
months.forEach(function (month) {
html += "<tr><td>" + month.label + "</td>";
SERIES.forEach(function (series) {
html += "<td>" + formatNumber(month[series.key]) + "</td>";
});
html += "<td>" + formatNumber(month.total_posts) + "</td>";
html += "<td>" + formatNumber(month.active_accounts) + "</td></tr>";
});
html += "</tbody>";
table.innerHTML = html;
}
function init() {
fetch(RELAY_STATS_URL)
.then(function (response) {
if (!response.ok) {
throw new Error("Relay responded with " + response.status);
}
return response.json();
})
.then(function (payload) {
if (payload.type !== "Success" || !payload.data) {
return;
}
var months = buildMonths(payload.data.years || {});
if (!months.length) {
return;
}
var section = document.getElementById("network-stats");
section.hidden = false;
renderTiles(section, payload.data.global || {});
renderChart(months);
renderTable(months);
})
.catch(function () {
/* Relay unreachable: leave the section hidden. */
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();