Porphyry.js
A lightweight, zero-dependency JavaScript library for rendering interactive mind maps as SVG. Named after Porphyry of Tyre, the ancient philosopher who introduced the hierarchical tree of categories.
Quick Start
Via CDN (recommended)
The fastest way to get started — no download or build step needed:
<!-- Latest release via jsDelivr --> <script src="https://cdn.jsdelivr.net/gh/antoniu86/porphyry.js@latest/porphyry.min.js"></script> <!-- Pin to a specific version --> <script src="https://cdn.jsdelivr.net/gh/antoniu86/porphyry.js@v1.6.2/porphyry.min.js"></script>
Self-hosted
Download porphyry.min.js (or porphyry.js for the commented source) and include it directly:
<script src="porphyry.min.js"></script>
Example
<!-- Include from CDN --> <script src="https://cdn.jsdelivr.net/gh/antoniu86/porphyry.js@v1.6.2/porphyry.min.js"></script> <!-- Give it a container --> <div id="map" style="width:100%;height:500px"></div> <script> const map = new Porphyry('#map'); map.render({ topic: 'My Topic', children: [ { topic: 'Branch A' }, { topic: 'Branch B', children: [ { topic: 'Sub-node' } ]} ] }); </script>
width and height — Porphyry fills 100% of it. Set these via CSS or inline styles.Data Format
Porphyry accepts a plain JSON object. Every node has a topic and an optional children array. Nesting can go as deep as needed.
{
topic: "Root Subject", // required — label text
url: "https://example.com", // optional — makes node clickable (opens URL)
onclick: function(node) { }, // optional — custom click handler (url takes priority)
direction: "left", // optional — "left"|"right", auto otherwise
children: [
{
topic: "First branch",
children: [
{ topic: "Leaf node" },
{ topic: "Linked leaf", url: "https://..." }
]
},
{ topic: "Second branch" }
]
}
Node fields
| Field | Type | Description |
|---|---|---|
| topic | string | Node label. Long text wraps automatically within the configured max width. |
| url | string? | If present, the node becomes clickable and opens this URL in a new tab. A small ↗ icon appears inside the node. Takes priority over onclick. |
| onclick | function? | A JS function called when the node is clicked. Receives the node object as its argument. Ignored if url is also set. A ▶ icon appears inside the node. |
| direction | "left"|"right"? | Pin a root-level child to a specific side. Only respected on direct children of the root in horizontal layouts. Ignored in vertical layouts. |
| children | Node[]? | Child nodes. Omit or leave empty for leaf nodes. |
Constructor
new Porphyry(selector, options)
| Parameter | Type | Description |
|---|---|---|
| selector | string | Element | A CSS selector string or a DOM element to render into. |
| options | object? | Optional configuration. See Options reference below. |
Options Reference
Layout
| Option | Default | Description |
|---|---|---|
| layout | "auto" | Direction mode. One of "auto", "left", "right", "down", "up". See Layout Modes below. |
| centerEdge | "side" | Where edges connect on the center node in horizontal layouts. "side" exits the left/right walls. "vertical" fans edges out from the top or bottom center — top vs bottom is chosen automatically per branch based on vertical position. Nodes whose vertical overlap with the root falls within the fanAlignThreshold band automatically draw from the side instead, keeping the fan shape clean. With "vertical" the center node's width no longer affects branch placement, so it can grow wide freely. Has no effect on "up"/"down" layouts. |
| fanAlignThreshold | 10 | Extra pixel buffer (px) used when centerEdge: "vertical" to decide whether a first-level node is close enough to the root's horizontal center to draw its edge from the side instead of the top/bottom fan. The detection zone is (root.height / 2) + (node.height / 2) + fanAlignThreshold. Increase to widen the side-exit band; set to 0 for exact edge-to-edge overlap only. |
| theme | "classic" | Visual theme controlling node shapes, fills, borders and edge anchors. See Themes below. |
| fitPadding | 20 | Pixels of padding around the graph when auto-fitting to the container. |
| lineHeight | 1.45 | Line height multiplier for wrapped text. |
| spacing | 1 | Spacing multiplier applied to all node distances before depth-adaptive scaling. Any value from 0.1 (extremely compact) to 2.0 (very spread out). 1 is the default. |
Spacing (horizontal layouts)
| Option | Default | Description |
|---|---|---|
| branchSpacingX | 220 | Horizontal gap (px) between the center node and depth-1 branches. Scales down automatically for deep trees. |
| subSpacingX | 170 | Horizontal gap (px) between sub-levels (depth ≥ 2). Also auto-scales. |
| verticalSpacing | 50 | Minimum vertical gap (px) between sibling nodes. |
Spacing (vertical layouts)
| Option | Default | Description |
|---|---|---|
| verticalSpacingY | 60 | Vertical gap (px) between depth levels in up/down layouts. |
| horizontalSpacing | 30 | Horizontal gap (px) between sibling subtrees in vertical layouts. |
Center node
| Option | Default | Description |
|---|---|---|
| center.fontSize | 17 | |
| center.fontWeight | "700" | |
| center.paddingX / paddingY | 28 / 16 | |
| center.maxWidth | 240 | Max node width (px) before text wraps to next line. |
| center.radius | 12 | Corner radius. 99 = pill (auto height/2 when single-line). |
| center.bgColor | "#1A1F2E" | Node background color. |
| center.border | false | Border style. false = none. true or 'around' = full rect border. 'bottom'/'top'/'left'/'right' = single edge. Space-separated for multiple: 'top bottom'. Overrides the theme default when set. |
| center.borderColor | null | Border color. null = use bgColor. |
| center.fontColor | "#FFFFFF" | Text color. |
| center.shadowColor | "rgba(0,0,0,0.35)" | Drop shadow color. |
Branch nodes (depth 1)
| Option | Default | Description |
|---|---|---|
| branch.fontSize | 14 | |
| branch.fontWeight | "600" | |
| branch.paddingX / paddingY | 18 / 10 | |
| branch.maxWidth | 200 | Max node width (px) before text wraps. |
| branch.radius | 99 | Corner radius. 99 = pill (auto height/2 when single-line). |
| branch.bgColor | null | Node background color. null = use color palette. |
| branch.border | false | Border style. false = none. true or 'around' = full rect border. 'bottom'/'top'/'left'/'right' = single edge. Space-separated for multiple: 'top bottom'. Overrides the theme default when set. |
| branch.borderColor | null | Border color. null = use palette color. |
| branch.fontColor | "#FFFFFF" | Text color. |
| branch.shadowColor | null | Drop shadow color. null = no shadow. |
Leaf nodes (depth ≥ 2)
| Option | Default | Description |
|---|---|---|
| leaf.fontSize | 13 | |
| leaf.fontWeight | "500" | |
| leaf.paddingX / paddingY | 14 / 7 | |
| leaf.maxWidth | 170 | Max node width (px) before text wraps. |
| leaf.radius | 3 | Corner radius. |
| leaf.bgColor | null | Node background color. null = use palette color (tinted at low opacity in classic/underline). Set to 'transparent' or 'none' to remove the background entirely while keeping any border. |
| leaf.border | "bottom" | Border style. false = none. true or 'around' = full rect border. 'bottom'/'top'/'left'/'right' = single edge. Space-separated for multiple: 'top bottom'. Overrides the theme default when set. |
| leaf.borderColor | null | Border color. null = use palette color. |
| leaf.fontColor | "#2D3748" | Text color. |
Colors & edges
| Option | Default | Description |
|---|---|---|
| colors | 10-color palette | Array of hex color strings. Each root branch is assigned one in order; descendants inherit it. |
| edgeWidth.root | 2.5 | Stroke width of edges leaving the center node. |
| edgeWidth.branch | 2 | Stroke width of depth-1 → depth-2 edges. |
| edgeWidth.leaf | 1.5 | Stroke width of deeper edges. |
| edgeOpacity | 0.85 | Global opacity of all edges. |
Interactions
All interactions are off by default for clean embedding. Opt in explicitly to what you need.
| Option | Default | Description |
|---|---|---|
| interactions.pan | false | Enable drag-to-pan (mouse + touch). |
| interactions.zoom | false | Enable scroll-wheel zoom and pinch-to-zoom. |
| interactions.collapse | false | Show +/− toggle buttons on nodes to expand/collapse subtrees. |
| interactions.hud | false | Inject a zoom HUD (−, level %, +, fit) into the bottom-right of the container. |
| interactions.tips | false | Inject a hint bar at the bottom-center describing active interactions. |
| interactions.download | false | Add a download-as-SVG button. When hud: true it appears inside the HUD; otherwise a standalone button is injected in the bottom-right corner. |
| minZoom | 0.08 | Minimum zoom scale (when zoom is enabled). |
| maxZoom | 4 | Maximum zoom scale (when zoom is enabled). |
| zoomSensitivity | 0.12 | Scroll-wheel zoom speed per tick. |
| showLinkIcons | true | Whether to show the ↗ icon on url nodes and the ▶ icon on onclick nodes. Set to false to hide the icons while keeping click behaviour active. |
| fontColor | null | Global text color override. When set, overrides fontColor on all node types. Accepts any CSS color string. null = use per-node-type defaults. |
| animationDuration | 350 | Duration (ms) of the render fade-in animation. Set to 0 to disable. |
Themes
The theme option controls node shapes, fills, borders and edge connection points across the whole map. All 9 themes work in every layout; 4 extra themes are exclusive to horizontal layouts (auto / left / right).
// Set at init time new Porphyry('#map', { theme: 'solid' }); // Change at runtime map.options.theme = 'baseline'; map._renderInternal(true);
Universal themes
| Theme | Fill | Border | Edge anchor |
|---|---|---|---|
| outline | white | colored full border, rounded | center (default for vertical) |
| solid | solid color, rounded | none | center |
| solid-sharp | solid color, sharp corners | none | center |
| outline-sharp | white, sharp corners | colored full border | center |
| minimal | none | none | center |
Horizontal-only themes
| Theme | Fill | Border | Edge anchor |
|---|---|---|---|
| classic | dark center, colored pills (depth 1), 10% tint (depth 2+) | bottom line on leaves | bottom for leaves, center otherwise (default for horizontal) |
| ghost | 10% tinted on all nodes | none | center — all nodes |
| underline | 10% tinted on all nodes | bottom solid line | bottom — all nodes |
| baseline | none | bottom solid line | bottom — all nodes |
When classic is selected with a vertical layout it automatically falls back to outline. When switching to a vertical layout in the demo, the classic, ghost, underline and baseline options are hidden from the dropdown.
Layout Modes
Set via options.layout at init time, or by mutating instance.options.layout before calling render() again.
| Value | Description |
|---|---|
| "auto" | Branches are distributed evenly left and right of the center node. Explicit direction fields in node data are honoured first; the remainder are balanced. |
| "left" | All branches grow to the left. Node direction fields are ignored. |
| "right" | All branches grow to the right. Node direction fields are ignored. |
| "down" | Tree grows downward. Siblings spread horizontally. All nodes use the outlined button style. |
| "up" | Tree grows upward. Siblings spread horizontally. All nodes use the outlined button style. |
"down" and "up" modes all nodes — including the center and branches — use an outlined button style (white fill, colored border) instead of the solid-fill styles used in horizontal layouts.Collapsible Branches
When interactions.collapse is enabled, a small circular toggle button appears at the child-facing edge of every non-root node that has children.
- Shows − when the subtree is expanded, + when collapsed.
- Button color matches the node's branch color; hover inverts fill and icon.
- Collapsed nodes are treated as leaves by the layout engine — the rest of the tree reflows automatically around them.
- Button position adapts to layout direction: left/right edge in horizontal layouts, top/bottom edge in vertical layouts.
- Collapse state is preserved across re-renders triggered by layout changes or data edits.
- Calling
render()clears all collapse state and restores the full tree.
const map = new Porphyry('#map', { interactions: { collapse: true } }); map.render(data); // Programmatically collapse a branch by its internal ID // (IDs assigned depth-first, root = 0) map._collapsed.add(3); map._renderInternal(false); // re-render, no re-fit
Methods
| Method | Description |
|---|---|
| render(data) | Parse data, lay out and draw the full tree. Clears all collapse state. Automatically calls fit() after the first paint. |
| fit() | Scale and translate the graph so it fits neatly inside the container, respecting fitPadding. |
| reset() | Reset pan and zoom to 1:1, centered. |
| destroy() | Disconnects the internal ResizeObserver. Call when removing the container from the DOM to avoid memory leaks. |
| downloadSVG(filename?) | Download the current mind map as a standalone SVG file. filename defaults to 'mindmap.svg'. The export strips the pan/zoom transform and recalculates a clean viewBox from content bounds. |
| _renderInternal(autoFit) | Re-layout and redraw while preserving collapse state. Pass false to skip re-fitting (e.g. after a collapse toggle or layout switch). |
| _rebindInteractions() | Call after mutating options.interactions at runtime. Strips old event listeners, re-attaches new ones, and updates cursor and tips text. |
const map = new Porphyry('#map', { layout: 'auto', interactions: { pan: true, zoom: true, collapse: true, hud: true, tips: true, download: true }, colors: ['#E05C5C', '#4A90D9', '#4CAF82'], branchSpacingX: 200, }); map.render(myData); // Switch to vertical layout and re-render map.options.layout = 'down'; map.render(myData); // Toggle pan off at runtime map.options.interactions.pan = false; map._rebindInteractions();
SVG Download
The downloadSVG() method exports the current mind map as a clean, self-contained SVG file that opens in browsers, Illustrator, Inkscape, and any SVG editor.
There are three ways to expose it depending on your use case:
// Option 1 — download icon appears inside the HUD alongside zoom controls new Porphyry('#map', { interactions: { hud: true, download: true } }); // Option 2 — standalone button injected in the bottom-right corner new Porphyry('#map', { interactions: { download: true } }); // Option 3 — no button, wire your own trigger anywhere on the page document.getElementById('my-btn').addEventListener('click', () => { map.downloadSVG('my-diagram.svg'); // filename is optional });
system-ui, sans-serif) are referenced by name in the export — text renders correctly on any machine that has them, which is effectively everywhere. The pan/zoom transform is stripped so the file always opens at the correct size regardless of the viewer state.Node Links & Actions
Any node can be made interactive with either a url or an onclick handler. If both are set, url takes priority.
url — external link
- A small ↗ icon appears inside the node on the right side.
- The cursor changes to a pointer on hover.
- Clicking opens the URL in a new tab (
noopener noreferrer). - A drag that moves more than 5 px never triggers the link, so panning over linked nodes is safe.
{ topic: "React", url: "https://react.dev" }
onclick — custom handler
- A small ▶ icon appears inside the node on the right side.
- The cursor changes to a pointer on hover.
- Clicking calls your function, receiving the node object as its argument.
- Drag-to-pan is still safe — the handler only fires on non-drag clicks.
{ topic: "Run Report", onclick: function(node) { alert(node.topic); } }
To hide the icons while keeping click behaviour, set showLinkIcons: false.
Text Wrapping
Long node labels wrap automatically. Each depth level has a configurable maxWidth. Text is measured with a hidden Canvas element before layout so node dimensions are always exact — the layout engine never needs to re-run after drawing.
- Words are greedily packed onto lines.
- A single word wider than
maxWidthgets its own line rather than being clipped. - Multi-line branch nodes (depth 1) switch from a pill shape to a rounded rectangle (
rx=10) automatically. - All vertical spacing accounts for the actual wrapped height, so nodes never overlap.
Adaptive Column Spacing
In horizontal layouts, column gaps scale down automatically as the tree gets deeper, keeping wide maps readable without manual tuning.
| Max depth | Factor | branchSpacingX | subSpacingX |
|---|---|---|---|
| ≤ 2 | 1.00 | 220 px | 170 px |
| 3 | 0.83 | 183 px | 141 px |
| 4 | 0.63 | 138 px | 107 px |
| 5 | 0.50 | 110 px | 85 px |
| ≥ 6 | 0.45 | 99 px | 77 px |
The floor is 45 % of the configured defaults. Vertical layouts are unaffected.
Automatic Width Scaling
When a container has significantly more horizontal space than vertical (horizontal-to-vertical ratio > 1.5×), the library automatically scales up node maxWidth values before layout — by up to 2.5× — so text wraps less and the map fills the available width. This happens transparently on every render; no configuration is needed.