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:
2026-08-24 12:31:25 +00:00
parent 921a5484a2
commit 563e8fcaf2
5 changed files with 126 additions and 150 deletions
+8 -4
View File
@@ -236,10 +236,14 @@ truncated at the current bucket, never drawing fake zeroes for the future);
its x labels are weekday names centered at midday UTC, without vertical grid
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,
with the month name substituted for the 1st. Year is a rolling 365-day window ending at now, re-bucketed to daily points,
with boundary lines at months/years. All uses the full data reach, but keeps
at least the past 30 days so the chart never collapses to a tiny sliver when
the site is young. Below the charts: a **transition map** (all pages from
with the month name substituted for the 1st. Month, year and all are
rolling windows ending at now, aligned to UTC day boundaries at the start
so the labels span the whole range; the bucket size follows the window —
6 hours up to 31 days, daily beyond — with boundary lines at months/years
on the longer ranges. All uses the full data reach, but keeps
at least the past 30 days (identical to the month view when the site is
younger than that, bucket size included) so the chart never collapses to a
tiny sliver when the site is young. Below the charts: a **transition map** (all pages from
`/_api/pages` — top-level menu items on a large-radius circular arc whose
bottom point is the last item (each earlier item a bit higher), connected
by an unlabeled top lane, each item's
+5 -3
View File
@@ -7,7 +7,7 @@
* so the visual scale can normalize against a one-week reference.
*/
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { WEEK } from './analytics/time.js'
import { DAY, WEEK } from './analytics/time.js'
import { formatCount } from './analytics/format.js'
import {
TNODE_W,
@@ -26,14 +26,16 @@ const props = defineProps({
const visualScale = computed(() => {
const { t0, t1 } = props.window
if (t0 != null && t1 != null) return WEEK / (t1 - t0)
// 'all': scale by the actual data span.
// 'all': scale by the actual data span, but never less than the 30-day
// minimum the plot enforces, so sparse young data is not over-amplified.
const times = new Set()
for (const buckets of Object.values(props.data?.views || {})) {
for (const k of Object.keys(buckets)) times.add(Date.parse(k))
}
const arr = [...times]
if (arr.length < 2) return 1
return WEEK / (Math.max(...arr) - Math.min(...arr))
const span = Math.max(...arr) - Math.min(...arr)
return WEEK / Math.max(span, 30 * DAY)
})
const graph = computed(() =>
+49 -90
View File
@@ -4,7 +4,15 @@
*/
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { makeSeries } from './analytics/time.js'
import { CHART_H, CHART_W, buildChart, fmtY } from './analytics/chart.js'
import {
CHART_H,
CHART_W,
MARGIN_B,
MARGIN_L,
VIEW_H,
VIEW_W,
buildChart,
} from './analytics/chart.js'
const DAY_REFRESH_MS = 15000
@@ -48,41 +56,35 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
{ ylabel: 'views', chart: viewChart, empty: 'no views recorded yet' },
]" :key="c.ylabel">
<template v-if="c.chart">
<div class="chartwrap">
<div class="plot">
<div class="plotarea">
<span class="yaxis-label">{{ freqLabel(c.chart.unit) }} {{ c.ylabel }}</span>
<svg class="chart" :viewBox="`0 0 ${CHART_W} ${CHART_H}`"
preserveAspectRatio="none" role="img" :aria-label="`${freqLabel(c.chart.unit)} ${c.ylabel}`">
<line v-for="g in c.chart.majors.slice(1)" :key="'j' + g.value"
:x1="0" :x2="CHART_W" :y1="g.y" :y2="g.y" class="major" />
<template v-for="t in c.chart.xticks" :key="'t' + t.x">
<line v-if="t.line" :x1="t.x" :x2="t.x" :y1="0" :y2="CHART_H"
class="minor vertical" />
</template>
<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>
<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" :style="{ opacity: s.opacity }" />
</template>
</template>
<line :x1="0" :x2="CHART_W" :y1="CHART_H - 0.5" :y2="CHART_H - 0.5"
class="axis" />
</svg>
<span v-for="g in c.chart.majors" :key="g.value" class="ylab"
:style="{ bottom: g.bottom + '%' }">{{ fmtY(g.value) }}</span>
</div>
<div class="xlabels">
<span v-for="t in c.chart.xticks" :key="t.x" class="xlab"
:style="{ left: t.left + '%' }">{{ t.label }}</span>
</div>
</div>
</div>
<svg class="chart" :viewBox="`${-MARGIN_L} 0 ${VIEW_W} ${VIEW_H}`"
role="img" :aria-label="`${freqLabel(c.chart.unit)} ${c.ylabel}`">
<line v-for="g in c.chart.majors.slice(1)" :key="'j' + g.value"
:x1="0" :x2="CHART_W" :y1="g.y" :y2="g.y" class="major" />
<template v-for="t in c.chart.xticks" :key="'t' + t.x">
<line v-if="t.line" :x1="t.x" :x2="t.x" :y1="0" :y2="CHART_H"
class="minor vertical" />
</template>
<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>
<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" :style="{ opacity: s.opacity }" />
</template>
</template>
<line :x1="0" :x2="CHART_W" :y1="CHART_H - 0.5" :y2="CHART_H - 0.5"
class="axis" />
<text v-for="g in c.chart.majors" :key="'y' + g.value" x="-8" :y="g.y"
text-anchor="end" dominant-baseline="middle" class="ylab">{{ g.label }}</text>
<text :x="-(MARGIN_L - 10)" :y="CHART_H / 2" text-anchor="middle"
:transform="`rotate(-90 ${-(MARGIN_L - 10)} ${CHART_H / 2})`"
class="yaxis-label">{{ freqLabel(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>
</svg>
<div v-if="c.chart.series && c.chart.series.length > 1" class="legend">
<span v-for="(s, i) in c.chart.series" :key="i" :style="{ opacity: s.opacity }">
● {{ s.label }}
@@ -94,57 +96,25 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
</template>
<style scoped>
/* The svg is stretched (preserveAspectRatio none), so all text lives in
HTML overlays positioned by the same fractions the geometry uses. */
.chartwrap {
padding-left: 2.8rem; /* y labels */
}
.plot {
display: flex;
flex-direction: column;
width: 100%;
}
.plotarea {
position: relative;
height: 8rem;
}
.xlabels {
position: relative;
height: 1.2rem;
}
/* Each chart is a self-contained SVG: the viewBox includes the axis label
margins, so nothing is positioned with HTML overlays. */
.chart {
display: block;
width: 100%;
height: 100%;
height: auto;
}
.ylab {
position: absolute;
left: -2.8rem;
width: 2.6rem;
text-align: right;
transform: translateY(50%);
font-size: 0.75rem;
color: var(--muted);
.chart .ylab,
.chart .xlab,
.chart .yaxis-label {
font-size: 12px;
fill: var(--muted);
}
.chart .ylab {
font-variant-numeric: tabular-nums;
}
.xlab {
position: absolute;
top: 0.25rem;
transform: translateX(-50%);
font-size: 0.75rem;
color: var(--muted);
white-space: nowrap;
}
.xlabels .xlab:first-child { transform: none; }
.xlabels .xlab:last-child { transform: translateX(-100%); }
.chart .minor {
stroke: var(--line);
stroke-width: 1;
@@ -199,17 +169,6 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
.legend span { color: var(--accent); }
.yaxis-label {
position: absolute;
top: 50%;
left: -2.8rem;
font-size: 0.75rem;
color: var(--muted);
writing-mode: vertical-rl;
white-space: nowrap;
transform: translateY(-50%) rotate(180deg);
}
section { margin-top: 1.8rem; }
.empty { color: var(--muted); }
+39 -36
View File
@@ -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: [] }
}
+25 -17
View File
@@ -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 }
}
/**