diff --git a/backend/api/schemas/tree.ts b/backend/api/schemas/tree.ts index 9e7ba837e..2ec102db1 100644 --- a/backend/api/schemas/tree.ts +++ b/backend/api/schemas/tree.ts @@ -218,4 +218,77 @@ export async function registerSchemas(app: FastifyInstance): Promise { } } }) + + /** + * 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.' + } + } + }) } diff --git a/backend/api/tree.ts b/backend/api/tree.ts index 4f21250f4..d2b51bc80 100644 --- a/backend/api/tree.ts +++ b/backend/api/tree.ts @@ -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 */ diff --git a/backend/locales/en.json b/backend/locales/en.json index 8aac485bd..62b3d1b52 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -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", diff --git a/backend/models/index.ts b/backend/models/index.ts index 6558d6bc6..73473fc45 100644 --- a/backend/models/index.ts +++ b/backend/models/index.ts @@ -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, diff --git a/backend/models/pageGraph.ts b/backend/models/pageGraph.ts new file mode 100644 index 000000000..1f492dbd6 --- /dev/null +++ b/backend/models/pageGraph.ts @@ -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 = { 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 { + 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() + // -> Asked once per page: a hub every other page links to would otherwise be judged per link + const refused = new Set() + 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() + const byAlias = new Map() + const byId = new Map() + const relationsOf = new Map>() + 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(), + refs: new Set() + } + 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/` 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() + 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 { + const wanted = new Set() + 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() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7d4d19b29..37d08c4a0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 08d8a3f78..79ce51dbb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index 2d03a581b..7368bf64b 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -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":"","width":32,"height":32}, @@ -96,6 +96,7 @@ export const BUNDLED_ICONS = { "la:landmark": {"body":"","width":32,"height":32}, "la:language": {"body":"","width":32,"height":32}, "la:leaf": {"body":"","width":32,"height":32}, + "la:level-up-alt": {"body":"","width":32,"height":32}, "la:life-ring": {"body":"","width":32,"height":32}, "la:link": {"body":"","width":32,"height":32}, "la:list": {"body":"","width":32,"height":32}, @@ -214,6 +215,8 @@ export const BUNDLED_ICONS = { "mdi:file-document-outline": {"body":"","width":24,"height":24}, "mdi:file-search-outline": {"body":"","width":24,"height":24}, "mdi:file-tree": {"body":"","width":24,"height":24}, + "mdi:file-tree-outline": {"body":"","width":24,"height":24}, + "mdi:fit-to-screen-outline": {"body":"","width":24,"height":24}, "mdi:flag-outline": {"body":"","width":24,"height":24}, "mdi:food-apple-outline": {"body":"","width":24,"height":24}, "mdi:format-align-center": {"body":"","width":24,"height":24}, @@ -241,6 +244,7 @@ export const BUNDLED_ICONS = { "mdi:format-subscript": {"body":"","width":24,"height":24}, "mdi:format-superscript": {"body":"","width":24,"height":24}, "mdi:format-underline": {"body":"","width":24,"height":24}, + "mdi:graph-outline": {"body":"","width":24,"height":24}, "mdi:hand-wave-outline": {"body":"","width":24,"height":24}, "mdi:highlight-off": {"body":"","width":24,"height":24}, "mdi:home": {"body":"","width":24,"height":24}, @@ -265,6 +269,7 @@ export const BUNDLED_ICONS = { "mdi:play": {"body":"","width":24,"height":24}, "mdi:playlist-edit": {"body":"","width":24,"height":24}, "mdi:power": {"body":"","width":24,"height":24}, + "mdi:radar": {"body":"","width":24,"height":24}, "mdi:redo-variant": {"body":"","width":24,"height":24}, "mdi:seed-plus-outline": {"body":"","width":24,"height":24}, "mdi:sort-alphabetical-descending-variant": {"body":"","width":24,"height":24}, diff --git a/frontend/src/components/FileManager.vue b/frontend/src/components/FileManager.vue index a28cdd41c..fee7fd6de 100644 --- a/frontend/src/components/FileManager.vue +++ b/frontend/src/components/FileManager.vue @@ -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. --> - +
+ + + @@ -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; diff --git a/frontend/src/components/PageGraph.vue b/frontend/src/components/PageGraph.vue new file mode 100644 index 000000000..1345ebb0b --- /dev/null +++ b/frontend/src/components/PageGraph.vue @@ -0,0 +1,1050 @@ + + + + + diff --git a/frontend/src/graph/engine.js b/frontend/src/graph/engine.js new file mode 100644 index 000000000..3167941a0 --- /dev/null +++ b/frontend/src/graph/engine.js @@ -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() + } + } +} diff --git a/frontend/src/graph/model.js b/frontend/src/graph/model.js new file mode 100644 index 000000000..60f63b9df --- /dev/null +++ b/frontend/src/graph/model.js @@ -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() +} diff --git a/frontend/src/graph/scene.js b/frontend/src/graph/scene.js new file mode 100644 index 000000000..deeb54ee3 --- /dev/null +++ b/frontend/src/graph/scene.js @@ -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} 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 +} diff --git a/frontend/src/graph/views/index.js b/frontend/src/graph/views/index.js new file mode 100644 index 000000000..b0fcf5269 --- /dev/null +++ b/frontend/src/graph/views/index.js @@ -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 +} diff --git a/frontend/src/graph/views/radial.js b/frontend/src/graph/views/radial.js new file mode 100644 index 000000000..b56ce4aa5 --- /dev/null +++ b/frontend/src/graph/views/radial.js @@ -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 + }) + } + } +} diff --git a/frontend/src/graph/views/relational.js b/frontend/src/graph/views/relational.js new file mode 100644 index 000000000..857097047 --- /dev/null +++ b/frontend/src/graph/views/relational.js @@ -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 + }) + } + } +} diff --git a/frontend/src/graph/views/shared.js b/frontend/src/graph/views/shared.js new file mode 100644 index 000000000..94e827a22 --- /dev/null +++ b/frontend/src/graph/views/shared.js @@ -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 }) +} diff --git a/frontend/src/graph/views/tree.js b/frontend/src/graph/views/tree.js new file mode 100644 index 000000000..6a1b425ce --- /dev/null +++ b/frontend/src/graph/views/tree.js @@ -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 }) + }) + } + } +} diff --git a/frontend/src/pages/AdminUsers.vue b/frontend/src/pages/AdminUsers.vue index a0dcb4b70..ba609f9f8 100644 --- a/frontend/src/pages/AdminUsers.vue +++ b/frontend/src/pages/AdminUsers.vue @@ -242,7 +242,7 @@ const state = reactive({ search: '', currentPage: 1, pageSize: 20, - totalPages: 15 + totalPages: 1 }) /*