Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/javascript.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,7 @@ jobs:
- name: "Check 'cropperjs'"
run: |
diff -wu wcfsetup/install/files/js/3rdParty/cropper.min.js node_modules/cropperjs/dist/cropper.min.js
- name: "Check 'chart.js'"
run: |
diff -wu wcfsetup/install/files/js/3rdParty/chart.js/chart.umd.min.js node_modules/chart.js/dist/chart.umd.min.js
diff -wu wcfsetup/install/files/js/3rdParty/chart.js/chart.umd.min.js.map node_modules/chart.js/dist/chart.umd.min.js.map
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@woltlab/editor": "git+https://github.com/WoltLab/editor.git#da3d29e2067693661c658e16a6869cc781bdd684",
"@woltlab/visual-dom-diff": "git+https://github.com/WoltLab/visual-dom-diff.git#e5b51fce3157d1eda310566fc1f86101341d1fea",
"@woltlab/zxcvbn": "git+https://github.com/WoltLab/zxcvbn.git#5b582b24e437f1883ccad3c37dae7c3c5f1e7da3",
"chart.js": "^4.6.1",
"cropperjs": "2.2.0",
"focus-trap": "^8.2.2",
"hash-wasm": "^4.12.0",
Expand Down
328 changes: 328 additions & 0 deletions ts/WoltLabSuite/Core/Acp/Ui/Stat/Chart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
/**
* Renders the daily statistics as a line chart with a table view.
*
* @author Alexander Ebert
* @copyright 2001-2026 WoltLab GmbH
* @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
* @since 6.3
* @woltlabExcludeBundle all
*/

import { _adapters, Chart, type ChartDataset, type PointStyle, type TimeUnit } from "chart.js";
import { getStats, type DateGrouping, type StatSeries, type StatValue } from "WoltLabSuite/Core/Api/Stats/GetStats";
import { setup as setupDateAdapter } from "WoltLabSuite/Core/Component/Chart/DateAdapter";
import DatePicker from "WoltLabSuite/Core/Date/Picker";
import { getPhrase } from "WoltLabSuite/Core/Language";
import { formatNumeric } from "WoltLabSuite/Core/StringUtil";

type Point = { x: number; y: number };

type DatasetWithObjectType = ChartDataset<"line", Point[]> & { objectTypeID: number };

type ColorScheme = "dark" | "light";

// Validated as a set for both color schemes against the ACP content surface,
// the order keeps adjacent series apart for color vision deficiencies.
const PALETTE: Record<ColorScheme, string[]> = {
light: ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6", "#e34948"],
dark: ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300", "#9085e9", "#e66767"],
};

// Distinguishes series that share a color once more series are shown than there are colors.
const POINT_STYLES: PointStyle[] = ["circle", "rectRot", "triangle"];

// Markers on every point turn into a solid band for long series.
const MAX_POINTS_WITH_MARKERS = 60;

const TIME_UNIT: Record<DateGrouping, { unit: TimeUnit | false; minUnit: TimeUnit; tooltipFormat: string }> = {
daily: { unit: false, minUnit: "day", tooltipFormat: "date" },
// Chart.js never picks weeks for automatically sized ticks.
weekly: { unit: "week", minUnit: "week", tooltipFormat: "date" },
monthly: { unit: false, minUnit: "month", tooltipFormat: "month" },
yearly: { unit: false, minUnit: "year", tooltipFormat: "year" },
};

let chart: Chart<"line", Point[]> | undefined;
let requestCounter = 0;

function getColorScheme(): ColorScheme {
return document.documentElement.dataset.colorScheme === "dark" ? "dark" : "light";
}

function getToken(style: CSSStyleDeclaration, name: string): string {
return style.getPropertyValue(`--${name}`).trim().replace(/\s+/g, " ");
}

/**
* Assigns each type the color matching its position in the list of types, so
* that it keeps its color across refreshes, unless a type further up already
* holds that color. Colors only repeat once all of them are in use.
*/
function assignColors(objectTypeIDs: number[], colorCount: number): Map<number, { color: number; style: number }> {
const checkboxes = Array.from(document.querySelectorAll<HTMLInputElement>('input[name="objectTypeID"]'));
const positions = new Map(
objectTypeIDs.map((objectTypeID) => [
objectTypeID,
Math.max(
0,
checkboxes.findIndex((checkbox) => checkbox.value === objectTypeID.toString()),
),
]),
);

const assignments = new Map<number, { color: number; style: number }>();
let usedColors = new Set<number>();
let style = 0;
for (const objectTypeID of [...objectTypeIDs].sort((a, b) => positions.get(a)! - positions.get(b)!)) {
if (usedColors.size === colorCount) {
usedColors = new Set();
style++;
}

let color = positions.get(objectTypeID)! % colorCount;
while (usedColors.has(color)) {
color = (color + 1) % colorCount;
}

usedColors.add(color);
assignments.set(objectTypeID, { color, style });
}

return assignments;
}

function applyTheme(chart: Chart<"line", Point[]>): void {
const style = window.getComputedStyle(document.documentElement);
const surface = getToken(style, "wcfContentContainerBackground");
const text = getToken(style, "wcfContentText");
const dimmedText = getToken(style, "wcfContentDimmedText");
const grid = getToken(style, "wcfContentBorderInner");
const font = {
family: getToken(style, "wcfFontFamily"),
size: parseInt(getToken(style, "wcfFontSizeSmall"), 10),
};
const palette = PALETTE[getColorScheme()];

const { options } = chart;
for (const axis of [options.scales!.x!, options.scales!.y!]) {
axis.ticks!.color = dimmedText;
axis.ticks!.font = font;
axis.border!.color = grid;
axis.grid!.color = grid;
}
options.plugins!.legend!.labels!.color = text;
options.plugins!.legend!.labels!.font = font;
options.plugins!.tooltip!.backgroundColor = getToken(style, "wcfTooltipBackground");
options.plugins!.tooltip!.titleColor = getToken(style, "wcfTooltipText");
options.plugins!.tooltip!.bodyColor = getToken(style, "wcfTooltipText");
options.plugins!.tooltip!.titleFont = { ...font, weight: "bold" };
options.plugins!.tooltip!.bodyFont = font;

const datasets = chart.data.datasets as DatasetWithObjectType[];
const assignments = assignColors(
datasets.map((dataset) => dataset.objectTypeID),
palette.length,
);
for (const dataset of datasets) {
const { color, style } = assignments.get(dataset.objectTypeID)!;

dataset.borderColor = palette[color];
dataset.backgroundColor = palette[color];
dataset.pointBackgroundColor = palette[color];
dataset.pointBorderColor = surface;
dataset.pointStyle = POINT_STYLES[style % POINT_STYLES.length];
}
}

function toTimestamp(date: string): number {
const [year, month, day] = date.split("-").map((part) => parseInt(part, 10));

return Date.UTC(year, month - 1, day);
}

function createDatasets(series: StatSeries[]): DatasetWithObjectType[] {
return series.map((entry) => {
const pointRadius = entry.data.length > MAX_POINTS_WITH_MARKERS ? 0 : 5;

return {
objectTypeID: entry.objectTypeID,
label: entry.label,
data: entry.data.map(({ date, value }) => ({ x: toTimestamp(date), y: value })),
borderWidth: 2,
pointRadius,
pointHoverRadius: 7,
pointBorderWidth: 2,
pointHitRadius: 8,
};
});
}

function createChart(container: HTMLElement): Chart<"line", Point[]> {
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
container.append(canvas);

const compactNumber = new Intl.NumberFormat(document.documentElement.lang, {
notation: "compact",
maximumFractionDigits: 1,
});

return new Chart<"line", Point[]>(canvas, {
type: "line",
data: { datasets: [] },
options: {
animation: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? false : undefined,
maintainAspectRatio: false,
interaction: {
mode: "index",
intersect: false,
},
scales: {
x: {
type: "time",
adapters: {
date: { utc: true },
},
border: {},
grid: { display: false },
ticks: { maxRotation: 0 },
},
y: {
beginAtZero: true,
border: { display: false },
grid: {},
ticks: {
precision: 0,
callback: (value) => compactNumber.format(Number(value)),
},
},
},
plugins: {
legend: {
labels: {
usePointStyle: true,
},
},
tooltip: {
usePointStyle: true,
callbacks: {
label: (context) => `${context.dataset.label}: ${formatNumeric(context.parsed.y ?? 0)}`,
},
},
},
},
});
}

function renderTable(table: HTMLTableElement, series: StatSeries[], tooltipFormat: string): void {
const adapter = new _adapters._date({ utc: true });
const dates = Array.from(new Set(series.flatMap((entry) => entry.data.map(({ date }) => date)))).sort();
const values = series.map((entry) => new Map(entry.data.map(({ date, value }) => [date, value])));

const headerRow = document.createElement("tr");
for (const [index, label] of [getPhrase("wcf.global.date"), ...series.map((entry) => entry.label)].entries()) {
const th = document.createElement("th");
th.scope = "col";
th.className = index === 0 ? "columnText" : "columnDigits";
th.textContent = label;
headerRow.append(th);
}

const tbody = document.createElement("tbody");
for (const date of dates) {
const row = document.createElement("tr");

const dateCell = document.createElement("td");
dateCell.className = "columnText";
dateCell.textContent = adapter.format(toTimestamp(date), tooltipFormat);
row.append(dateCell);

for (const valuesByDate of values) {
const td = document.createElement("td");
td.className = "columnDigits";
const value = valuesByDate.get(date);
if (value !== undefined) {
td.textContent = formatNumeric(value);
}
row.append(td);
}

tbody.append(row);
}

const thead = document.createElement("thead");
thead.append(headerRow);
table.replaceChildren(thead, tbody);
}

async function refresh(container: HTMLElement, noData: HTMLElement, tableView: HTMLDetailsElement): Promise<void> {
const objectTypeIDs = Array.from(
document.querySelectorAll<HTMLInputElement>('input[name="objectTypeID"]:checked'),
(checkbox) => parseInt(checkbox.value, 10),
);
if (objectTypeIDs.length === 0) {
return;
}

const dateGrouping = document.querySelector<HTMLInputElement>('input[name="dateGrouping"]:checked')!
.value as DateGrouping;
const value = document.querySelector<HTMLInputElement>('input[name="value"]:checked')!.value as StatValue;

const requestID = ++requestCounter;
const series = (
await getStats(DatePicker.getValue("startDate"), DatePicker.getValue("endDate"), objectTypeIDs, dateGrouping, value)
).unwrap();

// A newer request was started while this one was pending.
if (requestID !== requestCounter) {
return;
}

const hasData = series.length > 0;
container.hidden = !hasData;
tableView.hidden = !hasData;
noData.hidden = hasData;
if (!hasData) {
return;
}

if (chart === undefined) {
chart = createChart(container);
}

const { unit, minUnit, tooltipFormat } = TIME_UNIT[dateGrouping];
const x = chart.options.scales!.x!;
if (x.type === "time") {
x.time = { ...x.time, unit, minUnit, tooltipFormat };
}

chart.data.datasets = createDatasets(series);
applyTheme(chart);
chart.update();

renderTable(tableView.querySelector("table")!, series, tooltipFormat);
}

export function setup(): void {
setupDateAdapter();

const container = document.getElementById("statChart")!;
const noData = document.getElementById("statNoData")!;
const tableView = document.getElementById("statTable") as HTMLDetailsElement;

document.getElementById("statRefreshButton")!.addEventListener("click", () => {
void refresh(container, noData, tableView);

document.querySelector(".contentHeader > .contentTitle")!.scrollIntoView({ behavior: "smooth" });
});

new MutationObserver(() => {
if (chart !== undefined) {
applyTheme(chart);
// Not `update("none")`, which keeps the previously resolved point colors.
chart.update();
}
}).observe(document.documentElement, { attributeFilter: ["data-color-scheme"] });

void refresh(container, noData, tableView);
}
Loading
Loading