Transition map: bounded node scaling, concentric branch lanes, exit row at bottom
This commit is contained in:
+17
-6
@@ -240,10 +240,21 @@ month view labels days the same lineless way — day numbers at noon UTC,
|
|||||||
with the month name substituted for the 1st. Year is a rolling 365-day window ending at now, re-bucketed to daily points,
|
with the month name substituted for the 1st. Year is a rolling 365-day window ending at now, re-bucketed to daily points,
|
||||||
with boundary lines at months/years. All uses the full data reach, but keeps
|
with boundary lines at months/years. All uses the full data reach, but keeps
|
||||||
at least the past 30 days so the chart never collapses to a tiny sliver when
|
at least the past 30 days so the chart never collapses to a tiny sliver when
|
||||||
the site is young. Below the charts: a radial **transition map** (all pages from
|
the site is young. Below the charts: a **transition map** (all pages from
|
||||||
`/_api/pages` — front page at the center, each slug level on its own ring,
|
`/_api/pages` — top-level menu items on a large-radius circular arc whose
|
||||||
siblings clockwise in navigation order from the top, radial gap equal to
|
bottom point is the last item (each earlier item a bit higher), connected
|
||||||
the arc spacing — opposite transition directions joined into organic
|
by an unlabeled top lane, each item's
|
||||||
|
subtree fanning out below it in menu order along a large-radius circular
|
||||||
|
arc that leaves heading
|
||||||
|
straight down and gradually bends right, index pages without views omitted
|
||||||
|
and their children promoted in their place. The submenu structure is drawn
|
||||||
|
as wide branch lanes: one per path prefix with at least two visible
|
||||||
|
nodes, running behind the branch's node pills as circle arcs concentric
|
||||||
|
with the fan (parent levels one radius step outward, so all lanes of a
|
||||||
|
group share exactly one form), each labeled with its branch slug along the
|
||||||
|
first inter-node gap — so the lanes reflect the path
|
||||||
|
structure even where index pages are omitted — opposite transition
|
||||||
|
directions joined into organic
|
||||||
tapered connections whose middle width grows logarithmically with the
|
tapered connections whose middle width grows logarithmically with the
|
||||||
count (a single count renders as a ~1 px line, uncapped), connections
|
count (a single count renders as a ~1 px line, uncapped), connections
|
||||||
carrying less than 1% of the total traffic
|
carrying less than 1% of the total traffic
|
||||||
@@ -255,8 +266,8 @@ map: each visit is attributed to `utm_campaign`, then `utm_source`, then the
|
|||||||
referer origin, then any other `utm_*` tag, so UTM-tagged visits are grouped
|
referer origin, then any other `utm_*` tag, so UTM-tagged visits are grouped
|
||||||
under their campaign/source value rather than the referer domain. A UTM
|
under their campaign/source value rather than the referer domain. A UTM
|
||||||
source node only links to its referer when every visit carrying that tag
|
source node only links to its referer when every visit carrying that tag
|
||||||
came from the same origin. External exits are small nodes fanned outwards
|
came from the same origin. External exits are full-size nodes in a matching
|
||||||
from their source page), per-page view
|
row centered below the map, so the site itself stays in the middle), per-page view
|
||||||
counts, the top transitions and the 50 most recent visit trails. Data is
|
counts, the top transitions and the 50 most recent visit trails. Data is
|
||||||
streamed live over `WebSocket /_api/ws/analytics`, which pushes the latest
|
streamed live over `WebSocket /_api/ws/analytics`, which pushes the latest
|
||||||
JSON snapshot on connect and again whenever the analytics file is updated
|
JSON snapshot on connect and again whenever the analytics file is updated
|
||||||
|
|||||||
@@ -123,8 +123,11 @@ 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
|
// The svg never renders larger than its natural size (1 viewBox unit = 1
|
||||||
// how far the enlarged graph's viewBox is scaled down to fit the panel:
|
// 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
|
// 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.
|
// font-size rules divide by. Falls back to 1 (raw units) until measured.
|
||||||
const svgEl = ref(null)
|
const svgEl = ref(null)
|
||||||
@@ -148,18 +151,34 @@ watch(svgEl, (el) => {
|
|||||||
watch(() => graph.value?.bounds, updateScale)
|
watch(() => graph.value?.bounds, updateScale)
|
||||||
onBeforeUnmount(() => resizeObs?.disconnect())
|
onBeforeUnmount(() => resizeObs?.disconnect())
|
||||||
|
|
||||||
// Font size (px) that fits a label inside the pill width at the current
|
// Label text shortened to fit inside the pill at the current zoom (fonts
|
||||||
// zoom: ~0.52 em average glyph width, 12 px padding per side, capped.
|
// are fixed screen sizes): drop whole trailing words first, then hard-cut
|
||||||
const fitPx = (label) =>
|
// with an ellipsis. Width estimate: ~0.52 em per glyph, 12 px padding
|
||||||
Math.min(15, (TNODE_W * pxPerUnit.value - 24) / (0.52 * Math.max(label.length, 1)))
|
// 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}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
const countLabel = (n) =>
|
||||||
|
n.readMin ? `${formatCount(n.views)}×${n.readMin}m` : formatCount(n.views)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section v-if="graph">
|
<section v-if="graph">
|
||||||
<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}`"
|
<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}`"
|
||||||
role="img" aria-label="map of transitions between pages">
|
role="img" aria-label="map of transitions between pages">
|
||||||
<path v-for="(a, i) in graph.arcs" :key="'a' + i"
|
<path v-for="(a, i) in graph.arcs" :key="'a' + i"
|
||||||
:d="a.d" class="tarc" />
|
:id="`tarc${i}`" :d="a.d" :class="['tarc', a.top && 'tarc-top']" />
|
||||||
|
<template v-for="(a, i) in graph.arcs" :key="'t' + i">
|
||||||
|
<path v-if="a.ld" :id="`tarcl${i}`" :d="a.ld" fill="none" stroke="none" />
|
||||||
|
<text v-if="a.ld" class="tarclabel"><textPath :href="`#tarcl${i}`" startOffset="50%">{{ a.label }}</textPath></text>
|
||||||
|
</template>
|
||||||
<path v-for="(e, i) in graph.edges" :key="'e' + i"
|
<path v-for="(e, i) in graph.edges" :key="'e' + i"
|
||||||
:d="e.d" :class="['tconn', e.external && 'tconn-exit']">
|
:d="e.d" :class="['tconn', e.external && 'tconn-exit']">
|
||||||
<title>{{ e.title }}</title>
|
<title>{{ e.title }}</title>
|
||||||
@@ -171,31 +190,26 @@ const fitPx = (label) =>
|
|||||||
<title>{{ x.path }}</title>
|
<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"
|
<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']" />
|
: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"
|
<text :x="x.x" :y="x.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle">{{ fitLabel(x.label) }}</text>
|
||||||
:style="{ '--slug-px': `${fitPx(x.label)}px` }">{{ 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>
|
||||||
<text :x="x.x" :y="x.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">{{ formatCount(x.count) }}</text>
|
|
||||||
</a>
|
</a>
|
||||||
<g v-else>
|
<g v-else>
|
||||||
<title>{{ x.path }}</title>
|
<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"
|
<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']" />
|
: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"
|
<text :x="x.x" :y="x.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle">{{ fitLabel(x.label) }}</text>
|
||||||
:style="{ '--slug-px': `${fitPx(x.label)}px` }">{{ 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>
|
||||||
<text :x="x.x" :y="x.y + TNODE_H*0.24" 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">
|
||||||
<a :href="n.path">
|
<a :href="n.path">
|
||||||
<title>{{ n.title }}</title>
|
<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" />
|
<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"
|
<text :x="n.x" :y="n.y - TNODE_H*0.16" class="tnodeslug" dominant-baseline="middle">{{ fitLabel(n.label) }}</text>
|
||||||
:style="{ '--slug-px': `${fitPx(n.label)}px` }">{{ n.label }}</text>
|
|
||||||
<text :x="n.x" :y="n.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">
|
<text :x="n.x" :y="n.y + TNODE_H*0.24" class="tnodecount" dominant-baseline="middle">
|
||||||
{{ n.readMin ? `${formatCount(n.views)}×${n.readMin}m` : formatCount(n.views) }}
|
{{ fitLabel(countLabel(n), 13) }}
|
||||||
</text>
|
</text>
|
||||||
</a>
|
</a>
|
||||||
<text v-if="n.crumb" :x="n.x" :y="n.y - TNODE_H/2 - 10"
|
|
||||||
:class="['tnodepath', n.path === '/' && 'tnodepath-home']">{{ n.crumb }}</text>
|
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</section>
|
</section>
|
||||||
@@ -206,7 +220,9 @@ const fitPx = (label) =>
|
|||||||
.tmap {
|
.tmap {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
/* max-width is set inline to the natural content width (px = viewBox
|
||||||
|
units), so wide panels never upscale the graph beyond 1:1. */
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
.tmap .tconn {
|
.tmap .tconn {
|
||||||
fill: var(--accent);
|
fill: var(--accent);
|
||||||
@@ -226,10 +242,21 @@ const fitPx = (label) =>
|
|||||||
}
|
}
|
||||||
.tmap .txnode-source { fill: var(--text); }
|
.tmap .txnode-source { fill: var(--text); }
|
||||||
.tmap .txnode-exit { fill: var(--text); }
|
.tmap .txnode-exit { fill: var(--text); }
|
||||||
|
/* Branch lanes: one wide concentric arc per path prefix, running behind
|
||||||
|
the node pills around the fan's circle center; parent levels sit one
|
||||||
|
indent (radius step) outward. Each lane's label follows a short guide
|
||||||
|
arc across the first inter-node gap (the part pills never cover). */
|
||||||
.tmap .tarc {
|
.tmap .tarc {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: var(--line);
|
stroke: var(--muted);
|
||||||
stroke-width: 1;
|
stroke-width: 16;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
.tmap .tarc-top { stroke-width: 24; }
|
||||||
|
.tmap .tarclabel {
|
||||||
|
fill: var(--muted);
|
||||||
|
font-size: calc(13px / var(--u, 1));
|
||||||
|
text-anchor: middle;
|
||||||
}
|
}
|
||||||
.tmap .tnode {
|
.tmap .tnode {
|
||||||
fill: var(--accent);
|
fill: var(--accent);
|
||||||
@@ -237,10 +264,11 @@ const fitPx = (label) =>
|
|||||||
}
|
}
|
||||||
/* Text renders at a constant screen size: --u (set from JS) is the
|
/* 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
|
viewBox-unit → pixel ratio of the rendered svg, so dividing by it makes
|
||||||
the sizes independent of how far the graph is scaled down. */
|
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. */
|
||||||
.tmap .tnodeslug {
|
.tmap .tnodeslug {
|
||||||
fill: var(--bg, Canvas);
|
fill: var(--bg, Canvas);
|
||||||
font-size: calc(var(--slug-px, 15px) / var(--u, 1));
|
font-size: calc(15px / var(--u, 1));
|
||||||
text-anchor: middle;
|
text-anchor: middle;
|
||||||
}
|
}
|
||||||
.tmap a { cursor: pointer; }
|
.tmap a { cursor: pointer; }
|
||||||
@@ -250,12 +278,6 @@ const fitPx = (label) =>
|
|||||||
font-size: calc(13px / var(--u, 1));
|
font-size: calc(13px / var(--u, 1));
|
||||||
text-anchor: middle;
|
text-anchor: middle;
|
||||||
}
|
}
|
||||||
.tmap .tnodepath {
|
|
||||||
fill: var(--muted);
|
|
||||||
font-size: calc(11px / var(--u, 1));
|
|
||||||
text-anchor: middle;
|
|
||||||
}
|
|
||||||
.tmap .tnodepath-home { font-size: calc(17px / var(--u, 1)); }
|
|
||||||
|
|
||||||
section { margin-top: 1.8rem; }
|
section { margin-top: 1.8rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -22,9 +22,10 @@
|
|||||||
* referer, then other utm_* tags. Visits with a UTM tag are grouped under
|
* referer, then other utm_* tags. Visits with a UTM tag are grouped under
|
||||||
* that tag's value, not under the referer domain. A UTM source node only
|
* that tag's value, not under the referer domain. A UTM source node only
|
||||||
* becomes a clickable link when every visit carrying that UTM tag came
|
* becomes a clickable link when every visit carrying that UTM tag came
|
||||||
* from the same referer. External exits are full-size nodes just outside
|
* from the same referer. External exits are full-size nodes in a row below
|
||||||
* their source page, angled away from the center. Each distinct full exit
|
* the map, mirroring the source row, so the site itself stays in the
|
||||||
* URL is its own node. Self-loops (reload pings) are skipped.
|
* middle. Each distinct full exit URL is its own node. Self-loops (reload
|
||||||
|
* pings) are skipped.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MIN_READ_SECONDS } from './format.js'
|
import { MIN_READ_SECONDS } from './format.js'
|
||||||
@@ -142,8 +143,8 @@ 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
|
||||||
|
|
||||||
const MAX_EXT_IN = 8 // referer nodes in the top row
|
const MAX_EXT_IN = 8 // referer nodes in the top row
|
||||||
const MAX_EXT_OUT = 12 // exit nodes, at most MAX_EXT_OUT_PER_PAGE per page
|
const MAX_EXT_OUT = 12 // exit nodes in the bottom row
|
||||||
const MAX_EXT_OUT_PER_PAGE = 3
|
const EXT_GAP = 12 // vertical margin of the source/exit rows to the map
|
||||||
|
|
||||||
/** Flatten the site tree into navigation order via DFS. */
|
/** Flatten the site tree into navigation order via DFS. */
|
||||||
function buildNavigationOrder(pageTree) {
|
function buildNavigationOrder(pageTree) {
|
||||||
@@ -287,16 +288,10 @@ function annotateNodes(nodes, viewsData, titles, readMinutes) {
|
|||||||
n.views = viewCount(n.path)
|
n.views = viewCount(n.path)
|
||||||
n.readMin = readMinutes[n.path] || 0
|
n.readMin = readMinutes[n.path] || 0
|
||||||
// Article title inside the pill (shortened with ellipsis as needed),
|
// Article title inside the pill (shortened with ellipsis as needed),
|
||||||
// slug as fallback for pages missing from the site tree. The short
|
// slug as fallback for pages missing from the site tree.
|
||||||
// path (last two segments, no leading /) renders above the pill; the
|
|
||||||
// front page shows a home symbol there instead (larger).
|
|
||||||
const slug = n.path.split('/').pop()
|
const slug = n.path.split('/').pop()
|
||||||
const label = titles.get(n.path) || (n.path === '/' ? '🏠︎' : slug)
|
const label = titles.get(n.path) || (n.path === '/' ? '🏠︎' : slug)
|
||||||
n.label = label.length > 24 ? `${label.slice(0, 23)}…` : label
|
n.label = label.length > 24 ? `${label.slice(0, 23)}…` : label
|
||||||
const segs = n.path.split('/').filter(Boolean)
|
|
||||||
n.crumb = n.path === '/'
|
|
||||||
? '🏠︎'
|
|
||||||
: segs.length > 2 ? `…/${segs.slice(-2).join('/')}` : segs.join('/')
|
|
||||||
n.title = titles.get(n.path) || ''
|
n.title = titles.get(n.path) || ''
|
||||||
// Category (non-leaf) pages with no views in this window are omitted:
|
// Category (non-leaf) pages with no views in this window are omitted:
|
||||||
// their children move up in their place (see layoutGroups).
|
// their children move up in their place (see layoutGroups).
|
||||||
@@ -310,15 +305,13 @@ function annotateNodes(nodes, viewsData, titles, readMinutes) {
|
|||||||
* the row following a shallow circular sag (center lowest) so connections
|
* the row following a shallow circular sag (center lowest) so connections
|
||||||
* between neighbors do not overlap the pills in between. Each top item's
|
* between neighbors do not overlap the pills in between. Each top item's
|
||||||
* whole subtree fans out from it in menu (DFS preorder) order along a
|
* whole subtree fans out from it in menu (DFS preorder) order along a
|
||||||
* parabola that leaves the parent heading straight down and gradually
|
* large-radius circular arc that leaves the parent heading straight down
|
||||||
* bends to the right — no horizontal space is reserved for fans, they
|
* and gradually bends to the right — no horizontal space is reserved for fans, they
|
||||||
* extend under the slots to their right. Hidden index pages are omitted
|
* extend under the slots to their right. Hidden index pages are omitted
|
||||||
* from the fan; when the top item itself is hidden, the fan shifts one
|
* from the fan; when the top item itself is hidden, the fan shifts one
|
||||||
* slot up, the first visible child taking the top position. The short
|
* slot up, the first visible child taking the top position. Branch lanes
|
||||||
* path shown above each pill (last two segments) keeps the omitted menu
|
* labeled with the branch slug (see the branch-lane pass at the end)
|
||||||
* level visible.
|
* keep the omitted menu levels visible.
|
||||||
* Also returns curved spoke paths tracing each fan: top slot to first
|
|
||||||
* member, then member to member in menu order, each bowed to the right.
|
|
||||||
*/
|
*/
|
||||||
function layoutGroups(root) {
|
function layoutGroups(root) {
|
||||||
// Top slots are spaced well over one pill width apart regardless of
|
// Top slots are spaced well over one pill width apart regardless of
|
||||||
@@ -328,8 +321,10 @@ function layoutGroups(root) {
|
|||||||
|
|
||||||
// First pass: visible members per group, in menu order. Hidden index
|
// First pass: visible members per group, in menu order. Hidden index
|
||||||
// pages are skipped, but their children still appear. The front page
|
// pages are skipped, but their children still appear. The front page
|
||||||
// forms its own group.
|
// forms its own group. groupRoots keeps each group's subtree root for
|
||||||
|
// the branch-curve pass below.
|
||||||
const groups = []
|
const groups = []
|
||||||
|
const groupRoots = []
|
||||||
for (const g of [root, ...root.children]) {
|
for (const g of [root, ...root.children]) {
|
||||||
const members = []
|
const members = []
|
||||||
if (g === root) {
|
if (g === root) {
|
||||||
@@ -341,53 +336,121 @@ function layoutGroups(root) {
|
|||||||
}
|
}
|
||||||
walk(g)
|
walk(g)
|
||||||
}
|
}
|
||||||
if (members.length) groups.push(members)
|
if (members.length) {
|
||||||
|
groups.push(members)
|
||||||
|
groupRoots.push(g)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top row on a true circular sag: center lowest, edges raised by SAG.
|
// Top row on a large-radius circular arc whose bottom point is the
|
||||||
|
// LAST top item: each earlier item sits a bit higher (drop = 15% of
|
||||||
|
// the row span). Flat row when there is a single group.
|
||||||
const half = ((groups.length - 1) * SLOT) / 2 || 1
|
const half = ((groups.length - 1) * SLOT) / 2 || 1
|
||||||
const SAG = TNODE_H * 0.6
|
const span = (groups.length - 1) * SLOT
|
||||||
const Rc = (half * half + SAG * SAG) / (2 * SAG)
|
const topD = span * 0.15
|
||||||
const topY = (x) => SAG - Rc + Math.sqrt(Rc * Rc - x * x)
|
const R_T = span ? (span * span + topD * topD) / (2 * topD) : 0
|
||||||
|
const topY = span
|
||||||
|
? (x) => topD - R_T + Math.sqrt(R_T * R_T - (x - half) * (x - half))
|
||||||
|
: () => 0
|
||||||
|
|
||||||
// Second pass: place groups. Fan members follow a right-opening cubic
|
// Second pass: place groups. Fan members follow a circular arc of
|
||||||
// p(t) = (gx + B t³, y0 + t): the tangent stays vertical near the
|
// large radius FAN_R centered at (gx + FAN_R, y0): the trail leaves
|
||||||
// parent (leaving almost straight down) and bends right gently,
|
// the top node heading straight down (vertical tangent) and bends
|
||||||
// reaching ~50° from vertical at the last member. Member spacing along
|
// right gently, member i at arc angle π − i·CLEAR/FAN_R (spaced by
|
||||||
// the curve is the pill clearance (dt integrated against curve speed).
|
// arc length CLEAR). A circle — not a spline — so the branch lanes
|
||||||
const spokePairs = [] // [from node, to node] — paths emitted below
|
// below can be concentric arcs: identical forms, only radii differ.
|
||||||
|
const FAN_R = 1000
|
||||||
groups.forEach((members, gi) => {
|
groups.forEach((members, gi) => {
|
||||||
const gx = gi * SLOT - half
|
const gx = gi * SLOT - half
|
||||||
const y0 = topY(gx)
|
const y0 = topY(gx)
|
||||||
members[0].x = gx
|
members[0].x = gx
|
||||||
members[0].y = y0
|
members[0].y = y0
|
||||||
const m = members.length - 1
|
for (let i = 1; i < members.length; i++) {
|
||||||
if (!m) return
|
const th = Math.PI - (i * CLEAR) / FAN_R
|
||||||
const tMax = m * CLEAR * 0.9
|
members[i].x = gx + FAN_R * (1 + Math.cos(th))
|
||||||
const B = 0.4 / (tMax * tMax)
|
members[i].y = y0 + FAN_R * Math.sin(th)
|
||||||
let t = 0
|
|
||||||
for (let i = 1; i <= m; i++) {
|
|
||||||
const bend = 3 * B * t * t
|
|
||||||
t += CLEAR / Math.hypot(bend, 1)
|
|
||||||
const n = members[i]
|
|
||||||
n.x = gx + B * t * t * t
|
|
||||||
n.y = y0 + t
|
|
||||||
spokePairs.push([members[i - 1], n])
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fan spokes bow to the right via a quadratic control point pushed
|
// Branch lanes: one wide arc per path prefix (slug depth ≥ 1) whose
|
||||||
// rightward from the segment midpoint.
|
// subtree holds at least two visible nodes (a branch's visible nodes
|
||||||
const spokes = spokePairs.map(([p, n]) => {
|
// form one contiguous run in the fan's DFS preorder). Every lane of a
|
||||||
const mx = (p.x + n.x) / 2
|
// group is an arc around the group's fan center with a radius one
|
||||||
const my = (p.y + n.y) / 2
|
// INDENT larger per parent level — concentric circles, so all lanes
|
||||||
const bow = Math.hypot(n.x - p.x, n.y - p.y) * 0.18
|
// share exactly one form. Lanes span their branch's nodes plus a
|
||||||
return {
|
// little extra tucked under the first/last pill (so the line caps are
|
||||||
d: `M ${p.x.toFixed(2)} ${p.y.toFixed(2)} `
|
// never visible) and run behind the pills. A separate short arc
|
||||||
+ `Q ${(mx + bow).toFixed(2)} ${my.toFixed(2)} ${n.x.toFixed(2)} ${n.y.toFixed(2)}`,
|
// across the first inter-node gap carries the branch slug as a label,
|
||||||
|
// replacing per-node path crumbs. Hidden (unplaced) index pages still
|
||||||
|
// define a lane: it follows their promoted children, so lanes reflect
|
||||||
|
// the path structure rather than page existence.
|
||||||
|
const INDENT = 20 // lane spacing (radius) per nesting level (> lane width)
|
||||||
|
const END_TUCK = 22 // arc units tucked under the first/last pill
|
||||||
|
const GAP_TRIM = 32 // label arc clearance from the pills
|
||||||
|
const LABEL_CHARS = 8 // ~13px glyphs fitting the gap
|
||||||
|
const branches = []
|
||||||
|
groups.forEach((members, gi) => {
|
||||||
|
const g = groupRoots[gi]
|
||||||
|
if (g === root || members.length < 2) return
|
||||||
|
const idx = new Map(members.map((n, i) => [n, i]))
|
||||||
|
const C = [gi * SLOT - half + FAN_R, topY(gi * SLOT - half)]
|
||||||
|
const walk = (n) => {
|
||||||
|
let first = Infinity
|
||||||
|
let last = -1
|
||||||
|
const span = (m) => {
|
||||||
|
const k = idx.get(m)
|
||||||
|
if (k !== undefined) {
|
||||||
|
first = Math.min(first, k)
|
||||||
|
last = Math.max(last, k)
|
||||||
|
}
|
||||||
|
m.children.forEach(span)
|
||||||
|
}
|
||||||
|
span(n)
|
||||||
|
if (n.depth >= 1 && last > first) {
|
||||||
|
branches.push({ depth: n.depth, name: n.path.split('/').pop(), C, first, last })
|
||||||
|
}
|
||||||
|
n.children.forEach(walk)
|
||||||
}
|
}
|
||||||
|
walk(g)
|
||||||
})
|
})
|
||||||
return { GAP: TNODE_H * 2.6, spokes }
|
const depthMax = branches.reduce((d, b) => Math.max(d, b.depth), 1)
|
||||||
|
let arcLeft = Infinity // leftmost lane point, for the bounding box
|
||||||
|
const arcs = branches.map(({ depth, name, C, first, last }) => {
|
||||||
|
const R = FAN_R + (depthMax - depth) * INDENT
|
||||||
|
const th = (i) => Math.PI - (i * CLEAR) / FAN_R
|
||||||
|
const pt = (a, r) => [C[0] + r * Math.cos(a), C[1] + r * Math.sin(a)]
|
||||||
|
// Arc from angle a down to angle b (a > b; visually counterclockwise
|
||||||
|
// from the west point downward, hence sweep flag 0).
|
||||||
|
const arc = (a, b, r) => {
|
||||||
|
const [x0, y0] = pt(a, r)
|
||||||
|
const [x1, y1] = pt(b, r)
|
||||||
|
return `M ${x0.toFixed(2)} ${y0.toFixed(2)} A ${r.toFixed(2)} ${r.toFixed(2)} 0 0 0 ${x1.toFixed(2)} ${y1.toFixed(2)}`
|
||||||
|
}
|
||||||
|
const d = arc(th(first) + END_TUCK / R, th(last) - END_TUCK / R, R)
|
||||||
|
// The label guide rides GUIDE_OFF outward of the lane centerline: the
|
||||||
|
// text's alphabetic baseline sits on the guide, so this puts the
|
||||||
|
// glyph middle (not the baseline) on the lane center at any zoom —
|
||||||
|
// dominant-baseline tricks are em-based and break under downscale.
|
||||||
|
const GUIDE_OFF = 3.5
|
||||||
|
const ld = arc(th(first) - GAP_TRIM / R, th(first + 1) + GAP_TRIM / R, R + GUIDE_OFF)
|
||||||
|
arcLeft = Math.min(arcLeft, pt(th(first) + END_TUCK / R, R)[0])
|
||||||
|
const label = name.length > LABEL_CHARS ? `${name.slice(0, LABEL_CHARS - 1)}…` : name
|
||||||
|
return { d, ld, label }
|
||||||
|
})
|
||||||
|
// Top lane: an unlabeled arc along the top row's own circle, connecting
|
||||||
|
// the top nodes of all groups and tucked under the first and last of
|
||||||
|
// them (the arc bottoms at the last item, so it continues rightward
|
||||||
|
// under its pill). Drawn 50% thicker than branch lanes.
|
||||||
|
if (span) {
|
||||||
|
arcs.unshift({
|
||||||
|
d: `M ${(-half - END_TUCK).toFixed(2)} ${topY(-half - END_TUCK).toFixed(2)} `
|
||||||
|
+ `A ${R_T.toFixed(2)} ${R_T.toFixed(2)} 0 0 0 ${(half + END_TUCK).toFixed(2)} ${topY(half + END_TUCK).toFixed(2)}`,
|
||||||
|
ld: null,
|
||||||
|
label: null,
|
||||||
|
top: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { arcs, arcLeft }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collapse opposite transition directions into one unordered pair per page pair. */
|
/** Collapse opposite transition directions into one unordered pair per page pair. */
|
||||||
@@ -726,11 +789,13 @@ function collectSourcePairs(visits) {
|
|||||||
* flows.
|
* flows.
|
||||||
* Sources (incoming links) are derived from visit UTM/referer data and form
|
* Sources (incoming links) are derived from visit UTM/referer data and form
|
||||||
* a row centered above the map, hottest first; exits come from the
|
* a row centered above the map, hottest first; exits come from the
|
||||||
* transition matrix and sit just outside their source page.
|
* transition matrix and form a matching row centered below the map, so
|
||||||
|
* the site itself stays in the middle. Both rows sit EXT_GAP beyond the
|
||||||
|
* map's bounds.
|
||||||
* Widths and pruning use the same log scale and traffic-share rule as
|
* Widths and pruning use the same log scale and traffic-share rule as
|
||||||
* internal connections.
|
* internal connections.
|
||||||
*/
|
*/
|
||||||
function buildExternal({ sources, exits }, byPath, gap, innerBounds, visualScale = 1) {
|
function buildExternal({ sources, exits }, byPath, innerBounds, visualScale = 1) {
|
||||||
const extNodes = []
|
const extNodes = []
|
||||||
const edges = []
|
const edges = []
|
||||||
const flows = []
|
const flows = []
|
||||||
@@ -744,15 +809,6 @@ function buildExternal({ sources, exits }, byPath, gap, innerBounds, visualScale
|
|||||||
|
|
||||||
const width = (count) => scaledWidth(count * visualScale)
|
const width = (count) => scaledWidth(count * visualScale)
|
||||||
|
|
||||||
// Pill-shape overlap test (axis-aligned pills): much tighter than the
|
|
||||||
// bounding-circle test, so diagonal placements can sit close.
|
|
||||||
const overlaps = (x, y) =>
|
|
||||||
[...byPath.values(), ...extNodes].some(
|
|
||||||
(n) => !n.hidden
|
|
||||||
&& Math.abs(n.x - x) < TNODE_W + 12
|
|
||||||
&& Math.abs(n.y - y) < TNODE_H + 12,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Incoming: one source node per identified source, in a row centered
|
// Incoming: one source node per identified source, in a row centered
|
||||||
// above the map, with an edge to each page that source led to.
|
// above the map, with an edge to each page that source led to.
|
||||||
const bySource = new Map() // source -> pairs, sorted by total incoming count
|
const bySource = new Map() // source -> pairs, sorted by total incoming count
|
||||||
@@ -773,7 +829,7 @@ function buildExternal({ sources, exits }, byPath, gap, innerBounds, visualScale
|
|||||||
.slice(0, MAX_EXT_IN)
|
.slice(0, MAX_EXT_IN)
|
||||||
if (origins.length) {
|
if (origins.length) {
|
||||||
const cx = (innerBounds.x0 + innerBounds.x1) / 2
|
const cx = (innerBounds.x0 + innerBounds.x1) / 2
|
||||||
const y = innerBounds.y0 - TNODE_BOUND - 64
|
const y = innerBounds.y0 - TNODE_BOUND - EXT_GAP
|
||||||
const spacing = TNODE_W + 44
|
const spacing = TNODE_W + 44
|
||||||
const x0 = cx - ((origins.length - 1) * spacing) / 2
|
const x0 = cx - ((origins.length - 1) * spacing) / 2
|
||||||
origins.forEach(({ source, ps, total, href, isUtm }, i) => {
|
origins.forEach(({ source, ps, total, href, isUtm }, i) => {
|
||||||
@@ -799,72 +855,46 @@ function buildExternal({ sources, exits }, byPath, gap, innerBounds, visualScale
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outgoing: group by full URL so several links to the same domain stay
|
// Outgoing: one exit node per distinct full URL (so several links to
|
||||||
// distinct; each shows the total count across all pages linking to it.
|
// the same domain stay distinct), showing the total count across all
|
||||||
// Placement looks for empty space around the source page, always
|
// pages linking to it, in a row centered below the map (hottest
|
||||||
// leftward: diagonal down-left first (often right beside the source,
|
// first), mirroring the source row above. Each (URL, page) pair
|
||||||
// no need to drop below the fans), then left, up-left, and steeper
|
// contributes an edge from that page.
|
||||||
// fallbacks; the distance grows until a spot is free. Several exits of
|
const byExt = new Map() // full URL -> { ext, out, pairs }
|
||||||
// one page start at different directions.
|
for (const p of liveExits.filter((p) => p.out >= minCount)) {
|
||||||
const GAP = gap
|
const g = byExt.get(p.ext) || { ext: p.ext, out: 0, pairs: [] }
|
||||||
const DIRS = [
|
g.out += p.out
|
||||||
(3 * Math.PI) / 4, // diagonal down-left
|
g.pairs.push(p)
|
||||||
Math.PI, // left
|
byExt.set(p.ext, g)
|
||||||
(5 * Math.PI) / 4, // diagonal up-left
|
|
||||||
Math.PI / 2 + 0.35, // steep down-left
|
|
||||||
Math.PI - 0.35, // shallow up-left
|
|
||||||
(3 * Math.PI) / 4 + 0.5, // far down-left
|
|
||||||
]
|
|
||||||
const outgoing = liveExits.filter((p) => p.out >= minCount)
|
|
||||||
.sort((a, b) => b.out - a.out)
|
|
||||||
const perPage = new Map()
|
|
||||||
const selected = []
|
|
||||||
for (const p of outgoing) {
|
|
||||||
const used = perPage.get(p.page) || 0
|
|
||||||
if (used >= MAX_EXT_OUT_PER_PAGE) continue
|
|
||||||
perPage.set(p.page, used + 1)
|
|
||||||
selected.push(p)
|
|
||||||
if (selected.length >= MAX_EXT_OUT) break
|
|
||||||
}
|
}
|
||||||
|
const targets = [...byExt.values()]
|
||||||
const exitNodes = new Map() // full URL -> node
|
.sort((a, b) => b.out - a.out)
|
||||||
const placedPerPage = new Map() // for the placement direction offset
|
.slice(0, MAX_EXT_OUT)
|
||||||
for (const p of selected) {
|
if (targets.length) {
|
||||||
const page = byPath.get(p.page)
|
const cx = (innerBounds.x0 + innerBounds.x1) / 2
|
||||||
if (page.hidden) continue
|
const y = innerBounds.y1 + TNODE_BOUND + EXT_GAP
|
||||||
let xn = exitNodes.get(p.ext)
|
const spacing = TNODE_W + 44
|
||||||
if (!xn) {
|
const x0 = cx - ((targets.length - 1) * spacing) / 2
|
||||||
const used = placedPerPage.get(p.page) || 0
|
targets.forEach(({ ext, out, pairs }, i) => {
|
||||||
placedPerPage.set(p.page, used + 1)
|
const xn = {
|
||||||
let x = 0
|
path: ext,
|
||||||
let y = 0
|
href: ext,
|
||||||
let found = false
|
label: extLabel(ext),
|
||||||
for (let di = 0; di < DIRS.length && !found; di++) {
|
x: x0 + i * spacing,
|
||||||
const ang = DIRS[(used + di) % DIRS.length]
|
|
||||||
for (let dist = GAP; dist <= GAP * 3.5; dist += GAP * 0.4) {
|
|
||||||
x = page.x + Math.cos(ang) * dist
|
|
||||||
y = page.y + Math.sin(ang) * dist
|
|
||||||
if (!overlaps(x, y)) { found = true; break }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!found) continue // no empty space near the page: leave it out
|
|
||||||
xn = {
|
|
||||||
path: p.ext,
|
|
||||||
href: p.ext,
|
|
||||||
label: extLabel(p.ext),
|
|
||||||
x,
|
|
||||||
y,
|
y,
|
||||||
count: 0,
|
count: out,
|
||||||
kind: 'exit',
|
kind: 'exit',
|
||||||
}
|
}
|
||||||
exitNodes.set(p.ext, xn)
|
|
||||||
extNodes.push(xn)
|
extNodes.push(xn)
|
||||||
}
|
for (const p of pairs) {
|
||||||
xn.count += p.out
|
const page = byPath.get(p.page)
|
||||||
const wMid = width(p.out)
|
if (page.hidden) continue
|
||||||
if (wMid <= 0) continue
|
const wMid = width(p.out)
|
||||||
edges.push(buildRibbon(page, xn, p.out, 0, wMid, true))
|
if (wMid <= 0) continue
|
||||||
flows.push(...buildFlows(page, xn, p.out, 0, visualScale))
|
edges.push(buildRibbon(page, xn, p.out, 0, wMid, true))
|
||||||
|
flows.push(...buildFlows(page, xn, p.out, 0, visualScale))
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { extNodes, edges, flows }
|
return { extNodes, edges, flows }
|
||||||
@@ -873,7 +903,7 @@ function buildExternal({ sources, exits }, byPath, gap, innerBounds, visualScale
|
|||||||
/**
|
/**
|
||||||
* Build the transition map model.
|
* Build the transition map model.
|
||||||
* Returns { nodes, edges, flows, extNodes, arcs, bounds } or null when
|
* Returns { nodes, edges, flows, extNodes, arcs, bounds } or null when
|
||||||
* there is nothing to show. `arcs` holds the family spokes; `nodes` only
|
* there is nothing to show. `arcs` holds the branch curves; `nodes` only
|
||||||
* contains placed (visible) nodes.
|
* contains placed (visible) nodes.
|
||||||
*/
|
*/
|
||||||
export function buildTransitionGraph(data, pageTree, visits = [], visualScale = 1) {
|
export function buildTransitionGraph(data, pageTree, visits = [], visualScale = 1) {
|
||||||
@@ -889,23 +919,24 @@ export function buildTransitionGraph(data, pageTree, visits = [], visualScale =
|
|||||||
const { nodes, byPath, root } = buildNodeTree(internal, navOrder)
|
const { nodes, byPath, root } = buildNodeTree(internal, navOrder)
|
||||||
sortByNav(root, navOrder)
|
sortByNav(root, navOrder)
|
||||||
annotateNodes(nodes, data?.views, titles, readMinutes)
|
annotateNodes(nodes, data?.views, titles, readMinutes)
|
||||||
const { GAP, spokes } = layoutGroups(root)
|
const { arcs, arcLeft } = layoutGroups(root)
|
||||||
const placed = nodes.filter((n) => !n.hidden)
|
const placed = nodes.filter((n) => !n.hidden)
|
||||||
const pairs = aggregatePairs(internal)
|
const pairs = aggregatePairs(internal)
|
||||||
const { edges, flows } = buildInternalEdges(pairs, byPath, visualScale)
|
const { edges, flows } = buildInternalEdges(pairs, byPath, visualScale)
|
||||||
|
|
||||||
// Tight bounding box of the placed page nodes; external nodes extend it.
|
// 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
|
const pad = 16
|
||||||
const xs = placed.map((n) => n.x)
|
const xs = placed.map((n) => n.x)
|
||||||
const ys = placed.map((n) => n.y)
|
const ys = placed.map((n) => n.y)
|
||||||
const bounds = {
|
const bounds = {
|
||||||
x0: Math.min(...xs) - TNODE_BOUND - pad,
|
x0: Math.min(Math.min(...xs) - TNODE_BOUND, arcLeft) - pad,
|
||||||
y0: Math.min(...ys) - TNODE_BOUND - pad,
|
y0: Math.min(...ys) - TNODE_BOUND - pad,
|
||||||
x1: Math.max(...xs) + TNODE_BOUND + pad,
|
x1: Math.max(...xs) + TNODE_BOUND + pad,
|
||||||
y1: Math.max(...ys) + TNODE_BOUND + pad,
|
y1: Math.max(...ys) + TNODE_BOUND + pad,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = buildExternal({ sources, exits }, byPath, GAP, bounds, visualScale)
|
const ext = buildExternal({ sources, exits }, byPath, bounds, visualScale)
|
||||||
for (const xn of ext.extNodes) {
|
for (const xn of ext.extNodes) {
|
||||||
bounds.x0 = Math.min(bounds.x0, xn.x - TNODE_BOUND - pad)
|
bounds.x0 = Math.min(bounds.x0, xn.x - TNODE_BOUND - pad)
|
||||||
bounds.y0 = Math.min(bounds.y0, xn.y - TNODE_BOUND - pad)
|
bounds.y0 = Math.min(bounds.y0, xn.y - TNODE_BOUND - pad)
|
||||||
@@ -918,7 +949,7 @@ export function buildTransitionGraph(data, pageTree, visits = [], visualScale =
|
|||||||
edges: [...edges, ...ext.edges],
|
edges: [...edges, ...ext.edges],
|
||||||
flows: [...flows, ...ext.flows],
|
flows: [...flows, ...ext.flows],
|
||||||
extNodes: ext.extNodes,
|
extNodes: ext.extNodes,
|
||||||
arcs: spokes,
|
arcs,
|
||||||
bounds,
|
bounds,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user