Pill shaped nodes
This commit is contained in:
@@ -10,7 +10,8 @@ import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vu
|
||||
import { rangeWindow, WEEK } from './analytics/time.js'
|
||||
import { formatCount } from './analytics/format.js'
|
||||
import {
|
||||
TNODE_R,
|
||||
TNODE_W,
|
||||
TNODE_H,
|
||||
BEAD_R,
|
||||
BEAD_SPEED,
|
||||
buildTransitionGraph,
|
||||
@@ -146,16 +147,17 @@ watch(svgEl, (el) => {
|
||||
})
|
||||
watch(() => graph.value?.bounds, updateScale)
|
||||
onBeforeUnmount(() => resizeObs?.disconnect())
|
||||
|
||||
// Font size (px) that fits a label inside the pill width at the current
|
||||
// zoom: ~0.52 em average glyph width, 12 px padding per side, capped.
|
||||
const fitPx = (label) =>
|
||||
Math.min(15, (TNODE_W * pxPerUnit.value - 24) / (0.52 * Math.max(label.length, 1)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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}`"
|
||||
role="img" aria-label="map of transitions between pages">
|
||||
<defs>
|
||||
<!-- Unit-radius circle; only the portion near the bottom is used. -->
|
||||
<path id="tnode-label-arc" d="M 0,-1 A 1,1 0 1,0 0,1 A 1,1 0 1,0 -0.001,-1" />
|
||||
</defs>
|
||||
<path v-for="(a, i) in graph.arcs" :key="'a' + i"
|
||||
:d="a.d" class="tarc" />
|
||||
<path v-for="(e, i) in graph.edges" :key="'e' + i"
|
||||
@@ -166,29 +168,26 @@ onBeforeUnmount(() => resizeObs?.disconnect())
|
||||
:cx="b.x" :cy="b.y" :r="BEAD_R" class="tbead" />
|
||||
<g v-for="(x, i) in graph.extNodes" :key="'x' + i">
|
||||
<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"
|
||||
: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" class="tnodecount" dominant-baseline="middle">{{ formatCount(x.count) }}</text>
|
||||
<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"
|
||||
: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">{{ formatCount(x.count) }}</text>
|
||||
</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" class="tnodecount" dominant-baseline="middle">{{ formatCount(x.count) }}</text>
|
||||
<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"
|
||||
: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">{{ formatCount(x.count) }}</text>
|
||||
</g>
|
||||
</g>
|
||||
<g v-for="n in graph.nodes" :key="n.path">
|
||||
<a v-if="!n.hidden" :href="n.path" :title="n.title">
|
||||
<circle :cx="n.x" :cy="n.y" :r="TNODE_R" class="tnode" />
|
||||
<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" class="tnodecount" dominant-baseline="middle">
|
||||
<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"
|
||||
: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">
|
||||
{{ n.readMin ? `${formatCount(n.views)}×${n.readMin}m` : formatCount(n.views) }}
|
||||
</text>
|
||||
</a>
|
||||
@@ -227,33 +226,33 @@ onBeforeUnmount(() => resizeObs?.disconnect())
|
||||
filter: drop-shadow(0 0 2.5px var(--accent));
|
||||
}
|
||||
.tmap .txnode {
|
||||
fill: var(--bg, Canvas);
|
||||
stroke-width: 1.5;
|
||||
fill: var(--text);
|
||||
stroke: none;
|
||||
}
|
||||
.tmap .txnode-source { stroke: var(--text); }
|
||||
.tmap .txnode-exit { stroke: var(--text); }
|
||||
.tmap .txnode-source { fill: var(--text); }
|
||||
.tmap .txnode-exit { fill: var(--text); }
|
||||
.tmap .tarc {
|
||||
fill: none;
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.tmap .tnode {
|
||||
fill: var(--bg, Canvas);
|
||||
stroke: var(--accent);
|
||||
stroke-width: 1.5;
|
||||
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. */
|
||||
.tmap .tnodeslug {
|
||||
fill: var(--text);
|
||||
font-size: calc(15px / (var(--u, 1) * var(--node-r, 56)));
|
||||
fill: var(--bg, Canvas);
|
||||
font-size: calc(var(--slug-px, 15px) / var(--u, 1));
|
||||
text-anchor: middle;
|
||||
}
|
||||
.tmap a { cursor: pointer; }
|
||||
.tmap a:hover .tnodeslug { fill: var(--accent); }
|
||||
.tmap a:hover .tnodeslug { text-decoration: underline; }
|
||||
.tmap .tnodecount {
|
||||
fill: var(--muted);
|
||||
fill: var(--bg, Canvas);
|
||||
opacity: 0.75;
|
||||
font-size: calc(13px / var(--u, 1));
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* paths seen in transitions (deleted pages); siblings run clockwise in
|
||||
* navigation order, starting at the top. Internal path -> path transitions
|
||||
* join opposite directions into straight connections (middle width = total
|
||||
* count, wrapping the node circles at both ends). Connection width grows
|
||||
* count; connectors flare into the node pills at both ends and wrap
|
||||
* around their backs, surrounding them; the pills are drawn on top). Connection width grows
|
||||
* logarithmically with the count (a single count renders as a ~1 px
|
||||
* line, uncapped growth); connections carrying less than 1% of the total
|
||||
* traffic are pruned, which naturally keeps the graph under ~100
|
||||
@@ -25,8 +26,97 @@
|
||||
|
||||
import { MIN_READ_SECONDS } from './format.js'
|
||||
|
||||
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
|
||||
// Nodes are constant-size pills (stadium rects) holding the slug and the
|
||||
// view count on two centered lines. TNODE_BOUND is the pill's bounding
|
||||
// radius, used for layout clearance and placement; connectors and flows
|
||||
// use the exact outline geometry instead (pillContact below).
|
||||
export const TNODE_W = 160
|
||||
export const TNODE_H = 54
|
||||
const TNODE_BOUND = Math.hypot(TNODE_W, TNODE_H) / 2
|
||||
|
||||
const PILL_R = TNODE_H / 2 // cap radius and straight-section half-height
|
||||
const PILL_OFF = TNODE_W / 2 - PILL_R // x offset of the cap centers
|
||||
|
||||
/**
|
||||
* Where the ray from a node center along (ux, uy) exits the pill outline
|
||||
* (a capsule: straight top/bottom plus semicircular caps), enlarged by
|
||||
* `margin`. Returns the distance `t` to the contact point and the outline
|
||||
* arc position `s` of that point (see pillPointAt).
|
||||
*/
|
||||
const pillContact = (ux, uy, margin = 0) => {
|
||||
const r = PILL_R + margin
|
||||
const off = PILL_OFF + margin
|
||||
const q = (Math.PI / 2) * r
|
||||
// Straight top/bottom: valid when the crossing lands on the flat section.
|
||||
let tf = Infinity
|
||||
if (Math.abs(uy) > 1e-9) {
|
||||
const t = r / Math.abs(uy)
|
||||
if (Math.abs(t * ux) <= off + 1e-9) tf = t
|
||||
}
|
||||
// Rounded cap on the side the ray points to.
|
||||
const cx = off * (ux >= 0 ? 1 : -1)
|
||||
const disc = r * r - (cx * uy) ** 2
|
||||
const tc = disc >= 0 ? cx * ux + Math.sqrt(disc) : Infinity
|
||||
if (tf <= tc) {
|
||||
const x = tf * ux
|
||||
return { t: tf, s: uy > 0 ? q + off - x : q + 2 * off + Math.PI * r + x + off }
|
||||
}
|
||||
if (tc < Infinity) {
|
||||
let th = Math.atan2(tc * uy, tc * ux - cx)
|
||||
if (th < 0) th += 2 * Math.PI
|
||||
const s = cx > 0
|
||||
? th <= Math.PI / 2
|
||||
? th * r
|
||||
: q + 4 * off + Math.PI * r + (th - (3 * Math.PI) / 2) * r
|
||||
: q + 2 * off + (th - Math.PI / 2) * r
|
||||
return { t: tc, s }
|
||||
}
|
||||
return { t: TNODE_BOUND + margin, s: 0 }
|
||||
}
|
||||
|
||||
/** Total perimeter of the (margined) pill outline. */
|
||||
const pillPerimeter = (margin = 0) =>
|
||||
4 * (PILL_OFF + margin) + 2 * Math.PI * (PILL_R + margin)
|
||||
|
||||
/**
|
||||
* Point on the pill outline at arc position `s`, counterclockwise from the
|
||||
* right cap tip: right cap up, top flat right-to-left, left cap down,
|
||||
* bottom flat left-to-right, right cap up to the tip. Pills are never
|
||||
* rotated, so the returned offset from the node center is in absolute
|
||||
* coordinates.
|
||||
*/
|
||||
const pillPointAt = (s, margin = 0) => {
|
||||
const r = PILL_R + margin
|
||||
const off = PILL_OFF + margin
|
||||
const P = pillPerimeter(margin)
|
||||
const q = (Math.PI / 2) * r
|
||||
s = ((s % P) + P) % P
|
||||
if (s < q) {
|
||||
const th = s / r
|
||||
return [off + r * Math.cos(th), r * Math.sin(th)]
|
||||
}
|
||||
s -= q
|
||||
if (s < 2 * off) return [off - s, r]
|
||||
s -= 2 * off
|
||||
if (s < Math.PI * r) {
|
||||
const th = Math.PI / 2 + s / r
|
||||
return [-off + r * Math.cos(th), r * Math.sin(th)]
|
||||
}
|
||||
s -= Math.PI * r
|
||||
if (s < 2 * off) return [-off + s, -r]
|
||||
s -= 2 * off
|
||||
const th = (3 * Math.PI) / 2 + s / r
|
||||
return [off + r * Math.cos(th), r * Math.sin(th)]
|
||||
}
|
||||
|
||||
/** Unit tangent to the pill outline at arc position `s`, in the direction
|
||||
* of increasing `s` (numeric; exact on both flats and caps). */
|
||||
const pillTangent = (s, margin = 0) => {
|
||||
const [x1, y1] = pillPointAt(s - 0.5, margin)
|
||||
const [x2, y2] = pillPointAt(s + 0.5, margin)
|
||||
const m = Math.hypot(x2 - x1, y2 - y1) || 1
|
||||
return [(x2 - x1) / m, (y2 - y1) / m]
|
||||
}
|
||||
|
||||
// Edge width (half-width of the thin middle) grows logarithmically with
|
||||
// the count. The constants are scaled down by ~10× so busy ranges (day,
|
||||
@@ -210,7 +300,8 @@ function positionNodes(nodes, maxDepth, unit, viewsData, titles, readMinutes) {
|
||||
// along a ring: leaf arc = unit * GAP, so GAP scales up with `unit` on
|
||||
// sparse trees (where closing the circle forces wider arcs) and with
|
||||
// 1/unit on dense ones (keeping arcs at the node clearance).
|
||||
const CLEAR = 2 * TNODE_R + 12
|
||||
// Clearance is pill-width based.
|
||||
const CLEAR = TNODE_W + 20
|
||||
const GAP = CLEAR * Math.max(unit, 1 / unit)
|
||||
const radius = (d) => d * GAP
|
||||
|
||||
@@ -226,12 +317,12 @@ function positionNodes(nodes, maxDepth, unit, viewsData, titles, readMinutes) {
|
||||
n.y = Math.sin(n.angle) * r
|
||||
n.views = viewCount(n.path)
|
||||
n.readMin = readMinutes[n.path] || 0
|
||||
// Slug inside the circle; full title goes on the link title attribute.
|
||||
// Slug inside the pill; full title goes on the link title attribute.
|
||||
const slug = n.path === '/' ? '🏠︎' : n.path.split('/').pop()
|
||||
n.label = slug.length > 16 ? `${slug.slice(0, 15)}…` : slug
|
||||
n.title = titles.get(n.path) || ''
|
||||
// Category (non-leaf) pages with no views in this window are left
|
||||
// blank to keep the layout, but their circle/label is not drawn.
|
||||
// blank to keep the layout, but their pill/label is not drawn.
|
||||
n.hidden = n.children.length > 0 && n.views === 0
|
||||
}
|
||||
|
||||
@@ -250,8 +341,9 @@ function buildFamilyArcs(nodes, radius) {
|
||||
// The spoke aims along the FIRST CHILD's angle (the node's own angle
|
||||
// coincides with it, except for the center page which has none).
|
||||
const first = n.children[0]
|
||||
const r1 = radius(n.depth) + TNODE_R
|
||||
const r2 = radius(first.depth) - TNODE_R
|
||||
const pillR = pillContact(Math.cos(first.angle), Math.sin(first.angle)).t
|
||||
const r1 = radius(n.depth) + pillR
|
||||
const r2 = radius(first.depth) - pillR
|
||||
arcs.push({
|
||||
d: `M ${Math.cos(first.angle) * r1} ${Math.sin(first.angle) * r1} `
|
||||
+ `L ${Math.cos(first.angle) * r2} ${Math.sin(first.angle) * r2}`,
|
||||
@@ -310,9 +402,13 @@ const fmtPt = (p) => `${p[0].toFixed(2)} ${p[1].toFixed(2)}`
|
||||
/**
|
||||
* Build one ribbon edge between two nodes with counts ab and ba.
|
||||
* `wMid` is the half-width of the thin middle (already strength-scaled by
|
||||
* the caller); `ra`/`rb` are the radii of the node circles each end wraps.
|
||||
* the caller). Each end flares into the node's pill surround (the outline
|
||||
* enlarged by margin S): the flare contact points follow the pill outline
|
||||
* a constant arc distance to each side of the direct contact point, and
|
||||
* the back of the ribbon wraps all the way around the pill between them,
|
||||
* surrounding the node. The pills themselves are drawn on top.
|
||||
*/
|
||||
function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R, external = false) {
|
||||
function buildRibbon(a, b, ab, ba, wMid, external = false) {
|
||||
const count = ab + ba
|
||||
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
|
||||
const ux = (b.x - a.x) / len
|
||||
@@ -320,27 +416,40 @@ function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R, external =
|
||||
const nx = -uy
|
||||
const ny = ux
|
||||
|
||||
// Radius of each node surround and the attachment geometry on it.
|
||||
// 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.
|
||||
const BETA = (65 * Math.PI) / 180
|
||||
const ENDA = R2A * Math.cos(BETA)
|
||||
const wEndA = R2A * Math.sin(BETA)
|
||||
const ENDB = R2B * Math.cos(BETA)
|
||||
const wEndB = R2B * Math.sin(BETA)
|
||||
// Direct contact: where the centerline exits each pill's surround.
|
||||
const S = 4
|
||||
const cA = pillContact(ux, uy, S)
|
||||
const cB = pillContact(-ux, -uy, S)
|
||||
|
||||
// Flares take a fair share of the free span while leaving the
|
||||
// count-scaled thin middle a visible share of the connection length.
|
||||
// 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))
|
||||
// The maximum flare length scales with the contact distance so wide
|
||||
// approach angles still show a wide connector end.
|
||||
const free = Math.max(0, len - cA.t - cB.t)
|
||||
const FLARE = Math.min(Math.max(cA.t, cB.t) * 1.2, free * 0.4)
|
||||
|
||||
// Flare endpoints: walk the outline a constant arc distance to each
|
||||
// side of the direct contact point (spanning flats and caps alike).
|
||||
const D = (Math.PI / 4) * (PILL_R + S)
|
||||
// Per node: endpoints for the +n (left) and -n (right) flare sides,
|
||||
// each with its arc position, absolute point, and an outline tangent
|
||||
// oriented back toward the direct contact point.
|
||||
const ends = (cx, cy, contact) => {
|
||||
const pick = (s) => {
|
||||
const [px, py] = pillPointAt(s, S)
|
||||
// Outline tangent oriented back toward the direct contact point
|
||||
// (the flare side sweeps from the contact point around to its
|
||||
// endpoint and into the connection), so it can never fork outward.
|
||||
const tan = pillTangent(s, S)
|
||||
if (s > contact.s) { tan[0] = -tan[0]; tan[1] = -tan[1] }
|
||||
return { s, p: [cx + px, cy + py], tan, side: px * nx + py * ny }
|
||||
}
|
||||
const plus = pick(contact.s + D)
|
||||
const minus = pick(contact.s - D)
|
||||
return plus.side >= 0 ? [plus, minus] : [minus, plus]
|
||||
}
|
||||
const [aLeftEnd, aRightEnd] = ends(a.x, a.y, cA)
|
||||
const [bLeftEnd, bRightEnd] = ends(b.x, b.y, cB)
|
||||
|
||||
// Point on the connection centerline at distance t from A, offset s
|
||||
// perpendicular to it.
|
||||
@@ -349,48 +458,24 @@ function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R, external =
|
||||
a.y + t * uy + s * ny,
|
||||
]
|
||||
|
||||
// Arc around a node from p to q the long way, passing its back side.
|
||||
const wrap = (p, q, node, back, R2) => {
|
||||
const ang = (pt2) =>
|
||||
Math.atan2(pt2[1] - node[1], pt2[0] - node[0])
|
||||
|
||||
const TAU = 2 * Math.PI
|
||||
const da = ((ang(back) - ang(p)) % TAU + TAU) % TAU
|
||||
const db = ((ang(q) - ang(p)) % TAU + TAU) % TAU
|
||||
|
||||
return `A ${R2} ${R2} 0 1 ${da < db ? 1 : 0} ${fmtPt(q)} `
|
||||
}
|
||||
|
||||
// Build one side of a flare in node -> middle order.
|
||||
const flarePoints = (endT, midT, s, dir, R2, END, wEnd) => {
|
||||
const span = Math.abs(midT - endT)
|
||||
const pEnd = P(endT, s * wEnd)
|
||||
const pMid = P(midT, s * wMid)
|
||||
|
||||
// At the node, leave tangent to the circular surround.
|
||||
// The circle radius at the attachment is locally:
|
||||
// A: (+END, ±wEnd)
|
||||
// B: (-END, ±wEnd)
|
||||
// A perpendicular tangent pointing into the connection therefore has
|
||||
// these centerline/normal components.
|
||||
const tangentT = dir * wEnd / R2
|
||||
const tangentS = -s * END / R2
|
||||
|
||||
const hEnd = span * 0.65
|
||||
const hMid = span * 0.4
|
||||
|
||||
const cEnd = P(
|
||||
endT + tangentT * hEnd,
|
||||
s * wEnd + tangentS * hEnd,
|
||||
)
|
||||
|
||||
// At the thin end, arrive parallel with the centerline.
|
||||
const cMid = P(
|
||||
midT - dir * hMid,
|
||||
s * wMid,
|
||||
)
|
||||
|
||||
return { pEnd, cEnd, cMid, pMid }
|
||||
// One side of a flare: from the outline endpoint, leaving tangent to
|
||||
// the pill outline, to the connection middle arriving parallel with
|
||||
// the centerline. The tangent pull is clamped so the control point
|
||||
// stays well on its own side of the centerline — otherwise a long
|
||||
// flare on a rounded cap crosses the opposite side.
|
||||
const flarePoints = (end, midT, s, dir) => {
|
||||
let hEnd = FLARE * 0.65
|
||||
const hMid = FLARE * 0.4
|
||||
const tanS = end.tan[0] * nx + end.tan[1] * ny // inward rate
|
||||
if (tanS * end.side < 0) {
|
||||
hEnd = Math.min(hEnd, (Math.abs(end.side) * 0.6) / Math.abs(tanS))
|
||||
}
|
||||
return {
|
||||
pEnd: end.p,
|
||||
cEnd: [end.p[0] + end.tan[0] * hEnd, end.p[1] + end.tan[1] * hEnd],
|
||||
cMid: P(midT - dir * hMid, s * wMid),
|
||||
pMid: P(midT, s * wMid),
|
||||
}
|
||||
}
|
||||
|
||||
// Emit a cubic in either traversal direction. Reversing a cubic requires
|
||||
@@ -402,25 +487,39 @@ function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R, external =
|
||||
return `C ${fmtPt(f.cMid)} ${fmtPt(f.cEnd)} ${fmtPt(f.pEnd)} `
|
||||
}
|
||||
|
||||
const LA = P(ENDA, wEndA)
|
||||
const RA = P(ENDA, -wEndA)
|
||||
const LB = P(len - ENDB, wEndB)
|
||||
const RB = P(len - ENDB, -wEndB)
|
||||
// Trace the surround outline the long way around (behind the node) from
|
||||
// arc s1 to arc s2. Sampled as a polyline: the visible result is a thin
|
||||
// halo hugging the pill, so exact arc segments are unnecessary.
|
||||
const outlineWrap = (cx, cy, s1, s2) => {
|
||||
const per = pillPerimeter(S)
|
||||
const dPlus = ((s2 - s1) % per + per) % per
|
||||
const total = dPlus > per / 2 ? dPlus : per - dPlus
|
||||
const dir = dPlus > per / 2 ? 1 : -1
|
||||
const n = Math.max(4, Math.ceil(total / 6))
|
||||
let out = ''
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const [x, y] = pillPointAt(s1 + (dir * total * i) / n, S)
|
||||
out += `L ${(cx + x).toFixed(2)} ${(cy + y).toFixed(2)} `
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const aLeft = flarePoints(ENDA, ENDA + FLARE, 1, 1, R2A, ENDA, wEndA)
|
||||
const bLeft = flarePoints(len - ENDB, len - ENDB - FLARE, 1, -1, R2B, ENDB, wEndB)
|
||||
const bRight = flarePoints(len - ENDB, len - ENDB - FLARE, -1, -1, R2B, ENDB, wEndB)
|
||||
const aRight = flarePoints(ENDA, ENDA + FLARE, -1, 1, R2A, ENDA, wEndA)
|
||||
const aLeft = flarePoints(aLeftEnd, cA.t + FLARE, 1, 1)
|
||||
const bLeft = flarePoints(bLeftEnd, len - cB.t - FLARE, 1, -1)
|
||||
const bRight = flarePoints(bRightEnd, len - cB.t - FLARE, -1, -1)
|
||||
const aRight = flarePoints(aRightEnd, cA.t + FLARE, -1, 1)
|
||||
|
||||
const d = `M ${fmtPt(LA)} `
|
||||
// Each end wraps the full back of the node pill between its two flare
|
||||
// contact points (bLeft -> bRight around B, aRight -> aLeft around A).
|
||||
const d = `M ${fmtPt(aLeft.pEnd)} `
|
||||
+ curve(aLeft)
|
||||
+ `L ${fmtPt(bLeft.pMid)} `
|
||||
+ curve(bLeft, true)
|
||||
+ wrap(LB, RB, [b.x, b.y], P(len + R2B, 0), R2B)
|
||||
+ outlineWrap(b.x, b.y, bLeftEnd.s, bRightEnd.s)
|
||||
+ curve(bRight)
|
||||
+ `L ${fmtPt(aRight.pMid)} `
|
||||
+ curve(aRight, true)
|
||||
+ wrap(RA, LA, [a.x, a.y], P(-R2A, 0), R2A)
|
||||
+ outlineWrap(a.x, a.y, aRightEnd.s, aLeftEnd.s)
|
||||
+ 'Z'
|
||||
|
||||
return {
|
||||
@@ -433,7 +532,7 @@ function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R, external =
|
||||
/**
|
||||
* Flow descriptors for the bead animation, one per edge direction with a
|
||||
* nonzero count: a straight segment running from inside the source node
|
||||
* to inside the target node (beads render under the node circles, so
|
||||
* to inside the target node (beads render under the node pills, so
|
||||
* they emerge from and vanish beneath the nodes rather than popping in
|
||||
* at the surround), plus the emission interval (seconds between beads,
|
||||
* inverse of count * BEAD_RATE). Each segment is offset to the
|
||||
@@ -441,12 +540,14 @@ function buildRibbon(a, b, ab, ba, wMid, ra = TNODE_R, rb = TNODE_R, external =
|
||||
* edge run on parallel lanes instead of colliding. The component turns
|
||||
* these into independently simulated beads.
|
||||
*/
|
||||
function buildFlows(a, b, ra, rb, ab, ba, visualScale = 1) {
|
||||
function buildFlows(a, b, ab, ba, visualScale = 1) {
|
||||
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1
|
||||
const ux = (b.x - a.x) / len
|
||||
const uy = (b.y - a.y) / len
|
||||
const t0 = ra / 3
|
||||
const t1 = len - rb / 3
|
||||
const rA = pillContact(ux, uy).t
|
||||
const rB = pillContact(-ux, -uy).t
|
||||
const t0 = rA / 3
|
||||
const t1 = len - rB / 3
|
||||
if (t1 - t0 < 12) return []
|
||||
|
||||
// Unit normal pointing to the visual right of the A -> B direction.
|
||||
@@ -502,7 +603,7 @@ function buildInternalEdges(pairs, byPath, visualScale = 1) {
|
||||
const wMid = scaledWidth((ab + ba) * visualScale)
|
||||
if (wMid <= 0) continue
|
||||
edges.push(buildRibbon(a, b, ab, ba, wMid))
|
||||
flows.push(...buildFlows(a, b, TNODE_R, TNODE_R, ab, ba, visualScale))
|
||||
flows.push(...buildFlows(a, b, ab, ba, visualScale))
|
||||
}
|
||||
return { edges, flows }
|
||||
}
|
||||
@@ -636,9 +737,9 @@ function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualSc
|
||||
|
||||
const width = (count) => scaledWidth(count * visualScale)
|
||||
|
||||
const overlaps = (x, y, r) =>
|
||||
const overlaps = (x, y) =>
|
||||
[...byPath.values(), ...extNodes].some(
|
||||
(n) => Math.hypot(n.x - x, n.y - y) < (n.r ?? TNODE_R) + r + 10,
|
||||
(n) => Math.hypot(n.x - x, n.y - y) < TNODE_BOUND + TNODE_BOUND + 10,
|
||||
)
|
||||
|
||||
// Incoming: one source node per identified source, in a row centered
|
||||
@@ -661,8 +762,8 @@ function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualSc
|
||||
.slice(0, MAX_EXT_IN)
|
||||
if (origins.length) {
|
||||
const cx = (innerBounds.x0 + innerBounds.x1) / 2
|
||||
const y = innerBounds.y0 - TNODE_R - 64
|
||||
const spacing = 2 * EXT_R + 44
|
||||
const y = innerBounds.y0 - TNODE_BOUND - 64
|
||||
const spacing = TNODE_W + 44
|
||||
const x0 = cx - ((origins.length - 1) * spacing) / 2
|
||||
origins.forEach(({ source, ps, total, href, isUtm }, i) => {
|
||||
const label = isUtm ? source : extLabel(source)
|
||||
@@ -672,7 +773,6 @@ function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualSc
|
||||
label: label.length > 25 ? `${label.slice(0, 24)}…` : label,
|
||||
x: x0 + i * spacing,
|
||||
y,
|
||||
r: EXT_R,
|
||||
count: total,
|
||||
kind: 'source',
|
||||
}
|
||||
@@ -681,8 +781,8 @@ function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualSc
|
||||
const page = byPath.get(p.page)
|
||||
const wMid = width(p.in)
|
||||
if (wMid <= 0) continue
|
||||
edges.push(buildRibbon(xn, page, p.in, 0, wMid, EXT_R, TNODE_R, true))
|
||||
flows.push(...buildFlows(xn, page, EXT_R, TNODE_R, p.in, 0, visualScale))
|
||||
edges.push(buildRibbon(xn, page, p.in, 0, wMid, true))
|
||||
flows.push(...buildFlows(xn, page, p.in, 0, visualScale))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -717,7 +817,7 @@ function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualSc
|
||||
let dist = GAP
|
||||
let x = page.x + Math.cos(ang) * dist
|
||||
let y = page.y + Math.sin(ang) * dist
|
||||
for (let tries = 0; tries < 5 && overlaps(x, y, EXT_R); tries++) {
|
||||
for (let tries = 0; tries < 5 && overlaps(x, y); tries++) {
|
||||
dist += GAP * 0.3
|
||||
x = page.x + Math.cos(ang) * dist
|
||||
y = page.y + Math.sin(ang) * dist
|
||||
@@ -728,7 +828,6 @@ function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualSc
|
||||
label: extLabel(p.ext),
|
||||
x,
|
||||
y,
|
||||
r: EXT_R,
|
||||
count: 0,
|
||||
kind: 'exit',
|
||||
}
|
||||
@@ -738,8 +837,8 @@ function buildExternal({ sources, exits }, byPath, radius, innerBounds, visualSc
|
||||
xn.count += p.out
|
||||
const wMid = width(p.out)
|
||||
if (wMid <= 0) continue
|
||||
edges.push(buildRibbon(page, xn, p.out, 0, wMid, TNODE_R, EXT_R, true))
|
||||
flows.push(...buildFlows(page, xn, TNODE_R, EXT_R, 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 }
|
||||
@@ -772,17 +871,17 @@ export function buildTransitionGraph(data, pageTree, visits = [], visualScale =
|
||||
const { edges, flows } = buildInternalEdges(pairs, byPath, visualScale)
|
||||
|
||||
// Tight bounding box of the actual page nodes; family ring arcs can sweep
|
||||
// outside the node circle (e.g. a large arc between two siblings on the
|
||||
// outside the node pills (e.g. a large arc between two siblings on the
|
||||
// left side reaching around the right), so their geometry is included too.
|
||||
// External nodes extend the box below.
|
||||
const pad = 16
|
||||
const xs = nodes.map((n) => n.x)
|
||||
const ys = nodes.map((n) => n.y)
|
||||
const bounds = {
|
||||
x0: Math.min(...xs) - TNODE_R - pad,
|
||||
y0: Math.min(...ys) - TNODE_R - pad,
|
||||
x1: Math.max(...xs) + TNODE_R + pad,
|
||||
y1: Math.max(...ys) + TNODE_R + pad,
|
||||
x0: Math.min(...xs) - TNODE_BOUND - pad,
|
||||
y0: Math.min(...ys) - TNODE_BOUND - pad,
|
||||
x1: Math.max(...xs) + TNODE_BOUND + pad,
|
||||
y1: Math.max(...ys) + TNODE_BOUND + pad,
|
||||
}
|
||||
for (const arc of arcs) {
|
||||
if (arc.a0 == null) continue
|
||||
@@ -795,10 +894,10 @@ export function buildTransitionGraph(data, pageTree, visits = [], visualScale =
|
||||
|
||||
const ext = buildExternal({ sources, exits }, byPath, radius, bounds, visualScale)
|
||||
for (const xn of ext.extNodes) {
|
||||
bounds.x0 = Math.min(bounds.x0, xn.x - xn.r - pad)
|
||||
bounds.y0 = Math.min(bounds.y0, xn.y - xn.r - pad)
|
||||
bounds.x1 = Math.max(bounds.x1, xn.x + xn.r + pad)
|
||||
bounds.y1 = Math.max(bounds.y1, xn.y + xn.r + pad)
|
||||
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)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user