diff --git a/docs/analytics.md b/docs/analytics.md index bc1bca9..500217e 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -366,8 +366,17 @@ 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 (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 +zeroes for the future). Since the window is fixed Monday-to-Monday, **last +week's curve** continues the graph from the current bucket to the end of +the week in the secondary accent (`--accent2`, translucent fill like the +current week), so the chart shows useful data +on Monday too and last week is gradually replaced by the current week; +the tail is only drawn when the recorded data reaches into last week. +Both the week and day views overlay a **"typical" +history estimate** as a muted fill with no stroke, translucent to the same +degree as the current data — shown only once the history spans twice the +view's full time (from the third day on the day view, the third week on +the week view; `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 @@ -381,7 +390,9 @@ 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 +for "Last 24 hours"), the previous week's tail on a secondary-accent line +specimen (week view only), and the typical estimate on a muted fill +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 diff --git a/frontend/src/VisitorCharts.vue b/frontend/src/VisitorCharts.vue index 46e640a..d7b57c1 100644 --- a/frontend/src/VisitorCharts.vue +++ b/frontend/src/VisitorCharts.vue @@ -3,7 +3,7 @@ * Visitor and page-view smoothed curves for a single shared time range. */ import { computed, onMounted, onUnmounted, ref } from 'vue' -import { makeSeries } from './analytics/time.js' +import { DAY, WEEK, makeSeries } from './analytics/time.js' import { typicalWeek, weekBinIndex } from './analytics/seasonal.js' import { CHART_H, @@ -56,14 +56,17 @@ onUnmounted(() => { /** * Series for the current range plus, on the day and week views, the - * seasonal "typical week" history curve (all history up to now, already + * seasonal "typical week" history estimate (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. + * The estimate only appears once the recorded history spans twice the + * view's full time — from the third day / third week on. */ function withTypical(buckets) { const input = makeSeries(buckets, props.range) if (props.range !== 'day' && props.range !== 'week') return input - const estimate = typicalWeek(buckets, now.value) + const minHistory = props.range === 'week' ? 2 * WEEK : 2 * DAY + const estimate = typicalWeek(buckets, now.value, { minHistory }) if (!estimate) return input if (props.range === 'week') { return { ...input, typical: { values: [...estimate], label: 'Typical week' } } @@ -77,6 +80,11 @@ function withTypical(buckets) { const visitChart = computed(() => buildChart(withTypical(props.data?.site_visits), now.value)) const viewChart = computed(() => buildChart(withTypical(allViews.value), now.value)) + +/** The previous week's tail series on the week view, if present. */ +function pastSeries(chart) { + return chart.series.find((s) => s.past) +} - - + + @@ -124,16 +132,26 @@ const viewChart = computed(() => buildChart(withTypical(allViews.value), now.val {{ t.label }} - + (week label, or "Last 24 hours" on the day view), the previous + week's tail in the secondary accent (week view only), then the + typical history fill as a muted specimen. --> + {{ c.chart.bars ? 'Last 24 hours' : c.chart.series[0].label }} - - {{ c.chart.typical.label }} + + @@ -213,9 +231,21 @@ const viewChart = computed(() => buildChart(withTypical(allViews.value), now.val stroke-linecap: round; } -/* Past overlay weeks contrast with the current week's accent color. */ +/* The seasonal "typical" estimate is a muted fill under the current data, + translucent to the same degree, no stroke. */ +.chart .typical { + fill: var(--muted); + opacity: 0.6; +} + +/* The previous week's tail on the week view uses the secondary accent so + only the typical fill is grey. */ .chart .line.past { - stroke: var(--muted); + stroke: var(--accent2); +} + +.chart .area.past { + fill: var(--accent2); } .empty { color: var(--muted); } diff --git a/frontend/src/analytics/chart.js b/frontend/src/analytics/chart.js index 2a16217..51267b0 100644 --- a/frontend/src/analytics/chart.js +++ b/frontend/src/analytics/chart.js @@ -189,13 +189,14 @@ export function buildChart(input, now = Date.now()) { const smoothed = series.map((s) => smooth(s.points.map((p) => p.count), binMinutes, unitMinutes).map((v) => v * rate)) // The "typical week" seasonal estimate is already smooth: one value per - // bin spanning the full week (future included), drawn in the muted color. + // bin spanning the full week (future included), drawn as a translucent + // muted fill under the current data. 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 highest = Math.max(0, ...smoothed.flat(), ...(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) @@ -210,11 +211,15 @@ 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 + let typicalFill = 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 } + const line = spline(pts) + typicalFill = { + area: `${line}L${pts.at(-1).x},${CHART_H}L${pts[0].x},${CHART_H}Z`, + label: typical.label, + } } // Major (labeled) and minor (hairline) y grid ticks. const majors = [] @@ -267,7 +272,7 @@ export function buildChart(input, now = Date.now()) { x: x(t), label: fmtTick(t, t1 - t0), line: true, })) } - return { max, majors, minors, series: drawn, typical: typicalLine, xticks, unit } + return { max, majors, minors, series: drawn, typical: typicalFill, xticks, unit } } /** @@ -275,8 +280,8 @@ export function buildChart(input, now = Date.now()) { * 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. + * bins, cut from the typical-week estimate) underlays the bars as a + * translucent muted fill and also feeds the y scale. */ export function buildDayChart(input, now = Date.now()) { const { series, t0, t1, typical } = input @@ -327,13 +332,17 @@ export function buildDayChart(input, now = Date.now()) { } } - let typicalLine = null + let typicalFill = 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 line = spline(pts) + typicalFill = { + area: `${line}L${pts.at(-1).x},${CHART_H}L${pts[0].x},${CHART_H}Z`, + label: typical.label, + } } const majors = [] @@ -361,7 +370,7 @@ export function buildDayChart(input, now = Date.now()) { line: false, }) } - return { bars, skyline: skyline.trim(), typical: typicalLine, max, majors, minors, xticks, unit: '5min', series: [] } + return { bars, skyline: skyline.trim(), typical: typicalFill, 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 index c79d34c..4403550 100644 --- a/frontend/src/analytics/seasonal.js +++ b/frontend/src/analytics/seasonal.js @@ -92,13 +92,15 @@ export function weekBinIndex(t) { * (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. + * a day of history or the history span (first bucket to tEnd, before + * capping) is below minHistory. */ -export function typicalWeek(buckets, tEnd = Date.now()) { +export function typicalWeek(buckets, tEnd = Date.now(), { minHistory = 0 } = {}) { const raw = rawTimes(buckets) const times = Object.keys(raw).map(Number) if (!times.length) return null const end = Math.floor(tEnd / MIN5) * MIN5 + if (end - Math.min(...times) < minHistory) return null 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 diff --git a/frontend/src/analytics/time.js b/frontend/src/analytics/time.js index e7e1940..eb711e6 100644 --- a/frontend/src/analytics/time.js +++ b/frontend/src/analytics/time.js @@ -4,7 +4,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; a "typical week" seasonal estimate - * (seasonal.js) is overlaid on the week and day views by the chart builder. + * (seasonal.js) is overlaid as a solid fill on the week and day views by + * the chart builder. */ export const MIN5 = 5 * 60e3 @@ -55,8 +56,14 @@ export function sumRange(raw, t0, t1) { * 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. + * Since the window is fixed Monday-to-Monday, the days not yet reached + * would otherwise be blank early in the week: last week's curve continues + * the graph from the current bucket to the end of the week (secondary + * accent, translucent fill like the current week), gradually replaced by + * the current week as it accrues. The tail is only drawn when the data + * reaches into last week at all. The + * "typical week" seasonal estimate (seasonal.js) is the statistical + * history reference under both. */ export function weeklySeries(buckets) { const raw = rawTimes(buckets) @@ -67,8 +74,19 @@ export function weeklySeries(buckets) { for (let t = thisMonday; t < end; t += MIN5) { points.push({ t, count: raw[t] || 0 }) } + const past = [] + if (Object.keys(raw).some((t) => Number(t) < thisMonday)) { + for (let t = end; t < thisMonday + WEEK; t += MIN5) { + past.push({ t, count: raw[t - WEEK] || 0 }) + } + } return { - series: [{ points, label: `Week ${isoWeek(thisMonday)}`, opacity: 1, area: true }], + series: [ + { points, label: `Week ${isoWeek(thisMonday)}`, opacity: 1, area: true }, + ...(past.length + ? [{ points: past, label: `Week ${isoWeek(thisMonday - WEEK)}`, past: true, area: true }] + : []), + ], t0: thisMonday, t1: thisMonday + WEEK, rate: HOUR / MIN5,