repos
/ analytics-rust master

analytics-rust

mirror archived upstream

Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.

analyticsaxumdockerrustself-hostedsqliteviteweb-analytics

6.8 KB · 214 lines · JavaScript Raw History
  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");