Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.
analyticsaxumdockerrustself-hostedsqliteviteweb-analytics
1import Chart from "chart.js/auto";
2
3// Palette matches the warm-earth SCSS: mossy green primary, amber secondary,
4// terracotta accent, plus neutral earth tones for the longer tails of the
5// doughnut charts. Alpha-blended so adjacent segments stay readable.
6const palette = [
7 "rgba(107, 158, 120, 0.75)", // green
8 "rgba(201, 168, 76, 0.75)", // amber
9 "rgba(196, 112, 85, 0.75)", // terracotta
10 "rgba(126, 170, 184, 0.75)", // info blue-slate
11 "rgba(160, 152, 144, 0.75)", // warm grey
12 "rgba(125, 184, 140, 0.75)", // green bright
13 "rgba(221, 192, 106, 0.7)", // amber bright
14 "rgba(216, 136, 112, 0.7)", // terracotta bright
15 "rgba(132, 124, 114, 0.7)", // gray-400
16 "rgba(196, 189, 178, 0.7)", // gray-200
17];
18
19const paletteBorders = palette.map((c) => c.replace(/0?\.\d+\)/, "1)"));
20
21const fontStack = "'Monaspace Argon', ui-monospace, 'Cascadia Code', Consolas, monospace";
22
23// Common axis / grid styling for the dark theme.
24Chart.defaults.color = "rgba(221, 215, 205, 0.55)";
25Chart.defaults.borderColor = "rgba(107, 158, 120, 0.08)";
26Chart.defaults.font.family = fontStack;
27Chart.defaults.font.size = 11;
28
29const tooltipStyle = {
30 backgroundColor: "rgba(9, 8, 6, 0.95)",
31 borderColor: "rgba(107, 158, 120, 0.3)",
32 borderWidth: 1,
33 titleColor: "#ede8e0",
34 bodyColor: "#ddd7cd",
35 padding: 10,
36 titleFont: { family: fontStack, size: 12 },
37 bodyFont: { family: fontStack, size: 11 },
38 cornerRadius: 4,
39};
40
41// Normalize label widths across the four doughnut charts so their legends line
42// up in the sidebar. Pad with non-breaking spaces so monospace widths match.
43let maxLabelLength = 0;
44if (document.getElementById("chart-total-events-by-browser-data")) {
45 const browserData = JSON.parse(document.getElementById("chart-total-events-by-browser-data").innerHTML);
46 const deviceData = JSON.parse(document.getElementById("chart-total-events-by-device-data").innerHTML);
47 const screenSizeData = JSON.parse(document.getElementById("chart-total-events-by-screen-size-data").innerHTML);
48 const platformData = JSON.parse(document.getElementById("chart-total-events-by-platform-data").innerHTML);
49 const allData = [...browserData, ...deviceData, ...screenSizeData, ...platformData];
50 for (let i = 0; i < allData.length; i++) {
51 if (allData[i].label.length > maxLabelLength) {
52 maxLabelLength = allData[i].label.length;
53 }
54 }
55}
56
57function padLabels(data) {
58 for (let i = 0; i < data.length; i++) {
59 data[i].label = data[i].label + " ".repeat(Math.max(0, maxLabelLength - data[i].label.length));
60 }
61 return data;
62}
63
64const doughnutOptions = {
65 responsive: true,
66 aspectRatio: 1.8,
67 animation: { animateRotate: false },
68 cutout: "60%",
69 plugins: {
70 tooltip: tooltipStyle,
71 legend: {
72 position: "right",
73 labels: {
74 boxWidth: 8,
75 boxHeight: 8,
76 padding: 8,
77 color: "rgba(221, 215, 205, 0.7)",
78 font: { family: fontStack, size: 11 },
79 },
80 },
81 },
82};
83
84// Replace the canvas with a centered "no data yet" placeholder. Keeps the
85// card's visual footprint roughly the same so the layout doesn't reflow
86// when one chart has data and another doesn't.
87function renderEmpty(canvas) {
88 const placeholder = document.createElement("div");
89 placeholder.className = "chart-empty";
90 placeholder.textContent = "no data yet";
91 Object.assign(placeholder.style, {
92 display: "flex",
93 alignItems: "center",
94 justifyContent: "center",
95 height: "100%",
96 minHeight: "120px",
97 color: "rgba(132, 124, 114, 0.6)",
98 fontFamily: fontStack,
99 fontSize: "11px",
100 letterSpacing: "0.05em",
101 textTransform: "uppercase",
102 });
103 canvas.replaceWith(placeholder);
104}
105
106function hasData(rows) {
107 return rows.some((d) => (d.count || 0) > 0);
108}
109
110function renderDoughnut(canvasId, dataId) {
111 document.addEventListener("DOMContentLoaded", function () {
112 const canvas = document.getElementById(canvasId);
113 if (!canvas) return;
114 const raw = JSON.parse(document.getElementById(dataId).innerHTML);
115 if (!hasData(raw)) {
116 renderEmpty(canvas);
117 return;
118 }
119 const data = padLabels(raw);
120 new Chart(canvas.getContext("2d"), {
121 type: "doughnut",
122 data: {
123 labels: data.map((d) => d.label),
124 datasets: [
125 {
126 data: data.map((d) => d.count),
127 backgroundColor: palette,
128 borderColor: "rgba(14, 13, 10, 0.9)",
129 borderWidth: 2,
130 },
131 ],
132 },
133 options: doughnutOptions,
134 });
135 });
136}
137
138document.addEventListener("DOMContentLoaded", function () {
139 const canvas = document.getElementById("chart-total-events");
140 if (!canvas) return;
141 const data = JSON.parse(document.getElementById("chart-total-events-data").innerHTML);
142 if (!hasData(data)) {
143 renderEmpty(canvas);
144 return;
145 }
146 const ctx = canvas.getContext("2d");
147
148 // Build a subtle vertical gradient fill so the line chart feels lit from
149 // below without washing out the dark surface.
150 const gradient = ctx.createLinearGradient(0, 0, 0, 320);
151 gradient.addColorStop(0, "rgba(107, 158, 120, 0.35)");
152 gradient.addColorStop(1, "rgba(107, 158, 120, 0.01)");
153
154 new Chart(ctx, {
155 type: "line",
156 data: {
157 labels: data.map((d) => d.label),
158 datasets: [
159 {
160 label: "events",
161 data: data.map((d) => d.count),
162 backgroundColor: gradient,
163 borderColor: "rgba(125, 184, 140, 0.95)",
164 pointBackgroundColor: "rgba(125, 184, 140, 1)",
165 pointBorderColor: "rgba(14, 13, 10, 1)",
166 pointRadius: 3,
167 pointHoverRadius: 5,
168 borderWidth: 2,
169 tension: 0.25,
170 fill: true,
171 },
172 ],
173 },
174 options: {
175 responsive: true,
176 maintainAspectRatio: false,
177 animation: { duration: 0 },
178 plugins: {
179 tooltip: { ...tooltipStyle, mode: "index", intersect: false },
180 legend: {
181 display: false,
182 },
183 },
184 scales: {
185 x: {
186 ticks: {
187 autoSkip: true,
188 maxTicksLimit: 10,
189 maxRotation: 0,
190 color: "rgba(132, 124, 114, 0.85)",
191 font: { family: fontStack, size: 10 },
192 },
193 grid: { color: "rgba(107, 158, 120, 0.04)", drawTicks: false },
194 border: { color: "rgba(107, 158, 120, 0.12)" },
195 },
196 y: {
197 beginAtZero: true,
198 ticks: {
199 color: "rgba(132, 124, 114, 0.85)",
200 font: { family: fontStack, size: 10 },
201 },
202 grid: { color: "rgba(107, 158, 120, 0.06)", drawTicks: false },
203 border: { display: false },
204 },
205 },
206 },
207 });
208});
209
210renderDoughnut("chart-total-events-by-browser", "chart-total-events-by-browser-data");
211renderDoughnut("chart-total-events-by-device", "chart-total-events-by-device-data");
212renderDoughnut("chart-total-events-by-screen-size", "chart-total-events-by-screen-size-data");
213renderDoughnut("chart-total-events-by-platform", "chart-total-events-by-platform-data");