refactor: simplify tree class + add typing

This commit is contained in:
axolotle 2024-07-05 15:44:57 +02:00
parent cdaf8a7bcb
commit f9ddae3237
3 changed files with 103 additions and 110 deletions

View file

@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Obj } from '@/types/commons' import type { TreeChildNode, AnyTreeNode } from '@/helpers/data/tree'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
tree: Obj tree: AnyTreeNode
flush?: boolean flush?: boolean
last?: boolean last?: boolean
toggleText?: string toggleText?: string
@ -16,10 +16,14 @@ const props = withDefaults(
) )
defineSlots<{ defineSlots<{
default: (props: any) => any default: (props: {
[K in keyof TreeChildNode as TreeChildNode[K] extends Function
? never
: K]: TreeChildNode[K]
}) => any
}>() }>()
function getClasses(node: Obj, i: number) { function getClasses(node: AnyTreeNode, i: number) {
const children = node.height > 0 const children = node.height > 0
const opened = children && node.data?.opened const opened = children && node.data?.opened
const last = const last =

View file

@ -1,15 +1,23 @@
import type { RouteLocationRaw } from 'vue-router'
type TreeNodeData = {
name: string
parent: string | null
to: RouteLocationRaw
opened: boolean
}
/** /**
* A Node that can have a parent and children. * A Node that can have a parent and children.
*/ */
export class Node { class TreeNode {
constructor(data) { data: TreeNodeData | null = null
this.data = data depth: number = 0
this.depth = 0 height: number = 0
this.height = 0 parent: AnyTreeNode | null = null
this.parent = null id: string = 'root'
// this.id = null children: TreeChildNode[] = []
// this.children = null _remove: boolean = false
}
/** /**
* Invokes the specified `callback` for this node and each descendant in pre-order * Invokes the specified `callback` for this node and each descendant in pre-order
@ -18,17 +26,17 @@ export class Node {
* The specified function is passed the current descendant, the zero-based traversal * The specified function is passed the current descendant, the zero-based traversal
* index, and this node. * index, and this node.
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/eachBefore.js. * Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/eachBefore.js.
*
* @param {function} callback
* @return {Object}
*/ */
eachBefore(callback) { eachBefore(
const nodes = [] callback: (node: AnyTreeNode, index: number, root: TreeRootNode) => void,
) {
const root = this as TreeRootNode
const nodes: AnyTreeNode[] = []
let index = -1 let index = -1
let node = this let node: AnyTreeNode | undefined = root
while (node) { while (node) {
callback(node, ++index, this) callback(node, ++index, root)
if (node.children) { if (node.children) {
nodes.push(...node.children) nodes.push(...node.children)
} }
@ -45,14 +53,14 @@ export class Node {
* The specified function is passed the current descendant, the zero-based traversal * The specified function is passed the current descendant, the zero-based traversal
* index, and this node. * index, and this node.
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/eachAfter.js. * Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/eachAfter.js.
*
* @param {function} callback
* @return {Object}
*/ */
eachAfter(callback) { eachAfter(
const nodes = [] callback: (node: AnyTreeNode, index: number, root: TreeRootNode) => void,
const next = [] ) {
let node = this const root = this as TreeRootNode
const nodes: AnyTreeNode[] = []
const next: AnyTreeNode[] = []
let node: AnyTreeNode | undefined = root
while (node) { while (node) {
next.push(node) next.push(node)
@ -64,132 +72,113 @@ export class Node {
let index = 0 let index = 0
for (let i = next.length - 1; i >= 0; i--) { for (let i = next.length - 1; i >= 0; i--) {
callback(next[i], index++, this) callback(next[i], index++, root)
} }
return this return root
} }
/** /**
* Returns a deep copied and filtered tree of itself. * Returns a deep copied and filtered tree of itself.
* Specified filter function is passed each nodes in post-order traversal and must * Specified filter function is passed each nodes in post-order traversal and must
* return `true` or `false` like a regular filter function. * return `true` or `false` like a regular filter function.
*
* @param {Function} callback - filter callback function to invoke on each nodes
* @param {Object} args
* @param {String} [args.idKey='name'] - the key name where we can find the node identity.
* @param {String} [args.parentIdKey='name'] - the key name where we can find the parent identity.
* @return {Node}
*/ */
filter(callback) { filter(
callback: (node: AnyTreeNode, index: number, root: TreeRootNode) => boolean,
) {
const root = this as TreeRootNode
// Duplicates this tree and iter on nodes from leaves to root (post-order traversal) // Duplicates this tree and iter on nodes from leaves to root (post-order traversal)
return hierarchy(this).eachAfter((node, i) => { return hierarchy(root).eachAfter((node, i) => {
// Since we create a new hierarchy from another, nodes's `data` contains the // Since we create a new hierarchy from another, nodes's `data` contains the
// whole dupplicated node. Overwrite node's `data` by node's original `data`. // whole dupplicated node. Overwrite node's `data` by node's original `data`.
node.data = node.data.data
if (node.children) { if (node.children) {
// Removed flagged children // Removed flagged children
node.children = node.children.filter((child) => !child.remove) node.children = node.children.filter((child) => !child._remove)
if (!node.children.length) delete node.children
} }
// Perform filter callback on non-root nodes // Perform filter callback on non-root nodes
const match = node.data ? callback(node, i, this) : true const match =
node instanceof TreeChildNode ? callback(node, i, root) : true
// Flag node if there's no match in node nor in its children // Flag node if there's no match in node nor in its children
if (!match && !node.children) { if (!match && !node.children.length) {
node.remove = true node._remove = true
} }
}) })
} }
} }
export class TreeRootNode extends TreeNode {
data: null = null
parent: null = null
id: 'root' = 'root'
}
export class TreeChildNode extends TreeNode {
data: TreeNodeData
parent: AnyTreeNode
id: string
constructor(data: TreeNodeData, parent: AnyTreeNode) {
super()
this.data = data
this.parent = parent
this.id = data.name
}
}
export type AnyTreeNode = TreeRootNode | TreeChildNode
/** /**
* Generates a new hierarchy from the specified tabular `dataset`. * Generates a new hierarchy from the specified tabular `dataset`.
* The specified `dataset` must be an array of objects that contains at least a * The specified `dataset` must be an array of objects that contains at least a
* `name` property and an optional `parent` property referencing its parent `name`. * `name` property and an optional `parent` property referencing its parent `name`.
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/stratify.js#L16. * Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/stratify.js#L16.
*
* @param {Array} dataset
* @param {Object} args
* @param {String} [args.idKey='name'] - the key name where we can find the node identity.
* @param {String} [args.parentIdKey='name'] - the key name where we can find the parent identity.
* @return {Node}
*/ */
export function stratify( export function stratify(dataset: TreeNodeData[]) {
dataset, const root = new TreeRootNode()
{ idKey = 'name', parentIdKey = 'parent' } = {}, const nodesMap: Map<TreeChildNode['id'], TreeChildNode> = new Map()
) {
const root = new Node(null, true)
root.children = []
const nodesMap = new Map()
// Creates all nodes that will be arranged in a hierarchy // Creates all nodes that will be arranged in a hierarchy
const nodes = dataset.map((d) => { dataset.map((d) => {
const node = new Node(d) const parent = d.parent ? nodesMap.get(d.parent) || root : root
node.id = d[idKey] const node = new TreeChildNode(d, parent)
parent.children.push(node)
nodesMap.set(node.id, node) nodesMap.set(node.id, node)
if (d[parentIdKey]) {
node.parent = d[parentIdKey]
}
return node return node
}) })
// Build a hierarchy from nodes
nodes.forEach((node, i) => {
const parentId = node.parent
if (parentId) {
const parent = nodesMap.get(parentId)
if (!parent) throw new Error('Missing parent node: ' + parentId)
if (parent.children) parent.children.push(node)
else parent.children = [node]
node.parent = parent
} else {
node.parent = root
root.children.push(node)
}
})
root.eachBefore((node) => { root.eachBefore((node) => {
// Compute node depth // Compute node depth
if (node.parent) { if (node.parent) {
node.depth = node.parent.depth + 1 node.depth = node.parent.depth + 1
// Remove parent key if parent is root (node with no data) // Remove parent key if parent is root (node with no data)
if (!node.parent.data) delete node.parent
} }
computeNodeHeight(node) computeNodeHeight(node)
}) })
return root return root
} }
/** /**
* Constructs a root node from the specified hierarchical `data`. * Constructs a root node from the specified hierarchical `data`.
* The specified `data` must be an object representing the root node and its children. * The specified `data` must be an object representing the root node and its children.
* If given a `Node` object this will return a deep copy of it. * If given a `TreeRootNode` object this will return a deep copy of it.
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/index.js#L15. * Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/index.js#L15.
* *
* @param {Node|Object} data - object representing a root node (a simple { id, children } object or a `Node`) * @param data - object representing a root node (a simple { id, children } object or a `TreeNode`)
* @return {Node}
*/ */
export function hierarchy(data) { export function hierarchy(data: TreeRootNode) {
const root = new Node(data) function deepCopyNodes(nodes: TreeChildNode[], parent: AnyTreeNode) {
const nodes = [] return nodes.map((node) => {
let node = root const copy = new TreeChildNode(node.data, parent)
copy.depth = parent.depth + 1
while (node) { copy.children = deepCopyNodes(node.children, copy)
if (node.data.children) { return copy
node.children = node.data.children.map((child_) => { })
const child = new Node(child_)
child.id = child_.id
child.parent = node === root ? null : node
child.depth = node.depth + 1
nodes.push(child)
return child
})
}
node = nodes.pop()
} }
const root = new TreeRootNode()
root.children = deepCopyNodes(data.children, root)
root.eachBefore(computeNodeHeight) root.eachBefore(computeNodeHeight)
return root return root
} }
@ -197,13 +186,12 @@ export function hierarchy(data) {
/** /**
* Compute the node height by iterating on parents * Compute the node height by iterating on parents
* Code taken from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/index.js#L62. * Code taken from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/index.js#L62.
*
* @param {Node} node
*/ */
function computeNodeHeight(node) { function computeNodeHeight(node: TreeNode) {
let node_: TreeNode | null = node
let height = 0 let height = 0
do { do {
node.height = height node_.height = height
node = node.parent node_ = node_.parent
} while (node && node.height < ++height) } while (node_ && node_.height < ++height)
} }

View file

@ -3,6 +3,7 @@ import { useStoreGetters } from '@/store/utils'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import RecursiveListGroup from '@/components/RecursiveListGroup.vue' import RecursiveListGroup from '@/components/RecursiveListGroup.vue'
import type { TreeRootNode } from '@/helpers/data/tree'
const { domains, mainDomain, domainsTree } = useStoreGetters() const { domains, mainDomain, domainsTree } = useStoreGetters()
@ -11,12 +12,13 @@ const search = ref('')
const tree = computed(() => { const tree = computed(() => {
// FIXME rm ts type when moved to pinia or else // FIXME rm ts type when moved to pinia or else
if (!domainsTree.value) return const tree = domainsTree.value as TreeRootNode | undefined
if (!tree) return
const search_ = search.value.toLowerCase() const search_ = search.value.toLowerCase()
if (search_) { if (search_) {
return domainsTree.value.filter((node) => node.id.includes(search_)) return tree.filter((node) => node.id.includes(search_))
} }
return domainsTree.value return tree
}) })
const hasFilteredItems = computed(() => { const hasFilteredItems = computed(() => {
@ -42,6 +44,7 @@ const hasFilteredItems = computed(() => {
</template> </template>
<RecursiveListGroup <RecursiveListGroup
v-if="tree"
:tree="tree" :tree="tree"
:toggle-text="$t('domain.toggle_subdomains')" :toggle-text="$t('domain.toggle_subdomains')"
class="mb-5" class="mb-5"
@ -52,9 +55,7 @@ const hasFilteredItems = computed(() => {
<h5 class="me-3"> <h5 class="me-3">
<BLink :to="data.to" class="text-body text-decoration-none"> <BLink :to="data.to" class="text-body text-decoration-none">
<span class="fw-bold"> <span class="fw-bold">
{{ {{ data.name.replace(parent.data?.name ?? '', '') }}
data.name.replace(parent?.data ? parent.data.name : null, '')
}}
</span> </span>
<span v-if="parent?.data" class="text-secondary"> <span v-if="parent?.data" class="text-secondary">
{{ parent.data.name }} {{ parent.data.name }}