Single Script to Modify Graphs

The Digital Garden plugin offers a similar graph feature to Obsidian, showing all connected pages to the current page out to different layers. By default, all linked pages are the same color, and index pages can overshadow more helpful links.

Table of Contents

  1. How to add coloration to the graph
  2. Production-ready script

How to add coloration to the graph

  1. Replace the contents of the graphScript.njk at src/site/_includes/components/graphScript.njk with the code below.
  2. There are two sections commented **User Start** that mark where you should make changes.
    1. The first section is to identify files or folders you wish to exclude from the graph (like index files or home pages)
    2. The second section is where you make color choices based on folders.
  3. Excluding files and folders
    1. Use commas to separate files
      1. e.g., ['/about-adhd-bamf', '/']
    2. Make sure that the paths match the URL.
      1. For example on ADHD BAMF, the folder “Project Management” is written in the script as '/project-management/', the “Proto-List of Expressions.md” file in the “Project Management” folder would be '/project-management/proto-list-of-expressions',1 and the homepage is simply '/'.
    3. If you exclude a folder, you do not need to also exclude any pages it contains.
  4. Colorizing nodes based on folder
    1. To colorize specific folders, add the folder name to the list, and use the hex-value for the color you want.
      1. Like when excluding folders, make sure that the folders match the URL (e.g., /expressions instead of “Expressions”).
      2. You can search for hex codes, or use sites like ColorsWall to find hex codes for the colors you want.
  5. Save and commit changes.
  6. Profit!

Production-ready script

<script>
    async function fetchGraphData() {
        const graphData = await fetch('/graph.json').then(res => res.json());
        const fullGraphData  = filterFullGraphData(graphData);
        return {graphData, fullGraphData}
    }

    function getNextLevelNeighbours(existing, remaining) {
        const keys = Object.values(existing).map((n) => n.neighbors).flat();
        const n_remaining = Object.keys(remaining).reduce((acc, key) => {
                if (keys.indexOf(key) != -1) {
                    if (!remaining[key].hide) {
                        existing[key] = remaining[key];
                    }
                } else {
                    acc[key] = remaining[key];
                }
                return acc;
            }, {});
        return existing, n_remaining;
    }

    function filterLocalGraphData(graphData, depth) {
        if (graphData == null) {
            return null;
        }

        let remaining = JSON.parse(JSON.stringify(graphData.nodes));
        let links = JSON.parse(JSON.stringify(graphData.links));
        let currentLink = decodeURI(window.location.pathname);
        let currentNode = remaining[currentLink] || Object.values(remaining).find((v) => v.home);

        delete remaining[currentNode.url];
        if (!currentNode.home) {
            let home = Object.values(remaining).find((v) => v.home);
            delete remaining[home.url];
        }
        currentNode.current = true;
        let existing = {};
        existing[currentNode.url] = currentNode;
        for (let i = 0; i < depth; i++) {
            existing, remaining = getNextLevelNeighbours(existing, remaining);
        }

        // **Modification Start**: Filter nodes based on folders or files with decoded URLs
	    // **User Start**: Enter folder and file paths to exclude
        const foldersToExclude = ['/project-management/']; // Use the URL folder path
        const filesToExclude = ['/about-adhd-bamf', '/']; // Use the full URL file path
		// **User End**

        nodes = Object.values(existing).filter(n => {
            const nodeUrl = decodeURIComponent(n.url);

            // Exclude nodes in specified folders
            // Exclude specified files
            const isExcludedFile = filesToExclude.includes(nodeUrl);
            const shouldHide = n.hide || inExcludedFolder || isExcludedFile;

            return !shouldHide;
        });

        // **Modification End**

        if (!currentNode.home) {
            nodes = nodes.filter(n => !n.home);
        }
        let ids = nodes.map((n) => n.id);

        return {
            nodes,
            links: links.filter(function (con) {
                const includeLink = ids.indexOf(con.target) > -1 && ids.indexOf(con.source) > -1;
                return includeLink;
            }),
        }
    }

    function getCssVar(variable) { return getComputedStyle(document.body).getPropertyValue(variable) }

    function htmlDecode(input) {
        var doc = new DOMParser().parseFromString(input, "text/html");
        return doc.documentElement.textContent;
    }

    function renderGraph(graphData, id, delay, fullScreen) {
        if (graphData == null) {
            return;
        }
        const el = document.getElementById(id);
        width = el.offsetWidth;
        height = el.offsetHeight;
        const highlightNodes = new Set();
        let hoverNode = null;

        // **Modification Start**: Define specific colors for folders
        // **User Start**: Enter folder and color pairings
        const folderColorMap = {
            '/expressions': '#530000',    // Red
            '/mitigations': '#228B22',    // Green
            '/definitions': '#808080',    // Gray
            // Add more folders and colors as needed
        };

        // Default color palette for folders not specified
        const defaultColorPalette = ['#f58231', '#911eb4', '#46f0f0',
                                     '#f032e6', '#bcf60c', '#fabebe', '#008080',
                                     '#e6beff', '#9a6324', '#fffac8', '#800000',
                                     '#aaffc3', '#808000', '#ffd8b1', '#000075',
                                     '#808080'];
		// **User End**

        let defaultColorIndex = 0;

        function getNodeColor(node) {
            if (node.current) {
                return getCssVar("--graph-main");
            }
            const nodeUrl = decodeURIComponent(node.url);
            const folderMatch = nodeUrl.match(/^\/[^/]+/);
            const folder = folderMatch ? folderMatch[0] : '/';
            // Check if folder has a specified color
            if (folderColorMap[folder]) {
                return folderColorMap[folder];
            } else {
                // Assign a default color if not specified
                if (!folderColorMap[folder]) {
                    folderColorMap[folder] = defaultColorPalette[defaultColorIndex % defaultColorPalette.length];
                    defaultColorIndex++;
                }
                return folderColorMap[folder];
            }
        }
        // **Modification End**

        let Graph = ForceGraph()
        (el)
            .graphData(graphData)
            .nodeId('id')
            .nodeLabel('title')
            .linkSource('source')
            .linkTarget('target')
            .d3AlphaDecay(0.10)
            .width(width)
            .height(height)
            .linkDirectionalArrowLength(2)
            .linkDirectionalArrowRelPos(0.5)
            .autoPauseRedraw(false)
            .linkColor((link) => {
                if (hoverNode == null) {
                    return getCssVar("--graph-main");
                }
                if (link.source.id == hoverNode.id || link.target.id == hoverNode.id) {
                    return getCssVar("--graph-main");
                } else {
                    return getCssVar("--graph-muted");
                }
            })
            .nodeCanvasObject((node, ctx) => {
                const numberOfNeighbours = (node.neighbors && node.neighbors.length) || 2;
                const nodeR = Math.min(7, Math.max(numberOfNeighbours / 2, 2));

                ctx.beginPath();
                ctx.arc(node.x, node.y, nodeR, 0, 2 * Math.PI, false);

                // **Modification Start**: Use getNodeColor function
                let nodeColor = getNodeColor(node);

                if (hoverNode == null) {
                    ctx.fillStyle = nodeColor;
                } else {
                    if (node == hoverNode || highlightNodes.has(node.url)) {
                        ctx.fillStyle = nodeColor;
                    } else {
                        ctx.fillStyle = getCssVar("--graph-muted");
                    }
                }
                // **Modification End**

                ctx.fill();

                if (node.current) {
                    ctx.beginPath();
                    ctx.arc(node.x, node.y, nodeR + 1, 0, 2 * Math.PI, false);
                    ctx.lineWidth = 0.5;
                    ctx.strokeStyle = getCssVar("--graph-main");
                    ctx.stroke();
                }

                const label = htmlDecode(node.title)
                const fontSize = 3.5;
                ctx.font = `${fontSize}px Sans-Serif`;

                ctx.textAlign = 'center';
                ctx.textBaseline = 'top';
                ctx.fillText(label, node.x, node.y + nodeR + 2);
            })
            .onNodeClick(node => {
                window.location = node.url;
            })
            .onNodeHover(node => {
                highlightNodes.clear();
                if (node) {
                    highlightNodes.add(node);
                    node.neighbors.forEach(neighbor => highlightNodes.add(neighbor));
                }
                hoverNode = node || null;
            });
            if (fullScreen || (delay != null && graphData.nodes.length > 4)) {
                setTimeout(() => {
                    Graph.zoomToFit(5, 75);
                }, delay || 200);
            }
        return Graph;
    }

    function renderLocalGraph(graphData, depth, fullScreen) {
        if (window.graph){
            window.graph._destructor();
        }
        const data = filterLocalGraphData(graphData, depth);
        return renderGraph(data, 'link-graph', null, fullScreen);
    }

    function filterFullGraphData(graphData) {
        if (graphData == null) {
            return null;
        }

        graphData = JSON.parse(JSON.stringify(graphData));

        // **Modification Start**: Filter nodes based on folders or files with decoded URLs
        const foldersToExclude = ['/project-management/']; // Use the URL folder path
        const filesToExclude = ['/about-adhd-bamf', '/']; // Use the full URL file path

        // Filter out nodes in specified folders or files
        const hiddens = Object.values(graphData.nodes)
            .filter((n) => {
                const nodeUrl = decodeURIComponent(n.url);

                const inExcludedFolder = foldersToExclude.some(folder => nodeUrl.startsWith(folder));
                const isExcludedFile = filesToExclude.includes(nodeUrl);
                const shouldHide = n.hide || inExcludedFolder || isExcludedFile;

                return shouldHide;
            })
            .map((n) => n.id);

        const data = {
            links: graphData.links.filter((l) => {
                const includeLink = !hiddens.includes(l.source) && !hiddens.includes(l.target);
                return includeLink;
            }),
            nodes: Object.values(graphData.nodes).filter((n) => {
                const includeNode = !hiddens.includes(n.id);
                return includeNode;
            })
        };
        return data;
    }

    function openFullGraph(fullGraphData) {
        lucide.createIcons({
                attrs: {
                    class: ["svg-icon"]
                }
            });
        return renderGraph(fullGraphData, "full-graph-container", 200, false);;
    }

    function closefullGraph(fullGraph) {
        if (fullGraph) {
            fullGraph._destructor();
        }
        return null;
    }
</script>
<div  x-init="{graphData, fullGraphData} = await fetchGraphData();" x-data="{ graphData: null, depth: 1, graph: null, fullGraph: null, showFullGraph: false, fullScreen: false, fullGraphData: null}" id="graph-component" x-bind:class="fullScreen ? 'graph graph-fs' : 'graph'" v-scope>
    <div class="graph-title-container">
        <div class="graph-title">Connected Pages</div>
        <div id="graph-controls">
                <div class="depth-control">
                    <label for="graph-depth">Depth</label>
                    <div class="slider">
                            <input x-model.number="depth" name="graph-depth" list="depthmarkers" type="range" step="1" min="1" max="3" id="graph-depth"/>
                    <datalist id="depthmarkers">
                            <option value="1" label="1"></option>
                            <option value="2" label="2"></option>
                            <option value="3" label="3"></option>
                    </datalist>
                    </div>
                    <span id="depth-display" x-text="depth"></span>
                </div>
                <div class="ctrl-right">
                    <span id="global-graph-btn" x-on:click="showFullGraph = true; setTimeout(() => { fullGraph = openFullGraph(fullGraphData)}, 100)"><i  icon-name="globe" aria-hidden="true"></i></span>
                    <span  id="graph-fs-btn"  x-on:click="fullScreen = !fullScreen;"><i  icon-name="expand" aria-hidden="true"></i></span>
                </div>
        </div>
    </div>
    <div x-effect="window.graph = renderLocalGraph(graphData, depth, fullScreen);" id="link-graph" ></div>
    <div x-show="showFullGraph" id="full-graph" class="show" style="display: none;">
        <span id="full-graph-close" x-on:click="fullGraph = closefullGraph(fullGraph); showFullGraph = false;"><i icon-name="x" aria-hidden="true"></i></span><div id="full-graph-container"></div>
    </div>
</div>
  1. Note that you do not need to exclude both folder and file, this is just for demonstration purposes.