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:
+19
-7
@@ -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
|
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
|
range is 10 so tiny values such as a single visit are not stretched to a
|
||||||
fractional scale).
|
fractional scale).
|
||||||
The week range is aligned to Monday 00:00 UTC and overlays up to 8 previous
|
The week range is aligned to Monday 00:00 UTC (the current week keeps the
|
||||||
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
|
||||||
accent color and is
|
zeroes for the future). Both the week and day views overlay a **"typical"
|
||||||
truncated at the current bucket, never drawing fake zeroes for the future);
|
history curve** in the muted color (`analytics/seasonal.js`, a port of
|
||||||
a compact legend inside the top right of the visits chart marks the current
|
`seasonal.py`): the whole recorded history is densified to 5-minute bins,
|
||||||
ISO week in accent and the overlaid past weeks as "Week M" or "Week M–N" on
|
smoothed with the same Gaussian as the week view, then folded onto a weekly
|
||||||
a muted specimen. Its x labels are weekday names centered at midday UTC, without
|
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
|
vertical grid
|
||||||
lines (day boundaries would be misleading in the viewer's timezone). The
|
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,
|
month view labels days the same lineless way — day numbers at noon UTC,
|
||||||
|
|||||||
@@ -134,7 +134,8 @@ onUnmounted(() => {
|
|||||||
const window = computed(() => rangeWindow(range.value))
|
const window = computed(() => rangeWindow(range.value))
|
||||||
|
|
||||||
// All non-chart stats follow the selected range; the charts keep their own
|
// 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(() => {
|
const rangeData = computed(() => {
|
||||||
if (!data.value) return null
|
if (!data.value) return null
|
||||||
const { t0, t1 } = window.value
|
const { t0, t1 } = window.value
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { makeSeries } from './analytics/time.js'
|
import { makeSeries } from './analytics/time.js'
|
||||||
|
import { typicalWeek, weekBinIndex } from './analytics/seasonal.js'
|
||||||
import {
|
import {
|
||||||
CHART_H,
|
CHART_H,
|
||||||
CHART_W,
|
CHART_W,
|
||||||
@@ -35,9 +36,6 @@ const allViews = computed(() => {
|
|||||||
return all
|
return all
|
||||||
})
|
})
|
||||||
|
|
||||||
const visitSeries = computed(() => makeSeries(props.data?.site_visits, props.range))
|
|
||||||
const viewSeries = computed(() => makeSeries(allViews.value, props.range))
|
|
||||||
|
|
||||||
function freqLabel(unit) {
|
function freqLabel(unit) {
|
||||||
return unit === '5min' ? '5 min' : unit === 'hour' ? 'hourly' : 'daily'
|
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}`
|
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())
|
const now = ref(Date.now())
|
||||||
let refreshInterval = null
|
let refreshInterval = null
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -62,8 +54,29 @@ onUnmounted(() => {
|
|||||||
if (refreshInterval) clearInterval(refreshInterval)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -75,8 +88,7 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
|||||||
<svg class="chart" :viewBox="`${-MARGIN_L} 0 ${VIEW_W} ${VIEW_H}`"
|
<svg class="chart" :viewBox="`${-MARGIN_L} 0 ${VIEW_W} ${VIEW_H}`"
|
||||||
:style="{ maxWidth: `${VIEW_W}px`, marginLeft: CHART_MARGIN }"
|
:style="{ maxWidth: `${VIEW_W}px`, marginLeft: CHART_MARGIN }"
|
||||||
role="img" :aria-label="axisLabel(c.chart.unit, c.ylabel)">
|
role="img" :aria-label="axisLabel(c.chart.unit, c.ylabel)">
|
||||||
<!-- Clip the plot curves to the chart area: past-week overlays can
|
<!-- Clip the plot curves to the chart area; the svg itself is
|
||||||
run far above the autoscaled y range, and the svg itself is
|
|
||||||
overflow: visible for the axis labels. -->
|
overflow: visible for the axis labels. -->
|
||||||
<clipPath :id="`plot-${c.ylabel}`">
|
<clipPath :id="`plot-${c.ylabel}`">
|
||||||
<rect x="0" y="0" :width="CHART_W" :height="CHART_H" />
|
<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" />
|
class="minor vertical" />
|
||||||
</template>
|
</template>
|
||||||
<g :clip-path="`url(#plot-${c.ylabel})`">
|
<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">
|
<template v-if="c.chart.bars">
|
||||||
<rect v-for="(b, i) in c.chart.bars" :key="'b' + i"
|
<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" />
|
:x="b.x" :y="b.y" :width="b.width" :height="b.height" class="bar" />
|
||||||
<path :d="c.chart.skyline" class="line" />
|
<path :d="c.chart.skyline" class="line" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Oldest overlay weeks first so the current week paints on top. -->
|
<template v-for="(s, i) in c.chart.series" :key="i">
|
||||||
<template v-for="(s, i) in [...c.chart.series].reverse()" :key="i">
|
|
||||||
<path v-if="s.area" :d="s.area" class="area" />
|
<path v-if="s.area" :d="s.area" class="area" />
|
||||||
<path :d="s.line" class="line" :class="{ past: s.past }"
|
<path :d="s.line" class="line" />
|
||||||
:style="{ opacity: s.opacity }" />
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</g>
|
</g>
|
||||||
@@ -111,16 +123,23 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
|||||||
class="yaxis-label">{{ axisLabel(c.chart.unit, c.ylabel) }}</text>
|
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 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>
|
text-anchor="middle" class="xlab">{{ t.label }}</text>
|
||||||
<!-- Week overlay legend, top right inside the plot: current week in
|
<!-- Legend, top right inside the plot: current data in accent (week
|
||||||
accent, one muted specimen for the whole past range. -->
|
curve or day bars) and the typical history curve in muted. -->
|
||||||
<g v-if="c.legend && c.chart.series.length > 1">
|
<g v-if="c.legend && c.chart.typical">
|
||||||
<line :x1="CHART_W - 98" :x2="CHART_W - 78" y1="10" y2="10" class="line" />
|
<template v-if="c.chart.bars">
|
||||||
<text :x="CHART_W - 72" y="10" dominant-baseline="middle"
|
<rect :x="CHART_W - 118" y="5" width="20" height="9" class="bar" />
|
||||||
class="leglab">{{ c.chart.series[0].label }}</text>
|
<text :x="CHART_W - 92" y="10" dominant-baseline="middle"
|
||||||
<line :x1="CHART_W - 98" :x2="CHART_W - 78" y1="25" y2="25"
|
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" />
|
class="line past" style="opacity: 0.6" />
|
||||||
<text :x="CHART_W - 72" y="25" dominant-baseline="middle"
|
<text :x="CHART_W - 92" y="25" dominant-baseline="middle"
|
||||||
class="leglab">{{ pastLabel(c.chart.series) }}</text>
|
class="leglab">{{ c.chart.typical.label }}</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -181,16 +181,21 @@ export function spline(pts) {
|
|||||||
export function buildChart(input, now = Date.now()) {
|
export function buildChart(input, now = Date.now()) {
|
||||||
if (!input || !input.series.length) return null
|
if (!input || !input.series.length) return null
|
||||||
if (input.unit === '5min') return buildDayChart(input, now)
|
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
|
// 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
|
// 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
|
// 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.
|
// detector thresholds are count-based), the result is scaled back to rates.
|
||||||
const smoothed = series.map((s) =>
|
const smoothed = series.map((s) =>
|
||||||
smooth(s.points.map((p) => p.count), binMinutes, unitMinutes).map((v) => v * rate))
|
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
|
// The "typical week" seasonal estimate is already smooth: one value per
|
||||||
// with the same scale and allowed to overflow if they are busier.
|
// bin spanning the full week (future included), drawn in the muted color.
|
||||||
const highest = Math.max(0, ...smoothed[0])
|
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 { max, step, minor } = yScale(highest)
|
||||||
const x = (t) => ((t - t0) / (t1 - t0)) * CHART_W
|
const x = (t) => ((t - t0) / (t1 - t0)) * CHART_W
|
||||||
const y = (v) => PAD_TOP + (1 - Math.max(0, v) / max) * (CHART_H - PAD_TOP)
|
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,
|
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.
|
// Major (labeled) and minor (hairline) y grid ticks.
|
||||||
const majors = []
|
const majors = []
|
||||||
const minors = []
|
const minors = []
|
||||||
@@ -256,16 +267,19 @@ export function buildChart(input, now = Date.now()) {
|
|||||||
x: x(t), label: fmtTick(t, t1 - t0), line: true,
|
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
|
* 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
|
* 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.
|
* 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()) {
|
export function buildDayChart(input, now = Date.now()) {
|
||||||
const { series, t0, t1 } = input
|
const { series, t0, t1, typical } = input
|
||||||
const points = series[0]?.points || []
|
const points = series[0]?.points || []
|
||||||
const n = points.length
|
const n = points.length
|
||||||
if (!n) return null
|
if (!n) return null
|
||||||
@@ -285,7 +299,7 @@ export function buildDayChart(input, now = Date.now()) {
|
|||||||
const share = elapsed / bucketMs
|
const share = elapsed / bucketMs
|
||||||
return p.count + prevRaw * (1 - share)
|
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 { max, step, minor } = yScale(highest)
|
||||||
const y = (v) => PAD_TOP + (1 - Math.max(0, v) / max) * (CHART_H - PAD_TOP)
|
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 majors = []
|
||||||
const minors = []
|
const minors = []
|
||||||
const nMajor = Math.round(max / step)
|
const nMajor = Math.round(max / step)
|
||||||
@@ -338,7 +361,7 @@ export function buildDayChart(input, now = Date.now()) {
|
|||||||
line: false,
|
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
|
/** X ticks for year/all: Monday boundaries up to a quarter, UTC month
|
||||||
|
|||||||
@@ -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) })
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
*
|
*
|
||||||
* Raw data comes as sparse 5-minute buckets; the range picks the x window
|
* 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
|
* 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
|
* aligned to Monday 00:00 UTC; a "typical week" seasonal estimate
|
||||||
* with age), so weekly patterns compare directly.
|
* (seasonal.js) is overlaid on the week and day views by the chart builder.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const MIN5 = 5 * 60e3
|
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
|
* The current week at native 5-minute resolution, truncated at the current
|
||||||
* 5-minute resolution, up to 8 weeks back (and only weeks that overlap the
|
* bucket — no fake zeroes drawn for the future. Counts are rates per hour
|
||||||
* 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
|
|
||||||
* (bucket count * 12): a lone visit in a 5-minute bucket reads as "12/h".
|
* (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).
|
* 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) {
|
export function weeklySeries(buckets) {
|
||||||
const raw = rawTimes(buckets)
|
const raw = rawTimes(buckets)
|
||||||
const times = Object.keys(raw).map(Number)
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const thisMonday = mondayUTC(now)
|
const thisMonday = mondayUTC(now)
|
||||||
if (!times.length) {
|
const points = []
|
||||||
const points = []
|
const end = Math.min(thisMonday + WEEK, Math.floor(now / MIN5) * MIN5 + MIN5)
|
||||||
const end = Math.min(thisMonday + WEEK, Math.floor(now / MIN5) * MIN5 + MIN5)
|
for (let t = thisMonday; t < end; t += MIN5) {
|
||||||
for (let t = thisMonday; t < end; t += MIN5) {
|
points.push({ t, count: raw[t] || 0 })
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
series: out,
|
series: [{ points, label: `Week ${isoWeek(thisMonday)}`, opacity: 1, area: true }],
|
||||||
t0: thisMonday,
|
t0: thisMonday,
|
||||||
t1: thisMonday + WEEK,
|
t1: thisMonday + WEEK,
|
||||||
rate: HOUR / MIN5,
|
rate: HOUR / MIN5,
|
||||||
|
|||||||
Reference in New Issue
Block a user