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 range is 10 so tiny values such as a single visit are not stretched to a
fractional scale). fractional scale).
The week range is aligned to Monday 00:00 UTC and overlays up to 8 previous 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); 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 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. Month, year and all are 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' 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()) const now = ref(Date.now())
let refreshInterval = null let refreshInterval = null
onMounted(() => { onMounted(() => {
@@ -52,8 +58,8 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
<template> <template>
<section v-for="c in [ <section v-for="c in [
{ ylabel: 'visits', chart: visitChart, empty: 'no visits recorded yet' }, { ylabel: 'visits', chart: visitChart, legend: true, empty: 'no visits recorded yet' },
{ ylabel: 'views', chart: viewChart, empty: 'no views recorded yet' }, { ylabel: 'views', chart: viewChart, legend: false, empty: 'no views recorded yet' },
]" :key="c.ylabel"> ]" :key="c.ylabel">
<template v-if="c.chart"> <template v-if="c.chart">
<svg class="chart" :viewBox="`${-MARGIN_L} 0 ${VIEW_W} ${VIEW_H}`" <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" /> <path :d="c.chart.skyline" class="line" />
</template> </template>
<template v-else> <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 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>
</template> </template>
<line :x1="0" :x2="CHART_W" :y1="CHART_H - 0.5" :y2="CHART_H - 0.5" <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> 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 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> 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> </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> </template>
<p v-else class="empty">{{ c.empty }}</p> <p v-else class="empty">{{ c.empty }}</p>
</section> </section>
@@ -115,6 +129,11 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.chart .leglab {
font-size: 10px;
fill: var(--muted);
}
.chart .minor { .chart .minor {
stroke: var(--line); stroke: var(--line);
stroke-width: 1; stroke-width: 1;
@@ -159,16 +178,11 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
stroke-linecap: round; stroke-linecap: round;
} }
.legend { /* Past overlay weeks contrast with the current week's accent color. */
display: flex; .chart .line.past {
gap: 1.2rem; stroke: var(--muted);
margin-top: 0.4rem;
font-size: 0.75rem;
color: var(--muted);
} }
.legend span { color: var(--accent); }
section { margin-top: 1.8rem; } section { margin-top: 1.8rem; }
.empty { color: var(--muted); } .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' import { formatCount } from './format.js'
export const CHART_W = 720 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 PAD_TOP = 14 // room above the highest point
export const MARGIN_L = 56 // y tick labels + vertical axis label export const MARGIN_L = 56 // y tick labels + vertical axis label
export const MARGIN_B = 24 // x tick labels 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 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. */ /** Parse sparse timestamp buckets into a { epochMs: count } map. */
export function rawTimes(buckets) { export function rawTimes(buckets) {
const raw = {} const raw = {}
@@ -44,7 +53,9 @@ export function sumRange(raw, t0, t1) {
/** /**
* One series per overlaid week: [this week, 1 week ago, ...], at native * 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 * 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 * — 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". * (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). * The coarser ranges use per-day rates instead (unitMinutes = 24*60).
@@ -67,12 +78,13 @@ export function weeklySeries(buckets) {
: start + WEEK : start + WEEK
const points = [] const points = []
for (let t = start; t < end; t += MIN5) { 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({ out.push({
points, points,
label: back === 0 ? 'this week' : `${back}w ago`, label: `Week ${isoWeek(start)}`,
opacity: Math.max(0.15, 1 - back * 0.25), opacity: Math.max(0.15, 1 - back * 0.25),
past: back > 0,
area: back === 0, area: back === 0,
}) })
} }