Generate explicit hierarchy out of properties

Table of Contents

  • Introduction
  • Random example
  • Science Articles example
  • Current Features
  • Code
  • Change log

Introduction

  • Hierarchies in Logseq have been a hot topic (e.g. here).
    • This is also the case for namespaces, as they have been used to model hierarchies (e.g. here).
  • Properties is the obvious alternative in modeling hierarchies, however they have been missing a proper visualization.
    • There are various feature requests on the matter (e.g. this).
  • In the present post I put together some custom code to that direction.
    • It works, but is experimental.
    • It follows some conventions that may differ from your approach.
    • Feedback is welcome.
      • Looking forward to improve and expand.

Random example

Consider a graph under construction that models human relationships (one page per human), by using two complimentary properties parent and children:

boy1.md
  parent:: [[man1]]
boy2.md
  parent:: [[man2]]
girl1.md
  parent:: [[man2]]
girl2.md
  parent:: [[man1]]
grandmother1.md
  children:: [[man1]], [[woman1]]
grandmother2.md
  children:: [[man2]], [[woman2]]
man1.md
  parent:: [[grandfather1]]
man2.md
  parent:: [[grandfather2]]
woman1.md
  parent:: [[grandfather1]]
  children:: [[boy2]], [[girl1]]
woman2.md
  parent:: [[grandfather2]]
  children:: [[boy1]], [[girl2]]

Depending on the nature of the property, there are two different perspectives:

  • parent to child
  • child from parent

Usually only one of two complimentary properties is used, but here both are used for comparison. To get the resulting tree, can use a respective macro (preferably on the right sidebar):

{{pagetree to, children}}
{{pagetree from, parent}}

The result looks like this:


Science Articles example

This is based on @gax’s illustration here.

FEM.md
  broader:: [[Numerics]]
Mechanics.md
  broader:: [[Physics]]
Numerics.md
  broader:: [[Math]]
SomeArticle.md
  This is an article about #Physics
SomeArticle2.md
  This is an article about #TensionSims
TensionSims.md
  broader:: [[Tension]], [[FEM]]
Tension.md
  broader:: [[Mechanics]]

To get the resulting tree, can use this macro (preferably on the right sidebar):

{{pagetree from, broader, is, refs}}

This example uses a second pair of verb, property, which:

  • attaches on each node a special node with its direct children (if any) per the passed pair
  • accepts built-in values has and is
    • has behaves like to
    • is behaves like from
    • The combination is, refs receives special treatment, as instead of a property’s values it looks for references.

The result looks like this:
image


Current Features

  • Siblings are sorted alphabetically.
  • Click to open the respective page.
    • These are custom links, not like Logseq’s.
  • Collapse / Expand any node.
    • + / - when hovering
  • Prevent too big or circular models from getting out of control.
    • Can be customized by setting:
      • PageTree.max.depth
      • PageTree.max.siblings
  • A child with multiple parents appears multiple times in the tree.
  • If the tree’s block is not touched, it doesn’t auto-update its contents when the model gets updated.
    • To update, just click in and out.

Code

  • Inside file config.edn, inside macros{}:
:pagetree "<div class='kit pagetree' data-kit='pagetree' data-node-prep='$1' data-node-prop='$2' data-leaf-prep='$3' data-leaf-prop='$4'>pagetree</div>"
  • Inside file custom.css:
.pagetree .tree-button {
    visibility: hidden;
}
.pagetree:hover .tree-button {
    visibility: visible;
}
.pagetree-node {
  margin-left: 16px;
}
.tree-button {
  width: 16px;
}
  • This code requires having kits inside file custom.js.
    • If you prefer putting the whole code inside custom.js, you have to either:
      • remove all mentions to logseq.kits
      • create it yourself with logseq.kits = {};
  • Inside page PageTree in Logseq, put the following code in a single javascript code-block, or broken into multiple ones (could use the section comments):
// PageTree

PageTree = logseq.kits.PageTree = {
    children: { queries: {}, titles: {} },
    max: {},
    roots: { queries: {}, titles: {} }
};

PageTree.configFromDiv = function configFromDiv(div){
    const nodeprop = div.dataset.nodeProp;
    if (!nodeprop) return;

    delete div.dataset.nodeProp;

    const leafprep = div.dataset.leafPrep;
    const leafprop = div.dataset.leafProp;
    const leafconfig = {
        prep: (leafprep !== "$3") && leafprep,
        prop: (leafprop !== "$4") && leafprop,
        not: leafprop === "refs" && leafprep === "is" && nodeprop
    };

    const nodeconfig = {
        prep: div.dataset.nodePrep,
        prop: nodeprop
    };
    return {leaf: leafconfig, node: nodeconfig};
}
logseq.kits.pagetree = function PageTreeHandler(div){
    const config = PageTree.configFromDiv(div);
    if (!config) return;

    const nodeconfig = config.node;
    const pgRoots = PageTree.roots;
    div.textContent = pgRoots.getTitle(nodeconfig);
    const roots = pgRoots.get(nodeconfig);

    const nofRoots = roots.length;
    const buttonHandler = nofRoots && function(e){
        e.preventDefault();
        e.stopPropagation();
        PageTree.toggleChildren(button);
    }
    const button = PageTree.button((nofRoots) ? "-" : "Ø", buttonHandler);
    div.prepend(button);

    PageTree.filler(config, div, roots, PageTree.max.depth);
}

// Elements

PageTree.link = function pageLink(name, originalName){
    const a = document.createElement("a");
    a.classList.add("page-ref");
    a.href = "#/page/" + encodeURIComponent(name);
    a.textContent = originalName;
    return a;
}

PageTree.button = function treeButton(textContent, clickHandler){
    const button = document.createElement("button");
    button.classList.add("tree-button");
    button.textContent = textContent;
    if (clickHandler) button.addEventListener("click", clickHandler);
    return button;
}

PageTree.toggleChildren = function toggleChildren(button){
    const isExpanded = (button.textContent === "-");
    button.textContent = (isExpanded) ? "+" : "-";

    const newStyle = (isExpanded) ? "none" : "block";
    Array.prototype.forEach.call(button.parentElement.children, (child)=>{
        if (child.className === "pagetree-node") child.style.setProperty("display", newStyle);
    });
}

PageTree.node = function treeNode(button, link){
    const div = document.createElement("div");
    div.classList.add("pagetree-node");
    div.append(button, link);
    return div;
}

// Fillers

PageTree.fillMore = function fillMoreChildDivs(config, divParent, children, items, child, isItemsNode, depth){
    if (items.length) PageTree.filler(config, divParent, [{items, page: child}], depth);
    PageTree.filler(config, divParent, children, (isItemsNode) ? -1 : depth);
}

PageTree.filler = function fillChildDivs(config, divParent, children, depth){
    children.forEach( (child)=>{
        const page = child.page;
        const isItemsNode = (page) ? true : false;
        const childName = (isItemsNode) ? page["original-name"] : child["original-name"];
        const leafconfig = config.leaf;

        const getChildren = PageTree.children.get;
        const items = (leafconfig.prop && !isItemsNode && depth > 0) ? getChildren(leafconfig, childName) : [];
        const nofItems = items.length;
        const children = (depth < 0) ? [] : (isItemsNode) ? child.items : getChildren(config.node, childName);
        const nofChildren = children.length + (nofItems ? 1 : 0);

        const newDepth = depth - 1;
        const small = (newDepth > 0 && nofChildren <= PageTree.max.siblings);

        const buttonText = (nofChildren) ? ((small) ? "-" : "...") : " ";
        const buttonHandler = nofChildren && function(e){
            e.preventDefault();
            e.stopPropagation();

            if (button.textContent === "...") PageTree.fillMore(config, divChild, children, items, child, isItemsNode, PageTree.max.depth);
            PageTree.toggleChildren(button);
        };
        const button = PageTree.button(buttonText, buttonHandler);

        const link = (isItemsNode) ? PageTree.children.getTitle(leafconfig) : PageTree.link(child.name, childName);
        const divChild = PageTree.node(button, link);
        if (nofChildren && small) PageTree.fillMore(config, divChild, children, items, child, isItemsNode, newDepth);
        divParent.append(divChild);
    });
}

// Getters

PageTree.getData = function getData(query, ...inputs){
    const res = logseq.api.datascript_query(query, ...inputs);
    return res.flat().sort( (a, b)=>{ return a.name.localeCompare(b.name) } );
}

PageTree.children.get = function getChildren(config, parent){
    const query = PageTree.children.queries[(config.not) ? "refs" : config.prep];
    return PageTree.getData(query, ":" + (config.not || config.prop), `"${parent.toLowerCase()}"`);
}
PageTree.children.getTitle = function getTitle(config){
    const prep = (config.not) ? "refs" : config.prep;
    return PageTree.children.titles[prep].replace("leafprop", config.prop);
}

PageTree.roots.get = function getRoots(config){
    const query = PageTree.roots.queries[config.prep];
    return PageTree.getData(query, ":" + config.prop);
}
PageTree.roots.getTitle = function getTitle(config){
    return PageTree.roots.titles[config.prep].replace("nodeprop", config.prop);
}

// Queries

PageTree.children.queries.refs = `[
    :find (pull ?Child [*])
    :in $ ?prop ?Parent-name
    :where
        [?child :block/page ?Child]
        [?child :block/refs ?Parent]
        [?Parent :block/name ?Parent-name]
        [?Parent :block/original-name ?Parent-original-name]
        (not 
            [?child :block/properties ?child-props]
            [(get ?child-props ?prop) ?child-from]
            [(contains? ?child-from ?Parent-original-name)]
        )
]`;
PageTree.children.queries.from = `[
    :find (pull ?Child [*])
    :in $ ?prop ?Parent-name
    :where
        [?child :block/page ?Child]
        [?child :block/properties ?child-props]
        [(get ?child-props ?prop) ?child-from]
        [?Parent :block/name ?Parent-name]
        [?Parent :block/original-name ?Parent-original-name]
        [(contains? ?child-from ?Parent-original-name)]
]`;
PageTree.children.queries.to = `[
    :find (pull ?Child [*])
    :in $ ?prop ?Parent-name
    :where
        [?Parent :block/name ?Parent-name]
        [?parent :block/page ?Parent]
        [?parent :block/properties ?parent-props]
        [(get ?parent-props ?prop) ?parent-to]
        [?Child :block/original-name ?Child-original-name]
        [(contains? ?parent-to ?Child-original-name)]
]`;
PageTree.children.queries.has = PageTree.children.queries.to;
PageTree.children.queries.is = PageTree.children.queries.from;

PageTree.roots.queries.from = `[
    :find (pull ?Root [*])
    :in $ ?prop
    :where
        [?child :block/properties ?child-props]
        [(get ?child-props ?prop) ?child-from]
        [?Root :block/original-name ?Root-original-name]
        [(contains? ?child-from ?Root-original-name)]
        (not
            [?root :block/page ?Root]
            [?root :block/properties ?root-props]
            [(get ?root-props ?prop) ?root-from]
        )
]`;
PageTree.roots.queries.to = `[
    :find (pull ?Root [*])
    :in $ ?prop
    :where
        [?root :block/page ?Root]
        [?root :block/properties ?root-props]
        [(get ?root-props ?prop) ?root-to]
        (not 
            [?parent :block/properties ?parent-props]
            [(get ?parent-props ?prop) ?parent-to]
            [?Root :block/original-name ?Root-original-name]
            [(contains? ?parent-to ?Root-original-name)]
        )
]`;

// Settings

PageTree.max.depth = 16;
PageTree.max.siblings = 16;

PageTree.roots.titles.from = "Tree from nodeprop:"
PageTree.roots.titles.to = "Tree to nodeprop:"
PageTree.children.titles.is = "is in leafprop of:"
PageTree.children.titles.has = "has leafprop:"
PageTree.children.titles.refs = "is referred by:"

For example, I have created a page that looks like this:

  • The provided button is not needed to be pressed, unless changes are made to the code.
  • This full page could be distributed or reused by other graphs as a single PageTree.md file (currently one copy per graph).

Changelog

  • Encode links.
  • Merge macro definitions into one.
  • Accept an optional second pair of passed arguments.
    • Support references with the special pair is, refs.
  • Adopt kits.

I’m not sure I fully grasp what is happening even with the example below.
Could you perhaps elaborate for my understanding?
It looks very cool though!

The system doesn’t know the meaning of each property, thus it needs some help in interpreting the desired direction of the tree. If using the inverse direction:

{{pagetree-from children}}
{{pagetree-to parent}}

the result looks kind of upside-down and not much of a tree:


Fortunately the issue is immediately visible and the remedy is simply swapping from / to.

1 Like

So it uses the values in the parent (or children) property to infer relations and put those in a tree?
Grandfather point to parent points to child for example?

By definition, a tree data-structure is one where all the nodes possess the same directional relationship to one or more other nodes, but with different value. So the algorithm for to simply follows the value of the specific property from one node to the other (grandparent -> parent -> child) and paints the path until its edges (which have no value), while the algorithm for from searches for inverse pointers (grandparent <- parent <- child). Therefore, in each case should pick the respective algorithm based on what type of pointers are available in the model.

2 Likes

Thanks! Now it makes sense :smiley:
I use pages which are tagged with either projects/areas/references/archive. All pages under those will then have for example project:: page linking to a page with tags:: projects.

So maybe by adding project:: projects I can use this for a project hierarchy overview.

  • Projects
    • project A
      • subpage of A
    • project B
      • subpage of B

And if I really want to change things up I could use a more general property to make a complete hierarchy overview.

  • Projects
    • project A
    • etc.
  • Areas
    • area B
    • etc.

This makes me excited :smiley: thank you!

This is interesting. Just as I was struggling to get a handle on Namespaces you presented this solution which makes Namespaces redundant. It comes at the right time as I have read most of a book that I have made notes on and that are subdivided into chapters.

I amended my config.edn and custom.css files, and created a custom.js file, then in a page pasted {{pagetree-to children}}, but no tree was rendered.

In the parent page I have this:

image

and the properties block of each child looks like this:

image

Could it be that the icon somehow causes a blockage, but I deleted it in 1 page thinking I would get a tree with the parent and 1 child, but nothing is rendered.

If the used property is parent::, then the macro should be {{pagetree-from parent}} (this is under deprecation, but it is valid at the time of this writing).

I clarified my text above slightly: the narrow screenshot with children is of the parent page — you probably realized that, but I just want to make sure.

I changed the macro to {{pagetree-from parent}} but I still don’t get anything.

This is what I have in the config.edn:

 ;; added by PB from https://discuss.logseq.com/t/generate-explicit-hierarchy-out-of-properties/20635
:macros {:pagetree-from "[:div {:class pagetree, :data-treedirection from, :data-treeprop $1}]"
:pagetree-to "[:div {:class pagetree, :data-treedirection to, :data-treeprop $1}]"
}
  • Each property is independent, and so are the respective trees.
    • As humans we understand some properties to be complimentary to each other, but the system doesn’t.
  • Your config.edn looks ok.
  • Your example works for me, so there has to be some difference.
    • If there is some text Tree...: but no tree below, the issue is probably with the position of the properties.
    • If there is no output at all, should Ctrl + Shift + i to check for any clues in the console.
  • Your other option is to create a new (empty) graph and start adding minimal info, checking the result of the macro as you go, until you find which change breaks it.
  • Also make sure that:
    • your app is up-to-date
    • you have restarted Logseq after adding custom.js
    • you have answered positively to the question about allowing custom.js to be executed

Success!! The problem was that I had copy/pasted only part of the custom.js. Copy/pasting the full code and restarting Logseq did the trick.
Note that it is actually {{pagetree-to children}} that works.

3 follow-up questions:

  • in the tree there is also my Templates page, which has nothing to do with the book I am reading, Yahweh to Zion.
    image

Looking at the templates page, there is this template:

image
Could be because of children:: that the Templates page is included in the book’s tree? If so, how could I avoid the Templates inclusion?

  • All children are sorted alphabetically, which is not the order of the book, even though on the parent page the children are shown in the order of the book. How can I get that sort order in the tree too?

  • Am I right when I say that for each book I read/project I am engaged in, I can put {{pagetree-to children}} on the parent page to get a tree for each book/project?

The current code is light and generic, so none of the features that you ask has a positive answer at this time. But thank you for your feedback, this is what I am looking for. I will have to go through each one of the features and consider supporting it.

OK, clear. What there is, is already interesting. I love the fact that I can stay away from Namespaces — perhaps that is being narrow-minded, whatever — because I feel that if they are not used properly, they will sooner or later cause hassles/problems.

I’ll await the next stage, patiently :grinning:

BTW, I don’t know if you saw, but I advertised your system here.

1 Like

Big update, check the changelog.

Is it possible to use more than one property i.e. once the first hierarchy is evaluated, add more children by looking at the next property?

The algorithm can support endless combinations, but its input is currently limited by the syntax of macros. I don’t want to support a few scenarios and leave others out. If you have any idea for a generic-enough syntax, please share it. Meanwhile, you can get some results by editing the queries directly.

2 Likes

@alex Actually, could you provide something more specific as an example? I have come with some design and would like to ensure that it covers your case before implementing it.

@mentaloid sure, check this mockup for the query builder:

So something like {{pagetree from, nationality, author}} to create that hierarchy.

In my opinion this should be a feature of queries but I was curious if it was possible here too.

1 Like

I wish somebody could make a video about how to utilize it for some complex hierarchy topics.

I looked at the mockup:

  • There you propose a single filter-sort-group query, which:
    • is a bottom-up approach (hierarchy-wise)
    • has no surprises (thus no discoveries either)
    • operates like this:
      • extracts some items (there books)
      • imposes some structure on them, which is:
        • regular
          • in position: properties have fixed layers
            • top nationality, bottom authorship, birthday on authors only
          • in participation: e.g. a nation without books doesn’t appear
        • limited
          • in size: the exact number of layers is predefined
          • in info: the reader should already know what each layer represents
  • This is not a genuine tree.
    • unless if we abstract nationality and authorship to e.g. origin
      • but then more results may appear and of less rigid structure
  • In contrast, a genuine tree:
    • is a top-down approach (hierarchy-wise)
    • operates like this:
      • begins from some root
      • spreads out in irregular forms

In my approach I implement a generalized graph traversal (like you said on this occasion), fed on every step by multiple predefined parameterized queries, operating on a level above them, to provide an unlimited partial structure of the graph itself, potentially discovering emerged relationships (e.g. “items authored after the birth of nationalities”, which may be books, but not strictly). Therefore, this is more than a tree, it is a cascade. It should still be possible to cover your scenario, but should:

  • define some queries that are more targeted, in order to avoid unwanted results
  • implement some syntax that needs parsing, e.g.:
    {{pagecascade top strict-from nationality ; flat strict-from nationality ; flat strict-from author }}

I’m looking into it.

2 Likes