Rework analytics chart scaling; self-contained SVG charts
- Charts render as single SVGs with axis labels inside the viewBox, replacing the stretched plot + HTML overlay labels - Rolling ranges end at now, t0 aligned to UTC day; bucket size follows the window (6h up to 31 days) so "all" at its 30-day minimum renders identically to "month" - X labels always centered on their true position; no edge-align shifting - rangeWindow simplified to rolling spans ending at now - TransitionGraph "all" visual scale floored at the 30-day plot minimum
This commit is contained in:
1 parent
921a5484a2
commit
563e8fcaf2
5 files changed
+126
-150
No files matched your search
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Chart geometry, smoothing, and SVG path generation for analytics charts.
|
||||
*
|
||||
* Fixed 720x180 viewBox, stretched to the panel width; values are per-unit
|
||||
* rates (hour on the week view, day on month+).
|
||||
* Fixed 720x180 plot area inside a larger viewBox that also holds the axis
|
||||
* labels, so each chart SVG is self-contained; values are per-unit rates
|
||||
* (hour on the week view, day on month+).
|
||||
*/
|
||||
|
||||
import { DAY, HOUR, MIN5, WEEK, mondayUTC } from './time.js'
|
||||
@@ -11,6 +12,10 @@ import { formatCount } from './format.js'
|
||||
export const CHART_W = 720
|
||||
export const CHART_H = 180
|
||||
export const PAD_TOP = 14 // room above the highest point
|
||||
export const MARGIN_L = 56 // y tick labels + vertical axis label
|
||||
export const MARGIN_B = 24 // x tick labels
|
||||
export const VIEW_W = MARGIN_L + CHART_W + 8
|
||||
export const VIEW_H = CHART_H + MARGIN_B
|
||||
|
||||
/**
|
||||
* Y always starts at 0; the max is a multiple of a 1-2-5 major step with at
|
||||
@@ -190,7 +195,7 @@ export function buildChart(input, now = Date.now()) {
|
||||
const nMajor = Math.round(max / step)
|
||||
for (let k = 0; k <= nMajor; k++) {
|
||||
const v = k * step
|
||||
majors.push({ value: v, y: y(v), bottom: (1 - PAD_TOP / CHART_H) * (v / max) * 100 })
|
||||
majors.push({ value: v, y: y(v), label: fmtY(v) })
|
||||
}
|
||||
if (minor) {
|
||||
for (let v = minor; v < max; v += minor) {
|
||||
@@ -204,37 +209,37 @@ export function buildChart(input, now = Date.now()) {
|
||||
// ranges: boundary lines at Mondays / months / years.
|
||||
const isWeek = t1 - t0 === WEEK
|
||||
const isMonth = !isWeek && t1 - t0 <= 31 * DAY
|
||||
const xticks = isWeek
|
||||
? Array.from({ length: 7 }, (_, d) => {
|
||||
const t = t0 + d * DAY + 12 * HOUR
|
||||
return {
|
||||
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
|
||||
label: new Date(t).toLocaleDateString(undefined, {
|
||||
weekday: 'short', timeZone: 'UTC',
|
||||
}),
|
||||
line: false,
|
||||
}
|
||||
let xticks
|
||||
if (isWeek) {
|
||||
xticks = Array.from({ length: 7 }, (_, d) => {
|
||||
const t = t0 + d * DAY + 12 * HOUR
|
||||
return {
|
||||
x: x(t),
|
||||
label: new Date(t).toLocaleDateString(undefined, {
|
||||
weekday: 'short', timeZone: 'UTC',
|
||||
}),
|
||||
line: false,
|
||||
}
|
||||
})
|
||||
} else if (isMonth) {
|
||||
// t0 is day-aligned; label every day whose noon falls inside the range.
|
||||
xticks = []
|
||||
for (let day = t0; day + 12 * HOUR < t1; day += DAY) {
|
||||
const date = new Date(day)
|
||||
const t = day + 12 * HOUR
|
||||
xticks.push({
|
||||
x: x(t),
|
||||
label: date.getUTCDate() === 1
|
||||
? date.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC' })
|
||||
: String(date.getUTCDate()),
|
||||
line: false,
|
||||
})
|
||||
: isMonth
|
||||
? Array.from(
|
||||
{ length: Math.floor((t1 - Math.ceil(t0 / DAY) * DAY) / DAY) },
|
||||
(_, d) => {
|
||||
const day = Math.ceil(t0 / DAY) * DAY + d * DAY
|
||||
const date = new Date(day)
|
||||
const t = day + 12 * HOUR
|
||||
return {
|
||||
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
|
||||
label: date.getUTCDate() === 1
|
||||
? date.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC' })
|
||||
: String(date.getUTCDate()),
|
||||
line: false,
|
||||
}
|
||||
},
|
||||
)
|
||||
: xticksFor(t0, t1).map((t) => ({
|
||||
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
|
||||
label: fmtTick(t, t1 - t0), line: true,
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
xticks = xticksFor(t0, t1).map((t) => ({
|
||||
x: x(t), label: fmtTick(t, t1 - t0), line: true,
|
||||
}))
|
||||
}
|
||||
return { max, majors, minors, series: drawn, xticks, unit }
|
||||
}
|
||||
|
||||
@@ -297,7 +302,7 @@ export function buildDayChart(input, now = Date.now()) {
|
||||
const nMajor = Math.round(max / step)
|
||||
for (let k = 0; k <= nMajor; k++) {
|
||||
const v = k * step
|
||||
majors.push({ value: v, y: y(v), bottom: (1 - PAD_TOP / CHART_H) * (v / max) * 100 })
|
||||
majors.push({ value: v, y: y(v), label: fmtY(v) })
|
||||
}
|
||||
if (minor) {
|
||||
for (let v = minor; v < max; v += minor) {
|
||||
@@ -313,12 +318,10 @@ export function buildDayChart(input, now = Date.now()) {
|
||||
const d = new Date(t)
|
||||
xticks.push({
|
||||
x: ((t - t0) / (t1 - t0)) * CHART_W,
|
||||
left: ((t - t0) / (t1 - t0)) * 100,
|
||||
label: `${String(d.getUTCHours()).padStart(2, '0')}:00`,
|
||||
line: false,
|
||||
})
|
||||
}
|
||||
|
||||
return { bars, skyline: skyline.trim(), max, majors, minors, xticks, unit: '5min', series: [] }
|
||||
}
|
||||
|
||||
|
||||
@@ -92,27 +92,38 @@ export function weeklySeries(buckets) {
|
||||
* to per-day rates (the unit the month+ charts are read in).
|
||||
* Ranges without a fixed span use the full data reach, but never less than
|
||||
* their configured minSpan so the chart keeps a readable minimum x scale.
|
||||
* t0 is aligned to the UTC day so the x labels cover the whole range;
|
||||
* t1 is now, so the scale never extends into the future. The bucket size
|
||||
* follows the resulting window (6h up to 31 days, daily beyond), so ranges
|
||||
* covering the same window — "all" at its 30-day minimum vs "month" —
|
||||
* render the identical curve.
|
||||
*/
|
||||
export function rollingSeries(buckets, rangeKey) {
|
||||
const raw = rawTimes(buckets)
|
||||
const times = Object.keys(raw).map(Number)
|
||||
if (!times.length) return null
|
||||
const { span, bucket, minSpan = 0 } = RANGES[rangeKey]
|
||||
const t1 = Math.floor(Date.now() / bucket) * bucket + bucket
|
||||
const earliest = Math.floor(Math.min(...times) / bucket) * bucket
|
||||
const t0 = span != null
|
||||
const t1 = Date.now()
|
||||
const earliest = Math.min(...times)
|
||||
const t0 = Math.floor((span != null
|
||||
? t1 - span
|
||||
: Math.min(earliest, t1 - minSpan)
|
||||
: Math.min(earliest, t1 - minSpan)) / DAY) * DAY
|
||||
// The bucket follows the actual window length, not the range key: when
|
||||
// "all" is capped to its 30-day minimum it covers the very window "month"
|
||||
// does, and daily bins would draw a different curve over the same data
|
||||
// (coarser edge detection, points a day apart plotted at bin starts, the
|
||||
// last point stuck at today's midnight instead of reaching now).
|
||||
const bucketMs = t1 - t0 <= 31 * DAY ? Math.min(bucket, 6 * HOUR) : bucket
|
||||
const points = []
|
||||
for (let t = t0; t < t1; t += bucket) {
|
||||
points.push({ t, count: sumRange(raw, t, t + bucket) })
|
||||
for (let t = t0; t < t1; t += bucketMs) {
|
||||
points.push({ t, count: sumRange(raw, t, t + bucketMs) })
|
||||
}
|
||||
return {
|
||||
series: [{ points, label: '', opacity: 1, area: true }],
|
||||
t0,
|
||||
t1,
|
||||
rate: DAY / bucket,
|
||||
binMinutes: bucket / 60e3,
|
||||
rate: DAY / bucketMs,
|
||||
binMinutes: bucketMs / 60e3,
|
||||
unitMinutes: 24 * 60,
|
||||
unit: 'day',
|
||||
}
|
||||
@@ -153,22 +164,19 @@ export function makeSeries(buckets, rangeKey) {
|
||||
/**
|
||||
* Absolute UTC time window for a given range key. Used to filter visits,
|
||||
* transitions and views for the non-chart stats on the analytics page.
|
||||
* The week range is a rolling 7 days ending at now; the charts instead
|
||||
* align to Monday 00:00 UTC and overlay previous weeks, so their x window
|
||||
* differs from the stats range on purpose.
|
||||
* Every bounded range is a rolling span ending at now; the charts instead
|
||||
* align week to Monday 00:00 UTC (overlaying previous weeks) and month+
|
||||
* to UTC day boundaries, so their x windows differ from the stats range
|
||||
* on purpose.
|
||||
* Returns { t0, t1 } where null means unbounded.
|
||||
*/
|
||||
export function rangeWindow(rangeKey) {
|
||||
const now = Date.now()
|
||||
if (rangeKey === 'week') {
|
||||
return { t0: now - WEEK, t1: now }
|
||||
}
|
||||
if (rangeKey === 'all') {
|
||||
return { t0: null, t1: null }
|
||||
}
|
||||
const { span, bucket } = RANGES[rangeKey]
|
||||
const t1 = Math.floor(now / bucket) * bucket + bucket
|
||||
return { t0: t1 - span, t1 }
|
||||
const span = rangeKey === 'week' ? WEEK : RANGES[rangeKey].span
|
||||
return { t0: now - span, t1: now }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user