Transition map: svg-scaled fonts, border-clipped pill text, tight crop
- Fonts scale with the svg instead of the --u constant-screen-size compensation (ResizeObserver machinery removed) - Larger node text (slug 19px, count 15px) - Pill labels no longer ellipsis-truncated: text is clipped at the pill border via per-node clipPaths; captions center when they fit and anchor left on overflow so the title's beginning survives; count lines stay centered - Bounding box crops to pill half extents plus the ribbon halo instead of the diagonal radius, removing the large top/bottom margins
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
* before passing them in; `window` carries the absolute [t0, t1) window
|
||||
* so the visual scale can normalize against a one-week reference.
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
|
||||
import { DAY, WEEK } from './analytics/time.js'
|
||||
import { formatCount } from './analytics/format.js'
|
||||
import {
|
||||
@@ -140,45 +140,14 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
||||
|
||||
// The svg never renders larger than its natural size (1 viewBox unit = 1
|
||||
// px, max-width below): the layout geometry is designed in pixel-like
|
||||
// units, and upscaling would blow the pills up around their constant-size
|
||||
// text. Narrow panels still scale the graph down to fit (width: 100%).
|
||||
// Text renders at a constant screen size regardless of that downscale:
|
||||
// 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
|
||||
// units, and upscaling would blow up the pills around their text. Narrow
|
||||
// panels scale the graph down to fit (width: 100%), text along with it.
|
||||
|
||||
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())
|
||||
|
||||
// Label text shortened to fit inside the pill at the current zoom (fonts
|
||||
// are fixed screen sizes): drop whole trailing words first, then hard-cut
|
||||
// with an ellipsis. Width estimate: ~0.52 em per glyph, 12 px padding
|
||||
// per side.
|
||||
const fitLabel = (label, fontPx = 15) => {
|
||||
const budget = Math.max(2, (TNODE_W * pxPerUnit.value - 24) / (0.52 * fontPx))
|
||||
if (label.length <= budget) return label
|
||||
const words = label.split(' ')
|
||||
while (words.length > 1 && words.join(' ').length + 1 > budget) words.pop()
|
||||
let out = words.join(' ')
|
||||
if (out.length + 1 > budget) out = out.slice(0, Math.floor(budget) - 1)
|
||||
return `${out}…`
|
||||
}
|
||||
// Pill text is not truncated: text is clipped at the pill's rounded border
|
||||
// (clipPath per node, inset a few units for padding). Captions center when
|
||||
// they fit; overlong ones anchor left so their beginning (not their
|
||||
// middle) survives the clip. Width estimate: ~0.52 em per glyph.
|
||||
const fitsPill = (label, fontPx = 19) => label.length * 0.52 * fontPx <= TNODE_W - 16
|
||||
|
||||
const countLabel = (n) =>
|
||||
n.readMin ? `${formatCount(n.views)}×${n.readMin}m` : formatCount(n.views)
|
||||
@@ -186,7 +155,7 @@ const countLabel = (n) =>
|
||||
|
||||
<template>
|
||||
<section v-if="graph">
|
||||
<svg ref="svgEl" class="tmap" :style="{ '--u': pxPerUnit, maxWidth: `${graph.bounds.x1 - graph.bounds.x0}px` }" :viewBox="`${graph.bounds.x0} ${graph.bounds.y0} ${graph.bounds.x1 - graph.bounds.x0} ${graph.bounds.y1 - graph.bounds.y0}`"
|
||||
<svg class="tmap" :style="{ maxWidth: `${graph.bounds.x1 - graph.bounds.x0}px` }" :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">
|
||||
<path v-for="(a, i) in graph.arcs" :key="'a' + i"
|
||||
:id="`tarc${i}`" :d="a.d" :class="['tarc', a.top && 'tarc-top']" />
|
||||
@@ -201,29 +170,43 @@ const countLabel = (n) =>
|
||||
<circle v-for="(b, i) in beads" :key="'b' + i"
|
||||
:cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" />
|
||||
<g v-for="(x, i) in graph.extNodes" :key="'x' + i">
|
||||
<clipPath :id="`xclip${i}`">
|
||||
<rect :x="x.x - TNODE_W/2 + 6" :y="x.y - TNODE_H/2" :width="TNODE_W - 12"
|
||||
:height="TNODE_H" :rx="TNODE_H/2 - 4" />
|
||||
</clipPath>
|
||||
<a v-if="x.href" :href="x.href" target="_blank" rel="noopener">
|
||||
<title>{{ x.path }}</title>
|
||||
<rect :x="x.x - TNODE_W/2" :y="x.y - TNODE_H/2" :width="TNODE_W" :height="TNODE_H" :rx="TNODE_H/2"
|
||||
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
||||
<text :x="x.x" :y="x.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle">{{ fitLabel(x.label) }}</text>
|
||||
<text :x="x.x" :y="x.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">{{ fitLabel(formatCount(x.count), 13) }}</text>
|
||||
<g :clip-path="`url(#xclip${i})`">
|
||||
<text :x="fitsPill(x.label) ? x.x : x.x - TNODE_W/2 + 8" :y="x.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle" :style="{ textAnchor: fitsPill(x.label) ? 'middle' : 'start' }">{{ x.label }}</text>
|
||||
<text :x="x.x" :y="x.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">{{ formatCount(x.count) }}</text>
|
||||
</g>
|
||||
</a>
|
||||
<g v-else>
|
||||
<title>{{ x.path }}</title>
|
||||
<rect :x="x.x - TNODE_W/2" :y="x.y - TNODE_H/2" :width="TNODE_W" :height="TNODE_H" :rx="TNODE_H/2"
|
||||
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
||||
<text :x="x.x" :y="x.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle">{{ fitLabel(x.label) }}</text>
|
||||
<text :x="x.x" :y="x.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">{{ fitLabel(formatCount(x.count), 13) }}</text>
|
||||
<g :clip-path="`url(#xclip${i})`">
|
||||
<text :x="fitsPill(x.label) ? x.x : x.x - TNODE_W/2 + 8" :y="x.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle" :style="{ textAnchor: fitsPill(x.label) ? 'middle' : 'start' }">{{ x.label }}</text>
|
||||
<text :x="x.x" :y="x.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">{{ formatCount(x.count) }}</text>
|
||||
</g>
|
||||
</g>
|
||||
<g v-for="n in graph.nodes" :key="n.path">
|
||||
</g>
|
||||
<g v-for="(n, i) in graph.nodes" :key="n.path">
|
||||
<clipPath :id="`nclip${i}`">
|
||||
<rect :x="n.x - TNODE_W/2 + 6" :y="n.y - TNODE_H/2" :width="TNODE_W - 12"
|
||||
:height="TNODE_H" :rx="TNODE_H/2 - 4" />
|
||||
</clipPath>
|
||||
<a :href="n.path">
|
||||
<title>{{ n.title }}</title>
|
||||
<rect :x="n.x - TNODE_W/2" :y="n.y - TNODE_H/2" :width="TNODE_W" :height="TNODE_H" :rx="TNODE_H/2" class="tnode" />
|
||||
<text :x="n.x" :y="n.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle">{{ fitLabel(n.label) }}</text>
|
||||
<g :clip-path="`url(#nclip${i})`">
|
||||
<text :x="fitsPill(n.label) ? n.x : n.x - TNODE_W/2 + 8" :y="n.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle" :style="{ textAnchor: fitsPill(n.label) ? 'middle' : 'start' }">{{ n.label }}</text>
|
||||
<text :x="n.x" :y="n.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">
|
||||
{{ fitLabel(countLabel(n), 13) }}
|
||||
{{ countLabel(n) }}
|
||||
</text>
|
||||
</g>
|
||||
</a>
|
||||
</g>
|
||||
</svg>
|
||||
@@ -272,31 +255,29 @@ const countLabel = (n) =>
|
||||
pill's edge, the earliest point where the text is visible. */
|
||||
.tmap .tarclabel {
|
||||
fill: var(--muted);
|
||||
font-size: calc(13px / var(--u, 1));
|
||||
font-size: 13px;
|
||||
text-anchor: start;
|
||||
}
|
||||
/* The top lane is 50% thicker; its 🏠︎ label scales along. */
|
||||
.tmap .tarclabel-top {
|
||||
font-size: calc(19.5px / var(--u, 1));
|
||||
font-size: 19.5px;
|
||||
}
|
||||
.tmap .tnode {
|
||||
fill: var(--accent);
|
||||
stroke: none;
|
||||
}
|
||||
/* 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. Labels are
|
||||
shortened in JS to fit the pill instead of shrinking the font. */
|
||||
/* Text sizes are viewBox units: they shrink along with the graph on
|
||||
narrow panels. Overlong labels are clipped at the pill border. */
|
||||
.tmap .tnodeslug {
|
||||
fill: var(--bg, Canvas);
|
||||
font-size: calc(15px / var(--u, 1));
|
||||
text-anchor: middle;
|
||||
font-size: 19px;
|
||||
text-anchor: start;
|
||||
}
|
||||
.tmap a { cursor: pointer; }
|
||||
.tmap .tnodecount {
|
||||
fill: var(--bg, Canvas);
|
||||
opacity: 0.75;
|
||||
font-size: calc(13px / var(--u, 1));
|
||||
font-size: 15px;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
|
||||
@@ -187,14 +187,13 @@ function collectInternalTransitions(transitions) {
|
||||
return internal
|
||||
}
|
||||
|
||||
/** Domain-only label for an external origin (path and www. removed). */
|
||||
/** Domain-only label for an external origin (path and www. removed).
|
||||
* Pills clip the text at their border; no length cap needed. */
|
||||
function extLabel(ext) {
|
||||
try {
|
||||
const host = new URL(ext).hostname.replace(/^www\./, '')
|
||||
return host.length > 25 ? `${host.slice(0, 24)}…` : host
|
||||
return new URL(ext).hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
const s = ext.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0]
|
||||
return s.length > 25 ? `${s.slice(0, 24)}…` : s
|
||||
return ext.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,11 +288,9 @@ function annotateNodes(nodes, viewsData, titles, readMinutes) {
|
||||
for (const n of nodes) {
|
||||
n.views = viewCount(n.path)
|
||||
n.readMin = readMinutes[n.path] || 0
|
||||
// Article title inside the pill (shortened with ellipsis as needed),
|
||||
// slug as fallback for pages missing from the site tree.
|
||||
const slug = n.path.split('/').pop()
|
||||
const label = titles.get(n.path) || (n.path === '/' ? '🏠︎' : slug)
|
||||
n.label = label.length > 24 ? `${label.slice(0, 23)}…` : label
|
||||
// Article title inside the pill (clipped at the pill border on
|
||||
// render), slug as fallback for pages missing from the site tree.
|
||||
n.label = titles.get(n.path) || (n.path === '/' ? '🏠︎' : n.path.split('/').pop())
|
||||
n.title = titles.get(n.path) || ''
|
||||
// Category (non-leaf) pages with no views in this window are omitted:
|
||||
// their children move up in their place (see layoutGroups).
|
||||
@@ -816,7 +813,7 @@ function buildExternal({ sources, exits }, byPath, innerBounds, visualScale = 1)
|
||||
const xn = {
|
||||
path: source,
|
||||
href,
|
||||
label: label.length > 25 ? `${label.slice(0, 24)}…` : label,
|
||||
label, // clipped at the pill border on render
|
||||
x: x0 + i * spacing,
|
||||
y,
|
||||
count: total,
|
||||
@@ -908,22 +905,27 @@ export function buildTransitionGraph(data, pageTree, visits = [], visualScale =
|
||||
|
||||
// Tight bounding box of the placed page nodes, extended to cover the
|
||||
// branch curves running left of the pills; external nodes extend it.
|
||||
const pad = 16
|
||||
// Margins cover just the ribbon surround (pill outline + flare margin
|
||||
// S=4) plus a small pad: the pill's half width/height, not its diagonal
|
||||
// radius, so the map crops tight especially at top and bottom.
|
||||
const pad = 8
|
||||
const MX = TNODE_W / 2 + 4 + pad
|
||||
const MY = TNODE_H / 2 + 4 + pad
|
||||
const xs = placed.map((n) => n.x)
|
||||
const ys = placed.map((n) => n.y)
|
||||
const bounds = {
|
||||
x0: Math.min(Math.min(...xs) - TNODE_BOUND, arcLeft) - pad,
|
||||
y0: Math.min(...ys) - TNODE_BOUND - pad,
|
||||
x1: Math.max(...xs) + TNODE_BOUND + pad,
|
||||
y1: Math.max(...ys) + TNODE_BOUND + pad,
|
||||
x0: Math.min(Math.min(...xs) - MX, arcLeft - pad),
|
||||
y0: Math.min(...ys) - MY,
|
||||
x1: Math.max(...xs) + MX,
|
||||
y1: Math.max(...ys) + MY,
|
||||
}
|
||||
|
||||
const ext = buildExternal({ sources, exits }, byPath, bounds, visualScale)
|
||||
for (const xn of ext.extNodes) {
|
||||
bounds.x0 = Math.min(bounds.x0, xn.x - TNODE_BOUND - pad)
|
||||
bounds.y0 = Math.min(bounds.y0, xn.y - TNODE_BOUND - pad)
|
||||
bounds.x1 = Math.max(bounds.x1, xn.x + TNODE_BOUND + pad)
|
||||
bounds.y1 = Math.max(bounds.y1, xn.y + TNODE_BOUND + pad)
|
||||
bounds.x0 = Math.min(bounds.x0, xn.x - MX)
|
||||
bounds.y0 = Math.min(bounds.y0, xn.y - MY)
|
||||
bounds.x1 = Math.max(bounds.x1, xn.x + MX)
|
||||
bounds.y1 = Math.max(bounds.y1, xn.y + MY)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user