Analytics layout update, larger, consistent text sizing.

This commit is contained in:
2026-08-22 14:45:56 +00:00
parent 319163ee7e
commit 8aad64cced
6 changed files with 66 additions and 27 deletions
+6 -5
View File
@@ -261,9 +261,10 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
} }
.analytics-panel { .analytics-panel {
margin: 0 auto; margin: 0;
width: min(60rem, 96vw); width: 100%;
padding: 1.5rem 2rem 4rem; /* Same 1.25rem side spacing as main's article padding. */
padding: 1.5rem 1.25rem 4rem;
} }
.analytics-panel header { .analytics-panel header {
@@ -286,7 +287,7 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
.ranges button { .ranges button {
padding: 0.2rem 0.7rem; padding: 0.2rem 0.7rem;
font: inherit; font: inherit;
font-size: 0.85rem; font-size: 0.9rem;
color: var(--muted); color: var(--muted);
background: none; background: none;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -344,7 +345,7 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
.visit-table { .visit-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.82rem; font-size: 0.9rem;
line-height: 1.3; line-height: 1.3;
} }
+37 -10
View File
@@ -6,7 +6,7 @@
* count), so the graph sums the buckets falling inside the selected * count), so the graph sums the buckets falling inside the selected
* range, exactly like the charts and per-page views do. * range, exactly like the charts and per-page views do.
*/ */
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { rangeWindow, WEEK } from './analytics/time.js' import { rangeWindow, WEEK } from './analytics/time.js'
import { formatCount } from './analytics/format.js' import { formatCount } from './analytics/format.js'
import { import {
@@ -121,11 +121,36 @@ const startBeads = (flows) => {
watch(() => graph.value?.flows, startBeads, { immediate: true }) watch(() => graph.value?.flows, startBeads, { immediate: true })
onBeforeUnmount(() => cancelAnimationFrame(rafId)) onBeforeUnmount(() => cancelAnimationFrame(rafId))
// Text in the graph must render at a constant screen size regardless of
// how far the enlarged graph's viewBox is scaled down to fit the panel:
// measure the unit→pixel ratio and expose it as --u on the svg, which the
// font-size rules divide by. Falls back to 1 (raw units) until measured.
const svgEl = ref(null)
const pxPerUnit = ref(1)
let resizeObs = null
function updateScale() {
const el = svgEl.value
if (el && el.viewBox.baseVal.width) {
pxPerUnit.value = el.clientWidth / el.viewBox.baseVal.width
}
}
onMounted(() => {
resizeObs = new ResizeObserver(updateScale)
})
watch(svgEl, (el) => {
resizeObs?.disconnect()
if (el) resizeObs?.observe(el)
})
watch(() => graph.value?.bounds, updateScale)
onBeforeUnmount(() => resizeObs?.disconnect())
</script> </script>
<template> <template>
<section v-if="graph"> <section v-if="graph">
<svg class="tmap" :viewBox="`${graph.bounds.x0} ${graph.bounds.y0} ${graph.bounds.x1 - graph.bounds.x0} ${graph.bounds.y1 - graph.bounds.y0}`" <svg ref="svgEl" class="tmap" :style="{ '--u': pxPerUnit }" :viewBox="`${graph.bounds.x0} ${graph.bounds.y0} ${graph.bounds.x1 - graph.bounds.x0} ${graph.bounds.y1 - graph.bounds.y0}`"
role="img" aria-label="map of transitions between pages"> role="img" aria-label="map of transitions between pages">
<defs> <defs>
<!-- Unit-radius circle; only the portion near the bottom is used. --> <!-- Unit-radius circle; only the portion near the bottom is used. -->
@@ -146,7 +171,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
<text :transform="`translate(${x.x}, ${x.y}) scale(${x.r - 4})`" class="tnodeslug" :style="{ '--node-r': x.r - 4 }"> <text :transform="`translate(${x.x}, ${x.y}) scale(${x.r - 4})`" class="tnodeslug" :style="{ '--node-r': x.r - 4 }">
<textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ x.label }}</textPath> <textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ x.label }}</textPath>
</text> </text>
<text :x="x.x" :y="x.y + 4" class="tnodecount">{{ formatCount(x.count) }}</text> <text :x="x.x" :y="x.y" class="tnodecount" dominant-baseline="middle">{{ formatCount(x.count) }}</text>
</a> </a>
<g v-else :title="x.path"> <g v-else :title="x.path">
<circle :cx="x.x" :cy="x.y" :r="x.r" <circle :cx="x.x" :cy="x.y" :r="x.r"
@@ -154,7 +179,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
<text :transform="`translate(${x.x}, ${x.y}) scale(${x.r - 4})`" class="tnodeslug" :style="{ '--node-r': x.r - 4 }"> <text :transform="`translate(${x.x}, ${x.y}) scale(${x.r - 4})`" class="tnodeslug" :style="{ '--node-r': x.r - 4 }">
<textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ x.label }}</textPath> <textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ x.label }}</textPath>
</text> </text>
<text :x="x.x" :y="x.y + 4" class="tnodecount">{{ formatCount(x.count) }}</text> <text :x="x.x" :y="x.y" class="tnodecount" dominant-baseline="middle">{{ formatCount(x.count) }}</text>
</g> </g>
</g> </g>
<g v-for="n in graph.nodes" :key="n.path"> <g v-for="n in graph.nodes" :key="n.path">
@@ -163,7 +188,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
<text :transform="`translate(${n.x}, ${n.y}) scale(${TNODE_R - 4})`" class="tnodeslug" :style="{ '--node-r': TNODE_R - 4 }"> <text :transform="`translate(${n.x}, ${n.y}) scale(${TNODE_R - 4})`" class="tnodeslug" :style="{ '--node-r': TNODE_R - 4 }">
<textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ n.label }}</textPath> <textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ n.label }}</textPath>
</text> </text>
<text :x="n.x" :y="n.y + 4" class="tnodecount"> <text :x="n.x" :y="n.y" class="tnodecount" dominant-baseline="middle">
{{ n.readMin ? `${formatCount(n.views)}×${n.readMin}m` : formatCount(n.views) }} {{ n.readMin ? `${formatCount(n.views)}×${n.readMin}m` : formatCount(n.views) }}
</text> </text>
</a> </a>
@@ -187,8 +212,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
.tmap { .tmap {
display: block; display: block;
width: 100%; width: 100%;
max-width: 36rem; max-width: 100%;
margin: 0 auto;
} }
.tmap .tconn { .tmap .tconn {
fill: var(--accent); fill: var(--accent);
@@ -218,21 +242,24 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
stroke: var(--accent); stroke: var(--accent);
stroke-width: 1.5; stroke-width: 1.5;
} }
/* Text renders at a constant screen size: --u (set from JS) is the
viewBox-unit → pixel ratio of the rendered svg, so dividing by it makes
the sizes independent of how far the graph is scaled down. */
.tmap .tnodeslug { .tmap .tnodeslug {
fill: var(--text); fill: var(--text);
font-size: calc(11px / var(--node-r, 34)); font-size: calc(15px / (var(--u, 1) * var(--node-r, 56)));
text-anchor: middle; text-anchor: middle;
} }
.tmap a { cursor: pointer; } .tmap a { cursor: pointer; }
.tmap a:hover .tnodeslug { fill: var(--accent); } .tmap a:hover .tnodeslug { fill: var(--accent); }
.tmap .tnodecount { .tmap .tnodecount {
fill: var(--muted); fill: var(--muted);
font-size: 10px; font-size: calc(13px / var(--u, 1));
text-anchor: middle; text-anchor: middle;
} }
.tmap .tnodehidden { .tmap .tnodehidden {
fill: var(--text); fill: var(--text);
font-size: 9px; font-size: calc(13px / var(--u, 1));
} }
section { margin-top: 1.8rem; } section { margin-top: 1.8rem; }
+3 -3
View File
@@ -128,7 +128,7 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
width: 2.6rem; width: 2.6rem;
text-align: right; text-align: right;
transform: translateY(50%); transform: translateY(50%);
font-size: 0.7rem; font-size: 0.75rem;
color: var(--muted); color: var(--muted);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -137,7 +137,7 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
position: absolute; position: absolute;
top: 0.25rem; top: 0.25rem;
transform: translateX(-50%); transform: translateX(-50%);
font-size: 0.7rem; font-size: 0.75rem;
color: var(--muted); color: var(--muted);
white-space: nowrap; white-space: nowrap;
} }
@@ -203,7 +203,7 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
position: absolute; position: absolute;
top: 50%; top: 50%;
left: -2.8rem; left: -2.8rem;
font-size: 0.7rem; font-size: 0.75rem;
color: var(--muted); color: var(--muted);
writing-mode: vertical-rl; writing-mode: vertical-rl;
white-space: nowrap; white-space: nowrap;
+12 -6
View File
@@ -25,8 +25,8 @@
import { MIN_READ_SECONDS } from './format.js' import { MIN_READ_SECONDS } from './format.js'
export const TNODE_R = 34 // node circles hold the slug and the view count export const TNODE_R = 60 // node circles hold the slug and the view count
export const EXT_R = 34 // external referer/exit nodes use the same full size export const EXT_R = 60 // external source/exit nodes use the same full size
// Edge width (half-width of the thin middle) grows logarithmically with // Edge width (half-width of the thin middle) grows logarithmically with
// the count. The constants are scaled down by ~10× so busy ranges (day, // the count. The constants are scaled down by ~10× so busy ranges (day,
@@ -44,7 +44,7 @@ const PRUNE_FRACTION = 0.01
// bead independently in JS at BEAD_SPEED along the edge, with no limit on // bead independently in JS at BEAD_SPEED along the edge, with no limit on
// beads in flight. // beads in flight.
export const BEAD_SPEED = 180 // svg units per second export const BEAD_SPEED = 180 // svg units per second
export const BEAD_R = 2.2 export const BEAD_R = 3.2
const BEAD_RATE = 0.012 // beads per second per recorded transition const BEAD_RATE = 0.012 // beads per second per recorded transition
const FLOW_OFFSET = 3 // lane offset to the right of the travel direction const FLOW_OFFSET = 3 // lane offset to the right of the travel direction
@@ -321,8 +321,11 @@ function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R, external =
const ny = ux const ny = ux
// Radius of each node surround and the attachment geometry on it. // Radius of each node surround and the attachment geometry on it.
const R2A = ra + 3 // These are intentionally derived from the node radius so resizing the
const R2B = rb + 3 // graph (node circles, text) keeps connectors proportional.
const SURROUND = 4
const R2A = ra + SURROUND
const R2B = rb + SURROUND
// Attachment points sit somewhat forward from the side of the node, // Attachment points sit somewhat forward from the side of the node,
// leaving enough room for the surround to flow naturally into the flare. // leaving enough room for the surround to flow naturally into the flare.
@@ -334,7 +337,10 @@ function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R, external =
// Flares take a fair share of the free span while leaving the // Flares take a fair share of the free span while leaving the
// count-scaled thin middle a visible share of the connection length. // count-scaled thin middle a visible share of the connection length.
const FLARE = Math.min(36, Math.max(0, (len - ENDA - ENDB) * 0.4)) // The maximum flare length scales with the node radius so larger nodes
// still show a wide connector end instead of hiding it under the circle.
const FLARE_MAX = Math.max(ra, rb) * 1.2
const FLARE = Math.min(FLARE_MAX, Math.max(0, (len - ENDA - ENDB) * 0.4))
// Point on the connection centerline at distance t from A, offset s // Point on the connection centerline at distance t from A, offset s
// perpendicular to it. // perpendicular to it.
+5 -2
View File
@@ -791,8 +791,11 @@ figure:has(img[width]) {
The rules below re-anchor the bleed for the layouts where the article The rules below re-anchor the bleed for the layouts where the article
is not viewport-centered; each just overrides width/margin-inline, and is not viewport-centered; each just overrides width/margin-inline, and
later rules win at equal specificity. */ later rules win at equal specificity. The analytics dashboard uses the
figure:has(.wide) { same breakout directly on its container (div.wide — it is the page's
whole content, not a figure). */
figure:has(.wide),
div.wide {
width: 100vw; width: 100vw;
max-width: none; max-width: none;
margin-inline: calc(50% - 50vw); margin-inline: calc(50% - 50vw);
+3 -1
View File
@@ -772,7 +772,9 @@ def render_analytics(
stylesheets = page_stylesheets + analytics_stylesheets stylesheets = page_stylesheets + analytics_stylesheets
doc = E.article doc = E.article
with doc: with doc:
doc.div(id="analytics-app") # .wide: the dashboard breaks out of the article column to the full
# viewport width, like wide figures (see the .wide rules).
doc.div(id="analytics-app", class_="wide")
return str( return str(
_layout( _layout(
scripts, scripts,