feat: visualize pages

scarlett
NGPixel 21 hours ago
parent 55e325d2a7
commit 50e615db9a
No known key found for this signature in database

@ -218,4 +218,77 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
}
}
})
/**
* TREE GRAPH - Every page of a locale, the folders above them and the links between them
*/
app.addSchema({
$id: 'TreeGraph',
type: 'object',
properties: {
locale: {
type: 'string'
},
pages: {
type: 'array',
description:
'Every page of the locale the requester may see, then the pages in OTHER locales that one of those links to. Links address pages by their index in this array.',
items: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
locale: { type: 'string' },
path: {
type: 'string',
description: 'Slash-separated, without a leading or trailing slash.'
},
title: { type: 'string' },
isPublished: {
type: 'boolean',
description:
'False for a page only its editors may see, which is only ever sent to somebody who may edit it.'
},
isRedirect: { type: 'boolean' }
}
}
},
folders: {
type: 'array',
description:
'The folders holding at least one of the pages, in the locale asked for. A path segment with no folder of its own is absent, and is named after the segment.',
items: {
type: 'object',
properties: {
path: { type: 'string' },
title: { type: 'string' },
hue: {
type: 'integer',
description: 'The folder colour, in degrees. Absent on a folder nobody has coloured.'
}
}
}
},
links: {
type: 'array',
description:
'One entry per pair of pages, however many ways the source reaches the target.',
items: {
type: 'object',
properties: {
source: { type: 'integer', description: 'Index into `pages`.' },
target: { type: 'integer', description: 'Index into `pages`.' },
kind: { type: 'string', enum: ['link', 'relation', 'redirect'] },
weight: {
type: 'integer',
description: 'How many distinct hrefs on the source resolve to the target.'
}
}
}
},
truncated: {
type: 'boolean',
description: 'True when the locale holds more pages or links than one graph carries.'
}
}
})
}

@ -267,6 +267,62 @@ async function routes(app: FastifyInstance) {
}
)
/**
* PAGE GRAPH
*/
app.get<{ Params: { siteId: string }; Querystring: { locale?: string } }>(
'/sites/:siteId/tree/graph',
{
/*
No route-level permissions: `read:pages` is a page rule, asked of every page below, and a
caller allowed nowhere gets an empty graph -- the same answer as the tree listing above.
*/
schema: {
summary: 'Get the page graph',
description:
"Every page of one locale the caller may read, the folders they sit in, and the links between them — what the file manager's graph view draws.\n\nA page that is not published is included only for a caller who may also edit it (`write:pages`), and is marked with `isPublished: false`. A link is included only when the caller may see both of its ends. Links reaching a page in another locale bring that page along, so the far end can be named.",
tags: ['Tree'],
params: siteIdParam,
querystring: {
type: 'object',
properties: {
locale: {
type: 'string',
maxLength: 10,
description: "The site's primary locale when absent."
}
}
},
response: {
200: { $ref: 'TreeGraph#' }
}
}
},
async (req) => {
const siteId = req.params.siteId
const actor = WIKI.models.groups.actorForRequest(req)
// -> A draft belongs to the people working on it, and an API key is answered as the public is
// -- the same line the page route draws, via `actorFrom`
const isSession = actorFrom(req) !== null
return WIKI.models.pageGraph.graphFor({
siteId,
locale: req.query.locale || defaultLocale(siteId),
visibility: (page) => {
const ref = { siteId, path: page.path, locale: page.locale, tags: page.tags }
if (!WIKI.models.groups.checkAccess(actor, 'read:pages', ref)) {
return null
}
if (page.publishState === 'published') {
return 'published'
}
return isSession && WIKI.models.groups.checkAccess(actor, 'write:pages', ref)
? 'draft'
: null
}
})
}
)
/**
* BROWSE THE TREE AS A READER
*/

@ -2651,6 +2651,42 @@
"fileman.folderTitleInvalidChars": "Invalid Characters in Folder Name",
"fileman.folderTitleMissing": "Missing Folder Title",
"fileman.gifFileType": "Animated GIF Image",
"fileman.graph.actionMore": "Show More",
"fileman.graph.actionOpen": "Open Page",
"fileman.graph.actionUp": "Zoom Out",
"fileman.graph.actionZoom": "Explore Section",
"fileman.graph.back": "Back to Files",
"fileman.graph.elsewhere": "Elsewhere in this wiki",
"fileman.graph.empty": "There are no pages to show in this locale.",
"fileman.graph.fit": "Fit to Screen",
"fileman.graph.hint": "Scroll to zoom, drag to pan. Click a page to open it, or a section to explore it.",
"fileman.graph.legendCollapsed": "Collapsed section",
"fileman.graph.legendIn": "Links in",
"fileman.graph.legendOut": "Links out",
"fileman.graph.legendPage": "Page",
"fileman.graph.legendRelation": "Relation",
"fileman.graph.legendSection": "Section",
"fileman.graph.linksIn": "No links in | 1 link in | {count} links in",
"fileman.graph.linksOut": "No links out | 1 link out | {count} links out",
"fileman.graph.loadFailed": "Failed to load the page graph.",
"fileman.graph.loading": "Loading pages...",
"fileman.graph.matches": "No matches | 1 match | {count} matches",
"fileman.graph.more": "{count} more | {count} more",
"fileman.graph.pageCount": "No pages | 1 page | {count} pages",
"fileman.graph.parentLevel": "Go to Parent Level",
"fileman.graph.redirect": "Redirect",
"fileman.graph.retry": "Try Again",
"fileman.graph.search": "Find in graph...",
"fileman.graph.site": "Site",
"fileman.graph.title": "Visualize Pages",
"fileman.graph.truncated": "Too many pages to show them all",
"fileman.graph.unpublished": "Unpublished",
"fileman.graph.viewRadial": "Hierarchical Radial",
"fileman.graph.viewRelational": "Relational Radial",
"fileman.graph.viewTree": "Hierarchical Tree",
"fileman.graph.views": "Graph layout",
"fileman.graph.zoomIn": "Zoom In",
"fileman.graph.zoomOut": "Zoom Out",
"fileman.gzFileType": "GZipped Archive",
"fileman.heicFileType": "HEIC Image",
"fileman.icoFileType": "Icon File",

@ -18,6 +18,7 @@ import { locales } from './locales.ts'
import { mail } from './mail.ts'
import { metrics } from './metrics.ts'
import { navigation } from './navigation.ts'
import { pageGraph } from './pageGraph.ts'
import { pageHistory } from './pageHistory.ts'
import { pageLinks } from './pageLinks.ts'
import { pageProblems } from './pageProblems.ts'
@ -59,6 +60,7 @@ export default {
mail,
metrics,
navigation,
pageGraph,
pageHistory,
pageLinks,
pageProblems,

@ -0,0 +1,398 @@
import { and, eq, inArray, sql, type SQL } from 'drizzle-orm'
import {
pageLinks as pageLinksTable,
pages as pagesTable,
tree as treeTable
} from '../db/schema.ts'
import { decodeTreePath } from '../helpers/common.ts'
/**
* Page graph model
*
* Every page of one locale, the folders they sit in, and what links them together — the data behind
* the file manager's graph view. One read of three tables, assembled here rather than in the client,
* because the client may not be told about a page it cannot read, and which pages those are is only
* known after the rows are in hand (see `visibility` below).
*
* Nothing new is stored. The hierarchy is the tree the file manager already browses, and the links
* are `pageLinks`, which is kept in step with every save: what a page's render links to, where a
* redirection points, and the page's relations.
*/
/** The editor whose content IS a link. See the same constant in `models/pageLinks.ts`. */
const REDIRECT_EDITOR = 'redirect'
/**
* How many pages one graph may carry, links' far ends in other locales included.
*
* A ceiling rather than a page size: the view collapses what it draws to what fits on a screen, so it
* needs the whole tree to decide what that is, and a second page of a graph means nothing. Far past
* any wiki anybody runs as one locale of one site — `truncated` says so if one ever gets there.
*/
export const MAX_GRAPH_PAGES = 50_000
/** The same for links, which on a heavily cross-linked wiki outnumber the pages several times over. */
export const MAX_GRAPH_LINKS = 250_000
/** What a link is, as far as the graph draws it. */
export type GraphLinkKind = 'link' | 'relation' | 'redirect'
/**
* How visible a page is to whoever is asking: `published` and `draft` are both drawn, the second
* dimmed, and null is not drawn at all.
*/
export type GraphVisibility = 'published' | 'draft' | null
/** The columns the caller's visibility decision is made on. */
export interface GraphPageRef {
locale: string
path: string
tags: string[]
publishState: string
}
export interface GraphPage {
id: string
locale: string
path: string
title: string
isPublished: boolean
isRedirect: boolean
}
export interface GraphFolder {
path: string
title: string
hue?: number
}
export interface GraphLink {
/** Index into `pages` of the page the link is written on. */
source: number
/** Index into `pages` of the page it resolves to. */
target: number
kind: GraphLinkKind
/** How many distinct hrefs on the source resolve to the target. */
weight: number
}
export interface PageGraph {
locale: string
pages: GraphPage[]
folders: GraphFolder[]
links: GraphLink[]
truncated: boolean
}
interface PageRow {
id: string
locale: string
path: string
title: string
tags: string[] | null
publishState: string
editor: string
alias: string | null
relations: unknown
}
const pageColumns = {
id: pagesTable.id,
locale: pagesTable.locale,
path: pagesTable.path,
title: pagesTable.title,
tags: pagesTable.tags,
publishState: pagesTable.publishState,
editor: pagesTable.editor,
alias: pagesTable.alias
}
/** Precedence when one page reaches another in more than one way: the strongest link is kept. */
const KIND_RANK: Record<GraphLinkKind, number> = { link: 0, relation: 1, redirect: 2 }
class PageGraphModel {
/**
* The graph of one locale of a site, as one requester may see it.
*
* @param visibility Decides, per page, whether it is drawn and how. Asked of the locale's own pages
* and of every page in another locale that one of them links to. A page it
* refuses is left out entirely, and so is every link touching it: a link to
* something the reader may not know about would say that it is there.
*/
async graphFor({
siteId,
locale,
visibility
}: {
siteId: string
locale: string
visibility: (page: GraphPageRef) => GraphVisibility
}): Promise<PageGraph> {
let truncated = false
// -> The locale's own pages. Relations are read here and nowhere else: they are what tells a
// relation apart from a link in the rows below, and only a source page's matter
const pageRows = (await WIKI.db
.select({ ...pageColumns, relations: pagesTable.relations })
.from(pagesTable)
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.locale, locale)))
.orderBy(pagesTable.path)
.limit(MAX_GRAPH_PAGES + 1)) as PageRow[]
if (pageRows.length > MAX_GRAPH_PAGES) {
pageRows.length = MAX_GRAPH_PAGES
truncated = true
}
const pages: GraphPage[] = []
const indexById = new Map<string, number>()
// -> Asked once per page: a hub every other page links to would otherwise be judged per link
const refused = new Set<string>()
const add = (row: PageRow): number | null => {
const known = indexById.get(row.id)
if (known !== undefined) {
return known
}
if (refused.has(row.id)) {
return null
}
if (pages.length >= MAX_GRAPH_PAGES) {
truncated = true
return null
}
const seen = visibility({
locale: row.locale,
path: row.path,
tags: row.tags ?? [],
publishState: row.publishState
})
if (!seen) {
refused.add(row.id)
return null
}
indexById.set(row.id, pages.length)
pages.push({
id: row.id,
locale: row.locale,
path: row.path,
title: row.title,
isPublished: seen === 'published',
isRedirect: row.editor === REDIRECT_EDITOR
})
return pages.length - 1
}
// -> Every row goes into the lookups, visible or not: a link resolving to a page the reader may
// not see has to be recognised as exactly that and dropped, not sent off to be looked for in
// another locale
const byPath = new Map<string, PageRow>()
const byAlias = new Map<string, PageRow>()
const byId = new Map<string, PageRow>()
const relationsOf = new Map<string, Set<string>>()
for (const row of pageRows) {
byPath.set(`${row.locale}/${row.path}`, row)
byId.set(row.id, row)
if (row.alias) {
byAlias.set(row.alias, row)
}
const targets = (Array.isArray(row.relations) ? row.relations : [])
.map((relation: any) => relation?.target)
.filter((target: unknown): target is string => typeof target === 'string')
if (targets.length > 0) {
relationsOf.set(row.id, new Set(targets))
}
add(row)
}
// -> Every link written on one of those pages that addresses a page of this site. Assets are not
// pages, and a link to another site is not something this graph can place
const linkRows = await WIKI.db
.select({
pageId: pageLinksTable.pageId,
kind: pageLinksTable.kind,
href: pageLinksTable.href,
targetLocale: pageLinksTable.targetLocale,
targetPath: pageLinksTable.targetPath,
targetRef: pageLinksTable.targetRef
})
.from(pageLinksTable)
.innerJoin(pagesTable, eq(pagesTable.id, pageLinksTable.pageId))
.where(
and(
eq(pagesTable.siteId, siteId),
eq(pagesTable.locale, locale),
eq(pageLinksTable.targetSiteId, siteId),
inArray(pageLinksTable.kind, ['page', 'alias', 'pageId'])
)
)
.limit(MAX_GRAPH_LINKS + 1)
if (linkRows.length > MAX_GRAPH_LINKS) {
linkRows.length = MAX_GRAPH_LINKS
truncated = true
}
const resolve = (link: (typeof linkRows)[number]): PageRow | undefined => {
switch (link.kind) {
case 'page':
return byPath.get(`${link.targetLocale}/${link.targetPath}`)
case 'alias':
return link.targetRef ? byAlias.get(link.targetRef) : undefined
case 'pageId':
return link.targetRef ? byId.get(link.targetRef) : undefined
}
return undefined
}
// -> What the locale's own pages could not answer: a page in another locale, or an alias or an id
// naming one. Looked up in one query, and only for what was actually linked to
const missing = {
paths: new Map<string, { locale: string; path: string }>(),
refs: new Set<string>()
}
for (const link of linkRows) {
if (resolve(link)) {
continue
}
if (
link.kind === 'page' &&
link.targetLocale &&
link.targetPath &&
link.targetLocale !== locale
) {
missing.paths.set(`${link.targetLocale}/${link.targetPath}`, {
locale: link.targetLocale,
path: link.targetPath
})
} else if (link.kind !== 'page' && link.targetRef) {
missing.refs.add(link.targetRef)
}
}
if (missing.paths.size > 0 || missing.refs.size > 0) {
const lookups: SQL[] = []
if (missing.paths.size > 0) {
const wanted = [...missing.paths.values()]
lookups.push(
sql`(${pagesTable.locale}, ${pagesTable.path}) IN (SELECT * FROM unnest(${sql.param(
wanted.map((w) => w.locale)
)}::text[], ${sql.param(wanted.map((w) => w.path))}::text[]))`
)
}
if (missing.refs.size > 0) {
const refs = [...missing.refs]
// -> Compared as text: a `/i/<id>` link carries whatever was written after the slash, and a
// uuid cast would fail the whole read on the first one that is not an id at all
lookups.push(sql`${pagesTable.id}::text = ANY(${sql.param(refs)}::text[])`)
lookups.push(sql`${pagesTable.alias} = ANY(${sql.param(refs)}::text[])`)
}
const others = (await WIKI.db
.select(pageColumns)
.from(pagesTable)
.where(
and(eq(pagesTable.siteId, siteId), sql`(${sql.join(lookups, sql` OR `)})`)
)) as PageRow[]
for (const row of others) {
byPath.set(`${row.locale}/${row.path}`, row)
byId.set(row.id, row)
if (row.alias && !byAlias.has(row.alias)) {
byAlias.set(row.alias, row)
}
}
}
// -> One edge per pair of pages, however many ways the source reaches the target
const edges = new Map<string, GraphLink>()
for (const link of linkRows) {
const source = indexById.get(link.pageId)
const targetRow = resolve(link)
if (source === undefined || !targetRow || targetRow.id === link.pageId) {
continue
}
const target = add(targetRow)
if (target === null) {
continue
}
const sourceRow = byId.get(link.pageId)!
const kind: GraphLinkKind =
sourceRow.editor === REDIRECT_EDITOR
? 'redirect'
: relationsOf.get(link.pageId)?.has(link.href)
? 'relation'
: 'link'
const key = `${source}:${target}`
const edge = edges.get(key)
if (edge) {
edge.weight++
if (KIND_RANK[kind] > KIND_RANK[edge.kind]) {
edge.kind = kind
}
} else {
edges.set(key, { source, target, kind, weight: 1 })
}
}
return {
locale,
pages,
folders: await this.foldersAbove(siteId, locale, pages),
links: [...edges.values()],
truncated
}
}
/**
* The folders the drawn pages of this locale sit in, and no others.
*
* Derived from the pages rather than listed, so a folder holding nothing this reader may see is not
* in the graph at all — the file manager's own tree cannot afford that question, but here every page
* has already been decided. A path with no folder row above a page (a page created at a deep path
* before its folders existed) still gets drawn by the client, named after its segment.
*/
private async foldersAbove(
siteId: string,
locale: string,
pages: GraphPage[]
): Promise<GraphFolder[]> {
const wanted = new Set<string>()
for (const page of pages) {
if (page.locale !== locale) {
continue
}
const parts = page.path.split('/')
for (let i = 1; i < parts.length; i++) {
wanted.add(parts.slice(0, i).join('/'))
}
}
if (wanted.size < 1) {
return []
}
const rows = await WIKI.db
.select({
folderPath: treeTable.folderPath,
fileName: treeTable.fileName,
title: treeTable.title,
meta: treeTable.meta
})
.from(treeTable)
.where(
and(
eq(treeTable.siteId, siteId),
eq(treeTable.locale, locale),
eq(treeTable.type, 'folder')
)
)
const folders: GraphFolder[] = []
for (const row of rows) {
const parent = decodeTreePath(row.folderPath ?? '') ?? ''
const path = parent ? `${parent}/${row.fileName}` : row.fileName
if (!wanted.has(path)) {
continue
}
const hue = (row.meta as { hue?: number } | null)?.hue
folders.push({ path, title: row.title, ...(hue ? { hue } : {}) })
}
return folders.sort((a, b) => a.path.localeCompare(b.path))
}
}
export const pageGraph = new PageGraphModel()

@ -18,6 +18,12 @@
"@zip.js/zip.js": "2.17.0",
"browser-fs-access": "0.38.0",
"clipboard": "2.0.11",
"d3-ease": "3.0.1",
"d3-hierarchy": "3.1.2",
"d3-selection": "3.0.0",
"d3-shape": "3.2.0",
"d3-transition": "3.0.1",
"d3-zoom": "3.0.0",
"es-toolkit": "1.50.0",
"filesize": "11.0.22",
"filesize-parser": "1.5.2",

@ -27,6 +27,12 @@
"@zip.js/zip.js": "2.17.0",
"browser-fs-access": "0.38.0",
"clipboard": "2.0.11",
"d3-ease": "3.0.1",
"d3-hierarchy": "3.1.2",
"d3-selection": "3.0.0",
"d3-shape": "3.2.0",
"d3-transition": "3.0.1",
"d3-zoom": "3.0.0",
"es-toolkit": "1.50.0",
"filesize": "11.0.22",
"filesize-parser": "1.5.2",

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
285 icons.
290 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
@ -96,6 +96,7 @@ export const BUNDLED_ICONS = {
"la:landmark": {"body":"<path fill=\"currentColor\" d=\"M16 3.906L3.625 9.062L3 9.345V12h2v11H3v5h26v-5h-2V12h2V9.344l-.625-.281zm0 2.188L25.375 10H6.625zM7 12h2v11H7zm4 0h2v11h-2zm4 0h2v11h-2zm4 0h2v11h-2zm4 0h2v11h-2zM5 25h22v1H5z\"/>","width":32,"height":32},
"la:language": {"body":"<path fill=\"currentColor\" d=\"M4 4v18h6v6h18V10h-6V4zm2 2h14v4.563L10.562 20H6zm5 2v1H8v2h4.938c-.13 1.148-.481 2.055-1.063 2.688a4.5 4.5 0 0 1-.906-.407C10.266 12.863 10 12.418 10 12H8c0 1.191.734 2.184 1.719 2.844A8.3 8.3 0 0 1 8 15v2c1.773 0 3.25-.406 4.375-1.156c.523.09 1.055.156 1.625.156v-1.875c.543-.91.832-1.973.938-3.125H16V9h-3V8zm10.438 4H26v14H12v-4.563zM20 13.844l-.938 2.844l-2 6l-.062.156V24h2v-.875l.031-.125h1.938l.031.125V24h2v-1.156l-.063-.157l-2-6zm0 6.281l.281.875h-.562z\"/>","width":32,"height":32},
"la:leaf": {"body":"<path fill=\"currentColor\" d=\"M25.031 4L24 4.469c-3.18 1.402-7.773 1.93-11.688 3.312c-1.957.692-3.769 1.614-5.124 3.094C5.832 12.355 5 14.395 5 17c0 2.695 1.586 4.543 3.063 5.594a10 10 0 0 0 1.75 1c-.618 1.144-1.075 2.176-1.376 3.062c-.546 1.598-.632 2.848-.593 3.75s.156 1.52.156 1.5l2 .188c.063-.723-.125-1.102-.156-1.782s.027-1.613.5-3c.945-2.769 3.566-7.328 10.375-14.625l-1.438-1.374c-4.082 4.374-6.718 7.785-8.437 10.53a7.7 7.7 0 0 1-1.625-.874C8.059 20.145 7 18.937 7 17c0-2.203.629-3.656 1.656-4.781c1.028-1.125 2.524-1.93 4.313-2.563c3.238-1.144 7.246-1.722 10.687-3C23.976 7.871 25 12.031 25 18.531c0 3.688-.863 5.801-1.875 6.969s-2.23 1.5-3.313 1.5c-1.078 0-2.242-.55-3.25-1.25c-1.007-.7-1.703-1.43-2.406-1.875l-1.062 1.688c.18.113 1.176 1.035 2.344 1.843c1.167.809 2.648 1.594 4.374 1.594c1.54 0 3.422-.543 4.844-2.188C26.078 25.169 27 22.527 27 18.532c0-7.821-1.656-13.438-1.656-13.438z\"/>","width":32,"height":32},
"la:level-up-alt": {"body":"<path fill=\"currentColor\" d=\"m17 4l-.719.688l-8.5 8.5l1.438 1.439L16 7.844V26H6v2h12V7.844l6.781 6.781l1.438-1.438l-8.5-8.5z\"/>","width":32,"height":32},
"la:life-ring": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2c.336 0 .672.031 1 .063v3.03A7 7 0 0 0 16 9c-.34 0-.672.047-1 .094V6.063A11 11 0 0 1 16 6m-3 .438v3.25A7 7 0 0 0 9.687 13H6.47A9.98 9.98 0 0 1 13 6.437zm6 0A10.07 10.07 0 0 1 25.563 13h-3.25A7 7 0 0 0 19 9.687zM16 11c2.773 0 5 2.227 5 5s-2.227 5-5 5s-5-2.227-5-5s2.227-5 5-5m-9.938 4h3A7 7 0 0 0 9 16c0 .34.047.672.094 1H6.063A11 11 0 0 1 6 16c0-.336.031-.672.063-1zm16.844 0h3.032c.03.328.062.664.062 1s-.031.672-.063 1h-3.03c.046-.328.093-.66.093-1s-.047-.672-.094-1M6.438 19h3.25A7 7 0 0 0 13 22.313v3.25A10.07 10.07 0 0 1 6.437 19zm15.875 0h3.25A10.07 10.07 0 0 1 19 25.563v-3.25A7 7 0 0 0 22.313 19M15 22.906c.328.047.66.094 1 .094s.672-.047 1-.094v3.032A11 11 0 0 1 16 26c-.336 0-.672-.031-1-.063z\"/>","width":32,"height":32},
"la:link": {"body":"<path fill=\"currentColor\" d=\"M21.75 4c-1.672 0-3.258.66-4.438 1.844l-1.468 1.468A6.25 6.25 0 0 0 14 11.75c0 .793.152 1.566.438 2.281l1.624-1.625A4.29 4.29 0 0 1 17.25 8.72l1.469-1.469A4.28 4.28 0 0 1 21.75 6c1.137 0 2.195.445 3 1.25a4.274 4.274 0 0 1 0 6.031l-1.469 1.469A4.28 4.28 0 0 1 20.25 16q-.334-.002-.656-.063l-1.625 1.626A6.2 6.2 0 0 0 20.25 18c1.672 0 3.258-.66 4.438-1.844l1.468-1.468A6.25 6.25 0 0 0 28 10.25c0-1.672-.66-3.223-1.844-4.406A6.17 6.17 0 0 0 21.75 4m-2.469 7.281l-8 8l1.438 1.438l8-8zM11.75 14c-1.672 0-3.258.66-4.438 1.844l-1.468 1.469A6.25 6.25 0 0 0 4 21.75c0 1.672.66 3.223 1.844 4.406A6.17 6.17 0 0 0 10.25 28c1.672 0 3.258-.66 4.438-1.844l1.468-1.468A6.25 6.25 0 0 0 18 20.25c0-.793-.152-1.566-.438-2.281l-1.625 1.625a4.29 4.29 0 0 1-1.187 3.687l-1.469 1.469A4.28 4.28 0 0 1 10.25 26a4.2 4.2 0 0 1-3-1.25a4.274 4.274 0 0 1 0-6.031l1.469-1.469A4.28 4.28 0 0 1 11.75 16q.334.002.656.063l1.625-1.625A6.2 6.2 0 0 0 11.75 14\"/>","width":32,"height":32},
"la:list": {"body":"<path fill=\"currentColor\" d=\"M4 5v6h6V5zm2 2h2v2H6zm6 0v2h15V7zm-8 6v6h6v-6zm2 2h2v2H6zm6 0v2h15v-2zm-8 6v6h6v-6zm2 2h2v2H6zm6 0v2h15v-2z\"/>","width":32,"height":32},
@ -214,6 +215,8 @@ export const BUNDLED_ICONS = {
"mdi:file-document-outline": {"body":"<path fill=\"currentColor\" d=\"M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm0 2h7v5h5v11H6zm2 8v2h8v-2zm0 4v2h5v-2z\"/>","width":24,"height":24},
"mdi:file-search-outline": {"body":"<path fill=\"currentColor\" d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h7c-.41-.25-.8-.56-1.14-.9c-.33-.33-.61-.7-.86-1.1H6V4h7v5h5v1.18c.71.16 1.39.43 2 .82V8zm6.31 16.9c1.33-2.11.69-4.9-1.4-6.22c-2.11-1.33-4.91-.68-6.22 1.4c-1.34 2.11-.69 4.89 1.4 6.22c1.46.93 3.32.93 4.79.02L22 23.39L23.39 22zm-3.81.1a2.5 2.5 0 0 1-2.5-2.5a2.5 2.5 0 0 1 2.5-2.5a2.5 2.5 0 0 1 2.5 2.5a2.5 2.5 0 0 1-2.5 2.5\"/>","width":24,"height":24},
"mdi:file-tree": {"body":"<path fill=\"currentColor\" d=\"M3 3h6v4H3zm12 7h6v4h-6zm0 7h6v4h-6zm-2-4H7v5h6v2H5V9h2v2h6z\"/>","width":24,"height":24},
"mdi:file-tree-outline": {"body":"<path fill=\"currentColor\" d=\"M12 13H7v5h5v2H5V10h2v1h5zM8 4v2H4V4zm2-2H2v6h8zm10 9v2h-4v-2zm2-2h-8v6h8zm-2 9v2h-4v-2zm2-2h-8v6h8z\"/>","width":24,"height":24},
"mdi:fit-to-screen-outline": {"body":"<path fill=\"currentColor\" d=\"M17 4h3c1.1 0 2 .9 2 2v2h-2V6h-3zM4 8V6h3V4H4c-1.1 0-2 .9-2 2v2zm16 8v2h-3v2h3c1.1 0 2-.9 2-2v-2zM7 18H4v-2H2v2c0 1.1.9 2 2 2h3zm9-8v4H8v-4zm2-2H6v8h12z\"/>","width":24,"height":24},
"mdi:flag-outline": {"body":"<path fill=\"currentColor\" d=\"m12.36 6l.4 2H18v6h-3.36l-.4-2H7V6zM14 4H5v17h2v-7h5.6l.4 2h7V6h-5.6\"/>","width":24,"height":24},
"mdi:food-apple-outline": {"body":"<path fill=\"currentColor\" d=\"M20 10a5.27 5.27 0 0 0-7-2V3h-2v5a5.27 5.27 0 0 0-7 2c-2 3 3 12 5 12s2-1 3-1s1 1 3 1s7-9 5-12m-1.75 3.38c-.62 2.47-1.84 4.74-3.55 6.62c-.2 0-.43-.1-.6-.25a3.34 3.34 0 0 0-4.2 0c-.17.15-.4.25-.6.25a15.3 15.3 0 0 1-3.55-6.61c-.25-.73-.3-1.52-.09-2.27A3.37 3.37 0 0 1 8.5 9.4c.56.01 1.11.14 1.61.39l.89.45h2l.89-.45c.5-.25 1.05-.38 1.61-.39c1.18.03 2.26.68 2.84 1.71c.21.75.16 1.54-.09 2.27M11 5C5.38 8.07 4.11 3.78 4.11 3.78S6.77.19 11 5\"/>","width":24,"height":24},
"mdi:format-align-center": {"body":"<path fill=\"currentColor\" d=\"M3 3h18v2H3zm4 4h10v2H7zm-4 4h18v2H3zm4 4h10v2H7zm-4 4h18v2H3z\"/>","width":24,"height":24},
@ -241,6 +244,7 @@ export const BUNDLED_ICONS = {
"mdi:format-subscript": {"body":"<path fill=\"currentColor\" d=\"M16 7.41L11.41 12L16 16.59L14.59 18L10 13.41L5.41 18L4 16.59L8.59 12L4 7.41L5.41 6L10 10.59L14.59 6zm5.85 13.62h-4.88v-1l.89-.8c.76-.65 1.32-1.19 1.7-1.63c.37-.44.56-.85.57-1.24a.9.9 0 0 0-.27-.7c-.18-.16-.47-.28-.86-.28c-.31 0-.58.06-.84.18l-.66.38l-.45-1.17c.27-.21.59-.39.98-.53s.82-.24 1.29-.24c.78.04 1.38.25 1.78.66s.62.93.62 1.57c-.01.56-.19 1.08-.54 1.55c-.34.47-.76.92-1.27 1.36l-.64.52v.02h2.58z\"/>","width":24,"height":24},
"mdi:format-superscript": {"body":"<path fill=\"currentColor\" d=\"M16 7.41L11.41 12L16 16.59L14.59 18L10 13.41L5.41 18L4 16.59L8.59 12L4 7.41L5.41 6L10 10.59L14.59 6zM21.85 9h-4.88V8l.89-.82c.76-.64 1.32-1.18 1.7-1.63q.555-.66.57-1.23a.88.88 0 0 0-.27-.7c-.18-.19-.47-.28-.86-.29c-.31.01-.58.07-.84.17l-.66.39l-.45-1.17c.27-.22.59-.39.98-.53S18.85 2 19.32 2c.78 0 1.38.2 1.78.61c.4.39.62.93.62 1.57c-.01.56-.19 1.08-.54 1.55c-.34.48-.76.93-1.27 1.36l-.64.52v.02h2.58z\"/>","width":24,"height":24},
"mdi:format-underline": {"body":"<path fill=\"currentColor\" d=\"M5 21h14v-2H5zm7-4a6 6 0 0 0 6-6V3h-2.5v8a3.5 3.5 0 0 1-3.5 3.5A3.5 3.5 0 0 1 8.5 11V3H6v8a6 6 0 0 0 6 6\"/>","width":24,"height":24},
"mdi:graph-outline": {"body":"<path fill=\"currentColor\" d=\"M19.5 17c-.14 0-.26 0-.39.04L17.5 13.8c.45-.45.75-1.09.75-1.8a2.5 2.5 0 0 0-2.5-2.5c-.14 0-.25 0-.4.04L13.74 6.3c.47-.46.76-1.09.76-1.8a2.5 2.5 0 0 0-5 0c0 .7.29 1.34.76 1.79L8.65 9.54c-.15-.04-.26-.04-.4-.04a2.5 2.5 0 0 0-2.5 2.5c0 .71.29 1.34.75 1.79l-1.61 3.25C4.76 17 4.64 17 4.5 17a2.5 2.5 0 0 0 0 5A2.5 2.5 0 0 0 7 19.5c0-.7-.29-1.34-.76-1.79l1.62-3.25c.14.04.26.04.39.04s.25 0 .38-.04l1.63 3.25c-.47.45-.76 1.09-.76 1.79a2.5 2.5 0 0 0 5 0A2.5 2.5 0 0 0 12 17c-.13 0-.26 0-.39.04L10 13.8c.45-.45.75-1.09.75-1.8c0-.7-.29-1.33-.75-1.79l1.61-3.25c.13.04.26.04.39.04s.26 0 .39-.04L14 10.21a2.5 2.5 0 0 0 1.75 4.29c.13 0 .25 0 .38-.04l1.63 3.25c-.47.45-.76 1.09-.76 1.79a2.5 2.5 0 0 0 5 0a2.5 2.5 0 0 0-2.5-2.5m-15 3.5c-.55 0-1-.45-1-1s.45-1 1-1s1 .45 1 1s-.45 1-1 1m8.5-1c0 .55-.45 1-1 1s-1-.45-1-1s.45-1 1-1s1 .45 1 1M7.25 12c0-.55.45-1 1-1s1 .45 1 1s-.45 1-1 1s-1-.45-1-1M11 4.5c0-.55.45-1 1-1s1 .45 1 1s-.45 1-1 1s-1-.45-1-1m3.75 7.5c0-.55.45-1 1-1s1 .45 1 1s-.45 1-1 1s-1-.45-1-1m4.75 8.5c-.55 0-1-.45-1-1s.45-1 1-1s1 .45 1 1s-.45 1-1 1\"/>","width":24,"height":24},
"mdi:hand-wave-outline": {"body":"<path fill=\"currentColor\" d=\"M7.03 4.95L3.5 8.5c-3.33 3.31-3.33 8.69 0 12s8.69 3.33 12 0l6-6c1-.97 1-2.56 0-3.54c-.1-.12-.23-.23-.37-.32l.37-.39c1-.97 1-2.56 0-3.54c-.14-.16-.33-.3-.5-.41c.38-.92.21-2.02-.54-2.77c-.87-.87-2.22-.96-3.2-.28a2.517 2.517 0 0 0-3.88-.42l-2.51 2.51c-.09-.14-.2-.27-.32-.39a2.53 2.53 0 0 0-3.52 0m1.41 1.42c.2-.2.51-.2.71 0s.2.51 0 .71l-3.18 3.18a3 3 0 0 1 0 4.24l1.41 1.41a5 5 0 0 0 1.12-5.36l6.3-6.3c.2-.2.51-.2.7 0s.21.51 0 .71l-4.59 4.6l1.41 1.41l6.01-6.01c.2-.2.51-.2.71 0s.2.51 0 .71l-6.01 6.01l1.41 1.41l4.95-4.95c.2-.2.51-.2.71 0s.2.51 0 .71l-5.66 5.65l1.41 1.42l3.54-3.54c.2-.2.51-.2.71 0s.2.51 0 .71l-6 6.01c-2.54 2.54-6.65 2.54-9.19 0s-2.54-6.65 0-9.19zM23 17c0 3.31-2.69 6-6 6v-1.5c2.5 0 4.5-2 4.5-4.5zM1 7c0-3.31 2.69-6 6-6v1.5c-2.5 0-4.5 2-4.5 4.5z\"/>","width":24,"height":24},
"mdi:highlight-off": {"body":"<path fill=\"currentColor\" d=\"M12 20c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m0-18C6.47 2 2 6.47 2 12s4.47 10 10 10s10-4.47 10-10S17.53 2 12 2m2.59 6L12 10.59L9.41 8L8 9.41L10.59 12L8 14.59L9.41 16L12 13.41L14.59 16L16 14.59L13.41 12L16 9.41z\"/>","width":24,"height":24},
"mdi:home": {"body":"<path fill=\"currentColor\" d=\"M10 20v-6h4v6h5v-8h3L12 3L2 12h3v8z\"/>","width":24,"height":24},
@ -265,6 +269,7 @@ export const BUNDLED_ICONS = {
"mdi:play": {"body":"<path fill=\"currentColor\" d=\"M8 5.14v14l11-7z\"/>","width":24,"height":24},
"mdi:playlist-edit": {"body":"<path fill=\"currentColor\" d=\"M3 6v2h11V6zm0 4v2h11v-2zm17 .1c-.1 0-.3.1-.4.2l-1 1l2.1 2.1l1-1c.2-.2.2-.6 0-.8l-1.3-1.3c-.1-.1-.2-.2-.4-.2m-1.9 1.8l-6.1 6V20h2.1l6.1-6.1zM3 14v2h7v-2z\"/>","width":24,"height":24},
"mdi:power": {"body":"<path fill=\"currentColor\" d=\"m16.56 5.44l-1.45 1.45A5.97 5.97 0 0 1 18 12a6 6 0 0 1-6 6a6 6 0 0 1-6-6c0-2.17 1.16-4.06 2.88-5.12L7.44 5.44A7.96 7.96 0 0 0 4 12a8 8 0 0 0 8 8a8 8 0 0 0 8-8c0-2.72-1.36-5.12-3.44-6.56M13 3h-2v10h2\"/>","width":24,"height":24},
"mdi:radar": {"body":"<path fill=\"currentColor\" d=\"m19.07 4.93l-1.41 1.41A8 8 0 0 1 20 12a8 8 0 0 1-8 8a8 8 0 0 1-8-8c0-4.08 3.05-7.44 7-7.93v2.02C8.16 6.57 6 9.03 6 12a6 6 0 0 0 6 6a6 6 0 0 0 6-6c0-1.66-.67-3.16-1.76-4.24l-1.41 1.41C15.55 9.9 16 10.9 16 12a4 4 0 0 1-4 4a4 4 0 0 1-4-4c0-1.86 1.28-3.41 3-3.86v2.14c-.6.35-1 .98-1 1.72a2 2 0 0 0 2 2a2 2 0 0 0 2-2c0-.74-.4-1.38-1-1.72V2h-1A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10c0-2.76-1.12-5.26-2.93-7.07\"/>","width":24,"height":24},
"mdi:redo-variant": {"body":"<path fill=\"currentColor\" d=\"M10.5 7A6.5 6.5 0 0 0 4 13.5a6.5 6.5 0 0 0 6.5 6.5H14v-2h-3.5C8 18 6 16 6 13.5S8 9 10.5 9h5.67l-3.08 3.09l1.41 1.41L20 8l-5.5-5.5l-1.42 1.41L16.17 7zM18 18h-2v2h2z\"/>","width":24,"height":24},
"mdi:seed-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M17.2 5c.6 0 1.2 0 1.7.1c.14 1.6.18 4.32-.72 6.9c.71 0 1.38.17 2 .41c1.46-4.51.52-9.11.52-9.11S19.3 3 17.2 3c-5.5 0-15.6 2.1-14 17.8c1.1.1 2.2.2 3.2.2c2.35 0 4.34-.31 6-.84c-.24-.62-.4-1.29-.4-1.99c-1.59.55-3.47.83-5.6.83H5.1c-.2-4.6.7-8.2 2.8-10.5C10.4 5.6 14.4 5 17.2 5M17 7C7 7 7 17 7 17C11 9 17 7 17 7m0 10h-3v2h3v3h2v-3h3v-2h-3v-3h-2z\"/>","width":24,"height":24},
"mdi:sort-alphabetical-descending-variant": {"body":"<path fill=\"currentColor\" d=\"m15.75 19l-3.25 3.25L9.25 19zm-6.86-4.7H6L5.28 17H2.91L6 7h3l3.13 10H9.67zm-2.56-1.62h2.23l-.63-2.12l-.26-.97l-.25-.96h-.03l-.22.97l-.24.98zM13.05 17v-1.26l4.75-6.77v-.06h-4.3V7h7.23v1.34L16.09 15v.08h4.71V17z\"/>","width":24,"height":24},

@ -42,8 +42,8 @@
v-model="state.search"
type="text"
class="fileman-search-input"
:placeholder="t(`fileman.searchFolder`)"
:aria-label="t(`fileman.searchFolder`)"
:placeholder="searchPlaceholder"
:aria-label="searchPlaceholder"
autocomplete="off"
@focus="state.searchIsFocused = true"
@blur="state.searchIsFocused = false" />
@ -100,7 +100,11 @@
Narrower while it overlays, so there is a comfortable width of scrim left to tap on.
-->
<w-drawer class="fileman-left" v-model="treeDrawerOpen" :width="isTreeOverlay ? 300 : 350">
<w-drawer
class="fileman-left"
v-model="treeDrawerOpen"
:width="isTreeOverlay ? 300 : 350"
:inert="state.isGraph">
<div class="flex h-full flex-col">
<w-scroll-area
class="min-h-0 flex-1"
@ -129,6 +133,21 @@
:display-mode="state.displayMode" />
</div>
</w-scroll-area>
<!--
The graph view, pinned with the Recycle Bin for the same reason: it is a way of looking at the
whole locale rather than a place in it. Not while inserting -- the editor sent the reader here
to pick a file, and the graph has no file to pick.
-->
<button
v-if="!insertMode"
type="button"
class="fileman-bin w-unstyled"
:class="{ 'is-active': state.isGraph }"
:aria-pressed="state.isGraph"
@click="openGraph">
<w-icon name="la:project-diagram" size="sm" />
<span class="fileman-bin-label">{{ t('fileman.graph.title') }}</span>
</button>
<!--
Pinned under the tree rather than a node in it: it is not a place in the wiki, and it has to
stay reachable however far the tree has been scrolled or expanded. Signed in only -- the
@ -148,7 +167,12 @@
</button>
</div>
</w-drawer>
<w-drawer class="fileman-right" :model-value="detailsPaneShown" :width="350" side="right">
<w-drawer
class="fileman-right"
:model-value="detailsPaneShown"
:width="350"
side="right"
:inert="state.isGraph">
<div class="flex h-full flex-col">
<w-scroll-area
class="min-h-0 flex-1"
@ -229,7 +253,7 @@
</div>
</div>
</w-drawer>
<w-page-container>
<w-page-container :inert="state.isGraph">
<!--
Tapping this pane puts the tree panel away, which is the "tap outside to dismiss" the drawer's own
scrim would normally provide. It cannot here: `WDrawer` teleports that scrim to <body> at z-30, and
@ -727,7 +751,7 @@
</div>
</w-page>
</w-page-container>
<w-footer>
<w-footer :inert="state.isGraph">
<w-bar class="fileman-path">
<!--
-> `flex-1` on the path is what puts the counts at the far end: `ms-auto` cannot, because
@ -746,6 +770,18 @@
}}</small>
</w-bar>
</w-footer>
<!--
Over everything below the header, rather than in place of the list: the tree, the listing and the
details stay mounted underneath, so going back to them is instant and finds them as they were.
What it covers is `inert` meanwhile, so the keyboard cannot wander into it.
-->
<page-graph
v-if="state.isGraph"
class="fileman-graph"
:locale="state.locale"
:search="state.search"
@close="closeGraph"
@open="openGraphPage" />
<input type="file" ref="fileIpt" multiple @change="uploadNewFiles" style="display: none" />
</w-layout>
</template>
@ -792,6 +828,10 @@ import FolderDeleteDialog from '@/components/FolderDeleteDialog.vue'
import FolderRenameDialog from '@/components/FolderRenameDialog.vue'
import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue'
// -> d3 and everything else the graph draws with is fetched the first time it is opened, not with the
// file manager
const PageGraph = defineAsyncComponent(() => import('@/components/PageGraph.vue'))
// COMPOSABLES
const dark = useDark()
@ -900,7 +940,12 @@ const state = reactive({
/** The deleted pages this reader may recover, as `GET …/pages/deleted` answered, newest first. */
binItems: [],
/** Keyed by the deletion's version id, which is what every action on a bin entry works from. */
binSelectedId: null
binSelectedId: null,
/**
* Whether the graph view covers the manager. Independent of `isRecycleBin`: it goes over whichever
* of the two was showing, and closing it comes back to that.
*/
isGraph: false
})
// -> Over the defaults just above, which is what the view falls back to on a first visit
@ -991,6 +1036,11 @@ const folderPath = computed(() => {
const usePathTitle = computed(() => state.displayMode === 'path')
/** The search field finds nodes in the graph while it is open, and filters the folder otherwise. */
const searchPlaceholder = computed(() =>
state.isGraph ? t('fileman.graph.search') : t('fileman.searchFolder')
)
const filteredFiles = computed(() => {
if (state.search) {
const fuse = new Fuse(state.fileList, {
@ -1259,6 +1309,27 @@ function openRecycleBin() {
loadRecycleBin()
}
/*
The search is cleared going either way: what was typed to filter a folder means nothing to the
graph, and a graph search left behind would silently filter the folder it returns to.
*/
function openGraph() {
state.treeOpen = false
state.search = ''
state.isGraph = true
}
function closeGraph() {
state.search = ''
state.isGraph = false
}
/** A page picked in the graph: the same departure as opening one from the list. */
function openGraphPage({ locale, path }) {
router.push(`${siteStore.localeUrlPrefix(locale)}/${path}`)
close()
}
async function loadRecycleBin() {
state.binLoading = true
try {
@ -2596,6 +2667,17 @@ $fileman-bottom-row-height: 45px;
}
}
/*
Placed by grid LINE rather than by area, so it spans the drawers, the page and the footer at once;
see `WLayout` for the grid. Above the tree drawer, which is its own stacking context.
*/
&-graph {
grid-row: 2 / 4;
grid-column: 1 / 4;
z-index: 5;
min-width: 0;
}
&-toolbar-title {
display: flex;
align-items: center;

File diff suppressed because it is too large Load Diff

@ -0,0 +1,962 @@
import { easeCubicInOut, easeCubicOut } from 'd3-ease'
import { select } from 'd3-selection'
import 'd3-transition'
import { zoom, zoomIdentity } from 'd3-zoom'
import { searchModel } from './model.js'
import { buildScene } from './scene.js'
/**
* The page graph's renderer: one SVG, any view.
*
* Plain JavaScript over d3 rather than a Vue component, because what it draws is up to a few thousand
* elements moved every frame of an animation, and a virtual DOM diffing them sixty times a second
* would be the whole cost of the view. `PageGraph.vue` owns the chrome around it -- the toolbar, the
* hover card, the breadcrumbs -- and talks to this through the object `createGraph` returns and the
* callbacks it is given.
*
* What happens on each redraw, whichever view it is:
*
* - **Nodes move, they are not redrawn.** Every node is keyed by its scene id, so one that is in
* both the old drawing and the new one travels from where it was. One that is new grows out of
* the nearest ancestor that was already there -- which on the first draw is the root, so the tree
* visibly branches outward, a level at a time.
* - **Edges follow their nodes** while a layout of the same view changes, recomputed every frame from
* where both ends are at that moment. Between two views they have no common form, so they fade,
* the nodes glide, and they grow back in from each parent.
* - **Links draw in last**, once everything has settled, swept round in the order they start.
*
* Reduced motion -- the operating system's or the reader's own setting -- skips every one of those to
* its last frame. Nothing about what is drawn depends on having watched it arrive.
*/
/** How long nodes take to reach a new layout, and how much later each level of a first draw starts. */
const MOVE_DURATION = 700
const LEVEL_STAGGER = 160
/** Edges growing back in after a change of view. */
const GROW_DURATION = 420
const GROW_STAGGER = 70
/** The longest a link waits for its turn to draw in; see `linkPhase`. */
const LINK_SWEEP = 650
const LINK_DRAW = 900
/** Nothing is drawn smaller than this on screen, however far out the view is zoomed. */
const SCALE_EXTENT = [0.02, 6]
let instanceCount = 0
/**
* @param {HTMLElement} container Filled by the SVG, and measured for fitting.
* @param {object} options
* @param {object} options.labels Strings the engine draws: `graph`, `elsewhere`, `more(n)`, `locale(code)`.
* @param {Function} options.reduceMotion Asked before every animation, so the setting applies at once.
* @param {Function} options.onOpen Called with `{ locale, path }` for a page the reader chose.
* @param {Function} options.onChange Called with the trail, the view and the counts whenever they change.
* @param {Function} options.onHover Called with what is under the pointer or keyboard, or null.
*/
export function createGraph(container, options) {
const uid = ++instanceCount
const { labels } = options
const state = {
model: null,
view: null,
focus: null,
scene: null,
layout: null,
/** Folders whose "more" has been opened, and how many of their entries that shows. */
allowance: new Map(),
matches: new Set(),
containsMatch: new Set(),
/** Where each drawn node is -- in its view's own terms, and as a point -- and which view that is. */
current: new Map(),
currentView: null,
animation: null,
linkTimer: null,
transform: zoomIdentity,
activeId: null,
highlightId: null,
/** Short DOM ids for `aria-activedescendant`, since a scene id is a path. */
domIds: new Map()
}
// SVG ------------------------------------------------------------------
const svg = select(container)
.append('svg')
.attr('class', 'pg-svg')
.attr('role', 'tree')
.attr('tabindex', 0)
.attr('aria-label', labels.graph)
const viewport = svg.append('g').attr('class', 'pg-viewport')
const edgesLayer = viewport.append('g').attr('class', 'pg-edges')
const linksLayer = viewport.append('g').attr('class', 'pg-links')
const nodesLayer = viewport.append('g').attr('class', 'pg-nodes')
const zoomer = zoom()
.scaleExtent(SCALE_EXTENT)
.on('zoom', (ev) => {
state.transform = ev.transform
viewport.attr('transform', ev.transform)
applyZoomClasses()
// -> A card left where a node used to be is worse than none; only for a gesture, since a fit
// the engine runs itself is not the reader moving away
if (ev.sourceEvent) {
options.onHover(null)
}
})
svg.call(zoomer).on('dblclick.zoom', null)
function applyZoomClasses() {
const px = state.transform.k * (state.view?.fontSize ?? 11)
svg.classed('is-far', px < 7).classed('is-farther', px < 3.5)
}
// SCENE ----------------------------------------------------------------
function rebuild({ fit = true } = {}) {
const view = state.view
state.scene = buildScene(state.model, {
focus: state.focus,
budget: view.budget,
maxChildren: view.maxChildren,
allowance: state.allowance,
withLinks: view.links,
labels
})
state.layout = view.layout(state.scene)
svg.attr('class', `pg-svg is-${view.key}`)
applyZoomClasses()
if (state.activeId && !state.scene.nodesById.has(state.activeId)) {
state.activeId = null
}
if (state.highlightId && !state.scene.nodesById.has(state.highlightId)) {
state.highlightId = null
}
const isFirst = state.current.size === 0
draw()
if (fit) {
fitToScreen({ instant: isFirst })
}
emitChange()
}
function emitChange() {
const trail = []
for (let node = state.focus; node; node = node.parent) {
trail.unshift({ id: node.id, title: node.title })
}
options.onChange({
trail,
view: state.view.key,
matchCount: state.matches.size,
drawnCount: state.scene.nodesById.size
})
}
// DRAWING --------------------------------------------------------------
function radiusOf(d) {
switch (d.kind) {
case 'more':
return 6
case 'group':
return 3
case 'stub':
return d.stub === 'locale' ? 7 : 3.5
}
if (d.collapsed) {
return 7 + Math.min(9, Math.sqrt(d.hidden) * 1.2)
}
if (d.node === state.focus) {
return 7
}
return d.node.children.length > 0 ? 5 : 4
}
function labelOf(d) {
let text
switch (d.kind) {
case 'more':
return labels.more(d.hidden)
case 'stub':
text = d.stub === 'locale' ? `${d.title} (${d.hidden})` : `↗ ${d.title}`
break
default:
text = d.title
}
const max = state.view.labelChars
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text
}
function countOf(d) {
const n = d.kind === 'more' || d.collapsed ? d.hidden : 0
if (!n) {
return ''
}
return n > 999 ? `${Math.floor(n / 1000)}k` : String(n)
}
function domIdOf(id) {
let domId = state.domIds.get(id)
if (!domId) {
domId = `pg-${uid}-${state.domIds.size}`
state.domIds.set(id, domId)
}
return domId
}
/** Classes, glyph, label and accessible name: everything about a node but where it is. */
function dressNode(el, hnode) {
const d = hnode.data
const page = d.node?.page
const g = select(el)
const isFolder = d.kind === 'node' && d.node.children.length > 0
g.attr('id', domIdOf(d.id))
.attr('class', 'pg-node')
.classed(`pg-node--${d.kind}`, true)
.classed('is-page', Boolean(page))
.classed('is-folder', isFolder)
.classed('is-collapsed', Boolean(d.collapsed))
.classed('is-focus', d.node === state.focus && d.kind === 'node')
.classed('is-draft', Boolean(page && !page.isPublished))
.classed('is-actionable', defaultAction(d) !== null)
.attr('role', 'treeitem')
.attr('aria-level', hnode.depth + 1)
.attr('aria-label', accessibleName(d))
.attr('aria-expanded', isFolder ? String(!d.collapsed) : null)
.style('--h', d.hue ?? 0)
.style('--s', d.hue === null || d.hue === undefined ? '0%' : '62%')
const r = radiusOf(d)
el.__radius = r
g.select('.pg-hit').attr('r', Math.max(r + 5, 10))
g.select('.pg-dot').attr('r', r)
g.select('.pg-count').text(countOf(d))
g.select('.pg-label').text(labelOf(d))
}
function accessibleName(d) {
const parts = [d.kind === 'more' ? labels.more(d.hidden) : d.title]
if (d.collapsed || d.stub === 'locale') {
parts.push(labels.pages(d.hidden))
}
if (d.node?.page && !d.node.page.isPublished) {
parts.push(labels.unpublished)
}
return parts.join(', ')
}
function applyLabel(el, spec, r) {
el.setAttribute('transform', spec.rotate ? `rotate(${spec.rotate})` : '')
el.setAttribute('text-anchor', spec.anchor)
el.setAttribute('x', spec.side * (r + 5))
el.setAttribute('y', spec.side === 0 ? r + 13 : 0)
}
function lerp(from, to, t) {
const out = {}
for (const key in to) {
const a = from[key] ?? to[key]
out[key] = a + (to[key] - a) * t
}
return out
}
const clamp01 = (t) => (t < 0 ? 0 : t > 1 ? 1 : t)
function stopAnimation() {
state.animation?.cancel()
state.animation = null
clearTimeout(state.linkTimer)
}
/**
* Drive `frame(elapsed)` until `duration` has passed, then call `done`.
*
* requestAnimationFrame rather than d3-transition: a transition tweens one attribute of one element,
* and a frame here is every node and every edge computed together from the same moment.
*/
function run(duration, frame, done) {
if (options.reduceMotion()) {
frame(Infinity)
done()
return
}
const start = performance.now()
let raf = null
const tick = (now) => {
const elapsed = now - start
frame(elapsed)
if (elapsed >= duration) {
state.animation = null
done()
} else {
raf = requestAnimationFrame(tick)
}
}
raf = requestAnimationFrame(tick)
state.animation = { cancel: () => cancelAnimationFrame(raf) }
}
function draw() {
stopAnimation()
const { scene, layout, view } = state
const sameSpace = state.currentView === null || state.currentView === view.key
const isFirst = state.current.size === 0
const prev = state.current
const hnodes = scene.root.descendants()
const rootPos = layout.positions.get(scene.root.data.id)
// -> NODES
const joined = nodesLayer.selectAll('g.pg-node').data(hnodes, (d) => d.data.id)
joined.exit().each(function () {
// -> Faded rather than removed, and only removed if it is still gone once the fade is over:
// a node can come straight back in the next redraw
this.classList.add('is-exiting')
clearTimeout(this.__exitTimer)
this.__exitTimer = setTimeout(() => this.remove(), 320)
})
const entered = joined.enter().append('g')
entered.append('circle').attr('class', 'pg-hit')
entered.append('circle').attr('class', 'pg-dot')
entered
.append('text')
.attr('class', 'pg-count')
.attr('dy', '0.35em')
.attr('text-anchor', 'middle')
entered.append('text').attr('class', 'pg-label').attr('dy', '0.32em')
const nodes = entered.merge(joined)
const entries = []
const entryById = new Map()
nodes.each(function (hnode) {
clearTimeout(this.__exitTimer)
dressNode(this, hnode)
const id = hnode.data.id
const finalPos = layout.positions.get(id)
const was = prev.get(id)
let from = was ? (sameSpace ? was.pos : was.xy) : null
if (!from) {
// -> New: out of the nearest ancestor that was drawn before, or out of the root
let ancestor = hnode.parent
while (ancestor && !prev.has(ancestor.data.id)) {
ancestor = ancestor.parent
}
const origin = ancestor ? prev.get(ancestor.data.id) : null
if (origin) {
from = sameSpace ? origin.pos : origin.xy
} else {
from = sameSpace ? rootPos : layout.place(rootPos)
}
}
const entry = {
id,
el: this,
label: this.querySelector('.pg-label'),
hnode,
finalPos,
from,
to: sameSpace ? finalPos : layout.place(finalPos),
cur: from,
entering: !was,
delay: isFirst ? hnode.depth * LEVEL_STAGGER : 0,
// -> On a first draw a node leaves from wherever its parent has got to, so each level
// branches out of the one before it rather than every node flying out of the root
parent: isFirst && hnode.parent ? entryById.get(hnode.parent.data.id) : null
}
entries.push(entry)
entryById.set(id, entry)
if (!sameSpace) {
applyLabel(entry.label, layout.label(finalPos, hnode), this.__radius)
}
})
// -> EDGES
const edgeSel = edgesLayer
.selectAll('path.pg-edge')
.data(layout.edges, (d) => d.id)
.join('path')
.attr('class', 'pg-edge')
.each(function (edge) {
const child = scene.nodesById.get(edge.child).data
const page = child.node?.page
this.classList.toggle('is-draft', Boolean(page && !page.isPublished))
this.classList.toggle('is-stub', child.kind === 'stub' || child.kind === 'group')
this.style.setProperty('--h', child.hue ?? 0)
this.style.setProperty('--s', child.hue === null || child.hue === undefined ? '0%' : '62%')
})
const edges = []
edgeSel.each(function (edge) {
edges.push({
el: this,
parent: entryById.get(edge.parent),
child: entryById.get(edge.child),
depth: edge.depth
})
// -> Hidden until they grow back in after a change of view, and never left hidden by one that
// was interrupted
this.style.opacity = sameSpace ? '' : '0'
})
// -> LINKS are drawn once everything has settled; what was there goes now
linksLayer.selectAll('path.pg-link').classed('is-stale', true)
const maxDelay = isFirst ? scene.root.height * LEVEL_STAGGER : 0
const live = new Map()
const frame = (elapsed) => {
for (const e of entries) {
const t = easeCubicInOut(clamp01((elapsed - e.delay) / MOVE_DURATION))
// -> Entries are breadth first, so a parent's position for this frame is already in hand
e.cur = lerp(e.parent ? e.parent.cur : e.from, e.to, t)
const xy = sameSpace ? layout.place(e.cur) : e.cur
e.el.setAttribute('transform', `translate(${xy.x},${xy.y})`)
if (sameSpace) {
applyLabel(e.label, layout.label(e.cur, e.hnode), e.el.__radius)
} else {
e.label.style.opacity = String(clamp01((t - 0.65) / 0.35))
}
if (e.entering) {
e.el.style.opacity = String(t)
} else {
e.el.style.opacity = ''
}
live.set(e.id, { pos: sameSpace ? e.cur : e.finalPos, xy })
}
if (sameSpace) {
for (const edge of edges) {
if (edge.parent && edge.child) {
edge.el.setAttribute('d', layout.edgePath(edge.parent.cur, edge.child.cur))
}
}
}
state.current = live
}
state.currentView = view.key
run(MOVE_DURATION + maxDelay, frame, () => {
for (const e of entries) {
e.label.style.opacity = ''
}
if (sameSpace) {
drawLinks()
} else {
growEdges(edges, () => drawLinks())
}
})
applyHighlight()
applySearch()
}
/** After a change of view: every edge grows from its parent out to its child, a level at a time. */
function growEdges(edges, done) {
const { layout, scene } = state
run(
GROW_DURATION + scene.root.height * GROW_STAGGER,
(elapsed) => {
for (const edge of edges) {
if (!edge.parent || !edge.child) {
continue
}
const t = easeCubicOut(
clamp01((elapsed - (edge.depth - 1) * GROW_STAGGER) / GROW_DURATION)
)
const from = edge.parent.finalPos
edge.el.setAttribute('d', layout.edgePath(from, lerp(from, edge.child.finalPos, t)))
edge.el.style.opacity = t > 0 ? '' : '0'
}
},
done
)
}
function drawLinks() {
const { layout, scene } = state
linksLayer.selectAll('path.pg-link.is-stale').remove()
if (!layout.linkPath) {
return
}
const sel = linksLayer
.selectAll('path.pg-link')
.data(scene.links, (d) => d.id)
.join('path')
.attr('class', (d) => `pg-link pg-link--${d.kind}`)
.attr('d', (d) => layout.linkPath(d))
.style('stroke-width', (d) => (0.7 + Math.log2(d.weight) * 0.45).toFixed(2))
.each(function (d) {
const hue = scene.nodesById.get(d.source)?.data.hue
this.style.setProperty('--h', hue ?? 0)
this.style.setProperty('--s', hue === null || hue === undefined ? '0%' : '62%')
})
if (!options.reduceMotion()) {
// -> `pathLength` normalises every path to one unit, so a single dash the length of the path can
// be slid along it without measuring any of them. Taken off again once they have drawn, since
// it would stretch the flowing dashes of a highlighted link to the length of the link
sel
.attr('pathLength', 1)
.classed('is-drawing', true)
.style(
'animation-delay',
(d) => `${Math.round((layout.linkPhase?.(d) ?? 0) * LINK_SWEEP)}ms`
)
state.linkTimer = setTimeout(
() => {
sel.attr('pathLength', null).classed('is-drawing', false).style('animation-delay', null)
},
LINK_SWEEP + LINK_DRAW + 50
)
}
applyHighlight()
}
// FITTING --------------------------------------------------------------
function fitToScreen({ instant = false } = {}) {
const { layout, view } = state
const { width, height } = container.getBoundingClientRect()
if (!width || !height) {
return
}
const b = layout.bounds
const bw = Math.max(b.x1 - b.x0, 1)
const bh = Math.max(b.y1 - b.y0, 1)
const k = Math.max(
Math.min((width * 0.92) / bw, (height * 0.92) / bh, 1.4),
view.minFitScale ?? 0
)
// -> Centred along an axis it fits on. Along one it does not -- a view that stops shrinking at
// `minFitScale` -- the ROOT is centred instead, which is where it is read from, but never so
// far that blank space opens up before the first node or after the last
const root = layout.place(layout.positions.get(state.scene.root.data.id))
const place = (size, from, span, at) => {
if (k * span <= size * 0.92) {
return size / 2 - k * (from + span / 2)
}
const margin = 24
const centred = size / 2 - k * at
return Math.min(margin - k * from, Math.max(size - margin - k * (from + span), centred))
}
const tx = place(width, b.x0, bw, root.x)
const ty = place(height, b.y0, bh, root.y)
const target = zoomIdentity.translate(tx, ty).scale(k)
if (instant || options.reduceMotion()) {
svg.interrupt().call(zoomer.transform, target)
} else {
svg.transition().duration(MOVE_DURATION).ease(easeCubicInOut).call(zoomer.transform, target)
}
}
function zoomBy(factor) {
const duration = options.reduceMotion() ? 0 : 250
svg.transition().duration(duration).call(zoomer.scaleBy, factor)
}
// HIGHLIGHT AND SEARCH -------------------------------------------------
/**
* Light up one node and everything it is connected to: its links in and out, which flow toward and
* away from it, the nodes at their far ends, and its line back to the focus.
*/
function applyHighlight() {
const id = state.highlightId ?? state.activeId
const connected = new Set()
if (id) {
connected.add(id)
for (let hnode = state.scene.nodesById.get(id); hnode; hnode = hnode.parent) {
connected.add(hnode.data.id)
}
}
linksLayer.selectAll('path.pg-link').each(function (d) {
const isOut = d.source === id
const isIn = d.target === id
this.classList.toggle('is-out', isOut)
this.classList.toggle('is-in', isIn)
if (isOut) {
connected.add(d.target)
} else if (isIn) {
connected.add(d.source)
}
if (isOut || isIn) {
// -> On top of the others, so a highlighted strand is not drawn under a hundred faded ones
this.parentNode.appendChild(this)
}
})
edgesLayer.selectAll('path.pg-edge').each(function (d) {
this.classList.toggle(
'is-trail',
Boolean(id) && connected.has(d.child) && connected.has(d.parent)
)
})
nodesLayer.selectAll('g.pg-node').each(function (hnode) {
this.classList.toggle('is-linked', connected.has(hnode.data.id))
this.classList.toggle('is-highlighted', hnode.data.id === id)
this.classList.toggle('is-active', hnode.data.id === state.activeId)
})
svg.classed('is-highlighting', Boolean(id))
svg.attr('aria-activedescendant', state.activeId ? domIdOf(state.activeId) : null)
}
function applySearch() {
const { matches, containsMatch } = state
svg.classed('has-search', matches.size > 0)
nodesLayer.selectAll('g.pg-node').each(function (hnode) {
const d = hnode.data
const id = d.kind === 'stub' || d.kind === 'more' ? d.node?.id : d.id
this.classList.toggle('is-match', Boolean(id) && matches.has(id))
this.classList.toggle(
'is-contains-match',
Boolean(d.collapsed || d.kind === 'more') && Boolean(d.node) && containsMatch.has(d.node.id)
)
})
}
// ACTIONS --------------------------------------------------------------
/** What choosing a node does when nothing more specific is asked for. */
function defaultAction(d) {
switch (d.kind) {
case 'node':
if (d.node.page) {
return 'open'
}
if (d.node === state.focus) {
return d.node.parent ? 'up' : null
}
return 'zoom'
case 'more':
return 'more'
case 'stub':
if (d.stub === 'outside') {
return d.node.children.length > 0 ? 'zoom' : 'open'
}
return d.stub === 'external' ? 'open' : null
}
return null
}
/** Everything choosing a node could do, for the hover card to offer. */
function actionsFor(d) {
const actions = []
if (d.node?.page && d.kind !== 'more' && d.stub !== 'locale') {
actions.push('open')
}
if (
(d.kind === 'node' || d.stub === 'outside') &&
d.node.children.length > 0 &&
d.node !== state.focus
) {
actions.push('zoom')
}
if (d.kind === 'node' && d.node === state.focus && d.node.parent) {
actions.push('up')
}
if (d.kind === 'more') {
actions.push('more')
}
return actions
}
function activate(id, action) {
const hnode = state.scene?.nodesById.get(id)
if (!hnode) {
return
}
const d = hnode.data
switch (action ?? defaultAction(d)) {
case 'open': {
const page = d.node?.page
if (page) {
options.onOpen({ locale: page.locale, path: page.path })
}
break
}
case 'zoom':
focusOn(d.node)
break
case 'up':
if (state.focus.parent) {
focusOn(state.focus.parent)
}
break
case 'more': {
const view = state.view
state.allowance.set(
d.node.id,
(state.allowance.get(d.node.id) ?? view.maxChildren) + view.maxChildren
)
options.onHover(null)
rebuild({ fit: false })
break
}
}
}
function focusOn(node) {
if (!node || node === state.focus) {
return
}
// -> The node zoomed out of stays the one the keyboard is on, so arrowing on continues from it
const from = state.focus
state.focus = node
state.highlightId = null
options.onHover(null)
rebuild()
const stayOn = state.scene.nodesById.has(from.id) ? from.id : node.id
if (document.activeElement === svg.node()) {
// -> Once the view has settled: a card placed now would be placed against a zoom still moving
clearTimeout(state.activeTimer)
state.activeTimer = setTimeout(
() => setActive(stayOn),
options.reduceMotion() ? 0 : MOVE_DURATION + 50
)
}
}
// POINTER --------------------------------------------------------------
/** Where a node ends up, which is where a card about it belongs even while it is still moving. */
function finalXY(id) {
const pos = state.layout?.positions.get(id)
return pos ? state.layout.place(pos) : null
}
function hoverInfo(id) {
const hnode = state.scene.nodesById.get(id)
const xy = finalXY(id)
if (!hnode || !xy) {
return null
}
const d = hnode.data
const t = state.transform
const r = (nodeElement(id)?.__radius ?? 4) * t.k
let linksIn = 0
let linksOut = 0
for (const link of state.scene.links) {
if (link.source === id) {
linksOut += link.count
} else if (link.target === id) {
linksIn += link.count
}
}
return {
id,
kind: d.kind,
stub: d.stub ?? null,
title: d.kind === 'more' ? labels.more(d.hidden) : d.title,
locale: d.node?.page?.locale ?? d.locale ?? null,
path: d.node?.page?.path ?? d.node?.path ?? '',
isPage: Boolean(d.node?.page),
isPublished: d.node?.page?.isPublished ?? true,
isRedirect: d.node?.page?.isRedirect ?? false,
pages:
d.collapsed || d.kind === 'more' || d.stub === 'locale' ? d.hidden : (d.node?.size ?? 0),
isCollapsed: Boolean(d.collapsed),
hasLinks: state.view.links,
linksIn,
linksOut,
actions: actionsFor(d),
defaultAction: defaultAction(d),
// -> In the container's own pixels, so the card can be placed without knowing about the zoom
x: t.applyX(xy.x),
y: t.applyY(xy.y),
r
}
}
function nodeElement(id) {
return document.getElementById(domIdOf(id))
}
function nodeIdOf(ev) {
const el = ev.target.closest?.('g.pg-node')
return el && !el.classList.contains('is-exiting') ? el.__data__?.data.id : null
}
nodesLayer.on('click', (ev) => {
const id = nodeIdOf(ev)
if (id) {
activate(id)
}
})
nodesLayer.on('pointerover', (ev) => {
const id = nodeIdOf(ev)
if (!id || id === state.highlightId || state.animation) {
return
}
state.highlightId = id
applyHighlight()
options.onHover(hoverInfo(id))
})
nodesLayer.on('pointerout', (ev) => {
const id = nodeIdOf(ev)
const to = ev.relatedTarget?.closest?.('g.pg-node')
if (id && to?.__data__?.data.id !== id) {
// -> The card decides when the highlight goes, since the pointer may be on its way into it
options.onHover(null)
}
})
// KEYBOARD -------------------------------------------------------------
function setActive(id) {
state.activeId = id
state.highlightId = null
applyHighlight()
if (!id) {
options.onHover(null)
return
}
keepInView(id)
options.onHover({ ...hoverInfo(id), fromKeyboard: true })
}
/** Pan just enough to bring a node the keyboard moved to back on screen. */
function keepInView(id) {
const xy = finalXY(id)
if (!xy) {
return
}
const { width, height } = container.getBoundingClientRect()
const x = state.transform.applyX(xy.x)
const y = state.transform.applyY(xy.y)
const margin = 60
if (x < margin || x > width - margin || y < margin || y > height - margin) {
const duration = options.reduceMotion() ? 0 : 300
svg.transition().duration(duration).call(zoomer.translateTo, xy.x, xy.y)
}
}
svg.on('focus', () => {
// -> A click focuses the SVG as well, and a mouse reader has not asked to be walked from the root
if (!state.activeId && state.scene && svg.node().matches(':focus-visible')) {
setActive(state.scene.root.data.id)
}
})
svg.on('blur', () => {
state.activeId = null
applyHighlight()
options.onHover(null)
})
svg.on('keydown', (ev) => {
if (!state.scene) {
return
}
const hnode = state.scene.nodesById.get(state.activeId) ?? state.scene.root
const siblings = hnode.parent?.children ?? [hnode]
const index = siblings.indexOf(hnode)
let next = null
switch (ev.key) {
case 'ArrowDown':
next = siblings[index + 1]
break
case 'ArrowUp':
next = siblings[index - 1]
break
case 'ArrowRight':
next = hnode.children?.[0]
break
case 'ArrowLeft':
next = hnode.parent
break
case 'Home':
next = state.scene.root
break
case 'Enter':
case ' ':
activate(hnode.data.id)
break
case '+':
case '=':
if (actionsFor(hnode.data).includes('zoom')) {
activate(hnode.data.id, 'zoom')
}
break
case '-':
case 'Backspace':
activate(state.scene.root.data.id, 'up')
break
case 'Escape':
// -> Only swallowed when there is something to let go of: otherwise it is the overlay's
if (!state.activeId) {
return
}
setActive(null)
break
default:
return
}
ev.preventDefault()
ev.stopPropagation()
if (next) {
setActive(next.data.id)
}
})
// API ------------------------------------------------------------------
return {
setData(model, view) {
stopAnimation()
state.model = model
state.view = view
state.focus = model.root
state.allowance = new Map()
state.current = new Map()
state.currentView = null
state.activeId = null
state.highlightId = null
nodesLayer.selectAll('*').remove()
edgesLayer.selectAll('*').remove()
linksLayer.selectAll('*').remove()
this.setSearch(state.query ?? '', { silent: true })
rebuild()
},
setView(view) {
if (view === state.view) {
return
}
state.view = view
options.onHover(null)
if (state.model) {
rebuild()
}
},
focusOn(id) {
const node = state.model?.nodes.get(id)
if (node) {
focusOn(node)
}
},
activate,
/** Let go of the highlight the pointer left behind, unless the keyboard has one of its own. */
clearHover() {
state.highlightId = null
applyHighlight()
},
setSearch(query, { silent = false } = {}) {
state.query = query
if (!state.model) {
return
}
state.matches = searchModel(state.model, query)
state.containsMatch = new Set()
for (const id of state.matches) {
for (let node = state.model.nodes.get(id)?.parent; node; node = node.parent) {
state.containsMatch.add(node.id)
}
}
if (!silent && state.scene) {
applySearch()
emitChange()
}
},
fit: () => fitToScreen(),
zoomIn: () => zoomBy(1.4),
zoomOut: () => zoomBy(1 / 1.4),
destroy() {
stopAnimation()
clearTimeout(state.activeTimer)
svg.interrupt()
svg.remove()
}
}
}

@ -0,0 +1,195 @@
/**
* The page graph as a tree of nodes, built once per fetch.
*
* `GET /sites/:siteId/tree/graph` answers with flat lists -- pages, the folders above them, links by
* index -- and every view draws a hierarchy, so this is where one becomes the other. It is the whole
* of the wiki's structure for one locale and is never drawn as it stands: `scene.js` cuts out what
* fits on a screen, which is what lets a locale of fifty thousand pages cost no more to draw than one
* of fifty.
*
* A node is a PATH, not a page: `guides` can be a page, the folder of pages under it, or both at
* once, and in a tree those are one place with one name. `node.page` is set when there is a page
* there, and `node.children` when there is anything under it.
*/
export const ROOT_ID = 'root'
/**
* The hues given to top-level sections that nobody has coloured, in order.
*
* Spread round the wheel so neighbours differ, and skipping the yellows, which have too little
* contrast against a light background to carry a label beside them.
*/
const SECTION_HUES = [210, 155, 25, 275, 340, 190, 95, 250, 5, 125]
/**
* The hue a folder colour actually renders as.
*
* `folderColors.js` stores a ROTATION of a yellow folder icon, so the colour a reader has seen on the
* folder is that yellow turned by the stored angle -- 45 degrees being where the icon starts.
*/
const FOLDER_ICON_HUE = 45
function makeNode(fields) {
return {
id: fields.id,
path: fields.path ?? '',
title: fields.title ?? '',
parent: fields.parent ?? null,
children: [],
depth: fields.parent ? fields.parent.depth + 1 : 0,
/** The page at this path, when there is one: `{ locale, path, isPublished, isRedirect }`. */
page: null,
/** The folder colour set on this very folder, in rendered degrees. */
ownHue: fields.ownHue ?? null,
/** The colour it is drawn in: its own, or its section's. Null draws it neutral. */
hue: null,
/** How many pages are at or under this path. */
size: 0,
linksIn: 0,
linksOut: 0,
isExternal: false
}
}
/**
* @param {object} reply What the graph endpoint answered.
* @param {object} opts
* @param {string} opts.rootTitle What the node standing for the whole site is called.
* @returns {object} The model: `root`, `nodes` (by id), `links`, `externals`, `truncated`.
*/
export function buildModel(reply, { rootTitle }) {
const locale = reply.locale
const nodes = new Map()
const root = makeNode({ id: ROOT_ID, title: rootTitle })
nodes.set(ROOT_ID, root)
const folders = new Map(reply.folders.map((folder) => [folder.path, folder]))
/** The node at a path of this locale, creating every folder above it on the way. */
function nodeAt(path) {
if (!path) {
return root
}
const id = `n:${path}`
let node = nodes.get(id)
if (node) {
return node
}
const slash = path.lastIndexOf('/')
const parent = nodeAt(slash < 0 ? '' : path.slice(0, slash))
const folder = folders.get(path)
node = makeNode({
id,
path,
// -> A path segment with no folder row of its own is named after the segment, which is what the
// file manager's tree shows for it too
title: folder?.title || path.slice(slash + 1),
parent,
ownHue: folder?.hue ? (FOLDER_ICON_HUE + folder.hue) % 360 : null
})
parent.children.push(node)
nodes.set(id, node)
return node
}
// -> Index into `reply.pages` to the node it became, which is how links address pages
const byIndex = []
const externals = []
reply.pages.forEach((page, index) => {
const info = {
id: page.id,
locale: page.locale,
path: page.path,
isPublished: page.isPublished,
isRedirect: page.isRedirect
}
if (page.locale === locale) {
const node = nodeAt(page.path)
node.page = info
// -> The page's title wins over its folder's: one name for the one place, and the page is what
// a reader would search for
node.title = page.title || node.title
byIndex[index] = node
} else {
// -> Another locale's page, here only because something in this one links to it. Not placed in
// the tree: it has no folder of this locale to sit in
const node = makeNode({ id: `x:${page.id}`, path: page.path, title: page.title })
node.page = info
node.isExternal = true
node.locale = page.locale
externals.push(node)
nodes.set(node.id, node)
byIndex[index] = node
}
})
const links = []
for (const link of reply.links) {
const source = byIndex[link.source]
const target = byIndex[link.target]
if (!source || !target) {
continue
}
source.linksOut++
target.linksIn++
links.push({ source, target, kind: link.kind, weight: link.weight })
}
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
let sectionIndex = 0
;(function finish(node, hue) {
node.children.sort((a, b) => collator.compare(a.title, b.title))
if (node.depth === 1 && node.ownHue === null) {
// -> Only a section with something in it takes a colour from the rotation, so a root full of
// single pages does not use the palette up before the first real section
hue = node.children.length > 0 ? SECTION_HUES[sectionIndex++ % SECTION_HUES.length] : null
}
node.hue = node.ownHue ?? hue
node.size = node.page ? 1 : 0
for (const child of node.children) {
finish(child, node.hue)
node.size += child.size
}
})(root, null)
return {
locale,
root,
nodes,
links,
externals,
pageCount: root.size,
truncated: reply.truncated
}
}
/**
* The ids of every node whose title or path contains the query, case and accents aside.
*
* A substring rather than a fuzzy match: this highlights, it does not rank, and a fuzzy matcher lights
* up half a wiki on a three-letter query.
*/
export function searchModel(model, query) {
const needle = fold(query.trim())
const hits = new Set()
if (!needle) {
return hits
}
for (const node of model.nodes.values()) {
if (node.id === ROOT_ID) {
continue
}
if (fold(node.title).includes(needle) || fold(node.path).includes(needle)) {
hits.add(node.id)
}
}
return hits
}
function fold(str) {
return str
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase()
}

@ -0,0 +1,271 @@
import { hierarchy } from 'd3-hierarchy'
/**
* What of the model is drawn right now.
*
* The model is the whole locale; a scene is one screenful of it, and this is the only place that
* decides which. That is what makes the graph cost the same at any size: every view draws at most
* its `budget` of nodes, whatever the wiki holds, and the rest is folded into the nodes above it.
*
* Three ways a part of the tree is kept out, each drawn as something that can be asked for:
*
* - **A collapsed folder** is one node standing for everything under it, drawn with a count. Levels
* are opened breadth first, the smallest folders of a level first, until the next would not fit —
* so the shape of the whole tree is always visible, and detail fills in as far as the budget goes.
* - **A long folder** shows its first `maxChildren` entries and a "more" node for the rest, since
* one folder of three thousand pages would otherwise use the whole budget on one level.
* - **Outside the focus.** Zoomed into a section, the rest of the wiki is not drawn -- but a link
* crossing into it still is, to a stub naming the branch it goes to.
*
* Scene nodes wrap model nodes rather than being them: a collapsed folder and an expanded one are the
* same model node and different scene nodes, and the stubs and "more" nodes have no model node at all.
*/
/** A ceiling on drawn links, strongest kept. Past it a view is a solid disc of ink anyway. */
const MAX_SCENE_LINKS = 4000
/** Past this many linked pages in one other locale, the locale is drawn as one stub for all of them. */
const MAX_STUBS_PER_LOCALE = 40
/**
* @param {object} model From `buildModel`.
* @param {object} opts
* @param {object} opts.focus The model node the scene is rooted at.
* @param {number} opts.budget How many nodes the view draws at most.
* @param {number} opts.maxChildren How many entries of one folder are shown before "more".
* @param {Map<string, number>} opts.allowance Folders whose "more" has been opened, with how many.
* @param {boolean} opts.withLinks Whether to gather links, and the stubs their far ends need.
* @param {object} opts.labels `elsewhere` (the group of stubs outside the focus) and `locale(code)`.
*/
export function buildScene(model, { focus, budget, maxChildren, allowance, withLinks, labels }) {
// -> BREADTH FIRST: which folders are opened, and how many of each one's entries are shown
const expanded = new Map()
let count = 1
let frontier = [focus]
while (frontier.length > 0) {
const next = []
const candidates = frontier
.filter((node) => node.children.length > 0)
.sort((a, b) => a.children.length - b.children.length)
for (const node of candidates) {
const shown = Math.min(node.children.length, allowance.get(node.id) ?? maxChildren)
const cost = shown + (shown < node.children.length ? 1 : 0)
// -> The focus always opens: a section zoomed into and drawn as a single bubble would be a
// click that did nothing
if (node !== focus && count + cost > budget) {
// -> Sorted smallest first, so nothing after this one on the level fits either
break
}
expanded.set(node, shown)
count += cost
next.push(...node.children.slice(0, shown))
}
frontier = next
}
const byId = new Map()
function build(node) {
const shown = expanded.get(node)
const item = {
id: node.id,
kind: 'node',
node,
title: node.title,
collapsed: shown === undefined && node.children.length > 0,
// -> For a collapsed folder, the pages it stands for
hidden: shown === undefined ? node.size - (node.page ? 1 : 0) : 0,
hue: node.hue
}
byId.set(item.id, item)
if (shown !== undefined) {
item.children = node.children.slice(0, shown).map(build)
if (shown < node.children.length) {
const more = {
id: `more:${node.id}`,
kind: 'more',
node,
title: '',
hidden: node.children.length - shown,
hue: node.hue
}
byId.set(more.id, more)
item.children.push(more)
}
}
return item
}
const top = build(focus)
const links = withLinks ? gatherLinks(model, { focus, top, byId, expanded, labels }) : []
const root = hierarchy(top)
const nodesById = new Map()
for (const hnode of root.descendants()) {
nodesById.set(hnode.data.id, hnode)
}
return { root, nodesById, links, focus }
}
/**
* Every link of the model, folded onto what the scene draws.
*
* Each end is moved to the nearest thing drawn for it: the page itself, the collapsed folder or the
* "more" node it is hidden in, or -- outside the focus -- a stub for the branch it is in. Two links
* landing on the same pair become one, weighted by both, which is what keeps a collapsed section's
* links from being a hundred lines on top of one another.
*/
function gatherLinks(model, { focus, top, byId, expanded, labels }) {
const aboveFocus = new Set()
for (let node = focus.parent; node; node = node.parent) {
aboveFocus.add(node.id)
}
const outside = new Map()
const external = new Map()
/** Where one end of a link is drawn, or null for a branch-outside stub not yet made. */
function drawnAt(node) {
if (node.isExternal) {
return { external: node }
}
let current = node
let below = null
while (current) {
const item = byId.get(current.id)
if (item) {
// -> Arrived at an expanded folder from an entry it does not show: that entry is under "more"
if (below && expanded.has(current)) {
return { item: byId.get(`more:${current.id}`) ?? item }
}
return { item }
}
if (aboveFocus.has(current.id)) {
// -> Outside the focus: named after the branch leading to it from where the two meet, or
// after the node itself when it is one of the focus's own ancestors
return { outside: below ?? current }
}
below = current
current = current.parent
}
return null
}
const pending = []
for (const link of model.links) {
const from = drawnAt(link.source)
const to = drawnAt(link.target)
if (!from || !to || (!from.item && !to.item)) {
// -> Neither end in the section: nothing to draw it between
continue
}
for (const end of [from, to]) {
if (end.outside) {
outside.set(end.outside.id, end.outside)
} else if (end.external) {
const perLocale = external.get(end.external.locale) ?? new Map()
perLocale.set(end.external.id, end.external)
external.set(end.external.locale, perLocale)
}
}
pending.push({ from, to, link })
}
// -> The stubs, each group appended to the focus's own entries so that it takes an arc of its own at
// the rim rather than landing among the section's pages
const groups = []
if (outside.size > 0) {
const group = { id: 'group:outside', kind: 'group', title: labels.elsewhere, hue: null }
group.children = [...outside.values()].map((node) => {
const stub = {
id: `out:${node.id}`,
kind: 'stub',
stub: 'outside',
node,
title: node.title,
hue: node.hue
}
byId.set(stub.id, stub)
return stub
})
byId.set(group.id, group)
groups.push(group)
}
for (const [locale, pages] of [...external.entries()].sort()) {
const group = {
id: `group:locale:${locale}`,
kind: 'group',
locale,
title: labels.locale(locale),
hue: null
}
if (pages.size > MAX_STUBS_PER_LOCALE) {
// -> One stub for the whole locale: forty names round the rim already reads as a wall
group.kind = 'stub'
group.stub = 'locale'
group.hidden = pages.size
} else {
group.children = [...pages.values()].map((node) => {
const stub = {
id: `ext:${node.id}`,
kind: 'stub',
stub: 'external',
node,
locale,
title: node.title,
hue: null
}
byId.set(stub.id, stub)
return stub
})
}
byId.set(group.id, group)
groups.push(group)
}
if (groups.length > 0) {
top.children = [...(top.children ?? []), ...groups]
}
const resolveEnd = (end) => {
if (end.item) {
return end.item
}
if (end.outside) {
return byId.get(`out:${end.outside.id}`)
}
return byId.get(`ext:${end.external.id}`) ?? byId.get(`group:locale:${end.external.locale}`)
}
const merged = new Map()
const KIND_RANK = { link: 0, relation: 1, redirect: 2 }
for (const { from, to, link } of pending) {
const source = resolveEnd(from)
const target = resolveEnd(to)
if (!source || !target || source === target) {
continue
}
const id = `${source.id}>${target.id}`
const edge = merged.get(id)
if (edge) {
edge.weight += link.weight
edge.count++
if (KIND_RANK[link.kind] > KIND_RANK[edge.kind]) {
edge.kind = link.kind
}
} else {
merged.set(id, {
id,
source: source.id,
target: target.id,
kind: link.kind,
weight: link.weight,
count: 1
})
}
}
const links = [...merged.values()]
if (links.length > MAX_SCENE_LINKS) {
links.sort((a, b) => b.weight - a.weight)
links.length = MAX_SCENE_LINKS
}
return links
}

@ -0,0 +1,36 @@
import radial from './radial.js'
import relational from './relational.js'
import tree from './tree.js'
/**
* Every way the graph can be drawn, in the order the switcher offers them.
*
* A view is a layout and nothing else: given a scene it says where each node goes, how an edge
* between two positions is drawn, which way a label runs, and -- if it draws links -- the path of
* each. Drawing, animating, zooming, hovering and the keyboard are the engine's (`engine.js`), the
* same for every view. So a new view is a file exporting the shape below and a line here:
*
* key, icon, labelKey identity, and how the switcher shows it
* budget, maxChildren how much of the tree it can draw at once (`scene.js`)
* links whether the scene should gather links and their stubs
* labelChars, fontSize how long a label may run, and how big it is drawn
* minFitScale optional -- stop shrinking to fit below this, and start from the top left
* layout(scene) -> { positions, edges, place, label, edgePath, bounds, linkPath?, linkPhase? }
*
* Positions are whatever the view interpolates in -- `{ x, y }` for the tree, `{ a, r }` for the
* radial ones -- and `place` turns one into a point. Moving between two layouts of the SAME view
* interpolates positions, so nodes travel the view's own way; moving between views interpolates the
* points, since the two have nothing else in common.
*/
export const GRAPH_VIEWS = [tree, radial, relational]
/**
* What the graph opens in until somebody picks another view. Named rather than taken from the order
* above, which is the order the switcher reads in and a separate question.
*/
export const DEFAULT_GRAPH_VIEW = radial
/** The view with this key, or the default for a key that is not one -- absent, or no longer a view. */
export function graphView(key) {
return GRAPH_VIEWS.find((view) => view.key === key) ?? DEFAULT_GRAPH_VIEW
}

@ -0,0 +1,65 @@
import { tree } from 'd3-hierarchy'
import { linkRadial } from 'd3-shape'
import { boundsOf, polarToXY, radialLabel, radialLabelEnd } from './shared.js'
/**
* Hierarchical Radial: the same tree as the Hierarchical Tree, wrapped round its root.
*
* Positions are kept as an angle and a radius rather than as a point, so that a node moving between
* two layouts of this view swings round the centre instead of cutting across it.
*/
/** Arc length each leaf is given on the outer ring, and the gap between rings. */
const LEAF_ARC = 16
const RING = 140
const edge = linkRadial()
.angle((pos) => pos.a)
.radius((pos) => pos.r)
export default {
key: 'radial',
icon: 'mdi:radar',
labelKey: 'fileman.graph.viewRadial',
budget: 700,
maxChildren: 250,
links: false,
labelChars: 28,
fontSize: 11,
layout(scene) {
const { root } = scene
const leaves = root.leaves().length
const radius = Math.max(RING * Math.max(root.height, 1), (leaves * LEAF_ARC) / (2 * Math.PI))
tree()
.size([2 * Math.PI, radius])
.separation((a, b) => (a.parent === b.parent ? 1 : 2) / a.depth)(root)
const positions = new Map()
const edges = []
for (const node of root.descendants()) {
positions.set(node.data.id, { a: node.x, r: node.y })
if (node.parent) {
edges.push({
id: node.data.id,
parent: node.parent.data.id,
child: node.data.id,
depth: node.depth
})
}
}
return {
positions,
edges,
place: polarToXY,
label: (pos, node) => radialLabel(pos, node.depth === 0),
edgePath: (s, t) => edge({ source: s, target: t }),
bounds: boundsOf(root, positions, polarToXY, {
chars: this.labelChars,
labelEnd: radialLabelEnd
})
}
}
}

@ -0,0 +1,87 @@
import { cluster } from 'd3-hierarchy'
import { curveBundle, lineRadial, linkRadial } from 'd3-shape'
import { boundsOf, polarToXY, radialLabel, radialLabelEnd } from './shared.js'
/**
* Relational Radial: every page on one ring, and the links between them drawn across it.
*
* Hierarchical edge bundling -- each link is routed through the folders both of its ends sit in, so
* links between two sections run together as one visible strand instead of a web. The folders are
* where the bundles bend, so they are drawn too, faintly, inside the ring.
*
* The scene appends its stubs to the focus as groups of their own, which is what puts them on an arc
* of the ring apart from the section's pages; they are then pushed out past the ring, so a link
* leaving the section is seen to leave it.
*/
const LEAF_ARC = 14
const MIN_RADIUS = 220
/** How far past the ring a stub sits. */
const STUB_OFFSET = 26
const bundle = lineRadial()
.curve(curveBundle.beta(0.85))
.angle((pos) => pos.a)
.radius((pos) => pos.r)
const edge = linkRadial()
.angle((pos) => pos.a)
.radius((pos) => pos.r)
export default {
key: 'relational',
icon: 'mdi:graph-outline',
labelKey: 'fileman.graph.viewRelational',
budget: 800,
maxChildren: 300,
links: true,
labelChars: 28,
fontSize: 10.5,
layout(scene) {
const { root, nodesById } = scene
const leaves = root.leaves().length
const radius = Math.max(MIN_RADIUS, (leaves * LEAF_ARC) / (2 * Math.PI))
cluster()
.size([2 * Math.PI, radius])
.separation((a, b) => (a.parent === b.parent ? 1 : 2))(root)
const positions = new Map()
const edges = []
for (const node of root.descendants()) {
const isStub = node.data.kind === 'stub'
positions.set(node.data.id, { a: node.x, r: node.y + (isStub ? STUB_OFFSET : 0) })
if (node.parent) {
edges.push({
id: node.data.id,
parent: node.parent.data.id,
child: node.data.id,
depth: node.depth
})
}
}
return {
positions,
edges,
place: polarToXY,
label: (pos, node) => radialLabel(pos, node.depth === 0),
edgePath: (s, t) => edge({ source: s, target: t }),
linkPath(link) {
const source = nodesById.get(link.source)
const target = nodesById.get(link.target)
return bundle(source.path(target).map((node) => positions.get(node.data.id)))
},
/** Where a link starts round the ring, as a fraction of a turn: it sets when the link draws in. */
linkPhase(link) {
const a = positions.get(link.source)?.a ?? 0
return a / (2 * Math.PI)
},
bounds: boundsOf(root, positions, polarToXY, {
chars: this.labelChars,
labelEnd: radialLabelEnd
})
}
}
}

@ -0,0 +1,68 @@
/**
* Geometry the views have in common.
*/
/** Roughly how wide a character of a label is, in world units at the size the views draw them. */
const CHAR_WIDTH = 6.5
/** Where a node sits in polar coordinates, as the two radial views keep it. Angle 0 is twelve o'clock. */
export function polarToXY(pos) {
return { x: pos.r * Math.sin(pos.a), y: -pos.r * Math.cos(pos.a) }
}
/**
* How a label runs out from a node on a radial layout: along the radius, turned over on the left half
* so that it never reads upside down.
*/
export function radialLabel(pos, isCenter) {
if (isCenter) {
return { rotate: 0, side: 0, anchor: 'middle' }
}
const degrees = (pos.a * 180) / Math.PI - 90
// -> Normalised first, since an angle being interpolated between two layouts can leave [0, 2π)
const turn = ((pos.a % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)
return turn > Math.PI
? { rotate: degrees + 180, side: -1, anchor: 'end' }
: { rotate: degrees, side: 1, anchor: 'start' }
}
/**
* The box everything is drawn in, labels included, for fitting the view to the screen.
*
* Labels are measured by character count rather than by the DOM: the layout runs before anything is
* drawn, and a fit that is out by a few pixels is not worth a synchronous reflow per label.
*
* @param {Function} labelEnd Where a label of a given width, run out from a node, ends.
*/
export function boundsOf(root, positions, place, { chars, labelEnd }) {
let x0 = Infinity
let y0 = Infinity
let x1 = -Infinity
let y1 = -Infinity
const take = ({ x, y }) => {
x0 = Math.min(x0, x)
x1 = Math.max(x1, x)
y0 = Math.min(y0, y)
y1 = Math.max(y1, y)
}
for (const node of root.descendants()) {
const pos = positions.get(node.data.id)
const width = Math.min(node.data.title?.length ?? 0, chars) * CHAR_WIDTH + 16
const at = place(pos)
take({ x: at.x - 16, y: at.y - 16 })
take({ x: at.x + 16, y: at.y + 16 })
take(labelEnd(pos, width, node))
}
if (!Number.isFinite(x0)) {
return { x0: -100, y0: -100, x1: 100, y1: 100 }
}
return { x0, y0, x1, y1 }
}
/** `labelEnd` for the radial layouts: out along the radius, or under the node at the centre. */
export function radialLabelEnd(pos, width, node) {
if (node.depth === 0) {
return { x: 0, y: 30 }
}
return polarToXY({ a: pos.a, r: pos.r + width })
}

@ -0,0 +1,62 @@
import { tree } from 'd3-hierarchy'
import { boundsOf } from './shared.js'
/**
* Hierarchical Tree: the site root on the left, each level a column to the right of the last.
*
* The one view whose labels never rotate, which makes it the one to read titles in -- and so the one
* that stops shrinking to fit: past `minFitScale` it is started from its root, top left, and the rest is
* panned to, rather than scaled down to a column of dots.
*/
const ROW = 24
const COLUMN = 240
export default {
key: 'tree',
icon: 'mdi:file-tree-outline',
labelKey: 'fileman.graph.viewTree',
budget: 500,
maxChildren: 200,
links: false,
labelChars: 34,
fontSize: 12,
minFitScale: 0.8,
layout(scene) {
const { root } = scene
tree()
.nodeSize([ROW, COLUMN])
.separation((a, b) => (a.parent === b.parent ? 1 : 1.3))(root)
const positions = new Map()
const edges = []
for (const node of root.descendants()) {
// -> d3 lays a tree out top-down; turned on its side, its x is our y
positions.set(node.data.id, { x: node.y, y: node.x })
if (node.parent) {
edges.push({
id: node.data.id,
parent: node.parent.data.id,
child: node.data.id,
depth: node.depth
})
}
}
return {
positions,
edges,
place: (pos) => pos,
label: () => ({ rotate: 0, side: 1, anchor: 'start' }),
edgePath(s, t) {
const mid = (s.x + t.x) / 2
return `M${s.x},${s.y}C${mid},${s.y} ${mid},${t.y} ${t.x},${t.y}`
},
bounds: boundsOf(root, positions, (pos) => pos, {
chars: this.labelChars,
labelEnd: (pos, width) => ({ x: pos.x + width, y: pos.y })
})
}
}
}

@ -242,7 +242,7 @@ const state = reactive({
search: '',
currentPage: 1,
pageSize: 20,
totalPages: 15
totalPages: 1
})
/*

Loading…
Cancel
Save