add pictures to statcounter

This commit is contained in:
2026-10-18 11:21:25 +02:00
parent ca5af75bbd
commit 3ebfeca7cc
5 changed files with 1225 additions and 32 deletions
+166 -31
View File
@@ -145,7 +145,7 @@ HTML = r"""
50% { opacity: 0.35; }
}
.ranges { display: flex; gap: 8px; margin-bottom: 18px; }
.ranges { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; }
.range-btn {
background: var(--card);
@@ -302,6 +302,7 @@ HTML = r"""
<button class="range-btn active" data-range="all">All</button>
<button class="range-btn" data-range="7d">Last 7 days</button>
<button class="range-btn" data-range="24h">Last 24 h</button>
<button class="range-btn" data-range="6h">Last 6 h</button>
</div>
<div class="grid">
@@ -315,6 +316,11 @@ HTML = r"""
<div class="value" id="stat-per-day">–</div>
<div class="hint">Average over full history</div>
</div>
<div class="card">
<div class="label">Growth 6 h</div>
<div class="value" id="stat-delta-6h">–</div>
<div class="hint" id="stat-delta-6h-hint">vs. measurement ~6 h ago</div>
</div>
<div class="card">
<div class="label">Growth 24 h</div>
<div class="value" id="stat-delta-24h">–</div>
@@ -400,6 +406,55 @@ HTML = r"""
<div id="heatmap"></div>
</div>
<div class="section-title">Images – all graphs</div>
<div class="chart-sub">Only exact measurements (counted every 2 h) are plotted – the estimates in between are excluded.</div>
<div class="chart-sub" id="images-empty" style="display:none">
No image counts yet – they appear after the next collector run with the image migration in place.
</div>
<div class="chart-container">
<div class="chart-title">Images over time</div>
<div class="chart-sub">Image URLs found while crawling · exact count every 2 h · only exact measurements are plotted (no estimates) · dashed line = trend &amp; 24 h forecast</div>
<div class="chart-box"><canvas id="imagesMainChart"></canvas></div>
</div>
<div class="chart-container">
<div class="chart-title">Change per 5 minutes</div>
<div class="chart-sub">Normalized to a 5-minute interval · spans between two exact counts (2 h) · grey = raw · colored = smoothed (moving average over 5 measurements)</div>
<div class="chart-box small"><canvas id="imagesDeltaChart"></canvas></div>
</div>
<div class="chart-container">
<div class="chart-title">Cumulative net growth</div>
<div class="chart-sub">Total images gained/lost since the start of the selected range · linear time axis</div>
<div class="chart-box small"><canvas id="imagesCumChart"></canvas></div>
</div>
<div class="chart-container">
<div class="chart-title">Images per hour (last 48 h)</div>
<div class="chart-sub">Sum of changes per hour bucket · green = growth, red = loss</div>
<div class="chart-box small"><canvas id="imagesHourlyChart"></canvas></div>
</div>
<div class="chart-row">
<div class="chart-container">
<div class="chart-title">Image growth by hour of day</div>
<div class="chart-sub">Average gain per 5 min, grouped by hour of day (whole history)</div>
<div class="chart-box small"><canvas id="imagesHodChart"></canvas></div>
</div>
<div class="chart-container">
<div class="chart-title">Records – biggest image changes</div>
<div class="chart-sub">Largest gains and losses between two consecutive measurements (selected range)</div>
<div id="records-images"></div>
</div>
</div>
<div class="section-title">Activity heatmap – image gain per hour of day</div>
<div class="chart-container heatmap">
<div class="chart-sub">Rows = days · columns = hours (0–23) · green = growth, red = loss, grey = no data</div>
<div id="heatmap-images"></div>
</div>
<div class="footer" id="footer"></div>
</div>
@@ -559,10 +614,26 @@ function parsePoints(values) {
.sort(function (a, b) { return a.x - b.x; });
}
function filterRange(points) {
// Nur exakte Bildzählungen (alle 2 h). Die dazwischen geschätzten Werte
// werden in keinem Diagramm dargestellt, damit alle Graphen ausschließlich
// echte Messpunkte zeigen.
function toImagePoints(points) {
return (points || [])
.filter(function (p) { return p.images !== null && p.images !== undefined; })
.filter(function (p) { return p.imagesExact; })
.map(function (p) { return { x: p.x, y: p.images }; });
}
const RANGE_SPANS = {
'6h': 6 * 3600000,
'24h': 24 * 3600000,
'7d': 7 * 24 * 3600000
};
function filterRange(points, anchorX) {
if (range === 'all' || !points.length) return points;
const last = points[points.length - 1].x;
const span = range === '24h' ? 24 * 3600000 : 7 * 24 * 3600000;
const last = anchorX === undefined ? points[points.length - 1].x : anchorX;
const span = RANGE_SPANS[range] || RANGE_SPANS['24h'];
return points.filter(function (p) { return p.x >= last - span; });
}
@@ -675,6 +746,12 @@ function baseScales(yFmt) {
function tooltipTitle(items) { return fmtDateFull(items[0].parsed.x); }
// Pages keep the existing camelCase ids (mainChart); other metrics get a prefix
// (imagesMainChart).
function chartId(prefix, suffix) {
return prefix ? prefix + suffix : suffix.charAt(0).toLowerCase() + suffix.slice(1);
}
function gradientFill(colorTop, colorBottom) {
return function (context) {
const chart = context.chart;
@@ -689,8 +766,14 @@ function gradientFill(colorTop, colorBottom) {
/* ---------- charts ---------- */
function renderCharts(points, deltas, smoothedDeltas) {
destroyCharts();
function renderCharts(cfg) {
const prefix = cfg.prefix;
const points = cfg.points;
const deltas = cfg.deltas;
const smoothedDeltas = cfg.smoothedDeltas;
const unit = cfg.unit;
const accent = cfg.accent;
const accentBar = cfg.accentBar;
const grid = 'rgba(128,128,128,0.15)';
/* 1. main chart + trend */
@@ -710,21 +793,21 @@ function renderCharts(points, deltas, smoothedDeltas) {
];
}
charts.main = new Chart(document.getElementById('mainChart'), {
charts[prefix + 'main'] = new Chart(document.getElementById(chartId(prefix, 'MainChart')), {
type: 'line',
data: {
datasets: [
{
label: 'Pages',
label: unit,
data: points,
parsing: false,
borderColor: ACCENT,
borderColor: accent,
borderWidth: 2,
pointRadius: 2.5,
pointHoverRadius: 5,
tension: 0.25,
fill: true,
backgroundColor: gradientFill('rgba(79,140,255,0.28)', 'rgba(79,140,255,0.02)'),
backgroundColor: gradientFill(cfg.gradTop, cfg.gradBottom),
spanGaps: false
},
{
@@ -753,8 +836,8 @@ function renderCharts(points, deltas, smoothedDeltas) {
title: tooltipTitle,
label: function (item) {
return item.datasetIndex === 0
? (fmt(item.parsed.y) + ' Pages')
: ('Trend: ' + fmt(item.parsed.y) + ' Pages');
? (fmt(item.parsed.y) + ' ' + unit)
: ('Trend: ' + fmt(item.parsed.y) + ' ' + unit);
}
}
}
@@ -763,7 +846,7 @@ function renderCharts(points, deltas, smoothedDeltas) {
});
/* 2. delta chart */
charts.delta = new Chart(document.getElementById('deltaChart'), {
charts[prefix + 'delta'] = new Chart(document.getElementById(chartId(prefix, 'DeltaChart')), {
type: 'line',
data: {
datasets: [
@@ -781,7 +864,7 @@ function renderCharts(points, deltas, smoothedDeltas) {
label: 'Change per 5 min',
data: smoothedDeltas,
parsing: false,
borderColor: ACCENT,
borderColor: accent,
borderWidth: 2.5,
pointRadius: 0,
pointHoverRadius: 4,
@@ -824,7 +907,7 @@ function renderCharts(points, deltas, smoothedDeltas) {
label: function (item) {
const raw = item.raw;
return [
'Smoothed: ' + fmtSigned(raw.y) + ' Pages per 5 min',
'Smoothed: ' + fmtSigned(raw.y) + ' ' + unit + ' per 5 min',
'Raw: ' + fmtSigned(raw.raw) + ' per 5 min (change ' + fmtSigned(raw.diff) + ' in ' + fmtDur(raw.dt) + ')'
];
}
@@ -839,7 +922,7 @@ function renderCharts(points, deltas, smoothedDeltas) {
return { x: p.x, y: p.y - points[0].y };
});
charts.cum = new Chart(document.getElementById('cumChart'), {
charts[prefix + 'cum'] = new Chart(document.getElementById(chartId(prefix, 'CumChart')), {
type: 'line',
data: {
datasets: [{
@@ -874,7 +957,7 @@ function renderCharts(points, deltas, smoothedDeltas) {
tooltip: {
callbacks: {
title: tooltipTitle,
label: function (item) { return fmtSigned(item.parsed.y) + ' Pages since range start'; }
label: function (item) { return fmtSigned(item.parsed.y) + ' ' + unit + ' since range start'; }
}
}
}
@@ -891,7 +974,7 @@ function renderCharts(points, deltas, smoothedDeltas) {
const hourlyLabels = hours.map(function (h) { return fmtDate(h); });
const hourlyValues = hours.map(function (h) { return Math.round(buckets[h]); });
charts.hourly = new Chart(document.getElementById('hourlyChart'), {
charts[prefix + 'hourly'] = new Chart(document.getElementById(chartId(prefix, 'HourlyChart')), {
type: 'bar',
data: {
labels: hourlyLabels,
@@ -917,7 +1000,7 @@ function renderCharts(points, deltas, smoothedDeltas) {
},
plugins: {
legend: { labels: { usePointStyle: true, boxWidth: 8, color: mutedColor() } },
tooltip: { callbacks: { label: function (item) { return fmtSigned(item.parsed.y) + ' Pages in this hour'; } } }
tooltip: { callbacks: { label: function (item) { return fmtSigned(item.parsed.y) + ' ' + unit + ' in this hour'; } } }
}
}
});
@@ -934,14 +1017,14 @@ function renderCharts(points, deltas, smoothedDeltas) {
return hodCount[i] ? Math.round(s / hodCount[i] * 10) / 10 : 0;
});
charts.hod = new Chart(document.getElementById('hodChart'), {
charts[prefix + 'hod'] = new Chart(document.getElementById(chartId(prefix, 'HodChart')), {
type: 'bar',
data: {
labels: hodValues.map(function (_, i) { return (i < 10 ? '0' : '') + i + ':00'; }),
datasets: [{
label: 'Avg gain per 5 min',
data: hodValues,
backgroundColor: hodValues.map(function (v) { return v >= 0 ? 'rgba(79,140,255,0.65)' : 'rgba(255,107,107,0.65)'; }),
backgroundColor: hodValues.map(function (v) { return v >= 0 ? accentBar : 'rgba(255,107,107,0.65)'; }),
borderRadius: 4
}]
},
@@ -959,7 +1042,7 @@ function renderCharts(points, deltas, smoothedDeltas) {
},
plugins: {
legend: { labels: { usePointStyle: true, boxWidth: 8, color: mutedColor() } },
tooltip: { callbacks: { label: function (item) { return fmtSigned(item.parsed.y) + ' Pages per 5 min (avg of ' + hodCount[item.dataIndex] + ' measurements)'; } } }
tooltip: { callbacks: { label: function (item) { return fmtSigned(item.parsed.y) + ' ' + unit + ' per 5 min (avg of ' + hodCount[item.dataIndex] + ' measurements)'; } } }
}
}
});
@@ -967,8 +1050,8 @@ function renderCharts(points, deltas, smoothedDeltas) {
/* ---------- records table ---------- */
function renderRecords(deltas) {
const el = document.getElementById('records');
function renderRecords(deltas, id, unit) {
const el = document.getElementById(id);
if (deltas.length < 2) {
el.innerHTML = '<div class="chart-sub">Not enough data yet.</div>';
return;
@@ -985,7 +1068,7 @@ function renderRecords(deltas) {
el.innerHTML =
'<table class="records">' +
'<tr><th>Time</th><th style="text-align:right">Change</th><th style="text-align:right">Span</th></tr>' +
'<tr><th>Time</th><th style="text-align:right">Change (' + unit + ')</th><th style="text-align:right">Span</th></tr>' +
rows(gains, 'pos') +
rows(losses, 'neg') +
'</table>';
@@ -993,8 +1076,8 @@ function renderRecords(deltas) {
/* ---------- heatmap ---------- */
function renderHeatmap(points) {
const el = document.getElementById('heatmap');
function renderHeatmap(points, id, unit) {
const el = document.getElementById(id);
if (!points.length) { el.innerHTML = ''; return; }
function localDayKey(d) {
@@ -1044,7 +1127,7 @@ function renderHeatmap(points) {
const op = maxAbs ? Math.min(0.85, 0.15 + 0.7 * Math.abs(dv) / maxAbs) : 0.3;
const color = dv >= 0 ? '46,204,113' : '255,107,107';
style = ' style="background:rgba(' + color + ',' + op.toFixed(2) + ')"';
title = fmtDay(new Date(day + 'T12:00:00').getTime()) + ' ' + h + ':00 – ' + fmtSigned(dv) + ' Pages (' + fmt(val) + ' total)';
title = fmtDay(new Date(day + 'T12:00:00').getTime()) + ' ' + h + ':00 – ' + fmtSigned(dv) + ' ' + unit + ' (' + fmt(val) + ' total)';
}
html += '<td><div class="cell"' + style + ' title="' + title + '"></div></td>';
}
@@ -1123,6 +1206,12 @@ function renderAll() {
setSigned('stat-per-day', perHour * 24);
}
const span6h = closestBefore(all, last.x - 6 * 3600000, Math.max(3 * intervalMs, 3600000));
setDeltaCard('stat-delta-6h', 'stat-delta-6h-hint',
span6h ? last.y - span6h.y : null,
span6h ? last.x - span6h.x : 0,
span6h ? (last.y - span6h.y) * (3600000 / Math.max(1, last.x - span6h.x)) * (5 / 60) : 0);
const span24h = closestBefore(all, last.x - 24 * 3600000, Math.max(6 * intervalMs, 3 * 3600000));
setDeltaCard('stat-delta-24h', 'stat-delta-24h-hint',
span24h ? last.y - span24h.y : null,
@@ -1217,9 +1306,55 @@ function renderAll() {
document.getElementById('footer').textContent = 'Forecast available once there are at least 2 measurements.';
}
renderCharts(fpoints, deltas, smoothedDeltas);
renderRecords(deltas);
renderHeatmap(all);
/* images: same graphs, but only exact measurements (every 2 h) */
const imageAll = toImagePoints(all);
const imageFpoints = filterRange(imageAll, last.x);
const imageDeltas = computeDeltas(imageFpoints).deltas;
const imageSmoothY = movingAverage(imageDeltas.map(function (d) { return d.y; }), DELTA_SMOOTH_WINDOW);
const imageSmoothedDeltas = imageDeltas.map(function (d, i) {
return { x: d.x, y: imageSmoothY[i], raw: d.y, dt: d.dt, diff: d.diff };
});
const imagesEmpty = document.getElementById('images-empty');
const hasImageValues = all.some(function (p) { return p.images !== null; });
if (!imageAll.length) {
imagesEmpty.textContent = hasImageValues
? 'Only estimated image counts so far – the graphs show exact counts only (every 2 h).'
: 'No image counts yet – they appear after the next collector run with the image migration in place.';
imagesEmpty.style.display = '';
} else if (!imageFpoints.length) {
imagesEmpty.textContent = 'No exact image counts in the selected range (counted every 2 h).';
imagesEmpty.style.display = '';
} else {
imagesEmpty.style.display = 'none';
}
destroyCharts();
renderCharts({
prefix: '',
points: fpoints,
deltas: deltas,
smoothedDeltas: smoothedDeltas,
unit: 'Pages',
accent: ACCENT,
accentBar: 'rgba(79,140,255,0.65)',
gradTop: 'rgba(79,140,255,0.28)',
gradBottom: 'rgba(79,140,255,0.02)'
});
renderCharts({
prefix: 'images',
points: imageFpoints,
deltas: imageDeltas,
smoothedDeltas: imageSmoothedDeltas,
unit: 'Images',
accent: ACCENT2,
accentBar: 'rgba(180,122,255,0.65)',
gradTop: 'rgba(180,122,255,0.28)',
gradBottom: 'rgba(180,122,255,0.02)'
});
renderRecords(deltas, 'records', 'Pages');
renderRecords(imageDeltas, 'records-images', 'Images');
renderHeatmap(all, 'heatmap', 'Pages');
renderHeatmap(imageAll, 'heatmap-images', 'Images');
}
async function update() {