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:
+8
-4
@@ -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
|
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
|
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,
|
||||||
with the month name substituted for the 1st. Year is a rolling 365-day window ending at now, re-bucketed to daily points,
|
with the month name substituted for the 1st. Month, year and all are
|
||||||
with boundary lines at months/years. All uses the full data reach, but keeps
|
rolling windows ending at now, aligned to UTC day boundaries at the start
|
||||||
at least the past 30 days so the chart never collapses to a tiny sliver when
|
so the labels span the whole range; the bucket size follows the window —
|
||||||
the site is young. Below the charts: a **transition map** (all pages from
|
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
|
`/_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
|
bottom point is the last item (each earlier item a bit higher), connected
|
||||||
by an unlabeled top lane, each item's
|
by an unlabeled top lane, each item's
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* so the visual scale can normalize against a one-week reference.
|
* so the visual scale can normalize against a one-week reference.
|
||||||
*/
|
*/
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
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 { formatCount } from './analytics/format.js'
|
||||||
import {
|
import {
|
||||||
TNODE_W,
|
TNODE_W,
|
||||||
@@ -26,14 +26,16 @@ const props = defineProps({
|
|||||||
const visualScale = computed(() => {
|
const visualScale = computed(() => {
|
||||||
const { t0, t1 } = props.window
|
const { t0, t1 } = props.window
|
||||||
if (t0 != null && t1 != null) return WEEK / (t1 - t0)
|
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()
|
const times = new Set()
|
||||||
for (const buckets of Object.values(props.data?.views || {})) {
|
for (const buckets of Object.values(props.data?.views || {})) {
|
||||||
for (const k of Object.keys(buckets)) times.add(Date.parse(k))
|
for (const k of Object.keys(buckets)) times.add(Date.parse(k))
|
||||||
}
|
}
|
||||||
const arr = [...times]
|
const arr = [...times]
|
||||||
if (arr.length < 2) return 1
|
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(() =>
|
const graph = computed(() =>
|
||||||
|
|||||||
@@ -4,7 +4,15 @@
|
|||||||
*/
|
*/
|
||||||
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 { 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
|
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' },
|
{ ylabel: 'views', chart: viewChart, empty: 'no views recorded yet' },
|
||||||
]" :key="c.ylabel">
|
]" :key="c.ylabel">
|
||||||
<template v-if="c.chart">
|
<template v-if="c.chart">
|
||||||
<div class="chartwrap">
|
<svg class="chart" :viewBox="`${-MARGIN_L} 0 ${VIEW_W} ${VIEW_H}`"
|
||||||
<div class="plot">
|
role="img" :aria-label="`${freqLabel(c.chart.unit)} ${c.ylabel}`">
|
||||||
<div class="plotarea">
|
<line v-for="g in c.chart.majors.slice(1)" :key="'j' + g.value"
|
||||||
<span class="yaxis-label">{{ freqLabel(c.chart.unit) }} {{ c.ylabel }}</span>
|
:x1="0" :x2="CHART_W" :y1="g.y" :y2="g.y" class="major" />
|
||||||
<svg class="chart" :viewBox="`0 0 ${CHART_W} ${CHART_H}`"
|
<template v-for="t in c.chart.xticks" :key="'t' + t.x">
|
||||||
preserveAspectRatio="none" role="img" :aria-label="`${freqLabel(c.chart.unit)} ${c.ylabel}`">
|
<line v-if="t.line" :x1="t.x" :x2="t.x" :y1="0" :y2="CHART_H"
|
||||||
<line v-for="g in c.chart.majors.slice(1)" :key="'j' + g.value"
|
class="minor vertical" />
|
||||||
:x1="0" :x2="CHART_W" :y1="g.y" :y2="g.y" class="major" />
|
</template>
|
||||||
<template v-for="t in c.chart.xticks" :key="'t' + t.x">
|
<template v-if="c.chart.bars">
|
||||||
<line v-if="t.line" :x1="t.x" :x2="t.x" :y1="0" :y2="CHART_H"
|
<rect v-for="(b, i) in c.chart.bars" :key="'b' + i"
|
||||||
class="minor vertical" />
|
:x="b.x" :y="b.y" :width="b.width" :height="b.height" class="bar" />
|
||||||
</template>
|
<path :d="c.chart.skyline" class="line" />
|
||||||
<template v-if="c.chart.bars">
|
</template>
|
||||||
<rect v-for="(b, i) in c.chart.bars" :key="'b' + i"
|
<template v-else>
|
||||||
:x="b.x" :y="b.y" :width="b.width" :height="b.height" class="bar" />
|
<template v-for="(s, i) in c.chart.series" :key="i">
|
||||||
<path :d="c.chart.skyline" class="line" />
|
<path v-if="s.area" :d="s.area" class="area" />
|
||||||
</template>
|
<path :d="s.line" class="line" :style="{ opacity: s.opacity }" />
|
||||||
<template v-else>
|
</template>
|
||||||
<template v-for="(s, i) in c.chart.series" :key="i">
|
</template>
|
||||||
<path v-if="s.area" :d="s.area" class="area" />
|
<line :x1="0" :x2="CHART_W" :y1="CHART_H - 0.5" :y2="CHART_H - 0.5"
|
||||||
<path :d="s.line" class="line" :style="{ opacity: s.opacity }" />
|
class="axis" />
|
||||||
</template>
|
<text v-for="g in c.chart.majors" :key="'y' + g.value" x="-8" :y="g.y"
|
||||||
</template>
|
text-anchor="end" dominant-baseline="middle" class="ylab">{{ g.label }}</text>
|
||||||
<line :x1="0" :x2="CHART_W" :y1="CHART_H - 0.5" :y2="CHART_H - 0.5"
|
<text :x="-(MARGIN_L - 10)" :y="CHART_H / 2" text-anchor="middle"
|
||||||
class="axis" />
|
:transform="`rotate(-90 ${-(MARGIN_L - 10)} ${CHART_H / 2})`"
|
||||||
</svg>
|
class="yaxis-label">{{ freqLabel(c.chart.unit) }} {{ c.ylabel }}</text>
|
||||||
<span v-for="g in c.chart.majors" :key="g.value" class="ylab"
|
<text v-for="t in c.chart.xticks" :key="'x' + t.x" :x="t.x" :y="CHART_H + MARGIN_B - 8"
|
||||||
:style="{ bottom: g.bottom + '%' }">{{ fmtY(g.value) }}</span>
|
text-anchor="middle" class="xlab">{{ t.label }}</text>
|
||||||
</div>
|
</svg>
|
||||||
<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>
|
|
||||||
<div v-if="c.chart.series && c.chart.series.length > 1" class="legend">
|
<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 }">
|
<span v-for="(s, i) in c.chart.series" :key="i" :style="{ opacity: s.opacity }">
|
||||||
● {{ s.label }}
|
● {{ s.label }}
|
||||||
@@ -94,57 +96,25 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* The svg is stretched (preserveAspectRatio none), so all text lives in
|
/* Each chart is a self-contained SVG: the viewBox includes the axis label
|
||||||
HTML overlays positioned by the same fractions the geometry uses. */
|
margins, so nothing is positioned with HTML overlays. */
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart {
|
.chart {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ylab {
|
.chart .ylab,
|
||||||
position: absolute;
|
.chart .xlab,
|
||||||
left: -2.8rem;
|
.chart .yaxis-label {
|
||||||
width: 2.6rem;
|
font-size: 12px;
|
||||||
text-align: right;
|
fill: var(--muted);
|
||||||
transform: translateY(50%);
|
}
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--muted);
|
.chart .ylab {
|
||||||
font-variant-numeric: tabular-nums;
|
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 {
|
.chart .minor {
|
||||||
stroke: var(--line);
|
stroke: var(--line);
|
||||||
stroke-width: 1;
|
stroke-width: 1;
|
||||||
@@ -199,17 +169,6 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
|||||||
|
|
||||||
.legend span { color: var(--accent); }
|
.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; }
|
section { margin-top: 1.8rem; }
|
||||||
|
|
||||||
.empty { color: var(--muted); }
|
.empty { color: var(--muted); }
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Chart geometry, smoothing, and SVG path generation for analytics charts.
|
* Chart geometry, smoothing, and SVG path generation for analytics charts.
|
||||||
*
|
*
|
||||||
* Fixed 720x180 viewBox, stretched to the panel width; values are per-unit
|
* Fixed 720x180 plot area inside a larger viewBox that also holds the axis
|
||||||
* rates (hour on the week view, day on month+).
|
* 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'
|
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_W = 720
|
||||||
export const CHART_H = 180
|
export const CHART_H = 180
|
||||||
export const PAD_TOP = 14 // room above the highest point
|
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
|
* 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)
|
const nMajor = Math.round(max / step)
|
||||||
for (let k = 0; k <= nMajor; k++) {
|
for (let k = 0; k <= nMajor; k++) {
|
||||||
const v = k * step
|
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) {
|
if (minor) {
|
||||||
for (let v = minor; v < max; v += 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.
|
// ranges: boundary lines at Mondays / months / years.
|
||||||
const isWeek = t1 - t0 === WEEK
|
const isWeek = t1 - t0 === WEEK
|
||||||
const isMonth = !isWeek && t1 - t0 <= 31 * DAY
|
const isMonth = !isWeek && t1 - t0 <= 31 * DAY
|
||||||
const xticks = isWeek
|
let xticks
|
||||||
? Array.from({ length: 7 }, (_, d) => {
|
if (isWeek) {
|
||||||
const t = t0 + d * DAY + 12 * HOUR
|
xticks = Array.from({ length: 7 }, (_, d) => {
|
||||||
return {
|
const t = t0 + d * DAY + 12 * HOUR
|
||||||
x: x(t), left: ((t - t0) / (t1 - t0)) * 100,
|
return {
|
||||||
label: new Date(t).toLocaleDateString(undefined, {
|
x: x(t),
|
||||||
weekday: 'short', timeZone: 'UTC',
|
label: new Date(t).toLocaleDateString(undefined, {
|
||||||
}),
|
weekday: 'short', timeZone: 'UTC',
|
||||||
line: false,
|
}),
|
||||||
}
|
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(
|
} else {
|
||||||
{ length: Math.floor((t1 - Math.ceil(t0 / DAY) * DAY) / DAY) },
|
xticks = xticksFor(t0, t1).map((t) => ({
|
||||||
(_, d) => {
|
x: x(t), label: fmtTick(t, t1 - t0), line: true,
|
||||||
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,
|
|
||||||
}))
|
|
||||||
return { max, majors, minors, series: drawn, xticks, unit }
|
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)
|
const nMajor = Math.round(max / step)
|
||||||
for (let k = 0; k <= nMajor; k++) {
|
for (let k = 0; k <= nMajor; k++) {
|
||||||
const v = k * step
|
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) {
|
if (minor) {
|
||||||
for (let v = minor; v < max; v += 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)
|
const d = new Date(t)
|
||||||
xticks.push({
|
xticks.push({
|
||||||
x: ((t - t0) / (t1 - t0)) * CHART_W,
|
x: ((t - t0) / (t1 - t0)) * CHART_W,
|
||||||
left: ((t - t0) / (t1 - t0)) * 100,
|
|
||||||
label: `${String(d.getUTCHours()).padStart(2, '0')}:00`,
|
label: `${String(d.getUTCHours()).padStart(2, '0')}:00`,
|
||||||
line: false,
|
line: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { bars, skyline: skyline.trim(), max, majors, minors, xticks, unit: '5min', series: [] }
|
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).
|
* 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
|
* 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.
|
* 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) {
|
export function rollingSeries(buckets, rangeKey) {
|
||||||
const raw = rawTimes(buckets)
|
const raw = rawTimes(buckets)
|
||||||
const times = Object.keys(raw).map(Number)
|
const times = Object.keys(raw).map(Number)
|
||||||
if (!times.length) return null
|
if (!times.length) return null
|
||||||
const { span, bucket, minSpan = 0 } = RANGES[rangeKey]
|
const { span, bucket, minSpan = 0 } = RANGES[rangeKey]
|
||||||
const t1 = Math.floor(Date.now() / bucket) * bucket + bucket
|
const t1 = Date.now()
|
||||||
const earliest = Math.floor(Math.min(...times) / bucket) * bucket
|
const earliest = Math.min(...times)
|
||||||
const t0 = span != null
|
const t0 = Math.floor((span != null
|
||||||
? t1 - span
|
? 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 = []
|
const points = []
|
||||||
for (let t = t0; t < t1; t += bucket) {
|
for (let t = t0; t < t1; t += bucketMs) {
|
||||||
points.push({ t, count: sumRange(raw, t, t + bucket) })
|
points.push({ t, count: sumRange(raw, t, t + bucketMs) })
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
series: [{ points, label: '', opacity: 1, area: true }],
|
series: [{ points, label: '', opacity: 1, area: true }],
|
||||||
t0,
|
t0,
|
||||||
t1,
|
t1,
|
||||||
rate: DAY / bucket,
|
rate: DAY / bucketMs,
|
||||||
binMinutes: bucket / 60e3,
|
binMinutes: bucketMs / 60e3,
|
||||||
unitMinutes: 24 * 60,
|
unitMinutes: 24 * 60,
|
||||||
unit: 'day',
|
unit: 'day',
|
||||||
}
|
}
|
||||||
@@ -153,22 +164,19 @@ export function makeSeries(buckets, rangeKey) {
|
|||||||
/**
|
/**
|
||||||
* Absolute UTC time window for a given range key. Used to filter visits,
|
* 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.
|
* 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
|
* Every bounded range is a rolling span ending at now; the charts instead
|
||||||
* align to Monday 00:00 UTC and overlay previous weeks, so their x window
|
* align week to Monday 00:00 UTC (overlaying previous weeks) and month+
|
||||||
* differs from the stats range on purpose.
|
* to UTC day boundaries, so their x windows differ from the stats range
|
||||||
|
* on purpose.
|
||||||
* Returns { t0, t1 } where null means unbounded.
|
* Returns { t0, t1 } where null means unbounded.
|
||||||
*/
|
*/
|
||||||
export function rangeWindow(rangeKey) {
|
export function rangeWindow(rangeKey) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (rangeKey === 'week') {
|
|
||||||
return { t0: now - WEEK, t1: now }
|
|
||||||
}
|
|
||||||
if (rangeKey === 'all') {
|
if (rangeKey === 'all') {
|
||||||
return { t0: null, t1: null }
|
return { t0: null, t1: null }
|
||||||
}
|
}
|
||||||
const { span, bucket } = RANGES[rangeKey]
|
const span = rangeKey === 'week' ? WEEK : RANGES[rangeKey].span
|
||||||
const t1 = Math.floor(now / bucket) * bucket + bucket
|
return { t0: now - span, t1: now }
|
||||||
return { t0: t1 - span, t1 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user