Transition map: always show the site structure, omit content-less index pages

Node inclusion no longer depends on views in the window: every page
from /_api/pages is shown (the front page used to vanish when quiet).
Omission follows the navigation instead — index pages without content
of their own (category labels and empty pages, the nodes nav links
bypass to the first child) are omitted, children promoted, first
visible child taking the slot. Deleted pages named by traffic are
inserted as ghosts where they would have been, materializing missing
ancestors as content-less category lanes.

/_api/pages gains the "empty" flag (not node.chunks) for the nav
notion; has_content (chunks is not None) stays for the structure
editor's delete affordance.
This commit is contained in:
LeoVasanko committed 2026-09-25 03:23:53 +00:00
1 parent 0ff5c6b526
commit 204c6ab608
4 files changed
+109 -79

No files matched your search

+9 -2
View File
@@ -416,8 +416,15 @@ by a top lane labeled 🏠︎ beside the home pill (50% thicker than
the branch lanes, its label font and guide offset scaled along), 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
straight down and gradually bends right. Every page of the tree is shown
whether or not it had views in the window — only index pages without
content of their own (category labels and empty pages — the ones the
navigation itself bypasses in favor of the first child) are omitted,
their children
promoted in their place, the first visible child taking the slot. Paths
the traffic names but the tree no longer has (deleted pages) are
inserted as ghosts where they would have been, missing ancestors
materialized as category lanes. 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
+2 -1
View File
@@ -1,7 +1,8 @@
<script setup>
// Recursive site-structure tree with drag-and-drop ordering (vue-draggable).
// Nodes come from the server (GET /_api/pages via StructureEditor.vue) as
// {slug, path, title, translated, order, published, has_content, language,
// {slug, path, title, translated, order, published, has_content, empty,
// language,
// primary, children}. The row's flag
// (LangSelect) sets the node's primary language (language; '' = inherit —
// dimmed, showing the resolved flag); the setting covers the whole subtree.
+90 -76
View File
@@ -3,11 +3,16 @@
*
* Site map following the menu structure: top-level items in a row at the
* top (below the external source row), each item's subtree fanning out
* below it in menu order along a slightly circular downward arc. Index
* pages with no views are omitted, their children moving up in their
* place. All pages of the site are shown (from /_api/pages), plus any
* extra paths seen in transitions (deleted pages); these form their own
* top-level groups. Internal path -> path transitions join opposite
* below it in menu order along a slightly circular downward arc. Every
* page of the site is shown (from /_api/pages), whether or not it had
* views in the window — only index pages without content of their own
* (category labels and empty pages — the ones the site navigation itself
* bypasses in favor of the first child) are omitted, their children
* moving up in their place, the first visible child taking the slot.
* Paths the traffic
* names but the tree no longer has (deleted pages) are inserted as
* ghosts where they would have been, materializing missing ancestors as
* content-less category lanes. Internal path -> path transitions join opposite
* directions into straight connections (middle width = total
* 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
@@ -153,33 +158,6 @@ const MAX_EXT_IN = 8 // referer nodes in the top row
const MAX_EXT_OUT = 12 // exit nodes in the bottom row
const EXT_GAP = 12 // vertical margin of the source/exit rows to the map
/** Flatten the site tree into navigation order via DFS. */
function buildNavigationOrder(pageTree) {
const order = new Map()
const walk = (items) => {
for (const item of items || []) {
const p = `/${item.path}`
if (!order.has(p)) order.set(p, order.size)
walk(item.children)
}
}
walk(pageTree)
return order
}
/** Map page paths to their article titles from the site tree. */
function buildTitleMap(pageTree) {
const titles = new Map()
const walk = (items) => {
for (const item of items || []) {
titles.set(`/${item.path}`, item.title)
walk(item.children)
}
}
walk(pageTree)
return titles
}
/** Extract internal page-to-page transitions, excluding self-loops. */
function collectInternalTransitions(transitions) {
const internal = []
@@ -222,43 +200,70 @@ function collectExitPairs(transitions) {
return [...pairs.values()]
}
/** Build nodes with depth and a path lookup map; children are wired to parents. */
function buildNodeTree(internal, navOrder) {
const paths = new Set(['/', ...navOrder.keys()])
for (const e of internal) { paths.add(e.fr); paths.add(e.to) }
/**
* Build the node tree: every page of the current site tree (from
* /_api/pages), nested by URL and ordered by the menu, plus a ghost for
* every path the traffic names but the tree no longer has (deleted or
* renamed pages). A ghost is inserted where it would have been: under
* its URL parent, with missing ancestors materialized as content-less
* ghost categories — never drawn themselves, their children move up in
* their place and they still define branch lanes, exactly like live
* index pages without content (see annotateNodes/layoutGroups).
* Ghost pills sort after the real siblings, alphabetically among
* themselves (their original menu position is unknowable).
*/
function buildNodeTree(pageTree, extraPaths) {
// The front page anchors the map and is always shown, even with no
// views in the window (and even when the site has no front page node).
const root = { path: '/', depth: 0, title: '', content: true, ghost: false, children: [] }
const byPath = new Map([['/', root]])
const nodes = [root]
const depth = (p) => (p === '/' ? 0 : p.split('/').length - 1)
const nodes = [...paths].map((p) => ({
path: p, depth: depth(p), angle: 0, children: [],
}))
const byPath = new Map(nodes.map((n) => [n.path, n]))
// Parent is the nearest ancestor present in the map, front page last.
const parentOf = (p) => {
let q = p
while (q !== '/') {
q = q.slice(0, q.lastIndexOf('/')) || '/'
if (byPath.has(q)) return byPath.get(q)
const walk = (items, parent) => {
for (const item of items || []) {
const p = `/${item.path}`
let n = byPath.get(p) // the front page item (path "") maps to "/"
if (!n) {
n = {
path: p, depth: parent.depth + 1, title: '',
content: !item.empty, ghost: false, children: [],
}
byPath.set(p, n)
parent.children.push(n)
nodes.push(n)
}
n.title = item.title
walk(item.children, n)
}
return byPath.get('/')
}
for (const n of nodes) {
if (n.path !== '/') parentOf(n.path).children.push(n)
walk(pageTree, root)
// Ghosts: placed under their URL parent, materializing missing
// ancestors as content-less ghost categories (not drawn; their
// children move up and they still define branch lanes).
const materialize = (p) => {
let n = byPath.get(p)
if (n) return n
const parent = materialize(p.slice(0, p.lastIndexOf('/')) || '/')
n = { path: p, depth: parent.depth + 1, title: '', content: false, ghost: true, children: [] }
byPath.set(p, n)
parent.children.push(n)
nodes.push(n)
return n
}
for (const p of extraPaths) {
const n = materialize(p)
if (n.ghost) n.content = true // traffic seen: show the pill
}
return { nodes, byPath, root: byPath.get('/') }
}
/** Sort each node's children by navigation order, recursively. */
function sortByNav(root, navOrder) {
const byNav = (a, b) =>
(navOrder.get(a.path) ?? Infinity) - (navOrder.get(b.path) ?? Infinity)
|| a.path.localeCompare(b.path)
const walk = (n) => {
n.children.sort(byNav)
n.children.forEach(walk)
// Real siblings keep menu order; ghosts follow them, alphabetical.
const sortChildren = (n) => {
n.children.sort((a, b) => (a.ghost - b.ghost) || (a.ghost ? a.path.localeCompare(b.path) : 0))
n.children.forEach(sortChildren)
}
walk(root)
sortChildren(root)
return { nodes, byPath, root }
}
/** Compute median reading time per article in seconds. */
@@ -283,7 +288,7 @@ function buildReadSeconds(visits) {
}
/** Compute view counts, labels and hidden flags for each node. */
function annotateNodes(nodes, viewsData, titles, readSeconds) {
function annotateNodes(nodes, viewsData, readSeconds) {
const viewCount = (p) => {
let n = 0
for (const c of Object.values(viewsData?.[p] || {})) n += c
@@ -294,12 +299,17 @@ function annotateNodes(nodes, viewsData, titles, readSeconds) {
n.views = viewCount(n.path)
n.readSec = readSeconds[n.path] || 0
// 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).
n.hidden = n.children.length > 0 && n.views === 0
// render), slug as fallback for ghost pages missing from the tree.
n.label = n.title || (n.path === '/' ? '🏠︎' : n.path.split('/').pop())
// Index pages without content of their own — category labels (404
// placeholders), empty pages and materialized ghost categories — are
// the nodes the site navigation itself bypasses in favor of the first
// child page, so they are omitted from the map too, their children
// moving up in their place (see layoutGroups). A childless empty page
// is a real navigation stop and stays, and every real page stays
// whether or not it had views in the window: the map always shows
// the full site structure.
n.hidden = !n.content && n.children.length > 0
}
}
@@ -953,15 +963,19 @@ export function buildTransitionGraph(data, pageTree, visits = [], dayScale = 1)
const internal = collectInternalTransitions(data?.transitions)
const sources = collectSourcePairs(visits)
const exits = collectExitPairs(data?.transitions)
const navOrder = buildNavigationOrder(pageTree)
const titles = buildTitleMap(pageTree)
const readSeconds = buildReadSeconds(visits)
if (!internal.length && !navOrder.size) return null
// Every internal path the traffic names, for ghost placement:
// transition endpoints, plus pages with views but no internal
// transition (entries from outside — a deleted page still visited).
const extraPaths = new Set()
for (const e of internal) { extraPaths.add(e.fr); extraPaths.add(e.to) }
for (const p of Object.keys(data?.views || {})) extraPaths.add(p)
const { nodes, byPath, root } = buildNodeTree(internal, navOrder)
sortByNav(root, navOrder)
annotateNodes(nodes, data?.views, titles, readSeconds)
if (!pageTree?.length && !extraPaths.size) return null
const { nodes, byPath, root } = buildNodeTree(pageTree, extraPaths)
annotateNodes(nodes, data?.views, readSeconds)
const { arcs, arcLeft } = layoutGroups(root)
const placed = nodes.filter((n) => !n.hidden)
const pairs = aggregatePairs(internal)
+8
View File
@@ -65,6 +65,10 @@ async def list_pages(lang: str | None = None) -> list[dict]:
Nested by slug; each node carries its full path, menu order, flags and
language settings (``language`` is the node's own primary-language
setting, "" = inherit; ``primary`` is the resolved effective one).
``has_content`` tells whether a landing page exists at all (even an
empty one — the structure editor offers deleting it); ``empty`` is the
navigation's notion: no content of its own (None or empty markdown),
so nav links bypass the node to its first child page.
With a ``?lang=`` translation, titles come out in that language where a
translation exists (``translated`` flags it — true trivially for rows
whose primary language IS the selected one; other rows fall back to
@@ -88,6 +92,10 @@ async def list_pages(lang: str | None = None) -> list[dict]:
"order": node.order,
"published": node.published,
"has_content": node.chunks is not None,
# No content of its own (None or empty markdown): the
# navigation bypasses the node, linking its first child
# page instead (views nav_html/first_leaf).
"empty": not node.chunks,
"language": node.language,
"primary": primary,
"children": dump(node.children, path, primary),