mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 05:54:49 -05:00
feat: visualize pages
This commit is contained in:
19 files changed
+3469
-9
No files matched your search
@@ -218,4 +218,77 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* TREE GRAPH - Every page of a locale, the folders above them and the links between them
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'TreeGraph',
|
||||
type: 'object',
|
||||
properties: {
|
||||
locale: {
|
||||
type: 'string'
|
||||
},
|
||||
pages: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Every page of the locale the requester may see, then the pages in OTHER locales that one of those links to. Links address pages by their index in this array.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
locale: { type: 'string' },
|
||||
path: {
|
||||
type: 'string',
|
||||
description: 'Slash-separated, without a leading or trailing slash.'
|
||||
},
|
||||
title: { type: 'string' },
|
||||
isPublished: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'False for a page only its editors may see, which is only ever sent to somebody who may edit it.'
|
||||
},
|
||||
isRedirect: { type: 'boolean' }
|
||||
}
|
||||
}
|
||||
},
|
||||
folders: {
|
||||
type: 'array',
|
||||
description:
|
||||
'The folders holding at least one of the pages, in the locale asked for. A path segment with no folder of its own is absent, and is named after the segment.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
hue: {
|
||||
type: 'integer',
|
||||
description: 'The folder colour, in degrees. Absent on a folder nobody has coloured.'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
links: {
|
||||
type: 'array',
|
||||
description:
|
||||
'One entry per pair of pages, however many ways the source reaches the target.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source: { type: 'integer', description: 'Index into `pages`.' },
|
||||
target: { type: 'integer', description: 'Index into `pages`.' },
|
||||
kind: { type: 'string', enum: ['link', 'relation', 'redirect'] },
|
||||
weight: {
|
||||
type: 'integer',
|
||||
description: 'How many distinct hrefs on the source resolve to the target.'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
truncated: {
|
||||
type: 'boolean',
|
||||
description: 'True when the locale holds more pages or links than one graph carries.'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -267,6 +267,62 @@ async function routes(app: FastifyInstance) {
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PAGE GRAPH
|
||||
*/
|
||||
app.get<{ Params: { siteId: string }; Querystring: { locale?: string } }>(
|
||||
'/sites/:siteId/tree/graph',
|
||||
{
|
||||
/*
|
||||
No route-level permissions: `read:pages` is a page rule, asked of every page below, and a
|
||||
caller allowed nowhere gets an empty graph -- the same answer as the tree listing above.
|
||||
*/
|
||||
schema: {
|
||||
summary: 'Get the page graph',
|
||||
description:
|
||||
"Every page of one locale the caller may read, the folders they sit in, and the links between them — what the file manager's graph view draws.\n\nA page that is not published is included only for a caller who may also edit it (`write:pages`), and is marked with `isPublished: false`. A link is included only when the caller may see both of its ends. Links reaching a page in another locale bring that page along, so the far end can be named.",
|
||||
tags: ['Tree'],
|
||||
params: siteIdParam,
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
locale: {
|
||||
type: 'string',
|
||||
maxLength: 10,
|
||||
description: "The site's primary locale when absent."
|
||||
}
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: { $ref: 'TreeGraph#' }
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req) => {
|
||||
const siteId = req.params.siteId
|
||||
const actor = WIKI.models.groups.actorForRequest(req)
|
||||
// -> A draft belongs to the people working on it, and an API key is answered as the public is
|
||||
// -- the same line the page route draws, via `actorFrom`
|
||||
const isSession = actorFrom(req) !== null
|
||||
return WIKI.models.pageGraph.graphFor({
|
||||
siteId,
|
||||
locale: req.query.locale || defaultLocale(siteId),
|
||||
visibility: (page) => {
|
||||
const ref = { siteId, path: page.path, locale: page.locale, tags: page.tags }
|
||||
if (!WIKI.models.groups.checkAccess(actor, 'read:pages', ref)) {
|
||||
return null
|
||||
}
|
||||
if (page.publishState === 'published') {
|
||||
return 'published'
|
||||
}
|
||||
return isSession && WIKI.models.groups.checkAccess(actor, 'write:pages', ref)
|
||||
? 'draft'
|
||||
: null
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* BROWSE THE TREE AS A READER
|
||||
*/
|
||||
|
||||
@@ -2651,6 +2651,42 @@
|
||||
"fileman.folderTitleInvalidChars": "Invalid Characters in Folder Name",
|
||||
"fileman.folderTitleMissing": "Missing Folder Title",
|
||||
"fileman.gifFileType": "Animated GIF Image",
|
||||
"fileman.graph.actionMore": "Show More",
|
||||
"fileman.graph.actionOpen": "Open Page",
|
||||
"fileman.graph.actionUp": "Zoom Out",
|
||||
"fileman.graph.actionZoom": "Explore Section",
|
||||
"fileman.graph.back": "Back to Files",
|
||||
"fileman.graph.elsewhere": "Elsewhere in this wiki",
|
||||
"fileman.graph.empty": "There are no pages to show in this locale.",
|
||||
"fileman.graph.fit": "Fit to Screen",
|
||||
"fileman.graph.hint": "Scroll to zoom, drag to pan. Click a page to open it, or a section to explore it.",
|
||||
"fileman.graph.legendCollapsed": "Collapsed section",
|
||||
"fileman.graph.legendIn": "Links in",
|
||||
"fileman.graph.legendOut": "Links out",
|
||||
"fileman.graph.legendPage": "Page",
|
||||
"fileman.graph.legendRelation": "Relation",
|
||||
"fileman.graph.legendSection": "Section",
|
||||
"fileman.graph.linksIn": "No links in | 1 link in | {count} links in",
|
||||
"fileman.graph.linksOut": "No links out | 1 link out | {count} links out",
|
||||
"fileman.graph.loadFailed": "Failed to load the page graph.",
|
||||
"fileman.graph.loading": "Loading pages...",
|
||||
"fileman.graph.matches": "No matches | 1 match | {count} matches",
|
||||
"fileman.graph.more": "{count} more | {count} more",
|
||||
"fileman.graph.pageCount": "No pages | 1 page | {count} pages",
|
||||
"fileman.graph.parentLevel": "Go to Parent Level",
|
||||
"fileman.graph.redirect": "Redirect",
|
||||
"fileman.graph.retry": "Try Again",
|
||||
"fileman.graph.search": "Find in graph...",
|
||||
"fileman.graph.site": "Site",
|
||||
"fileman.graph.title": "Visualize Pages",
|
||||
"fileman.graph.truncated": "Too many pages to show them all",
|
||||
"fileman.graph.unpublished": "Unpublished",
|
||||
"fileman.graph.viewRadial": "Hierarchical Radial",
|
||||
"fileman.graph.viewRelational": "Relational Radial",
|
||||
"fileman.graph.viewTree": "Hierarchical Tree",
|
||||
"fileman.graph.views": "Graph layout",
|
||||
"fileman.graph.zoomIn": "Zoom In",
|
||||
"fileman.graph.zoomOut": "Zoom Out",
|
||||
"fileman.gzFileType": "GZipped Archive",
|
||||
"fileman.heicFileType": "HEIC Image",
|
||||
"fileman.icoFileType": "Icon File",
|
||||
|
||||
@@ -18,6 +18,7 @@ import { locales } from './locales.ts'
|
||||
import { mail } from './mail.ts'
|
||||
import { metrics } from './metrics.ts'
|
||||
import { navigation } from './navigation.ts'
|
||||
import { pageGraph } from './pageGraph.ts'
|
||||
import { pageHistory } from './pageHistory.ts'
|
||||
import { pageLinks } from './pageLinks.ts'
|
||||
import { pageProblems } from './pageProblems.ts'
|
||||
@@ -59,6 +60,7 @@ export default {
|
||||
mail,
|
||||
metrics,
|
||||
navigation,
|
||||
pageGraph,
|
||||
pageHistory,
|
||||
pageLinks,
|
||||
pageProblems,
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import { and, eq, inArray, sql, type SQL } from 'drizzle-orm'
|
||||
import {
|
||||
pageLinks as pageLinksTable,
|
||||
pages as pagesTable,
|
||||
tree as treeTable
|
||||
} from '../db/schema.ts'
|
||||
import { decodeTreePath } from '../helpers/common.ts'
|
||||
|
||||
/**
|
||||
* Page graph model
|
||||
*
|
||||
* Every page of one locale, the folders they sit in, and what links them together — the data behind
|
||||
* the file manager's graph view. One read of three tables, assembled here rather than in the client,
|
||||
* because the client may not be told about a page it cannot read, and which pages those are is only
|
||||
* known after the rows are in hand (see `visibility` below).
|
||||
*
|
||||
* Nothing new is stored. The hierarchy is the tree the file manager already browses, and the links
|
||||
* are `pageLinks`, which is kept in step with every save: what a page's render links to, where a
|
||||
* redirection points, and the page's relations.
|
||||
*/
|
||||
|
||||
/** The editor whose content IS a link. See the same constant in `models/pageLinks.ts`. */
|
||||
const REDIRECT_EDITOR = 'redirect'
|
||||
|
||||
/**
|
||||
* How many pages one graph may carry, links' far ends in other locales included.
|
||||
*
|
||||
* A ceiling rather than a page size: the view collapses what it draws to what fits on a screen, so it
|
||||
* needs the whole tree to decide what that is, and a second page of a graph means nothing. Far past
|
||||
* any wiki anybody runs as one locale of one site — `truncated` says so if one ever gets there.
|
||||
*/
|
||||
export const MAX_GRAPH_PAGES = 50_000
|
||||
|
||||
/** The same for links, which on a heavily cross-linked wiki outnumber the pages several times over. */
|
||||
export const MAX_GRAPH_LINKS = 250_000
|
||||
|
||||
/** What a link is, as far as the graph draws it. */
|
||||
export type GraphLinkKind = 'link' | 'relation' | 'redirect'
|
||||
|
||||
/**
|
||||
* How visible a page is to whoever is asking: `published` and `draft` are both drawn, the second
|
||||
* dimmed, and null is not drawn at all.
|
||||
*/
|
||||
export type GraphVisibility = 'published' | 'draft' | null
|
||||
|
||||
/** The columns the caller's visibility decision is made on. */
|
||||
export interface GraphPageRef {
|
||||
locale: string
|
||||
path: string
|
||||
tags: string[]
|
||||
publishState: string
|
||||
}
|
||||
|
||||
export interface GraphPage {
|
||||
id: string
|
||||
locale: string
|
||||
path: string
|
||||
title: string
|
||||
isPublished: boolean
|
||||
isRedirect: boolean
|
||||
}
|
||||
|
||||
export interface GraphFolder {
|
||||
path: string
|
||||
title: string
|
||||
hue?: number
|
||||
}
|
||||
|
||||
export interface GraphLink {
|
||||
/** Index into `pages` of the page the link is written on. */
|
||||
source: number
|
||||
/** Index into `pages` of the page it resolves to. */
|
||||
target: number
|
||||
kind: GraphLinkKind
|
||||
/** How many distinct hrefs on the source resolve to the target. */
|
||||
weight: number
|
||||
}
|
||||
|
||||
export interface PageGraph {
|
||||
locale: string
|
||||
pages: GraphPage[]
|
||||
folders: GraphFolder[]
|
||||
links: GraphLink[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
interface PageRow {
|
||||
id: string
|
||||
locale: string
|
||||
path: string
|
||||
title: string
|
||||
tags: string[] | null
|
||||
publishState: string
|
||||
editor: string
|
||||
alias: string | null
|
||||
relations: unknown
|
||||
}
|
||||
|
||||
const pageColumns = {
|
||||
id: pagesTable.id,
|
||||
locale: pagesTable.locale,
|
||||
path: pagesTable.path,
|
||||
title: pagesTable.title,
|
||||
tags: pagesTable.tags,
|
||||
publishState: pagesTable.publishState,
|
||||
editor: pagesTable.editor,
|
||||
alias: pagesTable.alias
|
||||
}
|
||||
|
||||
/** Precedence when one page reaches another in more than one way: the strongest link is kept. */
|
||||
const KIND_RANK: Record<GraphLinkKind, number> = { link: 0, relation: 1, redirect: 2 }
|
||||
|
||||
class PageGraphModel {
|
||||
/**
|
||||
* The graph of one locale of a site, as one requester may see it.
|
||||
*
|
||||
* @param visibility Decides, per page, whether it is drawn and how. Asked of the locale's own pages
|
||||
* and of every page in another locale that one of them links to. A page it
|
||||
* refuses is left out entirely, and so is every link touching it: a link to
|
||||
* something the reader may not know about would say that it is there.
|
||||
*/
|
||||
async graphFor({
|
||||
siteId,
|
||||
locale,
|
||||
visibility
|
||||
}: {
|
||||
siteId: string
|
||||
locale: string
|
||||
visibility: (page: GraphPageRef) => GraphVisibility
|
||||
}): Promise<PageGraph> {
|
||||
let truncated = false
|
||||
|
||||
// -> The locale's own pages. Relations are read here and nowhere else: they are what tells a
|
||||
// relation apart from a link in the rows below, and only a source page's matter
|
||||
const pageRows = (await WIKI.db
|
||||
.select({ ...pageColumns, relations: pagesTable.relations })
|
||||
.from(pagesTable)
|
||||
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.locale, locale)))
|
||||
.orderBy(pagesTable.path)
|
||||
.limit(MAX_GRAPH_PAGES + 1)) as PageRow[]
|
||||
if (pageRows.length > MAX_GRAPH_PAGES) {
|
||||
pageRows.length = MAX_GRAPH_PAGES
|
||||
truncated = true
|
||||
}
|
||||
|
||||
const pages: GraphPage[] = []
|
||||
const indexById = new Map<string, number>()
|
||||
// -> Asked once per page: a hub every other page links to would otherwise be judged per link
|
||||
const refused = new Set<string>()
|
||||
const add = (row: PageRow): number | null => {
|
||||
const known = indexById.get(row.id)
|
||||
if (known !== undefined) {
|
||||
return known
|
||||
}
|
||||
if (refused.has(row.id)) {
|
||||
return null
|
||||
}
|
||||
if (pages.length >= MAX_GRAPH_PAGES) {
|
||||
truncated = true
|
||||
return null
|
||||
}
|
||||
const seen = visibility({
|
||||
locale: row.locale,
|
||||
path: row.path,
|
||||
tags: row.tags ?? [],
|
||||
publishState: row.publishState
|
||||
})
|
||||
if (!seen) {
|
||||
refused.add(row.id)
|
||||
return null
|
||||
}
|
||||
indexById.set(row.id, pages.length)
|
||||
pages.push({
|
||||
id: row.id,
|
||||
locale: row.locale,
|
||||
path: row.path,
|
||||
title: row.title,
|
||||
isPublished: seen === 'published',
|
||||
isRedirect: row.editor === REDIRECT_EDITOR
|
||||
})
|
||||
return pages.length - 1
|
||||
}
|
||||
|
||||
// -> Every row goes into the lookups, visible or not: a link resolving to a page the reader may
|
||||
// not see has to be recognised as exactly that and dropped, not sent off to be looked for in
|
||||
// another locale
|
||||
const byPath = new Map<string, PageRow>()
|
||||
const byAlias = new Map<string, PageRow>()
|
||||
const byId = new Map<string, PageRow>()
|
||||
const relationsOf = new Map<string, Set<string>>()
|
||||
for (const row of pageRows) {
|
||||
byPath.set(`${row.locale}/${row.path}`, row)
|
||||
byId.set(row.id, row)
|
||||
if (row.alias) {
|
||||
byAlias.set(row.alias, row)
|
||||
}
|
||||
const targets = (Array.isArray(row.relations) ? row.relations : [])
|
||||
.map((relation: any) => relation?.target)
|
||||
.filter((target: unknown): target is string => typeof target === 'string')
|
||||
if (targets.length > 0) {
|
||||
relationsOf.set(row.id, new Set(targets))
|
||||
}
|
||||
add(row)
|
||||
}
|
||||
|
||||
// -> Every link written on one of those pages that addresses a page of this site. Assets are not
|
||||
// pages, and a link to another site is not something this graph can place
|
||||
const linkRows = await WIKI.db
|
||||
.select({
|
||||
pageId: pageLinksTable.pageId,
|
||||
kind: pageLinksTable.kind,
|
||||
href: pageLinksTable.href,
|
||||
targetLocale: pageLinksTable.targetLocale,
|
||||
targetPath: pageLinksTable.targetPath,
|
||||
targetRef: pageLinksTable.targetRef
|
||||
})
|
||||
.from(pageLinksTable)
|
||||
.innerJoin(pagesTable, eq(pagesTable.id, pageLinksTable.pageId))
|
||||
.where(
|
||||
and(
|
||||
eq(pagesTable.siteId, siteId),
|
||||
eq(pagesTable.locale, locale),
|
||||
eq(pageLinksTable.targetSiteId, siteId),
|
||||
inArray(pageLinksTable.kind, ['page', 'alias', 'pageId'])
|
||||
)
|
||||
)
|
||||
.limit(MAX_GRAPH_LINKS + 1)
|
||||
if (linkRows.length > MAX_GRAPH_LINKS) {
|
||||
linkRows.length = MAX_GRAPH_LINKS
|
||||
truncated = true
|
||||
}
|
||||
|
||||
const resolve = (link: (typeof linkRows)[number]): PageRow | undefined => {
|
||||
switch (link.kind) {
|
||||
case 'page':
|
||||
return byPath.get(`${link.targetLocale}/${link.targetPath}`)
|
||||
case 'alias':
|
||||
return link.targetRef ? byAlias.get(link.targetRef) : undefined
|
||||
case 'pageId':
|
||||
return link.targetRef ? byId.get(link.targetRef) : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// -> What the locale's own pages could not answer: a page in another locale, or an alias or an id
|
||||
// naming one. Looked up in one query, and only for what was actually linked to
|
||||
const missing = {
|
||||
paths: new Map<string, { locale: string; path: string }>(),
|
||||
refs: new Set<string>()
|
||||
}
|
||||
for (const link of linkRows) {
|
||||
if (resolve(link)) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
link.kind === 'page' &&
|
||||
link.targetLocale &&
|
||||
link.targetPath &&
|
||||
link.targetLocale !== locale
|
||||
) {
|
||||
missing.paths.set(`${link.targetLocale}/${link.targetPath}`, {
|
||||
locale: link.targetLocale,
|
||||
path: link.targetPath
|
||||
})
|
||||
} else if (link.kind !== 'page' && link.targetRef) {
|
||||
missing.refs.add(link.targetRef)
|
||||
}
|
||||
}
|
||||
if (missing.paths.size > 0 || missing.refs.size > 0) {
|
||||
const lookups: SQL[] = []
|
||||
if (missing.paths.size > 0) {
|
||||
const wanted = [...missing.paths.values()]
|
||||
lookups.push(
|
||||
sql`(${pagesTable.locale}, ${pagesTable.path}) IN (SELECT * FROM unnest(${sql.param(
|
||||
wanted.map((w) => w.locale)
|
||||
)}::text[], ${sql.param(wanted.map((w) => w.path))}::text[]))`
|
||||
)
|
||||
}
|
||||
if (missing.refs.size > 0) {
|
||||
const refs = [...missing.refs]
|
||||
// -> Compared as text: a `/i/<id>` link carries whatever was written after the slash, and a
|
||||
// uuid cast would fail the whole read on the first one that is not an id at all
|
||||
lookups.push(sql`${pagesTable.id}::text = ANY(${sql.param(refs)}::text[])`)
|
||||
lookups.push(sql`${pagesTable.alias} = ANY(${sql.param(refs)}::text[])`)
|
||||
}
|
||||
const others = (await WIKI.db
|
||||
.select(pageColumns)
|
||||
.from(pagesTable)
|
||||
.where(
|
||||
and(eq(pagesTable.siteId, siteId), sql`(${sql.join(lookups, sql` OR `)})`)
|
||||
)) as PageRow[]
|
||||
for (const row of others) {
|
||||
byPath.set(`${row.locale}/${row.path}`, row)
|
||||
byId.set(row.id, row)
|
||||
if (row.alias && !byAlias.has(row.alias)) {
|
||||
byAlias.set(row.alias, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -> One edge per pair of pages, however many ways the source reaches the target
|
||||
const edges = new Map<string, GraphLink>()
|
||||
for (const link of linkRows) {
|
||||
const source = indexById.get(link.pageId)
|
||||
const targetRow = resolve(link)
|
||||
if (source === undefined || !targetRow || targetRow.id === link.pageId) {
|
||||
continue
|
||||
}
|
||||
const target = add(targetRow)
|
||||
if (target === null) {
|
||||
continue
|
||||
}
|
||||
const sourceRow = byId.get(link.pageId)!
|
||||
const kind: GraphLinkKind =
|
||||
sourceRow.editor === REDIRECT_EDITOR
|
||||
? 'redirect'
|
||||
: relationsOf.get(link.pageId)?.has(link.href)
|
||||
? 'relation'
|
||||
: 'link'
|
||||
const key = `${source}:${target}`
|
||||
const edge = edges.get(key)
|
||||
if (edge) {
|
||||
edge.weight++
|
||||
if (KIND_RANK[kind] > KIND_RANK[edge.kind]) {
|
||||
edge.kind = kind
|
||||
}
|
||||
} else {
|
||||
edges.set(key, { source, target, kind, weight: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
locale,
|
||||
pages,
|
||||
folders: await this.foldersAbove(siteId, locale, pages),
|
||||
links: [...edges.values()],
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The folders the drawn pages of this locale sit in, and no others.
|
||||
*
|
||||
* Derived from the pages rather than listed, so a folder holding nothing this reader may see is not
|
||||
* in the graph at all — the file manager's own tree cannot afford that question, but here every page
|
||||
* has already been decided. A path with no folder row above a page (a page created at a deep path
|
||||
* before its folders existed) still gets drawn by the client, named after its segment.
|
||||
*/
|
||||
private async foldersAbove(
|
||||
siteId: string,
|
||||
locale: string,
|
||||
pages: GraphPage[]
|
||||
): Promise<GraphFolder[]> {
|
||||
const wanted = new Set<string>()
|
||||
for (const page of pages) {
|
||||
if (page.locale !== locale) {
|
||||
continue
|
||||
}
|
||||
const parts = page.path.split('/')
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
wanted.add(parts.slice(0, i).join('/'))
|
||||
}
|
||||
}
|
||||
if (wanted.size < 1) {
|
||||
return []
|
||||
}
|
||||
|
||||
const rows = await WIKI.db
|
||||
.select({
|
||||
folderPath: treeTable.folderPath,
|
||||
fileName: treeTable.fileName,
|
||||
title: treeTable.title,
|
||||
meta: treeTable.meta
|
||||
})
|
||||
.from(treeTable)
|
||||
.where(
|
||||
and(
|
||||
eq(treeTable.siteId, siteId),
|
||||
eq(treeTable.locale, locale),
|
||||
eq(treeTable.type, 'folder')
|
||||
)
|
||||
)
|
||||
|
||||
const folders: GraphFolder[] = []
|
||||
for (const row of rows) {
|
||||
const parent = decodeTreePath(row.folderPath ?? '') ?? ''
|
||||
const path = parent ? `${parent}/${row.fileName}` : row.fileName
|
||||
if (!wanted.has(path)) {
|
||||
continue
|
||||
}
|
||||
const hue = (row.meta as { hue?: number } | null)?.hue
|
||||
folders.push({ path, title: row.title, ...(hue ? { hue } : {}) })
|
||||
}
|
||||
return folders.sort((a, b) => a.path.localeCompare(b.path))
|
||||
}
|
||||
}
|
||||
|
||||
export const pageGraph = new PageGraphModel()
|
||||
Generated
+6
@@ -18,6 +18,12 @@
|
||||
"@zip.js/zip.js": "2.17.0",
|
||||
"browser-fs-access": "0.38.0",
|
||||
"clipboard": "2.0.11",
|
||||
"d3-ease": "3.0.1",
|
||||
"d3-hierarchy": "3.1.2",
|
||||
"d3-selection": "3.0.0",
|
||||
"d3-shape": "3.2.0",
|
||||
"d3-transition": "3.0.1",
|
||||
"d3-zoom": "3.0.0",
|
||||
"es-toolkit": "1.50.0",
|
||||
"filesize": "11.0.22",
|
||||
"filesize-parser": "1.5.2",
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
"@zip.js/zip.js": "2.17.0",
|
||||
"browser-fs-access": "0.38.0",
|
||||
"clipboard": "2.0.11",
|
||||
"d3-ease": "3.0.1",
|
||||
"d3-hierarchy": "3.1.2",
|
||||
"d3-selection": "3.0.0",
|
||||
"d3-shape": "3.2.0",
|
||||
"d3-transition": "3.0.1",
|
||||
"d3-zoom": "3.0.0",
|
||||
"es-toolkit": "1.50.0",
|
||||
"filesize": "11.0.22",
|
||||
"filesize-parser": "1.5.2",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
|
||||
removing an icon; `check-icons.mjs` fails the build if this drifts.
|
||||
|
||||
285 icons.
|
||||
290 icons.
|
||||
*/
|
||||
export const BUNDLED_ICONS = {
|
||||
"la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
|
||||
@@ -96,6 +96,7 @@ export const BUNDLED_ICONS = {
|
||||
"la:landmark": {"body":"<path fill=\"currentColor\" d=\"M16 3.906L3.625 9.062L3 9.345V12h2v11H3v5h26v-5h-2V12h2V9.344l-.625-.281zm0 2.188L25.375 10H6.625zM7 12h2v11H7zm4 0h2v11h-2zm4 0h2v11h-2zm4 0h2v11h-2zm4 0h2v11h-2zM5 25h22v1H5z\"/>","width":32,"height":32},
|
||||
"la:language": {"body":"<path fill=\"currentColor\" d=\"M4 4v18h6v6h18V10h-6V4zm2 2h14v4.563L10.562 20H6zm5 2v1H8v2h4.938c-.13 1.148-.481 2.055-1.063 2.688a4.5 4.5 0 0 1-.906-.407C10.266 12.863 10 12.418 10 12H8c0 1.191.734 2.184 1.719 2.844A8.3 8.3 0 0 1 8 15v2c1.773 0 3.25-.406 4.375-1.156c.523.09 1.055.156 1.625.156v-1.875c.543-.91.832-1.973.938-3.125H16V9h-3V8zm10.438 4H26v14H12v-4.563zM20 13.844l-.938 2.844l-2 6l-.062.156V24h2v-.875l.031-.125h1.938l.031.125V24h2v-1.156l-.063-.157l-2-6zm0 6.281l.281.875h-.562z\"/>","width":32,"height":32},
|
||||
"la:leaf": {"body":"<path fill=\"currentColor\" d=\"M25.031 4L24 4.469c-3.18 1.402-7.773 1.93-11.688 3.312c-1.957.692-3.769 1.614-5.124 3.094C5.832 12.355 5 14.395 5 17c0 2.695 1.586 4.543 3.063 5.594a10 10 0 0 0 1.75 1c-.618 1.144-1.075 2.176-1.376 3.062c-.546 1.598-.632 2.848-.593 3.75s.156 1.52.156 1.5l2 .188c.063-.723-.125-1.102-.156-1.782s.027-1.613.5-3c.945-2.769 3.566-7.328 10.375-14.625l-1.438-1.374c-4.082 4.374-6.718 7.785-8.437 10.53a7.7 7.7 0 0 1-1.625-.874C8.059 20.145 7 18.937 7 17c0-2.203.629-3.656 1.656-4.781c1.028-1.125 2.524-1.93 4.313-2.563c3.238-1.144 7.246-1.722 10.687-3C23.976 7.871 25 12.031 25 18.531c0 3.688-.863 5.801-1.875 6.969s-2.23 1.5-3.313 1.5c-1.078 0-2.242-.55-3.25-1.25c-1.007-.7-1.703-1.43-2.406-1.875l-1.062 1.688c.18.113 1.176 1.035 2.344 1.843c1.167.809 2.648 1.594 4.374 1.594c1.54 0 3.422-.543 4.844-2.188C26.078 25.169 27 22.527 27 18.532c0-7.821-1.656-13.438-1.656-13.438z\"/>","width":32,"height":32},
|
||||
"la:level-up-alt": {"body":"<path fill=\"currentColor\" d=\"m17 4l-.719.688l-8.5 8.5l1.438 1.439L16 7.844V26H6v2h12V7.844l6.781 6.781l1.438-1.438l-8.5-8.5z\"/>","width":32,"height":32},
|
||||
"la:life-ring": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2c.336 0 .672.031 1 .063v3.03A7 7 0 0 0 16 9c-.34 0-.672.047-1 .094V6.063A11 11 0 0 1 16 6m-3 .438v3.25A7 7 0 0 0 9.687 13H6.47A9.98 9.98 0 0 1 13 6.437zm6 0A10.07 10.07 0 0 1 25.563 13h-3.25A7 7 0 0 0 19 9.687zM16 11c2.773 0 5 2.227 5 5s-2.227 5-5 5s-5-2.227-5-5s2.227-5 5-5m-9.938 4h3A7 7 0 0 0 9 16c0 .34.047.672.094 1H6.063A11 11 0 0 1 6 16c0-.336.031-.672.063-1zm16.844 0h3.032c.03.328.062.664.062 1s-.031.672-.063 1h-3.03c.046-.328.093-.66.093-1s-.047-.672-.094-1M6.438 19h3.25A7 7 0 0 0 13 22.313v3.25A10.07 10.07 0 0 1 6.437 19zm15.875 0h3.25A10.07 10.07 0 0 1 19 25.563v-3.25A7 7 0 0 0 22.313 19M15 22.906c.328.047.66.094 1 .094s.672-.047 1-.094v3.032A11 11 0 0 1 16 26c-.336 0-.672-.031-1-.063z\"/>","width":32,"height":32},
|
||||
"la:link": {"body":"<path fill=\"currentColor\" d=\"M21.75 4c-1.672 0-3.258.66-4.438 1.844l-1.468 1.468A6.25 6.25 0 0 0 14 11.75c0 .793.152 1.566.438 2.281l1.624-1.625A4.29 4.29 0 0 1 17.25 8.72l1.469-1.469A4.28 4.28 0 0 1 21.75 6c1.137 0 2.195.445 3 1.25a4.274 4.274 0 0 1 0 6.031l-1.469 1.469A4.28 4.28 0 0 1 20.25 16q-.334-.002-.656-.063l-1.625 1.626A6.2 6.2 0 0 0 20.25 18c1.672 0 3.258-.66 4.438-1.844l1.468-1.468A6.25 6.25 0 0 0 28 10.25c0-1.672-.66-3.223-1.844-4.406A6.17 6.17 0 0 0 21.75 4m-2.469 7.281l-8 8l1.438 1.438l8-8zM11.75 14c-1.672 0-3.258.66-4.438 1.844l-1.468 1.469A6.25 6.25 0 0 0 4 21.75c0 1.672.66 3.223 1.844 4.406A6.17 6.17 0 0 0 10.25 28c1.672 0 3.258-.66 4.438-1.844l1.468-1.468A6.25 6.25 0 0 0 18 20.25c0-.793-.152-1.566-.438-2.281l-1.625 1.625a4.29 4.29 0 0 1-1.187 3.687l-1.469 1.469A4.28 4.28 0 0 1 10.25 26a4.2 4.2 0 0 1-3-1.25a4.274 4.274 0 0 1 0-6.031l1.469-1.469A4.28 4.28 0 0 1 11.75 16q.334.002.656.063l1.625-1.625A6.2 6.2 0 0 0 11.75 14\"/>","width":32,"height":32},
|
||||
"la:list": {"body":"<path fill=\"currentColor\" d=\"M4 5v6h6V5zm2 2h2v2H6zm6 0v2h15V7zm-8 6v6h6v-6zm2 2h2v2H6zm6 0v2h15v-2zm-8 6v6h6v-6zm2 2h2v2H6zm6 0v2h15v-2z\"/>","width":32,"height":32},
|
||||
@@ -214,6 +215,8 @@ export const BUNDLED_ICONS = {
|
||||
"mdi:file-document-outline": {"body":"<path fill=\"currentColor\" d=\"M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm0 2h7v5h5v11H6zm2 8v2h8v-2zm0 4v2h5v-2z\"/>","width":24,"height":24},
|
||||
"mdi:file-search-outline": {"body":"<path fill=\"currentColor\" d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h7c-.41-.25-.8-.56-1.14-.9c-.33-.33-.61-.7-.86-1.1H6V4h7v5h5v1.18c.71.16 1.39.43 2 .82V8zm6.31 16.9c1.33-2.11.69-4.9-1.4-6.22c-2.11-1.33-4.91-.68-6.22 1.4c-1.34 2.11-.69 4.89 1.4 6.22c1.46.93 3.32.93 4.79.02L22 23.39L23.39 22zm-3.81.1a2.5 2.5 0 0 1-2.5-2.5a2.5 2.5 0 0 1 2.5-2.5a2.5 2.5 0 0 1 2.5 2.5a2.5 2.5 0 0 1-2.5 2.5\"/>","width":24,"height":24},
|
||||
"mdi:file-tree": {"body":"<path fill=\"currentColor\" d=\"M3 3h6v4H3zm12 7h6v4h-6zm0 7h6v4h-6zm-2-4H7v5h6v2H5V9h2v2h6z\"/>","width":24,"height":24},
|
||||
"mdi:file-tree-outline": {"body":"<path fill=\"currentColor\" d=\"M12 13H7v5h5v2H5V10h2v1h5zM8 4v2H4V4zm2-2H2v6h8zm10 9v2h-4v-2zm2-2h-8v6h8zm-2 9v2h-4v-2zm2-2h-8v6h8z\"/>","width":24,"height":24},
|
||||
"mdi:fit-to-screen-outline": {"body":"<path fill=\"currentColor\" d=\"M17 4h3c1.1 0 2 .9 2 2v2h-2V6h-3zM4 8V6h3V4H4c-1.1 0-2 .9-2 2v2zm16 8v2h-3v2h3c1.1 0 2-.9 2-2v-2zM7 18H4v-2H2v2c0 1.1.9 2 2 2h3zm9-8v4H8v-4zm2-2H6v8h12z\"/>","width":24,"height":24},
|
||||
"mdi:flag-outline": {"body":"<path fill=\"currentColor\" d=\"m12.36 6l.4 2H18v6h-3.36l-.4-2H7V6zM14 4H5v17h2v-7h5.6l.4 2h7V6h-5.6\"/>","width":24,"height":24},
|
||||
"mdi:food-apple-outline": {"body":"<path fill=\"currentColor\" d=\"M20 10a5.27 5.27 0 0 0-7-2V3h-2v5a5.27 5.27 0 0 0-7 2c-2 3 3 12 5 12s2-1 3-1s1 1 3 1s7-9 5-12m-1.75 3.38c-.62 2.47-1.84 4.74-3.55 6.62c-.2 0-.43-.1-.6-.25a3.34 3.34 0 0 0-4.2 0c-.17.15-.4.25-.6.25a15.3 15.3 0 0 1-3.55-6.61c-.25-.73-.3-1.52-.09-2.27A3.37 3.37 0 0 1 8.5 9.4c.56.01 1.11.14 1.61.39l.89.45h2l.89-.45c.5-.25 1.05-.38 1.61-.39c1.18.03 2.26.68 2.84 1.71c.21.75.16 1.54-.09 2.27M11 5C5.38 8.07 4.11 3.78 4.11 3.78S6.77.19 11 5\"/>","width":24,"height":24},
|
||||
"mdi:format-align-center": {"body":"<path fill=\"currentColor\" d=\"M3 3h18v2H3zm4 4h10v2H7zm-4 4h18v2H3zm4 4h10v2H7zm-4 4h18v2H3z\"/>","width":24,"height":24},
|
||||
@@ -241,6 +244,7 @@ export const BUNDLED_ICONS = {
|
||||
"mdi:format-subscript": {"body":"<path fill=\"currentColor\" d=\"M16 7.41L11.41 12L16 16.59L14.59 18L10 13.41L5.41 18L4 16.59L8.59 12L4 7.41L5.41 6L10 10.59L14.59 6zm5.85 13.62h-4.88v-1l.89-.8c.76-.65 1.32-1.19 1.7-1.63c.37-.44.56-.85.57-1.24a.9.9 0 0 0-.27-.7c-.18-.16-.47-.28-.86-.28c-.31 0-.58.06-.84.18l-.66.38l-.45-1.17c.27-.21.59-.39.98-.53s.82-.24 1.29-.24c.78.04 1.38.25 1.78.66s.62.93.62 1.57c-.01.56-.19 1.08-.54 1.55c-.34.47-.76.92-1.27 1.36l-.64.52v.02h2.58z\"/>","width":24,"height":24},
|
||||
"mdi:format-superscript": {"body":"<path fill=\"currentColor\" d=\"M16 7.41L11.41 12L16 16.59L14.59 18L10 13.41L5.41 18L4 16.59L8.59 12L4 7.41L5.41 6L10 10.59L14.59 6zM21.85 9h-4.88V8l.89-.82c.76-.64 1.32-1.18 1.7-1.63q.555-.66.57-1.23a.88.88 0 0 0-.27-.7c-.18-.19-.47-.28-.86-.29c-.31.01-.58.07-.84.17l-.66.39l-.45-1.17c.27-.22.59-.39.98-.53S18.85 2 19.32 2c.78 0 1.38.2 1.78.61c.4.39.62.93.62 1.57c-.01.56-.19 1.08-.54 1.55c-.34.48-.76.93-1.27 1.36l-.64.52v.02h2.58z\"/>","width":24,"height":24},
|
||||
"mdi:format-underline": {"body":"<path fill=\"currentColor\" d=\"M5 21h14v-2H5zm7-4a6 6 0 0 0 6-6V3h-2.5v8a3.5 3.5 0 0 1-3.5 3.5A3.5 3.5 0 0 1 8.5 11V3H6v8a6 6 0 0 0 6 6\"/>","width":24,"height":24},
|
||||
"mdi:graph-outline": {"body":"<path fill=\"currentColor\" d=\"M19.5 17c-.14 0-.26 0-.39.04L17.5 13.8c.45-.45.75-1.09.75-1.8a2.5 2.5 0 0 0-2.5-2.5c-.14 0-.25 0-.4.04L13.74 6.3c.47-.46.76-1.09.76-1.8a2.5 2.5 0 0 0-5 0c0 .7.29 1.34.76 1.79L8.65 9.54c-.15-.04-.26-.04-.4-.04a2.5 2.5 0 0 0-2.5 2.5c0 .71.29 1.34.75 1.79l-1.61 3.25C4.76 17 4.64 17 4.5 17a2.5 2.5 0 0 0 0 5A2.5 2.5 0 0 0 7 19.5c0-.7-.29-1.34-.76-1.79l1.62-3.25c.14.04.26.04.39.04s.25 0 .38-.04l1.63 3.25c-.47.45-.76 1.09-.76 1.79a2.5 2.5 0 0 0 5 0A2.5 2.5 0 0 0 12 17c-.13 0-.26 0-.39.04L10 13.8c.45-.45.75-1.09.75-1.8c0-.7-.29-1.33-.75-1.79l1.61-3.25c.13.04.26.04.39.04s.26 0 .39-.04L14 10.21a2.5 2.5 0 0 0 1.75 4.29c.13 0 .25 0 .38-.04l1.63 3.25c-.47.45-.76 1.09-.76 1.79a2.5 2.5 0 0 0 5 0a2.5 2.5 0 0 0-2.5-2.5m-15 3.5c-.55 0-1-.45-1-1s.45-1 1-1s1 .45 1 1s-.45 1-1 1m8.5-1c0 .55-.45 1-1 1s-1-.45-1-1s.45-1 1-1s1 .45 1 1M7.25 12c0-.55.45-1 1-1s1 .45 1 1s-.45 1-1 1s-1-.45-1-1M11 4.5c0-.55.45-1 1-1s1 .45 1 1s-.45 1-1 1s-1-.45-1-1m3.75 7.5c0-.55.45-1 1-1s1 .45 1 1s-.45 1-1 1s-1-.45-1-1m4.75 8.5c-.55 0-1-.45-1-1s.45-1 1-1s1 .45 1 1s-.45 1-1 1\"/>","width":24,"height":24},
|
||||
"mdi:hand-wave-outline": {"body":"<path fill=\"currentColor\" d=\"M7.03 4.95L3.5 8.5c-3.33 3.31-3.33 8.69 0 12s8.69 3.33 12 0l6-6c1-.97 1-2.56 0-3.54c-.1-.12-.23-.23-.37-.32l.37-.39c1-.97 1-2.56 0-3.54c-.14-.16-.33-.3-.5-.41c.38-.92.21-2.02-.54-2.77c-.87-.87-2.22-.96-3.2-.28a2.517 2.517 0 0 0-3.88-.42l-2.51 2.51c-.09-.14-.2-.27-.32-.39a2.53 2.53 0 0 0-3.52 0m1.41 1.42c.2-.2.51-.2.71 0s.2.51 0 .71l-3.18 3.18a3 3 0 0 1 0 4.24l1.41 1.41a5 5 0 0 0 1.12-5.36l6.3-6.3c.2-.2.51-.2.7 0s.21.51 0 .71l-4.59 4.6l1.41 1.41l6.01-6.01c.2-.2.51-.2.71 0s.2.51 0 .71l-6.01 6.01l1.41 1.41l4.95-4.95c.2-.2.51-.2.71 0s.2.51 0 .71l-5.66 5.65l1.41 1.42l3.54-3.54c.2-.2.51-.2.71 0s.2.51 0 .71l-6 6.01c-2.54 2.54-6.65 2.54-9.19 0s-2.54-6.65 0-9.19zM23 17c0 3.31-2.69 6-6 6v-1.5c2.5 0 4.5-2 4.5-4.5zM1 7c0-3.31 2.69-6 6-6v1.5c-2.5 0-4.5 2-4.5 4.5z\"/>","width":24,"height":24},
|
||||
"mdi:highlight-off": {"body":"<path fill=\"currentColor\" d=\"M12 20c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m0-18C6.47 2 2 6.47 2 12s4.47 10 10 10s10-4.47 10-10S17.53 2 12 2m2.59 6L12 10.59L9.41 8L8 9.41L10.59 12L8 14.59L9.41 16L12 13.41L14.59 16L16 14.59L13.41 12L16 9.41z\"/>","width":24,"height":24},
|
||||
"mdi:home": {"body":"<path fill=\"currentColor\" d=\"M10 20v-6h4v6h5v-8h3L12 3L2 12h3v8z\"/>","width":24,"height":24},
|
||||
@@ -265,6 +269,7 @@ export const BUNDLED_ICONS = {
|
||||
"mdi:play": {"body":"<path fill=\"currentColor\" d=\"M8 5.14v14l11-7z\"/>","width":24,"height":24},
|
||||
"mdi:playlist-edit": {"body":"<path fill=\"currentColor\" d=\"M3 6v2h11V6zm0 4v2h11v-2zm17 .1c-.1 0-.3.1-.4.2l-1 1l2.1 2.1l1-1c.2-.2.2-.6 0-.8l-1.3-1.3c-.1-.1-.2-.2-.4-.2m-1.9 1.8l-6.1 6V20h2.1l6.1-6.1zM3 14v2h7v-2z\"/>","width":24,"height":24},
|
||||
"mdi:power": {"body":"<path fill=\"currentColor\" d=\"m16.56 5.44l-1.45 1.45A5.97 5.97 0 0 1 18 12a6 6 0 0 1-6 6a6 6 0 0 1-6-6c0-2.17 1.16-4.06 2.88-5.12L7.44 5.44A7.96 7.96 0 0 0 4 12a8 8 0 0 0 8 8a8 8 0 0 0 8-8c0-2.72-1.36-5.12-3.44-6.56M13 3h-2v10h2\"/>","width":24,"height":24},
|
||||
"mdi:radar": {"body":"<path fill=\"currentColor\" d=\"m19.07 4.93l-1.41 1.41A8 8 0 0 1 20 12a8 8 0 0 1-8 8a8 8 0 0 1-8-8c0-4.08 3.05-7.44 7-7.93v2.02C8.16 6.57 6 9.03 6 12a6 6 0 0 0 6 6a6 6 0 0 0 6-6c0-1.66-.67-3.16-1.76-4.24l-1.41 1.41C15.55 9.9 16 10.9 16 12a4 4 0 0 1-4 4a4 4 0 0 1-4-4c0-1.86 1.28-3.41 3-3.86v2.14c-.6.35-1 .98-1 1.72a2 2 0 0 0 2 2a2 2 0 0 0 2-2c0-.74-.4-1.38-1-1.72V2h-1A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10c0-2.76-1.12-5.26-2.93-7.07\"/>","width":24,"height":24},
|
||||
"mdi:redo-variant": {"body":"<path fill=\"currentColor\" d=\"M10.5 7A6.5 6.5 0 0 0 4 13.5a6.5 6.5 0 0 0 6.5 6.5H14v-2h-3.5C8 18 6 16 6 13.5S8 9 10.5 9h5.67l-3.08 3.09l1.41 1.41L20 8l-5.5-5.5l-1.42 1.41L16.17 7zM18 18h-2v2h2z\"/>","width":24,"height":24},
|
||||
"mdi:seed-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M17.2 5c.6 0 1.2 0 1.7.1c.14 1.6.18 4.32-.72 6.9c.71 0 1.38.17 2 .41c1.46-4.51.52-9.11.52-9.11S19.3 3 17.2 3c-5.5 0-15.6 2.1-14 17.8c1.1.1 2.2.2 3.2.2c2.35 0 4.34-.31 6-.84c-.24-.62-.4-1.29-.4-1.99c-1.59.55-3.47.83-5.6.83H5.1c-.2-4.6.7-8.2 2.8-10.5C10.4 5.6 14.4 5 17.2 5M17 7C7 7 7 17 7 17C11 9 17 7 17 7m0 10h-3v2h3v3h2v-3h3v-2h-3v-3h-2z\"/>","width":24,"height":24},
|
||||
"mdi:sort-alphabetical-descending-variant": {"body":"<path fill=\"currentColor\" d=\"m15.75 19l-3.25 3.25L9.25 19zm-6.86-4.7H6L5.28 17H2.91L6 7h3l3.13 10H9.67zm-2.56-1.62h2.23l-.63-2.12l-.26-.97l-.25-.96h-.03l-.22.97l-.24.98zM13.05 17v-1.26l4.75-6.77v-.06h-4.3V7h7.23v1.34L16.09 15v.08h4.71V17z\"/>","width":24,"height":24},
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
v-model="state.search"
|
||||
type="text"
|
||||
class="fileman-search-input"
|
||||
:placeholder="t(`fileman.searchFolder`)"
|
||||
:aria-label="t(`fileman.searchFolder`)"
|
||||
:placeholder="searchPlaceholder"
|
||||
:aria-label="searchPlaceholder"
|
||||
autocomplete="off"
|
||||
@focus="state.searchIsFocused = true"
|
||||
@blur="state.searchIsFocused = false" />
|
||||
@@ -100,7 +100,11 @@
|
||||
|
||||
Narrower while it overlays, so there is a comfortable width of scrim left to tap on.
|
||||
-->
|
||||
<w-drawer class="fileman-left" v-model="treeDrawerOpen" :width="isTreeOverlay ? 300 : 350">
|
||||
<w-drawer
|
||||
class="fileman-left"
|
||||
v-model="treeDrawerOpen"
|
||||
:width="isTreeOverlay ? 300 : 350"
|
||||
:inert="state.isGraph">
|
||||
<div class="flex h-full flex-col">
|
||||
<w-scroll-area
|
||||
class="min-h-0 flex-1"
|
||||
@@ -129,6 +133,21 @@
|
||||
:display-mode="state.displayMode" />
|
||||
</div>
|
||||
</w-scroll-area>
|
||||
<!--
|
||||
The graph view, pinned with the Recycle Bin for the same reason: it is a way of looking at the
|
||||
whole locale rather than a place in it. Not while inserting -- the editor sent the reader here
|
||||
to pick a file, and the graph has no file to pick.
|
||||
-->
|
||||
<button
|
||||
v-if="!insertMode"
|
||||
type="button"
|
||||
class="fileman-bin w-unstyled"
|
||||
:class="{ 'is-active': state.isGraph }"
|
||||
:aria-pressed="state.isGraph"
|
||||
@click="openGraph">
|
||||
<w-icon name="la:project-diagram" size="sm" />
|
||||
<span class="fileman-bin-label">{{ t('fileman.graph.title') }}</span>
|
||||
</button>
|
||||
<!--
|
||||
Pinned under the tree rather than a node in it: it is not a place in the wiki, and it has to
|
||||
stay reachable however far the tree has been scrolled or expanded. Signed in only -- the
|
||||
@@ -148,7 +167,12 @@
|
||||
</button>
|
||||
</div>
|
||||
</w-drawer>
|
||||
<w-drawer class="fileman-right" :model-value="detailsPaneShown" :width="350" side="right">
|
||||
<w-drawer
|
||||
class="fileman-right"
|
||||
:model-value="detailsPaneShown"
|
||||
:width="350"
|
||||
side="right"
|
||||
:inert="state.isGraph">
|
||||
<div class="flex h-full flex-col">
|
||||
<w-scroll-area
|
||||
class="min-h-0 flex-1"
|
||||
@@ -229,7 +253,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</w-drawer>
|
||||
<w-page-container>
|
||||
<w-page-container :inert="state.isGraph">
|
||||
<!--
|
||||
Tapping this pane puts the tree panel away, which is the "tap outside to dismiss" the drawer's own
|
||||
scrim would normally provide. It cannot here: `WDrawer` teleports that scrim to <body> at z-30, and
|
||||
@@ -727,7 +751,7 @@
|
||||
</div>
|
||||
</w-page>
|
||||
</w-page-container>
|
||||
<w-footer>
|
||||
<w-footer :inert="state.isGraph">
|
||||
<w-bar class="fileman-path">
|
||||
<!--
|
||||
-> `flex-1` on the path is what puts the counts at the far end: `ms-auto` cannot, because
|
||||
@@ -746,6 +770,18 @@
|
||||
}}</small>
|
||||
</w-bar>
|
||||
</w-footer>
|
||||
<!--
|
||||
Over everything below the header, rather than in place of the list: the tree, the listing and the
|
||||
details stay mounted underneath, so going back to them is instant and finds them as they were.
|
||||
What it covers is `inert` meanwhile, so the keyboard cannot wander into it.
|
||||
-->
|
||||
<page-graph
|
||||
v-if="state.isGraph"
|
||||
class="fileman-graph"
|
||||
:locale="state.locale"
|
||||
:search="state.search"
|
||||
@close="closeGraph"
|
||||
@open="openGraphPage" />
|
||||
<input type="file" ref="fileIpt" multiple @change="uploadNewFiles" style="display: none" />
|
||||
</w-layout>
|
||||
</template>
|
||||
@@ -792,6 +828,10 @@ import FolderDeleteDialog from '@/components/FolderDeleteDialog.vue'
|
||||
import FolderRenameDialog from '@/components/FolderRenameDialog.vue'
|
||||
import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue'
|
||||
|
||||
// -> d3 and everything else the graph draws with is fetched the first time it is opened, not with the
|
||||
// file manager
|
||||
const PageGraph = defineAsyncComponent(() => import('@/components/PageGraph.vue'))
|
||||
|
||||
// COMPOSABLES
|
||||
|
||||
const dark = useDark()
|
||||
@@ -900,7 +940,12 @@ const state = reactive({
|
||||
/** The deleted pages this reader may recover, as `GET …/pages/deleted` answered, newest first. */
|
||||
binItems: [],
|
||||
/** Keyed by the deletion's version id, which is what every action on a bin entry works from. */
|
||||
binSelectedId: null
|
||||
binSelectedId: null,
|
||||
/**
|
||||
* Whether the graph view covers the manager. Independent of `isRecycleBin`: it goes over whichever
|
||||
* of the two was showing, and closing it comes back to that.
|
||||
*/
|
||||
isGraph: false
|
||||
})
|
||||
|
||||
// -> Over the defaults just above, which is what the view falls back to on a first visit
|
||||
@@ -991,6 +1036,11 @@ const folderPath = computed(() => {
|
||||
|
||||
const usePathTitle = computed(() => state.displayMode === 'path')
|
||||
|
||||
/** The search field finds nodes in the graph while it is open, and filters the folder otherwise. */
|
||||
const searchPlaceholder = computed(() =>
|
||||
state.isGraph ? t('fileman.graph.search') : t('fileman.searchFolder')
|
||||
)
|
||||
|
||||
const filteredFiles = computed(() => {
|
||||
if (state.search) {
|
||||
const fuse = new Fuse(state.fileList, {
|
||||
@@ -1259,6 +1309,27 @@ function openRecycleBin() {
|
||||
loadRecycleBin()
|
||||
}
|
||||
|
||||
/*
|
||||
The search is cleared going either way: what was typed to filter a folder means nothing to the
|
||||
graph, and a graph search left behind would silently filter the folder it returns to.
|
||||
*/
|
||||
function openGraph() {
|
||||
state.treeOpen = false
|
||||
state.search = ''
|
||||
state.isGraph = true
|
||||
}
|
||||
|
||||
function closeGraph() {
|
||||
state.search = ''
|
||||
state.isGraph = false
|
||||
}
|
||||
|
||||
/** A page picked in the graph: the same departure as opening one from the list. */
|
||||
function openGraphPage({ locale, path }) {
|
||||
router.push(`${siteStore.localeUrlPrefix(locale)}/${path}`)
|
||||
close()
|
||||
}
|
||||
|
||||
async function loadRecycleBin() {
|
||||
state.binLoading = true
|
||||
try {
|
||||
@@ -2596,6 +2667,17 @@ $fileman-bottom-row-height: 45px;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Placed by grid LINE rather than by area, so it spans the drawers, the page and the footer at once;
|
||||
see `WLayout` for the grid. Above the tree drawer, which is its own stacking context.
|
||||
*/
|
||||
&-graph {
|
||||
grid-row: 2 / 4;
|
||||
grid-column: 1 / 4;
|
||||
z-index: 5;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&-toolbar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,962 @@
|
||||
import { easeCubicInOut, easeCubicOut } from 'd3-ease'
|
||||
import { select } from 'd3-selection'
|
||||
import 'd3-transition'
|
||||
import { zoom, zoomIdentity } from 'd3-zoom'
|
||||
|
||||
import { searchModel } from './model.js'
|
||||
import { buildScene } from './scene.js'
|
||||
|
||||
/**
|
||||
* The page graph's renderer: one SVG, any view.
|
||||
*
|
||||
* Plain JavaScript over d3 rather than a Vue component, because what it draws is up to a few thousand
|
||||
* elements moved every frame of an animation, and a virtual DOM diffing them sixty times a second
|
||||
* would be the whole cost of the view. `PageGraph.vue` owns the chrome around it -- the toolbar, the
|
||||
* hover card, the breadcrumbs -- and talks to this through the object `createGraph` returns and the
|
||||
* callbacks it is given.
|
||||
*
|
||||
* What happens on each redraw, whichever view it is:
|
||||
*
|
||||
* - **Nodes move, they are not redrawn.** Every node is keyed by its scene id, so one that is in
|
||||
* both the old drawing and the new one travels from where it was. One that is new grows out of
|
||||
* the nearest ancestor that was already there -- which on the first draw is the root, so the tree
|
||||
* visibly branches outward, a level at a time.
|
||||
* - **Edges follow their nodes** while a layout of the same view changes, recomputed every frame from
|
||||
* where both ends are at that moment. Between two views they have no common form, so they fade,
|
||||
* the nodes glide, and they grow back in from each parent.
|
||||
* - **Links draw in last**, once everything has settled, swept round in the order they start.
|
||||
*
|
||||
* Reduced motion -- the operating system's or the reader's own setting -- skips every one of those to
|
||||
* its last frame. Nothing about what is drawn depends on having watched it arrive.
|
||||
*/
|
||||
|
||||
/** How long nodes take to reach a new layout, and how much later each level of a first draw starts. */
|
||||
const MOVE_DURATION = 700
|
||||
const LEVEL_STAGGER = 160
|
||||
/** Edges growing back in after a change of view. */
|
||||
const GROW_DURATION = 420
|
||||
const GROW_STAGGER = 70
|
||||
/** The longest a link waits for its turn to draw in; see `linkPhase`. */
|
||||
const LINK_SWEEP = 650
|
||||
const LINK_DRAW = 900
|
||||
/** Nothing is drawn smaller than this on screen, however far out the view is zoomed. */
|
||||
const SCALE_EXTENT = [0.02, 6]
|
||||
|
||||
let instanceCount = 0
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container Filled by the SVG, and measured for fitting.
|
||||
* @param {object} options
|
||||
* @param {object} options.labels Strings the engine draws: `graph`, `elsewhere`, `more(n)`, `locale(code)`.
|
||||
* @param {Function} options.reduceMotion Asked before every animation, so the setting applies at once.
|
||||
* @param {Function} options.onOpen Called with `{ locale, path }` for a page the reader chose.
|
||||
* @param {Function} options.onChange Called with the trail, the view and the counts whenever they change.
|
||||
* @param {Function} options.onHover Called with what is under the pointer or keyboard, or null.
|
||||
*/
|
||||
export function createGraph(container, options) {
|
||||
const uid = ++instanceCount
|
||||
const { labels } = options
|
||||
|
||||
const state = {
|
||||
model: null,
|
||||
view: null,
|
||||
focus: null,
|
||||
scene: null,
|
||||
layout: null,
|
||||
/** Folders whose "more" has been opened, and how many of their entries that shows. */
|
||||
allowance: new Map(),
|
||||
matches: new Set(),
|
||||
containsMatch: new Set(),
|
||||
/** Where each drawn node is -- in its view's own terms, and as a point -- and which view that is. */
|
||||
current: new Map(),
|
||||
currentView: null,
|
||||
animation: null,
|
||||
linkTimer: null,
|
||||
transform: zoomIdentity,
|
||||
activeId: null,
|
||||
highlightId: null,
|
||||
/** Short DOM ids for `aria-activedescendant`, since a scene id is a path. */
|
||||
domIds: new Map()
|
||||
}
|
||||
|
||||
// SVG ------------------------------------------------------------------
|
||||
|
||||
const svg = select(container)
|
||||
.append('svg')
|
||||
.attr('class', 'pg-svg')
|
||||
.attr('role', 'tree')
|
||||
.attr('tabindex', 0)
|
||||
.attr('aria-label', labels.graph)
|
||||
const viewport = svg.append('g').attr('class', 'pg-viewport')
|
||||
const edgesLayer = viewport.append('g').attr('class', 'pg-edges')
|
||||
const linksLayer = viewport.append('g').attr('class', 'pg-links')
|
||||
const nodesLayer = viewport.append('g').attr('class', 'pg-nodes')
|
||||
|
||||
const zoomer = zoom()
|
||||
.scaleExtent(SCALE_EXTENT)
|
||||
.on('zoom', (ev) => {
|
||||
state.transform = ev.transform
|
||||
viewport.attr('transform', ev.transform)
|
||||
applyZoomClasses()
|
||||
// -> A card left where a node used to be is worse than none; only for a gesture, since a fit
|
||||
// the engine runs itself is not the reader moving away
|
||||
if (ev.sourceEvent) {
|
||||
options.onHover(null)
|
||||
}
|
||||
})
|
||||
svg.call(zoomer).on('dblclick.zoom', null)
|
||||
|
||||
function applyZoomClasses() {
|
||||
const px = state.transform.k * (state.view?.fontSize ?? 11)
|
||||
svg.classed('is-far', px < 7).classed('is-farther', px < 3.5)
|
||||
}
|
||||
|
||||
// SCENE ----------------------------------------------------------------
|
||||
|
||||
function rebuild({ fit = true } = {}) {
|
||||
const view = state.view
|
||||
state.scene = buildScene(state.model, {
|
||||
focus: state.focus,
|
||||
budget: view.budget,
|
||||
maxChildren: view.maxChildren,
|
||||
allowance: state.allowance,
|
||||
withLinks: view.links,
|
||||
labels
|
||||
})
|
||||
state.layout = view.layout(state.scene)
|
||||
svg.attr('class', `pg-svg is-${view.key}`)
|
||||
applyZoomClasses()
|
||||
if (state.activeId && !state.scene.nodesById.has(state.activeId)) {
|
||||
state.activeId = null
|
||||
}
|
||||
if (state.highlightId && !state.scene.nodesById.has(state.highlightId)) {
|
||||
state.highlightId = null
|
||||
}
|
||||
const isFirst = state.current.size === 0
|
||||
draw()
|
||||
if (fit) {
|
||||
fitToScreen({ instant: isFirst })
|
||||
}
|
||||
emitChange()
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
const trail = []
|
||||
for (let node = state.focus; node; node = node.parent) {
|
||||
trail.unshift({ id: node.id, title: node.title })
|
||||
}
|
||||
options.onChange({
|
||||
trail,
|
||||
view: state.view.key,
|
||||
matchCount: state.matches.size,
|
||||
drawnCount: state.scene.nodesById.size
|
||||
})
|
||||
}
|
||||
|
||||
// DRAWING --------------------------------------------------------------
|
||||
|
||||
function radiusOf(d) {
|
||||
switch (d.kind) {
|
||||
case 'more':
|
||||
return 6
|
||||
case 'group':
|
||||
return 3
|
||||
case 'stub':
|
||||
return d.stub === 'locale' ? 7 : 3.5
|
||||
}
|
||||
if (d.collapsed) {
|
||||
return 7 + Math.min(9, Math.sqrt(d.hidden) * 1.2)
|
||||
}
|
||||
if (d.node === state.focus) {
|
||||
return 7
|
||||
}
|
||||
return d.node.children.length > 0 ? 5 : 4
|
||||
}
|
||||
|
||||
function labelOf(d) {
|
||||
let text
|
||||
switch (d.kind) {
|
||||
case 'more':
|
||||
return labels.more(d.hidden)
|
||||
case 'stub':
|
||||
text = d.stub === 'locale' ? `${d.title} (${d.hidden})` : `↗ ${d.title}`
|
||||
break
|
||||
default:
|
||||
text = d.title
|
||||
}
|
||||
const max = state.view.labelChars
|
||||
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text
|
||||
}
|
||||
|
||||
function countOf(d) {
|
||||
const n = d.kind === 'more' || d.collapsed ? d.hidden : 0
|
||||
if (!n) {
|
||||
return ''
|
||||
}
|
||||
return n > 999 ? `${Math.floor(n / 1000)}k` : String(n)
|
||||
}
|
||||
|
||||
function domIdOf(id) {
|
||||
let domId = state.domIds.get(id)
|
||||
if (!domId) {
|
||||
domId = `pg-${uid}-${state.domIds.size}`
|
||||
state.domIds.set(id, domId)
|
||||
}
|
||||
return domId
|
||||
}
|
||||
|
||||
/** Classes, glyph, label and accessible name: everything about a node but where it is. */
|
||||
function dressNode(el, hnode) {
|
||||
const d = hnode.data
|
||||
const page = d.node?.page
|
||||
const g = select(el)
|
||||
const isFolder = d.kind === 'node' && d.node.children.length > 0
|
||||
g.attr('id', domIdOf(d.id))
|
||||
.attr('class', 'pg-node')
|
||||
.classed(`pg-node--${d.kind}`, true)
|
||||
.classed('is-page', Boolean(page))
|
||||
.classed('is-folder', isFolder)
|
||||
.classed('is-collapsed', Boolean(d.collapsed))
|
||||
.classed('is-focus', d.node === state.focus && d.kind === 'node')
|
||||
.classed('is-draft', Boolean(page && !page.isPublished))
|
||||
.classed('is-actionable', defaultAction(d) !== null)
|
||||
.attr('role', 'treeitem')
|
||||
.attr('aria-level', hnode.depth + 1)
|
||||
.attr('aria-label', accessibleName(d))
|
||||
.attr('aria-expanded', isFolder ? String(!d.collapsed) : null)
|
||||
.style('--h', d.hue ?? 0)
|
||||
.style('--s', d.hue === null || d.hue === undefined ? '0%' : '62%')
|
||||
|
||||
const r = radiusOf(d)
|
||||
el.__radius = r
|
||||
g.select('.pg-hit').attr('r', Math.max(r + 5, 10))
|
||||
g.select('.pg-dot').attr('r', r)
|
||||
g.select('.pg-count').text(countOf(d))
|
||||
g.select('.pg-label').text(labelOf(d))
|
||||
}
|
||||
|
||||
function accessibleName(d) {
|
||||
const parts = [d.kind === 'more' ? labels.more(d.hidden) : d.title]
|
||||
if (d.collapsed || d.stub === 'locale') {
|
||||
parts.push(labels.pages(d.hidden))
|
||||
}
|
||||
if (d.node?.page && !d.node.page.isPublished) {
|
||||
parts.push(labels.unpublished)
|
||||
}
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
function applyLabel(el, spec, r) {
|
||||
el.setAttribute('transform', spec.rotate ? `rotate(${spec.rotate})` : '')
|
||||
el.setAttribute('text-anchor', spec.anchor)
|
||||
el.setAttribute('x', spec.side * (r + 5))
|
||||
el.setAttribute('y', spec.side === 0 ? r + 13 : 0)
|
||||
}
|
||||
|
||||
function lerp(from, to, t) {
|
||||
const out = {}
|
||||
for (const key in to) {
|
||||
const a = from[key] ?? to[key]
|
||||
out[key] = a + (to[key] - a) * t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const clamp01 = (t) => (t < 0 ? 0 : t > 1 ? 1 : t)
|
||||
|
||||
function stopAnimation() {
|
||||
state.animation?.cancel()
|
||||
state.animation = null
|
||||
clearTimeout(state.linkTimer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive `frame(elapsed)` until `duration` has passed, then call `done`.
|
||||
*
|
||||
* requestAnimationFrame rather than d3-transition: a transition tweens one attribute of one element,
|
||||
* and a frame here is every node and every edge computed together from the same moment.
|
||||
*/
|
||||
function run(duration, frame, done) {
|
||||
if (options.reduceMotion()) {
|
||||
frame(Infinity)
|
||||
done()
|
||||
return
|
||||
}
|
||||
const start = performance.now()
|
||||
let raf = null
|
||||
const tick = (now) => {
|
||||
const elapsed = now - start
|
||||
frame(elapsed)
|
||||
if (elapsed >= duration) {
|
||||
state.animation = null
|
||||
done()
|
||||
} else {
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
state.animation = { cancel: () => cancelAnimationFrame(raf) }
|
||||
}
|
||||
|
||||
function draw() {
|
||||
stopAnimation()
|
||||
const { scene, layout, view } = state
|
||||
const sameSpace = state.currentView === null || state.currentView === view.key
|
||||
const isFirst = state.current.size === 0
|
||||
const prev = state.current
|
||||
const hnodes = scene.root.descendants()
|
||||
const rootPos = layout.positions.get(scene.root.data.id)
|
||||
|
||||
// -> NODES
|
||||
const joined = nodesLayer.selectAll('g.pg-node').data(hnodes, (d) => d.data.id)
|
||||
joined.exit().each(function () {
|
||||
// -> Faded rather than removed, and only removed if it is still gone once the fade is over:
|
||||
// a node can come straight back in the next redraw
|
||||
this.classList.add('is-exiting')
|
||||
clearTimeout(this.__exitTimer)
|
||||
this.__exitTimer = setTimeout(() => this.remove(), 320)
|
||||
})
|
||||
const entered = joined.enter().append('g')
|
||||
entered.append('circle').attr('class', 'pg-hit')
|
||||
entered.append('circle').attr('class', 'pg-dot')
|
||||
entered
|
||||
.append('text')
|
||||
.attr('class', 'pg-count')
|
||||
.attr('dy', '0.35em')
|
||||
.attr('text-anchor', 'middle')
|
||||
entered.append('text').attr('class', 'pg-label').attr('dy', '0.32em')
|
||||
const nodes = entered.merge(joined)
|
||||
|
||||
const entries = []
|
||||
const entryById = new Map()
|
||||
nodes.each(function (hnode) {
|
||||
clearTimeout(this.__exitTimer)
|
||||
dressNode(this, hnode)
|
||||
const id = hnode.data.id
|
||||
const finalPos = layout.positions.get(id)
|
||||
const was = prev.get(id)
|
||||
let from = was ? (sameSpace ? was.pos : was.xy) : null
|
||||
if (!from) {
|
||||
// -> New: out of the nearest ancestor that was drawn before, or out of the root
|
||||
let ancestor = hnode.parent
|
||||
while (ancestor && !prev.has(ancestor.data.id)) {
|
||||
ancestor = ancestor.parent
|
||||
}
|
||||
const origin = ancestor ? prev.get(ancestor.data.id) : null
|
||||
if (origin) {
|
||||
from = sameSpace ? origin.pos : origin.xy
|
||||
} else {
|
||||
from = sameSpace ? rootPos : layout.place(rootPos)
|
||||
}
|
||||
}
|
||||
const entry = {
|
||||
id,
|
||||
el: this,
|
||||
label: this.querySelector('.pg-label'),
|
||||
hnode,
|
||||
finalPos,
|
||||
from,
|
||||
to: sameSpace ? finalPos : layout.place(finalPos),
|
||||
cur: from,
|
||||
entering: !was,
|
||||
delay: isFirst ? hnode.depth * LEVEL_STAGGER : 0,
|
||||
// -> On a first draw a node leaves from wherever its parent has got to, so each level
|
||||
// branches out of the one before it rather than every node flying out of the root
|
||||
parent: isFirst && hnode.parent ? entryById.get(hnode.parent.data.id) : null
|
||||
}
|
||||
entries.push(entry)
|
||||
entryById.set(id, entry)
|
||||
if (!sameSpace) {
|
||||
applyLabel(entry.label, layout.label(finalPos, hnode), this.__radius)
|
||||
}
|
||||
})
|
||||
|
||||
// -> EDGES
|
||||
const edgeSel = edgesLayer
|
||||
.selectAll('path.pg-edge')
|
||||
.data(layout.edges, (d) => d.id)
|
||||
.join('path')
|
||||
.attr('class', 'pg-edge')
|
||||
.each(function (edge) {
|
||||
const child = scene.nodesById.get(edge.child).data
|
||||
const page = child.node?.page
|
||||
this.classList.toggle('is-draft', Boolean(page && !page.isPublished))
|
||||
this.classList.toggle('is-stub', child.kind === 'stub' || child.kind === 'group')
|
||||
this.style.setProperty('--h', child.hue ?? 0)
|
||||
this.style.setProperty('--s', child.hue === null || child.hue === undefined ? '0%' : '62%')
|
||||
})
|
||||
const edges = []
|
||||
edgeSel.each(function (edge) {
|
||||
edges.push({
|
||||
el: this,
|
||||
parent: entryById.get(edge.parent),
|
||||
child: entryById.get(edge.child),
|
||||
depth: edge.depth
|
||||
})
|
||||
// -> Hidden until they grow back in after a change of view, and never left hidden by one that
|
||||
// was interrupted
|
||||
this.style.opacity = sameSpace ? '' : '0'
|
||||
})
|
||||
|
||||
// -> LINKS are drawn once everything has settled; what was there goes now
|
||||
linksLayer.selectAll('path.pg-link').classed('is-stale', true)
|
||||
|
||||
const maxDelay = isFirst ? scene.root.height * LEVEL_STAGGER : 0
|
||||
const live = new Map()
|
||||
const frame = (elapsed) => {
|
||||
for (const e of entries) {
|
||||
const t = easeCubicInOut(clamp01((elapsed - e.delay) / MOVE_DURATION))
|
||||
// -> Entries are breadth first, so a parent's position for this frame is already in hand
|
||||
e.cur = lerp(e.parent ? e.parent.cur : e.from, e.to, t)
|
||||
const xy = sameSpace ? layout.place(e.cur) : e.cur
|
||||
e.el.setAttribute('transform', `translate(${xy.x},${xy.y})`)
|
||||
if (sameSpace) {
|
||||
applyLabel(e.label, layout.label(e.cur, e.hnode), e.el.__radius)
|
||||
} else {
|
||||
e.label.style.opacity = String(clamp01((t - 0.65) / 0.35))
|
||||
}
|
||||
if (e.entering) {
|
||||
e.el.style.opacity = String(t)
|
||||
} else {
|
||||
e.el.style.opacity = ''
|
||||
}
|
||||
live.set(e.id, { pos: sameSpace ? e.cur : e.finalPos, xy })
|
||||
}
|
||||
if (sameSpace) {
|
||||
for (const edge of edges) {
|
||||
if (edge.parent && edge.child) {
|
||||
edge.el.setAttribute('d', layout.edgePath(edge.parent.cur, edge.child.cur))
|
||||
}
|
||||
}
|
||||
}
|
||||
state.current = live
|
||||
}
|
||||
|
||||
state.currentView = view.key
|
||||
run(MOVE_DURATION + maxDelay, frame, () => {
|
||||
for (const e of entries) {
|
||||
e.label.style.opacity = ''
|
||||
}
|
||||
if (sameSpace) {
|
||||
drawLinks()
|
||||
} else {
|
||||
growEdges(edges, () => drawLinks())
|
||||
}
|
||||
})
|
||||
applyHighlight()
|
||||
applySearch()
|
||||
}
|
||||
|
||||
/** After a change of view: every edge grows from its parent out to its child, a level at a time. */
|
||||
function growEdges(edges, done) {
|
||||
const { layout, scene } = state
|
||||
run(
|
||||
GROW_DURATION + scene.root.height * GROW_STAGGER,
|
||||
(elapsed) => {
|
||||
for (const edge of edges) {
|
||||
if (!edge.parent || !edge.child) {
|
||||
continue
|
||||
}
|
||||
const t = easeCubicOut(
|
||||
clamp01((elapsed - (edge.depth - 1) * GROW_STAGGER) / GROW_DURATION)
|
||||
)
|
||||
const from = edge.parent.finalPos
|
||||
edge.el.setAttribute('d', layout.edgePath(from, lerp(from, edge.child.finalPos, t)))
|
||||
edge.el.style.opacity = t > 0 ? '' : '0'
|
||||
}
|
||||
},
|
||||
done
|
||||
)
|
||||
}
|
||||
|
||||
function drawLinks() {
|
||||
const { layout, scene } = state
|
||||
linksLayer.selectAll('path.pg-link.is-stale').remove()
|
||||
if (!layout.linkPath) {
|
||||
return
|
||||
}
|
||||
const sel = linksLayer
|
||||
.selectAll('path.pg-link')
|
||||
.data(scene.links, (d) => d.id)
|
||||
.join('path')
|
||||
.attr('class', (d) => `pg-link pg-link--${d.kind}`)
|
||||
.attr('d', (d) => layout.linkPath(d))
|
||||
.style('stroke-width', (d) => (0.7 + Math.log2(d.weight) * 0.45).toFixed(2))
|
||||
.each(function (d) {
|
||||
const hue = scene.nodesById.get(d.source)?.data.hue
|
||||
this.style.setProperty('--h', hue ?? 0)
|
||||
this.style.setProperty('--s', hue === null || hue === undefined ? '0%' : '62%')
|
||||
})
|
||||
if (!options.reduceMotion()) {
|
||||
// -> `pathLength` normalises every path to one unit, so a single dash the length of the path can
|
||||
// be slid along it without measuring any of them. Taken off again once they have drawn, since
|
||||
// it would stretch the flowing dashes of a highlighted link to the length of the link
|
||||
sel
|
||||
.attr('pathLength', 1)
|
||||
.classed('is-drawing', true)
|
||||
.style(
|
||||
'animation-delay',
|
||||
(d) => `${Math.round((layout.linkPhase?.(d) ?? 0) * LINK_SWEEP)}ms`
|
||||
)
|
||||
state.linkTimer = setTimeout(
|
||||
() => {
|
||||
sel.attr('pathLength', null).classed('is-drawing', false).style('animation-delay', null)
|
||||
},
|
||||
LINK_SWEEP + LINK_DRAW + 50
|
||||
)
|
||||
}
|
||||
applyHighlight()
|
||||
}
|
||||
|
||||
// FITTING --------------------------------------------------------------
|
||||
|
||||
function fitToScreen({ instant = false } = {}) {
|
||||
const { layout, view } = state
|
||||
const { width, height } = container.getBoundingClientRect()
|
||||
if (!width || !height) {
|
||||
return
|
||||
}
|
||||
const b = layout.bounds
|
||||
const bw = Math.max(b.x1 - b.x0, 1)
|
||||
const bh = Math.max(b.y1 - b.y0, 1)
|
||||
const k = Math.max(
|
||||
Math.min((width * 0.92) / bw, (height * 0.92) / bh, 1.4),
|
||||
view.minFitScale ?? 0
|
||||
)
|
||||
// -> Centred along an axis it fits on. Along one it does not -- a view that stops shrinking at
|
||||
// `minFitScale` -- the ROOT is centred instead, which is where it is read from, but never so
|
||||
// far that blank space opens up before the first node or after the last
|
||||
const root = layout.place(layout.positions.get(state.scene.root.data.id))
|
||||
const place = (size, from, span, at) => {
|
||||
if (k * span <= size * 0.92) {
|
||||
return size / 2 - k * (from + span / 2)
|
||||
}
|
||||
const margin = 24
|
||||
const centred = size / 2 - k * at
|
||||
return Math.min(margin - k * from, Math.max(size - margin - k * (from + span), centred))
|
||||
}
|
||||
const tx = place(width, b.x0, bw, root.x)
|
||||
const ty = place(height, b.y0, bh, root.y)
|
||||
const target = zoomIdentity.translate(tx, ty).scale(k)
|
||||
if (instant || options.reduceMotion()) {
|
||||
svg.interrupt().call(zoomer.transform, target)
|
||||
} else {
|
||||
svg.transition().duration(MOVE_DURATION).ease(easeCubicInOut).call(zoomer.transform, target)
|
||||
}
|
||||
}
|
||||
|
||||
function zoomBy(factor) {
|
||||
const duration = options.reduceMotion() ? 0 : 250
|
||||
svg.transition().duration(duration).call(zoomer.scaleBy, factor)
|
||||
}
|
||||
|
||||
// HIGHLIGHT AND SEARCH -------------------------------------------------
|
||||
|
||||
/**
|
||||
* Light up one node and everything it is connected to: its links in and out, which flow toward and
|
||||
* away from it, the nodes at their far ends, and its line back to the focus.
|
||||
*/
|
||||
function applyHighlight() {
|
||||
const id = state.highlightId ?? state.activeId
|
||||
const connected = new Set()
|
||||
if (id) {
|
||||
connected.add(id)
|
||||
for (let hnode = state.scene.nodesById.get(id); hnode; hnode = hnode.parent) {
|
||||
connected.add(hnode.data.id)
|
||||
}
|
||||
}
|
||||
linksLayer.selectAll('path.pg-link').each(function (d) {
|
||||
const isOut = d.source === id
|
||||
const isIn = d.target === id
|
||||
this.classList.toggle('is-out', isOut)
|
||||
this.classList.toggle('is-in', isIn)
|
||||
if (isOut) {
|
||||
connected.add(d.target)
|
||||
} else if (isIn) {
|
||||
connected.add(d.source)
|
||||
}
|
||||
if (isOut || isIn) {
|
||||
// -> On top of the others, so a highlighted strand is not drawn under a hundred faded ones
|
||||
this.parentNode.appendChild(this)
|
||||
}
|
||||
})
|
||||
edgesLayer.selectAll('path.pg-edge').each(function (d) {
|
||||
this.classList.toggle(
|
||||
'is-trail',
|
||||
Boolean(id) && connected.has(d.child) && connected.has(d.parent)
|
||||
)
|
||||
})
|
||||
nodesLayer.selectAll('g.pg-node').each(function (hnode) {
|
||||
this.classList.toggle('is-linked', connected.has(hnode.data.id))
|
||||
this.classList.toggle('is-highlighted', hnode.data.id === id)
|
||||
this.classList.toggle('is-active', hnode.data.id === state.activeId)
|
||||
})
|
||||
svg.classed('is-highlighting', Boolean(id))
|
||||
svg.attr('aria-activedescendant', state.activeId ? domIdOf(state.activeId) : null)
|
||||
}
|
||||
|
||||
function applySearch() {
|
||||
const { matches, containsMatch } = state
|
||||
svg.classed('has-search', matches.size > 0)
|
||||
nodesLayer.selectAll('g.pg-node').each(function (hnode) {
|
||||
const d = hnode.data
|
||||
const id = d.kind === 'stub' || d.kind === 'more' ? d.node?.id : d.id
|
||||
this.classList.toggle('is-match', Boolean(id) && matches.has(id))
|
||||
this.classList.toggle(
|
||||
'is-contains-match',
|
||||
Boolean(d.collapsed || d.kind === 'more') && Boolean(d.node) && containsMatch.has(d.node.id)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ACTIONS --------------------------------------------------------------
|
||||
|
||||
/** What choosing a node does when nothing more specific is asked for. */
|
||||
function defaultAction(d) {
|
||||
switch (d.kind) {
|
||||
case 'node':
|
||||
if (d.node.page) {
|
||||
return 'open'
|
||||
}
|
||||
if (d.node === state.focus) {
|
||||
return d.node.parent ? 'up' : null
|
||||
}
|
||||
return 'zoom'
|
||||
case 'more':
|
||||
return 'more'
|
||||
case 'stub':
|
||||
if (d.stub === 'outside') {
|
||||
return d.node.children.length > 0 ? 'zoom' : 'open'
|
||||
}
|
||||
return d.stub === 'external' ? 'open' : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Everything choosing a node could do, for the hover card to offer. */
|
||||
function actionsFor(d) {
|
||||
const actions = []
|
||||
if (d.node?.page && d.kind !== 'more' && d.stub !== 'locale') {
|
||||
actions.push('open')
|
||||
}
|
||||
if (
|
||||
(d.kind === 'node' || d.stub === 'outside') &&
|
||||
d.node.children.length > 0 &&
|
||||
d.node !== state.focus
|
||||
) {
|
||||
actions.push('zoom')
|
||||
}
|
||||
if (d.kind === 'node' && d.node === state.focus && d.node.parent) {
|
||||
actions.push('up')
|
||||
}
|
||||
if (d.kind === 'more') {
|
||||
actions.push('more')
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
function activate(id, action) {
|
||||
const hnode = state.scene?.nodesById.get(id)
|
||||
if (!hnode) {
|
||||
return
|
||||
}
|
||||
const d = hnode.data
|
||||
switch (action ?? defaultAction(d)) {
|
||||
case 'open': {
|
||||
const page = d.node?.page
|
||||
if (page) {
|
||||
options.onOpen({ locale: page.locale, path: page.path })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'zoom':
|
||||
focusOn(d.node)
|
||||
break
|
||||
case 'up':
|
||||
if (state.focus.parent) {
|
||||
focusOn(state.focus.parent)
|
||||
}
|
||||
break
|
||||
case 'more': {
|
||||
const view = state.view
|
||||
state.allowance.set(
|
||||
d.node.id,
|
||||
(state.allowance.get(d.node.id) ?? view.maxChildren) + view.maxChildren
|
||||
)
|
||||
options.onHover(null)
|
||||
rebuild({ fit: false })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function focusOn(node) {
|
||||
if (!node || node === state.focus) {
|
||||
return
|
||||
}
|
||||
// -> The node zoomed out of stays the one the keyboard is on, so arrowing on continues from it
|
||||
const from = state.focus
|
||||
state.focus = node
|
||||
state.highlightId = null
|
||||
options.onHover(null)
|
||||
rebuild()
|
||||
const stayOn = state.scene.nodesById.has(from.id) ? from.id : node.id
|
||||
if (document.activeElement === svg.node()) {
|
||||
// -> Once the view has settled: a card placed now would be placed against a zoom still moving
|
||||
clearTimeout(state.activeTimer)
|
||||
state.activeTimer = setTimeout(
|
||||
() => setActive(stayOn),
|
||||
options.reduceMotion() ? 0 : MOVE_DURATION + 50
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POINTER --------------------------------------------------------------
|
||||
|
||||
/** Where a node ends up, which is where a card about it belongs even while it is still moving. */
|
||||
function finalXY(id) {
|
||||
const pos = state.layout?.positions.get(id)
|
||||
return pos ? state.layout.place(pos) : null
|
||||
}
|
||||
|
||||
function hoverInfo(id) {
|
||||
const hnode = state.scene.nodesById.get(id)
|
||||
const xy = finalXY(id)
|
||||
if (!hnode || !xy) {
|
||||
return null
|
||||
}
|
||||
const d = hnode.data
|
||||
const t = state.transform
|
||||
const r = (nodeElement(id)?.__radius ?? 4) * t.k
|
||||
let linksIn = 0
|
||||
let linksOut = 0
|
||||
for (const link of state.scene.links) {
|
||||
if (link.source === id) {
|
||||
linksOut += link.count
|
||||
} else if (link.target === id) {
|
||||
linksIn += link.count
|
||||
}
|
||||
}
|
||||
return {
|
||||
id,
|
||||
kind: d.kind,
|
||||
stub: d.stub ?? null,
|
||||
title: d.kind === 'more' ? labels.more(d.hidden) : d.title,
|
||||
locale: d.node?.page?.locale ?? d.locale ?? null,
|
||||
path: d.node?.page?.path ?? d.node?.path ?? '',
|
||||
isPage: Boolean(d.node?.page),
|
||||
isPublished: d.node?.page?.isPublished ?? true,
|
||||
isRedirect: d.node?.page?.isRedirect ?? false,
|
||||
pages:
|
||||
d.collapsed || d.kind === 'more' || d.stub === 'locale' ? d.hidden : (d.node?.size ?? 0),
|
||||
isCollapsed: Boolean(d.collapsed),
|
||||
hasLinks: state.view.links,
|
||||
linksIn,
|
||||
linksOut,
|
||||
actions: actionsFor(d),
|
||||
defaultAction: defaultAction(d),
|
||||
// -> In the container's own pixels, so the card can be placed without knowing about the zoom
|
||||
x: t.applyX(xy.x),
|
||||
y: t.applyY(xy.y),
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
function nodeElement(id) {
|
||||
return document.getElementById(domIdOf(id))
|
||||
}
|
||||
|
||||
function nodeIdOf(ev) {
|
||||
const el = ev.target.closest?.('g.pg-node')
|
||||
return el && !el.classList.contains('is-exiting') ? el.__data__?.data.id : null
|
||||
}
|
||||
|
||||
nodesLayer.on('click', (ev) => {
|
||||
const id = nodeIdOf(ev)
|
||||
if (id) {
|
||||
activate(id)
|
||||
}
|
||||
})
|
||||
nodesLayer.on('pointerover', (ev) => {
|
||||
const id = nodeIdOf(ev)
|
||||
if (!id || id === state.highlightId || state.animation) {
|
||||
return
|
||||
}
|
||||
state.highlightId = id
|
||||
applyHighlight()
|
||||
options.onHover(hoverInfo(id))
|
||||
})
|
||||
nodesLayer.on('pointerout', (ev) => {
|
||||
const id = nodeIdOf(ev)
|
||||
const to = ev.relatedTarget?.closest?.('g.pg-node')
|
||||
if (id && to?.__data__?.data.id !== id) {
|
||||
// -> The card decides when the highlight goes, since the pointer may be on its way into it
|
||||
options.onHover(null)
|
||||
}
|
||||
})
|
||||
|
||||
// KEYBOARD -------------------------------------------------------------
|
||||
|
||||
function setActive(id) {
|
||||
state.activeId = id
|
||||
state.highlightId = null
|
||||
applyHighlight()
|
||||
if (!id) {
|
||||
options.onHover(null)
|
||||
return
|
||||
}
|
||||
keepInView(id)
|
||||
options.onHover({ ...hoverInfo(id), fromKeyboard: true })
|
||||
}
|
||||
|
||||
/** Pan just enough to bring a node the keyboard moved to back on screen. */
|
||||
function keepInView(id) {
|
||||
const xy = finalXY(id)
|
||||
if (!xy) {
|
||||
return
|
||||
}
|
||||
const { width, height } = container.getBoundingClientRect()
|
||||
const x = state.transform.applyX(xy.x)
|
||||
const y = state.transform.applyY(xy.y)
|
||||
const margin = 60
|
||||
if (x < margin || x > width - margin || y < margin || y > height - margin) {
|
||||
const duration = options.reduceMotion() ? 0 : 300
|
||||
svg.transition().duration(duration).call(zoomer.translateTo, xy.x, xy.y)
|
||||
}
|
||||
}
|
||||
|
||||
svg.on('focus', () => {
|
||||
// -> A click focuses the SVG as well, and a mouse reader has not asked to be walked from the root
|
||||
if (!state.activeId && state.scene && svg.node().matches(':focus-visible')) {
|
||||
setActive(state.scene.root.data.id)
|
||||
}
|
||||
})
|
||||
svg.on('blur', () => {
|
||||
state.activeId = null
|
||||
applyHighlight()
|
||||
options.onHover(null)
|
||||
})
|
||||
svg.on('keydown', (ev) => {
|
||||
if (!state.scene) {
|
||||
return
|
||||
}
|
||||
const hnode = state.scene.nodesById.get(state.activeId) ?? state.scene.root
|
||||
const siblings = hnode.parent?.children ?? [hnode]
|
||||
const index = siblings.indexOf(hnode)
|
||||
let next = null
|
||||
switch (ev.key) {
|
||||
case 'ArrowDown':
|
||||
next = siblings[index + 1]
|
||||
break
|
||||
case 'ArrowUp':
|
||||
next = siblings[index - 1]
|
||||
break
|
||||
case 'ArrowRight':
|
||||
next = hnode.children?.[0]
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
next = hnode.parent
|
||||
break
|
||||
case 'Home':
|
||||
next = state.scene.root
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
activate(hnode.data.id)
|
||||
break
|
||||
case '+':
|
||||
case '=':
|
||||
if (actionsFor(hnode.data).includes('zoom')) {
|
||||
activate(hnode.data.id, 'zoom')
|
||||
}
|
||||
break
|
||||
case '-':
|
||||
case 'Backspace':
|
||||
activate(state.scene.root.data.id, 'up')
|
||||
break
|
||||
case 'Escape':
|
||||
// -> Only swallowed when there is something to let go of: otherwise it is the overlay's
|
||||
if (!state.activeId) {
|
||||
return
|
||||
}
|
||||
setActive(null)
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
if (next) {
|
||||
setActive(next.data.id)
|
||||
}
|
||||
})
|
||||
|
||||
// API ------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
setData(model, view) {
|
||||
stopAnimation()
|
||||
state.model = model
|
||||
state.view = view
|
||||
state.focus = model.root
|
||||
state.allowance = new Map()
|
||||
state.current = new Map()
|
||||
state.currentView = null
|
||||
state.activeId = null
|
||||
state.highlightId = null
|
||||
nodesLayer.selectAll('*').remove()
|
||||
edgesLayer.selectAll('*').remove()
|
||||
linksLayer.selectAll('*').remove()
|
||||
this.setSearch(state.query ?? '', { silent: true })
|
||||
rebuild()
|
||||
},
|
||||
setView(view) {
|
||||
if (view === state.view) {
|
||||
return
|
||||
}
|
||||
state.view = view
|
||||
options.onHover(null)
|
||||
if (state.model) {
|
||||
rebuild()
|
||||
}
|
||||
},
|
||||
focusOn(id) {
|
||||
const node = state.model?.nodes.get(id)
|
||||
if (node) {
|
||||
focusOn(node)
|
||||
}
|
||||
},
|
||||
activate,
|
||||
/** Let go of the highlight the pointer left behind, unless the keyboard has one of its own. */
|
||||
clearHover() {
|
||||
state.highlightId = null
|
||||
applyHighlight()
|
||||
},
|
||||
setSearch(query, { silent = false } = {}) {
|
||||
state.query = query
|
||||
if (!state.model) {
|
||||
return
|
||||
}
|
||||
state.matches = searchModel(state.model, query)
|
||||
state.containsMatch = new Set()
|
||||
for (const id of state.matches) {
|
||||
for (let node = state.model.nodes.get(id)?.parent; node; node = node.parent) {
|
||||
state.containsMatch.add(node.id)
|
||||
}
|
||||
}
|
||||
if (!silent && state.scene) {
|
||||
applySearch()
|
||||
emitChange()
|
||||
}
|
||||
},
|
||||
fit: () => fitToScreen(),
|
||||
zoomIn: () => zoomBy(1.4),
|
||||
zoomOut: () => zoomBy(1 / 1.4),
|
||||
destroy() {
|
||||
stopAnimation()
|
||||
clearTimeout(state.activeTimer)
|
||||
svg.interrupt()
|
||||
svg.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* The page graph as a tree of nodes, built once per fetch.
|
||||
*
|
||||
* `GET /sites/:siteId/tree/graph` answers with flat lists -- pages, the folders above them, links by
|
||||
* index -- and every view draws a hierarchy, so this is where one becomes the other. It is the whole
|
||||
* of the wiki's structure for one locale and is never drawn as it stands: `scene.js` cuts out what
|
||||
* fits on a screen, which is what lets a locale of fifty thousand pages cost no more to draw than one
|
||||
* of fifty.
|
||||
*
|
||||
* A node is a PATH, not a page: `guides` can be a page, the folder of pages under it, or both at
|
||||
* once, and in a tree those are one place with one name. `node.page` is set when there is a page
|
||||
* there, and `node.children` when there is anything under it.
|
||||
*/
|
||||
|
||||
export const ROOT_ID = 'root'
|
||||
|
||||
/**
|
||||
* The hues given to top-level sections that nobody has coloured, in order.
|
||||
*
|
||||
* Spread round the wheel so neighbours differ, and skipping the yellows, which have too little
|
||||
* contrast against a light background to carry a label beside them.
|
||||
*/
|
||||
const SECTION_HUES = [210, 155, 25, 275, 340, 190, 95, 250, 5, 125]
|
||||
|
||||
/**
|
||||
* The hue a folder colour actually renders as.
|
||||
*
|
||||
* `folderColors.js` stores a ROTATION of a yellow folder icon, so the colour a reader has seen on the
|
||||
* folder is that yellow turned by the stored angle -- 45 degrees being where the icon starts.
|
||||
*/
|
||||
const FOLDER_ICON_HUE = 45
|
||||
|
||||
function makeNode(fields) {
|
||||
return {
|
||||
id: fields.id,
|
||||
path: fields.path ?? '',
|
||||
title: fields.title ?? '',
|
||||
parent: fields.parent ?? null,
|
||||
children: [],
|
||||
depth: fields.parent ? fields.parent.depth + 1 : 0,
|
||||
/** The page at this path, when there is one: `{ locale, path, isPublished, isRedirect }`. */
|
||||
page: null,
|
||||
/** The folder colour set on this very folder, in rendered degrees. */
|
||||
ownHue: fields.ownHue ?? null,
|
||||
/** The colour it is drawn in: its own, or its section's. Null draws it neutral. */
|
||||
hue: null,
|
||||
/** How many pages are at or under this path. */
|
||||
size: 0,
|
||||
linksIn: 0,
|
||||
linksOut: 0,
|
||||
isExternal: false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} reply What the graph endpoint answered.
|
||||
* @param {object} opts
|
||||
* @param {string} opts.rootTitle What the node standing for the whole site is called.
|
||||
* @returns {object} The model: `root`, `nodes` (by id), `links`, `externals`, `truncated`.
|
||||
*/
|
||||
export function buildModel(reply, { rootTitle }) {
|
||||
const locale = reply.locale
|
||||
const nodes = new Map()
|
||||
const root = makeNode({ id: ROOT_ID, title: rootTitle })
|
||||
nodes.set(ROOT_ID, root)
|
||||
|
||||
const folders = new Map(reply.folders.map((folder) => [folder.path, folder]))
|
||||
|
||||
/** The node at a path of this locale, creating every folder above it on the way. */
|
||||
function nodeAt(path) {
|
||||
if (!path) {
|
||||
return root
|
||||
}
|
||||
const id = `n:${path}`
|
||||
let node = nodes.get(id)
|
||||
if (node) {
|
||||
return node
|
||||
}
|
||||
const slash = path.lastIndexOf('/')
|
||||
const parent = nodeAt(slash < 0 ? '' : path.slice(0, slash))
|
||||
const folder = folders.get(path)
|
||||
node = makeNode({
|
||||
id,
|
||||
path,
|
||||
// -> A path segment with no folder row of its own is named after the segment, which is what the
|
||||
// file manager's tree shows for it too
|
||||
title: folder?.title || path.slice(slash + 1),
|
||||
parent,
|
||||
ownHue: folder?.hue ? (FOLDER_ICON_HUE + folder.hue) % 360 : null
|
||||
})
|
||||
parent.children.push(node)
|
||||
nodes.set(id, node)
|
||||
return node
|
||||
}
|
||||
|
||||
// -> Index into `reply.pages` to the node it became, which is how links address pages
|
||||
const byIndex = []
|
||||
const externals = []
|
||||
reply.pages.forEach((page, index) => {
|
||||
const info = {
|
||||
id: page.id,
|
||||
locale: page.locale,
|
||||
path: page.path,
|
||||
isPublished: page.isPublished,
|
||||
isRedirect: page.isRedirect
|
||||
}
|
||||
if (page.locale === locale) {
|
||||
const node = nodeAt(page.path)
|
||||
node.page = info
|
||||
// -> The page's title wins over its folder's: one name for the one place, and the page is what
|
||||
// a reader would search for
|
||||
node.title = page.title || node.title
|
||||
byIndex[index] = node
|
||||
} else {
|
||||
// -> Another locale's page, here only because something in this one links to it. Not placed in
|
||||
// the tree: it has no folder of this locale to sit in
|
||||
const node = makeNode({ id: `x:${page.id}`, path: page.path, title: page.title })
|
||||
node.page = info
|
||||
node.isExternal = true
|
||||
node.locale = page.locale
|
||||
externals.push(node)
|
||||
nodes.set(node.id, node)
|
||||
byIndex[index] = node
|
||||
}
|
||||
})
|
||||
|
||||
const links = []
|
||||
for (const link of reply.links) {
|
||||
const source = byIndex[link.source]
|
||||
const target = byIndex[link.target]
|
||||
if (!source || !target) {
|
||||
continue
|
||||
}
|
||||
source.linksOut++
|
||||
target.linksIn++
|
||||
links.push({ source, target, kind: link.kind, weight: link.weight })
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
||||
let sectionIndex = 0
|
||||
;(function finish(node, hue) {
|
||||
node.children.sort((a, b) => collator.compare(a.title, b.title))
|
||||
if (node.depth === 1 && node.ownHue === null) {
|
||||
// -> Only a section with something in it takes a colour from the rotation, so a root full of
|
||||
// single pages does not use the palette up before the first real section
|
||||
hue = node.children.length > 0 ? SECTION_HUES[sectionIndex++ % SECTION_HUES.length] : null
|
||||
}
|
||||
node.hue = node.ownHue ?? hue
|
||||
node.size = node.page ? 1 : 0
|
||||
for (const child of node.children) {
|
||||
finish(child, node.hue)
|
||||
node.size += child.size
|
||||
}
|
||||
})(root, null)
|
||||
|
||||
return {
|
||||
locale,
|
||||
root,
|
||||
nodes,
|
||||
links,
|
||||
externals,
|
||||
pageCount: root.size,
|
||||
truncated: reply.truncated
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids of every node whose title or path contains the query, case and accents aside.
|
||||
*
|
||||
* A substring rather than a fuzzy match: this highlights, it does not rank, and a fuzzy matcher lights
|
||||
* up half a wiki on a three-letter query.
|
||||
*/
|
||||
export function searchModel(model, query) {
|
||||
const needle = fold(query.trim())
|
||||
const hits = new Set()
|
||||
if (!needle) {
|
||||
return hits
|
||||
}
|
||||
for (const node of model.nodes.values()) {
|
||||
if (node.id === ROOT_ID) {
|
||||
continue
|
||||
}
|
||||
if (fold(node.title).includes(needle) || fold(node.path).includes(needle)) {
|
||||
hits.add(node.id)
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
function fold(str) {
|
||||
return str
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.toLowerCase()
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { hierarchy } from 'd3-hierarchy'
|
||||
|
||||
/**
|
||||
* What of the model is drawn right now.
|
||||
*
|
||||
* The model is the whole locale; a scene is one screenful of it, and this is the only place that
|
||||
* decides which. That is what makes the graph cost the same at any size: every view draws at most
|
||||
* its `budget` of nodes, whatever the wiki holds, and the rest is folded into the nodes above it.
|
||||
*
|
||||
* Three ways a part of the tree is kept out, each drawn as something that can be asked for:
|
||||
*
|
||||
* - **A collapsed folder** is one node standing for everything under it, drawn with a count. Levels
|
||||
* are opened breadth first, the smallest folders of a level first, until the next would not fit —
|
||||
* so the shape of the whole tree is always visible, and detail fills in as far as the budget goes.
|
||||
* - **A long folder** shows its first `maxChildren` entries and a "more" node for the rest, since
|
||||
* one folder of three thousand pages would otherwise use the whole budget on one level.
|
||||
* - **Outside the focus.** Zoomed into a section, the rest of the wiki is not drawn -- but a link
|
||||
* crossing into it still is, to a stub naming the branch it goes to.
|
||||
*
|
||||
* Scene nodes wrap model nodes rather than being them: a collapsed folder and an expanded one are the
|
||||
* same model node and different scene nodes, and the stubs and "more" nodes have no model node at all.
|
||||
*/
|
||||
|
||||
/** A ceiling on drawn links, strongest kept. Past it a view is a solid disc of ink anyway. */
|
||||
const MAX_SCENE_LINKS = 4000
|
||||
|
||||
/** Past this many linked pages in one other locale, the locale is drawn as one stub for all of them. */
|
||||
const MAX_STUBS_PER_LOCALE = 40
|
||||
|
||||
/**
|
||||
* @param {object} model From `buildModel`.
|
||||
* @param {object} opts
|
||||
* @param {object} opts.focus The model node the scene is rooted at.
|
||||
* @param {number} opts.budget How many nodes the view draws at most.
|
||||
* @param {number} opts.maxChildren How many entries of one folder are shown before "more".
|
||||
* @param {Map<string, number>} opts.allowance Folders whose "more" has been opened, with how many.
|
||||
* @param {boolean} opts.withLinks Whether to gather links, and the stubs their far ends need.
|
||||
* @param {object} opts.labels `elsewhere` (the group of stubs outside the focus) and `locale(code)`.
|
||||
*/
|
||||
export function buildScene(model, { focus, budget, maxChildren, allowance, withLinks, labels }) {
|
||||
// -> BREADTH FIRST: which folders are opened, and how many of each one's entries are shown
|
||||
const expanded = new Map()
|
||||
let count = 1
|
||||
let frontier = [focus]
|
||||
while (frontier.length > 0) {
|
||||
const next = []
|
||||
const candidates = frontier
|
||||
.filter((node) => node.children.length > 0)
|
||||
.sort((a, b) => a.children.length - b.children.length)
|
||||
for (const node of candidates) {
|
||||
const shown = Math.min(node.children.length, allowance.get(node.id) ?? maxChildren)
|
||||
const cost = shown + (shown < node.children.length ? 1 : 0)
|
||||
// -> The focus always opens: a section zoomed into and drawn as a single bubble would be a
|
||||
// click that did nothing
|
||||
if (node !== focus && count + cost > budget) {
|
||||
// -> Sorted smallest first, so nothing after this one on the level fits either
|
||||
break
|
||||
}
|
||||
expanded.set(node, shown)
|
||||
count += cost
|
||||
next.push(...node.children.slice(0, shown))
|
||||
}
|
||||
frontier = next
|
||||
}
|
||||
|
||||
const byId = new Map()
|
||||
function build(node) {
|
||||
const shown = expanded.get(node)
|
||||
const item = {
|
||||
id: node.id,
|
||||
kind: 'node',
|
||||
node,
|
||||
title: node.title,
|
||||
collapsed: shown === undefined && node.children.length > 0,
|
||||
// -> For a collapsed folder, the pages it stands for
|
||||
hidden: shown === undefined ? node.size - (node.page ? 1 : 0) : 0,
|
||||
hue: node.hue
|
||||
}
|
||||
byId.set(item.id, item)
|
||||
if (shown !== undefined) {
|
||||
item.children = node.children.slice(0, shown).map(build)
|
||||
if (shown < node.children.length) {
|
||||
const more = {
|
||||
id: `more:${node.id}`,
|
||||
kind: 'more',
|
||||
node,
|
||||
title: '',
|
||||
hidden: node.children.length - shown,
|
||||
hue: node.hue
|
||||
}
|
||||
byId.set(more.id, more)
|
||||
item.children.push(more)
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
const top = build(focus)
|
||||
|
||||
const links = withLinks ? gatherLinks(model, { focus, top, byId, expanded, labels }) : []
|
||||
|
||||
const root = hierarchy(top)
|
||||
const nodesById = new Map()
|
||||
for (const hnode of root.descendants()) {
|
||||
nodesById.set(hnode.data.id, hnode)
|
||||
}
|
||||
return { root, nodesById, links, focus }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every link of the model, folded onto what the scene draws.
|
||||
*
|
||||
* Each end is moved to the nearest thing drawn for it: the page itself, the collapsed folder or the
|
||||
* "more" node it is hidden in, or -- outside the focus -- a stub for the branch it is in. Two links
|
||||
* landing on the same pair become one, weighted by both, which is what keeps a collapsed section's
|
||||
* links from being a hundred lines on top of one another.
|
||||
*/
|
||||
function gatherLinks(model, { focus, top, byId, expanded, labels }) {
|
||||
const aboveFocus = new Set()
|
||||
for (let node = focus.parent; node; node = node.parent) {
|
||||
aboveFocus.add(node.id)
|
||||
}
|
||||
|
||||
const outside = new Map()
|
||||
const external = new Map()
|
||||
|
||||
/** Where one end of a link is drawn, or null for a branch-outside stub not yet made. */
|
||||
function drawnAt(node) {
|
||||
if (node.isExternal) {
|
||||
return { external: node }
|
||||
}
|
||||
let current = node
|
||||
let below = null
|
||||
while (current) {
|
||||
const item = byId.get(current.id)
|
||||
if (item) {
|
||||
// -> Arrived at an expanded folder from an entry it does not show: that entry is under "more"
|
||||
if (below && expanded.has(current)) {
|
||||
return { item: byId.get(`more:${current.id}`) ?? item }
|
||||
}
|
||||
return { item }
|
||||
}
|
||||
if (aboveFocus.has(current.id)) {
|
||||
// -> Outside the focus: named after the branch leading to it from where the two meet, or
|
||||
// after the node itself when it is one of the focus's own ancestors
|
||||
return { outside: below ?? current }
|
||||
}
|
||||
below = current
|
||||
current = current.parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const pending = []
|
||||
for (const link of model.links) {
|
||||
const from = drawnAt(link.source)
|
||||
const to = drawnAt(link.target)
|
||||
if (!from || !to || (!from.item && !to.item)) {
|
||||
// -> Neither end in the section: nothing to draw it between
|
||||
continue
|
||||
}
|
||||
for (const end of [from, to]) {
|
||||
if (end.outside) {
|
||||
outside.set(end.outside.id, end.outside)
|
||||
} else if (end.external) {
|
||||
const perLocale = external.get(end.external.locale) ?? new Map()
|
||||
perLocale.set(end.external.id, end.external)
|
||||
external.set(end.external.locale, perLocale)
|
||||
}
|
||||
}
|
||||
pending.push({ from, to, link })
|
||||
}
|
||||
|
||||
// -> The stubs, each group appended to the focus's own entries so that it takes an arc of its own at
|
||||
// the rim rather than landing among the section's pages
|
||||
const groups = []
|
||||
if (outside.size > 0) {
|
||||
const group = { id: 'group:outside', kind: 'group', title: labels.elsewhere, hue: null }
|
||||
group.children = [...outside.values()].map((node) => {
|
||||
const stub = {
|
||||
id: `out:${node.id}`,
|
||||
kind: 'stub',
|
||||
stub: 'outside',
|
||||
node,
|
||||
title: node.title,
|
||||
hue: node.hue
|
||||
}
|
||||
byId.set(stub.id, stub)
|
||||
return stub
|
||||
})
|
||||
byId.set(group.id, group)
|
||||
groups.push(group)
|
||||
}
|
||||
for (const [locale, pages] of [...external.entries()].sort()) {
|
||||
const group = {
|
||||
id: `group:locale:${locale}`,
|
||||
kind: 'group',
|
||||
locale,
|
||||
title: labels.locale(locale),
|
||||
hue: null
|
||||
}
|
||||
if (pages.size > MAX_STUBS_PER_LOCALE) {
|
||||
// -> One stub for the whole locale: forty names round the rim already reads as a wall
|
||||
group.kind = 'stub'
|
||||
group.stub = 'locale'
|
||||
group.hidden = pages.size
|
||||
} else {
|
||||
group.children = [...pages.values()].map((node) => {
|
||||
const stub = {
|
||||
id: `ext:${node.id}`,
|
||||
kind: 'stub',
|
||||
stub: 'external',
|
||||
node,
|
||||
locale,
|
||||
title: node.title,
|
||||
hue: null
|
||||
}
|
||||
byId.set(stub.id, stub)
|
||||
return stub
|
||||
})
|
||||
}
|
||||
byId.set(group.id, group)
|
||||
groups.push(group)
|
||||
}
|
||||
if (groups.length > 0) {
|
||||
top.children = [...(top.children ?? []), ...groups]
|
||||
}
|
||||
|
||||
const resolveEnd = (end) => {
|
||||
if (end.item) {
|
||||
return end.item
|
||||
}
|
||||
if (end.outside) {
|
||||
return byId.get(`out:${end.outside.id}`)
|
||||
}
|
||||
return byId.get(`ext:${end.external.id}`) ?? byId.get(`group:locale:${end.external.locale}`)
|
||||
}
|
||||
|
||||
const merged = new Map()
|
||||
const KIND_RANK = { link: 0, relation: 1, redirect: 2 }
|
||||
for (const { from, to, link } of pending) {
|
||||
const source = resolveEnd(from)
|
||||
const target = resolveEnd(to)
|
||||
if (!source || !target || source === target) {
|
||||
continue
|
||||
}
|
||||
const id = `${source.id}>${target.id}`
|
||||
const edge = merged.get(id)
|
||||
if (edge) {
|
||||
edge.weight += link.weight
|
||||
edge.count++
|
||||
if (KIND_RANK[link.kind] > KIND_RANK[edge.kind]) {
|
||||
edge.kind = link.kind
|
||||
}
|
||||
} else {
|
||||
merged.set(id, {
|
||||
id,
|
||||
source: source.id,
|
||||
target: target.id,
|
||||
kind: link.kind,
|
||||
weight: link.weight,
|
||||
count: 1
|
||||
})
|
||||
}
|
||||
}
|
||||
const links = [...merged.values()]
|
||||
if (links.length > MAX_SCENE_LINKS) {
|
||||
links.sort((a, b) => b.weight - a.weight)
|
||||
links.length = MAX_SCENE_LINKS
|
||||
}
|
||||
return links
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import radial from './radial.js'
|
||||
import relational from './relational.js'
|
||||
import tree from './tree.js'
|
||||
|
||||
/**
|
||||
* Every way the graph can be drawn, in the order the switcher offers them.
|
||||
*
|
||||
* A view is a layout and nothing else: given a scene it says where each node goes, how an edge
|
||||
* between two positions is drawn, which way a label runs, and -- if it draws links -- the path of
|
||||
* each. Drawing, animating, zooming, hovering and the keyboard are the engine's (`engine.js`), the
|
||||
* same for every view. So a new view is a file exporting the shape below and a line here:
|
||||
*
|
||||
* key, icon, labelKey identity, and how the switcher shows it
|
||||
* budget, maxChildren how much of the tree it can draw at once (`scene.js`)
|
||||
* links whether the scene should gather links and their stubs
|
||||
* labelChars, fontSize how long a label may run, and how big it is drawn
|
||||
* minFitScale optional -- stop shrinking to fit below this, and start from the top left
|
||||
* layout(scene) -> { positions, edges, place, label, edgePath, bounds, linkPath?, linkPhase? }
|
||||
*
|
||||
* Positions are whatever the view interpolates in -- `{ x, y }` for the tree, `{ a, r }` for the
|
||||
* radial ones -- and `place` turns one into a point. Moving between two layouts of the SAME view
|
||||
* interpolates positions, so nodes travel the view's own way; moving between views interpolates the
|
||||
* points, since the two have nothing else in common.
|
||||
*/
|
||||
export const GRAPH_VIEWS = [tree, radial, relational]
|
||||
|
||||
/**
|
||||
* What the graph opens in until somebody picks another view. Named rather than taken from the order
|
||||
* above, which is the order the switcher reads in and a separate question.
|
||||
*/
|
||||
export const DEFAULT_GRAPH_VIEW = radial
|
||||
|
||||
/** The view with this key, or the default for a key that is not one -- absent, or no longer a view. */
|
||||
export function graphView(key) {
|
||||
return GRAPH_VIEWS.find((view) => view.key === key) ?? DEFAULT_GRAPH_VIEW
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { tree } from 'd3-hierarchy'
|
||||
import { linkRadial } from 'd3-shape'
|
||||
import { boundsOf, polarToXY, radialLabel, radialLabelEnd } from './shared.js'
|
||||
|
||||
/**
|
||||
* Hierarchical Radial: the same tree as the Hierarchical Tree, wrapped round its root.
|
||||
*
|
||||
* Positions are kept as an angle and a radius rather than as a point, so that a node moving between
|
||||
* two layouts of this view swings round the centre instead of cutting across it.
|
||||
*/
|
||||
|
||||
/** Arc length each leaf is given on the outer ring, and the gap between rings. */
|
||||
const LEAF_ARC = 16
|
||||
const RING = 140
|
||||
|
||||
const edge = linkRadial()
|
||||
.angle((pos) => pos.a)
|
||||
.radius((pos) => pos.r)
|
||||
|
||||
export default {
|
||||
key: 'radial',
|
||||
icon: 'mdi:radar',
|
||||
labelKey: 'fileman.graph.viewRadial',
|
||||
budget: 700,
|
||||
maxChildren: 250,
|
||||
links: false,
|
||||
labelChars: 28,
|
||||
fontSize: 11,
|
||||
|
||||
layout(scene) {
|
||||
const { root } = scene
|
||||
const leaves = root.leaves().length
|
||||
const radius = Math.max(RING * Math.max(root.height, 1), (leaves * LEAF_ARC) / (2 * Math.PI))
|
||||
|
||||
tree()
|
||||
.size([2 * Math.PI, radius])
|
||||
.separation((a, b) => (a.parent === b.parent ? 1 : 2) / a.depth)(root)
|
||||
|
||||
const positions = new Map()
|
||||
const edges = []
|
||||
for (const node of root.descendants()) {
|
||||
positions.set(node.data.id, { a: node.x, r: node.y })
|
||||
if (node.parent) {
|
||||
edges.push({
|
||||
id: node.data.id,
|
||||
parent: node.parent.data.id,
|
||||
child: node.data.id,
|
||||
depth: node.depth
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
positions,
|
||||
edges,
|
||||
place: polarToXY,
|
||||
label: (pos, node) => radialLabel(pos, node.depth === 0),
|
||||
edgePath: (s, t) => edge({ source: s, target: t }),
|
||||
bounds: boundsOf(root, positions, polarToXY, {
|
||||
chars: this.labelChars,
|
||||
labelEnd: radialLabelEnd
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { cluster } from 'd3-hierarchy'
|
||||
import { curveBundle, lineRadial, linkRadial } from 'd3-shape'
|
||||
import { boundsOf, polarToXY, radialLabel, radialLabelEnd } from './shared.js'
|
||||
|
||||
/**
|
||||
* Relational Radial: every page on one ring, and the links between them drawn across it.
|
||||
*
|
||||
* Hierarchical edge bundling -- each link is routed through the folders both of its ends sit in, so
|
||||
* links between two sections run together as one visible strand instead of a web. The folders are
|
||||
* where the bundles bend, so they are drawn too, faintly, inside the ring.
|
||||
*
|
||||
* The scene appends its stubs to the focus as groups of their own, which is what puts them on an arc
|
||||
* of the ring apart from the section's pages; they are then pushed out past the ring, so a link
|
||||
* leaving the section is seen to leave it.
|
||||
*/
|
||||
|
||||
const LEAF_ARC = 14
|
||||
const MIN_RADIUS = 220
|
||||
/** How far past the ring a stub sits. */
|
||||
const STUB_OFFSET = 26
|
||||
|
||||
const bundle = lineRadial()
|
||||
.curve(curveBundle.beta(0.85))
|
||||
.angle((pos) => pos.a)
|
||||
.radius((pos) => pos.r)
|
||||
|
||||
const edge = linkRadial()
|
||||
.angle((pos) => pos.a)
|
||||
.radius((pos) => pos.r)
|
||||
|
||||
export default {
|
||||
key: 'relational',
|
||||
icon: 'mdi:graph-outline',
|
||||
labelKey: 'fileman.graph.viewRelational',
|
||||
budget: 800,
|
||||
maxChildren: 300,
|
||||
links: true,
|
||||
labelChars: 28,
|
||||
fontSize: 10.5,
|
||||
|
||||
layout(scene) {
|
||||
const { root, nodesById } = scene
|
||||
const leaves = root.leaves().length
|
||||
const radius = Math.max(MIN_RADIUS, (leaves * LEAF_ARC) / (2 * Math.PI))
|
||||
|
||||
cluster()
|
||||
.size([2 * Math.PI, radius])
|
||||
.separation((a, b) => (a.parent === b.parent ? 1 : 2))(root)
|
||||
|
||||
const positions = new Map()
|
||||
const edges = []
|
||||
for (const node of root.descendants()) {
|
||||
const isStub = node.data.kind === 'stub'
|
||||
positions.set(node.data.id, { a: node.x, r: node.y + (isStub ? STUB_OFFSET : 0) })
|
||||
if (node.parent) {
|
||||
edges.push({
|
||||
id: node.data.id,
|
||||
parent: node.parent.data.id,
|
||||
child: node.data.id,
|
||||
depth: node.depth
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
positions,
|
||||
edges,
|
||||
place: polarToXY,
|
||||
label: (pos, node) => radialLabel(pos, node.depth === 0),
|
||||
edgePath: (s, t) => edge({ source: s, target: t }),
|
||||
linkPath(link) {
|
||||
const source = nodesById.get(link.source)
|
||||
const target = nodesById.get(link.target)
|
||||
return bundle(source.path(target).map((node) => positions.get(node.data.id)))
|
||||
},
|
||||
/** Where a link starts round the ring, as a fraction of a turn: it sets when the link draws in. */
|
||||
linkPhase(link) {
|
||||
const a = positions.get(link.source)?.a ?? 0
|
||||
return a / (2 * Math.PI)
|
||||
},
|
||||
bounds: boundsOf(root, positions, polarToXY, {
|
||||
chars: this.labelChars,
|
||||
labelEnd: radialLabelEnd
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Geometry the views have in common.
|
||||
*/
|
||||
|
||||
/** Roughly how wide a character of a label is, in world units at the size the views draw them. */
|
||||
const CHAR_WIDTH = 6.5
|
||||
|
||||
/** Where a node sits in polar coordinates, as the two radial views keep it. Angle 0 is twelve o'clock. */
|
||||
export function polarToXY(pos) {
|
||||
return { x: pos.r * Math.sin(pos.a), y: -pos.r * Math.cos(pos.a) }
|
||||
}
|
||||
|
||||
/**
|
||||
* How a label runs out from a node on a radial layout: along the radius, turned over on the left half
|
||||
* so that it never reads upside down.
|
||||
*/
|
||||
export function radialLabel(pos, isCenter) {
|
||||
if (isCenter) {
|
||||
return { rotate: 0, side: 0, anchor: 'middle' }
|
||||
}
|
||||
const degrees = (pos.a * 180) / Math.PI - 90
|
||||
// -> Normalised first, since an angle being interpolated between two layouts can leave [0, 2π)
|
||||
const turn = ((pos.a % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)
|
||||
return turn > Math.PI
|
||||
? { rotate: degrees + 180, side: -1, anchor: 'end' }
|
||||
: { rotate: degrees, side: 1, anchor: 'start' }
|
||||
}
|
||||
|
||||
/**
|
||||
* The box everything is drawn in, labels included, for fitting the view to the screen.
|
||||
*
|
||||
* Labels are measured by character count rather than by the DOM: the layout runs before anything is
|
||||
* drawn, and a fit that is out by a few pixels is not worth a synchronous reflow per label.
|
||||
*
|
||||
* @param {Function} labelEnd Where a label of a given width, run out from a node, ends.
|
||||
*/
|
||||
export function boundsOf(root, positions, place, { chars, labelEnd }) {
|
||||
let x0 = Infinity
|
||||
let y0 = Infinity
|
||||
let x1 = -Infinity
|
||||
let y1 = -Infinity
|
||||
const take = ({ x, y }) => {
|
||||
x0 = Math.min(x0, x)
|
||||
x1 = Math.max(x1, x)
|
||||
y0 = Math.min(y0, y)
|
||||
y1 = Math.max(y1, y)
|
||||
}
|
||||
for (const node of root.descendants()) {
|
||||
const pos = positions.get(node.data.id)
|
||||
const width = Math.min(node.data.title?.length ?? 0, chars) * CHAR_WIDTH + 16
|
||||
const at = place(pos)
|
||||
take({ x: at.x - 16, y: at.y - 16 })
|
||||
take({ x: at.x + 16, y: at.y + 16 })
|
||||
take(labelEnd(pos, width, node))
|
||||
}
|
||||
if (!Number.isFinite(x0)) {
|
||||
return { x0: -100, y0: -100, x1: 100, y1: 100 }
|
||||
}
|
||||
return { x0, y0, x1, y1 }
|
||||
}
|
||||
|
||||
/** `labelEnd` for the radial layouts: out along the radius, or under the node at the centre. */
|
||||
export function radialLabelEnd(pos, width, node) {
|
||||
if (node.depth === 0) {
|
||||
return { x: 0, y: 30 }
|
||||
}
|
||||
return polarToXY({ a: pos.a, r: pos.r + width })
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { tree } from 'd3-hierarchy'
|
||||
import { boundsOf } from './shared.js'
|
||||
|
||||
/**
|
||||
* Hierarchical Tree: the site root on the left, each level a column to the right of the last.
|
||||
*
|
||||
* The one view whose labels never rotate, which makes it the one to read titles in -- and so the one
|
||||
* that stops shrinking to fit: past `minFitScale` it is started from its root, top left, and the rest is
|
||||
* panned to, rather than scaled down to a column of dots.
|
||||
*/
|
||||
|
||||
const ROW = 24
|
||||
const COLUMN = 240
|
||||
|
||||
export default {
|
||||
key: 'tree',
|
||||
icon: 'mdi:file-tree-outline',
|
||||
labelKey: 'fileman.graph.viewTree',
|
||||
budget: 500,
|
||||
maxChildren: 200,
|
||||
links: false,
|
||||
labelChars: 34,
|
||||
fontSize: 12,
|
||||
minFitScale: 0.8,
|
||||
|
||||
layout(scene) {
|
||||
const { root } = scene
|
||||
tree()
|
||||
.nodeSize([ROW, COLUMN])
|
||||
.separation((a, b) => (a.parent === b.parent ? 1 : 1.3))(root)
|
||||
|
||||
const positions = new Map()
|
||||
const edges = []
|
||||
for (const node of root.descendants()) {
|
||||
// -> d3 lays a tree out top-down; turned on its side, its x is our y
|
||||
positions.set(node.data.id, { x: node.y, y: node.x })
|
||||
if (node.parent) {
|
||||
edges.push({
|
||||
id: node.data.id,
|
||||
parent: node.parent.data.id,
|
||||
child: node.data.id,
|
||||
depth: node.depth
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
positions,
|
||||
edges,
|
||||
place: (pos) => pos,
|
||||
label: () => ({ rotate: 0, side: 1, anchor: 'start' }),
|
||||
edgePath(s, t) {
|
||||
const mid = (s.x + t.x) / 2
|
||||
return `M${s.x},${s.y}C${mid},${s.y} ${mid},${t.y} ${t.x},${t.y}`
|
||||
},
|
||||
bounds: boundsOf(root, positions, (pos) => pos, {
|
||||
chars: this.labelChars,
|
||||
labelEnd: (pos, width) => ({ x: pos.x + width, y: pos.y })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,7 +242,7 @@ const state = reactive({
|
||||
search: '',
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
totalPages: 15
|
||||
totalPages: 1
|
||||
})
|
||||
|
||||
/*
|
||||
|
||||
Reference in new issue
Block a user