orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1import Chart from "chart.js/auto";
2import {
3 applyDefaults,
4 fontStack,
5 foldToPalette,
6 ink,
7 palette,
8 series,
9 status,
10 tooltipStyle,
11} from "./chart_theme.js";
12
13const paletteBorders = palette;
14
15applyDefaults(Chart);
16
17// Labels are padded to a common width so the four doughnut legends line up in
18// the sidebar, which only works because the legend font is monospace.
19let maxLabelLength = 0;
20if (document.getElementById("chart-total-events-by-browser-data")) {
21 const browserData = JSON.parse(document.getElementById("chart-total-events-by-browser-data").innerHTML);
22 const deviceData = JSON.parse(document.getElementById("chart-total-events-by-device-data").innerHTML);
23 const screenSizeData = JSON.parse(document.getElementById("chart-total-events-by-screen-size-data").innerHTML);
24 const platformData = JSON.parse(document.getElementById("chart-total-events-by-platform-data").innerHTML);
25 const allData = [...browserData, ...deviceData, ...screenSizeData, ...platformData];
26 for (let i = 0; i < allData.length; i++) {
27 if (allData[i].label.length > maxLabelLength) {
28 maxLabelLength = allData[i].label.length;
29 }
30 }
31}
32
33function padLabels(data) {
34 for (let i = 0; i < data.length; i++) {
35 data[i].label = data[i].label + " ".repeat(Math.max(0, maxLabelLength - data[i].label.length));
36 }
37 return data;
38}
39
40const doughnutOptions = {
41 responsive: true,
42 aspectRatio: 1.8,
43 animation: { animateRotate: false },
44 cutout: "60%",
45 plugins: {
46 tooltip: tooltipStyle,
47 legend: {
48 position: "right",
49 labels: {
50 boxWidth: 8,
51 boxHeight: 8,
52 padding: 8,
53 color: ink.legend,
54 font: { family: fontStack, size: 11 },
55 },
56 },
57 },
58};
59
60// The placeholder keeps the card's footprint about the same, so a chart with no
61// data does not reflow the ones beside it.
62function renderEmpty(canvas) {
63 const placeholder = document.createElement("div");
64 placeholder.className = "chart-empty";
65 placeholder.textContent = "no data yet";
66 Object.assign(placeholder.style, {
67 display: "flex",
68 alignItems: "center",
69 justifyContent: "center",
70 height: "100%",
71 minHeight: "120px",
72 color: ink.ticks,
73 fontFamily: fontStack,
74 fontSize: "11px",
75 letterSpacing: "0.05em",
76 textTransform: "uppercase",
77 });
78 canvas.replaceWith(placeholder);
79}
80
81function hasData(rows) {
82 return rows.some((d) => (d.count || 0) > 0);
83}
84
85function renderDoughnut(canvasId, dataId) {
86 document.addEventListener("DOMContentLoaded", function () {
87 const canvas = document.getElementById(canvasId);
88 if (!canvas) return;
89 const raw = JSON.parse(document.getElementById(dataId).innerHTML);
90 if (!hasData(raw)) {
91 renderEmpty(canvas);
92 return;
93 }
94 const data = padLabels(foldToPalette(raw));
95 new Chart(canvas.getContext("2d"), {
96 type: "doughnut",
97 data: {
98 labels: data.map((d) => d.label),
99 datasets: [
100 {
101 data: data.map((d) => d.count),
102 backgroundColor: palette,
103 borderColor: "rgba(14, 13, 10, 0.9)",
104 borderWidth: 2,
105 },
106 ],
107 },
108 options: doughnutOptions,
109 });
110 });
111}
112
113document.addEventListener("DOMContentLoaded", function () {
114 const canvas = document.getElementById("chart-total-events");
115 if (!canvas) return;
116 const data = JSON.parse(document.getElementById("chart-total-events-data").innerHTML);
117 if (!hasData(data)) {
118 renderEmpty(canvas);
119 return;
120 }
121 const ctx = canvas.getContext("2d");
122
123 const gradient = ctx.createLinearGradient(0, 0, 0, 320);
124 gradient.addColorStop(0, "rgba(87, 179, 120, 0.35)");
125 gradient.addColorStop(1, "rgba(87, 179, 120, 0.01)");
126
127 new Chart(ctx, {
128 type: "line",
129 data: {
130 labels: data.map((d) => d.label),
131 datasets: [
132 {
133 label: "events",
134 data: data.map((d) => d.count),
135 backgroundColor: gradient,
136 borderColor: series[0],
137 pointBackgroundColor: series[0],
138 pointBorderColor: "rgba(14, 13, 10, 1)",
139 pointRadius: 3,
140 pointHoverRadius: 5,
141 borderWidth: 2,
142 tension: 0.25,
143 fill: true,
144 },
145 ],
146 },
147 options: {
148 responsive: true,
149 maintainAspectRatio: false,
150 animation: { duration: 0 },
151 plugins: {
152 tooltip: { ...tooltipStyle, mode: "index", intersect: false },
153 legend: {
154 display: false,
155 },
156 },
157 scales: {
158 x: {
159 ticks: {
160 autoSkip: true,
161 maxTicksLimit: 10,
162 maxRotation: 0,
163 color: ink.ticks,
164 font: { family: fontStack, size: 10 },
165 },
166 grid: { color: ink.grid, drawTicks: false },
167 border: { color: ink.border },
168 },
169 y: {
170 beginAtZero: true,
171 ticks: {
172 color: ink.ticks,
173 font: { family: fontStack, size: 10 },
174 },
175 grid: { color: ink.grid, drawTicks: false },
176 border: { display: false },
177 },
178 },
179 },
180 });
181});
182
183renderDoughnut("chart-total-events-by-browser", "chart-total-events-by-browser-data");
184renderDoughnut("chart-total-events-by-device", "chart-total-events-by-device-data");
185renderDoughnut("chart-total-events-by-screen-size", "chart-total-events-by-screen-size-data");
186renderDoughnut("chart-total-events-by-platform", "chart-total-events-by-platform-data");