Analytics: seasonal 'typical week' history curve on day and week views

Port the seasonal.py demo algorithm to JS (analytics/seasonal.js): the
smoothed 5-minute history is folded onto a weekly grid with exponential
age decay (7-day half-life for the time-of-day pattern, 42 days for
per-weekday deviations, shrunk by effective weeks of data), capped at
180 days of history where the weights become negligible.

Week view: the fading past-week overlays are replaced by the full
Monday-first 'Typical week' estimate. Day view gains the same estimate
cut to the rolling 24h window as a smooth muted 'Typical <weekday>'
curve behind the bars. Longer ranges are unchanged.
This commit is contained in:
LeoVasanko committed 2026-09-18 13:45:51 +00:00
1 parent 626dc1df8f
commit 2d221f6204
6 files changed
+217 -89

No files matched your search

+31 -8
View File
@@ -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
+109
View File
@@ -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) })
}
+11 -47
View File
@@ -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,