Support for UTM tags in transition graph as source sites.
This commit is contained in:
+7
-3
@@ -236,9 +236,13 @@ carrying less than 1% of the total traffic
|
|||||||
pruned; beads are simulated one by one in JS (requestAnimationFrame) and
|
pruned; beads are simulated one by one in JS (requestAnimationFrame) and
|
||||||
flow along each edge, emitted at a rate linearly proportional
|
flow along each edge, emitted at a rate linearly proportional
|
||||||
to the directional count with no in-flight limit, opposing directions
|
to the directional count with no in-flight limit, opposing directions
|
||||||
offset onto parallel lanes. External referers show as a node row above the
|
offset onto parallel lanes. External sources show as a node row above the
|
||||||
map, external exits as small nodes fanned outwards from their source
|
map: each visit is attributed to `utm_campaign`, then `utm_source`, then the
|
||||||
page), per-page view
|
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
|
||||||
|
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
|
||||||
|
from their source page), 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
|
||||||
|
|||||||
@@ -17,12 +17,6 @@ defineEmits(['close'])
|
|||||||
:rel="step.external ? 'noopener' : undefined"
|
:rel="step.external ? 'noopener' : undefined"
|
||||||
@click="(e) => { if (!step.external) $emit('close') }">
|
@click="(e) => { if (!step.external) $emit('close') }">
|
||||||
<small v-if="count > 1" class="muted">{{ formatCount(count) }}×</small>
|
<small v-if="count > 1" class="muted">{{ formatCount(count) }}×</small>
|
||||||
<span :class="{ desat: step.home }">{{ step.slug }}</span>
|
<span>{{ step.slug }}</span>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.desat {
|
|
||||||
filter: saturate(0);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
buildTransitionGraph,
|
buildTransitionGraph,
|
||||||
filterTransitionsByRange,
|
filterTransitionsByRange,
|
||||||
filterViewsByRange,
|
filterViewsByRange,
|
||||||
|
filterVisitsByRange,
|
||||||
} from './analytics/transitions.js'
|
} from './analytics/transitions.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -48,9 +49,13 @@ const filteredData = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const filteredVisits = computed(() =>
|
||||||
|
filterVisitsByRange(props.data?.visits, window.value.t0, window.value.t1),
|
||||||
|
)
|
||||||
|
|
||||||
const graph = computed(() =>
|
const graph = computed(() =>
|
||||||
filteredData.value
|
filteredData.value
|
||||||
? buildTransitionGraph(filteredData.value, props.pageTree, props.data?.visits, visualScale.value)
|
? buildTransitionGraph(filteredData.value, props.pageTree, filteredVisits.value, visualScale.value)
|
||||||
: null,
|
: null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -135,7 +140,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
|||||||
<circle v-for="(b, i) in beads" :key="'b' + i"
|
<circle v-for="(b, i) in beads" :key="'b' + i"
|
||||||
:cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" />
|
:cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" />
|
||||||
<g v-for="(x, i) in graph.extNodes" :key="'x' + i">
|
<g v-for="(x, i) in graph.extNodes" :key="'x' + i">
|
||||||
<a :href="x.path" target="_blank" rel="noopener" :title="x.path">
|
<a v-if="x.href" :href="x.href" target="_blank" rel="noopener" :title="x.path">
|
||||||
<circle :cx="x.x" :cy="x.y" :r="x.r"
|
<circle :cx="x.x" :cy="x.y" :r="x.r"
|
||||||
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
||||||
<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 }">
|
||||||
@@ -143,6 +148,14 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
|||||||
</text>
|
</text>
|
||||||
<text :x="x.x" :y="x.y + 4" class="tnodecount">{{ formatCount(x.count) }}</text>
|
<text :x="x.x" :y="x.y + 4" class="tnodecount">{{ formatCount(x.count) }}</text>
|
||||||
</a>
|
</a>
|
||||||
|
<g v-else :title="x.path">
|
||||||
|
<circle :cx="x.x" :cy="x.y" :r="x.r"
|
||||||
|
:class="['txnode', x.kind === 'source' ? 'txnode-source' : 'txnode-exit']" />
|
||||||
|
<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>
|
||||||
|
</g>
|
||||||
</g>
|
</g>
|
||||||
<g v-for="n in graph.nodes" :key="n.path">
|
<g v-for="n in graph.nodes" :key="n.path">
|
||||||
<a v-if="!n.hidden" :href="n.path" :title="n.title">
|
<a v-if="!n.hidden" :href="n.path" :title="n.title">
|
||||||
@@ -209,7 +222,6 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
|||||||
fill: var(--text);
|
fill: var(--text);
|
||||||
font-size: calc(11px / var(--node-r, 34));
|
font-size: calc(11px / var(--node-r, 34));
|
||||||
text-anchor: middle;
|
text-anchor: middle;
|
||||||
filter: saturate(0);
|
|
||||||
}
|
}
|
||||||
.tmap a { cursor: pointer; }
|
.tmap a { cursor: pointer; }
|
||||||
.tmap a:hover .tnodeslug { fill: var(--accent); }
|
.tmap a:hover .tnodeslug { fill: var(--accent); }
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function buildTitleMap(pageTree) {
|
|||||||
|
|
||||||
/** Last path segment for display; front page becomes a house icon. */
|
/** Last path segment for display; front page becomes a house icon. */
|
||||||
function slugOf(path) {
|
function slugOf(path) {
|
||||||
return path === '/' ? '🏠' : path.split('/').pop()
|
return path === '/' ? '🏠︎' : path.split('/').pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Host name of an external https origin, with scheme and www. stripped. */
|
/** Host name of an external https origin, with scheme and www. stripped. */
|
||||||
|
|||||||
@@ -13,10 +13,14 @@
|
|||||||
* connections. Animated beads flow along every edge in each direction,
|
* connections. Animated beads flow along every edge in each direction,
|
||||||
* emitted at time intervals inversely proportional (linear) to the
|
* emitted at time intervals inversely proportional (linear) to the
|
||||||
* directional count.
|
* directional count.
|
||||||
* External referers appear as nodes in a row above the map, external exits
|
* External sources appear as nodes in a row above the map. Sources are
|
||||||
* as full-size nodes just outside their source page, angled away from the
|
* identified from visit records in this order: utm_campaign, utm_source,
|
||||||
* center. Each distinct full exit URL is its own node. Self-loops (reload
|
* referer, then other utm_* tags. Visits with a UTM tag are grouped under
|
||||||
* pings) are skipped.
|
* 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
|
||||||
|
* from the same referer. External exits are full-size nodes just outside
|
||||||
|
* their source page, angled away from the center. 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'
|
||||||
@@ -99,23 +103,19 @@ function extLabel(ext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect external transitions: referer origin -> entry page (incoming) and
|
* Collect outgoing external transitions: page path -> full exit URL.
|
||||||
* page -> exit origin (outgoing). Aggregated per (origin, page) pair, with
|
* Aggregated per (URL, page) pair. Incoming external links are now derived
|
||||||
* separate directional counts. "(direct)" entries are not links and skipped.
|
* from visit records (which carry UTM tags), so only exits remain here.
|
||||||
*/
|
*/
|
||||||
function collectExternalPairs(transitions) {
|
function collectExitPairs(transitions) {
|
||||||
const pairs = new Map() // `${ext} ${page}` -> {ext, page, in, out}
|
const pairs = new Map() // `${ext} ${page}` -> {ext, page, out}
|
||||||
for (const [fr, tos] of Object.entries(transitions || {})) {
|
for (const [fr, tos] of Object.entries(transitions || {})) {
|
||||||
|
if (!fr.startsWith('/')) continue // ignore external -> anything
|
||||||
for (const [to, count] of Object.entries(tos)) {
|
for (const [to, count] of Object.entries(tos)) {
|
||||||
const frExt = !fr.startsWith('/')
|
if (!to.startsWith('http')) continue
|
||||||
const toExt = !to.startsWith('/')
|
const k = `${to} ${fr}`
|
||||||
if (frExt === toExt) continue // internal-internal or ext-ext
|
const p = pairs.get(k) || { ext: to, page: fr, out: 0 }
|
||||||
const ext = frExt ? fr : to
|
p.out += count
|
||||||
const page = frExt ? to : fr
|
|
||||||
if (!ext.startsWith('http')) continue
|
|
||||||
const k = `${ext} ${page}`
|
|
||||||
const p = pairs.get(k) || { ext, page, in: 0, out: 0 }
|
|
||||||
p[frExt ? 'in' : 'out'] += count
|
|
||||||
pairs.set(k, p)
|
pairs.set(k, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,7 +227,7 @@ function positionNodes(nodes, maxDepth, unit, 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
|
||||||
// Slug inside the circle; full title goes on the link title attribute.
|
// Slug inside the circle; full title goes on the link title attribute.
|
||||||
const slug = n.path === '/' ? '🏠' : n.path.split('/').pop()
|
const slug = n.path === '/' ? '🏠︎' : n.path.split('/').pop()
|
||||||
n.label = slug.length > 16 ? `${slug.slice(0, 15)}…` : slug
|
n.label = slug.length > 16 ? `${slug.slice(0, 15)}…` : slug
|
||||||
n.title = titles.get(n.path) || ''
|
n.title = titles.get(n.path) || ''
|
||||||
// Category (non-leaf) pages with no views in this window are left
|
// Category (non-leaf) pages with no views in this window are left
|
||||||
@@ -537,24 +537,96 @@ export function filterViewsByRange(views, t0, t1) {
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keep only visits whose start time falls inside [t0, t1). */
|
||||||
|
export function filterVisitsByRange(visits, t0, t1) {
|
||||||
|
const out = []
|
||||||
|
for (const v of visits || []) {
|
||||||
|
const t = Date.parse(v.start)
|
||||||
|
if ((t0 == null || t >= t0) && (t1 == null || t < t1)) out.push(v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const UTM_PRIORITY = ['utm_campaign', 'utm_source']
|
||||||
|
const UTM_FALLBACK = ['utm_medium', 'utm_content', 'utm_term', 'utm_id']
|
||||||
|
|
||||||
|
/** Identify the source of a visit according to the requested priority. */
|
||||||
|
function identifySource(visit) {
|
||||||
|
const utm = visit.utm || {}
|
||||||
|
for (const k of UTM_PRIORITY) {
|
||||||
|
const v = utm[k]
|
||||||
|
if (v) return { value: v, isUtm: true }
|
||||||
|
}
|
||||||
|
if (visit.referer?.startsWith('http')) {
|
||||||
|
return { value: visit.referer, isUtm: false }
|
||||||
|
}
|
||||||
|
for (const k of UTM_FALLBACK) {
|
||||||
|
const v = utm[k]
|
||||||
|
if (v) return { value: v, isUtm: true }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Place external referer and exit nodes and build their edges and bead
|
* Collect source -> entry page pairs from visit records. Sources are
|
||||||
|
* identified by UTM campaign/source (then referer, then other UTM tags).
|
||||||
|
* A UTM source only gets a link href when every visit using that source
|
||||||
|
* came from the same referer; referer sources always link to their origin.
|
||||||
|
*/
|
||||||
|
function collectSourcePairs(visits) {
|
||||||
|
const groups = new Map() // `${source}\0${page}` -> pair
|
||||||
|
for (const v of visits || []) {
|
||||||
|
const src = identifySource(v)
|
||||||
|
if (!src) continue
|
||||||
|
const k = `${src.value}\0${v.entry}`
|
||||||
|
const p = groups.get(k) || {
|
||||||
|
source: src.value,
|
||||||
|
page: v.entry,
|
||||||
|
in: 0,
|
||||||
|
refs: new Set(),
|
||||||
|
missingRef: false,
|
||||||
|
href: null,
|
||||||
|
isUtm: src.isUtm,
|
||||||
|
}
|
||||||
|
p.in += 1
|
||||||
|
if (v.referer?.startsWith('http')) {
|
||||||
|
p.refs.add(v.referer)
|
||||||
|
} else {
|
||||||
|
p.missingRef = true
|
||||||
|
}
|
||||||
|
groups.set(k, p)
|
||||||
|
}
|
||||||
|
for (const p of groups.values()) {
|
||||||
|
if (p.isUtm && !p.missingRef && p.refs.size === 1) {
|
||||||
|
const ref = [...p.refs][0]
|
||||||
|
if (ref.startsWith('http')) p.href = ref
|
||||||
|
} else if (!p.isUtm && p.source.startsWith('http')) {
|
||||||
|
p.href = p.source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...groups.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place external source and exit nodes and build their edges and bead
|
||||||
* flows.
|
* flows.
|
||||||
* Referers (incoming links) form a row centered above the map, hottest
|
* Sources (incoming links) are derived from visit UTM/referer data and form
|
||||||
* first; exits sit just outside their source page, fanned away from the
|
* a row centered above the map, hottest first; exits come from the
|
||||||
* center and nudged outwards until they no longer overlap any node.
|
* transition matrix and sit just outside their source page.
|
||||||
* 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(external, byPath, radius, innerBounds, visualScale = 1) {
|
function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualScale = 1) {
|
||||||
const extNodes = []
|
const extNodes = []
|
||||||
const edges = []
|
const edges = []
|
||||||
const flows = []
|
const flows = []
|
||||||
let extTotal = 0
|
let extTotal = 0
|
||||||
for (const p of external) extTotal += p.in + p.out
|
for (const p of sources) extTotal += p.in
|
||||||
|
for (const p of exits) extTotal += p.out
|
||||||
const minCount = extTotal * PRUNE_FRACTION
|
const minCount = extTotal * PRUNE_FRACTION
|
||||||
const live = external.filter((p) => byPath.has(p.page))
|
const liveSources = sources.filter((p) => byPath.has(p.page))
|
||||||
if (!live.length) return { extNodes, edges, flows }
|
const liveExits = exits.filter((p) => byPath.has(p.page))
|
||||||
|
if (!liveSources.length && !liveExits.length) return { extNodes, edges, flows }
|
||||||
|
|
||||||
const width = (count) => scaledWidth(count * visualScale)
|
const width = (count) => scaledWidth(count * visualScale)
|
||||||
|
|
||||||
@@ -563,16 +635,22 @@ function buildExternal(external, byPath, radius, innerBounds, visualScale = 1) {
|
|||||||
(n) => Math.hypot(n.x - x, n.y - y) < (n.r ?? TNODE_R) + r + 10,
|
(n) => Math.hypot(n.x - x, n.y - y) < (n.r ?? TNODE_R) + r + 10,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Incoming: one referer node per origin, in a row centered above the
|
// Incoming: one source node per identified source, in a row centered
|
||||||
// map, with an edge to each page that origin led to.
|
// above the map, with an edge to each page that source led to.
|
||||||
const byExt = new Map() // ext -> pairs, sorted by total incoming count
|
const bySource = new Map() // source -> pairs, sorted by total incoming count
|
||||||
for (const p of live.filter((p) => p.in >= minCount)) {
|
for (const p of liveSources.filter((p) => p.in >= minCount)) {
|
||||||
const g = byExt.get(p.ext) || []
|
const g = bySource.get(p.source) || []
|
||||||
g.push(p)
|
g.push(p)
|
||||||
byExt.set(p.ext, g)
|
bySource.set(p.source, g)
|
||||||
}
|
}
|
||||||
const origins = [...byExt]
|
const origins = [...bySource]
|
||||||
.map(([ext, ps]) => ({ ext, ps, total: ps.reduce((s, p) => s + p.in, 0) }))
|
.map(([source, ps]) => ({
|
||||||
|
source,
|
||||||
|
ps,
|
||||||
|
total: ps.reduce((s, p) => s + p.in, 0),
|
||||||
|
href: ps[0].href,
|
||||||
|
isUtm: ps[0].isUtm,
|
||||||
|
}))
|
||||||
.sort((a, b) => b.total - a.total)
|
.sort((a, b) => b.total - a.total)
|
||||||
.slice(0, MAX_EXT_IN)
|
.slice(0, MAX_EXT_IN)
|
||||||
if (origins.length) {
|
if (origins.length) {
|
||||||
@@ -580,9 +658,18 @@ function buildExternal(external, byPath, radius, innerBounds, visualScale = 1) {
|
|||||||
const y = innerBounds.y0 - TNODE_R - 64
|
const y = innerBounds.y0 - TNODE_R - 64
|
||||||
const spacing = 2 * EXT_R + 44
|
const spacing = 2 * EXT_R + 44
|
||||||
const x0 = cx - ((origins.length - 1) * spacing) / 2
|
const x0 = cx - ((origins.length - 1) * spacing) / 2
|
||||||
origins.forEach(({ ext, ps }, i) => {
|
origins.forEach(({ source, ps, total, href, isUtm }, i) => {
|
||||||
const total = ps.reduce((s, p) => s + p.in, 0)
|
const label = isUtm ? source : extLabel(source)
|
||||||
const xn = { path: ext, label: extLabel(ext), x: x0 + i * spacing, y, r: EXT_R, count: total, kind: 'source' }
|
const xn = {
|
||||||
|
path: source,
|
||||||
|
href,
|
||||||
|
label: label.length > 25 ? `${label.slice(0, 24)}…` : label,
|
||||||
|
x: x0 + i * spacing,
|
||||||
|
y,
|
||||||
|
r: EXT_R,
|
||||||
|
count: total,
|
||||||
|
kind: 'source',
|
||||||
|
}
|
||||||
extNodes.push(xn)
|
extNodes.push(xn)
|
||||||
for (const p of ps) {
|
for (const p of ps) {
|
||||||
const page = byPath.get(p.page)
|
const page = byPath.get(p.page)
|
||||||
@@ -599,7 +686,7 @@ function buildExternal(external, byPath, radius, innerBounds, visualScale = 1) {
|
|||||||
// (same radial spacing internal rings use), fanned around the source angle,
|
// (same radial spacing internal rings use), fanned around the source angle,
|
||||||
// and shows the total count across all pages that link to that URL.
|
// and shows the total count across all pages that link to that URL.
|
||||||
const GAP = radius(1) - radius(0)
|
const GAP = radius(1) - radius(0)
|
||||||
const outgoing = live.filter((p) => p.out >= minCount)
|
const outgoing = liveExits.filter((p) => p.out >= minCount)
|
||||||
.sort((a, b) => b.out - a.out)
|
.sort((a, b) => b.out - a.out)
|
||||||
const perPage = new Map()
|
const perPage = new Map()
|
||||||
const selected = []
|
const selected = []
|
||||||
@@ -629,7 +716,16 @@ function buildExternal(external, byPath, radius, innerBounds, visualScale = 1) {
|
|||||||
x = page.x + Math.cos(ang) * dist
|
x = page.x + Math.cos(ang) * dist
|
||||||
y = page.y + Math.sin(ang) * dist
|
y = page.y + Math.sin(ang) * dist
|
||||||
}
|
}
|
||||||
xn = { path: p.ext, label: extLabel(p.ext), x, y, r: EXT_R, count: 0, kind: 'exit' }
|
xn = {
|
||||||
|
path: p.ext,
|
||||||
|
href: p.ext,
|
||||||
|
label: extLabel(p.ext),
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
r: EXT_R,
|
||||||
|
count: 0,
|
||||||
|
kind: 'exit',
|
||||||
|
}
|
||||||
exitNodes.set(p.ext, xn)
|
exitNodes.set(p.ext, xn)
|
||||||
extNodes.push(xn)
|
extNodes.push(xn)
|
||||||
}
|
}
|
||||||
@@ -650,7 +746,8 @@ function buildExternal(external, byPath, radius, innerBounds, visualScale = 1) {
|
|||||||
*/
|
*/
|
||||||
export function buildTransitionGraph(data, pageTree, visits = [], visualScale = 1) {
|
export function buildTransitionGraph(data, pageTree, visits = [], visualScale = 1) {
|
||||||
const internal = collectInternalTransitions(data?.transitions)
|
const internal = collectInternalTransitions(data?.transitions)
|
||||||
const external = collectExternalPairs(data?.transitions)
|
const sources = collectSourcePairs(visits)
|
||||||
|
const exits = collectExitPairs(data?.transitions)
|
||||||
const navOrder = buildNavigationOrder(pageTree)
|
const navOrder = buildNavigationOrder(pageTree)
|
||||||
const titles = buildTitleMap(pageTree)
|
const titles = buildTitleMap(pageTree)
|
||||||
const readMinutes = buildReadMinutes(visits)
|
const readMinutes = buildReadMinutes(visits)
|
||||||
@@ -690,7 +787,7 @@ export function buildTransitionGraph(data, pageTree, visits = [], visualScale =
|
|||||||
bounds.y1 = Math.max(bounds.y1, b.y1)
|
bounds.y1 = Math.max(bounds.y1, b.y1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = buildExternal(external, byPath, radius, bounds, visualScale)
|
const ext = buildExternal({ sources, exits }, byPath, radius, bounds, visualScale)
|
||||||
for (const xn of ext.extNodes) {
|
for (const xn of ext.extNodes) {
|
||||||
bounds.x0 = Math.min(bounds.x0, xn.x - xn.r - pad)
|
bounds.x0 = Math.min(bounds.x0, xn.x - xn.r - pad)
|
||||||
bounds.y0 = Math.min(bounds.y0, xn.y - xn.r - pad)
|
bounds.y0 = Math.min(bounds.y0, xn.y - xn.r - pad)
|
||||||
|
|||||||
Reference in New Issue
Block a user