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:
2026-09-18 13:45:51 +00:00
parent 626dc1df8f
commit 2d221f6204
6 changed files with 217 additions and 89 deletions
+2 -1
View File
@@ -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
+45 -26
View File
@@ -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 MN". */
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))
</script>
<template>
@@ -75,8 +88,7 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
<svg class="chart" :viewBox="`${-MARGIN_L} 0 ${VIEW_W} ${VIEW_H}`"
:style="{ maxWidth: `${VIEW_W}px`, marginLeft: CHART_MARGIN }"
role="img" :aria-label="axisLabel(c.chart.unit, c.ylabel)">
<!-- Clip the plot curves to the chart area: past-week overlays can
run far above the autoscaled y range, and the svg itself is
<!-- Clip the plot curves to the chart area; the svg itself is
overflow: visible for the axis labels. -->
<clipPath :id="`plot-${c.ylabel}`">
<rect x="0" y="0" :width="CHART_W" :height="CHART_H" />
@@ -88,17 +100,17 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
class="minor vertical" />
</template>
<g :clip-path="`url(#plot-${c.ylabel})`">
<!-- The muted "typical" history curve under the current data. -->
<path v-if="c.chart.typical" :d="c.chart.typical.line" class="line past" />
<template v-if="c.chart.bars">
<rect v-for="(b, i) in c.chart.bars" :key="'b' + i"
:x="b.x" :y="b.y" :width="b.width" :height="b.height" class="bar" />
<path :d="c.chart.skyline" class="line" />
</template>
<template v-else>
<!-- Oldest overlay weeks first so the current week paints on top. -->
<template v-for="(s, i) in [...c.chart.series].reverse()" :key="i">
<template v-for="(s, i) in c.chart.series" :key="i">
<path v-if="s.area" :d="s.area" class="area" />
<path :d="s.line" class="line" :class="{ past: s.past }"
:style="{ opacity: s.opacity }" />
<path :d="s.line" class="line" />
</template>
</template>
</g>
@@ -111,16 +123,23 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
class="yaxis-label">{{ axisLabel(c.chart.unit, c.ylabel) }}</text>
<text v-for="t in c.chart.xticks" :key="'x' + t.x" :x="t.x" :y="CHART_H + MARGIN_B - 8"
text-anchor="middle" class="xlab">{{ t.label }}</text>
<!-- Week overlay legend, top right inside the plot: current week in
accent, one muted specimen for the whole past range. -->
<g v-if="c.legend && c.chart.series.length > 1">
<line :x1="CHART_W - 98" :x2="CHART_W - 78" y1="10" y2="10" class="line" />
<text :x="CHART_W - 72" y="10" dominant-baseline="middle"
class="leglab">{{ c.chart.series[0].label }}</text>
<line :x1="CHART_W - 98" :x2="CHART_W - 78" y1="25" y2="25"
<!-- Legend, top right inside the plot: current data in accent (week
curve or day bars) and the typical history curve in muted. -->
<g v-if="c.legend && c.chart.typical">
<template v-if="c.chart.bars">
<rect :x="CHART_W - 118" y="5" width="20" height="9" class="bar" />
<text :x="CHART_W - 92" y="10" dominant-baseline="middle"
class="leglab">Last 24 hours</text>
</template>
<template v-else>
<line :x1="CHART_W - 118" :x2="CHART_W - 98" y1="10" y2="10" class="line" />
<text :x="CHART_W - 92" y="10" dominant-baseline="middle"
class="leglab">{{ c.chart.series[0].label }}</text>
</template>
<line :x1="CHART_W - 118" :x2="CHART_W - 98" y1="25" y2="25"
class="line past" style="opacity: 0.6" />
<text :x="CHART_W - 72" y="25" dominant-baseline="middle"
class="leglab">{{ pastLabel(c.chart.series) }}</text>
<text :x="CHART_W - 92" y="25" dominant-baseline="middle"
class="leglab">{{ c.chart.typical.label }}</text>
</g>
</svg>
</template>
+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:0000: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,