Custom Widget: Mind Map (Ported from Obsidian)

Hi everyone!

I’m excited to share a new functionality I’ve been working on for Grist - a Mind Map widget.

I wanted a way to visualize hierarchical data directly within my tables, so I migrated the logic from a popular Obsidian plugin.

Credit & Attribution: This is a migration of the custom plugin for Obsidian created by lynchjames.

How it works: The widget takes a Markdown string from a Grist column and renders it as an interactive, zoomable mind map. It uses the Markmap library to render Markdown as a tree.

Example Input:

# 🗺️ Project Roadmap
## 🎯 Goals
### Launch MVP
### User Feedback
## 🛠️ Stack
### Grist (Data)
### Markmap (Visual)
## 📅 Timeline
### Week 1: Setup
### Week 2: Design
### Week 3: Release

Setup Instructions:

  1. Add a Custom Widget to your Grist page.

  2. Select Open Custom Widget Builder.

  3. Paste the code into the HTML/JavaScript sections.

  4. In the widget settings, map your Markdown column (MindMap) to the widget’s input.

  5. Watch your data transform into a Mind Map!

:warning:ACHTUNG: The column name should be “MindMap” - if you want something else, replace it in the HTML code.

HTML code:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <script src="https://docs.getgrist.com/grist-plugin-api.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/markmap-autoloader@0.16.0"></script>
  <script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
  
  <style>
    /* === Grist Theme Integration === */
    :root {
      --bg-color: var(--grist-theme-page-bg, #ffffff);
      --text-color: var(--grist-theme-text, #111111);
      --accent-color: var(--grist-theme-primary, #2196F3);
      --btn-bg: var(--grist-theme-widget-bg, #f0f0f0);
      --btn-text: var(--grist-theme-text, #333333);
      --btn-border: var(--grist-theme-widget-border, rgba(0,0,0,0.1));
    }

    html, body {
      margin: 0; padding: 0; height: 100vh; overflow: hidden;
      background-color: var(--bg-color);
      color: var(--text-color);
      font-family: sans-serif;
      transition: background-color 0.2s;
    }

    #mindmap { width: 100vw; height: 100vh; }
    svg { width: 100%; height: 100%; }

    /* UI Controls */
    .controls {
      position: absolute; top: 10px; right: 10px;
      display: flex; gap: 6px; z-index: 10;
    }

    button {
      background: var(--btn-bg);
      color: var(--btn-text);
      border: 1px solid var(--btn-border);
      padding: 6px 12px;
      border-radius: 6px;
      cursor: pointer;
      font-size: 13px;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
      transition: filter 0.2s;
    }
    button:hover { filter: brightness(0.9); }

    /* Markdown Editor Overlay */
    #editor-container {
      position: absolute; inset: 0;
      background: var(--bg-color);
      display: none; flex-direction: column; z-index: 20;
    }

    textarea {
      flex-grow: 1; background: var(--bg-color); color: var(--text-color);
      padding: 20px; border: none; font-family: monospace;
      font-size: 16px; outline: none; resize: none;
    }

    .editor-footer {
      padding: 10px; display: flex; justify-content: flex-end; gap: 10px;
      border-top: 1px solid var(--btn-border);
    }

    /* Node Label Styling */
    .markmap-node div {
      color: var(--text-color) !important;
      font-weight: 500;
    }
  </style>
</head>
<body>

  <div class="controls">
    <button onclick="toggleEditor()">📝 Edit</button>
    <button onclick="zoomFit()">🔍 Fit</button>
  </div>

  <div id="editor-container">
    <textarea id="markdown-input" placeholder="Enter Markdown here..."></textarea>
    <div class="editor-footer">
      <button style="background:#e57373;color:white;border:none" onclick="toggleEditor()">Cancel</button>
      <button style="background:#4CAF50;color:white;border:none" onclick="saveAndClose()">💾 Save & Close</button>
    </div>
  </div>

  <div id="mindmap"><svg id="markmap-svg"></svg></div>

  <script>
    let mm = null;
    let lastContent = "";
    let currentRecordId = null;
    
    // Obsidian-inspired color palette for main branches
    const obsidianColorPalette = [
      '#FF6B6B', '#4ECDC4', '#FFD166', '#06D6A0',
      '#118AB2', '#073B4C', '#EF476F', '#7209B7',
      '#F15BB5', '#00BBF9', '#00F5D4', '#FF9E00'
    ];

    // Initialize Grist API
    grist.ready({
      requiredAccess: 'full',
      columns: [{ name: "MindMap", type: "Text", title: "Markdown Data" }]
    });

    // Re-render map when Grist theme options change
    grist.onOptions(() => {
      if (lastContent) renderMindMap(lastContent);
    });

    // Load data from selected record
    grist.onRecord((record) => {
      currentRecordId = record.id;
      const content = record.MindMap || "";
      if (content !== lastContent) {
        lastContent = content;
        renderMindMap(content);
      }
    });

    // Get primary accent color from Grist theme
    function getGristAccent() {
      return getComputedStyle(document.documentElement)
        .getPropertyValue('--accent-color').trim() || '#2196F3';
    }

    // Calculate node colors based on depth and branch index
    function getNodeColor(node, depth = 0, branchIndex = 0) {
      // Root node uses Grist accent color
      if (depth === 0) return getGristAccent();
      
      const baseColor = obsidianColorPalette[branchIndex % obsidianColorPalette.length];
      // Main branches (depth 1) use base palette colors
      if (depth === 1) return baseColor;
      
      // Sub-branches are darkened progressively based on depth
      const hex = baseColor.replace('#', '');
      const r = parseInt(hex.substr(0, 2), 16);
      const g = parseInt(hex.substr(2, 2), 16);
      const b = parseInt(hex.substr(4, 2), 16);
      
      const darkenFactor = Math.pow(0.88, depth - 1);
      const newR = Math.max(0, Math.min(255, Math.floor(r * darkenFactor)));
      const newG = Math.max(0, Math.min(255, Math.floor(g * darkenFactor)));
      const newB = Math.max(0, Math.min(255, Math.floor(b * darkenFactor)));
      
      return `rgb(${newR}, ${newG}, ${newB})`;
    }

    // Recursively process tree data to assign colors
    function processTree(node, depth = 0, branchIndex = 0) {
      if (!node) return null; // Safety check for empty nodes
      
      node._color = getNodeColor(node, depth, branchIndex);
      
      if (node.children && Array.isArray(node.children)) {
        node.children.forEach((child, idx) => {
          // Pass new branch index for depth 1, otherwise inherit from parent
          const newBranchIndex = (depth === 0) ? idx : branchIndex;
          processTree(child, depth + 1, newBranchIndex);
        });
      }
      return node;
    }

    // Main rendering function
    function renderMindMap(markdown) {
      const svg = document.getElementById('markmap-svg');
      
      // Handle empty or invalid input
      if (!markdown || typeof markdown !== 'string' || markdown.trim() === '') {
        svg.innerHTML = '';
        if (mm) mm.setData({ content: '', children: [] });
        return;
      }

      try {
        const { Markmap, Transformer } = window.markmap;
        const transformer = new Transformer();
        
        // Transform Markdown to data structure
        const result = transformer.transform(markdown);
        const root = result.root;

        if (root) {
          processTree(root);
          
          const options = {
            autoFit: true,
            paddingX: 30,
            duration: 300,
            // Apply assigned colors to lines and nodes
            color: (node) => node._color || getGristAccent()
          };

          if (!mm) {
            mm = Markmap.create(svg, options, root);
          } else {
            mm.setData(root);
            mm.setOptions(options);
            mm.fit();
          }
        }
      } catch (e) { 
        console.error("Markmap Render Error:", e);
        svg.innerHTML = `<text x="20" y="40" fill="red" font-family="monospace">Markdown Error: ${e.message}</text>`;
      }
    }

    // Show/Hide Markdown editor
    function toggleEditor() {
      const editor = document.getElementById('editor-container');
      if (editor.style.display !== 'flex') {
        document.getElementById('markdown-input').value = lastContent;
        editor.style.display = 'flex';
        document.getElementById('markdown-input').focus();
      } else {
        editor.style.display = 'none';
      }
    }

    // Save changes back to Grist
    async function saveAndClose() {
      const newContent = document.getElementById('markdown-input').value;
      if (!currentRecordId) return;
      try {
        const table = await grist.getTable();
        const tableId = await table.getTableId();
        await grist.docApi.applyUserActions([
          ["UpdateRecord", tableId, currentRecordId, { MindMap: newContent }]
        ]);
        lastContent = newContent;
        renderMindMap(newContent);
        toggleEditor();
      } catch (e) { alert("Save Error: " + e.message); }
    }

    // Utility: Fit map to container
    function zoomFit() { if (mm) mm.fit(); }
    
    // Event Listeners for window lifecycle
    window.addEventListener('resize', () => mm && mm.fit());
    window.addEventListener('beforeunload', () => mm && mm.destroy());
  </script>
</body>
</html>

It’s still in a “port” state, but it’s fully functional within the Grist Custom Widget ecosystem. I’d love to hear your thoughts or if anyone wants to help refine the data-mapping part!

2 Likes

Thanks for sharing your widget. I really like the aesthetics and happened to use it just yesterday to visualize our data structure to my colleaques.

Do you see a way to incorporate actual data into this? Like, for example, a way to update a company hierarchy tree every time a person has been added by HR.

Actually, for organizational charts, I think a different widget by jta87 might be a better fit: https://forum.grist.libre.sh/t/custom-widget-quelques-widgets-grist/1007.

Regarding automatic updates: it’s definitely doable. You could set up the ‘MindMap’ column as a trigger formula that fires whenever the employee list changes. You’d just need a simple formula to convert flat data into a hierarchical format using Markdown-style headers.

For example:

  • Director# Name

  • Associate Director## Name

  • Specialist### Name

Is this what you meant by ‘incorporating actual data’, or were you thinking of a different approach?

I didn´t know about the widget from jta87, thanks for the tip. But yeah, it´s pretty much what I meant.

Your solution to use a trigger formula is actually quite smart. Maybe I´ll use that in the future.

I’ve put together an example of how this can be applied to your specific case, assuming I understood it correctly.

However, this method has one mandatory requirement: you must specify a direct manager for every employee. Otherwise, they will appear at the top of the hierarchy.

As an example of use, it was very interesting.

I can´t immediately test it but it looks like a good solution, thank you. Your method should work for most of the use cases I can think of, a company hierarchy like this being one of them.

Also, nice seeing some Deus Ex characters here :smiley: