Building Custom Nodes in Tiptap/ProseMirror: A Deep Dive with Mermaid Diagrams
Tiptap is a headless, framework-agnostic rich text editor built on top of ProseMirror. One of its most powerful features is the ability to create custom nodes—content types that go beyond simple paragraphs and headings. In this post, we'll build a complete Mermaid diagram node from scratch, covering the schema definition, NodeView implementation, commands, and rendering logic.
By the end, you'll understand not just how to build a Mermaid node, but the underlying concepts that let you build any custom node.
Table of Contents
- Understanding the ProseMirror Node Model
- Setting Up the Node Schema
- Building the NodeView
- Rendering Mermaid Diagrams
- Adding Commands and Input Rules
- Handling Node Updates
- Full Working Example
- Common Pitfalls
1. Understanding the ProseMirror Node Model
Before writing code, it's important to understand three core concepts:
- Schema: Defines what kinds of nodes/marks exist in the document, their attributes, and how they can be nested. This is the "grammar" of your document.
- NodeView: A bridge between ProseMirror's abstract document model and actual DOM rendering. It gives you full control over how a node is rendered and how it responds to updates.
- Commands: Functions that describe transactions—ways to modify the document (insert, delete, update attributes, etc.).
Tiptap wraps these concepts in a friendlier API via Node.create(), but under the hood, it's all ProseMirror.
For our Mermaid node, we want:
- A block-level, atomic node (users edit it as a single unit, not character-by-character)
- An attribute to store the raw Mermaid syntax (e.g.,
graph TD; A-->B;) - A NodeView that renders the diagram using the
mermaidlibrary - The ability to click to edit the raw text and see it re-render
2. Setting Up the Node Schema
Let's start with the basic schema definition using Tiptap's Node.create():
import { Node, mergeAttributes } from '@tiptap/core'
export interface MermaidOptions {
HTMLAttributes: Record<string, any>
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
mermaid: {
setMermaid: (content: string) => ReturnType
}
}
}
export const Mermaid = Node.create<MermaidOptions>({
name: 'mermaid',
group: 'block',
atom: true, // treat as a single, non-editable-by-default unit
addOptions() {
return {
HTMLAttributes: {},
}
},
addAttributes() {
return {
content: {
default: 'graph TD;\n A-->B;',
parseHTML: (element) => element.getAttribute('data-content'),
renderHTML: (attributes) => {
return {
'data-content': attributes.content,
}
},
},
}
},
parseHTML() {
return [
{
tag: 'div[data-type="mermaid"]',
},
]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': 'mermaid',
}),
]
},
})
Key Points
group: 'block': This tells the schema that our node behaves like a paragraph or heading—it takes up a block-level slot in the document.atom: true: This is crucial. It tells ProseMirror to treat the entire node as a single, indivisible unit for cursor movement and selection. Users can't place a cursor inside the rendered diagram the way they would inside a paragraph.addAttributes(): We store the raw Mermaid syntax as acontentattribute, which gets serialized todata-contentin the HTML.parseHTML/renderHTML: These define the (de)serialization to/from HTML, important for copy-paste and for the initial document import.
At this point, we have a valid schema, but nothing renders as an actual diagram—we're just outputting a <div> with a data attribute. That's where NodeViews come in.
3. Building the NodeView
A NodeView gives us imperative control over the DOM for a specific node type. Tiptap exposes this through addNodeView(), which typically wraps @tiptap/vue-3's VueNodeViewRenderer, @tiptap/react's ReactNodeViewRenderer, or a plain vanilla NodeView.
Let's build this with the vanilla JS approach first, since it best exposes what's happening under the hood, and then show the React version.
Vanilla NodeView
import { Node, mergeAttributes } from '@tiptap/core'
import { NodeViewRenderer } from '@tiptap/pm/view'
import mermaid from 'mermaid'
mermaid.initialize({ startOnLoad: false })
export const Mermaid = Node.create<MermaidOptions>({
// ... schema definition from before ...
addNodeView() {
return ({ node, editor, getPos }) => {
const container = document.createElement('div')
container.classList.add('mermaid-wrapper')
const preview = document.createElement('div')
preview.classList.add('mermaid-preview')
const textarea = document.createElement('textarea')
textarea.classList.add('mermaid-source')
textarea.style.display = 'none'
textarea.value = node.attrs.content
container.append(preview, textarea)
const renderDiagram = async (source: string) => {
try {
const id = `mermaid-${Math.random().toString(36).slice(2)}`
const { svg } = await mermaid.render(id, source)
preview.innerHTML = svg
} catch (err) {
preview.innerHTML = `<pre class="mermaid-error">${String(err)}</pre>`
}
}
renderDiagram(node.attrs.content)
// Click to toggle edit mode
preview.addEventListener('dblclick', () => {
preview.style.display = 'none'
textarea.style.display = 'block'
textarea.focus()
})
textarea.addEventListener('blur', () => {
const newContent = textarea.value
if (typeof getPos === 'function') {
editor.view.dispatch(
editor.view.state.tr.setNodeMarkup(getPos(), undefined, {
...node.attrs,
content: newContent,
})
)
}
textarea.style.display = 'none'
preview.style.display = 'block'
})
return {
dom: container,
update: (updatedNode) => {
if (updatedNode.type.name !== 'mermaid') return false
if (updatedNode.attrs.content !== node.attrs.content) {
renderDiagram(updatedNode.attrs.content)
textarea.value = updatedNode.attrs.content
}
node = updatedNode
return true
},
}
}
},
})
What's happening here?
getPos: A function that returns the current position of this node in the document. We need this to dispatch transactions that update this specific node's attributes.editor.view.dispatch(...): This is how we commit changes back to the ProseMirror state.setNodeMarkupis the standard way to update a node's attributes without replacing the whole node.- The
updatemethod: This is called whenever ProseMirror decides the node might need to re-render (e.g., due to collaborative editing or undo/redo). Returningtruetells ProseMirror "I've handled the update myself, don't tear down and rebuild the DOM." Returningfalseforces ProseMirror to destroy and recreate the NodeView. - Toggling between preview and edit mode: We use a simple double-click-to-edit pattern, since directly editing SVG content isn't meaningful—we want to edit the source.
4. Rendering Mermaid Diagrams
The core rendering logic uses the mermaid npm package:
npm install mermaid
import mermaid from 'mermaid'
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose', // needed if you allow click events inside diagrams
})
async function renderMermaidToSVG(source: string): Promise<string> {
const id = `mermaid-svg-${Date.now()}`
const { svg } = await mermaid.render(id, source)
return svg
}
A few gotchas worth knowing:
mermaid.renderis async and returns a Promise, so your NodeView update logic must handle asynchronous rendering gracefully (e.g., avoid race conditions if the user types quickly).- Unique IDs matter. Mermaid uses the
idinternally to generate SVG element IDs. If you reuse IDs across multiple diagrams, you'll get rendering conflicts. - Error handling is essential. Invalid Mermaid syntax throws, and you don't want that error to crash your whole editor—catch it and display a friendly error state instead.
5. Adding Commands and Input Rules
Now let's make it easy to insert a Mermaid node. We add a command:
addCommands() {
return {
setMermaid:
(content: string) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: { content },
})
},
}
},
Usage in your editor:
editor.commands.setMermaid('graph TD;\n A-->B;\n B-->C;')
You could also add an input rule so that typing a shortcut like ```mermaidon its own line auto-inserts a diagram node:
import { textblockTypeInputRule } from '@tiptap/core'
addInputRules() {
return [
textblockTypeInputRule({
find: /^```mermaid[\s\n]$/,
type: this.type,
}),
]
},
Note: for atom nodes, you'll typically want a custom
InputRulerather thantextblockTypeInputRule, since the latter is designed for textblock-type nodes with editable content. For an atomic node, a simpler approach is to use aPluginwith a regex-based text match that replaces the trigger text withinsertContent.
6. Handling Node Updates
One subtlety with NodeViews for atomic/complex nodes is understanding when ProseMirror calls update() vs. when it destroys and recreates the view.
Key rules:
- If the node's
typechanges, ProseMirror always recreates the view. - If only
attrschange (like ourcontentattribute), ProseMirror callsupdate(node, decorations)on the existing view, giving you a chance to patch the DOM instead of a full re-render. - Returning
falsefromupdate()forces a full teardown/rebuild—useful if the update is too complex to patch incrementally, but generally more expensive.
For our Mermaid node, since re-rendering the SVG is relatively cheap, we always return true from update() and let our internal renderDiagram() function handle applying the new SVG string.
If you're using React or Vue node views, this update-diffing is handled somewhat automatically via component re-renders when props/attrs change, which simplifies things considerably.
React Version (using @tiptap/react)
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { useEffect, useRef, useState } from 'react'
import mermaid from 'mermaid'
const MermaidComponent = ({ node, updateAttributes }: any) => {
const [editing, setEditing] = useState(false)
const [svg, setSvg] = useState('')
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
mermaid
.render(`mermaid-${Math.random().toString(36).slice(2)}`, node.attrs.content)
.then(({ svg }) => {
if (!cancelled) {
setSvg(svg)
setError(null)
}
})
.catch((err) => !cancelled && setError(String(err)))
return () => {
cancelled = true
}
}, [node.attrs.content])
if (editing) {
return (
<NodeViewWrapper>
<textarea
autoFocus
defaultValue={node.attrs.content}
onBlur={(e) => {
updateAttributes({ content: e.target.value })
setEditing(false)
}}
/>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper>
{error ? (
<pre className="mermaid-error">{error}</pre>
) : (
<div onDoubleClick={() => setEditing(true)} dangerouslySetInnerHTML={{ __html: svg }} />
)}
</NodeViewWrapper>
)
}
// In your Node.create({...}):
addNodeView() {
return ReactNodeViewRenderer(MermaidComponent)
},
Notice how much simpler this is: updateAttributes is provided directly by Tiptap's React integration, abstracting away the manual getPos + dispatch dance.
7. Full Working Example (Vanilla)
Putting it all together:
import { Node, mergeAttributes } from '@tiptap/core'
import mermaid from 'mermaid'
mermaid.initialize({ startOnLoad: false })
export const Mermaid = Node.create({
name: 'mermaid',
group: 'block',
atom: true,
addAttributes() {
return {
content: {
default: 'graph TD;\nA-->B;',
parseHTML: (el) => el.getAttribute('data-content'),
renderHTML: (attrs) => ({ 'data-content': attrs.content }),
},
}
},
parseHTML() {
return [{ tag: 'div[data-type="mermaid"]' }]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'mermaid' })]
},
addCommands() {
return {
setMermaid:
(content: string) =>
({ commands }) =>
commands.insertContent({ type: this.name, attrs: { content } }),
}
},
addNodeView() {
return ({ node, editor, getPos }) => {
const dom = document.createElement('div')
dom.className = 'mermaid-node'
const preview = document.createElement('div')
const textarea = document.createElement('textarea')
textarea.style.display = 'none'
dom.append(preview, textarea)
const render = async (src: string) => {
try {
const { svg } = await mermaid.render(`m-${Date.now()}`, src)
preview.innerHTML = svg
} catch (e) {
preview.innerHTML = `<pre>${e}</pre>`
}
}
render(node.attrs.content)
textarea.value = node.attrs.content
preview.ondblclick = () => {
preview.style.display = 'none'
textarea.style.display = 'block'
textarea.focus()
}
textarea.onblur = () => {
if (typeof getPos === 'function') {
editor.view.dispatch(
editor.view.state.tr.setNodeMarkup(getPos(), undefined, {
content: textarea.value,
})
)
}
preview.style.display = 'block'
textarea.style.display = 'none'
}
return {
dom,
update: (updated) => {
if (updated.type.name !== 'mermaid') return false
render(updated.attrs.content)
textarea.value = updated.attrs.content
node = updated
return true
},
}
}
},
})
Register it with your editor:
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Mermaid } from './mermaid-extension'
const editor = new Editor({
extensions: [StarterKit, Mermaid],
content: '<p>Hello world</p>',
})
editor.commands.setMermaid('graph TD;\nA-->B;\nB-->C;')
8. Common Pitfalls
Forgetting
atom: truefor non-text content. Without it, ProseMirror expects the node to have editable text content matching itscontentschema expression, which conflicts with rendering an SVG diagram.Not handling async rendering races. If a user rapidly edits the Mermaid source, multiple
mermaid.render()calls can resolve out of order. Use a request ID or cancellation flag to ensure only the latest result is applied.Missing
getPosin newer Tiptap versions. In Tiptap 2.x,getPosis a function you must call (getPos()), not a static value—forgetting the parentheses is a very common bug.SSR/hydration issues. If you use Tiptap with server-side rendering (e.g., Next.js), calling
mermaid.renderon the server will fail since it depends on browser DOM APIs. Guard NodeView rendering logic to only run client-side.Copy-paste losing your diagram. Make sure your
parseHTML/renderHTML(orparseDOMin raw ProseMirror) correctly round-trips thecontentattribute, otherwise copy-pasting Mermaid blocks between documents will silently drop the diagram source.
Conclusion
Custom nodes are where Tiptap and ProseMirror really shine, letting you embed arbitrarily rich, interactive content—diagrams, code sandboxes, polls, embeds—directly into a structured document model. The Mermaid node we built here demonstrates the essential pattern:
- Define a schema with the attributes you need.
- Use a NodeView to bridge the abstract node to real DOM/rendering logic.
- Provide commands for programmatic insertion.
- Handle updates gracefully to keep the editor responsive.
Once you understand this pattern, you can apply it to virtually any embeddable content type—it's just a matter of swapping out the rendering library (Mermaid, KaTeX, D3, a video player, etc.) while reusing the same schema/NodeView/command scaffolding.
Happy building! 🎨
Minimum Cycle Basis: The Algorithm Behind Ring Perception in Chemistry
Introduction
If you've ever worked with molecular structures in cheminformatics, you've likely encountered the concept of rings — cyclic substructures that are fundamental to understanding molecular topology. Benzene has one ring. Naphthalene has two. But what about complex fused ring systems like steroids or fullerenes? How do we systematically identify the "basic" set of rings in a molecule?
This is where the Minimum Cycle Basis (MCB) comes in — known in the chemistry world as the Smallest Set of Smallest Rings (SSSR). In this post, we'll explore what MCB is, how it's computed, why it matters in chemistry, and some of the subtleties that make it both powerful and occasionally controversial.
What Is a Cycle Basis?
Let's start with some graph theory fundamentals.
A graph \(G = (V, E)\) consists of vertices \(V\) and edges \(E\). A cycle (or circuit) in a graph is a closed path where no vertex is repeated except the starting/ending vertex.
The cycle space of a graph is a vector space over \(GF(2)\) (the field with two elements, \(\{0, 1\}\)), where each cycle is represented as a binary vector indicating which edges are included. Two cycles can be "added" by taking the symmetric difference (XOR) of their edge sets — the result is another element of the cycle space.
A cycle basis is a minimal set of linearly independent cycles that can generate all other cycles through symmetric difference (XOR) operations. The dimension of the cycle space is:
\(\nu = |E| - |V| + c\)
where \(c\) is the number of connected components. This number \(\nu\) is called the circuit rank (or cyclomatic number). Any cycle basis contains exactly \(\nu\) cycles.
A Minimum Cycle Basis (MCB) is a cycle basis where the total weight (sum of cycle lengths, or sum of edge weights) is minimized. In unweighted graphs, this means we want the set of \(\nu\) independent cycles whose total number of edges is as small as possible.
From Graph Theory to Chemistry: The SSSR
In cheminformatics, molecules are naturally represented as graphs: atoms are vertices, and bonds are edges. Ring perception — the identification of cyclic substructures — is one of the oldest and most fundamental problems in chemical information processing.
The Smallest Set of Smallest Rings (SSSR) is the chemistry community's name for the minimum cycle basis. The term was popularized in the 1960s and has been a cornerstone concept ever since.
Why Do Chemists Care About Rings?
- Aromaticity: Determining whether a ring is aromatic (e.g., benzene, pyridine) requires first identifying the ring.
- Molecular descriptors: Ring count, ring size distribution, and ring composition are widely used descriptors in QSAR/QSPR.
- Substructure searching: Many pharmacophore patterns and functional groups involve ring systems.
- Nomenclature: IUPAC nomenclature rules for polycyclic compounds depend on ring identification.
- Force fields: Molecular mechanics force fields treat ring atoms differently (e.g., sp2 carbon in a 5-membered ring vs. a 6-membered ring).
A Simple Example
Consider naphthalene (two fused six-membered rings):
The molecular graph has 10 atoms (vertices) and 11 bonds (edges), with 1 connected component. The circuit rank is:
\(\nu = 11 - 10 + 1 = 2\)
So the SSSR contains exactly 2 rings. These are the two individual six-membered rings. Note that the 10-membered peripheral ring (the outer boundary) is not in the SSSR — it can be obtained by XOR-ing the two six-membered rings.
Algorithms for Computing the Minimum Cycle Basis
Several algorithms have been developed over the decades. Let's walk through the major approaches.
1. Horton's Algorithm (1987)
Horton's algorithm was one of the first polynomial-time algorithms for finding an MCB. It works in two phases:
Phase 1: Generate candidate cycles
For every vertex \(v\) and every edge \((u, w)\), Horton considers the cycle formed by the shortest path from \(v\) to \(u\), the edge \((u, w)\), and the shortest path from \(w\) back to \(v\). This generates \(O(|V| \cdot |E|)\) candidate cycles.
Phase 2: Extract a minimum basis
From the candidate set, select \(\nu\) linearly independent cycles with minimum total weight using Gaussian elimination over \(GF(2)\).
Time complexity: \(O(|E|^3 \cdot |V|)\) with naive implementation, though this can be improved.
Pseudocode:
function Horton_MCB(G):
candidates = []
// Phase 1: Generate candidate cycles
for each vertex v in V:
Compute shortest path tree T_v from v (using BFS for unweighted)
for each edge (u, w) in E:
if (u, w) not in T_v:
cycle = shortest_path(v, u) + edge(u, w) + shortest_path(w, v)
if cycle is a simple cycle:
candidates.append(cycle)
// Phase 2: Gaussian elimination
Sort candidates by weight (length)
basis = []
for each cycle C in candidates (ascending weight):
if C is linearly independent from cycles in basis:
basis.append(C)
if |basis| == ν:
break
return basis
2. De Pina's Algorithm (1995)
De Pina introduced a more elegant approach based on the idea of maintaining a set of "witness" vectors. The algorithm iteratively finds the shortest cycle that is orthogonal to a growing set of support vectors.
Key idea: Maintain vectors \(S_1, S_2, \ldots\) in the edge space. At step \(i\), find the shortest cycle \(C_i\) such that \(\langle C_i, S_i \rangle \neq 0\) (non-zero inner product over \(GF(2)\)). Then update the remaining support vectors to ensure orthogonality.
Time complexity: \(O(\nu \cdot |E|^2)\) or better with efficient shortest-path subroutines.
3. Kavitha et al.'s Algorithm (2009)
This improved de Pina's approach to achieve a time complexity of \(O(|E|^2 |V| / \log |V|)\) for general weighted graphs, and even faster for sparse graphs. This is currently among the fastest known algorithms for MCB.
4. Vismara's Algorithm (1997) — Relevant Cycles
While not strictly an MCB algorithm, Vismara's approach is worth mentioning because it computes the union of all minimum cycle bases — the set of relevant cycles. This is important in chemistry because the MCB is not unique (more on this below), and chemists often want all "chemically meaningful" rings.
5. Classical Chemistry Approaches
In cheminformatics, several simpler (though sometimes less rigorous) algorithms have been widely used:
- Figueras' Algorithm (1996): Based on successive removal of nodes and edges.
- Zamora's Algorithm (1976): An early approach used in chemical databases.
- Fan, Panaye, Doucet, and Barber's Algorithm (1993): Ring perception using path-included distance matrix.
Most modern cheminformatics toolkits (RDKit, OpenBabel, CDK) implement some variant of the above algorithms, often with optimizations specific to molecular graphs (which are typically sparse and have small maximum degree).
A Worked Example
Let's trace through a simple example. Consider cubane (\(C_8H_8\)), whose carbon skeleton forms a cube:
- Vertices (V): 8
- Edges (E): 12
- Circuit rank: \(\nu = 12 - 8 + 1 = 5\)
So the SSSR has 5 rings, each of length 4 (the six faces of the cube give six 4-membered rings, but only five are linearly independent).
The six faces are:
- \(\{1,2,3,4\}\) (top)
- \(\{5,6,7,8\}\) (bottom)
- \(\{1,2,6,5\}\) (front)
- \(\{4,3,7,8\}\) (back)
- \(\{1,4,8,5\}\) (left)
- \(\{2,3,7,6\}\) (right)
Any five of these six 4-membered rings form an MCB. The sixth can always be obtained as the XOR of the other five. This immediately illustrates the non-uniqueness problem.
The Non-Uniqueness Problem
This is perhaps the most important caveat about the SSSR/MCB, and it has caused considerable debate in the cheminformatics community.
The Problem
The minimum cycle basis is not unique. For the cubane example above, there are six different valid SSSR, each containing five of the six faces. Which five should we choose? The choice is arbitrary, and different algorithms may return different results.
This non-uniqueness can lead to problems:
- Missing chemically intuitive rings: A valid SSSR might omit a ring that a chemist would consider "obvious."
- Non-reproducibility: Different software packages might give different SSSR for the same molecule.
- Counterintuitive results: In some pathological cases, the SSSR can omit rings that are "more important" than the ones it includes.
A Notorious Example: Bridged Bicyclics
Consider bicyclo[2.2.1]heptane (norbornane):
The molecule has three rings (two 5-membered and one 6-membered), but \(\nu = 2\). So the SSSR only contains two rings, and which two you get depends on the algorithm. A chemist might want all three.
Solutions
Several approaches have been proposed to deal with non-uniqueness:
- Relevant Cycles (Vismara): Compute the union of all possible MCBs. This gives all rings that could appear in some minimum cycle basis.
- Essential Cycles: Cycles that appear in every MCB. These are unambiguously part of the SSSR.
- ESSR (Extended SSSR): Supplements the SSSR with additional rings to capture all "chemically meaningful" cycles.
- All Rings: Simply enumerate all cycles (though this can be exponential).
Most modern cheminformatics applications use a combination: compute the SSSR as a basis, then augment it with relevant or essential cycles as needed.
Implementation in Modern Cheminformatics Toolkits
RDKit (Python)
from rdkit import Chem
from rdkit.Chem import rdmolops
mol = Chem.MolFromSmiles('c1ccc2ccccc2c1') # naphthalene
ring_info = mol.GetRingInfo()
# Get SSSR
sssr = Chem.GetSymmSSSR(mol)
print(f"Number of rings in SSSR: {len(sssr)}")
for ring in sssr:
print(list(ring))
Output:
Number of rings in SSSR: 2
[0, 1, 2, 3, 4, 9]
[4, 5, 6, 7, 8, 9]
OpenBabel (C++)
OpenBabel uses a modified version of Figueras' algorithm for ring perception:
#include <openbabel/mol.h>
#include <openbabel/obconversion.h>
OpenBabel::OBMol mol;
// ... read molecule ...
std::vector<OpenBabel::OBRing*>& sssr = mol.GetSSSR();
for (auto ring : sssr) {
std::cout << "Ring size: " << ring->Size() << std::endl;
}
CDK (Java)
import org.openscience.cdk.ringsearch.SSSRFinder;
import org.openscience.cdk.interfaces.IRingSet;
SSSRFinder sssrFinder = new SSSRFinder(molecule);
IRingSet sssr = sssrFinder.findSSSR();
System.out.println("Number of SSSR rings: " + sssr.getAtomContainerCount());
Beyond SSSR: Other Ring Sets in Chemistry
| Ring Set | Description | Size |
|---|---|---|
| SSSR / MCB | Minimum cycle basis; \(\nu\) linearly independent smallest cycles | Exactly \(\nu\) |
| Essential Rings | Rings in every MCB | \(\leq \nu\) |
| Relevant Rings | Rings in at least one MCB | \(\geq \nu\) |
| ESSR | SSSR + envelope rings | \(\geq \nu\) |
| All Rings | Every possible cycle | Can be exponential |
| Smallest Rings | For each edge, the smallest ring containing it | Variable |
For most practical applications in drug discovery and materials science, the relevant rings or a carefully augmented SSSR provides the best balance between completeness and computational tractability.
Complexity and Performance
For molecular graphs specifically, the situation is much better than for general graphs:
- Molecular graphs are sparse (maximum degree \(\le 4\) for organic molecules, rarely \(> 6\)).
- The circuit rank \(\nu\) is typically small (proportional to the number of atoms).
- Ring sizes are bounded in practice (3-membered to ~30-membered for macrocycles).
This means that even naive SSSR algorithms run efficiently on molecules. For a typical drug-like molecule (20–50 heavy atoms), SSSR computation takes microseconds. Even for large natural products or polymers, it rarely becomes a bottleneck.
However, for graph databases containing millions of molecules, the constant factors matter. Efficient implementations using Horton's algorithm with BFS-based shortest paths (since molecular graphs are unweighted) are preferred.
Mathematical Details: Linear Algebra over GF(2)
For those who want to understand the linear algebra underpinning, here's a deeper look.
Edge Space Representation
Each cycle \(C\) is represented as a vector in \(\{0,1\}^{|E|}\):
\(C = (c_1, c_2, \ldots, c_{|E|}), \quad c_i = \begin{cases} 1 & \text{if edge } e_i \in C \\ 0 & \text{otherwise} \end{cases}\)
XOR Operation
The sum of two cycles over \(GF(2)\) corresponds to the symmetric difference:
\(C_1 \oplus C_2 = C_1 \triangle C_2 = (C_1 \cup C_2) \setminus (C_1 \cap C_2)\)
The result is always a union of edge-disjoint cycles (or the empty set).
Independence Check
During Gaussian elimination, we maintain a matrix where each row is a cycle vector. A new cycle \(C\) is linearly independent from the existing set if it cannot be expressed as an XOR combination of the current basis vectors.
In practice, this is implemented as:
def is_independent(cycle_vector, basis_matrix):
"""Check if cycle_vector is linearly independent from rows of basis_matrix over GF(2)."""
v = cycle_vector.copy()
for row in basis_matrix:
# Find the leading 1 in this basis row
lead = leading_one(row)
if v[lead] == 1:
v = v ^ row # XOR
return any(v) # Independent if v is non-zero
Greedy Selection
The MCB can be found by a greedy algorithm: sort all candidate cycles by weight, then greedily select cycles that are linearly independent from those already chosen. This greedy approach works because the cycle matroid satisfies the matroid property — but note that the set of all cycles does not form a matroid. Horton's insight was identifying a polynomial-size candidate set that is guaranteed to contain an MCB.
Common Pitfalls and FAQs
Q: Is the SSSR always what a chemist expects?
No. The classic counterexample is the envelope of fused rings. In biphenylene (two benzene rings fused with a cyclobutadiene), the SSSR contains two 6-membered rings and one 4-membered ring (\(\nu = 3\)). But a chemist might also consider the 8-membered ring formed by the two six-membered rings sharing the four-membered bridge. This ring is not in the SSSR.
Q: Should I use SSSR or "all rings"?
It depends on your application. For most descriptor calculations and substructure searching, the SSSR is sufficient. For comprehensive ring analysis (e.g., in natural product chemistry), you might want relevant cycles or all small rings up to a size limit.
Q: What about macrocycles?
Macrocycles (rings with \(> 12\) atoms) are correctly identified by SSSR algorithms, but they can be computationally expensive if you're searching for all rings. Most implementations handle them fine for individual molecules.
Q: How does SSSR handle disconnected molecules?
The formula \(\nu = |E| - |V| + c\) accounts for multiple connected components. Each component contributes independently to the SSSR.
Conclusion
The Minimum Cycle Basis — or SSSR as chemists call it — sits at a beautiful intersection of graph theory and chemistry. While the underlying mathematics is elegant (linear algebra over \(GF(2)\), matroid theory, shortest-path algorithms), the practical application to chemical ring perception has driven decades of algorithmic development.
The key takeaways:
- MCB = SSSR: They're the same concept viewed from different disciplines.
- The circuit rank \(\nu = |E| - |V| + c\) tells you exactly how many rings are in the basis.
- Non-uniqueness is the main challenge — be aware that different algorithms may give different (but equally valid) results.
- For chemistry applications, consider using relevant cycles or augmented SSSR when completeness matters.
- Modern toolkits (RDKit, OpenBabel, CDK) handle SSSR computation efficiently for typical molecules.
Understanding ring perception is fundamental to almost every area of cheminformatics. Whether you're computing molecular descriptors, searching chemical databases, or designing retrosynthetic routes, the SSSR is working behind the scenes to make sense of molecular topology.
References
- Horton, J. D. (1987). "A polynomial-time algorithm to find the shortest cycle basis of a graph." SIAM Journal on Computing, 16(2), 358–366.
- De Pina, J. C. (1995). "Applications of shortest path methods." PhD thesis, University of Amsterdam.
- Kavitha, T., et al. (2009). "An \(\tilde{O}(m^2n)\) algorithm for minimum cycle basis of graphs." Algorithmica, 52(3), 333–349.
- Vismara, P. (1997). "Union of all the minimum cycle bases of a graph." Electronic Journal of Combinatorics, 4(1), R9.
- Downs, G. M., et al. (1989). "Review of ring perception algorithms for chemical graphs." Journal of Chemical Information and Computer Sciences, 29(3), 172–187.
- Berger, F., Gritzmann, P., & de Vries, S. (2004). "Minimum cycle bases for network graphs." Algorithmica, 40(1), 51–62.
- Plotkin, M. (1971). "Mathematical basis of ring-finding algorithms in CIDS." Journal of Chemical Documentation, 11(2), 94–98.
- Figueras, J. (1996). "Ring perception using breadth-first search." Journal of Chemical Information and Computer Sciences, 36(5), 986–991.