mirror of https://github.com/requarks/wiki
parent
55e325d2a7
commit
50e615db9a
@ -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()
|
||||
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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue