Analytics layout update, larger, consistent text sizing.
This commit is contained in:
@@ -261,9 +261,10 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
||||
}
|
||||
|
||||
.analytics-panel {
|
||||
margin: 0 auto;
|
||||
width: min(60rem, 96vw);
|
||||
padding: 1.5rem 2rem 4rem;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
/* Same 1.25rem side spacing as main's article padding. */
|
||||
padding: 1.5rem 1.25rem 4rem;
|
||||
}
|
||||
|
||||
.analytics-panel header {
|
||||
@@ -286,7 +287,7 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
||||
.ranges button {
|
||||
padding: 0.2rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: 1px solid var(--line);
|
||||
@@ -344,7 +345,7 @@ const abuseRows = computed(() => formatAbuseRows(data.value?.abuse || [], client
|
||||
.visit-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* count), so the graph sums the buckets falling inside the selected
|
||||
* 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 { formatCount } from './analytics/format.js'
|
||||
import {
|
||||
@@ -121,11 +121,36 @@ const startBeads = (flows) => {
|
||||
|
||||
watch(() => graph.value?.flows, startBeads, { immediate: true })
|
||||
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>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<defs>
|
||||
<!-- 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 }">
|
||||
<textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ x.label }}</textPath>
|
||||
</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>
|
||||
<g v-else :title="x.path">
|
||||
<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 }">
|
||||
<textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ x.label }}</textPath>
|
||||
</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 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 }">
|
||||
<textPath href="#tnode-label-arc" startOffset="50%" text-anchor="middle" side="right">{{ n.label }}</textPath>
|
||||
</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) }}
|
||||
</text>
|
||||
</a>
|
||||
@@ -187,8 +212,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
||||
.tmap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 36rem;
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
.tmap .tconn {
|
||||
fill: var(--accent);
|
||||
@@ -218,21 +242,24 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
||||
stroke: var(--accent);
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
.tmap a { cursor: pointer; }
|
||||
.tmap a:hover .tnodeslug { fill: var(--accent); }
|
||||
.tmap .tnodecount {
|
||||
fill: var(--muted);
|
||||
font-size: 10px;
|
||||
font-size: calc(13px / var(--u, 1));
|
||||
text-anchor: middle;
|
||||
}
|
||||
.tmap .tnodehidden {
|
||||
fill: var(--text);
|
||||
font-size: 9px;
|
||||
font-size: calc(13px / var(--u, 1));
|
||||
}
|
||||
|
||||
section { margin-top: 1.8rem; }
|
||||
|
||||
@@ -128,7 +128,7 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
||||
width: 2.6rem;
|
||||
text-align: right;
|
||||
transform: translateY(50%);
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -137,7 +137,7 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -203,7 +203,7 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -2.8rem;
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
writing-mode: vertical-rl;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
|
||||
import { MIN_READ_SECONDS } from './format.js'
|
||||
|
||||
export const TNODE_R = 34 // 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 TNODE_R = 60 // node circles hold the slug and the view count
|
||||
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
|
||||
// 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
|
||||
// beads in flight.
|
||||
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 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
|
||||
|
||||
// Radius of each node surround and the attachment geometry on it.
|
||||
const R2A = ra + 3
|
||||
const R2B = rb + 3
|
||||
// These are intentionally derived from the node radius so resizing the
|
||||
// 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,
|
||||
// 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
|
||||
// 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
|
||||
// perpendicular to it.
|
||||
|
||||
@@ -791,8 +791,11 @@ figure:has(img[width]) {
|
||||
|
||||
The rules below re-anchor the bleed for the layouts where the article
|
||||
is not viewport-centered; each just overrides width/margin-inline, and
|
||||
later rules win at equal specificity. */
|
||||
figure:has(.wide) {
|
||||
later rules win at equal specificity. The analytics dashboard uses the
|
||||
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;
|
||||
max-width: none;
|
||||
margin-inline: calc(50% - 50vw);
|
||||
|
||||
+3
-1
@@ -772,7 +772,9 @@ def render_analytics(
|
||||
stylesheets = page_stylesheets + analytics_stylesheets
|
||||
doc = E.article
|
||||
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(
|
||||
_layout(
|
||||
scripts,
|
||||
|
||||
Reference in New Issue
Block a user