diff --git a/docs/analytics.md b/docs/analytics.md index 021927b..f792b5d 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -362,13 +362,25 @@ Axes always start at 0 and end at a multiple of a 1-2-5 major step (max 5 labeled intervals, minor lines at fifths when integral; the minimum y-axis range is 10 so tiny values such as a single visit are not stretched to a fractional scale). -The week range is aligned to Monday 00:00 UTC and overlays up to 8 previous -weeks in the muted color at decreasing opacity (the current week keeps the -accent color and is -truncated at the current bucket, never drawing fake zeroes for the future); -a compact legend inside the top right of the visits chart marks the current -ISO week in accent and the overlaid past weeks as "Week M" or "Week M–N" on -a muted specimen. Its x labels are weekday names centered at midday UTC, without +The week range is aligned to Monday 00:00 UTC (the current week keeps the +accent color and is truncated at the current bucket, never drawing fake +zeroes for the future). Both the week and day views overlay a **"typical" +history curve** in the muted color (`analytics/seasonal.js`, a port of +`seasonal.py`): the whole recorded history is densified to 5-minute bins, +smoothed with the same Gaussian as the week view, then folded onto a weekly +grid with exponential decay over age — a 7-day half-life for the average +time-of-day pattern and a 42-day half-life for per-weekday deviations from +it, the deviation shrunk by the effective number of weeks behind each bin +(`n_eff / (n_eff + 3)`) so the estimate falls back to the common daily +pattern when history is short. History is capped at the most recent 180 +days, beyond which even the slow kernel's weight is negligible (~5%). The +week view draws the full Monday-first +estimate as "Typical week" (future included); the day view cuts the rolling +24-hour window's bins from the same estimate and labels them by the weekday +("Typical Saturday"). A compact legend inside the top right of the visits +chart marks the current data in accent (ISO week label, or a bar specimen +for "Last 24 hours") and the typical curve on a muted specimen. The week +view's x labels are weekday names centered at midday UTC, without vertical grid lines (day boundaries would be misleading in the viewer's timezone). The month view labels days the same lineless way — day numbers at noon UTC, diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue index 35da069..c8ac619 100644 --- a/frontend/src/AnalyticsView.vue +++ b/frontend/src/AnalyticsView.vue @@ -134,7 +134,8 @@ onUnmounted(() => { const window = computed(() => rangeWindow(range.value)) // All non-chart stats follow the selected range; the charts keep their own -// range-specific x windows (week overlays previous weeks aligned to Monday). +// range-specific x windows (week aligned to Monday, overlaid with the +// seasonal "typical week" curve). const rangeData = computed(() => { if (!data.value) return null const { t0, t1 } = window.value diff --git a/frontend/src/VisitorCharts.vue b/frontend/src/VisitorCharts.vue index 3102ecc..6e2b93d 100644 --- a/frontend/src/VisitorCharts.vue +++ b/frontend/src/VisitorCharts.vue @@ -4,6 +4,7 @@ */ import { computed, onMounted, onUnmounted, ref } from 'vue' import { makeSeries } from './analytics/time.js' +import { typicalWeek, weekBinIndex } from './analytics/seasonal.js' import { CHART_H, CHART_W, @@ -35,9 +36,6 @@ const allViews = computed(() => { return all }) -const visitSeries = computed(() => makeSeries(props.data?.site_visits, props.range)) -const viewSeries = computed(() => makeSeries(allViews.value, props.range)) - function freqLabel(unit) { return unit === '5min' ? '5 min' : unit === 'hour' ? 'hourly' : 'daily' } @@ -47,12 +45,6 @@ function axisLabel(unit, ylabel) { return unit === '5min' ? `${ylabel} / 5 min` : `${freqLabel(unit)} ${ylabel}` } -/** Legend label for the overlaid past weeks: "Week M" or "Week M–N". */ -function pastLabel(series) { - const oldest = series.at(-1).label.slice(5) // strip "Week " - return series.length > 2 ? `Week ${oldest}–${series[1].label.slice(5)}` : `Week ${oldest}` -} - const now = ref(Date.now()) let refreshInterval = null onMounted(() => { @@ -62,8 +54,29 @@ onUnmounted(() => { if (refreshInterval) clearInterval(refreshInterval) }) -const visitChart = computed(() => buildChart(visitSeries.value, now.value)) -const viewChart = computed(() => buildChart(viewSeries.value, now.value)) +/** + * Series for the current range plus, on the day and week views, the + * seasonal "typical week" history curve (all history up to now, already + * smoothed). Week view: the full Monday-first week. Day view: the rolling + * window's bins looked up from the same estimate, labeled by the weekday. + */ +function withTypical(buckets) { + const input = makeSeries(buckets, props.range) + if (props.range !== 'day' && props.range !== 'week') return input + const estimate = typicalWeek(buckets, now.value) + if (!estimate) return input + if (props.range === 'week') { + return { ...input, typical: { values: [...estimate], label: 'Typical week' } } + } + const values = input.series[0].points.map((p) => estimate[weekBinIndex(p.t)]) + const weekday = new Date(now.value).toLocaleDateString(undefined, { + weekday: 'long', timeZone: 'UTC', + }) + return { ...input, typical: { values, label: `Typical ${weekday}` } } +} + +const visitChart = computed(() => buildChart(withTypical(props.data?.site_visits), now.value)) +const viewChart = computed(() => buildChart(withTypical(allViews.value), now.value)) + + diff --git a/frontend/src/analytics/chart.js b/frontend/src/analytics/chart.js index fe2cff9..2a16217 100644 --- a/frontend/src/analytics/chart.js +++ b/frontend/src/analytics/chart.js @@ -181,16 +181,21 @@ export function spline(pts) { export function buildChart(input, now = Date.now()) { if (!input || !input.series.length) return null if (input.unit === '5min') return buildDayChart(input, now) - const { series, t0, t1, rate, binMinutes, unitMinutes, unit } = input + const { series, t0, t1, rate, binMinutes, unitMinutes, unit, typical } = input // Values are per-unit rates (hour on the week view, day on month+); the // y max is derived from the *smoothed* curves so random single-bucket // spikes don't blow up the scale. Smoothing works on raw counts (its edge // detector thresholds are count-based), the result is scaled back to rates. const smoothed = series.map((s) => smooth(s.points.map((p) => p.count), binMinutes, unitMinutes).map((v) => v * rate)) - // Scale from the current/primary series only; older overlay weeks are drawn - // with the same scale and allowed to overflow if they are busier. - const highest = Math.max(0, ...smoothed[0]) + // The "typical week" seasonal estimate is already smooth: one value per + // bin spanning the full week (future included), drawn in the muted color. + const typicalRates = typical + ? [...typical.values].map((v) => v * rate) + : null + // Scale from the current series plus the typical curve; both are smooth, + // and neither should be clipped in normal traffic. + const highest = Math.max(0, ...smoothed[0], ...(typicalRates || [])) const { max, step, minor } = yScale(highest) const x = (t) => ((t - t0) / (t1 - t0)) * CHART_W const y = (v) => PAD_TOP + (1 - Math.max(0, v) / max) * (CHART_H - PAD_TOP) @@ -205,6 +210,12 @@ export function buildChart(input, now = Date.now()) { area: s.area ? `${line}L${last.x},${CHART_H}L${first.x},${CHART_H}Z` : null, } }) + let typicalLine = null + if (typicalRates) { + const binMs = (t1 - t0) / typicalRates.length + const pts = typicalRates.map((v, i) => ({ x: x(t0 + i * binMs), y: y(v) })) + typicalLine = { line: spline(pts), label: typical.label } + } // Major (labeled) and minor (hairline) y grid ticks. const majors = [] const minors = [] @@ -256,16 +267,19 @@ export function buildChart(input, now = Date.now()) { x: x(t), label: fmtTick(t, t1 - t0), line: true, })) } - return { max, majors, minors, series: drawn, xticks, unit } + return { max, majors, minors, series: drawn, typical: typicalLine, xticks, unit } } /** * Day view: 5-minute bars for the last 24 hours. Bars are drawn at raw * counts; the skyline uses a projected full-bucket value for the still-open * final bucket. The y scale is derived from the projected skyline maximum. + * The optional "typical day" curve (per-bin counts aligned to the window's + * bins, cut from the typical-week estimate) overlays the bars as a smooth + * muted line and also feeds the y scale. */ export function buildDayChart(input, now = Date.now()) { - const { series, t0, t1 } = input + const { series, t0, t1, typical } = input const points = series[0]?.points || [] const n = points.length if (!n) return null @@ -285,7 +299,7 @@ export function buildDayChart(input, now = Date.now()) { const share = elapsed / bucketMs return p.count + prevRaw * (1 - share) }) - const highest = Math.max(0, ...projected) + const highest = Math.max(0, ...projected, ...(typical ? typical.values : [])) const { max, step, minor } = yScale(highest) const y = (v) => PAD_TOP + (1 - Math.max(0, v) / max) * (CHART_H - PAD_TOP) @@ -313,6 +327,15 @@ export function buildDayChart(input, now = Date.now()) { } } + let typicalLine = null + if (typical) { + const pts = points.map((p, i) => ({ + x: (i + 0.5) * bucketWidth, + y: y(typical.values[i] || 0), + })) + typicalLine = { line: spline(pts), label: typical.label } + } + const majors = [] const minors = [] const nMajor = Math.round(max / step) @@ -338,7 +361,7 @@ export function buildDayChart(input, now = Date.now()) { line: false, }) } - return { bars, skyline: skyline.trim(), max, majors, minors, xticks, unit: '5min', series: [] } + return { bars, skyline: skyline.trim(), typical: typicalLine, max, majors, minors, xticks, unit: '5min', series: [] } } /** X ticks for year/all: Monday boundaries up to a quarter, UTC month diff --git a/frontend/src/analytics/seasonal.js b/frontend/src/analytics/seasonal.js new file mode 100644 index 0000000..c79d34c --- /dev/null +++ b/frontend/src/analytics/seasonal.js @@ -0,0 +1,109 @@ +/** + * Seasonal "typical week" estimate from the full visit history. + * + * Port of the seasonal.py demo algorithm: the whole smoothed 5-minute + * history is collapsed onto a weekly grid with exponential decay over age — + * a fast kernel (half-life 7 days) for the average time-of-day pattern and + * a slow one (half-life 42 days) for per-weekday deviations from it. The + * deviation is shrunk by the effective number of weeks behind each bin + * (n_eff / (n_eff + 3)), so with little history the estimate falls back to + * the common daily pattern and weekday character emerges as data accrues. + * Bins before the first recorded bucket are treated as missing. + */ + +import { DAY, MIN5, mondayUTC, rawTimes } from './time.js' +import { smooth } from './chart.js' + +export const BINS_PER_DAY = 288 +export const BINS_PER_WEEK = 7 * BINS_PER_DAY + +// History cap: at 180 days the slow kernel's weight is 2^(-180/42) ≈ 5% +// (and the fast kernel's utterly negligible), so older data cannot move +// this noisy estimate — skipping it keeps the smoothing pass O(1). +const MAX_HISTORY_DAYS = 180 + +/** + * Estimate the typical week from a dense 5-minute count series (oldest + * first; non-finite values count as missing). endWeekBin is the week bin + * (Monday-first) just past the last sample. Returns BINS_PER_WEEK counts + * per 5-minute bin, starting Monday 00:00. + */ +export function seasonalCurve(counts, { + endWeekBin, + binsPerDay = BINS_PER_DAY, + recentHalfLife = 7, + weekdayHalfLife = 42, + shrinkWeeks = 3, +} = {}) { + const n = counts.length + const binsPerWeek = 7 * binsPerDay + + const recentW = new Float64Array(binsPerDay) + const recentX = new Float64Array(binsPerDay) + const dayW = new Float64Array(binsPerDay) + const dayX = new Float64Array(binsPerDay) + const weekW = new Float64Array(binsPerWeek) + const weekX = new Float64Array(binsPerWeek) + const weekW2 = new Float64Array(binsPerWeek) + + for (let i = 0; i < n; i++) { + const x = counts[i] + if (!Number.isFinite(x)) continue + let weekBin = (endWeekBin - n + i) % binsPerWeek + if (weekBin < 0) weekBin += binsPerWeek + const dayBin = weekBin % binsPerDay + const ageDays = (n - 1 - i) / binsPerDay + const recent = 2 ** (-ageDays / recentHalfLife) + const slow = 2 ** (-ageDays / weekdayHalfLife) + recentW[dayBin] += recent + recentX[dayBin] += recent * x + dayW[dayBin] += slow + dayX[dayBin] += slow * x + weekW[weekBin] += slow + weekX[weekBin] += slow * x + weekW2[weekBin] += slow * slow + } + + const estimate = new Float64Array(binsPerWeek) + for (let wb = 0; wb < binsPerWeek; wb++) { + const db = wb % binsPerDay + const recentMean = recentW[db] > 0 ? recentX[db] / recentW[db] : NaN + const dayMean = dayW[db] > 0 ? dayX[db] / dayW[db] : NaN + const weekMean = weekW[wb] > 0 ? weekX[wb] / weekW[wb] : 0 + const nEff = weekW2[wb] > 0 ? (weekW[wb] * weekW[wb]) / weekW2[wb] : 0 + const shrink = nEff / (nEff + shrinkWeeks) + const base = Number.isFinite(recentMean) + ? recentMean + : Number.isFinite(dayMean) ? dayMean : 0 + const deviation = Number.isFinite(dayMean) ? weekMean - dayMean : 0 + estimate[wb] = base + shrink * deviation + } + return estimate +} + +/** Week bin (0 = Monday 00:00–00:05 UTC) containing timestamp t. */ +export function weekBinIndex(t) { + return Math.floor((t - mondayUTC(t)) / MIN5) +} + +/** + * Typical-week estimate from sparse 5-minute buckets, using history up to + * tEnd (default now): bins are densified from the first recorded bucket + * (capped at MAX_HISTORY_DAYS back), smoothed with the same Gaussian the + * week view uses, then folded by seasonalCurve. Returns BINS_PER_WEEK + * counts per 5-minute bin starting Monday, or null when there is less than + * a day of history. + */ +export function typicalWeek(buckets, tEnd = Date.now()) { + const raw = rawTimes(buckets) + const times = Object.keys(raw).map(Number) + if (!times.length) return null + const end = Math.floor(tEnd / MIN5) * MIN5 + const start = Math.max(Math.min(...times), end - MAX_HISTORY_DAYS * DAY) + const n = Math.floor((end - start) / MIN5) + if (n < BINS_PER_DAY) return null + const counts = new Array(n) + for (let i = 0; i < n; i++) counts[i] = raw[start + i * MIN5] || 0 + const smoothed = smooth(counts, 5, 60) + return seasonalCurve(smoothed, { endWeekBin: weekBinIndex(end) }) +} diff --git a/frontend/src/analytics/time.js b/frontend/src/analytics/time.js index 343b458..e7e1940 100644 --- a/frontend/src/analytics/time.js +++ b/frontend/src/analytics/time.js @@ -3,8 +3,8 @@ * * Raw data comes as sparse 5-minute buckets; the range picks the x window * and a coarser bucket size to keep point counts sane. The week range is - * aligned to Monday 00:00 UTC and overlays previous weeks' curves (fading - * with age), so weekly patterns compare directly. + * aligned to Monday 00:00 UTC; a "typical week" seasonal estimate + * (seasonal.js) is overlaid on the week and day views by the chart builder. */ export const MIN5 = 5 * 60e3 @@ -51,60 +51,24 @@ export function sumRange(raw, t0, t1) { } /** - * One series per overlaid week: [this week, 1 week ago, ...], at native - * 5-minute resolution, up to 8 weeks back (and only weeks that overlap the - * recorded data at all). Each older week's timestamps are shifted forward - * onto the current week's axis so all curves overlay inside the plot. - * The current week is truncated at the current bucket - * — no fake zeroes drawn for the future. Counts are rates per hour + * The current week at native 5-minute resolution, truncated at the current + * bucket — no fake zeroes drawn for the future. Counts are rates per hour * (bucket count * 12): a lone visit in a 5-minute bucket reads as "12/h". * The coarser ranges use per-day rates instead (unitMinutes = 24*60). + * Previous weeks are no longer overlaid; the "typical week" seasonal + * estimate (seasonal.js) takes their place as the history reference. */ export function weeklySeries(buckets) { const raw = rawTimes(buckets) - const times = Object.keys(raw).map(Number) const now = Date.now() const thisMonday = mondayUTC(now) - if (!times.length) { - const points = [] - const end = Math.min(thisMonday + WEEK, Math.floor(now / MIN5) * MIN5 + MIN5) - for (let t = thisMonday; t < end; t += MIN5) { - points.push({ t, count: 0 }) - } - return { - series: [{ points, label: `Week ${isoWeek(thisMonday)}`, opacity: 1, area: true }], - t0: thisMonday, - t1: thisMonday + WEEK, - rate: HOUR / MIN5, - binMinutes: 5, - unitMinutes: 60, - unit: 'hour', - } - } - const oldest = Math.min(...times) - // Weeks back as far as the data reaches: difference in Monday indices. - const available = (thisMonday - mondayUTC(oldest)) / WEEK + 1 - const count = Math.min(available, 8) - const out = [] - for (let back = 0; back < count; back++) { - const start = thisMonday - back * WEEK - const end = back === 0 - ? Math.min(start + WEEK, Math.floor(now / MIN5) * MIN5 + MIN5) - : start + WEEK - const points = [] - for (let t = start; t < end; t += MIN5) { - points.push({ t: t + back * WEEK, count: raw[t] || 0 }) - } - out.push({ - points, - label: `Week ${isoWeek(start)}`, - opacity: Math.max(0.15, 1 - back * 0.25), - past: back > 0, - area: back === 0, - }) + const points = [] + const end = Math.min(thisMonday + WEEK, Math.floor(now / MIN5) * MIN5 + MIN5) + for (let t = thisMonday; t < end; t += MIN5) { + points.push({ t, count: raw[t] || 0 }) } return { - series: out, + series: [{ points, label: `Week ${isoWeek(thisMonday)}`, opacity: 1, area: true }], t0: thisMonday, t1: thisMonday + WEEK, rate: HOUR / MIN5,