Self-hostable website analytics on Django: a straightforward collector API, dashboards, a world map, and PDF reports.
analyticsdjangodockerhandcodedpythonself-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
84function renderDoughnut(canvasId, dataId) {
85 document.addEventListener("DOMContentLoaded", function () {
86 const canvas = document.getElementById(canvasId);
87 if (!canvas) return;
88 const raw = JSON.parse(document.getElementById(dataId).innerHTML);
89 const data = padLabels(raw);
90 new Chart(canvas.getContext("2d"), {
91 type: "doughnut",
92 data: {
93 labels: data.map((d) => d.label),
94 datasets: [
95 {
96 data: data.map((d) => d.count),
97 backgroundColor: palette,
98 borderColor: "rgba(14, 13, 10, 0.9)",
99 borderWidth: 2,
100 },
101 ],
102 },
103 options: doughnutOptions,
104 });
105 });
106}
107
108document.addEventListener("DOMContentLoaded", function () {
109 const canvas = document.getElementById("chart-total-events");
110 if (!canvas) return;
111 const data = JSON.parse(document.getElementById("chart-total-events-data").innerHTML);
112 const ctx = canvas.getContext("2d");
113
114 // Build a subtle vertical gradient fill so the line chart feels lit from
115 // below without washing out the dark surface.
116 const gradient = ctx.createLinearGradient(0, 0, 0, 320);
117 gradient.addColorStop(0, "rgba(107, 158, 120, 0.35)");
118 gradient.addColorStop(1, "rgba(107, 158, 120, 0.01)");
119
120 new Chart(ctx, {
121 type: "line",
122 data: {
123 labels: data.map((d) => d.label),
124 datasets: [
125 {
126 label: "events",
127 data: data.map((d) => d.count),
128 backgroundColor: gradient,
129 borderColor: "rgba(125, 184, 140, 0.95)",
130 pointBackgroundColor: "rgba(125, 184, 140, 1)",
131 pointBorderColor: "rgba(14, 13, 10, 1)",
132 pointRadius: 3,
133 pointHoverRadius: 5,
134 borderWidth: 2,
135 tension: 0.25,
136 fill: true,
137 },
138 ],
139 },
140 options: {
141 responsive: true,
142 maintainAspectRatio: false,
143 animation: { duration: 0 },
144 plugins: {
145 tooltip: { ...tooltipStyle, mode: "index", intersect: false },
146 legend: {
147 display: false,
148 },
149 },
150 scales: {
151 x: {
152 ticks: {
153 autoSkip: true,
154 maxTicksLimit: 10,
155 maxRotation: 0,
156 color: "rgba(132, 124, 114, 0.85)",
157 font: { family: fontStack, size: 10 },
158 },
159 grid: { color: "rgba(107, 158, 120, 0.04)", drawTicks: false },
160 border: { color: "rgba(107, 158, 120, 0.12)" },
161 },
162 y: {
163 beginAtZero: true,
164 ticks: {
165 color: "rgba(132, 124, 114, 0.85)",
166 font: { family: fontStack, size: 10 },
167 },
168 grid: { color: "rgba(107, 158, 120, 0.06)", drawTicks: false },
169 border: { display: false },
170 },
171 },
172 },
173 });
174});
175
176renderDoughnut("chart-total-events-by-browser", "chart-total-events-by-browser-data");
177renderDoughnut("chart-total-events-by-device", "chart-total-events-by-device-data");
178renderDoughnut("chart-total-events-by-screen-size", "chart-total-events-by-screen-size-data");
179renderDoughnut("chart-total-events-by-platform", "chart-total-events-by-platform-data");