Fix week overlay alignment, in-plot ISO week legend, shorter charts

- weeklySeries shifts overlaid weeks onto the current week's time axis so
  they overlay inside the plot instead of overflowing left; oldest weeks
  paint first, current week on top
- Legend moved inside the visits chart's top right: current ISO week in
  accent, past weeks as a single muted "Week M" / "Week M–N" specimen
- Past week curves use the muted color instead of faded accent
- Chart height reduced ~30% (180 -> 126)
This commit is contained in:
2026-08-24 17:03:49 +00:00
parent 563e8fcaf2
commit 2de4717230
4 changed files with 53 additions and 23 deletions
+6 -2
View File
@@ -231,9 +231,13 @@ 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
fractional scale).
The week range is aligned to Monday 00:00 UTC and overlays up to 8 previous
weeks in the same accent color at decreasing opacity (the current week is
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 zeroes for the future);
its x labels are weekday names centered at midday UTC, without vertical grid
a compact legend inside the top right of the visits chart marks the current
ISO week in accent and the overlaid past weeks as "Week M" or "Week MN" on
a muted specimen. 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. Month, year and all are
+31 -17
View File
@@ -37,6 +37,12 @@ function freqLabel(unit) {
return unit === '5min' ? '5 min' : unit === 'hour' ? 'hourly' : 'daily'
}
/** Legend label for the overlaid past weeks: "Week M" or "Week MN". */
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())
let refreshInterval = null
onMounted(() => {
@@ -52,8 +58,8 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
<template>
<section v-for="c in [
{ ylabel: 'visits', chart: visitChart, empty: 'no visits recorded yet' },
{ ylabel: 'views', chart: viewChart, empty: 'no views recorded yet' },
{ ylabel: 'visits', chart: visitChart, legend: true, empty: 'no visits recorded yet' },
{ ylabel: 'views', chart: viewChart, legend: false, empty: 'no views recorded yet' },
]" :key="c.ylabel">
<template v-if="c.chart">
<svg class="chart" :viewBox="`${-MARGIN_L} 0 ${VIEW_W} ${VIEW_H}`"
@@ -70,9 +76,11 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
<path :d="c.chart.skyline" class="line" />
</template>
<template v-else>
<template v-for="(s, i) in c.chart.series" :key="i">
<!-- Oldest overlay weeks first so the current week paints on top. -->
<template v-for="(s, i) in [...c.chart.series].reverse()" :key="i">
<path v-if="s.area" :d="s.area" class="area" />
<path :d="s.line" class="line" :style="{ opacity: s.opacity }" />
<path :d="s.line" class="line" :class="{ past: s.past }"
:style="{ opacity: s.opacity }" />
</template>
</template>
<line :x1="0" :x2="CHART_W" :y1="CHART_H - 0.5" :y2="CHART_H - 0.5"
@@ -84,12 +92,18 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
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>
<!-- Week overlay legend, top right inside the plot: current week in
accent, one muted specimen for the whole past range. -->
<g v-if="c.legend && c.chart.series.length > 1">
<line :x1="CHART_W - 86" :x2="CHART_W - 66" y1="10" y2="10" class="line" />
<text :x="CHART_W - 60" y="10" dominant-baseline="middle"
class="leglab">{{ c.chart.series[0].label }}</text>
<line :x1="CHART_W - 86" :x2="CHART_W - 66" y1="23" y2="23"
class="line past" style="opacity: 0.6" />
<text :x="CHART_W - 60" y="23" dominant-baseline="middle"
class="leglab">{{ pastLabel(c.chart.series) }}</text>
</g>
</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 }}
</span>
</div>
</template>
<p v-else class="empty">{{ c.empty }}</p>
</section>
@@ -115,6 +129,11 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
font-variant-numeric: tabular-nums;
}
.chart .leglab {
font-size: 10px;
fill: var(--muted);
}
.chart .minor {
stroke: var(--line);
stroke-width: 1;
@@ -159,16 +178,11 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
stroke-linecap: round;
}
.legend {
display: flex;
gap: 1.2rem;
margin-top: 0.4rem;
font-size: 0.75rem;
color: var(--muted);
/* Past overlay weeks contrast with the current week's accent color. */
.chart .line.past {
stroke: var(--muted);
}
.legend span { color: var(--accent); }
section { margin-top: 1.8rem; }
.empty { color: var(--muted); }
+1 -1
View File
@@ -10,7 +10,7 @@ import { DAY, HOUR, MIN5, WEEK, mondayUTC } from './time.js'
import { formatCount } from './format.js'
export const CHART_W = 720
export const CHART_H = 180
export const CHART_H = 126
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
+15 -3
View File
@@ -26,6 +26,15 @@ export function mondayUTC(t) {
return (d - ((d + 3) % 7)) * DAY
}
/** ISO 8601 week number of the week containing t (via its Thursday). */
export function isoWeek(t) {
const d = new Date(t)
d.setUTCHours(0, 0, 0, 0)
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7))
const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1)
return Math.ceil(((d - yearStart) / DAY + 1) / 7)
}
/** Parse sparse timestamp buckets into a { epochMs: count } map. */
export function rawTimes(buckets) {
const raw = {}
@@ -44,7 +53,9 @@ export function sumRange(raw, t0, t1) {
/**
* One series per overlaid week: [this week, 1 week ago, ...], at native
* 5-minute resolution, up to 8 weeks back (and only weeks that overlap the
* recorded data at all). The current week is truncated at the current bucket
* 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".
* The coarser ranges use per-day rates instead (unitMinutes = 24*60).
@@ -67,12 +78,13 @@ export function weeklySeries(buckets) {
: start + WEEK
const points = []
for (let t = start; t < end; t += MIN5) {
points.push({ t, count: raw[t] || 0 })
points.push({ t: t + back * WEEK, count: raw[t] || 0 })
}
out.push({
points,
label: back === 0 ? 'this week' : `${back}w ago`,
label: `Week ${isoWeek(start)}`,
opacity: Math.max(0.15, 1 - back * 0.25),
past: back > 0,
area: back === 0,
})
}