Files
pagerite/frontend/src/VisitorCharts.vue
T
LeoVasanko 4522b8fa40 Analytics: stronger chart fills, line specimen for the day legend
Area and bar fills at 0.6 opacity (were a near-invisible 0.15); the day
view's 'Last 24 hours' legend entry uses the same full-opacity accent
line as the other entries instead of a translucent bar swatch.
2026-09-18 13:54:33 +00:00

223 lines
7.5 KiB
Vue

<script setup>
/**
* 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 { typicalWeek, weekBinIndex } from './analytics/seasonal.js'
import {
CHART_H,
CHART_W,
MARGIN_B,
MARGIN_L,
VIEW_H,
VIEW_W,
buildChart,
} from './analytics/chart.js'
const DAY_REFRESH_MS = 15000
// Keep the whole svg within page bounds: full width below the natural
// size, centered with equal side margins above it (max() clamps the
// centering margin to 0 at the breakpoint, so the rule is continuous).
const CHART_MARGIN = `max(0px, calc(50% - ${VIEW_W / 2}px))`
const props = defineProps({
data: { type: Object, default: null },
range: { type: String, required: true },
})
// Views across all pages combined into one raw bucket map.
const allViews = computed(() => {
const all = {}
for (const buckets of Object.values(props.data?.views || {})) {
for (const [k, c] of Object.entries(buckets)) all[k] = (all[k] || 0) + c
}
return all
})
function freqLabel(unit) {
return unit === '5min' ? '5 min' : unit === 'hour' ? 'hourly' : 'daily'
}
/** Vertical axis caption: "visits / 5 min" on the day view, else "hourly visits" style. */
function axisLabel(unit, ylabel) {
return unit === '5min' ? `${ylabel} / 5 min` : `${freqLabel(unit)} ${ylabel}`
}
const now = ref(Date.now())
let refreshInterval = null
onMounted(() => {
refreshInterval = setInterval(() => { now.value = Date.now() }, DAY_REFRESH_MS)
})
onUnmounted(() => {
if (refreshInterval) clearInterval(refreshInterval)
})
/**
* 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>
<template>
<section v-for="c in [
{ ylabel: 'visits', chart: visitChart, legend: true },
{ ylabel: 'views', chart: viewChart, legend: false },
]" :key="c.ylabel">
<template v-if="c.chart">
<svg class="chart" :viewBox="`${-MARGIN_L} 0 ${VIEW_W} ${VIEW_H}`"
:style="{ maxWidth: `${VIEW_W}px`, marginLeft: CHART_MARGIN }"
role="img" :aria-label="axisLabel(c.chart.unit, c.ylabel)">
<!-- Clip the plot curves to the chart area; the svg itself is
overflow: visible for the axis labels. -->
<clipPath :id="`plot-${c.ylabel}`">
<rect x="0" y="0" :width="CHART_W" :height="CHART_H" />
</clipPath>
<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>
<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">
<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" />
</template>
</template>
</g>
<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="-5" :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">{{ 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-anchor="middle" class="xlab">{{ t.label }}</text>
<!-- Legend, top right inside the plot: current data in accent
(week label, or "Last 24 hours" on the day view) and the
typical history curve in muted. -->
<g v-if="c.legend && c.chart.typical">
<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.bars ? 'Last 24 hours' : c.chart.series[0].label }}</text>
<line :x1="CHART_W - 118" :x2="CHART_W - 98" y1="25" y2="25"
class="line past" style="opacity: 0.6" />
<text :x="CHART_W - 92" y="25" dominant-baseline="middle"
class="leglab">{{ c.chart.typical.label }}</text>
</g>
</svg>
</template>
</section>
</template>
<style scoped>
/* Each chart is a self-contained SVG: the viewBox includes the axis label
margins, so nothing is positioned with HTML overlays. Never upscale past
the natural size (1 viewBox unit = 1 px, max-width set inline) — that
would blow up the constant-size text; smaller panels still scale the
chart down to fit. The margin-left (set inline) centers the chart above
its natural width; the svg always stays within page bounds.
overflow: visible lets wider fonts extend past the viewBox instead of
clipping. */
.chart {
display: block;
width: 100%;
height: auto;
overflow: visible;
}
.chart .ylab,
.chart .xlab,
.chart .yaxis-label,
.chart .leglab {
font-family: system-ui, sans-serif; /* theme fonts can be overly styled */
font-size: 11px;
fill: var(--muted);
}
.chart .ylab {
font-variant-numeric: tabular-nums;
}
.chart .minor {
stroke: var(--line);
stroke-width: 1;
vector-effect: non-scaling-stroke;
opacity: 0.35;
}
.chart .minor.vertical {
opacity: 0.25;
}
.chart .major {
stroke: var(--line);
stroke-width: 1;
vector-effect: non-scaling-stroke;
stroke-dasharray: 3 4;
opacity: 0.8;
}
.chart .axis {
stroke: var(--line);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.chart .area {
fill: var(--accent);
opacity: 0.6;
}
.chart .bar {
fill: var(--accent);
opacity: 0.6;
}
.chart .line {
fill: none;
stroke: var(--accent);
stroke-width: 2;
vector-effect: non-scaling-stroke;
stroke-linejoin: round;
stroke-linecap: round;
}
/* Past overlay weeks contrast with the current week's accent color. */
.chart .line.past {
stroke: var(--muted);
}
.empty { color: var(--muted); }
</style>